121 lines
8.6 KiB
Markdown
121 lines
8.6 KiB
Markdown
# 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:
|
||
|
||
- 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.
|
||
```
|