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
- 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]>
306 lines
13 KiB
TypeScript
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();
|
|
});
|
|
});
|