Kuma/src/modules/courses/actions/cancel-booking-shared.ts
2026-10-01 23:28:32 +02:00

326 lines
14 KiB
TypeScript

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";
import { courseAmountCents } from "@/src/lib/booking-amounts";
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
import { notifyCourseWaitlist } from "./notify-waitlist";
import { notifyBusinessManager } from "@/src/modules/notifications/lib/create-notification";
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)),
]);
}
// Sockelzeit für den Wartelisten-Mailversand (deckt Kurs-/User-Lookups plus
// eine Handvoll E-Mails ab) + zusätzliche Zeit pro Wartelisten-Eintrag, da
// der Versand als Promise.allSettled-Fan-out läuft und mit mehr Empfängern
// länger dauert. Gedeckelt, damit ein sehr großer Kurs die serverlose
// Funktion nicht in die Nähe ihres eigenen Zeitlimits treibt (Vercel Hobby:
// 60s/Funktion) - danach bleiben unvollständige Sends bewusst hingenommen
// (siehe Docblock an notifyCourseWaitlist), bis der Versand auf einen
// eigenen Hintergrund-Job umzieht.
const WAITLIST_TIMEOUT_BASE_MS = 2000;
const WAITLIST_TIMEOUT_PER_ENTRY_MS = 300;
const WAITLIST_TIMEOUT_MAX_MS = 25000;
function computeWaitlistTimeoutMs(entryCount: number): number {
return Math.min(
WAITLIST_TIMEOUT_MAX_MS,
WAITLIST_TIMEOUT_BASE_MS + entryCount * WAITLIST_TIMEOUT_PER_ENTRY_MS
);
}
async function sendCancellationEmail(
booking: {
userId: string | null;
guestLeadId: string | null;
course: { name: string; business: { name: string; branding: unknown } | null };
},
amountRefundedCents: number,
originalAmountCents: number,
refundPercent: number,
cancelledBy: "user" | "manager",
reasonOverride?: "course_cancelled"
): 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:
reasonOverride ?? (cancelledBy === "manager" ? "cancelled_by_manager" : "cancelled_by_user"),
originalAmountCents,
companyName: process.env.COMPANY_NAME,
branding: toEmailBranding(booking.course.business),
});
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.
*
* `forceFullRefund` wird von der Kurs-Stornierung (executeCourseCancellation)
* gesetzt: wenn der GANZE Kurs ausfällt, trifft den Kunden keine Schuld, daher
* gilt weder die zeitbasierte Stornierungs-Policy noch die
* "Vergangene/begonnene Kurse"-Sperre - es wird immer zu 100% erstattet, auch
* wenn der Kurs schon begonnen hat oder die Frist eigentlich verstrichen wäre.
*/
export async function executeBookingCancellation(
bookingId: string,
cancelledBy: "user" | "manager",
options?: { forceFullRefund?: boolean; cancellationReason?: string }
): Promise<CancelBookingResult> {
const booking = await prisma.courseBooking.findUnique({
where: { id: bookingId },
select: {
paymentStatus: true,
amountPaid: true,
platformFee: true,
platformFeeOnTop: true,
stripePaymentIntentId: true,
stripeAccountId: true,
userId: true,
guestLeadId: true,
course: {
select: {
id: true,
name: true,
date: true,
timeFrom: true,
businessId: true,
business: { select: { name: true, branding: 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 forceFullRefund = options?.forceFullRefund ?? false;
// Stornoquote und Erstattungsbetrag beziehen sich auf den KURSANTEIL. Die
// Servicegebühr hat der Kunde zusätzlich gezahlt; sie ist Leistung der
// Plattform und wird bei einer regulären Stornierung einbehalten. Nur wenn
// der Kurs komplett ausfällt (forceFullRefund), geht auch sie zurück.
const courseAmount = courseAmountCents(booking);
if (!forceFullRefund) {
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.",
};
}
}
// Erst prüfen, ob Stornierung für dieses Business überhaupt erlaubt ist,
// BEVOR die Buchung auf "refunding" geclaimt wird - sonst würde eine
// gesperrte Stornierung die Buchung trotzdem in den Zwischenstatus schieben.
// Bei forceFullRefund entfällt diese Prüfung komplett (siehe Docblock oben).
const eligibility = forceFullRefund
? // Kursabsage durch das Studio: den Kunden trifft keine Schuld, er bekommt
// den vollen gezahlten Betrag inklusive Servicegebühr zurück - finanziert
// aus der Erstattungssumme, nicht über refund_application_fee.
{ cancellationAllowed: true, refundPercent: 100, refundAmountCents: booking.amountPaid }
: await calculateRefundAmountCents({
businessId: booking.course.businessId,
amountPaidCents: courseAmount,
courseStart: combineDateAndTime(booking.course.date, booking.course.timeFrom),
now: new Date(),
});
if (!eligibility.cancellationAllowed) {
return {
success: false,
error: "Stornierungen sind für dieses Business derzeit nicht möglich.",
};
}
const { refundPercent, refundAmountCents } = eligibility;
// Race-Schutz: bevor Stripe kontaktiert wird, wird die Buchung atomar von
// "paid" auf den Zwischenstatus "refunding" geclaimt. Zwei parallele Aufrufe
// (Doppelklick, erneuter Server-Action-Aufruf, o.ä.) können beide den obigen
// 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." };
}
// 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) {
// Die Erstattung geht vom Guthaben des Studios ab (Direct Charge) bzw. wird
// bei Altbuchungen per reverse_transfer dort zurückgeholt - beides
// entscheidet refundStripePayment anhand von stripeAccountId.
const refundResult = await refundStripePayment({
paymentIntentId: booking.stripePaymentIntentId,
stripeAccountId: booking.stripeAccountId,
// undefined hieße "komplette Charge" - und die enthält seit der fixen
// Servicegebühr mehr als den Kursanteil. Deshalb wird der Betrag bei
// jeder regulären Stornierung explizit gesetzt und nur bei der
// Kursabsage das Voll-Erstattungs-Verhalten genutzt.
amountCents:
forceFullRefund && refundAmountCents >= booking.amountPaid ? undefined : refundAmountCents,
idempotencyKey: `refund-${bookingId}`,
// Die Servicegebühr bleibt bei Kumasy - auch bei einer Kursabsage. Sie
// zurückzugeben würde den Kunden nicht besserstellen (der bekommt seinen
// vollen Betrag über die Erstattungssumme), sondern sie nur an das Studio
// abgeben, das den Kurs abgesagt hat.
refundApplicationFee: false,
});
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").
// "Vollständig erstattet" heißt: alles, was in diesem Fall erstattungsfähig
// war, ist zurück - bei regulärer Stornierung der Kursanteil, bei einer
// Kursabsage der komplette gezahlte Betrag.
const fullyRefundableCents = forceFullRefund ? booking.amountPaid : courseAmount;
const newPaymentStatus =
amountRefundedCents >= fullyRefundableCents
? "refunded"
: amountRefundedCents > 0
? "partially_refunded"
: "cancelled";
await prisma.courseBooking.update({
where: { id: bookingId },
data: {
paymentStatus: newPaymentStatus,
refundedAmount: amountRefundedCents,
refundedAt: new Date(),
cancelledBy,
...(options?.cancellationReason ? { cancellationReason: options.cancellationReason } : {}),
},
});
const emailPromise = sendCancellationEmail(
booking,
amountRefundedCents,
// Bezugsgröße der Stornoquote in der Mail - der Kursanteil, nicht die
// Charge inklusive Gebühr.
courseAmount,
refundPercent,
cancelledBy,
forceFullRefund ? "course_cancelled" : undefined
).catch((err) => console.error("Storno-Mail fehlgeschlagen:", err));
await withTimeout(emailPromise, 2000);
// In-App-Benachrichtigung ans Studio nur bei einer echten Selbst-Stornierung
// durch den Kunden. Manager-initiierte Stornos (cancelledBy === "manager")
// und der forceFullRefund-Pfad der Kurs-weiten Absage lösen hier bewusst
// nichts aus - für Letztere gibt es eine eigene "course.cancelled"-
// Benachrichtigung in executeCourseCancellation.
if (cancelledBy === "user" && !forceFullRefund) {
const notifyPromise = (async () => {
const customer = booking.userId
? await prisma.user.findUnique({
where: { id: booking.userId },
select: { name: true, email: true },
})
: null;
const customerLabel = customer?.name || customer?.email || "Ein Kunde";
const courseDate = booking.course.date.toLocaleDateString("de-DE", {
day: "numeric",
month: "long",
timeZone: "Europe/Berlin",
});
await notifyBusinessManager(booking.course.businessId, {
type: "booking.cancelled",
title: `Stornierung: ${booking.course.name}`,
body: `${customerLabel} hat die Buchung für den ${courseDate} storniert.`,
linkUrl: `/courses/edit/${booking.course.id}`,
});
})().catch((err) => console.error("Storno-Benachrichtigung fehlgeschlagen:", err));
await withTimeout(notifyPromise, 2000);
}
// Ein frei gewordener Platz soll allen Wartelisten-Einträgen des Kurses
// gemeldet werden - notifyCourseWaitlist prüft selbst frisch, ob der Kurs
// noch veröffentlicht ist (bricht bei einer Kurs-weiten Absage von selbst ab).
//
// Das Timeout richtet sich nach der Anzahl der Wartelisten-Einträge statt
// eines festen Werts - ein Kurs mit vielen Einträgen braucht für den
// Promise.allSettled-Fan-out entsprechend länger, ein fixes kurzes Limit
// würde dort systematisch Sends abschneiden (siehe computeWaitlistTimeoutMs
// oben). Bei einer sehr großen Warteliste greift trotzdem der Deckel -
// verbleibende Sends bleiben dann unvollständig (kein Retry), bis der
// Versand auf einen eigenen, vom Request-Lebenszyklus unabhängigen
// Hintergrund-Job umzieht.
const waitlistEntryCount = await prisma.courseWaitlistEntry.count({
where: { courseId: booking.course.id },
});
const waitlistPromise = notifyCourseWaitlist(booking.course.id).catch((err) =>
console.error("Warteliste-Benachrichtigung fehlgeschlagen:", err)
);
await withTimeout(waitlistPromise, computeWaitlistTimeoutMs(waitlistEntryCount));
return {
success: true,
refundedAmountCents: amountRefundedCents,
refundPercent,
};
}