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
+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) {