Files
Wabenhain/src/lib/sponsorship-subscription/webhook-handlers.test.ts
T
Nick SzaunigandClaude Opus 5.5 515fca1c16
Docker Image bauen und veroeffentlichen / typecheck (push) Successful in 2m57s
Docker Image bauen und veroeffentlichen / lint (push) Successful in 3m7s
Docker Image bauen und veroeffentlichen / build-and-push (push) Canceled after 0s
Docker Image bauen und veroeffentlichen / test (push) Canceled after 1m22s
fix: Befunde aus Sicherheits- und Korrektheitsreview der Bienenpatenschaft
- Oeffentliche Kuendigung: exakte Identifikation, Limits je Vertrag und
  Adresse, Bestaetigung nur an die Kontoadresse mit Ruecknahme-Link (HMAC)
- Vorschau: Body-Grenze beim Lesen, Parallelitaets- und Minutenlimit, Cache
- Rabatte gelten nicht fuer Patenschaftspositionen
- Abo aktiv erst nach bestaetigter Zahlung, offene Abschluesse zaehlen
  zum Kontingent und werden nach 24 h aufgeraeumt, Sitzung 58 Minuten
- Konto-Loeschung nur ohne laufendes Abo, Urkunden-Dateien werden entfernt
- Abo-Lieferungen mit Zahlungszeile (DHL-Marke moeglich)
- Shop zeigt freien Bestand nach Abo-Reservierung
- Storno/volle Erstattung widerruft die Patenschaft (Status WIDERRUFEN)
- Umsatzsteuer der Patenschaft per VAT_RATE_PERCENT_PATENSCHAFT (19 %)
- Digitale Bestellungen archivieren sich, Kalendertage in Europe/Berlin,
  Monatsenden begrenzt, Datenauskunft um Patenschaften ergaenzt

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 15:09:32 +02:00

306 lines
13 KiB
TypeScript

/**
* 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: "<p></p>", text: "" })),
}));
vi.mock("@/lib/sponsorship-subscription/invoice-paid", async (importOriginal) => {
const actual = await importOriginal<typeof import("./invoice-paid")>();
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<T>(type: string, object: T): Stripe.Event {
return { type, data: { object } } as unknown as Stripe.Event;
}
function stripeSubscription(overrides: Partial<Stripe.Subscription> = {}): 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: "[email protected]", firstName: "Erika" },
},
};
function dispute(overrides: Partial<Stripe.Dispute> = {}): 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();
});
});