A sentence that named a retry we had not built, and the retry we built rather than delete it
Decided
POST /api/v1/quotes/{id}/invoice — same path as the read, no /retry suffix, because it acts on the same resource and returns the same representation. It answers 201 with created: true when it wrote one and 200 with created: false when one was already there.
Affects: backend/src/controllers/quotes.controller.ts, backend/src/lib/errors.ts, backend/src/routes/quotes.routes.ts, backend/src/schemas/quotes.schema.ts, backend/scripts/generate-openapi.ts, public/openapi.json, backend/test/adapters.test.ts, backend/test/support/fetch-trap.ts, app/api/billing/checkout-session/route.ts, components/integrations/billing-runner.tsx.
What was wrong
GET /api/v1/quotes/{id}/invoice answered 404 for a quote with no recorded Stripe invoice, and both the API's message and the panel copy on /integrations#billing-sandbox explained the absence by saying the fan-out "failed and has not been retried". That sentence named a mechanism that did not exist. approve fires the fan-out exactly once and returns 409 on a second call — deliberately, because two approvals would mean two invoices for one job — so a quote whose Stripe leg failed, or which was approved before the connector was written, could never acquire an invoice by any route. There was no retry to have not happened.
The honest options were to delete the sentence or to build the thing it described. Deleting it would have left a real defect intact and merely stopped mentioning it, which is the failure mode this repository exists to avoid.
Why it is safe to call twice
Three independent layers, none trusted alone. A SUCCEEDED sync row short-circuits before any Stripe call. Every mutating call the adapter makes carries an Idempotency-Key derived from the quote id, so a retry after a timeout re-reads Stripe's stored response instead of creating a second invoice — including the case where the first attempt succeeded at Stripe and the failure was in writing our own row. And the unique index on (organization_id, provider, entity_type, local_id) makes the row itself single. So the endpoint's description is not "creates an invoice" but "ensures exactly one exists, and tells you which case you were in".
What it deliberately does not do
It does not reuse approve. Re-approving would re-fire the tenant's quote.approved webhook and the QuickBooks and Salesforce legs as well, and a missing Stripe invoice is not a reason to send someone a second event. It repairs one connector's outcome for one quote.
It does not recompute prices. The lines come from payloadJson.calculation, so the invoice matches the document the customer accepted rather than today's rate card. Where that payload is absent or does not reconcile to the stored total, a single "Approved quote" line carries the amount rather than an invented breakdown.
It does not loop. The frontend proxy issues exactly one POST when the GET returns exactly 404 — not on 403, which means the identity is not allowed near the figures, and not on 503, which means the deployment holds no Stripe key, both of which would answer identically one line later. If the write refuses too, the reason belongs in the API's logs and the panel says so instead of retrying.
Two supporting choices
A refusal is a 502, not a 500. Adapters record FAILED rather than throwing, so error-handler.ts — which only produces 502 from a thrown ProviderHttpError — would have reported Stripe's refusal as our internal error. providerError() in lib/errors.ts closes that gap, and the response carries { provider, sync_status } and the word "retried" without ever repeating Stripe's own decline language back to the caller. Per D-075: a provider's refusal is not our defect.
The fetch trap now lets loopback through. test/support/fetch-trap.ts replaces the global fetch, and support/harness.ts reaches its own Express server through that same global — so the only test file holding a Stripe key was the only one that could not make an HTTP request. The trap now parses the hostname and returns the real fetch for 127.0.0.1, localhost and [::1], before recording, so inbound traffic to the system under test does not appear in the sequence assertions about what we sent a provider. This is not a softening of "no fallthrough": an unmatched provider URL still throws, and a test asserts the passthrough stays narrow.
This decision is wrong if repair ever needs to fix more than the Stripe leg. At that point the right shape is a general per-connector retry keyed on the sync row, not four sibling endpoints — and this one becomes the first case of it rather than the pattern to copy.