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 }; +}