Files
touchbase/docs/progress/2026-05-02-stripe-scaffold.md
2026-05-02 14:05:30 -04:00

8.6 KiB
Raw Blame History

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 test86/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 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:

  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/apikeyssk_test_… and pk_test_…. Drop both in .env.
  2. Run the Stripe CLI for webhook forwarding:
    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:
    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:

  • 14 done
  • 5a + 5b + 5c.1 + 5c.2 done
  • UX phases AE done
  • 5d Stripe — scaffolded 2026-05-02 (this session). Awaits keys + live verification.
  • 5e Email reminders (pg-boss scheduled jobs) — next

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
  • ~23 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.