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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user