mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 21:41:17 +00:00
300 lines
11 KiB
TypeScript
300 lines
11 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() },
|
||
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();
|
||
});
|
||
});
|