From c2bc2b098e6af3e984e5b06f42e21a8d9501349b Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Sun, 28 Jun 2026 17:28:21 +0200 Subject: [PATCH] fix(invoices): show sub-cent Rundung in the editor totals preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live totals panel in the quote/invoice editor (LineItemsTable) summed the per-line rounded totals and showed that as Total — so with crm_invoice_round_total on, a 4 × (2.5h @ 32.25) invoice previewed CHF 322.52 while the saved invoice + PDF correctly show 322.50 with a Rundung row. The preview now mirrors the backend. - LineItemsTable gains a `roundTotal` prop. When set, it computes the clean net (full-precision sum rounded once — same rule as backend utils/invoiceRounding.cleanNetMinor, including the migration-119 priced sub-item override), shows a "Rundung" row for the drift, and folds it into the VAT base + Total. Off ⇒ unchanged (no row). - Bill + Quote editors pass roundTotal from appSettings.crm_invoice_round_total. - i18n: crm.lineItems.rounding (de "Rundung", en "Rounding"). The saved-invoice detail view already shows the stored clean total, so no change there. --- .../src/components/admin/LineItemsTable.tsx | 51 +++++++++++++++---- frontend/src/i18n/locales/de.json | 1 + frontend/src/i18n/locales/en.json | 1 + .../src/pages/admin/bills/BillEditorPage.tsx | 3 +- .../pages/admin/quotes/QuoteEditorPage.tsx | 1 + 5 files changed, 46 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/admin/LineItemsTable.tsx b/frontend/src/components/admin/LineItemsTable.tsx index 152cc495..d136de21 100644 --- a/frontend/src/components/admin/LineItemsTable.tsx +++ b/frontend/src/components/admin/LineItemsTable.tsx @@ -56,6 +56,14 @@ interface Props { showDiscount?: boolean; vatRate?: number; shippingAmount?: number; + /** + * Sub-cent rounding reconciliation (crm_invoice_round_total). When true, + * the net is the full-precision sum rounded once and the per-line + * rounding drift is shown as a "Rundung" row — mirrors the backend + * computeTotals + the PDF so the editor preview matches the saved + * document. Off ⇒ net is the plain sum of rounded lines (unchanged). + */ + roundTotal?: boolean; onChange: (items: EditableLineItem[]) => void; presets?: LineItemPresetMinimal[]; onSaveAsPreset?: (item: EditableLineItem) => void; @@ -70,7 +78,7 @@ function isSub(li: EditableLineItem) { } export const LineItemsTable: React.FC = ({ - items, currency, showDiscount = true, vatRate = 0, shippingAmount = 0, + items, currency, showDiscount = true, vatRate = 0, shippingAmount = 0, roundTotal = false, onChange, presets = [], onSaveAsPreset, }) => { const { t } = useTranslation(); @@ -220,13 +228,15 @@ export const LineItemsTable: React.FC = ({ // quote each keystroke ran ~O(n²) array scans. Build a Map once per // render and read O(1) afterwards. const childPricingByParent = useMemo(() => { - const map = new Map(); + const map = new Map(); for (const c of items) { if (c.parentPosition == null) continue; if (!(c.unitPrice > 0)) continue; - const cur = map.get(c.parentPosition) || { hasPriced: false, pricedSum: 0 }; + const cur = map.get(c.parentPosition) || { hasPriced: false, pricedSum: 0, pricedSumExact: 0 }; cur.hasPriced = true; cur.pricedSum += rawLineTotal(c); + // Un-rounded contribution for the clean-net reconciliation below. + cur.pricedSumExact += c.quantity * c.unitPrice * (1 - c.discountPercent / 100); map.set(c.parentPosition, cur); } return map; @@ -251,13 +261,31 @@ export const LineItemsTable: React.FC = ({ // never roll directly into net — they only feed their parent's // auto-resolved line total. const subtotal = items.filter((li) => !isSub(li)).reduce((s, li) => s + lineTotal(li), 0); - // subtotal is in MAJOR units, vatRate is a FRACTION (0.081). Round to cents: - // round(subtotal * vatRate * 100) / 100 — the *100 inside round was missing, - // which divided the VAT by 100 (CHF 0.63 instead of 63.18). Backend - // computeTotals + the PDF were always correct; only this live editor preview - // was wrong, and it only surfaced once invoices stopped defaulting to 0% VAT. - const vatAmount = Math.round(subtotal * vatRate * 100) / 100; - const total = subtotal + vatAmount + (Number(shippingAmount) || 0); + + // Sub-cent reconciliation (crm_invoice_round_total) — mirrors backend + // utils/invoiceRounding.cleanNetMinor: sum each contributing row's + // FULL-PRECISION product (parent with priced sub-items uses the + // children) and round ONCE. The drift vs the sum-of-rounded-lines + // `subtotal` is shown as a "Rundung" row and folded into the total, so + // the editor preview matches the saved invoice + PDF. + const cleanExact = items + .filter((li) => !isSub(li)) + .reduce((s, li) => ( + hasPricedChildren(li.position) + ? s + (childPricingByParent.get(li.position)?.pricedSumExact || 0) + : s + li.quantity * li.unitPrice * (1 - li.discountPercent / 100) + ), 0); + const cleanSubtotal = Math.round(cleanExact * 100) / 100; + const roundingAdjustment = roundTotal ? Math.round((cleanSubtotal - subtotal) * 100) / 100 : 0; + // Net the VAT + total work off: clean when reconciling, raw subtotal otherwise. + const netForTotals = subtotal + roundingAdjustment; + // vatRate is a FRACTION (0.081). Round to cents: round(net * vatRate * 100) + // / 100 — the *100 inside round was missing, which divided the VAT by 100 + // (CHF 0.63 instead of 63.18). Backend computeTotals + the PDF were always + // correct; only this live editor preview was wrong, and it only surfaced + // once invoices stopped defaulting to 0% VAT. + const vatAmount = Math.round(netForTotals * vatRate * 100) / 100; + const total = netForTotals + vatAmount + (Number(shippingAmount) || 0); // Display numbering: top-level items get 1, 2, 3...; sub-items // render as N.1, N.2 under the parent for clarity. @@ -474,6 +502,9 @@ export const LineItemsTable: React.FC = ({ {!!shippingAmount && (
{t('crm.lineItems.shipping', 'Shipping')}:{formatMoney(shippingAmount, currency)}
)} + {roundingAdjustment !== 0 && ( +
{t('crm.lineItems.rounding', 'Rounding')}:{formatMoney(roundingAdjustment, currency)}
+ )}
{t('crm.lineItems.total', 'Total')}:{formatMoney(total, currency)}
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index bf805d85..9ad036dc 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -4645,6 +4645,7 @@ "total": "Summe", "subtotal": "Zwischensumme", "vat": "MwSt.", + "rounding": "Rundung", "position": "Pos.", "discount": "Rabatt %", "descriptionPlaceholder": "Beschreibung (mehrere Zeilen möglich)", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 47510e2b..f6b086e0 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -4645,6 +4645,7 @@ "total": "Total", "subtotal": "Subtotal", "vat": "VAT", + "rounding": "Rounding", "position": "Pos.", "discount": "Rabatt %", "descriptionPlaceholder": "Description (multi-line OK)", diff --git a/frontend/src/pages/admin/bills/BillEditorPage.tsx b/frontend/src/pages/admin/bills/BillEditorPage.tsx index c2f0245f..adec553b 100644 --- a/frontend/src/pages/admin/bills/BillEditorPage.tsx +++ b/frontend/src/pages/admin/bills/BillEditorPage.tsx @@ -675,7 +675,8 @@ export const BillEditorPage: React.FC = () => {

{t('bills.section.lineItems', 'Line items')}

+ vatRate={vatRate / 100} shippingAmount={shipping} + roundTotal={appSettings?.crm_invoice_round_total === true} onChange={setLineItems} />
{ showDiscount={true} vatRate={form.vatRate / 100} shippingAmount={form.shippingAmount} + roundTotal={appSettings?.crm_invoice_round_total === true} presets={liPresets?.presets || []} onChange={(items) => setForm((f) => ({ ...f, lineItems: items }))} />