Kuma/tests/api/stripe/webhook.test.ts
2026-10-01 23:54:20 +02:00

300 lines
11 KiB
TypeScript
Raw 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() },
courseWaitlistEntry: { deleteMany: 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(),
}));
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";
// Die Datenbank-Transaktion der Kapazitätsprüfung.
const tx = {
courseBooking: { findUnique: vi.fn(), findFirst: vi.fn(), count: vi.fn(), upsert: vi.fn() },
course: { findUnique: 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.upsert.mockResolvedValue({ id: "booking-1" });
tx.course.findUnique.mockResolvedValue({ maxParticipants: 10 });
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(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.count.mockResolvedValue(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();
});
});