diff --git a/prisma/migrations/20260913155733_add_discount_codes/migration.sql b/prisma/migrations/20260913155733_add_discount_codes/migration.sql index 3967f3d..ba1f40f 100644 --- a/prisma/migrations/20260913155733_add_discount_codes/migration.sql +++ b/prisma/migrations/20260913155733_add_discount_codes/migration.sql @@ -55,6 +55,16 @@ CREATE UNIQUE INDEX "DiscountRedemption_discountCodeId_userId_key" ON "DiscountR CREATE INDEX "CourseBooking_discountCodeId_idx" ON "CourseBooking"("discountCodeId"); -- AddForeignKey +-- Bewusst OHNE CONCURRENTLY/NOT VALID: Postgres erlaubt CREATE INDEX +-- CONCURRENTLY nie innerhalb einer Transaktion, `prisma migrate dev` führt +-- aber jede Migration transaktional gegen eine Shadow-Database aus - dort +-- würde CONCURRENTLY hart fehlschlagen und dauerhaft JEDE künftige +-- `migrate dev`-Ausführung blockieren (nicht nur diese eine Migration), weil +-- die Shadow-DB alle Migrationen der Reihe nach durchspielt. Der Trade-off +-- ist hier vertretbar: CourseBooking existiert zu diesem Zeitpunkt zwar +-- schon, aber discountCodeId ist eine brandneue Spalte ohne bestehende +-- Nicht-NULL-Werte - Index-Build und FK-Validierung laufen daher in +-- Sekundenbruchteilen, unabhängig von der Zeilenzahl der Tabelle. ALTER TABLE "CourseBooking" ADD CONSTRAINT "CourseBooking_discountCodeId_fkey" FOREIGN KEY ("discountCodeId") REFERENCES "DiscountCode"("id") ON DELETE SET NULL ON UPDATE CASCADE; -- AddForeignKey diff --git a/prisma/migrations/20260913190000_discount_redemption_reservation/migration.sql b/prisma/migrations/20260913190000_discount_redemption_reservation/migration.sql new file mode 100644 index 0000000..0934e47 --- /dev/null +++ b/prisma/migrations/20260913190000_discount_redemption_reservation/migration.sql @@ -0,0 +1,30 @@ +-- DropForeignKey +ALTER TABLE "DiscountRedemption" DROP CONSTRAINT "DiscountRedemption_bookingId_fkey"; + +-- DropIndex +DROP INDEX "DiscountRedemption_bookingId_key"; + +-- AlterTable +ALTER TABLE "DiscountRedemption" + ADD COLUMN "stripeSessionId" TEXT, + ADD COLUMN "status" TEXT NOT NULL DEFAULT 'reserved', + ALTER COLUMN "bookingId" DROP NOT NULL; + +-- Tabelle ist zu diesem Zeitpunkt leer (siehe vorherige Migration), daher +-- ist ein Backfill für die neue NOT NULL-Spalte stripeSessionId nicht nötig - +-- UPDATE würde ohnehin keine Zeile treffen. Erst danach kann die Spalte auf +-- NOT NULL gesetzt werden, weil ALTER COLUMN ... SET NOT NULL sonst bereits +-- vorhandene NULL-Werte prüfen würde. +ALTER TABLE "DiscountRedemption" ALTER COLUMN "stripeSessionId" SET NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "DiscountRedemption_stripeSessionId_key" ON "DiscountRedemption"("stripeSessionId"); + +-- CreateIndex +CREATE UNIQUE INDEX "DiscountRedemption_bookingId_key" ON "DiscountRedemption"("bookingId"); + +-- CreateIndex +CREATE INDEX "DiscountRedemption_status_idx" ON "DiscountRedemption"("status"); + +-- AddForeignKey +ALTER TABLE "DiscountRedemption" ADD CONSTRAINT "DiscountRedemption_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "CourseBooking"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 59d8f05..c34e0fa 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -269,8 +269,11 @@ model Notification { // Rabattcode eines Business. scope = "specific_courses" schränkt die Gültigkeit // über courseIds ein (Array statt Join-Tabelle, analog Course.trainers/sport — // hier reicht eine Handvoll IDs pro Code, kein eigenes Modell nötig). -// redeemedCount wird ausschließlich im Stripe-Webhook per {increment: 1} erhöht, -// niemals beim Checkout-Start (siehe DiscountRedemption). +// redeemedCount zählt RESERVIERTE Slots (status "reserved" ODER "confirmed"), +// nicht nur bestätigte Zahlungen - die Reservierung passiert bereits beim +// Checkout-Start, um Race Conditions zwischen mehreren gleichzeitigen +// Checkouts zu verhindern (siehe DiscountRedemption). Der Zähler wird wieder +// dekrementiert, wenn eine Reservierung storniert/abgelaufen wird. model DiscountCode { id String @id @default(uuid()) businessId String @@ -294,20 +297,33 @@ model DiscountCode { @@index([businessId]) } -// Ein Eintrag pro tatsächlich eingelöstem Rabattcode (nur bei bezahlter Buchung, -// siehe Webhook handleCheckoutCompleted). Der @@unique-Constraint erzwingt -// "1x pro User" race-condition-sicher auf DB-Ebene, unabhängig von Anwendungslogik. +// Reserviert einen Rabattcode-Slot BEIM CHECKOUT-START, nicht erst nach +// Zahlungsbestätigung - sonst könnten mehrere gleichzeitige Checkouts (versch. +// User bei knappem Kontingent, oder ein User mit mehreren offenen Sessions) +// alle den rabattierten Preis bekommen, obwohl nur ein Slot frei ist. status: +// "reserved" (Checkout-Session erstellt, Zahlung ausstehend) → "confirmed" +// (Webhook hat die Zahlung bestätigt, bookingId gesetzt) oder gelöscht (Session +// abgelaufen/storniert, siehe handleCheckoutExpired - gibt den Slot wieder frei). +// bookingId ist erst ab "confirmed" gesetzt, da beim Reservieren noch keine +// CourseBooking-Zeile existiert. Der @@unique([discountCodeId, userId]) +// Constraint erzwingt "1x pro User" bereits ab der Reservierung, nicht erst +// ab Zahlungsbestätigung - das schließt die zweite Race Condition +// (ein User bucht mehrere Kurse mit demselben Code, bevor eine Zahlung +// durchläuft) auf DB-Ebene. model DiscountRedemption { - id String @id @default(uuid()) - discountCodeId String - userId String - bookingId String @unique - discountCode DiscountCode @relation(fields: [discountCodeId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - booking CourseBooking @relation(fields: [bookingId], references: [id], onDelete: Cascade) - createdAt DateTime @default(now()) + id String @id @default(uuid()) + discountCodeId String + userId String + stripeSessionId String @unique + bookingId String? @unique + status String @default("reserved") // "reserved" | "confirmed" + discountCode DiscountCode @relation(fields: [discountCodeId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + booking CourseBooking? @relation(fields: [bookingId], references: [id], onDelete: SetNull) + createdAt DateTime @default(now()) @@unique([discountCodeId, userId]) @@index([discountCodeId]) @@index([userId]) + @@index([status]) } diff --git a/src/app/(main)/settings/discounts/page.tsx b/src/app/(main)/settings/discounts/page.tsx index 0a6b662..b7b7b6c 100644 --- a/src/app/(main)/settings/discounts/page.tsx +++ b/src/app/(main)/settings/discounts/page.tsx @@ -35,6 +35,7 @@ export default async function SettingsDiscountPage() { businesses={businesses} initialCodes={codesResult?.success ? codesResult.codes : []} initialCourses={coursesResult?.success ? coursesResult.courses : []} + initialCoursesError={coursesResult && !coursesResult.success ? coursesResult.error : null} /> ); diff --git a/src/app/api/stripe/checkout/course/route.ts b/src/app/api/stripe/checkout/course/route.ts index 97d746e..fff7082 100644 --- a/src/app/api/stripe/checkout/course/route.ts +++ b/src/app/api/stripe/checkout/course/route.ts @@ -4,7 +4,8 @@ import { stripe, calculatePlatformFeeCents, eurosToCents } from "@/src/lib/strip import { getUserData, isUser } from "@/src/lib/auth/getUser"; import { isManagerOrEmployeeEmail } from "@/src/lib/auth/account-role-by-email"; import { - validateDiscountCode, + reserveDiscountCode, + releaseDiscountReservation, calculateDiscountedPriceCents, } from "@/src/modules/settings/lib/discount-validation"; @@ -179,27 +180,68 @@ export async function POST(request: NextRequest) { const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"; const originalPriceCents = eurosToCents(course.price); - // Rabattcode ist optional — ein ungültiger/abgelaufener Code lässt die - // Buchung bewusst NICHT scheitern, sondern wird einfach ignoriert (Preis - // bleibt regulär). Harte Fehler nur bei explizit falscher Eingabe wären - // hier unnötig reibungsvoll für den Kunden; die UI zeigt die Ablehnung - // bereits vorab über eine separate Preview-Action (siehe booking UI). + // Ein hier eingegebener Code wurde clientseitig bereits über die + // Preview-Action als gültig angezeigt - wird er bei dieser erneuten, + // server-seitigen Prüfung ungültig (Kontingent/Zeitfenster liefen + // zwischenzeitlich ab), MUSS die Anfrage abgelehnt werden. Ihn still zu + // ignorieren würde eine Session zum vollen Preis erzeugen, während die UI + // dem Kunden weiterhin den (nicht mehr zutreffenden) rabattierten Preis + // anzeigt - der Kunde könnte dann zu einem höheren Preis bezahlen, als + // ihm angezeigt wurde. + // + // reserveDiscountCode() (statt einer reinen Validierung) belegt das + // Kontingent SOFORT, bevor die Stripe-Session erstellt wird - das + // schließt die Race Condition, bei der mehrere gleichzeitige Checkouts + // (verschiedene User bei knappem Kontingent, oder ein User mit mehreren + // offenen Sessions) alle denselben letzten Slot rabattiert bekommen + // hätten, weil die Verbuchung vorher erst nach der Zahlung passierte. let priceCents = originalPriceCents; let discountAmountCents = 0; let appliedDiscountCodeId: string | null = null; + let reservationToken: string | null = null; if (discountCode?.trim()) { - const validation = await validateDiscountCode(prisma, { + // Rabattcodes sind nur für eingeloggte User verfügbar (siehe + // validateDiscountCode) - reserveDiscountCode() erwartet daher eine + // nicht-optionale userId und wird für Gäste gar nicht erst aufgerufen. + if (isGuest) { + return NextResponse.json( + { error: "Rabattcodes sind nur für angemeldete Kunden verfügbar." }, + { status: 400 } + ); + } + + const reservation = await reserveDiscountCode(prisma, { code: discountCode, businessId: course.business.id, courseId: course.id, - userId: isGuest ? null : userData!.id, + userId: userData!.id, }); - if (validation.valid) { - const result = calculateDiscountedPriceCents(originalPriceCents, validation.discountCode); - priceCents = result.discountedPriceCents; - discountAmountCents = result.discountAmountCents; - appliedDiscountCodeId = validation.discountCode.id; + if (!reservation.reserved) { + return NextResponse.json({ error: reservation.reason }, { status: 400 }); } + + const result = calculateDiscountedPriceCents(originalPriceCents, reservation.discountCode); + // Ein auf 0 reduzierter Preis würde eine Stripe Checkout Session ohne + // PaymentIntent erzeugen (kostenlose Bestellung). Der Webhook lehnt + // aktuell jede Session ab, deren payment_status nicht "paid" ist - eine + // 0€-Session hat aber gar keinen PaymentIntent, dessen Status geprüft + // werden könnte, sodass der Kunde den Checkout abschließen würde, ohne + // je eine Buchung zu bekommen. Bis kostenlose Buchungen als eigener Fall + // unterstützt werden, wird ein solcher Code hier abgelehnt statt eine + // funktionsunfähige Session zu erzeugen. + if (result.discountedPriceCents <= 0) { + await releaseDiscountReservation(prisma, reservation.reservationToken); + return NextResponse.json( + { + error: "Dieser Rabattcode reduziert den Preis auf 0 € und kann nicht verwendet werden.", + }, + { status: 400 } + ); + } + priceCents = result.discountedPriceCents; + discountAmountCents = result.discountAmountCents; + appliedDiscountCodeId = reservation.discountCode.id; + reservationToken = reservation.reservationToken; } // Platform-Fee auf den tatsächlich zu zahlenden (rabattierten) Betrag, @@ -209,25 +251,39 @@ export async function POST(request: NextRequest) { const customerEmail = isGuest ? guestEmail!.trim().toLowerCase() : userData!.email; - const session = await stripe.checkout.sessions.create({ - mode: "payment", - line_items: [ - { - price_data: { - currency: "eur", - product_data: { - name: course.name, - description: `${course.business.name} - ${new Date(course.date).toLocaleDateString("de-DE")} ${course.timeFrom}`, + let session; + try { + session = await stripe.checkout.sessions.create({ + mode: "payment", + line_items: [ + { + price_data: { + currency: "eur", + product_data: { + name: course.name, + description: `${course.business.name} - ${new Date(course.date).toLocaleDateString("de-DE")} ${course.timeFrom}`, + }, + unit_amount: priceCents, }, - unit_amount: priceCents, + quantity: 1, + }, + ], + payment_intent_data: { + application_fee_amount: platformFeeCents, + transfer_data: { + destination: course.business.stripeAccountId, + }, + 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 ?? "", }, - quantity: 1, - }, - ], - payment_intent_data: { - application_fee_amount: platformFeeCents, - transfer_data: { - destination: course.business.stripeAccountId, }, metadata: { courseId: course.id, @@ -238,22 +294,28 @@ export async function POST(request: NextRequest) { guestName: isGuest ? (guestName?.trim() ?? "") : "", discountCodeId: appliedDiscountCodeId ?? "", discountAmountCents: String(discountAmountCents), + discountReservationToken: reservationToken ?? "", }, - }, - 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), - }, - customer_email: customerEmail, - success_url: `${baseUrl}/courses/book/${course.id}/success?session_id={CHECKOUT_SESSION_ID}`, - cancel_url: `${baseUrl}/courses/book/${course.id}/cancel`, - }); + customer_email: customerEmail, + success_url: `${baseUrl}/courses/book/${course.id}/success?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${baseUrl}/courses/book/${course.id}/cancel`, + }); + } 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 + ); + }); + } + throw stripeError; + } return NextResponse.json({ url: session.url, diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts index 8765412..46163b0 100644 --- a/src/app/api/stripe/webhook/route.ts +++ b/src/app/api/stripe/webhook/route.ts @@ -13,7 +13,10 @@ import { import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding"; import { notifyBusinessManager } from "@/src/modules/notifications/lib/create-notification"; import { isManagerOrEmployeeEmail } from "@/src/lib/auth/account-role-by-email"; -import { redeemDiscountCode } from "@/src/modules/settings/lib/discount-validation"; +import { + confirmDiscountReservation, + releaseDiscountReservation, +} from "@/src/modules/settings/lib/discount-validation"; /** * Sucht einen registrierten User anhand der E-Mail, case-insensitive. @@ -111,10 +114,14 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) { // discountAmountCents ist ein Snapshot vom Checkout-Start (in der Session- // Metadata eingefroren) — bewusst NICHT aus dem aktuellen DiscountCode neu // berechnet, damit eine spätere Code-Änderung die Rechnung dieser Buchung - // nicht rückwirkend verändert. - const businessId = session.metadata?.businessId; + // nicht rückwirkend verändert. discountReservationToken verknüpft diese + // Zahlung mit der beim Checkout-Start bereits angelegten Reservierung + // (siehe reserveDiscountCode) - das Kontingent wurde dort schon verbraucht, + // hier wird die Reservierung nur noch auf "confirmed" gesetzt und mit der + // gleich entstehenden CourseBooking-Zeile verknüpft. const discountCodeId = session.metadata?.discountCodeId || null; const discountAmountCents = Number(session.metadata?.discountAmountCents ?? "0") || 0; + const discountReservationToken = session.metadata?.discountReservationToken || null; // Guest-Flow und Auth-Flow teilen die Kapazitäts-Logik, aber nutzen // unterschiedliche Identifikatoren und DB-Operationen. @@ -172,9 +179,9 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) { paymentIntentId, amountPaidCents, platformFeeCents, - businessId, discountCodeId, discountAmountCents, + discountReservationToken, treatDistinctSessionAsDuplicate: true, }) : await runCapacityTransactionGuest({ @@ -225,9 +232,9 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) { paymentIntentId, amountPaidCents, platformFeeCents, - businessId, discountCodeId, discountAmountCents, + discountReservationToken, treatDistinctSessionAsDuplicate: true, }) : await runCapacityTransactionGuest({ @@ -270,9 +277,9 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) { paymentIntentId, amountPaidCents, platformFeeCents, - businessId, discountCodeId, discountAmountCents, + discountReservationToken, }); } break; @@ -452,9 +459,9 @@ async function runCapacityTransactionUser(params: { paymentIntentId: string | null; amountPaidCents: number; platformFeeCents: number; - businessId?: string; discountCodeId?: string | null; discountAmountCents?: number; + discountReservationToken?: string | null; treatDistinctSessionAsDuplicate?: boolean; }): Promise { const { @@ -464,9 +471,9 @@ async function runCapacityTransactionUser(params: { paymentIntentId, amountPaidCents, platformFeeCents, - businessId, discountCodeId = null, discountAmountCents = 0, + discountReservationToken = null, treatDistinctSessionAsDuplicate = false, } = params; @@ -585,18 +592,18 @@ async function runCapacityTransactionUser(params: { }, }); - // Re-Validierung + Redemption/Increment innerhalb DIESER Transaktion - - // ein zwischenzeitlich ausgeschöpfter/abgelaufener Code lässt die - // Buchung bewusst NICHT scheitern (Zahlung ist schon erfolgt), sondern - // wird hier still übersprungen: booking.discountCodeId/discountAmount - // bleiben zwar gesetzt (Snapshot für die Rechnung), aber ohne - // DiscountRedemption-Zeile zählt der Code-Verbrauch nicht doppelt. - if (discountCodeId && businessId) { - await redeemDiscountCode(tx, { - discountCodeId, - businessId, - courseId, - userId, + // Das Kontingent wurde bereits beim Checkout-Start verbraucht (siehe + // reserveDiscountCode) - hier wird die Reservierung nur noch mit dieser + // Buchung verknüpft und auf "confirmed" gesetzt. Existiert keine + // "reserved"-Zeile mehr für dieses Token (z.B. weil + // checkout.session.expired sie zwischenzeitlich freigegeben hat, obwohl + // die Zahlung tatsächlich noch durchkam), tut confirmDiscountReservation + // nichts - die Buchung bleibt trotzdem bestehen (Zahlung ist schon + // erfolgt), nur booking.discountCodeId/discountAmount bleiben als + // Snapshot für die Rechnung gesetzt, ohne verbuchte Redemption. + if (discountReservationToken) { + await confirmDiscountReservation(tx, { + reservationToken: discountReservationToken, bookingId: booking.id, }); } @@ -869,6 +876,21 @@ async function getApplicationFeeFromPaymentIntent(paymentIntentId: string): Prom async function handleCheckoutExpired(session: Stripe.Checkout.Session) { const courseId = session.metadata?.courseId; const isGuest = session.metadata?.isGuest === "true"; + const discountReservationToken = session.metadata?.discountReservationToken || null; + + // Gibt einen beim Checkout-Start reservierten Rabattcode-Slot wieder frei + // (siehe reserveDiscountCode), da diese Session nie bezahlt wurde. Läuft + // VOR dem frühen "if (!courseId) return" unten, da eine Reservierung auch + // ohne courseId theoretisch bestehen könnte und sonst nie freigegeben würde. + if (discountReservationToken) { + await releaseDiscountReservation(prisma, discountReservationToken).catch((error) => { + console.error( + `Rabattcode-Reservierung ${discountReservationToken} konnte nach abgelaufener ` + + `Checkout-Session nicht freigegeben werden:`, + error + ); + }); + } if (!courseId) return; diff --git a/src/modules/courses/actions/booking-actions.ts b/src/modules/courses/actions/booking-actions.ts index ef02731..04fba2a 100644 --- a/src/modules/courses/actions/booking-actions.ts +++ b/src/modules/courses/actions/booking-actions.ts @@ -308,8 +308,11 @@ export async function getCourseSummaryForConfirmation(courseId: string, sessionI let paidPrice = course.price; let discountAmount: number | null = null; if (sessionId) { - const booking = await prisma.courseBooking.findUnique({ - where: { stripeSessionId: sessionId }, + // findFirst statt findUnique mit stripeSessionId allein: eine URL, die + // courseId von Kurs B mit der session_id von Kurs A kombiniert, würde + // sonst Kurs B mit den Zahlungsdaten von Kurs A anzeigen. + const booking = await prisma.courseBooking.findFirst({ + where: { stripeSessionId: sessionId, courseId }, select: { amountPaid: true, discountAmount: true }, }); if (booking?.amountPaid !== null && booking?.amountPaid !== undefined) { diff --git a/src/modules/settings/actions/discount-actions.ts b/src/modules/settings/actions/discount-actions.ts index e95e262..e46be3c 100644 --- a/src/modules/settings/actions/discount-actions.ts +++ b/src/modules/settings/actions/discount-actions.ts @@ -78,6 +78,22 @@ export async function createDiscountCode( return { success: false, error: "Dieser Code existiert bereits für dieses Business." }; } + // Dedupliziert eingereichte IDs und erzwingt dieselbe Berechtigung wie der + // Picker (getBusinessCoursesForDiscountPicker): Business-Zugehörigkeit + + // kein Standing-Order-Template. Ohne diesen Check könnte eine direkt + // übergebene fremde/ungültige ID unvalidiert gespeichert werden und in der + // Liste als "Unbekannt" auftauchen, oder ein Standing-Order-Template + // fälschlich rabattfähig werden. + const courseIds = [...new Set(parsed.data.courseIds)]; + if (parsed.data.scope === "specific_courses") { + const matchingCourses = await prisma.course.count({ + where: { id: { in: courseIds }, businessId, isStandingOrder: false }, + }); + if (matchingCourses !== courseIds.length) { + return { success: false, error: "Mindestens ein Kurs ist für dieses Business ungültig." }; + } + } + const created = await prisma.discountCode.create({ data: { businessId, @@ -85,7 +101,7 @@ export async function createDiscountCode( type: parsed.data.type, value: parsed.data.value, scope: parsed.data.scope, - courseIds: parsed.data.scope === "specific_courses" ? parsed.data.courseIds : [], + courseIds: parsed.data.scope === "specific_courses" ? courseIds : [], maxRedemptions: parsed.data.maxRedemptions, startsAt: parsed.data.startsAt, expiresAt: parsed.data.expiresAt, diff --git a/src/modules/settings/lib/discount-validation.ts b/src/modules/settings/lib/discount-validation.ts index 55c4ab9..ca7b959 100644 --- a/src/modules/settings/lib/discount-validation.ts +++ b/src/modules/settings/lib/discount-validation.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "crypto"; import type { prisma as prismaClient } from "@/src/lib/prisma"; // Erlaubt sowohl den globalen `prisma`-Client (Checkout-Route, Vorab-Prüfung) @@ -12,11 +13,10 @@ export type DiscountValidationResult = | { valid: true; discountCode: { id: string; type: string; value: number } } | { valid: false; reason: string }; -// Wird an ZWEI Stellen aufgerufen: (1) beim Checkout-Start für sofortiges -// User-Feedback, (2) erneut INNERHALB der Serializable-Transaktion im -// Stripe-Webhook als Source of Truth. Muss deshalb rein lesend und ohne -// Seiteneffekte bleiben (keine Redemption/Increment hier) - das Verbuchen -// passiert ausschließlich in redeemDiscountCode() nach erfolgreicher Zahlung. +// Rein lesende Prüfung ohne Seiteneffekte - wird von reserveDiscountCode() +// (Checkout-Start) und previewDiscountCode() (Vorschau vor dem Checkout) +// genutzt. Das eigentliche Verbrauchen des Kontingents passiert in +// reserveDiscountCode() gleich danach, nicht hier. export async function validateDiscountCode( db: PrismaOrTx, params: { code: string; businessId: string; courseId: string; userId: string | null } @@ -94,61 +94,149 @@ export function calculateDiscountedPriceCents( return { discountedPriceCents, discountAmountCents }; } +export type ReserveDiscountResult = + | { + reserved: true; + reservationToken: string; + discountCode: { id: string; type: string; value: number }; + } + | { reserved: false; reason: string }; + +// Reserviert einen Rabattcode-Slot ATOMAR BEIM CHECKOUT-START, nicht erst +// nach Zahlungsbestätigung. Ohne das könnten mehrere gleichzeitige Checkouts +// (verschiedene User bei knappem Kontingent, oder ein User mit mehreren +// offenen Sessions für denselben Code) alle den rabattierten Preis bekommen, +// obwohl z.B. nur noch 1 Slot frei war - jede Session würde unabhängig +// voneinander `validateDiscountCode` als "noch gültig" sehen, weil keine der +// anderen Sessions zu dem Zeitpunkt schon eine Redemption-Zeile geschrieben +// hatte (das passierte vorher erst im Webhook, also NACH der Zahlung, zu +// spät um die Preisanzeige noch zu verhindern). +// +// reservationToken ist NICHT die echte Stripe-Session-ID (die existiert zu +// diesem Zeitpunkt noch nicht - stripe.checkout.sessions.create() läuft +// danach) - es ist ein selbst generierter Korrelationsschlüssel, der in die +// Stripe-Metadata gelegt wird, damit der Webhook dieselbe Reservierung anhand +// des Tokens wiederfindet und bestätigt. +// +// Die Prisma-Unique-Constraints (@@unique([discountCodeId, userId]) und die +// atomare {increment: 1} auf redeemedCount unter derselben Transaktion) +// erzwingen Kontingent und 1x-pro-User bereits HIER race-condition-sicher auf +// DB-Ebene - ein zweiter, gleichzeitiger Aufruf für denselben User/Code +// schlägt mit einem Unique-Constraint-Fehler fehl, den diese Funktion als +// "bereits reserviert" abfängt, statt ihn nach oben durchzureichen. +export async function reserveDiscountCode( + db: PrismaOrTx, + params: { code: string; businessId: string; courseId: string; userId: string } +): Promise { + const validation = await validateDiscountCode(db, params); + if (!validation.valid) { + return { reserved: false, reason: validation.reason }; + } + + const reservationToken = randomUUID(); + + try { + // Prisma's Query-Builder kann zwei Spalten derselben Zeile nicht direkt + // gegeneinander vergleichen (kein "redeemedCount < maxRedemptions" als + // where-Filter) - das UPDATE läuft daher als rohes SQL. Der Vergleich + // steht in derselben atomaren UPDATE-Anweisung wie das Increment, sodass + // Postgres beides unter einer Zeilensperre ausführt: ein UPDATE, das das + // Kontingent knapp erschöpft, sieht garantiert den zuvor inkrementierten + // Wert einer gleichzeitigen, aber zuerst committeten Anfrage - ein + // "verlorenes Update" (zwei gleichzeitige Reads sehen beide "noch frei") + // ist damit ausgeschlossen, ganz ohne die teurere Serializable-Isolation + // aus dem Webhook zu benötigen. + const updated = await db.$executeRaw` + UPDATE "DiscountCode" + SET "redeemedCount" = "redeemedCount" + 1 + WHERE "id" = ${validation.discountCode.id} + AND ("maxRedemptions" IS NULL OR "redeemedCount" < "maxRedemptions") + `; + + if (updated === 0) { + return { reserved: false, reason: "Dieser Rabattcode wurde bereits vollständig eingelöst." }; + } + + await db.discountRedemption.create({ + data: { + discountCodeId: validation.discountCode.id, + userId: params.userId, + stripeSessionId: reservationToken, + status: "reserved", + }, + }); + + return { reserved: true, reservationToken, discountCode: validation.discountCode }; + } catch (error) { + // P2002: der @@unique([discountCodeId, userId]) Constraint schlägt an, + // wenn zwischen der validateDiscountCode-Prüfung oben und diesem Insert + // eine ANDERE gleichzeitige Anfrage für denselben User/Code zuerst + // reserviert hat (z.B. zwei Tabs mit demselben Code + Kurs). + if (error instanceof Error && "code" in error && (error as { code: string }).code === "P2002") { + // Kontingent-Increment oben lief in diesem Fall bereits durch, bevor + // der Redemption-Insert scheiterte - muss zurückgerollt werden, sonst + // verliert der Code dauerhaft einen Slot ohne zugehörige Redemption. + await db.discountCode + .update({ + where: { id: validation.discountCode.id }, + data: { redeemedCount: { decrement: 1 } }, + }) + .catch(() => {}); + return { reserved: false, reason: "Du hast diesen Rabattcode bereits verwendet." }; + } + throw error; + } +} + +// Wird aufgerufen, wenn eine Reservierung NICHT zu einer bezahlten Buchung +// führt: Stripe-Session-Erstellung schlägt nach der Reservierung fehl +// (Rollback im Checkout-Route-Handler), oder die Session läuft ungenutzt ab +// (checkout.session.expired-Webhook-Handler). Gibt den reservierten Slot +// wieder frei, damit er nicht dauerhaft blockiert bleibt. +// +// Nimmt bewusst den Top-Level-Client (nicht PrismaOrTx/ein laufendes `tx`) - +// öffnet selbst eine eigene $transaction([...]) und würde sonst verschachtelt +// innerhalb einer bereits laufenden Transaktion aufgerufen werden, was Prisma +// nicht unterstützt. +export async function releaseDiscountReservation( + db: typeof prismaClient, + reservationToken: string +): Promise { + const redemption = await db.discountRedemption.findUnique({ + where: { stripeSessionId: reservationToken }, + }); + // Bereits bestätigt (Webhook kam parallel zuerst durch) oder gar nicht + // (mehr) vorhanden - in beiden Fällen nichts freizugeben. + if (!redemption || redemption.status !== "reserved") return; + + await db.$transaction([ + db.discountRedemption.delete({ where: { id: redemption.id } }), + db.discountCode.update({ + where: { id: redemption.discountCodeId }, + data: { redeemedCount: { decrement: 1 } }, + }), + ]); +} + // Wird NUR innerhalb der Serializable-Transaktion im Webhook aufgerufen, // nachdem die Zahlung bereits bestätigt ist und die CourseBooking-Zeile -// bereits existiert (bookingId erforderlich, siehe DiscountRedemption.bookingId -// @unique). Validiert den Code ein zweites Mal (Kontingent/Zeitfenster/ -// 1x-pro-User können sich seit dem Checkout-Start geändert haben) und legt bei -// Erfolg atomar Redemption + Counter-Increment an. Wird der Code zwischenzeitlich -// ungültig, tut diese Funktion nichts (kein Fehler) - die Buchung wurde vom -// Aufrufer bereits unabhängig davon zum (schon berechneten) rabattierten Preis -// bestätigt, nur ohne verbuchte Redemption. Das ist bewusstes Verhalten. -export async function redeemDiscountCode( +// bereits existiert. Verknüpft die beim Checkout-Start angelegte Reservierung +// (siehe reserveDiscountCode) mit der jetzt existierenden Buchung und setzt +// sie auf "confirmed" - der Kontingent-Slot wurde bereits bei der Reservierung +// verbraucht, hier wird nur noch die Buchungs-Zuordnung nachgetragen. +// Existiert keine "reserved"-Zeile mehr für dieses Token (z.B. weil +// checkout.session.expired sie zwischenzeitlich freigegeben hat, obwohl die +// Zahlung tatsächlich noch durchkam - ein seltenes, aber mögliches Timing), +// tut diese Funktion nichts: die Buchung wurde vom Aufrufer bereits +// unabhängig davon zum schon belasteten (rabattierten) Preis bestätigt, nur +// ohne verbuchte Redemption. Das ist bewusstes Verhalten, kein Fehlerfall. +export async function confirmDiscountReservation( tx: PrismaOrTx, - params: { - discountCodeId: string; - businessId: string; - courseId: string; - userId: string | null; - bookingId: string; - } + params: { reservationToken: string; bookingId: string } ): Promise { - if (!params.userId) return false; // Guests haben keine User-Redemption-Zuordnung, siehe validateDiscountCode - - const discountCode = await tx.discountCode.findUnique({ - where: { id: params.discountCodeId }, + const result = await tx.discountRedemption.updateMany({ + where: { stripeSessionId: params.reservationToken, status: "reserved" }, + data: { status: "confirmed", bookingId: params.bookingId }, }); - if (!discountCode || discountCode.businessId !== params.businessId) return false; - - const now = new Date(); - const stillValid = - discountCode.isActive && - (!discountCode.startsAt || now >= discountCode.startsAt) && - (!discountCode.expiresAt || now <= discountCode.expiresAt) && - (discountCode.scope !== "specific_courses" || - discountCode.courseIds.includes(params.courseId)) && - (discountCode.maxRedemptions === null || - discountCode.redeemedCount < discountCode.maxRedemptions); - if (!stillValid) return false; - - const existingRedemption = await tx.discountRedemption.findUnique({ - where: { - discountCodeId_userId: { discountCodeId: discountCode.id, userId: params.userId }, - }, - }); - if (existingRedemption) return false; - - await tx.discountCode.update({ - where: { id: discountCode.id }, - data: { redeemedCount: { increment: 1 } }, - }); - await tx.discountRedemption.create({ - data: { - discountCodeId: discountCode.id, - userId: params.userId, - bookingId: params.bookingId, - }, - }); - - return true; + return result.count > 0; } diff --git a/src/modules/settings/ui/components/DiscountCodeList.tsx b/src/modules/settings/ui/components/DiscountCodeList.tsx index bf3cf29..ebfd4ca 100644 --- a/src/modules/settings/ui/components/DiscountCodeList.tsx +++ b/src/modules/settings/ui/components/DiscountCodeList.tsx @@ -43,34 +43,51 @@ const emptyForm = { expiresAt: "" as string, }; -type StatusFilter = "all" | "active" | "inactive" | "expired"; +type DisplayStatus = "active" | "inactive" | "expired" | "scheduled" | "exhausted"; +type StatusFilter = "all" | DisplayStatus; function formatDate(date: Date | null): string { if (!date) return "—"; return new Intl.DateTimeFormat("de-DE", { dateStyle: "medium" }).format(date); } -function isExpired(code: DiscountCodeDTO): boolean { - return !!code.expiresAt && new Date(code.expiresAt) < new Date(); +// new Date("YYYY-MM-DD") parst als UTC-Mitternacht, nicht als lokaler +// Tagesbeginn - in deutschen Zeitzonen (UTC+1/+2) würde "Gültig ab" dadurch +// erst um 1-2 Uhr morgens greifen und "Gültig bis" bereits zu Beginn des +// gewählten Tages enden, statt dessen ganzen Tag abzudecken. Die drei +// Zahlen-Komponenten werden daher explizit in der lokalen Zeitzone des +// Browsers zusammengesetzt. +function parseLocalStartOfDay(value: string): Date { + const [year, month, day] = value.split("-").map(Number); + return new Date(year, month - 1, day, 0, 0, 0, 0); +} + +function parseLocalEndOfDay(value: string): Date { + const [year, month, day] = value.split("-").map(Number); + return new Date(year, month - 1, day, 23, 59, 59, 999); } // Rein visueller Ableitungs-Status fürs Filtern/Badges - ändert nichts an // DiscountCode.isActive selbst (das bleibt der manuelle An/Aus-Schalter, -// siehe deactivateDiscountCode). "expired" hat Vorrang vor "active", weil ein -// abgelaufener Code trotz isActive=true nicht mehr einlösbar ist. -function getDisplayStatus(code: DiscountCodeDTO): "active" | "inactive" | "expired" { +// siehe deactivateDiscountCode). Reihenfolge ist Priorität: ein deaktivierter +// Code ist "inactive" unabhängig von allem anderen; ein abgelaufener oder +// ausgeschöpfter Code ist trotz isActive=true nicht mehr einlösbar und darf +// daher nicht als "Aktiv" markiert werden (sonst hält ein Manager einen +// unbrauchbaren Code für nutzbar und findet ihn fälschlich im Aktiv-Filter). +function getDisplayStatus(code: DiscountCodeDTO): DisplayStatus { if (!code.isActive) return "inactive"; - if (isExpired(code)) return "expired"; + if (code.expiresAt && new Date(code.expiresAt) < new Date()) return "expired"; + if (code.startsAt && new Date(code.startsAt) > new Date()) return "scheduled"; + if (code.maxRedemptions !== null && code.redeemedCount >= code.maxRedemptions) return "exhausted"; return "active"; } -const STATUS_STYLES: Record< - "active" | "inactive" | "expired", - { label: string; bg: string; text: string } -> = { +const STATUS_STYLES: Record = { active: { label: "Aktiv", bg: "bg-green-100", text: "text-green-700" }, inactive: { label: "Deaktiviert", bg: "bg-gray-200", text: "text-gray-600" }, expired: { label: "Abgelaufen", bg: "bg-orange-100", text: "text-orange-700" }, + scheduled: { label: "Geplant", bg: "bg-blue-100", text: "text-blue-700" }, + exhausted: { label: "Ausgeschöpft", bg: "bg-orange-100", text: "text-orange-700" }, }; const DiscountCodeList = ({ @@ -98,17 +115,25 @@ const DiscountCodeList = ({ }, [codes, search, statusFilter]); const handleCreate = async () => { - const success = await onCreate({ - code: form.code, - type: form.type, - value: form.value, - scope: form.scope, - courseIds: form.courseIds, - maxRedemptions: form.maxRedemptions.trim() ? Number(form.maxRedemptions) : null, - startsAt: form.startsAt ? new Date(form.startsAt) : null, - expiresAt: form.expiresAt ? new Date(form.expiresAt) : null, - isActive: true, - }); + // onCreate kann rejecten (Netzwerk-/Serverfehler, siehe SettingsDiscountsPageView) - + // der Aufrufer meldet das bereits per Toast, hier nur verhindern, dass das + // Formular bei einem Fehler trotzdem als "fertig" geschlossen wird. + let success = false; + try { + success = await onCreate({ + code: form.code, + type: form.type, + value: form.value, + scope: form.scope, + courseIds: form.courseIds, + maxRedemptions: form.maxRedemptions.trim() ? Number(form.maxRedemptions) : null, + startsAt: form.startsAt ? parseLocalStartOfDay(form.startsAt) : null, + expiresAt: form.expiresAt ? parseLocalEndOfDay(form.expiresAt) : null, + isActive: true, + }); + } catch { + return; + } if (success) { setForm(emptyForm); setIsCreating(false); @@ -139,8 +164,10 @@ const DiscountCodeList = ({ Alle Status Aktiv - Deaktiviert + Geplant + Ausgeschöpft Abgelaufen + Deaktiviert @@ -329,7 +356,12 @@ const DiscountCodeList = ({ variant="ghost" size="icon" className="text-red-500 hover:bg-red-50 hover:text-red-600 shrink-0" - onClick={() => onDeactivate(c.id)} + onClick={() => { + // onDeactivate kann rejecten - der Aufrufer zeigt den + // Fehler bereits per Toast, hier nur die unhandled + // rejection verhindern. + onDeactivate(c.id).catch(() => {}); + }} disabled={isSaving} title="Deaktivieren" > diff --git a/src/modules/settings/ui/views/settings-discounts-page-view.tsx b/src/modules/settings/ui/views/settings-discounts-page-view.tsx index b4cba7c..3c2dca7 100644 --- a/src/modules/settings/ui/views/settings-discounts-page-view.tsx +++ b/src/modules/settings/ui/views/settings-discounts-page-view.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useTransition } from "react"; +import { useRef, useState, useTransition } from "react"; import { Tag, AlertTriangle } from "lucide-react"; import { toast } from "sonner"; import { @@ -24,16 +24,22 @@ interface SettingsDiscountsPageViewProps { businesses: { id: string; name: string }[]; initialCodes: DiscountCodeDTO[]; initialCourses: { id: string; name: string }[]; + initialCoursesError: string | null; } const SettingsDiscountsPageView = ({ businesses, initialCodes, initialCourses, + initialCoursesError, }: SettingsDiscountsPageViewProps) => { const [selectedBusinessId, setSelectedBusinessId] = useState(businesses[0]?.id ?? ""); const [codes, setCodes] = useState(initialCodes); const [courses, setCourses] = useState(initialCourses); + // Getrennt vom Codes-Ladefehler: ein fehlgeschlagener Kurs-Abruf darf die + // Codeliste nicht blockieren (die ist unabhängig ladbar), aber der Manager + // muss sehen, dass die Kursauswahl für "spezifische Kurse" unvollständig ist. + const [coursesError, setCoursesError] = useState(initialCoursesError); // Analog zur Stornierungsseite: eine fehlgeschlagene Anfrage darf nicht als // leere Codeliste dargestellt werden, sonst wirkt es so, als gäbe es für das // neue Business einfach keine Codes statt eines Ladefehlers. @@ -41,15 +47,24 @@ const SettingsDiscountsPageView = ({ const [isLoading, startLoading] = useTransition(); const [isSaving, startSaving] = useTransition(); + // Verhindert, dass eine spät ankommende Antwort für ein bereits verlassenes + // Business die Anzeige des inzwischen ausgewählten Business überschreibt + // (zwei schnelle Wechsel können außer der Reihe auflösen). + const requestSeq = useRef(0); + const handleBusinessChange = (businessId: string) => { const previousBusinessId = selectedBusinessId; + const seq = ++requestSeq.current; setSelectedBusinessId(businessId); setLoadError(null); + setCoursesError(null); startLoading(async () => { const [codesResult, coursesResult] = await Promise.all([ getDiscountCodes(businessId), getBusinessCoursesForDiscountPicker(businessId), ]); + if (seq !== requestSeq.current) return; // veraltete Antwort, ein neuerer Wechsel läuft bereits + if (!codesResult.success) { setSelectedBusinessId(previousBusinessId); setLoadError(codesResult.error); @@ -57,39 +72,63 @@ const SettingsDiscountsPageView = ({ return; } setCodes(codesResult.codes); - setCourses(coursesResult.success ? coursesResult.courses : []); + if (coursesResult.success) { + setCourses(coursesResult.courses); + setCoursesError(null); + } else { + // Vorherige Kursliste/Auswahl bewusst NICHT auf [] zurücksetzen - sonst + // sieht ein bereits mit "spezifische Kurse" konfigurierter Code so aus, + // als gäbe es keine Kurse, obwohl nur der Ladeversuch fehlschlug. + setCoursesError(coursesResult.error); + toast.error(coursesResult.error); + } }); }; + // startTransition()'s Callback läuft asynchron nach dem Aufruf von + // startSaving() - ein try/catch AUSSERHALB von startSaving fängt einen Wurf + // im Callback nicht ab, weil der Callback erst später (mikrotask-versetzt) + // läuft. Ohne das innere try/catch würde die äußere Promise bei einem Fehler + // (Netzwerk, Serialisierung) nie resolven/rejecten und ewig pending bleiben. const handleCreate = (input: DiscountCodeInput): Promise => { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { startSaving(async () => { - const result = await createDiscountCode(selectedBusinessId, input); - if (result.success) { - toast.success("Rabattcode erstellt."); - setCodes((prev) => [result.code, ...prev]); - resolve(true); - } else { - toast.error(result.error); - resolve(false); + try { + const result = await createDiscountCode(selectedBusinessId, input); + if (result.success) { + toast.success("Rabattcode erstellt."); + setCodes((prev) => [result.code, ...prev]); + resolve(true); + } else { + toast.error(result.error); + resolve(false); + } + } catch (error) { + toast.error("Rabattcode konnte nicht erstellt werden."); + reject(error instanceof Error ? error : new Error(String(error))); } }); }); }; const handleDeactivate = (discountCodeId: string): Promise => { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { startSaving(async () => { - const result = await deactivateDiscountCode(selectedBusinessId, discountCodeId); - if (result.success) { - toast.success("Rabattcode deaktiviert."); - setCodes((prev) => - prev.map((c) => (c.id === discountCodeId ? { ...c, isActive: false } : c)) - ); - resolve(true); - } else { - toast.error(result.error); - resolve(false); + try { + const result = await deactivateDiscountCode(selectedBusinessId, discountCodeId); + if (result.success) { + toast.success("Rabattcode deaktiviert."); + setCodes((prev) => + prev.map((c) => (c.id === discountCodeId ? { ...c, isActive: false } : c)) + ); + resolve(true); + } else { + toast.error(result.error); + resolve(false); + } + } catch (error) { + toast.error("Rabattcode konnte nicht deaktiviert werden."); + reject(error instanceof Error ? error : new Error(String(error))); } }); }); @@ -135,6 +174,15 @@ const SettingsDiscountsPageView = ({ ) : (
+ {coursesError && ( +
+ +

+ Kurse konnten nicht geladen werden ({coursesError}). Die Auswahl "Bestimmte + Kurse" ist unvollständig, bis die Seite neu geladen wird. +

+
+ )}