Rechnungskopie: unter Einstellungen > Firma > "Rechnungskopie an" laesst sich eine Adresse hinterlegen; jede Rechnung und Stornorechnung geht zusaetzlich als eigene Mail mit derselben E-Rechnung dorthin (lib/invoice-mail.ts). Stornorechnung: eine Rechnung wird nie geloescht. Bei Stornierung im Admin, erstatteter Retoure oder voller Erstattung bei Stripe entsteht genau eine Stornorechnung ST-JJJJ-NNNN (eigener lueckenloser Nummernkreis) mit allen Positionen und Bezug auf die Rechnung, als E-Rechnung (Gutschrift 381) an die Kundschaft und als Kopie. Gleichzeitige Ausloeser (Admin-Storno und Stripe-Webhook derselben Erstattung) enden bei einer Stornorechnung. Bestellungen mit Stornorechnung lassen sich nicht wieder oeffnen. ERP-Export: GET /api/erp/rechnungen?art=storno liefert Stornorechnungen mit Bezug (cancels), Rechnungen tragen cancelledBy; PDFs auch fuer ST-Nummern. Admin-Bestelldetail zeigt die Stornorechnung mit Download. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
230 lines
8.1 KiB
TypeScript
230 lines
8.1 KiB
TypeScript
/**
|
|
* 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: "[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 })
|
|
);
|
|
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);
|
|
});
|
|
});
|