From 2205b0bd687587ff2b54fbddb4770bf69a5ef3b2 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:49:50 +0200 Subject: [PATCH 1/3] fix(pdf): correct multi-page invoice/quote layout + drop IBAN dup under Swiss QR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before drawing the line-items table, the renderer inflated page 1's bottom margin to reserve room for the bottom-pinned totals block, but the `finally` restored it on whichever page the table *ended* on — leaving page 1 permanently short on any multi-page document. On long invoices and quotes this caused: - the table to break far too early (only ~6 items on page 1, large blank gap beneath) - the page-number stamp to land below page 1's phantom bottom margin, auto-paginating a stray blank trailing page and desyncing the "Seite X von Y" labels (page 1 unnumbered, the blank page labelled "Seite 1 von N") Let the table paginate with the document's normal margins so each page fills to the bottom; the existing desiredTotalsY check already advances to a fresh page when the last item row would collide with the pinned totals block. Also suppress the IBAN block under the totals when a Swiss QR-bill slip is appended: the slip already prints the account/IBAN in human-readable form, so it was pure duplication. The EPC QR path keeps the block (its QR lives on a trailing page, so on-page bank details still help). --- backend/src/services/pdfService.js | 51 ++++++++++++++++-------------- 1 file changed, 27 insertions(+), 24 deletions(-) diff --git a/backend/src/services/pdfService.js b/backend/src/services/pdfService.js index c12d607b..613f386f 100644 --- a/backend/src/services/pdfService.js +++ b/backend/src/services/pdfService.js @@ -916,7 +916,14 @@ function drawPaymentBlock(doc, ctx, x, y, width) { const showSkontoHere = isQuote ? (issuer?.quoteShowSkonto !== false) : reminderLevel === 0; - const showIbanHere = !isQuote; + // 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 @@ -1610,29 +1617,25 @@ function renderDocument(type, context) { doc.y = y; doc.x = leftX; - // Force the items table to auto-paginate BEFORE it can collide - // with the totals + payment block at the page bottom. We - // compute the same anchor as below, then temporarily inflate - // the page's bottom margin so swissqrbill's Table sees a - // shorter usable area and breaks to a new page when items - // would otherwise spill into the totals zone. The header row - // is already marked `header: true` so it auto-repeats on the - // continuation page. - const _origBottomMargin = doc.page.margins.bottom; - const _itemsBottomReserve = PAGE.marginBottom - + 30 // FOOTER_RESERVE - + (ctx.paymentTerm ? 80 : 50) // PAYMENT_BLOCK_HEIGHT - + 12 // gap between totals + payment - + 90 // TOTALS_BLOCK_HEIGHT - + 20; // small breathing room - doc.page.margins.bottom = _itemsBottomReserve; - try { - drawLineItems(doc, ctx); - } finally { - // Restore even if drawLineItems threw — keeps subsequent - // pages on the document's normal margin geometry. - doc.page.margins.bottom = _origBottomMargin; - } + // 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 From 4670292139bf4c5ad523646f38b98b9d47ab3430 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Sun, 28 Jun 2026 15:53:44 +0200 Subject: [PATCH 2/3] feat(invoices): optional sub-cent rounding reconciliation ("Rundung" row) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-line totals are each rounded to the cent before the net is summed, so a long time-based invoice can drift a few Rappen from qty × rate — e.g. 68 h × 32.25 = 2193.00, but the 21 rounded line totals sum to 2193.02. This is the standard "sum of rounded lines" convention (Stripe/QuickBooks/Xero do the same) and it foots, but some issuers want the total to match the customer's arithmetic. New per-issuer setting `crm_invoice_round_total` (default OFF, no migration — read via getAppSetting with a false default). When on, the create paths store the full-precision net rounded ONCE (cleanNetMinor), and the drift is shown to the reader as an explicit "Rundung" row: Betrag Netto 2'193.02 (= Σ visible line totals, still foots) Rundung -0.02 Gesamtbetrag 2'193.00 - New util src/utils/invoiceRounding.js (cleanNetMinor) mirrors the migration-119 hierarchy (priced sub-items override their parent) but sums at full precision; rate-agnostic, so mixed hourly rates reconcile to one clean net. Single document-level VAT rate ⇒ one Rundung row. - computeTotals (quotes) + createInvoice + payload-preview gain the toggle. - Render contexts derive the row as storedNet − Σ(line totals); legacy/off documents have equal values ⇒ adjustment 0 ⇒ byte-identical output. Suppressed on Storno/Mahnung (negated net + sign-flipped lines). - Storno/tax-report stay correct: both use the stored net scalar, which is the clean value (createStorno negates net_amount_minor; it never re-sums). - pdf-i18n: totals_rounding in all 6 locales (de/en/fr confident; nl/pt/ru machine-translated — flag for native review). - Frontend: toggle on Settings → CRM (Invoices), default off. Tests: backend/__tests__/utils/invoiceRounding.test.js (real 68h invoice, mixed rates, discounts, sub-item hierarchy, no-op case). --- .../__tests__/utils/invoiceRounding.test.js | 66 +++++++++++++ backend/src/services/invoiceService.js | 38 +++++++- backend/src/services/pdf-i18n.js | 6 ++ backend/src/services/pdfService.js | 12 +++ backend/src/services/quoteService.js | 45 ++++++++- backend/src/utils/invoiceRounding.js | 95 +++++++++++++++++++ frontend/src/i18n/locales/de.json | 3 + frontend/src/i18n/locales/en.json | 3 + .../pages/admin/settings/CrmSettingsPage.tsx | 2 + 9 files changed, 264 insertions(+), 6 deletions(-) create mode 100644 backend/__tests__/utils/invoiceRounding.test.js create mode 100644 backend/src/utils/invoiceRounding.js diff --git a/backend/__tests__/utils/invoiceRounding.test.js b/backend/__tests__/utils/invoiceRounding.test.js new file mode 100644 index 00000000..44d77d42 --- /dev/null +++ b/backend/__tests__/utils/invoiceRounding.test.js @@ -0,0 +1,66 @@ +const { cleanNetMinor, exactLineMinor } = require('../../src/utils/invoiceRounding'); + +// Sum the per-line ROUNDED totals the way computeTotals / createInvoice do, +// so each test can compare "sum of rounded lines" against cleanNetMinor. +function roundedNet(items, parentKey = 'parent_position') { + return items + .filter((li) => li[parentKey] == null || li[parentKey] === '') + .reduce((s, li) => s + Math.round(li.line_total_minor), 0); +} + +function mkLine(position, quantity, unitPriceMinor, extra = {}) { + const discount = extra.discount_percent || 0; + return { + position, + quantity, + unit_price_minor: unitPriceMinor, + discount_percent: discount, + line_total_minor: Math.round(Math.round(quantity * unitPriceMinor) * (1 - discount / 100)), + parent_position: extra.parent_position ?? null, + }; +} + +describe('cleanNetMinor — sub-cent reconciliation', () => { + it('reconciles the real 68h × 32.25 invoice (sum-of-lines 2193.02 → clean 2193.00)', () => { + const qtys = [5.25, 3.25, 5.25, 2.75, 2, 1, 1.75, 5, 5.25, 5.25, 2.75, + 4.5, 3.5, 2.5, 4.5, 2, 1.75, 3.25, 1.75, 3.5, 1.25]; + const items = qtys.map((q, i) => mkLine(i + 1, q, 3225)); + expect(roundedNet(items)).toBe(219302); // sum of the 21 rounded lines + expect(cleanNetMinor(items)).toBe(219300); // full-precision, rounded once + expect(cleanNetMinor(items) - roundedNet(items)).toBe(-2); // the -0.02 drift + }); + + it('is a no-op when every line is already cent-exact (adjustment 0)', () => { + const items = [mkLine(1, 2, 5000), mkLine(2, 3, 4000)]; + expect(cleanNetMinor(items)).toBe(roundedNet(items)); + }); + + it('is rate-agnostic: mixed hourly rates reconcile to one clean net', () => { + const items = [mkLine(1, 2.5, 3225), mkLine(2, 1.25, 3225), mkLine(3, 3.5, 4850), mkLine(4, 1.75, 4850)]; + // sum-of-lines = 80.63 + 40.31 + 169.75 + 84.88 = 375.57; clean = 375.56 + expect(roundedNet(items)).toBe(37557); + expect(cleanNetMinor(items)).toBe(37556); + }); + + it('honours per-line discounts at full precision', () => { + const items = [mkLine(1, 3, 1000, { discount_percent: 33 })]; + // exact = 3 × 1000 × 0.67 = 2010 exactly → clean 2010 + expect(cleanNetMinor(items)).toBe(2010); + }); + + it('migration-119 hierarchy: a parent with priced sub-items derives from the children', () => { + // Parent (pos 1) has two priced sub-items; parent own price ignored. + const parent = mkLine(1, 1, 9999); // own price should NOT count + const subA = mkLine(2, 2.5, 3225, { parent_position: 1 }); + const subB = mkLine(3, 1.75, 3225, { parent_position: 1 }); + const items = [parent, subA, subB]; + // exact children = (2.5 + 1.75) × 3225 = 4.25 × 3225 = 13706.25 → 13706 + expect(cleanNetMinor(items)).toBe(13706); + // parent's own 9999 must not leak in + expect(cleanNetMinor(items)).not.toBe(9999); + }); + + it('exactLineMinor returns the un-rounded product', () => { + expect(exactLineMinor({ quantity: 2.5, unit_price_minor: 3225 })).toBeCloseTo(8062.5, 5); + }); +}); diff --git a/backend/src/services/invoiceService.js b/backend/src/services/invoiceService.js index 497ce2a6..7fa531d4 100644 --- a/backend/src/services/invoiceService.js +++ b/backend/src/services/invoiceService.js @@ -23,6 +23,7 @@ const crypto = require('crypto'); const { db, withRetry, logActivity } = require('../database/db'); const logger = require('../utils/logger'); const { getAppSetting } = require('../utils/appSettings'); +const { cleanNetMinor } = require('../utils/invoiceRounding'); const { AppError } = require('../utils/errors'); const { formatBoolean } = require('../utils/dbCompat'); const { claimNextSequence } = require('../utils/documentSequences'); @@ -784,6 +785,15 @@ async function createInvoice(payload, adminId, trx = db) { for (const li of items) { if (li.parent_position == null) netMinor += ensureInt(li.line_total_minor); } + // Optional sub-cent reconciliation (crm_invoice_round_total). When on, + // store the full-precision net rounded ONCE so the total matches + // qty × unit arithmetic; the per-line rounding drift is surfaced as a + // "Rundung" row at render time (storedNet − Σ line totals). Off by + // default ⇒ net stays the sum of rounded lines, unchanged behaviour. + const roundTotal = (await getAppSetting('crm_invoice_round_total', false)) === true; + if (roundTotal) { + netMinor = cleanNetMinor(items, { parentKey: 'parent_position', positionKey: 'position' }); + } const vatRate = ensureNumber(payload.vatRate, 0); const vatMinor = Math.round(netMinor * vatRate / 100); const shippingMinor = ensureInt(payload.shippingAmountMinor); @@ -1751,6 +1761,25 @@ async function buildInvoiceRenderContext(invoice, lineItems) { else if (typeof raw === 'string' && raw.trim()) dateFormat = { format: raw.trim() }; } catch (_) { /* fall back to default */ } + // Sub-cent reconciliation (crm_invoice_round_total). "Betrag Netto" + // shows the sum of the visible line totals so it foots with the items; + // the stored net may be the clean (rounded-once) value, and the gap is + // shown as a "Rundung" row. Legacy/unrounded invoices have equal + // values ⇒ adjustment 0, no row. Suppressed on Storno/Mahnung: those + // negate the stored net and flip line-total signs at render, so the + // forward "storedNet − Σ lines" derivation doesn't apply. + const isReversalDoc = invoice.kind === 'storno' || invoice.kind === 'mahnung'; + const displayedNetMinor = isReversalDoc + ? ensureInt(invoice.net_amount_minor) + : lineItems.reduce( + (s, li) => (li.parent_line_item_id == null && (li.parent_position == null || li.parent_position === '') + ? s + ensureInt(li.line_total_minor) : s), + 0, + ); + const roundingAdjustmentMinor = isReversalDoc + ? 0 + : ensureInt(invoice.net_amount_minor) - displayedNetMinor; + return { locale: invoice.language || profile?.default_locale || 'de', currency: invoice.currency, @@ -1778,7 +1807,8 @@ async function buildInvoiceRenderContext(invoice, lineItems) { detailsText: li.details_text || null, })), totals: { - netAmountMinor: invoice.net_amount_minor, + netAmountMinor: displayedNetMinor, + roundingAdjustmentMinor, vatRate: invoice.vat_rate, // Migration 130 — VAT-code snapshot (so re-editing preserves it). vatCode: invoice.vat_code ?? null, @@ -1895,6 +1925,12 @@ async function renderInvoicePdfFromPayload(payload) { netMinor += ensureInt(it.line_total_minor); } } + // Match the saved-invoice math: clean-net reconciliation when the + // crm_invoice_round_total setting is on (see createInvoice). + const roundTotal = (await getAppSetting('crm_invoice_round_total', false)) === true; + if (roundTotal) { + netMinor = cleanNetMinor(items, { parentKey: 'parent_position', positionKey: 'position' }); + } const vatRate = ensureNumber(payload.vatRate, 0); const vatMinor = Math.round(netMinor * vatRate / 100); const shippingMinor = ensureInt(payload.shippingAmountMinor); diff --git a/backend/src/services/pdf-i18n.js b/backend/src/services/pdf-i18n.js index 064c0002..c71c1e9a 100644 --- a/backend/src/services/pdf-i18n.js +++ b/backend/src/services/pdf-i18n.js @@ -45,6 +45,7 @@ const LABELS = { totals_shipping: 'Shipping', totals_vat: 'VAT', totals_late_fee: 'Late fee', + totals_rounding: 'Rounding', totals_grand: 'Total', payment_conditions: 'Payment conditions', iban_intro: 'Please transfer the amount to the following bank account:', @@ -175,6 +176,7 @@ const LABELS = { totals_shipping: 'Versand', totals_vat: 'ges. MwSt.', totals_late_fee: 'Mahngebühr', + totals_rounding: 'Rundung', totals_grand: 'Gesamtbetrag', payment_conditions: 'Zahlungsbedingungen', iban_intro: 'Der Betrag ist auf die folgende Bankverbindung zu überweisen:', @@ -295,6 +297,7 @@ const LABELS = { totals_shipping: 'Frais d\'expédition', totals_vat: 'TVA', totals_late_fee: 'Frais de retard', + totals_rounding: 'Arrondi', totals_grand: 'Total', payment_conditions: 'Conditions de paiement', iban_intro: 'Veuillez virer le montant sur le compte suivant :', @@ -384,6 +387,7 @@ const LABELS = { totals_shipping: 'Verzending', totals_vat: 'BTW', totals_late_fee: 'Aanmaningskosten', + totals_rounding: 'Afronding', totals_grand: 'Totaal', payment_conditions: 'Betalingsvoorwaarden', iban_intro: 'Gelieve het bedrag over te maken op de volgende bankrekening:', @@ -473,6 +477,7 @@ const LABELS = { totals_shipping: 'Envio', totals_vat: 'IVA', totals_late_fee: 'Taxa de atraso', + totals_rounding: 'Arredondamento', totals_grand: 'Total', payment_conditions: 'Condições de pagamento', iban_intro: 'Por favor transfira o valor para a seguinte conta bancária:', @@ -562,6 +567,7 @@ const LABELS = { totals_shipping: 'Доставка', totals_vat: 'НДС', totals_late_fee: 'Пеня за просрочку', + totals_rounding: 'Округление', totals_grand: 'Итого', payment_conditions: 'Условия оплаты', iban_intro: 'Просим перевести сумму на следующий банковский счёт:', diff --git a/backend/src/services/pdfService.js b/backend/src/services/pdfService.js index 613f386f..d55c198e 100644 --- a/backend/src/services/pdfService.js +++ b/backend/src/services/pdfService.js @@ -861,6 +861,18 @@ function drawTotals(doc, ctx, x, y, width) { 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 diff --git a/backend/src/services/quoteService.js b/backend/src/services/quoteService.js index 98aefae9..8fd1b5cf 100644 --- a/backend/src/services/quoteService.js +++ b/backend/src/services/quoteService.js @@ -30,6 +30,7 @@ const crypto = require('crypto'); const { db, withRetry, logActivity } = require('../database/db'); const logger = require('../utils/logger'); const { getAppSetting } = require('../utils/appSettings'); +const { cleanNetMinor } = require('../utils/invoiceRounding'); const { AppError } = require('../utils/errors'); const { formatBoolean } = require('../utils/dbCompat'); const { claimNextSequence } = require('../utils/documentSequences'); @@ -88,7 +89,7 @@ const { ensureInt, ensureNumber } = require('../utils/numericHelpers'); * array; we treat anything truthy on `parent_position` (number or * string that parses to int) as "I'm a sub-item". */ -function computeTotals(lineItems, vatRate, shippingAmountMinor = 0) { +function computeTotals(lineItems, vatRate, shippingAmountMinor = 0, options = {}) { // Phase 1: compute raw line_total_minor for every row from its own // qty × unit × discount. Sub-item lines are computed here too so // the renderer can display their individual amounts. @@ -135,6 +136,20 @@ function computeTotals(lineItems, vatRate, shippingAmountMinor = 0) { if (li.parent_position == null) netMinor += ensureInt(li.line_total_minor); } + // Optional sub-cent reconciliation (crm_invoice_round_total). When on, + // the stored net becomes the full-precision sum rounded ONCE so the + // total matches qty × unit arithmetic; the few-Rappen drift from the + // per-line rounding is surfaced as a "Rundung" row at render time + // (derived as storedNet − Σ line totals). Off by default ⇒ net stays + // the sum of rounded lines and roundingAdjustmentMinor is 0. + const roundedNet = netMinor; + let roundingAdjustmentMinor = 0; + if (options.roundTotal) { + const clean = cleanNetMinor(computed, { parentKey: 'parent_position', positionKey: 'position' }); + roundingAdjustmentMinor = clean - roundedNet; + netMinor = clean; + } + const vatPercent = ensureNumber(vatRate, 0); const vatMinor = Math.round(netMinor * vatPercent / 100); const shipping = ensureInt(shippingAmountMinor); @@ -144,6 +159,7 @@ function computeTotals(lineItems, vatRate, shippingAmountMinor = 0) { vatAmountMinor: vatMinor, shippingAmountMinor: shipping, totalAmountMinor: totalMinor, + roundingAdjustmentMinor, lineItems: computed, }; } @@ -501,10 +517,12 @@ async function createQuote(payload, adminId) { .toISOString().slice(0, 10); // Authoritative totals. + const roundTotal = (await getAppSetting('crm_invoice_round_total', false)) === true; const totals = computeTotals( Array.isArray(payload.lineItems) ? payload.lineItems : [], payload.vatRate, - payload.shippingAmountMinor + payload.shippingAmountMinor, + { roundTotal } ); // Negative line items (Rabatt) are allowed, but the resulting @@ -651,10 +669,12 @@ async function updateQuote(id, payload, adminId) { ); } + const roundTotal = (await getAppSetting('crm_invoice_round_total', false)) === true; const totals = computeTotals( Array.isArray(payload.lineItems) ? payload.lineItems : [], payload.vatRate ?? existing.vat_rate, - payload.shippingAmountMinor ?? existing.shipping_amount_minor + payload.shippingAmountMinor ?? existing.shipping_amount_minor, + { roundTotal } ); // Negative line items (Rabatt) are allowed, but the resulting @@ -821,6 +841,18 @@ async function buildRenderContext(quote, lineItems) { else if (typeof raw === 'string' && raw.trim()) dateFormat = { format: raw.trim() }; } catch (_) { /* fall back to default */ } + // Sub-cent reconciliation (crm_invoice_round_total). The displayed + // "Betrag Netto" is always the sum of the visible line totals so it + // foots with the items; the stored net may be the clean (rounded-once) + // value, in which case the gap is shown as a "Rundung" row. For + // legacy/unrounded quotes the two are equal ⇒ adjustment 0, no row. + const displayedNetMinor = lineItems.reduce( + (s, li) => (li.parent_line_item_id == null && (li.parent_position == null || li.parent_position === '') + ? s + ensureInt(li.line_total_minor) : s), + 0, + ); + const roundingAdjustmentMinor = ensureInt(quote.net_amount_minor) - displayedNetMinor; + return { locale: quote.language || profile?.default_locale || 'de', currency: quote.currency, @@ -862,7 +894,8 @@ async function buildRenderContext(quote, lineItems) { detailsText: li.details_text || null, })), totals: { - netAmountMinor: quote.net_amount_minor, + netAmountMinor: displayedNetMinor, + roundingAdjustmentMinor, vatRate: quote.vat_rate, vatAmountMinor: quote.vat_amount_minor, shippingAmountMinor: quote.shipping_amount_minor, @@ -893,10 +926,12 @@ async function renderQuotePdfBuffer(quoteId) { */ async function renderQuotePdfFromPayload(payload) { const customer = await db('customer_accounts').where({ id: payload.customerAccountId }).first(); + const roundTotal = (await getAppSetting('crm_invoice_round_total', false)) === true; const totals = computeTotals( Array.isArray(payload.lineItems) ? payload.lineItems : [], payload.vatRate, - payload.shippingAmountMinor + payload.shippingAmountMinor, + { roundTotal } ); const fakeQuote = { quote_number: 'PREVIEW', diff --git a/backend/src/utils/invoiceRounding.js b/backend/src/utils/invoiceRounding.js new file mode 100644 index 00000000..f0d49afb --- /dev/null +++ b/backend/src/utils/invoiceRounding.js @@ -0,0 +1,95 @@ +'use strict'; + +/** + * Sub-cent rounding reconciliation for CRM documents (quotes + invoices). + * + * Each line's total is rounded to the whole minor unit (Rappen/cent) + * BEFORE the net is summed — so for quantities with fractional tails + * (e.g. 2.5 h × 32.25 = 80.625 → 80.63) the sum of the rounded lines can + * drift a few minor units away from the "pure" product. Worked example + * from a real invoice: 68 h × 32.25 = 2193.00, but the 21 individually + * rounded line totals sum to 2193.02. + * + * This is the standard "sum of rounded lines" convention (Stripe, + * QuickBooks, Xero all do the same) and it foots — the printed line + * amounts genuinely add up to the shown total. But some issuers prefer + * the total to match the customer's mental arithmetic (hours × rate). + * + * When the `crm_invoice_round_total` setting is on, the create paths + * replace the stored net with `cleanNetMinor(...)` — the full-precision + * sum rounded ONCE — and the drift is surfaced to the reader as an + * explicit "Rundung" row, derived at render time as + * `storedNet − Σ(line totals)` (see the render-context builders). When + * the setting is off, stored net === Σ(line totals) and the adjustment + * is zero, so the behaviour is unchanged. + */ + +function ensureNumber(v, d = 0) { + const n = Number(v); + return Number.isFinite(n) ? n : d; +} + +function ensureInt(v, d = 0) { + const n = Math.round(Number(v)); + return Number.isFinite(n) ? n : d; +} + +/** + * Full-precision contribution of one line in minor units (NOT rounded): + * quantity × unit_price_minor × (1 − discount%/100) + */ +function exactLineMinor(li) { + const qty = ensureNumber(li.quantity, 1); + const unit = ensureInt(li.unit_price_minor); + const disc = Math.max(0, Math.min(100, ensureNumber(li.discount_percent, 0))); + return qty * unit * (1 - disc / 100); +} + +function isTopLevel(li, parentKey) { + const p = li[parentKey]; + return p == null || p === ''; +} + +/** + * Clean net = round(Σ full-precision contributions of the rows that roll + * into net). Mirrors the rounded-net summation exactly — top-level rows + * only, with a parent whose priced sub-items override it contributing + * its children instead of itself (migration 119 hierarchy) — but sums at + * full precision and rounds ONCE at the very end. + * + * `items` must carry quantity / unit_price_minor / discount_percent and, + * for the hierarchy resolution, `line_total_minor` (the already-rounded + * per-line value, used only to decide whether a parent is overridden by + * priced children — same test as computeTotals / resolveParentTotals). + * + * @param {Array} items + * @param {{parentKey?: string, positionKey?: string}} opts + * @returns {number} clean net in minor units + */ +function cleanNetMinor(items, { parentKey = 'parent_position', positionKey = 'position' } = {}) { + const childrenByParent = new Map(); + for (const li of items) { + if (isTopLevel(li, parentKey)) continue; + const key = ensureInt(li[parentKey]); + if (!childrenByParent.has(key)) childrenByParent.set(key, []); + childrenByParent.get(key).push(li); + } + + let exact = 0; + for (const li of items) { + if (!isTopLevel(li, parentKey)) continue; // sub-items roll into their parent + const kids = childrenByParent.get(ensureInt(li[positionKey])) || []; + const pricedKids = kids.filter((c) => ensureInt(c.unit_price_minor) > 0); + const pricedKidsRounded = pricedKids.reduce((s, c) => s + ensureInt(c.line_total_minor), 0); + // Same override test as computeTotals phase 2: a parent with at least + // one priced sub-item derives its total from those children. + if (pricedKidsRounded > 0) { + for (const c of pricedKids) exact += exactLineMinor(c); + } else { + exact += exactLineMinor(li); + } + } + return Math.round(exact); +} + +module.exports = { cleanNetMinor, exactLineMinor }; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index a0ea71e3..bf805d85 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -4990,6 +4990,9 @@ "crm_invoices_qr_enabled": { "label": "Zahlungs-QR auf Rechnungen aktivieren" }, + "crm_invoice_round_total": { + "label": "Rappenrundung auf einen sauberen Gesamtbetrag ausgleichen (fügt eine \"Rundung\"-Zeile hinzu, wenn die zeilenweise Rundung von Menge × Preis abweicht)" + }, "crm_invoices_reminders_enabled": { "label": "Automatische Mahnungen aktivieren" }, diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index fd5708d0..47510e2b 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -4988,6 +4988,9 @@ "crm_invoices_qr_enabled": { "label": "Render payment QR on invoice PDFs" }, + "crm_invoice_round_total": { + "label": "Reconcile sub-cent rounding to a clean total (adds a \"Rundung\" row when per-line rounding drifts from qty × rate)" + }, "crm_invoices_reminders_enabled": { "label": "Send automatic reminders for overdue invoices" }, diff --git a/frontend/src/pages/admin/settings/CrmSettingsPage.tsx b/frontend/src/pages/admin/settings/CrmSettingsPage.tsx index e5ffb95c..3201a03a 100644 --- a/frontend/src/pages/admin/settings/CrmSettingsPage.tsx +++ b/frontend/src/pages/admin/settings/CrmSettingsPage.tsx @@ -28,6 +28,7 @@ const SETTING_KEYS = [ 'crm_quotes_tos_text', 'crm_quotes_tos_url', 'crm_invoices_qr_enabled', + 'crm_invoice_round_total', 'crm_invoices_reminders_enabled', 'crm_invoices_reminder_first_days', 'crm_invoices_reminder_second_days', @@ -249,6 +250,7 @@ export const CrmSettingsPage: React.FC = () => {

