feat(erp): read-only invoice export for ERPNext

ERPNext (collab.vycept.com) now picks up every shop invoice with its PDF,
books it as a Wabenhain sales invoice and matches it to the Stripe payment
via the PaymentIntent, so fees, revenue and the invoice document sit on one
booking.

- GET /api/erp/rechnungen?seit=<ISO>&limit=<1-200>: invoices oldest first,
  with order, payment intent, customer address (no phone, no notes), items
  and the VAT split per rate exactly as printed (lib/vat.ts), including the
  shipping and discount share per rate, so ERPNext books the same cents.
- GET /api/erp/rechnungen/<RE-YYYY-NNNN>/pdf: the stored invoice PDF.
- Bearer token ERP_EXPORT_TOKEN (min. 32 chars, timing-safe compare);
  without it the export is off. Failed attempts are rate limited.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-10 04:39:11 +02:00
1 parent 7698d4cef3
commit b246c9ff4e
6 files changed
+417

No files matched your search

+176
View File
@@ -0,0 +1,176 @@
/**
* 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();
vi.mock("@/lib/prisma", () => ({ prisma: { invoice: { findMany: (...args: unknown[]) => findMany(...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,
isInvoiceNumber,
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 });
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("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);
});
});
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: "[email protected]",
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 })
);
});
});