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

+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";
}