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..3967f3d
--- /dev/null
+++ b/prisma/migrations/20260913155733_add_discount_codes/migration.sql
@@ -0,0 +1,70 @@
+-- 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
+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/schema.prisma b/prisma/schema.prisma
index 307c05e..59d8f05 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,49 @@ 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 wird ausschließlich im Stripe-Webhook per {increment: 1} erhöht,
+// niemals beim Checkout-Start (siehe DiscountRedemption).
+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])
+}
+
+// Ein Eintrag pro tatsächlich eingelöstem Rabattcode (nur bei bezahlter Buchung,
+// siehe Webhook handleCheckoutCompleted). Der @@unique-Constraint erzwingt
+// "1x pro User" race-condition-sicher auf DB-Ebene, unabhängig von Anwendungslogik.
+model DiscountRedemption {
+ id String @id @default(uuid())
+ discountCodeId String
+ userId String
+ bookingId String @unique
+ discountCode DiscountCode @relation(fields: [discountCodeId], references: [id], onDelete: Cascade)
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ booking CourseBooking @relation(fields: [bookingId], references: [id], onDelete: Cascade)
+ createdAt DateTime @default(now())
+
+ @@unique([discountCodeId, userId])
+ @@index([discountCodeId])
+ @@index([userId])
+}
diff --git a/src/app/(main)/settings/discounts/page.tsx b/src/app/(main)/settings/discounts/page.tsx
index f3eb6f0..0a6b662 100644
--- a/src/app/(main)/settings/discounts/page.tsx
+++ b/src/app/(main)/settings/discounts/page.tsx
@@ -1,13 +1,41 @@
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..97d746e 100644
--- a/src/app/api/stripe/checkout/course/route.ts
+++ b/src/app/api/stripe/checkout/course/route.ts
@@ -3,6 +3,10 @@ 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 {
+ validateDiscountCode,
+ 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 +65,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,8 +177,35 @@ 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);
+
+ // Rabattcode ist optional — ein ungültiger/abgelaufener Code lässt die
+ // Buchung bewusst NICHT scheitern, sondern wird einfach ignoriert (Preis
+ // bleibt regulär). Harte Fehler nur bei explizit falscher Eingabe wären
+ // hier unnötig reibungsvoll für den Kunden; die UI zeigt die Ablehnung
+ // bereits vorab über eine separate Preview-Action (siehe booking UI).
+ let priceCents = originalPriceCents;
+ let discountAmountCents = 0;
+ let appliedDiscountCodeId: string | null = null;
+ if (discountCode?.trim()) {
+ const validation = await validateDiscountCode(prisma, {
+ code: discountCode,
+ businessId: course.business.id,
+ courseId: course.id,
+ userId: isGuest ? null : userData!.id,
+ });
+ if (validation.valid) {
+ const result = calculateDiscountedPriceCents(originalPriceCents, validation.discountCode);
+ priceCents = result.discountedPriceCents;
+ discountAmountCents = result.discountAmountCents;
+ appliedDiscountCodeId = validation.discountCode.id;
+ }
+ }
+
+ // 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;
@@ -204,6 +236,8 @@ export async function POST(request: NextRequest) {
isGuest: isGuest ? "true" : "false",
guestEmail: isGuest ? customerEmail : "",
guestName: isGuest ? (guestName?.trim() ?? "") : "",
+ discountCodeId: appliedDiscountCodeId ?? "",
+ discountAmountCents: String(discountAmountCents),
},
},
metadata: {
@@ -213,13 +247,18 @@ export async function POST(request: NextRequest) {
isGuest: isGuest ? "true" : "false",
guestEmail: isGuest ? customerEmail : "",
guestName: isGuest ? (guestName?.trim() ?? "") : "",
+ discountCodeId: appliedDiscountCodeId ?? "",
+ discountAmountCents: String(discountAmountCents),
},
customer_email: customerEmail,
success_url: `${baseUrl}/courses/book/${course.id}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${baseUrl}/courses/book/${course.id}/cancel`,
});
- 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..8765412 100644
--- a/src/app/api/stripe/webhook/route.ts
+++ b/src/app/api/stripe/webhook/route.ts
@@ -13,6 +13,7 @@ import {
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
import { notifyBusinessManager } from "@/src/modules/notifications/lib/create-notification";
import { isManagerOrEmployeeEmail } from "@/src/lib/auth/account-role-by-email";
+import { redeemDiscountCode } from "@/src/modules/settings/lib/discount-validation";
/**
* Sucht einen registrierten User anhand der E-Mail, case-insensitive.
@@ -107,6 +108,13 @@ 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.
+ const businessId = session.metadata?.businessId;
+ const discountCodeId = session.metadata?.discountCodeId || null;
+ const discountAmountCents = Number(session.metadata?.discountAmountCents ?? "0") || 0;
// Guest-Flow und Auth-Flow teilen die Kapazitäts-Logik, aber nutzen
// unterschiedliche Identifikatoren und DB-Operationen.
@@ -164,6 +172,9 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
paymentIntentId,
amountPaidCents,
platformFeeCents,
+ businessId,
+ discountCodeId,
+ discountAmountCents,
treatDistinctSessionAsDuplicate: true,
})
: await runCapacityTransactionGuest({
@@ -214,6 +225,9 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
paymentIntentId,
amountPaidCents,
platformFeeCents,
+ businessId,
+ discountCodeId,
+ discountAmountCents,
treatDistinctSessionAsDuplicate: true,
})
: await runCapacityTransactionGuest({
@@ -256,6 +270,9 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
paymentIntentId,
amountPaidCents,
platformFeeCents,
+ businessId,
+ discountCodeId,
+ discountAmountCents,
});
}
break;
@@ -435,6 +452,9 @@ async function runCapacityTransactionUser(params: {
paymentIntentId: string | null;
amountPaidCents: number;
platformFeeCents: number;
+ businessId?: string;
+ discountCodeId?: string | null;
+ discountAmountCents?: number;
treatDistinctSessionAsDuplicate?: boolean;
}): Promise {
const {
@@ -444,6 +464,9 @@ async function runCapacityTransactionUser(params: {
paymentIntentId,
amountPaidCents,
platformFeeCents,
+ businessId,
+ discountCodeId = null,
+ discountAmountCents = 0,
treatDistinctSessionAsDuplicate = false,
} = params;
@@ -546,6 +569,8 @@ async function runCapacityTransactionUser(params: {
stripePaymentIntentId: paymentIntentId,
amountPaid: amountPaidCents,
platformFee: platformFeeCents,
+ discountCodeId,
+ discountAmount: discountCodeId ? discountAmountCents : null,
},
create: {
courseId,
@@ -555,8 +580,27 @@ async function runCapacityTransactionUser(params: {
stripePaymentIntentId: paymentIntentId,
amountPaid: amountPaidCents,
platformFee: platformFeeCents,
+ discountCodeId,
+ discountAmount: discountCodeId ? discountAmountCents : null,
},
});
+
+ // Re-Validierung + Redemption/Increment innerhalb DIESER Transaktion -
+ // ein zwischenzeitlich ausgeschöpfter/abgelaufener Code lässt die
+ // Buchung bewusst NICHT scheitern (Zahlung ist schon erfolgt), sondern
+ // wird hier still übersprungen: booking.discountCodeId/discountAmount
+ // bleiben zwar gesetzt (Snapshot für die Rechnung), aber ohne
+ // DiscountRedemption-Zeile zählt der Code-Verbrauch nicht doppelt.
+ if (discountCodeId && businessId) {
+ await redeemDiscountCode(tx, {
+ discountCodeId,
+ businessId,
+ courseId,
+ userId,
+ bookingId: booking.id,
+ });
+ }
+
return { result: "booked" as const, bookingId: booking.id };
},
{ isolationLevel: "Serializable" }
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..ef02731 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,25 @@ 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) {
+ const booking = await prisma.courseBooking.findUnique({
+ where: { stripeSessionId: sessionId },
+ 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 +430,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..e95e262
--- /dev/null
+++ b/src/modules/settings/actions/discount-actions.ts
@@ -0,0 +1,226 @@
+"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." };
+ }
+
+ 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" ? parsed.data.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..55c4ab9
--- /dev/null
+++ b/src/modules/settings/lib/discount-validation.ts
@@ -0,0 +1,154 @@
+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 };
+
+// Wird an ZWEI Stellen aufgerufen: (1) beim Checkout-Start für sofortiges
+// User-Feedback, (2) erneut INNERHALB der Serializable-Transaktion im
+// Stripe-Webhook als Source of Truth. Muss deshalb rein lesend und ohne
+// Seiteneffekte bleiben (keine Redemption/Increment hier) - das Verbuchen
+// passiert ausschließlich in redeemDiscountCode() nach erfolgreicher Zahlung.
+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 };
+}
+
+// Wird NUR innerhalb der Serializable-Transaktion im Webhook aufgerufen,
+// nachdem die Zahlung bereits bestätigt ist und die CourseBooking-Zeile
+// bereits existiert (bookingId erforderlich, siehe DiscountRedemption.bookingId
+// @unique). Validiert den Code ein zweites Mal (Kontingent/Zeitfenster/
+// 1x-pro-User können sich seit dem Checkout-Start geändert haben) und legt bei
+// Erfolg atomar Redemption + Counter-Increment an. Wird der Code zwischenzeitlich
+// ungültig, tut diese Funktion nichts (kein Fehler) - die Buchung wurde vom
+// Aufrufer bereits unabhängig davon zum (schon berechneten) rabattierten Preis
+// bestätigt, nur ohne verbuchte Redemption. Das ist bewusstes Verhalten.
+export async function redeemDiscountCode(
+ tx: PrismaOrTx,
+ params: {
+ discountCodeId: string;
+ businessId: string;
+ courseId: string;
+ userId: string | null;
+ bookingId: string;
+ }
+): Promise {
+ if (!params.userId) return false; // Guests haben keine User-Redemption-Zuordnung, siehe validateDiscountCode
+
+ const discountCode = await tx.discountCode.findUnique({
+ where: { id: params.discountCodeId },
+ });
+ if (!discountCode || discountCode.businessId !== params.businessId) return false;
+
+ const now = new Date();
+ const stillValid =
+ discountCode.isActive &&
+ (!discountCode.startsAt || now >= discountCode.startsAt) &&
+ (!discountCode.expiresAt || now <= discountCode.expiresAt) &&
+ (discountCode.scope !== "specific_courses" ||
+ discountCode.courseIds.includes(params.courseId)) &&
+ (discountCode.maxRedemptions === null ||
+ discountCode.redeemedCount < discountCode.maxRedemptions);
+ if (!stillValid) return false;
+
+ const existingRedemption = await tx.discountRedemption.findUnique({
+ where: {
+ discountCodeId_userId: { discountCodeId: discountCode.id, userId: params.userId },
+ },
+ });
+ if (existingRedemption) return false;
+
+ await tx.discountCode.update({
+ where: { id: discountCode.id },
+ data: { redeemedCount: { increment: 1 } },
+ });
+ await tx.discountRedemption.create({
+ data: {
+ discountCodeId: discountCode.id,
+ userId: params.userId,
+ bookingId: params.bookingId,
+ },
+ });
+
+ return true;
+}
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..bf3cf29
--- /dev/null
+++ b/src/modules/settings/ui/components/DiscountCodeList.tsx
@@ -0,0 +1,398 @@
+"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 StatusFilter = "all" | "active" | "inactive" | "expired";
+
+function formatDate(date: Date | null): string {
+ if (!date) return "—";
+ return new Intl.DateTimeFormat("de-DE", { dateStyle: "medium" }).format(date);
+}
+
+function isExpired(code: DiscountCodeDTO): boolean {
+ return !!code.expiresAt && new Date(code.expiresAt) < new Date();
+}
+
+// Rein visueller Ableitungs-Status fürs Filtern/Badges - ändert nichts an
+// DiscountCode.isActive selbst (das bleibt der manuelle An/Aus-Schalter,
+// siehe deactivateDiscountCode). "expired" hat Vorrang vor "active", weil ein
+// abgelaufener Code trotz isActive=true nicht mehr einlösbar ist.
+function getDisplayStatus(code: DiscountCodeDTO): "active" | "inactive" | "expired" {
+ if (!code.isActive) return "inactive";
+ if (isExpired(code)) return "expired";
+ return "active";
+}
+
+const STATUS_STYLES: Record<
+ "active" | "inactive" | "expired",
+ { label: string; bg: string; text: string }
+> = {
+ 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" },
+};
+
+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 () => {
+ const success = await onCreate({
+ code: form.code,
+ type: form.type,
+ value: form.value,
+ scope: form.scope,
+ courseIds: form.courseIds,
+ maxRedemptions: form.maxRedemptions.trim() ? Number(form.maxRedemptions) : null,
+ startsAt: form.startsAt ? new Date(form.startsAt) : null,
+ expiresAt: form.expiresAt ? new Date(form.expiresAt) : null,
+ isActive: true,
+ });
+ if (success) {
+ setForm(emptyForm);
+ setIsCreating(false);
+ }
+ };
+
+ return (
+
+
+ {codes.length > 0 && (
+
+
+
+ setSearch(e.target.value)}
+ />
+
+
setStatusFilter(v as StatusFilter)}>
+
+
+
+
+ Alle Status
+ Aktiv
+ Deaktiviert
+ Abgelaufen
+
+
+
+ )}
+ {!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(c.id)}
+ 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..b4cba7c 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,154 @@
-const SettingsDiscountsPageView = () => {
- return
SettingsDiscountsPageView
;
+"use client";
+
+import { 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 }[];
+}
+
+const SettingsDiscountsPageView = ({
+ businesses,
+ initialCodes,
+ initialCourses,
+}: SettingsDiscountsPageViewProps) => {
+ const [selectedBusinessId, setSelectedBusinessId] = useState(businesses[0]?.id ?? "");
+ const [codes, setCodes] = useState(initialCodes);
+ const [courses, setCourses] = useState(initialCourses);
+ // 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();
+
+ const handleBusinessChange = (businessId: string) => {
+ const previousBusinessId = selectedBusinessId;
+ setSelectedBusinessId(businessId);
+ setLoadError(null);
+ startLoading(async () => {
+ const [codesResult, coursesResult] = await Promise.all([
+ getDiscountCodes(businessId),
+ getBusinessCoursesForDiscountPicker(businessId),
+ ]);
+ if (!codesResult.success) {
+ setSelectedBusinessId(previousBusinessId);
+ setLoadError(codesResult.error);
+ toast.error(codesResult.error);
+ return;
+ }
+ setCodes(codesResult.codes);
+ setCourses(coursesResult.success ? coursesResult.courses : []);
+ });
+ };
+
+ const handleCreate = (input: DiscountCodeInput): Promise => {
+ return new Promise((resolve) => {
+ startSaving(async () => {
+ const result = await createDiscountCode(selectedBusinessId, input);
+ if (result.success) {
+ toast.success("Rabattcode erstellt.");
+ setCodes((prev) => [result.code, ...prev]);
+ resolve(true);
+ } else {
+ toast.error(result.error);
+ resolve(false);
+ }
+ });
+ });
+ };
+
+ const handleDeactivate = (discountCodeId: string): Promise => {
+ return new Promise((resolve) => {
+ startSaving(async () => {
+ const result = await deactivateDiscountCode(selectedBusinessId, discountCodeId);
+ if (result.success) {
+ toast.success("Rabattcode deaktiviert.");
+ setCodes((prev) =>
+ prev.map((c) => (c.id === discountCodeId ? { ...c, isActive: false } : c))
+ );
+ resolve(true);
+ } else {
+ toast.error(result.error);
+ resolve(false);
+ }
+ });
+ });
+ };
+
+ 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.
+
+ ) : (
+
+
+
+ )}
+
+ )}
+
+ );
};
export default SettingsDiscountsPageView;