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

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