Files
Wabenhain/src/lib/invoice-pdf.ts
T
Nick SzaunigandClaude Opus 5.5 508ff24aa7 feat(invoice): Rechnungen als E-Rechnung (ZUGFeRD/Factur-X EN 16931)
Jedes Rechnungs-PDF ist jetzt eine hybride E-Rechnung: PDF/A-3b mit
eingebettetem factur-x.xml (CII D16B, Profil EN 16931). Netto und Steuer je
Satz kommen unveraendert aus der Aufstellung des PDFs, Versand und Rabatt
stehen als Zu- und Abschlag je Satz; XML und PDF tragen dieselben Summen.

- lib/einvoice/cii.ts: XML-Erzeugung (Rechnung 380, Stornorechnung 381 mit
  Bezug), ohne USt-IdNr. oder bei Positionen ohne Satz nur PDF mit Warnung
- lib/einvoice/facturx-pdf.ts: PDF/A-3b, Factur-X-XMP mit Erweiterungsschema,
  eingebettete Liberation-Schriften (gleiche Laufweiten wie Helvetica/Times)
- invoice-pdf: Darstellung als Stornorechnung (negative Betraege, Bezug)
- Geprueft mit XSD, CEN-Schematron EN 16931 1.3.16 und Mustang 2.26
  (veraPDF: PDF/A-3b konform, XML valid)

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 05:32:44 +02:00

