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:
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">
|
||||
|
||||
@@ -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." })
|
||||
|
||||
@@ -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
@@ -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" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user