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:
1 parent
bcf0f5c937
commit
c1ad9ad2ca
18 files changed
+800
-555
No files matched your search
@@ -3,7 +3,8 @@ import { beforeEach, describe, expect, test } from "vitest";
|
||||
import { cancelBooking, refundInstallment, saveVoucher, voucherInputSchema } from "@/lib/admin-service";
|
||||
import { startCheckout } from "@/lib/checkout";
|
||||
import { handleStripeEvent } from "@/lib/webhook";
|
||||
import { chargeDueInstallments } from "@/lib/worker-jobs";
|
||||
import { stopCanceledInstallmentPlans } from "@/lib/installment-billing";
|
||||
import { detachFinishedPaymentMethods } from "@/lib/worker-jobs";
|
||||
import { bookingRequest, db, EARLY_BIRD_NOW, resetDb, SECRET, setupCamp } from "./support/db";
|
||||
import { FakeGateway } from "./support/fake-gateway";
|
||||
|
||||
@@ -33,8 +34,10 @@ async function paidInstallmentBooking() {
|
||||
}
|
||||
|
||||
describe("Stornierung", () => {
|
||||
test("stoppt alle weiteren Abbuchungen, gibt Platz frei, trennt die Zahlungsmethode", async () => {
|
||||
test("beendet den Stripe-Ratenplan, storniert offene Rechnungen, gibt Platz frei, trennt die Zahlungsmethode", async () => {
|
||||
const booking = await paidInstallmentBooking();
|
||||
const schedule = gateway.schedules.get(booking.stripeScheduleId!)!;
|
||||
const open = gateway.billNextInstallment(schedule.id, "failed");
|
||||
const result = await cancelBooking(db, gateway, { bookingId: booking.id, reason: "Test" }, admin);
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
@@ -42,14 +45,34 @@ describe("Stornierung", () => {
|
||||
expect(after.status).toBe("CANCELED");
|
||||
expect(after.reservation!.status).toBe("RELEASED");
|
||||
expect(after.installments.filter((i) => i.seq > 1).every((i) => i.status === "CANCELED")).toBe(true);
|
||||
expect(schedule.status).toBe("canceled");
|
||||
expect((await gateway.retrieveInvoice(open.id)).status).toBe("void");
|
||||
expect(() => gateway.billNextInstallment(schedule.id)).toThrow();
|
||||
expect(gateway.detached).toEqual([booking.stripePaymentMethodId]);
|
||||
|
||||
const later = { ...ctx, now: new Date("2027-06-01T12:00:00Z") };
|
||||
await chargeDueInstallments(db, gateway, later);
|
||||
expect(gateway.offSessionCharges).toHaveLength(0);
|
||||
expect(await db.auditLog.count({ where: { action: "booking.canceled", actorId: "admin-test" } })).toBe(1);
|
||||
});
|
||||
|
||||
test("Stripe nicht erreichbar: Storno bleibt, der Worker beendet den Ratenplan spaeter", async () => {
|
||||
const booking = await paidInstallmentBooking();
|
||||
const schedule = gateway.schedules.get(booking.stripeScheduleId!)!;
|
||||
const cancel = gateway.cancelInstallmentSchedule.bind(gateway);
|
||||
gateway.cancelInstallmentSchedule = async () => {
|
||||
throw new Error("Stripe nicht erreichbar (simuliert)");
|
||||
};
|
||||
const result = await cancelBooking(db, gateway, { bookingId: booking.id, reason: "Test" }, admin);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(schedule.status).toBe("active");
|
||||
// Der allgemeine Lauf trennt die Zahlungsmethode nicht, solange der Plan laeuft.
|
||||
await detachFinishedPaymentMethods(db, gateway);
|
||||
expect(gateway.detached).toEqual([]);
|
||||
|
||||
gateway.cancelInstallmentSchedule = cancel;
|
||||
expect((await stopCanceledInstallmentPlans(db, gateway)).stopped).toBe(1);
|
||||
expect(schedule.status).toBe("canceled");
|
||||
expect(gateway.detached).toEqual([booking.stripePaymentMethodId]);
|
||||
expect((await stopCanceledInstallmentPlans(db, gateway)).stopped).toBe(0);
|
||||
});
|
||||
|
||||
test("verlangt einen Grund", async () => {
|
||||
const booking = await paidInstallmentBooking();
|
||||
expect((await cancelBooking(db, gateway, { bookingId: booking.id, reason: " " }, admin)).ok).toBe(false);
|
||||
|
||||
Reference in new issue
Block a user