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);
|
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
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user