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

136 KiB
Raw Blame History

Phase 1 — Bank Reconciliation Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build fusion_accounting_bank_rec — a native bank-reconciliation widget that replaces Odoo Enterprise's account_accountant bank rec, in V19 OWL architecture, with a clean-room reconcile engine reading/writing Community's account.partial.reconcile tables. AI assistive with confidence badges + behavioural learning. Architecture-ready for local LLM (Ollama, LM Studio).

Architecture: Hybrid mirror/abstract per the roadmap. Strict mirror of all 18 Enterprise OWL units + 5 fusion-only components. Reconcile engine is models.AbstractModel with 6-method public API. Pure-Python helpers in services/ are unit-testable without Odoo DB. AI suggestions are persisted records, never write account.partial.reconcile directly. LLM provider abstraction makes local LLM a one-config-line drop-in.

Tech Stack: Odoo 19, Python 3.12, PostgreSQL 16 + pgvector, OWL JS framework, Hypothesis (property-based tests), pytest-benchmark (performance), anthropic + openai Python clients (already installed).

Spec Reference: docs/superpowers/specs/2026-04-19-phase-1-bank-rec-design.md.


Conventions Used Throughout This Plan

Workspace Identity (CRITICAL — environment safety rule)

  • All testing happens in the local OrbStack VM (odoo-westin-dev, 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):

orb -m odoo-westin-dev sudo docker exec westin-dev-app odoo \
  -d westin-v19 --test-tags post_install --stop-after-init --http-port=8099 \
  -c /etc/odoo/odoo.conf -u fusion_accounting_bank_rec \
  --log-handler=odoo.tests:INFO 2>&1 | tail -30

Module upgrade (no tests, just registry refresh):

orb -m odoo-westin-dev sudo docker exec westin-dev-app odoo \
  -d westin-v19 -u fusion_accounting_bank_rec --stop-after-init --http-port=8099 \
  -c /etc/odoo/odoo.conf 2>&1 | tail -10

Restart container (refreshes Python/JS caches after pure code edits):

orb -m odoo-westin-dev sudo docker restart westin-dev-app
sleep 10
/usr/bin/curl -sSo /dev/null -w 'HTTP %{http_code}\n' http://odoo-westin-dev.orb.local:8069/web/login

SQL inspection:

orb -m odoo-westin-dev sudo docker exec westin-dev-db psql -U odoo -d westin-v19 -c "SELECT ..."

