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]>
This commit is contained in:
1 parent
1daef79026
commit
77bdd9958c
12 files changed
+965
-246
No files matched your search
@@ -0,0 +1,108 @@
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user