Files
Odoo-Modules/fusion_accounting/docs/superpowers/plans/2026-04-19-phase-1-bank-rec-plan.md
gsinghpal fe003567a9 docs(fusion_accounting): Phase 1 bank reconciliation implementation plan
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
2026-04-19 09:45:25 -04:00

3521 lines
136 KiB
Markdown
Raw Blame History

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