# 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/`