feat(workflows): collections-handoff block after dunning exhausts
New escalate_to_collections action: when the 3-reminder loop ends still unpaid, consolidate ONE email to the admin — customer data, outstanding (invoice + late fees − paid), and the invoice PDF attached — ready to forward to an Inkasso agency / for Betreibung. Internal mail, sent immediately; does NOT touch the invoice. New invoice_collections_handoff email template (en + native de, seeded by the boot self-heal). Wired into the built-in dunning flow: loop 'exit' → collections → end (seed v4, re-seeds the disabled built-in). Selectable + labelled in the canvas editor. Tests 9/9, tsc 0, build green.
This commit is contained in:
@@ -243,12 +243,13 @@ 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(3);
|
expect(JSON.parse(wf.trigger_config).seedVersion).toBe(4);
|
||||||
|
|
||||||
const nodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: wf.version });
|
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.some((n) => n.type === 'gate')).toBe(false); // payment-check email IS the gate
|
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);
|
expect(nodes.some((n) => JSON.parse(n.config || '{}').action === 'queue_payment_check')).toBe(true);
|
||||||
|
expect(nodes.some((n) => JSON.parse(n.config || '{}').action === 'escalate_to_collections')).toBe(true);
|
||||||
|
|
||||||
await seedBuiltinWorkflowsAtBoot(db, noopLogger); // idempotent at current seed version
|
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 });
|
||||||
@@ -267,7 +268,7 @@ describe('workflow engine', () => {
|
|||||||
await seedBuiltinWorkflowsAtBoot(db, noopLogger);
|
await seedBuiltinWorkflowsAtBoot(db, noopLogger);
|
||||||
const reseeded = await db('workflows').where({ id: wf.id }).first();
|
const reseeded = await db('workflows').where({ id: wf.id }).first();
|
||||||
expect(reseeded.version).toBe(wf.version + 1); // bumped
|
expect(reseeded.version).toBe(wf.version + 1); // bumped
|
||||||
expect(JSON.parse(reseeded.trigger_config).seedVersion).toBe(3);
|
expect(JSON.parse(reseeded.trigger_config).seedVersion).toBe(4);
|
||||||
const newNodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: reseeded.version });
|
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
|
expect(newNodes.some((n) => n.type === 'gate')).toBe(false); // legacy graph replaced
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ 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
|
// Bump when the built-in graph changes so a disabled, never-activated copy is
|
||||||
// re-seeded on boot. v2 = delegation/cutover graph; v3 = 3 reminder loops.
|
// re-seeded on boot. v2 = delegation/cutover graph; v3 = 3 reminder loops;
|
||||||
const SEED_VERSION = 3;
|
// v4 = collections handoff after the loop exhausts.
|
||||||
|
const SEED_VERSION = 4;
|
||||||
|
|
||||||
function buildDunningGraph({ firstDays, gapDays, maxReminders }) {
|
function buildDunningGraph({ firstDays, gapDays, maxReminders }) {
|
||||||
// Delegation model: the payment-check email IS the admin gate (it drives the
|
// Delegation model: the payment-check email IS the admin gate (it drives the
|
||||||
@@ -37,14 +38,16 @@ function buildDunningGraph({ firstDays, gapDays, maxReminders }) {
|
|||||||
{ node_key: 'paymentCheck', type: 'action', config: { action: 'queue_payment_check' }, pos_x: 240, pos_y: 550 },
|
{ 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: '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: '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 },
|
{ node_key: 'collections', type: 'action', config: { action: 'escalate_to_collections' }, pos_x: 520, pos_y: 250 },
|
||||||
|
{ node_key: 'doneEnd', type: 'action', config: { action: 'noop' }, pos_x: 760, pos_y: 250 },
|
||||||
];
|
];
|
||||||
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: 'loop' },
|
{ from_node: 'waitGrace', to_node: 'loop' },
|
||||||
{ from_node: 'loop', from_handle: 'loop', to_node: 'checkPaid' },
|
{ from_node: 'loop', from_handle: 'loop', to_node: 'checkPaid' },
|
||||||
{ from_node: 'loop', from_handle: 'exit', to_node: 'doneEnd' },
|
{ from_node: 'loop', from_handle: 'exit', to_node: 'collections' },
|
||||||
|
{ from_node: 'collections', 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: 'paymentCheck' },
|
{ from_node: 'checkPaid', from_handle: 'no', to_node: 'paymentCheck' },
|
||||||
{ from_node: 'paymentCheck', to_node: 'waitGap' },
|
{ from_node: 'paymentCheck', to_node: 'waitGap' },
|
||||||
|
|||||||
@@ -358,6 +358,70 @@ Rechnung {{invoice_number}} für {{customer_name}}{{#if event_name}} ({{event_na
|
|||||||
Erfasst am: {{paid_at}}
|
Erfasst am: {{paid_at}}
|
||||||
|
|
||||||
Automatische Benachrichtigung — keine Aktion erforderlich.`,
|
Automatische Benachrichtigung — keine Aktion erforderlich.`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
invoice_collections_handoff: {
|
||||||
|
category: 'billing', feature_flag: 'bills',
|
||||||
|
variables: ['invoice_number', 'customer_name', 'customer_email', 'customer_address', 'event_name', 'original_amount', 'late_fee_amount', 'paid_amount', 'outstanding_amount', 'due_date', 'reminder_level'],
|
||||||
|
en: {
|
||||||
|
subject: 'Collections handoff: invoice {{invoice_number}} still unpaid after dunning',
|
||||||
|
body_html: `<h2>Ready to hand to collections</h2>
|
||||||
|
<p>Invoice <strong>{{invoice_number}}</strong>{{#if event_name}} ({{event_name}}){{/if}} is still unpaid after {{reminder_level}} reminders. The invoice PDF is attached for forwarding.</p>
|
||||||
|
<table role="presentation" cellpadding="6" cellspacing="0" border="0" style="border-collapse: collapse; margin: 16px 0;">
|
||||||
|
<tr><td style="color:#666;">Customer</td><td><strong>{{customer_name}}</strong></td></tr>
|
||||||
|
{{#if customer_email}}<tr><td style="color:#666;">Email</td><td>{{customer_email}}</td></tr>{{/if}}
|
||||||
|
{{#if customer_address}}<tr><td style="color:#666;">Address</td><td>{{customer_address}}</td></tr>{{/if}}
|
||||||
|
<tr><td style="color:#666;">Due date</td><td>{{due_date}}</td></tr>
|
||||||
|
<tr><td style="color:#666;">Original amount</td><td>{{original_amount}}</td></tr>
|
||||||
|
{{#if late_fee_amount}}<tr><td style="color:#666;">Late fees</td><td>{{late_fee_amount}}</td></tr>{{/if}}
|
||||||
|
<tr><td style="color:#666;">Paid</td><td>{{paid_amount}}</td></tr>
|
||||||
|
<tr><td style="color:#666;"><strong>Outstanding</strong></td><td><strong>{{outstanding_amount}}</strong></td></tr>
|
||||||
|
</table>
|
||||||
|
<p style="font-size:13px;color:#666;">Forward to your collections agency / for Betreibung. Automatic notification.</p>`,
|
||||||
|
body_text: `Ready to hand to collections
|
||||||
|
|
||||||
|
Invoice {{invoice_number}}{{#if event_name}} ({{event_name}}){{/if}} is still unpaid after {{reminder_level}} reminders. The invoice PDF is attached.
|
||||||
|
|
||||||
|
Customer: {{customer_name}}{{#if customer_email}}
|
||||||
|
Email: {{customer_email}}{{/if}}{{#if customer_address}}
|
||||||
|
Address: {{customer_address}}{{/if}}
|
||||||
|
Due date: {{due_date}}
|
||||||
|
Original amount: {{original_amount}}{{#if late_fee_amount}}
|
||||||
|
Late fees: {{late_fee_amount}}{{/if}}
|
||||||
|
Paid: {{paid_amount}}
|
||||||
|
Outstanding: {{outstanding_amount}}
|
||||||
|
|
||||||
|
Forward to your collections agency / for Betreibung.`,
|
||||||
|
},
|
||||||
|
de: {
|
||||||
|
subject: 'Inkasso-Übergabe: Rechnung {{invoice_number}} trotz Mahnungen offen',
|
||||||
|
body_html: `<h2>Bereit zur Inkasso-Übergabe</h2>
|
||||||
|
<p>Rechnung <strong>{{invoice_number}}</strong>{{#if event_name}} ({{event_name}}){{/if}} ist nach {{reminder_level}} Mahnungen weiterhin offen. Das Rechnungs-PDF ist zur Weiterleitung angehängt.</p>
|
||||||
|
<table role="presentation" cellpadding="6" cellspacing="0" border="0" style="border-collapse: collapse; margin: 16px 0;">
|
||||||
|
<tr><td style="color:#666;">Kunde</td><td><strong>{{customer_name}}</strong></td></tr>
|
||||||
|
{{#if customer_email}}<tr><td style="color:#666;">E-Mail</td><td>{{customer_email}}</td></tr>{{/if}}
|
||||||
|
{{#if customer_address}}<tr><td style="color:#666;">Adresse</td><td>{{customer_address}}</td></tr>{{/if}}
|
||||||
|
<tr><td style="color:#666;">Fälligkeit</td><td>{{due_date}}</td></tr>
|
||||||
|
<tr><td style="color:#666;">Rechnungsbetrag</td><td>{{original_amount}}</td></tr>
|
||||||
|
{{#if late_fee_amount}}<tr><td style="color:#666;">Mahngebühren</td><td>{{late_fee_amount}}</td></tr>{{/if}}
|
||||||
|
<tr><td style="color:#666;">Bezahlt</td><td>{{paid_amount}}</td></tr>
|
||||||
|
<tr><td style="color:#666;"><strong>Offen</strong></td><td><strong>{{outstanding_amount}}</strong></td></tr>
|
||||||
|
</table>
|
||||||
|
<p style="font-size:13px;color:#666;">Zur Weiterleitung an das Inkasso / für die Betreibung. Automatische Benachrichtigung.</p>`,
|
||||||
|
body_text: `Bereit zur Inkasso-Übergabe
|
||||||
|
|
||||||
|
Rechnung {{invoice_number}}{{#if event_name}} ({{event_name}}){{/if}} ist nach {{reminder_level}} Mahnungen weiterhin offen. Das Rechnungs-PDF ist angehängt.
|
||||||
|
|
||||||
|
Kunde: {{customer_name}}{{#if customer_email}}
|
||||||
|
E-Mail: {{customer_email}}{{/if}}{{#if customer_address}}
|
||||||
|
Adresse: {{customer_address}}{{/if}}
|
||||||
|
Fälligkeit: {{due_date}}
|
||||||
|
Rechnungsbetrag: {{original_amount}}{{#if late_fee_amount}}
|
||||||
|
Mahngebühren: {{late_fee_amount}}{{/if}}
|
||||||
|
Bezahlt: {{paid_amount}}
|
||||||
|
Offen: {{outstanding_amount}}
|
||||||
|
|
||||||
|
Zur Weiterleitung an das Inkasso / für die Betreibung.`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -79,6 +79,59 @@ registry.registerAction('queue_payment_check', async (ctx) => {
|
|||||||
return { payment_check_queued: id };
|
return { payment_check_queued: id };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// After the dunning loop exhausts (e.g. 3 unpaid reminders), consolidate
|
||||||
|
// everything collections needs into ONE email to the admin: customer data, the
|
||||||
|
// outstanding total (invoice + late fees − paid) and the invoice PDF attached —
|
||||||
|
// ready to forward to an Inkasso agency / for Betreibung. Internal mail → sent
|
||||||
|
// immediately. Does NOT touch the invoice.
|
||||||
|
registry.registerAction('escalate_to_collections', async (ctx) => {
|
||||||
|
const id = ctx.run.entity_id;
|
||||||
|
if (!id) return { skipped: true, reason: 'no invoice entity' };
|
||||||
|
const { db } = ctx;
|
||||||
|
const invoice = await db('invoices').where({ id }).first();
|
||||||
|
if (!invoice) return { skipped: true, reason: 'invoice not found' };
|
||||||
|
const customer = invoice.customer_account_id
|
||||||
|
? await db('customer_accounts').where({ id: invoice.customer_account_id }).first()
|
||||||
|
: null;
|
||||||
|
const profile = await db('business_profile').where({ id: 1 }).first();
|
||||||
|
const adminEmail = ctx.vars?.adminEmail || profile?.email || null;
|
||||||
|
if (!adminEmail) return { skipped: true, reason: 'no admin email' };
|
||||||
|
|
||||||
|
const currency = invoice.currency || 'CHF';
|
||||||
|
const fmt = (m) => `${currency} ${(Number(m || 0) / 100).toFixed(2)}`;
|
||||||
|
const total = Number(invoice.total_amount_minor || 0);
|
||||||
|
const fee = Number(invoice.late_fee_amount_minor || 0);
|
||||||
|
const paid = Number(invoice.paid_amount_minor || 0);
|
||||||
|
const outstanding = Math.max(0, total + fee - paid);
|
||||||
|
const address = [customer?.address, customer?.postal_code, customer?.city, customer?.country_name]
|
||||||
|
.filter(Boolean).join(', ');
|
||||||
|
|
||||||
|
const attachments = [];
|
||||||
|
try {
|
||||||
|
const fs = require('fs');
|
||||||
|
if (invoice.pdf_path && fs.existsSync(invoice.pdf_path)) {
|
||||||
|
attachments.push({ filename: `${invoice.invoice_number}.pdf`, contentPath: invoice.pdf_path, contentType: 'application/pdf' });
|
||||||
|
}
|
||||||
|
} catch (_) { /* attachment is best-effort */ }
|
||||||
|
|
||||||
|
await require('../emailProcessor').queueEmail(invoice.event_id || null, adminEmail, 'invoice_collections_handoff', {
|
||||||
|
invoice_number: invoice.invoice_number,
|
||||||
|
customer_name: customer?.display_name || customer?.email || '—',
|
||||||
|
customer_email: customer?.email || '',
|
||||||
|
customer_address: address,
|
||||||
|
event_name: invoice.event_name || '',
|
||||||
|
original_amount: fmt(total),
|
||||||
|
late_fee_amount: fee ? fmt(fee) : '',
|
||||||
|
paid_amount: fmt(paid),
|
||||||
|
outstanding_amount: fmt(outstanding),
|
||||||
|
due_date: invoice.due_date ? String(invoice.due_date).slice(0, 10) : '',
|
||||||
|
reminder_level: invoice.reminder_level || 0,
|
||||||
|
attachments,
|
||||||
|
}, { respectBusinessHours: false }); // internal/admin → immediate
|
||||||
|
|
||||||
|
return { collections_handoff_to: adminEmail, outstanding };
|
||||||
|
});
|
||||||
|
|
||||||
// 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) {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const lbl = 'block text-xs text-neutral-500 dark:text-neutral-400 mb-1';
|
|||||||
|
|
||||||
const ACTIONS = [
|
const ACTIONS = [
|
||||||
['queue_payment_check', 'Send payment-check email (dunning gate)'],
|
['queue_payment_check', 'Send payment-check email (dunning gate)'],
|
||||||
|
['escalate_to_collections', 'Hand off to collections (email admin)'],
|
||||||
['send_email', 'Send email'],
|
['send_email', 'Send email'],
|
||||||
['reserve_date', 'Reserve the event date'],
|
['reserve_date', 'Reserve the event date'],
|
||||||
['prepare_quote', 'Prepare a quote (draft)'],
|
['prepare_quote', 'Prepare a quote (draft)'],
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ const SOURCE_HANDLES: Record<string, string[]> = {
|
|||||||
|
|
||||||
const WAIT_ANCHOR_LABEL: Record<string, string> = { dueDate: 'due date', issueDate: 'invoice date', eventDate: 'event date' };
|
const WAIT_ANCHOR_LABEL: Record<string, string> = { dueDate: 'due date', issueDate: 'invoice date', eventDate: 'event date' };
|
||||||
const ACTION_LABEL: Record<string, string> = {
|
const ACTION_LABEL: Record<string, string> = {
|
||||||
queue_payment_check: 'Send payment-check email', send_email: 'Send email', reserve_date: 'Reserve the date',
|
queue_payment_check: 'Send payment-check email', escalate_to_collections: 'Collections handoff', send_email: 'Send email', reserve_date: 'Reserve the date',
|
||||||
prepare_quote: 'Prepare quote', prepare_contract: 'Prepare contract', prepare_invoice: 'Prepare invoice',
|
prepare_quote: 'Prepare quote', prepare_contract: 'Prepare contract', prepare_invoice: 'Prepare invoice',
|
||||||
prepare_event: 'Create event', prepare_gallery: 'Create gallery', send_document: 'Send document',
|
prepare_event: 'Create event', prepare_gallery: 'Create gallery', send_document: 'Send document',
|
||||||
webhook: 'Call webhook', noop: 'Do nothing', set_context: 'Set value',
|
webhook: 'Call webhook', noop: 'Do nothing', set_context: 'Set value',
|
||||||
|
|||||||
Reference in New Issue
Block a user