from __future__ import annotations import sqlite3 from typing import Any from uuid import uuid4 from .shared import db, fail, now_iso, ok, read_db _ALLOWED_VERIFICATION = {"pending", "unverified"} _ALLOWED_SETTLEMENT_STATUS = {"draft", "in_review"} LIQUIDITY_BUCKETS = ("cash", "deposit", "investment", "real_estate", "loan", "other") def normalize_liquidity_bucket(value: Any, *, default: str = "other") -> str: value = default if value is None or str(value).strip() == "" else str(value).strip().lower() if value not in LIQUIDITY_BUCKETS: raise ValueError("liquidity_bucket must be cash, deposit, investment, real_estate, loan or other") return value def _public_candidate(item: dict[str, Any], strict: bool = False) -> dict[str, Any]: """Compatibility projection used by asset evidence readers.""" allowed = {"id", "source_id", "source_type", "name", "amount", "currency", "basis_date", "valuation_id", "valuation_kind", "verification_status", "evidence_kind", "memo"} return {key: value for key, value in item.items() if key in allowed} def record_account_valuation_candidate(*args: Any, **kwargs: Any) -> dict[str, Any]: return fail("unsupported", "account valuation candidates are not writable") def _strict_int(value: Any, field: str) -> int: if isinstance(value, bool) or not isinstance(value, int): raise ValueError(f"{field} must be an integer") return value def register_account_valuation( account_id: str, basis_date: str, amount: int, currency: str | None = None, valuation_kind: str = "balance", source_type: str = "account", source_ref: str = "", verification_status: str = "unverified", memo: str = "", idempotency_key: str = "", monthly_settlement_id: str | None = None, db_path: str | None = None, liquidity_bucket: str | None = None, **kwargs: Any, ) -> dict[str, Any]: """Register one unverified account evaluation in the monthly snapshot. This is intentionally the account_snapshot write path; no valuation table is created. The transaction locks before checking duplicates so concurrent UI submissions cannot create two rows for the same settlement/account. """ try: if not account_id or not basis_date or not currency: raise ValueError("account_id, basis_date and currency are required") amount = _strict_int(amount, "amount") if source_type != "account": raise ValueError("source_type must be account") if verification_status not in _ALLOWED_VERIFICATION: raise ValueError("verification_status must be pending or unverified") with db(db_path) as conn: conn.execute("BEGIN IMMEDIATE") selection_policy = kwargs.get("selection_policy", "manual") valuation_source = kwargs.get("valuation_source", "manual") source_note = kwargs.get("source_note", "") if selection_policy not in {"manual", "previous", "next"}: raise ValueError("selection_policy must be manual, previous or next") if valuation_source not in {"manual", "ledger"}: raise ValueError("invalid valuation_source") account = conn.execute("SELECT id,name,currency,liquidity_bucket FROM accounts WHERE id=?", (account_id,)).fetchone() if account is None: conn.rollback(); return fail("not_found", "account not found") if account["currency"] != currency: conn.rollback(); return fail("validation_error", "currency does not match account") bucket = normalize_liquidity_bucket(liquidity_bucket, default=account["liquidity_bucket"] or "other") settlement = conn.execute( "SELECT id,basis_date,status FROM monthly_settlement WHERE id=?", (monthly_settlement_id,) ).fetchone() if settlement is None: conn.rollback(); return fail("not_found", "monthly settlement not found") if settlement["status"] == "closed": conn.rollback(); return fail("closed_settlement", "closed monthly settlement cannot be changed") if settlement["status"] not in _ALLOWED_SETTLEMENT_STATUS: conn.rollback(); return fail("invalid_settlement_status", "only draft or in_review settlements can be changed") if basis_date != settlement["basis_date"]: conn.rollback(); return fail("validation_error", "basis_date must match monthly settlement") if selection_policy != "manual": tx = conn.execute("""SELECT t.balance_after,t.transaction_date FROM transactions t JOIN accounts a ON a.id=t.account_id WHERE t.id=? AND t.account_id=? AND a.active=1 AND t.currency=? AND t.status='active' AND t.balance_after IS NOT NULL""", (source_note, account_id, currency)).fetchone() if tx is None or (selection_policy == "previous" and tx["transaction_date"] > basis_date) or (selection_policy == "next" and tx["transaction_date"] <= basis_date): conn.rollback(); return fail("stale_evidence", "selected ledger is no longer valid") amount = tx["balance_after"] duplicate = conn.execute( "SELECT id FROM account_snapshot WHERE monthly_settlement_id=? AND source_type='account' AND source_id=?", (settlement["id"], account_id), ).fetchone() if duplicate: conn.rollback(); return fail("duplicate", "account evaluation already registered", snapshot_id=duplicate["id"]) snapshot_id = f"snapshot_{uuid4().hex}" conn.execute( """INSERT INTO account_snapshot (id,basis_date,source_type,source_id,name,amount,currency,memo, monthly_settlement_id,valuation_id,selection_policy,valuation_source,source_note,verification_status,evidence_kind,liquidity_bucket,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (snapshot_id, basis_date, "account", account_id, account["name"], amount, currency, memo or "", settlement["id"], "", selection_policy, valuation_source, source_note, "unverified", "applied", bucket, now_iso()), ) return ok(snapshot_id=snapshot_id, id=snapshot_id, verification_status="unverified", created=True) except ValueError as exc: return fail("validation_error", str(exc)) except sqlite3.IntegrityError: return fail("duplicate", "account evaluation already registered") except Exception: return fail("db_error", "account evaluation registration failed") def list_account_valuations(account_id: str, include_rejected: bool = True, db_path: str | None = None) -> dict[str, Any]: try: with read_db(db_path) as conn: rows = conn.execute("SELECT * FROM account_snapshot WHERE source_type='account' AND source_id=? ORDER BY created_at,id", (account_id,)).fetchall() return ok(valuations=[dict(r) for r in rows], count=len(rows), read_only=True) except Exception: return fail("db_error", "account evaluations unavailable") def select_account_valuation(*args: Any, **kwargs: Any) -> dict[str, Any]: return fail("unsupported", "account valuation selection is unavailable") def correct_account_valuation(*args: Any, **kwargs: Any) -> dict[str, Any]: return fail("unsupported", "account valuation correction is unavailable") def update_account_snapshot_category(snapshot_id: str, monthly_settlement_id: str, liquidity_bucket: str, db_path: str | None = None) -> dict[str, Any]: try: bucket = normalize_liquidity_bucket(liquidity_bucket) with db(db_path) as conn: conn.execute("BEGIN IMMEDIATE") settlement = conn.execute("SELECT status FROM monthly_settlement WHERE id=?", (monthly_settlement_id,)).fetchone() if settlement is None: conn.rollback(); return fail("not_found", "monthly settlement not found") if settlement["status"] not in _ALLOWED_SETTLEMENT_STATUS: conn.rollback(); return fail("invalid_settlement_status", "only draft or in_review settlements can be changed") snapshot = conn.execute("SELECT id,source_type,monthly_settlement_id FROM account_snapshot WHERE id=?", (snapshot_id,)).fetchone() if snapshot is None: conn.rollback(); return fail("not_found", "account snapshot not found") if snapshot["source_type"] != "account" or snapshot["monthly_settlement_id"] != monthly_settlement_id: conn.rollback(); return fail("validation_error", "snapshot is not linked to this monthly settlement") changed = conn.execute("UPDATE account_snapshot SET liquidity_bucket=? WHERE id=? AND monthly_settlement_id=?", (bucket, snapshot_id, monthly_settlement_id)) if changed.rowcount != 1: conn.rollback(); return fail("stale_update", "snapshot category update is stale") return ok(snapshot_id=snapshot_id, monthly_settlement_id=monthly_settlement_id, liquidity_bucket=bucket, updated=True) except ValueError as exc: return fail("validation_error", str(exc)) except Exception: return fail("db_error", "account snapshot category update failed")