// Tests for the DB-side payment helpers. These don't touch Stripe — they // operate on the booking row keyed by `stripePaymentIntentId`. // // The webhook handler's signature-verification path needs real Stripe keys // to test end-to-end; the meaningful business logic lives in these helpers. import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest"; import { PrismaPg } from "@prisma/adapter-pg"; import { PrismaClient } from "@/generated/prisma/client"; import { createHold } from "@/lib/booking"; import { recordPaymentFailed, recordPaymentSucceeded, } from "@/lib/payments"; import { seed, type SeedResult } from "@/lib/seed"; const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }); const db = new PrismaClient({ adapter }); let fx: SeedResult; let serviceId: string; let therapistA: string; let roomA: string; let customerId: string; beforeAll(async () => { fx = await seed(db); serviceId = fx.services.find((s) => s.name === "60-minute Swedish")!.id; therapistA = fx.therapists[0].id; roomA = fx.rooms[0].id; customerId = fx.customers[0].id; }); afterAll(async () => { await db.$disconnect(); }); beforeEach(async () => { await db.payment.deleteMany(); await db.booking.deleteMany(); }); const D = (iso: string) => new Date(iso); async function holdWithPI(piId: string) { const hold = await createHold(db, { customerId, serviceId, therapistId: therapistA, roomId: roomA, startsAt: D("2026-05-05T14:00:00Z"), }); await db.booking.update({ where: { id: hold.id }, data: { stripePaymentIntentId: piId, paymentStatus: "PENDING" }, }); return hold; } describe("recordPaymentSucceeded", () => { test("flips HOLD → CONFIRMED, writes a Payment row, returns transitioned=true", async () => { const hold = await holdWithPI("pi_test_1"); const result = await recordPaymentSucceeded(db, "pi_test_1", 2000); expect(result.bookingId).toBe(hold.id); expect(result.transitioned).toBe(true); const row = await db.booking.findUnique({ where: { id: hold.id } }); expect(row?.status).toBe("CONFIRMED"); expect(row?.paymentStatus).toBe("CAPTURED"); expect(row?.holdExpiresAt).toBeNull(); const payment = await db.payment.findUnique({ where: { stripePaymentIntentId: "pi_test_1" }, }); expect(payment?.kind).toBe("DEPOSIT"); expect(payment?.status).toBe("CAPTURED"); expect(payment?.amountCents).toBe(2000); }); test("idempotent on webhook replay — second call returns transitioned=false", async () => { const hold = await holdWithPI("pi_test_2"); await recordPaymentSucceeded(db, "pi_test_2", 2000); const second = await recordPaymentSucceeded(db, "pi_test_2", 2000); expect(second.transitioned).toBe(false); // Payment row count should still be 1 const count = await db.payment.count({ where: { stripePaymentIntentId: "pi_test_2" }, }); expect(count).toBe(1); // Booking still CONFIRMED with CAPTURED payment status const row = await db.booking.findUnique({ where: { id: hold.id } }); expect(row?.status).toBe("CONFIRMED"); }); test("does not transition a CANCELLED booking back to CONFIRMED", async () => { const hold = await holdWithPI("pi_test_3"); await db.booking.update({ where: { id: hold.id }, data: { status: "CANCELLED", cancelledAt: new Date(), cancelReason: "hold expired", }, }); const result = await recordPaymentSucceeded(db, "pi_test_3", 2000); expect(result.transitioned).toBe(false); const row = await db.booking.findUnique({ where: { id: hold.id } }); expect(row?.status).toBe("CANCELLED"); // unchanged // Payment row still recorded — money was actually captured even though // the booking is gone; admin can refund manually. const payment = await db.payment.findUnique({ where: { stripePaymentIntentId: "pi_test_3" }, }); expect(payment?.status).toBe("CAPTURED"); }); test("throws on unknown PaymentIntent id", async () => { await expect( recordPaymentSucceeded(db, "pi_no_such", 2000), ).rejects.toThrow(/No booking/); }); }); describe("recordPaymentFailed", () => { test("cancels a HOLD booking with reason 'payment failed'", async () => { const hold = await holdWithPI("pi_test_fail_1"); const result = await recordPaymentFailed(db, "pi_test_fail_1"); expect(result.bookingId).toBe(hold.id); const row = await db.booking.findUnique({ where: { id: hold.id } }); expect(row?.status).toBe("CANCELLED"); expect(row?.cancelReason).toBe("payment failed"); expect(row?.paymentStatus).toBe("FAILED"); }); test("does not change status of a CONFIRMED booking, but updates paymentStatus", async () => { const hold = await holdWithPI("pi_test_fail_2"); await db.booking.update({ where: { id: hold.id }, data: { status: "CONFIRMED" }, }); await recordPaymentFailed(db, "pi_test_fail_2"); const row = await db.booking.findUnique({ where: { id: hold.id } }); expect(row?.status).toBe("CONFIRMED"); // unchanged expect(row?.paymentStatus).toBe("FAILED"); }); test("throws on unknown PaymentIntent id", async () => { await expect( recordPaymentFailed(db, "pi_no_such"), ).rejects.toThrow(/No booking/); }); });