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:
@@ -234,7 +234,7 @@ describe('workflow engine', () => {
|
||||
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 noopLogger = { info() {}, warn() {} };
|
||||
await seedBuiltinWorkflowsAtBoot(db, noopLogger);
|
||||
@@ -243,13 +243,39 @@ 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);
|
||||
|
||||
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.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 });
|
||||
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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,78 +18,117 @@
|
||||
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;
|
||||
|
||||
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 = [
|
||||
{ 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: '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: 'gate', type: 'gate', config: { type: 'payment_confirm', prompt: 'No payment received yet — send a reminder?' }, pos_x: 240, pos_y: 440 },
|
||||
{ node_key: 'loop', type: 'loop', config: { maxIterations: maxReminders }, 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: 770 },
|
||||
{ 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: 770 },
|
||||
{ node_key: 'donePaid', type: 'action', config: { action: 'noop' }, pos_x: 520, pos_y: 330 },
|
||||
{ node_key: 'loop', type: 'loop', config: { maxIterations: maxReminders }, pos_x: 240, pos_y: 330 },
|
||||
{ node_key: 'checkPaid', type: 'condition', config: { condition: 'invoice_paid' }, pos_x: 240, pos_y: 440 },
|
||||
{ node_key: 'paymentCheck', type: 'action', config: { action: 'queue_payment_check' }, pos_x: 240, pos_y: 550 },
|
||||
{ node_key: 'waitGap', type: 'wait', config: { delayDays: gapDays }, pos_x: 240, pos_y: 660 },
|
||||
{ node_key: 'donePaid', type: 'action', config: { action: 'noop' }, pos_x: 520, pos_y: 440 },
|
||||
{ node_key: 'doneEnd', type: 'action', config: { action: 'noop' }, pos_x: 520, pos_y: 330 },
|
||||
];
|
||||
const edges = [
|
||||
{ from_node: 't', to_node: 'waitDue' },
|
||||
{ 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: 'no', to_node: 'gate' },
|
||||
{ from_node: 'gate', from_handle: 'confirm', to_node: 'loop' },
|
||||
{ from_node: 'gate', from_handle: 'deny', to_node: 'donePaid' },
|
||||
{ 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' },
|
||||
{ from_node: 'checkPaid', from_handle: 'no', to_node: 'paymentCheck' },
|
||||
{ from_node: 'paymentCheck', to_node: 'waitGap' },
|
||||
{ from_node: 'waitGap', to_node: 'loop', loop_back: true },
|
||||
];
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
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) {
|
||||
try {
|
||||
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 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 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) => {
|
||||
const ins = await trx('workflows').insert({
|
||||
name: 'Invoice dunning (built-in)',
|
||||
description: 'Editable copy of the overdue-reminder ladder: wait to due date, '
|
||||
+ '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.',
|
||||
description,
|
||||
enabled: false,
|
||||
version: 1,
|
||||
trigger_type: 'invoice.sent',
|
||||
trigger_config: null,
|
||||
trigger_config: JSON.stringify({ seedVersion: SEED_VERSION }),
|
||||
is_builtin: true,
|
||||
builtin_key: DUNNING_KEY,
|
||||
});
|
||||
const workflowId = ins[0];
|
||||
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,
|
||||
});
|
||||
}
|
||||
await writeGraph(trx, ins[0], 1, nodes, edges);
|
||||
});
|
||||
|
||||
booted = true;
|
||||
|
||||
@@ -3392,7 +3392,18 @@ async function runScheduledTasks() {
|
||||
// Throttled to one email per 24h per invoice via
|
||||
// invoices.last_payment_check_at.
|
||||
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 secondDays = ensureInt(await getAppSetting('crm_invoices_reminder_second_days')) || 30;
|
||||
|
||||
|
||||
@@ -66,6 +66,19 @@ registry.registerAction('send_email', async (ctx) => {
|
||||
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
|
||||
// valid; service wiring is a follow-up. Records a skipped step (observable).
|
||||
for (const key of DOCUMENT_ACTIONS) {
|
||||
|
||||
Reference in New Issue
Block a user