Development fallback
diff --git a/src/lib/payments.test.ts b/src/lib/payments.test.ts
new file mode 100644
index 0000000..c3f9193
--- /dev/null
+++ b/src/lib/payments.test.ts
@@ -0,0 +1,206 @@
+import { BookingStatus, PaymentStatus } from '@prisma/client';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const prismaMock = vi.hoisted(() => ({
+ property: {
+ upsert: vi.fn(),
+ },
+ booking: {
+ create: vi.fn(),
+ update: vi.fn(),
+ findUnique: vi.fn(),
+ },
+ payment: {
+ create: vi.fn(),
+ update: vi.fn(),
+ },
+ $transaction: vi.fn(async (operations: Array>) => Promise.all(operations)),
+}));
+
+vi.mock('@/lib/prisma', () => ({
+ prisma: prismaMock,
+}));
+
+import { createBookingCheckout, handleStripeWebhookBody, handleStripeWebhookEvent } from './payments';
+
+describe('payments flow', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.spyOn(console, 'info').mockImplementation(() => {});
+ });
+
+ it('creates a booking checkout with the local fallback handoff when Stripe is not configured', async () => {
+ prismaMock.property.upsert.mockResolvedValue({ id: 'property_123' });
+ prismaMock.booking.create.mockResolvedValue({ id: 'booking_123' });
+ prismaMock.payment.create.mockResolvedValue({ id: 'payment_123' });
+
+ const result = await createBookingCheckout({
+ propertySlug: 'orchard-barn',
+ arrivalDate: '2026-06-22',
+ departureDate: '2026-06-25',
+ adults: 2,
+ children: 0,
+ pets: 1,
+ firstName: 'Casey',
+ lastName: 'Morgan',
+ email: 'casey@example.com',
+ termsAccepted: true,
+ });
+
+ expect(prismaMock.booking.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ propertyId: 'property_123',
+ status: BookingStatus.PENDING_PAYMENT,
+ totalCents: expect.any(Number),
+ currency: 'GBP',
+ }),
+ }),
+ );
+ expect(prismaMock.payment.create).toHaveBeenCalledWith({
+ data: {
+ bookingId: 'booking_123',
+ amountCents: result.quote.totalCents,
+ currency: 'GBP',
+ status: PaymentStatus.REQUIRES_PAYMENT,
+ },
+ });
+ expect(result).toMatchObject({
+ bookingId: 'booking_123',
+ paymentId: 'payment_123',
+ checkoutMode: 'mock',
+ checkoutUrl: 'http://localhost:3000/bookings/booking_123/checkout',
+ });
+ });
+
+ it('marks the booking confirmed when Stripe reports a successful payment event', async () => {
+ prismaMock.payment.update.mockResolvedValue({ id: 'payment_123' });
+ prismaMock.booking.update.mockResolvedValue({ id: 'booking_123', status: BookingStatus.CONFIRMED });
+ prismaMock.booking.findUnique.mockResolvedValue({
+ firstName: 'Casey',
+ lastName: 'Morgan',
+ email: 'casey@example.com',
+ arrivalDate: new Date('2026-06-22T00:00:00.000Z'),
+ departureDate: new Date('2026-06-25T00:00:00.000Z'),
+ totalCents: 63000,
+ status: BookingStatus.CONFIRMED,
+ property: {
+ title: 'Orchard Barn',
+ },
+ payment: {
+ status: PaymentStatus.COMPLETED,
+ },
+ });
+
+ const result = await handleStripeWebhookEvent({
+ id: 'evt_success_123',
+ type: 'checkout.session.completed',
+ data: {
+ object: {
+ id: 'cs_test_123',
+ payment_intent: 'pi_test_123',
+ metadata: {
+ bookingId: 'booking_123',
+ },
+ },
+ },
+ });
+
+ expect(prismaMock.payment.update).toHaveBeenCalledWith({
+ where: { bookingId: 'booking_123' },
+ data: {
+ status: PaymentStatus.COMPLETED,
+ stripeEventId: 'evt_success_123',
+ stripeCheckoutSessionId: 'cs_test_123',
+ stripePaymentIntentId: 'pi_test_123',
+ },
+ });
+ expect(prismaMock.booking.update).toHaveBeenCalledWith({
+ where: { id: 'booking_123' },
+ data: {
+ status: BookingStatus.CONFIRMED,
+ },
+ });
+ expect(result).toMatchObject({
+ bookingId: 'booking_123',
+ paymentId: 'payment_123',
+ status: BookingStatus.CONFIRMED,
+ notification: expect.objectContaining({
+ subject: 'Booking confirmed: Orchard Barn',
+ }),
+ });
+ });
+
+ it('marks the booking failed when Stripe reports an expired or failed payment', async () => {
+ prismaMock.payment.update.mockResolvedValue({ id: 'payment_123' });
+ prismaMock.booking.update.mockResolvedValue({ id: 'booking_123', status: BookingStatus.FAILED });
+ prismaMock.booking.findUnique.mockResolvedValue({
+ firstName: 'Casey',
+ lastName: 'Morgan',
+ email: 'casey@example.com',
+ arrivalDate: new Date('2026-06-22T00:00:00.000Z'),
+ departureDate: new Date('2026-06-25T00:00:00.000Z'),
+ totalCents: 63000,
+ status: BookingStatus.FAILED,
+ property: {
+ title: 'Orchard Barn',
+ },
+ payment: {
+ status: PaymentStatus.FAILED,
+ },
+ });
+
+ const result = await handleStripeWebhookBody(
+ JSON.stringify({
+ id: 'evt_failed_123',
+ type: 'payment_intent.payment_failed',
+ data: {
+ object: {
+ id: 'pi_test_123',
+ payment_intent: 'pi_test_123',
+ metadata: {
+ bookingId: 'booking_123',
+ },
+ },
+ },
+ }),
+ null,
+ );
+
+ expect(prismaMock.payment.update).toHaveBeenCalledWith({
+ where: { bookingId: 'booking_123' },
+ data: {
+ status: PaymentStatus.FAILED,
+ stripeEventId: 'evt_failed_123',
+ stripeCheckoutSessionId: 'pi_test_123',
+ stripePaymentIntentId: 'pi_test_123',
+ },
+ });
+ expect(prismaMock.booking.update).toHaveBeenCalledWith({
+ where: { id: 'booking_123' },
+ data: {
+ status: BookingStatus.FAILED,
+ },
+ });
+ expect(result).toMatchObject({
+ bookingId: 'booking_123',
+ paymentId: 'payment_123',
+ status: BookingStatus.FAILED,
+ notification: expect.objectContaining({
+ subject: 'Payment issue for Orchard Barn',
+ }),
+ });
+ });
+
+ it('rejects webhook payloads that do not include booking metadata', async () => {
+ await expect(
+ handleStripeWebhookEvent({
+ id: 'evt_missing_123',
+ type: 'checkout.session.completed',
+ data: {
+ object: {},
+ },
+ }),
+ ).rejects.toThrow('Webhook payload did not include booking metadata');
+ });
+});
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..068feba
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,19 @@
+import path from 'node:path';
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, 'src'),
+ },
+ },
+ esbuild: {
+ jsx: 'automatic',
+ },
+ test: {
+ environment: 'node',
+ restoreMocks: true,
+ clearMocks: true,
+ include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
+ },
+});