feat: Bienenpatenschaft mit Urkunde, Abo, Medaillen und 3D-Konto
Setzt Teilprojekte 1-5 der Spec 2026-10-16 um: - Urkunden-Engine (lib/certificate): ein Layout fuer PDF, Vorschau und 3D-Textur, Vorlage classic@1, eingebettete OFL-Schriften, Testmatrix - Einmal-Patenschaft ueber Traegerprodukte (Kategorie PATENSCHAFT), Paket wird im Checkout in Patenschaft (Regelsatz) und Honig aufgeteilt, Kontopflicht, rein digitale Bestellungen (FulfillmentMethod DIGITAL) - Ausstellung nach Zahlung idempotent, Wiederholung im App-Prozess (instrumentation + background-jobs), Ablage in CERTIFICATE_DIR - Verkaufsseite /patenschaft mit Konfigurator und Live-Vorschau, Warenkorb mit Schluessel je Position - Kundenkonto: 3D-Ansicht von Urkunde und Medaille, Downloads, ZIP, Uebertragung per befristetem Einmal-Link, Treuestufen bis VIP - Admin: Angebote, Patenschaften, Druck, Korrektur mit Revisionen, Abos, Erntefenster, Deckung, SEPA-Frist, Treuestufen - Abo ueber Stripe Billing: Lieferung erst nach bezahlten Monaten, SEPA-Neukunden-Frist und Rueckbuchungsschutz, Honigreservierung, Kuendigungsbutton, Monatsrechnungen, Stripe-Einrichtungsskript Startangebote werden inaktiv angelegt; Livebetrieb erst nach Freigabe. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
dcb578f062
commit
f227aeb8f9
269 files changed
+28054
-141
No files matched your search
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* 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",
|
||||
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("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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user