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