Documents capture their data
A document — a sales order, an invoice — records an agreement at a point in time. Correct a customer’s address tomorrow and the invoices you sent yesterday must still say what they said.
Nothing in OnERP enforces that. There is no captured: true flag, and neither the
meta layer nor the engine detects a document reading master data live. Capture is a
pattern you write — one determination per field,
copying a fact onto the document as the document is built.
Referenced for identity, captured for outcome
Section titled “Referenced for identity, captured for outcome”A document references master records for identity — which product, which customer — and a reference always resolves against the record as it stands today. Everything that decides the document’s commercial and legal outcome is captured instead: copied onto the document as its own field, where it stops following the source.
Identity is referenced. Outcome-determining facts are captured.
The product name is the trap worth naming, because it is both. Picking a product on a
line copies product.name into the line’s own description (lineProductDefaults),
and the printed document renders line.description. Rename the product afterwards and
the reference shows the new name while the description — and the PDF — keep the old
one. That is the design: what was sold is what the confirmation said.
What a sales document captures
Section titled “What a sales document captures”A sales document copies the facts that decided it onto itself: the sold-to address off the customer, a line’s text and unit off the product, the unit price off the price list, the VAT treatment off company, customer and destination, and the invoice’s payment schedule off the agreed terms.
Which fields those are, and what each rule reads to write them, is not written down
here — it is the data model screen, read live off the
registry. A captured field carries system, and the paths its rule reads sit
beneath it.
The order and the invoice share every header field and every rule on it: both
build from salesDocumentHeaderFields and both bind the same determinations from
domains/sales/src/document/. The payment schedule is the
invoice’s alone — an order agrees terms but owes nothing yet. It is also the clearest
statement of why a derived value gets written down rather than read back on demand:
cashDiscountRate is stamped rather than read back from paymentTerms, because it is
the rate the invoice went out with and the e-invoice has to transmit it verbatim.
How a captured field is written
Section titled “How a captured field is written”// domains/sales/src/document/header-defaults.determinations.ts — soldToAddressDefaultinputs: (doc) => [doc.customer],outputs: (doc) => [doc.soldToAddress],// …async compute(doc) { if (!doc.customer || doc.soldToAddress) { return null; // propose only when empty } const customer = await this.bom.get(BusinessPartnerSchema, doc.customer); return customer?.address ? { soldToAddress: { ...customer.address } } : null;}The return null when the slot is already filled is the whole mechanism. It reads
state, so it holds on every pass and every save thereafter: the address is copied on
the first pick and never again, and switching the customer later leaves the order with
the address it already has. The engine’s own protection of a hand-typed value is scoped
to one mutation (Determinations covers it), so only this
guard protects the value for the life of the document.
Stamped is not frozen
Section titled “Stamped is not frozen”A captured field whose rule has no empty-guard is stamped — written down, and
written again the next time an input moves. linePriceLookup is the one to know: it
has no guard, and it reads more of the line than its name suggests — the data model
screen lists what. Type a unit price, save, then change that line’s quantity, and the
price list’s answer replaces yours. lineVat behaves the same way, re-reading the
jurisdiction’s current rate whenever a line’s net amount changes.
For a document still being negotiated that is the point. But it means “the price is fixed when the order is made” is not something the engine gives you, and a document that must never move again cannot be left to determinations.
Capturing a whole document
Section titled “Capturing a whole document”copyRecord builds the Update that reproduces a persisted record field for field —
access: "system" fields included, ids dropped at every level so the engine mints
fresh ones. It is the one place in the framework that captures by mirroring
instead of re-deriving.
const cancellation = await this.bom.create(SalesInvoiceSchema, { ...copyRecord(invoice, this.metaService.resolve(SalesInvoiceSchema), { omit: ["documentNumber", "documentDate", "issuedPdf"], }), invoiceType: "cancellation", precedingInvoice: invoice.id,});Two details carry the idea:
- Pass the composed definition from
MetaService.resolve, never a statically imported schema. The walk covers whatever fields the composed definition actually has, so eu-vat’sjurisdictionandtaxHandlingare mirrored without sales naming — or knowing about — a single one of them. omitleaves a field absent, not null. Absence is what hands the field back to the new record’s determinations: everything the write carries is suppressed for that pass, sonullwould freeze the field empty instead.
Why mirror instead of re-derive: a Stornorechnung that re-ran the normal rules would be priced and taxed at today’s rates, so its VAT would never net back to zero against the invoice it reverses. Cancellations and corrections has the full case.
What is not captured
Section titled “What is not captured”- Only
soldToAddressis copied automatically.shipToAddressandbillToAddressare declared on every sales document and have no determination at all. Readers fall back rather than requiring both: the VAT rule takesshipToAddress ?? soldToAddressas the destination, and the invoice PDF addressesbillToAddress ?? soldToAddress. - The seller’s half of place of supply is read live.
computeVatTreatmenttakes the seller country from thecompany’s business-partner record at compute time, and the PDF letterhead resolves that same record at render time. The storedtaxHandlingstands until one of the document’s own inputs moves the rule again, but move your own company and every open order re-renders on the new address. A released invoice does not: its PDF is stored once it is issued (issuedPdf), and every download serves that file. - A missing capture is silent. A new document type that reads master data live compiles, validates, and prints fine. Nothing surfaces it until the master record changes and an old document reads differently than it did.