Skip to content

Public (no-login) lockout checkout

Problem

Staff can create a lockout invitation for a walk-in prospect, but the customer had to sign up + complete a profile before paying. That login wall is friction at the exact moment staff want to close the deal on-site. Waitlist deposits already pay with no login; lockouts did not.

Decision

Make the staff-generated lockout invitation a fully public, no-login checkout, mirroring the waitlist invite pattern (dedicated route + shared CheckoutExperience component), while leaving the authenticated self-service / hourly / migration flows untouched.

A lockout is a Stripe subscription, not a one-time PaymentIntent, so we could not pre-create it like waitlist (a default_incomplete subscription auto-cancels ~23h after creation, which would impose a 23-hour pay window on every invitation). Instead the subscription is created on demand when the customer opens the page — no expiry window, pay-now or pay-later both work.

See the rejected "pre-create" alternative (Option A) and rationale in the PR / memory project-lockout-public-checkout.

Security model

The unguessable invitation UUID is the only credential. Every public endpoint:

  • accepts only { paymentMethod } from the request body — never resource id, amounts, dates;
  • derives all pricing/identity from the stored invitation (invitation.params via the strict lockoutInvitationParamsSchema) and the linked INVITED reservation (the stub user);
  • runs under withTransaction({ audit: { source: 'public:...' } }) (service-role, audited).

One helper — lockoutInvitationPricingInputs(params) — is the single source of truth for pricing inputs, used by both the preview and the initialize endpoints, so what the customer is quoted is provably computed from the same inputs as what they are charged.

API (all no-auth, under /checkout/lockout-invitation/[id])

Endpoint Purpose
GET /checkout/lockout-invitation/[id] Display data + status (pending/used/cancelled/expired), the monthly card/ACH totals, and (once paid) the confirmation block.
POST .../preview Pricing summary for the chosen method. Reuses LockoutPreviewService.previewMonthlyLockout — the same computation (incl. Stripe previewInvoice for proration) as the authed /checkout/preview, so the summary matches the actual charge.
POST .../initialize Creates the Stripe subscription on demand via LockoutCheckoutService.createLockoutSubscription, reusing the INVITED reservation. Returns the client secret.
POST .../fulfill Optimistic ACH grant — grants studio access before the bank transfer clears. Mirrors the authed fulfill-lockout; idempotent.

Shared services extracted (behavior-preserving) so the authed and public routes share one implementation: LockoutCheckoutService (createLockoutSubscription, prepareInvitationReservation) and LockoutPreviewService (previewMonthlyLockout).

Routing — one surface (superseded; see ADR-020)

This originally shipped with a routing-by-account-state split (stub → public page, set-up user → authed /checkout/reservation?s=). That dual-URL scheme produced a string of bugs (MG-WEB-7A, the send-email regression, a fail-unsafe router) and was replaced by ADR-020: every monthly invitation routes to the one auth-optional /checkout/lockout-invitation/[id] surface. It serves anonymous walk-ins and logged-in members equally — the server resolves auth and provides the (ownership-gated) customer session, so members keep saved payment methods + account header, and the page works for anyone holding the link. The authed /checkout/reservation?s= page still handles already-sent monthly links for backward compatibility.

Customer flow (web)

  1. Staff create the invitation; invitationUrlForType('monthly') returns /checkout/lockout-invitation/[id] (the no-login page) for every invitation.
  2. Page renders the shared CheckoutExperience (userData={null}, subscription mode) — the same payment UI as the authed checkout. Invalid/cancelled/expired/used links render the existing CheckoutErrorClient.
  3. On submit it calls the public initialize; on ACH processing it calls the public fulfill.
  4. Success routes to /checkout/lockout-invitation/[id]/confirmation, which renders the same ReservationConfirmationClient as the authed flow (fed from the public GET) so the confirmation is visually identical. While the reservation is still finalizing (ACH) it shows a processing state.
  5. After payment the lockout subscription-create webhook sends the stub user an account-setup email (optional — they only claim an account if they later want to self-serve).

Out of scope / intentional

  • Waitlist is untouched — its one-time-PaymentIntent invitation flow is already optimal.
  • The authenticated /checkout/reservation self-service / hourly / migration flows are untouched.

Verification

  • API: integration tests for init (incl. body-cannot-smuggle-pricing), GET, preview, fulfill, confirmation enrichment, plus the 21 monthly-preview tests covering the extracted computation.
  • Needs a real-browser smoke test of the live Stripe payment → confirmation flow (card + ACH) before release — not coverable by unit tests.