Merge pull request #140 from Since-3/feature/refunding

added rule-based refunding-logic
This commit is contained in:
Rajpreet0 2026-08-07 17:56:33 +02:00 • committed by GitHub
commit 54b57632e8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1603 additions and 163 deletions

56
package-lock.json generated
View file

@ -8,6 +8,9 @@
"name": "s3-kuma",
"version": "0.1.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@prisma/client": "^6.19.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-avatar": "^1.1.11",
@ -634,6 +637,59 @@
"integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==",
"license": "MIT"
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",

View file

@ -16,6 +16,9 @@
"stripe:listen": "stripe listen --forward-to localhost:3000/api/stripe/webhook"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@prisma/client": "^6.19.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-avatar": "^1.1.11",

View file

@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "Business" ADD COLUMN "cancellationPolicy" JSONB;
-- AlterTable
ALTER TABLE "CourseBooking" ADD COLUMN "cancellationReason" TEXT,
ADD COLUMN "cancelledBy" TEXT,
ADD COLUMN "refundedAmount" INTEGER,
ADD COLUMN "refundedAt" TIMESTAMP(3);

View file

@ -102,6 +102,7 @@ model Business {
stripeAccountId String? @unique // Stripe Connect Account ID
stripeAccountStatus String? // "pending" | "active" | "restricted"
invoiceCounter Int @default(0) // Fortlaufender Zähler für Rechnungsnummern
cancellationPolicy Json? // { tiers: [{ hoursBeforeStart: number, refundPercent: number }, ...] }, absteigend sortiert. null = immer 100% Rückerstattung.
manager Manager @relation(fields: [managerId], references: [id])
courses Course[]
createdAt DateTime @default(now())
@ -171,13 +172,17 @@ model CourseBooking {
courseId String
userId String?
guestLeadId String?
paymentStatus String @default("pending") // "pending" | "paid" | "failed" | "refunded"
paymentStatus String @default("pending") // "pending" | "paid" | "failed" | "refunded" | "partially_refunded" | "refunding" | "cancelled" (cancelled = storniert ohne Geldbewegung, 0% Erstattung)
stripeSessionId String? @unique
stripePaymentIntentId String? @unique
amountPaid Int? // tatsächlich gezahlter Betrag in Cents
platformFee Int? // unsere Service-Fee in Cents
invoiceNumber String? @unique // Rechnungsnummer, wird lazy beim ersten PDF-Download vergeben
invoiceIssuedAt DateTime? // Zeitpunkt der Rechnungsnummer-Vergabe, damit das Rechnungsdatum bei jedem Download gleich bleibt
refundedAmount Int? // tatsächlich erstatteter Betrag in Cents
refundedAt DateTime? // Zeitpunkt der erfolgreichen Stripe-Rückerstattung
cancelledBy String? // "user" | "manager" | "system" (system = automatischer Überbuchungs-Refund)
cancellationReason String?
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
guestLead GuestLead? @relation(fields: [guestLeadId], references: [id], onDelete: SetNull)

View file

@ -1,13 +1,20 @@
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 SettingsLoading from "../loading";
export default async function SettingsCancellationPage() {
await requireManager();
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 : [];
return (
<Suspense fallback={<SettingsLoading />}>
<SettingsCancellationPageView />
<SettingsCancellationPageView businesses={businesses} initialTiers={initialTiers} />
</Suspense>
);
}

View file

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { stripe, getWebhookSecret } from "@/src/lib/stripe";
import { refundStripePayment } from "@/src/lib/stripe-refund";
import { prisma } from "@/src/lib/prisma";
import { revalidatePath, revalidateTag } from "next/cache";
import { sendMail } from "@/src/lib/mail/resend";
@ -204,15 +205,21 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
`Kurs ${courseId} war voll als Zahlung ankam. ` +
`Automatische Rückerstattung wird ausgelöst (PaymentIntent ${paymentIntentId}).`
);
refundSucceeded = await refundPayment(paymentIntentId);
if (refundSucceeded && outcome.bookingId) {
const refundResult = await refundStripePayment(paymentIntentId);
refundSucceeded = refundResult.success;
if (refundResult.success && outcome.bookingId) {
await prisma.courseBooking.update({
where: { id: outcome.bookingId },
data: { paymentStatus: "refunded" },
data: {
paymentStatus: "refunded",
refundedAmount: refundResult.amountRefundedCents,
refundedAt: new Date(),
cancelledBy: "system",
},
});
} else if (!refundSucceeded) {
} else if (!refundResult.success) {
console.error(
`Refund fehlgeschlagen für Kurs ${courseId}. ` +
`Refund fehlgeschlagen für Kurs ${courseId}: ${refundResult.error}. ` +
`Buchung bleibt "failed" – manuelle Erstattung im Stripe Dashboard nötig.`
);
}
@ -505,6 +512,8 @@ async function sendBookingRefund(
courseName: course.name,
businessName: course.business.name,
amountRefundedCents,
refundPercent: 100,
reason: "overbooked",
companyName: process.env.COMPANY_NAME,
});
@ -514,20 +523,6 @@ async function sendBookingRefund(
}
}
async function refundPayment(paymentIntentId: string): Promise<boolean> {
try {
await stripe.refunds.create({
payment_intent: paymentIntentId,
reverse_transfer: true,
refund_application_fee: true,
});
return true;
} catch (err) {
console.error(`Refund für PaymentIntent ${paymentIntentId} fehlgeschlagen:`, err);
return false;
}
}
async function getApplicationFeeFromPaymentIntent(paymentIntentId: string): Promise<number> {
try {
const intent = await stripe.paymentIntents.retrieve(paymentIntentId);

8
src/lib/currency.ts Normal file
View file

@ -0,0 +1,8 @@
export function formatPrice(cents: number): string {
return new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(cents / 100);
}
/** Rundet und formatiert einen Prozentsatz mit deutschem Dezimaltrennzeichen (Komma). */
export function formatPercent(percent: number): string {
return new Intl.NumberFormat("de-DE", { maximumFractionDigits: 1 }).format(percent);
}

View file

@ -1,31 +1,68 @@
/**
* E-Mail Template für automatische Rückerstattung
* E-Mail Template für Rückerstattungen und Stornierungen ohne Rückerstattung
*
* Dieses Template wird an den Kunden gesendet, wenn ein Kurs während des
* Bezahlvorgangs voll wurde und die Zahlung automatisch erstattet wird
* (Stripe Webhook: capacity_exceeded → refund).
* Dieses Template wird an den Kunden gesendet, wenn eine Buchung storniert wird -
* entweder weil ein Kurs während des Bezahlvorgangs voll wurde (automatischer
* Überbuchungs-Refund), weil eine Buchung durch den Kunden selbst oder durch
* den Manager storniert wurde. Bei refundPercent === 0 wird kein Geld bewegt;
* in diesem Fall wird bewusst NICHT die "wurde erstattet"-Sprache verwendet.
*/
import { escapeHtml, singleLine } from "./utils";
import { formatPrice, formatPercent } from "@/src/lib/currency";
export type BookingRefundReason = "overbooked" | "cancelled_by_user" | "cancelled_by_manager";
interface BookingRefundEmailParams {
courseName: string;
businessName: string;
amountRefundedCents: number;
refundPercent: number;
reason: BookingRefundReason;
originalAmountCents?: number; // nur relevant/angezeigt wenn refundPercent < 100
companyName?: string;
}
function formatPrice(cents: number): string {
return new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(cents / 100);
}
const REASON_COPY: Record<
BookingRefundReason,
{ intro: (courseName: string, businessName: string) => string }
> = {
overbooked: {
intro: (courseName, businessName) =>
`leider war der Kurs <strong>${courseName}</strong> von <strong>${businessName}</strong> bereits ausgebucht, als Ihre Zahlung bei uns eingegangen ist.`,
},
cancelled_by_user: {
intro: (courseName) =>
`Ihre Stornierung für den Kurs <strong>${courseName}</strong> wurde bestätigt.`,
},
cancelled_by_manager: {
intro: (courseName, businessName) =>
`Ihre Buchung für den Kurs <strong>${courseName}</strong> wurde durch <strong>${businessName}</strong> storniert.`,
},
};
const REASON_COPY_TEXT: Record<
BookingRefundReason,
(courseName: string, businessName: string) => string
> = {
overbooked: (courseName, businessName) =>
`leider war der Kurs "${courseName}" von ${businessName} bereits ausgebucht, als Ihre Zahlung bei uns eingegangen ist.`,
cancelled_by_user: (courseName) =>
`Ihre Stornierung für den Kurs "${courseName}" wurde bestätigt.`,
cancelled_by_manager: (courseName, businessName) =>
`Ihre Buchung für den Kurs "${courseName}" wurde durch ${businessName} storniert.`,
};
/**
* Generiert HTML für die Rückerstattungs-Mail
* Generiert HTML für die Rückerstattungs-/Stornierungs-Mail
*/
export function generateBookingRefundEmail({
courseName,
businessName,
amountRefundedCents,
refundPercent,
reason,
originalAmountCents,
companyName = "Ihr Unternehmen",
}: BookingRefundEmailParams): { subject: string; html: string; text: string } {
// Defense gegen Markup-Injection in der Mail.
@ -33,8 +70,62 @@ export function generateBookingRefundEmail({
const safeBusinessName = escapeHtml(businessName);
const safeCompanyName = escapeHtml(companyName);
const subject = singleLine(`Rückerstattung – ${courseName}`);
const hasRefund = refundPercent > 0 && amountRefundedCents > 0;
const isPartial = hasRefund && refundPercent < 100 && originalAmountCents !== undefined;
const formattedPercent = formatPercent(refundPercent);
const formattedPrice = formatPrice(amountRefundedCents);
const formattedOriginalPrice = isPartial ? formatPrice(originalAmountCents!) : null;
const subject = singleLine(
hasRefund ? `Rückerstattung – ${courseName}` : `Stornierung bestätigt – ${courseName}`
);
const introHtml = REASON_COPY[reason].intro(safeCourseName, safeBusinessName);
const introText = REASON_COPY_TEXT[reason](courseName, businessName);
// "Ihre Zahlung wurde erstattet"-Satz nur, wenn tatsächlich Geld bewegt wird -
// bei refundPercent === 0 (Stornierung ohne Rückerstattung, z.B. weil die
// konfigurierten Stornierungsfristen bereits verstrichen sind) wäre dieser
// Satz irreführend, da kein Betrag unterwegs ist.
const refundStatusHtml = hasRefund
? `<p style="margin: 0 0 30px; color: #333333; font-size: 16px; line-height: 1.6;">
Ihre Zahlung wurde <strong>erstattet</strong>. Der Betrag wird
Ihrer ursprünglichen Zahlungsmethode in der Regel innerhalb von 5–10 Werktagen
wieder gutgeschrieben.
</p>`
: `<p style="margin: 0 0 30px; color: #333333; font-size: 16px; line-height: 1.6;">
Gemäß den Stornierungsbedingungen von <strong>${safeBusinessName}</strong> erfolgt bei dieser
Stornierung <strong>keine Rückerstattung</strong>.
</p>`;
const refundStatusText = hasRefund
? "Ihre Zahlung wurde erstattet. Der Betrag wird Ihrer ursprünglichen Zahlungsmethode in der Regel innerhalb von 5–10 Werktagen wieder gutgeschrieben."
: `Gemäß den Stornierungsbedingungen von ${businessName} erfolgt bei dieser Stornierung keine Rückerstattung.`;
const policyLineHtml = isPartial
? `<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Gemäß den Stornierungsbedingungen von <strong>${safeBusinessName}</strong> wurden Ihnen
<strong>${formattedPercent} %</strong> des Kurspreises erstattet (${formattedPrice} von ${formattedOriginalPrice}).
</p>`
: "";
const policyLineText = isPartial
? `Gemäß den Stornierungsbedingungen von ${businessName} wurden Ihnen ${formattedPercent} % des Kurspreises erstattet (${formattedPrice} von ${formattedOriginalPrice}).\n\n`
: "";
const refundBoxHtml = hasRefund
? `<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #fff5f5; border: 1px solid #fed7d7; border-radius: 6px; margin: 0 0 30px;">
<tr>
<td style="padding: 24px;">
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td style="color: #666666; font-size: 14px;">Erstatteter Betrag</td>
<td align="right" style="color: #c53030; font-size: 18px; font-weight: 600;">${formattedPrice}</td>
</tr>
</table>
</td>
</tr>
</table>`
: "";
const refundBoxText = hasRefund ? `Erstatteter Betrag: ${formattedPrice}\n\n` : "";
const html = `
<!DOCTYPE html>
@ -42,7 +133,7 @@ export function generateBookingRefundEmail({
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rückerstattung</title>
<title>${hasRefund ? "Rückerstattung" : "Stornierung"}</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f5f5f5; padding: 40px 0;">
@ -54,7 +145,7 @@ export function generateBookingRefundEmail({
<tr>
<td style="background: linear-gradient(135deg, #f6a86b 0%, #ee5a52 100%); padding: 40px; text-align: center;">
<h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 600;">
Rückerstattung wurde veranlasst
${hasRefund ? "Rückerstattung wurde veranlasst" : "Stornierung bestätigt"}
</h1>
</td>
</tr>
@ -67,33 +158,17 @@ export function generateBookingRefundEmail({
</p>
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
leider war der Kurs <strong>${safeCourseName}</strong> von <strong>${safeBusinessName}</strong>
bereits ausgebucht, als Ihre Zahlung bei uns eingegangen ist.
${introHtml}
</p>
<p style="margin: 0 0 30px; color: #333333; font-size: 16px; line-height: 1.6;">
Ihre Zahlung wurde daher <strong>automatisch erstattet</strong>. Der Betrag wird
Ihrer ursprünglichen Zahlungsmethode in der Regel innerhalb von 5–10 Werktagen
wieder gutgeschrieben.
</p>
${refundStatusHtml}
<!-- Refund Info Box -->
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #fff5f5; border: 1px solid #fed7d7; border-radius: 6px; margin: 0 0 30px;">
<tr>
<td style="padding: 24px;">
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td style="color: #666666; font-size: 14px;">Erstatteter Betrag</td>
<td align="right" style="color: #c53030; font-size: 18px; font-weight: 600;">${formattedPrice}</td>
</tr>
</table>
</td>
</tr>
</table>
${policyLineHtml}
${refundBoxHtml}
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Es tut uns leid für die Unannehmlichkeiten. Sie können gerne einen anderen
freien Kurs bei <strong>${safeBusinessName}</strong> buchen.
Sie können gerne einen anderen freien Kurs bei <strong>${safeBusinessName}</strong> buchen.
</p>
<div style="border-top: 1px solid #e0e0e0; padding-top: 20px; margin-top: 30px;">
@ -126,17 +201,15 @@ export function generateBookingRefundEmail({
`;
const text = `
Rückerstattung – ${courseName}
${hasRefund ? "Rückerstattung" : "Stornierung bestätigt"} – ${courseName}
Hallo,
leider war der Kurs "${courseName}" von ${businessName} bereits ausgebucht, als Ihre Zahlung bei uns eingegangen ist.
${introText}
Ihre Zahlung wurde daher automatisch erstattet. Der Betrag wird Ihrer ursprünglichen Zahlungsmethode in der Regel innerhalb von 5–10 Werktagen wieder gutgeschrieben.
${refundStatusText}
Erstatteter Betrag: ${formattedPrice}
Es tut uns leid für die Unannehmlichkeiten. Sie können gerne einen anderen freien Kurs bei ${businessName} buchen.
${policyLineText}${refundBoxText}Sie können gerne einen anderen freien Kurs bei ${businessName} buchen.
Bei Fragen zur Rückerstattung wenden Sie sich an Ihre Bank oder Ihren Karten-Anbieter.

41
src/lib/stripe-refund.ts Normal file
View file

@ -0,0 +1,41 @@
import { stripe } from "@/src/lib/stripe";
export type RefundResult =
| { success: true; stripeRefundId: string; amountRefundedCents: number }
| { success: false; error: string };
/**
* Erstattet eine Zahlung über Stripe. Checkouts laufen als Connect-Destination-Charges
* (Geld wird per transfer_data.destination an den Business-Account transferiert),
* deshalb sind reverse_transfer (holt das Geld vom verbundenen Account zurück) und
* refund_application_fee (erstattet den anteiligen Plattform-Fee mit) zwingend nötig -
* ohne reverse_transfer würde die Erstattung fehlschlagen, da das Geld beim Business liegt.
*/
export async function refundStripePayment(
paymentIntentId: string,
amountCents?: number, // undefined = volle Erstattung
idempotencyKey?: string
): Promise<RefundResult> {
try {
const refund = await stripe.refunds.create(
{
payment_intent: paymentIntentId,
...(amountCents !== undefined ? { amount: amountCents } : {}),
reverse_transfer: true,
refund_application_fee: true,
},
idempotencyKey ? { idempotencyKey } : undefined
);
return {
success: true,
stripeRefundId: refund.id,
amountRefundedCents: refund.amount,
};
} catch (err) {
console.error(`Stripe-Refund für PaymentIntent ${paymentIntentId} fehlgeschlagen:`, err);
return {
success: false,
error: err instanceof Error ? err.message : "Stripe-Rückerstattung fehlgeschlagen",
};
}
}

View file

@ -10,6 +10,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";
export type UserBooking = {
bookingId: string;
@ -169,8 +170,7 @@ export async function cancelUserBooking(
where: { id: bookingId },
select: {
userId: true,
paymentStatus: true,
course: { select: { date: true, businessId: true } },
course: { select: { businessId: true } },
},
});
@ -178,18 +178,8 @@ export async function cancelUserBooking(
return { success: false, error: "Buchung nicht gefunden." };
}
if (new Date(booking.course.date) < new Date()) {
return { success: false, error: "Vergangene Kurse können nicht storniert werden." };
}
if (booking.paymentStatus !== "paid") {
return { success: false, error: "Nur bezahlte Buchungen können storniert werden." };
}
await prisma.courseBooking.update({
where: { id: bookingId },
data: { paymentStatus: "refunded" },
});
const result = await executeBookingCancellation(bookingId, "user");
if (!result.success) return result;
revalidatePath("/courses/myCourses");
// eslint-disable-next-line @typescript-eslint/no-explicit-any

View file

@ -0,0 +1,191 @@
import { prisma } from "@/src/lib/prisma";
import { refundStripePayment } from "@/src/lib/stripe-refund";
import { sendMail } from "@/src/lib/mail/resend";
import { generateBookingRefundEmail } from "@/src/lib/mail/templates/booking-refund";
import { combineDateAndTime, calculateRefundAmountCents } from "./cancellation-policy";
export type CancelBookingResult =
| { success: true; refundedAmountCents: number; refundPercent: number }
| { success: false; error: string };
/**
* Wartet maximal `ms` Millisekunden auf das Promise - E-Mail-Versand darf die
* Stornierung nicht verzögern/blockieren (gleiches Pattern wie im Stripe-Webhook).
*/
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T | null> {
return Promise.race([
promise,
new Promise<null>((resolve) => setTimeout(() => resolve(null), ms)),
]);
}
async function sendCancellationEmail(
booking: {
userId: string | null;
guestLeadId: string | null;
course: { name: string; business: { name: string } | null };
},
amountRefundedCents: number,
originalAmountCents: number,
refundPercent: number,
cancelledBy: "user" | "manager"
): Promise<void> {
if (!booking.course.business) return;
const recipientEmail = booking.userId
? (await prisma.user.findUnique({ where: { id: booking.userId }, select: { email: true } }))
?.email
: booking.guestLeadId
? (
await prisma.guestLead.findUnique({
where: { id: booking.guestLeadId },
select: { email: true },
})
)?.email
: null;
if (!recipientEmail) return;
const { subject, html, text } = generateBookingRefundEmail({
courseName: booking.course.name,
businessName: booking.course.business.name,
amountRefundedCents,
refundPercent,
reason: cancelledBy === "manager" ? "cancelled_by_manager" : "cancelled_by_user",
originalAmountCents,
companyName: process.env.COMPANY_NAME,
});
await sendMail({ to: recipientEmail, subject, html, text });
}
/**
* Zentrale Storno-/Erstattungslogik. Wird sowohl vom Kunden-Self-Service-Pfad
* (cancelUserBooking) als auch vom Manager-Pfad (cancelKundeBooking) aufgerufen -
* beide teilen sich Stripe-Aufruf, DB-Update und E-Mail-Versand, unterscheiden
* sich nur in Auth/Berechtigungsprüfung (die der jeweilige Aufrufer VOR diesem
* Aufruf erledigt) und im cancelledBy-Wert.
*/
export async function executeBookingCancellation(
bookingId: string,
cancelledBy: "user" | "manager"
): Promise<CancelBookingResult> {
const booking = await prisma.courseBooking.findUnique({
where: { id: bookingId },
select: {
paymentStatus: true,
amountPaid: true,
stripePaymentIntentId: true,
userId: true,
guestLeadId: true,
course: {
select: {
id: true,
name: true,
date: true,
timeFrom: true,
businessId: true,
business: { select: { name: true } },
},
},
},
});
if (!booking) return { success: false, error: "Buchung nicht gefunden." };
if (booking.paymentStatus !== "paid") {
return { success: false, error: "Nur bezahlte Buchungen können storniert werden." };
}
if (!booking.stripePaymentIntentId || !booking.amountPaid) {
return { success: false, error: "Zahlungsdaten fehlen — Stornierung nicht möglich." };
}
const courseStart = combineDateAndTime(booking.course.date, booking.course.timeFrom);
if (courseStart < new Date()) {
return {
success: false,
error: "Vergangene oder bereits begonnene Kurse können nicht storniert werden.",
};
}
// 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
// paymentStatus === "paid"-Read sehen, aber nur einer gewinnt dieses
// updateMany - der andere bricht sofort ab, bevor überhaupt ein Stripe-Call
// stattfindet. Der Idempotency-Key an Stripe (unten) ist eine zweite,
// unabhängige Absicherung für den Fall, dass trotzdem zwei echte
// refunds.create-Aufrufe rausgehen (z.B. bei einem Retry nach Timeout).
const claimed = await prisma.courseBooking.updateMany({
where: { id: bookingId, paymentStatus: "paid" },
data: { paymentStatus: "refunding" },
});
if (claimed.count !== 1) {
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.
let amountRefundedCents = 0;
if (refundAmountCents > 0) {
const refundResult = await refundStripePayment(
booking.stripePaymentIntentId,
refundAmountCents < booking.amountPaid ? refundAmountCents : undefined,
`refund-${bookingId}`
);
if (!refundResult.success) {
// WICHTIG: paymentStatus zurück auf "paid" setzen, wenn Stripe ablehnt -
// die Buchung darf nicht im "refunding"-Zwischenstatus hängen bleiben,
// sonst ist ein erneuter Versuch durch den claimed-Check oben blockiert.
await prisma.courseBooking.update({
where: { id: bookingId },
data: { paymentStatus: "paid" },
});
return { success: false, error: `Rückerstattung fehlgeschlagen: ${refundResult.error}` };
}
amountRefundedCents = refundResult.amountRefundedCents;
}
// 0% Rückerstattung storniert die Buchung trotzdem, aber ohne Geldbewegung -
// dafür gibt es einen eigenen Status, damit weder die Rechnungs-/Metrik-Logik
// noch die Storno-Mail fälschlich "erstattet" suggerieren (siehe §"cancelled").
const newPaymentStatus =
amountRefundedCents >= booking.amountPaid
? "refunded"
: amountRefundedCents > 0
? "partially_refunded"
: "cancelled";
await prisma.courseBooking.update({
where: { id: bookingId },
data: {
paymentStatus: newPaymentStatus,
refundedAmount: amountRefundedCents,
refundedAt: new Date(),
cancelledBy,
},
});
const emailPromise = sendCancellationEmail(
booking,
amountRefundedCents,
booking.amountPaid,
refundPercent,
cancelledBy
).catch((err) => console.error("Storno-Mail fehlgeschlagen:", err));
await withTimeout(emailPromise, 2000);
return {
success: true,
refundedAmountCents: amountRefundedCents,
refundPercent,
};
}

View file

@ -0,0 +1,96 @@
import { prisma } from "@/src/lib/prisma";
import {
cancellationPolicySchema,
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;
}
/**
* Wählt den zutreffenden Rückerstattungsprozentsatz.
*
* Eine Stufe {hoursBeforeStart: X, refundPercent: P} gilt für den Zeitraum
* "sobald weniger als X Stunden bis Kursbeginn übrig sind, bis zur nächststrengeren
* (kleineren) Stufe". Beispiel mit Stufen 24h→50%, 12h→25%, 6h→0%:
* - hoursUntilStart >= 24 → 100% (mehr Vorlauf als jede Stufe verlangt)
* - 12 <= hoursUntilStart < 24 → 50%
* - 6 <= hoursUntilStart < 12 → 25%
* - hoursUntilStart < 6 → 0% (weil hier explizit eine 6h→0%-Stufe konfiguriert ist)
*
* WICHTIG: Es gibt KEINEN impliziten 0%-Floor. Ohne eine explizite Stufe für
* den strengsten Zeitraum gilt der refundPercent der strengsten konfigurierten
* Stufe bis Kursbeginn weiter. Mit nur 24h→50% und 12h→25% (ohne 6h-Stufe)
* liefert eine Stornierung 1 Stunde vor Kursbeginn weiterhin 25%, nicht 0%.
* Soll 0% erreicht werden, muss dafür eine eigene Stufe angelegt werden.
*
* Implementierung: Stufen aufsteigend nach hoursBeforeStart sortiert. Ist mehr
* Vorlauf vorhanden als die größte konfigurierte Schwelle verlangt, gilt 100%.
* Sonst gilt der refundPercent der ERSTEN (kleinsten) Stufe, deren eigene
* Schwelle noch NICHT unterschritten ist (hoursBeforeStart > hoursUntilStart) -
* das ist die nächststrengere Stufe von "oben" gesehen. Ohne konfigurierte
* Stufen: immer 100%.
*/
export function selectRefundPercent(
tiers: CancellationTier[],
courseStart: Date,
now: Date
): number {
if (tiers.length === 0) return 100;
const sorted = [...tiers].sort((a, b) => a.hoursBeforeStart - b.hoursBeforeStart);
const hoursUntilStart = (courseStart.getTime() - now.getTime()) / (1000 * 60 * 60);
const largestThreshold = sorted[sorted.length - 1].hoursBeforeStart;
if (hoursUntilStart >= largestThreshold) return 100;
for (const tier of sorted) {
if (tier.hoursBeforeStart > hoursUntilStart) {
return tier.refundPercent;
}
}
// Unerreichbar: largestThreshold-Check oben deckt bereits alle Fälle ab,
// in denen keine Stufe > hoursUntilStart existiert.
return 0;
}
export async function calculateRefundAmountCents(params: {
businessId: string | null;
amountPaidCents: number;
courseStart: Date;
now: Date;
}): Promise<{ refundPercent: number; refundAmountCents: number }> {
let tiers: CancellationTier[] = [];
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.
}
const refundPercent = selectRefundPercent(tiers, params.courseStart, params.now);
const refundAmountCents = Math.round((params.amountPaidCents * refundPercent) / 100);
return { refundPercent, refundAmountCents };
}

View file

@ -0,0 +1,68 @@
"use server";
import { prisma } from "@/src/lib/prisma";
import { getUserData, isUser, isManager } from "@/src/lib/auth/getUser";
import { combineDateAndTime, calculateRefundAmountCents } from "./cancellation-policy";
export type CancellationPreview = {
refundPercent: number;
refundAmountCents: number;
amountPaidCents: number;
};
/**
* Berechnet, wie viel eine Stornierung JETZT zurückerstatten würde, ohne
* irgendetwas zu ändern. Wird verwendet, um dem Kunden/Manager vor dem
* Bestätigen zu zeigen, wenn keine 100%-Rückerstattung mehr greift.
*
* Autorisierung: erlaubt ist entweder der Kunde, dem die Buchung gehört, oder
* ein Manager, dessen Business den gebuchten Kurs anbietet - dasselbe Muster
* wie bei cancelUserBooking/cancelKundeBooking, da diese Vorschau denselben
* Betrag (inkl. amountPaidCents) offenlegt, den auch die Stornierung selbst
* verwendet.
*/
export async function previewBookingCancellation(
bookingId: string
): Promise<{ success: true; preview: CancellationPreview } | { success: false; error: string }> {
const userData = await getUserData();
if (!userData) {
return { success: false, error: "Nicht angemeldet." };
}
const booking = await prisma.courseBooking.findUnique({
where: { id: bookingId },
select: {
userId: true,
amountPaid: true,
course: { select: { date: true, timeFrom: true, businessId: true } },
},
});
if (!booking || !booking.amountPaid) {
return { success: false, error: "Buchung nicht gefunden." };
}
const isOwner = isUser(userData) && booking.userId === userData.id;
const isManagingBusiness =
isManager(userData) &&
!!booking.course.businessId &&
userData.businesses.some((b) => b.id === booking.course.businessId);
if (!isOwner && !isManagingBusiness) {
return { success: false, error: "Keine Berechtigung für diese Buchung." };
}
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(),
});
return {
success: true,
preview: { refundPercent, refundAmountCents, amountPaidCents: booking.amountPaid },
};
}

View file

@ -0,0 +1,165 @@
"use client";
import { useState } from "react";
import { AlertTriangle } from "lucide-react";
import { Button } from "@/src/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/src/components/ui/dialog";
import { toast } from "sonner";
import { formatPrice, formatPercent } from "@/src/lib/currency";
import {
previewBookingCancellation,
type CancellationPreview,
} from "@/src/modules/courses/actions/cancellation-preview-actions";
export type CancelBookingCopyVariant = "user" | "manager";
const VARIANT_COPY: Record<
CancelBookingCopyVariant,
{ description: string; fullRefund: string; partialRefund: (percent: string) => string }
> = {
user: {
description: "wird storniert.",
fullRefund: "Du erhältst den vollen Betrag zurück.",
partialRefund: (percent) =>
`Da du kurzfristig stornierst, erhältst du gemäß den Stornierungsbedingungen nur ${percent} % zurück:`,
},
manager: {
description:
"wird storniert und die Zahlung über Stripe erstattet. Dieser Vorgang kann nicht rückgängig gemacht werden.",
fullRefund: "Der Kunde erhält den vollen Betrag zurück.",
partialRefund: (percent) =>
`Gemäß den Stornierungsbedingungen erhält der Kunde nur ${percent} % zurück:`,
},
};
interface CancelBookingConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
bookingId: string;
courseName: string;
variant: CancelBookingCopyVariant;
onConfirm: () => Promise<{ success: true } | { success: false; error: string }>;
onSuccess: () => void;
}
/**
* 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.
*/
const CancelBookingConfirmDialog = ({
open,
onOpenChange,
bookingId,
courseName,
variant,
onConfirm,
onSuccess,
}: CancelBookingConfirmDialogProps) => {
const [preview, setPreview] = useState<CancellationPreview | null>(null);
const [loadingPreview, setLoadingPreview] = useState(false);
const [isPending, setIsPending] = useState(false);
const copy = VARIANT_COPY[variant];
const handleOpenChange = (nextOpen: boolean) => {
onOpenChange(nextOpen);
if (nextOpen) {
loadPreview();
} else {
setPreview(null);
}
};
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);
}
} catch {
toast.error("Rückerstattung konnte nicht berechnet werden.");
onOpenChange(false);
} finally {
setLoadingPreview(false);
}
};
const handleConfirm = async () => {
setIsPending(true);
try {
const result = await onConfirm();
if (result.success) {
onOpenChange(false);
onSuccess();
} else {
toast.error(result.error);
}
} catch {
toast.error("Stornierung fehlgeschlagen.");
} finally {
setIsPending(false);
}
};
const isPartialRefund = preview !== null && preview.refundPercent < 100;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Buchung stornieren?</DialogTitle>
<DialogDescription>
Die Buchung für <strong>{courseName}</strong> {copy.description}
</DialogDescription>
</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)})
</p>
)
) : null}
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isPending}>
Abbrechen
</Button>
<Button
variant="destructive"
onClick={handleConfirm}
disabled={isPending || loadingPreview}
>
{isPending ? "Wird storniert…" : "Ja, stornieren"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default CancelBookingConfirmDialog;

View file

@ -6,6 +6,7 @@ import Image from "next/image";
import { MapPin, Clock, X } from "lucide-react";
import type { UserBooking } from "../../actions/booking-actions";
import { cancelUserBooking } from "../../actions/booking-actions";
import CancelBookingConfirmDialog from "./CancelBookingConfirmDialog";
import AbstractTooltip from "@/src/components/layout/AbstractTooltip";
import { BookingDialog } from "@/src/modules/courses/ui/components/PublicCourseCard";
import type { PublicCourse } from "@/src/modules/courses/ui/components/PublicCourseCard";
@ -19,6 +20,13 @@ const PAYMENT_STATUS: Record<string, { label: string; bg: string; text: string }
paid: { label: "Bezahlt", bg: "bg-green-100", text: "text-green-700" },
pending: { label: "Ausstehend", bg: "bg-yellow-100", text: "text-yellow-700" },
refunded: { label: "Storniert", bg: "bg-red-100", text: "text-red-600" },
partially_refunded: {
label: "Teilweise erstattet",
bg: "bg-orange-100",
text: "text-orange-700",
},
cancelled: { label: "Storniert", bg: "bg-red-100", text: "text-red-600" },
refunding: { label: "Wird storniert…", bg: "bg-yellow-100", text: "text-yellow-700" },
failed: { label: "Fehlgeschlagen", bg: "bg-red-100", text: "text-red-600" },
};
@ -48,7 +56,8 @@ interface UserBookingCardProps {
const UserBookingCard = ({ booking }: UserBookingCardProps) => {
const router = useRouter();
const [dialogOpen, setDialogOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const [confirmOpen, setConfirmOpen] = useState(false);
const [, startTransition] = useTransition();
const [cancelled, setCancelled] = useState(booking.paymentStatus === "refunded");
const effectiveStatus = cancelled ? "refunded" : booking.paymentStatus;
@ -58,23 +67,18 @@ const UserBookingCard = ({ booking }: UserBookingCardProps) => {
day: "2-digit",
month: "2-digit",
year: "numeric",
timeZone: "Europe/Berlin",
});
const canCancel = !booking.isPast && !cancelled && booking.paymentStatus === "paid";
const handleCancel = (e: React.MouseEvent) => {
e.stopPropagation();
startTransition(async () => {
const result = await cancelUserBooking(booking.bookingId);
if (result.success) {
setCancelled(true);
router.refresh();
toast.success("Buchung erfolgreich storniert.");
} else {
toast.error(result.error);
}
const handleConfirmCancel = () =>
new Promise<{ success: true } | { success: false; error: string }>((resolve) => {
startTransition(async () => {
const result = await cancelUserBooking(booking.bookingId);
resolve(result);
});
});
};
return (
<>
@ -149,11 +153,13 @@ const UserBookingCard = ({ booking }: UserBookingCardProps) => {
variant="outline"
size="sm"
className="w-full text-red-600 border-red-200 hover:bg-red-50 hover:text-red-700 hover:border-red-300"
onClick={handleCancel}
disabled={isPending}
onClick={(e) => {
e.stopPropagation();
setConfirmOpen(true);
}}
>
<X size={14} className="mr-1.5 shrink-0" />
{isPending ? "Wird storniert…" : "Stornieren"}
Stornieren
</Button>
</div>
)}
@ -165,6 +171,20 @@ const UserBookingCard = ({ booking }: UserBookingCardProps) => {
onOpenChange={setDialogOpen}
paymentStatus={effectiveStatus}
/>
<CancelBookingConfirmDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
bookingId={booking.bookingId}
courseName={booking.courseName}
variant="user"
onConfirm={handleConfirmCancel}
onSuccess={() => {
setCancelled(true);
router.refresh();
toast.success("Buchung erfolgreich storniert.");
}}
/>
</>
);
};

View file

@ -0,0 +1,45 @@
"use server";
import { prisma } from "@/src/lib/prisma";
import { requireManager } from "@/src/lib/auth/getUser";
import { executeBookingCancellation } from "@/src/modules/courses/actions/cancel-booking-shared";
import { revalidatePath, revalidateTag } from "next/cache";
export async function cancelKundeBooking(
bookingId: string
): Promise<{ success: true } | { success: false; error: string }> {
const manager = await requireManager();
const businessIds = manager.businesses.map((b) => b.id);
try {
const booking = await prisma.courseBooking.findUnique({
where: { id: bookingId },
select: { userId: true, course: { select: { businessId: true } } },
});
if (
!booking ||
!booking.course.businessId ||
!businessIds.includes(booking.course.businessId)
) {
return { success: false, error: "Keine Berechtigung für diese Buchung." };
}
const result = await executeBookingCancellation(bookingId, "manager");
if (!result.success) return result;
if (booking.userId) {
revalidatePath(`/kunden/${booking.userId}`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(revalidateTag as any)(`kunde-detail-${booking.userId}`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(revalidateTag as any)(`user-bookings-${booking.userId}`);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(revalidateTag as any)(`business-courses-${booking.course.businessId}`);
return { success: true };
} catch {
return { success: false, error: "Stornierung fehlgeschlagen." };
}
}

View file

@ -1,12 +1,17 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import { MapPin, Clock } from "lucide-react";
import { MapPin, Clock, X } from "lucide-react";
import type { KundeBooking } from "../../actions/kunde-detail-actions";
import { cancelKundeBooking } from "../../actions/kunde-cancel-booking-actions";
import CancelBookingConfirmDialog from "@/src/modules/courses/ui/components/CancelBookingConfirmDialog";
import AbstractTooltip from "@/src/components/layout/AbstractTooltip";
import { BookingDialog } from "@/src/modules/courses/ui/components/PublicCourseCard";
import type { PublicCourse } from "@/src/modules/courses/ui/components/PublicCourseCard";
import { Button } from "@/src/components/ui/button";
import { toast } from "sonner";
// Nur anonymisierten Seed senden – keine internen IDs an Drittanbieter
const getDiceBearUrl = (seed: string) =>
@ -16,6 +21,13 @@ const PAYMENT_STATUS: Record<string, { label: string; bg: string; text: string }
paid: { label: "Bezahlt", bg: "bg-green-100", text: "text-green-700" },
pending: { label: "Ausstehend", bg: "bg-yellow-100", text: "text-yellow-700" },
refunded: { label: "Storniert", bg: "bg-red-100", text: "text-red-600" },
partially_refunded: {
label: "Teilweise erstattet",
bg: "bg-orange-100",
text: "text-orange-700",
},
cancelled: { label: "Storniert", bg: "bg-red-100", text: "text-red-600" },
refunding: { label: "Wird storniert…", bg: "bg-yellow-100", text: "text-yellow-700" },
failed: { label: "Fehlgeschlagen", bg: "bg-red-100", text: "text-red-600" },
};
@ -44,78 +56,103 @@ interface KundeBookingCardProps {
}
const KundeBookingCard = ({ booking }: KundeBookingCardProps) => {
const router = useRouter();
const [dialogOpen, setDialogOpen] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const status = PAYMENT_STATUS[booking.paymentStatus] ?? PAYMENT_STATUS.pending;
const dateFormatted = new Date(booking.date).toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
timeZone: "Europe/Berlin",
});
const canCancel = !booking.isPast && booking.paymentStatus === "paid";
return (
<>
<button
type="button"
onClick={() => setDialogOpen(true)}
className="text-left w-full bg-white/70 backdrop-blur-sm border border-white/50 rounded-xl p-4 shadow-sm hover:shadow-md hover:bg-white/85 transition-all cursor-pointer flex flex-col gap-3 select-none"
>
{/* Trainer Avatare + Uhrzeit */}
<div className="flex items-center justify-between">
<div className="flex items-center">
{booking.trainers.length > 0 ? (
booking.trainers.map((tid, idx) => {
const info = booking.trainersMap[tid];
const name = info?.label ?? "Trainer";
const src = info?.pbSrc ?? getDiceBearUrl(tid);
return (
<AbstractTooltip key={tid} tooltipText={name}>
<div
className={`relative w-8 h-8 rounded-full border-2 border-white overflow-hidden bg-gray-100 ${
idx !== 0 ? "-ml-2" : ""
}`}
style={{ zIndex: booking.trainers.length - idx }}
>
<Image src={src} alt={name} fill className="object-cover" unoptimized />
</div>
</AbstractTooltip>
);
})
) : (
<div className="w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center text-xs text-gray-400">
–
</div>
)}
<div className="bg-white/70 backdrop-blur-sm border border-white/50 rounded-xl shadow-sm hover:shadow-md hover:bg-white/85 transition-all flex flex-col gap-0 overflow-hidden">
<button
type="button"
onClick={() => setDialogOpen(true)}
className="text-left w-full p-4 flex flex-col gap-3 select-none cursor-pointer"
>
{/* Trainer Avatare + Uhrzeit */}
<div className="flex items-center justify-between">
<div className="flex items-center">
{booking.trainers.length > 0 ? (
booking.trainers.map((tid, idx) => {
const info = booking.trainersMap[tid];
const name = info?.label ?? "Trainer";
const src = info?.pbSrc ?? getDiceBearUrl(tid);
return (
<AbstractTooltip key={tid} tooltipText={name}>
<div
className={`relative w-8 h-8 rounded-full border-2 border-white overflow-hidden bg-gray-100 ${
idx !== 0 ? "-ml-2" : ""
}`}
style={{ zIndex: booking.trainers.length - idx }}
>
<Image src={src} alt={name} fill className="object-cover" unoptimized />
</div>
</AbstractTooltip>
);
})
) : (
<div className="w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center text-xs text-gray-400">
–
</div>
)}
</div>
<span className="text-sm text-gray-500 flex items-center gap-1">
<Clock size={13} className="shrink-0" />
{booking.timeFrom} – {booking.timeTo}
</span>
</div>
<span className="text-sm text-gray-500 flex items-center gap-1">
<Clock size={13} className="shrink-0" />
{booking.timeFrom} – {booking.timeTo}
</span>
</div>
{/* Kursname */}
<h3 className="font-semibold text-gray-900 text-base leading-tight">
{booking.courseName}
</h3>
{/* Kursname */}
<h3 className="font-semibold text-gray-900 text-base leading-tight">
{booking.courseName}
</h3>
{/* Datum + Raum */}
<div className="flex items-center justify-between text-sm text-gray-500">
<span>{dateFormatted}</span>
<span className="flex items-center gap-1">
<MapPin size={13} className="shrink-0" />
{booking.roomName}
</span>
</div>
{/* Datum + Raum */}
<div className="flex items-center justify-between text-sm text-gray-500">
<span>{dateFormatted}</span>
<span className="flex items-center gap-1">
<MapPin size={13} className="shrink-0" />
{booking.roomName}
</span>
</div>
{/* Status Badge */}
<div>
<span
className={`inline-block text-xs font-semibold px-2.5 py-1 rounded-full ${status.bg} ${status.text}`}
>
{status.label}
</span>
</div>
</button>
{/* Status Badge */}
<div>
<span
className={`inline-block text-xs font-semibold px-2.5 py-1 rounded-full ${status.bg} ${status.text}`}
>
{status.label}
</span>
</div>
</button>
{/* Storno-Button – nur für zukünftige, bezahlte Buchungen */}
{canCancel && (
<div className="px-4 pb-4">
<Button
variant="outline"
size="sm"
className="w-full text-red-600 border-red-200 hover:bg-red-50 hover:text-red-700 hover:border-red-300"
onClick={(e) => {
e.stopPropagation();
setConfirmOpen(true);
}}
>
<X size={14} className="mr-1.5 shrink-0" />
Stornieren
</Button>
</div>
)}
</div>
{/* Exakt derselbe Dialog wie auf der Public Page, aber in der Admin-Ansicht */}
<BookingDialog
@ -124,6 +161,19 @@ const KundeBookingCard = ({ booking }: KundeBookingCardProps) => {
onOpenChange={setDialogOpen}
paymentStatus={booking.paymentStatus}
/>
<CancelBookingConfirmDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
bookingId={booking.bookingId}
courseName={booking.courseName}
variant="manager"
onConfirm={() => cancelKundeBooking(booking.bookingId)}
onSuccess={() => {
router.refresh();
toast.success("Buchung erfolgreich storniert.");
}}
/>
</>
);
};

View file

@ -0,0 +1,79 @@
"use server";
import { prisma } from "@/src/lib/prisma";
import { requireManager } from "@/src/lib/auth/getUser";
import { revalidatePath } from "next/cache";
import {
cancellationPolicySchema,
type CancellationTier,
} from "../schemas/cancellation-policy-schema";
export async function getCancellationPolicy(
businessId: string
): Promise<{ success: true; tiers: CancellationTier[] } | { success: false; error: string }> {
// requireManager() löst bei fehlender Session/Rolle einen next/navigation-Redirect
// aus, der als spezieller Error implementiert ist - darf nicht im try/catch landen.
const manager = await requireManager();
try {
const business = await prisma.business.findUnique({
where: { id: businessId },
select: { managerId: true, cancellationPolicy: true },
});
if (!business || business.managerId !== manager.id) {
return { success: false, error: "Keine Berechtigung für dieses Business." };
}
const parsed = cancellationPolicySchema.safeParse(business.cancellationPolicy);
return { success: true, tiers: parsed.success ? parsed.data.tiers : [] };
} catch (error) {
console.error("Error fetching cancellation policy:", error);
return { success: false, error: "Stornierungsregeln konnten nicht geladen werden." };
}
}
export async function saveCancellationPolicy(
businessId: string,
tiers: CancellationTier[]
): Promise<{ success: true } | { success: false; error: string }> {
const manager = await requireManager();
try {
const business = await prisma.business.findUnique({
where: { id: businessId },
select: { managerId: true },
});
if (!business || business.managerId !== manager.id) {
return { success: false, error: "Keine Berechtigung für dieses Business." };
}
const parsed = cancellationPolicySchema.safeParse({ tiers });
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." };
}
hoursSeen.add(tier.hoursBeforeStart);
}
const sortedTiers = [...parsed.data.tiers].sort(
(a, b) => b.hoursBeforeStart - a.hoursBeforeStart
);
await prisma.business.update({
where: { id: businessId },
data: { cancellationPolicy: { tiers: sortedTiers } },
});
revalidatePath("/settings/cancellation");
return { success: true };
} catch (error) {
console.error("Error saving cancellation policy:", error);
return { success: false, error: "Stornierungsregeln konnten nicht gespeichert werden." };
}
}

View file

@ -0,0 +1,17 @@
import { z } from "zod";
export const cancellationTierSchema = z.object({
hoursBeforeStart: z
.number()
.int()
.min(0)
.max(24 * 90),
refundPercent: z.number().int().min(0).max(100),
});
export const cancellationPolicySchema = z.object({
tiers: z.array(cancellationTierSchema).max(20),
});
export type CancellationTier = z.infer<typeof cancellationTierSchema>;
export type CancellationPolicy = z.infer<typeof cancellationPolicySchema>;

View file

@ -0,0 +1,222 @@
"use client";
import { useId, useState } from "react";
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVertical, Plus, Trash2, Info } from "lucide-react";
import { Button } from "@/src/components/ui/button";
import { Input } from "@/src/components/ui/input";
import CancellationPolicyTimeline from "./CancellationPolicyTimeline";
import type { CancellationTier } from "../../schemas/cancellation-policy-schema";
type TierRow = CancellationTier & { id: string };
interface SortableTierRowProps {
tier: TierRow;
onChange: (id: string, field: keyof CancellationTier, value: number) => void;
onDelete: (id: string) => void;
}
const SortableTierRow = ({ tier, onChange, onDelete }: SortableTierRowProps) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: tier.id,
});
return (
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
className={`flex items-center gap-3 border border-white/60 bg-white/55 backdrop-blur-xl rounded-xl p-3 ${
isDragging ? "opacity-50" : ""
}`}
>
<button
type="button"
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 shrink-0"
aria-label="Stufe verschieben"
>
<GripVertical size={18} />
</button>
<div className="flex-1 flex flex-col gap-1">
<label className="text-xs text-gray-500">Stunden vor Kursbeginn</label>
<Input
type="number"
min={0}
value={tier.hoursBeforeStart}
onChange={(e) => onChange(tier.id, "hoursBeforeStart", Number(e.target.value))}
placeholder="z.B. 24 für einen Tag vorher"
/>
</div>
<div className="flex-1 flex flex-col gap-1">
<label className="text-xs text-gray-500">Rückerstattung in %</label>
<Input
type="number"
min={0}
max={100}
value={tier.refundPercent}
onChange={(e) => onChange(tier.id, "refundPercent", Number(e.target.value))}
/>
</div>
<Button
variant="ghost"
size="icon"
className="text-red-500 hover:bg-red-50 hover:text-red-600 shrink-0"
onClick={() => onDelete(tier.id)}
>
<Trash2 size={16} />
</Button>
</div>
);
};
interface CancellationPolicyEditorProps {
initialTiers: CancellationTier[];
onSave: (tiers: CancellationTier[]) => Promise<boolean>;
isSaving: boolean;
}
const CancellationPolicyEditor = ({
initialTiers,
onSave,
isSaving,
}: CancellationPolicyEditorProps) => {
const idPrefix = useId();
const [rows, setRows] = useState<TierRow[]>(
initialTiers.map((tier, idx) => ({ ...tier, id: `${idPrefix}-${idx}` }))
);
const [nextId, setNextId] = useState(initialTiers.length);
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
setRows((prev) => {
const oldIndex = prev.findIndex((r) => r.id === active.id);
const newIndex = prev.findIndex((r) => r.id === over.id);
return arrayMove(prev, oldIndex, newIndex);
});
};
const handleChange = (id: string, field: keyof CancellationTier, value: number) => {
// type="number"-Felder liefern bei leerem/unvollständigem Input (z.B. "-",
// "1e", geleertes Feld) NaN statt eines nutzbaren Zahlenwerts. Ohne diesen
// Guard würde ein NaN den Server-seitigen Zod-Check durchfallen lassen und
// die Eingabe zum unkontrollierten/kontrollierten Wechsel der Input-Komponente führen.
const safeValue = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0;
setRows((prev) => prev.map((r) => (r.id === id ? { ...r, [field]: safeValue } : r)));
};
const handleDelete = (id: string) => {
setRows((prev) => prev.filter((r) => r.id !== id));
};
const handleAdd = () => {
setRows((prev) => [
...prev,
{ id: `${idPrefix}-${nextId}`, hoursBeforeStart: 0, refundPercent: 0 },
]);
setNextId((n) => n + 1);
};
// 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
// sie in der Timeline-Vorschau nie auftaucht (validTiers filtert sie dort
// ebenfalls raus). Beide Stellen müssen exakt denselben Filter verwenden,
// sonst weicht das Gespeicherte von der zuletzt gezeigten Vorschau ab.
const validTiers = rows
.map(({ hoursBeforeStart, refundPercent }) => ({ hoursBeforeStart, refundPercent }))
.filter((t) => t.hoursBeforeStart > 0 || t.refundPercent > 0);
const handleSave = async () => {
try {
const success = await onSave(validTiers);
if (!success) return;
// Nach erfolgreichem Speichern serverseitig sortierte Reihenfolge übernehmen.
const sorted = [...validTiers].sort((a, b) => b.hoursBeforeStart - a.hoursBeforeStart);
setRows(sorted.map((tier, idx) => ({ ...tier, id: `${idPrefix}-saved-${idx}` })));
} catch {
// onSave meldet Fehler bereits per Toast - hier nur sicherstellen, dass
// die Zeilen bei einem Fehler unverändert (nicht re-sortiert) bleiben.
}
};
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>
</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>
<Button size="sm" onClick={handleSave} disabled={isSaving}>
{isSaving ? "Speichert…" : "Speichern"}
</Button>
</div>
</div>
);
};
export default CancellationPolicyEditor;

View file

@ -0,0 +1,80 @@
import type { CancellationTier } from "../../schemas/cancellation-policy-schema";
interface CancellationPolicyTimelineProps {
tiers: CancellationTier[];
}
// Farbverlauf von großzügig (grün) zu streng (rot), passend zum Prozentsatz.
// Textfarbe pro Segment separat gewählt: weißer Text auf den dunkleren
// grün/rot-Tönen erreicht WCAG-AA-Kontrast, auf den helleren gelb/orange/grau-
// Tönen wird stattdessen dunkler Text verwendet (weiß auf diesen Tönen liegt
// unter 4.5:1 bei den hier genutzten 10-12px-Schriftgrößen).
function colorForPercent(percent: number): { bg: string; text: string } {
if (percent >= 75) return { bg: "bg-green-400", text: "text-white" };
if (percent >= 50) return { bg: "bg-yellow-400", text: "text-gray-900" };
if (percent >= 25) return { bg: "bg-orange-400", text: "text-gray-900" };
if (percent > 0) return { bg: "bg-red-400", text: "text-white" };
return { bg: "bg-gray-300", text: "text-gray-900" };
}
/**
* Zeigt die konfigurierten Stufen als horizontale Zeitleiste: von "lange vor
* Kursbeginn" (links, großzügigste Stufe) bis "Kursbeginn" (rechts, strengste
* Stufe gilt bis 0h). Segmentbreiten sind proportional zur Zeitspanne der
* jeweiligen Stufe, mit einer Mindestbreite, damit auch kurze Stufen sichtbar bleiben.
*/
const CancellationPolicyTimeline = ({ tiers }: CancellationPolicyTimelineProps) => {
if (tiers.length === 0) return null;
const sorted = [...tiers].sort((a, b) => b.hoursBeforeStart - a.hoursBeforeStart);
const mostLenientThreshold = sorted[0].hoursBeforeStart;
// Segmente von links (weit vor Kursbeginn) nach rechts (Kursbeginn):
// ein "unbegrenzt"-Segment vor der ersten Stufe, dann je Stufe ein Segment
// von ihrer eigenen Schwelle bis zur nächststrengeren Stufe. Die strengste
// Stufe reicht dabei bis 0h (Kursbeginn) - es gibt kein separates 0%-Segment,
// weil die strengste konfigurierte Stufe ihren Prozentsatz bis Kursbeginn behält.
//
// Das Label eines Segments nennt bewusst die EIGENE Schwelle der Stufe
// ("ab 24h") statt des abgeleiteten Zeitbereichs ("12-24h vorher") - so ist
// direkt erkennbar, zu welchem Eingabefeld ("Stunden vor Kursbeginn") das
// Segment gehört, auch wenn es sich zeitlich erst NACH dieser Schwelle
// (also näher am Kursbeginn) auf der Timeline befindet.
const segments = [
{ label: `> ${mostLenientThreshold}h`, percent: 100, span: mostLenientThreshold || 1 },
...sorted.map((tier, idx) => {
const lowerBound = sorted[idx + 1]?.hoursBeforeStart ?? 0;
const span = Math.max(tier.hoursBeforeStart - lowerBound, 0.5);
return { label: `ab ${tier.hoursBeforeStart}h`, percent: tier.refundPercent, span };
}),
];
const totalSpan = segments.reduce((sum, s) => sum + s.span, 0);
return (
<div className="space-y-2">
<div className="flex min-h-14 rounded-lg overflow-hidden border border-white/60">
{segments.map((segment, idx) => {
const { bg, text } = colorForPercent(segment.percent);
return (
<div
key={idx}
className={`${bg} ${text} flex flex-col items-center justify-center gap-0.5 py-1.5 px-1 text-center`}
style={{ width: `${Math.max((segment.span / totalSpan) * 100, 8)}%` }}
title={`${segment.label}: ${segment.percent}%`}
>
<span className="text-xs font-semibold">{segment.percent}%</span>
<span className="text-[10px] leading-tight whitespace-nowrap">{segment.label}</span>
</div>
);
})}
</div>
<div className="flex justify-between text-[11px] text-gray-400">
<span>← weiter im Voraus</span>
<span>Kursbeginn</span>
</div>
</div>
);
};
export default CancellationPolicyTimeline;

View file

@ -1,5 +1,132 @@
const SettingsCancellationPageView = () => {
return <div>SettingsCancellationPageView</div>;
"use client";
import { useState, useTransition } from "react";
import { CalendarClock, AlertTriangle } from "lucide-react";
import { toast } from "sonner";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/src/components/ui/select";
import CancellationPolicyEditor from "../components/CancellationPolicyEditor";
import {
getCancellationPolicy,
saveCancellationPolicy,
} from "../../actions/cancellation-settings-actions";
import type { CancellationTier } from "../../schemas/cancellation-policy-schema";
interface SettingsCancellationPageViewProps {
businesses: { id: string; name: string }[];
initialTiers: CancellationTier[];
}
const SettingsCancellationPageView = ({
businesses,
initialTiers,
}: SettingsCancellationPageViewProps) => {
const [selectedBusinessId, setSelectedBusinessId] = useState(businesses[0]?.id ?? "");
const [tiers, setTiers] = useState(initialTiers);
// 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
// leeren Liste überschreiben, ohne dass der Manager einen Fehler sieht.
const [loadError, setLoadError] = useState<string | null>(null);
const [loadingPolicy, startLoadingPolicy] = useTransition();
const [isSaving, startSaving] = useTransition();
const handleBusinessChange = (businessId: string) => {
const previousBusinessId = selectedBusinessId;
setSelectedBusinessId(businessId);
setLoadError(null);
startLoadingPolicy(async () => {
const result = await getCancellationPolicy(businessId);
if (result.success) {
setTiers(result.tiers);
} else {
// Auswahl zurücksetzen, damit die UI nicht unter dem neuen
// Business-Namen die (Stufen des vorherigen Business gehörenden) Werte
// anzeigt und ein Speichern sie fälschlich der neuen Business-ID zuordnet.
setSelectedBusinessId(previousBusinessId);
setLoadError(result.error);
toast.error(result.error);
}
});
};
const handleSave = (newTiers: CancellationTier[]): Promise<boolean> => {
return new Promise((resolve) => {
startSaving(async () => {
try {
const result = await saveCancellationPolicy(selectedBusinessId, newTiers);
if (result.success) {
toast.success("Stornierungsregeln gespeichert.");
resolve(true);
} else {
toast.error(result.error);
resolve(false);
}
} catch {
toast.error("Stornierungsregeln konnten nicht gespeichert werden.");
resolve(false);
}
});
});
};
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl flex items-center gap-2 font-bold">
<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.
</p>
</div>
{businesses.length === 0 ? (
<div className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-xl p-6 text-center">
<p className="text-sm text-gray-500">Du hast noch keine Businesses angelegt.</p>
</div>
) : (
<div className="space-y-4">
{businesses.length > 1 && (
<Select value={selectedBusinessId} onValueChange={handleBusinessChange}>
<SelectTrigger className="bg-white/60 backdrop-blur-xl border-white/40 w-full sm:w-64">
<SelectValue placeholder="Business" />
</SelectTrigger>
<SelectContent>
{businesses.map((b) => (
<SelectItem key={b.id} value={b.id}>
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{loadError ? (
<div className="flex gap-2 text-sm bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
<AlertTriangle size={16} className="shrink-0 mt-0.5" />
<p>{loadError} Bitte lade die Seite neu, um die Stornierungsregeln zu bearbeiten.</p>
</div>
) : (
<div className={loadingPolicy ? "opacity-60 transition-opacity" : "transition-opacity"}>
<CancellationPolicyEditor
key={selectedBusinessId}
initialTiers={tiers}
onSave={handleSave}
isSaving={isSaving}
/>
</div>
)}
</div>
)}
</div>
);
};
export default SettingsCancellationPageView;

View file

@ -7,6 +7,12 @@ import type { ZahlungenFilters } from "./zahlungen-actions";
// eine unbegrenzte Zahl an PDFs auf einmal rendert.
const MAX_ZIP_INVOICES = 500;
// Rechnungen gibt es für Buchungen, bei denen tatsächlich Geld beim Business
// verblieben ist - das schließt Teilerstattungen mit ein (der verbliebene
// Betrag wird in toInvoiceData berechnet), aber nicht volle Erstattungen
// ("refunded") oder unbezahlte/fehlgeschlagene Buchungen.
const INVOICEABLE_STATUSES = ["paid", "partially_refunded"] as const;
export type InvoiceCustomer = {
name: string;
email: string;
@ -33,6 +39,8 @@ export type InvoiceData = {
};
customer: InvoiceCustomer;
amountCents: number;
originalAmountCents: number;
refundedAmountCents: number; // 0, außer bei Teilerstattung
};
function formatInvoiceNumber(businessId: string, counter: number): string {
@ -94,6 +102,7 @@ async function ensureInvoiceNumber(
const invoiceBookingSelect = {
amountPaid: true,
refundedAmount: true,
paymentStatus: true,
course: {
select: {
@ -165,7 +174,15 @@ function toInvoiceData(
date: booking.course.date.toISOString(),
},
customer,
amountCents: booking.amountPaid ?? Math.round(booking.course.price * 100),
// Bei Teilerstattung zeigt die Rechnung den nach Erstattung verbliebenen
// Betrag als Endbetrag, weist aber zusätzlich den ursprünglichen Preis und
// die Erstattung separat aus - sonst wäre für Kunde und Buchhaltung nicht
// nachvollziehbar, warum die Rechnung von der Kursgebühr abweicht.
amountCents:
(booking.amountPaid ?? Math.round(booking.course.price * 100)) -
(booking.refundedAmount ?? 0),
originalAmountCents: booking.amountPaid ?? Math.round(booking.course.price * 100),
refundedAmountCents: booking.refundedAmount ?? 0,
};
}
@ -193,7 +210,10 @@ export async function assembleInvoiceData(
});
if (!booking || !booking.course.business) return null;
if (booking.paymentStatus !== "paid") return null;
if (
!INVOICEABLE_STATUSES.includes(booking.paymentStatus as (typeof INVOICEABLE_STATUSES)[number])
)
return null;
const { invoiceNumber, issuedAt } = await ensureInvoiceNumber(
bookingId,
@ -220,7 +240,7 @@ export async function assembleInvoiceDataBatch(
const bookings = await prisma.courseBooking.findMany({
where: {
id: { in: bookingIds },
paymentStatus: "paid",
paymentStatus: { in: [...INVOICEABLE_STATUSES] },
course: { businessId: { in: businessIds } },
},
select: { id: true, ...invoiceBookingSelect },
@ -256,7 +276,18 @@ export async function findInvoiceableBookingIds(
if (businessIds.length === 0) return { ids: [], truncated: false };
const where = buildZahlungenWhere(filters, businessIds);
where.paymentStatus = "paid"; // Rechnungen gibt es nur für bezahlte Buchungen
// Rechnungen gibt es nur für Buchungen mit Restwert (paid/partially_refunded).
// Ein evtl. aktiver Status-Filter des Nutzers wird respektiert, aber immer
// mit den rechenbaren Status geschnitten - z.B. filtert "Status: Storniert"
// dann auf keine Treffer statt fälschlich alle stornierten Buchungen zu erfassen.
const requestedStatus = filters.status;
where.paymentStatus =
requestedStatus &&
INVOICEABLE_STATUSES.includes(requestedStatus as (typeof INVOICEABLE_STATUSES)[number])
? requestedStatus
: requestedStatus
? { in: [] } // aktiver Filter schließt Rechnungen kategorisch aus
: { in: [...INVOICEABLE_STATUSES] };
const bookings = await prisma.courseBooking.findMany({
where,

View file

@ -4,7 +4,13 @@ import { prisma } from "@/src/lib/prisma";
import { requireManager } from "@/src/lib/auth/getUser";
import { buildZahlungenWhere } from "../lib/build-zahlungen-where";
export type ZahlungStatus = "paid" | "pending" | "refunded" | "failed";
export type ZahlungStatus =
| "paid"
| "pending"
| "refunded"
| "partially_refunded"
| "cancelled"
| "failed";
export type ZahlungenFilters = {
status?: ZahlungStatus;

View file

@ -36,7 +36,7 @@ export async function getZahlungMetrics(
async (): Promise<ZahlungMetrics> => {
const monthStart = startOfMonth(new Date());
const [paidAgg, pendingGroups] = await Promise.all([
const [paidAgg, partialAgg, pendingGroups] = await Promise.all([
prisma.courseBooking.aggregate({
where: {
course: { businessId: { in: scopedBusinessIds } },
@ -46,6 +46,20 @@ export async function getZahlungMetrics(
_sum: { amountPaid: true },
_count: true,
}),
// Teilerstattete Buchungen fließen mit ihrem NETTO-Betrag (amountPaid
// minus refundedAmount) in den Umsatz ein - das spiegelt das Geld
// wider, das tatsächlich beim Business verblieben ist, statt entweder
// den vollen ursprünglichen Betrag zu zählen oder die Buchung ganz
// aus der Summe zu lassen.
prisma.courseBooking.aggregate({
where: {
course: { businessId: { in: scopedBusinessIds } },
paymentStatus: "partially_refunded",
createdAt: { gte: monthStart },
},
_sum: { amountPaid: true, refundedAmount: true },
_count: true,
}),
// Pro Kurs gruppieren statt eine Zeile pro Buchung zu laden: bei vielen
// ausstehenden Buchungen für denselben Kurs bleibt die übertragene
// Zeilenzahl auf die Anzahl unterschiedlicher Kurse begrenzt.
@ -76,11 +90,14 @@ export async function getZahlungMetrics(
pendingAmount += Math.round(price * 100) * group._count._all;
}
const partialNetRevenue =
(partialAgg._sum.amountPaid ?? 0) - (partialAgg._sum.refundedAmount ?? 0);
return {
revenueThisMonth: paidAgg._sum.amountPaid ?? 0,
revenueThisMonth: (paidAgg._sum.amountPaid ?? 0) + partialNetRevenue,
pendingCount,
pendingAmount,
paidCount: paidAgg._count,
paidCount: paidAgg._count + partialAgg._count,
};
},
[`zahlungen-metrics-${manager.id}-${scopedBusinessIds.join(",")}`],

View file

@ -24,6 +24,14 @@ const styles = StyleSheet.create({
},
colDescription: { flex: 3 },
colAmount: { flex: 1, textAlign: "right" },
summaryRow: {
flexDirection: "row",
paddingTop: 6,
justifyContent: "flex-end",
},
summaryLabel: { fontSize: 10, marginRight: 24, color: "#666666" },
summaryValue: { fontSize: 10, color: "#666666" },
refundValue: { fontSize: 10, color: "#c53030" },
totalRow: {
flexDirection: "row",
paddingTop: 12,
@ -60,7 +68,17 @@ interface InvoicePdfDocumentProps {
}
export function InvoicePdfDocument({ data }: InvoicePdfDocumentProps) {
const { business, course, customer, invoiceNumber, issueDate, amountCents } = data;
const {
business,
course,
customer,
invoiceNumber,
issueDate,
amountCents,
originalAmountCents,
refundedAmountCents,
} = data;
const hasRefund = refundedAmountCents > 0;
const customerAddressLine =
!customer.isGuest && customer.street && customer.plz && customer.city
@ -104,10 +122,23 @@ export function InvoicePdfDocument({ data }: InvoicePdfDocumentProps) {
<Text>{course.name}</Text>
<Text style={styles.muted}>Termin: {formatDate(course.date)}</Text>
</View>
<Text style={styles.colAmount}>{formatPrice(amountCents)}</Text>
<Text style={styles.colAmount}>{formatPrice(originalAmountCents)}</Text>
</View>
</View>
{hasRefund ? (
<>
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>Zwischensumme</Text>
<Text style={styles.summaryValue}>{formatPrice(originalAmountCents)}</Text>
</View>
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>Erstattung (Stornierung)</Text>
<Text style={styles.refundValue}>−{formatPrice(refundedAmountCents)}</Text>
</View>
</>
) : null}
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>Gesamtbetrag</Text>
<Text style={styles.totalValue}>{formatPrice(amountCents)}</Text>

View file

@ -54,7 +54,9 @@ const ZahlungenFilterBar = ({ filters, onChange, businesses }: ZahlungenFilterBa
<SelectItem value="all">Alle Status</SelectItem>
<SelectItem value="paid">Bezahlt</SelectItem>
<SelectItem value="pending">Ausstehend</SelectItem>
<SelectItem value="refunded">Storniert</SelectItem>
<SelectItem value="partially_refunded">Teilweise erstattet</SelectItem>
<SelectItem value="refunded">Storniert (erstattet)</SelectItem>
<SelectItem value="cancelled">Storniert (ohne Erstattung)</SelectItem>
<SelectItem value="failed">Fehlgeschlagen</SelectItem>
</SelectContent>
</Select>

View file

@ -2,6 +2,13 @@ export const PAYMENT_STATUS: Record<string, { label: string; bg: string; text: s
paid: { label: "Bezahlt", bg: "bg-green-100", text: "text-green-700" },
pending: { label: "Ausstehend", bg: "bg-yellow-100", text: "text-yellow-700" },
refunded: { label: "Storniert", bg: "bg-red-100", text: "text-red-600" },
partially_refunded: {
label: "Teilweise erstattet",
bg: "bg-orange-100",
text: "text-orange-700",
},
cancelled: { label: "Storniert", bg: "bg-red-100", text: "text-red-600" },
refunding: { label: "Wird storniert…", bg: "bg-yellow-100", text: "text-yellow-700" },
failed: { label: "Fehlgeschlagen", bg: "bg-red-100", text: "text-red-600" },
};

View file

@ -138,7 +138,7 @@ const ZahlungenTable = ({
<ZahlungStatusBadge status={z.paymentStatus} />
</TableCell>
<TableCell>
{z.paymentStatus === "paid" && (
{(z.paymentStatus === "paid" || z.paymentStatus === "partially_refunded") && (
<a href={`/api/zahlungen/${z.id}/invoice`} download>
<Button
variant="ghost"