fix: preserve schema 15 compatibility for v0.1.8
This commit is contained in:
@@ -1,40 +1,418 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
from datetime import date
|
||||
from uuid import uuid4
|
||||
import math
|
||||
import re
|
||||
from typing import Any
|
||||
import sqlite3
|
||||
|
||||
from .shared import db, fail, now_iso, ok, read_db
|
||||
from .shared import db, read_db, read_operation, fail, new_id, normalize_date, now_iso, ok, row_to_dict
|
||||
|
||||
_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
|
||||
_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 _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 _valuation(row: Any) -> dict[str, Any]:
|
||||
return row_to_dict(row)
|
||||
|
||||
|
||||
def record_account_valuation_candidate(*args: Any, **kwargs: Any) -> dict[str, Any]:
|
||||
return fail("unsupported", "account valuation candidates are not writable")
|
||||
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) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
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")
|
||||
return value
|
||||
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,
|
||||
@@ -59,7 +437,8 @@ def register_account_valuation(
|
||||
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 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:
|
||||
@@ -122,21 +501,6 @@ def register_account_valuation(
|
||||
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:
|
||||
@@ -161,3 +525,12 @@ def update_account_snapshot_category(snapshot_id: str, monthly_settlement_id: st
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user