From 077255ace34f4a2692dc07745739c1ab97729bf1 Mon Sep 17 00:00:00 2001 From: Kaustav Ghosh <44238657+teetangh@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:53:55 +0530 Subject: [PATCH 1/3] chore(booking): the two refunding sweeps are gated in DEGRADED maintenance, and no-show cancels write history (#1506) Gates expire-stale-requests in FINANCIAL_JOB_NAMES alongside detect-consultant-no-shows (#1505), adds a registry pin that greps scripts/** for callers of the four refund front doors and asserts each one's withCronLock name is gated, drops the status override on two more money-twin routes per #1390, and routes the no-show cancel through transitionConsultationRequest so it appends a BookingStatusHistory row (#1493). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EyngsXG829TRTBof4CSGT1 --- .../booking/no-show-refund-front-door.test.ts | 23 +++++-- .../maintenance/cron-lock-registry.test.ts | 42 +++++++++++++ .../no-show-auto-complete-handoff.test.ts | 27 ++++++-- app/api/cleanup/process-payouts/route.ts | 3 +- .../sweep-abandoned-overage-charges/route.ts | 3 +- lib/maintenance-cron.ts | 4 ++ .../detect-consultant-no-shows.ts | 62 +++++++++++++------ 7 files changed, 131 insertions(+), 33 deletions(-) diff --git a/__tests__/booking/no-show-refund-front-door.test.ts b/__tests__/booking/no-show-refund-front-door.test.ts index b6c8cd663..e7da72692 100644 --- a/__tests__/booking/no-show-refund-front-door.test.ts +++ b/__tests__/booking/no-show-refund-front-door.test.ts @@ -54,19 +54,32 @@ jest.mock("../../lib/novu/service", () => ({ notifyRefundProcessed: jest.fn(), })); -jest.mock("../../lib/prisma", () => ({ - __esModule: true, - default: { +// #1493 — claimConsultantNoShow now runs the cancel through +// transitionConsultationRequest inside prisma.$transaction, so the mock needs +// $transaction (running its callback against this same client), +// consultation.findUnique (the helper's pre-read of the from-status), and +// bookingStatusHistory.create (the audit row the helper appends). +jest.mock("../../lib/prisma", () => { + const client: Record = { consultation: { findMany: jest.fn(), updateMany: jest.fn().mockResolvedValue({ count: 1 }), + findUnique: jest.fn().mockResolvedValue({ + status: "APPROVED", + appointment: { id: "appt-1" }, + }), }, slotOfAppointment: { updateMany: jest.fn().mockResolvedValue({ count: 1 }), }, + bookingStatusHistory: { + create: jest.fn().mockResolvedValue({}), + }, $disconnect: jest.fn(), - }, -})); + }; + client.$transaction = jest.fn((fn: (tx: unknown) => unknown) => fn(client)); + return { __esModule: true, default: client }; +}); // #1280 — the detector now corroborates against Stream before refunding, // because our attendance rows come from per-participant webhook deliveries that diff --git a/__tests__/maintenance/cron-lock-registry.test.ts b/__tests__/maintenance/cron-lock-registry.test.ts index 412f11fa7..917ed5283 100644 --- a/__tests__/maintenance/cron-lock-registry.test.ts +++ b/__tests__/maintenance/cron-lock-registry.test.ts @@ -223,6 +223,48 @@ describe("cron lock registry (#1169)", () => { expect(orphaned).toEqual([]); }); + it("gates every refund front-door caller behind FINANCIAL_JOB_NAMES (#1506)", () => { + // A refunding sweep that is not in the set runs straight through DEGRADED + // maintenance, which is the exact bug #1506 fixed for the no-show and + // expiry sweeps. Grep scripts/** for callers rather than trusting a + // hand-maintained list, so a new refunding script fails this test instead + // of shipping unguarded. + const REFUND_FRONT_DOORS = [ + "refundBookingPayment(", + "refundWholeEventPayments(", + "refundRemovedAttendeeSeat(", + "refundPaymentsForExpired(", + ]; + const SCRIPTS_DIR = path.join(ROOT, "scripts"); + + function walk(dir: string): string[] { + const out: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) out.push(...walk(full)); + else if (entry.name.endsWith(".ts")) out.push(full); + } + return out; + } + + const callers = walk(SCRIPTS_DIR).filter((file) => { + const src = read(file); + return !!src && REFUND_FRONT_DOORS.some((fn) => src.includes(fn)); + }); + + expect(callers.length).toBeGreaterThan(0); + + const ungated = callers + .map((file) => { + const lock = findLock(read(file)); + return { file: path.relative(ROOT, file), jobName: lock?.jobName }; + }) + .filter((r) => !r.jobName || !FINANCIAL_JOB_NAMES.has(r.jobName)) + .map((r) => `${r.file} → withCronLock("${r.jobName ?? "none"}")`); + + expect(ungated).toEqual([]); + }); + it("gives every scheduled workflow a queueing concurrency group", () => { // #1413 — a second, redundant guard alongside withCronLock: an overlap // should queue behind the in-flight run at the Actions layer too, not diff --git a/__tests__/maintenance/no-show-auto-complete-handoff.test.ts b/__tests__/maintenance/no-show-auto-complete-handoff.test.ts index 68fc00a29..0e5f7cb10 100644 --- a/__tests__/maintenance/no-show-auto-complete-handoff.test.ts +++ b/__tests__/maintenance/no-show-auto-complete-handoff.test.ts @@ -64,19 +64,29 @@ jest.mock("../../lib/cron/with-cron-lock", () => ({ LONG_JOB_TTL_MS: 35 * 60 * 1000, })); -jest.mock("../../lib/prisma", () => ({ - __esModule: true, - default: { - consultation: { findMany: jest.fn(), updateMany: jest.fn() }, +// #1493 — the no-show detector's claim now runs through +// transitionConsultationRequest inside prisma.$transaction, which needs +// consultation.findUnique (the helper's pre-read) and bookingStatusHistory +// (the audit row it appends) alongside $transaction itself. +jest.mock("../../lib/prisma", () => { + const client: Record = { + consultation: { + findMany: jest.fn(), + updateMany: jest.fn(), + findUnique: jest.fn(), + }, webinar: { findMany: jest.fn(), updateMany: jest.fn() }, class: { findMany: jest.fn(), updateMany: jest.fn() }, subscription: { findMany: jest.fn(), updateMany: jest.fn() }, trialSession: { findMany: jest.fn() }, slotOfAppointment: { findMany: jest.fn(), updateMany: jest.fn() }, supportTicket: { findFirst: jest.fn() }, + bookingStatusHistory: { create: jest.fn() }, $disconnect: jest.fn(), - }, -})); + }; + client.$transaction = jest.fn((fn: (tx: unknown) => unknown) => fn(client)); + return { __esModule: true, default: client }; +}); import prisma from "../../lib/prisma"; import { autoCompleteAppointments } from "../../scripts/appointments/auto-complete-appointments"; @@ -147,6 +157,11 @@ beforeEach(() => { db[model].updateMany?.mockResolvedValue({ count: 1 }); } db.supportTicket.findFirst.mockResolvedValue(null); + db.consultation.findUnique.mockResolvedValue({ + status: "APPROVED", + appointment: { id: "appt-1" }, + }); + db.bookingStatusHistory.create.mockResolvedValue({}); refundBookingPayment.mockResolvedValue({ amountRefundedPaise: 150000, rail: "GATEWAY", diff --git a/app/api/cleanup/process-payouts/route.ts b/app/api/cleanup/process-payouts/route.ts index b67f5acf6..7124b401c 100644 --- a/app/api/cleanup/process-payouts/route.ts +++ b/app/api/cleanup/process-payouts/route.ts @@ -29,6 +29,7 @@ export const { GET, POST } = cleanupRoute({ failed: r.failed, processed: r.processed, }), - status: () => 200, + // #1390 review — the constant 200 masked a caught job error (success:false) + // as healthy; the default statusFor already reads result.success. failureMessage: "Failed to process payouts", }); diff --git a/app/api/cleanup/sweep-abandoned-overage-charges/route.ts b/app/api/cleanup/sweep-abandoned-overage-charges/route.ts index 51a12e45e..f04412690 100644 --- a/app/api/cleanup/sweep-abandoned-overage-charges/route.ts +++ b/app/api/cleanup/sweep-abandoned-overage-charges/route.ts @@ -10,6 +10,7 @@ export const { GET, POST } = cleanupRoute({ job: "sweep-abandoned-overage-charges", run: () => sweepAbandonedOverageCharges(), summarize: (r) => ({ scanned: r.scanned, failed: r.failed }), - status: () => 200, + // #1390 review — the constant 200 masked a caught job error (success:false) + // as healthy; the default statusFor already reads result.success. failureMessage: "Failed to sweep abandoned overage charges", }); diff --git a/lib/maintenance-cron.ts b/lib/maintenance-cron.ts index 9a0163b29..ccef8b4c4 100644 --- a/lib/maintenance-cron.ts +++ b/lib/maintenance-cron.ts @@ -48,6 +48,10 @@ export const FINANCIAL_JOB_NAMES = new Set([ // refundBookingPayment; every job that calls the refund front door belongs // here so DEGRADED maintenance holds it with the other refunding jobs. "detect-consultant-no-shows", + // #1506 — expirePaymentPendingRequests/expireApprovedUnallocatedSubscriptions + // in this job call refundPaymentsForExpired, another refund front-door + // caller that must be held with the rest of the money jobs. + "expire-stale-requests", // Added by the wave-5 sweep: each of these either moves money directly or // mutates the org contract/program state the checkout sponsorship resolver // reads, so a partial deployment can bill against a half-written entitlement. diff --git a/scripts/appointments/detect-consultant-no-shows.ts b/scripts/appointments/detect-consultant-no-shows.ts index ca7286fad..e85604e37 100644 --- a/scripts/appointments/detect-consultant-no-shows.ts +++ b/scripts/appointments/detect-consultant-no-shows.ts @@ -45,7 +45,11 @@ import { notificationScope } from "../../lib/novu/workflows"; import { notificationHref } from "../../lib/novu/resolve-href"; import { refundBookingPayment } from "@/lib/payments/operations/booking-refund"; import { withCronLock } from "@/lib/cron/with-cron-lock"; -import { CANCELLABLE_FROM } from "@/lib/booking/transitions"; +import { + CANCELLABLE_FROM, + transitionConsultationRequest, +} from "@/lib/booking/transitions"; +import { IllegalTransitionError } from "@/lib/enterprise/transitions"; import { NO_SHOW_GRACE_MINUTES, attendedAnySession, @@ -415,26 +419,44 @@ async function claimConsultantNoShow( consultationId: string, appointmentId: string, ): Promise { - const claimed = await prisma.consultation.updateMany({ - where: { id: consultationId, status: { in: CANCELLABLE_FROM } }, - data: { - status: AppointmentStatus.CANCELLED, - cancellationReason: CancellationReason.CONSULTANT_UNAVAILABLE, - cancellationNotes: "#471 consultant no-show — auto-cancelled + refunded", - cancelledAt: new Date(), - }, - }); - if (claimed.count === 0) return false; + try { + await prisma.$transaction(async (tx) => { + // #1493 — route through the CAS helper so this cancel writes a + // BookingStatusHistory row like every other status change; the bare + // updateMany left no timeline entry for the no-show path. + await transitionConsultationRequest(tx, { + where: { id: consultationId }, + to: AppointmentStatus.CANCELLED, + fromIn: CANCELLABLE_FROM, + actorUserId: null, + reason: "#471 consultant no-show — auto-cancelled + refunded", + data: { + cancellationReason: CancellationReason.CONSULTANT_UNAVAILABLE, + cancellationNotes: + "#471 consultant no-show — auto-cancelled + refunded", + cancelledAt: new Date(), + }, + }); - await prisma.slotOfAppointment.updateMany({ - where: { - appointmentId, - completionStatus: { - in: [SlotCompletionStatus.SCHEDULED, SlotCompletionStatus.UNVERIFIED], - }, - }, - data: { completionStatus: SlotCompletionStatus.CANCELLED }, - }); + await tx.slotOfAppointment.updateMany({ + where: { + appointmentId, + completionStatus: { + in: [ + SlotCompletionStatus.SCHEDULED, + SlotCompletionStatus.UNVERIFIED, + ], + }, + }, + data: { completionStatus: SlotCompletionStatus.CANCELLED }, + }); + }); + } catch (error) { + // Zero rows matched means someone else moved it between the scan and this + // claim — the existing skip branch at the call site, unchanged. + if (error instanceof IllegalTransitionError) return false; + throw error; + } return true; } From f474698e67218ebe0bf64a9b36e4125893a33beb Mon Sep 17 00:00:00 2001 From: Kaustav Ghosh <44238657+teetangh@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:54:04 +0530 Subject: [PATCH 2/3] docs(booking): the doctrine text matches the sweeps, and the closure-train changelog is consolidated (#1420) Corrects two stale doctrine paragraphs in .claude/skills/booking/SKILL.md (rule 2's hard-delete claim, superseded by #1380/#1424's soft-cancel; rule 5's counter-example, superseded by #1423's CAS fix), corrects the reschedule section of docs/booking/18-state-machines.md (COUNTERED has no writer, AUTO_ACCEPTED exists), links the slots-and-sessions glossary from docs/booking/README.md, notes the DEGRADED gate for the two refunding sweeps in both cron references, and adds one consolidated changelog section for the 2026-09-05 booking closure train while fixing the stale "only surviving MANUAL_REVIEW path" sentence #1513 obsoletes. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EyngsXG829TRTBof4CSGT1 --- .claude/skills/booking/SKILL.md | 27 ++++++------ .../05-troubleshooting-and-changelog.md | 42 +++++++++++++++---- .../13-cron-jobs-and-background-tasks.md | 4 +- docs/booking/18-state-machines.md | 18 +++++--- docs/booking/README.md | 1 + docs/maintenance/04-cron-jobs-reference.md | 26 ++++++------ 6 files changed, 75 insertions(+), 43 deletions(-) diff --git a/.claude/skills/booking/SKILL.md b/.claude/skills/booking/SKILL.md index 5b36479cd..07602d6b0 100644 --- a/.claude/skills/booking/SKILL.md +++ b/.claude/skills/booking/SKILL.md @@ -87,16 +87,13 @@ B-P1-05, #898). Requests are retired by status: `DELETE `__tests__/payments/appointment-delete-forbidden.test.ts` keeps the six sweep scripts free of the forbidden call shapes. -Be precise about slots rather than absolute, because slot rows are _not_ -uniformly soft-deleted. `cleanup-abandoned-payments` soft-cancels them -(`transitionSlotCompletion` to `CANCELLED` plus `deletedAt`), while -`expire-stale-requests.ts` and `cleanup-tentative-slots.ts` under -`scripts/appointments/` still hard-delete tentative holds — always re-checking -`isTentative: true` in the WHERE at delete time, so a slot confirmed between the -cohort read and the statement is never touched. If you think you need a delete -on an Appointment or a confirmed slot, you are almost certainly wrong: reconcile -in place, as `replaceContiguousSlotRun` does precisely so Stream -`MeetingSession` and `Recording` rows survive. +Slot rows are soft-deleted uniformly as of #1380/#1424: `cleanup-abandoned-payments`, +`expire-stale-requests.ts`, and `cleanup-tentative-slots.ts` all release a +tentative hold the same way, through `transitionSlotCompletion` to `CANCELLED` +with `deletedAt` set in the same call, so the row's history survives the +release. If you think you need a delete on an Appointment or a confirmed slot, +you are almost certainly wrong: reconcile in place, as `replaceContiguousSlotRun` +does precisely so Stream `MeetingSession` and `Recording` rows survive. ### 3. Refunds have exactly two front doors @@ -156,10 +153,12 @@ rows themselves expire from `PENDING` only. The sweep that does refund is a different cohort — `expireApprovedUnallocatedSubscriptions` in `scripts/appointments/expire-stale-requests.ts` calls `refundPaymentsForExpired`, which routes every `SUCCEEDED` payment through `refundBookingPayment`. The -sibling pass in that same file, `expirePaymentPendingRequests`, is the -counter-example rather than the pattern: it flips `APPROVED_PENDING_PAYMENT` to -`EXPIRED` with a bare `updateMany` that carries neither the money predicate nor -the CAS helper. +sibling pass in that same file, `expirePaymentPendingRequests`, is the pattern +rather than a counter-example as of #1423: it flips `APPROVED_PENDING_PAYMENT` +to `EXPIRED` through `transitionConsultationRequest`, with `fromIn: +["APPROVED_PENDING_PAYMENT"]` and the `UNPAID_CONSULTATION` money predicate +repeated inside the CAS `where`, the same two guards this rule requires of any +new sweep. ### 6. There are no backfill migrations diff --git a/docs/booking/05-troubleshooting-and-changelog.md b/docs/booking/05-troubleshooting-and-changelog.md index 45080c523..86212834a 100644 --- a/docs/booking/05-troubleshooting-and-changelog.md +++ b/docs/booking/05-troubleshooting-and-changelog.md @@ -85,6 +85,30 @@ flowchart TD --- +## Changelog: 2026-09-05 — booking closure train + +The 2026-09-05 train closes out a set of dated defects and doctrine drift the wave-6 train left open. Each PR appends its own subsection here. + +### PR — weekly availability rows mean the consultant's local day everywhere, and day segments are half-open (#1512, closes #1343, #1342, #1326, #1348, #1415, #1416) + +`SlotOfAvailabilityWeekly.startDay` is now unambiguously the day the consultant published in their own local calendar, with the UTC weekday always derived from it through the row's frozen `utcOffsetMinutes`. Four surfaces had each answered that question differently: the settings save path shifted the day to the UTC day the converted instant landed on while onboarding stored the local day, so an Asia/Kolkata row starting before 05:30 local walked back one weekday on every re-save; the calendar grid bucketed rows on the viewer's weekday instead of the stored columns, showing an overseas customer a day away from what checkout would accept; a consultant with no profile timezone defaulted to UTC 0 instead of the launch offset with nothing checking a caller who supplied a conflicting one; `splitSlotsByDay` cut day segments at `endOfDay`, silently dropping the last bookable slot of any block published up to local midnight; and the expert page's display merge used a sixty-second adjacency tolerance where booking requires an exact seam. A new `weeklySlotForSave` builder, a new `weekly-projection.ts` generator, a new `weeklyUtcOffset.ts` resolver, half-open day segments, and an exact-adjacency requirement on the display merge fix all six. All four weekly write paths also dual-write the five DST columns computed from the same resolver, as a comment-only schema change with no `db push` required. + +### PR — cancellation terms are typed versioned rows with per-org tiers, and a credit-funded partial cancel restores the credit in full (#1513, closes #1499, #1500, #1372) + +Refund terms move out of the `Appointment.cancellationPolicySnapshot` JSON column and into typed, versioned `CancellationPolicy`/`CancellationPolicyTier` rows, with `Appointment.cancellationPolicyId` pointing at the exact version that governed the sale; publishing a new ladder archives the current `ACTIVE` row and inserts the next version, so a buyer's agreed terms are structurally immutable rather than frozen by convention. Checkout resolves the governing version once, inside the booking transaction, and an organisation's ladder governs only the bookings that organisation funds. Separately, the credits rail cannot pay a fraction of a refund, so a partial-tier cancellation on a credit-funded booking used to escalate to `MANUAL_REVIEW` with nothing paid; any tier above 0% now restores the credit in full instead, and a 0% tier still restores nothing, preserving the late-cancel deterrent. `MANUAL_REVIEW` is gone from the cancel route, its response union, the client, and the docs. + +### PR — the consultant Home badge counts personal requests like the card below it, class cards show their first session, and a trial's Pay Now lands on the branded checkout (#1514, closes #1345, #1346; part of #1429) + +The Pending Requests badge on the consultant Home page counted every `PENDING` consultation and subscription with no org filter, while the "Needs you" card and mini request list on the same screen counted personal rows only, so a consultant who also delivers through an organisation saw up to three different totals for what reads as one cohort; the badge now shares the same personal-scope predicate builders as the card. A class card's displayed date read the class's authoring window (`schedulingPeriodStartsAt`) rather than its first real session, unlike the webinar card beside it; it now reads the earliest live slot on the run's appointments. A trial's second "Pay Now" entry point, added to the appointment detail page after the original branded-checkout redirect was built, sent buyers straight to the raw gateway link instead of the `/checkout/plans/trial/[trialId]` page that names the amount and hold deadline; the decision now lives once in a shared helper that all three call sites use. + +### PR — the allocator no longer declines the reschedule proposal it is confirming, and accept serialises on the appointment lock (#1515, closes #1340) + +Issue #1340 was filed as a double-booking race in the reschedule auto-confirm path, but that race cannot open given the existing transaction and lock structure. The defect that was actually live at those lines: both confirmation callers place the proposal's own times through the allocator and only afterwards flip the proposal's status, but the allocator's own supersede sweep already declines every open reschedule proposal whose released slots intersect the slots being placed — including the very proposal being confirmed. The confirming CAS then matched zero rows and threw, so a consultee whose session had in fact moved was told the confirmation failed. `AllocationRequest` gains an opt-in `excludeRescheduleRequestId` that both confirmation callers pass, which the supersede sweep's `WHERE` now excludes. Separately, the accept path took no appointment lock at all despite mutating the appointment's slots; it now runs inside the same `withAppointmentLock` atom the cancel and reschedule routes take. + +### PR — the two refunding sweeps are gated in DEGRADED maintenance, no-show cancels write history, and the doctrine text matches the sweeps (#1506; part of #1338, #1493, #1420) + +`expire-stale-requests` calls `refundPaymentsForExpired` on its abandoned-subscription cohort, which is a refund front-door caller exactly like every other job already listed in `FINANCIAL_JOB_NAMES`; it now joins that set, alongside `detect-consultant-no-shows`, which #1505 had already added. A new registry test greps `scripts/**/*.ts` for callers of all four refund front doors and asserts each one's `withCronLock` name is gated the same way, so a future refunding script fails the test instead of shipping unguarded. Separately, `detect-consultant-no-shows`'s no-show cancel wrote `Consultation.status` through a bare `updateMany`, which left the booking's audit trail silent on the one status change that closes a paid session with no session delivered; it now runs through `transitionConsultationRequest` inside `prisma.$transaction`, so the cancel appends a `BookingStatusHistory` row like every other guarded transition, and a lost race still surfaces as the same "someone else moved it" skip it always was. Two stale sentences in `.claude/skills/booking/SKILL.md`'s doctrine were also corrected: rule 2 claimed `expire-stale-requests.ts` and `cleanup-tentative-slots.ts` still hard-delete tentative holds, which #1380/#1424 had already replaced with a soft-cancel through `transitionSlotCompletion`; rule 5 named `expirePaymentPendingRequests` as the doctrine's counter-example, which #1423 had already turned into an instance of the pattern it once violated. `docs/booking/18-state-machines.md`'s reschedule section is corrected to note that `COUNTERED` has no writer and `AUTO_ACCEPTED` exists as a second terminal-acceptance state, and `docs/booking/README.md` now links the slots-and-sessions glossary under Core Concepts. + ## Changelog: 2026-09-03 — wave 6 Wave 6 finishes the doctrine cleanups that waves 1-5 started. Each PR appends its own subsection here. @@ -400,15 +424,15 @@ Two infrastructure fixes landed alongside it: a shared SQL sidecar splitter now Docs-only pass shipped as the final PR of the #1169 booking + maintenance productionization train, closing the long-standing booking-docs drift item #1013. No code changed in this entry. -| Change | Area | Description | -| ------------------------------ | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Collaborators de-drift | `docs/collaborators/` | All seven files rewritten against the merged `Collaborator` model (#784): typed permission booleans replacing the JSON override (#768), basis-point shares (#772 B5), pool-based split with floors (#778 §C-2), and the co-host availability guard (AE-2), which is enforced on webinar plans only. | -| Org-funded checkout documented | `docs/booking/17-org-funded-checkout.md` | New page covering the sponsored rail end to end — resolution chain, gateway skip, wallet/engagement debits, inline settlement, internal refunds — previously documented nowhere in this folder. | -| Funding-seam citations | `docs/payments/05-b2c-b2b-funding-seam.md` | Stale file:line references re-verified and corrected. | -| Prompts corpus | `prompts/` | New index README; enterprise shared-setup corrected (deleted three-ledger models replaced by the double-entry journal, seed cohort reconciled with the verification guide); two new booking case files (007 reschedule-response loop for #1162, 008 maintenance-freeze correctness for #1163). | -| Booking doctrine skill | `.claude/skills/booking/SKILL.md` | The subsystem's invariants (CAS transitions, nothing-is-deleted, refund front doors, lock namespaces, sidecars, org scoping, testing recipes) captured for future agents. | -| Cancellation flow rewritten | `docs/booking/08-cancellation-flow.md` | The chapter still taught delete-on-cancel, no authentication and no refunds — the three claims #1013 was raised against. Walkthrough, diagrams, record tables and error contract now match the route: soft-cancel under a CAS guard, participant/privileged/org-admin authorization (#1166), and the automatic policy refund. The #1006 manual-review escalation for partly-consumed subscriptions is gone, replaced by the linear per-session proration that closed it; the only surviving `MANUAL_REVIEW` path is the credit-funded partial-window case (#1161). | -| Lock namespaces corrected | `.claude/skills/booking/SKILL.md` | Rule 4 still listed `trial-slot-booking:` as a live namespace. #1170 retired it: trials take the shared `slot-booking:` atom keys, one key per 30-minute atom of the booked interval. | +| Change | Area | Description | +| ------------------------------ | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Collaborators de-drift | `docs/collaborators/` | All seven files rewritten against the merged `Collaborator` model (#784): typed permission booleans replacing the JSON override (#768), basis-point shares (#772 B5), pool-based split with floors (#778 §C-2), and the co-host availability guard (AE-2), which is enforced on webinar plans only. | +| Org-funded checkout documented | `docs/booking/17-org-funded-checkout.md` | New page covering the sponsored rail end to end — resolution chain, gateway skip, wallet/engagement debits, inline settlement, internal refunds — previously documented nowhere in this folder. | +| Funding-seam citations | `docs/payments/05-b2c-b2b-funding-seam.md` | Stale file:line references re-verified and corrected. | +| Prompts corpus | `prompts/` | New index README; enterprise shared-setup corrected (deleted three-ledger models replaced by the double-entry journal, seed cohort reconciled with the verification guide); two new booking case files (007 reschedule-response loop for #1162, 008 maintenance-freeze correctness for #1163). | +| Booking doctrine skill | `.claude/skills/booking/SKILL.md` | The subsystem's invariants (CAS transitions, nothing-is-deleted, refund front doors, lock namespaces, sidecars, org scoping, testing recipes) captured for future agents. | +| Cancellation flow rewritten | `docs/booking/08-cancellation-flow.md` | The chapter still taught delete-on-cancel, no authentication and no refunds — the three claims #1013 was raised against. Walkthrough, diagrams, record tables and error contract now match the route: soft-cancel under a CAS guard, participant/privileged/org-admin authorization (#1166), and the automatic policy refund. The #1006 manual-review escalation for partly-consumed subscriptions is gone, replaced by the linear per-session proration that closed it. The credit-funded partial-window case (#1161) was the last surviving `MANUAL_REVIEW` path until #1513 replaced it with a rule that restores the credit in full above 0% and nothing at 0%, so there is no `MANUAL_REVIEW` path left in the route at all. | +| Lock namespaces corrected | `.claude/skills/booking/SKILL.md` | Rule 4 still listed `trial-slot-booking:` as a live namespace. #1170 retired it: trials take the shared `slot-booking:` atom keys, one key per 30-minute atom of the booked interval. | --- diff --git a/docs/booking/13-cron-jobs-and-background-tasks.md b/docs/booking/13-cron-jobs-and-background-tasks.md index 4f15edc8a..cc03fd785 100644 --- a/docs/booking/13-cron-jobs-and-background-tasks.md +++ b/docs/booking/13-cron-jobs-and-background-tasks.md @@ -216,7 +216,7 @@ The deferral is bounded. The detector declines candidates it cannot decide — S - **Refunds (PR 2c money fix, audit gap #1)**: every expired consultation/subscription with SUCCEEDED payments is refunded via the booking front door (`refundBookingPayment`, full remaining balance). Failures are counted + logged, never thrown — one bad gateway call must not stall the cohort drain. - APPROVED_PENDING_PAYMENT requests: Bulk `updateMany` to `EXPIRED` and clears `pendingPaymentUrl` to invalidate stale payment links. -**Safety**: Three separate operations (PENDING consultations, PENDING subscriptions, payment-pending requests), each with its own `try/catch`. Uses bulk `updateMany` rather than per-record updates for efficiency. Hourly cadence bounds worst-case hold lifetime at ~49h. +**Safety**: Three separate operations (PENDING consultations, PENDING subscriptions, payment-pending requests), each with its own `try/catch`. Uses bulk `updateMany` rather than per-record updates for efficiency. Hourly cadence bounds worst-case hold lifetime at ~49h. Because two of those operations refund SUCCEEDED payments through `refundPaymentsForExpired`, this job is in `FINANCIAL_JOB_NAMES` and is held during DEGRADED maintenance as well as OFFLINE (#1506). --- @@ -263,7 +263,7 @@ The deferral is bounded. The detector declines candidates it cannot decide — S **Grace window**: A session must have ended at least 120 minutes ago (`NO_SHOW_GRACE_MINUTES = 120`) before a missing consultant is treated as a no-show, so a late join or a delayed Stream participant webhook cannot trigger a false-positive refund. The constant lives in `lib/booking/attendance.ts` alongside the attendance predicate, because the auto-completion job in section a has to honour the same window: it defers a booking in the no-show shape rather than completing it out from under this job (#1504). -**Safety**: The job runs under a fail-closed cron lock. Because it moves money, it refuses to run without a real Redis lock rather than risk a silent unlocked double-run, and `refundPayment`'s refundable-balance guard remains the correctness backstop. +**Safety**: The job runs under a fail-closed cron lock. Because it moves money, it refuses to run without a real Redis lock rather than risk a silent unlocked double-run, and `refundPayment`'s refundable-balance guard remains the correctness backstop. It is also in `FINANCIAL_JOB_NAMES`, so it is held during DEGRADED maintenance as well as OFFLINE (#1506). --- diff --git a/docs/booking/18-state-machines.md b/docs/booking/18-state-machines.md index 46b59588d..8e17291fa 100644 --- a/docs/booking/18-state-machines.md +++ b/docs/booking/18-state-machines.md @@ -49,11 +49,19 @@ publish" is the editor flow. `RescheduleRequestStatus` via `transitionRescheduleRequest`: -- Open states: `PENDING_REVIEW`, `COUNTERED`. -- `ACCEPTED ← [PENDING_REVIEW, COUNTERED]`; `DECLINED ←` open states; - `WITHDRAWN ←` open states (initiator only); `EXPIRED ← [PENDING_REVIEW, - COUNTERED]` (hourly sweep). `openForAppointmentId @unique` enforces at most - one live reschedule per appointment. +- Open state: `PENDING_REVIEW`. `COUNTERED` is still declared in the enum and + in `RESCHEDULE_ALLOWED_FROM`, but no writer ever transitions a row to it — + the counter-round was specified and never built, and `lib/booking/reschedule-proposals.ts` + documents the removal — so treat it as an unreachable edge, not a live state. +- `AUTO_ACCEPTED` is a second terminal-acceptance state alongside `ACCEPTED`, + written by `lib/booking/reschedule-auto-confirm.ts` when the responding + party lets the reschedule window lapse without a reply; it deliberately + carries no allowed-from entry in the map because that helper is the one + caller. +- `ACCEPTED ← [PENDING_REVIEW]`; `DECLINED ←` open state; `WITHDRAWN ←` open + state (initiator only); `EXPIRED ← [PENDING_REVIEW]` (hourly sweep). + `openForAppointmentId @unique` enforces at most one live reschedule per + appointment. - Decline/withdraw deliberately LEAVE slots released (the booking belongs in the consultant's allocate queue); only withdrawal restores them. diff --git a/docs/booking/README.md b/docs/booking/README.md index d7058816a..1d7a45cfb 100644 --- a/docs/booking/README.md +++ b/docs/booking/README.md @@ -36,6 +36,7 @@ graph TD - **Sunday-to-Saturday weeks** -- `SlotCalculationService.countWeeks()` is the single source of truth - **`isTentative` flag** -- marks slots pending payment or reschedule; cleaned up by cron after 24 hours (`TENTATIVE_EXPIRATION_HOURS = 24`, reduced from 7 days by #833); users can self-release via `DELETE /api/checkout/pending/[paymentId]` (#849) - **`startDay`/`endDay` DayOfWeek enum + `startTimeUtc`/`endTimeUtc` Int** -- source of truth for weekly availability (minutes since midnight UTC, 0-1439; supports overnight/cross-midnight slots) +- The canonical definitions of "slot" and "session" and the other terms this page uses live in [`docs/enterprise/00-foundations/07-slots-sessions-glossary.md`](../enterprise/00-foundations/07-slots-sessions-glossary.md), which this document assumes rather than restates. ## Reading the audit trail diff --git a/docs/maintenance/04-cron-jobs-reference.md b/docs/maintenance/04-cron-jobs-reference.md index b22cee063..85aec8d47 100644 --- a/docs/maintenance/04-cron-jobs-reference.md +++ b/docs/maintenance/04-cron-jobs-reference.md @@ -61,19 +61,19 @@ Every scheduled workflow appears exactly once, grouped by the part of the produc These jobs move bookings through their lifecycle and hand back the slots that nobody paid for. They are the fleet's most visible half: when one of them stops, consultees see availability that does not exist and consultants see sessions that never close. -| Workflow | Schedule (UTC) | Entrypoint | Lock | Financial | Mutates | During maintenance | -| -------------------------------------------------------------------------------- | --------------- | ----------------------------------------------------------------------------------------------------------------------------- | ------ | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | -| **Auto-Complete Appointments**
`auto-complete-appointments` | `7 * * * *` | `jobs/appointments/auto-complete-appointments.ts`
→ `scripts/appointments/auto-complete-appointments.ts` | open | no | `Consultation`, `Subscription`, `Class`, `TrialSession` → COMPLETED; `ActivityLog`; Novu. A consultation the consultant never joined is deferred to the no-show detector instead of completed (#1504) | Skips: OFFLINE | -| **Cleanup Invalid Appointments**
`cleanup-invalid-appointments` | `12 * * * *` | `jobs/appointments/cleanup-invalid-appointments.ts`
→ `scripts/appointments/cleanup-invalid-appointments.ts` | open | no | Duplicate and invalid `Consultation`/`Subscription` cancelled, their slots released | Skips: OFFLINE | -| **Cleanup Stale Pending Consultations**
`cleanup-stale-pending-consultations` | `37 * * * *` | `jobs/appointments/cleanup-stale-pending-consultations.ts`
→ `scripts/appointments/cleanup-stale-pending-consultations.ts` | open | no | Stale PENDING `Consultation` cancelled, reserved slots released | Skips: OFFLINE | -| **Cleanup Tentative Slots**
`cleanup-tentative-slots` | `38 */2 * * *` | `jobs/appointments/cleanup-tentative-slots.ts`
→ `scripts/appointments/cleanup-tentative-slots.ts` | open | no | Tentative `SlotOfAppointment` reservations released | Skips: OFFLINE | -| **Detect Consultant No-Shows**
`detect-consultant-no-shows` | `57 * * * *` | `jobs/appointments/detect-consultant-no-shows.ts`
→ `scripts/appointments/detect-consultant-no-shows.ts` | closed | yes | `Consultation` cancelled and refunded in full through `refundBookingPayment`, slot release, Novu notifications. Auto-complete hands these bookings over rather than racing it (#1504) | Skips: OFFLINE | -| **Expire Reschedule Proposals**
`expire-reschedule-proposals` | `45 * * * *` | `jobs/appointments/expire-reschedule-proposals.ts`
→ `scripts/appointments/expire-reschedule-proposals.ts` | open | no | Expired `RescheduleRequest` proposals | Skips: OFFLINE | -| **Expire Stale Requests**
`expire-stale-requests` | `20 1 * * *` | `jobs/appointments/expire-stale-requests.ts`
→ `scripts/appointments/expire-stale-requests.ts` | closed | no | Stale `Consultation` and `Subscription` requests expired, refunding SUCCEEDED payments through the refund front door | Skips: OFFLINE | -| **Expire Unpaid Trials**
`expire-unpaid-trials` | `40 * * * *` | `jobs/trials/expire-unpaid-trials.ts`
→ `scripts/trials/expire-unpaid-trials.ts` | open | no | `TrialSession` expiry, which frees the held trial slot | Skips: OFFLINE | -| **Reconcile Orphaned Meeting Sessions**
`reconcile-orphaned-sessions` | `25,55 * * * *` | `jobs/meetings/reconcile-orphaned-sessions.ts` | open | no | `MeetingSession` closure and slot state, reconciled against Stream calls | Skips: OFFLINE | -| **Reconcile Slot Availability**
`reconcile-slot-availability` | `32 * * * *` | `jobs/appointments/reconcile-slot-availability.ts`
→ `scripts/appointments/reconcile-slot-availability.ts` | open | no | `SlotOfAppointment` availability re-derived from live bookings | Skips: OFFLINE | -| **Send Appointment Reminders**
`send-appointment-reminders` | `47 * * * *` | `jobs/appointments/send-appointment-reminders.ts`
→ `scripts/appointments/send-appointment-reminders.ts` | open | no | Reads only; sends Novu reminders behind a Redis dedup key | Skips: OFFLINE | +| Workflow | Schedule (UTC) | Entrypoint | Lock | Financial | Mutates | During maintenance | +| -------------------------------------------------------------------------------- | --------------- | ----------------------------------------------------------------------------------------------------------------------------- | ------ | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | +| **Auto-Complete Appointments**
`auto-complete-appointments` | `7 * * * *` | `jobs/appointments/auto-complete-appointments.ts`
→ `scripts/appointments/auto-complete-appointments.ts` | open | no | `Consultation`, `Subscription`, `Class`, `TrialSession` → COMPLETED; `ActivityLog`; Novu. A consultation the consultant never joined is deferred to the no-show detector instead of completed (#1504) | Skips: OFFLINE | +| **Cleanup Invalid Appointments**
`cleanup-invalid-appointments` | `12 * * * *` | `jobs/appointments/cleanup-invalid-appointments.ts`
→ `scripts/appointments/cleanup-invalid-appointments.ts` | open | no | Duplicate and invalid `Consultation`/`Subscription` cancelled, their slots released | Skips: OFFLINE | +| **Cleanup Stale Pending Consultations**
`cleanup-stale-pending-consultations` | `37 * * * *` | `jobs/appointments/cleanup-stale-pending-consultations.ts`
→ `scripts/appointments/cleanup-stale-pending-consultations.ts` | open | no | Stale PENDING `Consultation` cancelled, reserved slots released | Skips: OFFLINE | +| **Cleanup Tentative Slots**
`cleanup-tentative-slots` | `38 */2 * * *` | `jobs/appointments/cleanup-tentative-slots.ts`
→ `scripts/appointments/cleanup-tentative-slots.ts` | open | no | Tentative `SlotOfAppointment` reservations released | Skips: OFFLINE | +| **Detect Consultant No-Shows**
`detect-consultant-no-shows` | `57 * * * *` | `jobs/appointments/detect-consultant-no-shows.ts`
→ `scripts/appointments/detect-consultant-no-shows.ts` | closed | yes | `Consultation` cancelled and refunded in full through `refundBookingPayment`, slot release, Novu notifications. Auto-complete hands these bookings over rather than racing it (#1504) | Skips: OFFLINE + DEGRADED | +| **Expire Reschedule Proposals**
`expire-reschedule-proposals` | `45 * * * *` | `jobs/appointments/expire-reschedule-proposals.ts`
→ `scripts/appointments/expire-reschedule-proposals.ts` | open | no | Expired `RescheduleRequest` proposals | Skips: OFFLINE | +| **Expire Stale Requests**
`expire-stale-requests` | `20 1 * * *` | `jobs/appointments/expire-stale-requests.ts`
→ `scripts/appointments/expire-stale-requests.ts` | closed | yes | Stale `Consultation` and `Subscription` requests expired, refunding SUCCEEDED payments through the refund front door | Skips: OFFLINE + DEGRADED | +| **Expire Unpaid Trials**
`expire-unpaid-trials` | `40 * * * *` | `jobs/trials/expire-unpaid-trials.ts`
→ `scripts/trials/expire-unpaid-trials.ts` | open | no | `TrialSession` expiry, which frees the held trial slot | Skips: OFFLINE | +| **Reconcile Orphaned Meeting Sessions**
`reconcile-orphaned-sessions` | `25,55 * * * *` | `jobs/meetings/reconcile-orphaned-sessions.ts` | open | no | `MeetingSession` closure and slot state, reconciled against Stream calls | Skips: OFFLINE | +| **Reconcile Slot Availability**
`reconcile-slot-availability` | `32 * * * *` | `jobs/appointments/reconcile-slot-availability.ts`
→ `scripts/appointments/reconcile-slot-availability.ts` | open | no | `SlotOfAppointment` availability re-derived from live bookings | Skips: OFFLINE | +| **Send Appointment Reminders**
`send-appointment-reminders` | `47 * * * *` | `jobs/appointments/send-appointment-reminders.ts`
→ `scripts/appointments/send-appointment-reminders.ts` | open | no | Reads only; sends Novu reminders behind a Redis dedup key | Skips: OFFLINE | Two of these jobs read the same cohort and must not race. **Auto-Complete Appointments** at :07 and **Detect Consultant No-Shows** at :57 both scan `APPROVED` and `SCHEDULED` consultations whose slots have ended, and only the detector can cancel and refund a session the consultant never joined. Since #1504 they share one attendance predicate in `lib/booking/attendance.ts`: auto-complete defers a booking in the no-show shape until the detector has had its runs, and completes it after `NO_SHOW_HANDOFF_MINUTES` so that a booking the detector declined is never left live forever. From 7a4e8c7eef0d1f815fc8f39eabbe5bea486e318d Mon Sep 17 00:00:00 2001 From: Kaustav Ghosh <44238657+teetangh@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:58:45 +0530 Subject: [PATCH 3/3] docs(maintenance): the financial-list summary count matches the set it summarises (#1506) The summary table said twenty jobs were on the financial list while the set already held twenty-two before this PR and holds twenty-four after it; the number is recounted from FINANCIAL_JOB_NAMES. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EyngsXG829TRTBof4CSGT1 --- docs/maintenance/04-cron-jobs-reference.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/maintenance/04-cron-jobs-reference.md b/docs/maintenance/04-cron-jobs-reference.md index 85aec8d47..86f158157 100644 --- a/docs/maintenance/04-cron-jobs-reference.md +++ b/docs/maintenance/04-cron-jobs-reference.md @@ -40,7 +40,7 @@ GitHub Actions is unchanged by this: it still owns every daily and weekly busine | — fail-open | 34 | | Locked by a bespoke Redis lock | 2 | | Deliberately unlocked | 2 | -| On the financial list | 20 | +| On the financial list | 24 | | Without an `abortIfMaintenance()` guard | 3 | ## How to read the tables