Files
Odoo-Modules/fusion_accounting/docs/superpowers/specs/2026-04-19-phase-1-bank-rec-design.md
gsinghpal 2d64f7efab docs(fusion_accounting): Phase 1 bank reconciliation design
Drafts the design for fusion_accounting_bank_rec — a native bank
reconciliation widget that replaces Odoo Enterprise account_accountant
in V19 OWL architecture, with a clean-room reconcile engine reading and
writing Community account.partial.reconcile rows.

Key design decisions captured:
- CORE scope (~5.5-6 weeks): manual + auto reconcile, write-offs,
  partial, multi-currency, chatter, model picker
- Strict mirror of all 18 Enterprise OWL units (zero functional loss)
  plus 5 fusion-only additions for AI/history visibility
- Hybrid AI badge layout: inline strip with one-click Accept plus
  expandable ranked-alternatives panel
- Behavioural learning via fusion.reconcile.pattern (per-partner) and
  fusion.reconcile.precedent (per-decision memory) with bootstrap from
  the 16,500 historical reconciliations
- Local LLM ready via OpenAI-compatible adapter base_url config and
  per-feature provider routing — works against LM Studio, Ollama, vLLM
- Statistical-mode-without-API-key as a first-class path
- Coexistence with Enterprise: Enterprise wins by default, fusion
  menu hides until uninstall, then auto-appears
- Migration wizard step bootstraps pattern memory and produces an
  audit report PDF proving every reconciliation preserved
- TDD on engine algorithms with Hypothesis property-based tests for
  amount invariants; migration round-trip integration test

Builds on Phase 0 (commit c450bb2, range pre-phase-0..phase-0-complete).

Made-with: Cursor
2026-04-19 09:27:52 -04:00

1154 lines
55 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 1 — Bank Reconciliation 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<br/>Odoo Community base"]
core["fusion_accounting_core<br/>(security, shared schema, helpers)"]
bankrec["fusion_accounting_bank_rec<br/>NEW (Phase 1)<br/>reconcile engine + OWL widget"]
ai["fusion_accounting_ai<br/>(BankRecAdapter._via_fusion fills in here)"]
migration["fusion_accounting_migration<br/>(adds bank-rec migration step)"]
meta["fusion_accounting<br/>(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<br/>(statement_line + targets)"]
val["2. Validate<br/>amounts balance, currency consistent,<br/>partner allowed, period not locked"]
diff["3. Compute deltas<br/>residual, exchange diff,<br/>tax recomputation if write-off"]
write["4. Write<br/>account.partial.reconcile rows<br/>+ optional exchange-diff move<br/>+ optional write-off move"]
audit["5. Audit<br/>log to mail.message<br/>+ trigger AI scoring update<br/>+ 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<br/>(widget root)"]
KC --> SS["statement_summary<br/>(top totals)"]
KC --> KR["kanban_renderer<br/>(grid)"]
KC --> BAB["batch_action_bar<br/>(bottom)"]
KC --> BFD["bankrec_form_dialog<br/>(modal — manual fallback)"]
KC --> CHT["chatter<br/>(per-line history panel)"]
KR --> SL["statement_line<br/>(per-line card)"]
KR --> QC["quick_create<br/>(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: `<name>.js` + `<name>.xml`
### 4.6 Coexistence Mode
Menu and views wrapped in `groups` filter referencing dynamically-computed group:
```xml
<menuitem id="menu_fusion_bank_rec"
name="Bank Reconciliation"
parent="fusion_accounting_ai.menu_fusion_accounting_root"
action="action_fusion_bank_rec_kanban"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
```
`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<br/>without fresh suggestions<br/>(LIMIT 200 per run)"]
open --> work
demand --> work
work --> p1["Pass 1: SQL filter<br/>partner match + reconcilable account"]
p1 --> p2["Pass 2: Statistical scoring<br/>(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<br/>send features + candidates<br/>get ranking + reasoning"]
aikey -->|no| stat["Use statistical reasoning"]
p3 --> write
stat --> write
write["Write fusion.reconcile.suggestion rows<br/>(supersede prior pending)"]
write --> badge["Frontend reads on next render<br/>→ 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": <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.
```
**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<br/>account.partial.reconcile, account.full.reconcile,<br/>mail.message linked to reconciles, ir.attachment"]
s2["Step 2: Bootstrap fusion.reconcile.precedent<br/>(scan 16,500 historical reconciles, batched 1000/commit)"]
s3["Step 3: Aggregate fusion.reconcile.pattern per partner"]
s4["Step 4: Verify counts unchanged<br/>(re-snapshot, assert equality)"]
s5["Step 5: Set fusion_accounting.migration.account_accountant.completed = True<br/>(safety guard now permits Enterprise uninstall)"]
s6["Step 6: Generate audit report PDF<br/>(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<br/>Client running Odoo 19 Enterprise"]
step1["1. Take pg_dump backup"]
step2["2. Install fusion_accounting meta-module"]
step3["3. Verify install<br/>HTTP /web/login → 200"]
step4["4. Coexistence detected<br/>fusion_bank_rec menu HIDDEN<br/>Enterprise widget keeps working"]
step5["5. Open Settings → Fusion Accounting → Migrate from Enterprise<br/>Click 'Run Migration'<br/>Wait ~30-60s for bootstrap"]
step6["6. Review audit report PDF"]
step7["7. Uninstall account_accountant<br/>(safety guard checks flag, allows uninstall)"]
step8["8. Coexistence re-detects<br/>fusion_bank_rec menu APPEARS"]
step9["9. Spot-check: open 5 historical reconciles<br/>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.<company_id>`)
- 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/`