Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesScheduling BookingsScheduling & Bookings — Behavior

Scheduling & Bookings — Behavior

State machines

Class session (class_sessions.status, enum publish_status)

FromToTriggerAllowed roles
(insert)draftPOST /sessions defaultowner/admin/coach
(insert)publishedPOST /sessions with status='published'owner/admin/coach
draftpublishedPOST /sessions/:id/publishowner/admin
publisheddraftPOST /sessions/:id/unpublish (rejected while any confirmed, attended or waitlisted booking exists)owner/admin
draft/publishedcancelledPOST /sessions/:id/cancelowner/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)

FromToTrigger
(insert)confirmedPOST /sessions/:id/book with a seat still free after the waitlist took its turn
(insert)waitlistedPOST /sessions/:id/book when no seat is left and the waitlist is open
waitlistedconfirmedfillFreeSeats 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
confirmedattendedPOST /sessions/:id/check-in/:bookingId (staff) or POST /sessions/:id/self-checkin (member)
attendedconfirmedDELETE /sessions/:id/check-in/:bookingId (undo check-in; no capacity check, the seat never left)
confirmed/waitlistedcancelledDELETE /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)
waitlistedcancelledThe 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 livecancelledSession cancel, single delete or bulk delete (session_cancelled), attended included
confirmedno_showNotificationSchedulerService.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) on bookings, across every status. book() reads the member’s row for the session whatever its status: an active one (confirmed, waitlisted, attended, no_show) raises BadRequestException('Already booked for this session'); a cancelled (or soft-deleted) one is revived in place — same id, new status/waitlist_position/subscription_id, cancelled_at/checked_in_at/checkin_method/deleted_at cleared, created_at and updated_at set 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 + attended bookings (SEAT_TAKING_STATUSES, seatsTaken / sessionOccupancy in booking-enforcement.util.ts) + Sales trial appointments in the class that are pending or attended and not superseded (holdsTrialSeat / SEAT_HOLDING_TRIAL_OUTCOMES in trial-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 below waitlistCapacity (null = unlimited, 0 = no waitlist), else Session is full. Check + insert run inside one SERIALIZABLE transaction. getBookingCounts (the schedule’s bookingCount/capacityRemaining, trial seats added via trialSeatCounts), the overview tiles and the day-capacity query count the same statuses.
  • No booking on a class that started or was deleted — book() answers 404 Session not found for a soft-deleted session and 400 { code: 'session_started', message: 'This class has already started' } once startsAt <= now, staff included.
  • A member with no plan is still judged on overlap — book() and promotion treat them as allowOverlappingBookings=false with no caps or credits.
  • Staff booking their own seat never carry a plan — subscription_id stays 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) (see ClassSessionsService.update/publish/unpublish/cancel/checkIn).
  • A draft cannot be booked — book() throws BadRequestException('Session is not open for booking') unless session.status === 'published'.
  • Registration window — an org-level rule, set on the booking settings page next to the cancellation window: registration_opens_before_minutes and registration_closes_before_minutes, both measured back from the session’s starts_at. Null on a side leaves it unbounded, so an org that sets nothing behaves as before. For members, book() throws a ConflictException 409 { 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 carry registrationOpensAt, registrationClosesAt and a server-resolved registrationState (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. With booking-policy-rules on, 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() and bulkDelete() run the status update and the bookings update in one SERIALIZABLE transaction with withSerializableRetry, so a book() racing them either lands first (and is cancelled and refunded) or sees the cancelled session. Every live booking gets cancellation_reason='session_cancelled'; confirmed and attended seats 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 ends attended, the rest cancelled and superseded, each with a sales_trial lead 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() throws BadRequestException('Cannot unpublish a session that has active bookings') while any confirmed, attended or waitlisted row 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 with batchId, sessionIds, filter payload.
  • Bulk-publish forces status filter to draft — caller’s filter status is 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' AND classCredits IS NULL) skip both. Course-type subs are excluded entirely from resolveSubscription*.
  • Plan caps count every booking that took the slot (TKN-457) — a plan’s max_bookings_per_day/week/month count the subscription’s bookings in the window with status confirmed, waitlisted, attended or no_show (QUOTA_COUNTED_STATUSES in apps/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, quotas on GET /organizations/:orgId/subscriptions/my), the schedule’s bookingsToday/bookingsThisWeek/bookingsThisMonth and blockReason, 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 only confirmed/waitlisted. Staff still bypass the caps.
  • Subscription re-verified inside the write transaction (FIT-254 review Wave C, C5) — activeSub is resolved BEFORE book()’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 own status and rejects (BadRequestException('Subscription not found or not active')) unless it’s still active/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 whose policy_snapshot says lateCancelCredit: 'refund' (a rule under flag booking-policy-rules, see Booking policy rules).
  • Coach role validation — classSession.coachMembershipId only accepts memberships with role ∈ {owner, admin, coach} and status='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 with coachMembershipId — 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:

#ConditionResponse
1Any plan cancelled for non-payment with its debt still open409 { code: 'booking_blocked_debt', message, billingDebt }, billingDebt = the debt with the earliest cancellation ({ amountInCents, since, reason, payBy, cancelsAt, state: 'cancelled' })
2No live plan and the org’s plan_required_to_book is on409 { code: 'plan_required_to_book', message }
—No live plan, setting off (the default)Allowed; the booking is untracked (no credit accounting) — unchanged
3Every live plan is a scheduled presale409 { 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 debt row 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.ts holds the one rule the gate, the schedule’s eligibility and member status share: bookingHorizon (how far ahead a plan may book), isCreditSource, startsBeyondHorizon. For a debt row only its cancellation instant applies. A lapsed cash plan (cash_prepaid_through passed) and a pay-later term past payment_due_at keep booking until debtCancelsAt of that instant, even before the lifecycle pass stamps the debt; past it they answer cash_prepayment_expired / payment_due on an explicit pick and are not auto-selected. A session starting after the horizon → booking_beyond_subscription_end.
  • A debt row 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.
  • message is an English fallback. Web and mobile map the codes through payments.errorCodes; booking_blocked_debt renders the amount from billingDebt, plan_not_started renders startsOn as 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-subscriptionId booking on a debt plan.
  • resolveOrgBookingConfig returns { timeZone, registrationPolicy, planRequiredToBook, debtPolicy } (billing_grace_days + timezone).
  • Schedule eligibility (bookingEligibility on GET /sessions, ClassSessionsService.getBookingEligibility) applies the same rules: debt_blocked (rule 1, plans empty), no_plan (no plan, including when plan_required_to_book is on), plan_not_started (presales only). Bookable plans come from findBookableForMembership (active and debt); each entry’s planEndsAt is its bookingHorizon (a debt plan’s cancellation instant), and a session past it is blocked with plan_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 payResponse
None liveUntracked booking (the billing gate already refused it if the org requires a plan)
Live, none can pay400 { code: 'no_credits' }
OneCharged 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.
  • plans entries are PlanSelectionCandidate (libs/shared scheduling.schema.ts): { subscriptionId, planName, planType, remainingCredits, planEndsAt }. remainingCredits is the ledger balance (null for unlimited); planEndsAt is the plan’s bookingHorizon. 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 emits booking_plan_selection_required { candidateCount } (group organization, 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, _child when acting for a child), and refreshes the schedule. Booking from the picker retries with the chosen subscriptionId. Mobile sends subscriptionId whenever 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 /sessions with locationId omitted takes the class type’s defaultLocationId when that space is active. Create-many (timetable × dates) does the same per slot.
  • Single create/edit (flag on). A session in an exclusive space may not overlap another live session there (409 space_conflict). A session’s capacity may not exceed the space’s capacity (409 space_capacity_exceeded). Live = not cancelled/archived, not soft-deleted. End == start is not overlap. shared spaces skip the overlap rule. The conflict check runs first.
  • Override. Owner/admin retry with overrideSpaceConflict: { reason } and/or overrideSpaceCapacity: { reason }. Audited as class_session.space_override (category operations). A coach sending a needed override gets 403; an unneeded override is ignored.
  • Edit scope. PATCH checks overlap only when locationId, startsAt or endsAt is sent, and capacity only when locationId or capacity is 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. PATCH with a locationId of 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.
WriterSpace clash handling
Schedule-template preview/applyconflicts[] group with kind: 'space' and locationName. Skipped even in overwrite mode; counts in skipped. abort mode aborts on it.
POST /sessions/copy-weekSame conflicts[] shape (kind: 'space'); skipped.
Spotter class_sessions.create_manyskipped[] entry with reason: 'space_occupied'.
Spotter replicate_dayA 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_rules rows override the org’s booking settings, each setting on its own: registrationOpens, registrationCloses, cancelDeadline (each a TimeRule or null = unbounded), allowLateCancel, lateCancelCredit (forfeit | refund). A rule decides only the keys present in its settings. The most specific layer that sets a key wins: session (pinned class_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-357 organizations columns). On a tie, a rule whose scope carries a plan condition wins. Every resolved value carries decidedBy: { layer, ruleId } (ruleId: null for org_default).
  • A plan condition on the schedule. The schedule resolves plan-conditioned rules only for a member’s sole plan: when the session’s eligibility is eligible on 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 a plan condition 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 a plan condition, 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. Fields space, class_type, program, plan, weekday, start_time, lead_time; ops in, not_in, between. A space is 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 in organizations.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_start is absolute minutes and day_before_at a 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 for week_before_at on 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 past MAX_LIVE_BOOKING_POLICY_RULES (200) live rules is refused 400 { code: 'booking_policy_limit', message, max }; archiving one frees a place. Two not_in conditions 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 one booking_policy.updated audit row with before/after { scope, settings, classSessionId }.
  • Booking. book() asks the resolver (built once per request in resolveOrgBookingConfig: 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’s registrationOpensAt / registrationClosesAt / registrationState / cancellationDeadline come 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 read cancelTermsFor(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 logs policy_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 } (message unchanged). Allowed: the debit is kept (forfeit) unless the snapshot says refund, 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 resolveRegistrationWindow and 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.ts runs 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= (or spaceId/classTypeId/programId/planId, plus draft=<rule JSON>/excludeRuleId for the settings dialog’s live preview) → every setting with decidedBy, and preview = 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. position is 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 by position → 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_types over the union of the matching quotas’ windows, inside the booking transaction (countMatchingBookings in entitlement-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 refused bucket_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), and bookingPlanEntry.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 in book().
  • 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 as book() 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 with decidedBy. 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 is untracked, 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). The book() response carries waitlistRank (read after commit) and the waitlisted email states the same rank; waitlistPosition on 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)CallerWhen
cancelBookingsService.cancelForSubject (member, guardian, staff-for-member route)The cancelled booking was confirmed
admin_cancelBookingsService.adminCancel; WaitlistPromotionService.releaseMemberBookings (departing member)A confirmed seat was cancelled
sweepBookingEntitlementSweepService.writeClassificationThe plan-change / lifecycle sweep revoked a confirmed seat
capacityClassSessionsService.update → WaitlistPromotionService.fillSessionscapacity went up (or to unlimited) on a session that had one; after the edit commits, own serializable transaction
arboxArboxScheduleImportService → fillSessionsAn inbound Arbox roster sync cancelled a Taikan seat (cancellation_reason='arbox')
trial_releasedTrialSeatsService.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 → fillSessionsA 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
bookBookingsService.bookA 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
schedulerWaitlistExpiryService.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.

CheckAlways / flaggedPassed over as
Membership status='active' and not deletedAlwaysSilently (no skip event): the departure teardown cancels their rows
Subscription (if the row has one) status active, past_due or debtAlwayssubscription_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 everyoneRules apply only with booking-policy-rules onregistration_closed
evaluateBooking: pack expiry, plan horizon, overlap, day/week/month caps, own waitlisted row excluded from the countsAlways. A member with no plan is judged on overlap onlyThe 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 creditsno_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

  1. The row becomes status='confirmed', waitlist_position=NULL, promoted_at=now. policy_snapshot is kept as written when the member joined the waitlist.
  2. 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.
  3. After commit: booking.promoted → BookingNotificationsListener sends one email (WAITLIST_PROMOTED) and one push (pushNotifications.copy.waitlistPromoted), both behind the bookingUpdate opt-outs and routed to a managed member’s adults (“regarding ”): “You’re in:
  4. Analytics and logs (after commit): waitlist_promoted, one waitlist_removed_elsewhere per released row, one seats_filled_from_waitlist per 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' }, logged check=waitlist_hold_limit_refused and tracked waitlist_hold_limit_refused { subscriptionId }. The code stays no_credits because 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:

  • BookingMembershipListener on MEMBERSHIP_DEACTIVATED (staff remove a member or set them cancelled; account deletion in users.service; the join-release / discard paths) and on MEMBERSHIP_SUSPENDED (staff set suspended; new event, emitted post-commit by MembershipsService).
  • MemberErasureService directly, 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 (startsAt or endsAt moved) → after commit session.time_changed (previousStartsAt, new times, the confirmed and waitlisted booking ids) → BookingNotificationsListener: a BOOKING_TIME_CHANGED email (old → new time; waitlisted members get the waitlist wording) and a sessionTimeChanged push to every confirmed and waitlisted member (bookingUpdate opt-outs), and a new T-30 reminder for each confirmed booking keyed on the new start. The reminder queued for the old start fails its booking-confirmed precondition and drops. TrialSessionSyncListener moves 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.

ValueWritten by
membercancelForSubject: the member, or a guardian acting for them
staffadminCancel; cancelForSubject when staff act for the member
session_cancelledClassSessionsService.cancel, deleteSession, bulkDelete
entitlement_sweepBookingEntitlementSweepService.writeClassification (revoked bookings)
member_removedWaitlistPromotionService.releaseMemberBookings
waitlist_promoted_elsewherefillFreeSeats (same-day release)
waitlist_expiredWaitlistExpiryService.expireStarted
arboxArboxScheduleImportService (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:

FieldOnMeaning
waitlistOpensessionA member who cannot get a seat may join now: waitlistCapacity null → true, 0 → false, N → live waitlisted count < N
waitlistCountsessionLive waitlisted rows
myWaitlistRanksessionThe viewer’s computed rank; null unless waitlisted
myCancellationStatesessionfree (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
myFreeCancelUntilsessionISO end of the promotion grace while it runs and the booking is confirmed; else null
myPromotedAtsessionISO promoted_at of the viewer’s booking, or null
cancellationDeadlinesessionThe effective deadline, grace included (legacy field, kept for mobile)
promotedAt, cancellationReason, waitlistRankbooking (/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

  1. GET /organizations/:orgId/class-types to see programs/class types.
  2. POST /organizations/:orgId/sessions for each slot (default status='draft'). Pre-validated against classType + location + coach membership. With locationId omitted, the class type’s active default space is used. With the spaces flag on, the space checks run.
  3. Optionally PUT /sessions/:id/workouts to attach pre-built workouts, or POST /sessions/:id/workouts/inline to create one in place.
  4. POST /sessions/:id/publish (or POST /sessions/bulk-publish with a filter for a whole week of drafts).
  5. Members now see the session inside their schedule and may book.

Owner/coach: copy last week (sessions)

  1. From the schedule’s “Set up timetable” menu, Copy last week.
  2. POST /organizations/:orgId/sessions/copy-week { programId, fromWeekStart, toWeekStart } (coach or above, org-scoped). fromWeekStart/toWeekStart are the Monday (or locale week-start) of the source and target weeks — the web client always sends the visible week for toWeekStart and that week minus 7 days for fromWeekStart, but the endpoint itself doesn’t assume they’re exactly a week apart.
  3. The server reads the source week’s live sessions (deletedAt IS NULL, status not cancelled/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 how ProgramTemplatesService.fromHistory derives a schedule template’s slots, but per-session rather than deduped to unique (day, time) pairs.
  4. That occurrence list is expanded onto the target week (durationWeeks=1) through the exact same planner and conflict detection applyToSchedule uses (see program-templates/behavior.md) — same class type, overlapping interval, same calendar date — always in skip mode. With the spaces flag 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.
  5. New sessions insert as draft, exactly like POST /sessions and template apply.
  6. Response: { created, skipped, overwritten: 0, blocked, conflicts[] } — the apply response shape, overwritten always 0 since copy-week never overwrites. Audit-logged as one class_session.create row for the batch, reason: 'copy_week', carrying the created session ids.
  7. 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.
  8. 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.

  1. 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.
  2. 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).
  3. 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.
  4. 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.
  5. On save, after POST /sessions succeeds (and any workout is attached): a new template is POST /program-templates with one slot carrying the session’s hall, area and coach; an existing one is PATCHed with its full slots[], 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.
  6. 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’s durationWeeks.
  7. 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.

  1. 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.
  2. 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”.
  3. 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 through create, so the space rules (with spaces on) 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.
  4. 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

  1. GET /organizations/:orgId/sessions?weekStart=… for the assigned slots.
  2. 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 reads GET /sessions/:id/bookings.
  3. 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.
  4. Throughout class, POST /sessions/:id/check-in/:bookingId (tap on the kiosk roster) for any member who hasn’t self-checked-in.
  5. Optionally POST /sessions/:id/workouts/inline to log what was actually trained that day.

Member: book + attend

  1. GET /sessions?weekStart=… — receives sessions with bookingEligibility per session.
  2. 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 no subscriptionId, it answers 409 plan_selection_required — see Which plan pays.
  3. Receives confirmed or waitlisted (depends on capacity). Push + email fire after commit.
  4. T-30 minute reminder push lands (scheduled BullMQ job).
  5. On arrival, scan QR or tap GPS check-in → POST /sessions/:id/self-checkin. selfCheckin validates: confirmed booking exists, check-in window open (isWithinCheckinWindow), QR signature valid OR GPS distance ≤ GPS_MAX_METERS.
  6. Booking status → attended, checkedInAt, checkinMethod populated.

Member: cancel

  1. DELETE /organizations/:orgId/sessions/:sessionId/book.
  2. Service reads the deadline and late-cancel terms from the booking’s policy_snapshot when it has one (flag booking-policy-rules), otherwise from org.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'.
  3. Otherwise cancel proceeds, credit refunded (if booking had subscriptionId).
  4. The row gets cancellation_reason='member' ('staff' when staff cancel through the member route). A cancel inside a promotion grace refunds and tracks booking_grace_cancel.
  5. If the cancelled booking was confirmed, fillFreeSeats fills 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 with booking-policy-rules on), or whose plan is paused, is skipped and keeps their place; each skip is logged (check=waitlist_promotion_skipped) and emits waitlist_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.
  6. The cancellation email’s creditRefunded is what reverseBookingCredit returned (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.

Rulebook()Schedule eligibilityEntitlement sweepWaitlist promotion
Staff bypassAll rules skippedstatus: 'staff'n/an/a
Forms gate, billing gateBefore any plan (409s)form_required, debt_blocked, plan_not_started, no_planNot judgedNot judged
Registration window409, members only; the instant comes from the policy resolverNot a block reason (registrationState, same resolver)Not judgedClose only: once registration has closed nothing on the session is promoted; the opening is not judged
Class start400 session_started (staff too); 404 for a deleted sessionn/aOnly future sessions are candidatesNothing promoted within 15 minutes of the start (WAITLIST_PROMOTION_CUTOFF_MINUTES)
Membership and plan statusBilling gate; subscription re-verified active/past_due/debt in the transactiondebt_blocked, plan_not_started, no_plann/aInactive or deleted membership skipped silently; a paused, cancelled (or any non-live) plan skipped as subscription_inactive
not_covered_by_plan, bucket_exhaustedEvaluator, every matching quota counted in the transaction (flag booking-policy-rules)Evaluator, one grouped count per requestEvaluator; kept bookings judged against the new plan’s quotasEvaluator, only with booking-policy-rules on
credits_expiredWhen the plan is picked (explicit plan: 400; auto-pick: plan skipped, and the first plan’s own reason when none is usable)EvaluatorEvaluator (flag booking-policy-rules)Evaluator, always
no_creditsAuto-pick (evaluator), then the ledger debit under the row lock; a waitlist place needs balance ≥ places held + 1Evaluator; a null balance reads as unlimitedEvaluator, against a simulated balanceLedger debit (skip)
plan_ends_before_sessionEvaluator, before the re-checks and countingEvaluatorEvaluator (flag booking-policy-rules); off, keptEvaluator, always
overlapEvaluator; other sessions only; a member with no plan tooEvaluator; other sessions only (a booked session is not its own overlap)Evaluator, against the baseline + kept-so-farEvaluator, always; a member with no plan too
Day/week/month capsEvaluator; target plan’s caps past a scheduled change’s boundary, counting only bookings past itEvaluator; the same target-plan caps and boundaryEvaluator, against the baseline + kept-so-farEvaluator, always, own row excluded
CapacityWaitlist filled first, then confirm, waitlist or Session is full; seats taken = confirmed + attended + trial seats (pending or attended) + heldcapacityRemaining, not a block reasonNot judgedEvery 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

ScenarioBehavior
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 orgNotFoundException('Program not found in this organization')
copy-week run twice with the same fromWeekStart/toWeekStartSecond 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 onConflictException 409 { code: 'space_conflict', message, locationId, conflictingSessionIds }. Owner/admin may retry with overrideSpaceConflict: { reason }
Session capacity above space capacity, flag spaces onConflictException 409 { code: 'space_capacity_exceeded', message, locationId, spaceCapacity, capacity }. Owner/admin may retry with overrideSpaceCapacity: { reason }
Coach sends a space override that is needed403
Same requests with flag spaces offNo space checks; overrides ignored (pre-TKN-444 behavior)
Member books an already-booked sessionBadRequestException('Already booked for this session')
Member books before the org’s opens-before lead timeConflictException 409 { code: 'registration_not_yet_open', opensAt: <ISO> }. Staff bypass.
Member books at or after the org’s closes-before lead timeConflictException 409 { code: 'registration_closed', closedAt: <ISO> }. Staff bypass.
Org PATCH leaves registration_opens_before_minutes <= registration_closes_before_minutesBadRequestException('Registration must open earlier than it closes')
Member books a draft sessionBadRequestException('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 startedConflictException 409 { code: 'plan_not_started', startsOn }
No credits remainingThe ledger debit finds no balance → 400 { code: 'no_credits', message: 'No credits remaining' }
Daily quota hit400 { code: 'daily_limit', message: 'Daily booking limit reached (N per day)' }
Weekly quota hit400 { code: 'weekly_limit', message: 'Weekly booking limit reached (N per week)' }
Monthly quota hit400 { 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 window400 { code: 'credits_expired', message: 'These credits have expired' }
Several usable plans, no subscriptionId passed409 { 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 onConflictException 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 pendingAllowed; 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 bookingNotFoundException('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 on400 { code: 'not_covered_by_plan' }; eligibility blockReason: 'not_covered_by_plan'
A matching quota full for its window400 { 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 differently409 { 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 / cancel400
Rule write while booking-policy-rules is off for the org403 { code: 'feature_disabled' }
Self-checkin outside check-in windowBadRequestException('Check-in window is not open for this session')
Self-checkin with mismatched QR signature/expiryBadRequestException('Invalid or expired QR code')
Self-checkin GPS too farBadRequestException('GPS_TOO_FAR') — exact string for client i18n
Cross-org booking attemptNotFoundException('Session not found') (every read joins through programs.organizationId)
Concurrent bookings racing for last slotSERIALIZABLE 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 sessionNotFoundException('Session not found')
Join a waitlist on a pack whose balance is already committed to other waitlist places400 { 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 waitingThe 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 closedNo promotion; the seat stays free for a direct booking
Waitlisted member’s plan is paused / membership not activeSkipped (subscription_inactive / silently), keeps their place
Member promoted into one class while waitlisted for others the same dayThe others are cancelled (waitlist_promoted_elsewhere), listed in the promotion message
Unpublish with only waitlisted members left400 Cannot unpublish a session that has active bookings
Undo check-in on a full classAllowed: an attended row already held its seat
bulk-preview with includeBooked=falseExcludes sessions with non-cancelled bookings from matched count and samples; still reports withActiveBookings separately
bulk-delete with notifyAffectedMembers=falseSessions soft-deleted, bookings cancelled, audit written; no emails sent

Side effects

EventSide 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-cancelEmail (cancellation, with creditRefunded flag); any waitlist promotion it triggers notifies through booking.promoted (row below).
Admin-cancelSame 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 ”), stating the free-cancel time when the grace applies and listing the same-day waitlist places released (no separate cancellation notice for those); the booking-confirmed WhatsApp; the T-30 reminder push, same job id and 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 cancelBulk 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 / erasedFuture confirmed and waitlisted rows → cancelled (member_removed), confirmed seats refunded, freed seats filled from the waitlist; no member notification.
Bulk-deleteSoft-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 / archiveOne 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/editOne 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-instatus='confirmed', clear checkedInAt, clear checkinMethod.

Permissions matrix

Endpointowneradmincoachmember
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/myselfselfselfself
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 keyed debit:<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 new created_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-credit now needs { reasonCode, note? }. Manual balance changes stay with subscriptions: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 sends late_cancel_waived and 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 canBook passes forUserId; the session’s eligibility is resolved for the child through GuardianAccessService.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 / cancelledByUserId are 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_only adults can’t book for themselves on either route (ungated). The plain POST …/sessions/:id/book answers 409 guardian_only_cannot_book; the member route refuses them as a subject (loadSubjectMembership). The session’s bookingEligibility.status is guardian_only, so clients explain it instead of offering Book. The plain route and the eligibility re-read participation before refusing (isGuardianOnly, apps/api/src/memberships/participation.ts), because requireMembership caches the row for 30 s and a promotion doesn’t clear it. Once they hold a plan they are trains and book normally.
  • Youth consent: with the flag on, a child can’t book until a parental_consent_youth form 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).