This commit is contained in:
gsinghpal
2026-04-02 23:40:34 -04:00
parent 1c560c6df2
commit 4cd7357aa0
73 changed files with 7076 additions and 0 deletions

View File

@@ -0,0 +1,581 @@
# fusion_accounting -- AI Accounting Co-Pilot for Odoo 19
**Module:** `fusion_accounting`
**Version:** 1.0
**Odoo:** 19.0 Enterprise
**Date:** April 3, 2026
**Status:** Design Specification
---
## 1. Purpose
An Odoo 19 module that embeds an AI agent (Claude / GPT with tool-calling) into the Accounting menu. The agent can query, analyze, reconcile, audit, and report on every aspect of the accounting system through a conversational interface backed by a dashboard.
The module serves three user types: business owner (strategic oversight), office staff (daily processing), and external accountant (period-end work). It starts with tiered permissions (read-free, low-risk auto-approved, high-risk requires approval) and adapts over time by promoting tool+scenario combinations that achieve high approval rates.
---
## 2. Architecture
### 2.1 Component Overview
```
fusion_accounting (Odoo Module)
├── AI Service Layer
│ ├── Agent orchestrator (prompt + tool dispatch)
│ ├── Claude adapter (Anthropic API, tool-calling)
│ ├── GPT adapter (OpenAI API, function-calling)
│ └── Provider config (switchable via settings)
├── Tool Layer (73+ Odoo ORM wrappers across 12 domains)
│ ├── Each tool: name, description, parameters, tier, domain
│ ├── Registered in fusion.accounting.tool model
│ └── Callable by AI via orchestrator dispatch
├── Rules Engine (Fusion Rules)
│ ├── fusion.accounting.rule model
│ ├── Created by admin or AI (with approval)
│ ├── Applied before general AI reasoning
│ └── Versioned with rollback
├── Memory Layer
│ ├── fusion.accounting.match.history (every suggestion + outcome)
│ ├── fusion.accounting.session (chat sessions)
│ └── Confidence scoring per tool+scenario
├── Dashboard
│ ├── Health cards (bank recon, AR, AP, HST, audit, month-end)
│ ├── Action center (prioritized needs-attention + recent activity)
│ └── Chat panel (persistent, context-aware)
└── Odoo Integration
├── Menu: Accounting > Fusion AI
├── Cron: periodic audit scan on posted entries
├── Hook: post-action_post audit check (optional)
└── Security: role-based tool access
```
### 2.2 Data Flow
User message or dashboard click enters the controller. The controller builds a prompt containing: the user message, active Fusion Rules, recent match history, current Odoo context (page, journal, period), and the tool definitions for the relevant domain(s). This prompt goes to the selected AI provider.
The AI responds with either a text message or one or more tool calls. Tool calls are dispatched to the tool layer, which executes ORM operations against Odoo. Results return to the AI for further reasoning or final response. Tier 3 tool calls are intercepted and presented to the user for approval before execution.
### 2.3 AI Provider Integration
Both Claude (Anthropic) and GPT (OpenAI) are supported via adapters that normalize the tool-calling interface.
- **Claude:** Uses `tools` parameter with `input_schema` per tool. Responses include `tool_use` content blocks.
- **GPT:** Uses `functions` parameter with JSON Schema per function. Responses include `function_call` in the message.
- **Adapter pattern:** Each adapter translates between the normalized internal tool format and the provider-specific format. Switching providers requires only changing a config setting.
API keys are stored in `ir.config_parameter` with the `fusion_accounting.` prefix.
---
## 3. Tool Catalog
### 3.1 Tool Tiers
| Tier | Behavior | Examples |
|---|---|---|
| **1 -- Free** | Execute immediately, no approval. All read-only operations. | `get_unreconciled_bank_lines`, `calculate_hst_balance`, `get_ar_aging` |
| **2 -- Auto-approved** | Execute immediately, logged. Low-risk writes that annotate but don't change financial data. | `flag_entry`, `set_audit_status`, `send_followup` (draft) |
| **3 -- Requires approval** | AI proposes, user approves/rejects. Financial writes. | `match_bank_line_to_payments`, `reconcile_payment_to_invoice`, `create_payroll_journal_entry` |
Tier promotion: when a Tier 3 tool+scenario combination reaches a configurable accuracy threshold (default 95%) over a minimum sample size (default 30 decisions), it is promoted to Tier 2. Promotions can be reverted by the admin.
### 3.2 Domain 1 -- Bank Reconciliation
| Tool | Tier | Odoo Method | Purpose |
|---|---|---|---|
| `get_unreconciled_bank_lines` | 1 | `account.bank.statement.line.search_read(is_reconciled=False)` | List unreconciled bank statement lines with filters |
| `get_unreconciled_receipts` | 1 | `account.move.line.search_read(account_id=1122, reconciled=False)` | List unreconciled Outstanding Receipts entries |
| `match_bank_line_to_payments` | 3 | `account.bank.statement.line.set_line_bank_statement_line(move_line_ids)` | Match a bank line to one or more payment journal items |
| `auto_reconcile_bank_lines` | 3 | `account.bank.statement.line._try_auto_reconcile_statement_lines()` | Run Odoo's built-in auto-reconciliation |
| `apply_reconcile_model` | 3 | `account.reconcile.model._trigger_reconciliation_model(st_line)` | Apply a specific reconciliation model/rule |
| `unmatch_bank_line` | 3 | `account.bank.statement.line.action_unreconcile_entry()` | Undo a reconciliation |
| `get_reconcile_suggestions` | 1 | `account.reconcile.model.get_available_reconcile_model_per_statement_line()` | Get Odoo's suggested reconciliation models |
| `sum_payments_by_date` | 1 | SQL aggregate on `account.move.line` | Sum card payments for a date range (Elavon batch matching) |
### 3.3 Domain 2 -- HST/GST Management
| Tool | Tier | Purpose |
|---|---|---|
| `calculate_hst_balance` | 1 | Net HST position (collected on 2005 minus ITCs on 2006) for a period |
| `get_tax_report` | 1 | Generate tax report via `account.report` generic tax handler |
| `find_missing_tax_invoices` | 1 | Invoices with taxable products but no tax applied |
| `find_missing_itc_bills` | 1 | Vendor bills without input tax credits |
| `get_tax_return_status` | 1 | Status of periodic tax returns via `account.return` |
| `generate_tax_return` | 2 | Refresh tax return data via `account.return._generate_or_refresh_all_returns()` |
| `validate_tax_return` | 3 | Mark tax return as validated |
### 3.4 Domain 3 -- Accounts Receivable
| Tool | Tier | Purpose |
|---|---|---|
| `get_ar_aging` | 1 | AR aging buckets (current, 30, 60, 90+) |
| `get_overdue_invoices` | 1 | Invoices past due with partner contact info |
| `get_partner_balance` | 1 | Single partner AR balance and open items |
| `send_followup` | 2 | Draft follow-up email via `res.partner.execute_followup()` |
| `get_followup_report` | 1 | HTML follow-up report for a partner |
| `reconcile_payment_to_invoice` | 3 | Match payment to invoice via `account.move.line.reconcile()` |
| `get_unmatched_payments` | 1 | Payments not matched to invoices |
### 3.5 Domain 4 -- Accounts Payable
| Tool | Tier | Purpose |
|---|---|---|
| `get_ap_aging` | 1 | AP aging buckets |
| `find_duplicate_bills` | 1 | Same vendor + amount + date within configurable window |
| `match_bill_to_po` | 1 | Cross-reference bill lines to PO lines |
| `get_unpaid_bills` | 1 | Vendor bills with outstanding balance |
| `verify_bill_taxes` | 1 | Check bill tax vs fiscal position expectation |
| `get_payment_schedule` | 1 | Bills sorted by due date for cash planning |
### 3.6 Domain 5 -- Journal Review and Error Detection
| Tool | Tier | Purpose |
|---|---|---|
| `find_wrong_direction_balances` | 1 | Accounts where balance direction contradicts account type |
| `find_duplicate_entries` | 1 | Entries with matching partner + amount + date + journal |
| `find_wrong_account_entries` | 1 | Product lines on unlikely accounts (e.g., revenue on tax account) |
| `find_sequence_gaps` | 1 | `account.move` records where `made_sequence_gap = true` |
| `find_draft_entries` | 1 | Draft entries that should have been posted or deleted |
| `find_unreconciled_suspense` | 1 | Suspense/clearing accounts with non-zero balance |
| `verify_reconciliation_integrity` | 1 | Check `account.partial.reconcile` consistency |
### 3.7 Domain 6 -- Month-End / Year-End Close
| Tool | Tier | Purpose |
|---|---|---|
| `get_close_checklist` | 1 | Aggregate all domain checks into a period close checklist |
| `get_unreconciled_counts` | 1 | Per-account count of unreconciled items |
| `find_entries_in_locked_period` | 1 | Entries after lock dates |
| `get_accrual_status` | 1 | Balance on accrual accounts (vacation, sick, etc.) |
| `run_hash_integrity_check` | 1 | `res.company._check_hash_integrity()` |
| `get_period_summary` | 1 | Trial balance for the closing period |
### 3.8 Domain 7 -- Payroll Verification
| Tool | Tier | Purpose |
|---|---|---|
| `get_payroll_entries` | 1 | Journal entries in payroll-related journals |
| `compare_payroll_to_bank` | 1 | Cross-reference payroll cheques to bank statement lines |
| `verify_source_deductions` | 1 | CPP + EI + tax calculation verification against CRA tables |
| `get_cra_remittance_status` | 1 | CRA payable balance vs payments made |
| `find_unmatched_payroll_cheques` | 1 | Bank cheques without matching payroll entry |
### 3.9 Domain 8 -- Inventory and COGS
| Tool | Tier | Purpose |
|---|---|---|
| `get_stock_valuation` | 1 | Stock In Hand (1069) balance and layers |
| `get_price_differences` | 1 | Entries on account 5010 (PO price vs bill price) |
| `get_cogs_ratio_by_category` | 1 | COGS vs revenue per product category |
| `find_unusual_adjustments` | 1 | Large inventory adjustment entries |
| `get_inventory_turnover` | 1 | Sales vs average inventory |
### 3.10 Domain 9 -- ADP Reconciliation
| Tool | Tier | Purpose |
|---|---|---|
| `get_adp_receivable_aging` | 1 | Aging on account 1101 (ADP Receivable) |
| `match_adp_payment_to_invoice` | 3 | Match ADP deposit to ADP invoices |
| `verify_adp_split` | 1 | Customer portion + ADP portion = invoice total |
| `find_adp_without_payment` | 1 | ADP invoices without matching government deposit |
| `get_adp_summary` | 1 | Period summary of ADP billing vs collection |
### 3.11 Domain 10 -- Financial Reporting
| Tool | Tier | Purpose |
|---|---|---|
| `get_profit_loss` | 1 | P&L via `account.report` |
| `get_balance_sheet` | 1 | Balance sheet via `account.report` |
| `get_trial_balance` | 1 | Trial balance via `account.report` |
| `get_cash_flow` | 1 | Cash flow via `account.report` |
| `compare_periods` | 1 | Two period reports side by side |
| `answer_financial_question` | 1 | Natural language to ORM/SQL query |
| `export_report` | 2 | `account.report.export_to_pdf()` or `export_to_xlsx()` |
### 3.12 Domain 11 -- Audit and Integrity
| Tool | Tier | Purpose |
|---|---|---|
| `audit_posted_entry` | 1 | Run all entry-level checks on a single `account.move` |
| `audit_account_balances` | 1 | Run all account-level checks (wrong direction, stale items) |
| `audit_tax_compliance` | 1 | All tax checks (missing tax, wrong rate, exempt verification) |
| `audit_reconciliation_integrity` | 1 | Verify `account.partial.reconcile` / `account.full.reconcile` consistency |
| `check_hash_chain` | 1 | `res.company._check_hash_integrity()` |
| `check_sequence_gaps` | 1 | `account.journal._query_has_sequence_holes()` |
| `flag_entry` | 2 | Create chatter message on `account.move` with flag and recommendation |
| `get_audit_status` | 1 | `account.audit.account.status` per tax return |
| `set_audit_status` | 2 | Update review status (todo / reviewed / supervised / anomaly) |
| `get_audit_trail` | 1 | `mail.message` history for an `account.move` |
| `run_full_audit` | 1 | All checks across all domains for a period |
| `get_audit_report` | 1 | Summary of all findings with severity ratings |
### 3.13 Domain 12 -- Payroll Management
| Tool | Tier | Purpose |
|---|---|---|
| `parse_payroll_summary` | 1 | Read pasted/uploaded QBO or fusion_payroll data |
| `create_payroll_journal_entry` | 3 | `account.move.create()` with payroll debit/credit lines |
| `get_payroll_schedule` | 1 | Employee pay dates, amounts, history |
| `match_payroll_cheques` | 3 | Match bank cheques to payroll liabilities |
| `verify_payroll_deductions` | 1 | Check CPP/EI/tax against CRA rate tables |
| `get_cra_remittance_due` | 1 | Calculate CRA obligation vs payments made |
| `prepare_cra_payment` | 3 | Create CRA remittance payment entry |
| `generate_t4` | 2 | Trigger `fusion_payroll` T4 generation |
| `generate_roe` | 2 | Trigger `fusion_payroll` ROE generation |
| `get_payroll_cost_report` | 1 | Period summary by employee/department |
Phase 1 (QBO bridge): tools work with pasted/uploaded payroll data.
Phase 2 (fusion_payroll native): tools call fusion_payroll ORM methods directly.
---
## 4. Fusion Rules Engine
### 4.1 Rule Model
```
fusion.accounting.rule
├── name Char, required
├── rule_type Selection: match / classify / audit / fee / routing / followup
├── description Text (natural language, read by AI)
├── trigger_domain Text/JSON (Odoo domain filter for matching records)
├── match_logic Text (natural language matching instructions for AI)
├── match_code Text (optional Python for deterministic matching)
├── fee_account_id Many2one → account.account
├── write_off_account_id Many2one → account.account
├── approval_tier Selection: auto / needs_approval
├── created_by Selection: admin / ai
├── confidence_score Float (0.0 to 1.0)
├── total_uses Integer
├── total_approved Integer
├── total_rejected Integer
├── promotion_threshold Float (default 0.95)
├── min_sample_size Integer (default 30)
├── active Boolean
├── version Integer
├── parent_rule_id Many2one → self (version chain)
├── journal_ids Many2many → account.journal
├── company_id Many2one → res.company
├── notes Text
```
### 4.2 Rule Lifecycle
1. **Creation:** Admin creates via UI form, or AI proposes after detecting a pattern (3+ identical matches). AI-proposed rules start at Tier 3.
2. **Application:** During reconciliation or auditing, the AI loads active rules and applies them before general reasoning. Rules with `match_code` run deterministically; rules with only `match_logic` are interpreted by the AI.
3. **Scoring:** Each use updates `total_uses` and `total_approved` / `total_rejected`. Confidence score is recalculated.
4. **Promotion:** When confidence crosses `promotion_threshold` with at least `min_sample_size` decisions, `approval_tier` changes from `needs_approval` to `auto`.
5. **Modification:** Admin or AI (with approval) can edit. Changes increment `version` and create a new record linked via `parent_rule_id`. Confidence resets for the modified variant.
6. **Rollback:** Admin can deactivate current version and reactivate a previous version.
### 4.3 Rule Priority
During processing, rules are evaluated in order:
1. Admin-created rules (highest priority)
2. AI-created rules with auto-approval (proven patterns)
3. AI-created rules needing approval (proposed patterns)
4. No rule matches: AI reasons from scratch using tools
---
## 5. Match History and Learning
### 5.1 Match History Model
```
fusion.accounting.match.history
├── session_id Many2one → fusion.accounting.session
├── tool_name Char (which tool was called)
├── tool_params Text/JSON (parameters passed)
├── tool_result Text/JSON (result returned)
├── ai_reasoning Text (AI's explanation for the match)
├── ai_confidence Float (AI's self-assessed confidence)
├── rule_id Many2one → fusion.accounting.rule (if rule-based)
├── proposed_at Datetime
├── decision Selection: approved / rejected / pending
├── decided_at Datetime
├── decided_by Many2one → res.users
├── rejection_reason Text (user's explanation if rejected)
├── correct_action Text/JSON (what should have happened, if corrected)
├── bank_statement_line_id Many2one → account.bank.statement.line
├── move_line_ids Many2many → account.move.line
├── amount Monetary
├── partner_id Many2one → res.partner
```
### 5.2 How Learning Works
The AI's system prompt includes the most recent N match history records (configurable, default 50) filtered to the current domain/scenario. This gives the AI context about:
- What patterns have been approved (do more of this)
- What was rejected and why (avoid this)
- Partner-specific quirks (e.g., "Shirley Ramsumair: label unreliable")
- Fee patterns (e.g., "Elavon: ~1.6% fee to 60545")
- Timing patterns (e.g., "weekend card batches combine Fri+Sat")
The AI does not have persistent memory across sessions beyond what is stored in match history and rules. Every session starts fresh with the system prompt + loaded history + loaded rules.
---
## 6. Dashboard
### 6.1 Location
Accounting menu > Fusion AI (submenu, next to Dashboard).
### 6.2 Layout
**Top row: 6 health cards.** Each shows a key metric with color coding (green/yellow/red). Clicking any card starts a relevant conversation in the chat panel.
| Card | Metric | Source |
|---|---|---|
| Bank Reconciliation | Unmatched line count + total amount | `account.bank.statement.line` where `is_reconciled = False` |
| AR Outstanding | Total receivable + overdue count | `account.move.line` on AR account, `amount_residual > 0` |
| AP Due | Total payable + due this week | `account.move.line` on AP account, grouped by due date |
| HST Balance | Net HST (collected minus ITCs) | Balances on accounts 2005 and 2006 |
| Audit Score | Score 0-100 + active flag count | Weighted composite of all audit checks |
| Month-End Status | Current period status + open items | Aggregate of close checklist items |
**Middle: two-column action center.**
Left column: "Needs Attention" -- AI-prioritized list of actionable items. Re-ranked daily by the audit cron. Items are clickable to start the relevant conversation.
Right column: "Recent AI Activity" -- log of autonomous actions (Tier 2), pending approvals (Tier 3), and completed conversations. Provides transparency.
**Bottom/Side: Chat panel.** Persistent across navigation within the Accounting module. Shows conversation history for the current session. Supports text input, file upload (for QBO payroll summaries), and structured approval cards for Tier 3 actions.
### 6.3 Approval Cards
When the AI proposes a Tier 3 action, the chat displays a structured card:
```
┌─────────────────────────────────────────────┐
│ Match Proposal 94% conf │
│ │
│ Bank: Mar 4 elavon mrch svc $697.61 │
│ ↔ 4 card payments (Mar 3) $709.14 │
│ Fee to 60545 (Elavon Fee) $11.53 │
│ │
│ AI: "Weekend daily batch, 1.6% fee" │
│ │
│ [ Approve ] [ Reject ] │
└─────────────────────────────────────────────┘
```
Batch mode: multiple cards can be displayed at once with "Approve All" / "Reject All" buttons plus individual controls.
---
## 7. Security
### 7.1 Access Groups
| Group | Dashboard | Chat (Read) | Chat (Tier 2) | Chat (Tier 3) | Rules | Config |
|---|---|---|---|---|---|---|
| `fusion_accounting.group_user` (Staff) | View | Yes | No | No | View | No |
| `fusion_accounting.group_manager` (Manager) | View | Yes | Yes | Yes | Create/Edit | No |
| `fusion_accounting.group_admin` (Admin) | View | Yes | Yes | Yes | Create/Edit | Yes |
### 7.2 Tool-Level Security
Each tool definition includes `required_groups`. The AI adapter filters available tools based on the current user's groups before building the prompt. A staff user's AI session simply does not have access to write tools.
### 7.3 Audit Trail
All AI actions are logged in `fusion.accounting.match.history` with the user who approved, timestamp, and full context. This is in addition to Odoo's standard chatter/mail tracking on modified records.
---
## 8. Module Structure
```
/mnt/extra-addons/fusion_accounting/
├── __manifest__.py
├── __init__.py
├── models/
│ ├── __init__.py
│ ├── accounting_session.py # Chat session model
│ ├── accounting_match_history.py # Match history (approved/rejected)
│ ├── accounting_rule.py # Fusion Rules
│ ├── accounting_tool.py # Tool registry model
│ ├── accounting_config.py # Settings (API keys, thresholds)
│ └── accounting_dashboard.py # Dashboard computed fields
├── services/
│ ├── __init__.py
│ ├── agent.py # AI orchestrator (prompt assembly, tool dispatch loop)
│ ├── adapters/
│ │ ├── __init__.py
│ │ ├── claude.py # Anthropic Claude adapter
│ │ └── openai.py # OpenAI GPT adapter
│ ├── tools/
│ │ ├── __init__.py
│ │ ├── bank_reconciliation.py # Domain 1 tools
│ │ ├── hst_management.py # Domain 2 tools
│ │ ├── accounts_receivable.py # Domain 3 tools
│ │ ├── accounts_payable.py # Domain 4 tools
│ │ ├── journal_review.py # Domain 5 tools
│ │ ├── month_end.py # Domain 6 tools
│ │ ├── payroll.py # Domain 7 + 12 tools
│ │ ├── inventory.py # Domain 8 tools
│ │ ├── adp.py # Domain 9 tools
│ │ ├── reporting.py # Domain 10 tools
│ │ └── audit.py # Domain 11 tools
│ ├── prompts/
│ │ ├── system_prompt.py # Base system prompt
│ │ └── domain_prompts.py # Per-domain context injections
│ └── scoring.py # Confidence scoring + tier promotion logic
├── controllers/
│ ├── __init__.py
│ └── chat_controller.py # JSON endpoint for chat messages
├── wizards/
│ ├── __init__.py
│ └── rule_wizard.py # Quick-create rule from chat suggestion
├── static/
│ └── src/
│ ├── components/
│ │ ├── dashboard/
│ │ │ ├── fusion_dashboard.js
│ │ │ ├── fusion_dashboard.xml
│ │ │ ├── health_card.js
│ │ │ └── health_card.xml
│ │ ├── chat/
│ │ │ ├── chat_panel.js
│ │ │ ├── chat_panel.xml
│ │ │ ├── approval_card.js
│ │ │ └── approval_card.xml
│ │ └── rules/
│ │ └── rule_form.js
│ └── scss/
│ ├── dashboard.scss
│ └── chat.scss
├── views/
│ ├── dashboard_views.xml
│ ├── session_views.xml
│ ├── rule_views.xml
│ ├── config_views.xml
│ ├── match_history_views.xml
│ └── menus.xml
├── security/
│ ├── security.xml # Groups
│ └── ir.model.access.csv # Model access rules
├── data/
│ ├── cron.xml # Periodic audit scan cron
│ ├── tool_definitions.xml # Seed tool registry
│ └── default_rules.xml # Starter Fusion Rules
└── report/
└── audit_report_template.xml # PDF audit report
```
---
## 9. Implementation Phases
### Phase 1: Foundation (estimated 2 weeks)
Build the module skeleton, AI service layer, and first 2 domains.
- Module skeleton: manifest, models, security, menu
- AI service: agent orchestrator, Claude adapter, GPT adapter
- Tool layer: Domain 1 (bank reconciliation) + Domain 5 (journal review)
- Controller: chat endpoint (JSON-RPC)
- Basic chat UI: simple text input/output in an Odoo form view (no OWL widget yet)
- Match history model + logging
- Test: reconcile a batch of bank lines via chat
**Milestone:** Can reconcile bank statement lines through conversation.
### Phase 2: Rules + More Domains (estimated 2 weeks)
- Fusion Rules model + CRUD views
- AI rule proposal flow (detect pattern, suggest rule, user approves)
- Confidence scoring + tier promotion logic
- Domains 2 (HST), 3 (AR), 4 (AP)
- Approval card flow for Tier 3 actions
**Milestone:** Can prepare HST filing, chase overdue invoices, and auto-create matching rules.
### Phase 3: Dashboard + Audit (estimated 2 weeks)
- OWL dashboard with health cards
- OWL chat panel (persistent side panel)
- Approval cards with approve/reject buttons
- Domain 11 (audit) with cron-based periodic scanning
- Domain 6 (month-end close)
**Milestone:** Dashboard shows accounting health at a glance. Audit cron flags issues automatically.
### Phase 4: Remaining Domains + Polish (estimated 2 weeks)
- Domains 7-10 (payroll verification, inventory, ADP, reporting)
- Domain 12 (payroll management -- QBO bridge for Phase 1, fusion_payroll-ready)
- Export/report tools (PDF, XLSX)
- Batch approval mode
- Learning/adaptation refinements
- Documentation
**Milestone:** Full 12-domain AI accounting co-pilot operational.
---
## 10. Dependencies
### Odoo Modules (required)
- `account` (core accounting)
- `account_accountant` (enterprise bank reconciliation)
- `account_reports` (enterprise reporting + tax returns + audit status)
- `account_followup` (AR follow-ups)
- `mail` (chatter integration for flagging)
### Odoo Modules (optional, enhanced features if installed)
- `account_budget` (budget tools)
- `account_asset` (asset depreciation tools)
- `account_batch_payment` (batch payment tools)
- `fusion_payroll` (native payroll integration for Domain 12 Phase 2)
- `fusion_poynt` (Poynt terminal data for card payment matching)
- `stock_account` (inventory valuation tools)
### External
- Anthropic API key (for Claude) OR OpenAI API key (for GPT) -- at least one required
- Python packages: `anthropic`, `openai` (installed in Odoo container)
---
## 11. Configuration
Settings page at: Accounting > Fusion AI > Configuration
| Setting | Type | Default |
|---|---|---|
| AI Provider | Selection (claude / openai) | claude |
| Anthropic API Key | Char (password field) | -- |
| OpenAI API Key | Char (password field) | -- |
| Claude Model | Char | claude-sonnet-4-20250514 |
| OpenAI Model | Char | gpt-4o |
| Tier 3 Promotion Threshold | Float | 0.95 |
| Tier 3 Min Sample Size | Integer | 30 |
| Audit Cron Frequency | Selection (daily / weekly / monthly) | daily |
| Match History in Prompt | Integer (recent N records) | 50 |
| Max Tool Calls Per Turn | Integer | 20 |
| Enable Post-Action Audit Hook | Boolean | False |
---
## 12. Risks and Mitigations
| Risk | Mitigation |
|---|---|
| AI calls wrong tool or wrong parameters | Tiered permissions; Tier 3 requires human approval; tool parameter validation in each tool function |
| AI hallucinates financial data | All data comes from Odoo ORM queries, not AI generation. AI reasons about data but cannot invent it. |
| Reconciliation error corrupts books | All reconciliation uses Odoo's native engine (`set_line_bank_statement_line`, `reconcile`). Reversible via `action_unreconcile_entry`. |
| API costs escalate | Token usage tracked per session. Max tool calls per turn limits runaway loops. Model selection (cheaper models for simple queries). |
| Learning from bad patterns | Confidence scoring requires minimum sample size. Admin can demote promoted tools. Rule versioning with rollback. |
| Sensitive data in API calls | Financial data sent to AI provider. Mitigate by using AI provider's data privacy agreements. No customer PII in tool descriptions. Partner names in transaction data are necessary for matching. |

View File

@@ -0,0 +1,2 @@
from . import models
from . import wizard

View File

@@ -0,0 +1,22 @@
{
'name': 'Fusion Bank Statements',
'version': '19.0.1.0.0',
'category': 'Accounting',
'summary': 'Import OFX/QFX bank statements with automatic duplicate detection',
'description': 'Upload OFX, QFX, or QBO files exported from your bank '
'(ScotiaConnect, TD, RBC, etc.) and import them as bank '
'statement lines. Smart duplicate detection using the bank\'s '
'transaction ID (fitid). No external server communication.',
'author': 'Fusion Central',
'website': 'https://fusionsoft.ca',
'license': 'LGPL-3',
'depends': ['account'],
'data': [
'security/ir.model.access.csv',
'wizard/import_statement_views.xml',
'views/account_journal_views.xml',
],
'external_dependencies': {'python': ['ofxparse']},
'installable': True,
'auto_install': False,
}

View File

@@ -0,0 +1,2 @@
from . import import_log
from . import account_journal

View File

@@ -0,0 +1,16 @@
from odoo import models
class AccountJournal(models.Model):
_inherit = 'account.journal'
def action_open_statement_import(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Import Bank Statement',
'res_model': 'fusion.statement.import',
'view_mode': 'form',
'target': 'new',
'context': {'default_journal_id': self.id},
}

View File

@@ -0,0 +1,26 @@
from odoo import fields, models
class FusionStatementImportLog(models.Model):
_name = 'fusion.statement.import.log'
_description = 'Imported Bank Transaction Log'
_order = 'date desc, id desc'
_rec_name = 'fitid'
journal_id = fields.Many2one(
'account.journal', required=True, ondelete='cascade', index=True,
)
fitid = fields.Char(string='Bank Transaction ID', required=True, index=True)
date = fields.Date()
amount = fields.Float(digits=(16, 2))
payment_ref = fields.Char(string='Description')
import_date = fields.Datetime(default=fields.Datetime.now, readonly=True)
statement_line_id = fields.Many2one('account.bank.statement.line', ondelete='set null')
company_id = fields.Many2one(
'res.company', required=True, default=lambda self: self.env.company,
)
_sql_constraints = [
('journal_fitid_unique', 'UNIQUE(journal_id, fitid)',
'This transaction has already been imported for this journal.'),
]

View File

@@ -0,0 +1,5 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fusion_import_log_accountant,fusion.statement.import.log accountant,model_fusion_statement_import_log,account.group_account_invoice,1,1,1,0
access_fusion_import_log_manager,fusion.statement.import.log manager,model_fusion_statement_import_log,account.group_account_manager,1,1,1,1
access_fusion_import_wizard,fusion.statement.import wizard,model_fusion_statement_import,account.group_account_invoice,1,1,1,1
access_fusion_import_line,fusion.statement.import.line wizard,model_fusion_statement_import_line,account.group_account_invoice,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fusion_import_log_accountant fusion.statement.import.log accountant model_fusion_statement_import_log account.group_account_invoice 1 1 1 0
3 access_fusion_import_log_manager fusion.statement.import.log manager model_fusion_statement_import_log account.group_account_manager 1 1 1 1
4 access_fusion_import_wizard fusion.statement.import wizard model_fusion_statement_import account.group_account_invoice 1 1 1 1
5 access_fusion_import_line fusion.statement.import.line wizard model_fusion_statement_import_line account.group_account_invoice 1 1 1 1

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Add "Import Statement" button to bank journal form view -->
<record id="view_account_journal_form_inherit_fusion" model="ir.ui.view">
<field name="name">account.journal.form.fusion.statements</field>
<field name="model">account.journal</field>
<field name="inherit_id" ref="account.view_account_journal_form"/>
<field name="arch" type="xml">
<xpath expr="//div[@name='button_box']" position="inside">
<button name="action_open_statement_import"
type="object"
class="oe_stat_button"
icon="fa-upload"
invisible="type != 'bank'">
<span class="o_stat_text">Import Statement</span>
</button>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1 @@
from . import import_statement

