feat(invoice): Stornorechnung und Rechnungskopie an Admin-Adresse
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]>
This commit is contained in:
1 parent
508ff24aa7
commit
4d1bcbbdb7
22 files changed
+809
-16
No files matched your search
@@ -8,14 +8,22 @@
|
||||
import { describe, test, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const findMany = vi.fn();
|
||||
vi.mock("@/lib/prisma", () => ({ prisma: { invoice: { findMany: (...args: unknown[]) => findMany(...args) } } }));
|
||||
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,
|
||||
@@ -42,18 +50,29 @@ describe("erpExportAuthorized", () => {
|
||||
|
||||
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-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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -172,5 +191,39 @@ describe("listErpInvoices", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user