Files
touchbase/docs/progress/2026-05-02-stripe-scaffold.md

121 lines
8.6 KiB
Markdown
Raw Permalink Normal View History

2026-05-02 14:05:30 -04:00
# 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` — 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:
- 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
## 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
- ~23 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.
```