View File

@@ -0,0 +1,243 @@
import base64
import io
import logging
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
try:
from ofxparse import OfxParser
except ImportError:
OfxParser = None
_logger.warning("ofxparse library not installed — OFX import disabled.")
class FusionStatementImportLine(models.TransientModel):
_name = 'fusion.statement.import.line'
_description = 'Statement Import Preview Line'
_order = 'date desc, id desc'
wizard_id = fields.Many2one('fusion.statement.import', ondelete='cascade')
selected = fields.Boolean(default=True)
is_duplicate = fields.Boolean(readonly=True)
fitid = fields.Char(string='Transaction ID', readonly=True)
date = fields.Date(readonly=True)
payment_ref = fields.Char(string='Description', readonly=True)
amount = fields.Float(digits=(16, 2), readonly=True)
class FusionStatementImport(models.TransientModel):
_name = 'fusion.statement.import'
_description = 'Import Bank Statement'
step = fields.Selection([
('upload', 'Upload'),
('review', 'Review'),
], default='upload', readonly=True)
journal_id = fields.Many2one(
'account.journal', string='Bank Journal', required=True,
domain="[('type', '=', 'bank')]",
)
data_file = fields.Binary(string='Statement File', attachment=False)
filename = fields.Char()
line_ids = fields.One2many('fusion.statement.import.line', 'wizard_id')
total_new = fields.Integer(compute='_compute_counts')
total_duplicate = fields.Integer(compute='_compute_counts')
total_selected = fields.Integer(compute='_compute_counts')
balance_start = fields.Float(digits=(16, 2), readonly=True)
balance_end = fields.Float(digits=(16, 2), readonly=True)
currency_code = fields.Char(readonly=True)
account_number = fields.Char(readonly=True)
@api.depends('line_ids.selected', 'line_ids.is_duplicate')
def _compute_counts(self):
for rec in self:
lines = rec.line_ids
rec.total_new = len(lines.filtered(lambda l: not l.is_duplicate))
rec.total_duplicate = len(lines.filtered(lambda l: l.is_duplicate))
rec.total_selected = len(lines.filtered(lambda l: l.selected))
# ------------------------------------------------------------------
# Step 1 → Step 2: Parse file
# ------------------------------------------------------------------
def action_parse(self):
self.ensure_one()
if not self.data_file:
raise UserError(_("Please upload a statement file."))
if not OfxParser:
raise UserError(_(
"The 'ofxparse' Python library is not installed. "
"Ask your administrator to run: pip install ofxparse"
))
raw = base64.b64decode(self.data_file)
try:
ofx = OfxParser.parse(io.BytesIO(raw))
except Exception as e:
raise UserError(_(
"Could not parse the file. Make sure it is a valid "
"OFX/QFX/QBO file.\n\nError: %s"
) % str(e)) from e
if not ofx.accounts:
raise UserError(_("No accounts found in the file."))
account = ofx.accounts[0]
transactions = account.statement.transactions
if not transactions:
raise UserError(_("No transactions found in the file."))
ImportLog = self.env['fusion.statement.import.log']
existing_fitids = set(
ImportLog.search([
('journal_id', '=', self.journal_id.id),
]).mapped('fitid')
)
lines = []
for tx in transactions:
fitid = str(tx.id).strip()
payee = tx.payee or ''
if tx.checknum:
payee += ' ' + tx.checknum
if tx.memo:
payee += ' : ' + tx.memo
is_dup = fitid in existing_fitids
lines.append((0, 0, {
'fitid': fitid,
'date': tx.date.date() if hasattr(tx.date, 'date') else tx.date,
'payment_ref': payee.strip(),
'amount': float(tx.amount),
'is_duplicate': is_dup,
'selected': not is_dup,
}))
balance = float(account.statement.balance)
total_amt = sum(float(tx.amount) for tx in transactions)
self.write({
'step': 'review',
'line_ids': [(5, 0, 0)] + lines,
'balance_end': balance,
'balance_start': balance - total_amt,
'currency_code': account.statement.currency or '',
'account_number': account.number or '',
})
return self._reopen()
# ------------------------------------------------------------------
# Step 2: Import selected lines
# ------------------------------------------------------------------
def action_import(self):
self.ensure_one()
selected = self.line_ids.filtered(lambda l: l.selected)
if not selected:
raise UserError(_("No transactions selected for import."))
journal = self.journal_id
statement = self.env['account.bank.statement'].create({
'name': self.filename or 'OFX Import',
'reference': self.filename or '',
'journal_id': journal.id,
'balance_start': self.balance_start,
'balance_end_real': self.balance_end,
})
ImportLog = self.env['fusion.statement.import.log']
created_lines = self.env['account.bank.statement.line']
for line in selected.sorted('date'):
st_line = self.env['account.bank.statement.line'].create({
'journal_id': journal.id,
'date': line.date,
'payment_ref': line.payment_ref,
'amount': line.amount,
'statement_id': statement.id,
})
created_lines |= st_line
ImportLog.create({
'journal_id': journal.id,
'fitid': line.fitid,
'date': line.date,
'amount': line.amount,
'payment_ref': line.payment_ref,
'statement_line_id': st_line.id,
'company_id': journal.company_id.id,
})
all_lines = self.line_ids
dup_count = len(all_lines.filtered(lambda l: l.is_duplicate))
manual_skip = len(all_lines.filtered(lambda l: not l.selected and not l.is_duplicate))
date_min = min(selected.mapped('date'))
date_max = max(selected.mapped('date'))
parts = ['%d transactions imported.' % len(selected)]
if dup_count:
parts.append('%d duplicates detected.' % dup_count)
if manual_skip:
parts.append('%d manually excluded.' % manual_skip)
parts.append('Date range: %s to %s' % (date_min, date_max))
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Bank Statement Imported'),
'message': ' '.join(parts),
'type': 'success',
'sticky': False,
'next': {
'type': 'ir.actions.act_window',
'name': _('Imported Statement'),
'res_model': 'account.bank.statement',
'res_id': statement.id,
'views': [(False, 'form')],
},
},
}
# ------------------------------------------------------------------
# Navigation helpers
# ------------------------------------------------------------------
def action_back(self):
self.ensure_one()
self.write({'step': 'upload', 'line_ids': [(5, 0, 0)]})
return self._reopen()
def action_select_all_new(self):
self.ensure_one()
for line in self.line_ids:
line.selected = not line.is_duplicate
return self._reopen()
def action_select_none(self):
self.ensure_one()
self.line_ids.write({'selected': False})
return self._reopen()
def action_select_all(self):
self.ensure_one()
self.line_ids.write({'selected': True})
return self._reopen()
def _reopen(self):
return {
'type': 'ir.actions.act_window',
'res_model': self._name,
'res_id': self.id,
'views': [(False, 'form')],
'target': 'new',
}

View File

@@ -0,0 +1,110 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="fusion_statement_import_form" model="ir.ui.view">
<field name="name">fusion.statement.import.form</field>
<field name="model">fusion.statement.import</field>
<field name="arch" type="xml">
<form string="Import Bank Statement">
<!-- Step 1: Upload -->
<group invisible="step != 'upload'">
<group>
<field name="journal_id"/>
<field name="data_file" filename="filename"/>
<field name="filename" invisible="1"/>
</group>
<group>
<p class="text-muted">
Upload an OFX, QFX, or QBO file exported from your bank portal.
Duplicate transactions will be detected automatically.
</p>
</group>
</group>
<!-- Step 2: Review -->
<group invisible="step != 'review'" string="File Summary">
<group>
<field name="account_number" readonly="1"/>
<field name="currency_code" readonly="1"/>
</group>
<group>
<field name="balance_start" readonly="1"/>
<field name="balance_end" readonly="1"/>
</group>
</group>
<div invisible="step != 'review'" class="mb-2">
<div class="d-flex gap-2 align-items-center mb-3">
<span class="badge text-bg-success fs-6">
New: <field name="total_new" class="d-inline" readonly="1"/>
</span>
<span class="badge text-bg-warning fs-6">
Duplicates: <field name="total_duplicate" class="d-inline" readonly="1"/>
</span>
<span class="badge text-bg-primary fs-6">
Selected: <field name="total_selected" class="d-inline" readonly="1"/>
</span>
<span class="flex-grow-1"/>
<button name="action_select_all_new" type="object"
class="btn btn-secondary btn-sm">
Select New Only
</button>
<button name="action_select_all" type="object"
class="btn btn-secondary btn-sm">
Select All
</button>
<button name="action_select_none" type="object"
class="btn btn-secondary btn-sm">
Deselect All
</button>
</div>
<field name="line_ids" nolabel="1">
<list editable="bottom"
decoration-danger="is_duplicate and selected"
decoration-muted="is_duplicate and not selected"
decoration-success="not is_duplicate and selected">
<field name="selected"/>
<field name="is_duplicate" string="Dup?" widget="boolean"/>
<field name="date"/>
<field name="payment_ref"/>
<field name="amount"/>
<field name="fitid"/>
</list>
</field>
</div>
<field name="step" invisible="1"/>
<footer>
<button name="action_parse" type="object"
string="Parse File" class="btn-primary"
invisible="step != 'upload'"/>
<button name="action_import" type="object"
string="Import Selected" class="btn-primary"
invisible="step != 'review'"/>
<button name="action_back" type="object"
string="Back" class="btn-secondary"
invisible="step != 'review'"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_fusion_statement_import" model="ir.actions.act_window">
<field name="name">Import Bank Statement</field>
<field name="res_model">fusion.statement.import</field>
<field name="view_mode">form</field>
<field name="view_id" ref="fusion_statement_import_form"/>
<field name="target">new</field>
</record>
<menuitem id="menu_fusion_statement_import"
name="Import Bank Statement (OFX)"
parent="account.account_transactions_menu"
action="action_fusion_statement_import"
sequence="90"/>
</odoo>

154
fusion_accounting/CLAUDE.md Normal file
View File

