mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 19:51:17 +00:00
see comments
This commit is contained in:
parent
7aa887c5aa
commit
d91b353dc2
20 changed files with 593 additions and 151 deletions
|
|
@ -85,7 +85,7 @@ ALTER TABLE "CustomerPass" ADD CONSTRAINT "CustomerPass_productId_fkey" FOREIGN
|
|||
ALTER TABLE "CustomerPass" ADD CONSTRAINT "CustomerPass_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CustomerPass" ADD CONSTRAINT "CustomerPass_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "CustomerPass" ADD CONSTRAINT "CustomerPass_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PassAdjustment" ADD CONSTRAINT "PassAdjustment_passId_fkey" FOREIGN KEY ("passId") REFERENCES "CustomerPass"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
|
|
|||
|
|
@ -446,8 +446,10 @@ model CustomerPass {
|
|||
id String @id @default(uuid())
|
||||
productId String
|
||||
businessId String
|
||||
// null = vom Studio an eine E-Mail vergeben, die noch kein Konto hat. Die Karte
|
||||
// hängt sich an das Konto, sobald sich jemand mit dieser Adresse anmeldet.
|
||||
// null = noch ohne Konto: vom Studio an eine E-Mail vergeben, oder das Konto
|
||||
// wurde gelöscht. Die Karte hängt sich an das Konto, sobald sich jemand mit
|
||||
// dieser Adresse anmeldet. Ein gelöschtes Konto nimmt die Karte bewusst nicht
|
||||
// mit: Kauf, Rechnung und Erstattung müssen erhalten bleiben.
|
||||
userId String?
|
||||
email String // Empfänger, immer lowercase
|
||||
name String
|
||||
|
|
@ -455,7 +457,7 @@ model CustomerPass {
|
|||
creditsTotal Int // alle Einheiten, die die Karte je bekommen hat (inkl. Gutschriften)
|
||||
creditsRemaining Int
|
||||
validUntil DateTime? // null = unbegrenzt gültig
|
||||
status String @default("active") // "active" | "ended" (vom Studio beendet) | "refunded"
|
||||
status String @default("active") // "active" | "ended" (vom Studio beendet) | "refunding" (Erstattung angestoßen, Ausgang offen) | "refunded"
|
||||
source String // "purchase" | "manual" (vom Studio vergeben, ohne Zahlung)
|
||||
// Zahlung - nur bei source = "purchase"
|
||||
priceCents Int?
|
||||
|
|
@ -472,7 +474,7 @@ model CustomerPass {
|
|||
refundedAt DateTime?
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: NoAction)
|
||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
bookings CourseBooking[]
|
||||
adjustments PassAdjustment[]
|
||||
createdAt DateTime @default(now())
|
||||
|
|
@ -490,7 +492,7 @@ model PassAdjustment {
|
|||
id String @id @default(uuid())
|
||||
passId String
|
||||
managerId String
|
||||
type String // "issue" | "credits" | "extend" | "end" | "refund"
|
||||
type String // "issue" | "credits" | "extend" | "end" | "refund" | "refund_failed"
|
||||
creditsDelta Int?
|
||||
previousValidUntil DateTime?
|
||||
newValidUntil DateTime?
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import {
|
|||
handlePassCheckoutCompleted,
|
||||
isPassCheckout,
|
||||
} from "@/src/modules/products/lib/pass-webhook";
|
||||
import { reconcilePassRefund } from "@/src/modules/products/lib/pass-refund";
|
||||
|
||||
/**
|
||||
* Sucht einen registrierten User anhand der E-Mail, case-insensitive.
|
||||
|
|
@ -68,6 +69,7 @@ async function findUserByEmailInsensitive(email: string) {
|
|||
* - checkout.session.expired / async_payment_failed → nie bezahlt: Rabattcode-
|
||||
* Reservierung und Platz-Hold freigeben, Pending-Buchung aufräumen.
|
||||
* - charge.updated → Stripe-Gebühr nachtragen, sobald Stripe sie kennt.
|
||||
* - charge.refunded → offene Kartenerstattung bestätigen (siehe pass-refund.ts).
|
||||
* - account.updated → Stripe Connect Account-Status synchronisieren.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
|
|
@ -113,6 +115,14 @@ export async function POST(request: NextRequest) {
|
|||
}
|
||||
break;
|
||||
}
|
||||
case "charge.refunded": {
|
||||
// Bestätigt eine Kartenerstattung, deren Ausgang beim Anstoßen offen
|
||||
// blieb (siehe pass-refund.ts). Kursbuchungen brauchen das nicht.
|
||||
if (stripeAccountId) {
|
||||
await reconcilePassRefund(event.data.object);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "charge.updated": {
|
||||
// Nur Direct Charges: bei Altbuchungen trug die Plattform die Gebühr,
|
||||
// für das Studio gibt es dort nichts nachzutragen.
|
||||
|
|
|
|||
|
|
@ -24,6 +24,11 @@ interface PassBookingCancellationEmailParams {
|
|||
/** Plätze, die gebucht bleiben (Teilstorno einer Mehrfachbuchung). */
|
||||
seatsRemaining: number;
|
||||
creditsReturned: boolean;
|
||||
/**
|
||||
* Warum Einheiten verfallen: Frist verstrichen ("policy") oder die Karte ist
|
||||
* nicht mehr gültig ("pass_inactive", z.B. abgelaufen). null = zurückgebucht.
|
||||
*/
|
||||
forfeitReason?: "policy" | "pass_inactive" | null;
|
||||
companyName?: string;
|
||||
branding?: EmailBranding;
|
||||
}
|
||||
|
|
@ -36,6 +41,7 @@ export function generatePassBookingCancellationEmail({
|
|||
seatsCancelled,
|
||||
seatsRemaining,
|
||||
creditsReturned,
|
||||
forfeitReason = null,
|
||||
companyName = "Ihr Unternehmen",
|
||||
branding = KUMASY_BRANDING,
|
||||
}: PassBookingCancellationEmailParams): { subject: string; html: string; text: string } {
|
||||
|
|
@ -65,9 +71,12 @@ export function generatePassBookingCancellationEmail({
|
|||
} für Sie gebucht.`
|
||||
: "";
|
||||
|
||||
const verfaellt = seatsCancelled === 1 ? "verfällt" : "verfallen";
|
||||
const creditsText = creditsReturned
|
||||
? `${units} ${seatsCancelled === 1 ? "wurde" : "wurden"} Ihrer Karte „${passName}“ wieder gutgeschrieben.`
|
||||
: `Gemäß den Stornierungsbedingungen von ${businessName} ${seatsCancelled === 1 ? "verfällt" : "verfallen"} ${units} Ihrer Karte „${passName}“.`;
|
||||
: forfeitReason === "pass_inactive"
|
||||
? `Ihre Karte „${passName}“ ist nicht mehr gültig, deshalb ${verfaellt} ${units}. Bei Fragen wenden Sie sich bitte an ${businessName}.`
|
||||
: `Gemäß den Stornierungsbedingungen von ${businessName} ${verfaellt} ${units} Ihrer Karte „${passName}“.`;
|
||||
|
||||
const subject = singleLine(`Stornierung bestätigt – ${courseName}`);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,32 @@ import { stripe } from "@/src/lib/stripe";
|
|||
|
||||
export type RefundResult =
|
||||
| { success: true; stripeRefundId: string; amountRefundedCents: number }
|
||||
| { success: false; error: string };
|
||||
| {
|
||||
success: false;
|
||||
error: string;
|
||||
/**
|
||||
* true = Stripe hat die Anfrage sicher abgelehnt, es ist kein Geld
|
||||
* geflossen. false = Ausgang unbekannt (Netzwerk, Zeitüberschreitung,
|
||||
* Stripe-Serverfehler) - die Erstattung kann trotzdem durchgegangen sein.
|
||||
*/
|
||||
definitive: boolean;
|
||||
/** Stripe-Fehlercode, z.B. "charge_already_refunded". */
|
||||
code?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fehlerarten, bei denen Stripe die Anfrage nachweislich nicht ausgeführt hat:
|
||||
* abgelehnt wegen Karte, ungültiger Anfrage (z.B. zu wenig Guthaben), fehlender
|
||||
* Berechtigung oder Rate Limit. Alles andere - Verbindungsabbruch, Timeout,
|
||||
* StripeAPIError - lässt offen, ob die Erstattung durchging.
|
||||
*/
|
||||
const DEFINITIVE_ERROR_TYPES = new Set([
|
||||
"StripeCardError",
|
||||
"StripeInvalidRequestError",
|
||||
"StripeAuthenticationError",
|
||||
"StripePermissionError",
|
||||
"StripeRateLimitError",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Erstattet eine Kurszahlung über Stripe.
|
||||
|
|
@ -58,9 +83,12 @@ export async function refundStripePayment(params: {
|
|||
};
|
||||
} catch (err) {
|
||||
console.error(`Stripe-Refund für PaymentIntent ${paymentIntentId} fehlgeschlagen:`, err);
|
||||
const { type, code } = err as { type?: string; code?: string };
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Stripe-Rückerstattung fehlgeschlagen",
|
||||
definitive: type !== undefined && DEFINITIVE_ERROR_TYPES.has(type),
|
||||
...(code ? { code } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ import {
|
|||
type PassCancellationReason,
|
||||
} from "@/src/lib/mail/templates/pass-booking-cancellation";
|
||||
|
||||
/** Die Buchung wurde parallel schon storniert - rollt eine bereits gebuchte Gutschrift zurück. */
|
||||
class CancellationConflict extends Error {}
|
||||
|
||||
export type CancelBookingResult =
|
||||
| {
|
||||
success: true;
|
||||
|
|
@ -457,6 +460,12 @@ async function notifyAfterCancellation({
|
|||
* Studios in diesem Moment 100 % erstatten würde - sonst verfallen sie. Bei
|
||||
* einer Kursabsage kommen sie immer zurück.
|
||||
*
|
||||
* Ausnahme: Die Karte selbst ist inzwischen abgelaufen, beendet oder erstattet.
|
||||
* Dann verfallen die Einheiten auch bei einer Kursabsage - der Kunde könnte sie
|
||||
* nicht mehr einlösen. Das Studio kann im Kunden-Detail verlängern und
|
||||
* gutschreiben, wenn es den Kunden entschädigen will. Mail und Storno-Dialog
|
||||
* nennen diesen Grund ausdrücklich.
|
||||
*
|
||||
* Kein Stripe-Aufruf, deshalb kein "refunding"-Zwischenstatus: Statuswechsel
|
||||
* und Gutschrift laufen in einer Transaktion, gebunden an den Platzstand, damit
|
||||
* ein Doppelklick nicht zweimal gutschreibt.
|
||||
|
|
@ -513,37 +522,54 @@ async function cancelPassBooking(
|
|||
}
|
||||
// Eine Einheit lässt sich nicht teilen: zurück gibt es sie nur, wenn die
|
||||
// Regel den vollen Betrag erstatten würde.
|
||||
const creditsReturned = eligibility.refundPercent >= 100;
|
||||
const policyAllowsReturn = eligibility.refundPercent >= 100;
|
||||
|
||||
const applied = await prisma.$transaction(async (tx) => {
|
||||
const claimed = await tx.courseBooking.updateMany({
|
||||
where: { id: bookingId, paymentStatus: "paid", seats: seatsBefore },
|
||||
data: cancelsAll
|
||||
? {
|
||||
paymentStatus: creditsReturned ? "refunded" : "cancelled",
|
||||
cancelledBy,
|
||||
...(options?.cancellationReason
|
||||
? { cancellationReason: options.cancellationReason }
|
||||
: {}),
|
||||
}
|
||||
: {
|
||||
seats: seatsBefore - count,
|
||||
spotIds: resolved.value.keptSpotIds,
|
||||
spotLabels: resolved.value.keptSpotLabels,
|
||||
},
|
||||
});
|
||||
if (claimed.count !== 1) return false;
|
||||
if (creditsReturned) {
|
||||
await tx.customerPass.update({
|
||||
where: { id: booking.passId },
|
||||
data: { creditsRemaining: { increment: count } },
|
||||
let creditsReturned: boolean;
|
||||
try {
|
||||
creditsReturned = await prisma.$transaction(async (tx) => {
|
||||
const now = new Date();
|
||||
// Gutschrift nur auf eine Karte, die noch einlösbar ist - geprüft in
|
||||
// derselben Anweisung, damit eine gleichzeitig beendete Karte nichts
|
||||
// mehr bekommt.
|
||||
const returned =
|
||||
policyAllowsReturn &&
|
||||
(
|
||||
await tx.customerPass.updateMany({
|
||||
where: {
|
||||
id: booking.passId,
|
||||
status: "active",
|
||||
OR: [{ validUntil: null }, { validUntil: { gt: now } }],
|
||||
},
|
||||
data: { creditsRemaining: { increment: count } },
|
||||
})
|
||||
).count === 1;
|
||||
|
||||
const claimed = await tx.courseBooking.updateMany({
|
||||
where: { id: bookingId, paymentStatus: "paid", seats: seatsBefore },
|
||||
data: cancelsAll
|
||||
? {
|
||||
paymentStatus: returned ? "refunded" : "cancelled",
|
||||
cancelledBy,
|
||||
...(options?.cancellationReason
|
||||
? { cancellationReason: options.cancellationReason }
|
||||
: {}),
|
||||
}
|
||||
: {
|
||||
seats: seatsBefore - count,
|
||||
spotIds: resolved.value.keptSpotIds,
|
||||
spotLabels: resolved.value.keptSpotLabels,
|
||||
},
|
||||
});
|
||||
if (claimed.count !== 1) throw new CancellationConflict();
|
||||
return returned;
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof CancellationConflict) {
|
||||
return { success: false, error: "Stornierung läuft bereits oder wurde bereits verarbeitet." };
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (!applied) {
|
||||
return { success: false, error: "Stornierung läuft bereits oder wurde bereits verarbeitet." };
|
||||
throw error;
|
||||
}
|
||||
const forfeitReason = creditsReturned ? null : policyAllowsReturn ? "pass_inactive" : "policy";
|
||||
|
||||
const remainingSeats = cancelsAll ? 0 : seatsBefore - count;
|
||||
|
||||
|
|
@ -567,6 +593,7 @@ async function cancelPassBooking(
|
|||
seatsCancelled: count,
|
||||
seatsRemaining: remainingSeats,
|
||||
creditsReturned,
|
||||
forfeitReason,
|
||||
companyName: process.env.COMPANY_NAME,
|
||||
branding: toEmailBranding(booking.course.business),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { prisma } from "@/src/lib/prisma";
|
||||
import { getUserData, isUser, isManager } from "@/src/lib/auth/getUser";
|
||||
import { isPassActive } from "@/src/modules/products/lib/pass-rules";
|
||||
import { combineDateAndTime, calculateRefundAmountCents } from "./cancellation-policy";
|
||||
import {
|
||||
resolveSeatCancellation,
|
||||
|
|
@ -23,9 +24,15 @@ export type CancellationPreview = {
|
|||
seatsToCancel: number;
|
||||
/**
|
||||
* Mit einer Karte gebucht: statt Geld geht es um Einheiten. creditsReturned =
|
||||
* sie kommen zurück auf die Karte, sonst verfallen sie. null = bezahlte Buchung.
|
||||
* sie kommen zurück auf die Karte, sonst verfallen sie - wegen der Frist
|
||||
* ("policy") oder weil die Karte nicht mehr gültig ist ("pass_inactive").
|
||||
* null = bezahlte Buchung.
|
||||
*/
|
||||
pass: { name: string; creditsReturned: boolean } | null;
|
||||
pass: {
|
||||
name: string;
|
||||
creditsReturned: boolean;
|
||||
forfeitReason: "policy" | "pass_inactive" | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -60,7 +67,7 @@ export async function previewBookingCancellation(
|
|||
spotIds: true,
|
||||
spotLabels: true,
|
||||
passId: true,
|
||||
pass: { select: { name: true } },
|
||||
pass: { select: { name: true, status: true, validUntil: true } },
|
||||
course: { select: { date: true, timeFrom: true, businessId: true } },
|
||||
},
|
||||
});
|
||||
|
|
@ -86,14 +93,17 @@ export async function previewBookingCancellation(
|
|||
const spots = booking.spotIds.map((id, i) => ({ id, label: booking.spotLabels[i] ?? "" }));
|
||||
|
||||
// Gleiche Regel wie in cancelPassBooking: zurück nur, wenn die Stornoregel
|
||||
// jetzt 100 % erstatten würde.
|
||||
// jetzt 100 % erstatten würde und die Karte noch einlösbar ist.
|
||||
if (booking.passId) {
|
||||
const now = new Date();
|
||||
const { cancellationAllowed, refundPercent } = await calculateRefundAmountCents({
|
||||
businessId: booking.course.businessId,
|
||||
amountPaidCents: 0,
|
||||
courseStart,
|
||||
now: new Date(),
|
||||
now,
|
||||
});
|
||||
const passUsable = booking.pass !== null && isPassActive(booking.pass, now);
|
||||
const creditsReturned = refundPercent >= 100 && passUsable;
|
||||
return {
|
||||
success: true,
|
||||
preview: {
|
||||
|
|
@ -104,7 +114,11 @@ export async function previewBookingCancellation(
|
|||
seats: booking.seats,
|
||||
spots,
|
||||
seatsToCancel: resolved.value.count,
|
||||
pass: { name: booking.pass?.name ?? "Karte", creditsReturned: refundPercent >= 100 },
|
||||
pass: {
|
||||
name: booking.pass?.name ?? "Karte",
|
||||
creditsReturned,
|
||||
forfeitReason: creditsReturned ? null : passUsable ? "policy" : "pass_inactive",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ const VARIANT_COPY: Record<
|
|||
passDescriptionPlural: string;
|
||||
passCreditsBack: (count: number, passName: string) => string;
|
||||
passCreditsForfeit: (count: number) => string;
|
||||
/** Die Karte ist abgelaufen, beendet oder erstattet - Einheiten können nicht zurück. */
|
||||
passCreditsForfeitInactive: (count: number) => string;
|
||||
}
|
||||
> = {
|
||||
user: {
|
||||
|
|
@ -52,6 +54,8 @@ const VARIANT_COPY: Record<
|
|||
`${unitsLabel(count)} ${count === 1 ? "geht" : "gehen"} zurück auf deine Karte „${passName}“.`,
|
||||
passCreditsForfeit: (count) =>
|
||||
`Da du kurzfristig stornierst, ${count === 1 ? "verfällt" : "verfallen"} ${unitsLabel(count)} gemäß den Stornierungsbedingungen.`,
|
||||
passCreditsForfeitInactive: (count) =>
|
||||
`Deine Karte ist nicht mehr gültig, deshalb ${count === 1 ? "verfällt" : "verfallen"} ${unitsLabel(count)}.`,
|
||||
},
|
||||
manager: {
|
||||
description:
|
||||
|
|
@ -67,6 +71,8 @@ const VARIANT_COPY: Record<
|
|||
`${unitsLabel(count)} ${count === 1 ? "geht" : "gehen"} zurück auf die Karte „${passName}“ des Kunden.`,
|
||||
passCreditsForfeit: (count) =>
|
||||
`Gemäß den Stornierungsbedingungen ${count === 1 ? "verfällt" : "verfallen"} ${unitsLabel(count)}.`,
|
||||
passCreditsForfeitInactive: (count) =>
|
||||
`Die Karte des Kunden ist nicht mehr gültig, deshalb ${count === 1 ? "verfällt" : "verfallen"} ${unitsLabel(count)}. Du kannst sie im Kunden-Detail verlängern und gutschreiben.`,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -276,7 +282,11 @@ function CancelBookingDialogBody({
|
|||
) : (
|
||||
<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.passCreditsForfeit(toCancel)}</p>
|
||||
<p>
|
||||
{preview.pass.forfeitReason === "pass_inactive"
|
||||
? copy.passCreditsForfeitInactive(toCancel)
|
||||
: copy.passCreditsForfeit(toCancel)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
) : preview ? (
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export type Kunde = KundeRow;
|
|||
interface KundenViewProps {
|
||||
initialKunden: KundeRow[];
|
||||
issueOptions: PassIssueOption[];
|
||||
/** Vergebene Karten, deren Empfänger sich noch nicht registriert hat. */
|
||||
/** Aktive Karten ohne Konto - vergeben vor der Registrierung oder Konto gelöscht. */
|
||||
pendingPasses: PendingPass[];
|
||||
}
|
||||
|
||||
|
|
@ -68,7 +68,7 @@ const KundenView = ({ initialKunden, issueOptions, pendingPasses }: KundenViewPr
|
|||
|
||||
{pendingPasses.length > 0 && (
|
||||
<section className="mt-8">
|
||||
<h2 className="text-xl font-semibold">Vergebene Karten ohne Konto</h2>
|
||||
<h2 className="text-xl font-semibold">Karten ohne Konto</h2>
|
||||
<p className="mb-3 mt-1 text-sm text-muted-foreground">
|
||||
Sobald sich jemand mit dieser E-Mail-Adresse registriert, erscheint die Karte in seinem
|
||||
Konto.
|
||||
|
|
@ -83,8 +83,8 @@ const KundenView = ({ initialKunden, issueOptions, pendingPasses }: KundenViewPr
|
|||
<span className="text-muted-foreground">
|
||||
{pass.name} · {pass.creditsRemaining}{" "}
|
||||
{pass.creditsRemaining === 1 ? "Einheit" : "Einheiten"}
|
||||
{pass.validUntil && ` · bis ${formatPassDate(new Date(pass.validUntil))}`} ·
|
||||
vergeben am {formatPassDate(new Date(pass.createdAt))}
|
||||
{pass.validUntil && ` · bis ${formatPassDate(new Date(pass.validUntil))}`} · seit{" "}
|
||||
{formatPassDate(new Date(pass.createdAt))}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { sendMail } from "@/src/lib/mail/resend";
|
|||
import { generatePassConfirmationEmail } from "@/src/lib/mail/templates/pass-confirmation";
|
||||
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
|
||||
import { berlinDayEnd } from "@/src/modules/courses/lib/course-days";
|
||||
import { markPassRefunded, restorePassAfterFailedRefund } from "../lib/pass-refund";
|
||||
|
||||
/**
|
||||
* Was der Manager im Kunden-Detail mit Karten machen kann. Jede Änderung steht
|
||||
|
|
@ -176,7 +177,7 @@ export async function adjustPassCredits(input: {
|
|||
manager.businesses.map((b) => b.id)
|
||||
);
|
||||
if (!pass) return { success: false, error: "Karte nicht gefunden." };
|
||||
if (pass.status === "refunded") {
|
||||
if (pass.status === "refunded" || pass.status === "refunding") {
|
||||
return { success: false, error: "Eine erstattete Karte lässt sich nicht mehr ändern." };
|
||||
}
|
||||
|
||||
|
|
@ -284,14 +285,20 @@ export async function endPass(input: { passId: string; reason: string }): Promis
|
|||
);
|
||||
if (!pass) return { success: false, error: "Karte nicht gefunden." };
|
||||
|
||||
const { count } = await prisma.customerPass.updateMany({
|
||||
where: { id: pass.id, status: "active" },
|
||||
data: { status: "ended" },
|
||||
});
|
||||
if (count !== 1) return { success: false, error: "Die Karte ist nicht mehr aktiv." };
|
||||
await prisma.passAdjustment.create({
|
||||
data: { passId: pass.id, managerId: manager.id, type: "end", reason: reason.data || null },
|
||||
// Status und Protokoll gemeinsam - eine beendete Karte ohne Eintrag im
|
||||
// Verlauf wäre später nicht mehr nachvollziehbar.
|
||||
const ended = await prisma.$transaction(async (tx) => {
|
||||
const { count } = await tx.customerPass.updateMany({
|
||||
where: { id: pass.id, status: "active" },
|
||||
data: { status: "ended" },
|
||||
});
|
||||
if (count !== 1) return false;
|
||||
await tx.passAdjustment.create({
|
||||
data: { passId: pass.id, managerId: manager.id, type: "end", reason: reason.data || null },
|
||||
});
|
||||
return true;
|
||||
});
|
||||
if (!ended) return { success: false, error: "Die Karte ist nicht mehr aktiv." };
|
||||
|
||||
refreshCustomer(pass.userId);
|
||||
return { success: true };
|
||||
|
|
@ -310,6 +317,13 @@ export async function endPass(input: { passId: string; reason: string }): Promis
|
|||
* Der Kunde bekommt den vollen Betrag inklusive Servicegebühr zurück. Die
|
||||
* Gebühr behält Kumasy, das Studio trägt sie - wie bei einer Kursabsage: wer
|
||||
* zurückzahlt, trägt die Kosten (siehe stripe-refund.ts).
|
||||
*
|
||||
* Ablauf (siehe pass-refund.ts): Erst sperren und protokollieren, dann Stripe.
|
||||
* Zurückgesetzt wird nur, wenn Stripe sicher abgelehnt hat. Bleibt der Ausgang
|
||||
* offen, bleibt die Karte gesperrt, bis der Webhook die Erstattung bestätigt
|
||||
* oder der Manager erneut prüft. Ein erneuter Aufruf ist sicher: Der
|
||||
* Idempotency-Key verhindert eine zweite Erstattung, und eine schon erstattete
|
||||
* Zahlung meldet Stripe als charge_already_refunded.
|
||||
*/
|
||||
export async function refundPass(input: { passId: string }): Promise<ActionResult> {
|
||||
const manager = await requireManager();
|
||||
|
|
@ -327,34 +341,50 @@ export async function refundPass(input: { passId: string }): Promise<ActionResul
|
|||
return { success: false, error: "Die Karte wurde bereits erstattet." };
|
||||
}
|
||||
|
||||
const used = await prisma.courseBooking.count({
|
||||
where: { passId: pass.id, paymentStatus: { in: ["paid", "refunding", "cancelled"] } },
|
||||
});
|
||||
if (used > 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Die Karte wurde schon genutzt und kann nicht mehr erstattet werden.",
|
||||
};
|
||||
}
|
||||
// Beim ersten Versuch sperren - bei "refunding" lief schon einer, dessen
|
||||
// Ausgang offen ist; dann nur erneut bei Stripe nachfragen.
|
||||
if (pass.status !== "refunding") {
|
||||
const used = await prisma.courseBooking.count({
|
||||
where: { passId: pass.id, paymentStatus: { in: ["paid", "refunding", "cancelled"] } },
|
||||
});
|
||||
if (used > 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Die Karte wurde schon genutzt und kann nicht mehr erstattet werden.",
|
||||
};
|
||||
}
|
||||
|
||||
// Vor dem Stripe-Aufruf sperren: ein Doppelklick erstattet nicht zweimal,
|
||||
// und eine gleichzeitige Buchung findet die Karte nicht mehr aktiv. Die
|
||||
// Bindung an den gelesenen Stand fängt eine Buchung ab, die zwischen der
|
||||
// Prüfung oben und hier Einheiten abgezogen hat.
|
||||
const { count } = await prisma.customerPass.updateMany({
|
||||
where: {
|
||||
id: pass.id,
|
||||
status: { in: ["active", "ended"] },
|
||||
creditsRemaining: pass.creditsRemaining,
|
||||
},
|
||||
data: { status: "refunded", creditsRemaining: 0 },
|
||||
});
|
||||
if (count !== 1) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"Die Karte hat sich gerade geändert. Bitte lade die Seite neu und versuche es erneut.",
|
||||
};
|
||||
// Sperre und Protokoll gemeinsam: Danach ist die Karte nicht mehr
|
||||
// einlösbar, egal was mit dem Stripe-Aufruf passiert. Die Bindung an den
|
||||
// gelesenen Stand fängt eine Buchung ab, die zwischen der Prüfung oben
|
||||
// und hier Einheiten abgezogen hat.
|
||||
const locked = await prisma.$transaction(async (tx) => {
|
||||
const { count } = await tx.customerPass.updateMany({
|
||||
where: {
|
||||
id: pass.id,
|
||||
status: { in: ["active", "ended"] },
|
||||
creditsRemaining: pass.creditsRemaining,
|
||||
},
|
||||
data: { status: "refunding", creditsRemaining: 0 },
|
||||
});
|
||||
if (count !== 1) return false;
|
||||
await tx.passAdjustment.create({
|
||||
data: {
|
||||
passId: pass.id,
|
||||
managerId: manager.id,
|
||||
type: "refund",
|
||||
creditsDelta: -pass.creditsRemaining,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
});
|
||||
if (!locked) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"Die Karte hat sich gerade geändert. Bitte lade die Seite neu und versuche es erneut.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const refund = await refundStripePayment({
|
||||
|
|
@ -363,32 +393,28 @@ export async function refundPass(input: { passId: string }): Promise<ActionResul
|
|||
idempotencyKey: `refund-pass-${pass.id}`,
|
||||
refundApplicationFee: false,
|
||||
});
|
||||
if (!refund.success) {
|
||||
await prisma.customerPass.update({
|
||||
where: { id: pass.id },
|
||||
data: { status: pass.status, creditsRemaining: pass.creditsRemaining },
|
||||
});
|
||||
|
||||
if (refund.success || refund.code === "charge_already_refunded") {
|
||||
await markPassRefunded(
|
||||
pass.id,
|
||||
refund.success ? refund.amountRefundedCents : pass.amountPaid
|
||||
);
|
||||
refreshCustomer(pass.userId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (refund.definitive) {
|
||||
await restorePassAfterFailedRefund(pass.id, manager.id, refund.error);
|
||||
refreshCustomer(pass.userId);
|
||||
return { success: false, error: `Erstattung fehlgeschlagen: ${refund.error}` };
|
||||
}
|
||||
|
||||
await prisma.customerPass.update({
|
||||
where: { id: pass.id },
|
||||
data: {
|
||||
refundedAmount: refund.amountRefundedCents,
|
||||
refundedAt: new Date(),
|
||||
adjustments: {
|
||||
create: {
|
||||
managerId: manager.id,
|
||||
type: "refund",
|
||||
creditsDelta: -pass.creditsRemaining,
|
||||
reason: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
refreshCustomer(pass.userId);
|
||||
return { success: true };
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"Stripe hat nicht eindeutig geantwortet. Die Karte bleibt gesperrt, bis die Erstattung bestätigt ist - Stripe meldet das von selbst, oder du prüfst es später erneut.",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error refunding pass:", error);
|
||||
return { success: false, error: "Erstattung fehlgeschlagen." };
|
||||
|
|
@ -425,7 +451,10 @@ export type PendingPass = {
|
|||
createdAt: string;
|
||||
};
|
||||
|
||||
/** Vergebene Karten, deren Empfänger noch kein Konto hat. */
|
||||
/**
|
||||
* Aktive Karten ohne Konto: vom Studio vergeben, bevor sich der Empfänger
|
||||
* registriert hat, oder gekauft von einem inzwischen gelöschten Konto.
|
||||
*/
|
||||
export async function getPendingPasses(): Promise<PendingPass[]> {
|
||||
const manager = await requireManager();
|
||||
const passes = await prisma.customerPass.findMany({
|
||||
|
|
|
|||
78
src/modules/products/lib/pass-refund.ts
Normal file
78
src/modules/products/lib/pass-refund.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import type Stripe from "stripe";
|
||||
import { prisma } from "@/src/lib/prisma";
|
||||
|
||||
/**
|
||||
* Abschluss einer Kartenerstattung. Eine Erstattung läuft in zwei Schritten:
|
||||
* refundPass sperrt die Karte ("refunding", keine freien Einheiten) und
|
||||
* protokolliert das, dann geht der Auftrag an Stripe. Erst wenn Stripe sie
|
||||
* bestätigt - direkt in der Antwort oder später per Webhook - steht die Karte
|
||||
* auf "refunded". Bleibt der Ausgang offen (Timeout, Netzwerk), bleibt sie
|
||||
* gesperrt: Der Kunde hat dann vielleicht schon sein Geld und darf die
|
||||
* Einheiten nicht mehr einlösen.
|
||||
*/
|
||||
|
||||
/** Setzt eine gesperrte Karte auf erstattet. Wirkt nur einmal, auch bei doppelter Bestätigung. */
|
||||
export async function markPassRefunded(passId: string, amountRefundedCents: number) {
|
||||
return prisma.customerPass.updateMany({
|
||||
where: { id: passId, status: "refunding" },
|
||||
data: { status: "refunded", refundedAmount: amountRefundedCents, refundedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stripe hat die Erstattung sicher abgelehnt: Die Karte bekommt Status und
|
||||
* Einheiten von vor der Sperre zurück. Beides steht im Protokoll - die Sperre
|
||||
* hat die Einheiten als "refund" abgezogen, und "beendet" kann eine Karte nur
|
||||
* über endPass werden (und nie zurück).
|
||||
*/
|
||||
export async function restorePassAfterFailedRefund(
|
||||
passId: string,
|
||||
managerId: string,
|
||||
error: string
|
||||
): Promise<void> {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const [lock, ended] = await Promise.all([
|
||||
tx.passAdjustment.findFirst({
|
||||
where: { passId, type: "refund" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { creditsDelta: true },
|
||||
}),
|
||||
tx.passAdjustment.count({ where: { passId, type: "end" } }),
|
||||
]);
|
||||
const credits = -(lock?.creditsDelta ?? 0);
|
||||
|
||||
const { count } = await tx.customerPass.updateMany({
|
||||
where: { id: passId, status: "refunding" },
|
||||
data: { status: ended > 0 ? "ended" : "active", creditsRemaining: credits },
|
||||
});
|
||||
if (count !== 1) return;
|
||||
|
||||
await tx.passAdjustment.create({
|
||||
data: {
|
||||
passId,
|
||||
managerId,
|
||||
type: "refund_failed",
|
||||
creditsDelta: credits,
|
||||
reason: error.slice(0, 200),
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Webhook charge.refunded: bestätigt eine Kartenerstattung, deren Ausgang beim
|
||||
* Anstoßen offen blieb. Nur vollständig erstattete Zahlungen zählen - eine
|
||||
* Karte wird nie teilweise erstattet.
|
||||
*/
|
||||
export async function reconcilePassRefund(charge: Stripe.Charge): Promise<void> {
|
||||
if (!charge.refunded) return;
|
||||
const paymentIntentId =
|
||||
typeof charge.payment_intent === "string" ? charge.payment_intent : charge.payment_intent?.id;
|
||||
if (!paymentIntentId) return;
|
||||
|
||||
const pass = await prisma.customerPass.findFirst({
|
||||
where: { stripePaymentIntentId: paymentIntentId, status: "refunding" },
|
||||
select: { id: true },
|
||||
});
|
||||
if (pass) await markPassRefunded(pass.id, charge.amount_refunded);
|
||||
}
|
||||
|
|
@ -31,11 +31,18 @@ export function canRedeemPass(pass: PassState, seats: number, now: Date): boolea
|
|||
return isPassActive(pass, now) && pass.creditsRemaining >= seats;
|
||||
}
|
||||
|
||||
export type PassDisplayStatus = "active" | "used_up" | "expired" | "ended" | "refunded";
|
||||
export type PassDisplayStatus =
|
||||
| "active"
|
||||
| "used_up"
|
||||
| "expired"
|
||||
| "ended"
|
||||
| "refunding"
|
||||
| "refunded";
|
||||
|
||||
/** Ein Status für Badge und Sortierung. Erstattet und beendet gehen vor abgelaufen. */
|
||||
export function passDisplayStatus(pass: PassState, now: Date): PassDisplayStatus {
|
||||
if (pass.status === "refunded") return "refunded";
|
||||
if (pass.status === "refunding") return "refunding";
|
||||
if (pass.status === "ended") return "ended";
|
||||
if (pass.validUntil !== null && pass.validUntil <= now) return "expired";
|
||||
if (pass.creditsRemaining <= 0) return "used_up";
|
||||
|
|
@ -47,6 +54,7 @@ export const PASS_STATUS_LABELS: Record<PassDisplayStatus, string> = {
|
|||
used_up: "Aufgebraucht",
|
||||
expired: "Abgelaufen",
|
||||
ended: "Beendet",
|
||||
refunding: "Erstattung läuft",
|
||||
refunded: "Erstattet",
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -59,7 +59,9 @@ function describeAdjustment(adjustment: KundePassAdjustment): string {
|
|||
case "end":
|
||||
return "Beendet";
|
||||
case "refund":
|
||||
return "Erstattet";
|
||||
return "Erstattung angestoßen";
|
||||
case "refund_failed":
|
||||
return `Erstattung von Stripe abgelehnt, ${units(delta)} wieder frei`;
|
||||
default:
|
||||
return adjustment.type;
|
||||
}
|
||||
|
|
@ -175,7 +177,7 @@ const KundePassesSection = ({ passes, customer, issueOptions }: KundePassesSecti
|
|||
onClick={() => setActive({ pass, action: "refund" })}
|
||||
>
|
||||
<Undo2 size={14} className="mr-1.5" />
|
||||
Erstatten
|
||||
{pass.status === "refunding" ? "Erstattung prüfen" : "Erstatten"}
|
||||
</Button>
|
||||
)}
|
||||
{editable && (
|
||||
|
|
@ -310,7 +312,7 @@ const PassActionDialog = ({
|
|||
credits: "Einheiten anpassen",
|
||||
validity: "Ablaufdatum ändern",
|
||||
end: "Karte beenden?",
|
||||
refund: "Karte erstatten?",
|
||||
refund: pass.status === "refunding" ? "Erstattung prüfen" : "Karte erstatten?",
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -398,18 +400,24 @@ const PassActionDialog = ({
|
|||
</p>
|
||||
)}
|
||||
|
||||
{action === "refund" && (
|
||||
<p className="text-sm text-gray-600">
|
||||
Der Kunde bekommt{" "}
|
||||
<strong>
|
||||
{pass.amountPaidCents !== null
|
||||
? formatPrice(pass.amountPaidCents)
|
||||
: "den Kaufpreis"}
|
||||
</strong>{" "}
|
||||
vollständig zurück, die Karte wird ungültig. Die Servicegebühr trägst du, wie bei
|
||||
einer Kursabsage.
|
||||
</p>
|
||||
)}
|
||||
{action === "refund" &&
|
||||
(pass.status === "refunding" ? (
|
||||
<p className="text-sm text-gray-600">
|
||||
Stripe hat beim letzten Versuch nicht eindeutig geantwortet. Erneut prüfen ist
|
||||
sicher: Stripe erstattet höchstens einmal.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-gray-600">
|
||||
Der Kunde bekommt{" "}
|
||||
<strong>
|
||||
{pass.amountPaidCents !== null
|
||||
? formatPrice(pass.amountPaidCents)
|
||||
: "den Kaufpreis"}
|
||||
</strong>{" "}
|
||||
vollständig zurück, die Karte wird ungültig. Die Servicegebühr trägst du, wie bei
|
||||
einer Kursabsage.
|
||||
</p>
|
||||
))}
|
||||
|
||||
{action !== "refund" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
|
|
@ -447,7 +455,9 @@ const PassActionDialog = ({
|
|||
: action === "end"
|
||||
? "Karte beenden"
|
||||
: action === "refund"
|
||||
? "Erstatten"
|
||||
? pass.status === "refunding"
|
||||
? "Erneut prüfen"
|
||||
: "Erstatten"
|
||||
: "Speichern"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
|
|
|||
|
|
@ -237,7 +237,12 @@ export async function getZahlungen(
|
|||
amountPaid: (p.amountPaid ?? 0) - (p.platformFee ?? 0),
|
||||
stripeFeeCents: p.stripeFee,
|
||||
payoutCents: (p.amountPaid ?? 0) - (p.platformFee ?? 0) - (p.stripeFee ?? 0),
|
||||
paymentStatus: p.status === "refunded" ? "pass_refunded" : "paid",
|
||||
paymentStatus:
|
||||
p.status === "refunded"
|
||||
? "pass_refunded"
|
||||
: p.status === "refunding"
|
||||
? "pass_refunding"
|
||||
: "paid",
|
||||
invoiceNumber: p.invoiceNumber,
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -10,8 +10,10 @@ export const PAYMENT_STATUS: Record<string, { label: string; bg: string; text: s
|
|||
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" },
|
||||
// Kartenkauf, den das Studio vollständig erstattet hat.
|
||||
// Kartenkauf, den das Studio vollständig erstattet hat - bzw. dessen
|
||||
// Erstattung angestoßen, aber von Stripe noch nicht bestätigt ist.
|
||||
pass_refunded: { label: "Erstattet", bg: "bg-red-100", text: "text-red-600" },
|
||||
pass_refunding: { label: "Wird erstattet…", bg: "bg-yellow-100", text: "text-yellow-700" },
|
||||
};
|
||||
|
||||
interface ZahlungStatusBadgeProps {
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ vi.mock("@/src/modules/products/lib/pass-webhook", async (importOriginal) => ({
|
|||
...(await importOriginal<object>()),
|
||||
handlePassCheckoutCompleted: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/src/modules/products/lib/pass-refund", () => ({ reconcilePassRefund: vi.fn() }));
|
||||
|
||||
import { POST } from "@/src/app/api/stripe/webhook/route";
|
||||
import { stripe } from "@/src/lib/stripe";
|
||||
|
|
@ -62,6 +63,7 @@ import { recordStripeFee, recordStripeFeeForCharge } from "@/src/lib/stripe-fee"
|
|||
import { isManagerOrEmployeeEmail } from "@/src/lib/auth/account-role-by-email";
|
||||
import { releaseDiscountReservation } from "@/src/modules/settings/lib/discount-validation";
|
||||
import { handlePassCheckoutCompleted } from "@/src/modules/products/lib/pass-webhook";
|
||||
import { reconcilePassRefund } from "@/src/modules/products/lib/pass-refund";
|
||||
|
||||
// Die Datenbank-Transaktion der Kapazitätsprüfung.
|
||||
const tx = {
|
||||
|
|
@ -569,4 +571,13 @@ describe("Stripe-Webhook – Karten", () => {
|
|||
);
|
||||
expect(tx.courseBooking.upsert.mock.calls[0][0].update).toMatchObject({ passId: null });
|
||||
});
|
||||
|
||||
it("bestätigt eine offene Kartenerstattung über charge.refunded", async () => {
|
||||
const charge = { id: "ch_1", refunded: true, payment_intent: "pi_1", amount_refunded: 18500 };
|
||||
await deliver(
|
||||
{ type: "charge.refunded", account: "acct_studio", data: { object: charge } },
|
||||
"whsec_connect"
|
||||
);
|
||||
expect(reconcilePassRefund).toHaveBeenCalledWith(charge);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
|||
|
||||
const tx = {
|
||||
courseBooking: { updateMany: vi.fn() },
|
||||
customerPass: { update: vi.fn() },
|
||||
customerPass: { updateMany: vi.fn() },
|
||||
};
|
||||
|
||||
vi.mock("@/src/lib/prisma", () => ({
|
||||
|
|
@ -85,7 +85,7 @@ beforeEach(() => {
|
|||
text: "t",
|
||||
});
|
||||
tx.courseBooking.updateMany.mockResolvedValue({ count: 1 });
|
||||
tx.customerPass.update.mockResolvedValue({});
|
||||
tx.customerPass.updateMany.mockResolvedValue({ count: 1 });
|
||||
vi.mocked(calculateRefundAmountCents).mockResolvedValue({
|
||||
cancellationAllowed: true,
|
||||
refundPercent: 100,
|
||||
|
|
@ -100,10 +100,10 @@ describe("executeBookingCancellation – Kartenbuchung", () => {
|
|||
expect(result).toMatchObject({ success: true, creditsReturned: 2, refundedAmountCents: 0 });
|
||||
expect(refundStripePayment).not.toHaveBeenCalled();
|
||||
expect(tx.courseBooking.updateMany.mock.calls[0][0].data.paymentStatus).toBe("refunded");
|
||||
expect(tx.customerPass.update).toHaveBeenCalledWith({
|
||||
where: { id: "pass-1" },
|
||||
data: { creditsRemaining: { increment: 2 } },
|
||||
});
|
||||
const { where, data } = tx.customerPass.updateMany.mock.calls[0][0];
|
||||
// Gutschrift nur auf eine Karte, die noch einlösbar ist.
|
||||
expect(where).toMatchObject({ id: "pass-1", status: "active" });
|
||||
expect(data).toEqual({ creditsRemaining: { increment: 2 } });
|
||||
});
|
||||
|
||||
it("lässt die Einheiten verfallen, wenn die Frist verstrichen ist", async () => {
|
||||
|
|
@ -117,10 +117,11 @@ describe("executeBookingCancellation – Kartenbuchung", () => {
|
|||
|
||||
expect(result).toMatchObject({ success: true, creditsReturned: 0 });
|
||||
expect(tx.courseBooking.updateMany.mock.calls[0][0].data.paymentStatus).toBe("cancelled");
|
||||
expect(tx.customerPass.update).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(generatePassBookingCancellationEmail).mock.calls[0][0].creditsReturned).toBe(
|
||||
false
|
||||
);
|
||||
expect(tx.customerPass.updateMany).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(generatePassBookingCancellationEmail).mock.calls[0][0]).toMatchObject({
|
||||
creditsReturned: false,
|
||||
forfeitReason: "policy",
|
||||
});
|
||||
});
|
||||
|
||||
it("gibt bei einer Kursabsage immer zurück, ohne die Stornoregel zu fragen", async () => {
|
||||
|
|
@ -135,6 +136,22 @@ describe("executeBookingCancellation – Kartenbuchung", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("schreibt einer abgelaufenen oder beendeten Karte nichts gut - auch nicht bei Kursabsage", async () => {
|
||||
// Die Karte ist nicht mehr aktiv: die bedingte Gutschrift trifft keine Zeile.
|
||||
tx.customerPass.updateMany.mockResolvedValue({ count: 0 });
|
||||
|
||||
const result = await executeBookingCancellation("booking-1", "manager", {
|
||||
forceFullRefund: true,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ success: true, creditsReturned: 0 });
|
||||
expect(tx.courseBooking.updateMany.mock.calls[0][0].data.paymentStatus).toBe("cancelled");
|
||||
expect(vi.mocked(generatePassBookingCancellationEmail).mock.calls[0][0]).toMatchObject({
|
||||
creditsReturned: false,
|
||||
forfeitReason: "pass_inactive",
|
||||
});
|
||||
});
|
||||
|
||||
it("storniert einzelne Plätze und gibt nur deren Einheiten zurück", async () => {
|
||||
const result = await executeBookingCancellation("booking-1", "user", {
|
||||
seatSelection: { seats: 1 },
|
||||
|
|
@ -142,7 +159,7 @@ describe("executeBookingCancellation – Kartenbuchung", () => {
|
|||
|
||||
expect(result).toMatchObject({ success: true, remainingSeats: 1, creditsReturned: 1 });
|
||||
expect(tx.courseBooking.updateMany.mock.calls[0][0].data).toMatchObject({ seats: 1 });
|
||||
expect(tx.customerPass.update.mock.calls[0][0].data).toEqual({
|
||||
expect(tx.customerPass.updateMany.mock.calls[0][0].data).toEqual({
|
||||
creditsRemaining: { increment: 1 },
|
||||
});
|
||||
});
|
||||
|
|
@ -152,7 +169,10 @@ describe("executeBookingCancellation – Kartenbuchung", () => {
|
|||
|
||||
const result = await executeBookingCancellation("booking-1", "user");
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(tx.customerPass.update).not.toHaveBeenCalled();
|
||||
// Die Gutschrift lief in derselben Transaktion und wird mit ihr zurückgerollt.
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: "Stornierung läuft bereits oder wurde bereits verarbeitet.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -51,6 +51,28 @@ describe("refundStripePayment", () => {
|
|||
refundApplicationFee: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: false, error: "insufficient balance" });
|
||||
expect(result).toEqual({ success: false, error: "insufficient balance", definitive: false });
|
||||
});
|
||||
|
||||
it("unterscheidet eine sichere Ablehnung von einem offenen Ausgang", async () => {
|
||||
const rejected = Object.assign(new Error("Charge already refunded"), {
|
||||
type: "StripeInvalidRequestError",
|
||||
code: "charge_already_refunded",
|
||||
});
|
||||
vi.mocked(stripe.refunds.create).mockRejectedValueOnce(rejected);
|
||||
const timeout = Object.assign(new Error("timeout"), { type: "StripeConnectionError" });
|
||||
vi.mocked(stripe.refunds.create).mockRejectedValueOnce(timeout);
|
||||
|
||||
const params = {
|
||||
paymentIntentId: "pi_1",
|
||||
stripeAccountId: "acct",
|
||||
refundApplicationFee: false,
|
||||
};
|
||||
expect(await refundStripePayment(params)).toMatchObject({
|
||||
success: false,
|
||||
definitive: true,
|
||||
code: "charge_already_refunded",
|
||||
});
|
||||
expect(await refundStripePayment(params)).toMatchObject({ success: false, definitive: false });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
|||
|
||||
const tx = {
|
||||
customerPass: { updateMany: vi.fn() },
|
||||
passAdjustment: { create: vi.fn() },
|
||||
passAdjustment: { create: vi.fn(), findFirst: vi.fn(), count: vi.fn() },
|
||||
};
|
||||
|
||||
vi.mock("@/src/lib/prisma", () => ({
|
||||
|
|
@ -22,6 +22,7 @@ vi.mock("@/src/lib/stripe-refund", () => ({ refundStripePayment: vi.fn() }));
|
|||
|
||||
import {
|
||||
adjustPassCredits,
|
||||
endPass,
|
||||
issuePass,
|
||||
refundPass,
|
||||
} from "@/src/modules/products/actions/pass-admin-actions";
|
||||
|
|
@ -55,6 +56,8 @@ beforeEach(() => {
|
|||
vi.mocked(sendMail).mockResolvedValue(undefined as never);
|
||||
tx.customerPass.updateMany.mockResolvedValue({ count: 1 });
|
||||
tx.passAdjustment.create.mockResolvedValue({});
|
||||
tx.passAdjustment.findFirst.mockResolvedValue({ creditsDelta: -10 });
|
||||
tx.passAdjustment.count.mockResolvedValue(0);
|
||||
});
|
||||
|
||||
describe("issuePass", () => {
|
||||
|
|
@ -152,22 +155,61 @@ describe("adjustPassCredits", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("refundPass", () => {
|
||||
it("erstattet eine ungenutzte Karte komplett, die Gebühr trägt das Studio", async () => {
|
||||
vi.mocked(refundStripePayment).mockResolvedValue({
|
||||
success: true,
|
||||
stripeRefundId: "re_1",
|
||||
amountRefundedCents: 12500,
|
||||
describe("endPass", () => {
|
||||
it("beendet und protokolliert in einer Transaktion", async () => {
|
||||
const result = await endPass({ passId: "pass-1", reason: "Kunde zieht weg" });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(tx.customerPass.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: "pass-1", status: "active" },
|
||||
data: { status: "ended" },
|
||||
});
|
||||
vi.mocked(prisma.customerPass.update).mockResolvedValue({} as never);
|
||||
expect(tx.passAdjustment.create.mock.calls[0][0].data).toMatchObject({ type: "end" });
|
||||
expect(prisma.passAdjustment.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("protokolliert nichts, wenn die Karte schon nicht mehr aktiv war", async () => {
|
||||
tx.customerPass.updateMany.mockResolvedValue({ count: 0 });
|
||||
expect((await endPass({ passId: "pass-1", reason: "" })).success).toBe(false);
|
||||
expect(tx.passAdjustment.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("refundPass", () => {
|
||||
const refunded = {
|
||||
success: true as const,
|
||||
stripeRefundId: "re_1",
|
||||
amountRefundedCents: 12500,
|
||||
};
|
||||
|
||||
it("sperrt und protokolliert zuerst, dann erstattet Stripe komplett - die Gebühr trägt das Studio", async () => {
|
||||
vi.mocked(refundStripePayment).mockResolvedValue(refunded);
|
||||
|
||||
const result = await refundPass({ passId: "pass-1" });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Sperre: nicht mehr einlösbar, an den gelesenen Stand gebunden.
|
||||
expect(tx.customerPass.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: "pass-1", status: { in: ["active", "ended"] }, creditsRemaining: 10 },
|
||||
data: { status: "refunding", creditsRemaining: 0 },
|
||||
});
|
||||
expect(tx.passAdjustment.create.mock.calls[0][0].data).toMatchObject({
|
||||
type: "refund",
|
||||
creditsDelta: -10,
|
||||
});
|
||||
expect(refundStripePayment).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ paymentIntentId: "pi_1", refundApplicationFee: false })
|
||||
expect.objectContaining({
|
||||
paymentIntentId: "pi_1",
|
||||
refundApplicationFee: false,
|
||||
idempotencyKey: "refund-pass-pass-1",
|
||||
})
|
||||
);
|
||||
expect(vi.mocked(refundStripePayment).mock.calls[0][0].amountCents).toBeUndefined();
|
||||
// Abschluss erst nach Stripes Bestätigung.
|
||||
expect(prisma.customerPass.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: "pass-1", status: "refunding" },
|
||||
data: expect.objectContaining({ status: "refunded", refundedAmount: 12500 }),
|
||||
});
|
||||
});
|
||||
|
||||
it("erstattet keine Karte, mit der schon gebucht wurde", async () => {
|
||||
|
|
@ -176,6 +218,7 @@ describe("refundPass", () => {
|
|||
const result = await refundPass({ passId: "pass-1" });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(tx.customerPass.updateMany).not.toHaveBeenCalled();
|
||||
expect(refundStripePayment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
@ -191,16 +234,81 @@ describe("refundPass", () => {
|
|||
expect(refundStripePayment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("setzt die Karte zurück, wenn Stripe die Erstattung ablehnt", async () => {
|
||||
vi.mocked(refundStripePayment).mockResolvedValue({ success: false, error: "kaputt" });
|
||||
vi.mocked(prisma.customerPass.update).mockResolvedValue({} as never);
|
||||
it("setzt die Karte nur zurück, wenn Stripe sicher abgelehnt hat", async () => {
|
||||
vi.mocked(refundStripePayment).mockResolvedValue({
|
||||
success: false,
|
||||
error: "Insufficient funds",
|
||||
definitive: true,
|
||||
});
|
||||
|
||||
const result = await refundPass({ passId: "pass-1" });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(prisma.customerPass.update).toHaveBeenCalledWith({
|
||||
where: { id: "pass-1" },
|
||||
// Status und Einheiten von vor der Sperre, aus dem Protokoll.
|
||||
expect(tx.customerPass.updateMany).toHaveBeenLastCalledWith({
|
||||
where: { id: "pass-1", status: "refunding" },
|
||||
data: { status: "active", creditsRemaining: 10 },
|
||||
});
|
||||
expect(tx.passAdjustment.create).toHaveBeenLastCalledWith({
|
||||
data: expect.objectContaining({ type: "refund_failed", creditsDelta: 10 }),
|
||||
});
|
||||
});
|
||||
|
||||
it("stellt eine vorher beendete Karte als beendet wieder her", async () => {
|
||||
vi.mocked(prisma.customerPass.findFirst).mockResolvedValue({
|
||||
...purchasedPass,
|
||||
status: "ended",
|
||||
} as never);
|
||||
tx.passAdjustment.count.mockResolvedValue(1);
|
||||
vi.mocked(refundStripePayment).mockResolvedValue({
|
||||
success: false,
|
||||
error: "nein",
|
||||
definitive: true,
|
||||
});
|
||||
|
||||
await refundPass({ passId: "pass-1" });
|
||||
|
||||
expect(tx.customerPass.updateMany).toHaveBeenLastCalledWith({
|
||||
where: { id: "pass-1", status: "refunding" },
|
||||
data: { status: "ended", creditsRemaining: 10 },
|
||||
});
|
||||
});
|
||||
|
||||
it("lässt die Karte gesperrt, wenn der Ausgang offen ist", async () => {
|
||||
vi.mocked(refundStripePayment).mockResolvedValue({
|
||||
success: false,
|
||||
error: "timeout",
|
||||
definitive: false,
|
||||
});
|
||||
|
||||
const result = await refundPass({ passId: "pass-1" });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
// Nur die Sperre - kein Zurücksetzen, kein Abschluss.
|
||||
expect(tx.customerPass.updateMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.customerPass.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fragt bei einer gesperrten Karte nur erneut bei Stripe nach", async () => {
|
||||
vi.mocked(prisma.customerPass.findFirst).mockResolvedValue({
|
||||
...purchasedPass,
|
||||
status: "refunding",
|
||||
creditsRemaining: 0,
|
||||
} as never);
|
||||
vi.mocked(refundStripePayment).mockResolvedValue({
|
||||
success: false,
|
||||
error: "Charge already refunded",
|
||||
definitive: true,
|
||||
code: "charge_already_refunded",
|
||||
});
|
||||
|
||||
const result = await refundPass({ passId: "pass-1" });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(tx.customerPass.updateMany).not.toHaveBeenCalled();
|
||||
expect(prisma.customerPass.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: "pass-1", status: "refunding" },
|
||||
data: expect.objectContaining({ status: "refunded", refundedAmount: 12500 }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
49
tests/products/pass-refund.test.ts
Normal file
49
tests/products/pass-refund.test.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/src/lib/prisma", () => ({
|
||||
prisma: { customerPass: { findFirst: vi.fn(), updateMany: vi.fn() } },
|
||||
}));
|
||||
|
||||
import { reconcilePassRefund } from "@/src/modules/products/lib/pass-refund";
|
||||
import { prisma } from "@/src/lib/prisma";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(prisma.customerPass.updateMany).mockResolvedValue({ count: 1 } as never);
|
||||
});
|
||||
|
||||
describe("reconcilePassRefund", () => {
|
||||
it("schließt eine offene Kartenerstattung ab, sobald Stripe sie meldet", async () => {
|
||||
vi.mocked(prisma.customerPass.findFirst).mockResolvedValue({ id: "pass-1" } as never);
|
||||
|
||||
await reconcilePassRefund({
|
||||
refunded: true,
|
||||
payment_intent: "pi_1",
|
||||
amount_refunded: 12500,
|
||||
} as never);
|
||||
|
||||
expect(prisma.customerPass.findFirst).toHaveBeenCalledWith({
|
||||
where: { stripePaymentIntentId: "pi_1", status: "refunding" },
|
||||
select: { id: true },
|
||||
});
|
||||
expect(prisma.customerPass.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: "pass-1", status: "refunding" },
|
||||
data: expect.objectContaining({ status: "refunded", refundedAmount: 12500 }),
|
||||
});
|
||||
});
|
||||
|
||||
it("ignoriert Teilerstattungen - eine Karte wird nur komplett erstattet", async () => {
|
||||
await reconcilePassRefund({ refunded: false, payment_intent: "pi_1" } as never);
|
||||
expect(prisma.customerPass.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tut nichts, wenn keine Kartenerstattung auf diese Zahlung wartet", async () => {
|
||||
vi.mocked(prisma.customerPass.findFirst).mockResolvedValue(null);
|
||||
await reconcilePassRefund({
|
||||
refunded: true,
|
||||
payment_intent: "pi_x",
|
||||
amount_refunded: 1,
|
||||
} as never);
|
||||
expect(prisma.customerPass.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue