from datetime import datetime import logging from typing import Any, Dict, List, Optional from accounting.models import ( Bill, BillStatus, CategorizationProposal, FinancialClose, Invoice, InvoiceStatus, JournalEntry, Transaction, TransactionStatus, ) from sqlalchemy import func from sqlalchemy.orm import Session logger = logging.getLogger(__name__) class CloseChecklistAgent: """ Agent responsible for monitoring readiness for the periodic financial close. """ def __init__(self, db: Session): self.db = db async def run_close_check(self, workspace_id: str, period: str) -> Dict[str, Any]: """ Evaluate if the workspace is ready for a financial close for the given period. """ results = { "period": period, "is_ready": True, "checklist": [], "blockers": [] } # 1. Check for Uncategorized Transactions uncategorized_count = self.db.query(Transaction).filter( Transaction.workspace_id == workspace_id, Transaction.status == TransactionStatus.PENDING ).count() if uncategorized_count > 0: results["is_ready"] = False results["blockers"].append(f"{uncategorized_count} transactions are still pending categorization.") results["checklist"].append({"task": "Categorize Transactions", "status": "blocked"}) else: results["checklist"].append({"task": "Categorize Transactions", "status": "complete"}) # 2. Check for Unbalanced Journal Entries # In our EventSourcedLedger, this shouldn't happen, but good to verify # SELECT transaction_id, SUM(CASE WHEN type='debit' THEN amount ELSE -amount END) as diff from sqlalchemy import case unbalanced = self.db.query(JournalEntry.transaction_id).group_by(JournalEntry.transaction_id).having( func.abs(func.sum(case((JournalEntry.type == 'debit', JournalEntry.amount), else_=-JournalEntry.amount))) > 0.001 ).all() if unbalanced: results["is_ready"] = False results["blockers"].append(f"{len(unbalanced)} transactions are unbalanced in the ledger.") results["checklist"].append({"task": "Ledger Integrity Check", "status": "blocked"}) else: results["checklist"].append({"task": "Ledger Integrity Check", "status": "complete"}) # 3. Check for Open Invoices / Bills (Optional for soft close, blocker for hard close) open_bills = self.db.query(Bill).filter( Bill.workspace_id == workspace_id, Bill.status == BillStatus.OPEN ).count() if open_bills > 0: results["checklist"].append({"task": "Review Open Bills", "status": "warning", "note": f"{open_bills} bills are still open."}) else: results["checklist"].append({"task": "Review Open Bills", "status": "complete"}) # Update or create the Close record close_record = self.db.query(FinancialClose).filter( FinancialClose.workspace_id == workspace_id, FinancialClose.period == period ).first() if not close_record: close_record = FinancialClose( workspace_id=workspace_id, period=period, metadata_json=results ) self.db.add(close_record) else: close_record.metadata_json = results self.db.commit() return results async def close_period(self, workspace_id: str, period: str, user_id: str) -> Dict[str, Any]: """ Permanently close a period if ready. """ check = await self.run_close_check(workspace_id, period) if not check["is_ready"]: return {"success": False, "message": "Cannot close period. Please resolve blockers.", "blockers": check["blockers"]} close_record = self.db.query(FinancialClose).filter( FinancialClose.workspace_id == workspace_id, FinancialClose.period == period ).first() close_record.is_closed = True close_record.closed_at = datetime.utcnow() close_record.closed_by = user_id self.db.commit() return {"success": True, "message": f"Period {period} has been closed successfully."}