Merge pull request #680 from Luca-Timo/fix/invoice-pdf-multipage

Fix/invoice pdf multipage
This commit is contained in:
Paul Nothaft
2026-06-28 22:17:07 +02:00
committed by GitHub
12 changed files with 337 additions and 41 deletions
@@ -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<Props> = ({
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<Props> = ({
// 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<number, { hasPriced: boolean; pricedSum: number }>();
const map = new Map<number, { hasPriced: boolean; pricedSum: number; pricedSumExact: number }>();
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<Props> = ({
// 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<Props> = ({
{!!shippingAmount && (
<div className="flex gap-6"><span className="text-neutral-600 dark:text-neutral-400">{t('crm.lineItems.shipping', 'Shipping')}:</span><span className="tabular-nums w-28 text-right">{formatMoney(shippingAmount, currency)}</span></div>
)}
{roundingAdjustment !== 0 && (
<div className="flex gap-6"><span className="text-neutral-600 dark:text-neutral-400">{t('crm.lineItems.rounding', 'Rounding')}:</span><span className="tabular-nums w-28 text-right">{formatMoney(roundingAdjustment, currency)}</span></div>
)}
<div className="flex gap-6 font-semibold text-base"><span>{t('crm.lineItems.total', 'Total')}:</span><span className="tabular-nums w-28 text-right">{formatMoney(total, currency)}</span></div>
</div>
</div>
+4
View File
@@ -4646,6 +4646,7 @@
"total": "Summe",
"subtotal": "Zwischensumme",
"vat": "MwSt.",
"rounding": "Rundung",
"position": "Pos.",
"discount": "Rabatt %",
"descriptionPlaceholder": "Beschreibung (mehrere Zeilen möglich)",
@@ -4991,6 +4992,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"
},
+4
View File
@@ -4646,6 +4646,7 @@
"total": "Total",
"subtotal": "Subtotal",
"vat": "VAT",
"rounding": "Rounding",
"position": "Pos.",
"discount": "Rabatt %",
"descriptionPlaceholder": "Description (multi-line OK)",
@@ -4989,6 +4990,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"
},
@@ -675,7 +675,8 @@ export const BillEditorPage: React.FC = () => {
<Card>
<h3 className="font-semibold mb-2">{t('bills.section.lineItems', 'Line items')}</h3>
<LineItemsTable items={lineItems} currency={currency} showDiscount={false}
vatRate={vatRate / 100} shippingAmount={shipping} onChange={setLineItems} />
vatRate={vatRate / 100} shippingAmount={shipping}
roundTotal={appSettings?.crm_invoice_round_total === true} onChange={setLineItems} />
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mt-4">
<VatRateSelect
label={t('bills.field.vatRate', 'VAT rate %') as string}
@@ -621,6 +621,7 @@ export const QuoteEditorPage: React.FC = () => {
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 }))}
/>
@@ -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