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,
|
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;
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(
|
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
Reference in new issue
Block a user