Scheduling & Bookings — Behavior
State machines
Class session (class_sessions.status, enum publish_status)
| From | To | Trigger | Allowed roles |
|---|---|---|---|
| (insert) | draft | POST /sessions default | owner/admin/coach |
| (insert) | published | POST /sessions with status='published' | owner/admin/coach |
draft | published | POST /sessions/:id/publish | owner/admin |
published | draft | POST /sessions/:id/unpublish (rejected while any confirmed, attended or waitlisted booking exists) | owner/admin |
draft/published | cancelled | POST /sessions/:id/cancel | owner/admin |
| any | (soft-delete) | POST /sessions/bulk-delete (stamps deletedAt) | owner/admin |
archived is in the enum but not used for class sessions (it’s the course terminal state).
Booking (bookings.status, enum booking_status)
| From | To | Trigger |
|---|---|---|
| (insert) | confirmed | POST /sessions/:id/book with a seat still free after the waitlist took its turn |
| (insert) | waitlisted | POST /sessions/:id/book when no seat is left and the waitlist is open |
waitlisted | confirmed | fillFreeSeats finds a free seat: member cancel, staff cancel, entitlement-sweep revocation, capacity increase, Arbox roster cancel, a departing member’s release, or a book() on a session with a free seat and members waiting. See Waitlist |
confirmed | attended | POST /sessions/:id/check-in/:bookingId (staff) or POST /sessions/:id/self-checkin (member) |
attended | confirmed | DELETE /sessions/:id/check-in/:bookingId (undo check-in; no capacity check, the seat never left) |
confirmed/waitlisted | cancelled | DELETE /sessions/:id/book (self, reason member; staff when staff act for someone) or DELETE /sessions/:id/bookings/:bookingId (admin, reason staff); entitlement sweep (entitlement_sweep); member removed, suspended or erased (member_removed); Arbox roster removal (arbox) |
waitlisted | cancelled | The member was promoted into another class the same org-local day (waitlist_promoted_elsewhere); the class started while they waited (waitlist_expired, job every 5 min) |
| any live | cancelled | Session cancel, single delete or bulk delete (session_cancelled), attended included |
confirmed | no_show | NotificationSchedulerService.handleAutoNoShow (every 30 min): a confirmed booking on a published session that ended more than 2 hours ago |
Every transition to cancelled written since migration 0181_waitlist_hardening stores cancellation_reason (see Cancellation reasons); older rows hold NULL.
Invariants
- One booking row per (session, member), ever — DB-level
unique(class_session_id, membership_id)onbookings, across every status.book()reads the member’s row for the session whatever its status: an active one (confirmed,waitlisted,attended,no_show) raisesBadRequestException('Already booked for this session'); acancelled(or soft-deleted) one is revived in place — same id, newstatus/waitlist_position/subscription_id,cancelled_at/checked_in_at/checkin_method/deleted_atcleared,created_atandupdated_atset to the re-booking time. So cancel → book again works any number of times, and charges/refunds a credit each cycle. - Capacity is hard — seats taken = live
confirmed+attendedbookings (SEAT_TAKING_STATUSES,seatsTaken/sessionOccupancyinbooking-enforcement.util.ts) + Sales trial appointments in the class that arependingorattendedand not superseded (holdsTrialSeat/SEAT_HOLDING_TRIAL_OUTCOMESintrial-seats.ts, see Trials) + seats an Arbox roster holds (ArboxCapacityService.heldSeatsFor);book()also subtracts seats an aggregator (Fizikal) sold. A check-in therefore never frees a seat (a coach’s trial check-in, and the rep confirming a trial attended, included). A lead converting before the class moves its trial seat into a member booking (bookings.trial_appointment_id) without a capacity check: the seat changes hands, the count does not move. A booking is confirmed only while seats taken < capacity (null = unlimited), after the waitlist has taken its turn (see Waitlist); otherwise it is waitlisted while the live waitlisted count is belowwaitlistCapacity(null = unlimited, 0 = no waitlist), elseSession is full. Check + insert run inside oneSERIALIZABLEtransaction.getBookingCounts(the schedule’sbookingCount/capacityRemaining, trial seats added viatrialSeatCounts), the overview tiles and the day-capacity query count the same statuses. - No booking on a class that started or was deleted —
book()answers 404Session not foundfor a soft-deleted session and 400{ code: 'session_started', message: 'This class has already started' }oncestartsAt <= now, staff included. - A member with no plan is still judged on overlap —
book()and promotion treat them asallowOverlappingBookings=falsewith no caps or credits. - Staff booking their own seat never carry a plan —
subscription_idstays NULL, so the seat is charged neither at booking nor on a later promotion. Staff booking for a member (bookForMember) charges the member’s plan as before. - Org isolation — every session read joins
classType → program → organizationId. Writes use a subquery to constrain the UPDATE:inArray(classSessions.id, orgScopedSessionIds)(seeClassSessionsService.update/publish/unpublish/cancel/checkIn). - A draft cannot be booked —
book()throwsBadRequestException('Session is not open for booking')unlesssession.status === 'published'. - Registration window — an org-level rule, set on the booking settings page next to the cancellation window:
registration_opens_before_minutesandregistration_closes_before_minutes, both measured back from the session’sstarts_at. Null on a side leaves it unbounded, so an org that sets nothing behaves as before. For members,book()throws aConflictException409{ code: 'registration_not_yet_open', opensAt }before the opens time (inclusive) and{ code: 'registration_closed', closedAt }from the closes time (exclusive). Staff skip both checks. Opens must be strictly earlier than closes when both are set; the org PATCH rejects the merged result otherwise. The window is derived on every read and never stored on a session, so editing the rule moves every upcoming session and copied or bulk-created sessions need no extra step. Session responses carryregistrationOpensAt,registrationClosesAtand a server-resolvedregistrationState(open|not_yet_open|closed). The window gates joining the waitlist too. Promotion from the waitlist stops once registration closes (and within 15 minutes of the start, whichever is earlier; see Waitlist); the opening time is not judged there. The window does not gate the entitlement sweep or imports from external systems, and it never blocks cancelling. Existing bookings stay valid when the rule changes. Withbooking-policy-ruleson, these columns are the bottom (org_default) layer of the layered rules; off, they are the only layer. - Cancelled sessions cancel all bookings —
cancel(),deleteSession()andbulkDelete()run the status update and the bookings update in oneSERIALIZABLEtransaction withwithSerializableRetry, so abook()racing them either lands first (and is cancelled and refunded) or sees the cancelled session. Every live booking getscancellation_reason='session_cancelled';confirmedandattendedseats are refunded (reverseBookingCredit,SESSION_CANCELLED), waitlisted and no-show rows spent nothing. The same transaction ends the session’s pending Sales trials (endSessionTrials): a checked-in one endsattended, the restcancelledand superseded, each with asales_triallead activity (action: 'session_cancelled'), and the rep’s open trial task is cancelled for the cancelled ones (see Trials). These paths never promote. - Unpublishing requires no live bookings —
unpublish()throwsBadRequestException('Cannot unpublish a session that has active bookings')while anyconfirmed,attendedorwaitlistedrow exists: a draft is invisible to members, so nobody may still hold a seat or a waitlist place on it. - Bulk-delete is soft-delete — sets
deletedAt. Active bookings get cancelled. Audit row created withbatchId,sessionIds, filter payload. - Bulk-publish forces status filter to
draft— caller’s filterstatusis overwritten regardless of input. Defense-in-depth. - Plan-credit consistency — credit deduction (
deductCreditAtomic→debitBookingCredit) and refund (reverseBookingCredit) run inside the same booking transaction, on the entitlement ledger. Unlimited subs (plan.type='subscription'ANDclassCredits IS NULL) skip both. Course-type subs are excluded entirely fromresolveSubscription*. - Plan caps count every booking that took the slot (TKN-457) — a plan’s
max_bookings_per_day/week/monthcount the subscription’s bookings in the window with statusconfirmed,waitlisted,attendedorno_show(QUOTA_COUNTED_STATUSESinapps/api/src/bookings/quota-window.ts). Only a cancellation frees a slot, so a check-in or the auto no-show job never hands one back: on a 2/week plan, a member who attended Sunday and booked Monday cannot book Tuesday. The one list feeds every cap counter —book()’s day/week/month checks, the member’s quota card (QuotaUsageService,quotasonGET /organizations/:orgId/subscriptions/my), the schedule’sbookingsToday/bookingsThisWeek/bookingsThisMonthandblockReason, and the plan-change sweep’s cap baseline — so the number a member is shown is the number the server enforces. Overlap checks still read onlyconfirmed/waitlisted. Staff still bypass the caps. - Subscription re-verified inside the write transaction (FIT-254 review Wave C, C5) —
activeSubis resolved BEFOREbook()’s transaction opens; a concurrent cancel/sweep/debt-entry could move it to a terminal status in that gap. The transaction now re-selects the subscription’s ownstatusand rejects (BadRequestException('Subscription not found or not active')) unless it’s stillactive/past_due/debt, before any quota/credit/write logic runs. - Late cancel keeps the debit by default — even if
allowLateCancellation=true, a late cancel writes no reversal. The one exception is a booking whosepolicy_snapshotsayslateCancelCredit: 'refund'(a rule under flagbooking-policy-rules, see Booking policy rules). - Coach role validation —
classSession.coachMembershipIdonly accepts memberships with role ∈{owner, admin, coach}andstatus='active'. - External (guest) coach — a session can instead carry
externalCoachName: a one-time guest who is not an org member (display-only, no account or permissions). Mutually exclusive withcoachMembershipId— setting one clears the other, and supplying both in one request is rejected with 400.
Billing gate
Rule: payments › Unpaid payment. For members (staff bypass), BookingsService.book judges billing before a plan is picked, right after the compliance-form gate. It loads every plan that bears on booking (loadLiveSubscriptions: active, past_due, debt, scheduled, plus cancelled rows with cancellation_reason='non_payment' and debt_amount_in_cents > 0; oldest first; course-type rows excluded) and runs assertBillingAllowsBooking:
| # | Condition | Response |
|---|---|---|
| 1 | Any plan cancelled for non-payment with its debt still open | 409 { code: 'booking_blocked_debt', message, billingDebt }, billingDebt = the debt with the earliest cancellation ({ amountInCents, since, reason, payBy, cancelsAt, state: 'cancelled' }) |
| 2 | No live plan and the org’s plan_required_to_book is on | 409 { code: 'plan_required_to_book', message } |
| — | No live plan, setting off (the default) | Allowed; the booking is untracked (no credit accounting) — unchanged |
| 3 | Every live plan is a scheduled presale | 409 { code: 'plan_not_started', message, startsOn }, always, whatever the setting. startsOn is the org-local YYYY-MM-DD of the earliest starts_on ?? next_charge_date, or null |
- Rule 1 reads every plan, so a healthy second plan cannot carry a member past a non-payment cancellation on another. Only paying the whole debt restores booking.
- A plan in its grace period books. A
debtrow is a credit source until its cancellation instant (debtCancelsAt(debt_since), org-local midnight of day N); credits come from the debt period.bookings/booking-horizon.tsholds the one rule the gate, the schedule’s eligibility and member status share:bookingHorizon(how far ahead a plan may book),isCreditSource,startsBeyondHorizon. For adebtrow only its cancellation instant applies. A lapsed cash plan (cash_prepaid_throughpassed) and a pay-later term pastpayment_due_atkeep booking untildebtCancelsAtof that instant, even before the lifecycle pass stamps the debt; past it they answercash_prepayment_expired/payment_dueon an explicit pick and are not auto-selected. A session starting after the horizon →booking_beyond_subscription_end. - A
debtrow past its cancellation instant that the hourly lifecycle has not cancelled yet no longer pays for bookings, but rule 1 only fires once it is actually cancelled. messageis an English fallback. Web and mobile map the codes throughpayments.errorCodes;booking_blocked_debtrenders the amount frombillingDebt,plan_not_startedrendersstartsOnas a plain date.- A presale beside a running plan neither blocks nor counts as a usable plan: auto-select only considers started plans.
- Paused plans are not judged.
- Staff skip the gate, including an explicit-
subscriptionIdbooking on adebtplan. resolveOrgBookingConfigreturns{ timeZone, registrationPolicy, planRequiredToBook, debtPolicy }(billing_grace_days+ timezone).- Schedule eligibility (
bookingEligibilityonGET /sessions,ClassSessionsService.getBookingEligibility) applies the same rules:debt_blocked(rule 1,plansempty),no_plan(no plan, including whenplan_required_to_bookis on),plan_not_started(presales only). Bookable plans come fromfindBookableForMembership(activeanddebt); each entry’splanEndsAtis itsbookingHorizon(a debt plan’s cancellation instant), and a session past it is blocked withplan_ends_before_session. Cash-coverage and pay-later limits are applied here as at booking.
Which plan pays
When the request names no subscriptionId, resolveSubscriptionOptional picks the plan from the member’s started live plans (active, past_due, debt). A plan can pay when no money wall is closed on it (isCreditSource) and evaluateBooking finds it neither expired nor spent (ledger balance, from EntitlementBalanceService.withBalances). The same pipeline serves every route that books: the member’s own (POST /organizations/:orgId/sessions/:sessionId/book, web and mobile alike), a guardian’s for a child and staff acting for a member (POST …/members/:memberUserId/sessions/:sessionId/book). Staff booking for themselves never picks a plan.
| Plans that can pay | Response |
|---|---|
| None live | Untracked booking (the billing gate already refused it if the org requires a plan) |
| Live, none can pay | 400 { code: 'no_credits' } |
| One | Charged to it: bookings.subscription_id is set and the confirmed seat is debited through the ledger (debit:<bookingId>) |
| Two or more (TKN-440) | 409 { code: 'plan_selection_required', message, plans }. Nothing is booked or debited. Retry with one entry’s subscriptionId. |
plansentries arePlanSelectionCandidate(libs/sharedscheduling.schema.ts):{ subscriptionId, planName, planType, remainingCredits, planEndsAt }.remainingCreditsis the ledger balance (null for unlimited);planEndsAtis the plan’sbookingHorizon. They come in the order eligibility lists plans, so a picker preselects the same one everywhere: unlimited, then recurring with credits, then class pack, then drop-in; oldest first within a tier. The server never picks between them.- The server counts a plan as able to pay without judging this session’s caps, overlap or horizon; those are judged only on the plan charged. Eligibility does judge them, so the schedule can show one unblocked plan where the server counts two. The web member drawer therefore always names the plan it shows, picker or not.
- Each refusal is logged at debug (
check=plan_selection_required, org id, candidate count, subscription ids) and emitsbooking_plan_selection_required { candidateCount }(grouporganization, no person or plan details). - Web: on the 409 the member drawer opens its plan picker with the plans from the body, in the server’s order and the first preselected, explains why above them (
payments.errorCodes.plan_selection_required,_childwhen acting for a child), and refreshes the schedule. Booking from the picker retries with the chosensubscriptionId. Mobile sendssubscriptionIdwhenever eligibility lists a plan that can pay (decideBookingPlan, plan picker for two or more).
Spaces
A space is a locations row (ADR-0024 D1–D4). Enforcement is behind the per-org flag spaces, default off. Flag off behaves exactly as before TKN-444. Full rules: locations behavior › Spaces flag.
- Default space (not gated).
POST /sessionswithlocationIdomitted takes the class type’sdefaultLocationIdwhen that space is active. Create-many (timetable × dates) does the same per slot. - Single create/edit (flag on). A session in an
exclusivespace may not overlap another live session there (409space_conflict). A session’scapacitymay not exceed the space’scapacity(409space_capacity_exceeded). Live = notcancelled/archived, not soft-deleted. End == start is not overlap.sharedspaces skip the overlap rule. The conflict check runs first. - Override. Owner/admin retry with
overrideSpaceConflict: { reason }and/oroverrideSpaceCapacity: { reason }. Audited asclass_session.space_override(categoryoperations). A coach sending a needed override gets 403; an unneeded override is ignored. - Edit scope.
PATCHchecks overlap only whenlocationId,startsAtorendsAtis sent, and capacity only whenlocationIdorcapacityis sent. The edited session never clashes with itself. - Capacity detail. A session without a capacity of its own is not compared; a space without a capacity sets no ceiling.
- Concurrency. The check and the write run in one transaction that locks the space row (
SELECT … FOR UPDATE), so two concurrent writes into one space cannot both pass the overlap check. - Space ownership on edit.
PATCHwith alocationIdof another org is refused with 400 ‘Location not found in this organization’ (create already did this), flag on or off. - Bulk writers (flag on). No override; a space clash is always skipped.
| Writer | Space clash handling |
|---|---|
| Schedule-template preview/apply | conflicts[] group with kind: 'space' and locationName. Skipped even in overwrite mode; counts in skipped. abort mode aborts on it. |
POST /sessions/copy-week | Same conflicts[] shape (kind: 'space'); skipped. |
Spotter class_sessions.create_many | skipped[] entry with reason: 'space_occupied'. |
Spotter replicate_day | A copy that overlaps a live session of another class in its exclusive space blocks the whole target date (listed in blockedTargetDates). Copies are checked against existing sessions only. |
Conflict groups are keyed by kind|classTypeId|day|time. Flag off: every group has kind: 'class_type' and locationName: null.
Web. Settings call locations Spaces. The class-type form has a default space picker. With the flag on and two or more active spaces, session create/edit requires a space. On either 409, owners/admins get an override dialog that asks for a reason; coaches see the error.
Booking policy rules (TKN-447/448)
ADR-0024 D9–D12. Behind the per-org flag booking-policy-rules, default off.
- Layers.
booking_policy_rulesrows override the org’s booking settings, each setting on its own:registrationOpens,registrationCloses,cancelDeadline(each aTimeRuleor null = unbounded),allowLateCancel,lateCancelCredit(forfeit|refund). A rule decides only the keys present in itssettings. The most specific layer that sets a key wins:session(pinnedclass_session_id) >class_type(scope names a class type) >space(names a hall) >program>org_rule(names none of the three, e.g. weekday only) >org_default(the TKN-357organizationscolumns). On a tie, a rule whose scope carries aplancondition wins. Every resolved value carriesdecidedBy: { layer, ruleId }(ruleId: nullfororg_default). - A
plancondition on the schedule. The schedule resolves plan-conditioned rules only for a member’s sole plan: when the session’s eligibility iseligibleon exactly one plan, its windows and (unbooked) cancel deadline are that plan’s. With two or more plans, a blocked one, or none, it resolves with no plan, so a rule with aplancondition does not apply there.book()resolves the plan that pays (chosen or auto-picked), and so does the staff trace. A plan-conditioned rule can therefore show a multi-plan member a class as open and refuse it at booking, or the reverse. The settings dialog never writes aplancondition, so only a rule saved through the API can do this today. - Scope. The ADR-0024 D9 condition model (
policyScopeSchema): groups ORed, conditions ANDed, values ORed. Fieldsspace,class_type,program,plan,weekday,start_time,lead_time; opsin,not_in,between. Aspaceis always a hall (locations.kind='hall', Hebrew מתחם); a session in an area is judged by its hall (class_sessions.location_id). Areas are never a scope. - Time rules.
before_start { minutes },day_before_at { days, time },week_before_at { weeks, weekday, time }(booking open only), computed inorganizations.timezone(apps/api/src/booking-policy/time-rule.ts). A local time that does not exist on a DST day moves forward (02:30 on 2026-03-27 in Asia/Jerusalem is 03:00); an ambiguous one takes the earlier instant (01:30 on 2026-10-25 is the +03:00 one).before_startis absolute minutes andday_before_ata wall-clock time, so across a transition they differ by an hour. - Saving. Owner and admin (
bookingPolicy:manage). Refused 400 when the open is not strictly before the close, when the close is after the start, or forweek_before_aton the close or the cancel deadline (judged on one session per distinct local weekday and start time the rule matches in the next 90 days, not only the next one); also refused when the cancel deadline the session would get falls at or before registration opens and the rule sets either (cancel_deadline_not_after_registration_opens). A session that is deleted archives the rules pinned to it; copying a session does not copy a pinned rule. Refused 409{ code: 'booking_policy_conflict', message, conflicts: [{ ruleId, ruleName, settings }] }when another live rule at the same layer sets one of the same settings to a different value over an overlapping scope; nothing is saved. The check and the write run in one transaction under a per-org advisory lock (live rules read through it), so of two overlapping saves at the same moment one gets the 409. A create pastMAX_LIVE_BOOKING_POLICY_RULES(200) live rules is refused 400{ code: 'booking_policy_limit', message, max }; archiving one frees a place. Twonot_inconditions on one field always count as overlapping (conservative). While the flag is off for the org the write endpoints answer 403{ code: 'feature_disabled' }; the reads still answer. Delete archives (archived_at). Each create, edit and archive writes onebooking_policy.updatedaudit row withbefore/after{ scope, settings, classSessionId }. - Booking.
book()asks the resolver (built once per request inresolveOrgBookingConfig: the org row it already reads, plus one rules query while the flag is on) for the session’s open and close instants. The two 409s keep their codes and bodies:{ code: 'registration_not_yet_open', opensAt },{ code: 'registration_closed', closedAt }. Staff skip both. The schedule’sregistrationOpensAt/registrationClosesAt/registrationState/cancellationDeadlinecome from the same resolver, loaded once per request and resolved per session in memory. - Cancel terms are copied onto the booking (D12). With the flag on, a booking (and a revived one: a re-book is a new acceptance) stores
policy_snapshot = { version: 2, cancelDeadline: TimeRule | null, allowLateCancel, lateCancelCredit, decidedBy }. The deadline is kept as its rule and computed against the session’s current start, so a class moved after booking (an edit, a bulk move, a vendor sync) moves its deadline with it. A cancel reads the snapshot when present and today’s org columns otherwise (never the live rules, which it does not load), so a later rule edit never moves terms a member already accepted; a new booking takes the new terms. The schedule and class detail show a booked member exactly what the cancel enforces: both readcancelTermsFor(booking), so a booking made before the flag flipped shows the org-column deadline, not a rule’s. A snapshot that does not parse (a hand edit, the never-shipped version 1) reads the org columns and logspolicy_snapshot_invalid; a rule or quota row that does not parse is left out (booking_policy_rule_invalid,plan_quota_invalid). Neither fails a read or a booking. - Late cancel. Past the deadline with late cancel off: 400
{ code: 'cancellation_closed', message, deadline }(messageunchanged). Allowed: the debit is kept (forfeit) unless the snapshot saysrefund, in which case the reversal is written as for an early cancel. Flag off or no rule:forfeit, as before. - Flag off, or on with no rules. The resolver reads only the org columns and reproduces
resolveRegistrationWindowand the cancel-window maths exactly; no snapshot is written. Flag off is a kill switch: every booking cancels on the org columns, including one that carries a snapshot from a rule (its terms come back if the flag is turned on again). Archiving a rule does not move a booking already made under it; turning the flag off does.apps/api/src/bookings/booking-policy-parity.int.spec.tsruns 17 booking and cancel scenarios twice on one seeded org, both flags off and both on with no rules and no buckets, and compares every status, body, booking row, ledger row, counter and class detail; a flag-off booking also keeps its statement count. - Reads.
GET …/booking-policy/rules?includeArchived=,GET …/booking-policy/effective?classSessionId=(orspaceId/classTypeId/programId/planId, plusdraft=<rule JSON>/excludeRuleIdfor the settings dialog’s live preview) → every setting withdecidedBy, andpreview= the same rules applied to the next matching session (registrationOpensAt,registrationClosesAt,cancellationDeadlineAt). The web never computes an instant itself.
Entry quotas at booking (TKN-446)
Behind the per-org flag booking-policy-rules, default off. Configuration and the plan columns as “All classes” rows: subscriptions-plans › Entry quotas. ADR-0024 D5/D8, amended 2026-10-16: quotas are limits, not pools, and an edit applies at once to every member, like a plan limit.
- A booking must fit every quota of the plan whose scope matches the session: for each, the member’s bookings on the subscription in that quota’s window that match its scope (
QUOTA_COUNTED_STATUSES, confirmed and waitlisted, like the caps) must be below its allowance.positionis display order only; there is no “first with room” and no implicit “all classes” quota. - A matching quota of 0 → 400
{ code: 'not_covered_by_plan' }(the plan leaves the class out), judged before anything else. Otherwise the first full matching quota byposition→ 400{ code: 'bucket_exhausted', bucketName }. The member’s ways out are a pack purchase or renew-early (ADR-0019 §4); nothing is charged automatically. A session no quota matches is allowed, subject to the plan’s credits and the other rules. - Credits are the plan’s: the debit (and an early cancel’s reversal) is the plan’s ledger row, untouched by quotas. The plan-wide day/week/month caps, which the editor shows as the “All classes” quotas, apply as before.
- Usage is one grouped read of
bookings+class_sessions+class_typesover the union of the matching quotas’ windows, inside the booking transaction (countMatchingBookingsinentitlement-buckets.ts): each booked session’s scope facts are built once and each window is a range filter. Quotas that cannot refuse on their count (unlimited, or 0) are not counted at booking. Two bookings racing for a quota’s last slot serialize: one wins, the other is refusedbucket_exhausted. - With the flag off nothing is read; a plan with no quotas reads its (empty) quota list and checks nothing, so it behaves exactly as before. The schedule judges quotas the way
book()does, for the whole week in two reads at most (the plans’ live quotas, then one count over every (plan, session) pair), andbookingPlanEntry.buckets[]lists the quotas that match the session with their usage in the session’s window; empty when none matches or the plan has none. The member’s plan picker shows these lines under the plan’s credits line. Quotas stay with the current subscription past a scheduled change’s boundary, as inbook(). - The staff “Can X book Y?” trace (
GET …/sessions/:sessionId/booking-preview?membershipId=,subscriptions:view— owner and admin; a coach gets 403, the answer is a member’s finance data — unflagged and read-only; 404 when the session or member is not the org’s) judges the member asbook()would, registration window included, for every plan they hold in the auto-pick order. It returns the verdict (the first plan that can pay; otherwise the window reason and then the first plan’s reasons), which plan would pay (chargeTo { source: 'plan', subscriptionId }), per-plan credits, quota lines and cap numbers, and the session’s policy block withdecidedBy. A plan-conditioned rule resolves for the plan that would pay. A staff member is judged as staff (decidedBy.layer: 'staff'); a member with no plan isuntracked, allowed unless the org requires a plan.
Waitlist
No flag: production had no customer bookings when these rules shipped. Code: fillFreeSeats and the seat/queue helpers in apps/api/src/bookings/booking-enforcement.util.ts, WaitlistPromotionService (waitlist-promotion.service.ts), WaitlistExpiryService, BookingMembershipListener; notifications in apps/api/src/notifications/booking-notifications.listener.ts.
Queue order and rank
- A new waitlist place gets
waitlist_position = max(position of the session's live waitlisted rows) + 1, never count + 1, so it lands behind everyone already waiting even when positions have gaps. - Promotion walks the queue by
(waitlist_position asc, created_at asc, id asc)(loadWaitlistQueues). - No screen or email shows the stored number. Members and staff see a computed rank: the row’s 1-based place among the session’s live waitlisted rows in that order (
waitlistRank). Thebook()response carrieswaitlistRank(read after commit) and the waitlisted email states the same rank;waitlistPositionon the row is the stored sort key.
Filling free seats (fillFreeSeats)
One engine fills every free seat on a session from its waitlist, in queue order, inside the caller’s transaction. Free seats = capacity − seats taken (confirmed + attended + trial seats) − held seats (Arbox roster; in book() also the aggregator’s). Unlimited capacity fills every eligible waiter. It returns one booking.promoted payload per promotion; the caller publishes them only after its transaction commits (publishPromotions / WaitlistPromotionService.publish).
Trigger (trigger on the event) | Caller | When |
|---|---|---|
cancel | BookingsService.cancelForSubject (member, guardian, staff-for-member route) | The cancelled booking was confirmed |
admin_cancel | BookingsService.adminCancel; WaitlistPromotionService.releaseMemberBookings (departing member) | A confirmed seat was cancelled |
sweep | BookingEntitlementSweepService.writeClassification | The plan-change / lifecycle sweep revoked a confirmed seat |
capacity | ClassSessionsService.update → WaitlistPromotionService.fillSessions | capacity went up (or to unlimited) on a session that had one; after the edit commits, own serializable transaction |
arbox | ArboxScheduleImportService → fillSessions | An inbound Arbox roster sync cancelled a Taikan seat (cancellation_reason='arbox') |
trial_released | TrialSeatsService.release (Sales reschedule, a no_show/cancelled trial outcome, Not relevant, conversion/linking of a trial whose class already started, or of a lead whose member already held a seat in the class); OrganizationLeadsService archive / bulk archive → fillSessions | A trial appointment in the session stopped holding its seat: ended without a check-in (endPendingTrials, superseded) or confirmed no-show / cancelled. A checked-in trial ends attended and keeps its seat, and a trial moved into the converting member’s booking keeps it there, so nothing is filled for either |
book | BookingsService.book | A seat is free and members are waiting: they are filled first, in a committed transaction of their own (fillSessions) so a newcomer who is then refused (hold limit, full, out of credits) does not undo their promotions, and again inside the newcomer’s transaction as a guard against a seat freed in between. The newcomer is confirmed only if a seat is still free afterwards, else waitlisted behind them |
scheduler | WaitlistExpiryService.refillFreeSeats (same 5-minute job as the expiry) | Backstop: any published session starting between 15 minutes and 14 days from now with fewer seats taken than its capacity and members waiting (a fill that failed, a seat freed by a path that does not refill) |
Nothing is promoted on a session that is not published, is soft-deleted, starts within 15 minutes (WAITLIST_PROMOTION_CUTOFF_MINUTES, now >= startsAt − 15 min), or whose registration has closed (registrationClosesAt from the policy resolver, when set). Inside the cutoff a freed seat stays free and the waiters keep their places (closed by the expiry job at the start); anyone may book the seat directly while registration allows, ahead of the waitlist. Session cancel, delete and bulk delete never promote.
Who may take a seat
Candidates are judged in queue order. A candidate passed over keeps their place for the next free seat.
| Check | Always / flagged | Passed over as |
|---|---|---|
Membership status='active' and not deleted | Always | Silently (no skip event): the departure teardown cancels their rows |
Subscription (if the row has one) status active, past_due or debt | Always | subscription_inactive (paused, cancelled, pending, scheduled) |
Registration still open for the candidate’s plan (forSession(session, { planId })): a rule scoped to the plan can close it earlier than for everyone | Rules apply only with booking-policy-rules on | registration_closed |
evaluateBooking: pack expiry, plan horizon, overlap, day/week/month caps, own waitlisted row excluded from the counts | Always. A member with no plan is judged on overlap only | The evaluator’s reason (credits_expired, plan_ends_before_session, overlap, daily_limit, weekly_limit, monthly_limit) |
Plan quotas (not_covered_by_plan, bucket_exhausted) | Only with booking-policy-rules on (WaitlistPolicy.bucketsEnabled) | The evaluator’s reason |
Ledger debit (tryDeductCreditAtomic, key debit:<bookingId>) | Always, for a plan with credits | no_credits |
Each skip is logged (check=waitlist_promotion_skipped) and emits waitlist_promotion_skipped { reason, sessionId } after commit. subscription_inactive is a skip reason only, never a booking refusal code.
What a promotion does
- The row becomes
status='confirmed',waitlist_position=NULL,promoted_at=now.policy_snapshotis kept as written when the member joined the waitlist. - The member’s other waitlisted rows on the same org-local calendar day are cancelled:
cancellation_reason='waitlist_promoted_elsewhere',cancelled_by_user_id=NULL. No seat is freed by that. No separate cancellation notice is sent; the promotion message lists them. - After commit:
booking.promoted→BookingNotificationsListenersends one email (WAITLIST_PROMOTED) and one push (pushNotifications.copy.waitlistPromoted), both behind thebookingUpdateopt-outs and routed to a managed member’s adults (“regarding”): “You’re in: - Analytics and logs (after commit):
waitlist_promoted, onewaitlist_removed_elsewhereper released row, oneseats_filled_from_waitlistper session and trigger. See observability.
Free-cancel grace after a late promotion
BookingPolicyResolver.cancelTermsFor(booking, startsAt) reads promoted_at. When a booking was promoted after its cancel deadline (promotedAt > deadlineAt, deadline not null), the deadline becomes min(startsAt, promotedAt + 10 min) (WAITLIST_PROMOTION_GRACE_MINUTES) and graceUntil names that instant (null in every other case). A cancel up to it is an early cancel: the credit is refunded and booking_grace_cancel { bookingId } is tracked. After it the normal late rules apply (cancellation_closed, or late cancel with forfeit/refund). Cancelling inside the grace does not restore the same-day waitlist places the promotion released. A booking with no deadline (free until the start) needs no grace. The schedule’s cancellationDeadline is this effective deadline, so older clients that only read it keep working.
Holding waitlist places on a credit plan
Joining a waitlist spends nothing; the credit is spent on promotion. On a plan with credits, every place must still be backed: the ledger balance must be ≥ (the subscription’s live waitlisted rows on future sessions) + 1 (waitlistHoldCovered). Otherwise:
- balance ≤ 0 → 400
{ code: 'no_credits', message: 'No credits remaining' }(unchanged); - balance > 0 but already committed → 400
{ code: 'no_credits', message: 'You need one available credit for each waitlist place' }, loggedcheck=waitlist_hold_limit_refusedand trackedwaitlist_hold_limit_refused { subscriptionId }. The code staysno_creditsbecause mobile switches exhaustively on codes.
Unlimited plans have no hold limit; caps and the same-day rule bound them.
Places on a class that started
WaitlistExpiryService (@ScheduledJob('WaitlistExpiryService.expireStarted', '*/5 * * * *')) first cancels every live waitlisted row whose session starts_at <= now(), with cancellation_reason='waitlist_expired' and no notification. They stop counting against caps and quotas. Per org with any closed: log check=waitlist_expired and waitlist_expired { count }. Safe to overlap: the update matches only rows still waitlisted. The same run then refills free seats with members waiting on upcoming sessions (refillFreeSeats, trigger scheduler, above).
A member who leaves
releaseMemberBookings(orgId, membershipId) cancels the member’s live confirmed and waitlisted rows on future sessions with cancellation_reason='member_removed', refunds each confirmed seat through the ledger (staff-cancel semantics), then fills each freed session (trigger='admin_cancel'). Idempotent; never fails the departure. Callers:
BookingMembershipListeneronMEMBERSHIP_DEACTIVATED(staff remove a member or set themcancelled; account deletion inusers.service; the join-release / discard paths) and onMEMBERSHIP_SUSPENDED(staff setsuspended; new event, emitted post-commit byMembershipsService).MemberErasureServicedirectly, before the rows are deleted.
Log check=member_removed_bookings_cancelled (cancelled, seatsFreed) when anything was cancelled; member_removed_bookings_failed on error.
Session edits
- Capacity up → fill (above). Capacity down never cancels anyone.
- Time change (
startsAtorendsAtmoved) → after commitsession.time_changed(previousStartsAt, new times, the confirmed and waitlisted booking ids) →BookingNotificationsListener: aBOOKING_TIME_CHANGEDemail (old → new time; waitlisted members get the waitlist wording) and asessionTimeChangedpush to every confirmed and waitlisted member (bookingUpdateopt-outs), and a new T-30 reminder for each confirmed booking keyed on the new start. The reminder queued for the old start fails itsbooking-confirmedprecondition and drops.TrialSessionSyncListenermoves the session’s pending trial appointments (and the lead’s trial date and trial follow-up task) to the new start; a coach brief already sent re-arms for the new time (Trials).
Cancellation reasons
bookings.cancellation_reason (BOOKING_CANCELLATION_REASONS in libs/shared/src/lib/constants/booking.ts). NULL = cancelled before the column existed; readers treat it as a member or staff cancel.
| Value | Written by |
|---|---|
member | cancelForSubject: the member, or a guardian acting for them |
staff | adminCancel; cancelForSubject when staff act for the member |
session_cancelled | ClassSessionsService.cancel, deleteSession, bulkDelete |
entitlement_sweep | BookingEntitlementSweepService.writeClassification (revoked bookings) |
member_removed | WaitlistPromotionService.releaseMemberBookings |
waitlist_promoted_elsewhere | fillFreeSeats (same-day release) |
waitlist_expired | WaitlistExpiryService.expireStarted |
arbox | ArboxScheduleImportService (a seat the Arbox roster dropped) |
A re-booked (revived) row clears cancellation_reason and promoted_at. The staff roster shows the reason as a badge on cancelled rows (schedule.sessionSheet.cancellationReasons.*). The spaces report’s late-cancel rate counts only member, staff and NULL, each judged against that booking’s effective deadline (its own terms, promotion grace included), so a cancel inside a grace is not late.
What the session and booking responses say
Additive fields (libs/shared/src/lib/schemas/scheduling.schema.ts), computed by the server so no client compares a deadline with its own clock:
| Field | On | Meaning |
|---|---|---|
waitlistOpen | session | A member who cannot get a seat may join now: waitlistCapacity null → true, 0 → false, N → live waitlisted count < N |
waitlistCount | session | Live waitlisted rows |
myWaitlistRank | session | The viewer’s computed rank; null unless waitlisted |
myCancellationState | session | free (cancel refunds; always for a waitlist place), late_refund (past the deadline, late cancel allowed, the terms refund), late_forfeit (past the deadline, late cancel allowed, the entry is lost), closed (cancel refused: past the deadline with late cancel off, or already checked in), not_booked |
myFreeCancelUntil | session | ISO end of the promotion grace while it runs and the booking is confirmed; else null |
myPromotedAt | session | ISO promoted_at of the viewer’s booking, or null |
cancellationDeadline | session | The effective deadline, grace included (legacy field, kept for mobile) |
promotedAt, cancellationReason, waitlistRank | booking (/bookings/my, roster) | As stored; waitlistRank is the computed rank, null unless waitlisted |
Web: the member drawer offers the waitlist from waitlistOpen, shows “You’re #N on the waitlist” from myWaitlistRank, and labels the cancel from myCancellationState (“Free cancel until HH:MM” during the grace; a refund note on late_refund; the late-cancel wording on late_forfeit; disabled on closed). Its was_late_cancel analytics flag reads the same state. Both fall back to the old capacity/deadline rule only when the API omits the field. The staff roster orders and numbers the waitlist by waitlistRank.
Golden paths
Owner: weekly schedule
GET /organizations/:orgId/class-typesto see programs/class types.POST /organizations/:orgId/sessionsfor each slot (defaultstatus='draft'). Pre-validated against classType + location + coach membership. WithlocationIdomitted, the class type’s active default space is used. With thespacesflag on, the space checks run.- Optionally
PUT /sessions/:id/workoutsto attach pre-built workouts, orPOST /sessions/:id/workouts/inlineto create one in place. POST /sessions/:id/publish(orPOST /sessions/bulk-publishwith a filter for a whole week of drafts).- Members now see the session inside their schedule and may book.
Owner/coach: copy last week (sessions)
- From the schedule’s “Set up timetable” menu, Copy last week.
POST /organizations/:orgId/sessions/copy-week{ programId, fromWeekStart, toWeekStart }(coach or above, org-scoped).fromWeekStart/toWeekStartare the Monday (or locale week-start) of the source and target weeks — the web client always sends the visible week fortoWeekStartand that week minus 7 days forfromWeekStart, but the endpoint itself doesn’t assume they’re exactly a week apart.- The server reads the source week’s live sessions (
deletedAt IS NULL, status notcancelled/archived) for that program, and for each one derives class type, weekday + time in the org’s own timezone, duration (endsAt - startsAt), coach membership, location and capacity — the session’s own values, not the class type’s defaults. This mirrors howProgramTemplatesService.fromHistoryderives a schedule template’s slots, but per-session rather than deduped to unique(day, time)pairs. - That occurrence list is expanded onto the target week (
durationWeeks=1) through the exact same planner and conflict detectionapplyToScheduleuses (see program-templates/behavior.md) — same class type, overlapping interval, same calendar date — always inskipmode. With thespacesflag on, an overlap in an exclusive space is also a conflict (kind: 'space'). Nothing already on the target week is touched; a clash is reported and left alone. - New sessions insert as
draft, exactly likePOST /sessionsand template apply. - Response:
{ created, skipped, overwritten: 0, blocked, conflicts[] }— the apply response shape,overwrittenalways0since copy-week never overwrites. Audit-logged as oneclass_session.createrow for the batch,reason: 'copy_week', carrying the created session ids. - Idempotent. Copying the same week twice creates nothing the second time — every session from the first copy is already on the target week and gets skipped.
- The Programming board has its own, unrelated “Copy last week” (
POST /organizations/:orgId/daily-programming/copy-week, see daily-programming) — that one copies workouts (published daily-programming cells) onto next week’s board. This one copies sessions (the class timetable itself). Same phrase, two different surfaces and two different endpoints.
Owner/coach: repeat a new session weekly
No flag: additive and opt-in per create (the switch defaults off), nothing existing changes shape.
- In the new-session sheet, Repeats weekly needs a class type, date and start time. It shows Template first, then the days inline: a row of weekday pills (the session’s day marked with a dot) and one line per day that’s on, holding its start times plus a + for another. A day switched on starts at the session’s start time.
- Template lists the class type’s program’s schedule templates. Suggested: the one already holding this class type, else the only one, else New template (name defaults to “{classType} weekly”, 1 week).
- The days and times start as every time the chosen template already holds for this class type plus the session’s own day and time, and follow both until the coach edits them. After an edit, changing the session’s date or start time still moves the session’s own entry (unless the coach removed it). Switching template drops the edits and reloads from the new one; edits made for one class type are ignored after switching class type. The days stay locked while templates load, and if the template list fails to load the repeat shows an error with Try again and blocks save — so a save can never replace the slot with a list that hadn’t seen it, or create a duplicate template.
- Save — and Open builder, and the structure-review hand-off to the builder — is blocked while two times of the class type run into each other (overlap by
defaultDurationMin ?? 60) or a time falls outside 06:00–21:00, the same rules as the template editor. - On save, after
POST /sessionssucceeds (and any workout is attached): a new template isPOST /program-templateswith one slot carrying the session’s hall, area and coach; an existing one isPATCHed with its fullslots[], this class type’s slot taking exactly the repeat’s times (a time removed here leaves the template) and keeping its own hall, area and coach, other slots untouched. Capacity, title and notes come from the class type on apply. Sessions already on the schedule are not deleted by a removed time. - Then
POST /program-templates/:id/apply{ mode:'schedule', startDate: <session date>, conflictMode:'skip', classTypeIds:[<class type>] }. The session just created is a same-class-type clash, so its own slot is skipped; times already on the schedule are skipped too; the rest land as drafts for the template’sdurationWeeks. - A failure after the session exists is non-fatal: the session stays. If the template write fails, the toast says the repeat did not save; if the template was written but the apply failed, the toast says the template was saved but the weeks weren’t filled. Success toasts the created count.
Owner/coach: copy a session to another day (TKN-478)
No flag: it is additive (before it, a drop on another day did nothing), and the same-day move between halls is unchanged.
- In the Expanded or Condensed view, drag a session card onto another day; or, the keyboard path, the card’s ⋮ menu → Copy to day… → a day of the shown week (the session’s own day is not offered). The Programming view shows class types, not session cards, so it has nothing to drag.
- Where it lands: the hall section it is dropped into (or the session’s own hall outside sections and from the menu); in the Expanded hour grid, at the hour of the slot it lands on, elsewhere at the session’s own time. Dropping on the same day in another hall’s section still moves it (space-context README §8.2); the same day in the same hall does nothing; nothing lands in “Other spaces”.
POST /organizations/:orgId/sessions/:id/copy{ startsAt, locationId?, overrideSpaceConflict?, overrideSpaceCapacity? }(coach or above). The copy keeps class type, title, coach (member or guest), capacity, waitlist capacity, notes and duration; it has no workout of its own; its status is the create default (draft). Its area is kept only in the same hall (ADR-0024 D13). It is written throughcreate, so the space rules (withspaceson) and the coach checks apply: an exclusive clash or a capacity above the hall’s/area’s opens the override dialog for owner/admin and a refusal toast for a coach.- Done: the copy’s sheet opens (to assign a workout), the live region and a toast say “{session} copied to {day}”, and the toast’s Undo deletes the copy.
schedule_session_copied { via: drag | menu, view: expanded | condensed, crossSpace }.
Coach: daily class
GET /organizations/:orgId/sessions?weekStart=…for the assigned slots.- Open session, see the roster via
GET /sessions/:id/roster(bookings and lead trial seats, live trials on top, mark / unmark a booking as a trial; see Trials). The kiosk still readsGET /sessions/:id/bookings. - Tap Open check-in screen (from the dashboard session sheet or the member schedule drawer) to launch the authenticated kiosk at
/[lang]/kiosk/:sessionId— full-screen QR (GET /sessions/:id/qr-token) plus a live, tappable roster for a fixed front-desk tablet. - Throughout class,
POST /sessions/:id/check-in/:bookingId(tap on the kiosk roster) for any member who hasn’t self-checked-in. - Optionally
POST /sessions/:id/workouts/inlineto log what was actually trained that day.
Member: book + attend
GET /sessions?weekStart=…— receives sessions withbookingEligibilityper session.- If
eligibility.status='eligible'and the chosen plan is unblocked,POST /sessions/:id/book. The server first applies the billing gate, then picks the single plan that can pay; with several and nosubscriptionId, it answers 409plan_selection_required— see Which plan pays. - Receives
confirmedorwaitlisted(depends on capacity). Push + email fire after commit. - T-30 minute reminder push lands (scheduled BullMQ job).
- On arrival, scan QR or tap GPS check-in →
POST /sessions/:id/self-checkin.selfCheckinvalidates: confirmed booking exists, check-in window open (isWithinCheckinWindow), QR signature valid OR GPS distance ≤GPS_MAX_METERS. - Booking status →
attended,checkedInAt,checkinMethodpopulated.
Member: cancel
DELETE /organizations/:orgId/sessions/:sessionId/book.- Service reads the deadline and late-cancel terms from the booking’s
policy_snapshotwhen it has one (flagbooking-policy-rules), otherwise fromorg.cancellationWindowHours/allowLateCancellation. If past the deadline:- Late cancel not allowed → 400
{ code: 'cancellation_closed', message, deadline }. - Allowed → cancel proceeds; the debit is kept unless the snapshot says
lateCancelCredit: 'refund'.
- Late cancel not allowed → 400
- Otherwise cancel proceeds, credit refunded (if booking had
subscriptionId). - The row gets
cancellation_reason='member'('staff'when staff cancel through the member route). A cancel inside a promotion grace refunds and tracksbooking_grace_cancel. - If the cancelled booking was
confirmed,fillFreeSeatsfills every free seat from the waitlist in queue order (trigger='cancel'). A member out of credits, or one the booking rules refuse right now (an expired pack, a plan ending before the class, an overlapping booking, a day/week/month cap; a plan quota only withbooking-policy-ruleson), or whose plan is paused, is skipped and keeps their place; each skip is logged (check=waitlist_promotion_skipped) and emitswaitlist_promotion_skipped { reason, sessionId }after commit. The member’s own waitlisted row does not count against their caps. See Waitlist for the cutoff, registration close and what the promoted member is sent. - The cancellation email’s
creditRefundedis whatreverseBookingCreditreturned (false when there was nothing to reverse), on both the member and the staff cancel.
Booking rules: one evaluator
evaluateBooking (libs/shared/src/lib/booking-evaluator.ts) is the single place that decides whether a plan lets its member take a seat. It is pure: callers load the facts with the queries they already run and pass null for a rule they do not judge. It returns { allowed, reasons[], decidedBy: { layer: 'staff' | 'untracked' | 'plan' }, chargeTo: { source: 'plan', bucketId } | null }. reasons follows one order, most actionable first: not_covered_by_plan > credits_expired > no_credits > bucket_exhausted > plan_ends_before_session > overlap > daily_limit > weekly_limit > monthly_limit. The two bucket reasons (TKN-446) are reachable only when the plan has buckets and plan-entitlement-buckets is on; buckets: null judges exactly what it judged before.
| Rule | book() | Schedule eligibility | Entitlement sweep | Waitlist promotion |
|---|---|---|---|---|
| Staff bypass | All rules skipped | status: 'staff' | n/a | n/a |
| Forms gate, billing gate | Before any plan (409s) | form_required, debt_blocked, plan_not_started, no_plan | Not judged | Not judged |
| Registration window | 409, members only; the instant comes from the policy resolver | Not a block reason (registrationState, same resolver) | Not judged | Close only: once registration has closed nothing on the session is promoted; the opening is not judged |
| Class start | 400 session_started (staff too); 404 for a deleted session | n/a | Only future sessions are candidates | Nothing promoted within 15 minutes of the start (WAITLIST_PROMOTION_CUTOFF_MINUTES) |
| Membership and plan status | Billing gate; subscription re-verified active/past_due/debt in the transaction | debt_blocked, plan_not_started, no_plan | n/a | Inactive or deleted membership skipped silently; a paused, cancelled (or any non-live) plan skipped as subscription_inactive |
not_covered_by_plan, bucket_exhausted | Evaluator, every matching quota counted in the transaction (flag booking-policy-rules) | Evaluator, one grouped count per request | Evaluator; kept bookings judged against the new plan’s quotas | Evaluator, only with booking-policy-rules on |
credits_expired | When the plan is picked (explicit plan: 400; auto-pick: plan skipped, and the first plan’s own reason when none is usable) | Evaluator | Evaluator (flag booking-policy-rules) | Evaluator, always |
no_credits | Auto-pick (evaluator), then the ledger debit under the row lock; a waitlist place needs balance ≥ places held + 1 | Evaluator; a null balance reads as unlimited | Evaluator, against a simulated balance | Ledger debit (skip) |
plan_ends_before_session | Evaluator, before the re-checks and counting | Evaluator | Evaluator (flag booking-policy-rules); off, kept | Evaluator, always |
overlap | Evaluator; other sessions only; a member with no plan too | Evaluator; other sessions only (a booked session is not its own overlap) | Evaluator, against the baseline + kept-so-far | Evaluator, always; a member with no plan too |
| Day/week/month caps | Evaluator; target plan’s caps past a scheduled change’s boundary, counting only bookings past it | Evaluator; the same target-plan caps and boundary | Evaluator, against the baseline + kept-so-far | Evaluator, always, own row excluded |
| Capacity | Waitlist filled first, then confirm, waitlist or Session is full; seats taken = confirmed + attended + trial seats (pending or attended) + held | capacityRemaining, not a block reason | Not judged | Every free seat (capacity − confirmed − attended − trial seats − held) |
Three of the old disagreements between these columns were plain bugs and are fixed for every org: eligibility now applies a scheduled change’s target-plan caps past the boundary, a session the member already holds no longer shows overlap on itself, and an auto-picked booking whose only plan is an expired pack answers credits_expired (what the schedule showed), not no_credits. The fourth, the sweep judging a pack’s expiry and the plan’s horizon, revokes seats and sends mail, so it is behind booking-policy-rules (per org, default off; off keeps such a booking, as before). Each is a regression test in apps/api/src/bookings/booking-rules-parity.int.spec.ts, the flagged one on both sides of its flag.
Edge cases & error states
| Scenario | Behavior |
|---|---|
copy-week on a program with no class types, or a source week with no live sessions | { created: 0, skipped: 0, overwritten: 0, blocked: 0, conflicts: [] }, no writes |
copy-week with programId from another org | NotFoundException('Program not found in this organization') |
copy-week run twice with the same fromWeekStart/toWeekStart | Second run creates nothing — every session from the first run is now a same-class-type, overlapping-interval clash and is skipped |
Create/edit overlaps a live session in the same exclusive space, flag spaces on | ConflictException 409 { code: 'space_conflict', message, locationId, conflictingSessionIds }. Owner/admin may retry with overrideSpaceConflict: { reason } |
Session capacity above space capacity, flag spaces on | ConflictException 409 { code: 'space_capacity_exceeded', message, locationId, spaceCapacity, capacity }. Owner/admin may retry with overrideSpaceCapacity: { reason } |
| Coach sends a space override that is needed | 403 |
Same requests with flag spaces off | No space checks; overrides ignored (pre-TKN-444 behavior) |
| Member books an already-booked session | BadRequestException('Already booked for this session') |
| Member books before the org’s opens-before lead time | ConflictException 409 { code: 'registration_not_yet_open', opensAt: <ISO> }. Staff bypass. |
| Member books at or after the org’s closes-before lead time | ConflictException 409 { code: 'registration_closed', closedAt: <ISO> }. Staff bypass. |
Org PATCH leaves registration_opens_before_minutes <= registration_closes_before_minutes | BadRequestException('Registration must open earlier than it closes') |
Member books a draft session | BadRequestException('Session is not open for booking') |
| Member holds a plan cancelled for non-payment with debt (any plan, not just the selected one) | ConflictException 409 { code: 'booking_blocked_debt', billingDebt }. A plan in its grace period (debt) books until its cancellation instant. Staff bypass |
| Member’s only live plans are presales that have not started | ConflictException 409 { code: 'plan_not_started', startsOn } |
| No credits remaining | The ledger debit finds no balance → 400 { code: 'no_credits', message: 'No credits remaining' } |
| Daily quota hit | 400 { code: 'daily_limit', message: 'Daily booking limit reached (N per day)' } |
| Weekly quota hit | 400 { code: 'weekly_limit', message: 'Weekly booking limit reached (N per week)' } |
| Monthly quota hit | 400 { code: 'monthly_limit', message: 'Monthly booking limit reached (N per month)' } |
| Overlap (and plan disallows) | 400 { code: 'overlap', message: 'You already have a booking that overlaps with this session' } |
| Explicitly chosen pack past its validity window | 400 { code: 'credits_expired', message: 'These credits have expired' } |
Several usable plans, no subscriptionId passed | 409 { code: 'plan_selection_required', plans }, nothing booked. See Which plan pays |
| Live plans exist but none is usable (credits used up, coverage or validity ended, unpaid term past its deadline) | 400 with the first plan’s own reason: { code: 'credits_expired' } when the only plan is an expired pack (what the schedule shows), { code: 'no_credits' } for a spent one |
No live plan, org plan_required_to_book off (default) | Booking allowed, no credit accounting |
No live plan, org plan_required_to_book on | ConflictException 409 { code: 'plan_required_to_book' } |
Unsigned required_for_booking compliance form (club regulations) | ConflictException 409 { code: 'form_signature_required', requirements }; the pending instance is lazily minted so clients deep-link into signing. Eligibility surfaces it as status='form_required' + requiredForms. Staff bypass. See forms/behavior.md §9. |
Booking beyond a subscription’s pending end (cancelAtPeriodEnd=true) | ConflictException 409 { code: 'booking_beyond_subscription_end', endsAt: <ISO> } (FIT-254 C3). endsAt travels alongside code (FIT-254 review Wave C, C7) so clients render a fully localized, date-formatted message — the English message string is a fallback only. See subscriptions-plans/behavior.md’s C3 — booking-horizon guard. |
| Booking beyond a subscription’s boundary with a scheduled plan change pending | Allowed; daily/weekly caps evaluated against the target plan, overlap + credits against the current subscription (reduced-scope C3 follow-up). FIT-254 review Wave C, C3-guard: the target-plan day/week count queries only count bookings that themselves start AFTER the boundary (startsAt > currentPeriodEnd) — mirrors the boundary sweep’s own post-boundary-only counting, so a pre-boundary booking sharing the same UTC-day/week window as the post-boundary session being judged can no longer consume a target-plan quota it was never attributed to. |
| Cancel a non-existent / non-active booking | NotFoundException('Booking not found') |
Cancel after window with late cancel off (org column, or the booking’s policy_snapshot) | 400 { code: 'cancellation_closed', message, deadline }; message is the hours-before sentence it always was |
Session a quota of 0 of the plan matches, flag booking-policy-rules on | 400 { code: 'not_covered_by_plan' }; eligibility blockReason: 'not_covered_by_plan' |
| A matching quota full for its window | 400 { code: 'bucket_exhausted', bucketName } (the first full one by position); eligibility blockReason: 'bucket_exhausted' |
| Rule save overlaps a same-layer rule that sets a setting differently | 409 { code: 'booking_policy_conflict', message, conflicts: [{ ruleId, ruleName, settings }] }; nothing saved |
Rule save with open not before close, close after start, or week_before_at on close / cancel | 400 |
Rule write while booking-policy-rules is off for the org | 403 { code: 'feature_disabled' } |
| Self-checkin outside check-in window | BadRequestException('Check-in window is not open for this session') |
| Self-checkin with mismatched QR signature/expiry | BadRequestException('Invalid or expired QR code') |
| Self-checkin GPS too far | BadRequestException('GPS_TOO_FAR') — exact string for client i18n |
| Cross-org booking attempt | NotFoundException('Session not found') (every read joins through programs.organizationId) |
| Concurrent bookings racing for last slot | SERIALIZABLE isolation + withSerializableRetry (32 full-jitter attempts on 40001 serialization failure) |
| Book a class that has started (staff too) | 400 { code: 'session_started', message: 'This class has already started' } |
| Book a deleted session | NotFoundException('Session not found') |
| Join a waitlist on a pack whose balance is already committed to other waitlist places | 400 { code: 'no_credits', message: 'You need one available credit for each waitlist place' }; waitlist_hold_limit_refused |
| Book while the class has a free seat and members waiting | The waiting members are promoted first (trigger='book'); the newcomer is confirmed only if a seat is left, else waitlisted last |
| Seat freed within 15 minutes of the start, or after registration closed | No promotion; the seat stays free for a direct booking |
| Waitlisted member’s plan is paused / membership not active | Skipped (subscription_inactive / silently), keeps their place |
| Member promoted into one class while waitlisted for others the same day | The others are cancelled (waitlist_promoted_elsewhere), listed in the promotion message |
| Unpublish with only waitlisted members left | 400 Cannot unpublish a session that has active bookings |
| Undo check-in on a full class | Allowed: an attended row already held its seat |
bulk-preview with includeBooked=false | Excludes sessions with non-cancelled bookings from matched count and samples; still reports withActiveBookings separately |
bulk-delete with notifyAffectedMembers=false | Sessions soft-deleted, bookings cancelled, audit written; no emails sent |
Side effects
| Event | Side effects |
|---|---|
| Book (confirmed) | Email (confirmation, respects bookingUpdate/email opt-out); push notification; BullMQ T-30min reminder scheduled if start > now+30m. |
| Book (waitlisted) | Email (waitlisted). Push only fires for confirmed. |
| Self-cancel | Email (cancellation, with creditRefunded flag); any waitlist promotion it triggers notifies through booking.promoted (row below). |
| Admin-cancel | Same as self-cancel emails for affected member (creditRefunded = the ledger’s answer); fills free seats from the waitlist (admin_cancel). |
Waitlist promotion (cancel, admin cancel, sweep, capacity increase, Arbox cancel, book() fill) | Post-commit booking.promoted event → BookingNotificationsListener: one “you’re in” email + one push (both behind bookingUpdate opt-outs; a managed member’s adults get them, framed “regarding booking-confirmed precondition as a fresh booking. |
Session time change (update with new startsAt/endsAt) | Post-commit session.time_changed event → BookingNotificationsListener: BOOKING_TIME_CHANGED email (old → new time) + push to every confirmed and waitlisted member (bookingUpdate opt-outs); a new T-30 reminder for confirmed bookings keyed on the new start — the old one fails its precondition and drops. |
| Session cancel | Bulk cancel all non-cancelled bookings (session_cancelled; confirmed and attended seats refunded); end the session’s pending trials (endSessionTrials, sales_trial activity session_cancelled, rep’s trial task cancelled for cancelled trials); per-user class-cancelled email (org TZ-formatted dateTime, logo). No promotion. |
| Stale waitlist expiry (every 5 min) | Waitlisted rows on started classes → cancelled (waitlist_expired); no notification; waitlist_expired { count } per org. |
| Member removed / suspended / account deleted / erased | Future confirmed and waitlisted rows → cancelled (member_removed), confirmed seats refunded, freed seats filled from the waitlist; no member notification. |
| Bulk-delete | Soft-delete; cancel all non-cancelled bookings on those sessions (session_cancelled; confirmed and attended seats refunded); end those sessions’ pending trials as on cancel; one audit_logs row carrying batch metadata; per-user class-cancelled email per affected session unless notifyAffectedMembers=false. |
| Booking policy rule create / edit / archive | One audit_events row: action booking_policy.updated, category settings, target booking_policy_rule, before/after { scope, settings, classSessionId }. |
Book (flag booking-policy-rules on) | bookings.policy_snapshot written with the cancel terms; rewritten when a cancelled row is revived. |
| Space override on create/edit | One audit_events row per overridden rule (AuditService): action class_session.space_override, category operations, target the session; metadata rule (conflict/capacity), reason, locationId, locationName, and conflictingSessionIds or spaceCapacity/capacity. |
| Check-in (manual) | bookings.status='attended', checkedInAt=now, checkinMethod='manual'. |
| Check-in (self/QR) | Same with checkinMethod='qr'. |
| Check-in (self/GPS) | Same with checkinMethod='gps'. |
| Undo check-in | status='confirmed', clear checkedInAt, clear checkinMethod. |
Permissions matrix
| Endpoint | owner | admin | coach | member |
|---|---|---|---|---|
POST /sessions (create) | ✓ | ✓ | ✓ | ✗ |
POST /sessions/copy-week | ✓ | ✓ | ✓ | ✗ |
PATCH /sessions/:id | ✓ | ✓ | ✓ | ✗ |
Space override (overrideSpaceConflict / overrideSpaceCapacity on create/edit) | ✓ | ✓ | ✗ (403) | ✗ |
PUT /sessions/:id/workouts | ✓ | ✓ | ✓ | ✗ |
POST /sessions/:id/workouts/inline | ✓ | ✓ | ✓ | ✗ |
POST /sessions/:id/publish | ✓ | ✓ | ✗ | ✗ |
POST /sessions/:id/unpublish | ✓ | ✓ | ✗ | ✗ |
POST /sessions/:id/cancel | ✓ | ✓ | ✗ | ✗ |
POST /sessions/bulk-* | ✓ | ✓ | ✗ | ✗ |
GET /sessions / GET /sessions/:id | ✓ | ✓ | ✓ | ✓ |
GET /sessions/:id/bookings (roster) | ✓ | ✓ | ✓ | ✗ |
POST /sessions/:id/check-in/:bookingId | ✓ | ✓ | ✓ | ✗ |
DELETE /sessions/:id/check-in/:bookingId | ✓ | ✓ | ✓ | ✗ |
GET /sessions/:id/roster, PUT /sessions/:id/bookings/:bookingId/trial, POST|DELETE /sessions/:id/trials/:appointmentId/check-in, POST /sessions/:id/trials/:appointmentId/notes, GET /sessions/trials-today | ✓ | ✓ | ✓ | ✗ |
GET /sessions/:id/qr-token | ✓ | ✓ | ✓ | ✗ |
POST /sessions/:id/self-checkin | ✓ | ✓ | ✓ | ✓ |
GET /sessions/attendance/:membershipId | ✓ | ✓ | ✓ | ✗ |
POST /sessions/:id/book | ✓ (staff bypass) | ✓ | ✓ | ✓ |
DELETE /sessions/:id/book | ✓ | ✓ | ✓ | ✓ |
DELETE /sessions/:id/bookings/:bookingId (admin cancel) | ✓ | ✓ | ✗ | ✗ |
POST /members/:membershipId/refund-credit | ✓ | ✓ | ✓ | ✗ |
GET /bookings/attendance-trend | ✓ | ✓ | ✗ | ✗ |
GET /analytics/attendance/summary | ✓ | ✓ | ✗ | ✗ |
GET /analytics/attendance/daily | ✓ | ✓ | ✗ | ✗ |
GET /bookings/my | self | self | self | self |
GET /booking-policy/rules, GET /booking-policy/effective | ✓ | ✓ | ✓ | ✗ |
POST/PATCH/DELETE /booking-policy/rules (flag booking-policy-rules) | ✓ | ✓ | ✗ | ✗ |
GET /sessions/:id/booking-preview?membershipId= (subscriptions:view: a member’s plans and credits) | ✓ | ✓ | ✗ | ✗ |
isStaffRole returns true for owner|admin|coach — used for credit/quota bypass on booking but not for the booking RBAC check (members can still book).
Credits and the entitlement ledger (TKN-445)
book()spends the credit after inserting the booking so the ledger row can be keyeddebit:<bookingId>; out of credits still throws and rolls the booking back. Waitlist entry spends nothing but needs the balance to cover every live waitlist place the plan holds plus this one; promotion writes the debit (debit:<bookingId>, so a replayed promotion charges once).- Member cancel, staff cancel, a departing member’s release and session
cancel/delete/bulk delete refund via
reverseBookingCredit(reversal:<bookingId>): at most once per booking, only for a debited (or pre-ledger) booking. Session cancel/delete refund checked-in (attended) seats too. Late cancel and no-show keep the debit, as before; a cancel inside a promotion grace is not late. - The plan-change sweep re-charges a kept confirmed booking under
debit:<bookingId>:<n>(a real second spend on the new or just-reset plan). - Booking keys carry the booking’s life (
@<created_at epoch ms>): a re-book revives the cancelled row with the same id and a newcreated_at(TKN-439), so the new life is debited and can be reversed again instead of colliding with the previous life’s keys. - The credit check and schedule eligibility read the ledger sum for every org
(no flag since 2026-10-14). The debit locks the subscription row
FOR NO KEY UPDATE; a subscription with no opened ledger falls back to its counter and is reported (check=entitlement_ledger_unopened). POST /members/:membershipId/refund-creditnow needs{ reasonCode, note? }. Manual balance changes stay withsubscriptions:manage, except the late-cancel waiver (refund-credit), which coaches can do with a reason (bookings:manage); every one is audited (entitlement.adjusted, actor = the staff member). The schedule’s refund button sendslate_cancel_waivedand shows for owner, admin and coach.
Managed members
Spec: managed-members/behavior.md §4.4, §4.7. Booking for someone else is behind the managed-members flag.
- Booking for a child: a confirmed adult with
canBookpassesforUserId; the session’s eligibility is resolved for the child throughGuardianAccessService.actingMembership(apps/api/src/class-sessions/class-sessions.service.ts:972). The child’s plan, overlap, forms and debt rules apply, not the adult’s. - Attribution (ungated):
bookings.bookedByUserId/cancelledByUserIdare set only when someone other than the member acted (apps/api/src/bookings/bookings.service.ts:131,491). The member app shows “Booked by {name}”. guardian_onlyadults can’t book for themselves on either route (ungated). The plainPOST …/sessions/:id/bookanswers409 guardian_only_cannot_book; the member route refuses them as a subject (loadSubjectMembership). The session’sbookingEligibility.statusisguardian_only, so clients explain it instead of offering Book. The plain route and the eligibility re-readparticipationbefore refusing (isGuardianOnly,apps/api/src/memberships/participation.ts), becauserequireMembershipcaches the row for 30 s and a promotion doesn’t clear it. Once they hold a plan they aretrainsand book normally.- Youth consent: with the flag on, a child can’t book until a
parental_consent_youthform is signed. - No child check-in from an adult’s phone; multi-member kiosk check-in is not built.
- Reminders and cancellations about a child go to their adults (see
notifications/behavior.md).