Browser test:

  • Local URL: http://odoo-westin-dev.orb.local:8069
  • Use /usr/bin/curl (not 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:
    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.019.0.1.0.119.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

    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

    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

    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

    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

    from . import models
    from . import controllers
    from . import services
    from . import wizards
    
  • Step 3: Write __manifest__.py

    Path: fusion_accounting_bank_rec/__manifest__.py

    {
        'name': 'Fusion Accounting — Bank Reconciliation',
        'version': '19.0.1.0.0',
        'category': 'Accounting/Accounting',
        'sequence': 28,
        'summary': 'Native V19 bank reconciliation widget with AI confidence scoring + behavioural learning.',
        'description': """
    Fusion Accounting — Bank Reconciliation
    ========================================
    Replaces Odoo Enterprise's account_accountant bank-rec widget with a
    native V19 OWL implementation reading/writing Community's
    account.partial.reconcile tables.
    
    Features:
    - Strict mirror of all Enterprise UI components (zero functional loss)
    - AI confidence badges with one-click Accept and ranked alternatives
    - Behavioural learning from historical reconciliations
    - Local LLM ready (Ollama, LM Studio) via OpenAI-compatible adapter
    - Coexists with account_accountant (Enterprise wins by default)
    
    Built by Nexa Systems Inc.
        """,
        'icon': '/fusion_accounting_bank_rec/static/description/icon.png',
        'author': 'Nexa Systems Inc.',
        'website': 'https://nexasystems.ca',
        'maintainer': 'Nexa Systems Inc.',
        'depends': ['fusion_accounting_core'],
        'external_dependencies': {
            'python': ['hypothesis'],
        },
        'data': [
            'security/ir.model.access.csv',
        ],
        'installable': True,
        'application': False,
        'license': 'OPL-1',
    }
    
  • Step 4: Write empty package __init__.py files

    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

    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

    cp /Users/gurpreet/Github/Odoo-Modules/fusion_accounting_ai/static/description/icon.png \
       /Users/gurpreet/Github/Odoo-Modules/fusion_accounting_bank_rec/static/description/icon.png
    
  • Step 7: Snapshot Enterprise reference files

    cp /Users/gurpreet/Github/RePackaged-Odoo/accounting/account_accountant/static/src/components/bank_reconciliation/bank_reconciliation_service.js \
       /Users/gurpreet/Github/Odoo-Modules/fusion_accounting_bank_rec/docs/odoo_diff/v19/account_accountant__bank_reconciliation_service.js
    cp /Users/gurpreet/Github/RePackaged-Odoo/accounting/account_accountant/models/account_reconcile_model.py \
       /Users/gurpreet/Github/Odoo-Modules/fusion_accounting_bank_rec/docs/odoo_diff/v19/account_accountant__account_reconcile_model.py
    cp /Users/gurpreet/Github/RePackaged-Odoo/accounting/account_accountant/wizard/account_auto_reconcile_wizard.py \
       /Users/gurpreet/Github/Odoo-Modules/fusion_accounting_bank_rec/docs/odoo_diff/v19/account_accountant__account_auto_reconcile_wizard.py
    
  • Step 8: Install hypothesis Python lib in container

    orb -m odoo-westin-dev sudo docker exec westin-dev-app pip install --break-system-packages hypothesis
    
  • Step 9: Verify install on local VM

    orb -m odoo-westin-dev sudo docker exec westin-dev-app odoo \
      -d westin-v19 -i fusion_accounting_bank_rec --stop-after-init --http-port=8099 \
      -c /etc/odoo/odoo.conf 2>&1 | tail -5
    

    Expected: clean install line near tail; no ERROR.

  • Step 10: Verify in DB

    orb -m odoo-westin-dev sudo docker exec westin-dev-db psql -U odoo -d westin-v19 -c \
      "SELECT name, state, latest_version FROM ir_module_module WHERE name='fusion_accounting_bank_rec';"
    

    Expected: installed, 19.0.1.0.0.

  • Step 11: Commit

    cd /Users/gurpreet/Github/Odoo-Modules
    git add fusion_accounting_bank_rec/
    git commit -m "feat(fusion_accounting_bank_rec): add empty sub-module skeleton"
    

Task 3: Add cron_last_check Shared Field to fusion_accounting_core

Files:

  • Modify: fusion_accounting_core/models/account_bank_statement_line.py (file may need creating)

  • Modify: fusion_accounting_core/models/__init__.py

  • Create: fusion_accounting_core/tests/test_shared_field_bank_statement.py

  • Step 1: Check if file exists

    ls /Users/gurpreet/Github/Odoo-Modules/fusion_accounting_core/models/account_bank_statement_line.py 2>/dev/null && echo "exists" || echo "missing"
    
  • Step 2: Write the failing test

    Path: fusion_accounting_core/tests/test_shared_field_bank_statement.py

    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

    orb -m odoo-westin-dev sudo docker exec westin-dev-app odoo \
      -d westin-v19 --test-tags post_install --stop-after-init --http-port=8099 \
      -c /etc/odoo/odoo.conf -u fusion_accounting_core --log-handler=odoo.tests:INFO 2>&1 | grep -E 'TestSharedFieldBankStatement|FAIL|AssertionError'
    

    Expected: AssertionError on cron_last_check must be declared.

  • Step 4: Create or update the model file

    Path: fusion_accounting_core/models/account_bank_statement_line.py

    """Shared-field-ownership for account.bank.statement.line.
    
    Enterprise's account_accountant adds cron_last_check (timestamp of last
    auto-reconcile cron run for the line). By declaring it here with the same
    schema, fusion_accounting_core becomes a co-owner so the column persists
    when account_accountant uninstalls.
    """
    
    from odoo import fields, models
    
    
    class AccountBankStatementLine(models.Model):
        _inherit = "account.bank.statement.line"
    
        cron_last_check = fields.Datetime(copy=False)
    
  • Step 5: Update models/__init__.py

    Path: fusion_accounting_core/models/__init__.py — add (preserving existing imports):

    from . import ir_module_module
    from . import account_move
    from . import account_reconcile_model
    from . import account_bank_statement_line  # NEW Phase 1
    
  • Step 6: Bump manifest version

    Path: fusion_accounting_core/__manifest__.py — change 'version': '19.0.1.0.0''version': '19.0.1.0.1'.

  • Step 7: Run test, confirm GREEN

    orb -m odoo-westin-dev sudo docker exec westin-dev-app odoo \
      -d westin-v19 --test-tags post_install --stop-after-init --http-port=8099 \
      -c /etc/odoo/odoo.conf -u fusion_accounting_core --log-handler=odoo.tests:INFO 2>&1 | grep -E 'TestSharedFieldBankStatement|FAIL|PASS'
    

    Expected: PASS.

  • Step 8: Commit

    cd /Users/gurpreet/Github/Odoo-Modules
    git add fusion_accounting_core/
    git commit -m "feat(fusion_accounting_core): shared-field-ownership for cron_last_check"
    

Task 4: Add Computed Coexistence Group to fusion_accounting_core

Files:

  • Create: fusion_accounting_core/models/res_users.py

  • Modify: fusion_accounting_core/models/__init__.py

  • Modify: fusion_accounting_core/security/fusion_accounting_security.xml (add the group definition)

  • Create: fusion_accounting_core/tests/test_coexistence_group.py

  • Step 1: Write the failing test

    Path: fusion_accounting_core/tests/test_coexistence_group.py

    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:

        <!-- Phase 1: dynamic coexistence group -->
        <record id="group_fusion_show_when_enterprise_absent" model="res.groups">
            <field name="name">Fusion: Show menus when Enterprise absent</field>
            <field name="comment">Computed group. Membership: all internal users when no Enterprise accounting module is installed. Used to hide fusion sub-module menus that would conflict with Enterprise UIs.</field>
        </record>
    
  • Step 4: Write the model that recomputes membership

    Path: fusion_accounting_core/models/res_users.py

    """Recompute membership of group_fusion_show_when_enterprise_absent
    whenever an Enterprise accounting module changes installation state."""
    
    from odoo import api, models
    
    
    class ResUsers(models.Model):
        _inherit = "res.users"
    
        @api.model
        def _fusion_recompute_coexistence_group(self):
            """Set group membership = all internal users iff Enterprise absent.
            Called from ir.module.module.button_immediate_install / uninstall overrides."""
            group = self.env.ref('fusion_accounting_core.group_fusion_show_when_enterprise_absent',
                                  raise_if_not_found=False)
            if not group:
                return
            enterprise_installed = self.env['ir.module.module']._fusion_is_enterprise_accounting_installed()
            if enterprise_installed:
                group.sudo().write({'user_ids': [(5, 0, 0)]})  # clear all
            else:
                all_internal = self.sudo().search([('share', '=', False)])
                group.sudo().write({'user_ids': [(6, 0, all_internal.ids)]})
    
    
    class IrModuleModule(models.Model):
        _inherit = "ir.module.module"
    
        def button_immediate_install(self):
            result = super().button_immediate_install()
            self.env['res.users']._fusion_recompute_coexistence_group()
            return result
    
        def button_immediate_uninstall(self):
            result = super().button_immediate_uninstall()
            self.env['res.users']._fusion_recompute_coexistence_group()
            return result
    
        def module_uninstall(self):
            result = super().module_uninstall()
            self.env['res.users']._fusion_recompute_coexistence_group()
            return 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:

        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:

    from . import models
    
    
    def post_init_hook(env):
        """Initialize coexistence group membership based on current Enterprise install state."""
        env['res.users']._fusion_recompute_coexistence_group()
    
  • Step 7: Bump manifest version → 19.0.1.0.2

  • Step 8: Run test, verify GREEN

    Same test command. Expected: both tests PASS.

  • Step 9: Commit

    cd /Users/gurpreet/Github/Odoo-Modules
    git add fusion_accounting_core/
    git commit -m "feat(fusion_accounting_core): add computed coexistence group + recompute hooks
    
    group_fusion_show_when_enterprise_absent has membership = all internal
    users iff no Enterprise accounting module is installed. Membership is
    recomputed on module install/uninstall via overrides on ir.module.module.
    Used by Phase 1 fusion_bank_rec menus to auto-hide when Enterprise is
    active and auto-appear after Enterprise uninstall."
    

Task 5: LLM Provider Contract + OpenAI Adapter Generalisation

Files:

  • Create: fusion_accounting_ai/services/adapters/_base.py

  • Modify: fusion_accounting_ai/services/adapters/__init__.py

  • Modify: fusion_accounting_ai/services/adapters/openai_adapter.py

  • Create: fusion_accounting_ai/tests/test_llm_provider_contract.py

  • Step 1: Write the failing test

    Path: fusion_accounting_ai/tests/test_llm_provider_contract.py

    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

    """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):

    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

    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:

    class ClaudeAdapter(LLMProvider):
        supports_tool_calling = True
        supports_streaming = True
        max_context_tokens = 200000
        supports_embeddings = False
        # ... existing init/complete logic preserved
    
  • Step 7: Bump fusion_accounting_ai manifest → 19.0.1.0.1

  • Step 8: Run all tests, verify nothing regressed

    orb -m odoo-westin-dev sudo docker exec westin-dev-app odoo \
      -d westin-v19 --test-tags post_install --stop-after-init --http-port=8099 \
      -c /etc/odoo/odoo.conf -u fusion_accounting_ai --log-handler=odoo.tests:INFO 2>&1 \
      | grep -E 'TestLLMProvider|TestDataAdapter|TestPostMigration|FAIL|PASS|tests.stats|tests.result'
    

    Expected: TestLLMProviderContract PASSES; existing fusion_accounting_ai tests still pass.

  • Step 9: Commit

    cd /Users/gurpreet/Github/Odoo-Modules
    git add fusion_accounting_ai/
    git commit -m "feat(fusion_accounting_ai): add LLMProvider contract + configurable openai base_url
    
    Phase 1 prerequisite for local LLM support. Adapters now declare
    capability flags (supports_tool_calling, max_context_tokens, etc.) so
    the engine can reason about what backend is available.
    
    OpenAI adapter accepts fusion_accounting.openai_base_url config — point
    it at LM Studio (http://host.docker.internal:1234/v1) or Ollama
    (http://host.docker.internal:11434/v1) and the existing OpenAI adapter
    works unchanged."
    

Group 2: Reconcile Engine — TDD Layered Build

Task 6: services/memo_tokenizer.py

Pure-Python utility to extract keywords from Canadian bank memos (RBC, TD, Scotia, BMO conventions). No ORM dependency — fastest test cycle.

Files:

  • Create: fusion_accounting_bank_rec/services/memo_tokenizer.py

  • Create: fusion_accounting_bank_rec/tests/test_memo_tokenizer.py

  • Step 1: Write failing test

    Path: fusion_accounting_bank_rec/tests/test_memo_tokenizer.py

    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

    """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

    from . import memo_tokenizer
    
  • Step 5: Update tests/__init__.py

    Path: fusion_accounting_bank_rec/tests/__init__.py

    from . import test_memo_tokenizer
    
  • Step 6: Run tests, verify GREEN

    orb -m odoo-westin-dev sudo docker exec westin-dev-app odoo \
      -d westin-v19 --test-tags post_install --stop-after-init --http-port=8099 \
      -c /etc/odoo/odoo.conf -u fusion_accounting_bank_rec --log-handler=odoo.tests:INFO 2>&1 \
      | grep -E 'TestMemoTokenizer|FAIL|PASS|tests.stats'
    

    Expected: 7 tests PASS.

  • Step 7: Commit

    cd /Users/gurpreet/Github/Odoo-Modules
    git add fusion_accounting_bank_rec/services/memo_tokenizer.py \
            fusion_accounting_bank_rec/services/__init__.py \
            fusion_accounting_bank_rec/tests/test_memo_tokenizer.py \
            fusion_accounting_bank_rec/tests/__init__.py
    git commit -m "feat(fusion_accounting_bank_rec): memo_tokenizer for Canadian bank memo formats"
    

Task 7: services/exchange_diff.py

Pure-Python helper that wraps Odoo's _create_exchange_difference_move — keeps engine signature stable while delegating to Odoo's FX math.

Files:

  • Create: fusion_accounting_bank_rec/services/exchange_diff.py

  • Create: fusion_accounting_bank_rec/tests/test_exchange_diff.py

  • Step 1: Write failing test

    Path: fusion_accounting_bank_rec/tests/test_exchange_diff.py

    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

    """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.

    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

    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

    """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.

    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

    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

    """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

    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

    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

    """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.

    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

    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

    """4-pass confidence scoring pipeline.
    
    Pass 1: SQL filter — partner match + reconcilable account
    Pass 2: Statistical scoring — amount delta + pattern match + precedent similarity
    Pass 3: AI re-rank (if provider configured) — feed top 5 to LLM, parse JSON ranking
    Pass 4: Persist as fusion.reconcile.suggestion rows
    """
    
    from dataclasses import dataclass
    from .matching_strategies import Candidate, AmountExactStrategy, FIFOStrategy
    from .precedent_lookup import find_nearest_precedents
    from .memo_tokenizer import tokenize_memo
    
    
    @dataclass
    class ScoredCandidate:
        candidate_id: int
        confidence: float
        reasoning: str
        score_amount_match: float
        score_partner_pattern: float
        score_precedent_similarity: float
        score_ai_rerank: float = 0.0
    
    
    def score_candidates(env, *, statement_line, candidates, k=5, use_ai=True) -> list[ScoredCandidate]:
        """Score and rank candidate matches for a statement line."""
        if not candidates or not statement_line:
            return []
    
        partner_id = statement_line.partner_id.id
        bank_amount = statement_line.amount
        memo_tokens = tokenize_memo(statement_line.payment_ref)
    
        # Pass 2.a: amount-match scoring
        pattern = env['fusion.reconcile.pattern'].sudo().search(
            [('partner_id', '=', partner_id)], limit=1)
        precedents = find_nearest_precedents(
            env, partner_id=partner_id, amount=bank_amount, k=5, memo_tokens=memo_tokens)
    
        scored = []
        for cand in candidates:
            amount_score = 1.0 - min(abs(cand.amount - bank_amount) / max(bank_amount, 1), 1.0)
            pattern_score = _pattern_score(cand, pattern, bank_amount) if pattern else 0.5
            precedent_score = _precedent_score(cand, precedents)
            # Weighted combine
            confidence = (amount_score * 0.5) + (pattern_score * 0.25) + (precedent_score * 0.25)
    
            reasoning = _build_reasoning(amount_score, pattern_score, precedent_score, pattern)
            scored.append(ScoredCandidate(
                candidate_id=cand.id,
                confidence=round(confidence, 3),
                reasoning=reasoning,
                score_amount_match=round(amount_score, 3),
                score_partner_pattern=round(pattern_score, 3),
                score_precedent_similarity=round(precedent_score, 3),
            ))
    
        scored.sort(key=lambda s: -s.confidence)
        top_k = scored[:k]
    
        # Pass 3: AI re-rank if provider available + use_ai=True
        if use_ai:
            provider = _get_provider(env, 'bank_rec_suggest')
            if provider is not None:
                top_k = _ai_rerank(env, provider, statement_line, top_k, pattern, precedents)
    
        return top_k
    
    
    def _pattern_score(cand, pattern, bank_amount) -> float:
        """How well does this candidate fit the partner's typical pattern?"""
        score = 0.5
        if pattern.pref_strategy == 'exact_amount' and abs(cand.amount - bank_amount) < 0.005:
            score = 1.0
        return score
    
    
    def _precedent_score(cand, precedents) -> float:
        """How similar is this candidate to past precedents?"""
        if not precedents:
            return 0.5
        # If candidate amount matches any precedent's amount, score high
        best = max((p.similarity_score for p in precedents), default=0.0)
        return best
    
    
    def _build_reasoning(amount_score, pattern_score, precedent_score, pattern) -> str:
        parts = []
        if amount_score >= 0.99:
            parts.append("Exact amount match")
        elif amount_score >= 0.95:
            parts.append("Amount close")
        if pattern and pattern.reconcile_count > 5:
            parts.append(f"Matches partner's {pattern.reconcile_count}-reconcile pattern")
        if precedent_score >= 0.8:
            parts.append("Strong precedent match")
        return " · ".join(parts) if parts else "Weak signal"
    
    
    def _get_provider(env, feature_name):
        """Look up provider name from per-feature config; instantiate adapter."""
        param = env['ir.config_parameter'].sudo()
        provider_name = param.get_param(f'fusion_accounting.provider.{feature_name}')
        if not provider_name:
            provider_name = param.get_param('fusion_accounting.provider.default')
        if not provider_name:
            return None
        # Instantiate via existing adapter registry
        from odoo.addons.fusion_accounting_ai.services.adapters import openai_adapter, claude
        if provider_name.startswith('openai'):
            return openai_adapter.OpenAIAdapter(env)
        elif provider_name.startswith('claude'):
            return claude.ClaudeAdapter(env)
        return None
    
    
    def _ai_rerank(env, provider, statement_line, scored, pattern, precedents):
        """Send top-K candidates + features to LLM for re-rank. Parse JSON response."""
        from odoo.addons.fusion_accounting_ai.services.prompts.bank_rec_prompt import build_prompt
        system, user = build_prompt(statement_line, scored, pattern, precedents)
        try:
            response = provider.complete(system=system, messages=[{'role': 'user', 'content': user}],
                                          max_tokens=800, temperature=0.0)
        except Exception:
            # On any provider failure, return the statistical scoring unchanged
            return scored
    
        import json
        try:
            parsed = json.loads(response['content'])
        except json.JSONDecodeError:
            return scored
    
        # Re-order scored list per AI's ranking, attach AI reasoning
        ai_order = {item['candidate_id']: item for item in parsed.get('ranked', [])}
        for s in scored:
            if s.candidate_id in ai_order:
                s.score_ai_rerank = ai_order[s.candidate_id].get('confidence', s.confidence)
                s.reasoning = ai_order[s.candidate_id].get('reason', s.reasoning)
                s.confidence = (s.confidence * 0.4) + (s.score_ai_rerank * 0.6)
        scored.sort(key=lambda x: -x.confidence)
        return scored
    
  • Step 3+: Standard TDD + commit pattern.

    git commit -m "feat(fusion_accounting_bank_rec): 4-pass confidence scoring pipeline"
    

Task 12: models/fusion_reconcile_engine.py (AbstractModel — 6-method API)

This is the orchestrator. Depends on Tasks 6-11, 14-17.

Files:

  • Create: fusion_accounting_bank_rec/models/fusion_reconcile_engine.py

  • Create: fusion_accounting_bank_rec/tests/test_reconcile_engine_unit.py

  • Step 1: Write failing tests for the 6 methods

    Path: fusion_accounting_bank_rec/tests/test_reconcile_engine_unit.py

    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

    """The reconcile engine — orchestrator for all bank-line reconciliations.
    
    Public API: 6 methods. All other code (controllers, AI tools, wizards)
    must go through these methods; no direct ORM writes to account.partial.reconcile
    from anywhere else.
    """
    
    import logging
    from odoo import _, api, models
    from odoo.exceptions import ValidationError, UserError
    
    from ..services.matching_strategies import (
        Candidate, AmountExactStrategy, FIFOStrategy, MultiInvoiceStrategy,
    )
    from ..services.confidence_scoring import score_candidates
    from ..services.exchange_diff import compute_exchange_diff
    from ..services.memo_tokenizer import tokenize_memo
    
    _logger = logging.getLogger(__name__)
    
    
    class FusionReconcileEngine(models.AbstractModel):
        _name = "fusion.reconcile.engine"
        _description = "Fusion Bank Reconciliation Engine"
    
        # ===== Public API =====
    
        @api.model
        def reconcile_one(self, statement_line, *, against_lines, write_off_vals=None):
            """Reconcile one bank line against a set of journal items.
    
            Returns: {'partial_ids': [...], 'exchange_diff_move_id': int|None,
                      'write_off_move_id': int|None}
            """
            if not statement_line:
                raise ValidationError(_("statement_line is required"))
            if not against_lines and not write_off_vals:
                raise ValidationError(_("Either against_lines or write_off_vals required"))
    
            # Phase 2: Validate
            self._validate_reconcile(statement_line, against_lines)
    
            # Phase 3: Optional write-off creation
            write_off_move_id = None
            if write_off_vals:
                write_off_move = self._create_write_off(statement_line, write_off_vals)
                against_lines = against_lines | write_off_move.line_ids.filtered(
                    lambda l: l.account_id.account_type in ('asset_receivable', 'liability_payable'))
                write_off_move_id = write_off_move.id
    
            # Phase 4: Reconcile via Odoo's standard API
            # Match the bank-line move's reconcile-account line with the against_lines
            line_move = statement_line.move_id
            line_move_lines_to_reconcile = line_move.line_ids.filtered(
                lambda l: l.account_id.account_type in ('asset_receivable', 'liability_payable'))
            all_to_reconcile = line_move_lines_to_reconcile | against_lines
            all_to_reconcile.reconcile()
    
            # Find the partials we just created
            partial_ids = self.env['account.partial.reconcile'].search([
                '|', ('debit_move_id', 'in', all_to_reconcile.ids),
                     ('credit_move_id', 'in', all_to_reconcile.ids),
                ('create_uid', '=', self.env.uid),
            ]).ids
    
            # Phase 5: Audit
            self._post_audit(statement_line, partial_ids, source='engine.reconcile_one')
    
            # Trigger precedent recording (fire-and-forget)
            self._record_precedent(statement_line, against_lines)
    
            return {
                'partial_ids': partial_ids,
                'exchange_diff_move_id': None,  # handled inline by reconcile()
                'write_off_move_id': write_off_move_id,
            }
    
        @api.model
        def reconcile_batch(self, statement_lines, *, strategy='auto'):
            """Bulk reconcile via the named strategy."""
            reconciled = 0
            skipped = 0
            errors = []
            for line in statement_lines:
                try:
                    candidates = self._fetch_candidates(line)
                    picked = self._apply_strategy(line, candidates, strategy)
                    if picked:
                        self.reconcile_one(line, against_lines=picked)
                        reconciled += 1
                    else:
                        skipped += 1
                except Exception as e:
                    errors.append({'line_id': line.id, 'error': str(e)})
            return {'reconciled_count': reconciled, 'skipped': skipped, 'errors': errors}
    
        @api.model
        def suggest_matches(self, statement_lines, *, limit_per_line=3):
            """Compute and persist AI suggestions per line.
    
            Returns: dict mapping line_id → list of suggestion dicts."""
            out = {}
            Suggestion = self.env['fusion.reconcile.suggestion']
            for line in statement_lines:
                candidates = self._fetch_candidates(line)
                if not candidates:
                    continue
                scored = score_candidates(self.env, statement_line=line, candidates=candidates,
                                           k=limit_per_line, use_ai=True)
                # Supersede any prior pending suggestions for this line
                Suggestion.search([
                    ('statement_line_id', '=', line.id),
                    ('state', '=', 'pending'),
                ]).write({'state': 'superseded'})
                # Persist new suggestions
                line_suggestions = []
                for rank, s in enumerate(scored, start=1):
                    candidate_line = self.env['account.move.line'].browse(s.candidate_id)
                    sug = Suggestion.create({
                        'company_id': line.company_id.id,
                        'statement_line_id': line.id,
                        'proposed_move_line_ids': [(6, 0, [s.candidate_id])],
                        'confidence': s.confidence,
                        'rank': rank,
                        'reasoning': s.reasoning,
                        'score_amount_match': s.score_amount_match,
                        'score_partner_pattern': s.score_partner_pattern,
                        'score_precedent_similarity': s.score_precedent_similarity,
                        'score_ai_rerank': s.score_ai_rerank,
                        'generated_by': 'on_demand',
                        'state': 'pending',
                    })
                    line_suggestions.append({
                        'id': sug.id, 'rank': rank, 'confidence': s.confidence,
                        'reasoning': s.reasoning, 'candidate_id': s.candidate_id,
                    })
                out[line.id] = line_suggestions
            return out
    
        @api.model
        def accept_suggestion(self, suggestion):
            """User clicked Accept on a suggestion → reconcile via its proposal."""
            if isinstance(suggestion, int):
                suggestion = self.env['fusion.reconcile.suggestion'].browse(suggestion)
            line = suggestion.statement_line_id
            against = suggestion.proposed_move_line_ids
            result = self.reconcile_one(line, against_lines=against)
            suggestion.write({
                'state': 'accepted',
                'accepted_at': self.env.cr.now(),
                'accepted_by': self.env.uid,
            })
            return result
    
        @api.model
        def write_off(self, statement_line, *, account, amount, tax_id=None, label):
            """Create a write-off move + reconcile the bank line against it."""
            # ... build account.move with one debit/credit pair, optional tax line
            # ... then call reconcile_one with the new move's reconcile-account line
            pass
    
        @api.model
        def unreconcile(self, partial_reconciles):
            """Reverse a reconciliation. Handles full vs. partial chains."""
            partial_reconciles = partial_reconciles.exists()
            all_lines = (partial_reconciles.mapped('debit_move_id')
                         | partial_reconciles.mapped('credit_move_id'))
            partial_reconciles.unlink()
            return {'unreconciled_line_ids': all_lines.ids}
    
        # ===== Private helpers =====
    
        def _validate_reconcile(self, statement_line, against_lines):
            if not statement_line.exists():
                raise ValidationError(_("Statement line does not exist"))
            if statement_line.is_reconciled:
                raise ValidationError(_("Line is already reconciled"))
            # ... currency consistency, period lock, partner allowed checks
    
        def _create_write_off(self, statement_line, write_off_vals):
            """Build and post the write-off account.move."""
            # ... create move with proper debit/credit + optional tax line
            pass
    
        def _fetch_candidates(self, statement_line):
            """SQL pre-filter: open journal items matching partner + reconcilable account."""
            domain = [
                ('parent_state', '=', 'posted'),
                ('account_id.reconcile', '=', True),
                ('reconciled', '=', False),
                ('display_type', 'not in', ['line_section', 'line_note']),
            ]
            if statement_line.partner_id:
                domain.append(('partner_id', '=', statement_line.partner_id.id))
            return self.env['account.move.line'].search(domain, limit=200)
    
        def _apply_strategy(self, line, candidates, strategy):
            """Convert candidates to Candidate dataclasses, apply strategy, return recordset."""
            today = fields.Date.today()
            dataclass_candidates = [
                Candidate(id=c.id, amount=abs(c.amount_residual), partner_id=c.partner_id.id,
                          age_days=(today - (c.date_maturity or c.date)).days)
                for c in candidates
            ]
            if strategy == 'auto':
                # Try strategies in order
                for strat_class in (AmountExactStrategy, MultiInvoiceStrategy, FIFOStrategy):
                    result = strat_class().match(bank_amount=abs(line.amount), candidates=dataclass_candidates)
                    if result.picked_ids:
                        return self.env['account.move.line'].browse(result.picked_ids)
            # ... handle other named strategies
            return self.env['account.move.line']
    
        def _post_audit(self, statement_line, partial_ids, source):
            """Log to mail.thread of the line's move."""
            statement_line.move_id.message_post(
                body=_("Reconciled via %s; partials: %s") % (source, partial_ids),
                subject=_("Bank reconciliation"),
            )
    
        def _record_precedent(self, statement_line, against_lines):
            """Append a precedent for future pattern learning."""
            self.env['fusion.reconcile.precedent'].sudo().create({
                'company_id': statement_line.company_id.id,
                'partner_id': statement_line.partner_id.id,
                'amount': abs(statement_line.amount),
                'currency_id': statement_line.currency_id.id,
                'date': statement_line.date,
                'memo_tokens': ','.join(tokenize_memo(statement_line.payment_ref)),
                'journal_id': statement_line.journal_id.id,
                'matched_move_line_count': len(against_lines),
                'matched_account_ids': ','.join(str(i) for i in against_lines.mapped('account_id').ids),
                'reconciler_user_id': self.env.uid,
                'reconciled_at': self.env.cr.now(),
                'source': 'manual',  # caller may override
            })
    

    This is the most complex single task. Engineer/subagent should expect ~3-5 days.

  • Step 4-6: Update models/__init__.py, run tests, commit per layer.

    Commit incrementally — one method at a time is reasonable for this large task.

    git commit -m "feat(fusion_accounting_bank_rec): reconcile engine 6-method public API"
    

Task 13: test_reconcile_engine_property.py (Hypothesis property-based tests)

Files:

  • Create: fusion_accounting_bank_rec/tests/test_reconcile_engine_property.py

  • Step 1: Write the property tests

    from decimal import Decimal
    from hypothesis import given, strategies as st, settings, HealthCheck
    from odoo.tests.common import TransactionCase, tagged
    
    
    @tagged('post_install', '-at_install', 'property_based')
    class TestReconcileEngineInvariants(TransactionCase):
        """Property-based tests for amount invariants. Each test runs 100 random inputs."""
    
        @given(
            bank_amount=st.decimals(min_value='0.01', max_value='100000.00', places=2),
            invoice_amounts=st.lists(
                st.decimals(min_value='0.01', max_value='100000.00', places=2),
                min_size=1, max_size=10,
            ),
        )
        @settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
        def test_invariant_total_debits_equal_total_credits(self, bank_amount, invoice_amounts):
            """Every reconciliation must produce balanced debit/credit totals.
            Skip impossible cases (over-allocation)."""
            if sum(invoice_amounts) < bank_amount:
                return  # skip — over-allocation handled separately
            # ... setup recordset
            # ... call engine.reconcile_one
            # ... assert sum(debits) == sum(credits)
    
        @given(amounts=st.lists(st.decimals(places=2), min_size=2, max_size=20))
        @settings(max_examples=50)
        def test_invariant_partial_reconciles_sum_to_full(self, amounts):
            """Sum of all account.partial.reconcile rows == line residual."""
            # ... setup, reconcile, assert
    
  • Step 2-4: Run, commit.

    git commit -m "test(fusion_accounting_bank_rec): Hypothesis property-based engine invariants"
    

Group 3: Models

Task 14: Create Pattern + Precedent Models

Files:

  • Create: fusion_accounting_bank_rec/models/fusion_reconcile_pattern.py

  • Create: fusion_accounting_bank_rec/models/fusion_reconcile_precedent.py

  • Modify: fusion_accounting_bank_rec/models/__init__.py

  • Modify: fusion_accounting_bank_rec/security/ir.model.access.csv

  • Step 1: Write fusion_reconcile_pattern.py

    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

    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

    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

    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.

    git commit -m "feat(fusion_accounting_bank_rec): pattern + precedent models for behavioural learning"
    

Task 15: Create Suggestion Model

Files:

  • Create: fusion_accounting_bank_rec/models/fusion_reconcile_suggestion.py

  • Modify: fusion_accounting_bank_rec/models/__init__.py

  • Modify: fusion_accounting_bank_rec/security/ir.model.access.csv

  • Create: fusion_accounting_bank_rec/tests/test_ai_suggestion_lifecycle.py

  • Step 1: Write the model from the spec (Section 5.1)

    Path: fusion_accounting_bank_rec/models/fusion_reconcile_suggestion.py — full schema per spec.

  • Step 2: Write lifecycle tests

    def test_suggestion_state_transitions(self):
        """pending → accepted, pending → rejected, pending → superseded all work"""
    
    def test_compute_band_from_confidence(self):
        """confidence_band auto-computed: 0.96 → 'high', 0.75 → 'medium', etc."""
    
    def test_acceptance_records_user_and_time(self):
        """Accepting a suggestion sets accepted_by + accepted_at"""
    
  • Step 3: Add ACL rows. Bump manifest. Run, verify, commit.

    git commit -m "feat(fusion_accounting_bank_rec): persisted AI suggestion model with state lifecycle"
    

Task 16: TransientModel for Widget Round-Trip Data

Files:

  • Create: fusion_accounting_bank_rec/models/fusion_bank_rec_widget.py

  • Modify: fusion_accounting_bank_rec/models/__init__.py

  • Step 1: Write the model

    from odoo import api, fields, models
    
    
    class FusionBankRecWidget(models.TransientModel):
        """Per-request widget state. Holds the kanban-load response shape
        so the controller can return one well-typed object."""
        _name = "fusion.bank.rec.widget"
        _description = "Bank reconciliation widget state (transient)"
    
        journal_id = fields.Many2one('account.journal')
        statement_line_ids = fields.Many2many('account.bank.statement.line')
        summary_count = fields.Integer()
        summary_unreconciled_balance = fields.Monetary(currency_field='currency_id')
        currency_id = fields.Many2one('res.currency')
    
        def action_open_kanban(self):
            """Return a window action opening the OWL kanban for this journal."""
            return {
                'type': 'ir.actions.client',
                'tag': 'fusion_bank_rec_kanban',
                'params': {'journal_id': self.journal_id.id},
            }
    
  • Step 2: Update init, ACL, manifest, commit.

    git commit -m "feat(fusion_accounting_bank_rec): transient model for widget round-trip data"
    

Task 17: Inherit account.bank.statement.line + account.reconcile.model

Files:

  • Create: fusion_accounting_bank_rec/models/account_bank_statement_line.py

  • Create: fusion_accounting_bank_rec/models/account_reconcile_model.py

  • Modify: fusion_accounting_bank_rec/models/__init__.py

  • Step 1: Write account_bank_statement_line.py

    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

    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.

    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

    git commit -m "test(fusion_accounting_bank_rec): factories + 5 SQL fixtures from real Westin reconciles"
    

Task 19: test_reconcile_engine_integration.py

Files:

  • Create: fusion_accounting_bank_rec/tests/test_reconcile_engine_integration.py

  • Step 1: Write replay tests

    from odoo.tests.common import TransactionCase, tagged
    from odoo.tools import file_open
    
    
    @tagged('post_install', '-at_install', 'integration')
    class TestEnterpriseReplay(TransactionCase):
        """Replay each fixture via fusion's engine, assert byte-identical output."""
    
        @classmethod
        def _load_fixture(cls, fixture_name):
            with file_open(f'fusion_accounting_bank_rec/tests/_fixtures/{fixture_name}', 'r') as f:
                cls.env.cr.execute(f.read())
    
        def test_replay_simple_match(self):
            self._load_fixture('westin_simple_match.sql')
            # ... unlink the captured partial; re-run fusion engine; assert identical
    
  • Step 2-4: Run, verify, commit.

    git commit -m "test(fusion_accounting_bank_rec): integration replay against real Westin reconciles"
    

Group 5: AI Integration Backend

Task 20: services/prompts/bank_rec_prompt.py

Files:

  • Create: fusion_accounting_ai/services/prompts/bank_rec_prompt.py

  • Step 1: Write the prompt builder

    """Bank-rec suggestion-ranking prompt builder.
    
    Produces (system, user) prompt pair for the LLM. Output JSON for
    compatibility with non-tool-calling local models.
    """
    
    
    SYSTEM_PROMPT = """\
    You are a bank reconciliation assistant for a Canadian accounting team.
    Your task: rank candidate matches for a bank statement line and explain
    each ranking in one sentence.
    
    Output format: JSON only, no prose. Schema:
    {
      "ranked": [
        {"candidate_id": <id>, "confidence": <0.0-1.0>, "reason": "<one sentence>"},
        ...
      ]
    }
    
    Rules:
    - Confidence >0.95 only when amount + partner + memo all align perfectly
    - Confidence 0.70-0.94 when 2 of 3 align
    - Confidence <0.70 when only one aligns or evidence is conflicting
    - If a candidate is clearly wrong, omit it
    - Reason must cite specific evidence (amount, prior reconciles, memo tokens)
    - Currency: Canadian English. Amounts in $X,XXX.XX format.
    """
    
    
    def build_prompt(statement_line, scored_candidates, pattern, precedents):
        """Assemble the user prompt for one line's suggestion ranking."""
        lines = [
            "BANK LINE",
            f"  Date: {statement_line.date}",
            f"  Amount: ${statement_line.amount:,.2f} {statement_line.currency_id.name}",
            f"  Memo: \"{statement_line.payment_ref or '(none)'}\"",
            f"  Partner: {statement_line.partner_id.name or '(none)'} (id={statement_line.partner_id.id})",
            "",
        ]
    
        if pattern and pattern.reconcile_count:
            lines += [
                f"PARTNER PATTERN ({statement_line.partner_id.name}, last 18 months)",
                f"  Total reconciles: {pattern.reconcile_count}",
                f"  Typical amount: {pattern.typical_amount_range or '(unknown)'}",
                f"  Typical cadence: every {pattern.typical_cadence_days:.1f} days" if pattern.typical_cadence_days else "  Typical cadence: (unknown)",
                f"  Preferred strategy: {pattern.pref_strategy or '(unknown)'}",
                f"  Common memo tokens: {pattern.common_memo_tokens or '(none)'}",
                "",
            ]
    
        if precedents:
            lines.append("NEAREST 3 PRECEDENTS")
            for i, p in enumerate(precedents[:3], start=1):
                lines.append(f"  {i}. ${p.amount:,.2f} · matched {p.matched_move_line_count} item(s) · memo \"{p.memo_tokens}\"")
            lines.append("")
    
        lines.append("CANDIDATE MATCHES (statistical pre-filter, top 5)")
        for sc in scored_candidates[:5]:
            # Look up candidate detail
            cand = statement_line.env['account.move.line'].browse(sc.candidate_id)
            age = "?"
            if cand.date_maturity:
                age = (statement_line.date - cand.date_maturity).days
            lines.append(f"  - candidate_id={sc.candidate_id}: {cand.move_id.name} · ${cand.amount_residual:,.2f} · age {age} days · partner {cand.partner_id.name or '(none)'}")
    
        lines.append("")
        lines.append("Rank these candidates.")
    
        return SYSTEM_PROMPT, "\n".join(lines)
    
  • Step 2: Update fusion_accounting_ai/services/prompts/__init__.py to import.

  • Step 3: Commit

    git commit -m "feat(fusion_accounting_ai): bank_rec_prompt builder for suggestion ranking"
    

Task 21: Fill in BankRecAdapter._via_fusion Paths

Files:

  • Modify: fusion_accounting_ai/services/data_adapters/bank_rec.py

  • Step 1: Update the adapter

    Replace list_unreconciled_via_fusion body to use the engine:

    def list_unreconciled_via_fusion(self, journal_id, limit=100):
        Line = self.env['account.bank.statement.line'].sudo()
        records = Line.search([
            ('journal_id', '=', journal_id),
            ('is_reconciled', '=', False),
        ], limit=limit, order='date desc, id desc')
        return [
            {
                'id': r.id,
                'date': r.date,
                'payment_ref': r.payment_ref,
                'amount': r.amount,
                'partner_id': r.partner_id.id if r.partner_id else None,
                'partner_name': r.partner_id.name if r.partner_id else None,
                'currency_id': r.currency_id.id if r.currency_id else None,
                'fusion_confidence_band': r.fusion_confidence_band,
                'fusion_top_suggestion_id': r.fusion_top_suggestion_id.id if r.fusion_top_suggestion_id else None,
            }
            for r in records
        ]
    
    def get_ai_suggestions(self, statement_line_ids):
        """Return all pending AI suggestions for the given lines."""
        sugs = self.env['fusion.reconcile.suggestion'].sudo().search([
            ('statement_line_id', 'in', statement_line_ids),
            ('state', '=', 'pending'),
        ])
        return [
            {'id': s.id, 'line_id': s.statement_line_id.id, 'confidence': s.confidence,
             'rank': s.rank, 'reasoning': s.reasoning}
            for s in sugs
        ]
    
    def accept_suggestion(self, suggestion_id):
        sug = self.env['fusion.reconcile.suggestion'].browse(suggestion_id)
        return self.env['fusion.reconcile.engine'].accept_suggestion(sug)
    
  • Step 2: Commit

    git commit -m "feat(fusion_accounting_ai): BankRecAdapter._via_fusion fills in to call fusion.reconcile.engine"
    

Task 22: New AI Tools in services/tools/bank_reconciliation.py

Files:

  • Modify: fusion_accounting_ai/services/tools/bank_reconciliation.py

  • Modify: fusion_accounting_ai/data/tool_definitions.xml

  • Step 1: Add the 5 new tools

    Add functions:

    • get_pending_suggestions(env, journal_id, limit=100) — Tier 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.

    git commit -m "feat(fusion_accounting_ai): add 5 new bank-rec AI tools (suggestions, accept, batch)"
    

Task 23: Refactor Existing AI Tools to Use Engine

Files:

  • Modify: fusion_accounting_ai/services/tools/bank_reconciliation.py

  • Step 1: Refactor existing tools (get_unreconciled_bank_lines, find_similar_bank_lines, etc.) to call through the BankRecAdapter rather than direct ORM

  • Step 2: Run existing test suite, verify no regression. Commit.

    git commit -m "refactor(fusion_accounting_ai): existing bank-rec tools route through engine"
    

Group 6: Materialized View + Cron

Task 24: Materialized View + Refresh Trigger

Files:

  • Create: fusion_accounting_bank_rec/data/materialized_view.xml

  • Modify: fusion_accounting_bank_rec/models/account_bank_statement_line.py (add _post override)

  • Step 1: Define materialized view via SQL in the data XML

    Path: fusion_accounting_bank_rec/data/materialized_view.xml

    <?xml version="1.0" encoding="utf-8"?>
    <odoo>
        <data noupdate="0">
            <!-- Initial creation of the materialized view -->
            <function model="fusion.bank.rec.widget" name="_create_materialized_view"/>
        </data>
    </odoo>
    

    Then add the SQL function on the widget model:

    @api.model
    def _create_materialized_view(self):
        """Idempotent creation of fusion_unreconciled_per_partner_mv."""
        self.env.cr.execute("""
            CREATE MATERIALIZED VIEW IF NOT EXISTS fusion_unreconciled_per_partner_mv AS
            SELECT
                aml.partner_id,
                aml.company_id,
                count(*) AS unreconciled_count,
                sum(aml.amount_residual) AS unreconciled_total
            FROM account_move_line aml
            JOIN account_account aa ON aa.id = aml.account_id
            WHERE aml.reconciled = false
              AND aa.reconcile = true
              AND aml.parent_state = 'posted'
              AND aml.partner_id IS NOT NULL
            GROUP BY aml.partner_id, aml.company_id;
    
            CREATE UNIQUE INDEX IF NOT EXISTS fusion_uppm_idx
                ON fusion_unreconciled_per_partner_mv (company_id, partner_id);
        """)
    
  • Step 2: Add refresh trigger via account.move._post override

    Modify fusion_accounting_bank_rec/models/account_bank_statement_line.py (or add account_move.py):

    # 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.

    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 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.

    git commit -m "feat(fusion_accounting_bank_rec): cron schedules for suggest, pattern refresh, MV refresh"
    

Group 7: Controllers

Task 26: Bank Rec Controller (10 Endpoints)

Files:

  • Create: fusion_accounting_bank_rec/controllers/bank_rec_controller.py

  • Modify: fusion_accounting_bank_rec/controllers/__init__.py

  • Create: fusion_accounting_bank_rec/tests/test_widget_endpoints.py

  • Step 1: Write failing tests for the 10 endpoints

    Each endpoint gets a test asserting:

    • Returns expected JSON shape
    • Requires auth (rejects anonymous)
    • Honors group permissions for write endpoints
    • Calls the correct engine method
  • Step 2: Write the controller

    from odoo import http
    from odoo.http import request
    
    
    class FusionBankRecController(http.Controller):
        """JSON-RPC endpoints for the OWL bank-rec widget."""
    
        @http.route('/fusion_bank_rec/load_kanban', type='jsonrpc', auth='user')
        def load_kanban(self, journal_id):
            # ... fetch unreconciled lines + statement summary + pending suggestions
            pass
    
        @http.route('/fusion_bank_rec/reconcile', type='jsonrpc', auth='user')
        def reconcile(self, line_id, against_line_ids, write_off_vals=None):
            line = request.env['account.bank.statement.line'].browse(line_id)
            against = request.env['account.move.line'].browse(against_line_ids)
            return request.env['fusion.reconcile.engine'].reconcile_one(
                line, against_lines=against, write_off_vals=write_off_vals)
    
        @http.route('/fusion_bank_rec/reconcile_batch', type='jsonrpc', auth='user')
        def reconcile_batch(self, line_ids, strategy='auto'):
            # Tier 3 — require manager group
            if not request.env.user.has_group('fusion_accounting_core.group_fusion_accounting_manager'):
                raise http.AccessError("Bulk reconcile requires Manager privilege.")
            lines = request.env['account.bank.statement.line'].browse(line_ids)
            return request.env['fusion.reconcile.engine'].reconcile_batch(lines, strategy=strategy)
    
        @http.route('/fusion_bank_rec/accept_suggestion', type='jsonrpc', auth='user')
        def accept_suggestion(self, suggestion_id):
            sug = request.env['fusion.reconcile.suggestion'].browse(suggestion_id)
            return request.env['fusion.reconcile.engine'].accept_suggestion(sug)
    
        @http.route('/fusion_bank_rec/reject_suggestion', type='jsonrpc', auth='user')
        def reject_suggestion(self, suggestion_id, reason='other'):
            sug = request.env['fusion.reconcile.suggestion'].browse(suggestion_id)
            sug.write({'state': 'rejected', 'rejected_at': fields.Datetime.now(),
                       'rejected_reason': reason})
            return {'ok': True}
    
        @http.route('/fusion_bank_rec/refresh_suggestions', type='jsonrpc', auth='user')
        def refresh_suggestions(self, line_ids):
            lines = request.env['account.bank.statement.line'].browse(line_ids)
            return request.env['fusion.reconcile.engine'].suggest_matches(lines)
    
        @http.route('/fusion_bank_rec/write_off', type='jsonrpc', auth='user')
        def write_off(self, line_id, account_id, amount, tax_id=None, label=''):
            line = request.env['account.bank.statement.line'].browse(line_id)
            account = request.env['account.account'].browse(account_id)
            return request.env['fusion.reconcile.engine'].write_off(
                line, account=account, amount=amount, tax_id=tax_id, label=label)
    
        @http.route('/fusion_bank_rec/unreconcile', type='jsonrpc', auth='user')
        def unreconcile(self, partial_ids):
            partials = request.env['account.partial.reconcile'].browse(partial_ids)
            return request.env['fusion.reconcile.engine'].unreconcile(partials)
    
        @http.route('/fusion_bank_rec/partner_history', type='jsonrpc', auth='user')
        def partner_history(self, partner_id, limit=20):
            # Read precedents for this partner
            precedents = request.env['fusion.reconcile.precedent'].search(
                [('partner_id', '=', partner_id)], limit=limit, order='reconciled_at desc')
            return [{'id': p.id, 'amount': p.amount, 'date': p.date,
                      'memo_tokens': p.memo_tokens, 'count': p.matched_move_line_count}
                     for p in precedents]
    
        @http.route('/fusion_bank_rec/upload_attachment', type='http', auth='user', methods=['POST'])
        def upload_attachment(self, line_id, ufile, **kw):
            line = request.env['account.bank.statement.line'].browse(int(line_id))
            # Create ir.attachment linked to line.move_id
            import base64
            request.env['ir.attachment'].create({
                'name': ufile.filename,
                'datas': base64.b64encode(ufile.read()),
                'res_model': 'account.move',
                'res_id': line.move_id.id,
            })
            return request.make_response('OK', headers=[('Content-Type', 'text/plain')])
    
  • Step 3: Update controllers/__init__.py. Bump manifest. Run tests. Commit.

    git commit -m "feat(fusion_accounting_bank_rec): controller with 10 JSON-RPC endpoints + auth + ACLs"
    

Group 8: Frontend Foundation

Task 27: SCSS Tokens + Main Stylesheet

Files:

  • Create: fusion_accounting_bank_rec/static/src/components/bank_reconciliation/_bank_rec_tokens.scss

  • Create: fusion_accounting_bank_rec/static/src/components/bank_reconciliation/bank_rec_widget.scss

  • Modify: fusion_accounting_bank_rec/__manifest__.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)

    'assets': {
        'web.assets_backend': [
            'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/_bank_rec_tokens.scss',
            'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/bank_rec_widget.scss',
            'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/**/*.js',
            'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/**/*.xml',
            'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/**/*.scss',
        ],
    },
    
  • Step 4: Bump manifest. Restart container to refresh asset bundle. Commit.

    git commit -m "feat(fusion_accounting_bank_rec): SCSS tokens (light + dark) and main widget stylesheet"
    

