diff --git a/README.md b/README.md index ce00b68..9859a4b 100644 --- a/README.md +++ b/README.md @@ -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: ``` -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. @@ -328,7 +328,15 @@ Der Satz wird je **Bestellposition** als `OrderItem.vatRatePercent` festgeschrie `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--`, 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 diff --git a/prisma/migrations/20260918010000_invoice_counter/migration.sql b/prisma/migrations/20260918010000_invoice_counter/migration.sql new file mode 100644 index 0000000..3f66e3d --- /dev/null +++ b/prisma/migrations/20260918010000_invoice_counter/migration.sql @@ -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") +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 28a5029..e838617 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -538,6 +538,26 @@ model Pickup { @@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 { id String @id @default(cuid()) orderId String @unique @@ -549,7 +569,7 @@ model Invoice { // Satz haengt deshalb an der einzelnen Position (OrderItem.vatRatePercent), // und die Aufstellung je Satz rechnet lib/vat.ts daraus aus. /// 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 /// Abruf weiterhin neu erzeugt. Der Pfad wird gespeichert statt jedes Mal diff --git a/src/app/api/checkout/webhook/route.ts b/src/app/api/checkout/webhook/route.ts index bea3a26..031cc9c 100644 --- a/src/app/api/checkout/webhook/route.ts +++ b/src/app/api/checkout/webhook/route.ts @@ -1,5 +1,4 @@ import { NextResponse } from "next/server"; -import { randomBytes } from "node:crypto"; import { getStripeClient } from "@/lib/stripe"; import { prisma } from "@/lib/prisma"; import { sendMail } from "@/lib/mail"; @@ -9,6 +8,7 @@ import { getBaseUrl } from "@/lib/url"; import { getInvoiceByNumber } from "@/lib/account-data"; import { generateInvoicePdf } from "@/lib/invoice-pdf"; import { invoiceRelativePath, storeInvoicePdf } from "@/lib/invoice-storage"; +import { formatInvoiceNumber } from "@/lib/invoice-number"; import { isLocale, defaultLocale } from "@/i18n/config"; import type Stripe from "stripe"; @@ -200,18 +200,39 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise { }, }); - const invoice = await prisma.invoice.upsert({ - where: { orderId: order.id }, - create: { - orderId: order.id, - invoiceNumber: `RE-${new Date().getFullYear()}-${randomBytes(4).toString("hex").toUpperCase()}`, - 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). - }, - update: {}, + // Rechnungsnummer und Rechnung in EINER Transaktion: die Zeilensperre auf + // dem Jahreszaehler haelt eine zweite, gleichzeitig bezahlte Bestellung so + // lange auf, bis diese hier festgeschrieben ist. Ohne das laesen beide + // denselben Stand und bekaemen dieselbe Nummer. + // + // Der Zaehler wird NUR beim tatsaechlichen Anlegen hochgezaehlt. Stripe + // stellt denselben Webhook mehrfach zu; ein blindes Hochzaehlen vor der + // Existenzpruefung risse bei jedem Wiederholungsversuch eine Luecke in die + // Nummernfolge — und eine Luecke ist genau das, was § 14 Abs. 4 Nr. 4 UStG + // ausschliessen soll. + const invoice = await prisma.$transaction(async (tx) => { + 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); diff --git a/src/lib/invoice-number.test.ts b/src/lib/invoice-number.test.ts new file mode 100644 index 0000000..e8d6108 --- /dev/null +++ b/src/lib/invoice-number.test.ts @@ -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"); + }); +}); diff --git a/src/lib/invoice-number.ts b/src/lib/invoice-number.ts new file mode 100644 index 0000000..0a1a38f --- /dev/null +++ b/src/lib/invoice-number.ts @@ -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")}`; +} diff --git a/src/lib/invoice-storage.test.ts b/src/lib/invoice-storage.test.ts index d331e78..4d3e92f 100644 --- a/src/lib/invoice-storage.test.ts +++ b/src/lib/invoice-storage.test.ts @@ -7,7 +7,7 @@ function makeInput(overrides: Partial[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); diff --git a/src/lib/invoice-storage.ts b/src/lib/invoice-storage.ts index b85e277..a6b2d68 100644 --- a/src/lib/invoice-storage.ts +++ b/src/lib/invoice-storage.ts @@ -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.