Files
picpeak/backend/__tests__/utils/invoiceRounding.test.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

67 lines
3.0 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.
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);
});
});