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
- System (any
→ CONFIRMEDtransition) — booking row’sstatusflips toCONFIRMEDvia 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 viafireBookingTicketEmail);libs/features/payments/src/lib/services/payments.service.ts(LiqPay + Monobank handlers viafireBookingTicketEmailForPayment).
- 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_ENABLEDenv flag (AC-6). False → exit silently. - Loads
session.startsAt/session.endsAtfrom 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→ fallbackusers.email; locale fromuserProfiles.language→enfallback (allowed set:en,uk,ru,de,fr). - Enqueues a slim BullMQ payload:
{ kind: 'BOOKING_TICKET', bookingId, sessionId, to, locale }withjobId = 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.
- Checks
- System (worker) —
EmailProcessorpicks the BullMQ job (notifications-emailqueue):BOOKING_TICKETbranch dispatches toBookingTicketDataService.loadBookingTicketData(bookingId, sessionId)— one-query join of booking + session + activity + company + customer (returnsnullif 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, onlyexpdiffers. - 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 (keybooking.pdf.entry_instructions). - Sends the email via
MessagingService.sendEmailwith the PDF as a base64 attachment namedticket-<bookingId>.pdf. Transactional bypass (AC-16): usesMessagingService.sendEmaildirectly, so the per-useremailEnabledpreference does NOT suppress the ticket. - Graceful degradation (AC-15): if PDF render throws, the worker logs a warn with
bookingIdand dispatches the plain HTML email WITHOUT the attachment. Booking confirmation is never blocked.
- 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).
- Gate scanner —
POST /api/scanner/bookings/verifyvalidates the HMAC +exp, flipsCONFIRMED → 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
emailfallback (whencompanyCustomer.emailis null) anduserProfiles.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(...)returnsnull→ 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.emailandusers.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 directMessagingService.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’sCHECKED_INstatus 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