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(); readonly sessions = new Map(); readonly paymentIntents = new Map(); readonly schedules = new Map(); readonly invoices = new Map(); 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(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 { const s = this.sessions.get(id); if (!s) throw new Error(`Unbekannte Session ${id}`); return { ...s }; } async expireCheckoutSession(id: string): Promise { 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 { 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 cancelInstallmentSchedule(id: string): Promise { 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 { const invoice = this.invoices.get(id); if (!invoice) throw new Error(`Unbekannte Rechnung ${id}`); return { ...invoice }; } async listScheduleInvoices(scheduleId: string): Promise { 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 { 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 }; }); } }