Files
Nick SzaunigandClaude Opus 5.5 1ce7947d93 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]>
2026-10-06 06:28:41 +02:00

213 lines
8.3 KiB
TypeScript

import type {
CheckoutSessionInfo,
CreateCheckoutParams,
CreateInstallmentScheduleParams,
InvoiceInfo,
PaymentGateway,
PaymentIntentInfo,
} from "@/lib/payment-gateway";
import type { StripeMode } from "@/lib/sales-gate";
type StoredSession = CheckoutSessionInfo & { params: CreateCheckoutParams };
type StoredSchedule = { id: string; status: "active" | "canceled"; params: CreateInstallmentScheduleParams; invoiceIds: string[] };
/**
* Stripe-Attrappe im Speicher. Bildet die fuer die Geschaeftslogik
* relevanten Eigenschaften nach: Idempotenzschluessel, Session-Zustaende,
* PaymentIntents und die Rechnungen eines Stripe-Billing-Ratenplans.
*/
export class FakeGateway implements PaymentGateway {
private seq = 0;
private readonly byKey = new Map<string, unknown>();
readonly sessions = new Map<string, StoredSession>();
readonly paymentIntents = new Map<string, PaymentIntentInfo>();
readonly schedules = new Map<string, StoredSchedule>();
readonly invoices = new Map<string, InvoiceInfo>();
readonly detached: string[] = [];
readonly refunds: { id: string; paymentIntentId: string; amountCents: number }[] = [];
failNextCheckout = false;
failNextSchedule = false;
constructor(readonly mode: StripeMode = "test") {}
private id(prefix: string): string {
this.seq++;
return `${prefix}_test_${this.seq}`;
}
private once<T>(key: string, create: () => T): T {
if (!this.byKey.has(key)) this.byKey.set(key, create());
return this.byKey.get(key) as T;
}
async createCustomer(_params: { email: string; name: string }, key: string) {
return this.once(`cus:${key}`, () => ({ id: this.id("cus") }));
}
async createCheckoutSession(params: CreateCheckoutParams, key: string) {
if (this.failNextCheckout) {
this.failNextCheckout = false;
throw new Error("Stripe nicht erreichbar (simuliert)");
}
return this.once(`cs:${key}`, () => {
const id = this.id("cs");
const url = `https://checkout.stripe.test/${id}`;
this.sessions.set(id, {
id,
status: "open",
paymentStatus: "unpaid",
paymentIntentId: null,
customerId: params.customerId,
url,
metadata: params.metadata,
params,
});
return { id, url };
});
}
async retrieveCheckoutSession(id: string): Promise<CheckoutSessionInfo> {
const s = this.sessions.get(id);
if (!s) throw new Error(`Unbekannte Session ${id}`);
return { ...s };
}
async expireCheckoutSession(id: string): Promise<void> {
const s = this.sessions.get(id);
if (s && s.status === "open") s.status = "expired";
}
/** Simuliert die Zahlung durch die Kundin in Stripe Checkout. */
completeSession(id: string, opts: { paid?: boolean; paymentMethodId?: string; amountCents?: number } = {}): CheckoutSessionInfo {
const s = this.sessions.get(id);
if (!s) throw new Error(`Unbekannte Session ${id}`);
const piId = this.id("pi");
const paid = opts.paid ?? true;
this.paymentIntents.set(piId, {
id: piId,
status: paid ? "succeeded" : "processing",
amountReceived: paid ? (opts.amountCents ?? s.params.amountCents) : 0,
paymentMethodId: opts.paymentMethodId ?? this.id("pm"),
chargeId: paid ? this.id("ch") : null,
metadata: s.metadata,
});
s.status = "complete";
s.paymentStatus = paid ? "paid" : "unpaid";
s.paymentIntentId = piId;
return { ...s };
}
async retrievePaymentIntent(id: string): Promise<PaymentIntentInfo> {
const pi = this.paymentIntents.get(id);
if (!pi) throw new Error(`Unbekannter PaymentIntent ${id}`);
return { ...pi };
}
async createInstallmentSchedule(params: CreateInstallmentScheduleParams, key: string) {
if (this.failNextSchedule) {
this.failNextSchedule = false;
throw new Error("Stripe nicht erreichbar (simuliert)");
}
return this.once(`sub_sched:${key}`, () => {
const id = this.id("sub_sched");
this.schedules.set(id, { id, status: "active", params, invoiceIds: [] });
return { id };
});
}
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}`);
schedule.status = "canceled";
for (const invoiceId of schedule.invoiceIds) {
const invoice = this.invoices.get(invoiceId)!;
if (invoice.status === "open") this.invoices.set(invoiceId, { ...invoice, status: "void", hostedInvoiceUrl: null });
}
}
async retrieveInvoice(id: string): Promise<InvoiceInfo> {
const invoice = this.invoices.get(id);
if (!invoice) throw new Error(`Unbekannte Rechnung ${id}`);
return { ...invoice };
}
async listScheduleInvoices(scheduleId: string): Promise<InvoiceInfo[]> {
return (this.schedules.get(scheduleId)?.invoiceIds ?? []).map((id) => ({ ...this.invoices.get(id)! }));
}
/**
* Simuliert den naechsten Einzug durch Stripe Billing: erzeugt die
* naechste Rechnung des Zeitplans, bezahlt oder offen. Ein aktiver Zeitplan
* erzeugt nie mehr Rechnungen als Folgeraten.
*/
billNextInstallment(scheduleId: string, outcome: "paid" | "failed" | "requires_action" = "paid", opts: { finalAttempt?: boolean } = {}): InvoiceInfo {
const schedule = this.schedules.get(scheduleId);
if (!schedule || schedule.status !== "active") throw new Error(`Zeitplan ${scheduleId} ist nicht aktiv`);
if (schedule.invoiceIds.length >= schedule.params.count) throw new Error("Zeitplan hat bereits alle Raten abgerechnet");
const id = this.id("in");
schedule.invoiceIds.push(id);
const invoice = this.invoiceResult(id, schedule, outcome, 1, opts.finalAttempt ?? false);
this.invoices.set(id, invoice);
return { ...invoice };
}
/** Simuliert einen weiteren Versuch (Smart Retry oder Zahlung ueber die Rechnungsseite). */
retryInvoice(invoiceId: string, outcome: "paid" | "failed" | "requires_action", opts: { finalAttempt?: boolean } = {}): InvoiceInfo {
const current = this.invoices.get(invoiceId)!;
if (current.status !== "open") throw new Error(`Rechnung ${invoiceId} ist nicht offen`);
const schedule = [...this.schedules.values()].find((s) => s.invoiceIds.includes(invoiceId))!;
const invoice = this.invoiceResult(invoiceId, schedule, outcome, current.attemptCount + 1, opts.finalAttempt ?? false);
this.invoices.set(invoiceId, invoice);
return { ...invoice };
}
private invoiceResult(id: string, schedule: StoredSchedule, outcome: "paid" | "failed" | "requires_action", attempt: number, finalAttempt: boolean): InvoiceInfo {
let paymentIntentId: string | null = null;
if (outcome === "paid") {
paymentIntentId = this.id("pi");
this.paymentIntents.set(paymentIntentId, {
id: paymentIntentId,
status: "succeeded",
amountReceived: schedule.params.amountCents,
paymentMethodId: schedule.params.paymentMethodId,
chargeId: this.id("ch"),
metadata: {},
});
}
return {
id,
status: outcome === "paid" ? "paid" : "open",
amountPaidCents: outcome === "paid" ? schedule.params.amountCents : 0,
paymentIntentId,
hostedInvoiceUrl: outcome === "paid" ? null : `https://invoice.stripe.test/${id}`,
attemptCount: attempt,
nextPaymentAttempt: outcome === "failed" && !finalAttempt ? new Date(Date.UTC(2027, 0, 1) + attempt * 86_400_000) : null,
metadata: schedule.params.metadata,
};
}
/** Summe aller ueber den Zeitplan eingezogenen Betraege. */
get billedCents(): number {
return [...this.invoices.values()].reduce((sum, i) => sum + i.amountPaidCents, 0);
}
async detachPaymentMethod(id: string): Promise<void> {
this.detached.push(id);
}
async createRefund(params: { paymentIntentId: string; amountCents: number }, key: string) {
return this.once(`re:${key}`, () => {
const refund = { id: this.id("re"), paymentIntentId: params.paymentIntentId, amountCents: params.amountCents };
this.refunds.push(refund);
return { id: refund.id, status: "succeeded", amount: params.amountCents };
});
}
}