add Stripe
This commit is contained in:
90
docs/progress/2026-05-02-booking-lifecycle.md
Normal file
90
docs/progress/2026-05-02-booking-lifecycle.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# 2026-05-02 — Booking Lifecycle (Phase C)
|
||||
|
||||
> Companion to `BuildLog.md`. Predecessor: `2026-05-02-therapist-self-serve.md`. Closes Phase C of the post-payments UX plan.
|
||||
|
||||
## Milestone
|
||||
|
||||
The booking lifecycle has all the verbs the practice needs: create, view, **reschedule, mark complete, mark no-show**, cancel — for both customer and admin. Reschedule is atomic (cancel-old + create-new in one transaction; rollback on conflict so the customer never loses their old slot to a phantom new booking). Single "rescheduled" email replaces the cancel + new-confirmation pair. Booking detail pages exist for both customer and admin.
|
||||
|
||||
## What landed
|
||||
|
||||
| Path | Role |
|
||||
|---|---|
|
||||
| `src/lib/booking.ts` | +`rescheduleBooking()` (atomic, transactional, surfaces `BookingConflictError` if new slot collides — old booking stays intact). +`markComplete()` and +`markNoShow()` (CONFIRMED→target only, idempotent). |
|
||||
| `src/lib/email.ts` | +`sendBookingRescheduled()` template (one email summarizing old → new). |
|
||||
| `src/app/admin/bookings/[id]/page.tsx` | Detail page — all booking fields incl. cancel-audit, payment-status placeholder, room-released-at, action buttons (Mark complete / Mark no-show / Cancel). Mark-* buttons only appear on past CONFIRMED bookings. |
|
||||
| `src/app/account/bookings/[id]/page.tsx` | Customer detail page — service / when / therapist / room / price + Reschedule + Cancel. Owner-or-admin authorization on the action. |
|
||||
| `src/app/book/page.tsx` | Accepts `fromBookingId` param — shows "Rescheduling your X — pick a new time" banner; threads param through to slot links and the date picker form. |
|
||||
| `src/app/book/confirm/page.tsx` | Detects `fromBookingId` and routes through `rescheduleBooking` + `sendBookingRescheduled` instead of `createHold` + `sendBookingConfirmation`. Page heading and copy adapt. Conflict redirects back to `/book` with `taken=1` and the `fromBookingId` preserved. |
|
||||
| `src/app/account/bookings/page.tsx` | Per-row `Reschedule` link → `/book?serviceId=…&fromBookingId=…`. Booking row is now itself a link to the detail page. |
|
||||
| `src/app/admin/bookings/page.tsx` | Booking row links to detail page. Therapist-name fallback for null. |
|
||||
| `test/booking.test.ts` | +8 tests: reschedule happy path, atomic rollback on conflict, rejects rescheduling CANCELLED, can change therapist/room on reschedule, markComplete CONFIRMED→COMPLETED, markComplete idempotent, markComplete rejects HOLD, markNoShow CONFIRMED→NO_SHOW. |
|
||||
|
||||
## What's verified
|
||||
|
||||
- `pnpm test` — **79/79 green** (was 71; +8 new in this session)
|
||||
- `pnpm lint` — clean
|
||||
- `pnpm exec tsc --noEmit` — clean
|
||||
- Live smoke test (curl + Mailpit, dev server):
|
||||
- **Customer (alex@example.com)**: signed in via magic link → `/account/bookings` shows Reschedule + Cancel buttons → click Reschedule → `/book?fromBookingId=…` shows "Rescheduling your..." banner and slot links carry `fromBookingId` → click new slot → `/book/confirm` shows "Confirm reschedule" heading, "previous booking will be cancelled" copy, hidden `fromBookingId` field
|
||||
- **Admin (admin@touchbase.local)**: `/admin/bookings/{id}` renders full detail with customer name, therapist link, room link, all fields, Cancel button (Mark complete / no-show buttons hidden because the booking is in the future — they appear only after the booking end time)
|
||||
|
||||
## Decisions ratified
|
||||
|
||||
| Decision | Resolution |
|
||||
|---|---|
|
||||
| Reschedule semantics | **Atomic** — cancel-old + create-new in one `db.$transaction`. If the new insert fails (conflict, FK, etc.), the entire transaction rolls back; old booking stays intact. Reason: customer should never lose their slot to a phantom new booking. |
|
||||
| Reschedule email | **One** rescheduled email, not cancel + new-confirmation pair. Reason: cleaner customer experience; one notification with old-vs-new framing. |
|
||||
| Mark complete / no-show timing | Buttons only render when `status === "CONFIRMED" && booking.endsAt < now`. Reason: marking a future booking complete is meaningless and almost always a misclick. |
|
||||
| Mark complete / no-show idempotency | Re-marking returns `{ ok: true }` (no-op). Switching from one final state to another is rejected. Reason: prevents accidental status flips after the fact. |
|
||||
| Cancel reason on reschedule | `"rescheduled"` literal in `Booking.cancelReason`. Reason: distinguishable from customer/admin cancellation in audit; could drive metrics later. |
|
||||
| Authorization on customer detail page | Owner or admin can view; otherwise redirect to `/account/bookings`. Same on cancel action. |
|
||||
| Authorization on admin detail page | Layout already requires `role=ADMIN`; actions re-check on the safe side. |
|
||||
| `/account/bookings/[id]` reachable by admins | Yes — they may want to debug from a customer's POV. The cancel button still works. |
|
||||
| Reschedule preserves service by default | `rescheduleBooking` defaults newServiceId/therapist/room to the old booking's. The customer flow always passes the same serviceId; admin could pass different ones if/when we build admin-side reschedule. |
|
||||
| Mark-* helpers return discriminated unions | `{ ok: true }` or `{ ok: false; reason: string }`. Reason: same shape as `cancelBooking` so the UI can react without exceptions. |
|
||||
|
||||
## Gotchas hit
|
||||
|
||||
None — clean session.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. Customer-visible brand name (still pending)
|
||||
2. Currency
|
||||
3. Stripe account ownership
|
||||
4. **NEW**: should reschedule require explicit confirmation if the old booking was CONFIRMED with a deposit? Not relevant until 5d Stripe.
|
||||
5. **NEW**: admin "reschedule on customer's behalf" UI flow — currently admins cancel and the customer rebooks. We have the `rescheduleBooking` helper; the admin UI for it is a future enhancement.
|
||||
|
||||
## Roadmap status
|
||||
|
||||
UX-completeness:
|
||||
|
||||
- A — Admin CRUD: done 2026-05-02
|
||||
- B — Therapist self-serve: done 2026-05-02
|
||||
- **C — Booking lifecycle UX: done 2026-05-02 (this session)**
|
||||
- D — PWA shell (per `BuildLog.md` §11)
|
||||
- E — Polish — 404, empty states, mobile review, dark-mode glance
|
||||
|
||||
## Recommended next step
|
||||
|
||||
**Phase D — PWA shell**. Per `BuildLog.md` §11, this is the "Stage 1" mobile story we promised at bootstrap:
|
||||
|
||||
- `manifest.webmanifest` with name, icons, `display: "standalone"`, theme color, start URL
|
||||
- A minimal service worker (`next-pwa` or hand-rolled with Workbox) — **network-first for HTML, stale-while-revalidate for static assets, no caching of API/booking endpoints**
|
||||
- iOS Safari quirks: separate `apple-touch-icon`, `apple-mobile-web-app-capable`, status bar style
|
||||
- Test on a phone or in DevTools "Add to Home Screen"
|
||||
|
||||
~half-day to a full day. After D, Phase E (~half-day): 404 page, empty-state polish, mobile-first audit, dark-mode glance.
|
||||
|
||||
## How to resume
|
||||
|
||||
```bash
|
||||
cd /Users/noise/Documents/code/touchbase
|
||||
docker-compose up -d postgres mailpit
|
||||
pnpm db:seed
|
||||
pnpm tsx scripts/book-on-behalf.ts alex@example.com "60-minute Swedish" 2026-05-05T10:00
|
||||
pnpm dev
|
||||
# As alex: sign in → /account/bookings → click row to see detail → Reschedule → pick new time → confirm
|
||||
# As admin: sign in → /admin/bookings → click row → view detail with action buttons
|
||||
```
|
||||
122
docs/progress/2026-05-02-pwa-and-polish.md
Normal file
122
docs/progress/2026-05-02-pwa-and-polish.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# 2026-05-02 — PWA Shell + Polish (Phases D + E)
|
||||
|
||||
> Companion to `BuildLog.md`. Predecessor: `2026-05-02-booking-lifecycle.md`. Closes Phases D and E of the post-payments UX plan — UX-completeness done.
|
||||
|
||||
## Milestone
|
||||
|
||||
Two small phases bundled because both came in well under estimate. The app is now installable as a PWA (Add to Home Screen on iOS / Android, manifest + service worker + icons), has friendly 404 / error pages instead of Next's default boxes, and the slot grid + admin tables stop crushing on narrow viewports.
|
||||
|
||||
**This closes the UX-completeness pivot.** Backend is feature-complete for v1 minus payments + reminders. Next workstream is **5d Stripe**.
|
||||
|
||||
## What landed
|
||||
|
||||
### Phase D — PWA shell
|
||||
| Path | Role |
|
||||
|---|---|
|
||||
| `public/icon.svg` | Simple TB monogram on dark rounded square — works as favicon and as PWA icon at any size |
|
||||
| `public/icon-mask.svg` | Maskable variant — full-bleed background, content in safe area for Android adaptive icons |
|
||||
| `src/app/manifest.ts` | Next.js typed Metadata Manifest. name + short_name + display=standalone + theme color + 2 icons (any + maskable) |
|
||||
| `public/sw.js` | Hand-rolled service worker. Network-first for HTML, stale-while-revalidate for static assets, **never** caches API/auth/admin/account/therapist/book paths. Versioned cache name (`tb-html-v1`, `tb-static-v1`). |
|
||||
| `src/app/layout.tsx` | Added `manifest`, `appleWebApp`, `icons` to metadata. Added `viewport.themeColor` (light + dark + viewport-fit=cover for iPhone notch). Inline SW registration script gated to `NODE_ENV === "production"` (avoids stale-chunk weirdness in dev). |
|
||||
|
||||
### Phase E — Polish
|
||||
| Path | Role |
|
||||
|---|---|
|
||||
| `src/app/not-found.tsx` | Friendly 404 — required because `notFound()` is called in several admin/account pages |
|
||||
| `src/app/error.tsx` | Global error boundary client component with "Try again" + "Go home" buttons. Error digest displayed for debugging. |
|
||||
| Slot grids in `/book` and `/admin/bookings/new` | Default to `grid-cols-3` instead of crushing to 1 col on mobile. Promotes to 4/6 cols at sm/md. |
|
||||
| Admin tables (5 files) | `overflow-hidden` → `overflow-x-auto` so they scroll on narrow viewports instead of clipping |
|
||||
|
||||
## What's verified
|
||||
|
||||
- `pnpm test` — **79/79 green**
|
||||
- `pnpm lint` — clean
|
||||
- `pnpm exec tsc --noEmit` — clean
|
||||
- Live PWA smoke (curl on dev server):
|
||||
- `GET /sw.js` → 200, `application/javascript`, 3.3 KB
|
||||
- `GET /manifest.webmanifest` → 200, `application/manifest+json`, well-formed JSON with TouchBase + display=standalone + theme color + 2 icon refs
|
||||
- `GET /icon.svg` → 200, `image/svg+xml`, 361 B
|
||||
- `GET /icon-mask.svg` → 200, 437 B
|
||||
- HTML head includes: `<meta viewport>` (with `viewport-fit=cover`), 2× `<meta theme-color>` (light + dark), `<link rel="manifest">`, `<meta mobile-web-app-capable>`, `<meta apple-mobile-web-app-title>`, `<meta apple-mobile-web-app-status-bar-style>`, `<link rel="apple-touch-icon">`
|
||||
- 404 smoke: `GET /no-such-page` → renders the new not-found page with "404" heading + "We couldn't find that page" + "Back to home" link
|
||||
|
||||
## Decisions ratified
|
||||
|
||||
| Decision | Resolution |
|
||||
|---|---|
|
||||
| PWA library | **None** — hand-rolled `public/sw.js` (~80 LOC). Reason: zero dependency, full visibility into caching behavior, easy to reason about. `next-pwa` / `@serwist/next` are reasonable upgrades when we have specific needs (offline queueing, push notifications, etc.). |
|
||||
| Icon format | **SVG** (single file, scaled by browser). Reason: ships in 360 bytes, looks crisp at any size, no asset pipeline. iOS Safari supports SVG `apple-touch-icon` from iOS 17+; on older iOS the icon falls back to the bookmark default — acceptable for v1. We can add a 180×180 PNG later if needed. |
|
||||
| SW caching policy | Network-first for HTML; stale-while-revalidate for static; **never** cache `/api/`, `/admin/`, `/account/`, `/therapist/`, `/book*`. Reason: any cached page in those routes could show stale slot availability or stale booking state. Safer to require fresh data. |
|
||||
| Versioned cache name | `tb-html-v1`, `tb-static-v1`. Bump on deploys that should invalidate. Reason: simple manual control vs. fingerprint-based invalidation. |
|
||||
| SW registration timing | `window.addEventListener('load', ...)` — runs after the page is interactive so it doesn't compete with first paint. Production-only via inline script gated on `NODE_ENV`. |
|
||||
| `mobile-web-app-capable` instead of `apple-mobile-web-app-capable` | Next.js's `appleWebApp.capable: true` emits the modern unprefixed form, which is the spec-current name. Both Apple and Android browsers respect it. |
|
||||
| `dynamic = "force-dynamic"` everywhere | Already the default in our pages. SW reinforces this — stale HTML is OK on cold reload but never on subsequent navigation. |
|
||||
| Error page is a Client Component | Required by Next.js's error boundary spec. Server logs already capture the underlying error; client-side `console.error` is a backup for browser inspection. |
|
||||
| 404 + error page styling | Match the rest of the app — minimal, Tailwind utility classes, matches dark mode automatically. |
|
||||
| Mobile slot grid breakpoint | `grid-cols-3 sm:grid-cols-4 md:grid-cols-6`. 3-across on phone is comfortable thumb territory; 4 on tablet; 6 on desktop. |
|
||||
| Admin table overflow | `overflow-x-auto` rather than `overflow-hidden`. Tradeoff: rounded corners look slightly worse when content overflows, but the alternative (clip) is functionally broken on phone. |
|
||||
|
||||
## Gotchas hit
|
||||
|
||||
### `apple-mobile-web-app-capable` missing
|
||||
Initial smoke didn't find this meta tag. Turned out Next.js (modern versions) emits the unprefixed `mobile-web-app-capable` instead — which is the current spec form. Both work. No fix needed.
|
||||
|
||||
### Lint flagged `<a>` instead of `<Link>` in error page
|
||||
Started with `<a href="/">` to avoid importing `<Link>` for one element. ESLint rule (`@next/next/no-html-link-for-pages`) caught it. Switched to `<Link>`.
|
||||
|
||||
### `'event' is defined but never used` in SW
|
||||
Pure JS file linted alongside the rest. The unused param in the install handler triggered the unused-vars rule. Removed the param.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. Customer-visible brand name (still pending)
|
||||
2. Currency
|
||||
3. Stripe account ownership — **needed for next phase**
|
||||
4. **NEW**: should the SW also cache the home page for an "offline" experience? Currently network-first means it works offline ONLY if previously visited. Acceptable for v1.
|
||||
5. **NEW**: PNG fallback for `apple-touch-icon` for iOS <17? Defer until someone reports the bookmark icon is missing.
|
||||
|
||||
## Roadmap status
|
||||
|
||||
UX-completeness:
|
||||
|
||||
- A — Admin CRUD: done 2026-05-02
|
||||
- B — Therapist self-serve: done 2026-05-02
|
||||
- C — Booking lifecycle: done 2026-05-02
|
||||
- **D — PWA shell: done 2026-05-02 (this session, part 1)**
|
||||
- **E — Polish: done 2026-05-02 (this session, part 2)**
|
||||
|
||||
**UX is done.** Backend roadmap continues:
|
||||
|
||||
- 5d — Stripe deposit flow + webhook
|
||||
- 5e — Email reminders (pg-boss scheduled jobs)
|
||||
|
||||
## Recommended next step
|
||||
|
||||
**5d — Stripe deposit flow.** The biggest remaining backend chunk. Sequence per `BuildLog.md` notes:
|
||||
|
||||
1. Stripe SDK + env config (test keys — user provides)
|
||||
2. Render Stripe Elements on `/book/confirm` (this is the **first place we need a real Client Component for interactive UI** — past Client Components have been minimal)
|
||||
3. `createPaymentIntent` for the deposit at `createHold` time; pass `client_secret` to the page
|
||||
4. Webhook handler at `/api/stripe/webhook` (verifies signature, updates `Payment` row, transitions Booking from HOLD to CONFIRMED on `payment_intent.succeeded`)
|
||||
5. Hold expiry job via pg-boss cancels HOLDs whose deposit didn't capture in time
|
||||
|
||||
~3–5 days. After 5d, **5e reminders** is small — pg-boss schedule + reminder template + send job (~2–3 days).
|
||||
|
||||
Before starting 5d we'll need:
|
||||
- Stripe test secret key + publishable key
|
||||
- Webhook signing secret (from Stripe CLI for local dev)
|
||||
|
||||
## How to resume
|
||||
|
||||
```bash
|
||||
cd /Users/noise/Documents/code/touchbase
|
||||
docker-compose up -d postgres mailpit
|
||||
pnpm db:seed
|
||||
pnpm tsx scripts/book-on-behalf.ts alex@example.com "60-minute Swedish" 2026-05-05T10:00
|
||||
pnpm dev
|
||||
# Try the customer flow on a phone (or DevTools mobile preview):
|
||||
# http://<your-IP>:3000 → tap a service → date → pick time → sign in → confirm
|
||||
# Add to Home Screen — PWA icon + standalone display
|
||||
# Try the admin/therapist flows on a phone — tables now scroll horizontally
|
||||
# Hit a bad URL like /xyzzy → see the 404 page
|
||||
```
|
||||
120
docs/progress/2026-05-02-stripe-scaffold.md
Normal file
120
docs/progress/2026-05-02-stripe-scaffold.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# 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.
|
||||
```
|
||||
Reference in New Issue
Block a user