From ea2f44287f1245360d14a61cd51d03e14dadceed Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 20:31:07 -0400 Subject: [PATCH 01/36] feat(fusion_accounting_followup): Phase 4 skeleton + plan 35-task plan to replace Enterprise account_followup module: - Multi-level dunning (gentle reminder -> firm warning -> legal) - AI augmentation: contextual follow-up text generation + payment risk scoring + tone selection - HYBRID engine: shared primitives + persisted level/run/cache models - Per-partner state: current level, paused-until, history - Coexists with Enterprise (group_fusion_show_when_enterprise_absent) - Same V19 conventions + test pyramid + perf-budget discipline as Phases 1-3 Made-with: Cursor --- fusion_accounting/PHASE_4_PLAN.md | 140 ++++++++++++++++++ fusion_accounting_followup/__init__.py | 0 fusion_accounting_followup/__manifest__.py | 45 ++++++ .../controllers/__init__.py | 0 fusion_accounting_followup/models/__init__.py | 0 .../reports/__init__.py | 0 .../security/ir.model.access.csv | 1 + .../services/__init__.py | 0 .../static/description/icon.png | Bin 0 -> 73585 bytes fusion_accounting_followup/tests/__init__.py | 0 .../wizards/__init__.py | 0 11 files changed, 186 insertions(+) create mode 100644 fusion_accounting/PHASE_4_PLAN.md create mode 100644 fusion_accounting_followup/__init__.py create mode 100644 fusion_accounting_followup/__manifest__.py create mode 100644 fusion_accounting_followup/controllers/__init__.py create mode 100644 fusion_accounting_followup/models/__init__.py create mode 100644 fusion_accounting_followup/reports/__init__.py create mode 100644 fusion_accounting_followup/security/ir.model.access.csv create mode 100644 fusion_accounting_followup/services/__init__.py create mode 100644 fusion_accounting_followup/static/description/icon.png create mode 100644 fusion_accounting_followup/tests/__init__.py create mode 100644 fusion_accounting_followup/wizards/__init__.py diff --git a/fusion_accounting/PHASE_4_PLAN.md b/fusion_accounting/PHASE_4_PLAN.md new file mode 100644 index 00000000..17b66285 --- /dev/null +++ b/fusion_accounting/PHASE_4_PLAN.md @@ -0,0 +1,140 @@ +# Phase 4 — Fusion Accounting Follow-up Implementation Plan + +**Module:** `fusion_accounting_followup` +**Branch:** `fusion_accounting/phase-4-followup` +**Pre-phase tag:** `fusion_accounting/pre-phase-4` +**Estimated tasks:** ~35 +**Reference:** `/Users/gurpreet/Github/RePackaged-Odoo/accounting/account_followup/` (~1318 LOC Python) + +## Goal + +Replace Enterprise's `account_followup` module — multi-level dunning sequences for unpaid invoices, with AI augmentation: contextually-appropriate follow-up text generation + payment-risk scoring + tone adjustment based on customer history. Coexists with Enterprise. + +## Architecture (HYBRID engine, Phases 1-3 pattern) + +``` +fusion.followup.engine (AbstractModel) ← shared primitives +├── compute_followup_level(partner) +├── get_overdue_for_partner(partner) +├── send_followup_email(partner, level=None) +├── escalate_to_next_level(partner) +├── pause_followup(partner, until_date) +├── reset_followup(partner) +└── snapshot_followup_history(partner) ← audit/history + +services/ ← pure-Python +├── overdue_aging.py → bucket overdue lines (current/30/60/90/120+) +├── level_resolver.py → match aging buckets to follow-up levels +├── risk_scorer.py → payment-history risk score (0-100) +├── tone_selector.py → gentle/firm/legal based on level + risk +├── followup_text_generator.py → LLM-generated follow-up text +└── followup_text_prompt.py → provider-agnostic LLM prompt + +models/ +├── fusion_followup_level.py → level definition (delay days, template, action) +├── fusion_followup_run.py → execution record (per-partner per-level) +├── fusion_followup_text_cache.py → LLM-generated text cache (cost-saving) +├── fusion_followup_engine.py → AbstractModel orchestrator +├── res_partner.py (inherit) → fusion_followup_status, fusion_followup_paused_until +└── account_move_line.py (inherit) → followup_level_id (which level last contacted at) + +controllers/followup_controller.py ← 6 JSON-RPC endpoints +├── /fusion/followup/list_overdue → list partners with overdue +├── /fusion/followup/get_partner_detail → single partner with aging + history +├── /fusion/followup/generate_text → AI-generate follow-up text +├── /fusion/followup/send → send a follow-up email +├── /fusion/followup/pause → pause follow-ups for a partner +└── /fusion/followup/reset → reset follow-up state + +static/src/ +├── scss/ ← follow-up design tokens +├── services/followup_service.js ← reactive state + RPC wrappers +├── views/followup_dashboard/ ← top-level OWL controller +└── components/ ← partner_card, aging_bucket_strip, ai_text_panel, + followup_history_table, risk_badge +``` + +## Coexistence + +`group_fusion_show_when_enterprise_absent`. Follow-up menu visible only when `account_followup` NOT installed. + +## Tasks (~35 total) + +### Group 1: Foundation (1-2) +1. Safety net (DONE) +2. Plan doc + module skeleton + +### Group 2: Pure-Python services TDD (3-7) +3. `services/overdue_aging.py` (TDD: bucket lines into 0/30/60/90/120+) +4. `services/level_resolver.py` (TDD: match aging to level) +5. `services/risk_scorer.py` (TDD: payment-history risk 0-100) +6. `services/tone_selector.py` (TDD: gentle/firm/legal) +7. `services/followup_text_generator.py` + `followup_text_prompt.py` (LLM) + +### Group 3: Persisted models (8-12) +8. `models/fusion_followup_level.py` (level definition) +9. `models/fusion_followup_run.py` (execution record) +10. `models/fusion_followup_text_cache.py` (LLM cache) +11. `models/res_partner.py` (inherit: fusion_followup_status, paused_until) +12. `models/account_move_line.py` (inherit: followup_level_id) + +### Group 4: Engine + integration tests (13-14) +13. `models/fusion_followup_engine.py` (7-method API) +14. Engine integration tests + +### Group 5: Backend wiring (15-18) +15. JSON-RPC controller (6 endpoints) +16. FollowupAdapter wiring `_via_fusion` paths +17. 4 new AI tools (list_overdue, generate_text, send_followup, get_risk_score) +18. Cron — daily scan + escalate + +### Group 6: Tests + perf (19-21) +19. Property-based tests (Hypothesis: aging buckets sum to total) +20. Integration tests (full follow-up flow: scan → escalate → send → reset) +21. Performance benchmarks (P95: scan < 500ms, generate_text < 5s incl. LLM) + +### Group 7: Frontend (22-26) +22. SCSS tokens + main stylesheet +23. `followup_service.js` +24. `followup_dashboard` (top-level) +25. `partner_card` + `aging_bucket_strip` + `risk_badge` +26. `ai_text_panel` (Fusion-only) + `followup_history_table` + +### Group 8: Wizards + data (27-29) +27. Default follow-up levels XML data (7-day reminder, 30-day, 60-day, legal) +28. Default mail templates XML data (3 escalation levels) +29. "Send batch follow-ups" wizard + +### Group 9: Migration + coexistence (30-32) +30. Migration wizard inheritance — backfill from account_followup tables +31. Menu + window action with coexistence group filter +32. Coexistence test + +### Group 10: Final tests + polish (33-37) +33. 5 OWL tour tests +34. Local LLM compat test for text_generator +35. Update meta-module manifest +36. CLAUDE.md, UPGRADE_NOTES.md, README.md +37. End-to-end smoke + tag phase-4-complete + push + +## Performance Targets (P95) + +- `compute_followup_level`: <50ms +- `get_overdue_for_partner`: <100ms +- `send_followup_email` (no LLM): <200ms +- `generate_text` (with LLM): <5s +- Controller `list_overdue` (50 partners): <500ms + +## V19 Conventions (Phases 1-3 lessons) + +- `models.Constraint` not `_sql_constraints` +- No `@api.depends('id')` on stored compute fields +- `@route(type='jsonrpc')` not `type='json'` +- `ir.cron` no `numbercall` field +- `res.groups.user_ids` not `users` +- `ir.ui.menu.group_ids` not `groups_id` +- `from odoo.exceptions import UserError, ValidationError` (NOT `self.env['ir.exceptions'].UserError`) + +## Test Targets + +Match Phases 1-3 test pyramid. Phase 4 target: ~80-100 additional tests → ~510-530 total project tests. diff --git a/fusion_accounting_followup/__init__.py b/fusion_accounting_followup/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fusion_accounting_followup/__manifest__.py b/fusion_accounting_followup/__manifest__.py new file mode 100644 index 00000000..a3936fac --- /dev/null +++ b/fusion_accounting_followup/__manifest__.py @@ -0,0 +1,45 @@ +{ + 'name': 'Fusion Accounting Follow-up', + 'version': '19.0.1.0.0', + 'category': 'Accounting/Accounting', + 'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.', + 'description': """ +Fusion Accounting Follow-up +=========================== + +A Fusion-native replacement for Odoo Enterprise's account_followup module. + +CORE scope (Phase 4): +- Multi-level dunning sequences (gentle reminder, firm warning, legal) +- Per-partner follow-up state (current level, paused-until, history) +- Automated daily scan + escalation cron +- Mail templates per level + +AI augmentation: +- Contextually-appropriate follow-up text generation (LLM) +- Payment-risk scoring from invoice/payment history +- Tone selection (gentle/firm/legal) based on level + risk + +Coexists with Enterprise: when account_followup is installed, the Fusion +menu hides; the engine + AI tools remain available for the chat. +""", + 'author': 'Fusion Accounting', + 'license': 'LGPL-3', + 'depends': [ + 'fusion_accounting_core', + 'fusion_accounting_ai', + 'account', + 'mail', + ], + 'data': [ + 'security/ir.model.access.csv', + ], + 'assets': { + 'web.assets_backend': [ + ], + }, + 'installable': True, + 'auto_install': False, + 'application': False, + 'icon': '/fusion_accounting_followup/static/description/icon.png', +} diff --git a/fusion_accounting_followup/controllers/__init__.py b/fusion_accounting_followup/controllers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fusion_accounting_followup/models/__init__.py b/fusion_accounting_followup/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fusion_accounting_followup/reports/__init__.py b/fusion_accounting_followup/reports/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fusion_accounting_followup/security/ir.model.access.csv b/fusion_accounting_followup/security/ir.model.access.csv new file mode 100644 index 00000000..97dd8b91 --- /dev/null +++ b/fusion_accounting_followup/security/ir.model.access.csv @@ -0,0 +1 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink diff --git a/fusion_accounting_followup/services/__init__.py b/fusion_accounting_followup/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fusion_accounting_followup/static/description/icon.png b/fusion_accounting_followup/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..6773c627dc8ca783b5414766fdbd486de33247d0 GIT binary patch literal 73585 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

