This commit is contained in:
gsinghpal
2026-04-04 15:37:16 -04:00
parent c66bdf5089
commit 3cc93b8783
36 changed files with 3278 additions and 548 deletions

View File

@@ -40,9 +40,9 @@ class FusionAccountingChatController(http.Controller):
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'}
def chat(self, session_id, message, context=None, image=None, **kwargs):
if not message and not image:
return {'error': 'Message or image is required'}
# S3: Ownership check
session = request.env['fusion.accounting.session'].browse(int(session_id))
if session.exists():
@@ -50,7 +50,7 @@ class FusionAccountingChatController(http.Controller):
if error:
return error
agent = request.env['fusion.accounting.agent']
result = agent.chat(int(session_id), message, context=context)
result = agent.chat(int(session_id), message or '', context=context, image=image)
return result
@http.route('/fusion_accounting/approve', type='jsonrpc', auth='user')
@@ -134,6 +134,30 @@ class FusionAccountingChatController(http.Controller):
results.append({'id': mid, 'status': 'error', 'error': 'Action could not be rejected. Check server logs for details.'})
return {'results': results}
@http.route('/fusion_accounting/chat/status', type='jsonrpc', auth='user')
def chat_status(self, session_id, **kwargs):
"""Poll the live execution state of a running chat — returns thinking text,
tool calls in progress, and current status. Called every 500ms by the frontend
while a chat request is in flight."""
from ..services.agent import get_execution_state
state = get_execution_state(int(session_id))
return state
@http.route('/fusion_accounting/search_matches', type='jsonrpc', auth='user')
def search_matches(self, statement_line_id, query='', **kwargs):
"""Live search for matching journal items — called directly by the
reconciliation table search bar (no AI round-trip)."""
from ..services.tools.bank_reconciliation import search_matching_entries
try:
result = search_matching_entries(request.env, {
'statement_line_id': int(statement_line_id),
'query': query,
})
return result
except Exception as e:
_logger.exception("Search matches failed")
return {'candidates': [], 'error': str(e)}
@http.route('/fusion_accounting/session/list', type='jsonrpc', auth='user')
def session_list(self, limit=20, **kwargs):
"""List recent sessions for the session picker dropdown."""
@@ -187,10 +211,20 @@ class FusionAccountingChatController(http.Controller):
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']})
# Include any pending approvals so they show on page load
agent = request.env['fusion.accounting.agent']
pending = request.env['fusion.accounting.match.history'].search([
('session_id', '=', session.id),
('decision', '=', 'pending'),
])
pending_approvals = [agent._format_pending_approval(p) for p in pending]
return {
'session_id': session.id,
'messages': display_messages,
'name': session.name,
'pending_approvals': pending_approvals,
}
@http.route('/fusion_accounting/session/history', type='jsonrpc', auth='user')

View File

@@ -59,6 +59,17 @@ for rule in model.search([('active', '=', True), ('approval_tier', '=', 'needs_a
<field name="active">True</field>
</record>
<!-- Daily auto-reconcile payroll cheques against open liability entries -->
<record id="cron_fusion_payroll_cheque_reconcile" model="ir.cron">
<field name="name">Fusion AI: Reconcile Payroll Cheques</field>
<field name="model_id" ref="model_fusion_accounting_agent"/>
<field name="state">code</field>
<field name="code">model._reconcile_payroll_cheques()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active">True</field>
</record>
<!-- Weekly vendor tax profile rebuild -->
<record id="cron_fusion_vendor_profiles" model="ir.cron">
<field name="name">Fusion AI: Rebuild Vendor Tax Profiles</field>

View File

@@ -151,10 +151,10 @@
<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="description">[Tier 1: Read-only] Get a partner's AR and AP balance with open items. Shows: how much they owe us (receivable), how much we owe them (payable), and net balance. Use for "how much do we owe Pride Mobility?", "what's the balance for ADP?".</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>
<field name="parameters_schema">{"type": "object", "properties": {"partner_id": {"type": "integer", "description": "Partner ID (optional if partner_name provided)"}, "partner_name": {"type": "string", "description": "Partner name to search for (e.g. 'Pride Mobility')"}}}</field>
</record>
<record id="tool_send_followup" model="fusion.accounting.tool">
<field name="name">send_followup</field>
@@ -476,6 +476,16 @@
<field name="parameters_schema">{"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
</record>
<record id="tool_register_adp_batch_payment" model="fusion.accounting.tool">
<field name="name">register_adp_batch_payment</field>
<field name="display_name_field">Register ADP Batch Payment</field>
<field name="description">[Tier 3: Requires user approval] Register payments for a batch of ADP invoices from a remittance advice. Takes a list of invoice numbers with payment amounts and a payment date. Registers each payment via Odoo's payment wizard, creating outstanding receipt entries (PBNK2) on account 1050. After this, use suggest_bank_line_matches on the bank deposit to match the outstanding receipts. Use this when the user uploads an ADP remittance advice screenshot and says "mark these paid".</field>
<field name="domain">adp</field>
<field name="tier">3</field>
<field name="parameters_schema">{"type": "object", "properties": {"invoices": {"type": "array", "items": {"type": "object", "properties": {"invoice_number": {"type": "string"}, "amount": {"type": "number"}}, "required": ["invoice_number", "amount"]}, "description": "List of invoices with number and payment amount"}, "payment_date": {"type": "string", "description": "Payment date from remittance (YYYY-MM-DD)"}, "journal_id": {"type": "integer", "description": "Bank journal ID (default 50 = Scotia Current)"}}, "required": ["invoices", "payment_date"]}</field>
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
</record>
<!-- Domain 10: Reporting -->
<record id="tool_get_profit_loss" model="fusion.accounting.tool">
<field name="name">get_profit_loss</field>
@@ -535,6 +545,31 @@
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
</record>
<record id="tool_get_invoicing_summary" model="fusion.accounting.tool">
<field name="name">get_invoicing_summary</field>
<field name="display_name_field">Get Invoicing Summary</field>
<field name="description">[Tier 1: Read-only] Get customer invoicing summary — monthly breakdown for a year, date range totals, or filtered by partner. Use this for questions like "how much did we invoice this year?", "show me invoicing by month", "how much did we bill ADP this quarter?".</field>
<field name="domain">reporting</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"year": {"type": "integer", "description": "Year for monthly breakdown (default: current year)"}, "partner_name": {"type": "string", "description": "Filter by partner name (optional)"}, "date_from": {"type": "string", "description": "Start date for date range (YYYY-MM-DD)"}, "date_to": {"type": "string", "description": "End date for date range (YYYY-MM-DD)"}}}</field>
</record>
<record id="tool_get_billing_summary" model="fusion.accounting.tool">
<field name="name">get_billing_summary</field>
<field name="display_name_field">Get Billing Summary</field>
<field name="description">[Tier 1: Read-only] Get vendor billing (purchases) summary — monthly breakdown for a year or date range. Use for "how much are our bills this month?", "show me vendor bills by month".</field>
<field name="domain">reporting</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"year": {"type": "integer"}, "partner_name": {"type": "string"}, "date_from": {"type": "string"}, "date_to": {"type": "string"}}}</field>
</record>
<record id="tool_get_collections_summary" model="fusion.accounting.tool">
<field name="name">get_collections_summary</field>
<field name="display_name_field">Get Collections Summary</field>
<field name="description">[Tier 1: Read-only] Get payment collections summary — how much was collected (customer payments received) in a period, broken down by partner. Use for "how much are we collecting this month?", "show me collections for March".</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>
<!-- Domain 11: Audit -->
<record id="tool_audit_posted_entry" model="fusion.accounting.tool">
<field name="name">audit_posted_entry</field>
@@ -763,6 +798,34 @@
<field name="parameters_schema">{"type": "object", "properties": {"journal_a_id": {"type": "integer", "description": "First bank journal ID"}, "journal_b_id": {"type": "integer", "description": "Second bank journal ID"}, "date_from": {"type": "string"}, "date_to": {"type": "string"}, "max_days_apart": {"type": "integer", "description": "Max days between matching lines (default 2)"}, "execute": {"type": "boolean", "description": "false=preview pairs only, true=actually reconcile"}}, "required": ["journal_a_id", "journal_b_id"]}</field>
</record>
<record id="tool_suggest_bank_line_matches" model="fusion.accounting.tool">
<field name="name">suggest_bank_line_matches</field>
<field name="display_name_field">Suggest Bank Line Matches</field>
<field name="description">[Tier 1: Read-only] Find candidate invoices/bills that could match a bank statement line. Extracts partner from the bank line reference, searches open receivables (for incoming payments) or payables (for outgoing payments), scores candidates by amount/partner/date proximity, and finds the best combination of entries that sum to the bank amount. Returns data for a reconciliation-mode fusion-table with editable amounts and search. The user reviews matches, adjusts amounts for partial payments, searches and adds more entries, then clicks Apply Match.</field>
<field name="domain">bank_reconciliation</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"statement_line_id": {"type": "integer", "description": "Bank statement line ID to find matches for"}}, "required": ["statement_line_id"]}</field>
</record>
<record id="tool_find_unreconciled_cheques" model="fusion.accounting.tool">
<field name="name">find_unreconciled_cheques</field>
<field name="display_name_field">Find Unreconciled Cheques</field>
<field name="description">[Tier 1: Read-only] Find unreconciled cheque bank lines and classify them as payroll or non-payroll. Payroll cheques have a matching credit amount on 2201 Payroll Liabilities. Non-payroll cheques (vendor payments, rent, etc.) don't. Default journal: Scotia Current (50).</field>
<field name="domain">bank_reconciliation</field>
<field name="tier">1</field>
<field name="parameters_schema">{"type": "object", "properties": {"journal_id": {"type": "integer", "description": "Bank journal ID (default 50 = Scotia Current)"}, "limit": {"type": "integer", "description": "Max results (default 50)"}}}</field>
</record>
<record id="tool_reconcile_payroll_cheques" model="fusion.accounting.tool">
<field name="name">reconcile_payroll_cheques</field>
<field name="display_name_field">Reconcile Payroll Cheques</field>
<field name="description">[Tier 3: Requires user approval] Reconcile payroll cheque bank lines by applying the Payroll Cheque Clearing model. ONLY processes cheques whose amount matches an existing payroll liability entry on 2201. Non-payroll cheques (vendor/rent) are skipped automatically. Uses the pre-configured "Payroll Cheque Clearing" reconcile model (writeoff to Dr 2201).</field>
<field name="domain">bank_reconciliation</field>
<field name="tier">3</field>
<field name="parameters_schema">{"type": "object", "properties": {"journal_id": {"type": "integer", "description": "Bank journal ID (default 50)"}, "line_ids": {"type": "array", "items": {"type": "integer"}, "description": "Optional: specific bank line IDs to reconcile. If empty, reconciles all matching payroll cheques."}}}</field>
<field name="required_groups">fusion_accounting.group_fusion_accounting_manager</field>
</record>
<record id="tool_create_expense_entry" model="fusion.accounting.tool">
<field name="name">create_expense_entry</field>
<field name="display_name_field">Create Direct GL Expense</field>

View File

@@ -209,59 +209,111 @@ class FusionAccountingDashboard(models.TransientModel):
def _compute_action_centre(self):
for rec in self:
attention = []
today = fields.Date.today()
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 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,
'title': f'{pending} AI actions awaiting approval',
'priority': 0, 'severity': 'danger',
'title': f'{pending} AI actions awaiting your approval',
'domain': 'audit',
'action': 'Review and approve/reject pending actions',
'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', '<=', fields.Date.today() - timedelta(days=30)),
('date', '<=', today - timedelta(days=30)),
('company_id', '=', rec.company_id.id),
])
if drafts > 0:
attention.append({
'priority': 3,
'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)

View File

@@ -1,19 +1,83 @@
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')
@@ -60,6 +124,30 @@ class FusionAccountingMatchHistory(models.Model):
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',

View File

@@ -50,9 +50,9 @@ class FusionAccountingAdapterClaude(models.AbstractModel):
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):
def call_with_tools(self, system_prompt, messages, tools=None, model_override=None):
client = self._get_client()
model = self._get_model_name()
model = model_override or self._get_model_name()
api_messages = []
for msg in messages:

View File

@@ -52,9 +52,9 @@ class FusionAccountingAdapterOpenAI(models.AbstractModel):
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):
def call_with_tools(self, system_prompt, messages, tools=None, model_override=None):
client = self._get_client()
model = self._get_model_name()
model = model_override or self._get_model_name()
is_reasoning = self._is_reasoning_model(model)
if is_reasoning:

View File

