Merge pull request #680 from Luca-Timo/fix/invoice-pdf-multipage
Fix/invoice pdf multipage
This commit is contained in:
@@ -0,0 +1,66 @@
|
|||||||
|
const { cleanNetMinor, exactLineMinor } = require('../../src/utils/invoiceRounding');
|
||||||
|
|
||||||
|
// Sum the per-line ROUNDED totals the way computeTotals / createInvoice do,
|
||||||
|
// so each test can compare "sum of rounded lines" against cleanNetMinor.
|
||||||
|
function roundedNet(items, parentKey = 'parent_position') {
|
||||||
|
return items
|
||||||
|
.filter((li) => li[parentKey] == null || li[parentKey] === '')
|
||||||
|
.reduce((s, li) => s + Math.round(li.line_total_minor), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mkLine(position, quantity, unitPriceMinor, extra = {}) {
|
||||||
|
const discount = extra.discount_percent || 0;
|
||||||
|
return {
|
||||||
|
position,
|
||||||
|
quantity,
|
||||||
|
unit_price_minor: unitPriceMinor,
|
||||||
|
discount_percent: discount,
|
||||||
|
line_total_minor: Math.round(Math.round(quantity * unitPriceMinor) * (1 - discount / 100)),
|
||||||
|
parent_position: extra.parent_position ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('cleanNetMinor — sub-cent reconciliation', () => {
|
||||||
|
it('reconciles the real 68h × 32.25 invoice (sum-of-lines 2193.02 → clean 2193.00)', () => {
|
||||||
|
const qtys = [5.25, 3.25, 5.25, 2.75, 2, 1, 1.75, 5, 5.25, 5.25, 2.75,
|
||||||
|
4.5, 3.5, 2.5, 4.5, 2, 1.75, 3.25, 1.75, 3.5, 1.25];
|
||||||
|
const items = qtys.map((q, i) => mkLine(i + 1, q, 3225));
|
||||||
|
expect(roundedNet(items)).toBe(219302); // sum of the 21 rounded lines
|
||||||
|
expect(cleanNetMinor(items)).toBe(219300); // full-precision, rounded once
|
||||||
|
expect(cleanNetMinor(items) - roundedNet(items)).toBe(-2); // the -0.02 drift
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when every line is already cent-exact (adjustment 0)', () => {
|
||||||
|
const items = [mkLine(1, 2, 5000), mkLine(2, 3, 4000)];
|
||||||
|
expect(cleanNetMinor(items)).toBe(roundedNet(items));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is rate-agnostic: mixed hourly rates reconcile to one clean net', () => {
|
||||||
|
const items = [mkLine(1, 2.5, 3225), mkLine(2, 1.25, 3225), mkLine(3, 3.5, 4850), mkLine(4, 1.75, 4850)];
|
||||||
|
// sum-of-lines = 80.63 + 40.31 + 169.75 + 84.88 = 375.57; clean = 375.56
|
||||||
|
expect(roundedNet(items)).toBe(37557);
|
||||||
|
expect(cleanNetMinor(items)).toBe(37556);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours per-line discounts at full precision', () => {
|
||||||
|
const items = [mkLine(1, 3, 1000, { discount_percent: 33 })];
|
||||||
|
// exact = 3 × 1000 × 0.67 = 2010 exactly → clean 2010
|
||||||
|
expect(cleanNetMinor(items)).toBe(2010);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('migration-119 hierarchy: a parent with priced sub-items derives from the children', () => {
|
||||||
|
// Parent (pos 1) has two priced sub-items; parent own price ignored.
|
||||||
|
const parent = mkLine(1, 1, 9999); // own price should NOT count
|
||||||
|
const subA = mkLine(2, 2.5, 3225, { parent_position: 1 });
|
||||||
|
const subB = mkLine(3, 1.75, 3225, { parent_position: 1 });
|
||||||
|
const items = [parent, subA, subB];
|
||||||
|
// exact children = (2.5 + 1.75) × 3225 = 4.25 × 3225 = 13706.25 → 13706
|
||||||
|
expect(cleanNetMinor(items)).toBe(13706);
|
||||||
|
// parent's own 9999 must not leak in
|
||||||
|
expect(cleanNetMinor(items)).not.toBe(9999);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exactLineMinor returns the un-rounded product', () => {
|
||||||
|
expect(exactLineMinor({ quantity: 2.5, unit_price_minor: 3225 })).toBeCloseTo(8062.5, 5);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,6 +23,7 @@ const crypto = require('crypto');
|
|||||||
const { db, withRetry, logActivity } = require('../database/db');
|
const { db, withRetry, logActivity } = require('../database/db');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const { getAppSetting } = require('../utils/appSettings');
|
const { getAppSetting } = require('../utils/appSettings');
|
||||||
|
const { cleanNetMinor } = require('../utils/invoiceRounding');
|
||||||
const { AppError } = require('../utils/errors');
|
const { AppError } = require('../utils/errors');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const { claimNextSequence } = require('../utils/documentSequences');
|
const { claimNextSequence } = require('../utils/documentSequences');
|
||||||
@@ -786,6 +787,15 @@ async function createInvoice(payload, adminId, trx = db) {
|
|||||||
for (const li of items) {
|
for (const li of items) {
|
||||||
if (li.parent_position == null) netMinor += ensureInt(li.line_total_minor);
|
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 vatRate = ensureNumber(payload.vatRate, 0);
|
||||||
const vatMinor = Math.round(netMinor * vatRate / 100);
|
const vatMinor = Math.round(netMinor * vatRate / 100);
|
||||||
const shippingMinor = ensureInt(payload.shippingAmountMinor);
|
const shippingMinor = ensureInt(payload.shippingAmountMinor);
|
||||||
@@ -1764,6 +1774,25 @@ async function buildInvoiceRenderContext(invoice, lineItems) {
|
|||||||
else if (typeof raw === 'string' && raw.trim()) dateFormat = { format: raw.trim() };
|
else if (typeof raw === 'string' && raw.trim()) dateFormat = { format: raw.trim() };
|
||||||
} catch (_) { /* fall back to default */ }
|
} 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 {
|
return {
|
||||||
locale: invoice.language || profile?.default_locale || 'de',
|
locale: invoice.language || profile?.default_locale || 'de',
|
||||||
currency: invoice.currency,
|
currency: invoice.currency,
|
||||||
@@ -1791,7 +1820,8 @@ async function buildInvoiceRenderContext(invoice, lineItems) {
|
|||||||
detailsText: li.details_text || null,
|
detailsText: li.details_text || null,
|
||||||
})),
|
})),
|
||||||
totals: {
|
totals: {
|
||||||
netAmountMinor: invoice.net_amount_minor,
|
netAmountMinor: displayedNetMinor,
|
||||||
|
roundingAdjustmentMinor,
|
||||||
vatRate: invoice.vat_rate,
|
vatRate: invoice.vat_rate,
|
||||||
// Migration 130 — VAT-code snapshot (so re-editing preserves it).
|
// Migration 130 — VAT-code snapshot (so re-editing preserves it).
|
||||||
vatCode: invoice.vat_code ?? null,
|
vatCode: invoice.vat_code ?? null,
|
||||||
@@ -1908,6 +1938,12 @@ async function renderInvoicePdfFromPayload(payload) {
|
|||||||
netMinor += ensureInt(it.line_total_minor);
|
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 vatRate = ensureNumber(payload.vatRate, 0);
|
||||||
const vatMinor = Math.round(netMinor * vatRate / 100);
|
const vatMinor = Math.round(netMinor * vatRate / 100);
|
||||||
const shippingMinor = ensureInt(payload.shippingAmountMinor);
|
const shippingMinor = ensureInt(payload.shippingAmountMinor);
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ const LABELS = {
|
|||||||
totals_shipping: 'Shipping',
|
totals_shipping: 'Shipping',
|
||||||
totals_vat: 'VAT',
|
totals_vat: 'VAT',
|
||||||
totals_late_fee: 'Late fee',
|
totals_late_fee: 'Late fee',
|
||||||
|
totals_rounding: 'Rounding',
|
||||||
totals_grand: 'Total',
|
totals_grand: 'Total',
|
||||||
payment_conditions: 'Payment conditions',
|
payment_conditions: 'Payment conditions',
|
||||||
iban_intro: 'Please transfer the amount to the following bank account:',
|
iban_intro: 'Please transfer the amount to the following bank account:',
|
||||||
@@ -175,6 +176,7 @@ const LABELS = {
|
|||||||
totals_shipping: 'Versand',
|
totals_shipping: 'Versand',
|
||||||
totals_vat: 'ges. MwSt.',
|
totals_vat: 'ges. MwSt.',
|
||||||
totals_late_fee: 'Mahngebühr',
|
totals_late_fee: 'Mahngebühr',
|
||||||
|
totals_rounding: 'Rundung',
|
||||||
totals_grand: 'Gesamtbetrag',
|
totals_grand: 'Gesamtbetrag',
|
||||||
payment_conditions: 'Zahlungsbedingungen',
|
payment_conditions: 'Zahlungsbedingungen',
|
||||||
iban_intro: 'Der Betrag ist auf die folgende Bankverbindung zu überweisen:',
|
iban_intro: 'Der Betrag ist auf die folgende Bankverbindung zu überweisen:',
|
||||||
@@ -295,6 +297,7 @@ const LABELS = {
|
|||||||
totals_shipping: 'Frais d\'expédition',
|
totals_shipping: 'Frais d\'expédition',
|
||||||
totals_vat: 'TVA',
|
totals_vat: 'TVA',
|
||||||
totals_late_fee: 'Frais de retard',
|
totals_late_fee: 'Frais de retard',
|
||||||
|
totals_rounding: 'Arrondi',
|
||||||
totals_grand: 'Total',
|
totals_grand: 'Total',
|
||||||
payment_conditions: 'Conditions de paiement',
|
payment_conditions: 'Conditions de paiement',
|
||||||
iban_intro: 'Veuillez virer le montant sur le compte suivant :',
|
iban_intro: 'Veuillez virer le montant sur le compte suivant :',
|
||||||
@@ -384,6 +387,7 @@ const LABELS = {
|
|||||||
totals_shipping: 'Verzending',
|
totals_shipping: 'Verzending',
|
||||||
totals_vat: 'BTW',
|
totals_vat: 'BTW',
|
||||||
totals_late_fee: 'Aanmaningskosten',
|
totals_late_fee: 'Aanmaningskosten',
|
||||||
|
totals_rounding: 'Afronding',
|
||||||
totals_grand: 'Totaal',
|
totals_grand: 'Totaal',
|
||||||
payment_conditions: 'Betalingsvoorwaarden',
|
payment_conditions: 'Betalingsvoorwaarden',
|
||||||
iban_intro: 'Gelieve het bedrag over te maken op de volgende bankrekening:',
|
iban_intro: 'Gelieve het bedrag over te maken op de volgende bankrekening:',
|
||||||
@@ -473,6 +477,7 @@ const LABELS = {
|
|||||||
totals_shipping: 'Envio',
|
totals_shipping: 'Envio',
|
||||||
totals_vat: 'IVA',
|
totals_vat: 'IVA',
|
||||||
totals_late_fee: 'Taxa de atraso',
|
totals_late_fee: 'Taxa de atraso',
|
||||||
|
totals_rounding: 'Arredondamento',
|
||||||
totals_grand: 'Total',
|
totals_grand: 'Total',
|
||||||
payment_conditions: 'Condições de pagamento',
|
payment_conditions: 'Condições de pagamento',
|
||||||
iban_intro: 'Por favor transfira o valor para a seguinte conta bancária:',
|
iban_intro: 'Por favor transfira o valor para a seguinte conta bancária:',
|
||||||
@@ -562,6 +567,7 @@ const LABELS = {
|
|||||||
totals_shipping: 'Доставка',
|
totals_shipping: 'Доставка',
|
||||||
totals_vat: 'НДС',
|
totals_vat: 'НДС',
|
||||||
totals_late_fee: 'Пеня за просрочку',
|
totals_late_fee: 'Пеня за просрочку',
|
||||||
|
totals_rounding: 'Округление',
|
||||||
totals_grand: 'Итого',
|
totals_grand: 'Итого',
|
||||||
payment_conditions: 'Условия оплаты',
|
payment_conditions: 'Условия оплаты',
|
||||||
iban_intro: 'Просим перевести сумму на следующий банковский счёт:',
|
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' });
|
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).text(formatMinor(lateFeeMinor, currency, intlLocale), valueX, y, { width: valueCol, align: 'right' });
|
||||||
y = doc.y + 4;
|
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;
|
y += 6;
|
||||||
|
|
||||||
// Divider line above grand total — spans the right half of the
|
// Divider line above grand total — spans the right half of the
|
||||||
@@ -916,7 +928,14 @@ function drawPaymentBlock(doc, ctx, x, y, width) {
|
|||||||
const showSkontoHere = isQuote
|
const showSkontoHere = isQuote
|
||||||
? (issuer?.quoteShowSkonto !== false)
|
? (issuer?.quoteShowSkonto !== false)
|
||||||
: reminderLevel === 0;
|
: reminderLevel === 0;
|
||||||
const showIbanHere = !isQuote;
|
// Invoices show the IBAN block in the right column EXCEPT when a
|
||||||
|
// Swiss QR-bill slip is appended: that slip already prints the
|
||||||
|
// account/IBAN ("Konto / Zahlbar an") in human-readable form, so
|
||||||
|
// repeating "Der Betrag ist auf die folgende Bankverbindung zu
|
||||||
|
// überweisen: …" under the totals is pure duplication. The EPC QR
|
||||||
|
// path keeps the block — its QR lives on a trailing page, so having
|
||||||
|
// the bank details on the invoice page itself still helps.
|
||||||
|
const showIbanHere = !isQuote && ctx.qrFormat !== 'swiss';
|
||||||
|
|
||||||
// If the quote has nothing to print in either column, bail out
|
// If the quote has nothing to print in either column, bail out
|
||||||
// early — don't render a bare "Payment conditions:" header with
|
// early — don't render a bare "Payment conditions:" header with
|
||||||
@@ -1610,29 +1629,25 @@ function renderDocument(type, context) {
|
|||||||
doc.y = y;
|
doc.y = y;
|
||||||
doc.x = leftX;
|
doc.x = leftX;
|
||||||
|
|
||||||
// Force the items table to auto-paginate BEFORE it can collide
|
// Let the items table paginate with the document's NORMAL
|
||||||
// with the totals + payment block at the page bottom. We
|
// margins so each page fills to the bottom. The header row is
|
||||||
// compute the same anchor as below, then temporarily inflate
|
// marked `header: true` so it auto-repeats on every
|
||||||
// the page's bottom margin so swissqrbill's Table sees a
|
// continuation page. Totals/payment placement is handled below:
|
||||||
// shorter usable area and breaks to a new page when items
|
// they're pinned to a fixed anchor near the page bottom, and if
|
||||||
// would otherwise spill into the totals zone. The header row
|
// the last item row spilled past that anchor we advance to a
|
||||||
// is already marked `header: true` so it auto-repeats on the
|
// fresh page before drawing them (see the desiredTotalsY check).
|
||||||
// continuation page.
|
//
|
||||||
const _origBottomMargin = doc.page.margins.bottom;
|
// We deliberately do NOT inflate the bottom margin here to
|
||||||
const _itemsBottomReserve = PAGE.marginBottom
|
// "reserve" the totals zone on every page. That older approach
|
||||||
+ 30 // FOOTER_RESERVE
|
// shortened the usable area on EVERY page (not just the last),
|
||||||
+ (ctx.paymentTerm ? 80 : 50) // PAYMENT_BLOCK_HEIGHT
|
// so a long invoice broke far too early — only a handful of
|
||||||
+ 12 // gap between totals + payment
|
// line items rendered on page 1 with a large blank gap beneath.
|
||||||
+ 90 // TOTALS_BLOCK_HEIGHT
|
// Worse, the inflated margin was set on the page active when the
|
||||||
+ 20; // small breathing room
|
// table started but restored on whichever page the table ended,
|
||||||
doc.page.margins.bottom = _itemsBottomReserve;
|
// leaving page 1 permanently short: the page-number stamp later
|
||||||
try {
|
// landed below that page's phantom bottom margin and spawned a
|
||||||
drawLineItems(doc, ctx);
|
// stray blank trailing page (which then desynced "Seite X von Y").
|
||||||
} finally {
|
drawLineItems(doc, ctx);
|
||||||
// Restore even if drawLineItems threw — keeps subsequent
|
|
||||||
// pages on the document's normal margin geometry.
|
|
||||||
doc.page.margins.bottom = _origBottomMargin;
|
|
||||||
}
|
|
||||||
// y after the table — used only to detect whether the items
|
// y after the table — used only to detect whether the items
|
||||||
// overflowed past the totals anchor below. We don't use it as
|
// overflowed past the totals anchor below. We don't use it as
|
||||||
// the totals position directly because the totals block is
|
// the totals position directly because the totals block is
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const crypto = require('crypto');
|
|||||||
const { db, withRetry, logActivity } = require('../database/db');
|
const { db, withRetry, logActivity } = require('../database/db');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const { getAppSetting } = require('../utils/appSettings');
|
const { getAppSetting } = require('../utils/appSettings');
|
||||||
|
const { cleanNetMinor } = require('../utils/invoiceRounding');
|
||||||
const { AppError } = require('../utils/errors');
|
const { AppError } = require('../utils/errors');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const { claimNextSequence } = require('../utils/documentSequences');
|
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
|
* array; we treat anything truthy on `parent_position` (number or
|
||||||
* string that parses to int) as "I'm a sub-item".
|
* 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
|
// Phase 1: compute raw line_total_minor for every row from its own
|
||||||
// qty × unit × discount. Sub-item lines are computed here too so
|
// qty × unit × discount. Sub-item lines are computed here too so
|
||||||
// the renderer can display their individual amounts.
|
// 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);
|
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 vatPercent = ensureNumber(vatRate, 0);
|
||||||
const vatMinor = Math.round(netMinor * vatPercent / 100);
|
const vatMinor = Math.round(netMinor * vatPercent / 100);
|
||||||
const shipping = ensureInt(shippingAmountMinor);
|
const shipping = ensureInt(shippingAmountMinor);
|
||||||
@@ -144,6 +159,7 @@ function computeTotals(lineItems, vatRate, shippingAmountMinor = 0) {
|
|||||||
vatAmountMinor: vatMinor,
|
vatAmountMinor: vatMinor,
|
||||||
shippingAmountMinor: shipping,
|
shippingAmountMinor: shipping,
|
||||||
totalAmountMinor: totalMinor,
|
totalAmountMinor: totalMinor,
|
||||||
|
roundingAdjustmentMinor,
|
||||||
lineItems: computed,
|
lineItems: computed,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -504,10 +520,12 @@ async function createQuote(payload, adminId) {
|
|||||||
.toISOString().slice(0, 10);
|
.toISOString().slice(0, 10);
|
||||||
|
|
||||||
// Authoritative totals.
|
// Authoritative totals.
|
||||||
|
const roundTotal = (await getAppSetting('crm_invoice_round_total', false)) === true;
|
||||||
const totals = computeTotals(
|
const totals = computeTotals(
|
||||||
Array.isArray(payload.lineItems) ? payload.lineItems : [],
|
Array.isArray(payload.lineItems) ? payload.lineItems : [],
|
||||||
payload.vatRate,
|
payload.vatRate,
|
||||||
payload.shippingAmountMinor
|
payload.shippingAmountMinor,
|
||||||
|
{ roundTotal }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Negative line items (Rabatt) are allowed, but the resulting
|
// Negative line items (Rabatt) are allowed, but the resulting
|
||||||
@@ -665,10 +683,12 @@ async function updateQuote(id, payload, adminId) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const roundTotal = (await getAppSetting('crm_invoice_round_total', false)) === true;
|
||||||
const totals = computeTotals(
|
const totals = computeTotals(
|
||||||
Array.isArray(payload.lineItems) ? payload.lineItems : [],
|
Array.isArray(payload.lineItems) ? payload.lineItems : [],
|
||||||
payload.vatRate ?? existing.vat_rate,
|
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
|
// Negative line items (Rabatt) are allowed, but the resulting
|
||||||
@@ -835,6 +855,18 @@ async function buildRenderContext(quote, lineItems) {
|
|||||||
else if (typeof raw === 'string' && raw.trim()) dateFormat = { format: raw.trim() };
|
else if (typeof raw === 'string' && raw.trim()) dateFormat = { format: raw.trim() };
|
||||||
} catch (_) { /* fall back to default */ }
|
} 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 {
|
return {
|
||||||
locale: quote.language || profile?.default_locale || 'de',
|
locale: quote.language || profile?.default_locale || 'de',
|
||||||
currency: quote.currency,
|
currency: quote.currency,
|
||||||
@@ -876,7 +908,8 @@ async function buildRenderContext(quote, lineItems) {
|
|||||||
detailsText: li.details_text || null,
|
detailsText: li.details_text || null,
|
||||||
})),
|
})),
|
||||||
totals: {
|
totals: {
|
||||||
netAmountMinor: quote.net_amount_minor,
|
netAmountMinor: displayedNetMinor,
|
||||||
|
roundingAdjustmentMinor,
|
||||||
vatRate: quote.vat_rate,
|
vatRate: quote.vat_rate,
|
||||||
vatAmountMinor: quote.vat_amount_minor,
|
vatAmountMinor: quote.vat_amount_minor,
|
||||||
shippingAmountMinor: quote.shipping_amount_minor,
|
shippingAmountMinor: quote.shipping_amount_minor,
|
||||||
@@ -907,10 +940,12 @@ async function renderQuotePdfBuffer(quoteId) {
|
|||||||
*/
|
*/
|
||||||
async function renderQuotePdfFromPayload(payload) {
|
async function renderQuotePdfFromPayload(payload) {
|
||||||
const customer = await db('customer_accounts').where({ id: payload.customerAccountId }).first();
|
const customer = await db('customer_accounts').where({ id: payload.customerAccountId }).first();
|
||||||
|
const roundTotal = (await getAppSetting('crm_invoice_round_total', false)) === true;
|
||||||
const totals = computeTotals(
|
const totals = computeTotals(
|
||||||
Array.isArray(payload.lineItems) ? payload.lineItems : [],
|
Array.isArray(payload.lineItems) ? payload.lineItems : [],
|
||||||
payload.vatRate,
|
payload.vatRate,
|
||||||
payload.shippingAmountMinor
|
payload.shippingAmountMinor,
|
||||||
|
{ roundTotal }
|
||||||
);
|
);
|
||||||
const fakeQuote = {
|
const fakeQuote = {
|
||||||
quote_number: 'PREVIEW',
|
quote_number: 'PREVIEW',
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sub-cent rounding reconciliation for CRM documents (quotes + invoices).
|
||||||
|
*
|
||||||
|
* Each line's total is rounded to the whole minor unit (Rappen/cent)
|
||||||
|
* BEFORE the net is summed — so for quantities with fractional tails
|
||||||
|
* (e.g. 2.5 h × 32.25 = 80.625 → 80.63) the sum of the rounded lines can
|
||||||
|
* drift a few minor units away from the "pure" product. Worked example
|
||||||
|
* from a real invoice: 68 h × 32.25 = 2193.00, but the 21 individually
|
||||||
|
* rounded line totals sum to 2193.02.
|
||||||
|
*
|
||||||
|
* This is the standard "sum of rounded lines" convention (Stripe,
|
||||||
|
* QuickBooks, Xero all do the same) and it foots — the printed line
|
||||||
|
* amounts genuinely add up to the shown total. But some issuers prefer
|
||||||
|
* the total to match the customer's mental arithmetic (hours × rate).
|
||||||
|
*
|
||||||
|
* When the `crm_invoice_round_total` setting is on, the create paths
|
||||||
|
* replace the stored net with `cleanNetMinor(...)` — the full-precision
|
||||||
|
* sum rounded ONCE — and the drift is surfaced to the reader as an
|
||||||
|
* explicit "Rundung" row, derived at render time as
|
||||||
|
* `storedNet − Σ(line totals)` (see the render-context builders). When
|
||||||
|
* the setting is off, stored net === Σ(line totals) and the adjustment
|
||||||
|
* is zero, so the behaviour is unchanged.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function ensureNumber(v, d = 0) {
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? n : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureInt(v, d = 0) {
|
||||||
|
const n = Math.round(Number(v));
|
||||||
|
return Number.isFinite(n) ? n : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-precision contribution of one line in minor units (NOT rounded):
|
||||||
|
* quantity × unit_price_minor × (1 − discount%/100)
|
||||||
|
*/
|
||||||
|
function exactLineMinor(li) {
|
||||||
|
const qty = ensureNumber(li.quantity, 1);
|
||||||
|
const unit = ensureInt(li.unit_price_minor);
|
||||||
|
const disc = Math.max(0, Math.min(100, ensureNumber(li.discount_percent, 0)));
|
||||||
|
return qty * unit * (1 - disc / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTopLevel(li, parentKey) {
|
||||||
|
const p = li[parentKey];
|
||||||
|
return p == null || p === '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean net = round(Σ full-precision contributions of the rows that roll
|
||||||
|
* into net). Mirrors the rounded-net summation exactly — top-level rows
|
||||||
|
* only, with a parent whose priced sub-items override it contributing
|
||||||
|
* its children instead of itself (migration 119 hierarchy) — but sums at
|
||||||
|
* full precision and rounds ONCE at the very end.
|
||||||
|
*
|
||||||
|
* `items` must carry quantity / unit_price_minor / discount_percent and,
|
||||||
|
* for the hierarchy resolution, `line_total_minor` (the already-rounded
|
||||||
|
* per-line value, used only to decide whether a parent is overridden by
|
||||||
|
* priced children — same test as computeTotals / resolveParentTotals).
|
||||||
|
*
|
||||||
|
* @param {Array} items
|
||||||
|
* @param {{parentKey?: string, positionKey?: string}} opts
|
||||||
|
* @returns {number} clean net in minor units
|
||||||
|
*/
|
||||||
|
function cleanNetMinor(items, { parentKey = 'parent_position', positionKey = 'position' } = {}) {
|
||||||
|
const childrenByParent = new Map();
|
||||||
|
for (const li of items) {
|
||||||
|
if (isTopLevel(li, parentKey)) continue;
|
||||||
|
const key = ensureInt(li[parentKey]);
|
||||||
|
if (!childrenByParent.has(key)) childrenByParent.set(key, []);
|
||||||
|
childrenByParent.get(key).push(li);
|
||||||
|
}
|
||||||
|
|
||||||
|
let exact = 0;
|
||||||
|
for (const li of items) {
|
||||||
|
if (!isTopLevel(li, parentKey)) continue; // sub-items roll into their parent
|
||||||
|
const kids = childrenByParent.get(ensureInt(li[positionKey])) || [];
|
||||||
|
const pricedKids = kids.filter((c) => ensureInt(c.unit_price_minor) > 0);
|
||||||
|
const pricedKidsRounded = pricedKids.reduce((s, c) => s + ensureInt(c.line_total_minor), 0);
|
||||||
|
// Same override test as computeTotals phase 2: a parent with at least
|
||||||
|
// one priced sub-item derives its total from those children.
|
||||||
|
if (pricedKidsRounded > 0) {
|
||||||
|
for (const c of pricedKids) exact += exactLineMinor(c);
|
||||||
|
} else {
|
||||||
|
exact += exactLineMinor(li);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Math.round(exact);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { cleanNetMinor, exactLineMinor };
|
||||||
@@ -56,6 +56,14 @@ interface Props {
|
|||||||
showDiscount?: boolean;
|
showDiscount?: boolean;
|
||||||
vatRate?: number;
|
vatRate?: number;
|
||||||
shippingAmount?: 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;
|
onChange: (items: EditableLineItem[]) => void;
|
||||||
presets?: LineItemPresetMinimal[];
|
presets?: LineItemPresetMinimal[];
|
||||||
onSaveAsPreset?: (item: EditableLineItem) => void;
|
onSaveAsPreset?: (item: EditableLineItem) => void;
|
||||||
@@ -70,7 +78,7 @@ function isSub(li: EditableLineItem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const LineItemsTable: React.FC<Props> = ({
|
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,
|
onChange, presets = [], onSaveAsPreset,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
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
|
// quote each keystroke ran ~O(n²) array scans. Build a Map once per
|
||||||
// render and read O(1) afterwards.
|
// render and read O(1) afterwards.
|
||||||
const childPricingByParent = useMemo(() => {
|
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) {
|
for (const c of items) {
|
||||||
if (c.parentPosition == null) continue;
|
if (c.parentPosition == null) continue;
|
||||||
if (!(c.unitPrice > 0)) 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.hasPriced = true;
|
||||||
cur.pricedSum += rawLineTotal(c);
|
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);
|
map.set(c.parentPosition, cur);
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
@@ -251,13 +261,31 @@ export const LineItemsTable: React.FC<Props> = ({
|
|||||||
// never roll directly into net — they only feed their parent's
|
// never roll directly into net — they only feed their parent's
|
||||||
// auto-resolved line total.
|
// auto-resolved line total.
|
||||||
const subtotal = items.filter((li) => !isSub(li)).reduce((s, li) => s + lineTotal(li), 0);
|
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,
|
// Sub-cent reconciliation (crm_invoice_round_total) — mirrors backend
|
||||||
// which divided the VAT by 100 (CHF 0.63 instead of 63.18). Backend
|
// utils/invoiceRounding.cleanNetMinor: sum each contributing row's
|
||||||
// computeTotals + the PDF were always correct; only this live editor preview
|
// FULL-PRECISION product (parent with priced sub-items uses the
|
||||||
// was wrong, and it only surfaced once invoices stopped defaulting to 0% VAT.
|
// children) and round ONCE. The drift vs the sum-of-rounded-lines
|
||||||
const vatAmount = Math.round(subtotal * vatRate * 100) / 100;
|
// `subtotal` is shown as a "Rundung" row and folded into the total, so
|
||||||
const total = subtotal + vatAmount + (Number(shippingAmount) || 0);
|
// 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
|
// Display numbering: top-level items get 1, 2, 3...; sub-items
|
||||||
// render as N.1, N.2 under the parent for clarity.
|
// render as N.1, N.2 under the parent for clarity.
|
||||||
@@ -474,6 +502,9 @@ export const LineItemsTable: React.FC<Props> = ({
|
|||||||
{!!shippingAmount && (
|
{!!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>
|
<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 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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4646,6 +4646,7 @@
|
|||||||
"total": "Summe",
|
"total": "Summe",
|
||||||
"subtotal": "Zwischensumme",
|
"subtotal": "Zwischensumme",
|
||||||
"vat": "MwSt.",
|
"vat": "MwSt.",
|
||||||
|
"rounding": "Rundung",
|
||||||
"position": "Pos.",
|
"position": "Pos.",
|
||||||
"discount": "Rabatt %",
|
"discount": "Rabatt %",
|
||||||
"descriptionPlaceholder": "Beschreibung (mehrere Zeilen möglich)",
|
"descriptionPlaceholder": "Beschreibung (mehrere Zeilen möglich)",
|
||||||
@@ -4991,6 +4992,9 @@
|
|||||||
"crm_invoices_qr_enabled": {
|
"crm_invoices_qr_enabled": {
|
||||||
"label": "Zahlungs-QR auf Rechnungen aktivieren"
|
"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": {
|
"crm_invoices_reminders_enabled": {
|
||||||
"label": "Automatische Mahnungen aktivieren"
|
"label": "Automatische Mahnungen aktivieren"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4646,6 +4646,7 @@
|
|||||||
"total": "Total",
|
"total": "Total",
|
||||||
"subtotal": "Subtotal",
|
"subtotal": "Subtotal",
|
||||||
"vat": "VAT",
|
"vat": "VAT",
|
||||||
|
"rounding": "Rounding",
|
||||||
"position": "Pos.",
|
"position": "Pos.",
|
||||||
"discount": "Rabatt %",
|
"discount": "Rabatt %",
|
||||||
"descriptionPlaceholder": "Description (multi-line OK)",
|
"descriptionPlaceholder": "Description (multi-line OK)",
|
||||||
@@ -4989,6 +4990,9 @@
|
|||||||
"crm_invoices_qr_enabled": {
|
"crm_invoices_qr_enabled": {
|
||||||
"label": "Render payment QR on invoice PDFs"
|
"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": {
|
"crm_invoices_reminders_enabled": {
|
||||||
"label": "Send automatic reminders for overdue invoices"
|
"label": "Send automatic reminders for overdue invoices"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -675,7 +675,8 @@ export const BillEditorPage: React.FC = () => {
|
|||||||
<Card>
|
<Card>
|
||||||
<h3 className="font-semibold mb-2">{t('bills.section.lineItems', 'Line items')}</h3>
|
<h3 className="font-semibold mb-2">{t('bills.section.lineItems', 'Line items')}</h3>
|
||||||
<LineItemsTable items={lineItems} currency={currency} showDiscount={false}
|
<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">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mt-4">
|
||||||
<VatRateSelect
|
<VatRateSelect
|
||||||
label={t('bills.field.vatRate', 'VAT rate %') as string}
|
label={t('bills.field.vatRate', 'VAT rate %') as string}
|
||||||
|
|||||||
@@ -621,6 +621,7 @@ export const QuoteEditorPage: React.FC = () => {
|
|||||||
showDiscount={true}
|
showDiscount={true}
|
||||||
vatRate={form.vatRate / 100}
|
vatRate={form.vatRate / 100}
|
||||||
shippingAmount={form.shippingAmount}
|
shippingAmount={form.shippingAmount}
|
||||||
|
roundTotal={appSettings?.crm_invoice_round_total === true}
|
||||||
presets={liPresets?.presets || []}
|
presets={liPresets?.presets || []}
|
||||||
onChange={(items) => setForm((f) => ({ ...f, lineItems: items }))}
|
onChange={(items) => setForm((f) => ({ ...f, lineItems: items }))}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const SETTING_KEYS = [
|
|||||||
'crm_quotes_tos_text',
|
'crm_quotes_tos_text',
|
||||||
'crm_quotes_tos_url',
|
'crm_quotes_tos_url',
|
||||||
'crm_invoices_qr_enabled',
|
'crm_invoices_qr_enabled',
|
||||||
|
'crm_invoice_round_total',
|
||||||
'crm_invoices_reminders_enabled',
|
'crm_invoices_reminders_enabled',
|
||||||
'crm_invoices_reminder_first_days',
|
'crm_invoices_reminder_first_days',
|
||||||
'crm_invoices_reminder_second_days',
|
'crm_invoices_reminder_second_days',
|
||||||
@@ -249,6 +250,7 @@ export const CrmSettingsPage: React.FC = () => {
|
|||||||
<Card>
|
<Card>
|
||||||
<h3 className="font-semibold mb-3">{t('crmSettings.section.invoices', 'Invoices')}</h3>
|
<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_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
|
{/* Reminder TIMING: owned by the Invoice dunning workflow when the
|
||||||
engine is live (callout); otherwise the legacy schedule controls. The
|
engine is live (callout); otherwise the legacy schedule controls. The
|
||||||
|
|||||||
Reference in New Issue
Block a user