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

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>