tIeyo-Gt$vj&yQ?ibyigSLLN~9+ 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∾̳|J{VR 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 bzK{MFeb>Wk^o&W(00000NkvXXu0mjfpW3^6 literal 0 HcmV?d00001 diff --git a/fusion_accounting_followup/tests/__init__.py b/fusion_accounting_followup/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fusion_accounting_followup/wizards/__init__.py b/fusion_accounting_followup/wizards/__init__.py new file mode 100644 index 00000000..e69de29b From 4ce0edc69800adcff6055d28d3dd15c6859aa8e4 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 20:35:39 -0400 Subject: [PATCH 02/36] feat(fusion_accounting_followup): overdue_aging service with 6 buckets Made-with: Cursor --- fusion_accounting_followup/__init__.py | 5 ++ fusion_accounting_followup/__manifest__.py | 2 +- .../services/__init__.py | 1 + .../services/overdue_aging.py | 88 +++++++++++++++++++ fusion_accounting_followup/tests/__init__.py | 1 + .../tests/test_overdue_aging.py | 69 +++++++++++++++ 6 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 fusion_accounting_followup/services/overdue_aging.py create mode 100644 fusion_accounting_followup/tests/test_overdue_aging.py diff --git a/fusion_accounting_followup/__init__.py b/fusion_accounting_followup/__init__.py index e69de29b..9898e1c8 100644 --- a/fusion_accounting_followup/__init__.py +++ b/fusion_accounting_followup/__init__.py @@ -0,0 +1,5 @@ +from . import models +from . import services +from . import controllers +from . import wizards +from . import reports diff --git a/fusion_accounting_followup/__manifest__.py b/fusion_accounting_followup/__manifest__.py index a3936fac..8891f162 100644 --- a/fusion_accounting_followup/__manifest__.py +++ b/fusion_accounting_followup/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting Follow-up', - 'version': '19.0.1.0.0', + 'version': '19.0.1.0.1', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.', 'description': """ diff --git a/fusion_accounting_followup/services/__init__.py b/fusion_accounting_followup/services/__init__.py index e69de29b..62b02039 100644 --- a/fusion_accounting_followup/services/__init__.py +++ b/fusion_accounting_followup/services/__init__.py @@ -0,0 +1 @@ +from . import overdue_aging diff --git a/fusion_accounting_followup/services/overdue_aging.py b/fusion_accounting_followup/services/overdue_aging.py new file mode 100644 index 00000000..f9315bc8 --- /dev/null +++ b/fusion_accounting_followup/services/overdue_aging.py @@ -0,0 +1,88 @@ +"""Aging bucket primitives. + +Pure-Python: callers pass a list of move-line dicts with `date_maturity` +and `amount_residual`; we bucket them into 0/30/60/90/120+ days overdue.""" + +from dataclasses import dataclass, field +from datetime import date + + +BUCKETS = [ + ('current', 0, 0), + ('1_30', 1, 30), + ('31_60', 31, 60), + ('61_90', 61, 90), + ('91_120', 91, 120), + ('120_plus', 121, None), +] + + +@dataclass +class AgingBucket: + name: str + days_min: int + days_max: int | None + amount: float = 0.0 + line_count: int = 0 + + +@dataclass +class AgingReport: + as_of: date + buckets: list[AgingBucket] = field(default_factory=list) + total_amount: float = 0.0 + total_overdue_amount: float = 0.0 + line_count: int = 0 + + def to_dict(self): + return { + 'as_of': str(self.as_of), + 'total_amount': self.total_amount, + 'total_overdue_amount': self.total_overdue_amount, + 'line_count': self.line_count, + 'buckets': [{ + 'name': b.name, 'days_min': b.days_min, 'days_max': b.days_max, + 'amount': b.amount, 'line_count': b.line_count, + } for b in self.buckets], + } + + +def compute_aging(*, move_lines: list[dict], as_of: date | None = None) -> AgingReport: + """Bucket move-line dicts into aging brackets. + + Each dict needs: date_maturity (date), amount_residual (float). + `as_of` defaults to today.""" + as_of = as_of or date.today() + report = AgingReport(as_of=as_of) + for name, days_min, days_max in BUCKETS: + report.buckets.append(AgingBucket(name=name, days_min=days_min, days_max=days_max)) + + for ml in move_lines: + maturity = ml.get('date_maturity') + amount = ml.get('amount_residual', 0.0) + if not maturity: + continue + days_overdue = (as_of - maturity).days + bucket = _find_bucket(report.buckets, days_overdue) + if bucket: + bucket.amount += amount + bucket.line_count += 1 + report.total_amount += amount + if days_overdue > 0: + report.total_overdue_amount += amount + report.line_count += 1 + + return report + + +def _find_bucket(buckets: list[AgingBucket], days_overdue: int) -> AgingBucket | None: + if days_overdue <= 0: + return next((b for b in buckets if b.name == 'current'), None) + for b in buckets: + if b.name == 'current': + continue + if b.days_max is None and days_overdue >= b.days_min: + return b + if b.days_max is not None and b.days_min <= days_overdue <= b.days_max: + return b + return None diff --git a/fusion_accounting_followup/tests/__init__.py b/fusion_accounting_followup/tests/__init__.py index e69de29b..525812c7 100644 --- a/fusion_accounting_followup/tests/__init__.py +++ b/fusion_accounting_followup/tests/__init__.py @@ -0,0 +1 @@ +from . import test_overdue_aging diff --git a/fusion_accounting_followup/tests/test_overdue_aging.py b/fusion_accounting_followup/tests/test_overdue_aging.py new file mode 100644 index 00000000..c1b620bd --- /dev/null +++ b/fusion_accounting_followup/tests/test_overdue_aging.py @@ -0,0 +1,69 @@ +from datetime import date, timedelta +from odoo.tests.common import TransactionCase +from odoo.tests import tagged +from odoo.addons.fusion_accounting_followup.services.overdue_aging import ( + compute_aging, BUCKETS, +) + + +@tagged('post_install', '-at_install') +class TestOverdueAging(TransactionCase): + + def test_empty_lines_returns_zero_buckets(self): + report = compute_aging(move_lines=[], as_of=date(2026, 4, 19)) + self.assertEqual(report.total_amount, 0) + self.assertEqual(len(report.buckets), len(BUCKETS)) + for b in report.buckets: + self.assertEqual(b.amount, 0) + + def test_current_bucket_for_future_maturity(self): + as_of = date(2026, 4, 19) + lines = [{'date_maturity': date(2026, 5, 19), 'amount_residual': 100}] + report = compute_aging(move_lines=lines, as_of=as_of) + current = next(b for b in report.buckets if b.name == 'current') + self.assertEqual(current.amount, 100) + self.assertEqual(report.total_overdue_amount, 0) + + def test_30_day_bucket(self): + as_of = date(2026, 4, 19) + lines = [{'date_maturity': as_of - timedelta(days=15), 'amount_residual': 200}] + report = compute_aging(move_lines=lines, as_of=as_of) + b = next(b for b in report.buckets if b.name == '1_30') + self.assertEqual(b.amount, 200) + + def test_60_day_bucket(self): + as_of = date(2026, 4, 19) + lines = [{'date_maturity': as_of - timedelta(days=45), 'amount_residual': 300}] + report = compute_aging(move_lines=lines, as_of=as_of) + b = next(b for b in report.buckets if b.name == '31_60') + self.assertEqual(b.amount, 300) + + def test_120_plus_bucket(self): + as_of = date(2026, 4, 19) + lines = [{'date_maturity': as_of - timedelta(days=200), 'amount_residual': 500}] + report = compute_aging(move_lines=lines, as_of=as_of) + b = next(b for b in report.buckets if b.name == '120_plus') + self.assertEqual(b.amount, 500) + + def test_total_overdue_excludes_current(self): + as_of = date(2026, 4, 19) + lines = [ + {'date_maturity': as_of + timedelta(days=10), 'amount_residual': 100}, + {'date_maturity': as_of - timedelta(days=10), 'amount_residual': 200}, + {'date_maturity': as_of - timedelta(days=50), 'amount_residual': 300}, + ] + report = compute_aging(move_lines=lines, as_of=as_of) + self.assertEqual(report.total_amount, 600) + self.assertEqual(report.total_overdue_amount, 500) + + def test_buckets_sum_equals_total(self): + as_of = date(2026, 4, 19) + lines = [ + {'date_maturity': as_of + timedelta(days=10), 'amount_residual': 100}, + {'date_maturity': as_of - timedelta(days=15), 'amount_residual': 200}, + {'date_maturity': as_of - timedelta(days=75), 'amount_residual': 300}, + {'date_maturity': as_of - timedelta(days=200), 'amount_residual': 500}, + ] + report = compute_aging(move_lines=lines, as_of=as_of) + bucket_sum = sum(b.amount for b in report.buckets) + self.assertAlmostEqual(bucket_sum, report.total_amount, places=2) From d4ef19858da971b3f76aad73d3f53225a60304e9 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 20:38:02 -0400 Subject: [PATCH 03/36] feat(fusion_accounting_followup): level_resolver service Made-with: Cursor --- fusion_accounting_followup/__manifest__.py | 2 +- .../services/__init__.py | 1 + .../services/level_resolver.py | 52 +++++++++++++++++ .../services/overdue_aging.py | 4 ++ fusion_accounting_followup/tests/__init__.py | 1 + .../tests/test_level_resolver.py | 58 +++++++++++++++++++ 6 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 fusion_accounting_followup/services/level_resolver.py create mode 100644 fusion_accounting_followup/tests/test_level_resolver.py diff --git a/fusion_accounting_followup/__manifest__.py b/fusion_accounting_followup/__manifest__.py index 8891f162..e9c8c47c 100644 --- a/fusion_accounting_followup/__manifest__.py +++ b/fusion_accounting_followup/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting Follow-up', - 'version': '19.0.1.0.1', + 'version': '19.0.1.0.2', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.', 'description': """ diff --git a/fusion_accounting_followup/services/__init__.py b/fusion_accounting_followup/services/__init__.py index 62b02039..a0ed7f7e 100644 --- a/fusion_accounting_followup/services/__init__.py +++ b/fusion_accounting_followup/services/__init__.py @@ -1 +1,2 @@ from . import overdue_aging +from . import level_resolver diff --git a/fusion_accounting_followup/services/level_resolver.py b/fusion_accounting_followup/services/level_resolver.py new file mode 100644 index 00000000..0752816a --- /dev/null +++ b/fusion_accounting_followup/services/level_resolver.py @@ -0,0 +1,52 @@ +"""Level resolver: which follow-up level should fire for this partner? + +Pure-Python: caller passes the aging report + the configured levels list, +and we pick the highest-numbered level whose threshold is met.""" + +from dataclasses import dataclass + + +@dataclass +class FollowupLevelSpec: + sequence: int + name: str + delay_days: int + tone: str + + def __post_init__(self): + if self.tone not in ('gentle', 'firm', 'legal'): + raise ValueError(f"Invalid tone: {self.tone}") + + +def resolve_level(*, aging_report, levels: list[FollowupLevelSpec]) -> FollowupLevelSpec | None: + """Pick the highest-sequence level whose delay_days has been crossed by + the most-overdue line in the aging report. Returns None if no overdue + lines or no levels configured.""" + if not levels or not aging_report: + return None + max_days_overdue = _max_days_overdue(aging_report) + if max_days_overdue <= 0: + return None + levels_sorted = sorted(levels, key=lambda l: l.sequence, reverse=True) + for level in levels_sorted: + if level.delay_days <= max_days_overdue: + return level + return None + + +def _max_days_overdue(aging_report) -> int: + """Return the actual max days-overdue tracked on the report, falling + back to the highest populated bucket's lower bound when an older + aging report (without `max_days_overdue`) is passed in.""" + tracked = getattr(aging_report, 'max_days_overdue', 0) or 0 + if tracked: + return tracked + max_days = 0 + for b in aging_report.buckets: + if b.name == 'current' or b.amount <= 0: + continue + if b.days_max is None: + max_days = max(max_days, b.days_min) + else: + max_days = max(max_days, b.days_min) + return max_days diff --git a/fusion_accounting_followup/services/overdue_aging.py b/fusion_accounting_followup/services/overdue_aging.py index f9315bc8..6ce86cae 100644 --- a/fusion_accounting_followup/services/overdue_aging.py +++ b/fusion_accounting_followup/services/overdue_aging.py @@ -33,6 +33,7 @@ class AgingReport: total_amount: float = 0.0 total_overdue_amount: float = 0.0 line_count: int = 0 + max_days_overdue: int = 0 def to_dict(self): return { @@ -40,6 +41,7 @@ class AgingReport: 'total_amount': self.total_amount, 'total_overdue_amount': self.total_overdue_amount, 'line_count': self.line_count, + 'max_days_overdue': self.max_days_overdue, 'buckets': [{ 'name': b.name, 'days_min': b.days_min, 'days_max': b.days_max, 'amount': b.amount, 'line_count': b.line_count, @@ -70,6 +72,8 @@ def compute_aging(*, move_lines: list[dict], as_of: date | None = None) -> Aging report.total_amount += amount if days_overdue > 0: report.total_overdue_amount += amount + if days_overdue > report.max_days_overdue: + report.max_days_overdue = days_overdue report.line_count += 1 return report diff --git a/fusion_accounting_followup/tests/__init__.py b/fusion_accounting_followup/tests/__init__.py index 525812c7..dac21459 100644 --- a/fusion_accounting_followup/tests/__init__.py +++ b/fusion_accounting_followup/tests/__init__.py @@ -1 +1,2 @@ from . import test_overdue_aging +from . import test_level_resolver diff --git a/fusion_accounting_followup/tests/test_level_resolver.py b/fusion_accounting_followup/tests/test_level_resolver.py new file mode 100644 index 00000000..12a8f35c --- /dev/null +++ b/fusion_accounting_followup/tests/test_level_resolver.py @@ -0,0 +1,58 @@ +from datetime import date, timedelta +from odoo.tests.common import TransactionCase +from odoo.tests import tagged +from odoo.addons.fusion_accounting_followup.services.level_resolver import ( + FollowupLevelSpec, resolve_level, +) +from odoo.addons.fusion_accounting_followup.services.overdue_aging import compute_aging + + +@tagged('post_install', '-at_install') +class TestLevelResolver(TransactionCase): + + def setUp(self): + super().setUp() + self.levels = [ + FollowupLevelSpec(sequence=1, name='Reminder', delay_days=7, tone='gentle'), + FollowupLevelSpec(sequence=2, name='Warning', delay_days=30, tone='firm'), + FollowupLevelSpec(sequence=3, name='Legal Notice', delay_days=60, tone='legal'), + ] + + def test_no_overdue_returns_none(self): + as_of = date(2026, 4, 19) + lines = [{'date_maturity': as_of + timedelta(days=10), 'amount_residual': 100}] + report = compute_aging(move_lines=lines, as_of=as_of) + result = resolve_level(aging_report=report, levels=self.levels) + self.assertIsNone(result) + + def test_15_days_overdue_picks_reminder(self): + as_of = date(2026, 4, 19) + lines = [{'date_maturity': as_of - timedelta(days=15), 'amount_residual': 100}] + report = compute_aging(move_lines=lines, as_of=as_of) + result = resolve_level(aging_report=report, levels=self.levels) + self.assertEqual(result.name, 'Reminder') + + def test_45_days_overdue_picks_warning(self): + as_of = date(2026, 4, 19) + lines = [{'date_maturity': as_of - timedelta(days=45), 'amount_residual': 200}] + report = compute_aging(move_lines=lines, as_of=as_of) + result = resolve_level(aging_report=report, levels=self.levels) + self.assertEqual(result.name, 'Warning') + + def test_75_days_overdue_picks_legal(self): + as_of = date(2026, 4, 19) + lines = [{'date_maturity': as_of - timedelta(days=75), 'amount_residual': 300}] + report = compute_aging(move_lines=lines, as_of=as_of) + result = resolve_level(aging_report=report, levels=self.levels) + self.assertEqual(result.name, 'Legal Notice') + + def test_no_levels_returns_none(self): + as_of = date(2026, 4, 19) + lines = [{'date_maturity': as_of - timedelta(days=30), 'amount_residual': 100}] + report = compute_aging(move_lines=lines, as_of=as_of) + result = resolve_level(aging_report=report, levels=[]) + self.assertIsNone(result) + + def test_invalid_tone_raises(self): + with self.assertRaises(ValueError): + FollowupLevelSpec(sequence=1, name='X', delay_days=7, tone='invalid') From 397fb238c5ff189f879598aec645c6d8648bfee1 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 20:38:44 -0400 Subject: [PATCH 04/36] feat(fusion_accounting_followup): risk_scorer service Made-with: Cursor --- fusion_accounting_followup/__manifest__.py | 2 +- .../services/__init__.py | 1 + .../services/risk_scorer.py | 62 +++++++++++++++++++ fusion_accounting_followup/tests/__init__.py | 1 + .../tests/test_risk_scorer.py | 48 ++++++++++++++ 5 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 fusion_accounting_followup/services/risk_scorer.py create mode 100644 fusion_accounting_followup/tests/test_risk_scorer.py diff --git a/fusion_accounting_followup/__manifest__.py b/fusion_accounting_followup/__manifest__.py index e9c8c47c..235a17ba 100644 --- a/fusion_accounting_followup/__manifest__.py +++ b/fusion_accounting_followup/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting Follow-up', - 'version': '19.0.1.0.2', + 'version': '19.0.1.0.3', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.', 'description': """ diff --git a/fusion_accounting_followup/services/__init__.py b/fusion_accounting_followup/services/__init__.py index a0ed7f7e..1758e546 100644 --- a/fusion_accounting_followup/services/__init__.py +++ b/fusion_accounting_followup/services/__init__.py @@ -1,2 +1,3 @@ from . import overdue_aging from . import level_resolver +from . import risk_scorer diff --git a/fusion_accounting_followup/services/risk_scorer.py b/fusion_accounting_followup/services/risk_scorer.py new file mode 100644 index 00000000..4db10909 --- /dev/null +++ b/fusion_accounting_followup/services/risk_scorer.py @@ -0,0 +1,62 @@ +"""Payment-history risk scorer. + +Pure-Python: takes payment history (list of payment events) + average days-late +and returns a risk score 0-100. Higher = more risky.""" + +from dataclasses import dataclass + + +@dataclass +class PartnerRiskScore: + score: int + band: str + drivers: list[str] + + +def score_partner(*, total_invoices: int = 0, paid_late_count: int = 0, + avg_days_late: float = 0.0, + longest_overdue_days: int = 0, + open_overdue_amount: float = 0.0, + average_invoice_amount: float = 1000.0) -> PartnerRiskScore: + """Compute a 0-100 risk score from payment-history primitives. + + Heuristic weights: + - 30% : late-payment ratio (paid_late_count / total_invoices) + - 25% : avg days late (capped at 60 days) + - 25% : longest current overdue (capped at 120 days) + - 20% : open overdue amount as multiple of average invoice + """ + drivers: list[str] = [] + score = 0.0 + + if total_invoices > 0: + late_ratio = paid_late_count / total_invoices + score += min(late_ratio * 100, 100) * 0.30 + if late_ratio > 0.5: + drivers.append(f"{paid_late_count}/{total_invoices} invoices paid late") + + score += min(avg_days_late / 60, 1) * 100 * 0.25 + if avg_days_late > 14: + drivers.append(f"Avg {avg_days_late:.1f} days late on payment") + + score += min(longest_overdue_days / 120, 1) * 100 * 0.25 + if longest_overdue_days > 30: + drivers.append(f"Longest currently overdue: {longest_overdue_days} days") + + if average_invoice_amount > 0: + ratio = open_overdue_amount / average_invoice_amount + score += min(ratio / 5, 1) * 100 * 0.20 + if ratio > 1.5: + drivers.append(f"Open overdue ${open_overdue_amount:,.2f} ({ratio:.1f}x avg invoice)") + + final = int(round(score)) + if final >= 80: + band = 'critical' + elif final >= 60: + band = 'high' + elif final >= 30: + band = 'medium' + else: + band = 'low' + + return PartnerRiskScore(score=final, band=band, drivers=drivers) diff --git a/fusion_accounting_followup/tests/__init__.py b/fusion_accounting_followup/tests/__init__.py index dac21459..5ad09c02 100644 --- a/fusion_accounting_followup/tests/__init__.py +++ b/fusion_accounting_followup/tests/__init__.py @@ -1,2 +1,3 @@ from . import test_overdue_aging from . import test_level_resolver +from . import test_risk_scorer diff --git a/fusion_accounting_followup/tests/test_risk_scorer.py b/fusion_accounting_followup/tests/test_risk_scorer.py new file mode 100644 index 00000000..93d00cdb --- /dev/null +++ b/fusion_accounting_followup/tests/test_risk_scorer.py @@ -0,0 +1,48 @@ +from odoo.tests.common import TransactionCase +from odoo.tests import tagged +from odoo.addons.fusion_accounting_followup.services.risk_scorer import ( + score_partner, PartnerRiskScore, +) + + +@tagged('post_install', '-at_install') +class TestRiskScorer(TransactionCase): + + def test_no_history_returns_low(self): + result = score_partner() + self.assertEqual(result.band, 'low') + self.assertLess(result.score, 30) + + def test_chronic_late_pays_returns_high(self): + result = score_partner( + total_invoices=20, paid_late_count=18, + avg_days_late=45, longest_overdue_days=90, + open_overdue_amount=15000, average_invoice_amount=2000, + ) + self.assertIn(result.band, ('high', 'critical')) + self.assertGreater(len(result.drivers), 0) + + def test_one_off_overdue_returns_medium(self): + result = score_partner( + total_invoices=10, paid_late_count=1, + avg_days_late=20, longest_overdue_days=45, + open_overdue_amount=2000, average_invoice_amount=2000, + ) + self.assertIn(result.band, ('low', 'medium')) + + def test_score_capped_at_100(self): + result = score_partner( + total_invoices=10, paid_late_count=10, + avg_days_late=180, longest_overdue_days=300, + open_overdue_amount=999999, average_invoice_amount=1000, + ) + self.assertLessEqual(result.score, 100) + + def test_score_floored_at_0(self): + result = score_partner() + self.assertGreaterEqual(result.score, 0) + + def test_band_thresholds(self): + for s, expected_band in [(10, 'low'), (40, 'medium'), (70, 'high'), (90, 'critical')]: + r = PartnerRiskScore(score=s, band=expected_band, drivers=[]) + self.assertEqual(r.band, expected_band) From 63f3e0ec142cabdc2fae6580c59b8810629d0e1e Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 20:39:17 -0400 Subject: [PATCH 05/36] feat(fusion_accounting_followup): tone_selector service Made-with: Cursor --- fusion_accounting_followup/__manifest__.py | 2 +- .../services/__init__.py | 1 + .../services/tone_selector.py | 18 +++++++++++++ fusion_accounting_followup/tests/__init__.py | 1 + .../tests/test_tone_selector.py | 25 +++++++++++++++++++ 5 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 fusion_accounting_followup/services/tone_selector.py create mode 100644 fusion_accounting_followup/tests/test_tone_selector.py diff --git a/fusion_accounting_followup/__manifest__.py b/fusion_accounting_followup/__manifest__.py index 235a17ba..cd00c486 100644 --- a/fusion_accounting_followup/__manifest__.py +++ b/fusion_accounting_followup/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting Follow-up', - 'version': '19.0.1.0.3', + 'version': '19.0.1.0.4', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.', 'description': """ diff --git a/fusion_accounting_followup/services/__init__.py b/fusion_accounting_followup/services/__init__.py index 1758e546..1338970d 100644 --- a/fusion_accounting_followup/services/__init__.py +++ b/fusion_accounting_followup/services/__init__.py @@ -1,3 +1,4 @@ from . import overdue_aging from . import level_resolver from . import risk_scorer +from . import tone_selector diff --git a/fusion_accounting_followup/services/tone_selector.py b/fusion_accounting_followup/services/tone_selector.py new file mode 100644 index 00000000..e77ecd3d --- /dev/null +++ b/fusion_accounting_followup/services/tone_selector.py @@ -0,0 +1,18 @@ +"""Tone selector: pick gentle/firm/legal based on follow-up level + risk score.""" + +TONE_BY_LEVEL = { + 1: 'gentle', + 2: 'firm', + 3: 'legal', + 4: 'legal', +} + + +def select_tone(*, level_sequence: int, risk_score: int = 0) -> str: + """Default tone follows level sequence; high risk can escalate.""" + base_tone = TONE_BY_LEVEL.get(level_sequence, 'gentle') + if risk_score >= 80 and base_tone == 'gentle': + return 'firm' + if risk_score >= 90 and base_tone == 'firm': + return 'legal' + return base_tone diff --git a/fusion_accounting_followup/tests/__init__.py b/fusion_accounting_followup/tests/__init__.py index 5ad09c02..d50dde45 100644 --- a/fusion_accounting_followup/tests/__init__.py +++ b/fusion_accounting_followup/tests/__init__.py @@ -1,3 +1,4 @@ from . import test_overdue_aging from . import test_level_resolver from . import test_risk_scorer +from . import test_tone_selector diff --git a/fusion_accounting_followup/tests/test_tone_selector.py b/fusion_accounting_followup/tests/test_tone_selector.py new file mode 100644 index 00000000..f7df4f63 --- /dev/null +++ b/fusion_accounting_followup/tests/test_tone_selector.py @@ -0,0 +1,25 @@ +from odoo.tests.common import TransactionCase +from odoo.tests import tagged +from odoo.addons.fusion_accounting_followup.services.tone_selector import select_tone + + +@tagged('post_install', '-at_install') +class TestToneSelector(TransactionCase): + + def test_level_1_default_gentle(self): + self.assertEqual(select_tone(level_sequence=1), 'gentle') + + def test_level_2_default_firm(self): + self.assertEqual(select_tone(level_sequence=2), 'firm') + + def test_level_3_default_legal(self): + self.assertEqual(select_tone(level_sequence=3), 'legal') + + def test_critical_risk_escalates_gentle_to_firm(self): + self.assertEqual(select_tone(level_sequence=1, risk_score=85), 'firm') + + def test_extreme_risk_escalates_firm_to_legal(self): + self.assertEqual(select_tone(level_sequence=2, risk_score=95), 'legal') + + def test_unknown_level_defaults_gentle(self): + self.assertEqual(select_tone(level_sequence=99), 'gentle') From 1829f0584fdd7711c3c29739b61dbe60ceda00ec Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 20:40:26 -0400 Subject: [PATCH 06/36] feat(fusion_accounting_followup): AI follow-up text generator + prompt Made-with: Cursor --- fusion_accounting_followup/__manifest__.py | 2 +- .../services/__init__.py | 2 + .../services/followup_text_generator.py | 123 ++++++++++++++++++ .../services/followup_text_prompt.py | 56 ++++++++ fusion_accounting_followup/tests/__init__.py | 1 + .../tests/test_followup_text_generator.py | 80 ++++++++++++ 6 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 fusion_accounting_followup/services/followup_text_generator.py create mode 100644 fusion_accounting_followup/services/followup_text_prompt.py create mode 100644 fusion_accounting_followup/tests/test_followup_text_generator.py diff --git a/fusion_accounting_followup/__manifest__.py b/fusion_accounting_followup/__manifest__.py index cd00c486..c2336df3 100644 --- a/fusion_accounting_followup/__manifest__.py +++ b/fusion_accounting_followup/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting Follow-up', - 'version': '19.0.1.0.4', + 'version': '19.0.1.0.5', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.', 'description': """ diff --git a/fusion_accounting_followup/services/__init__.py b/fusion_accounting_followup/services/__init__.py index 1338970d..8a72cd93 100644 --- a/fusion_accounting_followup/services/__init__.py +++ b/fusion_accounting_followup/services/__init__.py @@ -2,3 +2,5 @@ from . import overdue_aging from . import level_resolver from . import risk_scorer from . import tone_selector +from . import followup_text_prompt +from . import followup_text_generator diff --git a/fusion_accounting_followup/services/followup_text_generator.py b/fusion_accounting_followup/services/followup_text_generator.py new file mode 100644 index 00000000..66478f15 --- /dev/null +++ b/fusion_accounting_followup/services/followup_text_generator.py @@ -0,0 +1,123 @@ +"""AI-generated follow-up text with templated fallback.""" + +import json +import logging + +_logger = logging.getLogger(__name__) + + +TEMPLATES = { + 'gentle': { + 'subject': 'Friendly reminder: invoice payment', + 'body': 'Dear {partner_name},\n\nThis is a friendly reminder that you have ' + '{currency_code} {total_overdue:,.2f} outstanding on invoices that ' + 'are now {longest_overdue_days} days past due. We understand things ' + 'happen — please let us know if there is anything we can do to help ' + 'resolve this.\n\nBest regards.', + }, + 'firm': { + 'subject': 'Outstanding invoices — action required', + 'body': 'Dear {partner_name},\n\nOur records show {currency_code} ' + '{total_overdue:,.2f} outstanding on {invoice_count} invoice(s), ' + 'with the longest now {longest_overdue_days} days overdue. We ' + 'request immediate payment to avoid further action.\n\nRegards.', + }, + 'legal': { + 'subject': 'FINAL NOTICE — outstanding balance', + 'body': 'Dear {partner_name},\n\nDespite previous reminders, ' + '{currency_code} {total_overdue:,.2f} remains outstanding on your ' + 'account, with the longest invoice {longest_overdue_days} days ' + 'overdue. If full payment is not received within 7 days, we will ' + 'be forced to refer this matter for legal collection.\n\n' + 'Regards.', + }, +} + + +def generate_followup_text(env, *, partner_name: str, total_overdue: float, + currency_code: str, longest_overdue_days: int, + tone: str, invoice_count: int = 0, + last_payment_date: str = None, + risk_drivers: list[str] = None, + provider=None) -> dict: + """Generate follow-up text via LLM, with templated fallback. + + Returns: {subject, body, tone_used, key_points}""" + if provider is None: + provider = _get_provider(env) + if provider is None: + return _templated_fallback( + partner_name=partner_name, total_overdue=total_overdue, + currency_code=currency_code, + longest_overdue_days=longest_overdue_days, + tone=tone, invoice_count=invoice_count, + ) + + try: + from .followup_text_prompt import build_prompt + system, user = build_prompt( + partner_name=partner_name, total_overdue=total_overdue, + currency_code=currency_code, + longest_overdue_days=longest_overdue_days, tone=tone, + invoice_count=invoice_count, last_payment_date=last_payment_date, + risk_drivers=risk_drivers, + ) + response = provider.complete( + system=system, + messages=[{'role': 'user', 'content': user}], + max_tokens=800, temperature=0.3, + ) + content = response.get('content') if isinstance(response, dict) else response + parsed = json.loads(content) + for key in ('subject', 'body', 'tone_used'): + if key not in parsed: + raise ValueError(f"Missing key: {key}") + parsed.setdefault('key_points', []) + return parsed + except Exception as e: + _logger.warning("Follow-up text LLM generation failed (%s); falling back", e) + return _templated_fallback( + partner_name=partner_name, total_overdue=total_overdue, + currency_code=currency_code, + longest_overdue_days=longest_overdue_days, + tone=tone, invoice_count=invoice_count, + ) + + +def _templated_fallback(*, partner_name, total_overdue, currency_code, + longest_overdue_days, tone, invoice_count) -> dict: + template = TEMPLATES.get(tone, TEMPLATES['gentle']) + return { + 'subject': template['subject'], + 'body': template['body'].format( + partner_name=partner_name, total_overdue=total_overdue, + currency_code=currency_code, + longest_overdue_days=longest_overdue_days, + invoice_count=invoice_count or 0, + ), + 'tone_used': tone, + 'key_points': [ + f"${total_overdue:,.2f} outstanding", + f"{longest_overdue_days} days overdue", + ], + } + + +def _get_provider(env): + """Look up provider for 'followup_text' feature.""" + param = env['ir.config_parameter'].sudo() + name = param.get_param('fusion_accounting.provider.followup_text') + if not name: + name = param.get_param('fusion_accounting.provider.default') + if not name: + return None + try: + from odoo.addons.fusion_accounting_ai.services.adapters.openai_adapter import OpenAIAdapter + from odoo.addons.fusion_accounting_ai.services.adapters.claude import ClaudeAdapter + except ImportError: + return None + if name.startswith('openai'): + return OpenAIAdapter(env) + elif name.startswith('claude'): + return ClaudeAdapter(env) + return None diff --git a/fusion_accounting_followup/services/followup_text_prompt.py b/fusion_accounting_followup/services/followup_text_prompt.py new file mode 100644 index 00000000..f3635c01 --- /dev/null +++ b/fusion_accounting_followup/services/followup_text_prompt.py @@ -0,0 +1,56 @@ +"""LLM prompt for AI-generated follow-up text. + +Output contract: { + "subject": str, + "body": str, + "tone_used": str, + "key_points": [str, ...] +}""" + + +SYSTEM_PROMPT = """You are an experienced credit collections specialist writing a +follow-up email for an unpaid invoice. Output MUST be valid JSON of this +exact shape: + +{ + "subject": "", + "body": " wrapper>", + "tone_used": "gentle" | "firm" | "legal", + "key_points": ["", "", ...] +} + +Tone guide: +- gentle: friendly reminder, assume oversight, propose easy paths to pay +- firm: state amount + days overdue clearly, request immediate action, + hint at consequences +- legal: formal language, reference contract obligations, mention possible + legal action / collections agency, demand payment by specific date + +Always: +- Use the actual amounts and partner name from the data provided +- Don't invent contract terms or interest rates +- Don't include markdown code fences +- No prose outside the JSON +""" + + +def build_prompt(*, partner_name: str, total_overdue: float, currency_code: str, + longest_overdue_days: int, tone: str, + invoice_count: int = 0, last_payment_date: str = None, + risk_drivers: list[str] = None) -> tuple[str, str]: + parts = [ + f"PARTNER: {partner_name}", + f"TOTAL OVERDUE: {currency_code} {total_overdue:,.2f}", + f"LONGEST OVERDUE: {longest_overdue_days} days", + f"OPEN INVOICE COUNT: {invoice_count}", + f"REQUESTED TONE: {tone}", + ] + if last_payment_date: + parts.append(f"LAST PAYMENT: {last_payment_date}") + if risk_drivers: + parts.append("RISK FACTORS:") + for d in risk_drivers[:5]: + parts.append(f" - {d}") + parts.append("") + parts.append("Write the follow-up email per the system prompt.") + return (SYSTEM_PROMPT, "\n".join(parts)) diff --git a/fusion_accounting_followup/tests/__init__.py b/fusion_accounting_followup/tests/__init__.py index d50dde45..68af990b 100644 --- a/fusion_accounting_followup/tests/__init__.py +++ b/fusion_accounting_followup/tests/__init__.py @@ -2,3 +2,4 @@ from . import test_overdue_aging from . import test_level_resolver from . import test_risk_scorer from . import test_tone_selector +from . import test_followup_text_generator diff --git a/fusion_accounting_followup/tests/test_followup_text_generator.py b/fusion_accounting_followup/tests/test_followup_text_generator.py new file mode 100644 index 00000000..a8e62819 --- /dev/null +++ b/fusion_accounting_followup/tests/test_followup_text_generator.py @@ -0,0 +1,80 @@ +from odoo.tests.common import TransactionCase +from odoo.tests import tagged +from odoo.addons.fusion_accounting_followup.services.followup_text_generator import ( + generate_followup_text, +) +from odoo.addons.fusion_accounting_followup.services.followup_text_prompt import ( + SYSTEM_PROMPT, build_prompt, +) + + +@tagged('post_install', '-at_install') +class TestFollowupTextGenerator(TransactionCase): + + def setUp(self): + super().setUp() + self.env['ir.config_parameter'].sudo().search([ + ('key', 'in', ['fusion_accounting.provider.followup_text', + 'fusion_accounting.provider.default']) + ]).unlink() + + def test_fallback_gentle(self): + result = generate_followup_text( + self.env, partner_name='Acme Corp', total_overdue=1500, + currency_code='USD', longest_overdue_days=15, tone='gentle', + invoice_count=2, + ) + self.assertEqual(result['tone_used'], 'gentle') + self.assertIn('Acme Corp', result['body']) + self.assertIn('1,500.00', result['body']) + + def test_fallback_firm(self): + result = generate_followup_text( + self.env, partner_name='Acme', total_overdue=5000, + currency_code='USD', longest_overdue_days=45, tone='firm', + invoice_count=3, + ) + self.assertEqual(result['tone_used'], 'firm') + + def test_fallback_legal(self): + result = generate_followup_text( + self.env, partner_name='Acme', total_overdue=10000, + currency_code='USD', longest_overdue_days=90, tone='legal', + invoice_count=5, + ) + self.assertEqual(result['tone_used'], 'legal') + self.assertIn('FINAL NOTICE', result['subject']) + + def test_returns_required_keys(self): + result = generate_followup_text( + self.env, partner_name='X', total_overdue=100, + currency_code='USD', longest_overdue_days=10, tone='gentle', + ) + for key in ('subject', 'body', 'tone_used', 'key_points'): + self.assertIn(key, result) + + +@tagged('post_install', '-at_install') +class TestFollowupTextPrompt(TransactionCase): + + def test_system_prompt_requires_json(self): + self.assertIn('JSON', SYSTEM_PROMPT) + self.assertIn('"subject"', SYSTEM_PROMPT) + self.assertIn('"body"', SYSTEM_PROMPT) + + def test_build_prompt_returns_tuple(self): + result = build_prompt( + partner_name='X', total_overdue=100, currency_code='USD', + longest_overdue_days=10, tone='gentle', + ) + self.assertEqual(len(result), 2) + self.assertIn('100.00', result[1]) + + def test_build_prompt_includes_risk_drivers(self): + _, user = build_prompt( + partner_name='X', total_overdue=100, currency_code='USD', + longest_overdue_days=10, tone='firm', + risk_drivers=['Chronic late payer', '5/10 paid late'], + ) + self.assertIn('RISK FACTORS', user) + self.assertIn('Chronic late payer', user) From 9ae916189282d4cfef674a6b049525174fdc3824 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 20:43:51 -0400 Subject: [PATCH 07/36] feat(fusion_accounting_followup): fusion.followup.level definition model Made-with: Cursor --- fusion_accounting_followup/__manifest__.py | 2 +- fusion_accounting_followup/models/__init__.py | 1 + .../models/fusion_followup_level.py | 42 +++++++++++++++++++ .../security/ir.model.access.csv | 2 + fusion_accounting_followup/tests/__init__.py | 1 + .../tests/test_fusion_followup_level.py | 42 +++++++++++++++++++ 6 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 fusion_accounting_followup/models/fusion_followup_level.py create mode 100644 fusion_accounting_followup/tests/test_fusion_followup_level.py diff --git a/fusion_accounting_followup/__manifest__.py b/fusion_accounting_followup/__manifest__.py index c2336df3..316a3177 100644 --- a/fusion_accounting_followup/__manifest__.py +++ b/fusion_accounting_followup/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting Follow-up', - 'version': '19.0.1.0.5', + 'version': '19.0.1.0.6', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.', 'description': """ diff --git a/fusion_accounting_followup/models/__init__.py b/fusion_accounting_followup/models/__init__.py index e69de29b..c7fb1f45 100644 --- a/fusion_accounting_followup/models/__init__.py +++ b/fusion_accounting_followup/models/__init__.py @@ -0,0 +1 @@ +from . import fusion_followup_level diff --git a/fusion_accounting_followup/models/fusion_followup_level.py b/fusion_accounting_followup/models/fusion_followup_level.py new file mode 100644 index 00000000..e2e5d9d2 --- /dev/null +++ b/fusion_accounting_followup/models/fusion_followup_level.py @@ -0,0 +1,42 @@ +"""Follow-up level definition (e.g. Reminder at 7 days, Warning at 30, Legal at 60).""" + +from odoo import _, api, fields, models + + +TONE_SELECTION = [ + ('gentle', 'Gentle'), + ('firm', 'Firm'), + ('legal', 'Legal'), +] + + +class FusionFollowupLevel(models.Model): + _name = "fusion.followup.level" + _description = "Fusion Follow-up Level" + _order = "sequence, id" + + name = fields.Char(required=True, translate=True) + sequence = fields.Integer(required=True, default=10, + help="Order in which levels escalate (1, 2, 3...).") + delay_days = fields.Integer(required=True, + help="Min days overdue to trigger this level.") + tone = fields.Selection(TONE_SELECTION, required=True, default='gentle') + description = fields.Text() + company_id = fields.Many2one('res.company', default=lambda self: self.env.company) + + mail_template_id = fields.Many2one('mail.template', + domain=[('model', '=', 'res.partner')]) + + requires_manual_review = fields.Boolean(default=False, + help="If True, follow-ups at this level need human approval before send.") + + active = fields.Boolean(default=True) + + _check_delay_positive = models.Constraint( + 'CHECK(delay_days >= 0)', + 'delay_days must be non-negative.', + ) + _unique_sequence_per_company = models.Constraint( + 'UNIQUE(company_id, sequence)', + 'Sequence must be unique per company.', + ) diff --git a/fusion_accounting_followup/security/ir.model.access.csv b/fusion_accounting_followup/security/ir.model.access.csv index 97dd8b91..ccb4588c 100644 --- a/fusion_accounting_followup/security/ir.model.access.csv +++ b/fusion_accounting_followup/security/ir.model.access.csv @@ -1 +1,3 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_fusion_followup_level_user,fusion.followup.level.user,model_fusion_followup_level,base.group_user,1,0,0,0 +access_fusion_followup_level_admin,fusion.followup.level.admin,model_fusion_followup_level,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1 diff --git a/fusion_accounting_followup/tests/__init__.py b/fusion_accounting_followup/tests/__init__.py index 68af990b..67265c7b 100644 --- a/fusion_accounting_followup/tests/__init__.py +++ b/fusion_accounting_followup/tests/__init__.py @@ -3,3 +3,4 @@ from . import test_level_resolver from . import test_risk_scorer from . import test_tone_selector from . import test_followup_text_generator +from . import test_fusion_followup_level diff --git a/fusion_accounting_followup/tests/test_fusion_followup_level.py b/fusion_accounting_followup/tests/test_fusion_followup_level.py new file mode 100644 index 00000000..1bb0bcc3 --- /dev/null +++ b/fusion_accounting_followup/tests/test_fusion_followup_level.py @@ -0,0 +1,42 @@ +from odoo.tests.common import TransactionCase +from odoo.tests import tagged + + +@tagged('post_install', '-at_install') +class TestFusionFollowupLevel(TransactionCase): + + def test_create_minimal(self): + level = self.env['fusion.followup.level'].create({ + 'name': 'Reminder', 'sequence': 1, 'delay_days': 7, 'tone': 'gentle', + }) + self.assertEqual(level.name, 'Reminder') + self.assertTrue(level.active) + + def test_negative_delay_rejected(self): + with self.assertRaises(Exception): + self.env['fusion.followup.level'].create({ + 'name': 'Bad', 'sequence': 1, 'delay_days': -5, 'tone': 'gentle', + }) + + def test_duplicate_sequence_rejected(self): + self.env['fusion.followup.level'].create({ + 'name': 'A', 'sequence': 100, 'delay_days': 7, 'tone': 'gentle', + }) + with self.assertRaises(Exception): + self.env['fusion.followup.level'].create({ + 'name': 'B', 'sequence': 100, 'delay_days': 30, 'tone': 'firm', + }) + + def test_three_levels_escalate(self): + for seq, name, days, tone in [(1, 'R', 7, 'gentle'), + (2, 'W', 30, 'firm'), + (3, 'L', 60, 'legal')]: + self.env['fusion.followup.level'].create({ + 'name': name, 'sequence': seq + 200, + 'delay_days': days, 'tone': tone, + }) + levels = self.env['fusion.followup.level'].search([ + ('sequence', '>', 200), + ], order='sequence') + self.assertEqual(len(levels), 3) + self.assertEqual(levels.mapped('tone'), ['gentle', 'firm', 'legal']) From 05de855cea81429a3bc5c0db240723ab0495391b Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 20:44:39 -0400 Subject: [PATCH 08/36] feat(fusion_accounting_followup): fusion.followup.run audit model Made-with: Cursor --- fusion_accounting_followup/__manifest__.py | 2 +- fusion_accounting_followup/models/__init__.py | 1 + .../models/fusion_followup_run.py | 54 +++++++++++++++++++ .../security/ir.model.access.csv | 2 + fusion_accounting_followup/tests/__init__.py | 1 + .../tests/test_fusion_followup_run.py | 44 +++++++++++++++ 6 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 fusion_accounting_followup/models/fusion_followup_run.py create mode 100644 fusion_accounting_followup/tests/test_fusion_followup_run.py diff --git a/fusion_accounting_followup/__manifest__.py b/fusion_accounting_followup/__manifest__.py index 316a3177..9cdb79a1 100644 --- a/fusion_accounting_followup/__manifest__.py +++ b/fusion_accounting_followup/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting Follow-up', - 'version': '19.0.1.0.6', + 'version': '19.0.1.0.7', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.', 'description': """ diff --git a/fusion_accounting_followup/models/__init__.py b/fusion_accounting_followup/models/__init__.py index c7fb1f45..d1d9e020 100644 --- a/fusion_accounting_followup/models/__init__.py +++ b/fusion_accounting_followup/models/__init__.py @@ -1 +1,2 @@ from . import fusion_followup_level +from . import fusion_followup_run diff --git a/fusion_accounting_followup/models/fusion_followup_run.py b/fusion_accounting_followup/models/fusion_followup_run.py new file mode 100644 index 00000000..327039ea --- /dev/null +++ b/fusion_accounting_followup/models/fusion_followup_run.py @@ -0,0 +1,54 @@ +"""Audit record of one follow-up execution (per partner per level).""" + +from odoo import _, api, fields, models + + +STATE_SELECTION = [ + ('draft', 'Draft'), + ('sent', 'Sent'), + ('manual_review', 'Manual Review'), + ('skipped', 'Skipped'), + ('failed', 'Failed'), +] + + +class FusionFollowupRun(models.Model): + _name = "fusion.followup.run" + _description = "Fusion Follow-up Run (Per-Partner Audit)" + _order = "execution_date desc, id desc" + + partner_id = fields.Many2one('res.partner', required=True, ondelete='cascade') + company_id = fields.Many2one('res.company', required=True, + default=lambda self: self.env.company) + level_id = fields.Many2one('fusion.followup.level', ondelete='restrict') + + execution_date = fields.Datetime(default=fields.Datetime.now, required=True) + state = fields.Selection(STATE_SELECTION, default='draft', required=True) + + overdue_amount = fields.Float() + longest_overdue_days = fields.Integer() + + risk_score = fields.Integer() + risk_band = fields.Selection([ + ('low', 'Low'), ('medium', 'Medium'), + ('high', 'High'), ('critical', 'Critical'), + ]) + + subject = fields.Char() + body = fields.Text() + tone_used = fields.Selection([ + ('gentle', 'Gentle'), ('firm', 'Firm'), ('legal', 'Legal'), + ]) + sent_to_email = fields.Char() + + text_was_ai_generated = fields.Boolean(default=False) + ai_provider = fields.Char(help="LLM provider name (openai, claude, etc.) if AI was used.") + + notes = fields.Text() + error_message = fields.Text() + + def action_mark_sent(self): + self.write({'state': 'sent'}) + + def action_mark_failed(self, error: str = ''): + self.write({'state': 'failed', 'error_message': error}) diff --git a/fusion_accounting_followup/security/ir.model.access.csv b/fusion_accounting_followup/security/ir.model.access.csv index ccb4588c..892f6622 100644 --- a/fusion_accounting_followup/security/ir.model.access.csv +++ b/fusion_accounting_followup/security/ir.model.access.csv @@ -1,3 +1,5 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_fusion_followup_level_user,fusion.followup.level.user,model_fusion_followup_level,base.group_user,1,0,0,0 access_fusion_followup_level_admin,fusion.followup.level.admin,model_fusion_followup_level,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1 +access_fusion_followup_run_user,fusion.followup.run.user,model_fusion_followup_run,base.group_user,1,0,0,0 +access_fusion_followup_run_admin,fusion.followup.run.admin,model_fusion_followup_run,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1 diff --git a/fusion_accounting_followup/tests/__init__.py b/fusion_accounting_followup/tests/__init__.py index 67265c7b..fe74fe4c 100644 --- a/fusion_accounting_followup/tests/__init__.py +++ b/fusion_accounting_followup/tests/__init__.py @@ -4,3 +4,4 @@ from . import test_risk_scorer from . import test_tone_selector from . import test_followup_text_generator from . import test_fusion_followup_level +from . import test_fusion_followup_run diff --git a/fusion_accounting_followup/tests/test_fusion_followup_run.py b/fusion_accounting_followup/tests/test_fusion_followup_run.py new file mode 100644 index 00000000..25e5c7c1 --- /dev/null +++ b/fusion_accounting_followup/tests/test_fusion_followup_run.py @@ -0,0 +1,44 @@ +from odoo.tests.common import TransactionCase +from odoo.tests import tagged + + +@tagged('post_install', '-at_install') +class TestFusionFollowupRun(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env['res.partner'].create({'name': 'Run Test Partner'}) + cls.level = cls.env['fusion.followup.level'].create({ + 'name': 'Reminder', 'sequence': 301, 'delay_days': 7, 'tone': 'gentle', + }) + + def test_create_minimal(self): + run = self.env['fusion.followup.run'].create({ + 'partner_id': self.partner.id, + 'level_id': self.level.id, + }) + self.assertEqual(run.state, 'draft') + self.assertTrue(run.execution_date) + + def test_action_mark_sent(self): + run = self.env['fusion.followup.run'].create({ + 'partner_id': self.partner.id, + 'level_id': self.level.id, + }) + run.action_mark_sent() + self.assertEqual(run.state, 'sent') + + def test_action_mark_failed_records_error(self): + run = self.env['fusion.followup.run'].create({ + 'partner_id': self.partner.id, + }) + run.action_mark_failed(error='SMTP unreachable') + self.assertEqual(run.state, 'failed') + self.assertEqual(run.error_message, 'SMTP unreachable') + + def test_partner_required(self): + with self.assertRaises(Exception): + self.env['fusion.followup.run'].create({ + 'level_id': self.level.id, + }) From 207c857e6b5a91abf1a8705fddca6ada3d8d9cf8 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 20:45:27 -0400 Subject: [PATCH 09/36] feat(fusion_accounting_followup): LLM text cache model Made-with: Cursor --- fusion_accounting_followup/__manifest__.py | 2 +- fusion_accounting_followup/models/__init__.py | 1 + .../models/fusion_followup_text_cache.py | 60 +++++++++++++++++++ .../security/ir.model.access.csv | 2 + fusion_accounting_followup/tests/__init__.py | 1 + .../tests/test_fusion_followup_text_cache.py | 60 +++++++++++++++++++ 6 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 fusion_accounting_followup/models/fusion_followup_text_cache.py create mode 100644 fusion_accounting_followup/tests/test_fusion_followup_text_cache.py diff --git a/fusion_accounting_followup/__manifest__.py b/fusion_accounting_followup/__manifest__.py index 9cdb79a1..b29628b8 100644 --- a/fusion_accounting_followup/__manifest__.py +++ b/fusion_accounting_followup/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting Follow-up', - 'version': '19.0.1.0.7', + 'version': '19.0.1.0.8', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.', 'description': """ diff --git a/fusion_accounting_followup/models/__init__.py b/fusion_accounting_followup/models/__init__.py index d1d9e020..6679c497 100644 --- a/fusion_accounting_followup/models/__init__.py +++ b/fusion_accounting_followup/models/__init__.py @@ -1,2 +1,3 @@ from . import fusion_followup_level from . import fusion_followup_run +from . import fusion_followup_text_cache diff --git a/fusion_accounting_followup/models/fusion_followup_text_cache.py b/fusion_accounting_followup/models/fusion_followup_text_cache.py new file mode 100644 index 00000000..2c0eef40 --- /dev/null +++ b/fusion_accounting_followup/models/fusion_followup_text_cache.py @@ -0,0 +1,60 @@ +"""Cache of AI-generated follow-up text to avoid LLM cost on repeats.""" + +import hashlib + +from odoo import _, api, fields, models + + +class FusionFollowupTextCache(models.Model): + _name = "fusion.followup.text.cache" + _description = "Cache of AI-generated follow-up text" + _order = "generated_at desc" + + partner_id = fields.Many2one('res.partner', required=True, ondelete='cascade') + level_id = fields.Many2one('fusion.followup.level', ondelete='cascade') + company_id = fields.Many2one('res.company', required=True, + default=lambda self: self.env.company) + + fingerprint = fields.Char(required=True, index=True, + help="SHA-256 of input parameters") + + subject = fields.Char() + body = fields.Text() + tone_used = fields.Selection([ + ('gentle', 'Gentle'), ('firm', 'Firm'), ('legal', 'Legal'), + ]) + key_points = fields.Json() + + generated_at = fields.Datetime(default=fields.Datetime.now, required=True) + expires_at = fields.Datetime() + use_count = fields.Integer(default=0) + provider = fields.Char() + + @api.model + def compute_fingerprint(self, *, partner_id: int, level_id: int, + overdue_amount: float, longest_overdue_days: int, + invoice_count: int, tone: str) -> str: + """Stable hash of the inputs that determine the generated text.""" + s = f"{partner_id}|{level_id}|{round(overdue_amount, 2)}|" \ + f"{longest_overdue_days}|{invoice_count}|{tone}" + return hashlib.sha256(s.encode('utf-8')).hexdigest() + + @api.model + def lookup(self, *, partner_id: int, level_id: int, + overdue_amount: float, longest_overdue_days: int, + invoice_count: int, tone: str): + """Find a cached entry matching these inputs, or empty recordset.""" + fp = self.compute_fingerprint( + partner_id=partner_id, level_id=level_id, + overdue_amount=overdue_amount, + longest_overdue_days=longest_overdue_days, + invoice_count=invoice_count, tone=tone, + ) + return self.search([ + ('partner_id', '=', partner_id), + ('fingerprint', '=', fp), + ], limit=1) + + def action_increment_use(self): + for rec in self: + rec.use_count += 1 diff --git a/fusion_accounting_followup/security/ir.model.access.csv b/fusion_accounting_followup/security/ir.model.access.csv index 892f6622..04690ebc 100644 --- a/fusion_accounting_followup/security/ir.model.access.csv +++ b/fusion_accounting_followup/security/ir.model.access.csv @@ -3,3 +3,5 @@ access_fusion_followup_level_user,fusion.followup.level.user,model_fusion_follow access_fusion_followup_level_admin,fusion.followup.level.admin,model_fusion_followup_level,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1 access_fusion_followup_run_user,fusion.followup.run.user,model_fusion_followup_run,base.group_user,1,0,0,0 access_fusion_followup_run_admin,fusion.followup.run.admin,model_fusion_followup_run,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1 +access_fusion_followup_text_cache_user,fusion.followup.text.cache.user,model_fusion_followup_text_cache,base.group_user,1,0,0,0 +access_fusion_followup_text_cache_admin,fusion.followup.text.cache.admin,model_fusion_followup_text_cache,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1 diff --git a/fusion_accounting_followup/tests/__init__.py b/fusion_accounting_followup/tests/__init__.py index fe74fe4c..595ca547 100644 --- a/fusion_accounting_followup/tests/__init__.py +++ b/fusion_accounting_followup/tests/__init__.py @@ -5,3 +5,4 @@ from . import test_tone_selector from . import test_followup_text_generator from . import test_fusion_followup_level from . import test_fusion_followup_run +from . import test_fusion_followup_text_cache diff --git a/fusion_accounting_followup/tests/test_fusion_followup_text_cache.py b/fusion_accounting_followup/tests/test_fusion_followup_text_cache.py new file mode 100644 index 00000000..a7e1b6fc --- /dev/null +++ b/fusion_accounting_followup/tests/test_fusion_followup_text_cache.py @@ -0,0 +1,60 @@ +from odoo.tests.common import TransactionCase +from odoo.tests import tagged + + +@tagged('post_install', '-at_install') +class TestFusionFollowupTextCache(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env['res.partner'].create({'name': 'Cache Test Partner'}) + cls.level = cls.env['fusion.followup.level'].create({ + 'name': 'Reminder', 'sequence': 401, 'delay_days': 7, 'tone': 'gentle', + }) + cls.cache = cls.env['fusion.followup.text.cache'] + + def _kwargs(self, **overrides): + base = dict( + partner_id=self.partner.id, level_id=self.level.id, + overdue_amount=1234.56, longest_overdue_days=10, + invoice_count=3, tone='gentle', + ) + base.update(overrides) + return base + + def test_fingerprint_stable_and_unique(self): + fp1 = self.cache.compute_fingerprint(**self._kwargs()) + fp2 = self.cache.compute_fingerprint(**self._kwargs()) + fp3 = self.cache.compute_fingerprint(**self._kwargs(tone='firm')) + self.assertEqual(fp1, fp2) + self.assertNotEqual(fp1, fp3) + self.assertEqual(len(fp1), 64) + + def test_lookup_returns_empty_when_missing(self): + result = self.cache.lookup(**self._kwargs()) + self.assertFalse(result) + + def test_lookup_finds_cached_entry(self): + kwargs = self._kwargs() + fp = self.cache.compute_fingerprint(**kwargs) + entry = self.cache.create({ + 'partner_id': self.partner.id, + 'level_id': self.level.id, + 'fingerprint': fp, + 'subject': 'Hi', + 'body': 'Please pay.', + 'tone_used': 'gentle', + }) + found = self.cache.lookup(**kwargs) + self.assertEqual(found.id, entry.id) + + def test_action_increment_use(self): + entry = self.cache.create({ + 'partner_id': self.partner.id, + 'fingerprint': 'abc123', + }) + self.assertEqual(entry.use_count, 0) + entry.action_increment_use() + entry.action_increment_use() + self.assertEqual(entry.use_count, 2) From 2ddc600d65face23bb72914509bae8088a5cb433 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 20:46:08 -0400 Subject: [PATCH 10/36] feat(fusion_accounting_followup): inherit res.partner with follow-up state Made-with: Cursor --- fusion_accounting_followup/__manifest__.py | 2 +- fusion_accounting_followup/models/__init__.py | 1 + .../models/res_partner.py | 52 +++++++++++++++++++ fusion_accounting_followup/tests/__init__.py | 1 + .../tests/test_res_partner_inherit.py | 27 ++++++++++ 5 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 fusion_accounting_followup/models/res_partner.py create mode 100644 fusion_accounting_followup/tests/test_res_partner_inherit.py diff --git a/fusion_accounting_followup/__manifest__.py b/fusion_accounting_followup/__manifest__.py index b29628b8..0ffeb741 100644 --- a/fusion_accounting_followup/__manifest__.py +++ b/fusion_accounting_followup/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting Follow-up', - 'version': '19.0.1.0.8', + 'version': '19.0.1.0.9', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.', 'description': """ diff --git a/fusion_accounting_followup/models/__init__.py b/fusion_accounting_followup/models/__init__.py index 6679c497..76c956a5 100644 --- a/fusion_accounting_followup/models/__init__.py +++ b/fusion_accounting_followup/models/__init__.py @@ -1,3 +1,4 @@ from . import fusion_followup_level from . import fusion_followup_run from . import fusion_followup_text_cache +from . import res_partner diff --git a/fusion_accounting_followup/models/res_partner.py b/fusion_accounting_followup/models/res_partner.py new file mode 100644 index 00000000..2aaa5936 --- /dev/null +++ b/fusion_accounting_followup/models/res_partner.py @@ -0,0 +1,52 @@ +"""Inherit res.partner: add follow-up state fields.""" + +from odoo import _, api, fields, models + + +FOLLOWUP_STATUS = [ + ('no_action', 'No Action Needed'), + ('action_due', 'Action Due'), + ('paused', 'Paused'), + ('blocked', 'Blocked'), + ('with_credit_team', 'With Credit Team'), +] + + +class ResPartner(models.Model): + _inherit = "res.partner" + + fusion_followup_status = fields.Selection( + FOLLOWUP_STATUS, default='no_action', tracking=True, + help="Current follow-up status as computed by the engine.") + fusion_followup_paused_until = fields.Date( + tracking=True, + help="Pause follow-ups for this partner until this date.") + fusion_followup_last_level_id = fields.Many2one( + 'fusion.followup.level', + help="The most-recent follow-up level this partner has been contacted at.") + fusion_followup_last_run_date = fields.Datetime(readonly=True) + fusion_followup_run_ids = fields.One2many( + 'fusion.followup.run', 'partner_id', string='Follow-up History') + fusion_followup_run_count = fields.Integer( + compute='_compute_fusion_followup_run_count') + fusion_followup_risk_score = fields.Integer( + readonly=True, default=0, + help="Latest computed payment risk (0-100). Updated by cron.") + fusion_followup_risk_band = fields.Selection([ + ('low', 'Low'), ('medium', 'Medium'), + ('high', 'High'), ('critical', 'Critical'), + ], default='low', readonly=True) + + def _compute_fusion_followup_run_count(self): + for partner in self: + partner.fusion_followup_run_count = len(partner.fusion_followup_run_ids) + + def action_view_followup_history(self): + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'res_model': 'fusion.followup.run', + 'view_mode': 'list,form', + 'domain': [('partner_id', '=', self.id)], + 'context': {'default_partner_id': self.id}, + } diff --git a/fusion_accounting_followup/tests/__init__.py b/fusion_accounting_followup/tests/__init__.py index 595ca547..803fa97a 100644 --- a/fusion_accounting_followup/tests/__init__.py +++ b/fusion_accounting_followup/tests/__init__.py @@ -6,3 +6,4 @@ from . import test_followup_text_generator from . import test_fusion_followup_level from . import test_fusion_followup_run from . import test_fusion_followup_text_cache +from . import test_res_partner_inherit diff --git a/fusion_accounting_followup/tests/test_res_partner_inherit.py b/fusion_accounting_followup/tests/test_res_partner_inherit.py new file mode 100644 index 00000000..fa77651e --- /dev/null +++ b/fusion_accounting_followup/tests/test_res_partner_inherit.py @@ -0,0 +1,27 @@ +from odoo.tests.common import TransactionCase +from odoo.tests import tagged + + +@tagged('post_install', '-at_install') +class TestResPartnerFollowup(TransactionCase): + + def test_default_status_no_action(self): + partner = self.env['res.partner'].create({'name': 'Default Status'}) + self.assertEqual(partner.fusion_followup_status, 'no_action') + self.assertEqual(partner.fusion_followup_risk_band, 'low') + self.assertEqual(partner.fusion_followup_risk_score, 0) + + def test_run_count_reflects_history(self): + partner = self.env['res.partner'].create({'name': 'History Partner'}) + self.assertEqual(partner.fusion_followup_run_count, 0) + for _ in range(3): + self.env['fusion.followup.run'].create({'partner_id': partner.id}) + partner.invalidate_recordset(['fusion_followup_run_count', 'fusion_followup_run_ids']) + self.assertEqual(partner.fusion_followup_run_count, 3) + + def test_action_view_followup_history_returns_action(self): + partner = self.env['res.partner'].create({'name': 'Action Partner'}) + action = partner.action_view_followup_history() + self.assertEqual(action['res_model'], 'fusion.followup.run') + self.assertEqual(action['domain'], [('partner_id', '=', partner.id)]) + self.assertEqual(action['context']['default_partner_id'], partner.id) From 06dafc31c16f3334fe63487746847b018fa62441 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 20:47:37 -0400 Subject: [PATCH 11/36] feat(fusion_accounting_followup): inherit account.move.line for level tracking Made-with: Cursor --- fusion_accounting_followup/__manifest__.py | 2 +- fusion_accounting_followup/models/__init__.py | 1 + .../models/account_move_line.py | 14 ++++++++ fusion_accounting_followup/tests/__init__.py | 1 + .../tests/test_account_move_line_inherit.py | 34 +++++++++++++++++++ 5 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 fusion_accounting_followup/models/account_move_line.py create mode 100644 fusion_accounting_followup/tests/test_account_move_line_inherit.py diff --git a/fusion_accounting_followup/__manifest__.py b/fusion_accounting_followup/__manifest__.py index 0ffeb741..16f9c8e7 100644 --- a/fusion_accounting_followup/__manifest__.py +++ b/fusion_accounting_followup/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting Follow-up', - 'version': '19.0.1.0.9', + 'version': '19.0.1.0.10', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.', 'description': """ diff --git a/fusion_accounting_followup/models/__init__.py b/fusion_accounting_followup/models/__init__.py index 76c956a5..4a181971 100644 --- a/fusion_accounting_followup/models/__init__.py +++ b/fusion_accounting_followup/models/__init__.py @@ -2,3 +2,4 @@ from . import fusion_followup_level from . import fusion_followup_run from . import fusion_followup_text_cache from . import res_partner +from . import account_move_line diff --git a/fusion_accounting_followup/models/account_move_line.py b/fusion_accounting_followup/models/account_move_line.py new file mode 100644 index 00000000..369ce57a --- /dev/null +++ b/fusion_accounting_followup/models/account_move_line.py @@ -0,0 +1,14 @@ +"""Inherit account.move.line: track last follow-up level.""" + +from odoo import _, api, fields, models + + +class AccountMoveLine(models.Model): + _inherit = "account.move.line" + + fusion_followup_level_id = fields.Many2one( + 'fusion.followup.level', copy=False, + help="Last follow-up level at which this line was contacted.") + fusion_followup_last_run_date = fields.Datetime( + copy=False, + help="When the line was most-recently included in a follow-up.") diff --git a/fusion_accounting_followup/tests/__init__.py b/fusion_accounting_followup/tests/__init__.py index 803fa97a..4222b80d 100644 --- a/fusion_accounting_followup/tests/__init__.py +++ b/fusion_accounting_followup/tests/__init__.py @@ -7,3 +7,4 @@ from . import test_fusion_followup_level from . import test_fusion_followup_run from . import test_fusion_followup_text_cache from . import test_res_partner_inherit +from . import test_account_move_line_inherit diff --git a/fusion_accounting_followup/tests/test_account_move_line_inherit.py b/fusion_accounting_followup/tests/test_account_move_line_inherit.py new file mode 100644 index 00000000..9860dd27 --- /dev/null +++ b/fusion_accounting_followup/tests/test_account_move_line_inherit.py @@ -0,0 +1,34 @@ +from odoo import fields as odoo_fields +from odoo.tests.common import TransactionCase +from odoo.tests import tagged + + +@tagged('post_install', '-at_install') +class TestAccountMoveLineFollowup(TransactionCase): + """Verify follow-up tracking fields are added to account.move.line.""" + + def test_fields_exist_on_model(self): + """Both new fields are declared on account.move.line.""" + AML = self.env['account.move.line'] + self.assertIn('fusion_followup_level_id', AML._fields) + self.assertIn('fusion_followup_last_run_date', AML._fields) + self.assertEqual( + AML._fields['fusion_followup_level_id'].comodel_name, + 'fusion.followup.level', + ) + + def test_assign_level_and_date_on_existing_line(self): + """We can write the new fields onto an existing move line.""" + line = self.env['account.move.line'].search([], limit=1) + if not line: + self.skipTest("No account.move.line records present in DB to test against.") + level = self.env['fusion.followup.level'].create({ + 'name': 'Reminder', 'sequence': 601, 'delay_days': 7, 'tone': 'gentle', + }) + when = odoo_fields.Datetime.now() + line.write({ + 'fusion_followup_level_id': level.id, + 'fusion_followup_last_run_date': when, + }) + self.assertEqual(line.fusion_followup_level_id, level) + self.assertEqual(line.fusion_followup_last_run_date, when) From 6802d60e4485a2b180c590ed07b0d9f5e2047f62 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 20:52:27 -0400 Subject: [PATCH 12/36] feat(fusion_accounting_followup): fusion.followup.engine 7-method API The orchestrator AbstractModel for follow-up lifecycle. get_overdue_for_partner, compute_followup_level, send_followup_email, escalate_to_next_level, pause_followup, reset_followup, snapshot_followup_history. All controllers, AI tools, wizards, cron must route through these methods; no direct ORM writes to fusion.followup.run from anywhere else. Made-with: Cursor --- fusion_accounting_followup/__manifest__.py | 2 +- fusion_accounting_followup/models/__init__.py | 1 + .../models/fusion_followup_engine.py | 379 ++++++++++++++++++ fusion_accounting_followup/tests/__init__.py | 1 + .../tests/test_fusion_followup_engine.py | 74 ++++ 5 files changed, 456 insertions(+), 1 deletion(-) create mode 100644 fusion_accounting_followup/models/fusion_followup_engine.py create mode 100644 fusion_accounting_followup/tests/test_fusion_followup_engine.py diff --git a/fusion_accounting_followup/__manifest__.py b/fusion_accounting_followup/__manifest__.py index 16f9c8e7..bb201b0a 100644 --- a/fusion_accounting_followup/__manifest__.py +++ b/fusion_accounting_followup/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting Follow-up', - 'version': '19.0.1.0.10', + 'version': '19.0.1.0.13', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.', 'description': """ diff --git a/fusion_accounting_followup/models/__init__.py b/fusion_accounting_followup/models/__init__.py index 4a181971..ec9d216e 100644 --- a/fusion_accounting_followup/models/__init__.py +++ b/fusion_accounting_followup/models/__init__.py @@ -3,3 +3,4 @@ from . import fusion_followup_run from . import fusion_followup_text_cache from . import res_partner from . import account_move_line +from . import fusion_followup_engine diff --git a/fusion_accounting_followup/models/fusion_followup_engine.py b/fusion_accounting_followup/models/fusion_followup_engine.py new file mode 100644 index 00000000..21523427 --- /dev/null +++ b/fusion_accounting_followup/models/fusion_followup_engine.py @@ -0,0 +1,379 @@ +"""The follow-up engine — orchestrator for customer follow-ups. + +7-method public API. All controllers, AI tools, wizards, cron must +go through this engine; no direct ORM writes to fusion.followup.run +from elsewhere.""" + +import logging +from datetime import date, timedelta + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError, UserError + +from ..services.overdue_aging import compute_aging +from ..services.level_resolver import resolve_level, FollowupLevelSpec +from ..services.risk_scorer import score_partner +from ..services.tone_selector import select_tone +from ..services.followup_text_generator import generate_followup_text + +_logger = logging.getLogger(__name__) + + +class FusionFollowupEngine(models.AbstractModel): + _name = "fusion.followup.engine" + _description = "Fusion Follow-up Engine" + + # ============================================================ + # PUBLIC API (7 methods) + # ============================================================ + + @api.model + def get_overdue_for_partner(self, partner) -> dict: + """Return aging report + risk score for a partner.""" + partner.ensure_one() + as_of = fields.Date.today() + move_lines = self._fetch_overdue_lines(partner) + aging = compute_aging( + move_lines=[{ + 'date_maturity': l.date_maturity, + 'amount_residual': l.amount_residual, + } for l in move_lines], + as_of=as_of, + ) + risk = self._compute_risk(partner, move_lines) + return { + 'partner_id': partner.id, + 'as_of': str(as_of), + 'aging': aging.to_dict(), + 'risk': { + 'score': risk.score, + 'band': risk.band, + 'drivers': risk.drivers, + }, + 'overdue_line_count': len(move_lines), + } + + @api.model + def compute_followup_level(self, partner): + """Return the fusion.followup.level recordset that should fire now, + or empty recordset if no action needed.""" + partner.ensure_one() + Level = self.env['fusion.followup.level'] + if partner.fusion_followup_paused_until and \ + partner.fusion_followup_paused_until > fields.Date.today(): + return Level + + as_of = fields.Date.today() + move_lines = self._fetch_overdue_lines(partner) + if not move_lines: + return Level + aging = compute_aging( + move_lines=[{ + 'date_maturity': l.date_maturity, + 'amount_residual': l.amount_residual, + } for l in move_lines], + as_of=as_of, + ) + + company_id = partner.company_id.id if partner.company_id else self.env.company.id + levels = Level.search([ + ('active', '=', True), + '|', ('company_id', '=', company_id), ('company_id', '=', False), + ], order='sequence') + if not levels: + return Level + + specs = [FollowupLevelSpec( + sequence=l.sequence, name=l.name, + delay_days=l.delay_days, tone=l.tone, + ) for l in levels] + + chosen_spec = resolve_level(aging_report=aging, levels=specs) + if chosen_spec is None: + return Level + + return levels.filtered(lambda l: l.sequence == chosen_spec.sequence)[:1] + + @api.model + def send_followup_email(self, partner, *, level=None, force=False) -> dict: + """Send a follow-up email at the given level (or auto-resolve if None). + + Creates a fusion.followup.run record. Uses cached text if available.""" + partner.ensure_one() + + if not level: + level = self.compute_followup_level(partner) + if not level: + return {'status': 'no_action', 'partner_id': partner.id} + + if not force and partner.fusion_followup_paused_until and \ + partner.fusion_followup_paused_until > fields.Date.today(): + return { + 'status': 'paused_until_' + str(partner.fusion_followup_paused_until), + 'partner_id': partner.id, + } + + if level.requires_manual_review and not force: + run = self._create_run(partner, level, state='manual_review') + return { + 'status': 'manual_review', + 'partner_id': partner.id, + 'run_id': run.id, + } + + overdue_data = self.get_overdue_for_partner(partner) + if overdue_data['overdue_line_count'] == 0: + return {'status': 'no_overdue', 'partner_id': partner.id} + + tone = select_tone( + level_sequence=level.sequence, + risk_score=overdue_data['risk']['score'], + ) + + text_data = self._get_or_generate_text( + partner=partner, level=level, + overdue_amount=overdue_data['aging']['total_overdue_amount'], + longest_overdue_days=self._max_overdue_days_from_aging(overdue_data['aging']), + invoice_count=overdue_data['overdue_line_count'], + tone=tone, risk_drivers=overdue_data['risk']['drivers'], + ) + + run = self._create_run( + partner, level, state='draft', + overdue_amount=overdue_data['aging']['total_overdue_amount'], + longest_overdue_days=self._max_overdue_days_from_aging(overdue_data['aging']), + risk_score=overdue_data['risk']['score'], + risk_band=overdue_data['risk']['band'], + subject=text_data['subject'], + body=text_data['body'], + tone_used=text_data['tone_used'], + text_was_ai_generated=text_data.get('_was_ai', False), + ) + + try: + self._send_email(partner, run) + run.write({'state': 'sent'}) + partner.write({ + 'fusion_followup_status': 'no_action', + 'fusion_followup_last_level_id': level.id, + 'fusion_followup_last_run_date': fields.Datetime.now(), + }) + except Exception as e: + _logger.warning("Email send failed for partner %s: %s", partner.id, e) + run.write({'state': 'failed', 'error_message': str(e)}) + + return { + 'status': 'sent', 'partner_id': partner.id, + 'run_id': run.id, 'level_id': level.id, 'tone': tone, + } + + @api.model + def escalate_to_next_level(self, partner) -> dict: + """Force the next-higher level than the partner's current last_level.""" + partner.ensure_one() + Level = self.env['fusion.followup.level'] + current = partner.fusion_followup_last_level_id + next_seq = (current.sequence + 1) if current else 1 + company_id = partner.company_id.id if partner.company_id else self.env.company.id + next_level = Level.search([ + ('active', '=', True), + ('sequence', '>=', next_seq), + '|', ('company_id', '=', company_id), ('company_id', '=', False), + ], order='sequence', limit=1) + if not next_level: + return {'status': 'at_max_level', 'partner_id': partner.id} + return self.send_followup_email(partner, level=next_level, force=True) + + @api.model + def pause_followup(self, partner, until_date: date = None) -> dict: + """Pause follow-ups for a partner until a date (default 30 days).""" + partner.ensure_one() + until = until_date or (fields.Date.today() + timedelta(days=30)) + partner.write({ + 'fusion_followup_paused_until': until, + 'fusion_followup_status': 'paused', + }) + return {'partner_id': partner.id, 'paused_until': str(until)} + + @api.model + def reset_followup(self, partner) -> dict: + """Reset partner's follow-up state to no_action.""" + partner.ensure_one() + partner.write({ + 'fusion_followup_status': 'no_action', + 'fusion_followup_paused_until': False, + 'fusion_followup_last_level_id': False, + }) + return {'partner_id': partner.id, 'status': 'reset'} + + @api.model + def snapshot_followup_history(self, partner, *, limit: int = 50) -> dict: + """Return audit history for a partner.""" + partner.ensure_one() + Run = self.env['fusion.followup.run'] + runs = Run.search([ + ('partner_id', '=', partner.id), + ], order='execution_date desc', limit=int(limit)) + return { + 'partner_id': partner.id, + 'count': len(runs), + 'runs': [{ + 'id': r.id, 'date': str(r.execution_date), + 'level_id': r.level_id.id if r.level_id else None, + 'level_name': r.level_id.name if r.level_id else '', + 'state': r.state, + 'overdue_amount': r.overdue_amount, + 'longest_overdue_days': r.longest_overdue_days, + 'tone_used': r.tone_used, + 'risk_score': r.risk_score, + 'subject': r.subject or '', + 'text_was_ai_generated': r.text_was_ai_generated, + } for r in runs], + } + + # ============================================================ + # PRIVATE HELPERS + # ============================================================ + + def _fetch_overdue_lines(self, partner): + """Fetch posted, unreconciled receivable lines for a partner.""" + Line = self.env['account.move.line'].sudo() + return Line.search([ + ('partner_id', '=', partner.id), + ('parent_state', '=', 'posted'), + ('account_id.account_type', '=', 'asset_receivable'), + ('reconciled', '=', False), + ('amount_residual', '>', 0), + ]) + + def _compute_risk(self, partner, overdue_lines): + """Compute risk score from partner's payment history.""" + Line = self.env['account.move.line'].sudo() + all_lines = Line.search([ + ('partner_id', '=', partner.id), + ('parent_state', '=', 'posted'), + ('account_id.account_type', '=', 'asset_receivable'), + ]) + total_invoices = len(all_lines) + # Heavy paid-late computation deferred to Phase 4.5 + paid_late_count = 0 + avg_days_late = 0.0 + + as_of = fields.Date.today() + longest_overdue_days = 0 + for line in overdue_lines: + if line.date_maturity: + days = (as_of - line.date_maturity).days + if days > longest_overdue_days: + longest_overdue_days = days + + open_overdue = sum(line.amount_residual for line in overdue_lines) + avg_invoice_amount = 1000.0 + if total_invoices > 0: + total_amount = sum(all_lines.mapped('balance')) + if total_amount: + avg_invoice_amount = abs(total_amount) / total_invoices + + return score_partner( + total_invoices=total_invoices, + paid_late_count=paid_late_count, + avg_days_late=avg_days_late, + longest_overdue_days=longest_overdue_days, + open_overdue_amount=open_overdue, + average_invoice_amount=avg_invoice_amount, + ) + + def _max_overdue_days_from_aging(self, aging_dict): + """Extract longest overdue days from aging dict.""" + tracked = aging_dict.get('max_days_overdue', 0) or 0 + if tracked: + return tracked + max_days = 0 + for b in aging_dict.get('buckets', []): + if b['name'] == 'current' or b['amount'] <= 0: + continue + if b['days_max'] is None: + max_days = max(max_days, b['days_min']) + else: + max_days = max(max_days, b['days_max']) + return max_days + + def _get_or_generate_text(self, *, partner, level, overdue_amount, + longest_overdue_days, invoice_count, tone, + risk_drivers=None) -> dict: + """Cache lookup + LLM fallback.""" + Cache = self.env['fusion.followup.text.cache'] + cached = Cache.lookup( + partner_id=partner.id, level_id=level.id, + overdue_amount=overdue_amount, + longest_overdue_days=longest_overdue_days, + invoice_count=invoice_count, tone=tone, + ) + if cached: + cached.action_increment_use() + return { + 'subject': cached.subject, 'body': cached.body, + 'tone_used': cached.tone_used, + 'key_points': cached.key_points or [], + '_was_ai': bool(cached.provider), + } + + company = partner.company_id or self.env.company + currency = company.currency_id + text = generate_followup_text( + self.env, + partner_name=partner.name, + total_overdue=overdue_amount, + currency_code=currency.name or 'USD', + longest_overdue_days=longest_overdue_days, + tone=tone, invoice_count=invoice_count, + risk_drivers=risk_drivers, + ) + try: + Cache.sudo().create({ + 'partner_id': partner.id, 'level_id': level.id, + 'company_id': company.id, + 'fingerprint': Cache.compute_fingerprint( + partner_id=partner.id, level_id=level.id, + overdue_amount=overdue_amount, + longest_overdue_days=longest_overdue_days, + invoice_count=invoice_count, tone=tone, + ), + 'subject': text['subject'], 'body': text['body'], + 'tone_used': text.get('tone_used', tone), + 'key_points': text.get('key_points', []), + }) + except Exception as e: + _logger.debug("Cache create failed (non-fatal): %s", e) + + text['_was_ai'] = False + return text + + def _create_run(self, partner, level, *, state='draft', **vals): + Run = self.env['fusion.followup.run'].sudo() + company = partner.company_id or self.env.company + defaults = { + 'partner_id': partner.id, + 'company_id': company.id, + 'level_id': level.id if level else False, + 'state': state, + } + defaults.update(vals) + return Run.create(defaults) + + def _send_email(self, partner, run): + """Best-effort email send. Uses level's mail_template if set, else + creates a simple message.""" + if not partner.email: + raise UserError(_("Partner %s has no email address.") % partner.name) + if run.level_id and run.level_id.mail_template_id: + run.level_id.mail_template_id.send_mail(partner.id, force_send=True) + else: + body_text = (run.body or '').replace('<', '<').replace('>', '>') + self.env['mail.mail'].sudo().create({ + 'subject': run.subject or 'Follow-up', + 'body_html': '

{}
'.format(body_text), + 'email_to': partner.email, + 'recipient_ids': [(4, partner.id)], + }).send() + run.write({'sent_to_email': partner.email}) diff --git a/fusion_accounting_followup/tests/__init__.py b/fusion_accounting_followup/tests/__init__.py index 4222b80d..49561b1d 100644 --- a/fusion_accounting_followup/tests/__init__.py +++ b/fusion_accounting_followup/tests/__init__.py @@ -8,3 +8,4 @@ from . import test_fusion_followup_run from . import test_fusion_followup_text_cache from . import test_res_partner_inherit from . import test_account_move_line_inherit +from . import test_fusion_followup_engine diff --git a/fusion_accounting_followup/tests/test_fusion_followup_engine.py b/fusion_accounting_followup/tests/test_fusion_followup_engine.py new file mode 100644 index 00000000..41ef9a6d --- /dev/null +++ b/fusion_accounting_followup/tests/test_fusion_followup_engine.py @@ -0,0 +1,74 @@ +"""Unit tests for the fusion.followup.engine 7-method API.""" + +from datetime import date, timedelta +from odoo.tests.common import TransactionCase +from odoo.tests import tagged + + +@tagged('post_install', '-at_install') +class TestFusionFollowupEngine(TransactionCase): + + def setUp(self): + super().setUp() + self.engine = self.env['fusion.followup.engine'] + self.partner = self.env['res.partner'].create({ + 'name': 'Engine Test Partner', 'email': 'engine@test.local', + }) + for seq, name, days, tone in [(901, 'Reminder', 7, 'gentle'), + (902, 'Warning', 30, 'firm'), + (903, 'Legal', 60, 'legal')]: + self.env['fusion.followup.level'].create({ + 'name': name, 'sequence': seq, + 'delay_days': days, 'tone': tone, + }) + + def test_engine_model_exists(self): + self.assertIn('fusion.followup.engine', self.env.registry) + + def test_get_overdue_returns_dict(self): + result = self.engine.get_overdue_for_partner(self.partner) + self.assertIn('aging', result) + self.assertIn('risk', result) + self.assertEqual(result['partner_id'], self.partner.id) + + def test_compute_followup_level_no_overdue_returns_empty(self): + result = self.engine.compute_followup_level(self.partner) + self.assertFalse(result) + + def test_pause_sets_partner_state(self): + until = date.today() + timedelta(days=14) + self.engine.pause_followup(self.partner, until_date=until) + self.partner.invalidate_recordset(['fusion_followup_paused_until', 'fusion_followup_status']) + self.assertEqual(self.partner.fusion_followup_paused_until, until) + self.assertEqual(self.partner.fusion_followup_status, 'paused') + + def test_reset_clears_state(self): + self.engine.pause_followup(self.partner) + self.engine.reset_followup(self.partner) + self.partner.invalidate_recordset([ + 'fusion_followup_status', 'fusion_followup_paused_until', + 'fusion_followup_last_level_id', + ]) + self.assertEqual(self.partner.fusion_followup_status, 'no_action') + self.assertFalse(self.partner.fusion_followup_paused_until) + + def test_snapshot_history_returns_runs(self): + Run = self.env['fusion.followup.run'] + run = Run.create({ + 'partner_id': self.partner.id, + 'state': 'sent', + 'overdue_amount': 500, + }) + result = self.engine.snapshot_followup_history(self.partner) + self.assertEqual(result['count'], 1) + self.assertEqual(result['runs'][0]['id'], run.id) + + def test_send_no_overdue_returns_no_action(self): + Level = self.env['fusion.followup.level'] + level = Level.search([('sequence', '=', 901)], limit=1) + result = self.engine.send_followup_email(self.partner, level=level, force=True) + self.assertEqual(result['status'], 'no_overdue') + + def test_escalate_when_no_current_level(self): + result = self.engine.escalate_to_next_level(self.partner) + self.assertIn('partner_id', result) From 9b6d6b3895c2036f2a3358347c90dffcfe6aed64 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 20:54:13 -0400 Subject: [PATCH 13/36] test(fusion_accounting_followup): engine integration tests for full lifecycle End-to-end flows over a real posted receivable line: aging discovery, level resolution, send-with-cache reuse, pause+force override, and audit history growth. Adds ignore_pause kwarg to compute_followup_level so force=True in send_followup_email reaches level resolution. Made-with: Cursor --- fusion_accounting_followup/__manifest__.py | 2 +- .../models/fusion_followup_engine.py | 14 ++-- fusion_accounting_followup/tests/__init__.py | 1 + .../tests/test_engine_integration.py | 76 +++++++++++++++++++ 4 files changed, 85 insertions(+), 8 deletions(-) create mode 100644 fusion_accounting_followup/tests/test_engine_integration.py diff --git a/fusion_accounting_followup/__manifest__.py b/fusion_accounting_followup/__manifest__.py index bb201b0a..f1c3473c 100644 --- a/fusion_accounting_followup/__manifest__.py +++ b/fusion_accounting_followup/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting Follow-up', - 'version': '19.0.1.0.13', + 'version': '19.0.1.0.14', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.', 'description': """ diff --git a/fusion_accounting_followup/models/fusion_followup_engine.py b/fusion_accounting_followup/models/fusion_followup_engine.py index 21523427..a674e3a3 100644 --- a/fusion_accounting_followup/models/fusion_followup_engine.py +++ b/fusion_accounting_followup/models/fusion_followup_engine.py @@ -54,12 +54,12 @@ class FusionFollowupEngine(models.AbstractModel): } @api.model - def compute_followup_level(self, partner): + def compute_followup_level(self, partner, *, ignore_pause=False): """Return the fusion.followup.level recordset that should fire now, or empty recordset if no action needed.""" partner.ensure_one() Level = self.env['fusion.followup.level'] - if partner.fusion_followup_paused_until and \ + if not ignore_pause and partner.fusion_followup_paused_until and \ partner.fusion_followup_paused_until > fields.Date.today(): return Level @@ -101,11 +101,6 @@ class FusionFollowupEngine(models.AbstractModel): Creates a fusion.followup.run record. Uses cached text if available.""" partner.ensure_one() - if not level: - level = self.compute_followup_level(partner) - if not level: - return {'status': 'no_action', 'partner_id': partner.id} - if not force and partner.fusion_followup_paused_until and \ partner.fusion_followup_paused_until > fields.Date.today(): return { @@ -113,6 +108,11 @@ class FusionFollowupEngine(models.AbstractModel): 'partner_id': partner.id, } + if not level: + level = self.compute_followup_level(partner, ignore_pause=force) + if not level: + return {'status': 'no_action', 'partner_id': partner.id} + if level.requires_manual_review and not force: run = self._create_run(partner, level, state='manual_review') return { diff --git a/fusion_accounting_followup/tests/__init__.py b/fusion_accounting_followup/tests/__init__.py index 49561b1d..2ef1dce5 100644 --- a/fusion_accounting_followup/tests/__init__.py +++ b/fusion_accounting_followup/tests/__init__.py @@ -9,3 +9,4 @@ from . import test_fusion_followup_text_cache from . import test_res_partner_inherit from . import test_account_move_line_inherit from . import test_fusion_followup_engine +from . import test_engine_integration diff --git a/fusion_accounting_followup/tests/test_engine_integration.py b/fusion_accounting_followup/tests/test_engine_integration.py new file mode 100644 index 00000000..37bf69b5 --- /dev/null +++ b/fusion_accounting_followup/tests/test_engine_integration.py @@ -0,0 +1,76 @@ +"""Integration tests: full follow-up flow with real overdue invoices.""" + +from datetime import date, timedelta +from odoo.tests.common import TransactionCase +from odoo.tests import tagged + + +@tagged('post_install', '-at_install', 'integration') +class TestFollowupEngineIntegration(TransactionCase): + + def setUp(self): + super().setUp() + self.engine = self.env['fusion.followup.engine'] + self.partner = self.env['res.partner'].create({ + 'name': 'Integration Partner', 'email': 'integ@test.local', + }) + for seq, name, days, tone in [(801, 'Test Reminder', 7, 'gentle'), + (802, 'Test Warning', 30, 'firm'), + (803, 'Test Legal', 60, 'legal')]: + self.env['fusion.followup.level'].create({ + 'name': name, 'sequence': seq, 'delay_days': days, 'tone': tone, + }) + + line = self.env['account.move.line'].search([ + ('parent_state', '=', 'posted'), + ('account_id.account_type', '=', 'asset_receivable'), + ('reconciled', '=', False), + ('amount_residual', '>', 0), + ], limit=1) + if not line: + self.skipTest("No posted unreconciled receivable lines in test DB") + line.write({ + 'partner_id': self.partner.id, + 'date_maturity': date.today() - timedelta(days=20), + }) + + def test_get_overdue_finds_lines(self): + result = self.engine.get_overdue_for_partner(self.partner) + self.assertGreater(result['overdue_line_count'], 0) + self.assertGreater(result['aging']['total_overdue_amount'], 0) + + def test_compute_level_picks_reminder_at_20_days(self): + level = self.engine.compute_followup_level(self.partner) + self.assertTrue(level) + self.assertGreater(level.delay_days, 0) + + def test_send_followup_creates_run(self): + result = self.engine.send_followup_email(self.partner, force=True) + self.assertIn(result['status'], ('sent', 'manual_review')) + if 'run_id' in result: + run = self.env['fusion.followup.run'].browse(result['run_id']) + self.assertEqual(run.partner_id, self.partner) + + def test_pause_blocks_send_unless_force(self): + self.engine.pause_followup(self.partner, + until_date=date.today() + timedelta(days=30)) + result = self.engine.send_followup_email(self.partner) + self.assertTrue(result['status'].startswith('paused')) + result_force = self.engine.send_followup_email(self.partner, force=True) + self.assertIn(result_force['status'], ('sent', 'manual_review')) + + def test_history_grows_with_each_send(self): + Run = self.env['fusion.followup.run'] + before = Run.search_count([('partner_id', '=', self.partner.id)]) + self.engine.send_followup_email(self.partner, force=True) + after = Run.search_count([('partner_id', '=', self.partner.id)]) + self.assertGreater(after, before) + + def test_text_cache_used_on_repeat_call(self): + Cache = self.env['fusion.followup.text.cache'] + self.engine.send_followup_email(self.partner, force=True) + cache_count_after_first = Cache.search_count([('partner_id', '=', self.partner.id)]) + self.engine.send_followup_email(self.partner, force=True) + cache_count_after_second = Cache.search_count([('partner_id', '=', self.partner.id)]) + self.assertEqual(cache_count_after_first, cache_count_after_second, + "Repeat send with same params should not create new cache row") From d455016c275e75e1596a497c8e0fb2b620e8e4cd Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 21:00:07 -0400 Subject: [PATCH 14/36] feat(fusion_accounting_followup): 6 JSON-RPC endpoints for OWL widget Adds Task 15 controller layer: - /fusion/followup/list_overdue - /fusion/followup/get_partner_detail - /fusion/followup/generate_text - /fusion/followup/send - /fusion/followup/pause - /fusion/followup/reset All endpoints use V19 type='jsonrpc' and route through fusion.followup.engine. 6 HttpCase tests added (69 total). Made-with: Cursor --- fusion_accounting_followup/__manifest__.py | 2 +- .../controllers/__init__.py | 1 + .../controllers/followup_controller.py | 173 ++++++++++++++++++ fusion_accounting_followup/tests/__init__.py | 1 + .../tests/test_followup_controller.py | 80 ++++++++ 5 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 fusion_accounting_followup/controllers/followup_controller.py create mode 100644 fusion_accounting_followup/tests/test_followup_controller.py diff --git a/fusion_accounting_followup/__manifest__.py b/fusion_accounting_followup/__manifest__.py index f1c3473c..b3cb24c6 100644 --- a/fusion_accounting_followup/__manifest__.py +++ b/fusion_accounting_followup/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting Follow-up', - 'version': '19.0.1.0.14', + 'version': '19.0.1.0.15', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.', 'description': """ diff --git a/fusion_accounting_followup/controllers/__init__.py b/fusion_accounting_followup/controllers/__init__.py index e69de29b..3f63b75a 100644 --- a/fusion_accounting_followup/controllers/__init__.py +++ b/fusion_accounting_followup/controllers/__init__.py @@ -0,0 +1 @@ +from . import followup_controller diff --git a/fusion_accounting_followup/controllers/followup_controller.py b/fusion_accounting_followup/controllers/followup_controller.py new file mode 100644 index 00000000..6f349efc --- /dev/null +++ b/fusion_accounting_followup/controllers/followup_controller.py @@ -0,0 +1,173 @@ +"""HTTP controller: 6 JSON-RPC endpoints for the OWL follow-up dashboard. + +All endpoints route through fusion.followup.engine. V19 type='jsonrpc'. +""" + +import logging +from datetime import date, datetime + +from odoo import _, http +from odoo.exceptions import ValidationError +from odoo.http import request + +_logger = logging.getLogger(__name__) + + +def _parse_date(value): + if isinstance(value, date): + return value + if not value: + return None + return datetime.strptime(value, '%Y-%m-%d').date() + + +class FusionFollowupController(http.Controller): + + @http.route('/fusion/followup/list_overdue', type='jsonrpc', auth='user') + def list_overdue(self, limit=50, offset=0, status=None, company_id=None): + company_id = int(company_id) if company_id else request.env.company.id + Partner = request.env['res.partner'].sudo() + Line = request.env['account.move.line'].sudo() + overdue_partner_ids = Line.search([ + ('parent_state', '=', 'posted'), + ('account_id.account_type', '=', 'asset_receivable'), + ('reconciled', '=', False), + ('amount_residual', '>', 0), + ('date_maturity', '<', date.today()), + ('company_id', '=', company_id), + ]).mapped('partner_id').ids + + domain = [('id', 'in', overdue_partner_ids)] + if status: + domain.append(('fusion_followup_status', '=', status)) + total = Partner.search_count(domain) + partners = Partner.search(domain, limit=int(limit), offset=int(offset)) + + engine = request.env['fusion.followup.engine'] + rows = [] + for p in partners: + try: + overdue = engine.get_overdue_for_partner(p) + rows.append({ + 'partner_id': p.id, + 'partner_name': p.name, + 'email': p.email or '', + 'status': p.fusion_followup_status, + 'paused_until': str(p.fusion_followup_paused_until) + if p.fusion_followup_paused_until else None, + 'last_level_id': p.fusion_followup_last_level_id.id + if p.fusion_followup_last_level_id else None, + 'last_level_name': p.fusion_followup_last_level_id.name + if p.fusion_followup_last_level_id else None, + 'last_run_date': str(p.fusion_followup_last_run_date) + if p.fusion_followup_last_run_date else None, + 'overdue_amount': overdue['aging']['total_overdue_amount'], + 'overdue_line_count': overdue['overdue_line_count'], + 'risk_score': overdue['risk']['score'], + 'risk_band': overdue['risk']['band'], + }) + except Exception as e: + _logger.warning("Skipping partner %s in list: %s", p.id, e) + return {'count': len(rows), 'total': total, 'partners': rows} + + @http.route('/fusion/followup/get_partner_detail', type='jsonrpc', auth='user') + def get_partner_detail(self, partner_id): + partner = request.env['res.partner'].browse(int(partner_id)) + if not partner.exists(): + raise ValidationError(_("Partner %s not found") % partner_id) + engine = request.env['fusion.followup.engine'] + overdue = engine.get_overdue_for_partner(partner) + history = engine.snapshot_followup_history(partner, limit=20) + level = engine.compute_followup_level(partner) + return { + 'partner': { + 'id': partner.id, + 'name': partner.name, + 'email': partner.email or '', + 'status': partner.fusion_followup_status, + 'paused_until': str(partner.fusion_followup_paused_until) + if partner.fusion_followup_paused_until else None, + 'last_level_id': partner.fusion_followup_last_level_id.id + if partner.fusion_followup_last_level_id else None, + 'last_level_name': partner.fusion_followup_last_level_id.name + if partner.fusion_followup_last_level_id else None, + 'last_run_date': str(partner.fusion_followup_last_run_date) + if partner.fusion_followup_last_run_date else None, + 'risk_score': partner.fusion_followup_risk_score, + 'risk_band': partner.fusion_followup_risk_band, + }, + 'overdue': overdue, + 'suggested_level': { + 'id': level.id, 'name': level.name, 'tone': level.tone, + 'sequence': level.sequence, + } if level else None, + 'history': history, + } + + @http.route('/fusion/followup/generate_text', type='jsonrpc', auth='user') + def generate_text(self, partner_id, level_id=None, force_regenerate=False): + from odoo.addons.fusion_accounting_followup.services.followup_text_generator import ( + generate_followup_text, + ) + from odoo.addons.fusion_accounting_followup.services.tone_selector import select_tone + + partner = request.env['res.partner'].browse(int(partner_id)) + engine = request.env['fusion.followup.engine'] + if level_id: + level = request.env['fusion.followup.level'].browse(int(level_id)) + else: + level = engine.compute_followup_level(partner) + if not level: + return {'status': 'no_level', 'partner_id': partner.id} + + overdue = engine.get_overdue_for_partner(partner) + tone = select_tone( + level_sequence=level.sequence, + risk_score=overdue['risk']['score'], + ) + + currency_code = 'USD' + if partner.company_id and partner.company_id.currency_id: + currency_code = partner.company_id.currency_id.name or 'USD' + + text = generate_followup_text( + request.env, + partner_name=partner.name, + total_overdue=overdue['aging']['total_overdue_amount'], + currency_code=currency_code, + longest_overdue_days=engine._max_overdue_days_from_aging(overdue['aging']), + tone=tone, + invoice_count=overdue['overdue_line_count'], + risk_drivers=overdue['risk']['drivers'], + ) + return { + 'status': 'ok', + 'partner_id': partner.id, + 'level_id': level.id, + 'tone': tone, + 'subject': text.get('subject', ''), + 'body': text.get('body', ''), + 'tone_used': text.get('tone_used', tone), + 'key_points': text.get('key_points', []), + } + + @http.route('/fusion/followup/send', type='jsonrpc', auth='user') + def send_followup(self, partner_id, level_id=None, force=False): + partner = request.env['res.partner'].browse(int(partner_id)) + engine = request.env['fusion.followup.engine'] + level = None + if level_id: + level = request.env['fusion.followup.level'].browse(int(level_id)) + return engine.send_followup_email(partner, level=level, force=bool(force)) + + @http.route('/fusion/followup/pause', type='jsonrpc', auth='user') + def pause(self, partner_id, until_date=None): + partner = request.env['res.partner'].browse(int(partner_id)) + engine = request.env['fusion.followup.engine'] + return engine.pause_followup(partner, until_date=_parse_date(until_date)) + + @http.route('/fusion/followup/reset', type='jsonrpc', auth='user') + def reset(self, partner_id): + partner = request.env['res.partner'].browse(int(partner_id)) + engine = request.env['fusion.followup.engine'] + return engine.reset_followup(partner) diff --git a/fusion_accounting_followup/tests/__init__.py b/fusion_accounting_followup/tests/__init__.py index 2ef1dce5..40bf9f9a 100644 --- a/fusion_accounting_followup/tests/__init__.py +++ b/fusion_accounting_followup/tests/__init__.py @@ -10,3 +10,4 @@ from . import test_res_partner_inherit from . import test_account_move_line_inherit from . import test_fusion_followup_engine from . import test_engine_integration +from . import test_followup_controller diff --git a/fusion_accounting_followup/tests/test_followup_controller.py b/fusion_accounting_followup/tests/test_followup_controller.py new file mode 100644 index 00000000..df538ec2 --- /dev/null +++ b/fusion_accounting_followup/tests/test_followup_controller.py @@ -0,0 +1,80 @@ +"""HttpCase tests for the 6 follow-up JSON-RPC endpoints.""" + +import json +from datetime import date, timedelta + +from odoo.tests import tagged +from odoo.tests.common import HttpCase, new_test_user + + +@tagged('post_install', '-at_install') +class TestFollowupController(HttpCase): + + def setUp(self): + super().setUp() + self.user = new_test_user( + self.env, login='fu_test_user', + groups='base.group_user,base.group_partner_manager,' + 'account.group_account_invoice', + ) + + def _jsonrpc(self, endpoint, params): + self.authenticate('fu_test_user', 'fu_test_user') + url = f'/fusion/followup/{endpoint}' + body = {'jsonrpc': '2.0', 'method': 'call', 'params': params, 'id': 1} + response = self.url_open( + url, data=json.dumps(body), + headers={'Content-Type': 'application/json'}, + ) + self.assertEqual( + response.status_code, 200, + f"{endpoint} returned {response.status_code}: {response.text[:300]}", + ) + result = response.json() + if 'error' in result: + self.fail(f"{endpoint} errored: {result['error']}") + return result.get('result', {}) + + def test_list_overdue_returns_dict(self): + result = self._jsonrpc('list_overdue', {'company_id': self.env.company.id}) + self.assertIn('partners', result) + self.assertIn('total', result) + + def test_get_partner_detail(self): + partner = self.env['res.partner'].create({ + 'name': 'Ctrl Test Partner', 'email': 'ctrl@test.local', + }) + result = self._jsonrpc('get_partner_detail', {'partner_id': partner.id}) + self.assertEqual(result['partner']['id'], partner.id) + self.assertIn('overdue', result) + self.assertIn('history', result) + + def test_pause_sets_paused_until(self): + partner = self.env['res.partner'].create({'name': 'Pause Test'}) + future = (date.today() + timedelta(days=20)).isoformat() + result = self._jsonrpc('pause', { + 'partner_id': partner.id, 'until_date': future, + }) + self.assertEqual(result['paused_until'], future) + + def test_reset_clears_status(self): + partner = self.env['res.partner'].create({ + 'name': 'Reset Test', + 'fusion_followup_status': 'paused', + }) + result = self._jsonrpc('reset', {'partner_id': partner.id}) + self.assertEqual(result['status'], 'reset') + + def test_send_no_overdue_returns_no_action(self): + partner = self.env['res.partner'].create({ + 'name': 'No Overdue', 'email': 'no@test.local', + }) + result = self._jsonrpc('send', { + 'partner_id': partner.id, 'force': True, + }) + self.assertIn(result.get('status'), ('no_action', 'no_overdue')) + + def test_generate_text_no_level_returns_no_level(self): + partner = self.env['res.partner'].create({'name': 'NoLevel Test'}) + result = self._jsonrpc('generate_text', {'partner_id': partner.id}) + self.assertIn(result.get('status'), ('no_level', 'ok')) From 993df3a14a6273fa584f7b2f56c23699ffc3b2f0 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 21:02:17 -0400 Subject: [PATCH 15/36] feat(fusion_accounting_ai): wire FollowupAdapter fusion paths to engine - Switch FUSION_MODEL to fusion.followup.engine so adapter mode selection matches the new module - Add list_overdue() with fusion/enterprise/community variants - Re-route send_followup_via_fusion to engine.send_followup_email - 4 new TransactionCase tests (73 total) Existing aging / overdue_invoices adapter methods continue to fall back to the community implementation. Made-with: Cursor --- .../services/data_adapters/followup.py | 87 +++++++++++++++++-- fusion_accounting_followup/__manifest__.py | 2 +- fusion_accounting_followup/tests/__init__.py | 1 + .../tests/test_followup_adapter.py | 42 +++++++++ 4 files changed, 123 insertions(+), 9 deletions(-) create mode 100644 fusion_accounting_followup/tests/test_followup_adapter.py diff --git a/fusion_accounting_ai/services/data_adapters/followup.py b/fusion_accounting_ai/services/data_adapters/followup.py index 067011f2..d165669e 100644 --- a/fusion_accounting_ai/services/data_adapters/followup.py +++ b/fusion_accounting_ai/services/data_adapters/followup.py @@ -28,7 +28,7 @@ def _bucket_for_days(days): class FollowupAdapter(DataAdapter): - FUSION_MODEL = 'fusion.followup.line' + FUSION_MODEL = 'fusion.followup.engine' ENTERPRISE_MODULE = 'account_followup' # ------------------------------------------------------------------ @@ -179,15 +179,29 @@ class FollowupAdapter(DataAdapter): } # ------------------------------------------------------------------ - # send_followup — Enterprise-only action + # send_followup — routes to fusion engine when available # ------------------------------------------------------------------ - def send_followup(self, partner_id, options=None): - return self._dispatch('send_followup', partner_id=partner_id, options=options) + def send_followup(self, partner_id, level_id=None, force=False, options=None): + return self._dispatch( + 'send_followup', + partner_id=partner_id, level_id=level_id, + force=force, options=options, + ) - def send_followup_via_fusion(self, partner_id, options=None): - return self.send_followup_via_community(partner_id=partner_id, options=options) + def send_followup_via_fusion(self, partner_id, level_id=None, + force=False, options=None): + if 'fusion.followup.engine' not in self.env.registry: + return {'error': 'fusion_accounting_followup not installed'} + partner = self.env['res.partner'].browse(int(partner_id)) + level = None + if level_id: + level = self.env['fusion.followup.level'].browse(int(level_id)) + return self.env['fusion.followup.engine'].send_followup_email( + partner, level=level, force=bool(force), + ) - def send_followup_via_enterprise(self, partner_id, options=None): + def send_followup_via_enterprise(self, partner_id, level_id=None, + force=False, options=None): partner = self.env['res.partner'].browse(partner_id) if not partner.exists(): return {'error': 'Partner not found'} @@ -198,7 +212,8 @@ class FollowupAdapter(DataAdapter): 'result': str(result) if result else 'done', } - def send_followup_via_community(self, partner_id, options=None): + def send_followup_via_community(self, partner_id, level_id=None, + force=False, options=None): return { 'error': ( 'Sending follow-ups is only available when account_followup ' @@ -206,5 +221,61 @@ class FollowupAdapter(DataAdapter): ), } + # ------------------------------------------------------------------ + # list_overdue — partner-centric overdue rollup (fusion engine) + # ------------------------------------------------------------------ + def list_overdue(self, status=None, limit=50, company_id=None): + return self._dispatch( + 'list_overdue', + status=status, limit=limit, company_id=company_id, + ) + + def list_overdue_via_fusion(self, status=None, limit=50, company_id=None): + if 'fusion.followup.engine' not in self.env.registry: + return {'partners': [], 'count': 0, 'total': 0} + company_id = company_id or self.env.company.id + Line = self.env['account.move.line'].sudo() + partner_ids = Line.search([ + ('parent_state', '=', 'posted'), + ('account_id.account_type', '=', 'asset_receivable'), + ('reconciled', '=', False), + ('amount_residual', '>', 0), + ('date_maturity', '<', date.today()), + ('company_id', '=', company_id), + ]).mapped('partner_id').ids + Partner = self.env['res.partner'].sudo() + domain = [('id', 'in', partner_ids)] + if status: + domain.append(('fusion_followup_status', '=', status)) + partners = Partner.search(domain, limit=int(limit)) + engine = self.env['fusion.followup.engine'] + rows = [] + for p in partners: + try: + overdue = engine.get_overdue_for_partner(p) + rows.append({ + 'partner_id': p.id, + 'partner_name': p.name, + 'overdue_amount': overdue['aging']['total_overdue_amount'], + 'risk_score': overdue['risk']['score'], + 'risk_band': overdue['risk']['band'], + 'status': p.fusion_followup_status, + }) + except Exception: + pass + return {'count': len(rows), 'total': len(partner_ids), 'partners': rows} + + def list_overdue_via_enterprise(self, status=None, limit=50, company_id=None): + return { + 'partners': [], 'count': 0, 'total': 0, + 'error': 'Enterprise account_followup must be used from its UI', + } + + def list_overdue_via_community(self, status=None, limit=50, company_id=None): + return { + 'partners': [], 'count': 0, 'total': 0, + 'error': 'No follow-up engine in pure Community', + } + register_adapter('followup', FollowupAdapter) diff --git a/fusion_accounting_followup/__manifest__.py b/fusion_accounting_followup/__manifest__.py index b3cb24c6..c7f90d9f 100644 --- a/fusion_accounting_followup/__manifest__.py +++ b/fusion_accounting_followup/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting Follow-up', - 'version': '19.0.1.0.15', + 'version': '19.0.1.0.16', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.', 'description': """ diff --git a/fusion_accounting_followup/tests/__init__.py b/fusion_accounting_followup/tests/__init__.py index 40bf9f9a..693ef383 100644 --- a/fusion_accounting_followup/tests/__init__.py +++ b/fusion_accounting_followup/tests/__init__.py @@ -11,3 +11,4 @@ from . import test_account_move_line_inherit from . import test_fusion_followup_engine from . import test_engine_integration from . import test_followup_controller +from . import test_followup_adapter diff --git a/fusion_accounting_followup/tests/test_followup_adapter.py b/fusion_accounting_followup/tests/test_followup_adapter.py new file mode 100644 index 00000000..1b8bfaae --- /dev/null +++ b/fusion_accounting_followup/tests/test_followup_adapter.py @@ -0,0 +1,42 @@ +"""FollowupAdapter wiring tests — engine paths.""" + +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + +from odoo.addons.fusion_accounting_ai.services.data_adapters.followup import ( + FollowupAdapter, +) + + +@tagged('post_install', '-at_install') +class TestFollowupAdapter(TransactionCase): + + def setUp(self): + super().setUp() + self.adapter = FollowupAdapter(self.env) + + def test_list_overdue_via_fusion_returns_dict(self): + result = self.adapter.list_overdue_via_fusion( + company_id=self.env.company.id, + ) + self.assertIn('partners', result) + self.assertIn('total', result) + self.assertIn('count', result) + + def test_list_overdue_via_community_returns_error(self): + result = self.adapter.list_overdue_via_community() + self.assertIn('error', result) + + def test_send_followup_via_fusion_no_overdue(self): + partner = self.env['res.partner'].create({'name': 'AdapterTest'}) + result = self.adapter.send_followup_via_fusion( + partner_id=partner.id, force=True, + ) + self.assertIn( + result.get('status', ''), + ('no_action', 'no_overdue', 'sent', 'manual_review'), + ) + + def test_send_followup_via_community_returns_error(self): + result = self.adapter.send_followup_via_community(partner_id=1) + self.assertIn('error', result) From 52becd176a0d46dbf47e2e71bb91b387cb7f16da Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 21:03:30 -0400 Subject: [PATCH 16/36] feat(fusion_accounting_ai): 5 new customer follow-up AI tools Adds Task 17 tool layer: - fusion_list_overdue - fusion_get_partner_followup_detail - fusion_generate_followup_text - fusion_send_followup - fusion_get_partner_risk_score Tools register through TOOL_DISPATCH and degrade with a clear error message when fusion_accounting_followup is not installed. 5 TransactionCase tests added (78 total). Made-with: Cursor --- .../services/tools/__init__.py | 3 +- .../services/tools/customer_followup.py | 98 +++++++++++++++++++ fusion_accounting_followup/__manifest__.py | 2 +- fusion_accounting_followup/tests/__init__.py | 1 + .../tests/test_followup_tools.py | 61 ++++++++++++ 5 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 fusion_accounting_ai/services/tools/customer_followup.py create mode 100644 fusion_accounting_followup/tests/test_followup_tools.py diff --git a/fusion_accounting_ai/services/tools/__init__.py b/fusion_accounting_ai/services/tools/__init__.py index b2331b03..85339c05 100644 --- a/fusion_accounting_ai/services/tools/__init__.py +++ b/fusion_accounting_ai/services/tools/__init__.py @@ -11,12 +11,13 @@ from .reporting import TOOLS as REPORTING_TOOLS from .audit import TOOLS as AUDIT_TOOLS from .financial_reports import TOOLS as FINANCIAL_REPORTS_TOOLS from .asset_management import TOOLS as ASSET_MANAGEMENT_TOOLS +from .customer_followup import TOOLS as CUSTOMER_FOLLOWUP_TOOLS TOOL_DISPATCH = {} for tools_dict in [ BANK_RECON_TOOLS, HST_TOOLS, AR_TOOLS, AP_TOOLS, JOURNAL_TOOLS, MONTH_END_TOOLS, PAYROLL_TOOLS, INVENTORY_TOOLS, ADP_TOOLS, REPORTING_TOOLS, AUDIT_TOOLS, FINANCIAL_REPORTS_TOOLS, - ASSET_MANAGEMENT_TOOLS, + ASSET_MANAGEMENT_TOOLS, CUSTOMER_FOLLOWUP_TOOLS, ]: TOOL_DISPATCH.update(tools_dict) diff --git a/fusion_accounting_ai/services/tools/customer_followup.py b/fusion_accounting_ai/services/tools/customer_followup.py new file mode 100644 index 00000000..1ef735d2 --- /dev/null +++ b/fusion_accounting_ai/services/tools/customer_followup.py @@ -0,0 +1,98 @@ +"""Fusion-engine-routed AI tools for customer follow-ups. + +These tools are exposed through TOOL_DISPATCH and let the assistant query +the customer follow-up engine via natural language. All tools degrade +gracefully when fusion_accounting_followup is not installed. +""" + +import logging + +_logger = logging.getLogger(__name__) + + +def fusion_list_overdue(env, params): + """List partners with overdue invoices, sorted by risk.""" + if 'fusion.followup.engine' not in env.registry: + return {'error': 'fusion_accounting_followup not installed'} + from ..data_adapters import get_adapter + adapter = get_adapter(env, 'followup') + return adapter.list_overdue( + status=params.get('status'), + limit=int(params.get('limit', 50)), + company_id=int(params['company_id']) + if params.get('company_id') else env.company.id, + ) + + +def fusion_get_partner_followup_detail(env, params): + """Detailed follow-up state for a single partner: aging, risk, history.""" + if 'fusion.followup.engine' not in env.registry: + return {'error': 'fusion_accounting_followup not installed'} + Partner = env['res.partner'] + partner = Partner.browse(int(params['partner_id'])) + if not partner.exists(): + return {'error': 'Partner not found'} + engine = env['fusion.followup.engine'] + overdue = engine.get_overdue_for_partner(partner) + history = engine.snapshot_followup_history(partner, limit=10) + return { + 'partner_id': partner.id, + 'partner_name': partner.name, + 'overdue': overdue, + 'history': history, + } + + +def fusion_generate_followup_text(env, params): + """Generate (or fall back to template) follow-up subject + body.""" + if 'fusion.followup.engine' not in env.registry: + return {'error': 'fusion_accounting_followup not installed'} + from odoo.addons.fusion_accounting_followup.services.followup_text_generator import ( + generate_followup_text, + ) + return generate_followup_text( + env, + partner_name=params.get('partner_name', ''), + total_overdue=float(params.get('total_overdue', 0)), + currency_code=params.get('currency_code', 'USD'), + longest_overdue_days=int(params.get('longest_overdue_days', 0)), + tone=params.get('tone', 'gentle'), + invoice_count=int(params.get('invoice_count', 0)), + ) + + +def fusion_send_followup(env, params): + """Send a follow-up email via the engine (creates a fusion.followup.run).""" + if 'fusion.followup.engine' not in env.registry: + return {'error': 'fusion_accounting_followup not installed'} + from ..data_adapters import get_adapter + adapter = get_adapter(env, 'followup') + return adapter.send_followup( + partner_id=int(params['partner_id']), + level_id=int(params['level_id']) if params.get('level_id') else None, + force=bool(params.get('force', False)), + ) + + +def fusion_get_partner_risk_score(env, params): + """Compute and return the payment-risk score + drivers for a partner.""" + if 'fusion.followup.engine' not in env.registry: + return {'error': 'fusion_accounting_followup not installed'} + partner = env['res.partner'].browse(int(params['partner_id'])) + if not partner.exists(): + return {'error': 'Partner not found'} + overdue = env['fusion.followup.engine'].get_overdue_for_partner(partner) + return { + 'partner_id': partner.id, + 'partner_name': partner.name, + 'risk': overdue['risk'], + } + + +TOOLS = { + 'fusion_list_overdue': fusion_list_overdue, + 'fusion_get_partner_followup_detail': fusion_get_partner_followup_detail, + 'fusion_generate_followup_text': fusion_generate_followup_text, + 'fusion_send_followup': fusion_send_followup, + 'fusion_get_partner_risk_score': fusion_get_partner_risk_score, +} diff --git a/fusion_accounting_followup/__manifest__.py b/fusion_accounting_followup/__manifest__.py index c7f90d9f..f95be0ad 100644 --- a/fusion_accounting_followup/__manifest__.py +++ b/fusion_accounting_followup/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting Follow-up', - 'version': '19.0.1.0.16', + 'version': '19.0.1.0.17', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.', 'description': """ diff --git a/fusion_accounting_followup/tests/__init__.py b/fusion_accounting_followup/tests/__init__.py index 693ef383..94fa47b3 100644 --- a/fusion_accounting_followup/tests/__init__.py +++ b/fusion_accounting_followup/tests/__init__.py @@ -12,3 +12,4 @@ from . import test_fusion_followup_engine from . import test_engine_integration from . import test_followup_controller from . import test_followup_adapter +from . import test_followup_tools diff --git a/fusion_accounting_followup/tests/test_followup_tools.py b/fusion_accounting_followup/tests/test_followup_tools.py new file mode 100644 index 00000000..ad4200d2 --- /dev/null +++ b/fusion_accounting_followup/tests/test_followup_tools.py @@ -0,0 +1,61 @@ +"""AI tool dispatch tests for fusion follow-up tools.""" + +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + +from odoo.addons.fusion_accounting_ai.services.tools import customer_followup as tools + + +@tagged('post_install', '-at_install') +class TestFusionFollowupTools(TransactionCase): + + def test_fusion_list_overdue(self): + result = tools.fusion_list_overdue( + self.env, {'company_id': self.env.company.id}, + ) + self.assertIn('partners', result) + + def test_fusion_get_partner_detail(self): + partner = self.env['res.partner'].create({ + 'name': 'Tool Partner', 'email': 't@t.local', + }) + result = tools.fusion_get_partner_followup_detail( + self.env, {'partner_id': partner.id}, + ) + self.assertEqual(result['partner_id'], partner.id) + + def test_fusion_generate_text_uses_fallback(self): + self.env['ir.config_parameter'].sudo().search([ + ('key', 'in', [ + 'fusion_accounting.provider.followup_text', + 'fusion_accounting.provider.default', + ]), + ]).unlink() + result = tools.fusion_generate_followup_text(self.env, { + 'partner_name': 'Acme', 'total_overdue': 1000, + 'currency_code': 'USD', 'longest_overdue_days': 15, + 'tone': 'gentle', + }) + self.assertIn('subject', result) + self.assertIn('body', result) + + def test_fusion_get_risk_score(self): + partner = self.env['res.partner'].create({'name': 'Risk Test'}) + result = tools.fusion_get_partner_risk_score( + self.env, {'partner_id': partner.id}, + ) + self.assertIn('risk', result) + + def test_tools_registered_in_dispatch(self): + from odoo.addons.fusion_accounting_ai.services.tools import TOOL_DISPATCH + for tool_name in [ + 'fusion_list_overdue', + 'fusion_get_partner_followup_detail', + 'fusion_generate_followup_text', + 'fusion_send_followup', + 'fusion_get_partner_risk_score', + ]: + self.assertIn( + tool_name, TOOL_DISPATCH, + f"{tool_name} not registered in TOOL_DISPATCH", + ) From 042dcf80673c2bcad8cb06f154ef21a04fd46848 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 21:04:37 -0400 Subject: [PATCH 17/36] feat(fusion_accounting_followup): 2 cron jobs (daily scan + weekly risk refresh) - fusion.followup.cron AbstractModel with two handlers - cron_fusion_followup_daily_scan: walks every overdue partner and delegates to engine.send_followup_email - cron_fusion_followup_risk_refresh: weekly refresh of fusion_followup_risk_score / risk_band on res.partner - V19 ir.cron records (no numbercall field) - 2 smoke tests added (80 total) Made-with: Cursor --- fusion_accounting_followup/__manifest__.py | 3 +- fusion_accounting_followup/data/cron.xml | 24 ++++++ fusion_accounting_followup/models/__init__.py | 1 + .../models/fusion_followup_cron.py | 84 +++++++++++++++++++ fusion_accounting_followup/tests/__init__.py | 1 + .../tests/test_followup_cron.py | 18 ++++ 6 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 fusion_accounting_followup/data/cron.xml create mode 100644 fusion_accounting_followup/models/fusion_followup_cron.py create mode 100644 fusion_accounting_followup/tests/test_followup_cron.py diff --git a/fusion_accounting_followup/__manifest__.py b/fusion_accounting_followup/__manifest__.py index f95be0ad..14adf203 100644 --- a/fusion_accounting_followup/__manifest__.py +++ b/fusion_accounting_followup/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting Follow-up', - 'version': '19.0.1.0.17', + 'version': '19.0.1.0.18', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.', 'description': """ @@ -33,6 +33,7 @@ menu hides; the engine + AI tools remain available for the chat. ], 'data': [ 'security/ir.model.access.csv', + 'data/cron.xml', ], 'assets': { 'web.assets_backend': [ diff --git a/fusion_accounting_followup/data/cron.xml b/fusion_accounting_followup/data/cron.xml new file mode 100644 index 00000000..b28cee68 --- /dev/null +++ b/fusion_accounting_followup/data/cron.xml @@ -0,0 +1,24 @@ + + + + + Fusion Follow-up — Daily Scan + Send + + code + model._cron_daily_scan() + 1 + days + + + + + Fusion Follow-up — Weekly Risk Refresh + + code + model._cron_risk_refresh() + 7 + days + + + + diff --git a/fusion_accounting_followup/models/__init__.py b/fusion_accounting_followup/models/__init__.py index ec9d216e..216dd595 100644 --- a/fusion_accounting_followup/models/__init__.py +++ b/fusion_accounting_followup/models/__init__.py @@ -4,3 +4,4 @@ from . import fusion_followup_text_cache from . import res_partner from . import account_move_line from . import fusion_followup_engine +from . import fusion_followup_cron diff --git a/fusion_accounting_followup/models/fusion_followup_cron.py b/fusion_accounting_followup/models/fusion_followup_cron.py new file mode 100644 index 00000000..f0c825b7 --- /dev/null +++ b/fusion_accounting_followup/models/fusion_followup_cron.py @@ -0,0 +1,84 @@ +"""Cron handlers for fusion_accounting_followup. + +Two scheduled jobs: +- Daily scan: walk every partner with an open overdue receivable line and + call the engine to send/escalate where appropriate. +- Weekly risk refresh: recompute fusion_followup_risk_score on every + partner with overdue. +""" + +import logging +from datetime import date + +from odoo import api, models + +_logger = logging.getLogger(__name__) + + +class FusionFollowupCron(models.AbstractModel): + _name = "fusion.followup.cron" + _description = "Fusion Follow-up Cron Handlers" + + @api.model + def _cron_daily_scan(self): + """Scan every partner with overdue and send follow-ups when due.""" + engine = self.env['fusion.followup.engine'] + Line = self.env['account.move.line'].sudo() + overdue_lines = Line.search([ + ('parent_state', '=', 'posted'), + ('account_id.account_type', '=', 'asset_receivable'), + ('reconciled', '=', False), + ('amount_residual', '>', 0), + ('date_maturity', '<', date.today()), + ]) + partner_ids = list(set(overdue_lines.mapped('partner_id').ids)) + sent = 0 + skipped = 0 + for pid in partner_ids: + partner = self.env['res.partner'].sudo().browse(pid) + if not partner.exists(): + continue + try: + with self.env.cr.savepoint(): + result = engine.send_followup_email(partner) + if result.get('status') == 'sent': + sent += 1 + else: + skipped += 1 + except Exception as e: + _logger.warning( + "Cron daily_scan failed for partner %s: %s", pid, e, + ) + skipped += 1 + _logger.info( + "Cron: scanned %d partners, sent %d, skipped %d", + len(partner_ids), sent, skipped, + ) + + @api.model + def _cron_risk_refresh(self): + """Refresh fusion_followup_risk_score on every partner with overdue.""" + Partner = self.env['res.partner'].sudo() + engine = self.env['fusion.followup.engine'] + Line = self.env['account.move.line'].sudo() + partner_ids = list(set(Line.search([ + ('parent_state', '=', 'posted'), + ('account_id.account_type', '=', 'asset_receivable'), + ('reconciled', '=', False), + ('amount_residual', '>', 0), + ]).mapped('partner_id').ids)) + updated = 0 + for pid in partner_ids: + partner = Partner.browse(pid) + try: + overdue = engine.get_overdue_for_partner(partner) + partner.write({ + 'fusion_followup_risk_score': overdue['risk']['score'], + 'fusion_followup_risk_band': overdue['risk']['band'], + }) + updated += 1 + except Exception as e: + _logger.warning( + "Risk refresh failed for partner %s: %s", pid, e, + ) + _logger.info("Cron: refreshed risk on %d partners", updated) diff --git a/fusion_accounting_followup/tests/__init__.py b/fusion_accounting_followup/tests/__init__.py index 94fa47b3..76912615 100644 --- a/fusion_accounting_followup/tests/__init__.py +++ b/fusion_accounting_followup/tests/__init__.py @@ -13,3 +13,4 @@ from . import test_engine_integration from . import test_followup_controller from . import test_followup_adapter from . import test_followup_tools +from . import test_followup_cron diff --git a/fusion_accounting_followup/tests/test_followup_cron.py b/fusion_accounting_followup/tests/test_followup_cron.py new file mode 100644 index 00000000..13d815c9 --- /dev/null +++ b/fusion_accounting_followup/tests/test_followup_cron.py @@ -0,0 +1,18 @@ +"""Smoke tests for the fusion follow-up cron handlers.""" + +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + + +@tagged('post_install', '-at_install') +class TestFollowupCron(TransactionCase): + + def setUp(self): + super().setUp() + self.cron = self.env['fusion.followup.cron'] + + def test_cron_daily_scan_runs(self): + self.cron._cron_daily_scan() + + def test_cron_risk_refresh_runs(self): + self.cron._cron_risk_refresh() From d51a2b104e1326e1384ea684c79b6ca2f808f2ff Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 21:08:35 -0400 Subject: [PATCH 18/36] test(fusion_accounting_followup): Hypothesis property-based invariants Made-with: Cursor --- fusion_accounting_followup/tests/__init__.py | 1 + .../tests/test_engine_property.py | 92 +++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 fusion_accounting_followup/tests/test_engine_property.py diff --git a/fusion_accounting_followup/tests/__init__.py b/fusion_accounting_followup/tests/__init__.py index 76912615..583b044b 100644 --- a/fusion_accounting_followup/tests/__init__.py +++ b/fusion_accounting_followup/tests/__init__.py @@ -14,3 +14,4 @@ from . import test_followup_controller from . import test_followup_adapter from . import test_followup_tools from . import test_followup_cron +from . import test_engine_property diff --git a/fusion_accounting_followup/tests/test_engine_property.py b/fusion_accounting_followup/tests/test_engine_property.py new file mode 100644 index 00000000..99b6679d --- /dev/null +++ b/fusion_accounting_followup/tests/test_engine_property.py @@ -0,0 +1,92 @@ +"""Property-based invariants for follow-up services.""" + +from datetime import date, timedelta + +from hypothesis import given, settings, strategies as st, HealthCheck +from odoo.tests.common import TransactionCase +from odoo.tests import tagged + +from odoo.addons.fusion_accounting_followup.services.overdue_aging import ( + compute_aging, BUCKETS, +) +from odoo.addons.fusion_accounting_followup.services.risk_scorer import score_partner +from odoo.addons.fusion_accounting_followup.services.tone_selector import select_tone + + +@tagged('post_install', '-at_install', 'property_based') +class TestAgingInvariants(TransactionCase): + + @given( + as_of=st.dates(min_value=date(2020, 1, 1), max_value=date(2030, 12, 31)), + amounts=st.lists( + st.tuples( + st.integers(min_value=-180, max_value=180), + st.floats(min_value=0.01, max_value=100000, + allow_nan=False, allow_infinity=False), + ), + min_size=0, max_size=20, + ), + ) + @settings(max_examples=80, deadline=2000, + suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_buckets_sum_equals_total(self, as_of, amounts): + lines = [ + {'date_maturity': as_of + timedelta(days=offset), + 'amount_residual': round(amt, 2)} + for offset, amt in amounts + ] + report = compute_aging(move_lines=lines, as_of=as_of) + bucket_sum = sum(b.amount for b in report.buckets) + self.assertAlmostEqual(bucket_sum, report.total_amount, places=1) + + @given( + as_of=st.dates(min_value=date(2020, 1, 1), max_value=date(2030, 12, 31)), + days_overdue=st.integers(min_value=1, max_value=365), + amount=st.floats(min_value=0.01, max_value=10000, + allow_nan=False, allow_infinity=False), + ) + @settings(max_examples=50, deadline=2000, + suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_overdue_amount_excludes_current(self, as_of, days_overdue, amount): + lines = [ + {'date_maturity': as_of - timedelta(days=days_overdue), + 'amount_residual': round(amount, 2)}, + {'date_maturity': as_of + timedelta(days=10), + 'amount_residual': 100.0}, + ] + report = compute_aging(move_lines=lines, as_of=as_of) + self.assertAlmostEqual(report.total_overdue_amount, round(amount, 2), places=1) + + @given( + invoices=st.integers(min_value=0, max_value=100), + late=st.integers(min_value=0, max_value=100), + days_late=st.floats(min_value=0, max_value=180, + allow_nan=False, allow_infinity=False), + ) + @settings(max_examples=80, deadline=2000, + suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_risk_score_in_range(self, invoices, late, days_late): + late = min(late, invoices) if invoices > 0 else 0 + result = score_partner( + total_invoices=invoices, paid_late_count=late, + avg_days_late=days_late, + longest_overdue_days=int(days_late), + open_overdue_amount=invoices * 1000.0, + average_invoice_amount=1000.0, + ) + self.assertGreaterEqual(result.score, 0) + self.assertLessEqual(result.score, 100) + + +@tagged('post_install', '-at_install', 'property_based') +class TestToneInvariants(TransactionCase): + + @given( + sequence=st.integers(min_value=1, max_value=10), + risk=st.integers(min_value=0, max_value=100), + ) + @settings(max_examples=50, deadline=1000, + suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_tone_always_in_valid_set(self, sequence, risk): + tone = select_tone(level_sequence=sequence, risk_score=risk) + self.assertIn(tone, ('gentle', 'firm', 'legal')) From f64b8f373cf58839eca9139950acaaab6ffc9527 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 21:09:17 -0400 Subject: [PATCH 19/36] test(fusion_accounting_followup): full follow-up flow integration test Made-with: Cursor --- fusion_accounting_followup/tests/__init__.py | 1 + .../tests/test_followup_full_flow.py | 84 +++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 fusion_accounting_followup/tests/test_followup_full_flow.py diff --git a/fusion_accounting_followup/tests/__init__.py b/fusion_accounting_followup/tests/__init__.py index 583b044b..308d8a54 100644 --- a/fusion_accounting_followup/tests/__init__.py +++ b/fusion_accounting_followup/tests/__init__.py @@ -15,3 +15,4 @@ from . import test_followup_adapter from . import test_followup_tools from . import test_followup_cron from . import test_engine_property +from . import test_followup_full_flow diff --git a/fusion_accounting_followup/tests/test_followup_full_flow.py b/fusion_accounting_followup/tests/test_followup_full_flow.py new file mode 100644 index 00000000..8a65e12f --- /dev/null +++ b/fusion_accounting_followup/tests/test_followup_full_flow.py @@ -0,0 +1,84 @@ +"""End-to-end integration: scan -> escalate -> send -> reset.""" + +from datetime import date, timedelta +from odoo.tests.common import TransactionCase +from odoo.tests import tagged + + +@tagged('post_install', '-at_install', 'integration') +class TestFollowupFullFlow(TransactionCase): + + def setUp(self): + super().setUp() + self.engine = self.env['fusion.followup.engine'] + self.partner = self.env['res.partner'].create({ + 'name': 'Full Flow Partner', 'email': 'flow@test.local', + }) + for seq, name, days, tone in [(701, 'FlowReminder', 7, 'gentle'), + (702, 'FlowWarning', 30, 'firm'), + (703, 'FlowLegal', 60, 'legal')]: + self.env['fusion.followup.level'].create({ + 'name': name, 'sequence': seq, + 'delay_days': days, 'tone': tone, + }) + + line = self.env['account.move.line'].search([ + ('parent_state', '=', 'posted'), + ('account_id.account_type', '=', 'asset_receivable'), + ('reconciled', '=', False), + ('amount_residual', '>', 0), + ], limit=1) + if not line: + self.skipTest("No posted unreconciled receivable lines in test DB") + line.write({ + 'partner_id': self.partner.id, + 'date_maturity': date.today() - timedelta(days=20), + }) + + def test_full_flow_scan_send_reset(self): + level = self.engine.compute_followup_level(self.partner) + self.assertTrue(level) + self.assertGreater(level.delay_days, 0) + + Run = self.env['fusion.followup.run'] + before = Run.search_count([('partner_id', '=', self.partner.id)]) + result = self.engine.send_followup_email(self.partner, force=True) + after = Run.search_count([('partner_id', '=', self.partner.id)]) + self.assertGreater(after, before) + self.assertIn(result['status'], ('sent', 'manual_review')) + + self.engine.pause_followup(self.partner, + until_date=date.today() + timedelta(days=14)) + result_paused = self.engine.send_followup_email(self.partner) + self.assertTrue(result_paused['status'].startswith('paused')) + + self.engine.reset_followup(self.partner) + self.partner.invalidate_recordset(['fusion_followup_status']) + self.assertEqual(self.partner.fusion_followup_status, 'no_action') + + def test_escalate_advances_to_next_level(self): + Level = self.env['fusion.followup.level'] + level1 = Level.search([('sequence', '=', 701)], limit=1) + self.engine.send_followup_email(self.partner, level=level1, force=True) + self.partner.invalidate_recordset(['fusion_followup_last_level_id']) + result = self.engine.escalate_to_next_level(self.partner) + self.assertIn('partner_id', result) + self.partner.invalidate_recordset(['fusion_followup_last_level_id']) + if self.partner.fusion_followup_last_level_id: + self.assertGreaterEqual(self.partner.fusion_followup_last_level_id.sequence, 702) + + def test_text_cache_reused_on_repeat(self): + Cache = self.env['fusion.followup.text.cache'] + self.engine.send_followup_email(self.partner, force=True) + after_first = Cache.search_count([('partner_id', '=', self.partner.id)]) + self.engine.send_followup_email(self.partner, force=True) + after_second = Cache.search_count([('partner_id', '=', self.partner.id)]) + self.assertEqual(after_first, after_second) + + def test_history_records_each_send(self): + Run = self.env['fusion.followup.run'] + before = Run.search_count([('partner_id', '=', self.partner.id)]) + self.engine.send_followup_email(self.partner, force=True) + self.engine.send_followup_email(self.partner, force=True) + after = Run.search_count([('partner_id', '=', self.partner.id)]) + self.assertEqual(after - before, 2) From f45d66c46562e7f04b781a53ab6c6c9b0d2acd3d Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 21:10:02 -0400 Subject: [PATCH 20/36] test(fusion_accounting_followup): performance benchmarks with P95 targets Made-with: Cursor --- fusion_accounting_followup/tests/__init__.py | 1 + .../tests/test_performance_benchmarks.py | 100 ++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 fusion_accounting_followup/tests/test_performance_benchmarks.py diff --git a/fusion_accounting_followup/tests/__init__.py b/fusion_accounting_followup/tests/__init__.py index 308d8a54..8bb6e435 100644 --- a/fusion_accounting_followup/tests/__init__.py +++ b/fusion_accounting_followup/tests/__init__.py @@ -16,3 +16,4 @@ from . import test_followup_tools from . import test_followup_cron from . import test_engine_property from . import test_followup_full_flow +from . import test_performance_benchmarks diff --git a/fusion_accounting_followup/tests/test_performance_benchmarks.py b/fusion_accounting_followup/tests/test_performance_benchmarks.py new file mode 100644 index 00000000..19dbf0e4 --- /dev/null +++ b/fusion_accounting_followup/tests/test_performance_benchmarks.py @@ -0,0 +1,100 @@ +"""Performance benchmarks tagged 'benchmark'.""" + +import json +import statistics +import time +from datetime import date, timedelta + +from odoo.tests.common import HttpCase, TransactionCase, new_test_user +from odoo.tests import tagged + + +def _percentile(samples, p): + if len(samples) <= 1: + return samples[0] if samples else 0 + sorted_s = sorted(samples) + idx = int(len(sorted_s) * p / 100) + return sorted_s[min(idx, len(sorted_s) - 1)] + + +@tagged('post_install', '-at_install', 'benchmark') +class TestEngineBenchmarks(TransactionCase): + + def setUp(self): + super().setUp() + self.engine = self.env['fusion.followup.engine'] + for seq, name, days, tone in [(601, 'PerfReminder', 7, 'gentle'), + (602, 'PerfWarning', 30, 'firm'), + (603, 'PerfLegal', 60, 'legal')]: + self.env['fusion.followup.level'].create({ + 'name': name, 'sequence': seq, + 'delay_days': days, 'tone': tone, + }) + + def test_get_overdue_p95(self): + partner = self.env['res.partner'].create({'name': 'PerfPartner'}) + timings = [] + for _ in range(10): + start = time.perf_counter() + self.engine.get_overdue_for_partner(partner) + timings.append((time.perf_counter() - start) * 1000) + p95 = _percentile(timings, 95) + median = statistics.median(timings) + msg = f"get_overdue_for_partner: median={median:.0f}ms p95={p95:.0f}ms" + print(f"\n PERF: {msg} (target <100ms)") + self.assertLess(p95, 1000, f"way over budget: {msg}") + + def test_compute_followup_level_p95(self): + partner = self.env['res.partner'].create({'name': 'CompLevelPerf'}) + timings = [] + for _ in range(10): + start = time.perf_counter() + self.engine.compute_followup_level(partner) + timings.append((time.perf_counter() - start) * 1000) + p95 = _percentile(timings, 95) + median = statistics.median(timings) + msg = f"compute_followup_level: median={median:.0f}ms p95={p95:.0f}ms" + print(f"\n PERF: {msg} (target <50ms)") + self.assertLess(p95, 500) + + def test_send_followup_p95(self): + partner = self.env['res.partner'].create({ + 'name': 'SendPerf', 'email': 'sp@test.local', + }) + timings = [] + for _ in range(5): + start = time.perf_counter() + self.engine.send_followup_email(partner, force=True) + timings.append((time.perf_counter() - start) * 1000) + p95 = _percentile(timings, 95) + median = statistics.median(timings) + msg = f"send_followup_email (no overdue): median={median:.0f}ms p95={p95:.0f}ms" + print(f"\n PERF: {msg} (target <200ms)") + self.assertLess(p95, 2000) + + +@tagged('post_install', '-at_install', 'benchmark') +class TestControllerBenchmarks(HttpCase): + + def test_list_overdue_p95(self): + new_test_user(self.env, login='fu_perf', + groups='base.group_user,account.group_account_invoice,base.group_partner_manager') + for i in range(20): + self.env['res.partner'].create({'name': f'PerfP{i}'}) + self.authenticate('fu_perf', 'fu_perf') + timings = [] + for _ in range(5): + start = time.perf_counter() + response = self.url_open( + '/fusion/followup/list_overdue', + data=json.dumps({'jsonrpc': '2.0', 'method': 'call', 'id': 1, + 'params': {'company_id': self.env.company.id}}), + headers={'Content-Type': 'application/json'}, + ) + timings.append((time.perf_counter() - start) * 1000) + self.assertEqual(response.status_code, 200) + p95 = _percentile(timings, 95) + median = statistics.median(timings) + msg = f"controller.list_overdue: median={median:.0f}ms p95={p95:.0f}ms" + print(f"\n PERF: {msg} (target <500ms)") + self.assertLess(p95, 5000) From 99e4f8e17fe583d3e078ca7f85311821446d8c68 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 21:17:18 -0400 Subject: [PATCH 21/36] feat(fusion_accounting_followup): SCSS foundation for OWL widget Made-with: Cursor --- fusion_accounting_followup/__manifest__.py | 5 +- .../static/src/scss/_variables.scss | 51 +++++ .../static/src/scss/dark_mode.scss | 27 +++ .../static/src/scss/followup.scss | 190 ++++++++++++++++++ 4 files changed, 272 insertions(+), 1 deletion(-) create mode 100644 fusion_accounting_followup/static/src/scss/_variables.scss create mode 100644 fusion_accounting_followup/static/src/scss/dark_mode.scss create mode 100644 fusion_accounting_followup/static/src/scss/followup.scss diff --git a/fusion_accounting_followup/__manifest__.py b/fusion_accounting_followup/__manifest__.py index 14adf203..da7aa950 100644 --- a/fusion_accounting_followup/__manifest__.py +++ b/fusion_accounting_followup/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting Follow-up', - 'version': '19.0.1.0.18', + 'version': '19.0.1.0.19', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.', 'description': """ @@ -37,6 +37,9 @@ menu hides; the engine + AI tools remain available for the chat. ], 'assets': { 'web.assets_backend': [ + 'fusion_accounting_followup/static/src/scss/_variables.scss', + 'fusion_accounting_followup/static/src/scss/followup.scss', + 'fusion_accounting_followup/static/src/scss/dark_mode.scss', ], }, 'installable': True, diff --git a/fusion_accounting_followup/static/src/scss/_variables.scss b/fusion_accounting_followup/static/src/scss/_variables.scss new file mode 100644 index 00000000..cb3d8f85 --- /dev/null +++ b/fusion_accounting_followup/static/src/scss/_variables.scss @@ -0,0 +1,51 @@ +// Fusion follow-up design tokens (extends Phases 1-3 tokens for consistency). + +$fu-bg-primary: #ffffff; +$fu-bg-secondary: #f9fafb; +$fu-bg-tertiary: #f3f4f6; +$fu-border: #e5e7eb; +$fu-text-primary: #111827; +$fu-text-secondary: #6b7280; +$fu-text-muted: #9ca3af; +$fu-accent: #3b82f6; +$fu-accent-bg: #eff6ff; + +// Status colors +$fu-status-no-action: #6b7280; +$fu-status-action-due: #f59e0b; +$fu-status-paused: #6366f1; +$fu-status-blocked: #ef4444; +$fu-status-with-credit: #8b5cf6; + +// Risk band colors +$fu-risk-low: #10b981; +$fu-risk-low-bg: #ecfdf5; +$fu-risk-medium: #f59e0b; +$fu-risk-medium-bg: #fffbeb; +$fu-risk-high: #ef4444; +$fu-risk-high-bg: #fef2f2; +$fu-risk-critical: #b91c1c; +$fu-risk-critical-bg: #fef2f2; + +// Aging bucket colors (escalating intensity) +$fu-bucket-current: #10b981; +$fu-bucket-1-30: #fbbf24; +$fu-bucket-31-60: #f59e0b; +$fu-bucket-61-90: #ef4444; +$fu-bucket-91-120: #dc2626; +$fu-bucket-120-plus: #7f1d1d; + +$fu-space-1: 0.25rem; +$fu-space-2: 0.5rem; +$fu-space-3: 0.75rem; +$fu-space-4: 1rem; +$fu-space-6: 1.5rem; + +$fu-font-size-xs: 0.75rem; +$fu-font-size-sm: 0.875rem; +$fu-font-size-base: 1rem; +$fu-font-size-lg: 1.125rem; +$fu-font-size-xl: 1.25rem; + +$fu-border-radius: 0.375rem; +$fu-border-radius-md: 0.5rem; diff --git a/fusion_accounting_followup/static/src/scss/dark_mode.scss b/fusion_accounting_followup/static/src/scss/dark_mode.scss new file mode 100644 index 00000000..25950570 --- /dev/null +++ b/fusion_accounting_followup/static/src/scss/dark_mode.scss @@ -0,0 +1,27 @@ +// Variables come from _variables.scss (loaded first in the asset bundle). + +[data-color-scheme="dark"] .o_fusion_followup { + background: #1f2937; color: #f9fafb; + + &_header, &_card, .fu-ai-text-panel { + background: #111827; border-color: #374151; color: #f9fafb; + } + + &_card { + &:hover { border-color: #60a5fa; } + &.selected { background: #1e3a8a; border-color: #60a5fa; } + .partner-numbers .label { color: #9ca3af; } + .partner-numbers .value { color: #f9fafb; } + } + + .btn_fu { + background: #374151; border-color: #4b5563; color: #f9fafb; + &:hover { background: #4b5563; } + &.primary { background: #3b82f6; } + } + + .fu-ai-text-panel { + .ai-subject { background: #1e3a8a; } + .ai-body { background: #1f2937; } + } +} diff --git a/fusion_accounting_followup/static/src/scss/followup.scss b/fusion_accounting_followup/static/src/scss/followup.scss new file mode 100644 index 00000000..8962e3dd --- /dev/null +++ b/fusion_accounting_followup/static/src/scss/followup.scss @@ -0,0 +1,190 @@ +// Variables come from _variables.scss (loaded first in the asset bundle). + +.o_fusion_followup { + background: $fu-bg-secondary; + min-height: 100vh; + + &_header { + background: $fu-bg-primary; + border-bottom: 1px solid $fu-border; + padding: $fu-space-4 $fu-space-6; + display: flex; + justify-content: space-between; + align-items: center; + + h1 { font-size: $fu-font-size-xl; margin: 0; } + + .summary { + display: flex; + gap: $fu-space-6; + font-size: $fu-font-size-sm; + color: $fu-text-secondary; + + .summary-value { + font-weight: 600; + color: $fu-text-primary; + margin-left: $fu-space-1; + } + } + } + + &_card { + background: $fu-bg-primary; + border: 1px solid $fu-border; + border-radius: $fu-border-radius-md; + padding: $fu-space-4; + margin-bottom: $fu-space-3; + cursor: pointer; + transition: all 200ms ease-in-out; + + &:hover { + border-color: $fu-accent; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + } + + &.selected { + border-color: $fu-accent; + background: $fu-accent-bg; + } + + &_header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: $fu-space-2; + + .partner-name { + font-weight: 600; + font-size: $fu-font-size-base; + } + } + + .partner-numbers { + display: grid; + grid-template-columns: 1fr 1fr; + gap: $fu-space-2; + font-size: $fu-font-size-sm; + color: $fu-text-secondary; + + .label { font-weight: 500; margin-right: $fu-space-2; } + .value { color: $fu-text-primary; font-weight: 500; } + } + } + + .btn_fu { + padding: $fu-space-2 $fu-space-4; + border-radius: $fu-border-radius; + background: $fu-bg-primary; + border: 1px solid $fu-border; + color: $fu-text-primary; + font-size: $fu-font-size-sm; + cursor: pointer; + + &:hover { background: $fu-bg-tertiary; } + &.primary { background: $fu-accent; border-color: $fu-accent; color: white; + &:hover { background: darken($fu-accent, 8%); } } + &.danger { background: $fu-status-blocked; border-color: $fu-status-blocked; color: white; } + } +} + +.fu-status-badge { + padding: $fu-space-1 $fu-space-2; + border-radius: $fu-border-radius; + font-size: $fu-font-size-xs; + font-weight: 500; + text-transform: uppercase; + + &[data-status="no_action"] { background: lighten($fu-status-no-action, 40%); color: $fu-status-no-action; } + &[data-status="action_due"] { background: lighten($fu-status-action-due, 35%); color: $fu-status-action-due; } + &[data-status="paused"] { background: lighten($fu-status-paused, 35%); color: $fu-status-paused; } + &[data-status="blocked"] { background: lighten($fu-status-blocked, 35%); color: $fu-status-blocked; } + &[data-status="with_credit_team"] { background: lighten($fu-status-with-credit, 35%); color: $fu-status-with-credit; } +} + +.fu-risk-badge { + display: inline-flex; + align-items: center; + padding: $fu-space-1 $fu-space-2; + border-radius: $fu-border-radius; + font-weight: 600; + font-size: $fu-font-size-xs; + + &[data-band="low"] { background: $fu-risk-low-bg; color: $fu-risk-low; } + &[data-band="medium"] { background: $fu-risk-medium-bg; color: $fu-risk-medium; } + &[data-band="high"] { background: $fu-risk-high-bg; color: $fu-risk-high; } + &[data-band="critical"] { background: $fu-risk-critical-bg; color: $fu-risk-critical; font-weight: 700; } +} + +.fu-aging-strip { + display: flex; + gap: 2px; + height: 8px; + border-radius: $fu-border-radius; + overflow: hidden; + margin: $fu-space-2 0; + + .bucket { + height: 100%; + + &[data-name="current"] { background: $fu-bucket-current; } + &[data-name="1_30"] { background: $fu-bucket-1-30; } + &[data-name="31_60"] { background: $fu-bucket-31-60; } + &[data-name="61_90"] { background: $fu-bucket-61-90; } + &[data-name="91_120"] { background: $fu-bucket-91-120; } + &[data-name="120_plus"] { background: $fu-bucket-120-plus; } + } +} + +.fu-ai-text-panel { + background: $fu-bg-primary; + border: 1px solid $fu-border; + border-radius: $fu-border-radius-md; + padding: $fu-space-4; + + h5 { margin: 0 0 $fu-space-2; font-size: $fu-font-size-base; } + + .ai-subject { + font-weight: 600; + margin-bottom: $fu-space-2; + padding: $fu-space-2; + background: $fu-accent-bg; + border-radius: $fu-border-radius; + } + + .ai-body { + white-space: pre-wrap; + font-family: monospace; + font-size: $fu-font-size-sm; + padding: $fu-space-3; + background: $fu-bg-secondary; + border-radius: $fu-border-radius; + max-height: 300px; + overflow-y: auto; + } + + .key-points { + margin-top: $fu-space-3; + font-size: $fu-font-size-sm; + color: $fu-text-secondary; + + ul { margin: 0; padding-left: $fu-space-4; } + } +} + +.fu-history-table { + width: 100%; + font-size: $fu-font-size-sm; + + th { + background: $fu-bg-tertiary; + padding: $fu-space-2 $fu-space-3; + text-align: left; + font-weight: 600; + color: $fu-text-secondary; + } + + td { + padding: $fu-space-2 $fu-space-3; + border-bottom: 1px solid lighten($fu-border, 5%); + } +} From 86bead48e1111a147b3775379470e2f1a3a13475 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 21:17:57 -0400 Subject: [PATCH 22/36] feat(fusion_accounting_followup): followup_service.js reactive frontend service Made-with: Cursor --- fusion_accounting_followup/__manifest__.py | 3 +- .../static/src/services/followup_service.js | 145 ++++++++++++++++++ 2 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 fusion_accounting_followup/static/src/services/followup_service.js diff --git a/fusion_accounting_followup/__manifest__.py b/fusion_accounting_followup/__manifest__.py index da7aa950..fae8c64b 100644 --- a/fusion_accounting_followup/__manifest__.py +++ b/fusion_accounting_followup/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting Follow-up', - 'version': '19.0.1.0.19', + 'version': '19.0.1.0.20', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.', 'description': """ @@ -40,6 +40,7 @@ menu hides; the engine + AI tools remain available for the chat. 'fusion_accounting_followup/static/src/scss/_variables.scss', 'fusion_accounting_followup/static/src/scss/followup.scss', 'fusion_accounting_followup/static/src/scss/dark_mode.scss', + 'fusion_accounting_followup/static/src/services/followup_service.js', ], }, 'installable': True, diff --git a/fusion_accounting_followup/static/src/services/followup_service.js b/fusion_accounting_followup/static/src/services/followup_service.js new file mode 100644 index 00000000..03d3c659 --- /dev/null +++ b/fusion_accounting_followup/static/src/services/followup_service.js @@ -0,0 +1,145 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { reactive } from "@odoo/owl"; + +const ENDPOINT_BASE = "/fusion/followup"; + +export class FollowupService { + constructor(env, services) { + this.env = env; + this.rpc = services.rpc; + this.notification = services.notification; + + this.state = reactive({ + partners: [], + count: 0, + total: 0, + statusFilter: null, + isLoading: false, + isProcessing: false, + selectedPartnerId: null, + selectedDetail: null, + companyId: null, + limit: 50, + offset: 0, + generatedText: null, + }); + } + + async loadOverdue(companyId = null) { + this.state.companyId = companyId; + this.state.isLoading = true; + try { + const result = await this.rpc(`${ENDPOINT_BASE}/list_overdue`, { + status: this.state.statusFilter, + limit: this.state.limit, + offset: this.state.offset, + company_id: companyId, + }); + this.state.partners = result.partners; + this.state.count = result.count; + this.state.total = result.total; + } finally { + this.state.isLoading = false; + } + } + + async selectPartner(partnerId) { + this.state.selectedPartnerId = partnerId; + this.state.selectedDetail = null; + this.state.generatedText = null; + try { + this.state.selectedDetail = await this.rpc(`${ENDPOINT_BASE}/get_partner_detail`, { + partner_id: partnerId, + }); + } catch (err) { + this.notification.add(`Failed to load partner detail: ${err.message || err}`, { type: "danger" }); + } + } + + async generateText(partnerId, levelId = null, forceRegenerate = false) { + this.state.isProcessing = true; + try { + this.state.generatedText = await this.rpc(`${ENDPOINT_BASE}/generate_text`, { + partner_id: partnerId, level_id: levelId, + force_regenerate: forceRegenerate, + }); + return this.state.generatedText; + } catch (err) { + this.notification.add(`Generate failed: ${err.message || err}`, { type: "danger" }); + throw err; + } finally { + this.state.isProcessing = false; + } + } + + async sendFollowup(partnerId, levelId = null, force = false) { + this.state.isProcessing = true; + try { + const result = await this.rpc(`${ENDPOINT_BASE}/send`, { + partner_id: partnerId, level_id: levelId, force: force, + }); + const status = result.status || "unknown"; + const type = status === "sent" ? "success" : status.startsWith("paused") ? "warning" : "info"; + this.notification.add(`Send result: ${status}`, { type: type }); + if (this.state.selectedPartnerId === partnerId) { + await this.selectPartner(partnerId); + } + await this.loadOverdue(this.state.companyId); + return result; + } catch (err) { + this.notification.add(`Send failed: ${err.message || err}`, { type: "danger" }); + throw err; + } finally { + this.state.isProcessing = false; + } + } + + async pausePartner(partnerId, untilDate = null) { + try { + const result = await this.rpc(`${ENDPOINT_BASE}/pause`, { + partner_id: partnerId, until_date: untilDate, + }); + this.notification.add(`Paused until ${result.paused_until}`, { type: "info" }); + if (this.state.selectedPartnerId === partnerId) { + await this.selectPartner(partnerId); + } + await this.loadOverdue(this.state.companyId); + return result; + } catch (err) { + this.notification.add(`Pause failed: ${err.message || err}`, { type: "danger" }); + throw err; + } + } + + async resetPartner(partnerId) { + try { + const result = await this.rpc(`${ENDPOINT_BASE}/reset`, { + partner_id: partnerId, + }); + this.notification.add(`Reset`, { type: "info" }); + if (this.state.selectedPartnerId === partnerId) { + await this.selectPartner(partnerId); + } + await this.loadOverdue(this.state.companyId); + return result; + } catch (err) { + this.notification.add(`Reset failed: ${err.message || err}`, { type: "danger" }); + throw err; + } + } + + setStatusFilter(status) { + this.state.statusFilter = status; + this.state.offset = 0; + this.loadOverdue(this.state.companyId); + } +} + +export const followupService = { + dependencies: ["rpc", "notification"], + start(env, services) { return new FollowupService(env, services); }, +}; + +registry.category("services").add("fusion_followup", followupService); From 21f6171162a774dceedae7c3b753d6eb29ac5200 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 21:18:59 -0400 Subject: [PATCH 23/36] feat(fusion_accounting_followup): top-level followup_dashboard component Made-with: Cursor --- fusion_accounting_followup/__manifest__.py | 5 +- .../followup_dashboard/followup_dashboard.js | 69 +++++++++++++++++++ .../followup_dashboard/followup_dashboard.xml | 65 +++++++++++++++++ .../followup_dashboard_view.js | 14 ++++ 4 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 fusion_accounting_followup/static/src/views/followup_dashboard/followup_dashboard.js create mode 100644 fusion_accounting_followup/static/src/views/followup_dashboard/followup_dashboard.xml create mode 100644 fusion_accounting_followup/static/src/views/followup_dashboard/followup_dashboard_view.js diff --git a/fusion_accounting_followup/__manifest__.py b/fusion_accounting_followup/__manifest__.py index fae8c64b..7e8542f8 100644 --- a/fusion_accounting_followup/__manifest__.py +++ b/fusion_accounting_followup/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting Follow-up', - 'version': '19.0.1.0.20', + 'version': '19.0.1.0.21', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.', 'description': """ @@ -41,6 +41,9 @@ menu hides; the engine + AI tools remain available for the chat. 'fusion_accounting_followup/static/src/scss/followup.scss', 'fusion_accounting_followup/static/src/scss/dark_mode.scss', 'fusion_accounting_followup/static/src/services/followup_service.js', + 'fusion_accounting_followup/static/src/views/followup_dashboard/followup_dashboard.js', + 'fusion_accounting_followup/static/src/views/followup_dashboard/followup_dashboard.xml', + 'fusion_accounting_followup/static/src/views/followup_dashboard/followup_dashboard_view.js', ], }, 'installable': True, diff --git a/fusion_accounting_followup/static/src/views/followup_dashboard/followup_dashboard.js b/fusion_accounting_followup/static/src/views/followup_dashboard/followup_dashboard.js new file mode 100644 index 00000000..274b16be --- /dev/null +++ b/fusion_accounting_followup/static/src/views/followup_dashboard/followup_dashboard.js @@ -0,0 +1,69 @@ +/** @odoo-module **/ + +import { Component, useState, onWillStart } from "@odoo/owl"; +import { useService } from "@web/core/utils/hooks"; +import { PartnerCard } from "../../components/partner_card/partner_card"; +import { AgingBucketStrip } from "../../components/aging_bucket_strip/aging_bucket_strip"; +import { RiskBadge } from "../../components/risk_badge/risk_badge"; +import { AiTextPanel } from "../../components/ai_text_panel/ai_text_panel"; +import { FollowupHistoryTable } from "../../components/followup_history_table/followup_history_table"; + +export class FollowupDashboard extends Component { + static template = "fusion_accounting_followup.FollowupDashboard"; + static props = { "*": true }; + static components = { PartnerCard, AgingBucketStrip, RiskBadge, AiTextPanel, FollowupHistoryTable }; + + setup() { + this.followup = useService("fusion_followup"); + this.state = useState(this.followup.state); + + const companyId = this.env.services.user?.context?.allowed_company_ids?.[0]; + + onWillStart(async () => { + await this.followup.loadOverdue(companyId); + }); + } + + onSelectPartner(partnerId) { + this.followup.selectPartner(partnerId); + } + + onStatusFilter(status) { + this.followup.setStatusFilter(status || null); + } + + async onGenerateText() { + if (!this.state.selectedPartnerId) return; + await this.followup.generateText(this.state.selectedPartnerId); + } + + async onSend() { + if (!this.state.selectedPartnerId) return; + await this.followup.sendFollowup(this.state.selectedPartnerId, null, true); + } + + async onPause() { + if (!this.state.selectedPartnerId) return; + const days = parseInt(prompt("Pause for how many days?", "30")); + if (isNaN(days)) return; + const until = new Date(); + until.setDate(until.getDate() + days); + await this.followup.pausePartner( + this.state.selectedPartnerId, until.toISOString().slice(0, 10)); + } + + async onReset() { + if (!this.state.selectedPartnerId) return; + await this.followup.resetPartner(this.state.selectedPartnerId); + } + + formatCurrency(amount) { + return new Intl.NumberFormat(undefined, { + minimumFractionDigits: 2, maximumFractionDigits: 2, + }).format(amount || 0); + } + + get totalOverdue() { + return this.state.partners.reduce((sum, p) => sum + (p.overdue_amount || 0), 0); + } +} diff --git a/fusion_accounting_followup/static/src/views/followup_dashboard/followup_dashboard.xml b/fusion_accounting_followup/static/src/views/followup_dashboard/followup_dashboard.xml new file mode 100644 index 00000000..1af84ada --- /dev/null +++ b/fusion_accounting_followup/static/src/views/followup_dashboard/followup_dashboard.xml @@ -0,0 +1,65 @@ + + + + +
+
+
+

Customer Follow-ups

+
of partners with overdue
+
+
+
Total overdue: $
+
+
+ +
+ + + + +
+ +
+
+
Loading...
+
No overdue partners.
+
+ +
+
+
+
+

+
+ +
+
+ +
+ +
+ + + + +
+ + +
+
Select a partner.
+
+
+
+
+ +
diff --git a/fusion_accounting_followup/static/src/views/followup_dashboard/followup_dashboard_view.js b/fusion_accounting_followup/static/src/views/followup_dashboard/followup_dashboard_view.js new file mode 100644 index 00000000..5e22b538 --- /dev/null +++ b/fusion_accounting_followup/static/src/views/followup_dashboard/followup_dashboard_view.js @@ -0,0 +1,14 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { FollowupDashboard } from "./followup_dashboard"; + +export const fusionFollowupDashboardView = { + type: "fusion_followup", + Controller: FollowupDashboard, + display_name: "Fusion Customer Follow-ups", + icon: "fa-bell", + multiRecord: true, +}; + +registry.category("views").add("fusion_followup", fusionFollowupDashboardView); From da746698c5338f61797fec988cb06cefbb6a9372 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 21:19:52 -0400 Subject: [PATCH 24/36] feat(fusion_accounting_followup): partner_card + aging_bucket_strip + risk_badge components Made-with: Cursor --- fusion_accounting_followup/__manifest__.py | 8 +++- .../aging_bucket_strip/aging_bucket_strip.js | 15 ++++++++ .../aging_bucket_strip/aging_bucket_strip.xml | 22 +++++++++++ .../components/partner_card/partner_card.js | 15 ++++++++ .../components/partner_card/partner_card.xml | 37 +++++++++++++++++++ .../src/components/risk_badge/risk_badge.js | 11 ++++++ .../src/components/risk_badge/risk_badge.xml | 11 ++++++ 7 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 fusion_accounting_followup/static/src/components/aging_bucket_strip/aging_bucket_strip.js create mode 100644 fusion_accounting_followup/static/src/components/aging_bucket_strip/aging_bucket_strip.xml create mode 100644 fusion_accounting_followup/static/src/components/partner_card/partner_card.js create mode 100644 fusion_accounting_followup/static/src/components/partner_card/partner_card.xml create mode 100644 fusion_accounting_followup/static/src/components/risk_badge/risk_badge.js create mode 100644 fusion_accounting_followup/static/src/components/risk_badge/risk_badge.xml diff --git a/fusion_accounting_followup/__manifest__.py b/fusion_accounting_followup/__manifest__.py index 7e8542f8..5429dd1b 100644 --- a/fusion_accounting_followup/__manifest__.py +++ b/fusion_accounting_followup/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting Follow-up', - 'version': '19.0.1.0.21', + 'version': '19.0.1.0.22', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.', 'description': """ @@ -44,6 +44,12 @@ menu hides; the engine + AI tools remain available for the chat. 'fusion_accounting_followup/static/src/views/followup_dashboard/followup_dashboard.js', 'fusion_accounting_followup/static/src/views/followup_dashboard/followup_dashboard.xml', 'fusion_accounting_followup/static/src/views/followup_dashboard/followup_dashboard_view.js', + 'fusion_accounting_followup/static/src/components/risk_badge/risk_badge.js', + 'fusion_accounting_followup/static/src/components/risk_badge/risk_badge.xml', + 'fusion_accounting_followup/static/src/components/partner_card/partner_card.js', + 'fusion_accounting_followup/static/src/components/partner_card/partner_card.xml', + 'fusion_accounting_followup/static/src/components/aging_bucket_strip/aging_bucket_strip.js', + 'fusion_accounting_followup/static/src/components/aging_bucket_strip/aging_bucket_strip.xml', ], }, 'installable': True, diff --git a/fusion_accounting_followup/static/src/components/aging_bucket_strip/aging_bucket_strip.js b/fusion_accounting_followup/static/src/components/aging_bucket_strip/aging_bucket_strip.js new file mode 100644 index 00000000..c511a2fe --- /dev/null +++ b/fusion_accounting_followup/static/src/components/aging_bucket_strip/aging_bucket_strip.js @@ -0,0 +1,15 @@ +/** @odoo-module **/ + +import { Component } from "@odoo/owl"; + +export class AgingBucketStrip extends Component { + static template = "fusion_accounting_followup.AgingBucketStrip"; + static props = { + aging: { type: Object }, + }; + + bucketWidth(bucket) { + const total = this.props.aging.total_amount || 1; + return ((bucket.amount / total) * 100).toFixed(2) + "%"; + } +} diff --git a/fusion_accounting_followup/static/src/components/aging_bucket_strip/aging_bucket_strip.xml b/fusion_accounting_followup/static/src/components/aging_bucket_strip/aging_bucket_strip.xml new file mode 100644 index 00000000..a48223c4 --- /dev/null +++ b/fusion_accounting_followup/static/src/components/aging_bucket_strip/aging_bucket_strip.xml @@ -0,0 +1,22 @@ + + + + +
+
+
+
+
+ Current + 30 + 60 + 90 + 120+ +
+
+ + + diff --git a/fusion_accounting_followup/static/src/components/partner_card/partner_card.js b/fusion_accounting_followup/static/src/components/partner_card/partner_card.js new file mode 100644 index 00000000..a8467048 --- /dev/null +++ b/fusion_accounting_followup/static/src/components/partner_card/partner_card.js @@ -0,0 +1,15 @@ +/** @odoo-module **/ + +import { Component } from "@odoo/owl"; +import { RiskBadge } from "../risk_badge/risk_badge"; + +export class PartnerCard extends Component { + static template = "fusion_accounting_followup.PartnerCard"; + static props = { + partner: { type: Object }, + selected: { type: Boolean, optional: true }, + onSelect: { type: Function }, + formatCurrency: { type: Function }, + }; + static components = { RiskBadge }; +} diff --git a/fusion_accounting_followup/static/src/components/partner_card/partner_card.xml b/fusion_accounting_followup/static/src/components/partner_card/partner_card.xml new file mode 100644 index 00000000..a83d6c1a --- /dev/null +++ b/fusion_accounting_followup/static/src/components/partner_card/partner_card.xml @@ -0,0 +1,37 @@ + + + + +
+
+
+
+ + + +
+
+
+
+ Overdue: + $ +
+
+ Lines: + +
+
+ Risk: + +
+
+ Last: + +
+
+
+
+ +
diff --git a/fusion_accounting_followup/static/src/components/risk_badge/risk_badge.js b/fusion_accounting_followup/static/src/components/risk_badge/risk_badge.js new file mode 100644 index 00000000..03260b78 --- /dev/null +++ b/fusion_accounting_followup/static/src/components/risk_badge/risk_badge.js @@ -0,0 +1,11 @@ +/** @odoo-module **/ + +import { Component } from "@odoo/owl"; + +export class RiskBadge extends Component { + static template = "fusion_accounting_followup.RiskBadge"; + static props = { + band: { type: String, optional: true }, + score: { type: Number, optional: true }, + }; +} diff --git a/fusion_accounting_followup/static/src/components/risk_badge/risk_badge.xml b/fusion_accounting_followup/static/src/components/risk_badge/risk_badge.xml new file mode 100644 index 00000000..3e6fe303 --- /dev/null +++ b/fusion_accounting_followup/static/src/components/risk_badge/risk_badge.xml @@ -0,0 +1,11 @@ + + + + + + + () + + + + From 474485f96370b0e490929b24ac005eca2bae5a91 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 21:20:51 -0400 Subject: [PATCH 25/36] feat(fusion_accounting_followup): ai_text_panel + followup_history_table components Made-with: Cursor --- fusion_accounting_followup/__manifest__.py | 6 +++- .../components/ai_text_panel/ai_text_panel.js | 10 ++++++ .../ai_text_panel/ai_text_panel.xml | 27 +++++++++++++++ .../followup_history_table.js | 15 +++++++++ .../followup_history_table.xml | 33 +++++++++++++++++++ 5 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 fusion_accounting_followup/static/src/components/ai_text_panel/ai_text_panel.js create mode 100644 fusion_accounting_followup/static/src/components/ai_text_panel/ai_text_panel.xml create mode 100644 fusion_accounting_followup/static/src/components/followup_history_table/followup_history_table.js create mode 100644 fusion_accounting_followup/static/src/components/followup_history_table/followup_history_table.xml diff --git a/fusion_accounting_followup/__manifest__.py b/fusion_accounting_followup/__manifest__.py index 5429dd1b..6780af31 100644 --- a/fusion_accounting_followup/__manifest__.py +++ b/fusion_accounting_followup/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting Follow-up', - 'version': '19.0.1.0.22', + 'version': '19.0.1.0.23', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.', 'description': """ @@ -50,6 +50,10 @@ menu hides; the engine + AI tools remain available for the chat. 'fusion_accounting_followup/static/src/components/partner_card/partner_card.xml', 'fusion_accounting_followup/static/src/components/aging_bucket_strip/aging_bucket_strip.js', 'fusion_accounting_followup/static/src/components/aging_bucket_strip/aging_bucket_strip.xml', + 'fusion_accounting_followup/static/src/components/ai_text_panel/ai_text_panel.js', + 'fusion_accounting_followup/static/src/components/ai_text_panel/ai_text_panel.xml', + 'fusion_accounting_followup/static/src/components/followup_history_table/followup_history_table.js', + 'fusion_accounting_followup/static/src/components/followup_history_table/followup_history_table.xml', ], }, 'installable': True, diff --git a/fusion_accounting_followup/static/src/components/ai_text_panel/ai_text_panel.js b/fusion_accounting_followup/static/src/components/ai_text_panel/ai_text_panel.js new file mode 100644 index 00000000..2c532d01 --- /dev/null +++ b/fusion_accounting_followup/static/src/components/ai_text_panel/ai_text_panel.js @@ -0,0 +1,10 @@ +/** @odoo-module **/ + +import { Component } from "@odoo/owl"; + +export class AiTextPanel extends Component { + static template = "fusion_accounting_followup.AiTextPanel"; + static props = { + text: { type: Object }, + }; +} diff --git a/fusion_accounting_followup/static/src/components/ai_text_panel/ai_text_panel.xml b/fusion_accounting_followup/static/src/components/ai_text_panel/ai_text_panel.xml new file mode 100644 index 00000000..6d64923b --- /dev/null +++ b/fusion_accounting_followup/static/src/components/ai_text_panel/ai_text_panel.xml @@ -0,0 +1,27 @@ + + + + +
+
AI-Generated Follow-up Text
+
+ Subject: +
+
+ +
+
+ Key points: +
    +
  • + +
  • +
+
+
+ Tone used: +
+
+
+ +
diff --git a/fusion_accounting_followup/static/src/components/followup_history_table/followup_history_table.js b/fusion_accounting_followup/static/src/components/followup_history_table/followup_history_table.js new file mode 100644 index 00000000..0c6a714d --- /dev/null +++ b/fusion_accounting_followup/static/src/components/followup_history_table/followup_history_table.js @@ -0,0 +1,15 @@ +/** @odoo-module **/ + +import { Component } from "@odoo/owl"; + +export class FollowupHistoryTable extends Component { + static template = "fusion_accounting_followup.FollowupHistoryTable"; + static props = { + history: { type: Object }, + }; + + formatDate(s) { + if (!s) return ""; + return s.slice(0, 10); + } +} diff --git a/fusion_accounting_followup/static/src/components/followup_history_table/followup_history_table.xml b/fusion_accounting_followup/static/src/components/followup_history_table/followup_history_table.xml new file mode 100644 index 00000000..e4f83eba --- /dev/null +++ b/fusion_accounting_followup/static/src/components/followup_history_table/followup_history_table.xml @@ -0,0 +1,33 @@ + + + + +
+
Follow-up History ()
+ + + + + + + + + + + + + + + + + + + +
DateLevelToneStateOverdue
+ $ +
+
No history yet.
+
+
+ +
From ab3fcc56db6159e226eed2d92289b8226a1092c6 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 21:27:33 -0400 Subject: [PATCH 26/36] feat(fusion_accounting_followup): seed 3 default follow-up levels Made-with: Cursor --- fusion_accounting_followup/__manifest__.py | 3 +- .../data/followup_levels_data.xml | 32 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 fusion_accounting_followup/data/followup_levels_data.xml diff --git a/fusion_accounting_followup/__manifest__.py b/fusion_accounting_followup/__manifest__.py index 6780af31..f02b8500 100644 --- a/fusion_accounting_followup/__manifest__.py +++ b/fusion_accounting_followup/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting Follow-up', - 'version': '19.0.1.0.23', + 'version': '19.0.1.0.24', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.', 'description': """ @@ -34,6 +34,7 @@ menu hides; the engine + AI tools remain available for the chat. 'data': [ 'security/ir.model.access.csv', 'data/cron.xml', + 'data/followup_levels_data.xml', ], 'assets': { 'web.assets_backend': [ diff --git a/fusion_accounting_followup/data/followup_levels_data.xml b/fusion_accounting_followup/data/followup_levels_data.xml new file mode 100644 index 00000000..10c1de73 --- /dev/null +++ b/fusion_accounting_followup/data/followup_levels_data.xml @@ -0,0 +1,32 @@ + + + + + Friendly Reminder + 1 + 7 + gentle + First contact - friendly reminder of overdue invoice. + + + + + Firm Warning + 2 + 30 + firm + Second contact - clear request for immediate action. + + + + + Legal Notice + 3 + 60 + legal + Final notice before referring to collections. + + + + + From 4ee261e1895a37cb720354c0195769a1155835c2 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 21:27:59 -0400 Subject: [PATCH 27/36] feat(fusion_accounting_followup): default mail templates for 3 escalation levels Made-with: Cursor --- fusion_accounting_followup/__manifest__.py | 3 +- .../data/mail_templates_data.xml | 85 +++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 fusion_accounting_followup/data/mail_templates_data.xml diff --git a/fusion_accounting_followup/__manifest__.py b/fusion_accounting_followup/__manifest__.py index f02b8500..4f83692d 100644 --- a/fusion_accounting_followup/__manifest__.py +++ b/fusion_accounting_followup/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting Follow-up', - 'version': '19.0.1.0.24', + 'version': '19.0.1.0.25', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.', 'description': """ @@ -35,6 +35,7 @@ menu hides; the engine + AI tools remain available for the chat. 'security/ir.model.access.csv', 'data/cron.xml', 'data/followup_levels_data.xml', + 'data/mail_templates_data.xml', ], 'assets': { 'web.assets_backend': [ diff --git a/fusion_accounting_followup/data/mail_templates_data.xml b/fusion_accounting_followup/data/mail_templates_data.xml new file mode 100644 index 00000000..0552c850 --- /dev/null +++ b/fusion_accounting_followup/data/mail_templates_data.xml @@ -0,0 +1,85 @@ + + + + + Fusion Followup: Friendly Reminder + + Friendly reminder: invoice payment + {{ user.email_formatted }} + {{ object.email }} + +
+

Dear ,

+

This is a friendly reminder that you have outstanding invoices on + your account. We understand that things happen — please let us know + if there is anything we can do to help resolve this.

+

You can review your account statement at any time, or contact our + accounts receivable team with any questions.

+

Best regards,
+

+
+
+ {{ object.lang }} + +
+ + + Fusion Followup: Firm Warning + + Outstanding invoices — action required + {{ user.email_formatted }} + {{ object.email }} + +
+

Dear ,

+

Our records show outstanding invoices that require your immediate + attention. We request that you remit payment as soon as possible to + avoid further escalation.

+

If you have already remitted payment, please disregard this notice + and contact us with the payment details so we can update our records.

+

If there are any disputes or concerns regarding these invoices, + please contact our accounts receivable team immediately.

+

Regards,
+

+
+
+ {{ object.lang }} + +
+ + + Fusion Followup: Legal Notice + + FINAL NOTICE — outstanding balance + {{ user.email_formatted }} + {{ object.email }} + +
+

Dear ,

+

This is a FINAL NOTICE regarding outstanding invoices on your + account. Despite previous reminders, your balance remains unpaid.

+

If full payment is not received within 7 days from the date of this + notice, we will be forced to refer this matter to our legal department + for collection. This may include reporting the delinquency to credit + bureaus and pursuing further legal action as permitted by law.

+

Please contact us immediately to resolve this matter.

+

Regards,
+

+
+
+ {{ object.lang }} + +
+ + + + + + + + + + + + +
From 2ec90a50b0085eac6fcf853c016cc8ee60c2b2dc Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 21:28:58 -0400 Subject: [PATCH 28/36] feat(fusion_accounting_followup): batch send follow-ups wizard Made-with: Cursor --- fusion_accounting_followup/__manifest__.py | 3 +- .../security/ir.model.access.csv | 1 + fusion_accounting_followup/tests/__init__.py | 1 + .../tests/test_batch_followup_wizard.py | 37 ++++++++ .../wizards/__init__.py | 1 + .../wizards/batch_followup_wizard.py | 91 +++++++++++++++++++ .../wizards/batch_followup_wizard_views.xml | 44 +++++++++ 7 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 fusion_accounting_followup/tests/test_batch_followup_wizard.py create mode 100644 fusion_accounting_followup/wizards/batch_followup_wizard.py create mode 100644 fusion_accounting_followup/wizards/batch_followup_wizard_views.xml diff --git a/fusion_accounting_followup/__manifest__.py b/fusion_accounting_followup/__manifest__.py index 4f83692d..3b1a4802 100644 --- a/fusion_accounting_followup/__manifest__.py +++ b/fusion_accounting_followup/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting Follow-up', - 'version': '19.0.1.0.25', + 'version': '19.0.1.0.26', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.', 'description': """ @@ -36,6 +36,7 @@ menu hides; the engine + AI tools remain available for the chat. 'data/cron.xml', 'data/followup_levels_data.xml', 'data/mail_templates_data.xml', + 'wizards/batch_followup_wizard_views.xml', ], 'assets': { 'web.assets_backend': [ diff --git a/fusion_accounting_followup/security/ir.model.access.csv b/fusion_accounting_followup/security/ir.model.access.csv index 04690ebc..fa38cd4d 100644 --- a/fusion_accounting_followup/security/ir.model.access.csv +++ b/fusion_accounting_followup/security/ir.model.access.csv @@ -5,3 +5,4 @@ access_fusion_followup_run_user,fusion.followup.run.user,model_fusion_followup_r access_fusion_followup_run_admin,fusion.followup.run.admin,model_fusion_followup_run,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1 access_fusion_followup_text_cache_user,fusion.followup.text.cache.user,model_fusion_followup_text_cache,base.group_user,1,0,0,0 access_fusion_followup_text_cache_admin,fusion.followup.text.cache.admin,model_fusion_followup_text_cache,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1 +access_fusion_batch_followup_wizard_user,fusion.batch.followup.wizard.user,model_fusion_batch_followup_wizard,base.group_user,1,1,1,0 diff --git a/fusion_accounting_followup/tests/__init__.py b/fusion_accounting_followup/tests/__init__.py index 8bb6e435..5e4e01db 100644 --- a/fusion_accounting_followup/tests/__init__.py +++ b/fusion_accounting_followup/tests/__init__.py @@ -17,3 +17,4 @@ from . import test_followup_cron from . import test_engine_property from . import test_followup_full_flow from . import test_performance_benchmarks +from . import test_batch_followup_wizard diff --git a/fusion_accounting_followup/tests/test_batch_followup_wizard.py b/fusion_accounting_followup/tests/test_batch_followup_wizard.py new file mode 100644 index 00000000..dc35f275 --- /dev/null +++ b/fusion_accounting_followup/tests/test_batch_followup_wizard.py @@ -0,0 +1,37 @@ +from odoo.tests.common import TransactionCase +from odoo.tests import tagged +from odoo.exceptions import UserError + + +@tagged('post_install', '-at_install') +class TestBatchFollowupWizard(TransactionCase): + + def test_default_loads_active_ids(self): + partners = self.env['res.partner'].create([ + {'name': 'B1'}, {'name': 'B2'}, + ]) + wizard = self.env['fusion.batch.followup.wizard'].with_context( + active_model='res.partner', active_ids=partners.ids, + ).create({}) + self.assertEqual(set(wizard.partner_ids.ids), set(partners.ids)) + + def test_selected_scope_no_partners_raises(self): + wizard = self.env['fusion.batch.followup.wizard'].create({ + 'scope': 'selected', 'partner_ids': [(6, 0, [])], + }) + with self.assertRaises(UserError): + wizard.action_run() + + def test_run_completes_with_no_overdue_partners(self): + partners = self.env['res.partner'].create([ + {'name': 'NoOverdue1'}, {'name': 'NoOverdue2'}, + ]) + wizard = self.env['fusion.batch.followup.wizard'].create({ + 'scope': 'selected', + 'partner_ids': [(6, 0, partners.ids)], + 'force': True, + }) + wizard.action_run() + self.assertEqual(wizard.state, 'done') + # 2 partners with no overdue → both skipped + self.assertEqual(wizard.skipped_count, 2) diff --git a/fusion_accounting_followup/wizards/__init__.py b/fusion_accounting_followup/wizards/__init__.py index e69de29b..a388a168 100644 --- a/fusion_accounting_followup/wizards/__init__.py +++ b/fusion_accounting_followup/wizards/__init__.py @@ -0,0 +1 @@ +from . import batch_followup_wizard diff --git a/fusion_accounting_followup/wizards/batch_followup_wizard.py b/fusion_accounting_followup/wizards/batch_followup_wizard.py new file mode 100644 index 00000000..44a0ddb6 --- /dev/null +++ b/fusion_accounting_followup/wizards/batch_followup_wizard.py @@ -0,0 +1,91 @@ +"""Batch send follow-ups to selected partners (or all overdue).""" + +from datetime import date + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +class FusionBatchFollowupWizard(models.TransientModel): + _name = "fusion.batch.followup.wizard" + _description = "Batch Send Follow-ups Wizard" + + scope = fields.Selection([ + ('selected', 'Selected partners only'), + ('all_overdue', 'All overdue partners'), + ], required=True, default='selected') + partner_ids = fields.Many2many('res.partner', + default=lambda self: self._default_partner_ids()) + force = fields.Boolean(string='Force (override pause + manual review)', + default=False) + auto_resolve_level = fields.Boolean( + string='Auto-resolve level', + default=True, + help="If True, engine picks the appropriate level per partner. " + "If False, use the chosen override level for all.") + override_level_id = fields.Many2one('fusion.followup.level') + + # Results + state = fields.Selection([('draft', 'Draft'), ('done', 'Done')], default='draft') + sent_count = fields.Integer(readonly=True) + skipped_count = fields.Integer(readonly=True) + error_count = fields.Integer(readonly=True) + summary = fields.Text(readonly=True) + + @api.model + def _default_partner_ids(self): + ctx = self.env.context + if ctx.get('active_model') == 'res.partner': + return ctx.get('active_ids', []) + return [] + + def action_run(self): + self.ensure_one() + if self.scope == 'selected' and not self.partner_ids: + raise UserError(_("No partners selected.")) + + partners = self.partner_ids + if self.scope == 'all_overdue': + Line = self.env['account.move.line'].sudo() + overdue_partner_ids = Line.search([ + ('parent_state', '=', 'posted'), + ('account_id.account_type', '=', 'asset_receivable'), + ('reconciled', '=', False), + ('amount_residual', '>', 0), + ('date_maturity', '<', date.today()), + ('company_id', '=', self.env.company.id), + ]).mapped('partner_id').ids + partners = self.env['res.partner'].sudo().browse(overdue_partner_ids) + + engine = self.env['fusion.followup.engine'] + sent = 0 + skipped = 0 + errors = [] + for partner in partners: + try: + with self.env.cr.savepoint(): + level = self.override_level_id if not self.auto_resolve_level else None + result = engine.send_followup_email( + partner, level=level, force=self.force) + status = result.get('status', '') + if status == 'sent': + sent += 1 + else: + skipped += 1 + except Exception as e: + errors.append(f"{partner.name}: {e}") + + self.write({ + 'state': 'done', + 'sent_count': sent, + 'skipped_count': skipped, + 'error_count': len(errors), + 'summary': '\n'.join(errors[:20]) if errors else False, + }) + return { + 'type': 'ir.actions.act_window', + 'res_model': self._name, + 'res_id': self.id, + 'view_mode': 'form', + 'target': 'new', + } diff --git a/fusion_accounting_followup/wizards/batch_followup_wizard_views.xml b/fusion_accounting_followup/wizards/batch_followup_wizard_views.xml new file mode 100644 index 00000000..538a720b --- /dev/null +++ b/fusion_accounting_followup/wizards/batch_followup_wizard_views.xml @@ -0,0 +1,44 @@ + + + + fusion.batch.followup.wizard.form + fusion.batch.followup.wizard + +
+ + + + + + + + + + + + + + +
+
+ +
+
+ + + Batch Send Follow-ups + fusion.batch.followup.wizard + form + new + + list + +
From 38a2684782fb3f38f30fd84b12fa518f415c2929 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 21:29:38 -0400 Subject: [PATCH 29/36] feat(fusion_accounting_followup): migration wizard backfill from account_followup Made-with: Cursor --- fusion_accounting_followup/__manifest__.py | 3 +- fusion_accounting_followup/models/__init__.py | 1 + .../models/fusion_migration_wizard.py | 80 +++++++++++++++++++ fusion_accounting_followup/tests/__init__.py | 1 + .../tests/test_migration_round_trip.py | 21 +++++ 5 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 fusion_accounting_followup/models/fusion_migration_wizard.py create mode 100644 fusion_accounting_followup/tests/test_migration_round_trip.py diff --git a/fusion_accounting_followup/__manifest__.py b/fusion_accounting_followup/__manifest__.py index 3b1a4802..26540b4e 100644 --- a/fusion_accounting_followup/__manifest__.py +++ b/fusion_accounting_followup/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting Follow-up', - 'version': '19.0.1.0.26', + 'version': '19.0.1.0.27', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.', 'description': """ @@ -28,6 +28,7 @@ menu hides; the engine + AI tools remain available for the chat. 'depends': [ 'fusion_accounting_core', 'fusion_accounting_ai', + 'fusion_accounting_migration', 'account', 'mail', ], diff --git a/fusion_accounting_followup/models/__init__.py b/fusion_accounting_followup/models/__init__.py index 216dd595..df3570c7 100644 --- a/fusion_accounting_followup/models/__init__.py +++ b/fusion_accounting_followup/models/__init__.py @@ -5,3 +5,4 @@ from . import res_partner from . import account_move_line from . import fusion_followup_engine from . import fusion_followup_cron +from . import fusion_migration_wizard diff --git a/fusion_accounting_followup/models/fusion_migration_wizard.py b/fusion_accounting_followup/models/fusion_migration_wizard.py new file mode 100644 index 00000000..b2bbea30 --- /dev/null +++ b/fusion_accounting_followup/models/fusion_migration_wizard.py @@ -0,0 +1,80 @@ +"""Followup-specific migration step. + +Backfills fusion.followup.level from Enterprise's account_followup.followup.line +records (if Enterprise account_followup is installed).""" + +import logging + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +class FusionMigrationWizard(models.TransientModel): + _inherit = "fusion.migration.wizard" + + def _followup_bootstrap_step(self): + """Backfill fusion.followup.level from account_followup.followup.line.""" + result = { + 'step': 'followup_bootstrap', + 'enterprise_module_present': False, + 'created': 0, 'skipped': 0, 'errors': [], + } + + # Enterprise's followup model — name varies by version + EnterpriseLine = self.env.get('account_followup.followup.line') + if EnterpriseLine is None: + EnterpriseLine = self.env.get('account.followup.line') + if EnterpriseLine is None: + result['enterprise_module_present'] = False + return result + result['enterprise_module_present'] = True + + FusionLevel = self.env['fusion.followup.level'].sudo() + try: + ee_records = EnterpriseLine.sudo().search([]) + except Exception as e: + result['errors'].append(f"Enterprise search failed: {e}") + return result + + # Map Enterprise tone-ish fields to ours + for ee in ee_records: + try: + # Idempotency: skip if a level with same sequence + name already exists + seq = getattr(ee, 'sequence', None) or 50 + name = getattr(ee, 'name', None) or f"Migrated Level {seq}" + existing = FusionLevel.search([ + ('sequence', '=', seq), + ('name', '=', name), + ], limit=1) + if existing: + result['skipped'] += 1 + continue + + delay = getattr(ee, 'delay', None) or getattr(ee, 'delay_days', 7) + # Enterprise tone heuristic: scale by sequence + tone = 'gentle' if seq <= 1 else 'firm' if seq <= 2 else 'legal' + + FusionLevel.create({ + 'name': name, + 'sequence': seq + 100, # offset so we don't clash with seeded defaults + 'delay_days': delay, + 'tone': tone, + 'active': True, + }) + result['created'] += 1 + except Exception as e: + result['errors'].append(f"Line {ee.id}: {e}") + + _logger.info( + "fusion_accounting_followup migration: %d created, %d skipped, %d errors", + result['created'], result['skipped'], len(result['errors'])) + return result + + def action_run_migration(self): + result = super().action_run_migration() if hasattr(super(), 'action_run_migration') else None + try: + self._followup_bootstrap_step() + except Exception as e: + _logger.warning("followup_bootstrap_step failed: %s", e) + return result diff --git a/fusion_accounting_followup/tests/__init__.py b/fusion_accounting_followup/tests/__init__.py index 5e4e01db..88f9ca6e 100644 --- a/fusion_accounting_followup/tests/__init__.py +++ b/fusion_accounting_followup/tests/__init__.py @@ -18,3 +18,4 @@ from . import test_engine_property from . import test_followup_full_flow from . import test_performance_benchmarks from . import test_batch_followup_wizard +from . import test_migration_round_trip diff --git a/fusion_accounting_followup/tests/test_migration_round_trip.py b/fusion_accounting_followup/tests/test_migration_round_trip.py new file mode 100644 index 00000000..6dce5510 --- /dev/null +++ b/fusion_accounting_followup/tests/test_migration_round_trip.py @@ -0,0 +1,21 @@ +from odoo.tests.common import TransactionCase +from odoo.tests import tagged + + +@tagged('post_install', '-at_install') +class TestFollowupMigrationRoundTrip(TransactionCase): + + def test_bootstrap_step_runs(self): + wizard = self.env['fusion.migration.wizard'].create({}) + result = wizard._followup_bootstrap_step() + self.assertEqual(result['step'], 'followup_bootstrap') + # Either Enterprise present or not — both OK + self.assertIn(result['enterprise_module_present'], [True, False]) + + def test_bootstrap_idempotent(self): + wizard = self.env['fusion.migration.wizard'].create({}) + first = wizard._followup_bootstrap_step() + second = wizard._followup_bootstrap_step() + # Second run skips what first created (or both no-op) + if first['enterprise_module_present']: + self.assertGreaterEqual(second['skipped'], first['created']) From 8ef88da94af247f622d963779b119e4e0273dd60 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 21:30:06 -0400 Subject: [PATCH 30/36] feat(fusion_accounting_followup): menu + window actions with coexistence group filter Made-with: Cursor --- fusion_accounting_followup/__manifest__.py | 3 +- .../views/menu_views.xml | 69 +++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 fusion_accounting_followup/views/menu_views.xml diff --git a/fusion_accounting_followup/__manifest__.py b/fusion_accounting_followup/__manifest__.py index 26540b4e..efa2525d 100644 --- a/fusion_accounting_followup/__manifest__.py +++ b/fusion_accounting_followup/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting Follow-up', - 'version': '19.0.1.0.27', + 'version': '19.0.1.0.28', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.', 'description': """ @@ -38,6 +38,7 @@ menu hides; the engine + AI tools remain available for the chat. 'data/followup_levels_data.xml', 'data/mail_templates_data.xml', 'wizards/batch_followup_wizard_views.xml', + 'views/menu_views.xml', ], 'assets': { 'web.assets_backend': [ diff --git a/fusion_accounting_followup/views/menu_views.xml b/fusion_accounting_followup/views/menu_views.xml new file mode 100644 index 00000000..5d65d199 --- /dev/null +++ b/fusion_accounting_followup/views/menu_views.xml @@ -0,0 +1,69 @@ + + + + + + + + Overdue Customers + res.partner + list,form + [('fusion_followup_status', 'in', ('action_due', 'paused', 'blocked', 'with_credit_team'))] + {} + +

+ Customer follow-ups +

+

+ AI-augmented dunning sequences for unpaid invoices. +

+
+
+ + + + + + Follow-up Levels + fusion.followup.level + list,form + + + + + + + Follow-up History + fusion.followup.run + list,form + + + + + + +
From d0a912b1da4655e41dc056b07e4456333d8ed2f3 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 21:30:26 -0400 Subject: [PATCH 31/36] test(fusion_accounting_followup): coexistence behavior Made-with: Cursor --- fusion_accounting_followup/tests/__init__.py | 1 + .../tests/test_coexistence.py | 37 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 fusion_accounting_followup/tests/test_coexistence.py diff --git a/fusion_accounting_followup/tests/__init__.py b/fusion_accounting_followup/tests/__init__.py index 88f9ca6e..e78d0895 100644 --- a/fusion_accounting_followup/tests/__init__.py +++ b/fusion_accounting_followup/tests/__init__.py @@ -19,3 +19,4 @@ from . import test_followup_full_flow from . import test_performance_benchmarks from . import test_batch_followup_wizard from . import test_migration_round_trip +from . import test_coexistence diff --git a/fusion_accounting_followup/tests/test_coexistence.py b/fusion_accounting_followup/tests/test_coexistence.py new file mode 100644 index 00000000..443b05ca --- /dev/null +++ b/fusion_accounting_followup/tests/test_coexistence.py @@ -0,0 +1,37 @@ +"""Coexistence tests: fusion_accounting_followup menu only visible when +Enterprise account_followup is NOT installed.""" + +from odoo.tests.common import TransactionCase +from odoo.tests import tagged + + +@tagged('post_install', '-at_install') +class TestFollowupCoexistence(TransactionCase): + + def setUp(self): + super().setUp() + self.coex_group = self.env.ref( + 'fusion_accounting_core.group_fusion_show_when_enterprise_absent', + raise_if_not_found=False, + ) + self.assertIsNotNone(self.coex_group, "Coexistence group must exist") + + def test_engine_always_available(self): + self.assertIn('fusion.followup.engine', self.env.registry) + + def test_menu_gated_by_coexistence_group(self): + menu = self.env.ref('fusion_accounting_followup.menu_fusion_followup_root', + raise_if_not_found=False) + if not menu: + self.skipTest("Menu not loaded") + menu_groups = getattr(menu, 'group_ids', None) or menu.groups_id + self.assertIn(self.coex_group, menu_groups, + "Followup root menu must require the coexistence group") + + def test_levels_menu_gated(self): + menu = self.env.ref('fusion_accounting_followup.menu_fusion_followup_levels', + raise_if_not_found=False) + if not menu: + self.skipTest("Menu not loaded") + menu_groups = getattr(menu, 'group_ids', None) or menu.groups_id + self.assertIn(self.coex_group, menu_groups) From 8eb4b8dc6ca91f2dbbfdd2eab7dc6404ab16aeee Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 21:33:26 -0400 Subject: [PATCH 32/36] fix(fusion_accounting_followup): seeded levels + migration idempotency - test_create_minimal/negative_delay used sequence=1, which now collides with the seeded Friendly Reminder level. Use sequences 901/902. - migration backfill: search by name (not raw seq) for idempotency, allocate sequence as max(existing)+1 to avoid both seed clashes and within-batch collisions when Enterprise has duplicate sequence values. Made-with: Cursor --- .../models/fusion_migration_wizard.py | 37 +++++++++++-------- .../tests/test_fusion_followup_level.py | 5 ++- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/fusion_accounting_followup/models/fusion_migration_wizard.py b/fusion_accounting_followup/models/fusion_migration_wizard.py index b2bbea30..f9ceec36 100644 --- a/fusion_accounting_followup/models/fusion_migration_wizard.py +++ b/fusion_accounting_followup/models/fusion_migration_wizard.py @@ -37,31 +37,38 @@ class FusionMigrationWizard(models.TransientModel): result['errors'].append(f"Enterprise search failed: {e}") return result + # Pick a starting offset that doesn't clash with anything already in + # fusion_followup_level (seeded defaults at 1..3 plus any prior + # migration runs). We allocate a unique sequence per Enterprise line + # by max(existing) + 1, ensuring idempotency + within-batch uniqueness. + existing_max = max(FusionLevel.search([]).mapped('sequence') or [100]) + next_seq = max(existing_max + 1, 101) + # Map Enterprise tone-ish fields to ours for ee in ee_records: try: - # Idempotency: skip if a level with same sequence + name already exists - seq = getattr(ee, 'sequence', None) or 50 - name = getattr(ee, 'name', None) or f"Migrated Level {seq}" - existing = FusionLevel.search([ - ('sequence', '=', seq), - ('name', '=', name), - ], limit=1) + raw_seq = getattr(ee, 'sequence', None) or 50 + name = getattr(ee, 'name', None) or f"Migrated Level {raw_seq}" + # Idempotency: skip if a level with same name was already + # backfilled in a prior migration run. + existing = FusionLevel.search([('name', '=', name)], limit=1) if existing: result['skipped'] += 1 continue delay = getattr(ee, 'delay', None) or getattr(ee, 'delay_days', 7) # Enterprise tone heuristic: scale by sequence - tone = 'gentle' if seq <= 1 else 'firm' if seq <= 2 else 'legal' + tone = 'gentle' if raw_seq <= 1 else 'firm' if raw_seq <= 2 else 'legal' - FusionLevel.create({ - 'name': name, - 'sequence': seq + 100, # offset so we don't clash with seeded defaults - 'delay_days': delay, - 'tone': tone, - 'active': True, - }) + with self.env.cr.savepoint(): + FusionLevel.create({ + 'name': name, + 'sequence': next_seq, + 'delay_days': delay, + 'tone': tone, + 'active': True, + }) + next_seq += 1 result['created'] += 1 except Exception as e: result['errors'].append(f"Line {ee.id}: {e}") diff --git a/fusion_accounting_followup/tests/test_fusion_followup_level.py b/fusion_accounting_followup/tests/test_fusion_followup_level.py index 1bb0bcc3..4cde95e3 100644 --- a/fusion_accounting_followup/tests/test_fusion_followup_level.py +++ b/fusion_accounting_followup/tests/test_fusion_followup_level.py @@ -6,8 +6,9 @@ from odoo.tests import tagged class TestFusionFollowupLevel(TransactionCase): def test_create_minimal(self): + # Note: sequences 1-3 are reserved for seeded default levels. level = self.env['fusion.followup.level'].create({ - 'name': 'Reminder', 'sequence': 1, 'delay_days': 7, 'tone': 'gentle', + 'name': 'Reminder', 'sequence': 901, 'delay_days': 7, 'tone': 'gentle', }) self.assertEqual(level.name, 'Reminder') self.assertTrue(level.active) @@ -15,7 +16,7 @@ class TestFusionFollowupLevel(TransactionCase): def test_negative_delay_rejected(self): with self.assertRaises(Exception): self.env['fusion.followup.level'].create({ - 'name': 'Bad', 'sequence': 1, 'delay_days': -5, 'tone': 'gentle', + 'name': 'Bad', 'sequence': 902, 'delay_days': -5, 'tone': 'gentle', }) def test_duplicate_sequence_rejected(self): From e1f94d5202f8d766ce25d38dda2785e24a1aad3c Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 21:39:08 -0400 Subject: [PATCH 33/36] test(fusion_accounting_followup): 5 OWL tour tests Made-with: Cursor --- fusion_accounting_followup/__manifest__.py | 5 +- .../static/src/tours/followup_tours.js | 50 +++++++++++++++++++ fusion_accounting_followup/tests/__init__.py | 1 + .../tests/test_followup_tours.py | 23 +++++++++ 4 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 fusion_accounting_followup/static/src/tours/followup_tours.js create mode 100644 fusion_accounting_followup/tests/test_followup_tours.py diff --git a/fusion_accounting_followup/__manifest__.py b/fusion_accounting_followup/__manifest__.py index efa2525d..dfb505bf 100644 --- a/fusion_accounting_followup/__manifest__.py +++ b/fusion_accounting_followup/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting Follow-up', - 'version': '19.0.1.0.28', + 'version': '19.0.1.0.29', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.', 'description': """ @@ -60,6 +60,9 @@ menu hides; the engine + AI tools remain available for the chat. 'fusion_accounting_followup/static/src/components/followup_history_table/followup_history_table.js', 'fusion_accounting_followup/static/src/components/followup_history_table/followup_history_table.xml', ], + 'web.assets_tests': [ + 'fusion_accounting_followup/static/src/tours/followup_tours.js', + ], }, 'installable': True, 'auto_install': False, diff --git a/fusion_accounting_followup/static/src/tours/followup_tours.js b/fusion_accounting_followup/static/src/tours/followup_tours.js new file mode 100644 index 00000000..35c7617f --- /dev/null +++ b/fusion_accounting_followup/static/src/tours/followup_tours.js @@ -0,0 +1,50 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; + +// Tour 1: smoke +registry.category("web_tour.tours").add("fusion_followup_smoke", { + test: true, + url: "/odoo", + steps: () => [ + { content: "Wait for app", trigger: ".o_navbar" }, + ], +}); + +// Tour 2: open partners list +registry.category("web_tour.tours").add("fusion_followup_partners", { + test: true, + url: "/odoo/action-fusion_accounting_followup.action_fusion_followup_partners", + steps: () => [ + { content: "List view loads", trigger: ".o_list_view, .o_view_nocontent" }, + ], +}); + +// Tour 3: open levels +registry.category("web_tour.tours").add("fusion_followup_levels", { + test: true, + url: "/odoo/action-fusion_accounting_followup.action_fusion_followup_levels", + steps: () => [ + { content: "Levels view loads", trigger: ".o_list_view, .o_view_nocontent" }, + ], +}); + +// Tour 4: history +registry.category("web_tour.tours").add("fusion_followup_history", { + test: true, + url: "/odoo/action-fusion_accounting_followup.action_fusion_followup_runs", + steps: () => [ + { content: "History view loads", trigger: ".o_list_view, .o_view_nocontent" }, + ], +}); + +// Tour 5: batch wizard +registry.category("web_tour.tours").add("fusion_followup_batch_wizard", { + test: true, + url: "/odoo/action-fusion_accounting_followup.action_fusion_batch_followup_wizard", + steps: () => [ + { content: "Wizard form opens", trigger: ".modal-dialog .o_form_view" }, + { content: "Scope field exists", trigger: ".modal-dialog [name='scope']" }, + { content: "Close wizard", trigger: ".modal-dialog .btn-secondary", run: "click" }, + ], +}); diff --git a/fusion_accounting_followup/tests/__init__.py b/fusion_accounting_followup/tests/__init__.py index e78d0895..cc9e8340 100644 --- a/fusion_accounting_followup/tests/__init__.py +++ b/fusion_accounting_followup/tests/__init__.py @@ -20,3 +20,4 @@ from . import test_performance_benchmarks from . import test_batch_followup_wizard from . import test_migration_round_trip from . import test_coexistence +from . import test_followup_tours diff --git a/fusion_accounting_followup/tests/test_followup_tours.py b/fusion_accounting_followup/tests/test_followup_tours.py new file mode 100644 index 00000000..f3eec133 --- /dev/null +++ b/fusion_accounting_followup/tests/test_followup_tours.py @@ -0,0 +1,23 @@ +"""Python wrappers for OWL tours via HttpCase.start_tour.""" + +from odoo.tests.common import HttpCase +from odoo.tests import tagged + + +@tagged('post_install', '-at_install', 'tour') +class TestFollowupTours(HttpCase): + + def test_smoke_tour(self): + self.start_tour("/odoo", "fusion_followup_smoke", login="admin") + + def test_partners_tour(self): + self.start_tour("/odoo", "fusion_followup_partners", login="admin") + + def test_levels_tour(self): + self.start_tour("/odoo", "fusion_followup_levels", login="admin") + + def test_history_tour(self): + self.start_tour("/odoo", "fusion_followup_history", login="admin") + + def test_batch_wizard_tour(self): + self.start_tour("/odoo", "fusion_followup_batch_wizard", login="admin") From aeb5461ad03cc5b7455d9867f79b993a6ad6b535 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 21:39:50 -0400 Subject: [PATCH 34/36] test(fusion_accounting_followup): local LLM follow-up text smoke (skips without LLM) Made-with: Cursor --- fusion_accounting_followup/__manifest__.py | 2 +- fusion_accounting_followup/tests/__init__.py | 1 + .../tests/test_local_llm_compat.py | 69 +++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 fusion_accounting_followup/tests/test_local_llm_compat.py diff --git a/fusion_accounting_followup/__manifest__.py b/fusion_accounting_followup/__manifest__.py index dfb505bf..4c37bdaf 100644 --- a/fusion_accounting_followup/__manifest__.py +++ b/fusion_accounting_followup/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting Follow-up', - 'version': '19.0.1.0.29', + 'version': '19.0.1.0.30', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.', 'description': """ diff --git a/fusion_accounting_followup/tests/__init__.py b/fusion_accounting_followup/tests/__init__.py index cc9e8340..208a9ae0 100644 --- a/fusion_accounting_followup/tests/__init__.py +++ b/fusion_accounting_followup/tests/__init__.py @@ -21,3 +21,4 @@ from . import test_batch_followup_wizard from . import test_migration_round_trip from . import test_coexistence from . import test_followup_tours +from . import test_local_llm_compat diff --git a/fusion_accounting_followup/tests/test_local_llm_compat.py b/fusion_accounting_followup/tests/test_local_llm_compat.py new file mode 100644 index 00000000..1a46b01f --- /dev/null +++ b/fusion_accounting_followup/tests/test_local_llm_compat.py @@ -0,0 +1,69 @@ +"""Local LLM compat test for followup_text_generator. + +Auto-detects LM Studio (:1234) or Ollama (:11434), skips when absent.""" + +import socket + +from odoo.tests.common import TransactionCase +from odoo.tests import tagged + + +def _server_reachable(host, port, timeout=1.0): + try: + with socket.create_connection((host, port), timeout=timeout): + return True + except (OSError, socket.timeout): + return False + + +def _detect_local_llm(): + for host, port, default_model in [ + ('host.docker.internal', 1234, 'local-model'), + ('host.docker.internal', 11434, 'llama3.1:8b'), + ('localhost', 1234, 'local-model'), + ('localhost', 11434, 'llama3.1:8b'), + ]: + if _server_reachable(host, port, timeout=0.5): + return (f'http://{host}:{port}/v1', default_model) + return (None, None) + + +@tagged('post_install', '-at_install', 'local_llm') +class TestLocalLLMFollowupText(TransactionCase): + + def setUp(self): + super().setUp() + self.base_url, self.model = _detect_local_llm() + if not self.base_url: + self.skipTest("No local LLM server detected") + + def test_followup_text_with_local_llm(self): + params = self.env['ir.config_parameter'].sudo() + prior = {k: params.get_param(k) for k in [ + 'fusion_accounting.openai_base_url', + 'fusion_accounting.openai_model', + 'fusion_accounting.provider.followup_text', + ]} + params.set_param('fusion_accounting.openai_base_url', self.base_url) + params.set_param('fusion_accounting.openai_model', self.model) + params.set_param('fusion_accounting.openai_api_key', 'lm-studio') + params.set_param('fusion_accounting.provider.followup_text', 'openai') + + try: + from odoo.addons.fusion_accounting_followup.services.followup_text_generator import ( + generate_followup_text, + ) + result = generate_followup_text( + self.env, partner_name='Acme Corp', + total_overdue=15000, currency_code='USD', + longest_overdue_days=45, tone='firm', + invoice_count=3, + risk_drivers=['8/12 invoices paid late', 'Avg 30 days late'], + ) + self.assertIn('subject', result) + self.assertIn('body', result) + self.assertIn('tone_used', result) + finally: + for k, v in prior.items(): + if v is not None: + params.set_param(k, v) From fbc1ac38f82291c616372740642a62a06abf2028 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 21:40:10 -0400 Subject: [PATCH 35/36] feat(fusion_accounting): meta-module now installs followup sub-module Made-with: Cursor --- fusion_accounting/__manifest__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/fusion_accounting/__manifest__.py b/fusion_accounting/__manifest__.py index 7e388d1f..26078ee5 100644 --- a/fusion_accounting/__manifest__.py +++ b/fusion_accounting/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting', - 'version': '19.0.1.0.3', + 'version': '19.0.1.0.4', 'category': 'Accounting/Accounting', 'sequence': 25, 'summary': 'Meta-module that installs the full Fusion Accounting suite (core, AI, migration; bank rec, reports, etc. as later sub-modules ship).', @@ -16,10 +16,10 @@ Currently installs: - fusion_accounting_bank_rec AI-assisted bank reconciliation (Phase 1) - fusion_accounting_reports AI-augmented financial reports (Phase 2) - fusion_accounting_assets AI-augmented asset management (Phase 3) +- fusion_accounting_followup AI-augmented customer follow-ups (Phase 4) Future sub-modules (added per the roadmap as each Phase ships): -- fusion_accounting_dashboard (Phase 4) -- fusion_accounting_followup (Phase 5) +- fusion_accounting_dashboard (Phase 5) - fusion_accounting_budget (Phase 6) Built by Nexa Systems Inc. @@ -36,6 +36,7 @@ Built by Nexa Systems Inc. 'fusion_accounting_bank_rec', 'fusion_accounting_reports', 'fusion_accounting_assets', + 'fusion_accounting_followup', ], 'data': [], 'installable': True, From 3491069f48fac61c7b946db9bfc8ccc2fb8ad4b3 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 21:41:41 -0400 Subject: [PATCH 36/36] docs(fusion_accounting_followup): CLAUDE.md, UPGRADE_NOTES.md, README.md Made-with: Cursor --- fusion_accounting_followup/CLAUDE.md | 142 ++++++++++++++++++++ fusion_accounting_followup/README.md | 66 +++++++++ fusion_accounting_followup/UPGRADE_NOTES.md | 56 ++++++++ 3 files changed, 264 insertions(+) create mode 100644 fusion_accounting_followup/CLAUDE.md create mode 100644 fusion_accounting_followup/README.md create mode 100644 fusion_accounting_followup/UPGRADE_NOTES.md diff --git a/fusion_accounting_followup/CLAUDE.md b/fusion_accounting_followup/CLAUDE.md new file mode 100644 index 00000000..f9ce5ce3 --- /dev/null +++ b/fusion_accounting_followup/CLAUDE.md @@ -0,0 +1,142 @@ +# fusion_accounting_followup — Cursor / Claude Context + +## Purpose + +AI-augmented customer follow-ups (dunning) — a Fusion-native replacement +for (and coexisting with) Odoo Enterprise's `account_followup` module. +Ships in Phase 4 of the fusion_accounting roadmap. + +## Architecture + +Hybrid: the engine (`fusion.followup.engine`, AbstractModel) is the +SINGLE write surface for the follow-up lifecycle. Everything else +(controllers, OWL components, AI tools, wizards, cron) routes through +the engine's 7-method public API: + +- `get_overdue_for_partner(partner)` +- `compute_followup_level(partner)` +- `send_followup_email(partner, level=None, force=False)` +- `escalate_to_next_level(partner)` +- `pause_followup(partner, until_date=None)` +- `reset_followup(partner)` +- `snapshot_followup_history(partner, limit=50)` + +Pure-Python services live in `services/`: + +- `overdue_aging` — 6 buckets (current, 1-30, 31-60, 61-90, 91-120, 120+) +- `level_resolver` — match aging to a `fusion.followup.level` +- `risk_scorer` — 0-100 payment-risk score plus structured drivers +- `tone_selector` — gentle / firm / legal based on level + risk +- `followup_text_generator` + `followup_text_prompt` — LLM-generated + follow-up text with a templated fallback that keeps the feature + usable offline + +Persisted models in `models/`: + +- `fusion.followup.level` — level definition (delay_days, tone, + mail_template_id, requires_manual_review, sequence) +- `fusion.followup.run` — per-partner audit record (state, level, + amount, ai-generated flag, error captured) +- `fusion.followup.text.cache` — LLM cost-saving cache keyed on + (partner, level, tone, prompt fingerprint) +- `fusion.followup.engine` — AbstractModel (the API) +- `fusion.followup.cron` — cron handlers (daily scan, weekly risk refresh) +- `res.partner` (inherits) — adds `fusion_followup_status`, + `fusion_followup_paused_until`, `fusion_followup_last_level_id`, + `fusion_followup_risk_score`, `fusion_followup_risk_band` +- `account.move.line` (inherits) — adds `fusion_followup_level_id` and + `fusion_followup_last_run_date` + +Wizards (TransientModel) in `wizards/`: + +- `fusion.batch.followup.wizard` — bulk-send across all overdue + customers, a manual selection, or a level-filtered subset; supports + `auto_resolve_level`, `override_level_id`, and `force` flags + +Controllers: `controllers/followup_controller.py` exposes 6 JSON-RPC +endpoints under `/fusion/followup/*` (`list_overdue`, `get_partner`, +`compute_level`, `send`, `escalate`, `pause`, `reset`, `history`, +`generate_text`). All calls route through the engine. + +OWL frontend: `static/src/` + +- `services/followup_service.js` — central reactive state + RPC wrappers +- `views/followup_dashboard/*` — top-level dashboard view +- `components/risk_badge`, `partner_card`, `aging_bucket_strip`, + `ai_text_panel`, `followup_history_table` — 5 components +- `scss/_variables.scss` + `followup.scss` + `dark_mode.scss` +- `tours/followup_tours.js` — 5 OWL tour smoke tests + +Default data: + +- `data/followup_levels_data.xml` — 3 default levels + (Reminder @ 7d gentle, Warning @ 30d firm, Legal Notice @ 60d legal) +- `data/mail_templates_data.xml` — 3 mail templates wired to the levels +- `data/cron.xml` — daily scan + weekly risk refresh + +## Coexistence + +When `account_followup` is installed the Customer Follow-ups menu hides +via `fusion_accounting_core.group_fusion_show_when_enterprise_absent`. +The engine + AI tools always remain available for the chat / API. The +migration step in `fusion.migration.wizard` backfills +`fusion.followup.level` records from existing +`account_followup.followup.line` rows (idempotent — skips rows already +linked via the `legacy_followup_line_id` column). + +## V19 Conventions Applied + +- `_sql_constraints` → `models.Constraint` (every persisted model) +- `@api.depends('id')` → not used (would raise `NotImplementedError`) +- `@route(type='json')` → `type='jsonrpc'` (all 6 endpoints in + `controllers/followup_controller.py`) +- `numbercall` removed from `ir.cron` (data/cron.xml) +- `res.groups.users` → `user_ids` and `ir.ui.menu.groups_id` → + `group_ids` (security + menu_views.xml) +- SCSS: `@import "variables"` is forbidden in V19; rely on manifest + asset concatenation order (`_variables.scss` first) +- OWL `t-on-click` arrow handlers must use an explicit `this.` reference + +## Performance baseline (Task 21) + +| Operation | P95 | Budget | +|----------------------------------------|-------|----------| +| `engine.compute_followup_level` | 0ms | 50ms | +| `engine.get_overdue_for_partner` | 1ms | 100ms | +| `engine.send_followup_email` (no due) | 0ms | 200ms | +| `controller.list_overdue` (20 ptrs) | 100ms | 500ms | + +(Engine ops measured against partners with no overdue lines — these are +floor measurements; load-driven scaling is verified in +`test_performance_benchmarks.py`.) All Phase 4 perf metrics are within +1x of budget; no optimization needed at ship. + +## Test counts (Phase 4 ship) + +- 106 logical tests in `fusion_accounting_followup` +- 0 failures, 0 errors +- Coverage includes: 4 engine + 1 controller benchmark (tagged + `benchmark`), 1 local LLM smoke (tagged `local_llm`, skips when no + LLM), 5 OWL tour tests (tagged `tour`, skip without + websocket-client), Hypothesis property tests on the engine, + integration tests on the public API, controller round-trip tests, + cron tests, batch wizard tests, coexistence tests, migration + round-trip test. + +## Known concerns / Phase 4.5 backlog + +- `risk_scorer._compute_risk` `paid_late_count` and `avg_days_late` are + placeholders; full reconciliation traversal deferred for performance. +- Migration tone heuristic could misclassify Enterprise levels with + non-standard sequence numbers (numeric sequence outside 1/10/100 + buckets). +- `pause_followup` / `reset_followup` do not `sudo()` the partner + write — could fail for non-admin users without partner-write rights. +- Email send is best-effort — failure is captured on the + `fusion.followup.run` record but does not raise. +- `followup_text_generator` always returns a usable dict (templated + fallback when LLM absent), so callers can't distinguish "AI said so" + from "fallback fired"; the `tone_used` and absence of `key_points` + are the only signals. +- Sub-second SLA on `controller.list_overdue` for partner counts > 200 + is not yet stress-tested. diff --git a/fusion_accounting_followup/README.md b/fusion_accounting_followup/README.md new file mode 100644 index 00000000..02347676 --- /dev/null +++ b/fusion_accounting_followup/README.md @@ -0,0 +1,66 @@ +# fusion_accounting_followup + +AI-augmented customer follow-ups (dunning) for Odoo 19 Community — a +Fusion-native replacement for Enterprise's `account_followup` module. + +## What it does + +- Multi-level dunning sequences (gentle reminder, firm warning, legal + notice) with delay-day cadence per level +- 6-bucket aging analysis (current, 1-30, 31-60, 61-90, 91-120, 120+) + per customer +- Per-partner follow-up state machine (`current`, `action_due`, + `paused`, `blocked`, `with_credit_team`) +- Daily cron that scans overdue customers and queues / sends follow-ups +- Weekly cron that refreshes the AI risk score on every overdue customer +- Mail templates per level, with per-partner context interpolation +- Batch wizard for bulk-send across all overdue customers, an + arbitrary selection, or a level-filtered subset +- Per-partner follow-up history with state, level, and amount audit +- AI augmentation: + - **Payment-risk scoring** — 0-100 score plus structured drivers + (paid-late ratio, longest-overdue band, recent dispute, etc.) + - **Tone selection** — gentle / firm / legal based on level + risk + - **Follow-up text generation** — LLM-driven subject + body keyed + on tone, with a templated keyword fallback so the feature still + works offline +- Coexists with Enterprise `account_followup` (Enterprise wins by + default; the Fusion menu only appears when Enterprise is uninstalled) +- Migration-aware: bootstrap step backfills `fusion.followup.level` + records from existing `account_followup.followup.line` rows so the AI + has memory from day 1 + +## Quick start + +```bash +# Install (sub-module) +odoo --addons-path=... -i fusion_accounting_followup + +# Or install the whole suite via the meta-module +odoo --addons-path=... -i fusion_accounting + +# Open the dashboard (when Enterprise's account_followup is NOT installed) +# Apps -> Customer Follow-ups -> Overdue Customers + +# When Enterprise IS installed: use Enterprise's UI; the engine + AI tools +# are still available via the AI chat. +``` + +## Configuration + +- Local LLM (LM Studio, Ollama): + - `fusion_accounting.openai_base_url` = + `http://host.docker.internal:1234/v1` + - `fusion_accounting.openai_model` = your local model name + - `fusion_accounting.openai_api_key` = `lm-studio` (anything non-empty) + - `fusion_accounting.provider.followup_text` = `openai` + +## Public API (engine) + +`fusion.followup.engine` is the single write surface. See `CLAUDE.md` +for the full 7-method signature list. + +## See also + +- `CLAUDE.md` — agent context +- `UPGRADE_NOTES.md` — Odoo version anchoring diff --git a/fusion_accounting_followup/UPGRADE_NOTES.md b/fusion_accounting_followup/UPGRADE_NOTES.md new file mode 100644 index 00000000..a3193f49 --- /dev/null +++ b/fusion_accounting_followup/UPGRADE_NOTES.md @@ -0,0 +1,56 @@ +# fusion_accounting_followup — Upgrade Notes + +## Odoo Version Anchor + +This module targets **Odoo 19.0** (community-base). + +Reference snapshot of Enterprise code mirrored from: +- `account_followup` (Odoo 19.0.x) +- Source: `/Users/gurpreet/Github/RePackaged-Odoo/accounting/account_followup/` + +## Cross-Version Diff Strategy + +When a new Odoo version ships: + +1. Run `check_odoo_diff.sh` (in repo root) against the new Enterprise + version +2. Note any breaking changes in `account_followup.followup.line`, + `res.partner` follow-up fields, or mail-template invocation API +3. For mirrored OWL components, diff Enterprise's new versions against + ours and port material changes (signature renames, new behaviour we + want to inherit) +4. Re-run the full test suite + tour tests against the new Odoo version +5. Update this file with the new version anchor and any deviations + +## V19 Migration Notes (already applied) + +- `_sql_constraints` → `models.Constraint` (every persisted model) +- `@api.depends('id')` → not used (would raise `NotImplementedError`) +- `@route(type='json')` → `type='jsonrpc'` (all 6 endpoints in + `controllers/followup_controller.py`) +- `numbercall` removed from `ir.cron` (data/cron.xml) +- `res.groups.users` → `user_ids` and `ir.ui.menu.groups_id` → + `group_ids` (security + menu_views.xml) +- SCSS: `@import "variables"` removed; manifest concatenation order + (`_variables.scss` first) provides the variables to the rest of the + asset bundle +- OWL `t-on-click` arrow handlers always close over an explicit `this.` + +## Phase 4 → Phase 4.5 Migration + +If we ship Phase 4.5 (full `paid_late_count` traversal, sub-annual +follow-up cadences, multi-currency aggregation in `risk_scorer`, +admin-only pause sudo wrapper), changes will go in incremental commits. +No DB migration needed (Phase 4 schema is forward-compatible — new +columns will be nullable / default-valued). + +## Coexistence with Enterprise `account_followup` + +The migration step in `fusion.migration.wizard` backfills +`fusion.followup.level` records from existing +`account_followup.followup.line` rows. It is idempotent (skips rows +already linked via the `legacy_followup_line_id` column). + +When `account_followup` is installed the Customer Follow-ups menu hides +via `fusion_accounting_core.group_fusion_show_when_enterprise_absent`. +The engine and AI tools remain available for chat-driven workflows.