Skip to content
Skip to main content
Novel Systems home
Decision log
D-084August 5, 2026

The invoice API reports a subtotal net of our own tax lines, not Stripe's

Affects: backend/src/integrations/stripe/invoice.ts, backend/src/schemas/quotes.schema.ts, backend/test/adapters.test.ts

What was wrong

D-083 shipped, and the first live probe against the deployed build returned a populated invoice — P49QOGVA-0009, open, test mode, CA$4,120.79 — and, in the same body, two facts that cannot both be true of a working system: lookup: "found" alongside createdQuote: true. The route had found the tenant's approved quote, read its invoice, and thrown that invoice away.

The numbers said why. subtotal_cents: 412079, tax_cents: 47407, total_cents: 412079. The pre-tax figure is 364672; 412079 is the total. The API was publishing the tax-inclusive total in the subtotal field, so subtotal + tax = 459486 against a total of 412079, and isUnusableEvidence in app/api/billing/checkout-session/route.ts — which refuses to reuse an invoice whose parts do not add up — discarded every correct invoice the platform produced.

The cause is one line, and it is a consequence of a decision this codebase had already made and not followed all the way through. serialiseInvoice set subtotalCents from Stripe's invoice.subtotal. Stripe's subtotal is the sum of the line items *before Stripe's own tax* — and this platform's HST is not Stripe's own tax. Stripe Tax computes zero until a registration is configured on the account, and does so silently, which is exactly why adapter.ts posts HST as an ordinary line item instead. Having made HST a line item, we then read back a field that counts line items and published it as though it excluded ours.

Nothing caught it because the test fixture made the same mistake. INVOICE in adapters.test.ts set subtotal: SUBTOTAL and total: SUBTOTAL + HST — a Stripe response that Stripe does not produce. The suite was self-consistent and wrong about the third party it was standing in for.

What was decided

Subtract the lines we recognise as ours. subtotalCents is Stripe's subtotal less the sum of the lines whose description matches HST_LINE_DESCRIPTION — the same lines taxCents is derived from, so the two fields can no longer disagree by construction. The published invariant is subtotal_cents + tax_cents = total_cents whenever the first two are non-null, it is stated in the schema, and a test asserts it rather than implying it.

Nulls propagate rather than resolve. If Stripe sent no subtotal, the field is null; a subtotal computed from lines would be an invented number wearing the same clothes as a reported one. If no line carried our label there is nothing to subtract and Stripe's figure already is the pre-tax one.

The fixtures were corrected to model the Stripe that exists. Both invoice fixtures now carry a tax-inclusive subtotal, with a comment saying so and why, because the next person to read subtotal: SUBTOTAL + HST will assume it is a typo. Reverting the source fix with the fixtures corrected fails two tests, which is the property that was missing.

The alternatives, and why not

Loosen the guard instead — drop the `subtotal + tax` check from `isUnusableEvidence`. Rejected. The guard was right and the data was wrong. Deleting the check would have hidden a real arithmetic inconsistency in a published financial document and left the API contradicting itself for anyone who read it, including the OpenAPI consumers this project spent Task 4 courting.

Publish Stripe's subtotal verbatim and rename the field, e.g. `line_items_total_cents`. Rejected, though it is the honest minimal change. subtotal on an invoice has a settled meaning to every accountant who will read it, and a Canadian invoice whose "subtotal" equals its total is a document that invites a support ticket even when it is technically defensible. The API should answer the question the reader is asking.

Turn on Stripe Tax so Stripe owns the tax field. Rejected for now, and not on the merits — it is the better long-term answer, and would make invoice.tax and invoice.subtotal mean what their names say. It requires a configured Canadian tax registration on the Stripe account, which is an owner action and a tax-compliance decision, not a serialisation fix. Recorded here so the next person knows this line becomes deletable the day that happens.

What would have to change for this to be wrong

If HST is ever posted through Stripe Tax rather than as a line item, Stripe's subtotal becomes tax-exclusive on its own, taxLines becomes empty, the subtraction becomes a no-op, and the field stays correct — but taxCents would then be null on a taxed invoice, which is a different defect and one the header of invoice.ts already warns about. If a future line item is legitimately a pass-through charge that Stripe treats as tax, the description match stops being a complete account of what is tax on the document, and this subtraction would under-state the subtotal by that amount.