add Stripe

This commit is contained in:
2026-05-02 14:05:30 -04:00
parent a270d83c1a
commit 815d4e0bdd
33 changed files with 2269 additions and 43 deletions

View File

@@ -7,6 +7,9 @@ import {
confirmHold,
createHold,
expireStaleHolds,
markComplete,
markNoShow,
rescheduleBooking,
} from "@/lib/booking";
import { seed, type SeedResult } from "@/lib/seed";
@@ -309,3 +312,168 @@ describe("expireStaleHolds", () => {
expect(staleRow?.cancelReason).toBe("hold expired");
});
});
describe("rescheduleBooking", () => {
test("cancels old + creates new at the new time, atomically", async () => {
const original = await createHold(db, {
customerId,
serviceId,
therapistId: therapistA,
roomId: roomA,
startsAt: D("2026-05-05T14:00:00Z"),
});
await confirmHold(db, original.id);
const result = await rescheduleBooking(db, {
oldBookingId: original.id,
newStartsAt: D("2026-05-06T14:00:00Z"),
cancelledByUserId: customerId,
});
expect(result.oldBookingId).toBe(original.id);
expect(result.newBookingId).not.toBe(original.id);
const oldRow = await db.booking.findUnique({ where: { id: original.id } });
const newRow = await db.booking.findUnique({ where: { id: result.newBookingId } });
expect(oldRow?.status).toBe("CANCELLED");
expect(oldRow?.cancelReason).toBe("rescheduled");
expect(newRow?.status).toBe("CONFIRMED");
expect(newRow?.startsAt).toEqual(D("2026-05-06T14:00:00Z"));
});
test("rolls back if the new slot conflicts (old booking stays CONFIRMED)", async () => {
// Block the target slot with a CONFIRMED booking on therapistA at 11:00.
const blocker = await createHold(db, {
customerId,
serviceId,
therapistId: therapistA,
roomId: roomA,
startsAt: D("2026-05-06T15:00:00Z"),
});
await confirmHold(db, blocker.id);
const original = await createHold(db, {
customerId,
serviceId,
therapistId: therapistA,
roomId: roomA,
startsAt: D("2026-05-05T14:00:00Z"),
});
await confirmHold(db, original.id);
await expect(
rescheduleBooking(db, {
oldBookingId: original.id,
newStartsAt: D("2026-05-06T15:00:00Z"), // collides with blocker
cancelledByUserId: customerId,
}),
).rejects.toBeInstanceOf(BookingConflictError);
// Both bookings unchanged
const orig = await db.booking.findUnique({ where: { id: original.id } });
const blk = await db.booking.findUnique({ where: { id: blocker.id } });
expect(orig?.status).toBe("CONFIRMED");
expect(blk?.status).toBe("CONFIRMED");
});
test("rejects rescheduling a CANCELLED booking", async () => {
const original = await createHold(db, {
customerId,
serviceId,
therapistId: therapistA,
roomId: roomA,
startsAt: D("2026-05-05T14:00:00Z"),
});
await cancelBooking(db, {
bookingId: original.id,
cancelledByUserId: customerId,
});
await expect(
rescheduleBooking(db, {
oldBookingId: original.id,
newStartsAt: D("2026-05-06T14:00:00Z"),
cancelledByUserId: customerId,
}),
).rejects.toThrow(/Cannot reschedule/);
});
test("can change therapist/room/service on reschedule", async () => {
const original = await createHold(db, {
customerId,
serviceId,
therapistId: therapistA,
roomId: roomA,
startsAt: D("2026-05-05T14:00:00Z"),
});
await confirmHold(db, original.id);
const result = await rescheduleBooking(db, {
oldBookingId: original.id,
newStartsAt: D("2026-05-06T14:00:00Z"),
newTherapistId: therapistB,
newRoomId: roomB,
cancelledByUserId: customerId,
});
const newRow = await db.booking.findUnique({ where: { id: result.newBookingId } });
expect(newRow?.therapistId).toBe(therapistB);
expect(newRow?.roomId).toBe(roomB);
});
});
describe("markComplete / markNoShow", () => {
test("markComplete: CONFIRMED → COMPLETED", async () => {
const hold = await createHold(db, {
customerId,
serviceId,
therapistId: therapistA,
roomId: roomA,
startsAt: D("2026-05-05T14:00:00Z"),
});
await confirmHold(db, hold.id);
const result = await markComplete(db, hold.id);
expect(result.ok).toBe(true);
const row = await db.booking.findUnique({ where: { id: hold.id } });
expect(row?.status).toBe("COMPLETED");
});
test("markComplete is idempotent", async () => {
const hold = await createHold(db, {
customerId,
serviceId,
therapistId: therapistA,
roomId: roomA,
startsAt: D("2026-05-05T14:00:00Z"),
});
await confirmHold(db, hold.id);
await markComplete(db, hold.id);
const second = await markComplete(db, hold.id);
expect(second.ok).toBe(true);
});
test("markComplete rejects HOLD bookings", async () => {
const hold = await createHold(db, {
customerId,
serviceId,
therapistId: therapistA,
roomId: roomA,
startsAt: D("2026-05-05T14:00:00Z"),
});
const result = await markComplete(db, hold.id);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toMatch(/HOLD/);
});
test("markNoShow: CONFIRMED → NO_SHOW", async () => {
const hold = await createHold(db, {
customerId,
serviceId,
therapistId: therapistA,
roomId: roomA,
startsAt: D("2026-05-05T14:00:00Z"),
});
await confirmHold(db, hold.id);
const result = await markNoShow(db, hold.id);
expect(result.ok).toBe(true);
const row = await db.booking.findUnique({ where: { id: hold.id } });
expect(row?.status).toBe("NO_SHOW");
});
});

153
test/payments.test.ts Normal file
View File

@@ -0,0 +1,153 @@
// 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/);
});
});