fix: Worker übersteht Stripe-Fehler einzelner Buchungen, Rechnungsentwürfe beim Beenden löschen

Befunde aus dem Sicherheits-Review:

- Anhalten, Beenden und Abgleich der Ratenpläne fangen Fehler je Buchung ab.
  Ein Stripe-Fehler bei einer Buchung bricht nicht mehr den ganzen
  Worker-Lauf ab (E-Mails, Reservierungen liefen sonst nicht weiter).
  Teilfehler setzen den Exit-Code 1, damit der CronJob-Lauf sichtbar
  fehlschlägt.
- Beim Beenden eines Plans werden neben offenen Rechnungen auch Entwürfe
  gelöscht. Laut Stripe-Testuhr bleibt ein Entwurf nach dem Beenden zwar
  liegen und wird nicht eingezogen; das Löschen schließt das aus.

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:34:20 +02:00
1 parent 1ce7947d93
commit a28d18c3e6
5 files changed
+43 -17

No files matched your search

+5 -1
View File
@@ -25,7 +25,11 @@ try {
adminEmail: env().ADMIN_NOTIFY_EMAIL ?? null, adminEmail: env().ADMIN_NOTIFY_EMAIL ?? null,
}); });
console.log(`[worker] ${new Date().toISOString()} ${JSON.stringify(result)}`); console.log(`[worker] ${new Date().toISOString()} ${JSON.stringify(result)}`);
process.exitCode = result.emails.failed > 0 ? 1 : 0; // Teilfehler (einzelne Buchungen bei Stripe) machen den Job sichtbar
// fehlgeschlagen, ohne die uebrigen Aufgaben des Laufs zu verhindern.
const partialFailures =
result.emails.failed + result.held.failed + result.schedules.failed + result.installments.failed + result.stopped.failed;
process.exitCode = partialFailures > 0 ? 1 : 0;
} catch (error) { } catch (error) {
console.error(`[worker] Abbruch: ${error instanceof Error ? error.message : "unbekannter Fehler"}`); console.error(`[worker] Abbruch: ${error instanceof Error ? error.message : "unbekannter Fehler"}`);
process.exitCode = 1; process.exitCode = 1;
+2 -4
View File
@@ -59,10 +59,8 @@ export async function cancelBooking(
// Zahlungsmethode trennen: nach einer Stornierung darf nichts mehr // Zahlungsmethode trennen: nach einer Stornierung darf nichts mehr
// abgebucht werden. // abgebucht werden.
if (before.stripeCheckoutSessionId) await gateway.expireCheckoutSession(before.stripeCheckoutSessionId).catch(() => undefined); if (before.stripeCheckoutSessionId) await gateway.expireCheckoutSession(before.stripeCheckoutSessionId).catch(() => undefined);
try { const plan = await stopCanceledInstallmentPlans(db, gateway, [before.id], actor);
await stopCanceledInstallmentPlans(db, gateway, [before.id], actor); if (plan.failed > 0) {
} catch (error) {
console.error(`[admin] Ratenplan fuer Buchung ${before.id} nicht beendet: ${error instanceof Error ? error.message : "unbekannt"}`);
return { return {
ok: false, ok: false,
message: "Buchung storniert, aber der Ratenplan bei Stripe ließ sich noch nicht beenden. Der Hintergrundlauf versucht es alle 10 Minuten erneut.", message: "Buchung storniert, aber der Ratenplan bei Stripe ließ sich noch nicht beenden. Der Hintergrundlauf versucht es alle 10 Minuten erneut.",
+25 -5
View File
@@ -135,7 +135,9 @@ export async function holdSchedulesInReview(db: Db, gateway: PaymentGateway, boo
select: { id: true, stripeScheduleId: true }, select: { id: true, stripeScheduleId: true },
take: 50, take: 50,
}); });
for (const booking of candidates) { const failed = await eachBooking(candidates, "anhalten", async (booking) => {
// Erst bei Stripe beenden; scheitert das, bleibt alles wie es ist und der
// naechste Lauf versucht es erneut.
await gateway.cancelInstallmentSchedule(booking.stripeScheduleId!); await gateway.cancelInstallmentSchedule(booking.stripeScheduleId!);
await db.$transaction(async (tx) => { await db.$transaction(async (tx) => {
await lockBooking(tx, booking.id); await lockBooking(tx, booking.id);
@@ -146,8 +148,26 @@ export async function holdSchedulesInReview(db: Db, gateway: PaymentGateway, boo
await tx.booking.update({ where: { id: booking.id }, data: { stripeScheduleId: 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 }); await audit(tx, actor, "installments.schedule_held_for_review", "Booking", booking.id, { scheduleId: booking.stripeScheduleId });
}); });
});
return { held: candidates.length - failed, failed };
} }
return { held: candidates.length };
/**
* Arbeitet Buchungen einzeln ab: ein Stripe-Fehler bei einer Buchung haelt
* weder die uebrigen noch den restlichen Worker-Lauf auf. Liefert die Zahl
* der Fehlschlaege.
*/
export async function eachBooking<T extends { id: string }>(items: T[], label: string, fn: (item: T) => Promise<void>): Promise<number> {
let failed = 0;
for (const item of items) {
try {
await fn(item);
} catch (error) {
failed++;
console.error(`[ratenplan] ${label} fuer Buchung ${item.id} fehlgeschlagen: ${error instanceof Error ? error.message : "unbekannt"}`);
}
}
return failed;
} }
function schedulePlanProblem( function schedulePlanProblem(
@@ -183,13 +203,13 @@ export async function stopCanceledInstallmentPlans(db: Db, gateway: PaymentGatew
}, },
take: 50, take: 50,
}); });
for (const booking of candidates) { const failed = await eachBooking(candidates, "beenden", async (booking) => {
await gateway.cancelInstallmentSchedule(booking.stripeScheduleId!); await gateway.cancelInstallmentSchedule(booking.stripeScheduleId!);
if (booking.stripePaymentMethodId) await gateway.detachPaymentMethod(booking.stripePaymentMethodId); if (booking.stripePaymentMethodId) await gateway.detachPaymentMethod(booking.stripePaymentMethodId);
await db.booking.update({ where: { id: booking.id }, data: { paymentMethodDetachedAt: new Date() } }); await db.booking.update({ where: { id: booking.id }, data: { paymentMethodDetachedAt: new Date() } });
await audit(db, actor, "installments.schedule_canceled", "Booking", booking.id, { scheduleId: booking.stripeScheduleId }); await audit(db, actor, "installments.schedule_canceled", "Booking", booking.id, { scheduleId: booking.stripeScheduleId });
} });
return { stopped: candidates.length }; return { stopped: candidates.length - failed, failed };
} }
/** /**
+7 -3
View File
@@ -267,9 +267,13 @@ export class StripeGateway implements PaymentGateway {
} }
const subscriptionId = idOf(schedule.subscription); const subscriptionId = idOf(schedule.subscription);
if (!subscriptionId) return; if (!subscriptionId) return;
// Offene Rechnungen wuerden sonst weiter automatisch eingezogen. // Offene Rechnungen wuerden sonst weiter automatisch eingezogen. Entwuerfe
for await (const invoice of this.stripe.invoices.list({ subscription: subscriptionId, status: "open", limit: 100 })) { // (Stripe stellt Rechnungen erst ~1 h nach Periodenbeginn fertig) bleiben
await this.stripe.invoices.voidInvoice(invoice.id!); // laut Testuhr nach dem Beenden zwar liegen, werden aber vorsorglich
// geloescht, damit sie nie fertiggestellt und eingezogen werden.
for await (const invoice of this.stripe.invoices.list({ subscription: subscriptionId, limit: 100 })) {
if (invoice.status === "draft") await this.stripe.invoices.del(invoice.id!);
else if (invoice.status === "open") await this.stripe.invoices.voidInvoice(invoice.id!);
} }
} }
+4 -4
View File
@@ -10,7 +10,7 @@
import { audit, WORKER } from "./audit"; import { audit, WORKER } from "./audit";
import type { Db } from "./db"; import type { Db } from "./db";
import { flushEmails } from "./email"; import { flushEmails } from "./email";
import { ensureInstallmentSchedule, holdSchedulesInReview, settleInstallmentInvoice, stopCanceledInstallmentPlans } from "./installment-billing"; import { eachBooking, ensureInstallmentSchedule, holdSchedulesInReview, settleInstallmentInvoice, stopCanceledInstallmentPlans } from "./installment-billing";
import { applyCheckoutExpired, applyFirstPaymentFailed, type EventContext } from "./payment-events"; import { applyCheckoutExpired, applyFirstPaymentFailed, type EventContext } from "./payment-events";
import type { PaymentGateway } from "./payment-gateway"; import type { PaymentGateway } from "./payment-gateway";
import { purgeRateLimits } from "./rate-limit"; import { purgeRateLimits } from "./rate-limit";
@@ -112,13 +112,13 @@ export async function reconcileInstallmentInvoices(db: Db, gateway: PaymentGatew
take: 50, take: 50,
}); });
let settled = 0; let settled = 0;
for (const booking of bookings) { const failed = await eachBooking(bookings, "abgleichen", async (booking) => {
const invoices = await gateway.listScheduleInvoices(booking.stripeScheduleId!); const invoices = await gateway.listScheduleInvoices(booking.stripeScheduleId!);
for (const invoice of invoices.filter((i) => i.status === "paid")) { for (const invoice of invoices.filter((i) => i.status === "paid")) {
if (await settleInstallmentInvoice(db, gateway, invoice, ctx, WORKER)) settled++; if (await settleInstallmentInvoice(db, gateway, invoice, ctx, WORKER)) settled++;
} }
} });
return { checked: bookings.length, settled }; return { checked: bookings.length, settled, failed };
} }
/** Faengt verzoegerte erste Zahlungen (SEPA) auf, deren Ergebnis-Webhook ausblieb. */ /** Faengt verzoegerte erste Zahlungen (SEPA) auf, deren Ergebnis-Webhook ausblieb. */