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]>
This commit is contained in:
Nick SzaunigandClaude Opus 5 committed 2026-09-17 06:54:26 +02:00
1 parent 7c59283ede
commit 625b73a8c8
20 files changed
+518 -70

No files matched your search

@@ -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 -13
View File
@@ -241,6 +241,16 @@ model Product {
weightGrams Int
priceCents 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")
/// Liste von Bild-URLs als JSON-Array, siehe tasteTags-Kommentar oben.
images Json @default("[]")
@@ -455,6 +465,16 @@ model OrderItem {
quantity Int
weightGrams 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)
product Product @relation(fields: [productId], references: [id], onDelete: Restrict)
@@ -524,19 +544,10 @@ model Invoice {
invoiceNumber String @unique
issuedAt DateTime @default(now())
totalCents Int
/// Umsatzsteuersatz in ganzen Prozent, wie er zum Zeitpunkt der
/// Rechnungsstellung galt (0 = Kleinunternehmerregelung nach § 19 UStG).
///
/// Wird FESTGESCHRIEBEN statt bei jeder Anzeige aus VAT_RATE_PERCENT
/// gelesen: aendert sich der Steuerstatus des Betriebs, muessen alte
/// Rechnungen weiterhin den damals ausgewiesenen Satz zeigen. Eine Rechnung
/// ist ein Beleg ueber einen vergangenen Vorgang, keine Ansicht auf die
/// heutige Konfiguration.
///
/// Null bei Rechnungen aus der Zeit vor der Steuerausweisung — die zeigen
/// weiterhin keine Steuerzeilen, weil rueckwirkend nicht feststellbar ist,
/// welcher Satz damals richtig gewesen waere.
vatRatePercent 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.
/// "2026/mustermann-erika/RE-2026-A3F9C1B2.pdf". Siehe lib/invoice-storage.ts.
///