@@ -0,0 +1,154 @@
# fusion_accounting — AI Accounting Co-Pilot
## What This Module Does
An AI agent (Claude/GPT with tool-calling) embedded in Odoo 19 Enterprise Accounting. Conversational interface backed by a dashboard for bank reconciliation, HST/GST management, AR/AP analysis, journal review, month-end close, payroll, inventory, ADP reconciliation, financial reporting, and auditing.
## Architecture
```
fusion_accounting/
├── models/ 7 models (6 new + 1 inherit on account.move)
├── services/
│ ├── agent.py AI orchestrator (prompt assembly, tool dispatch loop)
│ ├── adapters/ Claude + OpenAI adapters with native tool-calling
│ ├── tools/ 85 tool functions across 11 domain files
│ ├── prompts/ System prompt builder + 12 domain-specific prompts
│ └── scoring.py Confidence scoring + tier promotion logic
├── controllers/ 8 JSON-RPC endpoints
├── wizards/ Rule creation wizard
├── static/src/ OWL dashboard + chat panel + approval cards
├── views/ List/form/search views, menus, settings
├── security/ 3 groups (User/Manager/Admin), record rules, ACLs
├── data/ 82 tool definitions, 2 default rules, 2 crons
└── report/ Audit report QWeb template
```
## Key Design Decisions
### AI Provider Integration
- Uses `fusion.api.service` (from fusion_api module) for API key resolution with fallback to `ir.config_parameter` — NO hard dependency on fusion_api
- Claude adapter: native `tool_use` blocks, extended thinking enabled (8K budget) for 4.5+ models
- OpenAI adapter: Chat Completions API with o-series reasoning model support (`developer` role, `max_completion_tokens`, `reasoning_effort`)
- API keys stored in `ir.config_parameter` with `fusion_accounting.` prefix
### Tool Tiering
- **Tier 1** (Free): Read-only, execute immediately — 60+ tools
- **Tier 2** (Auto-approved): Low-risk writes, logged — ~10 tools
- **Tier 3** (Requires approval): Financial writes, user must approve — ~15 tools
- Auto-promotion: Tier 3 → Tier 2 at 95% accuracy over 30+ decisions (atomic SQL counters)
### Menu Location
- **Parent**: `accountant.menu_accounting` (NOT `account.menu_finance` — that's Community Edition only)
- Enterprise uses `accountant.menu_accounting` (ID 1663) as the visible menu root
### Session Persistence
- Chat sessions stored in `fusion.accounting.session` with `message_ids_json` (JSON text field)
- On page load, chat panel calls `/session/latest` to restore the most recent active session
- "New Chat" button closes current session and creates a fresh one
## Odoo 19 Gotchas (Learned the Hard Way)
### Search Views
- NO `string` attribute on `<search>` element
- NO `string` attribute on `<group>` element inside search views
- Group-by filters MUST have `domain="[]"` attribute
- Add `<separator/>` before `<group>` in search views
### OWL Client Actions
- Components registered as client actions receive props: `action`, `actionId`, `updateActionState`, `className`
- Must use `static props = ["*"]` (accept any) — NOT `static props = []` (accept none)
### Cron Safe Eval
- NO `import` statements (forbidden opcode `IMPORT_NAME`)
- `datetime` module available as `datetime` (use `datetime.datetime.now()`, `datetime.timedelta()`)
- NO `from datetime import X` pattern
### read_group Deprecated
- `read_group()` is deprecated in Odoo 19 — use `_read_group()` instead
- Still works but throws DeprecationWarning
### Config Parameter Values
- When changing a Selection field's options, the stored DB value in `ir_config_parameter` must match one of the new options or Settings page will crash with `ValueError: Wrong value`
- Fix: UPDATE the value in DB after changing selection options
### Field Label Conflicts
- Odoo warns if two fields on the same model have the same `string` label
- Our `display_name_field` conflicted with built-in `display_name` — renamed string to "Tool Label"
### Group Assignment
- `implied_ids` on groups only applies to NEWLY added users, not existing ones
- After installing, manually add existing users to groups via SQL:
```sql
INSERT INTO res_groups_users_rel (gid, uid)
SELECT <group_id>, gu.uid FROM res_groups_users_rel gu
JOIN ir_model_data imd ON imd.res_id = gu.gid AND imd.model = 'res.groups'
WHERE imd.module = 'account' AND imd.name = 'group_account_manager'
ON CONFLICT DO NOTHING;
```
## Server Details
- **Server**: odoo-westin (192.168.1.40, SSH via `ssh odoo-westin`)
- **Container**: odoo-dev-app (Odoo), odoo-dev-db (PostgreSQL)
- **Database**: westin-v19
- **Module path**: `/mnt/extra-addons/fusion_accounting/`
- **Python deps**: anthropic (v0.88.0), openai (v2.30.0) — installed with `--break-system-packages`
## Deployment Commands
```bash
# Deploy module to server
ssh odoo-westin "docker exec -u 0 odoo-dev-app rm -rf /mnt/extra-addons/fusion_accounting"
scp -r "K:\Github\Odoo-Modules\fusion_accounting" odoo-westin:/tmp/fusion_accounting
ssh odoo-westin "docker cp /tmp/fusion_accounting odoo-dev-app:/mnt/extra-addons/fusion_accounting && rm -rf /tmp/fusion_accounting"
# Upgrade module (use alt port to avoid conflict with running instance)
ssh odoo-westin "docker exec odoo-dev-app odoo -d westin-v19 -u fusion_accounting --stop-after-init --http-port=8099 -c /etc/odoo/odoo.conf"
# Restart container
ssh odoo-westin "docker restart odoo-dev-app"
# Check logs
ssh odoo-westin "docker logs odoo-dev-app --tail 100"
```
## Security Groups
| Group ID | XML ID | Name | Access |
|---|---|---|---|
| 564 | `group_fusion_accounting_user` | User | Dashboard, chat (read-only tools) |
| 565 | `group_fusion_accounting_manager` | Manager | + Approve/reject, Tier 2 tools, rules |
| 566 | `group_fusion_accounting_admin` | Administrator | + Config, all tools, rule admin |
Auto-assigned: `account.group_account_user` → User, `account.group_account_manager` → Admin
## Models
| Model | Type | Purpose |
|---|---|---|
| `fusion.accounting.session` | Model | Chat sessions with message JSON storage |
| `fusion.accounting.match.history` | Model | Every AI tool call + decision (approved/rejected/pending) |
| `fusion.accounting.rule` | Model | Fusion Rules engine with versioning and auto-promotion |
| `fusion.accounting.tool` | Model | Tool registry (82 tools seeded from XML) |
| `fusion.accounting.dashboard` | TransientModel | Computed health metrics (use `.new()` not `.create()`) |
| `fusion.accounting.agent` | AbstractModel | AI orchestrator |
| `fusion.accounting.adapter.claude` | AbstractModel | Claude tool-calling adapter |
| `fusion.accounting.adapter.openai` | AbstractModel | OpenAI tool-calling adapter |
| `fusion.accounting.scoring` | AbstractModel | Confidence scoring |
| `account.move` (inherit) | Model | Post-action audit hook |
## AI Models Available
**Claude** (default: claude-sonnet-4-6):
- claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4-5
- claude-sonnet-4-5, claude-opus-4-5, claude-sonnet-4-0, claude-opus-4-0
**OpenAI** (default: gpt-5.4-mini):
- gpt-5.4, gpt-5.4-mini, gpt-5.4-nano
- o3, o4-mini
- gpt-4o, gpt-4o-mini (legacy)
## Theme / Styling Rules
- NO hardcoded colours — use CSS variables (`var(--o-border-color)`, `var(--bs-body-color-rgb)`) and Bootstrap utility classes
- Must work in both light and dark mode
- Box shadows: use `rgba(var(--bs-body-color-rgb), 0.1)` not `rgba(0,0,0,0.1)`
## Known Issues / Future Work
- `read_group()` deprecation warnings — migrate to `_read_group()` when format is documented
- `verify_source_deductions`, `generate_t4`, `generate_roe` are stubs pointing to fusion_payroll (by design — Phase 2)
- `account.return` model used in HST tools may not exist in all Odoo 19 setups — needs try/except guard
- Batch approval "Approve All" / "Reject All" buttons are in the chat panel but not yet in the match history list view

View File

@@ -0,0 +1,4 @@
from . import models
from . import services
from . import controllers
from . import wizards

View File

@@ -0,0 +1,61 @@
{
'name': 'Fusion Accounting AI',
'version': '19.0.1.0.0',
'category': 'Accounting/Accounting',
'sequence': 25,
'summary': 'AI Accounting Co-Pilot with conversational interface and automated analysis',
'description': """
Fusion Accounting AI
====================
An AI-powered accounting co-pilot that embeds Claude/GPT into the Odoo Accounting
module. Features conversational bank reconciliation, HST management, AR/AP analysis,
audit scanning, and a comprehensive dashboard.
Built by Nexa Systems Inc.
""",
'icon': '/fusion_accounting/static/description/icon.png',
'author': 'Nexa Systems Inc.',
'website': 'https://nexasystems.ca',
'support': 'support@nexasystems.ca',
'maintainer': 'Nexa Systems Inc.',
'depends': [
'account',
'account_accountant',
'account_reports',
'account_followup',
'mail',
],
'external_dependencies': {
'python': ['anthropic', 'openai'],
},
'data': [
# Security
'security/security.xml',
'security/ir.model.access.csv',
# Data
'data/cron.xml',
'data/tool_definitions.xml',
'data/default_rules.xml',
# Views
'views/config_views.xml',
'views/session_views.xml',
'views/match_history_views.xml',
'views/rule_views.xml',
'views/dashboard_views.xml',
'views/menus.xml',
# Wizards
'wizards/rule_wizard.xml',
# Reports
'report/audit_report_template.xml',
],
'installable': True,
'application': False,
'license': 'OPL-1',
'assets': {
'web.assets_backend': [
'fusion_accounting/static/src/**/*.js',
'fusion_accounting/static/src/**/*.xml',
'fusion_accounting/static/src/**/*.scss',
],
},
}

View File

@@ -0,0 +1 @@
from . import chat_controller

View File

@@ -0,0 +1,126 @@
import json
import logging
from odoo import http
from odoo.http import request
_logger = logging.getLogger(__name__)
class FusionAccountingChatController(http.Controller):
@http.route('/fusion_accounting/session/create', type='jsonrpc', auth='user')
def create_session(self, context_domain=None, **kwargs):
session = request.env['fusion.accounting.session'].create({
'user_id': request.env.user.id,
'company_id': request.env.company.id,
'context_domain': context_domain,
})
return {'session_id': session.id, 'name': session.name}
@http.route('/fusion_accounting/session/close', type='jsonrpc', auth='user')
def close_session(self, session_id, **kwargs):
session = request.env['fusion.accounting.session'].browse(int(session_id))
if session.exists() and session.state == 'active':
session.action_close_session()
return {'status': 'closed'}
@http.route('/fusion_accounting/chat', type='jsonrpc', auth='user')
def chat(self, session_id, message, context=None, **kwargs):
if not message:
return {'error': 'Message is required'}
agent = request.env['fusion.accounting.agent']
result = agent.chat(int(session_id), message, context=context)
return result
@http.route('/fusion_accounting/approve', type='jsonrpc', auth='user')
def approve_action(self, match_history_id, **kwargs):
if not request.env.user.has_group('fusion_accounting.group_fusion_accounting_manager'):
return {'error': 'Insufficient permissions to approve actions'}
agent = request.env['fusion.accounting.agent']
result = agent.approve_action(int(match_history_id))
return result
@http.route('/fusion_accounting/reject', type='jsonrpc', auth='user')
def reject_action(self, match_history_id, reason='', **kwargs):
if not request.env.user.has_group('fusion_accounting.group_fusion_accounting_manager'):
return {'error': 'Insufficient permissions to reject actions'}
agent = request.env['fusion.accounting.agent']
result = agent.reject_action(int(match_history_id), reason)
return result
@http.route('/fusion_accounting/dashboard/data', type='jsonrpc', auth='user')
def dashboard_data(self, **kwargs):
dashboard = request.env['fusion.accounting.dashboard'].new({
'company_id': request.env.company.id,
})
return {
'bank_recon': {'count': dashboard.bank_recon_count, 'amount': dashboard.bank_recon_amount},
'ar': {'total': dashboard.ar_total, 'overdue_count': dashboard.ar_overdue_count},
'ap': {'total': dashboard.ap_total, 'due_this_week': dashboard.ap_due_this_week},
'hst': {'balance': dashboard.hst_balance},
'audit': {'score': dashboard.audit_score, 'flags': dashboard.audit_flag_count},
'month_end': {'status': dashboard.month_end_status, 'open_items': dashboard.month_end_open_items},
}
@http.route('/fusion_accounting/approve_all', type='jsonrpc', auth='user')
def approve_all(self, match_history_ids, **kwargs):
if not request.env.user.has_group('fusion_accounting.group_fusion_accounting_manager'):
return {'error': 'Insufficient permissions to approve actions'}
agent = request.env['fusion.accounting.agent']
results = []
for mid in match_history_ids:
try:
result = agent.approve_action(int(mid))
results.append({'id': mid, 'status': 'approved', 'result': result})
except Exception as e:
results.append({'id': mid, 'status': 'error', 'error': str(e)})
return {'results': results}
@http.route('/fusion_accounting/reject_all', type='jsonrpc', auth='user')
def reject_all(self, match_history_ids, reason='', **kwargs):
if not request.env.user.has_group('fusion_accounting.group_fusion_accounting_manager'):
return {'error': 'Insufficient permissions to reject actions'}
agent = request.env['fusion.accounting.agent']
results = []
for mid in match_history_ids:
try:
result = agent.reject_action(int(mid), reason)
results.append({'id': mid, 'status': 'rejected'})
except Exception as e:
results.append({'id': mid, 'status': 'error', 'error': str(e)})
return {'results': results}
@http.route('/fusion_accounting/session/latest', type='jsonrpc', auth='user')
def session_latest(self, **kwargs):
session = request.env['fusion.accounting.session'].search([
('user_id', '=', request.env.user.id),
('state', '=', 'active'),
], limit=1, order='create_date desc')
if not session:
return {'session_id': None, 'messages': [], 'name': None}
messages = json.loads(session.message_ids_json or '[]')
display_messages = []
for msg in messages:
if isinstance(msg.get('content'), str) and msg['content'].strip():
display_messages.append(msg)
elif isinstance(msg.get('content'), list):
for block in msg['content']:
if isinstance(block, dict) and block.get('type') == 'text' and block['text'].strip():
display_messages.append({'role': msg['role'], 'content': block['text']})
return {
'session_id': session.id,
'messages': display_messages,
'name': session.name,
}
@http.route('/fusion_accounting/session/history', type='jsonrpc', auth='user')
def session_history(self, session_id, **kwargs):
session = request.env['fusion.accounting.session'].browse(int(session_id))
if not session.exists():
return {'error': 'Session not found'}
return {
'messages': json.loads(session.message_ids_json or '[]'),
'session_id': session.id,
'state': session.state,
}

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<!-- Session name sequence -->
<record id="seq_fusion_accounting_session" model="ir.sequence">
<field name="name">Fusion AI Session</field>
<field name="code">fusion.accounting.session</field>
<field name="prefix">FAS/%(year)s/</field>
<field name="padding">5</field>
</record>
<!-- Daily audit scan: expire stale pending approvals -->
<record id="cron_fusion_audit_scan" model="ir.cron">
<field name="name">Fusion AI: Periodic Audit Scan</field>
<field name="model_id" ref="model_fusion_accounting_match_history"/>
<field name="state">code</field>
<field name="code">
cutoff = datetime.datetime.now() - datetime.timedelta(days=30)
stale = model.search([('decision', '=', 'pending'), ('proposed_at', '&lt;', cutoff.strftime('%Y-%m-%d %H:%M:%S'))])
stale.write({'decision': 'rejected', 'rejection_reason': 'Auto-expired after 30 days'})
</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active">True</field>
</record>
<!-- Weekly tier promotion check -->
<record id="cron_fusion_tier_promotion" model="ir.cron">
<field name="name">Fusion AI: Tier Promotion Check</field>
<field name="model_id" ref="model_fusion_accounting_rule"/>
<field name="state">code</field>
<field name="code">
for rule in model.search([('active', '=', True), ('approval_tier', '=', 'needs_approval')]):
rule._check_promotion()
</field>
<field name="interval_number">7</field>
<field name="interval_type">days</field>
<field name="active">True</field>
</record>
</odoo>

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="rule_elavon_fee" model="fusion.accounting.rule">
<field name="name">Elavon Card Processing Fee</field>
<field name="rule_type">fee</field>
<field name="description">Elavon merchant service charges typically show as a fee deducted from card payment batches. The fee is approximately 1.5-1.8% of the gross batch amount and should be allocated to the Elavon Fee expense account.</field>
<field name="match_logic">When a bank statement line contains "elavon" or "mrch svc" and the amount is less than the sum of matching card payments, allocate the difference to the fee account as a processing fee.</field>
<field name="created_by">admin</field>
<field name="approval_tier">needs_approval</field>
<field name="sequence">10</field>
</record>
<record id="rule_weekend_batch" model="fusion.accounting.rule">
<field name="name">Weekend Card Batch Combination</field>
<field name="rule_type">match</field>
<field name="description">Card payment batches deposited on Monday often combine Friday, Saturday, and Sunday transactions. When matching Monday bank deposits to card payments, look across the preceding weekend.</field>
<field name="match_logic">For bank lines dated Monday with card-related labels, sum card payments from the preceding Friday through Sunday to find a match.</field>
<field name="created_by">admin</field>
<field name="approval_tier">needs_approval</field>
<field name="sequence">20</field>
</record>
</odoo>

View File

@@ -0,0 +1,700 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<!-- Domain 1: Bank Reconciliation -->
<record id="tool_get_unreconciled_bank_lines" model="fusion.accounting.tool">
<field name="name">get_unreconciled_bank_lines</field>
<field name="display_name_field">Get Unreconciled Bank Lines</field>
<field name="description">List unreconciled bank statement lines with optional filters for journal, date range, and minimum amount.</field>
<field name="domain">bank_reconciliation</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"journal_id": {"type": "integer", "description": "Journal ID to filter by"}, "date_from": {"type": "string", "description": "Start date (YYYY-MM-DD)"}, "date_to": {"type": "string", "description": "End date (YYYY-MM-DD)"}, "min_amount": {"type": "number", "description": "Minimum absolute amount"}, "limit": {"type": "integer", "description": "Max records to return", "default": 50}}}</field>
<field name="odoo_method">account.bank.statement.line.search_read</field>
</record>
<record id="tool_get_unreconciled_receipts" model="fusion.accounting.tool">
<field name="name">get_unreconciled_receipts</field>
<field name="display_name_field">Get Unreconciled Receipts</field>
<field name="description">List unreconciled Outstanding Receipts entries on the specified account (default 1122).</field>
<field name="domain">bank_reconciliation</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"account_code": {"type": "string", "description": "Account code prefix", "default": "1122"}}}</field>
</record>
<record id="tool_match_bank_line_to_payments" model="fusion.accounting.tool">
<field name="name">match_bank_line_to_payments</field>
<field name="display_name_field">Match Bank Line to Payments</field>
<field name="description">Match a bank statement line to one or more payment journal items for reconciliation.</field>
<field name="domain">bank_reconciliation</field>
<field name="tier">3</field>
<field name="parameters_schema">{"type": "object", "properties": {"statement_line_id": {"type": "integer", "description": "Bank statement line ID"}, "move_line_ids": {"type": "array", "items": {"type": "integer"}, "description": "Journal item IDs to match"}}, "required": ["statement_line_id", "move_line_ids"]}</field>
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
</record>
<record id="tool_auto_reconcile_bank_lines" model="fusion.accounting.tool">
<field name="name">auto_reconcile_bank_lines</field>
<field name="display_name_field">Auto-Reconcile Bank Lines</field>
<field name="description">Run Odoo's built-in auto-reconciliation engine on all unreconciled bank statement lines.</field>
<field name="domain">bank_reconciliation</field>
<field name="tier">3</field>
<field name="parameters_schema">{"type": "object", "properties": {"company_id": {"type": "integer"}}}</field>
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
</record>
<record id="tool_apply_reconcile_model" model="fusion.accounting.tool">
<field name="name">apply_reconcile_model</field>
<field name="display_name_field">Apply Reconciliation Model</field>
<field name="description">Apply a specific reconciliation model to a bank statement line.</field>
<field name="domain">bank_reconciliation</field>
<field name="tier">3</field>
<field name="parameters_schema">{"type": "object", "properties": {"model_id": {"type": "integer"}, "statement_line_id": {"type": "integer"}}, "required": ["model_id", "statement_line_id"]}</field>
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
</record>
<record id="tool_unmatch_bank_line" model="fusion.accounting.tool">
<field name="name">unmatch_bank_line</field>
<field name="display_name_field">Unmatch Bank Line</field>
<field name="description">Undo a bank statement line reconciliation.</field>
<field name="domain">bank_reconciliation</field>
<field name="tier">3</field>
<field name="parameters_schema">{"type": "object", "properties": {"statement_line_id": {"type": "integer"}}, "required": ["statement_line_id"]}</field>
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
</record>
<record id="tool_get_reconcile_suggestions" model="fusion.accounting.tool">
<field name="name">get_reconcile_suggestions</field>
<field name="display_name_field">Get Reconciliation Suggestions</field>
<field name="description">Get available reconciliation models for a bank statement line.</field>
<field name="domain">bank_reconciliation</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"statement_line_id": {"type": "integer"}}, "required": ["statement_line_id"]}</field>
</record>
<record id="tool_sum_payments_by_date" model="fusion.accounting.tool">
<field name="name">sum_payments_by_date</field>
<field name="display_name_field">Sum Payments by Date</field>
<field name="description">Sum payment journal items for a date range, useful for matching card batch deposits.</field>
<field name="domain">bank_reconciliation</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}, "journal_ids": {"type": "array", "items": {"type": "integer"}}}, "required": ["date_from", "date_to"]}</field>
</record>
<!-- Domain 2: HST/GST Management -->
<record id="tool_calculate_hst_balance" model="fusion.accounting.tool">
<field name="name">calculate_hst_balance</field>
<field name="display_name_field">Calculate HST Balance</field>
<field name="description">Calculate net HST position (collected minus ITCs) for a period.</field>
<field name="domain">hst_management</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
</record>
<record id="tool_get_tax_report" model="fusion.accounting.tool">
<field name="name">get_tax_report</field>
<field name="display_name_field">Get Tax Report</field>
<field name="description">Generate a tax report for a period using Odoo's reporting engine.</field>
<field name="domain">hst_management</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}, "report_ref": {"type": "string", "default": "account.generic_tax_report"}}}</field>
</record>
<record id="tool_find_missing_tax_invoices" model="fusion.accounting.tool">
<field name="name">find_missing_tax_invoices</field>
<field name="display_name_field">Find Missing Tax Invoices</field>
<field name="description">Find customer invoices with taxable products but no tax applied.</field>
<field name="domain">hst_management</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
</record>
<record id="tool_find_missing_itc_bills" model="fusion.accounting.tool">
<field name="name">find_missing_itc_bills</field>
<field name="display_name_field">Find Missing ITC Bills</field>
<field name="description">Find vendor bills without input tax credits.</field>
<field name="domain">hst_management</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
</record>
<record id="tool_get_tax_return_status" model="fusion.accounting.tool">
<field name="name">get_tax_return_status</field>
<field name="display_name_field">Get Tax Return Status</field>
<field name="description">Check the status of periodic tax returns.</field>
<field name="domain">hst_management</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
</record>
<record id="tool_generate_tax_return" model="fusion.accounting.tool">
<field name="name">generate_tax_return</field>
<field name="display_name_field">Generate Tax Return</field>
<field name="description">Refresh all tax return data.</field>
<field name="domain">hst_management</field>
<field name="tier">2</field>
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
</record>
<record id="tool_validate_tax_return" model="fusion.accounting.tool">
<field name="name">validate_tax_return</field>
<field name="display_name_field">Validate Tax Return</field>
<field name="description">Mark a tax return as validated.</field>
<field name="domain">hst_management</field>
<field name="tier">3</field>
<field name="parameters_schema">{"type": "object", "properties": {"return_id": {"type": "integer"}}, "required": ["return_id"]}</field>
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
</record>
<!-- Domain 3: Accounts Receivable -->
<record id="tool_get_ar_aging" model="fusion.accounting.tool">
<field name="name">get_ar_aging</field>
<field name="display_name_field">Get AR Aging</field>
<field name="description">Get accounts receivable aging buckets (current, 30, 60, 90+ days).</field>
<field name="domain">accounts_receivable</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
</record>
<record id="tool_get_overdue_invoices" model="fusion.accounting.tool">
<field name="name">get_overdue_invoices</field>
<field name="display_name_field">Get Overdue Invoices</field>
<field name="description">List invoices past due with partner contact information.</field>
<field name="domain">accounts_receivable</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"min_days_overdue": {"type": "integer", "default": 1}, "limit": {"type": "integer", "default": 50}}}</field>
</record>
<record id="tool_get_partner_balance" model="fusion.accounting.tool">
<field name="name">get_partner_balance</field>
<field name="display_name_field">Get Partner Balance</field>
<field name="description">Get a single partner's AR balance and open items.</field>
<field name="domain">accounts_receivable</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"partner_id": {"type": "integer"}}, "required": ["partner_id"]}</field>
</record>
<record id="tool_send_followup" model="fusion.accounting.tool">
<field name="name">send_followup</field>
<field name="display_name_field">Send Follow-Up</field>
<field name="description">Draft and send a follow-up email to a partner about overdue invoices.</field>
<field name="domain">accounts_receivable</field>
<field name="tier">2</field>
<field name="parameters_schema">{"type": "object", "properties": {"partner_id": {"type": "integer"}, "send_email": {"type": "boolean"}, "print_letter": {"type": "boolean"}, "email_subject": {"type": "string"}, "body": {"type": "string"}}, "required": ["partner_id"]}</field>
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
</record>
<record id="tool_get_followup_report" model="fusion.accounting.tool">
<field name="name">get_followup_report</field>
<field name="display_name_field">Get Follow-Up Report</field>
<field name="description">Get the HTML follow-up report for a partner.</field>
<field name="domain">accounts_receivable</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"partner_id": {"type": "integer"}}, "required": ["partner_id"]}</field>
</record>
<record id="tool_reconcile_payment_to_invoice" model="fusion.accounting.tool">
<field name="name">reconcile_payment_to_invoice</field>
<field name="display_name_field">Reconcile Payment to Invoice</field>
<field name="description">Match a payment to an invoice by reconciling journal items.</field>
<field name="domain">accounts_receivable</field>
<field name="tier">3</field>
<field name="parameters_schema">{"type": "object", "properties": {"move_line_ids": {"type": "array", "items": {"type": "integer"}}}, "required": ["move_line_ids"]}</field>
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
</record>
<record id="tool_get_unmatched_payments" model="fusion.accounting.tool">
<field name="name">get_unmatched_payments</field>
<field name="display_name_field">Get Unmatched Payments</field>
<field name="description">List payments not matched to invoices.</field>
<field name="domain">accounts_receivable</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
</record>
<!-- Domain 4: Accounts Payable -->
<record id="tool_get_ap_aging" model="fusion.accounting.tool">
<field name="name">get_ap_aging</field>
<field name="display_name_field">Get AP Aging</field>
<field name="description">Get accounts payable aging buckets.</field>
<field name="domain">accounts_payable</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
</record>
<record id="tool_find_duplicate_bills" model="fusion.accounting.tool">
<field name="name">find_duplicate_bills</field>
<field name="display_name_field">Find Duplicate Bills</field>
<field name="description">Detect potential duplicate vendor bills (same vendor + amount + date within window).</field>
<field name="domain">accounts_payable</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"window_days": {"type": "integer", "default": 7}}}</field>
</record>
<record id="tool_match_bill_to_po" model="fusion.accounting.tool">
<field name="name">match_bill_to_po</field>
<field name="display_name_field">Match Bill to PO</field>
<field name="description">Cross-reference bill lines to purchase order lines.</field>
<field name="domain">accounts_payable</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"bill_id": {"type": "integer"}}, "required": ["bill_id"]}</field>
</record>
<record id="tool_get_unpaid_bills" model="fusion.accounting.tool">
<field name="name">get_unpaid_bills</field>
<field name="display_name_field">Get Unpaid Bills</field>
<field name="description">List vendor bills with outstanding balance.</field>
<field name="domain">accounts_payable</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"partner_id": {"type": "integer"}, "limit": {"type": "integer", "default": 50}}}</field>
</record>
<record id="tool_verify_bill_taxes" model="fusion.accounting.tool">
<field name="name">verify_bill_taxes</field>
<field name="display_name_field">Verify Bill Taxes</field>
<field name="description">Check that bill tax matches fiscal position expectation.</field>
<field name="domain">accounts_payable</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"bill_id": {"type": "integer"}}, "required": ["bill_id"]}</field>
</record>
<record id="tool_get_payment_schedule" model="fusion.accounting.tool">
<field name="name">get_payment_schedule</field>
<field name="display_name_field">Get Payment Schedule</field>
<field name="description">Bills sorted by due date for cash planning.</field>
<field name="domain">accounts_payable</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"days_ahead": {"type": "integer", "default": 30}}}</field>
</record>
<!-- Domain 5: Journal Review -->
<record id="tool_find_wrong_direction_balances" model="fusion.accounting.tool">
<field name="name">find_wrong_direction_balances</field>
<field name="display_name_field">Find Wrong Direction Balances</field>
<field name="description">Find accounts where balance direction contradicts account type.</field>
<field name="domain">journal_review</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
</record>
<record id="tool_find_duplicate_entries" model="fusion.accounting.tool">
<field name="name">find_duplicate_entries</field>
<field name="display_name_field">Find Duplicate Entries</field>
<field name="description">Detect entries with matching partner + amount + date + journal.</field>
<field name="domain">journal_review</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
</record>
<record id="tool_find_wrong_account_entries" model="fusion.accounting.tool">
<field name="name">find_wrong_account_entries</field>
<field name="display_name_field">Find Wrong Account Entries</field>
<field name="description">Product lines on unlikely accounts (e.g., revenue on tax account).</field>
<field name="domain">journal_review</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
</record>
<record id="tool_find_sequence_gaps" model="fusion.accounting.tool">
<field name="name">find_sequence_gaps</field>
<field name="display_name_field">Find Sequence Gaps</field>
<field name="description">Find journal entries where made_sequence_gap is true.</field>
<field name="domain">journal_review</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
</record>
<record id="tool_find_draft_entries" model="fusion.accounting.tool">
<field name="name">find_draft_entries</field>
<field name="display_name_field">Find Draft Entries</field>
<field name="description">Draft entries older than specified days that should be posted or deleted.</field>
<field name="domain">journal_review</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"min_age_days": {"type": "integer", "default": 30}}}</field>
</record>
<record id="tool_find_unreconciled_suspense" model="fusion.accounting.tool">
<field name="name">find_unreconciled_suspense</field>
<field name="display_name_field">Find Unreconciled Suspense</field>
<field name="description">Suspense/clearing accounts with non-zero balance.</field>
<field name="domain">journal_review</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
</record>
<record id="tool_verify_reconciliation_integrity" model="fusion.accounting.tool">
<field name="name">verify_reconciliation_integrity</field>
<field name="display_name_field">Verify Reconciliation Integrity</field>
<field name="description">Check account.partial.reconcile consistency.</field>
<field name="domain">journal_review</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
</record>
<!-- Domain 6: Month-End -->
<record id="tool_get_close_checklist" model="fusion.accounting.tool">
<field name="name">get_close_checklist</field>
<field name="display_name_field">Get Close Checklist</field>
<field name="description">Aggregate all domain checks into a period close checklist.</field>
<field name="domain">month_end</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"period": {"type": "string", "description": "YYYY-MM format"}}}</field>
</record>
<record id="tool_get_unreconciled_counts" model="fusion.accounting.tool">
<field name="name">get_unreconciled_counts</field>
<field name="display_name_field">Get Unreconciled Counts</field>
<field name="description">Per-account count of unreconciled items.</field>
<field name="domain">month_end</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
</record>
<record id="tool_find_entries_in_locked_period" model="fusion.accounting.tool">
<field name="name">find_entries_in_locked_period</field>
<field name="display_name_field">Find Entries in Locked Period</field>
<field name="description">Find entries after lock dates.</field>
<field name="domain">month_end</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
</record>
<record id="tool_get_accrual_status" model="fusion.accounting.tool">
<field name="name">get_accrual_status</field>
<field name="display_name_field">Get Accrual Status</field>
<field name="description">Balance on accrual accounts (vacation, sick, etc.).</field>
<field name="domain">month_end</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"account_codes": {"type": "array", "items": {"type": "string"}}}}</field>
</record>
<record id="tool_run_hash_integrity_check" model="fusion.accounting.tool">
<field name="name">run_hash_integrity_check</field>
<field name="display_name_field">Run Hash Integrity Check</field>
<field name="description">Verify journal entry hash chain integrity.</field>
<field name="domain">month_end</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
</record>
<record id="tool_get_period_summary" model="fusion.accounting.tool">
<field name="name">get_period_summary</field>
<field name="display_name_field">Get Period Summary</field>
<field name="description">Trial balance for the closing period.</field>
<field name="domain">month_end</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}, "required": ["date_from", "date_to"]}</field>
</record>
<!-- Domain 7: Payroll Verification -->
<record id="tool_get_payroll_entries" model="fusion.accounting.tool">
<field name="name">get_payroll_entries</field>
<field name="display_name_field">Get Payroll Entries</field>
<field name="description">Journal entries in payroll-related journals.</field>
<field name="domain">payroll_verification</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}, "journal_id": {"type": "integer"}}}</field>
</record>
<record id="tool_compare_payroll_to_bank" model="fusion.accounting.tool">
<field name="name">compare_payroll_to_bank</field>
<field name="display_name_field">Compare Payroll to Bank</field>
<field name="description">Cross-reference payroll cheques to bank statement lines.</field>
<field name="domain">payroll_verification</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}, "required": ["date_from", "date_to"]}</field>
</record>
<record id="tool_verify_source_deductions" model="fusion.accounting.tool">
<field name="name">verify_source_deductions</field>
<field name="display_name_field">Verify Source Deductions</field>
<field name="description">CPP + EI + tax calculation verification against CRA tables.</field>
<field name="domain">payroll_verification</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
</record>
<record id="tool_get_cra_remittance_status" model="fusion.accounting.tool">
<field name="name">get_cra_remittance_status</field>
<field name="display_name_field">Get CRA Remittance Status</field>
<field name="description">CRA payable balance vs payments made.</field>
<field name="domain">payroll_verification</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
</record>
<record id="tool_find_unmatched_payroll_cheques" model="fusion.accounting.tool">
<field name="name">find_unmatched_payroll_cheques</field>
<field name="display_name_field">Find Unmatched Payroll Cheques</field>
<field name="description">Bank cheques without matching payroll entry.</field>
<field name="domain">payroll_verification</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
</record>
<!-- Domain 8: Inventory -->
<record id="tool_get_stock_valuation" model="fusion.accounting.tool">
<field name="name">get_stock_valuation</field>
<field name="display_name_field">Get Stock Valuation</field>
<field name="description">Stock In Hand balance and layers.</field>
<field name="domain">inventory</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
</record>
<record id="tool_get_price_differences" model="fusion.accounting.tool">
<field name="name">get_price_differences</field>
<field name="display_name_field">Get Price Differences</field>
<field name="description">Entries on price difference account (PO price vs bill price).</field>
<field name="domain">inventory</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
</record>
<record id="tool_get_cogs_ratio_by_category" model="fusion.accounting.tool">
<field name="name">get_cogs_ratio_by_category</field>
<field name="display_name_field">Get COGS Ratio</field>
<field name="description">COGS vs revenue per product category.</field>
<field name="domain">inventory</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
</record>
<record id="tool_find_unusual_adjustments" model="fusion.accounting.tool">
<field name="name">find_unusual_adjustments</field>
<field name="display_name_field">Find Unusual Adjustments</field>
<field name="description">Large inventory adjustment entries.</field>
<field name="domain">inventory</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"threshold": {"type": "number", "default": 1000}}}</field>
</record>
<record id="tool_get_inventory_turnover" model="fusion.accounting.tool">
<field name="name">get_inventory_turnover</field>
<field name="display_name_field">Get Inventory Turnover</field>
<field name="description">Sales vs average inventory calculation.</field>
<field name="domain">inventory</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
</record>
<!-- Domain 9: ADP -->
<record id="tool_get_adp_receivable_aging" model="fusion.accounting.tool">
<field name="name">get_adp_receivable_aging</field>
<field name="display_name_field">Get ADP Receivable Aging</field>
<field name="description">Aging on ADP Receivable account (1101).</field>
<field name="domain">adp</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
</record>
<record id="tool_match_adp_payment_to_invoice" model="fusion.accounting.tool">
<field name="name">match_adp_payment_to_invoice</field>
<field name="display_name_field">Match ADP Payment to Invoice</field>
<field name="description">Match ADP deposit to ADP invoices.</field>
<field name="domain">adp</field>
<field name="tier">3</field>
<field name="parameters_schema">{"type": "object", "properties": {"move_line_ids": {"type": "array", "items": {"type": "integer"}}}, "required": ["move_line_ids"]}</field>
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
</record>
<record id="tool_verify_adp_split" model="fusion.accounting.tool">
<field name="name">verify_adp_split</field>
<field name="display_name_field">Verify ADP Split</field>
<field name="description">Check customer + ADP portion = invoice total.</field>
<field name="domain">adp</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"invoice_id": {"type": "integer"}}, "required": ["invoice_id"]}</field>
</record>
<record id="tool_find_adp_without_payment" model="fusion.accounting.tool">
<field name="name">find_adp_without_payment</field>
<field name="display_name_field">Find ADP Without Payment</field>
<field name="description">ADP invoices without matching government deposit.</field>
<field name="domain">adp</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
</record>
<record id="tool_get_adp_summary" model="fusion.accounting.tool">
<field name="name">get_adp_summary</field>
<field name="display_name_field">Get ADP Summary</field>
<field name="description">Period summary of ADP billing vs collection.</field>
<field name="domain">adp</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
</record>
<!-- Domain 10: Reporting -->
<record id="tool_get_profit_loss" model="fusion.accounting.tool">
<field name="name">get_profit_loss</field>
<field name="display_name_field">Get Profit &amp; Loss</field>
<field name="description">Generate P&amp;L report for a period.</field>
<field name="domain">reporting</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
</record>
<record id="tool_get_balance_sheet" model="fusion.accounting.tool">
<field name="name">get_balance_sheet</field>
<field name="display_name_field">Get Balance Sheet</field>
<field name="description">Generate balance sheet report.</field>
<field name="domain">reporting</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
</record>
<record id="tool_get_trial_balance" model="fusion.accounting.tool">
<field name="name">get_trial_balance</field>
<field name="display_name_field">Get Trial Balance</field>
<field name="description">Generate trial balance report.</field>
<field name="domain">reporting</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
</record>
<record id="tool_get_cash_flow" model="fusion.accounting.tool">
<field name="name">get_cash_flow</field>
<field name="display_name_field">Get Cash Flow</field>
<field name="description">Generate cash flow statement.</field>
<field name="domain">reporting</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
</record>
<record id="tool_compare_periods" model="fusion.accounting.tool">
<field name="name">compare_periods</field>
<field name="display_name_field">Compare Periods</field>
<field name="description">Two period reports side by side for comparison.</field>
<field name="domain">reporting</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"report_ref": {"type": "string"}, "period1_from": {"type": "string"}, "period1_to": {"type": "string"}, "period2_from": {"type": "string"}, "period2_to": {"type": "string"}}, "required": ["period1_from", "period1_to", "period2_from", "period2_to"]}</field>
</record>
<record id="tool_answer_financial_question" model="fusion.accounting.tool">
<field name="name">answer_financial_question</field>
<field name="display_name_field">Answer Financial Question</field>
<field name="description">Natural language to report query for financial questions.</field>
<field name="domain">reporting</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"question": {"type": "string"}}, "required": ["question"]}</field>
</record>
<record id="tool_export_report" model="fusion.accounting.tool">
<field name="name">export_report</field>
<field name="display_name_field">Export Report</field>
<field name="description">Export a report to PDF or XLSX.</field>
<field name="domain">reporting</field>
<field name="tier">2</field>
<field name="parameters_schema">{"type": "object", "properties": {"report_ref": {"type": "string"}, "format": {"type": "string", "enum": ["pdf", "xlsx"]}, "date_from": {"type": "string"}, "date_to": {"type": "string"}}, "required": ["report_ref"]}</field>
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
</record>
<!-- Domain 11: Audit -->
<record id="tool_audit_posted_entry" model="fusion.accounting.tool">
<field name="name">audit_posted_entry</field>
<field name="display_name_field">Audit Posted Entry</field>
<field name="description">Run all entry-level checks on a single journal entry.</field>
<field name="domain">audit</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"move_id": {"type": "integer"}}, "required": ["move_id"]}</field>
</record>
<record id="tool_audit_account_balances" model="fusion.accounting.tool">
<field name="name">audit_account_balances</field>
<field name="display_name_field">Audit Account Balances</field>
<field name="description">Run all account-level checks (wrong direction, stale items).</field>
<field name="domain">audit</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
</record>
<record id="tool_audit_tax_compliance" model="fusion.accounting.tool">
<field name="name">audit_tax_compliance</field>
<field name="display_name_field">Audit Tax Compliance</field>
<field name="description">All tax checks (missing tax, wrong rate, exempt verification).</field>
<field name="domain">audit</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
</record>
<record id="tool_audit_reconciliation_integrity" model="fusion.accounting.tool">
<field name="name">audit_reconciliation_integrity</field>
<field name="display_name_field">Audit Reconciliation Integrity</field>
<field name="description">Verify partial/full reconcile consistency.</field>
<field name="domain">audit</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
</record>
<record id="tool_check_hash_chain" model="fusion.accounting.tool">
<field name="name">check_hash_chain</field>
<field name="display_name_field">Check Hash Chain</field>
<field name="description">Verify journal entry hash chain integrity.</field>
<field name="domain">audit</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
</record>
<record id="tool_check_sequence_gaps" model="fusion.accounting.tool">
<field name="name">check_sequence_gaps</field>
<field name="display_name_field">Check Sequence Gaps</field>
<field name="description">Check for sequence gaps in journals.</field>
<field name="domain">audit</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
</record>
<record id="tool_flag_entry" model="fusion.accounting.tool">
<field name="name">flag_entry</field>
<field name="display_name_field">Flag Entry</field>
<field name="description">Create a chatter note on a journal entry with flag and recommendation.</field>
<field name="domain">audit</field>
<field name="tier">2</field>
<field name="parameters_schema">{"type": "object", "properties": {"move_id": {"type": "integer"}, "flag": {"type": "string"}, "recommendation": {"type": "string"}}, "required": ["move_id"]}</field>
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
</record>
<record id="tool_get_audit_status" model="fusion.accounting.tool">
<field name="name">get_audit_status</field>
<field name="display_name_field">Get Audit Status</field>
<field name="description">Account audit status per tax return.</field>
<field name="domain">audit</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
</record>
<record id="tool_set_audit_status" model="fusion.accounting.tool">
<field name="name">set_audit_status</field>
<field name="display_name_field">Set Audit Status</field>
<field name="description">Update review status (todo / reviewed / supervised / anomaly).</field>
<field name="domain">audit</field>
<field name="tier">2</field>
<field name="parameters_schema">{"type": "object", "properties": {"status_id": {"type": "integer"}, "status": {"type": "string", "enum": ["todo", "reviewed", "supervised", "anomaly"]}}, "required": ["status_id", "status"]}</field>
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
</record>
<record id="tool_get_audit_trail" model="fusion.accounting.tool">
<field name="name">get_audit_trail</field>
<field name="display_name_field">Get Audit Trail</field>
<field name="description">Get mail.message history for a journal entry.</field>
<field name="domain">audit</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"move_id": {"type": "integer"}}, "required": ["move_id"]}</field>
</record>
<record id="tool_run_full_audit" model="fusion.accounting.tool">
<field name="name">run_full_audit</field>
<field name="display_name_field">Run Full Audit</field>
<field name="description">All checks across all domains for a period.</field>
<field name="domain">audit</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
</record>
<record id="tool_get_audit_report" model="fusion.accounting.tool">
<field name="name">get_audit_report</field>
<field name="display_name_field">Get Audit Report</field>
<field name="description">Summary of all audit findings with severity ratings.</field>
<field name="domain">audit</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
</record>
<!-- Domain 12: Payroll Management -->
<record id="tool_parse_payroll_summary" model="fusion.accounting.tool">
<field name="name">parse_payroll_summary</field>
<field name="display_name_field">Parse Payroll Summary</field>
<field name="description">Read pasted/uploaded payroll data from QBO or fusion_payroll.</field>
<field name="domain">payroll_management</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"data": {"type": "string"}}, "required": ["data"]}</field>
</record>
<record id="tool_create_payroll_journal_entry" model="fusion.accounting.tool">
<field name="name">create_payroll_journal_entry</field>
<field name="display_name_field">Create Payroll Journal Entry</field>
<field name="description">Create a payroll journal entry with debit/credit lines.</field>
<field name="domain">payroll_management</field>
<field name="tier">3</field>
<field name="parameters_schema">{"type": "object", "properties": {"journal_id": {"type": "integer"}, "date": {"type": "string"}, "ref": {"type": "string"}, "lines": {"type": "array", "items": {"type": "object", "properties": {"account_id": {"type": "integer"}, "name": {"type": "string"}, "debit": {"type": "number"}, "credit": {"type": "number"}, "partner_id": {"type": "integer"}}}}}, "required": ["journal_id", "date", "lines"]}</field>
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
</record>
<record id="tool_match_payroll_cheques" model="fusion.accounting.tool">
<field name="name">match_payroll_cheques</field>
<field name="display_name_field">Match Payroll Cheques</field>
<field name="description">Match bank cheques to payroll liabilities.</field>
<field name="domain">payroll_management</field>
<field name="tier">3</field>
<field name="parameters_schema">{"type": "object", "properties": {"statement_line_id": {"type": "integer"}, "move_line_ids": {"type": "array", "items": {"type": "integer"}}}, "required": ["statement_line_id", "move_line_ids"]}</field>
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
</record>
<record id="tool_prepare_cra_payment" model="fusion.accounting.tool">
<field name="name">prepare_cra_payment</field>
<field name="display_name_field">Prepare CRA Payment</field>
<field name="description">Create CRA remittance payment entry.</field>
<field name="domain">payroll_management</field>
<field name="tier">3</field>
<field name="parameters_schema">{"type": "object", "properties": {"journal_id": {"type": "integer"}, "date": {"type": "string"}, "lines": {"type": "array"}}, "required": ["journal_id", "date", "lines"]}</field>
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
</record>
<record id="tool_generate_t4" model="fusion.accounting.tool">
<field name="name">generate_t4</field>
<field name="display_name_field">Generate T4</field>
<field name="description">Trigger T4 generation via fusion_payroll.</field>
<field name="domain">payroll_management</field>
<field name="tier">2</field>
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
</record>
<record id="tool_generate_roe" model="fusion.accounting.tool">
<field name="name">generate_roe</field>
<field name="display_name_field">Generate ROE</field>
<field name="description">Trigger ROE generation via fusion_payroll.</field>
<field name="domain">payroll_management</field>
<field name="tier">2</field>
<field name="parameters_schema">{"type": "object", "properties": {}}</field>
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
</record>
<record id="tool_get_payroll_cost_report" model="fusion.accounting.tool">
<field name="name">get_payroll_cost_report</field>
<field name="display_name_field">Get Payroll Cost Report</field>
<field name="description">Period summary by employee/department.</field>
<field name="domain">payroll_management</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
</record>
</odoo>

View File

@@ -0,0 +1,7 @@
from . import accounting_config
from . import accounting_tool
from . import accounting_session
from . import accounting_match_history
from . import accounting_rule
from . import accounting_dashboard
from . import account_move_hook

View File

@@ -0,0 +1,53 @@
import logging
from odoo import models, api
_logger = logging.getLogger(__name__)
class AccountMoveAuditHook(models.Model):
_inherit = 'account.move'
def action_post(self):
res = super().action_post()
ICP = self.env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_accounting.enable_post_audit', 'False') != 'True':
return res
for move in self:
try:
self._fusion_audit_posted_entry(move)
except Exception as e:
_logger.warning("Fusion post-audit hook failed for %s: %s", move.name, e)
return res
def _fusion_audit_posted_entry(self, move):
issues = []
total_debit = sum(l.debit for l in move.line_ids)
total_credit = sum(l.credit for l in move.line_ids)
if abs(total_debit - total_credit) > 0.01:
issues.append(f'Unbalanced: debit={total_debit:.2f}, credit={total_credit:.2f}')
for line in move.line_ids:
if not line.account_id:
issues.append(f'Line missing account: {line.name}')
if line.product_id and not line.tax_ids:
if move.move_type in ('out_invoice', 'out_refund', 'in_invoice', 'in_refund'):
issues.append(f'Missing tax on product line: {line.product_id.name}')
if not move.line_ids:
issues.append('Entry has no lines')
if issues:
body_parts = ['<strong>Fusion AI Auto-Audit</strong><ul>']
for issue in issues:
body_parts.append(f'<li>{issue}</li>')
body_parts.append('</ul>')
move.message_post(
body=''.join(body_parts),
message_type='comment',
subtype_xmlid='mail.mt_note',
)

View File

