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]>
513 lines
21 KiB
TypeScript
513 lines
21 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, 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;
|
||
}
|
||
}
|