findReusablePendingOrderPayment (lib/payments/operations/checkout.ts, the candidate query) gates on expiresAt > now, by design ("never resume a stale hold"). A buyer who returns to the exact same slot after their 30-minute window lapsed therefore gets a fresh order (correct), but the lapsed row is never a candidate, so it is neither resumed nor superseded, and releaseSupersededHolds (#1465) does not run for it. The old Payment stays PENDING and its tentative slot rows stay SCHEDULED until the abandoned-payments sweep (the Netlify ticker runs it every 5 minutes in production).
Nothing is blocked or double-charged in the interim: buildDeadHoldFilter already treats the lapsed hold as dead for availability, which is why the new order mints. The cost is a stale tentative row on the consultant's calendar and an unresolved PENDING payment for up to one sweep cycle.
Suggested fix: when the self-hold lookup (findSelfHoldAppointmentIds) finds the buyer's own hold on the exact window whose payment has lapsed, release it through releaseSupersededHolds in the same request (same CAS, same transition helpers) before minting the new order. Keep the freshness gate for resume.
Evidence: wave 3 on deploy-preview-1422 (union ff7cf5d), Payment 5d4dd778-4eef-44e0-b9bc-be9ea81fcfde backdated by SQL, second POST minted order_TYIKJ3UB3LCva1 while the old row stayed PENDING with slot 84e49b8e-072b-4658-b955-0cffc16d4ddc SCHEDULED. The wave's report also claimed the supersede block only flips the payment status; that is wrong on dev — releaseSupersededHolds releases slots and parent requests for fresh-but-mismatched candidates.
findReusablePendingOrderPayment(lib/payments/operations/checkout.ts, the candidate query) gates onexpiresAt > now, by design ("never resume a stale hold"). A buyer who returns to the exact same slot after their 30-minute window lapsed therefore gets a fresh order (correct), but the lapsed row is never a candidate, so it is neither resumed nor superseded, andreleaseSupersededHolds(#1465) does not run for it. The old Payment stays PENDING and its tentative slot rows stay SCHEDULED until the abandoned-payments sweep (the Netlify ticker runs it every 5 minutes in production).Nothing is blocked or double-charged in the interim:
buildDeadHoldFilteralready treats the lapsed hold as dead for availability, which is why the new order mints. The cost is a stale tentative row on the consultant's calendar and an unresolved PENDING payment for up to one sweep cycle.Suggested fix: when the self-hold lookup (
findSelfHoldAppointmentIds) finds the buyer's own hold on the exact window whose payment has lapsed, release it throughreleaseSupersededHoldsin the same request (same CAS, same transition helpers) before minting the new order. Keep the freshness gate for resume.Evidence: wave 3 on deploy-preview-1422 (union ff7cf5d), Payment
5d4dd778-4eef-44e0-b9bc-be9ea81fcfdebackdated by SQL, second POST mintedorder_TYIKJ3UB3LCva1while the old row stayed PENDING with slot84e49b8e-072b-4658-b955-0cffc16d4ddcSCHEDULED. The wave's report also claimed the supersede block only flips the payment status; that is wrong on dev —releaseSupersededHoldsreleases slots and parent requests for fresh-but-mismatched candidates.