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
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.capturedfor the same order fell into the metadata-validation-failure branch ofhandlePaymentSuccess(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 plaintx.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
consulteeIdwhere schemas/webhooks/metadata.ts requiresuserId, 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