Task 28: Frontend Service bank_reconciliation_service.js

Files:

  • Create: fusion_accounting_bank_rec/static/src/components/bank_reconciliation/bank_reconciliation_service.js

  • Step 1: Mirror Enterprise's service file structurally

    Reference snapshot at docs/odoo_diff/v19/account_accountant__bank_reconciliation_service.js (Task 2 Step 7).

    Write equivalent but extended for fusion-specific state (suggestions, badges, alternatives panel state).

  • Step 2: Restart container, smoke check (browser), commit.

    git commit -m "feat(fusion_accounting_bank_rec): bank_reconciliation_service.js (mirror + AI extensions)"
    

Task 29: kanban_controller + kanban_renderer

Files:

  • Create: kanban_controller.js + .xml

  • Create: kanban_renderer.js + .xml

  • Step 1: Write OWL components following Enterprise structure + workspace OWL conventions:

    • static props = ["*"] (per workspace CLAUDE.md)
    • Class names with Fusion prefix
    • Register in registry.category("actions") for the client action fusion_bank_rec_kanban
  • Step 2: Smoke test in browser. Commit.

    git commit -m "feat(fusion_accounting_bank_rec): kanban_controller + kanban_renderer (widget root)"
    

Group 9: Frontend Mirror Components (4 batches)

