diff --git a/backend/__tests__/integration/workflowEngine.test.js b/backend/__tests__/integration/workflowEngine.test.js index 5440bf14..06b2ee6d 100644 --- a/backend/__tests__/integration/workflowEngine.test.js +++ b/backend/__tests__/integration/workflowEngine.test.js @@ -243,7 +243,7 @@ describe('workflow engine', () => { expect(wf).toBeTruthy(); expect(!!wf.is_builtin).toBe(true); expect(!!wf.enabled).toBe(false); - expect(JSON.parse(wf.trigger_config).seedVersion).toBe(2); + expect(JSON.parse(wf.trigger_config).seedVersion).toBe(3); const nodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: wf.version }); expect(nodes.filter((n) => n.type === 'trigger')).toHaveLength(1); @@ -267,7 +267,7 @@ describe('workflow engine', () => { await seedBuiltinWorkflowsAtBoot(db, noopLogger); const reseeded = await db('workflows').where({ id: wf.id }).first(); expect(reseeded.version).toBe(wf.version + 1); // bumped - expect(JSON.parse(reseeded.trigger_config).seedVersion).toBe(2); + expect(JSON.parse(reseeded.trigger_config).seedVersion).toBe(3); const newNodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: reseeded.version }); expect(newNodes.some((n) => n.type === 'gate')).toBe(false); // legacy graph replaced diff --git a/backend/migrations/core/143_seed_late_fee_type.js b/backend/migrations/core/143_seed_late_fee_type.js new file mode 100644 index 00000000..49b93582 --- /dev/null +++ b/backend/migrations/core/143_seed_late_fee_type.js @@ -0,0 +1,34 @@ +/** + * Migration 143: late-fee (Mahngebühr) type — flat amount OR percentage. + * + * Extends the existing flat `crm_invoices_late_fee_minor` with a type switch so + * the dunning fee can be a percentage of the invoice gross instead of a fixed + * amount. The fee is charged from the 2nd reminder onwards (the 1st is + * fee-free), accumulating per fee-bearing reminder (2nd = 1×, 3rd = 2×). + * + * Seeds conservative defaults that PRESERVE current behaviour: type='flat' + * (so the existing flat fee keeps applying) and percent=0. Idempotent — + * only inserts keys that don't already exist, never clobbers an admin value. + * + * ⚠️ A late fee is only legally enforceable if the concrete amount is stated in + * the AGB (Liechtenstein/Swiss law) — the admin UI surfaces this; verify with a + * Treuhänder. See docs/crm-disclaimers / [[feedback_legal_financial_examples_only]]. + */ +exports.up = async function (knex) { + if (!(await knex.schema.hasTable('app_settings'))) return; + 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' }, + ]; + for (const s of seeds) { + const exists = await knex('app_settings').where({ setting_key: s.setting_key }).first(); + if (!exists) await knex('app_settings').insert({ ...s, updated_at: new Date() }); + } +}; + +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']) + .del(); +}; diff --git a/backend/src/services/_workflowSeedBoot.js b/backend/src/services/_workflowSeedBoot.js index a6a70cc9..d6200a0a 100644 --- a/backend/src/services/_workflowSeedBoot.js +++ b/backend/src/services/_workflowSeedBoot.js @@ -19,8 +19,8 @@ const { getAppSetting } = require('../utils/appSettings'); const DUNNING_KEY = 'invoice_dunning'; // Bump when the built-in graph changes so a disabled, never-activated copy is -// re-seeded on boot. v2 = the delegation/cutover graph (payment-check gate). -const SEED_VERSION = 2; +// re-seeded on boot. v2 = delegation/cutover graph; v3 = 3 reminder loops. +const SEED_VERSION = 3; function buildDunningGraph({ firstDays, gapDays, maxReminders }) { // Delegation model: the payment-check email IS the admin gate (it drives the @@ -83,7 +83,7 @@ async function seedBuiltinWorkflowsAtBoot(db, logger) { const firstDays = Number(await getAppSetting('crm_invoices_reminder_first_days')) || 14; const secondDays = Number(await getAppSetting('crm_invoices_reminder_second_days')) || 30; const gapDays = Math.max(1, secondDays - firstDays); - const { nodes, edges } = buildDunningGraph({ firstDays, gapDays, maxReminders: 2 }); + const { nodes, edges } = buildDunningGraph({ firstDays, gapDays, maxReminders: 3 }); const description = 'Drives overdue dunning through the engine: wait to the due date, then up ' + 'to two payment-check cycles. Each cycle fires the existing admin confirm-payment email ' diff --git a/backend/src/services/invoiceService.js b/backend/src/services/invoiceService.js index b779338b..a5d098cd 100644 --- a/backend/src/services/invoiceService.js +++ b/backend/src/services/invoiceService.js @@ -2642,21 +2642,34 @@ async function sendReminder(id, levelOverride, adminId) { throw new AppError(`Cannot remind on status '${invoice.status}'`, 409); } const newLevel = levelOverride || (invoice.reminder_level + 1); - if (newLevel > 2) { + if (newLevel > 3) { throw new AppError('Reminder level exhausted', 409); } return await applyReminder(invoice, lineItems, newLevel, adminId); } +// Per-reminder Mahngebühr in minor units (0 when disabled). Flat amount OR a +// percentage of the invoice gross, per crm_invoices_late_fee_type. Charged from +// the 2nd reminder onwards. ⚠️ A late fee is only enforceable if the concrete +// amount is stated in the AGB — verify with a Treuhänder (the admin UI says so). +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'; + 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)); + } + return Math.max(0, ensureInt(await getAppSetting('crm_invoices_late_fee_minor')) || 2500); +} + async function applyReminder(invoice, lineItems, level, adminId) { const customer = await db('customer_accounts').where({ id: invoice.customer_account_id }).first(); let lateFeeMinor = invoice.late_fee_amount_minor || 0; - if (level === 2) { - const enabled = await getAppSetting('crm_invoices_late_fee_enabled'); - if (enabled !== false) { - const fee = ensureInt(await getAppSetting('crm_invoices_late_fee_minor')) || 2500; - lateFeeMinor = fee; - } + if (level >= 2) { + const perReminder = await resolvePerReminderFeeMinor(invoice); + // One fee per fee-bearing reminder (levels 2..level): 2nd = 1×, 3rd = 2×. + // Computed from `level` so re-applying the same level never stacks. + lateFeeMinor = (level - 1) * perReminder; } const newTotal = invoice.total_amount_minor + lateFeeMinor; @@ -2909,10 +2922,9 @@ async function queuePaymentCheckEmail(invoiceId, { skipThrottle = false } = {}) // Determine whether the customer reminder will include a Mahngebühr // if the admin selects "Not paid" / "Partial" — surfaced to the // email so the admin sees the consequence before clicking. - const reminderLateFeeEnabled = (await getAppSetting('crm_invoices_late_fee_enabled')) !== false; - const reminderFeeMinor = ensureInt(await getAppSetting('crm_invoices_late_fee_minor')) || 2500; + const reminderFeeMinor = await resolvePerReminderFeeMinor(invoice); const nextLevel = (invoice.reminder_level || 0) + 1; - const willChargeFee = reminderLateFeeEnabled && nextLevel >= 2; + const willChargeFee = reminderFeeMinor > 0 && nextLevel >= 2; const baseUrl = process.env.FRONTEND_URL || (await getAppSetting('app_frontend_url')) @@ -3163,7 +3175,7 @@ async function recordPaymentCheckAction({ token, action, amountMinor, ip, adminI const refreshed = await db('invoices').where({ id: invoice.id }).first(); if (refreshed.status !== 'paid') { const nextLevel = (refreshed.reminder_level || 0) + 1; - if (nextLevel <= 2) { + if (nextLevel <= 3) { const lineItems = await db('invoice_line_items') .where({ invoice_id: invoice.id }).orderBy('position', 'asc'); await applyReminder(refreshed, lineItems, nextLevel, adminId); @@ -3174,7 +3186,7 @@ async function recordPaymentCheckAction({ token, action, amountMinor, ip, adminI // 'unpaid' const nextLevel = (invoice.reminder_level || 0) + 1; - if (nextLevel > 2) { + if (nextLevel > 3) { // Already at max reminder — admin has to take this offline. return { applied: 'unpaid', reminderSkipped: 'max_level_reached' }; } diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 2db8a319..5ecea9f6 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -4898,6 +4898,20 @@ "crm_invoices_late_fee_minor": { "label": "Mahngebühr (Rappen / Cent)" }, + "crm_invoices_late_fee_type": { + "label": "Art der Mahngebühr" + }, + "crm_invoices_late_fee_percent": { + "label": "Mahngebühr (% der Rechnung)" + }, + "lateFeeType": { + "flat": "Fester Betrag (Rappen)", + "percent": "Prozentsatz der Rechnung" + }, + "lateFeeAgb": { + "title": "Mahngebühren müssen in den AGB stehen", + "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." + }, "crm_invoices_late_fee_label": { "label": "Bezeichnung Mahngebühr" }, diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index e45373f7..49948527 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -4896,6 +4896,20 @@ "crm_invoices_late_fee_minor": { "label": "Late fee (minor units / Rappen)" }, + "crm_invoices_late_fee_type": { + "label": "Late fee type" + }, + "crm_invoices_late_fee_percent": { + "label": "Late fee (% of invoice)" + }, + "lateFeeType": { + "flat": "Flat amount (Rappen)", + "percent": "Percentage of invoice" + }, + "lateFeeAgb": { + "title": "Late fees must be itemised in your terms (AGB)", + "body": "A contractual duty: phrases like “late fees apply” aren't enough. Your terms must state the concrete fee (e.g. “CHF 20 from the 2nd reminder”). Verify with your Treuhänder." + }, "crm_invoices_late_fee_label": { "label": "Late fee label" }, diff --git a/frontend/src/pages/admin/settings/CrmSettingsPage.tsx b/frontend/src/pages/admin/settings/CrmSettingsPage.tsx index f54134a9..28bb45f3 100644 --- a/frontend/src/pages/admin/settings/CrmSettingsPage.tsx +++ b/frontend/src/pages/admin/settings/CrmSettingsPage.tsx @@ -31,7 +31,9 @@ const SETTING_KEYS = [ 'crm_invoices_reminder_first_days', 'crm_invoices_reminder_second_days', 'crm_invoices_late_fee_enabled', + 'crm_invoices_late_fee_type', 'crm_invoices_late_fee_minor', + 'crm_invoices_late_fee_percent', 'crm_invoices_late_fee_label', 'crm_invoices_skonto_business_days', 'crm_invoices_skonto_percent_default', @@ -242,7 +244,11 @@ export const CrmSettingsPage: React.FC = () => {

{t('crmSettings.section.invoices', 'Invoices')}

{checkbox('crm_invoices_qr_enabled', 'Render payment QR on invoice PDFs')} {checkbox('crm_invoices_reminders_enabled', 'Send automatic reminders for overdue invoices')} - {checkbox('crm_invoices_late_fee_enabled', 'Add a late fee on the second reminder')} + {checkbox('crm_invoices_late_fee_enabled', 'Add a late fee (Mahngebühr) on the 2nd and 3rd reminder')} +
+

{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.')}

+
{ label={t('crmSettings.crm_invoices_reminder_second_days.label', 'Second reminder after (days past due)') as string} value={values.crm_invoices_reminder_second_days ?? 30} onChange={(e) => setVal('crm_invoices_reminder_second_days', Number(e.target.value))} /> - setVal('crm_invoices_late_fee_minor', Number(e.target.value))} /> +
+ + +
+ {(values.crm_invoices_late_fee_type ?? 'flat') === 'percent' ? ( + setVal('crm_invoices_late_fee_percent', Number(e.target.value))} /> + ) : ( + setVal('crm_invoices_late_fee_minor', Number(e.target.value))} /> + )}