The billing sandbox publishes why it created a quote, and waits for the invoice it caused
Affects: app/api/billing/checkout-session/route.ts, components/integrations/billing-runner.tsx
What was wrong
D-080 fixed the Stripe adapter. The Stripe test dashboard for acct_1TzogSPiVC0ozAWK proves it: invoices P49QOGVA-0005 through -0008 are finalised at CA$4,120.79 each, and only -0001 through -0004 — the four written before the fix — sit at CA$0.00. The adapter has been writing correct invoices in production since it deployed.
The live billing sandbox went on showing nothing. Three requests to /api/billing/checkout-session, each spaced past the sixty-second result cache, returned quote numbers 6, 7 and 8, createdQuote: true every time, and invoice: null every time. So the panel was wrong twice over, in two unrelated ways, while the system it exists to demonstrate was working.
Defect A — the lookup could not say it had failed. findApprovedQuote returned QuoteRef | null and reached null four different ways: the API refused, the body would not parse, the row was missing its fields, or the tenant honestly has no approved quote. Only the last is a reason to create one. The caller could not tell them apart, so all four produced the same behaviour — make another quote — in flat contradiction of the route's own header, which promises that "a visitor clicking the button ten times produces one quote and one payment page." It was producing one per click and nothing in the response said so. Establishing that took a login to a third party's dashboard, which is the precise failure this file already complains about, one function further down, in faultFrom.
Defect B — the invoice read raced the approval that caused it. dispatchApprovedQuote in backend/src/integrations/fan-out.ts is, in full, void fanOutApprovedQuote(quote).catch(...). The approve endpoint answers before the Stripe invoice exists — correctly, since a customer approving a quote should not wait on Stripe and should not be told their approval failed because Stripe was slow. But for a quote this route had just created, GET /invoice therefore 404s on timing alone, and requestInvoice read that 404 as licence to POST the repair immediately. The repair runs onQuoteApproved a second time, concurrently with the fan-out still in flight, against identical Stripe idempotency keys. Stripe answers a genuinely concurrent repeat of a key with 409 idempotency_key_in_use, so the repair fails and may record FAILED over the fan-out's SUCCEEDED row, depending on which write lands last.
What was decided
The reason travels with the answer. findApprovedQuote returns { quote, lookup }, where lookup is one of found, none_approved, http_<status>, unreadable, or malformed_row. It is a field on the response and a sentence on the panel, not a log line. The question it settles — is this route creating a quote per click, and if so why — was answerable on 5 August 2026 only from Stripe's dashboard, and a public evidence panel whose diagnosis requires a vendor login is not evidence.
A fresh quote's invoice is waited for, not raced. requestInvoice takes a settleFirst flag, passed as quote.fresh: four GETs, 1.2 s apart, before the repair POST. Under four seconds against an eight-second per-request timeout. The repair is still there — it is the only thing that helps when the fan-out genuinely lost — but it now runs after the race rather than inside it.
The alternatives, and why not
*Make the fan-out synchronous.* That fixes the sandbox by making every customer approval wait on Stripe, and makes a Stripe outage into an approval outage. The asynchrony is right; the caller's assumption about it was wrong.
*Log the lookup reason instead of publishing it.* Vercel function logs are behind an account only the operator holds. The whole argument of this panel is that a reader can verify the claim without our credentials, and a reason they cannot read is a reason they have to take on trust.
*Have the sandbox call the repair endpoint deliberately.* Rejected: the panel's value is that it exercises the ordinary approval path. A special path that only the demo uses demonstrates the demo.
What would have to change for this to be wrong
If POST /approve ever awaits the fan-out, settleFirst becomes four wasted seconds and should be deleted — not left in as harmless, since it would then be lying about a race that no longer exists. And if lookup reports anything other than found or none_approved in steady state, that is a defect in the quotes API, not a field to widen.