Kuma/tests/api/stripe/webhook.test.ts
2026-10-02 15:36:12 +02:00

583 lines
20 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
});
});