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]>
213 lines
8.3 KiB
TypeScript
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 };
|
|
});
|
|
}
|
|
}
|