@@ -0,0 +1,84 @@
import logging
from odoo import models, fields, api
_logger = logging.getLogger(__name__)
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
fusion_ai_provider = fields.Selection(
selection=[('claude', 'Anthropic Claude'), ('openai', 'OpenAI GPT')],
string='AI Provider',
default='claude',
config_parameter='fusion_accounting.ai_provider',
)
fusion_anthropic_api_key = fields.Char(
string='Anthropic API Key (Fusion AI)',
config_parameter='fusion_accounting.anthropic_api_key',
)
fusion_openai_api_key = fields.Char(
string='OpenAI API Key (Fusion AI)',
config_parameter='fusion_accounting.openai_api_key',
)
fusion_claude_model = fields.Selection(
selection=[
('claude-opus-4-6', 'Claude Opus 4.6 (Most Intelligent)'),
('claude-sonnet-4-6', 'Claude Sonnet 4.6 (Best Balance)'),
('claude-haiku-4-5', 'Claude Haiku 4.5 (Fastest)'),
('claude-sonnet-4-5', 'Claude Sonnet 4.5'),
('claude-opus-4-5', 'Claude Opus 4.5'),
('claude-sonnet-4-0', 'Claude Sonnet 4'),
('claude-opus-4-0', 'Claude Opus 4'),
],
string='Claude Model',
default='claude-sonnet-4-6',
config_parameter='fusion_accounting.claude_model',
)
fusion_openai_model = fields.Selection(
selection=[
('gpt-5.4', 'GPT-5.4 (Flagship)'),
('gpt-5.4-mini', 'GPT-5.4 Mini (Fast)'),
('gpt-5.4-nano', 'GPT-5.4 Nano (Cheapest)'),
('o3', 'o3 (Best Reasoning)'),
('o4-mini', 'o4-mini (Fast Reasoning)'),
('gpt-4o', 'GPT-4o (Legacy)'),
('gpt-4o-mini', 'GPT-4o Mini (Legacy)'),
],
string='OpenAI Model',
default='gpt-5.4-mini',
config_parameter='fusion_accounting.openai_model',
)
fusion_tier3_threshold = fields.Float(
string='Tier 3 Promotion Threshold',
default=0.95,
config_parameter='fusion_accounting.tier3_threshold',
help='Accuracy threshold for promoting Tier 3 tools to auto-approved.',
)
fusion_tier3_min_sample = fields.Integer(
string='Tier 3 Minimum Sample Size',
default=30,
config_parameter='fusion_accounting.tier3_min_sample',
)
fusion_audit_cron_frequency = fields.Selection(
selection=[('daily', 'Daily'), ('weekly', 'Weekly'), ('monthly', 'Monthly')],
string='Audit Scan Frequency',
default='daily',
config_parameter='fusion_accounting.audit_cron_frequency',
)
fusion_history_in_prompt = fields.Integer(
string='Match History in Prompt',
default=50,
config_parameter='fusion_accounting.history_in_prompt',
help='Number of recent match history records to include in AI prompt.',
)
fusion_max_tool_calls = fields.Integer(
string='Max Tool Calls Per Turn',
default=20,
config_parameter='fusion_accounting.max_tool_calls',
)
fusion_enable_post_audit = fields.Boolean(
string='Enable Post-Action Audit Hook',
default=False,
config_parameter='fusion_accounting.enable_post_audit',
)

View File

@@ -0,0 +1,278 @@
import json
import logging
from datetime import timedelta
from odoo import models, fields, api
_logger = logging.getLogger(__name__)
class FusionAccountingDashboard(models.TransientModel):
_name = 'fusion.accounting.dashboard'
_description = 'Fusion Accounting Dashboard'
company_id = fields.Many2one(
'res.company', string='Company',
default=lambda self: self.env.company,
)
bank_recon_count = fields.Integer(compute='_compute_bank_recon')
bank_recon_amount = fields.Monetary(
compute='_compute_bank_recon', currency_field='currency_id',
)
ar_total = fields.Monetary(
compute='_compute_ar', currency_field='currency_id',
)
ar_overdue_count = fields.Integer(compute='_compute_ar')
ap_total = fields.Monetary(
compute='_compute_ap', currency_field='currency_id',
)
ap_due_this_week = fields.Integer(compute='_compute_ap')
hst_balance = fields.Monetary(
compute='_compute_hst', currency_field='currency_id',
)
audit_score = fields.Integer(compute='_compute_audit')
audit_flag_count = fields.Integer(compute='_compute_audit')
month_end_status = fields.Char(compute='_compute_month_end')
month_end_open_items = fields.Integer(compute='_compute_month_end')
currency_id = fields.Many2one(
'res.currency', string='Currency',
default=lambda self: self.env.company.currency_id,
)
needs_attention_json = fields.Text(compute='_compute_action_centre')
recent_activity_json = fields.Text(compute='_compute_action_centre')
@api.depends('company_id')
def _compute_bank_recon(self):
for rec in self:
data = self.env['account.bank.statement.line'].read_group(
[('is_reconciled', '=', False), ('company_id', '=', rec.company_id.id)],
['amount:sum'], [],
)
row = data[0] if data else {}
rec.bank_recon_count = row.get('__count', 0)
rec.bank_recon_amount = abs(row.get('amount', 0) or 0)
@api.depends('company_id')
def _compute_ar(self):
for rec in self:
data = self.env['account.move.line'].read_group(
[
('account_id.account_type', '=', 'asset_receivable'),
('parent_state', '=', 'posted'),
('reconciled', '=', False),
('company_id', '=', rec.company_id.id),
],
['amount_residual:sum'], [],
)
row = data[0] if data else {}
rec.ar_total = row.get('amount_residual', 0) or 0
rec.ar_overdue_count = self.env['account.move.line'].search_count([
('account_id.account_type', '=', 'asset_receivable'),
('parent_state', '=', 'posted'),
('reconciled', '=', False),
('date_maturity', '<', fields.Date.today()),
('company_id', '=', rec.company_id.id),
])
@api.depends('company_id')
def _compute_ap(self):
for rec in self:
data = self.env['account.move.line'].read_group(
[
('account_id.account_type', '=', 'liability_payable'),
('parent_state', '=', 'posted'),
('reconciled', '=', False),
('company_id', '=', rec.company_id.id),
],
['amount_residual:sum'], [],
)
row = data[0] if data else {}
rec.ap_total = abs(row.get('amount_residual', 0) or 0)
week_end = fields.Date.today() + timedelta(days=7)
rec.ap_due_this_week = self.env['account.move.line'].search_count([
('account_id.account_type', '=', 'liability_payable'),
('parent_state', '=', 'posted'),
('reconciled', '=', False),
('date_maturity', '<=', week_end),
('company_id', '=', rec.company_id.id),
])
@api.depends('company_id')
def _compute_hst(self):
for rec in self:
collected_data = self.env['account.move.line'].read_group(
[
('account_id.code', '=like', '2005%'),
('parent_state', '=', 'posted'),
('company_id', '=', rec.company_id.id),
],
['balance:sum'], [],
)
itc_data = self.env['account.move.line'].read_group(
[
('account_id.code', '=like', '2006%'),
('parent_state', '=', 'posted'),
('company_id', '=', rec.company_id.id),
],
['balance:sum'], [],
)
collected = abs((collected_data[0] if collected_data else {}).get('balance', 0) or 0)
itcs = abs((itc_data[0] if itc_data else {}).get('balance', 0) or 0)
rec.hst_balance = collected - itcs
@api.depends('company_id')
def _compute_audit(self):
for rec in self:
issues = 0
# Wrong-direction balances via read_group
balance_data = self.env['account.move.line'].read_group(
[('parent_state', '=', 'posted'), ('company_id', '=', rec.company_id.id)],
['balance:sum'], ['account_id'],
)
acct_cache = {}
acct_ids = [row['account_id'][0] for row in balance_data if row.get('account_id')]
if acct_ids:
for acct in self.env['account.account'].browse(acct_ids):
acct_cache[acct.id] = acct.account_type
for row in balance_data:
if not row.get('account_id'):
continue
acct_type = acct_cache.get(row['account_id'][0], '')
balance = row.get('balance', 0) or 0
if acct_type in ('asset_receivable', 'asset_cash', 'asset_current',
'asset_non_current', 'asset_fixed', 'expense',
'expense_depreciation', 'expense_direct_cost'):
if balance < -0.01:
issues += 1
elif acct_type in ('liability_payable', 'liability_current',
'liability_non_current', 'equity', 'income',
'income_other'):
if balance > 0.01:
issues += 1
gaps = self.env['account.move'].search_count([
('state', '=', 'posted'),
('company_id', '=', rec.company_id.id),
('made_sequence_gap', '=', True),
])
issues += gaps
pending_approvals = self.env['fusion.accounting.match.history'].search_count([
('decision', '=', 'pending'),
('company_id', '=', rec.company_id.id),
])
rec.audit_score = max(0, min(100, 100 - issues * 3))
rec.audit_flag_count = issues + pending_approvals
@api.depends('company_id')
def _compute_month_end(self):
for rec in self:
open_items = 0
open_items += self.env['account.bank.statement.line'].search_count([
('is_reconciled', '=', False),
('company_id', '=', rec.company_id.id),
])
open_items += self.env['account.move'].search_count([
('state', '=', 'draft'),
('company_id', '=', rec.company_id.id),
])
suspense_data = self.env['account.move.line'].read_group(
[
('account_id.code', '=like', '999%'),
('parent_state', '=', 'posted'),
('company_id', '=', rec.company_id.id),
],
['balance:sum'], ['account_id'],
)
for row in suspense_data:
if abs(row.get('balance', 0) or 0) > 0.01:
open_items += 1
rec.month_end_open_items = open_items
if open_items == 0:
rec.month_end_status = 'Ready to Close'
elif open_items < 5:
rec.month_end_status = 'Almost Ready'
else:
rec.month_end_status = 'Open'
@api.depends('company_id')
def _compute_action_centre(self):
for rec in self:
attention = []
unrecon = self.env['account.bank.statement.line'].search_count([
('is_reconciled', '=', False),
('company_id', '=', rec.company_id.id),
])
if unrecon > 0:
attention.append({
'priority': 1,
'title': f'{unrecon} unreconciled bank lines',
'domain': 'bank_reconciliation',
'action': 'Review and reconcile bank statement lines',
})
overdue = self.env['account.move'].search_count([
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('payment_state', 'in', ('not_paid', 'partial')),
('invoice_date_due', '<', fields.Date.today()),
('company_id', '=', rec.company_id.id),
])
if overdue > 0:
attention.append({
'priority': 2,
'title': f'{overdue} overdue customer invoices',
'domain': 'accounts_receivable',
'action': 'Send follow-up reminders',
})
pending = self.env['fusion.accounting.match.history'].search_count([
('decision', '=', 'pending'),
('company_id', '=', rec.company_id.id),
])
if pending > 0:
attention.append({
'priority': 0,
'title': f'{pending} AI actions awaiting approval',
'domain': 'audit',
'action': 'Review and approve/reject pending actions',
})
drafts = self.env['account.move'].search_count([
('state', '=', 'draft'),
('date', '<=', fields.Date.today() - timedelta(days=30)),
('company_id', '=', rec.company_id.id),
])
if drafts > 0:
attention.append({
'priority': 3,
'title': f'{drafts} stale draft entries (30+ days)',
'domain': 'journal_review',
'action': 'Post or delete stale draft entries',
})
attention.sort(key=lambda x: x['priority'])
rec.needs_attention_json = json.dumps(attention)
recent = self.env['fusion.accounting.match.history'].search([
('company_id', '=', rec.company_id.id),
], limit=10, order='proposed_at desc')
rec.recent_activity_json = json.dumps([{
'tool': r.tool_name,
'decision': r.decision,
'date': str(r.proposed_at),
'amount': r.amount,
} for r in recent])
def action_refresh(self):
return {
'type': 'ir.actions.client',
'tag': 'fusion_accounting.dashboard',
}

View File

@@ -0,0 +1,81 @@
import logging
from odoo import models, fields, api
_logger = logging.getLogger(__name__)
class FusionAccountingMatchHistory(models.Model):
_name = 'fusion.accounting.match.history'
_description = 'Fusion Accounting Match History'
_order = 'proposed_at desc'
session_id = fields.Many2one(
'fusion.accounting.session', string='Session',
index=True, ondelete='cascade',
)
tool_name = fields.Char(string='Tool Name', required=True, index=True)
tool_params = fields.Text(string='Tool Parameters (JSON)')
tool_result = fields.Text(string='Tool Result (JSON)')
ai_reasoning = fields.Text(string='AI Reasoning')
ai_confidence = fields.Float(string='AI Confidence', digits=(3, 2))
rule_id = fields.Many2one(
'fusion.accounting.rule', string='Applied Rule',
ondelete='set null',
)
proposed_at = fields.Datetime(
string='Proposed At',
default=fields.Datetime.now,
required=True,
)
decision = fields.Selection(
selection=[
('approved', 'Approved'),
('rejected', 'Rejected'),
('pending', 'Pending'),
('auto', 'Auto-Executed'),
],
string='Decision',
default='pending',
index=True,
)
decided_at = fields.Datetime(string='Decided At')
decided_by = fields.Many2one('res.users', string='Decided By')
rejection_reason = fields.Text(string='Rejection Reason')
correct_action = fields.Text(string='Correct Action (JSON)')
bank_statement_line_id = fields.Many2one(
'account.bank.statement.line', string='Bank Statement Line',
ondelete='set null',
)
move_line_ids = fields.Many2many(
'account.move.line', string='Journal Items',
)
amount = fields.Monetary(string='Amount', currency_field='currency_id')
currency_id = fields.Many2one(
'res.currency', string='Currency',
default=lambda self: self.env.company.currency_id,
)
partner_id = fields.Many2one('res.partner', string='Partner')
company_id = fields.Many2one(
'res.company', string='Company',
default=lambda self: self.env.company,
)
def action_approve(self):
self.write({
'decision': 'approved',
'decided_at': fields.Datetime.now(),
'decided_by': self.env.user.id,
})
for rec in self:
if rec.rule_id:
rec.rule_id._record_decision(approved=True)
def action_reject(self):
self.write({
'decision': 'rejected',
'decided_at': fields.Datetime.now(),
'decided_by': self.env.user.id,
})
for rec in self:
if rec.rule_id:
rec.rule_id._record_decision(approved=False)

View File

@@ -0,0 +1,120 @@
import logging
from odoo import models, fields, api
_logger = logging.getLogger(__name__)
class FusionAccountingRule(models.Model):
_name = 'fusion.accounting.rule'
_description = 'Fusion Accounting Rule'
_order = 'sequence, id'
_inherit = ['mail.thread']
name = fields.Char(string='Name', required=True, tracking=True)
rule_type = fields.Selection(
selection=[
('match', 'Match'),
('classify', 'Classify'),
('audit', 'Audit'),
('fee', 'Fee'),
('routing', 'Routing'),
('followup', 'Follow-Up'),
],
string='Type',
required=True,
tracking=True,
)
description = fields.Text(
string='Description',
help='Natural language description read by the AI.',
)
trigger_domain = fields.Text(
string='Trigger Domain (JSON)',
help='Odoo domain filter for matching records.',
)
match_logic = fields.Text(
string='Match Logic',
help='Natural language matching instructions for the AI.',
)
match_code = fields.Text(
string='Match Code (Python)',
help='Optional deterministic Python matching code.',
)
fee_account_id = fields.Many2one(
'account.account', string='Fee Account',
)
write_off_account_id = fields.Many2one(
'account.account', string='Write-Off Account',
)
approval_tier = fields.Selection(
selection=[('auto', 'Auto-Approved'), ('needs_approval', 'Needs Approval')],
string='Approval Tier',
default='needs_approval',
tracking=True,
)
created_by = fields.Selection(
selection=[('admin', 'Admin'), ('ai', 'AI')],
string='Created By',
default='admin',
)
confidence_score = fields.Float(
string='Confidence Score', digits=(3, 2), default=0.0,
)
total_uses = fields.Integer(string='Total Uses', default=0)
total_approved = fields.Integer(string='Total Approved', default=0)
total_rejected = fields.Integer(string='Total Rejected', default=0)
promotion_threshold = fields.Float(
string='Promotion Threshold', default=0.95,
)
min_sample_size = fields.Integer(string='Min Sample Size', default=30)
active = fields.Boolean(string='Active', default=True, tracking=True)
version = fields.Integer(string='Version', default=1)
parent_rule_id = fields.Many2one(
'fusion.accounting.rule', string='Previous Version',
ondelete='set null',
)
journal_ids = fields.Many2many(
'account.journal', string='Journals',
)
company_id = fields.Many2one(
'res.company', string='Company',
default=lambda self: self.env.company,
)
sequence = fields.Integer(string='Sequence', default=10)
notes = fields.Text(string='Notes')
def _record_decision(self, approved=True):
for rec in self:
self.env.cr.execute("""
UPDATE fusion_accounting_rule
SET total_uses = total_uses + 1,
total_approved = total_approved + %s,
total_rejected = total_rejected + %s
WHERE id = %s
RETURNING total_uses, total_approved
""", (int(approved), int(not approved), rec.id))
row = self.env.cr.fetchone()
rec.invalidate_recordset(['total_uses', 'total_approved', 'total_rejected'])
if row and row[0] > 0:
rec.confidence_score = row[1] / row[0]
rec._check_promotion()
def _check_promotion(self):
for rec in self:
if (rec.approval_tier == 'needs_approval'
and rec.total_uses >= rec.min_sample_size
and rec.confidence_score >= rec.promotion_threshold):
rec.approval_tier = 'auto'
_logger.info(
"Rule '%s' promoted to auto-approved (confidence=%.2f, uses=%d)",
rec.name, rec.confidence_score, rec.total_uses,
)
def action_demote(self):
self.write({'approval_tier': 'needs_approval'})
def action_rollback(self):
for rec in self:
if rec.parent_rule_id:
rec.active = False
rec.parent_rule_id.active = True

View File

@@ -0,0 +1,60 @@
import logging
from odoo import models, fields, api
_logger = logging.getLogger(__name__)
class FusionAccountingSession(models.Model):
_name = 'fusion.accounting.session'
_description = 'Fusion Accounting AI Session'
_order = 'create_date desc'
_inherit = ['mail.thread']
name = fields.Char(
string='Session',
required=True,
default=lambda self: self.env['ir.sequence'].next_by_code('fusion.accounting.session') or 'New',
)
user_id = fields.Many2one(
'res.users', string='User',
required=True, default=lambda self: self.env.user,
index=True,
)
company_id = fields.Many2one(
'res.company', string='Company',
required=True, default=lambda self: self.env.company,
)
state = fields.Selection(
selection=[
('active', 'Active'),
('closed', 'Closed'),
],
string='Status',
default='active',
index=True,
)
message_ids_json = fields.Text(
string='Messages (JSON)',
default='[]',
help='Stored conversation messages as JSON array.',
)
context_domain = fields.Char(
string='Context Domain',
help='Active accounting domain when session started.',
)
context_data = fields.Text(
string='Context Data (JSON)',
help='Additional Odoo context captured at session start.',
)
match_history_ids = fields.One2many(
'fusion.accounting.match.history', 'session_id',
string='Match History',
)
token_count_in = fields.Integer(string='Tokens In', default=0)
token_count_out = fields.Integer(string='Tokens Out', default=0)
tool_call_count = fields.Integer(string='Tool Calls', default=0)
ai_provider = fields.Char(string='AI Provider')
ai_model = fields.Char(string='AI Model')
def action_close_session(self):
self.write({'state': 'closed'})

View File

@@ -0,0 +1,60 @@
import logging
from odoo import models, fields, api
_logger = logging.getLogger(__name__)
class FusionAccountingTool(models.Model):
_name = 'fusion.accounting.tool'
_description = 'Fusion Accounting AI Tool'
_order = 'domain, sequence, name'
name = fields.Char(string='Technical Name', required=True, index=True)
display_name_field = fields.Char(string='Tool Label', required=True)
description = fields.Text(string='Description', required=True)
domain = fields.Selection(
selection=[
('bank_reconciliation', 'Bank Reconciliation'),
('hst_management', 'HST/GST Management'),
('accounts_receivable', 'Accounts Receivable'),
('accounts_payable', 'Accounts Payable'),
('journal_review', 'Journal Review'),
('month_end', 'Month-End / Year-End'),
('payroll_verification', 'Payroll Verification'),
('inventory', 'Inventory & COGS'),
('adp', 'ADP Reconciliation'),
('reporting', 'Financial Reporting'),
('audit', 'Audit & Integrity'),
('payroll_management', 'Payroll Management'),
],
string='Domain',
required=True,
index=True,
)
tier = fields.Selection(
selection=[
('1', 'Tier 1 - Free (Read-Only)'),
('2', 'Tier 2 - Auto-Approved'),
('3', 'Tier 3 - Requires Approval'),
],
string='Tier',
required=True,
default='1',
)
parameters_schema = fields.Text(string='Parameters (JSON Schema)')
required_groups = fields.Char(
string='Required Groups',
help='Comma-separated XML IDs of required groups.',
)
odoo_method = fields.Char(string='Odoo Method Reference')
sequence = fields.Integer(string='Sequence', default=10)
active = fields.Boolean(string='Active', default=True)
company_id = fields.Many2one(
'res.company', string='Company',
default=lambda self: self.env.company,
)
_sql_constraints = [
('name_company_uniq', 'UNIQUE(name, company_id)',
'Tool name must be unique per company.'),
]

View File

@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Report action -->
<record id="action_report_fusion_audit" model="ir.actions.report">
<field name="name">Fusion AI Audit Report</field>
<field name="model">fusion.accounting.dashboard</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_accounting.audit_report_document</field>
<field name="report_file">fusion_accounting.audit_report_document</field>
<field name="binding_model_id" ref="model_fusion_accounting_dashboard"/>
<field name="binding_type">report</field>
</record>
<!-- Report template -->
<template id="audit_report_document">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="o">
<t t-call="web.external_layout">
<div class="page">
<h2>Fusion AI Audit Report</h2>
<p>Company: <span t-field="o.company_id.name"/></p>
<p>Generated: <span t-esc="context_timestamp(datetime.datetime.now()).strftime('%Y-%m-%d %H:%M')"/></p>
<hr/>
<h3>Health Summary</h3>
<table class="table table-bordered table-sm">
<thead class="table-light">
<tr>
<th>Domain</th>
<th>Metric</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr>
<td>Bank Reconciliation</td>
<td><t t-esc="o.bank_recon_count"/> unmatched lines ($<t t-esc="'%.2f' % o.bank_recon_amount"/>)</td>
<td t-att-class="'text-success' if o.bank_recon_count == 0 else 'text-danger'">
<t t-if="o.bank_recon_count == 0">OK</t>
<t t-else="">Attention</t>
</td>
</tr>
<tr>
<td>Accounts Receivable</td>
<td>$<t t-esc="'%.2f' % o.ar_total"/> outstanding, <t t-esc="o.ar_overdue_count"/> overdue</td>
<td t-att-class="'text-success' if o.ar_overdue_count == 0 else 'text-warning'">
<t t-if="o.ar_overdue_count == 0">OK</t>
<t t-else="">Overdue Items</t>
</td>
</tr>
<tr>
<td>Accounts Payable</td>
<td>$<t t-esc="'%.2f' % o.ap_total"/> total, <t t-esc="o.ap_due_this_week"/> due this week</td>
<td>Info</td>
</tr>
<tr>
<td>HST Balance</td>
<td>$<t t-esc="'%.2f' % o.hst_balance"/></td>
<td><t t-if="o.hst_balance > 0">Owing to CRA</t><t t-else="">Refund Expected</t></td>
</tr>
<tr>
<td>Audit Score</td>
<td><t t-esc="o.audit_score"/>/100 (<t t-esc="o.audit_flag_count"/> flags)</td>
<td t-att-class="'text-success' if o.audit_score >= 80 else ('text-warning' if o.audit_score >= 60 else 'text-danger')">
<t t-if="o.audit_score >= 80">Good</t>
<t t-elif="o.audit_score >= 60">Fair</t>
<t t-else="">Needs Attention</t>
</td>
</tr>
<tr>
<td>Month-End Status</td>
<td><t t-esc="o.month_end_status"/> (<t t-esc="o.month_end_open_items"/> open items)</td>
<td t-att-class="'text-success' if o.month_end_open_items == 0 else 'text-warning'">
<t t-esc="o.month_end_status"/>
</td>
</tr>
</tbody>
</table>
</div>
</t>
</t>
</t>
</template>
</odoo>

View File

@@ -0,0 +1,13 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fusion_session_user,fusion.accounting.session.user,model_fusion_accounting_session,group_fusion_accounting_user,1,1,1,0
access_fusion_session_admin,fusion.accounting.session.admin,model_fusion_accounting_session,group_fusion_accounting_admin,1,1,1,1
access_fusion_history_user,fusion.accounting.match.history.user,model_fusion_accounting_match_history,group_fusion_accounting_user,1,0,0,0
access_fusion_history_manager,fusion.accounting.match.history.manager,model_fusion_accounting_match_history,group_fusion_accounting_manager,1,1,1,0
access_fusion_history_admin,fusion.accounting.match.history.admin,model_fusion_accounting_match_history,group_fusion_accounting_admin,1,1,1,1
access_fusion_rule_user,fusion.accounting.rule.user,model_fusion_accounting_rule,group_fusion_accounting_user,1,0,0,0
access_fusion_rule_manager,fusion.accounting.rule.manager,model_fusion_accounting_rule,group_fusion_accounting_manager,1,1,1,0
access_fusion_rule_admin,fusion.accounting.rule.admin,model_fusion_accounting_rule,group_fusion_accounting_admin,1,1,1,1
access_fusion_tool_user,fusion.accounting.tool.user,model_fusion_accounting_tool,group_fusion_accounting_user,1,0,0,0
access_fusion_tool_admin,fusion.accounting.tool.admin,model_fusion_accounting_tool,group_fusion_accounting_admin,1,1,1,1
access_fusion_dashboard_user,fusion.accounting.dashboard.user,model_fusion_accounting_dashboard,group_fusion_accounting_user,1,1,1,1
access_fusion_rule_wizard_manager,fusion.accounting.rule.wizard.manager,model_fusion_accounting_rule_wizard,group_fusion_accounting_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fusion_session_user fusion.accounting.session.user model_fusion_accounting_session group_fusion_accounting_user 1 1 1 0
3 access_fusion_session_admin fusion.accounting.session.admin model_fusion_accounting_session group_fusion_accounting_admin 1 1 1 1
4 access_fusion_history_user fusion.accounting.match.history.user model_fusion_accounting_match_history group_fusion_accounting_user 1 0 0 0
5 access_fusion_history_manager fusion.accounting.match.history.manager model_fusion_accounting_match_history group_fusion_accounting_manager 1 1 1 0
6 access_fusion_history_admin fusion.accounting.match.history.admin model_fusion_accounting_match_history group_fusion_accounting_admin 1 1 1 1
7 access_fusion_rule_user fusion.accounting.rule.user model_fusion_accounting_rule group_fusion_accounting_user 1 0 0 0
8 access_fusion_rule_manager fusion.accounting.rule.manager model_fusion_accounting_rule group_fusion_accounting_manager 1 1 1 0
9 access_fusion_rule_admin fusion.accounting.rule.admin model_fusion_accounting_rule group_fusion_accounting_admin 1 1 1 1
10 access_fusion_tool_user fusion.accounting.tool.user model_fusion_accounting_tool group_fusion_accounting_user 1 0 0 0
11 access_fusion_tool_admin fusion.accounting.tool.admin model_fusion_accounting_tool group_fusion_accounting_admin 1 1 1 1
12 access_fusion_dashboard_user fusion.accounting.dashboard.user model_fusion_accounting_dashboard group_fusion_accounting_user 1 1 1 1
13 access_fusion_rule_wizard_manager fusion.accounting.rule.wizard.manager model_fusion_accounting_rule_wizard group_fusion_accounting_manager 1 1 1 1

View File

