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

Scheduling & Bookings — QA Plan

Pre-requisites

  • Test organization seeded with: 1 owner, 1 admin, 1 coach, 2 members; 1 program (schedule delivery mode); 2 class types; 1 location with lat/lng; 1 plan with 10 credits, maxBookingsPerDay=2, maxBookingsPerWeek=10, allowOverlappingBookings=false; member subscribed to that plan.
  • A second organization for cross-org isolation checks.
  • The org’s timezone set to a non-UTC value (e.g. Asia/Jerusalem) so TZ bugs surface.
  • cancellationWindowHours=2, allowLateCancellation=false for default policy tests.

Golden path scenarios

G1 — Owner creates and publishes a session

StepActionExpected
1Sign in as owner, navigate to /dashboard/schedule.Calendar week loads.
2Open “Create session”, pick class type, location, coach, start/end times, capacity=10, waitlist=5.Form validates.
3Submit with status=draft.Session appears as draft (visible to staff, not members).
4Click “Publish”.Status flips to published; members can now see it on their schedule.

G2 — Member books and is confirmed

StepActionExpected
1Sign in as member with active sub. Open /schedule.Sees the published session with bookingEligibility.status='eligible', plan unblocked.
2Tap “Book”.Booking confirmed. Confirmation email arrives. Push notification arrives.
3Check /bookings/my.Booking listed with status confirmed.
4Reload session.myBookingStatus='confirmed'. Remaining credits decremented by 1.

G3 — Member self-cancels with refund

StepActionExpected
1With G2 booking in place, well before cancellation deadline, tap “Cancel”.Booking row → cancelled, cancelled_at set. Email sent with creditRefunded=true. Remaining credits +1.

G4 — Waitlist auto-promotion

StepActionExpected
1Reduce a session’s capacity to 1 (or fully book it). Have member-A confirmed, member-B waitlisted (waitlist_position=1).
2Member-A cancels.Member-B promoted: status flips to confirmed, waitlist_position=null, promoted_at set. One “You’re in” email and one push. Member-A’s row has cancellation_reason='member'.

The full waitlist rule set is in Waitlist scenarios (G10–G30).

Automated end to end in the browser by apps/web/e2e/specs/waitlist.spec.ts (make test-e2e-web E2E_ARGS=e2e/specs/waitlist.spec.ts): join on the default unlimited waitlist with rank, queue order and recomputed rank, same-day removal on promotion, capacity raise seating the queue before a newcomer, the one-credit-per-place hold limit, the late-promotion free-cancel grace, the 15-minute cutoff, and the staff roster’s computed ranks.

G5 — Staff manual check-in

StepActionExpected
1Coach opens session detail, sees roster with 5 confirmed members.
2Tap check-in on one member.Booking status → attended; checkin_method='manual'.
3Tap “Undo check-in”.Status → confirmed; checked_in_at and checkin_method cleared.

G6 — Member self-checkin via QR

StepActionExpected
1Staff generates display URL → QR code visible on a screen.
2Member scans within check-in window (default: TODO: verify window in isWithinCheckinWindow).Booking → attended, checkin_method='qr'.
3Scan again with the same QR.selfCheckin finds no confirmed booking (already attended) → 400.

G7 — Member self-checkin via GPS

StepActionExpected
1Location has lat/lng set. Member is physically within GPS_MAX_METERS.
2POST self-checkin with method=gps and current coords.Booking → attended, checkin_method='gps'.
3Repeat from > GPS_MAX_METERS away.400 with message GPS_TOO_FAR (exact string for client i18n).

G8 — Owner bulk-publishes a week of drafts

StepActionExpected
1Owner has 14 draft sessions for next week (twice-daily).
2POST /sessions/bulk-preview with {programId, dateFrom, dateTo, status:'draft'}.Returns {matched:14, withActiveBookings:0, samples:[10 rows]}.
3POST /sessions/bulk-publish with the same filter.Returns {batchId, publishedSessions:14}. All 14 flip to published. Audit log row exists.

G9 — Owner bulk-deletes wrong-day sessions

