|
- {formatLocal(b.startsAt)}
+
+ {formatLocal(b.startsAt)}
+
|
{b.customer.name ?? unnamed}
@@ -81,7 +83,9 @@ export default async function BookingsPage() {
{b.service.durationMin}m
|
- {b.therapist.user.name} |
+
+ {b.therapist.user.name ?? unnamed}
+ |
{b.room.name} |
) : (
-
+
diff --git a/src/app/admin/services/page.tsx b/src/app/admin/services/page.tsx
index 11dcf03..09430df 100644
--- a/src/app/admin/services/page.tsx
+++ b/src/app/admin/services/page.tsx
@@ -27,7 +27,7 @@ export default async function ServicesPage() {
No services yet.
) : (
-
+
diff --git a/src/app/admin/therapists/page.tsx b/src/app/admin/therapists/page.tsx
index 98204c1..b485dd6 100644
--- a/src/app/admin/therapists/page.tsx
+++ b/src/app/admin/therapists/page.tsx
@@ -31,7 +31,7 @@ export default async function TherapistsPage() {
No therapists yet.
) : (
-
+
diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts
new file mode 100644
index 0000000..dbf6731
--- /dev/null
+++ b/src/app/api/stripe/webhook/route.ts
@@ -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 {
+ 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 },
+ );
+ }
+}
diff --git a/src/app/book/confirm/page.tsx b/src/app/book/confirm/page.tsx
index 920ced9..5f869b4 100644
--- a/src/app/book/confirm/page.tsx
+++ b/src/app/book/confirm/page.tsx
@@ -5,8 +5,18 @@ import { auth } from "@/auth";
import { db } from "@/lib/db";
import { findSlots } from "@/lib/availability";
import { loadAvailabilityState } from "@/lib/availability-loader";
-import { BookingConflictError, confirmHold, createHold } from "@/lib/booking";
-import { sendBookingConfirmation } from "@/lib/email";
+import {
+ BookingConflictError,
+ confirmHold,
+ createHold,
+ rescheduleBooking,
+} from "@/lib/booking";
+import {
+ sendBookingConfirmation,
+ sendBookingRescheduled,
+} from "@/lib/email";
+import { stripeConfigured } from "@/lib/stripe";
+import { createDepositIntentForBooking } from "@/lib/payments";
export const metadata = { title: "Confirm booking — TouchBase" };
export const dynamic = "force-dynamic";
@@ -17,6 +27,7 @@ type SearchParams = Promise<{
serviceId?: string;
startsAtIso?: string;
error?: string;
+ fromBookingId?: string;
}>;
function formatLocalLong(d: Date): string {
@@ -36,10 +47,12 @@ async function confirmBookingAction(formData: FormData): Promise {
const serviceId = String(formData.get("serviceId") ?? "");
const startsAtIso = String(formData.get("startsAtIso") ?? "");
const name = String(formData.get("name") ?? "").trim();
+ const fromBookingId = String(formData.get("fromBookingId") ?? "");
const session = await auth();
if (!session?.user) {
const next = new URLSearchParams({ serviceId, startsAtIso });
+ if (fromBookingId) next.set("fromBookingId", fromBookingId);
redirect(`/login?callbackUrl=${encodeURIComponent(`/book/confirm?${next}`)}`);
}
@@ -66,6 +79,7 @@ async function confirmBookingAction(formData: FormData): Promise {
startsAtIso,
error: "Could not compute availability — please try again.",
});
+ if (fromBookingId) params.set("fromBookingId", fromBookingId);
redirect(`/book/confirm?${params}`);
}
@@ -84,27 +98,63 @@ async function confirmBookingAction(formData: FormData): Promise {
date: startsAtIso.slice(0, 10),
taken: "1",
});
+ if (fromBookingId) back.set("fromBookingId", fromBookingId);
redirect(`/book?${back}`);
}
try {
- const hold = await createHold(db, {
- customerId: userId,
- serviceId,
- therapistId: slot.candidateTherapistIds[0],
- roomId: slot.candidateRoomIds[0],
- startsAt,
- });
- await confirmHold(db, hold.id);
- await sendBookingConfirmation({ db, bookingId: hold.id });
- redirect(`/book/done?bookingId=${hold.id}`);
+ if (fromBookingId) {
+ // Reschedule path: validate ownership, then atomic cancel-and-rebook.
+ const old = await db.booking.findUnique({
+ where: { id: fromBookingId },
+ select: { customerId: true },
+ });
+ if (!old || old.customerId !== userId) redirect("/account/bookings");
+
+ const result = await rescheduleBooking(db, {
+ oldBookingId: fromBookingId,
+ newStartsAt: startsAt,
+ newServiceId: serviceId,
+ newTherapistId: slot.candidateTherapistIds[0],
+ newRoomId: slot.candidateRoomIds[0],
+ cancelledByUserId: userId,
+ });
+ await sendBookingRescheduled({
+ db,
+ oldBookingId: result.oldBookingId,
+ newBookingId: result.newBookingId,
+ });
+ redirect(`/book/done?bookingId=${result.newBookingId}`);
+ } else {
+ const hold = await createHold(db, {
+ customerId: userId,
+ serviceId,
+ 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) {
if (e instanceof BookingConflictError) {
const params = new URLSearchParams({
serviceId,
date: startsAtIso.slice(0, 10),
+ taken: "1",
});
- redirect(`/book?${params}&taken=1`);
+ if (fromBookingId) params.set("fromBookingId", fromBookingId);
+ redirect(`/book?${params}`);
}
throw e;
}
@@ -116,12 +166,13 @@ export default async function ConfirmPage({
searchParams: SearchParams;
}) {
const params = await searchParams;
- const { serviceId, startsAtIso } = params;
+ const { serviceId, startsAtIso, fromBookingId } = params;
if (!serviceId || !startsAtIso) redirect("/");
const session = await auth();
if (!session?.user) {
const next = new URLSearchParams({ serviceId, startsAtIso });
+ if (fromBookingId) next.set("fromBookingId", fromBookingId);
redirect(`/login?callbackUrl=${encodeURIComponent(`/book/confirm?${next}`)}`);
}
@@ -155,10 +206,12 @@ export default async function ConfirmPage({
- Confirm your booking
+ {fromBookingId ? "Confirm reschedule" : "Confirm your booking"}
- Almost done — review and confirm.
+ {fromBookingId
+ ? "Your previous booking will be cancelled and replaced with this one."
+ : "Almost done — review and confirm."}
{params.error && (
@@ -201,6 +254,9 @@ export default async function ConfirmPage({
>
+ {fromBookingId && (
+
+ )}
{needsName && (
@@ -229,7 +285,14 @@ export default async function ConfirmPage({
{
+ const back = new URLSearchParams({
+ serviceId,
+ date: startsAtIso.slice(0, 10),
+ });
+ if (fromBookingId) back.set("fromBookingId", fromBookingId);
+ return `/book?${back}`;
+ })()}
className="mt-4 block text-center text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
>
← Pick a different time
diff --git a/src/app/book/done/page.tsx b/src/app/book/done/page.tsx
index 9f89539..bdfa821 100644
--- a/src/app/book/done/page.tsx
+++ b/src/app/book/done/page.tsx
@@ -38,6 +38,12 @@ export default async function DonePage({
});
if (!booking) redirect("/");
+ // Stripe redirects here right after `confirmPayment`, but the
+ // `payment_intent.succeeded` webhook may not have fired yet — the booking
+ // can still be HOLD for a few seconds. Show "processing" copy in that case.
+ const isProcessing = booking.status === "HOLD";
+ const isCancelled = booking.status === "CANCELLED";
+
return (
|