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}

+