/** * 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: "
", 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: "erika@example.org", 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 }); // Bezahlt-Nachweis fuer den DHL-Ablauf (admin/actions/dhl.ts verlangt eine erfolgreiche Zahlung). expect(order.payment.create).toMatchObject({ provider: "abo", status: "SUCCEEDED", amountCents: 0, paidAt: expect.any(Date) }); 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: "erika@example.org", 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(); }); });