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
+11
View File
@@ -254,6 +254,17 @@ router.patch('/:id/enabled', requirePermission('workflows.manage'), async (req,
// enabled state on the next SEED_VERSION bump (review nit #1).
if (await hasColumnCached('workflows', 'admin_toggled_at')) patch.admin_toggled_at = db.fn.now();
await db('workflows').where({ id }).update(patch);
// Turning dunning ON enrolls existing open/unpaid invoices (anchored to
// their due date) so it starts chasing current debtors, not only invoices
// sent after enabling (#750). Best-effort — never fail the toggle over it.
if (enabled && wf.builtin_key === 'invoice_dunning') {
try {
const n = await require('../services/workflows').backfillDunningRuns();
require('../utils/logger').info('[workflow] dunning enabled — enrolled existing invoices', { enrolled: n });
} catch (e) {
require('../utils/logger').warn('[workflow] dunning backfill failed', { error: e.message });
}
}
res.json({ id, enabled });
} catch (e) { next(e); }
});
+5 -2
View File
@@ -31,7 +31,10 @@ 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 },
// Anchor the grace period to the invoice's due date (dueDate + firstDays),
// not "now + firstDays" — so an already-overdue invoice enrolled via backfill
// duns on its real timeline instead of restarting a fresh grace clock (#750).
{ node_key: 'waitGrace', type: 'wait', config: { untilVar: 'dueDate', delayDays: firstDays }, pos_x: 240, pos_y: 220 },
{ 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 },
@@ -218,7 +221,7 @@ function buildGalleryExpiredGraph() {
const BUILTINS = [
{
key: DUNNING_KEY,
version: 6,
version: 7,
enabled: false,
name: 'Invoice dunning (built-in)',
trigger_type: 'invoice.sent',
+46 -2
View File
@@ -46,11 +46,15 @@ function outEdge(edges, fromNode, handle) {
function computeWakeAt(config = {}, vars = {}) {
const cfg = config || {};
if (cfg.untilVar && vars[cfg.untilVar]) return new Date(vars[cfg.untilVar]).toISOString();
const ms = (Number(cfg.delayDays || 0) * 86400000)
+ (Number(cfg.delayHours || 0) * 3600000)
+ (Number(cfg.delayMinutes || 0) * 60000);
return new Date(Date.now() + ms).toISOString();
// Anchor to a context var when given (e.g. dueDate), plus any delay offset —
// so `{ untilVar: 'dueDate', delayDays: 7 }` means "due date + 7 days"
// (absolute), and an already-past anchor resumes immediately. Backward
// compatible: untilVar-only → the var; delay-only → now + delay.
const base = (cfg.untilVar && vars[cfg.untilVar]) ? new Date(vars[cfg.untilVar]) : new Date();
return new Date(base.getTime() + ms).toISOString();
}
function gateTimeout(config = {}) {
@@ -421,6 +425,45 @@ async function isBuiltinFlowActive(builtinKey) {
}
}
/**
* Enroll every open, unpaid invoice into the dunning flow by emitting
* `invoice.sent` for it — called when the dunning built-in is turned ON so it
* starts chasing invoices that were already sent, not only new ones (#750).
* Idempotent: emitWorkflowEvent's per-(flow, entity) dedup means at most one
* run per invoice, so re-enabling is safe. Paired with the due-date-anchored
* grace wait, already-overdue invoices dun on their real timeline immediately.
*/
async function backfillDunningRuns() {
let enrolled = 0;
try {
if (!(await db.schema.hasTable('invoices'))) return 0;
const invoices = await db('invoices')
.whereIn('status', ['sent', 'overdue'])
.whereNotNull('due_date')
.whereRaw('COALESCE(paid_amount_minor, 0) < total_amount_minor');
for (const inv of invoices) {
const ids = await emitWorkflowEvent('invoice.sent', {
entityType: 'invoice',
entityId: inv.id,
payload: {
invoiceId: inv.id,
invoiceNumber: inv.invoice_number,
eventId: inv.event_id || null,
customerAccountId: inv.customer_account_id,
dueDate: inv.due_date,
issueDate: inv.issue_date,
totalMinor: inv.total_amount_minor,
currency: inv.currency,
},
});
if (ids && ids.length) enrolled += 1;
}
} catch (e) {
logger.error('[workflow] dunning backfill failed', { error: e.message });
}
return enrolled;
}
/**
* Emit `event.date_approaching` for events entering an enabled flow's lead
* window. This is the trigger source for the pre-event reminder built-in, so it
@@ -545,6 +588,7 @@ async function testRun(workflowId, { entityType = null, entityId = null, payload
module.exports = {
emitWorkflowEvent,
isBuiltinFlowActive,
backfillDunningRuns,
runDueWaits,
emitDueEventReminders,
recoverStaleRuns,