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

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