mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 20:51:18 +00:00
Merge branch 'dev' of https://github.com/Since-3/Kuma into dev
This commit is contained in:
commit
b204dbd672
14 changed files with 1766 additions and 73 deletions
|
|
@ -0,0 +1,80 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "CourseBooking" ADD COLUMN "discountAmount" INTEGER,
|
||||||
|
ADD COLUMN "discountCodeId" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "DiscountCode" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"businessId" TEXT NOT NULL,
|
||||||
|
"code" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"value" INTEGER NOT NULL,
|
||||||
|
"scope" TEXT NOT NULL,
|
||||||
|
"courseIds" TEXT[],
|
||||||
|
"maxRedemptions" INTEGER,
|
||||||
|
"redeemedCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"startsAt" TIMESTAMP(3),
|
||||||
|
"expiresAt" TIMESTAMP(3),
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "DiscountCode_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "DiscountRedemption" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"discountCodeId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"bookingId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "DiscountRedemption_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "DiscountCode_businessId_idx" ON "DiscountCode"("businessId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "DiscountCode_businessId_code_key" ON "DiscountCode"("businessId", "code");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "DiscountRedemption_bookingId_key" ON "DiscountRedemption"("bookingId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "DiscountRedemption_discountCodeId_idx" ON "DiscountRedemption"("discountCodeId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "DiscountRedemption_userId_idx" ON "DiscountRedemption"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "DiscountRedemption_discountCodeId_userId_key" ON "DiscountRedemption"("discountCodeId", "userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "CourseBooking_discountCodeId_idx" ON "CourseBooking"("discountCodeId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
-- Bewusst OHNE CONCURRENTLY/NOT VALID: Postgres erlaubt CREATE INDEX
|
||||||
|
-- CONCURRENTLY nie innerhalb einer Transaktion, `prisma migrate dev` führt
|
||||||
|
-- aber jede Migration transaktional gegen eine Shadow-Database aus - dort
|
||||||
|
-- würde CONCURRENTLY hart fehlschlagen und dauerhaft JEDE künftige
|
||||||
|
-- `migrate dev`-Ausführung blockieren (nicht nur diese eine Migration), weil
|
||||||
|
-- die Shadow-DB alle Migrationen der Reihe nach durchspielt. Der Trade-off
|
||||||
|
-- ist hier vertretbar: CourseBooking existiert zu diesem Zeitpunkt zwar
|
||||||
|
-- schon, aber discountCodeId ist eine brandneue Spalte ohne bestehende
|
||||||
|
-- Nicht-NULL-Werte - Index-Build und FK-Validierung laufen daher in
|
||||||
|
-- Sekundenbruchteilen, unabhängig von der Zeilenzahl der Tabelle.
|
||||||
|
ALTER TABLE "CourseBooking" ADD CONSTRAINT "CourseBooking_discountCodeId_fkey" FOREIGN KEY ("discountCodeId") REFERENCES "DiscountCode"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "DiscountCode" ADD CONSTRAINT "DiscountCode_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "DiscountRedemption" ADD CONSTRAINT "DiscountRedemption_discountCodeId_fkey" FOREIGN KEY ("discountCodeId") REFERENCES "DiscountCode"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "DiscountRedemption" ADD CONSTRAINT "DiscountRedemption_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "DiscountRedemption" ADD CONSTRAINT "DiscountRedemption_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "CourseBooking"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "DiscountRedemption" DROP CONSTRAINT "DiscountRedemption_bookingId_fkey";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "DiscountRedemption_bookingId_key";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "DiscountRedemption"
|
||||||
|
ADD COLUMN "stripeSessionId" TEXT,
|
||||||
|
ADD COLUMN "status" TEXT NOT NULL DEFAULT 'reserved',
|
||||||
|
ALTER COLUMN "bookingId" DROP NOT NULL;
|
||||||
|
|
||||||
|
-- Tabelle ist zu diesem Zeitpunkt leer (siehe vorherige Migration), daher
|
||||||
|
-- ist ein Backfill für die neue NOT NULL-Spalte stripeSessionId nicht nötig -
|
||||||
|
-- UPDATE würde ohnehin keine Zeile treffen. Erst danach kann die Spalte auf
|
||||||
|
-- NOT NULL gesetzt werden, weil ALTER COLUMN ... SET NOT NULL sonst bereits
|
||||||
|
-- vorhandene NULL-Werte prüfen würde.
|
||||||
|
ALTER TABLE "DiscountRedemption" ALTER COLUMN "stripeSessionId" SET NOT NULL;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "DiscountRedemption_stripeSessionId_key" ON "DiscountRedemption"("stripeSessionId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "DiscountRedemption_bookingId_key" ON "DiscountRedemption"("bookingId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "DiscountRedemption_status_idx" ON "DiscountRedemption"("status");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "DiscountRedemption" ADD CONSTRAINT "DiscountRedemption_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "CourseBooking"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
@ -42,6 +42,7 @@ model User {
|
||||||
trainings UserTraining[]
|
trainings UserTraining[]
|
||||||
courseBookings CourseBooking[]
|
courseBookings CourseBooking[]
|
||||||
courseWaitlistEntries CourseWaitlistEntry[]
|
courseWaitlistEntries CourseWaitlistEntry[]
|
||||||
|
discountRedemptions DiscountRedemption[]
|
||||||
createdAt DateTime? @default(now())
|
createdAt DateTime? @default(now())
|
||||||
updatedAt DateTime? @updatedAt
|
updatedAt DateTime? @updatedAt
|
||||||
}
|
}
|
||||||
|
|
@ -128,10 +129,11 @@ model Business {
|
||||||
onboardingStartedAt DateTime? // Wizard erstmals geoeffnet, ab dann kein Redirect mehr
|
onboardingStartedAt DateTime? // Wizard erstmals geoeffnet, ab dann kein Redirect mehr
|
||||||
onboardingDismissedAt DateTime? // Checkliste im Dashboard ausgeblendet
|
onboardingDismissedAt DateTime? // Checkliste im Dashboard ausgeblendet
|
||||||
onboardingCompletedAt DateTime? // Zeitpunkt, an dem erstmals alle Pflichtschritte erledigt waren
|
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[]
|
courses Course[]
|
||||||
createdAt DateTime @default(now())
|
discountCodes DiscountCode[]
|
||||||
updatedAt DateTime @updatedAt
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([managerId])
|
@@index([managerId])
|
||||||
}
|
}
|
||||||
|
|
@ -195,30 +197,35 @@ model GuestLead {
|
||||||
}
|
}
|
||||||
|
|
||||||
model CourseBooking {
|
model CourseBooking {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
courseId String
|
courseId String
|
||||||
userId String?
|
userId String?
|
||||||
guestLeadId String?
|
guestLeadId String?
|
||||||
paymentStatus String @default("pending") // "pending" | "paid" | "failed" | "refunded" | "partially_refunded" | "refunding" | "cancelled" (cancelled = storniert ohne Geldbewegung, 0% Erstattung)
|
paymentStatus String @default("pending") // "pending" | "paid" | "failed" | "refunded" | "partially_refunded" | "refunding" | "cancelled" (cancelled = storniert ohne Geldbewegung, 0% Erstattung)
|
||||||
stripeSessionId String? @unique
|
stripeSessionId String? @unique
|
||||||
stripePaymentIntentId String? @unique
|
stripePaymentIntentId String? @unique
|
||||||
amountPaid Int? // tatsächlich gezahlter Betrag in Cents
|
amountPaid Int? // tatsächlich gezahlter Betrag in Cents
|
||||||
platformFee Int? // unsere Service-Fee 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
|
invoiceIssuedAt DateTime? // Zeitpunkt der Rechnungsnummer-Vergabe, damit das Rechnungsdatum bei jedem Download gleich bleibt
|
||||||
refundedAmount Int? // tatsächlich erstatteter Betrag in Cents
|
refundedAmount Int? // tatsächlich erstatteter Betrag in Cents
|
||||||
refundedAt DateTime? // Zeitpunkt der erfolgreichen Stripe-Rückerstattung
|
refundedAt DateTime? // Zeitpunkt der erfolgreichen Stripe-Rückerstattung
|
||||||
cancelledBy String? // "user" | "manager" | "system" (system = automatischer Überbuchungs-Refund)
|
cancelledBy String? // "user" | "manager" | "system" (system = automatischer Überbuchungs-Refund)
|
||||||
cancellationReason String?
|
cancellationReason String?
|
||||||
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
discountCodeId String? // Gesetzt, sobald bei der Buchung ein gültiger Rabattcode eingelöst wurde
|
||||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
discountAmount Int? // Tatsächlich gewährter Rabatt in Cents (Snapshot, unabhängig von späteren Code-Änderungen)
|
||||||
guestLead GuestLead? @relation(fields: [guestLeadId], references: [id], onDelete: SetNull)
|
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||||
createdAt DateTime @default(now())
|
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
|
@@unique([courseId, userId]) // Ein User kann sich nur einmal pro Kurs anmelden
|
||||||
@@index([courseId])
|
@@index([courseId])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([guestLeadId])
|
@@index([guestLeadId])
|
||||||
|
@@index([discountCodeId])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bewusst nur für registrierte User (kein guestEmail-Feld): ein
|
// Bewusst nur für registrierte User (kein guestEmail-Feld): ein
|
||||||
|
|
@ -258,3 +265,65 @@ model Notification {
|
||||||
@@index([recipientId, readAt])
|
@@index([recipientId, readAt])
|
||||||
@@index([recipientId, createdAt])
|
@@index([recipientId, createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rabattcode eines Business. scope = "specific_courses" schränkt die Gültigkeit
|
||||||
|
// über courseIds ein (Array statt Join-Tabelle, analog Course.trainers/sport —
|
||||||
|
// hier reicht eine Handvoll IDs pro Code, kein eigenes Modell nötig).
|
||||||
|
// redeemedCount zählt RESERVIERTE Slots (status "reserved" ODER "confirmed"),
|
||||||
|
// nicht nur bestätigte Zahlungen - die Reservierung passiert bereits beim
|
||||||
|
// Checkout-Start, um Race Conditions zwischen mehreren gleichzeitigen
|
||||||
|
// Checkouts zu verhindern (siehe DiscountRedemption). Der Zähler wird wieder
|
||||||
|
// dekrementiert, wenn eine Reservierung storniert/abgelaufen wird.
|
||||||
|
model DiscountCode {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
businessId String
|
||||||
|
code String // Anzeigeform, z.B. "SOMMER25" — Eindeutigkeit nur pro Business (siehe @@unique)
|
||||||
|
type String // "percent" | "fixed"
|
||||||
|
value Int // percent: 1-100, fixed: Cents
|
||||||
|
scope String // "all_courses" | "specific_courses"
|
||||||
|
courseIds String[] // Nur relevant wenn scope = "specific_courses"
|
||||||
|
maxRedemptions Int? // null = unbegrenzt
|
||||||
|
redeemedCount Int @default(0)
|
||||||
|
startsAt DateTime? // null = sofort gültig
|
||||||
|
expiresAt DateTime? // null = kein Ablauf
|
||||||
|
isActive Boolean @default(true) // Manuelles An/Aus, unabhängig vom Zeitfenster
|
||||||
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
||||||
|
bookings CourseBooking[]
|
||||||
|
redemptions DiscountRedemption[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([businessId, code])
|
||||||
|
@@index([businessId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reserviert einen Rabattcode-Slot BEIM CHECKOUT-START, nicht erst nach
|
||||||
|
// Zahlungsbestätigung - sonst könnten mehrere gleichzeitige Checkouts (versch.
|
||||||
|
// User bei knappem Kontingent, oder ein User mit mehreren offenen Sessions)
|
||||||
|
// alle den rabattierten Preis bekommen, obwohl nur ein Slot frei ist. status:
|
||||||
|
// "reserved" (Checkout-Session erstellt, Zahlung ausstehend) → "confirmed"
|
||||||
|
// (Webhook hat die Zahlung bestätigt, bookingId gesetzt) oder gelöscht (Session
|
||||||
|
// abgelaufen/storniert, siehe handleCheckoutExpired - gibt den Slot wieder frei).
|
||||||
|
// bookingId ist erst ab "confirmed" gesetzt, da beim Reservieren noch keine
|
||||||
|
// CourseBooking-Zeile existiert. Der @@unique([discountCodeId, userId])
|
||||||
|
// Constraint erzwingt "1x pro User" bereits ab der Reservierung, nicht erst
|
||||||
|
// ab Zahlungsbestätigung - das schließt die zweite Race Condition
|
||||||
|
// (ein User bucht mehrere Kurse mit demselben Code, bevor eine Zahlung
|
||||||
|
// durchläuft) auf DB-Ebene.
|
||||||
|
model DiscountRedemption {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
discountCodeId String
|
||||||
|
userId String
|
||||||
|
stripeSessionId String @unique
|
||||||
|
bookingId String? @unique
|
||||||
|
status String @default("reserved") // "reserved" | "confirmed"
|
||||||
|
discountCode DiscountCode @relation(fields: [discountCodeId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
booking CourseBooking? @relation(fields: [bookingId], references: [id], onDelete: SetNull)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([discountCodeId, userId])
|
||||||
|
@@index([discountCodeId])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([status])
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,42 @@
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
import { requireManager } from "@/src/lib/auth/getUser";
|
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 SettingsDiscountsPageView from "@/src/modules/settings/ui/views/settings-discounts-page-view";
|
||||||
import SettingsLoading from "../loading";
|
import SettingsLoading from "../loading";
|
||||||
|
|
||||||
export default async function SettingsDiscountPage() {
|
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 (
|
||||||
|
<div className="flex gap-2 text-sm bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
|
||||||
|
<AlertTriangle size={16} className="shrink-0 mt-0.5" />
|
||||||
|
<p>{codesResult.error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<SettingsLoading />}>
|
<Suspense fallback={<SettingsLoading />}>
|
||||||
<SettingsDiscountsPageView />
|
<SettingsDiscountsPageView
|
||||||
|
businesses={businesses}
|
||||||
|
initialCodes={codesResult?.success ? codesResult.codes : []}
|
||||||
|
initialCourses={coursesResult?.success ? coursesResult.courses : []}
|
||||||
|
initialCoursesError={coursesResult && !coursesResult.success ? coursesResult.error : null}
|
||||||
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,11 @@ import { prisma } from "@/src/lib/prisma";
|
||||||
import { stripe, calculatePlatformFeeCents, eurosToCents } from "@/src/lib/stripe";
|
import { stripe, calculatePlatformFeeCents, eurosToCents } from "@/src/lib/stripe";
|
||||||
import { getUserData, isUser } from "@/src/lib/auth/getUser";
|
import { getUserData, isUser } from "@/src/lib/auth/getUser";
|
||||||
import { isManagerOrEmployeeEmail } from "@/src/lib/auth/account-role-by-email";
|
import { isManagerOrEmployeeEmail } from "@/src/lib/auth/account-role-by-email";
|
||||||
|
import {
|
||||||
|
reserveDiscountCode,
|
||||||
|
releaseDiscountReservation,
|
||||||
|
calculateDiscountedPriceCents,
|
||||||
|
} from "@/src/modules/settings/lib/discount-validation";
|
||||||
|
|
||||||
// Simple in-memory rate limiter: max 10 checkout attempts per IP per minute.
|
// Simple in-memory rate limiter: max 10 checkout attempts per IP per minute.
|
||||||
// Best-effort only — in multi-instance/serverless deployments each instance has
|
// Best-effort only — in multi-instance/serverless deployments each instance has
|
||||||
|
|
@ -61,8 +66,9 @@ export async function POST(request: NextRequest) {
|
||||||
courseId?: string;
|
courseId?: string;
|
||||||
guestEmail?: string;
|
guestEmail?: string;
|
||||||
guestName?: string;
|
guestName?: string;
|
||||||
|
discountCode?: string;
|
||||||
};
|
};
|
||||||
const { courseId, guestEmail, guestName } = body;
|
const { courseId, guestEmail, guestName, discountCode } = body;
|
||||||
|
|
||||||
if (!courseId) {
|
if (!courseId) {
|
||||||
return NextResponse.json({ error: "courseId fehlt" }, { status: 400 });
|
return NextResponse.json({ error: "courseId fehlt" }, { status: 400 });
|
||||||
|
|
@ -172,30 +178,112 @@ export async function POST(request: NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
|
const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
|
||||||
const priceCents = eurosToCents(course.price);
|
const originalPriceCents = eurosToCents(course.price);
|
||||||
const platformFeeCents = calculatePlatformFeeCents(course.price);
|
|
||||||
|
// Ein hier eingegebener Code wurde clientseitig bereits über die
|
||||||
|
// Preview-Action als gültig angezeigt - wird er bei dieser erneuten,
|
||||||
|
// server-seitigen Prüfung ungültig (Kontingent/Zeitfenster liefen
|
||||||
|
// zwischenzeitlich ab), MUSS die Anfrage abgelehnt werden. Ihn still zu
|
||||||
|
// ignorieren würde eine Session zum vollen Preis erzeugen, während die UI
|
||||||
|
// dem Kunden weiterhin den (nicht mehr zutreffenden) rabattierten Preis
|
||||||
|
// anzeigt - der Kunde könnte dann zu einem höheren Preis bezahlen, als
|
||||||
|
// ihm angezeigt wurde.
|
||||||
|
//
|
||||||
|
// reserveDiscountCode() (statt einer reinen Validierung) belegt das
|
||||||
|
// Kontingent SOFORT, bevor die Stripe-Session erstellt wird - das
|
||||||
|
// schließt die Race Condition, bei der mehrere gleichzeitige Checkouts
|
||||||
|
// (verschiedene User bei knappem Kontingent, oder ein User mit mehreren
|
||||||
|
// offenen Sessions) alle denselben letzten Slot rabattiert bekommen
|
||||||
|
// hätten, weil die Verbuchung vorher erst nach der Zahlung passierte.
|
||||||
|
let priceCents = originalPriceCents;
|
||||||
|
let discountAmountCents = 0;
|
||||||
|
let appliedDiscountCodeId: string | null = null;
|
||||||
|
let reservationToken: string | null = null;
|
||||||
|
if (discountCode?.trim()) {
|
||||||
|
// Rabattcodes sind nur für eingeloggte User verfügbar (siehe
|
||||||
|
// validateDiscountCode) - reserveDiscountCode() erwartet daher eine
|
||||||
|
// nicht-optionale userId und wird für Gäste gar nicht erst aufgerufen.
|
||||||
|
if (isGuest) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Rabattcodes sind nur für angemeldete Kunden verfügbar." },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reservation = await reserveDiscountCode(prisma, {
|
||||||
|
code: discountCode,
|
||||||
|
businessId: course.business.id,
|
||||||
|
courseId: course.id,
|
||||||
|
userId: userData!.id,
|
||||||
|
});
|
||||||
|
if (!reservation.reserved) {
|
||||||
|
return NextResponse.json({ error: reservation.reason }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = calculateDiscountedPriceCents(originalPriceCents, reservation.discountCode);
|
||||||
|
// Ein auf 0 reduzierter Preis würde eine Stripe Checkout Session ohne
|
||||||
|
// PaymentIntent erzeugen (kostenlose Bestellung). Der Webhook lehnt
|
||||||
|
// aktuell jede Session ab, deren payment_status nicht "paid" ist - eine
|
||||||
|
// 0€-Session hat aber gar keinen PaymentIntent, dessen Status geprüft
|
||||||
|
// werden könnte, sodass der Kunde den Checkout abschließen würde, ohne
|
||||||
|
// je eine Buchung zu bekommen. Bis kostenlose Buchungen als eigener Fall
|
||||||
|
// unterstützt werden, wird ein solcher Code hier abgelehnt statt eine
|
||||||
|
// funktionsunfähige Session zu erzeugen.
|
||||||
|
if (result.discountedPriceCents <= 0) {
|
||||||
|
await releaseDiscountReservation(prisma, reservation.reservationToken);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: "Dieser Rabattcode reduziert den Preis auf 0 € und kann nicht verwendet werden.",
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
priceCents = result.discountedPriceCents;
|
||||||
|
discountAmountCents = result.discountAmountCents;
|
||||||
|
appliedDiscountCodeId = reservation.discountCode.id;
|
||||||
|
reservationToken = reservation.reservationToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Platform-Fee auf den tatsächlich zu zahlenden (rabattierten) Betrag,
|
||||||
|
// nicht den Originalpreis — das entspricht dem, was via
|
||||||
|
// application_fee_amount wirklich von diesem Payment einbehalten wird.
|
||||||
|
const platformFeeCents = calculatePlatformFeeCents(priceCents / 100);
|
||||||
|
|
||||||
const customerEmail = isGuest ? guestEmail!.trim().toLowerCase() : userData!.email;
|
const customerEmail = isGuest ? guestEmail!.trim().toLowerCase() : userData!.email;
|
||||||
|
|
||||||
const session = await stripe.checkout.sessions.create({
|
let session;
|
||||||
mode: "payment",
|
try {
|
||||||
line_items: [
|
session = await stripe.checkout.sessions.create({
|
||||||
{
|
mode: "payment",
|
||||||
price_data: {
|
line_items: [
|
||||||
currency: "eur",
|
{
|
||||||
product_data: {
|
price_data: {
|
||||||
name: course.name,
|
currency: "eur",
|
||||||
description: `${course.business.name} - ${new Date(course.date).toLocaleDateString("de-DE")} ${course.timeFrom}`,
|
product_data: {
|
||||||
|
name: course.name,
|
||||||
|
description: `${course.business.name} - ${new Date(course.date).toLocaleDateString("de-DE")} ${course.timeFrom}`,
|
||||||
|
},
|
||||||
|
unit_amount: priceCents,
|
||||||
},
|
},
|
||||||
unit_amount: priceCents,
|
quantity: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
payment_intent_data: {
|
||||||
|
application_fee_amount: platformFeeCents,
|
||||||
|
transfer_data: {
|
||||||
|
destination: course.business.stripeAccountId,
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
courseId: course.id,
|
||||||
|
userId: isGuest ? "" : userData!.id,
|
||||||
|
businessId: course.business.id,
|
||||||
|
isGuest: isGuest ? "true" : "false",
|
||||||
|
guestEmail: isGuest ? customerEmail : "",
|
||||||
|
guestName: isGuest ? (guestName?.trim() ?? "") : "",
|
||||||
|
discountCodeId: appliedDiscountCodeId ?? "",
|
||||||
|
discountAmountCents: String(discountAmountCents),
|
||||||
|
discountReservationToken: reservationToken ?? "",
|
||||||
},
|
},
|
||||||
quantity: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
payment_intent_data: {
|
|
||||||
application_fee_amount: platformFeeCents,
|
|
||||||
transfer_data: {
|
|
||||||
destination: course.business.stripeAccountId,
|
|
||||||
},
|
},
|
||||||
metadata: {
|
metadata: {
|
||||||
courseId: course.id,
|
courseId: course.id,
|
||||||
|
|
@ -204,22 +292,35 @@ export async function POST(request: NextRequest) {
|
||||||
isGuest: isGuest ? "true" : "false",
|
isGuest: isGuest ? "true" : "false",
|
||||||
guestEmail: isGuest ? customerEmail : "",
|
guestEmail: isGuest ? customerEmail : "",
|
||||||
guestName: isGuest ? (guestName?.trim() ?? "") : "",
|
guestName: isGuest ? (guestName?.trim() ?? "") : "",
|
||||||
|
discountCodeId: appliedDiscountCodeId ?? "",
|
||||||
|
discountAmountCents: String(discountAmountCents),
|
||||||
|
discountReservationToken: reservationToken ?? "",
|
||||||
},
|
},
|
||||||
},
|
customer_email: customerEmail,
|
||||||
metadata: {
|
success_url: `${baseUrl}/courses/book/${course.id}/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||||
courseId: course.id,
|
cancel_url: `${baseUrl}/courses/book/${course.id}/cancel`,
|
||||||
userId: isGuest ? "" : userData!.id,
|
});
|
||||||
businessId: course.business.id,
|
} catch (stripeError) {
|
||||||
isGuest: isGuest ? "true" : "false",
|
// Reservierung wurde bereits vor der Session-Erstellung angelegt (siehe
|
||||||
guestEmail: isGuest ? customerEmail : "",
|
// oben) - schlägt die Session-Erstellung selbst fehl, muss der belegte
|
||||||
guestName: isGuest ? (guestName?.trim() ?? "") : "",
|
// Slot wieder freigegeben werden, sonst bliebe er dauerhaft blockiert,
|
||||||
},
|
// ohne dass je eine Buchung zustande kommt.
|
||||||
customer_email: customerEmail,
|
if (reservationToken) {
|
||||||
success_url: `${baseUrl}/courses/book/${course.id}/success?session_id={CHECKOUT_SESSION_ID}`,
|
await releaseDiscountReservation(prisma, reservationToken).catch((releaseError) => {
|
||||||
cancel_url: `${baseUrl}/courses/book/${course.id}/cancel`,
|
console.error(
|
||||||
});
|
`Rabattcode-Reservierung ${reservationToken} konnte nach fehlgeschlagener ` +
|
||||||
|
`Stripe-Session nicht freigegeben werden:`,
|
||||||
|
releaseError
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw stripeError;
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ url: session.url });
|
return NextResponse.json({
|
||||||
|
url: session.url,
|
||||||
|
discountApplied: appliedDiscountCodeId !== null,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error creating checkout session:", error);
|
console.error("Error creating checkout session:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,10 @@ import {
|
||||||
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
|
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
|
||||||
import { notifyBusinessManager } from "@/src/modules/notifications/lib/create-notification";
|
import { notifyBusinessManager } from "@/src/modules/notifications/lib/create-notification";
|
||||||
import { isManagerOrEmployeeEmail } from "@/src/lib/auth/account-role-by-email";
|
import { isManagerOrEmployeeEmail } from "@/src/lib/auth/account-role-by-email";
|
||||||
|
import {
|
||||||
|
confirmDiscountReservation,
|
||||||
|
releaseDiscountReservation,
|
||||||
|
} from "@/src/modules/settings/lib/discount-validation";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sucht einen registrierten User anhand der E-Mail, case-insensitive.
|
* Sucht einen registrierten User anhand der E-Mail, case-insensitive.
|
||||||
|
|
@ -107,6 +111,17 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
||||||
const platformFeeCents = paymentIntentId
|
const platformFeeCents = paymentIntentId
|
||||||
? await getApplicationFeeFromPaymentIntent(paymentIntentId)
|
? await getApplicationFeeFromPaymentIntent(paymentIntentId)
|
||||||
: 0;
|
: 0;
|
||||||
|
// discountAmountCents ist ein Snapshot vom Checkout-Start (in der Session-
|
||||||
|
// Metadata eingefroren) — bewusst NICHT aus dem aktuellen DiscountCode neu
|
||||||
|
// berechnet, damit eine spätere Code-Änderung die Rechnung dieser Buchung
|
||||||
|
// nicht rückwirkend verändert. discountReservationToken verknüpft diese
|
||||||
|
// Zahlung mit der beim Checkout-Start bereits angelegten Reservierung
|
||||||
|
// (siehe reserveDiscountCode) - das Kontingent wurde dort schon verbraucht,
|
||||||
|
// hier wird die Reservierung nur noch auf "confirmed" gesetzt und mit der
|
||||||
|
// gleich entstehenden CourseBooking-Zeile verknüpft.
|
||||||
|
const discountCodeId = session.metadata?.discountCodeId || null;
|
||||||
|
const discountAmountCents = Number(session.metadata?.discountAmountCents ?? "0") || 0;
|
||||||
|
const discountReservationToken = session.metadata?.discountReservationToken || null;
|
||||||
|
|
||||||
// Guest-Flow und Auth-Flow teilen die Kapazitäts-Logik, aber nutzen
|
// Guest-Flow und Auth-Flow teilen die Kapazitäts-Logik, aber nutzen
|
||||||
// unterschiedliche Identifikatoren und DB-Operationen.
|
// unterschiedliche Identifikatoren und DB-Operationen.
|
||||||
|
|
@ -164,6 +179,9 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
||||||
paymentIntentId,
|
paymentIntentId,
|
||||||
amountPaidCents,
|
amountPaidCents,
|
||||||
platformFeeCents,
|
platformFeeCents,
|
||||||
|
discountCodeId,
|
||||||
|
discountAmountCents,
|
||||||
|
discountReservationToken,
|
||||||
treatDistinctSessionAsDuplicate: true,
|
treatDistinctSessionAsDuplicate: true,
|
||||||
})
|
})
|
||||||
: await runCapacityTransactionGuest({
|
: await runCapacityTransactionGuest({
|
||||||
|
|
@ -214,6 +232,9 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
||||||
paymentIntentId,
|
paymentIntentId,
|
||||||
amountPaidCents,
|
amountPaidCents,
|
||||||
platformFeeCents,
|
platformFeeCents,
|
||||||
|
discountCodeId,
|
||||||
|
discountAmountCents,
|
||||||
|
discountReservationToken,
|
||||||
treatDistinctSessionAsDuplicate: true,
|
treatDistinctSessionAsDuplicate: true,
|
||||||
})
|
})
|
||||||
: await runCapacityTransactionGuest({
|
: await runCapacityTransactionGuest({
|
||||||
|
|
@ -256,6 +277,9 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
||||||
paymentIntentId,
|
paymentIntentId,
|
||||||
amountPaidCents,
|
amountPaidCents,
|
||||||
platformFeeCents,
|
platformFeeCents,
|
||||||
|
discountCodeId,
|
||||||
|
discountAmountCents,
|
||||||
|
discountReservationToken,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
@ -435,6 +459,9 @@ async function runCapacityTransactionUser(params: {
|
||||||
paymentIntentId: string | null;
|
paymentIntentId: string | null;
|
||||||
amountPaidCents: number;
|
amountPaidCents: number;
|
||||||
platformFeeCents: number;
|
platformFeeCents: number;
|
||||||
|
discountCodeId?: string | null;
|
||||||
|
discountAmountCents?: number;
|
||||||
|
discountReservationToken?: string | null;
|
||||||
treatDistinctSessionAsDuplicate?: boolean;
|
treatDistinctSessionAsDuplicate?: boolean;
|
||||||
}): Promise<CapacityOutcome> {
|
}): Promise<CapacityOutcome> {
|
||||||
const {
|
const {
|
||||||
|
|
@ -444,6 +471,9 @@ async function runCapacityTransactionUser(params: {
|
||||||
paymentIntentId,
|
paymentIntentId,
|
||||||
amountPaidCents,
|
amountPaidCents,
|
||||||
platformFeeCents,
|
platformFeeCents,
|
||||||
|
discountCodeId = null,
|
||||||
|
discountAmountCents = 0,
|
||||||
|
discountReservationToken = null,
|
||||||
treatDistinctSessionAsDuplicate = false,
|
treatDistinctSessionAsDuplicate = false,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
|
|
@ -546,6 +576,8 @@ async function runCapacityTransactionUser(params: {
|
||||||
stripePaymentIntentId: paymentIntentId,
|
stripePaymentIntentId: paymentIntentId,
|
||||||
amountPaid: amountPaidCents,
|
amountPaid: amountPaidCents,
|
||||||
platformFee: platformFeeCents,
|
platformFee: platformFeeCents,
|
||||||
|
discountCodeId,
|
||||||
|
discountAmount: discountCodeId ? discountAmountCents : null,
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
courseId,
|
courseId,
|
||||||
|
|
@ -555,8 +587,27 @@ async function runCapacityTransactionUser(params: {
|
||||||
stripePaymentIntentId: paymentIntentId,
|
stripePaymentIntentId: paymentIntentId,
|
||||||
amountPaid: amountPaidCents,
|
amountPaid: amountPaidCents,
|
||||||
platformFee: platformFeeCents,
|
platformFee: platformFeeCents,
|
||||||
|
discountCodeId,
|
||||||
|
discountAmount: discountCodeId ? discountAmountCents : null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Das Kontingent wurde bereits beim Checkout-Start verbraucht (siehe
|
||||||
|
// reserveDiscountCode) - hier wird die Reservierung nur noch mit dieser
|
||||||
|
// Buchung verknüpft und auf "confirmed" gesetzt. Existiert keine
|
||||||
|
// "reserved"-Zeile mehr für dieses Token (z.B. weil
|
||||||
|
// checkout.session.expired sie zwischenzeitlich freigegeben hat, obwohl
|
||||||
|
// die Zahlung tatsächlich noch durchkam), tut confirmDiscountReservation
|
||||||
|
// nichts - die Buchung bleibt trotzdem bestehen (Zahlung ist schon
|
||||||
|
// erfolgt), nur booking.discountCodeId/discountAmount bleiben als
|
||||||
|
// Snapshot für die Rechnung gesetzt, ohne verbuchte Redemption.
|
||||||
|
if (discountReservationToken) {
|
||||||
|
await confirmDiscountReservation(tx, {
|
||||||
|
reservationToken: discountReservationToken,
|
||||||
|
bookingId: booking.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return { result: "booked" as const, bookingId: booking.id };
|
return { result: "booked" as const, bookingId: booking.id };
|
||||||
},
|
},
|
||||||
{ isolationLevel: "Serializable" }
|
{ isolationLevel: "Serializable" }
|
||||||
|
|
@ -825,6 +876,21 @@ async function getApplicationFeeFromPaymentIntent(paymentIntentId: string): Prom
|
||||||
async function handleCheckoutExpired(session: Stripe.Checkout.Session) {
|
async function handleCheckoutExpired(session: Stripe.Checkout.Session) {
|
||||||
const courseId = session.metadata?.courseId;
|
const courseId = session.metadata?.courseId;
|
||||||
const isGuest = session.metadata?.isGuest === "true";
|
const isGuest = session.metadata?.isGuest === "true";
|
||||||
|
const discountReservationToken = session.metadata?.discountReservationToken || null;
|
||||||
|
|
||||||
|
// Gibt einen beim Checkout-Start reservierten Rabattcode-Slot wieder frei
|
||||||
|
// (siehe reserveDiscountCode), da diese Session nie bezahlt wurde. Läuft
|
||||||
|
// VOR dem frühen "if (!courseId) return" unten, da eine Reservierung auch
|
||||||
|
// ohne courseId theoretisch bestehen könnte und sonst nie freigegeben würde.
|
||||||
|
if (discountReservationToken) {
|
||||||
|
await releaseDiscountReservation(prisma, discountReservationToken).catch((error) => {
|
||||||
|
console.error(
|
||||||
|
`Rabattcode-Reservierung ${discountReservationToken} konnte nach abgelaufener ` +
|
||||||
|
`Checkout-Session nicht freigegeben werden:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!courseId) return;
|
if (!courseId) return;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,10 @@ interface SuccessPageProps {
|
||||||
searchParams: Promise<{ session_id?: string }>;
|
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 { 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 course = result.success ? result.course : null;
|
||||||
const businessSlug = course?.business?.slug ?? null;
|
const businessSlug = course?.business?.slug ?? null;
|
||||||
|
|
@ -28,9 +29,13 @@ export default async function CourseBookingSuccessPage({ params }: SuccessPagePr
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const formattedPrice = course
|
const priceFormatter = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" });
|
||||||
? new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(course.price)
|
const formattedPrice = result.success && course ? priceFormatter.format(result.paidPrice) : null;
|
||||||
: null;
|
const hasDiscount = result.success && result.discountAmount !== null;
|
||||||
|
const formattedDiscount =
|
||||||
|
result.success && result.discountAmount !== null
|
||||||
|
? priceFormatter.format(result.discountAmount)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto p-6">
|
<div className="max-w-2xl mx-auto p-6">
|
||||||
|
|
@ -84,21 +89,29 @@ export default async function CourseBookingSuccessPage({ params }: SuccessPagePr
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-gray-200 mt-4 pt-3 flex items-center justify-between">
|
<div className="border-t border-gray-200 mt-4 pt-3">
|
||||||
<span className="text-sm text-gray-500">Bezahlt</span>
|
{hasDiscount && (
|
||||||
{branding ? (
|
<div className="flex items-center justify-between text-xs text-gray-500 mb-1">
|
||||||
<span
|
<span>Rabatt</span>
|
||||||
className="text-lg font-bold px-2.5 py-0.5 rounded-md"
|
<span>− {formattedDiscount}</span>
|
||||||
style={{
|
</div>
|
||||||
backgroundColor: branding.accentColor,
|
|
||||||
color: getContrastTextColor(branding.accentColor),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formattedPrice}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-lg font-bold text-gray-900">{formattedPrice}</span>
|
|
||||||
)}
|
)}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-gray-500">Bezahlt</span>
|
||||||
|
{branding ? (
|
||||||
|
<span
|
||||||
|
className="text-lg font-bold px-2.5 py-0.5 rounded-md"
|
||||||
|
style={{
|
||||||
|
backgroundColor: branding.accentColor,
|
||||||
|
color: getContrastTextColor(branding.accentColor),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formattedPrice}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-lg font-bold text-gray-900">{formattedPrice}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,11 @@ import { executeBookingCancellation } from "./cancel-booking-shared";
|
||||||
import { combineDateAndTime } from "@/src/lib/course-time";
|
import { combineDateAndTime } from "@/src/lib/course-time";
|
||||||
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
|
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
|
||||||
import { KUMASY_BRANDING } from "@/src/lib/mail/templates/tokens";
|
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 = {
|
export type UserBooking = {
|
||||||
bookingId: string;
|
bookingId: string;
|
||||||
|
|
@ -264,7 +269,7 @@ export async function checkUserBookingStatus(courseId: string) {
|
||||||
*
|
*
|
||||||
* @param courseId - Die eindeutige ID des Kurses
|
* @param courseId - Die eindeutige ID des Kurses
|
||||||
*/
|
*/
|
||||||
export async function getCourseSummaryForConfirmation(courseId: string) {
|
export async function getCourseSummaryForConfirmation(courseId: string, sessionId?: string) {
|
||||||
try {
|
try {
|
||||||
const course = await prisma.course.findFirst({
|
const course = await prisma.course.findFirst({
|
||||||
where: {
|
where: {
|
||||||
|
|
@ -295,7 +300,28 @@ export async function getCourseSummaryForConfirmation(courseId: string) {
|
||||||
return { success: false as const, error: "Kurs nicht gefunden" };
|
return { success: false as const, error: "Kurs nicht gefunden" };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true as const, course };
|
// course.price ist der reguläre Kurspreis, NICHT der tatsächlich gezahlte
|
||||||
|
// Betrag (kann durch einen Rabattcode abweichen). sessionId (aus der
|
||||||
|
// Stripe-success_url) erlaubt es, die konkrete Buchung nachzuladen - ohne
|
||||||
|
// sessionId (oder falls der Webhook noch nicht durchgelaufen ist) fällt
|
||||||
|
// die Anzeige auf den regulären Kurspreis zurück.
|
||||||
|
let paidPrice = course.price;
|
||||||
|
let discountAmount: number | null = null;
|
||||||
|
if (sessionId) {
|
||||||
|
// findFirst statt findUnique mit stripeSessionId allein: eine URL, die
|
||||||
|
// courseId von Kurs B mit der session_id von Kurs A kombiniert, würde
|
||||||
|
// sonst Kurs B mit den Zahlungsdaten von Kurs A anzeigen.
|
||||||
|
const booking = await prisma.courseBooking.findFirst({
|
||||||
|
where: { stripeSessionId: sessionId, courseId },
|
||||||
|
select: { amountPaid: true, discountAmount: true },
|
||||||
|
});
|
||||||
|
if (booking?.amountPaid !== null && booking?.amountPaid !== undefined) {
|
||||||
|
paidPrice = booking.amountPaid / 100;
|
||||||
|
discountAmount = booking.discountAmount !== null ? booking.discountAmount / 100 : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true as const, course, paidPrice, discountAmount };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching course summary:", error);
|
console.error("Error fetching course summary:", error);
|
||||||
return { success: false as const, error: "Fehler beim Laden des Kurses" };
|
return { success: false as const, error: "Fehler beim Laden des Kurses" };
|
||||||
|
|
@ -407,3 +433,51 @@ async function _fetchCoursesForBusiness(businessId: string, options: { from: Dat
|
||||||
return { success: false, error: "Fehler beim Laden der Kurse", courses: [] };
|
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." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { Dialog, DialogClose, DialogContent, DialogTitle } from "@/src/component
|
||||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/src/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/src/components/ui/tooltip";
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { checkUserBookingStatus } from "../../actions/booking-actions";
|
import { checkUserBookingStatus, previewDiscountCode } from "../../actions/booking-actions";
|
||||||
import { joinWaitlistAsUser } from "../../actions/waitlist-actions";
|
import { joinWaitlistAsUser } from "../../actions/waitlist-actions";
|
||||||
import { getContrastTextColor } from "@/src/modules/settings/lib/contrast";
|
import { getContrastTextColor } from "@/src/modules/settings/lib/contrast";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
@ -168,6 +168,12 @@ export const BookingDialog = ({
|
||||||
const [showGuestForm, setShowGuestForm] = useState(false);
|
const [showGuestForm, setShowGuestForm] = useState(false);
|
||||||
const [guestEmail, setGuestEmail] = useState("");
|
const [guestEmail, setGuestEmail] = useState("");
|
||||||
const [guestName, setGuestName] = 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;
|
const isAdminView = paymentStatus !== undefined;
|
||||||
|
|
||||||
|
|
@ -224,10 +230,32 @@ export const BookingDialog = ({
|
||||||
|
|
||||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
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 }) => {
|
const handleBooking = async (opts?: { asGuest: boolean }) => {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const body: Record<string, string> = { courseId: course.id };
|
const body: Record<string, string> = { courseId: course.id };
|
||||||
|
if (appliedDiscount) body.discountCode = appliedDiscount.code;
|
||||||
if (opts?.asGuest) {
|
if (opts?.asGuest) {
|
||||||
const normalizedEmail = guestEmail.trim().toLowerCase();
|
const normalizedEmail = guestEmail.trim().toLowerCase();
|
||||||
if (!EMAIL_RE.test(normalizedEmail)) {
|
if (!EMAIL_RE.test(normalizedEmail)) {
|
||||||
|
|
@ -256,6 +284,11 @@ export const BookingDialog = ({
|
||||||
if (response.status === 401 || (response.status === 400 && data.isGuestRequired)) {
|
if (response.status === 401 || (response.status === 400 && data.isGuestRequired)) {
|
||||||
if (opts?.asGuest && data.error) toast.error(data.error);
|
if (opts?.asGuest && data.error) toast.error(data.error);
|
||||||
setShowGuestForm(true);
|
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);
|
setIsSubmitting(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -430,8 +463,48 @@ export const BookingDialog = ({
|
||||||
|
|
||||||
{/* Sticky Footer */}
|
{/* Sticky Footer */}
|
||||||
<div className="shrink-0 border-t border-gray-200 bg-white px-4 sm:px-6 py-3 sm:py-4 flex flex-col gap-3">
|
<div className="shrink-0 border-t border-gray-200 bg-white px-4 sm:px-6 py-3 sm:py-4 flex flex-col gap-3">
|
||||||
|
{/* 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 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Rabattcode"
|
||||||
|
value={discountInput}
|
||||||
|
onChange={(e) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleApplyDiscount}
|
||||||
|
disabled={isCheckingDiscount || !discountInput.trim()}
|
||||||
|
>
|
||||||
|
{isCheckingDiscount ? "Prüfe…" : "Anwenden"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
<span className="text-lg font-bold text-gray-900">Preis: {formattedPrice}</span>
|
<span className="text-lg font-bold text-gray-900">
|
||||||
|
{appliedDiscount ? (
|
||||||
|
<>
|
||||||
|
<span className="line-through text-gray-400 font-normal mr-2">
|
||||||
|
{formattedPrice}
|
||||||
|
</span>
|
||||||
|
Preis:{" "}
|
||||||
|
{appliedDiscount.discountedPrice.toLocaleString("de-DE", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "EUR",
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
`Preis: ${formattedPrice}`
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
<div className="flex items-center gap-3 w-full sm:w-auto">
|
<div className="flex items-center gap-3 w-full sm:w-auto">
|
||||||
{/* Admin-Ansicht: nur Status-Badge + Schließen */}
|
{/* Admin-Ansicht: nur Status-Badge + Schließen */}
|
||||||
{isAdminView ? (
|
{isAdminView ? (
|
||||||
|
|
|
||||||
242
src/modules/settings/actions/discount-actions.ts
Normal file
242
src/modules/settings/actions/discount-actions.ts
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/src/lib/prisma";
|
||||||
|
import { requireManager } from "@/src/lib/auth/getUser";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { discountCodeSchema, type DiscountCodeInput } from "../schemas/discount-code-schema";
|
||||||
|
|
||||||
|
export type DiscountCodeDTO = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
type: "percent" | "fixed";
|
||||||
|
value: number;
|
||||||
|
scope: "all_courses" | "specific_courses";
|
||||||
|
courseIds: string[];
|
||||||
|
maxRedemptions: number | null;
|
||||||
|
redeemedCount: number;
|
||||||
|
startsAt: Date | null;
|
||||||
|
expiresAt: Date | null;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function assertBusinessOwnership(businessId: string, managerId: string) {
|
||||||
|
const business = await prisma.business.findUnique({
|
||||||
|
where: { id: businessId },
|
||||||
|
select: { managerId: true },
|
||||||
|
});
|
||||||
|
return !!business && business.managerId === managerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDiscountCodes(
|
||||||
|
businessId: string
|
||||||
|
): Promise<{ success: true; codes: DiscountCodeDTO[] } | { success: false; error: string }> {
|
||||||
|
// requireManager() löst bei fehlender Session/Rolle einen next/navigation-Redirect
|
||||||
|
// aus, der als spezieller Error implementiert ist - darf nicht im try/catch landen.
|
||||||
|
const manager = await requireManager();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!(await assertBusinessOwnership(businessId, manager.id))) {
|
||||||
|
return { success: false, error: "Keine Berechtigung für dieses Business." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const codes = await prisma.discountCode.findMany({
|
||||||
|
where: { businessId },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, codes: codes as DiscountCodeDTO[] };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching discount codes:", error);
|
||||||
|
return { success: false, error: "Rabattcodes konnten nicht geladen werden." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDiscountCode(
|
||||||
|
businessId: string,
|
||||||
|
input: DiscountCodeInput
|
||||||
|
): Promise<{ success: true; code: DiscountCodeDTO } | { success: false; error: string }> {
|
||||||
|
const manager = await requireManager();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!(await assertBusinessOwnership(businessId, manager.id))) {
|
||||||
|
return { success: false, error: "Keine Berechtigung für dieses Business." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = discountCodeSchema.safeParse(input);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { success: false, error: parsed.error.issues[0]?.message ?? "Ungültige Eingabe." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedCode = parsed.data.code.toUpperCase();
|
||||||
|
|
||||||
|
const existing = await prisma.discountCode.findUnique({
|
||||||
|
where: { businessId_code: { businessId, code: normalizedCode } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
return { success: false, error: "Dieser Code existiert bereits für dieses Business." };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedupliziert eingereichte IDs und erzwingt dieselbe Berechtigung wie der
|
||||||
|
// Picker (getBusinessCoursesForDiscountPicker): Business-Zugehörigkeit +
|
||||||
|
// kein Standing-Order-Template. Ohne diesen Check könnte eine direkt
|
||||||
|
// übergebene fremde/ungültige ID unvalidiert gespeichert werden und in der
|
||||||
|
// Liste als "Unbekannt" auftauchen, oder ein Standing-Order-Template
|
||||||
|
// fälschlich rabattfähig werden.
|
||||||
|
const courseIds = [...new Set(parsed.data.courseIds)];
|
||||||
|
if (parsed.data.scope === "specific_courses") {
|
||||||
|
const matchingCourses = await prisma.course.count({
|
||||||
|
where: { id: { in: courseIds }, businessId, isStandingOrder: false },
|
||||||
|
});
|
||||||
|
if (matchingCourses !== courseIds.length) {
|
||||||
|
return { success: false, error: "Mindestens ein Kurs ist für dieses Business ungültig." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await prisma.discountCode.create({
|
||||||
|
data: {
|
||||||
|
businessId,
|
||||||
|
code: normalizedCode,
|
||||||
|
type: parsed.data.type,
|
||||||
|
value: parsed.data.value,
|
||||||
|
scope: parsed.data.scope,
|
||||||
|
courseIds: parsed.data.scope === "specific_courses" ? courseIds : [],
|
||||||
|
maxRedemptions: parsed.data.maxRedemptions,
|
||||||
|
startsAt: parsed.data.startsAt,
|
||||||
|
expiresAt: parsed.data.expiresAt,
|
||||||
|
isActive: parsed.data.isActive,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/settings/discounts");
|
||||||
|
|
||||||
|
return { success: true, code: created as DiscountCodeDTO };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating discount code:", error);
|
||||||
|
return { success: false, error: "Rabattcode konnte nicht erstellt werden." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bewusst kein voller Edit für code/type/value/scope, sobald ein Code schon
|
||||||
|
// mindestens einmal eingelöst wurde: eine nachträgliche Änderung würde
|
||||||
|
// bestehende, bereits abgerechnete Buchungen (CourseBooking.discountAmount)
|
||||||
|
// rückwirkend inkonsistent mit dem Code-Datensatz machen. Editierbar bleiben
|
||||||
|
// nur die Limits/Laufzeit/isActive, die den Code lediglich stärker
|
||||||
|
// einschränken oder ausschalten, nie rückwirkend die Vergangenheit verändern.
|
||||||
|
export async function updateDiscountCodeLimits(
|
||||||
|
businessId: string,
|
||||||
|
discountCodeId: string,
|
||||||
|
input: {
|
||||||
|
maxRedemptions: number | null;
|
||||||
|
startsAt: Date | null;
|
||||||
|
expiresAt: Date | null;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
): Promise<{ success: true } | { success: false; error: string }> {
|
||||||
|
const manager = await requireManager();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!(await assertBusinessOwnership(businessId, manager.id))) {
|
||||||
|
return { success: false, error: "Keine Berechtigung für dieses Business." };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.startsAt && input.expiresAt && input.expiresAt <= input.startsAt) {
|
||||||
|
return { success: false, error: "Ablaufdatum muss nach dem Startdatum liegen." };
|
||||||
|
}
|
||||||
|
if (input.maxRedemptions !== null && input.maxRedemptions <= 0) {
|
||||||
|
return { success: false, error: "Maximale Einlösungen muss größer als 0 sein." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = await prisma.discountCode.findUnique({
|
||||||
|
where: { id: discountCodeId },
|
||||||
|
select: { businessId: true },
|
||||||
|
});
|
||||||
|
if (!code || code.businessId !== businessId) {
|
||||||
|
return { success: false, error: "Rabattcode nicht gefunden." };
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.discountCode.update({
|
||||||
|
where: { id: discountCodeId },
|
||||||
|
data: {
|
||||||
|
maxRedemptions: input.maxRedemptions,
|
||||||
|
startsAt: input.startsAt,
|
||||||
|
expiresAt: input.expiresAt,
|
||||||
|
isActive: input.isActive,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/settings/discounts");
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating discount code:", error);
|
||||||
|
return { success: false, error: "Rabattcode konnte nicht aktualisiert werden." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hard-Delete bewusst vermieden: DiscountRedemption/CourseBooking verweisen auf
|
||||||
|
// die Code-ID (Rechnungs-/Audit-Historie). Deaktivieren reicht, um weitere
|
||||||
|
// Einlösungen zu verhindern.
|
||||||
|
export async function deactivateDiscountCode(
|
||||||
|
businessId: string,
|
||||||
|
discountCodeId: string
|
||||||
|
): Promise<{ success: true } | { success: false; error: string }> {
|
||||||
|
const manager = await requireManager();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!(await assertBusinessOwnership(businessId, manager.id))) {
|
||||||
|
return { success: false, error: "Keine Berechtigung für dieses Business." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = await prisma.discountCode.findUnique({
|
||||||
|
where: { id: discountCodeId },
|
||||||
|
select: { businessId: true },
|
||||||
|
});
|
||||||
|
if (!code || code.businessId !== businessId) {
|
||||||
|
return { success: false, error: "Rabattcode nicht gefunden." };
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.discountCode.update({
|
||||||
|
where: { id: discountCodeId },
|
||||||
|
data: { isActive: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/settings/discounts");
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deactivating discount code:", error);
|
||||||
|
return { success: false, error: "Rabattcode konnte nicht deaktiviert werden." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Für den "spezifische Kurse"-Picker im Settings-UI. Es gibt keine bestehende
|
||||||
|
// "Kurse nach Business"-Abfrage (course-actions.ts filtert nach createdBy =
|
||||||
|
// Manager-ID, nicht businessId) - Rabattcodes sind aber businessId-scoped,
|
||||||
|
// daher eine eigene, schlanke Query statt getMyCourses() zu erweitern.
|
||||||
|
export async function getBusinessCoursesForDiscountPicker(
|
||||||
|
businessId: string
|
||||||
|
): Promise<
|
||||||
|
| { success: true; courses: Array<{ id: string; name: string }> }
|
||||||
|
| { success: false; error: string }
|
||||||
|
> {
|
||||||
|
const manager = await requireManager();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!(await assertBusinessOwnership(businessId, manager.id))) {
|
||||||
|
return { success: false, error: "Keine Berechtigung für dieses Business." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const courses = await prisma.course.findMany({
|
||||||
|
where: { businessId, isStandingOrder: false },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
orderBy: { date: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, courses };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching business courses:", error);
|
||||||
|
return { success: false, error: "Kurse konnten nicht geladen werden." };
|
||||||
|
}
|
||||||
|
}
|
||||||
242
src/modules/settings/lib/discount-validation.ts
Normal file
242
src/modules/settings/lib/discount-validation.ts
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import type { prisma as prismaClient } from "@/src/lib/prisma";
|
||||||
|
|
||||||
|
// Erlaubt sowohl den globalen `prisma`-Client (Checkout-Route, Vorab-Prüfung)
|
||||||
|
// als auch den `tx`-Parameter aus prisma.$transaction() (Webhook, Source of
|
||||||
|
// Truth) - Prisma leitet den Transaktions-Client-Typ nicht separat exportierbar
|
||||||
|
// ab, daher hier über Parameters<> vom $transaction-Callback selbst.
|
||||||
|
type PrismaOrTx =
|
||||||
|
| typeof prismaClient
|
||||||
|
| Parameters<Parameters<typeof prismaClient.$transaction>[0]>[0];
|
||||||
|
|
||||||
|
export type DiscountValidationResult =
|
||||||
|
| { valid: true; discountCode: { id: string; type: string; value: number } }
|
||||||
|
| { valid: false; reason: string };
|
||||||
|
|
||||||
|
// Rein lesende Prüfung ohne Seiteneffekte - wird von reserveDiscountCode()
|
||||||
|
// (Checkout-Start) und previewDiscountCode() (Vorschau vor dem Checkout)
|
||||||
|
// genutzt. Das eigentliche Verbrauchen des Kontingents passiert in
|
||||||
|
// reserveDiscountCode() gleich danach, nicht hier.
|
||||||
|
export async function validateDiscountCode(
|
||||||
|
db: PrismaOrTx,
|
||||||
|
params: { code: string; businessId: string; courseId: string; userId: string | null }
|
||||||
|
): Promise<DiscountValidationResult> {
|
||||||
|
const normalizedCode = params.code.trim().toUpperCase();
|
||||||
|
if (!normalizedCode) {
|
||||||
|
return { valid: false, reason: "Kein Rabattcode angegeben." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const discountCode = await db.discountCode.findUnique({
|
||||||
|
where: { businessId_code: { businessId: params.businessId, code: normalizedCode } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!discountCode || !discountCode.isActive) {
|
||||||
|
return { valid: false, reason: "Dieser Rabattcode ist ungültig." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
if (discountCode.startsAt && now < discountCode.startsAt) {
|
||||||
|
return { valid: false, reason: "Dieser Rabattcode ist noch nicht gültig." };
|
||||||
|
}
|
||||||
|
if (discountCode.expiresAt && now > discountCode.expiresAt) {
|
||||||
|
return { valid: false, reason: "Dieser Rabattcode ist abgelaufen." };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
discountCode.scope === "specific_courses" &&
|
||||||
|
!discountCode.courseIds.includes(params.courseId)
|
||||||
|
) {
|
||||||
|
return { valid: false, reason: "Dieser Rabattcode gilt nicht für diesen Kurs." };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
discountCode.maxRedemptions !== null &&
|
||||||
|
discountCode.redeemedCount >= discountCode.maxRedemptions
|
||||||
|
) {
|
||||||
|
return { valid: false, reason: "Dieser Rabattcode wurde bereits vollständig eingelöst." };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rabattcodes sind bewusst nur eingeloggten Usern vorbehalten: Gäste haben
|
||||||
|
// keine stabile Identität (nur eine unverifizierte E-Mail), wodurch weder
|
||||||
|
// die 1x-pro-User-Regel noch das Gesamtkontingent zuverlässig durchsetzbar
|
||||||
|
// wären (siehe #182 - Gast-Redemptions wurden zunächst nicht mitgezählt).
|
||||||
|
if (!params.userId) {
|
||||||
|
return { valid: false, reason: "Rabattcodes sind nur für angemeldete Kunden verfügbar." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingRedemption = await db.discountRedemption.findUnique({
|
||||||
|
where: { discountCodeId_userId: { discountCodeId: discountCode.id, userId: params.userId } },
|
||||||
|
});
|
||||||
|
if (existingRedemption) {
|
||||||
|
return { valid: false, reason: "Du hast diesen Rabattcode bereits verwendet." };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
discountCode: { id: discountCode.id, type: discountCode.type, value: discountCode.value },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateDiscountedPriceCents(
|
||||||
|
originalPriceCents: number,
|
||||||
|
discount: { type: string; value: number }
|
||||||
|
): { discountedPriceCents: number; discountAmountCents: number } {
|
||||||
|
const rawDiscount =
|
||||||
|
discount.type === "percent"
|
||||||
|
? Math.round((originalPriceCents * discount.value) / 100)
|
||||||
|
: discount.value;
|
||||||
|
|
||||||
|
// Rabatt darf den Preis nicht unter 0 drücken (relevant v.a. bei "fixed" auf
|
||||||
|
// günstige Kurse) - Stripe lehnt unit_amount <= 0 in der Regel ohnehin ab.
|
||||||
|
const discountAmountCents = Math.min(rawDiscount, originalPriceCents);
|
||||||
|
const discountedPriceCents = originalPriceCents - discountAmountCents;
|
||||||
|
|
||||||
|
return { discountedPriceCents, discountAmountCents };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReserveDiscountResult =
|
||||||
|
| {
|
||||||
|
reserved: true;
|
||||||
|
reservationToken: string;
|
||||||
|
discountCode: { id: string; type: string; value: number };
|
||||||
|
}
|
||||||
|
| { reserved: false; reason: string };
|
||||||
|
|
||||||
|
// Reserviert einen Rabattcode-Slot ATOMAR BEIM CHECKOUT-START, nicht erst
|
||||||
|
// nach Zahlungsbestätigung. Ohne das könnten mehrere gleichzeitige Checkouts
|
||||||
|
// (verschiedene User bei knappem Kontingent, oder ein User mit mehreren
|
||||||
|
// offenen Sessions für denselben Code) alle den rabattierten Preis bekommen,
|
||||||
|
// obwohl z.B. nur noch 1 Slot frei war - jede Session würde unabhängig
|
||||||
|
// voneinander `validateDiscountCode` als "noch gültig" sehen, weil keine der
|
||||||
|
// anderen Sessions zu dem Zeitpunkt schon eine Redemption-Zeile geschrieben
|
||||||
|
// hatte (das passierte vorher erst im Webhook, also NACH der Zahlung, zu
|
||||||
|
// spät um die Preisanzeige noch zu verhindern).
|
||||||
|
//
|
||||||
|
// reservationToken ist NICHT die echte Stripe-Session-ID (die existiert zu
|
||||||
|
// diesem Zeitpunkt noch nicht - stripe.checkout.sessions.create() läuft
|
||||||
|
// danach) - es ist ein selbst generierter Korrelationsschlüssel, der in die
|
||||||
|
// Stripe-Metadata gelegt wird, damit der Webhook dieselbe Reservierung anhand
|
||||||
|
// des Tokens wiederfindet und bestätigt.
|
||||||
|
//
|
||||||
|
// Die Prisma-Unique-Constraints (@@unique([discountCodeId, userId]) und die
|
||||||
|
// atomare {increment: 1} auf redeemedCount unter derselben Transaktion)
|
||||||
|
// erzwingen Kontingent und 1x-pro-User bereits HIER race-condition-sicher auf
|
||||||
|
// DB-Ebene - ein zweiter, gleichzeitiger Aufruf für denselben User/Code
|
||||||
|
// schlägt mit einem Unique-Constraint-Fehler fehl, den diese Funktion als
|
||||||
|
// "bereits reserviert" abfängt, statt ihn nach oben durchzureichen.
|
||||||
|
export async function reserveDiscountCode(
|
||||||
|
db: PrismaOrTx,
|
||||||
|
params: { code: string; businessId: string; courseId: string; userId: string }
|
||||||
|
): Promise<ReserveDiscountResult> {
|
||||||
|
const validation = await validateDiscountCode(db, params);
|
||||||
|
if (!validation.valid) {
|
||||||
|
return { reserved: false, reason: validation.reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
const reservationToken = randomUUID();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Prisma's Query-Builder kann zwei Spalten derselben Zeile nicht direkt
|
||||||
|
// gegeneinander vergleichen (kein "redeemedCount < maxRedemptions" als
|
||||||
|
// where-Filter) - das UPDATE läuft daher als rohes SQL. Der Vergleich
|
||||||
|
// steht in derselben atomaren UPDATE-Anweisung wie das Increment, sodass
|
||||||
|
// Postgres beides unter einer Zeilensperre ausführt: ein UPDATE, das das
|
||||||
|
// Kontingent knapp erschöpft, sieht garantiert den zuvor inkrementierten
|
||||||
|
// Wert einer gleichzeitigen, aber zuerst committeten Anfrage - ein
|
||||||
|
// "verlorenes Update" (zwei gleichzeitige Reads sehen beide "noch frei")
|
||||||
|
// ist damit ausgeschlossen, ganz ohne die teurere Serializable-Isolation
|
||||||
|
// aus dem Webhook zu benötigen.
|
||||||
|
const updated = await db.$executeRaw`
|
||||||
|
UPDATE "DiscountCode"
|
||||||
|
SET "redeemedCount" = "redeemedCount" + 1
|
||||||
|
WHERE "id" = ${validation.discountCode.id}
|
||||||
|
AND ("maxRedemptions" IS NULL OR "redeemedCount" < "maxRedemptions")
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (updated === 0) {
|
||||||
|
return { reserved: false, reason: "Dieser Rabattcode wurde bereits vollständig eingelöst." };
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.discountRedemption.create({
|
||||||
|
data: {
|
||||||
|
discountCodeId: validation.discountCode.id,
|
||||||
|
userId: params.userId,
|
||||||
|
stripeSessionId: reservationToken,
|
||||||
|
status: "reserved",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { reserved: true, reservationToken, discountCode: validation.discountCode };
|
||||||
|
} catch (error) {
|
||||||
|
// P2002: der @@unique([discountCodeId, userId]) Constraint schlägt an,
|
||||||
|
// wenn zwischen der validateDiscountCode-Prüfung oben und diesem Insert
|
||||||
|
// eine ANDERE gleichzeitige Anfrage für denselben User/Code zuerst
|
||||||
|
// reserviert hat (z.B. zwei Tabs mit demselben Code + Kurs).
|
||||||
|
if (error instanceof Error && "code" in error && (error as { code: string }).code === "P2002") {
|
||||||
|
// Kontingent-Increment oben lief in diesem Fall bereits durch, bevor
|
||||||
|
// der Redemption-Insert scheiterte - muss zurückgerollt werden, sonst
|
||||||
|
// verliert der Code dauerhaft einen Slot ohne zugehörige Redemption.
|
||||||
|
await db.discountCode
|
||||||
|
.update({
|
||||||
|
where: { id: validation.discountCode.id },
|
||||||
|
data: { redeemedCount: { decrement: 1 } },
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
return { reserved: false, reason: "Du hast diesen Rabattcode bereits verwendet." };
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wird aufgerufen, wenn eine Reservierung NICHT zu einer bezahlten Buchung
|
||||||
|
// führt: Stripe-Session-Erstellung schlägt nach der Reservierung fehl
|
||||||
|
// (Rollback im Checkout-Route-Handler), oder die Session läuft ungenutzt ab
|
||||||
|
// (checkout.session.expired-Webhook-Handler). Gibt den reservierten Slot
|
||||||
|
// wieder frei, damit er nicht dauerhaft blockiert bleibt.
|
||||||
|
//
|
||||||
|
// Nimmt bewusst den Top-Level-Client (nicht PrismaOrTx/ein laufendes `tx`) -
|
||||||
|
// öffnet selbst eine eigene $transaction([...]) und würde sonst verschachtelt
|
||||||
|
// innerhalb einer bereits laufenden Transaktion aufgerufen werden, was Prisma
|
||||||
|
// nicht unterstützt.
|
||||||
|
export async function releaseDiscountReservation(
|
||||||
|
db: typeof prismaClient,
|
||||||
|
reservationToken: string
|
||||||
|
): Promise<void> {
|
||||||
|
const redemption = await db.discountRedemption.findUnique({
|
||||||
|
where: { stripeSessionId: reservationToken },
|
||||||
|
});
|
||||||
|
// Bereits bestätigt (Webhook kam parallel zuerst durch) oder gar nicht
|
||||||
|
// (mehr) vorhanden - in beiden Fällen nichts freizugeben.
|
||||||
|
if (!redemption || redemption.status !== "reserved") return;
|
||||||
|
|
||||||
|
await db.$transaction([
|
||||||
|
db.discountRedemption.delete({ where: { id: redemption.id } }),
|
||||||
|
db.discountCode.update({
|
||||||
|
where: { id: redemption.discountCodeId },
|
||||||
|
data: { redeemedCount: { decrement: 1 } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wird NUR innerhalb der Serializable-Transaktion im Webhook aufgerufen,
|
||||||
|
// nachdem die Zahlung bereits bestätigt ist und die CourseBooking-Zeile
|
||||||
|
// bereits existiert. Verknüpft die beim Checkout-Start angelegte Reservierung
|
||||||
|
// (siehe reserveDiscountCode) mit der jetzt existierenden Buchung und setzt
|
||||||
|
// sie auf "confirmed" - der Kontingent-Slot wurde bereits bei der Reservierung
|
||||||
|
// verbraucht, hier wird nur noch die Buchungs-Zuordnung nachgetragen.
|
||||||
|
// Existiert keine "reserved"-Zeile mehr für dieses Token (z.B. weil
|
||||||
|
// checkout.session.expired sie zwischenzeitlich freigegeben hat, obwohl die
|
||||||
|
// Zahlung tatsächlich noch durchkam - ein seltenes, aber mögliches Timing),
|
||||||
|
// tut diese Funktion nichts: die Buchung wurde vom Aufrufer bereits
|
||||||
|
// unabhängig davon zum schon belasteten (rabattierten) Preis bestätigt, nur
|
||||||
|
// ohne verbuchte Redemption. Das ist bewusstes Verhalten, kein Fehlerfall.
|
||||||
|
export async function confirmDiscountReservation(
|
||||||
|
tx: PrismaOrTx,
|
||||||
|
params: { reservationToken: string; bookingId: string }
|
||||||
|
): Promise<boolean> {
|
||||||
|
const result = await tx.discountRedemption.updateMany({
|
||||||
|
where: { stripeSessionId: params.reservationToken, status: "reserved" },
|
||||||
|
data: { status: "confirmed", bookingId: params.bookingId },
|
||||||
|
});
|
||||||
|
return result.count > 0;
|
||||||
|
}
|
||||||
47
src/modules/settings/schemas/discount-code-schema.ts
Normal file
47
src/modules/settings/schemas/discount-code-schema.ts
Normal file
|
|
@ -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<typeof discountCodeSchema>;
|
||||||
430
src/modules/settings/ui/components/DiscountCodeList.tsx
Normal file
430
src/modules/settings/ui/components/DiscountCodeList.tsx
Normal file
|
|
@ -0,0 +1,430 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Plus, Trash2, Tag, Search, Calendar, Percent, Coins, Layers } from "lucide-react";
|
||||||
|
import { Button } from "@/src/components/ui/button";
|
||||||
|
import { Input } from "@/src/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/src/components/ui/select";
|
||||||
|
import MultiSelectDropdown from "@/src/components/layout/MultiSelectDropdown";
|
||||||
|
import type { DiscountCodeDTO } from "../../actions/discount-actions";
|
||||||
|
|
||||||
|
interface DiscountCodeListProps {
|
||||||
|
codes: DiscountCodeDTO[];
|
||||||
|
courses: { id: string; name: string }[];
|
||||||
|
onCreate: (input: {
|
||||||
|
code: string;
|
||||||
|
type: "percent" | "fixed";
|
||||||
|
value: number;
|
||||||
|
scope: "all_courses" | "specific_courses";
|
||||||
|
courseIds: string[];
|
||||||
|
maxRedemptions: number | null;
|
||||||
|
startsAt: Date | null;
|
||||||
|
expiresAt: Date | null;
|
||||||
|
isActive: boolean;
|
||||||
|
}) => Promise<boolean>;
|
||||||
|
onDeactivate: (discountCodeId: string) => Promise<boolean>;
|
||||||
|
isSaving: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm = {
|
||||||
|
code: "",
|
||||||
|
type: "percent" as "percent" | "fixed",
|
||||||
|
value: 10,
|
||||||
|
scope: "all_courses" as "all_courses" | "specific_courses",
|
||||||
|
courseIds: [] as string[],
|
||||||
|
maxRedemptions: "" as string, // leer = unbegrenzt, siehe toInput()
|
||||||
|
startsAt: "" as string,
|
||||||
|
expiresAt: "" as string,
|
||||||
|
};
|
||||||
|
|
||||||
|
type DisplayStatus = "active" | "inactive" | "expired" | "scheduled" | "exhausted";
|
||||||
|
type StatusFilter = "all" | DisplayStatus;
|
||||||
|
|
||||||
|
function formatDate(date: Date | null): string {
|
||||||
|
if (!date) return "—";
|
||||||
|
return new Intl.DateTimeFormat("de-DE", { dateStyle: "medium" }).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
// new Date("YYYY-MM-DD") parst als UTC-Mitternacht, nicht als lokaler
|
||||||
|
// Tagesbeginn - in deutschen Zeitzonen (UTC+1/+2) würde "Gültig ab" dadurch
|
||||||
|
// erst um 1-2 Uhr morgens greifen und "Gültig bis" bereits zu Beginn des
|
||||||
|
// gewählten Tages enden, statt dessen ganzen Tag abzudecken. Die drei
|
||||||
|
// Zahlen-Komponenten werden daher explizit in der lokalen Zeitzone des
|
||||||
|
// Browsers zusammengesetzt.
|
||||||
|
function parseLocalStartOfDay(value: string): Date {
|
||||||
|
const [year, month, day] = value.split("-").map(Number);
|
||||||
|
return new Date(year, month - 1, day, 0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLocalEndOfDay(value: string): Date {
|
||||||
|
const [year, month, day] = value.split("-").map(Number);
|
||||||
|
return new Date(year, month - 1, day, 23, 59, 59, 999);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rein visueller Ableitungs-Status fürs Filtern/Badges - ändert nichts an
|
||||||
|
// DiscountCode.isActive selbst (das bleibt der manuelle An/Aus-Schalter,
|
||||||
|
// siehe deactivateDiscountCode). Reihenfolge ist Priorität: ein deaktivierter
|
||||||
|
// Code ist "inactive" unabhängig von allem anderen; ein abgelaufener oder
|
||||||
|
// ausgeschöpfter Code ist trotz isActive=true nicht mehr einlösbar und darf
|
||||||
|
// daher nicht als "Aktiv" markiert werden (sonst hält ein Manager einen
|
||||||
|
// unbrauchbaren Code für nutzbar und findet ihn fälschlich im Aktiv-Filter).
|
||||||
|
function getDisplayStatus(code: DiscountCodeDTO): DisplayStatus {
|
||||||
|
if (!code.isActive) return "inactive";
|
||||||
|
if (code.expiresAt && new Date(code.expiresAt) < new Date()) return "expired";
|
||||||
|
if (code.startsAt && new Date(code.startsAt) > new Date()) return "scheduled";
|
||||||
|
if (code.maxRedemptions !== null && code.redeemedCount >= code.maxRedemptions) return "exhausted";
|
||||||
|
return "active";
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_STYLES: Record<DisplayStatus, { 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" },
|
||||||
|
scheduled: { label: "Geplant", bg: "bg-blue-100", text: "text-blue-700" },
|
||||||
|
exhausted: { label: "Ausgeschöpft", bg: "bg-orange-100", text: "text-orange-700" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const DiscountCodeList = ({
|
||||||
|
codes,
|
||||||
|
courses,
|
||||||
|
onCreate,
|
||||||
|
onDeactivate,
|
||||||
|
isSaving,
|
||||||
|
}: DiscountCodeListProps) => {
|
||||||
|
const [form, setForm] = useState(emptyForm);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||||
|
|
||||||
|
const courseOptions = courses.map((c) => ({ value: c.id, label: c.name }));
|
||||||
|
const courseNameById = useMemo(() => new Map(courses.map((c) => [c.id, c.name])), [courses]);
|
||||||
|
|
||||||
|
const filteredCodes = useMemo(() => {
|
||||||
|
const query = search.trim().toUpperCase();
|
||||||
|
return codes.filter((c) => {
|
||||||
|
if (query && !c.code.includes(query)) return false;
|
||||||
|
if (statusFilter !== "all" && getDisplayStatus(c) !== statusFilter) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [codes, search, statusFilter]);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
// onCreate kann rejecten (Netzwerk-/Serverfehler, siehe SettingsDiscountsPageView) -
|
||||||
|
// der Aufrufer meldet das bereits per Toast, hier nur verhindern, dass das
|
||||||
|
// Formular bei einem Fehler trotzdem als "fertig" geschlossen wird.
|
||||||
|
let success = false;
|
||||||
|
try {
|
||||||
|
success = await onCreate({
|
||||||
|
code: form.code,
|
||||||
|
type: form.type,
|
||||||
|
value: form.value,
|
||||||
|
scope: form.scope,
|
||||||
|
courseIds: form.courseIds,
|
||||||
|
maxRedemptions: form.maxRedemptions.trim() ? Number(form.maxRedemptions) : null,
|
||||||
|
startsAt: form.startsAt ? parseLocalStartOfDay(form.startsAt) : null,
|
||||||
|
expiresAt: form.expiresAt ? parseLocalEndOfDay(form.expiresAt) : null,
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (success) {
|
||||||
|
setForm(emptyForm);
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
{codes.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
|
<div className="relative sm:w-60">
|
||||||
|
<Search
|
||||||
|
className="absolute left-3 top-1/2 -translate-y-1/2 z-10 pointer-events-none text-gray-400"
|
||||||
|
size={16}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Code suchen…"
|
||||||
|
className="pl-9 bg-white/60 backdrop-blur-xl border-white/40 placeholder:text-gray-400"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
|
||||||
|
<SelectTrigger className="bg-white/60 backdrop-blur-xl border-white/40 w-full sm:w-44">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Alle Status</SelectItem>
|
||||||
|
<SelectItem value="active">Aktiv</SelectItem>
|
||||||
|
<SelectItem value="scheduled">Geplant</SelectItem>
|
||||||
|
<SelectItem value="exhausted">Ausgeschöpft</SelectItem>
|
||||||
|
<SelectItem value="expired">Abgelaufen</SelectItem>
|
||||||
|
<SelectItem value="inactive">Deaktiviert</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isCreating && (
|
||||||
|
<Button size="sm" onClick={() => setIsCreating(true)} className="sm:ml-auto shrink-0">
|
||||||
|
<Plus size={14} className="mr-1.5" />
|
||||||
|
Rabattcode erstellen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isCreating && (
|
||||||
|
<div className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-xl p-4 space-y-3">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs text-gray-500">Code</label>
|
||||||
|
<Input
|
||||||
|
value={form.code}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, code: e.target.value.toUpperCase() }))}
|
||||||
|
placeholder="z.B. SOMMER25"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="flex-1 flex flex-col gap-1">
|
||||||
|
<label className="text-xs text-gray-500">Typ</label>
|
||||||
|
<Select
|
||||||
|
value={form.type}
|
||||||
|
onValueChange={(v) => setForm((f) => ({ ...f, type: v as "percent" | "fixed" }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="percent">Prozent</SelectItem>
|
||||||
|
<SelectItem value="fixed">Fixbetrag</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex flex-col gap-1">
|
||||||
|
<label className="text-xs text-gray-500">
|
||||||
|
{form.type === "percent" ? "Rabatt in %" : "Rabatt in €"}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={form.type === "percent" ? 1 : 0.01}
|
||||||
|
max={form.type === "percent" ? 100 : undefined}
|
||||||
|
step={form.type === "percent" ? 1 : 0.01}
|
||||||
|
value={form.type === "percent" ? form.value : form.value / 100}
|
||||||
|
onChange={(e) => {
|
||||||
|
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 }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 border border-white/60 bg-white/40 backdrop-blur-xl rounded-xl p-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={form.scope === "all_courses" ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => setForm((f) => ({ ...f, scope: "all_courses" }))}
|
||||||
|
>
|
||||||
|
Alle Kurse
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={form.scope === "specific_courses" ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => setForm((f) => ({ ...f, scope: "specific_courses" }))}
|
||||||
|
>
|
||||||
|
Bestimmte Kurse
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{form.scope === "specific_courses" && (
|
||||||
|
<MultiSelectDropdown
|
||||||
|
label="Kurse"
|
||||||
|
selected={form.courseIds}
|
||||||
|
onSelect={(values) => setForm((f) => ({ ...f, courseIds: values }))}
|
||||||
|
options={courseOptions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1 max-w-xs">
|
||||||
|
<label className="text-xs text-gray-500">
|
||||||
|
Maximale Einlösungen (leer = unbegrenzt)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={form.maxRedemptions}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, maxRedemptions: e.target.value }))}
|
||||||
|
placeholder="z.B. 50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="flex-1 flex flex-col gap-1">
|
||||||
|
<label className="text-xs text-gray-500">Gültig ab (optional)</label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={form.startsAt}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, startsAt: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex flex-col gap-1">
|
||||||
|
<label className="text-xs text-gray-500">Gültig bis (optional)</label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={form.expiresAt}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, expiresAt: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={isSaving || form.code.trim().length < 3}
|
||||||
|
>
|
||||||
|
{isSaving ? "Speichert…" : "Erstellen"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setIsCreating(false);
|
||||||
|
setForm(emptyForm);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{codes.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500 border border-white/60 bg-white/40 backdrop-blur-xl rounded-xl p-4">
|
||||||
|
Noch keine Rabattcodes angelegt.
|
||||||
|
</p>
|
||||||
|
) : filteredCodes.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500 border border-white/60 bg-white/40 backdrop-blur-xl rounded-xl p-4">
|
||||||
|
Keine Rabattcodes gefunden.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{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 (
|
||||||
|
<div
|
||||||
|
key={c.id}
|
||||||
|
className={`relative border border-white/60 bg-white/55 backdrop-blur-xl rounded-xl p-4 space-y-3 transition-opacity ${
|
||||||
|
status !== "active" ? "opacity-70" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<div className="shrink-0 flex items-center justify-center size-9 rounded-lg bg-blue-50 text-blue-600">
|
||||||
|
<Tag size={16} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-base font-bold text-gray-900 font-mono tracking-wide truncate">
|
||||||
|
{c.code}
|
||||||
|
</p>
|
||||||
|
<span
|
||||||
|
className={`inline-block text-xs font-semibold px-2 py-0.5 rounded-full mt-0.5 ${statusStyle.bg} ${statusStyle.text}`}
|
||||||
|
>
|
||||||
|
{statusStyle.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{c.isActive && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-red-500 hover:bg-red-50 hover:text-red-600 shrink-0"
|
||||||
|
onClick={() => {
|
||||||
|
// onDeactivate kann rejecten - der Aufrufer zeigt den
|
||||||
|
// Fehler bereits per Toast, hier nur die unhandled
|
||||||
|
// rejection verhindern.
|
||||||
|
onDeactivate(c.id).catch(() => {});
|
||||||
|
}}
|
||||||
|
disabled={isSaving}
|
||||||
|
title="Deaktivieren"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 text-2xl font-bold text-gray-900">
|
||||||
|
{c.type === "percent" ? (
|
||||||
|
<Percent size={18} className="text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<Coins size={18} className="text-gray-400" />
|
||||||
|
)}
|
||||||
|
{c.type === "percent" ? `${c.value}%` : `${(c.value / 100).toFixed(2)} €`}
|
||||||
|
<span className="text-xs font-normal text-gray-400 ml-1">Rabatt</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-gray-500">
|
||||||
|
<Layers size={13} className="shrink-0 text-gray-400" />
|
||||||
|
{c.scope === "all_courses" ? (
|
||||||
|
<span>Gilt für alle Kurse</span>
|
||||||
|
) : (
|
||||||
|
<span className="truncate">
|
||||||
|
Gilt für:{" "}
|
||||||
|
{c.courseIds.map((id) => courseNameById.get(id) ?? "Unbekannt").join(", ")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(c.startsAt || c.expiresAt) && (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-gray-500">
|
||||||
|
<Calendar size={13} className="shrink-0 text-gray-400" />
|
||||||
|
<span>
|
||||||
|
{formatDate(c.startsAt)} – {formatDate(c.expiresAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1 pt-1">
|
||||||
|
<div className="flex items-center justify-between text-xs text-gray-500">
|
||||||
|
<span>Eingelöst</span>
|
||||||
|
<span className="font-medium text-gray-700">
|
||||||
|
{c.redeemedCount}
|
||||||
|
{c.maxRedemptions !== null ? ` / ${c.maxRedemptions}` : " (unbegrenzt)"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{redemptionPercent !== null && (
|
||||||
|
<div className="h-1.5 w-full rounded-full bg-gray-200 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full ${redemptionPercent >= 100 ? "bg-orange-400" : "bg-blue-500"}`}
|
||||||
|
style={{ width: `${redemptionPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DiscountCodeList;
|
||||||
|
|
@ -1,5 +1,202 @@
|
||||||
const SettingsDiscountsPageView = () => {
|
"use client";
|
||||||
return <div>SettingsDiscountsPageView</div>;
|
|
||||||
|
import { useRef, useState, useTransition } from "react";
|
||||||
|
import { Tag, AlertTriangle } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/src/components/ui/select";
|
||||||
|
import DiscountCodeList from "../components/DiscountCodeList";
|
||||||
|
import {
|
||||||
|
getDiscountCodes,
|
||||||
|
createDiscountCode,
|
||||||
|
deactivateDiscountCode,
|
||||||
|
getBusinessCoursesForDiscountPicker,
|
||||||
|
type DiscountCodeDTO,
|
||||||
|
} from "../../actions/discount-actions";
|
||||||
|
import type { DiscountCodeInput } from "../../schemas/discount-code-schema";
|
||||||
|
|
||||||
|
interface SettingsDiscountsPageViewProps {
|
||||||
|
businesses: { id: string; name: string }[];
|
||||||
|
initialCodes: DiscountCodeDTO[];
|
||||||
|
initialCourses: { id: string; name: string }[];
|
||||||
|
initialCoursesError: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SettingsDiscountsPageView = ({
|
||||||
|
businesses,
|
||||||
|
initialCodes,
|
||||||
|
initialCourses,
|
||||||
|
initialCoursesError,
|
||||||
|
}: SettingsDiscountsPageViewProps) => {
|
||||||
|
const [selectedBusinessId, setSelectedBusinessId] = useState(businesses[0]?.id ?? "");
|
||||||
|
const [codes, setCodes] = useState(initialCodes);
|
||||||
|
const [courses, setCourses] = useState(initialCourses);
|
||||||
|
// Getrennt vom Codes-Ladefehler: ein fehlgeschlagener Kurs-Abruf darf die
|
||||||
|
// Codeliste nicht blockieren (die ist unabhängig ladbar), aber der Manager
|
||||||
|
// muss sehen, dass die Kursauswahl für "spezifische Kurse" unvollständig ist.
|
||||||
|
const [coursesError, setCoursesError] = useState(initialCoursesError);
|
||||||
|
// Analog zur Stornierungsseite: eine fehlgeschlagene Anfrage darf nicht als
|
||||||
|
// leere Codeliste dargestellt werden, sonst wirkt es so, als gäbe es für das
|
||||||
|
// neue Business einfach keine Codes statt eines Ladefehlers.
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
const [isLoading, startLoading] = useTransition();
|
||||||
|
const [isSaving, startSaving] = useTransition();
|
||||||
|
|
||||||
|
// Verhindert, dass eine spät ankommende Antwort für ein bereits verlassenes
|
||||||
|
// Business die Anzeige des inzwischen ausgewählten Business überschreibt
|
||||||
|
// (zwei schnelle Wechsel können außer der Reihe auflösen).
|
||||||
|
const requestSeq = useRef(0);
|
||||||
|
|
||||||
|
const handleBusinessChange = (businessId: string) => {
|
||||||
|
const previousBusinessId = selectedBusinessId;
|
||||||
|
const seq = ++requestSeq.current;
|
||||||
|
setSelectedBusinessId(businessId);
|
||||||
|
setLoadError(null);
|
||||||
|
setCoursesError(null);
|
||||||
|
startLoading(async () => {
|
||||||
|
const [codesResult, coursesResult] = await Promise.all([
|
||||||
|
getDiscountCodes(businessId),
|
||||||
|
getBusinessCoursesForDiscountPicker(businessId),
|
||||||
|
]);
|
||||||
|
if (seq !== requestSeq.current) return; // veraltete Antwort, ein neuerer Wechsel läuft bereits
|
||||||
|
|
||||||
|
if (!codesResult.success) {
|
||||||
|
setSelectedBusinessId(previousBusinessId);
|
||||||
|
setLoadError(codesResult.error);
|
||||||
|
toast.error(codesResult.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCodes(codesResult.codes);
|
||||||
|
if (coursesResult.success) {
|
||||||
|
setCourses(coursesResult.courses);
|
||||||
|
setCoursesError(null);
|
||||||
|
} else {
|
||||||
|
// Vorherige Kursliste/Auswahl bewusst NICHT auf [] zurücksetzen - sonst
|
||||||
|
// sieht ein bereits mit "spezifische Kurse" konfigurierter Code so aus,
|
||||||
|
// als gäbe es keine Kurse, obwohl nur der Ladeversuch fehlschlug.
|
||||||
|
setCoursesError(coursesResult.error);
|
||||||
|
toast.error(coursesResult.error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// startTransition()'s Callback läuft asynchron nach dem Aufruf von
|
||||||
|
// startSaving() - ein try/catch AUSSERHALB von startSaving fängt einen Wurf
|
||||||
|
// im Callback nicht ab, weil der Callback erst später (mikrotask-versetzt)
|
||||||
|
// läuft. Ohne das innere try/catch würde die äußere Promise bei einem Fehler
|
||||||
|
// (Netzwerk, Serialisierung) nie resolven/rejecten und ewig pending bleiben.
|
||||||
|
const handleCreate = (input: DiscountCodeInput): Promise<boolean> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
startSaving(async () => {
|
||||||
|
try {
|
||||||
|
const result = await createDiscountCode(selectedBusinessId, input);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success("Rabattcode erstellt.");
|
||||||
|
setCodes((prev) => [result.code, ...prev]);
|
||||||
|
resolve(true);
|
||||||
|
} else {
|
||||||
|
toast.error(result.error);
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Rabattcode konnte nicht erstellt werden.");
|
||||||
|
reject(error instanceof Error ? error : new Error(String(error)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeactivate = (discountCodeId: string): Promise<boolean> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
startSaving(async () => {
|
||||||
|
try {
|
||||||
|
const result = await deactivateDiscountCode(selectedBusinessId, discountCodeId);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success("Rabattcode deaktiviert.");
|
||||||
|
setCodes((prev) =>
|
||||||
|
prev.map((c) => (c.id === discountCodeId ? { ...c, isActive: false } : c))
|
||||||
|
);
|
||||||
|
resolve(true);
|
||||||
|
} else {
|
||||||
|
toast.error(result.error);
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Rabattcode konnte nicht deaktiviert werden.");
|
||||||
|
reject(error instanceof Error ? error : new Error(String(error)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl flex items-center gap-2 font-bold">
|
||||||
|
<Tag /> Rabattcodes
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Erstelle Rabattcodes für deine Kurse — prozentual oder als Fixbetrag, mit optionalem
|
||||||
|
Nutzungslimit und Gültigkeitszeitraum.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{businesses.length === 0 ? (
|
||||||
|
<div className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-xl p-6 text-center">
|
||||||
|
<p className="text-sm text-gray-500">Du hast noch keine Businesses angelegt.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{businesses.length > 1 && (
|
||||||
|
<Select value={selectedBusinessId} onValueChange={handleBusinessChange}>
|
||||||
|
<SelectTrigger className="bg-white/60 backdrop-blur-xl border-white/40 w-full sm:w-64">
|
||||||
|
<SelectValue placeholder="Business" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{businesses.map((b) => (
|
||||||
|
<SelectItem key={b.id} value={b.id}>
|
||||||
|
{b.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadError ? (
|
||||||
|
<div className="flex gap-2 text-sm bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
|
||||||
|
<AlertTriangle size={16} className="shrink-0 mt-0.5" />
|
||||||
|
<p>{loadError} Bitte lade die Seite neu, um die Rabattcodes zu bearbeiten.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={isLoading ? "opacity-60 transition-opacity" : "transition-opacity"}>
|
||||||
|
{coursesError && (
|
||||||
|
<div className="flex gap-2 text-sm bg-orange-50 border border-orange-200 rounded-lg p-4 text-orange-700 mb-4">
|
||||||
|
<AlertTriangle size={16} className="shrink-0 mt-0.5" />
|
||||||
|
<p>
|
||||||
|
Kurse konnten nicht geladen werden ({coursesError}). Die Auswahl "Bestimmte
|
||||||
|
Kurse" ist unvollständig, bis die Seite neu geladen wird.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DiscountCodeList
|
||||||
|
key={selectedBusinessId}
|
||||||
|
codes={codes}
|
||||||
|
courses={courses}
|
||||||
|
onCreate={handleCreate}
|
||||||
|
onDeactivate={handleDeactivate}
|
||||||
|
isSaving={isSaving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SettingsDiscountsPageView;
|
export default SettingsDiscountsPageView;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue