Add booking checkout and webhook flow
This commit is contained in:
412
src/lib/payments.ts
Normal file
412
src/lib/payments.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
import Stripe from 'stripe';
|
||||
import { BookingStatus, PaymentStatus } from '@prisma/client';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import {
|
||||
bookingCatalog,
|
||||
formatPoundsFromCents,
|
||||
quoteStay,
|
||||
type BookingQuote,
|
||||
type BookingSearchInput,
|
||||
} from '@/lib/booking';
|
||||
|
||||
export type BookingCheckoutInput = BookingSearchInput & {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
specialRequests?: string;
|
||||
termsAccepted: boolean;
|
||||
};
|
||||
|
||||
export type BookingCheckoutResult = {
|
||||
bookingId: string;
|
||||
paymentId: string;
|
||||
checkoutUrl: string;
|
||||
checkoutMode: 'stripe' | 'mock';
|
||||
quote: BookingQuote;
|
||||
};
|
||||
|
||||
type PaymentEventResult = {
|
||||
bookingId: string;
|
||||
paymentId: string;
|
||||
status: BookingStatus;
|
||||
notification: NotificationTemplate | null;
|
||||
};
|
||||
|
||||
export type NotificationTemplate = {
|
||||
subject: string;
|
||||
preview: string;
|
||||
lines: string[];
|
||||
};
|
||||
|
||||
const stripeKey = process.env.STRIPE_SECRET_KEY?.trim();
|
||||
const stripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET?.trim();
|
||||
|
||||
const stripeClient = stripeKey ? new Stripe(stripeKey) : null;
|
||||
|
||||
function getSiteUrl() {
|
||||
return (process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function findProperty(propertySlug: string) {
|
||||
return bookingCatalog.find((property) => property.slug === propertySlug);
|
||||
}
|
||||
|
||||
async function ensureDbProperty(propertySlug: string) {
|
||||
const property = findProperty(propertySlug);
|
||||
if (!property) {
|
||||
throw new Error('Unknown property');
|
||||
}
|
||||
|
||||
return prisma.property.upsert({
|
||||
where: { slug: property.slug },
|
||||
create: {
|
||||
slug: property.slug,
|
||||
title: property.name,
|
||||
summary: property.summary,
|
||||
longDescription: property.summary,
|
||||
locationText: property.area,
|
||||
sleeps: property.sleeps,
|
||||
bedrooms: property.bedrooms,
|
||||
bathrooms: property.bathrooms,
|
||||
petsAllowed: property.petsAllowed,
|
||||
published: true,
|
||||
featured: false,
|
||||
minStayNights: property.minStayNights,
|
||||
},
|
||||
update: {
|
||||
title: property.name,
|
||||
summary: property.summary,
|
||||
longDescription: property.summary,
|
||||
locationText: property.area,
|
||||
sleeps: property.sleeps,
|
||||
bedrooms: property.bedrooms,
|
||||
bathrooms: property.bathrooms,
|
||||
petsAllowed: property.petsAllowed,
|
||||
published: true,
|
||||
minStayNights: property.minStayNights,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRequiredString(value: unknown, field: string) {
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`${field} is required`);
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error(`${field} is required`);
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function normalizeBoolean(value: unknown, field: string) {
|
||||
if (typeof value !== 'boolean') {
|
||||
throw new Error(`${field} is required`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeNumber(value: unknown, field: string) {
|
||||
const parsed = typeof value === 'string' ? Number(value) : Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
throw new Error(`${field} must be a number`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function getBookingHoldMinutes() {
|
||||
const parsed = Number(process.env.BOOKING_HOLD_MINUTES || '30');
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 30;
|
||||
}
|
||||
|
||||
function getCheckoutUrls(bookingId: string) {
|
||||
const baseUrl = getSiteUrl();
|
||||
return {
|
||||
successUrl: `${baseUrl}/bookings/${bookingId}?checkout=success&session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancelUrl: `${baseUrl}/bookings/${bookingId}?checkout=cancelled`,
|
||||
fallbackUrl: `${baseUrl}/bookings/${bookingId}/checkout`,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildNotificationForBooking(bookingId: string, success: boolean): Promise<NotificationTemplate | null> {
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
include: { property: true, payment: true },
|
||||
});
|
||||
|
||||
if (!booking) return null;
|
||||
|
||||
if (success) {
|
||||
return {
|
||||
subject: `Booking confirmed: ${booking.property.title}`,
|
||||
preview: `${booking.firstName} ${booking.lastName} is now confirmed for ${formatPoundsFromCents(booking.totalCents)}.`,
|
||||
lines: [
|
||||
`${booking.property.title} is confirmed.`,
|
||||
`Guest: ${booking.firstName} ${booking.lastName} <${booking.email}>`,
|
||||
`Dates: ${booking.arrivalDate.toISOString().slice(0, 10)} to ${booking.departureDate.toISOString().slice(0, 10)}`,
|
||||
`Total: ${formatPoundsFromCents(booking.totalCents)}`,
|
||||
`Payment status: ${booking.payment?.status ?? 'unknown'}`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
subject: `Payment issue for ${booking.property.title}`,
|
||||
preview: `The booking for ${booking.firstName} ${booking.lastName} did not complete payment.`,
|
||||
lines: [
|
||||
`Booking: ${booking.property.title}`,
|
||||
`Guest: ${booking.firstName} ${booking.lastName} <${booking.email}>`,
|
||||
`Dates: ${booking.arrivalDate.toISOString().slice(0, 10)} to ${booking.departureDate.toISOString().slice(0, 10)}`,
|
||||
`Current booking state: ${booking.status}`,
|
||||
`Current payment state: ${booking.payment?.status ?? 'unknown'}`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function recordNotification(bookingId: string, success: boolean) {
|
||||
const notification = await buildNotificationForBooking(bookingId, success);
|
||||
if (!notification) return null;
|
||||
|
||||
console.info('[booking-notification]', JSON.stringify(notification, null, 2));
|
||||
return notification;
|
||||
}
|
||||
|
||||
async function createStripeSession(bookingId: string, amountCents: number, email: string) {
|
||||
if (!stripeClient) return null;
|
||||
|
||||
const urls = getCheckoutUrls(bookingId);
|
||||
const session = await stripeClient.checkout.sessions.create({
|
||||
mode: 'payment',
|
||||
customer_email: email,
|
||||
success_url: urls.successUrl,
|
||||
cancel_url: urls.cancelUrl,
|
||||
line_items: [
|
||||
{
|
||||
quantity: 1,
|
||||
price_data: {
|
||||
currency: 'gbp',
|
||||
product_data: {
|
||||
name: `Holiday Property Booking ${bookingId.slice(0, 8)}`,
|
||||
description: 'Booking deposit and reservation payment',
|
||||
},
|
||||
unit_amount: amountCents,
|
||||
},
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
bookingId,
|
||||
},
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function createBookingCheckout(input: BookingCheckoutInput): Promise<BookingCheckoutResult> {
|
||||
const property = findProperty(input.propertySlug);
|
||||
if (!property) {
|
||||
throw new Error('Unknown property');
|
||||
}
|
||||
|
||||
const quote = quoteStay(property, input);
|
||||
if (!quote.available) {
|
||||
throw new Error(quote.reasons.join(' '));
|
||||
}
|
||||
|
||||
const firstName = normalizeRequiredString(input.firstName, 'firstName');
|
||||
const lastName = normalizeRequiredString(input.lastName, 'lastName');
|
||||
const email = normalizeRequiredString(input.email, 'email');
|
||||
const termsAccepted = normalizeBoolean(input.termsAccepted, 'termsAccepted');
|
||||
const adults = normalizeNumber(input.adults, 'adults');
|
||||
const children = normalizeNumber(input.children, 'children');
|
||||
const pets = normalizeNumber(input.pets, 'pets');
|
||||
|
||||
const holdMinutes = getBookingHoldMinutes();
|
||||
const holdExpiresAt = new Date(Date.now() + holdMinutes * 60 * 1000);
|
||||
const dbProperty = await ensureDbProperty(property.slug);
|
||||
|
||||
const booking = await prisma.booking.create({
|
||||
data: {
|
||||
propertyId: dbProperty.id,
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
phone: input.phone?.trim() || null,
|
||||
arrivalDate: new Date(`${quote.arrivalDate}T00:00:00.000Z`),
|
||||
departureDate: new Date(`${quote.departureDate}T00:00:00.000Z`),
|
||||
adults,
|
||||
children,
|
||||
pets,
|
||||
specialRequests: input.specialRequests?.trim() || null,
|
||||
termsAccepted,
|
||||
holdExpiresAt,
|
||||
totalCents: quote.totalCents,
|
||||
currency: 'GBP',
|
||||
status: BookingStatus.PENDING_PAYMENT,
|
||||
},
|
||||
});
|
||||
|
||||
const payment = await prisma.payment.create({
|
||||
data: {
|
||||
bookingId: booking.id,
|
||||
amountCents: quote.totalCents,
|
||||
currency: 'GBP',
|
||||
status: PaymentStatus.REQUIRES_PAYMENT,
|
||||
},
|
||||
});
|
||||
|
||||
const session = await createStripeSession(booking.id, quote.totalCents, email);
|
||||
|
||||
if (session) {
|
||||
await prisma.payment.update({
|
||||
where: { id: payment.id },
|
||||
data: {
|
||||
stripeCheckoutSessionId: session.id,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
bookingId: booking.id,
|
||||
paymentId: payment.id,
|
||||
checkoutUrl: session.url || getCheckoutUrls(booking.id).fallbackUrl,
|
||||
checkoutMode: 'stripe',
|
||||
quote,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
bookingId: booking.id,
|
||||
paymentId: payment.id,
|
||||
checkoutUrl: getCheckoutUrls(booking.id).fallbackUrl,
|
||||
checkoutMode: 'mock',
|
||||
quote,
|
||||
};
|
||||
}
|
||||
|
||||
async function updatePaymentOutcome(bookingId: string, status: PaymentStatus, bookingStatus: BookingStatus, eventId: string, stripeIds: { checkoutSessionId?: string | null; paymentIntentId?: string | null } = {}) {
|
||||
const [payment, booking] = await prisma.$transaction([
|
||||
prisma.payment.update({
|
||||
where: { bookingId },
|
||||
data: {
|
||||
status,
|
||||
stripeEventId: eventId,
|
||||
stripeCheckoutSessionId: stripeIds.checkoutSessionId ?? undefined,
|
||||
stripePaymentIntentId: stripeIds.paymentIntentId ?? undefined,
|
||||
},
|
||||
}),
|
||||
prisma.booking.update({
|
||||
where: { id: bookingId },
|
||||
data: {
|
||||
status: bookingStatus,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return { payment, booking };
|
||||
}
|
||||
|
||||
export async function handleStripeWebhookEvent(rawEvent: unknown): Promise<PaymentEventResult> {
|
||||
const event = rawEvent as { id?: string; type?: string; data?: { object?: Record<string, unknown> } };
|
||||
const eventId = event.id ?? `dev-${Date.now()}`;
|
||||
const eventType = event.type ?? 'unknown';
|
||||
const object = event.data?.object ?? {};
|
||||
const metadata = (object.metadata ?? {}) as Record<string, string>;
|
||||
const bookingId = metadata.bookingId;
|
||||
|
||||
if (!bookingId) {
|
||||
throw new Error('Webhook payload did not include booking metadata');
|
||||
}
|
||||
|
||||
if (eventType === 'checkout.session.completed' || eventType === 'payment_intent.succeeded') {
|
||||
const result = await updatePaymentOutcome(
|
||||
bookingId,
|
||||
PaymentStatus.COMPLETED,
|
||||
BookingStatus.CONFIRMED,
|
||||
eventId,
|
||||
{
|
||||
checkoutSessionId: typeof object.id === 'string' ? object.id : null,
|
||||
paymentIntentId: typeof object.payment_intent === 'string' ? object.payment_intent : null,
|
||||
},
|
||||
);
|
||||
|
||||
const notification = await recordNotification(bookingId, true);
|
||||
|
||||
return {
|
||||
bookingId,
|
||||
paymentId: result.payment.id,
|
||||
status: BookingStatus.CONFIRMED,
|
||||
notification,
|
||||
};
|
||||
}
|
||||
|
||||
if (eventType === 'checkout.session.expired' || eventType === 'payment_intent.payment_failed') {
|
||||
const result = await updatePaymentOutcome(
|
||||
bookingId,
|
||||
PaymentStatus.FAILED,
|
||||
BookingStatus.FAILED,
|
||||
eventId,
|
||||
{
|
||||
checkoutSessionId: typeof object.id === 'string' ? object.id : null,
|
||||
paymentIntentId: typeof object.payment_intent === 'string' ? object.payment_intent : null,
|
||||
},
|
||||
);
|
||||
|
||||
const notification = await recordNotification(bookingId, false);
|
||||
|
||||
return {
|
||||
bookingId,
|
||||
paymentId: result.payment.id,
|
||||
status: BookingStatus.FAILED,
|
||||
notification,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
bookingId,
|
||||
paymentId: 'unknown',
|
||||
status: BookingStatus.PENDING_PAYMENT,
|
||||
notification: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleStripeWebhookBody(rawBody: string, signature: string | null) {
|
||||
if (stripeClient && stripeWebhookSecret && signature) {
|
||||
const event = stripeClient.webhooks.constructEvent(rawBody, signature, stripeWebhookSecret);
|
||||
return handleStripeWebhookEvent(event);
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(rawBody) as unknown;
|
||||
return handleStripeWebhookEvent(parsed);
|
||||
}
|
||||
|
||||
export async function simulateCompletedPayment(bookingId: string) {
|
||||
return handleStripeWebhookEvent({
|
||||
id: `sim-${bookingId}`,
|
||||
type: 'checkout.session.completed',
|
||||
data: {
|
||||
object: {
|
||||
id: `cs_sim_${bookingId.slice(0, 8)}`,
|
||||
payment_intent: `pi_sim_${bookingId.slice(0, 8)}`,
|
||||
metadata: {
|
||||
bookingId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getBookingCheckoutContext(bookingId: string) {
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
include: {
|
||||
property: true,
|
||||
payment: true,
|
||||
},
|
||||
});
|
||||
|
||||
return booking;
|
||||
}
|
||||
|
||||
export { stripeWebhookSecret };
|
||||
Reference in New Issue
Block a user