mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 19:51:17 +00:00
see comments
This commit is contained in:
parent
db514248a2
commit
f6f1973086
11 changed files with 512 additions and 184 deletions
|
|
@ -55,6 +55,16 @@ CREATE UNIQUE INDEX "DiscountRedemption_discountCodeId_userId_key" ON "DiscountR
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -269,8 +269,11 @@ model Notification {
|
|||
// Rabattcode eines Business. scope = "specific_courses" schränkt die Gültigkeit
|
||||
// über courseIds ein (Array statt Join-Tabelle, analog Course.trainers/sport —
|
||||
// hier reicht eine Handvoll IDs pro Code, kein eigenes Modell nötig).
|
||||
// redeemedCount wird ausschließlich im Stripe-Webhook per {increment: 1} erhöht,
|
||||
// niemals beim Checkout-Start (siehe DiscountRedemption).
|
||||
// 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
|
||||
|
|
@ -294,20 +297,33 @@ model DiscountCode {
|
|||
@@index([businessId])
|
||||
}
|
||||
|
||||
// Ein Eintrag pro tatsächlich eingelöstem Rabattcode (nur bei bezahlter Buchung,
|
||||
// siehe Webhook handleCheckoutCompleted). Der @@unique-Constraint erzwingt
|
||||
// "1x pro User" race-condition-sicher auf DB-Ebene, unabhängig von Anwendungslogik.
|
||||
// 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
|
||||
bookingId String @unique
|
||||
discountCode DiscountCode @relation(fields: [discountCodeId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
booking CourseBooking @relation(fields: [bookingId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
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])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export default async function SettingsDiscountPage() {
|
|||
businesses={businesses}
|
||||
initialCodes={codesResult?.success ? codesResult.codes : []}
|
||||
initialCourses={coursesResult?.success ? coursesResult.courses : []}
|
||||
initialCoursesError={coursesResult && !coursesResult.success ? coursesResult.error : null}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import { stripe, calculatePlatformFeeCents, eurosToCents } from "@/src/lib/strip
|
|||
import { getUserData, isUser } from "@/src/lib/auth/getUser";
|
||||
import { isManagerOrEmployeeEmail } from "@/src/lib/auth/account-role-by-email";
|
||||
import {
|
||||
validateDiscountCode,
|
||||
reserveDiscountCode,
|
||||
releaseDiscountReservation,
|
||||
calculateDiscountedPriceCents,
|
||||
} from "@/src/modules/settings/lib/discount-validation";
|
||||
|
||||
|
|
@ -179,27 +180,68 @@ export async function POST(request: NextRequest) {
|
|||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
|
||||
const originalPriceCents = eurosToCents(course.price);
|
||||
|
||||
// Rabattcode ist optional — ein ungültiger/abgelaufener Code lässt die
|
||||
// Buchung bewusst NICHT scheitern, sondern wird einfach ignoriert (Preis
|
||||
// bleibt regulär). Harte Fehler nur bei explizit falscher Eingabe wären
|
||||
// hier unnötig reibungsvoll für den Kunden; die UI zeigt die Ablehnung
|
||||
// bereits vorab über eine separate Preview-Action (siehe booking UI).
|
||||
// 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()) {
|
||||
const validation = await validateDiscountCode(prisma, {
|
||||
// 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: isGuest ? null : userData!.id,
|
||||
userId: userData!.id,
|
||||
});
|
||||
if (validation.valid) {
|
||||
const result = calculateDiscountedPriceCents(originalPriceCents, validation.discountCode);
|
||||
priceCents = result.discountedPriceCents;
|
||||
discountAmountCents = result.discountAmountCents;
|
||||
appliedDiscountCodeId = validation.discountCode.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,
|
||||
|
|
@ -209,25 +251,39 @@ export async function POST(request: NextRequest) {
|
|||
|
||||
const customerEmail = isGuest ? guestEmail!.trim().toLowerCase() : userData!.email;
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: "payment",
|
||||
line_items: [
|
||||
{
|
||||
price_data: {
|
||||
currency: "eur",
|
||||
product_data: {
|
||||
name: course.name,
|
||||
description: `${course.business.name} - ${new Date(course.date).toLocaleDateString("de-DE")} ${course.timeFrom}`,
|
||||
let session;
|
||||
try {
|
||||
session = await stripe.checkout.sessions.create({
|
||||
mode: "payment",
|
||||
line_items: [
|
||||
{
|
||||
price_data: {
|
||||
currency: "eur",
|
||||
product_data: {
|
||||
name: course.name,
|
||||
description: `${course.business.name} - ${new Date(course.date).toLocaleDateString("de-DE")} ${course.timeFrom}`,
|
||||
},
|
||||
unit_amount: priceCents,
|
||||
},
|
||||
unit_amount: priceCents,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
payment_intent_data: {
|
||||
application_fee_amount: platformFeeCents,
|
||||
transfer_data: {
|
||||
destination: course.business.stripeAccountId,
|
||||
},
|
||||
metadata: {
|
||||
courseId: course.id,
|
||||
userId: isGuest ? "" : userData!.id,
|
||||
businessId: course.business.id,
|
||||
isGuest: isGuest ? "true" : "false",
|
||||
guestEmail: isGuest ? customerEmail : "",
|
||||
guestName: isGuest ? (guestName?.trim() ?? "") : "",
|
||||
discountCodeId: appliedDiscountCodeId ?? "",
|
||||
discountAmountCents: String(discountAmountCents),
|
||||
discountReservationToken: reservationToken ?? "",
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
payment_intent_data: {
|
||||
application_fee_amount: platformFeeCents,
|
||||
transfer_data: {
|
||||
destination: course.business.stripeAccountId,
|
||||
},
|
||||
metadata: {
|
||||
courseId: course.id,
|
||||
|
|
@ -238,22 +294,28 @@ export async function POST(request: NextRequest) {
|
|||
guestName: isGuest ? (guestName?.trim() ?? "") : "",
|
||||
discountCodeId: appliedDiscountCodeId ?? "",
|
||||
discountAmountCents: String(discountAmountCents),
|
||||
discountReservationToken: reservationToken ?? "",
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
courseId: course.id,
|
||||
userId: isGuest ? "" : userData!.id,
|
||||
businessId: course.business.id,
|
||||
isGuest: isGuest ? "true" : "false",
|
||||
guestEmail: isGuest ? customerEmail : "",
|
||||
guestName: isGuest ? (guestName?.trim() ?? "") : "",
|
||||
discountCodeId: appliedDiscountCodeId ?? "",
|
||||
discountAmountCents: String(discountAmountCents),
|
||||
},
|
||||
customer_email: customerEmail,
|
||||
success_url: `${baseUrl}/courses/book/${course.id}/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${baseUrl}/courses/book/${course.id}/cancel`,
|
||||
});
|
||||
customer_email: customerEmail,
|
||||
success_url: `${baseUrl}/courses/book/${course.id}/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${baseUrl}/courses/book/${course.id}/cancel`,
|
||||
});
|
||||
} catch (stripeError) {
|
||||
// Reservierung wurde bereits vor der Session-Erstellung angelegt (siehe
|
||||
// oben) - schlägt die Session-Erstellung selbst fehl, muss der belegte
|
||||
// Slot wieder freigegeben werden, sonst bliebe er dauerhaft blockiert,
|
||||
// ohne dass je eine Buchung zustande kommt.
|
||||
if (reservationToken) {
|
||||
await releaseDiscountReservation(prisma, reservationToken).catch((releaseError) => {
|
||||
console.error(
|
||||
`Rabattcode-Reservierung ${reservationToken} konnte nach fehlgeschlagener ` +
|
||||
`Stripe-Session nicht freigegeben werden:`,
|
||||
releaseError
|
||||
);
|
||||
});
|
||||
}
|
||||
throw stripeError;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
url: session.url,
|
||||
|
|
|
|||
|
|
@ -13,7 +13,10 @@ import {
|
|||
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
|
||||
import { notifyBusinessManager } from "@/src/modules/notifications/lib/create-notification";
|
||||
import { isManagerOrEmployeeEmail } from "@/src/lib/auth/account-role-by-email";
|
||||
import { redeemDiscountCode } from "@/src/modules/settings/lib/discount-validation";
|
||||
import {
|
||||
confirmDiscountReservation,
|
||||
releaseDiscountReservation,
|
||||
} from "@/src/modules/settings/lib/discount-validation";
|
||||
|
||||
/**
|
||||
* Sucht einen registrierten User anhand der E-Mail, case-insensitive.
|
||||
|
|
@ -111,10 +114,14 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
|||
// discountAmountCents ist ein Snapshot vom Checkout-Start (in der Session-
|
||||
// Metadata eingefroren) — bewusst NICHT aus dem aktuellen DiscountCode neu
|
||||
// berechnet, damit eine spätere Code-Änderung die Rechnung dieser Buchung
|
||||
// nicht rückwirkend verändert.
|
||||
const businessId = session.metadata?.businessId;
|
||||
// nicht rückwirkend verändert. discountReservationToken verknüpft diese
|
||||
// Zahlung mit der beim Checkout-Start bereits angelegten Reservierung
|
||||
// (siehe reserveDiscountCode) - das Kontingent wurde dort schon verbraucht,
|
||||
// hier wird die Reservierung nur noch auf "confirmed" gesetzt und mit der
|
||||
// gleich entstehenden CourseBooking-Zeile verknüpft.
|
||||
const discountCodeId = session.metadata?.discountCodeId || null;
|
||||
const discountAmountCents = Number(session.metadata?.discountAmountCents ?? "0") || 0;
|
||||
const discountReservationToken = session.metadata?.discountReservationToken || null;
|
||||
|
||||
// Guest-Flow und Auth-Flow teilen die Kapazitäts-Logik, aber nutzen
|
||||
// unterschiedliche Identifikatoren und DB-Operationen.
|
||||
|
|
@ -172,9 +179,9 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
|||
paymentIntentId,
|
||||
amountPaidCents,
|
||||
platformFeeCents,
|
||||
businessId,
|
||||
discountCodeId,
|
||||
discountAmountCents,
|
||||
discountReservationToken,
|
||||
treatDistinctSessionAsDuplicate: true,
|
||||
})
|
||||
: await runCapacityTransactionGuest({
|
||||
|
|
@ -225,9 +232,9 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
|||
paymentIntentId,
|
||||
amountPaidCents,
|
||||
platformFeeCents,
|
||||
businessId,
|
||||
discountCodeId,
|
||||
discountAmountCents,
|
||||
discountReservationToken,
|
||||
treatDistinctSessionAsDuplicate: true,
|
||||
})
|
||||
: await runCapacityTransactionGuest({
|
||||
|
|
@ -270,9 +277,9 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
|||
paymentIntentId,
|
||||
amountPaidCents,
|
||||
platformFeeCents,
|
||||
businessId,
|
||||
discountCodeId,
|
||||
discountAmountCents,
|
||||
discountReservationToken,
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
|
@ -452,9 +459,9 @@ async function runCapacityTransactionUser(params: {
|
|||
paymentIntentId: string | null;
|
||||
amountPaidCents: number;
|
||||
platformFeeCents: number;
|
||||
businessId?: string;
|
||||
discountCodeId?: string | null;
|
||||
discountAmountCents?: number;
|
||||
discountReservationToken?: string | null;
|
||||
treatDistinctSessionAsDuplicate?: boolean;
|
||||
}): Promise<CapacityOutcome> {
|
||||
const {
|
||||
|
|
@ -464,9 +471,9 @@ async function runCapacityTransactionUser(params: {
|
|||
paymentIntentId,
|
||||
amountPaidCents,
|
||||
platformFeeCents,
|
||||
businessId,
|
||||
discountCodeId = null,
|
||||
discountAmountCents = 0,
|
||||
discountReservationToken = null,
|
||||
treatDistinctSessionAsDuplicate = false,
|
||||
} = params;
|
||||
|
||||
|
|
@ -585,18 +592,18 @@ async function runCapacityTransactionUser(params: {
|
|||
},
|
||||
});
|
||||
|
||||
// Re-Validierung + Redemption/Increment innerhalb DIESER Transaktion -
|
||||
// ein zwischenzeitlich ausgeschöpfter/abgelaufener Code lässt die
|
||||
// Buchung bewusst NICHT scheitern (Zahlung ist schon erfolgt), sondern
|
||||
// wird hier still übersprungen: booking.discountCodeId/discountAmount
|
||||
// bleiben zwar gesetzt (Snapshot für die Rechnung), aber ohne
|
||||
// DiscountRedemption-Zeile zählt der Code-Verbrauch nicht doppelt.
|
||||
if (discountCodeId && businessId) {
|
||||
await redeemDiscountCode(tx, {
|
||||
discountCodeId,
|
||||
businessId,
|
||||
courseId,
|
||||
userId,
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
|
|
@ -869,6 +876,21 @@ async function getApplicationFeeFromPaymentIntent(paymentIntentId: string): Prom
|
|||
async function handleCheckoutExpired(session: Stripe.Checkout.Session) {
|
||||
const courseId = session.metadata?.courseId;
|
||||
const isGuest = session.metadata?.isGuest === "true";
|
||||
const discountReservationToken = session.metadata?.discountReservationToken || null;
|
||||
|
||||
// Gibt einen beim Checkout-Start reservierten Rabattcode-Slot wieder frei
|
||||
// (siehe reserveDiscountCode), da diese Session nie bezahlt wurde. Läuft
|
||||
// VOR dem frühen "if (!courseId) return" unten, da eine Reservierung auch
|
||||
// ohne courseId theoretisch bestehen könnte und sonst nie freigegeben würde.
|
||||
if (discountReservationToken) {
|
||||
await releaseDiscountReservation(prisma, discountReservationToken).catch((error) => {
|
||||
console.error(
|
||||
`Rabattcode-Reservierung ${discountReservationToken} konnte nach abgelaufener ` +
|
||||
`Checkout-Session nicht freigegeben werden:`,
|
||||
error
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (!courseId) return;
|
||||
|
||||
|
|
|
|||
|
|
@ -308,8 +308,11 @@ export async function getCourseSummaryForConfirmation(courseId: string, sessionI
|
|||
let paidPrice = course.price;
|
||||
let discountAmount: number | null = null;
|
||||
if (sessionId) {
|
||||
const booking = await prisma.courseBooking.findUnique({
|
||||
where: { stripeSessionId: 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) {
|
||||
|
|
|
|||
|
|
@ -78,6 +78,22 @@ export async function createDiscountCode(
|
|||
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,
|
||||
|
|
@ -85,7 +101,7 @@ export async function createDiscountCode(
|
|||
type: parsed.data.type,
|
||||
value: parsed.data.value,
|
||||
scope: parsed.data.scope,
|
||||
courseIds: parsed.data.scope === "specific_courses" ? parsed.data.courseIds : [],
|
||||
courseIds: parsed.data.scope === "specific_courses" ? courseIds : [],
|
||||
maxRedemptions: parsed.data.maxRedemptions,
|
||||
startsAt: parsed.data.startsAt,
|
||||
expiresAt: parsed.data.expiresAt,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { randomUUID } from "crypto";
|
||||
import type { prisma as prismaClient } from "@/src/lib/prisma";
|
||||
|
||||
// Erlaubt sowohl den globalen `prisma`-Client (Checkout-Route, Vorab-Prüfung)
|
||||
|
|
@ -12,11 +13,10 @@ export type DiscountValidationResult =
|
|||
| { valid: true; discountCode: { id: string; type: string; value: number } }
|
||||
| { valid: false; reason: string };
|
||||
|
||||
// Wird an ZWEI Stellen aufgerufen: (1) beim Checkout-Start für sofortiges
|
||||
// User-Feedback, (2) erneut INNERHALB der Serializable-Transaktion im
|
||||
// Stripe-Webhook als Source of Truth. Muss deshalb rein lesend und ohne
|
||||
// Seiteneffekte bleiben (keine Redemption/Increment hier) - das Verbuchen
|
||||
// passiert ausschließlich in redeemDiscountCode() nach erfolgreicher Zahlung.
|
||||
// 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 }
|
||||
|
|
@ -94,61 +94,149 @@ export function calculateDiscountedPriceCents(
|
|||
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 (bookingId erforderlich, siehe DiscountRedemption.bookingId
|
||||
// @unique). Validiert den Code ein zweites Mal (Kontingent/Zeitfenster/
|
||||
// 1x-pro-User können sich seit dem Checkout-Start geändert haben) und legt bei
|
||||
// Erfolg atomar Redemption + Counter-Increment an. Wird der Code zwischenzeitlich
|
||||
// ungültig, tut diese Funktion nichts (kein Fehler) - die Buchung wurde vom
|
||||
// Aufrufer bereits unabhängig davon zum (schon berechneten) rabattierten Preis
|
||||
// bestätigt, nur ohne verbuchte Redemption. Das ist bewusstes Verhalten.
|
||||
export async function redeemDiscountCode(
|
||||
// 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: {
|
||||
discountCodeId: string;
|
||||
businessId: string;
|
||||
courseId: string;
|
||||
userId: string | null;
|
||||
bookingId: string;
|
||||
}
|
||||
params: { reservationToken: string; bookingId: string }
|
||||
): Promise<boolean> {
|
||||
if (!params.userId) return false; // Guests haben keine User-Redemption-Zuordnung, siehe validateDiscountCode
|
||||
|
||||
const discountCode = await tx.discountCode.findUnique({
|
||||
where: { id: params.discountCodeId },
|
||||
const result = await tx.discountRedemption.updateMany({
|
||||
where: { stripeSessionId: params.reservationToken, status: "reserved" },
|
||||
data: { status: "confirmed", bookingId: params.bookingId },
|
||||
});
|
||||
if (!discountCode || discountCode.businessId !== params.businessId) return false;
|
||||
|
||||
const now = new Date();
|
||||
const stillValid =
|
||||
discountCode.isActive &&
|
||||
(!discountCode.startsAt || now >= discountCode.startsAt) &&
|
||||
(!discountCode.expiresAt || now <= discountCode.expiresAt) &&
|
||||
(discountCode.scope !== "specific_courses" ||
|
||||
discountCode.courseIds.includes(params.courseId)) &&
|
||||
(discountCode.maxRedemptions === null ||
|
||||
discountCode.redeemedCount < discountCode.maxRedemptions);
|
||||
if (!stillValid) return false;
|
||||
|
||||
const existingRedemption = await tx.discountRedemption.findUnique({
|
||||
where: {
|
||||
discountCodeId_userId: { discountCodeId: discountCode.id, userId: params.userId },
|
||||
},
|
||||
});
|
||||
if (existingRedemption) return false;
|
||||
|
||||
await tx.discountCode.update({
|
||||
where: { id: discountCode.id },
|
||||
data: { redeemedCount: { increment: 1 } },
|
||||
});
|
||||
await tx.discountRedemption.create({
|
||||
data: {
|
||||
discountCodeId: discountCode.id,
|
||||
userId: params.userId,
|
||||
bookingId: params.bookingId,
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
return result.count > 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,34 +43,51 @@ const emptyForm = {
|
|||
expiresAt: "" as string,
|
||||
};
|
||||
|
||||
type StatusFilter = "all" | "active" | "inactive" | "expired";
|
||||
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);
|
||||
}
|
||||
|
||||
function isExpired(code: DiscountCodeDTO): boolean {
|
||||
return !!code.expiresAt && new Date(code.expiresAt) < new 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). "expired" hat Vorrang vor "active", weil ein
|
||||
// abgelaufener Code trotz isActive=true nicht mehr einlösbar ist.
|
||||
function getDisplayStatus(code: DiscountCodeDTO): "active" | "inactive" | "expired" {
|
||||
// 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 (isExpired(code)) return "expired";
|
||||
if (code.expiresAt && new Date(code.expiresAt) < new Date()) return "expired";
|
||||
if (code.startsAt && new Date(code.startsAt) > new Date()) return "scheduled";
|
||||
if (code.maxRedemptions !== null && code.redeemedCount >= code.maxRedemptions) return "exhausted";
|
||||
return "active";
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<
|
||||
"active" | "inactive" | "expired",
|
||||
{ label: string; bg: string; text: string }
|
||||
> = {
|
||||
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 = ({
|
||||
|
|
@ -98,17 +115,25 @@ const DiscountCodeList = ({
|
|||
}, [codes, search, statusFilter]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
const success = await onCreate({
|
||||
code: form.code,
|
||||
type: form.type,
|
||||
value: form.value,
|
||||
scope: form.scope,
|
||||
courseIds: form.courseIds,
|
||||
maxRedemptions: form.maxRedemptions.trim() ? Number(form.maxRedemptions) : null,
|
||||
startsAt: form.startsAt ? new Date(form.startsAt) : null,
|
||||
expiresAt: form.expiresAt ? new Date(form.expiresAt) : null,
|
||||
isActive: true,
|
||||
});
|
||||
// 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);
|
||||
|
|
@ -139,8 +164,10 @@ const DiscountCodeList = ({
|
|||
<SelectContent>
|
||||
<SelectItem value="all">Alle Status</SelectItem>
|
||||
<SelectItem value="active">Aktiv</SelectItem>
|
||||
<SelectItem value="inactive">Deaktiviert</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>
|
||||
|
|
@ -329,7 +356,12 @@ const DiscountCodeList = ({
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-red-500 hover:bg-red-50 hover:text-red-600 shrink-0"
|
||||
onClick={() => onDeactivate(c.id)}
|
||||
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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRef, useState, useTransition } from "react";
|
||||
import { Tag, AlertTriangle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
|
|
@ -24,16 +24,22 @@ 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.
|
||||
|
|
@ -41,15 +47,24 @@ const SettingsDiscountsPageView = ({
|
|||
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);
|
||||
|
|
@ -57,39 +72,63 @@ const SettingsDiscountsPageView = ({
|
|||
return;
|
||||
}
|
||||
setCodes(codesResult.codes);
|
||||
setCourses(coursesResult.success ? coursesResult.courses : []);
|
||||
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) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
startSaving(async () => {
|
||||
const result = await createDiscountCode(selectedBusinessId, input);
|
||||
if (result.success) {
|
||||
toast.success("Rabattcode erstellt.");
|
||||
setCodes((prev) => [result.code, ...prev]);
|
||||
resolve(true);
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
resolve(false);
|
||||
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) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
startSaving(async () => {
|
||||
const result = await deactivateDiscountCode(selectedBusinessId, discountCodeId);
|
||||
if (result.success) {
|
||||
toast.success("Rabattcode deaktiviert.");
|
||||
setCodes((prev) =>
|
||||
prev.map((c) => (c.id === discountCodeId ? { ...c, isActive: false } : c))
|
||||
);
|
||||
resolve(true);
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
resolve(false);
|
||||
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)));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -135,6 +174,15 @@ const SettingsDiscountsPageView = ({
|
|||
</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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue