From 625b73a8c812d29582f6dbe28667abebd2a634b2 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Thu, 17 Sep 2026 06:54:26 +0200 Subject: [PATCH] feat: Steuersatz je Kategorie und je Produkt, Rechnung weist mehrere Saetze aus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .env.example | 11 ++ README.md | 28 ++- k8s/01-configmap.yaml | 11 ++ .../migration.sql | 23 +++ prisma/schema.prisma | 37 ++-- src/app/admin/actions/products.ts | 4 + src/app/admin/produkte/[id]/page.tsx | 11 ++ src/app/admin/produkte/neu/page.tsx | 6 + src/app/api/checkout/route.ts | 5 + src/app/api/checkout/webhook/route.ts | 10 +- src/app/rechnung/[invoiceNumber]/page.tsx | 39 ++-- src/app/rechnung/[invoiceNumber]/pdf/route.ts | 1 - src/i18n/dictionaries/de.ts | 6 + src/i18n/dictionaries/en.ts | 6 + src/i18n/dictionary-type.ts | 4 + src/lib/invoice-pdf.ts | 45 ++--- src/lib/k8s-manifest-env.test.ts | 18 +- src/lib/validation.ts | 17 ++ src/lib/vat.test.ts | 136 +++++++++++++- src/lib/vat.ts | 170 ++++++++++++++++++ 20 files changed, 518 insertions(+), 70 deletions(-) create mode 100644 prisma/migrations/20260917070000_order_item_vat_rate/migration.sql diff --git a/.env.example b/.env.example index 64988d9..35978b5 100644 --- a/.env.example +++ b/.env.example @@ -229,6 +229,17 @@ INVOICE_DIR=./invoices # 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 # (Admin -> Inhalte -> Hero-Video). Leer lassen heisst 2, erlaubt sind 1 bis 8. # diff --git a/README.md b/README.md index 7274c7d..a8a0677 100644 --- a/README.md +++ b/README.md @@ -276,19 +276,29 @@ Das Jahr steht zuerst, weil sich die Aufbewahrungsfrist danach richtet: ein abge ### Umsatzsteuer -Der Satz kommt aus `VAT_RATE_PERCENT` (ganze Prozent) und steht auf Rechnungsansicht und PDF: +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: -| Wert | Bedeutung | Auf der Rechnung | -|------|-----------|------------------| -| `7` | ermäßigt — Honig als Lebensmittel, Anlage 2 zum UStG | Nettobetrag + „zzgl. 7 % Umsatzsteuer" | -| `19` | voller Satz | Nettobetrag + „zzgl. 19 % Umsatzsteuer" | -| `0` | Kleinunternehmerregelung | „Gemäß § 19 UStG wird keine Umsatzsteuer berechnet." anstelle der Steuerzeilen | +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). -Der Satz wird bei jeder Rechnung als `Invoice.vatRatePercent` **festgeschrieben**. Ändert sich der Steuerstatus, gilt der neue Wert nur für künftige Rechnungen — ein Beleg über einen vergangenen Vorgang darf sich nicht rückwirkend ändern. `pnpm preflight` blockiert einen fehlenden oder unplausiblen Wert. - -> **Ein Satz für alle Artikel.** Honig ist ermäßigt besteuert, Kerzen nicht. Werden beide verkauft, ist der eine Wert zwangsläufig für eine der beiden Gruppen falsch — dafür bräuchte es einen Satz je `ProductCategory`. Solange nur Honig verkauft wird, genügt `7`. +`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. diff --git a/k8s/01-configmap.yaml b/k8s/01-configmap.yaml index 853c00b..711a763 100644 --- a/k8s/01-configmap.yaml +++ b/k8s/01-configmap.yaml @@ -76,6 +76,17 @@ data: # 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 -> # Hero-Video). NICHT ohne Not erhoehen: ffmpeg teilt sich das Speicherlimit # dieses Pods mit dem Node-Prozess, und jeder zusaetzliche Thread haelt diff --git a/prisma/migrations/20260917070000_order_item_vat_rate/migration.sql b/prisma/migrations/20260917070000_order_item_vat_rate/migration.sql new file mode 100644 index 0000000..9defe6f --- /dev/null +++ b/prisma/migrations/20260917070000_order_item_vat_rate/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index def7949..28a5029 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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. /// diff --git a/src/app/admin/actions/products.ts b/src/app/admin/actions/products.ts index cc647a4..fa8518c 100644 --- a/src/app/admin/actions/products.ts +++ b/src/app/admin/actions/products.ts @@ -39,6 +39,7 @@ export async function createProductAction(formData: FormData): Promise { priceCents: str(formData, "priceCents"), pricePerKgCents: str(formData, "pricePerKgCents"), quantity: str(formData, "quantity"), + vatRatePercent: str(formData, "vatRatePercent"), }); if (!parsedNumbers.success) { fail("/admin/produkte/neu", parsedNumbers.error.issues[0]?.message ?? "Ungültige Eingabe."); @@ -85,6 +86,7 @@ export async function createProductAction(formData: FormData): Promise { weightGrams: numbers.weightGrams, priceCents: numbers.priceCents, pricePerKgCents: numbers.pricePerKgCents, + vatRatePercent: numbers.vatRatePercent, images: strArray(formData, "images").filter(isValidUploadPath), isActive, inventory: { create: { quantity: numbers.quantity } }, @@ -114,6 +116,7 @@ export async function updateProductAction(formData: FormData): Promise { priceCents: str(formData, "priceCents"), pricePerKgCents: str(formData, "pricePerKgCents"), quantity: str(formData, "quantity"), + vatRatePercent: str(formData, "vatRatePercent"), }); if (!parsedNumbers.success) { fail(`/admin/produkte/${id}`, parsedNumbers.error.issues[0]?.message ?? "Ungültige Eingabe."); @@ -141,6 +144,7 @@ export async function updateProductAction(formData: FormData): Promise { weightGrams: numbers.weightGrams, priceCents: numbers.priceCents, pricePerKgCents: numbers.pricePerKgCents, + vatRatePercent: numbers.vatRatePercent, images: strArray(formData, "images").filter(isValidUploadPath), isActive, inventory: { diff --git a/src/app/admin/produkte/[id]/page.tsx b/src/app/admin/produkte/[id]/page.tsx index 1eb43bb..5368d67 100644 --- a/src/app/admin/produkte/[id]/page.tsx +++ b/src/app/admin/produkte/[id]/page.tsx @@ -132,6 +132,17 @@ export default async function AdminProductEditPage(props: PageProps<"/admin/prod /> + {/* 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). */} + +

{t.adminProductEditPage.vatRateHint}

+