// 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 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"; import { scheduleReminderForBooking } from "@/lib/reminders"; /** * 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 after a payment succeeds and the booking * transitions to CONFIRMED — sends the confirmation email and schedules the * 24h reminder. */ export async function confirmAfterPayment( db: PrismaClient, bookingId: string, ): Promise { const booking = await db.booking.findUnique({ where: { id: bookingId }, select: { startsAt: true }, }); if (!booking) return; await sendBookingConfirmation({ db, bookingId }); await scheduleReminderForBooking(bookingId, booking.startsAt); }