Skip to content

[Domain] A declined card fails the order and releases stock, but the buyer can still pay the same PaymentIntent #304

Description

@LazyyVenom

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:

  1. The decline fires payment_failed, so the order becomes failed and its stock and coupon are released.
  2. 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

  1. 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.
  2. 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.
  3. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions