Files
picpeak/backend/src/services/pdfService.js
T
Luca 1476884dd0 feat(invoices): configurable VAT/free-text note + fix multi-page page-number overlap (#794)
Two invoice-PDF changes from #794.

1. VAT / free-text note (Benedikt's request, placement A). A new
   `crm_invoices_vat_note_text` setting (Settings → CRM → Invoices) prints a
   free-text line directly under the MwSt. row on every invoice. Data-driven:
   the admin types the exact wording (Austrian Kleinunternehmer § 6 Abs. 1 Z 27
   UStG, German § 19, reverse-charge, …) — no jurisdiction hardcoded. The
   totals-block reserve grows by the measured note height so a long note can't
   push the grand total into the footer. Read in invoice/render.js, threaded
   through normaliseContext, drawn in drawTotals. Empty → row omitted; quotes
   unaffected.

2. Multi-page footer overlap. On a full continuation page the line-item table
   filled to the bottom margin, but the "Seite X von Y" stamp was drawn at
   marginBottom-12 — INSIDE that fill zone — so items overlapped the page
   number. Move the stamp into the bottom margin (below the content edge),
   zeroing that page's bottom margin during the write so it can't trigger
   PDFKit's auto-page-break. Verified: on a full page the lowest item text is
   at pdfkitY ~790 while the page number sits at ~816 — ~26pt clearance.

Tests: render the note on a single page (byte-delta proves it renders) and
paginate a long invoice with the note (2–3 pages, no stray blank page).
2026-07-11 02:01:20 +02:00

2245 lines
100 KiB
JavaScript
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.
/**
* pdfService — render quote / invoice PDFs.
*
* Built on PDFKit + swissqrbill (the latter ships the SwissQRBill class
* for the QR-bill payment slip + a `Table` helper for the line items).
* Same engine renders both quotes and invoices — they differ only in
* title, lead-in text, optional Rabatt column (quotes only) and the
* QR-bill section (invoices only, when qr_format = 'swiss').
*
* Public API:
* renderQuoteToBuffer(context) → Promise<Buffer>
* renderInvoiceToBuffer(context) → Promise<Buffer>
*
* The caller (quoteService / invoiceService) hydrates the `context` from
* the DB and passes everything in — keeping pdfService a pure renderer
* makes both unit-tests and preview-from-form (no DB write) trivial.
*
* Money: every "*_minor" field is treated as INTEGER minor units
* (cents/Rappen) and rendered via Intl.NumberFormat using the supplied
* locale + currency.
*
* Layout reference: the user's existing Angebot / Rechnung templates
* (issuer block top-right, customer block left, "Datum" line, title,
* salutation + lead-in, line-item table, totals box right-aligned,
* payment conditions block, IBAN block, footer).
*/
const PDFDocument = require('pdfkit');
const { SwissQRBill, Table } = require('swissqrbill/pdf');
const { t } = require('./pdf-i18n');
// Page metrics in PDF points (1pt = 1/72in). A4 = 595.28 × 841.89.
// 1mm = 2.834645669pt.
const MM = 2.834645669;
const PAGE = {
// A4 ISO 216 — portrait. Quote/invoice rendering is hard-wired to this
// orientation (DIN 5008 address window only makes sense in portrait).
// Landscape callers (tax report, future wide-table exports) read their
// metrics from getPageMetrics('landscape') instead.
width: 595.28,
height: 841.89,
marginTop: 40,
marginBottom: 40,
marginLeft: 40,
marginRight: 40,
contentWidth: 595.28 - 80, // 515.28
};
// A4 landscape — width and height swapped. Same 40pt margins on all
// sides, so contentWidth grows from 515pt to 762pt — enough horizontal
// room for the tax-report table's 9 columns without column squashing.
const PAGE_LANDSCAPE = {
width: 841.89,
height: 595.28,
marginTop: 40,
marginBottom: 40,
marginLeft: 40,
marginRight: 40,
contentWidth: 841.89 - 80, // 761.89
};
/**
* Page metrics for the requested orientation. Default 'portrait' keeps
* every existing caller behaving identically. Used by createBaseDocument
* and by any renderer that needs to size its content against the page.
*/
function getPageMetrics(orientation) {
return orientation === 'landscape' ? PAGE_LANDSCAPE : PAGE;
}
// DIN 5008 Form B address window — the standard window position for
// envelopes commonly used in DACH (B5 / C5-6 / DL with window). The
// window's top-left corner sits 45mm from the top and 20mm from the
// left of the A4 sheet, 85mm × 45mm in size. Picking Form B (the
// "newer" form) over Form A means the document still fits envelopes
// printed by every German/Swiss/Austrian/Liechtenstein vendor.
//
// We render INSIDE the window:
// - Return address line (small grey "Absender" reference)
// positioned in the upper ~5mm of the window
// - The actual recipient address starts ~17.7mm below the top of
// the window (DIN 5008 says address-line 1 starts on row 4 of
// the window, which is 5mm down + 12.7mm of line-rows)
const ADDR_WINDOW = {
left: 20 * MM, // 56.69pt
top: 45 * MM, // 127.56pt
width: 85 * MM, // 240.94pt
height: 45 * MM, // 127.56pt
// Vertical offsets inside the window.
returnLineY: 47 * MM, // 133.23pt — tiny "Absender" reference line
addressY: 52 * MM, // 147.40pt — first line of recipient address
};
// Default to PDFKit's built-in Helvetica. These constants are STILL
// used by the rest of the renderer as logical font names; when the
// admin has uploaded a custom TTF (business_profile.pdf_font_ttf_path),
// renderDocument registers it under these same names so every existing
// `doc.font(doc._fonts ? doc._fonts.body : FONT_BODY)` / `doc.font(doc._fonts ? doc._fonts.bold : FONT_BOLD)` call automatically
// picks it up. If only one weight is available we register it for both
// — bold falls back gracefully to regular.
const FONT_BODY = 'Helvetica';
const FONT_BOLD = 'Helvetica-Bold';
const CUSTOM_BODY = 'crm-body';
const CUSTOM_BOLD = 'crm-bold';
/**
* Layout constants for the contract signature page (the dedicated
* final page of every contract PDF). Both renderContractToBuffer
* AND pdfStampService read these — the unsigned render draws empty
* boxes at these coordinates; the stamp service later overlays the
* signature PNGs at the same coordinates with pdf-lib.
*
* Coordinates are PDFKit-style (top-left origin, y increases down).
* pdfStampService converts to pdf-lib's bottom-left origin internally.
*
* Changing any value here means re-rendering all unsigned PDFs that
* are still pending signature — or the stamps will land in the wrong
* place. Leave alone unless redesigning the signature page entirely.
*/
const CONTRACT_SIGNATURE_LAYOUT = {
// Title row at top of page.
titleY: PAGE.marginTop,
// Prompt text below title (small instruction line).
promptY: PAGE.marginTop + 50,
// Y of the "Customer" / "Contractor" labels above each box.
paneLabelY: PAGE.marginTop + 100,
// Y of the empty signature box itself.
boxY: PAGE.marginTop + 114,
// Each box is half the content width minus a 20pt gutter.
boxWidth: (PAGE.contentWidth - 20) / 2,
// Tall enough that a typical canvas signature reads cleanly.
boxHeight: 80,
// Two side-by-side panes — customer on the left, admin on the right.
customerX: PAGE.marginLeft,
adminX: PAGE.marginLeft + ((PAGE.contentWidth - 20) / 2) + 20,
};
/**
* ISO 3166-1 alpha-2 → full country name, locale-aware. Falls back to
* the bare code when not in the map (no need to maintain every nation
* on earth — the user said de + en, with the issuer in LI/CH).
*
* Using `Intl.DisplayNames` would be neat but Node's built-in support
* for German names is patchy across versions, so a small explicit
* table is more reliable for the formats actually used.
*/
const COUNTRY_NAMES = {
de: {
LI: 'Liechtenstein', CH: 'Schweiz', AT: 'Österreich', DE: 'Deutschland',
FR: 'Frankreich', IT: 'Italien', ES: 'Spanien', PT: 'Portugal',
NL: 'Niederlande', BE: 'Belgien', LU: 'Luxemburg', GB: 'Vereinigtes Königreich',
US: 'USA', DK: 'Dänemark', SE: 'Schweden', NO: 'Norwegen',
FI: 'Finnland', PL: 'Polen', CZ: 'Tschechien', SK: 'Slowakei',
HU: 'Ungarn', IE: 'Irland',
},
en: {
LI: 'Liechtenstein', CH: 'Switzerland', AT: 'Austria', DE: 'Germany',
FR: 'France', IT: 'Italy', ES: 'Spain', PT: 'Portugal',
NL: 'Netherlands', BE: 'Belgium', LU: 'Luxembourg',
GB: 'United Kingdom',US: 'United States',
DK: 'Denmark', SE: 'Sweden', NO: 'Norway',
FI: 'Finland', PL: 'Poland', CZ: 'Czechia', SK: 'Slovakia',
HU: 'Hungary', IE: 'Ireland',
},
};
/**
* Build the salutation line. When the customer record carries an
* honorific (Herr / Frau / Mr. / Ms. / Dr.) AND a last name, we use
* a personalised greeting; otherwise we fall back to the generic
* locale-specific opening from the i18n dictionary.
*
* Recognised honorifics are matched loosely (lowercased + trimmed,
* dot suffix stripped) so "Herr", "herr", "Mr.", "Mr" all hit. The
* gendered forms only fire when we can pick a gender from the
* honorific; ambiguous titles like "Dr." use the inclusive
* "Sehr geehrte/r Dr. <last>," (German) or "Dear Dr. <last>,"
* (English) variant.
*/
function personalSalutation(locale, salutation, lastName) {
const honorific = (salutation || '').trim();
const last = (lastName || '').trim();
if (!honorific || !last) return null;
const key = honorific.toLowerCase().replace(/\.+$/, '').trim();
// gender from the honorific: 'm' / 'f' / null (ambiguous)
let gender = null;
if (['herr', 'mr', 'mister', 'monsieur', 'señor', 'senhor', 'meneer', 'sig', 'г-н', 'господин'].includes(key)) gender = 'm';
if (['frau', 'mrs', 'ms', 'miss', 'madame', 'mademoiselle', 'señora', 'senhora', 'mevrouw', 'sig.ra', 'г-жа', 'госпожа'].includes(key)) gender = 'f';
switch ((locale || 'de').toLowerCase()) {
case 'de':
if (gender === 'm') return `Sehr geehrter ${honorific} ${last},`;
if (gender === 'f') return `Sehr geehrte ${honorific} ${last},`;
return `Sehr geehrte/r ${honorific} ${last},`;
case 'en':
return `Dear ${honorific} ${last},`;
case 'fr':
if (gender === 'm') return `Cher ${honorific} ${last},`;
if (gender === 'f') return `Chère ${honorific} ${last},`;
return `Cher/Chère ${honorific} ${last},`;
case 'nl':
return `Geachte ${honorific} ${last},`;
case 'pt':
if (gender === 'm') return `Prezado ${honorific} ${last},`;
if (gender === 'f') return `Prezada ${honorific} ${last},`;
return `Prezado(a) ${honorific} ${last},`;
case 'ru':
return `Уважаемый(ая) ${honorific} ${last}!`;
default:
return `Dear ${honorific} ${last},`;
}
}
function countryName(code, locale) {
if (!code) return '';
const upper = String(code).trim().toUpperCase().slice(0, 2);
const dict = COUNTRY_NAMES[locale] || COUNTRY_NAMES.en;
return dict[upper] || COUNTRY_NAMES.en[upper] || upper;
}
/**
* Format a minor-unit BigInt-ish integer as a localised currency string.
* Returns just the number portion ("750.00") not "CHF 750.00" — the
* currency label is rendered separately in the totals box for layout
* reasons (matches the reference PDFs).
*/
function formatMinor(minor, currency, locale = 'de-CH') {
const value = Number(minor || 0) / 100;
// We render only the number — currency renders as a separate column
// to keep totals right-aligned cleanly.
return new Intl.NumberFormat(locale, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value);
}
function formatCurrencyLabel(currency) {
// Render the ISO code; matches the user's reference PDFs which show
// "Gesamtbetrag CHF 750.00".
return (currency || '').toUpperCase();
}
function formatDate(value, dateFormat) {
if (!value) return '';
const d = (value instanceof Date) ? value : new Date(value);
if (Number.isNaN(d.getTime())) return '';
// Respect the `general_date_format` app setting (read once in the
// service layer and passed through ctx.dateFormat). We build the
// string by hand instead of going through Intl.DateTimeFormat so
// a chosen "DD.MM.YYYY" actually renders with dots even when the
// customer's preferred_language maps to a locale that prints
// slashes (en-GB → 02/12/2025).
const dd = String(d.getDate()).padStart(2, '0');
const mm = String(d.getMonth() + 1).padStart(2, '0');
const yyyy = String(d.getFullYear());
const format = (dateFormat && dateFormat.format) || 'DD.MM.YYYY';
switch (format) {
case 'MM/DD/YYYY': return `${mm}/${dd}/${yyyy}`;
case 'DD/MM/YYYY': return `${dd}/${mm}/${yyyy}`;
case 'YYYY-MM-DD': return `${yyyy}-${mm}-${dd}`;
case 'DD.MM.YYYY':
default:
return `${dd}.${mm}.${yyyy}`;
}
}
/**
* Resolve the BCP-47 locale used for number / currency formatting.
*
* Strategy: the issuer's country code wins. CH/LI/DE/AT issuers all
* get the Swiss-style apostrophe thousands separator (e.g. 1'000.00 —
* what the local accountant + the bank expects on Stelleabrechnung /
* Rechnung), regardless of the document language. Outside the DACH
* region we fall through to the bare ISO 639 locale → BCP-47 mapping
* so en-GB, pt-PT, etc. keep their conventional formatting.
*
* Per maintainer: "in FL, CH, DE we write 1'000.00 not 1,000.00".
*/
function localeForIntl(locale, issuerCountryCode) {
const cc = (issuerCountryCode || '').toUpperCase();
if (['CH', 'LI', 'DE', 'AT'].includes(cc)) {
// de-CH is the only one of these that uses the apostrophe
// separator in Intl.NumberFormat. fr-CH would render 1 000.00
// (NBSP) which Swiss accountants don't want either.
return 'de-CH';
}
const map = { de: 'de-CH', en: 'en-GB', fr: 'fr-CH', nl: 'nl-NL', pt: 'pt-PT', ru: 'ru-RU' };
return map[locale] || locale || 'en-GB';
}
/**
* Render the issuer block (top-right): logo + company name as
* a side-by-side banner, then the address block, then a tidy
* label/value contact column. Matches the reference letterhead.
*
* Layout decisions:
* - Top banner: logo on the LEFT of the column with the company
* name vertically centred to the RIGHT of it (mirrors the
* "LUCA BRESCH MEDIA" branding screenshot). Either piece can be
* suppressed via issuer.showLogo / issuer.showCompanyName.
* - Address: line1 → "postal city" → CountryName, left-aligned
* within the right-side column.
* - Contact rows use two columns: "Phone:" labels at left,
* values aligned underneath each other. Looks like a small
* invisible table.
*/
function drawIssuerBlock(doc, issuer, x, y, width, locale) {
const startY = y;
const showLogo = issuer.showLogo !== false; // default true
const showName = issuer.showCompanyName !== false; // default true
// ---- top banner: logo (left) + company name (right of it) -----
// Path resolution happens upstream in resolveLogoFile() — by the
// time we get here, `issuer.logoPath` is either:
// - an absolute file path that has already been confirmed to
// exist on disk + filtered for PNG/JPEG, or
// - null when nothing resolved (logged upstream).
// We still wrap doc.image() in try/catch because PDFKit can reject
// valid-looking PNG/JPEG bytes (mislabelled extension, truncated
// download, etc.) — we'd rather render the rest of the PDF than
// crash on a broken logo.
const logoFound = showLogo && issuer.logoPath ? issuer.logoPath : null;
const drawLogoSafely = (file, opts) => {
try {
doc.image(file, opts.x, opts.y, { fit: [opts.w, opts.h] });
return true;
} catch (err) {
const logger = require('../utils/logger');
logger.warn('PDFKit failed to embed logo image', {
path: file, err: err.message,
});
return false;
}
};
// Logo height is admin-configurable (migration 108). Falls back to
// 56pt — the prior hard-coded value — when unset.
const bannerH = Math.max(24, Math.min(200, Number(issuer.logoHeight) || 56));
const inlineName = issuer.companyNameInline === true;
// Logo and company name stack VERTICALLY (logo on top, name
// underneath). When `companyNameInline` is set, the bold-title
// name branch is skipped and the name is rendered as a regular
// address line right before the street address (handled below).
let logoDrawn = false;
if (logoFound) {
logoDrawn = drawLogoSafely(logoFound, { x, y, w: width, h: bannerH });
if (logoDrawn) y += bannerH + 4;
}
if (showName && issuer.companyName && !inlineName) {
// Bold-title branch — the standard letterhead look. Skipped when
// the admin opted into the inline-name variant.
doc.font(doc._fonts ? doc._fonts.bold : FONT_BOLD).fontSize(12).fillColor('#000')
.text(issuer.companyName, x, y, { width, align: 'left' });
y = doc.y + 6;
}
// ---- address block (left-aligned within the column) -----------
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(8.5).fillColor('#000');
const cityCountry = (() => {
// Match the screenshot: "FL-9494 Schaan / Liechtenstein" on one
// line. Fall back gracefully when fields are missing. The
// country name comes from the explicit `countryName` override
// when set (migration 107); otherwise we resolve it from the
// ISO country code via the locale-aware COUNTRY_NAMES map.
const cc = issuer.countryCode ? String(issuer.countryCode).toUpperCase() : '';
const pc = issuer.postalCode || '';
const city = issuer.city || '';
const left = [cc && pc ? `${cc}-${pc}` : (pc || cc), city].filter(Boolean).join(' ');
const country = issuer.countryName || countryName(issuer.countryCode, locale);
return [left, country].filter(Boolean).join(' / ');
})();
// When the admin opted into the inline-name variant (migration 108)
// the company name renders as the first address line, in the same
// plain weight + size as the rest of the address. The bold-title
// branch above is skipped in that case.
const inlineCompanyLine = (showName && issuer.companyName && inlineName)
? issuer.companyName : null;
const addressLines = [
inlineCompanyLine,
issuer.addressLine1,
issuer.addressLine2,
cityCountry,
].filter(Boolean);
for (const line of addressLines) {
doc.text(line, x, y, { width, align: 'left' });
y = doc.y;
}
y += 6;
// ---- contact rows (label / value, two columns) ----------------
const labelCol = 38;
const gap = 4;
const valueCol = width - labelCol - gap;
const labelX = x;
const valueX = x + labelCol + gap;
const contactRows = [
issuer.phone ? ['Phone:', issuer.phone] : null,
issuer.mobile ? ['Mobile:', issuer.mobile] : null,
issuer.email ? ['Email:', issuer.email] : null,
issuer.website ? ['Web:', issuer.website] : null,
issuer.vatId ? ['VAT:', issuer.vatId] : null,
// Migration 139 — Steuernummer (DE/AT local tax number). Distinct
// from VAT-ID; both can appear simultaneously.
issuer.taxId ? ['Tax:', issuer.taxId] : null,
].filter(Boolean);
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(8.5);
for (const [label, value] of contactRows) {
const rowY = y;
doc.text(label, labelX, rowY, { width: labelCol, align: 'left', lineBreak: false });
doc.text(value, valueX, rowY, { width: valueCol, align: 'left', lineBreak: false });
y = rowY + 11;
}
return Math.max(y, startY + 60);
}
/**
* Render the recipient block INSIDE the DIN 5008 Form B address
* window. Two parts:
*
* 1. Return address line (small grey "Absender" reference) at the
* top of the window — this is what's visible through window
* envelopes above the actual address, by convention separated
* with "*" or "·". Optional; suppressed when issuerLine is
* blank.
* 2. Actual recipient block starting at ADDR_WINDOW.addressY:
* - With company → bold company name, then "z. Hd. <name>"
* - Without company → bold person name, NO attention line
* (avoids the "Noam Mayer / z. Hd. Noam Mayer" duplicate)
* - Address: Street → "POSTAL CITY" (no country prefix on
* postal — the country line below carries that already)
* - Country line in caps for window-envelope readability
*
* The block is positioned absolutely; the caller does not need to
* thread a `y` cursor through. Returns the y of the next free row
* AFTER the address window (useful when drawing the horizontal
* divider below).
*/
function drawRecipientBlock(doc, recipient, locale) {
const x = ADDR_WINDOW.left;
const w = ADDR_WINDOW.width;
// ---- tiny return address line at top of window ----------------
if (recipient.issuerLine) {
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(7.5).fillColor('#555');
doc.text(recipient.issuerLine, x, ADDR_WINDOW.returnLineY, {
width: w, align: 'left', lineBreak: false,
});
}
// ---- recipient address ----------------------------------------
let y = ADDR_WINDOW.addressY;
doc.font(doc._fonts ? doc._fonts.bold : FONT_BOLD).fontSize(11).fillColor('#000');
if (recipient.companyName) {
doc.text(recipient.companyName, x, y, { width: w });
y = doc.y;
}
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10);
// Postal line mirrors the issuer block: "<CC>-<postal> <city>"
// (e.g. "FL-9494 Schaan"). The country code prefix is dropped
// when the customer has no countryCodeIso so the line still
// reads cleanly. The country name on the line below comes from
// the explicit `country` override (customer_accounts.country_name,
// migration 107) or falls back to the locale-aware lookup.
const cc = recipient.countryCodeIso ? String(recipient.countryCodeIso).toUpperCase() : '';
const pc = recipient.postalCode || '';
const postalLeft = cc && pc ? `${cc}-${pc}` : (pc || cc);
const postalSegment = [postalLeft, recipient.city].filter(Boolean).join(' ');
const lines = [
recipient.hasCompany ? recipient.attentionLine : null,
recipient.addressLine1,
recipient.addressLine2,
postalSegment,
recipient.country || countryName(recipient.countryCodeIso, locale),
].filter(Boolean);
for (const line of lines) {
doc.text(line, x, y, { width: w });
y = doc.y;
}
// Return position just below the address window so the caller
// can position the date row / title underneath.
return Math.max(y, ADDR_WINDOW.top + ADDR_WINDOW.height);
}
/**
* Draw DIN 5008 folding marks on the LEFT page edge so the printed
* letter can be folded cleanly to fit a window envelope.
*
* 'half' → single mark at 148.5mm from top (C5 / half-fold)
* 'third' → DIN 5008 thirds-fold: marks at 105mm AND 210mm so the
* paper folds neatly into thirds for DL / C5-6 envelopes
* 'both' → 1/2 mark + both thirds marks (three total)
* 'none' (or anything else) → no marks
*
* Marks are drawn 7.5mm long, anchored against the left edge of the
* paper, 0.4pt hairline, mid-grey so they're visible to the person
* folding but unobtrusive when the page is scanned or photocopied.
*/
function drawFoldingMarks(doc, mode) {
if (!mode || mode === 'none') return;
const MARK_LEN_PT = 7.5 * MM; // 7.5mm = ~21.26pt
const ys = [];
if (mode === 'half' || mode === 'both') {
ys.push(148.5 * MM); // 1/2 fold (C5 envelope)
}
if (mode === 'third' || mode === 'both') {
// DIN 5008 thirds fold uses TWO marks at 105mm and 210mm. The
// 105mm line aligns with the top edge of the address window
// after the first fold; the 210mm line aligns with the next
// fold for the bottom third.
ys.push(105 * MM);
ys.push(210 * MM);
}
doc.save();
doc.strokeColor('#888').lineWidth(0.4);
for (const y of ys) {
doc.moveTo(0, y).lineTo(MARK_LEN_PT, y).stroke();
}
doc.restore();
}
function drawTitle(doc, title, x, y) {
doc.font(doc._fonts ? doc._fonts.bold : FONT_BOLD).fontSize(20).fillColor('#000').text(title, x, y);
return doc.y + 8;
}
function drawDate(doc, label, value, x, y, width) {
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10).fillColor('#000');
const right = x + width;
const labelWidth = 80;
doc.text(`${label}:`, right - labelWidth - 80, y, { width: 80, align: 'right' });
doc.text(value, right - 80, y, { width: 80, align: 'right' });
return doc.y + 10;
}
/**
* Render the line-items table via swissqrbill's Table helper. We supply
* widths in points; the helper draws the borderless layout the
* reference PDF uses.
*
* Columns (quotes): Pos / Anzahl / Beschreibung / Rabatt / Einzelpreis / Summe
* Columns (invoices): Pos / Anzahl / Beschreibung / Einzelpreis / Summe
*/
function drawLineItems(doc, ctx) {
const { type, locale, lineItems, currency, intlLocale } = ctx;
// On a Stornorechnung the line items were snapshotted from the
// original at FULL positive amounts (so the DB-level invariant
// qty × unit = line_total still holds for both rows of the pair).
// The cancellation semantics live on the row-level totals, which
// are already negative in the DB. For the customer-facing PDF
// we flip the per-line total display sign so each row visually
// reads as a credit ("-CHF 300.00") — matches what bookkeepers
// expect on a Storno.
const isStorno = type === 'invoice' && ctx.doc?.kind === 'storno';
const lineTotalSign = isStorno ? -1 : 1;
const labels = {
pos: t(locale, 'table_pos'),
qty: t(locale, 'table_qty'),
desc: t(locale, 'table_description'),
disc: t(locale, 'table_discount'),
unit: t(locale, 'table_unit_price'),
total: t(locale, 'table_line_total'),
};
const showDiscount = type === 'quote' && lineItems.some((li) => Number(li.discountPercent) > 0);
// Column widths sum to PAGE.contentWidth = 515.28. swissqrbill's
// PDFColumn carries `width` + `align` directly on each cell; there
// is NO top-level `columns: [...]` on the Table constructor. The
// previous attempt to pass column widths separately was a no-op,
// which is why numeric cells were left-aligned even though their
// headers were right-aligned (header `textOptions.align` happened
// to work on PDFKit's underlying text() call, but cell-level
// alignment needs the API-supported `align` property).
// Column widths sum to PAGE.contentWidth = 515.28. The qty column
// gets a bit more room than the original 40pt so the German
// header "Anzahl" (6 chars at 10pt + padding ≈ 50pt) doesn't wrap
// across two lines. Width borrowed from the description column,
// which has plenty of slack.
// Column order matches the public quote response webpage:
// # / Description / Qty / [Discount] / Unit / Total
// The maintainer's call — description first reads more like a
// line-by-line list, which is how the web view presents it.
// Widths sum to PAGE.contentWidth (515.28); description takes the
// widest column, qty + numeric columns stay narrow but right-
// aligned.
const widths = showDiscount
? [30, 225, 55, 50, 75, 80]
: [30, 275, 55, 70, 85];
// Per-row padding — tight rows. 3pt top + 3pt bottom keeps each
// line item compact, with just enough vertical breathing room
// for the divider lines to read clearly. swissqrbill's PDFPadding
// type requires array form (number | [top, right, bottom, left]);
// the earlier object form was silently dropped.
const ROW_PADDING = [3, 4, 3, 4];
// Match the totals box font size; the maintainer wants the line
// items and the billing totals to read at the same weight so the
// eye doesn't bounce between two scales.
const ROW_FONT_SIZE = 10;
// Visual divider between items — thin grey rule under every data
// row. swissqrbill PDFRow supports `borderWidth` as a 4-tuple
// [top, right, bottom, left] and matching `borderColor`. We only
// want the bottom line on each data row, and a slightly darker
// bottom on the header row to anchor the column titles. The
// grand-total divider above the sum row is drawn separately in
// drawTotals; here we just delimit items from one another.
const ROW_BORDER_BOTTOM_WIDTH = [0, 0, 0.5, 0];
const ROW_BORDER_BOTTOM_COLOR = ['#000', '#000', '#cccccc', '#000'];
const HEADER_BORDER_BOTTOM_WIDTH = [0, 0, 1, 0];
const HEADER_BORDER_BOTTOM_COLOR = ['#000', '#000', '#000', '#000'];
// Migration 119 — sub-items + details_text.
//
// Hierarchy rendering:
// - Top-level items get a numeric position (1, 2, 3...) and their
// line_total renders in full weight.
// - Sub-items render with an empty position column, the
// description indented with a bullet prefix ("• "), and
// their line_total wrapped in parentheses to mark it as
// display-only (doesn't roll into net). Sub-items with
// unit_price = 0 render the price columns empty.
//
// Details rendering:
// - Each item that has a non-empty details_text gets an extra
// row right below it: empty position cell + the details text
// spanning the description column (smaller font, italic, grey).
// swissqrbill's Table can't actually span columns, so the
// details row fills the description cell width and leaves the
// remaining columns empty — visually equivalent.
//
// We compute a displayIndex for top-level items so the position
// column stays 1..N regardless of how many sub-items sit between
// parents in the array.
let topLevelCount = 0;
const buildItemRow = (li) => {
const isSubItem = li.parentLineItemId != null || li.parentPosition != null;
const posLabel = isSubItem ? '' : String(++topLevelCount);
// Bullet (U+2022) is part of the WinAnsi character set that
// PDFKit's built-in Helvetica supports, unlike the earlier "↳"
// (U+21B3) which rendered as the font's .notdef glyph ("!3").
// Custom TTFs registered via business_profile.pdf_font_ttf_path
// typically include the arrow too, but the bullet is the safe
// common-denominator that always renders.
const descText = isSubItem ? `\u2022 ${li.description || ''}` : (li.description || '');
const subItemPriceless = isSubItem && (!li.unitPriceMinor || Number(li.unitPriceMinor) === 0);
const unitText = subItemPriceless ? '' : formatMinor(li.unitPriceMinor, currency, intlLocale);
const displayLineTotal = lineTotalSign * Number(li.lineTotalMinor || 0);
const lineTotalText = subItemPriceless
? ''
: isSubItem
? `(${formatMinor(displayLineTotal, currency, intlLocale)})`
: formatMinor(displayLineTotal, currency, intlLocale);
const numericColor = isSubItem ? '#666' : '#000';
return {
padding: ROW_PADDING,
fontSize: ROW_FONT_SIZE,
// Border is set by the caller (buildGroupRows) so the LAST row
// of each "group" (parent + sub-items + their details_text
// rows) carries the divider, and the rows above it leave the
// bottom edge empty. Without this, every row gets its own line
// and parent + sub-items look like separate items.
borderWidth: [0, 0, 0, 0],
columns: showDiscount
? [
{ text: posLabel, width: widths[0], align: 'left' },
{ text: descText, width: widths[1], align: 'left', color: numericColor },
{ text: stripTrailingZeros(li.quantity), width: widths[2], align: 'right', color: numericColor },
{ text: subItemPriceless ? '' : `${stripTrailingZeros(li.discountPercent)}%`, width: widths[3], align: 'right', color: numericColor },
{ text: unitText, width: widths[4], align: 'right', color: numericColor },
{ text: lineTotalText, width: widths[5], align: 'right', color: numericColor },
]
: [
{ text: posLabel, width: widths[0], align: 'left' },
{ text: descText, width: widths[1], align: 'left', color: numericColor },
{ text: stripTrailingZeros(li.quantity), width: widths[2], align: 'right', color: numericColor },
{ text: unitText, width: widths[3], align: 'right', color: numericColor },
{ text: lineTotalText, width: widths[4], align: 'right', color: numericColor },
],
};
};
/**
* Build a "details" row that follows an item with non-empty
* details_text. The details text fills the description cell at a
* smaller font + italic-ish (Helvetica-Oblique) + grey colour;
* other cells stay empty. No bottom border so the row visually
* belongs to the item above it.
*/
const buildDetailsRow = (text) => ({
padding: [0, 4, 3, 4],
fontSize: 9,
borderWidth: [0, 0, 0, 0],
columns: showDiscount
? [
{ text: '', width: widths[0], align: 'left' },
{ text, width: widths[1], align: 'left', color: '#666', fontName: 'Helvetica-Oblique' },
{ text: '', width: widths[2], align: 'right' },
{ text: '', width: widths[3], align: 'right' },
{ text: '', width: widths[4], align: 'right' },
{ text: '', width: widths[5], align: 'right' },
]
: [
{ text: '', width: widths[0], align: 'left' },
{ text, width: widths[1], align: 'left', color: '#666', fontName: 'Helvetica-Oblique' },
{ text: '', width: widths[2], align: 'right' },
{ text: '', width: widths[3], align: 'right' },
{ text: '', width: widths[4], align: 'right' },
],
});
const headerRow = {
// Table accepts any registered font name; if a custom font is in
// use we route the bold row through it too.
fontName: ctx.fonts?.bold || FONT_BOLD,
fontSize: ROW_FONT_SIZE,
padding: ROW_PADDING,
borderWidth: HEADER_BORDER_BOTTOM_WIDTH,
borderColor: HEADER_BORDER_BOTTOM_COLOR,
header: true,
columns: showDiscount
? [
{ text: labels.pos, width: widths[0], align: 'left' },
{ text: labels.desc, width: widths[1], align: 'left' },
{ text: labels.qty, width: widths[2], align: 'right' },
{ text: labels.disc, width: widths[3], align: 'right' },
{ text: labels.unit, width: widths[4], align: 'right' },
{ text: labels.total, width: widths[5], align: 'right' },
]
: [
{ text: labels.pos, width: widths[0], align: 'left' },
{ text: labels.desc, width: widths[1], align: 'left' },
{ text: labels.qty, width: widths[2], align: 'right' },
{ text: labels.unit, width: widths[3], align: 'right' },
{ text: labels.total, width: widths[4], align: 'right' },
],
};
// Group rows so a parent + its sub-items + every involved details_text
// share ONE bottom divider drawn after the entire group. Without
// this grouping, each row (parent, sub-item, details) gets its own
// divider and the visual cohesion is lost — sub-items look like
// independent line items, and a details block looks orphaned below
// its parent's divider.
//
// Algorithm:
// - Iterate items in their array order (already grouped by the
// editor: parent → its sub-items → next parent).
// - Collect each parent's row + its details row + every sub-item's
// row + sub-items' details rows into a single "group" array.
// - Apply the bottom border ONLY to the last row of each group.
const dataRows = [];
const groups = [];
let currentGroup = null;
for (const li of lineItems) {
const isSubItem = li.parentLineItemId != null || li.parentPosition != null;
if (!isSubItem) {
// Start a new group at every top-level item.
currentGroup = [];
groups.push(currentGroup);
} else if (!currentGroup) {
// Defensive: if the array starts with an orphaned sub-item
// (shouldn't happen — validateLineItemHierarchy rejects this)
// give it its own group rather than crashing.
currentGroup = [];
groups.push(currentGroup);
}
currentGroup.push(buildItemRow(li));
if (li.detailsText && String(li.detailsText).trim().length > 0) {
currentGroup.push(buildDetailsRow(String(li.detailsText).trim()));
}
}
// Apply the bottom border to the last row of each group.
for (const group of groups) {
if (group.length === 0) continue;
const last = group[group.length - 1];
last.borderWidth = ROW_BORDER_BOTTOM_WIDTH;
last.borderColor = ROW_BORDER_BOTTOM_COLOR;
for (const row of group) dataRows.push(row);
}
const table = new Table({
width: PAGE.contentWidth,
rows: [headerRow, ...dataRows],
});
table.attachTo(doc);
return doc.y;
}
function stripTrailingZeros(value) {
if (value == null) return '';
const num = Number(value);
if (Number.isNaN(num)) return String(value);
const s = num.toString();
// Only strip zeros AFTER the decimal point. Naively replacing
// `/\.?0+$/` also ate the trailing zero in whole numbers like
// "10" → "1", which made a quantity of 10 render as 1 on the
// PDF while the total (qty * unit) stayed correct: Anzahl=10,
// Einzelpreis 123, Summe 1230, but the column read "1".
if (!s.includes('.')) return s;
return s.replace(/0+$/, '').replace(/\.$/, '') || '0';
}
/**
* Totals box, right-aligned. Two columns: label (left), value (right).
* VAT row drops when rate is 0 + amount is 0? No — reference shows
* "ges. MwSt. 0.0% 0.00" so we keep it visible.
*/
function drawTotals(doc, ctx, x, y, width) {
const { locale, currency, intlLocale, totals } = ctx;
// Layout: align the totals labels with the RIGHT column of the
// payment block beneath (where "Please transfer the amount …",
// "<Account holder>", and "<IBAN>" appear). Both columns of the
// payment block split the page in half, so the right-column
// anchor sits at `x + width/2 + 10` (mirrors drawPaymentBlock's
// `rightX = x + colWidth + 20` with colWidth = (width-20)/2).
// Values + VAT-rate column stay right-aligned to the page edge
// so amounts still stack tabularly.
const right = x + width;
const valueCol = 80;
const rateCol = 40;
const valueX = right - valueCol;
const rateX = right - valueCol - rateCol;
const labelX = x + (width - 20) / 2 + 20; // matches drawPaymentBlock.rightX
const labelCol = rateX - labelX - 6; // small gap before rate column
// Divider line ABOVE the totals block — spans the FULL page
// content width (from the left margin to the right edge) so it
// visually closes off the line-items table above and the totals
// stack below as one continuous letterhead section.
doc.moveTo(x, y).lineTo(right, y).strokeColor('#000').lineWidth(0.8).stroke();
y += 6;
doc.font(doc._fonts ? doc._fonts.bold : FONT_BOLD).fontSize(10);
doc.text(t(locale, 'totals_net'), labelX, y, { width: labelCol });
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY);
doc.text(formatMinor(totals.netAmountMinor, currency, intlLocale), valueX, y, { width: valueCol, align: 'right' });
y = doc.y + 4;
doc.font(doc._fonts ? doc._fonts.bold : FONT_BOLD).text(t(locale, 'totals_shipping'), labelX, y, { width: labelCol });
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).text(formatMinor(totals.shippingAmountMinor, currency, intlLocale), valueX, y, { width: valueCol, align: 'right' });
y = doc.y + 4;
// Custom VAT label (Settings → Accounting) overrides the per-locale default.
const vatLabel = (ctx.issuer && ctx.issuer.vatLabel) || t(locale, 'totals_vat');
doc.font(doc._fonts ? doc._fonts.bold : FONT_BOLD).text(vatLabel, labelX, y, { width: labelCol });
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).text(`${stripTrailingZeros(totals.vatRate)}%`, rateX, y, { width: rateCol, align: 'right' });
doc.text(formatMinor(totals.vatAmountMinor, currency, intlLocale), valueX, y, { width: valueCol, align: 'right' });
y = doc.y + 4;
// Free-text VAT / legal note (#794) — printed directly under the MwSt. line
// (Benedikt's requested spot). The admin sets the exact wording in
// Settings → CRM → Invoices (e.g. the Austrian Kleinunternehmer statement).
// Optional; wraps across the totals column. Font size is restored to the row
// scale so the Mahngebühr / Rundung / grand-total rows below are unaffected.
if (ctx.vatNote) {
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(8).fillColor('#555');
doc.text(ctx.vatNote, labelX, y, { width: right - labelX });
doc.fillColor('#000').fontSize(10);
y = doc.y + 4;
}
// Mahngebühr row — only rendered when a late fee has been added
// (second reminder onwards). Sits between VAT and the grand-total
// divider so the customer sees a clear "VAT + late fee → Total"
// arithmetic chain. The grand-total figure below folds it in.
const lateFeeMinor = Number(totals.lateFeeAmountMinor || 0);
if (lateFeeMinor > 0) {
doc.font(doc._fonts ? doc._fonts.bold : FONT_BOLD).text(t(locale, 'totals_late_fee'), labelX, y, { width: labelCol });
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).text(formatMinor(lateFeeMinor, currency, intlLocale), valueX, y, { width: valueCol, align: 'right' });
y = doc.y + 4;
}
// Rundung — sub-cent reconciliation row (crm_invoice_round_total). Only
// rendered when the stored (clean) net differs from the sum of the
// visible line totals; bridges "Betrag Netto" (= Σ lines, foots with
// the items) down/up to the clean Gesamtbetrag below. Zero ⇒ omitted,
// so unrounded documents are byte-identical to before.
const roundingMinor = Number(totals.roundingAdjustmentMinor || 0);
if (roundingMinor !== 0) {
doc.font(doc._fonts ? doc._fonts.bold : FONT_BOLD).text(t(locale, 'totals_rounding'), labelX, y, { width: labelCol });
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).text(formatMinor(roundingMinor, currency, intlLocale), valueX, y, { width: valueCol, align: 'right' });
y = doc.y + 4;
}
y += 6;
// Divider line above grand total — spans the right half of the
// page only, from the label anchor to the right edge, so it sits
// visually over the same column as "Please transfer …" below.
doc.moveTo(labelX, y).lineTo(right, y).strokeColor('#000').lineWidth(0.8).stroke();
y += 6;
// Grand-total row uses the SAME font size as the rows above (and
// as the line-item table) — the maintainer wants the billing
// titles to read at one consistent scale instead of stair-
// stepping up to a bigger headline. The row stays bold for
// visual emphasis. Includes the Mahngebühr when present so the
// customer's "owed" figure is the single bottom-line number.
const grandTotalMinor = Number(totals.totalAmountMinor || 0) + lateFeeMinor;
doc.font(doc._fonts ? doc._fonts.bold : FONT_BOLD).fontSize(10);
doc.text(t(locale, 'totals_grand'), labelX, y, { width: labelCol });
doc.text(formatCurrencyLabel(currency), rateX, y, { width: rateCol, align: 'right' });
doc.text(formatMinor(grandTotalMinor, currency, intlLocale), valueX, y, { width: valueCol, align: 'right' });
return doc.y + 10;
}
/**
* Render the payment conditions + IBAN block. Two columns side by side
* matching the reference layout:
* left: "Payment conditions: <text>. The amount must be paid within
* 30 days from invoice date."
* right: "Please transfer the amount to the following bank account:
* <IBAN>"
*/
function drawPaymentBlock(doc, ctx, x, y, width) {
const { type, locale, paymentTerm, bank, intlLocale, totals, currency, issuer, doc: docMeta } = ctx;
const colWidth = (width - 20) / 2;
const leftX = x;
const rightX = x + colWidth + 20;
const startY = y;
// Quote vs invoice differs in two ways:
// - Quotes never render the IBAN block (right column). A quote
// is an offer, not a demand for payment, so wiring money
// against an unsigned quote should not be encouraged.
// - Quotes honor the per-issuer toggles for the net-days line
// and the Skonto line. Both default true; setting either to
// false suppresses that specific row.
// Invoices always show every available row + the IBAN.
const isQuote = type === 'quote';
const showNetDaysHere = isQuote ? (issuer?.quoteShowNetDays !== false) : true;
// Skonto is suppressed once the invoice is in dunning. A
// "Mahnrechnung" rewarding the customer with an early-payment
// discount makes no business sense — they're already late.
// Quotes still respect the per-issuer toggle.
const reminderLevel = Number(docMeta?.reminderLevel || 0);
const showSkontoHere = isQuote
? (issuer?.quoteShowSkonto !== false)
: reminderLevel === 0;
// Invoices show the IBAN block in the right column EXCEPT when a
// Swiss QR-bill slip is appended: that slip already prints the
// account/IBAN ("Konto / Zahlbar an") in human-readable form, so
// repeating "Der Betrag ist auf die folgende Bankverbindung zu
// überweisen: …" under the totals is pure duplication. The EPC QR
// path keeps the block — its QR lives on a trailing page, so having
// the bank details on the invoice page itself still helps.
const showIbanHere = !isQuote && ctx.qrFormat !== 'swiss';
// If the quote has nothing to print in either column, bail out
// early — don't render a bare "Payment conditions:" header with
// no rows under it.
const hasNetDaysRow = showNetDaysHere && paymentTerm?.netDays;
const hasSkontoRow = showSkontoHere && paymentTerm?.skontoPercent && paymentTerm?.skontoWithinDays;
// Late-fee note in the payment block is redundant now that the
// Mahngebühr appears as its own row in the totals stack. Keep it
// suppressed to avoid duplicate "+CHF 25.00 late fee" text.
const hasLateFeeRow = false;
const hasLeftContent = paymentTerm?.description || hasNetDaysRow || hasSkontoRow || hasLateFeeRow;
if (!hasLeftContent && !showIbanHere) return y;
if (hasLeftContent) {
doc.font(doc._fonts ? doc._fonts.bold : FONT_BOLD).fontSize(10).fillColor('#000');
doc.text(t(locale, 'payment_conditions') + ':', leftX, y, { width: colWidth });
y = doc.y + 2;
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10);
if (paymentTerm?.description) {
doc.text(paymentTerm.description, leftX, y, { width: colWidth });
y = doc.y + 4;
}
if (hasNetDaysRow) {
doc.text(
`${paymentTerm.netDays} ${t(locale, 'net_days_suffix')}`,
leftX, y, { width: colWidth }
);
y = doc.y + 4;
}
if (hasSkontoRow) {
doc.text(
t(locale, 'skonto_phrase', {
percent: stripTrailingZeros(paymentTerm.skontoPercent),
days: paymentTerm.skontoWithinDays,
}),
leftX, y, { width: colWidth }
);
y = doc.y + 2;
// Show the post-discount amount so the customer doesn't have
// to do the math. Computed off the grand total (incl. VAT +
// shipping) per CH/DE convention.
const skontoTotalMinor = totals?.totalAmountMinor
? Math.round(Number(totals.totalAmountMinor) * (1 - Number(paymentTerm.skontoPercent) / 100))
: null;
if (skontoTotalMinor != null) {
doc.fillColor('#444').text(
`${t(locale, 'skonto_amount_label')}: ${formatCurrencyLabel(currency)} ${formatMinor(skontoTotalMinor, currency, intlLocale)}`,
leftX, y, { width: colWidth }
);
doc.fillColor('#000');
y = doc.y + 4;
} else {
y += 2;
}
}
// Late fee note for second-reminder invoices (never on quotes).
if (hasLateFeeRow) {
doc.fillColor('#a00').text(
t(locale, 'late_fee_note', {
amount: `${formatCurrencyLabel(ctx.currency)} ${formatMinor(docMeta.lateFeeMinor, ctx.currency, intlLocale)}`,
}),
leftX, y, { width: colWidth }
);
doc.fillColor('#000');
y = doc.y + 4;
}
}
// Right column: IBAN (invoices only).
let ry = startY;
if (showIbanHere && bank) {
doc.font(doc._fonts ? doc._fonts.bold : FONT_BOLD).fontSize(10);
doc.text(t(locale, 'iban_intro'), rightX, ry, { width: colWidth });
ry = doc.y + 4;
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10);
if (bank.accountHolder) {
doc.text(bank.accountHolder, rightX, ry, { width: colWidth });
ry = doc.y;
}
if (bank.iban) {
const formatted = bank.iban.replace(/(.{4})/g, '$1 ').trim();
doc.text(formatted, rightX, ry, { width: colWidth });
ry = doc.y;
}
if (bank.bic) {
doc.text(`BIC: ${bank.bic}`, rightX, ry, { width: colWidth });
ry = doc.y;
}
}
return Math.max(y, ry) + 8;
}
function drawFooter(doc, issuer, locale) {
// Footer format (per design review):
// "<Company>, <Street>, <CC>-<PostalCode> <City>, <CountryName>"
// e.g.
// "Luca Bresch Media, Im Fetzer 45a, FL-9494 Schaan, Liechtenstein"
//
// The previous version printed `<PostalCode> <City>, <CC>` which
// dropped the country prefix from the postal block AND used the
// bare ISO code instead of the full country name.
//
// Footer sits within the content area (above the bottom margin) —
// writing past doc.page.height - marginBottom triggers PDFKit's
// auto-page-break (the original bug behind the mysterious empty
// trailing pages).
const lineH = 12;
const hasFooterLine = !!issuer.footerLine;
const reserved = hasFooterLine ? lineH * 2 + 4 : lineH;
const footerY = doc.page.height - PAGE.marginBottom - reserved;
const cc = issuer.countryCode ? String(issuer.countryCode).toUpperCase() : '';
const pc = issuer.postalCode || '';
const postalLeft = cc && pc ? `${cc}-${pc}` : (pc || cc);
const postalSegment = [postalLeft, issuer.city].filter(Boolean).join(' ');
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(8).fillColor('#888');
const parts = [
issuer.companyName,
issuer.addressLine1,
postalSegment,
// Prefer the explicit country_name override (migration 107)
// before falling back to the COUNTRY_NAMES lookup.
issuer.countryName || countryName(issuer.countryCode, locale),
].filter(Boolean);
doc.text(parts.join(', '), PAGE.marginLeft, footerY, {
width: PAGE.contentWidth, align: 'center', lineBreak: false,
});
if (hasFooterLine) {
doc.text(issuer.footerLine, PAGE.marginLeft, footerY + lineH, {
width: PAGE.contentWidth, align: 'center', lineBreak: false,
});
}
// Reset fill colour so any code that runs after the footer (e.g.
// the appendSwissQrBill page) doesn't inherit the grey.
doc.fillColor('#000');
}
/**
* Add the Swiss QR-bill payment slip on a fresh page. This is rendered
* by the swissqrbill library — we just feed it the issuer/recipient/
* amount. For non-swiss QR formats this returns without adding a page.
*
* The QR-bill spec REQUIRES the slip on a separate physical page, full
* width at the bottom — swissqrbill handles all of that.
*/
function appendSwissQrBill(doc, ctx) {
if (ctx.qrFormat !== 'swiss') return;
const { issuer, bank, doc: docMeta, recipient } = ctx;
if (!bank?.iban) return;
doc.addPage();
// swissqrbill expects amounts in major units (CHF, not Rappen).
const totalMajor = Number(docMeta.totalAmountMinor || 0) / 100;
try {
const qr = new SwissQRBill({
currency: (ctx.currency || 'CHF').toUpperCase() === 'EUR' ? 'EUR' : 'CHF',
amount: totalMajor > 0 ? totalMajor : undefined,
creditor: {
name: bank.accountHolder || issuer.companyName || '',
address: issuer.addressLine1 || '',
zip: issuer.postalCode || '',
city: issuer.city || '',
country: (issuer.countryCode || 'CH').toUpperCase(),
account: bank.iban.replace(/\s+/g, ''),
},
debtor: recipient?.companyName ? {
name: recipient.companyName.slice(0, 70),
address: recipient.addressLine1 || '',
zip: recipient.postalCode || '',
city: recipient.city || '',
country: (recipient.countryCodeIso || 'CH').toUpperCase(),
} : undefined,
message: docMeta.invoiceNumber ? `${docMeta.invoiceNumber}` : undefined,
});
qr.attachTo(doc);
} catch (err) {
// Don't kill PDF rendering if QR generation fails — log + carry on.
// The invoice without QR is still legally valid; admin gets a flag
// via the calling service.
const logger = require('../utils/logger');
logger.warn('SwissQRBill render failed; emitting invoice without QR section', { err: err.message });
}
}
/**
* Build an EPC069-12 (SEPA Credit Transfer) QR payload.
*
* Format (each field on its own line, '\n' separator):
* 1. "BCD" service tag
* 2. "002" version
* 3. "1" character set (UTF-8)
* 4. "SCT" identification (SEPA Credit Transfer)
* 5. BIC optional in v002
* 6. Beneficiary name max 70 chars, required
* 7. IBAN no spaces, required
* 8. Amount "EUR123.45", optional (customer
* enters amount manually if absent)
* 9. Purpose ISO 11649 4-letter, optional
* 10. Structured reference max 35 chars, optional
* 11. Unstructured reference max 140 chars, optional
* 12. Beneficiary-to-originator info max 70 chars, optional
*
* Total payload <= 331 bytes. EPC QR is EUR-only; banking apps
* silently reject non-EUR payloads.
*/
function buildEpcPayload({ name, iban, amount, currency, reference }) {
// Amount field: "<ISO 4217 3-letter><amount with 2 decimals>".
// Spec says EUR-only, but many wallets accept other 3-letter
// codes and either honour or ignore them. Emit whatever the
// invoice carries so the QR isn't a no-op for CHF/USD/etc.
const cur = String(currency || 'EUR').toUpperCase().slice(0, 3);
const lines = [
'BCD',
'002',
'1',
'SCT',
'', // BIC (optional in v002)
String(name || '').slice(0, 70),
String(iban || '').replace(/\s+/g, '').toUpperCase(),
amount > 0 ? `${cur}${amount.toFixed(2)}` : '',
'', // purpose
'', // structured reference
String(reference || '').slice(0, 140), // unstructured reference
'', // info
];
return lines.join('\n');
}
/**
* Append an EPC (SEPA) QR code to the document. Unlike Swiss QR-bill
* which is a full-page payment slip, EPC is just a QR code with a
* short caption — banking apps scan it to prefill a SEPA Credit
* Transfer. We add it on a fresh page so it never collides with the
* line items / totals above.
*
* Requires EUR currency. Non-EUR docs log a warning and skip — EPC
* QR codes in CHF/USD/etc. are silently rejected by every major
* banking app, so emitting one would be worse than emitting nothing.
*/
async function appendEpcQr(doc, ctx) {
if (ctx.qrFormat !== 'epc') return;
const logger = require('../utils/logger');
const { issuer, bank, doc: docMeta } = ctx;
if (!bank?.iban) {
logger.warn('EPC QR skipped — no IBAN on the resolved bank account');
return;
}
// EPC069-12 spec is technically EUR-only, but most banking apps
// still parse the payload for non-EUR currencies and either honor
// it (when the bank supports the destination currency) or fall
// back to manual entry. Render the QR regardless and log a note
// when the currency isn't EUR so the admin sees it in the logs —
// emitting something is always more useful than emitting nothing.
const currencyUpper = (ctx.currency || 'EUR').toUpperCase();
if (currencyUpper !== 'EUR') {
logger.info('EPC QR rendered with non-EUR currency; banking apps may fall back to manual entry', {
currency: currencyUpper,
});
}
const totalMajor = Number(docMeta.totalAmountMinor || 0) / 100;
const payload = buildEpcPayload({
name: bank.accountHolder || issuer.companyName || '',
iban: bank.iban,
amount: totalMajor,
currency: currencyUpper,
reference: docMeta.invoiceNumber || '',
});
let pngBuffer;
try {
const QRCode = require('qrcode');
pngBuffer = await QRCode.toBuffer(payload, {
errorCorrectionLevel: 'M',
type: 'png',
margin: 2,
width: 320,
});
} catch (err) {
logger.warn('EPC QR generation failed', { err: err.message });
return;
}
// Fresh page so the QR doesn't fight the totals/payment layout on
// page 1. Centered, with a caption explaining what it is.
doc.addPage();
const captionTop = PAGE.marginTop + 20;
doc.font(doc._fonts ? doc._fonts.bold : FONT_BOLD).fontSize(14).fillColor('#000');
doc.text(t(ctx.locale, 'epc_qr_title'), PAGE.marginLeft, captionTop, {
width: PAGE.contentWidth, align: 'center', lineBreak: false,
});
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10).fillColor('#444');
doc.text(t(ctx.locale, 'epc_qr_subtitle'), PAGE.marginLeft, captionTop + 22, {
width: PAGE.contentWidth, align: 'center',
});
// QR centred on the page, sized at ~180pt (≈63mm) — comfortably
// scannable on every phone camera + small enough to leave room
// for the printed IBAN beneath.
const qrSize = 180;
const qrX = (PAGE.width - qrSize) / 2;
const qrY = captionTop + 60;
try {
doc.image(pngBuffer, qrX, qrY, { fit: [qrSize, qrSize] });
} catch (err) {
logger.warn('EPC QR embed failed', { err: err.message });
return;
}
// Human-readable summary under the QR so the customer can still
// initiate the transfer manually if their banking app can't scan.
const summaryY = qrY + qrSize + 24;
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10).fillColor('#000');
const summaryLines = [
bank.accountHolder || issuer.companyName || '',
bank.iban.replace(/(.{4})/g, '$1 ').trim(),
bank.bic ? `BIC: ${bank.bic}` : '',
totalMajor > 0
? `${t(ctx.locale, 'totals_grand')}: ${currencyUpper} ${formatMinor(docMeta.totalAmountMinor, currencyUpper, ctx.intlLocale)}`
: '',
docMeta.invoiceNumber
? `${t(ctx.locale, 'reference_label')}: ${docMeta.invoiceNumber}`
: '',
].filter(Boolean);
let lineY = summaryY;
for (const line of summaryLines) {
doc.text(line, PAGE.marginLeft, lineY, { width: PAGE.contentWidth, align: 'center' });
lineY = doc.y + 2;
}
}
/**
* Build a configured PDFDocument with our font conventions and return
* it alongside its page metrics. Used by both the quote/invoice
* renderer below (portrait, DIN 5008) and the tax-report renderer
* (landscape, wide table). Keeps font registration + page sizing in
* one place so future PDF features stay consistent.
*
* options = {
* orientation: 'portrait' | 'landscape' (default 'portrait'),
* issuer: { pdfFontTtfPath? } — used for optional custom-font registration,
* info: { Title?, Author? } — PDF metadata (filename in Chrome viewer),
* }
*
* Returns: { doc, page, fonts } where
* doc — pdfkit PDFDocument instance, ready to write to
* page — page metrics for the chosen orientation (see getPageMetrics)
* fonts — { body, bold } logical font names; the caller passes these
* to doc.font(...) calls and they resolve to either the
* built-in Helvetica family or the admin's custom TTF.
*
* The function does NOT pipe the document to a stream — the caller
* decides whether to buffer (`doc.on('data', ...)`) or stream straight
* to an HTTP response. Mirrors the pattern the existing renderDocument
* already uses internally.
*/
function createBaseDocument(options = {}) {
const orientation = options.orientation === 'landscape' ? 'landscape' : 'portrait';
const page = getPageMetrics(orientation);
const doc = new PDFDocument({
size: 'A4',
layout: orientation,
bufferPages: true,
margins: {
top: page.marginTop, bottom: page.marginBottom,
left: page.marginLeft, right: page.marginRight,
},
info: options.info || {},
});
// Font registration. Resolution priority:
// 1. issuer.pdfFontTtfPath → legacy free-text upload (migration 103).
// The UI for setting it was retired in favour of the dropdown,
// but any existing value still wins so deployments that already
// pointed at a custom brand font keep rendering with it.
// 2. issuer.pdfFontFamily → bundled-fonts dropdown (migration 121).
// Maps to backend/assets/fonts/<family>/400.ttf for body and
// <family>/700.ttf for bold. Falls back to 600/400 if 700 is
// missing (some families don't ship every weight).
// 3. Helvetica → PDFKit's built-in default.
//
// Same block is mirrored below in renderDocument so quote / invoice
// / tax-report PDFs all resolve fonts identically.
doc._fonts = { body: FONT_BODY, bold: FONT_BOLD };
const issuer = options.issuer || {};
const fontRegistered = registerCustomFonts(doc, issuer);
if (fontRegistered) doc._fonts = fontRegistered;
return { doc, page, fonts: doc._fonts };
}
/**
* Try to register a custom font pair on the given doc per the
* priority order documented on createBaseDocument. Returns the new
* `{ body, bold }` logical-font-names object when a custom font is
* applied, or `null` when we fell through to Helvetica.
*
* Exported via _internal for unit tests.
*/
function registerCustomFonts(doc, issuer) {
if (!issuer || typeof issuer !== 'object') return null;
const path = require('path');
const fs = require('fs');
// Priority 1: legacy free-text path.
if (issuer.pdfFontTtfPath) {
try {
const raw = issuer.pdfFontTtfPath;
const candidates = [
path.isAbsolute(raw) ? raw : null,
path.join(process.cwd(), 'storage', raw.replace(/^\/+/, '')),
path.join(process.cwd(), 'storage', 'fonts', path.basename(raw)),
].filter(Boolean);
const found = candidates.find((p) => { try { return fs.existsSync(p); } catch { return false; } });
if (found && /\.(ttf|otf)$/i.test(found)) {
doc.registerFont(CUSTOM_BODY, found);
doc.registerFont(CUSTOM_BOLD, found);
return { body: CUSTOM_BODY, bold: CUSTOM_BOLD };
}
} catch { /* fall through to family / Helvetica */ }
}
// Priority 2: bundled-fonts dropdown.
if (issuer.pdfFontFamily) {
try {
// Sanitise the family name aggressively — comes from user input
// (a saved dropdown value), so prevent path traversal even
// though directory names should always be plain ASCII like
// "Inter" or "Playfair-Display".
const family = String(issuer.pdfFontFamily).replace(/[^A-Za-z0-9_-]/g, '');
if (family) {
const fontsRoot = path.resolve(__dirname, '../../assets/fonts', family);
const bodyCandidates = ['400.ttf', '500.ttf', '600.ttf', '700.ttf'];
const boldCandidates = ['700.ttf', '600.ttf', '500.ttf', '400.ttf'];
const findFirst = (names) => {
for (const n of names) {
const full = path.join(fontsRoot, n);
try { if (fs.existsSync(full)) return full; } catch { /* ignore */ }
}
return null;
};
const bodyFile = findFirst(bodyCandidates);
const boldFile = findFirst(boldCandidates);
if (bodyFile && boldFile) {
doc.registerFont(CUSTOM_BODY, bodyFile);
doc.registerFont(CUSTOM_BOLD, boldFile);
return { body: CUSTOM_BODY, bold: CUSTOM_BOLD };
}
}
} catch { /* fall through to Helvetica */ }
}
return null;
}
/**
* The main renderer. `type` is 'quote' | 'invoice'. Returns Buffer.
*/
function renderDocument(type, context) {
return new Promise((resolve, reject) => {
// Wrap the body in an async IIFE so we can `await` the EPC QR
// PNG generation (which uses the qrcode library asynchronously).
// Errors from the IIFE bubble up via reject(); the doc 'end'
// event still resolves the outer Promise once writes flush.
(async () => {
try {
const ctx = normaliseContext(type, context);
const doc = new PDFDocument({
size: 'A4',
// bufferPages: true keeps every page open in memory after
// they're emitted so we can switch back and stamp the page
// numbers ("Page 1 of N" / "Seite 1 von N") once we know how
// many pages the document ended up with. Without buffering,
// PDFKit flushes each page as soon as the next one starts,
// so we couldn't know N until it was too late.
bufferPages: true,
margins: {
top: PAGE.marginTop, bottom: PAGE.marginBottom,
left: PAGE.marginLeft, right: PAGE.marginRight,
},
info: {
// Chrome's built-in PDF viewer uses this Title metadata
// as the default save name when the PDF is served from a
// blob URL (where the original HTTP Content-Disposition
// header can't propagate). Format mirrors the filename
// we set on the HTTP response: "<number>_<customerLabel>"
// so saved files have a meaningful name in either path.
Title: (() => {
const docNumber = ctx.doc.invoiceNumber || ctx.doc.quoteNumber
|| (type === 'quote' ? 'Quote' : 'Invoice');
// Prefer the recipient (customer) for the label —
// matches how admins typically file invoices.
const recipient = ctx.recipient?.companyName || '';
return recipient ? `${docNumber}_${recipient}` : String(docNumber);
})(),
Author: ctx.issuer.companyName || 'picpeak',
},
});
const chunks = [];
doc.on('data', (c) => chunks.push(c));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
// Font registration. Same resolution priority as
// createBaseDocument: pdfFontTtfPath (legacy override) →
// pdfFontFamily (bundled dropdown) → Helvetica. Helpers below
// read `doc._fonts` (one extra word per doc) so we don't have
// to thread the font names through every drawing function or
// fork the helpers per branding.
doc._fonts = { body: FONT_BODY, bold: FONT_BOLD };
ctx.fonts = doc._fonts;
const registered = registerCustomFonts(doc, ctx.issuer);
if (registered) {
doc._fonts = registered;
ctx.fonts = registered;
}
// ---- header layout (DIN 5008 Form B) -------------------------
// - recipient block in the address window (top-left,
// 45mm from top, 20mm from left, 85×45mm)
// - issuer block top-right (logo + company + address +
// contact) sized to NOT overlap the address window
//
// The two blocks are positioned absolutely; we keep a `y`
// cursor for the body content that starts BELOW both blocks.
const leftX = PAGE.marginLeft;
// Sender block: narrower (180pt vs 220pt), further right, and
// nudged down by 16pt so it doesn't crowd the very top of the
// page. Leaves more breathing room for the logo + name banner.
const issuerWidth = 180;
const issuerX = PAGE.width - PAGE.marginRight - issuerWidth;
const issuerY = PAGE.marginTop + 16;
const issuerEndY = drawIssuerBlock(doc, ctx.issuer, issuerX, issuerY, issuerWidth, ctx.locale);
const recipientEndY = drawRecipientBlock(doc, ctx.recipient, ctx.locale);
// Start the body content below the header blocks AND the
// address-window bottom edge — never let the date/title row
// cut through the window region. The title position isn't
// dictated by DIN 5008 (the spec only fixes the address window
// position), so we pull it tight against the window's bottom
// edge to give the body more vertical room.
let y = Math.max(issuerEndY, recipientEndY, ADDR_WINDOW.top + ADDR_WINDOW.height) + 6;
// Storno discriminator. Drives:
// - page title swap ("Stornorechnung" instead of "Rechnung")
// - mandatory reference line under the title
// - sign flip on line totals (row-level totals are already
// stored negative in the DB, so drawTotals renders them
// naturally — see drawLineItems for the per-item flip)
// - suppression of payment terms / IBAN / QR-bill blocks
// `type === 'invoice'` is preserved as the outer document
// family — Storni share the invoice renderer surface, only
// the cosmetic + accounting-sign branches differ.
const isStorno = type === 'invoice' && ctx.doc.kind === 'storno';
// Mahnung (reminder letter) reuses the invoice surface: same line items +
// a Mahngebühr row + the new grand total, but a "Mahnung" title and NO
// QR (the QR would encode the original amount, not the new total).
const isMahnung = type === 'invoice' && ctx.doc.kind === 'mahnung';
// ---- document number (above) + date (below), both right-aligned
// The number sits directly under the sender address block so the
// customer + accountant find the invoice/quote/Storno reference
// exactly where DACH letter convention puts it. The date follows
// on its own row with the same right-anchored column structure so
// both label-and-value pairs align to the same right edge.
const docNumberForDisplay = ctx.doc.invoiceNumber || ctx.doc.quoteNumber || '';
const numberLabelKey = type === 'quote' ? 'quote_number_label' : 'invoice_number_label';
const metaRight = leftX + PAGE.contentWidth;
const metaLabelW = 110; // wider than the date label so "Rechnungsnummer" fits without wrap
const metaValueW = 110;
if (docNumberForDisplay) {
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10).fillColor('#000');
doc.text(`${t(ctx.locale, numberLabelKey)}:`,
metaRight - metaValueW - metaLabelW, y,
{ width: metaLabelW, align: 'right', lineBreak: false });
doc.text(docNumberForDisplay, metaRight - metaValueW, y,
{ width: metaValueW, align: 'right', lineBreak: false });
y += 14;
}
// Date row — same right-anchored layout so the two values stack
// visually as a single meta block. Replaces the previous
// drawDate() call, which lived below the title and used a
// tighter column spec.
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10).fillColor('#000');
doc.text(`${t(ctx.locale, 'date')}:`,
metaRight - metaValueW - metaLabelW, y,
{ width: metaLabelW, align: 'right', lineBreak: false });
doc.text(formatDate(ctx.doc.issueDate, ctx.dateFormat),
metaRight - metaValueW, y,
{ width: metaValueW, align: 'right', lineBreak: false });
y += 18; // line height + cushion before the title
// ---- title ----------------------------------------------------
const title = type === 'quote'
? t(ctx.locale, 'quote_title')
: isStorno
? t(ctx.locale, 'storno_title')
: isMahnung
? t(ctx.locale, 'mahnung_title')
: t(ctx.locale, 'invoice_title');
y = drawTitle(doc, title, leftX, y + 2);
// Mandatory Storno reference line — "Bezug: Storno zu Rechnung
// R-XXXX vom DATE". This is the §14c-defensible link from the
// cancellation document to the invoice it reverses; readers
// and Finanzamt auditors need both numbers + the original
// issue date to reconstruct the chain from the documents
// alone. Stamped FIRST (before sourceQuote / replaces) so
// it's the prominent reference on a Storno.
if (isStorno && ctx.doc.cancelsInvoice) {
const { number, issueDate } = ctx.doc.cancelsInvoice;
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10).fillColor('#666');
const datePart = issueDate ? ` ${t(ctx.locale, 'reference_dated', { date: formatDate(issueDate, ctx.dateFormat) })}` : '';
doc.text(
`${t(ctx.locale, 'reference_label')}: ${t(ctx.locale, 'reference_cancels')} ${t(ctx.locale, 'invoice_title')} ${number}${datePart}`,
leftX, y, { width: PAGE.contentWidth }
);
y = doc.y + 6;
doc.fillColor('#000');
}
// Invoice → source quote cross-reference. We deliberately keep
// invoice numbers on a strict monotonic sequence (R-YYYY-NNNN)
// for tax-compliance reasons (CH/LI/DE/AT require
// "lückenlose Rechnungsnummern") — instead of mirroring the
// quote number on the invoice, we surface the link as a small
// "Bezug: Angebot Q-…" line under the title. Readers see the
// provenance without breaking the numbering scheme. Only
// rendered for invoices that came from a quote; no-op for
// standalone invoices and Storni (which don't reference quotes).
if (type === 'invoice' && !isStorno && ctx.doc.sourceQuoteNumber) {
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10).fillColor('#666');
doc.text(
`${t(ctx.locale, 'reference_label')}: ${t(ctx.locale, 'quote_title')} ${ctx.doc.sourceQuoteNumber}`,
leftX, y, { width: PAGE.contentWidth }
);
y = doc.y + 6;
doc.fillColor('#000');
}
// Cancel + reissue trail (migration 114) — when this invoice
// replaces an earlier (cancelled) one, surface "Bezug: Ersetzt
// Rechnung R-XXXX vom DATE" so the customer (and auditors) can
// trace the chain. Rendered in the same grey-666 small-print
// style as the quote-source reference above. Suppressed on
// Storni (which carry their own cancelsInvoice reference).
if (type === 'invoice' && !isStorno && ctx.doc.replacesInvoice) {
const { number, issueDate } = ctx.doc.replacesInvoice;
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10).fillColor('#666');
const datePart = issueDate ? ` ${t(ctx.locale, 'reference_dated', { date: formatDate(issueDate, ctx.dateFormat) })}` : '';
doc.text(
`${t(ctx.locale, 'reference_label')}: ${t(ctx.locale, 'reference_replaces')} ${t(ctx.locale, 'invoice_title')} ${number}${datePart}`,
leftX, y, { width: PAGE.contentWidth }
);
y = doc.y + 6;
doc.fillColor('#000');
}
// ---- salutation + lead-in ------------------------------------
// Personalised greeting when the customer record has an
// honorific + last name on file ("Sehr geehrter Herr Bresch,"),
// otherwise the generic locale-specific opening from the i18n
// dictionary ("Sehr geehrte Damen und Herren,").
const greeting = personalSalutation(ctx.locale, ctx.recipient?.salutation, ctx.recipient?.lastName)
|| t(ctx.locale, 'salutation');
doc.font(doc._fonts ? doc._fonts.bold : FONT_BOLD).fontSize(10).fillColor('#000');
doc.text(greeting, leftX, y, { width: PAGE.contentWidth });
y = doc.y + 4;
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY);
const leadIn = type === 'quote'
? t(ctx.locale, 'lead_in_quote')
: t(ctx.locale, 'lead_in_invoice');
doc.text(leadIn, leftX, y, { width: PAGE.contentWidth });
y = doc.y + 16;
// ---- intro text override (admin-customisable) -----------------
if (ctx.doc.introText) {
doc.text(ctx.doc.introText, leftX, y, { width: PAGE.contentWidth });
y = doc.y + 12;
}
// ---- line items table ----------------------------------------
// Small top padding — tight against the lead-in text since the
// maintainer wants the items right under the greeting/intro.
y += 8;
doc.y = y;
doc.x = leftX;
// Let the items table paginate with the document's NORMAL
// margins so each page fills to the bottom. The header row is
// marked `header: true` so it auto-repeats on every
// continuation page. Totals/payment placement is handled below:
// they're pinned to a fixed anchor near the page bottom, and if
// the last item row spilled past that anchor we advance to a
// fresh page before drawing them (see the desiredTotalsY check).
//
// We deliberately do NOT inflate the bottom margin here to
// "reserve" the totals zone on every page. That older approach
// shortened the usable area on EVERY page (not just the last),
// so a long invoice broke far too early — only a handful of
// line items rendered on page 1 with a large blank gap beneath.
// Worse, the inflated margin was set on the page active when the
// table started but restored on whichever page the table ended,
// leaving page 1 permanently short: the page-number stamp later
// landed below that page's phantom bottom margin and spawned a
// stray blank trailing page (which then desynced "Seite X von Y").
drawLineItems(doc, ctx);
// y after the table — used only to detect whether the items
// overflowed past the totals anchor below. We don't use it as
// the totals position directly because the totals block is
// pinned to a fixed offset from the page bottom regardless of
// how many items rendered.
y = doc.y;
// ---- pin totals + payment block to footer ---------------------
// The totals box + payment block ALWAYS render at the same
// distance from the page bottom regardless of how many line
// items rendered. Reserves below are conservative-but-tight:
// they reflect the actual measured block heights, with just
// enough breathing room that a wrapped line or extra Skonto
// row doesn't crash into the footer.
// FOOTER_RESERVE = 30 (one footer line ~12pt + ~18pt gap)
// PAYMENT_BLOCK_HEIGHT = 80 with paymentTerm, 50 without
// (header + 3-4 rows including the
// skonto + skonto_amount lines)
// TOTALS_BLOCK_HEIGHT = 90 (top divider + Net + Shipping +
// VAT + middle divider + Total)
const FOOTER_RESERVE = 30;
const PAYMENT_BLOCK_HEIGHT = ctx.paymentTerm ? 80 : 50;
let TOTALS_BLOCK_HEIGHT = 90;
// A free-text VAT note (#794) adds a wrapped row under the MwSt. line —
// grow the reserved totals height by its measured height so a long note
// can't push the grand total / payment block into the footer.
if (ctx.vatNote) {
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(8);
const noteWidth = PAGE.contentWidth - ((PAGE.contentWidth - 20) / 2 + 20);
TOTALS_BLOCK_HEIGHT += doc.heightOfString(ctx.vatNote, { width: noteWidth }) + 4;
doc.fontSize(10);
}
const desiredPaymentY = PAGE.height - PAGE.marginBottom - FOOTER_RESERVE - PAYMENT_BLOCK_HEIGHT;
const desiredTotalsY = desiredPaymentY - 12 - TOTALS_BLOCK_HEIGHT;
// If line items used more space than the totals anchor allows,
// advance to a new page before drawing totals — keeps the
// bottom block at a CONSTANT position from the footer on
// whatever page it lands on.
if (y > desiredTotalsY) {
doc.addPage();
}
// Always reset to the fixed anchor — independent of where the
// table ended on the page.
y = desiredTotalsY;
// ---- totals box (right-aligned) -------------------------------
y = drawTotals(doc, ctx, leftX, y, PAGE.contentWidth);
// ---- outro text -----------------------------------------------
if (ctx.doc.outroText) {
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10).fillColor('#000');
doc.text(ctx.doc.outroText, leftX, y, { width: PAGE.contentWidth });
y = doc.y + 12;
}
// ---- payment conditions + IBAN block --------------------------
// Pin the payment block to the fixed anchor too — the totals
// box can end short of it (e.g. when only Net + Total render
// with no shipping/VAT), so we snap back unconditionally.
// Suppressed on Stornorechnungen: a cancellation document is
// not a payment instrument — no Zahlungsbedingungen, no IBAN,
// no Skonto. Customers reading a Storno expect total clarity
// that this is the REVERSAL of an obligation, not a new one.
if (!isStorno) {
y = desiredPaymentY;
y = drawPaymentBlock(doc, ctx, leftX, y, PAGE.contentWidth);
}
// ---- folding marks (left edge) --------------------------------
drawFoldingMarks(doc, ctx.issuer?.foldingMarks);
// ---- footer ---------------------------------------------------
drawFooter(doc, ctx.issuer, ctx.locale);
// ---- payment QR on fresh page (invoices only) -----------------
// Two paths, mutually exclusive:
// - 'swiss' → SwissQRBill payment slip (CHF / EUR within CH/LI)
// - 'epc' → SEPA EPC069-12 QR code (EUR-only, every SEPA bank)
// Both append a fresh page; 'none' is a no-op.
// Suppressed on Stornorechnungen — negative-amount QR codes
// aren't a defined construct in either spec.
if (type === 'invoice' && !isStorno && !isMahnung) {
if (ctx.qrFormat === 'swiss') {
appendSwissQrBill(doc, ctx);
} else if (ctx.qrFormat === 'epc') {
await appendEpcQr(doc, ctx);
}
}
// ---- page numbers ("Page 1 of N" / "Seite 1 von N") -----------
// Stamped after everything else so we know the final page
// count. bufferPages: true (on the PDFDocument options above)
// keeps every page open for back-editing — bufferedPageRange()
// returns {start, count}. We switchToPage() each one, draw the
// pagination label in the bottom-right corner, then end.
try {
const range = doc.bufferedPageRange();
const total = range.count;
// Stamp on EVERY page including single-page documents. The
// "Page 1 of 1" label is a tamper-evidence cue for the
// recipient — if they receive page 1 of 3 in isolation,
// they know pages are missing; conversely "1 of 1" lets a
// single-page invoice confirm it's complete. The cost (one
// grey line in the bottom corner) is negligible.
for (let i = 0; i < total; i++) {
doc.switchToPage(range.start + i);
// Drop this page's bottom margin to 0 so writing the label INTO the
// margin band (below the content area the line-item table fills) can't
// trigger PDFKit's auto-page-break. Previously the label sat at
// marginBottom-12 — INSIDE the content area — so on a full multi-page
// invoice the table's last row overlapped the "Seite X von Y" stamp
// (#794). The page is already fully laid out (buffered), so zeroing the
// margin here is safe.
doc.page.margins.bottom = 0;
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(8).fillColor('#888');
const label = t(ctx.locale, 'page_of', {
current: i + 1,
total,
});
// Bottom-right corner, INSIDE the bottom margin (below the content
// edge the table fills), so a full continuation page's last row can't
// overlap it.
const labelY = doc.page.height - PAGE.marginBottom + 8;
const labelW = 120;
const labelX = doc.page.width - PAGE.marginRight - labelW;
doc.text(label, labelX, labelY, {
width: labelW, align: 'right', lineBreak: false,
});
doc.fillColor('#000');
}
} catch (err) {
const logger = require('../utils/logger');
logger.warn('Failed to stamp page numbers on PDF', { err: err.message });
}
doc.end();
} catch (err) {
reject(err);
}
})();
});
}
/**
* Normalise + default the context shape so the rest of the renderer
* can rely on it without optional-chaining everywhere.
*/
function normaliseContext(type, ctx) {
const locale = ctx.locale || 'de';
return {
type,
locale,
intlLocale: localeForIntl(locale, ctx.issuer?.countryCode),
currency: (ctx.currency || ctx.doc?.currency || ctx.issuer?.defaultCurrency || 'CHF').toUpperCase(),
issuer: ctx.issuer || {},
recipient: ctx.recipient || {},
bank: ctx.bank || null,
paymentTerm: ctx.paymentTerm || null,
lineItems: Array.isArray(ctx.lineItems) ? ctx.lineItems : [],
totals: ctx.totals || {},
doc: ctx.doc || {},
qrFormat: ctx.qrFormat || 'none',
// Free-text VAT/legal note printed under the MwSt. line on invoices (#794).
vatNote: (typeof ctx.vatNote === 'string' && ctx.vatNote.trim()) ? ctx.vatNote.trim() : null,
// Date-format config from the `general_date_format` app setting.
// Shape: `{ format: 'DD.MM.YYYY' | 'DD/MM/YYYY' | 'MM/DD/YYYY' |
// 'YYYY-MM-DD', locale?: string }`. The service layer hydrates
// this; defaults to DD.MM.YYYY when unset.
dateFormat: ctx.dateFormat || { format: 'DD.MM.YYYY' },
};
}
async function renderQuoteToBuffer(context) {
return renderDocument('quote', context);
}
async function renderInvoiceToBuffer(context) {
return renderDocument('invoice', context);
}
/**
* Render a contract PDF. `context` is the shape produced by
* contractService.buildRenderContext: { locale, issuer, recipient, doc,
* sections, signatures }. Returns Promise<Buffer>.
*
* Layout:
* - DIN 5008 envelope window (same as quotes/invoices) so the
* recipient address lines up with envelope windows.
* - Title from doc.title (admin-typed) or t('contract_title').
* - Contract number + issue date right-aligned under the issuer block.
* - intro_text paragraph.
* - For each section: bold heading from t('section_<key>'), then each
* block rendered as a paragraph (block.name bold, then block.body).
* - outro_text paragraph.
* - Two-column signature block at the bottom of the closing page.
* If signature PNGs exist in context.signatures.{customer,admin}.signaturePath
* they're stamped into the box; otherwise blank lines for handwritten
* wet-signing.
*/
function renderContractToBuffer(context) {
return new Promise((resolve, reject) => {
(async () => {
try {
const ctx = context || {};
const locale = ctx.locale || 'de';
const doc = new PDFDocument({
size: 'A4',
bufferPages: true,
margins: {
top: PAGE.marginTop, bottom: PAGE.marginBottom,
left: PAGE.marginLeft, right: PAGE.marginRight,
},
info: {
Title: `${ctx.doc?.contractNumber || 'Contract'}${ctx.recipient?.companyName ? '_' + ctx.recipient.companyName : ''}`,
Author: ctx.issuer?.companyName || 'picpeak',
},
});
const chunks = [];
doc.on('data', (c) => chunks.push(c));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
doc._fonts = { body: FONT_BODY, bold: FONT_BOLD };
const registered = registerCustomFonts(doc, ctx.issuer || {});
if (registered) doc._fonts = registered;
// ---- header: issuer + recipient blocks (DIN 5008) ------------
const issuerWidth = 180;
const issuerX = PAGE.width - PAGE.marginRight - issuerWidth;
const issuerY = PAGE.marginTop + 16;
const issuerEndY = drawIssuerBlock(doc, ctx.issuer || {}, issuerX, issuerY, issuerWidth, locale);
const recipientEndY = drawRecipientBlock(doc, ctx.recipient || {}, locale);
let y = Math.max(issuerEndY, recipientEndY, ADDR_WINDOW.top + ADDR_WINDOW.height) + 6;
// ---- contract number + date (right-aligned) ------------------
const docNumberForDisplay = ctx.doc?.contractNumber || '';
const numberLabel = t(locale, 'contract_number_label');
const dateLabel = t(locale, 'date');
const issueDateDisplay = formatDate(ctx.doc?.issueDate, locale);
const labelColumnWidth = 110;
const valueColumnWidth = 120;
const blockWidth = labelColumnWidth + valueColumnWidth;
const blockRightX = PAGE.width - PAGE.marginRight;
const blockLeftX = blockRightX - blockWidth;
doc.font(doc._fonts.body).fontSize(9).fillColor('#000');
// Number row
doc.text(numberLabel, blockLeftX, y, { width: labelColumnWidth, align: 'right' });
doc.font(doc._fonts.bold).text(
docNumberForDisplay,
blockLeftX + labelColumnWidth,
y,
{ width: valueColumnWidth, align: 'right' },
);
y += 14;
// Date row
doc.font(doc._fonts.body);
doc.text(dateLabel, blockLeftX, y, { width: labelColumnWidth, align: 'right' });
doc.text(
issueDateDisplay,
blockLeftX + labelColumnWidth,
y,
{ width: valueColumnWidth, align: 'right' },
);
y += 22;
// ---- title --------------------------------------------------
const title = ctx.doc?.title || t(locale, 'contract_title');
doc.font(doc._fonts.bold).fontSize(18).fillColor('#000');
doc.text(title, PAGE.marginLeft, y, { width: PAGE.contentWidth });
y = doc.y + 10;
// ---- helper: ensure space before drawing, paginate if needed.
const bottomLimit = PAGE.height - PAGE.marginBottom - 20;
function ensureSpace(needed) {
if (y + needed > bottomLimit) {
doc.addPage();
y = PAGE.marginTop;
}
}
// ---- helper: render body text with inline **bold** support.
// Splits on `**text**` markers, switches the font weight per
// chunk via PDFKit's continued: true text continuation. The
// first chunk anchors at (PAGE.marginLeft, y); subsequent
// chunks continue from PDFKit's cursor so wrapping works
// across font switches. After rendering, we read doc.y as
// the new cursor.
function renderBodyMarkdown(text, opts) {
const parts = String(text || '').split(/(\*\*[^*]+\*\*)/g).filter((p) => p.length > 0);
if (parts.length === 0) return;
const last = parts.length - 1;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
const isBold = part.length > 4 && part.startsWith('**') && part.endsWith('**');
const chunk = isBold ? part.slice(2, -2) : part;
if (!chunk) continue;
doc.font(isBold ? doc._fonts.bold : doc._fonts.body);
if (i === 0) {
doc.text(chunk, PAGE.marginLeft, y, { ...opts, continued: i < last });
} else {
doc.text(chunk, { ...opts, continued: i < last });
}
}
}
// ---- intro text ---------------------------------------------
if (ctx.doc?.introText) {
doc.font(doc._fonts.body).fontSize(10).fillColor('#000');
ensureSpace(40);
renderBodyMarkdown(ctx.doc.introText, { width: PAGE.contentWidth, align: 'left' });
y = doc.y + 12;
}
// ---- sections + blocks --------------------------------------
for (const sec of ctx.sections || []) {
if (!sec.blocks || sec.blocks.length === 0) continue;
ensureSpace(32);
doc.font(doc._fonts.bold).fontSize(13).fillColor('#000');
doc.text(t(locale, `section_${sec.section}`), PAGE.marginLeft, y, {
width: PAGE.contentWidth, align: 'left',
});
y = doc.y + 6;
// Thin separator under the section heading.
doc
.strokeColor('#888')
.lineWidth(0.5)
.moveTo(PAGE.marginLeft, y)
.lineTo(PAGE.marginLeft + PAGE.contentWidth, y)
.stroke();
y += 8;
for (const block of sec.blocks) {
ensureSpace(48);
if (block.name) {
doc.font(doc._fonts.bold).fontSize(10).fillColor('#000');
doc.text(String(block.name), PAGE.marginLeft, y, {
width: PAGE.contentWidth, align: 'left',
});
y = doc.y + 4;
}
doc.font(doc._fonts.body).fontSize(10).fillColor('#000');
renderBodyMarkdown(block.body, { width: PAGE.contentWidth, align: 'left' });
y = doc.y + 10;
// If text rendering pushed past page bottom, PDFKit
// auto-paginated — sync y to the new doc.y for the next
// block.
if (doc.y < y) y = doc.y;
// Special-case: when the block is the
// `quote_line_items_table` system block AND the contract
// was generated from a quote, draw a real formatted line-
// items table immediately after the body text. Columns
// mirror drawLineItems (#, Qty, Description, Unit, Total)
// but inlined here because the contract document has no
// `lineItems` ctx the standalone helper expects.
if (
block.slug === 'quote_line_items_table'
&& ctx.quoteLineItems
&& ctx.quoteLineItems.length > 0
) {
const currency = (ctx.quoteCurrency || 'CHF').toUpperCase();
// Column widths sum to PAGE.contentWidth (515.28). Same
// shape as drawLineItems' no-discount variant. The desc
// column is widest; numeric columns stay narrow + right-
// aligned.
const widths = [30, 275, 55, 70, 85];
const colX = [PAGE.marginLeft];
for (let i = 1; i < widths.length; i++) colX[i] = colX[i - 1] + widths[i - 1];
const headers = [
t(locale, 'table_pos'),
t(locale, 'table_description'),
t(locale, 'table_qty'),
t(locale, 'table_unit_price'),
t(locale, 'table_line_total'),
];
const headerAligns = ['left', 'left', 'right', 'right', 'right'];
const ROW_MIN_HEIGHT = 18;
const PAD_X = 4;
ensureSpace(ROW_MIN_HEIGHT + 4);
// Header row — bold + bottom border.
doc.font(doc._fonts.bold).fontSize(10).fillColor('#000');
const headerStartY = y;
let headerMaxBottom = y;
for (let i = 0; i < headers.length; i++) {
doc.text(headers[i], colX[i] + PAD_X, y + 3, {
width: widths[i] - PAD_X * 2,
align: headerAligns[i],
});
if (doc.y > headerMaxBottom) headerMaxBottom = doc.y;
}
const headerBottom = Math.max(headerMaxBottom, headerStartY + ROW_MIN_HEIGHT);
doc.strokeColor('#000').lineWidth(1)
.moveTo(PAGE.marginLeft, headerBottom)
.lineTo(PAGE.marginLeft + PAGE.contentWidth, headerBottom)
.stroke();
y = headerBottom + 1;
// Data rows. Sub-items (parent_position != null) render
// with a "↳ " prefix + 8pt indent in the description
// column and an empty position column. Numeric values
// come from minor-unit BigInts via formatMinor.
doc.font(doc._fonts.body).fontSize(10).fillColor('#000');
let topLevelCount = 0;
for (const li of ctx.quoteLineItems) {
const isSub = li.parent_position != null;
const posLabel = isSub ? '' : String(++topLevelCount);
const descPrefix = isSub ? '\u21B3 ' : '';
const descIndent = isSub ? 8 : 0;
const qtyText = (() => {
const q = Number(li.quantity || 0);
return Number.isInteger(q) ? String(q) : String(q);
})();
const unitText = formatMinor(li.unit_price_minor, currency, 'de-CH');
const lineTotalText = formatMinor(li.line_total_minor, currency, 'de-CH');
const cells = [
{ text: posLabel, width: widths[0], align: 'left', x: colX[0] },
{ text: `${descPrefix}${li.description || ''}`, width: widths[1] - descIndent, align: 'left', x: colX[1] + descIndent },
{ text: qtyText, width: widths[2], align: 'right', x: colX[2] },
{ text: unitText, width: widths[3], align: 'right', x: colX[3] },
{ text: lineTotalText, width: widths[4], align: 'right', x: colX[4] },
];
// Measure tallest cell so the row's bottom is the max
// of all column heights + a minimum row height.
ensureSpace(ROW_MIN_HEIGHT + 2);
const rowStartY = y;
let rowMaxBottom = y;
for (const c of cells) {
doc.text(c.text, c.x + PAD_X, y + 3, {
width: c.width - PAD_X * 2,
align: c.align,
});
if (doc.y > rowMaxBottom) rowMaxBottom = doc.y;
}
const rowBottom = Math.max(rowMaxBottom, rowStartY + ROW_MIN_HEIGHT);
// Thin grey divider under each row.
doc.strokeColor('#cccccc').lineWidth(0.5)
.moveTo(PAGE.marginLeft, rowBottom)
.lineTo(PAGE.marginLeft + PAGE.contentWidth, rowBottom)
.stroke();
y = rowBottom + 1;
}
y += 10;
doc.y = y;
doc.fillColor('#000');
}
}
y += 6;
}
// ---- outro text ---------------------------------------------
if (ctx.doc?.outroText) {
ensureSpace(40);
doc.font(doc._fonts.body).fontSize(10).fillColor('#000');
renderBodyMarkdown(ctx.doc.outroText, { width: PAGE.contentWidth, align: 'left' });
y = doc.y + 16;
}
// ---- signature page (dedicated final page, fixed layout) ----
// The unsigned PDF ALWAYS contains an empty signature page at
// the end, with both signature boxes at FIXED coordinates
// (see CONTRACT_SIGNATURE_LAYOUT below). pdfStampService.js
// uses those same coordinates to overlay signature PNGs with
// pdf-lib AFTER the unsigned PDF is rendered — no re-render
// needed at signing time. This is the same model DocuSign /
// Adobe Sign use: the original is byte-immutable; signatures
// are appended as overlays.
//
// Audit data (timestamps, IPs, hashes) is rendered as a
// SEPARATE "audit certificate" PDF by pdfStampService — not
// embedded here — so the contract PDF stays purely
// representational and the audit trail is a sibling document
// that can be verified independently.
doc.addPage();
const L = CONTRACT_SIGNATURE_LAYOUT;
// Title row
doc.font(doc._fonts.bold).fontSize(16).fillColor('#000');
doc.text(t(locale, 'signature_page_title'), PAGE.marginLeft, L.titleY, {
width: PAGE.contentWidth, align: 'left',
});
doc.strokeColor('#888').lineWidth(0.5)
.moveTo(PAGE.marginLeft, L.titleY + 22)
.lineTo(PAGE.marginLeft + PAGE.contentWidth, L.titleY + 22)
.stroke();
// Closing prompt — generic line so unsigned doc reads coherently
doc.font(doc._fonts.body).fontSize(10).fillColor('#000');
doc.text(t(locale, 'signature_page_prompt'), PAGE.marginLeft, L.promptY, {
width: PAGE.contentWidth, align: 'left',
});
// Two empty signature boxes — customer on the left, admin on
// the right. drawn at fixed coordinates so the stamp service
// can find them later by constant rather than runtime layout.
function drawEmptySignaturePane(x, label, info) {
doc.font(doc._fonts.bold).fontSize(10).fillColor('#000');
doc.text(label, x, L.paneLabelY, { width: L.boxWidth });
doc.strokeColor('#cccccc').lineWidth(0.5)
.rect(x, L.boxY, L.boxWidth, L.boxHeight)
.stroke();
// Caption labels — name + date placeholders that the
// stamp service overwrites with the actual values when
// the signature is applied. The unsigned PDF shows these
// as empty labels.
const captionY = L.boxY + L.boxHeight + 6;
doc.font(doc._fonts.body).fontSize(9).fillColor('#000');
doc.text(
`${t(locale, 'signed_label_name')}: ${info?.name || ''}`,
x, captionY, { width: L.boxWidth },
);
doc.text(
`${t(locale, 'signed_label_date')}: ${info?.signedAt ? formatDate(info.signedAt, locale) : ''}`,
x, captionY + 12, { width: L.boxWidth },
);
}
drawEmptySignaturePane(L.customerX, t(locale, 'signature_customer'), ctx.signatures?.customer);
drawEmptySignaturePane(L.adminX, t(locale, 'signature_admin'), ctx.signatures?.admin);
// ---- page numbers ("Page 1 of N" / "Seite 1 von N") ----------
// Same stamp the quote/invoice renderer uses (line 1680 above).
// bufferPages:true keeps every page open for switchToPage; we
// walk the range after all content is drawn so we know N.
try {
const range = doc.bufferedPageRange();
const total = range.count;
for (let i = 0; i < total; i++) {
doc.switchToPage(range.start + i);
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(8).fillColor('#888');
const label = t(locale, 'page_of', { current: i + 1, total });
const labelY = doc.page.height - PAGE.marginBottom - 12;
const labelW = 120;
const labelX = doc.page.width - PAGE.marginRight - labelW;
doc.text(label, labelX, labelY, {
width: labelW, align: 'right', lineBreak: false,
});
doc.fillColor('#000');
}
} catch (err) {
const logger = require('../utils/logger');
logger.warn('Failed to stamp page numbers on contract PDF', { err: err.message });
}
doc.end();
} catch (err) {
reject(err);
}
})();
});
}
module.exports = {
renderQuoteToBuffer,
renderInvoiceToBuffer,
renderContractToBuffer,
// Building blocks shared with other PDF features (tax report etc.) —
// they all run through createBaseDocument so the font + orientation
// story stays consistent.
createBaseDocument,
getPageMetrics,
drawIssuerBlock,
// Shared with pdfStampService — the same coordinates the unsigned
// render uses to draw empty signature boxes are used to overlay
// signature PNGs at stamping time. Single source of truth.
CONTRACT_SIGNATURE_LAYOUT,
PAGE,
FONT_BODY,
FONT_BOLD,
// Exposed for unit tests + advanced callers.
_internal: { formatMinor, formatDate, t, registerCustomFonts },
};