From 7c59283ede122c5174fc7edd3d713dd27b22f01d Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Thu, 17 Sep 2026 06:32:12 +0200 Subject: [PATCH] feat: Umsatzsteuer ueber VAT_RATE_PERCENT konfigurierbar und auf der Rechnung ausgewiesen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Rechnung wies bisher gar keine Steuer aus — weder Satz und Betrag noch den Pflichthinweis nach § 19 UStG. Der Satz kommt jetzt aus VAT_RATE_PERCENT und steht auf PDF und HTML-Ansicht: 7 (ermaessigt, Honig als Lebensmittel), 19 (voll) oder 0 fuer die Kleinunternehmerregelung, bei der anstelle der Steuerzeilen der vorgeschriebene Hinweis erscheint. DIE SHOP-PREISE SIND BRUTTOPREISE. Gegenueber Verbrauchern 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 ergaebe einen anderen Endpreis als den, den die Kundschaft im Warenkorb gesehen und bezahlt hat. Gerechnet wird auf der Gesamtsumme statt je Position, damit sich Rundungen einzelner Zeilen nicht aufsummieren, und der Steuerbetrag ergibt sich als Rest statt aus einer zweiten Rundung: nur so gilt immer netto + steuer === brutto. Eine Rechnung, die sich nicht aufaddiert, ist keine brauchbare Rechnung. Der Versand traegt den Satz der Ware, er ist umsatzsteuerlich eine Nebenleistung. Der Satz wird als Invoice.vatRatePercent FESTGESCHRIEBEN statt bei jeder Anzeige aus der Umgebung gelesen. Wechselt der Steuerstatus des Betriebs, muessen alte Rechnungen weiterhin den damals ausgewiesenen Satz zeigen — ein Beleg ist die Aufzeichnung eines vergangenen Vorgangs, keine Ansicht auf die heutige Konfiguration. Bestehende Rechnungen tragen null und zeigen wie bisher keine Steuerzeilen, weil rueckwirkend nicht feststellbar ist, welcher Satz gegolten haette. Ein unbrauchbarer Wert faellt auf 7% zurueck und wird laut geloggt, statt eine bereits bezahlte Bestellung scheitern zu lassen. Damit das nicht unbemerkt bleibt, prueft "pnpm preflight" den Wert jetzt als Blocker und warnt zusaetzlich bei einem in Deutschland unueblichen Satz. Der bisherige legalNote war ein Platzhalter ("Kleinunternehmerregelung / Umsatzsteuerausweis gemaess tatsaechlichem Status ergaenzen") und ist ersetzt, da die Angabe nun tatsaechlich erfolgt. Bekannte Grenze, im README vermerkt: es gibt genau EINEN Satz. Honig ist ermaessigt besteuert, Kerzen nicht — werden beide verkauft, braucht es einen Satz je ProductCategory. Co-Authored-By: Claude Opus 5 (1M context) --- .env.example | 15 +++ README.md | 19 +++- k8s/01-configmap.yaml | 14 +++ .../migration.sql | 9 ++ prisma/schema.prisma | 13 +++ scripts/preflight.ts | 17 ++++ src/app/api/checkout/webhook/route.ts | 6 ++ src/app/rechnung/[invoiceNumber]/page.tsx | 27 ++++++ 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 | 3 + src/lib/invoice-pdf.ts | 29 ++++++ src/lib/vat.test.ts | 93 +++++++++++++++++++ src/lib/vat.ts | 83 +++++++++++++++++ 15 files changed, 334 insertions(+), 7 deletions(-) create mode 100644 prisma/migrations/20260917060000_invoice_vat_rate/migration.sql create mode 100644 src/lib/vat.test.ts create mode 100644 src/lib/vat.ts diff --git a/.env.example b/.env.example index acc7af6..64988d9 100644 --- a/.env.example +++ b/.env.example @@ -214,6 +214,21 @@ UPLOAD_DIR=./uploads # k8s/04-uploads-pvc.yaml und den Sicherungs-CronJob k8s/07-backup-cronjob.yaml. INVOICE_DIR=./invoices +# Umsatzsteuersatz in GANZEN Prozent, ausgewiesen auf jeder Rechnung. +# 7 = ermaessigter Satz (Honig als Lebensmittel, Anlage 2 zum UStG) +# 19 = voller Satz +# 0 = Kleinunternehmerregelung; die Rechnung traegt dann statt der +# Steuerzeilen den Pflichthinweis nach § 19 UStG +# +# Die Shop-Preise sind BRUTTOPREISE (gegenueber Verbrauchern ist der +# Gesamtpreis inkl. Umsatzsteuer anzugeben, § 1 PAngV). Die Steuer wird daraus +# HERAUSGERECHNET, nicht aufgeschlagen — der Endpreis bleibt genau der, den die +# Kundschaft im Warenkorb gesehen und bezahlt hat. +# +# Der Satz wird an jeder Rechnung festgeschrieben: eine Aenderung hier wirkt +# nur auf kuenftige Rechnungen, bereits ausgestellte behalten ihren Satz. +VAT_RATE_PERCENT=7 + # 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 127c682..7274c7d 100644 --- a/README.md +++ b/README.md @@ -274,10 +274,23 @@ Das Jahr steht zuerst, weil sich die Aufbewahrungsfrist danach richtet: ein abge > Bei **mehr als einem Knoten**: das PVC `wabenhain-invoices` ist `ReadWriteOnce` und lässt sich nur von Pods auf demselben Knoten einhängen. Landet der Sicherungs-Pod woanders als die App, bleibt er in „Pending" — ohne Fehlermeldung. Dann das PVC auf `ReadWriteMany` umstellen (NFS/SMB/CephFS). -**Noch offen** und vor dem Live-Gang zu klären: +### Umsatzsteuer -- Die Rechnungsnummer (`RE-2026-<8 Hex>`) ist eindeutig, aber **nicht fortlaufend**. § 14 Abs. 4 Nr. 4 UStG verlangt eine fortlaufende Nummer. -- Das PDF weist **keine Umsatzsteuer** aus — weder Satz und Betrag noch den Hinweis nach § 19 UStG bei Kleinunternehmerregelung. Welches von beidem nötig ist, hängt vom Steuerstatus der Imkerei ab. +Der Satz kommt aus `VAT_RATE_PERCENT` (ganze Prozent) und steht auf Rechnungsansicht und PDF: + +| 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 | + +**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`. + +**Noch offen:** die Rechnungsnummer (`RE-2026-<8 Hex>`) ist eindeutig, aber **nicht fortlaufend**. § 14 Abs. 4 Nr. 4 UStG verlangt eine fortlaufende Nummer. ## Health-Endpunkte diff --git a/k8s/01-configmap.yaml b/k8s/01-configmap.yaml index fd4fc50..853c00b 100644 --- a/k8s/01-configmap.yaml +++ b/k8s/01-configmap.yaml @@ -62,6 +62,20 @@ data: # src/lib/invoice-storage.ts und 04-uploads-pvc.yaml. INVOICE_DIR: "/app/invoices" + # Umsatzsteuersatz in GANZEN Prozent, der auf der Rechnung ausgewiesen wird. + # 7 = ermaessigter Satz (Honig als Lebensmittel, Anlage 2 zum UStG) + # 19 = voller Satz + # 0 = Kleinunternehmerregelung; die Rechnung traegt dann statt der + # Steuerzeilen den Pflichthinweis nach § 19 UStG + # + # Die Shop-Preise sind BRUTTOPREISE (§ 1 PAngV). Die Steuer wird daraus + # herausgerechnet, nicht aufgeschlagen — der Endpreis bleibt also genau der, + # den die Kundschaft im Warenkorb gesehen hat. + # + # Der Satz wird bei jeder Rechnung FESTGESCHRIEBEN. Eine Aenderung hier gilt + # nur fuer kuenftige Rechnungen; bereits ausgestellte behalten ihren Satz. + VAT_RATE_PERCENT: "7" + # 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/20260917060000_invoice_vat_rate/migration.sql b/prisma/migrations/20260917060000_invoice_vat_rate/migration.sql new file mode 100644 index 0000000..a32a5ae --- /dev/null +++ b/prisma/migrations/20260917060000_invoice_vat_rate/migration.sql @@ -0,0 +1,9 @@ +-- Umsatzsteuersatz in ganzen Prozent, festgeschrieben zum Zeitpunkt der +-- Rechnungsstellung (0 = Kleinunternehmerregelung nach § 19 UStG). +-- +-- Nullable, weil rueckwirkend nicht feststellbar ist, welcher Satz fuer +-- bereits bestehende Rechnungen richtig gewesen waere. Diese zeigen wie +-- bisher keine Steuerzeilen; erst ab hier ausgestellte Rechnungen weisen +-- die Steuer aus. +-- AlterTable +ALTER TABLE "invoices" ADD COLUMN "vatRatePercent" INTEGER; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e7df94a..def7949 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -524,6 +524,19 @@ 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? /// 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/scripts/preflight.ts b/scripts/preflight.ts index b387348..597a62d 100644 --- a/scripts/preflight.ts +++ b/scripts/preflight.ts @@ -113,6 +113,23 @@ should( "Absenderadressen fremder Domains landen regelmaessig im Spam-Ordner." ); +// --- Umsatzsteuer ---------------------------------------------------------- +// Ein falscher Steuersatz faellt im Betrieb kaum auf: die Rechnung sieht +// plausibel aus, nur die ausgewiesene Steuer stimmt nicht. Deshalb hier +// ausdruecklich pruefen, statt sich auf den Rueckfallwert zu verlassen. +const vatRate = value("VAT_RATE_PERCENT"); +const vatRateNumber = Number(vatRate); +must( + "VAT_RATE_PERCENT gesetzt und ein ganzzahliger Prozentwert", + vatRate.length > 0 && Number.isInteger(vatRateNumber) && vatRateNumber >= 0 && vatRateNumber <= 100, + "Wird auf jeder Rechnung ausgewiesen. 7 = ermaessigt (Honig), 19 = voll, 0 = Kleinunternehmerregelung nach § 19 UStG. Ohne gueltigen Wert rechnet die Anwendung mit 7% weiter — fuer Kerzen zu niedrig, fuer Kleinunternehmer schlicht falsch." +); +should( + "Umsatzsteuersatz ist ein in Deutschland ueblicher Wert", + ["0", "7", "19"].includes(vatRate), + `Eingetragen ist "${vatRate}". Ueblich sind 0 (§ 19 UStG), 7 (ermaessigt) oder 19 (voll) — ein anderer Wert ist moeglich, aber pruefenswert.` +); + // --- Datenbank ------------------------------------------------------------- must("DATABASE_URL gesetzt", isSet("DATABASE_URL"), "Ohne Datenbank startet die Anwendung nicht."); diff --git a/src/app/api/checkout/webhook/route.ts b/src/app/api/checkout/webhook/route.ts index 20e30d6..c864a70 100644 --- a/src/app/api/checkout/webhook/route.ts +++ b/src/app/api/checkout/webhook/route.ts @@ -9,6 +9,7 @@ import { getBaseUrl } from "@/lib/url"; import { getInvoiceByNumber } from "@/lib/account-data"; import { generateInvoicePdf } from "@/lib/invoice-pdf"; import { invoiceRelativePath, storeInvoicePdf } from "@/lib/invoice-storage"; +import { vatRatePercent } from "@/lib/vat"; import { isLocale, defaultLocale } from "@/i18n/config"; import type Stripe from "stripe"; @@ -40,6 +41,7 @@ async function storeInvoiceDocument(invoiceNumber: string): Promise { orderId: order.id, invoiceNumber: `RE-${new Date().getFullYear()}-${randomBytes(4).toString("hex").toUpperCase()}`, totalCents: order.totalCents, + // Satz festschreiben, nicht bei jeder Anzeige neu aus der Umgebung + // lesen: aendert sich der Steuerstatus des Betriebs, muss diese + // Rechnung weiterhin den damals gueltigen Satz ausweisen. + vatRatePercent: vatRatePercent(process.env.VAT_RATE_PERCENT), }, update: {}, }); diff --git a/src/app/rechnung/[invoiceNumber]/page.tsx b/src/app/rechnung/[invoiceNumber]/page.tsx index 69e2207..ed9e065 100644 --- a/src/app/rechnung/[invoiceNumber]/page.tsx +++ b/src/app/rechnung/[invoiceNumber]/page.tsx @@ -6,6 +6,7 @@ import { getCurrentCustomer } from "@/lib/auth"; import { getInvoiceForCustomer } from "@/lib/account-data"; import { formatPrice, formatDate } from "@/lib/format"; import { getCompanyInfo } from "@/lib/company-info"; +import { splitGross } from "@/lib/vat"; import { getLocale } from "@/i18n/locale"; import { getDictionary } from "@/i18n/get-dictionary"; @@ -23,6 +24,12 @@ export default async function InvoicePage(props: PageProps<"/rechnung/[invoiceNu const { order } = invoice; const company = await getCompanyInfo(); + // Nur einmal aufteilen statt je Zeile: derselbe Betrag zweimal zu zerlegen + // koennte bei spaeteren Aenderungen auseinanderlaufen. + const vat = + invoice.vatRatePercent != null && invoice.vatRatePercent > 0 + ? splitGross(invoice.totalCents, invoice.vatRatePercent) + : null; return (
@@ -99,10 +106,30 @@ export default async function InvoicePage(props: PageProps<"/rechnung/[invoiceNu −{formatPrice(order.discountCents, order.currency)}
) : null} + {/* Steuerausweis mit demselben an der Rechnung festgeschriebenen + Satz wie im PDF — beide Darstellungen desselben Belegs muessen + uebereinstimmen. */} + {vat ? ( + <> +
+ {t.invoicePage.netLabel} + {formatPrice(vat.netCents, order.currency)} +
+
+ {t.invoicePage.vatLabel(invoice.vatRatePercent ?? 0)} + {formatPrice(vat.vatCents, order.currency)} +
+ + ) : null}
{t.invoicePage.totalLabel} {formatPrice(invoice.totalCents, order.currency)}
+ {/* Pflichtangabe bei Kleinunternehmerregelung — steht anstelle der + Steuerzeilen, nicht zusaetzlich. */} + {invoice.vatRatePercent === 0 ? ( +

{t.invoicePage.smallBusinessNote}

+ ) : null} diff --git a/src/app/rechnung/[invoiceNumber]/pdf/route.ts b/src/app/rechnung/[invoiceNumber]/pdf/route.ts index 7c27cf3..4774582 100644 --- a/src/app/rechnung/[invoiceNumber]/pdf/route.ts +++ b/src/app/rechnung/[invoiceNumber]/pdf/route.ts @@ -40,6 +40,7 @@ export async function GET( invoiceNumber: invoice.invoiceNumber, issuedAt: invoice.issuedAt, totalCents: invoice.totalCents, + vatRatePercent: invoice.vatRatePercent, order: invoice.order, })); diff --git a/src/i18n/dictionaries/de.ts b/src/i18n/dictionaries/de.ts index 121d12b..2eb1814 100644 --- a/src/i18n/dictionaries/de.ts +++ b/src/i18n/dictionaries/de.ts @@ -1227,9 +1227,11 @@ export const de: Dictionary = { shippingLabel: "Versand", freeShipping: "kostenlos", discountLabel: "Rabatt", + netLabel: "Nettobetrag", + vatLabel: (ratePercent) => `zzgl. ${ratePercent} % Umsatzsteuer`, + smallBusinessNote: "Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.", totalLabel: "Gesamt", - legalNote: - "Kleinunternehmerregelung / Umsatzsteuerausweis gemäß tatsächlichem Status ergänzen. Diese Rechnung wurde automatisch erzeugt und ist ohne Unterschrift gültig.", + legalNote: "Diese Rechnung wurde automatisch erzeugt und ist ohne Unterschrift gültig.", }, addressesPage: { title: "Meine Adressen", diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts index e23e52f..52a0c24 100644 --- a/src/i18n/dictionaries/en.ts +++ b/src/i18n/dictionaries/en.ts @@ -1227,9 +1227,11 @@ export const en: Dictionary = { shippingLabel: "Shipping", freeShipping: "free", discountLabel: "Discount", + netLabel: "Net amount", + vatLabel: (ratePercent) => `plus ${ratePercent}% VAT`, + smallBusinessNote: "No VAT is charged (small-business scheme, § 19 German VAT Act).", totalLabel: "Total", - legalNote: - "Small-business tax exemption / VAT disclosure to be added according to actual status. This invoice was generated automatically and is valid without a signature.", + legalNote: "This invoice was generated automatically and is valid without a signature.", }, addressesPage: { title: "My Addresses", diff --git a/src/i18n/dictionary-type.ts b/src/i18n/dictionary-type.ts index 25ba73f..82564a9 100644 --- a/src/i18n/dictionary-type.ts +++ b/src/i18n/dictionary-type.ts @@ -914,6 +914,9 @@ export interface Dictionary { shippingLabel: string; freeShipping: string; discountLabel: string; + netLabel: string; + vatLabel: (ratePercent: number) => string; + smallBusinessNote: string; totalLabel: string; legalNote: string; }; diff --git a/src/lib/invoice-pdf.ts b/src/lib/invoice-pdf.ts index 72de724..001408f 100644 --- a/src/lib/invoice-pdf.ts +++ b/src/lib/invoice-pdf.ts @@ -2,6 +2,7 @@ import PDFDocument from "pdfkit"; import { formatPrice, formatDate } from "@/lib/format"; import { getCompanyInfo } from "@/lib/company-info"; import { getDictionary } from "@/i18n/get-dictionary"; +import { splitGross } from "@/lib/vat"; import type { Locale } from "@/i18n/config"; interface InvoicePdfInput { @@ -9,6 +10,13 @@ interface InvoicePdfInput { invoiceNumber: string; issuedAt: Date; totalCents: number; + /** + * Umsatzsteuersatz in ganzen Prozent, wie er an der Rechnung + * festgeschrieben wurde. 0 = Kleinunternehmerregelung (§ 19 UStG), null = + * Rechnung aus der Zeit vor der Steuerausweisung — dann bleibt der Block + * weg, weil rueckwirkend nicht feststellbar ist, welcher Satz gegolten hat. + */ + vatRatePercent?: number | null; order: { orderNumber: string; firstName: string; @@ -116,10 +124,31 @@ export async function generateInvoicePdf(input: InvoicePdfInput): Promise 0) { + const { netCents, vatCents } = splitGross(input.totalCents, input.vatRatePercent); + y += 16; + doc.fillColor("#666666").text(t.netLabel, 350, y); + doc.fillColor("#242424").text(formatPrice(netCents, input.order.currency), columns.total, y); + y += 16; + doc.fillColor("#666666").text(t.vatLabel(input.vatRatePercent), 350, y); + doc.fillColor("#242424").text(formatPrice(vatCents, input.order.currency), columns.total, y); + } + y += 20; doc.fontSize(11).fillColor("#24352B").text(t.totalLabel, 350, y); doc.text(formatPrice(input.totalCents, input.order.currency), columns.total, y); + // Der Hinweis nach § 19 UStG ist bei Kleinunternehmerregelung eine + // Pflichtangabe — er steht anstelle der Steuerzeilen, nicht zusaetzlich. + if (input.vatRatePercent === 0) { + y += 22; + doc.fontSize(9).fillColor("#666666").text(t.smallBusinessNote, 350, y, { width: 195 }); + } + doc .fontSize(8) .fillColor("#999999") diff --git a/src/lib/vat.test.ts b/src/lib/vat.test.ts new file mode 100644 index 0000000..b03031b --- /dev/null +++ b/src/lib/vat.test.ts @@ -0,0 +1,93 @@ +import { describe, test, expect, vi, afterEach } from "vitest"; +import { splitGross, vatRatePercent, 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 }); + }); +}); diff --git a/src/lib/vat.ts b/src/lib/vat.ts new file mode 100644 index 0000000..957ac43 --- /dev/null +++ b/src/lib/vat.ts @@ -0,0 +1,83 @@ +/** + * Umsatzsteuer auf Rechnungen. + * + * Der Satz kommt aus VAT_RATE_PERCENT und ist bewusst konfigurierbar statt + * fest verdrahtet: er haengt vom Steuerstatus des Betriebs ab und kann sich + * aendern, ohne dass dafuer ein Deploy noetig sein sollte. + * + * DIE PREISE IM SHOP SIND BRUTTOPREISE. Gegenueber Verbraucherinnen und + * Verbrauchern ist der Gesamtpreis inklusive Umsatzsteuer anzugeben (§ 1 + * PAngV), und genau dieser Betrag steht in Product.priceCents und in + * Order.totalCents. Die Steuer wird daher HERAUSGERECHNET, nicht + * aufgeschlagen — ein Aufschlag wuerde den Endpreis veraendern und damit von + * dem abweichen, was die Kundschaft im Warenkorb gesehen und bezahlt hat. + */ + +/** + * Ermaessigter Satz. Als Vorgabe gewaehlt, weil Honig als Lebensmittel + * darunter faellt (Anlage 2 zum UStG) und den Hauptteil des Sortiments + * ausmacht. + * + * ACHTUNG: Kerzen sind KEIN Lebensmittel und unterliegen dem vollen Satz. + * Solange es nur diesen einen Wert gibt, ist er fuer solche Artikel zu + * niedrig — siehe README-Abschnitt "Rechnungen". + */ +export const DEFAULT_VAT_RATE_PERCENT = 7; + +/** + * Liest den Steuersatz aus der Umgebung. + * + * Erlaubt sind ganze Prozentwerte von 0 bis 100. 0 bedeutet: keine + * Umsatzsteuer, also Kleinunternehmerregelung nach § 19 UStG — die Rechnung + * traegt dann statt der Steuerzeilen den dafuer vorgeschriebenen Hinweis. + * + * Ein unbrauchbarer Wert faellt auf die Vorgabe zurueck UND wird laut + * geloggt, statt den Vorgang abzubrechen: die Steuerausweisung darf keine + * bereits bezahlte Bestellung scheitern lassen. Damit ein falscher Wert + * trotzdem nicht unbemerkt bleibt, prueft ihn zusaetzlich "pnpm preflight", + * und der verwendete Satz steht auf jeder Rechnung. + * + * Nur ganze Zahlen: die deutschen Saetze sind ganzzahlig (7, 19), und ein + * versehentlich mit Komma geschriebenes "7,5" wuerde sonst still als 7 + * durchgehen. + */ +export function vatRatePercent(raw: string | undefined): number { + const trimmed = raw?.trim(); + if (!trimmed) return DEFAULT_VAT_RATE_PERCENT; + + const parsed = Number(trimmed); + if (!Number.isInteger(parsed) || parsed < 0 || parsed > 100) { + console.error( + `[vat] VAT_RATE_PERCENT="${raw}" ist kein ganzzahliger Prozentwert zwischen 0 und 100 — es wird mit ${DEFAULT_VAT_RATE_PERCENT}% gerechnet.` + ); + return DEFAULT_VAT_RATE_PERCENT; + } + return parsed; +} + +export interface VatSplit { + /** Nettobetrag in Cent. */ + netCents: number; + /** Steuerbetrag in Cent. */ + vatCents: number; +} + +/** + * Zerlegt einen Bruttobetrag in Netto und Steuer. + * + * Der Steuerbetrag ergibt sich als REST (brutto minus netto), nicht aus einer + * zweiten Rundung. Nur so gilt immer netto + steuer === brutto; zwei getrennt + * gerundete Werte koennen um einen Cent danebenliegen, und eine Rechnung, die + * sich nicht aufaddiert, ist keine brauchbare Rechnung. + * + * Gerechnet wird auf der GESAMTSUMME, nicht je Position: Rundungsfehler + * einzelner Zeilen wuerden sich sonst aufsummieren. Der Versand traegt dabei + * den Satz der Ware — er ist umsatzsteuerlich eine Nebenleistung und folgt + * der Hauptleistung. + */ +export function splitGross(grossCents: number, ratePercent: number): VatSplit { + if (ratePercent <= 0) return { netCents: grossCents, vatCents: 0 }; + + const netCents = Math.round(grossCents / (1 + ratePercent / 100)); + return { netCents, vatCents: grossCents - netCents }; +}