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:
@@ -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);
|
||||
|
||||
@@ -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: 'Просим перевести сумму на следующий банковский счёт:',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user