From 1476884dd04202f5f18d50d458b6176b0535c71b Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Sat, 11 Jul 2026 02:01:20 +0200 Subject: [PATCH] feat(invoices): configurable VAT/free-text note + fix multi-page page-number overlap (#794) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../services/pdfService.baseDocument.test.js | 63 +++++++++++++++++++ backend/src/services/invoice/render.js | 10 +++ backend/src/services/pdfService.js | 40 ++++++++++-- frontend/src/i18n/locales/de.json | 5 ++ frontend/src/i18n/locales/en.json | 5 ++ .../pages/admin/settings/CrmSettingsPage.tsx | 21 +++++++ 6 files changed, 140 insertions(+), 4 deletions(-) diff --git a/backend/__tests__/services/pdfService.baseDocument.test.js b/backend/__tests__/services/pdfService.baseDocument.test.js index e4ba52de..2a260b73 100644 --- a/backend/__tests__/services/pdfService.baseDocument.test.js +++ b/backend/__tests__/services/pdfService.baseDocument.test.js @@ -234,3 +234,66 @@ describe('renderInvoiceToBuffer — Storno branch', () => { 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 2–3 pages; a stray blank page (the old margin + // bug) or a runaway loop would blow past this. + expect(pages).toBeLessThanOrEqual(3); + }); +}); diff --git a/backend/src/services/invoice/render.js b/backend/src/services/invoice/render.js index b397fd04..40d67658 100644 --- a/backend/src/services/invoice/render.js +++ b/backend/src/services/invoice/render.js @@ -174,6 +174,14 @@ async function buildInvoiceRenderContext(invoice, lineItems) { ? 0 : ensureInt(invoice.net_amount_minor) - displayedNetMinor; + // Optional free-text VAT / legal note printed directly under the MwSt. line + // on the invoice PDF (#794). Configured globally in Settings → CRM → Invoices. + // Data-driven: the admin types the exact wording (e.g. the Austrian + // Kleinunternehmer statement, § 6 Abs. 1 Z 27 UStG 1994), so no jurisdiction + // is hardcoded. Empty/whitespace → null (row omitted). + const vatNoteRaw = await getAppSetting('crm_invoices_vat_note_text'); + const vatNote = typeof vatNoteRaw === 'string' && vatNoteRaw.trim() ? vatNoteRaw.trim() : null; + return { locale: invoice.language || profile?.default_locale || 'de', currency: invoice.currency, @@ -189,6 +197,8 @@ async function buildInvoiceRenderContext(invoice, lineItems) { iban: bank.iban, bic: bank.bic, currency: bank.currency, } : null, paymentTerm, + // Free-text VAT/legal note (#794) — rendered under the MwSt. line by drawTotals. + vatNote, lineItems: lineItems.map((li) => ({ quantity: li.quantity, description: li.description, diff --git a/backend/src/services/pdfService.js b/backend/src/services/pdfService.js index d55c198e..74257691 100644 --- a/backend/src/services/pdfService.js +++ b/backend/src/services/pdfService.js @@ -851,6 +851,18 @@ function drawTotals(doc, ctx, x, y, width) { 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" @@ -1670,7 +1682,16 @@ function renderDocument(type, context) { // VAT + middle divider + Total) const FOOTER_RESERVE = 30; const PAYMENT_BLOCK_HEIGHT = ctx.paymentTerm ? 80 : 50; - const TOTALS_BLOCK_HEIGHT = 90; + 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; @@ -1746,14 +1767,23 @@ function renderDocument(type, context) { // 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, just above the bottom margin so - // it doesn't trigger PDFKit's auto-paging. - const labelY = doc.page.height - PAGE.marginBottom - 12; + // 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, { @@ -1793,6 +1823,8 @@ function normaliseContext(type, ctx) { 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 diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index e5e81465..95ab5f10 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -5233,6 +5233,11 @@ "crm_invoices_late_fee_label": { "label": "Bezeichnung Mahngebühr" }, + "crm_invoices_vat_note_text": { + "label": "MwSt.- / Freitext-Hinweis auf Rechnungen", + "placeholder": "z. B. Gemäß § 6 Abs. 1 Z 27 UStG 1994 wird keine Umsatzsteuer berechnet (Kleinunternehmer).", + "help": "Wird direkt unter der MwSt.-Zeile auf jeder Rechnungs-PDF gedruckt. Leer lassen zum Ausblenden. Bitte den genauen Wortlaut mit deinem Steuerberater abstimmen." + }, "crm_invoices_skonto_percent_default": { "label": "Standard-Skonto %" }, diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 226490ab..8402084f 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -5231,6 +5231,11 @@ "crm_invoices_late_fee_label": { "label": "Late fee label" }, + "crm_invoices_vat_note_text": { + "label": "VAT / free-text note on invoices", + "placeholder": "e.g. Gemäß § 6 Abs. 1 Z 27 UStG 1994 wird keine Umsatzsteuer berechnet (Kleinunternehmer).", + "help": "Printed directly under the MwSt. line on every invoice PDF. Leave empty to hide. Please confirm the exact wording with your tax advisor." + }, "crm_invoices_skonto_percent_default": { "label": "Skonto rate (default %)" }, diff --git a/frontend/src/pages/admin/settings/CrmSettingsPage.tsx b/frontend/src/pages/admin/settings/CrmSettingsPage.tsx index 01d19c55..98d73b1c 100644 --- a/frontend/src/pages/admin/settings/CrmSettingsPage.tsx +++ b/frontend/src/pages/admin/settings/CrmSettingsPage.tsx @@ -29,6 +29,8 @@ const SETTING_KEYS = [ 'crm_quotes_tos_url', 'crm_invoices_qr_enabled', 'crm_invoice_round_total', + // Free-text VAT/legal note printed under the MwSt. line on invoice PDFs (#794). + 'crm_invoices_vat_note_text', 'crm_invoices_reminders_enabled', 'crm_invoices_reminder_first_days', 'crm_invoices_reminder_second_days', @@ -249,6 +251,25 @@ export const CrmSettingsPage: React.FC = () => { {checkbox('crm_invoices_qr_enabled', 'Render payment QR on invoice PDFs')} {checkbox('crm_invoice_round_total', 'Reconcile sub-cent rounding to a clean total (adds a "Rundung" row when per-line rounding drifts from qty × rate)')} + {/* Free-text VAT / legal note (#794) — printed directly under the MwSt. + line on every invoice PDF. Data-driven: the admin types the exact + wording (Austrian Kleinunternehmer, German §19, reverse-charge, …). */} +
+ +