first commit
Docker-Images bauen und veroeffentlichen / build-and-push (push) Failing after 1m32s

This commit is contained in:
Nick Szaunig committed 2026-09-27 06:06:56 +02:00
commit 59bb5a38a3
132 files changed
+19435

No files matched your search

+298
View File
@@ -0,0 +1,298 @@
/**
* 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.
*/
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 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;
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 };
}
/** 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({
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 } }] }],
},
orderBy: { dueAt: "asc" },
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;
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}`,
);
} 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) },
});
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 } } },
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;
}
// 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++;
}
// 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"] } } },
},
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 installments = await chargeDueInstallments(db, gateway, ctx);
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 };
}