What happens
In scripts/payments/cleanup-abandoned-payments.ts, expirePendingPayments calls the gateway cancel and, when it throws, records the message and continues past the payment.updateMany({ … EXPIRED }). The enclosing cleanupAbandonedAppointment transaction then still runs restoreReferralCredits, releaseGroupSeats / expireRequestAndReleaseSlots, and returns "cleaned". errorCount only counts thrown exceptions, so the summary says success: true.
Result: the Payment stays PENDING forever (its slot is already released, so nothing about it looks abandoned to a later sweep), any referral credit it consumed is handed back while the payment is still live, and the admin "Pending Payments" figure inflates with rows no sweep will ever heal.
The script's own cancelPaymentIntent builds a raw new Stripe(process.env.STRIPE_SECRET_KEY) and calls paymentIntents.cancel on whatever id the row carries, outside the Stripe fence (#1386) and its test-key guard; the Razorpay arm is a no-op because orders cannot be cancelled. So today every failure is a seeded Stripe row, but the structural gap is gateway-agnostic.
Evidence (deploy-preview-1422, 2026-09-05)
POST /api/cleanup/abandoned-payments → {"cleanedCount":5,"errorCount":0,"success":true} while payments 3077dfa8-…, 98c6d2d5-…, cb4daf25-…, 7fbc4476-…, 6a762605-… remain PENDING with expiresAt: null. Pre-existing on dev; #1461 preserves the skip.
Fix (fold into #1461, which owns this script)
Found by the wave 1A B2C edge-case run of the finance train (plan §8).
What happens
In
scripts/payments/cleanup-abandoned-payments.ts,expirePendingPaymentscalls the gateway cancel and, when it throws, records the message andcontinues past thepayment.updateMany({ … EXPIRED }). The enclosingcleanupAbandonedAppointmenttransaction then still runsrestoreReferralCredits,releaseGroupSeats/expireRequestAndReleaseSlots, and returns"cleaned".errorCountonly counts thrown exceptions, so the summary sayssuccess: true.Result: the Payment stays PENDING forever (its slot is already released, so nothing about it looks abandoned to a later sweep), any referral credit it consumed is handed back while the payment is still live, and the admin "Pending Payments" figure inflates with rows no sweep will ever heal.
The script's own
cancelPaymentIntentbuilds a rawnew Stripe(process.env.STRIPE_SECRET_KEY)and callspaymentIntents.cancelon whatever id the row carries, outside the Stripe fence (#1386) and its test-key guard; the Razorpay arm is a no-op because orders cannot be cancelled. So today every failure is a seeded Stripe row, but the structural gap is gateway-agnostic.Evidence (deploy-preview-1422, 2026-09-05)
POST /api/cleanup/abandoned-payments→{"cleanedCount":5,"errorCount":0,"success":true}while payments3077dfa8-…,98c6d2d5-…,cb4daf25-…,7fbc4476-…,6a762605-…remainPENDINGwithexpiresAt: null. Pre-existing on dev; #1461 preserves the skip.Fix (fold into #1461, which owns this script)
errorCountso the twin answers non-2xx.STRIPE_ENABLED !== "true"; when open,resource_missing/ already-cancelled is "nothing to cancel", not a failure.success: false.Found by the wave 1A B2C edge-case run of the finance train (plan §8).