fix: address account valuation review findings

This commit is contained in:
2026-10-06 14:43:29 +00:00
parent 399d0923be
commit 6a8a27a74e
34 changed files with 1940 additions and 131 deletions
@@ -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")}
+340 -49
View File
@@ -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])
+16
View File
@@ -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)