diff --git a/prisma/migrations/20260913155733_add_discount_codes/migration.sql b/prisma/migrations/20260913155733_add_discount_codes/migration.sql new file mode 100644 index 0000000..ba1f40f --- /dev/null +++ b/prisma/migrations/20260913155733_add_discount_codes/migration.sql @@ -0,0 +1,80 @@ +-- AlterTable +ALTER TABLE "CourseBooking" ADD COLUMN "discountAmount" INTEGER, +ADD COLUMN "discountCodeId" TEXT; + +-- CreateTable +CREATE TABLE "DiscountCode" ( + "id" TEXT NOT NULL, + "businessId" TEXT NOT NULL, + "code" TEXT NOT NULL, + "type" TEXT NOT NULL, + "value" INTEGER NOT NULL, + "scope" TEXT NOT NULL, + "courseIds" TEXT[], + "maxRedemptions" INTEGER, + "redeemedCount" INTEGER NOT NULL DEFAULT 0, + "startsAt" TIMESTAMP(3), + "expiresAt" TIMESTAMP(3), + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "DiscountCode_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DiscountRedemption" ( + "id" TEXT NOT NULL, + "discountCodeId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "bookingId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "DiscountRedemption_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "DiscountCode_businessId_idx" ON "DiscountCode"("businessId"); + +-- CreateIndex +CREATE UNIQUE INDEX "DiscountCode_businessId_code_key" ON "DiscountCode"("businessId", "code"); + +-- CreateIndex +CREATE UNIQUE INDEX "DiscountRedemption_bookingId_key" ON "DiscountRedemption"("bookingId"); + +-- CreateIndex +CREATE INDEX "DiscountRedemption_discountCodeId_idx" ON "DiscountRedemption"("discountCodeId"); + +-- CreateIndex +CREATE INDEX "DiscountRedemption_userId_idx" ON "DiscountRedemption"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "DiscountRedemption_discountCodeId_userId_key" ON "DiscountRedemption"("discountCodeId", "userId"); + +-- CreateIndex +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 +ALTER TABLE "DiscountCode" ADD CONSTRAINT "DiscountCode_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DiscountRedemption" ADD CONSTRAINT "DiscountRedemption_discountCodeId_fkey" FOREIGN KEY ("discountCodeId") REFERENCES "DiscountCode"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DiscountRedemption" ADD CONSTRAINT "DiscountRedemption_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DiscountRedemption" ADD CONSTRAINT "DiscountRedemption_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "CourseBooking"("id") ON DELETE CASCADE ON UPDATE CASCADE; 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 307c05e..c34e0fa 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -42,6 +42,7 @@ model User { trainings UserTraining[] courseBookings CourseBooking[] courseWaitlistEntries CourseWaitlistEntry[] + discountRedemptions DiscountRedemption[] createdAt DateTime? @default(now()) updatedAt DateTime? @updatedAt } @@ -128,10 +129,11 @@ model Business { onboardingStartedAt DateTime? // Wizard erstmals geoeffnet, ab dann kein Redirect mehr onboardingDismissedAt DateTime? // Checkliste im Dashboard ausgeblendet onboardingCompletedAt DateTime? // Zeitpunkt, an dem erstmals alle Pflichtschritte erledigt waren - manager Manager @relation(fields: [managerId], references: [id]) + manager Manager @relation(fields: [managerId], references: [id]) courses Course[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + discountCodes DiscountCode[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([managerId]) } @@ -195,30 +197,35 @@ model GuestLead { } model CourseBooking { - id String @id @default(uuid()) + id String @id @default(uuid()) courseId String userId String? guestLeadId String? - paymentStatus String @default("pending") // "pending" | "paid" | "failed" | "refunded" | "partially_refunded" | "refunding" | "cancelled" (cancelled = storniert ohne Geldbewegung, 0% Erstattung) - stripeSessionId String? @unique - stripePaymentIntentId String? @unique + paymentStatus String @default("pending") // "pending" | "paid" | "failed" | "refunded" | "partially_refunded" | "refunding" | "cancelled" (cancelled = storniert ohne Geldbewegung, 0% Erstattung) + stripeSessionId String? @unique + stripePaymentIntentId String? @unique amountPaid Int? // tatsächlich gezahlter Betrag in Cents platformFee Int? // unsere Service-Fee in Cents - invoiceNumber String? @unique // Rechnungsnummer, wird lazy beim ersten PDF-Download vergeben + invoiceNumber String? @unique // Rechnungsnummer, wird lazy beim ersten PDF-Download vergeben invoiceIssuedAt DateTime? // Zeitpunkt der Rechnungsnummer-Vergabe, damit das Rechnungsdatum bei jedem Download gleich bleibt refundedAmount Int? // tatsächlich erstatteter Betrag in Cents refundedAt DateTime? // Zeitpunkt der erfolgreichen Stripe-Rückerstattung cancelledBy String? // "user" | "manager" | "system" (system = automatischer Überbuchungs-Refund) cancellationReason String? - course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) - user User? @relation(fields: [userId], references: [id], onDelete: Cascade) - guestLead GuestLead? @relation(fields: [guestLeadId], references: [id], onDelete: SetNull) - createdAt DateTime @default(now()) + discountCodeId String? // Gesetzt, sobald bei der Buchung ein gültiger Rabattcode eingelöst wurde + discountAmount Int? // Tatsächlich gewährter Rabatt in Cents (Snapshot, unabhängig von späteren Code-Änderungen) + course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + guestLead GuestLead? @relation(fields: [guestLeadId], references: [id], onDelete: SetNull) + discountCode DiscountCode? @relation(fields: [discountCodeId], references: [id], onDelete: SetNull) + discountRedemption DiscountRedemption? + createdAt DateTime @default(now()) @@unique([courseId, userId]) // Ein User kann sich nur einmal pro Kurs anmelden @@index([courseId]) @@index([userId]) @@index([guestLeadId]) + @@index([discountCodeId]) } // Bewusst nur für registrierte User (kein guestEmail-Feld): ein @@ -258,3 +265,65 @@ model Notification { @@index([recipientId, readAt]) @@index([recipientId, createdAt]) } + +// 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 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 + code String // Anzeigeform, z.B. "SOMMER25" — Eindeutigkeit nur pro Business (siehe @@unique) + type String // "percent" | "fixed" + value Int // percent: 1-100, fixed: Cents + scope String // "all_courses" | "specific_courses" + courseIds String[] // Nur relevant wenn scope = "specific_courses" + maxRedemptions Int? // null = unbegrenzt + redeemedCount Int @default(0) + startsAt DateTime? // null = sofort gültig + expiresAt DateTime? // null = kein Ablauf + isActive Boolean @default(true) // Manuelles An/Aus, unabhängig vom Zeitfenster + business Business @relation(fields: [businessId], references: [id], onDelete: Cascade) + bookings CourseBooking[] + redemptions DiscountRedemption[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([businessId, code]) + @@index([businessId]) +} + +// 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 + 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 f3eb6f0..b7b7b6c 100644 --- a/src/app/(main)/settings/discounts/page.tsx +++ b/src/app/(main)/settings/discounts/page.tsx @@ -1,13 +1,42 @@ import { Suspense } from "react"; +import { AlertTriangle } from "lucide-react"; import { requireManager } from "@/src/lib/auth/getUser"; +import { + getDiscountCodes, + getBusinessCoursesForDiscountPicker, +} from "@/src/modules/settings/actions/discount-actions"; import SettingsDiscountsPageView from "@/src/modules/settings/ui/views/settings-discounts-page-view"; import SettingsLoading from "../loading"; export default async function SettingsDiscountPage() { - await requireManager(); + const manager = await requireManager(); + const businesses = manager.businesses.map((b) => ({ id: b.id, name: b.name })); + + const firstBusinessId = businesses[0]?.id; + const [codesResult, coursesResult] = firstBusinessId + ? await Promise.all([ + getDiscountCodes(firstBusinessId), + getBusinessCoursesForDiscountPicker(firstBusinessId), + ]) + : [null, null]; + + if (firstBusinessId && codesResult && !codesResult.success) { + return ( +
+ +

{codesResult.error}

+
+ ); + } + return ( }> - + ); } diff --git a/src/app/api/stripe/checkout/course/route.ts b/src/app/api/stripe/checkout/course/route.ts index d2164ea..fff7082 100644 --- a/src/app/api/stripe/checkout/course/route.ts +++ b/src/app/api/stripe/checkout/course/route.ts @@ -3,6 +3,11 @@ import { prisma } from "@/src/lib/prisma"; import { stripe, calculatePlatformFeeCents, eurosToCents } from "@/src/lib/stripe"; import { getUserData, isUser } from "@/src/lib/auth/getUser"; import { isManagerOrEmployeeEmail } from "@/src/lib/auth/account-role-by-email"; +import { + reserveDiscountCode, + releaseDiscountReservation, + calculateDiscountedPriceCents, +} from "@/src/modules/settings/lib/discount-validation"; // Simple in-memory rate limiter: max 10 checkout attempts per IP per minute. // Best-effort only — in multi-instance/serverless deployments each instance has @@ -61,8 +66,9 @@ export async function POST(request: NextRequest) { courseId?: string; guestEmail?: string; guestName?: string; + discountCode?: string; }; - const { courseId, guestEmail, guestName } = body; + const { courseId, guestEmail, guestName, discountCode } = body; if (!courseId) { return NextResponse.json({ error: "courseId fehlt" }, { status: 400 }); @@ -172,30 +178,112 @@ export async function POST(request: NextRequest) { } const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"; - const priceCents = eurosToCents(course.price); - const platformFeeCents = calculatePlatformFeeCents(course.price); + const originalPriceCents = eurosToCents(course.price); + + // 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()) { + // 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: userData!.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, + // nicht den Originalpreis — das entspricht dem, was via + // application_fee_amount wirklich von diesem Payment einbehalten wird. + const platformFeeCents = calculatePlatformFeeCents(priceCents / 100); 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, @@ -204,22 +292,35 @@ export async function POST(request: NextRequest) { isGuest: isGuest ? "true" : "false", guestEmail: isGuest ? customerEmail : "", 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() ?? "") : "", - }, - 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 }); + return NextResponse.json({ + url: session.url, + discountApplied: appliedDiscountCodeId !== null, + }); } catch (error) { console.error("Error creating checkout session:", error); return NextResponse.json( diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts index f78f1e3..46163b0 100644 --- a/src/app/api/stripe/webhook/route.ts +++ b/src/app/api/stripe/webhook/route.ts @@ -13,6 +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 { + confirmDiscountReservation, + releaseDiscountReservation, +} from "@/src/modules/settings/lib/discount-validation"; /** * Sucht einen registrierten User anhand der E-Mail, case-insensitive. @@ -107,6 +111,17 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) { const platformFeeCents = paymentIntentId ? await getApplicationFeeFromPaymentIntent(paymentIntentId) : 0; + // 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. 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. @@ -164,6 +179,9 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) { paymentIntentId, amountPaidCents, platformFeeCents, + discountCodeId, + discountAmountCents, + discountReservationToken, treatDistinctSessionAsDuplicate: true, }) : await runCapacityTransactionGuest({ @@ -214,6 +232,9 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) { paymentIntentId, amountPaidCents, platformFeeCents, + discountCodeId, + discountAmountCents, + discountReservationToken, treatDistinctSessionAsDuplicate: true, }) : await runCapacityTransactionGuest({ @@ -256,6 +277,9 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) { paymentIntentId, amountPaidCents, platformFeeCents, + discountCodeId, + discountAmountCents, + discountReservationToken, }); } break; @@ -435,6 +459,9 @@ async function runCapacityTransactionUser(params: { paymentIntentId: string | null; amountPaidCents: number; platformFeeCents: number; + discountCodeId?: string | null; + discountAmountCents?: number; + discountReservationToken?: string | null; treatDistinctSessionAsDuplicate?: boolean; }): Promise { const { @@ -444,6 +471,9 @@ async function runCapacityTransactionUser(params: { paymentIntentId, amountPaidCents, platformFeeCents, + discountCodeId = null, + discountAmountCents = 0, + discountReservationToken = null, treatDistinctSessionAsDuplicate = false, } = params; @@ -546,6 +576,8 @@ async function runCapacityTransactionUser(params: { stripePaymentIntentId: paymentIntentId, amountPaid: amountPaidCents, platformFee: platformFeeCents, + discountCodeId, + discountAmount: discountCodeId ? discountAmountCents : null, }, create: { courseId, @@ -555,8 +587,27 @@ async function runCapacityTransactionUser(params: { stripePaymentIntentId: paymentIntentId, amountPaid: amountPaidCents, platformFee: platformFeeCents, + discountCodeId, + discountAmount: discountCodeId ? discountAmountCents : null, }, }); + + // 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, + }); + } + return { result: "booked" as const, bookingId: booking.id }; }, { isolationLevel: "Serializable" } @@ -825,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/app/courses/book/[courseId]/success/page.tsx b/src/app/courses/book/[courseId]/success/page.tsx index ca70cb7..2efec3a 100644 --- a/src/app/courses/book/[courseId]/success/page.tsx +++ b/src/app/courses/book/[courseId]/success/page.tsx @@ -11,9 +11,10 @@ interface SuccessPageProps { searchParams: Promise<{ session_id?: string }>; } -export default async function CourseBookingSuccessPage({ params }: SuccessPageProps) { +export default async function CourseBookingSuccessPage({ params, searchParams }: SuccessPageProps) { const { courseId } = await params; - const result = await getCourseSummaryForConfirmation(courseId); + const { session_id: sessionId } = await searchParams; + const result = await getCourseSummaryForConfirmation(courseId, sessionId); const course = result.success ? result.course : null; const businessSlug = course?.business?.slug ?? null; @@ -28,9 +29,13 @@ export default async function CourseBookingSuccessPage({ params }: SuccessPagePr }) : null; - const formattedPrice = course - ? new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(course.price) - : null; + const priceFormatter = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }); + const formattedPrice = result.success && course ? priceFormatter.format(result.paidPrice) : null; + const hasDiscount = result.success && result.discountAmount !== null; + const formattedDiscount = + result.success && result.discountAmount !== null + ? priceFormatter.format(result.discountAmount) + : null; return (
@@ -84,21 +89,29 @@ export default async function CourseBookingSuccessPage({ params }: SuccessPagePr )}
-
- Bezahlt - {branding ? ( - - {formattedPrice} - - ) : ( - {formattedPrice} +
+ {hasDiscount && ( +
+ Rabatt + − {formattedDiscount} +
)} +
+ Bezahlt + {branding ? ( + + {formattedPrice} + + ) : ( + {formattedPrice} + )} +
)} diff --git a/src/modules/courses/actions/booking-actions.ts b/src/modules/courses/actions/booking-actions.ts index e70f840..04fba2a 100644 --- a/src/modules/courses/actions/booking-actions.ts +++ b/src/modules/courses/actions/booking-actions.ts @@ -14,6 +14,11 @@ import { executeBookingCancellation } from "./cancel-booking-shared"; import { combineDateAndTime } from "@/src/lib/course-time"; import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding"; import { KUMASY_BRANDING } from "@/src/lib/mail/templates/tokens"; +import { eurosToCents } from "@/src/lib/stripe"; +import { + validateDiscountCode, + calculateDiscountedPriceCents, +} from "@/src/modules/settings/lib/discount-validation"; export type UserBooking = { bookingId: string; @@ -264,7 +269,7 @@ export async function checkUserBookingStatus(courseId: string) { * * @param courseId - Die eindeutige ID des Kurses */ -export async function getCourseSummaryForConfirmation(courseId: string) { +export async function getCourseSummaryForConfirmation(courseId: string, sessionId?: string) { try { const course = await prisma.course.findFirst({ where: { @@ -295,7 +300,28 @@ export async function getCourseSummaryForConfirmation(courseId: string) { return { success: false as const, error: "Kurs nicht gefunden" }; } - return { success: true as const, course }; + // course.price ist der reguläre Kurspreis, NICHT der tatsächlich gezahlte + // Betrag (kann durch einen Rabattcode abweichen). sessionId (aus der + // Stripe-success_url) erlaubt es, die konkrete Buchung nachzuladen - ohne + // sessionId (oder falls der Webhook noch nicht durchgelaufen ist) fällt + // die Anzeige auf den regulären Kurspreis zurück. + let paidPrice = course.price; + let discountAmount: number | null = null; + if (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) { + paidPrice = booking.amountPaid / 100; + discountAmount = booking.discountAmount !== null ? booking.discountAmount / 100 : null; + } + } + + return { success: true as const, course, paidPrice, discountAmount }; } catch (error) { console.error("Error fetching course summary:", error); return { success: false as const, error: "Fehler beim Laden des Kurses" }; @@ -407,3 +433,51 @@ async function _fetchCoursesForBusiness(businessId: string, options: { from: Dat return { success: false, error: "Fehler beim Laden der Kurse", courses: [] }; } } + +// Rein lesende Vorschau für die Buchungs-UI, damit ein Kunde den rabattierten +// Preis VOR dem Stripe-Checkout sieht. Legt keine Redemption an und erhöht +// keinen Counter (siehe redeemDiscountCode) - die Checkout-Route führt exakt +// dieselbe Validierung erneut aus, bevor die Session erstellt wird. +export async function previewDiscountCode( + courseId: string, + code: string +): Promise< + { valid: true; discountedPrice: number; discountAmount: number } | { valid: false; error: string } +> { + try { + const userData = await getUserData(); + + const course = await prisma.course.findUnique({ + where: { id: courseId }, + select: { price: true, businessId: true }, + }); + if (!course || !course.businessId) { + return { valid: false, error: "Kurs nicht gefunden." }; + } + + const validation = await validateDiscountCode(prisma, { + code, + businessId: course.businessId, + courseId, + userId: userData && isUser(userData) ? userData.id : null, + }); + if (!validation.valid) { + return { valid: false, error: validation.reason }; + } + + const originalPriceCents = eurosToCents(course.price); + const { discountedPriceCents, discountAmountCents } = calculateDiscountedPriceCents( + originalPriceCents, + validation.discountCode + ); + + return { + valid: true, + discountedPrice: discountedPriceCents / 100, + discountAmount: discountAmountCents / 100, + }; + } catch (error) { + console.error("Error previewing discount code:", error); + return { valid: false, error: "Rabattcode konnte nicht geprüft werden." }; + } +} diff --git a/src/modules/courses/ui/components/PublicCourseCard.tsx b/src/modules/courses/ui/components/PublicCourseCard.tsx index f0ecfad..83f4bbf 100644 --- a/src/modules/courses/ui/components/PublicCourseCard.tsx +++ b/src/modules/courses/ui/components/PublicCourseCard.tsx @@ -9,7 +9,7 @@ import { Dialog, DialogClose, DialogContent, DialogTitle } from "@/src/component import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/src/components/ui/tooltip"; import DOMPurify from "dompurify"; -import { checkUserBookingStatus } from "../../actions/booking-actions"; +import { checkUserBookingStatus, previewDiscountCode } from "../../actions/booking-actions"; import { joinWaitlistAsUser } from "../../actions/waitlist-actions"; import { getContrastTextColor } from "@/src/modules/settings/lib/contrast"; import { toast } from "sonner"; @@ -168,6 +168,12 @@ export const BookingDialog = ({ const [showGuestForm, setShowGuestForm] = useState(false); const [guestEmail, setGuestEmail] = useState(""); const [guestName, setGuestName] = useState(""); + const [discountInput, setDiscountInput] = useState(""); + const [isCheckingDiscount, setIsCheckingDiscount] = useState(false); + const [appliedDiscount, setAppliedDiscount] = useState<{ + code: string; + discountedPrice: number; + } | null>(null); const isAdminView = paymentStatus !== undefined; @@ -224,10 +230,32 @@ export const BookingDialog = ({ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + const handleApplyDiscount = async () => { + const code = discountInput.trim(); + if (!code) return; + setIsCheckingDiscount(true); + try { + const result = await previewDiscountCode(course.id, code); + if (!result.valid) { + toast.error(result.error); + setAppliedDiscount(null); + return; + } + setAppliedDiscount({ code, discountedPrice: result.discountedPrice }); + toast.success("Rabattcode angewendet."); + } catch { + toast.error("Rabattcode konnte nicht geprüft werden."); + setAppliedDiscount(null); + } finally { + setIsCheckingDiscount(false); + } + }; + const handleBooking = async (opts?: { asGuest: boolean }) => { setIsSubmitting(true); try { const body: Record = { courseId: course.id }; + if (appliedDiscount) body.discountCode = appliedDiscount.code; if (opts?.asGuest) { const normalizedEmail = guestEmail.trim().toLowerCase(); if (!EMAIL_RE.test(normalizedEmail)) { @@ -256,6 +284,11 @@ export const BookingDialog = ({ if (response.status === 401 || (response.status === 400 && data.isGuestRequired)) { if (opts?.asGuest && data.error) toast.error(data.error); setShowGuestForm(true); + // Rabattcodes sind nur für eingeloggte User verfügbar (siehe + // validateDiscountCode) - ein bereits angewendeter Code darf nicht + // stillschweigend im Gast-Checkout landen, wo er ohnehin ignoriert würde. + setAppliedDiscount(null); + setDiscountInput(""); setIsSubmitting(false); return; } @@ -430,8 +463,48 @@ export const BookingDialog = ({ {/* Sticky Footer */}
+ {/* Rabattcodes sind nur für eingeloggte User verfügbar (siehe + validateDiscountCode) - im Gast-Formular macht das Feld daher + keinen Sinn und würde nur einen ungültigen Code suggerieren. */} + {!isAdminView && !isFull && !isBooked && !showGuestForm && ( +
+ { + setDiscountInput(e.target.value.toUpperCase()); + if (appliedDiscount) setAppliedDiscount(null); + }} + className="flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400" + /> + +
+ )}
- Preis: {formattedPrice} + + {appliedDiscount ? ( + <> + + {formattedPrice} + + Preis:{" "} + {appliedDiscount.discountedPrice.toLocaleString("de-DE", { + style: "currency", + currency: "EUR", + })} + + ) : ( + `Preis: ${formattedPrice}` + )} +
{/* Admin-Ansicht: nur Status-Badge + Schließen */} {isAdminView ? ( diff --git a/src/modules/settings/actions/discount-actions.ts b/src/modules/settings/actions/discount-actions.ts new file mode 100644 index 0000000..e46be3c --- /dev/null +++ b/src/modules/settings/actions/discount-actions.ts @@ -0,0 +1,242 @@ +"use server"; + +import { prisma } from "@/src/lib/prisma"; +import { requireManager } from "@/src/lib/auth/getUser"; +import { revalidatePath } from "next/cache"; +import { discountCodeSchema, type DiscountCodeInput } from "../schemas/discount-code-schema"; + +export type DiscountCodeDTO = { + id: string; + code: string; + type: "percent" | "fixed"; + value: number; + scope: "all_courses" | "specific_courses"; + courseIds: string[]; + maxRedemptions: number | null; + redeemedCount: number; + startsAt: Date | null; + expiresAt: Date | null; + isActive: boolean; + createdAt: Date; +}; + +async function assertBusinessOwnership(businessId: string, managerId: string) { + const business = await prisma.business.findUnique({ + where: { id: businessId }, + select: { managerId: true }, + }); + return !!business && business.managerId === managerId; +} + +export async function getDiscountCodes( + businessId: string +): Promise<{ success: true; codes: DiscountCodeDTO[] } | { success: false; error: string }> { + // requireManager() löst bei fehlender Session/Rolle einen next/navigation-Redirect + // aus, der als spezieller Error implementiert ist - darf nicht im try/catch landen. + const manager = await requireManager(); + + try { + if (!(await assertBusinessOwnership(businessId, manager.id))) { + return { success: false, error: "Keine Berechtigung für dieses Business." }; + } + + const codes = await prisma.discountCode.findMany({ + where: { businessId }, + orderBy: { createdAt: "desc" }, + }); + + return { success: true, codes: codes as DiscountCodeDTO[] }; + } catch (error) { + console.error("Error fetching discount codes:", error); + return { success: false, error: "Rabattcodes konnten nicht geladen werden." }; + } +} + +export async function createDiscountCode( + businessId: string, + input: DiscountCodeInput +): Promise<{ success: true; code: DiscountCodeDTO } | { success: false; error: string }> { + const manager = await requireManager(); + + try { + if (!(await assertBusinessOwnership(businessId, manager.id))) { + return { success: false, error: "Keine Berechtigung für dieses Business." }; + } + + const parsed = discountCodeSchema.safeParse(input); + if (!parsed.success) { + return { success: false, error: parsed.error.issues[0]?.message ?? "Ungültige Eingabe." }; + } + + const normalizedCode = parsed.data.code.toUpperCase(); + + const existing = await prisma.discountCode.findUnique({ + where: { businessId_code: { businessId, code: normalizedCode } }, + select: { id: true }, + }); + if (existing) { + 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, + code: normalizedCode, + type: parsed.data.type, + value: parsed.data.value, + scope: parsed.data.scope, + courseIds: parsed.data.scope === "specific_courses" ? courseIds : [], + maxRedemptions: parsed.data.maxRedemptions, + startsAt: parsed.data.startsAt, + expiresAt: parsed.data.expiresAt, + isActive: parsed.data.isActive, + }, + }); + + revalidatePath("/settings/discounts"); + + return { success: true, code: created as DiscountCodeDTO }; + } catch (error) { + console.error("Error creating discount code:", error); + return { success: false, error: "Rabattcode konnte nicht erstellt werden." }; + } +} + +// Bewusst kein voller Edit für code/type/value/scope, sobald ein Code schon +// mindestens einmal eingelöst wurde: eine nachträgliche Änderung würde +// bestehende, bereits abgerechnete Buchungen (CourseBooking.discountAmount) +// rückwirkend inkonsistent mit dem Code-Datensatz machen. Editierbar bleiben +// nur die Limits/Laufzeit/isActive, die den Code lediglich stärker +// einschränken oder ausschalten, nie rückwirkend die Vergangenheit verändern. +export async function updateDiscountCodeLimits( + businessId: string, + discountCodeId: string, + input: { + maxRedemptions: number | null; + startsAt: Date | null; + expiresAt: Date | null; + isActive: boolean; + } +): Promise<{ success: true } | { success: false; error: string }> { + const manager = await requireManager(); + + try { + if (!(await assertBusinessOwnership(businessId, manager.id))) { + return { success: false, error: "Keine Berechtigung für dieses Business." }; + } + + if (input.startsAt && input.expiresAt && input.expiresAt <= input.startsAt) { + return { success: false, error: "Ablaufdatum muss nach dem Startdatum liegen." }; + } + if (input.maxRedemptions !== null && input.maxRedemptions <= 0) { + return { success: false, error: "Maximale Einlösungen muss größer als 0 sein." }; + } + + const code = await prisma.discountCode.findUnique({ + where: { id: discountCodeId }, + select: { businessId: true }, + }); + if (!code || code.businessId !== businessId) { + return { success: false, error: "Rabattcode nicht gefunden." }; + } + + await prisma.discountCode.update({ + where: { id: discountCodeId }, + data: { + maxRedemptions: input.maxRedemptions, + startsAt: input.startsAt, + expiresAt: input.expiresAt, + isActive: input.isActive, + }, + }); + + revalidatePath("/settings/discounts"); + + return { success: true }; + } catch (error) { + console.error("Error updating discount code:", error); + return { success: false, error: "Rabattcode konnte nicht aktualisiert werden." }; + } +} + +// Hard-Delete bewusst vermieden: DiscountRedemption/CourseBooking verweisen auf +// die Code-ID (Rechnungs-/Audit-Historie). Deaktivieren reicht, um weitere +// Einlösungen zu verhindern. +export async function deactivateDiscountCode( + businessId: string, + discountCodeId: string +): Promise<{ success: true } | { success: false; error: string }> { + const manager = await requireManager(); + + try { + if (!(await assertBusinessOwnership(businessId, manager.id))) { + return { success: false, error: "Keine Berechtigung für dieses Business." }; + } + + const code = await prisma.discountCode.findUnique({ + where: { id: discountCodeId }, + select: { businessId: true }, + }); + if (!code || code.businessId !== businessId) { + return { success: false, error: "Rabattcode nicht gefunden." }; + } + + await prisma.discountCode.update({ + where: { id: discountCodeId }, + data: { isActive: false }, + }); + + revalidatePath("/settings/discounts"); + + return { success: true }; + } catch (error) { + console.error("Error deactivating discount code:", error); + return { success: false, error: "Rabattcode konnte nicht deaktiviert werden." }; + } +} + +// Für den "spezifische Kurse"-Picker im Settings-UI. Es gibt keine bestehende +// "Kurse nach Business"-Abfrage (course-actions.ts filtert nach createdBy = +// Manager-ID, nicht businessId) - Rabattcodes sind aber businessId-scoped, +// daher eine eigene, schlanke Query statt getMyCourses() zu erweitern. +export async function getBusinessCoursesForDiscountPicker( + businessId: string +): Promise< + | { success: true; courses: Array<{ id: string; name: string }> } + | { success: false; error: string } +> { + const manager = await requireManager(); + + try { + if (!(await assertBusinessOwnership(businessId, manager.id))) { + return { success: false, error: "Keine Berechtigung für dieses Business." }; + } + + const courses = await prisma.course.findMany({ + where: { businessId, isStandingOrder: false }, + select: { id: true, name: true }, + orderBy: { date: "desc" }, + }); + + return { success: true, courses }; + } catch (error) { + console.error("Error fetching business courses:", error); + return { success: false, error: "Kurse konnten nicht geladen werden." }; + } +} diff --git a/src/modules/settings/lib/discount-validation.ts b/src/modules/settings/lib/discount-validation.ts new file mode 100644 index 0000000..ca7b959 --- /dev/null +++ b/src/modules/settings/lib/discount-validation.ts @@ -0,0 +1,242 @@ +import { randomUUID } from "crypto"; +import type { prisma as prismaClient } from "@/src/lib/prisma"; + +// Erlaubt sowohl den globalen `prisma`-Client (Checkout-Route, Vorab-Prüfung) +// als auch den `tx`-Parameter aus prisma.$transaction() (Webhook, Source of +// Truth) - Prisma leitet den Transaktions-Client-Typ nicht separat exportierbar +// ab, daher hier über Parameters<> vom $transaction-Callback selbst. +type PrismaOrTx = + | typeof prismaClient + | Parameters[0]>[0]; + +export type DiscountValidationResult = + | { valid: true; discountCode: { id: string; type: string; value: number } } + | { valid: false; reason: string }; + +// 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 } +): Promise { + const normalizedCode = params.code.trim().toUpperCase(); + if (!normalizedCode) { + return { valid: false, reason: "Kein Rabattcode angegeben." }; + } + + const discountCode = await db.discountCode.findUnique({ + where: { businessId_code: { businessId: params.businessId, code: normalizedCode } }, + }); + + if (!discountCode || !discountCode.isActive) { + return { valid: false, reason: "Dieser Rabattcode ist ungültig." }; + } + + const now = new Date(); + if (discountCode.startsAt && now < discountCode.startsAt) { + return { valid: false, reason: "Dieser Rabattcode ist noch nicht gültig." }; + } + if (discountCode.expiresAt && now > discountCode.expiresAt) { + return { valid: false, reason: "Dieser Rabattcode ist abgelaufen." }; + } + + if ( + discountCode.scope === "specific_courses" && + !discountCode.courseIds.includes(params.courseId) + ) { + return { valid: false, reason: "Dieser Rabattcode gilt nicht für diesen Kurs." }; + } + + if ( + discountCode.maxRedemptions !== null && + discountCode.redeemedCount >= discountCode.maxRedemptions + ) { + return { valid: false, reason: "Dieser Rabattcode wurde bereits vollständig eingelöst." }; + } + + // Rabattcodes sind bewusst nur eingeloggten Usern vorbehalten: Gäste haben + // keine stabile Identität (nur eine unverifizierte E-Mail), wodurch weder + // die 1x-pro-User-Regel noch das Gesamtkontingent zuverlässig durchsetzbar + // wären (siehe #182 - Gast-Redemptions wurden zunächst nicht mitgezählt). + if (!params.userId) { + return { valid: false, reason: "Rabattcodes sind nur für angemeldete Kunden verfügbar." }; + } + + const existingRedemption = await db.discountRedemption.findUnique({ + where: { discountCodeId_userId: { discountCodeId: discountCode.id, userId: params.userId } }, + }); + if (existingRedemption) { + return { valid: false, reason: "Du hast diesen Rabattcode bereits verwendet." }; + } + + return { + valid: true, + discountCode: { id: discountCode.id, type: discountCode.type, value: discountCode.value }, + }; +} + +export function calculateDiscountedPriceCents( + originalPriceCents: number, + discount: { type: string; value: number } +): { discountedPriceCents: number; discountAmountCents: number } { + const rawDiscount = + discount.type === "percent" + ? Math.round((originalPriceCents * discount.value) / 100) + : discount.value; + + // Rabatt darf den Preis nicht unter 0 drücken (relevant v.a. bei "fixed" auf + // günstige Kurse) - Stripe lehnt unit_amount <= 0 in der Regel ohnehin ab. + const discountAmountCents = Math.min(rawDiscount, originalPriceCents); + const discountedPriceCents = originalPriceCents - discountAmountCents; + + 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. 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: { reservationToken: string; bookingId: string } +): Promise { + const result = await tx.discountRedemption.updateMany({ + where: { stripeSessionId: params.reservationToken, status: "reserved" }, + data: { status: "confirmed", bookingId: params.bookingId }, + }); + return result.count > 0; +} diff --git a/src/modules/settings/schemas/discount-code-schema.ts b/src/modules/settings/schemas/discount-code-schema.ts new file mode 100644 index 0000000..904b9dd --- /dev/null +++ b/src/modules/settings/schemas/discount-code-schema.ts @@ -0,0 +1,47 @@ +import { z } from "zod"; + +// type = "percent": value ist 1-100 (%). type = "fixed": value ist Cents (>0). +// Die Bounds hängen vom Typ ab, deshalb superRefine statt eines einzigen +// z.number()-Ranges. +export const discountCodeSchema = z + .object({ + code: z + .string() + .trim() + .min(3, "Mindestens 3 Zeichen") + .max(30, "Maximal 30 Zeichen") + .regex(/^[A-Z0-9_-]+$/, "Nur Großbuchstaben, Zahlen, - und _ erlaubt"), + type: z.enum(["percent", "fixed"]), + value: z.number().int().positive(), + scope: z.enum(["all_courses", "specific_courses"]), + courseIds: z.array(z.string()).default([]), + maxRedemptions: z.number().int().positive().nullable(), + startsAt: z.coerce.date().nullable(), + expiresAt: z.coerce.date().nullable(), + isActive: z.boolean().default(true), + }) + .superRefine((data, ctx) => { + if (data.type === "percent" && data.value > 100) { + ctx.addIssue({ + code: "custom", + path: ["value"], + message: "Prozentwert darf maximal 100 sein", + }); + } + if (data.scope === "specific_courses" && data.courseIds.length === 0) { + ctx.addIssue({ + code: "custom", + path: ["courseIds"], + message: "Mindestens ein Kurs muss ausgewählt werden", + }); + } + if (data.startsAt && data.expiresAt && data.expiresAt <= data.startsAt) { + ctx.addIssue({ + code: "custom", + path: ["expiresAt"], + message: "Ablaufdatum muss nach dem Startdatum liegen", + }); + } + }); + +export type DiscountCodeInput = z.infer; diff --git a/src/modules/settings/ui/components/DiscountCodeList.tsx b/src/modules/settings/ui/components/DiscountCodeList.tsx new file mode 100644 index 0000000..ebfd4ca --- /dev/null +++ b/src/modules/settings/ui/components/DiscountCodeList.tsx @@ -0,0 +1,430 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Plus, Trash2, Tag, Search, Calendar, Percent, Coins, Layers } from "lucide-react"; +import { Button } from "@/src/components/ui/button"; +import { Input } from "@/src/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/src/components/ui/select"; +import MultiSelectDropdown from "@/src/components/layout/MultiSelectDropdown"; +import type { DiscountCodeDTO } from "../../actions/discount-actions"; + +interface DiscountCodeListProps { + codes: DiscountCodeDTO[]; + courses: { id: string; name: string }[]; + onCreate: (input: { + code: string; + type: "percent" | "fixed"; + value: number; + scope: "all_courses" | "specific_courses"; + courseIds: string[]; + maxRedemptions: number | null; + startsAt: Date | null; + expiresAt: Date | null; + isActive: boolean; + }) => Promise; + onDeactivate: (discountCodeId: string) => Promise; + isSaving: boolean; +} + +const emptyForm = { + code: "", + type: "percent" as "percent" | "fixed", + value: 10, + scope: "all_courses" as "all_courses" | "specific_courses", + courseIds: [] as string[], + maxRedemptions: "" as string, // leer = unbegrenzt, siehe toInput() + startsAt: "" as string, + expiresAt: "" as string, +}; + +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); +} + +// 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). 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 (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: { 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 = ({ + codes, + courses, + onCreate, + onDeactivate, + isSaving, +}: DiscountCodeListProps) => { + const [form, setForm] = useState(emptyForm); + const [isCreating, setIsCreating] = useState(false); + const [search, setSearch] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + + const courseOptions = courses.map((c) => ({ value: c.id, label: c.name })); + const courseNameById = useMemo(() => new Map(courses.map((c) => [c.id, c.name])), [courses]); + + const filteredCodes = useMemo(() => { + const query = search.trim().toUpperCase(); + return codes.filter((c) => { + if (query && !c.code.includes(query)) return false; + if (statusFilter !== "all" && getDisplayStatus(c) !== statusFilter) return false; + return true; + }); + }, [codes, search, statusFilter]); + + const handleCreate = async () => { + // 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); + } + }; + + return ( +
+
+ {codes.length > 0 && ( +
+
+ + setSearch(e.target.value)} + /> +
+ +
+ )} + {!isCreating && ( + + )} +
+ + {isCreating && ( +
+
+ + setForm((f) => ({ ...f, code: e.target.value.toUpperCase() }))} + placeholder="z.B. SOMMER25" + /> +
+ +
+
+ + +
+
+ + { + const raw = Number(e.target.value); + const cents = form.type === "percent" ? raw : Math.round(raw * 100); + setForm((f) => ({ ...f, value: Number.isFinite(cents) ? cents : 0 })); + }} + /> +
+
+ +
+ + +
+ + {form.scope === "specific_courses" && ( + setForm((f) => ({ ...f, courseIds: values }))} + options={courseOptions} + /> + )} + +
+ + setForm((f) => ({ ...f, maxRedemptions: e.target.value }))} + placeholder="z.B. 50" + /> +
+ +
+
+ + setForm((f) => ({ ...f, startsAt: e.target.value }))} + /> +
+
+ + setForm((f) => ({ ...f, expiresAt: e.target.value }))} + /> +
+
+ +
+ + +
+
+ )} + + {codes.length === 0 ? ( +

+ Noch keine Rabattcodes angelegt. +

+ ) : filteredCodes.length === 0 ? ( +

+ Keine Rabattcodes gefunden. +

+ ) : ( +
+ {filteredCodes.map((c) => { + const status = getDisplayStatus(c); + const statusStyle = STATUS_STYLES[status]; + const redemptionPercent = + c.maxRedemptions !== null + ? Math.min(100, Math.round((c.redeemedCount / c.maxRedemptions) * 100)) + : null; + + return ( +
+
+
+
+ +
+
+

+ {c.code} +

+ + {statusStyle.label} + +
+
+ {c.isActive && ( + + )} +
+ +
+ {c.type === "percent" ? ( + + ) : ( + + )} + {c.type === "percent" ? `${c.value}%` : `${(c.value / 100).toFixed(2)} €`} + Rabatt +
+ +
+ + {c.scope === "all_courses" ? ( + Gilt für alle Kurse + ) : ( + + Gilt für:{" "} + {c.courseIds.map((id) => courseNameById.get(id) ?? "Unbekannt").join(", ")} + + )} +
+ + {(c.startsAt || c.expiresAt) && ( +
+ + + {formatDate(c.startsAt)} – {formatDate(c.expiresAt)} + +
+ )} + +
+
+ Eingelöst + + {c.redeemedCount} + {c.maxRedemptions !== null ? ` / ${c.maxRedemptions}` : " (unbegrenzt)"} + +
+ {redemptionPercent !== null && ( +
+
= 100 ? "bg-orange-400" : "bg-blue-500"}`} + style={{ width: `${redemptionPercent}%` }} + /> +
+ )} +
+
+ ); + })} +
+ )} +
+ ); +}; + +export default DiscountCodeList; 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 b3b1736..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,5 +1,202 @@ -const SettingsDiscountsPageView = () => { - return
SettingsDiscountsPageView
; +"use client"; + +import { useRef, useState, useTransition } from "react"; +import { Tag, AlertTriangle } from "lucide-react"; +import { toast } from "sonner"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/src/components/ui/select"; +import DiscountCodeList from "../components/DiscountCodeList"; +import { + getDiscountCodes, + createDiscountCode, + deactivateDiscountCode, + getBusinessCoursesForDiscountPicker, + type DiscountCodeDTO, +} from "../../actions/discount-actions"; +import type { DiscountCodeInput } from "../../schemas/discount-code-schema"; + +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. + const [loadError, setLoadError] = useState(null); + 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); + toast.error(codesResult.error); + return; + } + setCodes(codesResult.codes); + 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, reject) => { + startSaving(async () => { + 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, reject) => { + startSaving(async () => { + 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))); + } + }); + }); + }; + + return ( +
+
+

+ Rabattcodes +

+

+ Erstelle Rabattcodes für deine Kurse — prozentual oder als Fixbetrag, mit optionalem + Nutzungslimit und Gültigkeitszeitraum. +

+
+ + {businesses.length === 0 ? ( +
+

Du hast noch keine Businesses angelegt.

+
+ ) : ( +
+ {businesses.length > 1 && ( + + )} + + {loadError ? ( +
+ +

{loadError} Bitte lade die Seite neu, um die Rabattcodes zu bearbeiten.

+
+ ) : ( +
+ {coursesError && ( +
+ +

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

+
+ )} + +
+ )} +
+ )} +
+ ); }; export default SettingsDiscountsPageView;