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/previewMonthlyLockoutwith identical inputs; the public body is.strict()and accepts onlypaymentMethod(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/ensureUserExistsare 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, theuserIsSetUpplumbing — the #827 stopgap) is removed by the Phase 5 cutover; docs updated here. - Lint-ban on raw
getAuthHeaders()infeatures/checkout/— dropped. It can't cleanly distinguish legitimate authed-only calls (ensureUserExists,fetchSessionfor the hourly/reservation/credits/org flows) from anon-unsafe ones. ThegetCheckoutHeaders()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 (callerMatchesInvitation → createPaymentElementCustomerSession). 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
CheckoutExperienceserves 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.