add Stripe

This commit is contained in:
2026-05-02 14:05:30 -04:00
parent a270d83c1a
commit 815d4e0bdd
33 changed files with 2269 additions and 43 deletions

View 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
```

View 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
~35 days. After 5d, **5e reminders** is small — pg-boss schedule + reminder template + send job (~23 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
```

View 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:
- 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.
```

View File

@@ -25,6 +25,8 @@
"@auth/prisma-adapter": "^2.11.2",
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"@stripe/react-stripe-js": "^6.3.0",
"@stripe/stripe-js": "^9.4.0",
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"next": "16.2.4",
@@ -33,6 +35,7 @@
"pg": "^8.20.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"stripe": "^22.1.0",
"zod": "^4.3.6"
},
"devDependencies": {

42
pnpm-lock.yaml generated
View File

@@ -17,6 +17,12 @@ importers:
'@prisma/client':
specifier: ^7.8.0
version: 7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3)
'@stripe/react-stripe-js':
specifier: ^6.3.0
version: 6.3.0(@stripe/stripe-js@9.4.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@stripe/stripe-js':
specifier: ^9.4.0
version: 9.4.0
date-fns:
specifier: ^4.1.0
version: 4.1.0
@@ -41,6 +47,9 @@ importers:
react-dom:
specifier: 19.2.4
version: 19.2.4(react@19.2.4)
stripe:
specifier: ^22.1.0
version: 22.1.0(@types/node@22.19.17)
zod:
specifier: ^4.3.6
version: 4.3.6
@@ -908,6 +917,17 @@ packages:
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@stripe/react-stripe-js@6.3.0':
resolution: {integrity: sha512-N1FTRNCMKySElDz1lAsf/m6Oy5vcl6LRVXcW29t0Y3U3HYOAqCBlk6nuDsR2x7SAuaXkVCjnpCqrNbA/7l74jg==}
peerDependencies:
'@stripe/stripe-js': '>=9.3.1 <10.0.0'
react: '>=16.8.0 <20.0.0'
react-dom: '>=16.8.0 <20.0.0'
'@stripe/stripe-js@9.4.0':
resolution: {integrity: sha512-zXG86DnoLU5nH2U18tX5ApTE3IAm+txoiPm4YFyEtRS0K5y7ZDH9M8e1Le/cZyI6wvW3BkJn2daZe2FqZOrSSg==}
engines: {node: '>=12.16'}
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
@@ -2677,6 +2697,15 @@ packages:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
stripe@22.1.0:
resolution: {integrity: sha512-w/xHyJGxXWnLPbNHG13sz/fae0MrFGC80Oz7YbICQymbfpqfEcsoG+6yG+9BWb81PWc4rrkeSO4wmTcmefmbLw==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
styled-jsx@5.1.6:
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
engines: {node: '>= 12.0.0'}
@@ -3626,6 +3655,15 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
'@stripe/react-stripe-js@6.3.0(@stripe/stripe-js@9.4.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@stripe/stripe-js': 9.4.0
prop-types: 15.8.1
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@stripe/stripe-js@9.4.0': {}
'@swc/helpers@0.5.15':
dependencies:
tslib: 2.8.1
@@ -5614,6 +5652,10 @@ snapshots:
strip-json-comments@3.1.1: {}
stripe@22.1.0(@types/node@22.19.17):
optionalDependencies:
'@types/node': 22.19.17
styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.4):
dependencies:
client-only: 0.0.1

7
public/icon-mask.svg Normal file
View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="TouchBase">
<!-- Maskable: full bleed background, content in center safe area (~80% inset) -->
<rect width="256" height="256" fill="#18181b"/>
<text x="128" y="128" text-anchor="middle" dominant-baseline="central"
font-family="system-ui, -apple-system, sans-serif" font-weight="600"
font-size="96" fill="#fafafa">TB</text>
</svg>

After

Width:  |  Height:  |  Size: 437 B

6
public/icon.svg Normal file
View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="TouchBase">
<rect width="256" height="256" rx="48" fill="#18181b"/>
<text x="128" y="128" text-anchor="middle" dominant-baseline="central"
font-family="system-ui, -apple-system, sans-serif" font-weight="600"
font-size="120" fill="#fafafa">TB</text>
</svg>

After

Width:  |  Height:  |  Size: 361 B

106
public/sw.js Normal file
View File

@@ -0,0 +1,106 @@
// TouchBase service worker — minimal, hand-rolled.
//
// Strategy:
// - HTML pages (text/html): network-first → cache fallback for offline.
// - Static assets (/_next/static, /icon.svg, fonts): stale-while-revalidate.
// - API + auth + booking server actions: pass-through, NEVER cached.
// Stale appointment data is dangerous; better to fail than to lie.
//
// Cache name is versioned. Bump CACHE_VERSION on any deploy that should
// invalidate the cache for returning users.
const CACHE_VERSION = "v1";
const HTML_CACHE = `tb-html-${CACHE_VERSION}`;
const STATIC_CACHE = `tb-static-${CACHE_VERSION}`;
const ALL_CACHES = [HTML_CACHE, STATIC_CACHE];
self.addEventListener("install", () => {
// Activate the new SW immediately; users get the new caching rules on next nav.
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
const names = await caches.keys();
await Promise.all(
names
.filter((n) => n.startsWith("tb-") && !ALL_CACHES.includes(n))
.map((n) => caches.delete(n)),
);
await self.clients.claim();
})(),
);
});
self.addEventListener("fetch", (event) => {
const req = event.request;
// Only handle GET. Server actions (POST), auth callbacks, etc. always pass through.
if (req.method !== "GET") return;
const url = new URL(req.url);
// Same-origin only — don't cache cross-origin (analytics, fonts CDN, etc.).
if (url.origin !== self.location.origin) return;
// Never cache API, auth, or anything in the booking flow that could go stale.
// Stale slot data could let a customer "see" an already-taken slot.
if (
url.pathname.startsWith("/api/") ||
url.pathname.startsWith("/_next/data/") ||
url.pathname.startsWith("/admin/") ||
url.pathname.startsWith("/account/") ||
url.pathname.startsWith("/therapist/") ||
url.pathname.startsWith("/book")
) {
return; // pass through
}
// Static assets → stale-while-revalidate
if (
url.pathname.startsWith("/_next/static/") ||
url.pathname === "/icon.svg" ||
url.pathname === "/icon-mask.svg" ||
url.pathname === "/favicon.ico" ||
url.pathname === "/manifest.webmanifest"
) {
event.respondWith(staleWhileRevalidate(req, STATIC_CACHE));
return;
}
// HTML navigation → network-first → cache fallback
if (req.mode === "navigate" || req.headers.get("accept")?.includes("text/html")) {
event.respondWith(networkFirst(req, HTML_CACHE));
return;
}
});
async function networkFirst(request, cacheName) {
const cache = await caches.open(cacheName);
try {
const fresh = await fetch(request);
if (fresh && fresh.ok) {
cache.put(request, fresh.clone());
}
return fresh;
} catch (err) {
const cached = await cache.match(request);
if (cached) return cached;
throw err;
}
}
async function staleWhileRevalidate(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
const fetchPromise = fetch(request)
.then((response) => {
if (response && response.ok) {
cache.put(request, response.clone());
}
return response;
})
.catch(() => undefined);
return cached ?? (await fetchPromise) ?? new Response("offline", { status: 503 });
}

View File

@@ -0,0 +1,164 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { cancelBooking } from "@/lib/booking";
import { sendBookingCancellation } from "@/lib/email";
import { parseStringTrim } from "@/lib/forms";
export const metadata = { title: "Booking — TouchBase" };
export const dynamic = "force-dynamic";
const TZ = process.env.APP_TZ ?? "America/Detroit";
type Params = Promise<{ id: string }>;
function formatLocal(d: Date): string {
return new Intl.DateTimeFormat("en-US", {
timeZone: TZ,
weekday: "long",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
timeZoneName: "short",
}).format(d);
}
async function actionCancel(formData: FormData): Promise<void> {
"use server";
const session = await auth();
if (!session?.user) redirect("/login?callbackUrl=/account/bookings");
const id = parseStringTrim(formData.get("bookingId"));
if (!id) return;
const booking = await db.booking.findUnique({
where: { id },
select: { customerId: true },
});
if (!booking) return;
const isOwner = booking.customerId === session!.user.id;
const isAdmin = session!.user.role === "ADMIN";
if (!isOwner && !isAdmin) redirect("/account/bookings");
const result = await cancelBooking(db, {
bookingId: id,
cancelledByUserId: session!.user.id,
reason: isOwner ? "customer cancellation" : "admin cancellation",
});
if (result.kind === "cancelled" && !result.alreadyCancelled) {
await sendBookingCancellation({ db, bookingId: id });
}
redirect("/account/bookings");
}
export default async function CustomerBookingDetailPage({
params,
}: {
params: Params;
}) {
const { id } = await params;
const session = await auth();
if (!session?.user) redirect(`/login?callbackUrl=/account/bookings/${id}`);
const booking = await db.booking.findUnique({
where: { id },
include: {
service: { select: { name: true, durationMin: true, priceCents: true, depositCents: true } },
therapist: { include: { user: { select: { name: true, email: true } } } },
room: { select: { name: true } },
customer: { select: { id: true, email: true } },
},
});
if (!booking) notFound();
// Customers can only see their own; admins can see anyone's
const isOwner = booking.customer.id === session!.user.id;
const isAdmin = session!.user.role === "ADMIN";
if (!isOwner && !isAdmin) redirect("/account/bookings");
const isActive = booking.status === "HOLD" || booking.status === "CONFIRMED";
const isPast = booking.startsAt < new Date();
const canModify = isActive && !isPast;
return (
<div className="mx-auto max-w-2xl">
<Link
href="/account/bookings"
className="mb-4 inline-block text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
>
All my bookings
</Link>
<div className="mb-4 flex items-baseline justify-between">
<h1 className="text-xl font-semibold tracking-tight">
{booking.service.name}
</h1>
<span className={statusPill(booking.status)}>{booking.status}</span>
</div>
<section className="mb-6 rounded-lg border border-zinc-200 bg-white p-5 dark:border-zinc-800 dark:bg-zinc-950">
<dl className="grid gap-3 text-sm">
<Field label="When">{formatLocal(booking.startsAt)}</Field>
<Field label="Duration">{booking.service.durationMin} min</Field>
<Field label="Therapist">
{booking.therapist.user.name ?? booking.therapist.user.email}
</Field>
<Field label="Room">{booking.room.name}</Field>
<Field label="Price">
<span className="font-mono">${(booking.service.priceCents / 100).toFixed(2)}</span>
</Field>
</dl>
</section>
{canModify && (
<section className="rounded-lg border border-zinc-200 bg-white p-5 dark:border-zinc-800 dark:bg-zinc-950">
<div className="flex flex-wrap gap-2">
<Link
href={`/book?serviceId=${booking.serviceId}&fromBookingId=${booking.id}`}
className="rounded-md border border-zinc-300 px-3 py-1.5 text-sm hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-900"
>
Reschedule
</Link>
<form action={actionCancel}>
<input type="hidden" name="bookingId" value={id} />
<button
type="submit"
className="rounded-md border border-red-300 bg-white px-3 py-1.5 text-sm text-red-700 hover:bg-red-50 dark:border-red-800 dark:bg-zinc-900 dark:text-red-300 dark:hover:bg-red-950/40"
>
Cancel
</button>
</form>
</div>
</section>
)}
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<dt className="text-xs uppercase tracking-wide text-zinc-500">{label}</dt>
<dd className="mt-0.5">{children}</dd>
</div>
);
}
function statusPill(status: string): string {
const base = "rounded-full px-2 py-0.5 text-xs";
switch (status) {
case "CONFIRMED":
return `${base} bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-200`;
case "HOLD":
return `${base} bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200`;
case "COMPLETED":
return `${base} bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200`;
case "NO_SHOW":
return `${base} bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200`;
case "CANCELLED":
return `${base} bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400`;
default:
return `${base} bg-zinc-100 text-zinc-600`;
}
}

View File

@@ -1,3 +1,4 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { db } from "@/lib/db";
@@ -108,7 +109,7 @@ export default async function MyBookingsPage() {
className="rounded-md border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-950"
>
<div className="flex items-start justify-between gap-4">
<div>
<Link href={`/account/bookings/${b.id}`} className="flex-1 hover:underline">
<div className="font-medium">{b.service.name}</div>
<div className="text-sm text-zinc-600 dark:text-zinc-400">
{formatLocalLong(b.startsAt)}
@@ -116,7 +117,14 @@ export default async function MyBookingsPage() {
<div className="mt-1 text-xs text-zinc-500">
{b.service.durationMin} min
</div>
</div>
</Link>
<div className="flex flex-col items-end gap-2">
<Link
href={`/book?serviceId=${b.serviceId}&fromBookingId=${b.id}`}
className="rounded-md border border-zinc-300 px-3 py-1.5 text-sm hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-900"
>
Reschedule
</Link>
<form action={cancelAction}>
<input type="hidden" name="bookingId" value={b.id} />
<button
@@ -127,6 +135,7 @@ export default async function MyBookingsPage() {
</button>
</form>
</div>
</div>
</li>
))}
</ul>

View File

@@ -0,0 +1,251 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import {
cancelBooking,
markComplete,
markNoShow,
} from "@/lib/booking";
import { sendBookingCancellation } from "@/lib/email";
import { parseStringTrim } from "@/lib/forms";
export const metadata = { title: "Booking — TouchBase" };
export const dynamic = "force-dynamic";
const TZ = process.env.APP_TZ ?? "America/Detroit";
type Params = Promise<{ id: string }>;
function formatLocal(d: Date): string {
return new Intl.DateTimeFormat("en-US", {
timeZone: TZ,
weekday: "short",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
timeZoneName: "short",
}).format(d);
}
async function actionCancel(formData: FormData): Promise<void> {
"use server";
const session = await auth();
if (!session?.user || session.user.role !== "ADMIN") {
redirect("/login");
}
const id = parseStringTrim(formData.get("bookingId"));
if (!id) return;
const result = await cancelBooking(db, {
bookingId: id,
cancelledByUserId: session!.user.id,
reason: "admin cancellation",
});
if (result.kind === "cancelled" && !result.alreadyCancelled) {
await sendBookingCancellation({ db, bookingId: id });
}
redirect(`/admin/bookings/${id}`);
}
async function actionMarkComplete(formData: FormData): Promise<void> {
"use server";
const session = await auth();
if (!session?.user || session.user.role !== "ADMIN") redirect("/login");
const id = parseStringTrim(formData.get("bookingId"));
if (!id) return;
await markComplete(db, id);
redirect(`/admin/bookings/${id}`);
}
async function actionMarkNoShow(formData: FormData): Promise<void> {
"use server";
const session = await auth();
if (!session?.user || session.user.role !== "ADMIN") redirect("/login");
const id = parseStringTrim(formData.get("bookingId"));
if (!id) return;
await markNoShow(db, id);
redirect(`/admin/bookings/${id}`);
}
export default async function AdminBookingDetailPage({
params,
}: {
params: Params;
}) {
const { id } = await params;
const booking = await db.booking.findUnique({
where: { id },
include: {
customer: { select: { name: true, email: true, phone: true } },
service: { select: { name: true, durationMin: true, bufferAfterMin: true } },
therapist: { include: { user: { select: { name: true, email: true } } } },
room: { select: { name: true, id: true } },
},
});
if (!booking) notFound();
const isActive = booking.status === "HOLD" || booking.status === "CONFIRMED";
const isPast = booking.endsAt < new Date();
const canMarkComplete = booking.status === "CONFIRMED" && isPast;
const canMarkNoShow = booking.status === "CONFIRMED" && isPast;
const canCancel = isActive;
return (
<div className="mx-auto max-w-3xl">
<Link
href="/admin/bookings"
className="mb-4 inline-block text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
>
All bookings
</Link>
<div className="mb-4 flex items-baseline justify-between">
<h1 className="text-xl font-semibold tracking-tight">
{booking.service.name}
</h1>
<span
className={statusPill(booking.status)}
>
{booking.status}
</span>
</div>
<section className="mb-6 rounded-lg border border-zinc-200 bg-white p-5 dark:border-zinc-800 dark:bg-zinc-950">
<dl className="grid gap-3 text-sm sm:grid-cols-2">
<Field label="When">
{formatLocal(booking.startsAt)}
<div className="text-xs text-zinc-500">
ends {formatLocal(booking.endsAt)} · room held until{" "}
{formatLocal(booking.roomReleasedAt)}
</div>
</Field>
<Field label="Customer">
<div>
{booking.customer.name ?? <span className="italic text-zinc-500">unnamed</span>}
</div>
<div className="text-xs text-zinc-500">{booking.customer.email}</div>
{booking.customer.phone && (
<div className="text-xs text-zinc-500">{booking.customer.phone}</div>
)}
</Field>
<Field label="Therapist">
<Link
href={`/admin/therapists/${booking.therapistId}`}
className="hover:underline"
>
{booking.therapist.user.name ?? booking.therapist.user.email}
</Link>
</Field>
<Field label="Room">
<Link
href={`/admin/rooms/${booking.room.id}`}
className="hover:underline"
>
{booking.room.name}
</Link>
</Field>
<Field label="Price">
<span className="font-mono">${(booking.priceCents / 100).toFixed(2)}</span>
</Field>
<Field label="Deposit">
<span className="font-mono">${(booking.depositCents / 100).toFixed(2)}</span>
<div className="text-xs text-zinc-500">
Payment status: {booking.paymentStatus}
</div>
</Field>
{booking.status === "CANCELLED" && (
<>
<Field label="Cancelled at">
{booking.cancelledAt ? formatLocal(booking.cancelledAt) : "—"}
</Field>
<Field label="Cancel reason">
{booking.cancelReason ?? "—"}
</Field>
</>
)}
{booking.notes && (
<div className="sm:col-span-2">
<Field label="Front-desk notes">{booking.notes}</Field>
</div>
)}
</dl>
</section>
{(canMarkComplete || canMarkNoShow || canCancel) && (
<section className="rounded-lg border border-zinc-200 bg-white p-5 dark:border-zinc-800 dark:bg-zinc-950">
<h2 className="mb-3 text-sm font-medium text-zinc-700 dark:text-zinc-300">
Actions
</h2>
<div className="flex flex-wrap gap-2">
{canMarkComplete && (
<form action={actionMarkComplete}>
<input type="hidden" name="bookingId" value={id} />
<button
type="submit"
className="rounded-md bg-green-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-green-700"
>
Mark complete
</button>
</form>
)}
{canMarkNoShow && (
<form action={actionMarkNoShow}>
<input type="hidden" name="bookingId" value={id} />
<button
type="submit"
className="rounded-md bg-amber-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-amber-700"
>
Mark no-show
</button>
</form>
)}
{canCancel && (
<form action={actionCancel}>
<input type="hidden" name="bookingId" value={id} />
<button
type="submit"
className="rounded-md border border-red-300 bg-white px-3 py-1.5 text-sm text-red-700 hover:bg-red-50 dark:border-red-800 dark:bg-zinc-900 dark:text-red-300 dark:hover:bg-red-950/40"
>
Cancel booking
</button>
</form>
)}
</div>
{!canMarkComplete && !canMarkNoShow && booking.status === "CONFIRMED" && (
<p className="mt-2 text-xs text-zinc-500">
Mark complete / no-show available after the booking end time.
</p>
)}
</section>
)}
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<dt className="text-xs uppercase tracking-wide text-zinc-500">{label}</dt>
<dd className="mt-0.5">{children}</dd>
</div>
);
}
function statusPill(status: string): string {
const base = "rounded-full px-2 py-0.5 text-xs";
switch (status) {
case "CONFIRMED":
return `${base} bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-200`;
case "HOLD":
return `${base} bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200`;
case "COMPLETED":
return `${base} bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200`;
case "NO_SHOW":
return `${base} bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200`;
case "CANCELLED":
return `${base} bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400`;
default:
return `${base} bg-zinc-100 text-zinc-600`;
}
}

View File

@@ -273,7 +273,7 @@ export default async function NewBookingPage({
No available slots on this date for the selected service.
</p>
) : (
<div className="grid gap-2 sm:grid-cols-4 md:grid-cols-6">
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6">
{slots.map((slot) => (
<form key={slot.startsAt.toISOString()} action={bookSlotAction}>
<input type="hidden" name="serviceId" value={serviceId} />

View File

@@ -53,7 +53,7 @@ export default async function BookingsPage() {
No upcoming bookings.
</p>
) : (
<div className="overflow-hidden rounded-md border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-950">
<div className="overflow-x-auto rounded-md border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-950">
<table className="w-full text-sm">
<thead className="bg-zinc-50 text-left text-xs uppercase tracking-wide text-zinc-500 dark:bg-zinc-900">
<tr>
@@ -69,7 +69,9 @@ export default async function BookingsPage() {
{bookings.map((b) => (
<tr key={b.id}>
<td className="px-4 py-2 whitespace-nowrap font-mono text-xs">
<Link href={`/admin/bookings/${b.id}`} className="hover:underline">
{formatLocal(b.startsAt)}
</Link>
</td>
<td className="px-4 py-2">
<div>{b.customer.name ?? <span className="text-zinc-500 italic">unnamed</span>}</div>
@@ -81,7 +83,9 @@ export default async function BookingsPage() {
{b.service.durationMin}m
</div>
</td>
<td className="px-4 py-2">{b.therapist.user.name}</td>
<td className="px-4 py-2">
{b.therapist.user.name ?? <span className="text-zinc-500 italic">unnamed</span>}
</td>
<td className="px-4 py-2">{b.room.name}</td>
<td className="px-4 py-2">
<span

View File

@@ -30,7 +30,7 @@ export default async function RoomsPage() {
No rooms yet.
</p>
) : (
<div className="overflow-hidden rounded-md border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-950">
<div className="overflow-x-auto rounded-md border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-950">
<table className="w-full text-sm">
<thead className="bg-zinc-50 text-left text-xs uppercase tracking-wide text-zinc-500 dark:bg-zinc-900">
<tr>

View File

@@ -27,7 +27,7 @@ export default async function ServicesPage() {
No services yet.
</p>
) : (
<div className="overflow-hidden rounded-md border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-950">
<div className="overflow-x-auto rounded-md border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-950">
<table className="w-full text-sm">
<thead className="bg-zinc-50 text-left text-xs uppercase tracking-wide text-zinc-500 dark:bg-zinc-900">
<tr>

View File

@@ -31,7 +31,7 @@ export default async function TherapistsPage() {
No therapists yet.
</p>
) : (
<div className="overflow-hidden rounded-md border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-950">
<div className="overflow-x-auto rounded-md border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-950">
<table className="w-full text-sm">
<thead className="bg-zinc-50 text-left text-xs uppercase tracking-wide text-zinc-500 dark:bg-zinc-900">
<tr>

View File

@@ -0,0 +1,75 @@
// Stripe webhook handler.
//
// Configure your endpoint in the Stripe Dashboard (or via `stripe listen` for
// local dev). The signing secret goes in STRIPE_WEBHOOK_SECRET. We verify
// every request and ignore unsigned/invalid ones.
//
// Idempotency: handlers in src/lib/payments.ts upsert Payment rows on
// stripePaymentIntentId @unique, so duplicate webhook deliveries are safe.
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import type Stripe from "stripe";
import { db } from "@/lib/db";
import { stripe } from "@/lib/stripe";
import {
confirmAfterPayment,
recordPaymentFailed,
recordPaymentSucceeded,
} from "@/lib/payments";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export async function POST(req: NextRequest): Promise<Response> {
const secret = process.env.STRIPE_WEBHOOK_SECRET;
if (!secret) {
return NextResponse.json(
{ error: "STRIPE_WEBHOOK_SECRET is not configured" },
{ status: 500 },
);
}
const sig = req.headers.get("stripe-signature");
if (!sig) return NextResponse.json({ error: "missing signature" }, { status: 400 });
// Stripe needs the raw body to verify the signature.
const rawBody = await req.text();
let event: Stripe.Event;
try {
event = stripe().webhooks.constructEvent(rawBody, sig, secret);
} catch (err) {
const msg = err instanceof Error ? err.message : "invalid signature";
return NextResponse.json({ error: msg }, { status: 400 });
}
try {
switch (event.type) {
case "payment_intent.succeeded": {
const pi = event.data.object as Stripe.PaymentIntent;
const result = await recordPaymentSucceeded(db, pi.id, pi.amount);
if (result.transitioned) {
await confirmAfterPayment(db, result.bookingId);
}
return NextResponse.json({ received: true, bookingId: result.bookingId });
}
case "payment_intent.payment_failed": {
const pi = event.data.object as Stripe.PaymentIntent;
const result = await recordPaymentFailed(db, pi.id);
return NextResponse.json({ received: true, bookingId: result.bookingId });
}
default:
// Acknowledge unknown events so Stripe stops retrying. Logged for debugging.
console.log(`[stripe webhook] unhandled event: ${event.type}`);
return NextResponse.json({ received: true, unhandled: event.type });
}
} catch (err) {
// Returning 500 makes Stripe retry. That's the right behavior for transient errors.
console.error("[stripe webhook] handler threw:", err);
return NextResponse.json(
{ error: err instanceof Error ? err.message : "handler error" },
{ status: 500 },
);
}
}

View File

@@ -5,8 +5,18 @@ import { auth } from "@/auth";
import { db } from "@/lib/db";
import { findSlots } from "@/lib/availability";
import { loadAvailabilityState } from "@/lib/availability-loader";
import { BookingConflictError, confirmHold, createHold } from "@/lib/booking";
import { sendBookingConfirmation } from "@/lib/email";
import {
BookingConflictError,
confirmHold,
createHold,
rescheduleBooking,
} from "@/lib/booking";
import {
sendBookingConfirmation,
sendBookingRescheduled,
} from "@/lib/email";
import { stripeConfigured } from "@/lib/stripe";
import { createDepositIntentForBooking } from "@/lib/payments";
export const metadata = { title: "Confirm booking — TouchBase" };
export const dynamic = "force-dynamic";
@@ -17,6 +27,7 @@ type SearchParams = Promise<{
serviceId?: string;
startsAtIso?: string;
error?: string;
fromBookingId?: string;
}>;
function formatLocalLong(d: Date): string {
@@ -36,10 +47,12 @@ async function confirmBookingAction(formData: FormData): Promise<void> {
const serviceId = String(formData.get("serviceId") ?? "");
const startsAtIso = String(formData.get("startsAtIso") ?? "");
const name = String(formData.get("name") ?? "").trim();
const fromBookingId = String(formData.get("fromBookingId") ?? "");
const session = await auth();
if (!session?.user) {
const next = new URLSearchParams({ serviceId, startsAtIso });
if (fromBookingId) next.set("fromBookingId", fromBookingId);
redirect(`/login?callbackUrl=${encodeURIComponent(`/book/confirm?${next}`)}`);
}
@@ -66,6 +79,7 @@ async function confirmBookingAction(formData: FormData): Promise<void> {
startsAtIso,
error: "Could not compute availability — please try again.",
});
if (fromBookingId) params.set("fromBookingId", fromBookingId);
redirect(`/book/confirm?${params}`);
}
@@ -84,10 +98,34 @@ async function confirmBookingAction(formData: FormData): Promise<void> {
date: startsAtIso.slice(0, 10),
taken: "1",
});
if (fromBookingId) back.set("fromBookingId", fromBookingId);
redirect(`/book?${back}`);
}
try {
if (fromBookingId) {
// Reschedule path: validate ownership, then atomic cancel-and-rebook.
const old = await db.booking.findUnique({
where: { id: fromBookingId },
select: { customerId: true },
});
if (!old || old.customerId !== userId) redirect("/account/bookings");
const result = await rescheduleBooking(db, {
oldBookingId: fromBookingId,
newStartsAt: startsAt,
newServiceId: serviceId,
newTherapistId: slot.candidateTherapistIds[0],
newRoomId: slot.candidateRoomIds[0],
cancelledByUserId: userId,
});
await sendBookingRescheduled({
db,
oldBookingId: result.oldBookingId,
newBookingId: result.newBookingId,
});
redirect(`/book/done?bookingId=${result.newBookingId}`);
} else {
const hold = await createHold(db, {
customerId: userId,
serviceId,
@@ -95,16 +133,28 @@ async function confirmBookingAction(formData: FormData): Promise<void> {
roomId: slot.candidateRoomIds[0],
startsAt,
});
// If the service has a deposit AND Stripe is configured, route to the
// payment page; the booking stays HOLD until the webhook fires. Otherwise
// confirm immediately (current behavior).
if (hold.depositCents > 0 && stripeConfigured()) {
await createDepositIntentForBooking(db, hold.id);
redirect(`/book/pay/${hold.id}`);
}
await confirmHold(db, hold.id);
await sendBookingConfirmation({ db, bookingId: hold.id });
redirect(`/book/done?bookingId=${hold.id}`);
}
} catch (e) {
if (e instanceof BookingConflictError) {
const params = new URLSearchParams({
serviceId,
date: startsAtIso.slice(0, 10),
taken: "1",
});
redirect(`/book?${params}&taken=1`);
if (fromBookingId) params.set("fromBookingId", fromBookingId);
redirect(`/book?${params}`);
}
throw e;
}
@@ -116,12 +166,13 @@ export default async function ConfirmPage({
searchParams: SearchParams;
}) {
const params = await searchParams;
const { serviceId, startsAtIso } = params;
const { serviceId, startsAtIso, fromBookingId } = params;
if (!serviceId || !startsAtIso) redirect("/");
const session = await auth();
if (!session?.user) {
const next = new URLSearchParams({ serviceId, startsAtIso });
if (fromBookingId) next.set("fromBookingId", fromBookingId);
redirect(`/login?callbackUrl=${encodeURIComponent(`/book/confirm?${next}`)}`);
}
@@ -155,10 +206,12 @@ export default async function ConfirmPage({
<main className="mx-auto w-full max-w-md flex-1 p-8">
<h1 className="mb-2 text-2xl font-semibold tracking-tight">
Confirm your booking
{fromBookingId ? "Confirm reschedule" : "Confirm your booking"}
</h1>
<p className="mb-6 text-sm text-zinc-600 dark:text-zinc-400">
Almost done review and confirm.
{fromBookingId
? "Your previous booking will be cancelled and replaced with this one."
: "Almost done — review and confirm."}
</p>
{params.error && (
@@ -201,6 +254,9 @@ export default async function ConfirmPage({
>
<input type="hidden" name="serviceId" value={serviceId} />
<input type="hidden" name="startsAtIso" value={startsAtIso} />
{fromBookingId && (
<input type="hidden" name="fromBookingId" value={fromBookingId} />
)}
{needsName && (
<div className="mb-4">
@@ -229,7 +285,14 @@ export default async function ConfirmPage({
</form>
<Link
href={`/book?serviceId=${serviceId}&date=${startsAtIso.slice(0, 10)}`}
href={(() => {
const back = new URLSearchParams({
serviceId,
date: startsAtIso.slice(0, 10),
});
if (fromBookingId) back.set("fromBookingId", fromBookingId);
return `/book?${back}`;
})()}
className="mt-4 block text-center text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
>
Pick a different time

View File

@@ -38,6 +38,12 @@ export default async function DonePage({
});
if (!booking) redirect("/");
// Stripe redirects here right after `confirmPayment`, but the
// `payment_intent.succeeded` webhook may not have fired yet — the booking
// can still be HOLD for a few seconds. Show "processing" copy in that case.
const isProcessing = booking.status === "HOLD";
const isCancelled = booking.status === "CANCELLED";
return (
<div className="flex flex-1 flex-col">
<header className="flex items-center justify-between border-b border-zinc-200 bg-white px-6 py-4 dark:border-zinc-800 dark:bg-zinc-950">
@@ -48,10 +54,18 @@ export default async function DonePage({
<main className="mx-auto w-full max-w-md flex-1 p-8">
<h1 className="mb-2 text-2xl font-semibold tracking-tight">
You&apos;re booked
{isCancelled
? "Booking cancelled"
: isProcessing
? "Processing your payment"
: "Youre booked"}
</h1>
<p className="mb-6 text-sm text-zinc-600 dark:text-zinc-400">
A confirmation email is on its way to {booking.customer.email}.
{isCancelled
? "This booking is no longer active. If you didnt cancel, please contact us."
: isProcessing
? "Hold tight — were confirming your payment. This page will reflect the result once it clears (usually a few seconds)."
: `A confirmation email is on its way to ${booking.customer.email}.`}
</p>
<div className="rounded-lg border border-zinc-200 bg-white p-5 dark:border-zinc-800 dark:bg-zinc-950">

View File

@@ -15,6 +15,8 @@ const TZ = process.env.APP_TZ ?? "America/Detroit";
type SearchParams = Promise<{
serviceId?: string;
date?: string;
fromBookingId?: string; // set when rescheduling
taken?: string; // banner if a slot was taken between view and confirm
}>;
function formatLocalTime(d: Date): string {
@@ -47,7 +49,10 @@ async function pickDateAction(formData: FormData): Promise<void> {
"use server";
const serviceId = String(formData.get("serviceId") ?? "");
const date = String(formData.get("date") ?? "");
redirect(`/book?serviceId=${serviceId}&date=${date}`);
const fromBookingId = String(formData.get("fromBookingId") ?? "");
const params = new URLSearchParams({ serviceId, date });
if (fromBookingId) params.set("fromBookingId", fromBookingId);
redirect(`/book?${params}`);
}
export default async function BookPage({
@@ -58,11 +63,30 @@ export default async function BookPage({
const params = await searchParams;
const session = await auth();
const serviceId = params.serviceId;
const fromBookingId = params.fromBookingId;
if (!serviceId) redirect("/");
const service = await db.service.findUnique({ where: { id: serviceId } });
if (!service || !service.active) redirect("/");
// If rescheduling, validate the customer owns the booking and it's reschedulable
let rescheduleNote: string | null = null;
if (fromBookingId) {
if (!session?.user) {
redirect(`/login?callbackUrl=/book?serviceId=${serviceId}&fromBookingId=${fromBookingId}`);
}
const old = await db.booking.findUnique({
where: { id: fromBookingId },
include: { service: { select: { name: true } } },
});
if (!old || old.customerId !== session!.user.id) redirect("/");
if (old.status !== "HOLD" && old.status !== "CONFIRMED") {
// Old booking is no longer reschedulable; drop the param
redirect(`/book?serviceId=${serviceId}`);
}
rescheduleNote = `Rescheduling your ${old.service.name} — pick a new time below.`;
}
const todayISO = todayLocalISO();
const date = params.date ?? todayISO;
@@ -132,11 +156,25 @@ export default async function BookPage({
)}
</section>
{rescheduleNote && (
<div className="mb-4 rounded-md border border-blue-200 bg-blue-50 p-3 text-sm text-blue-800 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-200">
{rescheduleNote}
</div>
)}
{params.taken && (
<div className="mb-4 rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200">
That time was just taken pick another.
</div>
)}
<form
action={pickDateAction}
className="mb-6 rounded-md border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-950"
>
<input type="hidden" name="serviceId" value={serviceId} />
{fromBookingId && (
<input type="hidden" name="fromBookingId" value={fromBookingId} />
)}
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-zinc-500">
Choose a date
</label>
@@ -167,16 +205,17 @@ export default async function BookPage({
No available times on this date. Try another day.
</p>
) : (
<div className="grid gap-2 sm:grid-cols-4 md:grid-cols-6">
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6">
{slots.map((slot) => {
const params = new URLSearchParams({
const slotParams = new URLSearchParams({
serviceId,
startsAtIso: slot.startsAt.toISOString(),
});
if (fromBookingId) slotParams.set("fromBookingId", fromBookingId);
return (
<Link
key={slot.startsAt.toISOString()}
href={`/book/confirm?${params.toString()}`}
href={`/book/confirm?${slotParams.toString()}`}
className="block w-full rounded-md border border-zinc-300 bg-white px-2 py-2 text-center font-mono text-sm hover:border-zinc-900 hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:hover:border-zinc-100 dark:hover:bg-zinc-800"
>
{formatLocalTime(slot.startsAt)}

View File

@@ -0,0 +1,119 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { stripeConfigured } from "@/lib/stripe";
import { createDepositIntentForBooking } from "@/lib/payments";
import { PaymentForm } from "@/components/PaymentForm";
export const metadata = { title: "Payment — TouchBase" };
export const dynamic = "force-dynamic";
const TZ = process.env.APP_TZ ?? "America/Detroit";
type Params = Promise<{ id: string }>;
function formatLocal(d: Date): string {
return new Intl.DateTimeFormat("en-US", {
timeZone: TZ,
weekday: "short",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
timeZoneName: "short",
}).format(d);
}
export default async function PayPage({ params }: { params: Params }) {
const { id } = await params;
const session = await auth();
if (!session?.user) redirect(`/login?callbackUrl=/book/pay/${id}`);
const booking = await db.booking.findUnique({
where: { id },
include: { service: true, customer: { select: { id: true } } },
});
if (!booking) notFound();
if (booking.customer.id !== session!.user.id) redirect("/account/bookings");
// If already CONFIRMED, payment is done — bounce to the success page.
if (booking.status === "CONFIRMED") {
redirect(`/book/done?bookingId=${id}`);
}
if (booking.status === "CANCELLED" || booking.status === "COMPLETED" || booking.status === "NO_SHOW") {
redirect("/account/bookings");
}
if (booking.service.depositCents <= 0) {
redirect(`/book/done?bookingId=${id}`);
}
if (!stripeConfigured()) {
return (
<div className="mx-auto max-w-md p-8 text-center">
<h1 className="mb-2 text-xl font-semibold tracking-tight">
Payments not configured
</h1>
<p className="text-sm text-zinc-600 dark:text-zinc-400">
The practice hasn&apos;t finished setting up online payments yet.
Please contact them to confirm your booking.
</p>
<Link href="/account/bookings" className="mt-4 inline-block text-sm underline">
Back to my bookings
</Link>
</div>
);
}
const intent = await createDepositIntentForBooking(db, id);
const publishableKey = process.env.STRIPE_PUBLISHABLE_KEY!;
const appUrl = process.env.APP_URL ?? "http://localhost:3000";
const returnUrl = `${appUrl}/book/done?bookingId=${id}`;
return (
<div className="flex flex-1 flex-col">
<header className="flex items-center justify-between border-b border-zinc-200 bg-white px-6 py-4 dark:border-zinc-800 dark:bg-zinc-950">
<Link href="/" className="text-lg font-semibold tracking-tight">
TouchBase
</Link>
</header>
<main className="mx-auto w-full max-w-md flex-1 p-8">
<h1 className="mb-2 text-2xl font-semibold tracking-tight">
Pay your deposit
</h1>
<p className="mb-6 text-sm text-zinc-600 dark:text-zinc-400">
Confirm your card to lock in the booking.
</p>
<div className="mb-6 rounded-lg border border-zinc-200 bg-white p-5 dark:border-zinc-800 dark:bg-zinc-950">
<dl className="grid gap-3 text-sm">
<div>
<dt className="text-xs uppercase tracking-wide text-zinc-500">Service</dt>
<dd>{booking.service.name}</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-wide text-zinc-500">When</dt>
<dd>{formatLocal(booking.startsAt)}</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-wide text-zinc-500">Deposit</dt>
<dd className="font-mono">
${(booking.service.depositCents / 100).toFixed(2)} of ${(booking.service.priceCents / 100).toFixed(2)}
</dd>
</div>
</dl>
</div>
<div className="rounded-lg border border-zinc-200 bg-white p-5 dark:border-zinc-800 dark:bg-zinc-950">
<PaymentForm
publishableKey={publishableKey}
clientSecret={intent.clientSecret}
amountCents={intent.amountCents}
returnUrl={returnUrl}
/>
</div>
</main>
</div>
);
}

46
src/app/error.tsx Normal file
View File

@@ -0,0 +1,46 @@
"use client";
// Global error boundary. Required to be a client component by Next.js.
// Keep it minimal — server logs already capture the underlying error.
import Link from "next/link";
import { useEffect } from "react";
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error("[touchbase] page error:", error);
}, [error]);
return (
<main className="mx-auto flex min-h-full max-w-md flex-col items-center justify-center gap-4 p-8 text-center">
<h1 className="text-2xl font-semibold tracking-tight">Something went wrong</h1>
<p className="text-sm text-zinc-600 dark:text-zinc-400">
We hit an unexpected error. Try again, or head back to the start.
</p>
{error.digest && (
<p className="font-mono text-xs text-zinc-400">ref: {error.digest}</p>
)}
<div className="flex gap-2">
<button
type="button"
onClick={() => reset()}
className="rounded-md border border-zinc-300 px-4 py-1.5 text-sm hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-900"
>
Try again
</button>
<Link
href="/"
className="rounded-md bg-zinc-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900"
>
Go home
</Link>
</div>
</main>
);
}

View File

@@ -1,4 +1,4 @@
import type { Metadata } from "next";
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
@@ -15,8 +15,39 @@ const geistMono = Geist_Mono({
export const metadata: Metadata = {
title: "TouchBase",
description: "Massage practice scheduling",
manifest: "/manifest.webmanifest",
appleWebApp: {
capable: true,
title: "TouchBase",
statusBarStyle: "black-translucent",
},
icons: {
icon: "/icon.svg",
apple: "/icon.svg",
},
};
export const viewport: Viewport = {
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#fafafa" },
{ media: "(prefers-color-scheme: dark)", color: "#18181b" },
],
width: "device-width",
initialScale: 1,
viewportFit: "cover",
};
// Run at the body bottom: registers the SW once the page is interactive.
const SW_REGISTRATION_SCRIPT = `
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
navigator.serviceWorker.register('/sw.js').catch(function(err) {
console.warn('[touchbase] sw register failed:', err);
});
});
}
`;
export default function RootLayout({
children,
}: Readonly<{
@@ -27,7 +58,19 @@ export default function RootLayout({
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<body className="min-h-full flex flex-col">
{children}
<script
// Only valid in production; in dev the SW caches stale chunks and the
// HMR experience gets weird. Production-only registration below.
dangerouslySetInnerHTML={{
__html:
process.env.NODE_ENV === "production"
? SW_REGISTRATION_SCRIPT
: "",
}}
/>
</body>
</html>
);
}

28
src/app/manifest.ts Normal file
View File

@@ -0,0 +1,28 @@
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "TouchBase",
short_name: "TouchBase",
description: "Massage practice scheduling",
start_url: "/",
display: "standalone",
background_color: "#fafafa",
theme_color: "#18181b",
orientation: "portrait-primary",
icons: [
{
src: "/icon.svg",
sizes: "any",
type: "image/svg+xml",
purpose: "any",
},
{
src: "/icon-mask.svg",
sizes: "any",
type: "image/svg+xml",
purpose: "maskable",
},
],
};
}

20
src/app/not-found.tsx Normal file
View File

@@ -0,0 +1,20 @@
import Link from "next/link";
export const metadata = { title: "Not found — TouchBase" };
export default function NotFound() {
return (
<main className="mx-auto flex min-h-full max-w-md flex-col items-center justify-center gap-4 p-8 text-center">
<h1 className="text-3xl font-semibold tracking-tight">404</h1>
<p className="text-zinc-600 dark:text-zinc-400">
We couldn&apos;t find that page.
</p>
<Link
href="/"
className="rounded-full bg-zinc-900 px-5 py-2 text-sm font-medium text-white hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900"
>
Back to home
</Link>
</main>
);
}

View File

@@ -70,7 +70,7 @@ export default async function TherapistBookingsPage() {
No upcoming appointments.
</p>
) : (
<div className="overflow-hidden rounded-md border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-950">
<div className="overflow-x-auto rounded-md border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-950">
<table className="w-full text-sm">
<thead className="bg-zinc-50 text-left text-xs uppercase tracking-wide text-zinc-500 dark:bg-zinc-900">
<tr>

View File

@@ -0,0 +1,101 @@
"use client";
// Stripe Payment Element for the booking-deposit flow.
// First real client component in the app — needed because Stripe.js can only
// run in the browser.
import { useState } from "react";
import { loadStripe, type Stripe } from "@stripe/stripe-js";
import {
Elements,
PaymentElement,
useElements,
useStripe,
} from "@stripe/react-stripe-js";
// Cache the Stripe object across renders. loadStripe returns a Promise-like.
let stripePromise: ReturnType<typeof loadStripe> | null = null;
function getStripe(publishableKey: string) {
if (!stripePromise) stripePromise = loadStripe(publishableKey);
return stripePromise;
}
export function PaymentForm({
publishableKey,
clientSecret,
amountCents,
returnUrl,
}: {
publishableKey: string;
clientSecret: string;
amountCents: number;
returnUrl: string;
}) {
return (
<Elements
stripe={getStripe(publishableKey)}
options={{
clientSecret,
appearance: { theme: "stripe" },
}}
>
<CheckoutInner amountCents={amountCents} returnUrl={returnUrl} />
</Elements>
);
}
function CheckoutInner({
amountCents,
returnUrl,
}: {
amountCents: number;
returnUrl: string;
}) {
const stripe = useStripe();
const elements = useElements();
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
if (!stripe || !elements) return;
setSubmitting(true);
setError(null);
const { error: stripeError } = await stripe.confirmPayment({
elements,
confirmParams: { return_url: returnUrl },
});
// confirmPayment only returns here on validation/network errors;
// on success Stripe redirects to return_url.
if (stripeError) {
setError(stripeError.message ?? "Payment failed — try again.");
setSubmitting(false);
}
}
return (
<form onSubmit={onSubmit} className="space-y-4">
<PaymentElement />
{error && (
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-800 dark:border-red-900 dark:bg-red-950/40 dark:text-red-200">
{error}
</div>
)}
<button
type="submit"
disabled={!stripe || !elements || submitting}
className="w-full rounded-md bg-zinc-900 py-2.5 text-sm font-medium text-white hover:bg-zinc-800 disabled:opacity-50 dark:bg-zinc-100 dark:text-zinc-900"
>
{submitting ? "Processing…" : `Pay $${(amountCents / 100).toFixed(2)} deposit`}
</button>
<p className="text-center text-xs text-zinc-500">
Your card is charged the deposit now. The remaining balance is paid in person.
</p>
</form>
);
}
// Type re-export so the page can declare props without importing @stripe/stripe-js.
export type { Stripe };

View File

@@ -193,6 +193,157 @@ export async function cancelBooking(
return { kind: "cancelled", alreadyCancelled: false };
}
export type RescheduleBookingInput = {
oldBookingId: string;
newStartsAt: Date;
cancelledByUserId: string;
// If omitted, the new booking inherits the old booking's service/therapist/room.
newServiceId?: string;
newTherapistId?: string;
newRoomId?: string;
};
export type RescheduleBookingResult = {
oldBookingId: string;
newBookingId: string;
};
/**
* Atomically cancel an old booking and create a new one at a new time.
* Wrapped in a transaction so a conflict on the new insert leaves the old
* booking intact.
*/
export async function rescheduleBooking(
db: PrismaClient,
input: RescheduleBookingInput,
): Promise<RescheduleBookingResult> {
const old = await db.booking.findUnique({
where: { id: input.oldBookingId },
select: {
status: true,
customerId: true,
serviceId: true,
therapistId: true,
roomId: true,
},
});
if (!old) throw new Error(`Booking not found: ${input.oldBookingId}`);
if (old.status !== "HOLD" && old.status !== "CONFIRMED") {
throw new Error(`Cannot reschedule a ${old.status} booking`);
}
const serviceId = input.newServiceId ?? old.serviceId;
const therapistId = input.newTherapistId ?? old.therapistId;
const roomId = input.newRoomId ?? old.roomId;
const service = await db.service.findUnique({
where: { id: serviceId },
select: {
durationMin: true,
bufferAfterMin: true,
priceCents: true,
depositCents: true,
active: true,
},
});
if (!service) throw new Error(`Service not found: ${serviceId}`);
if (!service.active) throw new Error(`Service is inactive: ${serviceId}`);
const endsAt = new Date(input.newStartsAt.getTime() + service.durationMin * 60_000);
const roomReleasedAt = new Date(endsAt.getTime() + service.bufferAfterMin * 60_000);
try {
const result = await db.$transaction(async (tx) => {
// Cancel the old booking first so its slot is free for the new insert.
await tx.booking.update({
where: { id: input.oldBookingId },
data: {
status: "CANCELLED",
cancelledAt: new Date(),
cancelledBy: input.cancelledByUserId,
cancelReason: "rescheduled",
},
});
// Insert the new booking. Exclusion constraints will reject conflicts;
// if they do, the entire transaction rolls back and old stays intact.
const created = await tx.booking.create({
data: {
customerId: old.customerId,
therapistId,
roomId,
serviceId,
startsAt: input.newStartsAt,
endsAt,
roomReleasedAt,
status: "CONFIRMED",
priceCents: service.priceCents,
depositCents: service.depositCents,
},
});
return created.id;
});
return { oldBookingId: input.oldBookingId, newBookingId: result };
} catch (e) {
const constraint = extractConstraint(e);
if (constraint?.includes("_overlap")) {
throw new BookingConflictError(constraint);
}
const code = extractPgCode(e);
if (code === PG_DEADLOCK_DETECTED || code === PG_SERIALIZATION_FAILURE) {
throw new BookingConflictError();
}
throw e;
}
}
/**
* Mark a CONFIRMED booking as COMPLETED. Only valid from CONFIRMED.
* Idempotent — re-marking is a no-op.
*/
export async function markComplete(
db: PrismaClient,
bookingId: string,
): Promise<{ ok: true } | { ok: false; reason: string }> {
const existing = await db.booking.findUnique({
where: { id: bookingId },
select: { status: true },
});
if (!existing) return { ok: false, reason: "not found" };
if (existing.status === "COMPLETED") return { ok: true };
if (existing.status !== "CONFIRMED") {
return { ok: false, reason: `cannot mark ${existing.status} as complete` };
}
await db.booking.update({
where: { id: bookingId },
data: { status: "COMPLETED", holdExpiresAt: null },
});
return { ok: true };
}
/**
* Mark a CONFIRMED booking as NO_SHOW. Only valid from CONFIRMED.
* Idempotent — re-marking is a no-op.
*/
export async function markNoShow(
db: PrismaClient,
bookingId: string,
): Promise<{ ok: true } | { ok: false; reason: string }> {
const existing = await db.booking.findUnique({
where: { id: bookingId },
select: { status: true },
});
if (!existing) return { ok: false, reason: "not found" };
if (existing.status === "NO_SHOW") return { ok: true };
if (existing.status !== "CONFIRMED") {
return { ok: false, reason: `cannot mark ${existing.status} as no-show` };
}
await db.booking.update({
where: { id: bookingId },
data: { status: "NO_SHOW", holdExpiresAt: null },
});
return { ok: true };
}
/**
* Sweep expired holds. Run periodically (pg-boss job, eventually). Returns count.
*/

View File

@@ -160,6 +160,72 @@ function formatLocal(d: Date, tz: string): string {
}).format(d);
}
// ============================================================
// Booking rescheduled
// ============================================================
export type BookingRescheduledArgs = {
db: PrismaClient;
oldBookingId: string;
newBookingId: string;
};
export async function sendBookingRescheduled(
args: BookingRescheduledArgs,
): Promise<SendResult> {
const [oldBooking, newBooking] = await Promise.all([
args.db.booking.findUnique({
where: { id: args.oldBookingId },
select: { startsAt: true },
}),
args.db.booking.findUnique({
where: { id: args.newBookingId },
include: {
customer: true,
service: true,
therapist: { include: { user: true } },
room: true,
},
}),
]);
if (!oldBooking || !newBooking) {
throw new Error(
`Booking not found: old=${args.oldBookingId} new=${args.newBookingId}`,
);
}
const tz = process.env.APP_TZ ?? "America/Detroit";
const oldLocal = formatLocal(oldBooking.startsAt, tz);
const newStart = formatLocal(newBooking.startsAt, tz);
const newEnd = formatLocal(newBooking.endsAt, tz);
const subject = `Your ${newBooking.service.name} has been rescheduled`;
const text = [
`Hi ${newBooking.customer.name ?? newBooking.customer.email},`,
"",
`Your ${newBooking.service.name} appointment has been rescheduled.`,
"",
`Was: ${oldLocal}`,
`Now: ${newStart} ${newEnd}`,
`Therapist: ${newBooking.therapist.user.name ?? newBooking.therapist.user.email}`,
`Room: ${newBooking.room.name}`,
"",
`If this isn't right, reply to this email and we'll fix it up.`,
"",
`— TouchBase`,
].join("\n");
return sendEmail({
db: args.db,
to: newBooking.customer.email,
subject,
text,
template: "booking_rescheduled",
userId: newBooking.customerId,
bookingId: newBooking.id,
});
}
// ============================================================
// Booking cancellation
// ============================================================

186
src/lib/payments.ts Normal file
View File

@@ -0,0 +1,186 @@
// Payment plumbing on top of Stripe. Booking lifecycle integration:
//
// /book/confirm action
// ↓ createHold (Booking row, status=HOLD, holdExpiresAt set)
// ↓ if depositCents > 0: createDepositIntentForBooking → returns client_secret
// /book/pay/[id] (server)
// ↓ retrieves client_secret, renders <PaymentForm/> client component
// Stripe Elements (client)
// ↓ stripe.confirmPayment → redirect to /book/done?bookingId=…
// /api/stripe/webhook (asynchronous)
// ↓ payment_intent.succeeded → recordPaymentSucceeded → flip HOLD→CONFIRMED + send email
// ↓ payment_intent.payment_failed → recordPaymentFailed → cancel HOLD with reason
import type { PrismaClient } from "@/generated/prisma/client";
import { stripe } from "@/lib/stripe";
import { sendBookingConfirmation } from "@/lib/email";
/**
* Create a Stripe PaymentIntent for the booking's deposit and persist its id.
* Idempotent: if the booking already has a PI id, returns the existing one.
*/
export async function createDepositIntentForBooking(
db: PrismaClient,
bookingId: string,
): Promise<{ paymentIntentId: string; clientSecret: string; amountCents: number }> {
const booking = await db.booking.findUnique({
where: { id: bookingId },
include: {
service: { select: { name: true, depositCents: true } },
customer: { select: { email: true, name: true } },
},
});
if (!booking) throw new Error(`Booking not found: ${bookingId}`);
if (booking.service.depositCents <= 0) {
throw new Error(`Service has no deposit: ${booking.serviceId}`);
}
// Reuse if already created
if (booking.stripePaymentIntentId) {
const existing = await stripe().paymentIntents.retrieve(
booking.stripePaymentIntentId,
);
if (!existing.client_secret) {
throw new Error(`PaymentIntent ${existing.id} has no client_secret`);
}
return {
paymentIntentId: existing.id,
clientSecret: existing.client_secret,
amountCents: existing.amount,
};
}
const intent = await stripe().paymentIntents.create({
amount: booking.service.depositCents,
currency: "usd",
automatic_payment_methods: { enabled: true },
receipt_email: booking.customer.email,
description: `Deposit for ${booking.service.name}`,
metadata: {
bookingId: booking.id,
customerId: booking.customerId,
serviceId: booking.serviceId,
},
});
if (!intent.client_secret) {
throw new Error(`PaymentIntent ${intent.id} has no client_secret`);
}
await db.booking.update({
where: { id: bookingId },
data: {
stripePaymentIntentId: intent.id,
paymentStatus: "PENDING",
},
});
return {
paymentIntentId: intent.id,
clientSecret: intent.client_secret,
amountCents: intent.amount,
};
}
/**
* Webhook handler for `payment_intent.succeeded`. Idempotent on duplicate
* webhook deliveries via Payment.stripePaymentIntentId @unique.
*
* Returns whether the booking was just transitioned (so the caller can
* decide whether to send the confirmation email).
*/
export async function recordPaymentSucceeded(
db: PrismaClient,
paymentIntentId: string,
amountCents: number,
): Promise<{ bookingId: string; transitioned: boolean }> {
const booking = await db.booking.findFirst({
where: { stripePaymentIntentId: paymentIntentId },
select: { id: true, status: true },
});
if (!booking) {
throw new Error(`No booking for PaymentIntent ${paymentIntentId}`);
}
// Insert Payment row idempotently (unique on stripePaymentIntentId).
await db.payment.upsert({
where: { stripePaymentIntentId: paymentIntentId },
create: {
bookingId: booking.id,
kind: "DEPOSIT",
amountCents,
currency: "usd",
stripePaymentIntentId: paymentIntentId,
status: "CAPTURED",
},
update: {
status: "CAPTURED",
amountCents,
},
});
// Only transition HOLD → CONFIRMED. If already CONFIRMED (replay) or
// CANCELLED (e.g. hold expired before payment succeeded), don't touch.
let transitioned = false;
if (booking.status === "HOLD") {
await db.booking.update({
where: { id: booking.id },
data: {
status: "CONFIRMED",
holdExpiresAt: null,
paymentStatus: "CAPTURED",
},
});
transitioned = true;
} else if (booking.status === "CONFIRMED") {
// Webhook replay after manual confirm — just update payment status.
await db.booking.update({
where: { id: booking.id },
data: { paymentStatus: "CAPTURED" },
});
}
return { bookingId: booking.id, transitioned };
}
/**
* Webhook handler for `payment_intent.payment_failed`. Cancels the HOLD
* (booking is no longer reachable from Stripe).
*/
export async function recordPaymentFailed(
db: PrismaClient,
paymentIntentId: string,
): Promise<{ bookingId: string }> {
const booking = await db.booking.findFirst({
where: { stripePaymentIntentId: paymentIntentId },
select: { id: true, status: true, customerId: true },
});
if (!booking) {
throw new Error(`No booking for PaymentIntent ${paymentIntentId}`);
}
if (booking.status === "HOLD") {
await db.booking.update({
where: { id: booking.id },
data: {
status: "CANCELLED",
cancelledAt: new Date(),
cancelledBy: booking.customerId,
cancelReason: "payment failed",
paymentStatus: "FAILED",
},
});
} else {
await db.booking.update({
where: { id: booking.id },
data: { paymentStatus: "FAILED" },
});
}
return { bookingId: booking.id };
}
/** Convenience used by the webhook to send the confirmation email after payment. */
export async function confirmAfterPayment(
db: PrismaClient,
bookingId: string,
): Promise<void> {
await sendBookingConfirmation({ db, bookingId });
}

30
src/lib/stripe.ts Normal file
View File

@@ -0,0 +1,30 @@
import Stripe from "stripe";
let cached: Stripe | null = null;
/** Lazy server-side Stripe client. Throws if STRIPE_SECRET_KEY isn't set. */
export function stripe(): Stripe {
if (cached) return cached;
const key = process.env.STRIPE_SECRET_KEY;
if (!key) {
throw new Error(
"STRIPE_SECRET_KEY is not set — payments are unavailable. See .env.example.",
);
}
cached = new Stripe(key);
return cached;
}
/** Public-side check used to decide whether to render the payment UI. */
export function stripeConfigured(): boolean {
return Boolean(
process.env.STRIPE_SECRET_KEY &&
process.env.STRIPE_PUBLISHABLE_KEY &&
process.env.STRIPE_WEBHOOK_SECRET,
);
}
/** Test seam — clear the cached client (used between tests). */
export function resetStripeClient(): void {
cached = null;
}

View File

@@ -7,6 +7,9 @@ import {
confirmHold,
createHold,
expireStaleHolds,
markComplete,
markNoShow,
rescheduleBooking,
} from "@/lib/booking";
import { seed, type SeedResult } from "@/lib/seed";
@@ -309,3 +312,168 @@ describe("expireStaleHolds", () => {
expect(staleRow?.cancelReason).toBe("hold expired");
});
});
describe("rescheduleBooking", () => {
test("cancels old + creates new at the new time, atomically", async () => {
const original = await createHold(db, {
customerId,
serviceId,
therapistId: therapistA,
roomId: roomA,
startsAt: D("2026-05-05T14:00:00Z"),
});
await confirmHold(db, original.id);
const result = await rescheduleBooking(db, {
oldBookingId: original.id,
newStartsAt: D("2026-05-06T14:00:00Z"),
cancelledByUserId: customerId,
});
expect(result.oldBookingId).toBe(original.id);
expect(result.newBookingId).not.toBe(original.id);
const oldRow = await db.booking.findUnique({ where: { id: original.id } });
const newRow = await db.booking.findUnique({ where: { id: result.newBookingId } });
expect(oldRow?.status).toBe("CANCELLED");
expect(oldRow?.cancelReason).toBe("rescheduled");
expect(newRow?.status).toBe("CONFIRMED");
expect(newRow?.startsAt).toEqual(D("2026-05-06T14:00:00Z"));
});
test("rolls back if the new slot conflicts (old booking stays CONFIRMED)", async () => {
// Block the target slot with a CONFIRMED booking on therapistA at 11:00.
const blocker = await createHold(db, {
customerId,
serviceId,
therapistId: therapistA,
roomId: roomA,
startsAt: D("2026-05-06T15:00:00Z"),
});
await confirmHold(db, blocker.id);
const original = await createHold(db, {
customerId,
serviceId,
therapistId: therapistA,
roomId: roomA,
startsAt: D("2026-05-05T14:00:00Z"),
});
await confirmHold(db, original.id);
await expect(
rescheduleBooking(db, {
oldBookingId: original.id,
newStartsAt: D("2026-05-06T15:00:00Z"), // collides with blocker
cancelledByUserId: customerId,
}),
).rejects.toBeInstanceOf(BookingConflictError);
// Both bookings unchanged
const orig = await db.booking.findUnique({ where: { id: original.id } });
const blk = await db.booking.findUnique({ where: { id: blocker.id } });
expect(orig?.status).toBe("CONFIRMED");
expect(blk?.status).toBe("CONFIRMED");
});
test("rejects rescheduling a CANCELLED booking", async () => {
const original = await createHold(db, {
customerId,
serviceId,
therapistId: therapistA,
roomId: roomA,
startsAt: D("2026-05-05T14:00:00Z"),
});
await cancelBooking(db, {
bookingId: original.id,
cancelledByUserId: customerId,
});
await expect(
rescheduleBooking(db, {
oldBookingId: original.id,
newStartsAt: D("2026-05-06T14:00:00Z"),
cancelledByUserId: customerId,
}),
).rejects.toThrow(/Cannot reschedule/);
});
test("can change therapist/room/service on reschedule", async () => {
const original = await createHold(db, {
customerId,
serviceId,
therapistId: therapistA,
roomId: roomA,
startsAt: D("2026-05-05T14:00:00Z"),
});
await confirmHold(db, original.id);
const result = await rescheduleBooking(db, {
oldBookingId: original.id,
newStartsAt: D("2026-05-06T14:00:00Z"),
newTherapistId: therapistB,
newRoomId: roomB,
cancelledByUserId: customerId,
});
const newRow = await db.booking.findUnique({ where: { id: result.newBookingId } });
expect(newRow?.therapistId).toBe(therapistB);
expect(newRow?.roomId).toBe(roomB);
});
});
describe("markComplete / markNoShow", () => {
test("markComplete: CONFIRMED → COMPLETED", async () => {
const hold = await createHold(db, {
customerId,
serviceId,
therapistId: therapistA,
roomId: roomA,
startsAt: D("2026-05-05T14:00:00Z"),
});
await confirmHold(db, hold.id);
const result = await markComplete(db, hold.id);
expect(result.ok).toBe(true);
const row = await db.booking.findUnique({ where: { id: hold.id } });
expect(row?.status).toBe("COMPLETED");
});
test("markComplete is idempotent", async () => {
const hold = await createHold(db, {
customerId,
serviceId,
therapistId: therapistA,
roomId: roomA,
startsAt: D("2026-05-05T14:00:00Z"),
});
await confirmHold(db, hold.id);
await markComplete(db, hold.id);
const second = await markComplete(db, hold.id);
expect(second.ok).toBe(true);
});
test("markComplete rejects HOLD bookings", async () => {
const hold = await createHold(db, {
customerId,
serviceId,
therapistId: therapistA,
roomId: roomA,
startsAt: D("2026-05-05T14:00:00Z"),
});
const result = await markComplete(db, hold.id);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toMatch(/HOLD/);
});
test("markNoShow: CONFIRMED → NO_SHOW", async () => {
const hold = await createHold(db, {
customerId,
serviceId,
therapistId: therapistA,
roomId: roomA,
startsAt: D("2026-05-05T14:00:00Z"),
});
await confirmHold(db, hold.id);
const result = await markNoShow(db, hold.id);
expect(result.ok).toBe(true);
const row = await db.booking.findUnique({ where: { id: hold.id } });
expect(row?.status).toBe("NO_SHOW");
});
});

153
test/payments.test.ts Normal file
View File

@@ -0,0 +1,153 @@
// Tests for the DB-side payment helpers. These don't touch Stripe — they
// operate on the booking row keyed by `stripePaymentIntentId`.
//
// The webhook handler's signature-verification path needs real Stripe keys
// to test end-to-end; the meaningful business logic lives in these helpers.
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/generated/prisma/client";
import { createHold } from "@/lib/booking";
import {
recordPaymentFailed,
recordPaymentSucceeded,
} from "@/lib/payments";
import { seed, type SeedResult } from "@/lib/seed";
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
const db = new PrismaClient({ adapter });
let fx: SeedResult;
let serviceId: string;
let therapistA: string;
let roomA: string;
let customerId: string;
beforeAll(async () => {
fx = await seed(db);
serviceId = fx.services.find((s) => s.name === "60-minute Swedish")!.id;
therapistA = fx.therapists[0].id;
roomA = fx.rooms[0].id;
customerId = fx.customers[0].id;
});
afterAll(async () => {
await db.$disconnect();
});
beforeEach(async () => {
await db.payment.deleteMany();
await db.booking.deleteMany();
});
const D = (iso: string) => new Date(iso);
async function holdWithPI(piId: string) {
const hold = await createHold(db, {
customerId,
serviceId,
therapistId: therapistA,
roomId: roomA,
startsAt: D("2026-05-05T14:00:00Z"),
});
await db.booking.update({
where: { id: hold.id },
data: { stripePaymentIntentId: piId, paymentStatus: "PENDING" },
});
return hold;
}
describe("recordPaymentSucceeded", () => {
test("flips HOLD → CONFIRMED, writes a Payment row, returns transitioned=true", async () => {
const hold = await holdWithPI("pi_test_1");
const result = await recordPaymentSucceeded(db, "pi_test_1", 2000);
expect(result.bookingId).toBe(hold.id);
expect(result.transitioned).toBe(true);
const row = await db.booking.findUnique({ where: { id: hold.id } });
expect(row?.status).toBe("CONFIRMED");
expect(row?.paymentStatus).toBe("CAPTURED");
expect(row?.holdExpiresAt).toBeNull();
const payment = await db.payment.findUnique({
where: { stripePaymentIntentId: "pi_test_1" },
});
expect(payment?.kind).toBe("DEPOSIT");
expect(payment?.status).toBe("CAPTURED");
expect(payment?.amountCents).toBe(2000);
});
test("idempotent on webhook replay — second call returns transitioned=false", async () => {
const hold = await holdWithPI("pi_test_2");
await recordPaymentSucceeded(db, "pi_test_2", 2000);
const second = await recordPaymentSucceeded(db, "pi_test_2", 2000);
expect(second.transitioned).toBe(false);
// Payment row count should still be 1
const count = await db.payment.count({
where: { stripePaymentIntentId: "pi_test_2" },
});
expect(count).toBe(1);
// Booking still CONFIRMED with CAPTURED payment status
const row = await db.booking.findUnique({ where: { id: hold.id } });
expect(row?.status).toBe("CONFIRMED");
});
test("does not transition a CANCELLED booking back to CONFIRMED", async () => {
const hold = await holdWithPI("pi_test_3");
await db.booking.update({
where: { id: hold.id },
data: {
status: "CANCELLED",
cancelledAt: new Date(),
cancelReason: "hold expired",
},
});
const result = await recordPaymentSucceeded(db, "pi_test_3", 2000);
expect(result.transitioned).toBe(false);
const row = await db.booking.findUnique({ where: { id: hold.id } });
expect(row?.status).toBe("CANCELLED"); // unchanged
// Payment row still recorded — money was actually captured even though
// the booking is gone; admin can refund manually.
const payment = await db.payment.findUnique({
where: { stripePaymentIntentId: "pi_test_3" },
});
expect(payment?.status).toBe("CAPTURED");
});
test("throws on unknown PaymentIntent id", async () => {
await expect(
recordPaymentSucceeded(db, "pi_no_such", 2000),
).rejects.toThrow(/No booking/);
});
});
describe("recordPaymentFailed", () => {
test("cancels a HOLD booking with reason 'payment failed'", async () => {
const hold = await holdWithPI("pi_test_fail_1");
const result = await recordPaymentFailed(db, "pi_test_fail_1");
expect(result.bookingId).toBe(hold.id);
const row = await db.booking.findUnique({ where: { id: hold.id } });
expect(row?.status).toBe("CANCELLED");
expect(row?.cancelReason).toBe("payment failed");
expect(row?.paymentStatus).toBe("FAILED");
});
test("does not change status of a CONFIRMED booking, but updates paymentStatus", async () => {
const hold = await holdWithPI("pi_test_fail_2");
await db.booking.update({
where: { id: hold.id },
data: { status: "CONFIRMED" },
});
await recordPaymentFailed(db, "pi_test_fail_2");
const row = await db.booking.findUnique({ where: { id: hold.id } });
expect(row?.status).toBe("CONFIRMED"); // unchanged
expect(row?.paymentStatus).toBe("FAILED");
});
test("throws on unknown PaymentIntent id", async () => {
await expect(
recordPaymentFailed(db, "pi_no_such"),
).rejects.toThrow(/No booking/);
});
});