From a28d18c3e61d83c7aee33859dc7a825548e9632e Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Tue, 6 Oct 2026 06:34:20 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20Worker=20=C3=BCbersteht=20Stripe-Fehler?= =?UTF-8?q?=20einzelner=20Buchungen,=20Rechnungsentw=C3=BCrfe=20beim=20Bee?= =?UTF-8?q?nden=20l=C3=B6schen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- scripts/worker.ts | 6 +++++- src/lib/admin-service.ts | 6 ++---- src/lib/installment-billing.ts | 30 +++++++++++++++++++++++++----- src/lib/payment-gateway.ts | 10 +++++++--- src/lib/worker-jobs.ts | 8 ++++---- 5 files changed, 43 insertions(+), 17 deletions(-) diff --git a/scripts/worker.ts b/scripts/worker.ts index a5e0b7b..624fd05 100644 --- a/scripts/worker.ts +++ b/scripts/worker.ts @@ -25,7 +25,11 @@ try { adminEmail: env().ADMIN_NOTIFY_EMAIL ?? null, }); 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) { console.error(`[worker] Abbruch: ${error instanceof Error ? error.message : "unbekannter Fehler"}`); process.exitCode = 1; diff --git a/src/lib/admin-service.ts b/src/lib/admin-service.ts index ff8164b..64d8fba 100644 --- a/src/lib/admin-service.ts +++ b/src/lib/admin-service.ts @@ -59,10 +59,8 @@ export async function cancelBooking( // Zahlungsmethode trennen: nach einer Stornierung darf nichts mehr // abgebucht werden. if (before.stripeCheckoutSessionId) await gateway.expireCheckoutSession(before.stripeCheckoutSessionId).catch(() => undefined); - try { - await stopCanceledInstallmentPlans(db, gateway, [before.id], actor); - } catch (error) { - console.error(`[admin] Ratenplan fuer Buchung ${before.id} nicht beendet: ${error instanceof Error ? error.message : "unbekannt"}`); + const plan = await stopCanceledInstallmentPlans(db, gateway, [before.id], actor); + if (plan.failed > 0) { return { ok: false, message: "Buchung storniert, aber der Ratenplan bei Stripe ließ sich noch nicht beenden. Der Hintergrundlauf versucht es alle 10 Minuten erneut.", diff --git a/src/lib/installment-billing.ts b/src/lib/installment-billing.ts index 61f2909..bcc5c8c 100644 --- a/src/lib/installment-billing.ts +++ b/src/lib/installment-billing.ts @@ -135,7 +135,9 @@ export async function holdSchedulesInReview(db: Db, gateway: PaymentGateway, boo select: { id: true, stripeScheduleId: true }, 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 db.$transaction(async (tx) => { 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 audit(tx, actor, "installments.schedule_held_for_review", "Booking", booking.id, { scheduleId: booking.stripeScheduleId }); }); + }); + return { held: candidates.length - failed, failed }; +} + +/** + * 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(items: T[], label: string, fn: (item: T) => Promise): Promise { + 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 { held: candidates.length }; + return failed; } function schedulePlanProblem( @@ -183,13 +203,13 @@ export async function stopCanceledInstallmentPlans(db: Db, gateway: PaymentGatew }, take: 50, }); - for (const booking of candidates) { + const failed = await eachBooking(candidates, "beenden", async (booking) => { await gateway.cancelInstallmentSchedule(booking.stripeScheduleId!); if (booking.stripePaymentMethodId) await gateway.detachPaymentMethod(booking.stripePaymentMethodId); 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 }); - } - return { stopped: candidates.length }; + }); + return { stopped: candidates.length - failed, failed }; } /** diff --git a/src/lib/payment-gateway.ts b/src/lib/payment-gateway.ts index a7e5911..9ec40b8 100644 --- a/src/lib/payment-gateway.ts +++ b/src/lib/payment-gateway.ts @@ -267,9 +267,13 @@ export class StripeGateway implements PaymentGateway { } const subscriptionId = idOf(schedule.subscription); if (!subscriptionId) return; - // Offene Rechnungen wuerden sonst weiter automatisch eingezogen. - for await (const invoice of this.stripe.invoices.list({ subscription: subscriptionId, status: "open", limit: 100 })) { - await this.stripe.invoices.voidInvoice(invoice.id!); + // Offene Rechnungen wuerden sonst weiter automatisch eingezogen. Entwuerfe + // (Stripe stellt Rechnungen erst ~1 h nach Periodenbeginn fertig) bleiben + // 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!); } } diff --git a/src/lib/worker-jobs.ts b/src/lib/worker-jobs.ts index ffeceda..d399e36 100644 --- a/src/lib/worker-jobs.ts +++ b/src/lib/worker-jobs.ts @@ -10,7 +10,7 @@ import { audit, WORKER } from "./audit"; import type { Db } from "./db"; 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 type { PaymentGateway } from "./payment-gateway"; import { purgeRateLimits } from "./rate-limit"; @@ -112,13 +112,13 @@ export async function reconcileInstallmentInvoices(db: Db, gateway: PaymentGatew take: 50, }); let settled = 0; - for (const booking of bookings) { + const failed = await eachBooking(bookings, "abgleichen", async (booking) => { const invoices = await gateway.listScheduleInvoices(booking.stripeScheduleId!); for (const invoice of invoices.filter((i) => i.status === "paid")) { 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. */