StepActionExpected
1Owner accidentally creates 8 sessions on Saturday for an org that’s closed Saturdays.
2POST bulk-preview with daysOfWeek:['saturday'].Returns matched count + 10 samples.
3POST bulk-delete with notifyAffectedMembers:true.Sessions soft-deleted, active bookings cancelled, batch audit row written, cancellation emails sent.

Waitlist scenarios

Setup for every scenario below unless it says otherwise. Org timezone Asia/Jerusalem, cancellationWindowHours=2, allowLateCancellation=true, no registration window, booking-policy-rules off. Members: A, B, C, D each on an unlimited monthly plan; P on a class pack with 3 credits; one owner and one coach. Sessions are created by the owner on /dashboard/schedule and published. “Full class” = capacity 1, waitlist size empty (unlimited), with A booked.

DB checks run against the worktree database (DATABASE_URL in apps/api/.env):

SELECT u.first_name, b.status, b.waitlist_position, b.promoted_at, b.cancellation_reason, b.cancelled_at FROM bookings b JOIN memberships m ON m.id = b.membership_id JOIN users u ON u.id = m.user_id WHERE b.class_session_id = '<sessionId>' ORDER BY b.created_at;

Network checks read GET /organizations/:orgId/sessions/:id (member drawer) and GET /organizations/:orgId/sessions/:id/roster (staff roster) in DevTools.

G10 — Queue order and the rank people see

StepActionExpected
1Full class 2 days out. B, then C, then D tap Join waitlist.Each gets “waitlisted”. DB: positions 1, 2, 3.
2C opens the class in the member drawer.”You’re #2 on the waitlist” (data-testid="waitlist-rank"); response myWaitlistRank: 2, waitlistCount: 3.
3B leaves the waitlist.DB: C still waitlist_position=2, D 3.
4C reopens the drawer; D opens it.C: “#1”; D: “#2”. Response myWaitlistRank 1 / 2. The stored 2 / 3 never shows.
5Owner opens the session sheet → Waitlisted group.Badges #1 (C), #2 (D) (data-testid="waitlist-rank-<bookingId>"); roster response carries waitlistRank 1 / 2.
6B joins again.DB: B waitlist_position=4 (max + 1, not count + 1); B sees “#3”.

G11 — The waitlist goes before a newcomer

StepActionExpected
1Full class (A booked), B waitlisted. In SQL, raise capacity without going through the API: UPDATE class_sessions SET capacity = 2 WHERE id = '<sessionId>'; Do this with crons off (local default), or the 5-minute refill (G20 step 3) promotes B first.B still waitlisted (nothing refilled).
2C taps Book.C gets “waitlisted” (#1 after B’s promotion). DB: B confirmed with promoted_at set, C waitlisted with position 2. B receives the “You’re in” email + push.
3API log.check=waitlist_promoted … trigger=book.

G12 — A capacity increase fills every new seat

StepActionExpected
1Full class; B, C, D waitlisted in that order.
2Owner edits the session capacity 1 → 3 and saves.B and C confirmed (promoted_at set), D still waitlisted, now #1. B and C each get one “You’re in” email and push.
3API log.Two waitlist_promoted … trigger=capacity lines; one seats_filled_from_waitlist event with count: 2.
4Owner lowers capacity 3 → 1.Nobody is cancelled; class shows 3/1 booked.

G13 — Staff cancel refills and records why

StepActionExpected
1Full class held by P (pack, 3 → 2 credits); B waitlisted.
2Owner removes P from the roster (admin cancel).P cancelled, cancellation_reason='staff'; P’s credits back to 3; P’s cancellation email says the credit was refunded. B confirmed, “You’re in” email + push.
3Owner opens Cancelled group in the sheet.P’s row shows “Cancelled by staff”.
4Repeat with B cancelling their own seat from the member drawer while C waits.B’s row reason member (“Cancelled by member”); C promoted (trigger=cancel).

G14 — A check-in never frees a seat

StepActionExpected
1Class starting in 1 hour, capacity 1. A booked; B waitlisted. Coach checks A in.A attended. B still waitlisted; schedule shows 1/1 booked, capacityRemaining: 0.
2C taps Book.C waitlisted (#2).
3Coach undoes A’s check-in.A back to confirmed; no capacity error; B still waiting.

G15 — No promotion inside 15 minutes or after registration closes

StepActionExpected
1Full class starting in 12 minutes; B waitlisted. A cancels (late cancel allowed).A cancelled. B stays waitlisted; no email. The class shows a free seat.
2C books it from the schedule.C confirmed directly: inside the cutoff nobody is promoted, so the free seat goes to whoever books it. B stays waitlisted until the expiry job closes the place (G20).
3Full class starting in 60 minutes; B waitlisted. Owner sets “Registration closes 2 hours before”. A cancels.B stays waitlisted.
4Same with a class starting in 3 hours.B promoted (outside both the cutoff and the close).

G16 — Who is passed over keeps their place

StepActionExpected
1Full class; B then C waitlisted. UPDATE subscriptions SET status='paused' WHERE id = '<B sub>'; A cancels.C promoted. B still waitlisted, still #1. Log check=waitlist_promotion_skipped reason=subscription_inactive.
2Restore B (status='active'). C cancels.B promoted.
3Full class; B then C waitlisted. Owner sets B’s membership to Suspended.B’s waitlist row is cancelled (member_removed, see G23); C remains #1.
4booking-policy-rules OFF. B’s plan has maxBookingsPerWeek=1 and B already holds another confirmed class this week. Full class; B then C waitlisted. A cancels.C promoted; B skipped (weekly_limit), keeps the place. Caps apply with the flag off.
5P with 0 credits left (book 3 other classes first) waits first, C second. A cancels.C promoted; P skipped no_credits.
6B’s class pack expired (current_period_end in the past) waits first, C second. A cancels.C promoted; B skipped credits_expired.

G17 — Promotion releases the same day’s other waitlists

StepActionExpected
1Two full classes the same local day, 08:00 and 19:00 (A in 08:00, C in 19:00), and a third full class the next day (D in it). B joins all three waitlists.
2A cancels 08:00.B confirmed in 08:00. B’s 19:00 row cancelled, cancellation_reason='waitlist_promoted_elsewhere', cancelled_by_user_id NULL. Next-day row untouched.
3B’s inbox and phone.ONE email and ONE push: “You’re in:
4B cancels 08:00.B’s 19:00 place is NOT restored.

G18 — Free-cancel grace after a late promotion

StepActionExpected
1Org window 2 hours. Full class starting in 90 minutes (already past the deadline). P (pack) waitlisted. A cancels late.P confirmed, credits 3 → 2. Email/push: “You’re in … Free cancellation until <promoted + 10 min>“.
2P opens the drawer.”Free cancel until HH:MM” (data-testid="free-cancel-until"); policy note “You got this place from the waitlist. Cancel before HH:MM to get your credit back.”; response myCancellationState: 'free', myFreeCancelUntil = promotion + 10 min, myPromotedAt set, cancellationDeadline = the same instant.
3Within 10 minutes P cancels.Cancelled; credits back to 3; email says refunded. Event booking_grace_cancel.
4Repeat 1, wait 11 minutes, reopen the drawer.No free-cancel line; button shows the late cancel wording (myCancellationState: 'late_forfeit'). Cancel keeps the credit.
5Repeat 1 with allowLateCancellation=false, and wait out the 10 minutes.During the grace the cancel is enabled; after it the button is disabled (myCancellationState: 'closed').
6A promotion that happens before the deadline (class in 2 days).No free-cancel line; myFreeCancelUntil: null; normal deadline.

G19 — One credit per waitlist place on a pack

StepActionExpected
1P has 1 credit. Two full classes on different days. P joins the first waitlist.Waitlisted. Credits still 1.
2P tries to join the second.Refused with “You need one available credit for each waitlist place” (code no_credits); log check=waitlist_hold_limit_refused.
3P leaves the first waitlist, joins the second.Allowed.
4P with 0 credits tries to join any waitlist.Refused “No credits remaining”.
5B (unlimited) joins three waitlists on three days.All allowed.

G20 — Waitlist places close when the class starts

StepActionExpected
1Full class starting in 20 minutes; B waitlisted. Wait until 5 minutes past the start (the job runs every 5 minutes; locally run WaitlistExpiryService.expireStarted or set CRONS_ENABLED=true).B cancelled, cancellation_reason='waitlist_expired'. No email or push. Log check=waitlist_expired count=1.
2B’s weekly cap count (quota card).The expired place no longer counts.
3Stage G11 step 1 (capacity raised in SQL, B waiting, class 2 days out), then run the job once (handleExpireStarted).B promoted with trigger=scheduler; “You’re in” email + push. A class starting in under 15 minutes is left alone.

G21 — Booking guards

StepActionExpected
1Class that started 5 minutes ago. Member taps Book (or POST /book).400 session_started “This class has already started”. Same for the owner booking themselves.
2Owner deletes a session; POST /book on its id.404 “Session not found”.
3Member with NO plan (org allows booking without a plan) books 09:00–10:00, then 09:30–10:30 the same day.Second refused with the overlap message.

G22 — Staff booking themselves carry no plan

StepActionExpected
1The coach also holds a pack. Coach books a full class from the member schedule (waitlisted), then the seat frees.Coach promoted; subscription_id NULL on the row; the coach’s pack credits unchanged.
2Owner books member P into a class from the roster (“book for member”).P’s pack is charged as before.

G23 — A member who leaves releases their places

StepActionExpected
1P holds a confirmed seat in a full class (B waiting) and a waitlist place elsewhere, both in the future, plus a past attended class.
2Owner removes P from the members list (status cancelled).Both future rows cancelled, member_removed; P’s pack refunded for the confirmed seat; B promoted (“You’re in”). Past attended row untouched. P gets no cancellation email. Log check=member_removed_bookings_cancelled cancelled=2 seatsFreed=1.
3Repeat with Suspended instead of removed.Same.
4Repeat with the member deleting their own account.Same.

G24 — Session cancel, delete and unpublish

StepActionExpected
1Class in 1 hour: P confirmed and checked in, B confirmed, C waitlisted. Owner cancels the session.All three rows cancelled, session_cancelled. P’s credit refunded (attended seat too). C is NOT promoted anywhere. Class-cancelled emails go out.
2Same with Delete session and with bulk delete.Same reasons and refunds.
3Published class whose only live row is a waitlisted member (cancel the confirmed one in SQL to stage it). Owner unpublishes.400 “Cannot unpublish a session that has active bookings”.

G25 — Moving a class tells everyone holding a place

StepActionExpected
1Class in 2 days: A confirmed, B waitlisted. Owner moves it one hour later.A and B each receive a “Class time changed” email (old → new time; B’s says they are on the waitlist) and push.
2A’s reminders.One T-30 reminder at the new time; the one for the old time does not fire.
3A has turned off booking-update emails. Repeat.A gets no email (push per their push setting).

G26 — Cancellation emails tell the truth about refunds

StepActionExpected
1P cancels early.Email: credit refunded.
2P cancels late (allowed, forfeit).Email: no refund.
3B (unlimited) cancels early.Email does not claim a credit refund (creditRefunded=false: nothing to reverse).
4Owner admin-cancels a booking made by staff for themselves.No refund claim.

G27 — The member drawer follows the server

StepActionExpected
1Session with waitlist size empty (unlimited), full.Drawer offers Join waitlist (waitlistOpen: true).
2Waitlist size 1, one already waiting.Drawer says the class is full, no join button (waitlistOpen: false).
3Waitlist size 0.No waitlist offered.
4Confirmed booking past the deadline with late cancel off.Cancel disabled; note “window closed” (closed).
5Same with a booking-policy rule lateCancelCredit: refund (flag on).Cancel enabled with “Your credit will be refunded” (late_refund).
6Set the device clock an hour off and reopen.States do not change (they come from the server).

G28 — Analytics and logs

Run G12, G17, G18, G19, G20 and check the API log (Better Stack locally: stdout) and PostHog debug (EventTrackingService logs each track at info outside production).

Event / checkExpected after
waitlist_promoted with trigger, graceApplied, removedCountEvery promotion
waitlist_removed_elsewhereG17 step 2 (one)
seats_filled_from_waitlistG12 (count: 2)
booking_grace_cancelG18 step 3
waitlist_hold_limit_refusedG19 step 2
waitlist_expiredG20
member_removed_bookings_cancelled (log only)G23

G29 — Spaces report late-cancel rate

StepActionExpected
1In one space this week: one member late cancel, one staff late cancel, one session cancel inside the window, one cancel inside a promotion grace./dashboard spaces report counts 2 late cancels (member + staff), not the session cancel or the grace cancel.

G30 — Request-invite always emails (retired join-email-ownership-proof)

StepActionExpected
1Open a public join link, choose “I don’t have an account”, enter an email.Response { deliveryMode: 'email', email }; no Clerk sign-up URL in the response; the email arrives. Same for every org (no flag).

Edge cases

E1 — Race for the last slot

StepActionExpected
1Session has capacity=1, 0 booked. Two members tap Book simultaneously.One gets confirmed. The other either gets waitlisted (if waitlist available) or BadRequestException('Session is full'). Both transactions retry on 40001 (withSerializableRetry, up to 32 jittered attempts).

E2 — Booking with no credits

StepActionExpected
1Member’s remaining_credits=0 on a class-pack plan.
2Tap Book.400 ‘No credits remaining’. Booking row not created.

E3 — Daily / weekly quota hit

StepActionExpected
1Member already has 2 bookings today on a plan with maxBookingsPerDay=2.
2Book a 3rd session today.400 ‘Daily booking limit reached (2 per day)’.

E3a — Attended and no-show bookings keep their cap slot (TKN-457)

StepActionExpected
1Member on a plan with maxBookingsPerWeek=2 books Sunday’s class. Staff check them in from the roster.Booking shows as attended. The member’s quota card shows 1 of 2 used this week.
2Member books Monday’s class.Confirmed. Quota card shows 2 of 2. Tuesday’s class on the schedule is blocked with the weekly-limit message.
3Member tries to book Tuesday’s class anyway.400 ‘Weekly booking limit reached (2 per week)‘.
4Repeat 1–3, but leave Sunday unchecked until the auto no-show job marks it no_show (2 hours after the class ends).Same as 2–3: the no-show still takes its slot.
5Member on maxBookingsPerDay=1 is checked in to the morning class, then tries the evening class the same day.Evening shows as blocked with the daily-limit message. Booking it returns 400 ‘Daily booking limit reached (1 per day)‘.
6Member with an attended Sunday class books Monday’s class, cancels it, and books Wednesday’s class.Wednesday is confirmed (the cancellation returned Monday’s slot). Quota card shows 2 of 2.

E4 — Overlap

StepActionExpected
1Member booked 09:00–10:00. Tries to book 09:30–10:30. Plan disallows overlapping.400 ‘You already have a booking that overlaps…’.

E4a — Registration window

StepActionExpected
1Owner sets opens 7 days / closes 30 minutes on the booking settings page and saves.Saved. Values show again as “7 days” and “30 minutes”.
2Member opens a session 10 days away.Book button replaced by a disabled “Registration opens …” label. A direct POST /book returns 409 registration_not_yet_open with opensAt.
3Member opens a session 3 days away.Booking succeeds.
4Member opens a session starting in 20 minutes.Disabled “Registration closed” label. A direct POST /book returns 409 registration_closed.
5Admin books themselves into the session from step 4.Succeeds: staff bypass the window.
6Owner sets closes to 8 days while opens stays 7 days and saves.Rejected: “Registration must open earlier than it closes”.
7Owner clears both fields and saves.Members can book any published session again.

E5 — Late cancel

StepActionExpected
1Session starts in 30 min. Member tries to cancel. Org cancellationWindowHours=2, allowLateCancellation=false.400 with hours-before message.
2Owner flips allowLateCancellation=true. Member retries.Cancel succeeds. No credit refund (verify remaining credits unchanged).

E6 — Cancel after window with allowLateCancellation=true, then promotion

StepActionExpected
1Session full; one waitlisted. Window passed but allowLateCancellation=true, class more than 15 minutes away. Confirmed member cancels.Booking cancelled with no refund. Waitlisted member auto-promoted; their “you’re in” message states a free cancel until promotion + 10 minutes (see G18).

E7 — Cross-org isolation

StepActionExpected
1Owner A signs in. POST /organizations/{B}/sessions/{anything}/publish.403 ‘Not a member of this organization’ (from requireMembership).
2Even with both orgs’ UUIDs known: try to checkIn org-B’s booking via org-A’s session URL.404 — service joins through programs.organization_id and rejects.

E8 — Concurrent waitlist promotion

StepActionExpected
1Session full; 3 waitlisted (positions 1,2,3). Two confirmed members cancel within ms of each other.Two promotions occur (positions 1 and 2). Each fill runs inside the cancel’s serializable transaction; no double-promotion of the same row and never more confirmed seats than capacity.

E9 — Unpublish guard

StepActionExpected
1Published session with 3 confirmed bookings. POST /:id/unpublish.400 ‘Cannot unpublish a session that has active bookings’.
2Cancel all 3 bookings; retry unpublish.Succeeds; status → draft.
3Session whose only live rows are waitlisted, or attended. POST /:id/unpublish.400, same message.

E10 — Coach trying to publish

StepActionExpected
1Coach POST /:id/publish.403 ‘Only owners and admins can publish sessions’.

E11 — Inline workout requires session ownership

StepActionExpected
1Coach in org A POST inline workout on org B’s session.404 ‘Session not found’.

E12 — Bulk-delete excludes booked by default

StepActionExpected
15 sessions match filter; 2 have active bookings. includeBooked not set (default false).preview: matched=3, withActiveBookings=2. Delete: 3 sessions deleted, 2 untouched.
2Re-run with includeBooked:true.5 deleted, bookings cancelled, emails sent.

E13 — TZ correctness on bulk filter startTimes

StepActionExpected
1Org tz=Asia/Jerusalem. Sessions stored at 07:00 local (UTC stored as 04:00 or 05:00 depending on DST). Bulk filter startTimes=['07:00'].Matches the 07:00-local sessions regardless of host TZ or DST.

E14 — Display screen signed URL expires

StepActionExpected
1Use a display URL > TTL hours old.403 ‘Invalid or expired display link’.

E15 — Bulk writers skip space clashes

Flag spaces on; exclusive space “Main Floor” and shared space “Open Gym”. Single create/edit rows (S1–S14) live in locations QA › Spaces flag.

StepActionExpected
1Existing “Yoga” Mon 07:00–08:00 in Main Floor. Preview then apply a template with “WOD” Mon 07:30 in Main Floor, conflictMode:'skip'.Preview and apply both return a conflicts[] group { kind:'space', locationName:'Main Floor', classTypeId:<WOD>, day:'monday', time:'07:30' }. That session is not created; counted in skipped.
2Same with conflictMode:'overwrite'.Yoga untouched; WOD still skipped (kind:'space' is never overwritten).
3Same with conflictMode:'abort'.409 with the space group; nothing written.
4Same template slot in Open Gym (shared).No space group; session created.
5Copy-week where a source session’s space is taken on the target week by another class type.conflicts[] group kind:'space'; skipped.
6Spotter class_sessions.create_many hitting an occupied exclusive space.That item is in skipped[] with reason:'space_occupied'; others created.
7Spotter replicate_day onto a date where a copied session clashes in an exclusive space.Whole date listed in blockedTargetDates; nothing written for it.
8Flag off: repeat steps 1–7.No space groups; every group is kind:'class_type', locationName:null; no space_occupied.

E16 — Layered booking rules and entry quotas (flag booking-policy-rules)

Org tz Asia/Jerusalem; halls A, B, Parquet. Automated in booking-policy-victor.int.spec.ts; the steps below are the manual pass.

StepActionExpected
1Settings → Booking: add “For CrossFit in Parquet, open 1 day before at 20:00”.The dialog previews the next Parquet CrossFit class and “Booking opens …20:00” before saving. Saved as one sentence row.
2Member opens Monday 07:00’s Parquet class on Sunday 19:59, then 20:00.19:59: “Registration opens …”; book → 409 registration_not_yet_open. 20:00: books.
3Add a second hall rule over halls B + Parquet that also sets booking open.Refused inline: the conflict names the first rule and “registrationOpens”; nothing saved.
4Plan with quotas CrossFit 18 / Open gym 9 / Yoga 0 (window): book 18 CrossFit, then the 19th; a yoga class; a Pilates class.19th: “You’ve used all your CrossFit entries” with buy-a-pack / renew-early; yoga: “Your plan doesn’t include this class”; Pilates (no quota names it) books on the plan’s entries.
4bPlan with weekly limit 5, flag on: open the plan sheet (he, 1440).One “מכסות כניסה” list; its first row “כל השיעורים”, 5, “לשבוע”, all classes; no separate day/week/month fields and no up/down arrows. Save untouched → the plan still has maxBookingsPerWeek=5. Add a second all-classes weekly row → save refused with the duplicate message.
4cPlan with quotas “Everything 3” (all classes) and “CrossFit 2” (window): book 3 CrossFit, then a yoga class twice.3rd CrossFit refused naming CrossFit (Everything still has room); first yoga books; second yoga refused naming Everything.
5Book under a 2h cancel rule, edit the rule to 24h, cancel 3h before.The booking cancels with the entry back (its snapshot says 2h); a new booking on the class cannot cancel (cancellation_closed).
6Flags off: repeat 1–5.The rule list and quota list are hidden and the plan sheet shows its “Booking rules” section with the three limit fields; every booking behaves as with the org columns only.

Cross-persona

ScenarioSetupExpected
Coach books on their own sessionCoach has membership; books via webStaff bypass: no credit deduction, no quota check. Booking row has subscription_id=NULL.
Member’s two membershipsMember belongs to org A and org B, both with active subs/bookings/my returns rows across both orgs.
Admin cancels then member rebooksAdmin admin-cancels a member’s confirmed booking (DELETE …/sessions/:sessionId/bookings/:bookingId)Member can book the same session again: status='confirmed', cancelled_at null, one live booking for that member and session, credit charged again.
Member cancels then rebooks, twiceMember books, cancels, books, cancels, books the same sessionEvery step succeeds; one live booking; credit deducted on each booking and refunded on each in-window cancel. A full class puts the re-booking on the waitlist (no credit spent until promoted). With Fizikal mirroring on, the club holds exactly one vendor seat while booked and none after the last cancel.
Re-booking double-firesAfter a cancel, the same member sends several book requests at onceExactly one succeeds; the rest get 400 “Already booked for this session” (never a 500); one live booking; one credit spent.

All three rows are automated in apps/api/src/bookings/rebook-after-cancel.int.spec.ts (real Postgres; make test-integration-api). The plain cancel → book again path is also covered end to end by the multi-plan-booking Playwright spec and by the prod write smoke journey (apps/web/e2e/prod-write/member-journey.spec.ts).

i18n

LangCheck
enCancellation deadline error: “Cancellation window has closed. You can cancel up to {N} hour(s) before class start.”
heSame translation present in he.json under member.cancellation.* / schedule.*.
ruSame in ru.json.
AllGPS_TOO_FAR is a stable machine string the client translates to the localized “You are too far from the session location…” key (schedule.checkinPage.tooFar in en.json).
RTLHebrew calendar grid renders right-to-left; cancellation deadline text aligns to start.

Expected vs actual checks

  • DB: after each test, query bookings and class_sessions directly to confirm status, cancelled_at, deleted_at, checked_in_at, checkin_method match expectations.
  • Audit: bulk operations must leave exactly one audit_logs row per batchId with action ∈ {class_session.bulk_delete, class_session.bulk_publish} and the full filter payload.
  • Email: assert SES/mailpit log contains one entry per affected member; subject matches the template (e.g. “Class cancelled: Yoga Flow”).
  • Push: assert a record was scheduled for T-30 min reminder (BullMQ inspector or push service mock).
  • Credits: subscriptions.remaining_credits and the ledger sum (SELECT sum(delta) FROM entitlement_ledger WHERE subscription_id = …) match expected after every book/cancel/promotion.
  • Reasons: every row cancelled during a run has a non-NULL cancellation_reason matching the scenario.