413 lines
12 KiB
TypeScript
413 lines
12 KiB
TypeScript
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 };
|