Task 30-33: Mirror Components in 4 Batches

For each batch, create the OWL components by referencing the Enterprise source at RePackaged-Odoo/accounting/account_accountant/static/src/components/bank_reconciliation/<folder>/. Write fresh code using Enterprise as behavioural reference (not copy-paste).

Task 30: statement_line + statement_summary + line_info_pop_over + reconciled_line_name (4 components)

Task 31: button + button_list + line_to_reconcile + list_view + apply_amount (5 components)

Task 32: bankrec_form_dialog + search_dialog (2 components — biggest, most stateful)

Task 33: quick_create + chatter + file_uploader (3 components)

For each task:

  • Step 1: For each component, read the Enterprise source as reference

  • Step 2: Write clean-room JS + XML matching the file structure

  • Step 3: Wire into the kanban hierarchy

  • Step 4: Visual smoke test in browser

  • Step 5: Commit per task

    git commit -m "feat(fusion_accounting_bank_rec): mirror components batch N (statement_line, summary, ...)"
    

Group 10: Frontend Fusion-Only Components (3 batches)

Task 34: ai_suggestion folder (3 components)

  • ai_confidence_badge.js + .xml
  • ai_suggestion_strip.js + .xml
  • ai_alternatives_panel.js + .xml

Mockup reference: the browser session's ai-badge-hybrid-v2.html (saved in .superpowers/brainstorm/.../content/).

  • Steps: write, wire to statement_line, smoke, commit.

    git commit -m "feat(fusion_accounting_bank_rec): AI suggestion components (badge + strip + alternatives)"
    

