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]>
165 lines
5.8 KiB
TypeScript
165 lines
5.8 KiB
TypeScript
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;
|
||
}
|