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:
1 parent
1ce7947d93
commit
a28d18c3e6
5 files changed
+43
-17
No files matched your search
+5
-1
@@ -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;
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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<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 { 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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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!);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user