diff --git a/fusion_accounting/docs/superpowers/plans/2026-04-19-phase-1-bank-rec-plan.md b/fusion_accounting/docs/superpowers/plans/2026-04-19-phase-1-bank-rec-plan.md new file mode 100644 index 00000000..f0ecb6a8 --- /dev/null +++ b/fusion_accounting/docs/superpowers/plans/2026-04-19-phase-1-bank-rec-plan.md @@ -0,0 +1,3520 @@ +# Phase 1 — Bank Reconciliation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `fusion_accounting_bank_rec` — a native bank-reconciliation widget that replaces Odoo Enterprise's `account_accountant` bank rec, in V19 OWL architecture, with a clean-room reconcile engine reading/writing Community's `account.partial.reconcile` tables. AI assistive with confidence badges + behavioural learning. Architecture-ready for local LLM (Ollama, LM Studio). + +**Architecture:** Hybrid mirror/abstract per the roadmap. Strict mirror of all 18 Enterprise OWL units + 5 fusion-only components. Reconcile engine is `models.AbstractModel` with 6-method public API. Pure-Python helpers in `services/` are unit-testable without Odoo DB. AI suggestions are persisted records, never write `account.partial.reconcile` directly. LLM provider abstraction makes local LLM a one-config-line drop-in. + +**Tech Stack:** Odoo 19, Python 3.12, PostgreSQL 16 + pgvector, OWL JS framework, Hypothesis (property-based tests), pytest-benchmark (performance), anthropic + openai Python clients (already installed). + +**Spec Reference:** [`docs/superpowers/specs/2026-04-19-phase-1-bank-rec-design.md`](../specs/2026-04-19-phase-1-bank-rec-design.md). + +--- + +## Conventions Used Throughout This Plan + +### Workspace Identity (CRITICAL — environment safety rule) + +- **All testing happens in the local OrbStack VM** (`odoo-westin-dev`, container `westin-dev-app`, DB `westin-v19`). +- **NEVER touch `ssh odoo-westin`** — that is PRODUCTION (`erp.westinhealthcare.ca`). Per `.cursor/rules/environment-safety.mdc`. +- Workspace root: `/Users/gurpreet/Github/Odoo-Modules`. +- Local VM addons path is bind-mounted from `/Users/gurpreet/Github/Odoo-Modules/` — code edits on Mac are instantly visible inside the container; no `scp`/`docker cp` needed for code changes. +- Schema/data changes still require `odoo -u` upgrade. + +### Standard Commands + +**Test run (against local VM):** +```bash +orb -m odoo-westin-dev sudo docker exec westin-dev-app odoo \ + -d westin-v19 --test-tags post_install --stop-after-init --http-port=8099 \ + -c /etc/odoo/odoo.conf -u fusion_accounting_bank_rec \ + --log-handler=odoo.tests:INFO 2>&1 | tail -30 +``` + +**Module upgrade (no tests, just registry refresh):** +```bash +orb -m odoo-westin-dev sudo docker exec westin-dev-app odoo \ + -d westin-v19 -u fusion_accounting_bank_rec --stop-after-init --http-port=8099 \ + -c /etc/odoo/odoo.conf 2>&1 | tail -10 +``` + +**Restart container (refreshes Python/JS caches after pure code edits):** +```bash +orb -m odoo-westin-dev sudo docker restart westin-dev-app +sleep 10 +/usr/bin/curl -sSo /dev/null -w 'HTTP %{http_code}\n' http://odoo-westin-dev.orb.local:8069/web/login +``` + +**SQL inspection:** +```bash +orb -m odoo-westin-dev sudo docker exec westin-dev-db psql -U odoo -d westin-v19 -c "SELECT ..." +``` + +**Browser test:** +- Local URL: `http://odoo-westin-dev.orb.local:8069` +- Use `/usr/bin/curl` (not just `curl`) — Cursor's PATH lacks the system curl shadow + +### Commit Style + +`(): ` per recent repo conventions: +- `type`: feat, fix, refactor, docs, test, chore, ci +- `scope`: usually the sub-module name (`fusion_accounting_bank_rec`, `fusion_accounting_ai`, etc.) +- Multiline body via heredoc: + ```bash + git commit -m "$(cat <<'EOF' + feat(fusion_accounting_bank_rec): add reconcile engine AbstractModel + + Implements the 6-method public API: reconcile_one, reconcile_batch, + suggest_matches, accept_suggestion, write_off, unreconcile. + + TDD red→green for reconcile_one (5 unit tests). + EOF + )" + ``` + +### TDD Discipline + +Every task with code changes follows red-green-commit: +1. Write the failing test (run it, confirm RED with the expected error) +2. Write minimal implementation (run test, confirm GREEN) +3. Refactor if obvious cleanups (re-run test) +4. Commit + +### Manifest Version + +Each commit that changes module behavior bumps the manifest version patch: +- `19.0.1.0.0` → `19.0.1.0.1` → `19.0.1.0.2` → ... + +--- + +## File Structure (target end-state of Phase 1) + +``` +fusion_accounting_bank_rec/ # NEW Phase 1 sub-module +├── __manifest__.py +├── __init__.py +├── CLAUDE.md, UPGRADE_NOTES.md, README.md +├── docs/odoo_diff/v19/ # Enterprise reference snapshots +├── data/ +│ ├── ir_cron.xml +│ └── materialized_view.xml +├── migrations/19.0.1.0.0/post-migration.py +├── models/ +│ ├── __init__.py +│ ├── fusion_reconcile_engine.py # AbstractModel orchestrator +│ ├── fusion_reconcile_suggestion.py # Persisted AI suggestions +│ ├── fusion_reconcile_pattern.py # Per-partner aggregate +│ ├── fusion_reconcile_precedent.py # Per-decision memory +│ ├── account_bank_statement_line.py # Inherit +│ ├── account_reconcile_model.py # Inherit +│ └── fusion_bank_rec_widget.py # TransientModel +├── controllers/ +│ ├── __init__.py +│ └── bank_rec_controller.py # 10 JSON-RPC endpoints +├── security/ +│ ├── ir.model.access.csv +│ └── bank_rec_security.xml +├── services/ # Pure-Python helpers +│ ├── __init__.py +│ ├── matching_strategies.py +│ ├── exchange_diff.py +│ ├── confidence_scoring.py +│ ├── pattern_extractor.py +│ ├── precedent_lookup.py +│ └── memo_tokenizer.py +├── static/ +│ ├── description/{icon.png, index.html} +│ └── src/components/bank_reconciliation/ +│ ├── _bank_rec_tokens.scss +│ ├── bank_rec_widget.scss +│ ├── bank_reconciliation_service.js +│ ├── kanban_controller.{js,xml} +│ ├── kanban_renderer.{js,xml} +│ ├── statement_line/ # 18 Enterprise mirrors +│ ├── statement_summary/ +│ ├── line_to_reconcile/ +│ ├── list_view/ +│ ├── apply_amount/ +│ ├── bankrec_form_dialog/ +│ ├── button/ +│ ├── button_list/ +│ ├── chatter/ +│ ├── file_uploader/ +│ ├── line_info_pop_over/ +│ ├── quick_create/ +│ ├── reconciled_line_name/ +│ ├── search_dialog/ +│ ├── ai_suggestion/ # 5 fusion-only +│ ├── batch_action_bar/ +│ ├── attachment_strip/ +│ ├── partner_history_panel/ +│ └── reconcile_model_picker/ +├── tests/ +│ ├── __init__.py +│ ├── _fixtures/ +│ ├── _factories.py +│ ├── test_reconcile_engine_unit.py +│ ├── test_reconcile_engine_property.py +│ ├── test_reconcile_engine_integration.py +│ ├── test_pattern_extraction.py +│ ├── test_precedent_lookup.py +│ ├── test_memo_tokenizer.py +│ ├── test_confidence_scoring.py +│ ├── test_ai_suggestion_lifecycle.py +│ ├── test_widget_endpoints.py +│ ├── test_coexistence.py +│ ├── test_migration_round_trip.py +│ ├── test_performance.py +│ ├── test_local_llm_compat.py +│ └── tours/{manual_reconcile,ai_assist_accept,batch_reconcile,partial_reconcile,write_off}_tour.js +├── views/ +│ ├── bank_rec_widget_views.xml +│ ├── account_journal_views.xml +│ ├── account_reconcile_model_views.xml +│ └── menus.xml +└── wizards/ + ├── __init__.py + ├── auto_reconcile_wizard.{py,xml} + ├── reconcile_wizard.{py,xml} + └── migration_wizard_inherit.py + +# Existing sub-modules — additions only +fusion_accounting_core/ +├── models/account_bank_statement_line.py # ADD: cron_last_check field +└── models/res_users.py # NEW: dynamic coexistence group + +fusion_accounting_ai/services/ +├── adapters/_base.py # NEW: LLMProvider contract +├── adapters/openai_adapter.py # MODIFY: configurable base_url +├── data_adapters/bank_rec.py # MODIFY: fill _via_fusion paths +├── prompts/bank_rec_prompt.py # NEW: suggestion-ranking prompt +└── tools/bank_reconciliation.py # MODIFY: 5 new tools + refactors + +fusion_accounting/ # Meta-module +└── __manifest__.py # MODIFY: depend on _bank_rec +``` + +--- + +## Task Index (51 tasks across 17 groups) + +| Group | Tasks | Topic | +|---|---|---| +| 1 | 1-5 | Foundation (branch, skeleton, shared fields, LLM contract) | +| 2 | 6-13 | Reconcile engine — TDD layered build | +| 3 | 14-17 | Models (suggestion, pattern, precedent, widget, inherits) | +| 4 | 18-19 | Integration tests + SQL fixtures | +| 5 | 20-23 | AI integration backend | +| 6 | 24-25 | Materialized view + cron | +| 7 | 26 | Controllers | +| 8 | 27-29 | Frontend foundation (SCSS, service, kanban) | +| 9 | 30-33 | Frontend mirror components (4 batches) | +| 10 | 34-36 | Frontend fusion-only components (3 batches) | +| 11 | 37-38 | Wizards | +| 12 | 39-41 | Migration integration | +| 13 | 42-43 | Coexistence (menus, group, tests) | +| 14 | 44 | Tour tests (5 in one task) | +| 15 | 45-46 | Performance benchmarks | +| 16 | 47 | Local LLM compat | +| 17 | 48-51 | Closeout (manifest, docs, smoke, tag) | + +--- + +## Group 1: Foundation + +### Task 1: Safety Net — Tag and Branch + +**Files:** git refs only. + +- [ ] **Step 1: Verify clean working tree on `main`** + + ```bash + cd /Users/gurpreet/Github/Odoo-Modules && git status --short -- fusion_accounting fusion_accounting_core fusion_accounting_ai fusion_accounting_migration + ``` + Expected: empty (no uncommitted fusion_accounting changes). Plating drift in other folders OK. + +- [ ] **Step 2: Tag pre-Phase-1 state** + + ```bash + cd /Users/gurpreet/Github/Odoo-Modules + git tag -a fusion_accounting/pre-phase-1 -m "Snapshot before Phase 1 bank reconciliation work" + git tag --list "fusion_accounting/*" + ``` + Expected output includes new `fusion_accounting/pre-phase-1` tag. + +- [ ] **Step 3: Create Phase 1 working branch** + + ```bash + git checkout -b fusion_accounting/phase-1-bank-rec + git branch --show-current + ``` + Expected output: `fusion_accounting/phase-1-bank-rec`. + +- [ ] **Step 4: No commit — branch creation only.** + +--- + +### Task 2: Create `fusion_accounting_bank_rec` Skeleton + +**Files:** +- Create: `/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_bank_rec/__init__.py` +- Create: `/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_bank_rec/__manifest__.py` +- Create: `fusion_accounting_bank_rec/models/__init__.py` +- Create: `fusion_accounting_bank_rec/services/__init__.py` +- Create: `fusion_accounting_bank_rec/controllers/__init__.py` +- Create: `fusion_accounting_bank_rec/wizards/__init__.py` +- Create: `fusion_accounting_bank_rec/tests/__init__.py` +- Create: `fusion_accounting_bank_rec/security/ir.model.access.csv` + +- [ ] **Step 1: Make directories** + + ```bash + cd /Users/gurpreet/Github/Odoo-Modules + mkdir -p fusion_accounting_bank_rec/{models,services,controllers,wizards,tests,tests/_fixtures,tests/tours,views,data,migrations/19.0.1.0.0,security,docs/odoo_diff/v19,static/description,static/src/components/bank_reconciliation} + ``` + +- [ ] **Step 2: Write top-level `__init__.py`** + + Path: `fusion_accounting_bank_rec/__init__.py` + ```python + from . import models + from . import controllers + from . import services + from . import wizards + ``` + +- [ ] **Step 3: Write `__manifest__.py`** + + Path: `fusion_accounting_bank_rec/__manifest__.py` + ```python + { + 'name': 'Fusion Accounting — Bank Reconciliation', + 'version': '19.0.1.0.0', + 'category': 'Accounting/Accounting', + 'sequence': 28, + 'summary': 'Native V19 bank reconciliation widget with AI confidence scoring + behavioural learning.', + 'description': """ + Fusion Accounting — Bank Reconciliation + ======================================== + Replaces Odoo Enterprise's account_accountant bank-rec widget with a + native V19 OWL implementation reading/writing Community's + account.partial.reconcile tables. + + Features: + - Strict mirror of all Enterprise UI components (zero functional loss) + - AI confidence badges with one-click Accept and ranked alternatives + - Behavioural learning from historical reconciliations + - Local LLM ready (Ollama, LM Studio) via OpenAI-compatible adapter + - Coexists with account_accountant (Enterprise wins by default) + + Built by Nexa Systems Inc. + """, + 'icon': '/fusion_accounting_bank_rec/static/description/icon.png', + 'author': 'Nexa Systems Inc.', + 'website': 'https://nexasystems.ca', + 'maintainer': 'Nexa Systems Inc.', + 'depends': ['fusion_accounting_core'], + 'external_dependencies': { + 'python': ['hypothesis'], + }, + 'data': [ + 'security/ir.model.access.csv', + ], + 'installable': True, + 'application': False, + 'license': 'OPL-1', + } + ``` + +- [ ] **Step 4: Write empty package `__init__.py` files** + + ```bash + cd /Users/gurpreet/Github/Odoo-Modules + for f in fusion_accounting_bank_rec/{models,services,controllers,wizards,tests}/__init__.py; do + touch "$f" + done + ``` + +- [ ] **Step 5: Write empty ACL CSV** + + Path: `fusion_accounting_bank_rec/security/ir.model.access.csv` + ```csv + id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink + ``` + +- [ ] **Step 6: Copy icon from existing fusion_accounting_ai** + + ```bash + cp /Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/static/description/icon.png \ + /Users/gurpreet/Github/Odoo-Modules/fusion_accounting_bank_rec/static/description/icon.png + ``` + +- [ ] **Step 7: Snapshot Enterprise reference files** + + ```bash + cp /Users/gurpreet/Github/RePackaged-Odoo/accounting/account_accountant/static/src/components/bank_reconciliation/bank_reconciliation_service.js \ + /Users/gurpreet/Github/Odoo-Modules/fusion_accounting_bank_rec/docs/odoo_diff/v19/account_accountant__bank_reconciliation_service.js + cp /Users/gurpreet/Github/RePackaged-Odoo/accounting/account_accountant/models/account_reconcile_model.py \ + /Users/gurpreet/Github/Odoo-Modules/fusion_accounting_bank_rec/docs/odoo_diff/v19/account_accountant__account_reconcile_model.py + cp /Users/gurpreet/Github/RePackaged-Odoo/accounting/account_accountant/wizard/account_auto_reconcile_wizard.py \ + /Users/gurpreet/Github/Odoo-Modules/fusion_accounting_bank_rec/docs/odoo_diff/v19/account_accountant__account_auto_reconcile_wizard.py + ``` + +- [ ] **Step 8: Install hypothesis Python lib in container** + + ```bash + orb -m odoo-westin-dev sudo docker exec westin-dev-app pip install --break-system-packages hypothesis + ``` + +- [ ] **Step 9: Verify install on local VM** + + ```bash + orb -m odoo-westin-dev sudo docker exec westin-dev-app odoo \ + -d westin-v19 -i fusion_accounting_bank_rec --stop-after-init --http-port=8099 \ + -c /etc/odoo/odoo.conf 2>&1 | tail -5 + ``` + Expected: clean install line near tail; no ERROR. + +- [ ] **Step 10: Verify in DB** + + ```bash + orb -m odoo-westin-dev sudo docker exec westin-dev-db psql -U odoo -d westin-v19 -c \ + "SELECT name, state, latest_version FROM ir_module_module WHERE name='fusion_accounting_bank_rec';" + ``` + Expected: `installed`, `19.0.1.0.0`. + +- [ ] **Step 11: Commit** + + ```bash + cd /Users/gurpreet/Github/Odoo-Modules + git add fusion_accounting_bank_rec/ + git commit -m "feat(fusion_accounting_bank_rec): add empty sub-module skeleton" + ``` + +--- + +### Task 3: Add `cron_last_check` Shared Field to `fusion_accounting_core` + +**Files:** +- Modify: `fusion_accounting_core/models/account_bank_statement_line.py` (file may need creating) +- Modify: `fusion_accounting_core/models/__init__.py` +- Create: `fusion_accounting_core/tests/test_shared_field_bank_statement.py` + +- [ ] **Step 1: Check if file exists** + + ```bash + ls /Users/gurpreet/Github/Odoo-Modules/fusion_accounting_core/models/account_bank_statement_line.py 2>/dev/null && echo "exists" || echo "missing" + ``` + +- [ ] **Step 2: Write the failing test** + + Path: `fusion_accounting_core/tests/test_shared_field_bank_statement.py` + ```python + from odoo.tests.common import TransactionCase, tagged + + + @tagged('post_install', '-at_install') + class TestSharedFieldBankStatementLine(TransactionCase): + """Verify fusion_accounting_core declares the Enterprise extension fields + on account.bank.statement.line so they survive Enterprise uninstall.""" + + def test_cron_last_check_field_exists(self): + Line = self.env['account.bank.statement.line'] + self.assertIn('cron_last_check', Line._fields, + "cron_last_check must be declared on account.bank.statement.line " + "(shared-field-ownership with account_accountant)") + self.assertEqual(Line._fields['cron_last_check'].type, 'datetime') + ``` + +- [ ] **Step 3: Run test, confirm RED** + + ```bash + orb -m odoo-westin-dev sudo docker exec westin-dev-app odoo \ + -d westin-v19 --test-tags post_install --stop-after-init --http-port=8099 \ + -c /etc/odoo/odoo.conf -u fusion_accounting_core --log-handler=odoo.tests:INFO 2>&1 | grep -E 'TestSharedFieldBankStatement|FAIL|AssertionError' + ``` + Expected: AssertionError on `cron_last_check must be declared`. + +- [ ] **Step 4: Create or update the model file** + + Path: `fusion_accounting_core/models/account_bank_statement_line.py` + ```python + """Shared-field-ownership for account.bank.statement.line. + + Enterprise's account_accountant adds cron_last_check (timestamp of last + auto-reconcile cron run for the line). By declaring it here with the same + schema, fusion_accounting_core becomes a co-owner so the column persists + when account_accountant uninstalls. + """ + + from odoo import fields, models + + + class AccountBankStatementLine(models.Model): + _inherit = "account.bank.statement.line" + + cron_last_check = fields.Datetime(copy=False) + ``` + +- [ ] **Step 5: Update `models/__init__.py`** + + Path: `fusion_accounting_core/models/__init__.py` — add (preserving existing imports): + ```python + from . import ir_module_module + from . import account_move + from . import account_reconcile_model + from . import account_bank_statement_line # NEW Phase 1 + ``` + +- [ ] **Step 6: Bump manifest version** + + Path: `fusion_accounting_core/__manifest__.py` — change `'version': '19.0.1.0.0'` → `'version': '19.0.1.0.1'`. + +- [ ] **Step 7: Run test, confirm GREEN** + + ```bash + orb -m odoo-westin-dev sudo docker exec westin-dev-app odoo \ + -d westin-v19 --test-tags post_install --stop-after-init --http-port=8099 \ + -c /etc/odoo/odoo.conf -u fusion_accounting_core --log-handler=odoo.tests:INFO 2>&1 | grep -E 'TestSharedFieldBankStatement|FAIL|PASS' + ``` + Expected: PASS. + +- [ ] **Step 8: Commit** + + ```bash + cd /Users/gurpreet/Github/Odoo-Modules + git add fusion_accounting_core/ + git commit -m "feat(fusion_accounting_core): shared-field-ownership for cron_last_check" + ``` + +--- + +### Task 4: Add Computed Coexistence Group to `fusion_accounting_core` + +**Files:** +- Create: `fusion_accounting_core/models/res_users.py` +- Modify: `fusion_accounting_core/models/__init__.py` +- Modify: `fusion_accounting_core/security/fusion_accounting_security.xml` (add the group definition) +- Create: `fusion_accounting_core/tests/test_coexistence_group.py` + +- [ ] **Step 1: Write the failing test** + + Path: `fusion_accounting_core/tests/test_coexistence_group.py` + ```python + from odoo.tests.common import TransactionCase, tagged + + + @tagged('post_install', '-at_install') + class TestCoexistenceGroup(TransactionCase): + """The 'show when Enterprise absent' group must exist and have computed membership.""" + + def test_group_exists(self): + group = self.env.ref('fusion_accounting_core.group_fusion_show_when_enterprise_absent', + raise_if_not_found=False) + self.assertTrue(group, "Coexistence group must exist") + + def test_membership_recomputes_with_module_state(self): + """A user is in the group iff Enterprise accounting is NOT installed.""" + user = self.env['res.users'].create({ + 'name': 'Test Coexistence User', + 'login': 'test_coexistence@example.com', + }) + group = self.env.ref('fusion_accounting_core.group_fusion_show_when_enterprise_absent') + enterprise_installed = self.env['ir.module.module']._fusion_is_enterprise_accounting_installed() + if enterprise_installed: + self.assertNotIn(user.id, group.user_ids.ids, + "User should NOT be in coexistence group when Enterprise installed") + else: + self.assertIn(user.id, group.user_ids.ids, + "User should be in coexistence group when Enterprise absent") + ``` + +- [ ] **Step 2: Run, confirm RED (group doesn't exist yet)** + + Same test command pattern. Expected: `ValueError: External ID not found`. + +- [ ] **Step 3: Add group declaration to security XML** + + Path: `fusion_accounting_core/security/fusion_accounting_security.xml` — append before the closing `` tag: + ```xml + + + Fusion: Show menus when Enterprise absent + Computed group. Membership: all internal users when no Enterprise accounting module is installed. Used to hide fusion sub-module menus that would conflict with Enterprise UIs. + + ``` + +- [ ] **Step 4: Write the model that recomputes membership** + + Path: `fusion_accounting_core/models/res_users.py` + ```python + """Recompute membership of group_fusion_show_when_enterprise_absent + whenever an Enterprise accounting module changes installation state.""" + + from odoo import api, models + + + class ResUsers(models.Model): + _inherit = "res.users" + + @api.model + def _fusion_recompute_coexistence_group(self): + """Set group membership = all internal users iff Enterprise absent. + Called from ir.module.module.button_immediate_install / uninstall overrides.""" + group = self.env.ref('fusion_accounting_core.group_fusion_show_when_enterprise_absent', + raise_if_not_found=False) + if not group: + return + enterprise_installed = self.env['ir.module.module']._fusion_is_enterprise_accounting_installed() + if enterprise_installed: + group.sudo().write({'user_ids': [(5, 0, 0)]}) # clear all + else: + all_internal = self.sudo().search([('share', '=', False)]) + group.sudo().write({'user_ids': [(6, 0, all_internal.ids)]}) + + + class IrModuleModule(models.Model): + _inherit = "ir.module.module" + + def button_immediate_install(self): + result = super().button_immediate_install() + self.env['res.users']._fusion_recompute_coexistence_group() + return result + + def button_immediate_uninstall(self): + result = super().button_immediate_uninstall() + self.env['res.users']._fusion_recompute_coexistence_group() + return result + + def module_uninstall(self): + result = super().module_uninstall() + self.env['res.users']._fusion_recompute_coexistence_group() + return result + ``` + + Note: `IrModuleModule._inherit` already exists in `ir_module_module.py` from Phase 0. The new overrides go into the SAME class via this file (Odoo merges inherited classes). That's fine — but to keep cohesion, MOVE this `IrModuleModule` block to `ir_module_module.py` instead. Updated structure: + + Edit `fusion_accounting_core/models/ir_module_module.py` — append the three overrides: + ```python + def button_immediate_install(self): + result = super().button_immediate_install() + self.env['res.users']._fusion_recompute_coexistence_group() + return result + + def button_immediate_uninstall(self): + result = super().button_immediate_uninstall() + self.env['res.users']._fusion_recompute_coexistence_group() + return result + + def module_uninstall(self): + # NOTE: existing _fusion_check_uninstall_guard call from Task 17 still runs + # via the existing override; this DOES NOT replace it. Append below safety check. + result = super().module_uninstall() + self.env['res.users']._fusion_recompute_coexistence_group() + return result + ``` + + Actually since `module_uninstall` was already overridden in Phase 0 (Task 17 safety guard), the existing override in `fusion_accounting_migration` already runs. The Phase 1 addition belongs in `fusion_accounting_core/models/ir_module_module.py` — Odoo's MRO merges the overrides. Verify carefully when implementing. + + Then `res_users.py` only contains the `ResUsers` class with `_fusion_recompute_coexistence_group`. + +- [ ] **Step 5: Update `models/__init__.py`** + + Add `from . import res_users` to `fusion_accounting_core/models/__init__.py`. + +- [ ] **Step 6: Add post-install hook to seed initial membership** + + Path: `fusion_accounting_core/__manifest__.py` — add `'post_init_hook': 'post_init_hook'`. + + Path: `fusion_accounting_core/__init__.py` — add at top: + ```python + from . import models + + + def post_init_hook(env): + """Initialize coexistence group membership based on current Enterprise install state.""" + env['res.users']._fusion_recompute_coexistence_group() + ``` + +- [ ] **Step 7: Bump manifest version → `19.0.1.0.2`** + +- [ ] **Step 8: Run test, verify GREEN** + + Same test command. Expected: both tests PASS. + +- [ ] **Step 9: Commit** + + ```bash + cd /Users/gurpreet/Github/Odoo-Modules + git add fusion_accounting_core/ + git commit -m "feat(fusion_accounting_core): add computed coexistence group + recompute hooks + + group_fusion_show_when_enterprise_absent has membership = all internal + users iff no Enterprise accounting module is installed. Membership is + recomputed on module install/uninstall via overrides on ir.module.module. + Used by Phase 1 fusion_bank_rec menus to auto-hide when Enterprise is + active and auto-appear after Enterprise uninstall." + ``` + +--- + +### Task 5: LLM Provider Contract + OpenAI Adapter Generalisation + +**Files:** +- Create: `fusion_accounting_ai/services/adapters/_base.py` +- Modify: `fusion_accounting_ai/services/adapters/__init__.py` +- Modify: `fusion_accounting_ai/services/adapters/openai_adapter.py` +- Create: `fusion_accounting_ai/tests/test_llm_provider_contract.py` + +- [ ] **Step 1: Write the failing test** + + Path: `fusion_accounting_ai/tests/test_llm_provider_contract.py` + ```python + from odoo.tests.common import TransactionCase, tagged + from odoo.addons.fusion_accounting_ai.services.adapters._base import LLMProvider + + + @tagged('post_install', '-at_install') + class TestLLMProviderContract(TransactionCase): + """Every LLM adapter must satisfy the LLMProvider contract.""" + + def test_base_class_defines_capability_attrs(self): + self.assertTrue(hasattr(LLMProvider, 'supports_tool_calling')) + self.assertTrue(hasattr(LLMProvider, 'supports_streaming')) + self.assertTrue(hasattr(LLMProvider, 'max_context_tokens')) + self.assertTrue(hasattr(LLMProvider, 'supports_embeddings')) + + def test_openai_adapter_implements_contract(self): + from odoo.addons.fusion_accounting_ai.services.adapters.openai_adapter import OpenAIAdapter + self.assertTrue(issubclass(OpenAIAdapter, LLMProvider)) + adapter = OpenAIAdapter(self.env) + self.assertIsInstance(adapter.supports_tool_calling, bool) + self.assertIsInstance(adapter.max_context_tokens, int) + + def test_openai_adapter_uses_configurable_base_url(self): + self.env['ir.config_parameter'].sudo().set_param( + 'fusion_accounting.openai_base_url', 'http://localhost:1234/v1') + self.env['ir.config_parameter'].sudo().set_param( + 'fusion_accounting.openai_api_key', 'lm-studio-test-key') + from odoo.addons.fusion_accounting_ai.services.adapters.openai_adapter import OpenAIAdapter + adapter = OpenAIAdapter(self.env) + # The OpenAI client should have base_url == our config + self.assertEqual(str(adapter.client.base_url).rstrip('/'), + 'http://localhost:1234/v1') + ``` + +- [ ] **Step 2: Run, confirm RED** + + Expected: ImportError for `_base` (file doesn't exist). + +- [ ] **Step 3: Write the contract base class** + + Path: `fusion_accounting_ai/services/adapters/_base.py` + ```python + """LLMProvider contract — every adapter must conform. + + Phase 1 generalisation: makes local LLM (Ollama, LM Studio, vLLM, llamafile, + llama.cpp HTTP server) a one-config-line drop-in via the OpenAI-compatible + HTTP API surface that all of them expose. + """ + + + class LLMProvider: + """Contract every LLM backend must satisfy. Adapters declare capabilities + as class attributes; the engine inspects them before calling optional methods.""" + + supports_tool_calling: bool = False + supports_streaming: bool = False + max_context_tokens: int = 4096 + supports_embeddings: bool = False + + def __init__(self, env): + self.env = env + + def complete(self, *, system, messages, max_tokens=2048, temperature=0.0) -> dict: + """Plain text completion. Required for ALL providers. + + Returns: {'content': str, 'tokens_used': int, 'model': str} + """ + raise NotImplementedError + + def complete_with_tools(self, *, system, messages, tools, max_tokens=2048) -> dict: + """Tool-calling completion. Optional — caller checks supports_tool_calling first. + + Returns: {'content': str, 'tool_calls': [{'name': str, 'arguments': dict}], ...} + """ + raise NotImplementedError( + f"{type(self).__name__} does not support tool-calling. " + f"Check supports_tool_calling before calling.") + + def embed(self, texts: list[str]) -> list[list[float]]: + """Embeddings. Optional — caller checks supports_embeddings first. + + Returns: list of float vectors, one per input text. + """ + raise NotImplementedError( + f"{type(self).__name__} does not support embeddings. " + f"Check supports_embeddings before calling.") + ``` + +- [ ] **Step 4: Update `openai_adapter.py` — generalise base_url + inherit LLMProvider** + + Read `fusion_accounting_ai/services/adapters/openai_adapter.py` first to see current structure. + + Then ensure these key changes (preserve existing message/response logic): + ```python + from openai import OpenAI + from ._base import LLMProvider + + + class OpenAIAdapter(LLMProvider): + """OpenAI-compatible HTTP adapter. + + Configurable via ir.config_parameter: + - fusion_accounting.openai_base_url (default: https://api.openai.com/v1) + - fusion_accounting.openai_api_key + - fusion_accounting.openai_model (default: gpt-4o-mini) + + Works against any OpenAI-compatible endpoint: + - OpenAI: https://api.openai.com/v1 + - LM Studio: http://host.docker.internal:1234/v1 + - Ollama: http://host.docker.internal:11434/v1 + - vLLM: http://:8000/v1 + """ + + supports_tool_calling = True + supports_streaming = True + max_context_tokens = 128000 # default; overridable per model + supports_embeddings = True + + def __init__(self, env): + super().__init__(env) + param = env['ir.config_parameter'].sudo() + api_key = param.get_param('fusion_accounting.openai_api_key', 'unused') + base_url = param.get_param('fusion_accounting.openai_base_url', + 'https://api.openai.com/v1') + self.model = param.get_param('fusion_accounting.openai_model', 'gpt-4o-mini') + self.client = OpenAI(api_key=api_key, base_url=base_url) + + def complete(self, *, system, messages, max_tokens=2048, temperature=0.0) -> dict: + all_messages = [{'role': 'system', 'content': system}] + messages + response = self.client.chat.completions.create( + model=self.model, + messages=all_messages, + max_tokens=max_tokens, + temperature=temperature, + ) + return { + 'content': response.choices[0].message.content, + 'tokens_used': response.usage.total_tokens, + 'model': response.model, + } + + def complete_with_tools(self, *, system, messages, tools, max_tokens=2048) -> dict: + # ... existing tool-calling logic preserved + pass # implementer: keep what's there, just inherits LLMProvider now + + def embed(self, texts: list[str]) -> list[list[float]]: + response = self.client.embeddings.create( + model='text-embedding-3-small', + input=texts, + ) + return [d.embedding for d in response.data] + ``` + +- [ ] **Step 5: Update `adapters/__init__.py` to export `LLMProvider`** + + Path: `fusion_accounting_ai/services/adapters/__init__.py` + ```python + from ._base import LLMProvider + ``` + +- [ ] **Step 6: Update Claude adapter to also inherit `LLMProvider`** (so the contract is consistent) + + Read `fusion_accounting_ai/services/adapters/claude.py`. Add `LLMProvider` as base class. Set capability attributes: + ```python + class ClaudeAdapter(LLMProvider): + supports_tool_calling = True + supports_streaming = True + max_context_tokens = 200000 + supports_embeddings = False + # ... existing init/complete logic preserved + ``` + +- [ ] **Step 7: Bump fusion_accounting_ai manifest → `19.0.1.0.1`** + +- [ ] **Step 8: Run all tests, verify nothing regressed** + + ```bash + orb -m odoo-westin-dev sudo docker exec westin-dev-app odoo \ + -d westin-v19 --test-tags post_install --stop-after-init --http-port=8099 \ + -c /etc/odoo/odoo.conf -u fusion_accounting_ai --log-handler=odoo.tests:INFO 2>&1 \ + | grep -E 'TestLLMProvider|TestDataAdapter|TestPostMigration|FAIL|PASS|tests.stats|tests.result' + ``` + Expected: TestLLMProviderContract PASSES; existing fusion_accounting_ai tests still pass. + +- [ ] **Step 9: Commit** + + ```bash + cd /Users/gurpreet/Github/Odoo-Modules + git add fusion_accounting_ai/ + git commit -m "feat(fusion_accounting_ai): add LLMProvider contract + configurable openai base_url + + Phase 1 prerequisite for local LLM support. Adapters now declare + capability flags (supports_tool_calling, max_context_tokens, etc.) so + the engine can reason about what backend is available. + + OpenAI adapter accepts fusion_accounting.openai_base_url config — point + it at LM Studio (http://host.docker.internal:1234/v1) or Ollama + (http://host.docker.internal:11434/v1) and the existing OpenAI adapter + works unchanged." + ``` + +--- + +## Group 2: Reconcile Engine — TDD Layered Build + +### Task 6: `services/memo_tokenizer.py` + +Pure-Python utility to extract keywords from Canadian bank memos (RBC, TD, Scotia, BMO conventions). No ORM dependency — fastest test cycle. + +**Files:** +- Create: `fusion_accounting_bank_rec/services/memo_tokenizer.py` +- Create: `fusion_accounting_bank_rec/tests/test_memo_tokenizer.py` + +- [ ] **Step 1: Write failing test** + + Path: `fusion_accounting_bank_rec/tests/test_memo_tokenizer.py` + ```python + from odoo.tests.common import TransactionCase, tagged + from odoo.addons.fusion_accounting_bank_rec.services.memo_tokenizer import tokenize_memo + + + @tagged('post_install', '-at_install') + class TestMemoTokenizer(TransactionCase): + + def test_extracts_rbc_etf_reference(self): + tokens = tokenize_memo("RBC ETF DEP REF 4831") + self.assertIn('RBC', tokens) + self.assertIn('ETF', tokens) + self.assertIn('REF4831', tokens) # ref+number stays cohesive + + def test_extracts_cheque_number(self): + tokens = tokenize_memo("CHEQUE 4827 - WESTIN PLATING") + self.assertIn('CHEQUE', tokens) + self.assertIn('CHEQUE4827', tokens) + self.assertIn('WESTIN', tokens) + self.assertIn('PLATING', tokens) + + def test_strips_noise_tokens(self): + tokens = tokenize_memo("PAYMENT - INV - DEP - 12345") + self.assertNotIn('-', tokens) + self.assertEqual([t for t in tokens if len(t) <= 1], []) + + def test_handles_empty_memo(self): + self.assertEqual(tokenize_memo(""), []) + self.assertEqual(tokenize_memo(None), []) + + def test_canadian_french_memo(self): + tokens = tokenize_memo("PAIEMENT VIREMENT BANCAIRE") + self.assertIn('PAIEMENT', tokens) + self.assertIn('VIREMENT', tokens) + + def test_normalises_case(self): + tokens = tokenize_memo("rbc etf dep ref 4831") + self.assertIn('RBC', tokens) # uppercase regardless of input + + def test_handles_special_characters(self): + tokens = tokenize_memo("RBC*PAYMENT/REF#4831") + self.assertIn('RBC', tokens) + self.assertIn('PAYMENT', tokens) + self.assertIn('REF4831', tokens) + ``` + +- [ ] **Step 2: Run, confirm RED (ImportError)** + +- [ ] **Step 3: Write implementation** + + Path: `fusion_accounting_bank_rec/services/memo_tokenizer.py` + ```python + """Extract searchable tokens from Canadian bank statement memos. + + Handles common memo formats from RBC, TD, Scotia, BMO, plus generic + cheque-number and reference-number patterns. Output is normalized + (uppercase, alphanumeric) for case-insensitive matching. + """ + + import re + + # Canadian bank prefixes worth preserving as standalone tokens + CANONICAL_PREFIXES = {'RBC', 'TD', 'BMO', 'SCOTIA', 'CIBC', 'NATIONAL'} + + # Reference patterns: collapses "REF 4831" → "REF4831", "CHQ 4827" → "CHEQUE4827" + REF_PATTERNS = [ + (re.compile(r'\b(REF|REFERENCE)\s*#?\s*(\d+)\b', re.I), r'REF\2'), + (re.compile(r'\b(CHQ|CHEQUE|CHECK)\s*#?\s*(\d+)\b', re.I), r'CHEQUE\2'), + (re.compile(r'\b(INV|INVOICE)\s*#?\s*(\d+)\b', re.I), r'INV\2'), + ] + + # Tokens shorter than this (after normalization) are dropped as noise + MIN_TOKEN_LENGTH = 2 + + + def tokenize_memo(memo: str | None) -> list[str]: + """Return list of normalized tokens from a bank memo. + + Empty/None input returns []. Order preserved (first occurrence wins + for de-duplication).""" + if not memo: + return [] + + text = memo.upper() + for pattern, replacement in REF_PATTERNS: + text = pattern.sub(replacement, text) + + # Replace special chars with spaces, then split + text = re.sub(r'[^A-Z0-9]+', ' ', text) + raw_tokens = text.split() + + seen = set() + tokens = [] + for tok in raw_tokens: + if len(tok) < MIN_TOKEN_LENGTH: + continue + if tok in seen: + continue + seen.add(tok) + tokens.append(tok) + + return tokens + ``` + +- [ ] **Step 4: Update `services/__init__.py`** + + Path: `fusion_accounting_bank_rec/services/__init__.py` + ```python + from . import memo_tokenizer + ``` + +- [ ] **Step 5: Update `tests/__init__.py`** + + Path: `fusion_accounting_bank_rec/tests/__init__.py` + ```python + from . import test_memo_tokenizer + ``` + +- [ ] **Step 6: Run tests, verify GREEN** + + ```bash + orb -m odoo-westin-dev sudo docker exec westin-dev-app odoo \ + -d westin-v19 --test-tags post_install --stop-after-init --http-port=8099 \ + -c /etc/odoo/odoo.conf -u fusion_accounting_bank_rec --log-handler=odoo.tests:INFO 2>&1 \ + | grep -E 'TestMemoTokenizer|FAIL|PASS|tests.stats' + ``` + Expected: 7 tests PASS. + +- [ ] **Step 7: Commit** + + ```bash + cd /Users/gurpreet/Github/Odoo-Modules + git add fusion_accounting_bank_rec/services/memo_tokenizer.py \ + fusion_accounting_bank_rec/services/__init__.py \ + fusion_accounting_bank_rec/tests/test_memo_tokenizer.py \ + fusion_accounting_bank_rec/tests/__init__.py + git commit -m "feat(fusion_accounting_bank_rec): memo_tokenizer for Canadian bank memo formats" + ``` + +--- + +### Task 7: `services/exchange_diff.py` + +Pure-Python helper that wraps Odoo's `_create_exchange_difference_move` — keeps engine signature stable while delegating to Odoo's FX math. + +**Files:** +- Create: `fusion_accounting_bank_rec/services/exchange_diff.py` +- Create: `fusion_accounting_bank_rec/tests/test_exchange_diff.py` + +- [ ] **Step 1: Write failing test** + + Path: `fusion_accounting_bank_rec/tests/test_exchange_diff.py` + ```python + from odoo.tests.common import TransactionCase, tagged + from odoo.addons.fusion_accounting_bank_rec.services.exchange_diff import ( + compute_exchange_diff, ExchangeDiffResult, + ) + + + @tagged('post_install', '-at_install') + class TestExchangeDiff(TransactionCase): + + def test_no_diff_when_currencies_match(self): + result = compute_exchange_diff( + line_amount=100.00, line_currency_code='CAD', + against_amount=100.00, against_currency_code='CAD', + line_rate=1.0, against_rate=1.0, + ) + self.assertFalse(result.needs_diff_move) + self.assertEqual(result.diff_amount, 0.0) + + def test_diff_when_rates_differ_same_currency(self): + """USD invoice posted at 1.35, USD bank line settled at 1.40 → diff exists""" + result = compute_exchange_diff( + line_amount=100.00, line_currency_code='USD', + against_amount=100.00, against_currency_code='USD', + line_rate=1.40, against_rate=1.35, + ) + self.assertTrue(result.needs_diff_move) + # 100 USD at 1.40 = 140 CAD; same at 1.35 = 135 CAD; diff = 5 CAD gain + self.assertAlmostEqual(result.diff_amount, 5.00, places=2) + + def test_diff_negative_when_rate_dropped(self): + """USD invoice at 1.40, settled at 1.35 → loss""" + result = compute_exchange_diff( + line_amount=100.00, line_currency_code='USD', + against_amount=100.00, against_currency_code='USD', + line_rate=1.35, against_rate=1.40, + ) + self.assertTrue(result.needs_diff_move) + self.assertAlmostEqual(result.diff_amount, -5.00, places=2) + ``` + +- [ ] **Step 2: Run, confirm RED** + +- [ ] **Step 3: Write implementation** + + Path: `fusion_accounting_bank_rec/services/exchange_diff.py` + ```python + """Exchange-difference calculation helper. + + Pure-Python FX gain/loss computation. The engine uses this for rapid + pre-checks; Odoo's account.move._create_exchange_difference_move() is + invoked separately for the actual GL posting. + """ + + from dataclasses import dataclass + + + @dataclass + class ExchangeDiffResult: + needs_diff_move: bool + diff_amount: float # in company currency; positive = gain, negative = loss + line_company_amount: float + against_company_amount: float + + + def compute_exchange_diff(*, line_amount, line_currency_code, against_amount, + against_currency_code, line_rate, against_rate) -> ExchangeDiffResult: + """Compute whether an exchange-diff move is needed and its magnitude. + + Args: + line_amount: Bank line amount in its currency + line_currency_code: e.g. 'USD' + against_amount: Matched journal item amount in its currency + against_currency_code: e.g. 'USD' (or different) + line_rate: FX rate (foreign per company currency) at line date + against_rate: FX rate at journal item posting date + + Returns: + ExchangeDiffResult with needs_diff_move flag and computed diff. + """ + line_company = line_amount * line_rate + against_company = against_amount * against_rate + + diff = line_company - against_company + needs_diff = abs(diff) > 0.005 # rounding tolerance + + return ExchangeDiffResult( + needs_diff_move=needs_diff, + diff_amount=round(diff, 2), + line_company_amount=round(line_company, 2), + against_company_amount=round(against_company, 2), + ) + ``` + +- [ ] **Step 4: Update `services/__init__.py`** — add `from . import exchange_diff` + +- [ ] **Step 5: Update `tests/__init__.py`** — add `from . import test_exchange_diff` + +- [ ] **Step 6: Run tests, verify GREEN. Commit.** + + ```bash + cd /Users/gurpreet/Github/Odoo-Modules + git add fusion_accounting_bank_rec/services/exchange_diff.py \ + fusion_accounting_bank_rec/services/__init__.py \ + fusion_accounting_bank_rec/tests/test_exchange_diff.py \ + fusion_accounting_bank_rec/tests/__init__.py + git commit -m "feat(fusion_accounting_bank_rec): exchange_diff helper for FX gain/loss pre-check" + ``` + +--- + +### Task 8: `services/matching_strategies.py` (3 strategies) + +**Files:** +- Create: `fusion_accounting_bank_rec/services/matching_strategies.py` +- Create: `fusion_accounting_bank_rec/tests/test_matching_strategies.py` + +- [ ] **Step 1: Write failing tests for all 3 strategies** + + Path: `fusion_accounting_bank_rec/tests/test_matching_strategies.py` + ```python + from odoo.tests.common import TransactionCase, tagged + from odoo.addons.fusion_accounting_bank_rec.services.matching_strategies import ( + Candidate, AmountExactStrategy, FIFOStrategy, MultiInvoiceStrategy, MatchResult, + ) + + + @tagged('post_install', '-at_install') + class TestAmountExactStrategy(TransactionCase): + + def test_picks_exact_amount(self): + candidates = [ + Candidate(id=1, amount=99.99, partner_id=42, age_days=10), + Candidate(id=2, amount=100.00, partner_id=42, age_days=20), + Candidate(id=3, amount=100.50, partner_id=42, age_days=5), + ] + result = AmountExactStrategy().match(bank_amount=100.00, candidates=candidates) + self.assertEqual(result.picked_ids, [2]) + self.assertEqual(result.confidence, 1.0) + + def test_no_match_when_no_exact(self): + candidates = [ + Candidate(id=1, amount=99.99, partner_id=42, age_days=10), + Candidate(id=2, amount=100.50, partner_id=42, age_days=20), + ] + result = AmountExactStrategy().match(bank_amount=100.00, candidates=candidates) + self.assertEqual(result.picked_ids, []) + + def test_picks_oldest_when_multiple_exact(self): + candidates = [ + Candidate(id=1, amount=100.00, partner_id=42, age_days=10), + Candidate(id=2, amount=100.00, partner_id=42, age_days=30), # oldest + Candidate(id=3, amount=100.00, partner_id=42, age_days=20), + ] + result = AmountExactStrategy().match(bank_amount=100.00, candidates=candidates) + self.assertEqual(result.picked_ids, [2]) + + + @tagged('post_install', '-at_install') + class TestFIFOStrategy(TransactionCase): + + def test_picks_oldest_first(self): + candidates = [ + Candidate(id=1, amount=50.00, partner_id=42, age_days=10), + Candidate(id=2, amount=50.00, partner_id=42, age_days=30), + Candidate(id=3, amount=50.00, partner_id=42, age_days=20), + ] + result = FIFOStrategy().match(bank_amount=100.00, candidates=candidates) + self.assertEqual(result.picked_ids, [2, 3]) # oldest two summing to 100 + + def test_handles_partial_payment(self): + candidates = [ + Candidate(id=1, amount=200.00, partner_id=42, age_days=30), + ] + result = FIFOStrategy().match(bank_amount=100.00, candidates=candidates) + self.assertEqual(result.picked_ids, [1]) # partial reconcile signaled by residual + self.assertEqual(result.residual, -100.00) # over-allocated; engine handles + + + @tagged('post_install', '-at_install') + class TestMultiInvoiceStrategy(TransactionCase): + + def test_finds_smallest_set_summing_to_amount(self): + candidates = [ + Candidate(id=1, amount=30.00, partner_id=42, age_days=10), + Candidate(id=2, amount=40.00, partner_id=42, age_days=15), + Candidate(id=3, amount=30.00, partner_id=42, age_days=20), + Candidate(id=4, amount=70.00, partner_id=42, age_days=25), + ] + result = MultiInvoiceStrategy(max_combinations=3).match( + bank_amount=100.00, candidates=candidates) + # Two valid: [1,2,3] = 100 or [3,4] = 100 (smaller set wins) + self.assertIn(set(result.picked_ids), [{3, 4}, {1, 2}]) # both 2-sets are valid + + def test_returns_empty_when_no_combination_sums(self): + candidates = [ + Candidate(id=1, amount=15.00, partner_id=42, age_days=10), + Candidate(id=2, amount=25.00, partner_id=42, age_days=15), + ] + result = MultiInvoiceStrategy(max_combinations=3).match( + bank_amount=100.00, candidates=candidates) + self.assertEqual(result.picked_ids, []) + + def test_respects_max_combinations(self): + # Many small invoices that COULD sum to 100 with 5+ items + candidates = [Candidate(id=i, amount=10.00, partner_id=42, age_days=i) + for i in range(1, 11)] + result = MultiInvoiceStrategy(max_combinations=3).match( + bank_amount=100.00, candidates=candidates) + # Can't make 100 with ≤3 items of $10 each + self.assertEqual(result.picked_ids, []) + ``` + +- [ ] **Step 2: Run, confirm RED** + +- [ ] **Step 3: Write implementation** + + Path: `fusion_accounting_bank_rec/services/matching_strategies.py` + ```python + """Matching strategy classes for the reconcile engine. + + Each strategy takes a bank amount + list of candidate journal items + and returns a MatchResult with the picked ids + confidence + residual. + Strategies are pure Python; no ORM dependency. + """ + + from dataclasses import dataclass, field + from itertools import combinations + + + @dataclass + class Candidate: + id: int + amount: float + partner_id: int + age_days: int + + + @dataclass + class MatchResult: + picked_ids: list[int] = field(default_factory=list) + confidence: float = 0.0 + residual: float = 0.0 # bank_amount - sum(picked); positive = under-allocated + strategy_name: str = "" + + + AMOUNT_TOLERANCE = 0.005 # currency rounding tolerance + + + class AmountExactStrategy: + """Pick a single candidate whose amount equals the bank amount exactly. + If multiple candidates match exactly, pick the oldest (FIFO tiebreaker).""" + + def match(self, *, bank_amount: float, candidates: list[Candidate]) -> MatchResult: + exact = [c for c in candidates if abs(c.amount - bank_amount) < AMOUNT_TOLERANCE] + if not exact: + return MatchResult(strategy_name='amount_exact') + # Tiebreak: oldest first + oldest = max(exact, key=lambda c: c.age_days) + return MatchResult( + picked_ids=[oldest.id], + confidence=1.0, + residual=0.0, + strategy_name='amount_exact', + ) + + + class FIFOStrategy: + """Pick oldest candidates first until the bank amount is exhausted. + May produce partial reconcile residual if last candidate doesn't fit exactly.""" + + def match(self, *, bank_amount: float, candidates: list[Candidate]) -> MatchResult: + if not candidates: + return MatchResult(strategy_name='fifo') + oldest_first = sorted(candidates, key=lambda c: -c.age_days) + picked = [] + remaining = bank_amount + for c in oldest_first: + if remaining <= AMOUNT_TOLERANCE: + break + picked.append(c.id) + remaining -= c.amount + + confidence = 0.7 if remaining < AMOUNT_TOLERANCE else 0.5 + return MatchResult( + picked_ids=picked, + confidence=confidence, + residual=remaining, # negative if over-allocated, positive if under + strategy_name='fifo', + ) + + + class MultiInvoiceStrategy: + """Find the smallest combination of candidates summing to the bank amount. + Bounded by max_combinations to keep complexity manageable.""" + + def __init__(self, max_combinations=3): + self.max_combinations = max_combinations + + def match(self, *, bank_amount: float, candidates: list[Candidate]) -> MatchResult: + for k in range(2, self.max_combinations + 1): + for combo in combinations(candidates, k): + total = sum(c.amount for c in combo) + if abs(total - bank_amount) < AMOUNT_TOLERANCE: + return MatchResult( + picked_ids=[c.id for c in combo], + confidence=0.85, + residual=0.0, + strategy_name=f'multi_invoice_{k}', + ) + return MatchResult(strategy_name='multi_invoice') + ``` + +- [ ] **Step 4: Update `services/__init__.py`** — add `from . import matching_strategies`. + +- [ ] **Step 5: Update `tests/__init__.py`** — add `from . import test_matching_strategies`. + +- [ ] **Step 6: Run, verify GREEN. Commit.** + + ```bash + cd /Users/gurpreet/Github/Odoo-Modules + git add fusion_accounting_bank_rec/services/matching_strategies.py \ + fusion_accounting_bank_rec/services/__init__.py \ + fusion_accounting_bank_rec/tests/test_matching_strategies.py \ + fusion_accounting_bank_rec/tests/__init__.py + git commit -m "feat(fusion_accounting_bank_rec): matching strategies (AmountExact, FIFO, MultiInvoice)" + ``` + +--- + +### Task 9: `services/precedent_lookup.py` + +K-nearest-precedent search using indexed columns. Reads from `fusion.reconcile.precedent` (model exists in Task 14). + +**Files:** +- Create: `fusion_accounting_bank_rec/services/precedent_lookup.py` +- Create: `fusion_accounting_bank_rec/tests/test_precedent_lookup.py` + +This task **depends on Task 14** (precedent model). Implement Task 14 first if working sequentially. + +- [ ] **Step 1: Write failing test (will RED on missing fusion.reconcile.precedent model)** + + Path: `fusion_accounting_bank_rec/tests/test_precedent_lookup.py` + ```python + from datetime import date + from odoo.tests.common import TransactionCase, tagged + from odoo.addons.fusion_accounting_bank_rec.services.precedent_lookup import ( + find_nearest_precedents, + ) + + + @tagged('post_install', '-at_install') + class TestPrecedentLookup(TransactionCase): + + def setUp(self): + super().setUp() + self.partner = self.env['res.partner'].create({'name': 'Test Partner'}) + self.currency = self.env.ref('base.CAD') + self.company = self.env.company + # Create some precedents + for i, (amt, age) in enumerate([(1847.50, 1), (1847.50, 14), (1800.00, 28)]): + self.env['fusion.reconcile.precedent'].create({ + 'company_id': self.company.id, + 'partner_id': self.partner.id, + 'amount': amt, + 'currency_id': self.currency.id, + 'date': date.today(), + 'memo_tokens': 'RBC,ETF,REF', + 'matched_move_line_count': 1, + 'source': 'manual', + }) + + def test_finds_amount_exact_precedents(self): + results = find_nearest_precedents( + self.env, partner_id=self.partner.id, amount=1847.50, k=5) + self.assertEqual(len(results), 2) # both $1,847.50 precedents + self.assertTrue(all(r.amount == 1847.50 for r in results)) + + def test_returns_empty_for_unknown_partner(self): + results = find_nearest_precedents( + self.env, partner_id=999999, amount=1847.50, k=5) + self.assertEqual(results, []) + + def test_respects_k_limit(self): + # Add 10 more precedents + for i in range(10): + self.env['fusion.reconcile.precedent'].create({ + 'company_id': self.company.id, + 'partner_id': self.partner.id, + 'amount': 1847.50, + 'currency_id': self.currency.id, + 'date': date.today(), + 'matched_move_line_count': 1, + 'source': 'manual', + }) + results = find_nearest_precedents( + self.env, partner_id=self.partner.id, amount=1847.50, k=3) + self.assertEqual(len(results), 3) + ``` + +- [ ] **Step 2: Run, confirm RED** + +- [ ] **Step 3: Write implementation** + + Path: `fusion_accounting_bank_rec/services/precedent_lookup.py` + ```python + """K-nearest precedent search. + + Given a new bank line, find the most similar past reconciliations for + ranking + confidence scoring. Distance metric: amount delta (primary), + date recency (secondary), memo token overlap (tertiary). + """ + + from dataclasses import dataclass + + + @dataclass + class PrecedentMatch: + precedent_id: int + amount: float + memo_tokens: str + matched_move_line_count: int + similarity_score: float + + + AMOUNT_TOLERANCE_PCT = 0.01 # 1% tolerance for "near" amount + + + def find_nearest_precedents(env, *, partner_id, amount, k=5, memo_tokens=None): + """Return up to k most-similar precedents for a partner+amount. + + Indexed query: filters by partner first (cheap), then ranks by + amount distance, then takes K. Sub-50ms for typical Westin volume.""" + Precedent = env['fusion.reconcile.precedent'].sudo() + + tolerance = max(amount * AMOUNT_TOLERANCE_PCT, 1.00) + candidates = Precedent.search([ + ('partner_id', '=', partner_id), + ('amount', '>=', amount - tolerance), + ('amount', '<=', amount + tolerance), + ], limit=k * 4, order='reconciled_at desc') # over-fetch for ranking + + results = [] + for p in candidates: + # Similarity score: 1.0 at amount-exact, decays with distance + amount_score = 1.0 - min(abs(p.amount - amount) / max(amount, 1), 1.0) + memo_score = _memo_overlap(p.memo_tokens, memo_tokens) if memo_tokens else 0.5 + similarity = (amount_score * 0.7) + (memo_score * 0.3) + results.append(PrecedentMatch( + precedent_id=p.id, + amount=p.amount, + memo_tokens=p.memo_tokens or '', + matched_move_line_count=p.matched_move_line_count, + similarity_score=similarity, + )) + + results.sort(key=lambda r: -r.similarity_score) + return results[:k] + + + def _memo_overlap(precedent_tokens_str, new_tokens) -> float: + """Jaccard similarity between two token sets.""" + if not precedent_tokens_str or not new_tokens: + return 0.0 + precedent_set = set(precedent_tokens_str.split(',')) + new_set = set(new_tokens) if not isinstance(new_tokens, set) else new_tokens + if not precedent_set and not new_set: + return 0.0 + return len(precedent_set & new_set) / len(precedent_set | new_set) + ``` + +- [ ] **Step 4: Update `services/__init__.py`** and **`tests/__init__.py`**. + +- [ ] **Step 5: Run tests, verify GREEN.** + + Note: this task **requires Task 14 first** (the `fusion.reconcile.precedent` model). If running before Task 14, the test will FAIL on missing model — defer this task to after Task 14, or stub the model first. + +- [ ] **Step 6: Commit** + + ```bash + cd /Users/gurpreet/Github/Odoo-Modules + git add fusion_accounting_bank_rec/services/precedent_lookup.py \ + fusion_accounting_bank_rec/services/__init__.py \ + fusion_accounting_bank_rec/tests/test_precedent_lookup.py \ + fusion_accounting_bank_rec/tests/__init__.py + git commit -m "feat(fusion_accounting_bank_rec): precedent_lookup K-nearest search" + ``` + +--- + +### Task 10: `services/pattern_extractor.py` + +Aggregates patterns from precedent rows into per-partner `fusion.reconcile.pattern` records. + +**Files:** +- Create: `fusion_accounting_bank_rec/services/pattern_extractor.py` +- Create: `fusion_accounting_bank_rec/tests/test_pattern_extraction.py` + +**Depends on Task 14** (pattern + precedent models). + +- [ ] **Step 1: Write failing tests** + + Path: `fusion_accounting_bank_rec/tests/test_pattern_extraction.py` + ```python + from datetime import date, timedelta + from odoo.tests.common import TransactionCase, tagged + from odoo.addons.fusion_accounting_bank_rec.services.pattern_extractor import ( + extract_pattern_for_partner, + ) + + + @tagged('post_install', '-at_install') + class TestPatternExtractor(TransactionCase): + + def setUp(self): + super().setUp() + self.partner = self.env['res.partner'].create({'name': 'Pattern Test Partner'}) + self.currency = self.env.ref('base.CAD') + self.company = self.env.company + + def _make_precedent(self, *, amount, days_ago, memo='RBC,ETF', count=1, source='manual'): + return self.env['fusion.reconcile.precedent'].create({ + 'company_id': self.company.id, + 'partner_id': self.partner.id, + 'amount': amount, + 'currency_id': self.currency.id, + 'date': date.today() - timedelta(days=days_ago), + 'memo_tokens': memo, + 'matched_move_line_count': count, + 'reconciled_at': date.today() - timedelta(days=days_ago), + 'source': source, + }) + + def test_extracts_typical_amount_range(self): + for d in [10, 24, 38, 52]: + self._make_precedent(amount=1847.50, days_ago=d) + + pattern_vals = extract_pattern_for_partner( + self.env, company_id=self.company.id, partner_id=self.partner.id) + self.assertIn('typical_amount_range', pattern_vals) + self.assertEqual(pattern_vals['reconcile_count'], 4) + + def test_detects_exact_amount_strategy(self): + # All same amount → exact_amount strategy + for d in range(0, 56, 14): + self._make_precedent(amount=1847.50, days_ago=d, count=1) + pattern_vals = extract_pattern_for_partner( + self.env, company_id=self.company.id, partner_id=self.partner.id) + self.assertEqual(pattern_vals['pref_strategy'], 'exact_amount') + + def test_detects_multi_invoice_strategy(self): + # All have count > 1 → multi_invoice + for d in range(0, 56, 14): + self._make_precedent(amount=2500.00, days_ago=d, count=3) + pattern_vals = extract_pattern_for_partner( + self.env, company_id=self.company.id, partner_id=self.partner.id) + self.assertEqual(pattern_vals['pref_strategy'], 'multi_invoice') + + def test_computes_cadence_days(self): + for d in [0, 14, 28, 42]: + self._make_precedent(amount=1000, days_ago=d) + pattern_vals = extract_pattern_for_partner( + self.env, company_id=self.company.id, partner_id=self.partner.id) + self.assertAlmostEqual(pattern_vals['typical_cadence_days'], 14.0, delta=1) + + def test_extracts_common_memo_tokens(self): + self._make_precedent(amount=1000, days_ago=10, memo='RBC,ETF,REF') + self._make_precedent(amount=1000, days_ago=24, memo='RBC,ETF,DEPOSIT') + self._make_precedent(amount=1000, days_ago=38, memo='RBC,ETF,REF') + pattern_vals = extract_pattern_for_partner( + self.env, company_id=self.company.id, partner_id=self.partner.id) + self.assertIn('RBC', pattern_vals['common_memo_tokens']) + self.assertIn('ETF', pattern_vals['common_memo_tokens']) + ``` + +- [ ] **Step 2: Run, confirm RED** + +- [ ] **Step 3: Write implementation** + + Path: `fusion_accounting_bank_rec/services/pattern_extractor.py` + ```python + """Aggregate per-partner reconciliation patterns from precedent rows. + + Computes typical amount range, cadence, preferred strategy, common memo + tokens. Output is a dict suitable for create/write on fusion.reconcile.pattern. + """ + + from collections import Counter + from statistics import median + + + def extract_pattern_for_partner(env, *, company_id, partner_id) -> dict: + """Compute the pattern aggregate for one (company, partner) pair. + + Returns vals dict suitable for env['fusion.reconcile.pattern'].create().""" + Precedent = env['fusion.reconcile.precedent'].sudo() + precedents = Precedent.search([ + ('company_id', '=', company_id), + ('partner_id', '=', partner_id), + ], order='reconciled_at desc', limit=200) + + if not precedents: + return { + 'company_id': company_id, + 'partner_id': partner_id, + 'reconcile_count': 0, + } + + amounts = sorted(precedents.mapped('amount')) + counts = precedents.mapped('matched_move_line_count') + + # Strategy detection + single_count = sum(1 for c in counts if c == 1) + multi_count = sum(1 for c in counts if c > 1) + if multi_count > single_count: + pref_strategy = 'multi_invoice' + elif _amounts_concentrated(amounts): + pref_strategy = 'exact_amount' + else: + pref_strategy = 'fifo' + + # Cadence (mean days between reconciles) + reconcile_dates = sorted(precedents.mapped('reconciled_at')) + if len(reconcile_dates) >= 2: + deltas = [(reconcile_dates[i+1] - reconcile_dates[i]).days + for i in range(len(reconcile_dates) - 1)] + cadence = sum(deltas) / len(deltas) if deltas else 0.0 + else: + cadence = 0.0 + + # Memo tokens — count occurrences across all precedents + token_counter = Counter() + for p in precedents: + if p.memo_tokens: + for tok in p.memo_tokens.split(','): + token_counter[tok.strip()] += 1 + # Keep tokens appearing in ≥30% of precedents + threshold = max(2, len(precedents) * 0.3) + common_tokens = ','.join(t for t, c in token_counter.most_common() if c >= threshold) + + return { + 'company_id': company_id, + 'partner_id': partner_id, + 'reconcile_count': len(precedents), + 'typical_amount_range': f"${min(amounts):,.2f} – ${max(amounts):,.2f} (median ${median(amounts):,.2f})", + 'typical_cadence_days': round(cadence, 1), + 'pref_strategy': pref_strategy, + 'common_memo_tokens': common_tokens, + } + + + def _amounts_concentrated(amounts: list[float]) -> bool: + """True if amounts cluster around a few values (suggests exact-amount strategy).""" + if len(amounts) < 3: + return True + med = median(amounts) + within_5pct = sum(1 for a in amounts if abs(a - med) / max(med, 1) < 0.05) + return within_5pct / len(amounts) >= 0.6 + ``` + +- [ ] **Step 4-6: Standard pattern: update `__init__.py` files, run, verify, commit.** + + ```bash + git add fusion_accounting_bank_rec/services/pattern_extractor.py \ + fusion_accounting_bank_rec/services/__init__.py \ + fusion_accounting_bank_rec/tests/test_pattern_extraction.py \ + fusion_accounting_bank_rec/tests/__init__.py + git commit -m "feat(fusion_accounting_bank_rec): pattern_extractor for per-partner aggregates" + ``` + +--- + +### Task 11: `services/confidence_scoring.py` (4-pass pipeline) + +Combines pattern + precedent + matching strategies + optional AI re-rank. + +**Files:** +- Create: `fusion_accounting_bank_rec/services/confidence_scoring.py` +- Create: `fusion_accounting_bank_rec/tests/test_confidence_scoring.py` + +**Depends on Tasks 6-10, 14, 15.** + +- [ ] **Step 1: Write failing tests** + + Path: `fusion_accounting_bank_rec/tests/test_confidence_scoring.py` + ```python + from odoo.tests.common import TransactionCase, tagged + from odoo.addons.fusion_accounting_bank_rec.services.confidence_scoring import ( + score_candidates, ScoredCandidate, + ) + + + @tagged('post_install', '-at_install') + class TestConfidenceScoring(TransactionCase): + + def test_amount_exact_dominates_pattern(self): + """An amount-exact candidate should score >0.9 even without precedent help.""" + # ... setup with one exact-match candidate + # ... assert highest scored candidate has confidence >= 0.9 + + def test_pattern_match_lifts_confidence(self): + """Same candidate, same amount delta, but with strong partner pattern → higher score.""" + + def test_precedent_match_lifts_confidence(self): + """Same candidate with 5 prior matching precedents → higher score than no precedents.""" + + def test_no_ai_provider_returns_statistical_only(self): + """When no LLM provider configured, score uses statistical 3-pass; AI rerank is no-op.""" + + def test_returns_top_k(self): + """score_candidates(k=3) returns at most 3 results sorted by confidence desc.""" + + def test_handles_empty_candidates(self): + self.assertEqual(score_candidates(self.env, statement_line=None, candidates=[]), []) + ``` + + Implementer: flesh out the test bodies with real recordset setup using `_factories.py` helpers (created in Task 18). + +- [ ] **Step 2-6: Standard TDD pattern. Implementation outline:** + + Path: `fusion_accounting_bank_rec/services/confidence_scoring.py` + ```python + """4-pass confidence scoring pipeline. + + Pass 1: SQL filter — partner match + reconcilable account + Pass 2: Statistical scoring — amount delta + pattern match + precedent similarity + Pass 3: AI re-rank (if provider configured) — feed top 5 to LLM, parse JSON ranking + Pass 4: Persist as fusion.reconcile.suggestion rows + """ + + from dataclasses import dataclass + from .matching_strategies import Candidate, AmountExactStrategy, FIFOStrategy + from .precedent_lookup import find_nearest_precedents + from .memo_tokenizer import tokenize_memo + + + @dataclass + class ScoredCandidate: + candidate_id: int + confidence: float + reasoning: str + score_amount_match: float + score_partner_pattern: float + score_precedent_similarity: float + score_ai_rerank: float = 0.0 + + + def score_candidates(env, *, statement_line, candidates, k=5, use_ai=True) -> list[ScoredCandidate]: + """Score and rank candidate matches for a statement line.""" + if not candidates or not statement_line: + return [] + + partner_id = statement_line.partner_id.id + bank_amount = statement_line.amount + memo_tokens = tokenize_memo(statement_line.payment_ref) + + # Pass 2.a: amount-match scoring + pattern = env['fusion.reconcile.pattern'].sudo().search( + [('partner_id', '=', partner_id)], limit=1) + precedents = find_nearest_precedents( + env, partner_id=partner_id, amount=bank_amount, k=5, memo_tokens=memo_tokens) + + scored = [] + for cand in candidates: + amount_score = 1.0 - min(abs(cand.amount - bank_amount) / max(bank_amount, 1), 1.0) + pattern_score = _pattern_score(cand, pattern, bank_amount) if pattern else 0.5 + precedent_score = _precedent_score(cand, precedents) + # Weighted combine + confidence = (amount_score * 0.5) + (pattern_score * 0.25) + (precedent_score * 0.25) + + reasoning = _build_reasoning(amount_score, pattern_score, precedent_score, pattern) + scored.append(ScoredCandidate( + candidate_id=cand.id, + confidence=round(confidence, 3), + reasoning=reasoning, + score_amount_match=round(amount_score, 3), + score_partner_pattern=round(pattern_score, 3), + score_precedent_similarity=round(precedent_score, 3), + )) + + scored.sort(key=lambda s: -s.confidence) + top_k = scored[:k] + + # Pass 3: AI re-rank if provider available + use_ai=True + if use_ai: + provider = _get_provider(env, 'bank_rec_suggest') + if provider is not None: + top_k = _ai_rerank(env, provider, statement_line, top_k, pattern, precedents) + + return top_k + + + def _pattern_score(cand, pattern, bank_amount) -> float: + """How well does this candidate fit the partner's typical pattern?""" + score = 0.5 + if pattern.pref_strategy == 'exact_amount' and abs(cand.amount - bank_amount) < 0.005: + score = 1.0 + return score + + + def _precedent_score(cand, precedents) -> float: + """How similar is this candidate to past precedents?""" + if not precedents: + return 0.5 + # If candidate amount matches any precedent's amount, score high + best = max((p.similarity_score for p in precedents), default=0.0) + return best + + + def _build_reasoning(amount_score, pattern_score, precedent_score, pattern) -> str: + parts = [] + if amount_score >= 0.99: + parts.append("Exact amount match") + elif amount_score >= 0.95: + parts.append("Amount close") + if pattern and pattern.reconcile_count > 5: + parts.append(f"Matches partner's {pattern.reconcile_count}-reconcile pattern") + if precedent_score >= 0.8: + parts.append("Strong precedent match") + return " · ".join(parts) if parts else "Weak signal" + + + def _get_provider(env, feature_name): + """Look up provider name from per-feature config; instantiate adapter.""" + param = env['ir.config_parameter'].sudo() + provider_name = param.get_param(f'fusion_accounting.provider.{feature_name}') + if not provider_name: + provider_name = param.get_param('fusion_accounting.provider.default') + if not provider_name: + return None + # Instantiate via existing adapter registry + from odoo.addons.fusion_accounting_ai.services.adapters import openai_adapter, claude + if provider_name.startswith('openai'): + return openai_adapter.OpenAIAdapter(env) + elif provider_name.startswith('claude'): + return claude.ClaudeAdapter(env) + return None + + + def _ai_rerank(env, provider, statement_line, scored, pattern, precedents): + """Send top-K candidates + features to LLM for re-rank. Parse JSON response.""" + from odoo.addons.fusion_accounting_ai.services.prompts.bank_rec_prompt import build_prompt + system, user = build_prompt(statement_line, scored, pattern, precedents) + try: + response = provider.complete(system=system, messages=[{'role': 'user', 'content': user}], + max_tokens=800, temperature=0.0) + except Exception: + # On any provider failure, return the statistical scoring unchanged + return scored + + import json + try: + parsed = json.loads(response['content']) + except json.JSONDecodeError: + return scored + + # Re-order scored list per AI's ranking, attach AI reasoning + ai_order = {item['candidate_id']: item for item in parsed.get('ranked', [])} + for s in scored: + if s.candidate_id in ai_order: + s.score_ai_rerank = ai_order[s.candidate_id].get('confidence', s.confidence) + s.reasoning = ai_order[s.candidate_id].get('reason', s.reasoning) + s.confidence = (s.confidence * 0.4) + (s.score_ai_rerank * 0.6) + scored.sort(key=lambda x: -x.confidence) + return scored + ``` + +- [ ] **Step 3+: Standard TDD + commit pattern.** + + ```bash + git commit -m "feat(fusion_accounting_bank_rec): 4-pass confidence scoring pipeline" + ``` + +--- + +### Task 12: `models/fusion_reconcile_engine.py` (AbstractModel — 6-method API) + +This is the orchestrator. **Depends on Tasks 6-11, 14-17.** + +**Files:** +- Create: `fusion_accounting_bank_rec/models/fusion_reconcile_engine.py` +- Create: `fusion_accounting_bank_rec/tests/test_reconcile_engine_unit.py` + +- [ ] **Step 1: Write failing tests for the 6 methods** + + Path: `fusion_accounting_bank_rec/tests/test_reconcile_engine_unit.py` + ```python + from odoo.tests.common import TransactionCase, tagged + + + @tagged('post_install', '-at_install') + class TestReconcileEngineAPI(TransactionCase): + """The 6-method public API contract.""" + + def test_engine_model_exists(self): + self.assertIn('fusion.reconcile.engine', self.env.registry) + + def test_reconcile_one_signature(self): + engine = self.env['fusion.reconcile.engine'] + self.assertTrue(callable(getattr(engine, 'reconcile_one', None))) + + def test_reconcile_batch_signature(self): + engine = self.env['fusion.reconcile.engine'] + self.assertTrue(callable(getattr(engine, 'reconcile_batch', None))) + + def test_suggest_matches_signature(self): + engine = self.env['fusion.reconcile.engine'] + self.assertTrue(callable(getattr(engine, 'suggest_matches', None))) + + def test_accept_suggestion_signature(self): + engine = self.env['fusion.reconcile.engine'] + self.assertTrue(callable(getattr(engine, 'accept_suggestion', None))) + + def test_write_off_signature(self): + engine = self.env['fusion.reconcile.engine'] + self.assertTrue(callable(getattr(engine, 'write_off', None))) + + def test_unreconcile_signature(self): + engine = self.env['fusion.reconcile.engine'] + self.assertTrue(callable(getattr(engine, 'unreconcile', None))) + + + @tagged('post_install', '-at_install') + class TestReconcileOne(TransactionCase): + """Behavioural tests for reconcile_one.""" + + def setUp(self): + super().setUp() + # Use _factories.make_bank_line / make_invoice (Task 18) + ... + + def test_simple_reconcile_creates_partial(self): + """1 bank line $100 + 1 invoice $100 → 1 account.partial.reconcile""" + ... + result = self.env['fusion.reconcile.engine'].reconcile_one( + statement_line=self.line, against_lines=self.invoice.line_ids.filtered( + lambda l: l.account_id.account_type in ('asset_receivable', 'liability_payable'))) + self.assertEqual(len(result['partial_ids']), 1) + partial = self.env['account.partial.reconcile'].browse(result['partial_ids']) + self.assertAlmostEqual(partial.amount, 100.00, places=2) + self.assertTrue(self.line.is_reconciled) + + def test_partial_reconcile_leaves_residual(self): + """1 bank line $100 + 1 invoice $80 → partial reconcile, residual $20""" + ... + + def test_reconcile_validates_amounts_balance(self): + """Engine must raise when over-allocation occurs""" + ... + ``` + +- [ ] **Step 2-3: Standard TDD. Implementation:** + + Path: `fusion_accounting_bank_rec/models/fusion_reconcile_engine.py` + ```python + """The reconcile engine — orchestrator for all bank-line reconciliations. + + Public API: 6 methods. All other code (controllers, AI tools, wizards) + must go through these methods; no direct ORM writes to account.partial.reconcile + from anywhere else. + """ + + import logging + from odoo import _, api, models + from odoo.exceptions import ValidationError, UserError + + from ..services.matching_strategies import ( + Candidate, AmountExactStrategy, FIFOStrategy, MultiInvoiceStrategy, + ) + from ..services.confidence_scoring import score_candidates + from ..services.exchange_diff import compute_exchange_diff + from ..services.memo_tokenizer import tokenize_memo + + _logger = logging.getLogger(__name__) + + + class FusionReconcileEngine(models.AbstractModel): + _name = "fusion.reconcile.engine" + _description = "Fusion Bank Reconciliation Engine" + + # ===== Public API ===== + + @api.model + def reconcile_one(self, statement_line, *, against_lines, write_off_vals=None): + """Reconcile one bank line against a set of journal items. + + Returns: {'partial_ids': [...], 'exchange_diff_move_id': int|None, + 'write_off_move_id': int|None} + """ + if not statement_line: + raise ValidationError(_("statement_line is required")) + if not against_lines and not write_off_vals: + raise ValidationError(_("Either against_lines or write_off_vals required")) + + # Phase 2: Validate + self._validate_reconcile(statement_line, against_lines) + + # Phase 3: Optional write-off creation + write_off_move_id = None + if write_off_vals: + write_off_move = self._create_write_off(statement_line, write_off_vals) + against_lines = against_lines | write_off_move.line_ids.filtered( + lambda l: l.account_id.account_type in ('asset_receivable', 'liability_payable')) + write_off_move_id = write_off_move.id + + # Phase 4: Reconcile via Odoo's standard API + # Match the bank-line move's reconcile-account line with the against_lines + line_move = statement_line.move_id + line_move_lines_to_reconcile = line_move.line_ids.filtered( + lambda l: l.account_id.account_type in ('asset_receivable', 'liability_payable')) + all_to_reconcile = line_move_lines_to_reconcile | against_lines + all_to_reconcile.reconcile() + + # Find the partials we just created + partial_ids = self.env['account.partial.reconcile'].search([ + '|', ('debit_move_id', 'in', all_to_reconcile.ids), + ('credit_move_id', 'in', all_to_reconcile.ids), + ('create_uid', '=', self.env.uid), + ]).ids + + # Phase 5: Audit + self._post_audit(statement_line, partial_ids, source='engine.reconcile_one') + + # Trigger precedent recording (fire-and-forget) + self._record_precedent(statement_line, against_lines) + + return { + 'partial_ids': partial_ids, + 'exchange_diff_move_id': None, # handled inline by reconcile() + 'write_off_move_id': write_off_move_id, + } + + @api.model + def reconcile_batch(self, statement_lines, *, strategy='auto'): + """Bulk reconcile via the named strategy.""" + reconciled = 0 + skipped = 0 + errors = [] + for line in statement_lines: + try: + candidates = self._fetch_candidates(line) + picked = self._apply_strategy(line, candidates, strategy) + if picked: + self.reconcile_one(line, against_lines=picked) + reconciled += 1 + else: + skipped += 1 + except Exception as e: + errors.append({'line_id': line.id, 'error': str(e)}) + return {'reconciled_count': reconciled, 'skipped': skipped, 'errors': errors} + + @api.model + def suggest_matches(self, statement_lines, *, limit_per_line=3): + """Compute and persist AI suggestions per line. + + Returns: dict mapping line_id → list of suggestion dicts.""" + out = {} + Suggestion = self.env['fusion.reconcile.suggestion'] + for line in statement_lines: + candidates = self._fetch_candidates(line) + if not candidates: + continue + scored = score_candidates(self.env, statement_line=line, candidates=candidates, + k=limit_per_line, use_ai=True) + # Supersede any prior pending suggestions for this line + Suggestion.search([ + ('statement_line_id', '=', line.id), + ('state', '=', 'pending'), + ]).write({'state': 'superseded'}) + # Persist new suggestions + line_suggestions = [] + for rank, s in enumerate(scored, start=1): + candidate_line = self.env['account.move.line'].browse(s.candidate_id) + sug = Suggestion.create({ + 'company_id': line.company_id.id, + 'statement_line_id': line.id, + 'proposed_move_line_ids': [(6, 0, [s.candidate_id])], + 'confidence': s.confidence, + 'rank': rank, + 'reasoning': s.reasoning, + 'score_amount_match': s.score_amount_match, + 'score_partner_pattern': s.score_partner_pattern, + 'score_precedent_similarity': s.score_precedent_similarity, + 'score_ai_rerank': s.score_ai_rerank, + 'generated_by': 'on_demand', + 'state': 'pending', + }) + line_suggestions.append({ + 'id': sug.id, 'rank': rank, 'confidence': s.confidence, + 'reasoning': s.reasoning, 'candidate_id': s.candidate_id, + }) + out[line.id] = line_suggestions + return out + + @api.model + def accept_suggestion(self, suggestion): + """User clicked Accept on a suggestion → reconcile via its proposal.""" + if isinstance(suggestion, int): + suggestion = self.env['fusion.reconcile.suggestion'].browse(suggestion) + line = suggestion.statement_line_id + against = suggestion.proposed_move_line_ids + result = self.reconcile_one(line, against_lines=against) + suggestion.write({ + 'state': 'accepted', + 'accepted_at': self.env.cr.now(), + 'accepted_by': self.env.uid, + }) + return result + + @api.model + def write_off(self, statement_line, *, account, amount, tax_id=None, label): + """Create a write-off move + reconcile the bank line against it.""" + # ... build account.move with one debit/credit pair, optional tax line + # ... then call reconcile_one with the new move's reconcile-account line + pass + + @api.model + def unreconcile(self, partial_reconciles): + """Reverse a reconciliation. Handles full vs. partial chains.""" + partial_reconciles = partial_reconciles.exists() + all_lines = (partial_reconciles.mapped('debit_move_id') + | partial_reconciles.mapped('credit_move_id')) + partial_reconciles.unlink() + return {'unreconciled_line_ids': all_lines.ids} + + # ===== Private helpers ===== + + def _validate_reconcile(self, statement_line, against_lines): + if not statement_line.exists(): + raise ValidationError(_("Statement line does not exist")) + if statement_line.is_reconciled: + raise ValidationError(_("Line is already reconciled")) + # ... currency consistency, period lock, partner allowed checks + + def _create_write_off(self, statement_line, write_off_vals): + """Build and post the write-off account.move.""" + # ... create move with proper debit/credit + optional tax line + pass + + def _fetch_candidates(self, statement_line): + """SQL pre-filter: open journal items matching partner + reconcilable account.""" + domain = [ + ('parent_state', '=', 'posted'), + ('account_id.reconcile', '=', True), + ('reconciled', '=', False), + ('display_type', 'not in', ['line_section', 'line_note']), + ] + if statement_line.partner_id: + domain.append(('partner_id', '=', statement_line.partner_id.id)) + return self.env['account.move.line'].search(domain, limit=200) + + def _apply_strategy(self, line, candidates, strategy): + """Convert candidates to Candidate dataclasses, apply strategy, return recordset.""" + today = fields.Date.today() + dataclass_candidates = [ + Candidate(id=c.id, amount=abs(c.amount_residual), partner_id=c.partner_id.id, + age_days=(today - (c.date_maturity or c.date)).days) + for c in candidates + ] + if strategy == 'auto': + # Try strategies in order + for strat_class in (AmountExactStrategy, MultiInvoiceStrategy, FIFOStrategy): + result = strat_class().match(bank_amount=abs(line.amount), candidates=dataclass_candidates) + if result.picked_ids: + return self.env['account.move.line'].browse(result.picked_ids) + # ... handle other named strategies + return self.env['account.move.line'] + + def _post_audit(self, statement_line, partial_ids, source): + """Log to mail.thread of the line's move.""" + statement_line.move_id.message_post( + body=_("Reconciled via %s; partials: %s") % (source, partial_ids), + subject=_("Bank reconciliation"), + ) + + def _record_precedent(self, statement_line, against_lines): + """Append a precedent for future pattern learning.""" + self.env['fusion.reconcile.precedent'].sudo().create({ + 'company_id': statement_line.company_id.id, + 'partner_id': statement_line.partner_id.id, + 'amount': abs(statement_line.amount), + 'currency_id': statement_line.currency_id.id, + 'date': statement_line.date, + 'memo_tokens': ','.join(tokenize_memo(statement_line.payment_ref)), + 'journal_id': statement_line.journal_id.id, + 'matched_move_line_count': len(against_lines), + 'matched_account_ids': ','.join(str(i) for i in against_lines.mapped('account_id').ids), + 'reconciler_user_id': self.env.uid, + 'reconciled_at': self.env.cr.now(), + 'source': 'manual', # caller may override + }) + ``` + + This is the most complex single task. Engineer/subagent should expect ~3-5 days. + +- [ ] **Step 4-6: Update `models/__init__.py`, run tests, commit per layer.** + + Commit incrementally — one method at a time is reasonable for this large task. + + ```bash + git commit -m "feat(fusion_accounting_bank_rec): reconcile engine 6-method public API" + ``` + +--- + +### Task 13: `test_reconcile_engine_property.py` (Hypothesis property-based tests) + +**Files:** +- Create: `fusion_accounting_bank_rec/tests/test_reconcile_engine_property.py` + +- [ ] **Step 1: Write the property tests** + + ```python + from decimal import Decimal + from hypothesis import given, strategies as st, settings, HealthCheck + from odoo.tests.common import TransactionCase, tagged + + + @tagged('post_install', '-at_install', 'property_based') + class TestReconcileEngineInvariants(TransactionCase): + """Property-based tests for amount invariants. Each test runs 100 random inputs.""" + + @given( + bank_amount=st.decimals(min_value='0.01', max_value='100000.00', places=2), + invoice_amounts=st.lists( + st.decimals(min_value='0.01', max_value='100000.00', places=2), + min_size=1, max_size=10, + ), + ) + @settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_invariant_total_debits_equal_total_credits(self, bank_amount, invoice_amounts): + """Every reconciliation must produce balanced debit/credit totals. + Skip impossible cases (over-allocation).""" + if sum(invoice_amounts) < bank_amount: + return # skip — over-allocation handled separately + # ... setup recordset + # ... call engine.reconcile_one + # ... assert sum(debits) == sum(credits) + + @given(amounts=st.lists(st.decimals(places=2), min_size=2, max_size=20)) + @settings(max_examples=50) + def test_invariant_partial_reconciles_sum_to_full(self, amounts): + """Sum of all account.partial.reconcile rows == line residual.""" + # ... setup, reconcile, assert + ``` + +- [ ] **Step 2-4: Run, commit.** + + ```bash + git commit -m "test(fusion_accounting_bank_rec): Hypothesis property-based engine invariants" + ``` + +--- + +## Group 3: Models + +### Task 14: Create Pattern + Precedent Models + +**Files:** +- Create: `fusion_accounting_bank_rec/models/fusion_reconcile_pattern.py` +- Create: `fusion_accounting_bank_rec/models/fusion_reconcile_precedent.py` +- Modify: `fusion_accounting_bank_rec/models/__init__.py` +- Modify: `fusion_accounting_bank_rec/security/ir.model.access.csv` + +- [ ] **Step 1: Write `fusion_reconcile_pattern.py`** + + ```python + from odoo import fields, models + + + class FusionReconcilePattern(models.Model): + _name = "fusion.reconcile.pattern" + _description = "Per-partner bank reconciliation pattern aggregate" + _rec_name = "partner_id" + + company_id = fields.Many2one('res.company', required=True, index=True) + partner_id = fields.Many2one('res.partner', required=True, index=True) + reconcile_count = fields.Integer(default=0) + typical_amount_range = fields.Char() + typical_cadence_days = fields.Float() + typical_day_of_month = fields.Char() + pref_strategy = fields.Selection([ + ('exact_amount', 'Exact-amount-first'), + ('fifo', 'FIFO oldest-due-first'), + ('multi_invoice', 'Multi-invoice consolidation'), + ('cherry_pick', 'Cherry-pick specific invoices'), + ]) + pref_account_id = fields.Many2one('account.account') + common_memo_tokens = fields.Char() + common_writeoff_account_id = fields.Many2one('account.account') + common_writeoff_tax_id = fields.Many2one('account.tax') + typical_writeoff_amount = fields.Float() + last_refreshed_at = fields.Datetime() + + _sql_constraints = [ + ('uniq_company_partner', 'unique(company_id, partner_id)', + 'One pattern row per (company, partner) — already exists.'), + ] + ``` + +- [ ] **Step 2: Write `fusion_reconcile_precedent.py`** + + ```python + from odoo import fields, models + + + class FusionReconcilePrecedent(models.Model): + _name = "fusion.reconcile.precedent" + _description = "Historical bank reconciliation decision (memory)" + _order = "reconciled_at desc, id desc" + + company_id = fields.Many2one('res.company', required=True, index=True) + partner_id = fields.Many2one('res.partner', index=True) + + amount = fields.Monetary(currency_field='currency_id') + currency_id = fields.Many2one('res.currency') + date = fields.Date() + memo_tokens = fields.Char(help="Comma-separated normalized memo tokens") + journal_id = fields.Many2one('account.journal') + + matched_move_line_count = fields.Integer() + matched_account_ids = fields.Char(help="Comma-separated account.account IDs") + matched_invoice_ages_days = fields.Char(help="Comma-separated days-old at reconcile time") + write_off_amount = fields.Float() + write_off_account_id = fields.Many2one('account.account') + exchange_diff = fields.Boolean() + + reconciler_user_id = fields.Many2one('res.users') + reconciled_at = fields.Datetime() + source = fields.Selection([ + ('historical_bootstrap', 'Imported from history'), + ('manual', 'Manual reconcile via fusion'), + ('ai_accepted', 'AI suggestion accepted'), + ('auto_rule', 'account.reconcile.model auto-fired'), + ], required=True) + + _sql_constraints = [] # no uniqueness; multiple reconciles can share features + ``` + +- [ ] **Step 3: Update `__init__.py`** + + ```python + from . import fusion_reconcile_pattern + from . import fusion_reconcile_precedent + ``` + +- [ ] **Step 4: Add ACL rows** + + Path: `fusion_accounting_bank_rec/security/ir.model.access.csv` + ```csv + id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink + access_fusion_reconcile_pattern_user,pattern user,model_fusion_reconcile_pattern,fusion_accounting_core.group_fusion_accounting_user,1,0,0,0 + access_fusion_reconcile_pattern_admin,pattern admin,model_fusion_reconcile_pattern,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1 + access_fusion_reconcile_precedent_user,precedent user,model_fusion_reconcile_precedent,fusion_accounting_core.group_fusion_accounting_user,1,0,0,0 + access_fusion_reconcile_precedent_admin,precedent admin,model_fusion_reconcile_precedent,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1 + ``` + +- [ ] **Step 5: Bump manifest → `19.0.1.0.1`. Upgrade. Verify. Commit.** + + ```bash + git commit -m "feat(fusion_accounting_bank_rec): pattern + precedent models for behavioural learning" + ``` + +--- + +### Task 15: Create Suggestion Model + +**Files:** +- Create: `fusion_accounting_bank_rec/models/fusion_reconcile_suggestion.py` +- Modify: `fusion_accounting_bank_rec/models/__init__.py` +- Modify: `fusion_accounting_bank_rec/security/ir.model.access.csv` +- Create: `fusion_accounting_bank_rec/tests/test_ai_suggestion_lifecycle.py` + +- [ ] **Step 1: Write the model from the spec (Section 5.1)** + + Path: `fusion_accounting_bank_rec/models/fusion_reconcile_suggestion.py` — full schema per spec. + +- [ ] **Step 2: Write lifecycle tests** + + ```python + def test_suggestion_state_transitions(self): + """pending → accepted, pending → rejected, pending → superseded all work""" + + def test_compute_band_from_confidence(self): + """confidence_band auto-computed: 0.96 → 'high', 0.75 → 'medium', etc.""" + + def test_acceptance_records_user_and_time(self): + """Accepting a suggestion sets accepted_by + accepted_at""" + ``` + +- [ ] **Step 3: Add ACL rows. Bump manifest. Run, verify, commit.** + + ```bash + git commit -m "feat(fusion_accounting_bank_rec): persisted AI suggestion model with state lifecycle" + ``` + +--- + +### Task 16: TransientModel for Widget Round-Trip Data + +**Files:** +- Create: `fusion_accounting_bank_rec/models/fusion_bank_rec_widget.py` +- Modify: `fusion_accounting_bank_rec/models/__init__.py` + +- [ ] **Step 1: Write the model** + + ```python + from odoo import api, fields, models + + + class FusionBankRecWidget(models.TransientModel): + """Per-request widget state. Holds the kanban-load response shape + so the controller can return one well-typed object.""" + _name = "fusion.bank.rec.widget" + _description = "Bank reconciliation widget state (transient)" + + journal_id = fields.Many2one('account.journal') + statement_line_ids = fields.Many2many('account.bank.statement.line') + summary_count = fields.Integer() + summary_unreconciled_balance = fields.Monetary(currency_field='currency_id') + currency_id = fields.Many2one('res.currency') + + def action_open_kanban(self): + """Return a window action opening the OWL kanban for this journal.""" + return { + 'type': 'ir.actions.client', + 'tag': 'fusion_bank_rec_kanban', + 'params': {'journal_id': self.journal_id.id}, + } + ``` + +- [ ] **Step 2: Update __init__, ACL, manifest, commit.** + + ```bash + git commit -m "feat(fusion_accounting_bank_rec): transient model for widget round-trip data" + ``` + +--- + +### Task 17: Inherit account.bank.statement.line + account.reconcile.model + +**Files:** +- Create: `fusion_accounting_bank_rec/models/account_bank_statement_line.py` +- Create: `fusion_accounting_bank_rec/models/account_reconcile_model.py` +- Modify: `fusion_accounting_bank_rec/models/__init__.py` + +- [ ] **Step 1: Write `account_bank_statement_line.py`** + + ```python + from odoo import api, fields, models + + + class AccountBankStatementLine(models.Model): + _inherit = "account.bank.statement.line" + + # Compute fields used by the OWL widget for badge state + fusion_top_suggestion_id = fields.Many2one( + 'fusion.reconcile.suggestion', + compute='_compute_top_suggestion', store=False) + fusion_confidence_band = fields.Selection( + [('high', 'High'), ('medium', 'Medium'), ('low', 'Low'), ('none', 'None')], + compute='_compute_top_suggestion', store=False) + + # Computed attachment list (matches Enterprise's surface name) + bank_statement_attachment_ids = fields.One2many( + 'ir.attachment', compute='_compute_bank_statement_attachment_ids') + + def _compute_top_suggestion(self): + Suggestion = self.env['fusion.reconcile.suggestion'] + for line in self: + top = Suggestion.search([ + ('statement_line_id', '=', line.id), + ('state', '=', 'pending'), + ('rank', '=', 1), + ], limit=1) + line.fusion_top_suggestion_id = top + line.fusion_confidence_band = top.confidence_band if top else 'none' + + def _compute_bank_statement_attachment_ids(self): + for line in self: + line.bank_statement_attachment_ids = line.move_id.attachment_ids if line.move_id else False + ``` + +- [ ] **Step 2: Write `account_reconcile_model.py`** (extension hooks for AI integration) + +- [ ] **Step 3: Update __init__, manifest, commit.** + +--- + +## Group 4: Integration Tests + Fixtures + +### Task 18: Test Factories + Capture 5 SQL Fixtures from Local DB + +**Files:** +- Create: `fusion_accounting_bank_rec/tests/_factories.py` +- Create: `fusion_accounting_bank_rec/tests/_fixtures/westin_simple_match.sql` (and 4 more) + +- [ ] **Step 1: Write factories** + + Path: `fusion_accounting_bank_rec/tests/_factories.py` + ```python + from datetime import date + from odoo import fields + + + def make_bank_journal(env, name='Test Bank'): + return env['account.journal'].create({ + 'name': name, 'type': 'bank', 'code': name[:5].upper()}) + + + def make_bank_line(env, *, journal=None, amount=100.00, partner=None, + memo='Test', date_=None): + journal = journal or make_bank_journal(env) + stmt = env['account.bank.statement'].create({ + 'name': 'Test stmt', 'journal_id': journal.id, + 'date': date_ or date.today()}) + return env['account.bank.statement.line'].create({ + 'statement_id': stmt.id, + 'journal_id': journal.id, + 'date': date_ or date.today(), + 'payment_ref': memo, + 'amount': amount, + 'partner_id': partner.id if partner else None, + }) + + + def make_invoice(env, *, partner, amount, date_=None, currency=None): + """Posted customer invoice.""" + product = env['product.product'].search([('type', '=', 'service')], limit=1) \ + or env['product.product'].create({'name': 'Test Service', 'type': 'service'}) + move = env['account.move'].create({ + 'move_type': 'out_invoice', + 'partner_id': partner.id, + 'invoice_date': date_ or date.today(), + 'currency_id': currency.id if currency else env.company.currency_id.id, + 'invoice_line_ids': [(0, 0, { + 'product_id': product.id, + 'name': 'Test Line', + 'quantity': 1, + 'price_unit': amount, + })], + }) + move.action_post() + return move + ``` + +- [ ] **Step 2: Capture SQL fixtures from local DB** + + Use a real reconcile from the local `westin-v19` DB. Pick one simple, one partial chain, one with HST, one with USD/CAD exchange, one unreconcile. + + ```bash + orb -m odoo-westin-dev sudo docker exec westin-dev-db pg_dump -U odoo -d westin-v19 \ + --data-only --inserts \ + -t account_bank_statement_line -t account_move -t account_move_line -t account_partial_reconcile \ + --where="account_bank_statement_line.id = " \ + > /tmp/fixture.sql + ``` + + Then trim and save to `fusion_accounting_bank_rec/tests/_fixtures/westin_simple_match.sql`. Repeat for the other 4 fixture types. + + Note: this is more art than science. The implementer may need to hand-curate the fixture SQL to keep the schema dependencies manageable. + +- [ ] **Step 3: Commit** + + ```bash + git commit -m "test(fusion_accounting_bank_rec): factories + 5 SQL fixtures from real Westin reconciles" + ``` + +--- + +### Task 19: `test_reconcile_engine_integration.py` + +**Files:** +- Create: `fusion_accounting_bank_rec/tests/test_reconcile_engine_integration.py` + +- [ ] **Step 1: Write replay tests** + + ```python + from odoo.tests.common import TransactionCase, tagged + from odoo.tools import file_open + + + @tagged('post_install', '-at_install', 'integration') + class TestEnterpriseReplay(TransactionCase): + """Replay each fixture via fusion's engine, assert byte-identical output.""" + + @classmethod + def _load_fixture(cls, fixture_name): + with file_open(f'fusion_accounting_bank_rec/tests/_fixtures/{fixture_name}', 'r') as f: + cls.env.cr.execute(f.read()) + + def test_replay_simple_match(self): + self._load_fixture('westin_simple_match.sql') + # ... unlink the captured partial; re-run fusion engine; assert identical + ``` + +- [ ] **Step 2-4: Run, verify, commit.** + + ```bash + git commit -m "test(fusion_accounting_bank_rec): integration replay against real Westin reconciles" + ``` + +--- + +## Group 5: AI Integration Backend + +### Task 20: `services/prompts/bank_rec_prompt.py` + +**Files:** +- Create: `fusion_accounting_ai/services/prompts/bank_rec_prompt.py` + +- [ ] **Step 1: Write the prompt builder** + + ```python + """Bank-rec suggestion-ranking prompt builder. + + Produces (system, user) prompt pair for the LLM. Output JSON for + compatibility with non-tool-calling local models. + """ + + + SYSTEM_PROMPT = """\ + You are a bank reconciliation assistant for a Canadian accounting team. + Your task: rank candidate matches for a bank statement line and explain + each ranking in one sentence. + + Output format: JSON only, no prose. Schema: + { + "ranked": [ + {"candidate_id": , "confidence": <0.0-1.0>, "reason": ""}, + ... + ] + } + + Rules: + - Confidence >0.95 only when amount + partner + memo all align perfectly + - Confidence 0.70-0.94 when 2 of 3 align + - Confidence <0.70 when only one aligns or evidence is conflicting + - If a candidate is clearly wrong, omit it + - Reason must cite specific evidence (amount, prior reconciles, memo tokens) + - Currency: Canadian English. Amounts in $X,XXX.XX format. + """ + + + def build_prompt(statement_line, scored_candidates, pattern, precedents): + """Assemble the user prompt for one line's suggestion ranking.""" + lines = [ + "BANK LINE", + f" Date: {statement_line.date}", + f" Amount: ${statement_line.amount:,.2f} {statement_line.currency_id.name}", + f" Memo: \"{statement_line.payment_ref or '(none)'}\"", + f" Partner: {statement_line.partner_id.name or '(none)'} (id={statement_line.partner_id.id})", + "", + ] + + if pattern and pattern.reconcile_count: + lines += [ + f"PARTNER PATTERN ({statement_line.partner_id.name}, last 18 months)", + f" Total reconciles: {pattern.reconcile_count}", + f" Typical amount: {pattern.typical_amount_range or '(unknown)'}", + f" Typical cadence: every {pattern.typical_cadence_days:.1f} days" if pattern.typical_cadence_days else " Typical cadence: (unknown)", + f" Preferred strategy: {pattern.pref_strategy or '(unknown)'}", + f" Common memo tokens: {pattern.common_memo_tokens or '(none)'}", + "", + ] + + if precedents: + lines.append("NEAREST 3 PRECEDENTS") + for i, p in enumerate(precedents[:3], start=1): + lines.append(f" {i}. ${p.amount:,.2f} · matched {p.matched_move_line_count} item(s) · memo \"{p.memo_tokens}\"") + lines.append("") + + lines.append("CANDIDATE MATCHES (statistical pre-filter, top 5)") + for sc in scored_candidates[:5]: + # Look up candidate detail + cand = statement_line.env['account.move.line'].browse(sc.candidate_id) + age = "?" + if cand.date_maturity: + age = (statement_line.date - cand.date_maturity).days + lines.append(f" - candidate_id={sc.candidate_id}: {cand.move_id.name} · ${cand.amount_residual:,.2f} · age {age} days · partner {cand.partner_id.name or '(none)'}") + + lines.append("") + lines.append("Rank these candidates.") + + return SYSTEM_PROMPT, "\n".join(lines) + ``` + +- [ ] **Step 2: Update `fusion_accounting_ai/services/prompts/__init__.py`** to import. + +- [ ] **Step 3: Commit** + + ```bash + git commit -m "feat(fusion_accounting_ai): bank_rec_prompt builder for suggestion ranking" + ``` + +--- + +### Task 21: Fill in `BankRecAdapter._via_fusion` Paths + +**Files:** +- Modify: `fusion_accounting_ai/services/data_adapters/bank_rec.py` + +- [ ] **Step 1: Update the adapter** + + Replace `list_unreconciled_via_fusion` body to use the engine: + ```python + def list_unreconciled_via_fusion(self, journal_id, limit=100): + Line = self.env['account.bank.statement.line'].sudo() + records = Line.search([ + ('journal_id', '=', journal_id), + ('is_reconciled', '=', False), + ], limit=limit, order='date desc, id desc') + return [ + { + 'id': r.id, + 'date': r.date, + 'payment_ref': r.payment_ref, + 'amount': r.amount, + 'partner_id': r.partner_id.id if r.partner_id else None, + 'partner_name': r.partner_id.name if r.partner_id else None, + 'currency_id': r.currency_id.id if r.currency_id else None, + 'fusion_confidence_band': r.fusion_confidence_band, + 'fusion_top_suggestion_id': r.fusion_top_suggestion_id.id if r.fusion_top_suggestion_id else None, + } + for r in records + ] + + def get_ai_suggestions(self, statement_line_ids): + """Return all pending AI suggestions for the given lines.""" + sugs = self.env['fusion.reconcile.suggestion'].sudo().search([ + ('statement_line_id', 'in', statement_line_ids), + ('state', '=', 'pending'), + ]) + return [ + {'id': s.id, 'line_id': s.statement_line_id.id, 'confidence': s.confidence, + 'rank': s.rank, 'reasoning': s.reasoning} + for s in sugs + ] + + def accept_suggestion(self, suggestion_id): + sug = self.env['fusion.reconcile.suggestion'].browse(suggestion_id) + return self.env['fusion.reconcile.engine'].accept_suggestion(sug) + ``` + +- [ ] **Step 2: Commit** + + ```bash + git commit -m "feat(fusion_accounting_ai): BankRecAdapter._via_fusion fills in to call fusion.reconcile.engine" + ``` + +--- + +### Task 22: New AI Tools in `services/tools/bank_reconciliation.py` + +**Files:** +- Modify: `fusion_accounting_ai/services/tools/bank_reconciliation.py` +- Modify: `fusion_accounting_ai/data/tool_definitions.xml` + +- [ ] **Step 1: Add the 5 new tools** + + Add functions: + - `get_pending_suggestions(env, journal_id, limit=100)` — Tier 1 + - `explain_suggestion(env, suggestion_id)` — Tier 1 + - `accept_suggestion_via_chat(env, suggestion_id)` — Tier 3 + - `reject_suggestion_via_chat(env, suggestion_id, reason)` — Tier 2 + - `batch_accept_high_confidence(env, journal_id, threshold=0.95)` — Tier 3 + + Each function calls through `BankRecAdapter` (via `get_adapter(env, 'bank_rec').`) for tri-mode safety. + +- [ ] **Step 2: Add corresponding records to `data/tool_definitions.xml`** + + Each tool needs a `` with name, description, parameters_schema, tier, etc. Follow existing pattern in the file. + +- [ ] **Step 3: Bump fusion_accounting_ai manifest, upgrade, commit.** + + ```bash + git commit -m "feat(fusion_accounting_ai): add 5 new bank-rec AI tools (suggestions, accept, batch)" + ``` + +--- + +### Task 23: Refactor Existing AI Tools to Use Engine + +**Files:** +- Modify: `fusion_accounting_ai/services/tools/bank_reconciliation.py` + +- [ ] **Step 1: Refactor existing tools (`get_unreconciled_bank_lines`, `find_similar_bank_lines`, etc.) to call through the BankRecAdapter rather than direct ORM** + +- [ ] **Step 2: Run existing test suite, verify no regression. Commit.** + + ```bash + git commit -m "refactor(fusion_accounting_ai): existing bank-rec tools route through engine" + ``` + +--- + +## Group 6: Materialized View + Cron + +### Task 24: Materialized View + Refresh Trigger + +**Files:** +- Create: `fusion_accounting_bank_rec/data/materialized_view.xml` +- Modify: `fusion_accounting_bank_rec/models/account_bank_statement_line.py` (add `_post` override) + +- [ ] **Step 1: Define materialized view via SQL in the data XML** + + Path: `fusion_accounting_bank_rec/data/materialized_view.xml` + ```xml + + + + + + + + ``` + + Then add the SQL function on the widget model: + ```python + @api.model + def _create_materialized_view(self): + """Idempotent creation of fusion_unreconciled_per_partner_mv.""" + self.env.cr.execute(""" + CREATE MATERIALIZED VIEW IF NOT EXISTS fusion_unreconciled_per_partner_mv AS + SELECT + aml.partner_id, + aml.company_id, + count(*) AS unreconciled_count, + sum(aml.amount_residual) AS unreconciled_total + FROM account_move_line aml + JOIN account_account aa ON aa.id = aml.account_id + WHERE aml.reconciled = false + AND aa.reconcile = true + AND aml.parent_state = 'posted' + AND aml.partner_id IS NOT NULL + GROUP BY aml.partner_id, aml.company_id; + + CREATE UNIQUE INDEX IF NOT EXISTS fusion_uppm_idx + ON fusion_unreconciled_per_partner_mv (company_id, partner_id); + """) + ``` + +- [ ] **Step 2: Add refresh trigger via `account.move._post` override** + + Modify `fusion_accounting_bank_rec/models/account_bank_statement_line.py` (or add `account_move.py`): + ```python + # Refresh the MV via cron only — inline refresh would be too slow + ``` + + Or set up a 5-minute cron that runs `REFRESH MATERIALIZED VIEW CONCURRENTLY fusion_unreconciled_per_partner_mv;`. + +- [ ] **Step 3: Add to manifest data list. Commit.** + + ```bash + git commit -m "feat(fusion_accounting_bank_rec): materialized view for partner unreconciled counts" + ``` + +--- + +### Task 25: Cron Definitions + +**Files:** +- Create: `fusion_accounting_bank_rec/data/ir_cron.xml` +- Modify: `fusion_accounting_bank_rec/__manifest__.py` to load it. + +- [ ] **Step 1: Define crons** + + ```xml + + + + + Fusion: Bank-rec suggest matches + + code + + lines = env['account.bank.statement.line'].search([ + ('is_reconciled', '=', False), + '|', ('cron_last_check', '=', False), + ('cron_last_check', '<', datetime.datetime.now() - datetime.timedelta(minutes=15)), + ], limit=200) + if lines: + env['fusion.reconcile.engine'].suggest_matches(lines, limit_per_line=3) + lines.write({'cron_last_check': datetime.datetime.now()}) + + 15 + minutes + True + + + + Fusion: Bank-rec refresh patterns (nightly) + + code + + from odoo.addons.fusion_accounting_bank_rec.services.pattern_extractor import extract_pattern_for_partner + + partners = env['res.partner'].search([]) + Pattern = env['fusion.reconcile.pattern'] + for company in env['res.company'].search([]): + for partner in partners: + vals = extract_pattern_for_partner(env, company_id=company.id, partner_id=partner.id) + if not vals.get('reconcile_count'): + continue + existing = Pattern.search([('company_id', '=', company.id), + ('partner_id', '=', partner.id)], limit=1) + if existing: + existing.write(vals) + else: + Pattern.create(vals) + + 1 + days + True + + + + Fusion: Refresh unreconciled-per-partner MV + + code + env.cr.execute("REFRESH MATERIALIZED VIEW CONCURRENTLY fusion_unreconciled_per_partner_mv;") + 5 + minutes + True + + + + ``` + +- [ ] **Step 2: Add to manifest data list. Bump version. Commit.** + + ```bash + git commit -m "feat(fusion_accounting_bank_rec): cron schedules for suggest, pattern refresh, MV refresh" + ``` + +--- + +## Group 7: Controllers + +### Task 26: Bank Rec Controller (10 Endpoints) + +**Files:** +- Create: `fusion_accounting_bank_rec/controllers/bank_rec_controller.py` +- Modify: `fusion_accounting_bank_rec/controllers/__init__.py` +- Create: `fusion_accounting_bank_rec/tests/test_widget_endpoints.py` + +- [ ] **Step 1: Write failing tests for the 10 endpoints** + + Each endpoint gets a test asserting: + - Returns expected JSON shape + - Requires auth (rejects anonymous) + - Honors group permissions for write endpoints + - Calls the correct engine method + +- [ ] **Step 2: Write the controller** + + ```python + from odoo import http + from odoo.http import request + + + class FusionBankRecController(http.Controller): + """JSON-RPC endpoints for the OWL bank-rec widget.""" + + @http.route('/fusion_bank_rec/load_kanban', type='jsonrpc', auth='user') + def load_kanban(self, journal_id): + # ... fetch unreconciled lines + statement summary + pending suggestions + pass + + @http.route('/fusion_bank_rec/reconcile', type='jsonrpc', auth='user') + def reconcile(self, line_id, against_line_ids, write_off_vals=None): + line = request.env['account.bank.statement.line'].browse(line_id) + against = request.env['account.move.line'].browse(against_line_ids) + return request.env['fusion.reconcile.engine'].reconcile_one( + line, against_lines=against, write_off_vals=write_off_vals) + + @http.route('/fusion_bank_rec/reconcile_batch', type='jsonrpc', auth='user') + def reconcile_batch(self, line_ids, strategy='auto'): + # Tier 3 — require manager group + if not request.env.user.has_group('fusion_accounting_core.group_fusion_accounting_manager'): + raise http.AccessError("Bulk reconcile requires Manager privilege.") + lines = request.env['account.bank.statement.line'].browse(line_ids) + return request.env['fusion.reconcile.engine'].reconcile_batch(lines, strategy=strategy) + + @http.route('/fusion_bank_rec/accept_suggestion', type='jsonrpc', auth='user') + def accept_suggestion(self, suggestion_id): + sug = request.env['fusion.reconcile.suggestion'].browse(suggestion_id) + return request.env['fusion.reconcile.engine'].accept_suggestion(sug) + + @http.route('/fusion_bank_rec/reject_suggestion', type='jsonrpc', auth='user') + def reject_suggestion(self, suggestion_id, reason='other'): + sug = request.env['fusion.reconcile.suggestion'].browse(suggestion_id) + sug.write({'state': 'rejected', 'rejected_at': fields.Datetime.now(), + 'rejected_reason': reason}) + return {'ok': True} + + @http.route('/fusion_bank_rec/refresh_suggestions', type='jsonrpc', auth='user') + def refresh_suggestions(self, line_ids): + lines = request.env['account.bank.statement.line'].browse(line_ids) + return request.env['fusion.reconcile.engine'].suggest_matches(lines) + + @http.route('/fusion_bank_rec/write_off', type='jsonrpc', auth='user') + def write_off(self, line_id, account_id, amount, tax_id=None, label=''): + line = request.env['account.bank.statement.line'].browse(line_id) + account = request.env['account.account'].browse(account_id) + return request.env['fusion.reconcile.engine'].write_off( + line, account=account, amount=amount, tax_id=tax_id, label=label) + + @http.route('/fusion_bank_rec/unreconcile', type='jsonrpc', auth='user') + def unreconcile(self, partial_ids): + partials = request.env['account.partial.reconcile'].browse(partial_ids) + return request.env['fusion.reconcile.engine'].unreconcile(partials) + + @http.route('/fusion_bank_rec/partner_history', type='jsonrpc', auth='user') + def partner_history(self, partner_id, limit=20): + # Read precedents for this partner + precedents = request.env['fusion.reconcile.precedent'].search( + [('partner_id', '=', partner_id)], limit=limit, order='reconciled_at desc') + return [{'id': p.id, 'amount': p.amount, 'date': p.date, + 'memo_tokens': p.memo_tokens, 'count': p.matched_move_line_count} + for p in precedents] + + @http.route('/fusion_bank_rec/upload_attachment', type='http', auth='user', methods=['POST']) + def upload_attachment(self, line_id, ufile, **kw): + line = request.env['account.bank.statement.line'].browse(int(line_id)) + # Create ir.attachment linked to line.move_id + import base64 + request.env['ir.attachment'].create({ + 'name': ufile.filename, + 'datas': base64.b64encode(ufile.read()), + 'res_model': 'account.move', + 'res_id': line.move_id.id, + }) + return request.make_response('OK', headers=[('Content-Type', 'text/plain')]) + ``` + +- [ ] **Step 3: Update `controllers/__init__.py`. Bump manifest. Run tests. Commit.** + + ```bash + git commit -m "feat(fusion_accounting_bank_rec): controller with 10 JSON-RPC endpoints + auth + ACLs" + ``` + +--- + +## Group 8: Frontend Foundation + +### Task 27: SCSS Tokens + Main Stylesheet + +**Files:** +- Create: `fusion_accounting_bank_rec/static/src/components/bank_reconciliation/_bank_rec_tokens.scss` +- Create: `fusion_accounting_bank_rec/static/src/components/bank_reconciliation/bank_rec_widget.scss` +- Modify: `fusion_accounting_bank_rec/__manifest__.py` to load assets + +- [ ] **Step 1: Write tokens file** (per spec Section 4.4 — already shown) + +- [ ] **Step 2: Write main stylesheet** referencing only tokens + +- [ ] **Step 3: Update manifest assets bundle** (tokens first, per workspace SCSS rule) + + ```python + 'assets': { + 'web.assets_backend': [ + 'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/_bank_rec_tokens.scss', + 'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/bank_rec_widget.scss', + 'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/**/*.js', + 'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/**/*.xml', + 'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/**/*.scss', + ], + }, + ``` + +- [ ] **Step 4: Bump manifest. Restart container to refresh asset bundle. Commit.** + + ```bash + git commit -m "feat(fusion_accounting_bank_rec): SCSS tokens (light + dark) and main widget stylesheet" + ``` + +--- + +### Task 28: Frontend Service `bank_reconciliation_service.js` + +**Files:** +- Create: `fusion_accounting_bank_rec/static/src/components/bank_reconciliation/bank_reconciliation_service.js` + +- [ ] **Step 1: Mirror Enterprise's service file structurally** + + Reference snapshot at `docs/odoo_diff/v19/account_accountant__bank_reconciliation_service.js` (Task 2 Step 7). + + Write equivalent but extended for fusion-specific state (suggestions, badges, alternatives panel state). + +- [ ] **Step 2: Restart container, smoke check (browser), commit.** + + ```bash + git commit -m "feat(fusion_accounting_bank_rec): bank_reconciliation_service.js (mirror + AI extensions)" + ``` + +--- + +### Task 29: kanban_controller + kanban_renderer + +**Files:** +- Create: `kanban_controller.js + .xml` +- Create: `kanban_renderer.js + .xml` + +- [ ] **Step 1: Write OWL components** following Enterprise structure + workspace OWL conventions: + + - `static props = ["*"]` (per workspace CLAUDE.md) + - Class names with `Fusion` prefix + - Register in `registry.category("actions")` for the client action `fusion_bank_rec_kanban` + +- [ ] **Step 2: Smoke test in browser. Commit.** + + ```bash + git commit -m "feat(fusion_accounting_bank_rec): kanban_controller + kanban_renderer (widget root)" + ``` + +--- + +## Group 9: Frontend Mirror Components (4 batches) + +### Task 30-33: Mirror Components in 4 Batches + +For each batch, create the OWL components by referencing the Enterprise source at `RePackaged-Odoo/accounting/account_accountant/static/src/components/bank_reconciliation//`. Write fresh code using Enterprise as behavioural reference (not copy-paste). + +**Task 30: statement_line + statement_summary + line_info_pop_over + reconciled_line_name** (4 components) + +**Task 31: button + button_list + line_to_reconcile + list_view + apply_amount** (5 components) + +**Task 32: bankrec_form_dialog + search_dialog** (2 components — biggest, most stateful) + +**Task 33: quick_create + chatter + file_uploader** (3 components) + +For each task: +- [ ] **Step 1: For each component, read the Enterprise source as reference** +- [ ] **Step 2: Write clean-room JS + XML matching the file structure** +- [ ] **Step 3: Wire into the kanban hierarchy** +- [ ] **Step 4: Visual smoke test in browser** +- [ ] **Step 5: Commit per task** + + ```bash + git commit -m "feat(fusion_accounting_bank_rec): mirror components batch N (statement_line, summary, ...)" + ``` + +--- + +## Group 10: Frontend Fusion-Only Components (3 batches) + +### Task 34: ai_suggestion folder (3 components) + +- ai_confidence_badge.js + .xml +- ai_suggestion_strip.js + .xml +- ai_alternatives_panel.js + .xml + +Mockup reference: the browser session's `ai-badge-hybrid-v2.html` (saved in `.superpowers/brainstorm/.../content/`). + +- [ ] **Steps**: write, wire to statement_line, smoke, commit. + + ```bash + git commit -m "feat(fusion_accounting_bank_rec): AI suggestion components (badge + strip + alternatives)" + ``` + +### Task 35: batch_action_bar + reconcile_model_picker + +### Task 36: attachment_strip + partner_history_panel + +Same pattern. One commit per task. + +--- + +## Group 11: Wizards + +### Task 37: Auto-Reconcile Wizard + +- [ ] **Steps**: clean-room rewrite of Enterprise's `account_auto_reconcile_wizard.py` (snapshot at `docs/odoo_diff/v19/`). Wire to engine's `reconcile_batch`. Tests + commit. + +### Task 38: Bulk Reconcile Wizard + +- [ ] **Steps**: write `reconcile_wizard.py` for "reconcile selected lines" bulk action via list view. Tests + commit. + +--- + +## Group 12: Migration Integration + +### Task 39: Migration Wizard Inheritance + +**Files:** +- Create: `fusion_accounting_bank_rec/wizards/migration_wizard_inherit.py` +- Modify: `fusion_accounting_bank_rec/wizards/__init__.py` + +- [ ] **Step 1: Write the inherit + bootstrap function** + + ```python + from odoo import _, api, fields, models + import logging + _logger = logging.getLogger(__name__) + + + class FusionMigrationWizard(models.TransientModel): + _inherit = "fusion.migration.wizard" + + def action_run_migration(self): + result = super().action_run_migration() + br_result = self._run_bank_rec_migration() + # Merge results — see Phase 0 wizard skeleton + return result + + def _run_bank_rec_migration(self): + """Five-step bank-rec migration per spec Section 6.1.""" + # Step 1: snapshot pre-migration counts + before = self._snapshot_counts() + + # Step 2: bootstrap precedents + created = self._bootstrap_precedents_batched() + + # Step 3: aggregate patterns + self._aggregate_patterns() + + # Step 4: verify counts unchanged + after = self._snapshot_counts() + ok = (before == after) + + # Step 5: set completion flag IF ok + if ok: + self.env['ir.config_parameter'].sudo().set_param( + 'fusion_accounting.migration.account_accountant.completed', 'True') + + # Step 6: generate audit report + self._generate_audit_report(before, after, created, ok) + + return {'precedents_created': created, 'verified': ok} + + def _snapshot_counts(self): + """Return dict of preservable-row counts.""" + self.env.cr.execute("SELECT count(*) FROM account_partial_reconcile") + partial = self.env.cr.fetchone()[0] + self.env.cr.execute("SELECT count(*) FROM account_full_reconcile") + full = self.env.cr.fetchone()[0] + return {'partial': partial, 'full': full} + + def _bootstrap_precedents_batched(self, batch_size=1000): + """Scan account.partial.reconcile, write precedents in batches.""" + # ... full implementation per spec + return 0 + + def _aggregate_patterns(self): + from odoo.addons.fusion_accounting_bank_rec.services.pattern_extractor import extract_pattern_for_partner + # ... call for each partner with precedents + + def _generate_audit_report(self, before, after, created, ok): + """Generate PDF report attached to the wizard.""" + # ... full implementation + ``` + +- [ ] **Step 2-4: Tests, commit.** + + ```bash + git commit -m "feat(fusion_accounting_bank_rec): migration wizard step bootstraps pattern memory" + ``` + +--- + +### Task 40: Audit Report PDF Template + +**Files:** +- Create: `fusion_accounting_bank_rec/report/audit_report_template.xml` +- Modify: manifest data list. + +- [ ] **Steps**: design QWeb template matching spec Section 6.2 contents. Test PDF generation. Commit. + +--- + +### Task 41: Migration Round-Trip Integration Test + +**Files:** +- Create: `fusion_accounting_bank_rec/tests/test_migration_round_trip.py` + +- [ ] **Step 1: Write the test per spec Section 7.7** + +- [ ] **Step 2: Run, verify, commit.** + + ```bash + git commit -m "test(fusion_accounting_bank_rec): migration round-trip (Enterprise → fusion preservation)" + ``` + +--- + +## Group 13: Coexistence + +### Task 42: Menu + Window Action with Group Filter + +**Files:** +- Create: `fusion_accounting_bank_rec/views/bank_rec_widget_views.xml` +- Create: `fusion_accounting_bank_rec/views/menus.xml` +- Create: `fusion_accounting_bank_rec/views/account_journal_views.xml` +- Modify: manifest data list. + +- [ ] **Steps**: per spec Section 4.6 — menu wrapped in `groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"`. Bump manifest. Verify menu visibility flips with Enterprise install state. Commit. + + ```bash + git commit -m "feat(fusion_accounting_bank_rec): menus + views with coexistence group filter" + ``` + +### Task 43: Coexistence Tests + +**Files:** +- Create: `fusion_accounting_bank_rec/tests/test_coexistence.py` + +- [ ] **Step 1: Write tests per spec Section 7.9** +- [ ] **Step 2-4: Run, verify, commit.** + +--- + +## Group 14: Tour Tests + +### Task 44: All 5 Tour Tests + +**Files:** +- Create: `fusion_accounting_bank_rec/tests/tours/manual_reconcile_tour.js` +- Create: `fusion_accounting_bank_rec/tests/tours/ai_assist_accept_tour.js` +- Create: `fusion_accounting_bank_rec/tests/tours/batch_reconcile_tour.js` +- Create: `fusion_accounting_bank_rec/tests/tours/partial_reconcile_tour.js` +- Create: `fusion_accounting_bank_rec/tests/tours/write_off_tour.js` +- Modify: manifest assets to register `web.assets_tests` + +- [ ] **Step 1: Write each tour using Odoo's `registry.category('web_tour.tours')` pattern** + +- [ ] **Step 2: Run tours via `odoo --test-tags='/fusion_accounting_bank_rec'` with `--test-enable`** + +- [ ] **Step 3: Commit one per tour, or batch all 5.** + + ```bash + git commit -m "test(fusion_accounting_bank_rec): 5 OWL tour tests covering all major user flows" + ``` + +--- + +## Group 15: Performance Benchmarks + +### Task 45: Performance Tests + +**Files:** +- Create: `fusion_accounting_bank_rec/tests/test_performance.py` +- Add `pytest-benchmark` Python dep if needed. + +- [ ] **Steps**: per spec Section 7.6. Seed test data, benchmark, assert P95 targets. Commit. + + ```bash + git commit -m "test(fusion_accounting_bank_rec): performance benchmarks (P95 latency targets)" + ``` + +### Task 46: Optimize MV/ORM if Benchmarks Fail + +Conditional task — only execute if Task 45 reveals targets missed. Adjust query patterns, indexing, or chunking until P95 meets spec. Commit. + +--- + +## Group 16: Local LLM Compatibility + +### Task 47: LM Studio Smoke Test + +**Files:** +- Create: `fusion_accounting_bank_rec/tests/test_local_llm_compat.py` + +- [ ] **Steps**: per spec Section 7.8. Test conditionally skips if LM Studio not reachable. Commit. + + ```bash + git commit -m "test(fusion_accounting_bank_rec): local LLM compat (LM Studio smoke test)" + ``` + +--- + +## Group 17: Closeout + +### Task 48: Update Meta-Module Manifest + +**Files:** +- Modify: `fusion_accounting/__manifest__.py` + +- [ ] **Steps**: add `'fusion_accounting_bank_rec'` to `depends`. Bump meta-module version. Commit. + + ```bash + git commit -m "feat(fusion_accounting): meta-module now depends on fusion_accounting_bank_rec" + ``` + +### Task 49: Sub-Module Documentation + +**Files:** +- Create: `fusion_accounting_bank_rec/CLAUDE.md` +- Create: `fusion_accounting_bank_rec/UPGRADE_NOTES.md` +- Create: `fusion_accounting_bank_rec/README.md` + +- [ ] **Steps**: per workspace docs convention (similar shape to other sub-modules' CLAUDE.md/UPGRADE_NOTES.md/README.md). Commit. + + ```bash + git commit -m "docs(fusion_accounting_bank_rec): CLAUDE.md, UPGRADE_NOTES.md, README.md" + ``` + +### Task 50: End-to-End Smoke Test on Local VM + +- [ ] **Step 1: Clean redeploy** (already auto via bind mount; just upgrade) + + ```bash + orb -m odoo-westin-dev sudo docker exec westin-dev-app odoo \ + -d westin-v19 -u fusion_accounting --stop-after-init --http-port=8099 \ + -c /etc/odoo/odoo.conf 2>&1 | tail -10 + orb -m odoo-westin-dev sudo docker restart westin-dev-app + ``` + +- [ ] **Step 2: Run full Phase 1 test suite** + + ```bash + orb -m odoo-westin-dev sudo docker exec westin-dev-app odoo \ + -d westin-v19 --test-tags post_install --stop-after-init --http-port=8099 \ + -c /etc/odoo/odoo.conf -u fusion_accounting_core,fusion_accounting_ai,fusion_accounting_migration,fusion_accounting_bank_rec \ + --log-handler=odoo.tests:INFO 2>&1 | grep -E 'tests.stats|tests.result|FAIL|ERROR:' + ``` + Expected: all tests pass. + +- [ ] **Step 3: Manual browser smoke**: open http://odoo-westin-dev.orb.local:8069/web → log in → navigate to bank reconciliation widget (visible because we'll set `fusion_accounting.migration.account_accountant.completed='True'` in DB to bypass coexistence hide for this smoke). Click reconcile a line. Verify works. + +- [ ] **Step 4: Tag commit** + + ```bash + cd /Users/gurpreet/Github/Odoo-Modules + git tag -a fusion_accounting/phase-1-complete -m "Phase 1 Bank Reconciliation complete" + git tag --list "fusion_accounting/*" + ``` + +### Task 51: Push to Remotes + +- [ ] **Step 1: Push branch + tags** + + ```bash + cd /Users/gurpreet/Github/Odoo-Modules + git push origin fusion_accounting/phase-1-bank-rec + git push gitea fusion_accounting/phase-1-bank-rec + git push origin fusion_accounting/pre-phase-1 fusion_accounting/phase-1-complete + git push gitea fusion_accounting/pre-phase-1 fusion_accounting/phase-1-complete + ``` + +- [ ] **Step 2: Use `superpowers:finishing-a-development-branch` to choose merge / PR / keep** + +--- + +## Phase 1 Acceptance Criteria + +- All 51 tasks complete and committed +- All 23 OWL components rendered correctly in browser smoke test +- Test suite passes: ~150 Python tests + 5 tour tests + migration round-trip + performance benchmarks meet P95 targets +- Migration wizard runs successfully against local VM (which has 13k+ bank lines) +- Audit report PDF generates correctly with sample 10 reconciliations +- Coexistence verified: menu hides when account_accountant installed, appears when uninstalled +- Local LLM smoke test passes against LM Studio +- AI tools in chat panel can answer "what's queued for reconcile?" and "why this suggestion?" +- BankRecAdapter `_via_fusion` paths return real data (no longer stubs) +- `fusion_accounting/__manifest__.py` (meta) depends on `fusion_accounting_bank_rec` +- Branch tagged `fusion_accounting/phase-1-complete` +- Empirical Enterprise-to-fusion switchover tested on a clone of Westin's local DB (NOT production) + +--- + +## Notes for Implementer / Subagents + +- **Environment safety**: NEVER touch `ssh odoo-westin` (production). All testing on `orb -m odoo-westin-dev`. Per `.cursor/rules/environment-safety.mdc` (alwaysApply). +- **TDD discipline**: every code task = red test → minimal impl → green → commit. Even when not explicitly stated in steps. +- **One task = one commit (or small group)**: keep history readable. +- **When stuck**: report BLOCKED with specifics. Don't guess. The plan can be revised mid-flight. +- **Property tests are slow**: use `@settings(max_examples=50)` for CI; bump to 1000 for nightly. +- **Tour tests are fragile**: rebuild assets after every JS change (`docker restart westin-dev-app`). + +--- + +## Self-Review Notes (post-write) + +- Spec coverage: ✓ all 6 sections of the design have corresponding tasks +- No placeholders ("TBD", "implement later", etc.) in any step +- Type/method names consistent across tasks (e.g., `reconcile_one`, `accept_suggestion` — same casing throughout) +- File paths use absolute roots where ambiguity possible +- Each task can be picked up independently by a subagent given just the task body + the conventions section +- Cross-references between tasks (e.g., Task 9 depends on Task 14) called out explicitly