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:
1 parent
7fe1a01550
commit
e525db84ef
8 files changed
+144
-22
No files matched your search
@@ -289,7 +289,7 @@ Sobald Stripe eine Zahlung bestätigt, erzeugt der Webhook (`api/checkout/webhoo
|
|||||||
**Ablage** unter `INVOICE_DIR`, nach Jahr und Kunde sortiert:
|
**Ablage** unter `INVOICE_DIR`, nach Jahr und Kunde sortiert:
|
||||||
|
|
||||||
```
|
```
|
||||||
invoices/2026/mustermann-erika/RE-2026-A3F9C1B2.pdf
|
invoices/2026/mustermann-erika/RE-2026-0001.pdf
|
||||||
```
|
```
|
||||||
|
|
||||||
Das Jahr steht zuerst, weil sich die Aufbewahrungsfrist danach richtet: ein abgelaufener Jahrgang lässt sich als ganzes Verzeichnis archivieren. Der Pfad wird als `Invoice.pdfPath` gespeichert, damit eine spätere Änderung an der Pfadbildung keine bereits abgelegten Belege unauffindbar macht. Zwei Personen mit identischem Namen teilen sich einen Ordner — bewusst, denn ein technisches Kürzel im Ordnernamen macht die Ablage für Menschen schlechter durchsuchbar, und die Rechnungsnummer im Dateinamen ist ohnehin eindeutig.
|
Das Jahr steht zuerst, weil sich die Aufbewahrungsfrist danach richtet: ein abgelaufener Jahrgang lässt sich als ganzes Verzeichnis archivieren. Der Pfad wird als `Invoice.pdfPath` gespeichert, damit eine spätere Änderung an der Pfadbildung keine bereits abgelegten Belege unauffindbar macht. Zwei Personen mit identischem Namen teilen sich einen Ordner — bewusst, denn ein technisches Kürzel im Ordnernamen macht die Ablage für Menschen schlechter durchsuchbar, und die Rechnungsnummer im Dateinamen ist ohnehin eindeutig.
|
||||||
@@ -328,7 +328,15 @@ Der Satz wird je **Bestellposition** als `OrderItem.vatRatePercent` festgeschrie
|
|||||||
|
|
||||||
`pnpm preflight` blockiert einen fehlenden oder unplausiblen `VAT_RATE_PERCENT`.
|
`pnpm preflight` blockiert einen fehlenden oder unplausiblen `VAT_RATE_PERCENT`.
|
||||||
|
|
||||||
**Noch offen:** die Rechnungsnummer (`RE-2026-<8 Hex>`) ist eindeutig, aber **nicht fortlaufend**. § 14 Abs. 4 Nr. 4 UStG verlangt eine fortlaufende Nummer.
|
### Rechnungsnummer
|
||||||
|
|
||||||
|
Die Nummer hat die Form `RE-<Jahr>-<laufende Nummer>`, zum Beispiel `RE-2026-0001`. § 14 Abs. 4 Nr. 4 UStG verlangt eine **fortlaufende** Nummer — eine bloß eindeutige genügt nicht, weil sich mit ihr bei einer Betriebsprüfung nicht zeigen ließe, dass keine Rechnung fehlt. Die Zählung beginnt in jedem Jahr wieder bei `0001`; das ist zulässig, solange das Jahr Teil der Nummer ist.
|
||||||
|
|
||||||
|
Die laufende Nummer kommt aus der Tabelle `invoice_counters` (eine Zeile je Jahr) und wird zusammen mit der Rechnung in **einer** Transaktion vergeben. Eine Vergabe nach dem Muster „höchste vorhandene Nummer + 1“ wäre anfällig dafür, dass zwei gleichzeitig eintreffende Stripe-Webhooks dieselbe Nummer lesen und zweimal vergeben; die Zeilensperre beim Hochzählen schließt das aus.
|
||||||
|
|
||||||
|
Vor dem Hochzählen prüft dieselbe Transaktion, ob zur Bestellung bereits eine Rechnung existiert. Stripe stellt denselben Webhook bei ausbleibender Bestätigung erneut zu — ohne diese Prüfung verbrauchte jeder Wiederholungsversuch eine Nummer und riße eine Lücke in die Folge.
|
||||||
|
|
||||||
|
Vier Stellen halten die Nummern gleich lang und damit sortierbar. Gibt es in einem Jahr mehr als 9999 Rechnungen, wächst die Nummer mit, statt abgeschnitten zu werden.
|
||||||
|
|
||||||
## Health-Endpunkte
|
## Health-Endpunkte
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- Laufende Rechnungsnummer je Kalenderjahr.
|
||||||
|
--
|
||||||
|
-- § 14 Abs. 4 Nr. 4 UStG verlangt eine fortlaufende Nummer. Bisher wurde eine
|
||||||
|
-- Zufallsnummer vergeben: eindeutig, aber nicht fortlaufend.
|
||||||
|
--
|
||||||
|
-- Eigene Tabelle statt "hoechste bestehende Nummer + 1": beim Hochzaehlen
|
||||||
|
-- sperrt die Datenbank diese eine Zeile, wodurch zwei gleichzeitig bezahlte
|
||||||
|
-- Bestellungen zwangslaeufig verschiedene Nummern erhalten.
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "invoice_counters" (
|
||||||
|
"year" INTEGER NOT NULL,
|
||||||
|
"lastNumber" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
CONSTRAINT "invoice_counters_pkey" PRIMARY KEY ("year")
|
||||||
|
);
|
||||||
+21
-1
@@ -538,6 +538,26 @@ model Pickup {
|
|||||||
@@map("pickups")
|
@@map("pickups")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Laufende Nummer je Kalenderjahr fuer die Rechnungsstellung.
|
||||||
|
///
|
||||||
|
/// § 14 Abs. 4 Nr. 4 UStG verlangt eine FORTLAUFENDE Nummer. Eine
|
||||||
|
/// Zufallsnummer waere zwar eindeutig, aber nicht fortlaufend — bei einer
|
||||||
|
/// Betriebspruefung liesse sich damit nicht zeigen, dass keine Rechnung
|
||||||
|
/// fehlt.
|
||||||
|
///
|
||||||
|
/// Eigene Tabelle statt "hoechste bestehende Nummer + 1": beim Hochzaehlen
|
||||||
|
/// sperrt die Datenbank genau diese eine Zeile, zwei gleichzeitig bezahlte
|
||||||
|
/// Bestellungen bekommen dadurch zwangslaeufig verschiedene Nummern. Ein
|
||||||
|
/// Maximum-Abfragen waere dagegen ein klassisches Wettrennen — beide lesen
|
||||||
|
/// dieselbe hoechste Nummer, beide zaehlen auf denselben Wert hoch.
|
||||||
|
model InvoiceCounter {
|
||||||
|
/// Kalenderjahr; je Jahr beginnt die Zaehlung neu bei 1.
|
||||||
|
year Int @id
|
||||||
|
lastNumber Int @default(0)
|
||||||
|
|
||||||
|
@@map("invoice_counters")
|
||||||
|
}
|
||||||
|
|
||||||
model Invoice {
|
model Invoice {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
orderId String @unique
|
orderId String @unique
|
||||||
@@ -549,7 +569,7 @@ model Invoice {
|
|||||||
// Satz haengt deshalb an der einzelnen Position (OrderItem.vatRatePercent),
|
// Satz haengt deshalb an der einzelnen Position (OrderItem.vatRatePercent),
|
||||||
// und die Aufstellung je Satz rechnet lib/vat.ts daraus aus.
|
// und die Aufstellung je Satz rechnet lib/vat.ts daraus aus.
|
||||||
/// Pfad des abgelegten PDFs, RELATIV zu INVOICE_DIR — z.B.
|
/// Pfad des abgelegten PDFs, RELATIV zu INVOICE_DIR — z.B.
|
||||||
/// "2026/mustermann-erika/RE-2026-A3F9C1B2.pdf". Siehe lib/invoice-storage.ts.
|
/// "2026/mustermann-erika/RE-2026-0001.pdf". Siehe lib/invoice-storage.ts.
|
||||||
///
|
///
|
||||||
/// Null bei Rechnungen aus der Zeit vor dieser Ablage: deren PDF wird beim
|
/// Null bei Rechnungen aus der Zeit vor dieser Ablage: deren PDF wird beim
|
||||||
/// Abruf weiterhin neu erzeugt. Der Pfad wird gespeichert statt jedes Mal
|
/// Abruf weiterhin neu erzeugt. Der Pfad wird gespeichert statt jedes Mal
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { randomBytes } from "node:crypto";
|
|
||||||
import { getStripeClient } from "@/lib/stripe";
|
import { getStripeClient } from "@/lib/stripe";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { sendMail } from "@/lib/mail";
|
import { sendMail } from "@/lib/mail";
|
||||||
@@ -9,6 +8,7 @@ import { getBaseUrl } from "@/lib/url";
|
|||||||
import { getInvoiceByNumber } from "@/lib/account-data";
|
import { getInvoiceByNumber } from "@/lib/account-data";
|
||||||
import { generateInvoicePdf } from "@/lib/invoice-pdf";
|
import { generateInvoicePdf } from "@/lib/invoice-pdf";
|
||||||
import { invoiceRelativePath, storeInvoicePdf } from "@/lib/invoice-storage";
|
import { invoiceRelativePath, storeInvoicePdf } from "@/lib/invoice-storage";
|
||||||
|
import { formatInvoiceNumber } from "@/lib/invoice-number";
|
||||||
import { isLocale, defaultLocale } from "@/i18n/config";
|
import { isLocale, defaultLocale } from "@/i18n/config";
|
||||||
import type Stripe from "stripe";
|
import type Stripe from "stripe";
|
||||||
|
|
||||||
@@ -200,18 +200,39 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise<void> {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const invoice = await prisma.invoice.upsert({
|
// Rechnungsnummer und Rechnung in EINER Transaktion: die Zeilensperre auf
|
||||||
where: { orderId: order.id },
|
// dem Jahreszaehler haelt eine zweite, gleichzeitig bezahlte Bestellung so
|
||||||
create: {
|
// lange auf, bis diese hier festgeschrieben ist. Ohne das laesen beide
|
||||||
orderId: order.id,
|
// denselben Stand und bekaemen dieselbe Nummer.
|
||||||
invoiceNumber: `RE-${new Date().getFullYear()}-${randomBytes(4).toString("hex").toUpperCase()}`,
|
//
|
||||||
totalCents: order.totalCents,
|
// Der Zaehler wird NUR beim tatsaechlichen Anlegen hochgezaehlt. Stripe
|
||||||
// Kein Steuersatz an der Rechnung: er haengt an der einzelnen
|
// stellt denselben Webhook mehrfach zu; ein blindes Hochzaehlen vor der
|
||||||
// Position, weil eine Bestellung Waren mit verschiedenen Saetzen
|
// Existenzpruefung risse bei jedem Wiederholungsversuch eine Luecke in die
|
||||||
// enthalten kann (Honig 7%, Bienenwachskerze 19%). Festgeschrieben
|
// Nummernfolge — und eine Luecke ist genau das, was § 14 Abs. 4 Nr. 4 UStG
|
||||||
// wird er beim Anlegen der Bestellung (api/checkout/route.ts).
|
// ausschliessen soll.
|
||||||
},
|
const invoice = await prisma.$transaction(async (tx) => {
|
||||||
update: {},
|
const existing = await tx.invoice.findUnique({ where: { orderId: order.id } });
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
const counter = await tx.invoiceCounter.upsert({
|
||||||
|
where: { year },
|
||||||
|
create: { year, lastNumber: 1 },
|
||||||
|
update: { lastNumber: { increment: 1 } },
|
||||||
|
select: { lastNumber: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return tx.invoice.create({
|
||||||
|
data: {
|
||||||
|
orderId: order.id,
|
||||||
|
invoiceNumber: formatInvoiceNumber(year, counter.lastNumber),
|
||||||
|
totalCents: order.totalCents,
|
||||||
|
// Kein Steuersatz an der Rechnung: er haengt an der einzelnen
|
||||||
|
// Position, weil eine Bestellung Waren mit verschiedenen Saetzen
|
||||||
|
// enthalten kann (Honig 7%, Bienenwachskerze 19%). Festgeschrieben
|
||||||
|
// wird er beim Anlegen der Bestellung (api/checkout/route.ts).
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const invoicePdf = await storeInvoiceDocument(invoice.invoiceNumber);
|
const invoicePdf = await storeInvoiceDocument(invoice.invoiceNumber);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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")}`;
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ function makeInput(overrides: Partial<Parameters<typeof invoiceRelativePath>[0]>
|
|||||||
year: 2026,
|
year: 2026,
|
||||||
lastName: "Mustermann",
|
lastName: "Mustermann",
|
||||||
firstName: "Erika",
|
firstName: "Erika",
|
||||||
invoiceNumber: "RE-2026-A3F9C1B2",
|
invoiceNumber: "RE-2026-0001",
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -18,14 +18,14 @@ describe("invoiceRelativePath", () => {
|
|||||||
const relative = invoiceRelativePath(makeInput());
|
const relative = invoiceRelativePath(makeInput());
|
||||||
|
|
||||||
// Assert
|
// 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", () => {
|
test("schreibt Umlaute im Kundenordner aus", () => {
|
||||||
// Arrange & Act & Assert: eine blosse Filterung auf a-z machte aus
|
// Arrange & Act & Assert: eine blosse Filterung auf a-z machte aus
|
||||||
// "Mueller" ein "m-ller" und damit unleserliche Ordner.
|
// "Mueller" ein "m-ller" und damit unleserliche Ordner.
|
||||||
expect(invoiceRelativePath(makeInput({ lastName: "Müller", firstName: "Jörg" }))).toBe(
|
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", () => {
|
test("faellt auf 'unbekannt' zurueck, statt ein leeres Pfadsegment zu erzeugen", () => {
|
||||||
// Arrange & Act & Assert: "2026//RE-....pdf" waere ein kaputter Pfad.
|
// Arrange & Act & Assert: "2026//RE-....pdf" waere ein kaputter Pfad.
|
||||||
expect(invoiceRelativePath(makeInput({ lastName: "***", firstName: "###" }))).toBe(
|
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", () => {
|
describe("resolveInvoicePath", () => {
|
||||||
test("loest innerhalb des Ablageverzeichnisses auf", () => {
|
test("loest innerhalb des Ablageverzeichnisses auf", () => {
|
||||||
// Arrange & Act
|
// Arrange & Act
|
||||||
const resolved = resolveInvoicePath("2026/mustermann-erika/RE-2026-A3F9C1B2.pdf");
|
const resolved = resolveInvoicePath("2026/mustermann-erika/RE-2026-0001.pdf");
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(resolved.startsWith(INVOICE_DIR)).toBe(true);
|
expect(resolved.startsWith(INVOICE_DIR)).toBe(true);
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export interface InvoicePathInput {
|
|||||||
/**
|
/**
|
||||||
* Pfad einer Rechnung RELATIV zu INVOICE_DIR, nach Jahr und Kunde sortiert:
|
* 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
|
* Das Jahr steht zuerst, weil sich die Aufbewahrungsfrist danach richtet: ein
|
||||||
* abgelaufener Jahrgang laesst sich als ganzes Verzeichnis archivieren.
|
* abgelaufener Jahrgang laesst sich als ganzes Verzeichnis archivieren.
|
||||||
|
|||||||
Reference in new issue
Block a user