feat(crm): 3-reminder dunning + flat/percent Mahngebühr on 2nd & 3rd + AGB notice

- Late fee can now be a FLAT amount OR a PERCENTAGE of the invoice gross
  (crm_invoices_late_fee_type/_percent, migration 143; defaults preserve the
  current flat behaviour).
- Fee is charged from the 2nd reminder onward and accumulates per fee-bearing
  reminder (2nd = 1×, 3rd = 2×), computed from the level so re-applying a level
  never stacks. New resolvePerReminderFeeMinor() shared by applyReminder + the
  payment-check fee preview.
- Reminder ladder extended to 3 levels (caps raised in sendReminder +
  recordPaymentCheckAction); the built-in dunning flow now loops 3× (seed v3,
  re-seeds the disabled built-in on boot).
- Settings UI: flat/percent toggle + percent field, and a prominent AGB
  callout — a late fee is only enforceable if the concrete amount is stated in
  the terms (Mara's wording), 'verify with your Treuhänder'. en + native de.

The fee math is examples-only / Treuhänder-verify; issued invoices stay
immutable (the fee is tracked in late_fee_amount_minor, not folded into the
original total). Tests 17/17, tsc 0, build green.
This commit is contained in:
Luca
2026-06-23 12:26:32 +02:00
parent 289568fd52
commit dcdbeb9cc5
7 changed files with 122 additions and 22 deletions
@@ -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
@@ -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();
};
+3 -3
View File
@@ -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 '
+24 -12
View File
@@ -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' };
}
+14
View File
@@ -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"
},
+14
View File
@@ -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"
},
@@ -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 = () => {
<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_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')}
<div className="mt-2 rounded-lg border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20 p-3 text-sm text-amber-800 dark:text-amber-200">
<p className="font-medium">{t('crmSettings.lateFeeAgb.title', 'Late fees must be itemised in your terms (AGB)')}</p>
<p className="mt-1">{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.')}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
<Input type="number" min={1} max={365}
label={t('crmSettings.crm_invoices_reminder_first_days.label', 'First reminder after (days past due)') as string}
@@ -252,10 +258,30 @@ export const CrmSettingsPage: React.FC = () => {
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))} />
<Input type="number" min={0}
label={t('crmSettings.crm_invoices_late_fee_minor.label', 'Late fee (minor units / Rappen)') as string}
value={values.crm_invoices_late_fee_minor ?? 0}
onChange={(e) => setVal('crm_invoices_late_fee_minor', Number(e.target.value))} />
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('crmSettings.crm_invoices_late_fee_type.label', 'Late fee type')}
</label>
<select
value={values.crm_invoices_late_fee_type ?? 'flat'}
onChange={(e) => setVal('crm_invoices_late_fee_type', e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100"
>
<option value="flat">{t('crmSettings.lateFeeType.flat', 'Flat amount (Rappen)')}</option>
<option value="percent">{t('crmSettings.lateFeeType.percent', 'Percentage of invoice')}</option>
</select>
</div>
{(values.crm_invoices_late_fee_type ?? 'flat') === 'percent' ? (
<Input type="number" min={0} step="0.01" max={100}
label={t('crmSettings.crm_invoices_late_fee_percent.label', 'Late fee (% of invoice)') as string}
value={values.crm_invoices_late_fee_percent ?? 0}
onChange={(e) => setVal('crm_invoices_late_fee_percent', Number(e.target.value))} />
) : (
<Input type="number" min={0}
label={t('crmSettings.crm_invoices_late_fee_minor.label', 'Late fee (minor units / Rappen)') as string}
value={values.crm_invoices_late_fee_minor ?? 0}
onChange={(e) => setVal('crm_invoices_late_fee_minor', Number(e.target.value))} />
)}
<Input
label={t('crmSettings.crm_invoices_late_fee_label.label', 'Late fee label') as string}
value={values.crm_invoices_late_fee_label ?? 'Mahngebühr'}