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:
- New walk-in (on-site tour, stub user, pays on their phone, unauthenticated). No saved methods possible — but should still see the shared layout.
- 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) callsPOST /staff/waitlist/quick-signup, which returns/waitlist/invite/<id>. Nothing produces an authed waitlist link. - The backend already handles both payers correctly.
quick-signup→resolveCustomerForStaffreuses the real member whenbody.userIdis set (only stubs for new invites), thenWaitlistCheckoutService.initialize→ensureStripeCustomermints 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/waitlistflow is dead code — pre-#637 leftovers. Confirmed: no web navigation reaches/checkout/waitlist(every "Join Waitlist" CTA →/contact?reason=waitlist-request);quick-signupalways writesparams.waitlistEntryId, soresend-emailalways takes the/waitlist/invite/branch and the?s=arm is structurally unreachable; nothing POSTs/checkout/sessionwithtype: 'waitlist'.initialize-waitlist/preview-waitlistare used only by the dead page's config. - Doc/code divergence to fix.
docs/engineering/domains/waitlist.mdandcheckout.md(both "updated 2026-05-03") still describe/checkout/waitlist+initialize-waitlistas 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-waitlistis live, not dead — it's the ACH optimistic-fulfillment endpoint (userauth). 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'dcustomer-sessionandpreviewfetches 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 PaymentIntentclientSecret(when payment is PENDING). It holdsparams.userIdinternally 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/customerSessionClientSecretfrom the public invitation payload (nogetAuthHeaders), and build<Elements>inclientSecretmode (pre-issued PaymentIntent) instead of deferredmode/amount, attachingcustomerSessionClientSecretwhen present.useCheckoutFlow: add a path that skips the initialize POST and confirms the pre-issuedclientSecretdirectly. Reuse the existingconfirmIntent/finalizeSuccess/handlePendingVerification/ tracking logic unchanged. CallonPaymentProcessing(ACH optimisticfulfill-waitlist) only when authed —fulfill-waitlistisuser-auth andgetAuthHeaders()throws unauthenticated; the walk-in keeps relying on the webhook (no regression).selectedPaymentMethoddefault already keys offhasCustomerSession(useCheckoutFlow.tsx:84) — member case lands oncard(saved methods), walk-in onus_bank_account/card tabs, both via the sharedCheckoutLayout.- Preserve the invite path's current contract:
CheckoutInvitation.usedAtstays unset, the URL stays retryable after a failed ACH attempt, andPayment.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 ofcustomer-session/route.ts; both routes use it. - [x]
waitlist-invitation/[id]/route.ts: optional-bearer → mints customer session when caller matchesparams.userId; returns top-levelcustomerSessionClientSecret(customer display left null —CheckoutLayouttolerates it). Stays no-auth otherwise. New tests added (5/5 pass).
Web
- [x] Added invitation mode to
CheckoutFlowConfig/CheckoutExperience(pre-issuedclientSecretElements, invitation-sourced data, optionalcustomerSessionClientSecret), gated so the existing path is unchanged. - [x] Added the skip-initialize / confirm-pre-issued-intent path to
useCheckoutFlow(handleInvitationSubmit+confirmIntent(preIssued)). - [x] Built
waitlistInviteConfigand render/waitlist/invite/[id]throughCheckoutExperience; cancelled / already-paid / not-found gates moved server-side intopage.tsx. - [x] Deleted
WaitlistInviteClient.tsx.
Cleanup (vestigial authed flow — confirmed dead)
- [x] Deleted
/checkout/waitlistpage +CheckoutClient(waitlistCheckoutConfig). - [x] Deleted
preview-waitlist+initialize-waitlistroutes + their tests (one-active-entry coverage already exists in the quick-signup test). - [x] Removed the
?s=arm inresend-email, thetype: 'waitlist'create-enum + checkoutPath branch insession/route.ts, and the dead waitlist branch insession/[id]/send-invitation(+ its tests). - [x] Fixed
WaitlistInvitationEmaildefaultcheckoutUrl; deletedwaitlistConfig.test.ts.WaitlistForm.test.tsxleft 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), soonPaymentProcessingwas not wired; that leftfulfill-waitlistwith 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.