Skip to content

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

  1. 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
  2. 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 allowedPaymentMethods and {ON_SITE, LIQPAY} (AC-9 / AC-15). If the intersection is empty, the “Book” button is replaced by “Sign in to book” (AC-10).
  3. End User — fills in email + optional name/phone, picks payment method, taps “Book”.
  4. Client → APIPOST /api/client/guest/companies/{companyId}/sessions/{sessionId}/bookings (swagger) with body { email, name?, phone?, paymentMethod: ON_SITE | LIQPAY, resultUrl? (required for LIQPAY), extras? }. No Authorization header.
  5. System (BookingsGuestController + BookingsGuestService):
    • Kill switch check — GUEST_CHECKOUT_ENABLED=false → 404 (intentionally doesn’t advertise the feature).
    • IP throttle — ThrottlerGuard at 10 req / 60s (uses req.ip with trust proxy enabled on the Express adapter; D8 anti-abuse). Excess → 429.
    • Customer resolution — resolveGuestCustomerId(companyId, lower(email), name?, phone?) calls BookingsService.resolveCustomerId (find-or-create by (companyId, phone OR email), BANNED check, customers-limit guard). Single-retry on Postgres unique_violation (23505) to recover from concurrent inserts. Result: a companyCustomer.id with userId = 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 on actorUserId === null), PDF ticket dispatch (X1 — fireBookingTicketEmail).
    • Error neutralisation — errors.booking.customer_banned and errors.booking.already_exists are rewritten to errors.booking.unavailable on this surface only (D11 — prevents email enumeration). Other 400 / 404 exceptions propagate unchanged.
  6. System (BullMQ producer — X1 BookingConfirmationEmailService) — runs on every → CONFIRMED transition; for guests, the recipient resolution path (companyCustomers.email → fallback users.email via userId) lands directly on the guest’s email because userId IS NULL. Locale defaults to 'en' for guests. See FLOW-001.
  7. System (API response) — returns GuestCreateBookingResponseDto:
    • booking — the created booking record (status CONFIRMED for ON_SITE, PENDING_PAYMENT for LIQPAY).
    • payment — LiqPay checkout data (data + signature + paymentUrl) when paymentMethod = LIQPAY. Absent on ON_SITE.
    • verifyToken{ token, expiresAt, refreshIn } with a 5-minute (300s) TTL, emitted only when booking.status === 'CONFIRMED'. Extended from the 30s authenticated default because guests have no /me/* refresh endpoint. See ADR D7.
  8. 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 existing SignupPage was extended (AC-17) to read the email query 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.
  9. Client (LIQPAY branch) — receives 201 with payment.data + payment.signature (no verifyToken because status === 'PENDING_PAYMENT'). Redirects to LiqPay using the checkout fields. On return, shows “Check your email” — the PDF lands once LiqPay’s webhook lands.
  10. System (LiqPay webhook — existing X1 path, unchanged)POST /api/client/payments/webhook (already @Public(), resolves bookings by order_id → payment.id → payment.sourceId → booking.id). Webhook handler in payments.service.ts flips PENDING_PAYMENT → CONFIRMED and re-fires dispatchConfirmedBookingTicket(booking, session). The guest’s email lands.
  11. End User (signup later — AC-7). When the guest later signs up via Supabase with the same email, AuthSyncService.validateClient runs on the first authenticated hit and calls linkUnlinkedCustomersByEmail(supabaseId, email). The matching companyCustomer row(s) (across companies, all userId = NULL) have userId set to the new Supabase id. The previously-anonymous bookings now appear under GET /api/client/me/bookings. If users.globalName is null at link time, it is backfilled deterministically from the OLDEST matched companyCustomer.name (see Company customer — Lifecycle).

Entities touched

  • ENT-003 — Bookingcreated with customerId pointing to a userId = NULL company-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.companyId is the tenancy key for the find-or-create.
  • ENT-017 — Company customerfind-or-created by (companyId, lower(email)) with userId = NULL. Set by AuthSyncService.linkUnlinkedCustomersByEmail at 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/bookings

Edge cases

  • Concurrent guest POSTs with the same (companyId, email). The companyCustomers (companyId, email) UNIQUE constraint blocks the second INSERT. The guest service catches Postgres 23505 and re-queries the now-existing row, returning its id. Both bookings end up on the same companyCustomer. 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.com against a pre-X2 row of Bob@x.com creates a new lowercase row. AuthSync later normalises (both sides converge on Supabase-lowercased email), but two companyCustomer rows for the same person can persist until manually merged. Documented in spec ADR D3 — historical backfill is out of scope.
  • GuestPaymentMethod rejection. Sending WALLET, PASS, DEFER, or BONUS returns 400 (class-validator on the DTO). The intersection in the client UI is the first guard; the DTO enum is the second.
  • LIQPAY without resultUrl. Class-validator @ValidateIf((o) => o.paymentMethod === GuestPaymentMethod.LIQPAY) + @IsUrl() returns 400 before any business logic runs.
  • Customer is BANNED in this company. resolveCustomerId throws errors.booking.customer_banned. The guest service rewrites to errors.booking.unavailable (D11) to prevent email enumeration.
  • Duplicate booking for the same (sessionId, customerId) in a non-terminal status. createBookingForCustomer raises errors.booking.already_exists. The guest service rewrites to errors.booking.unavailable for the same reason.
  • Rate limit exceeded. 429 — ThrottlerGuard keyed on req.ip. The trust-proxy setting (apps/api/src/main.ts:44) honours X-Forwarded-For so 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. linkUnlinkedCustomersByEmail uses ORDER BY company_customer.created_at ASC, id ASC LIMIT 1 for the globalName backfill — deterministic across multi-company linking. If the guest booked at multiple companies before signing up, all matching rows are linked, but users.globalName comes 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 PENDING to PENDING_PAYMENT for 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