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"
+ />
+
+ {isCheckingDiscount ? "Prüfe…" : "Anwenden"}
+
+
+ )}
-
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)}
+ />
+
+
setStatusFilter(v as StatusFilter)}>
+
+
+
+
+ Alle Status
+ Aktiv
+ Geplant
+ Ausgeschöpft
+ Abgelaufen
+ Deaktiviert
+
+
+
+ )}
+ {!isCreating && (
+
setIsCreating(true)} className="sm:ml-auto shrink-0">
+
+ Rabattcode erstellen
+
+ )}
+
+
+ {isCreating && (
+
+
+ Code
+ setForm((f) => ({ ...f, code: e.target.value.toUpperCase() }))}
+ placeholder="z.B. SOMMER25"
+ />
+
+
+
+
+ Typ
+ setForm((f) => ({ ...f, type: v as "percent" | "fixed" }))}
+ >
+
+
+
+
+ Prozent
+ Fixbetrag
+
+
+
+
+
+ {form.type === "percent" ? "Rabatt in %" : "Rabatt in €"}
+
+ {
+ 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 }));
+ }}
+ />
+
+
+
+
+ setForm((f) => ({ ...f, scope: "all_courses" }))}
+ >
+ Alle Kurse
+
+ setForm((f) => ({ ...f, scope: "specific_courses" }))}
+ >
+ Bestimmte Kurse
+
+
+
+ {form.scope === "specific_courses" && (
+
setForm((f) => ({ ...f, courseIds: values }))}
+ options={courseOptions}
+ />
+ )}
+
+
+
+ Maximale Einlösungen (leer = unbegrenzt)
+
+ setForm((f) => ({ ...f, maxRedemptions: e.target.value }))}
+ placeholder="z.B. 50"
+ />
+
+
+
+
+
+
+ {isSaving ? "Speichert…" : "Erstellen"}
+
+ {
+ setIsCreating(false);
+ setForm(emptyForm);
+ }}
+ >
+ Abbrechen
+
+
+
+ )}
+
+ {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 && (
+
{
+ // 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"
+ >
+
+
+ )}
+
+
+
+ {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 && (
+
+
+
+
+
+ {businesses.map((b) => (
+
+ {b.name}
+
+ ))}
+
+
+ )}
+
+ {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;