Summary
actions/maintenance/freeze-appointments.ts — the code that cancels every appointment overlapping an OFFLINE maintenance window — predates the booking subsystem's soft-cancel and CAS-transition doctrine and violates it on every count. Its last substantive change was 2026-02-27 (6c8d22cd); the doctrine it now violates landed between June and August (lib/booking/transitions.ts created 2026-06-11, slot-level soft-cancel via #1081/#1091 on 2026-08-01).
The three defects
1. It hard-deletes appointments that hold PENDING payments, cascade-destroying the Payment row. The payment include is filtered to paymentStatus: "SUCCEEDED" (freeze-appointments.ts:148-149), and the deletion guard is appointment.payment.length === 0 (:371-372). An appointment whose only payment is PENDING — a buyer mid-Razorpay-checkout — therefore looks payment-free and is deleted; Payment.appointment is onDelete: Cascade, so the capture webhook arrives to find no payment row. This is exactly the defect class #1009/PR #1074 eliminated for trials, still live here. The comment at :365-367 believes appointments with payments are kept alive; the SUCCEEDED filter betrays it.
2. It refunds the gross amount through the wrong front door. :390-394 calls createRefund({ amount: payment.amount }) directly, bypassing refundBookingPayment and its refundable-balance clamp — a partially-refunded payment gets over-refunded or rejected — and createRefund cannot route org_* (or free_) intents, so every org-funded booking in the window throws UNKNOWN_GATEWAY with no wallet, ledger, or seat reversal.
3. It skips every CAS guard and hard-deletes slots. Raw tx.consultation.update / subscription / webinar / class / trialSession writes (:222-336) carry no CANCELLABLE_FROM predicate, so a freeze can resurrect a COMPLETED consultation to CANCELLED and refund it in full; :361-363 hard-deletes slots against the documented "Nothing is deleted" invariant (docs/booking/08-cancellation-flow.md:33). It also never closes open RescheduleRequest rows, so openForAppointmentId stays reserved forever on frozen bookings. Because refundWholeEventPayments is explicitly not idempotent (lib/payments/operations/event-refunds.ts:40-43 — "a second call would double-refund") and the freeze bypasses the CAS that callers rely on, a freeze followed by a manual cancel of the same event double-refunds every attendee.
Fix direction
Rewrite the freeze path on the doctrine: CAS transitions via lib/booking/transitions.ts, slot soft-cancel (completionStatus: "CANCELLED"), refunds via refundBookingPayment, close open reschedule requests, and bound the scan (it currently loads every slot in the window through a 5-level include inside a single 60-second transaction). Add a doctrine jest suite: a PENDING-payment appointment survives a freeze, refunds are clamped, COMPLETED cannot resurrect, and a second freeze run is a no-op. Lands in the booking + maintenance productionization train (wave 1, refund-doctrine PR).
Summary
actions/maintenance/freeze-appointments.ts— the code that cancels every appointment overlapping an OFFLINE maintenance window — predates the booking subsystem's soft-cancel and CAS-transition doctrine and violates it on every count. Its last substantive change was 2026-02-27 (6c8d22cd); the doctrine it now violates landed between June and August (lib/booking/transitions.tscreated 2026-06-11, slot-level soft-cancel via #1081/#1091 on 2026-08-01).The three defects
1. It hard-deletes appointments that hold PENDING payments, cascade-destroying the Payment row. The
paymentinclude is filtered topaymentStatus: "SUCCEEDED"(freeze-appointments.ts:148-149), and the deletion guard isappointment.payment.length === 0(:371-372). An appointment whose only payment is PENDING — a buyer mid-Razorpay-checkout — therefore looks payment-free and is deleted;Payment.appointmentisonDelete: Cascade, so the capture webhook arrives to find no payment row. This is exactly the defect class #1009/PR #1074 eliminated for trials, still live here. The comment at:365-367believes appointments with payments are kept alive; the SUCCEEDED filter betrays it.2. It refunds the gross amount through the wrong front door.
:390-394callscreateRefund({ amount: payment.amount })directly, bypassingrefundBookingPaymentand its refundable-balance clamp — a partially-refunded payment gets over-refunded or rejected — andcreateRefundcannot routeorg_*(orfree_) intents, so every org-funded booking in the window throwsUNKNOWN_GATEWAYwith no wallet, ledger, or seat reversal.3. It skips every CAS guard and hard-deletes slots. Raw
tx.consultation.update/subscription/webinar/class/trialSessionwrites (:222-336) carry noCANCELLABLE_FROMpredicate, so a freeze can resurrect a COMPLETED consultation to CANCELLED and refund it in full;:361-363hard-deletes slots against the documented "Nothing is deleted" invariant (docs/booking/08-cancellation-flow.md:33). It also never closes openRescheduleRequestrows, soopenForAppointmentIdstays reserved forever on frozen bookings. BecauserefundWholeEventPaymentsis explicitly not idempotent (lib/payments/operations/event-refunds.ts:40-43— "a second call would double-refund") and the freeze bypasses the CAS that callers rely on, a freeze followed by a manual cancel of the same event double-refunds every attendee.Fix direction
Rewrite the freeze path on the doctrine: CAS transitions via
lib/booking/transitions.ts, slot soft-cancel (completionStatus: "CANCELLED"), refunds viarefundBookingPayment, close open reschedule requests, and bound the scan (it currently loads every slot in the window through a 5-level include inside a single 60-second transaction). Add a doctrine jest suite: a PENDING-payment appointment survives a freeze, refunds are clamped, COMPLETED cannot resurrect, and a second freeze run is a no-op. Lands in the booking + maintenance productionization train (wave 1, refund-doctrine PR).