Awesome-omni-skill booking-flow
End-to-end booking lifecycle in the Ever Club Members App — booking request creation, booking flow, booking lifecycle, session creation, conflict detection, bay assignment, approval flow, auto-approve, Trackman sync, Trackman booking modifications, booking request statuses, guest pass holds, usage tracking, member cancel, staff approval, prepayment, calendar sync, and reconciliation.
git clone https://github.com/diegosouzapw/awesome-omni-skill
T=$(mktemp -d) && git clone --depth=1 https://github.com/diegosouzapw/awesome-omni-skill "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/development/booking-flow" ~/.claude/skills/diegosouzapw-awesome-omni-skill-booking-flow && rm -rf "$T"
skills/development/booking-flow/SKILL.mdBooking Flow
How a booking moves through its lifecycle from member request to completion.
Lifecycle Stages
Request → Guest Pass Hold → Staff Approval → Session Creation → Invoice Draft → Trackman Link → Check-in → Completion / Auto Check-In
1. Request (status: pending
)
pendingMember submits via
POST /api/booking-requests. The route:
- Validate time bounds (no cross-midnight:
blocked), resource availability, and participant membership status.endHours >= 24 - Run
— check owner bookings, participant bookings, and invites on the same date for time overlap.findConflictingBookings() - Run
— check closures, availability blocks, and existing sessions on the target resource.checkUnifiedAvailability() - Sanitize and deduplicate participants:
- Resolve email → userId by looking up users table.
- Resolve userId → email for directory-selected members (userId set but email missing).
- Reject participants with
.membershipStatus = 'inactive' | 'cancelled' - Deduplicate by email and userId sets; owner email always added first.
- Determine initial status:
- Conference rooms →
(auto-approve, skip staff review).confirmed - Golf simulators →
(require staff approval).pending
- Conference rooms →
- Insert into
table within a raw pg transaction.booking_requests - If guests present, call
to reserve guest passes (non-blocking — hold failure does not block booking).createGuestPassHold() - COMMIT transaction, then for conference rooms call
and create a draft invoice via the booking invoice service (same flow as simulators since v8.16.0).ensureSessionForBooking() - Send HTTP response, then asynchronously:
- Publish
event viabooking_created
.bookingEvents.publish() - Notify staff via
with push notification.notifyAllStaff() - Broadcast availability update via WebSocket.
- Track first booking for onboarding (
).users.first_booking_at
- Publish
2. Guest Pass Hold
Guest passes use a hold-then-convert pattern for atomicity:
- At request time:
reserves passes increateGuestPassHold()
table (does NOT decrementguest_pass_holds
yet).passes_used - At approval time: Inside the session creation transaction, holds are converted to actual usage via
onSELECT ... FOR UPDATE
, then the hold row is deleted.guest_passes - At cancellation:
deletes the hold without decrementing.releaseGuestPassHold()
This ensures passes are never double-spent even if approval and cancellation race.
3. Approval (status: approved
)
approvedStaff approves via
PUT /api/booking-requests/:id with status: 'approved'. The route:
- Validate
format if provided (must be numeric, not UUID). Check for duplicates on other bookings.trackman_booking_id - Enter
: a. Verify bay is assigned (db.transaction()
required before approval). b. Run conflict detection within the transaction: booking time overlaps (approved/confirmed/attended), closure conflicts, availability block conflicts. c. Create Google Calendar event:resource_id
→getCalendarNameForBayAsync()
→getCalendarIdByName()
. d. Determine final status: conference rooms →createCalendarEventOnCalendar()
, simulators →'attended'
. e. If'approved'
flag is set without apending_trackman_sync
, appendtrackman_booking_id
marker to[PENDING_TRACKMAN_SYNC]
. f. Build participant list fromstaff_notes
JSON column:request_participants- Start with owner as first participant (resolve
from email if needed).userId - For each request participant: resolve email ↔ userId, detect "guests" who are actually members, deduplicate.
- Guests with a matching email in the
table are converted tousers
type. g. Callmember
withcreateSessionWithUsageTracking()
(enables hold-to-usage conversion). h. Link session:bookingId
. i. ResolveUPDATE booking_requests SET session_id
fromownerUserId
table if not already known.users
- Start with owner as first participant (resolve
- Post-transaction (after COMMIT):
- Fee calculation (MUST be post-commit): Call
→ returnsrecalculateSessionFees(sessionId, 'approval')
. This CANNOT run inside the transaction because{ totalCents, overageCents, guestCents }
uses the globalrecalculateSessionFees
pool, not thedb
handle — Postgres Read Committed isolation prevents it from seeing uncommitted participant rows, causing $0 fees or deadlock. (Fixed v8.26.7, Bug 22)tx - If
, create a prepayment intent viatotalCents > 0
.createPrepaymentIntent() - If simulator booking with fees > 0, create draft Stripe invoice via
. StorescreateDraftInvoiceForBooking()
onstripe_invoice_id
. Non-blocking — invoice failure does not block approval.booking_requests - Link and notify participants via
.linkAndNotifyParticipants() - Notify member: push notification, WebSocket update, email.
- Publish
event.booking_approved - Broadcast availability and billing updates.
- Fee calculation (MUST be post-commit): Call
4. Session Creation
createSessionWithUsageTracking() is the central orchestrator. It accepts an optional external transaction and either joins it or creates its own. Steps:
Pre-transaction validation (runs outside transaction to avoid long locks):
- Tier validation:
→ ownerTier. ThengetMemberTier(ownerEmail)
:enforceSocialTierRules(ownerTier, participants)- Check if any participant has
.type: 'guest' - If owner is Social tier and guests are present → return
.{ allowed: false, errorType: 'social_tier_blocked' }
- Check if any participant has
- Resolve identities: For each participant with a
, calluserId
→ buildresolveUserIdToEmail(userId)
map. This is needed becauseuserIdToEmail
stores emails.usage_ledger.member_id - Calculate billing:
→ returnscalculateFullSessionBilling(sessionDate, durationMinutes, billingParticipants, ownerEmail, declaredPlayerCount, { resourceType })
with per-participantbillingBreakdown[]
,minutesAllocated
,overageFee
, plus totals andguestFee
.guestPassesUsed
Database writes (all-or-nothing within transaction):
- Find or create session:
uses PostgresfindOverlappingSession(resourceId, sessionDate, startTime, endTime)
withtsrange
bounds and the overlap operator[)
. If found, call&&
. If not found, calllinkParticipants(existingSession.id, participants, tx)
.createSession({ resourceId, sessionDate, startTime, endTime, trackmanBookingId, createdBy }, participants, source, tx) - Record usage ledger: For each billing entry:
- Guest:
— fee assigned to host, zero minutes to avoid double-counting in host's daily usage.recordUsage(sessionId, { memberId: ownerEmail, minutesCharged: 0, guestFee, ... }) - Member/Owner:
.recordUsage(sessionId, { memberId: email, minutesCharged, overageFee, ... })
- Guest:
- Deduct guest passes (if
):guestPassesUsed > 0- Path 1 — Booking request flow (has
):bookingId
→ verify hold exists.SELECT guest_pass_holds FOR UPDATE
→ verifySELECT guest_passes FOR UPDATE
.passes_used + passesToConvert <= passes_total
.UPDATE guest_passes SET passes_used += passesToConvert
.DELETE FROM guest_pass_holds - Path 2 — Staff/Trackman flow (no
):bookingId
→ verify available ≥ needed.SELECT guest_passes FOR UPDATE
.UPDATE guest_passes SET passes_used += passesNeeded - If either path fails validation → throw error → entire transaction rolls back.
- Path 1 — Booking request flow (has
5. Trackman Link
Trackman webhooks and CSV imports link external bookings to app bookings:
- Webhook auto-approve:
matches pending bookings by email + date + time (±10 minute tolerance). On match, setstryAutoApproveBooking()
, linksstatus='approved'
, and callstrackman_booking_id
. After session creation and fee calculation, creates a draft Stripe invoice viaensureSessionForBooking()
(non-blocking, simulator bookings only).createDraftInvoiceForBooking() - Webhook create:
tries to match existingcreateBookingForMember()
bookings first, then creates new booking records. Uses[PENDING_TRACKMAN_SYNC]
flag.was_auto_linked=true - Duration updates: If Trackman reports different duration, update
andbooking_requests
times, then callbooking_sessions
for delta billing, then sync the draft invoice viarecalculateSessionFees()
to update line items.syncBookingInvoice() - Bay changes: If Trackman reports a different
, update bothresource_id
andbooking_requests.resource_id
, then broadcast availability for old and new bays.booking_sessions.resource_id
See
references/trackman-sync.md for full details.
6. Check-in (status: attended
or no_show
)
attendedno_showStaff marks booking as attended or no-show via the BookingStatusDropdown. The dropdown allows toggling between statuses after initial selection. Session must exist before check-in. If no session exists yet, the check-in flow calls
ensureSessionForBooking() to create one.
7. Cancellation
Two cancellation paths:
Member cancel (
PUT /api/booking-requests/:id/member-cancel):
- Validate ownership — three accepted conditions:
- Session email matches booking's
.user_email - Admin/staff with
matching booking email.acting_as_email - Session user has linked email matching booking email (checked via
,trackman_email
JSONB, orlinked_emails
JSONB).manually_linked_emails
- Session email matches booking's
- Verify booking is not already cancelled.
- Update
.booking_requests.status = 'cancelled' - Release guest pass holds via
.releaseGuestPassHold(bookingId) - Cancel all pending Stripe payment intents linked to the booking (
). 5a. Handle Stripe invoice cleanup viastatus IN ('pending', 'requires_payment_method', 'requires_action', 'requires_confirmation')
(non-blocking). This handles all invoice states: draft (deletes), open (voids), paid (auto-refunds viavoidBookingInvoice(bookingId)
, notifies staff on failure), void/uncollectible (skips).stripe.refunds.create - Delete Google Calendar event via
.deleteCalendarEvent(calendarEventId) - Publish
event withbooking_cancelled
(deletes related notifications).cleanupNotifications: true - Broadcast availability update.
Trackman cancel (
cancelBookingByTrackmanId()):
- Find booking by
.trackman_booking_id - If already cancelled, return early.
- Detect if
(status waswasPendingCancellation
).cancellation_pending - Delegate to
:BookingStateService- If
→wasPendingCancellation
.BookingStateService.completePendingCancellation() - Otherwise →
withBookingStateService.cancelBooking()
and staff notessource: 'trackman_webhook'
.'[Cancelled via Trackman webhook]'
- If
handles all side effects atomically via a side-effects manifest:BookingStateService- Refund Stripe payment intents (paid → refund, pending → cancel).
- Refund balance payments (credit balance restoration).
- Void booking invoice via
.voidBookingInvoice(bookingId) - Delete Google Calendar event.
- Release guest pass holds.
- Notify staff and member (push + WebSocket).
- Publish
event.booking_cancelled - Broadcast availability update.
8. Auto Check-In (status: attended
)
attendedThe auto-complete scheduler (
server/schedulers/bookingAutoCompleteScheduler.ts) runs every 2 hours. It marks approved/confirmed bookings as attended (auto checked-in) when 24 hours have passed since the booking's end time. This assumes most members attended their bookings and avoids noisy false no-show notifications. Staff can still manually mark a booking as no_show via the BookingStatusDropdown if needed.
- Uses Pacific timezone via
/getTodayPacific()formatTimePacific() - Notifies staff when 2+ bookings are auto checked-in
- Manual trigger available via
runManualBookingAutoComplete()
9. Booking Modifications (Trackman Webhooks)
Booking modifications are handled exclusively through Trackman webhook
booking.updated events via handleBookingModification() in server/routes/trackman/webhook-handlers.ts. When a booking is modified in Trackman (bay change, time change), the webhook updates the local record, recalculates fees, syncs the invoice, and notifies the member. The previous staff-initiated reschedule feature (start/confirm/cancel routes) was removed in v8.41.0.
Conflict Detection Detail
findConflictingBookings(memberEmail, date, startTime, endTime, excludeBookingId?) checks two sources:
- Owner conflicts: Query
wherebooking_requests
matches andLOWER(user_email)
on the same date. Applystatus IN OCCUPIED_STATUSES
in application code.timePeriodsOverlap() - Participant conflicts: Query
→booking_participants
→booking_sessions
wherebooking_requests
matches the member's UUID andbp.user_id
. Also applybp.invite_status = 'accepted'
. (Note: All participants are now auto-accepted —timePeriodsOverlap()
defaults toinvite_status
at the database level. The invite system and'accepted'
have been removed as of v7.92.0.)inviteExpiryScheduler
timePeriodsOverlap() handles cross-midnight by adding 1440 minutes to any end time < start time. However, cross-midnight bookings cannot be created through normal flows (club closes at 10 PM).
hasTimeOverlap(start1, end1, start2, end2) in bookingValidation.ts handles overnight wrap-around closures (where start2 > end2, e.g., 22:00–06:00). When detected, it splits the range into two sub-ranges: [start2, 1440) (night portion) and [0, end2) (morning portion), checking overlap against each. This ensures facility closures spanning midnight correctly block bookings in both the late-night and early-morning windows.
Audit note (Feb 2026): The SQL-level overlap checks in
checkBookingConflict, checkAvailabilityBlockConflict, and checkSessionConflict/checkSessionConflictWithLock use the standard start < endTime AND end > startTime pattern without overnight handling. This was audited and confirmed correct — bookings, availability blocks, and sessions are all single-day constructs that cannot cross midnight. Only closure overlap checks need the midnight-spanning logic.
checkUnifiedAvailability(resourceId, date, startTime, endTime, excludeSessionId?) runs three layered checks:
— facility-wide closures fromcheckClosureConflict()
table.facility_closures
— per-resource event blocks fromcheckAvailabilityBlockConflict()
table (e.g., tournaments, private events).availability_blocks
— existing sessions oncheckSessionConflict()
with time overlap (booking_sessions
).start_time < endTime AND end_time > startTime
For pessimistic locking during concurrent session creation,
checkSessionConflictWithLock() uses FOR UPDATE NOWAIT to immediately fail if another transaction holds the lock on a conflicting session row.
Key Decision Trees
Approval vs Auto-Approve
Is resource a conference room? ├── Yes → Auto-confirm (status='confirmed'), create session + draft invoice (same invoice flow as simulators since v8.16.0) │ └── Invoice auto-finalized and charged to member's Stripe credit balance └── No (golf simulator) → Pending staff approval (status='pending') └── Trackman webhook arrives? ├── Yes, matching pending booking → Auto-approve, create session └── No match → Save to trackman_unmatched_bookings
Conference Room vs Simulator at Approval
Resource type? ├── conference_room → Final status = 'attended' (skip 'approved' stage) └── simulator → Final status = 'approved'
Key Invariants
-
Session before roster: A
row must exist before anybooking_sessions
can be linked. The session is the anchor for the participant roster and usage ledger.booking_participants -
Conflict detection scope:
checks OCCUPIED_STATUSES =findConflictingBookings()
. It checks both owner bookings and participant bookings on the same date. The['pending', 'pending_approval', 'approved', 'confirmed', 'checked_in', 'attended']
status was added in v8.6.0 to prevent double-booking against checked-in sessions. The auto-complete scheduler moves staleattended
/approved
bookings toconfirmed
after 24h, removing them from conflict detection.attended -
Availability guard layers:
runs three checks in order:checkUnifiedAvailability()- Facility closures (
table)facility_closures - Availability blocks (
table)availability_blocks - Existing sessions (
table with time overlap)booking_sessions
- Facility closures (
3a. Overnight closure detection:
hasTimeOverlap() supports wrap-around time ranges where startMinutes > endMinutes (e.g., 22:00 to 06:00). The range is split into [start, 1440) and [0, end) for overlap checks. This is critical for closures that span midnight.
-
Guest pass atomicity: Guest pass deduction happens INSIDE the session creation transaction. If session creation fails, guest passes are not deducted. Two paths: hold-then-convert (booking request flow) vs direct deduction (staff/Trackman flow).
-
Social tier restriction: Social tier members cannot bring guests. Enforced by
before session creation. This is a hard block, not a warning.enforceSocialTierRules() -
Overlapping session reuse:
uses PostgresfindOverlappingSession()
overlap operator (tsrange
) to find existing sessions within the time window. If a session already exists (e.g., from Trackman import), participants are linked to it rather than creating a duplicate.&& -
Time tolerance matching: Trackman webhook matching uses ±10 minute tolerance (
). This accounts for Trackman sessions starting slightly earlier/later than booked times.ABS(EXTRACT(EPOCH FROM (start_time::time - $3::time))) <= 600 -
Row-level locking:
usescheckSessionConflictWithLock()
onFOR UPDATE NOWAIT
for pessimistic locking during session creation. Guest pass deduction usesbooking_sessions
onFOR UPDATE
for atomic read-modify-write.guest_passes -
Usage ledger stores emails:
stores email addresses (not UUIDs) for historical consistency. Theusage_ledger.member_id
step converts UUIDs to emails before ledger writes.resolveUserIdToEmail() -
Post-commit notifications: Booking creation sends the HTTP response BEFORE executing post-commit operations (staff notifications, event publishing, availability broadcast). This ensures the client gets a success response even if notifications fail.
-
Fee calculation is post-commit:
uses the globalrecalculateSessionFees()
pool, NOT transaction handles. It MUST run afterdb
commits, never inside it. Inside an uncommitted transaction, the global pool cannot see the new session/participant rows (Postgres Read Committed isolation), causing $0 fees or deadlock. (v8.26.7, Bug 22)db.transaction() -
Cancellation status guard includes 'confirmed': The
check inwasApproved
includes bothcompleteCancellation
AND'approved'
statuses to prevent members from cancelling an in-progress booking without Trackman cleanup. (v8.26.7, Bug 14)'confirmed' -
Optimistic locking on status transitions:
usesdevConfirmBooking
on the UPDATE to prevent TOCTOU races where a concurrent cancellation could be overwritten. Always checkWHERE status IN ('pending', 'pending_approval')
after status-transition UPDATEs. (v8.26.7, Bug 11)rowCount -
One invoice per booking: Each booking (simulator or conference room) has at most one Stripe invoice (
). Draft created at approval (if fees > $0), updated on roster changes, finalized at payment. If a booking is approved with $0 fees (no invoice created) and later gains fees through roster edits,booking_requests.stripe_invoice_id
creates the draft invoice on-the-fly. Conference rooms were migrated to the same invoice flow as simulators in v8.16.0 (2026-02-24). The invoice lifecycle is managed bysyncBookingInvoice()
.bookingInvoiceService.ts -
Roster lock after paid invoice: Once a booking's Stripe invoice is paid, roster edits (add/remove participant, change player count) are blocked via
. Staff can override with a reason (logged via audit). The lock is fail-open: if the Stripe API check fails, edits proceed.enforceRosterLock() -
Conflict check scope:
,checkBookingConflict()
, andcheckAvailabilityBlockConflict()
use simple time overlap logic (checkSessionConflict()
). This is correct because bookings and availability blocks are constrained to single-day, same-day time windows — cross-midnight bookings cannot be created (club closes at 10 PM). Onlystart < end AND end > start
in closure checks needs overnight wrap-around handling, because facility closures CAN span midnight (e.g., 22:00–06:00).hasTimeOverlap()
must check all 6 active statuses:checkBookingConflict()
,pending
,pending_approval
,approved
,confirmed
,attended
— matching the inline SQL incancellation_pending
line 568. Missing statuses allow double-bookings through code paths that usebookings.ts
. (v8.26.7)checkAllConflicts -
Booking expiry includes pending_approval + Trackman guard + WebSocket broadcast: The booking expiry scheduler (
) targets bothbookingExpiryScheduler.ts
andpending
bookings. It waits 20 minutes past the bookingpending_approval
before acting. Trackman-linked bookings (those with astart_time
) are set totrackman_booking_id
instead ofcancellation_pending
, so the Trackman hardware cleanup flow runs and the physical bay is unlocked. Non-Trackman bookings are set toexpired
directly. After each status change, the scheduler callsexpired
for every affected booking with abroadcastAvailabilityUpdate()
, so front desk iPads and member phones update instantly. The scheduler tracks itsresourceId
timer IDs and clears them on shutdown. The manual expiry endpoint also includessetTimeout
in its query.pending_approval -
Roster fetch race protection:
infetchRosterData()
uses auseUnifiedBookingLogic.ts
counter to prevent stale roster data from overwriting current state. Each fetch increments the counter, and afterrosterFetchIdRef
, checks that it hasn't been superseded by a newer fetch. This prevents rapid-fire WebSocket events from causing the UI to flicker with old data. (v8.26.7)await -
Pending booking soft lock: Pending booking requests (
,pending
) on a specific bay block that bay/time slot in member-facing availability (pending_approval
), preventing other members from requesting the same slot. The soft lock is self-exclusive: a member's own pending requests are excluded from the lock so they see their own slot as accessible, not blocked. Only bay-assigned requests (server/routes/availability.ts
) trigger the soft lock — preference-only requests (where the member chose "No Preference" for bay) do not block specific bays. The frontend renders soft-locked slots with an amber "Requested" indicator viaresource_id IS NOT NULL
(ResourceCard
prop). The creation-time hard block inrequested
andserver/routes/bays/bookings.ts
checksserver/routes/staff/manualBooking.ts
— all six active statuses must be present in both. The availability query cache key (('pending', 'pending_approval', 'approved', 'confirmed', 'attended', 'cancellation_pending')
) includesbookGolfKeys.availability
to ensure self-exclusion is per-user when admins use "view as." Note:userEmail
is treated as an occupied booking (in thecancellation_pending
query alongsidebookedSlots
/approved
), NOT as a soft lock — this ensures the slot stays blocked for ALL members (including the owner) per Rule 6 of booking-import-standards.confirmed
Booking Event System
bookingEvents provides a pub/sub system via publish():
| Event | When |
|---|---|
| After booking request inserted |
| After staff approves booking |
| After staff declines booking |
| After cancellation (member or Trackman) |
| After staff marks attended |
Each event can trigger member notifications (push, WebSocket, email) and staff notifications independently via
PublishOptions.
Reconciliation
After a booking is completed,
findAttendanceDiscrepancies() compares declared_player_count (from member's request) against trackman_player_count (from Trackman data). If they differ:
- Calculate potential fee adjustment based on additional players × duration × overage rate.
- Staff reviews via reconciliation UI: mark as
orreviewed
.adjusted - Fee adjustments use
— computes per-player minute allocation and applies 30-minute block overage rates.calculatePotentialFeeAdjustment()
See
references/trackman-sync.md for reconciliation details.
Audit Findings (Feb 2026)
Date/String Type Safety in Booking Events
bookingEvents.publish() receives data.bookingDate from database queries that may return a Date object instead of a string. The formatBookingDateTime() function now accepts string | Date and converts Date objects to ISO date strings before calling formatDateDisplayWithDay(). The BookingEventData.bookingDate type was updated to string | Date.
Rule: When passing date values from database query results to formatting functions that expect strings, always handle both
Date and string types. Database ORMs may return Date objects for date columns.
Owner User ID Resolution (Trackman Auto-Link)
ensureSessionForBooking() now resolves the owner's user_id from the users table via email when callers don't pass ownerUserId. Previously, Trackman auto-link paths (e.g., tryMatchByBayDateTime) created owner participants with NULL user_id, making slots appear empty in the roster UI.
Rule: All session creation paths must ensure the owner participant has a valid
user_id. If the caller doesn't provide ownerUserId, resolve it from the users table by email.
Link Member sessionId Scoping (Feb 2026)
PUT /api/admin/booking/:bookingId/members/:slotId/link declared sessionId with const inside the if (session_id) block. After the block closed, the notification and broadcastBookingRosterUpdate() code at the bottom of the handler referenced sessionId outside its scope, causing a ReferenceError. The DB write (participant INSERT) succeeded, so the member appeared after a page refresh, but the catch block returned HTTP 500 with "Failed to link member to slot."
Fix: Moved
sessionId declaration to the outer function scope (const sessionId = bookingResult.rows[0]?.session_id) before the if block. This ensures sessionId is accessible throughout the handler for notifications, audit logging, and WebSocket broadcast.
Rule: Always declare variables at the scope where ALL consumers can access them. Block-scoped
const/let inside if blocks are invisible to code after the closing brace.
Reassign Endpoint Invoice Sync (Feb 2026)
PUT /api/admin/booking/:id/reassign called recalculateSessionFees() after reassigning a booking to a new member, but did NOT call syncBookingInvoice(). The fee service correctly computed $0 fees (new member has allowance), but the Stripe draft invoice retained the original overage charge from the old member. This caused members like Kenneth Lee to see a $50 overage charge even after reassignment.
Fix: Added
syncBookingInvoice(bookingId, sessionId) immediately after recalculateSessionFees() in the reassign endpoint. The invoice now reflects the recalculated $0 fees.
Rule: Every caller of
recalculateSessionFees() that modifies the billing context (roster change, reassignment, participant add/remove) MUST also call syncBookingInvoice() afterward. recalculateSessionFees() only updates booking_participants.cached_fee_cents — it does NOT touch the Stripe invoice. See fee-calculation skill for the full list of callers.
Staff Assignment FK Violation
Frontend was sending
staff.user_id || staff.id as member_id when assigning bookings to staff. If a staff member lacks a users record, user_id is null and staff.id (from staff_users table) is NOT a valid users.id UUID, causing a foreign key violation on booking_requests.user_id. Fixed: frontend sends staff.user_id || null, and backend resolves user_id from the owner's email via the users table when no valid member_id is provided.
Rule: Never use
staff_users.id as a substitute for users.id. They are different tables with different ID spaces. Always use staff.user_id (which references users.id) or resolve from email.
Resolve Endpoint Usage Ledger and Invoice Sync (Feb 2026)
PUT /api/admin/trackman/unmatched/:id/resolve had two bugs:
-
stored integer user ID instead of email.usage_ledger.member_id
was called withrecordUsage()
(integer primary key frommember.id
table) instead ofusers
. This violated invariant 10 (usage_ledger stores emails) and caused fee calculations to miss the resolved member's prior usage, producing incorrect overage charges.member.email -
Missing
aftersyncBookingInvoice()
. The resolve endpoint recalculated fees but never synced the Stripe draft invoice, leaving stale line items (e.g., $50 overage from the unmatched state) on the invoice even after the member was resolved with $0 fees.recalculateSessionFees()
Fix: Changed all three
recordUsage calls (visitor path line 751, member path lines 808 and 816) to use (member.email as string).toLowerCase(). Added syncBookingInvoice(booking.id, sessionId) immediately after recalculateSessionFees().
Rule: When writing to
usage_ledger.member_id, ALWAYS use the member's email address (lowercased), never the integer user ID. This applies to recordUsage() calls and direct UPDATE usage_ledger statements.
Trackman Webhook SQL Safety
Trackman webhook handlers (
webhook-billing.ts, webhook-validation.ts) had production SQL errors caused by undefined values in Drizzle sql template literals creating empty placeholders. Fixed by coalescing all optional values with ?? null.
Rule: Always use
${value ?? null} in Drizzle sql template literals for optional parameters. See stripe-webhook-flow skill for the full pattern.
Roster Lock After Payment (v8.37.0)
Once a booking's invoice has been paid, the roster is locked.
enforceRosterLock() in rosterService.ts checks isBookingInvoicePaid(bookingId) before allowing any roster mutation (add, remove, update player count). If the invoice is paid:
- Non-admin users are blocked outright (HTTP 403).
- Admin users can override with a required
parameter.lockOverrideReason - All overrides are audit-logged with the admin's email and reason.
This prevents fee discrepancies after payment — adding a player after payment would change fees but the invoice is already finalized.
Trackman Booking Modification Webhooks (v8.34.0)
When Trackman sends a Booking Update webhook with changes (bay, time, date), the app automatically:
- Detects the modification type (bay change, time change, date change) by comparing webhook data against the existing booking.
- Updates the booking record, session, fees, and invoice to reflect the new slot.
- Runs conflict detection on the new slot — if conflicts exist, staff are warned but the change is applied (Trackman is source of truth).
- Notifies staff via WebSocket with details about what changed.
- Sends push notifications to affected members.
- Records the event as
in webhook stats (purple badge).booking.modified
Idempotency: The webhook idempotency guard uses content-aware signatures (bay, time, status) so modification webhooks are not rejected as duplicates of the original creation webhook.
References
Core Reference Docs:
— Detailed API route → core service call chains for each booking operation.references/server-flow.md
— Trackman webhook handling, CSV import matching, reconciliation, and delta billing.references/trackman-sync.md
Key Implementation Files:
— Tier validation rules, daily minute limits, Social tier guest restrictions, andserver/core/bookingService/tierRules.ts
interface. SeeTierValidationResult
for detailed documentation.references/server-flow.md#tier-validation-rules
— Session creation, participant linking, usage ledger recording, and guest pass deduction.server/core/bookingService/sessionManager.ts
—server/core/bookingService/bookingStateService.ts
class: centralized cancellation (member and Trackman),BookingStateService
, side-effects manifest for atomic cleanup (Stripe refunds, balance restoration, invoice void, calendar deletion, guest pass release, notifications).completePendingCancellation()
— Booking conflict detection (owner and participant conflicts).server/core/bookingService/conflictDetection.ts
— Centralized booking conflict detection (server/core/bookingValidation.ts
). Used by booking creation for consistent conflict validation with advisory locks.checkBookingConflict
— Availability validation (closures, blocks, session overlaps).server/core/bookingService/availabilityGuard.ts
— Draft invoice creation, line item sync, finalization, voiding, paid-status check. Key exports:server/core/billing/bookingInvoiceService.ts
,createDraftInvoiceForBooking
,syncBookingInvoice
,finalizeAndPayInvoice
,finalizeInvoicePaidOutOfBand
,voidBookingInvoice
.isBookingInvoicePaid
includes terminal payment detection: before charging, it checks for existing terminal payments on the booking to avoid double-charging. It is also balance-aware: if the customer's Stripe balance fully covers the invoice amount, the invoice is finalized and auto-paid without requiring a card charge.finalizeAndPayInvoice()
— Roster changes withserver/core/bookingService/rosterService.ts
guard. Exports:enforceRosterLock()
,addParticipant
,removeParticipant
,updateDeclaredPlayerCount
.applyRosterBatch
Related Skills:
- Refer to
skill for CSV parsing rules, roster protection, and import data integrity rules.booking-import-standards