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