from __future__ import annotations from datetime import date from uuid import uuid4 import math import re from typing import Any import sqlite3 from .shared import db, read_db, read_operation, fail, new_id, normalize_date, now_iso, ok, row_to_dict _VALID_STATUSES = {"verified", "unverified", "rejected"} _VALID_LOAN_DEFINITIONS = {"principal_only", "principal_interest_fees"} _VALID_CURRENCIES = {"KRW", "USD", "EUR", "JPY", "CNY", "GBP"} _VALID_KINDS = {"balance", "cash_balance", "total_value", "loan", "loan_balance"} _VALID_SOURCE_TYPES = {"manual", "import", "account_write", "reconciliation", "snapshot"} _COLUMNS = ( "amount", "currency", "valuation_kind", "source_type", "source_ref", "verification_status", "memo", "as_of_at", "timezone", "cost_basis", "net_contributed", "reported_principal_kind", "reported_principal_label", "profit", "return_rate", "return_basis", "principal", "interest", "fee", "representative_liability_definition", ) def _valuation(row: Any) -> dict[str, Any]: return row_to_dict(row) def _public_candidate(row: Any) -> dict[str, Any]: return {k: row[k] for k in ("id", "basis_date", "currency", "valuation_kind", "verification_status")} def _strict_int(value: Any, field: str, allow_none: bool = True) -> int | None: if value is None and allow_none: return None if isinstance(value, bool): raise ValueError(f"{field} must be an integer") if isinstance(value, int): return value if isinstance(value, str) and re.fullmatch(r"[+-]?\d+", value.strip()): return int(value.strip()) raise ValueError(f"{field} must be an integer") def _strict_float(value: Any, field: str) -> float | None: if value is None: return None if isinstance(value, bool): raise ValueError(f"{field} must be a finite number") try: result = float(value) except (TypeError, ValueError) as exc: raise ValueError(f"{field} must be a finite number") from exc if not math.isfinite(result): raise ValueError(f"{field} must be a finite number") return result def _validate_common(account_id: str, basis_date: str, amount: int, currency: str, verification_status: str, valuation_kind: str, source_type: str, representative_liability_definition: str | None) -> tuple[str, int, str, str, str, str | None] | dict[str, Any]: if not isinstance(account_id, str) or not account_id.strip(): return fail("validation_error", "account_id must be non-empty") try: basis = normalize_date(basis_date) amount_int = _strict_int(amount, "amount", allow_none=False) except Exception as exc: return fail("validation_error", str(exc)) currency_norm = (currency or "KRW").strip().upper() if currency_norm not in _VALID_CURRENCIES: return fail("validation_error", f"currency must be one of {sorted(_VALID_CURRENCIES)}") status = (verification_status or "unverified").strip().lower() if status not in _VALID_STATUSES: return fail("validation_error", "verification_status must be verified, unverified, or rejected") if not isinstance(valuation_kind, str): return fail("validation_error", "valuation_kind must be a string") kind = valuation_kind.strip().lower() or "balance" if kind not in _VALID_KINDS: return fail("validation_error", f"valuation_kind must be one of {sorted(_VALID_KINDS)}") source = (source_type or "manual").strip().lower() or "manual" if source not in _VALID_SOURCE_TYPES: return fail("validation_error", f"source_type must be one of {sorted(_VALID_SOURCE_TYPES)}") definition = representative_liability_definition.strip() if representative_liability_definition else None if definition is not None and definition not in _VALID_LOAN_DEFINITIONS: return fail("validation_error", "representative liability definition is invalid") if kind in {"loan", "loan_balance"} and definition is None: return fail("validation_error", "loan valuation requires representative_liability_definition") if kind not in {"loan", "loan_balance", "balance"} and definition is not None: return fail("validation_error", "valuation kind cannot define representative liability") if amount_int is None: return fail("validation_error", "amount must be an integer") return basis, amount_int, currency_norm, status, source, definition def record_account_valuation_candidate(conn: sqlite3.Connection, account_id: str, basis_date: str, amount: int, currency: str, source_type: str, source_ref: str, idempotency_key: str, memo: str = "") -> dict[str, Any]: """Append an unverified candidate on a caller-owned transaction.""" account = conn.execute("SELECT type,currency FROM accounts WHERE id=?", (account_id,)).fetchone() if account is None: return fail("not_found", "account not found") if (currency or "KRW").upper() != (account["currency"] or "KRW").upper(): return fail("validation_error", "valuation currency must match account currency") if str(account["type"]).lower() == "loan": return fail("validation_error", "loan valuation requires representative_liability_definition") try: amount_int = _strict_int(amount, "amount", allow_none=False) except (TypeError, ValueError) as exc: return fail("validation_error", str(exc)) basis = normalize_date(basis_date) existing = conn.execute("SELECT * FROM account_valuations WHERE account_id=? AND idempotency_key=?", (account_id, idempotency_key)).fetchone() if existing: incoming = { "basis_date": basis, "amount": amount_int, "currency": (currency or "KRW").upper(), "valuation_kind": "balance", "source_type": source_type, "source_ref": source_ref or "", "verification_status": "unverified", "memo": memo, } if all(existing[key] == value for key, value in incoming.items()): return ok(valuation=_valuation(existing), reused=True) return fail( "idempotency_conflict", "idempotency_key already has a different candidate payload", valuation=_valuation(existing), ) valuation_id = new_id("valuation") conn.execute("""INSERT INTO account_valuations (id,account_id,basis_date,amount,currency,valuation_kind,source_type,source_ref,verification_status,recorded_at,memo,idempotency_key) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""", (valuation_id, account_id, basis, amount_int, (currency or "KRW").upper(), "balance", source_type, source_ref or "", "unverified", now_iso(), memo, idempotency_key)) return ok(valuation=_valuation(conn.execute("SELECT * FROM account_valuations WHERE id=?", (valuation_id,)).fetchone()), reused=False) def register_account_valuation( account_id: str, basis_date: str, amount: int, currency: str = "KRW", valuation_kind: str = "balance", source_type: str = "manual", source_ref: str = "", verification_status: str = "unverified", memo: str = "", idempotency_key: str = "", as_of_at: str | None = None, timezone: str | None = None, cost_basis: int | None = None, net_contributed: int | None = None, reported_principal_kind: str | None = None, reported_principal_label: str | None = None, profit: int | None = None, return_rate: float | None = None, return_basis: str | None = None, principal: int | None = None, interest: int | None = None, fee: int | None = None, representative_liability_definition: str | None = None, db_path: str | None = None, supersedes_id: str | None = None, correction_reason: str = "", *, monthly_settlement_id: str | None = None, liquidity_bucket: str | None = None, selection_policy: str = "manual", valuation_source: str = "manual", source_note: str = "", ) -> dict[str, Any]: if monthly_settlement_id is not None: return _register_monthly_account_valuation( account_id, basis_date, amount, currency, valuation_kind, "account" if source_type == "manual" else source_type, source_ref, verification_status, memo, idempotency_key, monthly_settlement_id, db_path, liquidity_bucket, selection_policy=selection_policy, valuation_source=valuation_source, source_note=source_note, ) validated = _validate_common(account_id, basis_date, amount, currency, verification_status, valuation_kind, source_type, representative_liability_definition) if isinstance(validated, dict): return validated basis, amount_int, currency_norm, status, source_norm, definition = validated try: optional_ints = {name: _strict_int(value, name) for name, value in { "cost_basis": cost_basis, "net_contributed": net_contributed, "profit": profit, "principal": principal, "interest": interest, "fee": fee}.items()} return_rate = _strict_float(return_rate, "return_rate") except (TypeError, ValueError) as exc: return fail("validation_error", str(exc)) if (as_of_at is None) != (timezone is None): return fail("validation_error", "as_of_at and timezone must be supplied together") if as_of_at is not None: try: from datetime import datetime parsed = datetime.fromisoformat(as_of_at) if parsed.tzinfo is None: return fail("validation_error", "as_of_at must include timezone offset") except (TypeError, ValueError) as exc: return fail("validation_error", f"invalid as_of_at: {exc}") if supersedes_id and not correction_reason.strip(): return fail("validation_error", "correction_reason is required") ts = now_iso() source_ref_norm = source_ref.strip() if isinstance(source_ref, str) else "" values = (amount_int, currency_norm, (valuation_kind or "balance").strip().lower(), source_norm, source_ref_norm, status, memo or "", as_of_at, timezone, optional_ints["cost_basis"], optional_ints["net_contributed"], reported_principal_kind, reported_principal_label, optional_ints["profit"], return_rate, return_basis, optional_ints["principal"], optional_ints["interest"], optional_ints["fee"], definition) with db(db_path) as conn: conn.execute("BEGIN IMMEDIATE") account = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone() if account is None: return fail("not_found", "account not found") if currency_norm != (account["currency"] or "KRW").upper(): return fail("validation_error", "valuation currency must match account currency") effective_kind = (valuation_kind or "balance").strip().lower() is_loan_account = str(account["type"]).lower() == "loan" if effective_kind in {"loan", "loan_balance"} and not is_loan_account: return fail("validation_error", "loan valuation kind requires a loan account") if is_loan_account and effective_kind not in {"balance", "loan", "loan_balance"}: return fail("validation_error", "loan account valuation kind must represent a liability") if is_loan_account and effective_kind in {"balance", "loan", "loan_balance"} and definition is None: return fail("validation_error", "loan valuation requires representative_liability_definition") if definition is not None and not is_loan_account: return fail("validation_error", "representative liability definition requires a loan account") seen: set[str] = set() if supersedes_id: parent = conn.execute("SELECT * FROM account_valuations WHERE id=?", (supersedes_id,)).fetchone() if parent is None: if idempotency_key and conn.execute("SELECT 1 FROM account_valuations WHERE account_id=? AND idempotency_key=?", (account_id, idempotency_key)).fetchone(): return fail("idempotency_conflict", "idempotency_key already has a different payload") return fail("validation_error", "supersedes_id must reference an existing valuation") if any(parent[field] != value for field, value in ( ("account_id", account_id), ("basis_date", basis), ("currency", currency_norm), ("valuation_kind", (valuation_kind or "balance").strip().lower()), )): return fail("validation_error", "superseded valuation scope must match account, basis, currency, and valuation kind") seen: set[str] = set() cursor = parent while cursor is not None: cursor_id = cursor["id"] if cursor_id in seen: return fail("validation_error", "supersedes ancestry is cyclic") seen.add(cursor_id) next_id = cursor["supersedes_id"] cursor = conn.execute("SELECT * FROM account_valuations WHERE id=?", (next_id,)).fetchone() if next_id else None existing = None by_key = conn.execute("SELECT * FROM account_valuations WHERE account_id=? AND idempotency_key=?", (account_id, idempotency_key)).fetchone() if idempotency_key else None source_rows = conn.execute("SELECT * FROM account_valuations WHERE account_id=? AND source_ref=? ORDER BY recorded_at, id", (account_id, source_ref_norm)).fetchall() if source_ref_norm else [] correction_chain = seen if supersedes_id else set() unrelated_source = next((row for row in source_rows if row["id"] not in correction_chain and (by_key is None or row["id"] != by_key["id"])), None) if by_key is not None and unrelated_source is not None: return fail("idempotency_conflict", "idempotency_key and source_ref identify different candidates") if unrelated_source is not None: by_source = unrelated_source else: by_source = None existing = by_key or by_source if existing: current = _valuation(existing) incoming = dict(zip(_COLUMNS, values)) incoming.update({"account_id": account_id, "basis_date": basis, "supersedes_id": supersedes_id, "correction_reason": correction_reason or ""}) comparable = set(incoming) - {"id", "recorded_at"} if all(current.get(k) == v for k, v in incoming.items() if k in comparable): return ok(valuation=current, reused=True) return fail("idempotency_conflict", "idempotency_key already has a different payload", valuation=current) valuation_id = new_id("valuation") try: conn.execute("""INSERT INTO account_valuations (id,account_id,basis_date,amount,currency,valuation_kind,source_type,source_ref,verification_status,recorded_at,memo,idempotency_key,supersedes_id,correction_reason,as_of_at,timezone,cost_basis,net_contributed,reported_principal_kind,reported_principal_label,profit,return_rate,return_basis,principal,interest,fee,representative_liability_definition) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (valuation_id, account_id, basis, *values[:6], ts, values[6], idempotency_key or "", supersedes_id, correction_reason, *values[7:])) except sqlite3.IntegrityError: if supersedes_id: return fail("correction_conflict", "valuation already has a correction") return fail("idempotency_conflict", "idempotency_key already has a different payload") except Exception as exc: return fail("validation_error", str(exc)) row = conn.execute("SELECT * FROM account_valuations WHERE id=?", (valuation_id,)).fetchone() return ok(valuation=_valuation(row), reused=False) @read_operation def list_account_valuations(account_id: str, include_rejected: bool = True, db_path: str | None = None) -> dict[str, Any]: where = "" if include_rejected else " AND verification_status <> 'rejected'" with read_db(db_path) as conn: rows = conn.execute( f"SELECT * FROM account_valuations WHERE account_id=?{where} ORDER BY basis_date, recorded_at, id", (account_id,), ).fetchall() snapshots = conn.execute( f"""SELECT * FROM account_snapshot WHERE source_type='account' AND source_id=?{where} ORDER BY basis_date, created_at, id""", (account_id,), ).fetchall() values = [_valuation(row) for row in rows] for row in snapshots: value = _valuation(row) value.update( account_id=value["source_id"], recorded_at=value["created_at"], valuation_kind="balance", source_ref=value.get("source_note", ""), ) for key in _COLUMNS: value.setdefault(key, None) value.setdefault("idempotency_key", "") value.setdefault("supersedes_id", None) value.setdefault("correction_reason", "") values.append(value) values.sort(key=lambda value: (value["basis_date"], value["recorded_at"], value["id"])) return ok(account_id=account_id, valuations=values, count=len(values)) @read_operation def select_account_valuation(account_id: str, basis_date: str, as_of: bool = False, currency: str | None = None, valuation_kind: str | None = None, db_path: str | None = None) -> dict[str, Any]: try: basis = normalize_date(basis_date) except Exception as exc: return fail("validation_error", str(exc)) with read_db(db_path) as conn: clauses = ["account_id=?"] params: list[Any] = [account_id] if currency: clauses.append("UPPER(currency)=?"); params.append(currency.strip().upper()) if valuation_kind: clauses.append("valuation_kind=?"); params.append(valuation_kind.strip().lower()) rows = conn.execute(f"SELECT * FROM account_valuations WHERE {' AND '.join(clauses)} ORDER BY basis_date DESC, recorded_at DESC, id DESC", params).fetchall() leaves = [r for r in rows if conn.execute("SELECT 1 FROM account_valuations WHERE supersedes_id=?", (r["id"],)).fetchone() is None] verified = [r for r in leaves if r["verification_status"] == "verified" and r["basis_date"] <= basis] exact = [r for r in verified if r["basis_date"] == basis] # Candidates are evidence at or before the requested basis. Exact # selection only surfaces same-date unverified evidence; as-of # selection surfaces unverified evidence newer than the selected # verified value, while never looking into the future. latest_verified_date = max((x["basis_date"] for x in verified), default="") candidate = [ r for r in leaves if r["verification_status"] == "unverified" and r["basis_date"] <= basis and (r["basis_date"] == basis if not as_of else (not latest_verified_date or r["basis_date"] > latest_verified_date)) ] matching = exact if not as_of else [r for r in verified if r["basis_date"] == latest_verified_date] if len(matching) > 1: return fail("ambiguous_valuation", "multiple verified valuation leaves match the scope") row = matching[0] if matching else None if row is None: result = ok(account_id=account_id, basis_date=basis, valuation=None, carried_forward=False) if candidate: result["warning"] = "unverified_candidate" result["unverified_candidates"] = [_public_candidate(item) for item in candidate] return result age = (date.fromisoformat(basis) - date.fromisoformat(row["basis_date"])).days warning = "unverified_candidate" if candidate else ("carry_forward" if age else None) result = ok(account_id=account_id, basis_date=basis, valuation=_valuation(row), carried_forward=bool(age), valuation_age_days=age, warning=warning) if candidate: result["unverified_candidates"] = [_public_candidate(item) for item in candidate] return result def correct_account_valuation(valuation_id: str, amount: int, correction_reason: str, idempotency_key: str = "", db_path: str | None = None, **fields: Any) -> dict[str, Any]: if not correction_reason or not correction_reason.strip(): return fail("validation_error", "correction_reason is required") with db(db_path) as conn: source = conn.execute("SELECT * FROM account_valuations WHERE id=?", (valuation_id,)).fetchone() if source is None: return fail("not_found", "valuation not found") if "currency" in fields and fields["currency"] is not None and fields["currency"].strip().upper() != source["currency"].upper(): return fail("validation_error", "correction currency must match parent valuation") if "valuation_kind" in fields and fields["valuation_kind"] is not None and fields["valuation_kind"].strip().lower() != source["valuation_kind"]: return fail("validation_error", "correction valuation_kind must match parent valuation") preserved = {name: fields[name] if name in fields else source[name] for name in ( "currency", "valuation_kind", "source_type", "source_ref", "verification_status", "memo", "as_of_at", "timezone", "cost_basis", "net_contributed", "reported_principal_kind", "reported_principal_label", "profit", "return_rate", "return_basis", "principal", "interest", "fee", "representative_liability_definition")} try: effective = { "amount": _strict_int(amount, "amount", allow_none=False), "account_id": source["account_id"], "basis_date": source["basis_date"], "currency": (preserved["currency"] or "KRW").strip().upper(), "valuation_kind": (preserved["valuation_kind"] or "balance").strip().lower(), "source_type": (preserved["source_type"] or "manual").strip().lower() or "manual", "source_ref": preserved["source_ref"].strip() if isinstance(preserved["source_ref"], str) else "", "verification_status": (preserved["verification_status"] or "unverified").strip().lower(), "memo": preserved["memo"] or "", "as_of_at": preserved["as_of_at"], "timezone": preserved["timezone"], "cost_basis": _strict_int(preserved["cost_basis"], "cost_basis"), "net_contributed": _strict_int(preserved["net_contributed"], "net_contributed"), "reported_principal_kind": preserved["reported_principal_kind"], "reported_principal_label": preserved["reported_principal_label"], "profit": _strict_int(preserved["profit"], "profit"), "return_rate": _strict_float(preserved["return_rate"], "return_rate"), "return_basis": preserved["return_basis"], "principal": _strict_int(preserved["principal"], "principal"), "interest": _strict_int(preserved["interest"], "interest"), "fee": _strict_int(preserved["fee"], "fee"), "representative_liability_definition": preserved["representative_liability_definition"].strip() if preserved["representative_liability_definition"] else None, "supersedes_id": valuation_id, "correction_reason": correction_reason.strip(), } except (TypeError, ValueError, AttributeError) as exc: return fail("validation_error", str(exc)) if idempotency_key: prior = conn.execute("SELECT * FROM account_valuations WHERE account_id=? AND idempotency_key=?", (source["account_id"], idempotency_key)).fetchone() if prior: if all(prior[key] == value for key, value in effective.items()): return ok(valuation=_valuation(prior), reused=True) return fail("idempotency_conflict", "idempotency_key already has a different payload", valuation=_valuation(prior)) child = conn.execute("SELECT * FROM account_valuations WHERE supersedes_id=?", (valuation_id,)).fetchone() if child is not None: if all(child[key] == value for key, value in effective.items()): return ok(valuation=_valuation(child), reused=True) return fail("correction_conflict", "correction payload conflicts with existing correction", valuation=_valuation(child)) return register_account_valuation( source["account_id"], source["basis_date"], amount, **preserved, idempotency_key=idempotency_key, supersedes_id=valuation_id, correction_reason=correction_reason.strip(), db_path=db_path, ) _ALLOWED_VERIFICATION = {"pending", "unverified"} _ALLOWED_SETTLEMENT_STATUS = {"draft", "in_review"} def _register_monthly_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") if isinstance(amount, bool) or not isinstance(amount, int): raise ValueError("amount must be an integer") 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 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") 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