feat: Rechnungen werden abgelegt, gesichert und mitversendet
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m12s

Die Rechnung entstand bisher nur als Datenbankzeile; das PDF wurde bei jedem
Abruf neu gerendert und tauchte in keiner E-Mail auf. Das hatte drei
Nachteile: Gastbestellungen haben kein Konto, in dem sie den Beleg finden
koennten; eine Sicherung der Datenbank sicherte den Beleg nicht mit; und ein
spaeter erneut gerendertes PDF konnte nach einer Aenderung an Firmendaten
oder Layout anders aussehen als das, was die Kundschaft in Haenden hielt.

Ablage jetzt unter INVOICE_DIR, nach Jahr und Kunde sortiert:

  invoices/2026/mustermann-erika/RE-2026-A3F9C1B2.pdf

Das Jahr zuerst, weil sich die Aufbewahrungsfrist danach richtet — ein
abgelaufener Jahrgang laesst sich als ganzes Verzeichnis archivieren. Der
relative Pfad steht als Invoice.pdfPath in der Datenbank, damit eine spaetere
Aenderung an der Pfadbildung keine bereits abgelegten Belege unauffindbar
macht. Dafuer wurde die nie gelesene Spalte "pdfUrl" umbenannt; eine URL war
ohnehin das falsche Konzept.

INVOICE_DIR ist bewusst ein ANDERES Verzeichnis als UPLOAD_DIR:
app/uploads/[...path] liefert jede Datei unterhalb von UPLOAD_DIR ohne
Anmeldung aus. Laegen Rechnungen dort, koennte jede Person, die den Pfad
erraet, fremde Rechnungen samt Anschrift und Bestellinhalt herunterladen.
Auf das Rechnungsverzeichnis zeigt keine oeffentliche Route; der Abruf laeuft
weiter ueber /rechnung/<nummer>/pdf mit Besitzpruefung.

Der Abruf liefert jetzt die abgelegte Datei aus und rendert nur neu, wenn
keine da ist — also bei Rechnungen aus der Zeit davor.

Versand: das PDF haengt an der Bestellbestaetigung. sendMail nimmt dafuer
optionale Anhaenge entgegen, alle zehn bestehenden Aufrufe bleiben
unveraendert. Schlaegt das Erzeugen oder Ablegen fehl, geht die Bestaetigung
trotzdem raus und die Rechnung bleibt abrufbar: ein 500er an Stripe waere
die falsche Antwort auf eine volle Platte, denn die Zahlung ist erfolgt und
ein Wiederholungslauf aenderte daran nichts.

Sicherung: der taegliche CronJob packt das Verzeichnis als
rechnungen-<Zeitstempel>.tar.gz neben den Datenbank-Dump und haengt das
Volume nur lesend ein. Vermerkt ist dort auch die Einschraenkung, dass
wabenhain-invoices als ReadWriteOnce nur von Pods auf demselben Knoten
einhaengbar ist — bei mehreren Knoten braucht es ReadWriteMany, sonst bleibt
der Sicherungs-Pod lautlos in "Pending".

Weiterhin offen und im README vermerkt: die Rechnungsnummer ist eindeutig,
aber nicht fortlaufend (§ 14 Abs. 4 Nr. 4 UStG), und das PDF weist keine
Umsatzsteuer aus.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5 committed 2026-09-17 04:12:44 +02:00
1 parent c525735285
commit 70ede77dde
14 files changed
+442 -11

No files matched your search

+77
View File
@@ -0,0 +1,77 @@
import { describe, test, expect } from "vitest";
import { invoiceRelativePath, resolveInvoicePath, INVOICE_DIR } from "./invoice-storage";
function makeInput(overrides: Partial<Parameters<typeof invoiceRelativePath>[0]> = {}) {
return {
year: 2026,
lastName: "Mustermann",
firstName: "Erika",
invoiceNumber: "RE-2026-A3F9C1B2",
...overrides,
};
}
describe("invoiceRelativePath", () => {
test("sortiert nach Jahr, dann Kunde, dann Rechnungsnummer", () => {
// Arrange & Act
const relative = invoiceRelativePath(makeInput());
// Assert
expect(relative).toBe("2026/mustermann-erika/RE-2026-A3F9C1B2.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"
);
});
test("legt Rechnungen derselben Person im selben Ordner ab", () => {
// Arrange
const first = invoiceRelativePath(makeInput({ invoiceNumber: "RE-2026-0001" }));
const second = invoiceRelativePath(makeInput({ invoiceNumber: "RE-2026-0002" }));
// Act & Assert: genau das ist der Zweck der Ablage — alle Belege einer
// Person eines Jahres beieinander.
expect(first.split("/").slice(0, 2)).toEqual(second.split("/").slice(0, 2));
expect(first).not.toBe(second);
});
test("trennt Jahrgaenge voneinander", () => {
// Arrange & Act
const older = invoiceRelativePath(makeInput({ year: 2025 }));
const newer = invoiceRelativePath(makeInput({ year: 2026 }));
// Assert: die Aufbewahrungsfrist laeuft je Jahr ab, ein Jahrgang muss
// sich als ganzes Verzeichnis archivieren lassen.
expect(older.startsWith("2025/")).toBe(true);
expect(newer.startsWith("2026/")).toBe(true);
});
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"
);
});
});
describe("resolveInvoicePath", () => {
test("loest innerhalb des Ablageverzeichnisses auf", () => {
// Arrange & Act
const resolved = resolveInvoicePath("2026/mustermann-erika/RE-2026-A3F9C1B2.pdf");
// Assert
expect(resolved.startsWith(INVOICE_DIR)).toBe(true);
});
test("weist Pfade ab, die das Verzeichnis verlassen", () => {
// Arrange & Act & Assert: der Pfad kommt zwar aus der eigenen Datenbank,
// ist aber irgendwann einmal aus Eingaben entstanden — "../" darf nie
// hinausfuehren.
expect(() => resolveInvoicePath("../../etc/passwd")).toThrow();
expect(() => resolveInvoicePath("2026/../../../geheim.pdf")).toThrow();
});
});
+112
View File
@@ -0,0 +1,112 @@
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"
);
/**
* 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-A3F9C1B2.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;
}
}
+21 -1
View File
@@ -1,10 +1,23 @@
import nodemailer from "nodemailer";
export interface MailAttachment {
filename: string;
content: Buffer;
contentType: string;
}
interface SendMailInput {
to: string;
subject: string;
html: string;
text: string;
/**
* Optionale Anhaenge, aktuell die Rechnung zur Bestellbestaetigung.
* Bewusst als Buffer und nicht als Pfad: der Aufrufer hat das Dokument
* ohnehin schon im Speicher, und so haengt der Mailversand nicht daran,
* dass das Ablageverzeichnis gerade erreichbar ist.
*/
attachments?: MailAttachment[];
}
/** Maskiert eine E-Mail-Adresse fuer Log-Ausgaben, z.B. "ma***@example.com". */
@@ -66,7 +79,14 @@ export async function sendMail(input: SendMailInput): Promise<void> {
}
try {
await transport.sendMail({ from, to: input.to, subject: input.subject, html: input.html, text: input.text });
await transport.sendMail({
from,
to: input.to,
subject: input.subject,
html: input.html,
text: input.text,
...(input.attachments?.length ? { attachments: input.attachments } : {}),
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
console.error(`[mail] Versand an ${maskEmail(input.to)} fehlgeschlagen: ${message}`);