Skip to content

Maintenance freeze predates the soft-cancel doctrine: hard-deletes PENDING-payment appointments, refunds gross, skips every CAS #1162

Description

@teetangh

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).

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 workingcriticalCritical priority itemslaunch: pre-mvpGates launch — money, data, or a failure we would not detectproductionProduction deployment and readiness

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions