feat: Rechnungen werden abgelegt, gesichert und mitversendet
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m12s
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:
1 parent
c525735285
commit
70ede77dde
14 files changed
+442
-11
No files matched your search
@@ -199,6 +199,21 @@ ENTRA_REDIRECT_URI=http://localhost:3000/api/auth/entra/callback
|
|||||||
# zeigen.
|
# zeigen.
|
||||||
UPLOAD_DIR=./uploads
|
UPLOAD_DIR=./uploads
|
||||||
|
|
||||||
|
# Ablage der Rechnungs-PDFs, nach Jahr und Kunde sortiert:
|
||||||
|
# invoices/2026/mustermann-erika/RE-2026-A3F9C1B2.pdf
|
||||||
|
# Lokal reicht der Default "./invoices" (per .gitignore ausgeschlossen).
|
||||||
|
#
|
||||||
|
# MUSS ein anderes Verzeichnis sein als UPLOAD_DIR: auf die Uploads zeigt
|
||||||
|
# eine oeffentliche Route (app/uploads/[...path]), die JEDE Datei darunter
|
||||||
|
# ohne Anmeldung ausliefert. Laegen Rechnungen dort, koennte jede Person, die
|
||||||
|
# den Pfad erraet, fremde Rechnungen samt Anschrift herunterladen.
|
||||||
|
#
|
||||||
|
# In Produktion ein eigenes, gesichertes Volume: Rechnungen unterliegen in
|
||||||
|
# Deutschland zehn Jahren Aufbewahrungspflicht (§ 147 AO), und das PDF liegt
|
||||||
|
# NUR hier — die Datenbank kennt lediglich Nummer, Betrag und Pfad. Siehe
|
||||||
|
# k8s/04-uploads-pvc.yaml und den Sicherungs-CronJob k8s/07-backup-cronjob.yaml.
|
||||||
|
INVOICE_DIR=./invoices
|
||||||
|
|
||||||
# Threads, die ffmpeg beim Umrechnen eines hochgeladenen Videos benutzen darf
|
# Threads, die ffmpeg beim Umrechnen eines hochgeladenen Videos benutzen darf
|
||||||
# (Admin -> Inhalte -> Hero-Video). Leer lassen heisst 2, erlaubt sind 1 bis 8.
|
# (Admin -> Inhalte -> Hero-Video). Leer lassen heisst 2, erlaubt sind 1 bis 8.
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -53,3 +53,6 @@ next-env.d.ts
|
|||||||
# lokale Datei-Uploads (UPLOAD_DIR-Default, siehe .env.example) — in
|
# lokale Datei-Uploads (UPLOAD_DIR-Default, siehe .env.example) — in
|
||||||
# Produktion ein Volume-Mount, hier nur ein lokales Testverzeichnis
|
# Produktion ein Volume-Mount, hier nur ein lokales Testverzeichnis
|
||||||
/uploads
|
/uploads
|
||||||
|
|
||||||
|
# Lokal abgelegte Rechnungs-PDFs (INVOICE_DIR) — Belege gehoeren nie ins Repository.
|
||||||
|
/invoices
|
||||||
@@ -254,6 +254,31 @@ Prüft die Konfiguration gegen alles, was einen öffentlichen Shop still unbrauc
|
|||||||
|
|
||||||
Unabhängig davon bleiben die Rechtstexte (AGB, Datenschutz, Widerrufsbelehrung) Platzhalter und **müssen** vor dem Live-Gang anwaltlich geprüft werden — das kann kein Skript feststellen.
|
Unabhängig davon bleiben die Rechtstexte (AGB, Datenschutz, Widerrufsbelehrung) Platzhalter und **müssen** vor dem Live-Gang anwaltlich geprüft werden — das kann kein Skript feststellen.
|
||||||
|
|
||||||
|
## Rechnungen
|
||||||
|
|
||||||
|
Sobald Stripe eine Zahlung bestätigt, erzeugt der Webhook (`api/checkout/webhook`) die Rechnung, legt sie als PDF ab und hängt sie an die Bestellbestätigung. Ohne bestätigte Zahlung gibt es keine Rechnung — fehlt `STRIPE_WEBHOOK_SECRET`, bleibt jede Bestellung auf „NEU" stehen.
|
||||||
|
|
||||||
|
**Ablage** unter `INVOICE_DIR`, nach Jahr und Kunde sortiert:
|
||||||
|
|
||||||
|
```
|
||||||
|
invoices/2026/mustermann-erika/RE-2026-A3F9C1B2.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.
|
||||||
|
|
||||||
|
**`INVOICE_DIR` muss ein anderes Verzeichnis sein als `UPLOAD_DIR`.** Auf die Uploads zeigt mit `app/uploads/[...path]` eine öffentliche Route, die jede Datei darunter ohne Anmeldung ausliefert. Lägen Rechnungen dort, könnte jede Person, die den Pfad errät, fremde Rechnungen samt Anschrift und Bestellinhalt herunterladen. Auf das Rechnungsverzeichnis zeigt keine Route; der Abruf läuft ausschließlich über `/rechnung/<nummer>/pdf` und prüft, ob die Rechnung der angemeldeten Person gehört (Admins sehen alle).
|
||||||
|
|
||||||
|
**Abgelegt wird genau das Dokument, das versendet wurde.** Der Abruf liefert die Datei aus und rendert nur dann neu, wenn keine vorhanden ist. Sonst sähe dieselbe Rechnungsnummer nach einer Änderung an Firmendaten oder Layout anders aus als der Beleg beim Kunden.
|
||||||
|
|
||||||
|
**Sicherung:** der CronJob in `k8s/07-backup-cronjob.yaml` packt das Verzeichnis täglich als `rechnungen-<Zeitstempel>.tar.gz` neben den Datenbank-Dump. Ein Datenbank-Dump allein genügt nicht — die Datenbank kennt nur Nummer, Betrag und Pfad, das PDF liegt ausschließlich auf dem Volume.
|
||||||
|
|
||||||
|
> Bei **mehr als einem Knoten**: das PVC `wabenhain-invoices` ist `ReadWriteOnce` und lässt sich nur von Pods auf demselben Knoten einhängen. Landet der Sicherungs-Pod woanders als die App, bleibt er in „Pending" — ohne Fehlermeldung. Dann das PVC auf `ReadWriteMany` umstellen (NFS/SMB/CephFS).
|
||||||
|
|
||||||
|
**Noch offen** und vor dem Live-Gang zu klären:
|
||||||
|
|
||||||
|
- Die Rechnungsnummer (`RE-2026-<8 Hex>`) ist eindeutig, aber **nicht fortlaufend**. § 14 Abs. 4 Nr. 4 UStG verlangt eine fortlaufende Nummer.
|
||||||
|
- Das PDF weist **keine Umsatzsteuer** aus — weder Satz und Betrag noch den Hinweis nach § 19 UStG bei Kleinunternehmerregelung. Welches von beidem nötig ist, hängt vom Steuerstatus der Imkerei ab.
|
||||||
|
|
||||||
## Health-Endpunkte
|
## Health-Endpunkte
|
||||||
|
|
||||||
Zwei getrennte Endpunkte, bewusst mit unterschiedlicher Aufgabe:
|
Zwei getrennte Endpunkte, bewusst mit unterschiedlicher Aufgabe:
|
||||||
|
|||||||
@@ -54,6 +54,14 @@ data:
|
|||||||
# nirgends zugesichert. Deshalb hier ausdruecklich setzen.
|
# nirgends zugesichert. Deshalb hier ausdruecklich setzen.
|
||||||
UPLOAD_DIR: "/app/uploads"
|
UPLOAD_DIR: "/app/uploads"
|
||||||
|
|
||||||
|
# Ablage der Rechnungs-PDFs, nach Jahr und Kunde sortiert:
|
||||||
|
# /app/invoices/2026/mustermann-erika/RE-2026-A3F9C1B2.pdf
|
||||||
|
# Muss zum mountPath des "invoices"-Volumes in 05-app-deployment.yaml
|
||||||
|
# passen. BEWUSST ein anderes Verzeichnis als UPLOAD_DIR: auf die Uploads
|
||||||
|
# zeigt eine oeffentliche Route, auf dieses Verzeichnis keine. Siehe
|
||||||
|
# src/lib/invoice-storage.ts und 04-uploads-pvc.yaml.
|
||||||
|
INVOICE_DIR: "/app/invoices"
|
||||||
|
|
||||||
# Threads fuer das Umrechnen hochgeladener Videos (Admin -> Inhalte ->
|
# Threads fuer das Umrechnen hochgeladener Videos (Admin -> Inhalte ->
|
||||||
# Hero-Video). NICHT ohne Not erhoehen: ffmpeg teilt sich das Speicherlimit
|
# Hero-Video). NICHT ohne Not erhoehen: ffmpeg teilt sich das Speicherlimit
|
||||||
# dieses Pods mit dem Node-Prozess, und jeder zusaetzliche Thread haelt
|
# dieses Pods mit dem Node-Prozess, und jeder zusaetzliche Thread haelt
|
||||||
|
|||||||
@@ -38,3 +38,33 @@ spec:
|
|||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
storage: 5Gi
|
storage: 5Gi
|
||||||
|
---
|
||||||
|
# Ablage der Rechnungs-PDFs (INVOICE_DIR, siehe 01-configmap.yaml und
|
||||||
|
# src/lib/invoice-storage.ts), sortiert nach Jahr und Kunde:
|
||||||
|
# /app/invoices/2026/mustermann-erika/RE-2026-A3F9C1B2.pdf
|
||||||
|
#
|
||||||
|
# BEWUSST EIN EIGENES VOLUME, nicht ein Unterordner von "uploads":
|
||||||
|
# app/uploads/[...path]/route.ts liefert JEDE Datei unterhalb von UPLOAD_DIR
|
||||||
|
# ohne Anmeldung aus. Laegen die Rechnungen dort, koennte jede Person, die den
|
||||||
|
# Pfad erraet, fremde Rechnungen samt Anschrift und Bestellinhalt
|
||||||
|
# herunterladen. Auf dieses Volume zeigt keine oeffentliche Route.
|
||||||
|
#
|
||||||
|
# Zweiter Grund fuer die Trennung: die Aufbewahrungsfristen sind
|
||||||
|
# verschieden. Produktfotos lassen sich ersetzen; Rechnungen unterliegen in
|
||||||
|
# Deutschland zehn Jahren Aufbewahrungspflicht (§ 147 AO). Dieses Volume
|
||||||
|
# sichert der CronJob in 07-backup-cronjob.yaml taeglich mit.
|
||||||
|
#
|
||||||
|
# 2Gi sind reichlich bemessen: eine Rechnung ist wenige Kilobyte gross, das
|
||||||
|
# reicht fuer weit mehr Bestellungen, als die Aufbewahrungsfrist umfasst.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: wabenhain-invoices
|
||||||
|
namespace: wabenhain
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
# storageClassName: "dein-storage-class"
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 2Gi
|
||||||
@@ -77,6 +77,12 @@ spec:
|
|||||||
# (fehlende Volume-Referenz).
|
# (fehlende Volume-Referenz).
|
||||||
- name: uploads
|
- name: uploads
|
||||||
mountPath: /app/uploads
|
mountPath: /app/uploads
|
||||||
|
# Rechnungsablage, getrennt von den Uploads — auf dieses
|
||||||
|
# Verzeichnis zeigt bewusst KEINE oeffentliche Route. Muss zu
|
||||||
|
# INVOICE_DIR in 01-configmap.yaml passen, ausfuehrliche
|
||||||
|
# Begruendung in 04-uploads-pvc.yaml.
|
||||||
|
- name: invoices
|
||||||
|
mountPath: /app/invoices
|
||||||
# Prueft die Datenbank mit (siehe app/api/health/ready/route.ts).
|
# Prueft die Datenbank mit (siehe app/api/health/ready/route.ts).
|
||||||
# Schlaegt sie fehl, nimmt Kubernetes den Pod nur aus dem
|
# Schlaegt sie fehl, nimmt Kubernetes den Pod nur aus dem
|
||||||
# Load-Balancing, startet ihn aber nicht neu.
|
# Load-Balancing, startet ihn aber nicht neu.
|
||||||
@@ -158,6 +164,9 @@ spec:
|
|||||||
- name: uploads
|
- name: uploads
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
claimName: wabenhain-uploads
|
claimName: wabenhain-uploads
|
||||||
|
- name: invoices
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: wabenhain-invoices
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Service
|
kind: Service
|
||||||
|
|||||||
@@ -105,14 +105,25 @@ spec:
|
|||||||
| gzip -9 > "${target}.partial"
|
| gzip -9 > "${target}.partial"
|
||||||
mv "${target}.partial" "${target}"
|
mv "${target}.partial" "${target}"
|
||||||
|
|
||||||
|
# Rechnungsablage mitsichern (INVOICE_DIR, siehe
|
||||||
|
# 04-uploads-pvc.yaml). Die Datenbank kennt von einer
|
||||||
|
# Rechnung nur Nummer, Betrag und Pfad — das ausgelieferte
|
||||||
|
# PDF liegt ausschliesslich auf dem Volume. Ein
|
||||||
|
# Datenbank-Dump allein ersetzt den Beleg also NICHT.
|
||||||
|
invoices="/backup/rechnungen-${stamp}.tar.gz"
|
||||||
|
tar -czf "${invoices}.partial" -C /invoices .
|
||||||
|
mv "${invoices}.partial" "${invoices}"
|
||||||
|
|
||||||
# Angefangene Dateien frueherer Abbrueche mit aufraeumen.
|
# Angefangene Dateien frueherer Abbrueche mit aufraeumen.
|
||||||
find /backup -name 'wabenhain-*.sql.gz.partial' -mtime +1 -delete
|
find /backup -name 'wabenhain-*.sql.gz.partial' -mtime +1 -delete
|
||||||
|
find /backup -name 'rechnungen-*.tar.gz.partial' -mtime +1 -delete
|
||||||
|
|
||||||
# 30 Tage Aufbewahrung. Achtung: ersetzt KEINE
|
# 30 Tage Aufbewahrung. Achtung: ersetzt KEINE
|
||||||
# langfristige Archivierung der Rechnungen (§ 147 AO,
|
# langfristige Archivierung der Rechnungen (§ 147 AO,
|
||||||
# zehn Jahre) — dafuer die Rechnungs-PDFs bzw. regelmaessig
|
# zehn Jahre) — dafuer die Rechnungs-PDFs bzw. regelmaessig
|
||||||
# ausgelagerte Sicherungen nutzen.
|
# ausgelagerte Sicherungen nutzen.
|
||||||
find /backup -name 'wabenhain-*.sql.gz' -mtime +30 -delete
|
find /backup -name 'wabenhain-*.sql.gz' -mtime +30 -delete
|
||||||
|
find /backup -name 'rechnungen-*.tar.gz' -mtime +30 -delete
|
||||||
|
|
||||||
echo "Sicherung abgeschlossen:"
|
echo "Sicherung abgeschlossen:"
|
||||||
ls -lh /backup
|
ls -lh /backup
|
||||||
@@ -125,7 +136,24 @@ spec:
|
|||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: backup
|
- name: backup
|
||||||
mountPath: /backup
|
mountPath: /backup
|
||||||
|
# Nur lesend: dieser Job darf Belege sichern, niemals aendern.
|
||||||
|
- name: invoices
|
||||||
|
mountPath: /invoices
|
||||||
|
readOnly: true
|
||||||
volumes:
|
volumes:
|
||||||
- name: backup
|
- name: backup
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
claimName: wabenhain-db-backup
|
claimName: wabenhain-db-backup
|
||||||
|
# ACHTUNG bei mehr als einem Knoten: wabenhain-invoices ist
|
||||||
|
# ReadWriteOnce (04-uploads-pvc.yaml). Ein solches Volume laesst
|
||||||
|
# sich nur von Pods auf DEMSELBEN Knoten einhaengen. Landet dieser
|
||||||
|
# Sicherungs-Pod auf einem anderen Knoten als der App-Pod, bleibt
|
||||||
|
# er in "Pending" haengen — sichtbar nur, wenn man nachsieht,
|
||||||
|
# denn ein Pending-Pod meldet keinen Fehler.
|
||||||
|
#
|
||||||
|
# Auf einem Ein-Knoten-Cluster ist das kein Thema. Bei mehreren
|
||||||
|
# Knoten das PVC auf ReadWriteMany umstellen (NFS/SMB/CephFS) —
|
||||||
|
# dann greift die Einschraenkung nicht mehr.
|
||||||
|
- name: invoices
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: wabenhain-invoices
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Die Spalte hiess "pdfUrl", wurde aber von keiner Code-Stelle gelesen. Eine
|
||||||
|
-- URL waere auch das falsche Konzept: das Rechnungs-PDF liegt bewusst NICHT
|
||||||
|
-- unter einem oeffentlich abrufbaren Pfad, sondern in einem eigenen,
|
||||||
|
-- gesicherten Verzeichnis (INVOICE_DIR, siehe src/lib/invoice-storage.ts).
|
||||||
|
--
|
||||||
|
-- Reines Umbenennen: der Typ bleibt TEXT und nullable, und da nie etwas in
|
||||||
|
-- die Spalte geschrieben wurde, aendert sich an keiner Zeile ein Wert.
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "invoices" RENAME COLUMN "pdfUrl" TO "pdfPath";
|
||||||
+12
-1
@@ -524,7 +524,18 @@ model Invoice {
|
|||||||
invoiceNumber String @unique
|
invoiceNumber String @unique
|
||||||
issuedAt DateTime @default(now())
|
issuedAt DateTime @default(now())
|
||||||
totalCents Int
|
totalCents Int
|
||||||
pdfUrl String?
|
/// Pfad des abgelegten PDFs, RELATIV zu INVOICE_DIR — z.B.
|
||||||
|
/// "2026/mustermann-erika/RE-2026-A3F9C1B2.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
|
||||||
|
/// aus Jahr und Namen neu berechnet, damit eine spaetere Aenderung an der
|
||||||
|
/// Pfadbildung keine bereits abgelegten Belege unauffindbar macht.
|
||||||
|
///
|
||||||
|
/// Hiess vorher "pdfUrl" und wurde nirgends gelesen — eine URL waere auch
|
||||||
|
/// das falsche Konzept gewesen: das Dokument liegt bewusst NICHT unter
|
||||||
|
/// einem oeffentlich abrufbaren Pfad.
|
||||||
|
pdfPath String?
|
||||||
|
|
||||||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,59 @@ import { sendMail } from "@/lib/mail";
|
|||||||
import { orderConfirmationEmail, paymentFailedEmail } from "@/lib/email-templates";
|
import { orderConfirmationEmail, paymentFailedEmail } from "@/lib/email-templates";
|
||||||
import { restockOrderItems } from "@/lib/inventory";
|
import { restockOrderItems } from "@/lib/inventory";
|
||||||
import { getBaseUrl } from "@/lib/url";
|
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 { isLocale, defaultLocale } from "@/i18n/config";
|
import { isLocale, defaultLocale } from "@/i18n/config";
|
||||||
import type Stripe from "stripe";
|
import type Stripe from "stripe";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt das Rechnungs-PDF, legt es nach Jahr und Kunde sortiert ab und
|
||||||
|
* vermerkt den Pfad an der Rechnung. Gibt das Dokument zurueck, damit es der
|
||||||
|
* Bestellbestaetigung angehaengt werden kann — oder null, wenn das nicht
|
||||||
|
* geklappt hat.
|
||||||
|
*
|
||||||
|
* Bewusst mit eigener Fehlerbehandlung statt im umgebenden try/catch: dort
|
||||||
|
* fuehrt jeder Fehler zu einem 500er an Stripe und damit zu einem erneuten
|
||||||
|
* Zustellversuch. Fuer eine volle Platte oder ein nicht eingehaengtes Volume
|
||||||
|
* waere das die falsche Antwort — die Zahlung ist erfolgt, die Bestellung
|
||||||
|
* steht, und die Rechnung laesst sich jederzeit nachtraeglich abrufen
|
||||||
|
* (/rechnung/<nummer>/pdf erzeugt sie dann neu). Ein Wiederholungslauf
|
||||||
|
* wuerde daran nichts aendern, nur die Bestaetigungsmail verzoegern.
|
||||||
|
*
|
||||||
|
* Gerendert wird aus derselben Quelle wie der spaetere Abruf
|
||||||
|
* (getInvoiceByNumber), damit abgelegtes, versendetes und spaeter
|
||||||
|
* angezeigtes Dokument garantiert dasselbe sind.
|
||||||
|
*/
|
||||||
|
async function storeInvoiceDocument(invoiceNumber: string): Promise<Buffer | null> {
|
||||||
|
try {
|
||||||
|
const invoice = await getInvoiceByNumber(invoiceNumber);
|
||||||
|
if (!invoice) return null;
|
||||||
|
|
||||||
|
const pdf = await generateInvoicePdf({
|
||||||
|
locale: isLocale(invoice.order.locale) ? invoice.order.locale : defaultLocale,
|
||||||
|
invoiceNumber: invoice.invoiceNumber,
|
||||||
|
issuedAt: invoice.issuedAt,
|
||||||
|
totalCents: invoice.totalCents,
|
||||||
|
order: invoice.order,
|
||||||
|
});
|
||||||
|
|
||||||
|
const relativePath = invoiceRelativePath({
|
||||||
|
year: invoice.issuedAt.getFullYear(),
|
||||||
|
lastName: invoice.order.lastName,
|
||||||
|
firstName: invoice.order.firstName,
|
||||||
|
invoiceNumber: invoice.invoiceNumber,
|
||||||
|
});
|
||||||
|
await storeInvoicePdf(relativePath, pdf);
|
||||||
|
await prisma.invoice.update({ where: { id: invoice.id }, data: { pdfPath: relativePath } });
|
||||||
|
|
||||||
|
return pdf;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error(`[invoice] ${invoiceNumber} konnte nicht abgelegt werden:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verarbeitet Stripe-Webhook-Events. In Stripe muss ein Webhook-Endpoint auf
|
* Verarbeitet Stripe-Webhook-Events. In Stripe muss ein Webhook-Endpoint auf
|
||||||
* /api/checkout/webhook mit den Events "checkout.session.completed",
|
* /api/checkout/webhook mit den Events "checkout.session.completed",
|
||||||
@@ -150,7 +200,7 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise<void> {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await prisma.invoice.upsert({
|
const invoice = await prisma.invoice.upsert({
|
||||||
where: { orderId: order.id },
|
where: { orderId: order.id },
|
||||||
create: {
|
create: {
|
||||||
orderId: order.id,
|
orderId: order.id,
|
||||||
@@ -160,6 +210,8 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise<void> {
|
|||||||
update: {},
|
update: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const invoicePdf = await storeInvoiceDocument(invoice.invoiceNumber);
|
||||||
|
|
||||||
const { subject, html, text } = await orderConfirmationEmail({
|
const { subject, html, text } = await orderConfirmationEmail({
|
||||||
locale: isLocale(order.locale) ? order.locale : defaultLocale,
|
locale: isLocale(order.locale) ? order.locale : defaultLocale,
|
||||||
orderNumber: order.orderNumber,
|
orderNumber: order.orderNumber,
|
||||||
@@ -181,7 +233,27 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise<void> {
|
|||||||
giftMessage: order.giftMessage,
|
giftMessage: order.giftMessage,
|
||||||
trackingUrl: `${getBaseUrl()}/bestellung-verfolgen?order=${encodeURIComponent(order.orderNumber)}&email=${encodeURIComponent(order.email)}`,
|
trackingUrl: `${getBaseUrl()}/bestellung-verfolgen?order=${encodeURIComponent(order.orderNumber)}&email=${encodeURIComponent(order.email)}`,
|
||||||
});
|
});
|
||||||
await sendMail({ to: order.email, subject, html, text });
|
await sendMail({
|
||||||
|
to: order.email,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
text,
|
||||||
|
// Die Rechnung liegt der Bestaetigung bei, statt nur im Konto zu
|
||||||
|
// warten: Gastbestellungen haben gar kein Konto, und ein Beleg, den
|
||||||
|
// man suchen muss, wird nicht abgeheftet. Fehlt das PDF (siehe
|
||||||
|
// storeInvoiceDocument), geht die Bestaetigung trotzdem raus.
|
||||||
|
...(invoicePdf
|
||||||
|
? {
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
filename: `${invoice.invoiceNumber}.pdf`,
|
||||||
|
content: invoicePdf,
|
||||||
|
contentType: "application/pdf",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
// Laut sichtbar machen (statt wie zuvor unbehandelt bis in den
|
// Laut sichtbar machen (statt wie zuvor unbehandelt bis in den
|
||||||
// Next.js-Request-Handler durchzureichen) und weiterwerfen: der 500er
|
// Next.js-Request-Handler durchzureichen) und weiterwerfen: der 500er
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|||||||
import { getCurrentCustomer } from "@/lib/auth";
|
import { getCurrentCustomer } from "@/lib/auth";
|
||||||
import { getInvoiceForCustomer, getInvoiceByNumber } from "@/lib/account-data";
|
import { getInvoiceForCustomer, getInvoiceByNumber } from "@/lib/account-data";
|
||||||
import { generateInvoicePdf } from "@/lib/invoice-pdf";
|
import { generateInvoicePdf } from "@/lib/invoice-pdf";
|
||||||
|
import { readStoredInvoicePdf } from "@/lib/invoice-storage";
|
||||||
import { isLocale, defaultLocale } from "@/i18n/config";
|
import { isLocale, defaultLocale } from "@/i18n/config";
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
@@ -23,13 +24,24 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: "Rechnung nicht gefunden." }, { status: 404 });
|
return NextResponse.json({ error: "Rechnung nicht gefunden." }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const pdf = await generateInvoicePdf({
|
// Zuerst das abgelegte Dokument: eine Rechnung ist ein Beleg, und der muss
|
||||||
locale: isLocale(invoice.order.locale) ? invoice.order.locale : defaultLocale,
|
// spaeter genauso aussehen wie das, was die Kundschaft per E-Mail bekommen
|
||||||
invoiceNumber: invoice.invoiceNumber,
|
// hat. Ein Neu-Rendern wuerde nach einer Aenderung an Firmendaten oder am
|
||||||
issuedAt: invoice.issuedAt,
|
// Layout ein ABWEICHENDES Dokument unter derselben Rechnungsnummer
|
||||||
totalCents: invoice.totalCents,
|
// ausliefern (siehe lib/invoice-storage.ts).
|
||||||
order: invoice.order,
|
//
|
||||||
});
|
// Neu erzeugt wird nur, wenn keine Datei da ist: bei Rechnungen aus der
|
||||||
|
// Zeit vor dieser Ablage, oder wenn das Ablegen seinerzeit fehlschlug.
|
||||||
|
const stored = invoice.pdfPath ? await readStoredInvoicePdf(invoice.pdfPath) : null;
|
||||||
|
const pdf =
|
||||||
|
stored ??
|
||||||
|
(await generateInvoicePdf({
|
||||||
|
locale: isLocale(invoice.order.locale) ? invoice.order.locale : defaultLocale,
|
||||||
|
invoiceNumber: invoice.invoiceNumber,
|
||||||
|
issuedAt: invoice.issuedAt,
|
||||||
|
totalCents: invoice.totalCents,
|
||||||
|
order: invoice.order,
|
||||||
|
}));
|
||||||
|
|
||||||
return new NextResponse(new Uint8Array(pdf), {
|
return new NextResponse(new Uint8Array(pdf), {
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -1,10 +1,23 @@
|
|||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
|
|
||||||
|
export interface MailAttachment {
|
||||||
|
filename: string;
|
||||||
|
content: Buffer;
|
||||||
|
contentType: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface SendMailInput {
|
interface SendMailInput {
|
||||||
to: string;
|
to: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
html: string;
|
html: string;
|
||||||
text: 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". */
|
/** 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 {
|
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) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
|
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
|
||||||
console.error(`[mail] Versand an ${maskEmail(input.to)} fehlgeschlagen: ${message}`);
|
console.error(`[mail] Versand an ${maskEmail(input.to)} fehlgeschlagen: ${message}`);
|
||||||
|
|||||||
Reference in new issue
Block a user