Skip to content

Booking confirmation PDF ticket email

Actor: End User (customer who just booked) Surface: any client surface (web, mobile gym, mobile tickets) — the email is dispatched by the backend on confirmation and read in the customer’s external mail client. No UI surface owns the flow. Prerequisites: booking transitions to CONFIRMED; BOOKING_PDF_TICKET_ENABLED env is true (default); the customer’s resolved email is non-empty. Goal: receive a printable / scannable ticket the customer can present at the venue gate without opening the app.

TBD by human — Purpose framing: this flow is purely backend-internal in X1 (no UI changes). X2 (869dt9c0x, “guest checkout via email”) landed Phase C and reuses this same PDF + long-lived token producer/worker pipeline unchanged. Recipient resolution already falls back from companyCustomers.email to users.email, so guest rows (where userId = NULL) deliver to the guest’s email without code changes. See sibling flow FLOW-002 — Guest checkout via email and post-purchase signup nudge for the guest UX state machine.

Steps

  1. System (any → CONFIRMED transition) — booking row’s status flips to CONFIRMED via one of the 9 known paths (see Booking → Mutation sources):
    • Direct CONFIRMED: ON_SITE create, WALLET sufficient, WALLET zero-price, BONUS sufficient, BONUS zero-price, PASS upfront.
    • PENDING / PENDING_PAYMENT → CONFIRMED: LiqPay webhook, Monobank webhook, customer wallet /pay.
    • File: libs/features/activities/src/lib/services/bookings-client.service.ts (7 call sites via fireBookingTicketEmail); libs/features/payments/src/lib/services/payments.service.ts (LiqPay + Monobank handlers via fireBookingTicketEmailForPayment).
  2. System (producer)BookingConfirmationEmailService.dispatchConfirmedBookingTicket(booking, session) runs on the HTTP hot path. Cheap by design (AC-14 P99 budget +10 ms):
    • Checks BOOKING_PDF_TICKET_ENABLED env flag (AC-6). False → exit silently.
    • Loads session.startsAt / session.endsAt from the caller’s pre-fetched row.
    • Computes exp = computeTokenExp(session, graceConfig) — pure math, no HMAC. Skip rule (AC-12): exp < now → log warn + exit silently.
    • Resolves recipient email + locale: companyCustomers.email → fallback users.email; locale from userProfiles.languageen fallback (allowed set: en, uk, ru, de, fr).
    • Enqueues a slim BullMQ payload: { kind: 'BOOKING_TICKET', bookingId, sessionId, to, locale } with jobId = booking-ticket-<bookingId> so retry-storms collapse to one delivery.
    • Errors NEVER propagate — the booking confirmation is the source of truth, the email is a side effect.
  3. System (worker)EmailProcessor picks the BullMQ job (notifications-email queue):
    • BOOKING_TICKET branch dispatches to BookingTicketDataService.loadBookingTicketData(bookingId, sessionId) — one-query join of booking + session + activity + company + customer (returns null if booking was deleted between enqueue and pick-up).
    • Generates the long-lived token via BookingVerifyTokenService.generateLongLivedToken({ bookingId, sessionStartsAt, sessionEndsAt, graceAfterEndMin, graceFromStartMin }) — identical HMAC shape to the 30s in-app token, only exp differs.
    • Best-effort fetches the company logo bytes (2s HTTP timeout). Any failure → null → text-only header.
    • Renders the PDF via BookingTicketPdfService — single A4 page: company logo or name, activity title, session date/time (timezone-localised), customer full name, QR code encoding the token, booking ID, i18n entry instructions (key booking.pdf.entry_instructions).
    • Sends the email via MessagingService.sendEmail with the PDF as a base64 attachment named ticket-<bookingId>.pdf. Transactional bypass (AC-16): uses MessagingService.sendEmail directly, so the per-user emailEnabled preference does NOT suppress the ticket.
    • Graceful degradation (AC-15): if PDF render throws, the worker logs a warn with bookingId and dispatches the plain HTML email WITHOUT the attachment. Booking confirmation is never blocked.
  4. End User — opens the email in their mail client, sees the booking summary + a QR code. They walk to the venue gate and present the QR (PDF screen or printed).
  5. Gate scannerPOST /api/scanner/bookings/verify validates the HMAC + exp, flips CONFIRMED → CHECKED_IN. Long-lived tokens are accepted with no code change (AC-8) — the scanner does not distinguish between the 30s in-app token and the long-lived email token.

Entities touched

  • ENT-003 — Booking — read (load id / customerId / sessionId for dispatch); never written by the flow itself (the upstream transition writes status).
  • ENT-012 — Session — read for startsAt / endsAt (token expiry math + timezone display).
  • ENT-016 — Company — read for name + logoUrl (PDF header).
  • ENT-017 — Company customer — read for fullName + email (recipient + PDF body).
  • ENT-021 — User — read for email fallback (when companyCustomer.email is null) and userProfiles.language (locale).

