154 lines
5.2 KiB
TypeScript
154 lines
5.2 KiB
TypeScript
|
|
// 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/);
|
||
|
|
});
|
||
|
|
});
|