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

Every Stripe invoice this platform ever wrote was empty, and the tests could not have known

Affects: backend/src/integrations/stripe/adapter.ts, backend/src/controllers/quotes.controller.ts, backend/test/adapters.test.ts.

What was wrong

createInvoice created the customer, posted one invoice item per quote line plus an HST line, and *then* created the invoice. That ordering is not careless — it is the documented one. An invoice item created with only a customer sits in that customer's pending queue, and Stripe sweeps the queue into the next invoice created for that customer. The adapter relied on the sweep, and a comment in the file said so, in the confident tone of something nobody had checked.

The sweep does not happen on this account. Every invoice this platform has ever written finalised at CA$0.00 with no line items and, being zero, was marked paid on finalisation. Stripe returned a finalised invoice object each time, so the adapter recorded SUCCEEDED, integration_syncs recorded a hosted URL, and the fan-out logged a success. The failure was invisible from inside the system because every internal signal was the signal for success.

It was visible from outside it. invoice.stripe.com — Stripe's own page, not ours — showed invoice P49QOGVA-0003 as "Invoice paid — CA$0.00" for a quote whose Checkout Session, built in the same codebase on the same day, carried $4,120.79. Checkout was right for the reason the invoice was wrong: it builds its line_items inside the request instead of trusting a queue.

Why the tests were green

test/adapters.test.ts drives the adapter against a fake Stripe, and asserted the call order specifically — the old case was named "creates customer, items, invoice and finalises — in that order" and its comment explained that an item created after the invoice lands on the following month's bill. It was asserting the right thing about the wrong contract. A fake returns whatever the fixture says a created invoice looks like; it cannot decline to perform a sweep, because the sweep is not something our code does. No fake-provider test could have caught this, and saying so is the point: D-012 already names "whether the providers actually behave as the fixtures say" as the irreducible gap, and this is what that gap costs when it comes due.

What was decided

The pending queue is not used. The draft invoice is created first, with an explicit currency and pending_invoice_items_behavior=exclude; every item then names invoice=<draft id>; the draft is finalised last. Nothing depends on Stripe moving an object we did not tell it to move.

exclude is the second half rather than a flourish. The old ordering stranded real invoice items on the sandbox customer with no invoice to join. Without exclude those orphans are swept into the next invoice created for that customer — which would be some later, legitimate bill, arriving with a stranger's blinds on it.

A finalised total that is not the quote's total is FAILED. onQuoteApproved now computes subtotal + HST itself and compares it against Stripe's total, returning FAILED — carrying externalId, so the offending invoice can be opened — when they differ. Two cases in the suite previously asserted the opposite: that Stripe's number is authoritative and recording it faithfully is the honest thing to do. It was accurate and it was how CA$0.00 got written down as a success for months. Recording a number correctly is not the same as agreeing to bill it, and an invoice whose total is not the approved quote's total is a document nobody approved.

The FAILED path stays repeatable rather than terminal: fan-out.ts skips only on a SUCCEEDED sync and repairInvoice short-circuits only on a SUCCEEDED sync, and every mutating call carries a per-quote idempotency key, so a retry reaches the same Stripe objects instead of minting a second invoice.

The alternative, and why not

Keep the ordering and add the read-back check alone. The check would have caught this — that is its whole justification — but it catches it *after* an empty invoice exists in a customer's account with a number on it. The ordering fix prevents the document; the check is there for the next failure of this class, which will not look like this one.

What would have to change for this to be wrong

If Stripe's API version pinned in STRIPE_API_VERSION (2024-06-20) rejected pending_invoice_items_behavior, the draft creation would 400, the adapter would record FAILED, and the billing sandbox would show upstream_refused rather than an invoice. That is a loud failure rather than a silent one, which is the trade this whole entry is about — but it must be confirmed against the live account, not against the fixtures, because confirming this class of thing against fixtures is what produced the defect.