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:
@@ -4990,6 +4990,9 @@
|
||||
"crm_invoices_qr_enabled": {
|
||||
"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": {
|
||||
"label": "Automatische Mahnungen aktivieren"
|
||||
},
|
||||
|
||||
@@ -4988,6 +4988,9 @@
|
||||
"crm_invoices_qr_enabled": {
|
||||
"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": {
|
||||
"label": "Send automatic reminders for overdue invoices"
|
||||
},
|
||||
|
||||
@@ -28,6 +28,7 @@ const SETTING_KEYS = [
|
||||
'crm_quotes_tos_text',
|
||||
'crm_quotes_tos_url',
|
||||
'crm_invoices_qr_enabled',
|
||||
'crm_invoice_round_total',
|
||||
'crm_invoices_reminders_enabled',
|
||||
'crm_invoices_reminder_first_days',
|
||||
'crm_invoices_reminder_second_days',
|
||||
@@ -249,6 +250,7 @@ export const CrmSettingsPage: React.FC = () => {
|
||||
<Card>
|
||||
<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_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
|
||||
engine is live (callout); otherwise the legacy schedule controls. The
|
||||
|
||||
Reference in New Issue
Block a user