feat(invoices): optional sub-cent rounding reconciliation ("Rundung" row)

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).
This commit is contained in:
Luca
2026-06-28 15:53:44 +02:00
parent 2205b0bd68
commit 4670292139
9 changed files with 264 additions and 6 deletions
@@ -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);
});
});
+37 -1
View File
@@ -23,6 +23,7 @@ const crypto = require('crypto');
const { db, withRetry, logActivity } = require('../database/db'); const { db, withRetry, logActivity } = require('../database/db');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { getAppSetting } = require('../utils/appSettings'); const { getAppSetting } = require('../utils/appSettings');
const { cleanNetMinor } = require('../utils/invoiceRounding');
const { AppError } = require('../utils/errors'); const { AppError } = require('../utils/errors');
const { formatBoolean } = require('../utils/dbCompat'); const { formatBoolean } = require('../utils/dbCompat');
const { claimNextSequence } = require('../utils/documentSequences'); const { claimNextSequence } = require('../utils/documentSequences');
@@ -784,6 +785,15 @@ async function createInvoice(payload, adminId, trx = db) {
for (const li of items) { for (const li of items) {
if (li.parent_position == null) netMinor += ensureInt(li.line_total_minor); 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 vatRate = ensureNumber(payload.vatRate, 0);
const vatMinor = Math.round(netMinor * vatRate / 100); const vatMinor = Math.round(netMinor * vatRate / 100);
const shippingMinor = ensureInt(payload.shippingAmountMinor); 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() }; else if (typeof raw === 'string' && raw.trim()) dateFormat = { format: raw.trim() };
} catch (_) { /* fall back to default */ } } 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 { return {
locale: invoice.language || profile?.default_locale || 'de', locale: invoice.language || profile?.default_locale || 'de',
currency: invoice.currency, currency: invoice.currency,
@@ -1778,7 +1807,8 @@ async function buildInvoiceRenderContext(invoice, lineItems) {
detailsText: li.details_text || null, detailsText: li.details_text || null,
})), })),
totals: { totals: {
netAmountMinor: invoice.net_amount_minor, netAmountMinor: displayedNetMinor,
roundingAdjustmentMinor,
vatRate: invoice.vat_rate, vatRate: invoice.vat_rate,
// Migration 130 — VAT-code snapshot (so re-editing preserves it). // Migration 130 — VAT-code snapshot (so re-editing preserves it).
vatCode: invoice.vat_code ?? null, vatCode: invoice.vat_code ?? null,
@@ -1895,6 +1925,12 @@ async function renderInvoicePdfFromPayload(payload) {
netMinor += ensureInt(it.line_total_minor); 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 vatRate = ensureNumber(payload.vatRate, 0);
const vatMinor = Math.round(netMinor * vatRate / 100); const vatMinor = Math.round(netMinor * vatRate / 100);
const shippingMinor = ensureInt(payload.shippingAmountMinor); const shippingMinor = ensureInt(payload.shippingAmountMinor);
+6
View File
@@ -45,6 +45,7 @@ const LABELS = {
totals_shipping: 'Shipping', totals_shipping: 'Shipping',
totals_vat: 'VAT', totals_vat: 'VAT',
totals_late_fee: 'Late fee', totals_late_fee: 'Late fee',
totals_rounding: 'Rounding',
totals_grand: 'Total', totals_grand: 'Total',
payment_conditions: 'Payment conditions', payment_conditions: 'Payment conditions',
iban_intro: 'Please transfer the amount to the following bank account:', iban_intro: 'Please transfer the amount to the following bank account:',
@@ -175,6 +176,7 @@ const LABELS = {
totals_shipping: 'Versand', totals_shipping: 'Versand',
totals_vat: 'ges. MwSt.', totals_vat: 'ges. MwSt.',
totals_late_fee: 'Mahngebühr', totals_late_fee: 'Mahngebühr',
totals_rounding: 'Rundung',
totals_grand: 'Gesamtbetrag', totals_grand: 'Gesamtbetrag',
payment_conditions: 'Zahlungsbedingungen', payment_conditions: 'Zahlungsbedingungen',
iban_intro: 'Der Betrag ist auf die folgende Bankverbindung zu überweisen:', iban_intro: 'Der Betrag ist auf die folgende Bankverbindung zu überweisen:',
@@ -295,6 +297,7 @@ const LABELS = {
totals_shipping: 'Frais d\'expédition', totals_shipping: 'Frais d\'expédition',
totals_vat: 'TVA', totals_vat: 'TVA',
totals_late_fee: 'Frais de retard', totals_late_fee: 'Frais de retard',
totals_rounding: 'Arrondi',
totals_grand: 'Total', totals_grand: 'Total',
payment_conditions: 'Conditions de paiement', payment_conditions: 'Conditions de paiement',
iban_intro: 'Veuillez virer le montant sur le compte suivant :', iban_intro: 'Veuillez virer le montant sur le compte suivant :',
@@ -384,6 +387,7 @@ const LABELS = {
totals_shipping: 'Verzending', totals_shipping: 'Verzending',
totals_vat: 'BTW', totals_vat: 'BTW',
totals_late_fee: 'Aanmaningskosten', totals_late_fee: 'Aanmaningskosten',
totals_rounding: 'Afronding',
totals_grand: 'Totaal', totals_grand: 'Totaal',
payment_conditions: 'Betalingsvoorwaarden', payment_conditions: 'Betalingsvoorwaarden',
iban_intro: 'Gelieve het bedrag over te maken op de volgende bankrekening:', iban_intro: 'Gelieve het bedrag over te maken op de volgende bankrekening:',
@@ -473,6 +477,7 @@ const LABELS = {
totals_shipping: 'Envio', totals_shipping: 'Envio',
totals_vat: 'IVA', totals_vat: 'IVA',
totals_late_fee: 'Taxa de atraso', totals_late_fee: 'Taxa de atraso',
totals_rounding: 'Arredondamento',
totals_grand: 'Total', totals_grand: 'Total',
payment_conditions: 'Condições de pagamento', payment_conditions: 'Condições de pagamento',
iban_intro: 'Por favor transfira o valor para a seguinte conta bancária:', iban_intro: 'Por favor transfira o valor para a seguinte conta bancária:',
@@ -562,6 +567,7 @@ const LABELS = {
totals_shipping: 'Доставка', totals_shipping: 'Доставка',
totals_vat: 'НДС', totals_vat: 'НДС',
totals_late_fee: 'Пеня за просрочку', totals_late_fee: 'Пеня за просрочку',
totals_rounding: 'Округление',
totals_grand: 'Итого', totals_grand: 'Итого',
payment_conditions: 'Условия оплаты', payment_conditions: 'Условия оплаты',
iban_intro: 'Просим перевести сумму на следующий банковский счёт:', iban_intro: 'Просим перевести сумму на следующий банковский счёт:',
+12
View File
@@ -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' }); doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).text(formatMinor(lateFeeMinor, currency, intlLocale), valueX, y, { width: valueCol, align: 'right' });
y = doc.y + 4; 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; y += 6;
// Divider line above grand total — spans the right half of the // Divider line above grand total — spans the right half of the
+40 -5
View File
@@ -30,6 +30,7 @@ const crypto = require('crypto');
const { db, withRetry, logActivity } = require('../database/db'); const { db, withRetry, logActivity } = require('../database/db');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { getAppSetting } = require('../utils/appSettings'); const { getAppSetting } = require('../utils/appSettings');
const { cleanNetMinor } = require('../utils/invoiceRounding');
const { AppError } = require('../utils/errors'); const { AppError } = require('../utils/errors');
const { formatBoolean } = require('../utils/dbCompat'); const { formatBoolean } = require('../utils/dbCompat');
const { claimNextSequence } = require('../utils/documentSequences'); 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 * array; we treat anything truthy on `parent_position` (number or
* string that parses to int) as "I'm a sub-item". * 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 // Phase 1: compute raw line_total_minor for every row from its own
// qty × unit × discount. Sub-item lines are computed here too so // qty × unit × discount. Sub-item lines are computed here too so
// the renderer can display their individual amounts. // 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); 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 vatPercent = ensureNumber(vatRate, 0);
const vatMinor = Math.round(netMinor * vatPercent / 100); const vatMinor = Math.round(netMinor * vatPercent / 100);
const shipping = ensureInt(shippingAmountMinor); const shipping = ensureInt(shippingAmountMinor);
@@ -144,6 +159,7 @@ function computeTotals(lineItems, vatRate, shippingAmountMinor = 0) {
vatAmountMinor: vatMinor, vatAmountMinor: vatMinor,
shippingAmountMinor: shipping, shippingAmountMinor: shipping,
totalAmountMinor: totalMinor, totalAmountMinor: totalMinor,
roundingAdjustmentMinor,
lineItems: computed, lineItems: computed,
}; };
} }
@@ -501,10 +517,12 @@ async function createQuote(payload, adminId) {
.toISOString().slice(0, 10); .toISOString().slice(0, 10);
// Authoritative totals. // Authoritative totals.
const roundTotal = (await getAppSetting('crm_invoice_round_total', false)) === true;
const totals = computeTotals( const totals = computeTotals(
Array.isArray(payload.lineItems) ? payload.lineItems : [], Array.isArray(payload.lineItems) ? payload.lineItems : [],
payload.vatRate, payload.vatRate,
payload.shippingAmountMinor payload.shippingAmountMinor,
{ roundTotal }
); );
// Negative line items (Rabatt) are allowed, but the resulting // 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( const totals = computeTotals(
Array.isArray(payload.lineItems) ? payload.lineItems : [], Array.isArray(payload.lineItems) ? payload.lineItems : [],
payload.vatRate ?? existing.vat_rate, 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 // 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() }; else if (typeof raw === 'string' && raw.trim()) dateFormat = { format: raw.trim() };
} catch (_) { /* fall back to default */ } } 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 { return {
locale: quote.language || profile?.default_locale || 'de', locale: quote.language || profile?.default_locale || 'de',
currency: quote.currency, currency: quote.currency,
@@ -862,7 +894,8 @@ async function buildRenderContext(quote, lineItems) {
detailsText: li.details_text || null, detailsText: li.details_text || null,
})), })),
totals: { totals: {
netAmountMinor: quote.net_amount_minor, netAmountMinor: displayedNetMinor,
roundingAdjustmentMinor,
vatRate: quote.vat_rate, vatRate: quote.vat_rate,
vatAmountMinor: quote.vat_amount_minor, vatAmountMinor: quote.vat_amount_minor,
shippingAmountMinor: quote.shipping_amount_minor, shippingAmountMinor: quote.shipping_amount_minor,
@@ -893,10 +926,12 @@ async function renderQuotePdfBuffer(quoteId) {
*/ */
async function renderQuotePdfFromPayload(payload) { async function renderQuotePdfFromPayload(payload) {
const customer = await db('customer_accounts').where({ id: payload.customerAccountId }).first(); const customer = await db('customer_accounts').where({ id: payload.customerAccountId }).first();
const roundTotal = (await getAppSetting('crm_invoice_round_total', false)) === true;
const totals = computeTotals( const totals = computeTotals(
Array.isArray(payload.lineItems) ? payload.lineItems : [], Array.isArray(payload.lineItems) ? payload.lineItems : [],
payload.vatRate, payload.vatRate,
payload.shippingAmountMinor payload.shippingAmountMinor,
{ roundTotal }
); );
const fakeQuote = { const fakeQuote = {
quote_number: 'PREVIEW', quote_number: 'PREVIEW',
+95
View File
@@ -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 };
+3
View File
@@ -4990,6 +4990,9 @@
"crm_invoices_qr_enabled": { "crm_invoices_qr_enabled": {
"label": "Zahlungs-QR auf Rechnungen aktivieren" "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": { "crm_invoices_reminders_enabled": {
"label": "Automatische Mahnungen aktivieren" "label": "Automatische Mahnungen aktivieren"
}, },
+3
View File
@@ -4988,6 +4988,9 @@
"crm_invoices_qr_enabled": { "crm_invoices_qr_enabled": {
"label": "Render payment QR on invoice PDFs" "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": { "crm_invoices_reminders_enabled": {
"label": "Send automatic reminders for overdue invoices" "label": "Send automatic reminders for overdue invoices"
}, },
@@ -28,6 +28,7 @@ const SETTING_KEYS = [
'crm_quotes_tos_text', 'crm_quotes_tos_text',
'crm_quotes_tos_url', 'crm_quotes_tos_url',
'crm_invoices_qr_enabled', 'crm_invoices_qr_enabled',
'crm_invoice_round_total',
'crm_invoices_reminders_enabled', 'crm_invoices_reminders_enabled',
'crm_invoices_reminder_first_days', 'crm_invoices_reminder_first_days',
'crm_invoices_reminder_second_days', 'crm_invoices_reminder_second_days',
@@ -249,6 +250,7 @@ export const CrmSettingsPage: React.FC = () => {
<Card> <Card>
<h3 className="font-semibold mb-3">{t('crmSettings.section.invoices', 'Invoices')}</h3> <h3 className="font-semibold mb-3">{t('crmSettings.section.invoices', 'Invoices')}</h3>
{checkbox('crm_invoices_qr_enabled', 'Render payment QR on invoice PDFs')} {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 {/* Reminder TIMING: owned by the Invoice dunning workflow when the
engine is live (callout); otherwise the legacy schedule controls. The engine is live (callout); otherwise the legacy schedule controls. The