Skip to content

[EPIC] Split the two booking god modules by extraction: SlotAllocationService (4,357 LOC) and checkout.ts (3,808 LOC) #1375

Description

@teetangh

Part of #1332 (architecture tracker) and #1319 (wave-5 umbrella, §4 "U2 god-module split (own epic after PR 2)"). This issue is the plan that #1332 §4 row 1 and #1319 §2 "UPDATE" both defer to. It is post-MVP and not scheduled. Nothing here changes behaviour; every step is a move plus a facade, with the existing suites as the oracle.

#1332 records the decisions (what things should be called, where folders should live). This issue records the mechanics for exactly two files: the measured section map, the caller inventory, the target tree, the order of extraction, and the traps. Where the two overlap, #1332 wins on naming and this issue defers to it; renames are explicitly excluded from every step below so that a rename never rides along with a move.

1. Scope measurement

Measured on dev at 5f057bc02 on 2026-09-03, after the whole wave-5 train (#1321 through #1337) had merged. Every file in the requested scope that exceeds 1,000 lines is listed below.

File LOC Band
utils/slotAllocation/SlotAllocationService.ts 4,357 giant
lib/payments/operations/checkout.ts 3,808 giant
lib/payments/webhooks/handlers.ts 2,270 over 1,500
lib/payments/operations/refund.ts 1,717 over 1,500
components/scheduling/UnifiedCalendar.tsx 1,667 over 1,500
hooks/scheduling/useSlotAllocation.ts 1,460 over 1,000
components/dashboard/shared/requests/RequestSlotAllocationTab.tsx 1,454 over 1,000
utils/slotAllocation/SlotValidationService.ts 1,190 over 1,000

For context, here are the folder totals the two giants sit inside, so the concentration is visible rather than asserted.

Folder Files Total LOC Largest file Largest file's share
utils/slotAllocation/ 11 7,731 SlotAllocationService.ts 56%
lib/payments/operations/ 10 8,271 checkout.ts 46%
lib/payments/webhooks/ 3 2,520 handlers.ts 90%
app/api/slots/**/route.ts 12 4,005 availability-with-allocation/[consultantId]/route.ts (768) 19%
components/scheduling/ 8 2,601 UnifiedCalendar.tsx 64%
hooks/scheduling/ 4 2,577 useSlotAllocation.ts 57%
components/dashboard/shared/requests/ 2 1,612 RequestSlotAllocationTab.tsx 90%

app/api/checkout/route.ts is 246 lines and is not a problem: it is a thin caller that validates, replays an idempotency key and delegates. That is the shape every other entry point should end up with, and it is worth saying out loud that the route layer is already fine. The god modules are behind it.

Three files just outside the requested scope also cross 1,000 lines and belong in the same conversation, so they are recorded here rather than discovered later: app/api/appointments/[appointmentId]/reschedule/route.ts (1,004), utils/timeSlotsProcessing.ts (988, just under) and app/api/appointments/[appointmentId]/cancel/route.ts (792, just under). The reschedule route is the one genuine counter-example to "the route layer is already fine", and it is listed as a follow-on in §8.

Both giants grew during wave 5. #1319 and #1332 quote them at 3,901 and 3,666 lines respectively. They are now 4,357 and 3,808, so the allocator gained 456 lines and checkout gained 142 in a single train. The split is not competing with a static target, which is the main argument for giving it a freeze window rather than interleaving it with feature work.

Only the two giants are planned in detail here. handlers.ts, refund.ts and UnifiedCalendar.tsx are named as follow-ons in §8 because they are large but structurally much healthier: each already has a flat list of named top-level functions rather than one 1,500-line function.

2. utils/slotAllocation/SlotAllocationService.ts

2.1 Section map

One class, 39 static methods, 4,357 lines. The table below gives every method with its line range, its size, the number of #NNNN issue references in that range (the patch-density proxy) and what it actually does. The cluster column is the responsibility the method belongs to, which is not the order the file puts them in.

Lines LOC #NNNN refs Cluster Member and purpose
105-141 37 1 entry AppointmentWithSlots, SLOT_DURATION_MS, MAX_CANDIDATE_STARTS_PER_ROW, AllocationWalkContext
147-207 61 2 entry allocate: the one public entry; wraps dispatch, fires the placement notification, classifies thrown errors into an AllocationResult
208-265 58 0 entry dispatch: mode router (AUTO / MANUAL / USE_REQUESTED)
533-557 25 2 entry isModeledOutcome: decides whether an error is an expected refusal or a fault, for Sentry level
583-650 68 5 entry classifyError: maps every thrown type to errorCode plus HTTP status
266-532 267 3 notifications notifyAllocationPlaced: fire-and-forget Novu fan-out to both parties, partial-allocation aware
651-700 50 1 identity getConsultantProfileId: resolves the owning consultant for any of the four event types
701-748 48 1 identity getConsulteeUserId: resolves the booking consultee for the consultee lock
4341-4353 13 0 identity getAppointmentType: EventType to AppointmentsType
4354-4357 4 0 identity getEventRelationField: event type to Prisma relation name
558-582 25 0 validation assertCollaboratorsFree: the AE-2 co-host guard
749-765 17 0 guards guardInitialAllocationInTx: in-transaction advisory guard for ADR B10
766-795 30 1 guards assertNoConfirmedSlots: refuses to reallocate over confirmed rows
796-819 24 1 guards assertExpectedTentativeSlotCount: optimistic count check outside the tx
820-860 41 2 guards assertExpectedTentativeSlotCountInTx: the same check as a CAS-style precondition inside the tx
861-916 56 1 idempotency findIdempotentAllocation: returns the prior batch for a repeated key
917-968 52 0 idempotency replayPartialCounts: reconstructs placed/required/unplaced for a replay
969-983 15 0 idempotency releasedSlotIdsOf: slot ids freed by a replayed attempt
984-1010 27 1 preference openPreferenceRequestWhere: the open-reschedule-preference predicate
1011-1044 34 0 preference findAllocationPreference: loads the consultee's preferred window
1045-1104 60 0 preference resolveConsumedPreferenceRequests: declines superseded preference requests via the CAS helper
1105-1544 440 22 modes autoAllocate: acquires auto-allocate: + consultee-booking:, searches, commits, releases
1545-1982 438 17 modes manualAllocate: same lock envelope for consultant-chosen times, with the wide-lock variant
1983-2268 286 8 modes useRequestedSlots: same envelope for approving consultee-stored times
2269-2317 49 1 search isWithinAvailability: is a candidate start inside published cover
2318-2373 56 3 search candidateStartsInRow: the bounded 30-minute walk over one availability row
2374-2415 42 0 search reportRowWalkTruncated: deduplicated truncation telemetry (#1194)
2416-2455 40 1 search buildConsecutiveBlock: assembles N contiguous atoms from a start
2456-2542 87 2 search bestFittingBlockInRow: preference-scored best block in one row
2543-2635 93 1 search bestBlockForSingleSession: single-session variant
2636-3168 533 11 search findAvailableSlots: the read-only search driver; explicitly takes the base client so it runs outside the write transaction (#908)
3169-3219 51 0 search getNextOccurrenceWeekly: weekly recurrence stepper
3220-3252 33 0 search matchWeeklySlotToDay: projects a weekly rule onto a date
3253-3285 33 0 search matchCustomSlotToDay: projects a dated exception onto a date
3286-3495 210 15 commit createAppointments: writes Appointment + N SlotOfAppointment atoms, stamps org context, records participants
3496-3655 160 2 commit recordSubscriptionAllocationCap: weekly-cap bookkeeping for recurring plans
3656-3706 51 1 commit reconnectEnrolledUsers: re-seats attendees onto regenerated slots
3707-3999 293 10 commit deleteExistingAppointments: releases never-paid tentative rows; carries all three doctrine rule-2 payment guards at :3795, :3883, :3972
4000-4123 124 2 lifecycle updateEventStatus: CAS status writes on the parent event
4124-4340 217 11 lifecycle fetchEventData: the polymorphic five-arm read that loads consultant, config and org context

Grouped by cluster, the patch density is where you would expect it. The three mode functions are 27% of the file and carry 37% of all issue references.

Cluster LOC Share of file #NNNN refs Refs per 100 LOC
modes (auto / manual / use-requested) 1,164 27% 47 4.0
search and occupancy read 1,017 23% 19 1.9
commit and write 714 16% 28 3.9
lifecycle and event fetch 358 8% 13 3.6
entry, dispatch, error classification 254 6% 9 3.5
notifications 267 6% 3 1.1
guards and idempotency 235 5% 4 1.7
preference 121 3% 1 0.8
identity 115 3% 2 1.7
collaborator validation 25 1% 0 0.0

The file carries 128 issue references across 24 distinct issues: #440, #446, #676, #710, #768, #784, #836, #837, #843, #860, #873, #898, #908, #939, #1012, #1060, #1065, #1071, #1132, #1169, #1189, #1194, #1206, #1319. That is the concrete form of "correct by accumulated patch, not by construction" from #1319 §2.

2.2 Callers

The production surface is six call sites, and every one of them uses exactly one symbol: SlotAllocationService.allocate.

Caller Line Symbol used
app/api/bookings/consultations/[consultationId]/allocate/route.ts 86 SlotAllocationService.allocate
app/api/bookings/subscriptions/[subscriptionId]/allocate/route.ts 85 SlotAllocationService.allocate
app/api/bookings/webinars/[webinarId]/allocate/route.ts 85 SlotAllocationService.allocate
app/api/bookings/classes/[classId]/allocate/route.ts 85 SlotAllocationService.allocate
lib/booking/reschedule-auto-confirm.ts 68 SlotAllocationService.allocate
lib/booking/reschedule-respond.ts 77 SlotAllocationService.allocate

Nothing in jobs/ or scripts/ imports it. scripts/appointments/reconcile-slot-availability.ts:397 only mentions it in a comment.

The test surface is much wider and, critically, is not confined to the public method. These are the couplings that decide the extraction order.

Test file Coupling
__tests__/booking-algorithm/slotAllocationService.test.ts allocate only, roughly 90 call sites
__tests__/booking-algorithm/collaborator-availability-modes.test.ts allocate, plus a source-text assertion: reads utils/slotAllocation/SlotAllocationService.ts from disk at :289 and asserts the literal string "SlotAllocationService.assertCollaboratorsFree(" appears at :297
__tests__/booking-algorithm/participant-shadow-write.test.ts source-text assertion at :105: asserts /recordParticipants\(/ matches inside utils/slotAllocation/SlotAllocationService.ts
__tests__/booking-algorithm/availability-window-scan.test.ts casts the class at :58 to reach a private static
__tests__/booking-algorithm/preference-scored-allocation.test.ts casts the class at :74 and :584 to reach private statics
__tests__/enterprise/appointment-org-stamping.test.ts const svc = SlotAllocationService as any at :46, then calls fetchEventData directly
__tests__/booking-algorithm/expected-tentative-count.test.ts allocate
__tests__/booking-algorithm/initial-allocation-guard.test.ts allocate
__tests__/booking-algorithm/row-walk-truncation.test.ts allocate
__tests__/booking-algorithm/reschedule-respond.test.ts jest.mock of the module path at :55
__tests__/payments/allocation-utilization-integrity.test.ts allocate

The two source-text assertions are the single biggest trap in this whole plan. They pass or fail on the contents of one file path, so moving assertCollaboratorsFree or the recordParticipants call into a sibling module makes them fail even though the behaviour is byte-for-byte identical. They must be converted to behavioural assertions before any extraction starts, otherwise every later step has a red suite for the wrong reason and the oracle stops being trustworthy.

2.3 The AFTER layout

lib/booking/allocation/
├── index.ts                          ~60   public surface: allocate() + AllocationRequest/AllocationResult re-exports
├── allocate.ts                      ~130   from 147-265: entry, dispatch, post-success notification hook
├── classify.ts                      ~100   from 533-557 + 583-650: isModeledOutcome, classifyError, the Sentry level policy
├── notify.ts                        ~275   from 266-532: notifyAllocationPlaced (the only Novu importer)
├── identity.ts                      ~120   from 651-748 + 4341-4357: profile/consultee resolution and the two enum mappers
├── event-data.ts                    ~350   from 4000-4340: fetchEventData + updateEventStatus (the five-arm polymorphic read)
├── guards.ts                        ~130   from 749-860: initial-allocation guard, confirmed-slot assert, both tentative-count asserts
├── idempotency.ts                   ~130   from 861-983: findIdempotentAllocation, replayPartialCounts, releasedSlotIdsOf
├── preference.ts                    ~130   from 984-1104: the preference read model and the supersede transition
├── collaborators.ts                  ~35   from 558-582: assertCollaboratorsFree (AE-2)
├── search/
│   ├── constants.ts                  ~45   from 109-141: SLOT_DURATION_MS, MAX_CANDIDATE_STARTS_PER_ROW, AllocationWalkContext
│   ├── window-walk.ts               ~160   from 2269-2415: isWithinAvailability, candidateStartsInRow, reportRowWalkTruncated
│   ├── blocks.ts                    ~230   from 2416-2635: buildConsecutiveBlock, bestFittingBlockInRow, bestBlockForSingleSession
│   ├── recurrence.ts                ~130   from 3169-3285: getNextOccurrenceWeekly, matchWeeklySlotToDay, matchCustomSlotToDay
│   └── find-available-slots.ts      ~550   from 2636-3168: the read-only search driver, still taking the base client
├── modes/
│   ├── auto.ts                      ~460   from 1105-1544: whole lock envelope, unchanged
│   ├── manual.ts                    ~460   from 1545-1982: whole lock envelope, unchanged
│   └── use-requested.ts             ~300   from 1983-2268: whole lock envelope, unchanged
└── commit/
    ├── create-appointments.ts       ~230   from 3286-3495: Appointment + atoms + org stamp + recordParticipants
    ├── release-tentative.ts         ~310   from 3707-3999: deleteExistingAppointments, keeping all three payment guards inline
    ├── reconnect-enrolled.ts         ~70   from 3656-3706: attendee re-seating
    └── subscription-cap.ts          ~175   from 3496-3655: weekly-cap bookkeeping

utils/slotAllocation/SlotAllocationService.ts   ~45   FACADE ONLY (see below)
utils/slotAllocation/occupancyPolicy.ts        222   unchanged in this epic; renamed to lib/booking/occupancy by #1332
utils/slotAllocation/SlotValidationService.ts 1190   unchanged in this epic; its own follow-on

Target: no file over 600 lines, and find-available-slots.ts is the only one near it. Total lands around 4,600 across 23 files, which is a small increase over 4,357 because each file re-declares its imports. That increase is expected and is not a regression.

2.4 The facade

utils/slotAllocation/SlotAllocationService.ts stays at its current path and keeps exporting a SlotAllocationService binding, so all six production callers and all eleven test files keep compiling with no import churn. It becomes a delegation object rather than a class:

// utils/slotAllocation/SlotAllocationService.ts: facade, see #1375
import { allocate } from "@/lib/booking/allocation";
import { fetchEventData } from "@/lib/booking/allocation/event-data";
import { findAvailableSlots } from "@/lib/booking/allocation/search/find-available-slots";
// ... the handful of members the suites reach through the `as any` cast

export const SlotAllocationService = {
  allocate,
  fetchEventData,
  findAvailableSlots,
  // ...
};

Two properties matter. First, the facade must expose every member the tests currently reach by casting, or those suites break on a move; the alternative is to update them in the same PR, which is acceptable but must be a deliberate choice per member rather than a discovery mid-step. Second, the facade is temporary: once the tests are migrated to import the extracted modules directly, a final PR deletes it and rewrites the six production imports to @/lib/booking/allocation. That deletion is the last step of the epic, never the first.

2.5 Extraction order

Each step is one PR based on dev, moves code with zero semantic edits, and is merged before the next begins. Leaves come first so that later steps have fewer live edges.

Step PR scope Files created Why this order
A0 Convert the two source-text test assertions to behavioural ones; no source change none Without this the oracle is wrong from step A1
A1 search/constants.ts, search/window-walk.ts, search/blocks.ts, search/recurrence.ts 4 Pure functions, no Prisma, no writes, no locks: the safest possible first move
A2 classify.ts, notify.ts 2 Leaves with only outward dependencies (Sentry, Novu, errors.ts)
A3 identity.ts, event-data.ts 2 Reads only; unblocks the mode functions by removing their largest read dependency
A4 guards.ts, idempotency.ts, preference.ts, collaborators.ts 4 Small, well-tested, each has a dedicated suite
A5 search/find-available-slots.ts 1 Large but strictly read-only; it already takes the client as a parameter, so it has no hidden transaction coupling
A6 commit/create-appointments.ts, commit/release-tentative.ts, commit/reconnect-enrolled.ts, commit/subscription-cap.ts 4 Highest risk in the allocator: doctrine rules 1 and 2 both live here. Race suite and chaos required
A7 modes/auto.ts, modes/manual.ts, modes/use-requested.ts, allocate.ts, index.ts; the class collapses to the facade 5 Last, because each mode owns a whole lock envelope and can only move once everything it calls has already moved
A8 Delete the facade; rewrite the six production imports and the test imports 0 Cosmetic and mechanical, separate so a revert of A8 does not revert the split

Renames from #1332 (deleteExistingAppointments to releaseUnpaidTentativeRows, the occupancy consolidation, mergeAdjacentWeeklyRows to coalesceAvailabilityRows) are excluded from A1 to A8 and land afterwards as their own PRs, so a reviewer can always diff a move as a move.

3. lib/payments/operations/checkout.ts

3.1 Section map

Eleven top-level exports, seven private helpers and one 1,530-line orchestrator, 3,808 lines total.

Lines LOC #NNNN refs Cluster Member and purpose
105-150 46 2 schema unifiedCheckoutSchema, CheckoutInput, SubscriptionCheckoutResult re-exports and types
151-211 61 2 schema buildPaymentMetadata: builds gateway notes shared by intent creation and the webhook
212-377 166 4 order reuse ReusableOrder + findReusablePendingOrderPayment: adopt-or-supersede for an open PENDING order (#1220)
378-482 105 0 gateway PaymentIntentManager: bounded FIFO intent tracking with cleanup
483-833 351 5 pricing calculateAmountAndValidate: the one price derivation; discounts, credits, tax, buyer country
834-1093 260 11 occupancy validateSlotAvailability: DPDP consent gate, confirmed-overlap check, duplicate tentative, tentative rate limit
1094-1136 43 0 locks getPlanDataForLock: resolves the consultant id needed to mint the lock key
1137-1183 47 0 locks readEventCapacity: pre-lock capacity fast-fail
1184-1300 117 4 locks acquireCheckoutLock: the whole three-family envelope: event, consultee, slot
1301-1325 25 0 locks releaseCheckoutLock
1326-1381 56 0 locks verifyPlanExistsInsideLock: BUG-E re-read
1382-1785 404 4 revalidate OrgFundingContext + revalidateInsideLock: re-runs the whole pre-lock validation under the lock
1786-1884 99 5 handlers handleConsultationCheckout: creates the consultation request rows
1885-2032 148 3 handlers handleSubscriptionCheckout: creates the subscription placeholder and its period
2033-2157 125 1 handlers handleWebinarCheckout: seats a registrant on an existing webinar
2158-2278 121 2 handlers handleClassCheckout: seats a learner across the class's sessions
2279-3808 1,530 33 orchestrator handleCheckout: see the sub-map below

handleCheckout is the real problem, so it gets its own map. The step comments in the source are the natural seams and they are already numbered.

Lines LOC #NNNN refs Phase
2279-2324 46 2 Signature and the eleven mutable outer variables (lock, lockType, consulteeLock, paymentResponse, organizationId, billingAccountId, fundingSource, programAssignmentId, fundingProgramId, callerMembershipId, creditEffectiveLimit)
2325-2532 208 7 Org-funded gate: org status, canSponsor, billing account, dunning suspension, funding source, program assignment resolve, credit limit fast-fail
2533-2563 31 0 STEP 1: calculateAmountAndValidate, outside the lock
2564-2599 36 1 STEP 2: getPlanDataForLock then acquireCheckoutLock, with the global lock order in a comment
2600-2640 41 1 STEP 3: revalidateInsideLock
2641-2807 167 4 STEP 4: open-order adoption then gateway intent mint
2808-2885 78 4 STEP 5a: withSerializableRetry opens, lock renewed per attempt, INVOICE credit re-checked inside the tx
2886-2965 80 0 STEP 5b: the four-arm switch that calls the per-type handler
2966-3050 85 4 STEP 5c: Payment row, AppointmentParticipant shadow write, atom-shape assertions
3051-3335 285 5 STEP 5d: org utilization row, overage decision (CHARGE_ORG / CHARGE_MEMBER / BLOCK), invoice accrual
3336-3611 276 1 STEP 5e: PaymentLeg construction and the legs-sum-to-amount assertion
3612-3762 151 4 STEP 5f: ledger journal entries and the healer enqueue for a durable gap
3763-3797 35 0 catch: the outermost classification boundary for steps 1 to 3
3798-3808 11 0 finally: lock release

Grouped, the picture is that half the file is one function and that function is itself six functions in a trench coat.

Cluster LOC Share #NNNN refs Refs per 100 LOC
orchestrator (handleCheckout) 1,530 40% 33 2.2
per-type handlers 493 13% 11 2.2
in-lock revalidation 404 11% 4 1.0
pricing 351 9% 5 1.4
lock plumbing 288 8% 4 1.4
occupancy validation 260 7% 11 4.2
order reuse 166 4% 4 2.4
gateway intent 105 3% 0 0.0
schema and metadata 107 3% 4 3.7

The file carries 76 issue references across 30 distinct issues: #437, #440, #520, #540, #674, #687, #701, #710, #750, #768, #775, #778, #780, #781, #785, #812, #828, #832, #837, #898, #971, #1071, #1076, #1093, #1132, #1169, #1220, #1230, #1319, #1320.

3.2 Callers

The public surface is remarkably small, which is the good news in this file.

Caller Line Symbol used
app/api/checkout/route.ts 3 handleCheckout
tests/typescript/race-conditions/test-checkout-race-condition-fix.ts 26 handleCheckout
__tests__/payments/checkout-open-order-reuse.test.ts 171, 418 handleCheckout, findReusablePendingOrderPayment
__tests__/payments/consultation-atom-parity.test.ts 154 handleConsultationCheckout

There is exactly one production caller and it uses one symbol. Nothing in lib/, jobs/, scripts/, actions/ or components/ imports the module by relative or aliased path; the webhook path in lib/payments/webhooks/handlers.ts has its own createConsultation / createSubscription / createWebinar / createClass twins rather than importing checkout's handlers, which is the duplication #1332 §4 last row proposes to collapse into one createBookingRows.

Of the eleven exported symbols, only three are imported anywhere: handleCheckout, findReusablePendingOrderPayment and handleConsultationCheckout. The other eight (unifiedCheckoutSchema, the CheckoutInput type re-export, PaymentIntentManager, calculateAmountAndValidate, validateSlotAvailability, handleSubscriptionCheckout, handleWebinarCheckout, handleClassCheckout) are imported by nothing at all. They are exported because the file grew, not because anything needs them. unifiedCheckoutSchema at :105 goes one better: it is a plain alias of checkoutSchema that is never referenced anywhere in the repository, including inside its own file, so it is fully dead code. The split should reduce the surface to handleCheckout plus whatever the suites genuinely pin.

3.3 The AFTER layout

lib/payments/checkout/
├── index.ts                        ~55   public surface: handleCheckout, plus the symbols the suites pin
├── schema.ts                      ~120   from 105-211: CheckoutInput, unifiedCheckoutSchema, buildPaymentMetadata, result types
├── order-reuse.ts                 ~180   from 212-377: findReusablePendingOrderPayment + ReusableOrder
├── gateway-intent.ts              ~120   from 378-482: PaymentIntentManager
├── pricing.ts                     ~370   from 483-833: calculateAmountAndValidate (still delegating the arithmetic to lib/payments/pricing/derive-checkout-amount.ts)
├── occupancy-validate.ts          ~280   from 834-1093: validateSlotAvailability (moves to lib/booking/occupancy under #1332's rename, not here)
├── locks.ts                       ~310   from 1094-1381: getPlanDataForLock, readEventCapacity, acquire/releaseCheckoutLock, verifyPlanExistsInsideLock
├── revalidate.ts                  ~420   from 1382-1785: revalidateInsideLock + OrgFundingContext
├── org-funding.ts                 ~240   from 2325-2532: the org gate lifted out of handleCheckout's preamble into one resolver returning a typed context
├── handlers/
│   ├── index.ts                    ~35   the kind-to-handler map that replaces the four-arm switch at 2886-2965
│   ├── consultation.ts            ~115   from 1786-1884
│   ├── subscription.ts            ~165   from 1885-2032
│   ├── webinar.ts                 ~140   from 2033-2157
│   └── class.ts                   ~135   from 2158-2278
├── commit/
│   ├── index.ts                   ~200   from 2808-2965: opens withSerializableRetry, renews the grant, re-checks credit, dispatches to handlers/
│   ├── payment-row.ts             ~100   from 2966-3050: Payment, participants, atom-shape assertions
│   ├── org-utilization.ts         ~300   from 3051-3335: utilization row, overage decision, invoice accrual
│   ├── payment-legs.ts            ~290   from 3336-3611: leg construction and the sum assertion
│   └── ledger.ts                  ~170   from 3612-3762: journal entries and the healer enqueue
└── orchestrate.ts                 ~230   from 2279-2324 + 2533-2807 + 3763-3808: the five steps, the catch, the finally

lib/payments/operations/checkout.ts   ~30   FACADE ONLY: re-exports from lib/payments/checkout
lib/payments/operations/checkout-replay.ts  62   unchanged (already separate, already thin)

Target: no file over 450 lines. Total lands around 4,000 across 21 files, which is a slight increase over 3,808 for the same reason as the allocator.

Note the deliberate asymmetry with the allocator: the checkout commit/ modules are called from inside the withSerializableRetry closure that commit/index.ts owns, so commit/index.ts is the only file that may open a transaction. Everything under commit/ takes tx as its first parameter and never touches the global client. That is the rule that keeps constraint 12 in §6 from being violated by a later edit.

3.4 The facade

lib/payments/operations/checkout.ts shrinks to a re-export file at its current path:

// lib/payments/operations/checkout.ts: facade, see #1375
export { handleCheckout } from "@/lib/payments/checkout";
export { findReusablePendingOrderPayment } from "@/lib/payments/checkout/order-reuse";
export { handleConsultationCheckout } from "@/lib/payments/checkout/handlers/consultation";
export { unifiedCheckoutSchema, type CheckoutInput } from "@/lib/payments/checkout/schema";
// ... the remaining legacy exports, each with a // deprecated: import from the module directly

Because only three symbols are actually imported anywhere, step C0 can demote the other eight to module-private before the split starts. That deletes no logic and changes no behaviour, but it removes eight symbols the facade would otherwise have to preserve through eleven PRs, and it deletes unifiedCheckoutSchema outright.

3.5 Extraction order

Step PR scope Files created Why this order
C0 Demote the eight exports nothing imports to module-private; delete the fully dead unifiedCheckoutSchema 0 Shrinks the surface the facade has to preserve through eleven PRs; provably safe because the grep is empty
C1 schema.ts, gateway-intent.ts, order-reuse.ts 3 Leaves: no locks, no transactions, one has a dedicated suite already
C2 locks.ts 1 Must move as one unit; the acquire and release pair and the ordering comment belong together
C3 pricing.ts 1 Self-contained transaction; the price-parity test in __tests__/payments is a direct oracle
C4 occupancy-validate.ts 1 Highest ref density in the file (4.2 per 100 LOC) but a narrow contract
C5 revalidate.ts 1 Large, but a single function with one caller
C6 handlers/* 5 Four sibling functions plus the map that replaces the switch
C7 org-funding.ts 1 Lifts 208 lines out of the orchestrator preamble into a resolver returning a typed context object
C8 commit/* 5 The riskiest step by a wide margin: Serializable boundary, ledger, legs, utilization. Race suite plus chaos plus a mock-payment dev-server round
C9 orchestrate.ts, index.ts; the old file collapses to the facade 2 Last, for the same reason as A7
C10 Delete the facade; rewrite app/api/checkout/route.ts and the three test imports 0 Mechanical; separate so it can be reverted alone

4. Where the code has drifted from the documented shape

docs/booking/01-architecture.md and docs/booking/06-dependency-graphs.md were both refreshed by #1335 (wave-5 PR 11) on 2026-09-02, so this is drift that survived a deliberate docs pass rather than neglect. That is worth saying because it changes the remedy: the two documents are hand-maintained descriptions of a shape that has been re-derived four times, and the sustainable fix is to regenerate the dependency graph from the imports after this epic rather than to hand-patch it again.

The good news first. Every file path either document names still exists, so there are zero broken references, and neither document makes a literal line-count claim, so there is nothing to correct there. 06's cron table is exact: all eight workflows, all eight schedules, all eight abortIfMaintenance guards verified. 06's core service edges (types into all three services, Calc into Val and Alloc, Val into Alloc and Occ) are correct, as is its validate-then-BEGIN TRANSACTION ordering. The claim in both documents that auto-allocation is server-only with no surviving client engine is true: lib/scheduling/allocationAlgorithms.ts is 316 lines exporting only manualAllocate and allocateRequestedSlots, and its only product importer is hooks/scheduling/useSlotAllocation.ts.

The drift falls into four kinds, and it matters to this epic because a reviewer who checks a step against the docs will be checking against the wrong picture.

The first kind is layering edges that no longer exist or point the wrong way.

Doc claim What the code shows Kind
06:106 draws MERGE --> COV availabilityCoverage.ts imports only @/lib/prisma and ./slotTimeUtils; the two modules do not touch. Merge's consumers are the four availability CRUD routes plus two scripts/db/coalesce-* scripts; coverage's consumers are checkout.ts:8-11 and app/api/trials/[trialId]/route.ts:50 stale layering
06:148 draws UC --> SP UnifiedCalendar.tsx never imports SlotPicker. They are peers, both imported at line 4 of four separate page clients stale layering
06:196-201 shows routes calling autoAllocate, manualAllocate and useRequestedSlots All three are private static (:1105, :1545, :1983). The route surface is one method, allocate at :147, fanning out through dispatch at :208 stale layering and stale name
06:99-105 draws six route edges backwards against the document's own bottom-up convention stated at :51 The routes are the importers, not the imported stale layering
06:93 omits any allocator-to-lock edge while :317 asserts every slot-occupying write holds one SlotAllocationService.ts:59-65 imports four lock functions, used at :1142, :1153, :1607, :1617, :2015, :2036 stale layering
06:9 states each layer only calls the layer directly below UnifiedCalendar.tsx:58 imports SlotCalculationService directly, skipping two tiers; lib/scheduling/calendarUtils.ts and allocationAlgorithms.ts do the same stale layering
06:81 files lib/payments/operations/checkout.ts under the "API Routes" subgraph It is a lib module; the route is app/api/checkout/route.ts (246 LOC) mis-tiered node
01:162 says allocationService.ts and useSlotAllocation.ts both call utils/subscriptionValidation directly Neither imports it. Its only product importers are SlotValidationService.ts:23 and one route. The two named functions getSubscriptionWeek and getSubscriptionType are referenced solely by a test stale layering
06:134 declares node EC["EventCard.tsx"] The node has no edges at all dead node

The second kind is names and signatures that have moved, and two of these bear directly on steps in this plan.

Doc claim What the code shows Kind
01:74 says all allocator operations run inside a Prisma transaction with a 60-second timeout Both halves are wrong. lib/prisma.ts:83-86 sets ALLOCATION_TX_TIMEOUT_MS to 30,000 with an 8,000 ms max wait, and #908 moved reads, search and validation out of the write transaction entirely (SlotAllocationService.ts:1173, :2637, :4125). 06:207-216 gets this right, so the two documents contradict each other stale layering and stale figure
06:204 names lockSlotBooking as an allocator dependency The allocator never imports it. lockSlotBooking belongs to the checkout path (checkout.ts:34, used at :1221). The allocator uses lockAutoAllocate and lockConsulteeBooking stale name
01:36 lists the occupancy statuses as APPROVED subscriptions, PENDING/APPROVED/APPROVED_PENDING_PAYMENT consultations, SCHEDULED events occupancyPolicy.ts:29-34 defines one list applied identically to both request types, and it includes SCHEDULED; :41 gives events ["SCHEDULED", "IN_PROGRESS"]. The doc also frames it as an exclusion of terminal statuses when the code is an inclusion allowlist stale name
01:101 says weekly generation covers 8 weeks and builds a lookup set of all available blocks SlotAllocationService.ts:2555 uses 8 weeks for consultations and 4 for everything else, and the code walks availability rows against a bookedSlots occupancy set rather than materialising blocks stale name
01:40 says validateSameDaySlots compares toDateString() SlotValidationService.ts:682-704 takes a timezone and compares SlotCalculationService.dayKey. toDateString() is gone stale name
01:29 gives validate five parameters SlotValidationService.ts:159-180 takes seven, adding excludeAppointmentIds and an options bag stale signature
01:19-21 lists countCompletedCalls, groupSlotsByDay and groupSlotsByWeek as public with untimezoned keys countCompletedCalls is private static at :563; both groupSlots* now take a timezone and key on dayKey/weekKey stale name
01:166 says preference scoring orders candidates but never filters them matchesPreferredDays is used as a filter in the first sweep (:3011-3017, :3038). The claim survives only because a second relaxed sweep runs at :3087, and the two-pass design is never described imprecise
01:304 documents a rate limit of three pending attempts per slot per 30 minutes Removed with an explicit do-not-re-add note at checkout.ts:1076-1079: since #1169 blocks on any live hold, the count could never reach one deleted rule

The third kind is modules that exist and carry real weight but appear in neither document. This is the largest category and the one that most affects a reader trying to understand the subsystem from the docs.

  • utils/slotAllocation/ has 11 files and 01 documents 3. Undocumented: slotTimeUtils.ts (408 LOC, imported by five modules including UnifiedCalendar.tsx), sessionCaps.ts, errors.ts, occupancyPolicy.ts, availabilityCoverage.ts, mergeAdjacentWeeklyRows.ts. preferenceScoring.ts appears in 01's prose and 06's diagram 4 but is missing from 06's diagram 2.
  • lib/booking/ has 14 files and the docs name one, transitions.ts, and only in 06. 01-architecture.md never mentions it at all, which for an architecture document that describes CAS-guarded lifecycles is a notable gap. Also undocumented: participants.ts, cancellation-scope.ts, org-actor.ts, overlap-meta.ts, reschedule-auto-confirm.ts, reschedule-proposals.ts, reschedule-respond.ts, reschedule-withdraw.ts and six more.
  • 01 never mentions utils/appointmentlock.ts (999 LOC) at all. Both correctness pillars this epic must not break, the lock module and the transition helpers, are invisible in the architecture document.
  • Neither document mentions lib/db/serializable-retry.ts, lib/db/pg-errors.ts, lib/api/scope/*, lib/payments/pricing/derive-checkout-amount.ts or the prisma/sql/ sidecars, even though slot_no_confirmed_overlap is what makes classifyError return 409 rather than 500.
  • 06's utils subgraph names 3 of the 12 files in lib/scheduling/, omitting slotSelectionValidation.ts (873 LOC), which 01:143 itself calls "the pure rules", plus allocationMessages.ts, slot-status-tokens.ts and slot-picker-focus.ts.
  • 01's data-model tables omit columns this epic's code reads: SlotOfAppointment.completionStatus, completedAt, deletedAt and the denormalised consultantProfileId that backs the exclusion constraint; SlotOfAvailabilityWeekly.utcOffsetMinutes (which the weekly recurrence stepper actually uses) and the five frozen DST columns; Appointment.allocationIdempotencyKey, cancellationPolicySnapshot, trialSession and participants.
  • 01:97-121 describes the three allocation modes without any of the features that account for most of their 1,164 lines: the two-pass preference sweep (N>1 reschedule: preference-scored auto-allocation #1065), partial allocation (Product decision: should consultants be able to PARTIALLY allocate recurring events when availability falls short? #1206), the collaborator guard, the idempotency replay path, the three in-transaction guards, and walk truncation (MAX_CANDIDATE_STARTS_PER_ROW is a silent truncation risk in auto-allocate candidate walks (tracked undeveloped in #1169) #1194).

The fourth kind is a scope gap in 06's ER and cron diagrams. The ER omits Appointment ||--o| TrialSession even though the same document draws the trial expiry cron, and its BookingStatusHistory edge is drawn as appointment-keyed when the real key is entity plus entityId with a nullable appointmentId (prisma/schema.prisma:4119-4123). The cron diagram omits four booking-relevant jobs that live in the same directories, including detect-consultant-no-shows and cleanup-abandoned-payments.

What this means for the epic. Three concrete actions, none of which block a step.

  1. Fix 01:74 before anyone reads it as licence to wrap a whole mode in a transaction. It is the one drift item that could cause a wrong change: a contributor who believes everything runs in a 60-second transaction might "restore" reads into the write transaction during step A5 or A7 and undo bug(allocation): subscription Auto-Allocate fails with 500 'Unable to start a transaction in the given time' (~115s) — paid subscription left unallocated #908.
  2. Do not treat 06 as the source of truth for the extraction order. Use the caller tables in §2.2 and §3.2, which were derived from the imports directly.
  3. Regenerate both documents from the new tree as the final PR of the epic, and make the regeneration mechanical so the fifth rewrite does not have the same failure mode. The dependency graph in particular should be produced by a script over the import edges rather than drawn by hand.

5. The AppointmentParticipant read shape

The split must land on the final read shape, not today's, because rewriting the same call sites twice is exactly the cost this epic exists to avoid.

Today, participation truth is the implicit many-to-many join SlotOfAppointment.user User[] @relation("SlotOfAppointmentToUser") (prisma/schema.prisma:4478, mirrored at :64 on User). AppointmentParticipant was added by #1322 as ADR A9 with @@unique([appointmentId, userId]) and a SetNull payment link (prisma/schema.prisma:4066-4095), but it is a shadow write only:

  • Writers: lib/booking/participants.ts (81 LOC) exports exactly three functions, recordParticipants, setParticipantStatus, linkParticipantsToPayment. They are called from utils/slotAllocation/SlotAllocationService.ts (inside createAppointments), lib/payments/operations/checkout.ts:3011, app/api/participants/webinar/[webinarId]/route.ts:233, app/api/participants/class/[classId]/route.ts:234 and prisma/seedFiles/8b-create-payments.ts:135.
  • Readers: none in product code. The only consumer is the parity check in scripts/appointments/reconcile-slot-availability.ts:426, which compares the new table against the old join.
  • Meanwhile 129 files under app/, lib/, jobs/, scripts/, components/, hooks/ and actions/ still read slotsOfAppointment.

Three consequences for this plan.

First, seat-writing must end up in exactly one file per giant: lib/booking/allocation/commit/create-appointments.ts and lib/payments/checkout/commit/payment-row.ts. Once it is, flipping the readers later touches those two files plus the read sites, not the whole allocator.

Second, __tests__/booking-algorithm/participant-shadow-write.test.ts:105 currently pins the shadow write by asserting /recordParticipants\(/ appears inside SlotAllocationService.ts. That assertion must be retargeted in step A0, and when it is, retarget it to the behaviour (a recordParticipants spy is called with the right rows) rather than to the new file path, so the reader flip does not have to touch it a third time.

Third, the reader flip itself is out of scope for this epic and belongs to its own issue under #1332 §3. This epic only has to guarantee that the flip becomes a small diff.

6. Risks specific to this repository

6.1 Doctrine invariants that a pure move can silently break

Read .claude/skills/booking-doctrine/SKILL.md and .claude/skills/booking-concurrency/SKILL.md before writing any step. The six doctrine rules are: every status write goes through a CAS transition helper; nothing a Payment points at is ever deleted; refunds have exactly two front doors; org scoping is explicit on every list; an approved request that was never paid has one outcome, EXPIRED; and there are no backfill migrations.

Most of those survive a file move for free. These do not, and each one is a co-location invariant, meaning the guard and the statement it guards must remain in the same statement or the same transaction.

Invariant Where it lives today How a move breaks it
Doctrine rule 2: the payment guard rides inside the DELETE WHERE SlotAllocationService.ts:3795, :3883, :3972, all three inside deleteExistingAppointments If step A6 hoists the payment check into a caller-side precondition, the rule breaks with no test-visible signature change. All three sites must move together into commit/release-tentative.ts with the guard still in the WHERE
Doctrine rule 1: BookingStatusHistory is appended in the same transaction as the CAS, reading the from-status first inside lib/booking/transitions.ts, invoked from both giants Do not extract history-writing into a post-commit module in either train
Concurrency: nothing non-idempotent inside withSerializableRetry one call site, checkout.ts:2817, four attempts max Step C8 must keep gateway calls, notifications and pay-link mints strictly outside the closure. The closure's contents are exactly what commit/index.ts owns
Concurrency: a lock grant taken before the retry loop is renewed inside it renewOrAbort(perAttemptTtl) at the top of the retry callback in checkout.ts; extendSlotInterval for the slot atoms If C2 puts acquisition in locks.ts and C8 puts the retry loop in commit/index.ts, the renewal call is the thing that quietly gets dropped. Pin it with a test before C2
Concurrency: every lock key is minted in utils/appointmentlock.ts and nowhere else 999 LOC, eight atom families No new module may rebuild slot-booking:${id}:${iso} inline. Two names for one atom is no lock at all
Concurrency: acquireGuarded is the single acquisition path, fail-closed via checkRedisHealth plus withCircuitBreaker utils/appointmentlock.ts No new module may touch the Redis client directly; that turns fail-closed into fail-open
Concurrency: lockSlotInterval is all-or-nothing, acquiring atoms in ascending key order and releasing in reverse on any failure utils/appointmentlock.ts Do not decompose interval acquisition into per-atom acquisition across files
Concurrency: the global lock order appointment / event / consultant → consultee → slot, with approval → mint as the only legal nesting a property of the call sequence, not of any file This is the reason steps A7 and C9 come last. All three allocator modes acquire their own envelope (SlotAllocationService.ts:1142/:1153, :1607/:1617, :2015/:2036), so each mode file owns acquire, critical section and release as one unit and never delegates a partial envelope
Concurrency: the 23P01 heuristic is quarantined in lib/db/pg-errors.ts 56 LOC The allocator has no withSerializableRetry call of its own; its correctness rests on locks plus CAS plus the GiST constraint, so isExclusionViolation catch sites are its only 409 path and must survive step A6 intact
Sidecar parity: slot_no_confirmed_overlap excludes tentative rows and null-consultantProfileId rows and uses half-open tstzrange prisma/sql/check-constraints.sql:66-73 Any JS occupancy predicate extracted in A1/A5/C4 must keep mirroring that predicate exactly

One more thing worth recording: booking-concurrency §4 explicitly retracts the "26 s function ceiling" as a configured number. There is no 26000 constant and no [functions] block in netlify.toml; it is an observed figure. The comment at utils/appointmentlock.ts:108 still states it as if configured, and that comment is stale. Correcting it is a one-line docs fix, not part of this epic, but do not build a new module around the number.

6.2 Shared node_modules across the fw-* worktrees

There are five sibling worktrees on this machine (fw-dpdp-docs, fw-explore-responsive, fw-perf-1148, fw-support-hub, fw-ui-overhaul) plus the agent worktrees under .claude/worktrees/. At least one of them, fw-support-hub, has node_modules as a symlink to the canonical tree at /Users/kaustavghosh/Desktop/familiarise_web/node_modules. There is therefore one generated Prisma client shared by every worktree, and running prisma generate in any of them rewrites it for all of them.

For an epic that churns imports across roughly forty new files, that matters concretely:

  • Always chain prisma generate and tsc --noEmit in a single command so nothing can regenerate the client between them.
  • jest.config.ts sets modulePathIgnorePatterns: ["/\\.claude/worktrees/"] and testPathIgnorePatterns with the same entry. That covers the agent worktrees, which live inside the repo root. It does not cover the fw-* siblings, which are separate roots outside the repo and so are invisible to Jest's crawler anyway. No config change is needed, but do not "helpfully" broaden the pattern.
  • Do not run this epic's steps concurrently with another agent in a sibling worktree. Two extraction PRs touching the same file will produce conflict markers that a fork can commit without noticing.

6.3 Formatting

A format-only pre-PR is the usual precondition for an extraction, because otherwise the first move reformats the file and the diff stops being reviewable as a move. It turns out neither giant needs one. Verified on 5f057bc02: prettier --check passes on both lib/payments/operations/checkout.ts and utils/slotAllocation/SlotAllocationService.ts, so any earlier note that checkout.ts is prettier-dirty on dev is out of date. The two files in the booking scope that are currently dirty are:

  • lib/payments/operations/refund.ts
  • components/dashboard/shared/requests/RequestSlotAllocationTab.tsx

Neither is touched by steps A0 to A8 or C0 to C10, so the format-only pre-PR is not needed for this epic as scoped. It is needed before the refund.ts follow-on in §8, and it should land as its own commit there so the extraction diff stays pure movement. Filing it now as a one-line PR against dev is cheap and removes the trap entirely.

Separately, .gitignore:108 lists prompts/, which Prettier reads as an ignore file, so prettier --check silently passes on unformatted files under prompts/. That does not affect this epic but is worth knowing when a step touches the prompt corpus.

6.4 The pre-MVP data reset

The reset runbook lives in #1319 §6 and is owned by #1322. Two consequences:

  • This epic touches no schema and needs no migration, which is what makes it safe to run either side of the reset. Doctrine rule 6 forbids backfill migrations, and nothing here needs one.
  • Every step must be verifiable without db push against the shared Supabase project. That project serves both dev and prod, has no branches, and every mutating script against it is a production operation. Verification is jest plus a seeded dev server with mock payments, never a push. See .claude/skills/booking-verification/SKILL.md.

6.5 The moving target

Both giants grew during wave 5 (§1). Running these twenty PRs while a feature train is also patching the same two files produces continuous conflicts, and rebase --onto restacks across squash merges are the exact failure mode #1319 already documented. This epic needs a declared freeze window on the two files, or it needs to run strictly between trains. It should not be started opportunistically.

6.6 Test coupling that a move breaks even when behaviour does not change

Restated here because it is the highest-probability failure in the plan: two suites assert on the text of SlotAllocationService.ts (collaborator-availability-modes.test.ts:289-297, participant-shadow-write.test.ts:105) and four reach private statics through a cast. Step A0 exists solely to defuse these. Do not begin A1 until A0 is merged and green.

7. Verification per step and definition of done

7.1 The per-step gate

Every PR in both trains runs the same gate. The suites are the oracle: since no step changes behaviour, any red test is a real regression in that step, never an expected update.

Check Command Applies to
Schema still valid, client fresh npx prisma validate && npx prisma generate chained with the next line every step
Cold type check rm -f tsconfig.tsbuildinfo && NODE_OPTIONS=--max-old-space-size=12288 npx tsc --noEmit every step; a warm incremental run hides errors here
Lint, warnings blocking npx next lint (unused-vars is a warning locally but fails the SonarCloud PR gate) every step
Format npx prettier --check on the changed files every step
Allocation oracle npx jest __tests__/booking-algorithm (66 files) A0-A8, and C8
Money oracle npx jest __tests__/payments (51 files) C0-C10, and A6
Booking lifecycle npx jest __tests__/booking (5 files) A6, A7, C8, C9
Enterprise org stamping npx jest __tests__/enterprise/appointment-org-stamping.test.ts A3, A6, C7, C8
Race suite tests/typescript/race-conditions/ master runner, categories 01-concurrent-access, 07-real-api-booking, 09-webhook-storm, 10-interval-lock-semantics (10 scenario folders total) A6, A7, C2, C8, C9
Chaos 07-real-api-booking and 09-webhook-storm against a deploy preview A7, C8, C9
Dev-server round one mock-payment booking end to end per booking type touched A6, A7, C8, C9
Bot triage CodeRabbit and Gemini threads triaged, SonarCloud PR gate green every step

The suite counts above are as of 5f057bc02. Both grew during the wave-5 train, so any older figure carried in a planning document is stale: __tests__/booking-algorithm is now 66 files and __tests__/payments is now 51. tests/typescript/race-conditions/scenarios holds 10 categories.

7.2 The move-purity check

The distinguishing gate for this epic, and the one that makes a reviewer's job tractable, is a mechanical proof that a step moved code rather than changed it. For each extraction PR:

  1. Concatenate the moved regions from the pre-PR file and the new files, strip import blocks and leading whitespace, and diff. The diff must be empty except for this. becoming a bare call, private static becoming function, and added export keywords.
  2. Assert the issue-reference count is conserved: grep -oE '#[0-9]{3,4}' over the before-file and over the after-set must produce the same multiset. A dropped #NNNN comment means a dropped patch, and that is the cheapest possible detector for the failure mode this whole epic is about.
  3. Assert the doctrine-critical string set survives: payment: { none: {} } still appears three times in the allocator's after-set, withSerializableRetry still appears exactly once in checkout's, and every transition* call site count is unchanged.

Items 2 and 3 are worth writing as a small script or as a jest pin, because they are run twenty times.

7.3 Definition of done

The epic is done when all of the following hold on dev.

  • No file under lib/booking/allocation/ exceeds 600 lines and no file under lib/payments/checkout/ exceeds 450 lines.
  • utils/slotAllocation/SlotAllocationService.ts and lib/payments/operations/checkout.ts no longer exist; the six allocator callers import @/lib/booking/allocation and app/api/checkout/route.ts imports @/lib/payments/checkout.
  • The full issue-reference multiset from the two original files is conserved across the two new trees, verified by the §7.2 check.
  • All three payment: { none: {} } guards remain inside their deleteMany WHERE clauses, in one file.
  • checkout has exactly one withSerializableRetry call site and it lives in lib/payments/checkout/commit/index.ts; no file under commit/ opens its own transaction or touches the global Prisma client.
  • Every lock acquisition still goes through utils/appointmentlock.ts; no new module constructs a lock key string or calls Redis directly. Each of the three allocator mode files owns a complete acquire-critical-section-release envelope.
  • No source-text test assertions remain against any file in either tree.
  • __tests__/booking-algorithm, __tests__/payments, __tests__/booking, __tests__/collaborators and __tests__/maintenance are green; the race suite categories listed in §7.1 pass; chaos 07 and 09 pass against a deploy preview.
  • Zero schema changes and zero migrations were produced by the epic.
  • docs/booking/01-architecture.md and docs/booking/06-dependency-graphs.md are regenerated from the new tree in a final docs PR, and the drift items in §4 are closed rather than carried forward.

8. Follow-ons, explicitly out of scope here

These are named so they are not silently absorbed into this epic.

Labels

booking, finance, tech-debt, launch: post-mvp

🤖 Generated with Claude Code

https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

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

    bookingBooking, scheduling, slots, reschedule, cancellationfinancePayments, refunds, earnings, payouts, invoicing, ledgerlaunch: post-mvpFirst 90 days after launch — coverage, polish, operational maturitytech-debtRefactors, structure, dependency upgrades, cleanup

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions