fix(workflows): backfill existing invoices + anchor grace to due date when dunning is enabled (#750)

Enabling the invoice-dunning built-in suppressed the legacy reminder ladder
but only created runs for invoices sent AFTER enabling — already-sent unpaid
invoices got dunned by neither. Now:

- Turning dunning ON enrolls every open sent/overdue unpaid invoice via
  emitWorkflowEvent('invoice.sent') (engine.backfillDunningRuns(), wired into
  the workflow enable toggle). Idempotent via the per-(flow,entity) dedup.
- The grace wait is anchored to the invoice's due date: computeWakeAt now
  treats { untilVar, delayDays } as "var + offset" (was var-only OR now+offset),
  and the built-in's waitGrace becomes { untilVar: 'dueDate', delayDays:
  firstDays } (seed v6 -> v7). An already-overdue invoice duns on its real
  timeline instead of restarting a fresh grace clock.

Note: the due-date-anchored graph applies to freshly seeded built-ins; an
already-admin-enabled dunning workflow still enrolls via backfill but keeps its
current grace timing until re-seeded.
This commit is contained in:
Luca
2026-07-06 21:57:23 +02:00
parent a52317d8a5
commit 2c7b351458
4 changed files with 65 additions and 7 deletions
@@ -239,7 +239,7 @@ describe('workflow engine', () => {
expect(again.already).toBe(true);
});
test('seeds the invoice-dunning built-in as the delegation graph (v6, disabled for first beta)', async () => {
test('seeds the invoice-dunning built-in as the delegation graph (disabled for first beta)', async () => {
const { seedBuiltinWorkflowsAtBoot, DUNNING_KEY } = require('../../src/services/_workflowSeedBoot');
const noopLogger = { info() {}, warn() {} };
await seedBuiltinWorkflowsAtBoot(db, noopLogger);
@@ -248,7 +248,7 @@ describe('workflow engine', () => {
expect(wf).toBeTruthy();
expect(!!wf.is_builtin).toBe(true);
expect(!!wf.enabled).toBe(false); // first beta: ships disabled; legacy ladder runs until enabled
expect(JSON.parse(wf.trigger_config).seedVersion).toBe(6);
expect(JSON.parse(wf.trigger_config).seedVersion).toBe(7);
const nodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: wf.version });
expect(nodes.filter((n) => n.type === 'trigger')).toHaveLength(1);
@@ -273,7 +273,7 @@ describe('workflow engine', () => {
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(6);
expect(JSON.parse(reseeded.trigger_config).seedVersion).toBe(7);
expect(!!reseeded.enabled).toBe(false); // seed default re-applied (not admin-owned → flips enabled→disabled)
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