# 2026-05-02 — Stripe Deposit Flow (5d, scaffolded) > Companion to `BuildLog.md`. Predecessor: `2026-05-02-pwa-and-polish.md`. Closes the no-keys-required portion of Phase 5d. ## Milestone Full Stripe deposit-payment flow is scaffolded end-to-end. Code is wired and typed and the DB-side payment helpers are unit-tested. **Live testing requires real Stripe test keys** — see "How to finish" below. Without keys, the codebase still compiles and ships; the deposit branch in `/book/confirm` only activates when `stripeConfigured()` returns true, so non-deposit services and the existing flow continue to work unchanged. ## What landed | Path | Role | |---|---| | `src/lib/stripe.ts` | Lazy server-side `Stripe` client + `stripeConfigured()` capability check. | | `src/lib/payments.ts` | `createDepositIntentForBooking` (creates PI, persists id), `recordPaymentSucceeded` (HOLD→CONFIRMED + Payment row, idempotent on replay), `recordPaymentFailed` (HOLD→CANCELLED with reason), `confirmAfterPayment` (sends booking confirmation). | | `src/app/api/stripe/webhook/route.ts` | POST endpoint at `/api/stripe/webhook`. Verifies signature against `STRIPE_WEBHOOK_SECRET`. Handles `payment_intent.succeeded` and `payment_intent.payment_failed`; acks unknown events so Stripe stops retrying. | | `src/components/PaymentForm.tsx` | **First real client component** — Stripe Elements (PaymentElement) wrapped in `` provider. Cached `loadStripe` promise. Submit → `stripe.confirmPayment` → Stripe redirects to return URL. | | `src/app/book/pay/[id]/page.tsx` | Server-renders the booking summary + Payment Element. Owner-only auth. Bounces to `/book/done` if booking is already CONFIRMED, or to `/account/bookings` if it's terminal. Shows a friendly "payments not configured" state when `STRIPE_*` env vars aren't set. | | `src/app/book/confirm/page.tsx` | Confirm action now branches: if `service.depositCents > 0 && stripeConfigured()`, creates the PI and redirects to `/book/pay/[id]`. Otherwise current behavior (immediate CONFIRMED + email). | | `src/app/book/done/page.tsx` | Adapts copy when booking is still HOLD (waiting for webhook): "Processing your payment". Also handles the CANCELLED case explicitly. | | `test/payments.test.ts` | 7 tests for payment helpers — succeeded transitions HOLD→CONFIRMED + writes Payment row, idempotent on replay, doesn't resurrect CANCELLED, fails-on-unknown-PI; failed cancels HOLDs with reason, doesn't change CONFIRMED status, fails-on-unknown-PI. | | `.env`, `.env.example` | Stripe placeholders: `STRIPE_SECRET_KEY`, `STRIPE_PUBLISHABLE_KEY`, `STRIPE_WEBHOOK_SECRET`. | ## What's verified - `pnpm test` — **86/86 green** (was 79; +7 new payment tests) - `pnpm lint` — clean - `pnpm exec tsc --noEmit` — clean - All deposit-flow code paths are typed against the real Stripe SDK (`stripe@22.1.0`, `@stripe/react-stripe-js@6.3.0`, `@stripe/stripe-js@9.4.0`). **Not verified (needs real keys)**: Stripe Elements rendering, `confirmPayment` round-trip, real webhook signature, end-to-end booking-with-deposit happy path, end-to-end payment failure. ## Decisions ratified | Decision | Resolution | |---|---| | Booking lifecycle with deposit | HOLD → (Stripe Payment Element via `/book/pay/[id]`) → webhook fires `payment_intent.succeeded` → CONFIRMED + email. Asynchronous webhook is the source of truth. | | Hold expiry safety | Existing `expireStaleHolds` job continues to run; if the customer abandons the payment page, the HOLD expires after 10 min and the slot frees up. | | Source of truth for payment status | The webhook. The page redirect after `confirmPayment` is just UX — `/book/done` displays "Processing" if the booking is still HOLD when they land. | | `client_secret` storage | NOT stored in our DB. Retrieved from Stripe via `paymentIntents.retrieve` when re-rendering `/book/pay`. Reason: client_secret is sensitive and Stripe recommends not persisting it. | | Payment row uniqueness | `Payment.stripePaymentIntentId @unique` already in schema. Webhook handler `upsert`s on this — ensures idempotency on duplicate webhook deliveries. | | Event acknowledgment | Always 200 for unknown events (Stripe will stop retrying). 400 for missing/invalid signature. 500 only for handler exceptions (so Stripe retries on transient errors). | | Capability detection | `stripeConfigured()` checks all three env vars. Used by `/book/confirm` to decide whether to route to payment, and by `/book/pay` to render a friendly "not configured" state. Lets the app run without Stripe for non-deposit services. | | Fallback when payments aren't configured | Services with deposit > 0 that get booked through a non-Stripe path proceed straight to CONFIRMED without payment. Reason: the deposit is an expectation between the practice and the customer; in dev or while Stripe is being set up, we don't want bookings to fail. | | Failure semantics | `payment_intent.payment_failed` cancels the HOLD with `cancelReason="payment failed"`. The customer can re-try by booking again from scratch. We don't currently support a "try a different card" flow on the same booking. | | Money math precision | All amounts in integer cents end-to-end. No `Number` rounding anywhere. | | First Client Component | `src/components/PaymentForm.tsx`. Necessary because Stripe.js is browser-only. We previously kept all UI server-rendered; this is the first deliberate exception. | ## Gotchas hit None this session — clean scaffold. ## Open items / known gaps (without real keys) These are deliberately deferred until you provide keys: 1. **Visual confirmation** that PaymentElement renders correctly with our theme. 2. **Real `payment_intent.succeeded` handling** end-to-end (Stripe CLI → webhook → DB → email). Code is correct; just needs to run. 3. **Signed webhook** verification — handler logic is correct but the signature constant-time comparison only fires with a real secret. 4. **Edge cases** like 3DS challenge flows, declined cards, post-redirect race when the user lands on /book/done before the webhook fires. ## How to finish (when you provide keys) 1. **Get Stripe test keys**: https://dashboard.stripe.com/test/apikeys → `sk_test_…` and `pk_test_…`. Drop both in `.env`. 2. **Run the Stripe CLI** for webhook forwarding: ```bash brew install stripe/stripe-cli/stripe # if needed stripe login # one-time stripe listen --forward-to localhost:3000/api/stripe/webhook ``` The CLI prints a `whsec_…` — paste into `.env` as `STRIPE_WEBHOOK_SECRET`. 3. **Restart `pnpm dev`** so the new env vars are picked up. 4. **Test the deposit flow** as a customer: ```bash pnpm db:seed # /60-minute Swedish has $20 deposit per the seed pnpm tsx scripts/book-on-behalf.ts alex@example.com "60-minute Swedish" 2026-05-05T10:00 # OR via the public flow: # /book → click 60-min Swedish → pick a date → pick a slot → /book/confirm → /book/pay/[id] ``` Use Stripe test card `4242 4242 4242 4242`, any future expiry, any CVC, any zip. 5. **Watch the Stripe CLI** output to see the webhook fire. Watch Mailpit (`http://localhost:8025`) for the confirmation email. 6. **Verify in DB**: `SELECT id, status, "paymentStatus", "stripePaymentIntentId" FROM "Booking" ORDER BY "createdAt" DESC LIMIT 1;` — should show `CONFIRMED` + `CAPTURED` + a `pi_…` id. 7. **Test failure**: use card `4000 0000 0000 9995` (declined) — booking should land in CANCELLED with `cancelReason="payment failed"`. ## Roadmap status Backend roadmap: - 1–4 done - 5a + 5b + 5c.1 + 5c.2 done - UX phases A–E done - **5d Stripe — scaffolded 2026-05-02 (this session). Awaits keys + live verification.** - 5e Email reminders (pg-boss scheduled jobs) — next ## Recommended next step **5e — email reminders** can be done in parallel with you setting up Stripe keys, since they're orthogonal: - Add pg-boss (we already chose it; just `pnpm add pg-boss`) - Migration: pg-boss creates its own schema in our Postgres - Worker process that runs reminder jobs on a schedule - `sendBookingReminder` email template (24h-before) - Trigger: when a booking is CONFIRMED, schedule its reminder; on cancel/reschedule, deschedule - ~2–3 days After 5e, **v1 is feature-complete for soft launch** — modulo whatever real-Stripe-test-mode reveals in 5d verification. ## How to resume ```bash cd /Users/noise/Documents/code/touchbase docker-compose up -d postgres mailpit pnpm db:seed pnpm dev # Without Stripe keys: deposit-required services (60-min Swedish, etc.) currently # proceed to confirmed-without-payment because stripeConfigured() returns false. # This means existing flows still work for development. # # With Stripe keys configured: see "How to finish" above. ```