add Stripe
This commit is contained in:
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 });
|
||||
}
|
||||
Reference in New Issue
Block a user