/** * Abo-Ereignisse ausser invoice.paid (eigene Datei): Abschluss, Abo-Stand, * Ende, fehlgeschlagene Zahlung und Rueckbuchungen — jeweils mit doppelter * und verspaeteter Zustellung, wie Stripe sie ausdruecklich vorsieht. */ import { beforeEach, describe, expect, test, vi } from "vitest"; import type Stripe from "stripe"; const m = vi.hoisted(() => ({ stripe: { subscriptions: { retrieve: vi.fn() }, invoicePayments: { list: vi.fn() }, }, db: { sponsorshipSubscription: { findUnique: vi.fn(), updateMany: vi.fn(), count: vi.fn() }, subscriptionPayment: { findFirst: vi.fn(), findMany: vi.fn(), updateMany: vi.fn(), count: vi.fn() }, customer: { updateMany: vi.fn() }, }, sendMail: vi.fn(), endSubscription: vi.fn(), handleInvoicePaid: vi.fn(), })); vi.mock("@/lib/stripe", () => ({ getStripeClient: () => m.stripe })); vi.mock("@/lib/prisma", () => ({ prisma: m.db })); vi.mock("@/lib/mail", () => ({ sendMail: m.sendMail })); vi.mock("@/lib/sponsorship-subscription/ending", () => ({ endSubscription: m.endSubscription })); vi.mock("@/lib/sponsorship-subscription/emails", () => ({ subscriptionDisputeEmail: vi.fn(async () => ({ subject: "Rueckbuchung", html: "

", text: "" })), })); vi.mock("@/lib/sponsorship-subscription/invoice-paid", async (importOriginal) => { const actual = await importOriginal(); return { subscriptionRefOfInvoice: actual.subscriptionRefOfInvoice, handleInvoicePaid: m.handleInvoicePaid }; }); const { handleSubscriptionEvent } = await import("./webhook-handlers"); const LOCAL = { id: "sub_local", status: "AKTIV", stripeSubscriptionId: "sub_stripe", delinquentSince: null as Date | null, startedAt: new Date("2026-07-01T00:00:00.000Z"), currentPeriodEnd: null as Date | null, }; function event(type: string, object: T): Stripe.Event { return { type, data: { object } } as unknown as Stripe.Event; } function stripeSubscription(overrides: Partial = {}): Stripe.Subscription { return { id: "sub_stripe", status: "active", cancel_at: null, cancel_at_period_end: false, ended_at: null, start_date: 1_780_000_000, metadata: { sponsorshipSubscriptionId: "sub_local" }, items: { data: [{ current_period_end: 1_790_000_000 }] }, ...overrides, } as unknown as Stripe.Subscription; } const disputedPayment = { id: "pay_1", stripeInvoiceId: "in_1", paymentMethodType: "sepa_debit", reversedAt: null as Date | null, subscriptionId: "sub_local", subscription: { id: "sub_local", status: "AKTIV", locale: "de", stripeSubscriptionId: "sub_stripe", offer: { name: "Vierteljährlich" }, customer: { id: "cust_1", email: "erika@example.org", firstName: "Erika" }, }, }; function dispute(overrides: Partial = {}): Stripe.Dispute { return { id: "dp_1", payment_intent: "pi_1", amount: 1500, status: "needs_response", ...overrides } as unknown as Stripe.Dispute; } beforeEach(() => { vi.clearAllMocks(); vi.spyOn(console, "error").mockImplementation(() => {}); m.db.sponsorshipSubscription.findUnique.mockResolvedValue(LOCAL); m.db.subscriptionPayment.findMany.mockResolvedValue([]); m.db.subscriptionPayment.count.mockResolvedValue(0); m.sendMail.mockResolvedValue(true); }); describe("checkout.session.completed (Abo)", () => { test("verknuepft das Stripe-Abo und setzt AUSSTEHEND auf AKTIV", async () => { m.db.sponsorshipSubscription.findUnique .mockResolvedValueOnce({ ...LOCAL, status: "AUSSTEHEND", stripeSubscriptionId: null }) // per Metadaten-ID .mockResolvedValueOnce(null) // per stripeSubscriptionId noch nicht verknuepft .mockResolvedValueOnce({ ...LOCAL, status: "AUSSTEHEND", stripeSubscriptionId: null }) // Kandidat .mockResolvedValueOnce({ ...LOCAL, status: "AUSSTEHEND" }); await handleSubscriptionEvent( event("checkout.session.completed", { id: "cs_1", mode: "subscription", subscription: "sub_stripe", payment_status: "paid", metadata: { sponsorshipSubscriptionId: "sub_local" }, }) ); expect(m.db.sponsorshipSubscription.updateMany).toHaveBeenCalledWith({ where: { id: "sub_local", stripeSubscriptionId: null }, data: { stripeSubscriptionId: "sub_stripe" }, }); expect(m.db.sponsorshipSubscription.updateMany).toHaveBeenCalledWith({ where: { id: "sub_local", status: "AUSSTEHEND" }, data: expect.objectContaining({ status: "AKTIV" }), }); }); test("verzoegerte Zahlart (SEPA, noch unbezahlt): verknuepft, aber aktiviert erst mit invoice.paid", async () => { m.db.sponsorshipSubscription.findUnique .mockResolvedValueOnce({ ...LOCAL, status: "AUSSTEHEND", stripeSubscriptionId: null }) .mockResolvedValueOnce(null) .mockResolvedValueOnce({ ...LOCAL, status: "AUSSTEHEND", stripeSubscriptionId: null }) .mockResolvedValueOnce({ ...LOCAL, status: "AUSSTEHEND" }); await handleSubscriptionEvent( event("checkout.session.completed", { id: "cs_1", mode: "subscription", subscription: "sub_stripe", payment_status: "unpaid", metadata: { sponsorshipSubscriptionId: "sub_local" }, }) ); expect(m.db.sponsorshipSubscription.updateMany).toHaveBeenCalledWith({ where: { id: "sub_local", stripeSubscriptionId: null }, data: { stripeSubscriptionId: "sub_stripe" }, }); expect(m.db.sponsorshipSubscription.updateMany).not.toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ status: "AKTIV" }) }) ); }); test("laesst Einmal-Checkouts unberuehrt", async () => { await handleSubscriptionEvent(event("checkout.session.completed", { id: "cs_1", mode: "payment", metadata: { orderId: "o1" } })); expect(m.db.sponsorshipSubscription.findUnique).not.toHaveBeenCalled(); }); }); describe("customer.subscription.updated", () => { test("liest das Abo frisch bei Stripe statt dem (moeglicherweise veralteten) Ereignis zu vertrauen", async () => { m.stripe.subscriptions.retrieve.mockResolvedValue(stripeSubscription({ cancel_at_period_end: true })); await handleSubscriptionEvent(event("customer.subscription.updated", stripeSubscription({ cancel_at_period_end: false }))); expect(m.stripe.subscriptions.retrieve).toHaveBeenCalledWith("sub_stripe"); expect(m.db.sponsorshipSubscription.updateMany).toHaveBeenCalledWith({ where: { id: "sub_local", status: { not: "BEENDET" } }, data: expect.objectContaining({ status: "GEKUENDIGT", cancelAt: new Date(1_790_000_000 * 1000), currentPeriodEnd: new Date(1_790_000_000 * 1000), }), }); }); test("past_due setzt das Abo in Verzug und haelt den Verzugsbeginn fest", async () => { m.stripe.subscriptions.retrieve.mockResolvedValue(stripeSubscription({ status: "past_due" })); await handleSubscriptionEvent(event("customer.subscription.updated", stripeSubscription())); const data = m.db.sponsorshipSubscription.updateMany.mock.calls[0][0].data; expect(data.status).toBe("IM_VERZUG"); expect(data.delinquentSince).toBeInstanceOf(Date); }); test("fremde Stripe-Abos kosten keinen API-Aufruf", async () => { m.db.sponsorshipSubscription.count.mockResolvedValue(0); await handleSubscriptionEvent(event("customer.subscription.updated", stripeSubscription({ metadata: {} }))); expect(m.stripe.subscriptions.retrieve).not.toHaveBeenCalled(); }); }); describe("customer.subscription.deleted", () => { test("beendet das Abo zum Endzeitpunkt von Stripe", async () => { await handleSubscriptionEvent( event("customer.subscription.deleted", stripeSubscription({ status: "canceled", ended_at: 1_791_000_000 })) ); expect(m.endSubscription).toHaveBeenCalledWith("sub_local", new Date(1_791_000_000 * 1000)); }); }); describe("invoice.payment_failed", () => { const failed = { id: "in_2", parent: { subscription_details: { subscription: "sub_stripe", metadata: {} } }, }; test("setzt ein laufendes Abo in Verzug", async () => { await handleSubscriptionEvent(event("invoice.payment_failed", failed)); expect(m.db.sponsorshipSubscription.updateMany).toHaveBeenCalledWith({ where: { id: "sub_local", status: { in: ["AKTIV", "GEKUENDIGT"] } }, data: expect.objectContaining({ status: "IM_VERZUG" }), }); }); test("kommt der Fehlschlag nach dem Erfolg derselben Rechnung an, passiert nichts", async () => { m.db.subscriptionPayment.count.mockResolvedValue(1); await handleSubscriptionEvent(event("invoice.payment_failed", failed)); expect(m.db.sponsorshipSubscription.updateMany).not.toHaveBeenCalled(); }); }); describe("charge.dispute.created", () => { test("bucht die Zahlung zurueck, setzt Verzug, sperrt SEPA und bittet per Mail um Ausgleich", async () => { m.db.subscriptionPayment.findFirst.mockResolvedValue(disputedPayment); m.db.subscriptionPayment.updateMany.mockResolvedValue({ count: 1 }); await handleSubscriptionEvent(event("charge.dispute.created", dispute())); expect(m.db.subscriptionPayment.updateMany).toHaveBeenCalledWith({ where: { id: "pay_1", reversedAt: null }, data: { reversedAt: expect.any(Date) }, }); expect(m.db.sponsorshipSubscription.updateMany).toHaveBeenCalledWith({ where: { id: "sub_local", status: { not: "BEENDET" } }, data: { status: "IM_VERZUG", delinquentSince: expect.any(Date) }, }); expect(m.db.customer.updateMany).toHaveBeenCalledWith({ where: { id: "cust_1", sepaBlockedAt: null }, data: { sepaBlockedAt: expect.any(Date) }, }); expect(m.sendMail).toHaveBeenCalledOnce(); }); test("ein doppelt zugestelltes Ereignis verschickt keine zweite Mail", async () => { m.db.subscriptionPayment.findFirst.mockResolvedValue({ ...disputedPayment, reversedAt: new Date() }); m.db.subscriptionPayment.updateMany.mockResolvedValue({ count: 0 }); await handleSubscriptionEvent(event("charge.dispute.created", dispute())); expect(m.sendMail).not.toHaveBeenCalled(); expect(m.db.sponsorshipSubscription.updateMany).not.toHaveBeenCalled(); }); test("Kartenzahlung: keine SEPA-Sperre", async () => { m.db.subscriptionPayment.findFirst.mockResolvedValue({ ...disputedPayment, paymentMethodType: "card" }); m.db.subscriptionPayment.updateMany.mockResolvedValue({ count: 1 }); await handleSubscriptionEvent(event("charge.dispute.created", dispute())); expect(m.db.customer.updateMany).not.toHaveBeenCalled(); }); test("Rueckbuchung vor der zugehoerigen Abo-Zahlung: wirft, damit Stripe spaeter erneut zustellt", async () => { m.db.subscriptionPayment.findFirst.mockResolvedValue(null); m.stripe.invoicePayments.list.mockResolvedValue({ data: [{ invoice: { id: "in_1", parent: { subscription_details: { subscription: "sub_stripe", metadata: {} } } } }], }); m.db.sponsorshipSubscription.count.mockResolvedValue(1); await expect(handleSubscriptionEvent(event("charge.dispute.created", dispute()))).rejects.toThrow(/erneut/); }); test("Rueckbuchung einer Einmalbestellung wird hier ignoriert", async () => { m.db.subscriptionPayment.findFirst.mockResolvedValue(null); m.stripe.invoicePayments.list.mockResolvedValue({ data: [] }); await handleSubscriptionEvent(event("charge.dispute.created", dispute())); expect(m.db.subscriptionPayment.updateMany).not.toHaveBeenCalled(); }); }); describe("charge.dispute.closed", () => { test("gewonnen: die Zahlung zaehlt wieder, der Status wird aus Stripe neu bestimmt", async () => { m.db.subscriptionPayment.findFirst.mockResolvedValue({ ...disputedPayment, reversedAt: new Date() }); m.stripe.subscriptions.retrieve.mockResolvedValue(stripeSubscription()); await handleSubscriptionEvent(event("charge.dispute.closed", dispute({ status: "won" }))); expect(m.db.subscriptionPayment.updateMany).toHaveBeenCalledWith({ where: { id: "pay_1", reversedAt: { not: null } }, data: { reversedAt: null }, }); expect(m.stripe.subscriptions.retrieve).toHaveBeenCalledWith("sub_stripe"); }); test("verloren: nichts aendert sich", async () => { await handleSubscriptionEvent(event("charge.dispute.closed", dispute({ status: "lost" }))); expect(m.db.subscriptionPayment.findFirst).not.toHaveBeenCalled(); }); }); describe("Weiterleitung", () => { test("invoice.paid geht an den eigenen Handler, unbekannte Ereignisse werden ignoriert", async () => { await handleSubscriptionEvent(event("invoice.paid", { id: "in_1" })); await handleSubscriptionEvent(event("product.updated", { id: "prod_1" })); expect(m.handleInvoicePaid).toHaveBeenCalledOnce(); }); });