Skip to content

[payments][P1 HIGH] handlePaymentSuccess recovery branches stamp SUCCEEDED without a status CAS, resurrecting an EXPIRED payment and leaking its tentative hold #1439

Description

@teetangh

Reproduced 2026-09-04 on the deploy preview of #1422 (chaos scenario 5, cleanup-versus-capture race).

What happened. A PENDING payment was expired by the abandoned-payments sweep (status EXPIRED, hold released). A later payment.captured for the same order fell into the metadata-validation-failure branch of handlePaymentSuccess (lib/payments/webhooks/handlers.ts ~L355-365; the amount-mismatch branch ~L268-278 has the same shape). Both branches write the REQUIRES_MANUAL_RECOVERY stamp with a plain tx.payment.update({ where: { id } }), so the EXPIRED row was flipped back to SUCCEEDED while its appointment stayed tentative with no path that ever releases the slot. Payments 4113a8f8… and 420a044a… on the shared project show the end state: SUCCEEDED, description REQUIRES_MANUAL_RECOVERY, appointment isTentative=true, no ledger posting.

Why it matters. The seven-helper doctrine (ADR 21) requires every status write to be a compare-and-set in the WHERE clause. A resurrected terminal payment is money recorded without a booking, and the leaked hold blocks the slot forever; reconcile-payment-status does not flag it because it only compares gateway status.

Fix. Scope both recovery writes to where: { id, paymentStatus: PaymentStatus.PENDING } (updateMany) and treat count 0 as "already terminal": record a system error + Sentry warning naming the order and the reason, and return without touching the row. Pin with the pool-1 style mock: an EXPIRED payment receiving a capture with invalid metadata must stay EXPIRED.

Also. The dev replay route app/api/dev/mock-webhook/route.ts ~L176-181 builds metadata with consulteeId where schemas/webhooks/metadata.ts requires userId, so every replay of a first capture takes this branch instead of confirming the booking. One-line key fix.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

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 workingfinancePayments, refunds, earnings, payouts, invoicing, ledgerlaunch: pre-mvpGates launch — money, data, or a failure we would not detectresilienceSystem resilience and fault tolerance

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions