feat: Folgeraten über Stripe Billing einziehen

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

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

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

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

No files matched your search

+64 -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",