feat: Folgeraten über Stripe Billing einziehen

Statt eigener Off-Session-Einzüge durch den Worker legt die Anwendung nach
bezahlter erster Rate einen Stripe Subscription Schedule an. Stripe übernimmt
Einzug, Wiederholungen (Smart Retries), 3-D-Secure-Links und Mahnungen.

- Gateway: Zeitplan anlegen/beenden, Rechnungen lesen; chargeOffSession entfällt
- Webhook: invoice.paid, invoice.payment_failed, invoice.payment_action_required
- Worker: fehlende Zeitpläne anlegen, überfällige Raten abgleichen,
  Zeitpläne stornierter Buchungen beenden
- Storno beendet den Zeitplan und storniert offene Rechnungen
- "Rate bezahlen" führt auf Stripes Rechnungsseite
- Migration: Booking.stripeScheduleId, Installment.stripeInvoiceId

Zeitplan-Form mit Stripe-Testuhr gegen die Test-API geprüft.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-05 23:52:43 +02:00
1 parent bcf0f5c937
commit c1ad9ad2ca
18 files changed
+800 -555

No files matched your search

+64 -171
View File
@@ -1,25 +1,23 @@
/**
* Hintergrundaufgaben, ausgefuehrt vom Kubernetes-CronJob (scripts/worker.ts).
*
* Kein In-Process-Timer: Faelligkeiten stehen in der Datenbank, jeder Lauf
* arbeitet den aktuellen Stand ab. Mehrere gleichzeitige Laeufe (z. B. ein
* manueller Start waehrend des CronJobs) sind unschaedlich, weil jede Rate
* vor dem Einzug atomar beansprucht wird und Stripe-Aufrufe
* Idempotenzschluessel tragen.
* 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 { MAX_OFF_SESSION_ATTEMPTS, applyCheckoutExpired, applyFirstPaymentFailed, applyInstallmentFailed, applyInstallmentPaid, type EventContext } from "./payment-events";
import { ensureInstallmentSchedule, 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-Idempotenzschluessel gelten 24 Stunden; ein haengender Einzug
* wird nur innerhalb dieser Frist automatisch mit demselben Schluessel
* wiederholt, danach entscheidet ein Mensch. */
const SAFE_RETRY_WINDOW_MS = 20 * 60 * 60_000;
const STUCK_AFTER_MS = 2 * 60 * 60_000;
/** 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 };
@@ -61,181 +59,71 @@ export async function releaseExpiredReservations(db: Db, gateway: PaymentGateway
return { released, settled };
}
/** Zieht faellige Folgeraten off-session ein. */
export async function chargeDueInstallments(db: Db, gateway: PaymentGateway, ctx: HandlerContext) {
const livemode = gateway.mode === "live";
const due = await db.installment.findMany({
/**
* 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: {
seq: { gt: 1 },
booking: { status: "CONFIRMED", livemode },
OR: [
{ status: "PENDING", dueAt: { lte: ctx.now } },
{ status: "FAILED", nextAttemptAt: { lte: ctx.now }, attemptCount: { lt: MAX_OFF_SESSION_ATTEMPTS } },
],
AND: [{ OR: [{ checkoutExpiresAt: null }, { checkoutExpiresAt: { lt: ctx.now } }] }],
status: "CONFIRMED",
planType: { not: "FULL" },
stripeScheduleId: null,
livemode: gateway.mode === "live",
installments: { some: { seq: { gt: 1 }, status: "PENDING" } },
},
orderBy: { dueAt: "asc" },
select: { id: true },
take: 50,
});
const summary = { charged: 0, failed: 0, requiresAction: 0, processing: 0, skipped: 0 };
for (const installment of due) {
// Atomar beanspruchen: nur ein Lauf darf diese Rate einziehen.
const claimed = await db.installment.updateMany({
where: {
id: installment.id,
status: installment.status,
OR: [{ checkoutExpiresAt: null }, { checkoutExpiresAt: { lt: ctx.now } }],
},
data: { status: "PROCESSING", lastAttemptAt: ctx.now },
});
if (claimed.count === 0) {
summary.skipped++;
continue;
}
const booking = await db.booking.findUniqueOrThrow({ where: { id: installment.bookingId } });
// Schutz gegen jede Abbuchung ueber den vereinbarten Gesamtpreis hinaus,
// insbesondere nach der letzten Rate.
if (booking.paidCents + installment.amountCents > booking.totalCents) {
await db.installment.update({ where: { id: installment.id }, data: { status: "CANCELED", failureCode: "plan_already_paid" } });
await audit(db, WORKER, "installment.skipped_overpayment_guard", "Installment", installment.id, {
paidCents: booking.paidCents,
totalCents: booking.totalCents,
});
summary.skipped++;
continue;
}
if (!booking.stripeCustomerId || !booking.stripePaymentMethodId || booking.paymentMethodDetachedAt) {
await db.$transaction((tx) =>
applyInstallmentFailed(tx, installment.id, { paymentIntentId: null, failureCode: "no_saved_payment_method", requiresAction: true }, eventCtx(ctx)),
);
summary.requiresAction++;
continue;
}
let result;
const summary = { created: 0, needsReview: 0, failed: 0 };
for (const booking of bookings) {
try {
result = await gateway.chargeOffSession(
{
customerId: booking.stripeCustomerId,
paymentMethodId: booking.stripePaymentMethodId,
amountCents: installment.amountCents,
description: `ARTISTA MODELCAMP 2027 · ${booking.reference} · Rate ${installment.seq}`,
metadata: {
kind: "installment_offsession",
bookingId: booking.id,
installmentId: installment.id,
reference: booking.reference,
},
},
// Gleicher Schluessel bei Wiederholung nach Netzwerkfehler: Stripe
// liefert dann das erste Ergebnis statt ein zweites Mal abzubuchen.
`offsession:${installment.id}:${installment.attemptCount}`,
);
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] Einzug Rate ${installment.id} unterbrochen: ${error instanceof Error ? error.message : "unbekannt"}`);
await db.installment.updateMany({
where: { id: installment.id, status: "PROCESSING" },
data: { status: installment.status, nextAttemptAt: new Date(ctx.now.getTime() + 60 * 60_000) },
});
console.error(`[worker] Ratenplan fuer Buchung ${booking.id} nicht angelegt: ${error instanceof Error ? error.message : "unbekannt"}`);
summary.failed++;
continue;
}
if (result.outcome === "succeeded" && result.paymentIntentId) {
await db.$transaction((tx) =>
applyInstallmentPaid(
tx,
installment.id,
{
paymentIntentId: result.paymentIntentId!,
chargeId: result.chargeId,
paymentMethodId: booking.stripePaymentMethodId,
customerId: booking.stripeCustomerId,
amountCents: installment.amountCents,
},
eventCtx(ctx),
),
);
summary.charged++;
} else if (result.outcome === "processing") {
await db.installment.update({ where: { id: installment.id }, data: { stripePaymentIntentId: result.paymentIntentId } });
summary.processing++;
} else {
await db.$transaction((tx) =>
applyInstallmentFailed(
tx,
installment.id,
{
paymentIntentId: result.paymentIntentId,
failureCode: result.failureCode,
requiresAction: result.outcome === "requires_action",
},
eventCtx(ctx),
),
);
if (result.outcome === "requires_action") summary.requiresAction++;
else summary.failed++;
}
}
return summary;
}
/** Faengt haengende Einzuege und verlorene Webhooks auf. */
export async function reconcileStuck(db: Db, gateway: PaymentGateway, ctx: HandlerContext) {
const stuck = await db.installment.findMany({
where: { status: "PROCESSING", lastAttemptAt: { lt: new Date(ctx.now.getTime() - STUCK_AFTER_MS) }, seq: { gt: 1 } },
include: { booking: { select: { livemode: true } } },
/**
* 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 resolved = 0;
for (const installment of stuck) {
if (installment.booking.livemode !== (gateway.mode === "live")) continue;
if (installment.stripePaymentIntentId) {
const pi = await gateway.retrievePaymentIntent(installment.stripePaymentIntentId);
if (pi.status === "succeeded") {
await db.$transaction((tx) =>
applyInstallmentPaid(
tx,
installment.id,
{ paymentIntentId: pi.id, chargeId: pi.chargeId, paymentMethodId: pi.paymentMethodId, customerId: null, amountCents: pi.amountReceived },
eventCtx(ctx),
),
);
resolved++;
} else if (pi.status === "requires_payment_method" || pi.status === "canceled" || pi.status === "requires_action") {
await db.$transaction((tx) =>
applyInstallmentFailed(
tx,
installment.id,
{ paymentIntentId: pi.id, failureCode: `stuck_${pi.status}`, requiresAction: pi.status === "requires_action" },
eventCtx(ctx),
),
);
resolved++;
}
continue;
let settled = 0;
for (const booking of bookings) {
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++;
}
// Kein Ergebnis gespeichert (z. B. Absturz waehrend des Aufrufs).
if (installment.lastAttemptAt && ctx.now.getTime() - installment.lastAttemptAt.getTime() < SAFE_RETRY_WINDOW_MS) {
// attemptCount bleibt gleich -> naechster Lauf nutzt denselben
// Idempotenzschluessel und kann nicht doppelt abbuchen.
await db.installment.updateMany({
where: { id: installment.id, status: "PROCESSING" },
data: { status: "FAILED", nextAttemptAt: ctx.now, failureCode: "worker_interrupted" },
});
} else {
await db.installment.updateMany({
where: { id: installment.id, status: "PROCESSING" },
data: { status: "REQUIRES_ACTION", failureCode: "manual_check_required" },
});
await audit(db, WORKER, "installment.manual_check_required", "Installment", installment.id);
}
resolved++;
}
return { checked: bookings.length, settled };
}
/** 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: {
@@ -276,6 +164,9 @@ export async function detachFinishedPaymentMethods(db: Db, gateway: PaymentGatew
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,
});
@@ -290,9 +181,11 @@ export async function detachFinishedPaymentMethods(db: Db, gateway: PaymentGatew
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 installments = await chargeDueInstallments(db, gateway, ctx);
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, installments, detach, emails, purgedRateLimits: purged };
return { reservations, reconcile, schedules, installments, stopped, detach, emails, purgedRateLimits: purged };
}