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] 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