diff --git a/prisma/migrations/20261004110000_customer_passes/migration.sql b/prisma/migrations/20261004110000_customer_passes/migration.sql index 2a156b6..b900924 100644 --- a/prisma/migrations/20261004110000_customer_passes/migration.sql +++ b/prisma/migrations/20261004110000_customer_passes/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 35fe908..180777c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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? diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts index 25e75ad..4f80cc3 100644 --- a/src/app/api/stripe/webhook/route.ts +++ b/src/app/api/stripe/webhook/route.ts @@ -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. diff --git a/src/lib/mail/templates/pass-booking-cancellation.ts b/src/lib/mail/templates/pass-booking-cancellation.ts index 3946f76..fa09d57 100644 --- a/src/lib/mail/templates/pass-booking-cancellation.ts +++ b/src/lib/mail/templates/pass-booking-cancellation.ts @@ -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}`); diff --git a/src/lib/stripe-refund.ts b/src/lib/stripe-refund.ts index 8fd72d7..262e3bf 100644 --- a/src/lib/stripe-refund.ts +++ b/src/lib/stripe-refund.ts @@ -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 } : {}), }; } } diff --git a/src/modules/courses/actions/cancel-booking-shared.ts b/src/modules/courses/actions/cancel-booking-shared.ts index c469446..ac7302a 100644 --- a/src/modules/courses/actions/cancel-booking-shared.ts +++ b/src/modules/courses/actions/cancel-booking-shared.ts @@ -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), }); diff --git a/src/modules/courses/actions/cancellation-preview-actions.ts b/src/modules/courses/actions/cancellation-preview-actions.ts index 9a051d3..0c80371 100644 --- a/src/modules/courses/actions/cancellation-preview-actions.ts +++ b/src/modules/courses/actions/cancellation-preview-actions.ts @@ -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", + }, }, }; } diff --git a/src/modules/courses/ui/components/CancelBookingConfirmDialog.tsx b/src/modules/courses/ui/components/CancelBookingConfirmDialog.tsx index 4c1edaf..1b2d85e 100644 --- a/src/modules/courses/ui/components/CancelBookingConfirmDialog.tsx +++ b/src/modules/courses/ui/components/CancelBookingConfirmDialog.tsx @@ -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({ ) : (
-

{copy.passCreditsForfeit(toCancel)}

+

+ {preview.pass.forfeitReason === "pass_inactive" + ? copy.passCreditsForfeitInactive(toCancel) + : copy.passCreditsForfeit(toCancel)} +

) ) : preview ? ( diff --git a/src/modules/kunden/ui/views/kunden-view.tsx b/src/modules/kunden/ui/views/kunden-view.tsx index 0a779a2..c567585 100644 --- a/src/modules/kunden/ui/views/kunden-view.tsx +++ b/src/modules/kunden/ui/views/kunden-view.tsx @@ -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 && (
-

Vergebene Karten ohne Konto

+

Karten ohne Konto

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 {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))} ))} diff --git a/src/modules/products/actions/pass-admin-actions.ts b/src/modules/products/actions/pass-admin-actions.ts index 8a99bc2..ee821d1 100644 --- a/src/modules/products/actions/pass-admin-actions.ts +++ b/src/modules/products/actions/pass-admin-actions.ts @@ -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 { const manager = await requireManager(); @@ -327,34 +341,50 @@ export async function refundPass(input: { passId: string }): Promise 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 { const manager = await requireManager(); const passes = await prisma.customerPass.findMany({ diff --git a/src/modules/products/lib/pass-refund.ts b/src/modules/products/lib/pass-refund.ts new file mode 100644 index 0000000..c153b91 --- /dev/null +++ b/src/modules/products/lib/pass-refund.ts @@ -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 { + 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 { + 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); +} diff --git a/src/modules/products/lib/pass-rules.ts b/src/modules/products/lib/pass-rules.ts index ea4d14c..3301368 100644 --- a/src/modules/products/lib/pass-rules.ts +++ b/src/modules/products/lib/pass-rules.ts @@ -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 = { used_up: "Aufgebraucht", expired: "Abgelaufen", ended: "Beendet", + refunding: "Erstattung läuft", refunded: "Erstattet", }; diff --git a/src/modules/products/ui/components/KundePassesSection.tsx b/src/modules/products/ui/components/KundePassesSection.tsx index 089b9c3..fdeacbc 100644 --- a/src/modules/products/ui/components/KundePassesSection.tsx +++ b/src/modules/products/ui/components/KundePassesSection.tsx @@ -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" })} > - Erstatten + {pass.status === "refunding" ? "Erstattung prüfen" : "Erstatten"} )} {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 = ({

)} - {action === "refund" && ( -

- Der Kunde bekommt{" "} - - {pass.amountPaidCents !== null - ? formatPrice(pass.amountPaidCents) - : "den Kaufpreis"} - {" "} - vollständig zurück, die Karte wird ungültig. Die Servicegebühr trägst du, wie bei - einer Kursabsage. -

- )} + {action === "refund" && + (pass.status === "refunding" ? ( +

+ Stripe hat beim letzten Versuch nicht eindeutig geantwortet. Erneut prüfen ist + sicher: Stripe erstattet höchstens einmal. +

+ ) : ( +

+ Der Kunde bekommt{" "} + + {pass.amountPaidCents !== null + ? formatPrice(pass.amountPaidCents) + : "den Kaufpreis"} + {" "} + vollständig zurück, die Karte wird ungültig. Die Servicegebühr trägst du, wie bei + einer Kursabsage. +

+ ))} {action !== "refund" && (
@@ -447,7 +455,9 @@ const PassActionDialog = ({ : action === "end" ? "Karte beenden" : action === "refund" - ? "Erstatten" + ? pass.status === "refunding" + ? "Erneut prüfen" + : "Erstatten" : "Speichern"} diff --git a/src/modules/zahlungen/actions/zahlungen-actions.ts b/src/modules/zahlungen/actions/zahlungen-actions.ts index 23e9f72..999b436 100644 --- a/src/modules/zahlungen/actions/zahlungen-actions.ts +++ b/src/modules/zahlungen/actions/zahlungen-actions.ts @@ -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, })); diff --git a/src/modules/zahlungen/ui/components/zahlungen-status-badge.tsx b/src/modules/zahlungen/ui/components/zahlungen-status-badge.tsx index 85aeb3c..3c6adb0 100644 --- a/src/modules/zahlungen/ui/components/zahlungen-status-badge.tsx +++ b/src/modules/zahlungen/ui/components/zahlungen-status-badge.tsx @@ -10,8 +10,10 @@ export const PAYMENT_STATUS: Record ({ ...(await importOriginal()), 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); + }); }); diff --git a/tests/courses/cancel-pass-booking.test.ts b/tests/courses/cancel-pass-booking.test.ts index c4cf54b..ed29220 100644 --- a/tests/courses/cancel-pass-booking.test.ts +++ b/tests/courses/cancel-pass-booking.test.ts @@ -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.", + }); }); }); diff --git a/tests/lib/stripe-refund.test.ts b/tests/lib/stripe-refund.test.ts index cb1a35d..5161d63 100644 --- a/tests/lib/stripe-refund.test.ts +++ b/tests/lib/stripe-refund.test.ts @@ -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 }); }); }); diff --git a/tests/products/pass-admin-actions.test.ts b/tests/products/pass-admin-actions.test.ts index 06c52d8..24c2149 100644 --- a/tests/products/pass-admin-actions.test.ts +++ b/tests/products/pass-admin-actions.test.ts @@ -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 }), + }); }); }); diff --git a/tests/products/pass-refund.test.ts b/tests/products/pass-refund.test.ts new file mode 100644 index 0000000..a6ac073 --- /dev/null +++ b/tests/products/pass-refund.test.ts @@ -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(); + }); +});