Sequence

sequenceDiagram
actor U as Customer
participant API as Backend (HTTP)
participant BCE as BookingConfirmationEmailService
participant Q as BullMQ notifications-email
participant EP as EmailProcessor (worker)
participant PDF as BookingTicketPdfService
participant VT as BookingVerifyTokenService
participant R as Resend
participant SC as Gate scanner
Note over API: any → CONFIRMED transition
API->>BCE: dispatchConfirmedBookingTicket(booking, session)
BCE->>BCE: env flag check (AC-6) + AC-12 skip rule (computeTokenExp)
BCE->>BCE: resolve recipient email + locale
BCE->>Q: enqueue { kind:'BOOKING_TICKET', bookingId, sessionId, to, locale }<br/>jobId = booking-ticket-<id>
API-->>U: HTTP 200 (does not wait on email)
Q->>EP: pick up job
EP->>EP: BookingTicketDataService.load(bookingId, sessionId)
EP->>VT: generateLongLivedToken({ booking, session, grace })
VT-->>EP: { token, exp }
EP->>PDF: render(booking, session, activity, company, customer, token, locale)
PDF-->>EP: PDF Buffer (or throw → AC-15 graceful degradation)
EP->>R: sendEmail(to, subject, html, attachments=[ticket.pdf])
R-->>U: email with PDF attachment
U->>SC: present QR at venue
SC->>API: POST /api/scanner/bookings/verify { token }
API->>VT: verify(token)
VT-->>API: { bookingId, iat, exp } or EXPIRED / BAD_SIGNATURE / MALFORMED
API-->>SC: CHECKED_IN (or 4xx)

Edge cases

  • Booking deleted between enqueue and worker pick-up. BookingTicketDataService.load(...) returns null → worker skips silently (no email, no retry). Documented in ADR D10.
  • PDF render fails (missing dep, oversized logo, pdfkit crash). Worker catches, logs a warn with bookingId, sends the HTML email WITHOUT the attachment. Customer still gets confirmation, just no PDF. (AC-15)
  • Token already expired at producer time. Producer skips enqueue (AC-12) — happens when a session in the past flips to CONFIRMED (e.g. post-fact billing after the venue gate closed). Producer logs a warn; no email sent.
  • Company logo URL slow or unreachable. Best-effort fetch with a 2 s timeout; failure → null → text-only header in the PDF. PDF still generates.
  • Recipient email missing on both companyCustomers.email and users.email. Producer logs a warn (“no recipient email”) and exits — no enqueue.
  • User opted out of marketing email (userProfile.emailEnabled = false). PDF email STILL sends — AC-16 transactional bypass via direct MessagingService.sendEmail.
  • BullMQ retry storm on transient Resend failure. jobId = booking-ticket-<bookingId> deduplicates within the queue’s retention window; customer receives at most one PDF ticket per booking confirmation.
  • CANCELLED / REFUNDED transitions. No PDF email dispatched (spec non-goal); existing plain-text confirmation emails remain.
  • Long-lived token replay after CHECKED_IN. Same behaviour as the 30s in-app token: the scanner endpoint returns 409 on already-checked-in. The token remains HMAC-valid until exp; the booking row’s CHECKED_IN status is the canonical guard. TBD by human — open: should X1 introduce a separate “already-used” response code distinct from 409? (carried over from #14a/#14b open question — not in scope for X1).

Open questions

  • OPEN: TBD by human — Should the PDF carry the company’s accent color when the company has not configured one? Today (AC-3) it falls back to #1A1A2E. Product confirmation pending.
  • OPEN: TBD by human — Per-company override for the entry instructions text. Today it is a hardcoded i18n string per locale; business team has not asked for customisation yet (spec non-goal).
  • OPEN: TBD by human — Retention / auditing of the PDF bytes. The PDF is rendered ephemerally per email and never persisted. A future “re-send my ticket” endpoint would need either re-render-on-demand or stored bytes; design open.

See also

  • Flow: FLOW-002 — Guest checkout via email and post-purchase signup nudge — reuses this PDF dispatch unchanged for unauthenticated guest bookings.
  • Entity: Booking — Mutation sources table lists every transition that triggers this flow.
  • Entity: Scanner credential — the gate side that consumes the long-lived token.
  • Spec: specs/long-lived-signed-token-pdf-ticket-via-email.md (X1)
  • Spec: specs/customer-ticket-verify-foundation.md (#14a — short-lived 30s token, in-app QR refresh)
  • Spec: specs/scanner-ecosystem.md (#14b — scanner surface + verify endpoint)
  • Spec: specs/guest-checkout-via-email-and-signup-nudge.md (X2 — guest checkout, reuses this flow’s pipeline)
  • ADR: long-lived-signed-token-pdf-ticket-via-email