diff --git a/prisma/migrations/20261002150000_spot_hold_session_state/migration.sql b/prisma/migrations/20261002150000_spot_hold_session_state/migration.sql new file mode 100644 index 0000000..5be7f4f --- /dev/null +++ b/prisma/migrations/20261002150000_spot_hold_session_state/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "CourseSpotHold" ADD COLUMN "status" TEXT NOT NULL DEFAULT 'checkout', +ADD COLUMN "stripeSessionId" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c7ab719..96d4ff5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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:" oder "guest:": 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:" oder "guest:": 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]) diff --git a/src/app/api/stripe/checkout/course/route.ts b/src/app/api/stripe/checkout/course/route.ts index aa5adf4..8a12f39 100644 --- a/src/app/api/stripe/checkout/course/route.ts +++ b/src/app/api/stripe/checkout/course/route.ts @@ -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({ diff --git a/src/modules/courses/actions/course-actions.ts b/src/modules/courses/actions/course-actions.ts index 6d387ea..2cabea0 100644 --- a/src/modules/courses/actions/course-actions.ts +++ b/src/modules/courses/actions/course-actions.ts @@ -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, diff --git a/src/modules/courses/lib/course-spots.ts b/src/modules/courses/lib/course-spots.ts index f7d8afa..4957ee8 100644 --- a/src/modules/courses/lib/course-spots.ts +++ b/src/modules/courses/lib/course-spots.ts @@ -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 { 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 { + await db.courseSpotHold.updateMany({ where: { id: holdId }, data: { stripeSessionId } }); +} + export async function releaseSpotHold(db: PrismaOrTx, holdId: string): Promise { 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 { - 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 }; diff --git a/tests/api/stripe/checkout-course.test.ts b/tests/api/stripe/checkout-course.test.ts index 4093e2b..88c105d 100644 --- a/tests/api/stripe/checkout-course.test.ts +++ b/tests/api/stripe/checkout-course.test.ts @@ -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(); + }); }); diff --git a/tests/api/stripe/webhook.test.ts b/tests/api/stripe/webhook.test.ts index 48e6b35..13005e4 100644 --- a/tests/api/stripe/webhook.test.ts +++ b/tests/api/stripe/webhook.test.ts @@ -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); }); diff --git a/tests/courses/course-spots.test.ts b/tests/courses/course-spots.test.ts index 878edb8..62b2b4c 100644 --- a/tests/courses/course-spots.test.ts +++ b/tests/courses/course-spots.test.ts @@ -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", + }); }); });