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
+25
-20
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user