Skip to content
Skip to main content
Novel Systems home
Decision log
D-025August 4, 2026

Two ways to pay for one quote, converging on a single paidAt

Decided

An approved quote can be paid two ways. The invoice path already existed: approval fans out to Stripe and raises a net-14 document that is emailed and sits in the customer's accounts-payable queue. The Checkout path is new — POST /api/v1/quotes/{id}/checkout-session returns a hosted card page as a URL. Neither is the default, both are supported, and both inbound webhooks write the same quote.paidAt.

Why this was built at all. It was reported in docs/FINAL-REPORT.md as the one genuinely incomplete item in the twenty-task brief: the PRD asks for a successful test-card checkout, and a grep for checkout across backend/src/ returned exactly one hit, which was a comment. Calling that "code complete, blocked on Stripe signup" would have been false — no account produces code that does not exist. Having said so, the honest next move was to write it.

The alternative, and why it was rejected. Making approval mint a payment page automatically, alongside the invoice, would have been fewer moving parts. It was rejected because a Checkout URL is a bearer credential: whoever holds it can pay, and can see the customer name and the amount. Minting one for every approved quote produces a large population of live payment links nobody ever sent to anyone. Which instrument a job wants is a commercial question — a residential job wants a card field, a commercial one usually wants terms — and an API should not answer a commercial question on the tenant's behalf.

Four choices inside it that will look arbitrary later

`payment_status`, not `status`. A session reaches status: "complete" the moment a payer finishes the form, including for delayed payment methods whose funds have not settled. payment_status is the field that says money moved. Reading the wrong one marks a job paid on the strength of somebody having filled in a page. no_payment_required is accepted alongside paid because a zero-value session is settled, not failed.

The idempotency key is bucketed by the hour — novel-checkout-${quote.id}-${hourBucket}. A key derived from the quote id alone would make every later request return the first session forever, including after it expired, at which point the product hands out a dead link with a 200 beside it. An hour is longer than any retry window and shorter than the 24-hour session TTL, so a retry is de-duplicated and a genuine re-request the next day mints a fresh page.

A prior session is read back before it is reused, and reused only if Stripe still reports it open with a URL. A cached URL returned without that check is the same dead-link-with-a-200 failure by a different route. A read *failure* falls through to minting a new session rather than refusing: the worst case of falling through is a second page for an unpaid quote, which Stripe expires on its own; the worst case of throwing is a customer who cannot pay because a lookup of a page they were never shown failed.

`return_url` is checked against `CORS_ALLOWED_ORIGINS`, not merely parsed. A caller-supplied redirect echoed into a page hosted on checkout.stripe.com is an open redirect on an origin both a phishing filter and a human trust. The allowlist is the one already maintained for CORS rather than a second list, so a new frontend origin cannot be permitted to call the API while being refused as a return target, or the reverse.

Why the sync row is written before the URL is returned

The row on integration_syncs is the only mapping from a session id back to a quote, and it is what checkout.session.completed resolves against. A session that exists at Stripe with no row here is a payment that can be taken and never recorded. Writing first means the worst case is a row for a page nobody opens.

The row carries entity type checkout_session, distinct from the invoice row's invoice. The unique index is (organization_id, provider, entity_type, local_id), so one quote holds both rows without either displacing the other, and each inbound id resolves through its own.

What would have to change for this to be wrong. If tenants turn out to want only one instrument, the unused path should be deleted rather than left as dead surface — two payment routes are two things to keep correct. And if a payment method that settles asynchronously is ever enabled on the account, the payment_method_types[0]=card line becomes a constraint to revisit rather than a guard: this code treats a completed session as a settled one within the hour, and acss_debit would break that assumption quietly.