/** * 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 }; }