Compare commits
3
Commits
70ede77dde
...
425fd76bc1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
425fd76bc1 | ||
|
|
625b73a8c8 | ||
|
|
7c59283ede |
No files matched your search
@@ -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.
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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: {},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user