@@ -230,7 +260,7 @@ export const WorkflowEditorPage: React.FC = () => {
colorMode={isDark ? 'dark' : 'light'}
nodes={nodes} edges={edges} nodeTypes={nodeTypes}
onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onConnect={onConnect}
- onNodeClick={(_, n) => setSelectedId(n.id)} fitView
+ onNodeClick={(_, n) => setSelectedId(n.id)} onInit={(inst) => { rfRef.current = inst; }} fitView
>
From 289568fd52fcf0f33a959f074d215853ae37e48c Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Tue, 23 Jun 2026 12:09:18 +0200
Subject: [PATCH 16/43] =?UTF-8?q?feat(workflows):=20advanced=20text=20mode?=
=?UTF-8?q?=20=E2=80=94=20export/import=20the=20flow=20as=20JSON?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A 'Text' toggle in the editor toolbar swaps the canvas for the whole flow as
pretty JSON ({name, trigger_type, enabled, nodes, edges}). Copy it to share or
hand to an LLM, or paste a flow and 'Load into editor' (validates parse + one
trigger; backend re-validates on Save). Imported nodes land at 0,0 — one
'Clean up layout' click arranges them. en + native de.
---
frontend/src/i18n/locales/de.json | 7 ++
frontend/src/i18n/locales/en.json | 7 ++
.../admin/workflows/WorkflowEditorPage.tsx | 68 ++++++++++++++++++-
3 files changed, 80 insertions(+), 2 deletions(-)
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json
index 05c92f2d..2db8a319 100644
--- a/frontend/src/i18n/locales/de.json
+++ b/frontend/src/i18n/locales/de.json
@@ -230,6 +230,13 @@
"namePlaceholder": "Workflow-Name",
"when": "Wenn",
"cleanUp": "Layout aufräumen",
+ "textView": "Text",
+ "canvasView": "Canvas",
+ "loadText": "In Editor laden",
+ "textLoaded": "Geladen – prüfen und speichern",
+ "textNeedsArrays": "Benötigt „nodes“- und „edges“-Arrays",
+ "textNeedsTrigger": "Benötigt genau einen Trigger-Knoten",
+ "textHint": "Der gesamte Ablauf als JSON – zum Teilen kopieren oder an ein LLM geben, oder einen Ablauf einfügen und in den Editor laden. Nach dem Import „Layout aufräumen“ klicken.",
"saved": "Workflow gespeichert",
"saveFailed": "Speichern fehlgeschlagen",
"badJson": "Konfiguration ist kein gültiges JSON",
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index 357dcd0c..e45373f7 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -230,6 +230,13 @@
"namePlaceholder": "Workflow name",
"when": "When",
"cleanUp": "Clean up layout",
+ "textView": "Text",
+ "canvasView": "Canvas",
+ "loadText": "Load into editor",
+ "textLoaded": "Loaded — review and Save",
+ "textNeedsArrays": "Needs \"nodes\" and \"edges\" arrays",
+ "textNeedsTrigger": "Needs exactly one trigger node",
+ "textHint": "The whole flow as JSON — copy it to share or hand to an LLM, or paste a flow and load it into the editor. Click “Clean up layout” after importing.",
"saved": "Workflow saved",
"saveFailed": "Could not save",
"badJson": "Config is not valid JSON",
diff --git a/frontend/src/pages/admin/workflows/WorkflowEditorPage.tsx b/frontend/src/pages/admin/workflows/WorkflowEditorPage.tsx
index 95f87d9f..8d68f089 100644
--- a/frontend/src/pages/admin/workflows/WorkflowEditorPage.tsx
+++ b/frontend/src/pages/admin/workflows/WorkflowEditorPage.tsx
@@ -18,7 +18,7 @@ import {
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import dagre from '@dagrejs/dagre';
-import { ArrowLeft, Save, Trash2, Wand2 } from 'lucide-react';
+import { ArrowLeft, Save, Trash2, Wand2, Code } from 'lucide-react';
import { Button, Loading } from '../../../components/common';
import { useAdminDarkMode } from '../../../contexts/AdminDarkModeContext';
import { workflowsService, type WorkflowNodeType } from '../../../services/workflows.service';
@@ -136,6 +136,47 @@ export const WorkflowEditorPage: React.FC = () => {
setTimeout(() => rfRef.current?.fitView?.({ padding: 0.2, duration: 300 }), 60);
}, [edges, setNodes]);
+ // --- Advanced text mode: the whole flow as JSON (share / LLM round-trip) ---
+ const [textMode, setTextMode] = useState(false);
+ const [text, setText] = useState('');
+ const [textErr, setTextErr] = useState(null);
+
+ const serializeFlow = () => JSON.stringify({
+ name,
+ trigger_type: triggerType,
+ enabled,
+ nodes: nodes.map((n) => ({
+ node_key: n.id, type: (n.data as any).nodeType, config: (n.data as any).config || {},
+ pos_x: Math.round(n.position.x), pos_y: Math.round(n.position.y),
+ })),
+ edges: edges.map((e) => ({ from_node: e.source, from_handle: e.sourceHandle || null, to_node: e.target })),
+ }, null, 2);
+
+ const openText = () => { setText(serializeFlow()); setTextErr(null); setTextMode(true); };
+
+ const applyText = () => {
+ let p: any;
+ try { p = JSON.parse(text); } catch (e) { setTextErr(t('workflows.editor.badJson', 'Config is not valid JSON') as string); return; }
+ if (!Array.isArray(p.nodes) || !Array.isArray(p.edges)) { setTextErr(t('workflows.editor.textNeedsArrays', 'Needs "nodes" and "edges" arrays') as string); return; }
+ if (p.nodes.filter((n: any) => n.type === 'trigger').length !== 1) { setTextErr(t('workflows.editor.textNeedsTrigger', 'Needs exactly one trigger node') as string); return; }
+ const tt = p.trigger_type || triggerType;
+ if (p.name != null) setName(p.name);
+ if (p.trigger_type) setTriggerType(p.trigger_type);
+ if (p.enabled != null) setEnabled(!!p.enabled);
+ setNodes(p.nodes.map((n: any) => ({
+ id: n.node_key, type: 'wf', position: { x: n.pos_x || 0, y: n.pos_y || 0 },
+ data: { nodeType: n.type, config: n.config || {}, triggerType: tt },
+ })));
+ setEdges(p.edges.map((e: any, i: number) => ({
+ id: `e${i}`, source: e.from_node, target: e.to_node, sourceHandle: e.from_handle || undefined, label: e.from_handle || undefined,
+ })));
+ setTextErr(null);
+ setTextMode(false);
+ toast.success(t('workflows.editor.textLoaded', 'Loaded — review and Save') as string);
+ };
+
+ const copyText = () => { navigator.clipboard?.writeText(text); toast.success(t('common.copied', 'Copied') as string); };
+
useEffect(() => {
if (!workflow) return;
setName(workflow.name);
@@ -230,13 +271,17 @@ export const WorkflowEditorPage: React.FC = () => {
setEnabled(e.target.checked)} />
{t('workflows.enabled', 'Enabled')}
-
+
+
+ {!textMode && (
{PALETTE.map((type) => (
+ )}
+ {textMode ? (
+
+
+ {t('workflows.editor.textHint', 'The whole flow as JSON — copy it to share or hand to an LLM, or paste a flow and load it into the editor. Click “Clean up layout” after importing.')}
+
+
+ ) : (
{
)}
+ )}
);
};
From dcdbeb9cc50a888ff66e65c62597fcc84c2c8f17 Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Tue, 23 Jun 2026 12:26:32 +0200
Subject: [PATCH 17/43] =?UTF-8?q?feat(crm):=203-reminder=20dunning=20+=20f?=
=?UTF-8?q?lat/percent=20Mahngeb=C3=BChr=20on=202nd=20&=203rd=20+=20AGB=20?=
=?UTF-8?q?notice?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Late fee can now be a FLAT amount OR a PERCENTAGE of the invoice gross
(crm_invoices_late_fee_type/_percent, migration 143; defaults preserve the
current flat behaviour).
- Fee is charged from the 2nd reminder onward and accumulates per fee-bearing
reminder (2nd = 1×, 3rd = 2×), computed from the level so re-applying a level
never stacks. New resolvePerReminderFeeMinor() shared by applyReminder + the
payment-check fee preview.
- Reminder ladder extended to 3 levels (caps raised in sendReminder +
recordPaymentCheckAction); the built-in dunning flow now loops 3× (seed v3,
re-seeds the disabled built-in on boot).
- Settings UI: flat/percent toggle + percent field, and a prominent AGB
callout — a late fee is only enforceable if the concrete amount is stated in
the terms (Mara's wording), 'verify with your Treuhänder'. en + native de.
The fee math is examples-only / Treuhänder-verify; issued invoices stay
immutable (the fee is tracked in late_fee_amount_minor, not folded into the
original total). Tests 17/17, tsc 0, build green.
---
.../integration/workflowEngine.test.js | 4 +--
.../migrations/core/143_seed_late_fee_type.js | 34 ++++++++++++++++++
backend/src/services/_workflowSeedBoot.js | 6 ++--
backend/src/services/invoiceService.js | 36 ++++++++++++-------
frontend/src/i18n/locales/de.json | 14 ++++++++
frontend/src/i18n/locales/en.json | 14 ++++++++
.../pages/admin/settings/CrmSettingsPage.tsx | 36 ++++++++++++++++---
7 files changed, 122 insertions(+), 22 deletions(-)
create mode 100644 backend/migrations/core/143_seed_late_fee_type.js
diff --git a/backend/__tests__/integration/workflowEngine.test.js b/backend/__tests__/integration/workflowEngine.test.js
index 5440bf14..06b2ee6d 100644
--- a/backend/__tests__/integration/workflowEngine.test.js
+++ b/backend/__tests__/integration/workflowEngine.test.js
@@ -243,7 +243,7 @@ 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(2);
+ expect(JSON.parse(wf.trigger_config).seedVersion).toBe(3);
const nodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: wf.version });
expect(nodes.filter((n) => n.type === 'trigger')).toHaveLength(1);
@@ -267,7 +267,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(2);
+ expect(JSON.parse(reseeded.trigger_config).seedVersion).toBe(3);
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/migrations/core/143_seed_late_fee_type.js b/backend/migrations/core/143_seed_late_fee_type.js
new file mode 100644
index 00000000..49b93582
--- /dev/null
+++ b/backend/migrations/core/143_seed_late_fee_type.js
@@ -0,0 +1,34 @@
+/**
+ * Migration 143: late-fee (Mahngebühr) type — flat amount OR percentage.
+ *
+ * Extends the existing flat `crm_invoices_late_fee_minor` with a type switch so
+ * the dunning fee can be a percentage of the invoice gross instead of a fixed
+ * amount. The fee is charged from the 2nd reminder onwards (the 1st is
+ * fee-free), accumulating per fee-bearing reminder (2nd = 1×, 3rd = 2×).
+ *
+ * Seeds conservative defaults that PRESERVE current behaviour: type='flat'
+ * (so the existing flat fee keeps applying) and percent=0. Idempotent —
+ * only inserts keys that don't already exist, never clobbers an admin value.
+ *
+ * ⚠️ A late fee is only legally enforceable if the concrete amount is stated in
+ * the AGB (Liechtenstein/Swiss law) — the admin UI surfaces this; verify with a
+ * Treuhänder. See docs/crm-disclaimers / [[feedback_legal_financial_examples_only]].
+ */
+exports.up = async function (knex) {
+ if (!(await knex.schema.hasTable('app_settings'))) return;
+ const seeds = [
+ { setting_key: 'crm_invoices_late_fee_type', setting_value: JSON.stringify('flat'), setting_type: 'crm' },
+ { setting_key: 'crm_invoices_late_fee_percent', setting_value: JSON.stringify(0), setting_type: 'crm' },
+ ];
+ for (const s of seeds) {
+ const exists = await knex('app_settings').where({ setting_key: s.setting_key }).first();
+ if (!exists) await knex('app_settings').insert({ ...s, updated_at: new Date() });
+ }
+};
+
+exports.down = async function (knex) {
+ if (!(await knex.schema.hasTable('app_settings'))) return;
+ await knex('app_settings')
+ .whereIn('setting_key', ['crm_invoices_late_fee_type', 'crm_invoices_late_fee_percent'])
+ .del();
+};
diff --git a/backend/src/services/_workflowSeedBoot.js b/backend/src/services/_workflowSeedBoot.js
index a6a70cc9..d6200a0a 100644
--- a/backend/src/services/_workflowSeedBoot.js
+++ b/backend/src/services/_workflowSeedBoot.js
@@ -19,8 +19,8 @@ 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 = the delegation/cutover graph (payment-check gate).
-const SEED_VERSION = 2;
+// re-seeded on boot. v2 = delegation/cutover graph; v3 = 3 reminder loops.
+const SEED_VERSION = 3;
function buildDunningGraph({ firstDays, gapDays, maxReminders }) {
// Delegation model: the payment-check email IS the admin gate (it drives the
@@ -83,7 +83,7 @@ async function seedBuiltinWorkflowsAtBoot(db, logger) {
const firstDays = Number(await getAppSetting('crm_invoices_reminder_first_days')) || 14;
const secondDays = Number(await getAppSetting('crm_invoices_reminder_second_days')) || 30;
const gapDays = Math.max(1, secondDays - firstDays);
- const { nodes, edges } = buildDunningGraph({ firstDays, gapDays, maxReminders: 2 });
+ const { nodes, edges } = buildDunningGraph({ firstDays, gapDays, maxReminders: 3 });
const description = 'Drives overdue dunning through the engine: wait to the due date, then up '
+ 'to two payment-check cycles. Each cycle fires the existing admin confirm-payment email '
diff --git a/backend/src/services/invoiceService.js b/backend/src/services/invoiceService.js
index b779338b..a5d098cd 100644
--- a/backend/src/services/invoiceService.js
+++ b/backend/src/services/invoiceService.js
@@ -2642,21 +2642,34 @@ async function sendReminder(id, levelOverride, adminId) {
throw new AppError(`Cannot remind on status '${invoice.status}'`, 409);
}
const newLevel = levelOverride || (invoice.reminder_level + 1);
- if (newLevel > 2) {
+ if (newLevel > 3) {
throw new AppError('Reminder level exhausted', 409);
}
return await applyReminder(invoice, lineItems, newLevel, adminId);
}
+// Per-reminder Mahngebühr in minor units (0 when disabled). Flat amount OR a
+// percentage of the invoice gross, per crm_invoices_late_fee_type. Charged from
+// the 2nd reminder onwards. ⚠️ A late fee is only enforceable if the concrete
+// amount is stated in the AGB — verify with a Treuhänder (the admin UI says so).
+async function resolvePerReminderFeeMinor(invoice) {
+ if ((await getAppSetting('crm_invoices_late_fee_enabled')) === false) return 0;
+ const type = (await getAppSetting('crm_invoices_late_fee_type')) || 'flat';
+ if (type === 'percent') {
+ const pct = Number(await getAppSetting('crm_invoices_late_fee_percent')) || 0;
+ return Math.max(0, Math.round(Number(invoice.total_amount_minor || 0) * pct / 100));
+ }
+ return Math.max(0, ensureInt(await getAppSetting('crm_invoices_late_fee_minor')) || 2500);
+}
+
async function applyReminder(invoice, lineItems, level, adminId) {
const customer = await db('customer_accounts').where({ id: invoice.customer_account_id }).first();
let lateFeeMinor = invoice.late_fee_amount_minor || 0;
- if (level === 2) {
- const enabled = await getAppSetting('crm_invoices_late_fee_enabled');
- if (enabled !== false) {
- const fee = ensureInt(await getAppSetting('crm_invoices_late_fee_minor')) || 2500;
- lateFeeMinor = fee;
- }
+ if (level >= 2) {
+ const perReminder = await resolvePerReminderFeeMinor(invoice);
+ // One fee per fee-bearing reminder (levels 2..level): 2nd = 1×, 3rd = 2×.
+ // Computed from `level` so re-applying the same level never stacks.
+ lateFeeMinor = (level - 1) * perReminder;
}
const newTotal = invoice.total_amount_minor + lateFeeMinor;
@@ -2909,10 +2922,9 @@ async function queuePaymentCheckEmail(invoiceId, { skipThrottle = false } = {})
// Determine whether the customer reminder will include a Mahngebühr
// if the admin selects "Not paid" / "Partial" — surfaced to the
// email so the admin sees the consequence before clicking.
- const reminderLateFeeEnabled = (await getAppSetting('crm_invoices_late_fee_enabled')) !== false;
- const reminderFeeMinor = ensureInt(await getAppSetting('crm_invoices_late_fee_minor')) || 2500;
+ const reminderFeeMinor = await resolvePerReminderFeeMinor(invoice);
const nextLevel = (invoice.reminder_level || 0) + 1;
- const willChargeFee = reminderLateFeeEnabled && nextLevel >= 2;
+ const willChargeFee = reminderFeeMinor > 0 && nextLevel >= 2;
const baseUrl = process.env.FRONTEND_URL
|| (await getAppSetting('app_frontend_url'))
@@ -3163,7 +3175,7 @@ async function recordPaymentCheckAction({ token, action, amountMinor, ip, adminI
const refreshed = await db('invoices').where({ id: invoice.id }).first();
if (refreshed.status !== 'paid') {
const nextLevel = (refreshed.reminder_level || 0) + 1;
- if (nextLevel <= 2) {
+ if (nextLevel <= 3) {
const lineItems = await db('invoice_line_items')
.where({ invoice_id: invoice.id }).orderBy('position', 'asc');
await applyReminder(refreshed, lineItems, nextLevel, adminId);
@@ -3174,7 +3186,7 @@ async function recordPaymentCheckAction({ token, action, amountMinor, ip, adminI
// 'unpaid'
const nextLevel = (invoice.reminder_level || 0) + 1;
- if (nextLevel > 2) {
+ if (nextLevel > 3) {
// Already at max reminder — admin has to take this offline.
return { applied: 'unpaid', reminderSkipped: 'max_level_reached' };
}
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json
index 2db8a319..5ecea9f6 100644
--- a/frontend/src/i18n/locales/de.json
+++ b/frontend/src/i18n/locales/de.json
@@ -4898,6 +4898,20 @@
"crm_invoices_late_fee_minor": {
"label": "Mahngebühr (Rappen / Cent)"
},
+ "crm_invoices_late_fee_type": {
+ "label": "Art der Mahngebühr"
+ },
+ "crm_invoices_late_fee_percent": {
+ "label": "Mahngebühr (% der Rechnung)"
+ },
+ "lateFeeType": {
+ "flat": "Fester Betrag (Rappen)",
+ "percent": "Prozentsatz der Rechnung"
+ },
+ "lateFeeAgb": {
+ "title": "Mahngebühren müssen in den AGB stehen",
+ "body": "Vertragliche Pflicht: Sätze wie „Es werden Mahnspesen erhoben“ reichen nicht aus. In den AGB muss die konkrete Gebühr klar beziffert sein (z.B. „CHF 20 ab der 2. Mahnung“). Mit dem Treuhänder prüfen."
+ },
"crm_invoices_late_fee_label": {
"label": "Bezeichnung Mahngebühr"
},
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index e45373f7..49948527 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -4896,6 +4896,20 @@
"crm_invoices_late_fee_minor": {
"label": "Late fee (minor units / Rappen)"
},
+ "crm_invoices_late_fee_type": {
+ "label": "Late fee type"
+ },
+ "crm_invoices_late_fee_percent": {
+ "label": "Late fee (% of invoice)"
+ },
+ "lateFeeType": {
+ "flat": "Flat amount (Rappen)",
+ "percent": "Percentage of invoice"
+ },
+ "lateFeeAgb": {
+ "title": "Late fees must be itemised in your terms (AGB)",
+ "body": "A contractual duty: phrases like “late fees apply” aren't enough. Your terms must state the concrete fee (e.g. “CHF 20 from the 2nd reminder”). Verify with your Treuhänder."
+ },
"crm_invoices_late_fee_label": {
"label": "Late fee label"
},
diff --git a/frontend/src/pages/admin/settings/CrmSettingsPage.tsx b/frontend/src/pages/admin/settings/CrmSettingsPage.tsx
index f54134a9..28bb45f3 100644
--- a/frontend/src/pages/admin/settings/CrmSettingsPage.tsx
+++ b/frontend/src/pages/admin/settings/CrmSettingsPage.tsx
@@ -31,7 +31,9 @@ const SETTING_KEYS = [
'crm_invoices_reminder_first_days',
'crm_invoices_reminder_second_days',
'crm_invoices_late_fee_enabled',
+ 'crm_invoices_late_fee_type',
'crm_invoices_late_fee_minor',
+ 'crm_invoices_late_fee_percent',
'crm_invoices_late_fee_label',
'crm_invoices_skonto_business_days',
'crm_invoices_skonto_percent_default',
@@ -242,7 +244,11 @@ export const CrmSettingsPage: React.FC = () => {
{t('crmSettings.section.invoices', 'Invoices')}
{checkbox('crm_invoices_qr_enabled', 'Render payment QR on invoice PDFs')}
{checkbox('crm_invoices_reminders_enabled', 'Send automatic reminders for overdue invoices')}
- {checkbox('crm_invoices_late_fee_enabled', 'Add a late fee on the second reminder')}
+ {checkbox('crm_invoices_late_fee_enabled', 'Add a late fee (Mahngebühr) on the 2nd and 3rd reminder')}
+
+
{t('crmSettings.lateFeeAgb.title', 'Late fees must be itemised in your terms (AGB)')}
+
{t('crmSettings.lateFeeAgb.body', 'Vertragliche Pflicht: Sätze wie „Es werden Mahnspesen erhoben“ reichen nicht aus. In den AGB muss die konkrete Gebühr klar beziffert sein (z.B. „CHF 20 ab der 2. Mahnung“). Mit dem Treuhänder prüfen.')}
+
{
label={t('crmSettings.crm_invoices_reminder_second_days.label', 'Second reminder after (days past due)') as string}
value={values.crm_invoices_reminder_second_days ?? 30}
onChange={(e) => setVal('crm_invoices_reminder_second_days', Number(e.target.value))} />
- setVal('crm_invoices_late_fee_minor', Number(e.target.value))} />
+
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}}
+ {{#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. 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: `
Bereit zur Inkasso-Übergabe
+
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}}
+ {{#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. 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 = {
const WAIT_ANCHOR_LABEL: Record = { dueDate: 'due date', issueDate: 'invoice date', eventDate: 'event date' };
const ACTION_LABEL: Record = {
- 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_event: 'Create event', prepare_gallery: 'Create gallery', send_document: 'Send document',
webhook: 'Call webhook', noop: 'Do nothing', set_context: 'Set value',
From eaceb7e71caa6466d8298c0fc84487f0f4af1dca Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Tue, 23 Jun 2026 12:41:44 +0200
Subject: [PATCH 19/43] feat(crm): toggle for VAT on late fees
(jurisdiction-dependent)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Mahngebühr VAT differs by country (CH: liable; DE/AT: not), so it's now a
toggle (crm_invoices_late_fee_vat_enabled, seeded into migration 143 in place
since it isn't deployed yet — no compensation migration). When on, VAT is
added on top of the net fee at the org's default rate
(business_profile.vat_rate_default). Gated so it's a NO-OP when the org doesn't
charge VAT (default rate 0/unset) — i.e. enabling the toggle on a non-VAT org
adds nothing, as required. Settings UI: a self-documenting checkbox.
The fee is treated as net + VAT-on-top; the tax-report VAT breakdown for the
fee is part of the deferred dunning-document rework. tsc 0, build green,
9/9 workflow tests.
---
.../migrations/core/143_seed_late_fee_type.js | 6 +++++-
backend/src/services/invoiceService.js | 20 +++++++++++++++++--
.../pages/admin/settings/CrmSettingsPage.tsx | 2 ++
3 files changed, 25 insertions(+), 3 deletions(-)
diff --git a/backend/migrations/core/143_seed_late_fee_type.js b/backend/migrations/core/143_seed_late_fee_type.js
index 49b93582..37995c89 100644
--- a/backend/migrations/core/143_seed_late_fee_type.js
+++ b/backend/migrations/core/143_seed_late_fee_type.js
@@ -19,6 +19,10 @@ exports.up = async function (knex) {
const seeds = [
{ setting_key: 'crm_invoices_late_fee_type', setting_value: JSON.stringify('flat'), setting_type: 'crm' },
{ setting_key: 'crm_invoices_late_fee_percent', setting_value: JSON.stringify(0), setting_type: 'crm' },
+ // VAT on the late fee is jurisdiction-dependent (CH: yes; DE/AT: no), so it's
+ // a toggle. Default OFF (preserve current no-VAT behaviour). No-op anyway
+ // when the org doesn't charge VAT (business_profile.vat_rate_default = 0).
+ { setting_key: 'crm_invoices_late_fee_vat_enabled', setting_value: JSON.stringify(false), setting_type: 'crm' },
];
for (const s of seeds) {
const exists = await knex('app_settings').where({ setting_key: s.setting_key }).first();
@@ -29,6 +33,6 @@ exports.up = async function (knex) {
exports.down = async function (knex) {
if (!(await knex.schema.hasTable('app_settings'))) return;
await knex('app_settings')
- .whereIn('setting_key', ['crm_invoices_late_fee_type', 'crm_invoices_late_fee_percent'])
+ .whereIn('setting_key', ['crm_invoices_late_fee_type', 'crm_invoices_late_fee_percent', 'crm_invoices_late_fee_vat_enabled'])
.del();
};
diff --git a/backend/src/services/invoiceService.js b/backend/src/services/invoiceService.js
index a5d098cd..7f0ba014 100644
--- a/backend/src/services/invoiceService.js
+++ b/backend/src/services/invoiceService.js
@@ -2655,11 +2655,27 @@ async function sendReminder(id, levelOverride, adminId) {
async function resolvePerReminderFeeMinor(invoice) {
if ((await getAppSetting('crm_invoices_late_fee_enabled')) === false) return 0;
const type = (await getAppSetting('crm_invoices_late_fee_type')) || 'flat';
+ let fee;
if (type === 'percent') {
const pct = Number(await getAppSetting('crm_invoices_late_fee_percent')) || 0;
- return Math.max(0, Math.round(Number(invoice.total_amount_minor || 0) * pct / 100));
+ fee = Math.round(Number(invoice.total_amount_minor || 0) * pct / 100);
+ } else {
+ fee = ensureInt(await getAppSetting('crm_invoices_late_fee_minor')) || 2500;
}
- return Math.max(0, ensureInt(await getAppSetting('crm_invoices_late_fee_minor')) || 2500);
+ fee = Math.max(0, fee);
+
+ // VAT on the late fee is jurisdiction-dependent (CH: yes; DE/AT: no), so it's
+ // toggle-gated. It also no-ops when the ORG doesn't charge VAT — the org's
+ // default rate (business_profile.vat_rate_default) is 0/unset — so enabling
+ // the toggle on a non-VAT org adds nothing. (The fee amount is treated as net;
+ // VAT is added on top. The tax-report VAT breakdown for the fee is part of the
+ // deferred dunning-document rework.)
+ if ((await getAppSetting('crm_invoices_late_fee_vat_enabled')) === true && fee > 0) {
+ const profile = await db('business_profile').where({ id: 1 }).first('vat_rate_default');
+ const rate = Number(profile?.vat_rate_default) || 0;
+ if (rate > 0) fee += Math.round(fee * rate / 100);
+ }
+ return fee;
}
async function applyReminder(invoice, lineItems, level, adminId) {
diff --git a/frontend/src/pages/admin/settings/CrmSettingsPage.tsx b/frontend/src/pages/admin/settings/CrmSettingsPage.tsx
index 28bb45f3..1488fd89 100644
--- a/frontend/src/pages/admin/settings/CrmSettingsPage.tsx
+++ b/frontend/src/pages/admin/settings/CrmSettingsPage.tsx
@@ -34,6 +34,7 @@ const SETTING_KEYS = [
'crm_invoices_late_fee_type',
'crm_invoices_late_fee_minor',
'crm_invoices_late_fee_percent',
+ 'crm_invoices_late_fee_vat_enabled',
'crm_invoices_late_fee_label',
'crm_invoices_skonto_business_days',
'crm_invoices_skonto_percent_default',
@@ -249,6 +250,7 @@ export const CrmSettingsPage: React.FC = () => {
{t('crmSettings.lateFeeAgb.title', 'Late fees must be itemised in your terms (AGB)')}
{t('crmSettings.lateFeeAgb.body', 'Vertragliche Pflicht: Sätze wie „Es werden Mahnspesen erhoben“ reichen nicht aus. In den AGB muss die konkrete Gebühr klar beziffert sein (z.B. „CHF 20 ab der 2. Mahnung“). Mit dem Treuhänder prüfen.')}
+ {checkbox('crm_invoices_late_fee_vat_enabled', 'Charge VAT on late fees (Switzerland — leave off for DE/AT; no effect if your organisation has no VAT rate)')}
Date: Tue, 23 Jun 2026 13:08:19 +0200
Subject: [PATCH 20/43] =?UTF-8?q?feat(crm):=20Mahngeb=C3=BChr=20on=20a=20s?=
=?UTF-8?q?eparate=20Mahnung=20document;=20invoice=20stays=20immutable?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Corrected dunning model (Mara): a Mahnung is a reminder LETTER showing the new
total (original + Mahngebühr), NOT a separate invoice and NOT a mutation of the
issued invoice.
- The invoice PDF no longer shows the fee (buildInvoiceRenderContext reports
lateFeeAmountMinor 0) and is NEVER re-rendered by a reminder — it stays
immutable (§14/§11).
- applyReminder now: tracks the fee as dunning state on the row (gross
late_fee_amount_minor + new late_fee_vat_minor for the VAT portion, migration
144), renders a separate MAHNUNG PDF (pdfService 'mahnung' kind — reuses the
invoice layout: same lines + Mahngebühr row + new total, 'Mahnung' title, no
QR), stored under storage/business-docs/mahnung/, and attaches BOTH the
unchanged original invoice + the Mahnung to the reminder email.
- Fee resolvers split into net + VAT-rate (toggle + org-rate gated); a gross
wrapper feeds the payment-check preview. en + de PDF title.
Outstanding/collections still read late_fee_amount_minor (now dunning state).
P3 (tax-report/Banana booking of the Mahngebühr VAT) stays Treuhänder-gated.
Syntax + 17/17 workflow/invoice tests green.
NOTE: the Mahnung PDF render path isn't unit-tested (PDF rendering is flaky in
the test env) — eyeball on the dev box: fire a level-2 reminder, confirm the
Mahnung PDF shows the new total and the original invoice PDF is unchanged.
---
.../core/144_add_late_fee_vat_minor.js | 24 +++
backend/src/services/invoiceService.js | 140 +++++++++---------
backend/src/services/pdf-i18n.js | 2 +
backend/src/services/pdfService.js | 10 +-
4 files changed, 107 insertions(+), 69 deletions(-)
create mode 100644 backend/migrations/core/144_add_late_fee_vat_minor.js
diff --git a/backend/migrations/core/144_add_late_fee_vat_minor.js b/backend/migrations/core/144_add_late_fee_vat_minor.js
new file mode 100644
index 00000000..d7db7a1f
--- /dev/null
+++ b/backend/migrations/core/144_add_late_fee_vat_minor.js
@@ -0,0 +1,24 @@
+/**
+ * Migration 144: track the VAT portion of the Mahngebühr separately.
+ *
+ * The dunning rework keeps the fee on the invoice ROW as dunning state (gross
+ * in late_fee_amount_minor) but renders it on a separate Mahnung document, NOT
+ * on the immutable invoice. `late_fee_vat_minor` records the VAT component
+ * (0 when VAT-exempt — DE/AT, or the org has no VAT) so the Mahnung can show
+ * the breakdown and the tax report can later book the Mahngebühr VAT (CH).
+ */
+exports.up = async function (knex) {
+ if (!(await knex.schema.hasTable('invoices'))) return;
+ if (!(await knex.schema.hasColumn('invoices', 'late_fee_vat_minor'))) {
+ await knex.schema.alterTable('invoices', (t) => {
+ t.bigInteger('late_fee_vat_minor').notNullable().defaultTo(0);
+ });
+ }
+};
+
+exports.down = async function (knex) {
+ if (!(await knex.schema.hasTable('invoices'))) return;
+ if (await knex.schema.hasColumn('invoices', 'late_fee_vat_minor')) {
+ await knex.schema.alterTable('invoices', (t) => t.dropColumn('late_fee_vat_minor'));
+ }
+};
diff --git a/backend/src/services/invoiceService.js b/backend/src/services/invoiceService.js
index 7f0ba014..775a5eb1 100644
--- a/backend/src/services/invoiceService.js
+++ b/backend/src/services/invoiceService.js
@@ -1785,11 +1785,10 @@ async function buildInvoiceRenderContext(invoice, lineItems) {
vatAmountMinor: invoice.vat_amount_minor,
shippingAmountMinor: invoice.shipping_amount_minor,
totalAmountMinor: invoice.total_amount_minor,
- // Mahngebühr surfaced to the totals box (renders a row
- // between VAT and the grand-total divider) and folded
- // into the displayed Grand Total when > 0. Reminder
- // invoices after level 2 carry a non-zero value.
- lateFeeAmountMinor: invoice.late_fee_amount_minor || 0,
+ // The Mahngebühr is shown on the separate Mahnung document, NEVER on
+ // the (immutable) invoice — so the invoice render always reports 0. The
+ // Mahnung render path (applyReminder) overrides this with the tracked fee.
+ lateFeeAmountMinor: 0,
},
doc: {
// Document type discriminator. `'invoice'` (default) renders
@@ -1803,7 +1802,7 @@ async function buildInvoiceRenderContext(invoice, lineItems) {
issueDate: invoice.issue_date,
dueDate: invoice.due_date,
totalAmountMinor: invoice.total_amount_minor,
- lateFeeMinor: invoice.late_fee_amount_minor,
+ lateFeeMinor: 0,
// Reminder level — drives Skonto suppression on second
// reminders (no early-payment discount once the customer
// is in dunning).
@@ -2652,7 +2651,8 @@ async function sendReminder(id, levelOverride, adminId) {
// percentage of the invoice gross, per crm_invoices_late_fee_type. Charged from
// the 2nd reminder onwards. ⚠️ A late fee is only enforceable if the concrete
// amount is stated in the AGB — verify with a Treuhänder (the admin UI says so).
-async function resolvePerReminderFeeMinor(invoice) {
+// Net per-reminder Mahngebühr (flat amount or % of invoice gross), 0 disabled.
+async function resolveLateFeeNetMinor(invoice) {
if ((await getAppSetting('crm_invoices_late_fee_enabled')) === false) return 0;
const type = (await getAppSetting('crm_invoices_late_fee_type')) || 'flat';
let fee;
@@ -2662,103 +2662,109 @@ async function resolvePerReminderFeeMinor(invoice) {
} else {
fee = ensureInt(await getAppSetting('crm_invoices_late_fee_minor')) || 2500;
}
- fee = Math.max(0, fee);
+ return Math.max(0, fee);
+}
- // VAT on the late fee is jurisdiction-dependent (CH: yes; DE/AT: no), so it's
- // toggle-gated. It also no-ops when the ORG doesn't charge VAT — the org's
- // default rate (business_profile.vat_rate_default) is 0/unset — so enabling
- // the toggle on a non-VAT org adds nothing. (The fee amount is treated as net;
- // VAT is added on top. The tax-report VAT breakdown for the fee is part of the
- // deferred dunning-document rework.)
- if ((await getAppSetting('crm_invoices_late_fee_vat_enabled')) === true && fee > 0) {
- const profile = await db('business_profile').where({ id: 1 }).first('vat_rate_default');
- const rate = Number(profile?.vat_rate_default) || 0;
- if (rate > 0) fee += Math.round(fee * rate / 100);
- }
- return fee;
+// VAT rate on the fee — jurisdiction-dependent (CH: yes; DE/AT: no), so
+// toggle-gated AND org-VAT-gated: 0 when the org has no default VAT rate, so
+// enabling the toggle on a non-VAT org adds nothing.
+async function resolveLateFeeVatRate() {
+ if ((await getAppSetting('crm_invoices_late_fee_vat_enabled')) !== true) return 0;
+ const profile = await db('business_profile').where({ id: 1 }).first('vat_rate_default');
+ return Number(profile?.vat_rate_default) || 0;
+}
+
+// Gross per-reminder fee (net + VAT) — for the admin payment-check preview.
+async function resolvePerReminderFeeMinor(invoice) {
+ const net = await resolveLateFeeNetMinor(invoice);
+ if (net <= 0) return 0;
+ const rate = await resolveLateFeeVatRate();
+ return rate > 0 ? net + Math.round(net * rate / 100) : net;
}
async function applyReminder(invoice, lineItems, level, adminId) {
const customer = await db('customer_accounts').where({ id: invoice.customer_account_id }).first();
- let lateFeeMinor = invoice.late_fee_amount_minor || 0;
- if (level >= 2) {
- const perReminder = await resolvePerReminderFeeMinor(invoice);
- // One fee per fee-bearing reminder (levels 2..level): 2nd = 1×, 3rd = 2×.
- // Computed from `level` so re-applying the same level never stacks.
- lateFeeMinor = (level - 1) * perReminder;
- }
- const newTotal = invoice.total_amount_minor + lateFeeMinor;
- await db('invoices').where({ id: invoice.id }).update({
+ // Per fee-bearing reminder (levels 2..level): 2nd = 1×, 3rd = 2×, computed
+ // from `level` so re-applying the same level never stacks. The fee is dunning
+ // STATE on the row (gross + the VAT portion) — it is NOT shown on the
+ // immutable invoice; it appears on the separate Mahnung document below.
+ let lateFeeGross = invoice.late_fee_amount_minor || 0;
+ let lateFeeVat = invoice.late_fee_vat_minor || 0;
+ if (level >= 2) {
+ const net = await resolveLateFeeNetMinor(invoice);
+ const rate = await resolveLateFeeVatRate();
+ const vatPer = rate > 0 ? Math.round(net * rate / 100) : 0;
+ lateFeeGross = (level - 1) * (net + vatPer);
+ lateFeeVat = (level - 1) * vatPer;
+ }
+ const newTotal = Number(invoice.total_amount_minor || 0) + lateFeeGross;
+
+ const update = {
status: 'overdue',
reminder_level: level,
last_reminder_sent_at: new Date(),
- late_fee_amount_minor: lateFeeMinor,
+ late_fee_amount_minor: lateFeeGross,
updated_at: new Date(),
- });
+ };
+ if (await hasColumnCached('invoices', 'late_fee_vat_minor')) update.late_fee_vat_minor = lateFeeVat;
+ await db('invoices').where({ id: invoice.id }).update(update);
- // Re-render PDF so the late fee shows up.
+ // Render the MAHNUNG (reminder letter). The original invoice PDF is left
+ // UNTOUCHED (immutable). The Mahnung reuses the invoice layout via a
+ // 'mahnung' kind: same line items + the Mahngebühr row + the new total, with
+ // a "Mahnung" title and no QR (it would encode the old amount).
const fresh = await db('invoices').where({ id: invoice.id }).first();
const ctx = await buildInvoiceRenderContext(fresh, lineItems);
+ ctx.doc.kind = 'mahnung';
+ ctx.doc.reminderLevel = level;
+ ctx.doc.lateFeeMinor = lateFeeGross;
+ ctx.totals.lateFeeAmountMinor = lateFeeGross;
const buffer = await pdfService.renderInvoiceToBuffer(ctx);
const fs = require('fs');
const path = require('path');
const year = new Date(fresh.issue_date).getFullYear();
- const root = path.join(process.cwd(), 'storage', 'business-docs', 'invoice', String(year));
+ const root = path.join(process.cwd(), 'storage', 'business-docs', 'mahnung', String(year));
fs.mkdirSync(root, { recursive: true });
- const pdfPath = path.join(root, `${fresh.invoice_number}.pdf`);
- fs.writeFileSync(pdfPath, buffer);
+ const mahnungPath = path.join(root, `${fresh.invoice_number}_mahnung_L${level}.pdf`);
+ fs.writeFileSync(mahnungPath, buffer);
- await db('invoices').where({ id: invoice.id }).update({ pdf_path: pdfPath, updated_at: new Date() });
-
- // days_overdue floors at 1 — a reminder that fires with "0 days
- // overdue" reads as broken to the customer ("Why am I getting this
- // already?"). The scheduler only triggers the row once
- // due_date <= now - reminder_first_days, so the natural minimum is
- // the configured threshold; for the manual "Send reminder now"
- // path the admin's intent is "this customer is late", so 1 is the
- // sensible lower bound even if the calendar arithmetic disagrees.
+ // days_overdue floors at 1 (a "0 days overdue" reminder reads as broken).
const rawDaysOverdue = Math.floor((Date.now() - new Date(invoice.due_date).getTime()) / 86400000);
const daysOverdue = Math.max(1, rawDaysOverdue);
const templateKey = level === 1 ? 'invoice_reminder_first' : 'invoice_reminder_second';
+ const locale = ctx.locale || invoice.language || 'de';
+ const outstandingMinor = Math.max(0, newTotal - Number(invoice.paid_amount_minor || 0));
- // Outstanding = gross total + late fee − already paid. Reminder
- // templates use this for the "outstanding is X" line so partial
- // payments are reflected in the reminder amount.
- const outstandingMinor = Math.max(0,
- Number(invoice.total_amount_minor || 0)
- + Number(lateFeeMinor || 0)
- - Number(invoice.paid_amount_minor || 0));
+ // Attach the (unchanged) original invoice PDF + the new Mahnung.
+ const attachments = [];
+ if (invoice.pdf_path && fs.existsSync(invoice.pdf_path)) {
+ attachments.push({ filename: `${invoice.invoice_number}.pdf`, contentPath: invoice.pdf_path, contentType: 'application/pdf' });
+ }
+ attachments.push({ filename: `${fresh.invoice_number}_Mahnung.pdf`, contentPath: mahnungPath, contentType: 'application/pdf' });
const { to: reminderTo, cc: reminderCc } = resolveBillingRecipients(customer, invoice.cc_pdf_email);
await emailProcessor.queueEmail(invoice.event_id || null, reminderTo, templateKey, {
invoice_number: invoice.invoice_number,
customer_name: customer.display_name || customer.first_name || customer.email.split('@')[0],
- total_amount: formatMajor(invoice.total_amount_minor, invoice.currency, ctx.locale),
- new_total_amount: formatMajor(newTotal, invoice.currency, ctx.locale),
- outstanding_amount: formatMajor(outstandingMinor, invoice.currency, ctx.locale),
- paid_amount: formatMajor(invoice.paid_amount_minor, invoice.currency, ctx.locale),
- late_fee_amount: formatMajor(lateFeeMinor, invoice.currency, ctx.locale),
- // Format dates as DD.MM.YYYY for the customer-facing email
- // (matches the quote_sent + invoice_sent templates).
+ total_amount: formatMajor(invoice.total_amount_minor, invoice.currency, locale),
+ new_total_amount: formatMajor(newTotal, invoice.currency, locale),
+ outstanding_amount: formatMajor(outstandingMinor, invoice.currency, locale),
+ paid_amount: formatMajor(invoice.paid_amount_minor, invoice.currency, locale),
+ late_fee_amount: formatMajor(lateFeeGross, invoice.currency, locale),
due_date: formatShortDate(invoice.due_date),
days_overdue: daysOverdue,
cc: reminderCc,
- attachments: [{
- filename: `${invoice.invoice_number}.pdf`,
- contentPath: pdfPath,
- contentType: 'application/pdf',
- }],
- // Dunning reminders are relationship mail — hold to business hours so
- // the customer isn't pinged overnight (no-op unless hours configured).
+ attachments,
+ // Dunning reminders are relationship mail — hold to business hours.
}, { respectBusinessHours: true });
try {
- await logActivity('invoice_reminder_sent', { invoiceId: invoice.id, level, lateFeeMinor },
+ await logActivity('invoice_reminder_sent', { invoiceId: invoice.id, level, lateFeeMinor: lateFeeGross },
invoice.event_id || null, `admin:${adminId || 'system'}`);
} catch (_) {}
- return { level, lateFeeMinor };
+ return { level, lateFeeMinor: lateFeeGross };
}
// ---------------------------------------------------------------------
diff --git a/backend/src/services/pdf-i18n.js b/backend/src/services/pdf-i18n.js
index 22ce8e78..064c0002 100644
--- a/backend/src/services/pdf-i18n.js
+++ b/backend/src/services/pdf-i18n.js
@@ -25,6 +25,7 @@ const LABELS = {
// under the title that the customer/auditor needs to trace the
// §14c-defensible reversal.
storno_title: 'Cancellation invoice',
+ mahnung_title: 'Payment reminder',
reference_cancels: 'Cancels',
date: 'Date',
quote_number: 'Quote',
@@ -154,6 +155,7 @@ const LABELS = {
quote_number_label: 'Angebotsnummer',
invoice_number_label: 'Rechnungsnummer',
storno_title: 'Stornorechnung',
+ mahnung_title: 'Mahnung',
reference_cancels: 'Storno zu',
date: 'Datum',
quote_number: 'Angebot',
diff --git a/backend/src/services/pdfService.js b/backend/src/services/pdfService.js
index 0e4cf8d6..c12d607b 100644
--- a/backend/src/services/pdfService.js
+++ b/backend/src/services/pdfService.js
@@ -1477,6 +1477,10 @@ function renderDocument(type, context) {
// family — Storni share the invoice renderer surface, only
// the cosmetic + accounting-sign branches differ.
const isStorno = type === 'invoice' && ctx.doc.kind === 'storno';
+ // Mahnung (reminder letter) reuses the invoice surface: same line items +
+ // a Mahngebühr row + the new grand total, but a "Mahnung" title and NO
+ // QR (the QR would encode the original amount, not the new total).
+ const isMahnung = type === 'invoice' && ctx.doc.kind === 'mahnung';
// ---- document number (above) + date (below), both right-aligned
// The number sits directly under the sender address block so the
@@ -1516,7 +1520,9 @@ function renderDocument(type, context) {
? t(ctx.locale, 'quote_title')
: isStorno
? t(ctx.locale, 'storno_title')
- : t(ctx.locale, 'invoice_title');
+ : isMahnung
+ ? t(ctx.locale, 'mahnung_title')
+ : t(ctx.locale, 'invoice_title');
y = drawTitle(doc, title, leftX, y + 2);
// Mandatory Storno reference line — "Bezug: Storno zu Rechnung
@@ -1700,7 +1706,7 @@ function renderDocument(type, context) {
// Both append a fresh page; 'none' is a no-op.
// Suppressed on Stornorechnungen — negative-amount QR codes
// aren't a defined construct in either spec.
- if (type === 'invoice' && !isStorno) {
+ if (type === 'invoice' && !isStorno && !isMahnung) {
if (ctx.qrFormat === 'swiss') {
appendSwissQrBill(doc, ctx);
} else if (ctx.qrFormat === 'epc') {
From 83dc95a62b6b9c9692578705e9f486fbcb72de53 Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Tue, 23 Jun 2026 13:30:48 +0200
Subject: [PATCH 21/43] test(crm): dunning fee math, VAT-toggle gating +
invoice immutability
Covers the tax-sensitive bits the dunning rework added (previously untested):
flat vs percent fee, the VAT toggle applying the org rate AND no-op'ing when
the org has no VAT rate, per-reminder accumulation (2nd=1x / 3rd=2x), the
invoice total staying immutable while the fee is tracked, and the 3-reminder
cap. Exports the fee resolvers + applyReminder for testing; PDF render stubbed
(flaky in CI, verified manually). 6/6 pass.
---
.../integration/invoiceDunning.test.js | 110 ++++++++++++++++++
backend/src/services/invoiceService.js | 4 +
2 files changed, 114 insertions(+)
create mode 100644 backend/__tests__/integration/invoiceDunning.test.js
diff --git a/backend/__tests__/integration/invoiceDunning.test.js b/backend/__tests__/integration/invoiceDunning.test.js
new file mode 100644
index 00000000..a1d54301
--- /dev/null
+++ b/backend/__tests__/integration/invoiceDunning.test.js
@@ -0,0 +1,110 @@
+/**
+ * Dunning / Mahngebühr logic — the tax-sensitive bits added in the dunning
+ * rework. Covers the fee math (flat / percent), the VAT toggle gating
+ * (incl. the "no-op when the org has no VAT rate" requirement), per-reminder
+ * accumulation (2nd = 1×, 3rd = 2×), invoice immutability (the fee never
+ * changes the issued invoice total), and the 3-reminder cap.
+ *
+ * The Mahnung PDF render is stubbed — PDF rendering (fonts) is flaky in CI and
+ * is verified manually; here we assert the data/immutability behaviour.
+ */
+const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
+
+let db;
+let cleanup;
+let invoiceService;
+let ids;
+
+async function setSetting(key, value) {
+ const { upsertAppSetting } = require('../../src/utils/appSettings');
+ await upsertAppSetting(key, JSON.stringify(value), 'crm');
+}
+
+beforeAll(async () => {
+ ({ db, cleanup } = await bootCrmDb());
+ ids = await seedMinimal(db);
+ try { await db('customer_accounts').where({ id: ids.customerId }).update({ feature_bills: true }); } catch (_) {}
+ invoiceService = require('../../src/services/invoiceService');
+ // Stub the (flaky) PDF render so applyReminder exercises its data path.
+ // eslint-disable-next-line global-require
+ const pdfService = require('../../src/services/pdfService');
+ pdfService.renderInvoiceToBuffer = async () => Buffer.from('%PDF-stub');
+});
+
+afterAll(async () => { await cleanup(); });
+
+describe('dunning fee resolvers', () => {
+ test('flat fee, no VAT', async () => {
+ await setSetting('crm_invoices_late_fee_enabled', true);
+ await setSetting('crm_invoices_late_fee_type', 'flat');
+ await setSetting('crm_invoices_late_fee_minor', 2000);
+ await setSetting('crm_invoices_late_fee_vat_enabled', false);
+ const inv = { total_amount_minor: 100000 };
+ expect(await invoiceService.resolveLateFeeNetMinor(inv)).toBe(2000);
+ expect(await invoiceService.resolveLateFeeVatRate()).toBe(0);
+ expect(await invoiceService.resolvePerReminderFeeMinor(inv)).toBe(2000);
+ });
+
+ test('percent fee = % of the invoice gross', async () => {
+ await setSetting('crm_invoices_late_fee_type', 'percent');
+ await setSetting('crm_invoices_late_fee_percent', 5);
+ expect(await invoiceService.resolveLateFeeNetMinor({ total_amount_minor: 100000 })).toBe(5000);
+ });
+
+ test('VAT toggle applies the org rate, but is a NO-OP when the org has no VAT rate', async () => {
+ await setSetting('crm_invoices_late_fee_type', 'flat');
+ await setSetting('crm_invoices_late_fee_minor', 2000);
+ await setSetting('crm_invoices_late_fee_vat_enabled', true);
+
+ await db('business_profile').where({ id: 1 }).update({ vat_rate_default: 8.1 });
+ expect(await invoiceService.resolveLateFeeVatRate()).toBeCloseTo(8.1);
+ expect(await invoiceService.resolvePerReminderFeeMinor({ total_amount_minor: 0 }))
+ .toBe(2000 + Math.round(2000 * 8.1 / 100)); // net + VAT
+
+ // Org doesn't charge VAT → toggle adds nothing (Mara's requirement).
+ await db('business_profile').where({ id: 1 }).update({ vat_rate_default: 0 });
+ expect(await invoiceService.resolveLateFeeVatRate()).toBe(0);
+ expect(await invoiceService.resolvePerReminderFeeMinor({ total_amount_minor: 0 })).toBe(2000);
+ });
+});
+
+describe('applyReminder — dunning-document model', () => {
+ let invoiceId;
+ let originalTotal;
+
+ beforeAll(async () => {
+ await setSetting('crm_invoices_late_fee_enabled', true);
+ await setSetting('crm_invoices_late_fee_type', 'flat');
+ await setSetting('crm_invoices_late_fee_minor', 2000);
+ await setSetting('crm_invoices_late_fee_vat_enabled', false);
+ const res = await invoiceService.createInvoice({
+ customerAccountId: ids.customerId,
+ currency: 'CHF',
+ vatRate: 0,
+ lineItems: [{ description: 'Service', quantity: 1, unit_price_minor: 100000 }],
+ }, ids.adminId);
+ invoiceId = res.invoiceIds[0];
+ originalTotal = Number((await db('invoices').where({ id: invoiceId }).first()).total_amount_minor);
+ });
+
+ test('level 2 tracks one fee and leaves the invoice total immutable', async () => {
+ const data = await invoiceService.getInvoiceById(invoiceId);
+ await invoiceService.applyReminder(data.invoice, data.lineItems, 2, ids.adminId);
+ const inv = await db('invoices').where({ id: invoiceId }).first();
+ expect(inv.reminder_level).toBe(2);
+ expect(Number(inv.late_fee_amount_minor)).toBe(2000);
+ expect(Number(inv.total_amount_minor)).toBe(originalTotal); // never mutated
+ });
+
+ test('level 3 accumulates the fee to 2×, total still immutable', async () => {
+ const data = await invoiceService.getInvoiceById(invoiceId);
+ await invoiceService.applyReminder(data.invoice, data.lineItems, 3, ids.adminId);
+ const inv = await db('invoices').where({ id: invoiceId }).first();
+ expect(Number(inv.late_fee_amount_minor)).toBe(4000);
+ expect(Number(inv.total_amount_minor)).toBe(originalTotal);
+ });
+
+ test('sendReminder refuses to exceed level 3', async () => {
+ await expect(invoiceService.sendReminder(invoiceId, 4, ids.adminId)).rejects.toThrow();
+ });
+});
diff --git a/backend/src/services/invoiceService.js b/backend/src/services/invoiceService.js
index 775a5eb1..72f5beb4 100644
--- a/backend/src/services/invoiceService.js
+++ b/backend/src/services/invoiceService.js
@@ -3528,6 +3528,10 @@ module.exports = {
validateInstallmentPlanInput,
sendInvoice,
sendReminder,
+ applyReminder,
+ resolveLateFeeNetMinor,
+ resolveLateFeeVatRate,
+ resolvePerReminderFeeMinor,
markPaid,
cancelInvoice,
releaseForDelivery,
From 192d2cbc06a0295dbfdc35f05d192ae7ed5273e6 Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Tue, 23 Jun 2026 13:39:17 +0200
Subject: [PATCH 22/43] =?UTF-8?q?feat(workflows):=20crash=20recovery=20?=
=?UTF-8?q?=E2=80=94=20resume=20runs=20orphaned=20mid-flow?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Closes the crash-safety gap: a run left in running/pending by a crash had
nothing to resume it (the scheduler only wakes 'waiting'). Adds a heartbeat
(workflow_runs.updated_at, stamped on every node advance + start/resume) and a
recoverStaleRuns() sweep that re-enters runs whose heartbeat has gone stale
(>10 min) from their persisted node. Runs on the scheduler tick AND the boot
tick, so a restart catches anything stranded during downtime.
Re-entry is at-least-once (the current node may re-execute) — loop counters +
the late-fee math are idempotent, so the only residual risk is a duplicate
reminder email. An attempts counter (migration 145, cap 5) marks a run failed
instead of recovering a node that reliably crashes the process (crash-loop
backstop). Flag-gated. Tests: orphan-resume + crash-loop cap.
---
.../integration/workflowEngine.test.js | 36 +++++++++++
.../core/145_workflow_run_recovery.js | 33 ++++++++++
.../src/services/invoiceSchedulerService.js | 7 ++-
backend/src/services/workflows/engine.js | 61 ++++++++++++++++++-
4 files changed, 133 insertions(+), 4 deletions(-)
create mode 100644 backend/migrations/core/145_workflow_run_recovery.js
diff --git a/backend/__tests__/integration/workflowEngine.test.js b/backend/__tests__/integration/workflowEngine.test.js
index dd796db1..34bbab6b 100644
--- a/backend/__tests__/integration/workflowEngine.test.js
+++ b/backend/__tests__/integration/workflowEngine.test.js
@@ -279,4 +279,40 @@ describe('workflow engine', () => {
const after = await db('workflows').where({ id: wf.id }).first();
expect(after.version).toBe(before.version); // unchanged
});
+
+ test('recoverStaleRuns resumes a run orphaned mid-flow (crash recovery)', async () => {
+ const wfId = await makeWorkflow({
+ trigger: 'recover.event',
+ nodes: [{ key: 'r1', type: 'trigger' }, { key: 'r2', type: 'action', config: { action: 'noop' } }],
+ edges: [{ from: 'r1', to: 'r2' }],
+ });
+ // Simulate a run left 'running' at r2 with a stale heartbeat (crash mid-flow).
+ await db('workflow_runs').insert({
+ workflow_id: wfId, version: 1, trigger_event: 'recover.event', status: 'running', current_node: 'r2',
+ context: JSON.stringify({ vars: {} }), dedup_key: 'recover-1',
+ updated_at: new Date(Date.now() - 3600000).toISOString(),
+ });
+ const run0 = await db('workflow_runs').where({ dedup_key: 'recover-1' }).first();
+ const n = await engine.recoverStaleRuns({ staleMs: 1000 });
+ expect(n).toBeGreaterThanOrEqual(1);
+ const run = await db('workflow_runs').where({ id: run0.id }).first();
+ expect(run.status).toBe('done');
+ });
+
+ test('recoverStaleRuns abandons a crash-looping run after the attempts cap', async () => {
+ const wfId = await makeWorkflow({
+ trigger: 'crashloop.event',
+ nodes: [{ key: 'c1', type: 'trigger' }, { key: 'c2', type: 'action', config: { action: 'noop' } }],
+ edges: [{ from: 'c1', to: 'c2' }],
+ });
+ await db('workflow_runs').insert({
+ workflow_id: wfId, version: 1, trigger_event: 'crashloop.event', status: 'running', current_node: 'c2',
+ context: JSON.stringify({ vars: {} }), dedup_key: 'crash-1', attempts: 5,
+ updated_at: new Date(Date.now() - 3600000).toISOString(),
+ });
+ const run0 = await db('workflow_runs').where({ dedup_key: 'crash-1' }).first();
+ await engine.recoverStaleRuns({ staleMs: 1000 });
+ const run = await db('workflow_runs').where({ id: run0.id }).first();
+ expect(run.status).toBe('failed');
+ });
});
diff --git a/backend/migrations/core/145_workflow_run_recovery.js b/backend/migrations/core/145_workflow_run_recovery.js
new file mode 100644
index 00000000..d44adec0
--- /dev/null
+++ b/backend/migrations/core/145_workflow_run_recovery.js
@@ -0,0 +1,33 @@
+/**
+ * Migration 145: crash-recovery fields for workflow runs.
+ *
+ * A run left in 'running'/'pending' by a crash has nothing to resume it (the
+ * scheduler only wakes 'waiting' runs). Add a heartbeat (`updated_at`, stamped
+ * on every step) so a recovery sweep can detect stale runs, plus an `attempts`
+ * counter so a node that reliably crashes the process can't be recovered
+ * forever (crash-loop backstop → marked failed after a cap).
+ */
+exports.up = async function (knex) {
+ if (!(await knex.schema.hasTable('workflow_runs'))) return;
+ const hasUpdated = await knex.schema.hasColumn('workflow_runs', 'updated_at');
+ const hasAttempts = await knex.schema.hasColumn('workflow_runs', 'attempts');
+ await knex.schema.alterTable('workflow_runs', (t) => {
+ if (!hasUpdated) t.timestamp('updated_at').defaultTo(knex.fn.now());
+ if (!hasAttempts) t.integer('attempts').notNullable().defaultTo(0);
+ });
+ // Recovery sweep queries by (status, updated_at).
+ if (!hasUpdated) {
+ try { await knex.schema.alterTable('workflow_runs', (t) => t.index(['status', 'updated_at'], 'workflow_runs_recovery_index')); } catch (_) {}
+ }
+};
+
+exports.down = async function (knex) {
+ if (!(await knex.schema.hasTable('workflow_runs'))) return;
+ try { await knex.schema.alterTable('workflow_runs', (t) => t.dropIndex(['status', 'updated_at'], 'workflow_runs_recovery_index')); } catch (_) {}
+ if (await knex.schema.hasColumn('workflow_runs', 'updated_at')) {
+ await knex.schema.alterTable('workflow_runs', (t) => t.dropColumn('updated_at'));
+ }
+ if (await knex.schema.hasColumn('workflow_runs', 'attempts')) {
+ await knex.schema.alterTable('workflow_runs', (t) => t.dropColumn('attempts'));
+ }
+};
diff --git a/backend/src/services/invoiceSchedulerService.js b/backend/src/services/invoiceSchedulerService.js
index 614349b1..49fe983d 100644
--- a/backend/src/services/invoiceSchedulerService.js
+++ b/backend/src/services/invoiceSchedulerService.js
@@ -46,8 +46,13 @@ async function runTick() {
// Resume workflow runs whose wait has elapsed. No-op (fails closed) when
// the `workflows` feature flag is off. Independent try/catch so a workflow
// failure never suppresses the invoice/reminder jobs above.
- const resumed = await require('./workflows').runDueWaits();
+ const wf = require('./workflows');
+ const resumed = await wf.runDueWaits();
if (resumed) logger.info('Workflow scheduler: resumed waiting runs', { resumed });
+ // Recover runs orphaned by a crash (stuck in running/pending). Runs on the
+ // boot tick too, so a restart catches anything stranded during downtime.
+ const recovered = await wf.recoverStaleRuns();
+ if (recovered) logger.warn('Workflow scheduler: recovered orphaned runs', { recovered });
} catch (err) {
logger.error('Workflow resume pass failed', { err: err.message });
}
diff --git a/backend/src/services/workflows/engine.js b/backend/src/services/workflows/engine.js
index b4bb401f..584bb29f 100644
--- a/backend/src/services/workflows/engine.js
+++ b/backend/src/services/workflows/engine.js
@@ -186,7 +186,7 @@ async function advanceRun(runId) {
}
currentKey = nextKey;
- await db('workflow_runs').where({ id: runId }).update({ current_node: currentKey || null, context: JSON.stringify(context) });
+ await db('workflow_runs').where({ id: runId }).update({ current_node: currentKey || null, context: JSON.stringify(context), updated_at: db.fn.now() });
}
await finishRun(runId);
@@ -200,7 +200,7 @@ async function startRun(runId) {
let entry = null;
for (const n of nodeByKey.values()) { if (n.type === 'trigger') { entry = n; break; } }
if (!entry) { await failRun(runId, 'no trigger node'); return; }
- await db('workflow_runs').where({ id: runId }).update({ status: 'running', current_node: entry.node_key });
+ await db('workflow_runs').where({ id: runId }).update({ status: 'running', current_node: entry.node_key, updated_at: db.fn.now() });
await advanceRun(runId);
}
@@ -214,7 +214,7 @@ async function resumeRun(runId, { decisionHandle = null } = {}) {
const { edges } = await loadGraph(run.workflow_id, run.version);
const e = outEdge(edges, run.current_node, decisionHandle);
const nextKey = e ? e.to_node : null;
- await db('workflow_runs').where({ id: runId }).update({ status: 'running', current_node: nextKey, wake_at: null });
+ await db('workflow_runs').where({ id: runId }).update({ status: 'running', current_node: nextKey, wake_at: null, updated_at: db.fn.now() });
if (!nextKey) { await finishRun(runId); return; }
await advanceRun(runId);
}
@@ -310,9 +310,64 @@ async function runDueWaits(limit = 100) {
}
}
+const RECOVERY_STALE_MS = 10 * 60 * 1000; // a 'running' run idle this long = orphaned by a crash
+const MAX_RECOVERY_ATTEMPTS = 5;
+
+/**
+ * Resume runs orphaned by a crash. A run left in 'running'/'pending' has nothing
+ * to resume it (the scheduler only wakes 'waiting'), so this sweep picks up ones
+ * whose heartbeat (updated_at) has gone stale and re-enters them from their
+ * persisted node. Re-entry is at-least-once: the current node may re-execute —
+ * loop counters + the late-fee math are idempotent, so the only residual risk is
+ * a duplicate reminder email. `attempts` caps recovery so a node that reliably
+ * crashes the process is marked failed instead of looping forever. Flag-gated
+ * (fails closed when workflows is off). Called from the scheduler tick + boot.
+ */
+async function recoverStaleRuns({ staleMs = RECOVERY_STALE_MS, limit = 50 } = {}) {
+ try {
+ const { isFeatureEnabled } = require('../../middleware/requireFeatureFlag');
+ let enabled = false;
+ try { enabled = await isFeatureEnabled('workflows'); } catch (e) { return 0; }
+ if (!enabled) return 0;
+ if (!(await db.schema.hasColumn('workflow_runs', 'updated_at'))) return 0;
+
+ const cutoff = new Date(Date.now() - staleMs).toISOString();
+ const stale = await db('workflow_runs')
+ .whereIn('status', ['running', 'pending'])
+ .where('updated_at', '<=', cutoff)
+ .limit(limit);
+
+ let recovered = 0;
+ for (const run of stale) {
+ try {
+ const attempts = Number(run.attempts) || 0;
+ if (attempts >= MAX_RECOVERY_ATTEMPTS) {
+ await failRun(run.id, `abandoned after ${attempts} recovery attempts (suspected crash loop)`);
+ continue;
+ }
+ await db('workflow_runs').where({ id: run.id }).update({ attempts: attempts + 1, updated_at: db.fn.now() });
+ if (!run.current_node) {
+ await startRun(run.id);
+ } else {
+ await db('workflow_runs').where({ id: run.id }).update({ status: 'running', updated_at: db.fn.now() });
+ await advanceRun(run.id);
+ }
+ recovered += 1;
+ } catch (err) {
+ logger.error('[workflow] recovery failed', { runId: run.id, error: err.message });
+ }
+ }
+ return recovered;
+ } catch (e) {
+ logger.error('[workflow] recoverStaleRuns failed', { error: e.message });
+ return 0;
+ }
+}
+
module.exports = {
emitWorkflowEvent,
runDueWaits,
+ recoverStaleRuns,
startRun,
advanceRun,
resumeRun,
From e70ddd36b8e9558dc44fc118277539fe6e7425d7 Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Tue, 23 Jun 2026 13:57:02 +0200
Subject: [PATCH 23/43] =?UTF-8?q?feat(workflows):=20test-fire=20=E2=80=94?=
=?UTF-8?q?=20safe=20dry-run=20of=20any=20flow=20on=20demand?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Engine testRun() walks the whole graph immediately: waits pass through,
gates auto-confirm, side-effecting actions short-circuit to {dryRun, would}
so no real emails go out. POST /admin/workflows/:id/test-run returns the
run status + per-node step log. Admin list gets a flask button that opens
a result modal with an optional entity id (e.g. invoice) for conditions.
---
.../integration/workflowEngine.test.js | 28 +++++++++
backend/src/routes/adminWorkflows.js | 22 +++++++
backend/src/services/workflows/actions.js | 3 +
backend/src/services/workflows/engine.js | 46 ++++++++++++++
frontend/src/i18n/locales/de.json | 8 +++
frontend/src/i18n/locales/en.json | 8 +++
.../admin/workflows/WorkflowsListPage.tsx | 61 ++++++++++++++++++-
frontend/src/services/workflows.service.ts | 19 ++++++
8 files changed, 192 insertions(+), 3 deletions(-)
diff --git a/backend/__tests__/integration/workflowEngine.test.js b/backend/__tests__/integration/workflowEngine.test.js
index 34bbab6b..3aa747dd 100644
--- a/backend/__tests__/integration/workflowEngine.test.js
+++ b/backend/__tests__/integration/workflowEngine.test.js
@@ -315,4 +315,32 @@ describe('workflow engine', () => {
const run = await db('workflow_runs').where({ id: run0.id }).first();
expect(run.status).toBe('failed');
});
+
+ test('testRun dry-run walks the whole flow (waits skipped, gate auto-confirmed, actions mocked)', async () => {
+ const wfId = await makeWorkflow({
+ trigger: 'testfire.event',
+ nodes: [
+ { key: 't', type: 'trigger' },
+ { key: 'w', type: 'wait', config: { delayDays: 14 } },
+ { key: 'g', type: 'gate', config: { type: 'payment_confirm' } },
+ { key: 'a', type: 'action', config: { action: 'send_email', recipientClass: 'customer' } },
+ { key: 'end', type: 'action', config: { action: 'noop' } },
+ ],
+ edges: [
+ { from: 't', to: 'w' },
+ { from: 'w', to: 'g' },
+ { from: 'g', handle: 'confirm', to: 'a' },
+ { from: 'g', handle: 'deny', to: 'end' },
+ { from: 'a', to: 'end' },
+ ],
+ });
+ const runId = await engine.testRun(wfId, { dryRun: true });
+ const run = await db('workflow_runs').where({ id: runId }).first();
+ expect(run.status).toBe('done'); // walked to completion — no parking at the wait/gate
+
+ const steps = await db('workflow_run_steps').where({ run_id: runId });
+ expect(steps.find((s) => s.node_key === 'w').status).toBe('skipped'); // wait passed through
+ const emailStep = steps.find((s) => s.node_key === 'a');
+ expect(JSON.parse(emailStep.result).dryRun).toBe(true); // send_email mocked, no real mail
+ });
});
diff --git a/backend/src/routes/adminWorkflows.js b/backend/src/routes/adminWorkflows.js
index 325af114..1cb8300f 100644
--- a/backend/src/routes/adminWorkflows.js
+++ b/backend/src/routes/adminWorkflows.js
@@ -96,6 +96,28 @@ router.get('/:id/runs', requirePermission('workflows.view'), async (req, res, ne
} catch (e) { next(e); }
});
+// Test-fire: run the workflow on demand (default dry-run — side effects mocked,
+// waits skipped, gates auto-confirm) and return the step-by-step log.
+router.post('/:id/test-run', requirePermission('workflows.manage'), async (req, res, next) => {
+ try {
+ const { entityType, entityId, payload, dryRun } = req.body || {};
+ const runId = await workflows.testRun(Number(req.params.id), {
+ entityType: entityType || null,
+ entityId: entityId != null && entityId !== '' ? Number(entityId) : null,
+ payload: payload && typeof payload === 'object' ? payload : {},
+ dryRun: dryRun !== false, // default true (safe)
+ });
+ const run = await db('workflow_runs').where({ id: runId }).first();
+ const steps = await db('workflow_run_steps').where({ run_id: runId }).orderBy('id', 'asc');
+ res.json({
+ runId,
+ dryRun: dryRun !== false,
+ status: run?.status,
+ steps: steps.map((s) => ({ ...s, result: parseJson(s.result, null) })),
+ });
+ } catch (e) { next(e); }
+});
+
// --- List / get ---
router.get('/', requirePermission('workflows.view'), async (req, res, next) => {
try {
diff --git a/backend/src/services/workflows/actions.js b/backend/src/services/workflows/actions.js
index 5d4b6eb0..b205ae36 100644
--- a/backend/src/services/workflows/actions.js
+++ b/backend/src/services/workflows/actions.js
@@ -48,6 +48,7 @@ registry.registerCondition('invoice_paid', async (ctx) => {
// anything else (customer/external) respects the business-hours floor.
registry.registerAction('send_email', async (ctx) => {
const cfg = ctx.node.config || {};
+ if (ctx.vars?.__dryRun) return { dryRun: true, would: 'send_email', recipientClass: cfg.recipientClass || cfg.recipient || 'customer', emailType: cfg.emailType || cfg.template };
const recipientClass = cfg.recipientClass || cfg.recipient || 'customer';
const isInternal = recipientClass === 'admin' || recipientClass === 'internal';
const to = cfg.to
@@ -75,6 +76,7 @@ registry.registerAction('send_email', async (ctx) => {
registry.registerAction('queue_payment_check', async (ctx) => {
const id = ctx.run.entity_id;
if (!id) return { skipped: true, reason: 'no invoice entity' };
+ if (ctx.vars?.__dryRun) return { dryRun: true, would: 'queue_payment_check', invoiceId: id };
await require('../invoiceService').queuePaymentCheckEmail(id);
return { payment_check_queued: id };
});
@@ -87,6 +89,7 @@ registry.registerAction('queue_payment_check', async (ctx) => {
registry.registerAction('escalate_to_collections', async (ctx) => {
const id = ctx.run.entity_id;
if (!id) return { skipped: true, reason: 'no invoice entity' };
+ if (ctx.vars?.__dryRun) return { dryRun: true, would: 'escalate_to_collections', invoiceId: id };
const { db } = ctx;
const invoice = await db('invoices').where({ id }).first();
if (!invoice) return { skipped: true, reason: 'invoice not found' };
diff --git a/backend/src/services/workflows/engine.js b/backend/src/services/workflows/engine.js
index 584bb29f..4bc86408 100644
--- a/backend/src/services/workflows/engine.js
+++ b/backend/src/services/workflows/engine.js
@@ -144,6 +144,14 @@ async function advanceRun(runId) {
break;
}
case 'wait': {
+ // Dry-run (test-fire): don't park — pass straight through so the whole
+ // flow runs in one shot, recording what it WOULD have waited for.
+ if (context.vars.__dryRun) {
+ const e = outEdge(edges, currentKey, null);
+ nextKey = e ? e.to_node : null;
+ await recordStep(runId, node, 'skipped', { dryRun: true, wouldWaitUntil: computeWakeAt(node.config, context.vars) });
+ break;
+ }
const wakeAt = computeWakeAt(node.config, context.vars);
await db('workflow_runs').where({ id: runId })
.update({ status: 'waiting', wake_at: wakeAt, current_node: currentKey, context: JSON.stringify(context) });
@@ -151,6 +159,14 @@ async function advanceRun(runId) {
return; // paused — scheduler resumes when wake_at passes
}
case 'gate': {
+ // Dry-run (test-fire): auto-take the 'confirm' path so the escalation
+ // is exercised end-to-end, without creating an approval / emailing.
+ if (context.vars.__dryRun) {
+ const e = outEdge(edges, currentKey, 'confirm') || outEdge(edges, currentKey, null);
+ nextKey = e ? e.to_node : null;
+ await recordStep(runId, node, 'skipped', { dryRun: true, gateAutoConfirm: true });
+ break;
+ }
await db('workflow_runs').where({ id: runId })
.update({ status: 'waiting', wake_at: gateTimeout(node.config), current_node: currentKey, context: JSON.stringify(context) });
await recordStep(runId, node, 'waiting', { gate: true });
@@ -364,10 +380,40 @@ async function recoverStaleRuns({ staleMs = RECOVERY_STALE_MS, limit = 50 } = {}
}
}
+/**
+ * Test-fire a workflow on demand (admin testing). Creates a run for the given
+ * entity/payload and starts it. Defaults to dryRun: side-effecting actions are
+ * mocked, waits pass through, and gates auto-take 'confirm' — so the WHOLE flow
+ * runs in one shot and the step log shows exactly what it would do, without
+ * sending real customer mail or charging fees.
+ */
+async function testRun(workflowId, { entityType = null, entityId = null, payload = {}, dryRun = true } = {}) {
+ const wf = await db('workflows').where({ id: workflowId }).first();
+ if (!wf) throw new Error('Workflow not found');
+ const vars = { ...(payload || {}), __test: true };
+ if (dryRun) vars.__dryRun = true;
+ const dedupKey = `test:${workflowId}:${Date.now()}:${Math.round(Math.random() * 1e9)}`;
+ await db('workflow_runs').insert({
+ workflow_id: wf.id,
+ version: wf.version,
+ trigger_event: `test:${wf.trigger_type}`,
+ entity_type: entityType,
+ entity_id: entityId,
+ status: 'pending',
+ context: JSON.stringify({ vars }),
+ dedup_key: dedupKey,
+ updated_at: db.fn.now(),
+ });
+ const row = await db('workflow_runs').where({ dedup_key: dedupKey }).first();
+ await startRun(row.id);
+ return row.id;
+}
+
module.exports = {
emitWorkflowEvent,
runDueWaits,
recoverStaleRuns,
+ testRun,
startRun,
advanceRun,
resumeRun,
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json
index 5ecea9f6..2bf9925d 100644
--- a/frontend/src/i18n/locales/de.json
+++ b/frontend/src/i18n/locales/de.json
@@ -212,6 +212,14 @@
"enabled": "Aktiv",
"disabled": "Inaktiv",
"confirmDelete": "Diesen Workflow löschen?",
+ "test": {
+ "title": "Testlauf",
+ "hint": "Probelauf: durchläuft den ganzen Ablauf sofort (Wartezeiten übersprungen, Gates automatisch bestätigt), Nebeneffekte werden nur simuliert – keine echten E-Mails. Optional eine Entitäts-ID (z. B. eine Rechnung) angeben, damit Bedingungen sie lesen können.",
+ "entityId": "Entitäts-ID (optional, z. B. Rechnungs-ID)",
+ "run": "Probelauf starten",
+ "result": "Ergebnis",
+ "failed": "Testlauf fehlgeschlagen"
+ },
"toast": {
"createFailed": "Workflow konnte nicht erstellt werden",
"deleted": "Workflow gelöscht",
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index 49948527..4db1229e 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -212,6 +212,14 @@
"enabled": "Enabled",
"disabled": "Disabled",
"confirmDelete": "Delete this workflow?",
+ "test": {
+ "title": "Test run",
+ "hint": "Dry run: walks the whole flow now (waits skipped, gates auto-confirmed) with side effects mocked — no real emails. Optionally give an entity id (e.g. an invoice) so conditions can read it.",
+ "entityId": "Entity id (optional, e.g. invoice id)",
+ "run": "Run dry test",
+ "result": "Result",
+ "failed": "Test run failed"
+ },
"toast": {
"createFailed": "Could not create workflow",
"deleted": "Workflow deleted",
diff --git a/frontend/src/pages/admin/workflows/WorkflowsListPage.tsx b/frontend/src/pages/admin/workflows/WorkflowsListPage.tsx
index c62def0a..8b31753e 100644
--- a/frontend/src/pages/admin/workflows/WorkflowsListPage.tsx
+++ b/frontend/src/pages/admin/workflows/WorkflowsListPage.tsx
@@ -4,14 +4,14 @@
* workflow" mints a minimal trigger→action graph and opens the editor. A
* pending-approvals shortcut sits in the header.
*/
-import React from 'react';
+import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
-import { Plus, Workflow as WorkflowIcon, Inbox, Trash2, Pencil } from 'lucide-react';
+import { Plus, Workflow as WorkflowIcon, Inbox, Trash2, Pencil, FlaskConical } from 'lucide-react';
import { Button, Card, Loading } from '../../../components/common';
-import { workflowsService, type WorkflowSummary, type WorkflowSavePayload } from '../../../services/workflows.service';
+import { workflowsService, type WorkflowSummary, type WorkflowSavePayload, type WorkflowTestResult } from '../../../services/workflows.service';
const NEW_WORKFLOW: WorkflowSavePayload = {
name: 'New workflow',
@@ -43,6 +43,18 @@ export const WorkflowsListPage: React.FC = () => {
onError: (err: any) => toast.error(err?.response?.data?.error || (t('workflows.toast.createFailed', 'Could not create workflow') as string)),
});
+ const [testTarget, setTestTarget] = useState(null);
+ const [testEntityId, setTestEntityId] = useState('');
+ const [testResult, setTestResult] = useState(null);
+ const testMutation = useMutation({
+ mutationFn: () => workflowsService.testRun(testTarget!.id, {
+ entityId: testEntityId ? Number(testEntityId) : null,
+ dryRun: true,
+ }),
+ onSuccess: (res) => setTestResult(res),
+ onError: (err: any) => toast.error(err?.response?.data?.error || (t('workflows.test.failed', 'Test run failed') as string)),
+ });
+
const toggleMutation = useMutation({
mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) => workflowsService.setEnabled(id, enabled),
onSuccess: () => qc.invalidateQueries({ queryKey: ['workflows'] }),
@@ -112,6 +124,9 @@ export const WorkflowsListPage: React.FC = () => {
>
{isEnabled(w) ? t('workflows.enabled', 'Enabled') : t('workflows.disabled', 'Disabled')}
+
@@ -130,6 +145,46 @@ export const WorkflowsListPage: React.FC = () => {
)}
+
+ {testTarget && (
+
+ {t('workflows.test.hint', 'Dry run: walks the whole flow now (waits skipped, gates auto-confirmed) with side effects mocked — no real emails. Optionally give an entity id (e.g. an invoice) so conditions can read it.')}
+
+ {i + 1}.
+ {s.node_type}:{s.node_key}
+ {s.status}
+ {s.result && (s.result as any).would ? → would {String((s.result as any).would)} : null}
+ {s.error ? {s.error} : null}
+
+ ))}
+
+
+ )}
+
+
+ )}
);
};
diff --git a/frontend/src/services/workflows.service.ts b/frontend/src/services/workflows.service.ts
index 644eb4e5..420ff32d 100644
--- a/frontend/src/services/workflows.service.ts
+++ b/frontend/src/services/workflows.service.ts
@@ -89,4 +89,23 @@ export const workflowsService = {
approvals: async (): Promise => (await api.get('/admin/workflows/approvals')).data,
actApproval: async (id: number, action: 'confirm' | 'deny') =>
(await api.post(`/admin/workflows/approvals/${id}/${action}`)).data,
+ testRun: async (
+ id: number,
+ body: { entityType?: string | null; entityId?: number | null; payload?: Record; dryRun?: boolean },
+ ): Promise => (await api.post(`/admin/workflows/${id}/test-run`, body)).data,
};
+
+export interface WorkflowTestStep {
+ node_key: string;
+ node_type?: string | null;
+ status: string;
+ result?: Record | null;
+ error?: string | null;
+}
+
+export interface WorkflowTestResult {
+ runId: number;
+ dryRun: boolean;
+ status: string;
+ steps: WorkflowTestStep[];
+}
From 62ba905464387784be7710610a65341569b68e46 Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Tue, 23 Jun 2026 14:11:11 +0200
Subject: [PATCH 24/43] feat(workflows): seed booking + pre-event built-ins,
wire event.date_approaching
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three more editable built-in flows, seeded disabled like the dunning ladder:
- booking_full: quote.accepted → prepare/send contract → admin "signed?" gate
→ create event → wait to event date → prepare/send invoice
- booking_simple: the no-contract path (quote.accepted → event → invoice)
- pre_event_email: customer reminder + admin heads-up, fired daysBefore the
event date
The booking document actions stay stubs (observable skipped steps) until the
booking cutover. pre_event_email uses the already-wired send_email action, so
it is functional once enabled — backed by a new scheduler emitter
(emitDueEventReminders) that fires event.date_approaching for events entering a
flow's lead window, deduped per event. Refactors the boot seeder to a built-in
registry so each flow self-heals on its own SEED_VERSION.
---
.../integration/workflowEngine.test.js | 53 ++++
backend/src/services/_workflowSeedBoot.js | 285 +++++++++++++-----
.../src/services/invoiceSchedulerService.js | 3 +
backend/src/services/workflows/engine.js | 74 +++++
4 files changed, 342 insertions(+), 73 deletions(-)
diff --git a/backend/__tests__/integration/workflowEngine.test.js b/backend/__tests__/integration/workflowEngine.test.js
index 3aa747dd..22ff4a17 100644
--- a/backend/__tests__/integration/workflowEngine.test.js
+++ b/backend/__tests__/integration/workflowEngine.test.js
@@ -280,6 +280,59 @@ describe('workflow engine', () => {
expect(after.version).toBe(before.version); // unchanged
});
+ test('seeds the booking + pre-event built-ins (disabled, correct triggers)', async () => {
+ const { seedBuiltinWorkflowsAtBoot } = require('../../src/services/_workflowSeedBoot');
+ await seedBuiltinWorkflowsAtBoot(db, { info() {}, warn() {} });
+
+ const bookingFull = await db('workflows').where({ builtin_key: 'booking_full' }).first();
+ expect(bookingFull).toBeTruthy();
+ expect(!!bookingFull.enabled).toBe(false);
+ expect(bookingFull.trigger_type).toBe('quote.accepted');
+ const fullNodes = await db('workflow_nodes').where({ workflow_id: bookingFull.id, version: bookingFull.version });
+ expect(fullNodes.some((n) => n.type === 'gate')).toBe(true); // contract-signed gate
+ expect(fullNodes.some((n) => JSON.parse(n.config || '{}').action === 'prepare_contract')).toBe(true);
+
+ const bookingSimple = await db('workflows').where({ builtin_key: 'booking_simple' }).first();
+ expect(bookingSimple).toBeTruthy();
+ expect(bookingSimple.trigger_type).toBe('quote.accepted');
+
+ const preEvent = await db('workflows').where({ builtin_key: 'pre_event_email' }).first();
+ expect(preEvent).toBeTruthy();
+ expect(preEvent.trigger_type).toBe('event.date_approaching');
+ expect(JSON.parse(preEvent.trigger_config).daysBefore).toBe(3);
+ const preNodes = await db('workflow_nodes').where({ workflow_id: preEvent.id, version: preEvent.version });
+ expect(preNodes.some((n) => JSON.parse(n.config || '{}').action === 'send_email')).toBe(true);
+ });
+
+ test('emitDueEventReminders starts a run for an event inside the lead window', async () => {
+ const wfId = await makeWorkflow({
+ trigger: 'event.date_approaching',
+ enabled: true,
+ nodes: [{ key: 'pe1', type: 'trigger' }, { key: 'pe2', type: 'action', config: { action: 'noop' } }],
+ edges: [{ from: 'pe1', to: 'pe2' }],
+ });
+ // Park the workflow's trigger window at 5 days so our event (2 days out) is in range.
+ await db('workflows').where({ id: wfId }).update({ trigger_config: JSON.stringify({ daysBefore: 5 }) });
+
+ const inWindow = new Date(Date.now() + 2 * 86400000).toISOString().slice(0, 10);
+ const tooFar = new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10);
+ const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
+ const evt = { event_type: 'wedding', password_hash: 'x', expires_at: farFuture, is_active: true, is_archived: false, customer_email: 'c@x.test' };
+ await db('events').insert({ ...evt, slug: 'pe-soon', share_link: 'pe-soon', event_name: 'Soon', event_date: inWindow });
+ await db('events').insert({ ...evt, slug: 'pe-far', share_link: 'pe-far', event_name: 'Far', event_date: tooFar });
+
+ const emitted = await engine.emitDueEventReminders();
+ expect(emitted).toBeGreaterThanOrEqual(1);
+
+ const runs = await db('workflow_runs').where({ workflow_id: wfId, entity_type: 'event' });
+ expect(runs.length).toBe(1); // only the in-window event, not the far one
+
+ // Idempotent: a second pass dedups (no duplicate run for the same event).
+ await engine.emitDueEventReminders();
+ const runs2 = await db('workflow_runs').where({ workflow_id: wfId, entity_type: 'event' });
+ expect(runs2.length).toBe(1);
+ });
+
test('recoverStaleRuns resumes a run orphaned mid-flow (crash recovery)', async () => {
const wfId = await makeWorkflow({
trigger: 'recover.event',
diff --git a/backend/src/services/_workflowSeedBoot.js b/backend/src/services/_workflowSeedBoot.js
index 4f33c9a4..d1137acc 100644
--- a/backend/src/services/_workflowSeedBoot.js
+++ b/backend/src/services/_workflowSeedBoot.js
@@ -1,34 +1,33 @@
/**
* Boot-time seed for built-in workflows.
*
- * Seeds the invoice-dunning ladder as an EDITABLE built-in flow (the corrected
- * gate-in-loop graph), so the canvas has real content and admins can see their
- * reminder process as blocks. Seeded from the current reminder settings.
+ * Seeds the reminder/booking ladders as EDITABLE built-in flows, so the canvas
+ * has real content and admins can see (and tweak) their processes as blocks.
*
- * IMPORTANT — seeded DISABLED, and live behaviour is UNCHANGED: the existing
- * hardcoded reminder ladder in invoiceService.runScheduledTasks still runs. The
- * cutover (drive reminders through the engine + stop the hardcoded ladder) is a
- * deliberate follow-up so we never double-send. Enabling this flow before that
- * cutover would duplicate reminders — hence default off.
+ * IMPORTANT — every built-in is seeded DISABLED. Live behaviour is UNCHANGED
+ * until an admin enables a flow: the hardcoded reminder ladder still runs, and
+ * the booking document actions (prepare_quote/contract/event/invoice) are still
+ * stubs that record an observable `skipped` step rather than firing. The
+ * cutover (drive each process through the engine + stop the hardcoded path) is a
+ * deliberate follow-up so we never double-act. Enabling a flow before its
+ * cutover is safe — at worst it records skipped steps — but the dunning flow in
+ * particular auto-suppresses the hardcoded ladder while enabled so the two never
+ * double-send.
*
- * Idempotent: keyed on builtin_key='invoice_dunning'. Once seeded, admin edits
- * are preserved (we never overwrite an existing built-in). Self-heal pattern
- * per [[feedback_self_heal_pattern]].
+ * Idempotent: keyed on builtin_key. Once seeded, admin edits are preserved (we
+ * never overwrite an enabled built-in, and re-seed a disabled one only when its
+ * SEED_VERSION moves on). Self-heal pattern per [[feedback_self_heal_pattern]].
*/
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;
-// 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
// existing confirm + reminder_level + Mahngebühr state machine), so the flow
// just decides WHEN to fire it. After due date + grace, loop up to
// maxReminders times: if still unpaid, queue a payment-check, wait the gap,
- // repeat; stop early once paid.
+ // repeat; stop early once paid. After the loop exhausts → collections handoff.
const nodes = [
{ node_key: 't', type: 'trigger', config: {}, pos_x: 240, pos_y: 0 },
{ node_key: 'waitDue', type: 'wait', config: { untilVar: 'dueDate' }, pos_x: 240, pos_y: 110 },
@@ -56,6 +55,146 @@ function buildDunningGraph({ firstDays, gapDays, maxReminders }) {
return { nodes, edges };
}
+// Booking — quote accepted → prepare + send contract → admin gate "signed?" →
+// create the event/gallery → wait to the event date → prepare + send invoice.
+// The signing step is an admin gate (no e-sign webhook yet); the document
+// actions are stubs until the booking cutover, so an enabled run records
+// observable skipped steps rather than acting.
+function buildBookingFullGraph() {
+ const nodes = [
+ { node_key: 't', type: 'trigger', config: {}, pos_x: 240, pos_y: 0 },
+ { node_key: 'prepContract', type: 'action', config: { action: 'prepare_contract' }, pos_x: 240, pos_y: 110 },
+ { node_key: 'sendContract', type: 'action', config: { action: 'send_document', document: 'contract', recipient: 'customer' }, pos_x: 240, pos_y: 220 },
+ { node_key: 'gateSigned', type: 'gate', config: { label: 'Contract signed?' }, pos_x: 240, pos_y: 330 },
+ { node_key: 'prepEvent', type: 'action', config: { action: 'prepare_event' }, pos_x: 240, pos_y: 440 },
+ { node_key: 'waitEvent', type: 'wait', config: { untilVar: 'eventDate' }, pos_x: 240, pos_y: 550 },
+ { node_key: 'prepInvoice', type: 'action', config: { action: 'prepare_invoice' }, pos_x: 240, pos_y: 660 },
+ { node_key: 'sendInvoice', type: 'action', config: { action: 'send_document', document: 'invoice', recipient: 'customer' }, pos_x: 240, pos_y: 770 },
+ { node_key: 'done', type: 'action', config: { action: 'noop' }, pos_x: 240, pos_y: 880 },
+ { node_key: 'declined', type: 'action', config: { action: 'noop' }, pos_x: 520, pos_y: 330 },
+ ];
+ const edges = [
+ { from_node: 't', to_node: 'prepContract' },
+ { from_node: 'prepContract', to_node: 'sendContract' },
+ { from_node: 'sendContract', to_node: 'gateSigned' },
+ { from_node: 'gateSigned', from_handle: 'confirm', to_node: 'prepEvent' },
+ { from_node: 'gateSigned', from_handle: 'deny', to_node: 'declined' },
+ { from_node: 'prepEvent', to_node: 'waitEvent' },
+ { from_node: 'waitEvent', to_node: 'prepInvoice' },
+ { from_node: 'prepInvoice', to_node: 'sendInvoice' },
+ { from_node: 'sendInvoice', to_node: 'done' },
+ ];
+ return { nodes, edges };
+}
+
+// Booking — quote accepted → create the event/gallery → wait to the event date
+// → prepare + send invoice. The no-contract path (e.g. small shoots). Same stub
+// caveat as the full booking flow.
+function buildBookingSimpleGraph() {
+ const nodes = [
+ { node_key: 't', type: 'trigger', config: {}, pos_x: 240, pos_y: 0 },
+ { node_key: 'prepEvent', type: 'action', config: { action: 'prepare_event' }, pos_x: 240, pos_y: 110 },
+ { node_key: 'waitEvent', type: 'wait', config: { untilVar: 'eventDate' }, pos_x: 240, pos_y: 220 },
+ { node_key: 'prepInvoice', type: 'action', config: { action: 'prepare_invoice' }, pos_x: 240, pos_y: 330 },
+ { node_key: 'sendInvoice', type: 'action', config: { action: 'send_document', document: 'invoice', recipient: 'customer' }, pos_x: 240, pos_y: 440 },
+ { node_key: 'done', type: 'action', config: { action: 'noop' }, pos_x: 240, pos_y: 550 },
+ ];
+ const edges = [
+ { from_node: 't', to_node: 'prepEvent' },
+ { from_node: 'prepEvent', to_node: 'waitEvent' },
+ { from_node: 'waitEvent', to_node: 'prepInvoice' },
+ { from_node: 'prepInvoice', to_node: 'sendInvoice' },
+ { from_node: 'sendInvoice', to_node: 'done' },
+ ];
+ return { nodes, edges };
+}
+
+// Pre-event email — fired by the scheduler `daysBefore` the event date (see
+// emitDueEventReminders in the engine). Sends a customer reminder, then a heads-
+// up to the admin. Unlike the booking flows this uses the already-wired
+// send_email action, so it is functional once enabled (the customer template
+// `pre_event_reminder` should exist / be authored).
+function buildPreEventEmailGraph() {
+ const nodes = [
+ { node_key: 't', type: 'trigger', config: {}, pos_x: 240, pos_y: 0 },
+ { node_key: 'emailCustomer', type: 'action', config: { action: 'send_email', recipientClass: 'customer', emailType: 'pre_event_reminder' }, pos_x: 240, pos_y: 110 },
+ { node_key: 'emailAdmin', type: 'action', config: { action: 'send_email', recipientClass: 'admin', emailType: 'pre_event_internal' }, pos_x: 240, pos_y: 220 },
+ { node_key: 'done', type: 'action', config: { action: 'noop' }, pos_x: 240, pos_y: 330 },
+ ];
+ const edges = [
+ { from_node: 't', to_node: 'emailCustomer' },
+ { from_node: 'emailCustomer', to_node: 'emailAdmin' },
+ { from_node: 'emailAdmin', to_node: 'done' },
+ ];
+ return { nodes, edges };
+}
+
+// Built-in registry. `version` is the SEED_VERSION — bump when a graph changes
+// so a disabled, never-activated copy is re-seeded on boot.
+// invoice_dunning v4 = collections handoff after the loop exhausts.
+const BUILTINS = [
+ {
+ key: DUNNING_KEY,
+ version: 4,
+ name: 'Invoice dunning (built-in)',
+ trigger_type: 'invoice.sent',
+ trigger_config: {},
+ description:
+ 'Drives overdue dunning through the engine: wait to the due date, then up to '
+ + 'three payment-check cycles. Each cycle fires the existing admin confirm-payment '
+ + 'email (the gate), which applies reminders + Mahngebühr via the proven payment-check '
+ + 'flow; after the cycles exhaust it hands the case to collections. Disabled by default; '
+ + 'while it is enabled the hardcoded reminder ladder is skipped automatically, so the two '
+ + 'never double-send.',
+ build: async () => {
+ const firstDays = Number(await getAppSetting('crm_invoices_reminder_first_days')) || 14;
+ const secondDays = Number(await getAppSetting('crm_invoices_reminder_second_days')) || 30;
+ const gapDays = Math.max(1, secondDays - firstDays);
+ return buildDunningGraph({ firstDays, gapDays, maxReminders: 3 });
+ },
+ },
+ {
+ key: 'booking_full',
+ version: 1,
+ name: 'Booking — quote → contract → event → invoice (built-in)',
+ trigger_type: 'quote.accepted',
+ trigger_config: {},
+ description:
+ 'On quote acceptance: prepare and send the contract, wait for the admin to confirm it is '
+ + 'signed, then create the event/gallery, wait to the shoot date and prepare + send the '
+ + 'invoice. Disabled by default — the document actions are stubs until the booking cutover, '
+ + 'so an enabled run just records observable skipped steps. A starting point to edit.',
+ build: async () => buildBookingFullGraph(),
+ },
+ {
+ key: 'booking_simple',
+ version: 1,
+ name: 'Booking — quote → event → invoice (built-in)',
+ trigger_type: 'quote.accepted',
+ trigger_config: {},
+ description:
+ 'The no-contract booking path: on quote acceptance create the event/gallery, wait to the '
+ + 'shoot date and prepare + send the invoice. Same stub caveat as the full booking flow; '
+ + 'disabled by default.',
+ build: async () => buildBookingSimpleGraph(),
+ },
+ {
+ key: 'pre_event_email',
+ version: 1,
+ name: 'Pre-event email (built-in)',
+ trigger_type: 'event.date_approaching',
+ // daysBefore drives the scheduler emitter — how many days before the event
+ // date the reminder fires.
+ trigger_config: { daysBefore: 3 },
+ description:
+ 'A few days before the event date, send the customer a reminder and the admin a heads-up. '
+ + 'Fired by the scheduler from the event date (daysBefore in the trigger config). Uses the '
+ + 'wired send_email action, so it works once enabled and the pre_event_reminder template '
+ + 'exists. Disabled by default.',
+ build: async () => buildPreEventEmailGraph(),
+ },
+];
+
let booted = false;
function parseSeedConfig(raw) {
@@ -79,70 +218,70 @@ async function writeGraph(trx, workflowId, version, nodes, edges) {
}
}
+async function seedOneBuiltin(db, logger, def) {
+ const { nodes, edges } = await def.build();
+ const triggerConfig = { ...(def.trigger_config || {}), seedVersion: def.version };
+
+ const existing = await db('workflows').where({ builtin_key: def.key }).first();
+
+ if (existing) {
+ // Re-seed the graph only when (a) it has never been activated and (b) our
+ // seed version moved on. Once the admin enables it, it's their live flow —
+ // never overwrite it.
+ const storedVersion = Number(parseSeedConfig(existing.trigger_config).seedVersion) || 0;
+ const isEnabled = existing.enabled === true || existing.enabled === 1;
+ if (isEnabled || storedVersion >= def.version) return;
+
+ const newVersion = (existing.version || 1) + 1;
+ await db.transaction(async (trx) => {
+ await trx('workflows').where({ id: existing.id }).update({
+ name: def.name,
+ description: def.description,
+ trigger_type: def.trigger_type,
+ trigger_config: JSON.stringify(triggerConfig),
+ version: newVersion,
+ updated_at: trx.fn.now(),
+ });
+ await writeGraph(trx, existing.id, newVersion, nodes, edges);
+ });
+ logger?.info?.(`Re-seeded built-in workflow: ${def.key} (v${def.version})`);
+ return;
+ }
+
+ await db.transaction(async (trx) => {
+ const ins = await trx('workflows').insert({
+ name: def.name,
+ description: def.description,
+ enabled: false,
+ version: 1,
+ trigger_type: def.trigger_type,
+ trigger_config: JSON.stringify(triggerConfig),
+ is_builtin: true,
+ builtin_key: def.key,
+ }).returning('id');
+ // Postgres returns [] without `.returning`, so ins[0] would be undefined and
+ // the child node inserts would roll back on NOT NULL. Normalise the {id}
+ // (pg) vs bare-id (sqlite) shapes.
+ const workflowId = ins[0]?.id ?? ins[0];
+ await writeGraph(trx, workflowId, 1, nodes, edges);
+ });
+ logger?.info?.(`Seeded built-in workflow: ${def.key} (disabled)`);
+}
+
async function seedBuiltinWorkflowsAtBoot(db, logger) {
try {
if (!(await db.schema.hasTable('workflows'))) return;
-
- const firstDays = Number(await getAppSetting('crm_invoices_reminder_first_days')) || 14;
- const secondDays = Number(await getAppSetting('crm_invoices_reminder_second_days')) || 30;
- const gapDays = Math.max(1, secondDays - firstDays);
- const { nodes, edges } = buildDunningGraph({ firstDays, gapDays, maxReminders: 3 });
-
- const description = 'Drives overdue dunning through the engine: wait to the due date, then up '
- + 'to two payment-check cycles. Each cycle fires the existing admin confirm-payment email '
- + '(the gate), which applies reminders + Mahngebühr via the proven payment-check flow. '
- + 'Disabled by default; while it is enabled the hardcoded reminder ladder is skipped '
- + 'automatically, so the two never double-send.';
-
- const existing = await db('workflows').where({ builtin_key: DUNNING_KEY }).first();
-
- if (existing) {
- // Re-seed the graph only when (a) it has never been activated and (b) our
- // seed version moved on (the dunning cutover). Once the admin enables it,
- // it's their live flow — never overwrite it.
- const storedVersion = Number(parseSeedConfig(existing.trigger_config).seedVersion) || 0;
- const isEnabled = existing.enabled === true || existing.enabled === 1;
- if (isEnabled || storedVersion >= SEED_VERSION) { booted = true; return; }
-
- const newVersion = (existing.version || 1) + 1;
- await db.transaction(async (trx) => {
- await trx('workflows').where({ id: existing.id }).update({
- name: 'Invoice dunning (built-in)',
- description,
- trigger_config: JSON.stringify({ seedVersion: SEED_VERSION }),
- version: newVersion,
- updated_at: trx.fn.now(),
- });
- await writeGraph(trx, existing.id, newVersion, nodes, edges);
- });
- booted = true;
- logger?.info?.('Re-seeded built-in workflow: invoice dunning (delegation graph v2)');
- return;
+ for (const def of BUILTINS) {
+ try {
+ await seedOneBuiltin(db, logger, def);
+ } catch (err) {
+ logger?.warn?.(`Built-in workflow seed failed for ${def.key}:`, err.message);
+ }
}
-
- await db.transaction(async (trx) => {
- const ins = await trx('workflows').insert({
- name: 'Invoice dunning (built-in)',
- description,
- enabled: false,
- version: 1,
- trigger_type: 'invoice.sent',
- trigger_config: JSON.stringify({ seedVersion: SEED_VERSION }),
- is_builtin: true,
- builtin_key: DUNNING_KEY,
- }).returning('id');
- // Postgres returns [] without `.returning`, so ins[0] would be undefined
- // and the child node inserts would roll back on NOT NULL. Normalise the
- // {id} (pg) vs bare-id (sqlite) shapes.
- const workflowId = ins[0]?.id ?? ins[0];
- await writeGraph(trx, workflowId, 1, nodes, edges);
- });
-
booted = true;
- logger?.info?.('Seeded built-in workflow: invoice dunning (disabled)');
} catch (err) {
logger?.warn?.('Built-in workflow seed failed at boot:', err.message);
}
}
-module.exports = { seedBuiltinWorkflowsAtBoot, buildDunningGraph, DUNNING_KEY };
+module.exports = { seedBuiltinWorkflowsAtBoot, buildDunningGraph, DUNNING_KEY, BUILTINS };
diff --git a/backend/src/services/invoiceSchedulerService.js b/backend/src/services/invoiceSchedulerService.js
index 49fe983d..a6ec2644 100644
--- a/backend/src/services/invoiceSchedulerService.js
+++ b/backend/src/services/invoiceSchedulerService.js
@@ -49,6 +49,9 @@ async function runTick() {
const wf = require('./workflows');
const resumed = await wf.runDueWaits();
if (resumed) logger.info('Workflow scheduler: resumed waiting runs', { resumed });
+ // Fire pre-event reminders for events entering an enabled flow's lead window.
+ const preEvent = await wf.emitDueEventReminders();
+ if (preEvent) logger.info('Workflow scheduler: emitted pre-event reminders', { preEvent });
// Recover runs orphaned by a crash (stuck in running/pending). Runs on the
// boot tick too, so a restart catches anything stranded during downtime.
const recovered = await wf.recoverStaleRuns();
diff --git a/backend/src/services/workflows/engine.js b/backend/src/services/workflows/engine.js
index 4bc86408..11d90dbe 100644
--- a/backend/src/services/workflows/engine.js
+++ b/backend/src/services/workflows/engine.js
@@ -380,6 +380,79 @@ async function recoverStaleRuns({ staleMs = RECOVERY_STALE_MS, limit = 50 } = {}
}
}
+/**
+ * Emit `event.date_approaching` for events whose date is within the configured
+ * lead window of an enabled pre-event flow. Iterates per flow so each respects
+ * its own trigger_config.daysBefore; emitWorkflowEvent's per-(flow,entity)
+ * dedup_key guarantees a single run per event, so polling hourly never
+ * duplicates. Fails CLOSED when the workflows flag is off. Called from the
+ * scheduler tick.
+ *
+ * Caveat (v1): emitWorkflowEvent fans out to ALL enabled flows of this trigger,
+ * so with multiple pre-event flows the widest window wins for surfacing an
+ * event; a narrower flow may then fire earlier than its own daysBefore. The
+ * single-built-in case (the norm) is exact.
+ */
+async function emitDueEventReminders(limit = 200) {
+ try {
+ const { isFeatureEnabled } = require('../../middleware/requireFeatureFlag');
+ let enabled = false;
+ try { enabled = await isFeatureEnabled('workflows'); } catch (e) { return 0; }
+ if (!enabled) return 0;
+ if (!(await db.schema.hasTable('events'))) return 0;
+
+ const flows = await db('workflows').where({ enabled: true, trigger_type: 'event.date_approaching' });
+ if (!flows.length) return 0;
+
+ // The admin heads-up resolves its recipient from ctx.vars.adminEmail; events
+ // don't carry one, so source it from the business profile (best-effort).
+ let adminEmail = null;
+ try {
+ if (await db.schema.hasTable('business_profile')) {
+ const profile = await db('business_profile').where({ id: 1 }).first();
+ adminEmail = profile?.email || null;
+ }
+ } catch (_) { /* best-effort */ }
+
+ let emitted = 0;
+ const todayIso = new Date().toISOString().slice(0, 10);
+ for (const wf of flows) {
+ const cfg = parseJson(wf.trigger_config, {});
+ const daysBefore = Number(cfg.daysBefore) > 0 ? Number(cfg.daysBefore) : 3;
+ const windowEndIso = new Date(Date.now() + daysBefore * 86400000).toISOString().slice(0, 10);
+
+ const events = await db('events')
+ .where('is_active', true)
+ .where('is_archived', false)
+ .whereNotNull('event_date')
+ .where('event_date', '>=', todayIso)
+ .where('event_date', '<=', windowEndIso)
+ .limit(limit);
+
+ for (const ev of events) {
+ const runIds = await emitWorkflowEvent('event.date_approaching', {
+ entityType: 'event',
+ entityId: ev.id,
+ payload: {
+ eventId: ev.id,
+ eventName: ev.event_name || null,
+ eventDate: ev.event_date,
+ hostName: ev.host_name || null,
+ customerEmail: ev.customer_email || ev.host_email || null,
+ adminEmail,
+ daysBefore,
+ },
+ });
+ emitted += runIds.length;
+ }
+ }
+ return emitted;
+ } catch (e) {
+ logger.error('[workflow] emitDueEventReminders failed', { error: e.message });
+ return 0;
+ }
+}
+
/**
* Test-fire a workflow on demand (admin testing). Creates a run for the given
* entity/payload and starts it. Defaults to dryRun: side-effecting actions are
@@ -412,6 +485,7 @@ async function testRun(workflowId, { entityType = null, entityId = null, payload
module.exports = {
emitWorkflowEvent,
runDueWaits,
+ emitDueEventReminders,
recoverStaleRuns,
testRun,
startRun,
From b38c216f22495a0091d3467a31ba33bcec53d0e7 Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Tue, 23 Jun 2026 14:40:07 +0200
Subject: [PATCH 25/43] test(workflows): raise beforeAll timeout for
migration-heavy CRM suites
The workflow/dunning suites boot the full core-migration set in beforeAll via
bootCrmDb. In isolation that's ~1.3s, but under full-suite parallel load on a
small CI runner it can exceed Jest's 5s default, timing out beforeAll and
failing every test in the file (the CI flake). Match the existing pattern used
by the other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill)
and set jest.setTimeout(30000) on workflowEngine, workflowRoutes and
invoiceDunning.
---
backend/__tests__/integration/invoiceDunning.test.js | 5 +++++
backend/__tests__/integration/workflowEngine.test.js | 5 +++++
backend/__tests__/integration/workflowRoutes.test.js | 5 +++++
3 files changed, 15 insertions(+)
diff --git a/backend/__tests__/integration/invoiceDunning.test.js b/backend/__tests__/integration/invoiceDunning.test.js
index a1d54301..8b4194a3 100644
--- a/backend/__tests__/integration/invoiceDunning.test.js
+++ b/backend/__tests__/integration/invoiceDunning.test.js
@@ -10,6 +10,11 @@
*/
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
+// bootCrmDb runs the full core-migration set in beforeAll; under full-suite
+// parallel load on a small CI runner that can exceed the 5s default. Match the
+// other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill).
+jest.setTimeout(30000);
+
let db;
let cleanup;
let invoiceService;
diff --git a/backend/__tests__/integration/workflowEngine.test.js b/backend/__tests__/integration/workflowEngine.test.js
index 22ff4a17..e57894e5 100644
--- a/backend/__tests__/integration/workflowEngine.test.js
+++ b/backend/__tests__/integration/workflowEngine.test.js
@@ -7,6 +7,11 @@
*/
const { bootCrmDb } = require('./helpers/crmDb');
+// bootCrmDb runs the full core-migration set in beforeAll; under full-suite
+// parallel load on a small CI runner that can exceed the 5s default. Match the
+// other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill).
+jest.setTimeout(30000);
+
let db;
let cleanup;
let engine;
diff --git a/backend/__tests__/integration/workflowRoutes.test.js b/backend/__tests__/integration/workflowRoutes.test.js
index e0b8024b..cda28f55 100644
--- a/backend/__tests__/integration/workflowRoutes.test.js
+++ b/backend/__tests__/integration/workflowRoutes.test.js
@@ -6,6 +6,11 @@ const {
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
} = require('./helpers/crmDb');
+// bootCrmDb runs the full core-migration set in beforeAll; under full-suite
+// parallel load on a small CI runner that can exceed the 5s default. Match the
+// other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill).
+jest.setTimeout(30000);
+
let db;
let cleanup;
let app;
From fa7b1bae951222de506ab8a3c8aee158b471efd1 Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Tue, 23 Jun 2026 14:58:48 +0200
Subject: [PATCH 26/43] feat(workflows): admin review gates before sends +
migrate lifecycle/time triggers
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Booking built-ins now gate every outbound document on an explicit admin OK:
prepare_* drafts the doc, the admin adjusts line items/terms, confirms the
"Review … before sending" gate, and only then does send_document fire. Added to
booking_full (contract + invoice) and booking_simple (invoice); seed versions
bumped so the disabled built-ins self-heal.
Migrated the remaining time- and event-driven triggers into the engine, all
additive / best-effort / fail-closed (no behaviour change when the flag is off):
- gallery.published (event creation)
- gallery.expiring + gallery.expired (expiration checker, alongside the email)
- quote.sent (was queued but never emitted — gap closed)
- contract.sent + contract.signed (sent, fully-signed via counter-sign or wet upload)
- customer.created (direct add + invitation accept)
- invoice.overdue (status→overdue flip, deduped per invoice)
Editor trigger list extended to match. Tests assert the review gates wire
confirm→send on both booking flows.
---
.../integration/workflowEngine.test.js | 10 ++-
backend/src/services/_workflowSeedBoot.js | 88 ++++++++++++-------
backend/src/services/contractService.js | 38 ++++++++
.../src/services/customerAccountsService.js | 19 ++++
backend/src/services/eventService.js | 23 +++++
backend/src/services/expirationChecker.js | 47 +++++++++-
backend/src/services/invoiceService.js | 21 +++++
backend/src/services/quoteService.js | 5 ++
.../admin/workflows/WorkflowEditorPage.tsx | 8 +-
9 files changed, 221 insertions(+), 38 deletions(-)
diff --git a/backend/__tests__/integration/workflowEngine.test.js b/backend/__tests__/integration/workflowEngine.test.js
index e57894e5..38abaca3 100644
--- a/backend/__tests__/integration/workflowEngine.test.js
+++ b/backend/__tests__/integration/workflowEngine.test.js
@@ -294,12 +294,20 @@ describe('workflow engine', () => {
expect(!!bookingFull.enabled).toBe(false);
expect(bookingFull.trigger_type).toBe('quote.accepted');
const fullNodes = await db('workflow_nodes').where({ workflow_id: bookingFull.id, version: bookingFull.version });
- expect(fullNodes.some((n) => n.type === 'gate')).toBe(true); // contract-signed gate
expect(fullNodes.some((n) => JSON.parse(n.config || '{}').action === 'prepare_contract')).toBe(true);
+ // Admin review gate guards BOTH document sends (adjust line items, then OK).
+ const fullGateKeys = fullNodes.filter((n) => n.type === 'gate').map((n) => n.node_key);
+ expect(fullGateKeys).toEqual(expect.arrayContaining(['reviewContract', 'reviewInvoice']));
+ const fullEdges = await db('workflow_edges').where({ workflow_id: bookingFull.id, version: bookingFull.version });
+ // reviewContract --confirm--> sendContract ; reviewInvoice --confirm--> sendInvoice
+ expect(fullEdges.some((e) => e.from_node === 'reviewContract' && e.from_handle === 'confirm' && e.to_node === 'sendContract')).toBe(true);
+ expect(fullEdges.some((e) => e.from_node === 'reviewInvoice' && e.from_handle === 'confirm' && e.to_node === 'sendInvoice')).toBe(true);
const bookingSimple = await db('workflows').where({ builtin_key: 'booking_simple' }).first();
expect(bookingSimple).toBeTruthy();
expect(bookingSimple.trigger_type).toBe('quote.accepted');
+ const simpleEdges = await db('workflow_edges').where({ workflow_id: bookingSimple.id, version: bookingSimple.version });
+ expect(simpleEdges.some((e) => e.from_node === 'reviewInvoice' && e.from_handle === 'confirm' && e.to_node === 'sendInvoice')).toBe(true);
const preEvent = await db('workflows').where({ builtin_key: 'pre_event_email' }).first();
expect(preEvent).toBeTruthy();
diff --git a/backend/src/services/_workflowSeedBoot.js b/backend/src/services/_workflowSeedBoot.js
index d1137acc..741323ed 100644
--- a/backend/src/services/_workflowSeedBoot.js
+++ b/backend/src/services/_workflowSeedBoot.js
@@ -55,55 +55,73 @@ function buildDunningGraph({ firstDays, gapDays, maxReminders }) {
return { nodes, edges };
}
-// Booking — quote accepted → prepare + send contract → admin gate "signed?" →
-// create the event/gallery → wait to the event date → prepare + send invoice.
-// The signing step is an admin gate (no e-sign webhook yet); the document
-// actions are stubs until the booking cutover, so an enabled run records
-// observable skipped steps rather than acting.
+// Booking — quote accepted → prepare contract → ADMIN REVIEW GATE → send
+// contract → admin gate "signed?" → create the event/gallery → wait to the
+// event date → prepare invoice → ADMIN REVIEW GATE → send invoice.
+//
+// A document is never sent without an explicit admin OK: prepare_* creates a
+// DRAFT, the admin adjusts line items / terms in the CRM, then confirms the
+// review gate, and only then does send_document fire. The "signed?" gate models
+// the external signing step (no e-sign webhook yet). The document actions are
+// stubs until the booking cutover, so an enabled run records observable skipped
+// steps rather than acting.
function buildBookingFullGraph() {
const nodes = [
- { node_key: 't', type: 'trigger', config: {}, pos_x: 240, pos_y: 0 },
- { node_key: 'prepContract', type: 'action', config: { action: 'prepare_contract' }, pos_x: 240, pos_y: 110 },
- { node_key: 'sendContract', type: 'action', config: { action: 'send_document', document: 'contract', recipient: 'customer' }, pos_x: 240, pos_y: 220 },
- { node_key: 'gateSigned', type: 'gate', config: { label: 'Contract signed?' }, pos_x: 240, pos_y: 330 },
- { node_key: 'prepEvent', type: 'action', config: { action: 'prepare_event' }, pos_x: 240, pos_y: 440 },
- { node_key: 'waitEvent', type: 'wait', config: { untilVar: 'eventDate' }, pos_x: 240, pos_y: 550 },
- { node_key: 'prepInvoice', type: 'action', config: { action: 'prepare_invoice' }, pos_x: 240, pos_y: 660 },
- { node_key: 'sendInvoice', type: 'action', config: { action: 'send_document', document: 'invoice', recipient: 'customer' }, pos_x: 240, pos_y: 770 },
- { node_key: 'done', type: 'action', config: { action: 'noop' }, pos_x: 240, pos_y: 880 },
- { node_key: 'declined', type: 'action', config: { action: 'noop' }, pos_x: 520, pos_y: 330 },
+ { node_key: 't', type: 'trigger', config: {}, pos_x: 320, pos_y: 0 },
+ { node_key: 'prepContract', type: 'action', config: { action: 'prepare_contract' }, pos_x: 320, pos_y: 110 },
+ { node_key: 'reviewContract', type: 'gate', config: { label: 'Review contract before sending' }, pos_x: 320, pos_y: 220 },
+ { node_key: 'sendContract', type: 'action', config: { action: 'send_document', document: 'contract', recipient: 'customer' }, pos_x: 320, pos_y: 330 },
+ { node_key: 'gateSigned', type: 'gate', config: { label: 'Contract signed?' }, pos_x: 320, pos_y: 440 },
+ { node_key: 'prepEvent', type: 'action', config: { action: 'prepare_event' }, pos_x: 320, pos_y: 550 },
+ { node_key: 'waitEvent', type: 'wait', config: { untilVar: 'eventDate' }, pos_x: 320, pos_y: 660 },
+ { node_key: 'prepInvoice', type: 'action', config: { action: 'prepare_invoice' }, pos_x: 320, pos_y: 770 },
+ { node_key: 'reviewInvoice', type: 'gate', config: { label: 'Review invoice before sending' }, pos_x: 320, pos_y: 880 },
+ { node_key: 'sendInvoice', type: 'action', config: { action: 'send_document', document: 'invoice', recipient: 'customer' }, pos_x: 320, pos_y: 990 },
+ { node_key: 'done', type: 'action', config: { action: 'noop' }, pos_x: 320, pos_y: 1100 },
+ { node_key: 'cancelContract', type: 'action', config: { action: 'noop' }, pos_x: 620, pos_y: 220 },
+ { node_key: 'declined', type: 'action', config: { action: 'noop' }, pos_x: 620, pos_y: 440 },
+ { node_key: 'cancelInvoice', type: 'action', config: { action: 'noop' }, pos_x: 620, pos_y: 880 },
];
const edges = [
{ from_node: 't', to_node: 'prepContract' },
- { from_node: 'prepContract', to_node: 'sendContract' },
+ { from_node: 'prepContract', to_node: 'reviewContract' },
+ { from_node: 'reviewContract', from_handle: 'confirm', to_node: 'sendContract' },
+ { from_node: 'reviewContract', from_handle: 'deny', to_node: 'cancelContract' },
{ from_node: 'sendContract', to_node: 'gateSigned' },
{ from_node: 'gateSigned', from_handle: 'confirm', to_node: 'prepEvent' },
{ from_node: 'gateSigned', from_handle: 'deny', to_node: 'declined' },
{ from_node: 'prepEvent', to_node: 'waitEvent' },
{ from_node: 'waitEvent', to_node: 'prepInvoice' },
- { from_node: 'prepInvoice', to_node: 'sendInvoice' },
+ { from_node: 'prepInvoice', to_node: 'reviewInvoice' },
+ { from_node: 'reviewInvoice', from_handle: 'confirm', to_node: 'sendInvoice' },
+ { from_node: 'reviewInvoice', from_handle: 'deny', to_node: 'cancelInvoice' },
{ from_node: 'sendInvoice', to_node: 'done' },
];
return { nodes, edges };
}
// Booking — quote accepted → create the event/gallery → wait to the event date
-// → prepare + send invoice. The no-contract path (e.g. small shoots). Same stub
-// caveat as the full booking flow.
+// → prepare invoice → ADMIN REVIEW GATE → send invoice. The no-contract path
+// (e.g. small shoots). Same review-before-send rule and stub caveat as the full
+// booking flow.
function buildBookingSimpleGraph() {
const nodes = [
- { node_key: 't', type: 'trigger', config: {}, pos_x: 240, pos_y: 0 },
- { node_key: 'prepEvent', type: 'action', config: { action: 'prepare_event' }, pos_x: 240, pos_y: 110 },
- { node_key: 'waitEvent', type: 'wait', config: { untilVar: 'eventDate' }, pos_x: 240, pos_y: 220 },
- { node_key: 'prepInvoice', type: 'action', config: { action: 'prepare_invoice' }, pos_x: 240, pos_y: 330 },
- { node_key: 'sendInvoice', type: 'action', config: { action: 'send_document', document: 'invoice', recipient: 'customer' }, pos_x: 240, pos_y: 440 },
- { node_key: 'done', type: 'action', config: { action: 'noop' }, pos_x: 240, pos_y: 550 },
+ { node_key: 't', type: 'trigger', config: {}, pos_x: 320, pos_y: 0 },
+ { node_key: 'prepEvent', type: 'action', config: { action: 'prepare_event' }, pos_x: 320, pos_y: 110 },
+ { node_key: 'waitEvent', type: 'wait', config: { untilVar: 'eventDate' }, pos_x: 320, pos_y: 220 },
+ { node_key: 'prepInvoice', type: 'action', config: { action: 'prepare_invoice' }, pos_x: 320, pos_y: 330 },
+ { node_key: 'reviewInvoice', type: 'gate', config: { label: 'Review invoice before sending' }, pos_x: 320, pos_y: 440 },
+ { node_key: 'sendInvoice', type: 'action', config: { action: 'send_document', document: 'invoice', recipient: 'customer' }, pos_x: 320, pos_y: 550 },
+ { node_key: 'done', type: 'action', config: { action: 'noop' }, pos_x: 320, pos_y: 660 },
+ { node_key: 'cancelInvoice', type: 'action', config: { action: 'noop' }, pos_x: 620, pos_y: 440 },
];
const edges = [
{ from_node: 't', to_node: 'prepEvent' },
{ from_node: 'prepEvent', to_node: 'waitEvent' },
{ from_node: 'waitEvent', to_node: 'prepInvoice' },
- { from_node: 'prepInvoice', to_node: 'sendInvoice' },
+ { from_node: 'prepInvoice', to_node: 'reviewInvoice' },
+ { from_node: 'reviewInvoice', from_handle: 'confirm', to_node: 'sendInvoice' },
+ { from_node: 'reviewInvoice', from_handle: 'deny', to_node: 'cancelInvoice' },
{ from_node: 'sendInvoice', to_node: 'done' },
];
return { nodes, edges };
@@ -155,27 +173,29 @@ const BUILTINS = [
},
{
key: 'booking_full',
- version: 1,
+ version: 2,
name: 'Booking — quote → contract → event → invoice (built-in)',
trigger_type: 'quote.accepted',
trigger_config: {},
description:
- 'On quote acceptance: prepare and send the contract, wait for the admin to confirm it is '
- + 'signed, then create the event/gallery, wait to the shoot date and prepare + send the '
- + 'invoice. Disabled by default — the document actions are stubs until the booking cutover, '
- + 'so an enabled run just records observable skipped steps. A starting point to edit.',
+ 'On quote acceptance: prepare the contract, let the admin review it (adjust line items / '
+ + 'terms) and confirm before it is sent, wait for the admin to confirm it is signed, then '
+ + 'create the event/gallery, wait to the shoot date, prepare the invoice and — after a '
+ + 'second admin review gate — send it. No document is ever sent without an explicit admin '
+ + 'OK. Disabled by default — the document actions are stubs until the booking cutover, so '
+ + 'an enabled run just records observable skipped steps. A starting point to edit.',
build: async () => buildBookingFullGraph(),
},
{
key: 'booking_simple',
- version: 1,
+ version: 2,
name: 'Booking — quote → event → invoice (built-in)',
trigger_type: 'quote.accepted',
trigger_config: {},
description:
'The no-contract booking path: on quote acceptance create the event/gallery, wait to the '
- + 'shoot date and prepare + send the invoice. Same stub caveat as the full booking flow; '
- + 'disabled by default.',
+ + 'shoot date, prepare the invoice and — after an admin review gate — send it. Same '
+ + 'review-before-send rule and stub caveat as the full booking flow; disabled by default.',
build: async () => buildBookingSimpleGraph(),
},
{
diff --git a/backend/src/services/contractService.js b/backend/src/services/contractService.js
index 54af7e41..31a959ea 100644
--- a/backend/src/services/contractService.js
+++ b/backend/src/services/contractService.js
@@ -87,6 +87,36 @@ function customerPublicActor() {
return { type: 'customer', name: 'Customer (public link)' };
}
+/**
+ * Fire a contract lifecycle event for the workflow engine. Best-effort:
+ * resolves the customer email (so send_email actions have a recipient) and
+ * never throws into the caller. No-op when the workflows flag is off (emit
+ * fails closed). Mirrors quoteService.emitQuoteEvent.
+ */
+async function emitContractEvent(contract, status) {
+ try {
+ let customerEmail = null;
+ if (contract.customer_account_id) {
+ const c = await db('customer_accounts').where({ id: contract.customer_account_id }).first();
+ customerEmail = c?.email || null;
+ }
+ await require('./workflows').emitWorkflowEvent(`contract.${status}`, {
+ entityType: 'contract',
+ entityId: contract.id,
+ payload: {
+ contractId: contract.id,
+ contractNumber: contract.contract_number,
+ customerAccountId: contract.customer_account_id || null,
+ customerEmail,
+ eventName: contract.event_name || null,
+ title: contract.title || null,
+ },
+ });
+ } catch (err) {
+ logger.warn('Failed to emit contract workflow event', { contractId: contract.id, status, error: err.message });
+ }
+}
+
/**
* Privacy gate for the customer/admin IP captured at signing time.
* The `crm_contracts_store_ip` setting (default true) controls
@@ -1065,6 +1095,8 @@ async function sendContract(id, adminId) {
await logActivity('contract_sent', { contractId: id, token }, null, await adminActor(adminId));
} catch (_) { /* logging is best-effort */ }
+ await emitContractEvent(contract, 'sent');
+
logger.info('Contract sent', { adminId, contractId: id });
return { token, pdfPath };
}
@@ -1460,6 +1492,10 @@ async function recordAdminCountersignature(contractId, { name, ip, signatureData
await logActivity(`contract_${newStatus}`, { contractId: contract.id }, null, await adminActor(adminId));
} catch (_) { /* logging is best-effort */ }
+ // The binding moment — fire contract.signed once the contract is fully signed
+ // (matches the editor's trigger). Best-effort / fail-closed.
+ if (newStatus === 'fully_signed') await emitContractEvent(contract, 'signed');
+
return { status: newStatus, signedAt: now };
}
@@ -1565,6 +1601,8 @@ async function attachSignedPdfUpload(contractId, filePath, uploaderRole) {
uploaderRole === 'admin' ? { type: 'admin', name: 'Admin (PDF upload)' } : customerPublicActor());
} catch (_) { /* logging is best-effort */ }
+ await emitContractEvent(contract, 'signed');
+
return { status: 'fully_signed', signedPdfPath: filePath };
}
diff --git a/backend/src/services/customerAccountsService.js b/backend/src/services/customerAccountsService.js
index 041790e0..8b8061d1 100644
--- a/backend/src/services/customerAccountsService.js
+++ b/backend/src/services/customerAccountsService.js
@@ -22,6 +22,23 @@ const { ConflictError, NotFoundError, ValidationError } = require('../utils/erro
const INVITATION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days, matches admin invites
+/**
+ * Fire customer.created for the workflow engine, from every creation path
+ * (direct add + invitation accept). Best-effort / fail-closed; never throws
+ * into the caller.
+ */
+async function emitCustomerCreated(id, email) {
+ try {
+ await require('./workflows').emitWorkflowEvent('customer.created', {
+ entityType: 'customer',
+ entityId: id,
+ payload: { customerAccountId: id, customerEmail: email || null },
+ });
+ } catch (err) {
+ logger.warn('Failed to emit customer.created workflow event', { customerId: id, error: err.message });
+ }
+}
+
/**
* Whitelist of customer profile fields the admin is allowed to pre-fill on
* an invitation (and that the customer can then edit on accept). Centralised
@@ -252,6 +269,7 @@ async function createDirect({ email, prefill, createdByAdminId }) {
);
logger.info('Passive customer created', { id, email: normalisedEmail, createdByAdminId });
+ await emitCustomerCreated(id, normalisedEmail);
return { id };
}
@@ -420,6 +438,7 @@ async function acceptInvitation({ token, name, password, profile }) {
);
logger.info('Customer invitation accepted', { customerId, email: invitation.email });
+ await emitCustomerCreated(customerId, invitation.email);
return { customerId, email: invitation.email };
}
diff --git a/backend/src/services/eventService.js b/backend/src/services/eventService.js
index 3f7faea7..bb732247 100644
--- a/backend/src/services/eventService.js
+++ b/backend/src/services/eventService.js
@@ -10,6 +10,7 @@ const crypto = require('crypto');
const path = require('path');
const fs = require('fs').promises;
const { db } = require('../database/db');
+const logger = require('../utils/logger');
const { formatBoolean } = require('../utils/dbCompat');
const { hasColumnCached } = require('../utils/schemaCache');
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
@@ -283,6 +284,28 @@ const createEvent = async (eventData) => {
const insertResult = await db('events').insert(insertData).returning('id');
const eventId = insertResult[0]?.id || insertResult[0];
+ // Fire gallery.published — a gallery goes live the moment it's created (active
+ // + share link). Best-effort; emit is fail-closed when the workflows flag is
+ // off and never throws into the create path.
+ try {
+ await require('./workflows').emitWorkflowEvent('gallery.published', {
+ entityType: 'event',
+ entityId: eventId,
+ payload: {
+ eventId,
+ slug,
+ eventName: event_name,
+ eventDate: event_date,
+ customerEmail: customer_email || null,
+ adminEmail: admin_email || null,
+ galleryLink: shareUrl,
+ expiresAt: expires_at,
+ },
+ });
+ } catch (err) {
+ logger.warn('Failed to emit gallery.published workflow event', { eventId, error: err.message });
+ }
+
return {
id: eventId,
slug,
diff --git a/backend/src/services/expirationChecker.js b/backend/src/services/expirationChecker.js
index 1666e1d3..58d67688 100644
--- a/backend/src/services/expirationChecker.js
+++ b/backend/src/services/expirationChecker.js
@@ -88,6 +88,30 @@ async function queueExpirationWarning(event) {
// Relationship mail — hold to business hours (no-op unless configured).
}, { respectBusinessHours: true });
+ // Fire gallery.expiring so admins can build flows on the warning window
+ // (e.g. a final download nudge). Best-effort; emit is fail-closed when the
+ // workflows flag is off, and deduped per (workflow, event) so the hourly
+ // sweep fires the trigger at most once per gallery.
+ try {
+ await require('./workflows').emitWorkflowEvent('gallery.expiring', {
+ entityType: 'event',
+ entityId: event.id,
+ payload: {
+ eventId: event.id,
+ slug: event.slug,
+ eventName: event.event_name,
+ eventDate: event.event_date,
+ expiresAt: event.expires_at,
+ daysRemaining,
+ customerEmail: recipientEmail,
+ adminEmail: event.admin_email || null,
+ galleryLink: shareUrl,
+ },
+ });
+ } catch (err) {
+ logger.warn('Failed to emit gallery.expiring workflow event', { eventId: event.id, error: err.message });
+ }
+
logger.info(`Queued expiration warning for event ${event.slug}`);
}
@@ -152,9 +176,30 @@ async function handleExpiredEvent(event) {
});
}
+ // Fire gallery.expired for the workflow engine (sibling to the
+ // event.expired webhook above). Best-effort / fail-closed; emitted BEFORE
+ // archiveEvent so flows see expired→archived in order.
+ try {
+ await require('./workflows').emitWorkflowEvent('gallery.expired', {
+ entityType: 'event',
+ entityId: event.id,
+ payload: {
+ eventId: event.id,
+ slug: event.slug,
+ eventName: event.event_name,
+ eventDate: event.event_date,
+ expiresAt: event.expires_at,
+ customerEmail: recipientEmail,
+ adminEmail: event.admin_email || null,
+ },
+ });
+ } catch (err) {
+ logger.warn('Failed to emit gallery.expired workflow event', { eventId: event.id, error: err.message });
+ }
+
// Start archiving process
await archiveEvent(event);
-
+
logger.info(`Handled expiration for event ${event.slug}`);
} catch (error) {
logger.error(`Error handling expired event ${event.slug}:`, error);
diff --git a/backend/src/services/invoiceService.js b/backend/src/services/invoiceService.js
index 72f5beb4..6528698b 100644
--- a/backend/src/services/invoiceService.js
+++ b/backend/src/services/invoiceService.js
@@ -2710,6 +2710,27 @@ async function applyReminder(invoice, lineItems, level, adminId) {
if (await hasColumnCached('invoices', 'late_fee_vat_minor')) update.late_fee_vat_minor = lateFeeVat;
await db('invoices').where({ id: invoice.id }).update(update);
+ // Fire invoice.overdue at the status→overdue flip. Deduped per (workflow,
+ // invoice), so across the reminder ladder it triggers a flow at most once.
+ // Best-effort / fail-closed.
+ try {
+ await require('./workflows').emitWorkflowEvent('invoice.overdue', {
+ entityType: 'invoice',
+ entityId: invoice.id,
+ payload: {
+ invoiceId: invoice.id,
+ invoiceNumber: invoice.invoice_number,
+ eventId: invoice.event_id || null,
+ customerAccountId: invoice.customer_account_id,
+ customerEmail: customer?.email || null,
+ dueDate: invoice.due_date,
+ reminderLevel: level,
+ totalMinor: invoice.total_amount_minor,
+ currency: invoice.currency,
+ },
+ });
+ } catch (_) {}
+
// Render the MAHNUNG (reminder letter). The original invoice PDF is left
// UNTOUCHED (immutable). The Mahnung reuses the invoice layout via a
// 'mahnung' kind: same line items + the Mahngebühr row + the new total, with
diff --git a/backend/src/services/quoteService.js b/backend/src/services/quoteService.js
index 3db3614b..8f1a1db2 100644
--- a/backend/src/services/quoteService.js
+++ b/backend/src/services/quoteService.js
@@ -981,6 +981,11 @@ async function sendQuote(id, adminId) {
await logActivity('quote_sent', { quoteId: id, token }, null, `admin:${adminId}`);
} catch (_) {}
+ // Fire the quote.sent workflow trigger (best-effort; emit is fail-closed when
+ // the workflows flag is off). The accepted/declined emits already exist; this
+ // closes the gap so flows can react to a quote going out.
+ await emitQuoteEvent(quote, 'sent');
+
logger.info('Quote sent', { adminId, quoteId: id });
return { token, pdfPath };
}
diff --git a/frontend/src/pages/admin/workflows/WorkflowEditorPage.tsx b/frontend/src/pages/admin/workflows/WorkflowEditorPage.tsx
index 2ef79c3e..a3b4ce05 100644
--- a/frontend/src/pages/admin/workflows/WorkflowEditorPage.tsx
+++ b/frontend/src/pages/admin/workflows/WorkflowEditorPage.tsx
@@ -26,8 +26,12 @@ import { NodeConfigPanel } from './NodeConfigPanel';
const PALETTE: WorkflowNodeType[] = ['trigger', 'condition', 'branch', 'loop', 'wait', 'action', 'gate', 'webhook'];
const TRIGGERS = [
- 'invoice.sent', 'invoice.paid', 'invoice.overdue', 'quote.accepted', 'quote.declined',
- 'contract.signed', 'event.date_approaching', 'gallery.published', 'gallery.expiring', 'customer.created',
+ 'invoice.sent', 'invoice.paid', 'invoice.overdue',
+ 'quote.sent', 'quote.accepted', 'quote.declined',
+ 'contract.sent', 'contract.signed',
+ 'event.date_approaching',
+ 'gallery.published', 'gallery.expiring', 'gallery.expired',
+ 'customer.created',
];
const COLORS: Record = {
From 0b6c33e59a2e1ba15210645a65800543eaf6305b Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Tue, 23 Jun 2026 15:58:19 +0200
Subject: [PATCH 27/43] feat(workflows): hard cutover of gallery-expiry +
dunning + pre-event to flows
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Seed gallery_expiring / gallery_expired built-ins and make the live automations
flow-owned, with zero feature loss:
- New delegating actions (notify_gallery_expiring / notify_gallery_expired /
notify_pre_event) call the EXISTING send functions, so the engine path is
byte-identical to the legacy hourly checker/pass (same templates, recipients,
variables, dedup, per-event overrides, sent_at idempotency).
- Cutover built-ins (invoice_dunning, gallery_expiring, gallery_expired,
pre_event_email) now ship ENABLED; booking flows stay disabled (stubs).
- The legacy paths stand down via existence-based isBuiltinFlowPresent guards:
once a built-in is seeded (flag on) the engine is the single switch — flow
enabled = it sends, flow disabled = off — so no double-send and reminders/
expiry emails can still be fully turned off.
- emitDueEventReminders now honours the per-event reminder controls
(disabled / offset / sent_at) so pre-event timing is faithful; fixed a
Number(null)===0 offset bug.
Settings UI cutover is gated on the `workflows` flag (default off): when the
engine is live, the dunning reminder schedule (CRM settings) and the pre-event
global toggle (Reminder emails) are replaced with a "now in Workflows" callout;
when it's off, the legacy controls stay so flag-off installs lose nothing. The
late-fee math and installment-trigger defaults stay (fee math / scheduler-owned).
Split/installment invoices intentionally remain scheduler-driven (no flow).
---
.../integration/workflowEngine.test.js | 56 ++++-
backend/src/services/_workflowSeedBoot.js | 155 ++++++++++----
backend/src/services/eventReminderService.js | 79 +++++++
backend/src/services/expirationChecker.js | 201 +++++++++++-------
backend/src/services/invoiceService.js | 14 +-
backend/src/services/workflows/actions.js | 40 ++++
backend/src/services/workflows/engine.js | 95 +++++++--
frontend/src/i18n/locales/de.json | 10 +
frontend/src/i18n/locales/en.json | 10 +
.../pages/admin/settings/CrmSettingsPage.tsx | 48 ++++-
.../admin/settings/ReminderTemplatesPage.tsx | 99 +++++----
11 files changed, 602 insertions(+), 205 deletions(-)
diff --git a/backend/__tests__/integration/workflowEngine.test.js b/backend/__tests__/integration/workflowEngine.test.js
index 38abaca3..0980c2cb 100644
--- a/backend/__tests__/integration/workflowEngine.test.js
+++ b/backend/__tests__/integration/workflowEngine.test.js
@@ -239,7 +239,7 @@ describe('workflow engine', () => {
expect(again.already).toBe(true);
});
- test('seeds the invoice-dunning built-in as the delegation graph (v2, disabled)', async () => {
+ test('seeds the invoice-dunning built-in as the delegation graph (v5, enabled cutover)', async () => {
const { seedBuiltinWorkflowsAtBoot, DUNNING_KEY } = require('../../src/services/_workflowSeedBoot');
const noopLogger = { info() {}, warn() {} };
await seedBuiltinWorkflowsAtBoot(db, noopLogger);
@@ -247,8 +247,8 @@ describe('workflow engine', () => {
const wf = await db('workflows').where({ builtin_key: DUNNING_KEY }).first();
expect(wf).toBeTruthy();
expect(!!wf.is_builtin).toBe(true);
- expect(!!wf.enabled).toBe(false);
- expect(JSON.parse(wf.trigger_config).seedVersion).toBe(4);
+ expect(!!wf.enabled).toBe(true); // cutover: dunning ships live
+ expect(JSON.parse(wf.trigger_config).seedVersion).toBe(5);
const nodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: wf.version });
expect(nodes.filter((n) => n.type === 'trigger')).toHaveLength(1);
@@ -273,7 +273,8 @@ 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(4);
+ expect(JSON.parse(reseeded.trigger_config).seedVersion).toBe(5);
+ expect(!!reseeded.enabled).toBe(true); // cutover default applied on re-seed
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
@@ -285,13 +286,28 @@ describe('workflow engine', () => {
expect(after.version).toBe(before.version); // unchanged
});
- test('seeds the booking + pre-event built-ins (disabled, correct triggers)', async () => {
+ test('seeds the gallery, pre-event (enabled cutover) + booking (disabled) built-ins', async () => {
const { seedBuiltinWorkflowsAtBoot } = require('../../src/services/_workflowSeedBoot');
await seedBuiltinWorkflowsAtBoot(db, { info() {}, warn() {} });
+ // Cutover flows ship ENABLED, delegating to the proven send functions.
+ const expiring = await db('workflows').where({ builtin_key: 'gallery_expiring' }).first();
+ expect(expiring).toBeTruthy();
+ expect(!!expiring.enabled).toBe(true);
+ expect(expiring.trigger_type).toBe('gallery.expiring');
+ const expiringNodes = await db('workflow_nodes').where({ workflow_id: expiring.id, version: expiring.version });
+ expect(expiringNodes.some((n) => JSON.parse(n.config || '{}').action === 'notify_gallery_expiring')).toBe(true);
+
+ const expired = await db('workflows').where({ builtin_key: 'gallery_expired' }).first();
+ expect(expired).toBeTruthy();
+ expect(!!expired.enabled).toBe(true);
+ expect(expired.trigger_type).toBe('gallery.expired');
+ const expiredNodes = await db('workflow_nodes').where({ workflow_id: expired.id, version: expired.version });
+ expect(expiredNodes.some((n) => JSON.parse(n.config || '{}').action === 'notify_gallery_expired')).toBe(true);
+
const bookingFull = await db('workflows').where({ builtin_key: 'booking_full' }).first();
expect(bookingFull).toBeTruthy();
- expect(!!bookingFull.enabled).toBe(false);
+ expect(!!bookingFull.enabled).toBe(false); // illustrative/stub — stays disabled
expect(bookingFull.trigger_type).toBe('quote.accepted');
const fullNodes = await db('workflow_nodes').where({ workflow_id: bookingFull.id, version: bookingFull.version });
expect(fullNodes.some((n) => JSON.parse(n.config || '{}').action === 'prepare_contract')).toBe(true);
@@ -311,10 +327,11 @@ describe('workflow engine', () => {
const preEvent = await db('workflows').where({ builtin_key: 'pre_event_email' }).first();
expect(preEvent).toBeTruthy();
+ expect(!!preEvent.enabled).toBe(true); // cutover: pre-event reminder ships live
expect(preEvent.trigger_type).toBe('event.date_approaching');
- expect(JSON.parse(preEvent.trigger_config).daysBefore).toBe(3);
+ expect(JSON.parse(preEvent.trigger_config).daysBefore).toBe(2); // default when global setting unset
const preNodes = await db('workflow_nodes').where({ workflow_id: preEvent.id, version: preEvent.version });
- expect(preNodes.some((n) => JSON.parse(n.config || '{}').action === 'send_email')).toBe(true);
+ expect(preNodes.some((n) => JSON.parse(n.config || '{}').action === 'notify_pre_event')).toBe(true);
});
test('emitDueEventReminders starts a run for an event inside the lead window', async () => {
@@ -346,6 +363,29 @@ describe('workflow engine', () => {
expect(runs2.length).toBe(1);
});
+ test('isBuiltinFlowActive reflects the built-in enabled state (cutover guard)', async () => {
+ const { seedBuiltinWorkflowsAtBoot } = require('../../src/services/_workflowSeedBoot');
+ await seedBuiltinWorkflowsAtBoot(db, { info() {}, warn() {} });
+ // Cutover built-ins ship enabled; booking stays disabled; unknown key → false.
+ expect(await engine.isBuiltinFlowActive('gallery_expiring')).toBe(true);
+ expect(await engine.isBuiltinFlowActive('pre_event_email')).toBe(true);
+ expect(await engine.isBuiltinFlowActive('booking_full')).toBe(false);
+ expect(await engine.isBuiltinFlowActive('does_not_exist')).toBe(false);
+ });
+
+ test('legacy event-reminder pass stands down when the pre_event_email flow is active', async () => {
+ const { seedBuiltinWorkflowsAtBoot } = require('../../src/services/_workflowSeedBoot');
+ await seedBuiltinWorkflowsAtBoot(db, { info() {}, warn() {} }); // pre_event_email enabled
+ // Reach the mutual-exclusion guard: the pass returns early on the global
+ // enable check unless the setting is on.
+ await db('app_settings')
+ .insert({ setting_key: 'crm_event_reminders_enabled', setting_value: JSON.stringify(true), setting_type: 'boolean' })
+ .onConflict('setting_key').merge();
+ const res = await require('../../src/services/eventReminderService').runEventReminderPass();
+ expect(res.byWorkflow).toBe(true);
+ expect(res.sent).toBe(0);
+ });
+
test('recoverStaleRuns resumes a run orphaned mid-flow (crash recovery)', async () => {
const wfId = await makeWorkflow({
trigger: 'recover.event',
diff --git a/backend/src/services/_workflowSeedBoot.js b/backend/src/services/_workflowSeedBoot.js
index 741323ed..981aad27 100644
--- a/backend/src/services/_workflowSeedBoot.js
+++ b/backend/src/services/_workflowSeedBoot.js
@@ -127,33 +127,69 @@ function buildBookingSimpleGraph() {
return { nodes, edges };
}
-// Pre-event email — fired by the scheduler `daysBefore` the event date (see
-// emitDueEventReminders in the engine). Sends a customer reminder, then a heads-
-// up to the admin. Unlike the booking flows this uses the already-wired
-// send_email action, so it is functional once enabled (the customer template
-// `pre_event_reminder` should exist / be authored).
+// Pre-event reminder — fired by the scheduler at event_date − daysBefore (see
+// emitDueEventReminders). The notify_pre_event action DELEGATES to
+// eventReminderService.sendReminderForEvent, so the email is byte-identical to
+// the legacy pass (per-type template, per-event override, sent_at idempotency).
+// This is the live replacement for that pass (mutual-exclusion guard there).
function buildPreEventEmailGraph() {
const nodes = [
{ node_key: 't', type: 'trigger', config: {}, pos_x: 240, pos_y: 0 },
- { node_key: 'emailCustomer', type: 'action', config: { action: 'send_email', recipientClass: 'customer', emailType: 'pre_event_reminder' }, pos_x: 240, pos_y: 110 },
- { node_key: 'emailAdmin', type: 'action', config: { action: 'send_email', recipientClass: 'admin', emailType: 'pre_event_internal' }, pos_x: 240, pos_y: 220 },
- { node_key: 'done', type: 'action', config: { action: 'noop' }, pos_x: 240, pos_y: 330 },
+ { node_key: 'notify', type: 'action', config: { action: 'notify_pre_event' }, pos_x: 240, pos_y: 110 },
+ { node_key: 'done', type: 'action', config: { action: 'noop' }, pos_x: 240, pos_y: 220 },
];
const edges = [
- { from_node: 't', to_node: 'emailCustomer' },
- { from_node: 'emailCustomer', to_node: 'emailAdmin' },
- { from_node: 'emailAdmin', to_node: 'done' },
+ { from_node: 't', to_node: 'notify' },
+ { from_node: 'notify', to_node: 'done' },
+ ];
+ return { nodes, edges };
+}
+
+// Gallery expiring — fired by the expiration checker `daysBefore` expiry. The
+// notify_gallery_expiring action delegates to the checker's queueExpirationWarning
+// so the warning email is identical. Live replacement for the legacy warning
+// email (mutual-exclusion guard in the checker).
+function buildGalleryExpiringGraph() {
+ const nodes = [
+ { node_key: 't', type: 'trigger', config: {}, pos_x: 240, pos_y: 0 },
+ { node_key: 'notify', type: 'action', config: { action: 'notify_gallery_expiring' }, pos_x: 240, pos_y: 110 },
+ { node_key: 'done', type: 'action', config: { action: 'noop' }, pos_x: 240, pos_y: 220 },
+ ];
+ const edges = [
+ { from_node: 't', to_node: 'notify' },
+ { from_node: 'notify', to_node: 'done' },
+ ];
+ return { nodes, edges };
+}
+
+// Gallery expired — fired when a gallery passes its expiry. The
+// notify_gallery_expired action delegates to the checker's sendGalleryExpiredEmails.
+// Live replacement for the legacy expired email (mutual-exclusion guard in the checker).
+function buildGalleryExpiredGraph() {
+ const nodes = [
+ { node_key: 't', type: 'trigger', config: {}, pos_x: 240, pos_y: 0 },
+ { node_key: 'notify', type: 'action', config: { action: 'notify_gallery_expired' }, pos_x: 240, pos_y: 110 },
+ { node_key: 'done', type: 'action', config: { action: 'noop' }, pos_x: 240, pos_y: 220 },
+ ];
+ const edges = [
+ { from_node: 't', to_node: 'notify' },
+ { from_node: 'notify', to_node: 'done' },
];
return { nodes, edges };
}
// Built-in registry. `version` is the SEED_VERSION — bump when a graph changes
-// so a disabled, never-activated copy is re-seeded on boot.
-// invoice_dunning v4 = collections handoff after the loop exhausts.
+// (or to re-assert the default `enabled` state) so a never-admin-touched copy is
+// re-seeded on boot. `enabled` is the cutover default: the live automations
+// (dunning, gallery expiry, pre-event) ship ENABLED and their legacy hardcoded
+// paths stand down (isBuiltinFlowActive guards), so behaviour is preserved with
+// zero double-send. Illustrative/stub flows (booking) ship disabled.
+// invoice_dunning v5 = enabled-by-default cutover (was v4: collections handoff).
const BUILTINS = [
{
key: DUNNING_KEY,
- version: 4,
+ version: 5,
+ enabled: true,
name: 'Invoice dunning (built-in)',
trigger_type: 'invoice.sent',
trigger_config: {},
@@ -161,9 +197,9 @@ const BUILTINS = [
'Drives overdue dunning through the engine: wait to the due date, then up to '
+ 'three payment-check cycles. Each cycle fires the existing admin confirm-payment '
+ 'email (the gate), which applies reminders + Mahngebühr via the proven payment-check '
- + 'flow; after the cycles exhaust it hands the case to collections. Disabled by default; '
+ + 'flow; after the cycles exhaust it hands the case to collections. ENABLED by default; '
+ 'while it is enabled the hardcoded reminder ladder is skipped automatically, so the two '
- + 'never double-send.',
+ + 'never double-send. Reminder timing is now edited here (no longer in Settings → CRM).',
build: async () => {
const firstDays = Number(await getAppSetting('crm_invoices_reminder_first_days')) || 14;
const secondDays = Number(await getAppSetting('crm_invoices_reminder_second_days')) || 30;
@@ -171,9 +207,59 @@ const BUILTINS = [
return buildDunningGraph({ firstDays, gapDays, maxReminders: 3 });
},
},
+ {
+ key: 'gallery_expiring',
+ version: 1,
+ enabled: true,
+ name: 'Gallery expiring (built-in)',
+ trigger_type: 'gallery.expiring',
+ trigger_config: {},
+ description:
+ 'When a gallery is approaching its expiry date, email the customer the expiration warning. '
+ + 'ENABLED by default; it delegates to the same email the hourly expiration checker used to '
+ + 'send, and that legacy email stands down while this flow is on (no double-send). Edit or '
+ + 'extend it here (e.g. add a final-download nudge).',
+ build: async () => buildGalleryExpiringGraph(),
+ },
+ {
+ key: 'gallery_expired',
+ version: 1,
+ enabled: true,
+ name: 'Gallery expired (built-in)',
+ trigger_type: 'gallery.expired',
+ trigger_config: {},
+ description:
+ 'When a gallery passes its expiry, email the customer (and admin) that it has expired. '
+ + 'ENABLED by default; delegates to the same email the expiration checker used to send, '
+ + 'and that legacy email stands down while this flow is on. The gallery is still archived '
+ + 'automatically regardless of this flow.',
+ build: async () => buildGalleryExpiredGraph(),
+ },
+ {
+ key: 'pre_event_email',
+ version: 2,
+ enabled: true,
+ name: 'Pre-event reminder (built-in)',
+ trigger_type: 'event.date_approaching',
+ // daysBefore seeds the scheduler emitter from the current global setting so
+ // upgrades preserve timing; per-event offset overrides still win. This flow
+ // is now the source of truth for the lead time (was Settings → Reminder emails).
+ trigger_config: async () => {
+ const d = Number(await getAppSetting('crm_event_reminders_days_before'));
+ return { daysBefore: Number.isFinite(d) && d >= 0 ? d : 2 };
+ },
+ description:
+ 'A few days before the event date, send the customer the pre-event reminder. ENABLED by '
+ + 'default; the notify_pre_event action delegates to the proven reminder logic (per-type '
+ + 'template, per-event override, send-once), and the legacy reminder pass stands down while '
+ + 'this flow is on. Lead time = daysBefore in the trigger config (seeded from your old '
+ + 'global setting); per-event overrides on the event page still apply.',
+ build: async () => buildPreEventEmailGraph(),
+ },
{
key: 'booking_full',
version: 2,
+ enabled: false,
name: 'Booking — quote → contract → event → invoice (built-in)',
trigger_type: 'quote.accepted',
trigger_config: {},
@@ -189,6 +275,7 @@ const BUILTINS = [
{
key: 'booking_simple',
version: 2,
+ enabled: false,
name: 'Booking — quote → event → invoice (built-in)',
trigger_type: 'quote.accepted',
trigger_config: {},
@@ -198,21 +285,6 @@ const BUILTINS = [
+ 'review-before-send rule and stub caveat as the full booking flow; disabled by default.',
build: async () => buildBookingSimpleGraph(),
},
- {
- key: 'pre_event_email',
- version: 1,
- name: 'Pre-event email (built-in)',
- trigger_type: 'event.date_approaching',
- // daysBefore drives the scheduler emitter — how many days before the event
- // date the reminder fires.
- trigger_config: { daysBefore: 3 },
- description:
- 'A few days before the event date, send the customer a reminder and the admin a heads-up. '
- + 'Fired by the scheduler from the event date (daysBefore in the trigger config). Uses the '
- + 'wired send_email action, so it works once enabled and the pre_event_reminder template '
- + 'exists. Disabled by default.',
- build: async () => buildPreEventEmailGraph(),
- },
];
let booted = false;
@@ -240,14 +312,20 @@ async function writeGraph(trx, workflowId, version, nodes, edges) {
async function seedOneBuiltin(db, logger, def) {
const { nodes, edges } = await def.build();
- const triggerConfig = { ...(def.trigger_config || {}), seedVersion: def.version };
+ const baseCfg = typeof def.trigger_config === 'function'
+ ? (await def.trigger_config()) || {}
+ : (def.trigger_config || {});
+ const triggerConfig = { ...baseCfg, seedVersion: def.version };
+ const defEnabled = def.enabled === true;
const existing = await db('workflows').where({ builtin_key: def.key }).first();
if (existing) {
- // Re-seed the graph only when (a) it has never been activated and (b) our
- // seed version moved on. Once the admin enables it, it's their live flow —
- // never overwrite it.
+ // Re-seed only a never-admin-activated copy whose SEED_VERSION moved on. An
+ // already-ENABLED built-in is the admin's live (possibly customised) flow —
+ // never overwrite it. The version bump carries the cutover default (incl.
+ // flipping a still-disabled flow to enabled); the cutover targets flows that
+ // shipped disabled and were never touched, so this leaves admin choices alone.
const storedVersion = Number(parseSeedConfig(existing.trigger_config).seedVersion) || 0;
const isEnabled = existing.enabled === true || existing.enabled === 1;
if (isEnabled || storedVersion >= def.version) return;
@@ -259,12 +337,13 @@ async function seedOneBuiltin(db, logger, def) {
description: def.description,
trigger_type: def.trigger_type,
trigger_config: JSON.stringify(triggerConfig),
+ enabled: defEnabled,
version: newVersion,
updated_at: trx.fn.now(),
});
await writeGraph(trx, existing.id, newVersion, nodes, edges);
});
- logger?.info?.(`Re-seeded built-in workflow: ${def.key} (v${def.version})`);
+ logger?.info?.(`Re-seeded built-in workflow: ${def.key} (v${def.version}, enabled=${defEnabled})`);
return;
}
@@ -272,7 +351,7 @@ async function seedOneBuiltin(db, logger, def) {
const ins = await trx('workflows').insert({
name: def.name,
description: def.description,
- enabled: false,
+ enabled: defEnabled,
version: 1,
trigger_type: def.trigger_type,
trigger_config: JSON.stringify(triggerConfig),
@@ -285,7 +364,7 @@ async function seedOneBuiltin(db, logger, def) {
const workflowId = ins[0]?.id ?? ins[0];
await writeGraph(trx, workflowId, 1, nodes, edges);
});
- logger?.info?.(`Seeded built-in workflow: ${def.key} (disabled)`);
+ logger?.info?.(`Seeded built-in workflow: ${def.key} (enabled=${defEnabled})`);
}
async function seedBuiltinWorkflowsAtBoot(db, logger) {
diff --git a/backend/src/services/eventReminderService.js b/backend/src/services/eventReminderService.js
index 74e4601b..07b92908 100644
--- a/backend/src/services/eventReminderService.js
+++ b/backend/src/services/eventReminderService.js
@@ -128,6 +128,17 @@ async function runEventReminderPass() {
return { scanned: 0, sent: 0, skipped: 0, disabled: true };
}
+ // Mutual exclusion with the workflow engine: once the pre_event_email built-in
+ // is seeded (flag on), the engine OWNS the reminder — it sends via the
+ // notify_pre_event action when the flow is enabled, or nothing when the admin
+ // disabled it. Either way the legacy pass stands down so the two never
+ // double-send. Fails closed → legacy pass keeps running if the subsystem is down.
+ try {
+ if (await require('./workflows').isBuiltinFlowPresent('pre_event_email')) {
+ return { scanned: 0, sent: 0, skipped: 0, byWorkflow: true };
+ }
+ } catch (_) { /* workflow subsystem down → keep the legacy pass running */ }
+
// Column-existence guards — pre-migration installs return early
// instead of throwing.
const hasCols = await hasColumnCached('events', 'event_reminder_sent_at');
@@ -254,8 +265,76 @@ async function runEventReminderPass() {
return { scanned: rows.length, sent, skipped };
}
+/**
+ * Send the pre-event reminder for ONE event — the per-event body of
+ * runEventReminderPass, reused by the workflow `notify_pre_event` action so the
+ * engine path is byte-identical to the legacy pass (same template resolution,
+ * per-event body override, recipient rule and `event_reminder_sent_at` idempotency).
+ *
+ * Returns { sent, skipped, reason? }. Never throws on a business skip (no email,
+ * disabled, already sent, no template-eligible recipient); only DB/queue errors
+ * propagate so the caller can surface them.
+ */
+async function sendReminderForEvent(eventId) {
+ const hasCols = await hasColumnCached('events', 'event_reminder_sent_at');
+ if (!hasCols) return { sent: 0, skipped: 1, reason: 'schema_not_migrated' };
+
+ // Self-heal templates (idempotent, process-cached) — same as the pass.
+ try { await ensureEventReminderTemplatesSeeded(db, logger); } catch (err) {
+ logger.error('Event reminder template self-heal failed', { message: err.message });
+ }
+
+ const row = await db('events')
+ .leftJoin('customer_accounts', 'customer_accounts.id', 'events.customer_account_id')
+ .where('events.id', eventId)
+ .select(
+ 'events.id', 'events.event_name', 'events.event_type', 'events.event_date',
+ 'events.is_active', 'events.is_archived',
+ 'events.event_reminder_disabled', 'events.event_reminder_offset_days',
+ 'events.event_reminder_body_override', 'events.event_reminder_sent_at',
+ 'events.customer_account_id',
+ 'customer_accounts.email as customer_email',
+ 'customer_accounts.first_name as customer_first_name',
+ 'customer_accounts.last_name as customer_last_name',
+ 'customer_accounts.display_name as customer_display_name',
+ 'customer_accounts.company_name as customer_company_name',
+ )
+ .first();
+
+ if (!row) return { sent: 0, skipped: 1, reason: 'not_found' };
+ if (row.event_reminder_disabled) return { sent: 0, skipped: 1, reason: 'disabled' };
+ if (row.event_reminder_sent_at) return { sent: 0, skipped: 1, reason: 'already_sent' };
+ if (!row.customer_email) return { sent: 0, skipped: 1, reason: 'no_recipient' };
+
+ const globalDaysBefore = Number(await getAppSetting('crm_event_reminders_days_before'));
+ const daysBeforeDefault = Number.isFinite(globalDaysBefore) && globalDaysBefore >= 0
+ ? globalDaysBefore : DEFAULT_DAYS_BEFORE;
+ const rawOffset = row.event_reminder_offset_days;
+ const offsetDays = (rawOffset != null && rawOffset !== '' && Number.isFinite(Number(rawOffset)))
+ ? Number(rawOffset) : daysBeforeDefault;
+
+ const profile = await db('business_profile').where({ id: 1 }).first('company_name');
+ const businessName = profile?.company_name || '';
+
+ const templateKey = await resolveTemplateKey(row.event_type);
+ const customer = {
+ email: row.customer_email,
+ first_name: row.customer_first_name,
+ last_name: row.customer_last_name,
+ display_name: row.customer_display_name,
+ company_name: row.customer_company_name,
+ };
+ const payload = composePayload({ event: row, customer, daysBefore: offsetDays, businessName });
+ if (row.event_reminder_body_override) payload.body_override = row.event_reminder_body_override;
+
+ await emailProcessor.queueEmail(row.id, customer.email, templateKey, payload);
+ await db('events').where({ id: row.id }).update({ event_reminder_sent_at: new Date() });
+ return { sent: 1, skipped: 0 };
+}
+
module.exports = {
runEventReminderPass,
+ sendReminderForEvent,
// exported for tests
_internal: {
resolveTemplateKey,
diff --git a/backend/src/services/expirationChecker.js b/backend/src/services/expirationChecker.js
index 58d67688..f5db10d9 100644
--- a/backend/src/services/expirationChecker.js
+++ b/backend/src/services/expirationChecker.js
@@ -11,7 +11,7 @@ function startExpirationChecker() {
cron.schedule('0 * * * *', async () => {
await checkExpirations();
});
-
+
logger.info('Expiration checker started');
}
@@ -19,7 +19,20 @@ async function checkExpirations() {
try {
const now = new Date();
const warningDate = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days from now
-
+
+ // Mutual exclusion with the workflow engine: when the matching built-in flow
+ // is enabled, the engine sends the email (via notify_gallery_* actions). We
+ // still EMIT the trigger every pass (for the built-in AND any custom flows),
+ // but skip the LEGACY email so the two never double-send. State transitions
+ // (is_active=false, archive) always run regardless — they're the expiry
+ // mechanic, not the notification.
+ // Existence-based: once the built-in is seeded the engine OWNS the email, so
+ // the legacy send stands down whether the flow is enabled (it sends) or
+ // disabled (admin turned it off). The trigger is still emitted regardless.
+ const { isBuiltinFlowPresent } = require('./workflows');
+ const warningFlowOwns = await isBuiltinFlowPresent('gallery_expiring');
+ const expiredFlowOwns = await isBuiltinFlowPresent('gallery_expired');
+
// Check for events needing warning emails
// Skip events with null expires_at (they never expire)
const eventsNeedingWarning = await db('events')
@@ -28,19 +41,14 @@ async function checkExpirations() {
.whereNotNull('expires_at')
.where('expires_at', '<=', warningDate)
.where('expires_at', '>', now);
-
+
for (const event of eventsNeedingWarning) {
- // Check if warning email already sent
- const existingWarning = await db('email_queue')
- .where('event_id', event.id)
- .where('email_type', 'expiration_warning')
- .first();
-
- if (!existingWarning) {
- await queueExpirationWarning(event);
+ await emitGalleryExpiring(event); // always — for the built-in + any custom flows
+ if (!warningFlowOwns) {
+ await queueExpirationWarning(event); // legacy email (self-dedupes)
}
}
-
+
// Check for expired events
// Skip events with null expires_at (they never expire)
const expiredEvents = await db('events')
@@ -48,17 +56,57 @@ async function checkExpirations() {
.where('is_archived', formatBoolean(false))
.whereNotNull('expires_at')
.where('expires_at', '<=', now);
-
+
for (const event of expiredEvents) {
- await handleExpiredEvent(event);
+ await handleExpiredEvent(event, { sendLegacyEmails: !expiredFlowOwns });
}
-
+
} catch (error) {
logger.error('Error checking expirations:', error);
}
}
+/**
+ * Emit gallery.expiring for the workflow engine. Best-effort / fail-closed;
+ * deduped per (workflow, event) by emitWorkflowEvent so the hourly sweep fires
+ * a flow at most once per gallery.
+ */
+async function emitGalleryExpiring(event) {
+ try {
+ const daysRemaining = Math.ceil((new Date(event.expires_at) - new Date()) / (1000 * 60 * 60 * 24));
+ const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token });
+ await require('./workflows').emitWorkflowEvent('gallery.expiring', {
+ entityType: 'event',
+ entityId: event.id,
+ payload: {
+ eventId: event.id,
+ slug: event.slug,
+ eventName: event.event_name,
+ eventDate: event.event_date,
+ expiresAt: event.expires_at,
+ daysRemaining,
+ customerEmail: event.customer_email || event.host_email || null,
+ adminEmail: event.admin_email || null,
+ galleryLink: shareUrl,
+ },
+ });
+ } catch (err) {
+ logger.warn('Failed to emit gallery.expiring workflow event', { eventId: event.id, error: err.message });
+ }
+}
+
+/**
+ * Queue the customer expiration-warning email. Self-dedupes on the
+ * (event_id, 'expiration_warning') email_queue row so both the legacy hourly
+ * loop and the workflow `notify_gallery_expiring` action are safe to call it.
+ */
async function queueExpirationWarning(event) {
+ const existingWarning = await db('email_queue')
+ .where('event_id', event.id)
+ .where('email_type', 'expiration_warning')
+ .first();
+ if (existingWarning) return;
+
const daysRemaining = Math.ceil((new Date(event.expires_at) - new Date()) / (1000 * 60 * 60 * 24));
const recipientEmail = event.customer_email || event.host_email;
@@ -88,34 +136,52 @@ async function queueExpirationWarning(event) {
// Relationship mail — hold to business hours (no-op unless configured).
}, { respectBusinessHours: true });
- // Fire gallery.expiring so admins can build flows on the warning window
- // (e.g. a final download nudge). Best-effort; emit is fail-closed when the
- // workflows flag is off, and deduped per (workflow, event) so the hourly
- // sweep fires the trigger at most once per gallery.
- try {
- await require('./workflows').emitWorkflowEvent('gallery.expiring', {
- entityType: 'event',
- entityId: event.id,
- payload: {
- eventId: event.id,
- slug: event.slug,
- eventName: event.event_name,
- eventDate: event.event_date,
- expiresAt: event.expires_at,
- daysRemaining,
- customerEmail: recipientEmail,
- adminEmail: event.admin_email || null,
- galleryLink: shareUrl,
- },
- });
- } catch (err) {
- logger.warn('Failed to emit gallery.expiring workflow event', { eventId: event.id, error: err.message });
- }
-
logger.info(`Queued expiration warning for event ${event.slug}`);
}
-async function handleExpiredEvent(event) {
+/**
+ * Queue the gallery_expired emails (customer + optional admin). Self-dedupes on
+ * the (event_id, 'gallery_expired') email_queue row, so both the legacy expiry
+ * handler and the workflow `notify_gallery_expired` action are safe to call it.
+ */
+async function sendGalleryExpiredEmails(event) {
+ const existing = await db('email_queue')
+ .where('event_id', event.id)
+ .where('email_type', 'gallery_expired')
+ .first();
+ if (existing) return;
+
+ // The shipped templates (EN/DE in legacy 028, NL/PT/RU in core 075) reference
+ // {{host_name}}, {{event_date}}, {{expiry_date}} and {{support_email}} — fill
+ // them all here.
+ const recipientEmail = event.customer_email || event.host_email;
+ const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
+ const supportEmail = await getSupportEmail();
+
+ const customerVars = {
+ customer_name: recipientName,
+ customer_email: recipientEmail,
+ host_name: recipientName,
+ event_name: event.event_name,
+ event_date: event.event_date,
+ expiry_date: event.expires_at,
+ admin_email: event.admin_email,
+ support_email: supportEmail
+ };
+
+ if (recipientEmail) {
+ await queueEmail(event.id, recipientEmail, 'gallery_expired', customerVars);
+ }
+ // Also notify admin (when configured).
+ if (event.admin_email && event.admin_email !== recipientEmail) {
+ await queueEmail(event.id, event.admin_email, 'gallery_expired', {
+ ...customerVars,
+ host_name: 'Admin'
+ });
+ }
+}
+
+async function handleExpiredEvent(event, { sendLegacyEmails = true } = {}) {
try {
// Mark as inactive
await db('events').where('id', event.id).update({ is_active: formatBoolean(false) });
@@ -144,41 +210,8 @@ async function handleExpiredEvent(event) {
});
} catch (e) { /* non-fatal */ }
- // Queue expiration emails. The shipped templates (EN/DE in legacy 028,
- // NL/PT/RU in core 075) reference {{host_name}}, {{event_date}},
- // {{expiry_date}} and {{support_email}}. Without these, customers used
- // to literally see "Hello {{host_name}}, your gallery expired on
- // {{expiry_date}}…" — fill them all here.
- const recipientEmail = event.customer_email || event.host_email;
- const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
- const supportEmail = await getSupportEmail();
-
- const customerVars = {
- customer_name: recipientName,
- customer_email: recipientEmail,
- host_name: recipientName,
- event_name: event.event_name,
- event_date: event.event_date,
- expiry_date: event.expires_at,
- admin_email: event.admin_email,
- support_email: supportEmail
- };
-
- if (recipientEmail) {
- await queueEmail(event.id, recipientEmail, 'gallery_expired', customerVars);
- }
-
- // Also notify admin (when configured).
- if (event.admin_email && event.admin_email !== recipientEmail) {
- await queueEmail(event.id, event.admin_email, 'gallery_expired', {
- ...customerVars,
- host_name: 'Admin'
- });
- }
-
- // Fire gallery.expired for the workflow engine (sibling to the
- // event.expired webhook above). Best-effort / fail-closed; emitted BEFORE
- // archiveEvent so flows see expired→archived in order.
+ // Emit gallery.expired for the workflow engine (sibling to the event.expired
+ // webhook). Always emitted; deduped per (workflow, event).
try {
await require('./workflows').emitWorkflowEvent('gallery.expired', {
entityType: 'event',
@@ -189,7 +222,7 @@ async function handleExpiredEvent(event) {
eventName: event.event_name,
eventDate: event.event_date,
expiresAt: event.expires_at,
- customerEmail: recipientEmail,
+ customerEmail: event.customer_email || event.host_email || null,
adminEmail: event.admin_email || null,
},
});
@@ -197,7 +230,13 @@ async function handleExpiredEvent(event) {
logger.warn('Failed to emit gallery.expired workflow event', { eventId: event.id, error: err.message });
}
- // Start archiving process
+ // Legacy notification — skipped when the gallery_expired built-in flow drives
+ // it (the flow's notify_gallery_expired action sends the same emails).
+ if (sendLegacyEmails) {
+ await sendGalleryExpiredEmails(event);
+ }
+
+ // Start archiving process (always — the expiry mechanic, not the email).
await archiveEvent(event);
logger.info(`Handled expiration for event ${event.slug}`);
@@ -206,4 +245,10 @@ async function handleExpiredEvent(event) {
}
}
-module.exports = { startExpirationChecker };
+module.exports = {
+ startExpirationChecker,
+ // Reused by the workflow notify_gallery_* actions so the engine path sends the
+ // exact same emails as the legacy hourly checker.
+ queueExpirationWarning,
+ sendGalleryExpiredEmails,
+};
diff --git a/backend/src/services/invoiceService.js b/backend/src/services/invoiceService.js
index 6528698b..3847ca83 100644
--- a/backend/src/services/invoiceService.js
+++ b/backend/src/services/invoiceService.js
@@ -3447,16 +3447,14 @@ async function runScheduledTasks() {
// Throttled to one email per 24h per invoice via
// invoices.last_payment_check_at.
const remindersEnabled = await getAppSetting('crm_invoices_reminders_enabled');
- // Mutual exclusion with the workflow engine: when the `workflows` flag is on
- // AND the invoice_dunning built-in is enabled, the engine fires the same
- // payment-check emails — running this hardcoded ladder too would double-send.
+ // Mutual exclusion with the workflow engine: once the invoice_dunning built-in
+ // is seeded (flag on), the engine OWNS dunning and is the single switch — it
+ // fires the payment-check emails when the flow is enabled, or nothing when the
+ // admin disabled it. Either way the hardcoded ladder stands down so the two
+ // never double-send. Fails closed → ladder stays on if the subsystem is down.
let engineDrivesDunning = false;
try {
- const { isFeatureEnabled } = require('../middleware/requireFeatureFlag');
- if (await isFeatureEnabled('workflows')) {
- const dunning = await db('workflows').where({ builtin_key: 'invoice_dunning', enabled: true }).first();
- engineDrivesDunning = !!dunning;
- }
+ engineDrivesDunning = await require('./workflows').isBuiltinFlowPresent('invoice_dunning');
} catch (_) { /* workflows tables absent / flag system down → ladder stays on */ }
if (remindersEnabled !== false && !engineDrivesDunning) {
const firstDays = ensureInt(await getAppSetting('crm_invoices_reminder_first_days')) || 14;
diff --git a/backend/src/services/workflows/actions.js b/backend/src/services/workflows/actions.js
index b205ae36..3beac482 100644
--- a/backend/src/services/workflows/actions.js
+++ b/backend/src/services/workflows/actions.js
@@ -135,6 +135,46 @@ registry.registerAction('escalate_to_collections', async (ctx) => {
return { collections_handoff_to: adminEmail, outstanding };
});
+// --- Gallery / pre-event notification actions (cutover) ---
+//
+// These DELEGATE to the existing service send functions, so the engine path is
+// byte-identical to the legacy hourly checker/pass it replaces (same templates,
+// recipients, variables, dedup). The legacy path stands down when the matching
+// built-in flow is enabled (isBuiltinFlowActive guard), so exactly one email
+// goes out.
+
+// Send the gallery expiration-warning email for the run's event entity.
+registry.registerAction('notify_gallery_expiring', async (ctx) => {
+ const id = ctx.run.entity_id;
+ if (!id) return { skipped: true, reason: 'no event entity' };
+ if (ctx.vars?.__dryRun) return { dryRun: true, would: 'notify_gallery_expiring', eventId: id };
+ const event = await ctx.db('events').where({ id }).first();
+ if (!event) return { skipped: true, reason: 'event not found' };
+ await require('../expirationChecker').queueExpirationWarning(event);
+ return { warning_queued: id };
+});
+
+// Send the gallery_expired email(s) for the run's event entity.
+registry.registerAction('notify_gallery_expired', async (ctx) => {
+ const id = ctx.run.entity_id;
+ if (!id) return { skipped: true, reason: 'no event entity' };
+ if (ctx.vars?.__dryRun) return { dryRun: true, would: 'notify_gallery_expired', eventId: id };
+ const event = await ctx.db('events').where({ id }).first();
+ if (!event) return { skipped: true, reason: 'event not found' };
+ await require('../expirationChecker').sendGalleryExpiredEmails(event);
+ return { expired_email_queued: id };
+});
+
+// Send the pre-event customer reminder for the run's event entity. Delegates to
+// eventReminderService so per-event overrides + sent_at idempotency are honoured.
+registry.registerAction('notify_pre_event', async (ctx) => {
+ const id = ctx.run.entity_id;
+ if (!id) return { skipped: true, reason: 'no event entity' };
+ if (ctx.vars?.__dryRun) return { dryRun: true, would: 'notify_pre_event', eventId: id };
+ const res = await require('../eventReminderService').sendReminderForEvent(id);
+ return res;
+});
+
// 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/backend/src/services/workflows/engine.js b/backend/src/services/workflows/engine.js
index 11d90dbe..2e2f76c5 100644
--- a/backend/src/services/workflows/engine.js
+++ b/backend/src/services/workflows/engine.js
@@ -381,17 +381,54 @@ async function recoverStaleRuns({ staleMs = RECOVERY_STALE_MS, limit = 50 } = {}
}
/**
- * Emit `event.date_approaching` for events whose date is within the configured
- * lead window of an enabled pre-event flow. Iterates per flow so each respects
- * its own trigger_config.daysBefore; emitWorkflowEvent's per-(flow,entity)
- * dedup_key guarantees a single run per event, so polling hourly never
- * duplicates. Fails CLOSED when the workflows flag is off. Called from the
- * scheduler tick.
- *
- * Caveat (v1): emitWorkflowEvent fans out to ALL enabled flows of this trigger,
- * so with multiple pre-event flows the widest window wins for surfacing an
- * event; a narrower flow may then fire earlier than its own daysBefore. The
- * single-built-in case (the norm) is exact.
+ * True when the workflows flag is on AND a built-in flow with this key is
+ * enabled. The hardcoded automations (reminder ladder, expiry emails, pre-event
+ * reminders) call this to STAND DOWN when their engine flow is live — so the
+ * engine and the legacy path never double-fire. Fails CLOSED (returns false) on
+ * any error so the legacy path keeps running if the workflow subsystem is down.
+ */
+async function isBuiltinFlowActive(builtinKey) {
+ try {
+ const { isFeatureEnabled } = require('../../middleware/requireFeatureFlag');
+ if (!(await isFeatureEnabled('workflows'))) return false;
+ if (!(await db.schema.hasTable('workflows'))) return false;
+ const wf = await db('workflows').where({ builtin_key: builtinKey, enabled: true }).first();
+ return !!wf;
+ } catch (e) {
+ return false;
+ }
+}
+
+/**
+ * True when the workflows flag is on AND a built-in flow with this key EXISTS
+ * (enabled or not). The legacy automations use this to decide whether the engine
+ * OWNS the automation — once the built-in is seeded, the engine is the single
+ * switch: the legacy path stands down whether the flow is enabled (the flow
+ * sends) or disabled (the admin turned it off → nothing sends). Distinct from
+ * isBuiltinFlowActive, which asks whether the flow is currently firing. Fails
+ * CLOSED (false) so the legacy path keeps running if the subsystem is down.
+ */
+async function isBuiltinFlowPresent(builtinKey) {
+ try {
+ const { isFeatureEnabled } = require('../../middleware/requireFeatureFlag');
+ if (!(await isFeatureEnabled('workflows'))) return false;
+ if (!(await db.schema.hasTable('workflows'))) return false;
+ const wf = await db('workflows').where({ builtin_key: builtinKey }).first();
+ return !!wf;
+ } catch (e) {
+ return false;
+ }
+}
+
+/**
+ * Emit `event.date_approaching` for events entering an enabled flow's lead
+ * window. This is the trigger source for the pre-event reminder built-in, so it
+ * faithfully honours the same per-event controls the legacy eventReminderService
+ * pass uses (migration 143): skips `event_reminder_disabled` events, skips ones
+ * already sent (`event_reminder_sent_at`), and fires at `event_date − offset`
+ * where offset = the event's `event_reminder_offset_days` override else the
+ * flow's `daysBefore`. emitWorkflowEvent's per-(flow,entity) dedup_key keeps the
+ * hourly sweep to a single run per event. Fails CLOSED when the flag is off.
*/
async function emitDueEventReminders(limit = 200) {
try {
@@ -404,6 +441,9 @@ async function emitDueEventReminders(limit = 200) {
const flows = await db('workflows').where({ enabled: true, trigger_type: 'event.date_approaching' });
if (!flows.length) return 0;
+ const { hasColumnCached } = require('../../utils/schemaCache');
+ const hasReminderCols = await hasColumnCached('events', 'event_reminder_sent_at');
+
// The admin heads-up resolves its recipient from ctx.vars.adminEmail; events
// don't carry one, so source it from the business profile (best-effort).
let adminEmail = null;
@@ -414,22 +454,40 @@ async function emitDueEventReminders(limit = 200) {
}
} catch (_) { /* best-effort */ }
+ const now = Date.now();
+ const todayIso = new Date(now).toISOString().slice(0, 10);
let emitted = 0;
- const todayIso = new Date().toISOString().slice(0, 10);
for (const wf of flows) {
const cfg = parseJson(wf.trigger_config, {});
const daysBefore = Number(cfg.daysBefore) > 0 ? Number(cfg.daysBefore) : 3;
- const windowEndIso = new Date(Date.now() + daysBefore * 86400000).toISOString().slice(0, 10);
+ // Surface every still-upcoming event up to the widest the offset could be;
+ // the per-event triggerAt check below decides if it's actually due.
+ const maxOffset = Math.max(daysBefore, 60);
+ const windowEndIso = new Date(now + maxOffset * 86400000).toISOString().slice(0, 10);
- const events = await db('events')
+ let q = db('events')
.where('is_active', true)
.where('is_archived', false)
.whereNotNull('event_date')
.where('event_date', '>=', todayIso)
- .where('event_date', '<=', windowEndIso)
- .limit(limit);
+ .where('event_date', '<=', windowEndIso);
+ // Faithful to the legacy pass: never remind a disabled or already-sent event.
+ if (hasReminderCols) {
+ q = q.where('event_reminder_disabled', false).whereNull('event_reminder_sent_at');
+ }
+ const events = await q.limit(limit);
for (const ev of events) {
+ // A null/blank per-event offset means "use the flow's daysBefore" — guard
+ // against Number(null)===0 silently making the reminder fire on the event day.
+ const rawOffset = hasReminderCols ? ev.event_reminder_offset_days : null;
+ const offset = (rawOffset != null && rawOffset !== '' && Number.isFinite(Number(rawOffset)))
+ ? Number(rawOffset)
+ : daysBefore;
+ const ed = ev.event_date instanceof Date ? ev.event_date : new Date(ev.event_date);
+ const triggerAt = ed.getTime() - offset * 86400000;
+ if (now < triggerAt) continue; // not yet inside this event's lead window
+
const runIds = await emitWorkflowEvent('event.date_approaching', {
entityType: 'event',
entityId: ev.id,
@@ -437,10 +495,11 @@ async function emitDueEventReminders(limit = 200) {
eventId: ev.id,
eventName: ev.event_name || null,
eventDate: ev.event_date,
+ eventType: ev.event_type || null,
hostName: ev.host_name || null,
customerEmail: ev.customer_email || ev.host_email || null,
adminEmail,
- daysBefore,
+ daysBefore: offset,
},
});
emitted += runIds.length;
@@ -484,6 +543,8 @@ async function testRun(workflowId, { entityType = null, entityId = null, payload
module.exports = {
emitWorkflowEvent,
+ isBuiltinFlowActive,
+ isBuiltinFlowPresent,
runDueWaits,
emitDueEventReminders,
recoverStaleRuns,
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json
index 2bf9925d..39da90d2 100644
--- a/frontend/src/i18n/locales/de.json
+++ b/frontend/src/i18n/locales/de.json
@@ -4920,6 +4920,11 @@
"title": "Mahngebühren müssen in den AGB stehen",
"body": "Vertragliche Pflicht: Sätze wie „Es werden Mahnspesen erhoben“ reichen nicht aus. In den AGB muss die konkrete Gebühr klar beziffert sein (z.B. „CHF 20 ab der 2. Mahnung“). Mit dem Treuhänder prüfen."
},
+ "dunningMoved": {
+ "title": "Der Mahnrhythmus liegt jetzt in den Workflows",
+ "body": "Wann und wie oft Zahlungserinnerungen für überfällige Rechnungen verschickt werden, wird im Workflow „Rechnungsmahnung“ festgelegt. Die Mahngebühren unten gelten weiterhin.",
+ "link": "Workflows öffnen"
+ },
"crm_invoices_late_fee_label": {
"label": "Bezeichnung Mahngebühr"
},
@@ -5023,6 +5028,11 @@
},
"reminderTemplates": {
"title": "Erinnerungs-E-Mails vor dem Anlass",
+ "scheduleMoved": {
+ "title": "Der Versandzeitpunkt liegt jetzt in den Workflows",
+ "body": "Ob Erinnerungen vor dem Anlass verschickt werden und wie viele Tage vorher, wird im Workflow „Erinnerung vor dem Anlass“ festgelegt. Auf dieser Seite werden die E-Mail-Vorlagen bearbeitet; anlassspezifische Überschreibungen bleiben auf der jeweiligen Anlass-Detailseite.",
+ "link": "Workflows öffnen"
+ },
"globalSection": "Globales Verhalten",
"globalHelp": "Standardmässig aus — aktiviere, um Erinnerungen zu senden. Der unten gesetzte Offset ist der Standard; jeder Anlass kann ihn auf der Detailseite überschreiben.",
"enableLabel": "Erinnerungs-E-Mails vor dem Anlass senden",
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index 4db1229e..4197e32b 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -4918,6 +4918,11 @@
"title": "Late fees must be itemised in your terms (AGB)",
"body": "A contractual duty: phrases like “late fees apply” aren't enough. Your terms must state the concrete fee (e.g. “CHF 20 from the 2nd reminder”). Verify with your Treuhänder."
},
+ "dunningMoved": {
+ "title": "Reminder schedule is now in Workflows",
+ "body": "When and how often overdue reminders go out is configured in the “Invoice dunning” workflow. The late-fee amounts below still apply.",
+ "link": "Open Workflows"
+ },
"crm_invoices_late_fee_label": {
"label": "Late fee label"
},
@@ -5021,6 +5026,11 @@
},
"reminderTemplates": {
"title": "Pre-event reminder emails",
+ "scheduleMoved": {
+ "title": "The reminder schedule is now in Workflows",
+ "body": "Whether pre-event reminders are sent, and how many days before the event, is configured in the “Pre-event reminder” workflow. This page edits the email templates; per-event overrides stay on each event’s detail page.",
+ "link": "Open Workflows"
+ },
"globalSection": "Global behaviour",
"globalHelp": "Off by default — turn on to start sending pre-event reminders. The offset below is the default; each event can override on its detail page.",
"enableLabel": "Send pre-event reminder emails",
diff --git a/frontend/src/pages/admin/settings/CrmSettingsPage.tsx b/frontend/src/pages/admin/settings/CrmSettingsPage.tsx
index 1488fd89..15fbbd27 100644
--- a/frontend/src/pages/admin/settings/CrmSettingsPage.tsx
+++ b/frontend/src/pages/admin/settings/CrmSettingsPage.tsx
@@ -8,8 +8,9 @@
*/
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
+import { Link } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
-import { Save as SaveIcon } from 'lucide-react';
+import { Save as SaveIcon, Workflow as WorkflowIcon } from 'lucide-react';
import { Button, Card, Loading, Input } from '../../../components/common';
import { settingsService } from '../../../services/settings.service';
import { quotesService } from '../../../services/quotes.service';
@@ -83,6 +84,10 @@ export const CrmSettingsPage: React.FC = () => {
// invoice-pipeline + revenue tiles, all of which are CRM-money).
const showQuotes = !!flags.quotes;
const showInvoices = !!flags.bills;
+ // When the workflow engine is live, reminder TIMING is owned by the Invoice
+ // dunning flow — show a pointer instead of the legacy schedule controls. When
+ // it's off, the legacy reminder ladder still runs, so keep its controls.
+ const workflowsLive = !!flags.workflows;
const showContracts = !!flags.contracts;
const showDashboardOverview = !!(flags.quotes || flags.bills);
const anySection = showQuotes || showInvoices || showContracts || showDashboardOverview;
@@ -244,7 +249,26 @@ export const CrmSettingsPage: React.FC = () => {
{t('crmSettings.section.invoices', 'Invoices')}
{checkbox('crm_invoices_qr_enabled', 'Render payment QR on invoice PDFs')}
- {checkbox('crm_invoices_reminders_enabled', 'Send automatic reminders for overdue invoices')}
+
+ {/* Reminder TIMING: owned by the Invoice dunning workflow when the
+ engine is live (callout); otherwise the legacy schedule controls. The
+ late-fee math below is configured here in both cases — it's the fee
+ the dunning path applies, not part of the schedule. */}
+ {workflowsLive ? (
+
+
+
+
{t('crmSettings.dunningMoved.title', 'Reminder schedule is now in Workflows')}
+
+ {t('crmSettings.dunningMoved.body', 'When and how often overdue reminders go out is configured in the “Invoice dunning” workflow. Late-fee amounts below still apply.')}{' '}
+ {t('crmSettings.dunningMoved.link', 'Open Workflows')}
+
+
+
+ ) : (
+ checkbox('crm_invoices_reminders_enabled', 'Send automatic reminders for overdue invoices')
+ )}
+
{checkbox('crm_invoices_late_fee_enabled', 'Add a late fee (Mahngebühr) on the 2nd and 3rd reminder')}
{t('crmSettings.lateFeeAgb.title', 'Late fees must be itemised in your terms (AGB)')}
{checkbox('crm_invoices_late_fee_vat_enabled', 'Charge VAT on late fees (Switzerland — leave off for DE/AT; no effect if your organisation has no VAT rate)')}
- {t('reminderTemplates.globalHelp',
- 'Off by default — turn on to start sending pre-event reminders. The offset below is the default; each event can override on its detail page.')}
-
{t('reminderTemplates.scheduleMoved.title', 'The reminder schedule is now in Workflows')}
+
+ {t('reminderTemplates.scheduleMoved.body', 'Whether pre-event reminders are sent, and how many days before the event, is configured in the “Pre-event reminder” workflow. This page edits the email templates; per-event overrides stay on each event’s detail page.')}{' '}
+ {t('reminderTemplates.scheduleMoved.link', 'Open Workflows')}
+
+ {t('reminderTemplates.globalHelp',
+ 'Off by default — turn on to start sending pre-event reminders. The offset below is the default; each event can override on its detail page.')}
+