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:
1 parent
7c59283ede
commit
625b73a8c8
20 files changed
+518
-70
No files matched your search
@@ -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: {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user