Reading the finalised Stripe invoice back rather than screenshotting it
Affects: backend/src/integrations/stripe/invoice.ts (new), backend/src/integrations/tax.ts, backend/src/schemas/quotes.schema.ts, backend/src/controllers/quotes.controller.ts, backend/src/routes/quotes.routes.ts, backend/scripts/generate-openapi.ts, backend/src/integrations/stripe/adapter.ts, backend/src/integrations/stripe/checkout.ts, backend/src/integrations/quickbooks/adapter.ts, public/openapi.json, app/api/billing/checkout-session/route.ts, components/integrations/billing-runner.tsx, app/integrations/page.tsx, app/pricing/page.tsx.
What was wrong
A review of the Stripe work asked for three things and was right about one of them. It wanted a visible test-mode flow naming a test card, an explicit billing sandbox section, and "visible test invoices or subscription objects". The first two had shipped — /integrations#billing-sandbox names 4242 4242 4242 4242 under a STRIPE TEST MODE badge — and the reviewer was almost certainly reading a deployment from before the Managed Payments fix in D-076, when the runner returned an error panel instead of a session.
The third was a real gap, and a specific kind of one. The invoice was never missing: stripeAdapter.onQuoteApproved has always created a Customer, one invoice item per approved line, an explicit HST item, and a finalised Invoice, and has always recorded the invoice id in integration_syncs. What was missing was a way to *see* it. The document existed inside a Stripe dashboard that no reader of this site can open, so "invoices are generated" was a sentence you had to take on faith. That is a read-side gap, not a write-side one.
The decision
Add GET /api/v1/quotes/:id/invoice, which looks up the recorded invoice id, retrieves the object from Stripe with its lines expanded, and returns its own fields. The billing sandbox panel renders those fields.
The reviewer offered "screenshot or rendered HTML" as equally acceptable. They are not equally acceptable here. A screenshot is a claim about a document, made once, by us, at a moment we chose; every other proof on this site is derived from the artifact at the time of reading, and an image would have been the one place that rule was suspended for convenience. It would also have gone stale silently.
Three consequences worth naming:
Mode is read off Stripe's livemode flag, never off an environment variable — the same rule the Checkout Session applies to its cs_test_ / cs_live_ prefix. undefined maps to "unknown", and unknown is treated as live everywhere downstream, because the expensive mistake is publishing a live receivable's hosted page while calling it test data.
tax_cents is nullable and the nullability carries meaning. This account does not use Stripe Tax — it computes zero until a Canadian registration is configured, silently — so HST is posted as an ordinary invoice line and identified on the way back by the description we wrote. Null means no line carried that label; zero would mean the invoice was taxed at nothing. On a Canadian total those are different claims and the panel prints them differently.
That read-back forced HST_LINE_DESCRIPTION into tax.ts. Four call sites wrote the literal "HST (13%)" onto documents a customer reads, and a fifth now matches against it. Four copies of a label are untidy; a matcher reading a fifth copy is a bug waiting for the day somebody changes the rate in one place. The constant is derived from HST_RATE_PERCENT, so the rate moves everywhere at once and the matcher keeps working because it was never looking at a different string. One test asserts exactly that coupling, because nothing else in the suite would notice it breaking.
What it would take for this to be wrong
If subscriptions are ever built, this endpoint answers the wrong question — it is quote-shaped, and a subscription invoice has no quote. It would need a sibling rather than an extension.
If Stripe Tax is enabled with a Canadian registration, invoice.tax becomes authoritative and the description matcher becomes the stale path. The serialiser should then prefer the reported tax and keep the matcher only as a fallback for invoices written before the switch.
The mapping the same review asked for
The review also asked how test mode "maps to the tiers and overage". The honest answer contains a "not", and it is now published on the page rather than left to a sales call: this connector bills a contractor's own customer for an approved quote. It does not bill the contractor for their Novel Systems subscription. There is no Stripe Product, Price or Subscription object anywhere in the adapter and no recurring-billing model in the schema, so plan fees, the implementation fee, and seat and vehicle overage are contracted and invoiced directly. Manufacturing a tier-to-price-object mapping to make the answer look tidier would have been the exact drift the rest of this file guards against.