197 lines
6.1 KiB
TypeScript
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);
|
|
}
|