mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 21:41:17 +00:00
Merge pull request #140 from Since-3/feature/refunding
added rule-based refunding-logic
This commit is contained in:
commit
54b57632e8
29 changed files with 1603 additions and 163 deletions
56
package-lock.json
generated
56
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
8
src/lib/currency.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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
41
src/lib/stripe-refund.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
191
src/modules/courses/actions/cancel-booking-shared.ts
Normal file
191
src/modules/courses/actions/cancel-booking-shared.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
96
src/modules/courses/actions/cancellation-policy.ts
Normal file
96
src/modules/courses/actions/cancellation-policy.ts
Normal 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 };
|
||||
}
|
||||
68
src/modules/courses/actions/cancellation-preview-actions.ts
Normal file
68
src/modules/courses/actions/cancellation-preview-actions.ts
Normal 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 },
|
||||
};
|
||||
}
|
||||
165
src/modules/courses/ui/components/CancelBookingConfirmDialog.tsx
Normal file
165
src/modules/courses/ui/components/CancelBookingConfirmDialog.tsx
Normal 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;
|
||||
|
|
@ -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.");
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
45
src/modules/kunden/actions/kunde-cancel-booking-actions.ts
Normal file
45
src/modules/kunden/actions/kunde-cancel-booking-actions.ts
Normal 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." };
|
||||
}
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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." };
|
||||
}
|
||||
}
|
||||
17
src/modules/settings/schemas/cancellation-policy-schema.ts
Normal file
17
src/modules/settings/schemas/cancellation-policy-schema.ts
Normal 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>;
|
||||
222
src/modules/settings/ui/components/CancellationPolicyEditor.tsx
Normal file
222
src/modules/settings/ui/components/CancellationPolicyEditor.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(",")}`],
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue