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]>
193 lines
8.0 KiB
TypeScript
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 };
|
|
}
|