Skip to content

Unified auth-optional checkout (Option A — full convergence)

Goal

One auth-optional, auth-aware checkout surface for monthly lockouts that serves an anonymous walk-in (stub) and a logged-in member equally, with full feature parity and one URL. Auth is resolved on the server (Supabase SSR getClaims), the server provides capabilities (the ownership-gated customer session), and the client never gates rendering or payment on a session. Generalizes the proven waitlist pattern (#792). See ADR-020.

Guiding principle

Build parity onto the unified surface first; flip the routing last. Members keep 100% of their experience at every intermediate step. The cutover (one URL) is the final phase, only after parity is proven across the matrix.

What's already true (firsthand-verified 2026-06-15)

  • Pricing is at parity. Public and authed monthly init/preview call the same createLockoutSubscription / previewMonthlyLockout with identical inputs; the public body is .strict() and accepts only paymentMethod (no price smuggling). No pricing work needed.
  • Identity is invitation-bound in the public endpoints (no logged-in-user bleed).
  • Idempotency parity (usedAt, billingStartDate, stale-attempt cleanup).
  • Lockout cannot use invitation mode (it needs a pre-issued clientSecret + skips the ACH fulfill call + uses the preIssued confirm path). It stays subscription mode.
  • fetchSession / ensureUserExists are shared by authed hourly, reservation-monthly, credits, org-dedicated. They must not be deleted — lockout opts out via a per-config flag.

Verified gap list (what must be closed)

# Layer Gap Sev
G1 API GET Lockout invitation GET mints no customerSessionClientSecret; must mirror waitlist, ownership-gated (only when auth.userId === invitation.userId) High
G2 Web Non-invitation branch can't consume a server-provided session; needs a per-config flag to use it and skip the client fetchSession High
G3 Web cfg Lockout config disables promotion codes, referral codes, URL discount, express checkout High
G4 API Public init lacks approved gate, attribution metadata (fbclid/fbp/fbc), and trackCheckoutInitToMeta Med
G5 API Public preview/init accept only {paymentMethod} — member-entered promo/referral codes have no path in High
G6 Web cfg Lockout config has no tracking (onStarted/onCompleted) Med
G7 Web Success path carries only paymentMethod, not the attribution params the confirmation/analytics expect Med
G8 Web Public confirmation has no "finish setting up your account" CTA for stubs Low

Security note on G5 (deliberate, bounded)

The public endpoints' "only {paymentMethod}" rule exists so the client cannot smuggle a price. Member-entered promo/referral are codes, not amounts — a code is a capability the customer legitimately holds, validated server-side against the DB. We widen the body to accept promotionCodeId / referrerId / discountCode only, validated exactly as the authed path validates them; resource, amounts, dates, and anchor stay invitation-derived. The security model (no client-supplied pricing) is preserved.

Status — implemented (one PR, ordered commits)

All phases below are implemented on refactor/unified-checkout-surface. The routing flip (Phase 5) is atomic, so the phases ship together in one PR.

  • Phase 0–5 done. Phase 6: the dead routing scaffolding (invitationUserIsSetUp, InvitationUrlOptions, the userIsSetUp plumbing — the #827 stopgap) is removed by the Phase 5 cutover; docs updated here.
  • Lint-ban on raw getAuthHeaders() in features/checkout/ — dropped. It can't cleanly distinguish legitimate authed-only calls (ensureUserExists, fetchSession for the hourly/reservation/credits/org flows) from anon-unsafe ones. The getCheckoutHeaders() helper + the server-provided-session pattern are the safeguard instead.

Build order (the phases below are ordered commit-stages)

Phase 0 — Foundation (no behavior change) - One graceful getCheckoutHeaders() (attaches session if present, anonymous otherwise); migrate the features/checkout/ call sites to it; lint-ban raw getAuthHeaders() there. Closes the scattered-catch footgun permanently.

Phase 1 — Server provides the customer session (the core mechanism) — closes G1, G2 - API: lockout GET mints customerSessionClientSecret, ownership-gated, mirroring waitlist-invitation/[id]/route.ts (callerMatchesInvitationcreatePaymentElementCustomerSession). Anon stub → none; non-owner link-holder → none; owner-logged-in → their saved methods. - Web: thread it into CheckoutExperience via a per-config flag; when present, use it for Elements and skip the client fetchSession. Other configs untouched. - Verify: member sees saved cards; stub doesn't; non-owner doesn't; card becomes the default PM for members again; hourly/reservation/credits/org unchanged.

Phase 2 — API feature parity on the public endpoints — closes G4, G5 - Public init: add the approved gate, buildStripeAttributionMetadata, and trackCheckoutInitToMeta. - Public preview + init: accept validated promotionCodeId/referrerId/discountCode (see security note). Money path — full integration tests + a "body still can't set amounts/resource" test.

Phase 3 — Web config parity — closes G3, G6, G7 - Unify the lockout config: enable promotionCodes/referralCodes/urlDiscount/expressCheckout, add tracking, thread member-entered codes to the now-accepting endpoints, carry attribution params in the success path. - Verify: member can enter a promo, use Apple/Google Pay, tracking + Meta CAPI fire; stub flow unaffected (fields simply unused).

Phase 4 — Confirmation parity — closes G8 - Stub gets a "finish setting up your account" CTA on the public confirmation; confirm member "view reservation / back to my account" routes to their account (audit says OK — verify).

Phase 5 — Cutover to one URL (last, after parity proven) - API: route ALL monthly invitations to /checkout/lockout-invitation/:id; remove invitationUserIsSetUp, InvitationUrlOptions, and the user-field selects that fed them. - Web: the authed reservation page stops handling monthly invitations (keeps hourly/migration); remove getCheckoutUserData if now unused. - Verify the full matrix end-to-end before merge.

Phase 6 — Cleanup - Remove dead dual-URL scaffolding; supersede the #825/#827 stopgaps where now redundant; update public-lockout-checkout/spec.md and this plan; flip ADR-020 references.

Verification matrix (every phase re-checks the relevant columns)

Flow Anon Authed (owner) Authed (non-owner link-holder)
Lockout monthly (card) pay, no saved PMs pay, saved PMs pay, no saved PMs (no card leak)
Lockout monthly (ACH) pay + optimistic fulfill same + saved same
Lockout future-dated (no proration) PENDING until anchor same same
Waitlist invite unchanged unchanged unchanged
Hourly / migration / credits / org-dedicated n/a unchanged (still client fetchSession) n/a

Money-path phases (1, 2, 5) require a real-browser Stripe smoke test (card + ACH), not just unit/integration tests.

Risk register

  • Phases 2 and 5 touch the money path — preserve idempotency guards, verify with real Stripe.
  • G5 widens the public input contract — keep it codes-only + server-validated; never accept client amounts/resource.
  • The shared CheckoutExperience serves five flows — every phase must leave the other four untouched and re-run the matrix.
  • Express checkout (Apple/Google Pay) on an auth-optional page needs its own device test.