see comments

This commit is contained in:
Rajpreet0 2026-10-02 00:56:16 +02:00
parent a8db32bf24
commit bc06a983de
8 changed files with 394 additions and 141 deletions

View file

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "CourseSpotHold" ADD COLUMN "status" TEXT NOT NULL DEFAULT 'checkout',
ADD COLUMN "stripeSessionId" TEXT;

View file

@ -257,16 +257,19 @@ model CourseBooking {
// Webhook nach der Zahlung gelöscht (dann belegt die Buchung den Platz) oder bei
// abgelaufener Session freigegeben. Abgelaufene Holds (expiresAt < jetzt) zählen
// nicht und werden beim nächsten Reservieren aufgeräumt.
// holderKey = "user:<id>" oder "guest:<email>": pro Person und Kurs gibt es höchstens
// einen Hold - wer zurück geht und einen anderen Platz wählt, gibt den alten frei.
// holderKey = "user:<id>" oder "guest:<email>": pro Person und Kurs läuft höchstens
// ein offener Checkout - wer zurück geht und neu wählt, beendet den alten (siehe
// Checkout-Route). Holds abgeschlossener Checkouts bleiben dabei unangetastet.
model CourseSpotHold {
id String @id @default(uuid())
courseId String
spotId String
holderKey String
expiresAt DateTime
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
id String @id @default(uuid())
courseId String
spotId String
holderKey String
expiresAt DateTime
stripeSessionId String? // Checkout-Session, für die der Platz gehalten wird. null, solange sie angelegt wird.
status String @default("checkout") // "checkout" (Session offen) | "awaiting_payment" (Checkout abgeschlossen, SEPA-Zahlung unterwegs)
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([courseId, spotId])
@@index([courseId])

View file

@ -12,7 +12,9 @@ import {
calculateDiscountedPriceCents,
} from "@/src/modules/settings/lib/discount-validation";
import {
attachSessionToHold,
getCourseSpots,
getOpenCheckoutHolds,
holdSpot,
releaseSpotHold,
spotHolderKey,
@ -48,6 +50,40 @@ async function createBrandedCheckoutSession(
}
}
/**
* Pro Person und Kurs läuft höchstens ein offener Checkout mit Platz. Startet
* jemand neu (zurück aus dem Checkout, zweiter Tab), wird die alte Session bei
* Stripe beendet und ihr Platz freigegeben - so kann sie auch nicht später
* noch bezahlt werden.
*
* Freigegeben wird nur, was Stripe nachweislich als offen meldet. Ist die alte
* Session schon abgeschlossen (bezahlt, Webhook noch unterwegs), behält sie
* ihren Platz. Im Zweifel (Stripe nicht erreichbar) bleibt der Hold stehen und
* läuft von selbst ab.
*/
async function endOpenCheckouts(courseId: string, holderKey: string, stripeAccountId: string) {
const options = { stripeAccount: stripeAccountId };
for (const hold of await getOpenCheckoutHolds(prisma, courseId, holderKey)) {
// Ohne Session-ID wurde die Session nie fertig angelegt - nichts zu beenden.
if (!hold.stripeSessionId) {
await releaseSpotHold(prisma, hold.id);
continue;
}
try {
const old = await stripe.checkout.sessions.retrieve(hold.stripeSessionId, {}, options);
if (old.status === "complete") continue;
if (old.status === "open") await stripe.checkout.sessions.expire(old.id, {}, options);
await releaseSpotHold(prisma, hold.id);
} catch (error) {
console.warn(
`Alte Checkout-Session ${hold.stripeSessionId} konnte nicht beendet werden, ` +
`Platz-Hold ${hold.id} bleibt bis zum Ablauf bestehen:`,
error
);
}
}
}
// Simple in-memory rate limiter: max 10 checkout attempts per IP per minute.
// Best-effort only — in multi-instance/serverless deployments each instance has
// its own Map, so a determined attacker can bypass it. The main purpose is to
@ -291,89 +327,119 @@ export async function POST(request: NextRequest) {
reservationToken = reservation.reservationToken;
}
// Platzwahl: erst nach dem Rabattcode, damit ein abgelehnter Code keinen
// Platz blockiert. Kann der Platz nicht gehalten werden, wird die eben
// reservierte Rabattcode-Nutzung wieder freigegeben.
const spots = await getCourseSpots(prisma, course);
const holderKey = spotHolderKey({
userId: isGuest ? null : userData!.id,
guestEmail: isGuest ? guestEmail : null,
});
let spotHoldId: string | null = null;
let spotLabel: string | null = null;
if (spots) {
const spot = spots.find((s) => s.id === spotId);
const rejectSpot = async (error: string, status: number) => {
if (reservationToken) await releaseDiscountReservation(prisma, reservationToken);
return NextResponse.json({ error, spotUnavailable: true }, { status });
};
if (!spot) {
return rejectSpot(
spotId
? "Diesen Platz gibt es nicht mehr. Bitte wählen Sie einen anderen."
: "Bitte wählen Sie einen Platz aus.",
400
);
// Ab hier ist ggf. ein Rabattcode reserviert und gleich ein Platz gehalten.
// Jeder Fehler bis zur fertigen Session muss beides wieder freigeben, sonst
// blieben sie blockiert, ohne dass je eine Buchung zustande kommt - eine
// nie angelegte Session meldet auch kein checkout.session.expired.
const releaseReservations = async () => {
if (reservationToken) {
await releaseDiscountReservation(prisma, reservationToken).catch((releaseError) => {
console.error(
`Rabattcode-Reservierung ${reservationToken} konnte nach fehlgeschlagenem ` +
`Checkout-Start nicht freigegeben werden:`,
releaseError
);
});
}
if (spotHoldId) {
await releaseSpotHold(prisma, spotHoldId).catch((releaseError) => {
console.error(
`Platz-Hold ${spotHoldId} konnte nach fehlgeschlagenem Checkout-Start nicht ` +
`freigegeben werden:`,
releaseError
);
});
}
const hold = await holdSpot(prisma, {
courseId: course.id,
spotId: spot.id,
holderKey: spotHolderKey({
userId: isGuest ? null : userData!.id,
guestEmail: isGuest ? guestEmail : null,
}),
// Etwas länger als die Session, damit ein Kunde, der in der letzten
// Minute bezahlt, seinen Platz bis zum Webhook behält.
expiresAt: new Date(Date.now() + (SPOT_HOLD_MINUTES + 5) * 60_000),
});
if (!hold.held) return rejectSpot(hold.reason, 409);
spotHoldId = hold.holdId;
spotLabel = spotDisplayName(spot);
}
// Fixe Servicegebühr, die der Kunde ZUSÄTZLICH zum Kurspreis zahlt und die
// als eigene Checkout-Position sichtbar ist. Der Gesamtbetrag der Charge
// ist damit priceCents + platformFeeCents; application_fee_amount führt
// exakt die Gebühr an Kumasy ab. Rabattcodes mindern nur den Kurspreis,
// nie die Gebühr.
const platformFeeCents = getPlatformFeeCents();
const customerEmail = isGuest ? guestEmail!.trim().toLowerCase() : userData!.email;
// Alles, was der Webhook später braucht, um die Buchung anzulegen. Liegt
// an Session UND PaymentIntent, damit es auch im Stripe-Dashboard an der
// Zahlung sichtbar ist.
const metadata = {
courseId: course.id,
userId: isGuest ? "" : userData!.id,
businessId: course.business.id,
isGuest: isGuest ? "true" : "false",
guestEmail: isGuest ? customerEmail : "",
guestName: isGuest ? (guestName?.trim() ?? "") : "",
discountCodeId: appliedDiscountCodeId ?? "",
discountAmountCents: String(discountAmountCents),
discountReservationToken: reservationToken ?? "",
// Sagt dem Webhook, dass die Gebühr obendrauf kam. Sessions aus der Zeit
// der prozentualen Gebühr haben diesen Schlüssel nicht - eine solche
// Session, die erst nach einem Deploy bezahlt wird, darf nicht als
// "obendrauf" verbucht werden.
platformFeeModel: "on_top",
spotId: spotHoldId ? spotId! : "",
spotHoldId: spotHoldId ?? "",
};
// Der Kunde bucht beim Studio - der Checkout trägt deshalb dessen Farben
// und Logo, wie die öffentliche Seite davor. Ohne eigenes Branding greifen
// die Kumasy-Farben.
const studioBranding = toEmailBranding(course.business);
const brandingSettings: Stripe.Checkout.SessionCreateParams.BrandingSettings = {
display_name: course.business.name,
background_color: studioBranding.primaryColor,
button_color: studioBranding.accentColor,
border_style: "rounded",
...(studioBranding.logoUrl && isAllowedLogoUrl(studioBranding.logoUrl)
? { logo: { type: "url", url: studioBranding.logoUrl } }
: {}),
};
let session;
try {
// Platzwahl: erst nach dem Rabattcode, damit ein abgelehnter Code keinen
// Platz blockiert.
const spots = await getCourseSpots(prisma, course);
if (spots) {
const spot = spots.find((s) => s.id === spotId);
if (!spot) {
await releaseReservations();
return NextResponse.json(
{
error: spotId
? "Diesen Platz gibt es nicht mehr. Bitte wählen Sie einen anderen."
: "Bitte wählen Sie einen Platz aus.",
spotUnavailable: true,
},
{ status: 400 }
);
}
await endOpenCheckouts(course.id, holderKey, course.business.stripeAccountId);
const hold = await holdSpot(prisma, {
courseId: course.id,
spotId: spot.id,
holderKey,
// Etwas länger als die Session, damit ein Kunde, der in der letzten
// Minute bezahlt, seinen Platz bis zum Webhook behält.
expiresAt: new Date(Date.now() + (SPOT_HOLD_MINUTES + 5) * 60_000),
});
if (!hold.held) {
await releaseReservations();
return NextResponse.json({ error: hold.reason, spotUnavailable: true }, { status: 409 });
}
spotHoldId = hold.holdId;
spotLabel = spotDisplayName(spot);
}
// Fixe Servicegebühr, die der Kunde ZUSÄTZLICH zum Kurspreis zahlt und die
// als eigene Checkout-Position sichtbar ist. Der Gesamtbetrag der Charge
// ist damit priceCents + platformFeeCents; application_fee_amount führt
// exakt die Gebühr an Kumasy ab. Rabattcodes mindern nur den Kurspreis,
// nie die Gebühr.
const platformFeeCents = getPlatformFeeCents();
const customerEmail = isGuest ? guestEmail!.trim().toLowerCase() : userData!.email;
// Alles, was der Webhook später braucht, um die Buchung anzulegen. Liegt
// an Session UND PaymentIntent, damit es auch im Stripe-Dashboard an der
// Zahlung sichtbar ist.
const metadata = {
courseId: course.id,
userId: isGuest ? "" : userData!.id,
businessId: course.business.id,
isGuest: isGuest ? "true" : "false",
guestEmail: isGuest ? customerEmail : "",
guestName: isGuest ? (guestName?.trim() ?? "") : "",
discountCodeId: appliedDiscountCodeId ?? "",
discountAmountCents: String(discountAmountCents),
discountReservationToken: reservationToken ?? "",
// Sagt dem Webhook, dass die Gebühr obendrauf kam. Sessions aus der Zeit
// der prozentualen Gebühr haben diesen Schlüssel nicht - eine solche
// Session, die erst nach einem Deploy bezahlt wird, darf nicht als
// "obendrauf" verbucht werden.
platformFeeModel: "on_top",
spotId: spotHoldId ? spotId! : "",
spotHoldId: spotHoldId ?? "",
};
// Der Kunde bucht beim Studio - der Checkout trägt deshalb dessen Farben
// und Logo, wie die öffentliche Seite davor. Ohne eigenes Branding greifen
// die Kumasy-Farben.
const studioBranding = toEmailBranding(course.business);
const brandingSettings: Stripe.Checkout.SessionCreateParams.BrandingSettings = {
display_name: course.business.name,
background_color: studioBranding.primaryColor,
button_color: studioBranding.accentColor,
border_style: "rounded",
...(studioBranding.logoUrl && isAllowedLogoUrl(studioBranding.logoUrl)
? { logo: { type: "url", url: studioBranding.logoUrl } }
: {}),
};
session = await createBrandedCheckoutSession(
{
mode: "payment",
@ -428,30 +494,18 @@ export async function POST(request: NextRequest) {
brandingSettings,
course.business.stripeAccountId
);
} catch (stripeError) {
// Reservierung wurde bereits vor der Session-Erstellung angelegt (siehe
// oben) - schlägt die Session-Erstellung selbst fehl, muss der belegte
// Slot wieder freigegeben werden, sonst bliebe er dauerhaft blockiert,
// ohne dass je eine Buchung zustande kommt.
if (reservationToken) {
await releaseDiscountReservation(prisma, reservationToken).catch((releaseError) => {
console.error(
`Rabattcode-Reservierung ${reservationToken} konnte nach fehlgeschlagener ` +
`Stripe-Session nicht freigegeben werden:`,
releaseError
);
});
}
if (spotHoldId) {
await releaseSpotHold(prisma, spotHoldId).catch((releaseError) => {
console.error(
`Platz-Hold ${spotHoldId} konnte nach fehlgeschlagener Stripe-Session nicht ` +
`freigegeben werden:`,
releaseError
);
});
}
throw stripeError;
} catch (error) {
await releaseReservations();
throw error;
}
// Damit ein späterer Checkout derselben Person diese Session beenden kann
// (siehe endOpenCheckouts). Scheitert das, bleibt der Platz trotzdem bis
// zum Ablauf gehalten - kein Grund, die fertige Session zu verwerfen.
if (spotHoldId && session.id) {
await attachSessionToHold(prisma, spotHoldId, session.id).catch((err) =>
console.error(`Session konnte nicht an Platz-Hold ${spotHoldId} gehängt werden:`, err)
);
}
return NextResponse.json({

View file

@ -29,18 +29,20 @@ type StandingOrderScope = "this" | "this_and_following" | "all";
* Teilnehmer zugelassen sein, als es Plätze gibt - sonst bekäme der letzte
* Kunde zwar eine Zahlung abgebucht, aber keinen Platz. Gibt eine
* Fehlermeldung zurück oder null, wenn alles passt.
*
* Liest nur Räume des eigenen Managers - sonst ließe sich mit einer fremden
* Raum-ID dessen Platzanzahl auslesen.
*/
async function validateSpotBooking(data: {
spotBooking?: boolean;
room?: string;
maxParticipants?: number;
}): Promise<{ error: string; field: "room" | "maxParticipants" } | null> {
async function validateSpotBooking(
data: { spotBooking?: boolean; room?: string; maxParticipants?: number },
managerId: string
): Promise<{ error: string; field: "room" | "maxParticipants" } | null> {
if (!data.spotBooking) return null;
if (!data.room) {
return { error: "Für die Platzwahl braucht der Kurs einen Raum", field: "room" };
}
const room = await prisma.room.findUnique({
where: { id: data.room },
const room = await prisma.room.findFirst({
where: { id: data.room, createdBy: managerId },
select: { floorPlan: true },
});
const spotCount = bookableSpots(parseFloorPlan(room?.floorPlan)).length;
@ -106,7 +108,12 @@ export async function createCourse(data: CourseFormData, status: "draft" | "publ
const validatedData = validation.data;
const spotError = await validateSpotBooking(validatedData);
// Schritt 5: Manager-ID ermitteln (Employee erbt createdBy vom eigenen Datensatz)
const creatorId = getEffectiveManagerId(userData);
if (!creatorId)
return { success: false, error: "Mitarbeiter-Account ist keinem Manager zugeordnet" };
const spotError = await validateSpotBooking(validatedData, creatorId);
if (spotError) {
return {
success: false,
@ -115,11 +122,6 @@ export async function createCourse(data: CourseFormData, status: "draft" | "publ
};
}
// Schritt 5: Manager-ID ermitteln (Employee erbt createdBy vom eigenen Datensatz)
const creatorId = getEffectiveManagerId(userData);
if (!creatorId)
return { success: false, error: "Mitarbeiter-Account ist keinem Manager zugeordnet" };
// Schritt 6: Kurs erstellen — bei Dauerauftrag Template + Instanzen, sonst einzelner Kurs
const commonFields = {
name: validatedData.name || "",
@ -504,7 +506,7 @@ export async function updateCourse(
const validatedData = validation.data;
const spotError = await validateSpotBooking(validatedData);
const spotError = await validateSpotBooking(validatedData, managerId);
if (spotError) {
return {
success: false,

View file

@ -12,7 +12,8 @@ import {
* Ablauf:
* 1. Checkout-Start: holdSpot() hält den gewählten Platz für die Dauer der
* Stripe-Session frei (CourseSpotHold), damit kein zweiter Kunde mit
* demselben Platz zur Kasse geht.
* demselben Platz zur Kasse geht. Startet dieselbe Person erneut, beendet
* die Checkout-Route vorher ihre alte, noch offene Session.
* 2. Webhook nach der Zahlung: assignSpot() schreibt den Platz an die Buchung.
* Verbindlich ist nur, was bezahlt ist - war der Wunschplatz inzwischen
* doch vergeben (Hold abgelaufen, SEPA-Zahlung kam Tage später), bekommt
@ -70,8 +71,9 @@ export async function getBookedSpotIds(db: PrismaOrTx, courseId: string): Promis
/**
* Plätze, die gerade jemand im Checkout hält. exceptHolderKey blendet die
* eigene Reservierung aus - wer den Checkout abbricht und zurückkommt, soll
* seinen Platz nicht als belegt sehen.
* eigenen offenen Checkouts aus - wer den Checkout abbricht und zurückkommt,
* soll seinen Platz nicht als belegt sehen (der nächste Checkout ersetzt ihn).
* Ein eigener abgeschlossener Checkout (SEPA unterwegs) bleibt belegt.
*/
export async function getHeldSpotIds(
db: PrismaOrTx,
@ -82,7 +84,9 @@ export async function getHeldSpotIds(
where: {
courseId,
expiresAt: { gt: new Date() },
...(opts.exceptHolderKey ? { holderKey: { not: opts.exceptHolderKey } } : {}),
...(opts.exceptHolderKey
? { NOT: { holderKey: opts.exceptHolderKey, status: "checkout" } }
: {}),
...(opts.exceptHoldId ? { id: { not: opts.exceptHoldId } } : {}),
},
select: { spotId: true },
@ -96,6 +100,11 @@ export type HoldSpotResult = { held: true; holdId: string } | { held: false; rea
* Hält einen Platz für den Checkout frei. Die Eindeutigkeit erzwingt
* @@unique([courseId, spotId]): fordern zwei Kunden gleichzeitig denselben
* Platz an, gewinnt der erste Insert, der zweite bekommt P2002.
*
* Räumt nur abgelaufene Holds weg. Holds derselben Person aus früheren
* Checkouts gibt die Checkout-Route vorher frei, und zwar nur, wenn deren
* Session nachweislich noch offen war - ein bereits bezahlter oder per SEPA
* abgeschlossener Checkout behält seinen Platz.
*/
export async function holdSpot(
db: typeof prismaClient,
@ -103,11 +112,9 @@ export async function holdSpot(
): Promise<HoldSpotResult> {
const { courseId, spotId, holderKey, expiresAt } = params;
// Abgelaufene Holds wegräumen (sonst blockiert ihr Unique-Key den Platz)
// und den eigenen alten Hold für diesen Kurs freigeben - wer aus dem
// Checkout zurückkommt und umwählt, hält danach nur den neuen Platz.
// Abgelaufene Holds wegräumen - sonst blockiert ihr Unique-Key den Platz.
await db.courseSpotHold.deleteMany({
where: { courseId, OR: [{ expiresAt: { lte: new Date() } }, { holderKey }] },
where: { courseId, expiresAt: { lte: new Date() } },
});
const booked = await db.courseBooking.findFirst({
@ -129,22 +136,57 @@ export async function holdSpot(
return { held: true, holdId: hold.id };
} catch (error) {
if (error instanceof Error && "code" in error && (error as { code: string }).code === "P2002") {
const existing = await db.courseSpotHold.findUnique({
where: { courseId_spotId: { courseId, spotId } },
select: { holderKey: true },
});
return {
held: false,
reason:
"Dieser Platz wird gerade von jemand anderem gebucht. Bitte wählen Sie einen anderen.",
existing?.holderKey === holderKey
? "Für diesen Platz läuft bereits Ihre Zahlung. Bitte warten Sie die Bestätigung ab."
: "Dieser Platz wird gerade von jemand anderem gebucht. Bitte wählen Sie einen anderen.",
};
}
throw error;
}
}
/** Offene Checkouts einer Person für diesen Kurs - die Checkout-Route ersetzt sie. */
export async function getOpenCheckoutHolds(
db: PrismaOrTx,
courseId: string,
holderKey: string
): Promise<{ id: string; stripeSessionId: string | null }[]> {
return db.courseSpotHold.findMany({
where: { courseId, holderKey, status: "checkout" },
select: { id: true, stripeSessionId: true },
});
}
/** Verknüpft den Hold mit seiner Session, sobald Stripe sie angelegt hat. */
export async function attachSessionToHold(
db: PrismaOrTx,
holdId: string,
stripeSessionId: string
): Promise<void> {
await db.courseSpotHold.updateMany({ where: { id: holdId }, data: { stripeSessionId } });
}
export async function releaseSpotHold(db: PrismaOrTx, holdId: string): Promise<void> {
await db.courseSpotHold.deleteMany({ where: { id: holdId } });
}
/**
* Checkout abgeschlossen, Zahlung unterwegs (SEPA): Platz länger halten und
* als abgeschlossen markieren, damit ein neuer Checkout derselben Person ihn
* nicht als "alten offenen Checkout" freigibt.
*/
export async function extendSpotHold(db: PrismaOrTx, holdId: string, until: Date): Promise<void> {
await db.courseSpotHold.updateMany({ where: { id: holdId }, data: { expiresAt: until } });
await db.courseSpotHold.updateMany({
where: { id: holdId },
data: { expiresAt: until, status: "awaiting_payment" },
});
}
export type AssignedSpot = { spotId: string; spotLabel: string; changed: boolean };

View file

@ -18,14 +18,24 @@ vi.mock("@/src/lib/prisma", () => ({
}));
vi.mock("@/src/lib/stripe", () => ({
stripe: { checkout: { sessions: { create: vi.fn() } } },
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", () => ({
reserveDiscountCode: vi.fn(),
releaseDiscountReservation: vi.fn().mockResolvedValue(undefined),
calculateDiscountedPriceCents: () => ({ discountedPriceCents: 2000, discountAmountCents: 900 }),
}));
// 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([]),
attachSessionToHold: vi.fn().mockResolvedValue(undefined),
holdSpot: vi.fn(),
releaseSpotHold: vi.fn().mockResolvedValue(undefined),
spotHolderKey: (p: { userId?: string | null; guestEmail?: string | null }) =>
@ -37,7 +47,17 @@ 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 { getCourseSpots, holdSpot, releaseSpotHold } from "@/src/modules/courses/lib/course-spots";
import {
attachSessionToHold,
getCourseSpots,
getOpenCheckoutHolds,
holdSpot,
releaseSpotHold,
} from "@/src/modules/courses/lib/course-spots";
import {
reserveDiscountCode,
releaseDiscountReservation,
} from "@/src/modules/settings/lib/discount-validation";
let ipCounter = 0;
@ -259,10 +279,16 @@ describe("POST /api/stripe/checkout/course – Platzwahl", () => {
vi.mocked(getCourseSpots).mockResolvedValue([reformer("spot-1", "1"), reformer("spot-2", "2")]);
vi.mocked(holdSpot).mockReset();
vi.mocked(releaseSpotHold).mockClear();
vi.mocked(attachSessionToHold).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 () => {
@ -336,4 +362,86 @@ describe("POST /api/stripe/checkout/course – Platzwahl", () => {
expect(args.metadata.spotId).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(attachSessionToHold).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(releaseSpotHold).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(releaseSpotHold).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(releaseSpotHold).not.toHaveBeenCalledWith(prisma, "hold-alt");
consoleWarn.mockRestore();
});
});

View file

@ -389,9 +389,11 @@ describe("Stripe-Webhook – Platzwahl", () => {
expect(prisma.$transaction).not.toHaveBeenCalled();
const call = vi.mocked(prisma.courseSpotHold.updateMany).mock.calls[0][0] as {
where: { id: string };
data: { expiresAt: Date };
data: { expiresAt: Date; status: string };
};
expect(call.where).toEqual({ id: "hold-1" });
// Ein neuer Checkout derselben Person darf diesen Platz nicht freigeben.
expect(call.data.status).toBe("awaiting_payment");
const days = (call.data.expiresAt.getTime() - Date.now()) / 86_400_000;
expect(days).toBeGreaterThan(13);
});

View file

@ -1,7 +1,9 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
assignSpot,
extendSpotHold,
getCourseSpots,
getHeldSpotIds,
holdSpot,
spotHolderKey,
} from "@/src/modules/courses/lib/course-spots";
@ -10,7 +12,13 @@ import type { FloorPlanItem } from "@/src/modules/rooms/lib/floor-plan";
const db = {
room: { findUnique: vi.fn() },
courseBooking: { findMany: vi.fn(), findFirst: vi.fn() },
courseSpotHold: { findMany: vi.fn(), deleteMany: vi.fn(), create: vi.fn() },
courseSpotHold: {
findMany: vi.fn(),
findUnique: vi.fn(),
deleteMany: vi.fn(),
create: vi.fn(),
updateMany: vi.fn(),
},
};
const spot = (id: string, label: string): FloorPlanItem => ({
@ -122,13 +130,12 @@ describe("holdSpot", () => {
expiresAt: new Date(Date.now() + 30 * 60_000),
};
it("räumt abgelaufene und eigene alte Holds weg, bevor es hält", async () => {
it("räumt nur abgelaufene Holds weg - eigene Holds anderer Checkouts bleiben", async () => {
const result = await holdSpot(db as never, params);
expect(result).toEqual({ held: true, holdId: "hold-1" });
const where = db.courseSpotHold.deleteMany.mock.calls[0][0].where;
expect(where.courseId).toBe("c1");
expect(where.OR).toContainEqual({ holderKey: "user:u1" });
expect(where).toEqual({ courseId: "c1", expiresAt: { lte: expect.any(Date) } });
});
it("hält keinen Platz, der schon bezahlt ist", async () => {
@ -142,8 +149,40 @@ describe("holdSpot", () => {
db.courseSpotHold.create.mockRejectedValue(
Object.assign(new Error("Unique constraint failed"), { code: "P2002" })
);
db.courseSpotHold.findUnique.mockResolvedValue({ holderKey: "user:jemand-anderes" });
const result = await holdSpot(db as never, params);
expect(result).toMatchObject({ held: false });
expect(result).toMatchObject({
held: false,
reason: expect.stringContaining("jemand anderem"),
});
});
it("sagt der Person, dass ihre eigene Zahlung für den Platz noch läuft", async () => {
db.courseSpotHold.create.mockRejectedValue(
Object.assign(new Error("Unique constraint failed"), { code: "P2002" })
);
db.courseSpotHold.findUnique.mockResolvedValue({ holderKey: "user:u1" });
const result = await holdSpot(db as never, params);
expect(result).toMatchObject({ held: false, reason: expect.stringContaining("Ihre Zahlung") });
});
});
describe("Hold-Status", () => {
it("markiert einen verlängerten Hold als abgeschlossenen Checkout", async () => {
const until = new Date(Date.now() + 14 * 86_400_000);
await extendSpotHold(db as never, "hold-1", until);
expect(db.courseSpotHold.updateMany).toHaveBeenCalledWith({
where: { id: "hold-1" },
data: { expiresAt: until, status: "awaiting_payment" },
});
});
it("blendet für die Person nur ihre offenen Checkouts aus", async () => {
await getHeldSpotIds(db as never, "c1", { exceptHolderKey: "user:u1" });
expect(db.courseSpotHold.findMany.mock.calls[0][0].where.NOT).toEqual({
holderKey: "user:u1",
status: "checkout",
});
});
});