see comments

This commit is contained in:
Rajpreet0 2026-10-02 15:36:12 +02:00
parent 7aa887c5aa
commit d91b353dc2
20 changed files with 593 additions and 151 deletions

View file

@ -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;

View file

@ -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?

View file

@ -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.

View file

@ -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}`);

View file

@ -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 } : {}),
};
}
}

View file

@ -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),
});

View file

@ -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",
},
},
};
}

View file

@ -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 ? (

View file

@ -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>
))}

View file

@ -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({

View 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);
}

View file

@ -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",
};

View file

@ -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>

View file

@ -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,
}));

View file

@ -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 {

View file

@ -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);
});
});

View file

@ -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.",
});
});
});

View file

@ -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 });
});
});

View file

@ -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 }),
});
});
});

View 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();
});
});