see comments

This commit is contained in:
Rajpreet0 2026-09-13 19:58:17 +02:00
parent db514248a2
commit f6f1973086
11 changed files with 512 additions and 184 deletions

View file

@ -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

View file

@ -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;

View file

@ -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])
}

View file

@ -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>
);

View file

@ -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,

View file

@ -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;

View file

@ -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) {

View file

@ -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,

View file

@ -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;
}

View file

@ -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"
>

View file

@ -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 &quot;Bestimmte
Kurse&quot; ist unvollständig, bis die Seite neu geladen wird.
</p>
</div>
)}
<DiscountCodeList
key={selectedBusinessId}
codes={codes}