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

@@ -108,7 +108,7 @@ export default async function SettingsPage({ searchParams }: { searchParams: Pro
name="installmentRuleApproved"
label="Terminregel für Raten freigegeben"
defaultChecked={camp.installmentRuleApproved}
hint="Letzte Rate am Stichtag, bei drei Raten die zweite in der Mitte; Einzug 9 Uhr."
hint="Letzte Rate am Stichtag, bei drei Raten die zweite in der Mitte; Stripe zieht ab 9 Uhr ein."
/>
<Checkbox name="installmentsApproved" label="Ratenzahlung für den Live-Verkauf freigegeben" defaultChecked={camp.installmentsApproved} />
<label className="mt-3 block">
+2 -3
View File
@@ -4,9 +4,8 @@
*/
import { z } from "zod";
import { checkSameOrigin, fail, ok, rateLimited, readJson } from "@/lib/api";
import { abandonCheckout, startInstallmentCheckout } from "@/lib/checkout";
import { abandonCheckout, installmentPaymentUrl } from "@/lib/checkout";
import { getDb } from "@/lib/db";
import { requireAppSecret } from "@/lib/env";
import { getGateway } from "@/lib/payment-gateway";
import { RATE_LIMITS } from "@/lib/rate-limit";
import { isWellFormedStatusToken, sha256Hex } from "@/lib/tokens";
@@ -37,7 +36,7 @@ export async function POST(request: Request) {
switch (body.data.action) {
case "pay_installment": {
const result = await startInstallmentCheckout(db, gateway, { bookingId: booking.id, installmentId: body.data.installmentId }, { now, secret: requireAppSecret() });
const result = await installmentPaymentUrl(db, gateway, { bookingId: booking.id, installmentId: body.data.installmentId });
if (result.ok) return ok({ url: result.url });
return result.error === "NOT_PAYABLE"
? fail(409, { code: "NOT_PAYABLE", message: "Diese Rate kann gerade nicht bezahlt werden. Bitte lade die Seite neu." })
+14 -4
View File
@@ -15,6 +15,7 @@ import { enqueueEmail } from "./email";
import { bookingConfirmedEmail } from "./email-templates";
import type { PaymentGateway } from "./payment-gateway";
import { getCamp } from "./camp";
import { stopCanceledInstallmentPlans } from "./installment-billing";
import { addDays, berlinStartOfDay, isoToDbDate } from "./time";
import { statusTokenFor } from "./tokens";
import { normalizeEmail, normalizeVoucherCode } from "./vouchers";
@@ -54,10 +55,19 @@ export async function cancelBooking(
});
if (!before) return { ok: false, message: "Buchung nicht gefunden oder bereits storniert." };
// Offene Stripe-Sessions schliessen und gespeicherte Zahlungsmethode
// trennen: nach einer Stornierung darf nichts mehr abgebucht werden.
const sessions = [before.stripeCheckoutSessionId, ...before.installments.map((i) => i.checkoutSessionId)].filter(Boolean) as string[];
for (const id of sessions) await gateway.expireCheckoutSession(id).catch(() => undefined);
// Offene Session schliessen, Stripe-Ratenplan beenden und gespeicherte
// Zahlungsmethode trennen: nach einer Stornierung darf nichts mehr
// abgebucht werden.
if (before.stripeCheckoutSessionId) await gateway.expireCheckoutSession(before.stripeCheckoutSessionId).catch(() => undefined);
try {
await stopCanceledInstallmentPlans(db, gateway, [before.id], actor);
} catch (error) {
console.error(`[admin] Ratenplan fuer Buchung ${before.id} nicht beendet: ${error instanceof Error ? error.message : "unbekannt"}`);
return {
ok: false,
message: "Buchung storniert, aber der Ratenplan bei Stripe ließ sich noch nicht beenden. Der Hintergrundlauf versucht es alle 10 Minuten erneut.",
};
}
await detachFinishedPaymentMethods(db, gateway, [before.id]);
return { ok: true, message: "Buchung storniert. Eine Erstattung wird separat ausgelöst." };
}
+17 -62
View File
@@ -120,77 +120,32 @@ export async function startCheckout(
}
}
export type InstallmentCheckoutResult =
| { ok: true; url: string }
| { ok: false; error: "NOT_PAYABLE" | "PAYMENT_PROVIDER" };
export type InstallmentPaymentResult = { ok: true; url: string } | { ok: false; error: "NOT_PAYABLE" | "PAYMENT_PROVIDER" };
/**
* Kundin zahlt eine Folgerate selbst (nach fehlgeschlagenem Einzug, bei
* verlangter Authentifizierung oder freiwillig vorzeitig). Die Rate wird
* fuer die Laufzeit der Session markiert, damit der Worker sie nicht
* gleichzeitig off-session einzieht.
* Kundin zahlt eine Folgerate selbst (nach fehlgeschlagenem Einzug oder bei
* verlangter Bestaetigung). Bezahlt wird die offene Stripe-Rechnung dieser
* Rate ueber Stripes Rechnungsseite — auch mit einer anderen Zahlungsmethode.
* Eine eigene Checkout-Session gibt es bewusst nicht: Stripe wuerde die
* Rechnung sonst parallel weiter einziehen.
*/
export async function startInstallmentCheckout(
export async function installmentPaymentUrl(
db: Db,
gateway: PaymentGateway,
params: { bookingId: string; installmentId: string },
ctx: { now: Date; secret: string },
): Promise<InstallmentCheckoutResult> {
const sessionExpiresAt = new Date(ctx.now.getTime() + MIN_STRIPE_SESSION_MS + 60_000);
const claim = await db.$transaction(async (tx) => {
await lockBooking(tx, params.bookingId);
const booking = await tx.booking.findUniqueOrThrow({
where: { id: params.bookingId },
include: { installments: true },
});
const installment = booking.installments.find((i) => i.id === params.installmentId);
if (!installment || installment.seq === 1) return null;
if (booking.status !== "CONFIRMED" || !booking.stripeCustomerId) return null;
if (!["PENDING", "FAILED", "REQUIRES_ACTION"].includes(installment.status)) return null;
if (booking.paidCents + installment.amountCents > booking.totalCents) return null;
if (installment.checkoutSessionId && installment.checkoutExpiresAt && installment.checkoutExpiresAt > new Date(ctx.now.getTime() + 5 * 60_000)) {
return { booking, installment, existingSessionId: installment.checkoutSessionId };
}
await tx.installment.update({
where: { id: installment.id },
data: { checkoutExpiresAt: sessionExpiresAt, checkoutSessionId: null },
});
return { booking, installment, existingSessionId: null as string | null };
): Promise<InstallmentPaymentResult> {
const installment = await db.installment.findFirst({
where: { id: params.installmentId, bookingId: params.bookingId, seq: { gt: 1 } },
include: { booking: { select: { status: true } } },
});
if (!claim) return { ok: false, error: "NOT_PAYABLE" };
const { booking, installment } = claim;
if (!installment?.stripeInvoiceId || installment.booking.status !== "CONFIRMED") return { ok: false, error: "NOT_PAYABLE" };
if (installment.status !== "FAILED" && installment.status !== "REQUIRES_ACTION") return { ok: false, error: "NOT_PAYABLE" };
try {
if (claim.existingSessionId) {
const existing = await gateway.retrieveCheckoutSession(claim.existingSessionId);
if (existing.status === "open" && existing.url) return { ok: true, url: existing.url };
}
const token = statusTokenFor(booking.id, ctx.secret);
const session = await gateway.createCheckoutSession(
{
customerId: booking.stripeCustomerId!,
amountCents: installment.amountCents,
productName: PRODUCT_NAME,
description: `${PRODUCT_DESCRIPTION} · Rate ${installment.seq} von ${booking.installments.length}`,
metadata: { kind: "installment", bookingId: booking.id, reference: booking.reference, installmentId: installment.id },
saveForFutureUsage: booking.installments.some((i) => i.seq > installment.seq && i.status !== "PAID"),
expiresAt: sessionExpiresAt,
successUrl: appUrl(`/buchung/status/${token}?checkout=success`),
cancelUrl: appUrl(`/buchung/status/${token}`),
submitMessage: `Rate ${installment.seq} über ${formatEuro(installment.amountCents)} für Buchung ${booking.reference}.`,
},
`installment-checkout:${installment.id}:${sessionExpiresAt.getTime()}`,
);
await db.installment.update({ where: { id: installment.id }, data: { checkoutSessionId: session.id } });
await audit(db, CUSTOMER, "checkout.installment_session_created", "Installment", installment.id, { sessionId: session.id });
return { ok: true, url: session.url };
const invoice = await gateway.retrieveInvoice(installment.stripeInvoiceId);
if (invoice.status !== "open" || !invoice.hostedInvoiceUrl) return { ok: false, error: "NOT_PAYABLE" };
return { ok: true, url: invoice.hostedInvoiceUrl };
} catch (error) {
console.error(`[checkout] Stripe-Fehler fuer Rate ${installment.id}: ${error instanceof Error ? error.message : "unbekannt"}`);
await db.installment.updateMany({
where: { id: installment.id, checkoutSessionId: null },
data: { checkoutExpiresAt: null },
});
console.error(`[checkout] Rechnung fuer Rate ${installment.id} nicht abrufbar: ${error instanceof Error ? error.message : "unbekannt"}`);
return { ok: false, error: "PAYMENT_PROVIDER" };
}
}
+160
View File
@@ -0,0 +1,160 @@
/**
* Folgeraten ueber Stripe Billing (docs/ARCHITEKTUR.md, ADR-3).
*
* Nach der bezahlten ersten Rate wird bei Stripe ein Subscription Schedule
* angelegt, der die Folgeraten zu ihren Faelligkeiten einzieht. Ab dann
* kuemmert sich Stripe um Einzug, Wiederholungen und Mahnungen; diese
* Anwendung verbucht nur die Rechnungen (Webhook, Abgleich im Worker).
*
* Voraussetzung, die payment-plan.ts garantiert: Alle Folgeraten sind gleich
* hoch (ein etwaiger Rest steckt in der ersten Rate). Der Zeitplan besteht
* deshalb aus einem einzigen Preis mit dem Ratenabstand in Tagen als
* Intervall und endet nach der letzten Rate von selbst.
*/
import { audit, WORKER, type Actor } from "./audit";
import type { Db } from "./db";
import { applyInstallmentInvoicePaid, flagBookingForReview, type EventContext } from "./payment-events";
import type { InvoiceInfo, PaymentGateway } from "./payment-gateway";
import { berlinDateString, daysBetween } from "./time";
export const INSTALLMENT_PLAN_KIND = "installment_plan";
/** Stripe verlangt einen Start in der Zukunft. */
const MIN_START_LEAD_MS = 60_000;
export type ScheduleOutcome = "created" | "exists" | "skipped" | "needs_review";
type Ctx = Pick<EventContext, "now" | "secret" | "adminEmail">;
export async function ensureInstallmentSchedule(
db: Db,
gateway: PaymentGateway,
bookingId: string,
ctx: Ctx,
actor: Actor,
): Promise<ScheduleOutcome> {
const booking = await db.booking.findUniqueOrThrow({
where: { id: bookingId },
include: { installments: { orderBy: { seq: "asc" } } },
});
if (booking.stripeScheduleId) return "exists";
if (booking.planType === "FULL" || booking.status !== "CONFIRMED") return "skipped";
if (booking.livemode !== (gateway.mode === "live")) return "skipped";
const later = booking.installments.filter((i) => i.seq > 1 && i.status === "PENDING");
if (later.length === 0) return "skipped";
const problem = schedulePlanProblem(booking, later, ctx.now);
if (problem) {
await db.$transaction((tx) => flagBookingForReview(tx, booking.id, problem, { ...ctx, actor }));
return "needs_review";
}
const first = later[0]!;
const intervalDays =
later.length === 1 ? 1 : daysBetween(berlinDateString(first.dueAt!), berlinDateString(later[1]!.dueAt!));
const startAt = new Date(Math.max(first.dueAt!.getTime(), ctx.now.getTime() + MIN_START_LEAD_MS));
const schedule = await gateway.createInstallmentSchedule(
{
customerId: booking.stripeCustomerId!,
paymentMethodId: booking.stripePaymentMethodId!,
startAt,
amountCents: first.amountCents,
intervalDays,
count: later.length,
description: `ARTISTA MODELCAMP 2027 · ${booking.reference} · Folgerate`,
metadata: { kind: INSTALLMENT_PLAN_KIND, bookingId: booking.id, reference: booking.reference },
},
`schedule:${booking.id}`,
);
const saved = await db.booking.updateMany({
where: { id: booking.id, stripeScheduleId: null },
data: { stripeScheduleId: schedule.id },
});
if (saved.count === 0) return "exists";
await audit(db, actor, "installments.schedule_created", "Booking", booking.id, {
scheduleId: schedule.id,
startAt: startAt.toISOString(),
intervalDays,
count: later.length,
});
// Wurde die Buchung waehrenddessen storniert, sofort wieder beenden.
const current = await db.booking.findUniqueOrThrow({ where: { id: booking.id }, select: { status: true } });
if (current.status === "CANCELED") await gateway.cancelInstallmentSchedule(schedule.id);
return "created";
}
function schedulePlanProblem(
booking: { stripeCustomerId: string | null; stripePaymentMethodId: string | null; paymentMethodDetachedAt: Date | null },
later: { amountCents: number; dueAt: Date | null }[],
now: Date,
): string | null {
if (!booking.stripeCustomerId || !booking.stripePaymentMethodId || booking.paymentMethodDetachedAt) {
return "Keine gespeicherte Zahlungsmethode für die Folgeraten – Ratenplan bei Stripe nicht angelegt.";
}
if (later.some((i) => !i.dueAt) || later.some((i) => i.amountCents !== later[0]!.amountCents)) {
return "Folgeraten passen nicht zu einem Stripe-Ratenplan (Betrag oder Termin) – bitte prüfen.";
}
if (later.at(-1)!.dueAt! < now) {
return "Termin der letzten Rate ist bereits verstrichen – Ratenplan bei Stripe nicht angelegt.";
}
return null;
}
/**
* Beendet die Stripe-Ratenplaene stornierter Buchungen und trennt danach die
* gespeicherte Zahlungsmethode. paymentMethodDetachedAt markiert "erledigt";
* scheitert Stripe, bleibt die Buchung Kandidat fuer den naechsten Lauf.
*/
export async function stopCanceledInstallmentPlans(db: Db, gateway: PaymentGateway, bookingIds?: string[], actor: Actor = WORKER) {
const candidates = await db.booking.findMany({
where: {
status: "CANCELED",
stripeScheduleId: { not: null },
paymentMethodDetachedAt: null,
livemode: gateway.mode === "live",
...(bookingIds ? { id: { in: bookingIds } } : {}),
},
take: 50,
});
for (const booking of candidates) {
await gateway.cancelInstallmentSchedule(booking.stripeScheduleId!);
if (booking.stripePaymentMethodId) await gateway.detachPaymentMethod(booking.stripePaymentMethodId);
await db.booking.update({ where: { id: booking.id }, data: { paymentMethodDetachedAt: new Date() } });
await audit(db, actor, "installments.schedule_canceled", "Booking", booking.id, { scheduleId: booking.stripeScheduleId });
}
return { stopped: candidates.length };
}
/**
* Verbucht eine bezahlte Rechnung des Zeitplans. Genutzt vom Webhook und vom
* Abgleich im Worker; mehrfacher Aufruf ist unschaedlich.
*/
export async function settleInstallmentInvoice(
db: Db,
gateway: PaymentGateway,
invoice: InvoiceInfo,
ctx: Ctx,
actor: Actor,
): Promise<string | null> {
const { kind, bookingId } = invoice.metadata;
if (kind !== INSTALLMENT_PLAN_KIND || !bookingId) return null;
if (invoice.status !== "paid" || !invoice.paymentIntentId) return null;
const pi = await gateway.retrievePaymentIntent(invoice.paymentIntentId);
await db.$transaction((tx) =>
applyInstallmentInvoicePaid(
tx,
bookingId,
invoice.id,
{
paymentIntentId: pi.id,
chargeId: pi.chargeId,
paymentMethodId: pi.paymentMethodId,
customerId: null,
amountCents: invoice.amountPaidCents,
},
{ ...ctx, actor },
),
);
return bookingId;
}
+65 -28
View File
@@ -31,9 +31,6 @@ import {
import { statusTokenFor } from "./tokens";
import { countUsedRedemptions } from "./vouchers";
export const MAX_OFF_SESSION_ATTEMPTS = 3;
export const RETRY_DELAY_DAYS = 3;
export type EventContext = { now: Date; secret: string; actor: Actor; adminEmail: string | null };
export type PaymentInfo = {
@@ -66,6 +63,11 @@ async function flagForReview(tx: Tx, booking: Booking, reason: string, ctx: Even
if (ctx.adminEmail) await enqueueEmail(tx, adminReviewEmail(ctx.adminEmail, booking, reason));
}
/** Fuer Probleme, die ausserhalb eines Zahlungsereignisses auffallen (z. B. Ratenplan bei Stripe). */
export async function flagBookingForReview(tx: Tx, bookingId: string, reason: string, ctx: EventContext): Promise<void> {
await flagForReview(tx, await loadLocked(tx, bookingId), reason, ctx);
}
export type FirstPaymentOutcome = "confirmed" | "duplicate" | "needs_review";
/** Erste Rate (bzw. Einmalzahlung) ist bei Stripe erfolgreich eingegangen. */
@@ -230,7 +232,7 @@ export async function applyCheckoutExpired(
export type InstallmentPaidOutcome = { outcome: "paid" | "duplicate" | "needs_review"; fullyPaid: boolean };
/** Folgerate bezahlt (Off-Session-Einzug oder selbst bezahlt per Link). */
/** Folgerate bezahlt (ueber ihre Stripe-Rechnung, siehe applyInstallmentInvoicePaid). */
export async function applyInstallmentPaid(
tx: Tx,
installmentId: string,
@@ -294,47 +296,82 @@ export async function applyInstallmentPaid(
}
/**
* Off-Session-Einzug fehlgeschlagen. Greift nur, solange die Rate vom Worker
* beansprucht ist (PROCESSING) — so zaehlt ein Fehler nie doppelt, egal ob
* ihn der Worker direkt oder der Webhook zuerst meldet.
* Ordnet eine Rechnung des Stripe-Zeitplans ihrer Folgerate zu. Stripe
* erzeugt die Rechnungen nacheinander (die naechste erst im naechsten
* Abrechnungszeitraum), deshalb gehoert eine neue Rechnung zur ersten noch
* nicht verknuepften, unbezahlten Rate.
*/
export async function applyInstallmentFailed(
async function installmentForInvoice(tx: Tx, booking: FullBooking, invoiceId: string): Promise<Installment | null> {
const linked = booking.installments.find((i) => i.stripeInvoiceId === invoiceId);
if (linked) return linked;
const next = booking.installments.find((i) => i.seq > 1 && !i.stripeInvoiceId && i.status !== "PAID");
if (!next) return null;
return tx.installment.update({ where: { id: next.id }, data: { stripeInvoiceId: invoiceId } });
}
/** Stripe Billing hat eine Folgerate eingezogen (automatisch oder von der Kundin ueber die Rechnungsseite bezahlt). */
export async function applyInstallmentInvoicePaid(
tx: Tx,
installmentId: string,
info: { paymentIntentId: string | null; failureCode: string | null; requiresAction: boolean },
bookingId: string,
invoiceId: string,
info: PaymentInfo,
ctx: EventContext,
): Promise<InstallmentPaidOutcome | "unmatched"> {
const booking = await loadLocked(tx, bookingId);
const installment = await installmentForInvoice(tx, booking, invoiceId);
if (!installment) {
await flagForReview(tx, booking, `Zahlung über Rechnung ${invoiceId} passt zu keiner offenen Rate – bitte prüfen und ggf. erstatten.`, ctx);
return "unmatched";
}
return applyInstallmentPaid(tx, installment.id, info, ctx);
}
/**
* Stripe konnte eine Folgerate nicht einziehen. Stripe versucht es selbst
* erneut (Smart Retries) und schickt bei verlangter Bestaetigung (3-D
* Secure) einen Link. Hier wird nur der Stand festgehalten, die Kundin
* informiert und — wenn Stripe aufgibt — ein Mensch eingeschaltet.
*/
export async function applyInstallmentInvoiceFailed(
tx: Tx,
bookingId: string,
invoice: { id: string; attemptCount: number; nextPaymentAttempt: Date | null },
info: { requiresAction: boolean },
ctx: EventContext,
): Promise<"recorded" | "ignored"> {
const target = await tx.installment.findUniqueOrThrow({ where: { id: installmentId } });
const booking = await loadLocked(tx, target.bookingId);
const installment = booking.installments.find((i) => i.id === installmentId)!;
if (installment.status !== "PROCESSING") return "ignored";
const booking = await loadLocked(tx, bookingId);
const installment = await installmentForInvoice(tx, booking, invoice.id);
if (!installment || installment.status === "PAID" || installment.status === "CANCELED") return "ignored";
const status = info.requiresAction ? "REQUIRES_ACTION" : "FAILED";
// Dasselbe Ergebnis desselben Versuchs (doppelt zugestellt oder als
// payment_failed UND payment_action_required gemeldet) nur einmal zaehlen.
if (installment.attemptCount >= invoice.attemptCount && installment.status === status) return "ignored";
const attempt = installment.attemptCount + 1;
const retry = !info.requiresAction && attempt < MAX_OFF_SESSION_ATTEMPTS;
const updatedInstallment = await tx.installment.update({
where: { id: installmentId },
where: { id: installment.id },
data: {
status: info.requiresAction ? "REQUIRES_ACTION" : "FAILED",
attemptCount: attempt,
failureCode: info.failureCode,
stripePaymentIntentId: info.paymentIntentId ?? installment.stripePaymentIntentId,
nextAttemptAt: retry ? new Date(ctx.now.getTime() + RETRY_DELAY_DAYS * 86_400_000) : null,
status,
attemptCount: Math.max(invoice.attemptCount, installment.attemptCount),
lastAttemptAt: ctx.now,
failureCode: info.requiresAction ? "authentication_required" : "payment_failed",
nextAttemptAt: invoice.nextPaymentAttempt,
},
});
await audit(tx, ctx.actor, "payment.installment_failed", "Installment", installmentId, {
await audit(tx, ctx.actor, "payment.installment_failed", "Installment", installment.id, {
bookingId: booking.id,
seq: installment.seq,
attempt,
failureCode: info.failureCode,
invoiceId: invoice.id,
attempt: invoice.attemptCount,
requiresAction: info.requiresAction,
nextAttemptAt: updatedInstallment.nextAttemptAt?.toISOString() ?? null,
nextAttemptAt: invoice.nextPaymentAttempt?.toISOString() ?? null,
});
await enqueueEmail(
tx,
installmentActionEmail(booking, updatedInstallment, statusTokenFor(booking.id, ctx.secret), info.requiresAction),
);
if (!retry && !info.requiresAction) {
await flagForReview(tx, booking, `Rate ${installment.seq} ist nach ${attempt} Versuchen nicht eingegangen.`, ctx);
if (!info.requiresAction && !invoice.nextPaymentAttempt) {
await flagForReview(tx, booking, `Rate ${installment.seq} ist nach ${invoice.attemptCount} Versuchen nicht eingegangen.`, ctx);
}
return "recorded";
}
+135 -52
View File
@@ -7,12 +7,12 @@
* Checkout erfasst sie, hier liegen nur Objekt-IDs.
*
* Architekturentscheidung Ratenzahlung (docs/ARCHITEKTUR.md, ADR-3):
* Erste Rate ueber Stripe Checkout mit setup_future_usage=off_session,
* Folgeraten als einzelne Off-Session-PaymentIntents, ausgeloest durch einen
* idempotenten Kubernetes-CronJob. Kein Abonnement, kein Subscription
* Schedule: deren Intervalle passen nicht zur Regel "letzte Rate 30 Tage vor
* Anreise", und die Planlaenge ist hier durch die Ratenzeilen selbst
* begrenzt.
* Erste Rate ueber Stripe Checkout mit setup_future_usage=off_session.
* Die Folgeraten zieht Stripe Billing ein: Nach der ersten Zahlung legt die
* Anwendung einen Subscription Schedule an (Start = Faelligkeit der zweiten
* Rate, Intervall = Abstand der Raten in Tagen, end_behavior=cancel). Stripe
* uebernimmt Einzug, Wiederholungen (Smart Retries), Bestaetigungslinks bei
* 3-D Secure und Mahnungen; die Anwendung verbucht nur die Rechnungs-Events.
*/
import Stripe from "stripe";
import { env, stripeMode } from "./env";
@@ -37,11 +37,32 @@ export type PaymentIntentInfo = {
metadata: Record<string, string>;
};
export type OffSessionResult = {
outcome: "succeeded" | "processing" | "requires_action" | "failed";
export type InvoiceInfo = {
id: string;
status: "draft" | "open" | "paid" | "uncollectible" | "void";
amountPaidCents: number;
paymentIntentId: string | null;
chargeId: string | null;
failureCode: string | null;
hostedInvoiceUrl: string | null;
attemptCount: number;
/** null = Stripe versucht keinen weiteren automatischen Einzug. */
nextPaymentAttempt: Date | null;
/** Metadaten des Abonnements zum Rechnungszeitpunkt (bookingId usw.). */
metadata: Record<string, string>;
};
export type CreateInstallmentScheduleParams = {
customerId: string;
paymentMethodId: string;
/** Faelligkeit der ersten Folgerate. */
startAt: Date;
/** Betrag je Folgerate (alle Folgeraten sind gleich hoch). */
amountCents: number;
/** Abstand zwischen den Folgeraten in Tagen. */
intervalDays: number;
/** Anzahl der Folgeraten. */
count: number;
description: string;
metadata: Record<string, string>;
};
export type CreateCheckoutParams = {
@@ -65,10 +86,12 @@ export interface PaymentGateway {
retrieveCheckoutSession(id: string): Promise<CheckoutSessionInfo>;
expireCheckoutSession(id: string): Promise<void>;
retrievePaymentIntent(id: string): Promise<PaymentIntentInfo>;
chargeOffSession(
params: { customerId: string; paymentMethodId: string; amountCents: number; description: string; metadata: Record<string, string> },
idempotencyKey: string,
): Promise<OffSessionResult>;
createInstallmentSchedule(params: CreateInstallmentScheduleParams, idempotencyKey: string): Promise<{ id: string }>;
/** Beendet den Zeitplan sofort und storniert offene Rechnungen: danach wird nichts mehr eingezogen. */
cancelInstallmentSchedule(id: string): Promise<void>;
retrieveInvoice(id: string): Promise<InvoiceInfo>;
/** Alle Rechnungen, die der Zeitplan bisher erzeugt hat (fuer den Abgleich). */
listScheduleInvoices(scheduleId: string): Promise<InvoiceInfo[]>;
detachPaymentMethod(id: string): Promise<void>;
createRefund(
params: { paymentIntentId: string; amountCents: number; metadata: Record<string, string> },
@@ -174,50 +197,87 @@ export class StripeGateway implements PaymentGateway {
};
}
async chargeOffSession(
params: { customerId: string; paymentMethodId: string; amountCents: number; description: string; metadata: Record<string, string> },
idempotencyKey: string,
): Promise<OffSessionResult> {
try {
const pi = await this.stripe.paymentIntents.create(
{
amount: params.amountCents,
currency: "eur",
customer: params.customerId,
payment_method: params.paymentMethodId,
off_session: true,
confirm: true,
async createInstallmentSchedule(params: CreateInstallmentScheduleParams, idempotencyKey: string) {
const productId = await this.installmentProductId();
const start = Math.floor(params.startAt.getTime() / 1000);
const schedule = await this.stripe.subscriptionSchedules.create(
{
customer: params.customerId,
start_date: start,
// Endet genau mit dem Abrechnungszeitraum der letzten Rate: Stripe
// kuendigt dann ohne weitere Rechnung (mit Testuhr geprueft).
end_behavior: "cancel",
default_settings: {
collection_method: "charge_automatically",
default_payment_method: params.paymentMethodId,
description: params.description,
metadata: params.metadata,
},
{ idempotencyKey },
);
return {
outcome:
pi.status === "succeeded"
? "succeeded"
: pi.status === "processing"
? "processing"
: pi.status === "requires_action"
? "requires_action"
: "failed",
paymentIntentId: pi.id,
chargeId: idOf(pi.latest_charge),
failureCode: pi.last_payment_error?.code ?? null,
};
phases: [
{
end_date: start + params.count * params.intervalDays * DAY_SECONDS,
proration_behavior: "none",
items: [
{
price_data: {
currency: "eur",
product: productId,
unit_amount: params.amountCents,
recurring: { interval: "day", interval_count: params.intervalDays },
},
},
],
metadata: params.metadata,
},
],
metadata: params.metadata,
},
{ idempotencyKey },
);
return { id: schedule.id };
}
/** Feste Produkt-ID: beim ersten Bedarf angelegt, danach wiederverwendet. */
private async installmentProductId(): Promise<string> {
try {
return (await this.stripe.products.retrieve(INSTALLMENT_PRODUCT_ID)).id;
} catch (error) {
if (error instanceof Stripe.errors.StripeCardError) {
return {
outcome: error.code === "authentication_required" ? "requires_action" : "failed",
paymentIntentId: error.payment_intent?.id ?? null,
chargeId: null,
failureCode: error.decline_code ?? error.code ?? "card_error",
};
}
throw error;
if (!(error instanceof Stripe.errors.StripeInvalidRequestError)) throw error;
const product = await this.stripe.products.create(
{ id: INSTALLMENT_PRODUCT_ID, name: "ARTISTA MODELCAMP 2027, Folgerate" },
{ idempotencyKey: `product:${INSTALLMENT_PRODUCT_ID}` },
);
return product.id;
}
}
async cancelInstallmentSchedule(id: string): Promise<void> {
const schedule = await this.stripe.subscriptionSchedules.retrieve(id);
if (schedule.status === "not_started" || schedule.status === "active") {
await this.stripe.subscriptionSchedules.cancel(id, { invoice_now: false, prorate: false });
}
const subscriptionId = idOf(schedule.subscription);
if (!subscriptionId) return;
// Offene Rechnungen wuerden sonst weiter automatisch eingezogen.
for await (const invoice of this.stripe.invoices.list({ subscription: subscriptionId, status: "open", limit: 100 })) {
await this.stripe.invoices.voidInvoice(invoice.id!);
}
}
async retrieveInvoice(id: string): Promise<InvoiceInfo> {
return invoiceInfoFromStripe(await this.stripe.invoices.retrieve(id, { expand: ["payments"] }));
}
async listScheduleInvoices(scheduleId: string): Promise<InvoiceInfo[]> {
const schedule = await this.stripe.subscriptionSchedules.retrieve(scheduleId);
const subscriptionId = idOf(schedule.subscription);
if (!subscriptionId) return [];
const invoices: InvoiceInfo[] = [];
for await (const invoice of this.stripe.invoices.list({ subscription: subscriptionId, limit: 100, expand: ["data.payments"] })) {
invoices.push(invoiceInfoFromStripe(invoice));
}
return invoices;
}
async detachPaymentMethod(id: string): Promise<void> {
try {
await this.stripe.paymentMethods.detach(id);
@@ -239,6 +299,29 @@ export class StripeGateway implements PaymentGateway {
}
}
const DAY_SECONDS = 86_400;
const INSTALLMENT_PRODUCT_ID = "artista_modelcamp_2027_folgerate";
/** Seit API 2025-03-31 haengt die Zahlung einer Rechnung an invoice.payments. */
function paidPaymentIntentId(invoice: Stripe.Invoice): string | null {
const payments = invoice.payments?.data ?? [];
const paid = payments.find((p) => p.status === "paid") ?? payments[0];
return idOf(paid?.payment.payment_intent);
}
export function invoiceInfoFromStripe(invoice: Stripe.Invoice): InvoiceInfo {
return {
id: invoice.id!,
status: (invoice.status ?? "draft") as InvoiceInfo["status"],
amountPaidCents: invoice.amount_paid,
paymentIntentId: paidPaymentIntentId(invoice),
hostedInvoiceUrl: invoice.hosted_invoice_url ?? null,
attemptCount: invoice.attempt_count,
nextPaymentAttempt: invoice.next_payment_attempt ? new Date(invoice.next_payment_attempt * 1000) : null,
metadata: (invoice.parent?.subscription_details?.metadata ?? {}) as Record<string, string>,
};
}
let gateway: PaymentGateway | null = null;
export function getGateway(): PaymentGateway {
+64 -89
View File
@@ -13,6 +13,7 @@ import type Stripe from "stripe";
import { Prisma } from "@/generated/prisma/client";
import { STRIPE_ACTOR, type Actor } from "./audit";
import type { Db } from "./db";
import { ensureInstallmentSchedule, INSTALLMENT_PLAN_KIND, settleInstallmentInvoice } from "./installment-billing";
import { normalizePaymentStatus, type CheckoutSessionInfo, type PaymentGateway } from "./payment-gateway";
import {
applyCheckoutExpired,
@@ -20,8 +21,7 @@ import {
applyFirstPaymentFailed,
applyFirstPaymentProcessing,
applyFirstPaymentSucceeded,
applyInstallmentFailed,
applyInstallmentPaid,
applyInstallmentInvoiceFailed,
applyRefundState,
type EventContext,
} from "./payment-events";
@@ -61,16 +61,15 @@ export async function settleCheckoutSession(
ctx: HandlerContext,
actor: Actor = STRIPE_ACTOR,
): Promise<string | null> {
const { kind, bookingId, installmentId } = session.metadata;
if (!bookingId || (kind !== "booking" && kind !== "installment")) return null;
const { kind, bookingId } = session.metadata;
if (!bookingId || kind !== "booking") return null;
const booking = await db.booking.findUnique({ where: { id: bookingId }, select: { id: true, stripeCheckoutSessionId: true } });
if (!booking) return null;
// Eine Buchungs-Session muss zur gespeicherten Session passen (oder es ist
// noch keine gespeichert, weil das Speichern nach Stripe scheiterte).
if (kind === "booking" && booking.stripeCheckoutSessionId && booking.stripeCheckoutSessionId !== session.id) return null;
// Die Session muss zur gespeicherten Session passen (oder es ist noch keine
// gespeichert, weil das Speichern nach Stripe scheiterte).
if (booking.stripeCheckoutSessionId && booking.stripeCheckoutSessionId !== session.id) return null;
const paid = session.paymentStatus === "paid";
if (paid) {
if (session.paymentStatus === "paid") {
if (!session.paymentIntentId) throw new Error(`Bezahlte Session ${session.id} ohne PaymentIntent.`);
const pi = await gateway.retrievePaymentIntent(session.paymentIntentId);
const info = {
@@ -80,29 +79,55 @@ export async function settleCheckoutSession(
customerId: session.customerId,
amountCents: pi.amountReceived,
};
if (kind === "booking") {
await db.$transaction((tx) => applyFirstPaymentSucceeded(tx, bookingId, info, eventCtx(ctx, actor)));
} else if (installmentId) {
await db.$transaction((tx) => applyInstallmentPaid(tx, installmentId, info, eventCtx(ctx, actor)));
}
await db.$transaction((tx) => applyFirstPaymentSucceeded(tx, bookingId, info, eventCtx(ctx, actor)));
await startInstallmentSchedule(db, gateway, bookingId, ctx, actor);
return bookingId;
}
if (session.status === "complete") {
if (kind === "booking") {
await db.$transaction((tx) =>
applyFirstPaymentProcessing(tx, bookingId, { paymentIntentId: session.paymentIntentId, customerId: session.customerId }, eventCtx(ctx, actor)),
);
} else if (installmentId) {
await db.installment.updateMany({
where: { id: installmentId, status: { in: ["PENDING", "FAILED", "REQUIRES_ACTION"] } },
data: { status: "PROCESSING", stripePaymentIntentId: session.paymentIntentId, lastAttemptAt: ctx.now },
});
}
await db.$transaction((tx) =>
applyFirstPaymentProcessing(tx, bookingId, { paymentIntentId: session.paymentIntentId, customerId: session.customerId }, eventCtx(ctx, actor)),
);
}
return bookingId;
}
/**
* Ratenplan bei Stripe anlegen, sobald die erste Rate bezahlt ist. Ein
* Fehler hier macht die verbuchte Zahlung nicht rueckgaengig: der Worker
* holt das Anlegen beim naechsten Lauf nach.
*/
async function startInstallmentSchedule(db: Db, gateway: PaymentGateway, bookingId: string, ctx: HandlerContext, actor: Actor) {
try {
await ensureInstallmentSchedule(db, gateway, bookingId, ctx, actor);
} catch (error) {
console.error(`[webhook] Ratenplan fuer Buchung ${bookingId} nicht angelegt: ${error instanceof Error ? error.message : "unbekannt"}`);
}
}
async function handleInstallmentInvoice(db: Db, gateway: PaymentGateway, event: Stripe.Event, ctx: HandlerContext): Promise<string[]> {
const object = event.data.object as Stripe.Invoice;
// Wahrheit bei Stripe nachladen statt dem Event-Inhalt zu vertrauen.
const invoice = await gateway.retrieveInvoice(object.id!);
const { kind, bookingId } = invoice.metadata;
if (kind !== INSTALLMENT_PLAN_KIND || !bookingId) return [];
if (event.type === "invoice.paid") {
await settleInstallmentInvoice(db, gateway, invoice, ctx, STRIPE_ACTOR);
return [bookingId];
}
if (invoice.status !== "open") return [];
await db.$transaction((tx) =>
applyInstallmentInvoiceFailed(
tx,
bookingId,
invoice,
{ requiresAction: event.type === "invoice.payment_action_required" },
eventCtx(ctx),
),
);
return [bookingId];
}
async function dispatch(db: Db, gateway: PaymentGateway, event: Stripe.Event, ctx: HandlerContext): Promise<string[]> {
switch (event.type) {
case "checkout.session.completed":
@@ -112,73 +137,22 @@ async function dispatch(db: Db, gateway: PaymentGateway, event: Stripe.Event, ct
}
case "checkout.session.async_payment_failed": {
const s = sessionInfoFromStripe(event.data.object);
const { kind, bookingId, installmentId } = s.metadata;
if (kind === "booking" && bookingId) {
await db.$transaction((tx) => applyFirstPaymentFailed(tx, bookingId, eventCtx(ctx)));
return [bookingId];
}
if (kind === "installment" && installmentId) {
await db.installment.updateMany({
where: { id: installmentId, status: "PROCESSING" },
data: { status: "FAILED", failureCode: "async_payment_failed", checkoutSessionId: null, checkoutExpiresAt: null },
});
return bookingId ? [bookingId] : [];
}
return [];
const { kind, bookingId } = s.metadata;
if (kind !== "booking" || !bookingId) return [];
await db.$transaction((tx) => applyFirstPaymentFailed(tx, bookingId, eventCtx(ctx)));
return [bookingId];
}
case "checkout.session.expired": {
const s = event.data.object;
const { kind, bookingId, installmentId } = (s.metadata ?? {}) as Record<string, string>;
if (kind === "booking" && bookingId) {
await db.$transaction((tx) => applyCheckoutExpired(tx, bookingId, s.id, eventCtx(ctx)));
return [bookingId];
}
if (kind === "installment" && installmentId) {
await db.installment.updateMany({
where: { id: installmentId, checkoutSessionId: s.id },
data: { checkoutSessionId: null, checkoutExpiresAt: null },
});
}
return [];
}
case "payment_intent.succeeded": {
const pi = event.data.object;
if (pi.metadata?.kind !== "installment_offsession" || !pi.metadata.installmentId) return [];
const outcome = await db.$transaction((tx) =>
applyInstallmentPaid(
tx,
pi.metadata.installmentId!,
{
paymentIntentId: pi.id,
chargeId: idOf(pi.latest_charge),
paymentMethodId: idOf(pi.payment_method),
customerId: idOf(pi.customer),
amountCents: pi.amount_received,
},
eventCtx(ctx),
),
);
void outcome;
return pi.metadata.bookingId ? [pi.metadata.bookingId] : [];
}
case "payment_intent.payment_failed": {
const pi = event.data.object;
if (pi.metadata?.kind !== "installment_offsession" || !pi.metadata.installmentId) return [];
const code = pi.last_payment_error?.code ?? null;
await db.$transaction((tx) =>
applyInstallmentFailed(
tx,
pi.metadata.installmentId!,
{
paymentIntentId: pi.id,
failureCode: pi.last_payment_error?.decline_code ?? code,
requiresAction: code === "authentication_required" || pi.status === "requires_action",
},
eventCtx(ctx),
),
);
return pi.metadata.bookingId ? [pi.metadata.bookingId] : [];
const { kind, bookingId } = (s.metadata ?? {}) as Record<string, string>;
if (kind !== "booking" || !bookingId) return [];
await db.$transaction((tx) => applyCheckoutExpired(tx, bookingId, s.id, eventCtx(ctx)));
return [bookingId];
}
case "invoice.paid":
case "invoice.payment_failed":
case "invoice.payment_action_required":
return handleInstallmentInvoice(db, gateway, event, ctx);
case "charge.refunded": {
const charge = event.data.object;
const piId = idOf(charge.payment_intent);
@@ -209,8 +183,9 @@ const HANDLED = new Set<string>([
"checkout.session.async_payment_succeeded",
"checkout.session.async_payment_failed",
"checkout.session.expired",
"payment_intent.succeeded",
"payment_intent.payment_failed",
"invoice.paid",
"invoice.payment_failed",
"invoice.payment_action_required",
"charge.refunded",
"refund.created",
"refund.updated",
+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 };
}