Skip to content

Align waitlist checkout with the shared checkout UX

Status: done

Decisions: (1) delete the dead authed-waitlist scaffolding in this PR; (2) customer session is minted server-side on the invitation endpoint (guarded by bearer↔params.userId match).

Problem

The waitlist deposit payment runs on a bespoke UI (/waitlist/invite/[id]WaitlistInviteClient) that bypasses the shared CheckoutExperience / CheckoutLayout used by every other checkout. It renders a stripped Stripe PaymentElement against a pre-issued PaymentIntent clientSecret with no Stripe customer session, so it can never show saved payment methods or wallets, and it diverges visually from lockout/hourly checkout.

This matters for two distinct payers, both of which must use the shared UX:

  1. New walk-in (on-site tour, stub user, pays on their phone, unauthenticated). No saved methods possible — but should still see the shared layout.
  2. Existing member joining a not-yet-open location's waitlist (e.g. has a lockout at location A, joins waitlist at location B). They are a logged-in member with a real Stripe customer and saved payment methods — and currently get none of them.

Findings

  • All live waitlist payments go through the no-auth invite path. Staff "Add to Waitlist" (CreateWaitlistPage) calls POST /staff/waitlist/quick-signup, which returns /waitlist/invite/<id>. Nothing produces an authed waitlist link.
  • The backend already handles both payers correctly. quick-signupresolveCustomerForStaff reuses the real member when body.userId is set (only stubs for new invites), then WaitlistCheckoutService.initializeensureStripeCustomer mints the PaymentIntent on the correct customer (real member's customer, or a fresh stub customer). So saved methods would work for the member case the moment the front-end fetches a customer session for that same customer.
  • The authed /checkout/waitlist flow is dead code — pre-#637 leftovers. Confirmed: no web navigation reaches /checkout/waitlist (every "Join Waitlist" CTA → /contact?reason=waitlist-request); quick-signup always writes params.waitlistEntryId, so resend-email always takes the /waitlist/invite/ branch and the ?s= arm is structurally unreachable; nothing POSTs /checkout/session with type: 'waitlist'. initialize-waitlist / preview-waitlist are used only by the dead page's config.
  • Doc/code divergence to fix. docs/engineering/domains/waitlist.md and checkout.md (both "updated 2026-05-03") still describe /checkout/waitlist + initialize-waitlist as the live authenticated self-service deposit flow. That predates #637 making waitlist invite-only and was never updated — the cleanup must correct these pages.
  • fulfill-waitlist is live, not dead — it's the ACH optimistic-fulfillment endpoint (user auth). The bespoke invite client never calls it, so invite-path ACH deposits currently rely on the webhook alone. Under the unified flow it becomes reachable (parity), but only when the payer is authed.
  • getAuthHeaders() throws when unauthed — the shared flow's auth'd customer-session and preview fetches can't be reused verbatim for the walk-in case.
  • The public invitation endpoint (GET /checkout/waitlist-invitation/[id]) already returns display data + the pre-issued PaymentIntent clientSecret (when payment is PENDING). It holds params.userId internally but does not return it, and runs no auth.

Target design

One page, one component tree, auth-aware. Keep /waitlist/invite/[id] publicly reachable (walk-ins must work) and route it through the shared checkout components.

Customer session (server-minted, guarded)

Extend GET /checkout/waitlist-invitation/[id] to optionally read an Authorization bearer. If a valid token is present and its user id equals the invitation's params.userId, mint a Stripe customer session for that user's customer (same config as customer-session/route.ts) and include customerSessionClientSecret + customer display fields in the response. Otherwise behave exactly as today (no session).

This keeps the userId comparison server-side: no UUID exposure, and a different logged-in member opening someone else's link never gets a customer session (the session customer would mismatch the PaymentIntent's customer). Factor the customer-session creation out of customer-session/route.ts into a shared helper to avoid divergence.

This is the waitlist analogue of the lockout wrong-account guard (reservation/page.tsx blocks when params.userId !== session.user.id via CheckoutErrorClient; see checkout.md §Checkout Sessions). Waitlist differs: it must serve unauthenticated walk-ins, so a mismatch (or no session) doesn't block — it just means no customer session and no saved methods. Payment still funds the correct entry because the pre-issued PaymentIntent is already on the intended customer.

Invitation mode in the shared flow

