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]>
228 lines
7.7 KiB
TypeScript
228 lines
7.7 KiB
TypeScript
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([]);
|
|
});
|
|
});
|