513 lines
21 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, type VatBreakdownLine } from "@/lib/vat";
import { groupInvoiceItemsByVatRate, type InvoiceItemGroup } from "@/lib/invoice-groups";
import { INVOICE_COLORS as C, loadInvoiceLogo, drawInvoiceLogo, type InvoiceLogo } from "@/lib/invoice-pdf-brand";
import { buildCiiXml, type EInvoiceResult } from "@/lib/einvoice/cii";
import { embedFacturX, pdfaOptions, registerEmbeddedFonts, xmpSafe } from "@/lib/einvoice/facturx-pdf";
import type { Dictionary } from "@/i18n/dictionary-type";
import type { Locale } from "@/i18n/config";
interface InvoiceItem {
productName: string;
weightGrams: number;
quantity: number;
unitPriceCents: number;
totalCents: number;
/** Satz dieser Position; null bei Bestellungen vor der Steuerausweisung. */
vatRatePercent: number | null;
}
interface InvoicePdfInput {
locale: Locale;
invoiceNumber: string;
issuedAt: Date;
totalCents: number;
order: {
orderNumber: string;
firstName: string;
lastName: string;
street: string;
zip: string;
city: string;
/** ISO-Laendercode der Rechnungsanschrift; fehlt er, gilt Deutschland. */
country?: string;
currency: string;
subtotalCents: number;
shippingCents: number;
discountCents: number;
discountCode?: { code: string } | null;
items: InvoiceItem[];
};
/**
* Gesetzt bei der Stornorechnung: sie hebt die Rechnung invoiceNumber vom
* issuedAt auf und traegt alle Betraege mit umgekehrtem Vorzeichen.
*/
cancellation?: { number: string; issuedAt: Date };
}
type Company = Awaited<ReturnType<typeof getCompanyInfo>>;
interface Ctx {
doc: PDFKit.PDFDocument;
t: Dictionary["invoicePage"];
/** Betrag mit dem Vorzeichen des Belegs (Stornorechnung negativ). */
money: (cents: number) => string;
/** Betrag ohne Vorzeichenumkehr. */
plain: (cents: number) => string;
isCancellation: boolean;
}
/** Sitz des Shops; die Firmenanschrift in den Einstellungen hat kein Land. */
const SELLER_COUNTRY_CODE = "DE";
// A4 in Punkt. Ab FOOTER_TOP beginnt die Fusszeile; was oberhalb von
// CONTENT_BOTTOM nicht mehr hinpasst, wandert auf die naechste Seite.
const PAGE_WIDTH = 595.28;
const PAGE_HEIGHT = 841.89;
const MARGIN = 50;
const RIGHT = PAGE_WIDTH - MARGIN;
const CONTENT_WIDTH = RIGHT - MARGIN;
const FOOTER_TOP = 768;
const CONTENT_BOTTOM = FOOTER_TOP - 20;
// Textboxen der Positionstabelle (x, Breite) — 10 pt Innenabstand zum Rand.
const COL = {
name: { x: 60, w: 214 },
vat: { x: 278, w: 40 },
qty: { x: 320, w: 40 },
price: { x: 364, w: 84 },
total: { x: 450, w: RIGHT - 10 - 450 },
} as const;
/**
* 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)
* und dieselben Rechenfunktionen (invoiceVatBreakdown,
* groupInvoiceItemsByVatRate), damit beide Darstellungen desselben Belegs
* uebereinstimmen.
*
* Gestaltung in den Markenfarben (lib/invoice-pdf-brand.ts): Logo und
* Wortmarke im Kopf, Honig-Akzente, Tabellenkopf in Waldgruen. Bei einem
* gemischten Korb stehen die Positionen je Steuersatz in eigenen Bloecken
* mit Zwischensumme; darunter die Steueraufstellung je Satz (Netto, USt.,
* Brutto) neben den Summen.
*/
export async function generateInvoicePdf(input: InvoicePdfInput): Promise<Buffer> {
const t = getDictionary(input.locale).invoicePage;
const [company, logo] = await Promise.all([getCompanyInfo(), loadInvoiceLogo()]);
const title = input.cancellation
? `${t.cancellationTitle} ${input.cancellation.number}`
: `${t.documentTitle} ${input.invoiceNumber}`;
const doc = new PDFDocument({
...pdfaOptions(),
margin: MARGIN,
size: "A4",
bufferPages: true,
info: { Title: xmpSafe(title), Author: xmpSafe(company.name) },
});
registerEmbeddedFonts(doc);
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);
});
const sign = input.cancellation ? -1 : 1;
const plain = (cents: number) => formatPrice(cents, input.order.currency);
const ctx: Ctx = { doc, t, money: (cents) => plain(sign * cents), plain, isCancellation: Boolean(input.cancellation) };
const breakdown = invoiceVatBreakdown(input.order);
drawHeader(ctx, company, logo);
const addressBottom = drawRecipient(ctx, input, company);
const metaBottom = drawMetaBox(ctx, input, company);
let y = drawTableHeader(ctx, Math.max(addressBottom, metaBottom) + 30);
y = drawItems(ctx, groupInvoiceItemsByVatRate(input.order.items), y);
y = drawSummary(ctx, input, breakdown, y + 22);
drawClosing(ctx, input, breakdown, y + 26);
drawFooters(ctx, company);
const einvoice = eInvoiceXml(input, company);
if (einvoice.ok) {
embedFacturX(doc, einvoice.xml, input.cancellation?.issuedAt ?? input.issuedAt);
} else {
console.warn(`[invoice-pdf] ${input.cancellation?.number ?? input.invoiceNumber} ohne E-Rechnung (nur PDF): ${einvoice.reason}`);
}
doc.end();
return done;
}
/**
* Das XML der E-Rechnung (EN 16931) aus denselben Daten wie das PDF. Die
* Stornorechnung ist eine Gutschrift mit Bezug auf die aufgehobene Rechnung.
*/
function eInvoiceXml(input: InvoicePdfInput, company: Company): EInvoiceResult {
const { order, cancellation } = input;
return buildCiiXml({
kind: cancellation ? "creditNote" : "invoice",
number: cancellation?.number ?? input.invoiceNumber,
issuedAt: cancellation?.issuedAt ?? input.issuedAt,
currency: order.currency,
orderNumber: order.orderNumber,
seller: {
name: company.name,
street: company.address.street,
zip: company.address.zip,
city: company.address.city,
countryCode: SELLER_COUNTRY_CODE,
vatId: company.vatId,
email: company.email,
},
buyer: {
name: `${order.firstName} ${order.lastName}`.trim(),
street: order.street,
zip: order.zip,
city: order.city,
countryCode: (order.country || SELLER_COUNTRY_CODE).toUpperCase(),
},
items: order.items.map((item) => ({
name: item.productName,
quantity: item.quantity,
totalCents: item.totalCents,
vatRatePercent: item.vatRatePercent,
})),
shippingCents: order.shippingCents,
discountCents: order.discountCents,
discountCode: order.discountCode?.code ?? null,
totalCents: input.totalCents,
precedingInvoice: cancellation ? { number: input.invoiceNumber, issuedAt: input.issuedAt } : undefined,
paymentNote: cancellation
? "Der Betrag wird über die ursprüngliche Zahlungsart erstattet."
: "Bereits bezahlt (Online-Zahlung über Stripe).",
});
}
function label(ctx: Ctx, text: string, x: number, y: number): void {
ctx.doc
.font("Helvetica-Bold")
.fontSize(7)
.fillColor(C.honeyDark)
.text(text.toUpperCase(), x, y, { characterSpacing: 1.2, lineBreak: false });
}
function hairline(ctx: Ctx, x1: number, x2: number, y: number, color: string = C.hairline): void {
ctx.doc.moveTo(x1, y).lineTo(x2, y).lineWidth(0.6).strokeColor(color).stroke();
}
function drawHeader(ctx: Ctx, company: Company, logo: InvoiceLogo | null): void {
const { doc, t } = ctx;
doc.rect(0, 0, PAGE_WIDTH, 6).fill(C.honey);
const textX = MARGIN + drawInvoiceLogo(doc, logo, MARGIN, 36, 56, 150) + 14;
doc
.font("Times-Roman")
.fontSize(22)
.fillColor(C.forest)
.text(company.name.toUpperCase(), textX, 46, { characterSpacing: 3.5, lineBreak: false });
if (company.claim) {
doc.font("Helvetica").fontSize(8.5).fillColor(C.honeyDark).text(company.claim, textX, 74, { width: 240, lineBreak: false });
}
// "STORNORECHNUNG" ist laenger als "RECHNUNG": die Schrift wird so weit
// verkleinert, dass der Titel in einer Zeile rechts oben bleibt.
const title = (ctx.isCancellation ? t.cancellationTitle : t.documentTitle).toUpperCase();
const titleX = 300;
const titleOptions = { characterSpacing: 2 };
let titleSize = 26;
doc.font("Times-Roman").fontSize(titleSize);
while (titleSize > 14 && doc.widthOfString(title, titleOptions) > RIGHT - titleX) {
doc.fontSize(--titleSize);
}
doc
.fillColor(C.forest)
.text(title, titleX, 48 + (26 - titleSize) / 2, { ...titleOptions, width: RIGHT - titleX, align: "right", lineBreak: false });
hairline(ctx, MARGIN, RIGHT, 112, C.honey);
}
/** Absenderzeile (fuer Fensterumschlaege) und Rechnungsempfaenger. */
function drawRecipient(ctx: Ctx, input: InvoicePdfInput, company: Company): number {
const { doc, t } = ctx;
const { order } = input;
const top = 136;
const sender = [company.name, company.address.street, `${company.address.zip} ${company.address.city}`].join(" · ");
doc.font("Helvetica").fontSize(7).fillColor(C.sage).text(sender, MARGIN, top, { width: 260, lineBreak: false });
hairline(ctx, MARGIN, MARGIN + 260, top + 11);
label(ctx, t.billedToLabel, MARGIN, top + 24);
doc.font("Helvetica-Bold").fontSize(10.5).fillColor(C.ink).text(`${order.firstName} ${order.lastName}`, MARGIN, top + 38, { width: 260 });
doc.font("Helvetica").fontSize(10).text(order.street, { width: 260 }).text(`${order.zip} ${order.city}`, { width: 260 });
return doc.y;
}
/** Rechnungsdaten rechts oben, inklusive USt-IdNr. (§ 14 Abs. 4 Nr. 2 UStG). */
function drawMetaBox(ctx: Ctx, input: InvoicePdfInput, company: Company): number {
const { doc, t } = ctx;
const documentRows: [string, string][] = input.cancellation
? [
[t.cancellationNumberLabel, input.cancellation.number],
[t.cancellationDateLabel, formatDate(input.cancellation.issuedAt)],
[t.cancelsInvoiceLabel, input.invoiceNumber],
]
: [
[t.invoiceNumberLabel, input.invoiceNumber],
[t.invoiceDateLabel, formatDate(input.issuedAt)],
];
const rows: [string, string][] = [
...documentRows,
[t.orderNumberLabel, input.order.orderNumber],
...(company.vatId ? ([[t.vatIdLabel, company.vatId]] as [string, string][]) : []),
];
const x = 330;
const top = 132;
const rowHeight = 21;
const height = 18 + rows.length * rowHeight;
doc.rect(x, top, RIGHT - x, height).fill(C.cream);
doc.rect(x, top, 3, height).fill(C.honey);
rows.forEach(([name, value], index) => {
const rowY = top + 13 + index * rowHeight;
doc.font("Helvetica").fontSize(8).fillColor(C.sage).text(name, x + 16, rowY + 1, { lineBreak: false });
doc
.font("Helvetica-Bold")
.fontSize(9.5)
.fillColor(C.forest)
.text(value, x + 16, rowY, { width: RIGHT - x - 30, align: "right", lineBreak: false });
});
return top + height;
}
function drawTableHeader(ctx: Ctx, y: number): number {
const { doc, t } = ctx;
doc.rect(MARGIN, y, CONTENT_WIDTH, 22).fill(C.forest);
doc.font("Helvetica-Bold").fontSize(7.5).fillColor(C.cream);
const cell = (text: string, col: { x: number; w: number }, align: "left" | "center" | "right") =>
doc.text(text.toUpperCase(), col.x, y + 8, { width: col.w, align, characterSpacing: 0.8, lineBreak: false });
cell(t.itemHeader, COL.name, "left");
cell(t.vatRateHeader, COL.vat, "center");
cell(t.quantityHeader, COL.qty, "center");
cell(t.priceHeader, COL.price, "right");
cell(t.totalHeader, COL.total, "right");
return y + 22;
}
/** Neue Seite, wenn der naechste Block nicht mehr ueber die Fusszeile passt. */
function ensureSpace(ctx: Ctx, y: number, needed: number, repeatTableHeader: boolean): number {
if (y + needed <= CONTENT_BOTTOM) return y;
ctx.doc.addPage();
return repeatTableHeader ? drawTableHeader(ctx, MARGIN) : MARGIN;
}
function drawItems(ctx: Ctx, groups: InvoiceItemGroup<InvoiceItem>[], startY: number): number {
// Blockueberschriften und Zwischensummen nur beim gemischten Korb — bei
// einem einheitlichen Satz waeren sie reine Wiederholung.
const isMixed = groups.length > 1;
let y = startY;
for (const group of groups) {
if (isMixed && group.ratePercent !== null) y = drawGroupHeading(ctx, group.ratePercent, y);
for (const item of group.items) y = drawItemRow(ctx, item, y);
if (isMixed && group.ratePercent !== null) y = drawGroupSubtotal(ctx, group, y);
}
return y;
}
function drawGroupHeading(ctx: Ctx, ratePercent: number, startY: number): number {
// Platz fuer Ueberschrift UND erste Position — eine Ueberschrift allein am
// Seitenende waere verwaist.
const y = ensureSpace(ctx, startY, 20 + 32, true);
ctx.doc.rect(MARGIN, y, CONTENT_WIDTH, 20).fill(C.honeyPale);
label(ctx, ctx.t.groupHeading(ratePercent), COL.name.x, y + 7);
return y + 20;
}
function drawItemRow(ctx: Ctx, item: InvoiceItem, startY: number): number {
const { doc, money } = ctx;
doc.font("Helvetica").fontSize(9.5);
const nameHeight = doc.heightOfString(item.productName, { width: COL.name.w });
const rowHeight = Math.max(32, nameHeight + 22);
const y = ensureSpace(ctx, startY, rowHeight, true);
doc.font("Helvetica").fontSize(9.5).fillColor(C.ink).text(item.productName, COL.name.x, y + 8, { width: COL.name.w });
doc.fontSize(7.5).fillColor(C.sage).text(`${item.weightGrams} g`, COL.name.x, y + 10 + nameHeight, { lineBreak: false });
const cell = (text: string, col: { x: number; w: number }, align: "center" | "right", bold = false) =>
doc
.font(bold ? "Helvetica-Bold" : "Helvetica")
.fontSize(9.5)
.fillColor(bold ? C.forest : C.ink)
.text(text, col.x, y + 8, { width: col.w, align, lineBreak: false });
cell(item.vatRatePercent === null ? "–" : `${item.vatRatePercent} %`, COL.vat, "center");
cell(String(item.quantity), COL.qty, "center");
cell(money(item.unitPriceCents), COL.price, "right");
cell(money(item.totalCents), COL.total, "right", true);
hairline(ctx, MARGIN, RIGHT, y + rowHeight);
return y + rowHeight;
}
function drawGroupSubtotal(ctx: Ctx, group: InvoiceItemGroup<InvoiceItem>, startY: number): number {
const { doc, t, money } = ctx;
const y = ensureSpace(ctx, startY, 24, true);
doc
.font("Helvetica")
.fontSize(8.5)
.fillColor(C.sage)
.text(t.groupSubtotalLabel(group.ratePercent ?? 0), COL.qty.x, y + 8, {
width: COL.price.x + COL.price.w - COL.qty.x,
align: "right",
lineBreak: false,
});
doc
.font("Helvetica-Bold")
.fontSize(9.5)
.fillColor(C.forest)
.text(money(group.totalCents), COL.total.x, y + 7, { width: COL.total.w, align: "right", lineBreak: false });
return y + 30;
}
/** Links die Steueraufstellung je Satz, rechts Summen und Gesamtbetrag. */
function drawSummary(ctx: Ctx, input: InvoicePdfInput, breakdown: VatBreakdownLine[], startY: number): number {
const y = ensureSpace(ctx, startY, 160, false);
const leftBottom = drawTaxSummary(ctx, breakdown, y);
const rightBottom = drawTotals(ctx, input, y);
return Math.max(leftBottom, rightBottom);
}
function drawTotals(ctx: Ctx, input: InvoicePdfInput, top: number): number {
const { doc, t, money, plain } = ctx;
const { order } = input;
const x = 330;
const discountName = order.discountCode ? `${t.discountLabel} (${order.discountCode.code})` : t.discountLabel;
const rows: [string, string][] = [
[t.subtotalLabel, money(order.subtotalCents)],
[t.shippingLabel, order.shippingCents === 0 ? t.freeShipping : money(order.shippingCents)],
...(order.discountCents > 0
? ([[discountName, `${ctx.isCancellation ? "+" : "–"} ${plain(order.discountCents)}`]] as [string, string][])
: []),
];
let y = top;
for (const [name, value] of rows) {
doc.font("Helvetica").fontSize(9).fillColor(C.sage).text(name, x + 12, y, { width: 120, lineBreak: false });
doc.fillColor(C.ink).text(value, x, y, { width: RIGHT - x - 12, align: "right", lineBreak: false });
y += 18;
}
y += 4;
doc.rect(x, y, RIGHT - x, 34).fill(C.forest);
doc.rect(x, y + 34, RIGHT - x, 3).fill(C.honey);
doc.font("Times-Roman").fontSize(13).fillColor(C.cream).text(t.totalLabel, x + 12, y + 11, { lineBreak: false });
doc
.font("Helvetica-Bold")
.fontSize(13)
.fillColor(C.cream)
.text(money(input.totalCents), x, y + 11, { width: RIGHT - x - 12, align: "right", lineBreak: false });
return y + 37;
}
/**
* 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. 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.
*/
function drawTaxSummary(ctx: Ctx, breakdown: VatBreakdownLine[], top: number): number {
const { doc, t, money } = ctx;
const width = 250;
if (breakdown.length === 0) return top;
label(ctx, t.taxSummaryHeading, MARGIN, top);
if (breakdown.every((line) => line.ratePercent === 0)) {
doc.font("Helvetica").fontSize(8.5).fillColor(C.ink).text(t.smallBusinessNote, MARGIN, top + 16, { width });
return doc.y;
}
const cols = [
{ x: MARGIN + 8, w: 50, align: "left" as const },
{ x: MARGIN + 58, w: 62, align: "right" as const },
{ x: MARGIN + 120, w: 58, align: "right" as const },
{ x: MARGIN + 178, w: width - 186, align: "right" as const },
];
const row = (cells: string[], y: number, font: string, color: string) =>
cells.forEach((text, index) =>
doc
.font(font)
.fontSize(8.5)
.fillColor(color)
.text(text, cols[index].x, y, { width: cols[index].w, align: cols[index].align, lineBreak: false })
);
let y = top + 14;
doc.rect(MARGIN, y, width, 18).fill(C.cream);
row([t.taxRateHeader, t.netHeader, t.vatAmountHeader, t.grossHeader], y + 5, "Helvetica-Bold", C.forest);
y += 18;
const lines = breakdown.filter((line) => line.ratePercent > 0);
for (const line of lines) {
row([`${line.ratePercent} %`, money(line.netCents), money(line.vatCents), money(line.grossCents)], y + 5, "Helvetica", C.ink);
y += 18;
hairline(ctx, MARGIN, MARGIN + width, y);
}
if (lines.length > 1) {
const sum = (key: "netCents" | "vatCents" | "grossCents") => money(lines.reduce((total, line) => total + line[key], 0));
row([t.totalHeader, sum("netCents"), sum("vatCents"), sum("grossCents")], y + 5, "Helvetica-Bold", C.forest);
y += 18;
}
return y;
}
function drawClosing(ctx: Ctx, input: InvoicePdfInput, breakdown: VatBreakdownLine[], startY: number): void {
const { doc, t } = ctx;
const y = ensureSpace(ctx, startY, 40, false);
if (breakdown.some((line) => line.ratePercent > 0)) {
doc.font("Helvetica").fontSize(8).fillColor(C.sage).text(t.pricesIncludeVat, MARGIN, y, { width: CONTENT_WIDTH });
}
if (input.cancellation) {
doc
.font("Helvetica")
.fontSize(9.5)
.fillColor(C.ink)
.text(t.cancellationNote(input.invoiceNumber, formatDate(input.issuedAt)), MARGIN, y + 16, { width: CONTENT_WIDTH });
return;
}
doc.font("Times-Italic").fontSize(13).fillColor(C.honeyDark).text(t.thankYou, MARGIN, y + 16, { width: CONTENT_WIDTH });
}
/**
* Fusszeile auf jeder Seite: Anschrift, Kontakt, USt-IdNr. und Seitenzahl.
* Der untere Seitenrand wird dafuer kurz auf 0 gesetzt — sonst wuerde pdfkit
* Text unterhalb des Rands als Seitenumbruch deuten und leere Seiten anhaengen.
*/
function drawFooters(ctx: Ctx, company: Company): void {
const { doc, t } = ctx;
const range = doc.bufferedPageRange();
for (let index = range.start; index < range.start + range.count; index++) {
doc.switchToPage(index);
const bottomMargin = doc.page.margins.bottom;
doc.page.margins.bottom = 0;
hairline(ctx, MARGIN, RIGHT, FOOTER_TOP, C.honey);
doc.font("Helvetica").fontSize(7).fillColor(C.sage);
const options = { width: 160, lineGap: 1.5 };
const address = [company.name, company.address.street, `${company.address.zip} ${company.address.city}`];
const contact = [company.email, company.phone, company.domain].filter(Boolean);
const legal = [company.vatId ? `${t.vatIdLabel} ${company.vatId}` : "", t.pageLabel(index - range.start + 1, range.count)].filter(Boolean);
doc.text(address.join("\n"), MARGIN, FOOTER_TOP + 9, options);
doc.text(contact.join("\n"), 218, FOOTER_TOP + 9, options);
doc.text(legal.join("\n"), RIGHT - 160, FOOTER_TOP + 9, { ...options, align: "right" });
doc.fontSize(6.5).text(t.legalNote, MARGIN, FOOTER_TOP + 48, { width: CONTENT_WIDTH, align: "center" });
doc.rect(0, PAGE_HEIGHT - 4, PAGE_WIDTH, 4).fill(C.honey);
doc.page.margins.bottom = bottomMargin;
}
}