diff --git a/backend/__tests__/integration/workflowEngine.test.js b/backend/__tests__/integration/workflowEngine.test.js index a038e62e..5440bf14 100644 --- a/backend/__tests__/integration/workflowEngine.test.js +++ b/backend/__tests__/integration/workflowEngine.test.js @@ -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 + }); }); diff --git a/backend/src/services/_workflowSeedBoot.js b/backend/src/services/_workflowSeedBoot.js index 01a74175..6601c607 100644 --- a/backend/src/services/_workflowSeedBoot.js +++ b/backend/src/services/_workflowSeedBoot.js @@ -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; diff --git a/backend/src/services/invoiceService.js b/backend/src/services/invoiceService.js index 821e9186..b779338b 100644 --- a/backend/src/services/invoiceService.js +++ b/backend/src/services/invoiceService.js @@ -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; diff --git a/backend/src/services/workflows/actions.js b/backend/src/services/workflows/actions.js index 7b3077cd..37388499 100644 --- a/backend/src/services/workflows/actions.js +++ b/backend/src/services/workflows/actions.js @@ -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) {