@@ -8,6 +8,17 @@ from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
# In-memory execution state for live status polling.
# Key: session_id, Value: {thinking, tool_calls, status}
# Cleared after each chat() call completes.
_execution_state = {}
def get_execution_state(session_id):
"""Get the current execution state for a session (called by polling endpoint)."""
return _execution_state.get(session_id, {'status': 'idle', 'thinking': '', 'tool_calls': []})
# Inter-account transfer pairs: (source_journal, cc_journal, cc_account_pattern)
# Source sends "MB-CREDIT CARD" (outgoing), CC receives "PAYMENT FROM" (incoming)
TRANSFER_PAIRS = [
@@ -25,12 +36,75 @@ class FusionAccountingAgent(models.AbstractModel):
ICP = self.env['ir.config_parameter'].sudo()
return ICP.get_param(f'fusion_accounting.{key}', default)
# Domains that need deeper reasoning → use Sonnet
COMPLEX_DOMAINS = {'audit', 'month_end', 'hst_management', 'payroll_management'}
# Keywords in user messages that suggest complex analysis → use Sonnet
COMPLEX_KEYWORDS = {
'audit', 'analyze', 'analyse', 'review all', 'full report', 'investigate',
'month-end', 'month end', 'close the books', 'hst filing', 'tax return',
'what went wrong', 'why is', 'explain the difference', 'compare',
}
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 _route_model(self, session, user_message, has_image=False):
"""Smart model routing: Haiku for routine tool calling, Sonnet for complex analysis.
Returns (model_name, can_escalate) — can_escalate=True means Haiku is trying first
and we should check if it needs help."""
provider = session.ai_provider or self._get_config('ai_provider', 'claude')
if provider != 'claude':
return None, False
# Always use Sonnet for images (vision quality matters for OCR)
if has_image:
return 'claude-sonnet-4-6', False
# Use Sonnet for complex domains
if session.context_domain in self.COMPLEX_DOMAINS:
return 'claude-sonnet-4-6', False
# Use Sonnet if the message contains complex analysis keywords
msg_lower = (user_message or '').lower()
if any(kw in msg_lower for kw in self.COMPLEX_KEYWORDS):
return 'claude-sonnet-4-6', False
# Default: Haiku with escalation enabled
return 'claude-haiku-4-5', True
def _should_escalate(self, response, tool_calls_log, turn):
"""Check if Haiku's response suggests it needs Sonnet's help."""
text = (response.get('text') or '').lower()
# Haiku said it can't do something
uncertainty_phrases = [
"i'm not sure", "i cannot determine", "i don't have enough",
"unable to", "i'm unable", "this is complex", "beyond my",
"i need more context", "difficult to assess", "i apologize",
"i'm having trouble", "let me think about this differently",
]
if any(phrase in text for phrase in uncertainty_phrases):
return True
# Haiku made no tool calls on first turn when it probably should have
# (user asked a question but Haiku just gave text without using tools)
if turn == 0 and not response.get('tool_calls') and not text:
return True
# Haiku had multiple tool errors
error_count = sum(1 for tc in tool_calls_log if tc.get('status') == 'error')
if error_count >= 2:
return True
# Response is very short for a data question (Haiku might be confused)
if turn == 0 and not response.get('tool_calls') and len(text) < 50:
return True
return False
def _get_tool_registry(self):
return self.env['fusion.accounting.tool'].search([('active', '=', True)])
@@ -114,8 +188,8 @@ class FusionAccountingAgent(models.AbstractModel):
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 '{}',
'tool_params': json.dumps(params, indent=2, default=str) if params else '{}',
'tool_result': json.dumps(result, indent=2, default=str) if result else '{}',
'ai_reasoning': reasoning,
'ai_confidence': confidence,
'rule_id': rule.id if rule else False,
@@ -125,7 +199,7 @@ class FusionAccountingAgent(models.AbstractModel):
}
return self.env['fusion.accounting.match.history'].sudo().create(vals)
def chat(self, session_id, user_message, context=None):
def chat(self, session_id, user_message, context=None, image=None):
session = self.env['fusion.accounting.session'].browse(session_id)
if not session.exists():
raise UserError(_("Session not found."))
@@ -155,36 +229,106 @@ class FusionAccountingAgent(models.AbstractModel):
)
messages_json = json.loads(session.message_ids_json or '[]')
messages_json.append({'role': 'user', 'content': user_message})
# Build user message — may include image for vision
if image and isinstance(image, dict) and image.get('base64'):
user_content = []
if user_message:
user_content.append({'type': 'text', 'text': user_message})
user_content.append({
'type': 'image',
'source': {
'type': 'base64',
'media_type': image.get('media_type', 'image/png'),
'data': image['base64'],
},
})
messages_json.append({'role': 'user', 'content': user_content})
else:
messages_json.append({'role': 'user', 'content': user_message})
# Smart model routing: Haiku for routine, Sonnet for complex
has_image = bool(image and isinstance(image, dict) and image.get('base64'))
model_override, can_escalate = self._route_model(session, user_message, has_image=has_image)
escalated = False
if model_override:
_logger.info("Model routing: %s%s (escalation=%s)", session.name, model_override, can_escalate)
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}
has_pending_tier3 = False
tool_calls_log = [] # Track tool calls for frontend display
reconciliation_data = None # Raw data from suggest_bank_line_matches
# Initialize live execution state for polling
_execution_state[session.id] = {
'status': 'thinking',
'thinking': '',
'tool_calls': [],
'turn': 0,
}
for turn in range(max_turns):
_execution_state[session.id]['status'] = 'calling_ai'
_execution_state[session.id]['turn'] = turn + 1
response = adapter.call_with_tools(
system_prompt=system_prompt,
messages=messages_json,
tools=tool_definitions,
model_override=model_override,
)
total_tokens_in += response.get('tokens_in', 0)
total_tokens_out += response.get('tokens_out', 0)
# Check if Haiku needs to escalate to Sonnet
if can_escalate and not escalated and self._should_escalate(response, tool_calls_log, turn):
_logger.info("Escalating %s from Haiku → Sonnet (turn %d)", session.name, turn)
model_override = 'claude-sonnet-4-6'
escalated = True
can_escalate = False
_execution_state[session.id]['status'] = 'escalating'
# Re-call with Sonnet
response = adapter.call_with_tools(
system_prompt=system_prompt,
messages=messages_json,
tools=tool_definitions,
model_override=model_override,
)
total_tokens_in += response.get('tokens_in', 0)
total_tokens_out += response.get('tokens_out', 0)
# Capture thinking text for live display
thinking = ''
for block in (response.get('raw_content') or []):
if hasattr(block, 'type') and block.type == 'thinking':
thinking = block.thinking
break
if thinking:
_execution_state[session.id]['thinking'] = thinking[:500] # Truncated for live display
if response.get('tool_calls'):
tool_results = []
_execution_state[session.id]['status'] = 'calling_tools'
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'
# Update live state: show which tool is running
_execution_state[session.id]['tool_calls'].append({
'name': tool_name, 'status': 'running',
})
if tier == '3':
has_pending_tier3 = True
history_rec = self._log_match_history(
session, tool_name, tool_params, None,
reasoning=tc.get('reasoning', ''),
reasoning=thinking or '',
confidence=tc.get('confidence', 0.0),
tier='3',
)
@@ -196,17 +340,43 @@ class FusionAccountingAgent(models.AbstractModel):
'message': f'Action requires user approval. Match history ID: {history_rec.id}',
}),
})
tool_calls_log.append({
'name': tool_name,
'tier': tier,
'status': 'pending_approval',
'summary': self._build_tool_call_summary(tool_name, tool_params, None),
})
_execution_state[session.id]['tool_calls'][-1]['status'] = 'pending'
else:
t0 = time.time()
result = self._execute_tool(tool_name, tool_params, session.id)
elapsed = round((time.time() - t0) * 1000)
self._log_match_history(
session, tool_name, tool_params, result,
reasoning=tc.get('reasoning', ''),
reasoning=thinking or '',
tier=tier,
)
tool_results.append({
'tool_call_id': tc.get('id', ''),
'result': json.dumps(result) if not isinstance(result, str) else result,
})
tc_status = 'error' if isinstance(result, dict) and result.get('error') else 'ok'
tc_summary = self._build_tool_call_summary(tool_name, tool_params, result)
# Capture reconciliation data for direct frontend rendering
if tool_name == 'suggest_bank_line_matches' and tc_status == 'ok':
reconciliation_data = result
tool_calls_log.append({
'name': tool_name,
'tier': tier,
'status': tc_status,
'summary': tc_summary,
'duration_ms': elapsed,
})
# Update live state
_execution_state[session.id]['tool_calls'][-1].update({
'status': tc_status, 'summary': tc_summary, 'duration_ms': elapsed,
})
try:
self._check_rule_proposal(tool_name, tool_params, session)
except Exception:
@@ -225,6 +395,7 @@ class FusionAccountingAgent(models.AbstractModel):
system_prompt=system_prompt,
messages=messages_json,
tools=[],
model_override=model_override,
)
total_tokens_in += response.get('tokens_in', 0)
total_tokens_out += response.get('tokens_out', 0)
@@ -249,6 +420,7 @@ class FusionAccountingAgent(models.AbstractModel):
system_prompt=system_prompt,
messages=messages_json,
tools=[],
model_override=model_override,
)
total_tokens_in += response.get('tokens_in', 0)
total_tokens_out += response.get('tokens_out', 0)
@@ -264,7 +436,7 @@ class FusionAccountingAgent(models.AbstractModel):
'token_count_in': session.token_count_in + total_tokens_in,
'token_count_out': session.token_count_out + total_tokens_out,
'ai_provider': provider,
'ai_model': adapter._get_model_name(),
'ai_model': model_override or adapter._get_model_name(),
})
pending = self.env['fusion.accounting.match.history'].search([
@@ -272,19 +444,190 @@ class FusionAccountingAgent(models.AbstractModel):
('decision', '=', 'pending'),
])
return {
# Clear live execution state
_execution_state.pop(session.id, None)
# Add escalation marker to tool calls log if it happened
if escalated:
tool_calls_log.insert(0, {
'name': 'model_escalation',
'tier': '-',
'status': 'ok',
'summary': 'Escalated from Haiku to Sonnet for deeper analysis',
'duration_ms': 0,
})
result_payload = {
'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],
'tool_calls_log': tool_calls_log,
'pending_approvals': [self._format_pending_approval(p) for p in pending],
'session_id': session.id,
'model_used': model_override or adapter._get_model_name(),
}
# Attach raw reconciliation data so frontend renders it directly
# (instead of relying on AI to format fusion-table JSON correctly)
if reconciliation_data:
result_payload['reconciliation_table'] = reconciliation_data
return result_payload
def _build_tool_call_summary(self, tool_name, params, result):
"""Build a one-line summary of what a tool call did, for the collapsed tool log."""
try:
# Result-based summaries (when we have output)
if result and isinstance(result, dict) and not result.get('error'):
count = result.get('count')
status = result.get('status')
if status == 'created':
name = result.get('name', '')
return f"Created {name}" if name else "Created successfully"
if status == 'matched':
return "Matched successfully"
if count is not None:
return f"Found {count} result{'s' if count != 1 else ''}"
if 'balance' in result:
return f"Balance: ${result['balance']:,.2f}"
if 'total' in result:
return f"Total: ${result['total']:,.2f}"
if 'entries' in result:
return f"Found {len(result['entries'])} entries"
if 'accounts' in result:
return f"Found {len(result['accounts'])} accounts"
if status:
return str(status)
if result and isinstance(result, dict) and result.get('error'):
err = str(result['error'])
return f"Error: {err[:80]}"
# Params-based summaries (for pending approvals, no result yet)
if params:
ref = params.get('ref', params.get('reference', params.get('name', '')))
amount = params.get('amount')
lines = params.get('lines', [])
if lines:
total = sum(l.get('debit', 0) for l in lines)
return f"{ref} — ${total:,.2f}" if ref else f"${total:,.2f} journal entry"
if ref and amount:
return f"{ref} — ${abs(amount):,.2f}"
if ref:
return str(ref)
return "Completed"
except Exception:
return "Completed"
def _format_pending_approval(self, history):
"""Build a rich approval payload so the UI can show exactly what's being approved."""
params = {}
try:
params = json.loads(history.tool_params or '{}')
except json.JSONDecodeError:
pass
# Extract amount from params — look in common locations
amount = history.amount or 0.0
if not amount:
# Try to compute from journal entry lines
lines = params.get('lines', [])
if lines:
amount = sum(l.get('debit', 0) for l in lines)
# Or from direct amount field
if not amount:
amount = abs(params.get('amount', 0))
# Build a human-readable summary of what this action will do
summary = self._build_approval_summary(history.tool_name, params)
return {
'id': history.id,
'tool_name': history.tool_name,
'params': history.tool_params,
'reasoning': history.ai_reasoning,
'confidence': history.ai_confidence,
'amount': amount,
'summary': summary,
}
def _resolve_account_label(self, account_id):
"""Resolve an account ID to 'code - name' for display."""
if not account_id:
return '?'
try:
acct = self.env['account.account'].browse(int(account_id))
if acct.exists():
return f"{acct.code} {acct.name}"
except Exception:
pass
return str(account_id)
def _build_approval_summary(self, tool_name, params):
"""Generate a short human-readable description of what a Tier 3 action will do."""
try:
if tool_name == 'create_payroll_journal_entry':
ref = params.get('ref', 'Payroll Entry')
date = params.get('date', '?')
lines = params.get('lines', [])
total = sum(l.get('debit', 0) for l in lines)
acct_names = []
for l in lines:
aid = l.get('account_id', '')
acct_label = self._resolve_account_label(aid)
if l.get('debit'):
acct_names.append(f"Dr {acct_label}: ${l['debit']:,.2f}")
elif l.get('credit'):
acct_names.append(f"Cr {acct_label}: ${l['credit']:,.2f}")
detail = ' / '.join(acct_names) if acct_names else ''
return f"{ref} on {date} — ${total:,.2f}\n{detail}"
elif tool_name == 'create_vendor_bill':
partner = params.get('partner_name', params.get('partner_id', '?'))
amount = params.get('amount', 0)
ref = params.get('ref', params.get('reference', ''))
date = params.get('date', '?')
return f"Vendor bill for {partner} — ${abs(amount):,.2f} on {date}" + (f" ({ref})" if ref else "")
elif tool_name == 'register_bill_payment':
bill_id = params.get('bill_id', '?')
amount = params.get('amount', 0)
journal = params.get('journal_id', '?')
return f"Pay bill #{bill_id} — ${abs(amount):,.2f} from journal {journal}"
elif tool_name == 'create_expense_entry':
ref = params.get('ref', params.get('memo', 'Expense'))
amount = params.get('amount', 0)
account = params.get('expense_account_id', '?')
return f"{ref} — ${abs(amount):,.2f} to account {account}"
elif tool_name == 'register_hst_payment':
amount = params.get('amount', 0)
date = params.get('date', '?')
return f"HST remittance — ${abs(amount):,.2f} on {date}"
elif tool_name in ('apply_payment', 'send_followup', 'create_payment_reminder'):
partner = params.get('partner_name', params.get('partner_id', '?'))
amount = params.get('amount', 0)
return f"{tool_name.replace('_', ' ').title()} for {partner}" + (f" — ${abs(amount):,.2f}" if amount else "")
elif tool_name == 'flag_entry':
move_id = params.get('move_id', '?')
reason = params.get('reason', '')
return f"Flag entry #{move_id}" + (f": {reason}" if reason else "")
else:
# Generic fallback: show key params
parts = []
for key in ('ref', 'reference', 'name', 'partner_name', 'date', 'move_id'):
if key in params:
parts.append(f"{key}: {params[key]}")
if 'amount' in params:
parts.append(f"${abs(params['amount']):,.2f}")
return ' | '.join(parts) if parts else json.dumps(params)[:120]
except Exception:
return str(params)[:120]
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':
@@ -504,3 +847,101 @@ class FusionAccountingAgent(models.AbstractModel):
)
_logger.info("Transfer reconcile complete: %d total reconciled", total_reconciled)
# ----------------------------------------------------------------
# One-time: Match payroll cheque bank lines against open payroll liability entries
# ----------------------------------------------------------------
@api.model
def _reconcile_payroll_cheques(self):
"""Reconcile payroll cheque bank lines using writeoff to Payroll Liabilities (2201).
Your payroll JEs post:
Dr Salaries / Dr ER CPP-EI / Dr CRA Taxes
Cr 2201 Payroll Liabilities (net pay = cheque amount)
When the cheque clears the bank, the bank line shows:
"Cheque 1773 : Cheque" = -$1,477.95
This method finds cheque bank lines that have a matching payroll liability
entry (same amount) and applies a reconcile model that writes off to account
433 (Payroll Liabilities). This debits 433 to clear the liability.
Non-payroll cheques (no matching entry on 433) are skipped.
"""
PAYROLL_LIABILITY_ACCT_ID = 433 # code 2201
SCOTIA_CURRENT_JOURNAL_ID = 50
AML = self.env['account.move.line'].sudo()
BSL = self.env['account.bank.statement.line'].sudo()
RecModel = self.env['account.reconcile.model'].sudo()
# Find the payroll cheque reconcile model (must be pre-created via XML or manually)
model = RecModel.search([
('name', 'ilike', 'Payroll Cheque'),
('company_id', '=', self.env.company.id),
], limit=1)
if not model:
_logger.warning("Payroll cheque reconcile: no 'Payroll Cheque' model found — create one manually")
return
# Find all unreconciled cheque lines on Scotia Current (negative = outgoing)
# Only process lines after lock date to avoid lock date errors
cheque_lines = BSL.search([
('journal_id', '=', SCOTIA_CURRENT_JOURNAL_ID),
('is_reconciled', '=', False),
('amount', '<', 0),
('payment_ref', 'ilike', 'cheque'),
('company_id', '=', self.env.company.id),
], order='move_id asc')
# Filter to post-lock-date lines only
lock_date = self.env.company.fiscalyear_lock_date
if lock_date:
cheque_lines = cheque_lines.filtered(lambda l: l.move_id.date > lock_date)
_logger.info("Payroll cheque reconcile: found %d unreconciled cheque lines (post lock date)", len(cheque_lines))
# Build set of all known payroll liability credit amounts
payroll_credit_amounts = set()
for aml in AML.search([
('account_id', '=', PAYROLL_LIABILITY_ACCT_ID),
('parent_state', '=', 'posted'),
('credit', '>', 0),
]):
payroll_credit_amounts.add(round(aml.credit, 2))
# Filter: only reconcile cheques that have a matching payroll liability entry
payroll_lines = cheque_lines.filtered(
lambda l: round(abs(l.amount), 2) in payroll_credit_amounts
)
_logger.info(
"Payroll cheque reconcile: %d payroll, %d non-payroll (skipped)",
len(payroll_lines), len(cheque_lines) - len(payroll_lines),
)
if not payroll_lines:
_logger.info("Payroll cheque reconcile: nothing to reconcile")
return
# Apply the reconcile model to payroll cheque lines
try:
model._apply_reconcile_models(payroll_lines)
self.env.cr.commit()
except Exception as e:
_logger.exception("Payroll cheque reconcile batch failed: %s", e)
self.env.cr.rollback()
return
# Count results
still_unreconciled = payroll_lines.filtered(lambda l: not l.is_reconciled)
reconciled = len(payroll_lines) - len(still_unreconciled)
for line in still_unreconciled[:10]:
_logger.info("Payroll cheque still unreconciled: %s $%.2f", line.payment_ref, abs(line.amount))
_logger.info(
"Payroll cheque reconcile complete: %d reconciled, %d still unreconciled",
reconciled, len(still_unreconciled),
)

View File

@@ -8,6 +8,34 @@ You are helping with bank statement reconciliation. Key concepts:
- 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.
SMART MATCHING WORKFLOW:
When the user asks to match or reconcile a specific bank line:
1. Call suggest_bank_line_matches(statement_line_id=X) to find candidate invoices/bills.
2. Present the results as a reconciliation-mode fusion-table. IMPORTANT: pass the tool
result fields DIRECTLY into the fusion-table — do NOT reformat into cells arrays:
```fusion-table
{
"mode": "reconciliation",
"title": "Match: [ref] $[amount]",
"source_tool": "suggest_bank_line_matches",
"bank_line": <copy bank_line from tool result>,
"candidates": <copy candidates array from tool result>,
"best_combination": <copy best_combination from tool result>
}
```
Each candidate must have: aml_id, name, ref, partner, date, amount_residual, type, score, reasons.
Do NOT convert candidates into {"id":..., "cells":[...]} format — use the raw tool output.
3. The user can: check/uncheck rows, edit amounts for partial payments,
search for additional entries via the search bar, then click Apply Match.
4. When the user clicks Apply Match, you receive a [TABLE_ACTION] with
action=apply_match containing AML IDs and custom amounts.
5. Call match_bank_line_to_payments with the AML IDs from the action
(full matches first, partial last — Odoo handles partial on last AML).
6. Partial payment: if apply_amount < amount_residual, it's partial.
Only ONE AML can be partial (the last one). Odoo leaves the residual open.
Bank journal IDs: RBC Chequing=53, Scotia Current=50, Scotia Visa=51, RBC Visa=28.
""",
'hst_management': """
@@ -119,10 +147,31 @@ INVENTORY & COGS CONTEXT:
""",
'adp': """
ADP RECONCILIATION CONTEXT:
ADP (ASSISTIVE DEVICE PROGRAM) RECONCILIATION CONTEXT:
- ADP Receivable tracked on account 1101.
- ADP invoices have customer portion + ADP portion = total.
- Government deposits should match ADP invoices.
- Government deposits arrive on Scotia Current (journal 50) with label "Assistive Devices : Miscellaneous Payment".
- ADP partner in Odoo: "ADP (Assistive Device Program)" (id 3421).
ADP PAYMENT MATCHING WORKFLOW:
1. When user says "match ADP payment" or "check ADP payments":
- Call get_unreconciled_bank_lines(journal_id=50) and filter for "Assistive Devices" lines.
- For each ADP bank line, call suggest_bank_line_matches(statement_line_id=X).
- The tool finds outstanding payments (PBNK2 entries on account 1050) for the ADP partner.
- Present as reconciliation fusion-table.
2. When user uploads an ADP remittance advice image:
- Read the image. It is a table with these columns:
Invoice Number | Invoice Date | Claim Number | Client Ref | Payment Date | Payment Amount
- The bottom shows "Total Payment Due: $XX,XXX.XX" — this is the bank deposit amount.
- Extract every row: invoice number and payment amount.
- Find the bank line on Scotia Current matching the total amount.
- Call suggest_bank_line_matches for that bank line.
- The outstanding payments on 1050 should sum to the total.
3. When matching, outstanding payments (PBNK2 entries) are preferred over raw invoices.
Each PBNK2 entry represents a registered payment batch. Two or more PBNK2 entries
may combine to equal the bank deposit total.
""",
'reporting': """

View File

@@ -89,6 +89,48 @@ LINKING TO ODOO RECORDS:
- Bank statement lines: mention the date, reference, and amount clearly.
- When tool results include record IDs, always link them.
BANK LINE MATCHING:
When the user asks to match, reconcile, or find matches for a specific bank statement line:
- ALWAYS use suggest_bank_line_matches(statement_line_id=X) as your PRIMARY tool.
- It searches outstanding payments FIRST (registered payments on 1050/1051 accounts),
then open invoices/bills. Outstanding payments are the correct match — not raw invoices.
- Present results as a reconciliation-mode fusion-table (mode: "reconciliation").
- Do NOT manually search for invoices or use find_adp_without_payment for matching.
- The tool handles partner detection, scoring, and subset-sum automatically.
- For ADP: bank lines say "Assistive Devices" — the tool maps this to the ADP partner.
ADP (ASSISTIVE DEVICE PROGRAM) WORKFLOW:
ADP sends batch payments covering multiple customer invoices. The bank deposit label is
"Assistive Devices : Miscellaneous Payment". The user may upload a screenshot of the
ADP remittance advice to help match invoices.
When handling ADP payments:
1. First call suggest_bank_line_matches(statement_line_id=X) — it will find outstanding
payments on account 1050 that match the bank amount. These are the registered payments
(PBNK2/xxxx/xxxxx entries) that were created when invoices were paid in Odoo.
2. Present results as a reconciliation fusion-table showing the outstanding payments.
3. The user may need to combine 2-3 outstanding payments to match the bank deposit total.
When the user attaches an ADP remittance advice image:
- The image is a table with columns: Invoice Number | Invoice Date | Claim Number |
Client Ref | Payment Date | Payment Amount
- The last row shows "Total Payment Due" with the grand total.
- Extract ALL invoice numbers and their payment amounts from the image.
- Present a summary table of what you extracted for confirmation.
- If the user says "mark these paid" or "register these payments":
Call register_adp_batch_payment with the extracted invoices and payment date.
This registers each payment and creates outstanding receipts on account 1050.
Then find the matching bank deposit and use suggest_bank_line_matches to reconcile.
- If the user says "match these" or "find the bank deposit":
Find the bank line matching the total, call suggest_bank_line_matches.
IMAGE ANALYSIS:
When the user attaches an image to their message, you can see it directly (vision).
- Read all text, numbers, and tables from the image.
- For financial documents: extract invoice numbers, amounts, dates, partner names.
- For remittance advices: extract the line items and grand total.
- Always confirm what you extracted before taking action.
TOOL CALLING:
- Call tools by name with the required parameters.
- You may call multiple tools in sequence to gather data before proposing an action.

View File

@@ -65,27 +65,56 @@ def get_overdue_invoices(env, params):
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),
"""Get AR and AP balance for a partner. Accepts partner_id or partner_name."""
partner = None
if params.get('partner_id'):
partner = env['res.partner'].browse(int(params['partner_id']))
elif params.get('partner_name'):
partner = env['res.partner'].search([
('name', 'ilike', params['partner_name']),
], limit=1)
if not partner or not partner.exists():
return {'error': f"Partner not found: {params.get('partner_name', params.get('partner_id', '?'))}"}
# AR balance (receivable)
ar_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),
])
ar_balance = sum(aml.amount_residual for aml in ar_amls)
# AP balance (payable)
ap_amls = env['account.move.line'].search([
('partner_id', '=', partner.id),
('account_id.account_type', '=', 'liability_payable'),
('parent_state', '=', 'posted'),
('reconciled', '=', False),
('company_id', '=', env.company.id),
])
ap_balance = sum(aml.amount_residual for aml in ap_amls)
open_items = [{
'id': aml.id,
'move_name': aml.move_id.name,
'ref': aml.ref or '',
'date': str(aml.date),
'amount_residual': aml.amount_residual,
'type': 'receivable' if aml.account_id.account_type == 'asset_receivable' else 'payable',
'date_maturity': str(aml.date_maturity) if aml.date_maturity else '',
} for aml in (ar_amls | ap_amls)[:30]]
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],
'partner_id': partner.id,
'ar_balance': ar_balance,
'ap_balance': ap_balance,
'net_balance': ar_balance + ap_balance,
'they_owe_us': ar_balance if ar_balance > 0 else 0,
'we_owe_them': abs(ap_balance) if ap_balance < 0 else 0,
'open_items': open_items,
}

