Skip to content

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.

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.

// domains/sales/src/document/header-defaults.determinations.ts — soldToAddressDefault
inputs: (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.

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.

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.

domains/sales/src/sales-invoice/cancel-invoice.action.ts
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’s jurisdiction and taxHandling are mirrored without sales naming — or knowing about — a single one of them.
  • omit leaves 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, so null would 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.

  • Only soldToAddress is copied automatically. shipToAddress and billToAddress are declared on every sales document and have no determination at all. Readers fall back rather than requiring both: the VAT rule takes shipToAddress ?? soldToAddress as the destination, and the invoice PDF addresses billToAddress ?? soldToAddress.
  • The seller’s half of place of supply is read live. computeVatTreatment takes the seller country from the company’s business-partner record at compute time, and the PDF letterhead resolves that same record at render time. The stored taxHandling stands 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.