Skip to content

fix(#1184): hosted checkout can be fulfilled — correlate by client_reference_id - #1191

Merged
TortoiseWolfe merged 1 commit into
mainfrom
fix/checkout-correlation
Sep 18, 2026
Merged

TortoiseWolfe merged 1 commit into
mainfrom
fix/checkout-correlation

Conversation

@TortoiseWolfe

@TortoiseWolfe TortoiseWolfe commented Sep 18, 2026 •

Copy link
Copy Markdown
Owner

Closes #1192
Refs #1184

Hosted checkout — the path real buyers take — could not be fulfilled. handlePaymentCheckout
looked the intent up by session.metadata?.intent_id, and create-stripe-checkout never sets
session-level metadata. It sets payment_intent_data.metadata.intent_id (which lands on the
PaymentIntent, not the Session) and client_reference_id.

Measured, not inferred

A real live Checkout Session created by the deployed function on 2026-09-17:

session.metadata     = {}
client_reference_id  = "516bf680-e568-445a-98da-6a04a40d9589"   ← our intent id, correct

And the corroborating state:

payment_results 0 rows for the life of the project
every historical payment_intent.succeeded related_payment_id: null — {handled:false} every time
verify-stripe-session read-only; its own header says payment_results is "the source of truth"

So neither fulfilment path had ever executed successfully, and there was no third path. A
customer who paid would have got: no payment row, an order stuck at pending, and no receipt.

The fix

Correlate by client_reference_id when session metadata is absent. That also repairs sessions
created before this change, because the field was always being set. Explicit session metadata
still wins, for forward compatibility.

The companion change is not optional. With both handlers alive they both write
payment_results, and idx_payment_results_one_succeeded_per_intent permits one succeeded row
per intent — so whichever Stripe event arrives second collides. A 500 there is retried for three
days and counts toward the endpoint being disabled, which is the exact pressure that nearly cost us
the webhook last night. Both inserts now acknowledge 23505 and still call
advanceOrderAndNotify, which is a compare-and-swap on status and returns already-advanced
rather than emailing the buyer twice.

Deliberately not adding session metadata to create-stripe-checkout: client_reference_id
already carries it, works on historical sessions, and needs no redeploy of the outbound function.

Verification

  • 6/6 new, 15/15 with advance-order, 985/985 test:scripts, deno check clean.
  • The mutation bites: removing the client_reference_id fallback fails 3 of 6 tests, including
    the control that proves the resolver can return both a value and null.
  • The regression case is pinned explicitly — {} is truthy, so a naive session.metadata ? …
    check would still resolve to undefined. That is the bug, and a test now fails on it.

This is a prerequisite for the go-live flip in #629/#1185: flipping the publishable key while this
was broken would have meant taking real money with nothing recorded.

🤖 Generated with Claude Code

…ference_id

`handlePaymentCheckout` looked the intent up by `session.metadata?.intent_id`, and
`create-stripe-checkout` never sets session-level metadata. It sets
`payment_intent_data.metadata.intent_id` — which lands on the PaymentIntent, not the
Session — and `client_reference_id`. So the lookup was always undefined, the handler always
returned {handled:false}, and the path real buyers take could not be fulfilled.

MEASURED, NOT INFERRED. A real live Checkout Session created by the deployed function on
2026-09-17 came back with `metadata: {}` and
`client_reference_id: 516bf680-e568-445a-98da-6a04a40d9589` — our intent id, present and
correct. Meanwhile `payment_results` held 0 rows for the life of the project and every
historical `payment_intent.succeeded` recorded `related_payment_id: null`, i.e. it too
returned {handled:false} every time. Neither fulfilment path had ever executed successfully.
`verify-stripe-session` is read-only and writes nothing, so there was no third path.

Reading `client_reference_id` as the fallback also repairs sessions created BEFORE this fix,
because it was always being set. Session metadata still wins when genuinely populated, for
forward compatibility.

BOTH HANDLERS CAN NOW WRITE THE SAME ROW, which was impossible while one of them was dead.
idx_payment_results_one_succeeded_per_intent permits ONE succeeded row per intent, so
whichever Stripe event arrives second collides. A 500 there is retried for three days and
counts toward the endpoint being disabled — the exact pressure that nearly cost us the
webhook last night. Both inserts now acknowledge 23505 and still call advanceOrderAndNotify,
which is a compare-and-swap on status and returns 'already-advanced' rather than emailing
the buyer twice.

NOT ADDING SESSION METADATA to create-stripe-checkout. client_reference_id already carries
it, is present on historical sessions, and needs no redeploy of the outbound function.

Verified: 6/6 new, 15/15 with advance-order, 985/985 test:scripts, `deno check` clean via
denoland/deno in Docker. The mutation bites — removing the client_reference_id fallback
fails 3 of 6 tests including the control.

Refs #1184

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@TortoiseWolfe
TortoiseWolfe merged commit 2b07390 into main Sep 18, 2026
26 checks passed
@TortoiseWolfe
TortoiseWolfe deleted the fix/checkout-correlation branch September 18, 2026 10:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Hosted checkout has never been fulfillable: the session is correlated by a field Stripe never receives

2 participants