Files
Wabenhain/src/lib/invoice-pdf.ts
T
Nick SzaunigandClaude Opus 5 625b73a8c8 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]>
2026-09-17 06:54:26 +02:00

165 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import PDFDocument from "pdfkit";
import { formatPrice, formatDate } from "@/lib/format";
import { getCompanyInfo } from "@/lib/company-info";
import { getDictionary } from "@/i18n/get-dictionary";
import { invoiceVatBreakdown } from "@/lib/vat";
import type { Locale } from "@/i18n/config";
interface InvoicePdfInput {
locale: Locale;
invoiceNumber: string;
issuedAt: Date;
totalCents: number;
order: {
orderNumber: string;
firstName: string;
lastName: string;
street: string;
zip: string;
city: string;
currency: string;
subtotalCents: number;
shippingCents: number;
discountCents: number;
discountCode?: { code: string } | null;
items: {
productName: string;
weightGrams: number;
quantity: number;
unitPriceCents: number;
totalCents: number;
/** Satz dieser Position; null bei Bestellungen vor der Steuerausweisung. */
vatRatePercent: number | null;
}[];
};
}
/**
* Erzeugt die Rechnung als PDF (pdfkit — reine JS-Bibliothek ohne
* Headless-Browser/native Abhaengigkeiten, haelt das Docker-Image schlank).
* Wird on-the-fly beim Download erzeugt statt dauerhaft gespeichert. Nutzt
* dieselben Uebersetzungen wie die HTML-Rechnungsansicht (t.invoicePage),
* damit beide Darstellungen derselben Rechnung konsistent bleiben.
*/
export async function generateInvoicePdf(input: InvoicePdfInput): Promise<Buffer> {
const t = getDictionary(input.locale).invoicePage;
const company = await getCompanyInfo();
const doc = new PDFDocument({ margin: 50, size: "A4" });
const chunks: Buffer[] = [];
doc.on("data", (chunk: Buffer) => chunks.push(chunk));
const done = new Promise<Buffer>((resolve, reject) => {
doc.on("end", () => resolve(Buffer.concat(chunks)));
doc.on("error", reject);
});
doc.fontSize(20).fillColor("#24352B").text(company.name.toUpperCase(), { continued: false });
doc
.fontSize(9)
.fillColor("#666666")
.text(`${company.address.street}, ${company.address.zip} ${company.address.city}`);
doc.moveDown(1.5);
doc.fontSize(14).fillColor("#24352B").text(t.invoiceLabel(input.invoiceNumber));
doc
.fontSize(9)
.fillColor("#666666")
.text(t.orderLabel(input.order.orderNumber))
.text(`${t.dateLabel} ${formatDate(input.issuedAt)}`);
doc.moveDown(1);
doc
.fontSize(10)
.fillColor("#242424")
.text(`${input.order.firstName} ${input.order.lastName}`)
.text(input.order.street)
.text(`${input.order.zip} ${input.order.city}`);
doc.moveDown(1.5);
const tableTop = doc.y;
const columns = { name: 50, qty: 320, price: 380, total: 470 };
doc
.fontSize(9)
.fillColor("#666666")
.text(t.itemHeader, columns.name, tableTop)
.text(t.quantityHeader, columns.qty, tableTop)
.text(t.priceHeader, columns.price, tableTop)
.text(t.totalHeader, columns.total, tableTop);
doc
.moveTo(50, tableTop + 14)
.lineTo(545, tableTop + 14)
.strokeColor("#dddddd")
.stroke();
let y = tableTop + 22;
doc.fontSize(10).fillColor("#242424");
for (const item of input.order.items) {
doc.text(`${item.productName} (${item.weightGrams} g)`, columns.name, y, { width: 260 });
doc.text(String(item.quantity), columns.qty, y);
doc.text(formatPrice(item.unitPriceCents, input.order.currency), columns.price, y);
doc.text(formatPrice(item.totalCents, input.order.currency), columns.total, y);
y += 20;
}
y += 10;
doc.moveTo(350, y).lineTo(545, y).strokeColor("#dddddd").stroke();
y += 10;
doc.fontSize(10).fillColor("#666666").text(t.subtotalLabel, 350, y);
doc.fillColor("#242424").text(formatPrice(input.order.subtotalCents, input.order.currency), columns.total, y);
y += 16;
doc.fillColor("#666666").text(t.shippingLabel, 350, y);
doc
.fillColor("#242424")
.text(
input.order.shippingCents === 0 ? t.freeShipping : formatPrice(input.order.shippingCents, input.order.currency),
columns.total,
y
);
if (input.order.discountCents > 0) {
y += 16;
doc
.fillColor("#666666")
.text(input.order.discountCode ? `${t.discountLabel} (${input.order.discountCode.code})` : t.discountLabel, 350, y);
doc
.fillColor("#242424")
.text(`−${formatPrice(input.order.discountCents, input.order.currency)}`, columns.total, y);
}
// 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(line.netCents, input.order.currency), columns.total, y);
y += 16;
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;
doc.fontSize(11).fillColor("#24352B").text(t.totalLabel, 350, y);
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,
// 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 });
}
doc
.fontSize(8)
.fillColor("#999999")
.text(t.legalNote, 50, 760, { width: 495 });
doc.end();
return done;
}