/** * Tests fuer den Rechnungsexport an ERPNext. * * Zwei Dinge duerfen hier nicht schiefgehen: der Export darf ohne den * richtigen Schluessel nichts preisgeben, und die Umsatzsteuer je Satz muss * genau der Rechnung entsprechen (ERPNext bucht sie unveraendert). */ import { describe, test, expect, vi, beforeEach } from "vitest"; const findMany = vi.fn(); const findCancellations = vi.fn(); vi.mock("@/lib/prisma", () => ({ prisma: { invoice: { findMany: (...args: unknown[]) => findMany(...args) }, cancellationInvoice: { findMany: (...args: unknown[]) => findCancellations(...args) }, }, })); vi.mock("@/lib/invoice-pdf", () => ({ generateInvoicePdf: vi.fn(async () => Buffer.from("pdf")) })); vi.mock("@/lib/invoice-storage", () => ({ readStoredInvoicePdf: vi.fn(async () => null) })); import { erpExportAuthorized, erpVatLines, isErpDocumentNumber, isInvoiceNumber, listErpCancellations, listErpInvoices, parseErpQuery, toErpInvoice, MIN_TOKEN_LENGTH, } from "./erp-export"; import { invoiceVatBreakdown } from "./vat"; const TOKEN = "t".repeat(MIN_TOKEN_LENGTH); describe("erpExportAuthorized", () => { test("laesst nur den genauen Bearer-Token durch", () => { expect(erpExportAuthorized(`Bearer ${TOKEN}`, TOKEN)).toBe(true); expect(erpExportAuthorized(`Bearer ${TOKEN}x`, TOKEN)).toBe(false); expect(erpExportAuthorized(TOKEN, TOKEN)).toBe(false); expect(erpExportAuthorized(null, TOKEN)).toBe(false); }); test("ist ohne oder mit zu kurzem Token abgeschaltet", () => { expect(erpExportAuthorized("Bearer ", "")).toBe(false); expect(erpExportAuthorized("Bearer kurz", "kurz")).toBe(false); expect(erpExportAuthorized(`Bearer ${TOKEN}`, undefined)).toBe(false); }); }); describe("parseErpQuery und isInvoiceNumber", () => { test("verlangt einen gueltigen Zeitpunkt und begrenzt das Limit", () => { expect(parseErpQuery(new URLSearchParams("seit=2026-01-01T00:00:00Z&limit=50"))).toEqual({ since: new Date("2026-01-01T00:00:00Z"), limit: 50, kind: "rechnung", }); expect(parseErpQuery(new URLSearchParams("seit=2026-01-01"))?.limit).toBe(200); expect(parseErpQuery(new URLSearchParams("seit=gestern"))).toBeNull(); expect(parseErpQuery(new URLSearchParams("seit=2026-01-01&limit=0"))).toBeNull(); expect(parseErpQuery(new URLSearchParams("seit=2026-01-01&limit=201"))).toBeNull(); }); test("liefert Stornorechnungen nur mit art=storno", () => { expect(parseErpQuery(new URLSearchParams("seit=2026-01-01&art=storno"))?.kind).toBe("storno"); expect(parseErpQuery(new URLSearchParams("seit=2026-01-01&art=alles"))).toBeNull(); }); test("erkennt nur Rechnungsnummern des Shops", () => { expect(isInvoiceNumber("RE-2026-0001")).toBe(true); expect(isInvoiceNumber("RE-2026-12345")).toBe(true); expect(isInvoiceNumber("../RE-2026-0001")).toBe(false); expect(isInvoiceNumber("RE-26-1")).toBe(false); expect(isErpDocumentNumber("ST-2026-0001")).toBe(true); expect(isErpDocumentNumber("ST-2026-0001.pdf")).toBe(false); }); }); describe("erpVatLines", () => { const order = { items: [ { totalCents: 2370, vatRatePercent: 7 }, { totalCents: 1250, vatRatePercent: 19 }, ], shippingCents: 590, discountCents: 300, }; test("entspricht der Steueraufstellung der Rechnung", () => { const lines = erpVatLines(order)!; const rechnung = invoiceVatBreakdown(order); expect(lines.map(({ ratePercent, grossCents, netCents, vatCents }) => ({ ratePercent, grossCents, netCents, vatCents }))).toEqual(rechnung); }); test("teilt Versand und Rabatt vollstaendig auf die Saetze auf", () => { const lines = erpVatLines(order)!; expect(lines.reduce((sum, l) => sum + l.shippingCents, 0)).toBe(590); expect(lines.reduce((sum, l) => sum + l.discountCents, 0)).toBe(300); for (const line of lines) { const ware = order.items.filter((i) => i.vatRatePercent === line.ratePercent).reduce((s, i) => s + i.totalCents, 0); expect(ware + line.shippingCents - line.discountCents).toBe(line.grossCents); } }); test("liefert null, wenn eine Position keinen Steuersatz hat", () => { expect(erpVatLines({ items: [{ totalCents: 100, vatRatePercent: null }], shippingCents: 0, discountCents: 0 })).toBeNull(); }); }); const record = { id: "inv1", orderId: "ord1", invoiceNumber: "RE-2026-0007", issuedAt: new Date("2026-10-01T10:00:00Z"), totalCents: 3620, pdfPath: "2026/kaeufer-max/RE-2026-0007.pdf", order: { id: "ord1", orderNumber: "WH-1007", customerId: null, status: "BEZAHLT", fulfillmentMethod: "VERSAND", locale: "de", email: "max@example.com", firstName: "Max", lastName: "Kaeufer", phone: "+49 821 1", street: "Lindenstrasse 5", zip: "86150", city: "Augsburg", country: "DE", packstationNumber: null, postNumber: null, subtotalCents: 3620, shippingCents: 590, discountCents: 590, totalCents: 3620, currency: "EUR", discountCodeId: "d1", createdAt: new Date("2026-10-01T09:58:00Z"), discountCode: { code: "VERSANDFREI" }, payment: { provider: "stripe", stripePaymentIntentId: "pi_123", status: "SUCCEEDED" }, items: [ { productId: "p1", productName: "Waldhonig 500 g", quantity: 3, unitPriceCents: 790, totalCents: 2370, weightGrams: 500, vatRatePercent: 7, product: { slug: "waldhonig-2026", category: "HONIG" }, }, { productId: "p2", productName: "Bienenwachskerze", quantity: 1, unitPriceCents: 1250, totalCents: 1250, weightGrams: 120, vatRatePercent: 19, product: { slug: "kerze-gross", category: "KERZEN" }, }, ], }, }; describe("toErpInvoice", () => { test("uebergibt Rechnung, Zahlung, Positionen und Steuer, aber keine Telefonnummer", () => { const erp = toErpInvoice(record as never); expect(erp.invoiceNumber).toBe("RE-2026-0007"); expect(erp.payment?.stripePaymentIntentId).toBe("pi_123"); expect(erp.order).toMatchObject({ orderNumber: "WH-1007", discountCode: "VERSANDFREI", fulfillmentMethod: "VERSAND" }); expect(erp.items).toEqual([ expect.objectContaining({ productSlug: "waldhonig-2026", category: "HONIG", quantity: 3, totalCents: 2370, vatRatePercent: 7 }), expect.objectContaining({ productSlug: "kerze-gross", category: "KERZEN", quantity: 1, totalCents: 1250, vatRatePercent: 19 }), ]); expect(erp.vat?.reduce((sum, l) => sum + l.grossCents, 0)).toBe(3620); expect(JSON.stringify(erp)).not.toContain("+49 821 1"); }); }); describe("listErpInvoices", () => { beforeEach(() => findMany.mockReset()); test("holt ab dem Zeitpunkt, aelteste zuerst, hoechstens limit", async () => { findMany.mockResolvedValue([record]); const since = new Date("2026-10-01T00:00:00Z"); const result = await listErpInvoices(since, 50); expect(result).toHaveLength(1); expect(findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { issuedAt: { gte: since } }, orderBy: [{ issuedAt: "asc" }, { id: "asc" }], take: 50 }) ); expect(result[0]).toMatchObject({ type: "invoice", cancelledBy: null }); }); }); describe("listErpCancellations", () => { beforeEach(() => findCancellations.mockReset()); test("liefert die Stornorechnung mit Positionen der Rechnung und Bezug", async () => { // Arrange findCancellations.mockResolvedValue([ { id: "st1", number: "ST-2026-0002", issuedAt: new Date("2026-10-05T08:00:00Z"), totalCents: 3620, reason: "STORNIERUNG", invoice: { ...record, cancellation: { number: "ST-2026-0002" } }, }, ]); // Act const [erp] = await listErpCancellations(new Date("2026-10-01T00:00:00Z"), 10); // Assert expect(erp).toMatchObject({ type: "cancellation", invoiceNumber: "ST-2026-0002", issuedAt: "2026-10-05T08:00:00.000Z", totalCents: 3620, reason: "STORNIERUNG", cancels: { invoiceNumber: "RE-2026-0007", issuedAt: "2026-10-01T10:00:00.000Z" }, }); expect(erp.items).toHaveLength(2); expect(erp.vat?.reduce((sum, line) => sum + line.grossCents, 0)).toBe(3620); }); });