changes
This commit is contained in:
@@ -1,12 +1,21 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Inter-account transfer pairs: (source_journal, cc_journal, cc_account_pattern)
|
||||
# Source sends "MB-CREDIT CARD" (outgoing), CC receives "PAYMENT FROM" (incoming)
|
||||
TRANSFER_PAIRS = [
|
||||
# (source_journal_id, cc_journal_id, outstanding_account_id)
|
||||
(50, 51, 493), # Scotia Current → Passport Visa, Outstanding Receipts - All Banks
|
||||
(53, 28, 493), # RBC Chequing → RBC Visa, Outstanding Receipts - All Banks
|
||||
]
|
||||
|
||||
|
||||
class FusionAccountingAgent(models.AbstractModel):
|
||||
_name = 'fusion.accounting.agent'
|
||||
@@ -41,9 +50,14 @@ class FusionAccountingAgent(models.AbstractModel):
|
||||
def _build_tool_definitions(self, tools):
|
||||
definitions = []
|
||||
for tool in tools:
|
||||
# A2: Include tier info in description so AI knows which tools need approval
|
||||
tier_label = {'1': 'Read-only', '2': 'Auto-approved', '3': 'Requires user approval'}.get(tool.tier, '')
|
||||
desc = tool.description or ''
|
||||
if tier_label:
|
||||
desc = f"[Tier {tool.tier}: {tier_label}] {desc}"
|
||||
defn = {
|
||||
'name': tool.name,
|
||||
'description': tool.description,
|
||||
'description': desc,
|
||||
}
|
||||
if tool.parameters_schema:
|
||||
try:
|
||||
@@ -117,6 +131,21 @@ class FusionAccountingAgent(models.AbstractModel):
|
||||
raise UserError(_("Session not found."))
|
||||
|
||||
adapter = self._get_adapter()
|
||||
provider = self._get_config('ai_provider', 'claude')
|
||||
|
||||
# Pin provider to session to prevent cross-adapter message contamination (C5)
|
||||
if session.ai_provider and session.ai_provider != provider:
|
||||
_logger.warning(
|
||||
"Session %s was started with %s but current provider is %s. "
|
||||
"Keeping original provider to avoid message format conflicts.",
|
||||
session.name, session.ai_provider, provider,
|
||||
)
|
||||
provider = session.ai_provider
|
||||
if provider == 'claude':
|
||||
adapter = self.env['fusion.accounting.adapter.claude']
|
||||
else:
|
||||
adapter = self.env['fusion.accounting.adapter.openai']
|
||||
|
||||
tools = self._get_tools_for_user()
|
||||
tool_definitions = self._build_tool_definitions(tools)
|
||||
rules = self._load_rules()
|
||||
@@ -132,6 +161,7 @@ class FusionAccountingAgent(models.AbstractModel):
|
||||
total_tokens_in = 0
|
||||
total_tokens_out = 0
|
||||
response = {'text': '', 'tool_calls': None}
|
||||
has_pending_tier3 = False
|
||||
|
||||
for turn in range(max_turns):
|
||||
response = adapter.call_with_tools(
|
||||
@@ -151,6 +181,7 @@ class FusionAccountingAgent(models.AbstractModel):
|
||||
tier = tool_rec.tier if tool_rec else '1'
|
||||
|
||||
if tier == '3':
|
||||
has_pending_tier3 = True
|
||||
history_rec = self._log_match_history(
|
||||
session, tool_name, tool_params, None,
|
||||
reasoning=tc.get('reasoning', ''),
|
||||
@@ -184,7 +215,29 @@ class FusionAccountingAgent(models.AbstractModel):
|
||||
messages_json = adapter.append_tool_results(
|
||||
messages_json, response, tool_results,
|
||||
)
|
||||
session.tool_call_count += len(tool_results)
|
||||
session.write({'tool_call_count': session.tool_call_count + len(tool_results)})
|
||||
|
||||
# C2: Short-circuit loop when Tier 3 actions are pending —
|
||||
# force a final text response so the AI can present approval cards
|
||||
if has_pending_tier3:
|
||||
try:
|
||||
response = adapter.call_with_tools(
|
||||
system_prompt=system_prompt,
|
||||
messages=messages_json,
|
||||
tools=[],
|
||||
)
|
||||
total_tokens_in += response.get('tokens_in', 0)
|
||||
total_tokens_out += response.get('tokens_out', 0)
|
||||
messages_json.append({
|
||||
'role': 'assistant',
|
||||
'content': response.get('text', 'I have proposed actions that require your approval.'),
|
||||
})
|
||||
except Exception:
|
||||
messages_json.append({
|
||||
'role': 'assistant',
|
||||
'content': 'I have proposed actions that require your approval. Please review the pending items above.',
|
||||
})
|
||||
break
|
||||
else:
|
||||
assistant_text = response.get('text', '')
|
||||
messages_json.append({'role': 'assistant', 'content': assistant_text})
|
||||
@@ -210,7 +263,7 @@ class FusionAccountingAgent(models.AbstractModel):
|
||||
'message_ids_json': json.dumps(messages_json),
|
||||
'token_count_in': session.token_count_in + total_tokens_in,
|
||||
'token_count_out': session.token_count_out + total_tokens_out,
|
||||
'ai_provider': self._get_config('ai_provider', 'claude'),
|
||||
'ai_provider': provider,
|
||||
'ai_model': adapter._get_model_name(),
|
||||
})
|
||||
|
||||
@@ -249,6 +302,15 @@ class FusionAccountingAgent(models.AbstractModel):
|
||||
if history.rule_id:
|
||||
history.rule_id.sudo()._record_decision(approved=True)
|
||||
|
||||
# C1: Update session messages_json so next chat turn has coherent history
|
||||
self._update_session_after_decision(history, result)
|
||||
|
||||
# M8: Trigger promotion check after approval
|
||||
try:
|
||||
self.env['fusion.accounting.scoring'].check_promotions()
|
||||
except Exception:
|
||||
_logger.exception("Error checking promotions after approval")
|
||||
|
||||
return result
|
||||
|
||||
def _check_rule_proposal(self, tool_name, params, session):
|
||||
@@ -312,4 +374,133 @@ class FusionAccountingAgent(models.AbstractModel):
|
||||
if history.rule_id:
|
||||
history.rule_id.sudo()._record_decision(approved=False)
|
||||
|
||||
return {'status': 'rejected', 'reason': reason}
|
||||
# C1: Update session messages_json so next chat turn has coherent history
|
||||
reject_result = {'status': 'rejected', 'reason': reason}
|
||||
self._update_session_after_decision(history, reject_result)
|
||||
|
||||
return reject_result
|
||||
|
||||
def _update_session_after_decision(self, history, result):
|
||||
"""Update session messages_json to replace pending_approval placeholder
|
||||
with actual tool result, preventing dangling tool_use blocks."""
|
||||
session = history.session_id
|
||||
if not session or not session.message_ids_json:
|
||||
return
|
||||
try:
|
||||
messages = json.loads(session.message_ids_json)
|
||||
result_str = json.dumps(result) if not isinstance(result, str) else result
|
||||
updated = False
|
||||
for msg in messages:
|
||||
if msg.get('role') != 'user':
|
||||
continue
|
||||
content = msg.get('content')
|
||||
if isinstance(content, list):
|
||||
for block in content:
|
||||
if (isinstance(block, dict) and block.get('type') == 'tool_result'
|
||||
and 'pending_approval' in str(block.get('content', ''))):
|
||||
# Check if this is the matching tool_result block
|
||||
if str(history.id) in str(block.get('content', '')):
|
||||
block['content'] = result_str
|
||||
updated = True
|
||||
break
|
||||
if updated:
|
||||
break
|
||||
if updated:
|
||||
session.write({'message_ids_json': json.dumps(messages)})
|
||||
except Exception:
|
||||
_logger.warning("Failed to update session messages after decision for history %s", history.id)
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Cron: Auto-Reconcile Inter-Account Transfers
|
||||
# ----------------------------------------------------------------
|
||||
@api.model
|
||||
def _cron_reconcile_transfers(self):
|
||||
"""Automatically reconcile inter-account credit card payments.
|
||||
|
||||
When a payment is made from a bank account (e.g. Scotia Current) to a
|
||||
credit card (e.g. Scotia Passport Visa), two bank statement lines appear:
|
||||
- Source side: "MB-CREDIT CARD" (negative) — reconciled by model 38/35
|
||||
- CC side: "PAYMENT FROM *7814" (positive) — needs matching
|
||||
|
||||
The source-side reconciliation creates outstanding entries on account 493.
|
||||
This cron matches the CC-side lines against those outstanding entries by
|
||||
exact amount and closest date (within 3 days).
|
||||
"""
|
||||
AML = self.env['account.move.line'].sudo()
|
||||
BSL = self.env['account.bank.statement.line'].sudo()
|
||||
company_partner_id = self.env.company.partner_id.id
|
||||
|
||||
total_reconciled = 0
|
||||
|
||||
for source_jid, cc_jid, outstanding_acct_id in TRANSFER_PAIRS:
|
||||
# Find all unreconciled INCOMING lines on the credit card journal
|
||||
cc_lines = BSL.search([
|
||||
('journal_id', '=', cc_jid),
|
||||
('is_reconciled', '=', False),
|
||||
('amount', '>', 0), # Incoming payments only
|
||||
('company_id', '=', self.env.company.id),
|
||||
])
|
||||
if not cc_lines:
|
||||
continue
|
||||
|
||||
journal_name = cc_lines[0].journal_id.name
|
||||
_logger.info(
|
||||
"Transfer reconcile: %s — %d incoming unreconciled lines",
|
||||
journal_name, len(cc_lines),
|
||||
)
|
||||
|
||||
reconciled = 0
|
||||
skipped = 0
|
||||
|
||||
for line in cc_lines:
|
||||
line_date = line.move_id.date
|
||||
amount = line.amount
|
||||
|
||||
# Find outstanding entries with exact matching amount
|
||||
candidates = AML.search([
|
||||
('account_id', '=', outstanding_acct_id),
|
||||
('partner_id', '=', company_partner_id),
|
||||
('reconciled', '=', False),
|
||||
('amount_residual', '=', amount),
|
||||
])
|
||||
|
||||
if not candidates:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Pick the candidate closest in date (within 3 days)
|
||||
best = None
|
||||
best_gap = 999
|
||||
for c in candidates:
|
||||
gap = abs((c.date - line_date).days)
|
||||
if gap < best_gap:
|
||||
best_gap = gap
|
||||
best = c
|
||||
|
||||
if best_gap > 7:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Set partner and reconcile
|
||||
try:
|
||||
line.partner_id = company_partner_id
|
||||
line.set_line_bank_statement_line(best.ids)
|
||||
reconciled += 1
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"Transfer reconcile failed: line %s (%s, $%.2f): %s",
|
||||
line.id, line.payment_ref, amount, e,
|
||||
)
|
||||
|
||||
# Commit every 50 lines to avoid long transactions
|
||||
if reconciled % 50 == 0 and reconciled > 0:
|
||||
self.env.cr.commit()
|
||||
|
||||
self.env.cr.commit()
|
||||
total_reconciled += reconciled
|
||||
_logger.info(
|
||||
"Transfer reconcile: %s — reconciled %d, skipped %d",
|
||||
journal_name, reconciled, skipped,
|
||||
)
|
||||
|
||||
_logger.info("Transfer reconcile complete: %d total reconciled", total_reconciled)
|
||||
|
||||
@@ -18,6 +18,54 @@ You are helping with Canadian HST/GST tax management.
|
||||
- Net HST = Collected - ITCs. Positive means owing to CRA.
|
||||
- Quarterly filing periods. Check for missing tax on invoices/bills.
|
||||
- All vendor bills should have ITCs unless explicitly exempt.
|
||||
- HST Purchase tax ID is 20 (13%). No Tax Purchase ID is 32 (0%).
|
||||
|
||||
HST FILING WORKFLOW (4 phases — follow this order):
|
||||
|
||||
PHASE 1 — REPORTS: Run all at once:
|
||||
calculate_hst_balance, get_tax_report, find_missing_itc_bills,
|
||||
find_missing_tax_invoices, audit_tax_compliance.
|
||||
Present summary with HST position (owing vs refund).
|
||||
|
||||
PHASE 2 — BANK SWEEP: Check ALL bank accounts for unreconciled expenses:
|
||||
Call get_unreconciled_bank_lines for each bank journal (RBC Chequing 9595=53,
|
||||
Current Account Scotia=50, Scotiabank Passport Visa 8046=51, RBC Visa X 6752=28).
|
||||
Present ALL unreconciled expense lines (negative amounts) as a fusion-table
|
||||
with your recommendation per row.
|
||||
|
||||
PHASE 3 — PER-LINE PROCESSING: For each flagged expense line:
|
||||
0. FIRST: check_recurring_pattern(line_id=X) — if match found, follow action_note
|
||||
instructions EXACTLY (account, HST, partner, reconcile model). No user input needed
|
||||
for recurring payments. If a reconcile_model_id is returned, use apply_reconcile_model.
|
||||
1. get_bank_line_details — check if a vendor bill already exists for same amount/date
|
||||
2. find_similar_bank_lines — check history AND vendor_tax_pattern for coding/tax pattern
|
||||
3. CRITICAL: Check vendor_tax_pattern.is_po_vendor flag:
|
||||
- If is_po_vendor=true: This vendor's bills come from Purchase Orders. Do NOT create
|
||||
a new bill. Instead, use get_unpaid_bills to find the existing bill and propose
|
||||
match_bank_line_to_payments to match the bank payment to that bill.
|
||||
- If is_po_vendor=false: Proceed with bill creation workflow below.
|
||||
4. If bill already exists → propose match_bank_line_to_payments
|
||||
5. If no bill but history match → propose create_vendor_bill with same coding pattern
|
||||
6. If no bill and no history → ask user: "Does this expense include HST?"
|
||||
7. search_partners — find the vendor by keyword from the bank description
|
||||
8. Once confirmed → create_vendor_bill + register_bill_payment (Tier 3, needs approval)
|
||||
9. Alternative: user can choose "Direct GL" → create_expense_entry (Tier 3)
|
||||
For expenses that obviously have no HST (bank fees, interest charges, insurance),
|
||||
proactively recommend "No HST" and explain why.
|
||||
|
||||
PO-TRACKED VENDORS (do NOT create bills for these — bills come from Purchase Orders):
|
||||
When find_similar_bank_lines returns is_po_vendor=true or the vendor_tax_pattern
|
||||
note starts with "PO-TRACKED VENDOR", the bill already exists or will be created
|
||||
from a PO. Your job is ONLY to find the existing unpaid bill and match the bank
|
||||
payment to it. If no unpaid bill exists, flag it for the user: "This is a PO vendor
|
||||
but no matching bill was found — the PO may not have been billed yet."
|
||||
|
||||
PHASE 4 — VERIFICATION: Re-run calculate_hst_balance and get_tax_report
|
||||
to show the updated HST position after all expenses are recorded.
|
||||
|
||||
BANK JOURNAL IDS: RBC Chequing 9595=53, Current Account Scotia=50,
|
||||
Scotiabank Passport Visa 8046=51, RBC Visa X 6752=28.
|
||||
MISC JOURNAL: ID=3 (for direct GL expense entries).
|
||||
""",
|
||||
|
||||
'accounts_receivable': """
|
||||
@@ -105,5 +153,36 @@ PAYROLL MANAGEMENT CONTEXT:
|
||||
}
|
||||
|
||||
|
||||
# A3/A5: Aliases so common domain variations still match a prompt
|
||||
DOMAIN_ALIASES = {
|
||||
'bank': 'bank_reconciliation',
|
||||
'bank_recon': 'bank_reconciliation',
|
||||
'hst': 'hst_management',
|
||||
'gst': 'hst_management',
|
||||
'tax': 'hst_management',
|
||||
'ar': 'accounts_receivable',
|
||||
'receivable': 'accounts_receivable',
|
||||
'ap': 'accounts_payable',
|
||||
'payable': 'accounts_payable',
|
||||
'journal': 'journal_review',
|
||||
'close': 'month_end',
|
||||
'month_end_close': 'month_end',
|
||||
'payroll': 'payroll_management',
|
||||
'payroll_verify': 'payroll_verification',
|
||||
'stock': 'inventory',
|
||||
'cogs': 'inventory',
|
||||
'report': 'reporting',
|
||||
'reports': 'reporting',
|
||||
'financial': 'reporting',
|
||||
}
|
||||
|
||||
|
||||
def get_domain_prompt(domain):
|
||||
return DOMAIN_PROMPTS.get(domain, '')
|
||||
if not domain:
|
||||
return ''
|
||||
# Try exact match first, then aliases
|
||||
prompt = DOMAIN_PROMPTS.get(domain, '')
|
||||
if not prompt:
|
||||
resolved = DOMAIN_ALIASES.get(domain, domain)
|
||||
prompt = DOMAIN_PROMPTS.get(resolved, '')
|
||||
return prompt
|
||||
|
||||
@@ -31,12 +31,56 @@ RESPONSE FORMATTING:
|
||||
- Use rich Markdown formatting in your responses. The chat renders Markdown as HTML.
|
||||
- Use **bold** for account names, amounts, and key terms.
|
||||
- Use ## and ### headers to organize sections in longer responses.
|
||||
- Use Markdown tables for tabular data (| col1 | col2 | format).
|
||||
- Use bullet lists (- item) for findings, issues, and action items.
|
||||
- Use numbered lists (1. item) for sequential steps or ranked items.
|
||||
- Use `code` for account codes, reference numbers, and technical IDs.
|
||||
- Use --- horizontal rules to separate sections in long reports.
|
||||
|
||||
INTERACTIVE TABLES (fusion-table) — MANDATORY FOR ACTIONABLE DATA:
|
||||
IMPORTANT: When a tool returns a list of records that the user could act on, you MUST use
|
||||
a ```fusion-table block instead of a Markdown table. This is REQUIRED — never use plain
|
||||
Markdown tables for actionable data. The fusion-table renders an interactive widget with
|
||||
checkboxes, your AI recommendations per row, user input fields, and bulk action buttons.
|
||||
|
||||
YOU MUST USE fusion-table FOR: missing ITCs/tax (find_missing_itc_bills, find_missing_tax_invoices),
|
||||
duplicate entries (find_duplicate_bills, find_duplicate_entries), overdue invoices (get_overdue_invoices),
|
||||
unreconciled lines (get_unreconciled_bank_lines, get_unreconciled_receipts, get_unmatched_payments,
|
||||
find_unreconciled_suspense), draft entries (find_draft_entries), wrong balances
|
||||
(find_wrong_direction_balances), sequence gaps (find_sequence_gaps), wrong accounts
|
||||
(find_wrong_account_entries), unpaid bills (get_unpaid_bills), and any other list where
|
||||
the user needs to review, dismiss, flag, or create rules for individual rows.
|
||||
|
||||
USE REGULAR MARKDOWN TABLES ONLY FOR: P&L (get_profit_loss), balance sheet (get_balance_sheet),
|
||||
trial balance (get_trial_balance), cash flow (get_cash_flow), period summaries, tax reports,
|
||||
and any purely informational/read-only data where there is nothing to act on per row.
|
||||
|
||||
Format: wrap a JSON object in a ```fusion-table fenced code block:
|
||||
|
||||
```fusion-table
|
||||
{
|
||||
"mode": "interactive",
|
||||
"title": "Descriptive Title",
|
||||
"columns": ["Col1", "Col2", "Col3"],
|
||||
"rows": [
|
||||
{"id": 123, "cells": ["val1", "val2", "val3"], "recommendation": {"action": "dismiss", "reason": "Brief explanation"}},
|
||||
{"id": 456, "cells": ["val1", "val2", "val3"], "recommendation": {"action": "flag", "reason": "Brief explanation"}}
|
||||
],
|
||||
"actions": ["dismiss", "flag", "create_rule"],
|
||||
"source_tool": "tool_name_that_produced_this"
|
||||
}
|
||||
```
|
||||
|
||||
- "mode": "interactive" (actionable) or "readonly" (informational but structured)
|
||||
- "id": the Odoo record ID (account.move id, account.bank.statement.line id, etc.)
|
||||
- "recommendation.action": one of "dismiss", "flag", "create_rule"
|
||||
- "recommendation.reason": short explanation of why you recommend this action
|
||||
- "actions": which bulk action buttons to show
|
||||
- "source_tool": the tool name that produced the data
|
||||
- You MUST provide a recommendation for each row when using interactive mode.
|
||||
- Format monetary amounts as "$X,XXX.XX" in cells.
|
||||
- Always include the record ID so actions can target the correct Odoo record.
|
||||
- Add a brief text summary before or after the fusion-table block for context.
|
||||
|
||||
LINKING TO ODOO RECORDS:
|
||||
- When referencing specific records, include clickable Odoo links.
|
||||
- Journal entries: [INV/2026/00123](/odoo/accounting/123) where 123 is the move ID.
|
||||
@@ -60,12 +104,14 @@ def _build_rules_section(rules):
|
||||
for rule in rules:
|
||||
priority = 'ADMIN' if rule.created_by == 'admin' else 'AI'
|
||||
tier = 'auto' if rule.approval_tier == 'auto' else 'needs-approval'
|
||||
conf_str = f', confidence={rule.confidence_score:.0%}, uses={rule.total_uses}' if rule.total_uses > 0 else ''
|
||||
lines.append(
|
||||
f'- [{priority}/{tier}] {rule.name} ({rule.rule_type}): '
|
||||
f'- [{priority}/{tier}{conf_str}] {rule.name} ({rule.rule_type}): '
|
||||
f'{rule.description or rule.match_logic or "No description"}'
|
||||
)
|
||||
if rule.match_logic:
|
||||
lines.append(f' Match logic: {rule.match_logic}')
|
||||
logic_text = rule.match_logic[:500] # Prevent prompt bloat
|
||||
lines.append(f' Match logic: {logic_text}')
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
@@ -73,7 +119,9 @@ def _build_history_section(history):
|
||||
if not history:
|
||||
return ''
|
||||
lines = ['RECENT MATCH HISTORY (learn from these patterns):']
|
||||
for h in history[:50]:
|
||||
# A4: Don't hard-cap at 50 — the caller (_load_match_history) already
|
||||
# respects the history_in_prompt config setting
|
||||
for h in history:
|
||||
status = h.decision
|
||||
reason = ''
|
||||
if h.rejection_reason:
|
||||
|
||||
@@ -140,6 +140,258 @@ def get_payment_schedule(env, params):
|
||||
}
|
||||
|
||||
|
||||
def search_partners(env, params):
|
||||
"""Search for partners/vendors by name keyword."""
|
||||
keyword = params.get('keyword', '')
|
||||
if not keyword or len(keyword) < 2:
|
||||
return {'error': 'Keyword must be at least 2 characters'}
|
||||
domain = [('name', 'ilike', keyword), ('company_id', 'in', [env.company.id, False])]
|
||||
if params.get('supplier_only'):
|
||||
domain.append(('supplier_rank', '>', 0))
|
||||
partners = env['res.partner'].search(domain, limit=int(params.get('limit', 20)))
|
||||
return {
|
||||
'count': len(partners),
|
||||
'partners': [{
|
||||
'id': p.id,
|
||||
'name': p.name,
|
||||
'supplier_rank': p.supplier_rank,
|
||||
'customer_rank': p.customer_rank,
|
||||
'vat': p.vat or '',
|
||||
'email': p.email or '',
|
||||
'phone': p.phone or '',
|
||||
} for p in partners],
|
||||
}
|
||||
|
||||
|
||||
def find_similar_bank_lines(env, params):
|
||||
"""Find past reconciled bank lines with similar description to suggest coding patterns.
|
||||
Also checks vendor bill tax patterns if a partner is identified."""
|
||||
keyword = params.get('keyword', '')
|
||||
if not keyword or len(keyword) < 3:
|
||||
return {'error': 'Keyword must be at least 3 characters'}
|
||||
# Find reconciled bank lines with matching payment_ref
|
||||
lines = env['account.bank.statement.line'].search([
|
||||
('is_reconciled', '=', True),
|
||||
('payment_ref', 'ilike', keyword),
|
||||
('company_id', '=', env.company.id),
|
||||
], order='date desc', limit=int(params.get('limit', 10)))
|
||||
|
||||
matches = []
|
||||
found_partner_id = None
|
||||
for line in lines:
|
||||
move = line.move_id
|
||||
if not move:
|
||||
continue
|
||||
expense_info = {'account_code': '', 'account_name': '', 'tax_applied': False, 'tax_amount': 0.0}
|
||||
for ml in move.line_ids:
|
||||
if ml.account_id.account_type in ('expense', 'expense_direct_cost', 'expense_depreciation'):
|
||||
expense_info['account_code'] = ml.account_id.code
|
||||
expense_info['account_name'] = ml.account_id.name
|
||||
expense_info['tax_applied'] = bool(ml.tax_ids)
|
||||
expense_info['tax_amount'] = sum(t.amount for t in ml.tax_ids) if ml.tax_ids else 0.0
|
||||
break
|
||||
if line.partner_id and not found_partner_id:
|
||||
found_partner_id = line.partner_id.id
|
||||
matches.append({
|
||||
'id': line.id,
|
||||
'date': str(line.date),
|
||||
'payment_ref': line.payment_ref or '',
|
||||
'amount': line.amount,
|
||||
'partner': line.partner_id.name if line.partner_id else '',
|
||||
'partner_id': line.partner_id.id if line.partner_id else None,
|
||||
'expense_account': expense_info['account_code'],
|
||||
'expense_account_name': expense_info['account_name'],
|
||||
'tax_applied': expense_info['tax_applied'],
|
||||
'tax_rate': expense_info['tax_amount'],
|
||||
})
|
||||
|
||||
result = {
|
||||
'keyword': keyword,
|
||||
'count': len(matches),
|
||||
'matches': matches,
|
||||
'suggestion': matches[0] if matches else None,
|
||||
}
|
||||
|
||||
# Check vendor tax profile cache first (fast), fall back to live query
|
||||
partner_id = found_partner_id or (int(params['partner_id']) if params.get('partner_id') else None)
|
||||
if partner_id:
|
||||
profile = env['fusion.vendor.tax.profile'].search([
|
||||
('partner_id', '=', partner_id),
|
||||
('company_id', '=', env.company.id),
|
||||
], limit=1)
|
||||
if profile:
|
||||
result['vendor_tax_pattern'] = {
|
||||
'source': 'cached_profile',
|
||||
'total_bills': profile.total_bills,
|
||||
'bills_with_tax': profile.bills_with_hst,
|
||||
'bills_no_tax': profile.bills_zero_rated,
|
||||
'avg_tax_pct': profile.avg_tax_pct,
|
||||
'tax_classification': profile.tax_classification,
|
||||
'tax_note': profile.tax_note,
|
||||
'primary_account_id': profile.primary_account_id.id if profile.primary_account_id else None,
|
||||
'primary_account_code': profile.primary_account_code or '',
|
||||
'is_foreign': profile.is_foreign,
|
||||
'is_po_vendor': profile.is_po_vendor,
|
||||
'po_count': profile.po_count,
|
||||
}
|
||||
else:
|
||||
# No cached profile — live query for new/small vendors
|
||||
bills = env['account.move'].search([
|
||||
('move_type', '=', 'in_invoice'), ('state', '=', 'posted'),
|
||||
('partner_id', '=', partner_id),
|
||||
], order='date desc', limit=10)
|
||||
tax_stats = {'source': 'live_query', 'total_bills': len(bills),
|
||||
'bills_with_tax': 0, 'bills_no_tax': 0,
|
||||
'avg_tax_pct': 0.0, 'tax_note': ''}
|
||||
tax_pcts = []
|
||||
for bill in bills:
|
||||
if bill.amount_tax > 0.01:
|
||||
tax_stats['bills_with_tax'] += 1
|
||||
if bill.amount_untaxed > 0:
|
||||
tax_pcts.append(round(bill.amount_tax / bill.amount_untaxed * 100, 2))
|
||||
else:
|
||||
tax_stats['bills_no_tax'] += 1
|
||||
if tax_pcts:
|
||||
tax_stats['avg_tax_pct'] = round(sum(tax_pcts) / len(tax_pcts), 2)
|
||||
if tax_stats['total_bills'] > 0:
|
||||
if tax_stats['bills_no_tax'] == tax_stats['total_bills']:
|
||||
tax_stats['tax_note'] = 'This vendor NEVER charges HST. All bills are zero-rated.'
|
||||
elif tax_stats['avg_tax_pct'] < 2.0 and tax_stats['bills_with_tax'] > 0:
|
||||
tax_stats['tax_note'] = (
|
||||
f'HST only on shipping (avg {tax_stats["avg_tax_pct"]}%). '
|
||||
f'Do NOT apply HST to full amount.'
|
||||
)
|
||||
elif tax_stats['avg_tax_pct'] >= 12.0:
|
||||
tax_stats['tax_note'] = f'Consistently charges HST at ~{tax_stats["avg_tax_pct"]}%.'
|
||||
result['vendor_tax_pattern'] = tax_stats
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def create_vendor_bill(env, params):
|
||||
"""[Tier 3] Create a vendor bill (account.move with move_type='in_invoice').
|
||||
Requires user approval before execution."""
|
||||
partner_id = int(params['partner_id'])
|
||||
invoice_date = params.get('invoice_date', str(fields.Date.today()))
|
||||
bill_lines = params.get('lines', [])
|
||||
if not bill_lines:
|
||||
return {'error': 'At least one invoice line is required'}
|
||||
|
||||
partner = env['res.partner'].browse(partner_id)
|
||||
if not partner.exists():
|
||||
return {'error': f'Partner not found: {partner_id}'}
|
||||
|
||||
invoice_line_vals = []
|
||||
for line in bill_lines:
|
||||
line_vals = {
|
||||
'name': line.get('description', 'Expense'),
|
||||
'price_unit': float(line.get('price_unit', 0)),
|
||||
'quantity': float(line.get('quantity', 1)),
|
||||
}
|
||||
if line.get('account_id'):
|
||||
line_vals['account_id'] = int(line['account_id'])
|
||||
if line.get('tax_ids'):
|
||||
line_vals['tax_ids'] = [(6, 0, [int(t) for t in line['tax_ids']])]
|
||||
invoice_line_vals.append((0, 0, line_vals))
|
||||
|
||||
try:
|
||||
bill = env['account.move'].create({
|
||||
'move_type': 'in_invoice',
|
||||
'partner_id': partner_id,
|
||||
'invoice_date': invoice_date,
|
||||
'date': invoice_date,
|
||||
'invoice_line_ids': invoice_line_vals,
|
||||
'company_id': env.company.id,
|
||||
})
|
||||
|
||||
if params.get('post', False):
|
||||
bill.action_post()
|
||||
|
||||
return {
|
||||
'status': 'created',
|
||||
'bill_id': bill.id,
|
||||
'bill_name': bill.name,
|
||||
'partner': partner.name,
|
||||
'amount_total': bill.amount_total,
|
||||
'state': bill.state,
|
||||
}
|
||||
except Exception as e:
|
||||
_logger.error("Failed to create vendor bill: %s", e)
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
def register_bill_payment(env, params):
|
||||
"""[Tier 3] Register payment on a posted vendor bill and optionally reconcile to bank line.
|
||||
Requires user approval before execution."""
|
||||
bill_id = int(params['bill_id'])
|
||||
journal_id = int(params['journal_id'])
|
||||
bill = env['account.move'].browse(bill_id)
|
||||
if not bill.exists() or bill.state != 'posted':
|
||||
return {'error': 'Bill not found or not posted'}
|
||||
|
||||
payment_date = params.get('payment_date', str(fields.Date.today()))
|
||||
|
||||
try:
|
||||
# Use the payment register wizard
|
||||
ctx = {
|
||||
'active_model': 'account.move',
|
||||
'active_ids': [bill_id],
|
||||
}
|
||||
wizard = env['account.payment.register'].with_context(**ctx).create({
|
||||
'journal_id': journal_id,
|
||||
'payment_date': payment_date,
|
||||
})
|
||||
# Optionally set amount if provided (otherwise defaults to bill amount)
|
||||
if params.get('amount'):
|
||||
wizard.amount = float(params['amount'])
|
||||
|
||||
payments = wizard.action_create_payments()
|
||||
|
||||
# Find the created payment
|
||||
payment = None
|
||||
if isinstance(payments, dict) and payments.get('res_id'):
|
||||
payment = env['account.payment'].browse(payments['res_id'])
|
||||
elif isinstance(payments, dict) and payments.get('domain'):
|
||||
payment = env['account.payment'].search(payments['domain'], limit=1)
|
||||
else:
|
||||
# Fallback: find the latest payment for this bill
|
||||
payment = env['account.payment'].search([
|
||||
('partner_id', '=', bill.partner_id.id),
|
||||
], order='create_date desc', limit=1)
|
||||
|
||||
result = {
|
||||
'status': 'paid',
|
||||
'bill_id': bill_id,
|
||||
'bill_name': bill.name,
|
||||
'payment_state': bill.payment_state,
|
||||
}
|
||||
if payment:
|
||||
result['payment_id'] = payment.id
|
||||
result['payment_name'] = payment.name
|
||||
|
||||
# Optionally reconcile to a bank statement line
|
||||
if params.get('statement_line_id') and payment:
|
||||
try:
|
||||
st_line = env['account.bank.statement.line'].browse(int(params['statement_line_id']))
|
||||
if st_line.exists() and not st_line.is_reconciled:
|
||||
# Find the payment's move lines on the bank's outstanding account
|
||||
pay_move_lines = payment.move_id.line_ids.filtered(
|
||||
lambda l: l.account_id.reconcile and not l.reconciled
|
||||
)
|
||||
if pay_move_lines:
|
||||
st_line.set_line_bank_statement_line(pay_move_lines.ids)
|
||||
result['reconciled'] = True
|
||||
result['statement_line_id'] = st_line.id
|
||||
except Exception as e:
|
||||
_logger.warning("Payment created but bank reconciliation failed: %s", e)
|
||||
result['reconcile_error'] = str(e)
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
_logger.error("Failed to register payment: %s", e)
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
TOOLS = {
|
||||
'get_ap_aging': get_ap_aging,
|
||||
'find_duplicate_bills': find_duplicate_bills,
|
||||
@@ -147,4 +399,8 @@ TOOLS = {
|
||||
'get_unpaid_bills': get_unpaid_bills,
|
||||
'verify_bill_taxes': verify_bill_taxes,
|
||||
'get_payment_schedule': get_payment_schedule,
|
||||
'search_partners': search_partners,
|
||||
'find_similar_bank_lines': find_similar_bank_lines,
|
||||
'create_vendor_bill': create_vendor_bill,
|
||||
'register_bill_payment': register_bill_payment,
|
||||
}
|
||||
|
||||
@@ -69,7 +69,11 @@ def flag_entry(env, params):
|
||||
|
||||
|
||||
def get_audit_status(env, params):
|
||||
statuses = env['account.audit.account.status'].search([])
|
||||
try:
|
||||
AuditStatus = env['account.audit.account.status']
|
||||
except KeyError:
|
||||
return {'error': 'Audit status model (account.audit.account.status) is not available. The account_audit Enterprise module may not be installed.'}
|
||||
statuses = AuditStatus.search([])
|
||||
return {
|
||||
'statuses': [{
|
||||
'id': s.id,
|
||||
@@ -81,9 +85,13 @@ def get_audit_status(env, params):
|
||||
|
||||
|
||||
def set_audit_status(env, params):
|
||||
try:
|
||||
AuditStatus = env['account.audit.account.status']
|
||||
except KeyError:
|
||||
return {'error': 'Audit status model (account.audit.account.status) is not available. The account_audit Enterprise module may not be installed.'}
|
||||
status_id = int(params['status_id'])
|
||||
new_status = params['status']
|
||||
rec = env['account.audit.account.status'].browse(status_id)
|
||||
rec = AuditStatus.browse(status_id)
|
||||
if not rec.exists():
|
||||
return {'error': 'Audit status record not found'}
|
||||
rec.status = new_status
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from odoo import fields
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -139,6 +140,10 @@ def get_reconcile_suggestions(env, params):
|
||||
|
||||
|
||||
def sum_payments_by_date(env, params):
|
||||
"""Sum payment/journal activity for a date range.
|
||||
IMPORTANT: Always pass journal_ids to filter to specific journals.
|
||||
Without journal_ids, returns totals across ALL journals which is
|
||||
almost never what you want for reconciliation."""
|
||||
date_from = params.get('date_from')
|
||||
date_to = params.get('date_to')
|
||||
if not date_from or not date_to:
|
||||
@@ -150,18 +155,332 @@ def sum_payments_by_date(env, params):
|
||||
('date', '>=', date_from),
|
||||
('date', '<=', date_to),
|
||||
]
|
||||
scope = 'all journals'
|
||||
if journal_ids:
|
||||
domain.append(('journal_id', 'in', [int(j) for j in journal_ids]))
|
||||
jids = [int(j) for j in journal_ids]
|
||||
domain.append(('journal_id', 'in', jids))
|
||||
journals = env['account.journal'].browse(jids)
|
||||
scope = ', '.join(j.name for j in journals if j.exists())
|
||||
else:
|
||||
# Without journal filter, include a warning and break down by journal
|
||||
pass
|
||||
|
||||
lines = env['account.move.line'].search(domain)
|
||||
total_debit = sum(l.debit for l in lines)
|
||||
total_credit = sum(l.credit for l in lines)
|
||||
return {
|
||||
|
||||
result = {
|
||||
'date_from': date_from,
|
||||
'date_to': date_to,
|
||||
'total_debit': total_debit,
|
||||
'total_credit': total_credit,
|
||||
'net': total_debit - total_credit,
|
||||
'line_count': len(lines),
|
||||
'scope': scope,
|
||||
}
|
||||
|
||||
# If no journal filter, add per-journal breakdown so AI doesn't
|
||||
# mistake company-wide totals for a specific journal's activity
|
||||
if not journal_ids:
|
||||
result['warning'] = (
|
||||
'No journal_ids filter was provided. These totals are across ALL '
|
||||
'journals in the company. To get card payment totals, pass the '
|
||||
'specific card/POS journal IDs.'
|
||||
)
|
||||
journal_totals = {}
|
||||
for l in lines:
|
||||
jname = l.journal_id.name
|
||||
if jname not in journal_totals:
|
||||
journal_totals[jname] = {'debit': 0.0, 'credit': 0.0, 'count': 0}
|
||||
journal_totals[jname]['debit'] += l.debit
|
||||
journal_totals[jname]['credit'] += l.credit
|
||||
journal_totals[jname]['count'] += 1
|
||||
result['by_journal'] = [
|
||||
{'journal': jn, 'debit': v['debit'], 'credit': v['credit'], 'count': v['count']}
|
||||
for jn, v in sorted(journal_totals.items(), key=lambda x: -x[1]['debit'])
|
||||
][:15]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_bank_line_details(env, params):
|
||||
"""Get full details of a single bank statement line plus matching suggestions."""
|
||||
line_id = int(params['line_id'])
|
||||
line = env['account.bank.statement.line'].browse(line_id)
|
||||
if not line.exists():
|
||||
return {'error': 'Bank statement line not found'}
|
||||
|
||||
result = {
|
||||
'id': line.id,
|
||||
'date': str(line.date),
|
||||
'payment_ref': line.payment_ref or '',
|
||||
'partner_name': line.partner_name or (line.partner_id.name if line.partner_id else ''),
|
||||
'partner_id': line.partner_id.id if line.partner_id else None,
|
||||
'amount': line.amount,
|
||||
'journal': line.journal_id.name,
|
||||
'journal_id': line.journal_id.id,
|
||||
'is_reconciled': line.is_reconciled,
|
||||
'existing_bills': [],
|
||||
'suggested_partner': None,
|
||||
}
|
||||
|
||||
# Search for existing vendor bills matching amount ± $0.50 and date ± 3 days
|
||||
abs_amount = abs(line.amount)
|
||||
from datetime import timedelta as td
|
||||
date_from = line.date - td(days=3)
|
||||
date_to = line.date + td(days=3)
|
||||
matching_bills = env['account.move'].search([
|
||||
('move_type', '=', 'in_invoice'),
|
||||
('state', '=', 'posted'),
|
||||
('amount_total', '>=', abs_amount - 0.50),
|
||||
('amount_total', '<=', abs_amount + 0.50),
|
||||
('date', '>=', str(date_from)),
|
||||
('date', '<=', str(date_to)),
|
||||
('company_id', '=', env.company.id),
|
||||
], limit=5)
|
||||
for bill in matching_bills:
|
||||
result['existing_bills'].append({
|
||||
'id': bill.id,
|
||||
'name': bill.name,
|
||||
'partner': bill.partner_id.name if bill.partner_id else '',
|
||||
'amount_total': bill.amount_total,
|
||||
'date': str(bill.date),
|
||||
'payment_state': bill.payment_state,
|
||||
})
|
||||
|
||||
# Try to suggest a partner from payment_ref keyword
|
||||
if line.payment_ref and not line.partner_id:
|
||||
# Extract meaningful words from payment_ref (skip common banking terms)
|
||||
skip_words = {'misc', 'payment', 'online', 'banking', 'pad', 'business',
|
||||
'deposit', 'cheque', 'transfer', 'e-transfer', 'sent', 'autodeposit'}
|
||||
words = [w for w in line.payment_ref.split() if len(w) > 2 and w.lower() not in skip_words]
|
||||
for word in words[:3]:
|
||||
partners = env['res.partner'].search([
|
||||
('name', 'ilike', word),
|
||||
('supplier_rank', '>', 0),
|
||||
], limit=3)
|
||||
if partners:
|
||||
result['suggested_partner'] = {
|
||||
'id': partners[0].id,
|
||||
'name': partners[0].name,
|
||||
'match_word': word,
|
||||
}
|
||||
break
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def check_recurring_pattern(env, params):
|
||||
"""Check if a bank line matches a known recurring payment pattern.
|
||||
Returns the historical coding (account, HST, partner, reconcile model) if found."""
|
||||
line_id = params.get('line_id')
|
||||
payment_ref = params.get('payment_ref', '')
|
||||
amount = params.get('amount')
|
||||
|
||||
# If line_id provided, get the ref and amount from the line
|
||||
if line_id:
|
||||
line = env['account.bank.statement.line'].browse(int(line_id))
|
||||
if line.exists():
|
||||
payment_ref = line.payment_ref or ''
|
||||
amount = line.amount
|
||||
|
||||
if not payment_ref:
|
||||
return {'match': False, 'reason': 'No payment reference to match'}
|
||||
|
||||
# Search cached patterns by keyword
|
||||
patterns = env['fusion.recurring.pattern'].search([
|
||||
('company_id', '=', env.company.id),
|
||||
])
|
||||
|
||||
best_match = None
|
||||
for pat in patterns:
|
||||
if not pat.ref_keyword:
|
||||
continue
|
||||
# Check if the pattern keyword appears in the payment_ref
|
||||
if pat.ref_keyword.lower()[:30] in payment_ref.lower():
|
||||
# If amount matches too, it's a strong match
|
||||
if amount and pat.amount_is_fixed and abs(pat.amount - amount) < 0.01:
|
||||
best_match = pat
|
||||
break
|
||||
# Keyword-only match (amount may vary)
|
||||
if not best_match or pat.occurrences > best_match.occurrences:
|
||||
best_match = pat
|
||||
|
||||
if not best_match:
|
||||
return {'match': False, 'payment_ref': payment_ref}
|
||||
|
||||
result = {
|
||||
'match': True,
|
||||
'pattern_id': best_match.id,
|
||||
'pattern_name': best_match.name,
|
||||
'occurrences': best_match.occurrences,
|
||||
'first_seen': str(best_match.first_seen) if best_match.first_seen else '',
|
||||
'last_seen': str(best_match.last_seen) if best_match.last_seen else '',
|
||||
'expense_account_id': best_match.expense_account_id.id if best_match.expense_account_id else None,
|
||||
'expense_account_code': best_match.expense_account_code or '',
|
||||
'expense_account_name': best_match.expense_account_id.name if best_match.expense_account_id else '',
|
||||
'has_hst': best_match.has_hst,
|
||||
'partner_id': best_match.partner_id.id if best_match.partner_id else None,
|
||||
'partner_name': best_match.partner_id.name if best_match.partner_id else '',
|
||||
'action_note': best_match.action_note or '',
|
||||
'amount_is_fixed': best_match.amount_is_fixed,
|
||||
}
|
||||
if best_match.reconcile_model_id:
|
||||
result['reconcile_model_id'] = best_match.reconcile_model_id.id
|
||||
result['reconcile_model_name'] = best_match.reconcile_model_id.name
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def match_internal_transfers(env, params):
|
||||
"""[Tier 3] Find and match inter-account transfers between two bank journals.
|
||||
Matches exact amounts within a date window. Only matches when there is exactly
|
||||
ONE candidate on each side (no ambiguous matches). Requires user approval.
|
||||
|
||||
Typical use: Scotia Current Account ↔ Scotia Visa payments."""
|
||||
journal_a_id = int(params['journal_a_id']) # e.g., Scotia Current (50)
|
||||
journal_b_id = int(params['journal_b_id']) # e.g., Scotia Visa (51)
|
||||
date_from = params.get('date_from', '2025-01-01')
|
||||
date_to = params.get('date_to', '2025-03-31')
|
||||
max_days_apart = int(params.get('max_days_apart', 2))
|
||||
|
||||
# Get unreconciled positive lines from both journals
|
||||
# (transfers show as positive on the RECEIVING side)
|
||||
lines_a = env['account.bank.statement.line'].search([
|
||||
('is_reconciled', '=', False),
|
||||
('journal_id', '=', journal_a_id),
|
||||
('company_id', '=', env.company.id),
|
||||
])
|
||||
lines_a = lines_a.filtered(
|
||||
lambda l: l.move_id.date >= fields.Date.from_string(date_from)
|
||||
and l.move_id.date <= fields.Date.from_string(date_to)
|
||||
and l.amount > 0 # money coming IN on this account
|
||||
)
|
||||
|
||||
lines_b = env['account.bank.statement.line'].search([
|
||||
('is_reconciled', '=', False),
|
||||
('journal_id', '=', journal_b_id),
|
||||
('company_id', '=', env.company.id),
|
||||
])
|
||||
lines_b = lines_b.filtered(
|
||||
lambda l: l.move_id.date >= fields.Date.from_string(date_from)
|
||||
and l.move_id.date <= fields.Date.from_string(date_to)
|
||||
and l.amount > 0 # money coming IN on this account
|
||||
)
|
||||
|
||||
matched_pairs = []
|
||||
used_a = set()
|
||||
used_b = set()
|
||||
|
||||
# For each line in A, find exact-amount match in B within date window
|
||||
for la in sorted(lines_a, key=lambda l: l.move_id.date):
|
||||
if la.id in used_a:
|
||||
continue
|
||||
candidates = []
|
||||
for lb in lines_b:
|
||||
if lb.id in used_b:
|
||||
continue
|
||||
if abs(la.amount - lb.amount) < 0.01:
|
||||
days = abs((la.move_id.date - lb.move_id.date).days)
|
||||
if days <= max_days_apart:
|
||||
candidates.append(lb)
|
||||
# Only match if EXACTLY ONE candidate — skip ambiguous
|
||||
if len(candidates) == 1:
|
||||
lb = candidates[0]
|
||||
matched_pairs.append({
|
||||
'line_a_id': la.id,
|
||||
'line_a_date': str(la.move_id.date),
|
||||
'line_a_ref': la.payment_ref or '',
|
||||
'line_a_journal': la.journal_id.name,
|
||||
'line_b_id': lb.id,
|
||||
'line_b_date': str(lb.move_id.date),
|
||||
'line_b_ref': lb.payment_ref or '',
|
||||
'line_b_journal': lb.journal_id.name,
|
||||
'amount': la.amount,
|
||||
'days_apart': abs((la.move_id.date - lb.move_id.date).days),
|
||||
})
|
||||
used_a.add(la.id)
|
||||
used_b.add(lb.id)
|
||||
|
||||
if not matched_pairs:
|
||||
return {
|
||||
'status': 'no_matches',
|
||||
'message': 'No unambiguous transfer pairs found.',
|
||||
'lines_a_checked': len(lines_a),
|
||||
'lines_b_checked': len(lines_b),
|
||||
}
|
||||
|
||||
# If this is just a dry-run check (no execute flag), return the pairs for review
|
||||
if not params.get('execute', False):
|
||||
return {
|
||||
'status': 'pairs_found',
|
||||
'count': len(matched_pairs),
|
||||
'pairs': matched_pairs,
|
||||
'message': f'Found {len(matched_pairs)} unambiguous transfer pairs. Set execute=true to reconcile them.',
|
||||
}
|
||||
|
||||
# Execute: create internal transfer journal entries to reconcile both sides
|
||||
reconciled = []
|
||||
for pair in matched_pairs:
|
||||
try:
|
||||
line_a = env['account.bank.statement.line'].browse(pair['line_a_id'])
|
||||
line_b = env['account.bank.statement.line'].browse(pair['line_b_id'])
|
||||
|
||||
# Create an internal transfer payment
|
||||
payment = env['account.payment'].create({
|
||||
'payment_type': 'outbound',
|
||||
'partner_type': 'supplier',
|
||||
'partner_id': env.company.partner_id.id, # Self as partner for internal transfer
|
||||
'amount': pair['amount'],
|
||||
'journal_id': journal_a_id,
|
||||
'destination_journal_id': journal_b_id,
|
||||
'date': line_a.move_id.date,
|
||||
'ref': f'Internal Transfer: {pair["line_a_ref"]} ↔ {pair["line_b_ref"]}',
|
||||
'is_internal_transfer': True,
|
||||
})
|
||||
payment.action_post()
|
||||
|
||||
# Now match the payment's move lines to the bank statement lines
|
||||
# The payment creates lines on both journals' outstanding accounts
|
||||
for move_line in payment.move_id.line_ids:
|
||||
if move_line.journal_id.id == journal_a_id and not move_line.reconciled:
|
||||
try:
|
||||
line_a.set_line_bank_statement_line(move_line.ids)
|
||||
except Exception:
|
||||
pass
|
||||
# Check paired transfer for the other side
|
||||
if payment.paired_internal_transfer_payment_id:
|
||||
paired = payment.paired_internal_transfer_payment_id
|
||||
for move_line in paired.move_id.line_ids:
|
||||
if move_line.journal_id.id == journal_b_id and not move_line.reconciled:
|
||||
try:
|
||||
line_b.set_line_bank_statement_line(move_line.ids)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
reconciled.append({
|
||||
'line_a_id': pair['line_a_id'],
|
||||
'line_b_id': pair['line_b_id'],
|
||||
'amount': pair['amount'],
|
||||
'payment_id': payment.id,
|
||||
'status': 'reconciled',
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.error("Failed to reconcile transfer pair %s: %s", pair, e)
|
||||
reconciled.append({
|
||||
'line_a_id': pair['line_a_id'],
|
||||
'line_b_id': pair['line_b_id'],
|
||||
'amount': pair['amount'],
|
||||
'status': 'error',
|
||||
'error': str(e),
|
||||
})
|
||||
|
||||
return {
|
||||
'status': 'executed',
|
||||
'total_pairs': len(matched_pairs),
|
||||
'reconciled': len([r for r in reconciled if r['status'] == 'reconciled']),
|
||||
'errors': len([r for r in reconciled if r['status'] == 'error']),
|
||||
'details': reconciled,
|
||||
}
|
||||
|
||||
|
||||
@@ -174,4 +493,7 @@ TOOLS = {
|
||||
'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,
|
||||
}
|
||||
|
||||
@@ -15,12 +15,22 @@ def calculate_hst_balance(env, params):
|
||||
if date_to:
|
||||
base_domain.append(('date', '<=', date_to))
|
||||
|
||||
collected_accounts = env['account.account'].search([
|
||||
('code', '=like', '2005%'), ('company_id', '=', env.company.id),
|
||||
])
|
||||
itc_accounts = env['account.account'].search([
|
||||
('code', '=like', '2006%'), ('company_id', '=', env.company.id),
|
||||
])
|
||||
# Odoo 19 Enterprise: account.account may not have company_id field
|
||||
# (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),
|
||||
])
|
||||
itc_accounts = env['account.account'].search([
|
||||
('code', '=like', '2006%'), ('company_id', '=', env.company.id),
|
||||
])
|
||||
except Exception:
|
||||
collected_accounts = env['account.account'].search([
|
||||
('code', '=like', '2005%'),
|
||||
])
|
||||
itc_accounts = env['account.account'].search([
|
||||
('code', '=like', '2006%'),
|
||||
])
|
||||
|
||||
collected_lines = env['account.move.line'].search(
|
||||
base_domain + [('account_id', 'in', collected_accounts.ids)]
|
||||
@@ -124,7 +134,11 @@ def find_missing_itc_bills(env, params):
|
||||
|
||||
|
||||
def get_tax_return_status(env, params):
|
||||
returns = env['account.return'].search([
|
||||
try:
|
||||
AccountReturn = env['account.return']
|
||||
except KeyError:
|
||||
return {'error': 'Tax return model (account.return) is not available. The account_tax_report or related Enterprise module may not be installed.'}
|
||||
returns = AccountReturn.search([
|
||||
('company_id', '=', env.company.id),
|
||||
], order='date_start desc', limit=10)
|
||||
return {
|
||||
@@ -140,7 +154,11 @@ def get_tax_return_status(env, params):
|
||||
|
||||
def generate_tax_return(env, params):
|
||||
try:
|
||||
env['account.return']._generate_or_refresh_all_returns(
|
||||
AccountReturn = env['account.return']
|
||||
except KeyError:
|
||||
return {'error': 'Tax return model (account.return) is not available.'}
|
||||
try:
|
||||
AccountReturn._generate_or_refresh_all_returns(
|
||||
company=env.company
|
||||
)
|
||||
return {'status': 'generated', 'message': 'Tax returns refreshed successfully.'}
|
||||
@@ -149,8 +167,12 @@ def generate_tax_return(env, params):
|
||||
|
||||
|
||||
def validate_tax_return(env, params):
|
||||
try:
|
||||
AccountReturn = env['account.return']
|
||||
except KeyError:
|
||||
return {'error': 'Tax return model (account.return) is not available.'}
|
||||
return_id = int(params['return_id'])
|
||||
tax_return = env['account.return'].browse(return_id)
|
||||
tax_return = AccountReturn.browse(return_id)
|
||||
if not tax_return.exists():
|
||||
return {'error': 'Tax return not found'}
|
||||
try:
|
||||
@@ -160,6 +182,111 @@ def validate_tax_return(env, params):
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
def create_expense_entry(env, params):
|
||||
"""[Tier 3] Create a direct GL expense entry in the Misc journal with optional HST split.
|
||||
This is the 'old school' way of recording expenses without a formal vendor bill.
|
||||
Requires user approval before execution."""
|
||||
date = params.get('date', str(env['account.move']._fields['date'].default(env['account.move'])))
|
||||
description = params.get('description', 'Expense')
|
||||
expense_account_id = int(params['expense_account_id'])
|
||||
amount = abs(float(params['amount']))
|
||||
has_hst = params.get('has_hst', False)
|
||||
bank_journal_id = int(params.get('bank_journal_id', 0))
|
||||
|
||||
# Find the MISC journal
|
||||
misc_journal = env['account.journal'].search([
|
||||
('code', '=', 'MISC'), ('company_id', '=', env.company.id),
|
||||
], limit=1)
|
||||
if not misc_journal:
|
||||
return {'error': 'Miscellaneous Operations journal (MISC) not found'}
|
||||
|
||||
expense_account = env['account.account'].browse(expense_account_id)
|
||||
if not expense_account.exists():
|
||||
return {'error': f'Expense account not found: {expense_account_id}'}
|
||||
|
||||
# Determine credit account (bank outstanding or AP)
|
||||
credit_account = None
|
||||
if bank_journal_id:
|
||||
bank_journal = env['account.journal'].browse(bank_journal_id)
|
||||
if bank_journal.exists():
|
||||
# Use the bank journal's default debit/credit account
|
||||
credit_account = (bank_journal.default_account_id
|
||||
or bank_journal.company_id.account_journal_payment_credit_account_id)
|
||||
if not credit_account:
|
||||
# Fallback to AP account
|
||||
credit_account = env['account.account'].search([
|
||||
('account_type', '=', 'liability_payable'),
|
||||
('company_id', '=', env.company.id),
|
||||
], limit=1)
|
||||
|
||||
if not credit_account:
|
||||
return {'error': 'Could not determine credit account for the expense entry'}
|
||||
|
||||
line_ids = []
|
||||
if has_hst:
|
||||
# Split: net expense + 13% HST ITC
|
||||
hst_rate = 0.13
|
||||
net_amount = round(amount / (1 + hst_rate), 2)
|
||||
hst_amount = round(amount - net_amount, 2)
|
||||
|
||||
# Find HST ITC account (2006%)
|
||||
itc_account = env['account.account'].search([
|
||||
('code', '=like', '2006%'),
|
||||
], limit=1)
|
||||
if not itc_account:
|
||||
# Fallback: use the HST purchase tax account
|
||||
hst_tax = env['account.tax'].search([
|
||||
('type_tax_use', '=', 'purchase'), ('amount', '=', 13.0),
|
||||
('company_id', '=', env.company.id),
|
||||
], limit=1)
|
||||
if hst_tax and hst_tax.invoice_repartition_line_ids:
|
||||
for rep in hst_tax.invoice_repartition_line_ids:
|
||||
if rep.repartition_type == 'tax' and rep.account_id:
|
||||
itc_account = rep.account_id
|
||||
break
|
||||
if not itc_account:
|
||||
return {'error': 'HST ITC account (2006) not found'}
|
||||
|
||||
line_ids = [
|
||||
(0, 0, {'name': description, 'account_id': expense_account_id,
|
||||
'debit': net_amount, 'credit': 0.0}),
|
||||
(0, 0, {'name': f'HST ITC - {description}', 'account_id': itc_account.id,
|
||||
'debit': hst_amount, 'credit': 0.0}),
|
||||
(0, 0, {'name': description, 'account_id': credit_account.id,
|
||||
'debit': 0.0, 'credit': amount}),
|
||||
]
|
||||
else:
|
||||
# Simple: debit expense / credit bank
|
||||
line_ids = [
|
||||
(0, 0, {'name': description, 'account_id': expense_account_id,
|
||||
'debit': amount, 'credit': 0.0}),
|
||||
(0, 0, {'name': description, 'account_id': credit_account.id,
|
||||
'debit': 0.0, 'credit': amount}),
|
||||
]
|
||||
|
||||
try:
|
||||
move = env['account.move'].create({
|
||||
'move_type': 'entry',
|
||||
'journal_id': misc_journal.id,
|
||||
'date': date,
|
||||
'ref': description,
|
||||
'line_ids': line_ids,
|
||||
'company_id': env.company.id,
|
||||
})
|
||||
move.action_post()
|
||||
return {
|
||||
'status': 'posted',
|
||||
'move_id': move.id,
|
||||
'move_name': move.name,
|
||||
'amount': amount,
|
||||
'has_hst': has_hst,
|
||||
'hst_amount': round(amount - amount / 1.13, 2) if has_hst else 0.0,
|
||||
}
|
||||
except Exception as e:
|
||||
_logger.error("Failed to create expense entry: %s", e)
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
TOOLS = {
|
||||
'calculate_hst_balance': calculate_hst_balance,
|
||||
'get_tax_report': get_tax_report,
|
||||
@@ -168,4 +295,5 @@ TOOLS = {
|
||||
'get_tax_return_status': get_tax_return_status,
|
||||
'generate_tax_return': generate_tax_return,
|
||||
'validate_tax_return': validate_tax_return,
|
||||
'create_expense_entry': create_expense_entry,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user