Commit Graph

9 Commits

Author SHA1 Message Date
Luca 192d2cbc06 feat(workflows): crash recovery — resume runs orphaned mid-flow
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.
2026-06-23 13:39:17 +02:00
Luca b78bd979dc feat(workflows): collections-handoff block after dunning exhausts
New escalate_to_collections action: when the 3-reminder loop ends still
unpaid, consolidate ONE email to the admin — customer data, outstanding
(invoice + late fees − paid), and the invoice PDF attached — ready to forward
to an Inkasso agency / for Betreibung. Internal mail, sent immediately; does
NOT touch the invoice. New invoice_collections_handoff email template (en +
native de, seeded by the boot self-heal). Wired into the built-in dunning
flow: loop 'exit' → collections → end (seed v4, re-seeds the disabled
built-in). Selectable + labelled in the canvas editor. Tests 9/9, tsc 0,
build green.
2026-06-23 12:37:50 +02:00
Luca dcdbeb9cc5 feat(crm): 3-reminder dunning + flat/percent Mahngebühr on 2nd & 3rd + AGB notice
- 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.
2026-06-23 12:26:32 +02:00
Luca 5259ee9705 feat(workflows): migrate the dunning ladder onto the engine (cutover)
Makes the built-in dunning flow a faithful replacement for the hardcoded
reminder ladder instead of a disabled representation:

- queue_payment_check action delegates to invoiceService.queuePaymentCheckEmail,
  so the proven confirm + reminder_level + Mahngebühr state machine
  (recordPaymentCheckAction) stays the single source of truth — the workflow
  only decides WHEN the payment-check email (the gate) fires.
- runScheduledTasks now SKIPS the hardcoded reminder batches when workflows is
  on AND the invoice_dunning built-in is enabled, so the two never double-send.
- The built-in graph is re-authored to the delegation model (wait→due, grace,
  loop: check-paid → payment-check → wait-gap), dropping the redundant gate +
  generic reminder emails. A SEED_VERSION re-seeds the disabled, never-activated
  built-in on boot but never touches an enabled/edited one.

Tests: delegation graph shape, re-seed-when-stale, enabled-protection (9 engine
+ 8 route = 17 passing).
2026-06-23 11:12:19 +02:00
Luca 9b557efbf3 feat(workflows): seed invoice-dunning ladder as an editable built-in flow
Boot self-heal seeds the corrected gate-in-loop dunning graph (wait→due,
grace wait, invoice_paid check, confirm-no-payment gate, bounded reminder
loop with re-check, final notice) keyed on builtin_key='invoice_dunning',
sized from the reminder_first/second_days settings. Seeded DISABLED and
is_builtin: live reminder behaviour is UNCHANGED (the hardcoded scheduler
ladder still runs) — enabling it pre-cutover would double-send, so the
engine cutover is a deliberate follow-up. Idempotent (preserves admin edits).
Built-ins refuse delete (enforced in the CRUD route). Test covers seed shape
+ idempotency.
2026-06-23 02:44:05 +02:00
Luca b48d8c2eb8 feat(workflows): approval gates — email confirm/deny + token resume
gate_setup action creates a workflow_approvals row (single-use token stored
as SHA-256 hash) and emails the admin confirm/deny links immediately
(internal mail, no business-hours floor). actByToken / actById finalize the
approval and resume the run down the matching confirm/deny edge; both are
idempotent (a second click → 'already recorded') and respect expiry. Public
GET /api/public/workflow-approvals/:token/:action returns a small HTML
confirmation page (clickable from email, single-use so prefetch can't
double-act). listPending backs the webview inbox (wired in the CRUD phase).
Test covers gate→approval→email→token-confirm→resume + idempotency.
2026-06-23 02:37:20 +02:00
Luca 96fb44045e feat(workflows): data-touching action + condition handlers
Adds send_email (INTERNAL/admin = immediate, EXTERNAL/customer = business-
hours floor via queueEmail's respectBusinessHours) and the invoice_paid
condition (paid_at / status / cumulative paid_amount). Registers the
prepare_quote/contract/event/gallery/invoice + send_document + reserve_date
document actions as recognized-but-not-yet-wired (record an observable
skipped step rather than crashing a flow). index.js side-effect-imports the
handlers. Tests cover the customer-mail routing + the invoice_paid logic.
2026-06-23 02:28:02 +02:00
Luca 610a3dfd73 feat(workflows): scheduler resumes elapsed wait nodes
Adds engine.runDueWaits() — polls waiting runs whose wake_at has passed and
resumes the ones parked on a wait node (gate timeouts handled later by the
approvals layer). Flag-gated (fails closed when workflows is off). Wired into
the existing hourly invoiceScheduler tick in its own try/catch so a workflow
failure never suppresses the invoice/reminder jobs. Test covers not-due vs
elapsed resume.
2026-06-23 02:09:57 +02:00
Luca 1eaef67c36 feat(workflows): execution engine core + registry + tests
Graph executor that walks nodes/edges per run: trigger, condition/branch
(registered conditions → yes/no edge), bounded loop (counter in context +
maxIterations cap), wait (status=waiting + wake_at for the scheduler), gate
(status=waiting; resumed via confirm/deny edge), action/webhook (registered
handlers). emitWorkflowEvent creates one idempotent run per matching enabled
workflow (unique dedup_key) and fails CLOSED if the flag system is
unavailable; never throws into callers (safe to call after commit). Every
node records a workflow_run_steps row. Registry seeds primitive
conditions (always/never/expr) + actions (noop/log/set_context). Integration
test covers loop+wait resume, gate confirm, and dedup.
2026-06-23 02:05:34 +02:00