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

+82 -16
View File
@@ -15,7 +15,9 @@ import { audit, WORKER, type Actor } from "./audit";
import type { Db } from "./db";
import { applyInstallmentInvoicePaid, flagBookingForReview, type EventContext } from "./payment-events";
import type { InvoiceInfo, PaymentGateway } from "./payment-gateway";
import { berlinDateString, daysBetween } from "./time";
import { lockBooking } from "./booking-service";
import { CHARGE_HOUR_BERLIN } from "./payment-plan";
import { addDays, berlinDateString, berlinStartOfDay, daysBetween } from "./time";
export const INSTALLMENT_PLAN_KIND = "installment_plan";
/** Stripe verlangt einen Start in der Zukunft. */
@@ -49,22 +51,40 @@ export async function ensureInstallmentSchedule(
}
const first = later[0]!;
const startAt = scheduleStart(first.dueAt!, ctx.now);
const intervalDays =
later.length === 1 ? 1 : daysBetween(berlinDateString(first.dueAt!), berlinDateString(later[1]!.dueAt!));
const startAt = new Date(Math.max(first.dueAt!.getTime(), ctx.now.getTime() + MIN_START_LEAD_MS));
const schedule = await gateway.createInstallmentSchedule(
{
customerId: booking.stripeCustomerId!,
paymentMethodId: booking.stripePaymentMethodId!,
startAt,
amountCents: first.amountCents,
intervalDays,
count: later.length,
description: `ARTISTA MODELCAMP 2027 · ${booking.reference} · Folgerate`,
metadata: { kind: INSTALLMENT_PLAN_KIND, bookingId: booking.id, reference: booking.reference },
},
`schedule:${booking.id}`,
);
later.length === 1 ? 1 : daysBetween(berlinDateString(startAt), berlinDateString(later.at(-1)!.dueAt!));
if (intervalDays < 1) {
const reason = "Bis zur letzten Rate bleibt kein Abstand mehr – Ratenplan bei Stripe nicht angelegt.";
await db.$transaction((tx) => flagBookingForReview(tx, booking.id, reason, { ...ctx, actor }));
return "needs_review";
}
// Schutz gegen einen zweiten Plan, auch wenn das Speichern der ID frueher
// scheiterte und der Idempotenzschluessel (24 h) abgelaufen ist.
const existing = await gateway.findInstallmentSchedule(booking.stripeCustomerId!, booking.id);
// Nach einer Pruefung entsteht ein neuer Plan; ohne Generation lieferte
// Stripe zum alten Schluessel den bereits beendeten Plan zurueck.
const generation = await db.auditLog.count({
where: { entityType: "Booking", entityId: booking.id, action: "installments.schedule_held_for_review" },
});
const schedule =
existing ??
(await gateway.createInstallmentSchedule(
{
customerId: booking.stripeCustomerId!,
paymentMethodId: booking.stripePaymentMethodId!,
startAt,
amountCents: first.amountCents,
intervalDays,
count: later.length,
description: `ARTISTA MODELCAMP 2027 · ${booking.reference} · Folgerate`,
metadata: { kind: INSTALLMENT_PLAN_KIND, bookingId: booking.id, reference: booking.reference },
},
// startAt ist fuer alle gleichzeitigen Aufrufer gleich (Faelligkeit
// oder naechster 9-Uhr-Termin), der Schluessel also auch.
`schedule:${booking.id}:${generation}:${startAt.getTime()}`,
));
const saved = await db.booking.updateMany({
where: { id: booking.id, stripeScheduleId: null },
@@ -84,6 +104,52 @@ export async function ensureInstallmentSchedule(
return "created";
}
/**
* Start des Plans: die Faelligkeit der naechsten Rate. Ist sie schon vorbei
* (Buchung erst nach Pruefung bestaetigt), der naechste 9-Uhr-Termin in
* Berlin — deterministisch, damit Wiederholungen denselben Plan meinen.
*/
function scheduleStart(dueAt: Date, now: Date): Date {
const earliest = now.getTime() + MIN_START_LEAD_MS;
if (dueAt.getTime() >= earliest) return dueAt;
const today = berlinDateString(now);
const todayAtNine = berlinStartOfDay(today, CHARGE_HOUR_BERLIN);
return todayAtNine.getTime() >= earliest ? todayAtNine : berlinStartOfDay(addDays(today, 1), CHARGE_HOUR_BERLIN);
}
/**
* Haelt den Stripe-Plan an, solange eine Buchung in Pruefung ist (z. B.
* Rueckbuchung, Doppelzahlung): Plan beenden, offene Rechnungen stornieren,
* unbezahlte Folgeraten wieder auf PENDING. Bestaetigt ein Mensch die
* Buchung, legt createMissingSchedules einen neuen Plan fuer die offenen
* Raten an; storniert er, bleibt es dabei.
*/
export async function holdSchedulesInReview(db: Db, gateway: PaymentGateway, bookingIds?: string[], actor: Actor = WORKER) {
const candidates = await db.booking.findMany({
where: {
status: "NEEDS_REVIEW",
stripeScheduleId: { not: null },
livemode: gateway.mode === "live",
...(bookingIds ? { id: { in: bookingIds } } : {}),
},
select: { id: true, stripeScheduleId: true },
take: 50,
});
for (const booking of candidates) {
await gateway.cancelInstallmentSchedule(booking.stripeScheduleId!);
await db.$transaction(async (tx) => {
await lockBooking(tx, booking.id);
await tx.installment.updateMany({
where: { bookingId: booking.id, seq: { gt: 1 }, status: { in: ["PENDING", "FAILED", "REQUIRES_ACTION"] } },
data: { status: "PENDING", stripeInvoiceId: null, nextAttemptAt: null },
});
await tx.booking.update({ where: { id: booking.id }, data: { stripeScheduleId: null } });
await audit(tx, actor, "installments.schedule_held_for_review", "Booking", booking.id, { scheduleId: booking.stripeScheduleId });
});
}
return { held: candidates.length };
}
function schedulePlanProblem(
booking: { stripeCustomerId: string | null; stripePaymentMethodId: string | null; paymentMethodDetachedAt: Date | null },
later: { amountCents: number; dueAt: Date | null }[],
+10
View File
@@ -87,6 +87,8 @@ export interface PaymentGateway {
expireCheckoutSession(id: string): Promise<void>;
retrievePaymentIntent(id: string): Promise<PaymentIntentInfo>;
createInstallmentSchedule(params: CreateInstallmentScheduleParams, idempotencyKey: string): Promise<{ id: string }>;
/** Laufender oder noch nicht gestarteter Plan dieser Buchung bei Stripe, falls vorhanden. */
findInstallmentSchedule(customerId: string, bookingId: string): Promise<{ id: string } | null>;
/** Beendet den Zeitplan sofort und storniert offene Rechnungen: danach wird nichts mehr eingezogen. */
cancelInstallmentSchedule(id: string): Promise<void>;
retrieveInvoice(id: string): Promise<InvoiceInfo>;
@@ -236,6 +238,14 @@ export class StripeGateway implements PaymentGateway {
return { id: schedule.id };
}
async findInstallmentSchedule(customerId: string, bookingId: string): Promise<{ id: string } | null> {
for await (const schedule of this.stripe.subscriptionSchedules.list({ customer: customerId, limit: 100 })) {
const live = schedule.status === "not_started" || schedule.status === "active";
if (live && schedule.metadata?.bookingId === bookingId) return { id: schedule.id };
}
return null;
}
/** Feste Produkt-ID: beim ersten Bedarf angelegt, danach wiederverwendet. */
private async installmentProductId(): Promise<string> {
try {
+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";
+3 -2
View File
@@ -10,7 +10,7 @@
import { audit, WORKER } from "./audit";
import type { Db } from "./db";
import { flushEmails } from "./email";
import { ensureInstallmentSchedule, settleInstallmentInvoice, stopCanceledInstallmentPlans } from "./installment-billing";
import { ensureInstallmentSchedule, holdSchedulesInReview, settleInstallmentInvoice, stopCanceledInstallmentPlans } from "./installment-billing";
import { applyCheckoutExpired, applyFirstPaymentFailed, type EventContext } from "./payment-events";
import type { PaymentGateway } from "./payment-gateway";
import { purgeRateLimits } from "./rate-limit";
@@ -181,11 +181,12 @@ export async function detachFinishedPaymentMethods(db: Db, gateway: PaymentGatew
export async function runWorker(db: Db, gateway: PaymentGateway, ctx: HandlerContext) {
const reservations = await releaseExpiredReservations(db, gateway, ctx);
const reconcile = await reconcileStuck(db, gateway, ctx);
const held = await holdSchedulesInReview(db, gateway);
const schedules = await createMissingSchedules(db, gateway, ctx);
const installments = await reconcileInstallmentInvoices(db, gateway, ctx);
const stopped = await stopCanceledInstallmentPlans(db, gateway);
const detach = await detachFinishedPaymentMethods(db, gateway);
const emails = await flushEmails(db, { limit: 200 });
const purged = await purgeRateLimits(db);
return { reservations, reconcile, schedules, installments, stopped, detach, emails, purgedRateLimits: purged };
return { reservations, reconcile, held, schedules, installments, stopped, detach, emails, purgedRateLimits: purged };
}