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

+118 -93
View File
@@ -1,10 +1,10 @@
import Stripe from "stripe";
import { beforeEach, describe, expect, test } from "vitest";
import { startCheckout, startInstallmentCheckout } from "@/lib/checkout";
import { applyRefundState, MAX_OFF_SESSION_ATTEMPTS } from "@/lib/payment-events";
import { installmentPaymentUrl, startCheckout } from "@/lib/checkout";
import { applyRefundState } from "@/lib/payment-events";
import { verifyStripeWebhook } from "@/lib/payment-gateway";
import { handleStripeEvent, settleCheckoutSession } from "@/lib/webhook";
import { chargeDueInstallments, detachFinishedPaymentMethods, releaseExpiredReservations } from "@/lib/worker-jobs";
import { createMissingSchedules, detachFinishedPaymentMethods, reconcileInstallmentInvoices, releaseExpiredReservations } from "@/lib/worker-jobs";
import { STRIPE_ACTOR } from "@/lib/audit";
import type { PlanType } from "@/lib/payment-plan";
import { bookingRequest, db, EARLY_BIRD_NOW, resetDb, SECRET, setupCamp } from "./support/db";
@@ -212,113 +212,138 @@ describe("Verzoegerte und verspaetete Zahlung", () => {
});
});
describe("Folgeraten", () => {
const afterLastDue = (booking: { installments: { dueAt: Date | null }[] }) => ({
...ctx,
now: new Date(Math.max(...booking.installments.map((i) => i.dueAt?.getTime() ?? 0)) + 60_000),
describe("Folgeraten ueber Stripe Billing", () => {
function invoiceEvent(type: string, invoice: { id: string }, id = `evt_${crypto.randomUUID()}`) {
return { id, type, livemode: false, data: { object: { id: invoice.id, object: "invoice" } } } as unknown as Stripe.Event;
}
async function scheduleOf(bookingId: string) {
const booking = await db.booking.findUniqueOrThrow({ where: { id: bookingId } });
return gateway.schedules.get(booking.stripeScheduleId!)!;
}
async function installment(bookingId: string, seq: number) {
return db.installment.findFirstOrThrow({ where: { bookingId, seq } });
}
test("erste Rate bezahlt: Stripe-Ratenplan mit Betrag, Start, Abstand und Zahlungsmethode", async () => {
const booking = await paidBooking("THREE_INSTALLMENTS");
const [, second, third] = booking.installments;
const schedule = await scheduleOf(booking.id);
expect(schedule.params).toMatchObject({
amountCents: 70_000,
count: 2,
startAt: second!.dueAt,
intervalDays: Math.round((third!.dueAt!.getTime() - second!.dueAt!.getTime()) / 86_400_000),
paymentMethodId: booking.stripePaymentMethodId,
customerId: booking.stripeCustomerId,
});
expect(schedule.params.metadata).toMatchObject({ kind: "installment_plan", bookingId: booking.id });
// Erneut zugestelltes Checkout-Event legt keinen zweiten Plan an.
const session = await gateway.retrieveCheckoutSession(booking.stripeCheckoutSessionId!);
await handleStripeEvent(db, gateway, sessionEvent("checkout.session.async_payment_succeeded", session), ctx);
expect(gateway.schedules.size).toBe(1);
});
test("Einzug aller Raten, danach keine weitere Abbuchung und Zahlungsmethode getrennt", async () => {
test("Einmalzahlung legt keinen Ratenplan an", async () => {
await paidBooking("FULL");
expect(gateway.schedules.size).toBe(0);
});
test("Stripe zieht alle Raten ein; danach keine weitere Rechnung, Zahlungsmethode getrennt", async () => {
const booking = await paidBooking("THREE_INSTALLMENTS");
const runCtx = afterLastDue(booking);
const summary = await chargeDueInstallments(db, gateway, runCtx);
expect(summary.charged).toBe(2);
let after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
const schedule = await scheduleOf(booking.id);
for (let i = 0; i < 2; i++) {
const invoice = gateway.billNextInstallment(schedule.id);
await handleStripeEvent(db, gateway, invoiceEvent("invoice.paid", invoice), ctx);
}
let after = await db.booking.findUniqueOrThrow({ where: { id: booking.id }, include: { installments: { orderBy: { seq: "asc" } } } });
expect(after.paymentStatus).toBe("PAID");
expect(after.paidCents).toBe(220_000);
expect(after.installments.map((i) => i.status)).toEqual(["PAID", "PAID", "PAID"]);
expect(gateway.billedCents).toBe(140_000);
expect(() => gateway.billNextInstallment(schedule.id)).toThrow();
await detachFinishedPaymentMethods(db, gateway);
after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
after = await db.booking.findUniqueOrThrow({ where: { id: booking.id }, include: { installments: true } });
expect(after.paymentMethodDetachedAt).not.toBeNull();
expect(gateway.detached).toEqual([booking.stripePaymentMethodId]);
// Weitere Laeufe buchen nichts mehr ab.
await chargeDueInstallments(db, gateway, runCtx);
await chargeDueInstallments(db, gateway, { ...runCtx, now: new Date(runCtx.now.getTime() + 30 * 86_400_000) });
expect(gateway.offSessionCharges).toHaveLength(2);
expect(gateway.offSessionCharges.reduce((s, c) => s + c.amountCents, 0)).toBe(140_000);
});
test("nur faellige Raten werden eingezogen", async () => {
const booking = await paidBooking("THREE_INSTALLMENTS");
const between = { ...ctx, now: new Date(booking.installments[1]!.dueAt!.getTime() + 60_000) };
expect((await chargeDueInstallments(db, gateway, between)).charged).toBe(1);
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
expect(after.paymentStatus).toBe("PARTIALLY_PAID");
expect(after.paidCents).toBe(150_000);
});
test("Fehlschlag: Wiederholung nach Wartezeit, nach dem letzten Versuch manuelle Pruefung", async () => {
test("doppelt zugestelltes invoice.paid wird nur einmal verbucht", async () => {
const booking = await paidBooking("TWO_INSTALLMENTS");
gateway.offSessionOutcomes = ["failed", "failed", "failed"];
let runCtx = afterLastDue(booking);
for (let attempt = 1; attempt <= MAX_OFF_SESSION_ATTEMPTS; attempt++) {
await chargeDueInstallments(db, gateway, runCtx);
const inst = await db.installment.findFirstOrThrow({ where: { bookingId: booking.id, seq: 2 } });
expect(inst.status).toBe("FAILED");
expect(inst.attemptCount).toBe(attempt);
// Vor Ablauf der Wartezeit kein erneuter Versuch.
await chargeDueInstallments(db, gateway, runCtx);
expect((await db.installment.findFirstOrThrow({ where: { id: inst.id } })).attemptCount).toBe(attempt);
runCtx = { ...runCtx, now: new Date(runCtx.now.getTime() + 4 * 86_400_000) };
}
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
expect(after.status).toBe("NEEDS_REVIEW");
expect(after.paymentStatus).toBe("PARTIALLY_PAID");
expect(gateway.offSessionCharges).toHaveLength(0);
});
test("Bank verlangt Authentifizierung: Kundin bekommt Link und zahlt selbst", async () => {
const booking = await paidBooking("TWO_INSTALLMENTS");
gateway.offSessionOutcomes = ["requires_action"];
const runCtx = afterLastDue(booking);
await chargeDueInstallments(db, gateway, runCtx);
const inst = await db.installment.findFirstOrThrow({ where: { bookingId: booking.id, seq: 2 } });
expect(inst.status).toBe("REQUIRES_ACTION");
expect(await db.emailMessage.count({ where: { dedupeKey: { startsWith: `installment-action:${inst.id}` } } })).toBe(1);
const pay = await startInstallmentCheckout(db, gateway, { bookingId: booking.id, installmentId: inst.id }, runCtx);
expect(pay.ok).toBe(true);
// Waehrend die Session offen ist, zieht der Worker nicht parallel ein.
await chargeDueInstallments(db, gateway, { ...runCtx, now: new Date(runCtx.now.getTime() + 60_000) });
expect(gateway.offSessionCharges).toHaveLength(0);
const sessionId = (await db.installment.findUniqueOrThrow({ where: { id: inst.id } })).checkoutSessionId!;
const session = gateway.completeSession(sessionId);
await handleStripeEvent(db, gateway, sessionEvent("checkout.session.completed", session), runCtx);
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
expect(after.paymentStatus).toBe("PAID");
expect(after.paidCents).toBe(220_000);
});
test("Webhook und Worker melden denselben Erfolg: nur einmal verbucht", async () => {
const booking = await paidBooking("TWO_INSTALLMENTS");
const runCtx = afterLastDue(booking);
await chargeDueInstallments(db, gateway, runCtx);
const inst = await db.installment.findFirstOrThrow({ where: { bookingId: booking.id, seq: 2 } });
const pi = await gateway.retrievePaymentIntent(inst.stripePaymentIntentId!);
const event = {
id: "evt_pi_ok",
type: "payment_intent.succeeded",
livemode: false,
data: {
object: {
id: pi.id,
object: "payment_intent",
amount_received: pi.amountReceived,
latest_charge: pi.chargeId,
payment_method: pi.paymentMethodId,
customer: null,
metadata: pi.metadata,
},
},
} as unknown as Stripe.Event;
await handleStripeEvent(db, gateway, event, runCtx);
const invoice = gateway.billNextInstallment((await scheduleOf(booking.id)).id);
await handleStripeEvent(db, gateway, invoiceEvent("invoice.paid", invoice), ctx);
await handleStripeEvent(db, gateway, invoiceEvent("invoice.paid", invoice), ctx);
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
expect(after.paidCents).toBe(220_000);
expect(after.status).toBe("CONFIRMED");
});
test("verlorener Rechnungs-Webhook: Worker gleicht mit Stripe ab", async () => {
const booking = await paidBooking("TWO_INSTALLMENTS");
gateway.billNextInstallment((await scheduleOf(booking.id)).id);
const second = await installment(booking.id, 2);
// Vor Faelligkeit wird nicht gefragt.
expect((await reconcileInstallmentInvoices(db, gateway, ctx)).checked).toBe(0);
const later = { ...ctx, now: new Date(second.dueAt!.getTime() + 7 * 60 * 60_000) };
expect((await reconcileInstallmentInvoices(db, gateway, later)).settled).toBe(1);
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
expect(after.paymentStatus).toBe("PAID");
});
test("Fehlschlag: Stripe wiederholt, Kundin wird informiert, nach dem letzten Versuch Pruefung", async () => {
const booking = await paidBooking("TWO_INSTALLMENTS");
const failed = gateway.billNextInstallment((await scheduleOf(booking.id)).id, "failed");
await handleStripeEvent(db, gateway, invoiceEvent("invoice.payment_failed", failed), ctx);
await handleStripeEvent(db, gateway, invoiceEvent("invoice.payment_failed", failed), ctx);
let second = await installment(booking.id, 2);
expect(second.status).toBe("FAILED");
expect(second.stripeInvoiceId).toBe(failed.id);
expect(second.nextAttemptAt).not.toBeNull();
expect(await db.emailMessage.count({ where: { dedupeKey: { startsWith: `installment-action:${second.id}` } } })).toBe(1);
expect((await db.booking.findUniqueOrThrow({ where: { id: booking.id } })).status).toBe("CONFIRMED");
const final = gateway.retryInvoice(failed.id, "failed", { finalAttempt: true });
await handleStripeEvent(db, gateway, invoiceEvent("invoice.payment_failed", final), ctx);
second = await installment(booking.id, 2);
expect(second.attemptCount).toBe(2);
expect(second.nextAttemptAt).toBeNull();
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
expect(after.status).toBe("NEEDS_REVIEW");
expect(after.paymentStatus).toBe("PARTIALLY_PAID");
});
test("Bank verlangt Bestaetigung: Kundin zahlt ueber Stripes Rechnungsseite", async () => {
const booking = await paidBooking("TWO_INSTALLMENTS");
const open = gateway.billNextInstallment((await scheduleOf(booking.id)).id, "requires_action");
await handleStripeEvent(db, gateway, invoiceEvent("invoice.payment_action_required", open), ctx);
const second = await installment(booking.id, 2);
expect(second.status).toBe("REQUIRES_ACTION");
const pay = await installmentPaymentUrl(db, gateway, { bookingId: booking.id, installmentId: second.id });
expect(pay).toEqual({ ok: true, url: `https://invoice.stripe.test/${open.id}` });
const paid = gateway.retryInvoice(open.id, "paid");
await handleStripeEvent(db, gateway, invoiceEvent("invoice.paid", paid), ctx);
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
expect(after.paymentStatus).toBe("PAID");
expect(after.paidCents).toBe(220_000);
// Bezahlte Rate hat keinen Zahlungslink mehr.
expect((await installmentPaymentUrl(db, gateway, { bookingId: booking.id, installmentId: second.id })).ok).toBe(false);
});
test("Ratenplan-Anlage scheitert: Zahlung bleibt verbucht, Worker holt die Anlage nach", async () => {
gateway.failNextSchedule = true;
const booking = await paidBooking("TWO_INSTALLMENTS");
expect(booking.status).toBe("CONFIRMED");
expect(booking.stripeScheduleId).toBeNull();
expect((await createMissingSchedules(db, gateway, ctx)).created).toBe(1);
expect(gateway.schedules.size).toBe(1);
expect((await createMissingSchedules(db, gateway, ctx)).created).toBe(0);
});
test("Erstattung aktualisiert Zahlungsstatus absolut (ungeordnete Events harmlos)", async () => {
const booking = await paidBooking("FULL");
const pi = booking.installments[0]!.stripePaymentIntentId!;