Files
Wabenhain/src/lib/invoice-storage.test.ts
T
Nick SzaunigandClaude Opus 5 e525db84ef feat: fortlaufende Rechnungsnummer statt Zufallszahl
§ 14 Abs. 4 Nr. 4 UStG verlangt eine FORTLAUFENDE Rechnungsnummer. Bisher
stand dort "RE-2026-<8 Hex>": eindeutig ja, fortlaufend nein. Bei einer
Betriebsprüfung ließe sich damit nicht zeigen, dass keine Rechnung fehlt —
aus einer Lücke in einer Zufallsfolge lässt sich nichts ablesen, aus einer
Lücke in 0001, 0002, 0004 sehr wohl. Das war der letzte offene Rechtspunkt
vor dem Live-Gang, und der einzige, der sich ohne Absprache schließen ließ.

Die Nummer heißt jetzt RE-<Jahr>-<laufende Nummer>, also RE-2026-0001. Die
Zählung beginnt jedes Jahr neu bei 0001; das ist zulässig, solange das Jahr
Teil der Nummer ist.

Die laufende Nummer kommt aus der neuen Tabelle invoice_counters, eine Zeile
je Jahr. Der naheliegende Weg "höchste vorhandene Nummer + 1" wäre falsch:
zwei gleichzeitig eintreffende Stripe-Webhooks läsen denselben Höchstwert und
vergäben dieselbe Nummer. Das upsert mit { increment: 1 } sperrt die Zeile,
solange es zählt, und serialisiert damit die Vergabe.

Vor dem Hochzählen prüft dieselbe Transaktion, ob zur Bestellung bereits eine
Rechnung existiert. Stripe stellt denselben Webhook erneut zu, wenn die
Bestätigung ausbleibt — ohne diese Prüfung verbrauchte jeder Wiederholungs-
versuch eine Nummer und risse genau die Lücke, die hier vermieden werden
soll.

Vier Stellen halten die Nummern gleich lang und damit sortierbar; ab der
zehntausendsten Rechnung eines Jahres wächst die Nummer mit, statt
abgeschnitten zu werden — eine abgeschnittene Nummer wäre nicht mehr
eindeutig.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-18 01:26:18 +02:00

92 lines
3.5 KiB
TypeScript

import { describe, test, expect } from "vitest";
import path from "node:path";
import { invoiceRelativePath, resolveInvoicePath, INVOICE_DIR } from "./invoice-storage";
function makeInput(overrides: Partial<Parameters<typeof invoiceRelativePath>[0]> = {}) {
return {
year: 2026,
lastName: "Mustermann",
firstName: "Erika",
invoiceNumber: "RE-2026-0001",
...overrides,
};
}
describe("invoiceRelativePath", () => {
test("sortiert nach Jahr, dann Kunde, dann Rechnungsnummer", () => {
// Arrange & Act
const relative = invoiceRelativePath(makeInput());
// Assert
expect(relative).toBe("2026/mustermann-erika/RE-2026-0001.pdf");
});
test("schreibt Umlaute im Kundenordner aus", () => {
// Arrange & Act & Assert: eine blosse Filterung auf a-z machte aus
// "Mueller" ein "m-ller" und damit unleserliche Ordner.
expect(invoiceRelativePath(makeInput({ lastName: "Müller", firstName: "Jörg" }))).toBe(
"2026/mueller-joerg/RE-2026-0001.pdf"
);
});
test("legt Rechnungen derselben Person im selben Ordner ab", () => {
// Arrange
const first = invoiceRelativePath(makeInput({ invoiceNumber: "RE-2026-0001" }));
const second = invoiceRelativePath(makeInput({ invoiceNumber: "RE-2026-0002" }));
// Act & Assert: genau das ist der Zweck der Ablage — alle Belege einer
// Person eines Jahres beieinander.
expect(first.split("/").slice(0, 2)).toEqual(second.split("/").slice(0, 2));
expect(first).not.toBe(second);
});
test("trennt Jahrgaenge voneinander", () => {
// Arrange & Act
const older = invoiceRelativePath(makeInput({ year: 2025 }));
const newer = invoiceRelativePath(makeInput({ year: 2026 }));
// Assert: die Aufbewahrungsfrist laeuft je Jahr ab, ein Jahrgang muss
// sich als ganzes Verzeichnis archivieren lassen.
expect(older.startsWith("2025/")).toBe(true);
expect(newer.startsWith("2026/")).toBe(true);
});
test("faellt auf 'unbekannt' zurueck, statt ein leeres Pfadsegment zu erzeugen", () => {
// Arrange & Act & Assert: "2026//RE-....pdf" waere ein kaputter Pfad.
expect(invoiceRelativePath(makeInput({ lastName: "***", firstName: "###" }))).toBe(
"2026/unbekannt/RE-2026-0001.pdf"
);
});
});
describe("Trennung von der oeffentlichen Upload-Ablage", () => {
test("die Rechnungsablage liegt nicht im oeffentlich ausgelieferten Upload-Verzeichnis", () => {
// Arrange: app/uploads/[...path] liefert JEDE Datei unterhalb von
// UPLOAD_DIR ohne Anmeldung aus.
const uploads = path.resolve(process.env.UPLOAD_DIR || "./uploads");
// Act & Assert: laege INVOICE_DIR darin, waeren fremde Rechnungen samt
// Anschrift und Bestellinhalt fuer jeden abrufbar, der den Pfad erraet.
expect(INVOICE_DIR).not.toBe(uploads);
expect(INVOICE_DIR.startsWith(uploads + path.sep)).toBe(false);
});
});
describe("resolveInvoicePath", () => {
test("loest innerhalb des Ablageverzeichnisses auf", () => {
// Arrange & Act
const resolved = resolveInvoicePath("2026/mustermann-erika/RE-2026-0001.pdf");
// Assert
expect(resolved.startsWith(INVOICE_DIR)).toBe(true);
});
test("weist Pfade ab, die das Verzeichnis verlassen", () => {
// Arrange & Act & Assert: der Pfad kommt zwar aus der eigenen Datenbank,
// ist aber irgendwann einmal aus Eingaben entstanden — "../" darf nie
// hinausfuehren.
expect(() => resolveInvoicePath("../../etc/passwd")).toThrow();
expect(() => resolveInvoicePath("2026/../../../geheim.pdf")).toThrow();
});
});