Files
touchbase/src/lib/payments.ts
2026-05-03 09:43:10 -04:00

197 lines
6.1 KiB
TypeScript

// 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";
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<void> {
const booking = await db.booking.findUnique({
where: { id: bookingId },
select: { startsAt: true },
});
if (!booking) return;
await sendBookingConfirmation({ db, bookingId });
await scheduleReminderForBooking(bookingId, booking.startsAt);
}