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]>
This commit is contained in:
Nick SzaunigandClaude Opus 5 committed 2026-09-18 01:26:18 +02:00
1 parent 7fe1a01550
commit e525db84ef
8 files changed
+144 -22

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
import { describe, test, expect } from "vitest";
import { formatInvoiceNumber } from "./invoice-number";
describe("formatInvoiceNumber", () => {
test("beginnt jedes Jahr bei 0001", () => {
// Arrange & Act & Assert
expect(formatInvoiceNumber(2026, 1)).toBe("RE-2026-0001");
expect(formatInvoiceNumber(2027, 1)).toBe("RE-2027-0001");
});
test("zaehlt fortlaufend weiter", () => {
// Arrange & Act & Assert: genau darum geht es — § 14 Abs. 4 Nr. 4 UStG
// verlangt eine fortlaufende, nicht bloss eine eindeutige Nummer.
expect(formatInvoiceNumber(2026, 2)).toBe("RE-2026-0002");
expect(formatInvoiceNumber(2026, 42)).toBe("RE-2026-0042");
});
test("haelt die Nummern gleich lang, damit sie sortierbar bleiben", () => {
// Arrange
const numbers = [1, 9, 10, 999].map((n) => formatInvoiceNumber(2026, n));
// Act & Assert: alphabetisch sortiert ergibt sich dieselbe Reihenfolge wie
// numerisch — bei ungepolsterten Nummern kaeme "RE-2026-10" vor
// "RE-2026-9".
expect([...numbers].sort()).toEqual(numbers);
});
test("waechst ueber vier Stellen hinaus, statt abzuschneiden", () => {
// Arrange & Act & Assert: eine abgeschnittene Nummer waere nicht mehr
// eindeutig — lieber eine Stelle mehr als eine doppelte Rechnungsnummer.
expect(formatInvoiceNumber(2026, 9999)).toBe("RE-2026-9999");
expect(formatInvoiceNumber(2026, 10000)).toBe("RE-2026-10000");
});
});
+24
View File
@@ -0,0 +1,24 @@
/**
* Aufbau der Rechnungsnummer.
*
* § 14 Abs. 4 Nr. 4 UStG verlangt eine FORTLAUFENDE Nummer, die der
* Rechnungsaussteller einmalig vergibt. Vorher stand hier eine Zufallszahl:
* eindeutig ja, fortlaufend nein — bei einer Betriebspruefung liesse sich
* damit nicht zeigen, dass keine Rechnung fehlt.
*
* Die laufende Nummer selbst kommt aus der Tabelle invoice_counters, deren
* Zeilensperre beim Hochzaehlen sicherstellt, dass keine Nummer zweimal
* vergeben wird (siehe prisma/schema.prisma).
*/
/**
* Mindestbreite der laufenden Nummer. Vier Stellen halten die Nummern gleich
* lang und damit sortierbar; gibt es in einem Jahr mehr als 9999 Rechnungen,
* waechst die Nummer einfach mit, statt abgeschnitten zu werden.
*/
const SEQUENCE_DIGITS = 4;
/** Bildet "RE-2026-0001" aus Jahr und laufender Nummer. */
export function formatInvoiceNumber(year: number, sequence: number): string {
return `RE-${year}-${String(sequence).padStart(SEQUENCE_DIGITS, "0")}`;
}
+5 -5
View File
@@ -7,7 +7,7 @@ function makeInput(overrides: Partial<Parameters<typeof invoiceRelativePath>[0]>
year: 2026,
lastName: "Mustermann",
firstName: "Erika",
invoiceNumber: "RE-2026-A3F9C1B2",
invoiceNumber: "RE-2026-0001",
...overrides,
};
}
@@ -18,14 +18,14 @@ describe("invoiceRelativePath", () => {
const relative = invoiceRelativePath(makeInput());
// Assert
expect(relative).toBe("2026/mustermann-erika/RE-2026-A3F9C1B2.pdf");
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-A3F9C1B2.pdf"
"2026/mueller-joerg/RE-2026-0001.pdf"
);
});
@@ -54,7 +54,7 @@ describe("invoiceRelativePath", () => {
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-A3F9C1B2.pdf"
"2026/unbekannt/RE-2026-0001.pdf"
);
});
});
@@ -75,7 +75,7 @@ describe("Trennung von der oeffentlichen Upload-Ablage", () => {
describe("resolveInvoicePath", () => {
test("loest innerhalb des Ablageverzeichnisses auf", () => {
// Arrange & Act
const resolved = resolveInvoicePath("2026/mustermann-erika/RE-2026-A3F9C1B2.pdf");
const resolved = resolveInvoicePath("2026/mustermann-erika/RE-2026-0001.pdf");
// Assert
expect(resolved.startsWith(INVOICE_DIR)).toBe(true);
+1 -1
View File
@@ -81,7 +81,7 @@ export interface InvoicePathInput {
/**
* Pfad einer Rechnung RELATIV zu INVOICE_DIR, nach Jahr und Kunde sortiert:
*
* 2026/mustermann-erika/RE-2026-A3F9C1B2.pdf
* 2026/mustermann-erika/RE-2026-0001.pdf
*
* Das Jahr steht zuerst, weil sich die Aufbewahrungsfrist danach richtet: ein
* abgelaufener Jahrgang laesst sich als ganzes Verzeichnis archivieren.