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. */