Compare commits

...
3 Commits
Author SHA1 Message Date
Nick SzaunigandClaude Opus 5 425fd76bc1 docs: fsGroup wirkt bei SMB/NFS nicht — Rechte gehoeren in die mountOptions
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m8s
Der Kommentar am fsGroup-Feld behauptete, Kubernetes ordne damit JEDES
gemountete Volume der Gruppe 1001 zu. Das stimmt nur fuer blockbasierten
Speicher.

Bei SMB/CIFS und NFS wird fsGroup ignoriert: diese Protokolle kennen keinen
per-Datei-Unix-Besitz, den der Knoten nachtraeglich aendern koennte. Ohne
uid/gid in den mountOptions erscheint dort alles als root:root, und der als
uid 1001 laufende Container scheitert bei jedem Upload mit EACCES — also
genau die Meldung "Die Datei konnte nicht gespeichert werden", ohne dass am
Deployment etwas falsch aussieht.

k8s/README.md bekommt dafuer einen eigenen Abschnitt: Diagnose im laufenden
Pod, die noetigen mountOptions (uid=1001, gid=1001 zusaetzlich zu
dir_mode/file_mode) und der Hinweis, dass Aenderungen daran erst beim
naechsten Einhaengen greifen, ein Rollout-Restart also dazugehoert.

Ebenfalls vermerkt: noperm umgeht die Rechtepruefung, statt den Besitz
richtigzustellen — es ersetzt uid/gid nicht.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 06:59:03 +02:00
Nick SzaunigandClaude Opus 5 625b73a8c8 feat: Steuersatz je Kategorie und je Produkt, Rechnung weist mehrere Saetze aus
Ein einziger Satz fuer den ganzen Shop war fachlich falsch: der Steuersatz
haengt in Deutschland an der Art der Ware, nicht am Betrieb. Honig ist ein
Lebensmittel und ermaessigt besteuert (Anlage 2 zum UStG), eine
Bienenwachskerze nicht.

Der Satz wird jetzt in drei Stufen bestimmt, die erste gewinnt:

1. Produkt — neues Feld "Umsatzsteuersatz" unter Admin -> Produkte, fuer
   Ausnahmen, die sich nicht aus der Kategorie ergeben.
2. Kategorie — VAT_RATE_PERCENT_HONIG / _KERZEN / _PROPOLIS.
3. VAT_RATE_PERCENT als gemeinsamer Rueckfallwert.

Die Kategorie-Stufe ist der Normalfall: aendern sich die gesetzlichen
Saetze, genuegt eine Aenderung an der Konfiguration statt an jedem Produkt.
Leer am Produkt heisst ausdruecklich "Satz der Kategorie" und NICHT 0 — 0
bedeutet steuerfrei nach § 19 UStG. Das Formularfeld hat dafuer einen
eigenen Zweig im Validierungsschema, sonst haette z.coerce.number() aus dem
leeren Feld eine 0 gemacht.

Damit kann eine Rechnung mehrere Saetze tragen, sobald Honig und Kerze im
selben Paket liegen. Sie weist deshalb je Satz eine eigene Netto- und
Steuerzeile aus. Versand und Rabatt verteilen sich im Verhaeltnis der
Warenwerte: der Versand ist umsatzsteuerlich eine Nebenleistung und teilt
das Schicksal der Hauptleistung — gibt es mehrere, teilt er sich anteilig
auf. Die Verteilung nutzt Hare-Niemeyer, damit dabei keine Cent
verschwinden oder entstehen; zugesichert ist, dass die Summe aller Zeilen
exakt den Rechnungsbetrag ergibt.

Der Satz wandert von der Rechnung an die BESTELLPOSITION
(OrderItem.vatRatePercent) und gehoert damit zum Snapshot wie der Preis:
wird ein Produkt spaeter umkategorisiert, bleiben bestehende Rechnungen
unveraendert. Invoice.vatRatePercent entfaellt wieder — ein einzelner Satz
je Rechnung ist bei gemischten Saetzen nicht darstellbar. Die Spalte
entstand in der vorigen Migration und wurde nie in Betrieb beschrieben.

VAT_RATE_PERCENT_PROPOLIS steht auf 19, weil der volle Satz im Zweifel der
sicherere ist — als Lebensmittel waeren 7 richtig, als
Nahrungsergaenzungs- oder Kosmetikprodukt 19. Im README als zu pruefen
vermerkt.

Der Manifest-Test hat die neuen Variablen zunaechst als "liest niemand"
gemeldet: lib/vat.ts bekommt die Umgebung als Parameter, damit sich die
Saetze ohne globalen Zustand testen lassen, und greift auf die
Kategorie-Saetze ueber einen berechneten Schluessel zu — beides kann eine
Regex nicht sehen. Dafuer gibt es jetzt eine begruendete Ausnahmeliste.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 06:54:26 +02:00
Nick SzaunigandClaude Opus 5 7c59283ede feat: Umsatzsteuer ueber VAT_RATE_PERCENT konfigurierbar und auf der Rechnung ausgewiesen
Die Rechnung wies bisher gar keine Steuer aus — weder Satz und Betrag noch
den Pflichthinweis nach § 19 UStG. Der Satz kommt jetzt aus
VAT_RATE_PERCENT und steht auf PDF und HTML-Ansicht: 7 (ermaessigt, Honig
als Lebensmittel), 19 (voll) oder 0 fuer die Kleinunternehmerregelung, bei
der anstelle der Steuerzeilen der vorgeschriebene Hinweis erscheint.

DIE SHOP-PREISE SIND BRUTTOPREISE. Gegenueber Verbrauchern ist der
Gesamtpreis inklusive Umsatzsteuer anzugeben (§ 1 PAngV), und genau dieser
Betrag steht in Product.priceCents und Order.totalCents. Die Steuer wird
deshalb herausgerechnet, nicht aufgeschlagen — ein Aufschlag ergaebe einen
anderen Endpreis als den, den die Kundschaft im Warenkorb gesehen und
bezahlt hat.

Gerechnet wird auf der Gesamtsumme statt je Position, damit sich Rundungen
einzelner Zeilen nicht aufsummieren, und der Steuerbetrag ergibt sich als
Rest statt aus einer zweiten Rundung: nur so gilt immer netto + steuer ===
brutto. Eine Rechnung, die sich nicht aufaddiert, ist keine brauchbare
Rechnung. Der Versand traegt den Satz der Ware, er ist umsatzsteuerlich
eine Nebenleistung.

Der Satz wird als Invoice.vatRatePercent FESTGESCHRIEBEN statt bei jeder
Anzeige aus der Umgebung gelesen. Wechselt der Steuerstatus des Betriebs,
muessen alte Rechnungen weiterhin den damals ausgewiesenen Satz zeigen — ein
Beleg ist die Aufzeichnung eines vergangenen Vorgangs, keine Ansicht auf die
heutige Konfiguration. Bestehende Rechnungen tragen null und zeigen wie
bisher keine Steuerzeilen, weil rueckwirkend nicht feststellbar ist, welcher
Satz gegolten haette.

Ein unbrauchbarer Wert faellt auf 7% zurueck und wird laut geloggt, statt
eine bereits bezahlte Bestellung scheitern zu lassen. Damit das nicht
unbemerkt bleibt, prueft "pnpm preflight" den Wert jetzt als Blocker und
warnt zusaetzlich bei einem in Deutschland unueblichen Satz.

