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, defaultgen_random_uuid()). sessionId(uuid, FK →activities.sessions.id) — what the customer booked.customerId(uuid, FK →companies.company_customer.id) — who booked.status(enumbooking_status:PENDING,CONFIRMED,CANCELLED,REFUNDED,PENDING_PAYMENT,CHECKED_IN).source(enumbooking_source:SELF_BOOKED,SLOT_ATTENDEE) —SLOT_ATTENDEEis 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 toCHECKED_INvia verify (seespecs/customer-ticket-verify-foundation.md; migration0050_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 withverifierUserId(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 migrations0051_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
sessionIdON DELETE CASCADE — deleting the session removes its bookings (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/bookings.schema.ts).customerIdON DELETE CASCADE — deleting the customer removes their bookings (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/bookings.schema.ts).customerEntitlementIdON 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).statusdefaults toPENDING;sourcedefaults toSELF_BOOKED;pricedefaults to'0';walletDebited/bonusDebiteddefault tofalse(enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/bookings.schema.ts).- Verifier mutually exclusive — DB CHECK
bookings_verifier_exactly_oneforbids the (verifierUserId IS NOT NULLANDverifierScannerCredentialId IS NOT NULL) combination. Both columns NULL is legal and represents two states: pre-verify (canonical,status != CHECKED_IN) AND post-credential-delete (booking remainedCHECKED_INbut its scanner credential was hard-deleted via FKON DELETE SET NULL). Migration0052_happy_lilith.sqlrelaxes 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). verifierScannerCredentialIdON 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:
sessionIdandcustomerIdare cross-schema FKs withON 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).customerEntitlementIdisON 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.priceis a snapshot at booking time — later changes to activity/session price do not retroactively apply.customer.status='BANNED'blocks booking creation (enforced inbookings.service.ts:305,324,bookings-client.service.ts:662).walletDebited=true/bonusDebited=truemean 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
CONFIRMEDwithwalletDebited=false. The session takes place, thenActivitiesScheduler.handlePostFactoBilling(cronEVERY_HOUR) charges either a pass entitlement or the customer’s wallet. - Every
→ CONFIRMEDtransition (direct or recovered) dispatches a PDF ticket email viaBookingConfirmationEmailService.dispatchConfirmedBookingTicket— enforced inbookings-client.service.ts(fireBookingTicketEmailprivate fan-out, 7 call sites) andpayments.service.ts(LiqPay + Monobank webhook handlersfireBookingTicketEmailForPayment). The dispatch is fire-and-forget; failures (DB lookup miss, queue enqueue error, PDF render crash in the worker) NEVER fail the booking confirmation (seespecs/long-lived-signed-token-pdf-ticket-via-email.mdAC-13 / AC-15). PDF email is suppressed when the long-lived token would be already-expired at producer time (AC-12) and when theBOOKING_PDF_TICKET_ENABLEDenv kill-switch isfalse(AC-6).CANCELLED/REFUNDEDtransitions 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)
| Source | What 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) —
sessionId→activities.sessions.id, on-delete cascade. N:1. - Company customer (ENT-017) —
customerId→companies.company_customer.id, on-delete cascade. N:1. - Customer entitlement (ENT-027) —
customerEntitlementId→passes.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_id→scanner.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
| Surface | Exposed | Notes |
|---|---|---|
| client | yes — /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 |
| business | embedded 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 |
| scanner | yes — 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-admin | no | — |
Known gotchas / open questions
- The booking lives in the
bookingsPostgres schema but the NestJS feature module islibs/features/activities/— there is no standalonebookingsfeature 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” statuses —
PENDINGwaits for an external gateway (LIQPAY webhook);PENDING_PAYMENTwaits for the customer’s internal wallet to be topped up. They are easy to confuse but mean different things. customerEntitlementId IS NULLmeans 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
handlePostFactoBillingcannot charge (no pass, insufficient wallet)? The post-fact path does not appear to transition the booking toPENDING_PAYMENT— verify whether retries continue forever or there’s an escape state. priceistext/decimal— cancel/refund paths parse it back viaparseFloat. 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 clarity —
PENDING → AWAITING_GATEWAY,PENDING_PAYMENT → AWAITING_TOPUP. The current names are easily confused. - Preserve
customerEntitlementIdon entitlement delete — switch fromON DELETE SET NULLto keeping the id (or duplicate into acoveredByEntitlementIdaudit 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.