from __future__ import annotations import json from .shared import read_db, DatabaseNotInitializedError from .assets_service import get_asset_snapshot_report from .imports_service import list_ledger_import_batches from .reconciliation_service import get_balance_reconciliation_report from .review_decision_service import get_review_operating_indicators from .shared import Any, fail, normalize_month, normalize_snapshot_at, ok from .transaction_report_services import get_budget_vs_actual_report, get_monthly_cashflow_report def _month_end_date(month: str) -> str: return normalize_snapshot_at(month, default_now=False)[:10] def _budget_overrun_count(budget_report: dict[str, Any]) -> int: return sum(1 for item in budget_report.get("items", []) if item.get("status") == "over") def _unresolved_blocker_summary(review_report: dict[str, Any], reconciliation_report: dict[str, Any]) -> dict[str, Any]: indicators = review_report.get("indicators", {}) if review_report.get("success") else {} reconciliation_items = reconciliation_report.get("items", []) if reconciliation_report.get("success") else [] reconciliation_attention_count = sum(1 for item in reconciliation_items if item.get("status") != "OK") counts = { "uncategorized_recommendation_backlog": int(indicators.get("uncategorized_recommendation_backlog") or 0), "pending_review_count": int(indicators.get("pending_review_count") or 0), "held_recheck_count": int(indicators.get("held_recheck_count") or 0), "unreconciled_account_count": int(indicators.get("unreconciled_account_count") or 0), "reconciliation_attention_count": reconciliation_attention_count, } return {"count": sum(counts.values()), "counts": counts} def _source_coverage( cashflow_report: dict[str, Any], budget_report: dict[str, Any], review_report: dict[str, Any], reconciliation_report: dict[str, Any], import_report: dict[str, Any], asset_report: dict[str, Any], ) -> dict[str, Any]: latest_import = (import_report.get("import_batches") or [None])[0] return { "cashflow": {"success": bool(cashflow_report.get("success")), "source": "transactions"}, "budget": { "success": bool(budget_report.get("success")), "budget_count": len(budget_report.get("items", [])), }, "review": { "success": bool(review_report.get("success")), "indicators": review_report.get("indicators", {}) if review_report.get("success") else {}, }, "reconciliation": { "success": bool(reconciliation_report.get("success")), "item_count": int(reconciliation_report.get("count") or 0), }, "imports": { "success": bool(import_report.get("success")), "recent_batch_count": int(import_report.get("count") or 0), "latest_imported_at": latest_import.get("imported_at") if isinstance(latest_import, dict) else "", }, "assets": { "success": bool(asset_report.get("success")), "basis_date": asset_report.get("basis_date", ""), "source": asset_report.get("source", ""), "is_closed_snapshot": bool(asset_report.get("is_closed_snapshot", False)), "net_worth": asset_report.get("net_worth"), "close_status": asset_report.get("close_status", ""), "approval": asset_report.get("approval", {}), "correction": asset_report.get("correction", {}), "warnings": asset_report.get("warnings", []), "provenance": asset_report.get( "provenance", {"valuation_date": "", "warnings": [], "exclusions": []} ), "missing_items": [item for item in asset_report.get("excluded_items", []) if "missing" in str(item.get("reason", ""))], "excluded_items": asset_report.get("excluded_items", []), "carried_items": asset_report.get("carried_items", asset_report.get("included_items", [])), "monthly_source_snapshot_id": asset_report.get("snapshot_run_id", ""), "draft_id": asset_report.get("close_metadata", {}).get("draft_id", ""), "draft_version": asset_report.get("close_metadata", {}).get("draft_version", 0), "review_fingerprint": asset_report.get("close_metadata", {}).get("review_fingerprint", ""), "contains_substitutions": asset_report.get("close_metadata", {}).get("contains_substitutions", False), "contains_carried": asset_report.get("close_metadata", {}).get("contains_carried", False), "has_approved_missing": asset_report.get("close_metadata", {}).get("has_approved_missing", False), "applied_evidence": asset_report.get("close_metadata", {}).get("applied_evidence", []), }, } def get_monthly_settlement_summary(month: str = "", db_path: str | None = None) -> dict[str, Any]: """Return the read-only monthly settlement summary foundation for dashboard/MCP consumers.""" try: selected_month = normalize_month(month) basis_date = _month_end_date(selected_month) except Exception as exc: return fail("validation_error", str(exc)) cashflow_report = get_monthly_cashflow_report(selected_month, db_path=db_path) if not cashflow_report.get("success"): return cashflow_report budget_report = get_budget_vs_actual_report(selected_month, db_path=db_path) review_report = get_review_operating_indicators(db_path=db_path) reconciliation_report = get_balance_reconciliation_report(db_path=db_path) import_report = list_ledger_import_batches(limit=5, db_path=db_path) asset_report = get_asset_snapshot_report(basis_date=basis_date, db_path=db_path) unresolved_blockers = _unresolved_blocker_summary(review_report, reconciliation_report) return ok( selected_month=selected_month, month=selected_month, basis_date=basis_date, actual_income=int(cashflow_report.get("income") or 0), actual_expense=int(cashflow_report.get("expenses") or 0), net_cashflow=int(cashflow_report.get("net_cashflow") or 0), savings_rate=cashflow_report.get("savings_rate"), budget_overrun_count=_budget_overrun_count(budget_report), unresolved_blocker_count=int(unresolved_blockers["count"]), unresolved_blockers=unresolved_blockers, source_coverage=_source_coverage( cashflow_report, budget_report, review_report, reconciliation_report, import_report, asset_report, ), reports={ "cashflow": cashflow_report, "budget": budget_report, "assets": asset_report, }, read_only=True, ) def get_monthly_settlement_snapshot(month: str = "", run_id: str = "", db_path: str | None = None) -> dict[str, Any]: """Read a persisted monthly settlement close without consulting live ledger state.""" if not isinstance(month, str) or not isinstance(run_id, str): return fail("validation_error", "month and run_id must be strings") try: selected_month = normalize_month(month) if month else "" if selected_month and (len(selected_month) != 7 or selected_month[4] != "-"): raise ValueError("invalid month") except (TypeError, ValueError): return fail("validation_error", "invalid settlement month") def parsed(value: Any, default: Any): if value in (None, ""): return default return json.loads(value) try: with read_db(db_path) as conn: tables = {r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} if "asset_snapshot_runs" not in tables or "asset_snapshots" not in tables: return fail("not_found", "settlement snapshot not found") run = None rows = [] legacy = False metadata: dict[str, Any] = {} if run_id: run = conn.execute("SELECT rowid AS _run_rowid,* FROM asset_snapshot_runs WHERE id=?", (run_id,)).fetchone() if run: from .assets_service import _snapshot_rows_for_run rows, metadata = _snapshot_rows_for_run(conn, run_id) else: cols = {r["name"] for r in conn.execute("PRAGMA table_info(asset_snapshots)")} if {"snapshot_run_id", "closed_at"} <= cols: rows = conn.execute("SELECT rowid AS _rowid,* FROM asset_snapshots WHERE snapshot_run_id=? AND closed_at<>'' ORDER BY rowid", (run_id,)).fetchall() if rows: legacy = True metadata = {"snapshot_run_id": run_id, "legacy_closed": True, "close_status": "legacy_read_only"} else: if selected_month: run = conn.execute("SELECT rowid AS _run_rowid,* FROM asset_snapshot_runs WHERE basis_date=? ORDER BY rowid DESC LIMIT 1", (_month_end_date(selected_month),)).fetchone() if run is None: basis = _month_end_date(selected_month) if selected_month else "" if basis: from .assets_service import _snapshot_rows_for_latest_run rows, metadata = _snapshot_rows_for_latest_run(conn, basis) legacy = bool(metadata.get("legacy_closed")) if legacy: rows = [r for r in rows if r["snapshot_run_id"] == metadata.get("snapshot_run_id") and r["closed_at"]] elif not metadata.get("snapshot_run_id"): rows = [] if metadata.get("snapshot_run_id"): run = conn.execute("SELECT rowid AS _run_rowid,* FROM asset_snapshot_runs WHERE id=?", (metadata["snapshot_run_id"],)).fetchone() if not run and not rows: return fail("not_found", "settlement snapshot not found") if run: rid = run["id"] basis_date = run["basis_date"] run_month = run["settlement_month"] if "settlement_month" in run.keys() else None if selected_month and ((run_month and run_month != selected_month) or (not run_month and basis_date[:7] != selected_month)): return fail("validation_error", "run does not match settlement month") if not metadata: from .assets_service import _snapshot_rows_for_run rows, metadata = _snapshot_rows_for_run(conn, rid) else: rid = metadata.get("snapshot_run_id", run_id) basis_date = rows[0]["basis_date"] if selected_month and basis_date[:7] != selected_month: return fail("validation_error", "run does not match settlement month") legacy = True if metadata.get("evidence_integrity_error"): return fail("data_integrity_error", "snapshot evidence is malformed", snapshot_run_id=rid) keys = set(run.keys()) if run else set() def field(name): return run[name] if run and name in keys else None from .settlement_cashflow_service import _AGGREGATION_VERSION if (field("snapshot_schema_version") is not None or field("aggregation_version") is not None) and ( field("snapshot_schema_version") != 1 or field("aggregation_version") != _AGGREGATION_VERSION ): raise ValueError("unsupported comprehensive snapshot version") comprehensive = bool(field("snapshot_schema_version") == 1 and not legacy) legacy = not comprehensive cashflows = [] if comprehensive and "settlement_cashflow_snapshots" in tables: cashflows = [dict(r) for r in conn.execute("SELECT cashflow_kind,category,subcategory,amount,transaction_count,currency FROM settlement_cashflow_snapshots WHERE settlement_run_id=? ORDER BY cashflow_kind,category,subcategory,currency", (rid,))] totals = {"income_total": field("income_total") if comprehensive else None, "expense_total": field("expense_total") if comprehensive else None, "net_cashflow": field("net_cashflow") if comprehensive else None, "total_assets": field("total_assets") if comprehensive else None, "total_liabilities": field("total_liabilities") if comprehensive else None, "net_worth": field("net_worth") if comprehensive else None} safe_items = [] for row in rows: d = dict(row) safe_items.append({k: d.get(k) for k in ("id","source_type","source_id","name","amount","currency","liquidity_bucket","valuation_source","snapshot_run_id","correction_of_run_id","correction_reason","is_correction","closed_at","valuation_id","selection_policy","verification_status","evidence_kind","exclusion_reason","created_at") if k in d}) source = parsed(field("source_snapshot_json"), {}) if comprehensive else {} if not isinstance(source, dict): raise ValueError() if comprehensive: tx_fields = ("id", "transaction_date", "type", "amount", "currency", "category", "subcategory", "original_cashflow_kind", "originalcashflow_kind", "cashflow_kind", "applied_amount", "sourceprovenance", "category_cashflow_kind", "category_active") asset_fields = ("id", "source_id", "source_type", "valuation_id", "valuation_date", "basis_date", "amount", "currency", "liquidity_bucket", "selection_mode", "applied_amount", "authority_source", "is_liability", "status", "verified", "verification_status", "evidence_kind", "exclusion_reason", "state_hash") def project(records, fields): if not isinstance(records, list) or any(not isinstance(x, dict) for x in records): raise ValueError() result = [] for record in records: item = {k: record[k] for k in fields if k in record} if any(value is not None and not isinstance(value, (str, int, bool)) for value in item.values()): raise ValueError() for key, allowed in (("decision", ("category", "subcategory", "cashflow_kind", "reason")), ("source_classification", ("id", "category", "subcategory", "cashflow_kind", "active"))): value = record.get(key) if isinstance(value, dict): item[key] = {k: value[k] for k in allowed if k in value and (value[k] is None or isinstance(value[k], (str, int, bool)))} result.append(item) return result source = {"transactions": project(source.get("transactions", []), tx_fields), "assets": project(source.get("assets", []), asset_fields)} policy = parsed(field("cashflow_policy_json"), {}) if comprehensive else None if comprehensive: from .settlement_cashflow_service import _normalize_cashflow_policy if not isinstance(policy, dict): raise ValueError() policy = _normalize_cashflow_policy(policy) from .assets_service import _closed_run_metadata public_metadata = _closed_run_metadata(metadata) header_evidence = {key: public_metadata[key] for key in ("excluded_items", "carried_items", "applied_evidence", "approval")} return ok(**header_evidence, source="settlement_snapshot", snapshot_run_id=rid, run_id=rid, settlement_month=field("settlement_month") or basis_date[:7], basis_date=basis_date, currency=field("currency"), is_closed_snapshot=True, is_comprehensive=comprehensive, legacy=legacy, aggregation_version=field("aggregation_version") if comprehensive else None, snapshot_schema_version=field("snapshot_schema_version") if comprehensive else None, cashflow_policy=policy, cashflow_items=cashflows, items=safe_items, source_snapshot=source, totals=totals, **totals, close_status=(field("close_status") or metadata.get("close_status", "legacy_read_only")), correction={"is_correction": bool(field("correction_of_run_id") or metadata.get("is_correction")), "correction_of_run_id": field("correction_of_run_id") or metadata.get("correction_of_run_id", ""), "correction_reason": field("correction_reason") or metadata.get("correction_reason", "")}) except DatabaseNotInitializedError: return fail("db_not_initialized", "ledger database is not initialized") except Exception: return fail("db_error", "settlement snapshot read failed")