Task 35: batch_action_bar + reconcile_model_picker

Task 36: attachment_strip + partner_history_panel

Same pattern. One commit per task.


Group 11: Wizards

Task 37: Auto-Reconcile Wizard

  • Steps: clean-room rewrite of Enterprise's account_auto_reconcile_wizard.py (snapshot 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

    from odoo import _, api, fields, models
    import logging
    _logger = logging.getLogger(__name__)
    
    
    class FusionMigrationWizard(models.TransientModel):
        _inherit = "fusion.migration.wizard"
    
        def action_run_migration(self):
            result = super().action_run_migration()
            br_result = self._run_bank_rec_migration()
            # Merge results — see Phase 0 wizard skeleton
            return result
    
        def _run_bank_rec_migration(self):
            """Five-step bank-rec migration per spec Section 6.1."""
            # Step 1: snapshot pre-migration counts
            before = self._snapshot_counts()
    
            # Step 2: bootstrap precedents
            created = self._bootstrap_precedents_batched()
    
            # Step 3: aggregate patterns
            self._aggregate_patterns()
    
            # Step 4: verify counts unchanged
            after = self._snapshot_counts()
            ok = (before == after)
    
            # Step 5: set completion flag IF ok
            if ok:
                self.env['ir.config_parameter'].sudo().set_param(
                    'fusion_accounting.migration.account_accountant.completed', 'True')
    
            # Step 6: generate audit report
            self._generate_audit_report(before, after, created, ok)
    
            return {'precedents_created': created, 'verified': ok}
    
        def _snapshot_counts(self):
            """Return dict of preservable-row counts."""
            self.env.cr.execute("SELECT count(*) FROM account_partial_reconcile")
            partial = self.env.cr.fetchone()[0]
            self.env.cr.execute("SELECT count(*) FROM account_full_reconcile")
            full = self.env.cr.fetchone()[0]
            return {'partial': partial, 'full': full}
    
        def _bootstrap_precedents_batched(self, batch_size=1000):
            """Scan account.partial.reconcile, write precedents in batches."""
            # ... full implementation per spec
            return 0
    
        def _aggregate_patterns(self):
            from odoo.addons.fusion_accounting_bank_rec.services.pattern_extractor import extract_pattern_for_partner
            # ... call for each partner with precedents
    
        def _generate_audit_report(self, before, after, created, ok):
            """Generate PDF report attached to the wizard."""
            # ... full implementation
    
  • Step 2-4: Tests, commit.

    git commit -m "feat(fusion_accounting_bank_rec): migration wizard step bootstraps pattern memory"
    

Task 40: Audit Report PDF Template

Files:

  • Create: fusion_accounting_bank_rec/report/audit_report_template.xml

  • Modify: manifest data list.

  • Steps: design QWeb template matching spec Section 6.2 contents. Test PDF generation. Commit.


Task 41: Migration Round-Trip Integration Test

Files:

  • Create: fusion_accounting_bank_rec/tests/test_migration_round_trip.py

  • Step 1: Write the test per spec Section 7.7

  • Step 2: Run, verify, commit.

    git commit -m "test(fusion_accounting_bank_rec): migration round-trip (Enterprise → fusion preservation)"
    

Group 13: Coexistence

Task 42: Menu + Window Action with Group Filter

Files:

  • Create: fusion_accounting_bank_rec/views/bank_rec_widget_views.xml

  • Create: fusion_accounting_bank_rec/views/menus.xml

  • Create: fusion_accounting_bank_rec/views/account_journal_views.xml

  • Modify: manifest data list.

  • Steps: per spec Section 4.6 — menu wrapped in groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent". Bump manifest. Verify menu visibility flips with Enterprise install state. Commit.

    git commit -m "feat(fusion_accounting_bank_rec): menus + views with coexistence group filter"
    

Task 43: Coexistence Tests

Files:

  • Create: fusion_accounting_bank_rec/tests/test_coexistence.py

  • Step 1: Write tests per spec Section 7.9

  • Step 2-4: Run, verify, commit.


