from __future__ import annotations import json import hashlib from .account_valuation_service import _public_candidate from .shared import ( ASSET_BUCKETS, ASSET_BUCKET_ORDER, Any, LIABILITY_BUCKETS, current_kst_date, date, db, read_db, read_operation, fail, new_id, normalize_date, normalize_liquidity_bucket, now_iso, ok, row_to_dict, sqlite3, ) def _valuation_policy() -> dict[str, Any]: return { "authority_sources": ["accounts.last_balance", "manual_assets"], "snapshot_role": "historical_record_layer", "formula": "asset_buckets - loan", "asset_buckets": ASSET_BUCKETS, "liability_buckets": LIABILITY_BUCKETS, "missing_valuation_policy": "exclude_without_transaction_inference", } def _canonical_provenance(*, valuation_date: str = "", basis_date: str = "", carried: bool = False, verification_status: str = "", evidence_kind: str = "", warnings: list[dict[str, Any]] | None = None, exclusions: list[dict[str, Any]] | None = None, snapshot_run_id: str = "", close_status: str = "", selection_policy: str = "", approval: dict[str, Any] | None = None, correction: dict[str, Any] | None = None, age_days: int | None = None) -> dict[str, Any]: valuation_date = str(valuation_date or "")[:10] basis_date = str(basis_date or "")[:10] calculated_age_days = None if valuation_date and basis_date: try: calculated_age_days = (date.fromisoformat(basis_date) - date.fromisoformat(valuation_date)).days except ValueError: pass if age_days is None: age_days = calculated_age_days return {"valuation_date": valuation_date, "age_days": age_days, "carried": bool(carried), "carried_forward": bool(carried), "verification_status": verification_status, "evidence_kind": evidence_kind, "warnings": warnings or [], "exclusions": exclusions or [], "snapshot_run_id": snapshot_run_id, "monthly_source_snapshot_id": snapshot_run_id, "close_status": close_status, "selection_policy": selection_policy, "approval": approval or {}, "correction": correction or {}} def _public_evidence(item: dict[str, Any], strict: bool = False) -> dict[str, Any]: """Return evidence metadata using only the canonical public shape.""" allowed = { "id", "source_id", "source_type", "authority_source", "valuation_source", "name", "type", "amount", "currency", "liquidity_bucket", "valuation_date", "basis_date", "age_days", "carried", "carried_forward", "verification_status", "evidence_kind", "valuation_id", "valuation_kind", "active", "last_synced_at", "last_balance", "valuation_amount", "reason", "unverified_candidates", "candidate", "candidates", "state_hash", "blocking", "snapshot_run_id", "is_correction", "correction_of_run_id", "correction_reason", } if isinstance(item, dict): return {key: _public_evidence(value, strict) for key, value in item.items() if key in allowed or (not strict and isinstance(value, (dict, list)))} if isinstance(item, list): return [_public_evidence(value, strict) for value in item] return item def _evidence_provenance(evidence: list[dict[str, Any]], basis_date: str) -> dict[str, Any]: values = [item for item in evidence if item] dates = {str(item.get("valuation_date") or "")[:10] for item in values if item.get("valuation_date")} ages = {item.get("age_days") for item in values if item.get("age_days") is not None} carried = {bool(item.get("carried_forward")) for item in values} statuses = {str(item.get("verification_status") or "") for item in values} kinds = {str(item.get("evidence_kind") or "") for item in values} return { "valuation_date": next(iter(dates), "") if len(dates) == 1 else "mixed" if dates else "", "age_days": next(iter(ages), None) if len(ages) == 1 else None, "carried_forward": next(iter(carried), False) if len(carried) == 1 else bool(any(carried)), "verification_status": next(iter(statuses), "") if len(statuses) == 1 else "mixed" if statuses else "", "evidence_kind": next(iter(kinds), "") if len(kinds) == 1 else "mixed" if kinds else "", } def _evidence_state_hash(item: dict[str, Any]) -> str: """Bind idempotency to evidence state without exposing sensitive text.""" fields = ( "source_type", "source_id", "name", "amount", "currency", "liquidity_bucket", "authority_source", "valuation_id", "valuation_date", "verification_status", "source_note", "memo", "active", "carried_forward", ) payload = {key: item.get(key) for key in fields} return hashlib.sha256( json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode() ).hexdigest() def _valuation_row_state_hash(row: sqlite3.Row) -> str: """Bind redacted candidate evidence to its complete immutable row.""" return hashlib.sha256( json.dumps(row_to_dict(row), sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode() ).hexdigest() def _account_valuation_items(conn: sqlite3.Connection, basis_date: str = "", selection_policy: str = "exact") -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]: rows = conn.execute("SELECT * FROM accounts WHERE active=1 ORDER BY name").fetchall() included: list[dict[str, Any]] = [] excluded: list[dict[str, Any]] = [] invalid: list[dict[str, Any]] = [] for row in rows: account = row_to_dict(row) account_type = str(account.get("type") or "other").lower() raw_bucket = str(account.get("liquidity_bucket") or "other") try: bucket = normalize_liquidity_bucket(raw_bucket) except ValueError: bucket = "other" invalid.append({"id": account.get("id", ""), "name": account.get("name", ""), "liquidity_bucket": raw_bucket}) if account_type == "loan": bucket = "loan" base = { "id": account.get("id", ""), "source_id": account.get("id", ""), "source_type": "account", "authority_source": "accounts.last_balance", "name": account.get("name", ""), "type": account.get("type", ""), "currency": (account.get("currency") or "KRW").upper(), "liquidity_bucket": bucket, "valuation_date": account.get("last_balance_basis_date", ""), "last_synced_at": account.get("last_synced_at", ""), "source_note": account.get("memo", ""), "memo": account.get("memo", ""), "evidence_kind": "account_balance", } if base["currency"] != "KRW": excluded.append({**base, "reason": "non_krw_without_fx"}) continue balance = account.get("last_balance") if basis_date: valuation_rows = conn.execute( "SELECT * FROM account_valuations WHERE account_id=? AND UPPER(currency)=? ORDER BY basis_date DESC, recorded_at DESC, id DESC", (account["id"], base["currency"]), ).fetchall() leaves = [v for v in valuation_rows if conn.execute("SELECT 1 FROM account_valuations WHERE supersedes_id=?", (v["id"],)).fetchone() is None] if account_type == "investment": # Older imports recorded investment balances as cash_balance; # keep that as a compatibility fallback after the canonical # total_value/balance purposes. purposes = ("total_value", "balance", "cash_balance") elif account_type == "loan": purposes = ("loan_balance", "loan", "balance") else: purposes = ("cash_balance", "balance") selected = None selected_purpose = None candidate_rows = [] for purpose in purposes: scoped = [v for v in leaves if v["valuation_kind"] == purpose] if selection_policy == "exact": verified = [v for v in scoped if v["verification_status"] == "verified" and v["basis_date"] == basis_date] else: eligible = [v for v in scoped if v["verification_status"] == "verified" and v["basis_date"] <= basis_date] latest = max((v["basis_date"] for v in eligible), default="") verified = [v for v in eligible if v["basis_date"] == latest] if len(verified) > 1: invalid.append({**base, "reason": "ambiguous_valuation", "valuation_kind": purpose}) verified = [] break if verified: selected, selected_purpose = verified[0], purpose break candidate_rows.extend(v for v in scoped if v["verification_status"] == "unverified" and v["basis_date"] <= basis_date and (v["basis_date"] == basis_date if selection_policy == "exact" else True)) if selected is None: excluded.append({**base, "reason": "missing_verified_exact_valuation" if selection_policy == "exact" else "missing_verified_valuation", "unverified_candidates": [_public_candidate(v) for v in candidate_rows]}) continue valuation = selected candidate_rows.extend( v for v in leaves if v["valuation_kind"] == selected_purpose and v["verification_status"] == "unverified" and v["basis_date"] <= basis_date and (v["basis_date"] == basis_date if selection_policy == "exact" else True) ) if account_type == "loan" and not valuation["representative_liability_definition"]: excluded.append({ **base, "reason": "missing_representative_liability_definition", "valuation_id": valuation["id"], "valuation_kind": selected_purpose, "valuation_date": valuation["basis_date"], "verification_status": valuation["verification_status"], }) continue balance = valuation["amount"] base.update({"valuation_id": valuation["id"], "verification_status": "verified", "valuation_kind": selected_purpose, "valuation_date": valuation["basis_date"], "currency": valuation["currency"], "carried_forward": valuation["basis_date"] != basis_date, "evidence_kind": "account_valuation", "age_days": (date.fromisoformat(basis_date) - date.fromisoformat(str(valuation["basis_date"])[:10])).days}) if candidate_rows: base["unverified_candidates"] = [_public_candidate(v) for v in candidate_rows if v["basis_date"] <= basis_date] if balance is None: excluded.append({**base, "reason": "missing_last_balance"}) continue amount = int(balance) included.append({**base, "amount": amount, "last_balance": amount}) return included, excluded, invalid def _manual_asset_valuation_items(conn: sqlite3.Connection, active_only: bool = True, basis_date: str = "", selection_policy: str = "exact") -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: where = "WHERE active=1" if active_only else "" rows = conn.execute(f"SELECT * FROM manual_assets {where} ORDER BY name").fetchall() included: list[dict[str, Any]] = [] excluded: list[dict[str, Any]] = [] for row in rows: asset = row_to_dict(row) if (asset.get("currency") or "KRW").upper() != "KRW": excluded.append({"id": asset.get("id", ""), "source_id": asset.get("id", ""), "source_type": "manual_asset", "reason": "non_krw_without_fx", "currency": asset.get("currency")}) continue try: bucket = normalize_liquidity_bucket(asset.get("liquidity_bucket")) except ValueError: bucket = "other" base = { "id": asset.get("id", ""), "source_id": asset.get("id", ""), "source_type": "manual_asset", "authority_source": "manual_assets", "name": asset.get("name", ""), "currency": str(asset.get("currency") or "KRW").upper(), "liquidity_bucket": bucket, "valuation_date": asset.get("valuation_date", ""), "source_note": asset.get("source_note", ""), "memo": asset.get("memo", ""), "active": asset.get("active", True), "verification_status": "verified", "evidence_kind": "manual_confirmed", } value = asset.get("valuation_amount") if basis_date: valuation_date = str(asset.get("valuation_date") or "")[:10] if not valuation_date or valuation_date > basis_date or (selection_policy == "exact" and valuation_date != basis_date): excluded.append({**base, "reason": "missing_exact_manual_asset_valuation" if selection_policy == "exact" else "future_or_unknown_manual_asset_valuation"}) continue base["carried_forward"] = valuation_date != basis_date base["age_days"] = (date.fromisoformat(basis_date) - date.fromisoformat(valuation_date)).days if value is None: excluded.append({**base, "reason": "missing_valuation_amount"}) continue included.append({**base, "amount": int(value), "valuation_amount": int(value)}) return included, excluded def _persisted_evidence_array(value: Any) -> list[dict[str, Any]] | None: try: decoded = json.loads(value or "[]") except (TypeError, json.JSONDecodeError): return None if not isinstance(decoded, list) or any(not isinstance(item, dict) for item in decoded): return None return decoded def _snapshot_rows_for_run(conn: sqlite3.Connection, run_id: str) -> tuple[list[sqlite3.Row], dict[str, Any]]: """Load one immutable close run and only the rows persisted by that run.""" run = conn.execute( "SELECT rowid AS _run_rowid, * FROM asset_snapshot_runs WHERE id=?", (run_id,) ).fetchone() if not run: return [], {} basis_date = run["basis_date"] rows = conn.execute( "SELECT rowid AS _rowid, * FROM asset_snapshots WHERE basis_date=? AND snapshot_run_id=? ORDER BY created_at ASC, name ASC", (basis_date, run["id"]), ).fetchall() total_rows = conn.execute( "SELECT COUNT(*) FROM asset_snapshots WHERE basis_date=?", (basis_date,) ).fetchone()[0] previous_rows = conn.execute( """SELECT COUNT(*) FROM asset_snapshots AS snapshot LEFT JOIN asset_snapshot_runs AS snapshot_run ON snapshot_run.id=snapshot.snapshot_run_id WHERE snapshot.basis_date=? AND ( snapshot.snapshot_run_id IS NULL OR snapshot.snapshot_run_id='' OR snapshot_run.rowid < ? )""", (basis_date, run["_run_rowid"]), ).fetchone()[0] evidence = { field: _persisted_evidence_array(run[field]) for field in ("applied_evidence", "excluded_evidence", "carried_evidence") } integrity_error = any(value is None for value in evidence.values()) metadata = { "snapshot_run_id": run["id"], "is_correction": bool(run["correction_of_run_id"]), "correction_of_run_id": run["correction_of_run_id"] or "", "correction_reason": run["correction_reason"] or "", "close_status": run["close_status"], "selection_policy": run["selection_policy"], "approved_by": run["approved_by"], "approved_at": run["approved_at"], "approval_reason": run["approval_reason"], "approval_reference": run["approval_reference"], "applied_evidence": evidence["applied_evidence"] or [], "excluded_evidence": evidence["excluded_evidence"] or [], "carried_evidence": evidence["carried_evidence"] or [], "evidence_integrity_error": integrity_error, "closed_at": run["created_at"] or "", "previous_snapshot_row_count": previous_rows, "total_snapshot_rows_for_basis_date": total_rows, } return rows, metadata def _snapshot_rows_for_latest_run(conn: sqlite3.Connection, basis_date: str) -> tuple[list[sqlite3.Row], dict[str, Any]]: rows = conn.execute("SELECT rowid AS _rowid, * FROM asset_snapshots WHERE basis_date=? ORDER BY created_at ASC, name ASC", (basis_date,)).fetchall() run = conn.execute("SELECT * FROM asset_snapshot_runs WHERE basis_date=? ORDER BY rowid DESC LIMIT 1", (basis_date,)).fetchone() if not run: return rows, {"snapshot_run_id": "", "is_correction": False, "correction_of_run_id": "", "correction_reason": "", "total_snapshot_rows_for_basis_date": len(rows)} if rows else {} return _snapshot_rows_for_run(conn, run["id"]) def _snapshot_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str, Any]]: rows, metadata = _snapshot_rows_for_latest_run(conn, basis_date) evidence_by_key = {} for evidence in metadata.get("applied_evidence", []): key = (evidence.get("source_type", ""), evidence.get("source_id", ""), evidence.get("valuation_id", "")) evidence_by_key[key] = evidence items: list[dict[str, Any]] = [] for row in rows: snap = row_to_dict(row) try: bucket = normalize_liquidity_bucket(snap.get("liquidity_bucket")) except ValueError: bucket = "other" evidence = evidence_by_key.get( (snap.get("source_type", ""), snap.get("source_id", ""), snap.get("valuation_id", "")), evidence_by_key.get((snap.get("source_type", ""), snap.get("source_id", ""), ""), {}), ) valuation_date = evidence.get("valuation_date") or snap.get("basis_date", "") carried_forward = bool(evidence.get("carried_forward", False)) public_item = _public_evidence({ "id": snap.get("id", ""), "source_id": snap.get("source_id", ""), "source_type": snap.get("source_type", ""), "authority_source": "asset_snapshots", "valuation_source": snap.get("valuation_source", ""), "name": snap.get("name", ""), "currency": snap.get("currency", ""), "valuation_id": snap.get("valuation_id", ""), "liquidity_bucket": bucket, "amount": int(snap["amount"]) if snap.get("amount") is not None else None, "valuation_date": valuation_date, "age_days": evidence.get("age_days"), "carried_forward": carried_forward, "verification_status": evidence.get("verification_status") or snap.get("verification_status", ""), "evidence_kind": evidence.get("evidence_kind") or snap.get("evidence_kind", ""), "source_note": snap.get("source_note", ""), "memo": snap.get("memo", ""), "snapshot_run_id": snap.get("snapshot_run_id", ""), "is_correction": bool(snap.get("is_correction", False)), "correction_of_run_id": snap.get("correction_of_run_id", ""), "correction_reason": snap.get("correction_reason", ""), }) if not snap.get("valuation_id"): public_item.pop("valuation_id", None) items.append(public_item) return items def _closed_run_metadata(metadata: dict[str, Any]) -> dict[str, Any]: """Expose immutable run evidence without source notes or memos.""" warnings = [] exclusions = [_public_evidence(item, strict=True) for item in metadata.get("excluded_evidence", [])] candidate_warnings: dict[tuple[str, str], dict[str, Any]] = {} for item in exclusions: if item.get("candidate"): key = (item.get("source_type", ""), item.get("source_id", "")) warning_shape = {"code": "unverified_candidate", "source_type": key[0], "source_id": key[1], "candidates": []} if item.get("blocking") is False: warning_shape["blocking"] = False warning = candidate_warnings.setdefault(key, warning_shape) warning["candidates"].append(item["candidate"]) else: warnings.append({"code": item.get("reason", "excluded_item"), "source_type": item.get("source_type", ""), "source_id": item.get("source_id", ""), "currency": item.get("currency", "KRW")}) warnings.extend(candidate_warnings.values()) return { "warnings": warnings, "excluded_items": exclusions, "carried_items": [_public_evidence(item, strict=True) for item in metadata.get("carried_evidence", [])], "snapshot_run_id": metadata.get("snapshot_run_id", ""), "close_status": metadata.get("close_status", ""), "selection_policy": metadata.get("selection_policy", ""), "approval": {k: metadata.get(k, "") for k in ("approved_by", "approved_at", "approval_reason", "approval_reference")}, "correction": {k: metadata.get(k, "") for k in ("is_correction", "correction_of_run_id", "correction_reason")}, } def _preview_fingerprint(preview: dict[str, Any], basis: str, selection_policy: str) -> str: """Opaque authority-state token shared by preview and close.""" included = preview.get("_included_items", preview.get("included_items", [])) payload = { "basis_date": basis, "selection_policy": selection_policy, "included": [{**{k: item.get(k) for k in ("source_type", "source_id", "amount", "currency", "valuation_id", "valuation_date", "verification_status")}, "state_hash": _evidence_state_hash(item)} for item in included], "excluded": [{**{k: item.get(k) for k in ("source_type", "source_id", "reason", "valuation_date", "verification_status")}, "state_hash": _evidence_state_hash(item)} for item in preview.get("excluded_items", [])], "warnings": [warning for warning in preview.get("warnings", []) if warning.get("code") != "existing_snapshot_rows"], "candidate_state_hashes": preview.get("_candidate_state_hashes", []), } return hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()).hexdigest() def _provenance_aliases(provenance: dict[str, Any], snapshot_run_id: str = "") -> dict[str, Any]: return {key: provenance.get(key) for key in ("valuation_date", "age_days", "verification_status", "evidence_kind")} | { "monthly_source_snapshot_id": provenance.get("monthly_source_snapshot_id") or snapshot_run_id, } def _ok_with_provenance_aliases(**payload: Any) -> dict[str, Any]: result = ok(**payload) provenance = result.get("provenance") if isinstance(provenance, dict): result.update(_provenance_aliases(provenance, result.get("snapshot_run_id", ""))) return result def _closed_warnings(metadata: dict[str, Any], basis_date: str, rows: list[sqlite3.Row]) -> list[dict[str, Any]]: warnings = _closed_run_metadata(metadata)["warnings"] row_by_key = {(row["source_type"], row["source_id"]): row_to_dict(row) for row in rows} for evidence in metadata.get("applied_evidence", []): if not evidence.get("carried_forward"): continue row = row_by_key.get((evidence.get("source_type", ""), evidence.get("source_id", "")), {}) valuation_date = str(evidence.get("valuation_date") or "")[:10] if valuation_date and valuation_date < basis_date: warnings.append({"code": "stale_valuation", "source_type": evidence.get("source_type", ""), "source_id": evidence.get("source_id", ""), "name": row.get("name", ""), "valuation_date": valuation_date, "basis_date": basis_date}) previous_rows = int(metadata.get("previous_snapshot_row_count") or 0) if previous_rows: warnings.append({ "code": "existing_snapshot_rows", "basis_date": basis_date, "snapshot_row_count": previous_rows, "policy": "append_only_correction_required", }) return warnings def _summarize_valuation_items(included_items: list[dict[str, Any]], excluded_items: list[dict[str, Any]] | None = None) -> dict[str, Any]: bucket_totals = {bucket: 0 for bucket in ASSET_BUCKET_ORDER} bucket_counts = {bucket: 0 for bucket in ASSET_BUCKET_ORDER} for item in included_items: bucket = normalize_liquidity_bucket(item.get("liquidity_bucket")) raw_amount = item.get("amount") if raw_amount is None: continue amount = int(raw_amount) bucket_counts[bucket] += 1 bucket_totals[bucket] += amount total_assets = sum(bucket_totals[bucket] for bucket in ASSET_BUCKETS) total_liabilities = sum(abs(bucket_totals[bucket]) for bucket in LIABILITY_BUCKETS) net_worth = total_assets - total_liabilities buckets = [] for bucket in ASSET_BUCKET_ORDER: amount = bucket_totals[bucket] denominator = total_liabilities if bucket in LIABILITY_BUCKETS else total_assets buckets.append( { "liquidity_bucket": bucket, "amount": amount, "account_count": sum(1 for item in included_items if item.get("source_type") == "account" and item.get("liquidity_bucket") == bucket), "manual_asset_count": sum(1 for item in included_items if item.get("source_type") == "manual_asset" and item.get("liquidity_bucket") == bucket), "item_count": bucket_counts[bucket], "asset_share": round(amount / total_assets, 4) if bucket not in LIABILITY_BUCKETS and total_assets else None, "liability_share": round(abs(amount) / total_liabilities, 4) if bucket in LIABILITY_BUCKETS and total_liabilities else None, "share": round(abs(amount) / denominator, 4) if denominator else None, } ) return { "buckets": buckets, "bucket_totals": bucket_totals, "total_assets": total_assets, "total_liabilities": total_liabilities, "net_worth": net_worth, "included_items": included_items, "excluded_items": excluded_items or [], "valuation_policy": _valuation_policy(), } def create_manual_asset(name: str, valuation_amount: int | None, valuation_date: str, liquidity_bucket: str, source_note: str = "", memo: str = "", currency: str = "KRW", active: bool = True, db_path: str | None = None) -> dict[str, Any]: if not name.strip(): return fail("validation_error", "manual asset name is required") try: bucket = normalize_liquidity_bucket(liquidity_bucket) amount = int(valuation_amount) if valuation_amount is not None else None vdate = normalize_date(valuation_date, default_today=True) except Exception as e: return fail("validation_error", str(e)) ts = now_iso() asset_id = new_id("asset") with db(db_path) as conn: conn.execute( """INSERT INTO manual_assets(id,name,liquidity_bucket,valuation_amount,valuation_date,currency,source_note,memo,active,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)""", (asset_id, name.strip(), bucket, amount, vdate, currency, source_note, memo, int(active), ts, ts), ) row = conn.execute("SELECT * FROM manual_assets WHERE id=?", (asset_id,)).fetchone() return ok(manual_asset={**row_to_dict(row), "authority_source": "manual_assets"}) @read_operation def list_manual_assets(active_only: bool = True, db_path: str | None = None) -> dict[str, Any]: where = "WHERE active=1" if active_only else "" with read_db(db_path) as conn: rows = conn.execute(f"SELECT * FROM manual_assets {where} ORDER BY name").fetchall() assets = [{**row_to_dict(row), "authority_source": "manual_assets"} for row in rows] return ok(manual_assets=assets, count=len(assets), valuation_policy=_valuation_policy()) def update_manual_asset(asset_id: str, name: str | None = None, valuation_amount: int | None = None, valuation_date: str | None = None, liquidity_bucket: str | None = None, source_note: str | None = None, memo: str | None = None, currency: str | None = None, active: bool | None = None, db_path: str | None = None) -> dict[str, Any]: allowed_updates: list[str] = [] params: list[Any] = [] try: if name is not None: if not name.strip(): return fail("validation_error", "manual asset name is required") allowed_updates.append("name=?") params.append(name.strip()) if valuation_amount is not None: allowed_updates.append("valuation_amount=?") params.append(int(valuation_amount)) if valuation_date is not None: allowed_updates.append("valuation_date=?") params.append(normalize_date(valuation_date, default_today=True)) if liquidity_bucket is not None: allowed_updates.append("liquidity_bucket=?") params.append(normalize_liquidity_bucket(liquidity_bucket)) if source_note is not None: allowed_updates.append("source_note=?") params.append(source_note) if memo is not None: allowed_updates.append("memo=?") params.append(memo) if currency is not None: allowed_updates.append("currency=?") params.append(currency or "KRW") if active is not None: allowed_updates.append("active=?") params.append(int(bool(active))) except Exception as e: return fail("validation_error", str(e)) if not allowed_updates: return fail("validation_error", "no fields to update") allowed_updates.append("updated_at=?") params.extend([now_iso(), asset_id]) with db(db_path) as conn: cur = conn.execute(f"UPDATE manual_assets SET {', '.join(allowed_updates)} WHERE id=?", params) if cur.rowcount == 0: return fail("not_found", "manual asset not found") row = conn.execute("SELECT * FROM manual_assets WHERE id=?", (asset_id,)).fetchone() return ok(manual_asset={**row_to_dict(row), "authority_source": "manual_assets"}) def deactivate_manual_asset(asset_id: str, hard_delete: bool = False, db_path: str | None = None) -> dict[str, Any]: with db(db_path) as conn: if hard_delete: cur = conn.execute("DELETE FROM manual_assets WHERE id=?", (asset_id,)) else: cur = conn.execute("UPDATE manual_assets SET active=0, updated_at=? WHERE id=?", (now_iso(), asset_id)) if cur.rowcount == 0: return fail("not_found", "manual asset not found") row = None if hard_delete else conn.execute("SELECT * FROM manual_assets WHERE id=?", (asset_id,)).fetchone() payload = {"manual_asset_id": asset_id, "deactivated": True, "hard_delete": hard_delete} if row is not None: payload["manual_asset"] = {**row_to_dict(row), "authority_source": "manual_assets"} return ok(**payload) def _snapshot_preview_from_conn(conn: sqlite3.Connection, basis: str, selection_policy: str = "exact") -> dict[str, Any]: account_items, account_excluded, invalid_bucket_accounts = _account_valuation_items(conn, basis, selection_policy) manual_items, manual_excluded = _manual_asset_valuation_items(conn, basis_date=basis, selection_policy=selection_policy) included = account_items + manual_items excluded = account_excluded + manual_excluded summary = _summarize_valuation_items(included, excluded) existing_rows = conn.execute("SELECT COUNT(*) FROM asset_snapshots WHERE basis_date=?", (basis,)).fetchone()[0] warnings: list[dict[str, Any]] = [] for item in excluded: warnings.append({"code": item.get("reason", "excluded_item"), "source_type": item.get("source_type", ""), "source_id": item.get("source_id", ""), "currency": item.get("currency", "KRW")}) if item.get("unverified_candidates"): warnings.append({"code": "unverified_candidate", "source_type": item.get("source_type", ""), "source_id": item.get("source_id", ""), "candidates": [{k: candidate.get(k) for k in ("id", "basis_date", "currency", "valuation_kind", "verification_status")} for candidate in item["unverified_candidates"]]}) for item in included: if item.get("unverified_candidates"): warnings.append({"code": "unverified_candidate", "blocking": False, "source_type": item.get("source_type", ""), "source_id": item.get("source_id", ""), "candidates": [{k: candidate.get(k) for k in ("id", "basis_date", "currency", "valuation_kind", "verification_status")} for candidate in item["unverified_candidates"]]}) item_date = str(item.get("valuation_date") or "")[:10] if item_date and item_date < basis: warnings.append({"code": "stale_valuation", "source_type": item.get("source_type", ""), "source_id": item.get("source_id", ""), "name": item.get("name", ""), "valuation_date": item_date, "basis_date": basis}) for item in invalid_bucket_accounts: warnings.append({"code": "invalid_liquidity_bucket", **item}) if existing_rows: warnings.append({"code": "existing_snapshot_rows", "basis_date": basis, "snapshot_row_count": int(existing_rows), "policy": "append_only_correction_required"}) candidate_ids = sorted({ candidate.get("id", "") for warning in warnings if warning.get("code") == "unverified_candidate" for candidate in warning.get("candidates", []) if candidate.get("id") }) candidate_state_hashes = [] for candidate_id in candidate_ids: candidate_row = conn.execute( "SELECT * FROM account_valuations WHERE id=?", (candidate_id,), ).fetchone() if candidate_row is not None: candidate_state_hashes.append({ "id": candidate_id, "state_hash": _valuation_row_state_hash(candidate_row), }) public_excluded = [_public_evidence(item) for item in excluded] return { "basis_date": basis, "source": "live_authority", "provenance": _canonical_provenance(basis_date=basis, warnings=warnings, exclusions=public_excluded, selection_policy=selection_policy), "is_closed_snapshot": False, "snapshot_count": 0, "existing_snapshot_row_count": int(existing_rows), "would_be_correction": bool(existing_rows), "warnings": warnings, "_candidate_state_hashes": candidate_state_hashes, "_included_items": included, **{**summary, "included_items": [_public_evidence(item) for item in included], "excluded_items": public_excluded}, } @read_operation def preview_asset_snapshot_close(basis_date: str = "", db_path: str | None = None, selection_policy: str = "exact") -> dict[str, Any]: if selection_policy not in {"exact", "as_of"}: return fail("validation_error", "selection_policy must be exact or as_of") try: basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat() except Exception as e: return fail("validation_error", str(e)) with read_db(db_path) as conn: preview = _snapshot_preview_from_conn(conn, basis, selection_policy) preview["selection_policy"] = selection_policy approval_eligible = [w for w in preview["warnings"] if w.get("blocking", True) and w.get("code", "").startswith(("missing_", "future_", "unknown_", "unverified", "ambiguous_"))] carried_items = [item for item in preview["included_items"] if item.get("carried_forward")] other_blocking = [w for w in preview["warnings"] if w.get("blocking", True) and w.get("code", "").startswith("stale_") and not carried_items] if other_blocking: preview["close_status"] = "incomplete_draft" elif approval_eligible or carried_items: preview["close_status"] = "incomplete_draft" else: preview["close_status"] = "complete_exact" # Fingerprint the private authority collection before redacting it from # the public preview. Close performs the same calculation. preview["preview_fingerprint"] = _preview_fingerprint(preview, basis, selection_policy) preview.pop("_included_items", None) preview.pop("_candidate_state_hashes", None) return ok(**preview) def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str = "", correction_reason: str = "", db_path: str | None = None, selection_policy: str = "exact", idempotency_key: str = "", allow_missing_exclusions: bool = False, allow_as_of_carry_forward: bool = False, approved_by: str = "", approval_reason: str = "", approval_reference: str = "", preview_fingerprint: str = "") -> dict[str, Any]: if selection_policy not in {"exact", "as_of"}: return fail("validation_error", "selection_policy must be exact or as_of") try: basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat() except Exception as e: return fail("validation_error", str(e)) ts = now_iso() run_id = new_id("snaprun") with db(db_path) as conn: conn.execute("BEGIN IMMEDIATE") preview = _snapshot_preview_from_conn(conn, basis, selection_policy) if preview_fingerprint and preview_fingerprint != _preview_fingerprint(preview, basis, selection_policy): return fail("authority_drift", "asset authority changed since preview", basis_date=basis) latest_rows, latest_meta = _snapshot_rows_for_latest_run(conn, basis) if latest_meta.get("evidence_integrity_error"): return fail("data_integrity_error", "snapshot evidence is malformed", snapshot_run_id=latest_meta.get("snapshot_run_id", "")) legacy_snapshot_rows = bool(latest_rows and not latest_meta.get("snapshot_run_id")) if legacy_snapshot_rows and not correction_reason.strip(): return fail( "legacy_snapshot_correction_required", "legacy snapshot rows require an explicit correction reason before close", basis_date=basis, existing_snapshot_row_count=preview["existing_snapshot_row_count"], ) missing_exclusions = [w for w in preview["warnings"] if w.get("blocking", True) and w.get("code", "").startswith(("missing_", "future_", "unknown_", "unverified", "ambiguous_"))] carried_items = [item for item in preview["included_items"] if item.get("carried_forward")] conditional = bool(missing_exclusions or carried_items) authority_fingerprint = _preview_fingerprint(preview, basis, selection_policy) request_payload = {"basis_date": basis, "source_note": source_note, "memo": memo, "correction_reason": correction_reason, "selection_policy": selection_policy, "allow_missing_exclusions": allow_missing_exclusions, "allow_as_of_carry_forward": allow_as_of_carry_forward, "approved_by": approved_by, "approval_reason": approval_reason, "approval_reference": approval_reference} request_fingerprint = hashlib.sha256(json.dumps(request_payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()).hexdigest() fingerprint = json.dumps({"authority": authority_fingerprint, "request": request_fingerprint}, sort_keys=True, separators=(",", ":")) if missing_exclusions and (not allow_missing_exclusions or not approved_by.strip() or not approval_reason.strip() or not approval_reference.strip()): return fail("conditional_approval_required", "missing exclusions require explicit approval metadata and allow_missing_exclusions") if carried_items and (not allow_as_of_carry_forward or not approved_by.strip() or not approval_reason.strip() or not approval_reference.strip()): return fail("conditional_approval_required", "conditional close requires explicit approval metadata and flags") if idempotency_key: existing_run = conn.execute("SELECT * FROM asset_snapshot_runs WHERE idempotency_key=?", (idempotency_key,)).fetchone() if existing_run: try: stored_fingerprint = json.loads(existing_run["request_fingerprint"]) except (TypeError, json.JSONDecodeError): stored_fingerprint = {"authority": existing_run["request_fingerprint"], "request": ""} latest_basis_run = conn.execute( "SELECT id, correction_of_run_id, request_fingerprint FROM asset_snapshot_runs WHERE basis_date=? ORDER BY rowid DESC LIMIT 1", (basis,), ).fetchone() run_in_correction_chain = False seen_run_ids: set[str] = set() chain_run = latest_basis_run while chain_run and chain_run["id"] not in seen_run_ids: if chain_run["id"] == existing_run["id"]: run_in_correction_chain = True break seen_run_ids.add(chain_run["id"]) parent_id = chain_run["correction_of_run_id"] chain_run = ( conn.execute( "SELECT id, correction_of_run_id FROM asset_snapshot_runs WHERE id=?", (parent_id,), ).fetchone() if parent_id else None ) try: latest_authority_fingerprint = json.loads( latest_basis_run["request_fingerprint"] ).get("authority") if latest_basis_run else None except (TypeError, json.JSONDecodeError, AttributeError): latest_authority_fingerprint = None immutable_replay_run = bool( run_in_correction_chain and latest_basis_run and latest_authority_fingerprint == authority_fingerprint and ( latest_basis_run["id"] != existing_run["id"] or existing_run["correction_of_run_id"] ) ) if stored_fingerprint.get("request") == request_fingerprint and ( stored_fingerprint.get("authority") == authority_fingerprint or immutable_replay_run ): replay_rows, replay_meta = _snapshot_rows_for_run(conn, existing_run["id"]) if replay_meta.get("evidence_integrity_error"): return fail("data_integrity_error", "snapshot evidence is malformed", snapshot_run_id=existing_run["id"]) replay_items = [_public_evidence(item) for item in replay_meta.get("applied_evidence", [])] replay_warnings = _closed_warnings(replay_meta, basis, replay_rows) replay_public = _closed_run_metadata(replay_meta) replay_public["warnings"] = replay_warnings replay_summary = _summarize_valuation_items(replay_items, replay_public["excluded_items"]) replay_provenance = _evidence_provenance(replay_meta.get("applied_evidence", []), basis) approval = replay_public["approval"] correction = replay_public["correction"] return _ok_with_provenance_aliases( basis_date=basis, source="snapshot", is_closed_snapshot=True, snapshot_run_id=existing_run["id"], correction_of_run_id=correction["correction_of_run_id"], correction_reason=correction["correction_reason"], is_correction=correction["is_correction"], closed_at=existing_run["created_at"], close_status=existing_run["close_status"], selection_policy=existing_run["selection_policy"], inserted_count=len(replay_rows), inserted_snapshot_ids=[ next((row["id"] for row in replay_rows if row["source_type"] == item.get("source_type") and row["source_id"] == item.get("source_id")), "") for item in replay_meta.get("applied_evidence", []) ], warnings=replay_warnings, carried_items=replay_public["carried_items"], approval=approval, correction=correction, provenance=_canonical_provenance( valuation_date=(replay_items[0].get("valuation_date", "") if replay_items else ""), age_days=(replay_items[0].get("age_days") if replay_items else None), carried=replay_provenance["carried_forward"], verification_status=replay_provenance["verification_status"], evidence_kind=replay_provenance["evidence_kind"], basis_date=basis, snapshot_run_id=existing_run["id"], close_status=existing_run["close_status"], selection_policy=existing_run["selection_policy"], warnings=replay_warnings, exclusions=replay_public["excluded_items"], approval=approval, correction=correction, ), **replay_summary, ) return fail("idempotency_conflict", "snapshot close idempotency payload differs") included = preview.pop("_included_items", preview["included_items"]) # A conditional close may legitimately contain zero snapshot rows. # The run record, not row count, is the authoritative close marker. is_correction = bool(latest_meta.get("snapshot_run_id") or legacy_snapshot_rows) if is_correction and not correction_reason.strip(): return fail("validation_error", "correction_reason is required when closing a basis_date that already has a snapshot run", basis_date=basis, existing_snapshot_row_count=preview["existing_snapshot_row_count"]) correction_of_run_id = latest_meta.get("snapshot_run_id", "") snapshot_ids: list[str] = [] for item in included: snapshot_id = new_id("snap") row_note = source_note or item.get("source_note", "") or "month-end close" row_memo = memo or item.get("memo", "") conn.execute( """INSERT INTO asset_snapshots(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,valuation_source,source_note,memo,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) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( snapshot_id, basis, item.get("source_type", ""), item.get("source_id", item.get("id", "")), item.get("name", ""), int(item["amount"]) if item.get("amount") is not None else None, item.get("currency", "KRW"), normalize_liquidity_bucket(item.get("liquidity_bucket")), item.get("authority_source", item.get("source_type", "")), row_note, row_memo, run_id, correction_of_run_id, correction_reason.strip(), int(is_correction), ts, item.get("valuation_id", ""), selection_policy, item.get("verification_status", "unverified"), "carried_forward" if item.get("carried_forward") else "applied", "", ts, ), ) snapshot_ids.append(snapshot_id) applied_evidence = [{k: item.get(k) for k in ("id", "source_type", "source_id", "authority_source", "name", "type", "liquidity_bucket", "active", "amount", "valuation_amount", "last_balance", "last_synced_at", "currency", "valuation_id", "valuation_kind", "valuation_date", "age_days", "carried_forward", "verification_status", "evidence_kind", "unverified_candidates") if item.get(k) is not None} for item in included] excluded_evidence = [{k: item.get(k) for k in ("source_type", "source_id", "reason", "currency")} for item in preview.get("excluded_items", [])] excluded_evidence.extend( { "source_type": warning.get("source_type", ""), "source_id": warning.get("source_id", ""), "reason": "unverified_candidate", "candidate": candidate, "blocking": warning.get("blocking", True), "state_hash": next( state["state_hash"] for state in preview["_candidate_state_hashes"] if state["id"] == candidate.get("id") ), } for warning in preview["warnings"] if warning.get("code") == "unverified_candidate" for candidate in warning.get("candidates", []) ) carried_evidence = [{k: item.get(k) for k in ("source_type", "source_id", "amount", "currency", "valuation_id", "valuation_date", "age_days", "carried_forward", "verification_status", "evidence_kind")} for item in included if item.get("carried_forward")] summary = _summarize_valuation_items([_public_evidence(item) for item in included], excluded_evidence) try: conn.execute("INSERT INTO asset_snapshot_runs(id,basis_date,close_status,selection_policy,idempotency_key,approved_by,approved_at,approval_reason,approval_reference,correction_of_run_id,correction_reason,applied_evidence,excluded_evidence,carried_evidence,request_fingerprint,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", (run_id, basis, "conditional" if conditional else "complete_exact", selection_policy, idempotency_key, approved_by, ts if approved_by else "", approval_reason, approval_reference, correction_of_run_id, correction_reason.strip(), json.dumps(applied_evidence, ensure_ascii=False, sort_keys=True), json.dumps(excluded_evidence, ensure_ascii=False, sort_keys=True), json.dumps(carried_evidence, ensure_ascii=False, sort_keys=True), fingerprint, ts)) except sqlite3.IntegrityError: conn.rollback() return fail("snapshot_conflict", "another snapshot close already owns this basis date", basis_date=basis) approval = { "approved_by": approved_by, "approved_at": ts if approved_by else "", "approval_reason": approval_reason, "approval_reference": approval_reference, } correction = { "is_correction": is_correction, "correction_of_run_id": correction_of_run_id, "correction_reason": correction_reason.strip(), } verification_statuses = {str(item.get("verification_status") or "") for item in included} evidence_kinds = {str(item.get("evidence_kind") or "") for item in included} return _ok_with_provenance_aliases( basis_date=basis, source="snapshot", is_closed_snapshot=True, snapshot_run_id=run_id, correction_of_run_id=correction_of_run_id, correction_reason=correction_reason.strip(), is_correction=is_correction, closed_at=ts, close_status="conditional" if conditional else "complete_exact", selection_policy=selection_policy, inserted_count=len(snapshot_ids), inserted_snapshot_ids=snapshot_ids, warnings=preview["warnings"], carried_items=carried_evidence, approval=approval, correction=correction, provenance=_canonical_provenance( valuation_date=(preview["included_items"][0].get("valuation_date", "") if preview["included_items"] else ""), basis_date=basis, carried=bool(carried_evidence), verification_status=(verification_statuses.pop() if len(verification_statuses) == 1 else "mixed" if verification_statuses else ""), evidence_kind=(evidence_kinds.pop() if len(evidence_kinds) == 1 else "mixed" if evidence_kinds else ""), snapshot_run_id=run_id, close_status="conditional" if conditional else "complete_exact", selection_policy=selection_policy, warnings=preview["warnings"], exclusions=excluded_evidence, approval=approval, correction=correction, ), **summary, ) def create_asset_snapshot(basis_date: str, source_type: str, source_id: str, name: str, amount: int | None, liquidity_bucket: str, valuation_source: str = "", source_note: str = "", memo: str = "", currency: str = "KRW", db_path: str | None = None) -> dict[str, Any]: if source_type not in {"account", "manual_asset"}: return fail("validation_error", "source_type must be one of ['account', 'manual_asset']") if not name.strip(): return fail("validation_error", "snapshot item name is required") try: basis = normalize_date(basis_date, default_today=True) bucket = normalize_liquidity_bucket(liquidity_bucket) amount_int = int(amount) if amount is not None else None except Exception as e: return fail("validation_error", str(e)) ts = now_iso() snapshot_id = new_id("snap") with db(db_path) as conn: conn.execute( """INSERT INTO asset_snapshots(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,valuation_source,source_note,memo,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""", (snapshot_id, basis, source_type, source_id, name.strip(), amount_int, currency, bucket, valuation_source or source_type, source_note, memo, ts), ) row = conn.execute("SELECT * FROM asset_snapshots WHERE id=?", (snapshot_id,)).fetchone() return ok(asset_snapshot=row_to_dict(row)) @read_operation def get_asset_snapshot_report(basis_date: str = "", db_path: str | None = None, selection_policy: str = "exact") -> dict[str, Any]: if selection_policy not in {"exact", "as_of"}: return fail("validation_error", "selection_policy must be exact or as_of") try: basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat() except Exception: return fail("validation_error", f"invalid date: {basis_date}") with read_db(db_path) as conn: rows, close_metadata = _snapshot_rows_for_latest_run(conn, basis) if close_metadata.get("evidence_integrity_error"): return fail("data_integrity_error", "snapshot evidence is malformed", snapshot_run_id=close_metadata.get("snapshot_run_id", "")) has_close_run = bool(close_metadata.get("snapshot_run_id")) snapshots = _snapshot_items(conn, basis) if rows else [] legacy_snapshots = snapshots if rows and not has_close_run else [] if has_close_run: run_public = _closed_run_metadata(close_metadata) run_public["warnings"] = _closed_warnings(close_metadata, basis, rows) summary = _summarize_valuation_items(snapshots, run_public["excluded_items"]) evidence_provenance = _evidence_provenance(close_metadata.get("applied_evidence", []), basis) return _ok_with_provenance_aliases( basis_date=basis, source="asset_snapshots", is_closed_snapshot=True, snapshot_run_id=close_metadata.get("snapshot_run_id", ""), snapshot_count=len(snapshots), close_metadata=run_public, selection_policy=close_metadata.get("selection_policy", selection_policy), close_status=close_metadata.get("close_status", "complete_exact"), warnings=run_public["warnings"], carried_items=run_public["carried_items"], approval=run_public["approval"], correction=run_public["correction"], provenance=_canonical_provenance( valuation_date=evidence_provenance["valuation_date"], age_days=evidence_provenance["age_days"], carried=evidence_provenance["carried_forward"], verification_status=evidence_provenance["verification_status"], evidence_kind=evidence_provenance["evidence_kind"], basis_date=basis, snapshot_run_id=close_metadata.get("snapshot_run_id", ""), close_status=close_metadata.get("close_status", "complete_exact"), selection_policy=close_metadata.get("selection_policy", selection_policy), warnings=run_public["warnings"], exclusions=run_public["excluded_items"], approval=run_public["approval"], correction=run_public["correction"], ), **summary, ) preview = _snapshot_preview_from_conn(conn, basis, selection_policy) included = preview.get("_included_items", []) if not included and not preview["excluded_items"] and legacy_snapshots: public_legacy = [_public_evidence(item) for item in legacy_snapshots] null_items = [item for item in public_legacy if item.get("amount") is None] null_excluded = [{**item, "reason": "missing_amount"} for item in null_items] summary = _summarize_valuation_items(public_legacy, null_excluded) return _ok_with_provenance_aliases( basis_date=basis, source="legacy_asset_snapshots", selection_policy=selection_policy, close_status="incomplete_draft" if null_items else close_metadata.get("close_status", "legacy_read_only"), is_closed_snapshot=False, provenance=_canonical_provenance(basis_date=basis, snapshot_run_id="", close_status="legacy_read_only", selection_policy=selection_policy), snapshot_count=len(legacy_snapshots), legacy_snapshot_row_count=len(legacy_snapshots), close_metadata={"legacy": True, "snapshot_run_id": ""}, **summary, ) public_included = [_public_evidence(item) for item in included] public_excluded = preview["excluded_items"] carried_items = [item for item in public_included if item.get("carried_forward")] warnings = preview["warnings"] approval = {} correction = {} evidence_provenance = _evidence_provenance(included, basis) summary = _summarize_valuation_items(public_included, public_excluded) return _ok_with_provenance_aliases( basis_date=basis, source="live_authority", selection_policy=selection_policy, close_status=preview["close_status"] if "close_status" in preview else ("incomplete_draft" if (public_excluded or carried_items) else "complete_exact"), provenance=_canonical_provenance( valuation_date=evidence_provenance["valuation_date"], age_days=evidence_provenance["age_days"], carried=evidence_provenance["carried_forward"], verification_status=evidence_provenance["verification_status"], evidence_kind=evidence_provenance["evidence_kind"], basis_date=basis, close_status=preview["close_status"] if "close_status" in preview else "", selection_policy=selection_policy, warnings=warnings, exclusions=public_excluded, approval=approval, correction=correction, ), is_closed_snapshot=False, snapshot_count=0, legacy_snapshot_row_count=len(legacy_snapshots), invalid_bucket_accounts=[warning for warning in warnings if warning.get("code") == "invalid_liquidity_bucket"], warnings=warnings, carried_items=carried_items, approval=approval, correction=correction, **{**summary, "included_items": public_included, "excluded_items": public_excluded}, ) @read_operation def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: int = 12, db_path: str | None = None) -> dict[str, Any]: try: months_int = max(1, min(int(months), 120)) end_norm = normalize_date(end_date, default_today=True) if end_date else current_kst_date().isoformat() if start_date: start_norm = normalize_date(start_date) else: end_dt = date.fromisoformat(end_norm) start_month = end_dt.month - months_int + 1 year = end_dt.year + (start_month - 1) // 12 month = (start_month - 1) % 12 + 1 start_norm = date(year, month, 1).isoformat() except Exception as e: return fail("validation_error", str(e)) with read_db(db_path) as conn: basis_rows = conn.execute( """SELECT basis_date FROM asset_snapshots WHERE basis_date>=? AND basis_date<=? UNION SELECT basis_date FROM asset_snapshot_runs WHERE basis_date>=? AND basis_date<=? ORDER BY basis_date ASC""", (start_norm, end_norm, start_norm, end_norm), ).fetchall() points = [] for row in basis_rows: basis = row["basis_date"] _, metadata = _snapshot_rows_for_latest_run(conn, basis) if metadata.get("evidence_integrity_error"): return fail("data_integrity_error", "snapshot evidence is malformed", snapshot_run_id=metadata.get("snapshot_run_id", "")) items = _snapshot_items(conn, basis) run_public = _closed_run_metadata(metadata) run_public["warnings"] = _closed_warnings(metadata, basis, conn.execute("SELECT rowid AS _rowid, * FROM asset_snapshots WHERE basis_date=? ORDER BY created_at ASC, name ASC", (basis,)).fetchall()) run_provenance = _evidence_provenance(metadata.get("applied_evidence", []), basis) summary = _summarize_valuation_items(items, run_public["excluded_items"]) points.append( { "basis_date": basis, "total_assets": summary["total_assets"], "total_liabilities": summary["total_liabilities"], "net_worth": summary["net_worth"], "source": "asset_snapshots", "item_count": len(items), "snapshot_run_id": metadata.get("snapshot_run_id", ""), "close_status": metadata.get("close_status", ""), "selection_policy": metadata.get("selection_policy", ""), "warnings": run_public["warnings"], "excluded_items": run_public["excluded_items"], "carried_items": run_public["carried_items"], "approval": run_public["approval"], "correction": run_public["correction"], "provenance": _canonical_provenance( valuation_date=run_provenance["valuation_date"], age_days=run_provenance["age_days"], carried=run_provenance["carried_forward"], verification_status=run_provenance["verification_status"], evidence_kind=run_provenance["evidence_kind"], basis_date=basis, snapshot_run_id=metadata.get("snapshot_run_id", ""), close_status=metadata.get("close_status", ""), selection_policy=metadata.get("selection_policy", ""), warnings=run_public["warnings"], exclusions=run_public["excluded_items"], approval=run_public["approval"], correction=run_public["correction"], ), } ) return ok(start_date=start_norm, end_date=end_norm, months=months_int, points=points, count=len(points), valuation_policy=_valuation_policy()) @read_operation def get_asset_liquidity_report(db_path: str | None = None) -> dict[str, Any]: """Summarize assets/liabilities from shared valuation authority sources. Existing REQ-005 keys are preserved. When manual_assets is empty, totals and source remain equivalent to the original accounts.last_balance behavior. """ with read_db(db_path) as conn: account_items, account_excluded, invalid_bucket_accounts = _account_valuation_items(conn) manual_items, manual_excluded = _manual_asset_valuation_items(conn) included = account_items + manual_items excluded = account_excluded + manual_excluded summary = _summarize_valuation_items(included, excluded) source_accounts = [item for item in included if item.get("source_type") == "account"] source = "accounts.last_balance" if not manual_items and not manual_excluded else "accounts.last_balance+manual_assets" return ok( buckets=summary["buckets"], accounts=source_accounts, manual_assets=manual_items, total_assets=summary["total_assets"], total_liabilities=summary["total_liabilities"], net_worth=summary["net_worth"], missing_balance_accounts=[ {"id": item.get("id", ""), "name": item.get("name", ""), "liquidity_bucket": item.get("liquidity_bucket", "")} for item in account_excluded ], missing_valuation_items=excluded, invalid_bucket_accounts=invalid_bucket_accounts, manual_asset_count=len(manual_items), included_items=included, excluded_items=excluded, valuation_policy=summary["valuation_policy"], source=source, )