fix: Stripe-Ratenplan bei Prüfung anhalten, doppelte Pläne verhindern
Befunde aus dem Sicherheits-Review des vorherigen Commits: - Gerät eine Buchung in "Prüfung nötig" (Rückbuchung, Doppelzahlung ...), wird der Stripe-Plan beendet und offene Rechnungen storniert. Nach Bestätigung legt der Worker einen neuen Plan für die offenen Raten an. Vorher zog Stripe auch bei Buchungen in Prüfung weiter ein. - Vor dem Anlegen wird bei Stripe nach einem laufenden Plan der Buchung gesucht; der Idempotenzschlüssel enthält Generation und Start. Verhindert einen zweiten Plan nach verlorener ID und die Rückgabe des alten, beendeten Plans nach einer Prüfung. - Start bei bereits verstrichener Fälligkeit deterministisch am nächsten 9-Uhr-Termin, letzte Rate bleibt am Stichtag. - Rückbuchungs-Event meldet die betroffene Buchung zurück. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
c1ad9ad2ca
commit
1ce7947d93
8 files changed
+168
-23
No files matched your search
@@ -6,6 +6,7 @@ import { verifyStripeWebhook } from "@/lib/payment-gateway";
|
||||
import { handleStripeEvent, settleCheckoutSession } from "@/lib/webhook";
|
||||
import { createMissingSchedules, detachFinishedPaymentMethods, reconcileInstallmentInvoices, releaseExpiredReservations } from "@/lib/worker-jobs";
|
||||
import { STRIPE_ACTOR } from "@/lib/audit";
|
||||
import { confirmReviewedBooking } from "@/lib/admin-service";
|
||||
import type { PlanType } from "@/lib/payment-plan";
|
||||
import { bookingRequest, db, EARLY_BIRD_NOW, resetDb, SECRET, setupCamp } from "./support/db";
|
||||
import { FakeGateway } from "./support/fake-gateway";
|
||||
@@ -334,6 +335,55 @@ describe("Folgeraten ueber Stripe Billing", () => {
|
||||
expect((await installmentPaymentUrl(db, gateway, { bookingId: booking.id, installmentId: second.id })).ok).toBe(false);
|
||||
});
|
||||
|
||||
test("Rueckbuchung: Stripe zieht bis zur Entscheidung nichts ein, nach Bestaetigung neuer Plan", async () => {
|
||||
const booking = await paidBooking("THREE_INSTALLMENTS");
|
||||
const first = await scheduleOf(booking.id);
|
||||
const dispute = {
|
||||
id: `evt_${crypto.randomUUID()}`,
|
||||
type: "charge.dispute.created",
|
||||
livemode: false,
|
||||
data: { object: { id: "dp_1", object: "dispute", payment_intent: booking.installments[0]!.stripePaymentIntentId } },
|
||||
} as unknown as Stripe.Event;
|
||||
await handleStripeEvent(db, gateway, dispute, ctx);
|
||||
|
||||
let after = await db.booking.findUniqueOrThrow({ where: { id: booking.id }, include: { installments: { orderBy: { seq: "asc" } } } });
|
||||
expect(after.status).toBe("NEEDS_REVIEW");
|
||||
expect(after.stripeScheduleId).toBeNull();
|
||||
expect(first.status).toBe("canceled");
|
||||
expect(after.installments.map((i) => i.status)).toEqual(["PAID", "PENDING", "PENDING"]);
|
||||
// Waehrend der Pruefung legt der Worker keinen neuen Plan an.
|
||||
expect((await createMissingSchedules(db, gateway, ctx)).created).toBe(0);
|
||||
|
||||
expect((await confirmReviewedBooking(db, booking.id, { type: "ADMIN", id: "admin-test" }, ctx.now)).ok).toBe(true);
|
||||
expect((await createMissingSchedules(db, gateway, ctx)).created).toBe(1);
|
||||
after = await db.booking.findUniqueOrThrow({ where: { id: booking.id }, include: { installments: true } });
|
||||
const second = gateway.schedules.get(after.stripeScheduleId!)!;
|
||||
expect(second.id).not.toBe(first.id);
|
||||
expect(second.params).toMatchObject({ amountCents: 70_000, count: 2 });
|
||||
});
|
||||
|
||||
test("ID des Plans ging verloren: kein zweiter Plan bei Stripe", async () => {
|
||||
const booking = await paidBooking("TWO_INSTALLMENTS");
|
||||
await db.booking.update({ where: { id: booking.id }, data: { stripeScheduleId: null } });
|
||||
expect((await createMissingSchedules(db, gateway, ctx)).created).toBe(1);
|
||||
expect(gateway.schedules.size).toBe(1);
|
||||
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
|
||||
expect(after.stripeScheduleId).toBe([...gateway.schedules.keys()][0]);
|
||||
});
|
||||
|
||||
test("Faelligkeit schon vorbei: Start am naechsten 9-Uhr-Termin, letzte Rate bleibt am Stichtag", async () => {
|
||||
gateway.failNextSchedule = true;
|
||||
const booking = await paidBooking("THREE_INSTALLMENTS");
|
||||
const [, second, third] = booking.installments;
|
||||
const late = { ...ctx, now: new Date(second!.dueAt!.getTime() + 3 * 86_400_000 + 2 * 60 * 60_000) };
|
||||
expect((await createMissingSchedules(db, gateway, late)).created).toBe(1);
|
||||
const schedule = [...gateway.schedules.values()][0]!;
|
||||
expect(schedule.params.startAt.getTime()).toBeGreaterThan(late.now.getTime());
|
||||
expect(schedule.params.startAt.getTime() - late.now.getTime()).toBeLessThanOrEqual(86_400_000);
|
||||
const end = schedule.params.startAt.getTime() + schedule.params.intervalDays * 86_400_000;
|
||||
expect(Math.abs(end - third!.dueAt!.getTime())).toBeLessThanOrEqual(60 * 60_000);
|
||||
});
|
||||
|
||||
test("Ratenplan-Anlage scheitert: Zahlung bleibt verbucht, Worker holt die Anlage nach", async () => {
|
||||
gateway.failNextSchedule = true;
|
||||
const booking = await paidBooking("TWO_INSTALLMENTS");
|
||||
|
||||
Reference in new issue
Block a user