Guest checkout via email and post-purchase signup nudge
Actor: End User (unauthenticated visitor — no Supabase account yet)
Surface: client-mobile-gym (gym_app) and tktspace-web — same UX state machine, two implementations (Flutter / Angular). tickets_app parity is out of scope for X2.
Prerequisites: the visitor has reached the checkout step for a public session on a public activity; the activity’s allowedPaymentMethods intersects {ON_SITE, LIQPAY} (otherwise the CTA is replaced by “Sign in to book” — AC-10); the backend kill switch GUEST_CHECKOUT_ENABLED is not false.
Goal: complete a booking and receive a printable PDF ticket without creating an account first, with the option to register later and have past bookings auto-attached.
TBD by human — Purpose framing: business rationale around impulse-conversion vs. account-creation friction; product-team confirmation on whether the post-purchase modal copy (“Create account with this email” / “Continue without account”) needs A/B testing surface.
Steps
- End User — browses an activity / session anonymously on the client surface (no JWT in the request). Mobile router has a guest-allowed allowlist for
/checkout/,/activity/,/guest/paths so the auth gate does not redirect away.- Screen (mobile): activity → session → checkout summary
- Screen (web):
/activity/:id→/checkout/:sessionId
- System (client) — the checkout summary step detects “no JWT” and renders additional guest fields: email (required), name (optional), phone (optional). The payment method selector is filtered to the intersection of
allowedPaymentMethodsand{ON_SITE, LIQPAY}(AC-9 / AC-15). If the intersection is empty, the “Book” button is replaced by “Sign in to book” (AC-10). - End User — fills in email + optional name/phone, picks payment method, taps “Book”.
- Client → API —
POST /api/client/guest/companies/{companyId}/sessions/{sessionId}/bookings(swagger) with body{ email, name?, phone?, paymentMethod: ON_SITE | LIQPAY, resultUrl? (required for LIQPAY), extras? }. NoAuthorizationheader. - System (
BookingsGuestController+BookingsGuestService):- Kill switch check —
GUEST_CHECKOUT_ENABLED=false→ 404 (intentionally doesn’t advertise the feature). - IP throttle —
ThrottlerGuardat 10 req / 60s (usesreq.ipwithtrust proxyenabled on the Express adapter; D8 anti-abuse). Excess → 429. - Customer resolution —
resolveGuestCustomerId(companyId, lower(email), name?, phone?)callsBookingsService.resolveCustomerId(find-or-create by(companyId, phone OR email), BANNED check, customers-limit guard). Single-retry on Postgresunique_violation(23505) to recover from concurrent inserts. Result: acompanyCustomer.idwithuserId = NULL. - Booking creation —
BookingsClientService.createBookingForCustomer(customerId, companyId, sessionId, innerDto, { isNew: false, actorUserId: null }). Same downstream pipeline as authenticated: subscription assertion, duplicate check, price calc, payment-method validation, payment-branch switch, extras insert, session sync,fireBookingNotifications(with the customer-push skip onactorUserId === null), PDF ticket dispatch (X1 —fireBookingTicketEmail). - Error neutralisation —
errors.booking.customer_bannedanderrors.booking.already_existsare rewritten toerrors.booking.unavailableon this surface only (D11 — prevents email enumeration). Other 400 / 404 exceptions propagate unchanged.
- Kill switch check —
- System (BullMQ producer — X1
BookingConfirmationEmailService) — runs on every→ CONFIRMEDtransition; for guests, the recipient resolution path (companyCustomers.email→ fallbackusers.emailviauserId) lands directly on the guest’s email becauseuserId IS NULL. Locale defaults to'en'for guests. See FLOW-001. - System (API response) — returns
GuestCreateBookingResponseDto:booking— the created booking record (statusCONFIRMEDfor ON_SITE,PENDING_PAYMENTfor LIQPAY).payment— LiqPay checkout data (data+signature+paymentUrl) whenpaymentMethod = LIQPAY. Absent on ON_SITE.verifyToken—{ token, expiresAt, refreshIn }with a 5-minute (300s) TTL, emitted only whenbooking.status === 'CONFIRMED'. Extended from the 30s authenticated default because guests have no/me/*refresh endpoint. See ADR D7.
- Client (ON_SITE branch) — receives 201 with
verifyToken. Renders the post-purchase modal (AC-11) with two CTAs:- “Create account with this email” → mobile: opens Supabase signup flow with email pre-filled. Web: navigates to
/auth/signup?email=<guestEmail>&returnUrl=/my-bookings. The existingSignupPagewas extended (AC-17) to read theemailquery param and pre-fill the email FormControl — no new page. - “Continue without account” → navigates to the guest-ticket view (AC-13 / AC-18) showing the booking summary, a QR code rendered from
verifyToken.token, and a “Register to save your tickets” banner. The QR expires when the 5-min token does; no refresh polling on the public surface.
- “Create account with this email” → mobile: opens Supabase signup flow with email pre-filled. Web: navigates to
- Client (LIQPAY branch) — receives 201 with
payment.data+payment.signature(noverifyTokenbecausestatus === 'PENDING_PAYMENT'). Redirects to LiqPay using the checkout fields. On return, shows “Check your email” — the PDF lands once LiqPay’s webhook lands. - System (LiqPay webhook — existing X1 path, unchanged) —
POST /api/client/payments/webhook(already@Public(), resolves bookings byorder_id → payment.id → payment.sourceId → booking.id). Webhook handler inpayments.service.tsflipsPENDING_PAYMENT → CONFIRMEDand re-firesdispatchConfirmedBookingTicket(booking, session). The guest’s email lands. - End User (signup later — AC-7). When the guest later signs up via Supabase with the same email,
AuthSyncService.validateClientruns on the first authenticated hit and callslinkUnlinkedCustomersByEmail(supabaseId, email). The matchingcompanyCustomerrow(s) (across companies, alluserId = NULL) haveuserIdset to the new Supabase id. The previously-anonymous bookings now appear underGET /api/client/me/bookings. Ifusers.globalNameis null at link time, it is backfilled deterministically from the OLDEST matchedcompanyCustomer.name(see Company customer — Lifecycle).
Entities touched
- ENT-003 — Booking — created with
customerIdpointing to auserId = NULLcompany-customer row. Same transition graph as authenticated. The X2-specific change is upstream (customer resolution by email), not on this row. - ENT-012 — Session — read for
price,allowedPaymentMethods,startsAt/endsAt(PDF token expiry). - ENT-016 — Company — read for branding / PDF header;
companyCustomers.companyIdis the tenancy key for the find-or-create. - ENT-017 — Company customer — find-or-created by
(companyId, lower(email))withuserId = NULL. Set byAuthSyncService.linkUnlinkedCustomersByEmailat later signup. See ADR global-user-identity D6. - ENT-021 — User — not touched at guest-create time (no
userId). Linked retroactively by AuthSync on first authenticated hit with the matching email.
Sequence
sequenceDiagram actor U as Guest visitor participant W as Client (gym_app / web) participant BGC as BookingsGuestController participant BGS as BookingsGuestService participant BS as BookingsService participant BCS as BookingsClientService participant BCE as BookingConfirmationEmailService (X1) participant LP as LiqPay participant AS as AuthSyncService
U->>W: Browse activity → checkout (no JWT) W->>W: Detect no-JWT → render guest fields + filter payment methods U->>W: Fill email + pick method + Book W->>BGC: POST /api/client/guest/companies/{cid}/sessions/{sid}/bookings BGC->>BGC: env kill switch check + IP throttle (10/60s) BGC->>BGS: createGuestBooking(cid, sid, dto) BGS->>BS: resolveCustomerId({ email: lower(email), name?, phone? }, cid) BS-->>BGS: customerId (userId = NULL) BGS->>BCS: createBookingForCustomer(customerId, ..., { actorUserId: null }) alt ON_SITE BCS-->>BGS: { booking: CONFIRMED } BGS->>BCE: dispatchConfirmedBookingTicket(booking, session) [fire-and-forget] BGS->>BGS: sign 5-min verifyToken BGS-->>W: { booking, verifyToken } W->>U: Post-purchase modal (Create account / Continue) else LIQPAY BCS-->>BGS: { booking: PENDING_PAYMENT, payment: { data, signature, paymentUrl } } BGS-->>W: { booking, payment } (no verifyToken) W->>LP: Redirect with checkout fields LP->>BCS: POST /api/client/payments/webhook (existing @Public path) BCS->>BCE: dispatchConfirmedBookingTicket(...) on PENDING_PAYMENT → CONFIRMED end
Note over U,AS: Later — guest signs up U->>AS: Supabase signup with same email AS->>BS: linkUnlinkedCustomersByEmail(supabaseId, email) AS-->>U: companyCustomer.userId set → bookings appear in /me/bookingsEdge cases
- Concurrent guest POSTs with the same
(companyId, email). ThecompanyCustomers (companyId, email)UNIQUE constraint blocks the second INSERT. The guest service catches Postgres23505and re-queries the now-existing row, returning its id. Both bookings end up on the samecompanyCustomer. No backoff loop — one retry is sufficient because the constraint guarantees row presence after one failed INSERT. - Mixed-case email pre-existing. Guest typing
bob@x.comagainst a pre-X2 row ofBob@x.comcreates a new lowercase row. AuthSync later normalises (both sides converge on Supabase-lowercased email), but twocompanyCustomerrows for the same person can persist until manually merged. Documented in spec ADR D3 — historical backfill is out of scope. GuestPaymentMethodrejection. SendingWALLET,PASS,DEFER, orBONUSreturns 400 (class-validator on the DTO). The intersection in the client UI is the first guard; the DTO enum is the second.LIQPAYwithoutresultUrl. Class-validator@ValidateIf((o) => o.paymentMethod === GuestPaymentMethod.LIQPAY)+@IsUrl()returns 400 before any business logic runs.- Customer is BANNED in this company.
resolveCustomerIdthrowserrors.booking.customer_banned. The guest service rewrites toerrors.booking.unavailable(D11) to prevent email enumeration. - Duplicate booking for the same
(sessionId, customerId)in a non-terminal status.createBookingForCustomerraiseserrors.booking.already_exists. The guest service rewrites toerrors.booking.unavailablefor the same reason. - Rate limit exceeded. 429 —
ThrottlerGuardkeyed onreq.ip. The trust-proxy setting (apps/api/src/main.ts:44) honoursX-Forwarded-Forso the e2e suite can isolate per-test IPs. GUEST_CHECKOUT_ENABLED=false. Controller throws 404 (NotFoundException) before invoking the service — intentionally indistinguishable from “endpoint does not exist”.- PDF render failure in the worker. Inherited from FLOW-001: worker logs a warn, sends the plain HTML confirmation email without the attachment, never fails the booking transition.
- Token already expired at producer time. Inherited from FLOW-001: producer skip rule. Happens when a session in the past flips to CONFIRMED; the booking row is still created, just no PDF email.
- Email arrives at a spam folder or never arrives. No in-app recovery path on the public surface (guest has no JWT, can’t call
/me/*). Recovery is an admin endpoint, not shipped in X2 — tracked as a follow-up (POST /api/business/bookings/:id/resend-confirmation-email). - AuthSync at later signup picks the wrong oldest row.
linkUnlinkedCustomersByEmailusesORDER BY company_customer.created_at ASC, id ASC LIMIT 1for the globalName backfill — deterministic across multi-company linking. If the guest booked at multiple companies before signing up, all matching rows are linked, butusers.globalNamecomes from the oldest one.
Open questions
- OPEN: TBD by human — Whether the “Continue without account” modal CTA should additionally display the long-lived PDF link (deep link to the email’s PDF) so guests can re-open the durable surface without their inbox. Spec leaves the modal text-only.
- OPEN: TBD by human — Whether ON_SITE guest bookings need a separate “PENDING_RESERVATION → CONFIRMED on first scan” semantic (CONCERN 7 in the ADR — distributed-attacker / ON_SITE seat-flooding mitigation). Out of scope for X2; future ticket.
- OPEN: TBD by human — Whether the per-IP throttle (10 req / 60s) needs upgrade to per-IP × per-company shaping if attack patterns warrant it. ADR D8 leaves the upgrade as a future iteration; needs ops-team review post-launch.
- OPEN: TBD by human — Whether a guest variant of “resend my ticket” needs a public endpoint (token-protected, against the long-lived PDF token) vs. the planned admin-only
POST /api/business/bookings/:id/resend-confirmation-email. Product confirmation pending.
See also
- Flow: FLOW-001 — Booking confirmation PDF ticket email — the PDF + long-lived token producer/worker pipeline that this flow reuses unchanged for the email surface.
- Entity: Booking — Mutation sources table now includes the guest path; LIQPAY path widened from
PENDINGtoPENDING_PAYMENTfor both authenticated and guest. - Entity: Company customer — Lifecycle “Create — guest checkout” describes the find-or-create + AuthSync auto-link path.
- Spec:
specs/guest-checkout-via-email-and-signup-nudge.md(X2) - Spec:
specs/long-lived-signed-token-pdf-ticket-via-email.md(X1 — direct dependency) - ADR: guest-checkout-via-email-and-signup-nudge