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,208 @@
|
||||
/**
|
||||
* Lieferauftraege im Abo: nur aus bezahlten Monaten, nie doppelt, und ohne
|
||||
* Adresse oder Bestand zurueckgestellt statt still verloren.
|
||||
*/
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const m = vi.hoisted(() => ({
|
||||
db: {
|
||||
subscriptionDelivery: { findMany: vi.fn(), findUniqueOrThrow: vi.fn(), create: vi.fn(), updateMany: vi.fn() },
|
||||
sponsorshipSubscription: { findMany: vi.fn() },
|
||||
subscriptionPayment: { updateMany: vi.fn() },
|
||||
inventory: { updateMany: vi.fn() },
|
||||
order: { create: vi.fn() },
|
||||
customer: { findUniqueOrThrow: vi.fn() },
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
sendMail: vi.fn(),
|
||||
recordOrderSale: vi.fn(),
|
||||
scheduleApiCoreStockSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({ prisma: m.db }));
|
||||
vi.mock("@/lib/mail", () => ({ sendMail: m.sendMail }));
|
||||
vi.mock("@/lib/url", () => ({ getBaseUrl: () => "https://wabenhain.test" }));
|
||||
vi.mock("@/lib/apicore-stock/stock-events", () => ({ recordOrderSale: m.recordOrderSale }));
|
||||
vi.mock("@/lib/apicore-stock/schedule", () => ({ scheduleApiCoreStockSync: m.scheduleApiCoreStockSync }));
|
||||
vi.mock("@/lib/sponsorship-subscription/emails", () => ({
|
||||
subscriptionDeliveryEmail: vi.fn(async () => ({ subject: "Lieferung", html: "<p></p>", text: "" })),
|
||||
}));
|
||||
|
||||
const { createDueDeliveries, NOTE_NO_ADDRESS, NOTE_NO_STOCK } = await import("./deliveries");
|
||||
|
||||
const NOW = new Date("2026-10-16T12:00:00.000Z");
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
const paid = (id: string, daysAgo: number) => {
|
||||
const paidAt = new Date(NOW.getTime() - daysAgo * DAY);
|
||||
return { id, paidAt, countsFrom: paidAt, reversedAt: null, deliveryId: null };
|
||||
};
|
||||
|
||||
const ADDRESS = {
|
||||
firstName: "Erika",
|
||||
lastName: "Mustermann",
|
||||
street: "Wiesenweg 1",
|
||||
zip: "86450",
|
||||
city: "Altenmünster",
|
||||
country: "DE",
|
||||
packstationNumber: null,
|
||||
postNumber: null,
|
||||
};
|
||||
|
||||
function eligibleSubscription(payments = [paid("p1", 70), paid("p2", 40), paid("p3", 10)]) {
|
||||
return {
|
||||
id: "sub_local",
|
||||
status: "AKTIV",
|
||||
offer: { cycle: "VIERTELJAEHRLICH", honeyJars: 2, harvestWindows: [] },
|
||||
payments,
|
||||
};
|
||||
}
|
||||
|
||||
const delivery = {
|
||||
id: "del_1",
|
||||
jars: 2,
|
||||
orderId: null,
|
||||
subscription: {
|
||||
id: "sub_local",
|
||||
status: "AKTIV",
|
||||
locale: "de",
|
||||
customerId: "cust_1",
|
||||
offerId: "offer_1",
|
||||
offer: {
|
||||
name: "Vierteljährlich",
|
||||
honeyProduct: { id: "honey_1", name: "Frühtracht 500 g", batchId: "batch_1", weightGrams: 500, category: "HONIG", vatRatePercent: null },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(console, "info").mockImplementation(() => {});
|
||||
m.db.$transaction.mockImplementation(async (fn: (tx: typeof m.db) => unknown) => fn(m.db));
|
||||
m.db.subscriptionDelivery.findMany.mockResolvedValue([]);
|
||||
m.db.sponsorshipSubscription.findMany.mockResolvedValue([eligibleSubscription()]);
|
||||
m.db.subscriptionDelivery.create.mockResolvedValue({ id: "del_1" });
|
||||
m.db.subscriptionPayment.updateMany.mockResolvedValue({ count: 3 });
|
||||
m.db.subscriptionDelivery.findUniqueOrThrow.mockResolvedValue(delivery);
|
||||
m.db.subscriptionDelivery.updateMany.mockResolvedValue({ count: 1 });
|
||||
m.db.customer.findUniqueOrThrow.mockResolvedValue({
|
||||
email: "[email protected]",
|
||||
firstName: "Erika",
|
||||
lastName: "Mustermann",
|
||||
phone: null,
|
||||
addresses: [ADDRESS],
|
||||
});
|
||||
m.db.inventory.updateMany.mockResolvedValue({ count: 1 });
|
||||
m.db.order.create.mockResolvedValue({ id: "order_9", orderNumber: "WH-2026-00000009" });
|
||||
m.sendMail.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
describe("createDueDeliveries", () => {
|
||||
test("legt nach drei bezahlten Monaten eine 0-€-Versandbestellung an und verbraucht die Zahlungen", async () => {
|
||||
const result = await createDueDeliveries(NOW);
|
||||
|
||||
expect(result).toEqual({ created: 1, deferred: 0 });
|
||||
expect(m.db.subscriptionPayment.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: { in: ["p1", "p2", "p3"] }, deliveryId: null, reversedAt: null },
|
||||
data: { deliveryId: "del_1" },
|
||||
});
|
||||
expect(m.db.inventory.updateMany).toHaveBeenCalledWith({
|
||||
where: { productId: "honey_1", quantity: { gte: 2 } },
|
||||
data: { quantity: { decrement: 2 } },
|
||||
});
|
||||
const order = m.db.order.create.mock.calls[0][0].data;
|
||||
expect(order).toMatchObject({ status: "BEZAHLT", fulfillmentMethod: "VERSAND", totalCents: 0, street: "Wiesenweg 1" });
|
||||
expect(order.items.create).toMatchObject({ productId: "honey_1", quantity: 2, unitPriceCents: 0, totalCents: 0 });
|
||||
expect(m.db.subscriptionDelivery.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: "del_1", orderId: null },
|
||||
data: { orderId: "order_9", status: "ANGELEGT", note: null },
|
||||
});
|
||||
expect(m.recordOrderSale).toHaveBeenCalledWith(m.db, "order_9");
|
||||
expect(m.scheduleApiCoreStockSync).toHaveBeenCalledWith(["honey_1"]);
|
||||
expect(m.sendMail).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("zwei bezahlte Monate: keine Lieferung", async () => {
|
||||
m.db.sponsorshipSubscription.findMany.mockResolvedValue([eligibleSubscription([paid("p1", 40), paid("p2", 10)])]);
|
||||
|
||||
const result = await createDueDeliveries(NOW);
|
||||
|
||||
expect(result).toEqual({ created: 0, deferred: 0 });
|
||||
expect(m.db.subscriptionDelivery.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("ein gleichzeitiger zweiter Lauf, der die Zahlungen nicht mehr bekommt, legt nichts an", async () => {
|
||||
m.db.subscriptionPayment.updateMany.mockResolvedValue({ count: 2 });
|
||||
|
||||
const result = await createDueDeliveries(NOW);
|
||||
|
||||
expect(result).toEqual({ created: 0, deferred: 0 });
|
||||
expect(m.db.order.create).not.toHaveBeenCalled();
|
||||
expect(m.sendMail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("ohne Standardadresse wird die Lieferung mit Hinweis zurueckgestellt", async () => {
|
||||
m.db.customer.findUniqueOrThrow.mockResolvedValue({
|
||||
email: "[email protected]",
|
||||
firstName: "Erika",
|
||||
lastName: "Mustermann",
|
||||
phone: null,
|
||||
addresses: [],
|
||||
});
|
||||
|
||||
const result = await createDueDeliveries(NOW);
|
||||
|
||||
expect(result).toEqual({ created: 0, deferred: 1 });
|
||||
expect(m.db.order.create).not.toHaveBeenCalled();
|
||||
expect(m.db.subscriptionDelivery.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: "del_1", orderId: null },
|
||||
data: { status: "ZURUECKGESTELLT", note: NOTE_NO_ADDRESS },
|
||||
});
|
||||
});
|
||||
|
||||
test("ohne ausreichenden Bestand wird die Lieferung zurueckgestellt, nichts wird abgebucht", async () => {
|
||||
m.db.inventory.updateMany.mockResolvedValue({ count: 0 });
|
||||
|
||||
const result = await createDueDeliveries(NOW);
|
||||
|
||||
expect(result).toEqual({ created: 0, deferred: 1 });
|
||||
expect(m.db.order.create).not.toHaveBeenCalled();
|
||||
expect(m.db.subscriptionDelivery.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: "del_1", orderId: null },
|
||||
data: { status: "ZURUECKGESTELLT", note: NOTE_NO_STOCK },
|
||||
});
|
||||
});
|
||||
|
||||
test("zurueckgestellte Lieferungen werden erneut versucht, sobald die Adresse da ist", async () => {
|
||||
m.db.subscriptionDelivery.findMany.mockResolvedValue([{ id: "del_1" }]);
|
||||
m.db.sponsorshipSubscription.findMany.mockResolvedValue([]);
|
||||
|
||||
const result = await createDueDeliveries(NOW);
|
||||
|
||||
expect(result).toEqual({ created: 1, deferred: 0 });
|
||||
expect(m.db.subscriptionDelivery.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("eine bereits angelegte Lieferung wird nicht ein zweites Mal bestellt", async () => {
|
||||
m.db.subscriptionDelivery.findMany.mockResolvedValue([{ id: "del_1" }]);
|
||||
m.db.sponsorshipSubscription.findMany.mockResolvedValue([]);
|
||||
m.db.subscriptionDelivery.findUniqueOrThrow.mockResolvedValue({ ...delivery, orderId: "order_9" });
|
||||
|
||||
const result = await createDueDeliveries(NOW);
|
||||
|
||||
expect(result).toEqual({ created: 0, deferred: 0 });
|
||||
expect(m.db.order.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("im Verzug ruht eine faellige Lieferung", async () => {
|
||||
m.db.subscriptionDelivery.findMany.mockResolvedValue([{ id: "del_1" }]);
|
||||
m.db.sponsorshipSubscription.findMany.mockResolvedValue([]);
|
||||
m.db.subscriptionDelivery.findUniqueOrThrow.mockResolvedValue({
|
||||
...delivery,
|
||||
subscription: { ...delivery.subscription, status: "IM_VERZUG" },
|
||||
});
|
||||
|
||||
await createDueDeliveries(NOW);
|
||||
|
||||
expect(m.db.order.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user