@@ -0,0 +1,94 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Module Category -->
<record id="module_category_fusion_accounting" model="ir.module.category">
<field name="name">Fusion Accounting AI</field>
<field name="sequence">25</field>
</record>
<!-- Groups Privilege -->
<record id="res_groups_privilege_fusion_accounting" model="res.groups.privilege">
<field name="name">Fusion Accounting AI</field>
<field name="category_id" ref="module_category_fusion_accounting"/>
</record>
<!-- User Group (Staff) -->
<record id="group_fusion_accounting_user" model="res.groups">
<field name="name">User</field>
<field name="sequence">10</field>
<field name="implied_ids" eval="[(4, ref('account.group_account_user'))]"/>
<field name="privilege_id" ref="res_groups_privilege_fusion_accounting"/>
</record>
<!-- Manager Group -->
<record id="group_fusion_accounting_manager" model="res.groups">
<field name="name">Manager</field>
<field name="sequence">20</field>
<field name="implied_ids" eval="[(4, ref('group_fusion_accounting_user'))]"/>
<field name="privilege_id" ref="res_groups_privilege_fusion_accounting"/>
</record>
<!-- Admin Group -->
<record id="group_fusion_accounting_admin" model="res.groups">
<field name="name">Administrator</field>
<field name="sequence">30</field>
<field name="implied_ids" eval="[(4, ref('group_fusion_accounting_manager'))]"/>
<field name="privilege_id" ref="res_groups_privilege_fusion_accounting"/>
</record>
<!-- Auto-assign: Accounting users get Fusion AI User, Advisers get Admin -->
<record id="account.group_account_user" model="res.groups">
<field name="implied_ids" eval="[(4, ref('group_fusion_accounting_user'))]"/>
</record>
<record id="account.group_account_manager" model="res.groups">
<field name="implied_ids" eval="[(4, ref('group_fusion_accounting_admin'))]"/>
</record>
<!-- Record Rules -->
<record id="rule_fusion_session_user" model="ir.rule">
<field name="name">Fusion Session: Own Sessions</field>
<field name="model_id" ref="model_fusion_accounting_session"/>
<field name="domain_force">[('user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('group_fusion_accounting_user'))]"/>
</record>
<record id="rule_fusion_session_manager" model="ir.rule">
<field name="name">Fusion Session: All Sessions</field>
<field name="model_id" ref="model_fusion_accounting_session"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_fusion_accounting_manager'))]"/>
</record>
<record id="rule_fusion_history_user" model="ir.rule">
<field name="name">Fusion History: Own History</field>
<field name="model_id" ref="model_fusion_accounting_match_history"/>
<field name="domain_force">[('session_id.user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('group_fusion_accounting_user'))]"/>
</record>
<record id="rule_fusion_history_manager" model="ir.rule">
<field name="name">Fusion History: All History</field>
<field name="model_id" ref="model_fusion_accounting_match_history"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_fusion_accounting_manager'))]"/>
</record>
<!-- Multi-company rules -->
<record id="rule_fusion_tool_company" model="ir.rule">
<field name="name">Fusion Tool: Multi-Company</field>
<field name="model_id" ref="model_fusion_accounting_tool"/>
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]</field>
</record>
<record id="rule_fusion_rule_company" model="ir.rule">
<field name="name">Fusion Rule: Multi-Company</field>
<field name="model_id" ref="model_fusion_accounting_rule"/>
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]</field>
</record>
<record id="rule_fusion_history_company" model="ir.rule">
<field name="name">Fusion History: Multi-Company</field>
<field name="model_id" ref="model_fusion_accounting_match_history"/>
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]</field>
</record>
</odoo>

View File

@@ -0,0 +1,5 @@
from . import adapters
from . import tools
from . import prompts
from . import agent
from . import scoring

View File

@@ -0,0 +1,2 @@
from . import claude
from . import openai_adapter

View File

@@ -0,0 +1,141 @@
import json
import logging
from odoo import models, api, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
try:
import anthropic as anthropic_sdk
except ImportError:
anthropic_sdk = None
class FusionAccountingAdapterClaude(models.AbstractModel):
_name = 'fusion.accounting.adapter.claude'
_description = 'Claude AI Adapter'
def _get_client(self):
if anthropic_sdk is None:
raise UserError(_("The 'anthropic' Python package is not installed."))
try:
key = self.env['fusion.api.service'].get_api_key(
provider_type='anthropic',
consumer='fusion_accounting',
feature='chat_with_tools',
)
except Exception:
ICP = self.env['ir.config_parameter'].sudo()
key = ICP.get_param('fusion_accounting.anthropic_api_key', '')
if not key:
raise UserError(_("No Anthropic API key configured."))
return anthropic_sdk.Anthropic(api_key=key)
def _get_model_name(self):
ICP = self.env['ir.config_parameter'].sudo()
return ICP.get_param('fusion_accounting.claude_model', 'claude-sonnet-4-6')
def _format_tools(self, tools):
formatted = []
for tool in tools:
t = {
'name': tool['name'],
'description': tool['description'],
'input_schema': tool.get('parameters', {'type': 'object', 'properties': {}}),
}
formatted.append(t)
return formatted
def _supports_extended_thinking(self, model):
return '4-6' in model or '4-5' in model or '4-1' in model or '4-0' in model
def call_with_tools(self, system_prompt, messages, tools=None):
client = self._get_client()
model = self._get_model_name()
api_messages = []
for msg in messages:
if msg['role'] in ('user', 'assistant'):
api_messages.append(msg)
kwargs = {
'model': model,
'max_tokens': 16384,
'system': system_prompt,
'messages': api_messages,
}
if tools:
kwargs['tools'] = self._format_tools(tools)
if self._supports_extended_thinking(model) and tools:
kwargs['thinking'] = {
'type': 'enabled',
'budget_tokens': 8192,
}
try:
response = client.messages.create(**kwargs)
except anthropic_sdk.BadRequestError as e:
if 'thinking' in str(e).lower():
kwargs.pop('thinking', None)
response = client.messages.create(**kwargs)
else:
raise UserError(_("Claude API error: %s", str(e)))
except Exception as e:
_logger.error("Claude API error: %s", e)
raise UserError(_("Claude API error: %s", str(e)))
text_parts = []
tool_calls = []
thinking_text = []
for block in response.content:
if block.type == 'text':
text_parts.append(block.text)
elif block.type == 'tool_use':
tool_calls.append({
'id': block.id,
'name': block.name,
'arguments': block.input,
})
elif block.type == 'thinking':
thinking_text.append(block.thinking)
if thinking_text:
_logger.debug("Claude thinking: %s", thinking_text[0][:500])
return {
'text': '\n'.join(text_parts),
'tool_calls': tool_calls if tool_calls else None,
'tokens_in': response.usage.input_tokens,
'tokens_out': response.usage.output_tokens,
'stop_reason': response.stop_reason,
'raw_content': response.content,
}
def append_tool_results(self, messages, ai_response, tool_results):
assistant_content = []
for block in ai_response.get('raw_content', []):
if hasattr(block, 'type'):
if block.type == 'text':
assistant_content.append({'type': 'text', 'text': block.text})
elif block.type == 'tool_use':
assistant_content.append({
'type': 'tool_use',
'id': block.id,
'name': block.name,
'input': block.input,
})
messages.append({'role': 'assistant', 'content': assistant_content})
tool_result_content = []
for tr in tool_results:
tool_result_content.append({
'type': 'tool_result',
'tool_use_id': tr['tool_call_id'],
'content': tr['result'],
})
messages.append({'role': 'user', 'content': tool_result_content})
return messages

View File

@@ -0,0 +1,137 @@
import json
import logging
from odoo import models, api, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
try:
from openai import OpenAI
except ImportError:
OpenAI = None
class FusionAccountingAdapterOpenAI(models.AbstractModel):
_name = 'fusion.accounting.adapter.openai'
_description = 'OpenAI AI Adapter'
def _get_client(self):
if OpenAI is None:
raise UserError(_("The 'openai' Python package is not installed."))
try:
key = self.env['fusion.api.service'].get_api_key(
provider_type='openai',
consumer='fusion_accounting',
feature='chat_with_tools',
)
except Exception:
ICP = self.env['ir.config_parameter'].sudo()
key = ICP.get_param('fusion_accounting.openai_api_key', '')
if not key:
raise UserError(_("No OpenAI API key configured."))
return OpenAI(api_key=key)
def _get_model_name(self):
ICP = self.env['ir.config_parameter'].sudo()
return ICP.get_param('fusion_accounting.openai_model', 'gpt-5.4-mini')
def _format_tools(self, tools):
formatted = []
for tool in tools:
formatted.append({
'type': 'function',
'function': {
'name': tool['name'],
'description': tool['description'],
'parameters': tool.get('parameters', {'type': 'object', 'properties': {}}),
},
})
return formatted
def _is_reasoning_model(self, model):
return model.startswith('o1') or model.startswith('o3') or model.startswith('o4')
def call_with_tools(self, system_prompt, messages, tools=None):
client = self._get_client()
model = self._get_model_name()
is_reasoning = self._is_reasoning_model(model)
if is_reasoning:
api_messages = [{'role': 'developer', 'content': system_prompt}]
else:
api_messages = [{'role': 'system', 'content': system_prompt}]
for msg in messages:
if msg['role'] in ('user', 'assistant', 'tool'):
api_messages.append(msg)
kwargs = {
'model': model,
'messages': api_messages,
}
if is_reasoning:
kwargs['max_completion_tokens'] = 16384
kwargs['reasoning_effort'] = 'medium'
else:
kwargs['max_tokens'] = 4096
if tools:
kwargs['tools'] = self._format_tools(tools)
try:
response = client.chat.completions.create(**kwargs)
except Exception as e:
_logger.error("OpenAI API error: %s", e)
raise UserError(_("OpenAI API error: %s", str(e)))
choice = response.choices[0]
message = choice.message
tool_calls = []
if message.tool_calls:
for tc in message.tool_calls:
try:
args = json.loads(tc.function.arguments)
except (json.JSONDecodeError, TypeError):
_logger.warning("Malformed tool args from OpenAI: %s", tc.function.arguments)
args = {}
tool_calls.append({
'id': tc.id,
'name': tc.function.name,
'arguments': args,
})
return {
'text': message.content or '',
'tool_calls': tool_calls if tool_calls else None,
'tokens_in': response.usage.prompt_tokens,
'tokens_out': response.usage.completion_tokens,
'stop_reason': choice.finish_reason,
'raw_message': message,
}
def append_tool_results(self, messages, ai_response, tool_results):
raw_msg = ai_response.get('raw_message')
assistant_msg = {'role': 'assistant', 'content': raw_msg.content or ''}
if raw_msg.tool_calls:
assistant_msg['tool_calls'] = [
{
'id': tc.id,
'type': 'function',
'function': {
'name': tc.function.name,
'arguments': tc.function.arguments,
},
}
for tc in raw_msg.tool_calls
]
messages.append(assistant_msg)
for tr in tool_results:
messages.append({
'role': 'tool',
'tool_call_id': tr['tool_call_id'],
'content': tr['result'],
})
return messages

View File

@@ -0,0 +1,315 @@
import json
import logging
import time
from odoo import models, fields, api, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class FusionAccountingAgent(models.AbstractModel):
_name = 'fusion.accounting.agent'
_description = 'Fusion Accounting AI Agent Orchestrator'
def _get_config(self, key, default=None):
ICP = self.env['ir.config_parameter'].sudo()
return ICP.get_param(f'fusion_accounting.{key}', default)
def _get_adapter(self):
provider = self._get_config('ai_provider', 'claude')
if provider == 'claude':
return self.env['fusion.accounting.adapter.claude']
return self.env['fusion.accounting.adapter.openai']
def _get_tool_registry(self):
return self.env['fusion.accounting.tool'].search([('active', '=', True)])
def _get_tools_for_user(self, user=None):
user = user or self.env.user
tools = self._get_tool_registry()
filtered = self.env['fusion.accounting.tool']
for tool in tools:
if not tool.required_groups:
filtered |= tool
continue
group_xmlids = [g.strip() for g in tool.required_groups.split(',') if g.strip()]
if all(user.has_group(g) for g in group_xmlids):
filtered |= tool
return filtered
def _build_tool_definitions(self, tools):
definitions = []
for tool in tools:
defn = {
'name': tool.name,
'description': tool.description,
}
if tool.parameters_schema:
try:
defn['parameters'] = json.loads(tool.parameters_schema)
except json.JSONDecodeError:
defn['parameters'] = {'type': 'object', 'properties': {}}
else:
defn['parameters'] = {'type': 'object', 'properties': {}}
definitions.append(defn)
return definitions
def _load_rules(self, domain=None):
rule_domain = [('active', '=', True), ('company_id', '=', self.env.company.id)]
if domain:
rule_domain.append(('rule_type', '=', domain))
rules = self.env['fusion.accounting.rule'].search(rule_domain, order='sequence')
admin_rules = rules.filtered(lambda r: r.created_by == 'admin')
ai_auto = rules.filtered(lambda r: r.created_by == 'ai' and r.approval_tier == 'auto')
ai_pending = rules.filtered(lambda r: r.created_by == 'ai' and r.approval_tier == 'needs_approval')
return admin_rules | ai_auto | ai_pending
def _load_match_history(self, domain=None, limit=None):
limit = limit or int(self._get_config('history_in_prompt', '50'))
history_domain = [('company_id', '=', self.env.company.id)]
if domain:
history_domain.append(('tool_name', 'ilike', domain))
return self.env['fusion.accounting.match.history'].search(
history_domain, limit=limit, order='proposed_at desc',
)
def _build_system_prompt(self, rules, history, context=None, domain=None):
from .prompts.system_prompt import build_system_prompt
from .prompts.domain_prompts import get_domain_prompt
base = build_system_prompt(rules, history, context)
if domain:
domain_prompt = get_domain_prompt(domain)
if domain_prompt:
base = f"{base}\n\n{domain_prompt}"
return base
def _execute_tool(self, tool_name, params, session_id=None):
from .tools import TOOL_DISPATCH
if tool_name not in TOOL_DISPATCH:
return {'error': f'Unknown tool: {tool_name}'}
try:
result = TOOL_DISPATCH[tool_name](self.env, params)
return result
except Exception as e:
_logger.error("Tool execution error (%s): %s", tool_name, e)
return {'error': str(e)}
def _log_match_history(self, session, tool_name, params, result, reasoning='',
confidence=0.0, rule=None, tier='1'):
vals = {
'session_id': session.id if session else False,
'tool_name': tool_name,
'tool_params': json.dumps(params) if params else '{}',
'tool_result': json.dumps(result) if result else '{}',
'ai_reasoning': reasoning,
'ai_confidence': confidence,
'rule_id': rule.id if rule else False,
'proposed_at': fields.Datetime.now(),
'decision': 'auto' if tier in ('1', '2') else 'pending',
'company_id': self.env.company.id,
}
return self.env['fusion.accounting.match.history'].sudo().create(vals)
def chat(self, session_id, user_message, context=None):
session = self.env['fusion.accounting.session'].browse(session_id)
if not session.exists():
raise UserError(_("Session not found."))
adapter = self._get_adapter()
tools = self._get_tools_for_user()
tool_definitions = self._build_tool_definitions(tools)
rules = self._load_rules()
history = self._load_match_history()
system_prompt = self._build_system_prompt(
rules, history, context, domain=session.context_domain,
)
messages_json = json.loads(session.message_ids_json or '[]')
messages_json.append({'role': 'user', 'content': user_message})
max_turns = max(int(self._get_config('max_tool_calls', '20')), 1)
total_tokens_in = 0
total_tokens_out = 0
response = {'text': '', 'tool_calls': None}
for turn in range(max_turns):
response = adapter.call_with_tools(
system_prompt=system_prompt,
messages=messages_json,
tools=tool_definitions,
)
total_tokens_in += response.get('tokens_in', 0)
total_tokens_out += response.get('tokens_out', 0)
if response.get('tool_calls'):
tool_results = []
for tc in response['tool_calls']:
tool_name = tc['name']
tool_params = tc.get('arguments', {})
tool_rec = tools.filtered(lambda t: t.name == tool_name)
tier = tool_rec.tier if tool_rec else '1'
if tier == '3':
history_rec = self._log_match_history(
session, tool_name, tool_params, None,
reasoning=tc.get('reasoning', ''),
confidence=tc.get('confidence', 0.0),
tier='3',
)
tool_results.append({
'tool_call_id': tc.get('id', ''),
'result': json.dumps({
'status': 'pending_approval',
'match_history_id': history_rec.id,
'message': f'Action requires user approval. Match history ID: {history_rec.id}',
}),
})
else:
result = self._execute_tool(tool_name, tool_params, session.id)
self._log_match_history(
session, tool_name, tool_params, result,
reasoning=tc.get('reasoning', ''),
tier=tier,
)
tool_results.append({
'tool_call_id': tc.get('id', ''),
'result': json.dumps(result) if not isinstance(result, str) else result,
})
try:
self._check_rule_proposal(tool_name, tool_params, session)
except Exception:
_logger.exception("Error in _check_rule_proposal for tool %s", tool_name)
messages_json = adapter.append_tool_results(
messages_json, response, tool_results,
)
session.tool_call_count += len(tool_results)
else:
assistant_text = response.get('text', '')
messages_json.append({'role': 'assistant', 'content': assistant_text})
break
else:
# Loop exhausted — force a final text response without tools
try:
response = adapter.call_with_tools(
system_prompt=system_prompt,
messages=messages_json,
tools=[],
)
total_tokens_in += response.get('tokens_in', 0)
total_tokens_out += response.get('tokens_out', 0)
messages_json.append({
'role': 'assistant',
'content': response.get('text', 'I reached the maximum number of steps. Please continue the conversation.'),
})
except Exception:
_logger.warning("Failed to get final summary after max tool calls")
session.write({
'message_ids_json': json.dumps(messages_json),
'token_count_in': session.token_count_in + total_tokens_in,
'token_count_out': session.token_count_out + total_tokens_out,
'ai_provider': self._get_config('ai_provider', 'claude'),
'ai_model': adapter._get_model_name(),
})
pending = self.env['fusion.accounting.match.history'].search([
('session_id', '=', session.id),
('decision', '=', 'pending'),
])
return {
'text': response.get('text', ''),
'pending_approvals': [{
'id': p.id,
'tool_name': p.tool_name,
'params': p.tool_params,
'reasoning': p.ai_reasoning,
'confidence': p.ai_confidence,
'amount': p.amount,
} for p in pending],
'session_id': session.id,
}
def approve_action(self, match_history_id):
history = self.env['fusion.accounting.match.history'].browse(match_history_id)
if not history.exists() or history.decision != 'pending':
raise UserError(_("Action not found or already decided."))
params = json.loads(history.tool_params or '{}')
result = self._execute_tool(history.tool_name, params, history.session_id.id)
history.write({
'decision': 'approved',
'decided_at': fields.Datetime.now(),
'decided_by': self.env.user.id,
'tool_result': json.dumps(result) if not isinstance(result, str) else result,
})
if history.rule_id:
history.rule_id.sudo()._record_decision(approved=True)
return result
def _check_rule_proposal(self, tool_name, params, session):
"""Detect repeated patterns and propose new rules when 3+ identical matches."""
recent = self.env['fusion.accounting.match.history'].search([
('tool_name', '=', tool_name),
('decision', 'in', ('approved', 'auto')),
('company_id', '=', self.env.company.id),
], limit=20, order='proposed_at desc')
if len(recent) < 3:
return
from collections import Counter
param_keys = []
for h in recent:
try:
p = json.loads(h.tool_params or '{}')
key_parts = []
for k in sorted(p.keys()):
if k not in ('limit', 'date_from', 'date_to'):
key_parts.append(f'{k}={json.dumps(p[k], sort_keys=True)}')
if key_parts:
param_keys.append('|'.join(key_parts))
except json.JSONDecodeError:
continue
counts = Counter(param_keys)
for pattern, count in counts.most_common(3):
if count < 3:
break
existing = self.env['fusion.accounting.rule'].search([
('match_logic', 'ilike', pattern[:50]),
('company_id', '=', self.env.company.id),
], limit=1)
if existing:
continue
self.env['fusion.accounting.rule'].create({
'name': f'AI Pattern: {tool_name} ({pattern[:40]})',
'rule_type': 'match',
'description': f'Automatically detected pattern from {count} approved uses of {tool_name}.',
'match_logic': f'When using {tool_name} with parameters matching: {pattern}',
'created_by': 'ai',
'approval_tier': 'needs_approval',
'company_id': self.env.company.id,
})
_logger.info("AI proposed rule for pattern: %s (%d matches)", tool_name, count)
def reject_action(self, match_history_id, reason=''):
history = self.env['fusion.accounting.match.history'].browse(match_history_id)
if not history.exists() or history.decision != 'pending':
raise UserError(_("Action not found or already decided."))
history.write({
'decision': 'rejected',
'decided_at': fields.Datetime.now(),
'decided_by': self.env.user.id,
'rejection_reason': reason,
})
if history.rule_id:
history.rule_id.sudo()._record_decision(approved=False)
return {'status': 'rejected', 'reason': reason}

View File

@@ -0,0 +1,2 @@
from . import system_prompt
from . import domain_prompts

View File

@@ -0,0 +1,109 @@
DOMAIN_PROMPTS = {
'bank_reconciliation': """
BANK RECONCILIATION CONTEXT:
You are helping with bank statement reconciliation. Key concepts:
- Bank statement lines (account.bank.statement.line) represent transactions from the bank feed.
- Each line needs to be matched to one or more journal items (account.move.line).
- Matching is done via set_line_bank_statement_line(move_line_ids).
- Fee differences (e.g., Elavon card processing fees) should be allocated to the fee account.
- Weekend batches may combine multiple days of card payments.
- Always verify amounts before proposing a match.
""",
'hst_management': """
HST/GST MANAGEMENT CONTEXT:
You are helping with Canadian HST/GST tax management.
- HST Collected is tracked on account 2005 (credit balance = liability).
- Input Tax Credits (ITCs) are on account 2006 (debit balance = asset).
- Net HST = Collected - ITCs. Positive means owing to CRA.
- Quarterly filing periods. Check for missing tax on invoices/bills.
- All vendor bills should have ITCs unless explicitly exempt.
""",
'accounts_receivable': """
ACCOUNTS RECEIVABLE CONTEXT:
- AR aging: current, 1-30, 31-60, 61-90, 90+ days overdue.
- Follow-up actions escalate by aging bucket.
- Payments should be matched to specific invoices.
- Unmatched payments sit on the Outstanding Receipts account (1122).
""",
'accounts_payable': """
ACCOUNTS PAYABLE CONTEXT:
- AP aging mirrors AR: current through 90+ days.
- Watch for duplicate bills (same vendor + amount + date).
- Bills should match purchase orders when applicable.
- Tax on bills should match the vendor's fiscal position.
""",
'journal_review': """
JOURNAL REVIEW CONTEXT:
- Check for wrong-direction balances (e.g., expense account with credit balance).
- Detect duplicate entries (same partner + amount + date + journal).
- Flag entries on unlikely accounts (revenue on a tax account, etc.).
- Sequence gaps may indicate deleted entries.
- Draft entries older than 30 days should be reviewed.
""",
'month_end': """
MONTH-END CLOSE CONTEXT:
- Aggregate all domain checks into a close checklist.
- Verify all bank reconciliations are current.
- Check accrual account balances (vacation, sick leave, etc.).
- Verify no entries exist after lock date.
- Run hash integrity check.
- Produce period trial balance summary.
""",
'payroll_verification': """
PAYROLL VERIFICATION CONTEXT:
- Cross-reference payroll journal entries to bank statement cheques.
- Verify CPP, EI, and income tax deductions against CRA rate tables.
- Check CRA remittance account balance vs payments made.
""",
'inventory': """
INVENTORY & COGS CONTEXT:
- Stock In Hand tracked on account 1069.
- Price differences on account 5010 (PO price vs bill price).
- COGS ratio by product category helps spot anomalies.
- Large inventory adjustments need review.
""",
'adp': """
ADP RECONCILIATION CONTEXT:
- ADP Receivable tracked on account 1101.
- ADP invoices have customer portion + ADP portion = total.
- Government deposits should match ADP invoices.
""",
'reporting': """
FINANCIAL REPORTING CONTEXT:
- Reports use Odoo's account.report engine.
- Available: P&L, Balance Sheet, Trial Balance, Cash Flow.
- Period comparison available for trend analysis.
- Export to PDF or XLSX for external distribution.
""",
'audit': """
AUDIT & INTEGRITY CONTEXT:
- Run comprehensive checks on posted entries.
- Verify hash chain integrity on journals.
- Check sequence continuity.
- Flag entries with chatter messages for review tracking.
- Audit status per account: todo / reviewed / supervised / anomaly.
""",
'payroll_management': """
PAYROLL MANAGEMENT CONTEXT:
- Parse pasted payroll summaries from QBO or fusion_payroll.
- Create payroll journal entries with proper debit/credit lines.
- Match payroll cheques to bank statement lines.
- Calculate CRA obligations (CPP employer + employee, EI, income tax).
- Prepare CRA remittance payment entries.
""",
}
def get_domain_prompt(domain):
return DOMAIN_PROMPTS.get(domain, '')

View File

@@ -0,0 +1,98 @@
import json
def build_system_prompt(rules, history, context=None):
parts = [
CORE_SYSTEM_PROMPT,
_build_rules_section(rules),
_build_history_section(history),
]
if context:
parts.append(_build_context_section(context))
return '\n\n'.join(p for p in parts if p)
CORE_SYSTEM_PROMPT = """You are Fusion AI, an expert accounting co-pilot embedded in Odoo 19.
You assist with bank reconciliation, HST/GST management, AR/AP analysis, journal review,
month-end close, payroll, inventory, ADP reconciliation, financial reporting, and auditing.
BEHAVIOUR:
- Use tools to query and act on Odoo data. Never invent financial figures.
- For Tier 1 tools: execute immediately and report results.
- For Tier 2 tools: execute and log. Inform the user what was done.
- For Tier 3 tools: propose the action with clear reasoning. The user must approve.
- When proposing a Tier 3 action, explain: what you want to do, why, the amounts involved, and your confidence level.
- Apply Fusion Rules (below) before general reasoning.
- Reference match history for patterns the user has approved/rejected before.
- Use Canadian English. Format monetary amounts with $ and two decimals.
- When you encounter ambiguity, ask clarifying questions rather than guessing.
RESPONSE FORMATTING:
- Use rich Markdown formatting in your responses. The chat renders Markdown as HTML.
- Use **bold** for account names, amounts, and key terms.
- Use ## and ### headers to organize sections in longer responses.
- Use Markdown tables for tabular data (| col1 | col2 | format).
- Use bullet lists (- item) for findings, issues, and action items.
- Use numbered lists (1. item) for sequential steps or ranked items.
- Use `code` for account codes, reference numbers, and technical IDs.
- Use --- horizontal rules to separate sections in long reports.
LINKING TO ODOO RECORDS:
- When referencing specific records, include clickable Odoo links.
- Journal entries: [INV/2026/00123](/odoo/accounting/123) where 123 is the move ID.
- Partners: [Customer Name](/odoo/contacts/456) where 456 is the partner ID.
- Accounts: reference by code in bold, e.g. **1001 - Cash**.
- Bank statement lines: mention the date, reference, and amount clearly.
- When tool results include record IDs, always link them.
TOOL CALLING:
- Call tools by name with the required parameters.
- You may call multiple tools in sequence to gather data before proposing an action.
- Do not exceed the maximum tool calls per turn.
- When presenting tool results, format them richly with tables, bold amounts, and links.
"""
def _build_rules_section(rules):
if not rules:
return ''
lines = ['ACTIVE FUSION RULES:']
for rule in rules:
priority = 'ADMIN' if rule.created_by == 'admin' else 'AI'
tier = 'auto' if rule.approval_tier == 'auto' else 'needs-approval'
lines.append(
f'- [{priority}/{tier}] {rule.name} ({rule.rule_type}): '
f'{rule.description or rule.match_logic or "No description"}'
)
if rule.match_logic:
lines.append(f' Match logic: {rule.match_logic}')
return '\n'.join(lines)
def _build_history_section(history):
if not history:
return ''
lines = ['RECENT MATCH HISTORY (learn from these patterns):']
for h in history[:50]:
status = h.decision
reason = ''
if h.rejection_reason:
reason = f' (reason: {h.rejection_reason})'
lines.append(
f'- {h.tool_name}: {status}{reason} '
f'[confidence={h.ai_confidence:.0%}]'
)
if h.ai_reasoning:
lines.append(f' Reasoning: {h.ai_reasoning[:200]}')
return '\n'.join(lines)
def _build_context_section(context):
if not context:
return ''
if isinstance(context, dict):
parts = ['CURRENT CONTEXT:']
for k, v in context.items():
parts.append(f'- {k}: {v}')
return '\n'.join(parts)
return f'CURRENT CONTEXT: {context}'

View File

@@ -0,0 +1,61 @@
import logging
from odoo import models, api
_logger = logging.getLogger(__name__)
class FusionAccountingScoring(models.AbstractModel):
_name = 'fusion.accounting.scoring'
_description = 'Fusion Accounting Confidence Scoring'
def calculate_confidence(self, tool_name, scenario_key=None):
domain = [('tool_name', '=', tool_name)]
if scenario_key:
domain.append(('tool_params', 'ilike', scenario_key))
history = self.env['fusion.accounting.match.history'].search(domain)
if not history:
return 0.0
decided = history.filtered(lambda h: h.decision in ('approved', 'rejected'))
if not decided:
return 0.0
approved = len(decided.filtered(lambda h: h.decision == 'approved'))
return approved / len(decided)
def check_promotions(self):
ICP = self.env['ir.config_parameter'].sudo()
threshold = float(ICP.get_param('fusion_accounting.tier3_threshold', '0.95'))
min_sample = int(ICP.get_param('fusion_accounting.tier3_min_sample', '30'))
rules = self.env['fusion.accounting.rule'].search([
('active', '=', True),
('approval_tier', '=', 'needs_approval'),
])
promoted = self.env['fusion.accounting.rule']
for rule in rules:
if rule.total_uses >= min_sample and rule.confidence_score >= threshold:
rule.approval_tier = 'auto'
promoted |= rule
_logger.info(
"Promoted rule '%s' to auto (confidence=%.2f, sample=%d)",
rule.name, rule.confidence_score, rule.total_uses,
)
return promoted
def get_tool_stats(self, tool_name=None):
domain = []
if tool_name:
domain.append(('tool_name', '=', tool_name))
history = self.env['fusion.accounting.match.history'].search(domain)
stats = {}
for h in history:
if h.tool_name not in stats:
stats[h.tool_name] = {
'total': 0, 'approved': 0, 'rejected': 0,
'pending': 0, 'auto': 0,
}
stats[h.tool_name]['total'] += 1
if h.decision in stats[h.tool_name]:
stats[h.tool_name][h.decision] += 1
return stats

View File

@@ -0,0 +1,19 @@
from .bank_reconciliation import TOOLS as BANK_RECON_TOOLS
from .hst_management import TOOLS as HST_TOOLS
from .accounts_receivable import TOOLS as AR_TOOLS
from .accounts_payable import TOOLS as AP_TOOLS
from .journal_review import TOOLS as JOURNAL_TOOLS
from .month_end import TOOLS as MONTH_END_TOOLS
from .payroll import TOOLS as PAYROLL_TOOLS
from .inventory import TOOLS as INVENTORY_TOOLS
from .adp import TOOLS as ADP_TOOLS
from .reporting import TOOLS as REPORTING_TOOLS
from .audit import TOOLS as AUDIT_TOOLS
TOOL_DISPATCH = {}
for tools_dict in [
BANK_RECON_TOOLS, HST_TOOLS, AR_TOOLS, AP_TOOLS, JOURNAL_TOOLS,
MONTH_END_TOOLS, PAYROLL_TOOLS, INVENTORY_TOOLS, ADP_TOOLS,
REPORTING_TOOLS, AUDIT_TOOLS,
]:
TOOL_DISPATCH.update(tools_dict)

View File

@@ -0,0 +1,150 @@
import logging
from odoo import fields
from datetime import timedelta
_logger = logging.getLogger(__name__)
def get_ap_aging(env, params):
today = fields.Date.today()
domain = [
('account_id.account_type', '=', 'liability_payable'),
('parent_state', '=', 'posted'),
('reconciled', '=', False),
('company_id', '=', env.company.id),
]
amls = env['account.move.line'].search(domain)
buckets = {'current': 0, '1_30': 0, '31_60': 0, '61_90': 0, '90_plus': 0}
for aml in amls:
amt = abs(aml.amount_residual)
if not aml.date_maturity or aml.date_maturity >= today:
buckets['current'] += amt
else:
days = (today - aml.date_maturity).days
if days <= 30:
buckets['1_30'] += amt
elif days <= 60:
buckets['31_60'] += amt
elif days <= 90:
buckets['61_90'] += amt
else:
buckets['90_plus'] += amt
return {'total': sum(buckets.values()), 'buckets': buckets, 'line_count': len(amls)}
def find_duplicate_bills(env, params):
window_days = int(params.get('window_days', 7))
bills = env['account.move'].search([
('move_type', 'in', ('in_invoice', 'in_refund')),
('state', '=', 'posted'),
('company_id', '=', env.company.id),
], order='partner_id, amount_total, date')
duplicates = []
prev = None
for bill in bills:
if prev and (
prev.partner_id == bill.partner_id
and abs(prev.amount_total - bill.amount_total) < 0.01
and abs((prev.date - bill.date).days) <= window_days
):
duplicates.append({
'bill_1': {'id': prev.id, 'name': prev.name, 'date': str(prev.date)},
'bill_2': {'id': bill.id, 'name': bill.name, 'date': str(bill.date)},
'partner': bill.partner_id.name,
'amount': bill.amount_total,
})
prev = bill
return {'count': len(duplicates), 'duplicates': duplicates[:20]}
def match_bill_to_po(env, params):
bill_id = int(params['bill_id'])
bill = env['account.move'].browse(bill_id)
if not bill.exists():
return {'error': 'Bill not found'}
matches = []
for line in bill.invoice_line_ids:
if line.purchase_line_id:
matches.append({
'bill_line': line.name or '',
'po': line.purchase_line_id.order_id.name,
'po_line': line.purchase_line_id.name,
'po_qty': line.purchase_line_id.product_qty,
'bill_qty': line.quantity,
'match': abs(line.quantity - line.purchase_line_id.product_qty) < 0.01,
})
return {'bill': bill.name, 'matches': matches, 'unmatched_lines': len(bill.invoice_line_ids) - len(matches)}
def get_unpaid_bills(env, params):
domain = [
('move_type', 'in', ('in_invoice', 'in_refund')),
('state', '=', 'posted'),
('payment_state', 'in', ('not_paid', 'partial')),
('company_id', '=', env.company.id),
]
if params.get('partner_id'):
domain.append(('partner_id', '=', int(params['partner_id'])))
bills = env['account.move'].search(domain, order='invoice_date_due asc', limit=int(params.get('limit', 50)))
return {
'count': len(bills),
'total': sum(b.amount_residual for b in bills),
'bills': [{
'id': b.id, 'name': b.name,
'partner': b.partner_id.name if b.partner_id else '',
'amount_total': b.amount_total,
'amount_residual': b.amount_residual,
'date_due': str(b.invoice_date_due) if b.invoice_date_due else '',
} for b in bills],
}
def verify_bill_taxes(env, params):
bill_id = int(params['bill_id'])
bill = env['account.move'].browse(bill_id)
if not bill.exists():
return {'error': 'Bill not found'}
issues = []
for line in bill.invoice_line_ids:
if line.product_id and not line.tax_ids:
issues.append({
'line': line.name or line.product_id.name,
'issue': 'No tax applied to product line',
})
return {'bill': bill.name, 'issues': issues, 'clean': len(issues) == 0}
def get_payment_schedule(env, params):
days_ahead = int(params.get('days_ahead', 30))
cutoff = fields.Date.today() + timedelta(days=days_ahead)
bills = env['account.move'].search([
('move_type', '=', 'in_invoice'),
('state', '=', 'posted'),
('payment_state', 'in', ('not_paid', 'partial')),
('invoice_date_due', '<=', cutoff),
('company_id', '=', env.company.id),
], order='invoice_date_due asc')
return {
'period': f'Next {days_ahead} days',
'total': sum(b.amount_residual for b in bills),
'bills': [{
'id': b.id, 'name': b.name,
'partner': b.partner_id.name if b.partner_id else '',
'amount_residual': b.amount_residual,
'date_due': str(b.invoice_date_due) if b.invoice_date_due else '',
} for b in bills[:50]],
}
TOOLS = {
'get_ap_aging': get_ap_aging,
'find_duplicate_bills': find_duplicate_bills,
'match_bill_to_po': match_bill_to_po,
'get_unpaid_bills': get_unpaid_bills,
'verify_bill_taxes': verify_bill_taxes,
'get_payment_schedule': get_payment_schedule,
}

View File

@@ -0,0 +1,165 @@
import logging
from odoo import fields
_logger = logging.getLogger(__name__)
def get_ar_aging(env, params):
today = fields.Date.today()
domain = [
('account_id.account_type', '=', 'asset_receivable'),
('parent_state', '=', 'posted'),
('reconciled', '=', False),
('company_id', '=', env.company.id),
]
amls = env['account.move.line'].search(domain)
buckets = {'current': 0, '1_30': 0, '31_60': 0, '61_90': 0, '90_plus': 0}
for aml in amls:
if not aml.date_maturity or aml.date_maturity >= today:
buckets['current'] += aml.amount_residual
else:
days = (today - aml.date_maturity).days
if days <= 30:
buckets['1_30'] += aml.amount_residual
elif days <= 60:
buckets['31_60'] += aml.amount_residual
elif days <= 90:
buckets['61_90'] += aml.amount_residual
else:
buckets['90_plus'] += aml.amount_residual
return {
'total': sum(buckets.values()),
'buckets': buckets,
'line_count': len(amls),
}
def get_overdue_invoices(env, params):
today = fields.Date.today()
days_overdue = int(params.get('min_days_overdue', 1))
from datetime import timedelta
cutoff = today - timedelta(days=days_overdue)
invoices = env['account.move'].search([
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('payment_state', 'in', ('not_paid', 'partial')),
('invoice_date_due', '<', cutoff),
('company_id', '=', env.company.id),
], order='invoice_date_due asc', limit=int(params.get('limit', 50)))
return {
'count': len(invoices),
'invoices': [{
'id': inv.id,
'name': inv.name,
'partner': inv.partner_id.name if inv.partner_id else '',
'email': inv.partner_id.email or '' if inv.partner_id else '',
'phone': inv.partner_id.phone or '' if inv.partner_id else '',
'amount_total': inv.amount_total,
'amount_residual': inv.amount_residual,
'date_due': str(inv.invoice_date_due),
'days_overdue': (today - inv.invoice_date_due).days,
} for inv in invoices],
}
def get_partner_balance(env, params):
partner_id = int(params['partner_id'])
partner = env['res.partner'].browse(partner_id)
if not partner.exists():
return {'error': 'Partner not found'}
amls = env['account.move.line'].search([
('partner_id', '=', partner_id),
('account_id.account_type', '=', 'asset_receivable'),
('parent_state', '=', 'posted'),
('reconciled', '=', False),
('company_id', '=', env.company.id),
])
return {
'partner': partner.name,
'balance': sum(aml.amount_residual for aml in amls),
'open_items': [{
'id': aml.id,
'ref': aml.ref or aml.move_id.name,
'date': str(aml.date),
'amount_residual': aml.amount_residual,
'date_maturity': str(aml.date_maturity) if aml.date_maturity else '',
} for aml in amls],
}
def send_followup(env, params):
partner_id = int(params['partner_id'])
partner = env['res.partner'].browse(partner_id)
if not partner.exists():
return {'error': 'Partner not found'}
options = {
'partner_id': partner_id,
'email': params.get('send_email', False),
'print': params.get('print_letter', False),
'sms': False,
}
if params.get('email_subject'):
options['email_subject'] = params['email_subject']
if params.get('body'):
options['body'] = params['body']
result = partner.execute_followup(options)
return {'status': 'sent', 'partner': partner.name, 'result': str(result) if result else 'done'}
def get_followup_report(env, params):
partner_id = int(params['partner_id'])
partner = env['res.partner'].browse(partner_id)
if not partner.exists():
return {'error': 'Partner not found'}
try:
report = env['account.followup.report']
html = report._get_followup_report_html(partner)
return {'partner': partner.name, 'html': html}
except Exception as e:
return {'error': str(e)}
def reconcile_payment_to_invoice(env, params):
move_line_ids = [int(x) for x in params['move_line_ids']]
amls = env['account.move.line'].browse(move_line_ids)
if len(amls) < 2:
return {'error': 'Need at least 2 journal items to reconcile'}
amls.reconcile()
return {
'status': 'reconciled',
'move_line_ids': move_line_ids,
}
def get_unmatched_payments(env, params):
domain = [
('account_id.account_type', '=', 'asset_receivable'),
('parent_state', '=', 'posted'),
('reconciled', '=', False),
('move_id.payment_id', '!=', False),
('company_id', '=', env.company.id),
]
amls = env['account.move.line'].search(domain, order='date desc')
return {
'count': len(amls),
'payments': [{
'id': aml.id,
'date': str(aml.date),
'ref': aml.ref or aml.move_id.name,
'partner': aml.partner_id.name if aml.partner_id else '',
'amount': abs(aml.amount_residual),
} for aml in amls[:50]],
}
TOOLS = {
'get_ar_aging': get_ar_aging,
'get_overdue_invoices': get_overdue_invoices,
'get_partner_balance': get_partner_balance,
'send_followup': send_followup,
'get_followup_report': get_followup_report,
'reconcile_payment_to_invoice': reconcile_payment_to_invoice,
'get_unmatched_payments': get_unmatched_payments,
}

View File

@@ -0,0 +1,111 @@
import logging
from odoo import fields
_logger = logging.getLogger(__name__)
def get_adp_receivable_aging(env, params):
accounts = env['account.account'].search([
('code', '=like', '1101%'),
('company_id', '=', env.company.id),
])
today = fields.Date.today()
amls = env['account.move.line'].search([
('account_id', 'in', accounts.ids),
('reconciled', '=', False),
('parent_state', '=', 'posted'),
])
buckets = {'current': 0, '1_30': 0, '31_60': 0, '61_90': 0, '90_plus': 0}
for aml in amls:
amt = abs(aml.amount_residual)
if not aml.date_maturity or aml.date_maturity >= today:
buckets['current'] += amt
else:
days = (today - aml.date_maturity).days
if days <= 30:
buckets['1_30'] += amt
elif days <= 60:
buckets['31_60'] += amt
elif days <= 90:
buckets['61_90'] += amt
else:
buckets['90_plus'] += amt
return {'total': sum(buckets.values()), 'buckets': buckets}
def match_adp_payment_to_invoice(env, params):
move_line_ids = [int(x) for x in params['move_line_ids']]
amls = env['account.move.line'].browse(move_line_ids).exists()
if len(amls) < 2:
return {'error': 'Need at least 2 existing journal items to reconcile'}
amls.reconcile()
return {'status': 'matched', 'move_line_ids': amls.ids}
def verify_adp_split(env, params):
invoice_id = int(params['invoice_id'])
invoice = env['account.move'].browse(invoice_id)
if not invoice.exists():
return {'error': 'Invoice not found'}
lines = invoice.invoice_line_ids
total = invoice.amount_untaxed
return {
'invoice': invoice.name,
'total_untaxed': total,
'total_with_tax': invoice.amount_total,
'lines': [{'name': l.name, 'subtotal': l.price_subtotal, 'total': l.price_total} for l in lines],
'balanced': abs(sum(l.price_subtotal for l in lines) - total) < 0.01,
}
def find_adp_without_payment(env, params):
adp_partner = env['res.partner'].search([('name', 'ilike', 'ADP')], limit=1)
if not adp_partner:
return {'status': 'info', 'message': 'No ADP partner found in the system.'}
invoices = env['account.move'].search([
('partner_id', '=', adp_partner.id),
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('payment_state', 'in', ('not_paid', 'partial')),
])
return {
'count': len(invoices),
'invoices': [{
'id': inv.id, 'name': inv.name,
'amount': inv.amount_residual, 'date': str(inv.date),
} for inv in invoices[:20]],
}
def get_adp_summary(env, params):
date_from = params.get('date_from')
date_to = params.get('date_to')
accounts = env['account.account'].search([
('code', '=like', '1101%'), ('company_id', '=', env.company.id),
])
domain = [
('account_id', 'in', accounts.ids),
('parent_state', '=', 'posted'),
]
if date_from:
domain.append(('date', '>=', date_from))
if date_to:
domain.append(('date', '<=', date_to))
lines = env['account.move.line'].search(domain)
total_debit = sum(l.debit for l in lines)
total_credit = sum(l.credit for l in lines)
return {
'period': f'{date_from or "all"} to {date_to or "now"}',
'billed': total_debit,
'collected': total_credit,
'outstanding': total_debit - total_credit,
}
TOOLS = {
'get_adp_receivable_aging': get_adp_receivable_aging,
'match_adp_payment_to_invoice': match_adp_payment_to_invoice,
'verify_adp_split': verify_adp_split,
'find_adp_without_payment': find_adp_without_payment,
'get_adp_summary': get_adp_summary,
}

View File

@@ -0,0 +1,156 @@
import logging
from odoo import fields
_logger = logging.getLogger(__name__)
def audit_posted_entry(env, params):
move_id = int(params['move_id'])
move = env['account.move'].browse(move_id)
if not move.exists():
return {'error': 'Entry not found'}
issues = []
total_debit = sum(l.debit for l in move.line_ids)
total_credit = sum(l.credit for l in move.line_ids)
if abs(total_debit - total_credit) > 0.01:
issues.append({'severity': 'critical', 'issue': f'Unbalanced entry: debit={total_debit}, credit={total_credit}'})
for line in move.line_ids:
if not line.account_id:
issues.append({'severity': 'critical', 'issue': f'Line missing account: {line.name}'})
if not move.line_ids:
issues.append({'severity': 'warning', 'issue': 'Entry has no lines'})
return {
'move': move.name, 'date': str(move.date),
'issues': issues, 'clean': len(issues) == 0,
}
def audit_account_balances(env, params):
from .journal_review import find_wrong_direction_balances
return find_wrong_direction_balances(env, params)
def audit_tax_compliance(env, params):
from .hst_management import find_missing_tax_invoices, find_missing_itc_bills
invoices = find_missing_tax_invoices(env, params)
bills = find_missing_itc_bills(env, params)
return {
'missing_tax_invoices': invoices.get('missing_tax_count', 0),
'missing_itc_bills': bills.get('missing_itc_count', 0),
'total_issues': invoices.get('missing_tax_count', 0) + bills.get('missing_itc_count', 0),
}
def audit_reconciliation_integrity(env, params):
from .journal_review import verify_reconciliation_integrity
return verify_reconciliation_integrity(env, params)
def check_hash_chain(env, params):
from .month_end import run_hash_integrity_check
return run_hash_integrity_check(env, params)
def check_sequence_gaps(env, params):
from .journal_review import find_sequence_gaps
return find_sequence_gaps(env, params)
def flag_entry(env, params):
move_id = int(params['move_id'])
flag = params.get('flag', 'Review Required')
recommendation = params.get('recommendation', '')
move = env['account.move'].browse(move_id)
if not move.exists():
return {'error': 'Entry not found'}
body = f'<strong>🏴 {flag}</strong><br/>{recommendation}'
move.message_post(body=body, message_type='comment', subtype_xmlid='mail.mt_note')
return {'status': 'flagged', 'move': move.name, 'flag': flag}
def get_audit_status(env, params):
statuses = env['account.audit.account.status'].search([])
return {
'statuses': [{
'id': s.id,
'account': s.account_id.name,
'status': s.status,
'audit': s.audit_id.display_name if s.audit_id else '',
} for s in statuses[:50]],
}
def set_audit_status(env, params):
status_id = int(params['status_id'])
new_status = params['status']
rec = env['account.audit.account.status'].browse(status_id)
if not rec.exists():
return {'error': 'Audit status record not found'}
rec.status = new_status
return {'status': 'updated', 'id': status_id, 'new_status': new_status}
def get_audit_trail(env, params):
move_id = int(params['move_id'])
move = env['account.move'].browse(move_id)
if not move.exists():
return {'error': 'Entry not found'}
messages = env['mail.message'].search([
('model', '=', 'account.move'),
('res_id', '=', move_id),
], order='date desc', limit=20)
return {
'move': move.name,
'messages': [{
'date': str(m.date),
'author': m.author_id.name if m.author_id else '',
'body': m.body or '',
'type': m.message_type,
} for m in messages],
}
def run_full_audit(env, params):
results = {}
results['account_balances'] = audit_account_balances(env, params)
results['tax_compliance'] = audit_tax_compliance(env, params)
results['reconciliation'] = audit_reconciliation_integrity(env, params)
results['hash_chain'] = check_hash_chain(env, params)
results['sequence_gaps'] = check_sequence_gaps(env, params)
total_issues = 0
for key, val in results.items():
total_issues += val.get('count', 0) + val.get('total_issues', 0)
score = max(0, 100 - total_issues * 5)
return {
'score': min(100, score),
'total_issues': total_issues,
'details': results,
}
def get_audit_report(env, params):
audit = run_full_audit(env, params)
report_lines = [f"Audit Score: {audit['score']}/100", f"Total Issues: {audit['total_issues']}", '']
for domain, detail in audit.get('details', {}).items():
report_lines.append(f"--- {domain.replace('_', ' ').title()} ---")
count = detail.get('count', detail.get('total_issues', 0))
report_lines.append(f" Issues: {count}")
return {'report': '\n'.join(report_lines), 'score': audit['score']}
TOOLS = {
'audit_posted_entry': audit_posted_entry,
'audit_account_balances': audit_account_balances,
'audit_tax_compliance': audit_tax_compliance,
'audit_reconciliation_integrity': audit_reconciliation_integrity,
'check_hash_chain': check_hash_chain,
'check_sequence_gaps': check_sequence_gaps,
'flag_entry': flag_entry,
'get_audit_status': get_audit_status,
'set_audit_status': set_audit_status,
'get_audit_trail': get_audit_trail,
'run_full_audit': run_full_audit,
'get_audit_report': get_audit_report,
}

View File

@@ -0,0 +1,177 @@
import logging
from datetime import datetime
_logger = logging.getLogger(__name__)
def get_unreconciled_bank_lines(env, params):
domain = [('is_reconciled', '=', False), ('company_id', '=', env.company.id)]
if params.get('journal_id'):
domain.append(('journal_id', '=', int(params['journal_id'])))
if params.get('date_from'):
domain.append(('date', '>=', params['date_from']))
if params.get('date_to'):
domain.append(('date', '<=', params['date_to']))
if params.get('min_amount'):
domain.append(('amount', '>=', float(params['min_amount'])))
limit = int(params.get('limit', 50))
lines = env['account.bank.statement.line'].search(domain, limit=limit, order='date desc')
return {
'count': len(lines),
'total_amount': sum(abs(l.amount) for l in lines),
'lines': [{
'id': l.id,
'date': str(l.date),
'payment_ref': l.payment_ref or '',
'partner_name': l.partner_name or (l.partner_id.name if l.partner_id else ''),
'amount': l.amount,
'journal': l.journal_id.name,
} for l in lines],
}
def get_unreconciled_receipts(env, params):
account_code = params.get('account_code', '1122')
accounts = env['account.account'].search([
('code', '=like', f'{account_code}%'),
('company_id', '=', env.company.id),
])
domain = [
('account_id', 'in', accounts.ids),
('reconciled', '=', False),
('parent_state', '=', 'posted'),
('company_id', '=', env.company.id),
]
lines = env['account.move.line'].search(domain, order='date desc')
return {
'count': len(lines),
'total_amount': sum(abs(l.amount_residual) for l in lines),
'lines': [{
'id': l.id,
'date': str(l.date),
'ref': l.ref or l.move_id.name,
'partner': l.partner_id.name if l.partner_id else '',
'amount_residual': l.amount_residual,
} for l in lines],
}
def match_bank_line_to_payments(env, params):
st_line_id = int(params['statement_line_id'])
move_line_ids = [int(x) for x in params['move_line_ids']]
st_line = env['account.bank.statement.line'].browse(st_line_id)
if not st_line.exists():
return {'error': 'Statement line not found'}
st_line.set_line_bank_statement_line(move_line_ids)
return {
'status': 'matched',
'statement_line_id': st_line_id,
'matched_move_lines': move_line_ids,
'is_reconciled': st_line.is_reconciled,
}
def auto_reconcile_bank_lines(env, params):
company_id = params.get('company_id', env.company.id)
lines = env['account.bank.statement.line'].search([
('is_reconciled', '=', False),
('company_id', '=', int(company_id)),
])
before_count = len(lines)
lines._try_auto_reconcile_statement_lines(company_id=int(company_id))
still_unreconciled = env['account.bank.statement.line'].search([
('is_reconciled', '=', False),
('company_id', '=', int(company_id)),
])
reconciled_count = before_count - len(still_unreconciled)
return {
'status': 'completed',
'lines_before': before_count,
'lines_reconciled': reconciled_count,
'lines_remaining': len(still_unreconciled),
}
def apply_reconcile_model(env, params):
model_id = int(params['model_id'])
st_line_id = int(params['statement_line_id'])
reco_model = env['account.reconcile.model'].browse(model_id)
st_line = env['account.bank.statement.line'].browse(st_line_id)
if not reco_model.exists() or not st_line.exists():
return {'error': 'Model or statement line not found'}
_liquidity_lines, suspense_lines, _other_lines = st_line._seek_for_lines()
residual = sum(l.amount_residual for l in suspense_lines) if suspense_lines else st_line.amount
write_off_vals = reco_model._get_write_off_move_lines_dict(st_line, residual)
if write_off_vals:
line_ids_create_command = [(0, 0, vals) for vals in write_off_vals]
st_line.move_id.write({'line_ids': line_ids_create_command})
return {
'status': 'applied',
'model': reco_model.name,
'write_off_lines': len(write_off_vals) if write_off_vals else 0,
}
def unmatch_bank_line(env, params):
st_line_id = int(params['statement_line_id'])
st_line = env['account.bank.statement.line'].browse(st_line_id)
if not st_line.exists():
return {'error': 'Statement line not found'}
st_line.action_unreconcile_entry()
return {'status': 'unmatched', 'statement_line_id': st_line_id}
def get_reconcile_suggestions(env, params):
st_line_id = int(params['statement_line_id'])
st_line = env['account.bank.statement.line'].browse(st_line_id)
if not st_line.exists():
return {'error': 'Statement line not found'}
models = env['account.reconcile.model'].search([
('company_id', '=', env.company.id),
])
return {
'models': [{
'id': m.id,
'name': m.name,
'trigger': m.trigger if hasattr(m, 'trigger') else 'manual',
} for m in models],
}
def sum_payments_by_date(env, params):
date_from = params.get('date_from')
date_to = params.get('date_to')
if not date_from or not date_to:
return {'error': 'date_from and date_to are required'}
journal_ids = params.get('journal_ids', [])
domain = [
('parent_state', '=', 'posted'),
('company_id', '=', env.company.id),
('date', '>=', date_from),
('date', '<=', date_to),
]
if journal_ids:
domain.append(('journal_id', 'in', [int(j) for j in journal_ids]))
lines = env['account.move.line'].search(domain)
total_debit = sum(l.debit for l in lines)
total_credit = sum(l.credit for l in lines)
return {
'date_from': date_from,
'date_to': date_to,
'total_debit': total_debit,
'total_credit': total_credit,
'net': total_debit - total_credit,
'line_count': len(lines),
}
TOOLS = {
'get_unreconciled_bank_lines': get_unreconciled_bank_lines,
'get_unreconciled_receipts': get_unreconciled_receipts,
'match_bank_line_to_payments': match_bank_line_to_payments,
'auto_reconcile_bank_lines': auto_reconcile_bank_lines,
'apply_reconcile_model': apply_reconcile_model,
'unmatch_bank_line': unmatch_bank_line,
'get_reconcile_suggestions': get_reconcile_suggestions,
'sum_payments_by_date': sum_payments_by_date,
}

View File

@@ -0,0 +1,171 @@
import logging
_logger = logging.getLogger(__name__)
def calculate_hst_balance(env, params):
date_from = params.get('date_from')
date_to = params.get('date_to')
base_domain = [
('parent_state', '=', 'posted'),
('company_id', '=', env.company.id),
]
if date_from:
base_domain.append(('date', '>=', date_from))
if date_to:
base_domain.append(('date', '<=', date_to))
collected_accounts = env['account.account'].search([
('code', '=like', '2005%'), ('company_id', '=', env.company.id),
])
itc_accounts = env['account.account'].search([
('code', '=like', '2006%'), ('company_id', '=', env.company.id),
])
collected_lines = env['account.move.line'].search(
base_domain + [('account_id', 'in', collected_accounts.ids)]
)
itc_lines = env['account.move.line'].search(
base_domain + [('account_id', 'in', itc_accounts.ids)]
)
hst_collected = abs(sum(l.balance for l in collected_lines))
itcs = abs(sum(l.balance for l in itc_lines))
return {
'hst_collected': hst_collected,
'input_tax_credits': itcs,
'net_hst': hst_collected - itcs,
'status': 'owing' if (hst_collected - itcs) > 0 else 'refund',
'period': f'{date_from or "all"} to {date_to or "now"}',
}
def get_tax_report(env, params):
report_ref = params.get('report_ref', 'account.generic_tax_report')
try:
report = env.ref(report_ref)
except Exception:
return {'error': f'Report not found: {report_ref}'}
options = report.get_options({
'date': {
'date_from': params.get('date_from', ''),
'date_to': params.get('date_to', ''),
}
})
lines = report._get_lines(options)
return {
'report_name': report.name,
'lines': [{
'name': l.get('name', ''),
'columns': [c.get('no_format', c.get('name', '')) for c in l.get('columns', [])],
} for l in lines[:50]],
}
def find_missing_tax_invoices(env, params):
date_from = params.get('date_from')
date_to = params.get('date_to')
domain = [
('move_type', 'in', ('out_invoice', 'out_refund')),
('state', '=', 'posted'),
('company_id', '=', env.company.id),
]
if date_from:
domain.append(('date', '>=', date_from))
if date_to:
domain.append(('date', '<=', date_to))
invoices = env['account.move'].search(domain)
missing = invoices.filtered(
lambda inv: not any(line.tax_ids for line in inv.invoice_line_ids)
)
return {
'total_invoices': len(invoices),
'missing_tax_count': len(missing),
'invoices': [{
'id': inv.id,
'name': inv.name,
'partner': inv.partner_id.name if inv.partner_id else '',
'amount_total': inv.amount_total,
'date': str(inv.date),
} for inv in missing[:30]],
}
def find_missing_itc_bills(env, params):
date_from = params.get('date_from')
date_to = params.get('date_to')
domain = [
('move_type', 'in', ('in_invoice', 'in_refund')),
('state', '=', 'posted'),
('company_id', '=', env.company.id),
]
if date_from:
domain.append(('date', '>=', date_from))
if date_to:
domain.append(('date', '<=', date_to))
bills = env['account.move'].search(domain)
missing = bills.filtered(
lambda b: not any(line.tax_ids for line in b.invoice_line_ids)
)
return {
'total_bills': len(bills),
'missing_itc_count': len(missing),
'bills': [{
'id': b.id,
'name': b.name,
'partner': b.partner_id.name if b.partner_id else '',
'amount_total': b.amount_total,
'date': str(b.date),
} for b in missing[:30]],
}
def get_tax_return_status(env, params):
returns = env['account.return'].search([
('company_id', '=', env.company.id),
], order='date_start desc', limit=10)
return {
'returns': [{
'id': r.id,
'name': r.display_name,
'date_start': str(r.date_start) if hasattr(r, 'date_start') else '',
'date_end': str(r.date_end) if hasattr(r, 'date_end') else '',
'state': r.state if hasattr(r, 'state') else '',
} for r in returns],
}
def generate_tax_return(env, params):
try:
env['account.return']._generate_or_refresh_all_returns(
company=env.company
)
return {'status': 'generated', 'message': 'Tax returns refreshed successfully.'}
except Exception as e:
return {'error': str(e)}
def validate_tax_return(env, params):
return_id = int(params['return_id'])
tax_return = env['account.return'].browse(return_id)
if not tax_return.exists():
return {'error': 'Tax return not found'}
try:
tax_return.action_validate()
return {'status': 'validated', 'return_id': return_id}
except Exception as e:
return {'error': str(e)}
TOOLS = {
'calculate_hst_balance': calculate_hst_balance,
'get_tax_report': get_tax_report,
'find_missing_tax_invoices': find_missing_tax_invoices,
'find_missing_itc_bills': find_missing_itc_bills,
'get_tax_return_status': get_tax_return_status,
'generate_tax_return': generate_tax_return,
'validate_tax_return': validate_tax_return,
}

View File

@@ -0,0 +1,113 @@
import logging
from odoo import fields
_logger = logging.getLogger(__name__)
def get_stock_valuation(env, params):
accounts = env['account.account'].search([
('code', '=like', '1069%'),
('company_id', '=', env.company.id),
])
result = []
for acct in accounts:
balance = sum(env['account.move.line'].search([
('account_id', '=', acct.id),
('parent_state', '=', 'posted'),
]).mapped('balance'))
result.append({'code': acct.code, 'name': acct.name, 'balance': balance})
return {'accounts': result, 'total': sum(r['balance'] for r in result)}
def get_price_differences(env, params):
accounts = env['account.account'].search([
('code', '=like', '5010%'),
('company_id', '=', env.company.id),
])
domain = [
('account_id', 'in', accounts.ids),
('parent_state', '=', 'posted'),
('company_id', '=', env.company.id),
]
if params.get('date_from'):
domain.append(('date', '>=', params['date_from']))
if params.get('date_to'):
domain.append(('date', '<=', params['date_to']))
lines = env['account.move.line'].search(domain, order='date desc', limit=50)
return {
'total': sum(l.balance for l in lines),
'entries': [{
'id': l.id, 'date': str(l.date),
'move': l.move_id.name, 'amount': l.balance,
'partner': l.partner_id.name if l.partner_id else '',
} for l in lines],
}
def get_cogs_ratio_by_category(env, params):
date_from = params.get('date_from')
date_to = params.get('date_to')
base_domain = [
('parent_state', '=', 'posted'),
('company_id', '=', env.company.id),
]
if date_from:
base_domain.append(('date', '>=', date_from))
if date_to:
base_domain.append(('date', '<=', date_to))
revenue_lines = env['account.move.line'].search(
base_domain + [('account_id.account_type', '=', 'income')]
)
cogs_lines = env['account.move.line'].search(
base_domain + [('account_id.account_type', '=', 'expense_direct_cost')]
)
revenue = abs(sum(l.balance for l in revenue_lines))
cogs = abs(sum(l.balance for l in cogs_lines))
ratio = (cogs / revenue * 100) if revenue else 0
return {'revenue': revenue, 'cogs': cogs, 'ratio_pct': round(ratio, 2)}
def find_unusual_adjustments(env, params):
threshold = float(params.get('threshold', 1000))
domain = [
('parent_state', '=', 'posted'),
('company_id', '=', env.company.id),
('account_id.account_type', '=', 'expense_direct_cost'),
]
lines = env['account.move.line'].search(domain)
unusual = lines.filtered(lambda l: abs(l.balance) > threshold)
return {
'count': len(unusual),
'adjustments': [{
'id': l.id, 'date': str(l.date), 'move': l.move_id.name,
'amount': l.balance, 'name': l.name or '',
} for l in unusual[:20]],
}
def get_inventory_turnover(env, params):
from .reporting import get_profit_loss
pl = get_profit_loss(env, params)
stock = get_stock_valuation(env, params)
avg_inventory = stock.get('total', 0)
cogs = 0
for line in pl.get('lines', []):
if 'cost' in line.get('name', '').lower():
cols = line.get('columns', [])
if cols:
try:
cogs = float(cols[0])
except (ValueError, TypeError):
pass
turnover = (cogs / avg_inventory) if avg_inventory else 0
return {'cogs': cogs, 'avg_inventory': avg_inventory, 'turnover': round(turnover, 2)}
TOOLS = {
'get_stock_valuation': get_stock_valuation,
'get_price_differences': get_price_differences,
'get_cogs_ratio_by_category': get_cogs_ratio_by_category,
'find_unusual_adjustments': find_unusual_adjustments,
'get_inventory_turnover': get_inventory_turnover,
}

View File

@@ -0,0 +1,220 @@
import logging
from odoo import fields
_logger = logging.getLogger(__name__)
ACCOUNT_TYPE_EXPECTED_DIRECTION = {
'asset_receivable': 'debit',
'asset_cash': 'debit',
'asset_current': 'debit',
'asset_non_current': 'debit',
'asset_prepayments': 'debit',
'asset_fixed': 'debit',
'liability_payable': 'credit',
'liability_credit_card': 'credit',
'liability_current': 'credit',
'liability_non_current': 'credit',
'equity': 'credit',
'equity_unaffected': 'credit',
'income': 'credit',
'income_other': 'credit',
'expense': 'debit',
'expense_depreciation': 'debit',
'expense_direct_cost': 'debit',
'off_balance': None,
}
def find_wrong_direction_balances(env, params):
balance_data = env['account.move.line'].read_group(
[('parent_state', '=', 'posted'), ('company_id', '=', env.company.id)],
['balance:sum'], ['account_id'],
)
acct_ids = [row['account_id'][0] for row in balance_data if row.get('account_id')]
acct_map = {}
if acct_ids:
for acct in env['account.account'].browse(acct_ids):
acct_map[acct.id] = acct
issues = []
for row in balance_data:
if not row.get('account_id'):
continue
acct = acct_map.get(row['account_id'][0])
if not acct:
continue
expected = ACCOUNT_TYPE_EXPECTED_DIRECTION.get(acct.account_type)
if not expected:
continue
balance = row.get('balance', 0) or 0
if (expected == 'debit' and balance < -0.01) or (expected == 'credit' and balance > 0.01):
issues.append({
'account_id': acct.id,
'code': acct.code,
'name': acct.name,
'balance': balance,
'expected': expected,
'actual': 'credit' if balance < 0 else 'debit',
})
return {'count': len(issues), 'issues': issues}
def find_duplicate_entries(env, params):
date_from = params.get('date_from')
date_to = params.get('date_to')
domain = [
('state', '=', 'posted'),
('company_id', '=', env.company.id),
]
if date_from:
domain.append(('date', '>=', date_from))
if date_to:
domain.append(('date', '<=', date_to))
moves = env['account.move'].search(domain, order='partner_id, amount_total, date')
duplicates = []
prev = None
for move in moves:
if prev and (
prev.partner_id == move.partner_id and prev.partner_id
and abs(prev.amount_total - move.amount_total) < 0.01
and prev.date == move.date
and prev.journal_id == move.journal_id
):
duplicates.append({
'entry_1': {'id': prev.id, 'name': prev.name},
'entry_2': {'id': move.id, 'name': move.name},
'partner': move.partner_id.name,
'amount': move.amount_total,
'date': str(move.date),
})
prev = move
return {'count': len(duplicates), 'duplicates': duplicates[:20]}
def find_wrong_account_entries(env, params):
date_from = params.get('date_from')
date_to = params.get('date_to')
domain = [
('parent_state', '=', 'posted'),
('company_id', '=', env.company.id),
]
if date_from:
domain.append(('date', '>=', date_from))
if date_to:
domain.append(('date', '<=', date_to))
issues = []
tax_accounts = env['account.account'].search([
('account_type', 'in', ('liability_current', 'asset_current')),
('code', '=like', '2005%'),
('company_id', '=', env.company.id),
])
if tax_accounts:
revenue_on_tax = env['account.move.line'].search(
domain + [
('account_id', 'in', tax_accounts.ids),
('product_id', '!=', False),
]
)
for line in revenue_on_tax[:20]:
issues.append({
'id': line.id,
'move': line.move_id.name,
'account': f'{line.account_id.code} {line.account_id.name}',
'product': line.product_id.name,
'amount': line.balance,
'issue': 'Product line on tax account',
})
return {'count': len(issues), 'issues': issues}
def find_sequence_gaps(env, params):
moves = env['account.move'].search([
('state', '=', 'posted'),
('company_id', '=', env.company.id),
('made_sequence_gap', '=', True),
], order='date desc', limit=50)
return {
'count': len(moves),
'gaps': [{
'id': m.id,
'name': m.name,
'date': str(m.date),
'journal': m.journal_id.name,
} for m in moves],
}
def find_draft_entries(env, params):
min_age_days = int(params.get('min_age_days', 30))
from datetime import timedelta
cutoff = fields.Date.today() - timedelta(days=min_age_days)
drafts = env['account.move'].search([
('state', '=', 'draft'),
('date', '<=', cutoff),
('company_id', '=', env.company.id),
], order='date asc', limit=50)
return {
'count': len(drafts),
'entries': [{
'id': d.id,
'name': d.name or 'Draft',
'date': str(d.date),
'journal': d.journal_id.name,
'amount': d.amount_total,
'partner': d.partner_id.name if d.partner_id else '',
} for d in drafts],
}
def find_unreconciled_suspense(env, params):
suspense_accounts = env['account.account'].search([
('code', '=like', '999%'),
('company_id', '=', env.company.id),
])
issues = []
for acct in suspense_accounts:
balance = sum(env['account.move.line'].search([
('account_id', '=', acct.id),
('parent_state', '=', 'posted'),
]).mapped('balance'))
if abs(balance) > 0.01:
issues.append({
'account_id': acct.id,
'code': acct.code,
'name': acct.name,
'balance': balance,
})
return {'count': len(issues), 'accounts': issues}
def verify_reconciliation_integrity(env, params):
partials = env['account.partial.reconcile'].search([
('company_id', '=', env.company.id),
], limit=500)
issues = []
for p in partials:
debit_ok = p.debit_move_id.reconciled or abs(p.debit_move_id.amount_residual) < 0.01
credit_ok = p.credit_move_id.reconciled or abs(p.credit_move_id.amount_residual) < 0.01
if not debit_ok and not credit_ok:
issues.append({
'id': p.id,
'debit_move': p.debit_move_id.move_id.name,
'credit_move': p.credit_move_id.move_id.name,
'amount': p.amount,
'debit_residual': p.debit_move_id.amount_residual,
'credit_residual': p.credit_move_id.amount_residual,
})
return {'count': len(issues), 'issues': issues[:20]}
TOOLS = {
'find_wrong_direction_balances': find_wrong_direction_balances,
'find_duplicate_entries': find_duplicate_entries,
'find_wrong_account_entries': find_wrong_account_entries,
'find_sequence_gaps': find_sequence_gaps,
'find_draft_entries': find_draft_entries,
'find_unreconciled_suspense': find_unreconciled_suspense,
'verify_reconciliation_integrity': verify_reconciliation_integrity,
}

View File

@@ -0,0 +1,130 @@
import logging
from odoo import fields
_logger = logging.getLogger(__name__)
def get_close_checklist(env, params):
from .bank_reconciliation import get_unreconciled_bank_lines
from .journal_review import find_draft_entries, find_sequence_gaps
from .hst_management import calculate_hst_balance
period = params.get('period', str(fields.Date.today())[:7])
date_from = f'{period}-01'
import calendar
year, month = int(period[:4]), int(period[5:7])
last_day = calendar.monthrange(year, month)[1]
date_to = f'{period}-{last_day:02d}'
p = {'date_from': date_from, 'date_to': date_to}
bank = get_unreconciled_bank_lines(env, p)
drafts = find_draft_entries(env, {'min_age_days': '0'})
gaps = find_sequence_gaps(env, p)
hst = calculate_hst_balance(env, p)
checklist = [
{'item': 'Bank Reconciliation', 'status': 'ok' if bank['count'] == 0 else 'attention', 'detail': f"{bank['count']} unreconciled lines"},
{'item': 'Draft Entries', 'status': 'ok' if drafts['count'] == 0 else 'attention', 'detail': f"{drafts['count']} draft entries"},
{'item': 'Sequence Gaps', 'status': 'ok' if gaps['count'] == 0 else 'warning', 'detail': f"{gaps['count']} gaps found"},
{'item': 'HST Balance', 'status': 'info', 'detail': f"Net HST: ${hst['net_hst']:.2f}"},
]
return {'period': period, 'checklist': checklist}
def get_unreconciled_counts(env, params):
accounts = env['account.account'].search([
('reconcile', '=', True),
('company_id', '=', env.company.id),
])
result = []
for acct in accounts:
count = env['account.move.line'].search_count([
('account_id', '=', acct.id),
('reconciled', '=', False),
('parent_state', '=', 'posted'),
])
if count > 0:
result.append({
'account_id': acct.id,
'code': acct.code,
'name': acct.name,
'unreconciled_count': count,
})
return {'accounts': sorted(result, key=lambda x: -x['unreconciled_count'])}
def find_entries_in_locked_period(env, params):
company = env.company
lock_date = company.fiscalyear_lock_date
if not lock_date:
return {'status': 'no_lock_date', 'entries': []}
entries = env['account.move'].search([
('date', '<=', lock_date),
('state', '=', 'draft'),
('company_id', '=', company.id),
])
return {
'lock_date': str(lock_date),
'count': len(entries),
'entries': [{'id': e.id, 'name': e.name, 'date': str(e.date)} for e in entries[:20]],
}
def get_accrual_status(env, params):
accrual_codes = params.get('account_codes', ['2100', '2110', '2120'])
result = []
for code in accrual_codes:
accounts = env['account.account'].search([
('code', '=like', f'{code}%'),
('company_id', '=', env.company.id),
])
for acct in accounts:
balance = sum(env['account.move.line'].search([
('account_id', '=', acct.id),
('parent_state', '=', 'posted'),
]).mapped('balance'))
result.append({'code': acct.code, 'name': acct.name, 'balance': balance})
return {'accruals': result}
def run_hash_integrity_check(env, params):
try:
result = env.company._check_hash_integrity()
return {
'status': 'completed',
'results': result.get('results', []),
'printing_date': result.get('printing_date', ''),
}
except Exception as e:
return {'error': str(e)}
def get_period_summary(env, params):
date_from = params.get('date_from')
date_to = params.get('date_to')
try:
report = env.ref('account_reports.trial_balance_report')
except Exception:
report = env.ref('account.trial_balance_report', raise_if_not_found=False)
if not report:
return {'error': 'Trial balance report not found'}
options = report.get_options({'date': {'date_from': date_from, 'date_to': date_to}})
lines = report._get_lines(options)
return {
'period': f'{date_from} to {date_to}',
'lines': [{
'name': l.get('name', ''),
'columns': [c.get('no_format', c.get('name', '')) for c in l.get('columns', [])],
} for l in lines[:100]],
}
TOOLS = {
'get_close_checklist': get_close_checklist,
'get_unreconciled_counts': get_unreconciled_counts,
'find_entries_in_locked_period': find_entries_in_locked_period,
'get_accrual_status': get_accrual_status,
'run_hash_integrity_check': run_hash_integrity_check,
'get_period_summary': get_period_summary,
}

View File

@@ -0,0 +1,205 @@
import logging
from odoo import fields
_logger = logging.getLogger(__name__)
def get_payroll_entries(env, params):
payroll_journals = env['account.journal'].search([
('name', 'ilike', 'payroll'),
('company_id', '=', env.company.id),
])
if not payroll_journals and params.get('journal_id'):
payroll_journals = env['account.journal'].browse(int(params['journal_id']))
domain = [
('journal_id', 'in', payroll_journals.ids),
('state', '=', 'posted'),
('company_id', '=', env.company.id),
]
if params.get('date_from'):
domain.append(('date', '>=', params['date_from']))
if params.get('date_to'):
domain.append(('date', '<=', params['date_to']))
entries = env['account.move'].search(domain, order='date desc', limit=50)
return {
'count': len(entries),
'entries': [{
'id': e.id, 'name': e.name, 'date': str(e.date),
'amount': e.amount_total, 'ref': e.ref or '',
} for e in entries],
}
def compare_payroll_to_bank(env, params):
date_from = params.get('date_from')
date_to = params.get('date_to')
if not date_from or not date_to:
return {'error': 'date_from and date_to are required'}
payroll_journals = env['account.journal'].search([
('name', 'ilike', 'payroll'), ('company_id', '=', env.company.id),
])
payroll_entries = env['account.move'].search([
('journal_id', 'in', payroll_journals.ids),
('state', '=', 'posted'),
('date', '>=', date_from), ('date', '<=', date_to),
])
bank_lines = env['account.bank.statement.line'].search([
('date', '>=', date_from), ('date', '<=', date_to),
('company_id', '=', env.company.id),
])
payroll_total = sum(e.amount_total for e in payroll_entries)
bank_payroll = sum(abs(l.amount) for l in bank_lines if 'payroll' in (l.payment_ref or '').lower())
return {
'payroll_journal_total': payroll_total,
'bank_payroll_total': bank_payroll,
'difference': payroll_total - bank_payroll,
}
def verify_source_deductions(env, params):
return {
'status': 'info',
'message': 'Source deduction verification requires CRA rate tables. Use fusion_payroll for full verification.',
}
def get_cra_remittance_status(env, params):
cra_accounts = env['account.account'].search([
('name', 'ilike', 'CRA'),
('company_id', '=', env.company.id),
])
result = []
for acct in cra_accounts:
balance = sum(env['account.move.line'].search([
('account_id', '=', acct.id),
('parent_state', '=', 'posted'),
]).mapped('balance'))
result.append({'code': acct.code, 'name': acct.name, 'balance': balance})
return {'accounts': result}
def find_unmatched_payroll_cheques(env, params):
bank_lines = env['account.bank.statement.line'].search([
('is_reconciled', '=', False),
('company_id', '=', env.company.id),
('payment_ref', 'ilike', 'cheque'),
])
return {
'count': len(bank_lines),
'cheques': [{
'id': l.id, 'date': str(l.date),
'ref': l.payment_ref, 'amount': l.amount,
} for l in bank_lines[:30]],
}
def parse_payroll_summary(env, params):
import re
raw_data = params.get('data', '')
if not raw_data:
return {'error': 'No payroll data provided'}
lines = raw_data.strip().split('\n')
entries = []
totals = {'gross': 0, 'cpp': 0, 'ei': 0, 'tax': 0, 'net': 0}
for line in lines:
amounts = re.findall(r'\$?([\d,]+\.?\d*)', line)
if len(amounts) >= 2:
name_part = re.sub(r'\$?[\d,]+\.?\d*', '', line).strip(' \t,|-')
parsed_amounts = [float(a.replace(',', '')) for a in amounts]
entry = {'name': name_part or 'Employee', 'amounts': parsed_amounts}
if len(parsed_amounts) >= 5:
entry.update({
'gross': parsed_amounts[0],
'cpp': parsed_amounts[1],
'ei': parsed_amounts[2],
'tax': parsed_amounts[3],
'net': parsed_amounts[4] if len(parsed_amounts) > 4 else parsed_amounts[0] - sum(parsed_amounts[1:4]),
})
for k in ('gross', 'cpp', 'ei', 'tax', 'net'):
totals[k] += entry.get(k, 0)
entries.append(entry)
return {
'status': 'parsed',
'employee_count': len(entries),
'entries': entries,
'totals': totals,
'raw_lines': len(lines),
}
def create_payroll_journal_entry(env, params):
journal_id = int(params['journal_id'])
date = params['date']
lines_data = params['lines']
move_vals = {
'journal_id': journal_id,
'date': date,
'ref': params.get('ref', 'Payroll Entry'),
'line_ids': [(0, 0, {
'account_id': int(line['account_id']),
'name': line.get('name', 'Payroll'),
'debit': float(line.get('debit', 0)),
'credit': float(line.get('credit', 0)),
'partner_id': int(line['partner_id']) if line.get('partner_id') else False,
}) for line in lines_data],
}
move = env['account.move'].create(move_vals)
return {'status': 'created', 'move_id': move.id, 'name': move.name}
def get_payroll_schedule(env, params):
return {'status': 'info', 'message': 'Payroll schedule available via fusion_payroll module.'}
def match_payroll_cheques(env, params):
st_line_id = int(params['statement_line_id'])
move_line_ids = [int(x) for x in params['move_line_ids']]
st_line = env['account.bank.statement.line'].browse(st_line_id)
st_line.set_line_bank_statement_line(move_line_ids)
return {'status': 'matched', 'statement_line_id': st_line_id}
def verify_payroll_deductions(env, params):
return verify_source_deductions(env, params)
def get_cra_remittance_due(env, params):
return get_cra_remittance_status(env, params)
def prepare_cra_payment(env, params):
return create_payroll_journal_entry(env, params)
def generate_t4(env, params):
return {'status': 'info', 'message': 'T4 generation available via fusion_payroll module.'}
def generate_roe(env, params):
return {'status': 'info', 'message': 'ROE generation available via fusion_payroll module.'}
def get_payroll_cost_report(env, params):
return get_payroll_entries(env, params)
TOOLS = {
'get_payroll_entries': get_payroll_entries,
'compare_payroll_to_bank': compare_payroll_to_bank,
'verify_source_deductions': verify_source_deductions,
'get_cra_remittance_status': get_cra_remittance_status,
'find_unmatched_payroll_cheques': find_unmatched_payroll_cheques,
'parse_payroll_summary': parse_payroll_summary,
'create_payroll_journal_entry': create_payroll_journal_entry,
'get_payroll_schedule': get_payroll_schedule,
'match_payroll_cheques': match_payroll_cheques,
'verify_payroll_deductions': verify_payroll_deductions,
'get_cra_remittance_due': get_cra_remittance_due,
'prepare_cra_payment': prepare_cra_payment,
'generate_t4': generate_t4,
'generate_roe': generate_roe,
'get_payroll_cost_report': get_payroll_cost_report,
}

View File

@@ -0,0 +1,117 @@
import logging
import base64
_logger = logging.getLogger(__name__)
def _get_report(env, ref_id):
try:
return env.ref(ref_id)
except Exception:
return None
def _run_report(env, report_ref, params):
report = _get_report(env, report_ref)
if not report:
return {'error': f'Report {report_ref} not found'}
date_opts = {}
if params.get('date_from'):
date_opts['date_from'] = params['date_from']
if params.get('date_to'):
date_opts['date_to'] = params['date_to']
options = report.get_options({'date': date_opts} if date_opts else {})
lines = report._get_lines(options)
return {
'report_name': report.name,
'lines': [{
'name': l.get('name', ''),
'level': l.get('level', 0),
'columns': [c.get('no_format', c.get('name', '')) for c in l.get('columns', [])],
} for l in lines[:100]],
}
def get_profit_loss(env, params):
return _run_report(env, 'account_reports.profit_and_loss', params)
def get_balance_sheet(env, params):
return _run_report(env, 'account_reports.balance_sheet', params)
def get_trial_balance(env, params):
return _run_report(env, 'account_reports.trial_balance_report', params)
def get_cash_flow(env, params):
return _run_report(env, 'account_reports.cash_flow_statement', params)
def compare_periods(env, params):
report_ref = params.get('report_ref', 'account_reports.profit_and_loss')
report = _get_report(env, report_ref)
if not report:
return {'error': f'Report {report_ref} not found'}
period1 = _run_report(env, report_ref, {
'date_from': params.get('period1_from'),
'date_to': params.get('period1_to'),
})
period2 = _run_report(env, report_ref, {
'date_from': params.get('period2_from'),
'date_to': params.get('period2_to'),
})
return {'period_1': period1, 'period_2': period2}
def answer_financial_question(env, params):
question = params.get('question', '')
sql_query = params.get('sql_query')
if sql_query:
return {'error': 'Direct SQL not permitted. Use report tools instead.'}
return {'status': 'info', 'message': f'Use specific report tools to answer: {question}'}
def export_report(env, params):
report_ref = params.get('report_ref', 'account_reports.profit_and_loss')
fmt = params.get('format', 'pdf')
report = _get_report(env, report_ref)
if not report:
return {'error': f'Report {report_ref} not found'}
date_opts = {}
if params.get('date_from'):
date_opts['date_from'] = params['date_from']
if params.get('date_to'):
date_opts['date_to'] = params['date_to']
options = report.get_options({'date': date_opts} if date_opts else {})
try:
if fmt == 'xlsx':
result = report.dispatch_report_action(options, 'export_to_xlsx')
else:
result = report.dispatch_report_action(options, 'export_to_pdf')
if isinstance(result, dict) and result.get('file_content'):
return {
'file_name': result.get('file_name', f'report.{fmt}'),
'file_type': result.get('file_type', fmt),
'file_content_b64': base64.b64encode(result['file_content']).decode(),
}
return {
'status': 'generated',
'message': f'Report exported as {fmt}. Use the Odoo UI to download.',
}
except Exception as e:
return {'error': f'Export failed: {str(e)}'}
TOOLS = {
'get_profit_loss': get_profit_loss,
'get_balance_sheet': get_balance_sheet,
'get_trial_balance': get_trial_balance,
'get_cash_flow': get_cash_flow,
'compare_periods': compare_periods,
'answer_financial_question': answer_financial_question,
'export_report': export_report,
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -0,0 +1,20 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
export class FusionApprovalCard extends Component {
static template = "fusion_accounting.ApprovalCard";
static props = ["approval", "onApprove", "onReject"];
get confidencePercent() {
return Math.round((this.props.approval.confidence || 0) * 100);
}
approve() {
this.props.onApprove(this.props.approval.id);
}
reject() {
this.props.onReject(this.props.approval.id);
}
}

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting.ApprovalCard">
<div class="fusion_approval_card card border-warning mb-2">
<div class="card-body p-2">
<div class="d-flex justify-content-between align-items-start mb-1">
<strong t-esc="props.approval.tool_name"/>
<span class="badge bg-warning text-dark">
<t t-esc="confidencePercent"/>% conf
</span>
</div>
<p class="small mb-1 text-muted" t-esc="props.approval.reasoning"/>
<t t-if="props.approval.amount">
<p class="small mb-1">
Amount: <strong>$<t t-esc="(props.approval.amount || 0).toFixed(2)"/></strong>
</p>
</t>
<div class="d-flex gap-2">
<button class="btn btn-success btn-sm flex-grow-1" t-on-click="approve">
<i class="fa fa-check"/> Approve
</button>
<button class="btn btn-outline-danger btn-sm flex-grow-1" t-on-click="reject">
<i class="fa fa-times"/> Reject
</button>
</div>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,304 @@
/** @odoo-module **/
import { Component, useState, useRef, onWillStart, onMounted, onPatched } from "@odoo/owl";
import { rpc } from "@web/core/network/rpc";
import { FusionApprovalCard } from "./approval_card";
function mdToHtml(text) {
if (!text) return "";
// Split into lines for block-level processing
const lines = text.split("\n");
const output = [];
let inTable = false;
let tableHeader = false;
let inList = false;
let listType = null;
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
const trimmed = line.trim();
// Close list if we're not in a list item anymore
if (inList && !trimmed.match(/^[-*]\s/) && !trimmed.match(/^\d+\.\s/) && trimmed !== "") {
output.push(listType === "ul" ? "</ul>" : "</ol>");
inList = false;
listType = null;
}
// Table row detection (line has at least 2 pipes)
const pipeCount = (trimmed.match(/\|/g) || []).length;
if (pipeCount >= 2 && trimmed.includes("|")) {
// Separator row (|---|---|)
if (/^[\s|:\-]+$/.test(trimmed)) {
tableHeader = true;
continue;
}
// Split cells
let cells = trimmed.split("|").map(c => c.trim());
// Remove empty first/last from leading/trailing pipes
if (cells[0] === "") cells.shift();
if (cells.length > 0 && cells[cells.length - 1] === "") cells.pop();
if (!inTable) {
output.push('<div class="table-responsive my-2"><table class="table table-sm table-bordered align-middle">');
inTable = true;
// First row is header
output.push("<thead><tr>");
cells.forEach(c => output.push(`<th class="px-2 py-1 fw-semibold">${inlineFormat(c)}</th>`));
output.push("</tr></thead><tbody>");
continue;
}
// Body row
output.push("<tr>");
cells.forEach(c => output.push(`<td class="px-2 py-1">${inlineFormat(c)}</td>`));
output.push("</tr>");
continue;
}
// Close table if we were in one
if (inTable) {
output.push("</tbody></table></div>");
inTable = false;
tableHeader = false;
}
// Empty line
if (trimmed === "") {
output.push("");
continue;
}
// Headers
const headerMatch = trimmed.match(/^(#{1,5})\s+(.+)$/);
if (headerMatch) {
const level = Math.min(headerMatch[1].length + 2, 6); // ## -> h4, ### -> h5
output.push(`<h${level} class="mt-3 mb-1">${inlineFormat(headerMatch[2])}</h${level}>`);
continue;
}
// Horizontal rule
if (/^[-*_]{3,}$/.test(trimmed)) {
output.push('<hr class="my-2"/>');
continue;
}
// Unordered list
const ulMatch = trimmed.match(/^[-*]\s+(.+)$/);
if (ulMatch) {
if (!inList || listType !== "ul") {
if (inList) output.push(listType === "ul" ? "</ul>" : "</ol>");
output.push('<ul class="mb-1">');
inList = true;
listType = "ul";
}
output.push(`<li>${inlineFormat(ulMatch[1])}</li>`);
continue;
}
// Ordered list
const olMatch = trimmed.match(/^\d+\.\s+(.+)$/);
if (olMatch) {
if (!inList || listType !== "ol") {
if (inList) output.push(listType === "ul" ? "</ul>" : "</ol>");
output.push('<ol class="mb-1">');
inList = true;
listType = "ol";
}
output.push(`<li>${inlineFormat(olMatch[1])}</li>`);
continue;
}
// Regular paragraph
output.push(`<p class="mb-1">${inlineFormat(trimmed)}</p>`);
}
// Close open elements
if (inTable) output.push("</tbody></table></div>");
if (inList) output.push(listType === "ul" ? "</ul>" : "</ol>");
return output.join("\n");
}
function inlineFormat(text) {
if (!text) return "";
return text
// Escape HTML entities
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
// Bold + italic
.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
// Inline code
.replace(/`([^`]+)`/g, '<code>$1</code>')
// Links [text](url)
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>')
// Odoo record links #model,id
.replace(/#([\w.]+),(\d+)/g, '<a href="/odoo/$1/$2" class="badge text-bg-primary text-decoration-none">$1#$2</a>');
}
export class FusionChatPanel extends Component {
static template = "fusion_accounting.ChatPanel";
static components = { FusionApprovalCard };
static props = ["sessionId?"];
setup() {
this.inputRef = useRef("chatInput");
this.messagesRef = useRef("messages");
this.state = useState({
messages: [],
pendingApprovals: [],
inputText: "",
sending: false,
loading: true,
internalSessionId: null,
sessionName: null,
});
onWillStart(async () => {
await this.loadLatestSession();
});
onMounted(() => {
this._renderRichMessages();
});
onPatched(() => {
this._renderRichMessages();
});
}
_renderRichMessages() {
const container = this.messagesRef.el;
if (!container) return;
const richDivs = container.querySelectorAll(".fusion_rich_slot[data-idx]");
for (const div of richDivs) {
const idx = parseInt(div.dataset.idx);
const msg = this.state.messages[idx];
if (msg && msg.role === "assistant" && msg.content) {
const html = mdToHtml(msg.content);
if (div.innerHTML !== html) {
div.innerHTML = html;
}
}
}
}
get sessionId() {
return this.state.internalSessionId || this.props.sessionId;
}
async loadLatestSession() {
this.state.loading = true;
try {
const data = await rpc("/fusion_accounting/session/latest", {});
if (data.session_id) {
this.state.internalSessionId = data.session_id;
this.state.messages = data.messages || [];
this.state.sessionName = data.name;
}
} catch (e) {
console.error("Failed to load session:", e);
}
this.state.loading = false;
this.scrollToBottom();
}
async onNewChat() {
if (this.sessionId) {
try {
await rpc("/fusion_accounting/session/close", { session_id: this.sessionId });
} catch (e) { /* not critical */ }
}
const session = await rpc("/fusion_accounting/session/create", {});
this.state.internalSessionId = session.session_id;
this.state.sessionName = session.name;
this.state.messages = [];
this.state.pendingApprovals = [];
}
async sendMessage() {
const text = this.state.inputText.trim();
if (!text || this.state.sending) return;
if (!this.sessionId) {
const session = await rpc("/fusion_accounting/session/create", {});
this.state.internalSessionId = session.session_id;
this.state.sessionName = session.name;
}
this.state.messages.push({ role: "user", content: text });
this.state.inputText = "";
this.state.sending = true;
this.scrollToBottom();
try {
const result = await rpc("/fusion_accounting/chat", {
session_id: this.sessionId,
message: text,
});
if (result.text) {
this.state.messages.push({ role: "assistant", content: result.text });
}
if (result.pending_approvals) {
this.state.pendingApprovals = result.pending_approvals;
}
} catch (e) {
this.state.messages.push({
role: "assistant",
content: `Error: ${e.message || "Something went wrong."}`,
});
}
this.state.sending = false;
this.scrollToBottom();
}
onKeyDown(ev) {
if (ev.key === "Enter" && !ev.shiftKey) {
ev.preventDefault();
this.sendMessage();
}
}
scrollToBottom() {
const el = this.messagesRef.el;
if (el) {
setTimeout(() => { el.scrollTop = el.scrollHeight; }, 100);
}
}
async onApprove(matchHistoryId) {
await rpc("/fusion_accounting/approve", { match_history_id: matchHistoryId });
this.state.pendingApprovals = this.state.pendingApprovals.filter(a => a.id !== matchHistoryId);
this.state.messages.push({ role: "assistant", content: "Action approved and executed." });
}
async onReject(matchHistoryId) {
await rpc("/fusion_accounting/reject", { match_history_id: matchHistoryId, reason: "User rejected" });
this.state.pendingApprovals = this.state.pendingApprovals.filter(a => a.id !== matchHistoryId);
this.state.messages.push({ role: "assistant", content: "Action rejected." });
}
async onApproveAll() {
const ids = this.state.pendingApprovals.map(a => a.id);
if (!ids.length) return;
await rpc("/fusion_accounting/approve_all", { match_history_ids: ids });
const count = this.state.pendingApprovals.length;
this.state.pendingApprovals = [];
this.state.messages.push({ role: "assistant", content: `${count} actions approved and executed.` });
}
async onRejectAll() {
const ids = this.state.pendingApprovals.map(a => a.id);
if (!ids.length) return;
await rpc("/fusion_accounting/reject_all", { match_history_ids: ids, reason: "Batch rejected" });
const count = this.state.pendingApprovals.length;
this.state.pendingApprovals = [];
this.state.messages.push({ role: "assistant", content: `${count} actions rejected.` });
}
}

View File

@@ -0,0 +1,103 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting.ChatPanel">
<div class="fusion_chat_panel card h-100 d-flex flex-column">
<div class="card-header d-flex justify-content-between align-items-center py-2">
<div>
<h5 class="mb-0 d-inline"><i class="fa fa-comments-o me-2"/>Fusion AI</h5>
<small class="text-muted ms-2" t-if="state.sessionName" t-esc="state.sessionName"/>
</div>
<button class="btn btn-outline-secondary btn-sm" t-on-click="onNewChat"
title="Start a new conversation">
<i class="fa fa-plus me-1"/>New Chat
</button>
</div>
<!-- Messages -->
<div class="fusion_chat_messages flex-grow-1 overflow-auto p-3" t-ref="messages">
<t t-if="state.loading">
<div class="text-center text-muted py-4">
<i class="fa fa-spinner fa-spin fa-2x"/>
<p class="mt-2">Loading conversation...</p>
</div>
</t>
<t t-elif="state.messages.length === 0">
<div class="text-center text-muted py-4">
<i class="fa fa-robot fa-3x mb-3 d-block"/>
<p>Ask me about your accounting data.<br/>
I can help with bank reconciliation, tax analysis, AR/AP, auditing, and more.</p>
</div>
</t>
<t t-foreach="state.messages" t-as="msg" t-key="msg_index">
<!-- User message -->
<t t-if="msg.role === 'user'">
<div class="fusion_chat_msg mb-2 p-2 rounded bg-primary-subtle ms-4">
<small class="text-muted d-block mb-1">
<i class="fa fa-user me-1"/>You
</small>
<span style="white-space: pre-wrap;" t-esc="msg.content"/>
</div>
</t>
<!-- AI message — rich HTML rendered via onPatched -->
<t t-else="">
<div class="fusion_chat_msg fusion_ai_msg mb-3 p-3 rounded me-4">
<small class="text-muted d-block mb-2">
<i class="fa fa-robot me-1"/>Fusion AI
</small>
<div class="fusion_rich_content fusion_rich_slot"
t-att-data-idx="msg_index"/>
</div>
</t>
</t>
<t t-if="state.sending">
<div class="fusion_ai_msg rounded p-3 me-4 mb-2">
<small class="text-muted d-block mb-1">
<i class="fa fa-robot me-1"/>Fusion AI
</small>
<i class="fa fa-spinner fa-spin me-1"/> Thinking...
</div>
</t>
</div>
<!-- Pending Approvals -->
<t t-if="state.pendingApprovals.length > 0">
<div class="border-top p-2">
<div class="d-flex justify-content-between align-items-center mb-1">
<small class="text-muted">Pending Approvals (<t t-esc="state.pendingApprovals.length"/>):</small>
<div class="d-flex gap-1" t-if="state.pendingApprovals.length > 1">
<button class="btn btn-success btn-sm" t-on-click="onApproveAll">
<i class="fa fa-check-double"/> Approve All
</button>
<button class="btn btn-outline-danger btn-sm" t-on-click="onRejectAll">
Reject All
</button>
</div>
</div>
<t t-foreach="state.pendingApprovals" t-as="approval" t-key="approval.id">
<FusionApprovalCard
approval="approval"
onApprove.bind="onApprove"
onReject.bind="onReject"/>
</t>
</div>
</t>
<!-- Input -->
<div class="fusion_chat_input border-top p-2">
<div class="input-group">
<textarea
t-ref="chatInput"
class="form-control form-control-sm"
placeholder="Ask Fusion AI..."
rows="2"
t-model="state.inputText"
t-on-keydown="onKeyDown"/>
<button class="btn btn-primary btn-sm" t-on-click="sendMessage"
t-att-disabled="state.sending">
<i class="fa fa-paper-plane"/>
</button>
</div>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,98 @@
/** @odoo-module **/
import { Component, useState, onWillStart } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { rpc } from "@web/core/network/rpc";
import { FusionHealthCard } from "./health_card";
import { FusionChatPanel } from "../chat/chat_panel";
export class FusionDashboard extends Component {
static template = "fusion_accounting.Dashboard";
static components = { FusionHealthCard, FusionChatPanel };
static props = ["*"];
setup() {
this.action = useService("action");
this.state = useState({
data: null,
loading: true,
chatSessionId: null,
});
onWillStart(async () => {
await this.loadDashboard();
});
}
async loadDashboard() {
this.state.loading = true;
try {
this.state.data = await rpc("/fusion_accounting/dashboard/data");
} catch (e) {
console.error("Dashboard load error:", e);
this.state.data = null;
}
this.state.loading = false;
}
async onCardClick(domain) {
if (!this.state.chatSessionId) {
const session = await rpc("/fusion_accounting/session/create", {
context_domain: domain,
});
this.state.chatSessionId = session.session_id;
}
}
get cards() {
if (!this.state.data) return [];
const d = this.state.data;
return [
{
title: "Bank Reconciliation",
metric: `${d.bank_recon.count} unmatched`,
subtext: `$${(d.bank_recon.amount || 0).toFixed(2)} total`,
domain: "bank_reconciliation",
status: d.bank_recon.count === 0 ? "green" : d.bank_recon.count < 10 ? "yellow" : "red",
},
{
title: "AR Outstanding",
metric: `$${(d.ar.total || 0).toFixed(2)}`,
subtext: `${d.ar.overdue_count} overdue`,
domain: "accounts_receivable",
status: d.ar.overdue_count === 0 ? "green" : d.ar.overdue_count < 5 ? "yellow" : "red",
},
{
title: "AP Due",
metric: `$${(d.ap.total || 0).toFixed(2)}`,
subtext: `${d.ap.due_this_week} due this week`,
domain: "accounts_payable",
status: d.ap.due_this_week === 0 ? "green" : "yellow",
},
{
title: "HST Balance",
metric: `$${(d.hst.balance || 0).toFixed(2)}`,
subtext: d.hst.balance > 0 ? "Owing to CRA" : "Refund expected",
domain: "hst_management",
status: "blue",
},
{
title: "Audit Score",
metric: `${d.audit.score}/100`,
subtext: `${d.audit.flags} flags`,
domain: "audit",
status: d.audit.score >= 80 ? "green" : d.audit.score >= 60 ? "yellow" : "red",
},
{
title: "Month-End",
metric: d.month_end.status,
subtext: `${d.month_end.open_items} open items`,
domain: "month_end",
status: d.month_end.open_items === 0 ? "green" : "yellow",
},
];
}
}
registry.category("actions").add("fusion_accounting.dashboard", FusionDashboard);

View File

@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting.Dashboard">
<div class="o_action fusion_accounting_dashboard">
<div class="fusion_dashboard_header d-flex justify-content-between align-items-center p-3">
<h2 class="mb-0">Fusion AI Dashboard</h2>
<button class="btn btn-outline-primary btn-sm" t-on-click="loadDashboard">
<i class="fa fa-refresh"/> Refresh
</button>
</div>
<t t-if="state.loading">
<div class="text-center p-5">
<i class="fa fa-spinner fa-spin fa-2x"/>
<p class="mt-2">Loading dashboard...</p>
</div>
</t>
<t t-else="">
<!-- Health Cards -->
<div class="fusion_health_cards d-flex flex-wrap gap-3 p-3">
<t t-foreach="cards" t-as="card" t-key="card.domain">
<FusionHealthCard
title="card.title"
metric="card.metric"
subtext="card.subtext"
status="card.status"
domain="card.domain"
onCardClick.bind="onCardClick"/>
</t>
</div>
<!-- Action Centre + Chat -->
<div class="d-flex gap-3 p-3" style="min-height: 500px;">
<!-- Action Centre -->
<div class="flex-grow-1">
<div class="card h-100">
<div class="card-header">
<h5 class="mb-0">Needs Attention</h5>
</div>
<div class="card-body overflow-auto">
<p class="text-muted">AI-prioritised items will appear here after the first audit scan.</p>
</div>
</div>
</div>
<!-- Chat Panel (720px = original 400 + 80%) -->
<div style="width: 720px; min-width: 600px;">
<FusionChatPanel sessionId="state.chatSessionId"/>
</div>
</div>
</t>
</div>
</t>
</templates>

View File

@@ -0,0 +1,22 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
export class FusionHealthCard extends Component {
static template = "fusion_accounting.HealthCard";
static props = ["title", "metric", "subtext", "status", "domain", "onCardClick"];
get statusClass() {
const map = {
green: "bg-success-subtle border-success",
yellow: "bg-warning-subtle border-warning",
red: "bg-danger-subtle border-danger",
blue: "bg-info-subtle border-info",
};
return map[this.props.status] || "bg-light";
}
onClick() {
this.props.onCardClick(this.props.domain);
}
}

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting.HealthCard">
<div class="fusion_health_card card border-2 cursor-pointer"
t-attf-class="{{statusClass}}"
style="min-width: 180px; flex: 1;"
t-on-click="onClick">
<div class="card-body text-center p-3">
<h6 class="card-title text-muted mb-1" t-esc="props.title"/>
<h3 class="mb-1" t-esc="props.metric"/>
<small class="text-muted" t-esc="props.subtext"/>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,72 @@
.fusion_chat_panel {
.fusion_chat_messages {
max-height: 500px;
min-height: 300px;
}
.fusion_chat_msg {
word-break: break-word;
}
.fusion_ai_msg {
background: var(--o-view-background-color);
border: 1px solid var(--o-border-color);
}
.fusion_rich_content {
font-size: 0.9rem;
line-height: 1.5;
h3, h4, h5 {
font-weight: 600;
color: var(--o-main-color-5, inherit);
}
table {
font-size: 0.85rem;
}
a {
color: var(--o-action-color, var(--bs-link-color));
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
a.badge {
font-size: 0.75rem;
cursor: pointer;
}
code {
font-size: 0.85em;
}
hr {
border-color: var(--o-border-color);
}
ul, ol {
margin-bottom: 0.25rem;
}
li {
margin-bottom: 0.15rem;
}
strong {
font-weight: 600;
}
}
.fusion_chat_input {
textarea {
resize: none;
}
}
.fusion_approval_card {
border-left: 3px solid var(--bs-warning);
}
}

View File

@@ -0,0 +1,16 @@
.fusion_accounting_dashboard {
.fusion_dashboard_header {
border-bottom: 1px solid var(--o-border-color);
background: var(--o-view-background-color);
}
.fusion_health_cards {
.fusion_health_card {
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(var(--bs-body-color-rgb), 0.1);
}
}
}
}

View File

@@ -0,0 +1,64 @@
import anthropic
import json
import sys
api_key = sys.argv[1]
model = sys.argv[2] if len(sys.argv) > 2 else 'claude-sonnet-4-6'
print(f'API Key: {api_key[:12]}...{api_key[-4:]}')
print(f'Model: {model}')
client = anthropic.Anthropic(api_key=api_key)
print()
print('--- Test 1: Basic API Call ---')
try:
r = client.messages.create(model=model, max_tokens=100, messages=[{'role': 'user', 'content': 'Say hello in one sentence.'}])
print(f'OK: {r.content[0].text}')
print(f'Tokens: {r.usage.input_tokens} in, {r.usage.output_tokens} out')
except Exception as e:
print(f'FAILED: {e}')
sys.exit(1)
print()
print('--- Test 2: Tool Calling ---')
try:
tools = [{'name': 'get_account_balance', 'description': 'Get balance of an account by code', 'input_schema': {'type': 'object', 'properties': {'account_code': {'type': 'string', 'description': 'Account code'}}, 'required': ['account_code']}}]
r = client.messages.create(model=model, max_tokens=300, system='You are an accounting AI. Always use tools to look up data before answering.', messages=[{'role': 'user', 'content': 'Look up the balance on account 2005.'}], tools=tools)
print(f'Stop reason: {r.stop_reason}')
tool_id = None
for b in r.content:
if b.type == 'text':
print(f'Text: {b.text}')
elif b.type == 'tool_use':
print(f'TOOL CALL: {b.name}({json.dumps(b.input)}) id={b.id}')
tool_id = b.id
if r.stop_reason == 'tool_use':
print('RESULT: Tool calling WORKING')
else:
print('RESULT: No tool call (model answered directly)')
except Exception as e:
print(f'FAILED: {e}')
sys.exit(1)
print()
print('--- Test 3: Tool Result Round-Trip ---')
try:
if not tool_id:
tool_id = 'toolu_test123'
msgs = [
{'role': 'user', 'content': 'Look up account 2005 balance.'},
{'role': 'assistant', 'content': [{'type': 'tool_use', 'id': tool_id, 'name': 'get_account_balance', 'input': {'account_code': '2005'}}]},
{'role': 'user', 'content': [{'type': 'tool_result', 'tool_use_id': tool_id, 'content': json.dumps({'balance': -15234.56, 'name': 'HST Collected'})}]}
]
r2 = client.messages.create(model=model, max_tokens=200, system='You are an accounting AI. Report findings in Canadian dollars.', messages=msgs, tools=tools)
for b in r2.content:
if b.type == 'text':
print(f'AI: {b.text}')
print(f'Tokens: {r2.usage.input_tokens} in, {r2.usage.output_tokens} out')
print('RESULT: Multi-turn tool flow WORKING')
except Exception as e:
print(f'FAILED: {e}')
sys.exit(1)
print()
print('=== ALL TESTS PASSED ===')

View File

@@ -0,0 +1,107 @@
import anthropic
import json
import subprocess
import sys
def get_db_param(key):
result = subprocess.run(
['docker', 'exec', 'odoo-dev-db', 'psql', '-U', 'odoo', '-d', 'westin-v19', '-t', '-A', '-c',
f"SELECT value FROM ir_config_parameter WHERE key = '{key}'"],
capture_output=True, text=True
)
return result.stdout.strip()
api_key = get_db_param('fusion_accounting.anthropic_api_key')
if not api_key:
print('ERROR: No API key found in database')
sys.exit(1)
print(f'API Key found: {api_key[:12]}...{api_key[-4:]}')
model = get_db_param('fusion_accounting.claude_model') or 'claude-sonnet-4-6'
print(f'Model: {model}')
client = anthropic.Anthropic(api_key=api_key)
# Test 1: Basic API call
print('\n--- Test 1: Basic API Call ---')
try:
response = client.messages.create(
model=model,
max_tokens=100,
messages=[{'role': 'user', 'content': 'Say hello in one sentence.'}]
)
print(f'Status: OK')
print(f'Response: {response.content[0].text}')
print(f'Tokens: {response.usage.input_tokens} in, {response.usage.output_tokens} out')
except Exception as e:
print(f'FAILED: {e}')
sys.exit(1)
# Test 2: Tool calling
print('\n--- Test 2: Tool Calling ---')
try:
tools = [{
'name': 'get_account_balance',
'description': 'Get the balance of an accounting account by code',
'input_schema': {
'type': 'object',
'properties': {
'account_code': {'type': 'string', 'description': 'Account code like 1000, 2005'},
},
'required': ['account_code']
}
}]
response = client.messages.create(
model=model,
max_tokens=300,
system='You are an accounting assistant. Use tools to look up data.',
messages=[{'role': 'user', 'content': 'What is the balance on account 2005 (HST Collected)?'}],
tools=tools,
)
print(f'Stop reason: {response.stop_reason}')
for block in response.content:
if block.type == 'text':
print(f'Text: {block.text}')
elif block.type == 'tool_use':
print(f'Tool call: {block.name}({json.dumps(block.input)})')
print(f'Tool ID: {block.id}')
print(f'Tokens: {response.usage.input_tokens} in, {response.usage.output_tokens} out')
if response.stop_reason == 'tool_use':
print('Tool calling: WORKING')
else:
print('Tool calling: Model responded with text (functional but did not use tool)')
except Exception as e:
print(f'FAILED: {e}')
sys.exit(1)
# Test 3: Multi-turn with tool result
print('\n--- Test 3: Tool Result Round-Trip ---')
try:
messages = [
{'role': 'user', 'content': 'What is the HST balance on account 2005?'},
{'role': 'assistant', 'content': [
{'type': 'tool_use', 'id': 'test_123', 'name': 'get_account_balance',
'input': {'account_code': '2005'}}
]},
{'role': 'user', 'content': [
{'type': 'tool_result', 'tool_use_id': 'test_123',
'content': json.dumps({'balance': -15234.56, 'name': 'HST Collected'})}
]}
]
response = client.messages.create(
model=model,
max_tokens=200,
system='You are an accounting assistant. Report findings concisely in Canadian dollars.',
messages=messages,
tools=tools,
)
for block in response.content:
if block.type == 'text':
print(f'AI Response: {block.text}')
print(f'Tokens: {response.usage.input_tokens} in, {response.usage.output_tokens} out')
print('Multi-turn tool flow: WORKING')
except Exception as e:
print(f'FAILED: {e}')
sys.exit(1)
print('\n=== ALL TESTS PASSED ===')

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form_fusion_accounting" model="ir.ui.view">
<field name="name">res.config.settings.view.form.fusion.accounting</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="account.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//app[@name='account']" position="inside">
<block title="Fusion AI" name="fusion_ai_settings">
<setting string="AI Provider" help="Select the AI provider for Fusion Accounting.">
<field name="fusion_ai_provider" widget="radio"/>
</setting>
<setting string="Anthropic API Key" help="API key for Anthropic Claude. Leave blank if using Fusion API module.">
<field name="fusion_anthropic_api_key" password="True"/>
</setting>
<setting string="OpenAI API Key" help="API key for OpenAI GPT. Leave blank if using Fusion API module.">
<field name="fusion_openai_api_key" password="True"/>
</setting>
<setting string="Claude Model" help="The Anthropic Claude model to use for conversations.">
<field name="fusion_claude_model"/>
</setting>
<setting string="OpenAI Model" help="The OpenAI model to use for conversations.">
<field name="fusion_openai_model"/>
</setting>
</block>
<block title="Fusion AI Behaviour" name="fusion_ai_behaviour">
<setting string="Tier 3 Promotion Threshold" help="Accuracy threshold (0.0 - 1.0) for promoting Tier 3 tools to auto-approved.">
<field name="fusion_tier3_threshold"/>
</setting>
<setting string="Tier 3 Minimum Sample Size" help="Minimum decisions before promotion is considered.">
<field name="fusion_tier3_min_sample"/>
</setting>
<setting string="Audit Scan Frequency" help="How often the automated audit scan runs.">
<field name="fusion_audit_cron_frequency"/>
</setting>
<setting string="Match History in Prompt" help="Number of recent match history records to include in AI prompt context.">
<field name="fusion_history_in_prompt"/>
</setting>
<setting string="Max Tool Calls Per Turn" help="Maximum number of tool calls the AI can make in a single conversation turn.">
<field name="fusion_max_tool_calls"/>
</setting>
<setting string="Post-Action Audit Hook" help="Run audit checks automatically after journal entries are posted.">
<field name="fusion_enable_post_audit"/>
</setting>
</block>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Client Action for OWL Dashboard -->
<record id="action_fusion_dashboard" model="ir.actions.client">
<field name="name">Fusion AI Dashboard</field>
<field name="tag">fusion_accounting.dashboard</field>
</record>
</odoo>

View File

@@ -0,0 +1,97 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_fusion_history_list" model="ir.ui.view">
<field name="name">fusion.accounting.match.history.list</field>
<field name="model">fusion.accounting.match.history</field>
<field name="arch" type="xml">
<list string="Match History">
<field name="proposed_at"/>
<field name="tool_name"/>
<field name="decision" widget="badge"
decoration-success="decision == 'approved'"
decoration-danger="decision == 'rejected'"
decoration-warning="decision == 'pending'"
decoration-info="decision == 'auto'"/>
<field name="ai_confidence" widget="progressbar"/>
<field name="amount"/>
<field name="partner_id"/>
<field name="decided_by"/>
<field name="decided_at"/>
</list>
</field>
</record>
<record id="view_fusion_history_form" model="ir.ui.view">
<field name="name">fusion.accounting.match.history.form</field>
<field name="model">fusion.accounting.match.history</field>
<field name="arch" type="xml">
<form string="Match History">
<header>
<button name="action_approve" string="Approve" type="object"
class="btn-primary" invisible="decision != 'pending'"
groups="fusion_accounting.group_fusion_accounting_manager"/>
<button name="action_reject" string="Reject" type="object"
class="btn-danger" invisible="decision != 'pending'"
groups="fusion_accounting.group_fusion_accounting_manager"/>
</header>
<sheet>
<group>
<group>
<field name="tool_name"/>
<field name="decision"/>
<field name="ai_confidence"/>
<field name="amount"/>
<field name="partner_id"/>
</group>
<group>
<field name="session_id"/>
<field name="rule_id"/>
<field name="proposed_at"/>
<field name="decided_at"/>
<field name="decided_by"/>
</group>
</group>
<group string="AI Details">
<field name="ai_reasoning"/>
<field name="tool_params"/>
<field name="tool_result"/>
</group>
<group string="Correction" invisible="decision != 'rejected'">
<field name="rejection_reason"/>
<field name="correct_action"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_fusion_history_search" model="ir.ui.view">
<field name="name">fusion.accounting.match.history.search</field>
<field name="model">fusion.accounting.match.history</field>
<field name="arch" type="xml">
<search>
<field name="tool_name"/>
<field name="partner_id"/>
<filter name="pending" string="Pending" domain="[('decision', '=', 'pending')]"/>
<filter name="approved" string="Approved" domain="[('decision', '=', 'approved')]"/>
<filter name="rejected" string="Rejected" domain="[('decision', '=', 'rejected')]"/>
<separator/>
<group>
<filter name="group_tool" string="Tool" domain="[]" context="{'group_by': 'tool_name'}"/>
<filter name="group_decision" string="Decision" domain="[]" context="{'group_by': 'decision'}"/>
</group>
</search>
</field>
</record>
<record id="action_fusion_history" model="ir.actions.act_window">
<field name="name">Match History</field>
<field name="res_model">fusion.accounting.match.history</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fusion_history_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">No match history yet</p>
<p>AI tool calls and their outcomes will appear here.</p>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Root menu under Accounting (account_accountant uses accountant.menu_accounting) -->
<menuitem id="menu_fusion_accounting_root"
name="Fusion AI"
parent="accountant.menu_accounting"
sequence="8"
groups="group_fusion_accounting_user"/>
<!-- Dashboard -->
<menuitem id="menu_fusion_dashboard"
name="Dashboard"
parent="menu_fusion_accounting_root"
action="action_fusion_dashboard"
sequence="10"/>
<!-- Sessions -->
<menuitem id="menu_fusion_sessions"
name="AI Sessions"
parent="menu_fusion_accounting_root"
action="action_fusion_session"
sequence="20"/>
<!-- Match History -->
<menuitem id="menu_fusion_history"
name="Match History"
parent="menu_fusion_accounting_root"
action="action_fusion_history"
sequence="30"/>
<!-- Rules -->
<menuitem id="menu_fusion_rules"
name="Fusion Rules"
parent="menu_fusion_accounting_root"
action="action_fusion_rule"
sequence="40"
groups="group_fusion_accounting_manager"/>
<!-- Configuration (link to settings) -->
<menuitem id="menu_fusion_config"
name="Configuration"
parent="menu_fusion_accounting_root"
action="account.action_account_config"
sequence="90"
groups="group_fusion_accounting_admin"/>
</odoo>

View File

@@ -0,0 +1,113 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_fusion_rule_list" model="ir.ui.view">
<field name="name">fusion.accounting.rule.list</field>
<field name="model">fusion.accounting.rule</field>
<field name="arch" type="xml">
<list string="Fusion Rules">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="rule_type" widget="badge"/>
<field name="approval_tier" widget="badge"
decoration-success="approval_tier == 'auto'"
decoration-warning="approval_tier == 'needs_approval'"/>
<field name="created_by"/>
<field name="confidence_score" widget="progressbar"/>
<field name="total_uses"/>
<field name="active"/>
</list>
</field>
</record>
<record id="view_fusion_rule_form" model="ir.ui.view">
<field name="name">fusion.accounting.rule.form</field>
<field name="model">fusion.accounting.rule</field>
<field name="arch" type="xml">
<form string="Fusion Rule">
<header>
<button name="action_demote" string="Demote to Needs Approval" type="object"
class="btn-warning" invisible="approval_tier != 'auto'"
groups="fusion_accounting.group_fusion_accounting_admin"/>
<button name="action_rollback" string="Rollback to Previous Version" type="object"
class="btn-secondary" invisible="not parent_rule_id"
groups="fusion_accounting.group_fusion_accounting_admin"/>
</header>
<sheet>
<div class="oe_title">
<h1><field name="name" placeholder="Rule Name"/></h1>
</div>
<group>
<group>
<field name="rule_type"/>
<field name="approval_tier"/>
<field name="created_by"/>
<field name="version"/>
<field name="parent_rule_id"/>
</group>
<group>
<field name="confidence_score" widget="progressbar"/>
<field name="total_uses"/>
<field name="total_approved"/>
<field name="total_rejected"/>
<field name="promotion_threshold"/>
<field name="min_sample_size"/>
</group>
</group>
<notebook>
<page string="Logic" name="logic">
<group>
<field name="description"/>
<field name="match_logic"/>
<field name="trigger_domain"/>
<field name="match_code"/>
</group>
</page>
<page string="Accounts" name="accounts">
<group>
<field name="fee_account_id"/>
<field name="write_off_account_id"/>
<field name="journal_ids" widget="many2many_tags"/>
</group>
</page>
<page string="Notes" name="notes">
<field name="notes"/>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="view_fusion_rule_search" model="ir.ui.view">
<field name="name">fusion.accounting.rule.search</field>
<field name="model">fusion.accounting.rule</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="rule_type"/>
<filter name="active" string="Active" domain="[('active', '=', True)]"/>
<filter name="auto" string="Auto-Approved" domain="[('approval_tier', '=', 'auto')]"/>
<filter name="admin_created" string="Admin Created" domain="[('created_by', '=', 'admin')]"/>
<filter name="ai_created" string="AI Created" domain="[('created_by', '=', 'ai')]"/>
<separator/>
<group>
<filter name="group_type" string="Type" domain="[]" context="{'group_by': 'rule_type'}"/>
<filter name="group_tier" string="Approval Tier" domain="[]" context="{'group_by': 'approval_tier'}"/>
</group>
</search>
</field>
</record>
<record id="action_fusion_rule" model="ir.actions.act_window">
<field name="name">Fusion Rules</field>
<field name="res_model">fusion.accounting.rule</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fusion_rule_search"/>
<field name="context">{'search_default_active': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">No rules defined yet</p>
<p>Create rules to teach the AI your accounting patterns.</p>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,105 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Session List View -->
<record id="view_fusion_session_list" model="ir.ui.view">
<field name="name">fusion.accounting.session.list</field>
<field name="model">fusion.accounting.session</field>
<field name="arch" type="xml">
<list string="AI Sessions">
<field name="name"/>
<field name="user_id"/>
<field name="state" widget="badge" decoration-success="state == 'active'" decoration-muted="state == 'closed'"/>
<field name="ai_provider"/>
<field name="ai_model"/>
<field name="tool_call_count"/>
<field name="create_date"/>
</list>
</field>
</record>
<!-- Session Form View (with basic chat) -->
<record id="view_fusion_session_form" model="ir.ui.view">
<field name="name">fusion.accounting.session.form</field>
<field name="model">fusion.accounting.session</field>
<field name="arch" type="xml">
<form string="AI Session">
<header>
<button name="action_close_session" string="Close Session" type="object"
class="btn-secondary" invisible="state == 'closed'"/>
<field name="state" widget="statusbar" statusbar_visible="active,closed"/>
</header>
<sheet>
<group>
<group>
<field name="name"/>
<field name="user_id"/>
<field name="context_domain"/>
</group>
<group>
<field name="ai_provider"/>
<field name="ai_model"/>
<field name="tool_call_count"/>
<field name="token_count_in"/>
<field name="token_count_out"/>
</group>
</group>
<notebook>
<page string="Conversation" name="conversation">
<field name="message_ids_json" widget="text" readonly="1"/>
</page>
<page string="Match History" name="history">
<field name="match_history_ids">
<list>
<field name="tool_name"/>
<field name="decision" widget="badge"
decoration-success="decision == 'approved'"
decoration-danger="decision == 'rejected'"
decoration-warning="decision == 'pending'"
decoration-info="decision == 'auto'"/>
<field name="ai_confidence" widget="progressbar"/>
<field name="amount"/>
<field name="proposed_at"/>
</list>
</field>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- Session Search View -->
<record id="view_fusion_session_search" model="ir.ui.view">
<field name="name">fusion.accounting.session.search</field>
<field name="model">fusion.accounting.session</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="user_id"/>
<filter name="active" string="Active" domain="[('state', '=', 'active')]"/>
<filter name="closed" string="Closed" domain="[('state', '=', 'closed')]"/>
<separator/>
<group>
<filter name="group_user" string="User" domain="[]" context="{'group_by': 'user_id'}"/>
<filter name="group_state" string="Status" domain="[]" context="{'group_by': 'state'}"/>
</group>
</search>
</field>
</record>
<!-- Session Action -->
<record id="action_fusion_session" model="ir.actions.act_window">
<field name="name">AI Sessions</field>
<field name="res_model">fusion.accounting.session</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fusion_session_search"/>
<field name="context">{'search_default_active': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No AI sessions yet
</p>
<p>Start a conversation with Fusion AI from the dashboard.</p>
</field>
</record>
</odoo>

View File

@@ -0,0 +1 @@
from . import rule_wizard

View File

@@ -0,0 +1,42 @@
from odoo import models, fields, api
class FusionRuleWizard(models.TransientModel):
_name = 'fusion.accounting.rule.wizard'
_description = 'Create Fusion Rule from AI Suggestion'
name = fields.Char(string='Rule Name', required=True)
rule_type = fields.Selection(
selection=[
('match', 'Match'), ('classify', 'Classify'),
('audit', 'Audit'), ('fee', 'Fee'),
('routing', 'Routing'), ('followup', 'Follow-Up'),
],
string='Type', required=True, default='match',
)
description = fields.Text(string='Description')
match_logic = fields.Text(string='Match Logic')
fee_account_id = fields.Many2one('account.account', string='Fee Account')
write_off_account_id = fields.Many2one('account.account', string='Write-Off Account')
journal_ids = fields.Many2many('account.journal', string='Journals')
def action_create_rule(self):
self.ensure_one()
rule = self.env['fusion.accounting.rule'].create({
'name': self.name,
'rule_type': self.rule_type,
'description': self.description,
'match_logic': self.match_logic,
'fee_account_id': self.fee_account_id.id,
'write_off_account_id': self.write_off_account_id.id,
'journal_ids': [(6, 0, self.journal_ids.ids)],
'created_by': 'admin',
'approval_tier': 'needs_approval',
})
return {
'type': 'ir.actions.act_window',
'res_model': 'fusion.accounting.rule',
'res_id': rule.id,
'view_mode': 'form',
'target': 'current',
}

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_fusion_rule_wizard_form" model="ir.ui.view">
<field name="name">fusion.accounting.rule.wizard.form</field>
<field name="model">fusion.accounting.rule.wizard</field>
<field name="arch" type="xml">
<form string="Create Fusion Rule">
<group>
<field name="name"/>
<field name="rule_type"/>
</group>
<group>
<field name="description"/>
<field name="match_logic"/>
</group>
<group>
<field name="fee_account_id"/>
<field name="write_off_account_id"/>
<field name="journal_ids" widget="many2many_tags"/>
</group>
<footer>
<button name="action_create_rule" string="Create Rule" type="object" class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_fusion_rule_wizard" model="ir.actions.act_window">
<field name="name">Create Fusion Rule</field>
<field name="res_model">fusion.accounting.rule.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>