Der bisherige legalNote war ein Platzhalter ("Kleinunternehmerregelung /
Umsatzsteuerausweis gemaess tatsaechlichem Status ergaenzen") und ist
ersetzt, da die Angabe nun tatsaechlich erfolgt.

Bekannte Grenze, im README vermerkt: es gibt genau EINEN Satz. Honig ist
ermaessigt besteuert, Kerzen nicht — werden beide verkauft, braucht es einen
Satz je ProductCategory.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 06:32:12 +02:00
23 changed files with 869 additions and 10 deletions

No files matched your search

+26
View File
@@ -214,6 +214,32 @@ UPLOAD_DIR=./uploads
# k8s/04-uploads-pvc.yaml und den Sicherungs-CronJob k8s/07-backup-cronjob.yaml. # k8s/04-uploads-pvc.yaml und den Sicherungs-CronJob k8s/07-backup-cronjob.yaml.
INVOICE_DIR=./invoices INVOICE_DIR=./invoices
# Umsatzsteuersatz in GANZEN Prozent, ausgewiesen auf jeder Rechnung.
# 7 = ermaessigter Satz (Honig als Lebensmittel, Anlage 2 zum UStG)
# 19 = voller Satz
# 0 = Kleinunternehmerregelung; die Rechnung traegt dann statt der
# Steuerzeilen den Pflichthinweis nach § 19 UStG
#
# Die Shop-Preise sind BRUTTOPREISE (gegenueber Verbrauchern ist der
# Gesamtpreis inkl. Umsatzsteuer anzugeben, § 1 PAngV). Die Steuer wird daraus
# HERAUSGERECHNET, nicht aufgeschlagen — der Endpreis bleibt genau der, den die
# Kundschaft im Warenkorb gesehen und bezahlt hat.
#
# Der Satz wird an jeder Rechnung festgeschrieben: eine Aenderung hier wirkt
# nur auf kuenftige Rechnungen, bereits ausgestellte behalten ihren Satz.
VAT_RATE_PERCENT=7
# Satz je Kategorie — der Steuersatz haengt an der Art der Ware. Fehlt hier
# ein Wert, gilt VAT_RATE_PERCENT von oben. Ein am einzelnen Produkt
# gepflegter Satz (Admin -> Produkte) schlaegt beides.
#
# PROPOLIS bitte pruefen: als Lebensmittel waeren 7% richtig, als
# Nahrungsergaenzungs- oder Kosmetikprodukt 19%. Voreingestellt ist der volle
# Satz, weil er im Zweifel der sicherere ist.
VAT_RATE_PERCENT_HONIG=7
VAT_RATE_PERCENT_KERZEN=19
VAT_RATE_PERCENT_PROPOLIS=19
# 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.
# #
+26 -3
View File
@@ -274,10 +274,33 @@ Das Jahr steht zuerst, weil sich die Aufbewahrungsfrist danach richtet: ein abge
> 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). > 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: ### Umsatzsteuer
- Die Rechnungsnummer (`RE-2026-<8 Hex>`) ist eindeutig, aber **nicht fortlaufend**. § 14 Abs. 4 Nr. 4 UStG verlangt eine fortlaufende Nummer. Der Steuersatz hängt an der Art der Ware, nicht am Betrieb: Honig ist ein Lebensmittel und ermäßigt besteuert (Anlage 2 zum UStG), eine Bienenwachskerze nicht. Der Satz wird deshalb in drei Stufen bestimmt — die erste, die etwas liefert, gewinnt:
- 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.
1. **Am Produkt** — Feld „Umsatzsteuersatz" unter Admin → Produkte. Für Ausnahmen, die sich nicht aus der Kategorie ergeben, etwa ein gemischtes Geschenkset. Leer lassen ist der Normalfall.
2. **An der Kategorie** — `VAT_RATE_PERCENT_HONIG`, `VAT_RATE_PERCENT_KERZEN`, `VAT_RATE_PERCENT_PROPOLIS`.
3. **Gemeinsamer Rückfallwert** — `VAT_RATE_PERCENT`, sonst 7.
Der Vorteil der Kategorie-Stufe: ändern sich die gesetzlichen Sätze, genügt eine Änderung an der Konfiguration statt an jedem einzelnen Produkt.
| Wert | Auf der Rechnung |
|------|------------------|
| `7` | Nettobetrag + „zzgl. 7 % Umsatzsteuer" |
| `19` | Nettobetrag + „zzgl. 19 % Umsatzsteuer" |
| `0` | „Gemäß § 19 UStG wird keine Umsatzsteuer berechnet." anstelle der Steuerzeilen |
> **`VAT_RATE_PERCENT_PROPOLIS` bitte prüfen.** Voreingestellt sind 19 %, weil der volle Satz im Zweifel der sicherere ist. Als Lebensmittel wären 7 % richtig, als Nahrungsergänzungs- oder Kosmetikprodukt 19 % — das hängt von der konkreten Aufmachung ab.
**Mehrere Sätze auf einer Rechnung** sind der Normalfall, sobald Honig und Kerze im selben Paket liegen. Die Rechnung weist dann je Satz eine eigene Netto- und Steuerzeile aus. Versand und Rabatt werden im Verhältnis der Warenwerte auf die Sätze verteilt — der Versand ist umsatzsteuerlich eine Nebenleistung und teilt das Schicksal der Hauptleistung; gibt es mehrere, teilt er sich anteilig auf. Die Verteilung nutzt das Hare-Niemeyer-Verfahren, damit dabei keine Cent verschwinden oder entstehen.
Der Satz wird je **Bestellposition** als `OrderItem.vatRatePercent` festgeschrieben — Teil des Snapshots wie der Preis. Wird ein Produkt später umkategorisiert oder ändern sich die Sätze, bleiben bestehende Rechnungen unverändert.
**Die Shop-Preise sind Bruttopreise.** Gegenüber Verbraucher:innen ist der Gesamtpreis inklusive Umsatzsteuer anzugeben (§ 1 PAngV), und genau dieser Betrag steht in `Product.priceCents` und `Order.totalCents`. Die Steuer wird deshalb **herausgerechnet, nicht aufgeschlagen** — ein Aufschlag würde den Endpreis verändern und von dem abweichen, was im Warenkorb stand. Gerechnet wird auf der Gesamtsumme, damit sich Rundungen einzelner Positionen nicht aufsummieren; der Steuerbetrag ergibt sich als Rest, sodass Netto + Steuer immer exakt den Bruttobetrag ergibt. Der Versand trägt den Satz der Ware (umsatzsteuerliche Nebenleistung).
`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.
## Health-Endpunkte ## Health-Endpunkte
+25
View File
@@ -62,6 +62,31 @@ data:
# src/lib/invoice-storage.ts und 04-uploads-pvc.yaml. # src/lib/invoice-storage.ts und 04-uploads-pvc.yaml.
INVOICE_DIR: "/app/invoices" INVOICE_DIR: "/app/invoices"
# Umsatzsteuersatz in GANZEN Prozent, der auf der Rechnung ausgewiesen wird.
# 7 = ermaessigter Satz (Honig als Lebensmittel, Anlage 2 zum UStG)
# 19 = voller Satz
# 0 = Kleinunternehmerregelung; die Rechnung traegt dann statt der
# Steuerzeilen den Pflichthinweis nach § 19 UStG
#
# Die Shop-Preise sind BRUTTOPREISE (§ 1 PAngV). Die Steuer wird daraus
# herausgerechnet, nicht aufgeschlagen — der Endpreis bleibt also genau der,
# den die Kundschaft im Warenkorb gesehen hat.
#
# Der Satz wird bei jeder Rechnung FESTGESCHRIEBEN. Eine Aenderung hier gilt
# nur fuer kuenftige Rechnungen; bereits ausgestellte behalten ihren Satz.
VAT_RATE_PERCENT: "7"
# Satz je Kategorie. Der Steuersatz haengt an der Art der Ware: Honig ist
# ein Lebensmittel und ermaessigt besteuert, eine Bienenwachskerze nicht.
# Fehlt hier ein Wert, gilt VAT_RATE_PERCENT von oben.
#
# PROPOLIS bitte pruefen: als Lebensmittel waeren 7% richtig, als
# Nahrungsergaenzungs- oder Kosmetikprodukt 19%. Voreingestellt ist der
# volle Satz, weil er im Zweifel der sicherere ist.
VAT_RATE_PERCENT_HONIG: "7"
VAT_RATE_PERCENT_KERZEN: "19"
VAT_RATE_PERCENT_PROPOLIS: "19"
# 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
+18 -1
View File
@@ -51,8 +51,25 @@ spec:
# Image bereits per chown gesetzten Besitzer von /app/uploads # Image bereits per chown gesetzten Besitzer von /app/uploads
# vollstaendig, und der nicht-root laufende Container koennte trotzdem # vollstaendig, und der nicht-root laufende Container koennte trotzdem
# keine Datei dort anlegen (EACCES bei jedem Admin-Upload). fsGroup # keine Datei dort anlegen (EACCES bei jedem Admin-Upload). fsGroup
# sorgt dafuer, dass Kubernetes JEDES gemountete Volume zusaetzlich der # sorgt dafuer, dass Kubernetes das gemountete Volume zusaetzlich der
# Gruppe 1001 zuordnet und gruppen-schreibbar macht. # Gruppe 1001 zuordnet und gruppen-schreibbar macht.
#
# ACHTUNG, GILT NICHT FUER JEDES BACKEND: fsGroup wirkt nur dort, wo
# Kubernetes den Besitz tatsaechlich setzen kann — also bei
# blockbasiertem Speicher. Bei SMB/CIFS und NFS wird es IGNORIERT, weil
# diese Protokolle keinen per-Datei-Unix-Besitz kennen, den der Knoten
# nachtraeglich aendern koennte. Dort gehoert die Zuordnung in die
# mountOptions des PersistentVolume:
#
# mountOptions:
# - uid=1001
# - gid=1001
# - dir_mode=0770
# - file_mode=0660
#
# Ohne uid/gid erscheint bei SMB ALLES als root:root, und der Container
# scheitert trotz fsGroup mit EACCES. Siehe k8s/README.md, Abschnitt
# "Uploads lassen sich nicht speichern".
securityContext: securityContext:
fsGroup: 1001 fsGroup: 1001
containers: containers:
+67
View File
@@ -221,6 +221,73 @@ Speicher- und CPU-Bedarf nicht mehr von der Größe des Knotens abhängt — sie
erfolgreicher Upload beim nächsten Neustart trotzdem weg. Siehe erfolgreicher Upload beim nächsten Neustart trotzdem weg. Siehe
„Bilder/Videos verschwinden nach jedem Redeploy" oben. „Bilder/Videos verschwinden nach jedem Redeploy" oben.
## Uploads lassen sich nicht speichern
Symptom: Im Admin erscheint „Die Datei konnte nicht gespeichert werden. Bitte
informiere die Technik." Im Pod-Log steht dahinter ein `EACCES`.
Ursache ist fast immer der Besitz des gemounteten Volumes. Der Container läuft
als **`nextjs`, uid 1001, Gruppe `nodejs` gid 1001** (siehe `Dockerfile`). Das
Image setzt zwar `chown nextjs:nodejs /app/uploads`, aber ein darübergelegtes
Volume überschreibt das vollständig.
Zuerst feststellen, was der Container tatsächlich sieht:
```bash
kubectl -n wabenhain exec deploy/wabenhain-app -- sh -c \
'id; ls -ld /app/uploads /app/invoices; \
touch /app/uploads/.probe && echo SCHREIBEN_OK || echo SCHREIBEN_FEHLT'
```
Der Fix hängt am Speicher-Backend — die beiden Wege gelten für verschiedene
Fälle, nicht alternativ:
### Blockspeicher (die meisten CSI-Treiber)
`securityContext.fsGroup: 1001` im Deployment. Kubernetes ordnet das Volume
dann der Gruppe 1001 zu und macht es gruppenschreibbar. Steht bereits in
`05-app-deployment.yaml`.
### SMB/CIFS und NFS
**Hier wird `fsGroup` ignoriert.** Diese Protokolle kennen keinen
per-Datei-Unix-Besitz, den der Knoten nachträglich ändern könnte; ohne
Zuordnung erscheint alles als `root:root`, und der Container scheitert trotz
`fsGroup` mit `EACCES`. Die Zuordnung gehört in die `mountOptions` des
PersistentVolume:
```yaml
spec:
mountOptions:
- vers=3.0
- uid=1001 # muss zum Containerbenutzer passen
- gid=1001
- dir_mode=0770
- file_mode=0660
- mfsymlinks
- nobrl # keine Byte-Range-Locks über SMB
```
`dir_mode`/`file_mode` allein genügen nicht: sie bestimmen nur, welche Rechte
angezeigt werden — wem die Datei gehört, entscheiden `uid`/`gid`.
`noperm` (der Client prüft Rechte gar nicht erst) wirkt in vielen Aufbauten
ebenfalls, ist aber die schwächere Zusicherung: es umgeht die Prüfung, statt
den Besitz richtigzustellen. `uid`/`gid` also auch dann setzen, wenn `noperm`
bereits gesetzt ist.
**Änderungen an `mountOptions` greifen erst beim nächsten Einhängen.** Nach dem
Anpassen des PV den Pod neu starten:
```bash
kubectl -n wabenhain rollout restart deploy/wabenhain-app
```
Lässt sich das PV nicht mehr ändern (bei bereits gebundenen PVs je nach
Kubernetes-Version), PVC und PV löschen und neu anlegen. Bei
`persistentVolumeReclaimPolicy: Retain` bleiben die Daten auf der Freigabe
erhalten.
## Sicherung und Wiederherstellung ## Sicherung und Wiederherstellung
`07-backup-cronjob.yaml` legt jede Nacht um 03:15 Uhr eine `07-backup-cronjob.yaml` legt jede Nacht um 03:15 Uhr eine
@@ -0,0 +1,9 @@
-- Umsatzsteuersatz in ganzen Prozent, festgeschrieben zum Zeitpunkt der
-- Rechnungsstellung (0 = Kleinunternehmerregelung nach § 19 UStG).
--
-- Nullable, weil rueckwirkend nicht feststellbar ist, welcher Satz fuer
-- bereits bestehende Rechnungen richtig gewesen waere. Diese zeigen wie
-- bisher keine Steuerzeilen; erst ab hier ausgestellte Rechnungen weisen
-- die Steuer aus.
-- AlterTable
ALTER TABLE "invoices" ADD COLUMN "vatRatePercent" INTEGER;
@@ -0,0 +1,23 @@
-- Der Umsatzsteuersatz haengt in Deutschland an der Art der Ware: Honig ist
-- ein Lebensmittel und ermaessigt besteuert (Anlage 2 zum UStG), eine
-- Bienenwachskerze nicht. Eine Bestellung kann also Positionen mit
-- VERSCHIEDENEN Saetzen enthalten.
--
-- Der Satz wandert deshalb von der Rechnung an die einzelne Position und
-- gehoert zum Snapshot der Bestelldaten: wird ein Produkt spaeter
-- umkategorisiert, darf das bestehende Rechnungen nicht rueckwirkend
-- veraendern.
--
-- Die Spalte an "invoices" entstand in der vorigen Migration und wurde nie
-- in Betrieb beschrieben — es gehen keine Daten verloren.
-- AlterTable
ALTER TABLE "order_items" ADD COLUMN "vatRatePercent" INTEGER;
-- AlterTable
ALTER TABLE "invoices" DROP COLUMN "vatRatePercent";
-- Satz je PRODUKT, als Ausnahme zur Kategorie-Vorgabe. Leer heisst: es
-- gilt der Satz der Kategorie (siehe src/lib/vat.ts). Bestehende Produkte
-- bekommen NULL und folgen damit unveraendert ihrer Kategorie.
-- AlterTable
ALTER TABLE "products" ADD COLUMN "vatRatePercent" INTEGER;
+24
View File
@@ -241,6 +241,16 @@ model Product {
weightGrams Int weightGrams Int
priceCents Int priceCents Int
pricePerKgCents Int pricePerKgCents Int
/// Umsatzsteuersatz dieses Produkts in ganzen Prozent — uebersteuert den
/// Satz, der sich sonst aus der Kategorie ergibt (siehe lib/vat.ts).
///
/// Leer lassen ist der Normalfall: dann gilt der Satz der Kategorie, und
/// eine spaetere Aenderung der gesetzlichen Saetze wirkt ueber die
/// Konfiguration auf alle Produkte zugleich. Ein Wert hier ist fuer die
/// Ausnahmen gedacht, die sich nicht aus der Kategorie ergeben — etwa ein
/// Geschenkset, das anders einzuordnen ist als seine Kategorie vermuten
/// liesse.
vatRatePercent Int?
currency String @default("EUR") currency String @default("EUR")
/// Liste von Bild-URLs als JSON-Array, siehe tasteTags-Kommentar oben. /// Liste von Bild-URLs als JSON-Array, siehe tasteTags-Kommentar oben.
images Json @default("[]") images Json @default("[]")
@@ -455,6 +465,16 @@ model OrderItem {
quantity Int quantity Int
weightGrams Int weightGrams Int
totalCents Int totalCents Int
/// Umsatzsteuersatz dieser Position in ganzen Prozent, abgeleitet aus der
/// Produktkategorie zum Bestellzeitpunkt (Honig ermaessigt, Kerzen voll —
/// siehe lib/vat.ts). Teil des Snapshots aus demselben Grund wie der Preis:
/// wird ein Produkt spaeter umkategorisiert oder aendert sich der Satz,
/// darf das bestehende Rechnungen nicht rueckwirkend veraendern.
///
/// Null bei Bestellungen aus der Zeit vor der Steuerausweisung — deren
/// Rechnungen zeigen weiterhin keine Steuerzeilen, weil rueckwirkend nicht
/// feststellbar ist, welcher Satz gegolten haette.
vatRatePercent Int?
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Restrict) product Product @relation(fields: [productId], references: [id], onDelete: Restrict)
@@ -524,6 +544,10 @@ model Invoice {
invoiceNumber String @unique invoiceNumber String @unique
issuedAt DateTime @default(now()) issuedAt DateTime @default(now())
totalCents Int totalCents Int
// Kein Steuersatz an der Rechnung: eine Bestellung kann Waren mit
// VERSCHIEDENEN Saetzen enthalten (Honig 7%, Bienenwachskerze 19%). Der
// 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. /// 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-A3F9C1B2.pdf". Siehe lib/invoice-storage.ts.
/// ///
+17
View File
@@ -113,6 +113,23 @@ should(
"Absenderadressen fremder Domains landen regelmaessig im Spam-Ordner." "Absenderadressen fremder Domains landen regelmaessig im Spam-Ordner."
); );
// --- Umsatzsteuer ----------------------------------------------------------
// Ein falscher Steuersatz faellt im Betrieb kaum auf: die Rechnung sieht
// plausibel aus, nur die ausgewiesene Steuer stimmt nicht. Deshalb hier
// ausdruecklich pruefen, statt sich auf den Rueckfallwert zu verlassen.
const vatRate = value("VAT_RATE_PERCENT");
const vatRateNumber = Number(vatRate);
must(
"VAT_RATE_PERCENT gesetzt und ein ganzzahliger Prozentwert",
vatRate.length > 0 && Number.isInteger(vatRateNumber) && vatRateNumber >= 0 && vatRateNumber <= 100,
"Wird auf jeder Rechnung ausgewiesen. 7 = ermaessigt (Honig), 19 = voll, 0 = Kleinunternehmerregelung nach § 19 UStG. Ohne gueltigen Wert rechnet die Anwendung mit 7% weiter — fuer Kerzen zu niedrig, fuer Kleinunternehmer schlicht falsch."
);
should(
"Umsatzsteuersatz ist ein in Deutschland ueblicher Wert",
["0", "7", "19"].includes(vatRate),
`Eingetragen ist "${vatRate}". Ueblich sind 0 (§ 19 UStG), 7 (ermaessigt) oder 19 (voll) — ein anderer Wert ist moeglich, aber pruefenswert.`
);
// --- Datenbank ------------------------------------------------------------- // --- Datenbank -------------------------------------------------------------
must("DATABASE_URL gesetzt", isSet("DATABASE_URL"), "Ohne Datenbank startet die Anwendung nicht."); must("DATABASE_URL gesetzt", isSet("DATABASE_URL"), "Ohne Datenbank startet die Anwendung nicht.");
+4
View File
@@ -39,6 +39,7 @@ export async function createProductAction(formData: FormData): Promise<void> {
priceCents: str(formData, "priceCents"), priceCents: str(formData, "priceCents"),
pricePerKgCents: str(formData, "pricePerKgCents"), pricePerKgCents: str(formData, "pricePerKgCents"),
quantity: str(formData, "quantity"), quantity: str(formData, "quantity"),
vatRatePercent: str(formData, "vatRatePercent"),
}); });
if (!parsedNumbers.success) { if (!parsedNumbers.success) {
fail("/admin/produkte/neu", parsedNumbers.error.issues[0]?.message ?? "Ungültige Eingabe."); fail("/admin/produkte/neu", parsedNumbers.error.issues[0]?.message ?? "Ungültige Eingabe.");
@@ -85,6 +86,7 @@ export async function createProductAction(formData: FormData): Promise<void> {
weightGrams: numbers.weightGrams, weightGrams: numbers.weightGrams,
priceCents: numbers.priceCents, priceCents: numbers.priceCents,
pricePerKgCents: numbers.pricePerKgCents, pricePerKgCents: numbers.pricePerKgCents,
vatRatePercent: numbers.vatRatePercent,
images: strArray(formData, "images").filter(isValidUploadPath), images: strArray(formData, "images").filter(isValidUploadPath),
isActive, isActive,
inventory: { create: { quantity: numbers.quantity } }, inventory: { create: { quantity: numbers.quantity } },
@@ -114,6 +116,7 @@ export async function updateProductAction(formData: FormData): Promise<void> {
priceCents: str(formData, "priceCents"), priceCents: str(formData, "priceCents"),
pricePerKgCents: str(formData, "pricePerKgCents"), pricePerKgCents: str(formData, "pricePerKgCents"),
quantity: str(formData, "quantity"), quantity: str(formData, "quantity"),
vatRatePercent: str(formData, "vatRatePercent"),
}); });
if (!parsedNumbers.success) { if (!parsedNumbers.success) {
fail(`/admin/produkte/${id}`, parsedNumbers.error.issues[0]?.message ?? "Ungültige Eingabe."); fail(`/admin/produkte/${id}`, parsedNumbers.error.issues[0]?.message ?? "Ungültige Eingabe.");
@@ -141,6 +144,7 @@ export async function updateProductAction(formData: FormData): Promise<void> {
weightGrams: numbers.weightGrams, weightGrams: numbers.weightGrams,
priceCents: numbers.priceCents, priceCents: numbers.priceCents,
pricePerKgCents: numbers.pricePerKgCents, pricePerKgCents: numbers.pricePerKgCents,
vatRatePercent: numbers.vatRatePercent,
images: strArray(formData, "images").filter(isValidUploadPath), images: strArray(formData, "images").filter(isValidUploadPath),
isActive, isActive,
inventory: { inventory: {
+11
View File
@@ -132,6 +132,17 @@ export default async function AdminProductEditPage(props: PageProps<"/admin/prod
/> />
</div> </div>
{/* Optional: nur fuer Artikel, die vom Satz ihrer Kategorie abweichen.
Leer heisst "Satz der Kategorie" — nicht 0, denn 0 waere
steuerfrei nach § 19 UStG (siehe lib/validation.ts). */}
<Field
name="vatRatePercent"
label={t.adminProductEditPage.vatRateLabel}
type="number"
defaultValue={product.vatRatePercent == null ? "" : String(product.vatRatePercent)}
/>
<p className="text-xs text-ink/65">{t.adminProductEditPage.vatRateHint}</p>
<label className="flex items-center gap-2 text-sm text-ink/70"> <label className="flex items-center gap-2 text-sm text-ink/70">
<input type="checkbox" name="isActive" defaultChecked={product.isActive} /> <input type="checkbox" name="isActive" defaultChecked={product.isActive} />
{t.adminProductEditPage.visibleInShop} {t.adminProductEditPage.visibleInShop}
+6
View File
@@ -127,6 +127,12 @@ export default async function AdminProductNewPage(props: PageProps<"/admin/produ
/> />
</div> </div>
{/* Optional: nur fuer Artikel, die vom Satz ihrer Kategorie abweichen.
Leer heisst "Satz der Kategorie" — nicht 0, denn 0 waere
steuerfrei nach § 19 UStG (siehe lib/validation.ts). */}
<Field name="vatRatePercent" label={t.adminProductNewPage.vatRateLabel} type="number" />
<p className="text-xs text-ink/65">{t.adminProductNewPage.vatRateHint}</p>
<label className="flex items-center gap-2 text-sm text-ink/70"> <label className="flex items-center gap-2 text-sm text-ink/70">
<input type="checkbox" name="isActive" defaultChecked /> <input type="checkbox" name="isActive" defaultChecked />
{t.adminProductNewPage.visibleInShop} {t.adminProductNewPage.visibleInShop}
+5
View File
@@ -12,6 +12,7 @@ import { getShippingCostCents } from "@/lib/shipping";
import { isDiscountCodeValid, calculateDiscountCents } from "@/lib/discount-calc"; import { isDiscountCodeValid, calculateDiscountCents } from "@/lib/discount-calc";
import { ensureBulkDiscountCode } from "@/lib/bulk-discount"; import { ensureBulkDiscountCode } from "@/lib/bulk-discount";
import { getBaseUrl } from "@/lib/url"; import { getBaseUrl } from "@/lib/url";
import { vatRateForProduct } from "@/lib/vat";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
class OutOfStockError extends Error {} class OutOfStockError extends Error {}
@@ -128,6 +129,10 @@ export async function POST(request: Request) {
quantity: cartItem.quantity, quantity: cartItem.quantity,
weightGrams: product.weightGrams, weightGrams: product.weightGrams,
totalCents: product.priceCents * cartItem.quantity, totalCents: product.priceCents * cartItem.quantity,
// Teil des Snapshots wie der Preis: der Satz haengt an der Art der Ware
// (Honig ermaessigt, Kerzen voll). Wird ein Produkt spaeter
// umkategorisiert, darf das diese Bestellung nicht mehr veraendern.
vatRatePercent: vatRateForProduct(product, process.env),
}; };
}); });
+4
View File
@@ -206,6 +206,10 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise<void> {
orderId: order.id, orderId: order.id,
invoiceNumber: `RE-${new Date().getFullYear()}-${randomBytes(4).toString("hex").toUpperCase()}`, invoiceNumber: `RE-${new Date().getFullYear()}-${randomBytes(4).toString("hex").toUpperCase()}`,
totalCents: order.totalCents, 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: {}, update: {},
}); });
+28
View File
@@ -6,6 +6,7 @@ import { getCurrentCustomer } from "@/lib/auth";
import { getInvoiceForCustomer } from "@/lib/account-data"; import { getInvoiceForCustomer } from "@/lib/account-data";
import { formatPrice, formatDate } from "@/lib/format"; import { formatPrice, formatDate } from "@/lib/format";
import { getCompanyInfo } from "@/lib/company-info"; import { getCompanyInfo } from "@/lib/company-info";
import { invoiceVatBreakdown } from "@/lib/vat";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
@@ -23,6 +24,11 @@ export default async function InvoicePage(props: PageProps<"/rechnung/[invoiceNu
const { order } = invoice; const { order } = invoice;
const company = await getCompanyInfo(); const company = await getCompanyInfo();
// Aufstellung je Steuersatz — eine Bestellung kann Honig (ermaessigt) und
// eine Bienenwachskerze (voll) zugleich enthalten. Dieselbe Funktion nutzt
// das PDF, damit beide Darstellungen desselben Belegs uebereinstimmen.
const vatLines = invoiceVatBreakdown(order);
const isSmallBusiness = vatLines.length > 0 && vatLines.every((line) => line.ratePercent === 0);
return ( return (
<div className="py-16 print:py-0"> <div className="py-16 print:py-0">
@@ -99,10 +105,32 @@ export default async function InvoicePage(props: PageProps<"/rechnung/[invoiceNu
<span>−{formatPrice(order.discountCents, order.currency)}</span> <span>−{formatPrice(order.discountCents, order.currency)}</span>
</div> </div>
) : null} ) : null}
{/* Steuerausweis mit demselben an der Rechnung festgeschriebenen
Satz wie im PDF — beide Darstellungen desselben Belegs muessen
uebereinstimmen. */}
{vatLines
.filter((line) => line.ratePercent > 0)
.map((line) => (
<div key={line.ratePercent}>
<div className="flex justify-between">
<span className="text-ink/70">{t.invoicePage.netLabel}</span>
<span>{formatPrice(line.netCents, order.currency)}</span>
</div>
<div className="flex justify-between">
<span className="text-ink/70">{t.invoicePage.vatLabel(line.ratePercent)}</span>
<span>{formatPrice(line.vatCents, order.currency)}</span>
</div>
</div>
))}
<div className="flex justify-between text-forest pt-2 border-t border-ink/10"> <div className="flex justify-between text-forest pt-2 border-t border-ink/10">
<span>{t.invoicePage.totalLabel}</span> <span>{t.invoicePage.totalLabel}</span>
<span>{formatPrice(invoice.totalCents, order.currency)}</span> <span>{formatPrice(invoice.totalCents, order.currency)}</span>
</div> </div>
{/* Pflichtangabe bei Kleinunternehmerregelung — steht anstelle der
Steuerzeilen, nicht zusaetzlich. */}
{isSmallBusiness ? (
<p className="pt-2 text-xs text-ink/65">{t.invoicePage.smallBusinessNote}</p>
) : null}
</div> </div>
</div> </div>
+10 -2
View File
@@ -973,6 +973,9 @@ export const de: Dictionary = {
stockLabel: "Lagerbestand (Gläser)", stockLabel: "Lagerbestand (Gläser)",
priceLabel: "Preis (Cent)", priceLabel: "Preis (Cent)",
pricePerKgLabel: "Grundpreis pro kg (Cent)", pricePerKgLabel: "Grundpreis pro kg (Cent)",
vatRateLabel: "Umsatzsteuersatz (%, optional)",
vatRateHint:
"Leer lassen: es gilt der Satz der Kategorie (Honig 7 %, Kerzen 19 %). Nur ausfüllen, wenn dieses Produkt davon abweicht. 0 bedeutet steuerfrei nach § 19 UStG.",
imagesLabel: "Produktbilder", imagesLabel: "Produktbilder",
visibleInShop: "Im Shop sichtbar", visibleInShop: "Im Shop sichtbar",
save: "Speichern", save: "Speichern",
@@ -1001,6 +1004,9 @@ export const de: Dictionary = {
stockLabel: "Lagerbestand (Gläser)", stockLabel: "Lagerbestand (Gläser)",
priceLabel: "Preis (Cent)", priceLabel: "Preis (Cent)",
pricePerKgLabel: "Grundpreis pro kg (Cent)", pricePerKgLabel: "Grundpreis pro kg (Cent)",
vatRateLabel: "Umsatzsteuersatz (%, optional)",
vatRateHint:
"Leer lassen: es gilt der Satz der Kategorie (Honig 7 %, Kerzen 19 %). Nur ausfüllen, wenn dieses Produkt davon abweicht. 0 bedeutet steuerfrei nach § 19 UStG.",
pricePlaceholder: "990", pricePlaceholder: "990",
pricePerKgPlaceholder: "1980", pricePerKgPlaceholder: "1980",
imagesLabel: "Produktbilder", imagesLabel: "Produktbilder",
@@ -1227,9 +1233,11 @@ export const de: Dictionary = {
shippingLabel: "Versand", shippingLabel: "Versand",
freeShipping: "kostenlos", freeShipping: "kostenlos",
discountLabel: "Rabatt", discountLabel: "Rabatt",
netLabel: "Nettobetrag",
vatLabel: (ratePercent) => `zzgl. ${ratePercent} % Umsatzsteuer`,
smallBusinessNote: "Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.",
totalLabel: "Gesamt", totalLabel: "Gesamt",
legalNote: legalNote: "Diese Rechnung wurde automatisch erzeugt und ist ohne Unterschrift gültig.",
"Kleinunternehmerregelung / Umsatzsteuerausweis gemäß tatsächlichem Status ergänzen. Diese Rechnung wurde automatisch erzeugt und ist ohne Unterschrift gültig.",
}, },
addressesPage: { addressesPage: {
title: "Meine Adressen", title: "Meine Adressen",
+10 -2
View File
@@ -973,6 +973,9 @@ export const en: Dictionary = {
stockLabel: "Inventory (jars)", stockLabel: "Inventory (jars)",
priceLabel: "Price (cents)", priceLabel: "Price (cents)",
pricePerKgLabel: "Base price per kg (cents)", pricePerKgLabel: "Base price per kg (cents)",
vatRateLabel: "VAT rate (%, optional)",
vatRateHint:
"Leave empty to use the category rate (honey 7%, candles 19%). Fill in only if this product differs. 0 means exempt under § 19 German VAT Act.",
imagesLabel: "Product images", imagesLabel: "Product images",
visibleInShop: "Visible in shop", visibleInShop: "Visible in shop",
save: "Save", save: "Save",
@@ -1001,6 +1004,9 @@ export const en: Dictionary = {
stockLabel: "Inventory (jars)", stockLabel: "Inventory (jars)",
priceLabel: "Price (cents)", priceLabel: "Price (cents)",
pricePerKgLabel: "Base price per kg (cents)", pricePerKgLabel: "Base price per kg (cents)",
vatRateLabel: "VAT rate (%, optional)",
vatRateHint:
"Leave empty to use the category rate (honey 7%, candles 19%). Fill in only if this product differs. 0 means exempt under § 19 German VAT Act.",
pricePlaceholder: "990", pricePlaceholder: "990",
pricePerKgPlaceholder: "1980", pricePerKgPlaceholder: "1980",
imagesLabel: "Product images", imagesLabel: "Product images",
@@ -1227,9 +1233,11 @@ export const en: Dictionary = {
shippingLabel: "Shipping", shippingLabel: "Shipping",
freeShipping: "free", freeShipping: "free",
discountLabel: "Discount", discountLabel: "Discount",
netLabel: "Net amount",
vatLabel: (ratePercent) => `plus ${ratePercent}% VAT`,
smallBusinessNote: "No VAT is charged (small-business scheme, § 19 German VAT Act).",
totalLabel: "Total", totalLabel: "Total",
legalNote: legalNote: "This invoice was generated automatically and is valid without a signature.",
"Small-business tax exemption / VAT disclosure to be added according to actual status. This invoice was generated automatically and is valid without a signature.",
}, },
addressesPage: { addressesPage: {
title: "My Addresses", title: "My Addresses",
+7
View File
@@ -671,6 +671,8 @@ export interface Dictionary {
stockLabel: string; stockLabel: string;
priceLabel: string; priceLabel: string;
pricePerKgLabel: string; pricePerKgLabel: string;
vatRateLabel: string;
vatRateHint: string;
imagesLabel: string; imagesLabel: string;
visibleInShop: string; visibleInShop: string;
save: string; save: string;
@@ -698,6 +700,8 @@ export interface Dictionary {
stockLabel: string; stockLabel: string;
priceLabel: string; priceLabel: string;
pricePerKgLabel: string; pricePerKgLabel: string;
vatRateLabel: string;
vatRateHint: string;
pricePlaceholder: string; pricePlaceholder: string;
pricePerKgPlaceholder: string; pricePerKgPlaceholder: string;
imagesLabel: string; imagesLabel: string;
@@ -914,6 +918,9 @@ export interface Dictionary {
shippingLabel: string; shippingLabel: string;
freeShipping: string; freeShipping: string;
discountLabel: string; discountLabel: string;
netLabel: string;
vatLabel: (ratePercent: number) => string;
smallBusinessNote: string;
totalLabel: string; totalLabel: string;
legalNote: string; legalNote: string;
}; };
+35 -1
View File
@@ -2,6 +2,7 @@ import PDFDocument from "pdfkit";
import { formatPrice, formatDate } from "@/lib/format"; import { formatPrice, formatDate } from "@/lib/format";
import { getCompanyInfo } from "@/lib/company-info"; import { getCompanyInfo } from "@/lib/company-info";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
import { invoiceVatBreakdown } from "@/lib/vat";
import type { Locale } from "@/i18n/config"; import type { Locale } from "@/i18n/config";
interface InvoicePdfInput { interface InvoicePdfInput {
@@ -21,7 +22,15 @@ interface InvoicePdfInput {
shippingCents: number; shippingCents: number;
discountCents: number; discountCents: number;
discountCode?: { code: string } | null; discountCode?: { code: string } | null;
items: { productName: string; weightGrams: number; quantity: number; unitPriceCents: number; totalCents: number }[]; items: {
productName: string;
weightGrams: number;
quantity: number;
unitPriceCents: number;
totalCents: number;
/** Satz dieser Position; null bei Bestellungen vor der Steuerausweisung. */
vatRatePercent: number | null;
}[];
}; };
} }
@@ -116,10 +125,35 @@ export async function generateInvoicePdf(input: InvoicePdfInput): Promise<Buffer
.fillColor("#242424") .fillColor("#242424")
.text(`−${formatPrice(input.order.discountCents, input.order.currency)}`, columns.total, y); .text(`−${formatPrice(input.order.discountCents, input.order.currency)}`, columns.total, y);
} }
// Steuerausweis JE SATZ: eine Bestellung kann Honig (ermaessigt) und eine
// Bienenwachskerze (voll) zugleich enthalten. Die Saetze stehen an den
// Positionen, nicht in der Umgebung — ein alter Beleg bleibt damit auch
// nach einer Umkategorisierung unveraendert. Eine leere Aufstellung heisst:
// Bestellung aus der Zeit vor der Steuerausweisung, dann bleibt der Block
// weg wie bisher.
const breakdown = invoiceVatBreakdown(input.order);
for (const line of breakdown) {
if (line.ratePercent <= 0) continue;
y += 16;
doc.fillColor("#666666").text(t.netLabel, 350, y);
doc.fillColor("#242424").text(formatPrice(line.netCents, input.order.currency), columns.total, y);
y += 16;
doc.fillColor("#666666").text(t.vatLabel(line.ratePercent), 350, y);
doc.fillColor("#242424").text(formatPrice(line.vatCents, input.order.currency), columns.total, y);
}
y += 20; y += 20;
doc.fontSize(11).fillColor("#24352B").text(t.totalLabel, 350, y); doc.fontSize(11).fillColor("#24352B").text(t.totalLabel, 350, y);
doc.text(formatPrice(input.totalCents, input.order.currency), columns.total, y); doc.text(formatPrice(input.totalCents, input.order.currency), columns.total, y);
// Der Hinweis nach § 19 UStG ist bei Kleinunternehmerregelung eine
// Pflichtangabe — er steht anstelle der Steuerzeilen, nicht zusaetzlich,
// und nur, wenn die GESAMTE Rechnung steuerfrei ist.
if (breakdown.length > 0 && breakdown.every((line) => line.ratePercent === 0)) {
y += 22;
doc.fontSize(9).fillColor("#666666").text(t.smallBusinessNote, 350, y, { width: 195 });
}
doc doc
.fontSize(8) .fontSize(8)
.fillColor("#999999") .fillColor("#999999")
+17 -1
View File
@@ -55,6 +55,22 @@ function envNamesUsedInCode(dir: string): Set<string> {
return found; return found;
} }
/**
* Schluessel, die die App zwar liest, aber nicht ueber ein woertliches
* `process.env.NAME` — deshalb findet sie die Suche unten nicht.
*
* lib/vat.ts bekommt die Umgebung als Parameter uebergeben, damit sich die
* Steuersaetze ohne globalen Zustand testen lassen, und greift auf die
* Kategorie-Saetze ueber einen berechneten Schluessel zu. Beides ist Absicht;
* eine Regex kann es nicht sehen.
*/
const READ_INDIRECTLY: Record<string, string> = {
VAT_RATE_PERCENT: "lib/vat.ts — als Parameter uebergebene Umgebung (vatRateForCategory).",
VAT_RATE_PERCENT_HONIG: "lib/vat.ts — berechneter Schluessel in CATEGORY_ENV_NAMES.",
VAT_RATE_PERCENT_KERZEN: "lib/vat.ts — berechneter Schluessel in CATEGORY_ENV_NAMES.",
VAT_RATE_PERCENT_PROPOLIS: "lib/vat.ts — berechneter Schluessel in CATEGORY_ENV_NAMES.",
};
/** /**
* Schluessel, die in den Manifesten stehen duerfen, ohne dass die App sie * Schluessel, die in den Manifesten stehen duerfen, ohne dass die App sie
* liest. Wer hier etwas ergaenzt, muss benennen koennen, WER den Wert sonst * liest. Wer hier etwas ergaenzt, muss benennen koennen, WER den Wert sonst
@@ -98,7 +114,7 @@ describe("Kubernetes-Manifeste und Code", () => {
// Act // Act
const unused = [...manifestKeys(CONFIGMAP), ...manifestKeys(SECRET)] const unused = [...manifestKeys(CONFIGMAP), ...manifestKeys(SECRET)]
.filter((name) => !used.has(name) && !(name in USED_OUTSIDE_THE_APP)) .filter((name) => !used.has(name) && !(name in USED_OUTSIDE_THE_APP) && !(name in READ_INDIRECTLY))
.sort(); .sort();
// Assert: toter Konfigurationsballast ist irrefuehrend — er sieht aus, // Assert: toter Konfigurationsballast ist irrefuehrend — er sieht aus,
+17
View File
@@ -109,6 +109,23 @@ export const adminProductNumbersSchema = z.object({
priceCents: z.coerce.number().int().min(0, "Der Preis darf nicht negativ sein."), priceCents: z.coerce.number().int().min(0, "Der Preis darf nicht negativ sein."),
pricePerKgCents: z.coerce.number().int().min(0, "Der Kilopreis darf nicht negativ sein."), pricePerKgCents: z.coerce.number().int().min(0, "Der Kilopreis darf nicht negativ sein."),
quantity: z.coerce.number().int().min(0, "Der Lagerbestand darf nicht negativ sein."), quantity: z.coerce.number().int().min(0, "Der Lagerbestand darf nicht negativ sein."),
/**
* Umsatzsteuersatz als Ausnahme zur Kategorie. Leeres Feld = null = "Satz
* der Kategorie" (der Normalfall, siehe lib/vat.ts) — deshalb ein eigener
* Zweig fuer den Leerstring: z.coerce.number() machte daraus sonst eine 0,
* und 0 bedeutet hier "steuerfrei nach § 19 UStG", also etwas voellig
* anderes als "nicht gesetzt".
*/
vatRatePercent: z
.union([
z.literal("").transform(() => null),
z.coerce
.number()
.int("Der Steuersatz muss eine ganze Zahl sein.")
.min(0, "Der Steuersatz darf nicht negativ sein.")
.max(100, "Der Steuersatz darf höchstens 100 betragen."),
])
.default(null),
}); });
export type AdminProductNumbersInput = z.infer<typeof adminProductNumbersSchema>; export type AdminProductNumbersInput = z.infer<typeof adminProductNumbersSchema>;
+227
View File
@@ -0,0 +1,227 @@
import { describe, test, expect, vi, afterEach } from "vitest";
import {
splitGross,
vatRatePercent,
vatRateForCategory,
vatBreakdown,
DEFAULT_VAT_RATE_PERCENT,
} from "./vat";
afterEach(() => {
vi.restoreAllMocks();
});
describe("vatRatePercent", () => {
test("nimmt einen gueltigen Satz", () => {
expect(vatRatePercent("7")).toBe(7);
expect(vatRatePercent("19")).toBe(19);
expect(vatRatePercent(" 19 ")).toBe(19);
});
test("laesst 0 zu — das ist die Kleinunternehmerregelung", () => {
// Arrange & Act & Assert: 0 ist ein gueltiger Wert und darf NICHT als
// "nicht gesetzt" behandelt werden, sonst waere § 19 UStG nicht abbildbar.
expect(vatRatePercent("0")).toBe(0);
});
test("faellt ohne Angabe auf den ermaessigten Satz zurueck", () => {
expect(vatRatePercent(undefined)).toBe(DEFAULT_VAT_RATE_PERCENT);
expect(vatRatePercent("")).toBe(DEFAULT_VAT_RATE_PERCENT);
expect(vatRatePercent(" ")).toBe(DEFAULT_VAT_RATE_PERCENT);
});
test("meldet einen unbrauchbaren Wert laut, statt still zu rechnen", () => {
// Arrange: ein falscher Steuersatz darf nicht unbemerkt bleiben.
const logged = vi.spyOn(console, "error").mockImplementation(() => {});
// Act
const rate = vatRatePercent("7,5");
// Assert
expect(rate).toBe(DEFAULT_VAT_RATE_PERCENT);
expect(logged).toHaveBeenCalledOnce();
});
test("weist Kommazahlen, negative Werte und Unsinn ab", () => {
// Arrange
vi.spyOn(console, "error").mockImplementation(() => {});
// Act & Assert
expect(vatRatePercent("7.5")).toBe(DEFAULT_VAT_RATE_PERCENT);
expect(vatRatePercent("-19")).toBe(DEFAULT_VAT_RATE_PERCENT);
expect(vatRatePercent("101")).toBe(DEFAULT_VAT_RATE_PERCENT);
expect(vatRatePercent("neunzehn")).toBe(DEFAULT_VAT_RATE_PERCENT);
});
});
describe("splitGross", () => {
test("rechnet die Steuer aus dem Bruttobetrag heraus", () => {
// Arrange & Act: 10,70 EUR brutto bei 7% sind 10,00 netto und 0,70 Steuer.
const split = splitGross(1070, 7);
// Assert
expect(split).toEqual({ netCents: 1000, vatCents: 70 });
});
test("rechnet auch beim vollen Satz heraus, statt aufzuschlagen", () => {
// Arrange & Act & Assert: 11,90 brutto bei 19% sind 10,00 netto. Ein
// Aufschlag ergaebe 14,16 und waere ein anderer Endpreis als der, den die
// Kundschaft im Warenkorb gesehen hat.
expect(splitGross(1190, 19)).toEqual({ netCents: 1000, vatCents: 190 });
});
test("netto plus Steuer ergibt immer exakt den Bruttobetrag", () => {
// Arrange: krumme Betraege, bei denen zwei getrennte Rundungen
// auseinanderlaufen wuerden.
const amounts = [1, 99, 999, 1234, 4567, 98765];
for (const rate of [7, 19]) {
for (const gross of amounts) {
// Act
const { netCents, vatCents } = splitGross(gross, rate);
// Assert: eine Rechnung, die sich nicht aufaddiert, ist keine
// brauchbare Rechnung.
expect(netCents + vatCents).toBe(gross);
}
}
});
test("weist bei Satz 0 keine Steuer aus", () => {
// Arrange & Act & Assert: Kleinunternehmerregelung — der Bruttobetrag ist
// zugleich der Nettobetrag.
expect(splitGross(1070, 0)).toEqual({ netCents: 1070, vatCents: 0 });
});
test("kommt mit dem Betrag null zurecht", () => {
expect(splitGross(0, 19)).toEqual({ netCents: 0, vatCents: 0 });
});
});
describe("vatRateForCategory", () => {
test("nimmt den Satz der jeweiligen Kategorie", () => {
// Arrange
const env = {
VAT_RATE_PERCENT_HONIG: "7",
VAT_RATE_PERCENT_KERZEN: "19",
VAT_RATE_PERCENT_PROPOLIS: "19",
};
// Act & Assert: Honig ist ein Lebensmittel und ermaessigt besteuert, eine
// Bienenwachskerze nicht.
expect(vatRateForCategory("HONIG", env)).toBe(7);
expect(vatRateForCategory("KERZEN", env)).toBe(19);
expect(vatRateForCategory("PROPOLIS", env)).toBe(19);
});
test("faellt auf den gemeinsamen Satz zurueck, wenn die Kategorie keinen eigenen hat", () => {
// Arrange & Act & Assert: eine bestehende Konfiguration mit nur
// VAT_RATE_PERCENT bleibt dadurch gueltig.
expect(vatRateForCategory("KERZEN", { VAT_RATE_PERCENT: "19" })).toBe(19);
expect(vatRateForCategory("HONIG", { VAT_RATE_PERCENT: "7" })).toBe(7);
});
test("behandelt eine unbekannte Kategorie wie eine ohne eigenen Satz", () => {
// Arrange & Act & Assert: eine spaeter ergaenzte Kategorie soll den
// gemeinsamen Wert erben, statt geraten zu werden.
expect(vatRateForCategory("SEIFE", { VAT_RATE_PERCENT: "19" })).toBe(19);
});
test("greift ohne jede Angabe auf den ermaessigten Satz zurueck", () => {
expect(vatRateForCategory("HONIG", {})).toBe(DEFAULT_VAT_RATE_PERCENT);
});
});
describe("vatBreakdown", () => {
test("weist bei nur einem Satz eine einzige Zeile aus", () => {
// Arrange & Act
const lines = vatBreakdown({
items: [{ totalCents: 1000, vatRatePercent: 7 }],
shippingCents: 70,
discountCents: 0,
});
// Assert
expect(lines).toEqual([{ ratePercent: 7, grossCents: 1070, netCents: 1000, vatCents: 70 }]);
});
test("trennt Honig und Kerze nach Saetzen", () => {
// Arrange: ein Glas Honig und eine Kerze im selben Paket.
const lines = vatBreakdown({
items: [
{ totalCents: 1070, vatRatePercent: 7 },
{ totalCents: 1190, vatRatePercent: 19 },
],
shippingCents: 0,
discountCents: 0,
});
// Act & Assert: je Satz eine eigene Zeile, kein Mischsatz.
expect(lines).toEqual([
{ ratePercent: 7, grossCents: 1070, netCents: 1000, vatCents: 70 },
{ ratePercent: 19, grossCents: 1190, netCents: 1000, vatCents: 190 },
]);
});
test("verteilt Versand und Rabatt im Verhaeltnis der Warenwerte", () => {
// Arrange: 3/4 Honig, 1/4 Kerze — der Versand teilt sich genauso auf,
// denn als Nebenleistung folgt er der jeweiligen Hauptleistung.
const lines = vatBreakdown({
items: [
{ totalCents: 3000, vatRatePercent: 7 },
{ totalCents: 1000, vatRatePercent: 19 },
],
shippingCents: 400,
discountCents: 0,
});
// Act & Assert
expect(lines[0].grossCents).toBe(3300);
expect(lines[1].grossCents).toBe(1100);
});
test("die Summe der Zeilen ergibt exakt den Rechnungsbetrag", () => {
// Arrange: krumme Betraege, bei denen eine naive Rundung Cents
// verschwinden oder entstehen liesse.
const items = [
{ totalCents: 1333, vatRatePercent: 7 },
{ totalCents: 777, vatRatePercent: 19 },
{ totalCents: 99, vatRatePercent: 0 },
];
const shippingCents = 495;
const discountCents = 317;
// Act
const lines = vatBreakdown({ items, shippingCents, discountCents });
// Assert
const expectedTotal =
items.reduce((sum, item) => sum + item.totalCents, 0) + shippingCents - discountCents;
expect(lines.reduce((sum, line) => sum + line.grossCents, 0)).toBe(expectedTotal);
for (const line of lines) {
expect(line.netCents + line.vatCents).toBe(line.grossCents);
}
});
test("fasst mehrere Positionen desselben Satzes zusammen", () => {
// Arrange & Act
const lines = vatBreakdown({
items: [
{ totalCents: 500, vatRatePercent: 7 },
{ totalCents: 570, vatRatePercent: 7 },
],
shippingCents: 0,
discountCents: 0,
});
// Assert: eine Rechnung listet je Satz eine Zeile, nicht je Position.
expect(lines).toHaveLength(1);
expect(lines[0].grossCents).toBe(1070);
});
test("liefert ohne Positionen keine Aufstellung", () => {
// Arrange & Act & Assert: betrifft Rechnungen aus der Zeit vor der
// Steuerausweisung — dort bleibt der Block weg statt falsch zu rechnen.
expect(vatBreakdown({ items: [], shippingCents: 500, discountCents: 0 })).toEqual([]);
});
});
+253
View File
@@ -0,0 +1,253 @@
/**
* Umsatzsteuer auf Rechnungen.
*
* Der Satz kommt aus VAT_RATE_PERCENT und ist bewusst konfigurierbar statt
* fest verdrahtet: er haengt vom Steuerstatus des Betriebs ab und kann sich
* aendern, ohne dass dafuer ein Deploy noetig sein sollte.
*
* DIE PREISE IM SHOP SIND BRUTTOPREISE. Gegenueber Verbraucherinnen und
* Verbrauchern ist der Gesamtpreis inklusive Umsatzsteuer anzugeben (§ 1
* PAngV), und genau dieser Betrag steht in Product.priceCents und in
* Order.totalCents. Die Steuer wird daher HERAUSGERECHNET, nicht
* aufgeschlagen — ein Aufschlag wuerde den Endpreis veraendern und damit von
* dem abweichen, was die Kundschaft im Warenkorb gesehen und bezahlt hat.
*/
/**
* Ermaessigter Satz. Als Vorgabe gewaehlt, weil Honig als Lebensmittel
* darunter faellt (Anlage 2 zum UStG) und den Hauptteil des Sortiments
* ausmacht.
*
* ACHTUNG: Kerzen sind KEIN Lebensmittel und unterliegen dem vollen Satz.
* Solange es nur diesen einen Wert gibt, ist er fuer solche Artikel zu
* niedrig — siehe README-Abschnitt "Rechnungen".
*/
export const DEFAULT_VAT_RATE_PERCENT = 7;
/**
* Liest den Steuersatz aus der Umgebung.
*
* Erlaubt sind ganze Prozentwerte von 0 bis 100. 0 bedeutet: keine
* Umsatzsteuer, also Kleinunternehmerregelung nach § 19 UStG — die Rechnung
* traegt dann statt der Steuerzeilen den dafuer vorgeschriebenen Hinweis.
*
* Ein unbrauchbarer Wert faellt auf die Vorgabe zurueck UND wird laut
* geloggt, statt den Vorgang abzubrechen: die Steuerausweisung darf keine
* bereits bezahlte Bestellung scheitern lassen. Damit ein falscher Wert
* trotzdem nicht unbemerkt bleibt, prueft ihn zusaetzlich "pnpm preflight",
* und der verwendete Satz steht auf jeder Rechnung.
*
* Nur ganze Zahlen: die deutschen Saetze sind ganzzahlig (7, 19), und ein
* versehentlich mit Komma geschriebenes "7,5" wuerde sonst still als 7
* durchgehen.
*/
export function vatRatePercent(raw: string | undefined): number {
const trimmed = raw?.trim();
if (!trimmed) return DEFAULT_VAT_RATE_PERCENT;
const parsed = Number(trimmed);
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 100) {
console.error(
`[vat] VAT_RATE_PERCENT="${raw}" ist kein ganzzahliger Prozentwert zwischen 0 und 100 — es wird mit ${DEFAULT_VAT_RATE_PERCENT}% gerechnet.`
);
return DEFAULT_VAT_RATE_PERCENT;
}
return parsed;
}
export interface VatSplit {
/** Nettobetrag in Cent. */
netCents: number;
/** Steuerbetrag in Cent. */
vatCents: number;
}
/**
* Zerlegt einen Bruttobetrag in Netto und Steuer.
*
* Der Steuerbetrag ergibt sich als REST (brutto minus netto), nicht aus einer
* zweiten Rundung. Nur so gilt immer netto + steuer === brutto; zwei getrennt
* gerundete Werte koennen um einen Cent danebenliegen, und eine Rechnung, die
* sich nicht aufaddiert, ist keine brauchbare Rechnung.
*
* Gerechnet wird auf der GESAMTSUMME, nicht je Position: Rundungsfehler
* einzelner Zeilen wuerden sich sonst aufsummieren. Der Versand traegt dabei
* den Satz der Ware — er ist umsatzsteuerlich eine Nebenleistung und folgt
* der Hauptleistung.
*/
export function splitGross(grossCents: number, ratePercent: number): VatSplit {
if (ratePercent <= 0) return { netCents: grossCents, vatCents: 0 };
const netCents = Math.round(grossCents / (1 + ratePercent / 100));
return { netCents, vatCents: grossCents - netCents };
}
// ---------------------------------------------------------------------------
// Satz je Produktkategorie
// ---------------------------------------------------------------------------
/**
* Umgebungsvariable je Kategorie. Der Steuersatz haengt in Deutschland an der
* Art der Ware, nicht am Betrieb: Honig ist ein Lebensmittel und ermaessigt
* besteuert (Anlage 2 zum UStG), eine Bienenwachskerze ist es nicht.
*
* Fehlt der kategoriespezifische Wert, gilt VAT_RATE_PERCENT als gemeinsamer
* Rueckfallwert — so bleibt eine bestehende Konfiguration mit nur diesem
* einen Wert weiterhin gueltig.
*/
const CATEGORY_ENV_NAMES = {
HONIG: "VAT_RATE_PERCENT_HONIG",
KERZEN: "VAT_RATE_PERCENT_KERZEN",
PROPOLIS: "VAT_RATE_PERCENT_PROPOLIS",
} as const;
export type VatCategory = keyof typeof CATEGORY_ENV_NAMES;
export function isVatCategory(value: string): value is VatCategory {
return value in CATEGORY_ENV_NAMES;
}
/**
* Steuersatz fuer eine Produktkategorie.
*
* Reihenfolge: kategoriespezifischer Wert, sonst der gemeinsame
* VAT_RATE_PERCENT, sonst der ermaessigte Satz. Eine unbekannte Kategorie
* (etwa eine spaeter ergaenzte) faellt ebenfalls auf den gemeinsamen Wert
* zurueck, statt zu raten.
*/
export function vatRateForCategory(
category: string,
env: Record<string, string | undefined>
): number {
const specific = isVatCategory(category) ? env[CATEGORY_ENV_NAMES[category]] : undefined;
return vatRatePercent(specific?.trim() ? specific : env.VAT_RATE_PERCENT);
}
/**
* Steuersatz eines konkreten Produkts.
*
* Ein am Produkt gesetzter Wert gewinnt immer — er ist die Ausnahme fuer
* Artikel, die sich nicht aus ihrer Kategorie ergeben. Ist dort nichts
* hinterlegt (der Normalfall), gilt der Satz der Kategorie; eine Aenderung
* der gesetzlichen Saetze wirkt dann ueber die Konfiguration auf alle
* betroffenen Produkte zugleich, statt in jedem Datensatz nachgepflegt
* werden zu muessen.
*
* Ein unsinniger Wert am Produkt (ausserhalb 0 bis 100) wird ignoriert und
* faellt auf die Kategorie zurueck, statt eine Bestellung scheitern zu
* lassen — dieselbe Haltung wie bei der Umgebungsvariablen.
*/
export function vatRateForProduct(
product: { category: string; vatRatePercent?: number | null },
env: Record<string, string | undefined>
): number {
const own = product.vatRatePercent;
if (own != null && Number.isInteger(own) && own >= 0 && own <= 100) return own;
return vatRateForCategory(product.category, env);
}
// ---------------------------------------------------------------------------
// Steueraufstellung einer Rechnung
// ---------------------------------------------------------------------------
export interface VatBreakdownLine extends VatSplit {
ratePercent: number;
grossCents: number;
}
export interface VatBreakdownInput {
/** Bestellpositionen mit ihrem bei der Bestellung festgehaltenen Satz. */
items: ReadonlyArray<{ totalCents: number; vatRatePercent: number }>;
shippingCents: number;
discountCents: number;
}
/**
* Verteilt einen Betrag im Verhaeltnis der Gewichte, ohne dass dabei Cents
* verschwinden oder entstehen.
*
* Nach dem Abrunden jedes Anteils bleibt ein Rest von wenigen Cent uebrig;
* der geht an die Anteile mit dem groessten abgeschnittenen Nachkommaanteil
* (Hare-Niemeyer). Eine blosse Rundung je Anteil koennte in der Summe daneben
* liegen — bei Geldbetraegen auf einer Rechnung ist das nicht hinnehmbar.
*/
function distributeProportionally(amountCents: number, weights: readonly number[]): number[] {
const totalWeight = weights.reduce((sum, weight) => sum + weight, 0);
if (totalWeight <= 0 || amountCents === 0) return weights.map(() => 0);
const exact = weights.map((weight) => (amountCents * weight) / totalWeight);
const shares = exact.map((value) => Math.floor(value));
let remainder = amountCents - shares.reduce((sum, share) => sum + share, 0);
const byLargestFraction = exact
.map((value, index) => ({ index, fraction: value - Math.floor(value) }))
.sort((a, b) => b.fraction - a.fraction);
for (const { index } of byLargestFraction) {
if (remainder <= 0) break;
shares[index] += 1;
remainder -= 1;
}
return shares;
}
/**
* Stellt die Umsatzsteuer einer Rechnung je Satz auf.
*
* Eine Bestellung kann Waren mit verschiedenen Saetzen enthalten — ein Glas
* Honig (7%) und eine Bienenwachskerze (19%) im selben Paket. Die Rechnung
* muss dann JE SATZ Netto und Steuer ausweisen, nicht einen Mischsatz.
*
* Versand und Rabatt werden im Verhaeltnis der Warenwerte auf die Saetze
* verteilt: der Versand ist umsatzsteuerlich eine Nebenleistung und teilt das
* Schicksal der Hauptleistung — gibt es mehrere Hauptleistungen, teilt er
* sich anteilig auf. Fuer den Rabatt gilt dasselbe, er mindert alle Saetze
* im selben Verhaeltnis.
*
* Zugesichert: die Summe aller grossCents ergibt exakt den Rechnungsbetrag,
* und je Zeile gilt netCents + vatCents === grossCents.
*/
export function vatBreakdown(input: VatBreakdownInput): VatBreakdownLine[] {
const grossByRate = new Map<number, number>();
for (const item of input.items) {
grossByRate.set(item.vatRatePercent, (grossByRate.get(item.vatRatePercent) ?? 0) + item.totalCents);
}
if (grossByRate.size === 0) return [];
const rates = [...grossByRate.keys()].sort((a, b) => a - b);
const itemGross = rates.map((rate) => grossByRate.get(rate) ?? 0);
const shipping = distributeProportionally(input.shippingCents, itemGross);
const discount = distributeProportionally(input.discountCents, itemGross);
return rates.map((ratePercent, index) => {
const grossCents = itemGross[index] + shipping[index] - discount[index];
return { ratePercent, grossCents, ...splitGross(grossCents, ratePercent) };
});
}
/**
* Steueraufstellung einer Bestellung, wie sie Rechnungsansicht und PDF
* anzeigen. Gemeinsame Stelle fuer beide, damit dieselbe Rechnung nicht in
* zwei Darstellungen unterschiedlich gerechnet wird.
*
* Fehlt auch nur an EINER Position der Satz, bleibt die Aufstellung leer.
* Ein teilweiser Ausweis waere schlimmer als gar keiner: die Summe der
* Steuerzeilen ergaebe dann nicht den Rechnungsbetrag. Der Fall betrifft
* Bestellungen aus der Zeit vor der Steuerausweisung.
*/
export function invoiceVatBreakdown(order: {
items: ReadonlyArray<{ totalCents: number; vatRatePercent: number | null }>;
shippingCents: number;
discountCents: number;
}): VatBreakdownLine[] {
const items: { totalCents: number; vatRatePercent: number }[] = [];
for (const item of order.items) {
if (item.vatRatePercent == null) return [];
items.push({ totalCents: item.totalCents, vatRatePercent: item.vatRatePercent });
}
return vatBreakdown({
items,
shippingCents: order.shippingCents,
discountCents: order.discountCents,
});
}