feat(workflows): seed invoice-dunning ladder as an editable built-in flow

Boot self-heal seeds the corrected gate-in-loop dunning graph (wait→due,
grace wait, invoice_paid check, confirm-no-payment gate, bounded reminder
loop with re-check, final notice) keyed on builtin_key='invoice_dunning',
sized from the reminder_first/second_days settings. Seeded DISABLED and
is_builtin: live reminder behaviour is UNCHANGED (the hardcoded scheduler
ladder still runs) — enabling it pre-cutover would double-send, so the
engine cutover is a deliberate follow-up. Idempotent (preserves admin edits).
Built-ins refuse delete (enforced in the CRUD route). Test covers seed shape
+ idempotency.
This commit is contained in:
Luca
2026-06-23 02:44:05 +02:00
parent 1a0d6de04d
commit 9b557efbf3
3 changed files with 130 additions and 0 deletions
@@ -233,4 +233,23 @@ describe('workflow engine', () => {
const again = await engine.actByToken(rawToken, 'confirm');
expect(again.already).toBe(true);
});
test('seeds the invoice-dunning built-in flow (disabled, idempotent)', async () => {
const { seedBuiltinWorkflowsAtBoot, DUNNING_KEY } = require('../../src/services/_workflowSeedBoot');
const noopLogger = { info() {}, warn() {} };
await seedBuiltinWorkflowsAtBoot(db, noopLogger);
const wf = await db('workflows').where({ builtin_key: DUNNING_KEY }).first();
expect(wf).toBeTruthy();
expect(!!wf.is_builtin).toBe(true);
expect(!!wf.enabled).toBe(false);
const nodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: 1 });
expect(nodes.filter((n) => n.type === 'trigger')).toHaveLength(1);
expect(nodes.length).toBeGreaterThanOrEqual(10);
await seedBuiltinWorkflowsAtBoot(db, noopLogger); // idempotent
const all = await db('workflows').where({ builtin_key: DUNNING_KEY });
expect(all.length).toBe(1);
});
});
+9
View File
@@ -897,6 +897,15 @@ async function startServer() {
logger.warn('restore-settings self-heal failed at boot:', err.message);
}
// Seed built-in workflows (the editable invoice-dunning flow). Disabled by
// default — live reminder behaviour is unchanged. See _workflowSeedBoot.js.
try {
const { seedBuiltinWorkflowsAtBoot } = require('./src/services/_workflowSeedBoot');
await seedBuiltinWorkflowsAtBoot(db, logger);
} catch (err) {
logger.warn('built-in workflow seed failed at boot:', err.message);
}
// Install-from-backup trigger. If `RESTORE_ON_INSTALL` (or
// `.txt`) exists in the /backup mount AND the DB is empty, run
// the restore HERE before any admin UI surfaces. Lets admins
+102
View File
@@ -0,0 +1,102 @@
/**
* Boot-time seed for built-in workflows.
*
* Seeds the invoice-dunning ladder as an EDITABLE built-in flow (the corrected
* gate-in-loop graph), so the canvas has real content and admins can see their
* reminder process as blocks. Seeded from the current reminder settings.
*
* IMPORTANT — seeded DISABLED, and live behaviour is UNCHANGED: the existing
* hardcoded reminder ladder in invoiceService.runScheduledTasks still runs. The
* cutover (drive reminders through the engine + stop the hardcoded ladder) is a
* deliberate follow-up so we never double-send. Enabling this flow before that
* cutover would duplicate reminders — hence default off.
*
* Idempotent: keyed on builtin_key='invoice_dunning'. Once seeded, admin edits
* are preserved (we never overwrite an existing built-in). Self-heal pattern
* per [[feedback_self_heal_pattern]].
*/
const { getAppSetting } = require('../utils/appSettings');
const DUNNING_KEY = 'invoice_dunning';
function buildDunningGraph({ firstDays, gapDays, maxReminders }) {
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 },
];
const edges = [
{ from_node: 't', to_node: 'waitDue' },
{ from_node: 'waitDue', to_node: 'waitGrace' },
{ from_node: 'waitGrace', to_node: 'checkPaid' },
{ 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' },
];
return { nodes, edges };
}
let booted = false;
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 });
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.',
enabled: false,
version: 1,
trigger_type: 'invoice.sent',
trigger_config: null,
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,
});
}
});
booted = true;
logger?.info?.('Seeded built-in workflow: invoice dunning (disabled)');
} catch (err) {
logger?.warn?.('Built-in workflow seed failed at boot:', err.message);
}
}
module.exports = { seedBuiltinWorkflowsAtBoot, buildDunningGraph, DUNNING_KEY };