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

+ Upload an OFX, QFX, or QBO file exported from your bank portal. + Duplicate transactions will be detected automatically. +

+
+
+ + + + + + + + + + + + + +
+
+ + New: + + + Duplicates: + + + Selected: + + + + + +
+ + + + + + + + + + + +
+ + + +
+
+ +
+
+ + + Import Bank Statement + fusion.statement.import + form + + new + + + + +
diff --git a/fusion_accounting/CLAUDE.md b/fusion_accounting/CLAUDE.md new file mode 100644 index 00000000..c87008f7 --- /dev/null +++ b/fusion_accounting/CLAUDE.md @@ -0,0 +1,154 @@ +# fusion_accounting — AI Accounting Co-Pilot + +## What This Module Does +An AI agent (Claude/GPT with tool-calling) embedded in Odoo 19 Enterprise Accounting. Conversational interface backed by a dashboard for bank reconciliation, HST/GST management, AR/AP analysis, journal review, month-end close, payroll, inventory, ADP reconciliation, financial reporting, and auditing. + +## Architecture +``` +fusion_accounting/ +├── models/ 7 models (6 new + 1 inherit on account.move) +├── services/ +│ ├── agent.py AI orchestrator (prompt assembly, tool dispatch loop) +│ ├── adapters/ Claude + OpenAI adapters with native tool-calling +│ ├── tools/ 85 tool functions across 11 domain files +│ ├── prompts/ System prompt builder + 12 domain-specific prompts +│ └── scoring.py Confidence scoring + tier promotion logic +├── controllers/ 8 JSON-RPC endpoints +├── wizards/ Rule creation wizard +├── static/src/ OWL dashboard + chat panel + approval cards +├── views/ List/form/search views, menus, settings +├── security/ 3 groups (User/Manager/Admin), record rules, ACLs +├── data/ 82 tool definitions, 2 default rules, 2 crons +└── report/ Audit report QWeb template +``` + +## Key Design Decisions + +### AI Provider Integration +- Uses `fusion.api.service` (from fusion_api module) for API key resolution with fallback to `ir.config_parameter` — NO hard dependency on fusion_api +- Claude adapter: native `tool_use` blocks, extended thinking enabled (8K budget) for 4.5+ models +- OpenAI adapter: Chat Completions API with o-series reasoning model support (`developer` role, `max_completion_tokens`, `reasoning_effort`) +- API keys stored in `ir.config_parameter` with `fusion_accounting.` prefix + +### Tool Tiering +- **Tier 1** (Free): Read-only, execute immediately — 60+ tools +- **Tier 2** (Auto-approved): Low-risk writes, logged — ~10 tools +- **Tier 3** (Requires approval): Financial writes, user must approve — ~15 tools +- Auto-promotion: Tier 3 → Tier 2 at 95% accuracy over 30+ decisions (atomic SQL counters) + +### Menu Location +- **Parent**: `accountant.menu_accounting` (NOT `account.menu_finance` — that's Community Edition only) +- Enterprise uses `accountant.menu_accounting` (ID 1663) as the visible menu root + +### Session Persistence +- Chat sessions stored in `fusion.accounting.session` with `message_ids_json` (JSON text field) +- On page load, chat panel calls `/session/latest` to restore the most recent active session +- "New Chat" button closes current session and creates a fresh one + +## Odoo 19 Gotchas (Learned the Hard Way) + +### Search Views +- NO `string` attribute on `` element +- NO `string` attribute on `` element inside search views +- Group-by filters MUST have `domain="[]"` attribute +- Add `` before `` in search views + +### OWL Client Actions +- Components registered as client actions receive props: `action`, `actionId`, `updateActionState`, `className` +- Must use `static props = ["*"]` (accept any) — NOT `static props = []` (accept none) + +### Cron Safe Eval +- NO `import` statements (forbidden opcode `IMPORT_NAME`) +- `datetime` module available as `datetime` (use `datetime.datetime.now()`, `datetime.timedelta()`) +- NO `from datetime import X` pattern + +### read_group Deprecated +- `read_group()` is deprecated in Odoo 19 — use `_read_group()` instead +- Still works but throws DeprecationWarning + +### Config Parameter Values +- When changing a Selection field's options, the stored DB value in `ir_config_parameter` must match one of the new options or Settings page will crash with `ValueError: Wrong value` +- Fix: UPDATE the value in DB after changing selection options + +### Field Label Conflicts +- Odoo warns if two fields on the same model have the same `string` label +- Our `display_name_field` conflicted with built-in `display_name` — renamed string to "Tool Label" + +### Group Assignment +- `implied_ids` on groups only applies to NEWLY added users, not existing ones +- After installing, manually add existing users to groups via SQL: + ```sql + INSERT INTO res_groups_users_rel (gid, uid) + SELECT , gu.uid FROM res_groups_users_rel gu + JOIN ir_model_data imd ON imd.res_id = gu.gid AND imd.model = 'res.groups' + WHERE imd.module = 'account' AND imd.name = 'group_account_manager' + ON CONFLICT DO NOTHING; + ``` + +## Server Details +- **Server**: odoo-westin (192.168.1.40, SSH via `ssh odoo-westin`) +- **Container**: odoo-dev-app (Odoo), odoo-dev-db (PostgreSQL) +- **Database**: westin-v19 +- **Module path**: `/mnt/extra-addons/fusion_accounting/` +- **Python deps**: anthropic (v0.88.0), openai (v2.30.0) — installed with `--break-system-packages` + +## Deployment Commands +```bash +# Deploy module to server +ssh odoo-westin "docker exec -u 0 odoo-dev-app rm -rf /mnt/extra-addons/fusion_accounting" +scp -r "K:\Github\Odoo-Modules\fusion_accounting" odoo-westin:/tmp/fusion_accounting +ssh odoo-westin "docker cp /tmp/fusion_accounting odoo-dev-app:/mnt/extra-addons/fusion_accounting && rm -rf /tmp/fusion_accounting" + +# Upgrade module (use alt port to avoid conflict with running instance) +ssh odoo-westin "docker exec odoo-dev-app odoo -d westin-v19 -u fusion_accounting --stop-after-init --http-port=8099 -c /etc/odoo/odoo.conf" + +# Restart container +ssh odoo-westin "docker restart odoo-dev-app" + +# Check logs +ssh odoo-westin "docker logs odoo-dev-app --tail 100" +``` + +## Security Groups +| Group ID | XML ID | Name | Access | +|---|---|---|---| +| 564 | `group_fusion_accounting_user` | User | Dashboard, chat (read-only tools) | +| 565 | `group_fusion_accounting_manager` | Manager | + Approve/reject, Tier 2 tools, rules | +| 566 | `group_fusion_accounting_admin` | Administrator | + Config, all tools, rule admin | + +Auto-assigned: `account.group_account_user` → User, `account.group_account_manager` → Admin + +## Models +| Model | Type | Purpose | +|---|---|---| +| `fusion.accounting.session` | Model | Chat sessions with message JSON storage | +| `fusion.accounting.match.history` | Model | Every AI tool call + decision (approved/rejected/pending) | +| `fusion.accounting.rule` | Model | Fusion Rules engine with versioning and auto-promotion | +| `fusion.accounting.tool` | Model | Tool registry (82 tools seeded from XML) | +| `fusion.accounting.dashboard` | TransientModel | Computed health metrics (use `.new()` not `.create()`) | +| `fusion.accounting.agent` | AbstractModel | AI orchestrator | +| `fusion.accounting.adapter.claude` | AbstractModel | Claude tool-calling adapter | +| `fusion.accounting.adapter.openai` | AbstractModel | OpenAI tool-calling adapter | +| `fusion.accounting.scoring` | AbstractModel | Confidence scoring | +| `account.move` (inherit) | Model | Post-action audit hook | + +## AI Models Available +**Claude** (default: claude-sonnet-4-6): +- claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4-5 +- claude-sonnet-4-5, claude-opus-4-5, claude-sonnet-4-0, claude-opus-4-0 + +**OpenAI** (default: gpt-5.4-mini): +- gpt-5.4, gpt-5.4-mini, gpt-5.4-nano +- o3, o4-mini +- gpt-4o, gpt-4o-mini (legacy) + +## Theme / Styling Rules +- NO hardcoded colours — use CSS variables (`var(--o-border-color)`, `var(--bs-body-color-rgb)`) and Bootstrap utility classes +- Must work in both light and dark mode +- Box shadows: use `rgba(var(--bs-body-color-rgb), 0.1)` not `rgba(0,0,0,0.1)` + +## Known Issues / Future Work +- `read_group()` deprecation warnings — migrate to `_read_group()` when format is documented +- `verify_source_deductions`, `generate_t4`, `generate_roe` are stubs pointing to fusion_payroll (by design — Phase 2) +- `account.return` model used in HST tools may not exist in all Odoo 19 setups — needs try/except guard +- Batch approval "Approve All" / "Reject All" buttons are in the chat panel but not yet in the match history list view diff --git a/fusion_accounting/__init__.py b/fusion_accounting/__init__.py new file mode 100644 index 00000000..e8f90eb7 --- /dev/null +++ b/fusion_accounting/__init__.py @@ -0,0 +1,4 @@ +from . import models +from . import services +from . import controllers +from . import wizards diff --git a/fusion_accounting/__manifest__.py b/fusion_accounting/__manifest__.py new file mode 100644 index 00000000..4cd27acc --- /dev/null +++ b/fusion_accounting/__manifest__.py @@ -0,0 +1,61 @@ +{ + 'name': 'Fusion Accounting AI', + 'version': '19.0.1.0.0', + 'category': 'Accounting/Accounting', + 'sequence': 25, + 'summary': 'AI Accounting Co-Pilot with conversational interface and automated analysis', + 'description': """ +Fusion Accounting AI +==================== +An AI-powered accounting co-pilot that embeds Claude/GPT into the Odoo Accounting +module. Features conversational bank reconciliation, HST management, AR/AP analysis, +audit scanning, and a comprehensive dashboard. + +Built by Nexa Systems Inc. + """, + 'icon': '/fusion_accounting/static/description/icon.png', + 'author': 'Nexa Systems Inc.', + 'website': 'https://nexasystems.ca', + 'support': 'support@nexasystems.ca', + 'maintainer': 'Nexa Systems Inc.', + 'depends': [ + 'account', + 'account_accountant', + 'account_reports', + 'account_followup', + 'mail', + ], + 'external_dependencies': { + 'python': ['anthropic', 'openai'], + }, + 'data': [ + # Security + 'security/security.xml', + 'security/ir.model.access.csv', + # Data + 'data/cron.xml', + 'data/tool_definitions.xml', + 'data/default_rules.xml', + # Views + 'views/config_views.xml', + 'views/session_views.xml', + 'views/match_history_views.xml', + 'views/rule_views.xml', + 'views/dashboard_views.xml', + 'views/menus.xml', + # Wizards + 'wizards/rule_wizard.xml', + # Reports + 'report/audit_report_template.xml', + ], + 'installable': True, + 'application': False, + 'license': 'OPL-1', + 'assets': { + 'web.assets_backend': [ + 'fusion_accounting/static/src/**/*.js', + 'fusion_accounting/static/src/**/*.xml', + 'fusion_accounting/static/src/**/*.scss', + ], + }, +} diff --git a/fusion_accounting/controllers/__init__.py b/fusion_accounting/controllers/__init__.py new file mode 100644 index 00000000..aac8675a --- /dev/null +++ b/fusion_accounting/controllers/__init__.py @@ -0,0 +1 @@ +from . import chat_controller diff --git a/fusion_accounting/controllers/chat_controller.py b/fusion_accounting/controllers/chat_controller.py new file mode 100644 index 00000000..f0f1252a --- /dev/null +++ b/fusion_accounting/controllers/chat_controller.py @@ -0,0 +1,126 @@ +import json +import logging + +from odoo import http +from odoo.http import request + +_logger = logging.getLogger(__name__) + + +class FusionAccountingChatController(http.Controller): + + @http.route('/fusion_accounting/session/create', type='jsonrpc', auth='user') + def create_session(self, context_domain=None, **kwargs): + session = request.env['fusion.accounting.session'].create({ + 'user_id': request.env.user.id, + 'company_id': request.env.company.id, + 'context_domain': context_domain, + }) + return {'session_id': session.id, 'name': session.name} + + @http.route('/fusion_accounting/session/close', type='jsonrpc', auth='user') + def close_session(self, session_id, **kwargs): + session = request.env['fusion.accounting.session'].browse(int(session_id)) + if session.exists() and session.state == 'active': + session.action_close_session() + return {'status': 'closed'} + + @http.route('/fusion_accounting/chat', type='jsonrpc', auth='user') + def chat(self, session_id, message, context=None, **kwargs): + if not message: + return {'error': 'Message is required'} + agent = request.env['fusion.accounting.agent'] + result = agent.chat(int(session_id), message, context=context) + return result + + @http.route('/fusion_accounting/approve', type='jsonrpc', auth='user') + def approve_action(self, match_history_id, **kwargs): + if not request.env.user.has_group('fusion_accounting.group_fusion_accounting_manager'): + return {'error': 'Insufficient permissions to approve actions'} + agent = request.env['fusion.accounting.agent'] + result = agent.approve_action(int(match_history_id)) + return result + + @http.route('/fusion_accounting/reject', type='jsonrpc', auth='user') + def reject_action(self, match_history_id, reason='', **kwargs): + if not request.env.user.has_group('fusion_accounting.group_fusion_accounting_manager'): + return {'error': 'Insufficient permissions to reject actions'} + agent = request.env['fusion.accounting.agent'] + result = agent.reject_action(int(match_history_id), reason) + return result + + @http.route('/fusion_accounting/dashboard/data', type='jsonrpc', auth='user') + def dashboard_data(self, **kwargs): + dashboard = request.env['fusion.accounting.dashboard'].new({ + 'company_id': request.env.company.id, + }) + return { + 'bank_recon': {'count': dashboard.bank_recon_count, 'amount': dashboard.bank_recon_amount}, + 'ar': {'total': dashboard.ar_total, 'overdue_count': dashboard.ar_overdue_count}, + 'ap': {'total': dashboard.ap_total, 'due_this_week': dashboard.ap_due_this_week}, + 'hst': {'balance': dashboard.hst_balance}, + 'audit': {'score': dashboard.audit_score, 'flags': dashboard.audit_flag_count}, + 'month_end': {'status': dashboard.month_end_status, 'open_items': dashboard.month_end_open_items}, + } + + @http.route('/fusion_accounting/approve_all', type='jsonrpc', auth='user') + def approve_all(self, match_history_ids, **kwargs): + if not request.env.user.has_group('fusion_accounting.group_fusion_accounting_manager'): + return {'error': 'Insufficient permissions to approve actions'} + agent = request.env['fusion.accounting.agent'] + results = [] + for mid in match_history_ids: + try: + result = agent.approve_action(int(mid)) + results.append({'id': mid, 'status': 'approved', 'result': result}) + except Exception as e: + results.append({'id': mid, 'status': 'error', 'error': str(e)}) + return {'results': results} + + @http.route('/fusion_accounting/reject_all', type='jsonrpc', auth='user') + def reject_all(self, match_history_ids, reason='', **kwargs): + if not request.env.user.has_group('fusion_accounting.group_fusion_accounting_manager'): + return {'error': 'Insufficient permissions to reject actions'} + agent = request.env['fusion.accounting.agent'] + results = [] + for mid in match_history_ids: + try: + result = agent.reject_action(int(mid), reason) + results.append({'id': mid, 'status': 'rejected'}) + except Exception as e: + results.append({'id': mid, 'status': 'error', 'error': str(e)}) + return {'results': results} + + @http.route('/fusion_accounting/session/latest', type='jsonrpc', auth='user') + def session_latest(self, **kwargs): + session = request.env['fusion.accounting.session'].search([ + ('user_id', '=', request.env.user.id), + ('state', '=', 'active'), + ], limit=1, order='create_date desc') + if not session: + return {'session_id': None, 'messages': [], 'name': None} + messages = json.loads(session.message_ids_json or '[]') + display_messages = [] + for msg in messages: + if isinstance(msg.get('content'), str) and msg['content'].strip(): + display_messages.append(msg) + elif isinstance(msg.get('content'), list): + for block in msg['content']: + if isinstance(block, dict) and block.get('type') == 'text' and block['text'].strip(): + display_messages.append({'role': msg['role'], 'content': block['text']}) + return { + 'session_id': session.id, + 'messages': display_messages, + 'name': session.name, + } + + @http.route('/fusion_accounting/session/history', type='jsonrpc', auth='user') + def session_history(self, session_id, **kwargs): + session = request.env['fusion.accounting.session'].browse(int(session_id)) + if not session.exists(): + return {'error': 'Session not found'} + return { + 'messages': json.loads(session.message_ids_json or '[]'), + 'session_id': session.id, + 'state': session.state, + } diff --git a/fusion_accounting/data/cron.xml b/fusion_accounting/data/cron.xml new file mode 100644 index 00000000..ec3bbec9 --- /dev/null +++ b/fusion_accounting/data/cron.xml @@ -0,0 +1,39 @@ + + + + + Fusion AI Session + fusion.accounting.session + FAS/%(year)s/ + 5 + + + + + Fusion AI: Periodic Audit Scan + + code + +cutoff = datetime.datetime.now() - datetime.timedelta(days=30) +stale = model.search([('decision', '=', 'pending'), ('proposed_at', '<', cutoff.strftime('%Y-%m-%d %H:%M:%S'))]) +stale.write({'decision': 'rejected', 'rejection_reason': 'Auto-expired after 30 days'}) + + 1 + days + True + + + + + Fusion AI: Tier Promotion Check + + code + +for rule in model.search([('active', '=', True), ('approval_tier', '=', 'needs_approval')]): + rule._check_promotion() + + 7 + days + True + + diff --git a/fusion_accounting/data/default_rules.xml b/fusion_accounting/data/default_rules.xml new file mode 100644 index 00000000..abe1d28c --- /dev/null +++ b/fusion_accounting/data/default_rules.xml @@ -0,0 +1,22 @@ + + + + Elavon Card Processing Fee + fee + Elavon merchant service charges typically show as a fee deducted from card payment batches. The fee is approximately 1.5-1.8% of the gross batch amount and should be allocated to the Elavon Fee expense account. + When a bank statement line contains "elavon" or "mrch svc" and the amount is less than the sum of matching card payments, allocate the difference to the fee account as a processing fee. + admin + needs_approval + 10 + + + + Weekend Card Batch Combination + match + Card payment batches deposited on Monday often combine Friday, Saturday, and Sunday transactions. When matching Monday bank deposits to card payments, look across the preceding weekend. + For bank lines dated Monday with card-related labels, sum card payments from the preceding Friday through Sunday to find a match. + admin + needs_approval + 20 + + diff --git a/fusion_accounting/data/tool_definitions.xml b/fusion_accounting/data/tool_definitions.xml new file mode 100644 index 00000000..9e64cc00 --- /dev/null +++ b/fusion_accounting/data/tool_definitions.xml @@ -0,0 +1,700 @@ + + + + + get_unreconciled_bank_lines + Get Unreconciled Bank Lines + List unreconciled bank statement lines with optional filters for journal, date range, and minimum amount. + bank_reconciliation + 1 + {"type": "object", "properties": {"journal_id": {"type": "integer", "description": "Journal ID to filter by"}, "date_from": {"type": "string", "description": "Start date (YYYY-MM-DD)"}, "date_to": {"type": "string", "description": "End date (YYYY-MM-DD)"}, "min_amount": {"type": "number", "description": "Minimum absolute amount"}, "limit": {"type": "integer", "description": "Max records to return", "default": 50}}} + account.bank.statement.line.search_read + + + get_unreconciled_receipts + Get Unreconciled Receipts + List unreconciled Outstanding Receipts entries on the specified account (default 1122). + bank_reconciliation + 1 + {"type": "object", "properties": {"account_code": {"type": "string", "description": "Account code prefix", "default": "1122"}}} + + + match_bank_line_to_payments + Match Bank Line to Payments + Match a bank statement line to one or more payment journal items for reconciliation. + bank_reconciliation + 3 + {"type": "object", "properties": {"statement_line_id": {"type": "integer", "description": "Bank statement line ID"}, "move_line_ids": {"type": "array", "items": {"type": "integer"}, "description": "Journal item IDs to match"}}, "required": ["statement_line_id", "move_line_ids"]} + fusion_accounting.group_fusion_accounting_manager + + + auto_reconcile_bank_lines + Auto-Reconcile Bank Lines + Run Odoo's built-in auto-reconciliation engine on all unreconciled bank statement lines. + bank_reconciliation + 3 + {"type": "object", "properties": {"company_id": {"type": "integer"}}} + fusion_accounting.group_fusion_accounting_manager + + + apply_reconcile_model + Apply Reconciliation Model + Apply a specific reconciliation model to a bank statement line. + bank_reconciliation + 3 + {"type": "object", "properties": {"model_id": {"type": "integer"}, "statement_line_id": {"type": "integer"}}, "required": ["model_id", "statement_line_id"]} + fusion_accounting.group_fusion_accounting_manager + + + unmatch_bank_line + Unmatch Bank Line + Undo a bank statement line reconciliation. + bank_reconciliation + 3 + {"type": "object", "properties": {"statement_line_id": {"type": "integer"}}, "required": ["statement_line_id"]} + fusion_accounting.group_fusion_accounting_manager + + + get_reconcile_suggestions + Get Reconciliation Suggestions + Get available reconciliation models for a bank statement line. + bank_reconciliation + 1 + {"type": "object", "properties": {"statement_line_id": {"type": "integer"}}, "required": ["statement_line_id"]} + + + sum_payments_by_date + Sum Payments by Date + Sum payment journal items for a date range, useful for matching card batch deposits. + bank_reconciliation + 1 + {"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}, "journal_ids": {"type": "array", "items": {"type": "integer"}}}, "required": ["date_from", "date_to"]} + + + + + calculate_hst_balance + Calculate HST Balance + Calculate net HST position (collected minus ITCs) for a period. + hst_management + 1 + {"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}} + + + get_tax_report + Get Tax Report + Generate a tax report for a period using Odoo's reporting engine. + hst_management + 1 + {"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}, "report_ref": {"type": "string", "default": "account.generic_tax_report"}}} + + + find_missing_tax_invoices + Find Missing Tax Invoices + Find customer invoices with taxable products but no tax applied. + hst_management + 1 + {"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}} + + + find_missing_itc_bills + Find Missing ITC Bills + Find vendor bills without input tax credits. + hst_management + 1 + {"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}} + + + get_tax_return_status + Get Tax Return Status + Check the status of periodic tax returns. + hst_management + 1 + {"type": "object", "properties": {}} + + + generate_tax_return + Generate Tax Return + Refresh all tax return data. + hst_management + 2 + {"type": "object", "properties": {}} + fusion_accounting.group_fusion_accounting_manager + + + validate_tax_return + Validate Tax Return + Mark a tax return as validated. + hst_management + 3 + {"type": "object", "properties": {"return_id": {"type": "integer"}}, "required": ["return_id"]} + fusion_accounting.group_fusion_accounting_manager + + + + + get_ar_aging + Get AR Aging + Get accounts receivable aging buckets (current, 30, 60, 90+ days). + accounts_receivable + 1 + {"type": "object", "properties": {}} + + + get_overdue_invoices + Get Overdue Invoices + List invoices past due with partner contact information. + accounts_receivable + 1 + {"type": "object", "properties": {"min_days_overdue": {"type": "integer", "default": 1}, "limit": {"type": "integer", "default": 50}}} + + + get_partner_balance + Get Partner Balance + Get a single partner's AR balance and open items. + accounts_receivable + 1 + {"type": "object", "properties": {"partner_id": {"type": "integer"}}, "required": ["partner_id"]} + + + send_followup + Send Follow-Up + Draft and send a follow-up email to a partner about overdue invoices. + accounts_receivable + 2 + {"type": "object", "properties": {"partner_id": {"type": "integer"}, "send_email": {"type": "boolean"}, "print_letter": {"type": "boolean"}, "email_subject": {"type": "string"}, "body": {"type": "string"}}, "required": ["partner_id"]} + fusion_accounting.group_fusion_accounting_manager + + + get_followup_report + Get Follow-Up Report + Get the HTML follow-up report for a partner. + accounts_receivable + 1 + {"type": "object", "properties": {"partner_id": {"type": "integer"}}, "required": ["partner_id"]} + + + reconcile_payment_to_invoice + Reconcile Payment to Invoice + Match a payment to an invoice by reconciling journal items. + accounts_receivable + 3 + {"type": "object", "properties": {"move_line_ids": {"type": "array", "items": {"type": "integer"}}}, "required": ["move_line_ids"]} + fusion_accounting.group_fusion_accounting_manager + + + get_unmatched_payments + Get Unmatched Payments + List payments not matched to invoices. + accounts_receivable + 1 + {"type": "object", "properties": {}} + + + + + get_ap_aging + Get AP Aging + Get accounts payable aging buckets. + accounts_payable + 1 + {"type": "object", "properties": {}} + + + find_duplicate_bills + Find Duplicate Bills + Detect potential duplicate vendor bills (same vendor + amount + date within window). + accounts_payable + 1 + {"type": "object", "properties": {"window_days": {"type": "integer", "default": 7}}} + + + match_bill_to_po + Match Bill to PO + Cross-reference bill lines to purchase order lines. + accounts_payable + 1 + {"type": "object", "properties": {"bill_id": {"type": "integer"}}, "required": ["bill_id"]} + + + get_unpaid_bills + Get Unpaid Bills + List vendor bills with outstanding balance. + accounts_payable + 1 + {"type": "object", "properties": {"partner_id": {"type": "integer"}, "limit": {"type": "integer", "default": 50}}} + + + verify_bill_taxes + Verify Bill Taxes + Check that bill tax matches fiscal position expectation. + accounts_payable + 1 + {"type": "object", "properties": {"bill_id": {"type": "integer"}}, "required": ["bill_id"]} + + + get_payment_schedule + Get Payment Schedule + Bills sorted by due date for cash planning. + accounts_payable + 1 + {"type": "object", "properties": {"days_ahead": {"type": "integer", "default": 30}}} + + + + + find_wrong_direction_balances + Find Wrong Direction Balances + Find accounts where balance direction contradicts account type. + journal_review + 1 + {"type": "object", "properties": {}} + + + find_duplicate_entries + Find Duplicate Entries + Detect entries with matching partner + amount + date + journal. + journal_review + 1 + {"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}} + + + find_wrong_account_entries + Find Wrong Account Entries + Product lines on unlikely accounts (e.g., revenue on tax account). + journal_review + 1 + {"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}} + + + find_sequence_gaps + Find Sequence Gaps + Find journal entries where made_sequence_gap is true. + journal_review + 1 + {"type": "object", "properties": {}} + + + find_draft_entries + Find Draft Entries + Draft entries older than specified days that should be posted or deleted. + journal_review + 1 + {"type": "object", "properties": {"min_age_days": {"type": "integer", "default": 30}}} + + + find_unreconciled_suspense + Find Unreconciled Suspense + Suspense/clearing accounts with non-zero balance. + journal_review + 1 + {"type": "object", "properties": {}} + + + verify_reconciliation_integrity + Verify Reconciliation Integrity + Check account.partial.reconcile consistency. + journal_review + 1 + {"type": "object", "properties": {}} + + + + + get_close_checklist + Get Close Checklist + Aggregate all domain checks into a period close checklist. + month_end + 1 + {"type": "object", "properties": {"period": {"type": "string", "description": "YYYY-MM format"}}} + + + get_unreconciled_counts + Get Unreconciled Counts + Per-account count of unreconciled items. + month_end + 1 + {"type": "object", "properties": {}} + + + find_entries_in_locked_period + Find Entries in Locked Period + Find entries after lock dates. + month_end + 1 + {"type": "object", "properties": {}} + + + get_accrual_status + Get Accrual Status + Balance on accrual accounts (vacation, sick, etc.). + month_end + 1 + {"type": "object", "properties": {"account_codes": {"type": "array", "items": {"type": "string"}}}} + + + run_hash_integrity_check + Run Hash Integrity Check + Verify journal entry hash chain integrity. + month_end + 1 + {"type": "object", "properties": {}} + + + get_period_summary + Get Period Summary + Trial balance for the closing period. + month_end + 1 + {"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}, "required": ["date_from", "date_to"]} + + + + + get_payroll_entries + Get Payroll Entries + Journal entries in payroll-related journals. + payroll_verification + 1 + {"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}, "journal_id": {"type": "integer"}}} + + + compare_payroll_to_bank + Compare Payroll to Bank + Cross-reference payroll cheques to bank statement lines. + payroll_verification + 1 + {"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}, "required": ["date_from", "date_to"]} + + + verify_source_deductions + Verify Source Deductions + CPP + EI + tax calculation verification against CRA tables. + payroll_verification + 1 + {"type": "object", "properties": {}} + + + get_cra_remittance_status + Get CRA Remittance Status + CRA payable balance vs payments made. + payroll_verification + 1 + {"type": "object", "properties": {}} + + + find_unmatched_payroll_cheques + Find Unmatched Payroll Cheques + Bank cheques without matching payroll entry. + payroll_verification + 1 + {"type": "object", "properties": {}} + + + + + get_stock_valuation + Get Stock Valuation + Stock In Hand balance and layers. + inventory + 1 + {"type": "object", "properties": {}} + + + get_price_differences + Get Price Differences + Entries on price difference account (PO price vs bill price). + inventory + 1 + {"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}} + + + get_cogs_ratio_by_category + Get COGS Ratio + COGS vs revenue per product category. + inventory + 1 + {"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}} + + + find_unusual_adjustments + Find Unusual Adjustments + Large inventory adjustment entries. + inventory + 1 + {"type": "object", "properties": {"threshold": {"type": "number", "default": 1000}}} + + + get_inventory_turnover + Get Inventory Turnover + Sales vs average inventory calculation. + inventory + 1 + {"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}} + + + + + get_adp_receivable_aging + Get ADP Receivable Aging + Aging on ADP Receivable account (1101). + adp + 1 + {"type": "object", "properties": {}} + + + match_adp_payment_to_invoice + Match ADP Payment to Invoice + Match ADP deposit to ADP invoices. + adp + 3 + {"type": "object", "properties": {"move_line_ids": {"type": "array", "items": {"type": "integer"}}}, "required": ["move_line_ids"]} + fusion_accounting.group_fusion_accounting_manager + + + verify_adp_split + Verify ADP Split + Check customer + ADP portion = invoice total. + adp + 1 + {"type": "object", "properties": {"invoice_id": {"type": "integer"}}, "required": ["invoice_id"]} + + + find_adp_without_payment + Find ADP Without Payment + ADP invoices without matching government deposit. + adp + 1 + {"type": "object", "properties": {}} + + + get_adp_summary + Get ADP Summary + Period summary of ADP billing vs collection. + adp + 1 + {"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}} + + + + + get_profit_loss + Get Profit & Loss + Generate P&L report for a period. + reporting + 1 + {"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}} + + + get_balance_sheet + Get Balance Sheet + Generate balance sheet report. + reporting + 1 + {"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}} + + + get_trial_balance + Get Trial Balance + Generate trial balance report. + reporting + 1 + {"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}} + + + get_cash_flow + Get Cash Flow + Generate cash flow statement. + reporting + 1 + {"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}} + + + compare_periods + Compare Periods + Two period reports side by side for comparison. + reporting + 1 + {"type": "object", "properties": {"report_ref": {"type": "string"}, "period1_from": {"type": "string"}, "period1_to": {"type": "string"}, "period2_from": {"type": "string"}, "period2_to": {"type": "string"}}, "required": ["period1_from", "period1_to", "period2_from", "period2_to"]} + + + answer_financial_question + Answer Financial Question + Natural language to report query for financial questions. + reporting + 1 + {"type": "object", "properties": {"question": {"type": "string"}}, "required": ["question"]} + + + export_report + Export Report + Export a report to PDF or XLSX. + reporting + 2 + {"type": "object", "properties": {"report_ref": {"type": "string"}, "format": {"type": "string", "enum": ["pdf", "xlsx"]}, "date_from": {"type": "string"}, "date_to": {"type": "string"}}, "required": ["report_ref"]} + fusion_accounting.group_fusion_accounting_manager + + + + + audit_posted_entry + Audit Posted Entry + Run all entry-level checks on a single journal entry. + audit + 1 + {"type": "object", "properties": {"move_id": {"type": "integer"}}, "required": ["move_id"]} + + + audit_account_balances + Audit Account Balances + Run all account-level checks (wrong direction, stale items). + audit + 1 + {"type": "object", "properties": {}} + + + audit_tax_compliance + Audit Tax Compliance + All tax checks (missing tax, wrong rate, exempt verification). + audit + 1 + {"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}} + + + audit_reconciliation_integrity + Audit Reconciliation Integrity + Verify partial/full reconcile consistency. + audit + 1 + {"type": "object", "properties": {}} + + + check_hash_chain + Check Hash Chain + Verify journal entry hash chain integrity. + audit + 1 + {"type": "object", "properties": {}} + + + check_sequence_gaps + Check Sequence Gaps + Check for sequence gaps in journals. + audit + 1 + {"type": "object", "properties": {}} + + + flag_entry + Flag Entry + Create a chatter note on a journal entry with flag and recommendation. + audit + 2 + {"type": "object", "properties": {"move_id": {"type": "integer"}, "flag": {"type": "string"}, "recommendation": {"type": "string"}}, "required": ["move_id"]} + fusion_accounting.group_fusion_accounting_manager + + + get_audit_status + Get Audit Status + Account audit status per tax return. + audit + 1 + {"type": "object", "properties": {}} + + + set_audit_status + Set Audit Status + Update review status (todo / reviewed / supervised / anomaly). + audit + 2 + {"type": "object", "properties": {"status_id": {"type": "integer"}, "status": {"type": "string", "enum": ["todo", "reviewed", "supervised", "anomaly"]}}, "required": ["status_id", "status"]} + fusion_accounting.group_fusion_accounting_manager + + + get_audit_trail + Get Audit Trail + Get mail.message history for a journal entry. + audit + 1 + {"type": "object", "properties": {"move_id": {"type": "integer"}}, "required": ["move_id"]} + + + run_full_audit + Run Full Audit + All checks across all domains for a period. + audit + 1 + {"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}} + + + get_audit_report + Get Audit Report + Summary of all audit findings with severity ratings. + audit + 1 + {"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}} + + + + + parse_payroll_summary + Parse Payroll Summary + Read pasted/uploaded payroll data from QBO or fusion_payroll. + payroll_management + 1 + {"type": "object", "properties": {"data": {"type": "string"}}, "required": ["data"]} + + + create_payroll_journal_entry + Create Payroll Journal Entry + Create a payroll journal entry with debit/credit lines. + payroll_management + 3 + {"type": "object", "properties": {"journal_id": {"type": "integer"}, "date": {"type": "string"}, "ref": {"type": "string"}, "lines": {"type": "array", "items": {"type": "object", "properties": {"account_id": {"type": "integer"}, "name": {"type": "string"}, "debit": {"type": "number"}, "credit": {"type": "number"}, "partner_id": {"type": "integer"}}}}}, "required": ["journal_id", "date", "lines"]} + fusion_accounting.group_fusion_accounting_manager + + + match_payroll_cheques + Match Payroll Cheques + Match bank cheques to payroll liabilities. + payroll_management + 3 + {"type": "object", "properties": {"statement_line_id": {"type": "integer"}, "move_line_ids": {"type": "array", "items": {"type": "integer"}}}, "required": ["statement_line_id", "move_line_ids"]} + fusion_accounting.group_fusion_accounting_manager + + + prepare_cra_payment + Prepare CRA Payment + Create CRA remittance payment entry. + payroll_management + 3 + {"type": "object", "properties": {"journal_id": {"type": "integer"}, "date": {"type": "string"}, "lines": {"type": "array"}}, "required": ["journal_id", "date", "lines"]} + fusion_accounting.group_fusion_accounting_manager + + + generate_t4 + Generate T4 + Trigger T4 generation via fusion_payroll. + payroll_management + 2 + {"type": "object", "properties": {}} + fusion_accounting.group_fusion_accounting_manager + + + generate_roe + Generate ROE + Trigger ROE generation via fusion_payroll. + payroll_management + 2 + {"type": "object", "properties": {}} + fusion_accounting.group_fusion_accounting_manager + + + get_payroll_cost_report + Get Payroll Cost Report + Period summary by employee/department. + payroll_management + 1 + {"type": "object", "properties": {"date_from": {"type": "string"}, "date_to": {"type": "string"}}} + + diff --git a/fusion_accounting/models/__init__.py b/fusion_accounting/models/__init__.py new file mode 100644 index 00000000..fa4e1236 --- /dev/null +++ b/fusion_accounting/models/__init__.py @@ -0,0 +1,7 @@ +from . import accounting_config +from . import accounting_tool +from . import accounting_session +from . import accounting_match_history +from . import accounting_rule +from . import accounting_dashboard +from . import account_move_hook diff --git a/fusion_accounting/models/account_move_hook.py b/fusion_accounting/models/account_move_hook.py new file mode 100644 index 00000000..ab0ea4f4 --- /dev/null +++ b/fusion_accounting/models/account_move_hook.py @@ -0,0 +1,53 @@ +import logging + +from odoo import models, api + +_logger = logging.getLogger(__name__) + + +class AccountMoveAuditHook(models.Model): + _inherit = 'account.move' + + def action_post(self): + res = super().action_post() + + ICP = self.env['ir.config_parameter'].sudo() + if ICP.get_param('fusion_accounting.enable_post_audit', 'False') != 'True': + return res + + for move in self: + try: + self._fusion_audit_posted_entry(move) + except Exception as e: + _logger.warning("Fusion post-audit hook failed for %s: %s", move.name, e) + + return res + + def _fusion_audit_posted_entry(self, move): + issues = [] + + total_debit = sum(l.debit for l in move.line_ids) + total_credit = sum(l.credit for l in move.line_ids) + if abs(total_debit - total_credit) > 0.01: + issues.append(f'Unbalanced: debit={total_debit:.2f}, credit={total_credit:.2f}') + + for line in move.line_ids: + if not line.account_id: + issues.append(f'Line missing account: {line.name}') + if line.product_id and not line.tax_ids: + if move.move_type in ('out_invoice', 'out_refund', 'in_invoice', 'in_refund'): + issues.append(f'Missing tax on product line: {line.product_id.name}') + + if not move.line_ids: + issues.append('Entry has no lines') + + if issues: + body_parts = ['Fusion AI Auto-Audit
    '] + for issue in issues: + body_parts.append(f'
  • {issue}
  • ') + body_parts.append('
