mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 20:51:18 +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");
|
CREATE INDEX "CourseBooking_discountCodeId_idx" ON "CourseBooking"("discountCodeId");
|
||||||
|
|
||||||
-- AddForeignKey
|
-- 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;
|
ALTER TABLE "CourseBooking" ADD CONSTRAINT "CourseBooking_discountCodeId_fkey" FOREIGN KEY ("discountCodeId") REFERENCES "DiscountCode"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- 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
|
// Rabattcode eines Business. scope = "specific_courses" schränkt die Gültigkeit
|
||||||
// über courseIds ein (Array statt Join-Tabelle, analog Course.trainers/sport —
|
// über courseIds ein (Array statt Join-Tabelle, analog Course.trainers/sport —
|
||||||
// hier reicht eine Handvoll IDs pro Code, kein eigenes Modell nötig).
|
// hier reicht eine Handvoll IDs pro Code, kein eigenes Modell nötig).
|
||||||
// redeemedCount wird ausschließlich im Stripe-Webhook per {increment: 1} erhöht,
|
// redeemedCount zählt RESERVIERTE Slots (status "reserved" ODER "confirmed"),
|
||||||
// niemals beim Checkout-Start (siehe DiscountRedemption).
|
// 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 {
|
model DiscountCode {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
businessId String
|
businessId String
|
||||||
|
|
@ -294,20 +297,33 @@ model DiscountCode {
|
||||||
@@index([businessId])
|
@@index([businessId])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ein Eintrag pro tatsächlich eingelöstem Rabattcode (nur bei bezahlter Buchung,
|
// Reserviert einen Rabattcode-Slot BEIM CHECKOUT-START, nicht erst nach
|
||||||
// siehe Webhook handleCheckoutCompleted). Der @@unique-Constraint erzwingt
|
// Zahlungsbestätigung - sonst könnten mehrere gleichzeitige Checkouts (versch.
|
||||||
// "1x pro User" race-condition-sicher auf DB-Ebene, unabhängig von Anwendungslogik.
|
// 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 {
|
model DiscountRedemption {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
discountCodeId String
|
discountCodeId String
|
||||||
userId String
|
userId String
|
||||||
bookingId String @unique
|
stripeSessionId String @unique
|
||||||
discountCode DiscountCode @relation(fields: [discountCodeId], references: [id], onDelete: Cascade)
|
bookingId String? @unique
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
status String @default("reserved") // "reserved" | "confirmed"
|
||||||
booking CourseBooking @relation(fields: [bookingId], references: [id], onDelete: Cascade)
|
discountCode DiscountCode @relation(fields: [discountCodeId], references: [id], onDelete: Cascade)
|
||||||
createdAt DateTime @default(now())
|
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])
|
@@unique([discountCodeId, userId])
|
||||||
@@index([discountCodeId])
|
@@index([discountCodeId])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
|
@@index([status])
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ export default async function SettingsDiscountPage() {
|
||||||
businesses={businesses}
|
businesses={businesses}
|
||||||
initialCodes={codesResult?.success ? codesResult.codes : []}
|
initialCodes={codesResult?.success ? codesResult.codes : []}
|
||||||
initialCourses={coursesResult?.success ? coursesResult.courses : []}
|
initialCourses={coursesResult?.success ? coursesResult.courses : []}
|
||||||
|
initialCoursesError={coursesResult && !coursesResult.success ? coursesResult.error : null}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@ import { stripe, calculatePlatformFeeCents, eurosToCents } from "@/src/lib/strip
|
||||||
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 {
|
import {
|
||||||
validateDiscountCode,
|
reserveDiscountCode,
|
||||||
|
releaseDiscountReservation,
|
||||||
calculateDiscountedPriceCents,
|
calculateDiscountedPriceCents,
|
||||||
} from "@/src/modules/settings/lib/discount-validation";
|
} 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 baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
|
||||||
const originalPriceCents = eurosToCents(course.price);
|
const originalPriceCents = eurosToCents(course.price);
|
||||||
|
|
||||||
// Rabattcode ist optional — ein ungültiger/abgelaufener Code lässt die
|
// Ein hier eingegebener Code wurde clientseitig bereits über die
|
||||||
// Buchung bewusst NICHT scheitern, sondern wird einfach ignoriert (Preis
|
// Preview-Action als gültig angezeigt - wird er bei dieser erneuten,
|
||||||
// bleibt regulär). Harte Fehler nur bei explizit falscher Eingabe wären
|
// server-seitigen Prüfung ungültig (Kontingent/Zeitfenster liefen
|
||||||
// hier unnötig reibungsvoll für den Kunden; die UI zeigt die Ablehnung
|
// zwischenzeitlich ab), MUSS die Anfrage abgelehnt werden. Ihn still zu
|
||||||
// bereits vorab über eine separate Preview-Action (siehe booking UI).
|
// 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 priceCents = originalPriceCents;
|
||||||
let discountAmountCents = 0;
|
let discountAmountCents = 0;
|
||||||
let appliedDiscountCodeId: string | null = null;
|
let appliedDiscountCodeId: string | null = null;
|
||||||
|
let reservationToken: string | null = null;
|
||||||
if (discountCode?.trim()) {
|
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,
|
code: discountCode,
|
||||||
businessId: course.business.id,
|
businessId: course.business.id,
|
||||||
courseId: course.id,
|
courseId: course.id,
|
||||||
userId: isGuest ? null : userData!.id,
|
userId: userData!.id,
|
||||||
});
|
});
|
||||||
if (validation.valid) {
|
if (!reservation.reserved) {
|
||||||
const result = calculateDiscountedPriceCents(originalPriceCents, validation.discountCode);
|
return NextResponse.json({ error: reservation.reason }, { status: 400 });
|
||||||
priceCents = result.discountedPriceCents;
|
|
||||||
discountAmountCents = result.discountAmountCents;
|
|
||||||
appliedDiscountCodeId = validation.discountCode.id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
// 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 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,
|
||||||
|
|
@ -238,22 +294,28 @@ export async function POST(request: NextRequest) {
|
||||||
guestName: isGuest ? (guestName?.trim() ?? "") : "",
|
guestName: isGuest ? (guestName?.trim() ?? "") : "",
|
||||||
discountCodeId: appliedDiscountCodeId ?? "",
|
discountCodeId: appliedDiscountCodeId ?? "",
|
||||||
discountAmountCents: String(discountAmountCents),
|
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,
|
||||||
discountCodeId: appliedDiscountCodeId ?? "",
|
// ohne dass je eine Buchung zustande kommt.
|
||||||
discountAmountCents: String(discountAmountCents),
|
if (reservationToken) {
|
||||||
},
|
await releaseDiscountReservation(prisma, reservationToken).catch((releaseError) => {
|
||||||
customer_email: customerEmail,
|
console.error(
|
||||||
success_url: `${baseUrl}/courses/book/${course.id}/success?session_id={CHECKOUT_SESSION_ID}`,
|
`Rabattcode-Reservierung ${reservationToken} konnte nach fehlgeschlagener ` +
|
||||||
cancel_url: `${baseUrl}/courses/book/${course.id}/cancel`,
|
`Stripe-Session nicht freigegeben werden:`,
|
||||||
});
|
releaseError
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw stripeError;
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
url: session.url,
|
url: session.url,
|
||||||
|
|
|
||||||
|
|
@ -13,7 +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 { 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.
|
* 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-
|
// discountAmountCents ist ein Snapshot vom Checkout-Start (in der Session-
|
||||||
// Metadata eingefroren) — bewusst NICHT aus dem aktuellen DiscountCode neu
|
// Metadata eingefroren) — bewusst NICHT aus dem aktuellen DiscountCode neu
|
||||||
// berechnet, damit eine spätere Code-Änderung die Rechnung dieser Buchung
|
// berechnet, damit eine spätere Code-Änderung die Rechnung dieser Buchung
|
||||||
// nicht rückwirkend verändert.
|
// nicht rückwirkend verändert. discountReservationToken verknüpft diese
|
||||||
const businessId = session.metadata?.businessId;
|
// 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 discountCodeId = session.metadata?.discountCodeId || null;
|
||||||
const discountAmountCents = Number(session.metadata?.discountAmountCents ?? "0") || 0;
|
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.
|
||||||
|
|
@ -172,9 +179,9 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
||||||
paymentIntentId,
|
paymentIntentId,
|
||||||
amountPaidCents,
|
amountPaidCents,
|
||||||
platformFeeCents,
|
platformFeeCents,
|
||||||
businessId,
|
|
||||||
discountCodeId,
|
discountCodeId,
|
||||||
discountAmountCents,
|
discountAmountCents,
|
||||||
|
discountReservationToken,
|
||||||
treatDistinctSessionAsDuplicate: true,
|
treatDistinctSessionAsDuplicate: true,
|
||||||
})
|
})
|
||||||
: await runCapacityTransactionGuest({
|
: await runCapacityTransactionGuest({
|
||||||
|
|
@ -225,9 +232,9 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
||||||
paymentIntentId,
|
paymentIntentId,
|
||||||
amountPaidCents,
|
amountPaidCents,
|
||||||
platformFeeCents,
|
platformFeeCents,
|
||||||
businessId,
|
|
||||||
discountCodeId,
|
discountCodeId,
|
||||||
discountAmountCents,
|
discountAmountCents,
|
||||||
|
discountReservationToken,
|
||||||
treatDistinctSessionAsDuplicate: true,
|
treatDistinctSessionAsDuplicate: true,
|
||||||
})
|
})
|
||||||
: await runCapacityTransactionGuest({
|
: await runCapacityTransactionGuest({
|
||||||
|
|
@ -270,9 +277,9 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
||||||
paymentIntentId,
|
paymentIntentId,
|
||||||
amountPaidCents,
|
amountPaidCents,
|
||||||
platformFeeCents,
|
platformFeeCents,
|
||||||
businessId,
|
|
||||||
discountCodeId,
|
discountCodeId,
|
||||||
discountAmountCents,
|
discountAmountCents,
|
||||||
|
discountReservationToken,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
@ -452,9 +459,9 @@ async function runCapacityTransactionUser(params: {
|
||||||
paymentIntentId: string | null;
|
paymentIntentId: string | null;
|
||||||
amountPaidCents: number;
|
amountPaidCents: number;
|
||||||
platformFeeCents: number;
|
platformFeeCents: number;
|
||||||
businessId?: string;
|
|
||||||
discountCodeId?: string | null;
|
discountCodeId?: string | null;
|
||||||
discountAmountCents?: number;
|
discountAmountCents?: number;
|
||||||
|
discountReservationToken?: string | null;
|
||||||
treatDistinctSessionAsDuplicate?: boolean;
|
treatDistinctSessionAsDuplicate?: boolean;
|
||||||
}): Promise<CapacityOutcome> {
|
}): Promise<CapacityOutcome> {
|
||||||
const {
|
const {
|
||||||
|
|
@ -464,9 +471,9 @@ async function runCapacityTransactionUser(params: {
|
||||||
paymentIntentId,
|
paymentIntentId,
|
||||||
amountPaidCents,
|
amountPaidCents,
|
||||||
platformFeeCents,
|
platformFeeCents,
|
||||||
businessId,
|
|
||||||
discountCodeId = null,
|
discountCodeId = null,
|
||||||
discountAmountCents = 0,
|
discountAmountCents = 0,
|
||||||
|
discountReservationToken = null,
|
||||||
treatDistinctSessionAsDuplicate = false,
|
treatDistinctSessionAsDuplicate = false,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
|
|
@ -585,18 +592,18 @@ async function runCapacityTransactionUser(params: {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Re-Validierung + Redemption/Increment innerhalb DIESER Transaktion -
|
// Das Kontingent wurde bereits beim Checkout-Start verbraucht (siehe
|
||||||
// ein zwischenzeitlich ausgeschöpfter/abgelaufener Code lässt die
|
// reserveDiscountCode) - hier wird die Reservierung nur noch mit dieser
|
||||||
// Buchung bewusst NICHT scheitern (Zahlung ist schon erfolgt), sondern
|
// Buchung verknüpft und auf "confirmed" gesetzt. Existiert keine
|
||||||
// wird hier still übersprungen: booking.discountCodeId/discountAmount
|
// "reserved"-Zeile mehr für dieses Token (z.B. weil
|
||||||
// bleiben zwar gesetzt (Snapshot für die Rechnung), aber ohne
|
// checkout.session.expired sie zwischenzeitlich freigegeben hat, obwohl
|
||||||
// DiscountRedemption-Zeile zählt der Code-Verbrauch nicht doppelt.
|
// die Zahlung tatsächlich noch durchkam), tut confirmDiscountReservation
|
||||||
if (discountCodeId && businessId) {
|
// nichts - die Buchung bleibt trotzdem bestehen (Zahlung ist schon
|
||||||
await redeemDiscountCode(tx, {
|
// erfolgt), nur booking.discountCodeId/discountAmount bleiben als
|
||||||
discountCodeId,
|
// Snapshot für die Rechnung gesetzt, ohne verbuchte Redemption.
|
||||||
businessId,
|
if (discountReservationToken) {
|
||||||
courseId,
|
await confirmDiscountReservation(tx, {
|
||||||
userId,
|
reservationToken: discountReservationToken,
|
||||||
bookingId: booking.id,
|
bookingId: booking.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -869,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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -308,8 +308,11 @@ export async function getCourseSummaryForConfirmation(courseId: string, sessionI
|
||||||
let paidPrice = course.price;
|
let paidPrice = course.price;
|
||||||
let discountAmount: number | null = null;
|
let discountAmount: number | null = null;
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
const booking = await prisma.courseBooking.findUnique({
|
// findFirst statt findUnique mit stripeSessionId allein: eine URL, die
|
||||||
where: { stripeSessionId: sessionId },
|
// 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 },
|
select: { amountPaid: true, discountAmount: true },
|
||||||
});
|
});
|
||||||
if (booking?.amountPaid !== null && booking?.amountPaid !== undefined) {
|
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." };
|
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({
|
const created = await prisma.discountCode.create({
|
||||||
data: {
|
data: {
|
||||||
businessId,
|
businessId,
|
||||||
|
|
@ -85,7 +101,7 @@ export async function createDiscountCode(
|
||||||
type: parsed.data.type,
|
type: parsed.data.type,
|
||||||
value: parsed.data.value,
|
value: parsed.data.value,
|
||||||
scope: parsed.data.scope,
|
scope: parsed.data.scope,
|
||||||
courseIds: parsed.data.scope === "specific_courses" ? parsed.data.courseIds : [],
|
courseIds: parsed.data.scope === "specific_courses" ? courseIds : [],
|
||||||
maxRedemptions: parsed.data.maxRedemptions,
|
maxRedemptions: parsed.data.maxRedemptions,
|
||||||
startsAt: parsed.data.startsAt,
|
startsAt: parsed.data.startsAt,
|
||||||
expiresAt: parsed.data.expiresAt,
|
expiresAt: parsed.data.expiresAt,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
import type { prisma as prismaClient } from "@/src/lib/prisma";
|
import type { prisma as prismaClient } from "@/src/lib/prisma";
|
||||||
|
|
||||||
// Erlaubt sowohl den globalen `prisma`-Client (Checkout-Route, Vorab-Prüfung)
|
// 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: true; discountCode: { id: string; type: string; value: number } }
|
||||||
| { valid: false; reason: string };
|
| { valid: false; reason: string };
|
||||||
|
|
||||||
// Wird an ZWEI Stellen aufgerufen: (1) beim Checkout-Start für sofortiges
|
// Rein lesende Prüfung ohne Seiteneffekte - wird von reserveDiscountCode()
|
||||||
// User-Feedback, (2) erneut INNERHALB der Serializable-Transaktion im
|
// (Checkout-Start) und previewDiscountCode() (Vorschau vor dem Checkout)
|
||||||
// Stripe-Webhook als Source of Truth. Muss deshalb rein lesend und ohne
|
// genutzt. Das eigentliche Verbrauchen des Kontingents passiert in
|
||||||
// Seiteneffekte bleiben (keine Redemption/Increment hier) - das Verbuchen
|
// reserveDiscountCode() gleich danach, nicht hier.
|
||||||
// passiert ausschließlich in redeemDiscountCode() nach erfolgreicher Zahlung.
|
|
||||||
export async function validateDiscountCode(
|
export async function validateDiscountCode(
|
||||||
db: PrismaOrTx,
|
db: PrismaOrTx,
|
||||||
params: { code: string; businessId: string; courseId: string; userId: string | null }
|
params: { code: string; businessId: string; courseId: string; userId: string | null }
|
||||||
|
|
@ -94,61 +94,149 @@ export function calculateDiscountedPriceCents(
|
||||||
return { discountedPriceCents, 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,
|
// Wird NUR innerhalb der Serializable-Transaktion im Webhook aufgerufen,
|
||||||
// nachdem die Zahlung bereits bestätigt ist und die CourseBooking-Zeile
|
// nachdem die Zahlung bereits bestätigt ist und die CourseBooking-Zeile
|
||||||
// bereits existiert (bookingId erforderlich, siehe DiscountRedemption.bookingId
|
// bereits existiert. Verknüpft die beim Checkout-Start angelegte Reservierung
|
||||||
// @unique). Validiert den Code ein zweites Mal (Kontingent/Zeitfenster/
|
// (siehe reserveDiscountCode) mit der jetzt existierenden Buchung und setzt
|
||||||
// 1x-pro-User können sich seit dem Checkout-Start geändert haben) und legt bei
|
// sie auf "confirmed" - der Kontingent-Slot wurde bereits bei der Reservierung
|
||||||
// Erfolg atomar Redemption + Counter-Increment an. Wird der Code zwischenzeitlich
|
// verbraucht, hier wird nur noch die Buchungs-Zuordnung nachgetragen.
|
||||||
// ungültig, tut diese Funktion nichts (kein Fehler) - die Buchung wurde vom
|
// Existiert keine "reserved"-Zeile mehr für dieses Token (z.B. weil
|
||||||
// Aufrufer bereits unabhängig davon zum (schon berechneten) rabattierten Preis
|
// checkout.session.expired sie zwischenzeitlich freigegeben hat, obwohl die
|
||||||
// bestätigt, nur ohne verbuchte Redemption. Das ist bewusstes Verhalten.
|
// Zahlung tatsächlich noch durchkam - ein seltenes, aber mögliches Timing),
|
||||||
export async function redeemDiscountCode(
|
// 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,
|
tx: PrismaOrTx,
|
||||||
params: {
|
params: { reservationToken: string; bookingId: string }
|
||||||
discountCodeId: string;
|
|
||||||
businessId: string;
|
|
||||||
courseId: string;
|
|
||||||
userId: string | null;
|
|
||||||
bookingId: string;
|
|
||||||
}
|
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (!params.userId) return false; // Guests haben keine User-Redemption-Zuordnung, siehe validateDiscountCode
|
const result = await tx.discountRedemption.updateMany({
|
||||||
|
where: { stripeSessionId: params.reservationToken, status: "reserved" },
|
||||||
const discountCode = await tx.discountCode.findUnique({
|
data: { status: "confirmed", bookingId: params.bookingId },
|
||||||
where: { id: params.discountCodeId },
|
|
||||||
});
|
});
|
||||||
if (!discountCode || discountCode.businessId !== params.businessId) return false;
|
return result.count > 0;
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,34 +43,51 @@ const emptyForm = {
|
||||||
expiresAt: "" as string,
|
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 {
|
function formatDate(date: Date | null): string {
|
||||||
if (!date) return "—";
|
if (!date) return "—";
|
||||||
return new Intl.DateTimeFormat("de-DE", { dateStyle: "medium" }).format(date);
|
return new Intl.DateTimeFormat("de-DE", { dateStyle: "medium" }).format(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isExpired(code: DiscountCodeDTO): boolean {
|
// new Date("YYYY-MM-DD") parst als UTC-Mitternacht, nicht als lokaler
|
||||||
return !!code.expiresAt && new Date(code.expiresAt) < new Date();
|
// 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
|
// Rein visueller Ableitungs-Status fürs Filtern/Badges - ändert nichts an
|
||||||
// DiscountCode.isActive selbst (das bleibt der manuelle An/Aus-Schalter,
|
// DiscountCode.isActive selbst (das bleibt der manuelle An/Aus-Schalter,
|
||||||
// siehe deactivateDiscountCode). "expired" hat Vorrang vor "active", weil ein
|
// siehe deactivateDiscountCode). Reihenfolge ist Priorität: ein deaktivierter
|
||||||
// abgelaufener Code trotz isActive=true nicht mehr einlösbar ist.
|
// Code ist "inactive" unabhängig von allem anderen; ein abgelaufener oder
|
||||||
function getDisplayStatus(code: DiscountCodeDTO): "active" | "inactive" | "expired" {
|
// 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.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";
|
return "active";
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_STYLES: Record<
|
const STATUS_STYLES: Record<DisplayStatus, { label: string; bg: string; text: string }> = {
|
||||||
"active" | "inactive" | "expired",
|
|
||||||
{ label: string; bg: string; text: string }
|
|
||||||
> = {
|
|
||||||
active: { label: "Aktiv", bg: "bg-green-100", text: "text-green-700" },
|
active: { label: "Aktiv", bg: "bg-green-100", text: "text-green-700" },
|
||||||
inactive: { label: "Deaktiviert", bg: "bg-gray-200", text: "text-gray-600" },
|
inactive: { label: "Deaktiviert", bg: "bg-gray-200", text: "text-gray-600" },
|
||||||
expired: { label: "Abgelaufen", bg: "bg-orange-100", text: "text-orange-700" },
|
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 = ({
|
const DiscountCodeList = ({
|
||||||
|
|
@ -98,17 +115,25 @@ const DiscountCodeList = ({
|
||||||
}, [codes, search, statusFilter]);
|
}, [codes, search, statusFilter]);
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
const success = await onCreate({
|
// onCreate kann rejecten (Netzwerk-/Serverfehler, siehe SettingsDiscountsPageView) -
|
||||||
code: form.code,
|
// der Aufrufer meldet das bereits per Toast, hier nur verhindern, dass das
|
||||||
type: form.type,
|
// Formular bei einem Fehler trotzdem als "fertig" geschlossen wird.
|
||||||
value: form.value,
|
let success = false;
|
||||||
scope: form.scope,
|
try {
|
||||||
courseIds: form.courseIds,
|
success = await onCreate({
|
||||||
maxRedemptions: form.maxRedemptions.trim() ? Number(form.maxRedemptions) : null,
|
code: form.code,
|
||||||
startsAt: form.startsAt ? new Date(form.startsAt) : null,
|
type: form.type,
|
||||||
expiresAt: form.expiresAt ? new Date(form.expiresAt) : null,
|
value: form.value,
|
||||||
isActive: true,
|
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) {
|
if (success) {
|
||||||
setForm(emptyForm);
|
setForm(emptyForm);
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
|
|
@ -139,8 +164,10 @@ const DiscountCodeList = ({
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">Alle Status</SelectItem>
|
<SelectItem value="all">Alle Status</SelectItem>
|
||||||
<SelectItem value="active">Aktiv</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="expired">Abgelaufen</SelectItem>
|
||||||
|
<SelectItem value="inactive">Deaktiviert</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -329,7 +356,12 @@ const DiscountCodeList = ({
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="text-red-500 hover:bg-red-50 hover:text-red-600 shrink-0"
|
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}
|
disabled={isSaving}
|
||||||
title="Deaktivieren"
|
title="Deaktivieren"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
import { useRef, useState, useTransition } from "react";
|
||||||
import { Tag, AlertTriangle } from "lucide-react";
|
import { Tag, AlertTriangle } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
|
|
@ -24,16 +24,22 @@ interface SettingsDiscountsPageViewProps {
|
||||||
businesses: { id: string; name: string }[];
|
businesses: { id: string; name: string }[];
|
||||||
initialCodes: DiscountCodeDTO[];
|
initialCodes: DiscountCodeDTO[];
|
||||||
initialCourses: { id: string; name: string }[];
|
initialCourses: { id: string; name: string }[];
|
||||||
|
initialCoursesError: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SettingsDiscountsPageView = ({
|
const SettingsDiscountsPageView = ({
|
||||||
businesses,
|
businesses,
|
||||||
initialCodes,
|
initialCodes,
|
||||||
initialCourses,
|
initialCourses,
|
||||||
|
initialCoursesError,
|
||||||
}: SettingsDiscountsPageViewProps) => {
|
}: SettingsDiscountsPageViewProps) => {
|
||||||
const [selectedBusinessId, setSelectedBusinessId] = useState(businesses[0]?.id ?? "");
|
const [selectedBusinessId, setSelectedBusinessId] = useState(businesses[0]?.id ?? "");
|
||||||
const [codes, setCodes] = useState(initialCodes);
|
const [codes, setCodes] = useState(initialCodes);
|
||||||
const [courses, setCourses] = useState(initialCourses);
|
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
|
// Analog zur Stornierungsseite: eine fehlgeschlagene Anfrage darf nicht als
|
||||||
// leere Codeliste dargestellt werden, sonst wirkt es so, als gäbe es für das
|
// leere Codeliste dargestellt werden, sonst wirkt es so, als gäbe es für das
|
||||||
// neue Business einfach keine Codes statt eines Ladefehlers.
|
// neue Business einfach keine Codes statt eines Ladefehlers.
|
||||||
|
|
@ -41,15 +47,24 @@ const SettingsDiscountsPageView = ({
|
||||||
const [isLoading, startLoading] = useTransition();
|
const [isLoading, startLoading] = useTransition();
|
||||||
const [isSaving, startSaving] = 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 handleBusinessChange = (businessId: string) => {
|
||||||
const previousBusinessId = selectedBusinessId;
|
const previousBusinessId = selectedBusinessId;
|
||||||
|
const seq = ++requestSeq.current;
|
||||||
setSelectedBusinessId(businessId);
|
setSelectedBusinessId(businessId);
|
||||||
setLoadError(null);
|
setLoadError(null);
|
||||||
|
setCoursesError(null);
|
||||||
startLoading(async () => {
|
startLoading(async () => {
|
||||||
const [codesResult, coursesResult] = await Promise.all([
|
const [codesResult, coursesResult] = await Promise.all([
|
||||||
getDiscountCodes(businessId),
|
getDiscountCodes(businessId),
|
||||||
getBusinessCoursesForDiscountPicker(businessId),
|
getBusinessCoursesForDiscountPicker(businessId),
|
||||||
]);
|
]);
|
||||||
|
if (seq !== requestSeq.current) return; // veraltete Antwort, ein neuerer Wechsel läuft bereits
|
||||||
|
|
||||||
if (!codesResult.success) {
|
if (!codesResult.success) {
|
||||||
setSelectedBusinessId(previousBusinessId);
|
setSelectedBusinessId(previousBusinessId);
|
||||||
setLoadError(codesResult.error);
|
setLoadError(codesResult.error);
|
||||||
|
|
@ -57,39 +72,63 @@ const SettingsDiscountsPageView = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setCodes(codesResult.codes);
|
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> => {
|
const handleCreate = (input: DiscountCodeInput): Promise<boolean> => {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve, reject) => {
|
||||||
startSaving(async () => {
|
startSaving(async () => {
|
||||||
const result = await createDiscountCode(selectedBusinessId, input);
|
try {
|
||||||
if (result.success) {
|
const result = await createDiscountCode(selectedBusinessId, input);
|
||||||
toast.success("Rabattcode erstellt.");
|
if (result.success) {
|
||||||
setCodes((prev) => [result.code, ...prev]);
|
toast.success("Rabattcode erstellt.");
|
||||||
resolve(true);
|
setCodes((prev) => [result.code, ...prev]);
|
||||||
} else {
|
resolve(true);
|
||||||
toast.error(result.error);
|
} else {
|
||||||
resolve(false);
|
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> => {
|
const handleDeactivate = (discountCodeId: string): Promise<boolean> => {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve, reject) => {
|
||||||
startSaving(async () => {
|
startSaving(async () => {
|
||||||
const result = await deactivateDiscountCode(selectedBusinessId, discountCodeId);
|
try {
|
||||||
if (result.success) {
|
const result = await deactivateDiscountCode(selectedBusinessId, discountCodeId);
|
||||||
toast.success("Rabattcode deaktiviert.");
|
if (result.success) {
|
||||||
setCodes((prev) =>
|
toast.success("Rabattcode deaktiviert.");
|
||||||
prev.map((c) => (c.id === discountCodeId ? { ...c, isActive: false } : c))
|
setCodes((prev) =>
|
||||||
);
|
prev.map((c) => (c.id === discountCodeId ? { ...c, isActive: false } : c))
|
||||||
resolve(true);
|
);
|
||||||
} else {
|
resolve(true);
|
||||||
toast.error(result.error);
|
} else {
|
||||||
resolve(false);
|
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>
|
||||||
) : (
|
) : (
|
||||||
<div className={isLoading ? "opacity-60 transition-opacity" : "transition-opacity"}>
|
<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
|
<DiscountCodeList
|
||||||
key={selectedBusinessId}
|
key={selectedBusinessId}
|
||||||
codes={codes}
|
codes={codes}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue