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

121 lines
8.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
```