§ 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]>
143 lines
5.8 KiB
TypeScript
143 lines
5.8 KiB
TypeScript
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
/**
|
|
* Ablage der erzeugten Rechnungs-PDFs.
|
|
*
|
|
* BEWUSST NICHT unter UPLOAD_DIR: app/uploads/[...path]/route.ts liefert
|
|
* JEDE Datei unterhalb von UPLOAD_DIR ohne Anmeldung aus. Eine Rechnung dort
|
|
* abzulegen hiesse, dass jede Person, die den Pfad erraet, fremde Rechnungen
|
|
* samt Anschrift und Bestellinhalt herunterladen kann. Rechnungen bekommen
|
|
* deshalb ein eigenes Verzeichnis, auf das keine oeffentliche Route zeigt.
|
|
*
|
|
* Warum ueberhaupt als Datei und nicht bei jedem Abruf neu erzeugt: eine
|
|
* Rechnung ist ein Beleg. Wuerde sie jedes Mal neu gerendert, saehe dieselbe
|
|
* Rechnungsnummer nach einer Aenderung an Firmendaten oder am Layout anders
|
|
* aus als das, was die Kundschaft per E-Mail bekommen hat. Abgelegt wird
|
|
* deshalb genau das Dokument, das auch versendet wurde.
|
|
*
|
|
* Verzeichnis ueber INVOICE_DIR einstellbar; in Kubernetes zeigt das auf ein
|
|
* eigenes, gesichertes Volume (siehe k8s/04-uploads-pvc.yaml und den
|
|
* Sicherungs-CronJob 07-backup-cronjob.yaml).
|
|
*/
|
|
export const INVOICE_DIR = path.resolve(
|
|
/*turbopackIgnore: true*/ process.env.INVOICE_DIR || "./invoices"
|
|
);
|
|
|
|
/**
|
|
* Stellt sicher, dass die Rechnungsablage NICHT innerhalb des oeffentlich
|
|
* ausgelieferten Upload-Verzeichnisses liegt.
|
|
*
|
|
* Die Trennung der beiden Verzeichnisse ist die einzige Sicherung dagegen,
|
|
* dass Rechnungen ueber app/uploads/[...path] abrufbar werden — jene Route
|
|
* liefert JEDE Datei unterhalb von UPLOAD_DIR ohne Anmeldung aus. Bisher hing
|
|
* diese Trennung allein daran, dass jemand INVOICE_DIR richtig setzt. Ein
|
|
* INVOICE_DIR="/app/uploads/rechnungen" haette genuegt, damit fremde
|
|
* Rechnungen samt Anschrift und Bestellinhalt oeffentlich abrufbar sind — und
|
|
* es waere nicht aufgefallen, denn funktioniert haette weiterhin alles.
|
|
*
|
|
* Deshalb eine harte Pruefung beim Laden des Moduls statt eines blossen
|
|
* Hinweises in der Dokumentation: lieber startet die Anwendung nicht, als
|
|
* dass sie Belege oeffentlich ausliefert.
|
|
*/
|
|
function assertInvoicesAreNotPubliclyServed(): void {
|
|
const uploads = path.resolve(/*turbopackIgnore: true*/ process.env.UPLOAD_DIR || "./uploads");
|
|
const withSep = uploads.endsWith(path.sep) ? uploads : uploads + path.sep;
|
|
if (INVOICE_DIR === uploads || INVOICE_DIR.startsWith(withSep)) {
|
|
throw new Error(
|
|
`INVOICE_DIR ("${INVOICE_DIR}") liegt innerhalb von UPLOAD_DIR ("${uploads}"). ` +
|
|
"Das Upload-Verzeichnis wird oeffentlich und ohne Anmeldung ausgeliefert — Rechnungen waeren damit " +
|
|
"fuer jeden abrufbar, der den Pfad erraet. Bitte ein eigenes Verzeichnis ausserhalb von UPLOAD_DIR waehlen."
|
|
);
|
|
}
|
|
}
|
|
|
|
assertInvoicesAreNotPubliclyServed();
|
|
|
|
/**
|
|
* Macht aus einem Namen einen Verzeichnisnamen: Kleinbuchstaben, Umlaute
|
|
* ausgeschrieben, alles Uebrige zu Bindestrichen. Ohne das Ausschreiben
|
|
* wuerde aus "Mueller" ein "m-ller".
|
|
*/
|
|
function slugifyNamePart(value: string): string {
|
|
return value
|
|
.toLowerCase()
|
|
.replace(/ä/g, "ae")
|
|
.replace(/ö/g, "oe")
|
|
.replace(/ü/g, "ue")
|
|
.replace(/ß/g, "ss")
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "");
|
|
}
|
|
|
|
export interface InvoicePathInput {
|
|
/** Kalenderjahr der Rechnung — die oberste Ebene der Ablage. */
|
|
year: number;
|
|
lastName: string;
|
|
firstName: string;
|
|
invoiceNumber: string;
|
|
}
|
|
|
|
/**
|
|
* Pfad einer Rechnung RELATIV zu INVOICE_DIR, nach Jahr und Kunde sortiert:
|
|
*
|
|
* 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.
|
|
*
|
|
* Bekannte Grenze: zwei verschiedene Personen mit identischem Namen teilen
|
|
* sich einen Ordner. Das ist Absicht — ein angehaengtes technisches Kuerzel
|
|
* machte die Ablage fuer Menschen schlechter durchsuchbar, und die
|
|
* Rechnungsnummer im Dateinamen bleibt ohnehin eindeutig.
|
|
*
|
|
* Faellt ein Name vollstaendig weg (nur Sonderzeichen), tritt "unbekannt" an
|
|
* seine Stelle, damit nie ein leeres Pfadsegment entsteht.
|
|
*/
|
|
export function invoiceRelativePath(input: InvoicePathInput): string {
|
|
const customer =
|
|
[slugifyNamePart(input.lastName), slugifyNamePart(input.firstName)].filter(Boolean).join("-") ||
|
|
"unbekannt";
|
|
return path.posix.join(String(input.year), customer, `${input.invoiceNumber}.pdf`);
|
|
}
|
|
|
|
/**
|
|
* Loest einen relativen Rechnungspfad gegen INVOICE_DIR auf und stellt
|
|
* sicher, dass er das Verzeichnis nicht verlaesst.
|
|
*
|
|
* Dieselbe Absicherung wie bei den Uploads (lib/uploads.ts): der relative
|
|
* Pfad stammt zwar aus der eigenen Datenbank, aber ein Pfad aus der Datenbank
|
|
* ist kein Beweis fuer einen harmlosen Pfad — er ist irgendwann einmal aus
|
|
* Eingaben entstanden. "../" darf hier nie hinausfuehren.
|
|
*/
|
|
export function resolveInvoicePath(relativePath: string): string {
|
|
const resolved = path.resolve(/*turbopackIgnore: true*/ INVOICE_DIR, relativePath);
|
|
const withSep = INVOICE_DIR.endsWith(path.sep) ? INVOICE_DIR : INVOICE_DIR + path.sep;
|
|
if (!resolved.startsWith(withSep)) {
|
|
throw new Error(`Rechnungspfad liegt ausserhalb von INVOICE_DIR: ${relativePath}`);
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
/** Legt das PDF ab und gibt den relativen Pfad zurueck, unter dem es liegt. */
|
|
export async function storeInvoicePdf(relativePath: string, pdf: Buffer): Promise<string> {
|
|
const target = resolveInvoicePath(relativePath);
|
|
await mkdir(path.dirname(target), { recursive: true });
|
|
await writeFile(target, pdf);
|
|
return relativePath;
|
|
}
|
|
|
|
/**
|
|
* Liest ein abgelegtes PDF, oder null, wenn es (noch) keines gibt.
|
|
*
|
|
* Null ist ein regulaerer Fall, kein Fehler: Rechnungen aus der Zeit vor
|
|
* dieser Ablage haben keine Datei, und der Abruf erzeugt sie dann eben neu.
|
|
*/
|
|
export async function readStoredInvoicePdf(relativePath: string): Promise<Buffer | null> {
|
|
try {
|
|
return await readFile(resolveInvoicePath(relativePath));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|