Problem
A payment_intent.payment_failed webhook fails the order and releases its stock and coupon (packages/domain/src/orders/settle-order.ts:124-133: markFailed, then releaseAll, then couponStore.releaseByOrder).
But the pay page keeps the same PaymentIntent. sites/staging/src/pages/checkout/pay.astro mounts Elements with the order's clientSecret (:130, :349) and calls confirmPayment again (:394) when the buyer retries with another card.
If the buyer's first card is declined and the second succeeds on the same PaymentIntent:
- The decline fires
payment_failed, so the order becomes failed and its stock and coupon are released.
- The success fires
payment_intent.succeeded for an order that is already failed. It lands as PAID_FLIP_LOST (settle-order.ts:197-213): the order is flagged for reconciliation, but the buyer has been charged, and their stock may already have gone to someone else.
What's confirmed and what isn't
- Confirmed in code: the fail-and-release on
payment_failed, and the pay page reusing the client secret.
- Not yet confirmed: that Stripe sends
payment_failed for each declined attempt while the PaymentIntent stays payable (status requires_payment_method). That's Stripe's documented behaviour, but it hasn't been reproduced against this code.
Declines are routine (insufficient funds, 3DS abandoned, typos), so if confirmed this would hit real buyers.
Proposed direction (needs an ADR)
Treat payment_failed as informational: record the event and keep the order pending. Let the existing order-expiry sweep (expire-orders in packages/plugin/src/cron/sweeps.ts:300) release stock if nobody pays. This changes the order lifecycle, so it needs an ADR, updated contract tests on all four storage tiers, and a decision on whether the expiry window works for buyers. #26 (whether to email the buyer on payment failure) depends on this decision.
Prerequisite
#286: enable the in-process payment contract cases first.
Validation
- Reproduce first. In Stripe test mode on a non-production deployment, pay with
4000 0000 0000 0002 (declined), then 4242 4242 4242 4242 on the same pay page. Record the order state and timeline.
- After the fix, a domain contract test for "
payment_failed then succeeded on one order": it ends paid, stock is committed once, and there's no reconciliation flag. It must run on in-memory, SQLite, Postgres and D1.
- An order whose payment fails and is never retried still releases its stock when it expires.
Found in a read-only audit of main @ 3264354.
Problem
A
payment_intent.payment_failedwebhook fails the order and releases its stock and coupon (packages/domain/src/orders/settle-order.ts:124-133:markFailed, thenreleaseAll, thencouponStore.releaseByOrder).But the pay page keeps the same PaymentIntent.
sites/staging/src/pages/checkout/pay.astromounts Elements with the order'sclientSecret(:130,:349) and callsconfirmPaymentagain (:394) when the buyer retries with another card.If the buyer's first card is declined and the second succeeds on the same PaymentIntent:
payment_failed, so the order becomesfailedand its stock and coupon are released.payment_intent.succeededfor an order that is alreadyfailed. It lands asPAID_FLIP_LOST(settle-order.ts:197-213): the order is flagged for reconciliation, but the buyer has been charged, and their stock may already have gone to someone else.What's confirmed and what isn't
payment_failed, and the pay page reusing the client secret.payment_failedfor each declined attempt while the PaymentIntent stays payable (statusrequires_payment_method). That's Stripe's documented behaviour, but it hasn't been reproduced against this code.Declines are routine (insufficient funds, 3DS abandoned, typos), so if confirmed this would hit real buyers.
Proposed direction (needs an ADR)
Treat
payment_failedas informational: record the event and keep the orderpending. Let the existing order-expiry sweep (expire-ordersinpackages/plugin/src/cron/sweeps.ts:300) release stock if nobody pays. This changes the order lifecycle, so it needs an ADR, updated contract tests on all four storage tiers, and a decision on whether the expiry window works for buyers. #26 (whether to email the buyer on payment failure) depends on this decision.Prerequisite
#286: enable the in-process payment contract cases first.
Validation
4000 0000 0000 0002(declined), then4242 4242 4242 4242on the same pay page. Record the order state and timeline.payment_failedthensucceededon one order": it endspaid, stock is committed once, and there's no reconciliation flag. It must run on in-memory, SQLite, Postgres and D1.Found in a read-only audit of
main@ 3264354.