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

+135 -1
View File
@@ -1,5 +1,11 @@
import { describe, test, expect, vi, afterEach } from "vitest";
import { splitGross, vatRatePercent, DEFAULT_VAT_RATE_PERCENT } from "./vat";
import {
splitGross,
vatRatePercent,
vatRateForCategory,
vatBreakdown,
DEFAULT_VAT_RATE_PERCENT,
} from "./vat";
afterEach(() => {
vi.restoreAllMocks();
@@ -91,3 +97,131 @@ describe("splitGross", () => {
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([]);
});
});