feat(workflows): migrate the dunning ladder onto the engine (cutover)

Makes the built-in dunning flow a faithful replacement for the hardcoded
reminder ladder instead of a disabled representation:

- queue_payment_check action delegates to invoiceService.queuePaymentCheckEmail,
  so the proven confirm + reminder_level + Mahngebühr state machine
  (recordPaymentCheckAction) stays the single source of truth — the workflow
  only decides WHEN the payment-check email (the gate) fires.
- runScheduledTasks now SKIPS the hardcoded reminder batches when workflows is
  on AND the invoice_dunning built-in is enabled, so the two never double-send.
- The built-in graph is re-authored to the delegation model (wait→due, grace,
  loop: check-paid → payment-check → wait-gap), dropping the redundant gate +
  generic reminder emails. A SEED_VERSION re-seeds the disabled, never-activated
  built-in on boot but never touches an enabled/edited one.

Tests: delegation graph shape, re-seed-when-stale, enabled-protection (9 engine
+ 8 route = 17 passing).
This commit is contained in:
Luca
2026-06-23 11:09:07 +02:00
parent 88881d0428
commit 5259ee9705
4 changed files with 131 additions and 42 deletions
@@ -234,7 +234,7 @@ describe('workflow engine', () => {
expect(again.already).toBe(true); expect(again.already).toBe(true);
}); });
test('seeds the invoice-dunning built-in flow (disabled, idempotent)', async () => { test('seeds the invoice-dunning built-in as the delegation graph (v2, disabled)', async () => {
const { seedBuiltinWorkflowsAtBoot, DUNNING_KEY } = require('../../src/services/_workflowSeedBoot'); const { seedBuiltinWorkflowsAtBoot, DUNNING_KEY } = require('../../src/services/_workflowSeedBoot');
const noopLogger = { info() {}, warn() {} }; const noopLogger = { info() {}, warn() {} };
await seedBuiltinWorkflowsAtBoot(db, noopLogger); await seedBuiltinWorkflowsAtBoot(db, noopLogger);
@@ -243,13 +243,39 @@ describe('workflow engine', () => {
expect(wf).toBeTruthy(); expect(wf).toBeTruthy();
expect(!!wf.is_builtin).toBe(true); expect(!!wf.is_builtin).toBe(true);
expect(!!wf.enabled).toBe(false); expect(!!wf.enabled).toBe(false);
expect(JSON.parse(wf.trigger_config).seedVersion).toBe(2);
const nodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: 1 }); const nodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: wf.version });
expect(nodes.filter((n) => n.type === 'trigger')).toHaveLength(1); expect(nodes.filter((n) => n.type === 'trigger')).toHaveLength(1);
expect(nodes.length).toBeGreaterThanOrEqual(10); expect(nodes.some((n) => n.type === 'gate')).toBe(false); // payment-check email IS the gate
expect(nodes.some((n) => JSON.parse(n.config || '{}').action === 'queue_payment_check')).toBe(true);
await seedBuiltinWorkflowsAtBoot(db, noopLogger); // idempotent await seedBuiltinWorkflowsAtBoot(db, noopLogger); // idempotent at current seed version
const all = await db('workflows').where({ builtin_key: DUNNING_KEY }); const all = await db('workflows').where({ builtin_key: DUNNING_KEY });
expect(all.length).toBe(1); expect(all.length).toBe(1);
}); });
test('re-seeds a disabled, stale built-in but never an enabled one', async () => {
const { seedBuiltinWorkflowsAtBoot, DUNNING_KEY } = require('../../src/services/_workflowSeedBoot');
const noopLogger = { info() {}, warn() {} };
// Simulate an older, never-activated seed (v1, with a legacy gate node).
const wf = await db('workflows').where({ builtin_key: DUNNING_KEY }).first();
await db('workflows').where({ id: wf.id }).update({ enabled: false, trigger_config: JSON.stringify({ seedVersion: 1 }) });
await db('workflow_nodes').insert({ workflow_id: wf.id, version: wf.version, node_key: 'legacyGate', type: 'gate', config: '{}', pos_x: 0, pos_y: 0 });
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);
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
// Enabled + stale → must NOT be touched (it's the admin's live flow).
await db('workflows').where({ id: wf.id }).update({ enabled: true, trigger_config: JSON.stringify({ seedVersion: 1 }) });
const before = await db('workflows').where({ id: wf.id }).first();
await seedBuiltinWorkflowsAtBoot(db, noopLogger);
const after = await db('workflows').where({ id: wf.id }).first();
expect(after.version).toBe(before.version); // unchanged
});
}); });
+76 -37
View File
@@ -18,78 +18,117 @@
const { getAppSetting } = require('../utils/appSettings'); const { getAppSetting } = require('../utils/appSettings');
const DUNNING_KEY = 'invoice_dunning'; 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;
function buildDunningGraph({ firstDays, gapDays, maxReminders }) { function buildDunningGraph({ firstDays, gapDays, maxReminders }) {
// Delegation model: the payment-check email IS the admin gate (it drives the
// existing confirm + reminder_level + Mahngebühr state machine), so the flow
// just decides WHEN to fire it. After due date + grace, loop up to
// maxReminders times: if still unpaid, queue a payment-check, wait the gap,
// repeat; stop early once paid.
const nodes = [ const nodes = [
{ node_key: 't', type: 'trigger', config: {}, pos_x: 240, pos_y: 0 }, { node_key: 't', type: 'trigger', config: {}, pos_x: 240, pos_y: 0 },
{ node_key: 'waitDue', type: 'wait', config: { untilVar: 'dueDate' }, pos_x: 240, pos_y: 110 }, { node_key: 'waitDue', type: 'wait', config: { untilVar: 'dueDate' }, pos_x: 240, pos_y: 110 },
{ node_key: 'waitGrace', type: 'wait', config: { delayDays: firstDays }, pos_x: 240, pos_y: 220 }, { node_key: 'waitGrace', type: 'wait', config: { delayDays: firstDays }, pos_x: 240, pos_y: 220 },
{ node_key: 'checkPaid', type: 'condition', config: { condition: 'invoice_paid' }, pos_x: 240, pos_y: 330 }, { node_key: 'loop', type: 'loop', config: { maxIterations: maxReminders }, pos_x: 240, pos_y: 330 },
{ node_key: 'gate', type: 'gate', config: { type: 'payment_confirm', prompt: 'No payment received yet — send a reminder?' }, pos_x: 240, pos_y: 440 }, { node_key: 'checkPaid', type: 'condition', config: { condition: 'invoice_paid' }, pos_x: 240, pos_y: 440 },
{ node_key: 'loop', type: 'loop', config: { maxIterations: maxReminders }, pos_x: 240, pos_y: 550 }, { node_key: 'paymentCheck', type: 'action', config: { action: 'queue_payment_check' }, pos_x: 240, pos_y: 550 },
{ node_key: 'remind', type: 'action', config: { action: 'send_email', recipientClass: 'customer', emailType: 'invoice_reminder' }, pos_x: 240, pos_y: 660 }, { node_key: 'waitGap', type: 'wait', config: { delayDays: gapDays }, pos_x: 240, pos_y: 660 },
{ node_key: 'waitGap', type: 'wait', config: { delayDays: gapDays }, pos_x: 240, pos_y: 770 }, { node_key: 'donePaid', type: 'action', config: { action: 'noop' }, pos_x: 520, pos_y: 440 },
{ node_key: 'final', type: 'action', config: { action: 'send_email', recipientClass: 'customer', emailType: 'invoice_final_notice' }, pos_x: 520, pos_y: 660 }, { node_key: 'doneEnd', type: 'action', config: { action: 'noop' }, pos_x: 520, pos_y: 330 },
{ node_key: 'doneEnd', type: 'action', config: { action: 'noop' }, pos_x: 520, pos_y: 770 },
{ node_key: 'donePaid', type: 'action', config: { action: 'noop' }, pos_x: 520, pos_y: 330 },
]; ];
const edges = [ const edges = [
{ from_node: 't', to_node: 'waitDue' }, { from_node: 't', to_node: 'waitDue' },
{ from_node: 'waitDue', to_node: 'waitGrace' }, { from_node: 'waitDue', to_node: 'waitGrace' },
{ from_node: 'waitGrace', to_node: 'checkPaid' }, { from_node: 'waitGrace', to_node: 'loop' },
{ from_node: 'loop', from_handle: 'loop', to_node: 'checkPaid' },
{ from_node: 'loop', from_handle: 'exit', to_node: 'doneEnd' },
{ from_node: 'checkPaid', from_handle: 'yes', to_node: 'donePaid' }, { from_node: 'checkPaid', from_handle: 'yes', to_node: 'donePaid' },
{ from_node: 'checkPaid', from_handle: 'no', to_node: 'gate' }, { from_node: 'checkPaid', from_handle: 'no', to_node: 'paymentCheck' },
{ from_node: 'gate', from_handle: 'confirm', to_node: 'loop' }, { from_node: 'paymentCheck', to_node: 'waitGap' },
{ from_node: 'gate', from_handle: 'deny', to_node: 'donePaid' }, { from_node: 'waitGap', to_node: 'loop', loop_back: true },
{ from_node: 'loop', from_handle: 'loop', to_node: 'remind' },
{ from_node: 'loop', from_handle: 'exit', to_node: 'final' },
{ from_node: 'remind', to_node: 'waitGap' },
{ from_node: 'waitGap', to_node: 'checkPaid', loop_back: true },
{ from_node: 'final', to_node: 'doneEnd' },
]; ];
return { nodes, edges }; return { nodes, edges };
} }
let booted = false; let booted = false;
function parseSeedConfig(raw) {
if (raw == null) return {};
if (typeof raw === 'object') return raw;
try { return JSON.parse(raw) || {}; } catch (e) { return {}; }
}
async function writeGraph(trx, workflowId, version, nodes, edges) {
for (const n of nodes) {
await trx('workflow_nodes').insert({
workflow_id: workflowId, version, node_key: n.node_key, type: n.type,
config: JSON.stringify(n.config || {}), pos_x: n.pos_x || 0, pos_y: n.pos_y || 0,
});
}
for (const e of edges) {
await trx('workflow_edges').insert({
workflow_id: workflowId, version, from_node: e.from_node, from_handle: e.from_handle || null,
to_node: e.to_node, label: e.label || null, loop_back: !!e.loop_back,
});
}
}
async function seedBuiltinWorkflowsAtBoot(db, logger) { async function seedBuiltinWorkflowsAtBoot(db, logger) {
try { try {
if (!(await db.schema.hasTable('workflows'))) return; if (!(await db.schema.hasTable('workflows'))) return;
const existing = await db('workflows').where({ builtin_key: DUNNING_KEY }).first();
if (existing) { booted = true; return; }
const firstDays = Number(await getAppSetting('crm_invoices_reminder_first_days')) || 14; const firstDays = Number(await getAppSetting('crm_invoices_reminder_first_days')) || 14;
const secondDays = Number(await getAppSetting('crm_invoices_reminder_second_days')) || 30; const secondDays = Number(await getAppSetting('crm_invoices_reminder_second_days')) || 30;
const gapDays = Math.max(1, secondDays - firstDays); const gapDays = Math.max(1, secondDays - firstDays);
const { nodes, edges } = buildDunningGraph({ firstDays, gapDays, maxReminders: 2 }); const { nodes, edges } = buildDunningGraph({ firstDays, gapDays, maxReminders: 2 });
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 '
+ '(the gate), which applies reminders + Mahngebühr via the proven payment-check flow. '
+ 'Disabled by default; while it is enabled the hardcoded reminder ladder is skipped '
+ 'automatically, so the two never double-send.';
const existing = await db('workflows').where({ builtin_key: DUNNING_KEY }).first();
if (existing) {
// Re-seed the graph only when (a) it has never been activated and (b) our
// seed version moved on (the dunning cutover). Once the admin enables it,
// it's their live flow — never overwrite it.
const storedVersion = Number(parseSeedConfig(existing.trigger_config).seedVersion) || 0;
const isEnabled = existing.enabled === true || existing.enabled === 1;
if (isEnabled || storedVersion >= SEED_VERSION) { booted = true; return; }
const newVersion = (existing.version || 1) + 1;
await db.transaction(async (trx) => {
await trx('workflows').where({ id: existing.id }).update({
name: 'Invoice dunning (built-in)',
description,
trigger_config: JSON.stringify({ seedVersion: SEED_VERSION }),
version: newVersion,
updated_at: trx.fn.now(),
});
await writeGraph(trx, existing.id, newVersion, nodes, edges);
});
booted = true;
logger?.info?.('Re-seeded built-in workflow: invoice dunning (delegation graph v2)');
return;
}
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
const ins = await trx('workflows').insert({ const ins = await trx('workflows').insert({
name: 'Invoice dunning (built-in)', name: 'Invoice dunning (built-in)',
description: 'Editable copy of the overdue-reminder ladder: wait to due date, ' description,
+ 'confirm-no-payment gate, then up to two reminders before a final notice. '
+ 'Disabled by default — the live reminder ladder still runs via the scheduler '
+ 'until an explicit cutover, so enabling this without that change would double-send.',
enabled: false, enabled: false,
version: 1, version: 1,
trigger_type: 'invoice.sent', trigger_type: 'invoice.sent',
trigger_config: null, trigger_config: JSON.stringify({ seedVersion: SEED_VERSION }),
is_builtin: true, is_builtin: true,
builtin_key: DUNNING_KEY, builtin_key: DUNNING_KEY,
}); });
const workflowId = ins[0]; await writeGraph(trx, ins[0], 1, nodes, edges);
for (const n of nodes) {
await trx('workflow_nodes').insert({
workflow_id: workflowId, version: 1, node_key: n.node_key, type: n.type,
config: JSON.stringify(n.config || {}), pos_x: n.pos_x || 0, pos_y: n.pos_y || 0,
});
}
for (const e of edges) {
await trx('workflow_edges').insert({
workflow_id: workflowId, version: 1, from_node: e.from_node, from_handle: e.from_handle || null,
to_node: e.to_node, label: e.label || null, loop_back: !!e.loop_back,
});
}
}); });
booted = true; booted = true;
+12 -1
View File
@@ -3392,7 +3392,18 @@ async function runScheduledTasks() {
// Throttled to one email per 24h per invoice via // Throttled to one email per 24h per invoice via
// invoices.last_payment_check_at. // invoices.last_payment_check_at.
const remindersEnabled = await getAppSetting('crm_invoices_reminders_enabled'); const remindersEnabled = await getAppSetting('crm_invoices_reminders_enabled');
if (remindersEnabled !== false) { // Mutual exclusion with the workflow engine: when the `workflows` flag is on
// AND the invoice_dunning built-in is enabled, the engine fires the same
// payment-check emails — running this hardcoded ladder too would double-send.
let engineDrivesDunning = false;
try {
const { isFeatureEnabled } = require('../middleware/requireFeatureFlag');
if (await isFeatureEnabled('workflows')) {
const dunning = await db('workflows').where({ builtin_key: 'invoice_dunning', enabled: true }).first();
engineDrivesDunning = !!dunning;
}
} catch (_) { /* workflows tables absent / flag system down → ladder stays on */ }
if (remindersEnabled !== false && !engineDrivesDunning) {
const firstDays = ensureInt(await getAppSetting('crm_invoices_reminder_first_days')) || 14; const firstDays = ensureInt(await getAppSetting('crm_invoices_reminder_first_days')) || 14;
const secondDays = ensureInt(await getAppSetting('crm_invoices_reminder_second_days')) || 30; const secondDays = ensureInt(await getAppSetting('crm_invoices_reminder_second_days')) || 30;
+13
View File
@@ -66,6 +66,19 @@ registry.registerAction('send_email', async (ctx) => {
return { sent_to: to, recipientClass, respectBusinessHours }; return { sent_to: to, recipientClass, respectBusinessHours };
}); });
// Fire the existing admin payment-check email (the dunning gate). Delegates to
// invoiceService.queuePaymentCheckEmail so the proven escalation +
// Mahngebühr / reminder_level state machine (recordPaymentCheckAction) stays
// the single source of truth — the workflow only decides WHEN it fires. This
// is what makes the built-in dunning flow a faithful replacement for the
// hardcoded ladder (paired with the mutual-exclusion guard in runScheduledTasks).
registry.registerAction('queue_payment_check', async (ctx) => {
const id = ctx.run.entity_id;
if (!id) return { skipped: true, reason: 'no invoice entity' };
await require('../invoiceService').queuePaymentCheckEmail(id);
return { payment_check_queued: id };
});
// Create/prepare-document actions — registered so flows referencing them are // Create/prepare-document actions — registered so flows referencing them are
// valid; service wiring is a follow-up. Records a skipped step (observable). // valid; service wiring is a follow-up. Records a skipped step (observable).
for (const key of DOCUMENT_ACTIONS) { for (const key of DOCUMENT_ACTIONS) {