The invite payer confirms a pre-issued PaymentIntent — no initialize round-trip, no new entry. Add an invitation mode to the shared flow rather than forking:

  • CheckoutExperience: when the config is in invitation mode, source amount/resource/location/clientSecret/customerSessionClientSecret from the public invitation payload (no getAuthHeaders), and build <Elements> in clientSecret mode (pre-issued PaymentIntent) instead of deferred mode/amount, attaching customerSessionClientSecret when present.
  • useCheckoutFlow: add a path that skips the initialize POST and confirms the pre-issued clientSecret directly. Reuse the existing confirmIntent / finalizeSuccess / handlePendingVerification / tracking logic unchanged. Call onPaymentProcessing (ACH optimistic fulfill-waitlist) only when authedfulfill-waitlist is user-auth and getAuthHeaders() throws unauthenticated; the walk-in keeps relying on the webhook (no regression).
  • selectedPaymentMethod default already keys off hasCustomerSession (useCheckoutFlow.tsx:84) — member case lands on card (saved methods), walk-in on us_bank_account/card tabs, both via the shared CheckoutLayout.
  • Preserve the invite path's current contract: CheckoutInvitation.usedAt stays unset, the URL stays retryable after a failed ACH attempt, and Payment.status === 'PAID' remains the "already paid" source of truth (waitlist.md §quick-signup).

Result: member sees saved methods + wallets in the full shared layout; walk-in sees the same layout with card/ACH entry and no saved methods. Both share the UX; saved methods light up only when they legitimately can.

Changes

API

  • [x] Factored StripeService.createPaymentElementCustomerSession(customerId) out of customer-session/route.ts; both routes use it.
  • [x] waitlist-invitation/[id]/route.ts: optional-bearer → mints customer session when caller matches params.userId; returns top-level customerSessionClientSecret (customer display left null — CheckoutLayout tolerates it). Stays no-auth otherwise. New tests added (5/5 pass).

Web

  • [x] Added invitation mode to CheckoutFlowConfig / CheckoutExperience (pre-issued clientSecret Elements, invitation-sourced data, optional customerSessionClientSecret), gated so the existing path is unchanged.
  • [x] Added the skip-initialize / confirm-pre-issued-intent path to useCheckoutFlow (handleInvitationSubmit + confirmIntent(preIssued)).
  • [x] Built waitlistInviteConfig and render /waitlist/invite/[id] through CheckoutExperience; cancelled / already-paid / not-found gates moved server-side into page.tsx.
  • [x] Deleted WaitlistInviteClient.tsx.

Cleanup (vestigial authed flow — confirmed dead)

  • [x] Deleted /checkout/waitlist page + CheckoutClient (waitlistCheckoutConfig).
  • [x] Deleted preview-waitlist + initialize-waitlist routes + their tests (one-active-entry coverage already exists in the quick-signup test).
  • [x] Removed the ?s= arm in resend-email, the type: 'waitlist' create-enum + checkoutPath branch in session/route.ts, and the dead waitlist branch in session/[id]/send-invitation (+ its tests).
  • [x] Fixed WaitlistInvitationEmail default checkoutUrl; deleted waitlistConfig.test.ts. WaitlistForm.test.tsx left as-is — its ?s= strings are mock fixtures, not real behavior.
  • [x] Deleted fulfill-waitlist — deviation from the original "keep + wire" intent. Invitation mode confirms the pre-issued PI and lets the webhook fulfill (matching the bespoke client's behavior exactly), so onPaymentProcessing was not wired; that left fulfill-waitlist with zero callers, so it was removed. Aaron approved. For waitlist the ACH-optimistic path granted nothing extra (you're just PENDING on the list).
  • [x] Update docs/engineering/domains/waitlist.md + checkout.md: drop the "self-service authenticated deposit" description, reflect invite-only + the unified invitation-mode checkout.

Testing

Automated (done): API endpoint unit tests for the customer-session guard (authed-match → session; mismatch/walk-in → none), quick-signup 409 constraint still covered, all touched checkout-session suites green, both typechecks + biome clean.

Manual / staging (NOT yet executed — the scenarios below need a real Stripe test env):

  • [ ] Member with saved cards opens an invite while logged in → saved methods + wallets shown; confirm pays the pre-issued intent on their customer.
  • [ ] Member with a lockout at A joins waitlist at B → deposit paid, entry PENDING, no double customer.
  • [ ] Unauthenticated walk-in → shared layout, card + ACH, no saved methods; confirm succeeds.
  • [ ] Different member logged in opens someone else's invite → no customer session surfaced (no wrong-customer cards), falls back to unauthed entry.
  • [ ] ACH micro-deposit / processing path still routes through handlePendingVerification.
  • [ ] Cancelled / already-paid invitation states render correctly.

Open questions

  • ACH via Financial Connections against a pre-issued PaymentIntent already works in the bespoke client (waitlist.md §quick-signup), so the mechanism is proven — just verify it behaves identically once moved inside the shared Elements/useCheckoutFlow.