') + move.message_post( + body=''.join(body_parts), + message_type='comment', + subtype_xmlid='mail.mt_note', + ) diff --git a/fusion_accounting/models/accounting_config.py b/fusion_accounting/models/accounting_config.py new file mode 100644 index 00000000..0e7c767c --- /dev/null +++ b/fusion_accounting/models/accounting_config.py @@ -0,0 +1,84 @@ +import logging +from odoo import models, fields, api + +_logger = logging.getLogger(__name__) + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + fusion_ai_provider = fields.Selection( + selection=[('claude', 'Anthropic Claude'), ('openai', 'OpenAI GPT')], + string='AI Provider', + default='claude', + config_parameter='fusion_accounting.ai_provider', + ) + fusion_anthropic_api_key = fields.Char( + string='Anthropic API Key (Fusion AI)', + config_parameter='fusion_accounting.anthropic_api_key', + ) + fusion_openai_api_key = fields.Char( + string='OpenAI API Key (Fusion AI)', + config_parameter='fusion_accounting.openai_api_key', + ) + fusion_claude_model = fields.Selection( + selection=[ + ('claude-opus-4-6', 'Claude Opus 4.6 (Most Intelligent)'), + ('claude-sonnet-4-6', 'Claude Sonnet 4.6 (Best Balance)'), + ('claude-haiku-4-5', 'Claude Haiku 4.5 (Fastest)'), + ('claude-sonnet-4-5', 'Claude Sonnet 4.5'), + ('claude-opus-4-5', 'Claude Opus 4.5'), + ('claude-sonnet-4-0', 'Claude Sonnet 4'), + ('claude-opus-4-0', 'Claude Opus 4'), + ], + string='Claude Model', + default='claude-sonnet-4-6', + config_parameter='fusion_accounting.claude_model', + ) + fusion_openai_model = fields.Selection( + selection=[ + ('gpt-5.4', 'GPT-5.4 (Flagship)'), + ('gpt-5.4-mini', 'GPT-5.4 Mini (Fast)'), + ('gpt-5.4-nano', 'GPT-5.4 Nano (Cheapest)'), + ('o3', 'o3 (Best Reasoning)'), + ('o4-mini', 'o4-mini (Fast Reasoning)'), + ('gpt-4o', 'GPT-4o (Legacy)'), + ('gpt-4o-mini', 'GPT-4o Mini (Legacy)'), + ], + string='OpenAI Model', + default='gpt-5.4-mini', + config_parameter='fusion_accounting.openai_model', + ) + fusion_tier3_threshold = fields.Float( + string='Tier 3 Promotion Threshold', + default=0.95, + config_parameter='fusion_accounting.tier3_threshold', + help='Accuracy threshold for promoting Tier 3 tools to auto-approved.', + ) + fusion_tier3_min_sample = fields.Integer( + string='Tier 3 Minimum Sample Size', + default=30, + config_parameter='fusion_accounting.tier3_min_sample', + ) + fusion_audit_cron_frequency = fields.Selection( + selection=[('daily', 'Daily'), ('weekly', 'Weekly'), ('monthly', 'Monthly')], + string='Audit Scan Frequency', + default='daily', + config_parameter='fusion_accounting.audit_cron_frequency', + ) + fusion_history_in_prompt = fields.Integer( + string='Match History in Prompt', + default=50, + config_parameter='fusion_accounting.history_in_prompt', + help='Number of recent match history records to include in AI prompt.', + ) + fusion_max_tool_calls = fields.Integer( + string='Max Tool Calls Per Turn', + default=20, + config_parameter='fusion_accounting.max_tool_calls', + ) + fusion_enable_post_audit = fields.Boolean( + string='Enable Post-Action Audit Hook', + default=False, + config_parameter='fusion_accounting.enable_post_audit', + ) diff --git a/fusion_accounting/models/accounting_dashboard.py b/fusion_accounting/models/accounting_dashboard.py new file mode 100644 index 00000000..cbbfa7e5 --- /dev/null +++ b/fusion_accounting/models/accounting_dashboard.py @@ -0,0 +1,278 @@ +import json +import logging +from datetime import timedelta + +from odoo import models, fields, api + +_logger = logging.getLogger(__name__) + + +class FusionAccountingDashboard(models.TransientModel): + _name = 'fusion.accounting.dashboard' + _description = 'Fusion Accounting Dashboard' + + company_id = fields.Many2one( + 'res.company', string='Company', + default=lambda self: self.env.company, + ) + bank_recon_count = fields.Integer(compute='_compute_bank_recon') + bank_recon_amount = fields.Monetary( + compute='_compute_bank_recon', currency_field='currency_id', + ) + ar_total = fields.Monetary( + compute='_compute_ar', currency_field='currency_id', + ) + ar_overdue_count = fields.Integer(compute='_compute_ar') + ap_total = fields.Monetary( + compute='_compute_ap', currency_field='currency_id', + ) + ap_due_this_week = fields.Integer(compute='_compute_ap') + hst_balance = fields.Monetary( + compute='_compute_hst', currency_field='currency_id', + ) + audit_score = fields.Integer(compute='_compute_audit') + audit_flag_count = fields.Integer(compute='_compute_audit') + month_end_status = fields.Char(compute='_compute_month_end') + month_end_open_items = fields.Integer(compute='_compute_month_end') + currency_id = fields.Many2one( + 'res.currency', string='Currency', + default=lambda self: self.env.company.currency_id, + ) + needs_attention_json = fields.Text(compute='_compute_action_centre') + recent_activity_json = fields.Text(compute='_compute_action_centre') + + @api.depends('company_id') + def _compute_bank_recon(self): + for rec in self: + data = self.env['account.bank.statement.line'].read_group( + [('is_reconciled', '=', False), ('company_id', '=', rec.company_id.id)], + ['amount:sum'], [], + ) + row = data[0] if data else {} + rec.bank_recon_count = row.get('__count', 0) + rec.bank_recon_amount = abs(row.get('amount', 0) or 0) + + @api.depends('company_id') + def _compute_ar(self): + for rec in self: + data = self.env['account.move.line'].read_group( + [ + ('account_id.account_type', '=', 'asset_receivable'), + ('parent_state', '=', 'posted'), + ('reconciled', '=', False), + ('company_id', '=', rec.company_id.id), + ], + ['amount_residual:sum'], [], + ) + row = data[0] if data else {} + rec.ar_total = row.get('amount_residual', 0) or 0 + + rec.ar_overdue_count = self.env['account.move.line'].search_count([ + ('account_id.account_type', '=', 'asset_receivable'), + ('parent_state', '=', 'posted'), + ('reconciled', '=', False), + ('date_maturity', '<', fields.Date.today()), + ('company_id', '=', rec.company_id.id), + ]) + + @api.depends('company_id') + def _compute_ap(self): + for rec in self: + data = self.env['account.move.line'].read_group( + [ + ('account_id.account_type', '=', 'liability_payable'), + ('parent_state', '=', 'posted'), + ('reconciled', '=', False), + ('company_id', '=', rec.company_id.id), + ], + ['amount_residual:sum'], [], + ) + row = data[0] if data else {} + rec.ap_total = abs(row.get('amount_residual', 0) or 0) + + week_end = fields.Date.today() + timedelta(days=7) + rec.ap_due_this_week = self.env['account.move.line'].search_count([ + ('account_id.account_type', '=', 'liability_payable'), + ('parent_state', '=', 'posted'), + ('reconciled', '=', False), + ('date_maturity', '<=', week_end), + ('company_id', '=', rec.company_id.id), + ]) + + @api.depends('company_id') + def _compute_hst(self): + for rec in self: + collected_data = self.env['account.move.line'].read_group( + [ + ('account_id.code', '=like', '2005%'), + ('parent_state', '=', 'posted'), + ('company_id', '=', rec.company_id.id), + ], + ['balance:sum'], [], + ) + itc_data = self.env['account.move.line'].read_group( + [ + ('account_id.code', '=like', '2006%'), + ('parent_state', '=', 'posted'), + ('company_id', '=', rec.company_id.id), + ], + ['balance:sum'], [], + ) + collected = abs((collected_data[0] if collected_data else {}).get('balance', 0) or 0) + itcs = abs((itc_data[0] if itc_data else {}).get('balance', 0) or 0) + rec.hst_balance = collected - itcs + + @api.depends('company_id') + def _compute_audit(self): + for rec in self: + issues = 0 + + # Wrong-direction balances via read_group + balance_data = self.env['account.move.line'].read_group( + [('parent_state', '=', 'posted'), ('company_id', '=', rec.company_id.id)], + ['balance:sum'], ['account_id'], + ) + acct_cache = {} + acct_ids = [row['account_id'][0] for row in balance_data if row.get('account_id')] + if acct_ids: + for acct in self.env['account.account'].browse(acct_ids): + acct_cache[acct.id] = acct.account_type + for row in balance_data: + if not row.get('account_id'): + continue + acct_type = acct_cache.get(row['account_id'][0], '') + balance = row.get('balance', 0) or 0 + if acct_type in ('asset_receivable', 'asset_cash', 'asset_current', + 'asset_non_current', 'asset_fixed', 'expense', + 'expense_depreciation', 'expense_direct_cost'): + if balance < -0.01: + issues += 1 + elif acct_type in ('liability_payable', 'liability_current', + 'liability_non_current', 'equity', 'income', + 'income_other'): + if balance > 0.01: + issues += 1 + + gaps = self.env['account.move'].search_count([ + ('state', '=', 'posted'), + ('company_id', '=', rec.company_id.id), + ('made_sequence_gap', '=', True), + ]) + issues += gaps + + pending_approvals = self.env['fusion.accounting.match.history'].search_count([ + ('decision', '=', 'pending'), + ('company_id', '=', rec.company_id.id), + ]) + + rec.audit_score = max(0, min(100, 100 - issues * 3)) + rec.audit_flag_count = issues + pending_approvals + + @api.depends('company_id') + def _compute_month_end(self): + for rec in self: + open_items = 0 + open_items += self.env['account.bank.statement.line'].search_count([ + ('is_reconciled', '=', False), + ('company_id', '=', rec.company_id.id), + ]) + open_items += self.env['account.move'].search_count([ + ('state', '=', 'draft'), + ('company_id', '=', rec.company_id.id), + ]) + + suspense_data = self.env['account.move.line'].read_group( + [ + ('account_id.code', '=like', '999%'), + ('parent_state', '=', 'posted'), + ('company_id', '=', rec.company_id.id), + ], + ['balance:sum'], ['account_id'], + ) + for row in suspense_data: + if abs(row.get('balance', 0) or 0) > 0.01: + open_items += 1 + + rec.month_end_open_items = open_items + if open_items == 0: + rec.month_end_status = 'Ready to Close' + elif open_items < 5: + rec.month_end_status = 'Almost Ready' + else: + rec.month_end_status = 'Open' + + @api.depends('company_id') + def _compute_action_centre(self): + for rec in self: + attention = [] + + unrecon = self.env['account.bank.statement.line'].search_count([ + ('is_reconciled', '=', False), + ('company_id', '=', rec.company_id.id), + ]) + if unrecon > 0: + attention.append({ + 'priority': 1, + 'title': f'{unrecon} unreconciled bank lines', + 'domain': 'bank_reconciliation', + 'action': 'Review and reconcile bank statement lines', + }) + + overdue = self.env['account.move'].search_count([ + ('move_type', '=', 'out_invoice'), + ('state', '=', 'posted'), + ('payment_state', 'in', ('not_paid', 'partial')), + ('invoice_date_due', '<', fields.Date.today()), + ('company_id', '=', rec.company_id.id), + ]) + if overdue > 0: + attention.append({ + 'priority': 2, + 'title': f'{overdue} overdue customer invoices', + 'domain': 'accounts_receivable', + 'action': 'Send follow-up reminders', + }) + + pending = self.env['fusion.accounting.match.history'].search_count([ + ('decision', '=', 'pending'), + ('company_id', '=', rec.company_id.id), + ]) + if pending > 0: + attention.append({ + 'priority': 0, + 'title': f'{pending} AI actions awaiting approval', + 'domain': 'audit', + 'action': 'Review and approve/reject pending actions', + }) + + drafts = self.env['account.move'].search_count([ + ('state', '=', 'draft'), + ('date', '<=', fields.Date.today() - timedelta(days=30)), + ('company_id', '=', rec.company_id.id), + ]) + if drafts > 0: + attention.append({ + 'priority': 3, + 'title': f'{drafts} stale draft entries (30+ days)', + 'domain': 'journal_review', + 'action': 'Post or delete stale draft entries', + }) + + attention.sort(key=lambda x: x['priority']) + rec.needs_attention_json = json.dumps(attention) + + recent = self.env['fusion.accounting.match.history'].search([ + ('company_id', '=', rec.company_id.id), + ], limit=10, order='proposed_at desc') + rec.recent_activity_json = json.dumps([{ + 'tool': r.tool_name, + 'decision': r.decision, + 'date': str(r.proposed_at), + 'amount': r.amount, + } for r in recent]) + + def action_refresh(self): + return { + 'type': 'ir.actions.client', + 'tag': 'fusion_accounting.dashboard', + } diff --git a/fusion_accounting/models/accounting_match_history.py b/fusion_accounting/models/accounting_match_history.py new file mode 100644 index 00000000..dbbd4304 --- /dev/null +++ b/fusion_accounting/models/accounting_match_history.py @@ -0,0 +1,81 @@ +import logging +from odoo import models, fields, api + +_logger = logging.getLogger(__name__) + + +class FusionAccountingMatchHistory(models.Model): + _name = 'fusion.accounting.match.history' + _description = 'Fusion Accounting Match History' + _order = 'proposed_at desc' + + session_id = fields.Many2one( + 'fusion.accounting.session', string='Session', + index=True, ondelete='cascade', + ) + tool_name = fields.Char(string='Tool Name', required=True, index=True) + tool_params = fields.Text(string='Tool Parameters (JSON)') + tool_result = fields.Text(string='Tool Result (JSON)') + ai_reasoning = fields.Text(string='AI Reasoning') + ai_confidence = fields.Float(string='AI Confidence', digits=(3, 2)) + rule_id = fields.Many2one( + 'fusion.accounting.rule', string='Applied Rule', + ondelete='set null', + ) + proposed_at = fields.Datetime( + string='Proposed At', + default=fields.Datetime.now, + required=True, + ) + decision = fields.Selection( + selection=[ + ('approved', 'Approved'), + ('rejected', 'Rejected'), + ('pending', 'Pending'), + ('auto', 'Auto-Executed'), + ], + string='Decision', + default='pending', + index=True, + ) + decided_at = fields.Datetime(string='Decided At') + decided_by = fields.Many2one('res.users', string='Decided By') + rejection_reason = fields.Text(string='Rejection Reason') + correct_action = fields.Text(string='Correct Action (JSON)') + bank_statement_line_id = fields.Many2one( + 'account.bank.statement.line', string='Bank Statement Line', + ondelete='set null', + ) + move_line_ids = fields.Many2many( + 'account.move.line', string='Journal Items', + ) + amount = fields.Monetary(string='Amount', currency_field='currency_id') + currency_id = fields.Many2one( + 'res.currency', string='Currency', + default=lambda self: self.env.company.currency_id, + ) + partner_id = fields.Many2one('res.partner', string='Partner') + company_id = fields.Many2one( + 'res.company', string='Company', + default=lambda self: self.env.company, + ) + + def action_approve(self): + self.write({ + 'decision': 'approved', + 'decided_at': fields.Datetime.now(), + 'decided_by': self.env.user.id, + }) + for rec in self: + if rec.rule_id: + rec.rule_id._record_decision(approved=True) + + def action_reject(self): + self.write({ + 'decision': 'rejected', + 'decided_at': fields.Datetime.now(), + 'decided_by': self.env.user.id, + }) + for rec in self: + if rec.rule_id: + rec.rule_id._record_decision(approved=False) diff --git a/fusion_accounting/models/accounting_rule.py b/fusion_accounting/models/accounting_rule.py new file mode 100644 index 00000000..b1dc7709 --- /dev/null +++ b/fusion_accounting/models/accounting_rule.py @@ -0,0 +1,120 @@ +import logging +from odoo import models, fields, api + +_logger = logging.getLogger(__name__) + + +class FusionAccountingRule(models.Model): + _name = 'fusion.accounting.rule' + _description = 'Fusion Accounting Rule' + _order = 'sequence, id' + _inherit = ['mail.thread'] + + name = fields.Char(string='Name', required=True, tracking=True) + rule_type = fields.Selection( + selection=[ + ('match', 'Match'), + ('classify', 'Classify'), + ('audit', 'Audit'), + ('fee', 'Fee'), + ('routing', 'Routing'), + ('followup', 'Follow-Up'), + ], + string='Type', + required=True, + tracking=True, + ) + description = fields.Text( + string='Description', + help='Natural language description read by the AI.', + ) + trigger_domain = fields.Text( + string='Trigger Domain (JSON)', + help='Odoo domain filter for matching records.', + ) + match_logic = fields.Text( + string='Match Logic', + help='Natural language matching instructions for the AI.', + ) + match_code = fields.Text( + string='Match Code (Python)', + help='Optional deterministic Python matching code.', + ) + fee_account_id = fields.Many2one( + 'account.account', string='Fee Account', + ) + write_off_account_id = fields.Many2one( + 'account.account', string='Write-Off Account', + ) + approval_tier = fields.Selection( + selection=[('auto', 'Auto-Approved'), ('needs_approval', 'Needs Approval')], + string='Approval Tier', + default='needs_approval', + tracking=True, + ) + created_by = fields.Selection( + selection=[('admin', 'Admin'), ('ai', 'AI')], + string='Created By', + default='admin', + ) + confidence_score = fields.Float( + string='Confidence Score', digits=(3, 2), default=0.0, + ) + total_uses = fields.Integer(string='Total Uses', default=0) + total_approved = fields.Integer(string='Total Approved', default=0) + total_rejected = fields.Integer(string='Total Rejected', default=0) + promotion_threshold = fields.Float( + string='Promotion Threshold', default=0.95, + ) + min_sample_size = fields.Integer(string='Min Sample Size', default=30) + active = fields.Boolean(string='Active', default=True, tracking=True) + version = fields.Integer(string='Version', default=1) + parent_rule_id = fields.Many2one( + 'fusion.accounting.rule', string='Previous Version', + ondelete='set null', + ) + journal_ids = fields.Many2many( + 'account.journal', string='Journals', + ) + company_id = fields.Many2one( + 'res.company', string='Company', + default=lambda self: self.env.company, + ) + sequence = fields.Integer(string='Sequence', default=10) + notes = fields.Text(string='Notes') + + def _record_decision(self, approved=True): + for rec in self: + self.env.cr.execute(""" + UPDATE fusion_accounting_rule + SET total_uses = total_uses + 1, + total_approved = total_approved + %s, + total_rejected = total_rejected + %s + WHERE id = %s + RETURNING total_uses, total_approved + """, (int(approved), int(not approved), rec.id)) + row = self.env.cr.fetchone() + rec.invalidate_recordset(['total_uses', 'total_approved', 'total_rejected']) + if row and row[0] > 0: + rec.confidence_score = row[1] / row[0] + rec._check_promotion() + + def _check_promotion(self): + for rec in self: + if (rec.approval_tier == 'needs_approval' + and rec.total_uses >= rec.min_sample_size + and rec.confidence_score >= rec.promotion_threshold): + rec.approval_tier = 'auto' + _logger.info( + "Rule '%s' promoted to auto-approved (confidence=%.2f, uses=%d)", + rec.name, rec.confidence_score, rec.total_uses, + ) + + def action_demote(self): + self.write({'approval_tier': 'needs_approval'}) + + def action_rollback(self): + for rec in self: + if rec.parent_rule_id: + rec.active = False + rec.parent_rule_id.active = True diff --git a/fusion_accounting/models/accounting_session.py b/fusion_accounting/models/accounting_session.py new file mode 100644 index 00000000..676a01c1 --- /dev/null +++ b/fusion_accounting/models/accounting_session.py @@ -0,0 +1,60 @@ +import logging +from odoo import models, fields, api + +_logger = logging.getLogger(__name__) + + +class FusionAccountingSession(models.Model): + _name = 'fusion.accounting.session' + _description = 'Fusion Accounting AI Session' + _order = 'create_date desc' + _inherit = ['mail.thread'] + + name = fields.Char( + string='Session', + required=True, + default=lambda self: self.env['ir.sequence'].next_by_code('fusion.accounting.session') or 'New', + ) + user_id = fields.Many2one( + 'res.users', string='User', + required=True, default=lambda self: self.env.user, + index=True, + ) + company_id = fields.Many2one( + 'res.company', string='Company', + required=True, default=lambda self: self.env.company, + ) + state = fields.Selection( + selection=[ + ('active', 'Active'), + ('closed', 'Closed'), + ], + string='Status', + default='active', + index=True, + ) + message_ids_json = fields.Text( + string='Messages (JSON)', + default='[]', + help='Stored conversation messages as JSON array.', + ) + context_domain = fields.Char( + string='Context Domain', + help='Active accounting domain when session started.', + ) + context_data = fields.Text( + string='Context Data (JSON)', + help='Additional Odoo context captured at session start.', + ) + match_history_ids = fields.One2many( + 'fusion.accounting.match.history', 'session_id', + string='Match History', + ) + token_count_in = fields.Integer(string='Tokens In', default=0) + token_count_out = fields.Integer(string='Tokens Out', default=0) + tool_call_count = fields.Integer(string='Tool Calls', default=0) + ai_provider = fields.Char(string='AI Provider') + ai_model = fields.Char(string='AI Model') + + def action_close_session(self): + self.write({'state': 'closed'}) diff --git a/fusion_accounting/models/accounting_tool.py b/fusion_accounting/models/accounting_tool.py new file mode 100644 index 00000000..e34f5151 --- /dev/null +++ b/fusion_accounting/models/accounting_tool.py @@ -0,0 +1,60 @@ +import logging +from odoo import models, fields, api + +_logger = logging.getLogger(__name__) + + +class FusionAccountingTool(models.Model): + _name = 'fusion.accounting.tool' + _description = 'Fusion Accounting AI Tool' + _order = 'domain, sequence, name' + + name = fields.Char(string='Technical Name', required=True, index=True) + display_name_field = fields.Char(string='Tool Label', required=True) + description = fields.Text(string='Description', required=True) + domain = fields.Selection( + selection=[ + ('bank_reconciliation', 'Bank Reconciliation'), + ('hst_management', 'HST/GST Management'), + ('accounts_receivable', 'Accounts Receivable'), + ('accounts_payable', 'Accounts Payable'), + ('journal_review', 'Journal Review'), + ('month_end', 'Month-End / Year-End'), + ('payroll_verification', 'Payroll Verification'), + ('inventory', 'Inventory & COGS'), + ('adp', 'ADP Reconciliation'), + ('reporting', 'Financial Reporting'), + ('audit', 'Audit & Integrity'), + ('payroll_management', 'Payroll Management'), + ], + string='Domain', + required=True, + index=True, + ) + tier = fields.Selection( + selection=[ + ('1', 'Tier 1 - Free (Read-Only)'), + ('2', 'Tier 2 - Auto-Approved'), + ('3', 'Tier 3 - Requires Approval'), + ], + string='Tier', + required=True, + default='1', + ) + parameters_schema = fields.Text(string='Parameters (JSON Schema)') + required_groups = fields.Char( + string='Required Groups', + help='Comma-separated XML IDs of required groups.', + ) + odoo_method = fields.Char(string='Odoo Method Reference') + sequence = fields.Integer(string='Sequence', default=10) + active = fields.Boolean(string='Active', default=True) + company_id = fields.Many2one( + 'res.company', string='Company', + default=lambda self: self.env.company, + ) + + _sql_constraints = [ + ('name_company_uniq', 'UNIQUE(name, company_id)', + 'Tool name must be unique per company.'), + ] diff --git a/fusion_accounting/report/audit_report_template.xml b/fusion_accounting/report/audit_report_template.xml new file mode 100644 index 00000000..a64ab47c --- /dev/null +++ b/fusion_accounting/report/audit_report_template.xml @@ -0,0 +1,84 @@ + + + + + Fusion AI Audit Report + fusion.accounting.dashboard + qweb-pdf + fusion_accounting.audit_report_document + fusion_accounting.audit_report_document + + report + + + + + diff --git a/fusion_accounting/security/ir.model.access.csv b/fusion_accounting/security/ir.model.access.csv new file mode 100644 index 00000000..27174c6c --- /dev/null +++ b/fusion_accounting/security/ir.model.access.csv @@ -0,0 +1,13 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_fusion_session_user,fusion.accounting.session.user,model_fusion_accounting_session,group_fusion_accounting_user,1,1,1,0 +access_fusion_session_admin,fusion.accounting.session.admin,model_fusion_accounting_session,group_fusion_accounting_admin,1,1,1,1 +access_fusion_history_user,fusion.accounting.match.history.user,model_fusion_accounting_match_history,group_fusion_accounting_user,1,0,0,0 +access_fusion_history_manager,fusion.accounting.match.history.manager,model_fusion_accounting_match_history,group_fusion_accounting_manager,1,1,1,0 +access_fusion_history_admin,fusion.accounting.match.history.admin,model_fusion_accounting_match_history,group_fusion_accounting_admin,1,1,1,1 +access_fusion_rule_user,fusion.accounting.rule.user,model_fusion_accounting_rule,group_fusion_accounting_user,1,0,0,0 +access_fusion_rule_manager,fusion.accounting.rule.manager,model_fusion_accounting_rule,group_fusion_accounting_manager,1,1,1,0 +access_fusion_rule_admin,fusion.accounting.rule.admin,model_fusion_accounting_rule,group_fusion_accounting_admin,1,1,1,1 +access_fusion_tool_user,fusion.accounting.tool.user,model_fusion_accounting_tool,group_fusion_accounting_user,1,0,0,0 +access_fusion_tool_admin,fusion.accounting.tool.admin,model_fusion_accounting_tool,group_fusion_accounting_admin,1,1,1,1 +access_fusion_dashboard_user,fusion.accounting.dashboard.user,model_fusion_accounting_dashboard,group_fusion_accounting_user,1,1,1,1 +access_fusion_rule_wizard_manager,fusion.accounting.rule.wizard.manager,model_fusion_accounting_rule_wizard,group_fusion_accounting_manager,1,1,1,1 diff --git a/fusion_accounting/security/security.xml b/fusion_accounting/security/security.xml new file mode 100644 index 00000000..25c3c297 --- /dev/null +++ b/fusion_accounting/security/security.xml @@ -0,0 +1,94 @@ + + + + + Fusion Accounting AI + 25 + + + + + Fusion Accounting AI + + + + + + User + 10 + + + + + + + Manager + 20 + + + + + + + Administrator + 30 + + + + + + + + + + + + + + + Fusion Session: Own Sessions + + [('user_id', '=', user.id)] + + + + + Fusion Session: All Sessions + + [(1, '=', 1)] + + + + + Fusion History: Own History + + [('session_id.user_id', '=', user.id)] + + + + + Fusion History: All History + + [(1, '=', 1)] + + + + + + Fusion Tool: Multi-Company + + ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] + + + + Fusion Rule: Multi-Company + + ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] + + + + Fusion History: Multi-Company + + ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] + + diff --git a/fusion_accounting/services/__init__.py b/fusion_accounting/services/__init__.py new file mode 100644 index 00000000..f25b2789 --- /dev/null +++ b/fusion_accounting/services/__init__.py @@ -0,0 +1,5 @@ +from . import adapters +from . import tools +from . import prompts +from . import agent +from . import scoring diff --git a/fusion_accounting/services/adapters/__init__.py b/fusion_accounting/services/adapters/__init__.py new file mode 100644 index 00000000..26807733 --- /dev/null +++ b/fusion_accounting/services/adapters/__init__.py @@ -0,0 +1,2 @@ +from . import claude +from . import openai_adapter diff --git a/fusion_accounting/services/adapters/claude.py b/fusion_accounting/services/adapters/claude.py new file mode 100644 index 00000000..668b4d7d --- /dev/null +++ b/fusion_accounting/services/adapters/claude.py @@ -0,0 +1,141 @@ +import json +import logging + +from odoo import models, api, _ +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + +try: + import anthropic as anthropic_sdk +except ImportError: + anthropic_sdk = None + + +class FusionAccountingAdapterClaude(models.AbstractModel): + _name = 'fusion.accounting.adapter.claude' + _description = 'Claude AI Adapter' + + def _get_client(self): + if anthropic_sdk is None: + raise UserError(_("The 'anthropic' Python package is not installed.")) + try: + key = self.env['fusion.api.service'].get_api_key( + provider_type='anthropic', + consumer='fusion_accounting', + feature='chat_with_tools', + ) + except Exception: + ICP = self.env['ir.config_parameter'].sudo() + key = ICP.get_param('fusion_accounting.anthropic_api_key', '') + if not key: + raise UserError(_("No Anthropic API key configured.")) + return anthropic_sdk.Anthropic(api_key=key) + + def _get_model_name(self): + ICP = self.env['ir.config_parameter'].sudo() + return ICP.get_param('fusion_accounting.claude_model', 'claude-sonnet-4-6') + + def _format_tools(self, tools): + formatted = [] + for tool in tools: + t = { + 'name': tool['name'], + 'description': tool['description'], + 'input_schema': tool.get('parameters', {'type': 'object', 'properties': {}}), + } + formatted.append(t) + return formatted + + def _supports_extended_thinking(self, model): + return '4-6' in model or '4-5' in model or '4-1' in model or '4-0' in model + + def call_with_tools(self, system_prompt, messages, tools=None): + client = self._get_client() + model = self._get_model_name() + + api_messages = [] + for msg in messages: + if msg['role'] in ('user', 'assistant'): + api_messages.append(msg) + + kwargs = { + 'model': model, + 'max_tokens': 16384, + 'system': system_prompt, + 'messages': api_messages, + } + if tools: + kwargs['tools'] = self._format_tools(tools) + + if self._supports_extended_thinking(model) and tools: + kwargs['thinking'] = { + 'type': 'enabled', + 'budget_tokens': 8192, + } + + try: + response = client.messages.create(**kwargs) + except anthropic_sdk.BadRequestError as e: + if 'thinking' in str(e).lower(): + kwargs.pop('thinking', None) + response = client.messages.create(**kwargs) + else: + raise UserError(_("Claude API error: %s", str(e))) + except Exception as e: + _logger.error("Claude API error: %s", e) + raise UserError(_("Claude API error: %s", str(e))) + + text_parts = [] + tool_calls = [] + thinking_text = [] + for block in response.content: + if block.type == 'text': + text_parts.append(block.text) + elif block.type == 'tool_use': + tool_calls.append({ + 'id': block.id, + 'name': block.name, + 'arguments': block.input, + }) + elif block.type == 'thinking': + thinking_text.append(block.thinking) + + if thinking_text: + _logger.debug("Claude thinking: %s", thinking_text[0][:500]) + + return { + 'text': '\n'.join(text_parts), + 'tool_calls': tool_calls if tool_calls else None, + 'tokens_in': response.usage.input_tokens, + 'tokens_out': response.usage.output_tokens, + 'stop_reason': response.stop_reason, + 'raw_content': response.content, + } + + def append_tool_results(self, messages, ai_response, tool_results): + assistant_content = [] + for block in ai_response.get('raw_content', []): + if hasattr(block, 'type'): + if block.type == 'text': + assistant_content.append({'type': 'text', 'text': block.text}) + elif block.type == 'tool_use': + assistant_content.append({ + 'type': 'tool_use', + 'id': block.id, + 'name': block.name, + 'input': block.input, + }) + + messages.append({'role': 'assistant', 'content': assistant_content}) + + tool_result_content = [] + for tr in tool_results: + tool_result_content.append({ + 'type': 'tool_result', + 'tool_use_id': tr['tool_call_id'], + 'content': tr['result'], + }) + messages.append({'role': 'user', 'content': tool_result_content}) + + return messages diff --git a/fusion_accounting/services/adapters/openai_adapter.py b/fusion_accounting/services/adapters/openai_adapter.py new file mode 100644 index 00000000..0f0d0b1d --- /dev/null +++ b/fusion_accounting/services/adapters/openai_adapter.py @@ -0,0 +1,137 @@ +import json +import logging + +from odoo import models, api, _ +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + +try: + from openai import OpenAI +except ImportError: + OpenAI = None + + +class FusionAccountingAdapterOpenAI(models.AbstractModel): + _name = 'fusion.accounting.adapter.openai' + _description = 'OpenAI AI Adapter' + + def _get_client(self): + if OpenAI is None: + raise UserError(_("The 'openai' Python package is not installed.")) + try: + key = self.env['fusion.api.service'].get_api_key( + provider_type='openai', + consumer='fusion_accounting', + feature='chat_with_tools', + ) + except Exception: + ICP = self.env['ir.config_parameter'].sudo() + key = ICP.get_param('fusion_accounting.openai_api_key', '') + if not key: + raise UserError(_("No OpenAI API key configured.")) + return OpenAI(api_key=key) + + def _get_model_name(self): + ICP = self.env['ir.config_parameter'].sudo() + return ICP.get_param('fusion_accounting.openai_model', 'gpt-5.4-mini') + + def _format_tools(self, tools): + formatted = [] + for tool in tools: + formatted.append({ + 'type': 'function', + 'function': { + 'name': tool['name'], + 'description': tool['description'], + 'parameters': tool.get('parameters', {'type': 'object', 'properties': {}}), + }, + }) + return formatted + + def _is_reasoning_model(self, model): + return model.startswith('o1') or model.startswith('o3') or model.startswith('o4') + + def call_with_tools(self, system_prompt, messages, tools=None): + client = self._get_client() + model = self._get_model_name() + is_reasoning = self._is_reasoning_model(model) + + if is_reasoning: + api_messages = [{'role': 'developer', 'content': system_prompt}] + else: + api_messages = [{'role': 'system', 'content': system_prompt}] + for msg in messages: + if msg['role'] in ('user', 'assistant', 'tool'): + api_messages.append(msg) + + kwargs = { + 'model': model, + 'messages': api_messages, + } + if is_reasoning: + kwargs['max_completion_tokens'] = 16384 + kwargs['reasoning_effort'] = 'medium' + else: + kwargs['max_tokens'] = 4096 + + if tools: + kwargs['tools'] = self._format_tools(tools) + + try: + response = client.chat.completions.create(**kwargs) + except Exception as e: + _logger.error("OpenAI API error: %s", e) + raise UserError(_("OpenAI API error: %s", str(e))) + + choice = response.choices[0] + message = choice.message + + tool_calls = [] + if message.tool_calls: + for tc in message.tool_calls: + try: + args = json.loads(tc.function.arguments) + except (json.JSONDecodeError, TypeError): + _logger.warning("Malformed tool args from OpenAI: %s", tc.function.arguments) + args = {} + tool_calls.append({ + 'id': tc.id, + 'name': tc.function.name, + 'arguments': args, + }) + + return { + 'text': message.content or '', + 'tool_calls': tool_calls if tool_calls else None, + 'tokens_in': response.usage.prompt_tokens, + 'tokens_out': response.usage.completion_tokens, + 'stop_reason': choice.finish_reason, + 'raw_message': message, + } + + def append_tool_results(self, messages, ai_response, tool_results): + raw_msg = ai_response.get('raw_message') + assistant_msg = {'role': 'assistant', 'content': raw_msg.content or ''} + if raw_msg.tool_calls: + assistant_msg['tool_calls'] = [ + { + 'id': tc.id, + 'type': 'function', + 'function': { + 'name': tc.function.name, + 'arguments': tc.function.arguments, + }, + } + for tc in raw_msg.tool_calls + ] + messages.append(assistant_msg) + + for tr in tool_results: + messages.append({ + 'role': 'tool', + 'tool_call_id': tr['tool_call_id'], + 'content': tr['result'], + }) + + return messages diff --git a/fusion_accounting/services/agent.py b/fusion_accounting/services/agent.py new file mode 100644 index 00000000..2a754c1a --- /dev/null +++ b/fusion_accounting/services/agent.py @@ -0,0 +1,315 @@ +import json +import logging +import time + +from odoo import models, fields, api, _ +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class FusionAccountingAgent(models.AbstractModel): + _name = 'fusion.accounting.agent' + _description = 'Fusion Accounting AI Agent Orchestrator' + + def _get_config(self, key, default=None): + ICP = self.env['ir.config_parameter'].sudo() + return ICP.get_param(f'fusion_accounting.{key}', default) + + def _get_adapter(self): + provider = self._get_config('ai_provider', 'claude') + if provider == 'claude': + return self.env['fusion.accounting.adapter.claude'] + return self.env['fusion.accounting.adapter.openai'] + + def _get_tool_registry(self): + return self.env['fusion.accounting.tool'].search([('active', '=', True)]) + + def _get_tools_for_user(self, user=None): + user = user or self.env.user + tools = self._get_tool_registry() + filtered = self.env['fusion.accounting.tool'] + for tool in tools: + if not tool.required_groups: + filtered |= tool + continue + group_xmlids = [g.strip() for g in tool.required_groups.split(',') if g.strip()] + if all(user.has_group(g) for g in group_xmlids): + filtered |= tool + return filtered + + def _build_tool_definitions(self, tools): + definitions = [] + for tool in tools: + defn = { + 'name': tool.name, + 'description': tool.description, + } + if tool.parameters_schema: + try: + defn['parameters'] = json.loads(tool.parameters_schema) + except json.JSONDecodeError: + defn['parameters'] = {'type': 'object', 'properties': {}} + else: + defn['parameters'] = {'type': 'object', 'properties': {}} + definitions.append(defn) + return definitions + + def _load_rules(self, domain=None): + rule_domain = [('active', '=', True), ('company_id', '=', self.env.company.id)] + if domain: + rule_domain.append(('rule_type', '=', domain)) + rules = self.env['fusion.accounting.rule'].search(rule_domain, order='sequence') + admin_rules = rules.filtered(lambda r: r.created_by == 'admin') + ai_auto = rules.filtered(lambda r: r.created_by == 'ai' and r.approval_tier == 'auto') + ai_pending = rules.filtered(lambda r: r.created_by == 'ai' and r.approval_tier == 'needs_approval') + return admin_rules | ai_auto | ai_pending + + def _load_match_history(self, domain=None, limit=None): + limit = limit or int(self._get_config('history_in_prompt', '50')) + history_domain = [('company_id', '=', self.env.company.id)] + if domain: + history_domain.append(('tool_name', 'ilike', domain)) + return self.env['fusion.accounting.match.history'].search( + history_domain, limit=limit, order='proposed_at desc', + ) + + def _build_system_prompt(self, rules, history, context=None, domain=None): + from .prompts.system_prompt import build_system_prompt + from .prompts.domain_prompts import get_domain_prompt + base = build_system_prompt(rules, history, context) + if domain: + domain_prompt = get_domain_prompt(domain) + if domain_prompt: + base = f"{base}\n\n{domain_prompt}" + return base + + def _execute_tool(self, tool_name, params, session_id=None): + from .tools import TOOL_DISPATCH + if tool_name not in TOOL_DISPATCH: + return {'error': f'Unknown tool: {tool_name}'} + try: + result = TOOL_DISPATCH[tool_name](self.env, params) + return result + except Exception as e: + _logger.error("Tool execution error (%s): %s", tool_name, e) + return {'error': str(e)} + + def _log_match_history(self, session, tool_name, params, result, reasoning='', + confidence=0.0, rule=None, tier='1'): + vals = { + 'session_id': session.id if session else False, + 'tool_name': tool_name, + 'tool_params': json.dumps(params) if params else '{}', + 'tool_result': json.dumps(result) if result else '{}', + 'ai_reasoning': reasoning, + 'ai_confidence': confidence, + 'rule_id': rule.id if rule else False, + 'proposed_at': fields.Datetime.now(), + 'decision': 'auto' if tier in ('1', '2') else 'pending', + 'company_id': self.env.company.id, + } + return self.env['fusion.accounting.match.history'].sudo().create(vals) + + def chat(self, session_id, user_message, context=None): + session = self.env['fusion.accounting.session'].browse(session_id) + if not session.exists(): + raise UserError(_("Session not found.")) + + adapter = self._get_adapter() + tools = self._get_tools_for_user() + tool_definitions = self._build_tool_definitions(tools) + rules = self._load_rules() + history = self._load_match_history() + system_prompt = self._build_system_prompt( + rules, history, context, domain=session.context_domain, + ) + + messages_json = json.loads(session.message_ids_json or '[]') + messages_json.append({'role': 'user', 'content': user_message}) + + max_turns = max(int(self._get_config('max_tool_calls', '20')), 1) + total_tokens_in = 0 + total_tokens_out = 0 + response = {'text': '', 'tool_calls': None} + + for turn in range(max_turns): + response = adapter.call_with_tools( + system_prompt=system_prompt, + messages=messages_json, + tools=tool_definitions, + ) + total_tokens_in += response.get('tokens_in', 0) + total_tokens_out += response.get('tokens_out', 0) + + if response.get('tool_calls'): + tool_results = [] + for tc in response['tool_calls']: + tool_name = tc['name'] + tool_params = tc.get('arguments', {}) + tool_rec = tools.filtered(lambda t: t.name == tool_name) + tier = tool_rec.tier if tool_rec else '1' + + if tier == '3': + history_rec = self._log_match_history( + session, tool_name, tool_params, None, + reasoning=tc.get('reasoning', ''), + confidence=tc.get('confidence', 0.0), + tier='3', + ) + tool_results.append({ + 'tool_call_id': tc.get('id', ''), + 'result': json.dumps({ + 'status': 'pending_approval', + 'match_history_id': history_rec.id, + 'message': f'Action requires user approval. Match history ID: {history_rec.id}', + }), + }) + else: + result = self._execute_tool(tool_name, tool_params, session.id) + self._log_match_history( + session, tool_name, tool_params, result, + reasoning=tc.get('reasoning', ''), + tier=tier, + ) + tool_results.append({ + 'tool_call_id': tc.get('id', ''), + 'result': json.dumps(result) if not isinstance(result, str) else result, + }) + try: + self._check_rule_proposal(tool_name, tool_params, session) + except Exception: + _logger.exception("Error in _check_rule_proposal for tool %s", tool_name) + + messages_json = adapter.append_tool_results( + messages_json, response, tool_results, + ) + session.tool_call_count += len(tool_results) + else: + assistant_text = response.get('text', '') + messages_json.append({'role': 'assistant', 'content': assistant_text}) + break + else: + # Loop exhausted — force a final text response without tools + try: + response = adapter.call_with_tools( + system_prompt=system_prompt, + messages=messages_json, + tools=[], + ) + total_tokens_in += response.get('tokens_in', 0) + total_tokens_out += response.get('tokens_out', 0) + messages_json.append({ + 'role': 'assistant', + 'content': response.get('text', 'I reached the maximum number of steps. Please continue the conversation.'), + }) + except Exception: + _logger.warning("Failed to get final summary after max tool calls") + + session.write({ + 'message_ids_json': json.dumps(messages_json), + 'token_count_in': session.token_count_in + total_tokens_in, + 'token_count_out': session.token_count_out + total_tokens_out, + 'ai_provider': self._get_config('ai_provider', 'claude'), + 'ai_model': adapter._get_model_name(), + }) + + pending = self.env['fusion.accounting.match.history'].search([ + ('session_id', '=', session.id), + ('decision', '=', 'pending'), + ]) + + return { + 'text': response.get('text', ''), + 'pending_approvals': [{ + 'id': p.id, + 'tool_name': p.tool_name, + 'params': p.tool_params, + 'reasoning': p.ai_reasoning, + 'confidence': p.ai_confidence, + 'amount': p.amount, + } for p in pending], + 'session_id': session.id, + } + + def approve_action(self, match_history_id): + history = self.env['fusion.accounting.match.history'].browse(match_history_id) + if not history.exists() or history.decision != 'pending': + raise UserError(_("Action not found or already decided.")) + + params = json.loads(history.tool_params or '{}') + result = self._execute_tool(history.tool_name, params, history.session_id.id) + + history.write({ + 'decision': 'approved', + 'decided_at': fields.Datetime.now(), + 'decided_by': self.env.user.id, + 'tool_result': json.dumps(result) if not isinstance(result, str) else result, + }) + if history.rule_id: + history.rule_id.sudo()._record_decision(approved=True) + + return result + + def _check_rule_proposal(self, tool_name, params, session): + """Detect repeated patterns and propose new rules when 3+ identical matches.""" + recent = self.env['fusion.accounting.match.history'].search([ + ('tool_name', '=', tool_name), + ('decision', 'in', ('approved', 'auto')), + ('company_id', '=', self.env.company.id), + ], limit=20, order='proposed_at desc') + + if len(recent) < 3: + return + + from collections import Counter + param_keys = [] + for h in recent: + try: + p = json.loads(h.tool_params or '{}') + key_parts = [] + for k in sorted(p.keys()): + if k not in ('limit', 'date_from', 'date_to'): + key_parts.append(f'{k}={json.dumps(p[k], sort_keys=True)}') + if key_parts: + param_keys.append('|'.join(key_parts)) + except json.JSONDecodeError: + continue + + counts = Counter(param_keys) + for pattern, count in counts.most_common(3): + if count < 3: + break + existing = self.env['fusion.accounting.rule'].search([ + ('match_logic', 'ilike', pattern[:50]), + ('company_id', '=', self.env.company.id), + ], limit=1) + if existing: + continue + + self.env['fusion.accounting.rule'].create({ + 'name': f'AI Pattern: {tool_name} ({pattern[:40]})', + 'rule_type': 'match', + 'description': f'Automatically detected pattern from {count} approved uses of {tool_name}.', + 'match_logic': f'When using {tool_name} with parameters matching: {pattern}', + 'created_by': 'ai', + 'approval_tier': 'needs_approval', + 'company_id': self.env.company.id, + }) + _logger.info("AI proposed rule for pattern: %s (%d matches)", tool_name, count) + + def reject_action(self, match_history_id, reason=''): + history = self.env['fusion.accounting.match.history'].browse(match_history_id) + if not history.exists() or history.decision != 'pending': + raise UserError(_("Action not found or already decided.")) + + history.write({ + 'decision': 'rejected', + 'decided_at': fields.Datetime.now(), + 'decided_by': self.env.user.id, + 'rejection_reason': reason, + }) + if history.rule_id: + history.rule_id.sudo()._record_decision(approved=False) + + return {'status': 'rejected', 'reason': reason} diff --git a/fusion_accounting/services/prompts/__init__.py b/fusion_accounting/services/prompts/__init__.py new file mode 100644 index 00000000..ff7682de --- /dev/null +++ b/fusion_accounting/services/prompts/__init__.py @@ -0,0 +1,2 @@ +from . import system_prompt +from . import domain_prompts diff --git a/fusion_accounting/services/prompts/domain_prompts.py b/fusion_accounting/services/prompts/domain_prompts.py new file mode 100644 index 00000000..dacb2bef --- /dev/null +++ b/fusion_accounting/services/prompts/domain_prompts.py @@ -0,0 +1,109 @@ +DOMAIN_PROMPTS = { + 'bank_reconciliation': """ +BANK RECONCILIATION CONTEXT: +You are helping with bank statement reconciliation. Key concepts: +- Bank statement lines (account.bank.statement.line) represent transactions from the bank feed. +- Each line needs to be matched to one or more journal items (account.move.line). +- Matching is done via set_line_bank_statement_line(move_line_ids). +- Fee differences (e.g., Elavon card processing fees) should be allocated to the fee account. +- Weekend batches may combine multiple days of card payments. +- Always verify amounts before proposing a match. +""", + + 'hst_management': """ +HST/GST MANAGEMENT CONTEXT: +You are helping with Canadian HST/GST tax management. +- HST Collected is tracked on account 2005 (credit balance = liability). +- Input Tax Credits (ITCs) are on account 2006 (debit balance = asset). +- Net HST = Collected - ITCs. Positive means owing to CRA. +- Quarterly filing periods. Check for missing tax on invoices/bills. +- All vendor bills should have ITCs unless explicitly exempt. +""", + + 'accounts_receivable': """ +ACCOUNTS RECEIVABLE CONTEXT: +- AR aging: current, 1-30, 31-60, 61-90, 90+ days overdue. +- Follow-up actions escalate by aging bucket. +- Payments should be matched to specific invoices. +- Unmatched payments sit on the Outstanding Receipts account (1122). +""", + + 'accounts_payable': """ +ACCOUNTS PAYABLE CONTEXT: +- AP aging mirrors AR: current through 90+ days. +- Watch for duplicate bills (same vendor + amount + date). +- Bills should match purchase orders when applicable. +- Tax on bills should match the vendor's fiscal position. +""", + + 'journal_review': """ +JOURNAL REVIEW CONTEXT: +- Check for wrong-direction balances (e.g., expense account with credit balance). +- Detect duplicate entries (same partner + amount + date + journal). +- Flag entries on unlikely accounts (revenue on a tax account, etc.). +- Sequence gaps may indicate deleted entries. +- Draft entries older than 30 days should be reviewed. +""", + + 'month_end': """ +MONTH-END CLOSE CONTEXT: +- Aggregate all domain checks into a close checklist. +- Verify all bank reconciliations are current. +- Check accrual account balances (vacation, sick leave, etc.). +- Verify no entries exist after lock date. +- Run hash integrity check. +- Produce period trial balance summary. +""", + + 'payroll_verification': """ +PAYROLL VERIFICATION CONTEXT: +- Cross-reference payroll journal entries to bank statement cheques. +- Verify CPP, EI, and income tax deductions against CRA rate tables. +- Check CRA remittance account balance vs payments made. +""", + + 'inventory': """ +INVENTORY & COGS CONTEXT: +- Stock In Hand tracked on account 1069. +- Price differences on account 5010 (PO price vs bill price). +- COGS ratio by product category helps spot anomalies. +- Large inventory adjustments need review. +""", + + 'adp': """ +ADP RECONCILIATION CONTEXT: +- ADP Receivable tracked on account 1101. +- ADP invoices have customer portion + ADP portion = total. +- Government deposits should match ADP invoices. +""", + + 'reporting': """ +FINANCIAL REPORTING CONTEXT: +- Reports use Odoo's account.report engine. +- Available: P&L, Balance Sheet, Trial Balance, Cash Flow. +- Period comparison available for trend analysis. +- Export to PDF or XLSX for external distribution. +""", + + 'audit': """ +AUDIT & INTEGRITY CONTEXT: +- Run comprehensive checks on posted entries. +- Verify hash chain integrity on journals. +- Check sequence continuity. +- Flag entries with chatter messages for review tracking. +- Audit status per account: todo / reviewed / supervised / anomaly. +""", + + 'payroll_management': """ +PAYROLL MANAGEMENT CONTEXT: +- Parse pasted payroll summaries from QBO or fusion_payroll. +- Create payroll journal entries with proper debit/credit lines. +- Match payroll cheques to bank statement lines. +- Calculate CRA obligations (CPP employer + employee, EI, income tax). +- Prepare CRA remittance payment entries. +""", +} + + +def get_domain_prompt(domain): + return DOMAIN_PROMPTS.get(domain, '') diff --git a/fusion_accounting/services/prompts/system_prompt.py b/fusion_accounting/services/prompts/system_prompt.py new file mode 100644 index 00000000..ca580435 --- /dev/null +++ b/fusion_accounting/services/prompts/system_prompt.py @@ -0,0 +1,98 @@ +import json + + +def build_system_prompt(rules, history, context=None): + parts = [ + CORE_SYSTEM_PROMPT, + _build_rules_section(rules), + _build_history_section(history), + ] + if context: + parts.append(_build_context_section(context)) + return '\n\n'.join(p for p in parts if p) + + +CORE_SYSTEM_PROMPT = """You are Fusion AI, an expert accounting co-pilot embedded in Odoo 19. +You assist with bank reconciliation, HST/GST management, AR/AP analysis, journal review, +month-end close, payroll, inventory, ADP reconciliation, financial reporting, and auditing. + +BEHAVIOUR: +- Use tools to query and act on Odoo data. Never invent financial figures. +- For Tier 1 tools: execute immediately and report results. +- For Tier 2 tools: execute and log. Inform the user what was done. +- For Tier 3 tools: propose the action with clear reasoning. The user must approve. +- When proposing a Tier 3 action, explain: what you want to do, why, the amounts involved, and your confidence level. +- Apply Fusion Rules (below) before general reasoning. +- Reference match history for patterns the user has approved/rejected before. +- Use Canadian English. Format monetary amounts with $ and two decimals. +- When you encounter ambiguity, ask clarifying questions rather than guessing. + +RESPONSE FORMATTING: +- Use rich Markdown formatting in your responses. The chat renders Markdown as HTML. +- Use **bold** for account names, amounts, and key terms. +- Use ## and ### headers to organize sections in longer responses. +- Use Markdown tables for tabular data (| col1 | col2 | format). +- Use bullet lists (- item) for findings, issues, and action items. +- Use numbered lists (1. item) for sequential steps or ranked items. +- Use `code` for account codes, reference numbers, and technical IDs. +- Use --- horizontal rules to separate sections in long reports. + +LINKING TO ODOO RECORDS: +- When referencing specific records, include clickable Odoo links. +- Journal entries: [INV/2026/00123](/odoo/accounting/123) where 123 is the move ID. +- Partners: [Customer Name](/odoo/contacts/456) where 456 is the partner ID. +- Accounts: reference by code in bold, e.g. **1001 - Cash**. +- Bank statement lines: mention the date, reference, and amount clearly. +- When tool results include record IDs, always link them. + +TOOL CALLING: +- Call tools by name with the required parameters. +- You may call multiple tools in sequence to gather data before proposing an action. +- Do not exceed the maximum tool calls per turn. +- When presenting tool results, format them richly with tables, bold amounts, and links. +""" + + +def _build_rules_section(rules): + if not rules: + return '' + lines = ['ACTIVE FUSION RULES:'] + for rule in rules: + priority = 'ADMIN' if rule.created_by == 'admin' else 'AI' + tier = 'auto' if rule.approval_tier == 'auto' else 'needs-approval' + lines.append( + f'- [{priority}/{tier}] {rule.name} ({rule.rule_type}): ' + f'{rule.description or rule.match_logic or "No description"}' + ) + if rule.match_logic: + lines.append(f' Match logic: {rule.match_logic}') + return '\n'.join(lines) + + +def _build_history_section(history): + if not history: + return '' + lines = ['RECENT MATCH HISTORY (learn from these patterns):'] + for h in history[:50]: + status = h.decision + reason = '' + if h.rejection_reason: + reason = f' (reason: {h.rejection_reason})' + lines.append( + f'- {h.tool_name}: {status}{reason} ' + f'[confidence={h.ai_confidence:.0%}]' + ) + if h.ai_reasoning: + lines.append(f' Reasoning: {h.ai_reasoning[:200]}') + return '\n'.join(lines) + + +def _build_context_section(context): + if not context: + return '' + if isinstance(context, dict): + parts = ['CURRENT CONTEXT:'] + for k, v in context.items(): + parts.append(f'- {k}: {v}') + return '\n'.join(parts) + return f'CURRENT CONTEXT: {context}' diff --git a/fusion_accounting/services/scoring.py b/fusion_accounting/services/scoring.py new file mode 100644 index 00000000..02b72b16 --- /dev/null +++ b/fusion_accounting/services/scoring.py @@ -0,0 +1,61 @@ +import logging + +from odoo import models, api + +_logger = logging.getLogger(__name__) + + +class FusionAccountingScoring(models.AbstractModel): + _name = 'fusion.accounting.scoring' + _description = 'Fusion Accounting Confidence Scoring' + + def calculate_confidence(self, tool_name, scenario_key=None): + domain = [('tool_name', '=', tool_name)] + if scenario_key: + domain.append(('tool_params', 'ilike', scenario_key)) + history = self.env['fusion.accounting.match.history'].search(domain) + if not history: + return 0.0 + decided = history.filtered(lambda h: h.decision in ('approved', 'rejected')) + if not decided: + return 0.0 + approved = len(decided.filtered(lambda h: h.decision == 'approved')) + return approved / len(decided) + + def check_promotions(self): + ICP = self.env['ir.config_parameter'].sudo() + threshold = float(ICP.get_param('fusion_accounting.tier3_threshold', '0.95')) + min_sample = int(ICP.get_param('fusion_accounting.tier3_min_sample', '30')) + + rules = self.env['fusion.accounting.rule'].search([ + ('active', '=', True), + ('approval_tier', '=', 'needs_approval'), + ]) + promoted = self.env['fusion.accounting.rule'] + for rule in rules: + if rule.total_uses >= min_sample and rule.confidence_score >= threshold: + rule.approval_tier = 'auto' + promoted |= rule + _logger.info( + "Promoted rule '%s' to auto (confidence=%.2f, sample=%d)", + rule.name, rule.confidence_score, rule.total_uses, + ) + return promoted + + def get_tool_stats(self, tool_name=None): + domain = [] + if tool_name: + domain.append(('tool_name', '=', tool_name)) + history = self.env['fusion.accounting.match.history'].search(domain) + + stats = {} + for h in history: + if h.tool_name not in stats: + stats[h.tool_name] = { + 'total': 0, 'approved': 0, 'rejected': 0, + 'pending': 0, 'auto': 0, + } + stats[h.tool_name]['total'] += 1 + if h.decision in stats[h.tool_name]: + stats[h.tool_name][h.decision] += 1 + return stats diff --git a/fusion_accounting/services/tools/__init__.py b/fusion_accounting/services/tools/__init__.py new file mode 100644 index 00000000..b97b6963 --- /dev/null +++ b/fusion_accounting/services/tools/__init__.py @@ -0,0 +1,19 @@ +from .bank_reconciliation import TOOLS as BANK_RECON_TOOLS +from .hst_management import TOOLS as HST_TOOLS +from .accounts_receivable import TOOLS as AR_TOOLS +from .accounts_payable import TOOLS as AP_TOOLS +from .journal_review import TOOLS as JOURNAL_TOOLS +from .month_end import TOOLS as MONTH_END_TOOLS +from .payroll import TOOLS as PAYROLL_TOOLS +from .inventory import TOOLS as INVENTORY_TOOLS +from .adp import TOOLS as ADP_TOOLS +from .reporting import TOOLS as REPORTING_TOOLS +from .audit import TOOLS as AUDIT_TOOLS + +TOOL_DISPATCH = {} +for tools_dict in [ + BANK_RECON_TOOLS, HST_TOOLS, AR_TOOLS, AP_TOOLS, JOURNAL_TOOLS, + MONTH_END_TOOLS, PAYROLL_TOOLS, INVENTORY_TOOLS, ADP_TOOLS, + REPORTING_TOOLS, AUDIT_TOOLS, +]: + TOOL_DISPATCH.update(tools_dict) diff --git a/fusion_accounting/services/tools/accounts_payable.py b/fusion_accounting/services/tools/accounts_payable.py new file mode 100644 index 00000000..5cfc47ee --- /dev/null +++ b/fusion_accounting/services/tools/accounts_payable.py @@ -0,0 +1,150 @@ +import logging +from odoo import fields +from datetime import timedelta + +_logger = logging.getLogger(__name__) + + +def get_ap_aging(env, params): + today = fields.Date.today() + domain = [ + ('account_id.account_type', '=', 'liability_payable'), + ('parent_state', '=', 'posted'), + ('reconciled', '=', False), + ('company_id', '=', env.company.id), + ] + amls = env['account.move.line'].search(domain) + + buckets = {'current': 0, '1_30': 0, '31_60': 0, '61_90': 0, '90_plus': 0} + for aml in amls: + amt = abs(aml.amount_residual) + if not aml.date_maturity or aml.date_maturity >= today: + buckets['current'] += amt + else: + days = (today - aml.date_maturity).days + if days <= 30: + buckets['1_30'] += amt + elif days <= 60: + buckets['31_60'] += amt + elif days <= 90: + buckets['61_90'] += amt + else: + buckets['90_plus'] += amt + + return {'total': sum(buckets.values()), 'buckets': buckets, 'line_count': len(amls)} + + +def find_duplicate_bills(env, params): + window_days = int(params.get('window_days', 7)) + bills = env['account.move'].search([ + ('move_type', 'in', ('in_invoice', 'in_refund')), + ('state', '=', 'posted'), + ('company_id', '=', env.company.id), + ], order='partner_id, amount_total, date') + + duplicates = [] + prev = None + for bill in bills: + if prev and ( + prev.partner_id == bill.partner_id + and abs(prev.amount_total - bill.amount_total) < 0.01 + and abs((prev.date - bill.date).days) <= window_days + ): + duplicates.append({ + 'bill_1': {'id': prev.id, 'name': prev.name, 'date': str(prev.date)}, + 'bill_2': {'id': bill.id, 'name': bill.name, 'date': str(bill.date)}, + 'partner': bill.partner_id.name, + 'amount': bill.amount_total, + }) + prev = bill + + return {'count': len(duplicates), 'duplicates': duplicates[:20]} + + +def match_bill_to_po(env, params): + bill_id = int(params['bill_id']) + bill = env['account.move'].browse(bill_id) + if not bill.exists(): + return {'error': 'Bill not found'} + matches = [] + for line in bill.invoice_line_ids: + if line.purchase_line_id: + matches.append({ + 'bill_line': line.name or '', + 'po': line.purchase_line_id.order_id.name, + 'po_line': line.purchase_line_id.name, + 'po_qty': line.purchase_line_id.product_qty, + 'bill_qty': line.quantity, + 'match': abs(line.quantity - line.purchase_line_id.product_qty) < 0.01, + }) + return {'bill': bill.name, 'matches': matches, 'unmatched_lines': len(bill.invoice_line_ids) - len(matches)} + + +def get_unpaid_bills(env, params): + domain = [ + ('move_type', 'in', ('in_invoice', 'in_refund')), + ('state', '=', 'posted'), + ('payment_state', 'in', ('not_paid', 'partial')), + ('company_id', '=', env.company.id), + ] + if params.get('partner_id'): + domain.append(('partner_id', '=', int(params['partner_id']))) + bills = env['account.move'].search(domain, order='invoice_date_due asc', limit=int(params.get('limit', 50))) + return { + 'count': len(bills), + 'total': sum(b.amount_residual for b in bills), + 'bills': [{ + 'id': b.id, 'name': b.name, + 'partner': b.partner_id.name if b.partner_id else '', + 'amount_total': b.amount_total, + 'amount_residual': b.amount_residual, + 'date_due': str(b.invoice_date_due) if b.invoice_date_due else '', + } for b in bills], + } + + +def verify_bill_taxes(env, params): + bill_id = int(params['bill_id']) + bill = env['account.move'].browse(bill_id) + if not bill.exists(): + return {'error': 'Bill not found'} + issues = [] + for line in bill.invoice_line_ids: + if line.product_id and not line.tax_ids: + issues.append({ + 'line': line.name or line.product_id.name, + 'issue': 'No tax applied to product line', + }) + return {'bill': bill.name, 'issues': issues, 'clean': len(issues) == 0} + + +def get_payment_schedule(env, params): + days_ahead = int(params.get('days_ahead', 30)) + cutoff = fields.Date.today() + timedelta(days=days_ahead) + bills = env['account.move'].search([ + ('move_type', '=', 'in_invoice'), + ('state', '=', 'posted'), + ('payment_state', 'in', ('not_paid', 'partial')), + ('invoice_date_due', '<=', cutoff), + ('company_id', '=', env.company.id), + ], order='invoice_date_due asc') + return { + 'period': f'Next {days_ahead} days', + 'total': sum(b.amount_residual for b in bills), + 'bills': [{ + 'id': b.id, 'name': b.name, + 'partner': b.partner_id.name if b.partner_id else '', + 'amount_residual': b.amount_residual, + 'date_due': str(b.invoice_date_due) if b.invoice_date_due else '', + } for b in bills[:50]], + } + + +TOOLS = { + 'get_ap_aging': get_ap_aging, + 'find_duplicate_bills': find_duplicate_bills, + 'match_bill_to_po': match_bill_to_po, + 'get_unpaid_bills': get_unpaid_bills, + 'verify_bill_taxes': verify_bill_taxes, + 'get_payment_schedule': get_payment_schedule, +} diff --git a/fusion_accounting/services/tools/accounts_receivable.py b/fusion_accounting/services/tools/accounts_receivable.py new file mode 100644 index 00000000..a7601c57 --- /dev/null +++ b/fusion_accounting/services/tools/accounts_receivable.py @@ -0,0 +1,165 @@ +import logging +from odoo import fields + +_logger = logging.getLogger(__name__) + + +def get_ar_aging(env, params): + today = fields.Date.today() + domain = [ + ('account_id.account_type', '=', 'asset_receivable'), + ('parent_state', '=', 'posted'), + ('reconciled', '=', False), + ('company_id', '=', env.company.id), + ] + amls = env['account.move.line'].search(domain) + + buckets = {'current': 0, '1_30': 0, '31_60': 0, '61_90': 0, '90_plus': 0} + for aml in amls: + if not aml.date_maturity or aml.date_maturity >= today: + buckets['current'] += aml.amount_residual + else: + days = (today - aml.date_maturity).days + if days <= 30: + buckets['1_30'] += aml.amount_residual + elif days <= 60: + buckets['31_60'] += aml.amount_residual + elif days <= 90: + buckets['61_90'] += aml.amount_residual + else: + buckets['90_plus'] += aml.amount_residual + + return { + 'total': sum(buckets.values()), + 'buckets': buckets, + 'line_count': len(amls), + } + + +def get_overdue_invoices(env, params): + today = fields.Date.today() + days_overdue = int(params.get('min_days_overdue', 1)) + from datetime import timedelta + cutoff = today - timedelta(days=days_overdue) + invoices = env['account.move'].search([ + ('move_type', '=', 'out_invoice'), + ('state', '=', 'posted'), + ('payment_state', 'in', ('not_paid', 'partial')), + ('invoice_date_due', '<', cutoff), + ('company_id', '=', env.company.id), + ], order='invoice_date_due asc', limit=int(params.get('limit', 50))) + return { + 'count': len(invoices), + 'invoices': [{ + 'id': inv.id, + 'name': inv.name, + 'partner': inv.partner_id.name if inv.partner_id else '', + 'email': inv.partner_id.email or '' if inv.partner_id else '', + 'phone': inv.partner_id.phone or '' if inv.partner_id else '', + 'amount_total': inv.amount_total, + 'amount_residual': inv.amount_residual, + 'date_due': str(inv.invoice_date_due), + 'days_overdue': (today - inv.invoice_date_due).days, + } for inv in invoices], + } + + +def get_partner_balance(env, params): + partner_id = int(params['partner_id']) + partner = env['res.partner'].browse(partner_id) + if not partner.exists(): + return {'error': 'Partner not found'} + amls = env['account.move.line'].search([ + ('partner_id', '=', partner_id), + ('account_id.account_type', '=', 'asset_receivable'), + ('parent_state', '=', 'posted'), + ('reconciled', '=', False), + ('company_id', '=', env.company.id), + ]) + return { + 'partner': partner.name, + 'balance': sum(aml.amount_residual for aml in amls), + 'open_items': [{ + 'id': aml.id, + 'ref': aml.ref or aml.move_id.name, + 'date': str(aml.date), + 'amount_residual': aml.amount_residual, + 'date_maturity': str(aml.date_maturity) if aml.date_maturity else '', + } for aml in amls], + } + + +def send_followup(env, params): + partner_id = int(params['partner_id']) + partner = env['res.partner'].browse(partner_id) + if not partner.exists(): + return {'error': 'Partner not found'} + options = { + 'partner_id': partner_id, + 'email': params.get('send_email', False), + 'print': params.get('print_letter', False), + 'sms': False, + } + if params.get('email_subject'): + options['email_subject'] = params['email_subject'] + if params.get('body'): + options['body'] = params['body'] + result = partner.execute_followup(options) + return {'status': 'sent', 'partner': partner.name, 'result': str(result) if result else 'done'} + + +def get_followup_report(env, params): + partner_id = int(params['partner_id']) + partner = env['res.partner'].browse(partner_id) + if not partner.exists(): + return {'error': 'Partner not found'} + try: + report = env['account.followup.report'] + html = report._get_followup_report_html(partner) + return {'partner': partner.name, 'html': html} + except Exception as e: + return {'error': str(e)} + + +def reconcile_payment_to_invoice(env, params): + move_line_ids = [int(x) for x in params['move_line_ids']] + amls = env['account.move.line'].browse(move_line_ids) + if len(amls) < 2: + return {'error': 'Need at least 2 journal items to reconcile'} + amls.reconcile() + return { + 'status': 'reconciled', + 'move_line_ids': move_line_ids, + } + + +def get_unmatched_payments(env, params): + domain = [ + ('account_id.account_type', '=', 'asset_receivable'), + ('parent_state', '=', 'posted'), + ('reconciled', '=', False), + ('move_id.payment_id', '!=', False), + ('company_id', '=', env.company.id), + ] + amls = env['account.move.line'].search(domain, order='date desc') + return { + 'count': len(amls), + 'payments': [{ + 'id': aml.id, + 'date': str(aml.date), + 'ref': aml.ref or aml.move_id.name, + 'partner': aml.partner_id.name if aml.partner_id else '', + 'amount': abs(aml.amount_residual), + } for aml in amls[:50]], + } + + +TOOLS = { + 'get_ar_aging': get_ar_aging, + 'get_overdue_invoices': get_overdue_invoices, + 'get_partner_balance': get_partner_balance, + 'send_followup': send_followup, + 'get_followup_report': get_followup_report, + 'reconcile_payment_to_invoice': reconcile_payment_to_invoice, + 'get_unmatched_payments': get_unmatched_payments, +} diff --git a/fusion_accounting/services/tools/adp.py b/fusion_accounting/services/tools/adp.py new file mode 100644 index 00000000..ccca45cf --- /dev/null +++ b/fusion_accounting/services/tools/adp.py @@ -0,0 +1,111 @@ +import logging +from odoo import fields + +_logger = logging.getLogger(__name__) + + +def get_adp_receivable_aging(env, params): + accounts = env['account.account'].search([ + ('code', '=like', '1101%'), + ('company_id', '=', env.company.id), + ]) + today = fields.Date.today() + amls = env['account.move.line'].search([ + ('account_id', 'in', accounts.ids), + ('reconciled', '=', False), + ('parent_state', '=', 'posted'), + ]) + buckets = {'current': 0, '1_30': 0, '31_60': 0, '61_90': 0, '90_plus': 0} + for aml in amls: + amt = abs(aml.amount_residual) + if not aml.date_maturity or aml.date_maturity >= today: + buckets['current'] += amt + else: + days = (today - aml.date_maturity).days + if days <= 30: + buckets['1_30'] += amt + elif days <= 60: + buckets['31_60'] += amt + elif days <= 90: + buckets['61_90'] += amt + else: + buckets['90_plus'] += amt + return {'total': sum(buckets.values()), 'buckets': buckets} + + +def match_adp_payment_to_invoice(env, params): + move_line_ids = [int(x) for x in params['move_line_ids']] + amls = env['account.move.line'].browse(move_line_ids).exists() + if len(amls) < 2: + return {'error': 'Need at least 2 existing journal items to reconcile'} + amls.reconcile() + return {'status': 'matched', 'move_line_ids': amls.ids} + + +def verify_adp_split(env, params): + invoice_id = int(params['invoice_id']) + invoice = env['account.move'].browse(invoice_id) + if not invoice.exists(): + return {'error': 'Invoice not found'} + lines = invoice.invoice_line_ids + total = invoice.amount_untaxed + return { + 'invoice': invoice.name, + 'total_untaxed': total, + 'total_with_tax': invoice.amount_total, + 'lines': [{'name': l.name, 'subtotal': l.price_subtotal, 'total': l.price_total} for l in lines], + 'balanced': abs(sum(l.price_subtotal for l in lines) - total) < 0.01, + } + + +def find_adp_without_payment(env, params): + adp_partner = env['res.partner'].search([('name', 'ilike', 'ADP')], limit=1) + if not adp_partner: + return {'status': 'info', 'message': 'No ADP partner found in the system.'} + invoices = env['account.move'].search([ + ('partner_id', '=', adp_partner.id), + ('move_type', '=', 'out_invoice'), + ('state', '=', 'posted'), + ('payment_state', 'in', ('not_paid', 'partial')), + ]) + return { + 'count': len(invoices), + 'invoices': [{ + 'id': inv.id, 'name': inv.name, + 'amount': inv.amount_residual, 'date': str(inv.date), + } for inv in invoices[:20]], + } + + +def get_adp_summary(env, params): + date_from = params.get('date_from') + date_to = params.get('date_to') + accounts = env['account.account'].search([ + ('code', '=like', '1101%'), ('company_id', '=', env.company.id), + ]) + domain = [ + ('account_id', 'in', accounts.ids), + ('parent_state', '=', 'posted'), + ] + if date_from: + domain.append(('date', '>=', date_from)) + if date_to: + domain.append(('date', '<=', date_to)) + lines = env['account.move.line'].search(domain) + total_debit = sum(l.debit for l in lines) + total_credit = sum(l.credit for l in lines) + return { + 'period': f'{date_from or "all"} to {date_to or "now"}', + 'billed': total_debit, + 'collected': total_credit, + 'outstanding': total_debit - total_credit, + } + + +TOOLS = { + 'get_adp_receivable_aging': get_adp_receivable_aging, + 'match_adp_payment_to_invoice': match_adp_payment_to_invoice, + 'verify_adp_split': verify_adp_split, + 'find_adp_without_payment': find_adp_without_payment, + 'get_adp_summary': get_adp_summary, +} diff --git a/fusion_accounting/services/tools/audit.py b/fusion_accounting/services/tools/audit.py new file mode 100644 index 00000000..c64b1616 --- /dev/null +++ b/fusion_accounting/services/tools/audit.py @@ -0,0 +1,156 @@ +import logging +from odoo import fields + +_logger = logging.getLogger(__name__) + + +def audit_posted_entry(env, params): + move_id = int(params['move_id']) + move = env['account.move'].browse(move_id) + if not move.exists(): + return {'error': 'Entry not found'} + issues = [] + total_debit = sum(l.debit for l in move.line_ids) + total_credit = sum(l.credit for l in move.line_ids) + if abs(total_debit - total_credit) > 0.01: + issues.append({'severity': 'critical', 'issue': f'Unbalanced entry: debit={total_debit}, credit={total_credit}'}) + for line in move.line_ids: + if not line.account_id: + issues.append({'severity': 'critical', 'issue': f'Line missing account: {line.name}'}) + if not move.line_ids: + issues.append({'severity': 'warning', 'issue': 'Entry has no lines'}) + return { + 'move': move.name, 'date': str(move.date), + 'issues': issues, 'clean': len(issues) == 0, + } + + +def audit_account_balances(env, params): + from .journal_review import find_wrong_direction_balances + return find_wrong_direction_balances(env, params) + + +def audit_tax_compliance(env, params): + from .hst_management import find_missing_tax_invoices, find_missing_itc_bills + invoices = find_missing_tax_invoices(env, params) + bills = find_missing_itc_bills(env, params) + return { + 'missing_tax_invoices': invoices.get('missing_tax_count', 0), + 'missing_itc_bills': bills.get('missing_itc_count', 0), + 'total_issues': invoices.get('missing_tax_count', 0) + bills.get('missing_itc_count', 0), + } + + +def audit_reconciliation_integrity(env, params): + from .journal_review import verify_reconciliation_integrity + return verify_reconciliation_integrity(env, params) + + +def check_hash_chain(env, params): + from .month_end import run_hash_integrity_check + return run_hash_integrity_check(env, params) + + +def check_sequence_gaps(env, params): + from .journal_review import find_sequence_gaps + return find_sequence_gaps(env, params) + + +def flag_entry(env, params): + move_id = int(params['move_id']) + flag = params.get('flag', 'Review Required') + recommendation = params.get('recommendation', '') + move = env['account.move'].browse(move_id) + if not move.exists(): + return {'error': 'Entry not found'} + body = f'🏴 {flag}
{recommendation}' + move.message_post(body=body, message_type='comment', subtype_xmlid='mail.mt_note') + return {'status': 'flagged', 'move': move.name, 'flag': flag} + + +def get_audit_status(env, params): + statuses = env['account.audit.account.status'].search([]) + return { + 'statuses': [{ + 'id': s.id, + 'account': s.account_id.name, + 'status': s.status, + 'audit': s.audit_id.display_name if s.audit_id else '', + } for s in statuses[:50]], + } + + +def set_audit_status(env, params): + status_id = int(params['status_id']) + new_status = params['status'] + rec = env['account.audit.account.status'].browse(status_id) + if not rec.exists(): + return {'error': 'Audit status record not found'} + rec.status = new_status + return {'status': 'updated', 'id': status_id, 'new_status': new_status} + + +def get_audit_trail(env, params): + move_id = int(params['move_id']) + move = env['account.move'].browse(move_id) + if not move.exists(): + return {'error': 'Entry not found'} + messages = env['mail.message'].search([ + ('model', '=', 'account.move'), + ('res_id', '=', move_id), + ], order='date desc', limit=20) + return { + 'move': move.name, + 'messages': [{ + 'date': str(m.date), + 'author': m.author_id.name if m.author_id else '', + 'body': m.body or '', + 'type': m.message_type, + } for m in messages], + } + + +def run_full_audit(env, params): + results = {} + results['account_balances'] = audit_account_balances(env, params) + results['tax_compliance'] = audit_tax_compliance(env, params) + results['reconciliation'] = audit_reconciliation_integrity(env, params) + results['hash_chain'] = check_hash_chain(env, params) + results['sequence_gaps'] = check_sequence_gaps(env, params) + + total_issues = 0 + for key, val in results.items(): + total_issues += val.get('count', 0) + val.get('total_issues', 0) + + score = max(0, 100 - total_issues * 5) + return { + 'score': min(100, score), + 'total_issues': total_issues, + 'details': results, + } + + +def get_audit_report(env, params): + audit = run_full_audit(env, params) + report_lines = [f"Audit Score: {audit['score']}/100", f"Total Issues: {audit['total_issues']}", ''] + for domain, detail in audit.get('details', {}).items(): + report_lines.append(f"--- {domain.replace('_', ' ').title()} ---") + count = detail.get('count', detail.get('total_issues', 0)) + report_lines.append(f" Issues: {count}") + return {'report': '\n'.join(report_lines), 'score': audit['score']} + + +TOOLS = { + 'audit_posted_entry': audit_posted_entry, + 'audit_account_balances': audit_account_balances, + 'audit_tax_compliance': audit_tax_compliance, + 'audit_reconciliation_integrity': audit_reconciliation_integrity, + 'check_hash_chain': check_hash_chain, + 'check_sequence_gaps': check_sequence_gaps, + 'flag_entry': flag_entry, + 'get_audit_status': get_audit_status, + 'set_audit_status': set_audit_status, + 'get_audit_trail': get_audit_trail, + 'run_full_audit': run_full_audit, + 'get_audit_report': get_audit_report, +} diff --git a/fusion_accounting/services/tools/bank_reconciliation.py b/fusion_accounting/services/tools/bank_reconciliation.py new file mode 100644 index 00000000..cb7d6052 --- /dev/null +++ b/fusion_accounting/services/tools/bank_reconciliation.py @@ -0,0 +1,177 @@ +import logging +from datetime import datetime + +_logger = logging.getLogger(__name__) + + +def get_unreconciled_bank_lines(env, params): + domain = [('is_reconciled', '=', False), ('company_id', '=', env.company.id)] + if params.get('journal_id'): + domain.append(('journal_id', '=', int(params['journal_id']))) + if params.get('date_from'): + domain.append(('date', '>=', params['date_from'])) + if params.get('date_to'): + domain.append(('date', '<=', params['date_to'])) + if params.get('min_amount'): + domain.append(('amount', '>=', float(params['min_amount']))) + limit = int(params.get('limit', 50)) + lines = env['account.bank.statement.line'].search(domain, limit=limit, order='date desc') + return { + 'count': len(lines), + 'total_amount': sum(abs(l.amount) for l in lines), + 'lines': [{ + 'id': l.id, + 'date': str(l.date), + 'payment_ref': l.payment_ref or '', + 'partner_name': l.partner_name or (l.partner_id.name if l.partner_id else ''), + 'amount': l.amount, + 'journal': l.journal_id.name, + } for l in lines], + } + + +def get_unreconciled_receipts(env, params): + account_code = params.get('account_code', '1122') + accounts = env['account.account'].search([ + ('code', '=like', f'{account_code}%'), + ('company_id', '=', env.company.id), + ]) + domain = [ + ('account_id', 'in', accounts.ids), + ('reconciled', '=', False), + ('parent_state', '=', 'posted'), + ('company_id', '=', env.company.id), + ] + lines = env['account.move.line'].search(domain, order='date desc') + return { + 'count': len(lines), + 'total_amount': sum(abs(l.amount_residual) for l in lines), + 'lines': [{ + 'id': l.id, + 'date': str(l.date), + 'ref': l.ref or l.move_id.name, + 'partner': l.partner_id.name if l.partner_id else '', + 'amount_residual': l.amount_residual, + } for l in lines], + } + + +def match_bank_line_to_payments(env, params): + st_line_id = int(params['statement_line_id']) + move_line_ids = [int(x) for x in params['move_line_ids']] + st_line = env['account.bank.statement.line'].browse(st_line_id) + if not st_line.exists(): + return {'error': 'Statement line not found'} + st_line.set_line_bank_statement_line(move_line_ids) + return { + 'status': 'matched', + 'statement_line_id': st_line_id, + 'matched_move_lines': move_line_ids, + 'is_reconciled': st_line.is_reconciled, + } + + +def auto_reconcile_bank_lines(env, params): + company_id = params.get('company_id', env.company.id) + lines = env['account.bank.statement.line'].search([ + ('is_reconciled', '=', False), + ('company_id', '=', int(company_id)), + ]) + before_count = len(lines) + lines._try_auto_reconcile_statement_lines(company_id=int(company_id)) + still_unreconciled = env['account.bank.statement.line'].search([ + ('is_reconciled', '=', False), + ('company_id', '=', int(company_id)), + ]) + reconciled_count = before_count - len(still_unreconciled) + return { + 'status': 'completed', + 'lines_before': before_count, + 'lines_reconciled': reconciled_count, + 'lines_remaining': len(still_unreconciled), + } + + +def apply_reconcile_model(env, params): + model_id = int(params['model_id']) + st_line_id = int(params['statement_line_id']) + reco_model = env['account.reconcile.model'].browse(model_id) + st_line = env['account.bank.statement.line'].browse(st_line_id) + if not reco_model.exists() or not st_line.exists(): + return {'error': 'Model or statement line not found'} + _liquidity_lines, suspense_lines, _other_lines = st_line._seek_for_lines() + residual = sum(l.amount_residual for l in suspense_lines) if suspense_lines else st_line.amount + write_off_vals = reco_model._get_write_off_move_lines_dict(st_line, residual) + if write_off_vals: + line_ids_create_command = [(0, 0, vals) for vals in write_off_vals] + st_line.move_id.write({'line_ids': line_ids_create_command}) + return { + 'status': 'applied', + 'model': reco_model.name, + 'write_off_lines': len(write_off_vals) if write_off_vals else 0, + } + + +def unmatch_bank_line(env, params): + st_line_id = int(params['statement_line_id']) + st_line = env['account.bank.statement.line'].browse(st_line_id) + if not st_line.exists(): + return {'error': 'Statement line not found'} + st_line.action_unreconcile_entry() + return {'status': 'unmatched', 'statement_line_id': st_line_id} + + +def get_reconcile_suggestions(env, params): + st_line_id = int(params['statement_line_id']) + st_line = env['account.bank.statement.line'].browse(st_line_id) + if not st_line.exists(): + return {'error': 'Statement line not found'} + models = env['account.reconcile.model'].search([ + ('company_id', '=', env.company.id), + ]) + return { + 'models': [{ + 'id': m.id, + 'name': m.name, + 'trigger': m.trigger if hasattr(m, 'trigger') else 'manual', + } for m in models], + } + + +def sum_payments_by_date(env, params): + date_from = params.get('date_from') + date_to = params.get('date_to') + if not date_from or not date_to: + return {'error': 'date_from and date_to are required'} + journal_ids = params.get('journal_ids', []) + domain = [ + ('parent_state', '=', 'posted'), + ('company_id', '=', env.company.id), + ('date', '>=', date_from), + ('date', '<=', date_to), + ] + if journal_ids: + domain.append(('journal_id', 'in', [int(j) for j in journal_ids])) + lines = env['account.move.line'].search(domain) + total_debit = sum(l.debit for l in lines) + total_credit = sum(l.credit for l in lines) + return { + 'date_from': date_from, + 'date_to': date_to, + 'total_debit': total_debit, + 'total_credit': total_credit, + 'net': total_debit - total_credit, + 'line_count': len(lines), + } + + +TOOLS = { + 'get_unreconciled_bank_lines': get_unreconciled_bank_lines, + 'get_unreconciled_receipts': get_unreconciled_receipts, + 'match_bank_line_to_payments': match_bank_line_to_payments, + 'auto_reconcile_bank_lines': auto_reconcile_bank_lines, + 'apply_reconcile_model': apply_reconcile_model, + 'unmatch_bank_line': unmatch_bank_line, + 'get_reconcile_suggestions': get_reconcile_suggestions, + 'sum_payments_by_date': sum_payments_by_date, +} diff --git a/fusion_accounting/services/tools/hst_management.py b/fusion_accounting/services/tools/hst_management.py new file mode 100644 index 00000000..eff64d11 --- /dev/null +++ b/fusion_accounting/services/tools/hst_management.py @@ -0,0 +1,171 @@ +import logging + +_logger = logging.getLogger(__name__) + + +def calculate_hst_balance(env, params): + date_from = params.get('date_from') + date_to = params.get('date_to') + base_domain = [ + ('parent_state', '=', 'posted'), + ('company_id', '=', env.company.id), + ] + if date_from: + base_domain.append(('date', '>=', date_from)) + if date_to: + base_domain.append(('date', '<=', date_to)) + + collected_accounts = env['account.account'].search([ + ('code', '=like', '2005%'), ('company_id', '=', env.company.id), + ]) + itc_accounts = env['account.account'].search([ + ('code', '=like', '2006%'), ('company_id', '=', env.company.id), + ]) + + collected_lines = env['account.move.line'].search( + base_domain + [('account_id', 'in', collected_accounts.ids)] + ) + itc_lines = env['account.move.line'].search( + base_domain + [('account_id', 'in', itc_accounts.ids)] + ) + + hst_collected = abs(sum(l.balance for l in collected_lines)) + itcs = abs(sum(l.balance for l in itc_lines)) + + return { + 'hst_collected': hst_collected, + 'input_tax_credits': itcs, + 'net_hst': hst_collected - itcs, + 'status': 'owing' if (hst_collected - itcs) > 0 else 'refund', + 'period': f'{date_from or "all"} to {date_to or "now"}', + } + + +def get_tax_report(env, params): + report_ref = params.get('report_ref', 'account.generic_tax_report') + try: + report = env.ref(report_ref) + except Exception: + return {'error': f'Report not found: {report_ref}'} + options = report.get_options({ + 'date': { + 'date_from': params.get('date_from', ''), + 'date_to': params.get('date_to', ''), + } + }) + lines = report._get_lines(options) + return { + 'report_name': report.name, + 'lines': [{ + 'name': l.get('name', ''), + 'columns': [c.get('no_format', c.get('name', '')) for c in l.get('columns', [])], + } for l in lines[:50]], + } + + +def find_missing_tax_invoices(env, params): + date_from = params.get('date_from') + date_to = params.get('date_to') + domain = [ + ('move_type', 'in', ('out_invoice', 'out_refund')), + ('state', '=', 'posted'), + ('company_id', '=', env.company.id), + ] + if date_from: + domain.append(('date', '>=', date_from)) + if date_to: + domain.append(('date', '<=', date_to)) + + invoices = env['account.move'].search(domain) + missing = invoices.filtered( + lambda inv: not any(line.tax_ids for line in inv.invoice_line_ids) + ) + return { + 'total_invoices': len(invoices), + 'missing_tax_count': len(missing), + 'invoices': [{ + 'id': inv.id, + 'name': inv.name, + 'partner': inv.partner_id.name if inv.partner_id else '', + 'amount_total': inv.amount_total, + 'date': str(inv.date), + } for inv in missing[:30]], + } + + +def find_missing_itc_bills(env, params): + date_from = params.get('date_from') + date_to = params.get('date_to') + domain = [ + ('move_type', 'in', ('in_invoice', 'in_refund')), + ('state', '=', 'posted'), + ('company_id', '=', env.company.id), + ] + if date_from: + domain.append(('date', '>=', date_from)) + if date_to: + domain.append(('date', '<=', date_to)) + + bills = env['account.move'].search(domain) + missing = bills.filtered( + lambda b: not any(line.tax_ids for line in b.invoice_line_ids) + ) + return { + 'total_bills': len(bills), + 'missing_itc_count': len(missing), + 'bills': [{ + 'id': b.id, + 'name': b.name, + 'partner': b.partner_id.name if b.partner_id else '', + 'amount_total': b.amount_total, + 'date': str(b.date), + } for b in missing[:30]], + } + + +def get_tax_return_status(env, params): + returns = env['account.return'].search([ + ('company_id', '=', env.company.id), + ], order='date_start desc', limit=10) + return { + 'returns': [{ + 'id': r.id, + 'name': r.display_name, + 'date_start': str(r.date_start) if hasattr(r, 'date_start') else '', + 'date_end': str(r.date_end) if hasattr(r, 'date_end') else '', + 'state': r.state if hasattr(r, 'state') else '', + } for r in returns], + } + + +def generate_tax_return(env, params): + try: + env['account.return']._generate_or_refresh_all_returns( + company=env.company + ) + return {'status': 'generated', 'message': 'Tax returns refreshed successfully.'} + except Exception as e: + return {'error': str(e)} + + +def validate_tax_return(env, params): + return_id = int(params['return_id']) + tax_return = env['account.return'].browse(return_id) + if not tax_return.exists(): + return {'error': 'Tax return not found'} + try: + tax_return.action_validate() + return {'status': 'validated', 'return_id': return_id} + except Exception as e: + return {'error': str(e)} + + +TOOLS = { + 'calculate_hst_balance': calculate_hst_balance, + 'get_tax_report': get_tax_report, + 'find_missing_tax_invoices': find_missing_tax_invoices, + 'find_missing_itc_bills': find_missing_itc_bills, + 'get_tax_return_status': get_tax_return_status, + 'generate_tax_return': generate_tax_return, + 'validate_tax_return': validate_tax_return, +} diff --git a/fusion_accounting/services/tools/inventory.py b/fusion_accounting/services/tools/inventory.py new file mode 100644 index 00000000..34d04d5a --- /dev/null +++ b/fusion_accounting/services/tools/inventory.py @@ -0,0 +1,113 @@ +import logging +from odoo import fields + +_logger = logging.getLogger(__name__) + + +def get_stock_valuation(env, params): + accounts = env['account.account'].search([ + ('code', '=like', '1069%'), + ('company_id', '=', env.company.id), + ]) + result = [] + for acct in accounts: + balance = sum(env['account.move.line'].search([ + ('account_id', '=', acct.id), + ('parent_state', '=', 'posted'), + ]).mapped('balance')) + result.append({'code': acct.code, 'name': acct.name, 'balance': balance}) + return {'accounts': result, 'total': sum(r['balance'] for r in result)} + + +def get_price_differences(env, params): + accounts = env['account.account'].search([ + ('code', '=like', '5010%'), + ('company_id', '=', env.company.id), + ]) + domain = [ + ('account_id', 'in', accounts.ids), + ('parent_state', '=', 'posted'), + ('company_id', '=', env.company.id), + ] + if params.get('date_from'): + domain.append(('date', '>=', params['date_from'])) + if params.get('date_to'): + domain.append(('date', '<=', params['date_to'])) + lines = env['account.move.line'].search(domain, order='date desc', limit=50) + return { + 'total': sum(l.balance for l in lines), + 'entries': [{ + 'id': l.id, 'date': str(l.date), + 'move': l.move_id.name, 'amount': l.balance, + 'partner': l.partner_id.name if l.partner_id else '', + } for l in lines], + } + + +def get_cogs_ratio_by_category(env, params): + date_from = params.get('date_from') + date_to = params.get('date_to') + base_domain = [ + ('parent_state', '=', 'posted'), + ('company_id', '=', env.company.id), + ] + if date_from: + base_domain.append(('date', '>=', date_from)) + if date_to: + base_domain.append(('date', '<=', date_to)) + + revenue_lines = env['account.move.line'].search( + base_domain + [('account_id.account_type', '=', 'income')] + ) + cogs_lines = env['account.move.line'].search( + base_domain + [('account_id.account_type', '=', 'expense_direct_cost')] + ) + revenue = abs(sum(l.balance for l in revenue_lines)) + cogs = abs(sum(l.balance for l in cogs_lines)) + ratio = (cogs / revenue * 100) if revenue else 0 + return {'revenue': revenue, 'cogs': cogs, 'ratio_pct': round(ratio, 2)} + + +def find_unusual_adjustments(env, params): + threshold = float(params.get('threshold', 1000)) + domain = [ + ('parent_state', '=', 'posted'), + ('company_id', '=', env.company.id), + ('account_id.account_type', '=', 'expense_direct_cost'), + ] + lines = env['account.move.line'].search(domain) + unusual = lines.filtered(lambda l: abs(l.balance) > threshold) + return { + 'count': len(unusual), + 'adjustments': [{ + 'id': l.id, 'date': str(l.date), 'move': l.move_id.name, + 'amount': l.balance, 'name': l.name or '', + } for l in unusual[:20]], + } + + +def get_inventory_turnover(env, params): + from .reporting import get_profit_loss + pl = get_profit_loss(env, params) + stock = get_stock_valuation(env, params) + avg_inventory = stock.get('total', 0) + cogs = 0 + for line in pl.get('lines', []): + if 'cost' in line.get('name', '').lower(): + cols = line.get('columns', []) + if cols: + try: + cogs = float(cols[0]) + except (ValueError, TypeError): + pass + turnover = (cogs / avg_inventory) if avg_inventory else 0 + return {'cogs': cogs, 'avg_inventory': avg_inventory, 'turnover': round(turnover, 2)} + + +TOOLS = { + 'get_stock_valuation': get_stock_valuation, + 'get_price_differences': get_price_differences, + 'get_cogs_ratio_by_category': get_cogs_ratio_by_category, + 'find_unusual_adjustments': find_unusual_adjustments, + 'get_inventory_turnover': get_inventory_turnover, +} diff --git a/fusion_accounting/services/tools/journal_review.py b/fusion_accounting/services/tools/journal_review.py new file mode 100644 index 00000000..99120b42 --- /dev/null +++ b/fusion_accounting/services/tools/journal_review.py @@ -0,0 +1,220 @@ +import logging +from odoo import fields + +_logger = logging.getLogger(__name__) + +ACCOUNT_TYPE_EXPECTED_DIRECTION = { + 'asset_receivable': 'debit', + 'asset_cash': 'debit', + 'asset_current': 'debit', + 'asset_non_current': 'debit', + 'asset_prepayments': 'debit', + 'asset_fixed': 'debit', + 'liability_payable': 'credit', + 'liability_credit_card': 'credit', + 'liability_current': 'credit', + 'liability_non_current': 'credit', + 'equity': 'credit', + 'equity_unaffected': 'credit', + 'income': 'credit', + 'income_other': 'credit', + 'expense': 'debit', + 'expense_depreciation': 'debit', + 'expense_direct_cost': 'debit', + 'off_balance': None, +} + + +def find_wrong_direction_balances(env, params): + balance_data = env['account.move.line'].read_group( + [('parent_state', '=', 'posted'), ('company_id', '=', env.company.id)], + ['balance:sum'], ['account_id'], + ) + acct_ids = [row['account_id'][0] for row in balance_data if row.get('account_id')] + acct_map = {} + if acct_ids: + for acct in env['account.account'].browse(acct_ids): + acct_map[acct.id] = acct + + issues = [] + for row in balance_data: + if not row.get('account_id'): + continue + acct = acct_map.get(row['account_id'][0]) + if not acct: + continue + expected = ACCOUNT_TYPE_EXPECTED_DIRECTION.get(acct.account_type) + if not expected: + continue + balance = row.get('balance', 0) or 0 + if (expected == 'debit' and balance < -0.01) or (expected == 'credit' and balance > 0.01): + issues.append({ + 'account_id': acct.id, + 'code': acct.code, + 'name': acct.name, + 'balance': balance, + 'expected': expected, + 'actual': 'credit' if balance < 0 else 'debit', + }) + return {'count': len(issues), 'issues': issues} + + +def find_duplicate_entries(env, params): + date_from = params.get('date_from') + date_to = params.get('date_to') + domain = [ + ('state', '=', 'posted'), + ('company_id', '=', env.company.id), + ] + if date_from: + domain.append(('date', '>=', date_from)) + if date_to: + domain.append(('date', '<=', date_to)) + moves = env['account.move'].search(domain, order='partner_id, amount_total, date') + + duplicates = [] + prev = None + for move in moves: + if prev and ( + prev.partner_id == move.partner_id and prev.partner_id + and abs(prev.amount_total - move.amount_total) < 0.01 + and prev.date == move.date + and prev.journal_id == move.journal_id + ): + duplicates.append({ + 'entry_1': {'id': prev.id, 'name': prev.name}, + 'entry_2': {'id': move.id, 'name': move.name}, + 'partner': move.partner_id.name, + 'amount': move.amount_total, + 'date': str(move.date), + }) + prev = move + return {'count': len(duplicates), 'duplicates': duplicates[:20]} + + +def find_wrong_account_entries(env, params): + date_from = params.get('date_from') + date_to = params.get('date_to') + domain = [ + ('parent_state', '=', 'posted'), + ('company_id', '=', env.company.id), + ] + if date_from: + domain.append(('date', '>=', date_from)) + if date_to: + domain.append(('date', '<=', date_to)) + + issues = [] + tax_accounts = env['account.account'].search([ + ('account_type', 'in', ('liability_current', 'asset_current')), + ('code', '=like', '2005%'), + ('company_id', '=', env.company.id), + ]) + if tax_accounts: + revenue_on_tax = env['account.move.line'].search( + domain + [ + ('account_id', 'in', tax_accounts.ids), + ('product_id', '!=', False), + ] + ) + for line in revenue_on_tax[:20]: + issues.append({ + 'id': line.id, + 'move': line.move_id.name, + 'account': f'{line.account_id.code} {line.account_id.name}', + 'product': line.product_id.name, + 'amount': line.balance, + 'issue': 'Product line on tax account', + }) + return {'count': len(issues), 'issues': issues} + + +def find_sequence_gaps(env, params): + moves = env['account.move'].search([ + ('state', '=', 'posted'), + ('company_id', '=', env.company.id), + ('made_sequence_gap', '=', True), + ], order='date desc', limit=50) + return { + 'count': len(moves), + 'gaps': [{ + 'id': m.id, + 'name': m.name, + 'date': str(m.date), + 'journal': m.journal_id.name, + } for m in moves], + } + + +def find_draft_entries(env, params): + min_age_days = int(params.get('min_age_days', 30)) + from datetime import timedelta + cutoff = fields.Date.today() - timedelta(days=min_age_days) + drafts = env['account.move'].search([ + ('state', '=', 'draft'), + ('date', '<=', cutoff), + ('company_id', '=', env.company.id), + ], order='date asc', limit=50) + return { + 'count': len(drafts), + 'entries': [{ + 'id': d.id, + 'name': d.name or 'Draft', + 'date': str(d.date), + 'journal': d.journal_id.name, + 'amount': d.amount_total, + 'partner': d.partner_id.name if d.partner_id else '', + } for d in drafts], + } + + +def find_unreconciled_suspense(env, params): + suspense_accounts = env['account.account'].search([ + ('code', '=like', '999%'), + ('company_id', '=', env.company.id), + ]) + issues = [] + for acct in suspense_accounts: + balance = sum(env['account.move.line'].search([ + ('account_id', '=', acct.id), + ('parent_state', '=', 'posted'), + ]).mapped('balance')) + if abs(balance) > 0.01: + issues.append({ + 'account_id': acct.id, + 'code': acct.code, + 'name': acct.name, + 'balance': balance, + }) + return {'count': len(issues), 'accounts': issues} + + +def verify_reconciliation_integrity(env, params): + partials = env['account.partial.reconcile'].search([ + ('company_id', '=', env.company.id), + ], limit=500) + issues = [] + for p in partials: + debit_ok = p.debit_move_id.reconciled or abs(p.debit_move_id.amount_residual) < 0.01 + credit_ok = p.credit_move_id.reconciled or abs(p.credit_move_id.amount_residual) < 0.01 + if not debit_ok and not credit_ok: + issues.append({ + 'id': p.id, + 'debit_move': p.debit_move_id.move_id.name, + 'credit_move': p.credit_move_id.move_id.name, + 'amount': p.amount, + 'debit_residual': p.debit_move_id.amount_residual, + 'credit_residual': p.credit_move_id.amount_residual, + }) + return {'count': len(issues), 'issues': issues[:20]} + + +TOOLS = { + 'find_wrong_direction_balances': find_wrong_direction_balances, + 'find_duplicate_entries': find_duplicate_entries, + 'find_wrong_account_entries': find_wrong_account_entries, + 'find_sequence_gaps': find_sequence_gaps, + 'find_draft_entries': find_draft_entries, + 'find_unreconciled_suspense': find_unreconciled_suspense, + 'verify_reconciliation_integrity': verify_reconciliation_integrity, +} diff --git a/fusion_accounting/services/tools/month_end.py b/fusion_accounting/services/tools/month_end.py new file mode 100644 index 00000000..93e64cd1 --- /dev/null +++ b/fusion_accounting/services/tools/month_end.py @@ -0,0 +1,130 @@ +import logging +from odoo import fields + +_logger = logging.getLogger(__name__) + + +def get_close_checklist(env, params): + from .bank_reconciliation import get_unreconciled_bank_lines + from .journal_review import find_draft_entries, find_sequence_gaps + from .hst_management import calculate_hst_balance + + period = params.get('period', str(fields.Date.today())[:7]) + date_from = f'{period}-01' + import calendar + year, month = int(period[:4]), int(period[5:7]) + last_day = calendar.monthrange(year, month)[1] + date_to = f'{period}-{last_day:02d}' + + p = {'date_from': date_from, 'date_to': date_to} + + bank = get_unreconciled_bank_lines(env, p) + drafts = find_draft_entries(env, {'min_age_days': '0'}) + gaps = find_sequence_gaps(env, p) + hst = calculate_hst_balance(env, p) + + checklist = [ + {'item': 'Bank Reconciliation', 'status': 'ok' if bank['count'] == 0 else 'attention', 'detail': f"{bank['count']} unreconciled lines"}, + {'item': 'Draft Entries', 'status': 'ok' if drafts['count'] == 0 else 'attention', 'detail': f"{drafts['count']} draft entries"}, + {'item': 'Sequence Gaps', 'status': 'ok' if gaps['count'] == 0 else 'warning', 'detail': f"{gaps['count']} gaps found"}, + {'item': 'HST Balance', 'status': 'info', 'detail': f"Net HST: ${hst['net_hst']:.2f}"}, + ] + return {'period': period, 'checklist': checklist} + + +def get_unreconciled_counts(env, params): + accounts = env['account.account'].search([ + ('reconcile', '=', True), + ('company_id', '=', env.company.id), + ]) + result = [] + for acct in accounts: + count = env['account.move.line'].search_count([ + ('account_id', '=', acct.id), + ('reconciled', '=', False), + ('parent_state', '=', 'posted'), + ]) + if count > 0: + result.append({ + 'account_id': acct.id, + 'code': acct.code, + 'name': acct.name, + 'unreconciled_count': count, + }) + return {'accounts': sorted(result, key=lambda x: -x['unreconciled_count'])} + + +def find_entries_in_locked_period(env, params): + company = env.company + lock_date = company.fiscalyear_lock_date + if not lock_date: + return {'status': 'no_lock_date', 'entries': []} + entries = env['account.move'].search([ + ('date', '<=', lock_date), + ('state', '=', 'draft'), + ('company_id', '=', company.id), + ]) + return { + 'lock_date': str(lock_date), + 'count': len(entries), + 'entries': [{'id': e.id, 'name': e.name, 'date': str(e.date)} for e in entries[:20]], + } + + +def get_accrual_status(env, params): + accrual_codes = params.get('account_codes', ['2100', '2110', '2120']) + result = [] + for code in accrual_codes: + accounts = env['account.account'].search([ + ('code', '=like', f'{code}%'), + ('company_id', '=', env.company.id), + ]) + for acct in accounts: + balance = sum(env['account.move.line'].search([ + ('account_id', '=', acct.id), + ('parent_state', '=', 'posted'), + ]).mapped('balance')) + result.append({'code': acct.code, 'name': acct.name, 'balance': balance}) + return {'accruals': result} + + +def run_hash_integrity_check(env, params): + try: + result = env.company._check_hash_integrity() + return { + 'status': 'completed', + 'results': result.get('results', []), + 'printing_date': result.get('printing_date', ''), + } + except Exception as e: + return {'error': str(e)} + + +def get_period_summary(env, params): + date_from = params.get('date_from') + date_to = params.get('date_to') + try: + report = env.ref('account_reports.trial_balance_report') + except Exception: + report = env.ref('account.trial_balance_report', raise_if_not_found=False) + if not report: + return {'error': 'Trial balance report not found'} + options = report.get_options({'date': {'date_from': date_from, 'date_to': date_to}}) + lines = report._get_lines(options) + return { + 'period': f'{date_from} to {date_to}', + 'lines': [{ + 'name': l.get('name', ''), + 'columns': [c.get('no_format', c.get('name', '')) for c in l.get('columns', [])], + } for l in lines[:100]], + } + + +TOOLS = { + 'get_close_checklist': get_close_checklist, + 'get_unreconciled_counts': get_unreconciled_counts, + 'find_entries_in_locked_period': find_entries_in_locked_period, + 'get_accrual_status': get_accrual_status, + 'run_hash_integrity_check': run_hash_integrity_check, + 'get_period_summary': get_period_summary, +} diff --git a/fusion_accounting/services/tools/payroll.py b/fusion_accounting/services/tools/payroll.py new file mode 100644 index 00000000..1603dade --- /dev/null +++ b/fusion_accounting/services/tools/payroll.py @@ -0,0 +1,205 @@ +import logging +from odoo import fields + +_logger = logging.getLogger(__name__) + + +def get_payroll_entries(env, params): + payroll_journals = env['account.journal'].search([ + ('name', 'ilike', 'payroll'), + ('company_id', '=', env.company.id), + ]) + if not payroll_journals and params.get('journal_id'): + payroll_journals = env['account.journal'].browse(int(params['journal_id'])) + domain = [ + ('journal_id', 'in', payroll_journals.ids), + ('state', '=', 'posted'), + ('company_id', '=', env.company.id), + ] + if params.get('date_from'): + domain.append(('date', '>=', params['date_from'])) + if params.get('date_to'): + domain.append(('date', '<=', params['date_to'])) + entries = env['account.move'].search(domain, order='date desc', limit=50) + return { + 'count': len(entries), + 'entries': [{ + 'id': e.id, 'name': e.name, 'date': str(e.date), + 'amount': e.amount_total, 'ref': e.ref or '', + } for e in entries], + } + + +def compare_payroll_to_bank(env, params): + date_from = params.get('date_from') + date_to = params.get('date_to') + if not date_from or not date_to: + return {'error': 'date_from and date_to are required'} + payroll_journals = env['account.journal'].search([ + ('name', 'ilike', 'payroll'), ('company_id', '=', env.company.id), + ]) + payroll_entries = env['account.move'].search([ + ('journal_id', 'in', payroll_journals.ids), + ('state', '=', 'posted'), + ('date', '>=', date_from), ('date', '<=', date_to), + ]) + bank_lines = env['account.bank.statement.line'].search([ + ('date', '>=', date_from), ('date', '<=', date_to), + ('company_id', '=', env.company.id), + ]) + payroll_total = sum(e.amount_total for e in payroll_entries) + bank_payroll = sum(abs(l.amount) for l in bank_lines if 'payroll' in (l.payment_ref or '').lower()) + return { + 'payroll_journal_total': payroll_total, + 'bank_payroll_total': bank_payroll, + 'difference': payroll_total - bank_payroll, + } + + +def verify_source_deductions(env, params): + return { + 'status': 'info', + 'message': 'Source deduction verification requires CRA rate tables. Use fusion_payroll for full verification.', + } + + +def get_cra_remittance_status(env, params): + cra_accounts = env['account.account'].search([ + ('name', 'ilike', 'CRA'), + ('company_id', '=', env.company.id), + ]) + result = [] + for acct in cra_accounts: + balance = sum(env['account.move.line'].search([ + ('account_id', '=', acct.id), + ('parent_state', '=', 'posted'), + ]).mapped('balance')) + result.append({'code': acct.code, 'name': acct.name, 'balance': balance}) + return {'accounts': result} + + +def find_unmatched_payroll_cheques(env, params): + bank_lines = env['account.bank.statement.line'].search([ + ('is_reconciled', '=', False), + ('company_id', '=', env.company.id), + ('payment_ref', 'ilike', 'cheque'), + ]) + return { + 'count': len(bank_lines), + 'cheques': [{ + 'id': l.id, 'date': str(l.date), + 'ref': l.payment_ref, 'amount': l.amount, + } for l in bank_lines[:30]], + } + + +def parse_payroll_summary(env, params): + import re + raw_data = params.get('data', '') + if not raw_data: + return {'error': 'No payroll data provided'} + + lines = raw_data.strip().split('\n') + entries = [] + totals = {'gross': 0, 'cpp': 0, 'ei': 0, 'tax': 0, 'net': 0} + + for line in lines: + amounts = re.findall(r'\$?([\d,]+\.?\d*)', line) + if len(amounts) >= 2: + name_part = re.sub(r'\$?[\d,]+\.?\d*', '', line).strip(' \t,|-') + parsed_amounts = [float(a.replace(',', '')) for a in amounts] + entry = {'name': name_part or 'Employee', 'amounts': parsed_amounts} + if len(parsed_amounts) >= 5: + entry.update({ + 'gross': parsed_amounts[0], + 'cpp': parsed_amounts[1], + 'ei': parsed_amounts[2], + 'tax': parsed_amounts[3], + 'net': parsed_amounts[4] if len(parsed_amounts) > 4 else parsed_amounts[0] - sum(parsed_amounts[1:4]), + }) + for k in ('gross', 'cpp', 'ei', 'tax', 'net'): + totals[k] += entry.get(k, 0) + entries.append(entry) + + return { + 'status': 'parsed', + 'employee_count': len(entries), + 'entries': entries, + 'totals': totals, + 'raw_lines': len(lines), + } + + +def create_payroll_journal_entry(env, params): + journal_id = int(params['journal_id']) + date = params['date'] + lines_data = params['lines'] + move_vals = { + 'journal_id': journal_id, + 'date': date, + 'ref': params.get('ref', 'Payroll Entry'), + 'line_ids': [(0, 0, { + 'account_id': int(line['account_id']), + 'name': line.get('name', 'Payroll'), + 'debit': float(line.get('debit', 0)), + 'credit': float(line.get('credit', 0)), + 'partner_id': int(line['partner_id']) if line.get('partner_id') else False, + }) for line in lines_data], + } + move = env['account.move'].create(move_vals) + return {'status': 'created', 'move_id': move.id, 'name': move.name} + + +def get_payroll_schedule(env, params): + return {'status': 'info', 'message': 'Payroll schedule available via fusion_payroll module.'} + + +def match_payroll_cheques(env, params): + st_line_id = int(params['statement_line_id']) + move_line_ids = [int(x) for x in params['move_line_ids']] + st_line = env['account.bank.statement.line'].browse(st_line_id) + st_line.set_line_bank_statement_line(move_line_ids) + return {'status': 'matched', 'statement_line_id': st_line_id} + + +def verify_payroll_deductions(env, params): + return verify_source_deductions(env, params) + + +def get_cra_remittance_due(env, params): + return get_cra_remittance_status(env, params) + + +def prepare_cra_payment(env, params): + return create_payroll_journal_entry(env, params) + + +def generate_t4(env, params): + return {'status': 'info', 'message': 'T4 generation available via fusion_payroll module.'} + + +def generate_roe(env, params): + return {'status': 'info', 'message': 'ROE generation available via fusion_payroll module.'} + + +def get_payroll_cost_report(env, params): + return get_payroll_entries(env, params) + + +TOOLS = { + 'get_payroll_entries': get_payroll_entries, + 'compare_payroll_to_bank': compare_payroll_to_bank, + 'verify_source_deductions': verify_source_deductions, + 'get_cra_remittance_status': get_cra_remittance_status, + 'find_unmatched_payroll_cheques': find_unmatched_payroll_cheques, + 'parse_payroll_summary': parse_payroll_summary, + 'create_payroll_journal_entry': create_payroll_journal_entry, + 'get_payroll_schedule': get_payroll_schedule, + 'match_payroll_cheques': match_payroll_cheques, + 'verify_payroll_deductions': verify_payroll_deductions, + 'get_cra_remittance_due': get_cra_remittance_due, + 'prepare_cra_payment': prepare_cra_payment, + 'generate_t4': generate_t4, + 'generate_roe': generate_roe, + 'get_payroll_cost_report': get_payroll_cost_report, +} diff --git a/fusion_accounting/services/tools/reporting.py b/fusion_accounting/services/tools/reporting.py new file mode 100644 index 00000000..92324b2d --- /dev/null +++ b/fusion_accounting/services/tools/reporting.py @@ -0,0 +1,117 @@ +import logging +import base64 + +_logger = logging.getLogger(__name__) + + +def _get_report(env, ref_id): + try: + return env.ref(ref_id) + except Exception: + return None + + +def _run_report(env, report_ref, params): + report = _get_report(env, report_ref) + if not report: + return {'error': f'Report {report_ref} not found'} + date_opts = {} + if params.get('date_from'): + date_opts['date_from'] = params['date_from'] + if params.get('date_to'): + date_opts['date_to'] = params['date_to'] + options = report.get_options({'date': date_opts} if date_opts else {}) + lines = report._get_lines(options) + return { + 'report_name': report.name, + 'lines': [{ + 'name': l.get('name', ''), + 'level': l.get('level', 0), + 'columns': [c.get('no_format', c.get('name', '')) for c in l.get('columns', [])], + } for l in lines[:100]], + } + + +def get_profit_loss(env, params): + return _run_report(env, 'account_reports.profit_and_loss', params) + + +def get_balance_sheet(env, params): + return _run_report(env, 'account_reports.balance_sheet', params) + + +def get_trial_balance(env, params): + return _run_report(env, 'account_reports.trial_balance_report', params) + + +def get_cash_flow(env, params): + return _run_report(env, 'account_reports.cash_flow_statement', params) + + +def compare_periods(env, params): + report_ref = params.get('report_ref', 'account_reports.profit_and_loss') + report = _get_report(env, report_ref) + if not report: + return {'error': f'Report {report_ref} not found'} + + period1 = _run_report(env, report_ref, { + 'date_from': params.get('period1_from'), + 'date_to': params.get('period1_to'), + }) + period2 = _run_report(env, report_ref, { + 'date_from': params.get('period2_from'), + 'date_to': params.get('period2_to'), + }) + return {'period_1': period1, 'period_2': period2} + + +def answer_financial_question(env, params): + question = params.get('question', '') + sql_query = params.get('sql_query') + if sql_query: + return {'error': 'Direct SQL not permitted. Use report tools instead.'} + return {'status': 'info', 'message': f'Use specific report tools to answer: {question}'} + + +def export_report(env, params): + report_ref = params.get('report_ref', 'account_reports.profit_and_loss') + fmt = params.get('format', 'pdf') + report = _get_report(env, report_ref) + if not report: + return {'error': f'Report {report_ref} not found'} + date_opts = {} + if params.get('date_from'): + date_opts['date_from'] = params['date_from'] + if params.get('date_to'): + date_opts['date_to'] = params['date_to'] + options = report.get_options({'date': date_opts} if date_opts else {}) + + try: + if fmt == 'xlsx': + result = report.dispatch_report_action(options, 'export_to_xlsx') + else: + result = report.dispatch_report_action(options, 'export_to_pdf') + + if isinstance(result, dict) and result.get('file_content'): + return { + 'file_name': result.get('file_name', f'report.{fmt}'), + 'file_type': result.get('file_type', fmt), + 'file_content_b64': base64.b64encode(result['file_content']).decode(), + } + return { + 'status': 'generated', + 'message': f'Report exported as {fmt}. Use the Odoo UI to download.', + } + except Exception as e: + return {'error': f'Export failed: {str(e)}'} + + +TOOLS = { + 'get_profit_loss': get_profit_loss, + 'get_balance_sheet': get_balance_sheet, + 'get_trial_balance': get_trial_balance, + 'get_cash_flow': get_cash_flow, + 'compare_periods': compare_periods, + 'answer_financial_question': answer_financial_question, + 'export_report': export_report, +} diff --git a/fusion_accounting/static/description/icon.png b/fusion_accounting/static/description/icon.png new file mode 100644 index 00000000..1e284497 Binary files /dev/null and b/fusion_accounting/static/description/icon.png differ diff --git a/fusion_accounting/static/src/components/chat/approval_card.js b/fusion_accounting/static/src/components/chat/approval_card.js new file mode 100644 index 00000000..596fcbc7 --- /dev/null +++ b/fusion_accounting/static/src/components/chat/approval_card.js @@ -0,0 +1,20 @@ +/** @odoo-module **/ + +import { Component } from "@odoo/owl"; + +export class FusionApprovalCard extends Component { + static template = "fusion_accounting.ApprovalCard"; + static props = ["approval", "onApprove", "onReject"]; + + get confidencePercent() { + return Math.round((this.props.approval.confidence || 0) * 100); + } + + approve() { + this.props.onApprove(this.props.approval.id); + } + + reject() { + this.props.onReject(this.props.approval.id); + } +} diff --git a/fusion_accounting/static/src/components/chat/approval_card.xml b/fusion_accounting/static/src/components/chat/approval_card.xml new file mode 100644 index 00000000..467e2f27 --- /dev/null +++ b/fusion_accounting/static/src/components/chat/approval_card.xml @@ -0,0 +1,29 @@ + + + +
+
+
+ + + % conf + +
+

