Adding a document type
A document type is the other half of this codebase's "no business code names a thing" thesis — see Extending Invoicerr if you haven't read it yet. Where a country is a folder of JSON files (see Adding a country), a document type is a single TypeScript file — a descriptor — that describes one kind of paper a business creates: a quote, an invoice, a credit note, an expense, a received invoice, a purchase order, a goods receipt. This page has the same two-part shape as every other guide here: Part 1 for a first-time reader, Part 2 for the person about to actually write one.
Part 1 — In plain words
A document type is a description, not a program. Open
backend/src/modules/documents/descriptors/expense.descriptor.ts and you'll find a single object:
"an expense has these fields (a description, an amount, a currency, a date, some notes), these
buttons ('save', 'delete'), and it only ever has one status ('draft')." Nothing in that file draws
a form, builds a list screen, or talks to a database — the rest of the app (the edit form, the list
of expenses, the dashboard widget) is generic code that reads this description and builds itself
from it. Adding an eighth document type — say, a "delivery note" — means writing one more file like
this one, not designing a new screen from scratch.
Every document, whatever its type, lives in the exact same place. There is no separate
database table for quotes, another for invoices, another for expenses. There is one single table
("DocumentInstance"), and every row in it just carries a small tag saying which type it is (an
invoice's row says typeId: "invoice", an expense's says typeId: "expense") plus a bag of its
own field values. Adding a new document type never means "build a new filing cabinet" — it's one
more label in the cabinet that's already there, and — critically — it never needs a database
migration.
Buttons ("actions") on a document can be in one of four honest states, exactly the same four the country guide describes for a country's own rules — because they are, in fact, the exact same mechanism: a country can forbid an action outright, the document's own current state can make an action unavailable right now, the action can be declared but not actually built yet (an honest "not implemented", never a fake success), or what you typed can simply not fit what's expected. A new document type gets all four of these checks for free, automatically, the moment it's registered — nobody writing a new type has to reimplement any of that.
Part 2 — The details
The descriptor — the whole contract, as data
descriptors/types.ts's DocumentTypeDescriptor interface is the complete shape (read the file
itself — every field carries a paragraph explaining exactly why it exists; what follows is the
summary):
interface DocumentTypeDescriptor {
id: string; // e.g. "expense" — the registry key and the API/URL segment
label: string; // plain text, not an i18n key (see below)
fields: DocumentFieldDescriptor[]; // the document's own data
actions: DocumentActionDescriptor[]; // the buttons this type offers
statuses?: DocumentStatusDescriptor[];// the whole lifecycle — omit to opt out entirely
initialStatus?: string; // which status a brand-new record starts at
numbering?: { onEnterStatus: string };// which status hands out a sequential display number
email?: { subject: string; body: string }; // this type's default email template
contributions?: WidgetLocation[]; // 'dashboard' | 'statistics' — opts into aggregation screens
listItem?: { titleFields?: string[]; secondaryFields?: string[] }; // how a generic list card reads it
usesLegalMentions?: boolean; // opts into the country-mandated-mentions block on the PDF
}
A field (DocumentFieldDescriptor) is { key, kind, label, required?, ... }, where kind is
one of the ten CORE_FIELD_KINDS (text, longText, number, money, date, boolean,
select, reference, array, rowSelection) or a plugin-registered one (always prefixed, e.g.
"plugin:acme.rating", so a future core kind can never collide with a third party's). The same
kind selects both how the backend validates the value (FieldKindRegistry) and how the frontend
renders it — a document type never hand-rolls either. Everything past key/kind/label is an
optional, kind-specific hint: currency/currencyField for money, options/allowCustomValue
for select, entity/entities for reference, fields/min/max for array, and so on —
see types.ts itself for the full, richly-commented list; a type only sets the hints its own
fields' kinds actually use.
An action (DocumentActionDescriptor) is { id, label, availableWhen, params?, transitions? }.
availableWhen is either 'always' or an array of statuses the action is offered from.
transitions — when the action actually changes the acted-upon record's own status — is the
single source of truth for that effect: availableWhen is then derived from it
(transitionsAvailableWhen(transitions), lifecycle.ts) rather than hand-typed a second time, and
validateLifecycle (run the moment a descriptor is registered, see below) re-derives it
independently to catch a drift. An action that changes a different record entirely (e.g.
"convert-to-invoice" writes a fresh invoice; "duplicate" writes a fresh copy) or has no
implementation yet to observe declares no transitions at all — availableWhen then stays the
sole, hand-declared fact about when it may run, exactly as it always did before transitions
existed.
Labels (label, action label, status label) are plain text, not i18n keys — a descriptor
is data a third-party plugin can ship in any language it likes, so it is rendered verbatim by the
frontend rather than looked up through t(), the one deliberate exception to this codebase's
usual t()-everywhere rule for user-facing strings.
One table for every type — DocumentInstance
model DocumentInstance {
id String @id @default(cuid())
companyId String
typeId String // "invoice", "expense", a plugin's own id — never a Prisma enum
status String @default("draft")
data Json // every field value, keyed by DocumentFieldDescriptor.key
number Int?
displayNumber String?
lastActionError String?
transportRef String?
// ...
}
Every document type — core or third-party — is a row in this ONE table, distinguished only by
typeId. This is why adding a type needs no Prisma migration: there is no per-type schema to
extend, only a new descriptor and, usually, a new set of data keys nobody else uses. number/
displayNumber are set exactly once, the first time a record's status matches the type's own
declared numbering.onEnterStatus — a type that never declares numbering (e.g. "expense") never
sets them at all, on any record.
Registering the type — one line
// documents-core.module.ts
function buildDocumentTypeRegistry(): DocumentTypeRegistry {
const registry = new DocumentTypeRegistry();
registry.register(buildQuoteDescriptor());
registry.register(buildInvoiceDescriptor());
registry.register(buildCreditNoteDescriptor());
registry.register(buildExpenseDescriptor());
registry.register(buildReceivedInvoiceDescriptor());
registry.register(buildPurchaseOrderDescriptor());
registry.register(buildGoodsReceiptDescriptor()); // ← adding a type is exactly this one line
return registry;
}
DocumentTypeRegistry.register() (descriptors/type-registry.ts) does two things, synchronously,
the instant it runs — at real app boot, or the moment a vitest spec calls it directly:
- Refuses a duplicate
id(Error, not an HTTP exception — this registry has to stay usable outside an HTTP request). - Calls
validateLifecycle(descriptor)(lifecycle.ts) — a brokenstatuses/initialStatus/transitionsdeclaration fails registration immediately, which for the real app means the backend never finishes booting, and for a test means the.register()call itself throws. There is no "the type loaded, but its lifecycle is silently broken" state to reach production.
Wiring the buttons — the ActionRegistry
A descriptor only declares an action exists; something still has to say what running it actually
does. That's a second, independent registry (actions/action-registry.ts), wired in the same
module:
function buildActionRegistry(/* deps */): ActionRegistry {
const registry = new ActionRegistry();
registerExpenseActions(registry, webhookDispatcher); // registerSaveDraftAction + registerDeleteAction
// ...
return registry;
}
For the common cases (save-draft, delete), actions/generic-actions.ts already has a
type-agnostic implementation — registerSaveDraftAction(registry, typeId, webhooks?) and
registerDeleteAction(registry, typeId, webhooks?) cover any type whose handler doesn't need to
read a single field of data. A type whose behavior genuinely depends on country/channel/settlement
logic (the invoice's own "send") needs its own <type>-actions.ts with a bespoke ActionHandler —
invoice-actions.ts is the template to read for that shape. An action declared on the descriptor
with no handler registered here is not a bug: DocumentsService.runAction turns that into the
501 gate below, on purpose — the deliberate, honest state for e.g. "convert-to-invoice" until an
invoicing pipeline exists to back it.
The four gates — inherited automatically
Every action of every document type — core or third-party — runs through the exact same
DocumentsService.runAction (documents.service.ts), in the exact same order, described in full
in Adding a country:
country policy (403) → status (409) → implementation (501) → validation (400). A document type
author never reimplements any of this — it is generic over typeId. The only thing a new type's
own country-policy/data/*.json files need to add is a documentTypes entry naming the new type
and rules for each of its actions (see that guide); until a country's file says so, EVERY action
of a brand-new type is refused for it, loudly, by the same "no permissive fallback" rule that
already governs every existing type.
Composing with countries, without naming one
A document type descriptor never mentions a country. Three separate, optional mechanisms let a country still shape a type without either one naming the other:
country-fields/data/<cc>.jsoncanadd/modify/removea FIELD on an existing type's shape for one country (country-fields/data/fr.json'sinvoice.lines[].supplyTypeis the worked example) — the type's own descriptor stays the trunk shape every country starts from.usesLegalMentions: trueopts a type into the country-mandated-mentions block on its rendered PDF (mentions/) — onlyinvoice.descriptor.tssets it today, since "expense" has noissueDatefield for a mention to hang off and a third-party type may have no reason to.country-policy/data/<cc>.jsondecides, per country, which of the type's own declared actions are even offered — the type itself has no opinion on this; see Adding a country.
A worked example — reading expense.descriptor.ts end to end
descriptors/expense.descriptor.ts is the shortest real type in this codebase and a good template
for a type with no transmission and no per-country nuance:
- One status (
"draft") —initialStatus: "draft", and"save-draft"'s owntransitionsare[{ from: 'always', to: 'draft' }](an expense never leaves "draft"). - Two actions:
"save-draft"(viaregisterSaveDraftAction, generic) and"delete"(viaregisterDeleteAction, generic) — restricted toavailableWhen: ['draft']since a never-saved record has nothing to delete yet. - No
numbering— nothing about an internal bookkeeping entry needs a sequential display number the way an issued invoice does. contributions: ['dashboard', 'statistics']— seecontributions/expense-contributions.tsfor the actual widget code (registered separately, exactly like an action's handler).listItem: { titleFields: ['description'], secondaryFields: ['amount', 'date'] }— an expense has no client or source document to lead a card with, so its own required, human-writtendescriptionis the title instead.
Testing
- Registering a broken lifecycle already fails loudly —
type-registry.spec.ts/lifecycle.spec.tscover the mechanism itself; your own new type doesn't need a separate "does it load" test, sinceDocumentTypeRegistry.register()already proves it the moment any spec (or the real app) registers it. - Give the type its own
<type>.descriptor.spec.tspinning the exact fields/actions/statuses it declares — a future edit that silently drops a field or changes anavailableWhenshould fail a named test, the same disciplineadding-a-country.md's own "pin the content" advice holds for a country's JSON. - If the type has bespoke action handlers, test them the way
invoice-actions.spec.tstestsinvoice-actions.ts— including, if any handler is country/channel-aware, a case per relevant country. - If the type is user-facing, extend the relevant Cypress spec so the whole path (form → save → action) is proven through the actual UI, not just the descriptor in isolation.