Skip to content

Rollups — a field kept equal to what other records add

A rollup keeps one system field of a business object equal to the sum of what other records contribute to it — a field on the record itself, or on a row at any hasMany depth. The invoiced quantity on a sales order line is one: every released invoice line that names the order line adds its quantity, a cancellation invoice takes it back, and the order line always shows the total. Delivered quantity, credited amount, committed amount — same shape.

The field is an ordinary field.quantity or field.amount with access: "system". There is no rollup field kind, nothing about it ships to the browser, and a person can never type it. What makes it a rollup is the one class that maintains it.

One rule: a source record counts while it is active

Section titled “One rule: a source record counts while it is active”

The rollup is a listener on every verb of the source business object (see Events), and it applies one rule to all five:

verb counted before counted after
created nothing the record
updated previous, if it was active the record, if it is active
archived the record nothing
restored nothing the record
deleted nothing nothing

For each holder it writes after − before. A zero delta writes nothing. deleted is a no-op because only an archived record can be deleted, and archiving already took it out. updated checks the status on both sides: the lock seals input fields, not the row, so a system-only write reaches an archived record and must count for nothing until a restored brings it back.

That single rule is what makes the pattern fit both kinds of source. A document that is read-only from birth — an invoice, a goods movement — only ever fires created, so it is added once and never moves. A business object that can be archived and edited fires the other four, and the rule handles each without a special case. Drafts contribute nothing: the draft world is silent, and activate is the created the rollup hears.

Rollup.on(Target, { field, from }) returns the base a rollup extends, in the same extend-and-list shape as Action.on. It is registered on the business object whose field it keeps — the same sense Action.on has — and fed from the source. The subclass states one thing: what a source record contributes.

domains/sales/src/sales-order/invoiced-quantity.rollup.ts
@Injectable()
export class InvoicedQuantity extends Rollup.on(SalesOrderSchema, {
field: "items.quantityInvoiced",
from: SalesInvoiceSchema,
}) {
contribution(invoice: SalesInvoice): Contribution[] {
const sign = invoice.invoiceType === "cancellation" ? -1 : 1;
return invoice.items.flatMap((line) =>
line.orderLine
? [{ holder: line.orderLine, value: line.quantity.times(sign) }]
: []
);
}
}

Then list it in the plugin’s providers. There is no decorator to add: the base carries the @OnEvent subscription for the source’s own event name, and NestJS finds an inherited listener method like any other.

  • field is a dotted path: a decimal on the root ("balance"), or hasMany keys down to one ("items.quantityInvoiced", "items.scheduleLines.quantityConfirmed"). The type admits nothing else, and the factory throws at load unless that field is access: "system".
  • contribution is a pure function of the source record: one entry per holder it touches, holder being that holder’s id — the record’s own id for a root field, the row’s id below it (a row reference such as orderLine carries exactly that). Name the same holder twice and the two add. A record that touches nothing returns [].
  • Sign lives here. A cancellation invoice contributes the negative of what it mirrors, so the one projection boundary that concept page demands is this method, and every reader of quantityInvoiced gets a signed total without knowing an invoice type exists.

Source and target may be the same business object. The credited amount on an invoice line is fed by the credits that name the line — invoices feeding invoices — and nothing about the declaration changes:

domains/sales/src/sales-invoice/credited-amount.rollup.ts
@Injectable()
export class CreditedAmount extends Rollup.on(SalesInvoiceSchema, {
field: "items.amountCredited",
from: SalesInvoiceSchema,
}) {
contribution(credit: SalesInvoice): Contribution[] {
return credit.items.flatMap((line) =>
line.precedingLine
? [{ holder: line.precedingLine, value: line.netAmount }]
: []
);
}
}

An ordinary invoice names no preceding line and contributes nothing; a cancellation or a correction names one per row and adds its net amount, positive in both cases, because a credit’s direction is its type.

Every ERP re-derives such a field by summing the source rows again at the moment one of them changes, and none of them adds a delta. The reason that does not transfer here is the unit of work: list and SQL see committed rows only. A re-sum inside the listener would miss the very record that triggered it — and, worse, a second source record staged in the same unit, which a seeder, a batch action, or a test’s atomic block does routinely. The first listener would write a total the second one then overwrites from a stale sum.

What the framework does give a listener is the transition itself: every verb carries record, updated carries previous, and bom.get sees staged writes. So the rollup computes the delta from the event, reads the target with get, and writes it with update. A second source in the same unit reads the target the first one just staged and adds to it. Nothing is re-derived, nothing is missed, and the arithmetic stays in application code — no SUM() in the database.

The rollup writes through BusinessObjectManager.update, deliberately not raw SQL in an onCommit callback, and that buys the rest of the framework for free:

  • One transaction. The listener runs before commit — inside the source’s own create, before it returns — and stages the target’s update into the same unit as the source’s write; a throw anywhere discards both. So an action that creates the source and then reads the target through bom.get already sees the delta; a bound it wants to judge reads the target it was handed instead (see One save, in order).
  • The lock. A delta touching only system fields passes isLocked, so a sealed order still learns it was invoiced — see Lifecycle.
  • Audit and cause. The target gets an audit row whose cause is the source event.
  • Events and determinations. The target emits updated and runs its own determinations, which is how a derived header status can follow from the line totals without the rollup knowing it exists.
  • The optimistic lock. Two units rolling into the same target collide on the target’s version; the loser gets ConcurrentModificationError and a retry recomputes from a fresh read. A lost update is impossible.

Every rollup has rebuild(): re-sum every active source record with the same contribution, correct every holder that disagrees, and return what changed. It is the repair path if a value ever drifts, and it is the standing closing assertion of any scenario that touches the rollup:

expect(await atomic(() => getApp().get(InvoicedQuantity).rebuild())).toEqual([]);

An empty result proves the incremental writes were exact. It also catches the one way a delta can go wrong that a re-sum cannot: a rollup class listed in two modules subscribes twice and double-counts, and the oracle fails on the first scenario.

In a scenario test, resolve the rollup through its package path (@onerp/sales/sales-order/invoiced-quantity.rollup), not ../src: the plugin registers the built class, and a provider token is identity.

  • The value is a decimal, because the write is a delta: whatever a rollup keeps must let a contribution be taken back out. A sum and a count (a contribution of one) both do; a minimum, a latest date or a flag would need a re-sum over every source, which the unit of work cannot see.
  • contribution reads the record it is handed and nothing else. A rule that needs another record to decide what a source contributes is a sign the fact belongs on the source document as a field of its own.
  • The rollup keeps the number; it does not guard it. A bound on what a source may add is the source action’s precondition — in isAvailable when the target’s own fields answer it, in run when the source’s computed totals are needed — judged against the target as loaded. See Where a rule lives.