fixed couople of bugs

This commit is contained in:
Rajpreet0 2026-08-08 00:24:28 +02:00
parent 54b57632e8
commit 6da4e9f55d
15 changed files with 436 additions and 192 deletions

View file

@ -2,19 +2,27 @@ import { Suspense } from "react";
import { requireManager } from "@/src/lib/auth/getUser";
import { getCancellationPolicy } from "@/src/modules/settings/actions/cancellation-settings-actions";
import SettingsCancellationPageView from "@/src/modules/settings/ui/views/settings-cancellation-page-view";
import type { CancellationPolicy } from "@/src/modules/settings/schemas/cancellation-policy-schema";
import SettingsLoading from "../loading";
const DEFAULT_POLICY: CancellationPolicy = {
enabled: true,
mode: "tiered",
fixedRefundPercent: 100,
tiers: [],
};
export default async function SettingsCancellationPage() {
const manager = await requireManager();
const businesses = manager.businesses.map((b) => ({ id: b.id, name: b.name }));
const firstBusinessId = businesses[0]?.id;
const policyResult = firstBusinessId ? await getCancellationPolicy(firstBusinessId) : null;
const initialTiers = policyResult?.success ? policyResult.tiers : [];
const initialPolicy = policyResult?.success ? policyResult.policy : DEFAULT_POLICY;
return (
<Suspense fallback={<SettingsLoading />}>
<SettingsCancellationPageView businesses={businesses} initialTiers={initialTiers} />
<SettingsCancellationPageView businesses={businesses} initialPolicy={initialPolicy} />
</Suspense>
);
}

View file

