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

+11
View File
@@ -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.
#
+19 -9
View File
@@ -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.
+11
View File
@@ -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
@@ -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;
+24 -13
View File
@@ -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.
///
+4
View File
@@ -39,6 +39,7 @@ export async function createProductAction(formData: FormData): Promise<void> {
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<void> {
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<void> {
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<void> {
weightGrams: numbers.weightGrams,
priceCents: numbers.priceCents,
pricePerKgCents: numbers.pricePerKgCents,
vatRatePercent: numbers.vatRatePercent,
images: strArray(formData, "images").filter(isValidUploadPath),
isActive,
inventory: {
+11
View File
@@ -132,6 +132,17 @@ export default async function AdminProductEditPage(props: PageProps<"/admin/prod
/>
</div>
{/* 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). */}
<Field
name="vatRatePercent"
label={t.adminProductEditPage.vatRateLabel}
type="number"
defaultValue={product.vatRatePercent == null ? "" : String(product.vatRatePercent)}
/>
<p className="text-xs text-ink/65">{t.adminProductEditPage.vatRateHint}</p>
<label className="flex items-center gap-2 text-sm text-ink/70">
<input type="checkbox" name="isActive" defaultChecked={product.isActive} />
{t.adminProductEditPage.visibleInShop}
+6
View File
@@ -127,6 +127,12 @@ export default async function AdminProductNewPage(props: PageProps<"/admin/produ
/>
</div>
{/* 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). */}
<Field name="vatRatePercent" label={t.adminProductNewPage.vatRateLabel} type="number" />
<p className="text-xs text-ink/65">{t.adminProductNewPage.vatRateHint}</p>
<label className="flex items-center gap-2 text-sm text-ink/70">
<input type="checkbox" name="isActive" defaultChecked />
{t.adminProductNewPage.visibleInShop}
+5
View File
@@ -12,6 +12,7 @@ import { getShippingCostCents } from "@/lib/shipping";
import { isDiscountCodeValid, calculateDiscountCents } from "@/lib/discount-calc";
import { ensureBulkDiscountCode } from "@/lib/bulk-discount";
import { getBaseUrl } from "@/lib/url";
import { vatRateForProduct } from "@/lib/vat";
import { getLocale } from "@/i18n/locale";
class OutOfStockError extends Error {}
@@ -128,6 +129,10 @@ export async function POST(request: Request) {
quantity: cartItem.quantity,
weightGrams: product.weightGrams,
totalCents: product.priceCents * cartItem.quantity,
// Teil des Snapshots wie der Preis: der Satz haengt an der Art der Ware
// (Honig ermaessigt, Kerzen voll). Wird ein Produkt spaeter
// umkategorisiert, darf das diese Bestellung nicht mehr veraendern.
vatRatePercent: vatRateForProduct(product, process.env),
};
});
+4 -6
View File
@@ -9,7 +9,6 @@ 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";
@@ -41,7 +40,6 @@ async function storeInvoiceDocument(invoiceNumber: string): Promise<Buffer | nul
invoiceNumber: invoice.invoiceNumber,
issuedAt: invoice.issuedAt,
totalCents: invoice.totalCents,
vatRatePercent: invoice.vatRatePercent,
order: invoice.order,
});
@@ -208,10 +206,10 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise<void> {
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),
// Kein Steuersatz an der Rechnung: er haengt an der einzelnen
// Position, weil eine Bestellung Waren mit verschiedenen Saetzen
// enthalten kann (Honig 7%, Bienenwachskerze 19%). Festgeschrieben
// wird er beim Anlegen der Bestellung (api/checkout/route.ts).
},
update: {},
});
+20 -19
View File
@@ -6,7 +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 { invoiceVatBreakdown } from "@/lib/vat";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
@@ -24,12 +24,11 @@ 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;
// Aufstellung je Steuersatz — eine Bestellung kann Honig (ermaessigt) und
// eine Bienenwachskerze (voll) zugleich enthalten. Dieselbe Funktion nutzt
// das PDF, damit beide Darstellungen desselben Belegs uebereinstimmen.
const vatLines = invoiceVatBreakdown(order);
const isSmallBusiness = vatLines.length > 0 && vatLines.every((line) => line.ratePercent === 0);
return (
<div className="py-16 print:py-0">
@@ -109,25 +108,27 @@ export default async function InvoicePage(props: PageProps<"/rechnung/[invoiceNu
{/* Steuerausweis mit demselben an der Rechnung festgeschriebenen
Satz wie im PDF — beide Darstellungen desselben Belegs muessen
uebereinstimmen. */}
{vat ? (
<>
<div className="flex justify-between">
<span className="text-ink/70">{t.invoicePage.netLabel}</span>
<span>{formatPrice(vat.netCents, order.currency)}</span>
{vatLines
.filter((line) => line.ratePercent > 0)
.map((line) => (
<div key={line.ratePercent}>
<div className="flex justify-between">
<span className="text-ink/70">{t.invoicePage.netLabel}</span>
<span>{formatPrice(line.netCents, order.currency)}</span>
</div>
<div className="flex justify-between">
<span className="text-ink/70">{t.invoicePage.vatLabel(line.ratePercent)}</span>
<span>{formatPrice(line.vatCents, order.currency)}</span>
</div>
</div>
<div className="flex justify-between">
<span className="text-ink/70">{t.invoicePage.vatLabel(invoice.vatRatePercent ?? 0)}</span>
<span>{formatPrice(vat.vatCents, order.currency)}</span>
</div>
</>
) : null}
))}
<div className="flex justify-between text-forest pt-2 border-t border-ink/10">
<span>{t.invoicePage.totalLabel}</span>
<span>{formatPrice(invoice.totalCents, order.currency)}</span>
</div>
{/* Pflichtangabe bei Kleinunternehmerregelung — steht anstelle der
Steuerzeilen, nicht zusaetzlich. */}
{invoice.vatRatePercent === 0 ? (
{isSmallBusiness ? (
<p className="pt-2 text-xs text-ink/65">{t.invoicePage.smallBusinessNote}</p>
) : null}
</div>
@@ -40,7 +40,6 @@ export async function GET(
invoiceNumber: invoice.invoiceNumber,
issuedAt: invoice.issuedAt,
totalCents: invoice.totalCents,
vatRatePercent: invoice.vatRatePercent,
order: invoice.order,
}));
+6
View File
@@ -973,6 +973,9 @@ export const de: Dictionary = {
stockLabel: "Lagerbestand (Gläser)",
priceLabel: "Preis (Cent)",
pricePerKgLabel: "Grundpreis pro kg (Cent)",
vatRateLabel: "Umsatzsteuersatz (%, optional)",
vatRateHint:
"Leer lassen: es gilt der Satz der Kategorie (Honig 7 %, Kerzen 19 %). Nur ausfüllen, wenn dieses Produkt davon abweicht. 0 bedeutet steuerfrei nach § 19 UStG.",
imagesLabel: "Produktbilder",
visibleInShop: "Im Shop sichtbar",
save: "Speichern",
@@ -1001,6 +1004,9 @@ export const de: Dictionary = {
stockLabel: "Lagerbestand (Gläser)",
priceLabel: "Preis (Cent)",
pricePerKgLabel: "Grundpreis pro kg (Cent)",
vatRateLabel: "Umsatzsteuersatz (%, optional)",
vatRateHint:
"Leer lassen: es gilt der Satz der Kategorie (Honig 7 %, Kerzen 19 %). Nur ausfüllen, wenn dieses Produkt davon abweicht. 0 bedeutet steuerfrei nach § 19 UStG.",
pricePlaceholder: "990",
pricePerKgPlaceholder: "1980",
imagesLabel: "Produktbilder",
+6
View File
@@ -973,6 +973,9 @@ export const en: Dictionary = {
stockLabel: "Inventory (jars)",
priceLabel: "Price (cents)",
pricePerKgLabel: "Base price per kg (cents)",
vatRateLabel: "VAT rate (%, optional)",
vatRateHint:
"Leave empty to use the category rate (honey 7%, candles 19%). Fill in only if this product differs. 0 means exempt under § 19 German VAT Act.",
imagesLabel: "Product images",
visibleInShop: "Visible in shop",
save: "Save",
@@ -1001,6 +1004,9 @@ export const en: Dictionary = {
stockLabel: "Inventory (jars)",
priceLabel: "Price (cents)",
pricePerKgLabel: "Base price per kg (cents)",
vatRateLabel: "VAT rate (%, optional)",
vatRateHint:
"Leave empty to use the category rate (honey 7%, candles 19%). Fill in only if this product differs. 0 means exempt under § 19 German VAT Act.",
pricePlaceholder: "990",
pricePerKgPlaceholder: "1980",
imagesLabel: "Product images",
+4
View File
@@ -671,6 +671,8 @@ export interface Dictionary {
stockLabel: string;
priceLabel: string;
pricePerKgLabel: string;
vatRateLabel: string;
vatRateHint: string;
imagesLabel: string;
visibleInShop: string;
save: string;
@@ -698,6 +700,8 @@ export interface Dictionary {
stockLabel: string;
priceLabel: string;
pricePerKgLabel: string;
vatRateLabel: string;
vatRateHint: string;
pricePlaceholder: string;
pricePerKgPlaceholder: string;
imagesLabel: string;
+25 -20
View File
@@ -2,7 +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 { invoiceVatBreakdown } from "@/lib/vat";
import type { Locale } from "@/i18n/config";
interface InvoicePdfInput {
@@ -10,13 +10,6 @@ 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;
@@ -29,7 +22,15 @@ interface InvoicePdfInput {
shippingCents: number;
discountCents: number;
discountCode?: { code: string } | null;
items: { productName: string; weightGrams: number; quantity: number; unitPriceCents: number; totalCents: number }[];
items: {
productName: string;
weightGrams: number;
quantity: number;
unitPriceCents: number;
totalCents: number;
/** Satz dieser Position; null bei Bestellungen vor der Steuerausweisung. */
vatRatePercent: number | null;
}[];
};
}
@@ -124,18 +125,21 @@ export async function generateInvoicePdf(input: InvoicePdfInput): Promise<Buffer
.fillColor("#242424")
.text(`−${formatPrice(input.order.discountCents, input.order.currency)}`, columns.total, y);
}
// Steuerausweis. Der Satz steht an der Rechnung selbst (nicht in der
// Umgebung), damit ein alter Beleg auch nach einem Wechsel des
// Steuerstatus unveraendert bleibt. Null = Rechnung aus der Zeit vor der
// Steuerausweisung: dann bleibt der Block weg wie bisher.
if (input.vatRatePercent != null && input.vatRatePercent > 0) {
const { netCents, vatCents } = splitGross(input.totalCents, input.vatRatePercent);
// Steuerausweis JE SATZ: eine Bestellung kann Honig (ermaessigt) und eine
// Bienenwachskerze (voll) zugleich enthalten. Die Saetze stehen an den
// Positionen, nicht in der Umgebung — ein alter Beleg bleibt damit auch
// nach einer Umkategorisierung unveraendert. Eine leere Aufstellung heisst:
// Bestellung aus der Zeit vor der Steuerausweisung, dann bleibt der Block
// weg wie bisher.
const breakdown = invoiceVatBreakdown(input.order);
for (const line of breakdown) {
if (line.ratePercent <= 0) continue;
y += 16;
doc.fillColor("#666666").text(t.netLabel, 350, y);
doc.fillColor("#242424").text(formatPrice(netCents, input.order.currency), columns.total, y);
doc.fillColor("#242424").text(formatPrice(line.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);
doc.fillColor("#666666").text(t.vatLabel(line.ratePercent), 350, y);
doc.fillColor("#242424").text(formatPrice(line.vatCents, input.order.currency), columns.total, y);
}
y += 20;
@@ -143,8 +147,9 @@ export async function generateInvoicePdf(input: InvoicePdfInput): Promise<Buffer
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) {
// Pflichtangabe — er steht anstelle der Steuerzeilen, nicht zusaetzlich,
// und nur, wenn die GESAMTE Rechnung steuerfrei ist.
if (breakdown.length > 0 && breakdown.every((line) => line.ratePercent === 0)) {
y += 22;
doc.fontSize(9).fillColor("#666666").text(t.smallBusinessNote, 350, y, { width: 195 });
}
+17 -1
View File
@@ -55,6 +55,22 @@ function envNamesUsedInCode(dir: string): Set<string> {
return found;
}
/**
* Schluessel, die die App zwar liest, aber nicht ueber ein woertliches
* `process.env.NAME` — deshalb findet sie die Suche unten nicht.
*
* lib/vat.ts bekommt die Umgebung als Parameter uebergeben, damit sich die
* Steuersaetze ohne globalen Zustand testen lassen, und greift auf die
* Kategorie-Saetze ueber einen berechneten Schluessel zu. Beides ist Absicht;
* eine Regex kann es nicht sehen.
*/
const READ_INDIRECTLY: Record<string, string> = {
VAT_RATE_PERCENT: "lib/vat.ts — als Parameter uebergebene Umgebung (vatRateForCategory).",
VAT_RATE_PERCENT_HONIG: "lib/vat.ts — berechneter Schluessel in CATEGORY_ENV_NAMES.",
VAT_RATE_PERCENT_KERZEN: "lib/vat.ts — berechneter Schluessel in CATEGORY_ENV_NAMES.",
VAT_RATE_PERCENT_PROPOLIS: "lib/vat.ts — berechneter Schluessel in CATEGORY_ENV_NAMES.",
};
/**
* Schluessel, die in den Manifesten stehen duerfen, ohne dass die App sie
* liest. Wer hier etwas ergaenzt, muss benennen koennen, WER den Wert sonst
@@ -98,7 +114,7 @@ describe("Kubernetes-Manifeste und Code", () => {
// Act
const unused = [...manifestKeys(CONFIGMAP), ...manifestKeys(SECRET)]
.filter((name) => !used.has(name) && !(name in USED_OUTSIDE_THE_APP))
.filter((name) => !used.has(name) && !(name in USED_OUTSIDE_THE_APP) && !(name in READ_INDIRECTLY))
.sort();
// Assert: toter Konfigurationsballast ist irrefuehrend — er sieht aus,
+17
View File
@@ -109,6 +109,23 @@ export const adminProductNumbersSchema = z.object({
priceCents: z.coerce.number().int().min(0, "Der Preis darf nicht negativ sein."),
pricePerKgCents: z.coerce.number().int().min(0, "Der Kilopreis darf nicht negativ sein."),
quantity: z.coerce.number().int().min(0, "Der Lagerbestand darf nicht negativ sein."),
/**
* Umsatzsteuersatz als Ausnahme zur Kategorie. Leeres Feld = null = "Satz
* der Kategorie" (der Normalfall, siehe lib/vat.ts) — deshalb ein eigener
* Zweig fuer den Leerstring: z.coerce.number() machte daraus sonst eine 0,
* und 0 bedeutet hier "steuerfrei nach § 19 UStG", also etwas voellig
* anderes als "nicht gesetzt".
*/
vatRatePercent: z
.union([
z.literal("").transform(() => null),
z.coerce
.number()
.int("Der Steuersatz muss eine ganze Zahl sein.")
.min(0, "Der Steuersatz darf nicht negativ sein.")
.max(100, "Der Steuersatz darf höchstens 100 betragen."),
])
.default(null),
});
export type AdminProductNumbersInput = z.infer<typeof adminProductNumbersSchema>;
+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([]);
});
});
+170
View File
@@ -81,3 +81,173 @@ export function splitGross(grossCents: number, ratePercent: number): VatSplit {
const netCents = Math.round(grossCents / (1 + ratePercent / 100));
return { netCents, vatCents: grossCents - netCents };
}
// ---------------------------------------------------------------------------
// Satz je Produktkategorie
// ---------------------------------------------------------------------------
/**
* Umgebungsvariable je Kategorie. 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 ist es nicht.
*
* Fehlt der kategoriespezifische Wert, gilt VAT_RATE_PERCENT als gemeinsamer
* Rueckfallwert — so bleibt eine bestehende Konfiguration mit nur diesem
* einen Wert weiterhin gueltig.
*/
const CATEGORY_ENV_NAMES = {
HONIG: "VAT_RATE_PERCENT_HONIG",
KERZEN: "VAT_RATE_PERCENT_KERZEN",
PROPOLIS: "VAT_RATE_PERCENT_PROPOLIS",
} as const;
export type VatCategory = keyof typeof CATEGORY_ENV_NAMES;
export function isVatCategory(value: string): value is VatCategory {
return value in CATEGORY_ENV_NAMES;
}
/**
* Steuersatz fuer eine Produktkategorie.
*
* Reihenfolge: kategoriespezifischer Wert, sonst der gemeinsame
* VAT_RATE_PERCENT, sonst der ermaessigte Satz. Eine unbekannte Kategorie
* (etwa eine spaeter ergaenzte) faellt ebenfalls auf den gemeinsamen Wert
* zurueck, statt zu raten.
*/
export function vatRateForCategory(
category: string,
env: Record<string, string | undefined>
): number {
const specific = isVatCategory(category) ? env[CATEGORY_ENV_NAMES[category]] : undefined;
return vatRatePercent(specific?.trim() ? specific : env.VAT_RATE_PERCENT);
}
/**
* Steuersatz eines konkreten Produkts.
*
* Ein am Produkt gesetzter Wert gewinnt immer — er ist die Ausnahme fuer
* Artikel, die sich nicht aus ihrer Kategorie ergeben. Ist dort nichts
* hinterlegt (der Normalfall), gilt der Satz der Kategorie; eine Aenderung
* der gesetzlichen Saetze wirkt dann ueber die Konfiguration auf alle
* betroffenen Produkte zugleich, statt in jedem Datensatz nachgepflegt
* werden zu muessen.
*
* Ein unsinniger Wert am Produkt (ausserhalb 0 bis 100) wird ignoriert und
* faellt auf die Kategorie zurueck, statt eine Bestellung scheitern zu
* lassen — dieselbe Haltung wie bei der Umgebungsvariablen.
*/
export function vatRateForProduct(
product: { category: string; vatRatePercent?: number | null },
env: Record<string, string | undefined>
): number {
const own = product.vatRatePercent;
if (own != null && Number.isInteger(own) && own >= 0 && own <= 100) return own;
return vatRateForCategory(product.category, env);
}
// ---------------------------------------------------------------------------
// Steueraufstellung einer Rechnung
// ---------------------------------------------------------------------------
export interface VatBreakdownLine extends VatSplit {
ratePercent: number;
grossCents: number;
}
export interface VatBreakdownInput {
/** Bestellpositionen mit ihrem bei der Bestellung festgehaltenen Satz. */
items: ReadonlyArray<{ totalCents: number; vatRatePercent: number }>;
shippingCents: number;
discountCents: number;
}
/**
* Verteilt einen Betrag im Verhaeltnis der Gewichte, ohne dass dabei Cents
* verschwinden oder entstehen.
*
* Nach dem Abrunden jedes Anteils bleibt ein Rest von wenigen Cent uebrig;
* der geht an die Anteile mit dem groessten abgeschnittenen Nachkommaanteil
* (Hare-Niemeyer). Eine blosse Rundung je Anteil koennte in der Summe daneben
* liegen — bei Geldbetraegen auf einer Rechnung ist das nicht hinnehmbar.
*/
function distributeProportionally(amountCents: number, weights: readonly number[]): number[] {
const totalWeight = weights.reduce((sum, weight) => sum + weight, 0);
if (totalWeight <= 0 || amountCents === 0) return weights.map(() => 0);
const exact = weights.map((weight) => (amountCents * weight) / totalWeight);
const shares = exact.map((value) => Math.floor(value));
let remainder = amountCents - shares.reduce((sum, share) => sum + share, 0);
const byLargestFraction = exact
.map((value, index) => ({ index, fraction: value - Math.floor(value) }))
.sort((a, b) => b.fraction - a.fraction);
for (const { index } of byLargestFraction) {
if (remainder <= 0) break;
shares[index] += 1;
remainder -= 1;
}
return shares;
}
/**
* Stellt die Umsatzsteuer einer Rechnung je Satz auf.
*
* Eine Bestellung kann Waren mit verschiedenen Saetzen enthalten — ein Glas
* Honig (7%) und eine Bienenwachskerze (19%) im selben Paket. Die Rechnung
* muss dann JE SATZ Netto und Steuer ausweisen, nicht einen Mischsatz.
*
* Versand und Rabatt werden im Verhaeltnis der Warenwerte auf die Saetze
* verteilt: der Versand ist umsatzsteuerlich eine Nebenleistung und teilt das
* Schicksal der Hauptleistung — gibt es mehrere Hauptleistungen, teilt er
* sich anteilig auf. Fuer den Rabatt gilt dasselbe, er mindert alle Saetze
* im selben Verhaeltnis.
*
* Zugesichert: die Summe aller grossCents ergibt exakt den Rechnungsbetrag,
* und je Zeile gilt netCents + vatCents === grossCents.
*/
export function vatBreakdown(input: VatBreakdownInput): VatBreakdownLine[] {
const grossByRate = new Map<number, number>();
for (const item of input.items) {
grossByRate.set(item.vatRatePercent, (grossByRate.get(item.vatRatePercent) ?? 0) + item.totalCents);
}
if (grossByRate.size === 0) return [];
const rates = [...grossByRate.keys()].sort((a, b) => a - b);
const itemGross = rates.map((rate) => grossByRate.get(rate) ?? 0);
const shipping = distributeProportionally(input.shippingCents, itemGross);
const discount = distributeProportionally(input.discountCents, itemGross);
return rates.map((ratePercent, index) => {
const grossCents = itemGross[index] + shipping[index] - discount[index];
return { ratePercent, grossCents, ...splitGross(grossCents, ratePercent) };
});
}
/**
* Steueraufstellung einer Bestellung, wie sie Rechnungsansicht und PDF
* anzeigen. Gemeinsame Stelle fuer beide, damit dieselbe Rechnung nicht in
* zwei Darstellungen unterschiedlich gerechnet wird.
*
* Fehlt auch nur an EINER Position der Satz, bleibt die Aufstellung leer.
* Ein teilweiser Ausweis waere schlimmer als gar keiner: die Summe der
* Steuerzeilen ergaebe dann nicht den Rechnungsbetrag. Der Fall betrifft
* Bestellungen aus der Zeit vor der Steuerausweisung.
*/
export function invoiceVatBreakdown(order: {
items: ReadonlyArray<{ totalCents: number; vatRatePercent: number | null }>;
shippingCents: number;
discountCents: number;
}): VatBreakdownLine[] {
const items: { totalCents: number; vatRatePercent: number }[] = [];
for (const item of order.items) {
if (item.vatRatePercent == null) return [];
items.push({ totalCents: item.totalCents, vatRatePercent: item.vatRatePercent });
}
return vatBreakdown({
items,
shippingCents: order.shippingCents,
discountCents: order.discountCents,
});
}