mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 21:41:17 +00:00
326 lines
14 KiB
TypeScript
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,
|
|
};
|
|
}
|