diff --git a/fusion_accounting/docs/superpowers/specs/2026-04-19-phase-1-bank-rec-design.md b/fusion_accounting/docs/superpowers/specs/2026-04-19-phase-1-bank-rec-design.md new file mode 100644 index 00000000..425d30d6 --- /dev/null +++ b/fusion_accounting/docs/superpowers/specs/2026-04-19-phase-1-bank-rec-design.md @@ -0,0 +1,1153 @@ +# 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/`