Kuma/tests/api/stripe/checkout-course.test.ts
2026-10-02 01:28:49 +02:00

560 lines
22 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, afterEach } from "vitest";
import { POST } from "@/src/app/api/stripe/checkout/course/route";
vi.mock("@/src/lib/auth/getUser", () => ({
getUserData: vi.fn(),
isUser: vi.fn(),
}));
vi.mock("@/src/lib/auth/account-role-by-email", () => ({
isManagerOrEmployeeEmail: vi.fn(),
}));
vi.mock("@/src/lib/prisma", () => ({
prisma: {
course: { findUnique: vi.fn() },
courseBooking: { findUnique: vi.fn() },
},
}));
vi.mock("@/src/lib/stripe", () => ({
stripe: { checkout: { sessions: { create: vi.fn(), retrieve: vi.fn(), expire: vi.fn() } } },
getPlatformFeeCents: vi.fn().mockReturnValue(50),
eurosToCents: vi.fn().mockReturnValue(2900),
}));
// Rabattcodes nur für den Test, dass ein Fehler bei der Platzwahl die
// Reservierung wieder freigibt.
vi.mock("@/src/modules/settings/lib/discount-validation", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/src/modules/settings/lib/discount-validation")>();
return {
reserveDiscountCode: vi.fn(),
releaseDiscountReservation: vi.fn().mockResolvedValue(undefined),
// Die echte Rechnung - die Tests prüfen, wie der Rabatt auf mehrere Plätze wirkt.
calculateDiscountedPriceCents: actual.calculateDiscountedPriceCents,
};
});
// Belegte Plätze des Kurses - pro Test überschreibbar.
vi.mock("@/src/modules/courses/lib/seat-counts", () => ({
MAX_SEATS_PER_BOOKING: 10,
paidSeatCount: vi.fn().mockResolvedValue(0),
}));
// Kurse ohne Platzwahl liefern null - so bleiben alle übrigen Tests unberührt.
vi.mock("@/src/modules/courses/lib/course-spots", () => ({
getCourseSpots: vi.fn().mockResolvedValue(null),
getOpenCheckoutHolds: vi.fn().mockResolvedValue([]),
attachSessionToHolds: vi.fn().mockResolvedValue(undefined),
holdSpot: vi.fn(),
releaseSpotHolds: vi.fn().mockResolvedValue(undefined),
spotHolderKey: (p: { userId?: string | null; guestEmail?: string | null }) =>
p.userId ? `user:${p.userId}` : `guest:${p.guestEmail}`,
SPOT_HOLD_MINUTES: 30,
}));
import { getUserData, isUser } from "@/src/lib/auth/getUser";
import { isManagerOrEmployeeEmail } from "@/src/lib/auth/account-role-by-email";
import { prisma } from "@/src/lib/prisma";
import { stripe, getPlatformFeeCents } from "@/src/lib/stripe";
import {
attachSessionToHolds,
getCourseSpots,
getOpenCheckoutHolds,
holdSpot,
releaseSpotHolds,
} from "@/src/modules/courses/lib/course-spots";
import {
reserveDiscountCode,
releaseDiscountReservation,
} from "@/src/modules/settings/lib/discount-validation";
import { paidSeatCount } from "@/src/modules/courses/lib/seat-counts";
let ipCounter = 0;
function makeRequest(body: Record<string, unknown>) {
// Jede Anfrage aus einer eigenen IP, damit der In-Memory-Rate-Limiter
// (10/Minute/IP) die Tests nicht gegenseitig beeinflusst.
ipCounter += 1;
return new Request("http://localhost/api/stripe/checkout/course", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-forwarded-for": `10.0.0.${ipCounter}`,
},
body: JSON.stringify(body),
}) as never;
}
const publishedCourse = {
id: "course-1",
name: "Yoga",
status: "published",
price: 29,
date: new Date("2099-01-01"),
timeFrom: "10:00",
maxParticipants: 20,
business: {
id: "biz-1",
name: "Studio",
stripeAccountId: "acct_123",
stripeAccountStatus: "active",
},
};
beforeEach(() => {
vi.mocked(getUserData).mockReset();
vi.mocked(isUser).mockReset();
vi.mocked(isManagerOrEmployeeEmail).mockReset();
vi.mocked(prisma.course.findUnique).mockReset();
vi.mocked(prisma.courseBooking.findUnique).mockReset();
vi.mocked(stripe.checkout.sessions.create).mockReset();
vi.mocked(stripe.checkout.sessions.create).mockResolvedValue({
url: "https://checkout.stripe.com/session",
} as never);
vi.mocked(prisma.course.findUnique).mockResolvedValue(publishedCourse as never);
});
describe("POST /api/stripe/checkout/course – Gast mit Team-Account-E-Mail (Ticket 6)", () => {
it("lehnt eine Gast-Buchung mit Manager/Employee-E-Mail mit 403 ab, ohne Stripe-Session", async () => {
vi.mocked(getUserData).mockResolvedValue(null as never);
vi.mocked(isManagerOrEmployeeEmail).mockResolvedValue(true);
const res = await POST(makeRequest({ courseId: "course-1", guestEmail: "Manager@Studio.de" }));
expect(res.status).toBe(403);
const data = await res.json();
expect(data.error).toMatch(/Team-Account/i);
expect(isManagerOrEmployeeEmail).toHaveBeenCalledWith("manager@studio.de");
expect(stripe.checkout.sessions.create).not.toHaveBeenCalled();
});
it("lässt eine normale Gast-Buchung durch, wenn die E-Mail zu keinem Team-Account gehört", async () => {
vi.mocked(getUserData).mockResolvedValue(null as never);
vi.mocked(isManagerOrEmployeeEmail).mockResolvedValue(false);
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue(null as never);
const res = await POST(makeRequest({ courseId: "course-1", guestEmail: "gast@example.com" }));
expect(res.status).toBe(200);
const data = await res.json();
expect(data.url).toBe("https://checkout.stripe.com/session");
expect(stripe.checkout.sessions.create).toHaveBeenCalledOnce();
});
it("prüft die Team-Account-E-Mail nicht für eingeloggte Kunden-Accounts", async () => {
vi.mocked(getUserData).mockResolvedValue({
id: "user-1",
email: "kunde@example.com",
} as never);
vi.mocked(isUser).mockReturnValue(true);
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue(null as never);
const res = await POST(makeRequest({ courseId: "course-1" }));
expect(res.status).toBe(200);
expect(isManagerOrEmployeeEmail).not.toHaveBeenCalled();
});
it("weist eine ungültige Gast-E-Mail weiterhin vor der Team-Account-Prüfung mit 400 ab", async () => {
vi.mocked(getUserData).mockResolvedValue(null as never);
const res = await POST(makeRequest({ courseId: "course-1", guestEmail: "keine-email" }));
expect(res.status).toBe(400);
const data = await res.json();
expect(data.isGuestRequired).toBe(true);
expect(isManagerOrEmployeeEmail).not.toHaveBeenCalled();
});
});
describe("POST /api/stripe/checkout/course – Servicegebühr", () => {
it("schlägt die Gebühr als eigene Position auf und behält sie als application_fee ein", async () => {
vi.mocked(getUserData).mockResolvedValue({ id: "user-1", email: "kunde@example.com" } as never);
vi.mocked(isUser).mockReturnValue(true);
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue(null as never);
const res = await POST(makeRequest({ courseId: "course-1" }));
expect(res.status).toBe(200);
const [args, options] = vi.mocked(stripe.checkout.sessions.create).mock.calls[0] as never as [
{
line_items: { price_data: { unit_amount: number; product_data: { name: string } } }[];
payment_intent_data: { application_fee_amount?: number; transfer_data?: unknown };
metadata: Record<string, string>;
},
{ stripeAccount?: string },
];
// Direct Charge: die Session entsteht auf dem Studio-Account, ohne
// Transfer von der Plattform.
expect(options).toEqual({ stripeAccount: "acct_123" });
expect(args.payment_intent_data.transfer_data).toBeUndefined();
// Kurs zum vollen Preis + separate Gebührenposition
expect(args.line_items).toHaveLength(2);
expect(args.line_items[0].price_data.unit_amount).toBe(2900);
expect(args.line_items[1].price_data.product_data.name).toBe("Servicegebühr");
expect(args.line_items[1].price_data.unit_amount).toBe(50);
// Kumasy bekommt exakt die Gebühr als application_fee.
expect(args.payment_intent_data.application_fee_amount).toBe(50);
// Der Webhook erkennt daran, dass die Gebühr obendrauf kam.
expect(args.metadata.platformFeeModel).toBe("on_top");
});
it("erzeugt keine Gebührenposition, wenn die Gebühr auf 0 konfiguriert ist", async () => {
vi.mocked(getPlatformFeeCents).mockReturnValueOnce(0);
vi.mocked(getUserData).mockResolvedValue({ id: "user-1", email: "kunde@example.com" } as never);
vi.mocked(isUser).mockReturnValue(true);
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue(null as never);
const res = await POST(makeRequest({ courseId: "course-1" }));
expect(res.status).toBe(200);
const args = vi.mocked(stripe.checkout.sessions.create).mock.calls[0][0] as never as {
line_items: unknown[];
payment_intent_data: { application_fee_amount?: number };
};
expect(args.line_items).toHaveLength(1);
// Ohne Gebühr wird gar keine application_fee gesetzt statt einer von 0.
expect(args.payment_intent_data.application_fee_amount).toBeUndefined();
});
});
describe("POST /api/stripe/checkout/course – Checkout-Seite", () => {
function loggedIn() {
vi.mocked(getUserData).mockResolvedValue({ id: "user-1", email: "kunde@example.com" } as never);
vi.mocked(isUser).mockReturnValue(true);
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue(null as never);
}
it("verlangt die Rechnungsadresse und trägt das Branding des Studios", async () => {
loggedIn();
const res = await POST(makeRequest({ courseId: "course-1" }));
expect(res.status).toBe(200);
const args = vi.mocked(stripe.checkout.sessions.create).mock.calls[0][0] as never as {
billing_address_collection: string;
branding_settings: Record<string, unknown>;
};
expect(args.billing_address_collection).toBe("required");
// Ohne eigenes Studio-Branding greifen die Kumasy-Farben.
expect(args.branding_settings).toMatchObject({
display_name: "Studio",
background_color: "#1E293B",
button_color: "#F4C00C",
border_style: "rounded",
});
});
it("bucht ohne Branding weiter, wenn Stripe das Branding ablehnt", async () => {
loggedIn();
vi.mocked(stripe.checkout.sessions.create)
.mockRejectedValueOnce(
Object.assign(new Error("Invalid logo"), { param: "branding_settings[logo][url]" })
)
.mockResolvedValueOnce({ url: "https://checkout.stripe.com/ohne-branding" } as never);
const res = await POST(makeRequest({ courseId: "course-1" }));
expect(res.status).toBe(200);
expect((await res.json()).url).toBe("https://checkout.stripe.com/ohne-branding");
const retry = vi.mocked(stripe.checkout.sessions.create).mock.calls[1][0] as never as {
branding_settings?: unknown;
billing_address_collection: string;
};
expect(retry.branding_settings).toBeUndefined();
expect(retry.billing_address_collection).toBe("required");
});
});
describe("POST /api/stripe/checkout/course – Platzwahl", () => {
const reformer = (id: string, label: string) => ({
id,
type: "reformer" as const,
x: 1,
y: 1.5,
rotation: 0 as const,
label,
});
beforeEach(() => {
vi.mocked(getUserData).mockResolvedValue({ id: "user-1", email: "kunde@example.com" } as never);
vi.mocked(isUser).mockReturnValue(true);
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue(null as never);
vi.mocked(getCourseSpots).mockResolvedValue([reformer("spot-1", "1"), reformer("spot-2", "2")]);
vi.mocked(holdSpot).mockReset();
vi.mocked(releaseSpotHolds).mockClear();
vi.mocked(attachSessionToHolds).mockClear();
vi.mocked(getOpenCheckoutHolds).mockResolvedValue([]);
vi.mocked(stripe.checkout.sessions.retrieve).mockReset();
vi.mocked(stripe.checkout.sessions.expire).mockReset();
vi.mocked(releaseDiscountReservation).mockClear();
});
afterEach(() => {
vi.mocked(getCourseSpots).mockResolvedValue(null);
vi.mocked(getOpenCheckoutHolds).mockResolvedValue([]);
});
it("verlangt einen Platz, bevor es zu Stripe geht", async () => {
const res = await POST(makeRequest({ courseId: "course-1" }));
expect(res.status).toBe(400);
expect((await res.json()).spotUnavailable).toBe(true);
expect(holdSpot).not.toHaveBeenCalled();
expect(stripe.checkout.sessions.create).not.toHaveBeenCalled();
});
it("lehnt einen Platz ab, den gerade jemand anderes bucht", async () => {
vi.mocked(holdSpot).mockResolvedValue({ held: false, reason: "Platz gerade vergeben" });
const res = await POST(makeRequest({ courseId: "course-1", spotId: "spot-2" }));
expect(res.status).toBe(409);
// Bei mehreren Plätzen muss klar sein, welcher nicht geklappt hat.
expect(await res.json()).toMatchObject({
error: "Reformer 2: Platz gerade vergeben",
spotUnavailable: true,
});
expect(stripe.checkout.sessions.create).not.toHaveBeenCalled();
});
it("hält den Platz und lässt die Session mit dem Hold verfallen", async () => {
vi.mocked(holdSpot).mockResolvedValue({ held: true, holdId: "hold-1" });
const res = await POST(makeRequest({ courseId: "course-1", spotId: "spot-2" }));
expect(res.status).toBe(200);
expect(vi.mocked(holdSpot).mock.calls[0][1]).toMatchObject({
courseId: "course-1",
spotId: "spot-2",
holderKey: "user:user-1",
});
const args = vi.mocked(stripe.checkout.sessions.create).mock.calls[0][0] as never as {
expires_at?: number;
metadata: Record<string, string>;
line_items: { price_data: { product_data: { description: string } } }[];
};
expect(args.metadata).toMatchObject({ seats: "1", spotIds: "spot-2", spotHoldIds: "hold-1" });
expect(args.line_items[0].price_data.product_data.description).toContain("Reformer 2");
const inMinutes = (args.expires_at! * 1000 - Date.now()) / 60_000;
expect(inMinutes).toBeGreaterThan(29);
expect(inMinutes).toBeLessThanOrEqual(30);
});
it("gibt den Platz frei, wenn Stripe die Session nicht anlegt", async () => {
vi.mocked(holdSpot).mockResolvedValue({ held: true, holdId: "hold-1" });
vi.mocked(stripe.checkout.sessions.create).mockRejectedValueOnce(new Error("Stripe down"));
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
const res = await POST(makeRequest({ courseId: "course-1", spotId: "spot-1" }));
expect(res.status).toBe(500);
expect(releaseSpotHolds).toHaveBeenCalledWith(prisma, ["hold-1"]);
consoleError.mockRestore();
});
it("setzt ohne Platzwahl kein Ablaufdatum - Stripes Standard bleibt", async () => {
vi.mocked(getCourseSpots).mockResolvedValue(null);
const res = await POST(makeRequest({ courseId: "course-1", spotId: "spot-1" }));
expect(res.status).toBe(200);
const args = vi.mocked(stripe.checkout.sessions.create).mock.calls[0][0] as never as {
expires_at?: number;
metadata: Record<string, string>;
};
expect(args.expires_at).toBeUndefined();
expect(args.metadata.spotIds).toBe("");
expect(holdSpot).not.toHaveBeenCalled();
});
it("gibt den Rabattcode frei, wenn die Platzwahl mit einem Fehler abbricht", async () => {
vi.mocked(reserveDiscountCode).mockResolvedValue({
reserved: true,
reservationToken: "tok-1",
discountCode: { id: "code-1", type: "percent", value: 30 },
} as never);
vi.mocked(holdSpot).mockRejectedValue(new Error("DB weg"));
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
const res = await POST(
makeRequest({ courseId: "course-1", spotId: "spot-1", discountCode: "PILATES30" })
);
expect(res.status).toBe(500);
expect(releaseDiscountReservation).toHaveBeenCalledWith(prisma, "tok-1");
expect(stripe.checkout.sessions.create).not.toHaveBeenCalled();
consoleError.mockRestore();
});
it("hängt die neue Session an den Hold", async () => {
vi.mocked(holdSpot).mockResolvedValue({ held: true, holdId: "hold-1" });
vi.mocked(stripe.checkout.sessions.create).mockResolvedValueOnce({
id: "cs_neu",
url: "https://checkout.stripe.com/neu",
} as never);
await POST(makeRequest({ courseId: "course-1", spotId: "spot-1" }));
expect(attachSessionToHolds).toHaveBeenCalledWith(prisma, ["hold-1"], "cs_neu");
});
it("beendet einen alten, noch offenen Checkout derselben Person", async () => {
vi.mocked(getOpenCheckoutHolds).mockResolvedValue([
{ id: "hold-alt", stripeSessionId: "cs_alt" },
]);
vi.mocked(stripe.checkout.sessions.retrieve).mockResolvedValue({
id: "cs_alt",
status: "open",
} as never);
vi.mocked(holdSpot).mockResolvedValue({ held: true, holdId: "hold-1" });
await POST(makeRequest({ courseId: "course-1", spotId: "spot-2" }));
expect(stripe.checkout.sessions.expire).toHaveBeenCalledWith(
"cs_alt",
{},
{ stripeAccount: "acct_123" }
);
expect(releaseSpotHolds).toHaveBeenCalledWith(prisma, ["hold-alt"]);
});
it("lässt den Platz eines schon bezahlten Checkouts stehen, bis der Webhook kommt", async () => {
vi.mocked(getOpenCheckoutHolds).mockResolvedValue([
{ id: "hold-alt", stripeSessionId: "cs_alt" },
]);
vi.mocked(stripe.checkout.sessions.retrieve).mockResolvedValue({
id: "cs_alt",
status: "complete",
} as never);
vi.mocked(holdSpot).mockResolvedValue({ held: true, holdId: "hold-1" });
await POST(makeRequest({ courseId: "course-1", spotId: "spot-2" }));
expect(stripe.checkout.sessions.expire).not.toHaveBeenCalled();
expect(releaseSpotHolds).not.toHaveBeenCalledWith(prisma, ["hold-alt"]);
});
it("lässt den alten Hold im Zweifel stehen, wenn Stripe nicht antwortet", async () => {
vi.mocked(getOpenCheckoutHolds).mockResolvedValue([
{ id: "hold-alt", stripeSessionId: "cs_alt" },
]);
vi.mocked(stripe.checkout.sessions.retrieve).mockRejectedValue(new Error("Timeout"));
vi.mocked(holdSpot).mockResolvedValue({ held: true, holdId: "hold-1" });
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
const res = await POST(makeRequest({ courseId: "course-1", spotId: "spot-2" }));
expect(res.status).toBe(200);
expect(releaseSpotHolds).not.toHaveBeenCalledWith(prisma, ["hold-alt"]);
consoleWarn.mockRestore();
});
});
describe("POST /api/stripe/checkout/course – Mehrfachbuchung", () => {
type SessionArgs = {
line_items: {
price_data: { unit_amount: number; product_data: { name: string } };
quantity: number;
}[];
payment_intent_data: { application_fee_amount?: number };
metadata: Record<string, string>;
};
const sessionArgs = () =>
vi.mocked(stripe.checkout.sessions.create).mock.calls[0][0] as never as SessionArgs;
beforeEach(() => {
vi.mocked(getUserData).mockResolvedValue({ id: "user-1", email: "kunde@example.com" } as never);
vi.mocked(isUser).mockReturnValue(true);
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue(null as never);
vi.mocked(paidSeatCount).mockResolvedValue(0);
vi.mocked(reserveDiscountCode).mockReset();
vi.mocked(holdSpot).mockReset();
});
it("berechnet Kurspreis und Servicegebühr pro Platz", async () => {
const res = await POST(makeRequest({ courseId: "course-1", seats: 3 }));
expect(res.status).toBe(200);
const args = sessionArgs();
expect(args.line_items[0]).toMatchObject({ quantity: 3, price_data: { unit_amount: 2900 } });
expect(args.line_items[1]).toMatchObject({ quantity: 3, price_data: { unit_amount: 50 } });
expect(args.payment_intent_data.application_fee_amount).toBe(150);
expect(args.metadata.seats).toBe("3");
});
it("lässt Gäste nur einen Platz buchen", async () => {
vi.mocked(getUserData).mockResolvedValue(null as never);
vi.mocked(isManagerOrEmployeeEmail).mockResolvedValue(false);
const res = await POST(
makeRequest({ courseId: "course-1", guestEmail: "gast@example.com", seats: 2 })
);
expect(res.status).toBe(400);
expect(stripe.checkout.sessions.create).not.toHaveBeenCalled();
});
it("lehnt mehr Plätze ab, als frei sind", async () => {
// 20 Plätze, 18 belegt
vi.mocked(paidSeatCount).mockResolvedValue(18);
const res = await POST(makeRequest({ courseId: "course-1", seats: 3 }));
expect(res.status).toBe(400);
expect((await res.json()).error).toContain("2 Plätze frei");
expect(stripe.checkout.sessions.create).not.toHaveBeenCalled();
});
it("lehnt eine ungültige Platzanzahl ab", async () => {
for (const seats of [0, 11, 1.5]) {
const res = await POST(makeRequest({ courseId: "course-1", seats }));
expect(res.status).toBe(400);
}
expect(stripe.checkout.sessions.create).not.toHaveBeenCalled();
});
it("zieht einen festen Rabatt einmal pro Buchung ab", async () => {
vi.mocked(reserveDiscountCode).mockResolvedValue({
reserved: true,
reservationToken: "tok-1",
discountCode: { id: "code-1", type: "fixed", value: 501 },
} as never);
const res = await POST(makeRequest({ courseId: "course-1", seats: 2, discountCode: "FIX5" }));
expect(res.status).toBe(200);
// 2 × 29 € − 5,01 € = 52,99 € - teilt sich nicht in ganze Cent pro Platz,
// also eine Position mit der Platzanzahl im Namen
const course = sessionArgs().line_items[0];
expect(course.quantity).toBe(1);
expect(course.price_data.unit_amount).toBe(5299);
expect(course.price_data.product_data.name).toBe("Yoga (2 Plätze)");
});
it("verlangt bei Platzwahl genau so viele Plätze wie gebucht werden", async () => {
const reformer = (id: string, label: string) => ({
id,
type: "reformer" as const,
x: 1,
y: 1.5,
rotation: 0 as const,
label,
});
vi.mocked(getCourseSpots).mockResolvedValue([reformer("s1", "1"), reformer("s2", "2")]);
const res = await POST(makeRequest({ courseId: "course-1", seats: 2, spotIds: ["s1"] }));
expect(res.status).toBe(400);
expect((await res.json()).spotUnavailable).toBe(true);
expect(holdSpot).not.toHaveBeenCalled();
vi.mocked(getCourseSpots).mockResolvedValue(null);
});
});