diff --git a/backend/__tests__/integration/workflowEngine.test.js b/backend/__tests__/integration/workflowEngine.test.js index 06b2ee6d..dd796db1 100644 --- a/backend/__tests__/integration/workflowEngine.test.js +++ b/backend/__tests__/integration/workflowEngine.test.js @@ -243,12 +243,13 @@ describe('workflow engine', () => { expect(wf).toBeTruthy(); expect(!!wf.is_builtin).toBe(true); 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 }); 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) => 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 const all = await db('workflows').where({ builtin_key: DUNNING_KEY }); @@ -267,7 +268,7 @@ describe('workflow engine', () => { await seedBuiltinWorkflowsAtBoot(db, noopLogger); const reseeded = await db('workflows').where({ id: wf.id }).first(); 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 }); expect(newNodes.some((n) => n.type === 'gate')).toBe(false); // legacy graph replaced diff --git a/backend/src/services/_workflowSeedBoot.js b/backend/src/services/_workflowSeedBoot.js index d6200a0a..4f33c9a4 100644 --- a/backend/src/services/_workflowSeedBoot.js +++ b/backend/src/services/_workflowSeedBoot.js @@ -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' }, diff --git a/backend/src/services/crmEmailTemplates.js b/backend/src/services/crmEmailTemplates.js index 2114fdb2..84572e24 100644 --- a/backend/src/services/crmEmailTemplates.js +++ b/backend/src/services/crmEmailTemplates.js @@ -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: `
Invoice {{invoice_number}}{{#if event_name}} ({{event_name}}){{/if}} is still unpaid after {{reminder_level}} reminders. The invoice PDF is attached for forwarding.
+| Customer | {{customer_name}} |
| {{customer_email}} | |
| Address | {{customer_address}} |
| Due date | {{due_date}} |
| Original amount | {{original_amount}} |
| Late fees | {{late_fee_amount}} |
| Paid | {{paid_amount}} |
| Outstanding | {{outstanding_amount}} |
Forward to your collections agency / for Betreibung. Automatic notification.
`, + 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: `Rechnung {{invoice_number}}{{#if event_name}} ({{event_name}}){{/if}} ist nach {{reminder_level}} Mahnungen weiterhin offen. Das Rechnungs-PDF ist zur Weiterleitung angehängt.
+| Kunde | {{customer_name}} |
| {{customer_email}} | |
| Adresse | {{customer_address}} |
| Fälligkeit | {{due_date}} |
| Rechnungsbetrag | {{original_amount}} |
| Mahngebühren | {{late_fee_amount}} |
| Bezahlt | {{paid_amount}} |
| Offen | {{outstanding_amount}} |
Zur Weiterleitung an das Inkasso / für die Betreibung. Automatische Benachrichtigung.
`, + 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.`, }, }, }; diff --git a/backend/src/services/workflows/actions.js b/backend/src/services/workflows/actions.js index 37388499..5d4b6eb0 100644 --- a/backend/src/services/workflows/actions.js +++ b/backend/src/services/workflows/actions.js @@ -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) { diff --git a/frontend/src/pages/admin/workflows/NodeConfigPanel.tsx b/frontend/src/pages/admin/workflows/NodeConfigPanel.tsx index ea0406af..2dc7a183 100644 --- a/frontend/src/pages/admin/workflows/NodeConfigPanel.tsx +++ b/frontend/src/pages/admin/workflows/NodeConfigPanel.tsx @@ -20,6 +20,7 @@ const lbl = 'block text-xs text-neutral-500 dark:text-neutral-400 mb-1'; const ACTIONS = [ ['queue_payment_check', 'Send payment-check email (dunning gate)'], + ['escalate_to_collections', 'Hand off to collections (email admin)'], ['send_email', 'Send email'], ['reserve_date', 'Reserve the event date'], ['prepare_quote', 'Prepare a quote (draft)'], diff --git a/frontend/src/pages/admin/workflows/WorkflowEditorPage.tsx b/frontend/src/pages/admin/workflows/WorkflowEditorPage.tsx index 8d68f089..2ef79c3e 100644 --- a/frontend/src/pages/admin/workflows/WorkflowEditorPage.tsx +++ b/frontend/src/pages/admin/workflows/WorkflowEditorPage.tsx @@ -40,7 +40,7 @@ const SOURCE_HANDLES: Record