Group 14: Tour Tests

Task 44: All 5 Tour Tests

Files:

  • Create: fusion_accounting_bank_rec/tests/tours/manual_reconcile_tour.js

  • Create: fusion_accounting_bank_rec/tests/tours/ai_assist_accept_tour.js

  • Create: fusion_accounting_bank_rec/tests/tours/batch_reconcile_tour.js

  • Create: fusion_accounting_bank_rec/tests/tours/partial_reconcile_tour.js

  • Create: fusion_accounting_bank_rec/tests/tours/write_off_tour.js

  • Modify: manifest assets to register web.assets_tests

  • Step 1: Write each tour using Odoo's registry.category('web_tour.tours') pattern

  • Step 2: Run tours via odoo --test-tags='/fusion_accounting_bank_rec' with --test-enable

  • Step 3: Commit one per tour, or batch all 5.

    git commit -m "test(fusion_accounting_bank_rec): 5 OWL tour tests covering all major user flows"
    

Group 15: Performance Benchmarks

Task 45: Performance Tests

Files:

  • Create: fusion_accounting_bank_rec/tests/test_performance.py

  • Add pytest-benchmark Python dep if needed.

  • Steps: per spec Section 7.6. Seed test data, benchmark, assert P95 targets. Commit.

    git commit -m "test(fusion_accounting_bank_rec): performance benchmarks (P95 latency targets)"
    

