refactor(fusion_accounting): move AI module code into fusion_accounting_ai sub-module

git mv preserves history. fusion_accounting/ retains only __manifest__.py,
__init__.py, CLAUDE.md, and docs/ — the meta-module shell. All Python,
data, views, security, services, static, tests, wizards, report move to
fusion_accounting_ai/. Manifest data list updated; security.xml move to
_core deferred to Task 12.

Made-with: Cursor
This commit is contained in:
gsinghpal
2026-04-18 21:45:06 -04:00
parent b7483d5177
commit 6c72f2ab49
74 changed files with 76 additions and 60 deletions

View File

@@ -0,0 +1,9 @@
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
from . import vendor_tax_profile
from . import recurring_pattern

View File

@@ -0,0 +1,58 @@
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}')
# M6: Only flag missing tax when the product has taxes configured
# (avoids false positives for HST-exempt healthcare services)
if (line.product_id and not line.tax_ids
and move.move_type in ('out_invoice', 'out_refund', 'in_invoice', 'in_refund')):
# Check if the product has default taxes configured
product_taxes = line.product_id.taxes_id if move.move_type in ('out_invoice', 'out_refund') else line.product_id.supplier_taxes_id
if product_taxes:
issues.append(f'Missing tax on product line: {line.product_id.name} (product has taxes configured but line has none)')
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,334 @@
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
# M4: Guard against made_sequence_gap field not existing
try:
gaps = self.env['account.move'].search_count([
('state', '=', 'posted'),
('company_id', '=', rec.company_id.id),
('made_sequence_gap', '=', True),
])
except (ValueError, KeyError):
gaps = 0
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 = []
today = fields.Date.today()
# Pending AI approvals (highest priority)
pending = self.env['fusion.accounting.match.history'].search_count([
('decision', '=', 'pending'),
('company_id', '=', rec.company_id.id),
])
if pending > 0:
attention.append({
'priority': 0, 'severity': 'danger',
'title': f'{pending} AI actions awaiting your approval',
'domain': 'audit',
'action': 'Review and approve or reject pending actions',
'prompt': 'Show me all pending approval actions',
})
# Unreconciled bank lines
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, 'severity': 'warning',
'title': f'{unrecon} unreconciled bank lines',
'domain': 'bank_reconciliation',
'action': 'Review and reconcile bank statement lines',
'prompt': 'Show me unreconciled bank lines across all journals with a breakdown by journal',
})
# Overdue customer invoices
overdue = self.env['account.move'].search_count([
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('payment_state', 'in', ('not_paid', 'partial')),
('invoice_date_due', '<', today),
('company_id', '=', rec.company_id.id),
])
if overdue > 0:
attention.append({
'priority': 2, 'severity': 'warning',
'title': f'{overdue} overdue customer invoices',
'domain': 'accounts_receivable',
'action': 'Send follow-up reminders',
'prompt': 'Show me overdue invoices sorted by amount',
})
# Unpaid vendor bills due this week
week_end = today + timedelta(days=7)
due_bills = self.env['account.move'].search_count([
('move_type', '=', 'in_invoice'),
('state', '=', 'posted'),
('payment_state', 'in', ('not_paid', 'partial')),
('invoice_date_due', '<=', week_end),
('invoice_date_due', '>=', today),
('company_id', '=', rec.company_id.id),
])
if due_bills > 0:
attention.append({
'priority': 3, 'severity': 'info',
'title': f'{due_bills} vendor bills due this week',
'domain': 'accounts_payable',
'action': 'Review upcoming payments',
'prompt': f'Show me vendor bills due between {today} and {week_end}',
})
# Stale draft entries
drafts = self.env['account.move'].search_count([
('state', '=', 'draft'),
('date', '<=', today - timedelta(days=30)),
('company_id', '=', rec.company_id.id),
])
if drafts > 0:
attention.append({
'priority': 4, 'severity': 'muted',
'title': f'{drafts} stale draft entries (30+ days)',
'domain': 'journal_review',
'action': 'Post or delete stale draft entries',
'prompt': 'Find all stale draft entries older than 30 days',
})
# Unmatched customer payments (on outstanding receipts accounts)
try:
outstanding_accts = self.env['account.account'].search([
('name', 'ilike', 'outstanding receipt'),
('company_ids', 'in', rec.company_id.id),
])
if outstanding_accts:
unmatched_payments = self.env['account.move.line'].search_count([
('account_id', 'in', outstanding_accts.ids),
('parent_state', '=', 'posted'),
('reconciled', '=', False),
('company_id', '=', rec.company_id.id),
])
if unmatched_payments > 0:
attention.append({
'priority': 5, 'severity': 'info',
'title': f'{unmatched_payments} unmatched customer payments',
'domain': 'accounts_receivable',
'action': 'Match payments to invoices',
'prompt': 'Show me unmatched customer payments that need to be applied to invoices',
})
except Exception:
pass
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': r.proposed_at.isoformat() if r.proposed_at else '',
'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,169 @@
import json
import logging
from odoo import models, fields, api
_logger = logging.getLogger(__name__)
TOOL_LABELS = {
'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',
'get_bank_line_details': 'Get Bank Line Details',
'check_recurring_pattern': 'Check Recurring Pattern',
'match_internal_transfers': 'Match Internal Transfers',
'find_unreconciled_cheques': 'Find Unreconciled Cheques',
'reconcile_payroll_cheques': 'Reconcile Payroll Cheques',
'suggest_bank_line_matches': 'Suggest Bank Line Matches',
'search_matching_entries': 'Search Matching Entries',
'calculate_hst_balance': 'Calculate HST Balance',
'create_expense_entry': 'Create Expense Entry',
'find_missing_itc_bills': 'Find Missing ITC Bills',
'find_missing_tax_invoices': 'Find Missing Tax Invoices',
'get_tax_report': 'Get Tax Report',
'get_ar_aging': 'Get AR Aging',
'get_overdue_invoices': 'Get Overdue Invoices',
'get_partner_balance': 'Get Partner Balance',
'get_ap_aging': 'Get AP Aging',
'get_unpaid_bills': 'Get Unpaid Bills',
'find_duplicate_bills': 'Find Duplicate Bills',
'create_vendor_bill': 'Create Vendor Bill',
'register_bill_payment': 'Register Bill Payment',
'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',
'get_invoicing_summary': 'Get Invoicing Summary',
'get_billing_summary': 'Get Billing Summary',
'get_collections_summary': 'Get Collections Summary',
'create_payroll_journal_entry': 'Create Payroll Journal Entry',
'find_adp_without_payment': 'Find ADP Without Payment',
'get_adp_receivable_aging': 'Get ADP Receivable Aging',
'register_adp_batch_payment': 'Register ADP Batch Payment',
'get_close_checklist': 'Get Month-End Checklist',
'find_draft_entries': 'Find Draft Entries',
'find_wrong_direction_balances': 'Find Wrong Direction Balances',
'find_duplicate_entries': 'Find Duplicate Entries',
'get_payroll_entries': 'Get Payroll Entries',
'get_cra_remittance_status': 'Get CRA Remittance Status',
}
class FusionAccountingMatchHistory(models.Model):
_name = 'fusion.accounting.match.history'
_description = 'Fusion Accounting Match History'
_order = 'proposed_at desc'
_rec_name = 'display_label'
display_label = fields.Char(
string='Label', compute='_compute_display_label', store=True,
)
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_display_name = fields.Char(
string='Tool', compute='_compute_tool_display_name', store=True,
)
tool_params_pretty = fields.Text(
string='Parameters', compute='_compute_pretty_json',
)
tool_result_pretty = fields.Text(
string='Result', compute='_compute_pretty_json',
)
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,
)
@api.depends('tool_name')
def _compute_tool_display_name(self):
for rec in self:
rec.tool_display_name = TOOL_LABELS.get(rec.tool_name, (rec.tool_name or '').replace('_', ' ').title())
@api.depends('tool_params', 'tool_result')
def _compute_pretty_json(self):
for rec in self:
for src, dst in [('tool_params', 'tool_params_pretty'), ('tool_result', 'tool_result_pretty')]:
raw = getattr(rec, src) or '{}'
try:
parsed = json.loads(raw)
setattr(rec, dst, json.dumps(parsed, indent=2, default=str, ensure_ascii=False))
except (json.JSONDecodeError, TypeError):
setattr(rec, dst, raw)
@api.depends('tool_name', 'proposed_at', 'decision')
def _compute_display_label(self):
for rec in self:
label = TOOL_LABELS.get(rec.tool_name, (rec.tool_name or '').replace('_', ' ').title())
date_str = rec.proposed_at.strftime('%b %d %H:%M') if rec.proposed_at else ''
decision_str = dict(rec._fields['decision'].selection).get(rec.decision, '')
rec.display_label = f"{label}{decision_str} ({date_str})" if date_str else label
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,121 @@
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.write({'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:
# M5: Use write() to trigger tracking on tracked fields
rec.write({'active': False})
rec.parent_rule_id.write({'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,216 @@
import json
import logging
import re
from collections import defaultdict
from odoo import models, fields, api
_logger = logging.getLogger(__name__)
class FusionRecurringPattern(models.Model):
_name = 'fusion.recurring.pattern'
_description = 'Recurring Bank Transaction Pattern (AI Cache)'
_order = 'occurrences desc'
name = fields.Char(string='Pattern Name', required=True)
ref_keyword = fields.Char(
string='Reference Keyword',
help='The payment_ref substring that identifies this pattern.',
index=True,
)
amount = fields.Float(string='Amount', digits=(12, 2))
amount_is_fixed = fields.Boolean(
string='Fixed Amount',
help='True if the amount is always the same. False if it varies.',
)
journal_id = fields.Many2one('account.journal', string='Bank Journal')
company_id = fields.Many2one(
'res.company', string='Company',
default=lambda self: self.env.company,
)
# How this was coded historically
expense_account_id = fields.Many2one(
'account.account', string='Expense Account',
)
expense_account_code = fields.Char(
related='expense_account_id.code', string='Account Code', store=True,
)
has_hst = fields.Boolean(string='Has HST')
partner_id = fields.Many2one('res.partner', string='Partner')
reconcile_model_id = fields.Many2one(
'account.reconcile.model', string='Reconciliation Model',
help='If this pattern was handled by a reconciliation model.',
)
# AI-readable instructions
action_note = fields.Text(
string='Action (AI-Readable)',
help='Plain English instructions for the AI on how to handle this pattern.',
)
# Stats
occurrences = fields.Integer(string='Times Seen')
first_seen = fields.Date(string='First Seen')
last_seen = fields.Date(string='Last Seen')
last_computed = fields.Datetime(string='Last Computed')
_sql_constraints = [
('pattern_uniq', 'unique(ref_keyword, amount, company_id)',
'One pattern per keyword+amount per company'),
]
def _rebuild_all_patterns(self, min_occurrences=3, since='2024-01-01'):
"""Scan reconciled bank lines for recurring patterns and cache how they were coded."""
_logger.info("Rebuilding recurring patterns (min=%d, since=%s)...", min_occurrences, since)
companies = self.env['res.company'].search([])
total_created = 0
total_updated = 0
for company in companies:
# Step 1: Find recurring ref+amount combinations
self.env.cr.execute("""
SELECT LEFT(bsl.payment_ref, 60) as ref_pattern,
bsl.amount,
count(*) as occurrences,
MIN(am.date) as first_seen,
MAX(am.date) as last_seen,
MODE() WITHIN GROUP (ORDER BY am.journal_id) as journal_id
FROM account_bank_statement_line bsl
JOIN account_move am ON bsl.move_id = am.id
WHERE bsl.is_reconciled = true
AND am.company_id = %s
AND am.date >= %s
AND bsl.payment_ref IS NOT NULL
AND bsl.payment_ref != ''
GROUP BY LEFT(bsl.payment_ref, 60), bsl.amount
HAVING count(*) >= %s
ORDER BY count(*) DESC
LIMIT 200
""", (company.id, since, min_occurrences))
patterns = self.env.cr.dictfetchall()
for pat in patterns:
ref = pat['ref_pattern'].strip()
if not ref or len(ref) < 3:
continue
# Step 2: Trace how one instance was coded
self.env.cr.execute("""
SELECT aml.account_id, aml.tax_line_id, aml.partner_id
FROM account_bank_statement_line bsl
JOIN account_move am ON bsl.move_id = am.id
JOIN account_move_line aml ON aml.move_id = am.id
WHERE bsl.is_reconciled = true
AND bsl.payment_ref ILIKE %s
AND bsl.amount = %s
AND am.company_id = %s
AND aml.display_type NOT IN ('line_section', 'line_note')
AND aml.account_id NOT IN (
SELECT default_account_id FROM account_journal
WHERE company_id = %s AND default_account_id IS NOT NULL
)
ORDER BY bsl.id DESC
LIMIT 5
""", (f'%{ref[:40]}%', pat['amount'], company.id, company.id))
coded_lines = self.env.cr.dictfetchall()
expense_account_id = None
has_hst = False
partner_id = None
for cl in coded_lines:
if cl['tax_line_id']:
has_hst = True
elif cl['account_id'] and not expense_account_id:
acct = self.env['account.account'].browse(cl['account_id'])
if acct.exists() and acct.account_type in (
'expense', 'expense_direct_cost', 'expense_depreciation',
'asset_non_current', 'liability_non_current',
):
expense_account_id = cl['account_id']
if cl['partner_id'] and not partner_id:
partner_id = cl['partner_id']
# Build a friendly name
clean_ref = re.sub(r'[X*]{3,}[\w-]*', '', ref).strip()
clean_ref = re.sub(r'\s{2,}', ' ', clean_ref)[:50]
# Build AI action note
acct_name = ''
if expense_account_id:
acct = self.env['account.account'].browse(expense_account_id)
acct_name = f'{acct.code} {acct.name}' if acct.exists() else ''
partner_name = ''
if partner_id:
p = self.env['res.partner'].browse(partner_id)
partner_name = p.name if p.exists() else ''
action_parts = [f'RECURRING PAYMENT (seen {pat["occurrences"]} times).']
if expense_account_id:
action_parts.append(f'Post to account: {acct_name}.')
if has_hst:
action_parts.append('HST applies — split with 13% ITC.')
else:
action_parts.append('No HST — post without tax.')
if partner_name:
action_parts.append(f'Partner: {partner_name}.')
action_parts.append('Apply same coding as previous occurrences — no user input needed.')
action_note = ' '.join(action_parts)
# Step 3: Check if a reconciliation model already handles this pattern
reco_model_id = None
try:
reco_models = self.env['account.reconcile.model'].search([
('company_id', '=', company.id),
('active', '=', True),
('match_label_param', '!=', False),
])
ref_lower = ref.lower()
for rm in reco_models:
if rm.match_label_param and rm.match_label_param.lower() in ref_lower:
reco_model_id = rm.id
action_parts.append(
f'Reconciliation model "{rm.name}" (ID:{rm.id}) already handles this — '
f'use apply_reconcile_model to apply it automatically.'
)
break
except Exception:
pass
# Upsert
existing = self.search([
('ref_keyword', '=', ref),
('amount', '=', pat['amount']),
('company_id', '=', company.id),
], limit=1)
vals = {
'name': clean_ref,
'ref_keyword': ref,
'amount': pat['amount'],
'amount_is_fixed': True,
'journal_id': pat['journal_id'],
'company_id': company.id,
'expense_account_id': expense_account_id,
'has_hst': has_hst,
'partner_id': partner_id,
'reconcile_model_id': reco_model_id,
'action_note': action_note,
'occurrences': pat['occurrences'],
'first_seen': pat['first_seen'],
'last_seen': pat['last_seen'],
'last_computed': fields.Datetime.now(),
}
if existing:
existing.write(vals)
total_updated += 1
else:
self.create(vals)
total_created += 1
_logger.info("Recurring patterns rebuilt: %d created, %d updated", total_created, total_updated)
return {'created': total_created, 'updated': total_updated}

View File

@@ -0,0 +1,221 @@
import json
import logging
from odoo import models, fields, api
_logger = logging.getLogger(__name__)
class FusionVendorTaxProfile(models.Model):
_name = 'fusion.vendor.tax.profile'
_description = 'Vendor Tax Profile (AI Cache)'
_order = 'total_bills desc'
_rec_name = 'partner_id'
partner_id = fields.Many2one(
'res.partner', string='Vendor', required=True, index=True,
ondelete='cascade',
)
company_id = fields.Many2one(
'res.company', string='Company',
default=lambda self: self.env.company,
)
total_bills = fields.Integer(string='Total Bills')
bills_with_hst = fields.Integer(string='Bills with HST')
bills_zero_rated = fields.Integer(string='Bills Zero-Rated')
avg_tax_pct = fields.Float(string='Avg Tax %', digits=(5, 2))
# Classification
tax_classification = fields.Selection([
('always_hst', 'Always HST (13%)'),
('mostly_hst', 'Mostly HST (>10%)'),
('shipping_only', 'HST on Shipping Only (<2%)'),
('never_hst', 'Never HST (0%)'),
('mixed', 'Mixed / Inconsistent'),
], string='Tax Classification')
# Most common expense account
primary_account_id = fields.Many2one(
'account.account', string='Primary Expense Account',
)
primary_account_code = fields.Char(
related='primary_account_id.code', string='Account Code', store=True,
)
# AI-readable note
tax_note = fields.Text(
string='Tax Note (AI-Readable)',
help='Plain English note the AI reads to understand tax treatment.',
)
# PO-tracked vendor — bills come from purchase orders, never from bank recon
is_po_vendor = fields.Boolean(
string='PO-Tracked Vendor',
help='Bills for this vendor are created from Purchase Orders. '
'Do NOT create bills during bank reconciliation — just match to existing bills.',
)
po_count = fields.Integer(string='Purchase Orders')
# Vendor details for matching
is_foreign = fields.Boolean(string='Foreign Vendor')
vendor_country = fields.Char(string='Vendor Country')
# Timestamps
last_computed = fields.Datetime(string='Last Computed')
_sql_constraints = [
('partner_company_uniq', 'unique(partner_id, company_id)',
'One tax profile per vendor per company'),
]
def _rebuild_all_profiles(self, min_bills=3):
"""Rebuild all vendor tax profiles from posted bill history.
Called by cron or manually."""
_logger.info("Rebuilding vendor tax profiles (min_bills=%d)...", min_bills)
companies = self.env['res.company'].search([])
total_created = 0
total_updated = 0
for company in companies:
# Find all vendors with enough bills
self.env.cr.execute("""
SELECT m.partner_id, count(*) as bill_count,
SUM(CASE WHEN m.amount_tax > 0.01 THEN 1 ELSE 0 END) as with_tax,
SUM(CASE WHEN m.amount_tax <= 0.01 THEN 1 ELSE 0 END) as no_tax,
COALESCE(AVG(CASE WHEN m.amount_untaxed > 0
THEN m.amount_tax / m.amount_untaxed * 100
ELSE 0 END), 0) as avg_tax_pct
FROM account_move m
WHERE m.move_type = 'in_invoice'
AND m.state = 'posted'
AND m.company_id = %s
AND m.partner_id IS NOT NULL
GROUP BY m.partner_id
HAVING count(*) >= %s
""", (company.id, min_bills))
vendor_stats = self.env.cr.dictfetchall()
for vs in vendor_stats:
partner = self.env['res.partner'].browse(vs['partner_id'])
if not partner.exists():
continue
# Classify
avg_pct = round(vs['avg_tax_pct'], 2)
total = vs['bill_count']
with_tax = vs['with_tax']
no_tax = vs['no_tax']
if no_tax == total:
classification = 'never_hst'
note = f'{partner.name} NEVER charges HST. All {total} bills are zero-rated. Do NOT apply HST.'
elif avg_pct >= 12.0:
classification = 'always_hst'
note = f'{partner.name} consistently charges HST at ~{avg_pct}%. Apply HST PURCHASE (13%) to all product lines.'
elif avg_pct >= 10.0:
classification = 'mostly_hst'
note = f'{partner.name} usually charges HST (~{avg_pct}%). {no_tax} of {total} bills had no tax. Apply HST by default but verify zero-rated items.'
elif avg_pct < 2.0 and with_tax > 0:
classification = 'shipping_only'
note = (
f'{partner.name} products are zero-rated (avg tax only {avg_pct}% of subtotal). '
f'HST applies ONLY to shipping/freight charges, NOT to product lines. '
f'When creating a bill, use NO TAX PURCHASE on product lines and HST PURCHASE (13%) only on shipping lines.'
)
else:
classification = 'mixed'
note = (
f'{partner.name} has mixed tax treatment ({with_tax} bills with HST, {no_tax} without, avg {avg_pct}%). '
f'Check each bill individually — some items may be zero-rated while others have HST.'
)
# Find primary expense account
self.env.cr.execute("""
SELECT aml.account_id, count(*) as cnt
FROM account_move_line aml
JOIN account_move m ON aml.move_id = m.id
WHERE m.partner_id = %s
AND m.move_type = 'in_invoice'
AND m.state = 'posted'
AND m.company_id = %s
AND aml.display_type = 'product'
GROUP BY aml.account_id
ORDER BY count(*) DESC
LIMIT 1
""", (vs['partner_id'], company.id))
acct_row = self.env.cr.fetchone()
primary_account_id = acct_row[0] if acct_row else False
# Check if foreign vendor
is_foreign = False
country = ''
if partner.country_id:
country = partner.country_id.name
is_foreign = partner.country_id.code != 'CA'
elif partner.vat and not partner.vat.startswith('CA'):
is_foreign = True
# Only override to never_hst if foreign AND bills actually confirm no tax
# (Don't override if bill data shows they DO charge HST — e.g., Amazon Canada)
if is_foreign and avg_pct < 1.0 and no_tax > with_tax:
classification = 'never_hst'
note = f'{partner.name} is a FOREIGN vendor ({country or "non-Canadian"}) and bills confirm no HST. Do NOT apply any Canadian tax.'
# Check if this is a PO-tracked vendor (has confirmed purchase orders)
is_po_vendor = False
vendor_po_count = 0
try:
self.env.cr.execute("""
SELECT count(*) FROM purchase_order
WHERE partner_id = %s AND state IN ('purchase', 'done')
AND company_id = %s
""", (vs['partner_id'], company.id))
po_row = self.env.cr.fetchone()
vendor_po_count = po_row[0] if po_row else 0
is_po_vendor = vendor_po_count >= 3
except Exception:
pass # purchase module may not be installed
if is_po_vendor:
note = (
f'PO-TRACKED VENDOR ({vendor_po_count} purchase orders). '
f'Bills are created from Purchase Orders — do NOT create bills during bank reconciliation. '
f'Instead, find the existing unpaid bill and match the bank payment to it. '
f'Tax treatment: {note}'
)
# Upsert
existing = self.search([
('partner_id', '=', vs['partner_id']),
('company_id', '=', company.id),
], limit=1)
vals = {
'partner_id': vs['partner_id'],
'company_id': company.id,
'total_bills': total,
'bills_with_hst': with_tax,
'bills_zero_rated': no_tax,
'avg_tax_pct': avg_pct,
'tax_classification': classification,
'primary_account_id': primary_account_id,
'tax_note': note,
'is_po_vendor': is_po_vendor,
'po_count': vendor_po_count,
'is_foreign': is_foreign,
'vendor_country': country,
'last_computed': fields.Datetime.now(),
}
if existing:
existing.write(vals)
total_updated += 1
else:
self.create(vals)
total_created += 1
_logger.info(
"Vendor tax profiles rebuilt: %d created, %d updated",
total_created, total_updated,
)
return {'created': total_created, 'updated': total_updated}