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

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