@ -257,10 +257,26 @@ type CapacityOutcome = {
| "already_paid"
| "already_refunded"
| "already_failed"
| "already_processed"
| "course_not_found";
bookingId?: string;
};
// Status, die eine bereits abschließend verarbeitete Buchung markieren - bei
// erneuter Zustellung DERSELBEN Checkout-Session (Stripe-Retry, manuelles
// Redeliver im Dashboard) darf der Webhook den Datensatz auf KEINEN Fall per
// upsert erneut auf "paid" setzen. Das ist explizit als Zulassungsliste
// formuliert (statt einzelner if-Zweige), damit ein künftig neu eingeführter
// paymentStatus-Wert automatisch mit abgedeckt ist, statt vergessen zu werden.
const ALREADY_PROCESSED_STATUSES = [
"paid",
"refunded",
"failed",
"partially_refunded",
"cancelled",
"refunding",
] as const;
/**
* Atomare Kapazitätsprüfung für authentifizierte User.
*/
@ -287,6 +303,13 @@ async function runCapacityTransactionUser(params: {
return { result: "already_refunded" as const, bookingId: existing.id };
if (existing.paymentStatus === "failed")
return { result: "already_failed" as const, bookingId: existing.id };
// Alle übrigen abschließenden Status (z.B. eine inzwischen erfolgte
// Stornierung: "partially_refunded"/"cancelled"/"refunding") dürfen
// NICHT erneut per upsert auf "paid" zurückgesetzt werden - das würde
// eine bereits stornierte Buchung fälschlich wieder als aktiv/bezahlt
// markieren, obwohl das Geld ganz oder teilweise bereits erstattet ist.
if (ALREADY_PROCESSED_STATUSES.includes(existing.paymentStatus as never))
return { result: "already_processed" as const, bookingId: existing.id };
}
const course = await tx.course.findUnique({

20
src/lib/course-time.ts Normal file
View file

@ -0,0 +1,20 @@
/**
* Kombiniert Course.date (Datum, i.d.R. Mitternacht UTC) mit Course.timeFrom
* ("HH:mm") zu einem exakten Zeitpunkt. Course.date allein trägt keine
* verlässliche Uhrzeit - ein reiner Datumsvergleich (courseDate < now) würde
* einen Kurs bereits ab 00:00 Uhr des Kurstages als "vergangen" einstufen,
* selbst wenn er erst abends stattfindet. Wird sowohl für die Stornierungs-
* Cutoff-Berechnung als auch für "vergangen/bevorstehend"-Anzeigen genutzt.
*
* Bei einem ungültigen timeHHmm-Wert (z.B. leerer String, kein "HH:mm"-Format)
* würde Number(...) NaN liefern; setHours(NaN, ...) ergäbe ein Invalid Date.
* Deshalb wird ein nicht-endlicher Wert explizit auf 0 (Mitternacht) abgefangen.
*/
export function combineDateAndTime(date: Date, timeHHmm: string): Date {
const [rawHours, rawMinutes] = (timeHHmm ?? "").split(":").map(Number);
const hours = Number.isFinite(rawHours) ? rawHours : 0;
const minutes = Number.isFinite(rawMinutes) ? rawMinutes : 0;
const combined = new Date(date);
combined.setHours(hours, minutes, 0, 0);
return combined;
}

View file

@ -11,6 +11,7 @@ import { prisma } from "@/src/lib/prisma";
import { getUserData, isUser } from "@/src/lib/auth/getUser";
import { unstable_cache, revalidatePath, revalidateTag } from "next/cache";
import { executeBookingCancellation } from "./cancel-booking-shared";
import { combineDateAndTime } from "@/src/lib/course-time";
export type UserBooking = {
bookingId: string;
@ -122,7 +123,7 @@ export async function getUserBookings(): Promise<
const now = new Date();
return bookingsRaw.map((b) => {
const courseDate = new Date(b.course.date);
const courseStart = combineDateAndTime(b.course.date, b.course.timeFrom);
return {
bookingId: b.id,
courseId: b.course.id,
@ -141,7 +142,7 @@ export async function getUserBookings(): Promise<
trainers: b.course.trainers,
trainersMap,
paymentStatus: b.paymentStatus,
isPast: courseDate < now,
isPast: courseStart < now,
} satisfies UserBooking;
});
},

View file

@ -107,6 +107,25 @@ export async function executeBookingCancellation(
};
}
// Erst prüfen, ob Stornierung für dieses Business überhaupt erlaubt ist,
// BEVOR die Buchung auf "refunding" geclaimt wird - sonst würde eine
// gesperrte Stornierung die Buchung trotzdem in den Zwischenstatus schieben.
const eligibility = await calculateRefundAmountCents({
businessId: booking.course.businessId,
amountPaidCents: booking.amountPaid,
courseStart,
now: new Date(),
});
if (!eligibility.cancellationAllowed) {
return {
success: false,
error: "Stornierungen sind für dieses Business derzeit nicht möglich.",
};
}
const { refundPercent, refundAmountCents } = eligibility;
// Race-Schutz: bevor Stripe kontaktiert wird, wird die Buchung atomar von
// "paid" auf den Zwischenstatus "refunding" geclaimt. Zwei parallele Aufrufe
// (Doppelklick, erneuter Server-Action-Aufruf, o.ä.) können beide den obigen
@ -123,13 +142,6 @@ export async function executeBookingCancellation(
return { success: false, error: "Stornierung läuft bereits oder wurde bereits verarbeitet." };
}
const { refundPercent, refundAmountCents } = await calculateRefundAmountCents({
businessId: booking.course.businessId,
amountPaidCents: booking.amountPaid,
courseStart,
now: new Date(),
});
// Stripe lehnt einen Refund mit amount: 0 ab ("must be greater than or equal
// to 1") - bei 0% Rückerstattung wird die Buchung storniert, ohne Stripe
// überhaupt zu kontaktieren.

View file

@ -1,30 +1,25 @@
import { prisma } from "@/src/lib/prisma";
import {
cancellationPolicySchema,
type CancellationPolicy,
type CancellationTier,
} from "@/src/modules/settings/schemas/cancellation-policy-schema";
/**
* Kombiniert Course.date (Datum) mit Course.timeFrom ("HH:mm") zu einem exakten
* Zeitpunkt. Bisher wurde im Code nur Course.date verglichen und timeFrom ignoriert,
* wodurch Stornierungs-Cutoffs am selben Tag ungenau waren.
* Bei einem ungültigen timeHHmm-Wert (z.B. leerer String, kein "HH:mm"-Format)
* würde Number(...) NaN liefern; setHours(NaN, ...) ergäbe ein Invalid Date, das
* jeden nachfolgenden Vergleich in selectRefundPercent auf false kippen und so
* fälschlich 0% Rückerstattung erzeugen würde. Deshalb wird ein nicht-endlicher
* Wert explizit auf 0 (Mitternacht) abgefangen, statt NaN durchzureichen.
*/
export function combineDateAndTime(date: Date, timeHHmm: string): Date {
const [rawHours, rawMinutes] = (timeHHmm ?? "").split(":").map(Number);
const hours = Number.isFinite(rawHours) ? rawHours : 0;
const minutes = Number.isFinite(rawMinutes) ? rawMinutes : 0;
const combined = new Date(date);
combined.setHours(hours, minutes, 0, 0);
return combined;
}
// combineDateAndTime lebt in src/lib/course-time.ts, weil es nicht nur von der
// Stornierungslogik gebraucht wird, sondern auch von "vergangen/bevorstehend"-
// Anzeigen (Meine Kurse, Kunden-Detailansicht) - re-exportiert hier, damit
// bestehende Importe aus diesem Modul weiter funktionieren.
export { combineDateAndTime } from "@/src/lib/course-time";
const DEFAULT_POLICY: CancellationPolicy = {
enabled: true,
mode: "tiered",
fixedRefundPercent: 100,
tiers: [],
};
/**
* Wählt den zutreffenden Rückerstattungsprozentsatz.
* Wählt den zutreffenden Rückerstattungsprozentsatz für den "tiered"-Modus.
*
* Eine Stufe {hoursBeforeStart: X, refundPercent: P} gilt für den Zeitraum
* "sobald weniger als X Stunden bis Kursbeginn übrig sind, bis zur nächststrengeren
@ -71,26 +66,41 @@ export function selectRefundPercent(
return 0;
}
async function loadPolicy(businessId: string | null): Promise<CancellationPolicy> {
if (!businessId) return DEFAULT_POLICY;
const business = await prisma.business.findUnique({
where: { id: businessId },
select: { cancellationPolicy: true },
});
const parsed = cancellationPolicySchema.safeParse(business?.cancellationPolicy);
// Bei fehlendem/ungültigem Json -> Fallback auf Default (Storno erlaubt, 100%).
return parsed.success ? parsed.data : DEFAULT_POLICY;
}
export type RefundEligibility = {
cancellationAllowed: boolean;
refundPercent: number;
refundAmountCents: number;
};
export async function calculateRefundAmountCents(params: {
businessId: string | null;
amountPaidCents: number;
courseStart: Date;
now: Date;
}): Promise<{ refundPercent: number; refundAmountCents: number }> {
let tiers: CancellationTier[] = [];
}): Promise<RefundEligibility> {
const policy = await loadPolicy(params.businessId);
if (params.businessId) {
const business = await prisma.business.findUnique({
where: { id: params.businessId },
select: { cancellationPolicy: true },
});
const parsed = cancellationPolicySchema.safeParse(business?.cancellationPolicy);
if (parsed.success) tiers = parsed.data.tiers;
// Bei fehlendem/ungültigem Json bleibt tiers leer -> Fallback auf 100%, kein Absturz.
if (!policy.enabled) {
return { cancellationAllowed: false, refundPercent: 0, refundAmountCents: 0 };
}
const refundPercent = selectRefundPercent(tiers, params.courseStart, params.now);
const refundPercent =
policy.mode === "fixed"
? policy.fixedRefundPercent
: selectRefundPercent(policy.tiers, params.courseStart, params.now);
const refundAmountCents = Math.round((params.amountPaidCents * refundPercent) / 100);
return { refundPercent, refundAmountCents };
return { cancellationAllowed: true, refundPercent, refundAmountCents };
}

View file

@ -5,6 +5,7 @@ import { getUserData, isUser, isManager } from "@/src/lib/auth/getUser";
import { combineDateAndTime, calculateRefundAmountCents } from "./cancellation-policy";
export type CancellationPreview = {
cancellationAllowed: boolean;
refundPercent: number;
refundAmountCents: number;
amountPaidCents: number;
@ -54,15 +55,21 @@ export async function previewBookingCancellation(
const courseStart = combineDateAndTime(booking.course.date, booking.course.timeFrom);
const { refundPercent, refundAmountCents } = await calculateRefundAmountCents({
businessId: booking.course.businessId,
amountPaidCents: booking.amountPaid,
courseStart,
now: new Date(),
});
const { cancellationAllowed, refundPercent, refundAmountCents } =
await calculateRefundAmountCents({
businessId: booking.course.businessId,
amountPaidCents: booking.amountPaid,
courseStart,
now: new Date(),
});
return {
success: true,
preview: { refundPercent, refundAmountCents, amountPaidCents: booking.amountPaid },
preview: {
cancellationAllowed,
refundPercent,
refundAmountCents,
amountPaidCents: booking.amountPaid,
},
};
}

View file

@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import { AlertTriangle } from "lucide-react";
import { Button } from "@/src/components/ui/button";
import {
@ -50,59 +50,66 @@ interface CancelBookingConfirmDialogProps {
}
/**
* Bestätigungs-Dialog vor dem Stornieren einer Buchung, gemeinsam genutzt vom
* Kunden-Self-Service-Pfad (UserBookingCard) und dem Manager-Pfad
* (kunde-booking-card). Lädt vor der Bestätigung eine Vorschau des
* Rückerstattungsbetrags und warnt sichtbar, wenn keine 100% erstattet werden.
* Innerer Inhalt, der pro Öffnen frisch gemounted wird (key={bookingId} am
* Aufrufer) - dadurch starten preview/loadingPreview bei jedem Öffnen bereits
* mit korrekten Initialwerten und der Lade-Effect muss sie nicht mehr per
* setState im Effect-Body zurücksetzen (verletzt sonst die react-hooks/
* set-state-in-effect-Regel: kein setState direkt im Effect-Body ohne
* externen Auslöser).
*/
const CancelBookingConfirmDialog = ({
open,
onOpenChange,
function CancelBookingDialogBody({
bookingId,
courseName,
variant,
onConfirm,
onClose,
onSuccess,
}: CancelBookingConfirmDialogProps) => {
}: {
bookingId: string;
courseName: string;
variant: CancelBookingCopyVariant;
onConfirm: () => Promise<{ success: true } | { success: false; error: string }>;
onClose: () => void;
onSuccess: () => void;
}) {
const [preview, setPreview] = useState<CancellationPreview | null>(null);
const [loadingPreview, setLoadingPreview] = useState(false);
const [loadingPreview, setLoadingPreview] = useState(true);
const [isPending, setIsPending] = useState(false);
const copy = VARIANT_COPY[variant];
const handleOpenChange = (nextOpen: boolean) => {
onOpenChange(nextOpen);
if (nextOpen) {
loadPreview();
} else {
setPreview(null);
}
};
useEffect(() => {
let cancelled = false;
const loadPreview = async () => {
setPreview(null);
setLoadingPreview(true);
try {
const result = await previewBookingCancellation(bookingId);
if (result.success) {
setPreview(result.preview);
} else {
toast.error(result.error);
onOpenChange(false);
(async () => {
try {
const result = await previewBookingCancellation(bookingId);
if (cancelled) return;
if (result.success) {
setPreview(result.preview);
} else {
toast.error(result.error);
onClose();
}
} catch {
if (cancelled) return;
toast.error("Rückerstattung konnte nicht berechnet werden.");
onClose();
} finally {
if (!cancelled) setLoadingPreview(false);
}
} catch {
toast.error("Rückerstattung konnte nicht berechnet werden.");
onOpenChange(false);
} finally {
setLoadingPreview(false);
}
};
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookingId]);
const handleConfirm = async () => {
setIsPending(true);
try {
const result = await onConfirm();
if (result.success) {
onOpenChange(false);
onSuccess();
} else {
toast.error(result.error);
@ -115,40 +122,48 @@ const CancelBookingConfirmDialog = ({
};
const isPartialRefund = preview !== null && preview.refundPercent < 100;
const cancellationBlocked = preview !== null && !preview.cancellationAllowed;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Buchung stornieren?</DialogTitle>
<>
<DialogHeader>
<DialogTitle>Buchung stornieren?</DialogTitle>
{!cancellationBlocked && (
<DialogDescription>
Die Buchung für <strong>{courseName}</strong> {copy.description}
</DialogDescription>
</DialogHeader>
)}
</DialogHeader>
{loadingPreview ? (
<p className="text-sm text-gray-500">Berechne Rückerstattung…</p>
) : preview ? (
isPartialRefund ? (
<div className="flex gap-2 text-sm bg-orange-50 border border-orange-200 rounded-lg p-3 text-orange-800">
<AlertTriangle size={16} className="shrink-0 mt-0.5" />
<p>
{copy.partialRefund(formatPercent(preview.refundPercent))}{" "}
<strong>{formatPrice(preview.refundAmountCents)}</strong> von{" "}
{formatPrice(preview.amountPaidCents)}.
</p>
</div>
) : (
<p className="text-sm text-gray-600">
{copy.fullRefund} ({formatPrice(preview.amountPaidCents)})
{loadingPreview ? (
<p className="text-sm text-gray-500">Berechne Rückerstattung…</p>
) : cancellationBlocked ? (
<div className="flex gap-2 text-sm bg-red-50 border border-red-200 rounded-lg p-3 text-red-700">
<AlertTriangle size={16} className="shrink-0 mt-0.5" />
<p>Stornierungen sind für diese Buchung derzeit nicht möglich.</p>
</div>
) : preview ? (
isPartialRefund ? (
<div className="flex gap-2 text-sm bg-orange-50 border border-orange-200 rounded-lg p-3 text-orange-800">
<AlertTriangle size={16} className="shrink-0 mt-0.5" />
<p>
{copy.partialRefund(formatPercent(preview.refundPercent))}{" "}
<strong>{formatPrice(preview.refundAmountCents)}</strong> von{" "}
{formatPrice(preview.amountPaidCents)}.
</p>
)
) : null}
</div>
) : (
<p className="text-sm text-gray-600">
{copy.fullRefund} ({formatPrice(preview.amountPaidCents)})
</p>
)
) : null}
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isPending}>
Abbrechen
</Button>
<DialogFooter>
<Button variant="ghost" onClick={onClose} disabled={isPending}>
{cancellationBlocked ? "Schließen" : "Abbrechen"}
</Button>
{!cancellationBlocked && (
<Button
variant="destructive"
onClick={handleConfirm}
@ -156,7 +171,49 @@ const CancelBookingConfirmDialog = ({
>
{isPending ? "Wird storniert…" : "Ja, stornieren"}
</Button>
</DialogFooter>
)}
</DialogFooter>
</>
);
}
/**
* Bestätigungs-Dialog vor dem Stornieren einer Buchung, gemeinsam genutzt vom
* Kunden-Self-Service-Pfad (UserBookingCard) und dem Manager-Pfad
* (kunde-booking-card). Lädt vor der Bestätigung eine Vorschau des
* Rückerstattungsbetrags und warnt sichtbar, wenn keine 100% erstattet werden
* oder Stornierungen für das Business deaktiviert sind.
*/
const CancelBookingConfirmDialog = ({
open,
onOpenChange,
bookingId,
courseName,
variant,
onConfirm,
onSuccess,
}: CancelBookingConfirmDialogProps) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
{/* key={bookingId} sorgt für einen frischen Mount pro Öffnen - Radix'
onOpenChange feuert nur bei intern (ESC, Overlay-Klick, DialogClose)
ausgelösten Wechseln, nicht wenn "open" von außen auf true gesetzt
wird, daher lädt CancelBookingDialogBody sich selbst beim Mount. */}
{open && (
<CancelBookingDialogBody
key={bookingId}
bookingId={bookingId}
courseName={courseName}
variant={variant}
onConfirm={onConfirm}
onClose={() => onOpenChange(false)}
onSuccess={() => {
onOpenChange(false);
onSuccess();
}}
/>
)}
</DialogContent>
</Dialog>
);

View file

@ -3,6 +3,7 @@
import { prisma } from "@/src/lib/prisma";
import { requireManager } from "@/src/lib/auth/getUser";
import { unstable_cache } from "next/cache";
import { combineDateAndTime } from "@/src/lib/course-time";
export type KundeBooking = {
bookingId: string;
@ -94,8 +95,9 @@ export async function getKundeDetail(
maxParticipants: true,
coverImage: true,
description: true,
// Anzahl aller Buchungen für diesen Kurs
_count: { select: { bookings: true } },
// Nur bezahlte Buchungen zählen als belegte Plätze - pending/failed/
// refunded/cancelled/partially_refunded blockieren keinen Platz mehr.
_count: { select: { bookings: { where: { paymentStatus: "paid" } } } },
},
},
},
@ -151,7 +153,7 @@ export async function getKundeDetail(
const now = new Date();
const bookings: KundeBooking[] = userWithBookings.courseBookings.map((b) => {
const courseDate = new Date(b.course.date);
const courseStart = combineDateAndTime(b.course.date, b.course.timeFrom);
return {
bookingId: b.id,
courseId: b.course.id,
@ -170,7 +172,7 @@ export async function getKundeDetail(
trainers: b.course.trainers,
trainersMap,
paymentStatus: b.paymentStatus,
isPast: courseDate < now,
isPast: courseStart < now,
};
});

View file

@ -8,13 +8,16 @@ export type KundeRow = {
name: string;
email: string;
telefon: string;
status: "Bezahlt" | "Storniert" | "Ausstehend";
status: "Bezahlt" | "Storniert" | "Teilweise erstattet" | "Ausstehend" | "Fehlgeschlagen";
isGuest: boolean;
};
function mapPaymentStatus(paymentStatus: string): KundeRow["status"] {
if (paymentStatus === "paid") return "Bezahlt";
if (paymentStatus === "refunded") return "Storniert";
if (paymentStatus === "refunded" || paymentStatus === "cancelled") return "Storniert";
if (paymentStatus === "partially_refunded") return "Teilweise erstattet";
if (paymentStatus === "failed") return "Fehlgeschlagen";
// "pending" und der kurzlebige Zwischenstatus "refunding" fallen hier rein.
return "Ausstehend";
}

View file

@ -16,7 +16,9 @@ import type { Kunde } from "../views/kunden-view";
const STATUS_STYLES: Record<Kunde["status"], string> = {
Bezahlt: "bg-green-100 text-green-700",
Storniert: "bg-red-100 text-red-600",
"Teilweise erstattet": "bg-orange-100 text-orange-700",
Ausstehend: "bg-yellow-100 text-yellow-700",
Fehlgeschlagen: "bg-red-100 text-red-600",
};
interface KundenTableProps {

View file

@ -5,12 +5,19 @@ import { requireManager } from "@/src/lib/auth/getUser";
import { revalidatePath } from "next/cache";
import {
cancellationPolicySchema,
type CancellationTier,
type CancellationPolicy,
} from "../schemas/cancellation-policy-schema";
const DEFAULT_POLICY: CancellationPolicy = {
enabled: true,
mode: "tiered",
fixedRefundPercent: 100,
tiers: [],
};
export async function getCancellationPolicy(
businessId: string
): Promise<{ success: true; tiers: CancellationTier[] } | { success: false; error: string }> {
): Promise<{ success: true; policy: CancellationPolicy } | { success: false; error: string }> {
// requireManager() löst bei fehlender Session/Rolle einen next/navigation-Redirect
// aus, der als spezieller Error implementiert ist - darf nicht im try/catch landen.
const manager = await requireManager();
@ -25,7 +32,7 @@ export async function getCancellationPolicy(
}
const parsed = cancellationPolicySchema.safeParse(business.cancellationPolicy);
return { success: true, tiers: parsed.success ? parsed.data.tiers : [] };
return { success: true, policy: parsed.success ? parsed.data : DEFAULT_POLICY };
} catch (error) {
console.error("Error fetching cancellation policy:", error);
return { success: false, error: "Stornierungsregeln konnten nicht geladen werden." };
@ -34,7 +41,7 @@ export async function getCancellationPolicy(
export async function saveCancellationPolicy(
businessId: string,
tiers: CancellationTier[]
policy: CancellationPolicy
): Promise<{ success: true } | { success: false; error: string }> {
const manager = await requireManager();
@ -47,17 +54,21 @@ export async function saveCancellationPolicy(
return { success: false, error: "Keine Berechtigung für dieses Business." };
}
const parsed = cancellationPolicySchema.safeParse({ tiers });
const parsed = cancellationPolicySchema.safeParse(policy);
if (!parsed.success) {
return { success: false, error: "Ungültige Stornierungsregeln." };
}
const hoursSeen = new Set<number>();
for (const tier of parsed.data.tiers) {
if (hoursSeen.has(tier.hoursBeforeStart)) {
return { success: false, error: "Zwei Stufen können nicht denselben Zeitpunkt haben." };
// Der Zeitschwellen-Check ist nur im "tiered"-Modus sinnvoll - im "fixed"-
// Modus gibt es keine Stufen, die kollidieren könnten.
if (parsed.data.mode === "tiered") {
const hoursSeen = new Set<number>();
for (const tier of parsed.data.tiers) {
if (hoursSeen.has(tier.hoursBeforeStart)) {
return { success: false, error: "Zwei Stufen können nicht denselben Zeitpunkt haben." };
}
hoursSeen.add(tier.hoursBeforeStart);
}
hoursSeen.add(tier.hoursBeforeStart);
}
const sortedTiers = [...parsed.data.tiers].sort(
@ -66,7 +77,7 @@ export async function saveCancellationPolicy(
await prisma.business.update({
where: { id: businessId },
data: { cancellationPolicy: { tiers: sortedTiers } },
data: { cancellationPolicy: { ...parsed.data, tiers: sortedTiers } },
});
revalidatePath("/settings/cancellation");

View file

@ -9,7 +9,16 @@ export const cancellationTierSchema = z.object({
refundPercent: z.number().int().min(0).max(100),
});
// "tiered": zeitgestaffelte Prozentsätze über die tiers-Liste (Standard).
// "fixed": immer derselbe fixedRefundPercent, unabhängig vom Stornierungszeitpunkt.
// enabled=false: Stornierung ist für dieses Business komplett deaktiviert - das
// ist bewusst von einer 0%-tiered-Regel unterschieden, weil dort eine
// Stornierung technisch weiterhin möglich bliebe (nur ohne Rückerstattung),
// während enabled=false den Stornieren-Button gar nicht erst anzeigt.
export const cancellationPolicySchema = z.object({
enabled: z.boolean().default(true),
mode: z.enum(["tiered", "fixed"]).default("tiered"),
fixedRefundPercent: z.number().int().min(0).max(100).default(100),
tiers: z.array(cancellationTierSchema).max(20),
});

View file

@ -16,11 +16,15 @@ import {
arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVertical, Plus, Trash2, Info } from "lucide-react";
import { GripVertical, Plus, Trash2, Info, Ban } from "lucide-react";
import { Button } from "@/src/components/ui/button";
import { Input } from "@/src/components/ui/input";
import { Switch } from "@/src/components/ui/switch";
import CancellationPolicyTimeline from "./CancellationPolicyTimeline";
import type { CancellationTier } from "../../schemas/cancellation-policy-schema";
import type {
CancellationPolicy,
CancellationTier,
} from "../../schemas/cancellation-policy-schema";
type TierRow = CancellationTier & { id: string };
@ -88,21 +92,24 @@ const SortableTierRow = ({ tier, onChange, onDelete }: SortableTierRowProps) =>
};
interface CancellationPolicyEditorProps {
initialTiers: CancellationTier[];
onSave: (tiers: CancellationTier[]) => Promise<boolean>;
initialPolicy: CancellationPolicy;
onSave: (policy: CancellationPolicy) => Promise<boolean>;
isSaving: boolean;
}
const CancellationPolicyEditor = ({
initialTiers,
initialPolicy,
onSave,
isSaving,
}: CancellationPolicyEditorProps) => {
const idPrefix = useId();
const [enabled, setEnabled] = useState(initialPolicy.enabled);
const [mode, setMode] = useState(initialPolicy.mode);
const [fixedRefundPercent, setFixedRefundPercent] = useState(initialPolicy.fixedRefundPercent);
const [rows, setRows] = useState<TierRow[]>(
initialTiers.map((tier, idx) => ({ ...tier, id: `${idPrefix}-${idx}` }))
initialPolicy.tiers.map((tier, idx) => ({ ...tier, id: `${idPrefix}-${idx}` }))
);
const [nextId, setNextId] = useState(initialTiers.length);
const [nextId, setNextId] = useState(initialPolicy.tiers.length);
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
@ -138,6 +145,11 @@ const CancellationPolicyEditor = ({
setNextId((n) => n + 1);
};
const handleFixedPercentChange = (value: number) => {
const safeValue = Number.isFinite(value) ? Math.min(100, Math.max(0, Math.round(value))) : 0;
setFixedRefundPercent(safeValue);
};
// Zeilen ohne jede Eingabe (frisch per "Stufe hinzufügen" angelegt, noch
// nicht bearbeitet) werden nicht mitgespeichert - sonst würde eine
// unbearbeitete 0h→0%-Zeile fälschlich als echte Stufe persistiert, obwohl
@ -149,8 +161,9 @@ const CancellationPolicyEditor = ({
.filter((t) => t.hoursBeforeStart > 0 || t.refundPercent > 0);
const handleSave = async () => {
const policy: CancellationPolicy = { enabled, mode, fixedRefundPercent, tiers: validTiers };
try {
const success = await onSave(validTiers);
const success = await onSave(policy);
if (!success) return;
// Nach erfolgreichem Speichern serverseitig sortierte Reihenfolge übernehmen.
const sorted = [...validTiers].sort((a, b) => b.hoursBeforeStart - a.hoursBeforeStart);
@ -163,58 +176,125 @@ const CancellationPolicyEditor = ({
return (
<div className="space-y-4">
<div className="flex gap-2 text-sm text-gray-600 border border-white/60 bg-white/40 backdrop-blur-xl rounded-xl p-4">
<Info size={16} className="shrink-0 mt-0.5 text-gray-400" />
<p>
Jede Stufe gilt <strong>ab</strong> ihrer Zeitschwelle bis zur nächststrengeren Stufe.
Storniert ein Kunde z.B. bei den Stufen 24h→50% und 12h→25% genau 15 Stunden vor
Kursbeginn, gilt die 24h-Stufe (50%), weil er die 24h-Schwelle noch nicht unterschritten
hat. Die strengste konfigurierte Stufe gilt bis Kursbeginn weiter — für{" "}
<strong>0%</strong> ab einem bestimmten Zeitpunkt muss also explizit eine eigene Stufe mit
0% angelegt werden.
</p>
{/* Storno global erlauben/sperren */}
<div className="flex items-center justify-between gap-3 border border-white/60 bg-white/55 backdrop-blur-xl rounded-xl p-4">
<div className="flex gap-2">
<Ban size={16} className="shrink-0 mt-0.5 text-gray-400" />
<div>
<p className="text-sm font-medium text-gray-900">Stornierung erlauben</p>
<p className="text-xs text-gray-500">
Ist dies deaktiviert, können Kunden und Manager gebuchte Kurse gar nicht mehr
stornieren - der Stornieren-Button wird nicht mehr angezeigt.
</p>
</div>
</div>
<Switch checked={enabled} onCheckedChange={setEnabled} />
</div>
{rows.length === 0 ? (
<p className="text-sm text-gray-500 border border-white/60 bg-white/40 backdrop-blur-xl rounded-xl p-4">
Ohne konfigurierte Stufen erhalten Kunden bei Stornierung immer 100% Rückerstattung.
</p>
) : (
{enabled && (
<>
{validTiers.length > 0 && <CancellationPolicyTimeline tiers={validTiers} />}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={rows.map((r) => r.id)} strategy={verticalListSortingStrategy}>
<div className="space-y-2">
{rows.map((tier) => (
<SortableTierRow
key={tier.id}
tier={tier}
onChange={handleChange}
onDelete={handleDelete}
/>
))}
{/* Modus: zeitgestaffelt vs. fester Prozentsatz */}
<div className="flex gap-2 border border-white/60 bg-white/55 backdrop-blur-xl rounded-xl p-2">
<Button
type="button"
variant={mode === "tiered" ? "default" : "ghost"}
size="sm"
className="flex-1"
onClick={() => setMode("tiered")}
>
Zeitbasierte Staffelung
</Button>
<Button
type="button"
variant={mode === "fixed" ? "default" : "ghost"}
size="sm"
className="flex-1"
onClick={() => setMode("fixed")}
>
Fester Prozentsatz
</Button>
</div>
{mode === "fixed" ? (
<div className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-xl p-4 space-y-3">
<p className="text-sm text-gray-600">
Kunden erhalten bei jeder Stornierung immer denselben Prozentsatz zurück, unabhängig
davon wie kurzfristig vor Kursbeginn storniert wird.
</p>
<div className="flex flex-col gap-1 max-w-xs">
<label className="text-xs text-gray-500">Rückerstattung in %</label>
<Input
type="number"
min={0}
max={100}
value={fixedRefundPercent}
onChange={(e) => handleFixedPercentChange(Number(e.target.value))}
/>
</div>
</SortableContext>
</DndContext>
<p className="text-xs text-gray-400">
Die Reihenfolge wird beim Speichern automatisch nach Zeitpunkt sortiert.
</p>
</div>
) : (
<>
<div className="flex gap-2 text-sm text-gray-600 border border-white/60 bg-white/40 backdrop-blur-xl rounded-xl p-4">
<Info size={16} className="shrink-0 mt-0.5 text-gray-400" />
<p>
Jede Stufe gilt <strong>ab</strong> ihrer Zeitschwelle bis zur nächststrengeren
Stufe. Storniert ein Kunde z.B. bei den Stufen 24h→50% und 12h→25% genau 15
Stunden vor Kursbeginn, gilt die 24h-Stufe (50%), weil er die 24h-Schwelle noch
nicht unterschritten hat. Die strengste konfigurierte Stufe gilt bis Kursbeginn
weiter — für <strong>0%</strong> ab einem bestimmten Zeitpunkt muss also explizit
eine eigene Stufe mit 0% angelegt werden.
</p>
</div>
{rows.length === 0 ? (
<p className="text-sm text-gray-500 border border-white/60 bg-white/40 backdrop-blur-xl rounded-xl p-4">
Ohne konfigurierte Stufen erhalten Kunden bei Stornierung immer 100%
Rückerstattung.
</p>
) : (
<>
{validTiers.length > 0 && <CancellationPolicyTimeline tiers={validTiers} />}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={rows.map((r) => r.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{rows.map((tier) => (
<SortableTierRow
key={tier.id}
tier={tier}
onChange={handleChange}
onDelete={handleDelete}
/>
))}
</div>
</SortableContext>
</DndContext>
<p className="text-xs text-gray-400">
Die Reihenfolge wird beim Speichern automatisch nach Zeitpunkt sortiert.
</p>
</>
)}
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleAdd}>
<Plus size={14} className="mr-1.5" />
Stufe hinzufügen
</Button>
</div>
</>
)}
</>
)}
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleAdd}>
<Plus size={14} className="mr-1.5" />
Stufe hinzufügen
</Button>
<Button size="sm" onClick={handleSave} disabled={isSaving}>
{isSaving ? "Speichert…" : "Speichern"}
</Button>
</div>
<Button size="sm" onClick={handleSave} disabled={isSaving}>
{isSaving ? "Speichert…" : "Speichern"}
</Button>
</div>
);
};

View file

@ -15,19 +15,19 @@ import {
getCancellationPolicy,
saveCancellationPolicy,
} from "../../actions/cancellation-settings-actions";
import type { CancellationTier } from "../../schemas/cancellation-policy-schema";
import type { CancellationPolicy } from "../../schemas/cancellation-policy-schema";
interface SettingsCancellationPageViewProps {
businesses: { id: string; name: string }[];
initialTiers: CancellationTier[];
initialPolicy: CancellationPolicy;
}
const SettingsCancellationPageView = ({
businesses,
initialTiers,
initialPolicy,
}: SettingsCancellationPageViewProps) => {
const [selectedBusinessId, setSelectedBusinessId] = useState(businesses[0]?.id ?? "");
const [tiers, setTiers] = useState(initialTiers);
const [policy, setPolicy] = useState(initialPolicy);
// Getrennt von "keine Stufen konfiguriert" - eine fehlgeschlagene Anfrage
// darf niemals als leere Policy dargestellt werden, sonst würde ein
// "Speichern" versehentlich die tatsächlich gespeicherten Stufen mit einer
@ -43,7 +43,7 @@ const SettingsCancellationPageView = ({
startLoadingPolicy(async () => {
const result = await getCancellationPolicy(businessId);
if (result.success) {
setTiers(result.tiers);
setPolicy(result.policy);
} else {
// Auswahl zurücksetzen, damit die UI nicht unter dem neuen
// Business-Namen die (Stufen des vorherigen Business gehörenden) Werte
@ -55,11 +55,11 @@ const SettingsCancellationPageView = ({
});
};
const handleSave = (newTiers: CancellationTier[]): Promise<boolean> => {
const handleSave = (newPolicy: CancellationPolicy): Promise<boolean> => {
return new Promise((resolve) => {
startSaving(async () => {
try {
const result = await saveCancellationPolicy(selectedBusinessId, newTiers);
const result = await saveCancellationPolicy(selectedBusinessId, newPolicy);
if (result.success) {
toast.success("Stornierungsregeln gespeichert.");
resolve(true);
@ -82,8 +82,7 @@ const SettingsCancellationPageView = ({
<CalendarClock /> Stornierung
</h1>
<p className="text-sm text-gray-500 mt-1">
Lege fest, wie viel Kunden bei einer Stornierung zurückerstattet bekommen, abhängig davon
wie kurzfristig vor Kursbeginn storniert wird.
Lege fest, ob und wie viel Kunden bei einer Stornierung zurückerstattet bekommen.
</p>
</div>
@ -117,7 +116,7 @@ const SettingsCancellationPageView = ({
<div className={loadingPolicy ? "opacity-60 transition-opacity" : "transition-opacity"}>
<CancellationPolicyEditor
key={selectedBusinessId}
initialTiers={tiers}
initialPolicy={policy}
onSave={handleSave}
isSaving={isSaving}
/>