fix: address account valuation review findings
This commit is contained in:
@@ -6,7 +6,7 @@ import re
|
||||
from typing import Any
|
||||
import sqlite3
|
||||
|
||||
from .shared import db, fail, new_id, normalize_date, now_iso, ok, row_to_dict
|
||||
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"}
|
||||
@@ -173,8 +173,11 @@ def register_account_valuation(
|
||||
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()
|
||||
values = (amount_int, currency_norm, (valuation_kind or "balance").strip().lower(), source_norm, source_ref or "", 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)
|
||||
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()
|
||||
@@ -186,16 +189,47 @@ def register_account_valuation(
|
||||
return fail("validation_error", "loan valuation kind requires a loan account")
|
||||
if definition is not None and str(account["type"]).lower() != "loan":
|
||||
return fail("validation_error", "representative liability definition requires a loan account")
|
||||
if idempotency_key:
|
||||
existing = conn.execute("SELECT * FROM account_valuations WHERE account_id=? AND idempotency_key=?", (account_id, idempotency_key)).fetchone()
|
||||
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)
|
||||
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
|
||||
@@ -211,20 +245,22 @@ def register_account_valuation(
|
||||
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 db(db_path) as conn:
|
||||
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()
|
||||
values = [_valuation(row) for row in rows]
|
||||
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 db(db_path) as conn:
|
||||
with read_db(db_path) as conn:
|
||||
clauses = ["account_id=?"]
|
||||
params: list[Any] = [account_id]
|
||||
if currency:
|
||||
@@ -271,6 +307,10 @@ def correct_account_valuation(valuation_id: str, amount: int, correction_reason:
|
||||
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")}
|
||||
|
||||
@@ -13,6 +13,8 @@ from .shared import (
|
||||
current_kst_date,
|
||||
date,
|
||||
db,
|
||||
read_db,
|
||||
read_operation,
|
||||
fail,
|
||||
new_id,
|
||||
normalize_date,
|
||||
@@ -34,6 +36,61 @@ def _valuation_policy() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
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 = (
|
||||
@@ -84,6 +141,7 @@ def _account_valuation_items(conn: sqlite3.Connection, basis_date: str = "", sel
|
||||
"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"})
|
||||
@@ -145,7 +203,7 @@ def _account_valuation_items(conn: sqlite3.Connection, basis_date: str = "", sel
|
||||
})
|
||||
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})
|
||||
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]
|
||||
|
||||
@@ -177,11 +235,14 @@ def _manual_asset_valuation_items(conn: sqlite3.Connection, active_only: bool =
|
||||
"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:
|
||||
@@ -190,6 +251,7 @@ def _manual_asset_valuation_items(conn: sqlite3.Connection, active_only: bool =
|
||||
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
|
||||
@@ -208,7 +270,11 @@ def _snapshot_rows_for_latest_run(conn: sqlite3.Connection, basis_date: str) ->
|
||||
|
||||
|
||||
def _snapshot_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str, Any]]:
|
||||
rows, _metadata = _snapshot_rows_for_latest_run(conn, basis_date)
|
||||
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)
|
||||
@@ -216,8 +282,14 @@ def _snapshot_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str,
|
||||
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))
|
||||
items.append(
|
||||
{
|
||||
_public_evidence({
|
||||
"id": snap.get("id", ""),
|
||||
"source_id": snap.get("source_id", ""),
|
||||
"source_type": snap.get("source_type", ""),
|
||||
@@ -225,25 +297,98 @@ def _snapshot_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str,
|
||||
"valuation_source": snap.get("valuation_source", ""),
|
||||
"name": snap.get("name", ""),
|
||||
"liquidity_bucket": bucket,
|
||||
"amount": int(snap.get("amount") or 0),
|
||||
"valuation_date": snap.get("basis_date", ""),
|
||||
"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", ""),
|
||||
}
|
||||
})
|
||||
)
|
||||
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 = candidate_warnings.setdefault(key, {"code": "unverified_candidate", "source_type": key[0], "source_id": key[1], "candidates": []})
|
||||
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})
|
||||
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"))
|
||||
amount = int(item.get("amount") or 0)
|
||||
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)
|
||||
@@ -298,9 +443,10 @@ def create_manual_asset(name: str, valuation_amount: int | None, valuation_date:
|
||||
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 db(db_path) as conn:
|
||||
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())
|
||||
@@ -404,19 +550,23 @@ def _snapshot_preview_from_conn(conn: sqlite3.Connection, basis: str, selection_
|
||||
"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,
|
||||
**summary,
|
||||
"_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")
|
||||
@@ -424,8 +574,9 @@ def preview_asset_snapshot_close(basis_date: str = "", db_path: str | None = Non
|
||||
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 db(db_path) as conn:
|
||||
with read_db(db_path) as conn:
|
||||
preview = _snapshot_preview_from_conn(conn, basis, selection_policy)
|
||||
preview.pop("_included_items", None)
|
||||
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")]
|
||||
@@ -433,14 +584,15 @@ def preview_asset_snapshot_close(basis_date: str = "", db_path: str | None = Non
|
||||
if other_blocking:
|
||||
preview["close_status"] = "incomplete_draft"
|
||||
elif approval_eligible or carried_items:
|
||||
preview["close_status"] = "conditional"
|
||||
preview["close_status"] = "incomplete_draft"
|
||||
else:
|
||||
preview["close_status"] = "complete_exact"
|
||||
preview["preview_fingerprint"] = _preview_fingerprint(preview, basis, selection_policy)
|
||||
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 = "") -> dict[str, Any]:
|
||||
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:
|
||||
@@ -452,6 +604,8 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
|
||||
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)
|
||||
legacy_snapshot_rows = bool(latest_rows and not latest_meta.get("snapshot_run_id"))
|
||||
if legacy_snapshot_rows and not correction_reason.strip():
|
||||
@@ -464,7 +618,8 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
|
||||
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)
|
||||
fingerprint_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, "included": [{**{k: i.get(k) for k in ("source_type", "source_id", "amount", "currency", "valuation_id", "valuation_date", "verification_status")}, "state_hash": _evidence_state_hash(i)} for i in preview["included_items"]], "excluded": [{**{k: i.get(k) for k in ("source_type", "source_id", "reason", "valuation_date", "verification_status")}, "state_hash": _evidence_state_hash(i)} for i in preview["excluded_items"]], "warnings": [warning for warning in preview["warnings"] if warning.get("code") != "existing_snapshot_rows"], "candidate_state_hashes": preview["_candidate_state_hashes"]}
|
||||
fingerprint_items = preview.get("_included_items", preview["included_items"])
|
||||
fingerprint_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, "included": [{**{k: i.get(k) for k in ("source_type", "source_id", "amount", "currency", "valuation_id", "valuation_date", "verification_status")}, "state_hash": _evidence_state_hash(i)} for i in fingerprint_items], "excluded": [{**{k: i.get(k) for k in ("source_type", "source_id", "reason", "valuation_date", "verification_status")}, "state_hash": _evidence_state_hash(i)} for i in preview["excluded_items"]], "warnings": [warning for warning in preview["warnings"] if warning.get("code") != "existing_snapshot_rows"], "candidate_state_hashes": preview["_candidate_state_hashes"]}
|
||||
fingerprint = hashlib.sha256(json.dumps(fingerprint_payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()).hexdigest()
|
||||
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")
|
||||
@@ -474,9 +629,46 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
|
||||
existing_run = conn.execute("SELECT * FROM asset_snapshot_runs WHERE idempotency_key=?", (idempotency_key,)).fetchone()
|
||||
if existing_run:
|
||||
if existing_run["request_fingerprint"] == fingerprint:
|
||||
return ok(reused=True, snapshot_run_id=existing_run["id"], close_status=existing_run["close_status"], basis_date=basis)
|
||||
replay_rows, replay_meta = _snapshot_rows_for_latest_run(conn, basis)
|
||||
replay_items = [_public_evidence(item) for item in replay_meta.get("applied_evidence", [])] or _snapshot_items(conn, basis)
|
||||
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=[row["id"] for row in replay_rows],
|
||||
warnings=replay_warnings,
|
||||
carried_items=replay_public["carried_items"],
|
||||
approval=approval, correction=correction,
|
||||
provenance=_canonical_provenance(
|
||||
valuation_date=replay_provenance["valuation_date"],
|
||||
age_days=replay_provenance["age_days"],
|
||||
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["included_items"]
|
||||
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)
|
||||
@@ -497,7 +689,7 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
|
||||
item.get("source_type", ""),
|
||||
item.get("source_id", item.get("id", "")),
|
||||
item.get("name", ""),
|
||||
int(item.get("amount") or 0),
|
||||
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", "")),
|
||||
@@ -517,8 +709,7 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
|
||||
),
|
||||
)
|
||||
snapshot_ids.append(snapshot_id)
|
||||
summary = _summarize_valuation_items(included, [])
|
||||
applied_evidence = [{k: item.get(k) for k in ("source_type", "source_id", "amount", "currency", "valuation_id", "valuation_date", "verification_status")} for item in included]
|
||||
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", "currency", "valuation_id", "valuation_date", "age_days", "carried_forward", "verification_status", "evidence_kind") 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(
|
||||
{
|
||||
@@ -536,13 +727,27 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
|
||||
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", "verification_status")} for item in included if item.get("carried_forward")]
|
||||
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)
|
||||
return ok(
|
||||
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,
|
||||
@@ -556,6 +761,23 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -583,58 +805,99 @@ def create_asset_snapshot(basis_date: str, source_type: str, source_id: str, nam
|
||||
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")
|
||||
basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat()
|
||||
with db(db_path) as conn:
|
||||
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)
|
||||
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:
|
||||
summary = _summarize_valuation_items(snapshots, [])
|
||||
return ok(
|
||||
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=close_metadata,
|
||||
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,
|
||||
)
|
||||
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
|
||||
if not included and not excluded and legacy_snapshots:
|
||||
summary = _summarize_valuation_items(legacy_snapshots, [])
|
||||
return ok(
|
||||
basis_date=basis,
|
||||
source="legacy_asset_snapshots",
|
||||
selection_policy=selection_policy,
|
||||
close_status="legacy_read_only",
|
||||
is_closed_snapshot=False,
|
||||
snapshot_count=len(legacy_snapshots),
|
||||
legacy_snapshot_row_count=len(legacy_snapshots),
|
||||
close_metadata={"legacy": True, "snapshot_run_id": ""},
|
||||
**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,
|
||||
)
|
||||
summary = _summarize_valuation_items(included, excluded)
|
||||
return ok(
|
||||
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="incomplete_draft" if excluded else "complete_exact",
|
||||
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=invalid_bucket_accounts,
|
||||
**summary,
|
||||
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))
|
||||
@@ -649,7 +912,7 @@ def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: i
|
||||
start_norm = date(year, month, 1).isoformat()
|
||||
except Exception as e:
|
||||
return fail("validation_error", str(e))
|
||||
with db(db_path) as conn:
|
||||
with read_db(db_path) as conn:
|
||||
basis_rows = conn.execute(
|
||||
"""SELECT DISTINCT basis_date FROM asset_snapshots
|
||||
WHERE basis_date>=? AND basis_date<=?
|
||||
@@ -660,7 +923,11 @@ def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: i
|
||||
for row in basis_rows:
|
||||
basis = row["basis_date"]
|
||||
items = _snapshot_items(conn, basis)
|
||||
summary = _summarize_valuation_items(items, [])
|
||||
_, metadata = _snapshot_rows_for_latest_run(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,
|
||||
@@ -669,18 +936,42 @@ def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: i
|
||||
"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 db(db_path) as conn:
|
||||
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
|
||||
|
||||
@@ -4,6 +4,8 @@ from .shared import (
|
||||
Any,
|
||||
datetime,
|
||||
db,
|
||||
read_db,
|
||||
read_operation,
|
||||
fail,
|
||||
now_iso,
|
||||
ok,
|
||||
@@ -100,9 +102,10 @@ def seed_default_category_rules(overwrite: bool = False, db_path: str | None = N
|
||||
return ok(created_or_updated=created, skipped=skipped)
|
||||
|
||||
|
||||
@read_operation
|
||||
def list_category_rules(active_only: bool = True, db_path: str | None = None) -> dict[str, Any]:
|
||||
sql="SELECT * FROM category_rules" + (" WHERE active=1" if active_only else "") + " ORDER BY priority, id"
|
||||
with db(db_path) as conn:
|
||||
with read_db(db_path) as conn:
|
||||
rows=conn.execute(sql).fetchall()
|
||||
return ok(rules=[row_to_dict(r) for r in rows], count=len(rows))
|
||||
|
||||
@@ -115,7 +118,7 @@ def _load_active_rules(conn: Any) -> list[dict[str, Any]]:
|
||||
|
||||
|
||||
def _categorization_preview_data(limit: int = 1000, only_uncategorized: bool = True, db_path: str | None = None) -> dict[str, Any]:
|
||||
with db(db_path) as conn:
|
||||
with read_db(db_path) as conn:
|
||||
rules = _load_active_rules(conn)
|
||||
where = "WHERE status='active'" + (" AND category=''" if only_uncategorized else "")
|
||||
rows = [row_to_dict(r) for r in conn.execute(
|
||||
@@ -163,8 +166,11 @@ def _categorization_preview_data(limit: int = 1000, only_uncategorized: bool = T
|
||||
}
|
||||
|
||||
|
||||
@read_operation
|
||||
def preview_categorization(limit: int = 1000, only_uncategorized: bool = True, db_path: str | None = None) -> dict[str, Any]:
|
||||
data = _categorization_preview_data(limit, only_uncategorized, db_path)
|
||||
if data.get("success") is False:
|
||||
return data
|
||||
return ok(
|
||||
scanned=len(data["rows"]),
|
||||
matched_count=len(data["matches"]),
|
||||
@@ -180,7 +186,7 @@ def _category_rule_match_rows(rule_id: str, limit: int = 10000, db_path: str | N
|
||||
if not rule_id:
|
||||
return fail("validation_error", "rule_id is required")
|
||||
row_limit = max(1, min(int(limit), 50000))
|
||||
with db(db_path) as conn:
|
||||
with read_db(db_path) as conn:
|
||||
rule_row = conn.execute("SELECT * FROM category_rules WHERE id=?", (rule_id,)).fetchone()
|
||||
if not rule_row:
|
||||
return fail("not_found", f"category rule not found: {rule_id}")
|
||||
@@ -220,6 +226,7 @@ def _category_rule_match_rows(rule_id: str, limit: int = 10000, db_path: str | N
|
||||
return ok(rule=rule, scanned=len(rows), matched=matched, uncategorized=uncategorized)
|
||||
|
||||
|
||||
@read_operation
|
||||
def preview_category_rule_matches(rule_id: str, limit: int = 10000, sample_limit: int = 3, db_path: str | None = None) -> dict[str, Any]:
|
||||
data = _category_rule_match_rows(rule_id, limit, db_path)
|
||||
if not data.get("success"):
|
||||
@@ -235,6 +242,8 @@ def preview_category_rule_matches(rule_id: str, limit: int = 10000, sample_limit
|
||||
|
||||
|
||||
def apply_category_rule(rule_id: str, limit: int = 10000, db_path: str | None = None) -> dict[str, Any]:
|
||||
with db(db_path):
|
||||
pass
|
||||
data = _category_rule_match_rows(rule_id, limit, db_path)
|
||||
if not data.get("success"):
|
||||
return data
|
||||
@@ -266,7 +275,11 @@ def apply_category_rule(rule_id: str, limit: int = 10000, db_path: str | None =
|
||||
)
|
||||
|
||||
def apply_categorization_rules(limit: int = 1000, only_uncategorized: bool = True, db_path: str | None = None) -> dict[str, Any]:
|
||||
with db(db_path):
|
||||
pass
|
||||
data = _categorization_preview_data(limit, only_uncategorized, db_path)
|
||||
if not data.get("success", True):
|
||||
return data
|
||||
preview = ok(
|
||||
scanned=len(data["rows"]),
|
||||
matched_count=len(data["matches"]),
|
||||
@@ -380,12 +393,13 @@ def seed_default_ledger_categories(overwrite: bool = False, db_path: str | None
|
||||
return ok(created_or_updated=created)
|
||||
|
||||
|
||||
@read_operation
|
||||
def list_ledger_categories(active_only: bool = True, include_usage: bool = True, db_path: str | None = None) -> dict[str, Any]:
|
||||
clauses = []
|
||||
if active_only:
|
||||
clauses.append("active=1")
|
||||
where = " WHERE " + " AND ".join(clauses) if clauses else ""
|
||||
with db(db_path) as conn:
|
||||
with read_db(db_path) as conn:
|
||||
rows = conn.execute(f"SELECT * FROM ledger_categories{where} ORDER BY sort_order, category, subcategory").fetchall()
|
||||
items = [row_to_dict(r) for r in rows]
|
||||
if include_usage:
|
||||
|
||||
@@ -17,6 +17,8 @@ from .shared import (
|
||||
now_iso,
|
||||
ok,
|
||||
re,
|
||||
read_db,
|
||||
read_operation,
|
||||
row_to_dict,
|
||||
)
|
||||
from .review_decision_service import attach_review_decisions
|
||||
@@ -457,7 +459,8 @@ def _run_cross_account_transfer_match(limit: int = 500, dry_run: bool = True, db
|
||||
review_items: list[dict[str, Any]] = []
|
||||
matched_tx_ids: set[str] = set()
|
||||
|
||||
with db(db_path) as conn:
|
||||
connection_context = db if not dry_run else read_db
|
||||
with connection_context(db_path) as conn:
|
||||
kb_rows = _fetch_transfer_candidates(conn, "kb_bank_pdf")
|
||||
toss_rows = _fetch_transfer_candidates(conn, "toss_bank_pdf")
|
||||
toss_index = _build_cross_transfer_index(toss_rows)
|
||||
@@ -591,6 +594,7 @@ def _run_cross_account_transfer_match(limit: int = 500, dry_run: bool = True, db
|
||||
)
|
||||
|
||||
|
||||
@read_operation
|
||||
def preview_cross_account_transfers(limit: int = 500, db_path: str | None = None) -> dict[str, Any]:
|
||||
return _run_cross_account_transfer_match(limit=limit, dry_run=True, db_path=db_path)
|
||||
|
||||
@@ -599,11 +603,12 @@ def run_cross_account_transfer_match(limit: int = 500, db_path: str | None = Non
|
||||
return _run_cross_account_transfer_match(limit=limit, dry_run=False, db_path=db_path)
|
||||
|
||||
|
||||
@read_operation
|
||||
def list_cross_transfer_review_queue(limit: int = 300, db_path: str | None = None) -> dict[str, Any]:
|
||||
clauses = ["t.status='active'", "t.source IN ('kb_bank_pdf','toss_bank_pdf')"]
|
||||
clauses.append("t.memo LIKE '%cross_transfer_review:%'")
|
||||
where = " WHERE " + " AND ".join(clauses)
|
||||
with db(db_path) as conn:
|
||||
with read_db(db_path) as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM transactions t{where} ORDER BY t.transaction_date DESC, t.created_at DESC LIMIT ?",
|
||||
(max(1, min(int(limit), 1000)),),
|
||||
|
||||
@@ -13,6 +13,8 @@ from .shared import (
|
||||
Path,
|
||||
csv,
|
||||
db,
|
||||
read_db,
|
||||
read_operation,
|
||||
fail,
|
||||
json,
|
||||
make_dedupe_key,
|
||||
@@ -38,6 +40,24 @@ def _record_import_valuation_candidate(conn: Any, account_id: str, balance: Any,
|
||||
from .account_valuation_service import record_account_valuation_candidate
|
||||
record_account_valuation_candidate(conn, account_id, basis_date, balance, "KRW", "import", source_hash, f"import-balance:{account_id}:{basis_date}:{source_hash}")
|
||||
|
||||
def _update_current_balance_if_newer(conn: Any, account_id: str, balance: Any, basis_date: str, synced_at: str) -> None:
|
||||
if balance is None or not basis_date:
|
||||
return
|
||||
current = conn.execute("SELECT last_synced_at FROM accounts WHERE id=?", (account_id,)).fetchone()
|
||||
latest_evidence = conn.execute(
|
||||
"""SELECT basis_date FROM account_valuations
|
||||
WHERE account_id=? AND verification_status IN ('verified', 'unverified')
|
||||
ORDER BY basis_date DESC, recorded_at DESC, id DESC LIMIT 1""",
|
||||
(account_id,),
|
||||
).fetchone()
|
||||
current_date = max(
|
||||
str(current["last_synced_at"] or "")[:10] if current else "",
|
||||
str(latest_evidence["basis_date"] or "")[:10] if latest_evidence else "",
|
||||
)
|
||||
if not current_date or basis_date[:10] >= current_date:
|
||||
conn.execute("UPDATE accounts SET last_balance=?, last_synced_at=?, updated_at=? WHERE id=?", (balance, synced_at, synced_at, account_id))
|
||||
|
||||
@read_operation
|
||||
def preview_ledger_csv_import(file_path: str, source: str = "generic", db_path: str | None = None) -> dict[str, Any]:
|
||||
path=Path(file_path).expanduser()
|
||||
if not path.exists():
|
||||
@@ -49,6 +69,7 @@ def preview_ledger_csv_import(file_path: str, source: str = "generic", db_path:
|
||||
return ok(source=source, filename=redacted_source_file_label(str(path), source_hash), source_hash=source_hash, row_count=len(rows), preview=preview, required_columns=["transaction_date/date", "amount", "type optional", "category optional", "merchant/description optional"])
|
||||
|
||||
|
||||
@read_operation
|
||||
def preview_kb_bank_pdf_import(file_path: str, password: str, preview_limit: int = 20, db_path: str | None = None) -> dict[str, Any]:
|
||||
try:
|
||||
parsed = parse_kb_bank_pdf(file_path, password)
|
||||
@@ -62,7 +83,7 @@ def preview_kb_bank_pdf_import(file_path: str, password: str, preview_limit: int
|
||||
validation = parsed["validation"]
|
||||
source_hash = parsed["source_hash"]
|
||||
duplicate_count = 0
|
||||
with db(db_path) as conn:
|
||||
with read_db(db_path) as conn:
|
||||
for tx in transactions:
|
||||
dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, tx.counterparty, "", tx.external_id(source_hash))
|
||||
found = conn.execute("SELECT 1 FROM transactions WHERE dedupe_key=?", (dedupe_key,)).fetchone()
|
||||
@@ -218,7 +239,7 @@ def import_kb_bank_pdf(file_path: str, password: str, account_id: str = "", skip
|
||||
raise
|
||||
conn.execute("UPDATE import_batches SET row_count=? WHERE id=?", (inserted, batch_id))
|
||||
if transactions:
|
||||
conn.execute("UPDATE accounts SET last_balance=?, last_synced_at=?, updated_at=? WHERE id=?", (transactions[-1].balance_after, ts, ts, linked_account_id))
|
||||
_update_current_balance_if_newer(conn, linked_account_id, transactions[-1].balance_after, transactions[-1].transaction_date, ts)
|
||||
if transactions and transactions[-1].balance_after is not None:
|
||||
_record_import_valuation_candidate(conn, linked_account_id, transactions[-1].balance_after, transactions[-1].transaction_date, source_hash)
|
||||
except ValueError as e:
|
||||
@@ -362,6 +383,7 @@ def _classify_toss_bank_transaction(tx: Any, owner_name: str = "") -> tuple[str,
|
||||
return "수입", "캐시백", counterparty, False
|
||||
return "이체", "확인필요", counterparty, False
|
||||
|
||||
@read_operation
|
||||
def preview_seoulpay_xlsx_import(file_path: str, password: str = "", preview_limit: int = 20, db_path: str | None = None) -> dict[str, Any]:
|
||||
try:
|
||||
parsed = parse_seoulpay_xlsx(file_path, password=password)
|
||||
@@ -376,7 +398,7 @@ def preview_seoulpay_xlsx_import(file_path: str, password: str = "", preview_lim
|
||||
source_hash = parsed["source_hash"]
|
||||
duplicate_count = 0
|
||||
by_category: dict[str, dict[str, int]] = {}
|
||||
with db(db_path) as conn:
|
||||
with read_db(db_path) as conn:
|
||||
linked_account_id = ""
|
||||
metadata = parsed["metadata"]
|
||||
holder_name = metadata.get("holder_name") or ""
|
||||
@@ -529,6 +551,7 @@ def import_seoulpay_xlsx(file_path: str, password: str = "", account_id: str = "
|
||||
)
|
||||
|
||||
|
||||
@read_operation
|
||||
def preview_toss_bank_pdf_import(file_path: str, preview_limit: int = 20, db_path: str | None = None) -> dict[str, Any]:
|
||||
try:
|
||||
parsed = parse_toss_bank_pdf(file_path)
|
||||
@@ -542,7 +565,7 @@ def preview_toss_bank_pdf_import(file_path: str, preview_limit: int = 20, db_pat
|
||||
transactions = parsed["transactions"]
|
||||
source_hash = parsed["source_hash"]
|
||||
duplicate_count = 0
|
||||
with db(db_path) as conn:
|
||||
with read_db(db_path) as conn:
|
||||
metadata = parsed["metadata"]
|
||||
account_number_masked = metadata.get("account_number_masked") or ""
|
||||
linked_account_id = ""
|
||||
@@ -683,7 +706,7 @@ def import_toss_bank_pdf(file_path: str, account_id: str = "", skip_duplicates:
|
||||
raise
|
||||
conn.execute("UPDATE import_batches SET row_count=? WHERE id=?", (inserted, batch_id))
|
||||
if transactions:
|
||||
conn.execute("UPDATE accounts SET last_balance=?, last_synced_at=?, updated_at=? WHERE id=?", (transactions[-1].balance_after, ts, ts, linked_account_id))
|
||||
_update_current_balance_if_newer(conn, linked_account_id, transactions[-1].balance_after, transactions[-1].transaction_date, ts)
|
||||
if transactions and transactions[-1].balance_after is not None:
|
||||
_record_import_valuation_candidate(conn, linked_account_id, transactions[-1].balance_after, transactions[-1].transaction_date, source_hash)
|
||||
except ValueError as e:
|
||||
@@ -763,6 +786,7 @@ def _classify_toss_transaction(tx: Any, owner_name: str = "") -> tuple[str, str,
|
||||
return "이체", "투자이동", counterparty, False
|
||||
|
||||
|
||||
@read_operation
|
||||
def preview_toss_securities_pdf_import(file_path: str, preview_limit: int = 20, db_path: str | None = None) -> dict[str, Any]:
|
||||
try:
|
||||
parsed = parse_toss_securities_pdf(file_path)
|
||||
@@ -775,7 +799,7 @@ def preview_toss_securities_pdf_import(file_path: str, preview_limit: int = 20,
|
||||
transactions = parsed["transactions"]
|
||||
source_hash = parsed["source_hash"]
|
||||
duplicate_count = 0
|
||||
with db(db_path) as conn:
|
||||
with read_db(db_path) as conn:
|
||||
metadata = parsed["metadata"]
|
||||
account_number_masked = metadata.get("account_number_masked") or ""
|
||||
linked_account_id = ""
|
||||
@@ -907,7 +931,7 @@ def import_toss_securities_pdf(file_path: str, account_id: str = "", skip_duplic
|
||||
raise
|
||||
conn.execute("UPDATE import_batches SET row_count=? WHERE id=?", (inserted, batch_id))
|
||||
if transactions:
|
||||
conn.execute("UPDATE accounts SET last_balance=?, last_synced_at=?, updated_at=? WHERE id=?", (transactions[-1].balance_after, ts, ts, linked_account_id))
|
||||
_update_current_balance_if_newer(conn, linked_account_id, transactions[-1].balance_after, transactions[-1].transaction_date, ts)
|
||||
if transactions and transactions[-1].balance_after is not None:
|
||||
_record_import_valuation_candidate(conn, linked_account_id, transactions[-1].balance_after, transactions[-1].transaction_date, source_hash)
|
||||
except ValueError as e:
|
||||
@@ -927,6 +951,7 @@ def import_toss_securities_pdf(file_path: str, account_id: str = "", skip_duplic
|
||||
)
|
||||
|
||||
|
||||
@read_operation
|
||||
def list_ledger_import_batches(
|
||||
source: str = "",
|
||||
include_rolled_back: bool = True,
|
||||
@@ -972,6 +997,7 @@ def _batch_provenance(row: dict[str, Any]) -> dict[str, Any]:
|
||||
return provenance
|
||||
|
||||
|
||||
@read_operation
|
||||
def list_ledger_import_batches_with_provenance(
|
||||
source: str = "",
|
||||
include_rolled_back: bool = True,
|
||||
@@ -992,7 +1018,7 @@ def list_ledger_import_batches_with_provenance(
|
||||
clauses.append("status='active'")
|
||||
where = " WHERE " + " AND ".join(clauses) if clauses else ""
|
||||
params.append(max(1, min(int(limit), 500)))
|
||||
with db(db_path) as conn:
|
||||
with read_db(db_path) as conn:
|
||||
rows=conn.execute(f"SELECT * FROM import_batches{where} ORDER BY imported_at DESC LIMIT ?", params).fetchall()
|
||||
try:
|
||||
items = []
|
||||
|
||||
@@ -65,9 +65,16 @@ def _source_coverage(
|
||||
"is_closed_snapshot": bool(asset_report.get("is_closed_snapshot", False)),
|
||||
"net_worth": asset_report.get("net_worth"),
|
||||
"close_status": asset_report.get("close_status", ""),
|
||||
"approval": asset_report.get("approval", {}),
|
||||
"correction": asset_report.get("correction", {}),
|
||||
"warnings": asset_report.get("warnings", []),
|
||||
"provenance": asset_report.get(
|
||||
"provenance", {"valuation_date": "", "warnings": [], "exclusions": []}
|
||||
),
|
||||
"missing_items": [item for item in asset_report.get("excluded_items", []) if "missing" in str(item.get("reason", ""))],
|
||||
"carried_items": [item for item in asset_report.get("included_items", []) if item.get("carried_forward")],
|
||||
"excluded_items": asset_report.get("excluded_items", []),
|
||||
"carried_items": asset_report.get("carried_items", asset_report.get("included_items", [])),
|
||||
"monthly_source_snapshot_id": asset_report.get("snapshot_run_id", ""),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from .shared import (
|
||||
Any,
|
||||
BALANCE_RECONCILIATION_REFERENCE_RATIO,
|
||||
BALANCE_RECONCILIATION_TOLERANCE_KRW,
|
||||
RECONCILIATION_TARGET_STATUSES,
|
||||
db,
|
||||
read_db,
|
||||
read_operation,
|
||||
fail,
|
||||
normalize_date,
|
||||
normalize_snapshot_at,
|
||||
@@ -25,7 +28,7 @@ def _transaction_signed_amount(tx_type: str, amount: int) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _account_balance_row(conn: sqlite3.Connection, account_id: str, baseline_at: str) -> dict[str, Any]:
|
||||
def _account_balance_row(conn: sqlite3.Connection, account_id: str, baseline_at: str, opening_evidence: bool = False) -> dict[str, Any]:
|
||||
date_cutoff = normalize_date(baseline_at[:10])
|
||||
rows = conn.execute(
|
||||
"""SELECT type, amount, balance_after, transaction_date, transaction_datetime, created_at
|
||||
@@ -44,15 +47,24 @@ def _account_balance_row(conn: sqlite3.Connection, account_id: str, baseline_at:
|
||||
calculated_balance = int(signed_total)
|
||||
basis = "net_transaction_delta_without_opening_balance"
|
||||
basis_at = baseline_at
|
||||
has_transaction_coverage = bool(
|
||||
rows
|
||||
and rows[-1]["transaction_date"] == date_cutoff
|
||||
and all(row["balance_after"] is not None for row in rows)
|
||||
)
|
||||
return {
|
||||
"calculated_balance": calculated_balance,
|
||||
"calculation_basis": basis,
|
||||
"calculation_basis_at": basis_at,
|
||||
"transaction_count": len(rows),
|
||||
"signed_transaction_total": int(signed_total),
|
||||
"calculation_complete": has_transaction_coverage and (
|
||||
opening_evidence or (len(rows) == 1 and rows[0]["transaction_date"] == date_cutoff)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@read_operation
|
||||
def get_balance_reconciliation_report(account_id: str = "", baseline_balance: int | None = None, baseline_at: str = "", source_note: str = "", memo: str = "", tolerance: int = BALANCE_RECONCILIATION_TOLERANCE_KRW, db_path: str | None = None) -> dict[str, Any]:
|
||||
"""Preview manual/partial-import account balance reconciliation without mutating transactions."""
|
||||
try:
|
||||
@@ -63,7 +75,7 @@ def get_balance_reconciliation_report(account_id: str = "", baseline_balance: in
|
||||
baseline_balance_int = int(baseline_balance) if baseline_balance is not None else None
|
||||
except Exception as e:
|
||||
return fail("validation_error", str(e))
|
||||
with db(db_path) as conn:
|
||||
with read_db(db_path) as conn:
|
||||
clauses = ["active=1"]
|
||||
params: list[Any] = []
|
||||
if account_id:
|
||||
@@ -85,27 +97,56 @@ def get_balance_reconciliation_report(account_id: str = "", baseline_balance: in
|
||||
for account in accounts:
|
||||
effective_baseline = baseline_balance_int if baseline_balance_int is not None else account["last_balance"]
|
||||
effective_at = baseline_at_norm or account["last_synced_at"] or now_iso()
|
||||
calc = _account_balance_row(conn, account["id"], effective_at)
|
||||
valuation = None
|
||||
ambiguous_valuation = False
|
||||
valuation_rows = conn.execute(
|
||||
"""SELECT * FROM account_valuations v
|
||||
WHERE v.account_id=? AND v.verification_status='verified'
|
||||
AND UPPER(v.currency)=UPPER(?)
|
||||
AND v.basis_date<=?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM account_valuations child
|
||||
WHERE child.supersedes_id=v.id
|
||||
)
|
||||
ORDER BY v.basis_date DESC, v.recorded_at DESC, v.id DESC""",
|
||||
(account["id"], effective_at[:10]),
|
||||
(account["id"], account["currency"] or "KRW", effective_at[:10]),
|
||||
).fetchall()
|
||||
purposes = (
|
||||
("total_value", "balance", "cash_balance")
|
||||
if account["type"] == "investment"
|
||||
else ("cash_balance", "balance")
|
||||
("loan_balance", "loan", "balance")
|
||||
if account["type"] == "loan"
|
||||
else (("total_value", "balance", "cash_balance") if account["type"] == "investment" else ("cash_balance", "balance"))
|
||||
)
|
||||
transaction_rows = conn.execute(
|
||||
"""SELECT transaction_date FROM transactions
|
||||
WHERE status='active' AND account_id=? AND transaction_date<=?
|
||||
ORDER BY transaction_date ASC, transaction_datetime ASC, created_at ASC""",
|
||||
(account["id"], effective_at[:10]),
|
||||
).fetchall()
|
||||
first_transaction_date = transaction_rows[0]["transaction_date"] if transaction_rows else None
|
||||
has_opening_evidence = bool(
|
||||
first_transaction_date
|
||||
and any(
|
||||
row["basis_date"] < first_transaction_date
|
||||
and row["valuation_kind"] in purposes
|
||||
and (
|
||||
account["type"] != "loan"
|
||||
or row["representative_liability_definition"]
|
||||
in {"principal_only", "principal_interest_fees"}
|
||||
)
|
||||
for row in valuation_rows
|
||||
)
|
||||
)
|
||||
calc = _account_balance_row(conn, account["id"], effective_at, has_opening_evidence)
|
||||
for purpose in purposes:
|
||||
scoped = [row for row in valuation_rows if row["valuation_kind"] == purpose]
|
||||
scoped = [
|
||||
row for row in valuation_rows
|
||||
if row["valuation_kind"] == purpose
|
||||
and (
|
||||
account["type"] != "loan"
|
||||
or row["representative_liability_definition"]
|
||||
in {"principal_only", "principal_interest_fees"}
|
||||
)
|
||||
]
|
||||
if not scoped:
|
||||
continue
|
||||
latest_basis = max(row["basis_date"] for row in scoped)
|
||||
@@ -126,6 +167,12 @@ def get_balance_reconciliation_report(account_id: str = "", baseline_balance: in
|
||||
if account["type"] == "investment" and ambiguous_valuation:
|
||||
status = "AMBIGUOUS_VALUATION"
|
||||
difference = None
|
||||
elif account["type"] == "investment" and valuation is None:
|
||||
status = "VALUATION_UNAVAILABLE"
|
||||
difference = None
|
||||
elif not calc["calculation_complete"]:
|
||||
status = "INCOMPLETE_CALCULATION"
|
||||
difference = None
|
||||
elif account["type"] == "investment" and valuation is not None:
|
||||
status = "VALUATION_MANAGED"
|
||||
difference = None
|
||||
@@ -145,6 +192,18 @@ def get_balance_reconciliation_report(account_id: str = "", baseline_balance: in
|
||||
"external_confirmed_value": int(valuation["amount"]) if valuation is not None else None,
|
||||
"valuation_id": valuation["id"] if valuation is not None else "",
|
||||
"valuation_kind": valuation["valuation_kind"] if valuation is not None else "",
|
||||
"provenance": {
|
||||
"valuation_date": valuation["basis_date"] if valuation is not None else "",
|
||||
"age_days": (date.fromisoformat(effective_at[:10]) - date.fromisoformat(valuation["basis_date"])).days if valuation is not None else None,
|
||||
"carried": bool(valuation is not None and valuation["basis_date"] != effective_at[:10]),
|
||||
"verification_status": valuation["verification_status"] if valuation is not None else "unavailable",
|
||||
"evidence_kind": "account_valuation" if valuation is not None else "none",
|
||||
"warnings": [status] if status not in {"OK", "REVIEW_NEEDED", "VALUATION_MANAGED"} else [],
|
||||
"exclusions": [],
|
||||
"snapshot_run_id": "",
|
||||
"close_status": "",
|
||||
"selection_policy": "as_of",
|
||||
},
|
||||
"recommended_decisions": ["계좌이동", "실지출", "실수입", "투자이동", "분석제외", "확인필요"] if status == "REVIEW_NEEDED" else [],
|
||||
"reconciliation_session": latest_sessions.get(account["id"]),
|
||||
"review_status": latest_decisions.get(("reconciliation_account", account["id"]), {}).get("status", "pending"),
|
||||
|
||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
||||
from .shared import (
|
||||
Any,
|
||||
db,
|
||||
read_db,
|
||||
read_operation,
|
||||
fail,
|
||||
json,
|
||||
new_id,
|
||||
@@ -113,6 +115,7 @@ def attach_review_decisions(db_conn: Any, items: list[dict[str, Any]], target_ty
|
||||
return enriched
|
||||
|
||||
|
||||
@read_operation
|
||||
def list_review_decisions(target_type: str = "", target_id: str = "", status: str = "", limit: int = 100, db_path: str | None = None) -> dict[str, Any]:
|
||||
clauses: list[str] = []
|
||||
params: list[Any] = []
|
||||
@@ -131,7 +134,7 @@ def list_review_decisions(target_type: str = "", target_id: str = "", status: st
|
||||
params.append(status)
|
||||
where = " WHERE " + " AND ".join(clauses) if clauses else ""
|
||||
max_limit = max(1, min(int(limit), 1000))
|
||||
with db(db_path) as conn:
|
||||
with read_db(db_path) as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM review_decisions{where} ORDER BY decided_at DESC, updated_at DESC LIMIT ?",
|
||||
(*params, max_limit),
|
||||
@@ -214,6 +217,7 @@ def release_review_decision(target_type: str, target_id: str, memo: str = "", op
|
||||
return upsert_review_decision(target_type, target_id, status="pending", decision_type="unknown", reason_code="released", memo=memo, operator=operator, review_after="", db_path=db_path)
|
||||
|
||||
|
||||
@read_operation
|
||||
def list_reconciliation_sessions(account_id: str = "", status: str = "", basis_date: str = "", limit: int = 100, db_path: str | None = None) -> dict[str, Any]:
|
||||
clauses: list[str] = []
|
||||
params: list[Any] = []
|
||||
@@ -235,7 +239,7 @@ def list_reconciliation_sessions(account_id: str = "", status: str = "", basis_d
|
||||
clauses.append("basis_date=?")
|
||||
params.append(basis_date)
|
||||
where = " WHERE " + " AND ".join(clauses) if clauses else ""
|
||||
with db(db_path) as conn:
|
||||
with read_db(db_path) as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM reconciliation_sessions{where} ORDER BY basis_date DESC, updated_at DESC LIMIT ?",
|
||||
(*params, max(1, min(int(limit), 1000))),
|
||||
@@ -338,8 +342,9 @@ def close_reconciliation_session(session_id: str = "", account_id: str = "", bas
|
||||
)
|
||||
|
||||
|
||||
@read_operation
|
||||
def get_review_operating_indicators(db_path: str | None = None) -> dict[str, Any]:
|
||||
with db(db_path) as conn:
|
||||
with read_db(db_path) as conn:
|
||||
review_rows = conn.execute("SELECT status, COUNT(*) AS count FROM review_decisions GROUP BY status").fetchall()
|
||||
pending_count = int(conn.execute("SELECT COUNT(*) FROM review_decisions WHERE status='pending'").fetchone()[0])
|
||||
held_recheck_count = int(conn.execute("SELECT COUNT(*) FROM review_decisions WHERE status IN ('held','needs_recheck')").fetchone()[0])
|
||||
|
||||
@@ -5,6 +5,7 @@ import sqlite3 as sqlite3
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from functools import wraps
|
||||
from datetime import date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -23,6 +24,8 @@ from ..infrastructure.parsers import (
|
||||
)
|
||||
from ..infrastructure.sqlite import db as db
|
||||
from ..infrastructure.sqlite import init_db as init_db
|
||||
from ..infrastructure.sqlite import read_db as read_db
|
||||
from ..infrastructure.sqlite import DatabaseNotInitializedError
|
||||
from ..infrastructure.sqlite import resolve_db_path as resolve_db_path
|
||||
|
||||
VALID_TRANSACTION_TYPES = {"income", "expense", "transfer", "refund", "adjustment"}
|
||||
@@ -121,6 +124,19 @@ def ok(**data: Any) -> dict[str, Any]:
|
||||
def fail(error_code: str, message: str, **data: Any) -> dict[str, Any]:
|
||||
return {"success": False, "error_code": error_code, "error": message, **data}
|
||||
|
||||
|
||||
def read_operation(function):
|
||||
"""Translate missing/read-only SQLite failures to the public envelope."""
|
||||
@wraps(function)
|
||||
def wrapped(*args, **kwargs):
|
||||
try:
|
||||
return function(*args, **kwargs)
|
||||
except DatabaseNotInitializedError:
|
||||
return fail("db_not_initialized", "ledger database is not initialized")
|
||||
except sqlite3.Error:
|
||||
return fail("db_error", "ledger database read failed")
|
||||
return wrapped
|
||||
|
||||
def row_to_dict(row: Any) -> dict[str, Any]:
|
||||
d = dict(row)
|
||||
if "tags" in d:
|
||||
|
||||
@@ -10,6 +10,8 @@ from .shared import (
|
||||
current_kst_month,
|
||||
datetime,
|
||||
db,
|
||||
read_db,
|
||||
read_operation,
|
||||
fail,
|
||||
hashlib,
|
||||
init_db,
|
||||
@@ -29,6 +31,15 @@ def health_check(db_path: str | None = None) -> dict[str, Any]:
|
||||
return ok(server="jarca-ledger-mcp", db_path=path, schema_version=schema_version, active_transactions=tx_count)
|
||||
|
||||
|
||||
@read_operation
|
||||
def health_check_readonly(db_path: str | None = None) -> dict[str, Any]:
|
||||
path = resolve_db_path(db_path)
|
||||
with read_db(str(path)) as conn:
|
||||
schema_version = conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0]
|
||||
tx_count = conn.execute("SELECT COUNT(*) FROM transactions WHERE status='active'").fetchone()[0]
|
||||
return ok(server="jarca-ledger-mcp", db_path=path, schema_version=schema_version, active_transactions=tx_count)
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
@@ -186,8 +197,9 @@ def get_ledger_backup_status(file_path: str, include_hash: bool = True, include_
|
||||
|
||||
from .accounts_write_services import create_ledger_account as create_ledger_account
|
||||
|
||||
@read_operation
|
||||
def list_ledger_accounts(active_only: bool = True, db_path: str | None = None) -> dict[str, Any]:
|
||||
sql = "SELECT * FROM accounts" + (" WHERE active=1" if active_only else "") + " ORDER BY name"
|
||||
with db(db_path) as conn:
|
||||
with read_db(db_path) as conn:
|
||||
rows = conn.execute(sql).fetchall()
|
||||
return ok(accounts=[row_to_dict(r) for r in rows], count=len(rows))
|
||||
|
||||
@@ -4,7 +4,8 @@ from .shared import (
|
||||
Any,
|
||||
CASHFLOW_KIND_SQL,
|
||||
date,
|
||||
db,
|
||||
read_db,
|
||||
read_operation,
|
||||
normalize_date,
|
||||
normalize_month,
|
||||
ok,
|
||||
@@ -16,6 +17,7 @@ from .transactions_write_services import create_ledger_transaction as create_led
|
||||
from .transactions_write_services import delete_ledger_transaction as delete_ledger_transaction
|
||||
from .transactions_write_services import update_ledger_transaction as update_ledger_transaction
|
||||
|
||||
@read_operation
|
||||
def list_ledger_transactions(start_date: str = "", end_date: str = "", type: str = "", category: str = "", person: str = "", account_id: str = "", include_deleted: bool = False, limit: int = 100, db_path: str | None = None) -> dict[str, Any]:
|
||||
clauses=[]; params=[]
|
||||
if not include_deleted:
|
||||
@@ -30,18 +32,19 @@ def list_ledger_transactions(start_date: str = "", end_date: str = "", type: str
|
||||
where = " WHERE " + " AND ".join(clauses) if clauses else ""
|
||||
sql = f"SELECT * FROM transactions{where} ORDER BY transaction_date DESC, created_at DESC LIMIT ?"
|
||||
params.append(max(1, min(int(limit), 1000)))
|
||||
with db(db_path) as conn:
|
||||
with read_db(db_path) as conn:
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
return ok(transactions=[row_to_dict(r) for r in rows], count=len(rows))
|
||||
|
||||
from .reconciliation_service import get_balance_reconciliation_report as get_balance_reconciliation_report
|
||||
|
||||
@read_operation
|
||||
def list_ledger_budgets(month: str = "", db_path: str | None = None) -> dict[str, Any]:
|
||||
clauses=[]; params=[]
|
||||
if month:
|
||||
clauses.append("month=?"); params.append(normalize_month(month))
|
||||
where = " WHERE " + " AND ".join(clauses) if clauses else ""
|
||||
with db(db_path) as conn:
|
||||
with read_db(db_path) as conn:
|
||||
rows = conn.execute(f"SELECT * FROM budgets{where} ORDER BY month DESC, category", params).fetchall()
|
||||
return ok(budgets=[row_to_dict(r) for r in rows], count=len(rows))
|
||||
|
||||
@@ -52,9 +55,10 @@ def _month_range(month: str) -> tuple[str, str]:
|
||||
end = date(y + (mo == 12), 1 if mo == 12 else mo + 1, 1)
|
||||
return start.isoformat(), end.isoformat()
|
||||
|
||||
@read_operation
|
||||
def get_monthly_cashflow_report(month: str = "", db_path: str | None = None) -> dict[str, Any]:
|
||||
month_norm = normalize_month(month); start, next_month = _month_range(month_norm)
|
||||
with db(db_path) as conn:
|
||||
with read_db(db_path) as conn:
|
||||
type_rows = conn.execute("""SELECT type, SUM(amount) AS total FROM transactions
|
||||
WHERE status='active' AND transaction_date>=? AND transaction_date<?
|
||||
GROUP BY type""", (start, next_month)).fetchall()
|
||||
@@ -72,6 +76,7 @@ def get_monthly_cashflow_report(month: str = "", db_path: str | None = None) ->
|
||||
savings_rate = round(net_cashflow / income, 4) if income else None
|
||||
return ok(month=month_norm, income=income, expenses=expenses, net_cashflow=net_cashflow, savings_rate=savings_rate, by_type=type_totals, by_cashflow_kind=cashflow_totals)
|
||||
|
||||
@read_operation
|
||||
def get_category_spending_report(month: str = "", start_date: str = "", end_date: str = "", person: str = "", db_path: str | None = None) -> dict[str, Any]:
|
||||
if month:
|
||||
start_date, next_month = _month_range(month); end_op = "<"; end_value = next_month
|
||||
@@ -85,15 +90,16 @@ def get_category_spending_report(month: str = "", start_date: str = "", end_date
|
||||
LEFT JOIN ledger_categories lc ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory
|
||||
WHERE t.status='active' AND {CASHFLOW_KIND_SQL}='expense' AND t.transaction_date>=? AND t.transaction_date{end_op}?{person_clause}
|
||||
GROUP BY t.category, t.subcategory ORDER BY total DESC"""
|
||||
with db(db_path) as conn:
|
||||
with read_db(db_path) as conn:
|
||||
rows=conn.execute(sql, params).fetchall()
|
||||
total=sum(int(r["total"] or 0) for r in rows)
|
||||
items=[{"category": r["category"], "subcategory": r["subcategory"], "amount": int(r["total"] or 0), "count": r["count"], "share": round((int(r["total"] or 0)/total),4) if total else 0} for r in rows]
|
||||
return ok(total_expense=total, items=items, count=len(items))
|
||||
|
||||
@read_operation
|
||||
def get_fixed_cost_report(month: str = "", db_path: str | None = None) -> dict[str, Any]:
|
||||
month_norm=normalize_month(month); start,next_month=_month_range(month_norm)
|
||||
with db(db_path) as conn:
|
||||
with read_db(db_path) as conn:
|
||||
rows=conn.execute(f"""SELECT t.category, t.merchant, t.amount, t.transaction_date, t.person, t.memo FROM transactions t
|
||||
LEFT JOIN ledger_categories lc ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory
|
||||
WHERE t.status='active' AND {CASHFLOW_KIND_SQL}='expense' AND t.is_fixed=1 AND t.transaction_date>=? AND t.transaction_date<?
|
||||
@@ -101,9 +107,10 @@ def get_fixed_cost_report(month: str = "", db_path: str | None = None) -> dict[s
|
||||
items=[row_to_dict(r) for r in rows]
|
||||
return ok(month=month_norm, total_fixed_cost=sum(i["amount"] for i in items), items=items, count=len(items))
|
||||
|
||||
@read_operation
|
||||
def get_budget_vs_actual_report(month: str = "", db_path: str | None = None) -> dict[str, Any]:
|
||||
month_norm=normalize_month(month); start,next_month=_month_range(month_norm)
|
||||
with db(db_path) as conn:
|
||||
with read_db(db_path) as conn:
|
||||
budgets=conn.execute("SELECT * FROM budgets WHERE month=?", (month_norm,)).fetchall()
|
||||
actuals=conn.execute(f"""SELECT t.category, t.subcategory, t.person, SUM(t.amount) AS amount FROM transactions t
|
||||
LEFT JOIN ledger_categories lc ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory
|
||||
@@ -116,6 +123,7 @@ def get_budget_vs_actual_report(month: str = "", db_path: str | None = None) ->
|
||||
items.append({"category": b["category"], "subcategory": b["subcategory"], "person": b["person"], "budget": budget, "actual": actual, "remaining": budget-actual, "usage_rate": round(actual/budget,4) if budget else None, "status": "over" if actual>budget else "ok"})
|
||||
return ok(month=month_norm, items=items, total_budget=sum(i["budget"] for i in items), total_actual=sum(i["actual"] for i in items))
|
||||
|
||||
@read_operation
|
||||
def get_investable_cashflow_report(month: str = "", minimum_cash_buffer: int = 0, planned_extra_expense: int = 0, db_path: str | None = None) -> dict[str, Any]:
|
||||
cashflow=get_monthly_cashflow_report(month, db_path)
|
||||
if not cashflow.get("success"):
|
||||
|
||||
@@ -4,6 +4,8 @@ from .shared import (
|
||||
Any,
|
||||
datetime,
|
||||
db,
|
||||
read_db,
|
||||
read_operation,
|
||||
fail,
|
||||
now_iso,
|
||||
ok,
|
||||
@@ -42,14 +44,16 @@ def _rule_matches(rule: dict[str, Any], tx: dict[str, Any]) -> bool:
|
||||
return re.search(pattern, value) is not None
|
||||
return False
|
||||
|
||||
@read_operation
|
||||
def list_uncategorized_transactions(limit: int = 100, min_amount: int = 0, db_path: str | None = None) -> dict[str, Any]:
|
||||
with db(db_path) as conn:
|
||||
with read_db(db_path) as conn:
|
||||
rows=conn.execute("""SELECT * FROM transactions WHERE status='active' AND COALESCE(category,'')='' AND amount>=? ORDER BY amount DESC, transaction_datetime DESC LIMIT ?""", (int(min_amount), max(1,min(int(limit),1000)))).fetchall()
|
||||
return ok(transactions=[row_to_dict(r) for r in rows], count=len(rows))
|
||||
|
||||
|
||||
@read_operation
|
||||
def get_uncategorized_merchant_summary(limit: int = 50, db_path: str | None = None) -> dict[str, Any]:
|
||||
with db(db_path) as conn:
|
||||
with read_db(db_path) as conn:
|
||||
rows=conn.execute("""SELECT merchant, raw_summary, type, COUNT(*) count, SUM(amount) total_amount, MAX(amount) max_amount
|
||||
FROM transactions WHERE status='active' AND COALESCE(category,'')=''
|
||||
GROUP BY merchant, raw_summary, type ORDER BY total_amount DESC LIMIT ?""", (max(1,min(int(limit),500)),)).fetchall()
|
||||
@@ -90,6 +94,7 @@ def _recommend_category_for_transaction(tx: dict[str, Any], rules: list[dict[str
|
||||
return {"category": "", "subcategory": "", "confidence": 0.0, "source": "none", "reason": "추천 근거 없음", "rule_id": ""}
|
||||
|
||||
|
||||
@read_operation
|
||||
def get_uncategorized_workbench(
|
||||
limit: int = 100,
|
||||
min_amount: int = 0,
|
||||
@@ -103,7 +108,7 @@ def get_uncategorized_workbench(
|
||||
"date_desc": "transaction_datetime DESC, transaction_date DESC, created_at DESC, amount DESC",
|
||||
}
|
||||
order_by = order_by_map.get(sort_by, order_by_map["amount_desc"])
|
||||
with db(db_path) as conn:
|
||||
with read_db(db_path) as conn:
|
||||
tx_rows = [row_to_dict(r) for r in conn.execute(
|
||||
f"""SELECT id, transaction_date, transaction_datetime, type, amount, currency, merchant, raw_summary,
|
||||
counterparty, account_id, payment_method, person, is_fixed, source, memo, created_at
|
||||
@@ -172,7 +177,7 @@ def get_uncategorized_workbench(
|
||||
group["recommendation"] = rec
|
||||
|
||||
group_items = sorted(groups.values(), key=lambda g: (int(g["count"]), int(g["total_amount"])), reverse=True)
|
||||
with db(db_path) as conn:
|
||||
with read_db(db_path) as conn:
|
||||
txs = attach_review_decisions(conn, txs, "uncategorized_transaction", "id")
|
||||
recommendable_count = sum(1 for tx in txs if tx.get("recommended_category"))
|
||||
repeated_group_count = sum(1 for g in group_items if int(g.get("count") or 0) >= 2)
|
||||
|
||||
Reference in New Issue
Block a user