"""Private, deterministic aggregation for comprehensive settlement previews.""" from __future__ import annotations from datetime import date import hashlib import json from decimal import Decimal from .shared import CASHFLOW_KIND_SQL _AGGREGATION_VERSION = "comprehensive-v1" _SNAPSHOT_SCHEMA_VERSION = 1 _ALLOWED_KINDS = {"income", "expense", "excluded"} def _canonical_value(value): """Convert review inputs to a stable, JSON-safe representation.""" if isinstance(value, Decimal): return format(value, "f") if isinstance(value, dict): return {str(k): _canonical_value(value[k]) for k in sorted(value)} if isinstance(value, (list, tuple)): return [_canonical_value(x) for x in value] return value def cashflow_fingerprint(review: dict, month: str) -> str: """Fingerprint policy, aggregation, and every included source row.""" payload = { "month": month, "aggregation_version": review.get("aggregation_version"), "snapshot_schema_version": review.get("snapshot_schema_version"), "cashflow_policy": review.get("cashflow_policy", {}), "cashflow_items": review.get("cashflow_items", []), "source_snapshot": review.get("source_snapshot", {}), "blockers": review.get("cashflow_blockers", []), } encoded = json.dumps(_canonical_value(payload), ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False) return hashlib.sha256(encoded.encode("utf-8")).hexdigest() def _normalize_cashflow_policy(payload: dict) -> dict: if not isinstance(payload, dict) or set(payload) - {"savings_categories", "category_decisions"}: raise ValueError("cashflow policy must contain only supported keys") savings = payload.get("savings_categories", []) decisions = payload.get("category_decisions", []) if not isinstance(savings, list) or not isinstance(decisions, list): raise ValueError("policy categories and decisions must be lists") def pair(row, required): if not isinstance(row, dict) or set(row) != required: raise ValueError("invalid category policy entry") if any(not isinstance(row[k], str) for k in ("category", "subcategory")): raise ValueError("category fields must be strings") return row["category"], row["subcategory"] normalized_savings = [pair(x, {"category", "subcategory"}) for x in savings] if len(set(normalized_savings)) != len(normalized_savings): raise ValueError("duplicate savings category") normalized_decisions = [] for row in decisions: cat, sub = pair(row, {"category", "subcategory", "cashflow_kind", "reason"}) kind, reason = row["cashflow_kind"], row["reason"] if not isinstance(kind, str) or kind not in _ALLOWED_KINDS: raise ValueError("invalid cashflow decision kind") if not isinstance(reason, str) or not reason.strip(): raise ValueError("decision reason must be nonempty") normalized_decisions.append((cat, sub, kind, reason)) keys = [(x[0], x[1]) for x in normalized_decisions] if len(set(keys)) != len(keys): raise ValueError("duplicate category decision") return {"savings_categories": [{"category": c, "subcategory": s} for c, s in sorted(normalized_savings)], "category_decisions": [{"category": c, "subcategory": s, "cashflow_kind": k, "reason": r} for c, s, k, r in sorted(normalized_decisions)]} def _review_cashflow(conn, month: str, policy: dict) -> dict: policy = _normalize_cashflow_policy(policy) start = date.fromisoformat(month + "-01") end = date(start.year + (start.month == 12), 1 if start.month == 12 else start.month + 1, 1) rows = conn.execute(f"""SELECT t.id,t.transaction_date,t.type,t.amount,t.currency,t.category,t.subcategory, {CASHFLOW_KIND_SQL} AS originalcashflow_kind, lc.cashflow_kind AS category_cashflow_kind, lc.active AS category_active FROM transactions t LEFT JOIN ledger_categories lc ON lc.category=t.category AND lc.subcategory=t.subcategory AND lc.active=1 WHERE t.status='active' AND t.transaction_date>=? AND t.transaction_date