fix: Stripe-Ratenplan bei Prüfung anhalten, doppelte Pläne verhindern

Befunde aus dem Sicherheits-Review des vorherigen Commits:

- Gerät eine Buchung in "Prüfung nötig" (Rückbuchung, Doppelzahlung ...),
  wird der Stripe-Plan beendet und offene Rechnungen storniert. Nach
  Bestätigung legt der Worker einen neuen Plan für die offenen Raten an.
  Vorher zog Stripe auch bei Buchungen in Prüfung weiter ein.
- Vor dem Anlegen wird bei Stripe nach einem laufenden Plan der Buchung
  gesucht; der Idempotenzschlüssel enthält Generation und Start. Verhindert
  einen zweiten Plan nach verlorener ID und die Rückgabe des alten,
  beendeten Plans nach einer Prüfung.
- Start bei bereits verstrichener Fälligkeit deterministisch am nächsten
  9-Uhr-Termin, letzte Rate bleibt am Stichtag.
- Rückbuchungs-Event meldet die betroffene Buchung zurück.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-06 06:28:41 +02:00
1 parent c1ad9ad2ca
commit 1ce7947d93
8 files changed
+168 -23

No files matched your search

+13 -3
View File
@@ -13,7 +13,7 @@ import type Stripe from "stripe";
import { Prisma } from "@/generated/prisma/client";
import { STRIPE_ACTOR, type Actor } from "./audit";
import type { Db } from "./db";
import { ensureInstallmentSchedule, INSTALLMENT_PLAN_KIND, settleInstallmentInvoice } from "./installment-billing";
import { ensureInstallmentSchedule, holdSchedulesInReview, INSTALLMENT_PLAN_KIND, settleInstallmentInvoice } from "./installment-billing";
import { normalizePaymentStatus, type CheckoutSessionInfo, type PaymentGateway } from "./payment-gateway";
import {
applyCheckoutExpired,
@@ -170,8 +170,10 @@ async function dispatch(db: Db, gateway: PaymentGateway, event: Stripe.Event, ct
}
case "charge.dispute.created": {
const piId = idOf(event.data.object.payment_intent);
if (piId) await db.$transaction((tx) => applyDispute(tx, piId, eventCtx(ctx)));
return [];
if (!piId) return [];
await db.$transaction((tx) => applyDispute(tx, piId, eventCtx(ctx)));
const disputed = await db.installment.findUnique({ where: { stripePaymentIntentId: piId }, select: { bookingId: true } });
return disputed ? [disputed.bookingId] : [];
}
default:
return [];
@@ -218,6 +220,14 @@ export async function handleStripeEvent(
try {
const bookingIds = await dispatch(db, gateway, event, ctx);
await db.stripeEvent.update({ where: { id: event.id }, data: { status: "PROCESSED", processedAt: new Date(), error: null } });
// Geriet eine Buchung in Pruefung (Rueckbuchung, Doppelzahlung ...),
// zieht Stripe bis zur Entscheidung nichts weiter ein. Scheitert das
// hier, erledigt es der Worker.
if (bookingIds.length > 0) {
await holdSchedulesInReview(db, gateway, bookingIds, STRIPE_ACTOR).catch((error: unknown) => {
console.error(`[webhook] Ratenplan nicht angehalten: ${error instanceof Error ? error.message : "unbekannt"}`);
});
}
return { outcome: "processed", bookingIds };
} catch (error) {
const message = error instanceof Error ? error.message.slice(0, 500) : "unbekannter Fehler";