mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 21:31:17 +00:00
583 lines
20 KiB
TypeScript
583 lines
20 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||
|
||
vi.mock("@/src/lib/stripe", () => ({
|
||
stripe: {
|
||
webhooks: { constructEvent: vi.fn() },
|
||
paymentIntents: { retrieve: vi.fn() },
|
||
},
|
||
getWebhookSecrets: () => ["whsec_platform", "whsec_connect"],
|
||
onAccount: (id: string | null) => (id ? { stripeAccount: id } : undefined),
|
||
}));
|
||
vi.mock("@/src/lib/stripe-refund", () => ({ refundStripePayment: vi.fn() }));
|
||
vi.mock("@/src/lib/stripe-fee", () => ({
|
||
recordStripeFee: vi.fn(),
|
||
recordStripeFeeForCharge: vi.fn(),
|
||
}));
|
||
vi.mock("@/src/lib/prisma", () => ({
|
||
prisma: {
|
||
$transaction: vi.fn(),
|
||
user: { findUnique: vi.fn(), findFirst: vi.fn() },
|
||
course: { findUnique: vi.fn() },
|
||
courseBooking: {
|
||
findUnique: vi.fn(),
|
||
findFirst: vi.fn(),
|
||
update: vi.fn(),
|
||
count: vi.fn(),
|
||
aggregate: vi.fn(),
|
||
},
|
||
courseWaitlistEntry: { deleteMany: vi.fn() },
|
||
courseSpotHold: { deleteMany: vi.fn(), updateMany: vi.fn() },
|
||
business: { findUnique: vi.fn(), update: vi.fn() },
|
||
},
|
||
}));
|
||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn(), revalidateTag: vi.fn() }));
|
||
vi.mock("@/src/lib/mail/resend", () => ({ sendMail: vi.fn() }));
|
||
vi.mock("@/src/lib/mail/templates/booking-confirmation", () => ({
|
||
generateBookingConfirmationEmail: () => ({ subject: "s", html: "h", text: "t" }),
|
||
}));
|
||
vi.mock("@/src/lib/mail/templates/booking-refund", () => ({
|
||
generateBookingRefundEmail: () => ({ subject: "s", html: "h", text: "t" }),
|
||
}));
|
||
vi.mock("@/src/modules/settings/lib/to-email-branding", () => ({ toEmailBranding: () => ({}) }));
|
||
vi.mock("@/src/modules/notifications/lib/create-notification", () => ({
|
||
notifyBusinessManager: vi.fn(),
|
||
}));
|
||
vi.mock("@/src/lib/auth/account-role-by-email", () => ({
|
||
isManagerOrEmployeeEmail: vi.fn(),
|
||
}));
|
||
vi.mock("@/src/modules/settings/lib/discount-validation", () => ({
|
||
confirmDiscountReservation: vi.fn(),
|
||
releaseDiscountReservation: vi.fn(),
|
||
}));
|
||
vi.mock("@/src/modules/products/lib/pass-webhook", async (importOriginal) => ({
|
||
...(await importOriginal<object>()),
|
||
handlePassCheckoutCompleted: vi.fn(),
|
||
}));
|
||
vi.mock("@/src/modules/products/lib/pass-refund", () => ({ reconcilePassRefund: vi.fn() }));
|
||
|
||
import { POST } from "@/src/app/api/stripe/webhook/route";
|
||
import { stripe } from "@/src/lib/stripe";
|
||
import { prisma } from "@/src/lib/prisma";
|
||
import { refundStripePayment } from "@/src/lib/stripe-refund";
|
||
import { recordStripeFee, recordStripeFeeForCharge } from "@/src/lib/stripe-fee";
|
||
import { isManagerOrEmployeeEmail } from "@/src/lib/auth/account-role-by-email";
|
||
import { releaseDiscountReservation } from "@/src/modules/settings/lib/discount-validation";
|
||
import { handlePassCheckoutCompleted } from "@/src/modules/products/lib/pass-webhook";
|
||
import { reconcilePassRefund } from "@/src/modules/products/lib/pass-refund";
|
||
|
||
// Die Datenbank-Transaktion der Kapazitätsprüfung.
|
||
const tx = {
|
||
courseBooking: {
|
||
findUnique: vi.fn(),
|
||
findFirst: vi.fn(),
|
||
findMany: vi.fn(),
|
||
count: vi.fn(),
|
||
aggregate: vi.fn(),
|
||
upsert: vi.fn(),
|
||
},
|
||
course: { findUnique: vi.fn() },
|
||
room: { findUnique: vi.fn() },
|
||
courseSpotHold: { findMany: vi.fn() },
|
||
};
|
||
|
||
function request() {
|
||
return new Request("http://localhost/api/stripe/webhook", {
|
||
method: "POST",
|
||
headers: { "stripe-signature": "t=1,v1=abc" },
|
||
body: "{}",
|
||
}) as never;
|
||
}
|
||
|
||
/** Ein Event, das nur mit dem angegebenen Secret als gültig signiert gilt. */
|
||
function deliver(event: object, signedWith = "whsec_platform") {
|
||
vi.mocked(stripe.webhooks.constructEvent).mockImplementation((_body, _sig, secret) => {
|
||
if (secret !== signedWith)
|
||
throw new Error("No signatures found matching the expected signature");
|
||
return event as never;
|
||
});
|
||
return POST(request());
|
||
}
|
||
|
||
function sessionEvent(
|
||
type: string,
|
||
opts: { account?: string; paymentStatus?: string; metadata?: Record<string, string> } = {}
|
||
) {
|
||
return {
|
||
type,
|
||
account: opts.account,
|
||
data: {
|
||
object: {
|
||
id: "cs_1",
|
||
payment_status: opts.paymentStatus ?? "paid",
|
||
amount_total: 2950,
|
||
payment_intent: "pi_1",
|
||
customer_email: "kunde@example.com",
|
||
customer_details: { email: "kunde@example.com", name: "Kunde" },
|
||
metadata: {
|
||
courseId: "course-1",
|
||
userId: "user-1",
|
||
isGuest: "false",
|
||
platformFeeModel: "on_top",
|
||
...opts.metadata,
|
||
},
|
||
},
|
||
},
|
||
};
|
||
}
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
|
||
vi.mocked(prisma.$transaction).mockImplementation(((callback: (t: typeof tx) => unknown) =>
|
||
callback(tx)) as never);
|
||
tx.courseBooking.findUnique.mockResolvedValue(null);
|
||
tx.courseBooking.findFirst.mockResolvedValue(null);
|
||
tx.courseBooking.count.mockResolvedValue(0);
|
||
tx.courseBooking.aggregate.mockResolvedValue({ _sum: { seats: 0 } });
|
||
tx.courseBooking.upsert.mockResolvedValue({ id: "booking-1" });
|
||
tx.course.findUnique.mockResolvedValue({ maxParticipants: 10 });
|
||
tx.courseBooking.findMany.mockResolvedValue([]);
|
||
tx.courseSpotHold.findMany.mockResolvedValue([]);
|
||
tx.room.findUnique.mockResolvedValue(null);
|
||
|
||
vi.mocked(stripe.paymentIntents.retrieve).mockResolvedValue({
|
||
application_fee_amount: 50,
|
||
} as never);
|
||
vi.mocked(isManagerOrEmployeeEmail).mockResolvedValue(false);
|
||
vi.mocked(recordStripeFee).mockResolvedValue(undefined);
|
||
vi.mocked(prisma.user.findUnique).mockResolvedValue({ id: "user-1" } as never);
|
||
vi.mocked(prisma.course.findUnique).mockResolvedValue({
|
||
name: "Yoga",
|
||
date: new Date("2099-01-01"),
|
||
timeFrom: "10:00",
|
||
timeTo: "11:00",
|
||
coverImage: null,
|
||
businessId: "biz-1",
|
||
maxParticipants: 10,
|
||
business: { name: "Studio", address: "Weg 1", branding: null },
|
||
} as never);
|
||
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue({ userId: "user-1" } as never);
|
||
vi.mocked(prisma.courseBooking.count).mockResolvedValue(1 as never);
|
||
vi.mocked(prisma.courseBooking.aggregate).mockResolvedValue({ _sum: { seats: 1 } } as never);
|
||
vi.mocked(refundStripePayment).mockResolvedValue({
|
||
success: true,
|
||
stripeRefundId: "re_1",
|
||
amountRefundedCents: 2950,
|
||
});
|
||
});
|
||
|
||
describe("Stripe-Webhook – Signatur", () => {
|
||
it("akzeptiert Events vom Connect-Endpoint mit dessen eigenem Secret", async () => {
|
||
const res = await deliver({ type: "customer.created", data: { object: {} } }, "whsec_connect");
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(stripe.webhooks.constructEvent).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
it("lehnt ab, wenn kein Secret passt", async () => {
|
||
const res = await deliver({ type: "customer.created", data: { object: {} } }, "whsec_fremd");
|
||
|
||
expect(res.status).toBe(400);
|
||
});
|
||
});
|
||
|
||
describe("Stripe-Webhook – Buchung", () => {
|
||
it("bucht eine Direct Charge auf dem Studio-Account mit Gebühr obendrauf", async () => {
|
||
const res = await deliver(
|
||
sessionEvent("checkout.session.completed", { account: "acct_studio" }),
|
||
"whsec_connect"
|
||
);
|
||
|
||
expect(res.status).toBe(200);
|
||
// Die application_fee liegt am PaymentIntent des Studio-Accounts.
|
||
expect(stripe.paymentIntents.retrieve).toHaveBeenCalledWith("pi_1", undefined, {
|
||
stripeAccount: "acct_studio",
|
||
});
|
||
expect(tx.courseBooking.upsert.mock.calls[0][0].create).toMatchObject({
|
||
paymentStatus: "paid",
|
||
stripeSessionId: "cs_1",
|
||
stripePaymentIntentId: "pi_1",
|
||
stripeAccountId: "acct_studio",
|
||
amountPaid: 2950,
|
||
platformFee: 50,
|
||
platformFeeOnTop: true,
|
||
});
|
||
expect(recordStripeFee).toHaveBeenCalledWith("pi_1", "acct_studio");
|
||
});
|
||
|
||
it("bucht nicht mit Gebühr 0, wenn der PaymentIntent nicht abrufbar ist", async () => {
|
||
// Mit 0 als Ersatz wäre die Gebühr dauerhaft falsch verbucht - eine erneute
|
||
// Zustellung ändert eine bezahlte Buchung nicht mehr. Stattdessen 500,
|
||
// damit Stripe das Event erneut zustellt.
|
||
vi.mocked(stripe.paymentIntents.retrieve).mockRejectedValue(
|
||
new Error("Stripe nicht erreichbar")
|
||
);
|
||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||
|
||
const res = await deliver(
|
||
sessionEvent("checkout.session.completed", { account: "acct_studio" }),
|
||
"whsec_connect"
|
||
);
|
||
|
||
expect(res.status).toBe(500);
|
||
expect(tx.courseBooking.upsert).not.toHaveBeenCalled();
|
||
consoleError.mockRestore();
|
||
});
|
||
|
||
it("speichert die Rechnungsadresse aus dem Checkout an der Buchung", async () => {
|
||
const event = sessionEvent("checkout.session.completed", { account: "acct_studio" });
|
||
(event.data.object as Record<string, unknown>).customer_details = {
|
||
email: "kunde@example.com",
|
||
name: "Erika Mustermann",
|
||
address: {
|
||
line1: "Sandweg 91",
|
||
line2: null,
|
||
postal_code: "60316",
|
||
city: "Frankfurt",
|
||
country: "DE",
|
||
},
|
||
};
|
||
|
||
await deliver(event, "whsec_connect");
|
||
|
||
expect(tx.courseBooking.upsert.mock.calls[0][0].create.billingAddress).toEqual({
|
||
name: "Erika Mustermann",
|
||
line1: "Sandweg 91",
|
||
line2: null,
|
||
postalCode: "60316",
|
||
city: "Frankfurt",
|
||
country: "DE",
|
||
});
|
||
});
|
||
|
||
it("verbucht eine Session von vor der Umstellung als Altbuchung", async () => {
|
||
// Prozentuale Gebühr, Destination Charge auf der Plattform: kein
|
||
// event.account, kein platformFeeModel in den Metadaten.
|
||
const event = sessionEvent("checkout.session.completed");
|
||
delete (event.data.object.metadata as Record<string, string>).platformFeeModel;
|
||
|
||
await deliver(event);
|
||
|
||
expect(stripe.paymentIntents.retrieve).toHaveBeenCalledWith("pi_1", undefined, undefined);
|
||
expect(tx.courseBooking.upsert.mock.calls[0][0].create).toMatchObject({
|
||
stripeAccountId: null,
|
||
platformFeeOnTop: false,
|
||
});
|
||
// Bei Altbuchungen trug die Plattform die Stripe-Gebühr.
|
||
expect(recordStripeFee).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("bucht SEPA erst, wenn die verzögerte Zahlung angekommen ist", async () => {
|
||
await deliver(
|
||
sessionEvent("checkout.session.completed", {
|
||
account: "acct_studio",
|
||
paymentStatus: "unpaid",
|
||
}),
|
||
"whsec_connect"
|
||
);
|
||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||
|
||
await deliver(
|
||
sessionEvent("checkout.session.async_payment_succeeded", { account: "acct_studio" }),
|
||
"whsec_connect"
|
||
);
|
||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it("erstattet eine Überbuchung auf dem Studio-Account und gibt die Gebühr ans Studio zurück", async () => {
|
||
tx.courseBooking.aggregate.mockResolvedValue({ _sum: { seats: 10 } });
|
||
|
||
await deliver(
|
||
sessionEvent("checkout.session.completed", { account: "acct_studio" }),
|
||
"whsec_connect"
|
||
);
|
||
|
||
expect(refundStripePayment).toHaveBeenCalledWith({
|
||
paymentIntentId: "pi_1",
|
||
stripeAccountId: "acct_studio",
|
||
refundApplicationFee: true,
|
||
});
|
||
expect(recordStripeFee).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe("Stripe-Webhook – nie bezahlt und Gebühren", () => {
|
||
it("gibt bei gescheiterter SEPA-Zahlung die Rabatt-Reservierung frei", async () => {
|
||
await deliver(
|
||
sessionEvent("checkout.session.async_payment_failed", {
|
||
account: "acct_studio",
|
||
paymentStatus: "unpaid",
|
||
metadata: { discountReservationToken: "tok_1" },
|
||
}),
|
||
"whsec_connect"
|
||
);
|
||
|
||
expect(releaseDiscountReservation).toHaveBeenCalledWith(prisma, "tok_1");
|
||
});
|
||
|
||
it("trägt die Stripe-Gebühr aus charge.updated nach - nur für Direct Charges", async () => {
|
||
const charge = { payment_intent: "pi_1", balance_transaction: "txn_1" };
|
||
|
||
await deliver({ type: "charge.updated", account: "acct_studio", data: { object: charge } });
|
||
expect(recordStripeFeeForCharge).toHaveBeenCalledWith(charge, "acct_studio");
|
||
|
||
vi.mocked(recordStripeFeeForCharge).mockClear();
|
||
await deliver({ type: "charge.updated", data: { object: charge } });
|
||
expect(recordStripeFeeForCharge).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe("Stripe-Webhook – Platzwahl", () => {
|
||
const reformer = (id: string, label: string, x: number) => ({
|
||
id,
|
||
type: "reformer",
|
||
x,
|
||
y: 1.5,
|
||
rotation: 0,
|
||
label,
|
||
});
|
||
|
||
beforeEach(() => {
|
||
tx.course.findUnique.mockResolvedValue({
|
||
maxParticipants: 10,
|
||
spotBooking: true,
|
||
room: "room-1",
|
||
});
|
||
tx.room.findUnique.mockResolvedValue({
|
||
floorPlan: {
|
||
width: 8,
|
||
depth: 6,
|
||
items: [reformer("spot-1", "1", 1), reformer("spot-2", "2", 2)],
|
||
},
|
||
});
|
||
});
|
||
|
||
const spotSession = (type = "checkout.session.completed", paymentStatus = "paid") =>
|
||
sessionEvent(type, {
|
||
account: "acct_studio",
|
||
paymentStatus,
|
||
metadata: { spotIds: "spot-1", spotHoldIds: "hold-1" },
|
||
});
|
||
|
||
it("schreibt den gewählten Platz an die Buchung und gibt den Hold frei", async () => {
|
||
await deliver(spotSession(), "whsec_connect");
|
||
|
||
expect(tx.courseBooking.upsert.mock.calls[0][0].create).toMatchObject({
|
||
paymentStatus: "paid",
|
||
spotIds: ["spot-1"],
|
||
spotLabels: ["Reformer 1"],
|
||
});
|
||
expect(prisma.courseSpotHold.deleteMany).toHaveBeenCalledWith({
|
||
where: { id: { in: ["hold-1"] } },
|
||
});
|
||
});
|
||
|
||
it("gibt den nächsten freien Platz, wenn der Wunschplatz inzwischen bezahlt ist", async () => {
|
||
tx.courseBooking.findMany.mockResolvedValue([{ spotIds: ["spot-1"] }]);
|
||
|
||
await deliver(spotSession(), "whsec_connect");
|
||
|
||
expect(tx.courseBooking.upsert.mock.calls[0][0].create).toMatchObject({
|
||
paymentStatus: "paid",
|
||
spotIds: ["spot-2"],
|
||
spotLabels: ["Reformer 2"],
|
||
});
|
||
expect(refundStripePayment).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("erstattet wie bei einem vollen Kurs, wenn kein Platz mehr frei ist", async () => {
|
||
tx.courseBooking.aggregate.mockResolvedValue({ _sum: { seats: 2 } });
|
||
tx.courseBooking.findMany.mockResolvedValue([{ spotIds: ["spot-1", "spot-2"] }]);
|
||
|
||
await deliver(spotSession(), "whsec_connect");
|
||
|
||
expect(tx.courseBooking.upsert.mock.calls[0][0].create).toMatchObject({
|
||
paymentStatus: "failed",
|
||
});
|
||
expect(refundStripePayment).toHaveBeenCalledWith({
|
||
paymentIntentId: "pi_1",
|
||
stripeAccountId: "acct_studio",
|
||
refundApplicationFee: true,
|
||
});
|
||
});
|
||
|
||
it("hält den Platz bei SEPA, bis die Zahlung ankommt", async () => {
|
||
await deliver(spotSession("checkout.session.completed", "unpaid"), "whsec_connect");
|
||
|
||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||
const call = vi.mocked(prisma.courseSpotHold.updateMany).mock.calls[0][0] as {
|
||
where: { id: string };
|
||
data: { expiresAt: Date; status: string };
|
||
};
|
||
expect(call.where).toEqual({ id: { in: ["hold-1"] } });
|
||
// Ein neuer Checkout derselben Person darf diesen Platz nicht freigeben.
|
||
expect(call.data.status).toBe("awaiting_payment");
|
||
const days = (call.data.expiresAt.getTime() - Date.now()) / 86_400_000;
|
||
expect(days).toBeGreaterThan(13);
|
||
});
|
||
|
||
it("gibt den Platz frei, wenn die Session abläuft", async () => {
|
||
await deliver(spotSession("checkout.session.expired", "unpaid"), "whsec_connect");
|
||
|
||
expect(prisma.courseSpotHold.deleteMany).toHaveBeenCalledWith({
|
||
where: { id: { in: ["hold-1"] } },
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("Stripe-Webhook – Mehrfachbuchung", () => {
|
||
const multiSession = (seats: string, metadata: Record<string, string> = {}) =>
|
||
sessionEvent("checkout.session.completed", {
|
||
account: "acct_studio",
|
||
metadata: { seats, ...metadata },
|
||
});
|
||
|
||
it("legt eine Buchung mit allen Plätzen an", async () => {
|
||
tx.courseBooking.aggregate.mockResolvedValue({ _sum: { seats: 4 } });
|
||
|
||
await deliver(multiSession("3"), "whsec_connect");
|
||
|
||
expect(tx.courseBooking.upsert.mock.calls[0][0].create).toMatchObject({
|
||
paymentStatus: "paid",
|
||
seats: 3,
|
||
seatsPurchased: 3,
|
||
});
|
||
expect(refundStripePayment).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("erstattet, wenn nicht mehr alle Plätze frei sind - kein halbes Buchen", async () => {
|
||
// 10 Plätze, 8 belegt: 3 passen nicht mehr
|
||
tx.courseBooking.aggregate.mockResolvedValue({ _sum: { seats: 8 } });
|
||
|
||
await deliver(multiSession("3"), "whsec_connect");
|
||
|
||
expect(tx.courseBooking.upsert.mock.calls[0][0].create).toMatchObject({
|
||
paymentStatus: "failed",
|
||
});
|
||
expect(refundStripePayment).toHaveBeenCalledWith({
|
||
paymentIntentId: "pi_1",
|
||
stripeAccountId: "acct_studio",
|
||
refundApplicationFee: true,
|
||
});
|
||
});
|
||
|
||
it("setzt Storno-Felder einer alten Buchung desselben Kunden zurück", async () => {
|
||
await deliver(multiSession("2"), "whsec_connect");
|
||
|
||
expect(tx.courseBooking.upsert.mock.calls[0][0].update).toMatchObject({
|
||
paymentStatus: "paid",
|
||
seats: 2,
|
||
refundedAmount: null,
|
||
refundedFeeAmount: null,
|
||
cancelledBy: null,
|
||
});
|
||
});
|
||
|
||
it("weist bei Platzwahl jedem Platz ein Gerät zu", async () => {
|
||
tx.course.findUnique.mockResolvedValue({
|
||
maxParticipants: 10,
|
||
spotBooking: true,
|
||
room: "room-1",
|
||
});
|
||
tx.room.findUnique.mockResolvedValue({
|
||
floorPlan: {
|
||
width: 8,
|
||
depth: 6,
|
||
items: ["1", "2", "3"].map((label, i) => ({
|
||
id: `spot-${label}`,
|
||
type: "reformer",
|
||
x: i + 1,
|
||
y: 1.5,
|
||
rotation: 0,
|
||
label,
|
||
})),
|
||
},
|
||
});
|
||
|
||
await deliver(
|
||
multiSession("2", { spotIds: "spot-1,spot-3", spotHoldIds: "hold-1,hold-3" }),
|
||
"whsec_connect"
|
||
);
|
||
|
||
expect(tx.courseBooking.upsert.mock.calls[0][0].create).toMatchObject({
|
||
seats: 2,
|
||
spotIds: ["spot-1", "spot-3"],
|
||
spotLabels: ["Reformer 1", "Reformer 3"],
|
||
});
|
||
expect(prisma.courseSpotHold.deleteMany).toHaveBeenCalledWith({
|
||
where: { id: { in: ["hold-1", "hold-3"] } },
|
||
});
|
||
});
|
||
|
||
it("behandelt Sessions ohne Platzanzahl als einen Platz", async () => {
|
||
await deliver(
|
||
sessionEvent("checkout.session.completed", { account: "acct_studio" }),
|
||
"whsec_connect"
|
||
);
|
||
|
||
expect(tx.courseBooking.upsert.mock.calls[0][0].create).toMatchObject({ seats: 1 });
|
||
});
|
||
});
|
||
|
||
describe("Stripe-Webhook – Karten", () => {
|
||
const passMetadata = { kind: "pass", productId: "prod-1", courseId: "" };
|
||
|
||
it("legt bei einem Kartenkauf die Karte an, keine Kursbuchung", async () => {
|
||
const res = await deliver(
|
||
sessionEvent("checkout.session.completed", {
|
||
account: "acct_studio",
|
||
metadata: passMetadata,
|
||
}),
|
||
"whsec_connect"
|
||
);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(handlePassCheckoutCompleted).toHaveBeenCalledWith(
|
||
expect.objectContaining({ id: "cs_1" }),
|
||
"acct_studio"
|
||
);
|
||
expect(tx.courseBooking.upsert).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("räumt bei einem abgelaufenen Kartenkauf nichts auf - es war nichts reserviert", async () => {
|
||
await deliver(sessionEvent("checkout.session.expired", { metadata: passMetadata }));
|
||
expect(prisma.courseSpotHold.deleteMany).not.toHaveBeenCalled();
|
||
expect(releaseDiscountReservation).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("erstattet eine Zahlung für einen Kurs, der inzwischen mit Karte gebucht ist", async () => {
|
||
tx.courseBooking.findUnique.mockResolvedValue({
|
||
id: "booking-1",
|
||
stripeSessionId: null,
|
||
paymentStatus: "paid",
|
||
passId: "pass-1",
|
||
});
|
||
|
||
await deliver(
|
||
sessionEvent("checkout.session.completed", { account: "acct_studio" }),
|
||
"whsec_connect"
|
||
);
|
||
|
||
expect(tx.courseBooking.upsert).not.toHaveBeenCalled();
|
||
expect(refundStripePayment).toHaveBeenCalledWith(
|
||
expect.objectContaining({ paymentIntentId: "pi_1", refundApplicationFee: true })
|
||
);
|
||
});
|
||
|
||
it("löst eine alte, stornierte Kartenbuchung von der Karte, wenn neu bezahlt wird", async () => {
|
||
await deliver(
|
||
sessionEvent("checkout.session.completed", { account: "acct_studio" }),
|
||
"whsec_connect"
|
||
);
|
||
expect(tx.courseBooking.upsert.mock.calls[0][0].update).toMatchObject({ passId: null });
|
||
});
|
||
|
||
it("bestätigt eine offene Kartenerstattung über charge.refunded", async () => {
|
||
const charge = { id: "ch_1", refunded: true, payment_intent: "pi_1", amount_refunded: 18500 };
|
||
await deliver(
|
||
{ type: "charge.refunded", account: "acct_studio", data: { object: charge } },
|
||
"whsec_connect"
|
||
);
|
||
expect(reconcilePassRefund).toHaveBeenCalledWith(charge);
|
||
});
|
||
});
|