Files
Artista-Filmcamp/src/lib/worker-jobs.ts
T
Nick SzaunigandClaude Opus 5.5 a28d18c3e6 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]>
2026-10-06 06:34:20 +02:00

193 lines
8.0 KiB
TypeScript

/**
* Hintergrundaufgaben, ausgefuehrt vom Kubernetes-CronJob (scripts/worker.ts).
*
* Kein In-Process-Timer: jeder Lauf arbeitet den aktuellen Stand ab. Die
* Folgeraten zieht Stripe Billing ein; der Worker legt nur fehlende
* Ratenplaene an und gleicht verlorene Webhooks ab. Mehrere gleichzeitige
* Laeufe sind unschaedlich, weil Stripe-Aufrufe Idempotenzschluessel tragen
* und jede Verbuchung idempotent ist.
*/
import { audit, WORKER } from "./audit";
import type { Db } from "./db";
import { flushEmails } from "./email";
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";
import { settleCheckoutSession, type HandlerContext } from "./webhook";
/** Stripe stellt Rechnungen erst rund eine Stunde nach Faelligkeit fertig. */
const RECONCILE_AFTER_MS = 6 * 60 * 60_000;
function eventCtx(ctx: HandlerContext): EventContext {
return { ...ctx, actor: WORKER };
}
/**
* Gibt abgelaufene Reservierungen frei. Vorher wird immer Stripe gefragt:
* wurde die Session doch bezahlt (Webhook verloren), wird die Zahlung
* verbucht statt der Platz freigegeben.
*/
export async function releaseExpiredReservations(db: Db, gateway: PaymentGateway, ctx: HandlerContext) {
const expired = await db.seatReservation.findMany({
where: { status: "ACTIVE", expiresAt: { lt: ctx.now } },
include: { booking: { select: { id: true, livemode: true, stripeCheckoutSessionId: true } } },
take: 100,
});
let released = 0;
let settled = 0;
for (const reservation of expired) {
const booking = reservation.booking;
const sameMode = booking.livemode === (gateway.mode === "live");
if (booking.stripeCheckoutSessionId && sameMode) {
let session = await gateway.retrieveCheckoutSession(booking.stripeCheckoutSessionId);
if (session.status === "open") {
await gateway.expireCheckoutSession(session.id);
session = await gateway.retrieveCheckoutSession(session.id);
}
if (session.status === "complete") {
await settleCheckoutSession(db, gateway, session, ctx, WORKER);
settled++;
continue;
}
}
const result = await db.$transaction((tx) =>
applyCheckoutExpired(tx, booking.id, null, { ...eventCtx(ctx), reason: "reservation_expired" }),
);
if (result === "released") released++;
}
return { released, settled };
}
/**
* Legt den Stripe-Ratenplan fuer bestaetigte Ratenbuchungen an, falls das
* direkt nach der ersten Zahlung scheiterte oder die Buchung erst spaeter
* bestaetigt wurde (Pruefung im Adminbereich).
*/
export async function createMissingSchedules(db: Db, gateway: PaymentGateway, ctx: HandlerContext) {
const bookings = await db.booking.findMany({
where: {
status: "CONFIRMED",
planType: { not: "FULL" },
stripeScheduleId: null,
livemode: gateway.mode === "live",
installments: { some: { seq: { gt: 1 }, status: "PENDING" } },
},
select: { id: true },
take: 50,
});
const summary = { created: 0, needsReview: 0, failed: 0 };
for (const booking of bookings) {
try {
const outcome = await ensureInstallmentSchedule(db, gateway, booking.id, ctx, WORKER);
if (outcome === "created") summary.created++;
if (outcome === "needs_review") summary.needsReview++;
} catch (error) {
console.error(`[worker] Ratenplan fuer Buchung ${booking.id} nicht angelegt: ${error instanceof Error ? error.message : "unbekannt"}`);
summary.failed++;
}
}
return summary;
}
/**
* Faengt verlorene Rechnungs-Webhooks auf: Ist eine Folgerate seit
* RECONCILE_AFTER_MS faellig und nicht verbucht, fragt der Worker Stripe
* nach den Rechnungen des Zeitplans und verbucht bezahlte.
*/
export async function reconcileInstallmentInvoices(db: Db, gateway: PaymentGateway, ctx: HandlerContext) {
const bookings = await db.booking.findMany({
where: {
stripeScheduleId: { not: null },
livemode: gateway.mode === "live",
installments: {
some: {
seq: { gt: 1 },
status: { in: ["PENDING", "FAILED", "REQUIRES_ACTION"] },
dueAt: { lt: new Date(ctx.now.getTime() - RECONCILE_AFTER_MS) },
},
},
},
select: { id: true, stripeScheduleId: true },
take: 50,
});
let settled = 0;
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, failed };
}
/** Faengt verzoegerte erste Zahlungen (SEPA) auf, deren Ergebnis-Webhook ausblieb. */
export async function reconcileStuck(db: Db, gateway: PaymentGateway, ctx: HandlerContext) {
let resolved = 0;
// Verzoegerte erste Zahlungen (SEPA), deren Ergebnis-Webhook ausblieb.
const processing = await db.booking.findMany({
where: {
status: "PENDING",
paymentStatus: "PROCESSING",
livemode: gateway.mode === "live",
updatedAt: { lt: new Date(ctx.now.getTime() - 24 * 60 * 60_000) },
stripeCheckoutSessionId: { not: null },
},
take: 50,
});
for (const booking of processing) {
const session = await gateway.retrieveCheckoutSession(booking.stripeCheckoutSessionId!);
if (session.paymentStatus === "paid") {
await settleCheckoutSession(db, gateway, session, ctx, WORKER);
resolved++;
} else if (session.paymentIntentId) {
const pi = await gateway.retrievePaymentIntent(session.paymentIntentId);
if (pi.status === "requires_payment_method" || pi.status === "canceled") {
await db.$transaction((tx) => applyFirstPaymentFailed(tx, booking.id, eventCtx(ctx)));
resolved++;
}
}
}
return { resolved };
}
/**
* Trennt die gespeicherte Zahlungsmethode, sobald keine Rate mehr offen ist
* (vollstaendig bezahlt oder storniert). Danach ist technisch keine weitere
* Abbuchung mehr moeglich.
*/
export async function detachFinishedPaymentMethods(db: Db, gateway: PaymentGateway, bookingIds?: string[]) {
const candidates = await db.booking.findMany({
where: {
stripePaymentMethodId: { not: null },
paymentMethodDetachedAt: null,
livemode: gateway.mode === "live",
...(bookingIds ? { id: { in: bookingIds } } : {}),
installments: { none: { status: { in: ["PENDING", "PROCESSING", "FAILED", "REQUIRES_ACTION"] } } },
// Stornierte Buchungen mit Stripe-Ratenplan: erst den Plan beenden
// (stopCanceledInstallmentPlans), sonst zoege Stripe weiter ein.
NOT: { status: "CANCELED", stripeScheduleId: { not: null } },
},
take: 50,
});
for (const booking of candidates) {
await gateway.detachPaymentMethod(booking.stripePaymentMethodId!);
await db.booking.update({ where: { id: booking.id }, data: { paymentMethodDetachedAt: new Date() } });
await audit(db, WORKER, "payment_method.detached", "Booking", booking.id, { paymentMethodId: booking.stripePaymentMethodId });
}
return { detached: candidates.length };
}
export async function runWorker(db: Db, gateway: PaymentGateway, ctx: HandlerContext) {
const reservations = await releaseExpiredReservations(db, gateway, ctx);
const reconcile = await reconcileStuck(db, gateway, ctx);
const held = await holdSchedulesInReview(db, gateway);
const schedules = await createMissingSchedules(db, gateway, ctx);
const installments = await reconcileInstallmentInvoices(db, gateway, ctx);
const stopped = await stopCanceledInstallmentPlans(db, gateway);
const detach = await detachFinishedPaymentMethods(db, gateway);
const emails = await flushEmails(db, { limit: 200 });
const purged = await purgeRateLimits(db);
return { reservations, reconcile, held, schedules, installments, stopped, detach, emails, purgedRateLimits: purged };
}