Skip to content

Domain model & invariants

The state machine

planned ──assign──▶ planned ──start──▶ in_progress ──complete──▶ completed ──close──▶ closed
TransitionOperationRequires statusPermission
createworkorder/createworkorder:create
assign technicianworkorder/assignplannedworkorder:assign
start workworkorder/startplannedworkorder:report
report timeworkorder/report-timeplanned or in_progressworkorder:report
report materialworkorder/report-materialplanned or in_progressworkorder:report
complete (with billable lines)workorder/completein_progressworkorder:complete
closeworkorder/closecompletedworkorder:close

Invalid transitions throw — a completed order cannot be started, a planned order cannot be completed. The engine owns this; no caller can skip a state.

Note that assign leaves the order in planned: assignment is a field, not a state.

Tables

Created by migration 0001-init, inside each scope's own database:

  • workorder_orders — id, sequential number (per scope), facility and customer as opaque EntityRef columns, vertical-defined kind, title/description, status, assignment, provenance (created_by, created_at, completed_at).
  • workorder_time_entries — order, technician, decimal hours, note, reported_at.
  • workorder_material_lines — order, article, decimal qty, note, reporter, reported_at.

Notice what's absent: no facility table, no customer table, no price columns. The engine references facilities and customers as opaque refs the vertical owns, and it records quantities, not prices — quantities are facts, prices are business decisions.

The invariants

  1. The state machine cannot skip states (above).
  2. Time and material are append-only. There is no update or delete operation for reported entries. Corrections are a vertical-level concern (a compensating entry), never a silent edit.
  3. The reporter is the principal. Time entries record ctx.principal as the technician; material lines record the reporter. Attribution comes from the ambient context, not from the input — a caller cannot claim someone else did the work.
  4. Every mutation emits a fat event (events).

Completion: the pricing boundary

complete is where the engine meets money, and the boundary is precise: the vertical prices, the engine freezes.

ts
await stub.invoke('workorder/complete', {
  orderId,
  billable: [
    {
      article: 'TIME', description: 'Servicetekniker', qty: '2.5', unit: 'h',
      unitPrice: moneyOf('850', 'SEK'), lineTotal: moneyOf('2125', 'SEK'),
      sourceType: 'time', sourceId: timeEntryId,
    },
    // ...
  ],
});

Each billable line carries provenance (sourceType: 'time' | 'material', sourceId) back to the reported entry it prices. The engine validates the lines (Zod + exact decimal arithmetic), sums the total with addMoney, transitions the order, and emits workorder.completed with the full billable snapshot in the payload.

The engine never derives a price from a reported entry — it has no rates to derive from. It checks that what it was handed is arithmetically sound, then makes it permanent.

The hard parts, hosted.