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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-12 06:19:42 +02:00
1 parent 508ff24aa7
commit 4d1bcbbdb7
22 files changed
+809 -16

No files matched your search

+125
View File
@@ -0,0 +1,125 @@
/**
* Tests fuer die Stornorechnung: hoechstens eine je Rechnung, auch wenn
* Admin-Stornierung und Stripe-Webhook derselben Erstattung gleichzeitig
* ankommen, und sie geht an die Kundschaft und als Kopie hinaus.
*/
import { describe, test, expect, vi, beforeEach } from "vitest";
const tx = {
cancellationCounter: { upsert: vi.fn() },
cancellationInvoice: { create: vi.fn() },
};
const db = {
invoice: { findUnique: vi.fn() },
cancellationInvoice: { findUnique: vi.fn(), update: vi.fn() },
$transaction: vi.fn(async (run: (client: typeof tx) => Promise<unknown>) => run(tx)),
};
vi.mock("@/lib/prisma", () => ({ prisma: db }));
const generateInvoicePdf = vi.fn(async () => Buffer.from("storno-pdf"));
vi.mock("@/lib/invoice-pdf", () => ({ generateInvoicePdf: (...args: unknown[]) => generateInvoicePdf(...(args as [])) }));
const storeInvoicePdf = vi.fn(async () => undefined);
vi.mock("@/lib/invoice-storage", () => ({
invoiceRelativePath: vi.fn(() => "2026/musterfrau-erika/ST-2026-0003.pdf"),
storeInvoicePdf: (...args: unknown[]) => storeInvoicePdf(...(args as [])),
readStoredInvoicePdf: vi.fn(async () => null),
}));
const sendMail = vi.fn(async () => true);
vi.mock("@/lib/mail", () => ({ sendMail: (...args: unknown[]) => sendMail(...(args as [])) }));
vi.mock("@/lib/email-templates", () => ({
cancellationInvoiceEmail: vi.fn(async () => ({ subject: "Stornorechnung", html: "<p>x</p>", text: "x" })),
}));
const sendInvoiceCopy = vi.fn(async () => true);
vi.mock("@/lib/invoice-mail", () => ({ sendInvoiceCopy: (...args: unknown[]) => sendInvoiceCopy(...(args as [])) }));
const { issueCancellationInvoice, isCancellationNumber } = await import("./cancellation-invoice");
const order = {
orderNumber: "WH-2026-0042",
email: "[email protected]",
firstName: "Erika",
lastName: "Musterfrau",
locale: "de",
items: [],
};
const loaded = {
number: "ST-2026-0003",
issuedAt: new Date("2026-10-14T09:00:00Z"),
pdfPath: null,
invoice: { invoiceNumber: "RE-2026-0042", issuedAt: new Date("2026-10-12T10:00:00Z"), totalCents: 5375, order },
};
beforeEach(() => {
vi.clearAllMocks();
db.invoice.findUnique.mockResolvedValue({ id: "inv1", totalCents: 5375, cancellation: null });
tx.cancellationCounter.upsert.mockResolvedValue({ lastNumber: 3 });
tx.cancellationInvoice.create.mockImplementation(async ({ data }: { data: { number: string } }) => ({ number: data.number }));
db.cancellationInvoice.findUnique.mockResolvedValue(loaded);
});
describe("issueCancellationInvoice", () => {
test("ohne Rechnung gibt es nichts aufzuheben", async () => {
db.invoice.findUnique.mockResolvedValue(null);
expect(await issueCancellationInvoice("order1", "STORNIERUNG")).toBeNull();
expect(db.$transaction).not.toHaveBeenCalled();
});
test("legt die Stornorechnung mit eigener Nummer an und verschickt sie mit Kopie", async () => {
// Act
const result = await issueCancellationInvoice("order1", "STORNIERUNG");
// Assert
const year = new Date().getFullYear();
expect(result).toEqual({ number: `ST-${year}-0003`, created: true });
expect(tx.cancellationInvoice.create).toHaveBeenCalledWith(
expect.objectContaining({ data: { invoiceId: "inv1", number: `ST-${year}-0003`, totalCents: 5375, reason: "STORNIERUNG" } })
);
expect(generateInvoicePdf).toHaveBeenCalledWith(
expect.objectContaining({ invoiceNumber: "RE-2026-0042", cancellation: { number: "ST-2026-0003", issuedAt: loaded.issuedAt } })
);
expect(storeInvoicePdf).toHaveBeenCalledOnce();
expect(sendMail).toHaveBeenCalledWith(
expect.objectContaining({ to: "[email protected]", attachments: [expect.objectContaining({ filename: `ST-${year}-0003.pdf` })] })
);
expect(sendInvoiceCopy).toHaveBeenCalledWith(expect.objectContaining({ title: "Stornorechnung", orderNumber: "WH-2026-0042" }));
});
test("zweiter Ausloeser findet die vorhandene Stornorechnung, ohne erneut zu verschicken", async () => {
db.invoice.findUnique.mockResolvedValue({ id: "inv1", totalCents: 5375, cancellation: { number: "ST-2026-0001" } });
expect(await issueCancellationInvoice("order1", "ERSTATTUNG")).toEqual({ number: "ST-2026-0001", created: false });
expect(db.$transaction).not.toHaveBeenCalled();
expect(sendMail).not.toHaveBeenCalled();
});
test("gleichzeitig angelegt: die zweite Transaktion scheitert eindeutig und liefert die erste", async () => {
// Arrange: die Datenbank lehnt die zweite Stornorechnung zur selben Rechnung ab.
tx.cancellationInvoice.create.mockRejectedValue(Object.assign(new Error("Unique constraint failed"), { code: "P2002" }));
db.cancellationInvoice.findUnique.mockResolvedValue({ number: "ST-2026-0001" });
// Act
const result = await issueCancellationInvoice("order1", "ERSTATTUNG");
// Assert
expect(result).toEqual({ number: "ST-2026-0001", created: false });
expect(sendMail).not.toHaveBeenCalled();
});
test("ein Fehler beim Verschicken laesst die Stornorechnung bestehen", async () => {
generateInvoicePdf.mockRejectedValueOnce(new Error("Schrift fehlt"));
const errors = vi.spyOn(console, "error").mockImplementation(() => undefined);
const result = await issueCancellationInvoice("order1", "RETOURE");
expect(result?.created).toBe(true);
expect(errors).toHaveBeenCalled();
errors.mockRestore();
});
});
test("isCancellationNumber erkennt nur ST-Nummern", () => {
expect(isCancellationNumber("ST-2026-0001")).toBe(true);
expect(isCancellationNumber("RE-2026-0001")).toBe(false);
expect(isCancellationNumber("ST-2026-0001/../x")).toBe(false);
});