Files
Wabenhain/src/lib/invoice-pdf-brand.ts
T
Nick SzaunigandClaude Opus 5.5 77bdd9958c feat(rechnung): branded invoice with VAT ID and per-rate grouping
The invoice PDF and the invoice page now share one branded layout:
logo (uploaded brand logo, otherwise the drawn honeycomb mark) and
wordmark in the header, honey accents, a forest-green table header,
a meta box with invoice number, date, order number and VAT ID, and a
footer with address, contact, VAT ID and page numbers on every page.

A mixed cart (e.g. honey at 7 % and a beeswax candle at 19 %) lists
its items in one block per VAT rate with a subtotal each. Next to the
totals, a VAT table shows net, VAT and gross per rate plus a sum row.
Long invoices continue on further pages with the table header repeated.

The VAT identification number is a new company field
(company.vatId), editable in Admin > Inhalte, defaulting to the
number already stated in the imprint.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 00:49:59 +02:00

109 lines
3.4 KiB
TypeScript

import { readFile } from "node:fs/promises";
import sharp from "sharp";
import { getBrandAssets } from "@/lib/brand";
import { resolveUploadPath } from "@/lib/uploads";
/**
* Markenfarben fuer das Rechnungs-PDF — dieselben Werte wie die Tokens in
* app/globals.css (helles Farbschema). pdfkit kennt keine CSS-Variablen,
* deshalb hier als feste Hexwerte; bei einer Aenderung der Markenfarben
* beide Stellen anpassen.
*/
export const INVOICE_COLORS = {
forest: "#24352B",
honey: "#B88A44",
honeyDark: "#8A6531",
honeyPale: "#E4CFA4",
cream: "#F4EFE5",
ink: "#242424",
sage: "#788774",
hairline: "#E6DDCB",
} as const;
export interface InvoiceLogo {
png: Buffer;
width: number;
height: number;
}
/**
* Laedt das im Admin hochgeladene Logo (Inhalte > Logo und Favicon) als PNG.
*
* pdfkit bettet nur PNG und JPEG ein, Uploads duerfen aber auch WebP/AVIF
* sein — sharp wandelt deshalb immer nach PNG (behaelt Transparenz).
* Der Pfad ist doppelt abgesichert: getBrandAssets laesst nur das feste
* Upload-Muster durch, resolveUploadPath verhindert das Verlassen von
* UPLOAD_DIR.
*
* Jeder Fehler (Datei fehlt, kaputtes Bild) fuehrt zu null und damit zum
* gezeichneten Waben-Zeichen: eine Rechnung darf nie an einem Logo scheitern.
*/
export async function loadInvoiceLogo(): Promise<InvoiceLogo | null> {
const { logo } = await getBrandAssets();
if (!logo) return null;
const filePath = resolveUploadPath([logo.replace(/^\/uploads\//, "")]);
if (!filePath) return null;
try {
const { data, info } = await sharp(await readFile(filePath)).png().toBuffer({ resolveWithObject: true });
return { png: data, width: info.width, height: info.height };
} catch (error) {
console.error(`[invoice-pdf] Logo ${logo} konnte nicht geladen werden — es wird das Waben-Zeichen verwendet.`, error);
return null;
}
}
/**
* Zeichnet das Waben-Zeichen (components/logomark.tsx) als Vektor: Hexagon in
* Honiggold, darin drei Tannen in Waldgruen. Gleiche Geometrie wie im
* Web-Logo (viewBox 100), skaliert auf `size` Punkt.
*/
export function drawLogomark(doc: PDFKit.PDFDocument, x: number, y: number, size: number): void {
doc.save();
doc.translate(x, y).scale(size / 100);
doc
.polygon([50, 5], [88.97, 27.5], [88.97, 72.5], [50, 95], [11.03, 72.5], [11.03, 27.5])
.lineWidth(2.5)
.strokeColor(INVOICE_COLORS.honey)
.stroke();
for (const tree of [
{ x: 20, y: 45, scale: 0.7 },
{ x: 38, y: 32, scale: 0.95 },
{ x: 56, y: 45, scale: 0.7 },
]) {
doc.save();
doc.translate(tree.x, tree.y).scale(tree.scale);
doc.lineWidth(1.4).lineJoin("round").strokeColor(INVOICE_COLORS.forest);
doc.polygon([10, 2], [5, 13], [15, 13]).stroke();
doc.polygon([10, 10], [2, 22], [18, 22]).stroke();
doc.rect(8, 22, 4, 4).stroke();
doc.restore();
}
doc.restore();
}
/**
* Setzt das Logo in eine Box der Hoehe `height` und gibt die tatsaechlich
* belegte Breite zurueck — breite Wortmarken werden bis `maxWidth` breit,
* quadratische Zeichen bleiben quadratisch.
*/
export function drawInvoiceLogo(
doc: PDFKit.PDFDocument,
logo: InvoiceLogo | null,
x: number,
y: number,
height: number,
maxWidth: number
): number {
if (!logo) {
drawLogomark(doc, x, y, height);
return height;
}
const scale = Math.min(height / logo.height, maxWidth / logo.width);
const width = logo.width * scale;
doc.image(logo.png, x, y + (height - logo.height * scale) / 2, { width });
return width;
}