+ +

+ Amount: $ +

+ +
+ + +
+
+
+
+
diff --git a/fusion_accounting/static/src/components/chat/chat_panel.js b/fusion_accounting/static/src/components/chat/chat_panel.js new file mode 100644 index 00000000..d832c7d5 --- /dev/null +++ b/fusion_accounting/static/src/components/chat/chat_panel.js @@ -0,0 +1,304 @@ +/** @odoo-module **/ + +import { Component, useState, useRef, onWillStart, onMounted, onPatched } from "@odoo/owl"; +import { rpc } from "@web/core/network/rpc"; +import { FusionApprovalCard } from "./approval_card"; + +function mdToHtml(text) { + if (!text) return ""; + + // Split into lines for block-level processing + const lines = text.split("\n"); + const output = []; + let inTable = false; + let tableHeader = false; + let inList = false; + let listType = null; + + for (let i = 0; i < lines.length; i++) { + let line = lines[i]; + const trimmed = line.trim(); + + // Close list if we're not in a list item anymore + if (inList && !trimmed.match(/^[-*]\s/) && !trimmed.match(/^\d+\.\s/) && trimmed !== "") { + output.push(listType === "ul" ? "" : ""); + inList = false; + listType = null; + } + + // Table row detection (line has at least 2 pipes) + const pipeCount = (trimmed.match(/\|/g) || []).length; + if (pipeCount >= 2 && trimmed.includes("|")) { + // Separator row (|---|---|) + if (/^[\s|:\-]+$/.test(trimmed)) { + tableHeader = true; + continue; + } + + // Split cells + let cells = trimmed.split("|").map(c => c.trim()); + // Remove empty first/last from leading/trailing pipes + if (cells[0] === "") cells.shift(); + if (cells.length > 0 && cells[cells.length - 1] === "") cells.pop(); + + if (!inTable) { + output.push('
'); + inTable = true; + // First row is header + output.push(""); + cells.forEach(c => output.push(``)); + output.push(""); + continue; + } + + // Body row + output.push(""); + cells.forEach(c => output.push(``)); + output.push(""); + continue; + } + + // Close table if we were in one + if (inTable) { + output.push("
${inlineFormat(c)}
${inlineFormat(c)}
"); + inTable = false; + tableHeader = false; + } + + // Empty line + if (trimmed === "") { + output.push(""); + continue; + } + + // Headers + const headerMatch = trimmed.match(/^(#{1,5})\s+(.+)$/); + if (headerMatch) { + const level = Math.min(headerMatch[1].length + 2, 6); // ## -> h4, ### -> h5 + output.push(`${inlineFormat(headerMatch[2])}`); + continue; + } + + // Horizontal rule + if (/^[-*_]{3,}$/.test(trimmed)) { + output.push('
'); + continue; + } + + // Unordered list + const ulMatch = trimmed.match(/^[-*]\s+(.+)$/); + if (ulMatch) { + if (!inList || listType !== "ul") { + if (inList) output.push(listType === "ul" ? "" : ""); + output.push('
    '); + inList = true; + listType = "ul"; + } + output.push(`
  • ${inlineFormat(ulMatch[1])}
  • `); + continue; + } + + // Ordered list + const olMatch = trimmed.match(/^\d+\.\s+(.+)$/); + if (olMatch) { + if (!inList || listType !== "ol") { + if (inList) output.push(listType === "ul" ? "
" : ""); + output.push('
    '); + inList = true; + listType = "ol"; + } + output.push(`
  1. ${inlineFormat(olMatch[1])}
  2. `); + continue; + } + + // Regular paragraph + output.push(`

    ${inlineFormat(trimmed)}

    `); + } + + // Close open elements + if (inTable) output.push(""); + if (inList) output.push(listType === "ul" ? "" : "
"); + + return output.join("\n"); +} + +function inlineFormat(text) { + if (!text) return ""; + return text + // Escape HTML entities + .replace(/&/g, "&") + .replace(//g, ">") + // Bold + italic + .replace(/\*\*\*(.+?)\*\*\*/g, '$1') + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/\*(.+?)\*/g, '$1') + // Inline code + .replace(/`([^`]+)`/g, '$1') + // Links [text](url) + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') + // Odoo record links #model,id + .replace(/#([\w.]+),(\d+)/g, '$1#$2'); +} + + +export class FusionChatPanel extends Component { + static template = "fusion_accounting.ChatPanel"; + static components = { FusionApprovalCard }; + static props = ["sessionId?"]; + + setup() { + this.inputRef = useRef("chatInput"); + this.messagesRef = useRef("messages"); + this.state = useState({ + messages: [], + pendingApprovals: [], + inputText: "", + sending: false, + loading: true, + internalSessionId: null, + sessionName: null, + }); + + onWillStart(async () => { + await this.loadLatestSession(); + }); + + onMounted(() => { + this._renderRichMessages(); + }); + + onPatched(() => { + this._renderRichMessages(); + }); + } + + _renderRichMessages() { + const container = this.messagesRef.el; + if (!container) return; + const richDivs = container.querySelectorAll(".fusion_rich_slot[data-idx]"); + for (const div of richDivs) { + const idx = parseInt(div.dataset.idx); + const msg = this.state.messages[idx]; + if (msg && msg.role === "assistant" && msg.content) { + const html = mdToHtml(msg.content); + if (div.innerHTML !== html) { + div.innerHTML = html; + } + } + } + } + + get sessionId() { + return this.state.internalSessionId || this.props.sessionId; + } + + async loadLatestSession() { + this.state.loading = true; + try { + const data = await rpc("/fusion_accounting/session/latest", {}); + if (data.session_id) { + this.state.internalSessionId = data.session_id; + this.state.messages = data.messages || []; + this.state.sessionName = data.name; + } + } catch (e) { + console.error("Failed to load session:", e); + } + this.state.loading = false; + this.scrollToBottom(); + } + + async onNewChat() { + if (this.sessionId) { + try { + await rpc("/fusion_accounting/session/close", { session_id: this.sessionId }); + } catch (e) { /* not critical */ } + } + const session = await rpc("/fusion_accounting/session/create", {}); + this.state.internalSessionId = session.session_id; + this.state.sessionName = session.name; + this.state.messages = []; + this.state.pendingApprovals = []; + } + + async sendMessage() { + const text = this.state.inputText.trim(); + if (!text || this.state.sending) return; + + if (!this.sessionId) { + const session = await rpc("/fusion_accounting/session/create", {}); + this.state.internalSessionId = session.session_id; + this.state.sessionName = session.name; + } + + this.state.messages.push({ role: "user", content: text }); + this.state.inputText = ""; + this.state.sending = true; + this.scrollToBottom(); + + try { + const result = await rpc("/fusion_accounting/chat", { + session_id: this.sessionId, + message: text, + }); + if (result.text) { + this.state.messages.push({ role: "assistant", content: result.text }); + } + if (result.pending_approvals) { + this.state.pendingApprovals = result.pending_approvals; + } + } catch (e) { + this.state.messages.push({ + role: "assistant", + content: `Error: ${e.message || "Something went wrong."}`, + }); + } + this.state.sending = false; + this.scrollToBottom(); + } + + onKeyDown(ev) { + if (ev.key === "Enter" && !ev.shiftKey) { + ev.preventDefault(); + this.sendMessage(); + } + } + + scrollToBottom() { + const el = this.messagesRef.el; + if (el) { + setTimeout(() => { el.scrollTop = el.scrollHeight; }, 100); + } + } + + async onApprove(matchHistoryId) { + await rpc("/fusion_accounting/approve", { match_history_id: matchHistoryId }); + this.state.pendingApprovals = this.state.pendingApprovals.filter(a => a.id !== matchHistoryId); + this.state.messages.push({ role: "assistant", content: "Action approved and executed." }); + } + + async onReject(matchHistoryId) { + await rpc("/fusion_accounting/reject", { match_history_id: matchHistoryId, reason: "User rejected" }); + this.state.pendingApprovals = this.state.pendingApprovals.filter(a => a.id !== matchHistoryId); + this.state.messages.push({ role: "assistant", content: "Action rejected." }); + } + + async onApproveAll() { + const ids = this.state.pendingApprovals.map(a => a.id); + if (!ids.length) return; + await rpc("/fusion_accounting/approve_all", { match_history_ids: ids }); + const count = this.state.pendingApprovals.length; + this.state.pendingApprovals = []; + this.state.messages.push({ role: "assistant", content: `${count} actions approved and executed.` }); + } + + async onRejectAll() { + const ids = this.state.pendingApprovals.map(a => a.id); + if (!ids.length) return; + await rpc("/fusion_accounting/reject_all", { match_history_ids: ids, reason: "Batch rejected" }); + const count = this.state.pendingApprovals.length; + this.state.pendingApprovals = []; + this.state.messages.push({ role: "assistant", content: `${count} actions rejected.` }); + } +} diff --git a/fusion_accounting/static/src/components/chat/chat_panel.xml b/fusion_accounting/static/src/components/chat/chat_panel.xml new file mode 100644 index 00000000..fa3e19ad --- /dev/null +++ b/fusion_accounting/static/src/components/chat/chat_panel.xml @@ -0,0 +1,103 @@ + + + +
+
+
+
Fusion AI
+ +
+ +
+ + +
+ +
+ +

Loading conversation...

+
+
+ +
+ +

Ask me about your accounting data.
+ I can help with bank reconciliation, tax analysis, AR/AP, auditing, and more.

+
+
+ + + +
+ + You + + +
+
+ + +
+ + Fusion AI + +
+
+ + + +
+ + Fusion AI + + Thinking... +
+
+
+ + + +
+
+ Pending Approvals (): +
+ + +
+
+ + + +
+
+ + +
+
+