Quote Domain Runtime Layer: Service, Route, Atomic Convert
Why This Matters
The previous release (5.32.0) shipped the schema half of the quote domain rebuild: new tables for quotes and quote lines, the invoice constraint surface stripped of quote accommodations, bidirectional provenance wired at the schema level. The Known Issues section of that release was honest about what remained owed: the quote API endpoints still threw on writes, no service layer existed, no atomic convert function existed, and the provenance invariant was representable but not yet runtime-enforced.
This release closes those Known Issues. Quotes can now be created, updated, status-transitioned, converted to invoices, and deleted through the API. Every mutation writes a hash-chained audit record. The quote-to-invoice conversion is a single atomic transaction that either fully completes or fully rolls back, and the bidirectional provenance between converted quote and spawned invoice is now mechanically verifiable rather than an operational expectation.
The session also surfaced a class of latent read-boundary defects that were waiting in both quote and invoice paths: database driver coercion of dates, timestamps, and small-scale numeric values varies by environment and driver build, and the previous schemas declared one shape and would have failed in the other. Production quote reads exposed this immediately; production invoice reads had been getting away with it because the affected emission paths were exercised less frequently. The shared read-boundary primitives now make this class of defect unrepresentable across both domains. That is the side benefit; the headline is that the quote domain rebuild is complete.
Quote Service Introduced
The quote domain now has a dedicated service layer that owns calculation, transactions, audit writes, and the state machine. The route layer carries only dispatch, validation, and response shaping. No SQL, no transactions, no business logic, and no audit writes appear at the route layer.
Calculation Boundary
Quote line arithmetic runs through the universal calculation
template that governs the invoice domain: quantities and unit prices
cross the wire as exact decimal strings, VAT rates as integer
percentages, totals as pence integers. Decimal.js carries every
intermediate value; floats do not appear on any path between the
ingestion boundary and the database boundary. The single rounding
authority (accounting.round_half_up) is used by both the service
arithmetic and the database CHECK constraints, which means service
and database agree on every penny by construction rather than by
coincidence.
Proof anchors: chk_quote_lines_net_pence_correct,
chk_quote_lines_vat_pence_correct,
chk_quote_lines_gross_pence_correct (migration 087, Class M). Each
constraint independently verifies the service computation for that
column.
Header to Line Reconciliation
The quote header subtotal, VAT amount, and total are pinned to the sum of their lines by a header-level CHECK constraint. A header that disagrees with its lines cannot exist in the database.
Proof anchor: chk_quotes_totals_correct (migration 087, Class M).
State Machine
The quote lifecycle has six states and seven legal transitions. The
public status-update endpoint admits four targets (draft, sent,
accepted, declined); converted is reachable only through the
dedicated convert endpoint, and expired is reserved for a future
expiry automation. The service rejects illegal transitions with a
typed allowlist; the database CHECK rejects them again as a second
layer.
Proof anchors: service-level assertTransitionAllowed,
chk_quotes_status (migration 087, Class M).
Audit Chain Integration
Every quote mutation now appends a hash-chained record to the canonical audit trail: creation, update, status change, conversion, and deletion. Conversion appends two records in one transaction (one for the source quote, one for the spawned invoice) so the chain captures both halves of the morphism. No quote mutation writes to the legacy audit log; the chain advances monotonically.
Proof anchor: accounting.audit_trail hash-chain head update via
writeAuditRecord (Class M via chain integrity check on insert).
Atomic Quote-to-Invoice Conversion
The convert endpoint is the centrepiece of the rebuild. It locks the
source quote, verifies the status guard and the empty-quote guard
and the line account-assignment guard, issues an invoice number
atomically, inserts the spawned invoice with both foreign keys set,
copies the lines verbatim, updates the source quote to converted
status with the back-reference foreign key, and writes a dual audit
record. The whole sequence runs inside one transaction. Any failure
rolls everything back; partial conversions are not representable.
The bidirectional provenance invariant from 5.32.0 is now mechanically enforced at runtime. The integrity-harness reconciliation query (every converted quote and its spawned invoice must agree mutually on the foreign key pair) returns zero rows by construction. This is the Class M promotion the previous release flagged as pending.
A second conversion attempt against an already-converted quote returns a conflict response rather than spawning a duplicate invoice. Idempotency on retry holds.
Quotes whose lines have no account assignment cannot be converted (the resulting invoice would be unbookable). The guard runs at the service layer with a clear error before any database mutation.
Proof anchor: bidirectional FK pair on accounting.quotes and
accounting.invoices (migration 087 schema, runtime enforcement
this release, Class M).
Read-Boundary Hardening
The read boundary between the database driver and the service layer now normalises driver-specific coercions to canonical forms at parse time. This closes a class of defect that affected both the quote and the invoice domains.
The PostgreSQL driver coerces DATE and TIMESTAMPTZ columns to native Date objects under its default configuration and ISO strings under certain alternative configurations. The previous read schemas declared one shape and would have failed under the other. The new preprocessors accept either and canonicalise to a string before validation, so downstream code no longer has to branch on driver behaviour.
Small-scale NUMERIC columns (VAT rates and similar) exhibit the same duality: returned as strings under some driver builds and as numbers under others. A shared preprocessor coerces both to a bounded number at the parse boundary.
Both fixes apply to the invoice domain as a side benefit. The invoice read path carried the same latent coercion mismatches; they had not surfaced because the affected emission paths were exercised less frequently in production traffic. The shared primitives now make this class of defect unrepresentable across both domains.
Foreign Key Errors Now Return 400, Not 500
When a request references a contact, account, or other resource that does not exist in the caller’s tenant, the API now returns a 400 validation error with the referenced subject named in the message (“Referenced contact does not exist”). Previously the foreign key violation surfaced as a 500.
The mapper extracts the subject from the constraint name at the route’s error boundary. Check-constraint violations and not-null violations are deliberately left as 500 because those indicate a service/schema mismatch rather than bad user input, and the journal needs the full stack for diagnosis.
Operational Impact
-
Quote API endpoints now function. Create, update, status transition, convert, delete are all operational. The Known Issue from 5.32.0 (“quote API endpoints still throw on writes”) is closed.
-
Provenance invariant promoted from operational to Class M. The Known Issue from 5.32.0 (“provenance invariant not yet runtime-enforced”) is closed; the convert function writes both foreign keys in one transaction, and the reconciliation query returns zero rows by construction.
-
Audit chain advances on every quote mutation, including the dual write across both entities during conversion. The chain captures the full lifecycle from creation through conversion in a single monotonically-advancing sequence.
-
Foreign key violations against user-supplied identifiers return 400 validation errors with a useful subject rather than 500.
-
Database driver coercion differences between environments no longer affect parse boundaries. The shared read-boundary primitives apply to both quote and invoice schemas.
-
Illegal quote status transitions are rejected at the service layer and again at the database layer. Two-layer enforcement.
-
End-to-end verification ran against the live system as part of this release. One quote was created (
QT-0001), status-transitioned throughsentandaccepted, and converted to a draft sales invoice (INV-0012). The bidirectional provenance reconciliation query returned zero rows; the audit chain advanced fromQUOTE_CREATEDthroughQUOTE_CONVERTEDandINVOICE_CREATED_FROM_QUOTEwith every chain hash at full length.
Known Issues
-
Outer error envelope mismatch. The route layer correctly constructs typed
validation_errorandconflictresponses for the foreign-key and idempotency cases; the outer Fastify error handler currently re-wraps some of these as a generic envelope before they reach the client. Service-layer logging captures the typed errors correctly. Alignment of the outer envelope to the inner typed code is a follow-up; no functional impact, journal traces are accurate. -
Invoice audit-log legacy path. The invoice service still writes mutation records to the legacy audit log table rather than the canonical audit chain that the quote service now uses. The invoice domain’s migration to the canonical chain is owed; it was not part of the quote rebuild scope. Tracked.
-
Quote
termsfield silently dropped. The quote create endpoint accepts atermsfield in the request body for forward compatibility, but the schema does not yet carry atermscolumn. The field is dropped silently on write. A future single-purpose change will add the column and wire the field end-to-end. This matches the pre-rebuild behaviour and is intentionally preserved rather than rushed in.
Files Changed
Backend:
-
Quote service introduced. Owns calculation, transactions, audit writes, the typed state machine, and the atomic convert morphism. Built on the same calculation contract as the invoice service.
-
Quote routes rewritten on the layered contract. Dispatch, validation, prefixed-ID resolution, and response shaping only. No SQL, no transactions, no audit writes, no business logic. The PostgreSQL error mapper that promotes foreign-key violations to 400 validation errors lives at the route’s error boundary.
-
Quote domain types added to the shared type registry, including the wire-facing Zod schemas with string-decimal monetary fields and the service-facing input shapes.
-
Read-boundary primitives hardened in the shared schema library. Date and timestamp preprocessors normalise driver coercion variance; a NUMERIC fractional preprocessor handles string-or-number driver behaviour. Applied to both quote and invoice read schemas.
With this release the quote domain rebuild is complete. The agreement-to-invoice path is now mechanically continuous from the first line entered to the spawned invoice’s first ledger posting, with audit and provenance verified at every step.