From db514248a21c23bfbeb9dfb05e58fe36573e6db1 Mon Sep 17 00:00:00 2001 From: Rajpreet0 Date: Sun, 13 Sep 2026 18:45:46 +0200 Subject: [PATCH] added discount codes --- .../migration.sql | 70 +++ prisma/schema.prisma | 77 +++- src/app/(main)/settings/discounts/page.tsx | 32 +- src/app/api/stripe/checkout/course/route.ts | 47 ++- src/app/api/stripe/webhook/route.ts | 44 ++ .../courses/book/[courseId]/success/page.tsx | 51 ++- .../courses/actions/booking-actions.ts | 75 +++- .../ui/components/PublicCourseCard.tsx | 77 +++- .../settings/actions/discount-actions.ts | 226 ++++++++++ .../settings/lib/discount-validation.ts | 154 +++++++ .../settings/schemas/discount-code-schema.ts | 47 +++ .../ui/components/DiscountCodeList.tsx | 398 ++++++++++++++++++ .../ui/views/settings-discounts-page-view.tsx | 153 ++++++- 13 files changed, 1408 insertions(+), 43 deletions(-) create mode 100644 prisma/migrations/20260913155733_add_discount_codes/migration.sql create mode 100644 src/modules/settings/actions/discount-actions.ts create mode 100644 src/modules/settings/lib/discount-validation.ts create mode 100644 src/modules/settings/schemas/discount-code-schema.ts create mode 100644 src/modules/settings/ui/components/DiscountCodeList.tsx 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" + /> + +
+ )}
- 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)} + /> +
+ +
+ )} + {!isCreating && ( + + )} +
+ + {isCreating && ( +
+
+ + setForm((f) => ({ ...f, code: e.target.value.toUpperCase() }))} + placeholder="z.B. SOMMER25" + /> +
+ +
+
+ + +
+
+ + { + const raw = Number(e.target.value); + const cents = form.type === "percent" ? raw : Math.round(raw * 100); + setForm((f) => ({ ...f, value: Number.isFinite(cents) ? cents : 0 })); + }} + /> +
+
+ +
+ + +
+ + {form.scope === "specific_courses" && ( + setForm((f) => ({ ...f, courseIds: values }))} + options={courseOptions} + /> + )} + +
+ + setForm((f) => ({ ...f, maxRedemptions: e.target.value }))} + placeholder="z.B. 50" + /> +
+ +
+
+ + setForm((f) => ({ ...f, startsAt: e.target.value }))} + /> +
+
+ + setForm((f) => ({ ...f, expiresAt: e.target.value }))} + /> +
+
+ +
+ + +
+
+ )} + + {codes.length === 0 ? ( +

+ Noch keine Rabattcodes angelegt. +

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

+ Keine Rabattcodes gefunden. +

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

+ {c.code} +

+ + {statusStyle.label} + +
+
+ {c.isActive && ( + + )} +
+ +
+ {c.type === "percent" ? ( + + ) : ( + + )} + {c.type === "percent" ? `${c.value}%` : `${(c.value / 100).toFixed(2)} €`} + Rabatt +
+ +
+ + {c.scope === "all_courses" ? ( + Gilt für alle Kurse + ) : ( + + Gilt für:{" "} + {c.courseIds.map((id) => courseNameById.get(id) ?? "Unbekannt").join(", ")} + + )} +
+ + {(c.startsAt || c.expiresAt) && ( +
+ + + {formatDate(c.startsAt)} – {formatDate(c.expiresAt)} + +
+ )} + +
+
+ Eingelöst + + {c.redeemedCount} + {c.maxRedemptions !== null ? ` / ${c.maxRedemptions}` : " (unbegrenzt)"} + +
+ {redemptionPercent !== null && ( +
+
= 100 ? "bg-orange-400" : "bg-blue-500"}`} + style={{ width: `${redemptionPercent}%` }} + /> +
+ )} +
+
+ ); + })} +
+ )} +
+ ); +}; + +export default DiscountCodeList; diff --git a/src/modules/settings/ui/views/settings-discounts-page-view.tsx b/src/modules/settings/ui/views/settings-discounts-page-view.tsx index b3b1736..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 && ( + + )} + + {loadError ? ( +
+ +

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

+
+ ) : ( +
+ +
+ )} +
+ )} +
+ ); }; export default SettingsDiscountsPageView;