Task 46: Optimize MV/ORM if Benchmarks Fail

Conditional task — only execute if Task 45 reveals targets missed. Adjust query patterns, indexing, or chunking until P95 meets spec. Commit.


Group 16: Local LLM Compatibility

Task 47: LM Studio Smoke Test

Files:

  • Create: fusion_accounting_bank_rec/tests/test_local_llm_compat.py

  • Steps: per spec Section 7.8. Test conditionally skips if LM Studio not reachable. Commit.

    git commit -m "test(fusion_accounting_bank_rec): local LLM compat (LM Studio smoke test)"
    

Group 17: Closeout

Task 48: Update Meta-Module Manifest

Files:

  • Modify: fusion_accounting/__manifest__.py

  • Steps: add 'fusion_accounting_bank_rec' to depends. Bump meta-module version. Commit.

    git commit -m "feat(fusion_accounting): meta-module now depends on fusion_accounting_bank_rec"
    

Task 49: Sub-Module Documentation

Files:

  • Create: fusion_accounting_bank_rec/CLAUDE.md

  • Create: fusion_accounting_bank_rec/UPGRADE_NOTES.md

  • Create: fusion_accounting_bank_rec/README.md

  • Steps: per workspace docs convention (similar shape to other sub-modules' CLAUDE.md/UPGRADE_NOTES.md/README.md). Commit.

    git commit -m "docs(fusion_accounting_bank_rec): CLAUDE.md, UPGRADE_NOTES.md, README.md"
    

Task 50: End-to-End Smoke Test on Local VM

  • Step 1: Clean redeploy (already auto via bind mount; just upgrade)

    orb -m odoo-westin-dev sudo docker exec westin-dev-app odoo \
      -d westin-v19 -u fusion_accounting --stop-after-init --http-port=8099 \
      -c /etc/odoo/odoo.conf 2>&1 | tail -10
    orb -m odoo-westin-dev sudo docker restart westin-dev-app
    
  • Step 2: Run full Phase 1 test suite

    orb -m odoo-westin-dev sudo docker exec westin-dev-app odoo \
      -d westin-v19 --test-tags post_install --stop-after-init --http-port=8099 \
      -c /etc/odoo/odoo.conf -u fusion_accounting_core,fusion_accounting_ai,fusion_accounting_migration,fusion_accounting_bank_rec \
      --log-handler=odoo.tests:INFO 2>&1 | grep -E 'tests.stats|tests.result|FAIL|ERROR:'
    

    Expected: all tests pass.

  • Step 3: Manual browser smoke: open http://odoo-westin-dev.orb.local:8069/web → log in → navigate to bank reconciliation widget (visible because we'll set fusion_accounting.migration.account_accountant.completed='True' in DB to bypass coexistence hide for this smoke). Click reconcile a line. Verify works.

  • Step 4: Tag commit

    cd /Users/gurpreet/Github/Odoo-Modules
    git tag -a fusion_accounting/phase-1-complete -m "Phase 1 Bank Reconciliation complete"
    git tag --list "fusion_accounting/*"
    

Task 51: Push to Remotes

  • Step 1: Push branch + tags

    cd /Users/gurpreet/Github/Odoo-Modules
    git push origin fusion_accounting/phase-1-bank-rec
    git push gitea fusion_accounting/phase-1-bank-rec
    git push origin fusion_accounting/pre-phase-1 fusion_accounting/phase-1-complete
    git push gitea fusion_accounting/pre-phase-1 fusion_accounting/phase-1-complete
    
  • Step 2: Use superpowers:finishing-a-development-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