Skip to content

Booking

Purpose

A customer’s reservation for a single Session. The booking is the central pivot that crosses five other contexts: catalog (the session), companies (the customer), passes (pass entitlement coverage), wallet (balance debits and refunds), payments (gateway records when paid by card). Booking-extra child rows snapshot each Activity extra purchased alongside.

Identity & key fields

  • Primary key: id (uuid, default gen_random_uuid()).
  • sessionId (uuid, FK → activities.sessions.id) — what the customer booked.
  • customerId (uuid, FK → companies.company_customer.id) — who booked.
  • status (enum booking_status: PENDING, CONFIRMED, CANCELLED, REFUNDED, PENDING_PAYMENT, CHECKED_IN).
  • source (enum booking_source: SELF_BOOKED, SLOT_ATTENDEE) — SLOT_ATTENDEE is post-fact billing (coach added the customer to a recurring slot).
  • price (decimal 10,2) — snapshot at booking time.
  • walletDebited, bonusDebited (booleans) — flags marking irreversible side-effects on the customer wallet / bonus balance.
  • customerEntitlementId (nullable uuid, FK → passes.customer_entitlement.id) — set when the booking was absorbed by a pass.
  • reminderSentAt (nullable timestamp) — idempotency mark for the session-reminder notification.
  • checkedInAt (nullable timestamp) — set when the booking transitions to CHECKED_IN via verify (see specs/customer-ticket-verify-foundation.md; migration 0050_skinny_deathbird.sql).
  • verifierUserId (nullable uuid, FK → users.users.id) — the admin user that verified the ticket at the gate. Populated only by the #14a admin-token path (now retired in favor of #14b scanner credentials).
  • verifierScannerCredentialId (nullable uuid, FK → scanner.scanner_credentials.id, ON DELETE SET NULL) — the Scanner credential that performed the verify on the scanner surface (#14b). Mutually exclusive with verifierUserId (see CHECK invariant below). On hard-delete of the credential the booking row is preserved and this column is set to NULL — the audit trail loses the operator id but the booking stays checked-in (introduced by migrations 0051_classy_hairball.sql + 0052_happy_lilith.sql).

status distinguishes six lifecycle states (see Lifecycle below). source distinguishes who initiated the booking — SELF_BOOKED (customer through client app) vs SLOT_ATTENDEE (coach pre-confirmed the customer for a recurring slot; charging happens post-fact). The walletDebited and bonusDebited flags record that an irreversible balance change has happened and is reflected in Wallet transaction — they are read on cancel paths to decide what to refund.

Invariants

  • sessionId ON DELETE CASCADE — deleting the session removes its bookings (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/bookings.schema.ts).
  • customerId ON DELETE CASCADE — deleting the customer removes their bookings (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/bookings.schema.ts).
  • customerEntitlementId ON DELETE SET NULL — entitlement deletion does not orphan the booking (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/bookings.schema.ts).
  • status defaults to PENDING; source defaults to SELF_BOOKED; price defaults to '0'; walletDebited/bonusDebited default to false (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/bookings.schema.ts).
  • Verifier mutually exclusive — DB CHECK bookings_verifier_exactly_one forbids the (verifierUserId IS NOT NULL AND verifierScannerCredentialId IS NOT NULL) combination. Both columns NULL is legal and represents two states: pre-verify (canonical, status != CHECKED_IN) AND post-credential-delete (booking remained CHECKED_IN but its scanner credential was hard-deleted via FK ON DELETE SET NULL). Migration 0052_happy_lilith.sql relaxes the predicate from “exactly-one” to “at-most-one” precisely to allow the post-delete state (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/bookings.schema.ts:104).
  • verifierScannerCredentialId ON DELETE SET NULL — credential deletion preserves the booking but discards the operator id (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/bookings.schema.ts:92).

Business invariants:

  • sessionId and customerId are cross-schema FKs with ON DELETE CASCADE — this is a central coupling, not a soft reference. Note: this contradicts the over-generalisation in ADR cross-schema-references-without-fk (to be revised).
  • customerEntitlementId is ON DELETE SET NULL: deleting the customer-pass entitlement does not orphan the booking, but the proof that the booking was covered by a pass is lost.
  • price is a snapshot at booking time — later changes to activity/session price do not retroactively apply.
  • customer.status='BANNED' blocks booking creation (enforced in bookings.service.ts:305,324, bookings-client.service.ts:662).
  • walletDebited=true / bonusDebited=true mean the matching balance write has already happened — the cancel/refund path must consult these flags before issuing a refund to avoid double-crediting.
  • For SLOT_ATTENDEE bookings, the row is created as CONFIRMED with walletDebited=false. The session takes place, then ActivitiesScheduler.handlePostFactoBilling (cron EVERY_HOUR) charges either a pass entitlement or the customer’s wallet.
  • Every → CONFIRMED transition (direct or recovered) dispatches a PDF ticket email via BookingConfirmationEmailService.dispatchConfirmedBookingTicket — enforced in bookings-client.service.ts (fireBookingTicketEmail private fan-out, 7 call sites) and payments.service.ts (LiqPay + Monobank webhook handlers fireBookingTicketEmailForPayment). The dispatch is fire-and-forget; failures (DB lookup miss, queue enqueue error, PDF render crash in the worker) NEVER fail the booking confirmation (see specs/long-lived-signed-token-pdf-ticket-via-email.md AC-13 / AC-15). PDF email is suppressed when the long-lived token would be already-expired at producer time (AC-12) and when the BOOKING_PDF_TICKET_ENABLED env kill-switch is false (AC-6). CANCELLED / REFUNDED transitions do NOT trigger PDF emails — cancellation/refund flows keep their plain-text confirmations (spec non-goal).

Lifecycle

Status enum values: PENDING, CONFIRMED, CANCELLED, REFUNDED, PENDING_PAYMENT, CHECKED_IN.

created → CONFIRMED (admin booking; self-book with sufficient wallet balance; BONUS/PASS upfront; guest ON_SITE)
created → PENDING_PAYMENT (self-book LIQPAY — waiting for gateway webhook; guest LIQPAY; self-book WALLET with insufficient balance — held until customer tops up; DEFER)
PENDING → CONFIRMED (legacy: pre-X2 LIQPAY rows that pre-date the PENDING_PAYMENT widening still transition via webhook)
PENDING_PAYMENT → CONFIRMED (LiqPay webhook payment confirmed; customer tops up wallet and retries pay)
CONFIRMED → CANCELLED (cancellation outside refund window — no refund)
CONFIRMED → REFUNDED (cancellation within refund window — refund to wallet / bonus / pass restore)
CONFIRMED → CHECKED_IN (gate verify: POST /api/scanner/bookings/verify consumes a customer-issued HMAC token; sets checked_in_at + verifier_scanner_credential_id)

X2 lifecycle note (guest checkout): guest bookings created via POST /api/client/guest/companies/:companyId/sessions/:sessionId/bookings follow the same transition graph as authenticated bookings. The only difference is upstream: customerId is resolved by (companyId, lower(email)) find-or-create and the resulting companyCustomer.userId is NULL. AuthSyncService.linkUnlinkedCustomersByEmail later sets that userId on first authenticated hit with the matching email — no booking-level field changes.

Mutation sources (verified against code)

SourceWhat it does
Admin booking (bookings.service.ts:66,90)Creates CONFIRMED with walletDebited=false. Used for both admin-side ad-hoc bookings and SLOT_ATTENDEE pre-confirmation.
Self-book WALLET sufficient (bookings-client.service.ts:529)CONFIRMED + immediate wallet debit.
Self-book WALLET insufficient (bookings-client.service.ts:522)PENDING_PAYMENT — held until top-up.
Self-book BONUS sufficient (bookings-client.service.ts:557)CONFIRMED + bonusDebited=true.
Self-book PASS path (bookings-client.service.ts:510)CONFIRMED + customerEntitlementId set, entitlement counter decremented.
Self-book LIQPAY (bookings-client.service.ts:722)PENDING_PAYMENT + redirect to gateway checkout. X2 change: widened from PENDING to PENDING_PAYMENT for the LIQPAY path so guest and authenticated flows share the same pre-webhook status; webhook handler is unchanged (it accepts both).
Guest checkout LIQPAY / ON_SITE (bookings-guest.service.ts)PENDING_PAYMENT (LIQPAY) or CONFIRMED (ON_SITE) on a companyCustomer row with userId = NULL. Delegates to BookingsClientService.createBookingForCustomer(customerId, ..., { actorUserId: null }) — same downstream pipeline as authenticated, only the customer-resolution step differs (find-or-create by (companyId, lower(email)) instead of (companyId, userId)).
LiqPay webhook / wallet top-up retry (bookings.service.ts:242)`PENDING
Cancel within refund window (bookings-client.service.ts:346,378,406)CONFIRMED → REFUNDED + automatic refund.
Cancel outside refund window (bookings-client.service.ts:306)CONFIRMED → CANCELLED, no refund.
ActivitiesScheduler.handlePostFactoBilling (cron EVERY_HOUR)Finds CONFIRMED + SLOT_ATTENDEE + walletDebited=false + session has passed. Charges pass entitlement first, then wallet. Sets walletDebited=true on success.
ActivitiesScheduler.handleSessionReminders (cron every 30 min, 0,30 * * * *)Sends 24h reminders. Doesn’t change status; sets reminderSentAt.
Ticket verify — POST /api/scanner/bookings/verify (scannerBookingsVerify, see specs/scanner-ecosystem.md)Race-safe CONFIRMED → CHECKED_IN; sets checked_in_at = now() and verifier_scanner_credential_id = <calling scanner>. Replay within token TTL returns 409. Tenancy: the calling Scanner credential companyId must equal booking.session.companyId. Supersedes the now-removed POST /api/business/bookings/verify (bookingsAdminVerify) from #14a — admin tokens are no longer accepted at the gate.
PDF ticket email dispatch — BookingConfirmationEmailService.dispatchConfirmedBookingTicket (X1, specs/long-lived-signed-token-pdf-ticket-via-email.md)Side-effect (no status change). Fan-out from 7 CONFIRMED transition sites in bookings-client.service.ts (ON_SITE, WALLET sufficient, WALLET zero-price, BONUS sufficient, BONUS zero-price, PASS, wallet /pay) plus the LiqPay + Monobank webhook handlers in payments.service.ts. Producer enqueues a slim BullMQ payload ({ kind: 'BOOKING_TICKET', bookingId, sessionId, to, locale }) with a stable per-booking jobId (dedup across retries). Worker renders PDF in EmailProcessor and sends via Resend with the PDF attached. AC-12 skip rule: if computeTokenExp(session) < now at producer time, the email is suppressed (the QR would be immediately expired); the booking transition itself is unaffected. AC-16 transactional bypass: uses MessagingService.sendEmail directly so the user-pref emailEnabled flag does NOT suppress the ticket.

Files: bookings.service.ts (X2: resolveCustomerId widened from private to public so the guest service can reuse the find-or-create), bookings-client.service.ts (X2: createBookingForCustomer(customerId, ..., { isNew, actorUserId: string | null }) extracted; createBooking(userId, ...) is now a thin wrapper that resolves by userId then delegates; fireBookingNotifications accepts actorUserId: string | null and skips the customer push on the null branch — guest path), bookings-guest.controller.ts (X2: @Public() + ThrottlerGuard 10 req / 60s / IP + GUEST_CHECKOUT_ENABLED=false 404 kill switch), bookings-guest.service.ts (X2: guest adapter — find-or-create by (companyId, lower(email)), race-recovery via unique-violation re-query, 5-min verifyToken inline on CONFIRMED, error neutralisation), activities-scheduler.service.ts, bookings-scanner.controller.ts (verify endpoint moved onto the new scanner surface in #14b), booking-confirmation-email.service.ts (X1 producer helper — reused unchanged by guests; recipient resolution already falls back from companyCustomers.email so userId = NULL works without modification), booking-ticket-data.service.ts (X1 worker-side single-query loader), email.processor.ts (X1 BOOKING_TICKET job kind), payments.service.ts (X1 LiqPay + Monobank webhook fan-out — webhook is @Public() and resolves bookings by order_id → payment.id → payment.sourceId so the guest LIQPAY round-trip works end-to-end with zero callback-side changes). The HMAC token used by gate verify is issued/validated by the shared libs/shared/booking-verify-token library — same token format as #14a; X1 adds a generateLongLivedToken({ booking, sessionStartsAt, sessionEndsAt, graceAfterEndMin, graceFromStartMin }) variant whose exp is computed from session timing (default endsAt + 30 min, fallback startsAt + 240 min when endsAt is null) and a pure computeTokenExp(session, grace) helper used by the producer skip rule.

Relationships

  • Session (ENT-012) — sessionIdactivities.sessions.id, on-delete cascade. N:1.
  • Company customer (ENT-017) — customerIdcompanies.company_customer.id, on-delete cascade. N:1.
  • Customer entitlement (ENT-027) — customerEntitlementIdpasses.customer_entitlement.id, on-delete set null. N:1, optional.
  • Booking extra (ENT-004) — 1:N, child rows snapshot each extra purchased with the booking.
  • Refund request (ENT-039) — 0..1, referenced by wallet.refund_request.booking_id (UNIQUE).
  • Scanner credential (ENT-044) — verifier_scanner_credential_idscanner.scanner_credentials.id, ON DELETE SET NULL. N:1, optional. Populated when the gate verify is performed by a scanner credential (the only path after #14b).

API surfaces

SurfaceExposedNotes
clientyes — /companies/{companyId}/my-bookings, /companies/{companyId}/bookings, /companies/{companyId}/bookings/{bookingId}/pay, /companies/{companyId}/bookings/{bookingId}/cancel, /companies/{companyId}/sessions/{sessionId}/bookings. Also GET /bookings/{bookingId}/reviews-eligibility (AC-7 of specs/contributor-reviews-ratings.md) — returns which contributors on that booking the caller may still review. Cross-company list (Phase C, 869dr5gj6): new GET /api/client/me/bookings?upcoming&page&limit (operationId bookingsClientListMineGlobal) — BookingsGlobalClientController @ me/bookings, delegates to ActivitiesClientService.findMyBookingsGlobal(userId, opts). Cross-user isolation via companyCustomers.userId = $userId (no companyId segment). Returns the same { items, total, page, limit } paginated shape; items are CustomerBookingClientResponseDto. DTO widening (shared across both list endpoints): CustomerBookingClientResponseDto.company: BookingCompanyDto (required — { id, name, logoUrl }, logoUrl null when absent, never empty string); BookingActivityDto.sphereCode: string (required — joined from spheres.code via activities.sphereId, drives client-side sphere icon lookup). Per-company /companies/{companyId}/my-bookings also returns the new fields (shared DTO; redundant company on the company-scoped endpoint is harmless). See ADR cross-company-me-bookings. activity.refundable: CustomerBookingClientResponseDto.activity (i.e. BookingActivityDto) now carries the refundable: boolean flag from the parent activity, consumed by the gym_app to gate the cancel CTA. See Activity and ADR activity-refundable-and-cancellation-window. Ticket verify token (Phase C, 869drv9gw / #14a): new GET /api/client/me/bookings/{id}/verify-token (operationId bookingsClientIssueVerifyToken) — issues a rotating HMAC-SHA256 token (30s TTL, mobile polls every ~25s) for the booking owned by the authed user. Response: BookingVerifyTokenClientResponseDto { token, expiresAt, refreshIn }. 403/404 for cross-user access; 409 when the booking is in a non-eligible status (CHECKED_IN / CANCELLED / REFUNDED). Driven by the new libs/shared/booking-verify-token library. See specs/customer-ticket-verify-foundation.md. Guest checkout (X2, 869dt9c0x): new POST /api/client/guest/companies/{companyId}/sessions/{sessionId}/bookings (operationId bookingsGuestCreate) — public (@Public(), no ClientJwtGuard), IP-throttled at 10 req / 60s via @nestjs/throttler (uses req.ip with trust proxy enabled in apps/api/src/main.ts:44). Body: CreateGuestBookingDto { email (required), name?, phone?, paymentMethod: GuestPaymentMethod, resultUrl? (required when LIQPAY), extras? }. GuestPaymentMethod enum is restricted to [ON_SITE, LIQPAY] — sending WALLET/PASS/DEFER/BONUS returns 400. Response: GuestCreateBookingResponseDto { booking, payment?, verifyToken? }. The verifyToken block is emitted only on terminal-CONFIRMED bookings (ON_SITE) with a 5-minute (300s) TTL — extended from the 30s authenticated default because guests have no /me/* refresh endpoint. For LIQPAY the booking is PENDING_PAYMENT at create time and the guest receives the PDF link by email after webhook confirmation. Customer resolution: (companyId, lower(email)) find-or-create with single-retry on Postgres unique-violation (race-safe via the existing companyCustomers (companyId, email) UNIQUE). Kill switch: GUEST_CHECKOUT_ENABLED=false → controller returns 404 (does not advertise the feature). Error neutralisation: errors.booking.customer_banned / errors.booking.already_exists collapse to errors.booking.unavailable to prevent email enumeration. See FLOW-002 and specs/guest-checkout-via-email-and-signup-nudge.md.Swagger UI
businessembedded only via SessionBookingDto inside session-expansion responses (SessionResponseDto.bookings, activeBookingsCount); no top-level /bookings CRUD on business. Ticket verify (#14b — removed from business surface): the temporary POST /api/business/bookings/verify (bookingsAdminVerify) endpoint introduced in #14a has been DELETED. Verification is now scanner-only — see the scanner row below. Admin operators must provision a Scanner credential and verify from the scanner mobile app. See specs/scanner-ecosystem.md.Swagger UI
scanneryes — POST /api/scanner/bookings/verify (operationId scannerBookingsVerify). Authenticated by the scanner-surface JWT (issued by POST /api/scanner/auth/login against a scanner.scanner_credentials row — see Scanner credential). Validates HMAC + expiry, then race-safe UPDATE transitions CONFIRMED → CHECKED_IN, populating checked_in_at and verifier_scanner_credential_id. Tenancy: refuses to verify a booking whose session’s companyId differs from the calling credential’s companyId. 400 on malformed / expired / invalid-HMAC / non-verifiable status; 409 on replay. The scanner surface is the 4th API contract (alongside client / business / super-admin) — _workflow/contracts/scanner.openapi.yaml.Swagger UI
super-adminno

Known gotchas / open questions

  • The booking lives in the bookings Postgres schema but the NestJS feature module is libs/features/activities/ — there is no standalone bookings feature library. Domain context (bookings/) and module layout intentionally differ.
  • Bookings reach the admin panel only via session expansion (SessionResponseDto.bookings, activeBookingsCount). There is no top-level admin booking CRUD — by design, bookings are scoped to their parent session.
  • Two distinct “waiting” statusesPENDING waits for an external gateway (LIQPAY webhook); PENDING_PAYMENT waits for the customer’s internal wallet to be topped up. They are easy to confuse but mean different things.
  • customerEntitlementId IS NULL means either “never covered by a pass” OR “was covered but the pass entitlement was deleted (SET NULL)”. Without an audit trail there is no way to tell which.
  • OPEN: what happens when handlePostFactoBilling cannot charge (no pass, insufficient wallet)? The post-fact path does not appear to transition the booking to PENDING_PAYMENT — verify whether retries continue forever or there’s an escape state.
  • price is text/decimal — cancel/refund paths parse it back via parseFloat. Currency precision risk on edge cases.

Recommendations

Forward-looking improvements suggested while filling this doc — not currently in place.

  • DB CHECK constraint on legal transitions — prevent illegal hops like CANCELLED → CONFIRMED.
  • Status-transition audit log — critical for customer disputes (“I didn’t cancel, why is it cancelled?”). Today no record exists of who/when transitioned a booking.
  • Document the post-fact billing failure path explicitly. When neither pass nor wallet can pay, the booking should likely move to a dedicated state (e.g. PENDING_PAYMENT) with admin visibility, not silently keep retrying.
  • Rename for clarityPENDING → AWAITING_GATEWAY, PENDING_PAYMENT → AWAITING_TOPUP. The current names are easily confused.
  • Preserve customerEntitlementId on entitlement delete — switch from ON DELETE SET NULL to keeping the id (or duplicate into a coveredByEntitlementId audit column) so refunds and reports retain the linkage.
  • Lock on (sessionId, customerId) during booking insert — defend against race conditions creating two simultaneous bookings of the same session by the same customer.
  • Idempotency on the LiqPay webhook — pairs with the same recommendation on Payment externalId.