164 lines
9.2 KiB
Python
164 lines
9.2 KiB
Python
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")
|