{t('crmSettings.section.invoices', 'Invoices')}

{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)')} {/* Reminder TIMING: owned by the Invoice dunning workflow when the engine is live (callout); otherwise the legacy schedule controls. The From c2bc2b098e6af3e984e5b06f42e21a8d9501349b Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Sun, 28 Jun 2026 17:28:21 +0200 Subject: [PATCH 3/3] fix(invoices): show sub-cent Rundung in the editor totals preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live totals panel in the quote/invoice editor (LineItemsTable) summed the per-line rounded totals and showed that as Total — so with crm_invoice_round_total on, a 4 × (2.5h @ 32.25) invoice previewed CHF 322.52 while the saved invoice + PDF correctly show 322.50 with a Rundung row. The preview now mirrors the backend. - LineItemsTable gains a `roundTotal` prop. When set, it computes the clean net (full-precision sum rounded once — same rule as backend utils/invoiceRounding.cleanNetMinor, including the migration-119 priced sub-item override), shows a "Rundung" row for the drift, and folds it into the VAT base + Total. Off ⇒ unchanged (no row). - Bill + Quote editors pass roundTotal from appSettings.crm_invoice_round_total. - i18n: crm.lineItems.rounding (de "Rundung", en "Rounding"). The saved-invoice detail view already shows the stored clean total, so no change there. --- .../src/components/admin/LineItemsTable.tsx | 51 +++++++++++++++---- frontend/src/i18n/locales/de.json | 1 + frontend/src/i18n/locales/en.json | 1 + .../src/pages/admin/bills/BillEditorPage.tsx | 3 +- .../pages/admin/quotes/QuoteEditorPage.tsx | 1 + 5 files changed, 46 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/admin/LineItemsTable.tsx b/frontend/src/components/admin/LineItemsTable.tsx index 152cc495..d136de21 100644 --- a/frontend/src/components/admin/LineItemsTable.tsx +++ b/frontend/src/components/admin/LineItemsTable.tsx @@ -56,6 +56,14 @@ interface Props { showDiscount?: boolean; vatRate?: number; shippingAmount?: number; + /** + * Sub-cent rounding reconciliation (crm_invoice_round_total). When true, + * the net is the full-precision sum rounded once and the per-line + * rounding drift is shown as a "Rundung" row — mirrors the backend + * computeTotals + the PDF so the editor preview matches the saved + * document. Off ⇒ net is the plain sum of rounded lines (unchanged). + */ + roundTotal?: boolean; onChange: (items: EditableLineItem[]) => void; presets?: LineItemPresetMinimal[]; onSaveAsPreset?: (item: EditableLineItem) => void; @@ -70,7 +78,7 @@ function isSub(li: EditableLineItem) { } export const LineItemsTable: React.FC = ({ - items, currency, showDiscount = true, vatRate = 0, shippingAmount = 0, + items, currency, showDiscount = true, vatRate = 0, shippingAmount = 0, roundTotal = false, onChange, presets = [], onSaveAsPreset, }) => { const { t } = useTranslation(); @@ -220,13 +228,15 @@ export const LineItemsTable: React.FC = ({ // quote each keystroke ran ~O(n²) array scans. Build a Map once per // render and read O(1) afterwards. const childPricingByParent = useMemo(() => { - const map = new Map(); + const map = new Map(); for (const c of items) { if (c.parentPosition == null) continue; if (!(c.unitPrice > 0)) continue; - const cur = map.get(c.parentPosition) || { hasPriced: false, pricedSum: 0 }; + const cur = map.get(c.parentPosition) || { hasPriced: false, pricedSum: 0, pricedSumExact: 0 }; cur.hasPriced = true; cur.pricedSum += rawLineTotal(c); + // Un-rounded contribution for the clean-net reconciliation below. + cur.pricedSumExact += c.quantity * c.unitPrice * (1 - c.discountPercent / 100); map.set(c.parentPosition, cur); } return map; @@ -251,13 +261,31 @@ export const LineItemsTable: React.FC = ({ // never roll directly into net — they only feed their parent's // auto-resolved line total. const subtotal = items.filter((li) => !isSub(li)).reduce((s, li) => s + lineTotal(li), 0); - // subtotal is in MAJOR units, vatRate is a FRACTION (0.081). Round to cents: - // round(subtotal * vatRate * 100) / 100 — the *100 inside round was missing, - // which divided the VAT by 100 (CHF 0.63 instead of 63.18). Backend - // computeTotals + the PDF were always correct; only this live editor preview - // was wrong, and it only surfaced once invoices stopped defaulting to 0% VAT. - const vatAmount = Math.round(subtotal * vatRate * 100) / 100; - const total = subtotal + vatAmount + (Number(shippingAmount) || 0); + + // Sub-cent reconciliation (crm_invoice_round_total) — mirrors backend + // utils/invoiceRounding.cleanNetMinor: sum each contributing row's + // FULL-PRECISION product (parent with priced sub-items uses the + // children) and round ONCE. The drift vs the sum-of-rounded-lines + // `subtotal` is shown as a "Rundung" row and folded into the total, so + // the editor preview matches the saved invoice + PDF. + const cleanExact = items + .filter((li) => !isSub(li)) + .reduce((s, li) => ( + hasPricedChildren(li.position) + ? s + (childPricingByParent.get(li.position)?.pricedSumExact || 0) + : s + li.quantity * li.unitPrice * (1 - li.discountPercent / 100) + ), 0); + const cleanSubtotal = Math.round(cleanExact * 100) / 100; + const roundingAdjustment = roundTotal ? Math.round((cleanSubtotal - subtotal) * 100) / 100 : 0; + // Net the VAT + total work off: clean when reconciling, raw subtotal otherwise. + const netForTotals = subtotal + roundingAdjustment; + // vatRate is a FRACTION (0.081). Round to cents: round(net * vatRate * 100) + // / 100 — the *100 inside round was missing, which divided the VAT by 100 + // (CHF 0.63 instead of 63.18). Backend computeTotals + the PDF were always + // correct; only this live editor preview was wrong, and it only surfaced + // once invoices stopped defaulting to 0% VAT. + const vatAmount = Math.round(netForTotals * vatRate * 100) / 100; + const total = netForTotals + vatAmount + (Number(shippingAmount) || 0); // Display numbering: top-level items get 1, 2, 3...; sub-items // render as N.1, N.2 under the parent for clarity. @@ -474,6 +502,9 @@ export const LineItemsTable: React.FC = ({ {!!shippingAmount && (
{t('crm.lineItems.shipping', 'Shipping')}:{formatMoney(shippingAmount, currency)}
)} + {roundingAdjustment !== 0 && ( +
{t('crm.lineItems.rounding', 'Rounding')}:{formatMoney(roundingAdjustment, currency)}
+ )}
{t('crm.lineItems.total', 'Total')}:{formatMoney(total, currency)}
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index bf805d85..9ad036dc 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -4645,6 +4645,7 @@ "total": "Summe", "subtotal": "Zwischensumme", "vat": "MwSt.", + "rounding": "Rundung", "position": "Pos.", "discount": "Rabatt %", "descriptionPlaceholder": "Beschreibung (mehrere Zeilen möglich)", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 47510e2b..f6b086e0 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -4645,6 +4645,7 @@ "total": "Total", "subtotal": "Subtotal", "vat": "VAT", + "rounding": "Rounding", "position": "Pos.", "discount": "Rabatt %", "descriptionPlaceholder": "Description (multi-line OK)", diff --git a/frontend/src/pages/admin/bills/BillEditorPage.tsx b/frontend/src/pages/admin/bills/BillEditorPage.tsx index c2f0245f..adec553b 100644 --- a/frontend/src/pages/admin/bills/BillEditorPage.tsx +++ b/frontend/src/pages/admin/bills/BillEditorPage.tsx @@ -675,7 +675,8 @@ export const BillEditorPage: React.FC = () => {

{t('bills.section.lineItems', 'Line items')}

+ vatRate={vatRate / 100} shippingAmount={shipping} + roundTotal={appSettings?.crm_invoice_round_total === true} onChange={setLineItems} />
{ showDiscount={true} vatRate={form.vatRate / 100} shippingAmount={form.shippingAmount} + roundTotal={appSettings?.crm_invoice_round_total === true} presets={liPresets?.presets || []} onChange={(items) => setForm((f) => ({ ...f, lineItems: items }))} />