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
@@ -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),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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: {},
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user