# Phase 1 — Bank Reconciliation Design
**Status:** Design (drafted 2026-04-19, pending user approval before plan)
**Owner:** Nexa Systems Inc.
**Scope:** 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.
**Estimate:** ~5.5-6 weeks single-engineer.
---
## 1. Context and Goals
### 1.1 Where Phase 1 Fits
Phase 0 (complete, merged at `c450bb2`, range `fusion_accounting/pre-phase-0..fusion_accounting/phase-0-complete`) shipped:
- 4 sub-modules (`fusion_accounting`, `_core`, `_ai`, `_migration`)
- Data-adapter pattern with `BankRecAdapter` already stubbed at `fusion_accounting_ai/services/data_adapters/bank_rec.py` — `_via_fusion()` returns the community fallback
- Empirical verification that bank reconciliation data lives in Community `account.partial.reconcile` and survives Enterprise uninstall (16,500 rows on Westin's DB confirmed safe)
- Migration safety guard blocks Enterprise uninstall until per-module flag set
- Migration wizard skeleton with `_inherit`-extension point at `action_run_migration`
Phase 1 fills in the `_via_fusion()` paths of the bank-rec adapter with a real implementation.
### 1.2 What Phase 1 Delivers
A `fusion_accounting_bank_rec` sub-module providing:
- A V19-architecture OWL bank-rec widget (frontend-only, no `_auto=False` Python widget model — Enterprise removed that pattern in V19)
- A clean-room reconcile engine (Python, AbstractModel) that produces `account.partial.reconcile` rows byte-identical to what Enterprise produces
- Per-line AI confidence badges with one-click Accept and an expandable alternatives panel
- Behavioural learning: pattern aggregation per partner + nearest-neighbour precedent lookup, bootstrapped from existing 16,500 historical reconciliations
- Architecture ready for local LLM (Ollama, LM Studio, vLLM, llamafile) via the existing OpenAI-compatible adapter with configurable `base_url`
- Coexistence with Enterprise: when `account_accountant` is installed, fusion's menu hides; widget surfaces only after Enterprise uninstall
- Migration wizard step that bootstraps pattern memory and produces a downloadable audit report proving every reconciliation preserved
### 1.3 Non-Goals (deferred to later phases)
- Mexican CFDI electronic accounting (account_accountant Mexican branch)
- Cash-basis tax move generation (CABA flow)
- Batch payment matching (`account_batch_payment`)
- ISO 20022 statement attachment parsing
- Native Ollama adapter UI (model picker, registry pull)
- Local embedding model integration with pgvector (keyword tokenization is v1)
- Mobile-responsive widget below 768px viewport
- True auto-apply mode (cron applies high-confidence reconciles silently) — explicitly chose AI-assistive over AI-native in scoping
### 1.4 Locked Decisions from Brainstorming Session
| Dimension | Decision |
|---|---|
| Scope | CORE — manual + auto reconcile, write-offs, partial, multi-currency, chatter, model picker. Defers Mexican / cash-basis / batch-payment edge cases |
| UI mirror depth | Strict mirror — all 18 Enterprise OWL units replicated 1:1 (revised from initial "moderate" after user push-back) |
| AI integration depth | Assistive — per-line confidence badges, background batch-suggest cron, statistical-mode-without-API-key as first-class |
| AI badge layout | Hybrid: Layout A inline strip with one-click Accept/Reject + Layout B's ranked-alternatives panel as inline expansion |
| Coexistence | Enterprise wins by default (augment mode); fusion menu/widget hide when `account_accountant` detected |
| Performance target | Beat Enterprise: P95 widget open <1.5s @ 500 lines, <3s @ 5000 lines via materialized view + batched ORM |
| Test posture | TDD on engine algorithms + Hypothesis property-based tests for amount invariants + tour tests for UI smoke + migration round-trip integration test |
| LLM provider | Architecture-ready for local LLM via OpenAI-compatible adapter with configurable `base_url`; per-feature provider routing |
| Behavioural learning | Two-layer: `fusion.reconcile.pattern` (per-partner aggregate) + `fusion.reconcile.precedent` (per-historical-decision memory). Bootstrapped from existing reconciliations on first install |
| History preservation | Chatter, mail tracking, attachments, prior-reconciler audit fields all surface in fusion widget; `cron_last_check` field added to `fusion_accounting_core` shared-field-ownership |
---
## 2. Sub-Module Topology
### 2.1 Where `fusion_accounting_bank_rec` Slots In
```mermaid
graph TD
community["account
Odoo Community base"]
core["fusion_accounting_core
(security, shared schema, helpers)"]
bankrec["fusion_accounting_bank_rec
NEW (Phase 1)
reconcile engine + OWL widget"]
ai["fusion_accounting_ai
(BankRecAdapter._via_fusion fills in here)"]
migration["fusion_accounting_migration
(adds bank-rec migration step)"]
meta["fusion_accounting
(meta — depends on _bank_rec now too)"]
core --> community
bankrec --> core
ai --> core
migration --> core
ai -.adapter routes to.-> bankrec
migration -.registers migration step from.-> bankrec
meta --> bankrec
```
### 2.2 File Layout (canonical, per roadmap Section 5.5)
```
fusion_accounting_bank_rec/
├── __manifest__.py # depends: fusion_accounting_core; runtime-detect account_accountant
├── __init__.py
├── CLAUDE.md, UPGRADE_NOTES.md, README.md
├── docs/odoo_diff/v19/ # snapshots of Enterprise reference for V20 diffing
│ ├── account_accountant__bank_reconciliation_service.js
│ ├── account_accountant__account_reconcile_model.py
│ └── account_accountant__account_auto_reconcile_wizard.py
├── data/
│ ├── ir_cron.xml # AI batch-suggest cron (every 15 min); pattern refresh cron (nightly)
│ └── materialized_view.xml # SQL for fusion_unreconciled_per_partner_mv
├── migrations/
│ └── 19.0.1.0.0/
│ └── post-migration.py # Bootstrap pattern memory from existing reconciliations
├── models/
│ ├── __init__.py
│ ├── fusion_reconcile_engine.py # ABSTRACT — FIFO + write-off + exchange-diff orchestration
│ ├── fusion_reconcile_suggestion.py # MODEL — persisted AI suggestions
│ ├── fusion_reconcile_pattern.py # MODEL — per-partner aggregate profile
│ ├── fusion_reconcile_precedent.py # MODEL — per-historical-decision memory
│ ├── account_bank_statement_line.py # MIRROR/inherit — adds compute fields for badge state
│ ├── account_reconcile_model.py # MIRROR/inherit — AI integration hooks
│ └── fusion_bank_rec_widget.py # MIRROR — TransientModel for widget round-trip data
├── controllers/
│ ├── __init__.py
│ └── bank_rec_controller.py # JSON-RPC endpoints for OWL components
├── security/
│ ├── ir.model.access.csv
│ └── bank_rec_security.xml # ACLs + record rules
├── services/ # ABSTRACT — pure-Python helpers
│ ├── matching_strategies.py # AmountExact, FIFO, MultiInvoice strategies
│ ├── exchange_diff.py # FX gain/loss helpers
│ ├── confidence_scoring.py # 4-pass scoring pipeline
│ ├── pattern_extractor.py # Aggregates patterns from precedents
│ ├── precedent_lookup.py # K-nearest precedent search
│ └── memo_tokenizer.py # Canadian bank memo keyword extraction
├── static/
│ ├── description/
│ │ ├── icon.png
│ │ └── index.html
│ └── src/
│ └── components/bank_reconciliation/
│ ├── _bank_rec_tokens.scss # SCSS token partial (loaded first)
│ ├── bank_rec_widget.scss
│ ├── bank_reconciliation_service.js
│ ├── kanban_controller.js + .xml
│ ├── kanban_renderer.js + .xml
│ ├── apply_amount/ # mirror
│ ├── bankrec_form_dialog/ # mirror
│ ├── button/ # mirror
│ ├── button_list/ # mirror
│ ├── chatter/ # mirror
│ ├── file_uploader/ # mirror
│ ├── line_info_pop_over/ # mirror
│ ├── line_to_reconcile/ # mirror
│ ├── list_view/ # mirror (3 files)
│ ├── quick_create/ # mirror
│ ├── reconciled_line_name/ # mirror
│ ├── search_dialog/ # mirror (4 files)
│ ├── statement_line/ # mirror
│ ├── statement_summary/ # mirror
│ ├── ai_suggestion/ # NEW (fusion-only)
│ │ ├── ai_confidence_badge.js + .xml
│ │ ├── ai_suggestion_strip.js + .xml
│ │ └── ai_alternatives_panel.js + .xml
│ ├── batch_action_bar/ # NEW (fusion-only)
│ ├── attachment_strip/ # NEW (fusion-only)
│ ├── partner_history_panel/ # NEW (fusion-only)
│ └── reconcile_model_picker/ # NEW (fusion-only)
├── 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_tour.js
│ ├── ai_assist_accept_tour.js
│ ├── batch_reconcile_tour.js
│ ├── partial_reconcile_tour.js
│ └── write_off_tour.js
├── views/
│ ├── bank_rec_widget_views.xml
│ ├── account_journal_views.xml # adds "Reconcile (Fusion)" button
│ ├── account_reconcile_model_views.xml
│ └── menus.xml
└── wizards/
├── __init__.py
├── auto_reconcile_wizard.py # clean-room rewrite of Enterprise auto-reconcile
├── auto_reconcile_wizard.xml
├── reconcile_wizard.py # bulk action: reconcile selected lines
├── reconcile_wizard.xml
└── migration_wizard_inherit.py # extends fusion.migration.wizard.action_run_migration
```
### 2.3 Additions to Existing Sub-Modules
| Sub-module | Phase 1 addition |
|---|---|
| `fusion_accounting_core/models/account_bank_statement_line.py` | Add `cron_last_check = fields.Datetime()` to existing inherit (shared-field-ownership for Enterprise's auto-reconcile timestamp) |
| `fusion_accounting_core/models/res_users.py` | NEW — computed group `group_fusion_show_when_enterprise_absent` for menu coexistence |
| `fusion_accounting_ai/services/data_adapters/bank_rec.py` | Fill `list_unreconciled_via_fusion()`; add `get_ai_suggestions`, `accept_suggestion`, `compute_partner_history_score` |
| `fusion_accounting_ai/services/tools/bank_reconciliation.py` | Add tools: `get_pending_suggestions`, `explain_suggestion`, `accept_suggestion_via_chat`, `reject_suggestion_via_chat`, `batch_accept_high_confidence` |
| `fusion_accounting_ai/services/prompts/` | NEW file: `bank_rec_prompt.py` with the suggestion-ranking prompt |
| `fusion_accounting_ai/services/adapters/_base.py` | NEW — `LLMProvider` contract base class |
| `fusion_accounting_ai/services/adapters/openai_adapter.py` | Generalise to accept configurable `base_url` from `ir.config_parameter` |
| `fusion_accounting/__manifest__.py` | Add `'fusion_accounting_bank_rec'` to `depends` |
---
## 3. Reconcile Engine Architecture
### 3.1 Engine Module Layout
```
fusion_accounting_bank_rec/models/
├── fusion_reconcile_engine.py # AbstractModel — orchestrator
├── fusion_reconcile_suggestion.py # Model — persisted AI suggestions
├── fusion_reconcile_pattern.py # Model — per-partner aggregate
├── fusion_reconcile_precedent.py # Model — per-decision memory
├── fusion_bank_rec_widget.py # TransientModel — per-request widget state
└── account_bank_statement_line.py # Inherit — adds compute fields for badge state
fusion_accounting_bank_rec/services/ # Pure-Python helpers, no ORM dependency
├── matching_strategies.py
├── exchange_diff.py
├── confidence_scoring.py
├── pattern_extractor.py
├── precedent_lookup.py
└── memo_tokenizer.py
```
`models/fusion_reconcile_engine.py` is `models.AbstractModel` so it inherits Odoo conventions (env, security, transactions). Helpers in `services/` are pure Python — directly unit-testable without an Odoo DB, supporting the TDD-on-engine discipline.
### 3.2 Public API Surface (six methods)
```python
class FusionReconcileEngine(models.AbstractModel):
_name = "fusion.reconcile.engine"
def reconcile_one(self, statement_line, *, against_lines, write_off_vals=None):
"""Reconcile a single bank line against given journal items.
Returns: {partial_ids, exchange_diff_move_id, write_off_move_id}"""
def reconcile_batch(self, statement_lines, *, strategy='auto'):
"""Bulk-reconcile a recordset using the chosen strategy.
Returns: {reconciled_count, skipped, errors}"""
def suggest_matches(self, statement_lines, *, limit_per_line=3):
"""Compute AI-ranked candidate matches per line.
Returns: {statement_line_id: [{move_line_ids, confidence, reasoning}, ...]}"""
def accept_suggestion(self, suggestion):
"""User clicked Accept on a suggestion → invoke reconcile_one with its proposal."""
def write_off(self, statement_line, *, account, amount, tax_id=None, label):
"""Create write-off line + post → invoke reconcile_one with the new line."""
def unreconcile(self, partial_reconciles):
"""Reverse a reconciliation. Handles full vs. partial chains."""
```
Every OWL component, AI tool, and wizard goes through one of these six methods. No direct ORM writes from the frontend or the wizards.
### 3.3 Internal Pipeline
Every reconcile request flows through five phases:
```mermaid
graph LR
in["1. Request
(statement_line + targets)"]
val["2. Validate
amounts balance, currency consistent,
partner allowed, period not locked"]
diff["3. Compute deltas
residual, exchange diff,
tax recomputation if write-off"]
write["4. Write
account.partial.reconcile rows
+ optional exchange-diff move
+ optional write-off move"]
audit["5. Audit
log to mail.message
+ trigger AI scoring update
+ refresh materialized view"]
in --> val --> diff --> write --> audit
```
Steps 2-3 are pure functions in `services/`. Step 4 is the only ORM-write step. Step 5 is fire-and-forget.
### 3.4 Algorithm Choices
**Matching strategy `auto`-mode order:**
1. Pass 1: amount-exact-first (single open item where `amount_residual == bank_line.amount` to within currency rounding)
2. Pass 2: smallest set of open items (≤3) summing to bank amount
3. Pass 3: FIFO oldest-due-first, partial-reconcile until exhausted
4. Pass 4: leave for AI suggestion or manual
This order reflects what Canadian bookkeepers do mentally (~70% exact-match dominance per Westin's ledger). FIFO is the CRA-expected fallback.
**Exchange difference:** delegate to Odoo's existing `account.move._create_exchange_difference_move(partial_ids)`. Don't reinvent FX math; respect company-currency vs. journal-currency rates and posted gain/loss accounts on `res.currency`.
**Write-off taxes:** set `tax_ids` on the new line; let `account.move.line._compute_tax_amount` do the math. Don't compute tax independently.
**Partial reconcile:** first-class case, not a special case. `reconcile_one` always produces `account.partial.reconcile` rows; full-reconcile coalescence checked via Odoo's existing `_create_or_update_full_reconcile`.
### 3.5 AI Integration Hooks
The engine exposes exactly two AI hooks; AI never writes `account.partial.reconcile` directly.
| Hook | When it fires | What it does |
|---|---|---|
| `suggest_matches()` | (a) cron every 15 min; (b) widget-open lazy fill; (c) user "Show alternatives" | Calls `services/confidence_scoring.py`, writes `fusion.reconcile.suggestion` rows |
| `accept_suggestion()` | User clicks Accept | Records feedback, then calls `reconcile_one` with the suggestion's proposal |
If AI hallucinates a wrong match, worst case is a wrong suggestion in the UI — never a wrong reconciliation in the books.
### 3.6 Behavioural Pattern Learning
Two layers:
**Layer 1 — `fusion.reconcile.pattern`** (one row per `(company_id, partner_id)`):
```python
class FusionReconcilePattern(models.Model):
_name = "fusion.reconcile.pattern"
company_id = fields.Many2one('res.company', required=True)
partner_id = fields.Many2one('res.partner', required=True)
reconcile_count = fields.Integer()
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()
```
Refreshed nightly via cron from new precedents.
**Layer 2 — `fusion.reconcile.precedent`** (one row per historical reconcile):
```python
class FusionReconcilePrecedent(models.Model):
_name = "fusion.reconcile.precedent"
company_id = fields.Many2one('res.company', required=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()
journal_id = fields.Many2one('account.journal')
matched_move_line_count = fields.Integer()
matched_account_ids = fields.Char()
matched_invoice_ages_days = fields.Char()
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'),
])
```
Used for K-nearest neighbour lookup when scoring new lines.
**Bootstrap on install:** scan `account.partial.reconcile` rows in batches of 1000, write precedents, aggregate patterns. ~30-60s for Westin's 16,500 reconciliations. Idempotent via `source='historical_bootstrap'` dedupe.
### 3.7 Performance
- **Suggestion generation** runs in cron, not in request context. OWL widget reads pre-computed `fusion.reconcile.suggestion` rows. Sub-300ms render even for 500 unreconciled lines.
- **Partner-unreconciled counts** come from a materialized view `fusion_unreconciled_per_partner_mv`, refreshed on `account.move` post via overridden `_post`. Constant-time lookup vs. Enterprise's per-record compute (the main culprit in Westin's slow widget loads).
- **`reconcile_batch`** uses chunked ORM (50 lines per chunk, commit between) to avoid lock contention.
### 3.8 Local LLM Readiness
**Provider contract** (`fusion_accounting_ai/services/adapters/_base.py`):
```python
class LLMProvider:
supports_tool_calling: bool
supports_streaming: bool
max_context_tokens: int
supports_embeddings: bool
def complete(self, *, system, messages, max_tokens, temperature) -> dict:
"""Plain text completion. Required for ALL providers."""
def complete_with_tools(self, *, system, messages, tools, max_tokens) -> dict:
"""Optional. Engine checks supports_tool_calling first."""
def embed(self, texts: list[str]) -> list[list[float]]:
"""Optional. Engine falls back to keyword tokenization if absent."""
```
**OpenAI-compatible adapter generalisation** — single change:
```python
# fusion_accounting_ai/services/adapters/openai_adapter.py
class OpenAIAdapter(LLMProvider):
def __init__(self, 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.client = openai.OpenAI(api_key=api_key, base_url=base_url)
```
Set `base_url` to `http://host.docker.internal:1234/v1` (LM Studio) or `http://host.docker.internal:11434/v1` (Ollama) — local LLM works through the existing adapter.
**Per-feature provider routing** via `ir.config_parameter`:
```
fusion_accounting.provider.chat = claude_sonnet_4_5
fusion_accounting.provider.bank_rec_suggest = openai_compatible
fusion_accounting.provider.tier3_review = claude_sonnet_4_5
fusion_accounting.provider.embeddings = openai_compatible
```
**Engine guards:** treats AI as an optional re-ranker on top of a deterministic statistical baseline. No tool-calling support? Engine uses `complete()` only and parses JSON. Context too small? Truncates precedent batch to fit budget. Provider unreachable? Falls back to statistical-only, logs warning.
### 3.9 Architectural Invariants
These commitments must not be broken by Phase 1 implementation or future phases:
1. **No model-specific code in engines.** Engine talks to `LLMProvider` interface, never `import anthropic` directly.
2. **Deterministic statistical baseline.** Every AI feature has a non-AI fallback that produces useful output.
3. **Capability negotiation, not assumption.** Code never assumes tool-calling, never assumes 200k context, never assumes streaming.
4. **Per-feature, per-company provider routing.** Multi-company installs can route Company A through Claude, Company B through local Llama.
5. **Privacy-by-default feature vectors.** AI calls send extracted features (amount, partner ID, memo tokens, candidate IDs), never raw transaction descriptions.
---
## 4. UI Components & Layout
### 4.1 Component Inventory (23 total — 18 mirrored + 5 fusion-new)
**Top-level units (Enterprise-mirrored):**
| Unit | Type | Responsibility |
|---|---|---|
| `bank_reconciliation_service.js` | service | Frontend state hub; ORM helpers; chatter state; reconcile-model availability |
| `bank_rec_widget.scss` | styles | All widget styling, light + dark mode via SCSS branch |
| `kanban_controller.js + .xml` | component | Widget root; orchestrates summary, grid, dialog, batch bar |
| `kanban_renderer.js + .xml` | component | Renders the grid of statement lines |
**Sub-components (Enterprise-mirrored, 14 folders):**
`statement_summary`, `statement_line`, `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`.
**Fusion-only additions (5 folders):**
| Folder | Responsibility |
|---|---|
| `ai_suggestion/` | Confidence badge + inline strip + alternatives panel (3 files) |
| `batch_action_bar/` | Bottom bar: "Accept all 47 high-confidence (≥95%)" |
| `attachment_strip/` | Per-line attachment thumbnails (renders preserved `bank_statement_attachment_ids`) |
| `partner_history_panel/` | Slide-out: "Sarah has 47 prior reconciles for this partner" |
| `reconcile_model_picker/` | Per-line dropdown to apply an `account.reconcile.model` rule |
### 4.2 Component Nesting
```mermaid
graph TD
KC["kanban_controller
(widget root)"]
KC --> SS["statement_summary
(top totals)"]
KC --> KR["kanban_renderer
(grid)"]
KC --> BAB["batch_action_bar
(bottom)"]
KC --> BFD["bankrec_form_dialog
(modal — manual fallback)"]
KC --> CHT["chatter
(per-line history panel)"]
KR --> SL["statement_line
(per-line card)"]
KR --> QC["quick_create
(per-line action)"]
SL --> ACB["ai_confidence_badge"]
SL --> ASS["ai_suggestion_strip"]
SL --> AAP["ai_alternatives_panel"]
SL --> LIP["line_info_pop_over"]
SL --> RLN["reconciled_line_name"]
SL --> RMP["reconcile_model_picker"]
SL --> BL["button_list"]
SL --> AS["attachment_strip"]
BFD --> LTR["line_to_reconcile"]
BFD --> LV["list_view"]
BFD --> SD["search_dialog"]
BFD --> FU["file_uploader"]
BFD --> BTN["button"]
LV --> AA["apply_amount"]
```
### 4.3 Data Contract — Controllers and Endpoints
Every UI action goes through `controllers/bank_rec_controller.py`, which calls one of the engine's six public methods.
| Route | Verb | Engine method | Used by component |
|---|---|---|---|
| `/fusion_bank_rec/load_kanban` | GET | (read-only ORM) | kanban_controller on mount |
| `/fusion_bank_rec/reconcile` | POST | `reconcile_one()` | bankrec_form_dialog on confirm |
| `/fusion_bank_rec/reconcile_batch` | POST | `reconcile_batch()` | batch_action_bar on click |
| `/fusion_bank_rec/accept_suggestion` | POST | `accept_suggestion()` | ai_suggestion_strip Accept |
| `/fusion_bank_rec/reject_suggestion` | POST | (writes to suggestion model) | ai_suggestion_strip Reject |
| `/fusion_bank_rec/refresh_suggestions` | POST | `suggest_matches()` | ai_alternatives_panel on expand |
| `/fusion_bank_rec/write_off` | POST | `write_off()` | bankrec_form_dialog write-off branch |
| `/fusion_bank_rec/unreconcile` | POST | `unreconcile()` | reconciled_line_name on click |
| `/fusion_bank_rec/partner_history` | GET | (read-only) | partner_history_panel on partner select |
| `/fusion_bank_rec/upload_attachment` | POST | (writes ir.attachment) | file_uploader on drop |
All routes use `type='jsonrpc'` (`type='json'` deprecated in V19). All write-routes have `auth='user'` + imperative `has_group()` check inside the handler.
### 4.4 SCSS Discipline
```scss
// _bank_rec_tokens.scss — loaded first per workspace SCSS rule (no @import)
$o-webclient-color-scheme: bright !default;
$_page-hex: #f3f4f6;
$_card-hex: #ffffff;
$_border-hex: #d8dadd;
$_match-hex: #22c55e;
$_partial-hex: #f59e0b;
$_nomatch-hex: #94a3b8;
@if $o-webclient-color-scheme == dark {
$_page-hex: #1a1d21 !global;
$_card-hex: #22262d !global;
$_border-hex: #3a3f47 !global;
}
$br-page: var(--br-page-bg, $_page-hex);
$br-card: var(--br-card-bg, $_card-hex);
$br-border: var(--br-border, $_border-hex);
$br-match: var(--br-match, $_match-hex);
$br-partial: var(--br-partial, $_partial-hex);
$br-nomatch: var(--br-nomatch, $_nomatch-hex);
```
All component SCSS references only `$br-*` tokens. No hardcoded hex outside `_bank_rec_tokens.scss`. Asset bundle declares tokens file FIRST (per workspace rule that `@import` is forbidden in Odoo 19).
### 4.5 OWL Conventions
- `static props = ["*"]` — never `static props = []`
- Rich-HTML rendering (markdown reasoning, partner-history HTML): `onMounted` + `onPatched` + direct `innerHTML`, NOT `markup()`/`t-out`
- Component class names: `Fusion` prefix (`FusionStatementLine`, `FusionAISuggestionStrip`)
- File pairs: `.js` + `.xml`
### 4.6 Coexistence Mode
Menu and views wrapped in `groups` filter referencing dynamically-computed group:
```xml
```
`group_fusion_show_when_enterprise_absent` is a computed group whose membership is "all users when `ir.module.module._fusion_is_enterprise_accounting_installed()` returns False." Implemented in `fusion_accounting_core/models/res_users.py` Phase 1 addition.
---
## 5. AI Integration
### 5.1 Suggestion Record Schema
```python
class FusionReconcileSuggestion(models.Model):
_name = "fusion.reconcile.suggestion"
_order = "statement_line_id, confidence desc"
company_id = fields.Many2one('res.company', required=True, index=True)
statement_line_id = fields.Many2one('account.bank.statement.line',
required=True, index=True, ondelete='cascade')
proposed_move_line_ids = fields.Many2many('account.move.line',
string="Proposed matches")
proposed_write_off_amount = fields.Monetary()
proposed_write_off_account_id = fields.Many2one('account.account')
confidence = fields.Float(required=True)
confidence_band = fields.Selection([
('high', 'High (≥95%)'),
('medium', 'Medium (70-94%)'),
('low', 'Low (50-69%)'),
('none', 'No confidence (<50%)'),
], compute='_compute_band', store=True)
rank = fields.Integer()
reasoning = fields.Text()
score_amount_match = fields.Float()
score_partner_pattern = fields.Float()
score_precedent_similarity = fields.Float()
score_ai_rerank = fields.Float()
generated_at = fields.Datetime(default=fields.Datetime.now)
generated_by = fields.Selection([
('cron_batch', 'Batch cron'),
('on_demand', 'User refreshed alternatives'),
('on_open', 'Widget opened (lazy)'),
])
provider_used = fields.Char()
tokens_used = fields.Integer()
generation_ms = fields.Integer()
state = fields.Selection([
('pending', 'Pending review'),
('accepted', 'Accepted'),
('rejected', 'Rejected'),
('superseded', 'Superseded by newer suggestion'),
('stale', 'Stale (line changed since)'),
], default='pending', index=True)
accepted_at = fields.Datetime()
accepted_by = fields.Many2one('res.users')
rejected_at = fields.Datetime()
rejected_reason = fields.Selection([
('wrong_invoice', 'Wrong invoice'),
('wrong_partner', 'Wrong partner'),
('wrong_amount', 'Amount off'),
('not_a_match', 'No good match exists'),
('other', 'Other'),
])
```
### 5.2 Suggestion Generation Flow
```mermaid
graph TD
trigger[Trigger]
trigger --> cron["Cron batch (every 15 min)"]
trigger --> open["Widget open (lazy fill)"]
trigger --> demand["User clicks Show alternatives"]
cron --> work["Pick unreconciled lines
without fresh suggestions
(LIMIT 200 per run)"]
open --> work
demand --> work
work --> p1["Pass 1: SQL filter
partner match + reconcilable account"]
p1 --> p2["Pass 2: Statistical scoring
(amount + pattern + precedent features)"]
p2 --> top5["Take top 5 candidates per line"]
top5 --> aikey{AI key configured?}
aikey -->|yes| p3["Pass 3: AI re-rank
send features + candidates
get ranking + reasoning"]
aikey -->|no| stat["Use statistical reasoning"]
p3 --> write
stat --> write
write["Write fusion.reconcile.suggestion rows
(supersede prior pending)"]
write --> badge["Frontend reads on next render
→ confidence badge appears"]
```
### 5.3 Domain Prompt
`fusion_accounting_ai/services/prompts/bank_rec_prompt.py` builds the suggestion-ranking prompt. Same pattern as existing domain prompts.
**System prompt (sent once per request):**
```
You are a bank reconciliation assistant for a Canadian accounting team.
Your task: rank candidate matches for a bank statement line and explain
each ranking in one sentence.
Output format: JSON only, no prose. Schema:
{
"ranked": [
{"candidate_id": , "confidence": <0.0-1.0>, "reason": ""},
...
]
}
Rules:
- Confidence >0.95 only when amount + partner + memo all align perfectly
- Confidence 0.70-0.94 when 2 of 3 align
- Confidence <0.70 when only one aligns or evidence is conflicting
- If a candidate is clearly wrong, omit it
- Reason must cite specific evidence (amount, prior reconciles, memo tokens)
- Currency: Canadian English. Amounts in $X,XXX.XX format.
```
**User prompt** assembles bank line features + partner pattern + 3 nearest precedents + top-5 statistical candidates. Token budget bounded by `fusion_accounting.bank_rec.suggest_token_budget_per_line` (default 1500).
**Key prompt design choices:**
- JSON output, not tool-calling — works on local models that lack reliable tool-calling
- Feature vector only, no raw transaction descriptions — privacy + token efficiency
- Pre-filtered candidates — statistical pass narrows to top 5 before LLM sees anything
- Precedents inline as concrete evidence, not abstract rules
### 5.4 Per-Feature Provider Defaults
| Setting | Default | Rationale |
|---|---|---|
| `fusion_accounting.provider.bank_rec_suggest` | (global default) | Statistical baseline always works; AI re-rank is bonus |
| `fusion_accounting.provider.bank_rec_explain` | (global default) | Used for chat-panel "why?" queries |
| `fusion_accounting.bank_rec.suggest_temperature` | `0.0` | Deterministic ranking |
| `fusion_accounting.bank_rec.suggest_max_tokens` | `800` | Bounded output for 5 candidates × ~150 tokens |
| `fusion_accounting.bank_rec.suggest_token_budget_per_line` | `1500` | Truncates precedent context if would exceed |
### 5.5 Auto-Accept Threshold (the safety question)
Per the assistive-not-native scope decision: **AI never auto-applies a reconciliation.** What looks like "auto" is:
1. Cron generates suggestions overnight (writes pending suggestions)
2. User opens widget, sees N high-confidence (≥95%) suggestions
3. User clicks "Accept all N high-confidence" in `batch_action_bar`
4. Batch processed via `engine.reconcile_batch()` — same audit trail, just bulk-clicked
Threshold (`0.95`) is configurable via `fusion_accounting.bank_rec.high_confidence_threshold`. User owns the decision; no silent reconciles in the books.
### 5.6 Feedback Loop
| User action | Suggestion record | Precedent record | Pattern impact |
|---|---|---|---|
| Click Accept on AI suggestion | `state='accepted'`, `accepted_by`, `accepted_at` | New, `source='ai_accepted'` | Reinforces partner's pref_strategy |
| Click Reject with reason | `state='rejected'`, `rejected_reason` | (none) | Pattern weighting lowered if multiple rejects show same wrong-strategy |
| Manually reconcile against different items | Existing `state='superseded'` | New, `source='manual'` | Updated normally |
| Manually reconcile after refusing AI | Suggestion `state='rejected'` (auto), `rejected_reason='other'` | New, `source='manual'` | Pattern shifts toward what user picked |
Pattern refresh cron (nightly) reads accepted/rejected/superseded suggestions and adjusts `fusion.reconcile.pattern` weights via exponential moving average (90-day half-life, configurable).
### 5.7 Integration with Existing Phase 0 AI Tools
**New tools** added to `fusion_accounting_ai/services/tools/bank_reconciliation.py`:
- `get_pending_suggestions(journal_id, limit=100)` — returns current pending suggestions
- `explain_suggestion(suggestion_id)` — expands reasoning with precedent details
- `accept_suggestion_via_chat(suggestion_id)` — Tier 3 action (requires approval card)
- `reject_suggestion_via_chat(suggestion_id, reason)` — Tier 2 action; logged
- `batch_accept_high_confidence(journal_id, threshold=0.95)` — Tier 3 action
**Existing tools refactored** to use the engine:
- `get_unreconciled_bank_lines` — reads from materialized view, includes confidence badges
- `find_similar_bank_lines` — uses precedent lookup (much better matches than original SQL)
- `find_missing_itc_bills` — pulls AI reasoning for HST suggestions
The chat AI can now have conversations like: *"Look at the RBC unreconciled lines. Anything obvious?" → "There are 47 lines with ≥95% confidence ready, including 12 from Westin Plating Co matching their typical $1,847.50 cadence. Want to walk through any, or batch-accept all 47?"*
### 5.8 Cost / Token Budget Controls
| Knob | Default | Purpose |
|---|---|---|
| `fusion_accounting.bank_rec.cron_max_lines_per_run` | `200` | Per-cron-tick cap |
| `fusion_accounting.bank_rec.suggest_token_budget_per_line` | `1500` | Per-prompt cap including precedent context |
| `fusion_accounting.bank_rec.daily_token_budget_per_company` | `100000` | Hard daily cap; falls back to statistical when exceeded |
| `fusion_accounting.bank_rec.suggest_min_confidence_to_persist` | `0.50` | Skip writing suggestion if statistical baseline < 50% |
Token usage logged per-suggestion (`tokens_used` field) for cost auditing via SQL.
---
## 6. Migration & Switchover Story
### 6.1 Phase 1 Contribution to Migration Wizard
Phase 1 fills the wizard's `action_run_migration` extension point with a five-step bank-rec migration:
```mermaid
graph TD
s1["Step 1: Snapshot pre-migration counts
account.partial.reconcile, account.full.reconcile,
mail.message linked to reconciles, ir.attachment"]
s2["Step 2: Bootstrap fusion.reconcile.precedent
(scan 16,500 historical reconciles, batched 1000/commit)"]
s3["Step 3: Aggregate fusion.reconcile.pattern per partner"]
s4["Step 4: Verify counts unchanged
(re-snapshot, assert equality)"]
s5["Step 5: Set fusion_accounting.migration.account_accountant.completed = True
(safety guard now permits Enterprise uninstall)"]
s6["Step 6: Generate audit report PDF
(saved as ir.attachment, downloadable from wizard)"]
s1 --> s2 --> s3 --> s4 --> s5 --> s6
```
Bootstrap timing on Westin's 16,500 reconciles: ~30-60 seconds. Steps run inside `cr.savepoint()` per batch — failure mid-batch doesn't corrupt state; next click resumes (idempotent via `source='historical_bootstrap'` check).
### 6.2 Audit Report Contents
Generated as PDF + JSON `ir.attachment` linked to wizard. Sections:
**Summary:**
- Migration timestamp, operator user, company
- Pre/post counts side-by-side: `account.partial.reconcile`, `account.full.reconcile`, `mail.message`-on-reconciles, `ir.attachment`-on-bank-statements
- Verdict: ✓ identical / ✗ mismatch (with delta)
**Pattern bootstrap:**
- Total precedents created (e.g., 16,500)
- Patterns aggregated (e.g., 412 unique partners)
- Per-partner snapshot table (top 20 by reconcile volume)
- Anomalies flagged: partners with conflicting historical patterns, broken xml-id links
**Sample reconciliation deep-dive:**
- 10 randomly-sampled historical reconciliations
- For each: bank line summary, matched journal items, who reconciled, when, full chatter dump, attachments listed
- Same 10 lines re-fetched after Enterprise uninstall → assert byte-identical
The report is the operator's *proof of preservation* — handoff artifact for auditor or client.
### 6.3 Flag Mechanics
Safety guard (Phase 0 Task 17) blocks Enterprise uninstall until `fusion_accounting.migration.account_accountant.completed = 'True'`. Phase 1 sets that flag in Step 5 only after Step 4 verification passes.
If verification fails (count mismatch — defensively impossible since data is Community-owned, but checked anyway):
- Flag NOT set
- Audit report generated marked `verdict: FAIL`
- Wizard surfaces UserError linking to support
- Safety guard remains active
### 6.4 Full Switchover Sequence
```mermaid
graph TD
pre["Pre-switchover
Client running Odoo 19 Enterprise"]
step1["1. Take pg_dump backup"]
step2["2. Install fusion_accounting meta-module"]
step3["3. Verify install
HTTP /web/login → 200"]
step4["4. Coexistence detected
fusion_bank_rec menu HIDDEN
Enterprise widget keeps working"]
step5["5. Open Settings → Fusion Accounting → Migrate from Enterprise
Click 'Run Migration'
Wait ~30-60s for bootstrap"]
step6["6. Review audit report PDF"]
step7["7. Uninstall account_accountant
(safety guard checks flag, allows uninstall)"]
step8["8. Coexistence re-detects
fusion_bank_rec menu APPEARS"]
step9["9. Spot-check: open 5 historical reconciles
Verify chatter visible, attachments, all marked reconciled"]
step10["10. Hand audit report to client/auditor"]
pre --> step1 --> step2 --> step3 --> step4 --> step5 --> step6 --> step7 --> step8 --> step9 --> step10
```
Total switchover wall-clock: ~30-60 minutes (excluding pg_dump). Most of that is human verification.
### 6.5 Rollback Scenarios
**Scenario A: Operator regrets after Enterprise uninstall**
- Restore pg_dump from Step 1
- Restore previous extra-addons checkout
- Restart Odoo
- Wall-clock: ~10-30 min
**Scenario B: Step 5 fails mid-bootstrap**
- Bootstrap is batched + idempotent
- Re-clicking "Run Migration" resumes
- No partial-state corruption possible
**Scenario C: Step 7 fails (safety guard blocks)**
- Migration not run yet → wizard error tells operator to run it
- Migration ran but Step 4 verification failed → audit report explains; do not proceed
- Operator wants to force-uninstall → set flag manually via Developer Mode → Settings → Technical → System Parameters
**Scenario D: Step 8 fails (fusion menu doesn't appear)**
- Coexistence detection re-evaluates user groups on session refresh, not instantly
- Operator logs out + back in → menu appears
### 6.6 Edge Cases
**Multi-company:**
- Migration is per-company; per-company completion flag (`fusion_accounting.migration.account_accountant.completed.`)
- Safety guard checks all-companies-completed before allowing Enterprise uninstall
- Audit report shows per-company sections
**Live reconciles during migration:**
- Wizard takes brief advisory lock on `account.partial.reconcile` during snapshot steps
- Bootstrap step doesn't lock — runs against snapshot view
- New Enterprise reconciles during bootstrap are outside scope; next pattern refresh cron picks them up
**Re-running migration:**
- Safe; bootstrap dedupe-checks via existing precedent rows (`source='historical_bootstrap'`)
- Useful for re-bootstrap after manual data fixes
### 6.7 Inputs from Other Sub-Modules
| Source | Needed | Status |
|---|---|---|
| `fusion_accounting_core/models/account_bank_statement_line.py` | Shared-field-ownership for `cron_last_check` | Phase 1 adds (1-line addition) |
| `fusion_accounting_core/models/res_users.py` | Computed group `group_fusion_show_when_enterprise_absent` | Phase 1 NEW file |
| `fusion_accounting_migration` wizard | `action_run_migration` extension point | Already exists (Phase 0 Task 17) |
| `fusion_accounting_ai` | `BankRecAdapter._via_fusion` paths | Already stubbed (Phase 0 Task 9), Phase 1 fills |
No new sub-modules needed; Phase 1's migration is entirely additive.
---
## 7. Testing Strategy
### 7.1 Test File Layout
```
tests/
├── _fixtures/ # SQL-captured Enterprise reconcile outcomes
│ ├── westin_simple_match.sql
│ ├── westin_partial_chain.sql
│ ├── westin_exchange_diff_usd_cad.sql
│ ├── westin_writeoff_with_hst.sql
│ └── westin_unreconcile.sql
├── _factories.py # Test data builders
├── test_reconcile_engine_unit.py # Pure algorithm tests
├── test_reconcile_engine_property.py # Hypothesis property-based
├── test_reconcile_engine_integration.py # Full-stack ORM tests
├── 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_tour.js
├── ai_assist_accept_tour.js
├── batch_reconcile_tour.js
├── partial_reconcile_tour.js
└── write_off_tour.js
```
~150 tests total (~115 Python + ~35 user flows in 5 tours).
### 7.2 TDD Discipline
Every engine algorithm gets a failing test first, then implementation, then green. Order matters: tests written by dependency layer.
**Layer 1: Pure validators (no ORM).** Tests written first, fail initially.
**Layer 2: Matching strategy classes.** Built on Layer 1.
**Layer 3: Engine orchestration.** Uses Layers 1-2.
### 7.3 Property-Based Tests for Amount Invariants
Hypothesis library generates thousands of input combinations. Specific invariants:
```python
@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))
def test_invariant_total_debits_equal_total_credits(bank_amount, invoice_amounts):
"""Every reconciliation MUST produce balanced debit/credit totals."""
@given(amounts=st.lists(st.decimals(places=2), min_size=2, max_size=20))
def test_invariant_partial_reconciles_sum_to_full(amounts):
"""Sum of all account.partial.reconcile rows must equal the line residual."""
@given(rate_invoice=st.decimals(min_value='0.5', max_value='2.0'),
rate_reconcile=st.decimals(min_value='0.5', max_value='2.0'))
def test_invariant_exchange_diff_balances_books(rate_invoice, rate_reconcile):
"""Cross-currency reconciles produce balanced exchange-diff move."""
```
Hypothesis runs each test 100 times with shrinking — finds failures by minimising input.
### 7.4 Integration Tests — Real Odoo, Real Data
5 SQL-captured fixtures from real Westin reconciliations. Each tests `fusion.reconcile.engine.reconcile_one()` produces byte-identical `account.partial.reconcile` rows to what Enterprise produced.
```python
@classmethod
def setUpClass(cls):
super().setUpClass()
cls._load_fixture('westin_simple_match.sql')
def test_replay_simple_match(self):
line = self.env.ref('test_fixture.westin_bank_line_4827')
candidates = self.env.ref('test_fixture.westin_invoice_00118').line_ids
expected_partial = self.env.ref('test_fixture.westin_partial_42891')
self.env['account.partial.reconcile'].browse(expected_partial.id).unlink()
result = self.env['fusion.reconcile.engine'].reconcile_one(line, against_lines=candidates)
new_partial = self.env['account.partial.reconcile'].browse(result['partial_ids'])
for fname in ('amount', 'debit_move_id', 'credit_move_id', 'company_id', 'currency_id'):
assert new_partial[fname] == expected_partial[fname]
```
Coverage: simple, partial chain, exchange diff USD/CAD, write-off with HST, unreconcile reversal.
### 7.5 Tour Tests
5 OWL tours, one per major user flow. Slow (~30s each) but catches JS regressions no unit test would.
| Tour | Flow validated |
|---|---|
| `manual_reconcile_tour.js` | Open widget → search invoice → click Reconcile |
| `ai_assist_accept_tour.js` | See badge → click Accept on strip |
| `batch_reconcile_tour.js` | Click "Accept all 47 high-confidence" |
| `partial_reconcile_tour.js` | Pick 2 invoices summing to less than line → assert partial residual |
| `write_off_tour.js` | $0.05 write-off with HST → assert move with tax split posted |
### 7.6 Performance Benchmarks
Per Q5 target: P95 <1.5s @ 500 lines, <3s @ 5000 lines.
```python
def test_kanban_load_500_lines_under_1500ms(env, benchmark):
seed_unreconciled_lines(env, count=500, journal=env.ref('test.bank_journal_rbc'))
result = benchmark(env['fusion.bank.rec.widget'].action_open_kanban,
journal_id=env.ref('test.bank_journal_rbc').id)
assert benchmark.stats.stats.percentiles[95] < 1.5
def test_suggest_matches_uses_materialized_view(env):
"""Regression guard: must hit materialized view, not per-record compute."""
seed_lines(env, count=100)
with env.cr.queries_recorded() as queries:
env['fusion.reconcile.engine'].suggest_matches(env['account.bank.statement.line'].search([], limit=100))
mv_hits = [q for q in queries if 'fusion_unreconciled_per_partner_mv' in q.sql]
assert len(mv_hits) >= 1
```
Performance regressions become test failures.
### 7.7 Migration Round-Trip Test
The single most important integration test — codifies the seamlessness guarantee.
```python
@tagged('post_install', '-at_install', 'migration_round_trip')
class TestMigrationRoundTrip(TransactionCase):
def test_full_round_trip_preserves_every_reconciliation(self):
# Step 1: with Enterprise installed, create representative dataset
self._install_enterprise_modules()
bank_lines = self._create_bank_lines(count=50)
invoices = self._create_invoices(count=30)
self._enterprise_reconcile(bank_lines, invoices)
# Step 2: snapshot
before = self._snapshot_reconcile_state()
assert before['partial_count'] > 0
# Step 3: install fusion + run migration
self._install_module('fusion_accounting_bank_rec')
wizard = self.env['fusion.migration.wizard'].create({})
wizard.action_run_migration()
# Step 4: assert flag set
flag = self.env['ir.config_parameter'].get_param(
'fusion_accounting.migration.account_accountant.completed')
assert flag == 'True'
# Step 5: uninstall Enterprise (safety guard now permits)
self._uninstall_module('account_accountant')
# Step 6: re-snapshot and compare
after = self._snapshot_reconcile_state()
assert after == before, f"Reconcile state changed: {self._diff(before, after)}"
# Step 7: open historical reconcile via fusion widget, assert chatter visible
line = bank_lines[0]
widget_state = self.env['fusion.bank.rec.widget']._render_for_line(line)
assert widget_state['is_reconciled'] is True
assert widget_state['chatter_messages']
```
Run on every CI push; failure blocks merge.
### 7.8 Local LLM Compatibility Tests
Skipped automatically if no local LLM configured. When LM Studio reachable:
```python
@pytest.mark.skipif(not _is_lmstudio_reachable(), reason='LM Studio not running')
def test_openai_compat_adapter_against_lmstudio(env):
env['ir.config_parameter'].set_param(
'fusion_accounting.openai_base_url', 'http://host.docker.internal:1234/v1')
env['ir.config_parameter'].set_param(
'fusion_accounting.openai_api_key', 'lm-studio')
env['ir.config_parameter'].set_param(
'fusion_accounting.provider.bank_rec_suggest', 'openai_compatible')
result = env['fusion.reconcile.engine'].suggest_matches(
env['account.bank.statement.line'].search([], limit=1))
suggestions = env['fusion.reconcile.suggestion'].search([])
assert suggestions
assert suggestions[0].provider_used.startswith('openai_compatible')
assert 0.0 <= suggestions[0].confidence <= 1.0
```
These tests document the local LLM contract executably.
### 7.9 Coexistence Tests
```python
def test_menu_hidden_when_enterprise_installed(env):
self._install_module('account_accountant')
self._install_module('fusion_accounting_bank_rec')
user = self._create_user_with_group('fusion_accounting_core.group_fusion_accounting_user')
menu = env.ref('fusion_accounting_bank_rec.menu_fusion_bank_rec').sudo(user)
assert not _menu_visible_to(menu, user)
def test_menu_visible_when_enterprise_absent(env):
self._uninstall_module('account_accountant')
user = self._create_user_with_group('fusion_accounting_core.group_fusion_accounting_user')
menu = env.ref('fusion_accounting_bank_rec.menu_fusion_bank_rec').sudo(user)
assert _menu_visible_to(menu, user)
```
### 7.10 CI Integration
Phase 1 adds `fusion_accounting_bank_rec` to existing CI matrix at `.gitea/workflows/fusion_accounting_ci.yml`:
```yaml
matrix:
sub_module:
- fusion_accounting_core
- fusion_accounting_ai
- fusion_accounting_migration
- fusion_accounting_bank_rec # NEW
```
Plus separate slow-tests job runs migration_round_trip + performance benchmarks weekly (~10 min).
---
## 8. Acceptance Criteria for This Roadmap
Phase 1 is "done" and ready for Phase 2 brainstorming when:
- All 6 sections of this design implemented and committed to a feature branch
- 23 OWL components rendered correctly in browser smoke test (manual visual check)
- Test suite passes: ~150 Python tests + 5 tour tests + migration round-trip + performance benchmarks meet P95 targets
- Migration wizard runs successfully against the local OrbStack 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 (`http://host.docker.internal:1234/v1`)
- 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-module) depends on `fusion_accounting_bank_rec`
- All commits on `main`, tagged `fusion_accounting/phase-1-complete`
- Empirical Enterprise-to-fusion switchover tested on a clone of Westin's local DB (not production)
---
## 9. Open Items Deferred to Phase 1.x or Later
- Native Ollama adapter UI (model picker, pull from registry) — separate sub-module if needed
- Local embedding model + pgvector pattern matching — keyword tokenization is v1 baseline
- "Privacy mode" UI badge when local provider active
- Mobile-responsive widget below 768px viewport
- True auto-apply mode (cron applies high-confidence reconciles silently) — separate AI-native sub-module
- Mexican CFDI, cash-basis CABA, batch payment matching — out-of-scope features, deferred to dedicated phases
- Native llama.cpp Python binding (no HTTP) — niche
- Pre-migration "dry-run" mode for the wizard
- Multi-language prompts beyond English
---
## 10. References
- Roadmap design: [`fusion_accounting/docs/superpowers/specs/2026-04-18-fusion-accounting-enterprise-takeover-roadmap-design.md`](2026-04-18-fusion-accounting-enterprise-takeover-roadmap-design.md) Section 4.3
- Phase 0 plan: [`fusion_accounting/docs/superpowers/plans/2026-04-18-phase-0-foundation-plan.md`](../plans/2026-04-18-phase-0-foundation-plan.md)
- Phase 0 empirical verification: [`fusion_accounting/docs/superpowers/specs/2026-04-18-empirical-uninstall-test-results.md`](2026-04-18-empirical-uninstall-test-results.md)
- Workspace conventions: [`/Users/gurpreet/Github/Odoo-Modules/CLAUDE.md`](../../../../CLAUDE.md)
- Environment safety rule: [`/Users/gurpreet/Github/Odoo-Modules/.cursor/rules/environment-safety.mdc`](../../../../.cursor/rules/environment-safety.mdc)
- Reference Enterprise source (V19 snapshot): `/Users/gurpreet/Github/RePackaged-Odoo/accounting/account_accountant/`
- Visual design mockups (this session): `/Users/gurpreet/Github/Odoo-Modules/.superpowers/brainstorm/84408-1776602183/content/`