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
+2
-1
@@ -56,7 +56,8 @@ Der gehostete Checkout bildet den Ablauf vollständig ab: Betrag, Ablaufzeit, da
|
||||
- Storno beendet den Zeitplan sofort und storniert offene Rechnungen (`void`). Scheitert das bei Stripe, holt der Worker es nach; die Zahlungsmethode wird erst danach getrennt.
|
||||
- Fehlschlag: Stripe wiederholt nach den Einstellungen unter „Revenue recovery“ (Smart Retries). Die Rate steht auf `FAILED` mit nächstem Versuch, die Kundin bekommt eine Mail mit Zahlungslink. Gibt Stripe auf, wird die Buchung „Prüfung nötig“.
|
||||
- Verlangt die Bank eine Bestätigung, geht die Rate auf `REQUIRES_ACTION`. Die Kundin zahlt über Stripes Rechnungsseite (`hosted_invoice_url`), auch mit einer anderen Zahlungsmethode. Eine eigene Checkout-Session gibt es bewusst nicht, sonst würde Stripe parallel weiter einziehen.
|
||||
- Idempotenz: Der Zeitplan wird mit Schlüssel `schedule:<buchung>` angelegt. Doppelte oder ungeordnete Events werden über die Rechnungs-ID und den PaymentIntent nur einmal verbucht.
|
||||
- „Prüfung nötig“ hält den Plan an: Gerät eine Buchung in Prüfung (z. B. Rückbuchung, Doppelzahlung), wird der Stripe-Plan beendet, offene Rechnungen werden storniert und unbezahlte Folgeraten gehen zurück auf `PENDING`. Bestätigt ein Mensch die Buchung, legt der Worker einen neuen Plan für die offenen Raten an (Start an der Fälligkeit oder, falls vorbei, am nächsten 9-Uhr-Termin; letzte Rate bleibt am Stichtag). So zieht Stripe wie früher der Worker nur bei bestätigten Buchungen ein.
|
||||
- Idempotenz: Vor dem Anlegen sucht die Anwendung bei Stripe nach einem laufenden Plan dieser Buchung (Metadaten `bookingId`) und übernimmt ihn, auch wenn das Speichern der ID früher scheiterte. Angelegt wird mit Schlüssel `schedule:<buchung>:<generation>:<start>`; die Generation steigt mit jedem Anhalten, sonst lieferte Stripe binnen 24 Stunden den alten, beendeten Plan zurück. Doppelte oder ungeordnete Events werden über die Rechnungs-ID und den PaymentIntent nur einmal verbucht.
|
||||
- Verlorene Webhooks: Der Worker legt fehlende Zeitpläne an und fragt bei Raten, die seit 6 Stunden fällig und nicht verbucht sind, die Rechnungen des Zeitplans ab.
|
||||
- Zustimmung: Vor der Zahlung wird ausdrücklich dem Einzug zugestimmt, mit Zeitstempel `installmentMandateAt`.
|
||||
- Keine Gleichsetzung mit Klarna: Es ist die eigene Ratenvereinbarung des Veranstalters, das Ausfallrisiko bleibt beim Veranstalter. Im Livemodus ist sie erst nach ausdrücklicher Freigabe verfügbar.
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@ Hinweis: Läuft der Testserver nicht unter der Adresse aus `APP_URL`, lehnt der
|
||||
- Der Standalone-Build läuft mit Datenbank. Worker-Lauf und `admin:create` funktionieren, eine doppelte Anlage wird abgewiesen.
|
||||
- Build, Typprüfung und ESLint laufen ohne Fehler, Kustomize rendert 17 Ressourcen.
|
||||
|
||||
Lauf 05.10.2026 nach Umstellung der Folgeraten auf Stripe Billing: 85/85 Unit- und Integrationstests, Typprüfung und ESLint ohne Fehler. Zusätzlich `StripeGateway` gegen die echte Stripe-Test-API mit Testuhr geprüft: Zeitplan mit zwei Folgeraten erzeugt genau zwei bezahlte Rechnungen (je 700 €, Metadaten `bookingId`/`kind`, PaymentIntent mit Charge) und endet danach (`completed`); abgelehnte Karte ergibt offene Rechnung mit geplantem Wiederholungsversuch und Zahlungslink; Storno setzt den Zeitplan auf `canceled` und die offene Rechnung auf `void`, auch wiederholt und vor dem Start.
|
||||
Lauf 06.10.2026 nach Umstellung der Folgeraten auf Stripe Billing (inkl. Anhalten bei Prüfung und Schutz vor doppeltem Plan): 88/88 Unit- und Integrationstests, Typprüfung und ESLint ohne Fehler. Zusätzlich `StripeGateway` gegen die echte Stripe-Test-API mit Testuhr geprüft: Zeitplan mit zwei Folgeraten erzeugt genau zwei bezahlte Rechnungen (je 700 €, Metadaten `bookingId`/`kind`, PaymentIntent mit Charge) und endet danach (`completed`); abgelehnte Karte ergibt offene Rechnung mit geplantem Wiederholungsversuch und Zahlungslink; Storno setzt den Zeitplan auf `canceled` und die offene Rechnung auf `void`, auch wiederholt und vor dem Start.
|
||||
|
||||
## Nicht ausgeführt / Einschränkungen
|
||||
|
||||
|
||||
@@ -15,7 +15,9 @@ import { audit, WORKER, type Actor } from "./audit";
|
||||
import type { Db } from "./db";
|
||||
import { applyInstallmentInvoicePaid, flagBookingForReview, type EventContext } from "./payment-events";
|
||||
import type { InvoiceInfo, PaymentGateway } from "./payment-gateway";
|
||||
import { berlinDateString, daysBetween } from "./time";
|
||||
import { lockBooking } from "./booking-service";
|
||||
import { CHARGE_HOUR_BERLIN } from "./payment-plan";
|
||||
import { addDays, berlinDateString, berlinStartOfDay, daysBetween } from "./time";
|
||||
|
||||
export const INSTALLMENT_PLAN_KIND = "installment_plan";
|
||||
/** Stripe verlangt einen Start in der Zukunft. */
|
||||
@@ -49,22 +51,40 @@ export async function ensureInstallmentSchedule(
|
||||
}
|
||||
|
||||
const first = later[0]!;
|
||||
const startAt = scheduleStart(first.dueAt!, ctx.now);
|
||||
const intervalDays =
|
||||
later.length === 1 ? 1 : daysBetween(berlinDateString(first.dueAt!), berlinDateString(later[1]!.dueAt!));
|
||||
const startAt = new Date(Math.max(first.dueAt!.getTime(), ctx.now.getTime() + MIN_START_LEAD_MS));
|
||||
const schedule = await gateway.createInstallmentSchedule(
|
||||
{
|
||||
customerId: booking.stripeCustomerId!,
|
||||
paymentMethodId: booking.stripePaymentMethodId!,
|
||||
startAt,
|
||||
amountCents: first.amountCents,
|
||||
intervalDays,
|
||||
count: later.length,
|
||||
description: `ARTISTA MODELCAMP 2027 · ${booking.reference} · Folgerate`,
|
||||
metadata: { kind: INSTALLMENT_PLAN_KIND, bookingId: booking.id, reference: booking.reference },
|
||||
},
|
||||
`schedule:${booking.id}`,
|
||||
);
|
||||
later.length === 1 ? 1 : daysBetween(berlinDateString(startAt), berlinDateString(later.at(-1)!.dueAt!));
|
||||
if (intervalDays < 1) {
|
||||
const reason = "Bis zur letzten Rate bleibt kein Abstand mehr – Ratenplan bei Stripe nicht angelegt.";
|
||||
await db.$transaction((tx) => flagBookingForReview(tx, booking.id, reason, { ...ctx, actor }));
|
||||
return "needs_review";
|
||||
}
|
||||
|
||||
// Schutz gegen einen zweiten Plan, auch wenn das Speichern der ID frueher
|
||||
// scheiterte und der Idempotenzschluessel (24 h) abgelaufen ist.
|
||||
const existing = await gateway.findInstallmentSchedule(booking.stripeCustomerId!, booking.id);
|
||||
// Nach einer Pruefung entsteht ein neuer Plan; ohne Generation lieferte
|
||||
// Stripe zum alten Schluessel den bereits beendeten Plan zurueck.
|
||||
const generation = await db.auditLog.count({
|
||||
where: { entityType: "Booking", entityId: booking.id, action: "installments.schedule_held_for_review" },
|
||||
});
|
||||
const schedule =
|
||||
existing ??
|
||||
(await gateway.createInstallmentSchedule(
|
||||
{
|
||||
customerId: booking.stripeCustomerId!,
|
||||
paymentMethodId: booking.stripePaymentMethodId!,
|
||||
startAt,
|
||||
amountCents: first.amountCents,
|
||||
intervalDays,
|
||||
count: later.length,
|
||||
description: `ARTISTA MODELCAMP 2027 · ${booking.reference} · Folgerate`,
|
||||
metadata: { kind: INSTALLMENT_PLAN_KIND, bookingId: booking.id, reference: booking.reference },
|
||||
},
|
||||
// startAt ist fuer alle gleichzeitigen Aufrufer gleich (Faelligkeit
|
||||
// oder naechster 9-Uhr-Termin), der Schluessel also auch.
|
||||
`schedule:${booking.id}:${generation}:${startAt.getTime()}`,
|
||||
));
|
||||
|
||||
const saved = await db.booking.updateMany({
|
||||
where: { id: booking.id, stripeScheduleId: null },
|
||||
@@ -84,6 +104,52 @@ export async function ensureInstallmentSchedule(
|
||||
return "created";
|
||||
}
|
||||
|
||||
/**
|
||||
* Start des Plans: die Faelligkeit der naechsten Rate. Ist sie schon vorbei
|
||||
* (Buchung erst nach Pruefung bestaetigt), der naechste 9-Uhr-Termin in
|
||||
* Berlin — deterministisch, damit Wiederholungen denselben Plan meinen.
|
||||
*/
|
||||
function scheduleStart(dueAt: Date, now: Date): Date {
|
||||
const earliest = now.getTime() + MIN_START_LEAD_MS;
|
||||
if (dueAt.getTime() >= earliest) return dueAt;
|
||||
const today = berlinDateString(now);
|
||||
const todayAtNine = berlinStartOfDay(today, CHARGE_HOUR_BERLIN);
|
||||
return todayAtNine.getTime() >= earliest ? todayAtNine : berlinStartOfDay(addDays(today, 1), CHARGE_HOUR_BERLIN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Haelt den Stripe-Plan an, solange eine Buchung in Pruefung ist (z. B.
|
||||
* Rueckbuchung, Doppelzahlung): Plan beenden, offene Rechnungen stornieren,
|
||||
* unbezahlte Folgeraten wieder auf PENDING. Bestaetigt ein Mensch die
|
||||
* Buchung, legt createMissingSchedules einen neuen Plan fuer die offenen
|
||||
* Raten an; storniert er, bleibt es dabei.
|
||||
*/
|
||||
export async function holdSchedulesInReview(db: Db, gateway: PaymentGateway, bookingIds?: string[], actor: Actor = WORKER) {
|
||||
const candidates = await db.booking.findMany({
|
||||
where: {
|
||||
status: "NEEDS_REVIEW",
|
||||
stripeScheduleId: { not: null },
|
||||
livemode: gateway.mode === "live",
|
||||
...(bookingIds ? { id: { in: bookingIds } } : {}),
|
||||
},
|
||||
select: { id: true, stripeScheduleId: true },
|
||||
take: 50,
|
||||
});
|
||||
for (const booking of candidates) {
|
||||
await gateway.cancelInstallmentSchedule(booking.stripeScheduleId!);
|
||||
await db.$transaction(async (tx) => {
|
||||
await lockBooking(tx, booking.id);
|
||||
await tx.installment.updateMany({
|
||||
where: { bookingId: booking.id, seq: { gt: 1 }, status: { in: ["PENDING", "FAILED", "REQUIRES_ACTION"] } },
|
||||
data: { status: "PENDING", stripeInvoiceId: null, nextAttemptAt: null },
|
||||
});
|
||||
await tx.booking.update({ where: { id: booking.id }, data: { stripeScheduleId: null } });
|
||||
await audit(tx, actor, "installments.schedule_held_for_review", "Booking", booking.id, { scheduleId: booking.stripeScheduleId });
|
||||
});
|
||||
}
|
||||
return { held: candidates.length };
|
||||
}
|
||||
|
||||
function schedulePlanProblem(
|
||||
booking: { stripeCustomerId: string | null; stripePaymentMethodId: string | null; paymentMethodDetachedAt: Date | null },
|
||||
later: { amountCents: number; dueAt: Date | null }[],
|
||||
|
||||
@@ -87,6 +87,8 @@ export interface PaymentGateway {
|
||||
expireCheckoutSession(id: string): Promise<void>;
|
||||
retrievePaymentIntent(id: string): Promise<PaymentIntentInfo>;
|
||||
createInstallmentSchedule(params: CreateInstallmentScheduleParams, idempotencyKey: string): Promise<{ id: string }>;
|
||||
/** Laufender oder noch nicht gestarteter Plan dieser Buchung bei Stripe, falls vorhanden. */
|
||||
findInstallmentSchedule(customerId: string, bookingId: string): Promise<{ id: string } | null>;
|
||||
/** Beendet den Zeitplan sofort und storniert offene Rechnungen: danach wird nichts mehr eingezogen. */
|
||||
cancelInstallmentSchedule(id: string): Promise<void>;
|
||||
retrieveInvoice(id: string): Promise<InvoiceInfo>;
|
||||
@@ -236,6 +238,14 @@ export class StripeGateway implements PaymentGateway {
|
||||
return { id: schedule.id };
|
||||
}
|
||||
|
||||
async findInstallmentSchedule(customerId: string, bookingId: string): Promise<{ id: string } | null> {
|
||||
for await (const schedule of this.stripe.subscriptionSchedules.list({ customer: customerId, limit: 100 })) {
|
||||
const live = schedule.status === "not_started" || schedule.status === "active";
|
||||
if (live && schedule.metadata?.bookingId === bookingId) return { id: schedule.id };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Feste Produkt-ID: beim ersten Bedarf angelegt, danach wiederverwendet. */
|
||||
private async installmentProductId(): Promise<string> {
|
||||
try {
|
||||
|
||||
+13
-3
@@ -13,7 +13,7 @@ import type Stripe from "stripe";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { STRIPE_ACTOR, type Actor } from "./audit";
|
||||
import type { Db } from "./db";
|
||||
import { ensureInstallmentSchedule, INSTALLMENT_PLAN_KIND, settleInstallmentInvoice } from "./installment-billing";
|
||||
import { ensureInstallmentSchedule, holdSchedulesInReview, INSTALLMENT_PLAN_KIND, settleInstallmentInvoice } from "./installment-billing";
|
||||
import { normalizePaymentStatus, type CheckoutSessionInfo, type PaymentGateway } from "./payment-gateway";
|
||||
import {
|
||||
applyCheckoutExpired,
|
||||
@@ -170,8 +170,10 @@ async function dispatch(db: Db, gateway: PaymentGateway, event: Stripe.Event, ct
|
||||
}
|
||||
case "charge.dispute.created": {
|
||||
const piId = idOf(event.data.object.payment_intent);
|
||||
if (piId) await db.$transaction((tx) => applyDispute(tx, piId, eventCtx(ctx)));
|
||||
return [];
|
||||
if (!piId) return [];
|
||||
await db.$transaction((tx) => applyDispute(tx, piId, eventCtx(ctx)));
|
||||
const disputed = await db.installment.findUnique({ where: { stripePaymentIntentId: piId }, select: { bookingId: true } });
|
||||
return disputed ? [disputed.bookingId] : [];
|
||||
}
|
||||
default:
|
||||
return [];
|
||||
@@ -218,6 +220,14 @@ export async function handleStripeEvent(
|
||||
try {
|
||||
const bookingIds = await dispatch(db, gateway, event, ctx);
|
||||
await db.stripeEvent.update({ where: { id: event.id }, data: { status: "PROCESSED", processedAt: new Date(), error: null } });
|
||||
// Geriet eine Buchung in Pruefung (Rueckbuchung, Doppelzahlung ...),
|
||||
// zieht Stripe bis zur Entscheidung nichts weiter ein. Scheitert das
|
||||
// hier, erledigt es der Worker.
|
||||
if (bookingIds.length > 0) {
|
||||
await holdSchedulesInReview(db, gateway, bookingIds, STRIPE_ACTOR).catch((error: unknown) => {
|
||||
console.error(`[webhook] Ratenplan nicht angehalten: ${error instanceof Error ? error.message : "unbekannt"}`);
|
||||
});
|
||||
}
|
||||
return { outcome: "processed", bookingIds };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message.slice(0, 500) : "unbekannter Fehler";
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { audit, WORKER } from "./audit";
|
||||
import type { Db } from "./db";
|
||||
import { flushEmails } from "./email";
|
||||
import { ensureInstallmentSchedule, settleInstallmentInvoice, stopCanceledInstallmentPlans } from "./installment-billing";
|
||||
import { ensureInstallmentSchedule, holdSchedulesInReview, settleInstallmentInvoice, stopCanceledInstallmentPlans } from "./installment-billing";
|
||||
import { applyCheckoutExpired, applyFirstPaymentFailed, type EventContext } from "./payment-events";
|
||||
import type { PaymentGateway } from "./payment-gateway";
|
||||
import { purgeRateLimits } from "./rate-limit";
|
||||
@@ -181,11 +181,12 @@ export async function detachFinishedPaymentMethods(db: Db, gateway: PaymentGatew
|
||||
export async function runWorker(db: Db, gateway: PaymentGateway, ctx: HandlerContext) {
|
||||
const reservations = await releaseExpiredReservations(db, gateway, ctx);
|
||||
const reconcile = await reconcileStuck(db, gateway, ctx);
|
||||
const held = await holdSchedulesInReview(db, gateway);
|
||||
const schedules = await createMissingSchedules(db, gateway, ctx);
|
||||
const installments = await reconcileInstallmentInvoices(db, gateway, ctx);
|
||||
const stopped = await stopCanceledInstallmentPlans(db, gateway);
|
||||
const detach = await detachFinishedPaymentMethods(db, gateway);
|
||||
const emails = await flushEmails(db, { limit: 200 });
|
||||
const purged = await purgeRateLimits(db);
|
||||
return { reservations, reconcile, schedules, installments, stopped, detach, emails, purgedRateLimits: purged };
|
||||
return { reservations, reconcile, held, schedules, installments, stopped, detach, emails, purgedRateLimits: purged };
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -115,6 +115,13 @@ export class FakeGateway implements PaymentGateway {
|
||||
});
|
||||
}
|
||||
|
||||
async findInstallmentSchedule(customerId: string, bookingId: string): Promise<{ id: string } | null> {
|
||||
const found = [...this.schedules.values()].find(
|
||||
(s) => s.status === "active" && s.params.customerId === customerId && s.params.metadata.bookingId === bookingId,
|
||||
);
|
||||
return found ? { id: found.id } : null;
|
||||
}
|
||||
|
||||
async cancelInstallmentSchedule(id: string): Promise<void> {
|
||||
const schedule = this.schedules.get(id);
|
||||
if (!schedule) throw new Error(`Unbekannter Zeitplan ${id}`);
|
||||
|
||||
Reference in new issue
Block a user