8.6 KiB
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 <Elements> 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— cleanpnpm 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 upserts 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:
- Visual confirmation that PaymentElement renders correctly with our theme.
- Real
payment_intent.succeededhandling end-to-end (Stripe CLI → webhook → DB → email). Code is correct; just needs to run. - Signed webhook verification — handler logic is correct but the signature constant-time comparison only fires with a real secret.
- 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)
- Get Stripe test keys: https://dashboard.stripe.com/test/apikeys →
sk_test_…andpk_test_…. Drop both in.env. - Run the Stripe CLI for webhook forwarding:
The CLI prints a
brew install stripe/stripe-cli/stripe # if needed stripe login # one-time stripe listen --forward-to localhost:3000/api/stripe/webhookwhsec_…— paste into.envasSTRIPE_WEBHOOK_SECRET. - Restart
pnpm devso the new env vars are picked up. - Test the deposit flow as a customer:
Use Stripe test card
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]4242 4242 4242 4242, any future expiry, any CVC, any zip. - Watch the Stripe CLI output to see the webhook fire. Watch Mailpit (
http://localhost:8025) for the confirmation email. - Verify in DB:
SELECT id, status, "paymentStatus", "stripePaymentIntentId" FROM "Booking" ORDER BY "createdAt" DESC LIMIT 1;— should showCONFIRMED+CAPTURED+ api_…id. - Test failure: use card
4000 0000 0000 9995(declined) — booking should land in CANCELLED withcancelReason="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
sendBookingReminderemail 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
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.