add Stripe
This commit is contained in:
90
docs/progress/2026-05-02-booking-lifecycle.md
Normal file
90
docs/progress/2026-05-02-booking-lifecycle.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# 2026-05-02 — Booking Lifecycle (Phase C)
|
||||||
|
|
||||||
|
> Companion to `BuildLog.md`. Predecessor: `2026-05-02-therapist-self-serve.md`. Closes Phase C of the post-payments UX plan.
|
||||||
|
|
||||||
|
## Milestone
|
||||||
|
|
||||||
|
The booking lifecycle has all the verbs the practice needs: create, view, **reschedule, mark complete, mark no-show**, cancel — for both customer and admin. Reschedule is atomic (cancel-old + create-new in one transaction; rollback on conflict so the customer never loses their old slot to a phantom new booking). Single "rescheduled" email replaces the cancel + new-confirmation pair. Booking detail pages exist for both customer and admin.
|
||||||
|
|
||||||
|
## What landed
|
||||||
|
|
||||||
|
| Path | Role |
|
||||||
|
|---|---|
|
||||||
|
| `src/lib/booking.ts` | +`rescheduleBooking()` (atomic, transactional, surfaces `BookingConflictError` if new slot collides — old booking stays intact). +`markComplete()` and +`markNoShow()` (CONFIRMED→target only, idempotent). |
|
||||||
|
| `src/lib/email.ts` | +`sendBookingRescheduled()` template (one email summarizing old → new). |
|
||||||
|
| `src/app/admin/bookings/[id]/page.tsx` | Detail page — all booking fields incl. cancel-audit, payment-status placeholder, room-released-at, action buttons (Mark complete / Mark no-show / Cancel). Mark-* buttons only appear on past CONFIRMED bookings. |
|
||||||
|
| `src/app/account/bookings/[id]/page.tsx` | Customer detail page — service / when / therapist / room / price + Reschedule + Cancel. Owner-or-admin authorization on the action. |
|
||||||
|
| `src/app/book/page.tsx` | Accepts `fromBookingId` param — shows "Rescheduling your X — pick a new time" banner; threads param through to slot links and the date picker form. |
|
||||||
|
| `src/app/book/confirm/page.tsx` | Detects `fromBookingId` and routes through `rescheduleBooking` + `sendBookingRescheduled` instead of `createHold` + `sendBookingConfirmation`. Page heading and copy adapt. Conflict redirects back to `/book` with `taken=1` and the `fromBookingId` preserved. |
|
||||||
|
| `src/app/account/bookings/page.tsx` | Per-row `Reschedule` link → `/book?serviceId=…&fromBookingId=…`. Booking row is now itself a link to the detail page. |
|
||||||
|
| `src/app/admin/bookings/page.tsx` | Booking row links to detail page. Therapist-name fallback for null. |
|
||||||
|
| `test/booking.test.ts` | +8 tests: reschedule happy path, atomic rollback on conflict, rejects rescheduling CANCELLED, can change therapist/room on reschedule, markComplete CONFIRMED→COMPLETED, markComplete idempotent, markComplete rejects HOLD, markNoShow CONFIRMED→NO_SHOW. |
|
||||||
|
|
||||||
|
## What's verified
|
||||||
|
|
||||||
|
- `pnpm test` — **79/79 green** (was 71; +8 new in this session)
|
||||||
|
- `pnpm lint` — clean
|
||||||
|
- `pnpm exec tsc --noEmit` — clean
|
||||||
|
- Live smoke test (curl + Mailpit, dev server):
|
||||||
|
- **Customer (alex@example.com)**: signed in via magic link → `/account/bookings` shows Reschedule + Cancel buttons → click Reschedule → `/book?fromBookingId=…` shows "Rescheduling your..." banner and slot links carry `fromBookingId` → click new slot → `/book/confirm` shows "Confirm reschedule" heading, "previous booking will be cancelled" copy, hidden `fromBookingId` field
|
||||||
|
- **Admin (admin@touchbase.local)**: `/admin/bookings/{id}` renders full detail with customer name, therapist link, room link, all fields, Cancel button (Mark complete / no-show buttons hidden because the booking is in the future — they appear only after the booking end time)
|
||||||
|
|
||||||
|
## Decisions ratified
|
||||||
|
|
||||||
|
| Decision | Resolution |
|
||||||
|
|---|---|
|
||||||
|
| Reschedule semantics | **Atomic** — cancel-old + create-new in one `db.$transaction`. If the new insert fails (conflict, FK, etc.), the entire transaction rolls back; old booking stays intact. Reason: customer should never lose their slot to a phantom new booking. |
|
||||||
|
| Reschedule email | **One** rescheduled email, not cancel + new-confirmation pair. Reason: cleaner customer experience; one notification with old-vs-new framing. |
|
||||||
|
| Mark complete / no-show timing | Buttons only render when `status === "CONFIRMED" && booking.endsAt < now`. Reason: marking a future booking complete is meaningless and almost always a misclick. |
|
||||||
|
| Mark complete / no-show idempotency | Re-marking returns `{ ok: true }` (no-op). Switching from one final state to another is rejected. Reason: prevents accidental status flips after the fact. |
|
||||||
|
| Cancel reason on reschedule | `"rescheduled"` literal in `Booking.cancelReason`. Reason: distinguishable from customer/admin cancellation in audit; could drive metrics later. |
|
||||||
|
| Authorization on customer detail page | Owner or admin can view; otherwise redirect to `/account/bookings`. Same on cancel action. |
|
||||||
|
| Authorization on admin detail page | Layout already requires `role=ADMIN`; actions re-check on the safe side. |
|
||||||
|
| `/account/bookings/[id]` reachable by admins | Yes — they may want to debug from a customer's POV. The cancel button still works. |
|
||||||
|
| Reschedule preserves service by default | `rescheduleBooking` defaults newServiceId/therapist/room to the old booking's. The customer flow always passes the same serviceId; admin could pass different ones if/when we build admin-side reschedule. |
|
||||||
|
| Mark-* helpers return discriminated unions | `{ ok: true }` or `{ ok: false; reason: string }`. Reason: same shape as `cancelBooking` so the UI can react without exceptions. |
|
||||||
|
|
||||||
|
## Gotchas hit
|
||||||
|
|
||||||
|
None — clean session.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
1. Customer-visible brand name (still pending)
|
||||||
|
2. Currency
|
||||||
|
3. Stripe account ownership
|
||||||
|
4. **NEW**: should reschedule require explicit confirmation if the old booking was CONFIRMED with a deposit? Not relevant until 5d Stripe.
|
||||||
|
5. **NEW**: admin "reschedule on customer's behalf" UI flow — currently admins cancel and the customer rebooks. We have the `rescheduleBooking` helper; the admin UI for it is a future enhancement.
|
||||||
|
|
||||||
|
## Roadmap status
|
||||||
|
|
||||||
|
UX-completeness:
|
||||||
|
|
||||||
|
- A — Admin CRUD: done 2026-05-02
|
||||||
|
- B — Therapist self-serve: done 2026-05-02
|
||||||
|
- **C — Booking lifecycle UX: done 2026-05-02 (this session)**
|
||||||
|
- D — PWA shell (per `BuildLog.md` §11)
|
||||||
|
- E — Polish — 404, empty states, mobile review, dark-mode glance
|
||||||
|
|
||||||
|
## Recommended next step
|
||||||
|
|
||||||
|
**Phase D — PWA shell**. Per `BuildLog.md` §11, this is the "Stage 1" mobile story we promised at bootstrap:
|
||||||
|
|
||||||
|
- `manifest.webmanifest` with name, icons, `display: "standalone"`, theme color, start URL
|
||||||
|
- A minimal service worker (`next-pwa` or hand-rolled with Workbox) — **network-first for HTML, stale-while-revalidate for static assets, no caching of API/booking endpoints**
|
||||||
|
- iOS Safari quirks: separate `apple-touch-icon`, `apple-mobile-web-app-capable`, status bar style
|
||||||
|
- Test on a phone or in DevTools "Add to Home Screen"
|
||||||
|
|
||||||
|
~half-day to a full day. After D, Phase E (~half-day): 404 page, empty-state polish, mobile-first audit, dark-mode glance.
|
||||||
|
|
||||||
|
## How to resume
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/noise/Documents/code/touchbase
|
||||||
|
docker-compose up -d postgres mailpit
|
||||||
|
pnpm db:seed
|
||||||
|
pnpm tsx scripts/book-on-behalf.ts alex@example.com "60-minute Swedish" 2026-05-05T10:00
|
||||||
|
pnpm dev
|
||||||
|
# As alex: sign in → /account/bookings → click row to see detail → Reschedule → pick new time → confirm
|
||||||
|
# As admin: sign in → /admin/bookings → click row → view detail with action buttons
|
||||||
|
```
|
||||||
122
docs/progress/2026-05-02-pwa-and-polish.md
Normal file
122
docs/progress/2026-05-02-pwa-and-polish.md
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
# 2026-05-02 — PWA Shell + Polish (Phases D + E)
|
||||||
|
|
||||||
|
> Companion to `BuildLog.md`. Predecessor: `2026-05-02-booking-lifecycle.md`. Closes Phases D and E of the post-payments UX plan — UX-completeness done.
|
||||||
|
|
||||||
|
## Milestone
|
||||||
|
|
||||||
|
Two small phases bundled because both came in well under estimate. The app is now installable as a PWA (Add to Home Screen on iOS / Android, manifest + service worker + icons), has friendly 404 / error pages instead of Next's default boxes, and the slot grid + admin tables stop crushing on narrow viewports.
|
||||||
|
|
||||||
|
**This closes the UX-completeness pivot.** Backend is feature-complete for v1 minus payments + reminders. Next workstream is **5d Stripe**.
|
||||||
|
|
||||||
|
## What landed
|
||||||
|
|
||||||
|
### Phase D — PWA shell
|
||||||
|
| Path | Role |
|
||||||
|
|---|---|
|
||||||
|
| `public/icon.svg` | Simple TB monogram on dark rounded square — works as favicon and as PWA icon at any size |
|
||||||
|
| `public/icon-mask.svg` | Maskable variant — full-bleed background, content in safe area for Android adaptive icons |
|
||||||
|
| `src/app/manifest.ts` | Next.js typed Metadata Manifest. name + short_name + display=standalone + theme color + 2 icons (any + maskable) |
|
||||||
|
| `public/sw.js` | Hand-rolled service worker. Network-first for HTML, stale-while-revalidate for static assets, **never** caches API/auth/admin/account/therapist/book paths. Versioned cache name (`tb-html-v1`, `tb-static-v1`). |
|
||||||
|
| `src/app/layout.tsx` | Added `manifest`, `appleWebApp`, `icons` to metadata. Added `viewport.themeColor` (light + dark + viewport-fit=cover for iPhone notch). Inline SW registration script gated to `NODE_ENV === "production"` (avoids stale-chunk weirdness in dev). |
|
||||||
|
|
||||||
|
### Phase E — Polish
|
||||||
|
| Path | Role |
|
||||||
|
|---|---|
|
||||||
|
| `src/app/not-found.tsx` | Friendly 404 — required because `notFound()` is called in several admin/account pages |
|
||||||
|
| `src/app/error.tsx` | Global error boundary client component with "Try again" + "Go home" buttons. Error digest displayed for debugging. |
|
||||||
|
| Slot grids in `/book` and `/admin/bookings/new` | Default to `grid-cols-3` instead of crushing to 1 col on mobile. Promotes to 4/6 cols at sm/md. |
|
||||||
|
| Admin tables (5 files) | `overflow-hidden` → `overflow-x-auto` so they scroll on narrow viewports instead of clipping |
|
||||||
|
|
||||||
|
## What's verified
|
||||||
|
|
||||||
|
- `pnpm test` — **79/79 green**
|
||||||
|
- `pnpm lint` — clean
|
||||||
|
- `pnpm exec tsc --noEmit` — clean
|
||||||
|
- Live PWA smoke (curl on dev server):
|
||||||
|
- `GET /sw.js` → 200, `application/javascript`, 3.3 KB
|
||||||
|
- `GET /manifest.webmanifest` → 200, `application/manifest+json`, well-formed JSON with TouchBase + display=standalone + theme color + 2 icon refs
|
||||||
|
- `GET /icon.svg` → 200, `image/svg+xml`, 361 B
|
||||||
|
- `GET /icon-mask.svg` → 200, 437 B
|
||||||
|
- HTML head includes: `<meta viewport>` (with `viewport-fit=cover`), 2× `<meta theme-color>` (light + dark), `<link rel="manifest">`, `<meta mobile-web-app-capable>`, `<meta apple-mobile-web-app-title>`, `<meta apple-mobile-web-app-status-bar-style>`, `<link rel="apple-touch-icon">`
|
||||||
|
- 404 smoke: `GET /no-such-page` → renders the new not-found page with "404" heading + "We couldn't find that page" + "Back to home" link
|
||||||
|
|
||||||
|
## Decisions ratified
|
||||||
|
|
||||||
|
| Decision | Resolution |
|
||||||
|
|---|---|
|
||||||
|
| PWA library | **None** — hand-rolled `public/sw.js` (~80 LOC). Reason: zero dependency, full visibility into caching behavior, easy to reason about. `next-pwa` / `@serwist/next` are reasonable upgrades when we have specific needs (offline queueing, push notifications, etc.). |
|
||||||
|
| Icon format | **SVG** (single file, scaled by browser). Reason: ships in 360 bytes, looks crisp at any size, no asset pipeline. iOS Safari supports SVG `apple-touch-icon` from iOS 17+; on older iOS the icon falls back to the bookmark default — acceptable for v1. We can add a 180×180 PNG later if needed. |
|
||||||
|
| SW caching policy | Network-first for HTML; stale-while-revalidate for static; **never** cache `/api/`, `/admin/`, `/account/`, `/therapist/`, `/book*`. Reason: any cached page in those routes could show stale slot availability or stale booking state. Safer to require fresh data. |
|
||||||
|
| Versioned cache name | `tb-html-v1`, `tb-static-v1`. Bump on deploys that should invalidate. Reason: simple manual control vs. fingerprint-based invalidation. |
|
||||||
|
| SW registration timing | `window.addEventListener('load', ...)` — runs after the page is interactive so it doesn't compete with first paint. Production-only via inline script gated on `NODE_ENV`. |
|
||||||
|
| `mobile-web-app-capable` instead of `apple-mobile-web-app-capable` | Next.js's `appleWebApp.capable: true` emits the modern unprefixed form, which is the spec-current name. Both Apple and Android browsers respect it. |
|
||||||
|
| `dynamic = "force-dynamic"` everywhere | Already the default in our pages. SW reinforces this — stale HTML is OK on cold reload but never on subsequent navigation. |
|
||||||
|
| Error page is a Client Component | Required by Next.js's error boundary spec. Server logs already capture the underlying error; client-side `console.error` is a backup for browser inspection. |
|
||||||
|
| 404 + error page styling | Match the rest of the app — minimal, Tailwind utility classes, matches dark mode automatically. |
|
||||||
|
| Mobile slot grid breakpoint | `grid-cols-3 sm:grid-cols-4 md:grid-cols-6`. 3-across on phone is comfortable thumb territory; 4 on tablet; 6 on desktop. |
|
||||||
|
| Admin table overflow | `overflow-x-auto` rather than `overflow-hidden`. Tradeoff: rounded corners look slightly worse when content overflows, but the alternative (clip) is functionally broken on phone. |
|
||||||
|
|
||||||
|
## Gotchas hit
|
||||||
|
|
||||||
|
### `apple-mobile-web-app-capable` missing
|
||||||
|
Initial smoke didn't find this meta tag. Turned out Next.js (modern versions) emits the unprefixed `mobile-web-app-capable` instead — which is the current spec form. Both work. No fix needed.
|
||||||
|
|
||||||
|
### Lint flagged `<a>` instead of `<Link>` in error page
|
||||||
|
Started with `<a href="/">` to avoid importing `<Link>` for one element. ESLint rule (`@next/next/no-html-link-for-pages`) caught it. Switched to `<Link>`.
|
||||||
|
|
||||||
|
### `'event' is defined but never used` in SW
|
||||||
|
Pure JS file linted alongside the rest. The unused param in the install handler triggered the unused-vars rule. Removed the param.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
1. Customer-visible brand name (still pending)
|
||||||
|
2. Currency
|
||||||
|
3. Stripe account ownership — **needed for next phase**
|
||||||
|
4. **NEW**: should the SW also cache the home page for an "offline" experience? Currently network-first means it works offline ONLY if previously visited. Acceptable for v1.
|
||||||
|
5. **NEW**: PNG fallback for `apple-touch-icon` for iOS <17? Defer until someone reports the bookmark icon is missing.
|
||||||
|
|
||||||
|
## Roadmap status
|
||||||
|
|
||||||
|
UX-completeness:
|
||||||
|
|
||||||
|
- A — Admin CRUD: done 2026-05-02
|
||||||
|
- B — Therapist self-serve: done 2026-05-02
|
||||||
|
- C — Booking lifecycle: done 2026-05-02
|
||||||
|
- **D — PWA shell: done 2026-05-02 (this session, part 1)**
|
||||||
|
- **E — Polish: done 2026-05-02 (this session, part 2)**
|
||||||
|
|
||||||
|
**UX is done.** Backend roadmap continues:
|
||||||
|
|
||||||
|
- 5d — Stripe deposit flow + webhook
|
||||||
|
- 5e — Email reminders (pg-boss scheduled jobs)
|
||||||
|
|
||||||
|
## Recommended next step
|
||||||
|
|
||||||
|
**5d — Stripe deposit flow.** The biggest remaining backend chunk. Sequence per `BuildLog.md` notes:
|
||||||
|
|
||||||
|
1. Stripe SDK + env config (test keys — user provides)
|
||||||
|
2. Render Stripe Elements on `/book/confirm` (this is the **first place we need a real Client Component for interactive UI** — past Client Components have been minimal)
|
||||||
|
3. `createPaymentIntent` for the deposit at `createHold` time; pass `client_secret` to the page
|
||||||
|
4. Webhook handler at `/api/stripe/webhook` (verifies signature, updates `Payment` row, transitions Booking from HOLD to CONFIRMED on `payment_intent.succeeded`)
|
||||||
|
5. Hold expiry job via pg-boss cancels HOLDs whose deposit didn't capture in time
|
||||||
|
|
||||||
|
~3–5 days. After 5d, **5e reminders** is small — pg-boss schedule + reminder template + send job (~2–3 days).
|
||||||
|
|
||||||
|
Before starting 5d we'll need:
|
||||||
|
- Stripe test secret key + publishable key
|
||||||
|
- Webhook signing secret (from Stripe CLI for local dev)
|
||||||
|
|
||||||
|
## How to resume
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/noise/Documents/code/touchbase
|
||||||
|
docker-compose up -d postgres mailpit
|
||||||
|
pnpm db:seed
|
||||||
|
pnpm tsx scripts/book-on-behalf.ts alex@example.com "60-minute Swedish" 2026-05-05T10:00
|
||||||
|
pnpm dev
|
||||||
|
# Try the customer flow on a phone (or DevTools mobile preview):
|
||||||
|
# http://<your-IP>:3000 → tap a service → date → pick time → sign in → confirm
|
||||||
|
# Add to Home Screen — PWA icon + standalone display
|
||||||
|
# Try the admin/therapist flows on a phone — tables now scroll horizontally
|
||||||
|
# Hit a bad URL like /xyzzy → see the 404 page
|
||||||
|
```
|
||||||
120
docs/progress/2026-05-02-stripe-scaffold.md
Normal file
120
docs/progress/2026-05-02-stripe-scaffold.md
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
# 2026-05-02 — Stripe Deposit Flow (5d, scaffolded)
|
||||||
|
|
||||||
|
> Companion to `BuildLog.md`. Predecessor: `2026-05-02-pwa-and-polish.md`. Closes the no-keys-required portion of Phase 5d.
|
||||||
|
|
||||||
|
## Milestone
|
||||||
|
|
||||||
|
Full Stripe deposit-payment flow is scaffolded end-to-end. Code is wired and typed and the DB-side payment helpers are unit-tested. **Live testing requires real Stripe test keys** — see "How to finish" below. Without keys, the codebase still compiles and ships; the deposit branch in `/book/confirm` only activates when `stripeConfigured()` returns true, so non-deposit services and the existing flow continue to work unchanged.
|
||||||
|
|
||||||
|
## What landed
|
||||||
|
|
||||||
|
| Path | Role |
|
||||||
|
|---|---|
|
||||||
|
| `src/lib/stripe.ts` | Lazy server-side `Stripe` client + `stripeConfigured()` capability check. |
|
||||||
|
| `src/lib/payments.ts` | `createDepositIntentForBooking` (creates PI, persists id), `recordPaymentSucceeded` (HOLD→CONFIRMED + Payment row, idempotent on replay), `recordPaymentFailed` (HOLD→CANCELLED with reason), `confirmAfterPayment` (sends booking confirmation). |
|
||||||
|
| `src/app/api/stripe/webhook/route.ts` | POST endpoint at `/api/stripe/webhook`. Verifies signature against `STRIPE_WEBHOOK_SECRET`. Handles `payment_intent.succeeded` and `payment_intent.payment_failed`; acks unknown events so Stripe stops retrying. |
|
||||||
|
| `src/components/PaymentForm.tsx` | **First real client component** — Stripe Elements (PaymentElement) wrapped in `<Elements>` provider. Cached `loadStripe` promise. Submit → `stripe.confirmPayment` → Stripe redirects to return URL. |
|
||||||
|
| `src/app/book/pay/[id]/page.tsx` | Server-renders the booking summary + Payment Element. Owner-only auth. Bounces to `/book/done` if booking is already CONFIRMED, or to `/account/bookings` if it's terminal. Shows a friendly "payments not configured" state when `STRIPE_*` env vars aren't set. |
|
||||||
|
| `src/app/book/confirm/page.tsx` | Confirm action now branches: if `service.depositCents > 0 && stripeConfigured()`, creates the PI and redirects to `/book/pay/[id]`. Otherwise current behavior (immediate CONFIRMED + email). |
|
||||||
|
| `src/app/book/done/page.tsx` | Adapts copy when booking is still HOLD (waiting for webhook): "Processing your payment". Also handles the CANCELLED case explicitly. |
|
||||||
|
| `test/payments.test.ts` | 7 tests for payment helpers — succeeded transitions HOLD→CONFIRMED + writes Payment row, idempotent on replay, doesn't resurrect CANCELLED, fails-on-unknown-PI; failed cancels HOLDs with reason, doesn't change CONFIRMED status, fails-on-unknown-PI. |
|
||||||
|
| `.env`, `.env.example` | Stripe placeholders: `STRIPE_SECRET_KEY`, `STRIPE_PUBLISHABLE_KEY`, `STRIPE_WEBHOOK_SECRET`. |
|
||||||
|
|
||||||
|
## What's verified
|
||||||
|
|
||||||
|
- `pnpm test` — **86/86 green** (was 79; +7 new payment tests)
|
||||||
|
- `pnpm lint` — clean
|
||||||
|
- `pnpm exec tsc --noEmit` — clean
|
||||||
|
- All deposit-flow code paths are typed against the real Stripe SDK (`stripe@22.1.0`, `@stripe/react-stripe-js@6.3.0`, `@stripe/stripe-js@9.4.0`).
|
||||||
|
|
||||||
|
**Not verified (needs real keys)**: Stripe Elements rendering, `confirmPayment` round-trip, real webhook signature, end-to-end booking-with-deposit happy path, end-to-end payment failure.
|
||||||
|
|
||||||
|
## Decisions ratified
|
||||||
|
|
||||||
|
| Decision | Resolution |
|
||||||
|
|---|---|
|
||||||
|
| Booking lifecycle with deposit | HOLD → (Stripe Payment Element via `/book/pay/[id]`) → webhook fires `payment_intent.succeeded` → CONFIRMED + email. Asynchronous webhook is the source of truth. |
|
||||||
|
| Hold expiry safety | Existing `expireStaleHolds` job continues to run; if the customer abandons the payment page, the HOLD expires after 10 min and the slot frees up. |
|
||||||
|
| Source of truth for payment status | The webhook. The page redirect after `confirmPayment` is just UX — `/book/done` displays "Processing" if the booking is still HOLD when they land. |
|
||||||
|
| `client_secret` storage | NOT stored in our DB. Retrieved from Stripe via `paymentIntents.retrieve` when re-rendering `/book/pay`. Reason: client_secret is sensitive and Stripe recommends not persisting it. |
|
||||||
|
| Payment row uniqueness | `Payment.stripePaymentIntentId @unique` already in schema. Webhook handler `upsert`s on this — ensures idempotency on duplicate webhook deliveries. |
|
||||||
|
| Event acknowledgment | Always 200 for unknown events (Stripe will stop retrying). 400 for missing/invalid signature. 500 only for handler exceptions (so Stripe retries on transient errors). |
|
||||||
|
| Capability detection | `stripeConfigured()` checks all three env vars. Used by `/book/confirm` to decide whether to route to payment, and by `/book/pay` to render a friendly "not configured" state. Lets the app run without Stripe for non-deposit services. |
|
||||||
|
| Fallback when payments aren't configured | Services with deposit > 0 that get booked through a non-Stripe path proceed straight to CONFIRMED without payment. Reason: the deposit is an expectation between the practice and the customer; in dev or while Stripe is being set up, we don't want bookings to fail. |
|
||||||
|
| Failure semantics | `payment_intent.payment_failed` cancels the HOLD with `cancelReason="payment failed"`. The customer can re-try by booking again from scratch. We don't currently support a "try a different card" flow on the same booking. |
|
||||||
|
| Money math precision | All amounts in integer cents end-to-end. No `Number` rounding anywhere. |
|
||||||
|
| First Client Component | `src/components/PaymentForm.tsx`. Necessary because Stripe.js is browser-only. We previously kept all UI server-rendered; this is the first deliberate exception. |
|
||||||
|
|
||||||
|
## Gotchas hit
|
||||||
|
|
||||||
|
None this session — clean scaffold.
|
||||||
|
|
||||||
|
## Open items / known gaps (without real keys)
|
||||||
|
|
||||||
|
These are deliberately deferred until you provide keys:
|
||||||
|
|
||||||
|
1. **Visual confirmation** that PaymentElement renders correctly with our theme.
|
||||||
|
2. **Real `payment_intent.succeeded` handling** end-to-end (Stripe CLI → webhook → DB → email). Code is correct; just needs to run.
|
||||||
|
3. **Signed webhook** verification — handler logic is correct but the signature constant-time comparison only fires with a real secret.
|
||||||
|
4. **Edge cases** like 3DS challenge flows, declined cards, post-redirect race when the user lands on /book/done before the webhook fires.
|
||||||
|
|
||||||
|
## How to finish (when you provide keys)
|
||||||
|
|
||||||
|
1. **Get Stripe test keys**: https://dashboard.stripe.com/test/apikeys → `sk_test_…` and `pk_test_…`. Drop both in `.env`.
|
||||||
|
2. **Run the Stripe CLI** for webhook forwarding:
|
||||||
|
```bash
|
||||||
|
brew install stripe/stripe-cli/stripe # if needed
|
||||||
|
stripe login # one-time
|
||||||
|
stripe listen --forward-to localhost:3000/api/stripe/webhook
|
||||||
|
```
|
||||||
|
The CLI prints a `whsec_…` — paste into `.env` as `STRIPE_WEBHOOK_SECRET`.
|
||||||
|
3. **Restart `pnpm dev`** so the new env vars are picked up.
|
||||||
|
4. **Test the deposit flow** as a customer:
|
||||||
|
```bash
|
||||||
|
pnpm db:seed
|
||||||
|
# /60-minute Swedish has $20 deposit per the seed
|
||||||
|
pnpm tsx scripts/book-on-behalf.ts alex@example.com "60-minute Swedish" 2026-05-05T10:00
|
||||||
|
# OR via the public flow:
|
||||||
|
# /book → click 60-min Swedish → pick a date → pick a slot → /book/confirm → /book/pay/[id]
|
||||||
|
```
|
||||||
|
Use Stripe test card `4242 4242 4242 4242`, any future expiry, any CVC, any zip.
|
||||||
|
5. **Watch the Stripe CLI** output to see the webhook fire. Watch Mailpit (`http://localhost:8025`) for the confirmation email.
|
||||||
|
6. **Verify in DB**: `SELECT id, status, "paymentStatus", "stripePaymentIntentId" FROM "Booking" ORDER BY "createdAt" DESC LIMIT 1;` — should show `CONFIRMED` + `CAPTURED` + a `pi_…` id.
|
||||||
|
7. **Test failure**: use card `4000 0000 0000 9995` (declined) — booking should land in CANCELLED with `cancelReason="payment failed"`.
|
||||||
|
|
||||||
|
## Roadmap status
|
||||||
|
|
||||||
|
Backend roadmap:
|
||||||
|
|
||||||
|
- 1–4 done
|
||||||
|
- 5a + 5b + 5c.1 + 5c.2 done
|
||||||
|
- UX phases A–E done
|
||||||
|
- **5d Stripe — scaffolded 2026-05-02 (this session). Awaits keys + live verification.**
|
||||||
|
- 5e Email reminders (pg-boss scheduled jobs) — next
|
||||||
|
|
||||||
|
## Recommended next step
|
||||||
|
|
||||||
|
**5e — email reminders** can be done in parallel with you setting up Stripe keys, since they're orthogonal:
|
||||||
|
|
||||||
|
- Add pg-boss (we already chose it; just `pnpm add pg-boss`)
|
||||||
|
- Migration: pg-boss creates its own schema in our Postgres
|
||||||
|
- Worker process that runs reminder jobs on a schedule
|
||||||
|
- `sendBookingReminder` email template (24h-before)
|
||||||
|
- Trigger: when a booking is CONFIRMED, schedule its reminder; on cancel/reschedule, deschedule
|
||||||
|
- ~2–3 days
|
||||||
|
|
||||||
|
After 5e, **v1 is feature-complete for soft launch** — modulo whatever real-Stripe-test-mode reveals in 5d verification.
|
||||||
|
|
||||||
|
## How to resume
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/noise/Documents/code/touchbase
|
||||||
|
docker-compose up -d postgres mailpit
|
||||||
|
pnpm db:seed
|
||||||
|
pnpm dev
|
||||||
|
# Without Stripe keys: deposit-required services (60-min Swedish, etc.) currently
|
||||||
|
# proceed to confirmed-without-payment because stripeConfigured() returns false.
|
||||||
|
# This means existing flows still work for development.
|
||||||
|
#
|
||||||
|
# With Stripe keys configured: see "How to finish" above.
|
||||||
|
```
|
||||||
@@ -25,6 +25,8 @@
|
|||||||
"@auth/prisma-adapter": "^2.11.2",
|
"@auth/prisma-adapter": "^2.11.2",
|
||||||
"@prisma/adapter-pg": "^7.8.0",
|
"@prisma/adapter-pg": "^7.8.0",
|
||||||
"@prisma/client": "^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": "^4.1.0",
|
||||||
"date-fns-tz": "^3.2.0",
|
"date-fns-tz": "^3.2.0",
|
||||||
"next": "16.2.4",
|
"next": "16.2.4",
|
||||||
@@ -33,6 +35,7 @@
|
|||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"stripe": "^22.1.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
42
pnpm-lock.yaml
generated
42
pnpm-lock.yaml
generated
@@ -17,6 +17,12 @@ importers:
|
|||||||
'@prisma/client':
|
'@prisma/client':
|
||||||
specifier: ^7.8.0
|
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)
|
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:
|
date-fns:
|
||||||
specifier: ^4.1.0
|
specifier: ^4.1.0
|
||||||
version: 4.1.0
|
version: 4.1.0
|
||||||
@@ -41,6 +47,9 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: 19.2.4
|
specifier: 19.2.4
|
||||||
version: 19.2.4(react@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:
|
zod:
|
||||||
specifier: ^4.3.6
|
specifier: ^4.3.6
|
||||||
version: 4.3.6
|
version: 4.3.6
|
||||||
@@ -908,6 +917,17 @@ packages:
|
|||||||
'@standard-schema/spec@1.1.0':
|
'@standard-schema/spec@1.1.0':
|
||||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
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':
|
'@swc/helpers@0.5.15':
|
||||||
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
||||||
|
|
||||||
@@ -2677,6 +2697,15 @@ packages:
|
|||||||
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
|
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
|
||||||
engines: {node: '>=8'}
|
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:
|
styled-jsx@5.1.6:
|
||||||
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
|
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
@@ -3626,6 +3655,15 @@ snapshots:
|
|||||||
|
|
||||||
'@standard-schema/spec@1.1.0': {}
|
'@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':
|
'@swc/helpers@0.5.15':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
@@ -5614,6 +5652,10 @@ snapshots:
|
|||||||
|
|
||||||
strip-json-comments@3.1.1: {}
|
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):
|
styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
client-only: 0.0.1
|
client-only: 0.0.1
|
||||||
|
|||||||
7
public/icon-mask.svg
Normal file
7
public/icon-mask.svg
Normal 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
6
public/icon.svg
Normal 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
106
public/sw.js
Normal 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 });
|
||||||
|
}
|
||||||
164
src/app/account/bookings/[id]/page.tsx
Normal file
164
src/app/account/bookings/[id]/page.tsx
Normal 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`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db";
|
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"
|
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 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="font-medium">{b.service.name}</div>
|
||||||
<div className="text-sm text-zinc-600 dark:text-zinc-400">
|
<div className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
{formatLocalLong(b.startsAt)}
|
{formatLocalLong(b.startsAt)}
|
||||||
@@ -116,16 +117,24 @@ export default async function MyBookingsPage() {
|
|||||||
<div className="mt-1 text-xs text-zinc-500">
|
<div className="mt-1 text-xs text-zinc-500">
|
||||||
{b.service.durationMin} min
|
{b.service.durationMin} min
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Link>
|
||||||
<form action={cancelAction}>
|
<div className="flex flex-col items-end gap-2">
|
||||||
<input type="hidden" name="bookingId" value={b.id} />
|
<Link
|
||||||
<button
|
href={`/book?serviceId=${b.serviceId}&fromBookingId=${b.id}`}
|
||||||
type="submit"
|
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"
|
||||||
className="rounded-md border border-red-300 px-3 py-1.5 text-sm text-red-700 hover:bg-red-50 dark:border-red-800 dark:text-red-300 dark:hover:bg-red-950/40"
|
|
||||||
>
|
>
|
||||||
Cancel
|
Reschedule
|
||||||
</button>
|
</Link>
|
||||||
</form>
|
<form action={cancelAction}>
|
||||||
|
<input type="hidden" name="bookingId" value={b.id} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-md border border-red-300 px-3 py-1.5 text-sm text-red-700 hover:bg-red-50 dark:border-red-800 dark:text-red-300 dark:hover:bg-red-950/40"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
251
src/app/admin/bookings/[id]/page.tsx
Normal file
251
src/app/admin/bookings/[id]/page.tsx
Normal 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`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -273,7 +273,7 @@ export default async function NewBookingPage({
|
|||||||
No available slots on this date for the selected service.
|
No available slots on this date for the selected service.
|
||||||
</p>
|
</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) => (
|
{slots.map((slot) => (
|
||||||
<form key={slot.startsAt.toISOString()} action={bookSlotAction}>
|
<form key={slot.startsAt.toISOString()} action={bookSlotAction}>
|
||||||
<input type="hidden" name="serviceId" value={serviceId} />
|
<input type="hidden" name="serviceId" value={serviceId} />
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export default async function BookingsPage() {
|
|||||||
No upcoming bookings.
|
No upcoming bookings.
|
||||||
</p>
|
</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">
|
<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">
|
<thead className="bg-zinc-50 text-left text-xs uppercase tracking-wide text-zinc-500 dark:bg-zinc-900">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -69,7 +69,9 @@ export default async function BookingsPage() {
|
|||||||
{bookings.map((b) => (
|
{bookings.map((b) => (
|
||||||
<tr key={b.id}>
|
<tr key={b.id}>
|
||||||
<td className="px-4 py-2 whitespace-nowrap font-mono text-xs">
|
<td className="px-4 py-2 whitespace-nowrap font-mono text-xs">
|
||||||
{formatLocal(b.startsAt)}
|
<Link href={`/admin/bookings/${b.id}`} className="hover:underline">
|
||||||
|
{formatLocal(b.startsAt)}
|
||||||
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<div>{b.customer.name ?? <span className="text-zinc-500 italic">unnamed</span>}</div>
|
<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
|
{b.service.durationMin}m
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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">{b.room.name}</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export default async function RoomsPage() {
|
|||||||
No rooms yet.
|
No rooms yet.
|
||||||
</p>
|
</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">
|
<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">
|
<thead className="bg-zinc-50 text-left text-xs uppercase tracking-wide text-zinc-500 dark:bg-zinc-900">
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export default async function ServicesPage() {
|
|||||||
No services yet.
|
No services yet.
|
||||||
</p>
|
</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">
|
<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">
|
<thead className="bg-zinc-50 text-left text-xs uppercase tracking-wide text-zinc-500 dark:bg-zinc-900">
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export default async function TherapistsPage() {
|
|||||||
No therapists yet.
|
No therapists yet.
|
||||||
</p>
|
</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">
|
<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">
|
<thead className="bg-zinc-50 text-left text-xs uppercase tracking-wide text-zinc-500 dark:bg-zinc-900">
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
75
src/app/api/stripe/webhook/route.ts
Normal file
75
src/app/api/stripe/webhook/route.ts
Normal 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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,18 @@ import { auth } from "@/auth";
|
|||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { findSlots } from "@/lib/availability";
|
import { findSlots } from "@/lib/availability";
|
||||||
import { loadAvailabilityState } from "@/lib/availability-loader";
|
import { loadAvailabilityState } from "@/lib/availability-loader";
|
||||||
import { BookingConflictError, confirmHold, createHold } from "@/lib/booking";
|
import {
|
||||||
import { sendBookingConfirmation } from "@/lib/email";
|
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 metadata = { title: "Confirm booking — TouchBase" };
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -17,6 +27,7 @@ type SearchParams = Promise<{
|
|||||||
serviceId?: string;
|
serviceId?: string;
|
||||||
startsAtIso?: string;
|
startsAtIso?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
fromBookingId?: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
function formatLocalLong(d: Date): string {
|
function formatLocalLong(d: Date): string {
|
||||||
@@ -36,10 +47,12 @@ async function confirmBookingAction(formData: FormData): Promise<void> {
|
|||||||
const serviceId = String(formData.get("serviceId") ?? "");
|
const serviceId = String(formData.get("serviceId") ?? "");
|
||||||
const startsAtIso = String(formData.get("startsAtIso") ?? "");
|
const startsAtIso = String(formData.get("startsAtIso") ?? "");
|
||||||
const name = String(formData.get("name") ?? "").trim();
|
const name = String(formData.get("name") ?? "").trim();
|
||||||
|
const fromBookingId = String(formData.get("fromBookingId") ?? "");
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
const next = new URLSearchParams({ serviceId, startsAtIso });
|
const next = new URLSearchParams({ serviceId, startsAtIso });
|
||||||
|
if (fromBookingId) next.set("fromBookingId", fromBookingId);
|
||||||
redirect(`/login?callbackUrl=${encodeURIComponent(`/book/confirm?${next}`)}`);
|
redirect(`/login?callbackUrl=${encodeURIComponent(`/book/confirm?${next}`)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +79,7 @@ async function confirmBookingAction(formData: FormData): Promise<void> {
|
|||||||
startsAtIso,
|
startsAtIso,
|
||||||
error: "Could not compute availability — please try again.",
|
error: "Could not compute availability — please try again.",
|
||||||
});
|
});
|
||||||
|
if (fromBookingId) params.set("fromBookingId", fromBookingId);
|
||||||
redirect(`/book/confirm?${params}`);
|
redirect(`/book/confirm?${params}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,27 +98,63 @@ async function confirmBookingAction(formData: FormData): Promise<void> {
|
|||||||
date: startsAtIso.slice(0, 10),
|
date: startsAtIso.slice(0, 10),
|
||||||
taken: "1",
|
taken: "1",
|
||||||
});
|
});
|
||||||
|
if (fromBookingId) back.set("fromBookingId", fromBookingId);
|
||||||
redirect(`/book?${back}`);
|
redirect(`/book?${back}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const hold = await createHold(db, {
|
if (fromBookingId) {
|
||||||
customerId: userId,
|
// Reschedule path: validate ownership, then atomic cancel-and-rebook.
|
||||||
serviceId,
|
const old = await db.booking.findUnique({
|
||||||
therapistId: slot.candidateTherapistIds[0],
|
where: { id: fromBookingId },
|
||||||
roomId: slot.candidateRoomIds[0],
|
select: { customerId: true },
|
||||||
startsAt,
|
});
|
||||||
});
|
if (!old || old.customerId !== userId) redirect("/account/bookings");
|
||||||
await confirmHold(db, hold.id);
|
|
||||||
await sendBookingConfirmation({ db, bookingId: hold.id });
|
const result = await rescheduleBooking(db, {
|
||||||
redirect(`/book/done?bookingId=${hold.id}`);
|
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,
|
||||||
|
therapistId: slot.candidateTherapistIds[0],
|
||||||
|
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) {
|
} catch (e) {
|
||||||
if (e instanceof BookingConflictError) {
|
if (e instanceof BookingConflictError) {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
serviceId,
|
serviceId,
|
||||||
date: startsAtIso.slice(0, 10),
|
date: startsAtIso.slice(0, 10),
|
||||||
|
taken: "1",
|
||||||
});
|
});
|
||||||
redirect(`/book?${params}&taken=1`);
|
if (fromBookingId) params.set("fromBookingId", fromBookingId);
|
||||||
|
redirect(`/book?${params}`);
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
@@ -116,12 +166,13 @@ export default async function ConfirmPage({
|
|||||||
searchParams: SearchParams;
|
searchParams: SearchParams;
|
||||||
}) {
|
}) {
|
||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
const { serviceId, startsAtIso } = params;
|
const { serviceId, startsAtIso, fromBookingId } = params;
|
||||||
if (!serviceId || !startsAtIso) redirect("/");
|
if (!serviceId || !startsAtIso) redirect("/");
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
const next = new URLSearchParams({ serviceId, startsAtIso });
|
const next = new URLSearchParams({ serviceId, startsAtIso });
|
||||||
|
if (fromBookingId) next.set("fromBookingId", fromBookingId);
|
||||||
redirect(`/login?callbackUrl=${encodeURIComponent(`/book/confirm?${next}`)}`);
|
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">
|
<main className="mx-auto w-full max-w-md flex-1 p-8">
|
||||||
<h1 className="mb-2 text-2xl font-semibold tracking-tight">
|
<h1 className="mb-2 text-2xl font-semibold tracking-tight">
|
||||||
Confirm your booking
|
{fromBookingId ? "Confirm reschedule" : "Confirm your booking"}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mb-6 text-sm text-zinc-600 dark:text-zinc-400">
|
<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>
|
</p>
|
||||||
|
|
||||||
{params.error && (
|
{params.error && (
|
||||||
@@ -201,6 +254,9 @@ export default async function ConfirmPage({
|
|||||||
>
|
>
|
||||||
<input type="hidden" name="serviceId" value={serviceId} />
|
<input type="hidden" name="serviceId" value={serviceId} />
|
||||||
<input type="hidden" name="startsAtIso" value={startsAtIso} />
|
<input type="hidden" name="startsAtIso" value={startsAtIso} />
|
||||||
|
{fromBookingId && (
|
||||||
|
<input type="hidden" name="fromBookingId" value={fromBookingId} />
|
||||||
|
)}
|
||||||
|
|
||||||
{needsName && (
|
{needsName && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
@@ -229,7 +285,14 @@ export default async function ConfirmPage({
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<Link
|
<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"
|
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
|
← Pick a different time
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ export default async function DonePage({
|
|||||||
});
|
});
|
||||||
if (!booking) redirect("/");
|
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 (
|
return (
|
||||||
<div className="flex flex-1 flex-col">
|
<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">
|
<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">
|
<main className="mx-auto w-full max-w-md flex-1 p-8">
|
||||||
<h1 className="mb-2 text-2xl font-semibold tracking-tight">
|
<h1 className="mb-2 text-2xl font-semibold tracking-tight">
|
||||||
You're booked
|
{isCancelled
|
||||||
|
? "Booking cancelled"
|
||||||
|
: isProcessing
|
||||||
|
? "Processing your payment"
|
||||||
|
: "You’re booked"}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mb-6 text-sm text-zinc-600 dark:text-zinc-400">
|
<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 didn’t cancel, please contact us."
|
||||||
|
: isProcessing
|
||||||
|
? "Hold tight — we’re 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>
|
</p>
|
||||||
|
|
||||||
<div className="rounded-lg border border-zinc-200 bg-white p-5 dark:border-zinc-800 dark:bg-zinc-950">
|
<div className="rounded-lg border border-zinc-200 bg-white p-5 dark:border-zinc-800 dark:bg-zinc-950">
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ const TZ = process.env.APP_TZ ?? "America/Detroit";
|
|||||||
type SearchParams = Promise<{
|
type SearchParams = Promise<{
|
||||||
serviceId?: string;
|
serviceId?: string;
|
||||||
date?: 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 {
|
function formatLocalTime(d: Date): string {
|
||||||
@@ -47,7 +49,10 @@ async function pickDateAction(formData: FormData): Promise<void> {
|
|||||||
"use server";
|
"use server";
|
||||||
const serviceId = String(formData.get("serviceId") ?? "");
|
const serviceId = String(formData.get("serviceId") ?? "");
|
||||||
const date = String(formData.get("date") ?? "");
|
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({
|
export default async function BookPage({
|
||||||
@@ -58,11 +63,30 @@ export default async function BookPage({
|
|||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const serviceId = params.serviceId;
|
const serviceId = params.serviceId;
|
||||||
|
const fromBookingId = params.fromBookingId;
|
||||||
if (!serviceId) redirect("/");
|
if (!serviceId) redirect("/");
|
||||||
|
|
||||||
const service = await db.service.findUnique({ where: { id: serviceId } });
|
const service = await db.service.findUnique({ where: { id: serviceId } });
|
||||||
if (!service || !service.active) redirect("/");
|
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 todayISO = todayLocalISO();
|
||||||
const date = params.date ?? todayISO;
|
const date = params.date ?? todayISO;
|
||||||
|
|
||||||
@@ -132,11 +156,25 @@ export default async function BookPage({
|
|||||||
)}
|
)}
|
||||||
</section>
|
</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
|
<form
|
||||||
action={pickDateAction}
|
action={pickDateAction}
|
||||||
className="mb-6 rounded-md border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-950"
|
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} />
|
<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">
|
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-zinc-500">
|
||||||
Choose a date
|
Choose a date
|
||||||
</label>
|
</label>
|
||||||
@@ -167,16 +205,17 @@ export default async function BookPage({
|
|||||||
No available times on this date. Try another day.
|
No available times on this date. Try another day.
|
||||||
</p>
|
</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) => {
|
{slots.map((slot) => {
|
||||||
const params = new URLSearchParams({
|
const slotParams = new URLSearchParams({
|
||||||
serviceId,
|
serviceId,
|
||||||
startsAtIso: slot.startsAt.toISOString(),
|
startsAtIso: slot.startsAt.toISOString(),
|
||||||
});
|
});
|
||||||
|
if (fromBookingId) slotParams.set("fromBookingId", fromBookingId);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={slot.startsAt.toISOString()}
|
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"
|
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)}
|
{formatLocalTime(slot.startsAt)}
|
||||||
|
|||||||
119
src/app/book/pay/[id]/page.tsx
Normal file
119
src/app/book/pay/[id]/page.tsx
Normal 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'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
46
src/app/error.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
@@ -15,8 +15,39 @@ const geistMono = Geist_Mono({
|
|||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "TouchBase",
|
title: "TouchBase",
|
||||||
description: "Massage practice scheduling",
|
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({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
@@ -27,7 +58,19 @@ export default function RootLayout({
|
|||||||
lang="en"
|
lang="en"
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
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>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
28
src/app/manifest.ts
Normal file
28
src/app/manifest.ts
Normal 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
20
src/app/not-found.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -70,7 +70,7 @@ export default async function TherapistBookingsPage() {
|
|||||||
No upcoming appointments.
|
No upcoming appointments.
|
||||||
</p>
|
</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">
|
<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">
|
<thead className="bg-zinc-50 text-left text-xs uppercase tracking-wide text-zinc-500 dark:bg-zinc-900">
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
101
src/components/PaymentForm.tsx
Normal file
101
src/components/PaymentForm.tsx
Normal 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 };
|
||||||
@@ -193,6 +193,157 @@ export async function cancelBooking(
|
|||||||
return { kind: "cancelled", alreadyCancelled: false };
|
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.
|
* Sweep expired holds. Run periodically (pg-boss job, eventually). Returns count.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -160,6 +160,72 @@ function formatLocal(d: Date, tz: string): string {
|
|||||||
}).format(d);
|
}).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
|
// Booking cancellation
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
186
src/lib/payments.ts
Normal file
186
src/lib/payments.ts
Normal 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
30
src/lib/stripe.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -7,6 +7,9 @@ import {
|
|||||||
confirmHold,
|
confirmHold,
|
||||||
createHold,
|
createHold,
|
||||||
expireStaleHolds,
|
expireStaleHolds,
|
||||||
|
markComplete,
|
||||||
|
markNoShow,
|
||||||
|
rescheduleBooking,
|
||||||
} from "@/lib/booking";
|
} from "@/lib/booking";
|
||||||
import { seed, type SeedResult } from "@/lib/seed";
|
import { seed, type SeedResult } from "@/lib/seed";
|
||||||
|
|
||||||
@@ -309,3 +312,168 @@ describe("expireStaleHolds", () => {
|
|||||||
expect(staleRow?.cancelReason).toBe("hold expired");
|
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
153
test/payments.test.ts
Normal 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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user