feat: Folgeraten über Stripe Billing einziehen
Statt eigener Off-Session-Einzüge durch den Worker legt die Anwendung nach bezahlter erster Rate einen Stripe Subscription Schedule an. Stripe übernimmt Einzug, Wiederholungen (Smart Retries), 3-D-Secure-Links und Mahnungen. - Gateway: Zeitplan anlegen/beenden, Rechnungen lesen; chargeOffSession entfällt - Webhook: invoice.paid, invoice.payment_failed, invoice.payment_action_required - Worker: fehlende Zeitpläne anlegen, überfällige Raten abgleichen, Zeitpläne stornierter Buchungen beenden - Storno beendet den Zeitplan und storniert offene Rechnungen - "Rate bezahlen" führt auf Stripes Rechnungsseite - Migration: Booking.stripeScheduleId, Installment.stripeInvoiceId Zeitplan-Form mit Stripe-Testuhr gegen die Test-API geprüft. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
bcf0f5c937
commit
c1ad9ad2ca
18 files changed
+800
-555
No files matched your search
@@ -3,7 +3,8 @@ import { beforeEach, describe, expect, test } from "vitest";
|
||||
import { cancelBooking, refundInstallment, saveVoucher, voucherInputSchema } from "@/lib/admin-service";
|
||||
import { startCheckout } from "@/lib/checkout";
|
||||
import { handleStripeEvent } from "@/lib/webhook";
|
||||
import { chargeDueInstallments } from "@/lib/worker-jobs";
|
||||
import { stopCanceledInstallmentPlans } from "@/lib/installment-billing";
|
||||
import { detachFinishedPaymentMethods } from "@/lib/worker-jobs";
|
||||
import { bookingRequest, db, EARLY_BIRD_NOW, resetDb, SECRET, setupCamp } from "./support/db";
|
||||
import { FakeGateway } from "./support/fake-gateway";
|
||||
|
||||
@@ -33,8 +34,10 @@ async function paidInstallmentBooking() {
|
||||
}
|
||||
|
||||
describe("Stornierung", () => {
|
||||
test("stoppt alle weiteren Abbuchungen, gibt Platz frei, trennt die Zahlungsmethode", async () => {
|
||||
test("beendet den Stripe-Ratenplan, storniert offene Rechnungen, gibt Platz frei, trennt die Zahlungsmethode", async () => {
|
||||
const booking = await paidInstallmentBooking();
|
||||
const schedule = gateway.schedules.get(booking.stripeScheduleId!)!;
|
||||
const open = gateway.billNextInstallment(schedule.id, "failed");
|
||||
const result = await cancelBooking(db, gateway, { bookingId: booking.id, reason: "Test" }, admin);
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
@@ -42,14 +45,34 @@ describe("Stornierung", () => {
|
||||
expect(after.status).toBe("CANCELED");
|
||||
expect(after.reservation!.status).toBe("RELEASED");
|
||||
expect(after.installments.filter((i) => i.seq > 1).every((i) => i.status === "CANCELED")).toBe(true);
|
||||
expect(schedule.status).toBe("canceled");
|
||||
expect((await gateway.retrieveInvoice(open.id)).status).toBe("void");
|
||||
expect(() => gateway.billNextInstallment(schedule.id)).toThrow();
|
||||
expect(gateway.detached).toEqual([booking.stripePaymentMethodId]);
|
||||
|
||||
const later = { ...ctx, now: new Date("2027-06-01T12:00:00Z") };
|
||||
await chargeDueInstallments(db, gateway, later);
|
||||
expect(gateway.offSessionCharges).toHaveLength(0);
|
||||
expect(await db.auditLog.count({ where: { action: "booking.canceled", actorId: "admin-test" } })).toBe(1);
|
||||
});
|
||||
|
||||
test("Stripe nicht erreichbar: Storno bleibt, der Worker beendet den Ratenplan spaeter", async () => {
|
||||
const booking = await paidInstallmentBooking();
|
||||
const schedule = gateway.schedules.get(booking.stripeScheduleId!)!;
|
||||
const cancel = gateway.cancelInstallmentSchedule.bind(gateway);
|
||||
gateway.cancelInstallmentSchedule = async () => {
|
||||
throw new Error("Stripe nicht erreichbar (simuliert)");
|
||||
};
|
||||
const result = await cancelBooking(db, gateway, { bookingId: booking.id, reason: "Test" }, admin);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(schedule.status).toBe("active");
|
||||
// Der allgemeine Lauf trennt die Zahlungsmethode nicht, solange der Plan laeuft.
|
||||
await detachFinishedPaymentMethods(db, gateway);
|
||||
expect(gateway.detached).toEqual([]);
|
||||
|
||||
gateway.cancelInstallmentSchedule = cancel;
|
||||
expect((await stopCanceledInstallmentPlans(db, gateway)).stopped).toBe(1);
|
||||
expect(schedule.status).toBe("canceled");
|
||||
expect(gateway.detached).toEqual([booking.stripePaymentMethodId]);
|
||||
expect((await stopCanceledInstallmentPlans(db, gateway)).stopped).toBe(0);
|
||||
});
|
||||
|
||||
test("verlangt einen Grund", async () => {
|
||||
const booking = await paidInstallmentBooking();
|
||||
expect((await cancelBooking(db, gateway, { bookingId: booking.id, reason: " " }, admin)).ok).toBe(false);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import Stripe from "stripe";
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import { startCheckout, startInstallmentCheckout } from "@/lib/checkout";
|
||||
import { applyRefundState, MAX_OFF_SESSION_ATTEMPTS } from "@/lib/payment-events";
|
||||
import { installmentPaymentUrl, startCheckout } from "@/lib/checkout";
|
||||
import { applyRefundState } from "@/lib/payment-events";
|
||||
import { verifyStripeWebhook } from "@/lib/payment-gateway";
|
||||
import { handleStripeEvent, settleCheckoutSession } from "@/lib/webhook";
|
||||
import { chargeDueInstallments, detachFinishedPaymentMethods, releaseExpiredReservations } from "@/lib/worker-jobs";
|
||||
import { createMissingSchedules, detachFinishedPaymentMethods, reconcileInstallmentInvoices, releaseExpiredReservations } from "@/lib/worker-jobs";
|
||||
import { STRIPE_ACTOR } from "@/lib/audit";
|
||||
import type { PlanType } from "@/lib/payment-plan";
|
||||
import { bookingRequest, db, EARLY_BIRD_NOW, resetDb, SECRET, setupCamp } from "./support/db";
|
||||
@@ -212,113 +212,138 @@ describe("Verzoegerte und verspaetete Zahlung", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Folgeraten", () => {
|
||||
const afterLastDue = (booking: { installments: { dueAt: Date | null }[] }) => ({
|
||||
...ctx,
|
||||
now: new Date(Math.max(...booking.installments.map((i) => i.dueAt?.getTime() ?? 0)) + 60_000),
|
||||
describe("Folgeraten ueber Stripe Billing", () => {
|
||||
function invoiceEvent(type: string, invoice: { id: string }, id = `evt_${crypto.randomUUID()}`) {
|
||||
return { id, type, livemode: false, data: { object: { id: invoice.id, object: "invoice" } } } as unknown as Stripe.Event;
|
||||
}
|
||||
|
||||
async function scheduleOf(bookingId: string) {
|
||||
const booking = await db.booking.findUniqueOrThrow({ where: { id: bookingId } });
|
||||
return gateway.schedules.get(booking.stripeScheduleId!)!;
|
||||
}
|
||||
|
||||
async function installment(bookingId: string, seq: number) {
|
||||
return db.installment.findFirstOrThrow({ where: { bookingId, seq } });
|
||||
}
|
||||
|
||||
test("erste Rate bezahlt: Stripe-Ratenplan mit Betrag, Start, Abstand und Zahlungsmethode", async () => {
|
||||
const booking = await paidBooking("THREE_INSTALLMENTS");
|
||||
const [, second, third] = booking.installments;
|
||||
const schedule = await scheduleOf(booking.id);
|
||||
expect(schedule.params).toMatchObject({
|
||||
amountCents: 70_000,
|
||||
count: 2,
|
||||
startAt: second!.dueAt,
|
||||
intervalDays: Math.round((third!.dueAt!.getTime() - second!.dueAt!.getTime()) / 86_400_000),
|
||||
paymentMethodId: booking.stripePaymentMethodId,
|
||||
customerId: booking.stripeCustomerId,
|
||||
});
|
||||
expect(schedule.params.metadata).toMatchObject({ kind: "installment_plan", bookingId: booking.id });
|
||||
|
||||
// Erneut zugestelltes Checkout-Event legt keinen zweiten Plan an.
|
||||
const session = await gateway.retrieveCheckoutSession(booking.stripeCheckoutSessionId!);
|
||||
await handleStripeEvent(db, gateway, sessionEvent("checkout.session.async_payment_succeeded", session), ctx);
|
||||
expect(gateway.schedules.size).toBe(1);
|
||||
});
|
||||
|
||||
test("Einzug aller Raten, danach keine weitere Abbuchung und Zahlungsmethode getrennt", async () => {
|
||||
test("Einmalzahlung legt keinen Ratenplan an", async () => {
|
||||
await paidBooking("FULL");
|
||||
expect(gateway.schedules.size).toBe(0);
|
||||
});
|
||||
|
||||
test("Stripe zieht alle Raten ein; danach keine weitere Rechnung, Zahlungsmethode getrennt", async () => {
|
||||
const booking = await paidBooking("THREE_INSTALLMENTS");
|
||||
const runCtx = afterLastDue(booking);
|
||||
const summary = await chargeDueInstallments(db, gateway, runCtx);
|
||||
expect(summary.charged).toBe(2);
|
||||
let after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
|
||||
const schedule = await scheduleOf(booking.id);
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const invoice = gateway.billNextInstallment(schedule.id);
|
||||
await handleStripeEvent(db, gateway, invoiceEvent("invoice.paid", invoice), ctx);
|
||||
}
|
||||
let after = await db.booking.findUniqueOrThrow({ where: { id: booking.id }, include: { installments: { orderBy: { seq: "asc" } } } });
|
||||
expect(after.paymentStatus).toBe("PAID");
|
||||
expect(after.paidCents).toBe(220_000);
|
||||
expect(after.installments.map((i) => i.status)).toEqual(["PAID", "PAID", "PAID"]);
|
||||
expect(gateway.billedCents).toBe(140_000);
|
||||
expect(() => gateway.billNextInstallment(schedule.id)).toThrow();
|
||||
|
||||
await detachFinishedPaymentMethods(db, gateway);
|
||||
after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
|
||||
after = await db.booking.findUniqueOrThrow({ where: { id: booking.id }, include: { installments: true } });
|
||||
expect(after.paymentMethodDetachedAt).not.toBeNull();
|
||||
expect(gateway.detached).toEqual([booking.stripePaymentMethodId]);
|
||||
|
||||
// Weitere Laeufe buchen nichts mehr ab.
|
||||
await chargeDueInstallments(db, gateway, runCtx);
|
||||
await chargeDueInstallments(db, gateway, { ...runCtx, now: new Date(runCtx.now.getTime() + 30 * 86_400_000) });
|
||||
expect(gateway.offSessionCharges).toHaveLength(2);
|
||||
expect(gateway.offSessionCharges.reduce((s, c) => s + c.amountCents, 0)).toBe(140_000);
|
||||
});
|
||||
|
||||
test("nur faellige Raten werden eingezogen", async () => {
|
||||
const booking = await paidBooking("THREE_INSTALLMENTS");
|
||||
const between = { ...ctx, now: new Date(booking.installments[1]!.dueAt!.getTime() + 60_000) };
|
||||
expect((await chargeDueInstallments(db, gateway, between)).charged).toBe(1);
|
||||
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
|
||||
expect(after.paymentStatus).toBe("PARTIALLY_PAID");
|
||||
expect(after.paidCents).toBe(150_000);
|
||||
});
|
||||
|
||||
test("Fehlschlag: Wiederholung nach Wartezeit, nach dem letzten Versuch manuelle Pruefung", async () => {
|
||||
test("doppelt zugestelltes invoice.paid wird nur einmal verbucht", async () => {
|
||||
const booking = await paidBooking("TWO_INSTALLMENTS");
|
||||
gateway.offSessionOutcomes = ["failed", "failed", "failed"];
|
||||
let runCtx = afterLastDue(booking);
|
||||
for (let attempt = 1; attempt <= MAX_OFF_SESSION_ATTEMPTS; attempt++) {
|
||||
await chargeDueInstallments(db, gateway, runCtx);
|
||||
const inst = await db.installment.findFirstOrThrow({ where: { bookingId: booking.id, seq: 2 } });
|
||||
expect(inst.status).toBe("FAILED");
|
||||
expect(inst.attemptCount).toBe(attempt);
|
||||
// Vor Ablauf der Wartezeit kein erneuter Versuch.
|
||||
await chargeDueInstallments(db, gateway, runCtx);
|
||||
expect((await db.installment.findFirstOrThrow({ where: { id: inst.id } })).attemptCount).toBe(attempt);
|
||||
runCtx = { ...runCtx, now: new Date(runCtx.now.getTime() + 4 * 86_400_000) };
|
||||
}
|
||||
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
|
||||
expect(after.status).toBe("NEEDS_REVIEW");
|
||||
expect(after.paymentStatus).toBe("PARTIALLY_PAID");
|
||||
expect(gateway.offSessionCharges).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("Bank verlangt Authentifizierung: Kundin bekommt Link und zahlt selbst", async () => {
|
||||
const booking = await paidBooking("TWO_INSTALLMENTS");
|
||||
gateway.offSessionOutcomes = ["requires_action"];
|
||||
const runCtx = afterLastDue(booking);
|
||||
await chargeDueInstallments(db, gateway, runCtx);
|
||||
const inst = await db.installment.findFirstOrThrow({ where: { bookingId: booking.id, seq: 2 } });
|
||||
expect(inst.status).toBe("REQUIRES_ACTION");
|
||||
expect(await db.emailMessage.count({ where: { dedupeKey: { startsWith: `installment-action:${inst.id}` } } })).toBe(1);
|
||||
|
||||
const pay = await startInstallmentCheckout(db, gateway, { bookingId: booking.id, installmentId: inst.id }, runCtx);
|
||||
expect(pay.ok).toBe(true);
|
||||
// Waehrend die Session offen ist, zieht der Worker nicht parallel ein.
|
||||
await chargeDueInstallments(db, gateway, { ...runCtx, now: new Date(runCtx.now.getTime() + 60_000) });
|
||||
expect(gateway.offSessionCharges).toHaveLength(0);
|
||||
|
||||
const sessionId = (await db.installment.findUniqueOrThrow({ where: { id: inst.id } })).checkoutSessionId!;
|
||||
const session = gateway.completeSession(sessionId);
|
||||
await handleStripeEvent(db, gateway, sessionEvent("checkout.session.completed", session), runCtx);
|
||||
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
|
||||
expect(after.paymentStatus).toBe("PAID");
|
||||
expect(after.paidCents).toBe(220_000);
|
||||
});
|
||||
|
||||
test("Webhook und Worker melden denselben Erfolg: nur einmal verbucht", async () => {
|
||||
const booking = await paidBooking("TWO_INSTALLMENTS");
|
||||
const runCtx = afterLastDue(booking);
|
||||
await chargeDueInstallments(db, gateway, runCtx);
|
||||
const inst = await db.installment.findFirstOrThrow({ where: { bookingId: booking.id, seq: 2 } });
|
||||
const pi = await gateway.retrievePaymentIntent(inst.stripePaymentIntentId!);
|
||||
const event = {
|
||||
id: "evt_pi_ok",
|
||||
type: "payment_intent.succeeded",
|
||||
livemode: false,
|
||||
data: {
|
||||
object: {
|
||||
id: pi.id,
|
||||
object: "payment_intent",
|
||||
amount_received: pi.amountReceived,
|
||||
latest_charge: pi.chargeId,
|
||||
payment_method: pi.paymentMethodId,
|
||||
customer: null,
|
||||
metadata: pi.metadata,
|
||||
},
|
||||
},
|
||||
} as unknown as Stripe.Event;
|
||||
await handleStripeEvent(db, gateway, event, runCtx);
|
||||
const invoice = gateway.billNextInstallment((await scheduleOf(booking.id)).id);
|
||||
await handleStripeEvent(db, gateway, invoiceEvent("invoice.paid", invoice), ctx);
|
||||
await handleStripeEvent(db, gateway, invoiceEvent("invoice.paid", invoice), ctx);
|
||||
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
|
||||
expect(after.paidCents).toBe(220_000);
|
||||
expect(after.status).toBe("CONFIRMED");
|
||||
});
|
||||
|
||||
test("verlorener Rechnungs-Webhook: Worker gleicht mit Stripe ab", async () => {
|
||||
const booking = await paidBooking("TWO_INSTALLMENTS");
|
||||
gateway.billNextInstallment((await scheduleOf(booking.id)).id);
|
||||
const second = await installment(booking.id, 2);
|
||||
// Vor Faelligkeit wird nicht gefragt.
|
||||
expect((await reconcileInstallmentInvoices(db, gateway, ctx)).checked).toBe(0);
|
||||
const later = { ...ctx, now: new Date(second.dueAt!.getTime() + 7 * 60 * 60_000) };
|
||||
expect((await reconcileInstallmentInvoices(db, gateway, later)).settled).toBe(1);
|
||||
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
|
||||
expect(after.paymentStatus).toBe("PAID");
|
||||
});
|
||||
|
||||
test("Fehlschlag: Stripe wiederholt, Kundin wird informiert, nach dem letzten Versuch Pruefung", async () => {
|
||||
const booking = await paidBooking("TWO_INSTALLMENTS");
|
||||
const failed = gateway.billNextInstallment((await scheduleOf(booking.id)).id, "failed");
|
||||
await handleStripeEvent(db, gateway, invoiceEvent("invoice.payment_failed", failed), ctx);
|
||||
await handleStripeEvent(db, gateway, invoiceEvent("invoice.payment_failed", failed), ctx);
|
||||
let second = await installment(booking.id, 2);
|
||||
expect(second.status).toBe("FAILED");
|
||||
expect(second.stripeInvoiceId).toBe(failed.id);
|
||||
expect(second.nextAttemptAt).not.toBeNull();
|
||||
expect(await db.emailMessage.count({ where: { dedupeKey: { startsWith: `installment-action:${second.id}` } } })).toBe(1);
|
||||
expect((await db.booking.findUniqueOrThrow({ where: { id: booking.id } })).status).toBe("CONFIRMED");
|
||||
|
||||
const final = gateway.retryInvoice(failed.id, "failed", { finalAttempt: true });
|
||||
await handleStripeEvent(db, gateway, invoiceEvent("invoice.payment_failed", final), ctx);
|
||||
second = await installment(booking.id, 2);
|
||||
expect(second.attemptCount).toBe(2);
|
||||
expect(second.nextAttemptAt).toBeNull();
|
||||
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
|
||||
expect(after.status).toBe("NEEDS_REVIEW");
|
||||
expect(after.paymentStatus).toBe("PARTIALLY_PAID");
|
||||
});
|
||||
|
||||
test("Bank verlangt Bestaetigung: Kundin zahlt ueber Stripes Rechnungsseite", async () => {
|
||||
const booking = await paidBooking("TWO_INSTALLMENTS");
|
||||
const open = gateway.billNextInstallment((await scheduleOf(booking.id)).id, "requires_action");
|
||||
await handleStripeEvent(db, gateway, invoiceEvent("invoice.payment_action_required", open), ctx);
|
||||
const second = await installment(booking.id, 2);
|
||||
expect(second.status).toBe("REQUIRES_ACTION");
|
||||
|
||||
const pay = await installmentPaymentUrl(db, gateway, { bookingId: booking.id, installmentId: second.id });
|
||||
expect(pay).toEqual({ ok: true, url: `https://invoice.stripe.test/${open.id}` });
|
||||
|
||||
const paid = gateway.retryInvoice(open.id, "paid");
|
||||
await handleStripeEvent(db, gateway, invoiceEvent("invoice.paid", paid), ctx);
|
||||
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
|
||||
expect(after.paymentStatus).toBe("PAID");
|
||||
expect(after.paidCents).toBe(220_000);
|
||||
// Bezahlte Rate hat keinen Zahlungslink mehr.
|
||||
expect((await installmentPaymentUrl(db, gateway, { bookingId: booking.id, installmentId: second.id })).ok).toBe(false);
|
||||
});
|
||||
|
||||
test("Ratenplan-Anlage scheitert: Zahlung bleibt verbucht, Worker holt die Anlage nach", async () => {
|
||||
gateway.failNextSchedule = true;
|
||||
const booking = await paidBooking("TWO_INSTALLMENTS");
|
||||
expect(booking.status).toBe("CONFIRMED");
|
||||
expect(booking.stripeScheduleId).toBeNull();
|
||||
expect((await createMissingSchedules(db, gateway, ctx)).created).toBe(1);
|
||||
expect(gateway.schedules.size).toBe(1);
|
||||
expect((await createMissingSchedules(db, gateway, ctx)).created).toBe(0);
|
||||
});
|
||||
|
||||
test("Erstattung aktualisiert Zahlungsstatus absolut (ungeordnete Events harmlos)", async () => {
|
||||
const booking = await paidBooking("FULL");
|
||||
const pi = booking.installments[0]!.stripePaymentIntentId!;
|
||||
|
||||
@@ -1,30 +1,32 @@
|
||||
import type {
|
||||
CheckoutSessionInfo,
|
||||
CreateCheckoutParams,
|
||||
OffSessionResult,
|
||||
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 das Ergebnis von Off-Session-Einzuegen.
|
||||
* 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 offSessionCharges: { key: string; amountCents: number; installmentId: string }[] = [];
|
||||
readonly schedules = new Map<string, StoredSchedule>();
|
||||
readonly invoices = new Map<string, InvoiceInfo>();
|
||||
readonly detached: string[] = [];
|
||||
readonly refunds: { id: string; paymentIntentId: string; amountCents: number }[] = [];
|
||||
/** Ergebnisse fuer die naechsten Off-Session-Einzuege (Standard: Erfolg). */
|
||||
offSessionOutcomes: OffSessionResult["outcome"][] = [];
|
||||
failNextCheckout = false;
|
||||
failNextSchedule = false;
|
||||
|
||||
constructor(readonly mode: StripeMode = "test") {}
|
||||
|
||||
@@ -101,34 +103,94 @@ export class FakeGateway implements PaymentGateway {
|
||||
return { ...pi };
|
||||
}
|
||||
|
||||
async chargeOffSession(
|
||||
params: { customerId: string; paymentMethodId: string; amountCents: number; metadata: Record<string, string> },
|
||||
key: string,
|
||||
): Promise<OffSessionResult> {
|
||||
return this.once(`pi:${key}`, () => {
|
||||
const outcome = this.offSessionOutcomes.shift() ?? "succeeded";
|
||||
const piId = this.id("pi");
|
||||
const chargeId = outcome === "succeeded" ? this.id("ch") : null;
|
||||
this.paymentIntents.set(piId, {
|
||||
id: piId,
|
||||
status: outcome === "succeeded" ? "succeeded" : outcome === "processing" ? "processing" : outcome === "requires_action" ? "requires_action" : "requires_payment_method",
|
||||
amountReceived: outcome === "succeeded" ? params.amountCents : 0,
|
||||
paymentMethodId: params.paymentMethodId,
|
||||
chargeId,
|
||||
metadata: params.metadata,
|
||||
});
|
||||
if (outcome === "succeeded") {
|
||||
this.offSessionCharges.push({ key, amountCents: params.amountCents, installmentId: params.metadata.installmentId! });
|
||||
}
|
||||
return {
|
||||
outcome,
|
||||
paymentIntentId: piId,
|
||||
chargeId,
|
||||
failureCode: outcome === "failed" ? "card_declined" : outcome === "requires_action" ? "authentication_required" : null,
|
||||
};
|
||||
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<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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user