From eaceb7e71caa6466d8298c0fc84487f0f4af1dca Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:41:44 +0200 Subject: [PATCH] feat(crm): toggle for VAT on late fees (jurisdiction-dependent) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mahngebühr VAT differs by country (CH: liable; DE/AT: not), so it's now a toggle (crm_invoices_late_fee_vat_enabled, seeded into migration 143 in place since it isn't deployed yet — no compensation migration). When on, VAT is added on top of the net fee at the org's default rate (business_profile.vat_rate_default). Gated so it's a NO-OP when the org doesn't charge VAT (default rate 0/unset) — i.e. enabling the toggle on a non-VAT org adds nothing, as required. Settings UI: a self-documenting checkbox. The fee is treated as net + VAT-on-top; the tax-report VAT breakdown for the fee is part of the deferred dunning-document rework. tsc 0, build green, 9/9 workflow tests. --- .../migrations/core/143_seed_late_fee_type.js | 6 +++++- backend/src/services/invoiceService.js | 20 +++++++++++++++++-- .../pages/admin/settings/CrmSettingsPage.tsx | 2 ++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/backend/migrations/core/143_seed_late_fee_type.js b/backend/migrations/core/143_seed_late_fee_type.js index 49b93582..37995c89 100644 --- a/backend/migrations/core/143_seed_late_fee_type.js +++ b/backend/migrations/core/143_seed_late_fee_type.js @@ -19,6 +19,10 @@ exports.up = async function (knex) { const seeds = [ { setting_key: 'crm_invoices_late_fee_type', setting_value: JSON.stringify('flat'), setting_type: 'crm' }, { setting_key: 'crm_invoices_late_fee_percent', setting_value: JSON.stringify(0), setting_type: 'crm' }, + // VAT on the late fee is jurisdiction-dependent (CH: yes; DE/AT: no), so it's + // a toggle. Default OFF (preserve current no-VAT behaviour). No-op anyway + // when the org doesn't charge VAT (business_profile.vat_rate_default = 0). + { setting_key: 'crm_invoices_late_fee_vat_enabled', setting_value: JSON.stringify(false), setting_type: 'crm' }, ]; for (const s of seeds) { const exists = await knex('app_settings').where({ setting_key: s.setting_key }).first(); @@ -29,6 +33,6 @@ exports.up = async function (knex) { exports.down = async function (knex) { if (!(await knex.schema.hasTable('app_settings'))) return; await knex('app_settings') - .whereIn('setting_key', ['crm_invoices_late_fee_type', 'crm_invoices_late_fee_percent']) + .whereIn('setting_key', ['crm_invoices_late_fee_type', 'crm_invoices_late_fee_percent', 'crm_invoices_late_fee_vat_enabled']) .del(); }; diff --git a/backend/src/services/invoiceService.js b/backend/src/services/invoiceService.js index a5d098cd..7f0ba014 100644 --- a/backend/src/services/invoiceService.js +++ b/backend/src/services/invoiceService.js @@ -2655,11 +2655,27 @@ async function sendReminder(id, levelOverride, adminId) { async function resolvePerReminderFeeMinor(invoice) { if ((await getAppSetting('crm_invoices_late_fee_enabled')) === false) return 0; const type = (await getAppSetting('crm_invoices_late_fee_type')) || 'flat'; + let fee; if (type === 'percent') { const pct = Number(await getAppSetting('crm_invoices_late_fee_percent')) || 0; - return Math.max(0, Math.round(Number(invoice.total_amount_minor || 0) * pct / 100)); + fee = Math.round(Number(invoice.total_amount_minor || 0) * pct / 100); + } else { + fee = ensureInt(await getAppSetting('crm_invoices_late_fee_minor')) || 2500; } - return Math.max(0, ensureInt(await getAppSetting('crm_invoices_late_fee_minor')) || 2500); + fee = Math.max(0, fee); + + // VAT on the late fee is jurisdiction-dependent (CH: yes; DE/AT: no), so it's + // toggle-gated. It also no-ops when the ORG doesn't charge VAT — the org's + // default rate (business_profile.vat_rate_default) is 0/unset — so enabling + // the toggle on a non-VAT org adds nothing. (The fee amount is treated as net; + // VAT is added on top. The tax-report VAT breakdown for the fee is part of the + // deferred dunning-document rework.) + if ((await getAppSetting('crm_invoices_late_fee_vat_enabled')) === true && fee > 0) { + const profile = await db('business_profile').where({ id: 1 }).first('vat_rate_default'); + const rate = Number(profile?.vat_rate_default) || 0; + if (rate > 0) fee += Math.round(fee * rate / 100); + } + return fee; } async function applyReminder(invoice, lineItems, level, adminId) { diff --git a/frontend/src/pages/admin/settings/CrmSettingsPage.tsx b/frontend/src/pages/admin/settings/CrmSettingsPage.tsx index 28bb45f3..1488fd89 100644 --- a/frontend/src/pages/admin/settings/CrmSettingsPage.tsx +++ b/frontend/src/pages/admin/settings/CrmSettingsPage.tsx @@ -34,6 +34,7 @@ const SETTING_KEYS = [ 'crm_invoices_late_fee_type', 'crm_invoices_late_fee_minor', 'crm_invoices_late_fee_percent', + 'crm_invoices_late_fee_vat_enabled', 'crm_invoices_late_fee_label', 'crm_invoices_skonto_business_days', 'crm_invoices_skonto_percent_default', @@ -249,6 +250,7 @@ export const CrmSettingsPage: React.FC = () => {
{t('crmSettings.lateFeeAgb.title', 'Late fees must be itemised in your terms (AGB)')}
{t('crmSettings.lateFeeAgb.body', 'Vertragliche Pflicht: Sätze wie „Es werden Mahnspesen erhoben“ reichen nicht aus. In den AGB muss die konkrete Gebühr klar beziffert sein (z.B. „CHF 20 ab der 2. Mahnung“). Mit dem Treuhänder prüfen.')}
+ {checkbox('crm_invoices_late_fee_vat_enabled', 'Charge VAT on late fees (Switzerland — leave off for DE/AT; no effect if your organisation has no VAT rate)')}