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 -13
View File
@@ -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<void> {
},
});
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);