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:
Luca
2026-06-23 12:37:50 +02:00
parent dcdbeb9cc5
commit b78bd979dc
6 changed files with 129 additions and 7 deletions
+7 -4
View File
@@ -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' },
+64
View File
@@ -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.`,
},
},
};
+53
View File
@@ -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) {