| """ |
| AI Accounting Engine - Phase 39 |
| Transaction ingestion, AI categorization, and Chart of Accounts learning. |
| """ |
|
|
| from dataclasses import dataclass, field |
| from datetime import datetime, timedelta |
| from decimal import Decimal |
| from enum import Enum |
| import logging |
| import re |
| from typing import Any, Dict, List, Optional, Tuple, Union |
|
|
| from core.decimal_utils import to_decimal |
|
|
| logger = logging.getLogger(__name__) |
|
|
| class TransactionStatus(Enum): |
| PENDING = "pending" |
| CATEGORIZED = "categorized" |
| POSTED = "posted" |
| REVIEW_REQUIRED = "review_required" |
|
|
| class TransactionSource(Enum): |
| BANK = "bank" |
| CREDIT_CARD = "credit_card" |
| STRIPE = "stripe" |
| PAYPAL = "paypal" |
| MANUAL = "manual" |
|
|
| @dataclass |
| class Transaction: |
| """Financial transaction record""" |
| id: str |
| date: datetime |
| amount: Decimal |
| description: str |
| merchant: Optional[str] = None |
| source: TransactionSource = TransactionSource.BANK |
| status: TransactionStatus = TransactionStatus.PENDING |
| category_id: Optional[str] = None |
| category_name: Optional[str] = None |
| confidence: float = 0.0 |
| reasoning: Optional[str] = None |
| posted_at: Optional[datetime] = None |
| reviewed_by: Optional[str] = None |
|
|
| @dataclass |
| class ChartOfAccountsEntry: |
| """Chart of Accounts entry""" |
| account_id: str |
| name: str |
| type: str |
| parent_id: Optional[str] = None |
| keywords: List[str] = field(default_factory=list) |
| merchant_patterns: List[str] = field(default_factory=list) |
|
|
| class AIAccountingEngine: |
| """ |
| AI-powered accounting engine with confidence-based categorization. |
| |
| Architecture: |
| - LLM only proposes, never posts directly |
| - Approval + rollback support |
| - Immutable audit trails |
| """ |
| |
| CONFIDENCE_THRESHOLD = 0.85 |
| |
| def __init__(self): |
| self._transactions: Dict[str, Transaction] = {} |
| self._chart_of_accounts: Dict[str, ChartOfAccountsEntry] = {} |
| self._category_history: Dict[str, List[str]] = {} |
| self._pending_review: List[str] = [] |
| self._audit_log: List[Dict[str, Any]] = [] |
| |
| |
| self._load_default_coa() |
| |
| def _load_default_coa(self): |
| """Load default Chart of Accounts""" |
| defaults = [ |
| ChartOfAccountsEntry("1000", "Cash", "asset", keywords=["deposit", "withdrawal"]), |
| ChartOfAccountsEntry("1100", "Accounts Receivable", "asset", keywords=["invoice", "payment received"]), |
| ChartOfAccountsEntry("2000", "Accounts Payable", "liability", keywords=["bill", "vendor"]), |
| ChartOfAccountsEntry("4000", "Revenue", "revenue", keywords=["sale", "income", "payment"]), |
| ChartOfAccountsEntry("5000", "Cost of Goods Sold", "expense", keywords=["inventory", "product"]), |
| ChartOfAccountsEntry("6100", "Rent", "expense", keywords=["rent", "lease"], merchant_patterns=["landlord", "property"]), |
| ChartOfAccountsEntry("6200", "Utilities", "expense", keywords=["electric", "gas", "water", "internet"]), |
| ChartOfAccountsEntry("6300", "Software", "expense", keywords=["subscription", "saas"], merchant_patterns=["slack", "notion", "github", "aws"]), |
| ChartOfAccountsEntry("6400", "Marketing", "expense", keywords=["ads", "marketing", "campaign"], merchant_patterns=["google ads", "facebook", "linkedin"]), |
| ChartOfAccountsEntry("6500", "Travel", "expense", keywords=["flight", "hotel", "uber", "lyft"]), |
| ChartOfAccountsEntry("6600", "Meals", "expense", keywords=["restaurant", "food", "dining"]), |
| ChartOfAccountsEntry("6700", "Office Supplies", "expense", keywords=["supplies", "office"], merchant_patterns=["amazon", "staples"]), |
| ChartOfAccountsEntry("6800", "Professional Services", "expense", keywords=["legal", "accounting", "consulting"]), |
| ] |
| for entry in defaults: |
| self._chart_of_accounts[entry.account_id] = entry |
| |
| |
| |
| def ingest_transaction(self, tx: Transaction) -> Transaction: |
| """Ingest a new transaction and categorize it""" |
| self._transactions[tx.id] = tx |
| |
| |
| category_id, category_name, confidence, reasoning = self._categorize_transaction(tx) |
| |
| tx.category_id = category_id |
| tx.category_name = category_name |
| tx.confidence = confidence |
| tx.reasoning = reasoning |
| |
| |
| if confidence >= self.CONFIDENCE_THRESHOLD: |
| tx.status = TransactionStatus.CATEGORIZED |
| self._log_audit("auto_categorized", tx, f"High confidence ({confidence:.0%})") |
| else: |
| tx.status = TransactionStatus.REVIEW_REQUIRED |
| self._pending_review.append(tx.id) |
| self._log_audit("review_required", tx, f"Low confidence ({confidence:.0%})") |
| |
| return tx |
| |
| def ingest_bank_feed(self, transactions: List[Dict[str, Any]]) -> List[Transaction]: |
| """Bulk ingest from bank feed""" |
| results = [] |
| for tx_data in transactions: |
| tx = Transaction( |
| id=tx_data.get("id", f"tx_{datetime.now().timestamp()}"), |
| date=datetime.fromisoformat(tx_data["date"]) if isinstance(tx_data["date"], str) else tx_data["date"], |
| amount=to_decimal(tx_data["amount"]), |
| description=tx_data["description"], |
| merchant=tx_data.get("merchant"), |
| source=TransactionSource(tx_data.get("source", "bank")) |
| ) |
| results.append(self.ingest_transaction(tx)) |
|
|
| logger.info(f"Ingested {len(results)} transactions from bank feed") |
| return results |
| |
| |
| |
| def _categorize_transaction(self, tx: Transaction) -> Tuple[str, str, float, str]: |
| """ |
| Categorize transaction using AI/heuristics. |
| Returns: (category_id, category_name, confidence, reasoning) |
| """ |
| merchant = (tx.merchant or "").lower() |
| description = tx.description.lower() |
| combined_text = f"{merchant} {description}" |
| |
| best_match = None |
| best_score = 0.0 |
| reasoning_parts = [] |
| |
| |
| for account in self._chart_of_accounts.values(): |
| for pattern in account.merchant_patterns: |
| if pattern.lower() in merchant: |
| score = 0.95 |
| if score > best_score: |
| best_match = account |
| best_score = score |
| reasoning_parts = [f"Merchant '{merchant}' matches pattern '{pattern}'"] |
| |
| |
| if merchant and merchant in self._category_history: |
| historical = self._category_history[merchant] |
| if historical: |
| most_common = max(set(historical), key=historical.count) |
| if most_common in self._chart_of_accounts: |
| score = 0.90 if historical.count(most_common) > 2 else 0.75 |
| if score > best_score: |
| best_match = self._chart_of_accounts[most_common] |
| best_score = score |
| reasoning_parts = [f"Historical: {merchant} usually categorized as {best_match.name}"] |
| |
| |
| if not best_match or best_score < 0.7: |
| for account in self._chart_of_accounts.values(): |
| keyword_matches = sum(1 for kw in account.keywords if kw.lower() in combined_text) |
| if keyword_matches > 0: |
| score = min(0.70 + (keyword_matches * 0.05), 0.85) |
| if score > best_score: |
| best_match = account |
| best_score = score |
| matched_kws = [kw for kw in account.keywords if kw.lower() in combined_text] |
| reasoning_parts = [f"Keywords matched: {matched_kws}"] |
| |
| |
| if not best_match: |
| return (None, "Uncategorized", 0.0, "No matching patterns found") |
| |
| reasoning = "; ".join(reasoning_parts) |
| return (best_match.account_id, best_match.name, best_score, reasoning) |
| |
| |
| |
| def learn_categorization(self, tx_id: str, category_id: str, user_id: str): |
| """Learn from user categorization to improve future predictions""" |
| tx = self._transactions.get(tx_id) |
| if not tx: |
| return |
| |
| account = self._chart_of_accounts.get(category_id) |
| if not account: |
| return |
| |
| |
| tx.category_id = category_id |
| tx.category_name = account.name |
| tx.confidence = 1.0 |
| tx.status = TransactionStatus.CATEGORIZED |
| tx.reviewed_by = user_id |
| |
| |
| merchant = (tx.merchant or tx.description[:20]).lower() |
| if merchant not in self._category_history: |
| self._category_history[merchant] = [] |
| self._category_history[merchant].append(category_id) |
| |
| |
| if tx_id in self._pending_review: |
| self._pending_review.remove(tx_id) |
| |
| self._log_audit("user_categorized", tx, f"User {user_id} categorized as {account.name}") |
| logger.info(f"Learned: {merchant} -> {account.name}") |
| |
| |
| |
| def post_transaction(self, tx_id: str, user_id: Optional[str] = None) -> bool: |
| """Post a transaction to the ledger (with human approval if required)""" |
| tx = self._transactions.get(tx_id) |
| if not tx: |
| return False |
| |
| if tx.status == TransactionStatus.REVIEW_REQUIRED and not user_id: |
| logger.warning(f"Cannot post {tx_id}: requires review and no user_id provided") |
| return False |
| |
| tx.status = TransactionStatus.POSTED |
| tx.posted_at = datetime.now() |
| tx.reviewed_by = user_id or tx.reviewed_by |
|
|
| if tx_id in self._pending_review: |
| self._pending_review.remove(tx_id) |
| |
| self._log_audit("posted", tx, f"Posted by {user_id or 'system'}") |
| return True |
| |
| def auto_post_high_confidence(self) -> int: |
| """Auto-post all high confidence transactions""" |
| posted = 0 |
| for tx in self._transactions.values(): |
| if tx.status == TransactionStatus.CATEGORIZED and tx.confidence >= self.CONFIDENCE_THRESHOLD: |
| if self.post_transaction(tx.id): |
| posted += 1 |
| return posted |
| |
| |
| |
| def get_pending_review(self) -> List[Transaction]: |
| """Get transactions pending review""" |
| return [self._transactions[tid] for tid in self._pending_review if tid in self._transactions] |
| |
| def get_all_transactions(self) -> List[Transaction]: |
| """Get all documented transactions sorted by date descending""" |
| return sorted(list(self._transactions.values()), key=lambda tx: tx.date, reverse=True) |
| |
| def update_transaction(self, tx_id: str, updates: Dict[str, Any], user_id: str) -> bool: |
| """Update transaction details (amount, description, merchant, date)""" |
| tx = self._transactions.get(tx_id) |
| if not tx: |
| return False |
| |
| old_values = {} |
| for key, value in updates.items(): |
| if hasattr(tx, key) and key in ["amount", "description", "merchant"]: |
| old_values[key] = getattr(tx, key) |
| setattr(tx, key, value) |
| elif key == "date" and isinstance(value, str): |
| old_values["date"] = tx.date.isoformat() |
| tx.date = datetime.fromisoformat(value) |
| |
| if old_values: |
| |
| if "description" in old_values or "merchant" in old_values: |
| category_id, category_name, confidence, reasoning = self._categorize_transaction(tx) |
| tx.category_id = category_id |
| tx.category_name = category_name |
| tx.confidence = confidence |
| tx.reasoning = f"Re-categorized after update: {reasoning}" |
| |
| |
| if confidence < self.CONFIDENCE_THRESHOLD and tx.id not in self._pending_review: |
| self._pending_review.append(tx.id) |
| tx.status = TransactionStatus.REVIEW_REQUIRED |
| elif confidence >= self.CONFIDENCE_THRESHOLD and tx.id in self._pending_review: |
| self._pending_review.remove(tx.id) |
| tx.status = TransactionStatus.CATEGORIZED |
| |
| self._log_audit("updated", tx, f"User {user_id} updated: {old_values} -> {updates}") |
| return True |
| |
| def delete_transaction(self, tx_id: str, user_id: str) -> bool: |
| """Delete a transaction""" |
| tx = self._transactions.get(tx_id) |
| if not tx: |
| return False |
| |
| if tx_id in self._pending_review: |
| self._pending_review.remove(tx_id) |
| |
| del self._transactions[tx_id] |
| |
| self._log_audit("deleted", tx, f"User {user_id} deleted transaction") |
| return True |
| |
| |
| |
| def _log_audit(self, action: str, tx: Transaction, details: str): |
| """Immutable audit log entry""" |
| self._audit_log.append({ |
| "timestamp": datetime.now().isoformat(), |
| "action": action, |
| "transaction_id": tx.id, |
| "category": tx.category_name, |
| "confidence": tx.confidence, |
| "details": details |
| }) |
| |
| def get_audit_log(self, tx_id: str = None) -> List[Dict[str, Any]]: |
| """Get audit log, optionally filtered by transaction""" |
| if tx_id: |
| return [e for e in self._audit_log if e["transaction_id"] == tx_id] |
| return self._audit_log |
| |
| |
| |
| def export_general_ledger_csv(self) -> str: |
| """Export all transactions in a flat CSV format""" |
| import io |
| import csv |
| output = io.StringIO() |
| writer = csv.writer(output) |
| |
| writer.writerow([ |
| "Date", "Transaction ID", "Account Name", "Amount", |
| "Description", "Merchant", "Status", "Confidence" |
| ]) |
| |
| for tx in self.get_all_transactions(): |
| writer.writerow([ |
| tx.date.strftime("%Y-%m-%d"), |
| tx.id, |
| tx.category_name or "Uncategorized", |
| tx.amount, |
| tx.description, |
| tx.merchant or "", |
| tx.status.value, |
| f"{tx.confidence:.0%}" |
| ]) |
| |
| return output.getvalue() |
|
|
| def export_trial_balance_json(self) -> Dict[str, Any]: |
| """Export summarized balances for all accounts""" |
| report = { |
| "export_date": datetime.utcnow().isoformat(), |
| "standard": "Multi-Standard (GAAP/IFRS Ready)", |
| "accounts": [] |
| } |
| |
| balances = {} |
| for tx in self.get_all_transactions(): |
| if tx.status in (TransactionStatus.POSTED, TransactionStatus.CATEGORIZED): |
| cat = tx.category_name or "Uncategorized" |
| balances[cat] = balances.get(cat, Decimal('0.0')) + tx.amount |
| |
| for acc_name, balance in balances.items(): |
| report["accounts"].append({ |
| "name": acc_name, |
| "net_balance": float(balance) |
| }) |
| |
| return report |
|
|
| |
|
|
| def get_13_week_forecast(self, current_balance: float = 100000.0) -> Dict[str, Any]: |
| """Generate a simple 13-week cash flow projection based on recent transaction averages""" |
| from collections import defaultdict |
| |
| |
| transactions = [tx for tx in self.get_all_transactions() |
| if tx.status in (TransactionStatus.POSTED, TransactionStatus.CATEGORIZED)] |
| |
| weekly_net = 0.0 |
| if transactions: |
| oldest = min(tx.date for tx in transactions) |
| newest = max(tx.date for tx in transactions) |
| weeks_diff = (newest - oldest).days / 7.0 |
| |
| total_net = sum(float(tx.amount) for tx in transactions if tx.category_id != "1000") |
| weekly_net = total_net / max(weeks_diff, 1.0) |
| |
| |
| if weekly_net == 0: |
| weekly_net = -2500.0 |
|
|
| projection = [] |
| running_balance = current_balance |
| start_date = datetime.now() |
| |
| for i in range(13): |
| week_start = start_date + timedelta(weeks=i) |
| |
| variance = (i % 3) * 500 |
| projected_change = weekly_net + (variance if weekly_net < 0 else -variance) |
| running_balance += projected_change |
| |
| projection.append({ |
| "week": i + 1, |
| "week_start": week_start.isoformat(), |
| "projected_change": projected_change, |
| "projected_balance": running_balance |
| }) |
| |
| return { |
| "historical_weekly_avg": weekly_net, |
| "projection": projection |
| } |
|
|
| def run_scenario(self, description: str, current_forecast: List[Dict[str, Any]]) -> Dict[str, Any]: |
| """Mock AI scenario parsing and impact analysis""" |
| description = description.lower() |
| |
| |
| impact_value = 0 |
| risk_level = "low" |
| analysis = "Scenario analyzed based on requested parameters." |
| |
| |
| num_match = re.search(r'\$?(\d+)[k,]*', description) |
| val = 0 |
| if num_match: |
| val = int(num_match.group(1).replace(',', '')) |
| if 'k' in num_match.group(0): |
| val *= 1000 |
| |
| if "hire" in description or "buy" in description or "expense" in description or "cost" in description or "lose" in description: |
| impact_value = -val if val > 0 else -5000 |
| risk_level = "medium" if abs(impact_value) > 10000 else "low" |
| if "lose" in description and "client" in description: |
| risk_level = "high" |
| impact_value = -val if val > 0 else -15000 |
| analysis = f"Increases cash burn by roughly ${abs(impact_value):,} per week." |
| elif "sell" in description or "raise" in description or "win" in description or "revenue" in description: |
| impact_value = val if val > 0 else 10000 |
| analysis = f"Improves cash position by approximately ${abs(impact_value):,}." |
| |
| if impact_value == 0: |
| impact_value = -1000 |
| |
| return { |
| "scenario": description, |
| "impact_value": impact_value, |
| "risk_level": risk_level, |
| "analysis": analysis |
| } |
|
|
| |
| |
| def post_to_ledger(self, tx_id: str, db_session = None) -> Dict[str, Any]: |
| """ |
| Post approved transaction to the existing EventSourcedLedger. |
| Integrates with accounting/ledger.py for proper double-entry. |
| """ |
| tx = self._transactions.get(tx_id) |
| if not tx: |
| return {"status": "failed", "error": f"Transaction {tx_id} not found"} |
| |
| if tx.status == TransactionStatus.REVIEW_REQUIRED: |
| return {"status": "failed", "error": "Transaction requires review before posting"} |
| |
| if tx.status == TransactionStatus.POSTED: |
| return {"status": "skipped", "reason": "Already posted"} |
| |
| try: |
| |
| from accounting.ledger import DoubleEntryEngine, EventSourcedLedger |
| from accounting.models import EntryType |
| |
| if db_session is None: |
| |
| tx.status = TransactionStatus.POSTED |
| tx.posted_at = datetime.now() |
| self._log_audit("posted_mock", tx, "Posted without DB session") |
| return {"status": "posted", "mode": "mock", "tx_id": tx_id} |
| |
| ledger = EventSourcedLedger(db_session) |
| |
| |
| cash_account = "1000" |
| expense_account = tx.category_id or "6700" |
| |
| |
| entries = DoubleEntryEngine.create_payment_entry( |
| cash_account_id=cash_account, |
| expense_account_id=expense_account, |
| amount=abs(tx.amount), |
| description=tx.description |
| ) |
| |
| |
| ledger_tx = ledger.record_transaction( |
| workspace_id="default", |
| transaction_date=tx.date, |
| description=tx.description, |
| entries=entries, |
| source="ai_accounting", |
| external_id=tx.id, |
| metadata={"confidence": tx.confidence, "reasoning": tx.reasoning} |
| ) |
| |
| tx.status = TransactionStatus.POSTED |
| tx.posted_at = datetime.now() |
| self._log_audit("posted_to_ledger", tx, f"Ledger TX: {ledger_tx.id}") |
| |
| return {"status": "posted", "ledger_tx_id": str(ledger_tx.id), "tx_id": tx_id} |
| |
| except ImportError: |
| |
| tx.status = TransactionStatus.POSTED |
| tx.posted_at = datetime.now() |
| self._log_audit("posted_standalone", tx, "Posted without ledger integration") |
| return {"status": "posted", "mode": "standalone", "tx_id": tx_id} |
| except Exception as e: |
| self._log_audit("post_failed", tx, str(e)) |
| return {"status": "failed", "error": str(e)} |
|
|
| |
| ai_accounting = AIAccountingEngine() |
|
|