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
55 KiB
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
BankRecAdapteralready stubbed atfusion_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.reconcileand 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 ataction_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=FalsePython widget model — Enterprise removed that pattern in V19) - A clean-room reconcile engine (Python, AbstractModel) that produces
account.partial.reconcilerows 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_accountantis 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
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)
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:
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:
- Pass 1: amount-exact-first (single open item where
amount_residual == bank_line.amountto within currency rounding) - Pass 2: smallest set of open items (≤3) summing to bank amount
- Pass 3: FIFO oldest-due-first, partial-reconcile until exhausted
- 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)):
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):
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.suggestionrows. Sub-300ms render even for 500 unreconciled lines. - Partner-unreconciled counts come from a materialized view
fusion_unreconciled_per_partner_mv, refreshed onaccount.movepost via overridden_post. Constant-time lookup vs. Enterprise's per-record compute (the main culprit in Westin's slow widget loads). reconcile_batchuses 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):
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:
# 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:
- No model-specific code in engines. Engine talks to
LLMProviderinterface, neverimport anthropicdirectly. - Deterministic statistical baseline. Every AI feature has a non-AI fallback that produces useful output.
- Capability negotiation, not assumption. Code never assumes tool-calling, never assumes 200k context, never assumes streaming.
- Per-feature, per-company provider routing. Multi-company installs can route Company A through Claude, Company B through local Llama.
- 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
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
// _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 = ["*"]— neverstatic props = []- Rich-HTML rendering (markdown reasoning, partner-history HTML):
onMounted+onPatched+ directinnerHTML, NOTmarkup()/t-out - Component class names:
Fusionprefix (FusionStatementLine,FusionAISuggestionStrip) - File pairs:
<name>.js+<name>.xml
4.6 Coexistence Mode
Menu and views wrapped in groups filter referencing dynamically-computed group:
<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
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
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:
- Cron generates suggestions overnight (writes pending suggestions)
- User opens widget, sees N high-confidence (≥95%) suggestions
- User clicks "Accept all N high-confidence" in
batch_action_bar - 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 suggestionsexplain_suggestion(suggestion_id)— expands reasoning with precedent detailsaccept_suggestion_via_chat(suggestion_id)— Tier 3 action (requires approval card)reject_suggestion_via_chat(suggestion_id, reason)— Tier 2 action; loggedbatch_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 badgesfind_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:
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
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.reconcileduring 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:
@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.
@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.
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.
@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:
@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
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:
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_accountantinstalled, 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_fusionpaths return real data (no longer stubs) fusion_accounting/__manifest__.py(meta-module) depends onfusion_accounting_bank_rec- All commits on
main, taggedfusion_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.mdSection 4.3 - Phase 0 plan:
fusion_accounting/docs/superpowers/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 - Workspace conventions:
/Users/gurpreet/Github/Odoo-Modules/CLAUDE.md - Environment safety rule:
/Users/gurpreet/Github/Odoo-Modules/.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/