51 tasks across 17 groups covering the full Phase 1 build: Group 1 (5 tasks): Foundation — branch, sub-module skeleton, shared fields on _core, LLMProvider contract for local LLM readiness Group 2 (8 tasks): Reconcile engine — TDD-layered build of matching_strategies, exchange_diff, memo_tokenizer, precedent_lookup, pattern_extractor, confidence_scoring 4-pass pipeline, the AbstractModel engine with 6-method API, and Hypothesis property-based tests Group 3 (4 tasks): Models — fusion.reconcile.pattern, fusion.reconcile.precedent, fusion.reconcile.suggestion, widget transient, and inherits on Community account.bank.statement.line + account.reconcile.model Group 4-5 (6 tasks): Integration tests with SQL fixtures from real Westin reconciles + AI prompts + adapter fill-ins + AI tools refactor Group 6-7 (3 tasks): Materialized view, cron schedules, and 10-endpoint JSON-RPC controller with auth guards Group 8-10 (10 tasks): Frontend — SCSS tokens, service, kanban controllers, all 18 Enterprise-mirror OWL components, and 5 fusion-only components (ai_suggestion folder, batch_action_bar, attachment_strip, partner_history_panel, reconcile_model_picker) Group 11-13 (5 tasks): Wizards (auto-reconcile + bulk), migration wizard inheritance with bootstrap of 16,500 historical reconciliations + audit report PDF + round-trip test, coexistence menu/group + tests Group 14-16 (3 tasks): 5 OWL tour tests, performance benchmarks against P95 targets, local LLM compatibility test against LM Studio Group 17 (4 tasks): Closeout — meta-module manifest update, sub-module docs, end-to-end smoke test, completion tag TDD discipline throughout: every code task is red test → impl → green → commit. Property-based tests for amount invariants. Migration round- trip test asserts byte-identical reconciliation state pre/post Enterprise uninstall. All testing on local OrbStack VM only (environment-safety rule applies). Made-with: Cursor
136 KiB
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.
Conventions Used Throughout This Plan
Workspace Identity (CRITICAL — environment safety rule)
- All testing happens in the local OrbStack VM (
odoo-westin-dev, containerwestin-dev-app, DBwestin-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; noscp/docker cpneeded for code changes. - Schema/data changes still require
odoo -uupgrade.
Standard Commands
Test run (against local VM):
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):
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):
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:
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 justcurl) — Cursor's PATH lacks the system curl shadow
Commit Style
<type>(<scope>): <description> per recent repo conventions:
type: feat, fix, refactor, docs, test, chore, ciscope: usually the sub-module name (fusion_accounting_bank_rec,fusion_accounting_ai, etc.)- Multiline body via heredoc:
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:
- Write the failing test (run it, confirm RED with the expected error)
- Write minimal implementation (run test, confirm GREEN)
- Refactor if obvious cleanups (re-run test)
- 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
maincd /Users/gurpreet/Github/Odoo-Modules && git status --short -- fusion_accounting fusion_accounting_core fusion_accounting_ai fusion_accounting_migrationExpected: empty (no uncommitted fusion_accounting changes). Plating drift in other folders OK.
-
Step 2: Tag pre-Phase-1 state
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-1tag. -
Step 3: Create Phase 1 working branch
git checkout -b fusion_accounting/phase-1-bank-rec git branch --show-currentExpected 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
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__.pyPath:
fusion_accounting_bank_rec/__init__.pyfrom . import models from . import controllers from . import services from . import wizards -
Step 3: Write
__manifest__.pyPath:
fusion_accounting_bank_rec/__manifest__.py{ '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__.pyfilescd /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.csvid,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink -
Step 6: Copy icon from existing fusion_accounting_ai
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
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
orb -m odoo-westin-dev sudo docker exec westin-dev-app pip install --break-system-packages hypothesis -
Step 9: Verify install on local VM
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 -5Expected: clean install line near tail; no ERROR.
-
Step 10: Verify in DB
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
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
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.pyfrom 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
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"""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__.pyPath:
fusion_accounting_core/models/__init__.py— add (preserving existing imports):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
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
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.pyfrom 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</odoo>tag:<!-- Phase 1: dynamic coexistence group --> <record id="group_fusion_show_when_enterprise_absent" model="res.groups"> <field name="name">Fusion: Show menus when Enterprise absent</field> <field name="comment">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.</field> </record> -
Step 4: Write the model that recomputes membership
Path:
fusion_accounting_core/models/res_users.py"""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 resultNote:
IrModuleModule._inheritalready exists inir_module_module.pyfrom 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 thisIrModuleModuleblock toir_module_module.pyinstead. Updated structure:Edit
fusion_accounting_core/models/ir_module_module.py— append the three overrides: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 resultActually since
module_uninstallwas already overridden in Phase 0 (Task 17 safety guard), the existing override infusion_accounting_migrationalready runs. The Phase 1 addition belongs infusion_accounting_core/models/ir_module_module.py— Odoo's MRO merges the overrides. Verify carefully when implementing.Then
res_users.pyonly contains theResUsersclass with_fusion_recompute_coexistence_group. -
Step 5: Update
models/__init__.pyAdd
from . import res_userstofusion_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: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
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.pyfrom 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"""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 LLMProviderRead
fusion_accounting_ai/services/adapters/openai_adapter.pyfirst to see current structure.Then ensure these key changes (preserve existing message/response logic):
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://<host>: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__.pyto exportLLMProviderPath:
fusion_accounting_ai/services/adapters/__init__.pyfrom ._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. AddLLMProvideras base class. Set capability attributes: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
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
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.pyfrom 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"""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__.pyPath:
fusion_accounting_bank_rec/services/__init__.pyfrom . import memo_tokenizer -
Step 5: Update
tests/__init__.pyPath:
fusion_accounting_bank_rec/tests/__init__.pyfrom . import test_memo_tokenizer -
Step 6: Run tests, verify GREEN
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
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.pyfrom 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"""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— addfrom . import exchange_diff -
Step 5: Update
tests/__init__.py— addfrom . import test_exchange_diff -
Step 6: Run tests, verify GREEN. Commit.
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.pyfrom 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"""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— addfrom . import matching_strategies. -
Step 5: Update
tests/__init__.py— addfrom . import test_matching_strategies. -
Step 6: Run, verify GREEN. Commit.
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.pyfrom 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"""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__.pyandtests/__init__.py. -
Step 5: Run tests, verify GREEN.
Note: this task requires Task 14 first (the
fusion.reconcile.precedentmodel). 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
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.pyfrom 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"""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__.pyfiles, run, verify, commit.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.pyfrom 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.pyhelpers (created in Task 18). -
Step 2-6: Standard TDD pattern. Implementation outline:
Path:
fusion_accounting_bank_rec/services/confidence_scoring.py"""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.
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.pyfrom 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"""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.
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
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.
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.pyfrom 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.pyfrom 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__.pyfrom . import fusion_reconcile_pattern from . import fusion_reconcile_precedent -
Step 4: Add ACL rows
Path:
fusion_accounting_bank_rec/security/ir.model.access.csvid,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.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
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.
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
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.
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.pyfrom 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.pyfrom 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-v19DB. Pick one simple, one partial chain, one with HST, one with USD/CAD exchange, one unreconcile.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 = <pick a real id>" \ > /tmp/fixture.sqlThen 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
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
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.
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
"""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": <id>, "confidence": <0.0-1.0>, "reason": "<one sentence>"}, ... ] } 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__.pyto import. -
Step 3: Commit
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_fusionbody to use the engine: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
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 1explain_suggestion(env, suggestion_id)— Tier 1accept_suggestion_via_chat(env, suggestion_id)— Tier 3reject_suggestion_via_chat(env, suggestion_id, reason)— Tier 2batch_accept_high_confidence(env, journal_id, threshold=0.95)— Tier 3
Each function calls through
BankRecAdapter(viaget_adapter(env, 'bank_rec').<method>) for tri-mode safety. -
Step 2: Add corresponding records to
data/tool_definitions.xmlEach tool needs a
<record id="fusion_tool_..." model="fusion.accounting.tool">with name, description, parameters_schema, tier, etc. Follow existing pattern in the file. -
Step 3: Bump fusion_accounting_ai manifest, upgrade, commit.
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.
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_postoverride) -
Step 1: Define materialized view via SQL in the data XML
Path:
fusion_accounting_bank_rec/data/materialized_view.xml<?xml version="1.0" encoding="utf-8"?> <odoo> <data noupdate="0"> <!-- Initial creation of the materialized view --> <function model="fusion.bank.rec.widget" name="_create_materialized_view"/> </data> </odoo>Then add the SQL function on the widget model:
@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._postoverrideModify
fusion_accounting_bank_rec/models/account_bank_statement_line.py(or addaccount_move.py):# Refresh the MV via cron only — inline refresh would be too slowOr 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.
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__.pyto load it. -
Step 1: Define crons
<?xml version="1.0" encoding="utf-8"?> <odoo> <data noupdate="1"> <record id="cron_suggest_matches" model="ir.cron"> <field name="name">Fusion: Bank-rec suggest matches</field> <field name="model_id" ref="model_fusion_reconcile_engine"/> <field name="state">code</field> <field name="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()}) </field> <field name="interval_number">15</field> <field name="interval_type">minutes</field> <field name="active">True</field> </record> <record id="cron_refresh_patterns" model="ir.cron"> <field name="name">Fusion: Bank-rec refresh patterns (nightly)</field> <field name="model_id" ref="model_fusion_reconcile_pattern"/> <field name="state">code</field> <field name="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) </field> <field name="interval_number">1</field> <field name="interval_type">days</field> <field name="active">True</field> </record> <record id="cron_refresh_mv" model="ir.cron"> <field name="name">Fusion: Refresh unreconciled-per-partner MV</field> <field name="model_id" ref="model_fusion_bank_rec_widget"/> <field name="state">code</field> <field name="code">env.cr.execute("REFRESH MATERIALIZED VIEW CONCURRENTLY fusion_unreconciled_per_partner_mv;")</field> <field name="interval_number">5</field> <field name="interval_type">minutes</field> <field name="active">True</field> </record> </data> </odoo> -
Step 2: Add to manifest data list. Bump version. Commit.
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
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.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__.pyto 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)
'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.
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.
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
Fusionprefix - Register in
registry.category("actions")for the client actionfusion_bank_rec_kanban
-
Step 2: Smoke test in browser. Commit.
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/<folder>/. 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
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.
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 atdocs/odoo_diff/v19/). Wire to engine'sreconcile_batch. Tests + commit.
Task 38: Bulk Reconcile Wizard
- Steps: write
reconcile_wizard.pyfor "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
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.
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.
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.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.
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-benchmarkPython dep if needed. -
Steps: per spec Section 7.6. Seed test data, benchmark, assert P95 targets. Commit.
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.
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'todepends. Bump meta-module version. Commit.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.
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)
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
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
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
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-branchto 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_fusionpaths return real data (no longer stubs) fusion_accounting/__manifest__.py(meta) depends onfusion_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 onorb -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