Files
picpeak/backend/src/utils/invoiceRounding.js
T
Luca 4670292139 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).
2026-06-28 15:53:44 +02:00

96 lines
3.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 };