Skip to content

fix(booking): the cancel and reschedule routes move status through the CAS helpers, and cancelled slots are tombstoned - #1383

Merged
teetangh merged 8 commits into
devfrom
fix/lifecycle-routes-cas-helpers
Sep 5, 2026
Merged

fix(booking): the cancel and reschedule routes move status through the CAS helpers, and cancelled slots are tombstoned#1383
teetangh merged 8 commits into
devfrom
fix/lifecycle-routes-cas-helpers

Conversation

@teetangh

@teetangh teetangh commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

What

Wave-6 PR E (umbrella #1319). Found by the 2026-09-03 production cleanup: a user cancel succeeded and wrote no BookingStatusHistory row, and the cancelled slots kept deletedAt = null. The two most-used lifecycle routes were moving request, slot and reschedule status with raw updateMany calls (status re-checked in the WHERE, so a CAS, but outside the helpers), which is why BookingStatusHistory was empty on production.

  • Cancel route. The four request/event writes go through transitionConsultationRequest / transitionSubscriptionRequest / transitionWebinarEvent / transitionClassEvent with the route's own from-sets as fromIn and the session user as actor; IllegalTransitionError maps to the unchanged 409 NOT_CANCELLABLE. The three slot writes collapse to one transitionSlotCompletion to CANCELLED with deletedAt, scoped through fromIn (never completionStatus in the where). The open reschedule proposal is read in the transaction and each is CAS-closed to DECLINED.
  • Reschedule route. The four slot writes go through transitionSlotCompletion to RESCHEDULED with isTentative: true and deliberately no deletedAt (rule 2: a reschedule keeps its rows so a withdrawal can restore them); the four request/event writes go through the helpers; the shared catch answers the unchanged 409 NOT_RESCHEDULABLE. proposedSlots and rescheduleRequest.create are untouched.
  • reschedule-withdraw.ts. Its one raw write (RESCHEDULED → SCHEDULED) is now the helper with allowZero, which is load-bearing there; its existing helper calls gain the actor.
  • Slot sweeps pass no appointmentId on purpose: a whole-subscription cancel would otherwise file sibling appointments' slot history under the appointment the request arrived on; the request/event transitions pass it explicitly (the override fix(booking): every status history row names its appointment, and creation is a row #1378 documents).

Raw status writes: cancel route 8 → 0, reschedule route 8 → 0, withdraw 1 → 0. lib/booking/transitions.ts is untouched.

Verification

Cold tsc clean; __tests__/booking-algorithm + __tests__/booking + __tests__/payments + __tests__/maintenance: 129 suites / 1,724 tests green; prettier clean; eslint per-file counts identical to dev. Pin extended in the existing __tests__/payments/cancel-route-refund.test.ts: a successful consultation cancel writes one CONSULTATION history row (actor, reason, appointment id) plus one per moved slot with deletedAt; a lost CAS still answers 409 with zero history rows. Both fail on pre-change code.

Rebased onto dev at b713e48 (#1378 included); five route assertions loosened to objectContaining because #1378 changed the helper's select. Commit 1 is format-only (reschedule-withdraw.ts was prettier-dirty on dev).

Out of scope, found on the way

transitionSlotCompletion appends history rows in a sequential loop, one INSERT per slot inside the transaction; a whole-class cancel with many attendees would benefit from a createMany. Left for a follow-up on the helper file.

Part of #1319.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

teetangh and others added 3 commits September 3, 2026 18:16
The notification block landed unformatted, so the file fails `prettier
--check` on dev and every later diff carries reflow noise. Format-only;
no behaviour change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7
…CAS helpers

A successful cancel wrote no BookingStatusHistory row and left every
cancelled slot with `deletedAt: null`, which is why that table is empty on
production and why cancelled sessions still read as occupied to every
caller that filters on the tombstone. Between them the two routes and
`reschedule-withdraw` carried 17 raw status writes (8/8/1): each re-checked
the status in its own WHERE, so the compare-and-set was sound, but none of
them appended the audit row or released the calendar.

Every one now goes through `lib/booking/transitions.ts` on the same `tx`.
The WHERE guards survive as `fromIn` plus their non-status predicates, the
sweeps that legitimately match nothing pass `allowZero`, and each route maps
`IllegalTransitionError` back to the code it has always answered — 409
NOT_CANCELLABLE and 409 NOT_RESCHEDULABLE — so no client contract moves.
Only the cancel path tombstones: a rescheduled slot is kept and restored in
place by a withdrawal, so it must not be retired. `transitionRescheduleRequest`
CASes one row by id, so the cancel reads its open proposals inside the tx and
moves each; a proposal answered concurrently by the expiry cron, which holds
no appointment lock, leaves the booking in the state this step wanted anyway.

The slot sweeps pass no appointmentId, so #1333's per-row resolution files
each history row against the appointment the slot actually sits on rather
than the one the request came in on.

The pin extends the suite that already drives this route.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7
@netlify

netlify Bot commented Sep 3, 2026

Copy link
Copy Markdown

Deploy Preview for familiarise ready!

Name Link
🔨 Latest commit dd762e0
🔍 Latest deploy log https://app.netlify.com/projects/familiarise/deploys/6a9c5c1dfc712300080be771
😎 Deploy Preview https://deploy-preview-1383--familiarise.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
Lighthouse
Lighthouse
1 paths audited
Performance: 36 (🔴 down 17 from production)
Accessibility: 90 (no change from production)
Best Practices: 83 (no change from production)
SEO: 90 (🟢 up 8 from production)
PWA: -
View the detailed breakdown and full score reports

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Sep 3, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 55 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available. Your 90 included PR review attempts over the past 7 days set your current allowance at 1 review per hour.

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 204554f0-bff5-4649-8970-1d67aaaeb608

📥 Commits

Reviewing files that changed from the base of the PR and between 01a3773 and dd762e0.

📒 Files selected for processing (11)
  • __tests__/booking-algorithm/authorization.test.ts
  • __tests__/booking-algorithm/reschedule-preference-route.test.ts
  • __tests__/booking-algorithm/reschedule-withdraw-behavior.test.ts
  • __tests__/booking-algorithm/rescheduleCancel.test.ts
  • __tests__/booking-algorithm/rescheduleResponses.test.ts
  • __tests__/payments/cancel-route-refund.test.ts
  • __tests__/payments/refund-preview-parity.test.ts
  • app/api/appointments/[appointmentId]/cancel/route.ts
  • app/api/appointments/[appointmentId]/reschedule/route.ts
  • docs/booking/05-troubleshooting-and-changelog.md
  • lib/booking/reschedule-withdraw.ts

Comment @coderabbitai help to get the list of available commands.

teetangh and others added 2 commits September 5, 2026 23:12
…as-helpers

# Conflicts:
#	docs/booking/05-troubleshooting-and-changelog.md
…weep scope serves both sweeps (#1383)

Behaviour-identical shape-only change to clear the two Sonar issues this PR's
new code raised on `app/api/appointments/[appointmentId]/cancel/route.ts`.

typescript:S3776 — the `prisma.$transaction` callback scored a cognitive
complexity of 20 against a limit of 15. Two blocks move out to module scope,
where their own branching no longer counts against the callback: the
reschedule-proposal decline loop becomes `declineOpenReschedules`, and the
"which rows does this cancel sweep" decision becomes `cancelSweepScope`. The
callback now scores 8.

typescript:S3358 — the nested ternary choosing the slot sweep's WHERE is gone.
It was written out twice, once for `transitionSlotCompletion` and once for
`setParticipantStatus`, and the two had to stay in step; `cancelSweepScope`
is now the single answer and both sweeps read it. The scope is resolved before
the transaction opens, off the same pre-transaction `appointment` row the
ternaries read, so the value is identical.

No status write, WHERE clause, CAS from-set, status code, `code` string or
response body changes. The gate itself is already OK on this head — the
duplicated blocks Sonar lists for the reschedule route, the cancel route and
`scripts/appointments/send-appointment-reminders.ts` are all present verbatim
on `origin/dev`, so after the dev merge they are no longer new code and
`new_duplicated_lines_density` reads 0.0%.

Verification: cold `tsc --noEmit` clean, `eslint` 0 problems, `prettier --check`
clean, and 78 jest suites / 1251 tests pass across the cancel-refund,
refund-preview-parity, booking-algorithm and maintenance suites.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EyngsXG829TRTBof4CSGT1
@teetangh

teetangh commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

Sonar pass on this PR's new code

The quality gate was already OK on the merged head (f27c1fcde)new_duplicated_lines_density reads 0.0% against the 3% threshold, and all five conditions pass. The 16.6% duplication reading came from the pre-merge head 59e1ba7be; the two blocks Sonar still lists in the duplication index exist verbatim on origin/dev, so they are no longer attributed as new code:

Sonar duplication block Verdict
cancel/route.ts 792–807 ⇔ reschedule/route.ts 975–992 (the lock/httpStatus → 409 catch mapping) Not in git diff origin/dev...HEAD. Present on origin/dev unchanged. Left alone.
reschedule/route.ts 778–832 ⇔ scripts/appointments/send-appointment-reminders.ts 67–123 (the plan/participant include shape) Not in this PR's diff either. Left alone, and the reminders job is untouched.

What this commit does fix is the two open issues that genuinely are this PR's new code, both in app/api/appointments/[appointmentId]/cancel/route.ts:

  • typescript:S3776 — the prisma.$transaction callback scored cognitive complexity 20 against a limit of 15. The reschedule-proposal decline loop moved out to a module-level declineOpenReschedules, and the sweep-scope decision to cancelSweepScope; the callback now scores 8.
  • typescript:S3358 — the nested ternary picking the slot sweep's where is gone. It was spelled out twice, for transitionSlotCompletion and for setParticipantStatus, and both now read the one cancelSweepScope(appointment) value, resolved before the transaction opens off the same pre-transaction appointment row.

Shape only: no status write, WHERE clause, CAS from-set, HTTP status, code string or response body changed.

Verification — cold tsc --noEmit clean; eslint 0 problems; prettier --check clean; jest exit 0 with 78 suites / 1251 tests passing across __tests__/payments/cancel-route-refund.test.ts, __tests__/payments/refund-preview-parity.test.ts, __tests__/booking-algorithm and __tests__/maintenance.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EyngsXG829TRTBof4CSGT1

@sonarqubecloud

sonarqubecloud Bot commented Sep 5, 2026

Copy link
Copy Markdown

@teetangh
teetangh merged commit 7a0bf0a into dev Sep 5, 2026
8 checks passed
teetangh added a commit that referenced this pull request Sep 5, 2026
…l it is confirming, and accept serialises on the appointment lock (#1340) (#1515)

## Summary

Issue #1340 is titled as a double-booking race in the reschedule auto-confirm path. That race cannot open: `manualAllocate` writes every slot inside one `prisma.$transaction` under `lockAutoAllocate` plus the consultee lock, with the `slot_no_confirmed_overlap` GiST constraint as the backstop, and `openForAppointmentId` cannot dangle through this path because `DECLINED` is terminal and `transitionRescheduleRequest` clears the reservation on every terminal target. What is live at those exact lines is a different and worse defect, and this PR fixes that.

**The real defect.** `SlotAllocationService.resolveConsumedPreferenceRequests` ends every allocating transaction with a supersede sweep that `DECLINE`s each open `RescheduleRequest` whose `releasedSlotIds` intersect the slots being placed. That sweep is right for a consultant who answers a proposal by placing *different* times by hand. It was wrong for the two confirmation callers, which place the proposal's *own* times:

- `lib/booking/reschedule-auto-confirm.ts:68` and `lib/booking/reschedule-respond.ts:77` both call `SlotAllocationService.allocate({ mode: "manual", slots: <the proposed times>, wideLock: true })`, and only afterwards CAS the proposal `PENDING_REVIEW → AUTO_ACCEPTED` / `→ ACCEPTED`.
- By the time that CAS runs, the sweep has already moved the row to `DECLINED` inside the allocator's transaction, so the `updateMany` matches zero rows and `transitionRescheduleRequest` throws `IllegalTransitionError`.
- The booking has moved either way. `reschedule/route.ts:~727` swallows the error and answers `autoConfirmed: false` to a consultee whose session was in fact rescheduled; the explicit accept rethrows, so the consultee gets a **409 on a booking that moved**, and the MOVED notification in `reschedule-respond.ts` is never reached. The audit trail records a refusal for a request that was granted.

**The fix.** `AllocationRequest` gains an opt-in `excludeRescheduleRequestId`, threaded through `dispatch` into `manualAllocate` and `autoAllocate` and down to the sweep, whose supersede `WHERE` gains `...(excludeId ? { id: { not: excludeId } } : {})`. Both confirmation callers pass their own proposal id. Nothing else in the allocator moves: locks, TTLs and transaction boundaries are untouched, and an allocation that is not confirming a specific proposal still supersedes every open proposal on those slots exactly as before.

**Secondary (the same issue's real concurrency gap).** The accept path took no appointment lock at all. Accept is a lifecycle mutation that moves this appointment's slots, but the allocator's locks are keyed by consultant and by consultee, never by appointment, so an accept and a concurrent cancel of the same booking never contended for anything. `respond/route.ts` now wraps `acceptProposal` in `withAppointmentLock`, the same `appointment-lock:` atom the cancel and reschedule routes take, and maps `AppointmentBusyError` to 423 and `BookingLockUnavailableError` to 503 exactly as `reschedule/route.ts` does. Lock order is unchanged — the appointment atom is the coarsest key and is taken before the allocator acquires its own. Decline is deliberately left outside the lock: it moves nothing.

## Files touched

| File | Change |
|---|---|
| `utils/slotAllocation/types.ts` | `AllocationRequest.excludeRescheduleRequestId?: string` with the why-comment. |
| `utils/slotAllocation/SlotAllocationService.ts` | Threads the id through `dispatch` → `autoAllocate` / `manualAllocate` → `resolveConsumedPreferenceRequests`; the supersede `WHERE` gains `id: { not: … }`. |
| `lib/booking/reschedule-auto-confirm.ts` | Passes `excludeRescheduleRequestId: request.id`. |
| `lib/booking/reschedule-respond.ts` | Passes `excludeRescheduleRequestId: request.id`. |
| `app/api/appointments/[appointmentId]/reschedule/respond/route.ts` | Accept runs inside `withAppointmentLock`; 423 / 503 lock answers. |
| `__tests__/booking-algorithm/reschedule-respond.test.ts` | The pin's caller half (+ the lock case). |
| `__tests__/booking-algorithm/preference-scored-allocation.test.ts` | The pin's allocator half. |
| `docs/booking/07-rescheduling-flow.md`, `docs/booking/18-state-machines.md` | The confirmation sequence and the exclusion rule. |

## Verification

| Check | Result |
|---|---|
| `npx prettier --check` on every changed file | The five code files and `reschedule-respond.test.ts` are clean. `lib/booking/reschedule-respond.ts`, `preference-scored-allocation.test.ts` and both docs still warn, and every remaining hunk is byte-identical to `origin/dev` (pre-existing wrapping in the big `select` payloads, the two markdown tables and unrelated object literals); no added line is unformatted. |
| `npx eslint` on the changed files | 0 errors, 3 warnings — all three are the pre-existing `no-explicit-any` at `SlotAllocationService.ts` dev-lines 3446/3499 and `types.ts` dev-line 147, shifted by this diff and otherwise identical. |
| Cold `tsc --noEmit` (`tsconfig.tsbuildinfo` deleted, 8 GB heap) | exit 0, after the rebase onto `73c0d9f2c`. |
| `npx jest __tests__/booking-algorithm __tests__/booking __tests__/payments/cancel-route-refund.test.ts` | exit 0 — **72 suites, 1132 tests passed**, re-run after the rebase. |

**The pin fails without the fix.** Reverting the five production files to `origin/dev` and re-running the two suites fails 5 assertions:

```
● #1340 — resolveConsumedPreferenceRequests and the confirming proposal › declines a different stale proposal on the same slots but never the excluded one
● accept re-validates through the allocator before anything is written › sends the proposed times through manual allocation under the wide lock
● #1340 — a confirmation keeps the proposal it is confirming › accept names its own proposal to the allocator and then closes it ACCEPTED
● #1340 — a confirmation keeps the proposal it is confirming › auto-confirm names its own proposal to the allocator and then closes it AUTO_ACCEPTED
● the respond route drives the loop for the counterparty › serializes the accept on the appointment lock and answers 423 while it is held
Tests: 5 failed, 55 passed, 60 total
```

Reverting only the supersede `WHERE` (the one-line heart of the fix) fails the allocator case on its own. That case drives the real `resolveConsumedPreferenceRequests` against a transaction stub that answers `findMany` the way Postgres would, so the assertion rides on the `WHERE` the sweep actually issues rather than on a stubbed outcome: with an exclusion in flight only the *other* stale proposal is declined, and with no exclusion both still are.

## Not done

- `app/api/appointments/[appointmentId]/reschedule/route.ts` is untouched: PR #1383 owns that file. Its auto-confirm call at `:~717` therefore still runs **outside** `withAppointmentLock`, so the lock-scope half of the auto-confirm path stays as it is. Worth a follow-up once #1383 lands; the correctness fix above does not depend on it.
- The #1497 limiter rider was not taken.
- No `db push`, no schema change, no notification-workflow change.

Closes #1340
Part of #1433

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01EyngsXG829TRTBof4CSGT1
teetangh added a commit that referenced this pull request Sep 5, 2026
…rg tiers, and a credit-funded partial cancel restores the credit in full (#1499, #1500, #1372) (#1513)

## Summary

**The model (#1499).** Refund terms move out of the `Appointment.cancellationPolicySnapshot` Json column and into typed, versioned rows. `CancellationPolicy` is one published version of a ladder — its scope, version, status, and the percentage a consultant-initiated cancellation settles at — and `CancellationPolicyTier` holds its rungs. `Appointment.cancellationPolicyId` points at the exact row that governed the sale. A published version is **immutable**: editing a ladder archives the current `ACTIVE` row and inserts a new one at the next version, so the freeze the old snapshot provided by convention is now structural, and no code path can rewrite terms a buyer already agreed to.

**Resolution.** Checkout resolves the governing version exactly once, inside the booking transaction, through `resolveCheckoutCancellationPolicyId()`. An organisation's ladder governs the bookings the organisation **funds** — on a refund it is the organisation's money that moves — so the organisation id is passed only on the sponsored path; a personal booking merely tagged to an organisation keeps the platform ladder, as does an organisation that has never published. Sessions allocated later against a subscription **inherit** the version from the row checkout created rather than resolving afresh, which is the whole point of versioning. The platform default lives at a fixed id, is seeded, and is also provisioned idempotently by `ensurePlatformCancellationPolicy()` so a database nobody seeded cannot fail a checkout.

**The credit rule (#1500).** The credits rail cannot pay a fraction — `refundBookingPayment` refuses an `amountPaise` on a `free_` intent — so a partial tier previously had nothing it could pay and escalated to a human. That escalation is replaced by a rule with two halves:

- **Any tier above 0% restores the credit IN FULL**, via `refundBookingPayment` with no `amountPaise`. The buyer gave the notice the ladder rewards, and the rail's inability to divide should not cost them the refund.
- **A 0% tier restores NOTHING**, falling through to the existing `POLICY_ZERO` arm. A late cancel bites a credit buyer exactly as it bites a card buyer; paying a full credit back for a cancellation that earns a card buyer nothing would make free credit strictly better than money and delete the late-cancel deterrent.

The whole rule is one predicate in `quoteBookingRefund` — `isFreeCreditFunded && refundPct > 0` — surfaced as `creditRestoresInFull`. `MANUAL_REVIEW` is gone from the route, the response union, the client and the docs.

## Schema

Additive only.

- **`CancellationPolicy`** — `organizationId` (null = the platform default), `version`, `status` (`CancellationPolicyStatus`: `ACTIVE` | `ARCHIVED`), `consultantInitiatedBps`, `policyText`, `publishedByUserId`, `createdAt`, `archivedAt`. `@@unique([organizationId, version])`, `@@index([organizationId, status])`.
- **`CancellationPolicyTier`** — `policyId`, `hoursBefore`, `refundBps`. `@@unique([policyId, hoursBefore])`, `@@index([policyId])`.
- **`Appointment.cancellationPolicyId String?`** — FK at `onDelete: SetNull`, plus `@@index([cancellationPolicyId])`. `SetNull` because a booking must survive its organisation being torn down; losing the pointer degrades to the platform ladder rather than deleting money history. Null and "sold before this change" are deliberately indistinguishable.
- **`Appointment.cancellationPolicySnapshot Json?`** stays, **frozen**: never written, never read, annotated as such, dropped at the pre-MVP reset. A column a running deploy still reads must not be dropped under it, and this repo writes no backfill migrations.
- Back-relations on `Organization` and `User`; enums declared below the models that use them.
- **Staged, commented** in `prisma/sql/check-constraints.sql`: the `NULLS NOT DISTINCT` partial unique for "one ACTIVE version per scope" and for `(organizationId, version)`. Postgres treats null keys as distinct, so the platform row escapes the Prisma `@@unique` entirely. These stay commented because `check-db-sidecars` strips comments and would demand an unapplied index. Until they land, one active version per scope is enforced by the Serializable rotation in `publishOrgCancellationPolicy()`, and readers order `version desc` and take one so a slip degrades to "newest wins".

**This PR requires `npm run db:push` by the orchestrator after merge**, followed by `npm run db:assert-sidecars`. CI's `check-db-drift` skips new enums and tables, so CI is green before the push; the seed and manual QA need the push first.

## Files touched

**Schema and seed** — `prisma/schema.prisma`, `prisma/sql/check-constraints.sql`, `prisma/seed.ts`, `prisma/seedFiles/16b-create-cancellation-policy.ts` (new).

**Money core** — `lib/payments/operations/cancellation-policy.ts` (stays Prisma-free: `RefundTier`, `CancellationPolicyTerms`, `PLATFORM_DEFAULT_TERMS`, `tiersFromBps`, `validateTierLadder`, `computeRefundPct`, `quoteBookingRefund`), `lib/payments/operations/cancellation-policy-store.ts` (new: the one select shape, the platform provisioner, the resolver, the publish routine), `lib/payments/operations/checkout.ts`, `utils/slotAllocation/SlotAllocationService.ts`.

**The six readers** — `lib/booking/cancellation-scope.ts`, `lib/booking/rejection-refund.ts`, `lib/trials/cancellation.ts`, `lib/payments/operations/event-refunds.ts`, `lib/support/context.ts`, and the two cancel routes (`app/api/appointments/[appointmentId]/cancel/route.ts`, `.../cancel/preview/route.ts`). `parsePolicySnapshot` and `resolveCancellationPolicySnapshot` are deleted; no reader consults the Json column.

**Org editor** — `app/api/organizations/[orgId]/cancellation-policy/route.ts` (new; GET gated on `settings.manage`, PUT on `minimumRole: "OWNER"`, no PATCH or DELETE because a version is immutable), `app/dashboard/organization/[orgId]/settings/CancellationPolicyCard.tsx` (new) wired from `GeneralPanel.tsx` behind `isAtLeast("OWNER")`, `lib/enterprise/audit-actions.ts`.

**Client** — `components/appointments/consultee/useEventActions.ts`.

**Tests** — `__tests__/enterprise/multi-engagement-cap.test.ts` (#1372's three cases, in the existing file), `__tests__/booking/cancellation-policy.test.ts`, `__tests__/payments/{cancel-route-refund,refund-preview-parity,attendee-removal-refund,trial-cancellation-refund,rejection-refund,consultation-atom-parity,checkout-open-order-reuse,allocation-utilization-integrity}.test.ts`, `__tests__/booking-algorithm/{cancellation-scope,allocation-top-up,row-walk-truncation,collaborator-availability-modes,slotAllocationService}.test.ts`.

**Docs** — `docs/booking/08-cancellation-flow.md`, `docs/booking/17-org-funded-checkout.md`, `docs/enterprise/70-design-decisions/28-typed-versioned-cancellation-policy.md` (new) + its index row in `00-README.md`, `.claude/skills/booking/references/money-boundary.md`.

## Verification

| Check | Result |
| --- | --- |
| `npx prisma generate` then cold `npx tsc --noEmit` (`tsconfig.tsbuildinfo` deleted, 8 GB heap) | **exit 0**, no errors |
| `npx eslint` on all 33 changed code files | **0 errors, 0 warnings introduced**. 1 error + 44 warnings remain, all byte-identical at `origin/dev`: `jest/no-mocks-import` and 32 `no-explicit-any` in `slotAllocationService.test.ts`, 9 `no-explicit-any` in `checkout-open-order-reuse.test.ts`, 1 `eqeqeq` in `checkout.ts`, 2 `no-explicit-any` in `SlotAllocationService.ts`. The other 29 files are clean. |
| `npx prettier --check` on every changed file | clean. The 10 files my edits drifted were verified clean at baseline first, then formatted. |
| `npx prisma format` | no change; schema already formatted |
| `npx jest __tests__/booking __tests__/payments __tests__/enterprise __tests__/booking-algorithm/cancellation-scope.test.ts` | **exit 0** — 227 suites passed, 2440 tests passed |

The five allocation suites initially failed (60 tests) because `createAppointments` now reads `tx.appointment.findFirst` to inherit the policy and their transaction mocks did not define it. Fixed by adding `findFirst: jest.fn().mockResolvedValue(null)` to those five mocks — mock plumbing, not a semantic change.

## Limitations / not done

- **Org-funded event seats fall back to the platform ladder.** One shared `Appointment` row serves every registrant of a webinar or class, across every funding organisation, so it cannot carry one buyer's terms; its FK stays null. Whole-event refunds already assumed the platform ladder, so this is consistent, but organisation tiers genuinely do not reach event seats. Reaching them means moving the terms onto the participant row — worth a follow-up issue if it is wanted.
- **A `free_` intent with a non-zero amount is out of scope.** That is a mixed payment; it takes the money arm and is refused `INVALID_AMOUNT`, exactly as today. Both halves of the `isFreeCreditFunded` predicate are load-bearing for this reason.
- **Merge order: land #1383 first.** It rewrites the status writes in the cancel transaction body (~`:39-447`). This PR touches only the import block and the post-transaction refund block, so expect at most one import-block conflict.
- **`docs/booking/05-troubleshooting-and-changelog.md` is deliberately untouched.** Its line about "the only surviving MANUAL_REVIEW path" is now stale and is amended by PR-D's consolidated changelog pass.
- No end-to-end run: that is the orchestrator-announced round, and it needs the schema pushed first.

Closes #1499
Closes #1500
Closes #1372
Part of #1503

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01EyngsXG829TRTBof4CSGT1
@teetangh
teetangh deleted the fix/lifecycle-routes-cas-helpers branch September 5, 2026 23:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant