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 { 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 { 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 { const event = rawEvent as { id?: string; type?: string; data?: { object?: Record } }; const eventId = event.id ?? `dev-${Date.now()}`; const eventType = event.type ?? 'unknown'; const object = event.data?.object ?? {}; const metadata = (object.metadata ?? {}) as Record; 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 };