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:
@@ -19,8 +19,9 @@ 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 = delegation/cutover graph; v3 = 3 reminder loops.
|
||||
const SEED_VERSION = 3;
|
||||
// re-seeded on boot. v2 = delegation/cutover graph; v3 = 3 reminder loops;
|
||||
// v4 = collections handoff after the loop exhausts.
|
||||
const SEED_VERSION = 4;
|
||||
|
||||
function buildDunningGraph({ firstDays, gapDays, maxReminders }) {
|
||||
// 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: '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 },
|
||||
{ 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 = [
|
||||
{ from_node: 't', to_node: 'waitDue' },
|
||||
{ from_node: 'waitDue', to_node: 'waitGrace' },
|
||||
{ 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: '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: 'no', to_node: 'paymentCheck' },
|
||||
{ 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}}
|
||||
|
||||
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 };
|
||||
});
|
||||
|
||||
// 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
|
||||
// valid; service wiring is a follow-up. Records a skipped step (observable).
|
||||
for (const key of DOCUMENT_ACTIONS) {
|
||||
|
||||
Reference in New Issue
Block a user