Kuma/tests/courses/cancel-booking-refund.test.ts
2026-10-02 01:28:49 +02:00

302 lines
11 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/prisma", () => ({
prisma: {
courseBooking: { findUnique: vi.fn(), updateMany: vi.fn(), update: vi.fn() },
courseWaitlistEntry: { count: vi.fn() },
user: { findUnique: vi.fn() },
guestLead: { findUnique: vi.fn() },
},
}));
vi.mock("@/src/lib/stripe-refund", () => ({ refundStripePayment: vi.fn() }));
vi.mock("@/src/lib/mail/resend", () => ({ sendMail: vi.fn() }));
vi.mock("@/src/lib/mail/templates/booking-refund", () => ({
generateBookingRefundEmail: vi.fn().mockReturnValue({ subject: "s", html: "h", text: "t" }),
}));
vi.mock("@/src/modules/courses/actions/notify-waitlist", () => ({ notifyCourseWaitlist: vi.fn() }));
vi.mock("@/src/modules/notifications/lib/create-notification", () => ({
notifyBusinessManager: vi.fn(),
}));
vi.mock("@/src/modules/settings/lib/to-email-branding", () => ({
toEmailBranding: vi.fn().mockReturnValue({}),
}));
vi.mock("@/src/modules/courses/actions/cancellation-policy", async (importOriginal) => {
const actual = await importOriginal<object>();
return { ...actual, calculateRefundAmountCents: vi.fn() };
});
import { executeBookingCancellation } from "@/src/modules/courses/actions/cancel-booking-shared";
import { calculateRefundAmountCents } from "@/src/modules/courses/actions/cancellation-policy";
import { refundStripePayment } from "@/src/lib/stripe-refund";
import { prisma } from "@/src/lib/prisma";
import { generateBookingRefundEmail } from "@/src/lib/mail/templates/booking-refund";
import { notifyCourseWaitlist } from "@/src/modules/courses/actions/notify-waitlist";
import { notifyBusinessManager } from "@/src/modules/notifications/lib/create-notification";
// Kunde zahlte 29,50 € = 29,00 € Kurs + 0,50 € Servicegebühr.
const booking = {
paymentStatus: "paid",
amountPaid: 2950,
platformFee: 50,
platformFeeOnTop: true,
stripePaymentIntentId: "pi_1",
stripeAccountId: "acct_studio",
userId: "user-1",
guestLeadId: null,
seats: 1,
seatsPurchased: 1,
spotIds: [] as string[],
spotLabels: [] as string[],
refundedAmount: null as number | null,
refundedFeeAmount: null as number | null,
refundedAt: null as Date | null,
course: {
id: "course-1",
name: "Yoga",
date: new Date("2099-01-01"),
timeFrom: "10:00",
businessId: "biz-1",
business: { name: "Studio", branding: null },
},
};
beforeEach(() => {
// Ohne das leben die mock.calls des vorigen Tests weiter und die
// Assertions auf calls[0] prüfen den falschen Aufruf.
vi.clearAllMocks();
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue(booking as never);
vi.mocked(prisma.courseBooking.updateMany).mockResolvedValue({ count: 1 } as never);
vi.mocked(prisma.courseBooking.update).mockResolvedValue({} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({ email: "kunde@example.com" } as never);
vi.mocked(prisma.courseWaitlistEntry.count).mockResolvedValue(0 as never);
vi.mocked(notifyCourseWaitlist).mockResolvedValue(undefined as never);
vi.mocked(notifyBusinessManager).mockResolvedValue(undefined as never);
vi.mocked(generateBookingRefundEmail).mockReturnValue({
subject: "s",
html: "h",
text: "t",
} as never);
vi.mocked(refundStripePayment).mockResolvedValue({
success: true,
stripeRefundId: "re_1",
amountRefundedCents: 2900,
});
vi.mocked(calculateRefundAmountCents).mockResolvedValue({
cancellationAllowed: true,
refundPercent: 100,
refundAmountCents: 2900,
});
});
describe("executeBookingCancellation – Servicegebühr", () => {
it("erstattet nur den Kursanteil und behält die Servicegebühr", async () => {
const result = await executeBookingCancellation("booking-1", "user");
expect(result.success).toBe(true);
// Bezugsgröße der Stornoquote ist der Kursanteil, nicht die Charge.
expect(vi.mocked(calculateRefundAmountCents).mock.calls[0][0].amountPaidCents).toBe(2900);
const { amountCents, refundApplicationFee, stripeAccountId } =
vi.mocked(refundStripePayment).mock.calls[0][0];
expect(amountCents).toBe(2900);
expect(refundApplicationFee).toBe(false);
// Direct Charge: erstattet wird auf dem Studio-Account, auf dem die Zahlung liegt.
expect(stripeAccountId).toBe("acct_studio");
});
it("gilt als vollständig erstattet, obwohl weniger als die Charge zurückging", async () => {
await executeBookingCancellation("booking-1", "user");
// 2900 < amountPaid (2950) - ohne die Kursanteil-Bezugsgröße stünde hier
// fälschlich "partially_refunded".
expect(vi.mocked(prisma.courseBooking.update).mock.calls[0][0].data.paymentStatus).toBe(
"refunded"
);
});
it("erstattet bei einer Kursabsage alles an den Kunden, gibt die Gebühr aber nicht ab", async () => {
vi.mocked(refundStripePayment).mockResolvedValue({
success: true,
stripeRefundId: "re_2",
amountRefundedCents: 2950,
});
await executeBookingCancellation("booking-1", "manager", { forceFullRefund: true });
const { amountCents, refundApplicationFee } = vi.mocked(refundStripePayment).mock.calls[0][0];
// undefined = komplette Charge inkl. Servicegebühr zurück an den Kunden.
expect(amountCents).toBeUndefined();
// Die Gebühr wandert NICHT ans Studio zurück: wer absagt, trägt die
// Zahlungskosten seiner Absage.
expect(refundApplicationFee).toBe(false);
});
it("rechnet bei Altbuchungen ohne aufgeschlagene Gebühr unverändert", async () => {
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue({
...booking,
amountPaid: 2900,
platformFee: 145,
platformFeeOnTop: false,
} as never);
await executeBookingCancellation("booking-1", "user");
// Alte Prozent-Fee: amountPaid IST der Kursanteil, nichts abziehen.
expect(vi.mocked(calculateRefundAmountCents).mock.calls[0][0].amountPaidCents).toBe(2900);
});
it("erstattet Altbuchungen über die Plattform (Destination Charge)", async () => {
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue({
...booking,
stripeAccountId: null,
} as never);
await executeBookingCancellation("booking-1", "user");
// null schaltet refundStripePayment auf reverse_transfer um.
expect(vi.mocked(refundStripePayment).mock.calls[0][0].stripeAccountId).toBeNull();
});
});
describe("executeBookingCancellation – einzelne Plätze einer Mehrfachbuchung", () => {
// 3 Plätze à 29 € + 3 × 0,50 € Servicegebühr = 88,50 €
const threeSeats = {
...booking,
amountPaid: 8850,
platformFee: 150,
seats: 3,
seatsPurchased: 3,
};
const lastUpdate = () => vi.mocked(prisma.courseBooking.update).mock.calls.at(-1)![0].data;
it("storniert einen Platz und lässt die Buchung mit zwei Plätzen aktiv", async () => {
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue(threeSeats as never);
const result = await executeBookingCancellation("booking-1", "user", {
seatSelection: { seats: 1 },
});
expect(result).toMatchObject({ success: true, remainingSeats: 2 });
// Erstattungsbasis ist der Kursanteil EINES Platzes
expect(vi.mocked(calculateRefundAmountCents).mock.calls[0][0].amountPaidCents).toBe(2900);
// Claim an den Platzstand gebunden, Idempotenz pro Schritt
expect(vi.mocked(prisma.courseBooking.updateMany).mock.calls[0][0].where).toMatchObject({
paymentStatus: "paid",
seats: 3,
});
expect(vi.mocked(refundStripePayment).mock.calls[0][0].idempotencyKey).toBe(
"refund-booking-1-3"
);
expect(lastUpdate()).toMatchObject({ paymentStatus: "paid", seats: 2, refundedAmount: 2900 });
expect(lastUpdate().cancelledBy).toBeUndefined();
});
it("gilt als erstattet, wenn auch der letzte Rest voll zurückgeht", async () => {
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue({
...threeSeats,
seats: 2,
refundedAmount: 2900,
} as never);
vi.mocked(calculateRefundAmountCents).mockResolvedValue({
cancellationAllowed: true,
refundPercent: 100,
refundAmountCents: 5800,
});
vi.mocked(refundStripePayment).mockResolvedValue({
success: true,
stripeRefundId: "re_3",
amountRefundedCents: 5800,
});
const result = await executeBookingCancellation("booking-1", "user");
expect(result).toMatchObject({ success: true, remainingSeats: 0 });
expect(vi.mocked(calculateRefundAmountCents).mock.calls[0][0].amountPaidCents).toBe(5800);
expect(lastUpdate()).toMatchObject({
paymentStatus: "refunded",
refundedAmount: 8700,
cancelledBy: "user",
});
});
it("bleibt teilerstattet, wenn ein früherer Platz nur anteilig zurückging", async () => {
// Erster Platz zu 50 % storniert (14,50 €), jetzt der Rest zu 100 %
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue({
...threeSeats,
seats: 2,
refundedAmount: 1450,
} as never);
vi.mocked(calculateRefundAmountCents).mockResolvedValue({
cancellationAllowed: true,
refundPercent: 100,
refundAmountCents: 5800,
});
vi.mocked(refundStripePayment).mockResolvedValue({
success: true,
stripeRefundId: "re_4",
amountRefundedCents: 5800,
});
await executeBookingCancellation("booking-1", "user");
// Das Studio behält 14,50 € - die Buchung bleibt rechnungsfähig.
expect(lastUpdate()).toMatchObject({
paymentStatus: "partially_refunded",
refundedAmount: 7250,
});
});
it("gibt bei Platzwahl genau die gewählten Geräte frei", async () => {
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue({
...threeSeats,
spotIds: ["r1", "r2", "r3"],
spotLabels: ["Reformer 1", "Reformer 2", "Reformer 3"],
} as never);
await executeBookingCancellation("booking-1", "user", {
seatSelection: { spotIds: ["r2"] },
});
expect(lastUpdate()).toMatchObject({
seats: 2,
spotIds: ["r1", "r3"],
spotLabels: ["Reformer 1", "Reformer 3"],
});
});
it("erstattet bei einer Kursabsage nach einem Teilstorno nur noch den Rest", async () => {
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue({
...threeSeats,
seats: 2,
refundedAmount: 2900,
} as never);
vi.mocked(refundStripePayment).mockResolvedValue({
success: true,
stripeRefundId: "re_5",
amountRefundedCents: 5900,
});
await executeBookingCancellation("booking-1", "manager", { forceFullRefund: true });
// 2 Plätze Kurs (58 €) + deren Servicegebühr (1 €) - explizit, nicht "alles"
expect(vi.mocked(refundStripePayment).mock.calls[0][0].amountCents).toBe(5900);
expect(lastUpdate()).toMatchObject({
paymentStatus: "refunded",
refundedAmount: 8800,
refundedFeeAmount: 100,
});
});
it("lehnt eine ungültige Auswahl ab, bevor etwas passiert", async () => {
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue(threeSeats as never);
const result = await executeBookingCancellation("booking-1", "user", {
seatSelection: { seats: 4 },
});
expect(result.success).toBe(false);
expect(prisma.courseBooking.updateMany).not.toHaveBeenCalled();
expect(refundStripePayment).not.toHaveBeenCalled();
});
});