Files
picpeak/backend/__tests__/services/pdfService.baseDocument.test.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

300 lines
12 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.
/**
* Tests for the createBaseDocument + getPageMetrics helpers — the
* shared PDF factory used by quote/invoice rendering AND by the
* upcoming tax-report renderer. These verify orientation handling
* and font defaults without touching DB or filesystem.
*/
const pdfService = require('../../src/services/pdfService');
describe('getPageMetrics', () => {
it('returns portrait A4 metrics by default', () => {
const p = pdfService.getPageMetrics();
expect(p.width).toBeCloseTo(595.28, 1);
expect(p.height).toBeCloseTo(841.89, 1);
expect(p.contentWidth).toBeCloseTo(515.28, 1);
});
it('returns portrait when orientation is "portrait"', () => {
const p = pdfService.getPageMetrics('portrait');
expect(p.width).toBeLessThan(p.height);
});
it('returns landscape A4 metrics (width > height) when orientation is "landscape"', () => {
const p = pdfService.getPageMetrics('landscape');
expect(p.width).toBeCloseTo(841.89, 1);
expect(p.height).toBeCloseTo(595.28, 1);
expect(p.contentWidth).toBeCloseTo(761.89, 1);
expect(p.width).toBeGreaterThan(p.height);
});
it('ignores unknown orientation values (falls back to portrait)', () => {
const p = pdfService.getPageMetrics('upside-down');
expect(p.width).toBeLessThan(p.height);
});
});
describe('createBaseDocument', () => {
it('returns a PDFKit doc, page metrics, and logical font names by default', () => {
const { doc, page, fonts } = pdfService.createBaseDocument();
expect(doc).toBeDefined();
expect(typeof doc.on).toBe('function');
expect(typeof doc.font).toBe('function');
expect(page.width).toBeCloseTo(595.28, 1); // portrait by default
expect(fonts).toEqual({ body: 'Helvetica', bold: 'Helvetica-Bold' });
});
it('produces a landscape document when orientation is "landscape"', () => {
const { doc, page } = pdfService.createBaseDocument({ orientation: 'landscape' });
expect(page.width).toBeGreaterThan(page.height);
// PDFKit stores the active page dims on doc.page.
expect(doc.page.width).toBeCloseTo(841.89, 1);
expect(doc.page.height).toBeCloseTo(595.28, 1);
});
it('produces a buffered PDF of non-zero size with the PDF magic header', async () => {
const { doc } = pdfService.createBaseDocument({ orientation: 'landscape' });
const chunks = [];
doc.on('data', (c) => chunks.push(c));
const ended = new Promise((resolve) => doc.on('end', resolve));
doc.text('hello', 40, 40);
doc.end();
await ended;
const buf = Buffer.concat(chunks);
expect(buf.length).toBeGreaterThan(0);
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
});
it('keeps Helvetica fonts when the issuer has no custom TTF path', () => {
const { fonts } = pdfService.createBaseDocument({
issuer: { pdfFontTtfPath: null },
});
expect(fonts.body).toBe('Helvetica');
expect(fonts.bold).toBe('Helvetica-Bold');
});
it('falls back to Helvetica when the custom TTF path does not exist', () => {
// No exception, no logger.error blow-up — just silent fallback.
const { fonts } = pdfService.createBaseDocument({
issuer: { pdfFontTtfPath: '/nonexistent/path/font.ttf' },
});
expect(fonts.body).toBe('Helvetica');
expect(fonts.bold).toBe('Helvetica-Bold');
});
it('registers a bundled font family when pdfFontFamily is set', () => {
// Migration-121 dropdown path. Inter ships 400 + 600 + 700 under
// backend/assets/fonts/Inter/, so the resolver should pick 400
// for body and 700 for bold.
const { fonts } = pdfService.createBaseDocument({
issuer: { pdfFontFamily: 'Inter' },
});
expect(fonts.body).toBe('crm-body');
expect(fonts.bold).toBe('crm-bold');
});
it('falls back to Helvetica when pdfFontFamily names a non-existent directory', () => {
const { fonts } = pdfService.createBaseDocument({
issuer: { pdfFontFamily: 'NotARealFamily' },
});
expect(fonts.body).toBe('Helvetica');
expect(fonts.bold).toBe('Helvetica-Bold');
});
it('strips path-traversal characters from pdfFontFamily', () => {
// Defence in depth: the sanitiser keeps only [A-Za-z0-9_-].
// "../../etc/passwd" becomes "etcpasswd" → no such font dir → fallback.
const { fonts } = pdfService.createBaseDocument({
issuer: { pdfFontFamily: '../../etc/passwd' },
});
expect(fonts.body).toBe('Helvetica');
expect(fonts.bold).toBe('Helvetica-Bold');
});
it('prefers pdfFontTtfPath over pdfFontFamily when both are set', () => {
// The explicit upload is the priority-1 override. When the upload
// path is unusable (file missing) the family is consulted next.
// Here we set BOTH to invalid values and confirm Helvetica fallback
// — what matters is that the family DIDN'T get registered while a
// (failed) explicit path was being evaluated.
const { fonts } = pdfService.createBaseDocument({
issuer: {
pdfFontTtfPath: '/nonexistent/path/font.ttf',
pdfFontFamily: 'Inter',
},
});
// pdfFontTtfPath misses → falls through to pdfFontFamily → Inter
// registers successfully. crm-body / crm-bold confirm a custom
// font won.
expect(fonts.body).toBe('crm-body');
expect(fonts.bold).toBe('crm-bold');
});
it('forwards PDF info metadata (Title, Author) to the document', () => {
const { doc } = pdfService.createBaseDocument({
info: { Title: 'Tax Report 2026', Author: 'picpeak' },
});
// PDFKit copies these onto doc.info during construction.
expect(doc.info.Title).toBe('Tax Report 2026');
expect(doc.info.Author).toBe('picpeak');
});
});
describe('exported letterhead helper', () => {
it('exposes drawIssuerBlock for reuse by non-quote/invoice renderers', () => {
expect(typeof pdfService.drawIssuerBlock).toBe('function');
});
});
// Storno rendering — smoke-tests that exercise the kind='storno'
// branch in renderInvoiceToBuffer. We can't search the PDF buffer
// directly for German strings because PDFKit Flate-compresses
// content streams, but we CAN verify the renderer:
// - completes without throwing on a Storno-shaped context,
// - produces a valid %PDF magic header,
// - produces a SMALLER document than its invoice counterpart
// (no payment block, no QR slip → fewer bytes), proving the
// suppression branches actually fire.
//
// Visual correctness (title swap, reference line, signed totals) is
// validated by manual review of a real Storno PDF; the renderer's
// branch logic is unit-tested in service tests where the inputs
// can be asserted directly.
describe('renderInvoiceToBuffer — Storno branch', () => {
function buildContext(overrides = {}) {
return {
locale: 'de',
currency: 'CHF',
issuer: { companyName: 'AcmeCo' },
recipient: {
companyName: 'KundenCo', addressLine1: 'Strasse 1',
city: 'Bern', postalCode: '3000',
},
lineItems: [{
quantity: 1, description: 'Photo session',
unitPriceMinor: 30000, lineTotalMinor: 30000,
parentLineItemId: null, parentPosition: null,
}],
totals: {
netAmountMinor: 30000, vatRate: 7.7, vatAmountMinor: 2310,
shippingAmountMinor: 0, totalAmountMinor: 32310,
},
doc: { invoiceNumber: 'R-2026-0042', issueDate: '2026-04-12' },
// Bank + payment term are part of the baseline invoice so the
// payment block renders a real IBAN + Zahlungsbedingungen
// section. The Storno branch suppresses this entirely, which
// produces a visible byte-size delta.
bank: {
accountHolder: 'AcmeCo',
iban: 'CH9300762011623852957',
bic: 'POFICHBE',
currency: 'CHF',
},
qrFormat: 'none',
paymentTerm: { netDays: 30, skontoPercent: 2, skontoWithinDays: 10 },
...overrides,
};
}
it('renders a valid Storno PDF (kind="storno", negated totals)', async () => {
const buf = await pdfService.renderInvoiceToBuffer(buildContext({
totals: {
netAmountMinor: -30000, vatRate: 7.7, vatAmountMinor: -2310,
shippingAmountMinor: 0, totalAmountMinor: -32310,
},
doc: {
kind: 'storno',
invoiceNumber: 'R-2026-0080',
issueDate: '2026-05-15',
cancelsInvoice: { number: 'R-2026-0042', issueDate: '2026-04-12' },
},
}));
expect(buf.length).toBeGreaterThan(0);
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
});
it('produces a smaller PDF than the equivalent invoice (no payment block, no QR slip)', async () => {
// Baseline: normal invoice with a payment block.
const invoiceBuf = await pdfService.renderInvoiceToBuffer(buildContext());
// Storno: same context but kind='storno' → payment block + QR
// both suppressed. Payment block alone is ~80pt tall in the
// PDF; its absence is reliably detectable as a byte-size delta.
const stornoBuf = await pdfService.renderInvoiceToBuffer(buildContext({
totals: {
netAmountMinor: -30000, vatRate: 7.7, vatAmountMinor: -2310,
shippingAmountMinor: 0, totalAmountMinor: -32310,
},
doc: {
kind: 'storno',
invoiceNumber: 'R-2026-0080',
issueDate: '2026-05-15',
cancelsInvoice: { number: 'R-2026-0042', issueDate: '2026-04-12' },
},
}));
expect(stornoBuf.length).toBeGreaterThan(0);
expect(stornoBuf.length).toBeLessThan(invoiceBuf.length);
});
});
// VAT free-text note (#794) + multi-page page-number placement. Same
// constraint as the Storno tests: PDFKit Flate-compresses content streams,
// so we can't grep the note text — but the page-TREE objects are NOT
// compressed, so `/Type /Page` (not `/Pages`) is countable to assert
// pagination, and a byte-size delta proves the note actually rendered.
describe('renderInvoiceToBuffer — VAT note + multi-page footer (#794)', () => {
function baseCtx(overrides = {}) {
return {
locale: 'de', currency: 'CHF',
issuer: { companyName: 'AcmeCo' },
recipient: {
companyName: 'KundenCo', addressLine1: 'Strasse 1',
city: 'Bern', postalCode: '3000',
},
lineItems: [{
quantity: 1, description: 'Photo session',
unitPriceMinor: 30000, lineTotalMinor: 30000,
parentLineItemId: null, parentPosition: null,
}],
totals: {
netAmountMinor: 30000, vatRate: 0, vatAmountMinor: 0,
shippingAmountMinor: 0, totalAmountMinor: 30000,
},
doc: { invoiceNumber: 'R-2026-0042', issueDate: '2026-04-12' },
qrFormat: 'none',
paymentTerm: { netDays: 30 },
...overrides,
};
}
const pageCount = (buf) => (buf.toString('latin1').match(/\/Type\s*\/Page(?![s])/g) || []).length;
const VAT_NOTE = 'Gemäß § 6 Abs. 1 Z 27 UStG 1994 wird keine Umsatzsteuer berechnet (Kleinunternehmer).';
it('renders the VAT note on a single-page invoice (adds content, valid PDF)', async () => {
const withNote = await pdfService.renderInvoiceToBuffer(baseCtx({ vatNote: VAT_NOTE }));
const without = await pdfService.renderInvoiceToBuffer(baseCtx());
expect(withNote.slice(0, 4).toString('ascii')).toBe('%PDF');
expect(pageCount(withNote)).toBe(1);
expect(withNote.length).toBeGreaterThan(without.length);
});
it('paginates a long invoice (with the note) across multiple pages without a stray blank page', async () => {
const manyItems = Array.from({ length: 60 }, (_, i) => ({
quantity: 1, description: `Position ${i + 1} — fotografische Leistung`,
unitPriceMinor: 3225, lineTotalMinor: 3225,
parentLineItemId: null, parentPosition: null,
}));
const buf = await pdfService.renderInvoiceToBuffer(baseCtx({
lineItems: manyItems,
totals: {
netAmountMinor: 193500, vatRate: 0, vatAmountMinor: 0,
shippingAmountMinor: 0, totalAmountMinor: 193500,
},
vatNote: VAT_NOTE,
}));
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
const pages = pageCount(buf);
expect(pages).toBeGreaterThanOrEqual(2);
// 60 short rows fit in 23 pages; a stray blank page (the old margin
// bug) or a runaway loop would blow past this.
expect(pages).toBeLessThanOrEqual(3);
});
});