View File

@@ -7,7 +7,7 @@ _logger = logging.getLogger(__name__)
def get_adp_receivable_aging(env, params):
accounts = env['account.account'].search([
('code', '=like', '1101%'),
('company_id', '=', env.company.id),
('company_ids', 'in', env.company.id),
])
today = fields.Date.today()
amls = env['account.move.line'].search([
@@ -81,7 +81,7 @@ 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),
('code', '=like', '1101%'), ('company_ids', 'in', env.company.id),
])
domain = [
('account_id', 'in', accounts.ids),
@@ -102,10 +102,136 @@ def get_adp_summary(env, params):
}
def register_adp_batch_payment(env, params):
"""Register payments for a batch of ADP invoices from a remittance advice.
Takes a list of invoice numbers with payment amounts and a payment date.
Registers a payment for each invoice via Odoo's payment wizard, which
creates outstanding receipt entries (PBNK2) on account 1050.
After calling this, use suggest_bank_line_matches on the bank deposit line
to match the outstanding receipts against the bank line.
"""
invoices_data = params.get('invoices', [])
payment_date = params.get('payment_date')
journal_id = int(params.get('journal_id', 50)) # Default Scotia Current
if not invoices_data:
return {'error': 'No invoices provided'}
if not payment_date:
return {'error': 'payment_date is required (YYYY-MM-DD)'}
ADP_PARTNER_ID = 3421 # ADP (Assistive Device Program)
results = []
total_paid = 0.0
errors = []
for inv_data in invoices_data:
inv_number = str(inv_data.get('invoice_number', '')).strip()
amount = float(inv_data.get('amount', 0))
if not inv_number or not amount:
errors.append(f"Skipped: missing invoice_number or amount in {inv_data}")
continue
# Find the invoice by name/number
invoice = env['account.move'].search([
('name', 'ilike', inv_number),
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('company_id', '=', env.company.id),
], limit=1)
if not invoice:
# Try without leading zeros or with different format
invoice = env['account.move'].search([
('name', '=like', f'%{inv_number}'),
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
], limit=1)
if not invoice:
errors.append(f"Invoice {inv_number} not found")
continue
if invoice.payment_state == 'paid':
results.append({
'invoice': inv_number,
'status': 'already_paid',
'move_id': invoice.id,
})
continue
# Check if amount matches residual (allow partial)
if amount > invoice.amount_residual + 0.01:
errors.append(
f"Invoice {inv_number}: payment ${amount:.2f} exceeds "
f"residual ${invoice.amount_residual:.2f}"
)
continue
# Register payment via the payment wizard
try:
payment_vals = {
'payment_type': 'inbound',
'partner_type': 'customer',
'partner_id': invoice.partner_id.id or ADP_PARTNER_ID,
'amount': amount,
'date': payment_date,
'journal_id': journal_id,
'ref': f'ADP Remittance - {inv_number}',
}
# Use the payment register wizard
ctx = {
'active_model': 'account.move',
'active_ids': [invoice.id],
}
wizard = env['account.payment.register'].with_context(**ctx).create({
'payment_date': payment_date,
'amount': amount,
'journal_id': journal_id,
'payment_method_line_id': env['account.payment.method.line'].search([
('journal_id', '=', journal_id),
('payment_type', '=', 'inbound'),
], limit=1).id,
})
wizard.action_create_payments()
results.append({
'invoice': inv_number,
'status': 'paid',
'amount': amount,
'move_id': invoice.id,
'move_name': invoice.name,
})
total_paid += amount
except Exception as e:
_logger.warning("ADP payment failed for %s: %s", inv_number, e)
errors.append(f"Invoice {inv_number}: payment failed — {e}")
env.cr.commit()
return {
'status': 'completed',
'paid_count': len([r for r in results if r.get('status') == 'paid']),
'already_paid_count': len([r for r in results if r.get('status') == 'already_paid']),
'total_paid': total_paid,
'results': results,
'errors': errors,
'message': (
f"Registered payments for {len([r for r in results if r.get('status') == 'paid'])} invoices "
f"totalling ${total_paid:,.2f}. "
+ (f"{len(errors)} errors." if errors else "No errors.")
+ " Now use suggest_bank_line_matches to match the bank deposit."
),
}
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,
'register_adp_batch_payment': register_adp_batch_payment,
}

View File

@@ -35,7 +35,7 @@ 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),
('company_ids', 'in', env.company.id),
])
domain = [
('account_id', 'in', accounts.ids),
@@ -484,6 +484,464 @@ def match_internal_transfers(env, params):
}
def find_unreconciled_cheques(env, params):
"""Find unreconciled cheque bank lines and classify as payroll vs non-payroll
by checking if the amount matches an existing payroll liability entry."""
PAYROLL_ACCT = 433 # 2201 Payroll Liabilities
journal_id = int(params.get('journal_id', 50)) # Default Scotia Current
limit = int(params.get('limit', 50))
AML = env['account.move.line'].sudo()
BSL = env['account.bank.statement.line'].sudo()
# Build set of known payroll liability amounts
payroll_amounts = set()
for aml in AML.search([
('account_id', '=', PAYROLL_ACCT),
('parent_state', '=', 'posted'),
('credit', '>', 0),
]):
payroll_amounts.add(round(aml.credit, 2))
cheque_lines = BSL.search([
('journal_id', '=', journal_id),
('is_reconciled', '=', False),
('payment_ref', 'ilike', 'cheque'),
('amount', '<', 0),
('company_id', '=', env.company.id),
], limit=limit, order='move_id desc')
payroll = []
non_payroll = []
for line in cheque_lines:
amt = round(abs(line.amount), 2)
entry = {
'id': line.id,
'date': str(line.move_id.date),
'ref': line.payment_ref or '',
'amount': amt,
'journal': line.journal_id.name,
}
if amt in payroll_amounts:
entry['type'] = 'payroll'
payroll.append(entry)
else:
entry['type'] = 'non_payroll'
non_payroll.append(entry)
return {
'count': len(cheque_lines),
'payroll_count': len(payroll),
'non_payroll_count': len(non_payroll),
'payroll': payroll,
'non_payroll': non_payroll,
}
def reconcile_payroll_cheques(env, params):
"""Reconcile payroll cheque bank lines by applying the Payroll Cheque Clearing
reconcile model. Only reconciles cheques whose amount matches an existing
payroll liability entry on account 2201. Non-payroll cheques are skipped.
Params:
journal_id (int): Bank journal ID (default 50 = Scotia Current)
line_ids (list): Optional list of specific bank line IDs to reconcile.
If not provided, reconciles all matching payroll cheques.
"""
PAYROLL_ACCT = 433
journal_id = int(params.get('journal_id', 50))
AML = env['account.move.line'].sudo()
BSL = env['account.bank.statement.line'].sudo()
RecModel = env['account.reconcile.model'].sudo()
model = RecModel.search([
('name', 'ilike', 'Payroll Cheque'),
('company_id', '=', env.company.id),
], limit=1)
if not model:
return {'error': 'No "Payroll Cheque Clearing" reconcile model found. Create one first.'}
# Get lines to process
if params.get('line_ids'):
cheque_lines = BSL.browse([int(x) for x in params['line_ids']])
cheque_lines = cheque_lines.filtered(lambda l: not l.is_reconciled)
else:
cheque_lines = BSL.search([
('journal_id', '=', journal_id),
('is_reconciled', '=', False),
('payment_ref', 'ilike', 'cheque'),
('amount', '<', 0),
('company_id', '=', env.company.id),
])
# Filter post-lock-date
lock = env.company.fiscalyear_lock_date
if lock:
cheque_lines = cheque_lines.filtered(lambda l: l.move_id.date > lock)
# Filter to payroll-only amounts
payroll_amounts = set()
for aml in AML.search([
('account_id', '=', PAYROLL_ACCT),
('parent_state', '=', 'posted'),
('credit', '>', 0),
]):
payroll_amounts.add(round(aml.credit, 2))
payroll_lines = cheque_lines.filtered(
lambda l: round(abs(l.amount), 2) in payroll_amounts
)
skipped = len(cheque_lines) - len(payroll_lines)
if not payroll_lines:
return {
'status': 'nothing_to_do',
'message': f'No payroll cheques to reconcile ({skipped} non-payroll cheques skipped)',
}
try:
model._apply_reconcile_models(payroll_lines)
env.cr.commit()
except Exception as e:
return {'error': f'Reconciliation failed: {e}'}
still = payroll_lines.filtered(lambda l: not l.is_reconciled)
reconciled = len(payroll_lines) - len(still)
return {
'status': 'completed',
'reconciled': reconciled,
'still_unreconciled': len(still),
'non_payroll_skipped': skipped,
'message': f'Reconciled {reconciled} payroll cheques. {skipped} non-payroll cheques skipped.',
}
def _extract_partner_from_ref(env, payment_ref):
"""Extract a partner from a bank line payment_ref using keyword matching."""
if not payment_ref:
return None
skip_words = {
'misc', 'payment', 'online', 'banking', 'pad', 'business', 'deposit',
'cheque', 'transfer', 'e-transfer', 'sent', 'autodeposit', 'credit',
'debit', 'memo', 'free', 'interac', 'from', 'the', 'and', 'for',
'miscellaneous', 'bill', 'correction', 'adjustment', 'other',
}
# Strip common suffixes like colons and split
clean_ref = payment_ref.replace(':', ' ').replace('-', ' ')
words = [w for w in clean_ref.split() if len(w) > 2 and w.lower() not in skip_words]
# Try progressively shorter phrases
for n in range(min(len(words), 4), 0, -1):
for i in range(len(words) - n + 1):
phrase = ' '.join(words[i:i+n])
partners = env['res.partner'].search([
('name', 'ilike', phrase),
('company_id', 'in', [env.company.id, False]),
], limit=3)
if partners:
return partners[0]
# Fallback: try each word individually with supplier/customer rank
for word in words:
if len(word) < 4:
continue
partners = env['res.partner'].search([
('name', 'ilike', word),
('company_id', 'in', [env.company.id, False]),
'|', ('customer_rank', '>', 0), ('supplier_rank', '>', 0),
], limit=3)
if partners:
return partners[0]
return None
def _find_best_subset(candidates, target, max_items=8):
"""Find the subset of candidates whose amounts sum closest to target.
Returns (aml_ids, total) for the best combination."""
items = candidates[:max_items]
if not items:
return [], 0.0
best_ids = []
best_total = 0.0
best_diff = abs(target)
n = len(items)
# Brute force all subsets (2^n, max 256)
for mask in range(1, 1 << n):
subset_ids = []
subset_total = 0.0
for j in range(n):
if mask & (1 << j):
subset_ids.append(items[j]['aml_id'])
subset_total += items[j]['amount_residual']
diff = abs(subset_total - target)
if diff < best_diff:
best_diff = diff
best_ids = subset_ids
best_total = subset_total
if diff < 0.01:
break # Exact match found
return best_ids, round(best_total, 2)
def suggest_bank_line_matches(env, params):
"""Find candidate journal items (invoices/bills) that could match a bank statement line.
Scores and ranks matches, finds best subset-sum combination.
Returns data for a reconciliation-mode fusion-table."""
line_id = int(params['statement_line_id'])
line = env['account.bank.statement.line'].browse(line_id)
if not line.exists():
return {'error': 'Bank statement line not found'}
if line.is_reconciled:
return {'error': 'Bank statement line is already reconciled'}
AML = env['account.move.line'].sudo()
bank_amount = abs(line.amount)
line_date = line.move_id.date
is_incoming = line.amount > 0 # positive = customer payment, negative = vendor payment
from datetime import timedelta as td
# Determine partner
partner = line.partner_id
if not partner:
partner = _extract_partner_from_ref(env, line.payment_ref)
# Base domain common to all searches
base_domain = [
('parent_state', '=', 'posted'),
('reconciled', '=', False),
('company_id', '=', env.company.id),
('display_type', 'not in', ('line_section', 'line_subsection', 'line_note')),
('statement_line_id', '=', False),
]
# --- PRIORITY 1: Outstanding payments/receipts on bank journal accounts ---
# These are registered payments waiting to be matched to bank lines.
# For incoming bank lines → look for outstanding receipts (credit on outstanding account)
# For outgoing bank lines → look for outstanding payments (debit on outstanding account)
outstanding_acct_ids = env['account.account'].search([
('name', 'ilike', 'outstanding'),
('company_ids', 'in', env.company.id),
]).ids
outstanding_amls = AML
if outstanding_acct_ids:
os_domain = base_domain + [('account_id', 'in', outstanding_acct_ids)]
if is_incoming:
os_domain.append(('amount_residual', '>', 0)) # Debit residual on outstanding receipts
else:
os_domain.append(('amount_residual', '<', 0)) # Credit residual on outstanding payments
if partner:
outstanding_amls = AML.search(os_domain + [('partner_id', '=', partner.id)], limit=30)
if not outstanding_amls:
outstanding_amls = AML.search(os_domain, limit=30)
else:
outstanding_amls = AML.search(os_domain, limit=30)
# --- PRIORITY 2: Open invoices/bills (receivable/payable accounts) ---
inv_domain = list(base_domain)
if is_incoming:
inv_domain.append(('account_id.account_type', '=', 'asset_receivable'))
inv_domain.append(('amount_residual', '>', 0))
else:
inv_domain.append(('account_id.account_type', '=', 'liability_payable'))
inv_domain.append(('amount_residual', '<', 0))
inv_domain.append(('date', '>=', str(line_date - td(days=90))))
inv_domain.append(('date', '<=', str(line_date + td(days=30))))
invoice_amls = AML
if partner:
invoice_amls = AML.search(inv_domain + [('partner_id', '=', partner.id)], limit=30)
if not invoice_amls:
invoice_amls = AML.search(inv_domain, limit=30)
else:
invoice_amls = AML.search(inv_domain, limit=30)
# Merge: outstanding payments first (priority), then invoices/bills
combined = outstanding_amls | invoice_amls
# Score and format candidates
outstanding_ids = set(outstanding_amls.ids) if outstanding_amls else set()
candidates = []
seen_ids = set()
for aml in combined:
if aml.id in seen_ids:
continue
seen_ids.add(aml.id)
residual = abs(aml.amount_residual)
score = 0
reasons = []
is_payment = aml.id in outstanding_ids
# Source type: payment entries get a boost (preferred match)
if is_payment:
score += 15
reasons.append('payment')
# Amount scoring
if abs(residual - bank_amount) < 0.01:
score += 40
reasons.append('exact amount')
elif residual <= bank_amount * 1.05:
score += 20
reasons.append('close amount')
# Partner scoring
if partner and aml.partner_id.id == partner.id:
score += 25
reasons.append('partner')
elif partner and aml.partner_id and partner.name and aml.partner_id.name:
p1_words = set(partner.name.upper().split())
p2_words = set(aml.partner_id.name.upper().split())
if p1_words & p2_words:
score += 10
reasons.append('partial partner')
# Date proximity scoring
days_apart = abs((aml.date - line_date).days)
if days_apart <= 3:
score += 15
reasons.append(f'{days_apart}d')
elif days_apart <= 7:
score += 10
elif days_apart <= 14:
score += 5
# Reference matching
if line.payment_ref and aml.move_id.ref:
if any(w.upper() in (aml.move_id.ref or '').upper()
for w in line.payment_ref.split() if len(w) > 3):
score += 10
reasons.append('ref match')
# Determine entry type label
entry_type = 'payment' if is_payment else 'invoice'
if aml.move_id.move_type == 'in_invoice':
entry_type = 'bill'
elif aml.move_id.move_type == 'out_invoice':
entry_type = 'invoice'
elif aml.move_id.move_type in ('in_refund', 'out_refund'):
entry_type = 'credit note'
elif aml.payment_id:
entry_type = 'payment'
candidates.append({
'aml_id': aml.id,
'move_id': aml.move_id.id,
'name': aml.move_id.name or '',
'ref': aml.move_id.ref or '',
'partner': aml.partner_id.name if aml.partner_id else '',
'partner_id': aml.partner_id.id if aml.partner_id else None,
'date': str(aml.date),
'amount_total': abs(aml.balance),
'amount_residual': residual,
'account': aml.account_id.code if hasattr(aml.account_id, 'code') else '',
'type': entry_type,
'score': score,
'reasons': ', '.join(reasons) if reasons else '',
})
# Sort by score descending
candidates.sort(key=lambda c: -c['score'])
# Find best subset-sum combination
best_combo_ids, best_combo_total = _find_best_subset(candidates, bank_amount)
# Mark which candidates are in the best combination
for c in candidates:
c['in_best_combo'] = c['aml_id'] in best_combo_ids
return {
'bank_line': {
'id': line.id,
'date': str(line_date),
'ref': line.payment_ref or '',
'amount': line.amount,
'abs_amount': bank_amount,
'journal': line.journal_id.name,
'partner': partner.name if partner else '',
'partner_id': partner.id if partner else None,
'direction': 'incoming' if is_incoming else 'outgoing',
},
'candidates': candidates[:20],
'best_combination': best_combo_ids,
'best_combination_total': best_combo_total,
'is_exact_match': abs(best_combo_total - bank_amount) < 0.01,
'count': len(candidates),
}
def search_matching_entries(env, params):
"""Search open journal items by query (invoice/bill number, amount, or partner name).
Used by the reconciliation table search bar via direct RPC."""
query = (params.get('query') or '').strip()
line_id = params.get('statement_line_id')
if not query:
return {'candidates': []}
AML = env['account.move.line'].sudo()
# Search across receivable, payable, AND outstanding accounts
outstanding_acct_ids = env['account.account'].search([
('name', 'ilike', 'outstanding'),
('company_ids', 'in', env.company.id),
]).ids
domain = [
('parent_state', '=', 'posted'),
('reconciled', '=', False),
('company_id', '=', env.company.id),
('display_type', 'not in', ('line_section', 'line_subsection', 'line_note')),
'|',
('account_id.account_type', 'in', ('asset_receivable', 'liability_payable')),
('account_id', 'in', outstanding_acct_ids),
]
# Try as amount first
try:
amount = float(query.replace('$', '').replace(',', ''))
amount_domain = domain + [
'|',
'&', ('amount_residual', '>=', amount - 0.50), ('amount_residual', '<=', amount + 0.50),
'&', ('amount_residual', '>=', -amount - 0.50), ('amount_residual', '<=', -amount + 0.50),
]
amls = AML.search(amount_domain, limit=15)
if amls:
return {'candidates': _format_aml_candidates(amls)}
except ValueError:
pass
# Search by move name (invoice/bill number)
name_amls = AML.search(domain + [('move_id.name', 'ilike', query)], limit=15)
if name_amls:
return {'candidates': _format_aml_candidates(name_amls)}
# Search by move ref
ref_amls = AML.search(domain + [('move_id.ref', 'ilike', query)], limit=15)
if ref_amls:
return {'candidates': _format_aml_candidates(ref_amls)}
# Search by partner name
partner_amls = AML.search(domain + [('partner_id.name', 'ilike', query)], limit=15)
return {'candidates': _format_aml_candidates(partner_amls)}
def _format_aml_candidates(amls):
"""Format AMLs as candidate dicts for the reconciliation table."""
return [{
'aml_id': aml.id,
'move_id': aml.move_id.id,
'name': aml.move_id.name or '',
'ref': aml.move_id.ref or '',
'partner': aml.partner_id.name if aml.partner_id else '',
'partner_id': aml.partner_id.id if aml.partner_id else None,
'date': str(aml.date),
'amount_total': abs(aml.balance),
'amount_residual': abs(aml.amount_residual),
'account': aml.account_id.code if hasattr(aml.account_id, 'code') else '',
'score': 0,
'reasons': 'manual search',
'in_best_combo': False,
} for aml in amls]
TOOLS = {
'get_unreconciled_bank_lines': get_unreconciled_bank_lines,
'get_unreconciled_receipts': get_unreconciled_receipts,
@@ -496,4 +954,8 @@ TOOLS = {
'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,
}

View File

@@ -19,10 +19,10 @@ def calculate_hst_balance(env, params):
# (shared chart of accounts). Use try/except to handle both cases.
try:
collected_accounts = env['account.account'].search([
('code', '=like', '2005%'), ('company_id', '=', env.company.id),
('code', '=like', '2005%'), ('company_ids', 'in', env.company.id),
])
itc_accounts = env['account.account'].search([
('code', '=like', '2006%'), ('company_id', '=', env.company.id),
('code', '=like', '2006%'), ('company_ids', 'in', env.company.id),
])
except Exception:
collected_accounts = env['account.account'].search([
@@ -216,7 +216,7 @@ def create_expense_entry(env, params):
# Fallback to AP account
credit_account = env['account.account'].search([
('account_type', '=', 'liability_payable'),
('company_id', '=', env.company.id),
('company_ids', 'in', env.company.id),
], limit=1)
if not credit_account:

View File

@@ -7,7 +7,7 @@ _logger = logging.getLogger(__name__)
def get_stock_valuation(env, params):
accounts = env['account.account'].search([
('code', '=like', '1069%'),
('company_id', '=', env.company.id),
('company_ids', 'in', env.company.id),
])
result = []
for acct in accounts:
@@ -22,7 +22,7 @@ def get_stock_valuation(env, params):
def get_price_differences(env, params):
accounts = env['account.account'].search([
('code', '=like', '5010%'),
('company_id', '=', env.company.id),
('company_ids', 'in', env.company.id),
])
domain = [
('account_id', 'in', accounts.ids),

View File

@@ -108,7 +108,7 @@ def find_wrong_account_entries(env, params):
tax_accounts = env['account.account'].search([
('account_type', 'in', ('liability_current', 'asset_current')),
('code', '=like', '2005%'),
('company_id', '=', env.company.id),
('company_ids', 'in', env.company.id),
])
if tax_accounts:
revenue_on_tax = env['account.move.line'].search(
@@ -171,7 +171,7 @@ def find_draft_entries(env, params):
def find_unreconciled_suspense(env, params):
suspense_accounts = env['account.account'].search([
('code', '=like', '999%'),
('company_id', '=', env.company.id),
('company_ids', 'in', env.company.id),
])
issues = []
for acct in suspense_accounts:

View File

@@ -35,7 +35,7 @@ def get_close_checklist(env, params):
def get_unreconciled_counts(env, params):
accounts = env['account.account'].search([
('reconcile', '=', True),
('company_id', '=', env.company.id),
('company_ids', 'in', env.company.id),
])
result = []
for acct in accounts:
@@ -77,7 +77,7 @@ def get_accrual_status(env, params):
for code in accrual_codes:
accounts = env['account.account'].search([
('code', '=like', f'{code}%'),
('company_id', '=', env.company.id),
('company_ids', 'in', env.company.id),
])
for acct in accounts:
balance = sum(env['account.move.line'].search([

View File

@@ -66,7 +66,7 @@ def verify_source_deductions(env, params):
def get_cra_remittance_status(env, params):
cra_accounts = env['account.account'].search([
('name', 'ilike', 'CRA'),
('company_id', '=', env.company.id),
('company_ids', 'in', env.company.id),
])
result = []
for acct in cra_accounts:
@@ -130,21 +130,72 @@ def parse_payroll_summary(env, params):
}
def _resolve_account_id(env, val):
"""Resolve an account code or ID to a valid account ID.
Accepts: integer ID, string ID, or account code string like '2201'."""
if not val:
return False
val_str = str(val).strip()
# Try as a direct ID first
try:
acct = env['account.account'].browse(int(val_str))
if acct.exists():
return acct.id
except (ValueError, TypeError):
pass
# Try as an account code
acct = env['account.account'].search([
('code', '=', val_str),
('company_ids', 'in', env.company.id),
], limit=1)
if acct:
return acct.id
return False
def create_payroll_journal_entry(env, params):
journal_id = int(params['journal_id'])
date = params['date']
ref = params.get('ref', 'Payroll Entry')
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']),
# Duplicate check: same journal + date + ref + similar amount
total_debit = sum(float(l.get('debit', 0)) for l in lines_data)
existing = env['account.move'].search([
('journal_id', '=', journal_id),
('date', '=', date),
('ref', 'ilike', ref[:30]),
('state', 'in', ('draft', 'posted')),
], limit=1)
if existing:
return {
'status': 'duplicate',
'error': f'Entry already exists: {existing.name} (ref: {existing.ref}) on {existing.date} '
f'for ${existing.amount_total:,.2f}. Skipping to avoid duplicate.',
'existing_move_id': existing.id,
'existing_name': existing.name,
}
# Resolve account codes to IDs
resolved_lines = []
for line in lines_data:
account_id = _resolve_account_id(env, line['account_id'])
if not account_id:
return {'error': f"Account not found: {line['account_id']}. "
f"Provide a valid account code (e.g. '2201') or database ID."}
resolved_lines.append((0, 0, {
'account_id': 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_vals = {
'journal_id': journal_id,
'date': date,
'ref': ref,
'line_ids': resolved_lines,
}
move = env['account.move'].create(move_vals)
return {'status': 'created', 'move_id': move.id, 'name': move.name}

View File

@@ -106,6 +106,171 @@ def export_report(env, params):
return {'error': f'Export failed: {str(e)}'}
def get_invoicing_summary(env, params):
"""Get invoicing summary — total invoiced by month, by partner, or for a date range.
Supports: monthly breakdown for a year, current month totals, or filtered by partner."""
from datetime import date, timedelta
import calendar
year = int(params.get('year', date.today().year))
partner_name = params.get('partner_name')
date_from = params.get('date_from')
date_to = params.get('date_to')
domain = [
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('company_id', '=', env.company.id),
]
if partner_name:
partner = env['res.partner'].search([('name', 'ilike', partner_name)], limit=1)
if partner:
domain.append(('partner_id', '=', partner.id))
else:
return {'error': f'Partner not found: {partner_name}'}
if date_from and date_to:
domain += [('date', '>=', date_from), ('date', '<=', date_to)]
invoices = env['account.move'].search(domain, order='date desc')
total = sum(inv.amount_total for inv in invoices)
return {
'period': f'{date_from} to {date_to}',
'count': len(invoices),
'total': total,
'invoices': [{
'id': inv.id, 'name': inv.name, 'partner': inv.partner_id.name,
'date': str(inv.date), 'amount': inv.amount_total,
'payment_state': inv.payment_state,
} for inv in invoices[:30]],
}
# Monthly breakdown for the year
months = []
grand_total = 0
for month in range(1, 13):
m_start = f'{year}-{month:02d}-01'
last_day = calendar.monthrange(year, month)[1]
m_end = f'{year}-{month:02d}-{last_day}'
m_domain = domain + [('date', '>=', m_start), ('date', '<=', m_end)]
invoices = env['account.move'].search(m_domain)
total = sum(inv.amount_total for inv in invoices)
grand_total += total
months.append({
'month': f'{year}-{month:02d}',
'month_name': calendar.month_name[month],
'count': len(invoices),
'total': round(total, 2),
})
return {
'year': year,
'grand_total': round(grand_total, 2),
'months': months,
'partner': partner_name or 'All',
}
def get_billing_summary(env, params):
"""Get billing (vendor bills) summary — total billed by month or date range."""
from datetime import date
import calendar
year = int(params.get('year', date.today().year))
partner_name = params.get('partner_name')
date_from = params.get('date_from')
date_to = params.get('date_to')
domain = [
('move_type', '=', 'in_invoice'),
('state', '=', 'posted'),
('company_id', '=', env.company.id),
]
if partner_name:
partner = env['res.partner'].search([('name', 'ilike', partner_name)], limit=1)
if partner:
domain.append(('partner_id', '=', partner.id))
else:
return {'error': f'Partner not found: {partner_name}'}
if date_from and date_to:
domain += [('date', '>=', date_from), ('date', '<=', date_to)]
bills = env['account.move'].search(domain, order='date desc')
total = sum(b.amount_total for b in bills)
return {
'period': f'{date_from} to {date_to}',
'count': len(bills),
'total': total,
'bills': [{
'id': b.id, 'name': b.name, 'partner': b.partner_id.name,
'date': str(b.date), 'amount': b.amount_total,
'payment_state': b.payment_state,
} for b in bills[:30]],
}
# Monthly breakdown
months = []
grand_total = 0
for month in range(1, 13):
m_start = f'{year}-{month:02d}-01'
last_day = calendar.monthrange(year, month)[1]
m_end = f'{year}-{month:02d}-{last_day}'
m_domain = domain + [('date', '>=', m_start), ('date', '<=', m_end)]
bills = env['account.move'].search(m_domain)
total = sum(b.amount_total for b in bills)
grand_total += total
months.append({
'month': f'{year}-{month:02d}',
'month_name': calendar.month_name[month],
'count': len(bills),
'total': round(total, 2),
})
return {
'year': year,
'grand_total': round(grand_total, 2),
'months': months,
'partner': partner_name or 'All',
}
def get_collections_summary(env, params):
"""Get payment collections summary — how much was collected (received) in a period."""
date_from = params.get('date_from')
date_to = params.get('date_to')
if not date_from or not date_to:
from datetime import date
today = date.today()
date_from = date_from or f'{today.year}-{today.month:02d}-01'
date_to = date_to or str(today)
payments = env['account.payment'].search([
('payment_type', '=', 'inbound'),
('state', '=', 'posted'),
('date', '>=', date_from),
('date', '<=', date_to),
('company_id', '=', env.company.id),
], order='date desc')
total = sum(p.amount for p in payments)
by_partner = {}
for p in payments:
pname = p.partner_id.name if p.partner_id else 'Unknown'
by_partner.setdefault(pname, {'count': 0, 'total': 0})
by_partner[pname]['count'] += 1
by_partner[pname]['total'] += p.amount
top_partners = sorted(by_partner.items(), key=lambda x: -x[1]['total'])[:15]
return {
'period': f'{date_from} to {date_to}',
'total_collected': round(total, 2),
'payment_count': len(payments),
'by_partner': [{'partner': k, 'count': v['count'], 'total': round(v['total'], 2)} for k, v in top_partners],
}
TOOLS = {
'get_profit_loss': get_profit_loss,
'get_balance_sheet': get_balance_sheet,
@@ -114,4 +279,7 @@ TOOLS = {
'compare_periods': compare_periods,
'answer_financial_question': answer_financial_question,
'export_report': export_report,
'get_invoicing_summary': get_invoicing_summary,
'get_billing_summary': get_billing_summary,
'get_collections_summary': get_collections_summary,
}

View File

@@ -6,8 +6,25 @@ 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);
get toolLabel() {
const name = this.props.approval.tool_name || "";
// Short labels for common tools
const labels = {
create_payroll_journal_entry: "Payroll JE",
create_vendor_bill: "Vendor Bill",
register_bill_payment: "Bill Payment",
create_expense_entry: "Expense",
register_hst_payment: "HST Payment",
apply_payment: "Payment",
send_followup: "Follow-up",
flag_entry: "Flag",
};
return labels[name] || name.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase());
}
formatAmount(val) {
if (!val) return "";
return Number(val).toLocaleString("en-CA", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
approve() {

View File

@@ -1,29 +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"/>
<!-- Single row in the approval table — rendered inside <tbody> by chat_panel -->
<tr class="fusion_approval_row">
<td class="px-2 py-1 small text-nowrap" t-esc="toolLabel"/>
<td class="px-2 py-1 small" style="white-space: pre-line; max-width: 320px;">
<t t-esc="props.approval.summary || ''"/>
</td>
<td class="px-2 py-1 small text-end text-nowrap fw-semibold">
<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 t-esc="formatAmount(props.approval.amount)"/>
</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>
</td>
<td class="px-1 py-1 text-end text-nowrap">
<button class="btn btn-success btn-xs px-2 py-0 me-1" t-on-click="approve"
style="font-size: 0.75rem; line-height: 1.5;"
title="Approve">
<i class="fa fa-check"/>
</button>
<button class="btn btn-outline-danger btn-xs px-2 py-0" t-on-click="reject"
style="font-size: 0.75rem; line-height: 1.5;"
title="Reject">
<i class="fa fa-times"/>
</button>
</td>
</tr>
</t>
</templates>

View File

@@ -192,6 +192,7 @@ export class FusionChatPanel extends Component {
setup() {
this.inputRef = useRef("chatInput");
this.messagesRef = useRef("messages");
this.fileRef = useRef("fileInput");
// Track parsed table data per message index for interactive tables
this._parsedTables = {};
this.state = useState({
@@ -207,7 +208,13 @@ export class FusionChatPanel extends Component {
// Session history picker
showSessionPicker: false,
sessionList: [],
// Image attachment
pendingImage: null, // { name, dataUrl, base64, mediaType }
// Live execution status (from polling)
liveThinking: '',
liveToolCalls: [],
});
this._statusPollInterval = null;
onWillStart(async () => {
await this.loadLatestSession();
@@ -229,33 +236,51 @@ export class FusionChatPanel extends Component {
for (const div of richDivs) {
const idx = parseInt(div.dataset.idx);
const msg = this.state.messages[idx];
if (msg && msg.role === "assistant" && msg.content) {
// Check for fusion-table blocks
if (msg && msg.role === "assistant") {
let html = "";
// Priority 1: Server-side reconciliation table (direct from tool result)
if (msg.reconciliationTable && !div.dataset.reconMounted) {
const reconKey = `recon_${idx}`;
// Strip any fusion-table blocks from AI text to avoid double rendering
const cleanText = (msg.content || "").replace(/```fusion-table[\s\S]*?```/g, '').trim();
html = mdToHtml(cleanText);
html += `<div class="fusion_table_mount" data-table-key="${reconKey}"></div>`;
this._parsedTables[reconKey] = {
mode: "reconciliation",
source_tool: "suggest_bank_line_matches",
bank_line: msg.reconciliationTable.bank_line,
candidates: msg.reconciliationTable.candidates,
best_combination: msg.reconciliationTable.best_combination,
};
div.innerHTML = html;
div.dataset.reconMounted = "true";
this._mountInteractiveTables(div);
}
// Priority 2: AI-generated fusion-table blocks
else if (msg.content && !div.dataset.reconMounted) {
const parsed = parseFusionTableBlock(msg.content);
if (parsed) {
// Build HTML with placeholders for interactive tables
let html = "";
for (const part of parsed.parts) {
if (part.type === "md") {
html += mdToHtml(part.content);
} else if (part.type === "table") {
const tableKey = `${idx}_${part.idx}`;
html += `<div class="fusion_table_mount" data-table-key="${tableKey}"></div>`;
// Store table data for OWL mounting
this._parsedTables[tableKey] = parsed.tables[part.idx];
}
}
if (div.innerHTML !== html) {
div.innerHTML = html;
}
// Mount OWL interactive table components into placeholders
this._mountInteractiveTables(div);
} else {
const html = mdToHtml(msg.content);
if (div.innerHTML !== html) {
div.innerHTML = html;
const mdHtml = mdToHtml(msg.content);
if (div.innerHTML !== mdHtml) {
div.innerHTML = mdHtml;
}
}
}
}
}
}
@@ -269,8 +294,13 @@ export class FusionChatPanel extends Component {
if (!tableData) continue;
el.dataset.mounted = "true";
el.innerHTML = this._buildInteractiveTableHtml(tableData, key);
this._wireTableEvents(el, tableData, key);
if (tableData.mode === "reconciliation") {
el.innerHTML = this._buildReconciliationTableHtml(tableData, key);
this._wireReconciliationEvents(el, tableData, key);
} else {
el.innerHTML = this._buildInteractiveTableHtml(tableData, key);
this._wireTableEvents(el, tableData, key);
}
}
}
@@ -464,6 +494,383 @@ export class FusionChatPanel extends Component {
return result;
}
// ================================================================
// Image Upload
// ================================================================
triggerFileUpload() {
const input = this.fileRef.el;
if (input) input.click();
}
onFileSelected(ev) {
const file = ev.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
console.warn("Only image files are supported");
return;
}
if (file.size > 10 * 1024 * 1024) {
console.warn("Image too large (max 10MB)");
return;
}
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result;
// Extract base64 and media type
const match = dataUrl.match(/^data:(image\/[^;]+);base64,(.+)$/);
if (match) {
this.state.pendingImage = {
name: file.name,
dataUrl,
base64: match[2],
mediaType: match[1],
};
}
};
reader.readAsDataURL(file);
// Reset input so same file can be selected again
ev.target.value = '';
}
clearImage() {
this.state.pendingImage = null;
}
_startStatusPolling() {
this._stopStatusPolling();
this.state.liveThinking = '';
this.state.liveToolCalls = [];
this._statusPollInterval = setInterval(async () => {
if (!this.state.sending || !this.sessionId) {
this._stopStatusPolling();
return;
}
try {
const status = await rpc('/fusion_accounting/chat/status', {
session_id: this.sessionId,
});
if (status.thinking) {
this.state.liveThinking = status.thinking;
}
if (status.tool_calls && status.tool_calls.length) {
this.state.liveToolCalls = status.tool_calls;
}
} catch (e) {
// Polling failure is not critical — just skip
}
}, 600);
}
_stopStatusPolling() {
if (this._statusPollInterval) {
clearInterval(this._statusPollInterval);
this._statusPollInterval = null;
}
this.state.liveThinking = '';
this.state.liveToolCalls = [];
}
// ================================================================
// Reconciliation Table Mode
// ================================================================
_buildReconciliationTableHtml(tableData, key) {
const bankLine = tableData.bank_line || {};
// Accept candidates from either 'candidates' (tool output) or 'rows' (AI formatted)
let rawCandidates = tableData.candidates || tableData.rows || [];
// Normalize: if AI sent rows with 'id' instead of 'aml_id', or 'cells' arrays, fix them
const candidates = rawCandidates.map(c => {
if (c.aml_id) return c; // Already in correct format
// Try to extract from cells array if AI used interactive table format
const norm = { ...c };
if (!norm.aml_id && norm.id) norm.aml_id = norm.id;
if (!norm.amount_residual && typeof norm.amount_residual !== 'number') norm.amount_residual = norm.amount_total || norm.amount || 0;
return norm;
});
// Store normalized rows back for collect function
tableData.rows = candidates;
const bestCombo = new Set((tableData.best_combination || []).map(String));
const bankAmount = Math.abs(bankLine.abs_amount || bankLine.amount || 0);
const title = tableData.title || `Match: ${bankLine.ref || ''} $${bankAmount.toFixed(2)}`;
let h = `<div class="fusion_recon_table my-2" data-key="${key}" data-bank-line-id="${bankLine.id || ''}" data-bank-amount="${bankAmount}">`;
// Header
h += `<div class="d-flex align-items-center justify-content-between mb-2">`;
h += `<div><i class="fa fa-exchange me-2 text-primary"></i><strong>${this._esc(title)}</strong>`;
h += `<span class="badge bg-secondary-subtle text-secondary ms-2">${bankLine.direction || ''}</span></div>`;
h += `<span class="badge bg-primary">${bankLine.journal || ''}</span></div>`;
// Table
h += '<div class="table-responsive"><table class="table table-sm table-hover align-middle mb-0"><thead><tr>';
h += '<th class="fit-content px-2"><input type="checkbox" class="form-check-input" data-action="recon-select-all"/></th>';
h += '<th class="px-2 py-1">Entry</th>';
h += '<th class="px-2 py-1">Type</th>';
h += '<th class="px-2 py-1">Partner</th>';
h += '<th class="px-2 py-1">Date</th>';
h += '<th class="px-2 py-1 text-end">Residual</th>';
h += '<th class="px-2 py-1 text-end" style="min-width:110px;">Apply</th>';
h += '<th class="px-2 py-1">Score</th>';
h += '</tr></thead><tbody>';
for (let i = 0; i < candidates.length; i++) {
const c = candidates[i];
const inCombo = bestCombo.has(String(c.aml_id));
const checked = inCombo ? 'checked' : '';
const residual = c.amount_residual || c.amount_total || 0;
const score = c.score || 0;
const scoreClass = score >= 60 ? 'text-success' : score >= 30 ? 'text-warning' : 'text-muted';
h += `<tr data-row-idx="${i}" data-aml-id="${c.aml_id}">`;
h += `<td class="fit-content px-2"><input type="checkbox" class="form-check-input recon-row-check" data-idx="${i}" ${checked}/></td>`;
h += `<td class="px-2 py-1 small"><strong>${this._esc(c.name)}</strong>`;
if (c.ref) h += `<br/><span class="text-muted">${this._esc(c.ref)}</span>`;
h += `</td>`;
const typeClass = c.type === 'payment' ? 'bg-success-subtle text-success' : c.type === 'bill' ? 'bg-info-subtle text-info' : 'bg-primary-subtle text-primary';
h += `<td class="px-2 py-1 small"><span class="badge ${typeClass}">${this._esc(c.type || 'entry')}</span></td>`;
h += `<td class="px-2 py-1 small">${this._esc(c.partner)}</td>`;
h += `<td class="px-2 py-1 small text-nowrap">${this._esc(c.date)}</td>`;
h += `<td class="px-2 py-1 small text-end text-nowrap">$${residual.toFixed(2)}</td>`;
h += `<td class="px-2 py-1 text-end">`;
h += `<input type="number" class="form-control form-control-sm fusion_apply_amount text-end" `;
h += `data-idx="${i}" data-max="${residual}" step="0.01" min="0" max="${residual}" `;
h += `value="${residual.toFixed(2)}" style="width:110px; display:inline-block;"/>`;
h += `</td>`;
h += `<td class="px-2 py-1 small"><span class="${scoreClass}">${score}</span>`;
if (c.reasons) h += ` <span class="text-muted">— ${this._esc(c.reasons)}</span>`;
h += `</td></tr>`;
}
h += '</tbody></table></div>';
// Search bar
h += '<div class="fusion_recon_search p-2 border-top position-relative">';
h += '<div class="input-group input-group-sm">';
h += '<span class="input-group-text"><i class="fa fa-search"></i></span>';
h += `<input type="text" class="form-control fusion_match_search" placeholder="Search by invoice #, amount, or partner..." data-key="${key}" data-line-id="${bankLine.id || ''}"/>`;
h += '</div>';
h += `<div class="fusion_search_results d-none" data-key="${key}"></div>`;
h += '</div>';
// Running total footer
h += '<div class="fusion_match_total d-flex align-items-center justify-content-between p-2 border-top">';
h += '<div class="small">';
h += '<span class="fusion_selected_total fw-semibold">$0.00</span>';
h += ` <span class="text-muted">/ Bank: $${bankAmount.toFixed(2)}</span>`;
h += ' <span class="fusion_remaining_badge badge ms-1"></span>';
h += '</div>';
h += `<button class="btn btn-primary btn-sm fusion_apply_match_btn" data-action="apply_match" disabled>`;
h += '<i class="fa fa-check me-1"></i>Apply Match</button>';
h += '</div>';
h += '</div>';
return h;
}
_wireReconciliationEvents(container, tableData, key) {
const bankAmount = parseFloat(container.querySelector('.fusion_recon_table')?.dataset.bankAmount || '0');
const recalcTotal = () => {
let total = 0;
const checks = container.querySelectorAll('.recon-row-check:checked');
for (const cb of checks) {
const idx = parseInt(cb.dataset.idx);
const amtInput = container.querySelector(`.fusion_apply_amount[data-idx="${idx}"]`);
if (amtInput) total += parseFloat(amtInput.value) || 0;
}
total = Math.round(total * 100) / 100;
const totalEl = container.querySelector('.fusion_selected_total');
const badgeEl = container.querySelector('.fusion_remaining_badge');
const applyBtn = container.querySelector('.fusion_apply_match_btn');
if (totalEl) totalEl.textContent = `$${total.toFixed(2)}`;
const remaining = Math.round((bankAmount - total) * 100) / 100;
if (badgeEl) {
if (Math.abs(remaining) < 0.01) {
badgeEl.textContent = 'Balanced ✓';
badgeEl.className = 'fusion_remaining_badge badge ms-1 bg-success';
} else if (remaining > 0) {
badgeEl.textContent = `Remaining: $${remaining.toFixed(2)}`;
badgeEl.className = 'fusion_remaining_badge badge ms-1 bg-warning text-dark';
} else {
badgeEl.textContent = `Over: $${Math.abs(remaining).toFixed(2)}`;
badgeEl.className = 'fusion_remaining_badge badge ms-1 bg-danger';
}
}
if (applyBtn) applyBtn.disabled = total === 0;
};
// Select-all
const selectAll = container.querySelector('[data-action="recon-select-all"]');
if (selectAll) {
selectAll.addEventListener('change', () => {
for (const cb of container.querySelectorAll('.recon-row-check')) cb.checked = selectAll.checked;
recalcTotal();
});
}
// Row checkboxes
for (const cb of container.querySelectorAll('.recon-row-check')) {
cb.addEventListener('change', recalcTotal);
}
// Amount inputs
for (const inp of container.querySelectorAll('.fusion_apply_amount')) {
inp.addEventListener('input', () => {
const max = parseFloat(inp.dataset.max) || 0;
let val = parseFloat(inp.value) || 0;
if (val > max) { inp.value = max.toFixed(2); }
if (val < 0) { inp.value = '0.00'; }
recalcTotal();
});
}
// Apply Match button
const applyBtn = container.querySelector('.fusion_apply_match_btn');
if (applyBtn) {
applyBtn.addEventListener('click', () => {
const rows = this._collectReconciliationRows(container, tableData);
if (!rows.length) return;
const bankLineId = container.querySelector('.fusion_recon_table')?.dataset.bankLineId;
this.onTableAction({
action: 'apply_match',
source_tool: tableData.source_tool || 'suggest_bank_line_matches',
bank_line_id: bankLineId,
bank_amount: bankAmount,
rows,
});
});
}
// Search bar with debounce
const searchInput = container.querySelector('.fusion_match_search');
const resultsDiv = container.querySelector('.fusion_search_results');
let searchTimeout = null;
if (searchInput && resultsDiv) {
searchInput.addEventListener('input', () => {
clearTimeout(searchTimeout);
const q = searchInput.value.trim();
if (q.length < 2) { resultsDiv.classList.add('d-none'); return; }
searchTimeout = setTimeout(async () => {
try {
const data = await rpc('/fusion_accounting/search_matches', {
statement_line_id: parseInt(searchInput.dataset.lineId) || 0,
query: q,
});
this._renderSearchResults(resultsDiv, container, data.candidates || [], tableData, key);
} catch (e) {
resultsDiv.innerHTML = '<div class="p-2 text-danger small">Search failed</div>';
resultsDiv.classList.remove('d-none');
}
}, 300);
});
}
// Initial total calculation
recalcTotal();
}
_renderSearchResults(resultsDiv, tableContainer, candidates, tableData, key) {
if (!candidates.length) {
resultsDiv.innerHTML = '<div class="p-2 text-muted small">No matching entries found</div>';
resultsDiv.classList.remove('d-none');
return;
}
let h = '<div class="list-group list-group-flush">';
for (const c of candidates.slice(0, 8)) {
// Skip if already in the table
if (tableContainer.querySelector(`tr[data-aml-id="${c.aml_id}"]`)) continue;
h += `<button class="list-group-item list-group-item-action p-2 small fusion_search_result" `;
h += `data-aml='${JSON.stringify(c).replace(/'/g, "&#39;")}'>`;
h += `<div class="d-flex justify-content-between">`;
h += `<span><strong>${this._esc(c.name)}</strong> — ${this._esc(c.partner)}</span>`;
h += `<span class="fw-semibold">$${(c.amount_residual || 0).toFixed(2)}</span>`;
h += `</div>`;
h += `<small class="text-muted">${this._esc(c.date)} | ${this._esc(c.ref || '')}</small>`;
h += `</button>`;
}
h += '</div>';
resultsDiv.innerHTML = h;
resultsDiv.classList.remove('d-none');
// Wire add-on-click
for (const btn of resultsDiv.querySelectorAll('.fusion_search_result')) {
btn.addEventListener('click', () => {
const candidate = JSON.parse(btn.dataset.aml);
this._addRowToReconciliationTable(tableContainer, candidate, tableData);
resultsDiv.classList.add('d-none');
const searchInput = tableContainer.querySelector('.fusion_match_search');
if (searchInput) searchInput.value = '';
});
}
}
_addRowToReconciliationTable(container, candidate, tableData) {
const tbody = container.querySelector('tbody');
if (!tbody) return;
const rows = tableData.rows || [];
const idx = rows.length;
rows.push(candidate);
const residual = candidate.amount_residual || 0;
const tr = document.createElement('tr');
tr.dataset.rowIdx = idx;
tr.dataset.amlId = candidate.aml_id;
const cType = candidate.type || 'entry';
const cTypeClass = cType === 'payment' ? 'bg-success-subtle text-success' : cType === 'bill' ? 'bg-info-subtle text-info' : 'bg-primary-subtle text-primary';
tr.innerHTML = `
<td class="fit-content px-2"><input type="checkbox" class="form-check-input recon-row-check" data-idx="${idx}" checked/></td>
<td class="px-2 py-1 small"><strong>${this._esc(candidate.name)}</strong>${candidate.ref ? '<br/><span class="text-muted">' + this._esc(candidate.ref) + '</span>' : ''}</td>
<td class="px-2 py-1 small"><span class="badge ${cTypeClass}">${this._esc(cType)}</span></td>
<td class="px-2 py-1 small">${this._esc(candidate.partner)}</td>
<td class="px-2 py-1 small text-nowrap">${this._esc(candidate.date)}</td>
<td class="px-2 py-1 small text-end text-nowrap">$${residual.toFixed(2)}</td>
<td class="px-2 py-1 text-end">
<input type="number" class="form-control form-control-sm fusion_apply_amount text-end"
data-idx="${idx}" data-max="${residual}" step="0.01" min="0" max="${residual}"
value="${residual.toFixed(2)}" style="width:110px; display:inline-block;"/>
</td>
<td class="px-2 py-1 small text-muted">added</td>
`;
tbody.appendChild(tr);
// Wire new row's checkbox and amount input
const cb = tr.querySelector('.recon-row-check');
const amt = tr.querySelector('.fusion_apply_amount');
const recalc = () => {
// Trigger recalc by dispatching change on first checkbox
const first = container.querySelector('.recon-row-check');
if (first) first.dispatchEvent(new Event('change'));
};
if (cb) cb.addEventListener('change', recalc);
if (amt) amt.addEventListener('input', recalc);
recalc();
}
_collectReconciliationRows(container, tableData) {
const result = [];
const checks = container.querySelectorAll('.recon-row-check:checked');
const rows = tableData.rows || [];
for (const cb of checks) {
const idx = parseInt(cb.dataset.idx);
const tr = cb.closest('tr');
const amlId = tr?.dataset.amlId;
const amtInput = container.querySelector(`.fusion_apply_amount[data-idx="${idx}"]`);
const applyAmount = parseFloat(amtInput?.value) || 0;
const maxAmount = parseFloat(amtInput?.dataset.max) || 0;
const c = rows[idx] || {};
result.push({
aml_id: parseInt(amlId) || c.aml_id,
name: c.name || '',
apply_amount: applyAmount,
amount_residual: maxAmount,
is_partial: applyAmount < maxAmount - 0.01,
});
}
// Sort: full matches first, partial last (Odoo applies partial on last AML)
result.sort((a, b) => (a.is_partial ? 1 : 0) - (b.is_partial ? 1 : 0));
return result;
}
get sessionId() {
return this.state.internalSessionId || this.props.sessionId;
}
@@ -476,6 +883,9 @@ export class FusionChatPanel extends Component {
this.state.internalSessionId = data.session_id;
this.state.messages = data.messages || [];
this.state.sessionName = data.name;
if (data.pending_approvals && data.pending_approvals.length) {
this.state.pendingApprovals = data.pending_approvals;
}
}
} catch (e) {
console.error("Failed to load session:", e);
@@ -569,7 +979,8 @@ export class FusionChatPanel extends Component {
async sendMessage() {
const text = this.state.inputText.trim();
if (!text || this.state.sending) return;
const image = this.state.pendingImage;
if ((!text && !image) || this.state.sending) return;
if (!this.sessionId) {
const session = await rpc("/fusion_accounting/session/create", {});
@@ -577,30 +988,54 @@ export class FusionChatPanel extends Component {
this.state.sessionName = session.name;
}
this.state.messages.push({ role: "user", content: text });
this.state.messages.push({
role: "user",
content: text || (image ? `[Attached: ${image.name}]` : ''),
hasImage: !!image,
imageUrl: image?.dataUrl || null,
});
this.state.inputText = "";
this.state.pendingImage = null;
this.state.sending = true;
this.scrollToBottom();
this._startStatusPolling();
const chatPayload = {
session_id: this.sessionId,
message: text || "Please analyze the attached image.",
};
if (image) {
chatPayload.image = {
base64: image.base64,
media_type: image.mediaType,
name: image.name,
};
}
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 });
const result = await rpc("/fusion_accounting/chat", chatPayload);
this._stopStatusPolling();
if (result.text || (result.tool_calls_log && result.tool_calls_log.length)) {
this.state.messages.push({
role: "assistant",
content: result.text || "",
toolCalls: result.tool_calls_log || [],
// Attach server-side reconciliation data for direct rendering
reconciliationTable: result.reconciliation_table || null,
});
}
if (result.pending_approvals) {
this.state.pendingApprovals = result.pending_approvals;
}
} catch (e) {
this._stopStatusPolling();
this.state.messages.push({
role: "assistant",
content: `Error: ${e.message || "Something went wrong."}`,
});
}
this.state.sending = false;
this.scrollToBottom();
this.scrollToNewReply();
}
/**
@@ -615,25 +1050,41 @@ export class FusionChatPanel extends Component {
create_rule: "Create Rules",
dismiss: "Dismiss",
submit_notes: "Submit Notes",
apply_match: "Apply Match",
};
const label = actionLabels[action] || action;
// Build a structured message for the AI
let parts = [`[TABLE_ACTION] source=${source_tool} action=${action}`];
for (const row of rows) {
const cellSummary = (row.cells || []).join(" | ");
let line = `- Row #${row.id}: ${cellSummary}`;
if (row.recommendation) {
line += ` (AI suggested: ${row.recommendation.action} - ${row.recommendation.reason})`;
let message;
if (action === 'apply_match') {
// Reconciliation-specific format with AML IDs and custom amounts
const bankLineId = payload.bank_line_id || '';
const bankAmount = payload.bank_amount || 0;
const total = rows.reduce((s, r) => s + (r.apply_amount || 0), 0);
let parts = [`[TABLE_ACTION] source=${source_tool} action=apply_match`];
parts.push(`bank_line_id=${bankLineId} bank_amount=${bankAmount}`);
for (const row of rows) {
const tag = row.is_partial ? 'partial' : 'full';
parts.push(`- AML #${row.aml_id}: ${row.name} | apply=$${(row.apply_amount || 0).toFixed(2)} residual=$${(row.amount_residual || 0).toFixed(2)} (${tag})`);
}
if (row.userNote) {
line += ` [User note: ${row.userNote}]`;
parts.push(`Total: $${total.toFixed(2)} / Bank: $${bankAmount.toFixed(2)}`);
message = parts.join('\n');
} else {
// Standard interactive table format
let parts = [`[TABLE_ACTION] source=${source_tool} action=${action}`];
for (const row of rows) {
const cellSummary = (row.cells || []).join(" | ");
let line = `- Row #${row.id}: ${cellSummary}`;
if (row.recommendation) {
line += ` (AI suggested: ${row.recommendation.action} - ${row.recommendation.reason})`;
}
if (row.userNote) {
line += ` [User note: ${row.userNote}]`;
}
parts.push(line);
}
parts.push(line);
message = parts.join('\n');
}
const message = parts.join("\n");
// Show user what we're sending
this.state.messages.push({
role: "user",
@@ -641,26 +1092,38 @@ export class FusionChatPanel extends Component {
});
this.state.sending = true;
this.scrollToBottom();
this._startStatusPolling();
try {
const result = await rpc("/fusion_accounting/chat", {
session_id: this.sessionId,
message: message,
});
if (result.text) {
this.state.messages.push({ role: "assistant", content: result.text });
this._stopStatusPolling();
if (result.text || (result.tool_calls_log && result.tool_calls_log.length)) {
this.state.messages.push({
role: "assistant",
content: result.text || "",
toolCalls: result.tool_calls_log || [],
});
}
if (result.pending_approvals) {
this.state.pendingApprovals = result.pending_approvals;
}
} catch (e) {
this._stopStatusPolling();
this.state.messages.push({
role: "assistant",
content: `Error processing table action: ${e.message || "Something went wrong."}`,
});
}
this.state.sending = false;
this.scrollToBottom();
this.scrollToNewReply();
}
sendStarter(text) {
this.state.inputText = text;
this.sendMessage();
}
onKeyDown(ev) {
@@ -670,6 +1133,33 @@ export class FusionChatPanel extends Component {
}
}
onPaste(ev) {
const items = ev.clipboardData?.items;
if (!items) return;
for (const item of items) {
if (item.type.startsWith('image/')) {
ev.preventDefault();
const file = item.getAsFile();
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result;
const match = dataUrl.match(/^data:(image\/[^;]+);base64,(.+)$/);
if (match) {
this.state.pendingImage = {
name: `screenshot-${Date.now()}.png`,
dataUrl,
base64: match[2],
mediaType: match[1],
};
}
};
reader.readAsDataURL(file);
return;
}
}
}
scrollToBottom() {
const el = this.messagesRef.el;
if (el) {
@@ -677,6 +1167,23 @@ export class FusionChatPanel extends Component {
}
}
scrollToNewReply() {
// Scroll so the TOP of the latest AI message is visible
const el = this.messagesRef.el;
if (!el) return;
setTimeout(() => {
const aiMsgs = el.querySelectorAll(".fusion_ai_msg");
if (aiMsgs.length) {
const last = aiMsgs[aiMsgs.length - 1];
// Scroll so the message top aligns near the top of the container
const offset = last.offsetTop - el.offsetTop - 8;
el.scrollTop = offset;
} else {
el.scrollTop = el.scrollHeight;
}
}, 120);
}
async onApprove(matchHistoryId) {
await rpc("/fusion_accounting/approve", { match_history_id: matchHistoryId });
this.state.pendingApprovals = this.state.pendingApprovals.filter(a => a.id !== matchHistoryId);

View File

@@ -66,10 +66,51 @@
</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 class="text-center text-muted py-3">
<i class="fa fa-robot fa-3x mb-2 d-block"/>
<p class="mb-3">What would you like to work on?</p>
</div>
<div class="d-flex flex-wrap gap-2 justify-content-center px-3 pb-3">
<button class="btn btn-outline-secondary btn-sm fusion_starter"
t-on-click="() => this.sendStarter('Reconcile the latest ADP payment on Scotia Current')">
<i class="fa fa-exchange me-1"/>Match ADP Payment
</button>
<button class="btn btn-outline-secondary btn-sm fusion_starter"
t-on-click="() => this.sendStarter('Show me unreconciled bank lines on all journals')">
<i class="fa fa-bank me-1"/>Unreconciled Lines
</button>
<button class="btn btn-outline-secondary btn-sm fusion_starter"
t-on-click="() => this.sendStarter('How much do we owe to Pride Mobility?')">
<i class="fa fa-credit-card me-1"/>Vendor Balance
</button>
<button class="btn btn-outline-secondary btn-sm fusion_starter"
t-on-click="() => this.sendStarter('Show me invoicing by month for this year')">
<i class="fa fa-bar-chart me-1"/>Invoicing by Month
</button>
<button class="btn btn-outline-secondary btn-sm fusion_starter"
t-on-click="() => this.sendStarter('How much are we collecting this month?')">
<i class="fa fa-money me-1"/>Collections
</button>
<button class="btn btn-outline-secondary btn-sm fusion_starter"
t-on-click="() => this.sendStarter('What is our current HST balance?')">
<i class="fa fa-percent me-1"/>HST Balance
</button>
<button class="btn btn-outline-secondary btn-sm fusion_starter"
t-on-click="() => this.sendStarter('Show me overdue invoices')">
<i class="fa fa-exclamation-circle me-1"/>Overdue Invoices
</button>
<button class="btn btn-outline-secondary btn-sm fusion_starter"
t-on-click="() => this.sendStarter('Run month-end close checklist')">
<i class="fa fa-check-square-o me-1"/>Month-End Close
</button>
<button class="btn btn-outline-secondary btn-sm fusion_starter"
t-on-click="() => this.sendStarter('Show me the P&amp;L for this quarter')">
<i class="fa fa-line-chart me-1"/>Profit &amp; Loss
</button>
<button class="btn btn-outline-secondary btn-sm fusion_starter"
t-on-click="() => this.sendStarter('Find duplicate bills')">
<i class="fa fa-copy me-1"/>Duplicate Bills
</button>
</div>
</t>
<t t-foreach="state.messages" t-as="msg" t-key="msg_index">
@@ -78,7 +119,14 @@
<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
<t t-if="msg.hasImage">
<i class="fa fa-image ms-1 text-info" title="Image attached"/>
</t>
</small>
<t t-if="msg.imageUrl">
<img t-att-src="msg.imageUrl" class="rounded mb-1 d-block" style="max-height: 120px; max-width: 200px; cursor: pointer; object-fit: cover;"
t-on-click="() => window.open(msg.imageUrl, '_blank')"/>
</t>
<span style="white-space: pre-wrap;" t-esc="msg.content"/>
</div>
</t>
@@ -88,59 +136,161 @@
<small class="text-muted d-block mb-2">
<i class="fa fa-robot me-1"/>Fusion AI
</small>
<!-- Collapsible tool calls log (like Claude Code) -->
<t t-if="msg.toolCalls and msg.toolCalls.length">
<details class="fusion_tool_calls mb-2">
<summary class="small text-muted cursor-pointer d-inline-flex align-items-center gap-1 user-select-none">
<i class="fa fa-wrench" style="font-size: 0.7rem;"/>
<span><t t-esc="msg.toolCalls.length"/> tool call<t t-if="msg.toolCalls.length > 1">s</t></span>
</summary>
<div class="mt-1 ms-2 border-start ps-2" style="border-color: var(--o-border-color) !important;">
<t t-foreach="msg.toolCalls" t-as="tc" t-key="tc_index">
<div class="d-flex align-items-start gap-1 py-1 small"
style="line-height: 1.3;">
<i t-att-class="'fa fa-fw ' + (tc.status === 'error' ? 'fa-times-circle text-danger' : tc.status === 'pending_approval' ? 'fa-clock-o text-warning' : 'fa-check-circle text-success')"
style="font-size: 0.7rem; margin-top: 3px;"/>
<span>
<code class="small" style="font-size: 0.78rem;" t-esc="tc.name"/>
<t t-if="tc.summary">
<span class="text-muted ms-1"><t t-esc="tc.summary"/></span>
</t>
<t t-if="tc.duration_ms">
<span class="text-muted ms-1" style="font-size: 0.7rem;">(<t t-esc="tc.duration_ms"/>ms)</span>
</t>
</span>
</div>
</t>
</div>
</details>
</t>
<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">
<div class="fusion_ai_msg rounded p-3 me-4 mb-2 fusion_live_status">
<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...
<!-- Live thinking text -->
<t t-if="state.liveThinking">
<div class="fusion_thinking_block mb-2 p-2 rounded small fst-italic"
style="background: rgba(var(--bs-body-color-rgb), 0.03); border-left: 3px solid var(--bs-purple, #6f42c1); max-height: 120px; overflow-y: auto;">
<i class="fa fa-brain me-1 text-purple" style="color: var(--bs-purple, #6f42c1);"/>
<span t-esc="state.liveThinking"/>
</div>
</t>
<!-- Live tool calls -->
<t t-if="state.liveToolCalls.length > 0">
<div class="mb-1">
<t t-foreach="state.liveToolCalls" t-as="tc" t-key="tc_index">
<div class="d-flex align-items-center gap-1 small py-1" style="line-height: 1.3;">
<t t-if="tc.status === 'running'">
<i class="fa fa-spinner fa-spin text-primary" style="font-size: 0.7rem;"/>
</t>
<t t-elif="tc.status === 'ok'">
<i class="fa fa-check-circle text-success" style="font-size: 0.7rem;"/>
</t>
<t t-elif="tc.status === 'error'">
<i class="fa fa-times-circle text-danger" style="font-size: 0.7rem;"/>
</t>
<t t-else="">
<i class="fa fa-clock-o text-warning" style="font-size: 0.7rem;"/>
</t>
<code class="small" style="font-size: 0.75rem;" t-esc="tc.name"/>
<t t-if="tc.summary">
<span class="text-muted"><t t-esc="tc.summary"/></span>
</t>
<t t-if="tc.duration_ms">
<span class="text-muted" style="font-size: 0.68rem;">(<t t-esc="tc.duration_ms"/>ms)</span>
</t>
</div>
</t>
</div>
</t>
<!-- Default thinking indicator if no live data yet -->
<t t-if="!state.liveThinking and state.liveToolCalls.length === 0">
<i class="fa fa-spinner fa-spin me-1"/> Thinking...
</t>
</div>
</t>
</div>
<!-- Pending Approvals -->
<!-- Pending Approvals — compact table -->
<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="border-top">
<div class="d-flex justify-content-between align-items-center px-2 py-1 bg-warning-subtle">
<small class="fw-semibold">
<i class="fa fa-exclamation-triangle me-1 text-warning"/>
<t t-esc="state.pendingApprovals.length"/> Pending Approval<t t-if="state.pendingApprovals.length > 1">s</t>
</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 class="btn btn-success px-2 py-0" style="font-size: 0.75rem;"
t-on-click="onApproveAll" title="Approve all">
<i class="fa fa-check me-1"/>All
</button>
<button class="btn btn-outline-danger btn-sm" t-on-click="onRejectAll">
Reject All
<button class="btn btn-outline-danger px-2 py-0" style="font-size: 0.75rem;"
t-on-click="onRejectAll" title="Reject all">
<i class="fa fa-times me-1"/>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 class="overflow-auto" style="max-height: 280px;">
<table class="table table-sm table-hover align-middle mb-0">
<thead>
<tr class="small text-muted">
<th class="px-2 py-1 fw-semibold">Type</th>
<th class="px-2 py-1 fw-semibold">Details</th>
<th class="px-2 py-1 fw-semibold text-end">Amount</th>
<th class="px-1 py-1 fw-semibold text-end" style="width: 80px;"></th>
</tr>
</thead>
<tbody>
<t t-foreach="state.pendingApprovals" t-as="approval" t-key="approval.id">
<FusionApprovalCard
approval="approval"
onApprove.bind="onApprove"
onReject.bind="onReject"/>
</t>
</tbody>
</table>
</div>
</div>
</t>
<!-- Input -->
<div class="fusion_chat_input border-top p-2">
<!-- Image preview -->
<t t-if="state.pendingImage">
<div class="fusion_image_preview d-flex align-items-center gap-2 mb-1 p-1 rounded bg-body-tertiary">
<img t-att-src="state.pendingImage.dataUrl" class="rounded" style="max-height: 48px; max-width: 80px; object-fit: cover;"/>
<small class="text-muted flex-grow-1 text-truncate" t-esc="state.pendingImage.name"/>
<button class="btn btn-sm p-0 text-danger" t-on-click="clearImage" title="Remove">
<i class="fa fa-times"/>
</button>
</div>
</t>
<div class="input-group">
<button class="btn btn-outline-secondary btn-sm" t-on-click="triggerFileUpload"
title="Attach image (screenshot, remittance advice, etc.)">
<i class="fa fa-paperclip"/>
</button>
<textarea
t-ref="chatInput"
class="form-control form-control-sm"
placeholder="Ask Fusion AI..."
rows="2"
placeholder="Ask Fusion AI... (paste screenshot with Ctrl+V)"
rows="1"
t-model="state.inputText"
t-on-keydown="onKeyDown"/>
t-on-keydown="onKeyDown"
t-on-paste="onPaste"/>
<button class="btn btn-primary btn-sm" t-on-click="sendMessage"
t-att-disabled="state.sending">
<i class="fa fa-paper-plane"/>
</button>
</div>
<input type="file" t-ref="fileInput" class="d-none" accept="image/*"
t-on-change="onFileSelected"/>
</div>
</div>
</t>

View File

@@ -36,12 +36,22 @@ export class FusionDashboard extends Component {
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;
async onAttentionClick(domain, prompt) {
// Type the prompt into the chat input and send
if (!prompt) return;
const textarea = this.el?.querySelector('.fusion_chat_input textarea');
if (textarea) {
// Set value and trigger OWL's model update via input event
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype, 'value'
).set;
nativeInputValueSetter.call(textarea, prompt);
textarea.dispatchEvent(new Event('input', { bubbles: true }));
// Click send button
setTimeout(() => {
const sendBtn = this.el?.querySelector('.fusion_chat_input .btn-primary');
if (sendBtn) sendBtn.click();
}, 50);
}
}
@@ -52,27 +62,27 @@ export class FusionDashboard extends Component {
{
title: "Bank Reconciliation",
metric: `${d.bank_recon.count} unmatched`,
subtext: `$${(d.bank_recon.amount || 0).toFixed(2)} total`,
subtext: `$${(d.bank_recon.amount || 0).toLocaleString('en-CA', {minimumFractionDigits: 0})} 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)}`,
metric: `$${Math.abs(d.ar.total || 0).toLocaleString('en-CA', {minimumFractionDigits: 0})}`,
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)}`,
metric: `$${(d.ap.total || 0).toLocaleString('en-CA', {minimumFractionDigits: 0})}`,
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)}`,
metric: `$${(d.hst.balance || 0).toLocaleString('en-CA', {minimumFractionDigits: 0})}`,
subtext: d.hst.balance > 0 ? "Owing to CRA" : "Refund expected",
domain: "hst_management",
status: "blue",

View File

@@ -2,29 +2,27 @@
<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
<div class="fusion_dashboard_header d-flex justify-content-between align-items-center px-3 py-2">
<h4 class="mb-0"><i class="fa fa-bolt me-2"/>Fusion AI</h4>
<button class="btn btn-outline-secondary btn-sm" t-on-click="loadDashboard">
<i class="fa fa-refresh me-1"/>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>
<p class="mt-2 text-muted">Loading dashboard...</p>
</div>
</t>
<t t-else="">
<!-- Main layout: Left panel (cards + needs attention) | Right panel (chat) -->
<div class="fusion_main_layout d-flex">
<!-- LEFT SIDE: Cards (2 rows of 3) + Needs Attention -->
<!-- LEFT: Cards + Needs Attention -->
<div class="fusion_left_panel d-flex flex-column p-3 gap-3">
<!-- Health Cards: 2 rows x 3 cards -->
<div class="fusion_health_cards d-flex flex-wrap gap-2">
<div class="fusion_health_cards">
<t t-foreach="cards" t-as="card" t-key="card.domain">
<FusionHealthCard
title="card.title"
@@ -32,37 +30,45 @@
subtext="card.subtext"
status="card.status"
domain="card.domain"
onCardClick.bind="onCardClick"/>
onCardClick.bind="onAttentionClick"/>
</t>
</div>
<!-- Needs Attention Panel -->
<div class="card fusion_attention_card">
<div class="card-header py-2">
<h5 class="mb-0"><i class="fa fa-exclamation-triangle me-2 text-warning"/>Needs Attention</h5>
<!-- Needs Attention -->
<div class="fusion_attention_panel flex-grow-1 d-flex flex-column">
<div class="d-flex align-items-center gap-2 mb-2">
<i class="fa fa-bell text-warning"/>
<span class="fw-semibold small">Needs Attention</span>
<t t-if="state.data and state.data.needs_attention">
<span class="badge bg-warning text-dark" t-esc="state.data.needs_attention.length"/>
</t>
</div>
<div class="card-body overflow-auto p-2">
<div class="fusion_attention_list flex-grow-1 overflow-auto">
<t t-if="state.data and state.data.needs_attention and state.data.needs_attention.length">
<t t-foreach="state.data.needs_attention" t-as="item" t-key="item_index">
<div class="fusion_attention_item d-flex align-items-start gap-2 p-2 rounded mb-1 cursor-pointer"
t-on-click="() => this.onCardClick(item.domain)">
<i class="fa fa-circle-o text-warning mt-1" style="font-size: 0.6rem;"/>
<div>
<div class="fw-semibold small" t-esc="item.title"/>
<div class="text-muted" style="font-size: 0.78rem;" t-esc="item.action"/>
<div class="fusion_attention_item d-flex align-items-start gap-2 p-2 rounded cursor-pointer"
t-on-click="() => this.onAttentionClick(item.domain, item.prompt)">
<div t-attf-class="fusion_attn_dot fusion_attn_{{item.severity || 'warning'}}"/>
<div class="flex-grow-1 small">
<div class="fw-semibold" t-esc="item.title"/>
<div class="text-muted" style="font-size: 0.75rem;" t-esc="item.action"/>
</div>
<i class="fa fa-chevron-right text-muted mt-1" style="font-size: 0.6rem;"/>
</div>
</t>
</t>
<t t-else="">
<p class="text-muted small mb-0">AI-prioritised items will appear here after the first audit scan.</p>
<div class="text-center text-muted small py-3">
<i class="fa fa-check-circle fa-2x mb-2 d-block text-success"/>
All clear! No items need attention.
</div>
</t>
</div>
</div>
</div>
<!-- RIGHT SIDE: Chat Panel (full height, input pinned to bottom) -->
<div class="fusion_right_panel border-start">
<!-- RIGHT: Chat -->
<div class="fusion_right_panel">
<FusionChatPanel sessionId="state.chatSessionId"/>
</div>
</div>

View File

@@ -6,14 +6,16 @@ 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",
get icon() {
const icons = {
bank_reconciliation: "fa-bank",
accounts_receivable: "fa-file-text-o",
accounts_payable: "fa-credit-card",
hst_management: "fa-percent",
audit: "fa-shield",
month_end: "fa-calendar-check-o",
};
return map[this.props.status] || "bg-light";
return icons[this.props.domain] || "fa-bar-chart";
}
onClick() {

View File

@@ -1,15 +1,17 @@
<?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;"
<div class="fusion_health_card cursor-pointer"
t-attf-class="fusion_card_{{props.status}}"
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 class="d-flex align-items-center gap-2 mb-2">
<div class="fusion_card_icon">
<i t-attf-class="fa {{icon}}"/>
</div>
<span class="fusion_card_title" t-esc="props.title"/>
</div>
<div class="fusion_card_metric" t-esc="props.metric"/>
<div class="fusion_card_sub" t-esc="props.subtext"/>
</div>
</t>
</templates>

View File

@@ -6,11 +6,22 @@
.fusion_chat_msg {
word-break: break-word;
animation: fusionFadeIn 0.25s ease;
}
.fusion_ai_msg {
background: var(--o-view-background-color);
border: 1px solid var(--o-border-color);
border-radius: 0.75rem;
}
// Live thinking block
.fusion_live_status {
animation: fusionPulse 2s ease-in-out infinite;
}
.fusion_thinking_block {
animation: fusionFadeIn 0.3s ease;
}
.fusion_rich_content {
@@ -72,15 +83,165 @@
}
}
.fusion_chat_input {
flex-shrink: 0;
textarea {
resize: none;
// Conversation starters
.fusion_starter.btn {
font-size: 0.8rem;
border-radius: 1rem;
padding: 0.3rem 0.8rem;
transition: all 0.15s ease;
background: transparent;
color: var(--bs-body-color);
border-color: var(--o-border-color, var(--bs-border-color));
&:hover, &:focus, &:active {
background: var(--o-action-color, #714B67) !important;
color: #fff !important;
border-color: var(--o-action-color, #714B67) !important;
}
}
.fusion_approval_card {
border-left: 3px solid var(--bs-warning);
.fusion_chat_input {
flex-shrink: 0;
textarea {
resize: none;
font-size: 0.88rem;
min-height: 42px;
max-height: 120px;
line-height: 1.5;
// Vertically centre single-line placeholder
padding: 0.5rem 0.75rem;
&::placeholder { opacity: 0.5; }
}
// Wider send button
.btn-primary {
min-width: 52px;
padding-left: 0.85rem;
padding-right: 0.85rem;
}
// Paperclip button
.btn-outline-secondary {
border-right: none;
}
}
.fusion_image_preview {
animation: fusionFadeIn 0.2s ease;
}
.fusion_approval_row {
td {
vertical-align: middle;
}
&:hover {
background: rgba(var(--bs-body-color-rgb), 0.04);
}
}
// Collapsible tool calls log
.fusion_tool_calls {
summary {
cursor: pointer;
list-style: none;
&::-webkit-details-marker { display: none; }
&::before {
content: "\f105"; // fa-angle-right
font-family: FontAwesome;
display: inline-block;
width: 0.8em;
font-size: 0.75rem;
transition: transform 0.15s ease;
}
}
&[open] > summary::before {
transform: rotate(90deg);
}
code {
color: var(--o-action-color, var(--bs-primary));
background: rgba(var(--bs-body-color-rgb), 0.04);
padding: 0.1em 0.3em;
border-radius: 0.2rem;
}
}
// Reconciliation table styles
.fusion_recon_table {
border: 1px solid var(--o-border-color);
border-radius: 0.375rem;
overflow: hidden;
background: var(--o-view-background-color);
.table {
font-size: 0.82rem;
margin-bottom: 0;
thead th {
font-weight: 600;
font-size: 0.78rem;
white-space: nowrap;
background: rgba(var(--bs-body-color-rgb), 0.03);
border-bottom: 2px solid var(--o-border-color);
}
tbody tr {
transition: background-color 0.15s ease;
&:hover { background: rgba(var(--bs-body-color-rgb), 0.04); }
}
.fit-content { width: 1%; white-space: nowrap; }
}
.fusion_apply_amount {
font-size: 0.8rem;
padding: 0.15rem 0.35rem;
background: transparent;
border: 1px solid var(--o-border-color);
color: inherit;
text-align: right;
&:focus {
background: var(--o-view-background-color);
border-color: var(--o-action-color, var(--bs-primary));
box-shadow: 0 0 0 0.15rem rgba(var(--bs-primary-rgb), 0.15);
}
}
.fusion_recon_search {
background: rgba(var(--bs-body-color-rgb), 0.02);
}
.fusion_search_results {
position: absolute;
left: 0.5rem;
right: 0.5rem;
top: 100%;
z-index: 10;
background: var(--o-view-background-color);
border: 1px solid var(--o-border-color);
border-radius: 0.375rem;
box-shadow: 0 4px 12px rgba(var(--bs-body-color-rgb), 0.15);
max-height: 200px;
overflow-y: auto;
.list-group-item {
border: none;
border-bottom: 1px solid var(--o-border-color);
background: transparent;
color: inherit;
&:hover {
background: rgba(var(--bs-primary-rgb), 0.06);
}
&:last-child { border-bottom: none; }
}
}
.fusion_match_total {
background: rgba(var(--bs-body-color-rgb), 0.02);
}
}
// Interactive table styles
@@ -162,3 +323,16 @@
}
}
}
// ================================================================
// Animations
// ================================================================
@keyframes fusionFadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fusionPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}

View File

@@ -1,6 +1,4 @@
.fusion_accounting_dashboard {
// Fill the available Odoo content area (below navbar + menu bar)
// Use 100% of parent instead of 100vh to respect Odoo's own layout
display: flex;
flex-direction: column;
height: 100%;
@@ -11,101 +9,205 @@
flex-shrink: 0;
}
// Main two-column layout — must fill remaining height
// ================================================================
// Main two-column layout
// ================================================================
.fusion_main_layout {
flex: 1;
// This is the key: prevent the flex container from growing beyond
// the viewport, which would push the chat input off-screen
min-height: 0;
overflow: hidden;
}
// Left panel: cards + needs attention (scrollable)
// Left panel — padding matches right panel so both columns align
.fusion_left_panel {
width: 50%;
min-width: 400px;
max-width: 600px;
overflow-y: auto;
flex-shrink: 0;
padding: 0.75rem !important;
}
// Health cards: 3 per row
.fusion_health_cards {
flex-shrink: 0;
.fusion_health_card {
flex: 0 0 calc(33.333% - 6px);
min-width: 150px;
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);
}
}
}
// Needs Attention: fill remaining left panel space
.fusion_attention_card {
flex: 1;
min-height: 150px;
overflow: hidden;
.card-body {
overflow-y: auto;
}
}
// Needs Attention items
.fusion_attention_item {
transition: background 0.15s ease;
&:hover {
background: rgba(var(--bs-body-color-rgb), 0.04);
}
}
// Right panel: chat takes all remaining width and height
// Right panel
.fusion_right_panel {
flex: 1;
min-width: 500px;
display: flex;
flex-direction: column;
// Critical: prevent overflow so chat input stays visible
min-height: 0;
overflow: hidden;
padding: 0.75rem 0.75rem 0.75rem 0;
// Override chat panel to fill the container
.fusion_chat_panel {
// Fill the right panel completely
flex: 1;
display: flex;
flex-direction: column;
border-radius: 0;
border: none;
// Must not exceed container
border: 1px solid #dee2e6;
border-radius: 0.75rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
min-height: 0;
height: auto !important;
overflow: hidden;
html[data-color-scheme="dark"] &,
body.o_dark & {
border-color: rgba(255, 255, 255, 0.12);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.card-header {
flex-shrink: 0;
border-radius: 0.75rem 0.75rem 0 0;
}
.fusion_chat_messages {
// Override base chat.scss values that break flex layout
max-height: none !important;
min-height: 0 !important;
// Grow to fill, but scrollable
flex: 1;
overflow-y: auto;
}
.fusion_chat_input {
flex-shrink: 0;
border-radius: 0 0 0.75rem 0.75rem;
}
}
}
// ================================================================
// Health Cards — modern rounded design
// ================================================================
.fusion_health_cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
flex-shrink: 0;
}
.fusion_health_card {
border-radius: 0.75rem;
padding: 1rem;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
border: 1px solid #dee2e6;
background: var(--o-view-background-color, #fff);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
&:hover {
transform: translateY(-3px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
}
// Dark mode adjustments
html[data-color-scheme="dark"] &,
body.o_dark & {
border-color: rgba(255, 255, 255, 0.12);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
&:hover {
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
}
}
.fusion_card_icon {
width: 28px;
height: 28px;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.85rem;
flex-shrink: 0;
}
.fusion_card_title {
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--bs-secondary-color, #6c757d);
}
.fusion_card_metric {
font-size: 1.35rem;
font-weight: 700;
line-height: 1.2;
margin-bottom: 0.15rem;
}
.fusion_card_sub {
font-size: 0.75rem;
color: var(--bs-secondary-color, #6c757d);
}
// Status-based left border + icon colour
&.fusion_card_green {
border-left: 3px solid var(--bs-success);
.fusion_card_icon { background: rgba(var(--bs-success-rgb), 0.12); color: var(--bs-success); }
}
&.fusion_card_yellow {
border-left: 3px solid var(--bs-warning);
.fusion_card_icon { background: rgba(var(--bs-warning-rgb), 0.15); color: var(--bs-warning); }
}
&.fusion_card_red {
border-left: 3px solid var(--bs-danger);
.fusion_card_icon { background: rgba(var(--bs-danger-rgb), 0.12); color: var(--bs-danger); }
}
&.fusion_card_blue {
border-left: 3px solid var(--bs-info);
.fusion_card_icon { background: rgba(var(--bs-info-rgb), 0.12); color: var(--bs-info); }
}
}
// ================================================================
// Needs Attention panel
// ================================================================
.fusion_attention_panel {
background: var(--o-view-background-color, #fff);
border: 1px solid #dee2e6;
border-radius: 0.75rem;
padding: 0.75rem;
min-height: 150px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
html[data-color-scheme="dark"] &,
body.o_dark & {
border-color: rgba(255, 255, 255, 0.12);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
}
.fusion_attention_item {
transition: background 0.15s ease;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
&:last-child { border-bottom: none; }
&:hover {
background: rgba(0, 0, 0, 0.03);
}
html[data-color-scheme="dark"] &,
body.o_dark & {
border-bottom-color: rgba(255, 255, 255, 0.06);
&:hover { background: rgba(255, 255, 255, 0.04); }
}
}
.fusion_attn_dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
margin-top: 5px;
&.fusion_attn_danger { background: var(--bs-danger); }
&.fusion_attn_warning { background: var(--bs-warning); }
&.fusion_attn_info { background: var(--bs-info); }
&.fusion_attn_muted { background: var(--bs-secondary); }
}
}
// Also ensure the Odoo action container gives us full height
// Full height in Odoo's action container
.o_action_manager {
.o_action.fusion_accounting_dashboard {
height: 100%;

View File

@@ -4,19 +4,21 @@
<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"/>
<list string="Match History" default_order="proposed_at desc">
<field name="proposed_at" string="Date"/>
<field name="session_id" string="Session"/>
<field name="tool_display_name" string="Tool"/>
<field name="tool_name" string="Tool (Code)" optional="hide"/>
<field name="decision" widget="badge"
decoration-success="decision == 'approved'"
decoration-success="decision in ('approved', 'auto')"
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"/>
decoration-warning="decision == 'pending'"/>
<field name="ai_confidence" string="Confidence" widget="progressbar"/>
<field name="amount" string="Amount"/>
<field name="partner_id" string="Partner"/>
<field name="ai_reasoning" string="Reasoning" optional="hide"/>
<field name="decided_by" string="Decided By" optional="hide"/>
<field name="decided_at" string="Decided At" optional="hide"/>
</list>
</field>
</record>
@@ -33,32 +35,63 @@
<button name="action_reject" string="Reject" type="object"
class="btn-danger" invisible="decision != 'pending'"
groups="fusion_accounting.group_fusion_accounting_manager"/>
<field name="decision" widget="statusbar"
statusbar_visible="pending,approved,rejected,auto"/>
</header>
<sheet>
<div class="oe_title mb-3">
<h1>
<field name="tool_display_name" readonly="1"/>
</h1>
<div class="text-muted small">
Internal: <field name="tool_name" readonly="1" class="d-inline"/>
</div>
</div>
<group>
<group>
<field name="tool_name"/>
<field name="decision"/>
<field name="ai_confidence"/>
<group string="Request Details">
<field name="session_id"/>
<field name="proposed_at" string="When"/>
<field name="ai_confidence" widget="progressbar" string="Confidence"/>
<field name="amount"/>
<field name="partner_id"/>
<field name="rule_id" invisible="not rule_id"/>
</group>
<group>
<field name="session_id"/>
<field name="rule_id"/>
<field name="proposed_at"/>
<field name="decided_at"/>
<group string="Decision">
<field name="decided_by"/>
<field name="decided_at"/>
<field name="rejection_reason"
invisible="decision != 'rejected'"/>
</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"/>
<notebook>
<page string="AI Reasoning" name="reasoning">
<field name="ai_reasoning" widget="text"
placeholder="No AI reasoning recorded for this tool call."
nolabel="1"/>
</page>
<page string="Parameters" name="params">
<field name="tool_params_pretty" widget="text"
nolabel="1" readonly="1"/>
</page>
<page string="Result" name="result">
<field name="tool_result_pretty" widget="text"
nolabel="1" readonly="1"/>
</page>
<page string="Correction" name="correction"
invisible="decision != 'rejected'">
<group>
<field name="rejection_reason" string="Why was this rejected?"/>
<field name="correct_action" widget="text"
string="What should have been done instead?"/>
</group>
</page>
</notebook>
<group string="Linked Records" invisible="not bank_statement_line_id">
<field name="bank_statement_line_id"/>
<field name="move_line_ids" widget="many2many_tags"/>
</group>
</sheet>
</form>
@@ -72,13 +105,19 @@
<search>
<field name="tool_name"/>
<field name="partner_id"/>
<field name="session_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')]"/>
<filter name="auto" string="Auto-Executed" domain="[('decision', '=', 'auto')]"/>
<separator/>
<filter name="today" string="Today" domain="[('proposed_at', '>=', (context_today()).strftime('%Y-%m-%d'))]"/>
<filter name="this_week" string="This Week" domain="[('proposed_at', '>=', (context_today() - datetime.timedelta(days=7)).strftime('%Y-%m-%d'))]"/>
<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'}"/>
<filter name="group_session" string="Session" domain="[]" context="{'group_by': 'session_id'}"/>
</group>
</search>
</field>
@@ -89,6 +128,7 @@
<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="context">{'search_default_today': 1}</field>
<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>

View File

@@ -22,6 +22,7 @@
'views/payment_poynt_templates.xml',
'views/poynt_terminal_views.xml',
'views/account_move_views.xml',
'views/account_payment_views.xml',
'views/sale_order_views.xml',
'views/res_config_settings_views.xml',
'views/poynt_settlement_views.xml',

View File

@@ -1,6 +1,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import account_move
from . import account_payment
from . import payment_provider
from . import payment_token
from . import payment_transaction

View File

@@ -0,0 +1,42 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, api, fields, models
class AccountPayment(models.Model):
_inherit = 'account.payment'
poynt_settlement_line_ids = fields.One2many(
'poynt.settlement.line',
'existing_payment_id',
string="Settlement Lines",
)
poynt_settlement_count = fields.Integer(
string="Settlements",
compute='_compute_poynt_settlement_count',
)
@api.depends('poynt_settlement_line_ids')
def _compute_poynt_settlement_count(self):
for payment in self:
payment.poynt_settlement_count = len(payment.poynt_settlement_line_ids)
def action_view_poynt_settlement(self):
"""Open the settlement batch linked to this payment."""
self.ensure_one()
batch_ids = self.poynt_settlement_line_ids.mapped('batch_id').ids
if len(batch_ids) == 1:
return {
'type': 'ir.actions.act_window',
'name': _("Settlement Batch"),
'res_model': 'poynt.settlement.batch',
'view_mode': 'form',
'res_id': batch_ids[0],
}
return {
'type': 'ir.actions.act_window',
'name': _("Settlement Batches"),
'res_model': 'poynt.settlement.batch',
'view_mode': 'list,form',
'domain': [('id', 'in', batch_ids)],
}

View File

@@ -4,7 +4,7 @@ import logging
from datetime import timedelta
from odoo import _, api, fields, models
from odoo.exceptions import UserError, ValidationError
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
@@ -52,10 +52,10 @@ class PoyntSettlementBatch(models.Model):
)
state = fields.Selection([
('draft', "Draft"),
('matched', "Matched"),
('matched', "Matched to Deposit"),
('reconciled', "Reconciled"),
('error', "Error"),
], string="Status", required=True, default='draft', tracking=True)
], string="Status", required=True, default='draft')
currency_id = fields.Many2one(
'res.currency',
@@ -93,10 +93,18 @@ class PoyntSettlementBatch(models.Model):
store=True,
)
matched_count = fields.Integer(
string="Matched to Customers",
string="Matched to Existing Payments",
compute='_compute_totals',
store=True,
)
payment_count = fields.Integer(
string="Payments",
compute='_compute_smart_buttons',
)
invoice_count = fields.Integer(
string="Invoices",
compute='_compute_smart_buttons',
)
notes = fields.Text(string="Notes")
_sql_constraints = [
@@ -113,7 +121,7 @@ class PoyntSettlementBatch(models.Model):
) or '/'
return super().create(vals_list)
@api.depends('line_ids.amount', 'line_ids.action', 'line_ids.partner_id', 'elavon_deposit')
@api.depends('line_ids.amount', 'line_ids.action', 'line_ids.existing_payment_id', 'elavon_deposit')
def _compute_totals(self):
for batch in self:
sales = sum(
@@ -127,7 +135,38 @@ class PoyntSettlementBatch(models.Model):
batch.fee_amount = net - batch.elavon_deposit if batch.elavon_deposit else 0.0
batch.sale_count = len(batch.line_ids.filtered(lambda l: l.action == 'SALE'))
batch.refund_count = len(batch.line_ids.filtered(lambda l: l.action == 'REFUND'))
batch.matched_count = len(batch.line_ids.filtered(lambda l: l.partner_id))
batch.matched_count = len(batch.line_ids.filtered(lambda l: l.existing_payment_id))
def _compute_smart_buttons(self):
for batch in self:
payments = batch.line_ids.mapped('existing_payment_id')
invoices = batch.line_ids.mapped('existing_invoice_id')
batch.payment_count = len(payments)
batch.invoice_count = len(invoices)
def action_view_payments(self):
"""Open linked payments in a list view."""
self.ensure_one()
payment_ids = self.line_ids.mapped('existing_payment_id').ids
return {
'type': 'ir.actions.act_window',
'name': _("Payments - %s", self.name),
'res_model': 'account.payment',
'view_mode': 'list,form',
'domain': [('id', 'in', payment_ids)],
}
def action_view_invoices(self):
"""Open linked invoices in a list view."""
self.ensure_one()
invoice_ids = self.line_ids.mapped('existing_invoice_id').ids
return {
'type': 'ir.actions.act_window',
'name': _("Invoices - %s", self.name),
'res_model': 'account.move',
'view_mode': 'list,form',
'domain': [('id', 'in', invoice_ids)],
}
# === BUSINESS METHODS === #
@@ -165,7 +204,7 @@ class PoyntSettlementBatch(models.Model):
card = txn.get('fundingSource', {}).get('card', {})
# Convert ISO 8601 timestamp (2025-03-05T19:19:10Z) to Odoo format
# Convert ISO 8601 timestamp to Odoo format
created_at = txn.get('createdAt', '')
if created_at:
created_at = created_at.replace('T', ' ').replace('Z', '')
@@ -198,90 +237,117 @@ class PoyntSettlementBatch(models.Model):
if not self.line_ids:
raise UserError(_("No transaction lines to match. Fetch transactions first."))
# Look for Elavon deposit on the settlement date (or ±1 day for timing)
StmtLine = self.env['account.bank.statement.line']
domain = [
('journal_id.name', 'ilike', 'Scotia'),
('date', '>=', self.settlement_date - timedelta(days=1)),
('date', '<=', self.settlement_date + timedelta(days=1)),
('amount', '>', 0),
('payment_ref', 'ilike', 'ELAVON'),
('is_reconciled', '=', False),
]
candidates = StmtLine.search(domain, order='date asc')
# Search for Elavon deposits near the settlement date
# Use journal_id = 50 (Scotia Current) and SQL for the date
# since date is a related field from account.move
self.env.cr.execute("""
SELECT absl.id, am.date, absl.amount
FROM account_bank_statement_line absl
JOIN account_move am ON am.id = absl.move_id
WHERE absl.journal_id = 50
AND am.date >= %s
AND am.date <= %s
AND absl.amount > 0
AND absl.payment_ref ILIKE '%%ELAVON%%'
ORDER BY am.date
""", [
self.settlement_date - timedelta(days=1),
self.settlement_date + timedelta(days=1),
])
rows = self.env.cr.fetchall()
if not candidates:
self.notes = f"No unreconciled Elavon deposit found near {self.settlement_date}"
return False
if not rows:
self.write({
'notes': f"No Elavon deposit found near {self.settlement_date}",
})
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'message': _("No Elavon deposit found near %s", self.settlement_date),
'type': 'warning',
'sticky': False,
},
}
# Try to find the closest match by amount
net_amount = self.poynt_total
best_match = None
best_diff = float('inf')
for line in candidates:
diff = abs(line.amount - net_amount)
for row_id, row_date, row_amount in rows:
diff = abs(float(row_amount) - net_amount)
# Allow up to 5% tolerance for processing fees
if diff < best_diff and diff <= net_amount * 0.05:
if diff < best_diff and (net_amount == 0 or diff <= abs(net_amount) * 0.05):
best_diff = diff
best_match = line
best_match = (row_id, row_date, float(row_amount))
if best_match:
self.write({
'bank_statement_line_id': best_match.id,
'elavon_deposit': best_match.amount,
'settlement_date': best_match.date,
'bank_statement_line_id': best_match[0],
'elavon_deposit': best_match[2],
'settlement_date': best_match[1],
'state': 'matched',
})
_logger.info(
"Poynt batch %s matched to bank line %s (deposit $%.2f, fees $%.2f)",
self.name, best_match.id, best_match.amount, self.fee_amount,
self.name, best_match[0], best_match[2], self.fee_amount,
)
return True
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'message': _("Matched to Elavon deposit of $%(amount).2f (fees: $%(fees).2f)",
amount=best_match[2], fees=self.fee_amount),
'type': 'success',
'sticky': False,
},
}
else:
self.notes = (
f"No matching Elavon deposit found. "
f"Poynt net: ${net_amount:.2f}, "
f"closest candidate: ${candidates[0].amount:.2f}"
)
return False
closest = min(rows, key=lambda r: abs(float(r[2]) - net_amount))
self.write({
'notes': (
f"No matching deposit. "
f"Poynt net: ${net_amount:.2f}, "
f"closest: ${float(closest[2]):.2f} on {closest[1]}"
),
})
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'message': _(
"No matching deposit found. Poynt net: $%(net).2f, "
"closest deposit: $%(closest).2f",
net=net_amount, closest=float(closest[2]),
),
'type': 'warning',
'sticky': False,
},
}
def action_match_customers(self):
"""Attempt to match settlement lines to Odoo customers and invoices."""
def action_match_existing_payments(self):
"""Match settlement lines to EXISTING payments already recorded by staff.
This does NOT create new payments. Staff already record payments when
customers pay at the terminal. This method links the Poynt transaction
to that existing payment for audit/reconciliation purposes.
"""
self.ensure_one()
matched = 0
for line in self.line_ids.filtered(lambda l: not l.partner_id and l.action == 'SALE'):
if line._match_to_customer():
for line in self.line_ids.filtered(lambda l: not l.existing_payment_id and l.action == 'SALE'):
if line._match_to_existing_payment():
matched += 1
_logger.info(
"Poynt batch %s: matched %d/%d lines to customers",
self.name, matched, len(self.line_ids),
)
return True
def action_create_payments(self):
"""Create account.payment records for matched settlement lines."""
self.ensure_one()
if self.state == 'reconciled':
raise UserError(_("This batch is already reconciled."))
payable_lines = self.line_ids.filtered(
lambda l: l.partner_id and l.action == 'SALE' and l.state in ('fetched', 'matched') and not l.payment_id
"Poynt batch %s: matched %d/%d lines to existing payments",
self.name, matched, len(self.line_ids.filtered(lambda l: l.action == 'SALE')),
)
if not payable_lines:
raise UserError(_("No matched lines available for payment creation."))
for line in payable_lines:
line._create_customer_payment()
# Check if all lines are processed
all_paid = all(
l.state in ('paid', 'error') or l.action == 'REFUND'
for l in self.line_ids
# Check if all SALE lines are matched
unmatched = self.line_ids.filtered(
lambda l: l.action == 'SALE' and not l.existing_payment_id and l.state != 'no_match'
)
if all_paid:
if not unmatched and self.state == 'matched':
self.state = 'reconciled'
return True
@@ -318,10 +384,10 @@ class PoyntSettlementBatch(models.Model):
return
# Handle weekend: if today is Monday, fetch Fri+Sat+Sun
weekday = yesterday.weekday() # 0=Monday, 6=Sunday
if weekday == 6: # Sunday → fetch Fri-Sun, deposit Monday
txn_date_from = yesterday - timedelta(days=2) # Friday
elif weekday == 5: # Saturday → skip, will be batched with Sunday
weekday = yesterday.weekday()
if weekday == 6: # Sunday → fetch Fri-Sun
txn_date_from = yesterday - timedelta(days=2)
elif weekday == 5: # Saturday → skip
_logger.info("Poynt settlement cron: Saturday — will batch with Sunday/Monday.")
return
else:
@@ -334,7 +400,6 @@ class PoyntSettlementBatch(models.Model):
})
try:
# Fetch all transactions for the date range
transactions = provider._poynt_fetch_settlement_transactions(
txn_date_from, yesterday,
)
@@ -360,7 +425,6 @@ class PoyntSettlementBatch(models.Model):
amount = amounts.get('transactionAmount', 0) / 100.0
card = txn.get('fundingSource', {}).get('card', {})
# Convert ISO 8601 timestamp to Odoo format
created_at = txn.get('createdAt', '')
if created_at:
created_at = created_at.replace('T', ' ').replace('Z', '')
@@ -384,8 +448,8 @@ class PoyntSettlementBatch(models.Model):
# Try to match to bank deposit
batch.action_match_deposit()
# Try to match customers
batch.action_match_customers()
# Try to match to existing payments (NOT create new ones)
batch.action_match_existing_payments()
_logger.info(
"Poynt settlement cron: created batch %s with %d lines for %s%s",
@@ -427,23 +491,28 @@ class PoyntSettlementLine(models.Model):
card_brand = fields.Char(string="Card Brand")
card_last4 = fields.Char(string="Card Last 4", size=4)
card_holder_name = fields.Char(string="Cardholder Name")
# Links to EXISTING records (staff-created, not settlement-created)
existing_payment_id = fields.Many2one(
'account.payment',
string="Existing Payment",
readonly=True,
ondelete='set null',
help="The payment already recorded by staff for this transaction.",
)
existing_invoice_id = fields.Many2one(
'account.move',
string="Linked Invoice",
domain="[('move_type', '=', 'out_invoice')]",
ondelete='set null',
help="The invoice this payment was applied to.",
)
partner_id = fields.Many2one(
'res.partner',
string="Customer",
ondelete='set null',
)
invoice_id = fields.Many2one(
'account.move',
string="Matched Invoice",
domain="[('move_type', '=', 'out_invoice')]",
ondelete='set null',
)
payment_id = fields.Many2one(
'account.payment',
string="Payment",
readonly=True,
ondelete='set null',
)
action = fields.Selection([
('SALE', "Sale"),
('REFUND', "Refund"),
@@ -451,13 +520,13 @@ class PoyntSettlementLine(models.Model):
], string="Action", required=True)
state = fields.Selection([
('fetched', "Fetched"),
('matched', "Matched"),
('paid', "Payment Created"),
('matched', "Matched to Payment"),
('no_match', "No Existing Payment"),
('error', "Error"),
], string="Status", required=True, default='fetched')
match_method = fields.Char(
string="Match Method",
help="How this line was matched to a customer (e.g., 'odoo_txn', 'card_token', 'invoice_amount', 'name').",
help="How this line was matched to an existing payment.",
)
notes = fields.Text(string="Notes")
@@ -466,167 +535,131 @@ class PoyntSettlementLine(models.Model):
'This Poynt transaction has already been recorded.'),
]
# === CUSTOMER MATCHING === #
# === MATCH TO EXISTING PAYMENTS === #
def _match_to_customer(self):
"""Attempt to match this settlement line to an Odoo customer/invoice.
def _match_to_existing_payment(self):
"""Match this Poynt transaction to an existing payment already in Odoo.
Staff record payments when customers pay at the terminal. This method
finds that existing payment — it does NOT create a new one.
Matching strategy (in priority order):
1. Check poynt_transaction_id in payment.transaction (direct Odoo payment)
2. Match by card_last4 against payment.token records
3. Match by amount against open invoices within ±2 days
4. Match by card_holder_name fuzzy search against res.partner
1. Poynt transaction ID in payment.transaction (direct Odoo integration)
2. Poynt transaction UUID found in payment memo field
3. Exact amount + cardholder name match on same date (±2 days)
4. Exact amount match on same date (±2 days)
:return: True if matched, False otherwise.
"""
self.ensure_one()
if self.partner_id:
if self.existing_payment_id:
return True
# Strategy 1: Direct Odoo payment transaction
# Strategy 1: Direct Odoo payment transaction (Poynt-integrated payments)
PaymentTxn = self.env['payment.transaction']
odoo_txn = PaymentTxn.search([
('poynt_transaction_id', '=', self.poynt_transaction_id),
], limit=1)
if odoo_txn and odoo_txn.partner_id:
if odoo_txn and odoo_txn.payment_id:
self.write({
'existing_payment_id': odoo_txn.payment_id.id,
'partner_id': odoo_txn.partner_id.id,
'invoice_id': odoo_txn.invoice_ids[:1].id if odoo_txn.invoice_ids else False,
'match_method': 'odoo_txn',
'existing_invoice_id': odoo_txn.invoice_ids[:1].id if odoo_txn.invoice_ids else False,
'match_method': 'poynt_txn',
'state': 'matched',
})
return True
# Strategy 2: Card token match
if self.card_last4:
token = self.env['payment.token'].search([
('payment_details', 'ilike', self.card_last4),
('provider_id.code', '=', 'poynt'),
# Strategy 2: Poynt transaction UUID in payment memo field
# Staff sometimes record the UUID when entering payments manually
if self.poynt_transaction_id:
memo_match = self.env['account.payment'].search([
('memo', 'ilike', self.poynt_transaction_id),
('payment_type', '=', 'inbound'),
('state', 'in', ('posted', 'in_process')),
], limit=1)
if token and token.partner_id:
if memo_match:
self.write({
'partner_id': token.partner_id.id,
'match_method': 'card_token',
'state': 'matched',
})
# Try to find matching invoice
self._match_invoice()
return True
# Strategy 3: Amount match against open invoices
if self.amount and self.transaction_date:
date = self.transaction_date.date() if self.transaction_date else fields.Date.today()
invoices = self.env['account.move'].search([
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('payment_state', 'in', ('not_paid', 'partial')),
('amount_residual', '=', self.amount),
('invoice_date', '>=', date - timedelta(days=7)),
('invoice_date', '<=', date + timedelta(days=2)),
], limit=1)
if invoices:
self.write({
'partner_id': invoices.partner_id.id,
'invoice_id': invoices.id,
'match_method': 'invoice_amount',
'existing_payment_id': memo_match.id,
'partner_id': memo_match.partner_id.id if memo_match.partner_id else False,
'existing_invoice_id': self._find_invoice_for_payment(memo_match),
'match_method': 'memo_uuid',
'state': 'matched',
})
return True
# Strategy 4: Cardholder name fuzzy match
if self.card_holder_name:
name = self.card_holder_name.strip()
if len(name) >= 3:
partners = self.env['res.partner'].search([
'|',
('name', 'ilike', name),
('name', 'ilike', name.split()[-1] if ' ' in name else name),
], limit=5)
if len(partners) == 1:
self.write({
'partner_id': partners.id,
'match_method': 'name',
'state': 'matched',
})
self._match_invoice()
return True
# Determine the date range for searching
if self.transaction_date:
txn_date = self.transaction_date.date()
else:
txn_date = self.batch_id.transaction_date
date_from = txn_date - timedelta(days=2)
date_to = txn_date + timedelta(days=2)
# Strategy 3: Exact amount + same date range on account.payment
# These are payments staff manually recorded
payments = self.env['account.payment'].search([
('amount', '=', self.amount),
('payment_type', '=', 'inbound'),
('date', '>=', date_from),
('date', '<=', date_to),
('state', 'in', ('posted', 'in_process')),
# Exclude payments already matched to other settlement lines
('id', 'not in', self._get_already_matched_payment_ids()),
], order='date asc')
if payments:
# Prefer one with a partner that matches cardholder name
if self.card_holder_name:
name = self.card_holder_name.strip()
for pay in payments:
if pay.partner_id and name.lower() in (pay.partner_id.name or '').lower():
self.write({
'existing_payment_id': pay.id,
'partner_id': pay.partner_id.id,
'existing_invoice_id': self._find_invoice_for_payment(pay),
'match_method': 'amount_name',
'state': 'matched',
})
return True
# Fall back to first matching payment
pay = payments[0]
self.write({
'existing_payment_id': pay.id,
'partner_id': pay.partner_id.id if pay.partner_id else False,
'existing_invoice_id': self._find_invoice_for_payment(pay),
'match_method': 'amount_date',
'state': 'matched',
})
return True
# No existing payment found — mark for review
self.write({
'state': 'no_match',
'notes': f"No existing payment found for ${self.amount:.2f} near {txn_date}",
})
return False
def _match_invoice(self):
"""Try to find a matching open invoice for this line's partner and amount."""
self.ensure_one()
if self.invoice_id or not self.partner_id:
return
def _get_already_matched_payment_ids(self):
"""Get payment IDs already matched to other lines in this batch."""
return self.batch_id.line_ids.filtered(
lambda l: l.existing_payment_id and l.id != self.id
).mapped('existing_payment_id').ids
invoices = self.env['account.move'].search([
('partner_id', '=', self.partner_id.id),
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('payment_state', 'in', ('not_paid', 'partial')),
('amount_residual', '=', self.amount),
], limit=1, order='invoice_date desc')
if invoices:
self.invoice_id = invoices.id
# === PAYMENT CREATION === #
def _create_customer_payment(self):
"""Create an account.payment for this matched settlement line."""
self.ensure_one()
if not self.partner_id:
self.write({'state': 'error', 'notes': 'No customer matched'})
def _find_invoice_for_payment(self, payment):
"""Find the invoice that a payment was applied to."""
if not payment.partner_id:
return False
if self.payment_id:
return True
try:
# Use the provider's journal (Poynt payment journal)
journal = self.batch_id.provider_id.journal_id
if not journal:
# Fall back to first bank journal
journal = self.env['account.journal'].search([
('type', '=', 'bank'),
('company_id', '=', self.env.company.id),
], limit=1)
# Check reconciled invoices via the payment's move lines
receivable_lines = payment.move_id.line_ids.filtered(
lambda l: l.account_id.account_type == 'asset_receivable' and l.reconciled
)
for line in receivable_lines:
for partial in (line.matched_debit_ids | line.matched_credit_ids):
counterpart = partial.debit_move_id if partial.credit_move_id == line else partial.credit_move_id
if counterpart.move_id.move_type == 'out_invoice':
return counterpart.move_id.id
payment_vals = {
'partner_id': self.partner_id.id,
'amount': self.amount,
'currency_id': self.currency_id.id,
'journal_id': journal.id,
'payment_type': 'inbound',
'partner_type': 'customer',
'payment_method_line_id': journal.inbound_payment_method_line_ids[:1].id,
'memo': f"Poynt {self.card_brand or 'Card'} ****{self.card_last4 or '????'} - {self.batch_id.name}",
}
payment = self.env['account.payment'].create(payment_vals)
payment.action_post()
self.write({
'payment_id': payment.id,
'state': 'paid',
})
# Reconcile with invoice if matched
if self.invoice_id and self.invoice_id.payment_state in ('not_paid', 'partial'):
try:
(payment.move_id.line_ids + self.invoice_id.line_ids).filtered(
lambda l: l.account_id.account_type == 'asset_receivable' and not l.reconciled
).reconcile()
except Exception as e:
_logger.warning(
"Could not auto-reconcile payment %s with invoice %s: %s",
payment.name, self.invoice_id.name, e,
)
return True
except Exception as e:
self.write({'state': 'error', 'notes': str(e)})
_logger.error(
"Failed to create payment for settlement line %s: %s",
self.poynt_transaction_id, e,
)
return False
return False

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_account_payment_form_inherit_poynt_settlement" model="ir.ui.view">
<field name="name">account.payment.form.inherit.poynt.settlement</field>
<field name="model">account.payment</field>
<field name="inherit_id" ref="account.view_account_payment_form"/>
<field name="arch" type="xml">
<xpath expr="//div[@name='button_box']" position="inside">
<button name="action_view_poynt_settlement" type="object"
class="oe_stat_button" icon="fa-credit-card"
invisible="poynt_settlement_count == 0">
<field name="poynt_settlement_count" string="Settlement" widget="statinfo"/>
</button>
</xpath>
</field>
</record>
</odoo>

View File

@@ -40,19 +40,27 @@
<button name="action_match_deposit" type="object"
string="Match Bank Deposit" class="btn-primary"
invisible="state != 'draft' or not line_ids"/>
<button name="action_match_customers" type="object"
string="Match Customers" class="btn-secondary"
<button name="action_match_existing_payments" type="object"
string="Match Existing Payments" class="btn-secondary"
invisible="state not in ('draft', 'matched')"/>
<button name="action_create_payments" type="object"
string="Create Payments" class="btn-primary"
invisible="state not in ('matched',)"
confirm="This will create customer payment records for all matched lines. Continue?"/>
<button name="action_reset_to_draft" type="object"
string="Reset to Draft" class="btn-secondary"
invisible="state in ('draft', 'reconciled')"/>
<field name="state" widget="statusbar" statusbar_visible="draft,matched,reconciled"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_payments" type="object"
class="oe_stat_button" icon="fa-money"
invisible="payment_count == 0">
<field name="payment_count" string="Payments" widget="statinfo"/>
</button>
<button name="action_view_invoices" type="object"
class="oe_stat_button" icon="fa-pencil-square-o"
invisible="invoice_count == 0">
<field name="invoice_count" string="Invoices" widget="statinfo"/>
</button>
</div>
<div class="oe_title">
<h1>
<field name="name" readonly="1"/>
@@ -83,8 +91,8 @@
<page string="Transaction Lines" name="lines">
<field name="line_ids">
<list editable="bottom"
decoration-success="state == 'paid'"
decoration-warning="state == 'matched'"
decoration-success="state == 'matched'"
decoration-muted="state == 'no_match'"
decoration-danger="state == 'error'">
<field name="transaction_date"/>
<field name="action"/>
@@ -93,12 +101,12 @@
<field name="card_last4"/>
<field name="card_holder_name"/>
<field name="partner_id"/>
<field name="invoice_id"/>
<field name="payment_id"/>
<field name="existing_payment_id" string="Staff Payment"/>
<field name="existing_invoice_id" string="Invoice"/>
<field name="match_method"/>
<field name="state" widget="badge"
decoration-success="state == 'paid'"
decoration-warning="state == 'matched'"
decoration-success="state == 'matched'"
decoration-muted="state == 'no_match'"
decoration-danger="state == 'error'"/>
<field name="currency_id" column_invisible="1"/>
</list>
@@ -158,7 +166,7 @@
<field name="name">poynt.settlement.line.list</field>
<field name="model">poynt.settlement.line</field>
<field name="arch" type="xml">
<list decoration-success="state == 'paid'" decoration-warning="state == 'matched'" decoration-danger="state == 'error'">
<list decoration-success="state == 'matched'" decoration-muted="state == 'no_match'" decoration-danger="state == 'error'">
<field name="batch_id"/>
<field name="transaction_date"/>
<field name="action"/>
@@ -167,12 +175,12 @@
<field name="card_last4"/>
<field name="card_holder_name"/>
<field name="partner_id"/>
<field name="invoice_id"/>
<field name="payment_id"/>
<field name="existing_payment_id" string="Staff Payment"/>
<field name="existing_invoice_id" string="Invoice"/>
<field name="match_method"/>
<field name="state" widget="badge"
decoration-success="state == 'paid'"
decoration-warning="state == 'matched'"
decoration-success="state == 'matched'"
decoration-muted="state == 'no_match'"
decoration-danger="state == 'error'"/>
</list>
</field>
@@ -188,9 +196,9 @@
<field name="card_last4"/>
<field name="partner_id"/>
<field name="poynt_transaction_id"/>
<filter name="filter_unmatched" string="Unmatched" domain="[('partner_id', '=', False), ('action', '=', 'SALE')]"/>
<filter name="filter_matched" string="Matched" domain="[('state', '=', 'matched')]"/>
<filter name="filter_paid" string="Paid" domain="[('state', '=', 'paid')]"/>
<filter name="filter_unmatched" string="No Payment Found" domain="[('state', '=', 'no_match')]"/>
<filter name="filter_matched" string="Matched to Payment" domain="[('state', '=', 'matched')]"/>
<filter name="filter_fetched" string="Pending Match" domain="[('state', '=', 'fetched')]"/>
<filter name="filter_errors" string="Errors" domain="[('state', '=', 'error')]"/>
<separator/>
<filter name="group_batch" string="Batch" context="{'group_by': 'batch_id'}" domain="[]"/>