§ 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]>
92 lines
3.5 KiB
TypeScript
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();
|
|
});
|
|
});
|