feat: deliver schema 15 monthly settlement lifecycle
This commit is contained in:
@@ -1,375 +1,163 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
import math
|
||||
import re
|
||||
from typing import Any
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from .shared import db, read_db, read_operation, fail, new_id, normalize_date, now_iso, ok, row_to_dict
|
||||
from .shared import db, fail, now_iso, ok, read_db
|
||||
|
||||
_VALID_STATUSES = {"verified", "unverified", "rejected"}
|
||||
_VALID_LOAN_DEFINITIONS = {"principal_only", "principal_interest_fees"}
|
||||
_VALID_CURRENCIES = {"KRW", "USD", "EUR", "JPY", "CNY", "GBP"}
|
||||
_VALID_KINDS = {"balance", "cash_balance", "total_value", "loan", "loan_balance"}
|
||||
_VALID_SOURCE_TYPES = {"manual", "import", "account_write", "reconciliation", "snapshot"}
|
||||
_COLUMNS = (
|
||||
"amount", "currency", "valuation_kind", "source_type", "source_ref",
|
||||
"verification_status", "memo", "as_of_at", "timezone", "cost_basis",
|
||||
"net_contributed", "reported_principal_kind", "reported_principal_label",
|
||||
"profit", "return_rate", "return_basis", "principal", "interest", "fee",
|
||||
"representative_liability_definition",
|
||||
)
|
||||
_ALLOWED_VERIFICATION = {"pending", "unverified"}
|
||||
_ALLOWED_SETTLEMENT_STATUS = {"draft", "in_review"}
|
||||
LIQUIDITY_BUCKETS = ("cash", "deposit", "investment", "real_estate", "loan", "other")
|
||||
|
||||
def normalize_liquidity_bucket(value: Any, *, default: str = "other") -> str:
|
||||
value = default if value is None or str(value).strip() == "" else str(value).strip().lower()
|
||||
if value not in LIQUIDITY_BUCKETS:
|
||||
raise ValueError("liquidity_bucket must be cash, deposit, investment, real_estate, loan or other")
|
||||
return value
|
||||
|
||||
|
||||
def _valuation(row: Any) -> dict[str, Any]:
|
||||
return row_to_dict(row)
|
||||
def _public_candidate(item: dict[str, Any], strict: bool = False) -> dict[str, Any]:
|
||||
"""Compatibility projection used by asset evidence readers."""
|
||||
allowed = {"id", "source_id", "source_type", "name", "amount", "currency", "basis_date",
|
||||
"valuation_id", "valuation_kind", "verification_status", "evidence_kind", "memo"}
|
||||
return {key: value for key, value in item.items() if key in allowed}
|
||||
|
||||
|
||||
def _public_candidate(row: Any) -> dict[str, Any]:
|
||||
return {k: row[k] for k in ("id", "basis_date", "currency", "valuation_kind", "verification_status")}
|
||||
def record_account_valuation_candidate(*args: Any, **kwargs: Any) -> dict[str, Any]:
|
||||
return fail("unsupported", "account valuation candidates are not writable")
|
||||
|
||||
|
||||
def _strict_int(value: Any, field: str, allow_none: bool = True) -> int | None:
|
||||
if value is None and allow_none:
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
def _strict_int(value: Any, field: str) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(f"{field} must be an integer")
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, str) and re.fullmatch(r"[+-]?\d+", value.strip()):
|
||||
return int(value.strip())
|
||||
raise ValueError(f"{field} must be an integer")
|
||||
|
||||
|
||||
def _strict_float(value: Any, field: str) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
raise ValueError(f"{field} must be a finite number")
|
||||
try:
|
||||
result = float(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"{field} must be a finite number") from exc
|
||||
if not math.isfinite(result):
|
||||
raise ValueError(f"{field} must be a finite number")
|
||||
return result
|
||||
|
||||
|
||||
def _validate_common(account_id: str, basis_date: str, amount: int, currency: str, verification_status: str, valuation_kind: str, source_type: str, representative_liability_definition: str | None) -> tuple[str, int, str, str, str, str | None] | dict[str, Any]:
|
||||
if not isinstance(account_id, str) or not account_id.strip():
|
||||
return fail("validation_error", "account_id must be non-empty")
|
||||
try:
|
||||
basis = normalize_date(basis_date)
|
||||
amount_int = _strict_int(amount, "amount", allow_none=False)
|
||||
except Exception as exc:
|
||||
return fail("validation_error", str(exc))
|
||||
currency_norm = (currency or "KRW").strip().upper()
|
||||
if currency_norm not in _VALID_CURRENCIES:
|
||||
return fail("validation_error", f"currency must be one of {sorted(_VALID_CURRENCIES)}")
|
||||
status = (verification_status or "unverified").strip().lower()
|
||||
if status not in _VALID_STATUSES:
|
||||
return fail("validation_error", "verification_status must be verified, unverified, or rejected")
|
||||
if not isinstance(valuation_kind, str):
|
||||
return fail("validation_error", "valuation_kind must be a string")
|
||||
kind = valuation_kind.strip().lower() or "balance"
|
||||
if kind not in _VALID_KINDS:
|
||||
return fail("validation_error", f"valuation_kind must be one of {sorted(_VALID_KINDS)}")
|
||||
source = (source_type or "manual").strip().lower() or "manual"
|
||||
if source not in _VALID_SOURCE_TYPES:
|
||||
return fail("validation_error", f"source_type must be one of {sorted(_VALID_SOURCE_TYPES)}")
|
||||
definition = representative_liability_definition.strip() if representative_liability_definition else None
|
||||
if definition is not None and definition not in _VALID_LOAN_DEFINITIONS:
|
||||
return fail("validation_error", "representative liability definition is invalid")
|
||||
if kind in {"loan", "loan_balance"} and definition is None:
|
||||
return fail("validation_error", "loan valuation requires representative_liability_definition")
|
||||
if kind not in {"loan", "loan_balance", "balance"} and definition is not None:
|
||||
return fail("validation_error", "valuation kind cannot define representative liability")
|
||||
if amount_int is None:
|
||||
return fail("validation_error", "amount must be an integer")
|
||||
return basis, amount_int, currency_norm, status, source, definition
|
||||
|
||||
|
||||
def record_account_valuation_candidate(conn: sqlite3.Connection, account_id: str, basis_date: str, amount: int, currency: str, source_type: str, source_ref: str, idempotency_key: str, memo: str = "") -> dict[str, Any]:
|
||||
"""Append an unverified candidate on a caller-owned transaction."""
|
||||
account = conn.execute("SELECT type,currency FROM accounts WHERE id=?", (account_id,)).fetchone()
|
||||
if account is None:
|
||||
return fail("not_found", "account not found")
|
||||
if (currency or "KRW").upper() != (account["currency"] or "KRW").upper():
|
||||
return fail("validation_error", "valuation currency must match account currency")
|
||||
if str(account["type"]).lower() == "loan":
|
||||
return fail("validation_error", "loan valuation requires representative_liability_definition")
|
||||
try:
|
||||
amount_int = _strict_int(amount, "amount", allow_none=False)
|
||||
except (TypeError, ValueError) as exc:
|
||||
return fail("validation_error", str(exc))
|
||||
basis = normalize_date(basis_date)
|
||||
existing = conn.execute("SELECT * FROM account_valuations WHERE account_id=? AND idempotency_key=?", (account_id, idempotency_key)).fetchone()
|
||||
if existing:
|
||||
incoming = {
|
||||
"basis_date": basis,
|
||||
"amount": amount_int,
|
||||
"currency": (currency or "KRW").upper(),
|
||||
"valuation_kind": "balance",
|
||||
"source_type": source_type,
|
||||
"source_ref": source_ref or "",
|
||||
"verification_status": "unverified",
|
||||
"memo": memo,
|
||||
}
|
||||
if all(existing[key] == value for key, value in incoming.items()):
|
||||
return ok(valuation=_valuation(existing), reused=True)
|
||||
return fail(
|
||||
"idempotency_conflict",
|
||||
"idempotency_key already has a different candidate payload",
|
||||
valuation=_valuation(existing),
|
||||
)
|
||||
valuation_id = new_id("valuation")
|
||||
conn.execute("""INSERT INTO account_valuations
|
||||
(id,account_id,basis_date,amount,currency,valuation_kind,source_type,source_ref,verification_status,recorded_at,memo,idempotency_key)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""", (valuation_id, account_id, basis, amount_int, (currency or "KRW").upper(), "balance", source_type, source_ref or "", "unverified", now_iso(), memo, idempotency_key))
|
||||
return ok(valuation=_valuation(conn.execute("SELECT * FROM account_valuations WHERE id=?", (valuation_id,)).fetchone()), reused=False)
|
||||
return value
|
||||
|
||||
|
||||
def register_account_valuation(
|
||||
account_id: str,
|
||||
basis_date: str,
|
||||
amount: int,
|
||||
currency: str = "KRW",
|
||||
currency: str | None = None,
|
||||
valuation_kind: str = "balance",
|
||||
source_type: str = "manual",
|
||||
source_type: str = "account",
|
||||
source_ref: str = "",
|
||||
verification_status: str = "unverified",
|
||||
memo: str = "",
|
||||
idempotency_key: str = "",
|
||||
as_of_at: str | None = None,
|
||||
timezone: str | None = None,
|
||||
cost_basis: int | None = None,
|
||||
net_contributed: int | None = None,
|
||||
reported_principal_kind: str | None = None,
|
||||
reported_principal_label: str | None = None,
|
||||
profit: int | None = None,
|
||||
return_rate: float | None = None,
|
||||
return_basis: str | None = None,
|
||||
principal: int | None = None,
|
||||
interest: int | None = None,
|
||||
fee: int | None = None,
|
||||
representative_liability_definition: str | None = None,
|
||||
monthly_settlement_id: str | None = None,
|
||||
db_path: str | None = None,
|
||||
supersedes_id: str | None = None,
|
||||
correction_reason: str = "",
|
||||
liquidity_bucket: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
validated = _validate_common(account_id, basis_date, amount, currency, verification_status, valuation_kind, source_type, representative_liability_definition)
|
||||
if isinstance(validated, dict):
|
||||
return validated
|
||||
basis, amount_int, currency_norm, status, source_norm, definition = validated
|
||||
"""Register one unverified account evaluation in the monthly snapshot.
|
||||
|
||||
This is intentionally the account_snapshot write path; no valuation table is
|
||||
created. The transaction locks before checking duplicates so concurrent UI
|
||||
submissions cannot create two rows for the same settlement/account.
|
||||
"""
|
||||
try:
|
||||
optional_ints = {name: _strict_int(value, name) for name, value in {
|
||||
"cost_basis": cost_basis, "net_contributed": net_contributed, "profit": profit,
|
||||
"principal": principal, "interest": interest, "fee": fee}.items()}
|
||||
return_rate = _strict_float(return_rate, "return_rate")
|
||||
except (TypeError, ValueError) as exc:
|
||||
if not account_id or not basis_date or not currency:
|
||||
raise ValueError("account_id, basis_date and currency are required")
|
||||
amount = _strict_int(amount, "amount")
|
||||
if source_type != "account":
|
||||
raise ValueError("source_type must be account")
|
||||
if verification_status not in _ALLOWED_VERIFICATION:
|
||||
raise ValueError("verification_status must be pending or unverified")
|
||||
with db(db_path) as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
selection_policy = kwargs.get("selection_policy", "manual")
|
||||
valuation_source = kwargs.get("valuation_source", "manual")
|
||||
source_note = kwargs.get("source_note", "")
|
||||
if selection_policy not in {"manual", "previous", "next"}:
|
||||
raise ValueError("selection_policy must be manual, previous or next")
|
||||
if valuation_source not in {"manual", "ledger"}:
|
||||
raise ValueError("invalid valuation_source")
|
||||
account = conn.execute("SELECT id,name,currency,liquidity_bucket FROM accounts WHERE id=?", (account_id,)).fetchone()
|
||||
if account is None:
|
||||
conn.rollback(); return fail("not_found", "account not found")
|
||||
if account["currency"] != currency:
|
||||
conn.rollback(); return fail("validation_error", "currency does not match account")
|
||||
bucket = normalize_liquidity_bucket(liquidity_bucket, default=account["liquidity_bucket"] or "other")
|
||||
settlement = conn.execute(
|
||||
"SELECT id,basis_date,status FROM monthly_settlement WHERE id=?", (monthly_settlement_id,)
|
||||
).fetchone()
|
||||
if settlement is None:
|
||||
conn.rollback(); return fail("not_found", "monthly settlement not found")
|
||||
if settlement["status"] == "closed":
|
||||
conn.rollback(); return fail("closed_settlement", "closed monthly settlement cannot be changed")
|
||||
if settlement["status"] not in _ALLOWED_SETTLEMENT_STATUS:
|
||||
conn.rollback(); return fail("invalid_settlement_status", "only draft or in_review settlements can be changed")
|
||||
if basis_date != settlement["basis_date"]:
|
||||
conn.rollback(); return fail("validation_error", "basis_date must match monthly settlement")
|
||||
if selection_policy != "manual":
|
||||
tx = conn.execute("""SELECT t.balance_after,t.transaction_date
|
||||
FROM transactions t JOIN accounts a ON a.id=t.account_id
|
||||
WHERE t.id=? AND t.account_id=? AND a.active=1 AND t.currency=?
|
||||
AND t.status='active' AND t.balance_after IS NOT NULL""", (source_note, account_id, currency)).fetchone()
|
||||
if tx is None or (selection_policy == "previous" and tx["transaction_date"] > basis_date) or (selection_policy == "next" and tx["transaction_date"] <= basis_date):
|
||||
conn.rollback(); return fail("stale_evidence", "selected ledger is no longer valid")
|
||||
amount = tx["balance_after"]
|
||||
duplicate = conn.execute(
|
||||
"SELECT id FROM account_snapshot WHERE monthly_settlement_id=? AND source_type='account' AND source_id=?",
|
||||
(settlement["id"], account_id),
|
||||
).fetchone()
|
||||
if duplicate:
|
||||
conn.rollback(); return fail("duplicate", "account evaluation already registered", snapshot_id=duplicate["id"])
|
||||
snapshot_id = f"snapshot_{uuid4().hex}"
|
||||
conn.execute(
|
||||
"""INSERT INTO account_snapshot
|
||||
(id,basis_date,source_type,source_id,name,amount,currency,memo,
|
||||
monthly_settlement_id,valuation_id,selection_policy,valuation_source,source_note,verification_status,evidence_kind,liquidity_bucket,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(snapshot_id, basis_date, "account", account_id, account["name"], amount,
|
||||
currency, memo or "", settlement["id"], "", selection_policy, valuation_source, source_note, "unverified", "applied", bucket, now_iso()),
|
||||
)
|
||||
return ok(snapshot_id=snapshot_id, id=snapshot_id, verification_status="unverified", created=True)
|
||||
except ValueError as exc:
|
||||
return fail("validation_error", str(exc))
|
||||
if (as_of_at is None) != (timezone is None):
|
||||
return fail("validation_error", "as_of_at and timezone must be supplied together")
|
||||
if as_of_at is not None:
|
||||
try:
|
||||
from datetime import datetime
|
||||
parsed = datetime.fromisoformat(as_of_at)
|
||||
if parsed.tzinfo is None:
|
||||
return fail("validation_error", "as_of_at must include timezone offset")
|
||||
except (TypeError, ValueError) as exc:
|
||||
return fail("validation_error", f"invalid as_of_at: {exc}")
|
||||
if supersedes_id and not correction_reason.strip():
|
||||
return fail("validation_error", "correction_reason is required")
|
||||
ts = now_iso()
|
||||
source_ref_norm = source_ref.strip() if isinstance(source_ref, str) else ""
|
||||
values = (amount_int, currency_norm, (valuation_kind or "balance").strip().lower(), source_norm, source_ref_norm, status, memo or "", as_of_at, timezone, optional_ints["cost_basis"], optional_ints["net_contributed"], reported_principal_kind, reported_principal_label, optional_ints["profit"], return_rate, return_basis, optional_ints["principal"], optional_ints["interest"], optional_ints["fee"], definition)
|
||||
with db(db_path) as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
account = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone()
|
||||
if account is None:
|
||||
return fail("not_found", "account not found")
|
||||
if currency_norm != (account["currency"] or "KRW").upper():
|
||||
return fail("validation_error", "valuation currency must match account currency")
|
||||
effective_kind = (valuation_kind or "balance").strip().lower()
|
||||
is_loan_account = str(account["type"]).lower() == "loan"
|
||||
if effective_kind in {"loan", "loan_balance"} and not is_loan_account:
|
||||
return fail("validation_error", "loan valuation kind requires a loan account")
|
||||
if is_loan_account and effective_kind not in {"balance", "loan", "loan_balance"}:
|
||||
return fail("validation_error", "loan account valuation kind must represent a liability")
|
||||
if is_loan_account and effective_kind in {"balance", "loan", "loan_balance"} and definition is None:
|
||||
return fail("validation_error", "loan valuation requires representative_liability_definition")
|
||||
if definition is not None and not is_loan_account:
|
||||
return fail("validation_error", "representative liability definition requires a loan account")
|
||||
seen: set[str] = set()
|
||||
if supersedes_id:
|
||||
parent = conn.execute("SELECT * FROM account_valuations WHERE id=?", (supersedes_id,)).fetchone()
|
||||
if parent is None:
|
||||
if idempotency_key and conn.execute("SELECT 1 FROM account_valuations WHERE account_id=? AND idempotency_key=?", (account_id, idempotency_key)).fetchone():
|
||||
return fail("idempotency_conflict", "idempotency_key already has a different payload")
|
||||
return fail("validation_error", "supersedes_id must reference an existing valuation")
|
||||
if any(parent[field] != value for field, value in (
|
||||
("account_id", account_id), ("basis_date", basis),
|
||||
("currency", currency_norm), ("valuation_kind", (valuation_kind or "balance").strip().lower()),
|
||||
)):
|
||||
return fail("validation_error", "superseded valuation scope must match account, basis, currency, and valuation kind")
|
||||
seen: set[str] = set()
|
||||
cursor = parent
|
||||
while cursor is not None:
|
||||
cursor_id = cursor["id"]
|
||||
if cursor_id in seen:
|
||||
return fail("validation_error", "supersedes ancestry is cyclic")
|
||||
seen.add(cursor_id)
|
||||
next_id = cursor["supersedes_id"]
|
||||
cursor = conn.execute("SELECT * FROM account_valuations WHERE id=?", (next_id,)).fetchone() if next_id else None
|
||||
existing = None
|
||||
by_key = conn.execute("SELECT * FROM account_valuations WHERE account_id=? AND idempotency_key=?", (account_id, idempotency_key)).fetchone() if idempotency_key else None
|
||||
source_rows = conn.execute("SELECT * FROM account_valuations WHERE account_id=? AND source_ref=? ORDER BY recorded_at, id", (account_id, source_ref_norm)).fetchall() if source_ref_norm else []
|
||||
correction_chain = seen if supersedes_id else set()
|
||||
unrelated_source = next((row for row in source_rows if row["id"] not in correction_chain and (by_key is None or row["id"] != by_key["id"])), None)
|
||||
if by_key is not None and unrelated_source is not None:
|
||||
return fail("idempotency_conflict", "idempotency_key and source_ref identify different candidates")
|
||||
if unrelated_source is not None:
|
||||
by_source = unrelated_source
|
||||
else:
|
||||
by_source = None
|
||||
existing = by_key or by_source
|
||||
if existing:
|
||||
current = _valuation(existing)
|
||||
incoming = dict(zip(_COLUMNS, values))
|
||||
incoming.update({"account_id": account_id, "basis_date": basis, "supersedes_id": supersedes_id, "correction_reason": correction_reason or ""})
|
||||
comparable = set(incoming) - {"id", "recorded_at"}
|
||||
if all(current.get(k) == v for k, v in incoming.items() if k in comparable):
|
||||
return ok(valuation=current, reused=True)
|
||||
return fail("idempotency_conflict", "idempotency_key already has a different payload", valuation=current)
|
||||
valuation_id = new_id("valuation")
|
||||
try:
|
||||
conn.execute("""INSERT INTO account_valuations
|
||||
(id,account_id,basis_date,amount,currency,valuation_kind,source_type,source_ref,verification_status,recorded_at,memo,idempotency_key,supersedes_id,correction_reason,as_of_at,timezone,cost_basis,net_contributed,reported_principal_kind,reported_principal_label,profit,return_rate,return_basis,principal,interest,fee,representative_liability_definition)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (valuation_id, account_id, basis, *values[:6], ts, values[6], idempotency_key or "", supersedes_id, correction_reason, *values[7:]))
|
||||
except sqlite3.IntegrityError:
|
||||
if supersedes_id:
|
||||
return fail("correction_conflict", "valuation already has a correction")
|
||||
return fail("idempotency_conflict", "idempotency_key already has a different payload")
|
||||
except Exception as exc:
|
||||
return fail("validation_error", str(exc))
|
||||
row = conn.execute("SELECT * FROM account_valuations WHERE id=?", (valuation_id,)).fetchone()
|
||||
return ok(valuation=_valuation(row), reused=False)
|
||||
except sqlite3.IntegrityError:
|
||||
return fail("duplicate", "account evaluation already registered")
|
||||
except Exception:
|
||||
return fail("db_error", "account evaluation registration failed")
|
||||
|
||||
|
||||
@read_operation
|
||||
def list_account_valuations(account_id: str, include_rejected: bool = True, db_path: str | None = None) -> dict[str, Any]:
|
||||
where = "" if include_rejected else " AND verification_status <> 'rejected'"
|
||||
with read_db(db_path) as conn:
|
||||
rows = conn.execute(f"SELECT * FROM account_valuations WHERE account_id=?{where} ORDER BY basis_date, recorded_at, id", (account_id,)).fetchall()
|
||||
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:
|
||||
with read_db(db_path) as conn:
|
||||
rows = conn.execute("SELECT * FROM account_snapshot WHERE source_type='account' AND source_id=? ORDER BY created_at,id", (account_id,)).fetchall()
|
||||
return ok(valuations=[dict(r) for r in rows], count=len(rows), read_only=True)
|
||||
except Exception:
|
||||
return fail("db_error", "account evaluations unavailable")
|
||||
|
||||
|
||||
def select_account_valuation(*args: Any, **kwargs: Any) -> dict[str, Any]:
|
||||
return fail("unsupported", "account valuation selection is unavailable")
|
||||
|
||||
|
||||
def correct_account_valuation(*args: Any, **kwargs: Any) -> dict[str, Any]:
|
||||
return fail("unsupported", "account valuation correction is unavailable")
|
||||
|
||||
def update_account_snapshot_category(snapshot_id: str, monthly_settlement_id: str, liquidity_bucket: str, db_path: str | None = None) -> dict[str, Any]:
|
||||
try:
|
||||
bucket = normalize_liquidity_bucket(liquidity_bucket)
|
||||
with db(db_path) as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
settlement = conn.execute("SELECT status FROM monthly_settlement WHERE id=?", (monthly_settlement_id,)).fetchone()
|
||||
if settlement is None:
|
||||
conn.rollback(); return fail("not_found", "monthly settlement not found")
|
||||
if settlement["status"] not in _ALLOWED_SETTLEMENT_STATUS:
|
||||
conn.rollback(); return fail("invalid_settlement_status", "only draft or in_review settlements can be changed")
|
||||
snapshot = conn.execute("SELECT id,source_type,monthly_settlement_id FROM account_snapshot WHERE id=?", (snapshot_id,)).fetchone()
|
||||
if snapshot is None:
|
||||
conn.rollback(); return fail("not_found", "account snapshot not found")
|
||||
if snapshot["source_type"] != "account" or snapshot["monthly_settlement_id"] != monthly_settlement_id:
|
||||
conn.rollback(); return fail("validation_error", "snapshot is not linked to this monthly settlement")
|
||||
changed = conn.execute("UPDATE account_snapshot SET liquidity_bucket=? WHERE id=? AND monthly_settlement_id=?", (bucket, snapshot_id, monthly_settlement_id))
|
||||
if changed.rowcount != 1:
|
||||
conn.rollback(); return fail("stale_update", "snapshot category update is stale")
|
||||
return ok(snapshot_id=snapshot_id, monthly_settlement_id=monthly_settlement_id, liquidity_bucket=bucket, updated=True)
|
||||
except ValueError as exc:
|
||||
return fail("validation_error", str(exc))
|
||||
with read_db(db_path) as conn:
|
||||
clauses = ["account_id=?"]
|
||||
params: list[Any] = [account_id]
|
||||
if currency:
|
||||
clauses.append("UPPER(currency)=?"); params.append(currency.strip().upper())
|
||||
if valuation_kind:
|
||||
clauses.append("valuation_kind=?"); params.append(valuation_kind.strip().lower())
|
||||
rows = conn.execute(f"SELECT * FROM account_valuations WHERE {' AND '.join(clauses)} ORDER BY basis_date DESC, recorded_at DESC, id DESC", params).fetchall()
|
||||
leaves = [r for r in rows if conn.execute("SELECT 1 FROM account_valuations WHERE supersedes_id=?", (r["id"],)).fetchone() is None]
|
||||
verified = [r for r in leaves if r["verification_status"] == "verified" and r["basis_date"] <= basis]
|
||||
exact = [r for r in verified if r["basis_date"] == basis]
|
||||
# Candidates are evidence at or before the requested basis. Exact
|
||||
# selection only surfaces same-date unverified evidence; as-of
|
||||
# selection surfaces unverified evidence newer than the selected
|
||||
# verified value, while never looking into the future.
|
||||
latest_verified_date = max((x["basis_date"] for x in verified), default="")
|
||||
candidate = [
|
||||
r for r in leaves
|
||||
if r["verification_status"] == "unverified"
|
||||
and r["basis_date"] <= basis
|
||||
and (r["basis_date"] == basis if not as_of else (not latest_verified_date or r["basis_date"] > latest_verified_date))
|
||||
]
|
||||
matching = exact if not as_of else [r for r in verified if r["basis_date"] == latest_verified_date]
|
||||
if len(matching) > 1:
|
||||
return fail("ambiguous_valuation", "multiple verified valuation leaves match the scope")
|
||||
row = matching[0] if matching else None
|
||||
if row is None:
|
||||
result = ok(account_id=account_id, basis_date=basis, valuation=None, carried_forward=False)
|
||||
if candidate:
|
||||
result["warning"] = "unverified_candidate"
|
||||
result["unverified_candidates"] = [_public_candidate(item) for item in candidate]
|
||||
return result
|
||||
age = (date.fromisoformat(basis) - date.fromisoformat(row["basis_date"])).days
|
||||
warning = "unverified_candidate" if candidate else ("carry_forward" if age else None)
|
||||
result = ok(account_id=account_id, basis_date=basis, valuation=_valuation(row), carried_forward=bool(age), valuation_age_days=age, warning=warning)
|
||||
if candidate:
|
||||
result["unverified_candidates"] = [_public_candidate(item) for item in candidate]
|
||||
return result
|
||||
|
||||
|
||||
def correct_account_valuation(valuation_id: str, amount: int, correction_reason: str, idempotency_key: str = "", db_path: str | None = None, **fields: Any) -> dict[str, Any]:
|
||||
if not correction_reason or not correction_reason.strip():
|
||||
return fail("validation_error", "correction_reason is required")
|
||||
with db(db_path) as conn:
|
||||
source = conn.execute("SELECT * FROM account_valuations WHERE id=?", (valuation_id,)).fetchone()
|
||||
if source is None:
|
||||
return fail("not_found", "valuation not found")
|
||||
if "currency" in fields and fields["currency"] is not None and fields["currency"].strip().upper() != source["currency"].upper():
|
||||
return fail("validation_error", "correction currency must match parent valuation")
|
||||
if "valuation_kind" in fields and fields["valuation_kind"] is not None and fields["valuation_kind"].strip().lower() != source["valuation_kind"]:
|
||||
return fail("validation_error", "correction valuation_kind must match parent valuation")
|
||||
preserved = {name: fields[name] if name in fields else source[name] for name in (
|
||||
"currency", "valuation_kind", "source_type", "source_ref", "verification_status", "memo", "as_of_at", "timezone",
|
||||
"cost_basis", "net_contributed", "reported_principal_kind", "reported_principal_label", "profit", "return_rate", "return_basis", "principal", "interest", "fee", "representative_liability_definition")}
|
||||
try:
|
||||
effective = {
|
||||
"amount": _strict_int(amount, "amount", allow_none=False),
|
||||
"account_id": source["account_id"],
|
||||
"basis_date": source["basis_date"],
|
||||
"currency": (preserved["currency"] or "KRW").strip().upper(),
|
||||
"valuation_kind": (preserved["valuation_kind"] or "balance").strip().lower(),
|
||||
"source_type": (preserved["source_type"] or "manual").strip().lower() or "manual",
|
||||
"source_ref": preserved["source_ref"].strip() if isinstance(preserved["source_ref"], str) else "",
|
||||
"verification_status": (preserved["verification_status"] or "unverified").strip().lower(),
|
||||
"memo": preserved["memo"] or "",
|
||||
"as_of_at": preserved["as_of_at"],
|
||||
"timezone": preserved["timezone"],
|
||||
"cost_basis": _strict_int(preserved["cost_basis"], "cost_basis"),
|
||||
"net_contributed": _strict_int(preserved["net_contributed"], "net_contributed"),
|
||||
"reported_principal_kind": preserved["reported_principal_kind"],
|
||||
"reported_principal_label": preserved["reported_principal_label"],
|
||||
"profit": _strict_int(preserved["profit"], "profit"),
|
||||
"return_rate": _strict_float(preserved["return_rate"], "return_rate"),
|
||||
"return_basis": preserved["return_basis"],
|
||||
"principal": _strict_int(preserved["principal"], "principal"),
|
||||
"interest": _strict_int(preserved["interest"], "interest"),
|
||||
"fee": _strict_int(preserved["fee"], "fee"),
|
||||
"representative_liability_definition": preserved["representative_liability_definition"].strip() if preserved["representative_liability_definition"] else None,
|
||||
"supersedes_id": valuation_id,
|
||||
"correction_reason": correction_reason.strip(),
|
||||
}
|
||||
except (TypeError, ValueError, AttributeError) as exc:
|
||||
return fail("validation_error", str(exc))
|
||||
if idempotency_key:
|
||||
prior = conn.execute("SELECT * FROM account_valuations WHERE account_id=? AND idempotency_key=?", (source["account_id"], idempotency_key)).fetchone()
|
||||
if prior:
|
||||
if all(prior[key] == value for key, value in effective.items()):
|
||||
return ok(valuation=_valuation(prior), reused=True)
|
||||
return fail("idempotency_conflict", "idempotency_key already has a different payload", valuation=_valuation(prior))
|
||||
child = conn.execute("SELECT * FROM account_valuations WHERE supersedes_id=?", (valuation_id,)).fetchone()
|
||||
if child is not None:
|
||||
if all(child[key] == value for key, value in effective.items()):
|
||||
return ok(valuation=_valuation(child), reused=True)
|
||||
return fail("correction_conflict", "correction payload conflicts with existing correction", valuation=_valuation(child))
|
||||
return register_account_valuation(
|
||||
source["account_id"], source["basis_date"], amount,
|
||||
**preserved,
|
||||
idempotency_key=idempotency_key,
|
||||
supersedes_id=valuation_id, correction_reason=correction_reason.strip(),
|
||||
db_path=db_path,
|
||||
)
|
||||
except Exception:
|
||||
return fail("db_error", "account snapshot category update failed")
|
||||
|
||||
@@ -3,23 +3,18 @@ from .shared import Any, VALID_ACCOUNT_TYPES, db, fail, new_id, normalize_date,
|
||||
|
||||
|
||||
def _record_balance_candidate(conn: Any, account: dict[str, Any], balance: Any = None, basis_date: str | None = None) -> None:
|
||||
balance = account.get("last_balance") if balance is None else balance
|
||||
basis_date = account.get("last_balance_basis_date") if basis_date is None else basis_date
|
||||
if balance is None or not str(basis_date or ""):
|
||||
return
|
||||
try:
|
||||
from .account_valuation_service import record_account_valuation_candidate
|
||||
record_account_valuation_candidate(conn, account["id"], str(basis_date), int(balance), account.get("currency", "KRW"), "account_write", account.get("updated_at", ""), f"account-balance:{account['id']}:{str(basis_date)}:{balance}", "account balance write candidate")
|
||||
except (ValueError, TypeError):
|
||||
return
|
||||
"""Deprecated: balances remain on accounts; no valuation row is created."""
|
||||
return
|
||||
|
||||
|
||||
def create_ledger_account(name: str, type: str = "bank", institution: str = "", currency: str = "KRW", active: bool = True, memo: str = "", account_number_masked: str = "", holder_name: str = "", last_balance: int | None = None, last_synced_at: str = "", statement_status: str = "unknown", liquidity_bucket: str = "other", db_path: str | None = None, last_balance_basis_date: str = "") -> dict[str, Any]:
|
||||
def create_ledger_account(name: str, type: str = "bank", institution: str = "", currency: str = "KRW", active: bool = True, memo: str = "", account_number_masked: str = "", holder_name: str = "", last_balance: int | None = None, last_synced_at: str = "", statement_status: str = "unknown", liquidity_bucket: str | None = None, db_path: str | None = None, last_balance_basis_date: str = "") -> dict[str, Any]:
|
||||
if not name.strip(): return fail("validation_error", "account name is required")
|
||||
if type not in VALID_ACCOUNT_TYPES: return fail("validation_error", f"type must be one of {sorted(VALID_ACCOUNT_TYPES)}")
|
||||
try:
|
||||
statement_status_norm = normalize_statement_status(statement_status)
|
||||
liquidity_bucket_norm = normalize_liquidity_bucket(liquidity_bucket)
|
||||
liquidity_bucket_norm = normalize_liquidity_bucket(
|
||||
"real_estate" if liquidity_bucket is None and type == "real_estate" else liquidity_bucket
|
||||
)
|
||||
except Exception as e: return fail("validation_error", str(e))
|
||||
ts = now_iso(); account_id = new_id("acct")
|
||||
try:
|
||||
@@ -79,14 +74,7 @@ def update_ledger_account(account_id: str, db_path: str | None = None, **fields:
|
||||
if not updates: return fail("validation_error", "no fields to update")
|
||||
target_type = fields.get("type", current["type"])
|
||||
if str(current["type"]).lower() != "loan" and target_type == "loan":
|
||||
invalid_valuation = conn.execute(
|
||||
"SELECT 1 FROM account_valuations "
|
||||
"WHERE account_id=? AND (representative_liability_definition IS NULL "
|
||||
"OR representative_liability_definition NOT IN (?, ?)) LIMIT 1",
|
||||
(account_id, "principal_only", "principal_interest_fees"),
|
||||
).fetchone()
|
||||
if invalid_valuation is not None:
|
||||
return fail("validation_error", "loan account requires a valid representative liability definition for every valuation")
|
||||
return fail("unsupported", "account valuation history is unavailable; loan conversion requires a supported liability valuation source")
|
||||
stored_date = str(current["last_balance_basis_date"] or "")[:10]
|
||||
incoming_date = normalized_basis if basis_update else ""
|
||||
historical = bool(balance_update and incoming_date and stored_date and incoming_date < stored_date)
|
||||
|
||||
@@ -3,13 +3,9 @@ from __future__ import annotations
|
||||
from .assets_service import (
|
||||
close_asset_snapshot as close_asset_snapshot,
|
||||
create_asset_snapshot as create_asset_snapshot,
|
||||
create_manual_asset as create_manual_asset,
|
||||
deactivate_manual_asset as deactivate_manual_asset,
|
||||
get_asset_liquidity_report as get_asset_liquidity_report,
|
||||
get_asset_snapshot_report as get_asset_snapshot_report,
|
||||
get_net_worth_timeseries as get_net_worth_timeseries,
|
||||
list_manual_assets as list_manual_assets,
|
||||
preview_asset_snapshot_close as preview_asset_snapshot_close,
|
||||
update_manual_asset as update_manual_asset,
|
||||
)
|
||||
from .accounts_write_services import update_ledger_account as update_ledger_account
|
||||
|
||||
@@ -25,9 +25,15 @@ from .shared import (
|
||||
sqlite3,
|
||||
)
|
||||
|
||||
_RUN_HISTORY_UNAVAILABLE = "run history unavailable"
|
||||
|
||||
|
||||
def _snapshot_run_history_unavailable() -> dict[str, Any]:
|
||||
return fail("unsupported", _RUN_HISTORY_UNAVAILABLE)
|
||||
|
||||
def _valuation_policy() -> dict[str, Any]:
|
||||
return {
|
||||
"authority_sources": ["accounts.last_balance", "manual_assets"],
|
||||
"authority_sources": ["accounts.last_balance"],
|
||||
"snapshot_role": "historical_record_layer",
|
||||
"formula": "asset_buckets - loan",
|
||||
"asset_buckets": ASSET_BUCKETS,
|
||||
@@ -160,64 +166,8 @@ def _account_valuation_items(conn: sqlite3.Connection, basis_date: str = "", sel
|
||||
continue
|
||||
balance = account.get("last_balance")
|
||||
if basis_date:
|
||||
valuation_rows = conn.execute(
|
||||
"SELECT * FROM account_valuations WHERE account_id=? AND UPPER(currency)=? ORDER BY basis_date DESC, recorded_at DESC, id DESC",
|
||||
(account["id"], base["currency"]),
|
||||
).fetchall()
|
||||
leaves = [v for v in valuation_rows if conn.execute("SELECT 1 FROM account_valuations WHERE supersedes_id=?", (v["id"],)).fetchone() is None]
|
||||
if account_type == "investment":
|
||||
# Older imports recorded investment balances as cash_balance;
|
||||
# keep that as a compatibility fallback after the canonical
|
||||
# total_value/balance purposes.
|
||||
purposes = ("total_value", "balance", "cash_balance")
|
||||
elif account_type == "loan":
|
||||
purposes = ("loan_balance", "loan", "balance")
|
||||
else:
|
||||
purposes = ("cash_balance", "balance")
|
||||
selected = None
|
||||
selected_purpose = None
|
||||
candidate_rows = []
|
||||
for purpose in purposes:
|
||||
scoped = [v for v in leaves if v["valuation_kind"] == purpose]
|
||||
if selection_policy == "exact":
|
||||
verified = [v for v in scoped if v["verification_status"] == "verified" and v["basis_date"] == basis_date]
|
||||
else:
|
||||
eligible = [v for v in scoped if v["verification_status"] == "verified" and v["basis_date"] <= basis_date]
|
||||
latest = max((v["basis_date"] for v in eligible), default="")
|
||||
verified = [v for v in eligible if v["basis_date"] == latest]
|
||||
if len(verified) > 1:
|
||||
invalid.append({**base, "reason": "ambiguous_valuation", "valuation_kind": purpose})
|
||||
verified = []
|
||||
break
|
||||
if verified:
|
||||
selected, selected_purpose = verified[0], purpose
|
||||
break
|
||||
candidate_rows.extend(v for v in scoped if v["verification_status"] == "unverified" and v["basis_date"] <= basis_date and (v["basis_date"] == basis_date if selection_policy == "exact" else True))
|
||||
if selected is None:
|
||||
excluded.append({**base, "reason": "missing_verified_exact_valuation" if selection_policy == "exact" else "missing_verified_valuation", "unverified_candidates": [_public_candidate(v) for v in candidate_rows]})
|
||||
continue
|
||||
valuation = selected
|
||||
candidate_rows.extend(
|
||||
v for v in leaves
|
||||
if v["valuation_kind"] == selected_purpose
|
||||
and v["verification_status"] == "unverified"
|
||||
and v["basis_date"] <= basis_date
|
||||
and (v["basis_date"] == basis_date if selection_policy == "exact" else True)
|
||||
)
|
||||
if account_type == "loan" and not valuation["representative_liability_definition"]:
|
||||
excluded.append({
|
||||
**base,
|
||||
"reason": "missing_representative_liability_definition",
|
||||
"valuation_id": valuation["id"],
|
||||
"valuation_kind": selected_purpose,
|
||||
"valuation_date": valuation["basis_date"],
|
||||
"verification_status": valuation["verification_status"],
|
||||
})
|
||||
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, "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]
|
||||
base.update({"valuation_id": "", "verification_status": "unavailable", "valuation_kind": "", "valuation_date": "", "evidence_kind": "account_valuation_missing", "source_missing_warning": "account valuation source unavailable"})
|
||||
balance = 0
|
||||
|
||||
if balance is None:
|
||||
excluded.append({**base, "reason": "missing_last_balance"})
|
||||
@@ -227,49 +177,6 @@ def _account_valuation_items(conn: sqlite3.Connection, basis_date: str = "", sel
|
||||
return included, excluded, invalid
|
||||
|
||||
|
||||
def _manual_asset_valuation_items(conn: sqlite3.Connection, active_only: bool = True, basis_date: str = "", selection_policy: str = "exact") -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
where = "WHERE active=1" if active_only else ""
|
||||
rows = conn.execute(f"SELECT * FROM manual_assets {where} ORDER BY name").fetchall()
|
||||
included: list[dict[str, Any]] = []
|
||||
excluded: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
asset = row_to_dict(row)
|
||||
if (asset.get("currency") or "KRW").upper() != "KRW":
|
||||
excluded.append({"id": asset.get("id", ""), "source_id": asset.get("id", ""), "source_type": "manual_asset", "reason": "non_krw_without_fx", "currency": asset.get("currency")})
|
||||
continue
|
||||
try:
|
||||
bucket = normalize_liquidity_bucket(asset.get("liquidity_bucket"))
|
||||
except ValueError:
|
||||
bucket = "other"
|
||||
base = {
|
||||
"id": asset.get("id", ""),
|
||||
"source_id": asset.get("id", ""),
|
||||
"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:
|
||||
valuation_date = str(asset.get("valuation_date") or "")[:10]
|
||||
if not valuation_date or valuation_date > basis_date or (selection_policy == "exact" and valuation_date != basis_date):
|
||||
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
|
||||
included.append({**base, "amount": int(value), "valuation_amount": int(value)})
|
||||
return included, excluded
|
||||
|
||||
|
||||
def _persisted_evidence_array(value: Any) -> list[dict[str, Any]] | None:
|
||||
try:
|
||||
@@ -283,94 +190,11 @@ def _persisted_evidence_array(value: Any) -> list[dict[str, Any]] | None:
|
||||
|
||||
def _snapshot_rows_for_run(conn: sqlite3.Connection, run_id: str) -> tuple[list[sqlite3.Row], dict[str, Any]]:
|
||||
"""Load one immutable close run and only the rows persisted by that run."""
|
||||
run = conn.execute(
|
||||
"SELECT rowid AS _run_rowid, * FROM asset_snapshot_runs WHERE id=?", (run_id,)
|
||||
).fetchone()
|
||||
if not run:
|
||||
return [], {}
|
||||
basis_date = run["basis_date"]
|
||||
rows = conn.execute(
|
||||
"SELECT rowid AS _rowid, * FROM asset_snapshots WHERE basis_date=? AND snapshot_run_id=? ORDER BY created_at ASC, name ASC",
|
||||
(basis_date, run["id"]),
|
||||
).fetchall()
|
||||
total_rows = conn.execute(
|
||||
"SELECT COUNT(*) FROM asset_snapshots WHERE basis_date=?", (basis_date,)
|
||||
).fetchone()[0]
|
||||
previous_rows = conn.execute(
|
||||
"""SELECT COUNT(*)
|
||||
FROM asset_snapshots AS snapshot
|
||||
LEFT JOIN asset_snapshot_runs AS snapshot_run
|
||||
ON snapshot_run.id=snapshot.snapshot_run_id
|
||||
WHERE snapshot.basis_date=?
|
||||
AND (
|
||||
snapshot.snapshot_run_id IS NULL
|
||||
OR snapshot.snapshot_run_id=''
|
||||
OR snapshot_run.rowid < ?
|
||||
)""",
|
||||
(basis_date, run["_run_rowid"]),
|
||||
).fetchone()[0]
|
||||
evidence = {
|
||||
field: _persisted_evidence_array(run[field])
|
||||
for field in ("applied_evidence", "excluded_evidence", "carried_evidence")
|
||||
}
|
||||
integrity_error = any(value is None for value in evidence.values())
|
||||
metadata = {
|
||||
"snapshot_run_id": run["id"],
|
||||
"draft_id": run["draft_id"] if "draft_id" in run.keys() else "",
|
||||
"draft_version": run["draft_version"] if "draft_version" in run.keys() else 0,
|
||||
"review_fingerprint": run["review_fingerprint"] if "review_fingerprint" in run.keys() else "",
|
||||
"is_correction": bool(run["correction_of_run_id"]),
|
||||
"correction_of_run_id": run["correction_of_run_id"] or "",
|
||||
"correction_reason": run["correction_reason"] or "",
|
||||
"close_status": run["close_status"],
|
||||
"selection_policy": run["selection_policy"],
|
||||
"approved_by": run["approved_by"],
|
||||
"approved_at": run["approved_at"],
|
||||
"approval_reason": run["approval_reason"],
|
||||
"approval_reference": run["approval_reference"],
|
||||
"applied_evidence": evidence["applied_evidence"] or [],
|
||||
"excluded_evidence": evidence["excluded_evidence"] or [],
|
||||
"carried_evidence": evidence["carried_evidence"] or [],
|
||||
"evidence_integrity_error": integrity_error,
|
||||
"closed_at": run["created_at"] or "",
|
||||
"previous_snapshot_row_count": previous_rows,
|
||||
"total_snapshot_rows_for_basis_date": total_rows,
|
||||
}
|
||||
return rows, metadata
|
||||
return [], {"run_history_unavailable": True}
|
||||
|
||||
|
||||
def _snapshot_rows_for_latest_run(conn: sqlite3.Connection, basis_date: str) -> tuple[list[sqlite3.Row], dict[str, Any]]:
|
||||
rows = conn.execute("SELECT rowid AS _rowid, * FROM asset_snapshots WHERE basis_date=? ORDER BY created_at ASC, name ASC", (basis_date,)).fetchall()
|
||||
run = conn.execute("SELECT * FROM asset_snapshot_runs WHERE basis_date=? ORDER BY rowid DESC LIMIT 1", (basis_date,)).fetchone()
|
||||
if not run:
|
||||
legacy_run_rows = [row for row in rows if row["closed_at"] and row["snapshot_run_id"]]
|
||||
if legacy_run_rows:
|
||||
# Schema 11 persisted run identity only on the snapshot rows. Keep
|
||||
# its rowid-based latest-run rule rather than falling back to live
|
||||
# authority merely because schema 12 has no matching run record.
|
||||
latest = max(
|
||||
legacy_run_rows,
|
||||
key=lambda row: (
|
||||
int(row["_rowid"]),
|
||||
row["closed_at"] or row["created_at"] or "",
|
||||
row["snapshot_run_id"],
|
||||
),
|
||||
)
|
||||
latest_run_id = latest["snapshot_run_id"]
|
||||
latest_rows = [row for row in legacy_run_rows if row["snapshot_run_id"] == latest_run_id]
|
||||
return latest_rows, {
|
||||
"snapshot_run_id": latest_run_id,
|
||||
"legacy_closed": True,
|
||||
"is_correction": bool(latest["is_correction"]),
|
||||
"correction_of_run_id": latest["correction_of_run_id"] or "",
|
||||
"correction_reason": latest["correction_reason"] or "",
|
||||
"close_status": "legacy_read_only",
|
||||
"selection_policy": latest["selection_policy"] or "exact",
|
||||
"closed_at": latest["closed_at"] or latest["created_at"] or "",
|
||||
"total_snapshot_rows_for_basis_date": len(rows),
|
||||
}
|
||||
return rows, {"snapshot_run_id": "", "is_correction": False, "correction_of_run_id": "", "correction_reason": "", "total_snapshot_rows_for_basis_date": len(rows)} if rows else {}
|
||||
return _snapshot_rows_for_run(conn, run["id"])
|
||||
return [], {"run_history_unavailable": True}
|
||||
|
||||
|
||||
def _snapshot_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str, Any]]:
|
||||
@@ -396,7 +220,7 @@ def _snapshot_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str,
|
||||
"id": snap.get("id", ""),
|
||||
"source_id": snap.get("source_id", ""),
|
||||
"source_type": snap.get("source_type", ""),
|
||||
"authority_source": "asset_snapshots",
|
||||
"authority_source": "account_snapshot",
|
||||
"valuation_source": snap.get("valuation_source", ""),
|
||||
"name": snap.get("name", ""),
|
||||
"currency": snap.get("currency", ""),
|
||||
@@ -493,6 +317,8 @@ def _ok_with_provenance_aliases(**payload: Any) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _closed_warnings(metadata: dict[str, Any], basis_date: str, rows: list[sqlite3.Row]) -> list[dict[str, Any]]:
|
||||
if isinstance(metadata.get("frozen_warnings"), list):
|
||||
return metadata["frozen_warnings"]
|
||||
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", []):
|
||||
@@ -536,8 +362,7 @@ def _summarize_valuation_items(included_items: list[dict[str, Any]], excluded_it
|
||||
"liquidity_bucket": bucket,
|
||||
"amount": amount,
|
||||
"account_count": sum(1 for item in included_items if item.get("source_type") == "account" and item.get("liquidity_bucket") == bucket),
|
||||
"manual_asset_count": sum(1 for item in included_items if item.get("source_type") == "manual_asset" and item.get("liquidity_bucket") == bucket),
|
||||
"item_count": bucket_counts[bucket],
|
||||
"item_count": bucket_counts[bucket],
|
||||
"asset_share": round(amount / total_assets, 4) if bucket not in LIABILITY_BUCKETS and total_assets else None,
|
||||
"liability_share": round(abs(amount) / total_liabilities, 4) if bucket in LIABILITY_BUCKETS and total_liabilities else None,
|
||||
"share": round(abs(amount) / denominator, 4) if denominator else None,
|
||||
@@ -555,102 +380,29 @@ def _summarize_valuation_items(included_items: list[dict[str, Any]], excluded_it
|
||||
}
|
||||
|
||||
|
||||
def create_manual_asset(name: str, valuation_amount: int | None, valuation_date: str, liquidity_bucket: str, source_note: str = "", memo: str = "", currency: str = "KRW", active: bool = True, db_path: str | None = None) -> dict[str, Any]:
|
||||
if not name.strip():
|
||||
return fail("validation_error", "manual asset name is required")
|
||||
try:
|
||||
bucket = normalize_liquidity_bucket(liquidity_bucket)
|
||||
amount = int(valuation_amount) if valuation_amount is not None else None
|
||||
vdate = normalize_date(valuation_date, default_today=True)
|
||||
except Exception as e:
|
||||
return fail("validation_error", str(e))
|
||||
ts = now_iso()
|
||||
asset_id = new_id("asset")
|
||||
with db(db_path) as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO manual_assets(id,name,liquidity_bucket,valuation_amount,valuation_date,currency,source_note,memo,active,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(asset_id, name.strip(), bucket, amount, vdate, currency, source_note, memo, int(active), ts, ts),
|
||||
)
|
||||
row = conn.execute("SELECT * FROM manual_assets WHERE id=?", (asset_id,)).fetchone()
|
||||
return ok(manual_asset={**row_to_dict(row), "authority_source": "manual_assets"})
|
||||
|
||||
def create_manual_asset(*args, **kwargs):
|
||||
return fail("unsupported", "manual assets are no longer supported")
|
||||
|
||||
|
||||
@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 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())
|
||||
def list_manual_assets(*args, **kwargs):
|
||||
return fail("unsupported", "manual assets are no longer supported")
|
||||
|
||||
|
||||
def update_manual_asset(asset_id: str, name: str | None = None, valuation_amount: int | None = None, valuation_date: str | None = None, liquidity_bucket: str | None = None, source_note: str | None = None, memo: str | None = None, currency: str | None = None, active: bool | None = None, db_path: str | None = None) -> dict[str, Any]:
|
||||
allowed_updates: list[str] = []
|
||||
params: list[Any] = []
|
||||
try:
|
||||
if name is not None:
|
||||
if not name.strip():
|
||||
return fail("validation_error", "manual asset name is required")
|
||||
allowed_updates.append("name=?")
|
||||
params.append(name.strip())
|
||||
if valuation_amount is not None:
|
||||
allowed_updates.append("valuation_amount=?")
|
||||
params.append(int(valuation_amount))
|
||||
if valuation_date is not None:
|
||||
allowed_updates.append("valuation_date=?")
|
||||
params.append(normalize_date(valuation_date, default_today=True))
|
||||
if liquidity_bucket is not None:
|
||||
allowed_updates.append("liquidity_bucket=?")
|
||||
params.append(normalize_liquidity_bucket(liquidity_bucket))
|
||||
if source_note is not None:
|
||||
allowed_updates.append("source_note=?")
|
||||
params.append(source_note)
|
||||
if memo is not None:
|
||||
allowed_updates.append("memo=?")
|
||||
params.append(memo)
|
||||
if currency is not None:
|
||||
allowed_updates.append("currency=?")
|
||||
params.append(currency or "KRW")
|
||||
if active is not None:
|
||||
allowed_updates.append("active=?")
|
||||
params.append(int(bool(active)))
|
||||
except Exception as e:
|
||||
return fail("validation_error", str(e))
|
||||
if not allowed_updates:
|
||||
return fail("validation_error", "no fields to update")
|
||||
allowed_updates.append("updated_at=?")
|
||||
params.extend([now_iso(), asset_id])
|
||||
with db(db_path) as conn:
|
||||
cur = conn.execute(f"UPDATE manual_assets SET {', '.join(allowed_updates)} WHERE id=?", params)
|
||||
if cur.rowcount == 0:
|
||||
return fail("not_found", "manual asset not found")
|
||||
row = conn.execute("SELECT * FROM manual_assets WHERE id=?", (asset_id,)).fetchone()
|
||||
return ok(manual_asset={**row_to_dict(row), "authority_source": "manual_assets"})
|
||||
def update_manual_asset(*args, **kwargs):
|
||||
return fail("unsupported", "manual assets are no longer supported")
|
||||
|
||||
|
||||
def deactivate_manual_asset(asset_id: str, hard_delete: bool = False, db_path: str | None = None) -> dict[str, Any]:
|
||||
with db(db_path) as conn:
|
||||
if hard_delete:
|
||||
cur = conn.execute("DELETE FROM manual_assets WHERE id=?", (asset_id,))
|
||||
else:
|
||||
cur = conn.execute("UPDATE manual_assets SET active=0, updated_at=? WHERE id=?", (now_iso(), asset_id))
|
||||
if cur.rowcount == 0:
|
||||
return fail("not_found", "manual asset not found")
|
||||
row = None if hard_delete else conn.execute("SELECT * FROM manual_assets WHERE id=?", (asset_id,)).fetchone()
|
||||
payload = {"manual_asset_id": asset_id, "deactivated": True, "hard_delete": hard_delete}
|
||||
if row is not None:
|
||||
payload["manual_asset"] = {**row_to_dict(row), "authority_source": "manual_assets"}
|
||||
return ok(**payload)
|
||||
def deactivate_manual_asset(*args, **kwargs):
|
||||
return fail("unsupported", "manual assets are no longer supported")
|
||||
|
||||
|
||||
def _snapshot_preview_from_conn(conn: sqlite3.Connection, basis: str, selection_policy: str = "exact") -> dict[str, Any]:
|
||||
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
|
||||
included = account_items
|
||||
excluded = account_excluded
|
||||
summary = _summarize_valuation_items(included, excluded)
|
||||
existing_rows = conn.execute("SELECT COUNT(*) FROM asset_snapshots WHERE basis_date=?", (basis,)).fetchone()[0]
|
||||
existing_rows = conn.execute("SELECT COUNT(*) FROM account_snapshot WHERE basis_date=?", (basis,)).fetchone()[0]
|
||||
warnings: list[dict[str, Any]] = []
|
||||
for item in excluded:
|
||||
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")})
|
||||
@@ -673,16 +425,8 @@ def _snapshot_preview_from_conn(conn: sqlite3.Connection, basis: str, selection_
|
||||
for candidate in warning.get("candidates", [])
|
||||
if candidate.get("id")
|
||||
})
|
||||
# The removed valuation table is never queried, including for preview hashes.
|
||||
candidate_state_hashes = []
|
||||
for candidate_id in candidate_ids:
|
||||
candidate_row = conn.execute(
|
||||
"SELECT * FROM account_valuations WHERE id=?", (candidate_id,),
|
||||
).fetchone()
|
||||
if candidate_row is not None:
|
||||
candidate_state_hashes.append({
|
||||
"id": candidate_id,
|
||||
"state_hash": _valuation_row_state_hash(candidate_row),
|
||||
})
|
||||
public_excluded = [_public_evidence(item) for item in excluded]
|
||||
return {
|
||||
"basis_date": basis,
|
||||
@@ -728,6 +472,7 @@ def preview_asset_snapshot_close(basis_date: str = "", db_path: str | None = Non
|
||||
|
||||
|
||||
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]:
|
||||
return _snapshot_run_history_unavailable()
|
||||
if selection_policy not in {"exact", "as_of"}:
|
||||
return fail("validation_error", "selection_policy must be exact or as_of")
|
||||
try:
|
||||
@@ -738,6 +483,12 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
|
||||
run_id = new_id("snaprun")
|
||||
with db(db_path) as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
month_row = conn.execute("SELECT id FROM monthly_settlement WHERE month=?", (basis[:7],)).fetchone()
|
||||
if month_row:
|
||||
monthly_settlement_id = month_row["id"]
|
||||
else:
|
||||
monthly_settlement_id = new_id("month")
|
||||
conn.execute("INSERT INTO monthly_settlement(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) VALUES(?,?,?,?,?,?,?,?,?)", (monthly_settlement_id,basis[:7],basis,"draft",1,approved_by or "asset-close",ts,approved_by or "asset-close",ts))
|
||||
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)
|
||||
@@ -867,7 +618,7 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
|
||||
row_note = source_note or item.get("source_note", "") or "month-end close"
|
||||
row_memo = memo or item.get("memo", "")
|
||||
conn.execute(
|
||||
"""INSERT INTO asset_snapshots(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,valuation_source,source_note,memo,snapshot_run_id,correction_of_run_id,correction_reason,is_correction,closed_at,valuation_id,selection_policy,verification_status,evidence_kind,exclusion_reason,created_at)
|
||||
"""INSERT INTO account_snapshot(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,valuation_source,source_note,memo,monthly_settlement_id,correction_of_run_id,correction_reason,is_correction,closed_at,valuation_id,selection_policy,verification_status,evidence_kind,exclusion_reason,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
snapshot_id,
|
||||
@@ -881,7 +632,7 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
|
||||
item.get("authority_source", item.get("source_type", "")),
|
||||
row_note,
|
||||
row_memo,
|
||||
run_id,
|
||||
monthly_settlement_id,
|
||||
correction_of_run_id,
|
||||
correction_reason.strip(),
|
||||
int(is_correction),
|
||||
@@ -894,6 +645,10 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
|
||||
ts,
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE account_snapshot SET monthly_settlement_id=? WHERE id=? AND monthly_settlement_id IS NULL",
|
||||
(monthly_settlement_id, snapshot_id),
|
||||
)
|
||||
snapshot_ids.append(snapshot_id)
|
||||
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", "last_synced_at", "currency", "valuation_id", "valuation_kind", "valuation_date", "age_days", "carried_forward", "verification_status", "evidence_kind", "unverified_candidates") 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", [])]
|
||||
@@ -921,6 +676,8 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
|
||||
except sqlite3.IntegrityError:
|
||||
conn.rollback()
|
||||
return fail("snapshot_conflict", "another snapshot close already owns this basis date", basis_date=basis)
|
||||
if "source_snapshot_json" in {r["name"] for r in conn.execute("PRAGMA table_info(asset_snapshot_runs)") }:
|
||||
conn.execute("UPDATE asset_snapshot_runs SET source_snapshot_json=? WHERE id=?", (json.dumps({"snapshot_row_ids": snapshot_ids, "frozen_warnings": preview["warnings"]}, ensure_ascii=False, sort_keys=True), run_id))
|
||||
approval = {
|
||||
"approved_by": approved_by,
|
||||
"approved_at": ts if approved_by else "",
|
||||
@@ -970,8 +727,8 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
|
||||
|
||||
|
||||
def create_asset_snapshot(basis_date: str, source_type: str, source_id: str, name: str, amount: int | None, liquidity_bucket: str, valuation_source: str = "", source_note: str = "", memo: str = "", currency: str = "KRW", db_path: str | None = None) -> dict[str, Any]:
|
||||
if source_type not in {"account", "manual_asset"}:
|
||||
return fail("validation_error", "source_type must be one of ['account', 'manual_asset']")
|
||||
if source_type != "account":
|
||||
return fail("unsupported", "manual asset snapshots are no longer supported")
|
||||
if not name.strip():
|
||||
return fail("validation_error", "snapshot item name is required")
|
||||
try:
|
||||
@@ -984,16 +741,17 @@ def create_asset_snapshot(basis_date: str, source_type: str, source_id: str, nam
|
||||
snapshot_id = new_id("snap")
|
||||
with db(db_path) as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO asset_snapshots(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,valuation_source,source_note,memo,created_at)
|
||||
"""INSERT INTO account_snapshot(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,valuation_source,source_note,memo,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(snapshot_id, basis, source_type, source_id, name.strip(), amount_int, currency, bucket, valuation_source or source_type, source_note, memo, ts),
|
||||
)
|
||||
row = conn.execute("SELECT * FROM asset_snapshots WHERE id=?", (snapshot_id,)).fetchone()
|
||||
row = conn.execute("SELECT * FROM account_snapshot WHERE id=?", (snapshot_id,)).fetchone()
|
||||
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]:
|
||||
return _snapshot_run_history_unavailable()
|
||||
if selection_policy not in {"exact", "as_of"}:
|
||||
return fail("validation_error", "selection_policy must be exact or as_of")
|
||||
try:
|
||||
@@ -1017,7 +775,7 @@ def get_asset_snapshot_report(basis_date: str = "", db_path: str | None = None,
|
||||
evidence_provenance = _evidence_provenance(close_metadata.get("applied_evidence", []), basis)
|
||||
return _ok_with_provenance_aliases(
|
||||
basis_date=basis,
|
||||
source="legacy_asset_snapshots" if legacy_closed else "asset_snapshots",
|
||||
source="legacy_asset_snapshots" if legacy_closed else "account_snapshot",
|
||||
is_closed_snapshot=True,
|
||||
snapshot_run_id=close_metadata.get("snapshot_run_id", ""),
|
||||
snapshot_count=len(snapshots),
|
||||
@@ -1091,6 +849,7 @@ def get_asset_snapshot_report(basis_date: str = "", db_path: str | None = None,
|
||||
|
||||
@read_operation
|
||||
def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: int = 12, db_path: str | None = None) -> dict[str, Any]:
|
||||
return ok(timeseries=[], warnings=[{"code": "run_history_unavailable", "message": _RUN_HISTORY_UNAVAILABLE}])
|
||||
try:
|
||||
months_int = max(1, min(int(months), 120))
|
||||
end_norm = normalize_date(end_date, default_today=True) if end_date else current_kst_date().isoformat()
|
||||
@@ -1106,7 +865,7 @@ def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: i
|
||||
return fail("validation_error", str(e))
|
||||
with read_db(db_path) as conn:
|
||||
basis_rows = conn.execute(
|
||||
"""SELECT basis_date FROM asset_snapshots
|
||||
"""SELECT basis_date FROM account_snapshot
|
||||
WHERE basis_date>=? AND basis_date<=?
|
||||
UNION
|
||||
SELECT basis_date FROM asset_snapshot_runs
|
||||
@@ -1122,7 +881,7 @@ def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: i
|
||||
return fail("data_integrity_error", "snapshot evidence is malformed", snapshot_run_id=metadata.get("snapshot_run_id", ""))
|
||||
items = _snapshot_items(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_public["warnings"] = _closed_warnings(metadata, basis, conn.execute("SELECT rowid AS _rowid, * FROM account_snapshot 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(
|
||||
@@ -1131,7 +890,7 @@ def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: i
|
||||
"total_assets": summary["total_assets"],
|
||||
"total_liabilities": summary["total_liabilities"],
|
||||
"net_worth": summary["net_worth"],
|
||||
"source": "asset_snapshots",
|
||||
"source": "account_snapshot",
|
||||
"item_count": len(items),
|
||||
"snapshot_run_id": metadata.get("snapshot_run_id", ""),
|
||||
"draft_id": run_public.get("draft_id", ""),
|
||||
@@ -1177,16 +936,15 @@ def get_asset_liquidity_report(db_path: str | None = None) -> dict[str, Any]:
|
||||
"""
|
||||
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
|
||||
excluded = account_excluded + manual_excluded
|
||||
included = account_items
|
||||
excluded = account_excluded
|
||||
summary = _summarize_valuation_items(included, excluded)
|
||||
source_accounts = [item for item in included if item.get("source_type") == "account"]
|
||||
source = "accounts.last_balance" if not manual_items and not manual_excluded else "accounts.last_balance+manual_assets"
|
||||
source = "accounts.last_balance"
|
||||
return ok(
|
||||
buckets=summary["buckets"],
|
||||
accounts=source_accounts,
|
||||
manual_assets=manual_items,
|
||||
manual_assets=[],
|
||||
total_assets=summary["total_assets"],
|
||||
total_liabilities=summary["total_liabilities"],
|
||||
net_worth=summary["net_worth"],
|
||||
@@ -1196,7 +954,7 @@ def get_asset_liquidity_report(db_path: str | None = None) -> dict[str, Any]:
|
||||
],
|
||||
missing_valuation_items=excluded,
|
||||
invalid_bucket_accounts=invalid_bucket_accounts,
|
||||
manual_asset_count=len(manual_items),
|
||||
manual_asset_count=0,
|
||||
included_items=included,
|
||||
excluded_items=excluded,
|
||||
valuation_policy=summary["valuation_policy"],
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from uuid import uuid4
|
||||
|
||||
from .shared import read_db, DatabaseNotInitializedError
|
||||
from .shared import read_db, db, DatabaseNotInitializedError, now_iso
|
||||
|
||||
from .assets_service import get_asset_snapshot_report
|
||||
from .imports_service import list_ledger_import_batches
|
||||
@@ -16,6 +18,430 @@ def _month_end_date(month: str) -> str:
|
||||
return normalize_snapshot_at(month, default_now=False)[:10]
|
||||
|
||||
|
||||
def _cashflow_fingerprint(report: dict[str, Any]) -> str:
|
||||
payload = {
|
||||
"categories": [{k: item.get(k) for k in ("cashflow_kind", "category", "subcategory", "amount", "count", "currency")} for item in report.get("categories", [])],
|
||||
"source_transactions": report.get("source_transactions", []),
|
||||
}
|
||||
return hashlib.sha256(json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||
|
||||
|
||||
def _review_cashflow(month: str, db_path: str | None = None, conn=None) -> dict[str, Any]:
|
||||
if conn is None:
|
||||
return get_monthly_cashflow_report(month, db_path=db_path)
|
||||
from .transaction_report_services import _month_range, CASHFLOW_KIND_SQL
|
||||
start, end = _month_range(month)
|
||||
rows = conn.execute(f"SELECT {CASHFLOW_KIND_SQL} AS cashflow_kind, t.category, t.subcategory, t.currency, SUM(t.amount) AS amount, COUNT(*) AS count 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 t.transaction_date>=? AND t.transaction_date<? GROUP BY {CASHFLOW_KIND_SQL}, t.category, t.subcategory, t.currency ORDER BY cashflow_kind, amount DESC", (start, end)).fetchall()
|
||||
categories = [dict(r) for r in rows]
|
||||
source_rows = conn.execute(f"SELECT t.id, t.transaction_date, t.type, t.amount, t.currency, t.category, t.subcategory, t.account_id 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 t.transaction_date>=? AND t.transaction_date<? ORDER BY t.id", (start, end)).fetchall()
|
||||
totals = {}
|
||||
for item in categories: totals[item['cashflow_kind']] = totals.get(item['cashflow_kind'], 0) + int(item['amount'] or 0)
|
||||
income, expenses = totals.get('income', 0), totals.get('expense', 0)
|
||||
return ok(month=month, income=income, expenses=expenses, net_cashflow=income-expenses, by_cashflow_kind=totals, categories=categories, source_transactions=[dict(r) for r in source_rows])
|
||||
|
||||
|
||||
def review_monthly_settlement(settlement_id: str, db_path: str | None = None) -> dict[str, Any]:
|
||||
try:
|
||||
with read_db(db_path) as conn:
|
||||
row = conn.execute("SELECT id,month,status,version FROM monthly_settlement WHERE id=?", (settlement_id,)).fetchone()
|
||||
if not row:
|
||||
return fail("not_found", "monthly settlement not found")
|
||||
if row["status"] not in {"draft", "in_review"}:
|
||||
return fail("invalid_settlement_status", "only draft or in_review settlements can be reviewed")
|
||||
report = _review_cashflow(row["month"], conn=conn)
|
||||
fp = _cashflow_fingerprint(report)
|
||||
return ok(settlement_id=settlement_id, month=row["month"], version=row["version"], status=row["status"], cashflow=report, review_fingerprint=fp)
|
||||
except Exception:
|
||||
return fail("operation_failed", "monthly settlement review unavailable")
|
||||
|
||||
|
||||
def approve_monthly_settlement(settlement_id: str, expected_version: int, review_fingerprint: str, approved_by: str = "dashboard-admin", db_path: str | None = None) -> dict[str, Any]:
|
||||
if not review_fingerprint:
|
||||
return fail("validation_error", "review_fingerprint is required")
|
||||
try:
|
||||
with db(db_path) as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
row = conn.execute("SELECT * FROM monthly_settlement WHERE id=?", (settlement_id,)).fetchone()
|
||||
if not row: return fail("not_found", "monthly settlement not found")
|
||||
if row["status"] not in {"draft", "in_review"} or row["version"] != expected_version:
|
||||
return fail("stale_settlement", "settlement status or version is stale")
|
||||
report = _review_cashflow(row["month"], conn=conn)
|
||||
actual = _cashflow_fingerprint(report)
|
||||
if actual != review_fingerprint:
|
||||
return fail("stale_review", "review fingerprint is stale", current_fingerprint=actual)
|
||||
ts = now_iso()
|
||||
conn.execute("UPDATE monthly_settlement SET status='approved', approved_by=?, approved_at=?, approved_version=version, approved_fingerprint=COALESCE(review_fingerprint,?), approved_cashflow_fingerprint=?, updated_by=?, updated_at=? WHERE id=? AND version=?", (approved_by, ts, review_fingerprint, actual, approved_by, ts, settlement_id, expected_version))
|
||||
conn.execute("UPDATE monthly_settlement SET review_fingerprint=? WHERE id=?", (review_fingerprint, settlement_id))
|
||||
return ok(settlement_id=settlement_id, status="approved", version=expected_version, approved_cashflow_fingerprint=actual)
|
||||
except Exception:
|
||||
return fail("operation_failed", "monthly settlement approval failed")
|
||||
|
||||
|
||||
def cancel_monthly_settlement_approval(settlement_id: str, expected_version: int, approved_by: str = "dashboard-admin", db_path: str | None = None) -> dict[str, Any]:
|
||||
"""Return an approved settlement to review without changing its source data."""
|
||||
if not isinstance(expected_version, int) or isinstance(expected_version, bool) or expected_version < 1:
|
||||
return fail("validation_error", "expected_version is required")
|
||||
try:
|
||||
with db(db_path) as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
row = conn.execute("SELECT id, status, version FROM monthly_settlement WHERE id=?", (settlement_id,)).fetchone()
|
||||
if not row:
|
||||
return fail("not_found", "monthly settlement not found")
|
||||
if row["status"] != "approved" or row["version"] != expected_version:
|
||||
return fail("stale_settlement", "only the current approved settlement can have approval cancelled")
|
||||
ts = now_iso()
|
||||
cur = conn.execute("""UPDATE monthly_settlement
|
||||
SET status='in_review', version=version+1,
|
||||
approved_by=NULL, approved_at=NULL, approved_version=NULL,
|
||||
approved_fingerprint=NULL, approved_cashflow_fingerprint=NULL,
|
||||
approval_reason=NULL, approval_flags_json='{}',
|
||||
review_fingerprint=NULL, updated_by=?, updated_at=?
|
||||
WHERE id=? AND status='approved' AND version=?""", (approved_by, ts, settlement_id, expected_version))
|
||||
if cur.rowcount != 1:
|
||||
return fail("stale_settlement", "settlement status or version is stale")
|
||||
return ok(settlement_id=settlement_id, status="in_review", version=expected_version + 1)
|
||||
except Exception:
|
||||
return fail("operation_failed", "monthly settlement approval cancellation failed")
|
||||
|
||||
|
||||
def close_monthly_settlement(settlement_id: str, expected_version: int, closed_by: str = "dashboard-admin", db_path: str | None = None) -> dict[str, Any]:
|
||||
try:
|
||||
with db(db_path) as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
row = conn.execute("SELECT * FROM monthly_settlement WHERE id=?", (settlement_id,)).fetchone()
|
||||
if not row: return fail("not_found", "monthly settlement not found")
|
||||
if row["status"] == "closed": return ok(settlement_id=settlement_id, status="closed", version=row["version"], idempotent=True)
|
||||
if row["status"] != "approved" or row["version"] != expected_version:
|
||||
return fail("stale_settlement", "settlement status or version is stale")
|
||||
if not row["approved_cashflow_fingerprint"]: return fail("missing_fingerprint", "approved cashflow fingerprint is missing")
|
||||
report = _review_cashflow(row["month"], conn=conn)
|
||||
actual = _cashflow_fingerprint(report)
|
||||
if actual != row["approved_cashflow_fingerprint"]: return fail("stale_cashflow", "cashflow changed after approval", current_fingerprint=actual)
|
||||
conn.execute("DELETE FROM settlement_cashflow_snapshots WHERE monthly_settlement_id=?", (settlement_id,))
|
||||
items = report.get("categories", [])
|
||||
for item in items:
|
||||
conn.execute("INSERT INTO settlement_cashflow_snapshots(id,monthly_settlement_id,cashflow_kind,category,subcategory,amount,transaction_count,currency) VALUES(?,?,?,?,?,?,?,?)", (uuid4().hex, settlement_id, item.get("cashflow_kind") or "excluded", item.get("category") or "", item.get("subcategory") or "", int(item.get("amount") or 0), max(1, int(item.get("count") or 0)), item.get("currency") or ""))
|
||||
ts = now_iso()
|
||||
conn.execute("UPDATE monthly_settlement SET status='closed', closed_by=?, closed_at=?, updated_by=?, updated_at=? WHERE id=? AND status='approved' AND version=?", (closed_by, ts, closed_by, ts, settlement_id, expected_version))
|
||||
return ok(settlement_id=settlement_id, status="closed", version=expected_version, snapshot_count=len(items), approved_cashflow_fingerprint=row["approved_cashflow_fingerprint"])
|
||||
except Exception:
|
||||
return fail("operation_failed", "monthly settlement close failed")
|
||||
|
||||
|
||||
def create_monthly_settlement(month: str, created_by: str = "dashboard-admin", db_path: str | None = None) -> dict[str, Any]:
|
||||
"""Create only the canonical monthly-settlement parent draft."""
|
||||
if not isinstance(month, str) or not __import__("re").fullmatch(r"\d{4}-\d{2}", month):
|
||||
return fail("validation_error", "month must be YYYY-MM")
|
||||
try:
|
||||
selected = normalize_month(month)
|
||||
basis = _month_end_date(selected)
|
||||
ts = now_iso()
|
||||
with db(db_path) as conn:
|
||||
existing = conn.execute("SELECT id FROM monthly_settlement WHERE month=?", (selected,)).fetchone()
|
||||
if existing:
|
||||
return fail("duplicate_month", "monthly settlement already exists", settlement_id=existing["id"], month=selected)
|
||||
settlement_id = uuid4().hex
|
||||
conn.execute(
|
||||
"INSERT INTO monthly_settlement(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) VALUES(?,?,?,?,?,?,?,?,?)",
|
||||
(settlement_id, selected, basis, "draft", 1, created_by, ts, created_by, ts),
|
||||
)
|
||||
return ok(settlement_id=settlement_id, month=selected, status="draft", created=True)
|
||||
except DatabaseNotInitializedError:
|
||||
return fail("db_not_initialized", "ledger database is not initialized")
|
||||
except Exception as exc:
|
||||
if "UNIQUE" in str(exc).upper():
|
||||
return fail("duplicate_month", "monthly settlement already exists", month=month)
|
||||
return fail("operation_failed", "monthly settlement creation failed")
|
||||
|
||||
|
||||
def list_monthly_settlement_overview(year: int | None = None, status: str = "", db_path: str | None = None) -> dict[str, Any]:
|
||||
"""List monthly-settlement parent rows without consulting removed run history."""
|
||||
try:
|
||||
with read_db(db_path) as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
WITH live AS (
|
||||
SELECT substr(transaction_date, 1, 7) AS month,
|
||||
SUM(CASE WHEN type IN ('income','credit') THEN amount ELSE 0 END) AS income,
|
||||
SUM(CASE WHEN type IN ('expense','debit') THEN amount ELSE 0 END) AS expense
|
||||
FROM transactions
|
||||
GROUP BY substr(transaction_date, 1, 7)
|
||||
)
|
||||
SELECT m.month, m.id AS settlement_id, m.basis_date, m.status AS settlement_status,
|
||||
m.version AS settlement_version, m.created_at, m.updated_at,
|
||||
l.income AS live_income, l.expense AS live_expense
|
||||
FROM monthly_settlement m
|
||||
LEFT JOIN live l ON l.month = m.month
|
||||
WHERE (? IS NULL OR ? = '' OR substr(m.month, 1, 4) = ?)
|
||||
AND (? = '' OR m.status = ?)
|
||||
ORDER BY m.month DESC
|
||||
""",
|
||||
(str(year) if year is not None else None, str(year) if year is not None else '', str(year) if year is not None else '', status or '', status or ''),
|
||||
).fetchall()
|
||||
result = []
|
||||
for row in rows:
|
||||
d = dict(row)
|
||||
parent_exists = bool(d.get("settlement_id"))
|
||||
lifecycle = d.get("settlement_status") or "unregistered"
|
||||
label = {
|
||||
"closed": "마감완료",
|
||||
"approved": "승인완료",
|
||||
"in_review": "검토중",
|
||||
"draft": "초안",
|
||||
"cancelled": "취소",
|
||||
"unregistered": "정산 미생성·상세 선택 불가·현재집계(미확정)",
|
||||
}.get(lifecycle, lifecycle)
|
||||
d.update(
|
||||
status_label=label,
|
||||
amounts_are_snapshot=False,
|
||||
income=d.get("live_income") or 0,
|
||||
expense=d.get("live_expense") or 0,
|
||||
income_display=d.get("live_income") or 0,
|
||||
expense_display=d.get("live_expense") or 0,
|
||||
amount_confirmation="미확정",
|
||||
net_cashflow_display=None,
|
||||
net_worth_display=None,
|
||||
run_id=None,
|
||||
run_version=None,
|
||||
close_status=None,
|
||||
closed_at=None,
|
||||
income_total=None,
|
||||
expense_total=None,
|
||||
net_cashflow=None,
|
||||
net_worth=None,
|
||||
correction_active=False,
|
||||
draft_active=parent_exists,
|
||||
)
|
||||
result.append(d)
|
||||
return ok(months=result, read_only=True)
|
||||
except DatabaseNotInitializedError:
|
||||
return fail("db_not_initialized", "ledger database is not initialized")
|
||||
except Exception:
|
||||
return fail("operation_failed", "monthly settlement overview unavailable")
|
||||
|
||||
|
||||
def get_monthly_settlement_record(settlement_id: str, db_path: str | None = None) -> dict[str, Any]:
|
||||
"""Read one monthly-settlement parent by canonical ID."""
|
||||
if not isinstance(settlement_id, str) or not settlement_id.strip():
|
||||
return fail("validation_error", "settlement_id is required")
|
||||
try:
|
||||
with read_db(db_path) as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, month, basis_date, status, version, created_at, updated_at "
|
||||
"FROM monthly_settlement WHERE id = ?", (settlement_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return fail("not_found", "monthly settlement not found")
|
||||
return ok(settlement=dict(row), read_only=True)
|
||||
except DatabaseNotInitializedError:
|
||||
return fail("db_not_initialized", "ledger database is not initialized")
|
||||
except Exception:
|
||||
return fail("operation_failed", "monthly settlement unavailable")
|
||||
|
||||
|
||||
def _ledger_candidates(conn, account_id: str, currency: str, basis_date: str) -> dict[str, Any]:
|
||||
base = """SELECT t.id,t.transaction_date,t.transaction_datetime,t.created_at,t.balance_after,t.currency,t.account_id
|
||||
FROM transactions t
|
||||
WHERE t.account_id=? AND t.currency=? AND t.status='active'
|
||||
AND t.balance_after IS NOT NULL AND t.transaction_date {op} ?
|
||||
ORDER BY t.transaction_date {direction}, t.transaction_datetime {direction}, t.created_at {direction}, t.id {direction} LIMIT 1"""
|
||||
prev = conn.execute(base.format(op="<=", direction="DESC"), (account_id, currency, basis_date)).fetchone()
|
||||
nxt = conn.execute(base.format(op=">", direction="ASC"), (account_id, currency, basis_date)).fetchone()
|
||||
return {"previous_ledger": dict(prev) if prev else None, "next_ledger": dict(nxt) if nxt else None}
|
||||
|
||||
|
||||
def list_account_snapshots_for_settlement(settlement_id: str, db_path: str | None = None) -> dict[str, Any]:
|
||||
"""Return the account evaluation rows for a settlement without inventing values.
|
||||
|
||||
Draft/review/approved/cancelled settlements use the complete registered-account
|
||||
set and exact monthly snapshot matches. Closed settlements are historical:
|
||||
they read only rows stored against that settlement, so later accounts cannot
|
||||
leak into the report. Duplicate snapshot rows are deliberately preserved.
|
||||
"""
|
||||
if not isinstance(settlement_id, str) or not settlement_id.strip():
|
||||
return fail("validation_error", "settlement_id is required")
|
||||
try:
|
||||
with read_db(db_path) as conn:
|
||||
settlement = conn.execute(
|
||||
"SELECT status, basis_date FROM monthly_settlement WHERE id=?", (settlement_id,)
|
||||
).fetchone()
|
||||
if settlement is None:
|
||||
return fail("not_found", "monthly settlement not found")
|
||||
closed = settlement["status"] == "closed"
|
||||
if closed:
|
||||
rows = conn.execute(
|
||||
"""SELECT s.name, s.source_type, s.source_id, s.amount, s.currency,
|
||||
a.type AS account_type, a.liquidity_bucket AS account_liquidity_bucket,
|
||||
s.liquidity_bucket AS snapshot_account_category,
|
||||
s.basis_date, s.verification_status, s.memo, s.source_note,
|
||||
s.id AS snapshot_id, 'snapshot' AS match_status
|
||||
FROM account_snapshot s
|
||||
LEFT JOIN accounts a ON a.id=s.source_id
|
||||
WHERE s.monthly_settlement_id=? AND s.source_type='account'
|
||||
ORDER BY s.name, s.source_id, s.created_at, s.id""", (settlement_id,)
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"""SELECT a.name, 'account' AS source_type, a.id AS source_id,
|
||||
a.type AS account_type, a.liquidity_bucket AS account_liquidity_bucket,
|
||||
s.liquidity_bucket AS snapshot_account_category,
|
||||
s.amount, COALESCE(s.currency, a.currency) AS currency,
|
||||
COALESCE(s.basis_date, ?) AS basis_date,
|
||||
s.verification_status, s.memo, s.source_note,
|
||||
s.id AS snapshot_id,
|
||||
CASE WHEN s.id IS NULL THEN 'unregistered' ELSE 'snapshot' END AS match_status
|
||||
FROM accounts a
|
||||
LEFT JOIN account_snapshot s
|
||||
ON s.source_id=a.id AND s.source_type='account'
|
||||
AND s.monthly_settlement_id=?
|
||||
ORDER BY a.name, a.id, s.created_at, s.id""",
|
||||
(settlement["basis_date"], settlement_id),
|
||||
).fetchall()
|
||||
result = []
|
||||
for row in rows:
|
||||
item = dict(row)
|
||||
item["account_name"] = item.get("name") or ""
|
||||
item["account_type"] = item.get("account_type") or ""
|
||||
item["account_liquidity_bucket"] = item.get("account_liquidity_bucket") or ""
|
||||
item["snapshot_account_category"] = item.get("snapshot_account_category") or ""
|
||||
item["display_status"] = "미등록" if item["match_status"] == "unregistered" else ("미확인" if item.get("amount") is None else "확인")
|
||||
if item["match_status"] == "unregistered":
|
||||
with read_db(db_path) as candidate_conn:
|
||||
item.update(_ledger_candidates(candidate_conn, item["source_id"], item["currency"], item["basis_date"]))
|
||||
if item["match_status"] == "unregistered":
|
||||
with read_db(db_path) as candidate_conn:
|
||||
candidates = candidate_conn.execute(
|
||||
"""SELECT s.id AS snapshot_id, s.basis_date, s.amount, s.currency,
|
||||
s.verification_status, s.created_at
|
||||
FROM account_snapshot s
|
||||
WHERE s.source_type='account' AND s.source_id=?
|
||||
AND s.monthly_settlement_id IS NULL AND s.currency=?
|
||||
ORDER BY CASE WHEN s.basis_date=? THEN 0 ELSE 1 END,
|
||||
s.basis_date DESC, s.created_at DESC, s.id DESC""",
|
||||
(item["source_id"], item["currency"], item["basis_date"]),
|
||||
).fetchall()
|
||||
item["unlinked_candidates"] = [dict(candidate) for candidate in candidates]
|
||||
result.append(item)
|
||||
return ok(snapshots=result, count=len(result), registered_count=sum(r["match_status"] == "snapshot" for r in result), unregistered_count=sum(r["match_status"] == "unregistered" for r in result), closed=closed, status=settlement["status"], read_only=True)
|
||||
except DatabaseNotInitializedError:
|
||||
return fail("db_not_initialized", "ledger database is not initialized")
|
||||
except Exception:
|
||||
return fail("operation_failed", "account snapshot details unavailable")
|
||||
|
||||
|
||||
def link_account_snapshot(snapshot_id: str, monthly_settlement_id: str, *, allow_basis_date_mismatch: bool = False, db_path: str | None = None) -> dict[str, Any]:
|
||||
"""Atomically attach one existing, unlinked account snapshot."""
|
||||
if not isinstance(snapshot_id, str) or not snapshot_id.strip() or not isinstance(monthly_settlement_id, str) or not monthly_settlement_id.strip():
|
||||
return fail("validation_error", "snapshot_id and monthly_settlement_id are required")
|
||||
try:
|
||||
with db(db_path) as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
settlement = conn.execute("SELECT id,basis_date,status FROM monthly_settlement WHERE id=?", (monthly_settlement_id,)).fetchone()
|
||||
if settlement is None:
|
||||
conn.rollback(); return fail("not_found", "monthly settlement not found")
|
||||
if settlement["status"] not in {"draft", "in_review"}:
|
||||
conn.rollback(); return fail("invalid_settlement_status", "only draft or in_review settlements can be changed")
|
||||
snapshot = conn.execute("SELECT id,source_type,source_id,currency,basis_date,monthly_settlement_id FROM account_snapshot WHERE id=?", (snapshot_id,)).fetchone()
|
||||
if snapshot is None:
|
||||
conn.rollback(); return fail("not_found", "account snapshot not found")
|
||||
account = conn.execute("SELECT id,currency FROM accounts WHERE id=?", (snapshot["source_id"],)).fetchone()
|
||||
if snapshot["source_type"] != "account" or account is None or snapshot["currency"] != account["currency"]:
|
||||
conn.rollback(); return fail("validation_error", "snapshot account or currency is invalid")
|
||||
if snapshot["monthly_settlement_id"] is not None:
|
||||
conn.rollback(); return fail("already_linked", "account snapshot is already linked")
|
||||
if snapshot["basis_date"] != settlement["basis_date"] and not allow_basis_date_mismatch:
|
||||
conn.rollback(); return fail("basis_date_mismatch", "basis date mismatch requires explicit approval")
|
||||
duplicate = conn.execute("SELECT id FROM account_snapshot WHERE monthly_settlement_id=? AND source_type='account' AND source_id=?", (monthly_settlement_id, snapshot["source_id"])).fetchone()
|
||||
if duplicate:
|
||||
conn.rollback(); return fail("duplicate", "account evaluation already registered", snapshot_id=duplicate["id"])
|
||||
updated = conn.execute("UPDATE account_snapshot SET monthly_settlement_id=? WHERE id=? AND monthly_settlement_id IS NULL", (monthly_settlement_id, snapshot_id))
|
||||
if updated.rowcount != 1:
|
||||
conn.rollback(); return fail("stale_link", "account snapshot link is stale")
|
||||
return ok(snapshot_id=snapshot_id, monthly_settlement_id=monthly_settlement_id, linked=True)
|
||||
except Exception:
|
||||
return fail("db_error", "account snapshot link failed")
|
||||
|
||||
|
||||
def unlink_account_snapshot(snapshot_id: str, monthly_settlement_id: str, db_path: str | None = None) -> dict[str, Any]:
|
||||
"""Detach exactly one account snapshot from one editable monthly settlement."""
|
||||
if not isinstance(snapshot_id, str) or not snapshot_id.strip() or not isinstance(monthly_settlement_id, str) or not monthly_settlement_id.strip():
|
||||
return fail("validation_error", "snapshot_id and monthly_settlement_id are required")
|
||||
try:
|
||||
with db(db_path) as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
settlement = conn.execute("SELECT id,status FROM monthly_settlement WHERE id=?", (monthly_settlement_id,)).fetchone()
|
||||
if settlement is None:
|
||||
conn.rollback(); return fail("not_found", "monthly settlement not found")
|
||||
if settlement["status"] not in {"draft", "in_review"}:
|
||||
conn.rollback(); return fail("invalid_settlement_status", "only draft or in_review settlements can be changed")
|
||||
snapshot = conn.execute(
|
||||
"SELECT id,source_type,source_id,monthly_settlement_id FROM account_snapshot WHERE id=?", (snapshot_id,)
|
||||
).fetchone()
|
||||
if snapshot is None:
|
||||
conn.rollback(); return fail("not_found", "account snapshot not found")
|
||||
if snapshot["source_type"] != "account":
|
||||
conn.rollback(); return fail("validation_error", "snapshot source_type must be account")
|
||||
if not snapshot["source_id"] or conn.execute("SELECT 1 FROM accounts WHERE id=?", (snapshot["source_id"],)).fetchone() is None:
|
||||
conn.rollback(); return fail("validation_error", "snapshot account not found")
|
||||
if snapshot["monthly_settlement_id"] != monthly_settlement_id:
|
||||
conn.rollback(); return fail("validation_error", "snapshot is not linked to this monthly settlement")
|
||||
result = conn.execute(
|
||||
"UPDATE account_snapshot SET monthly_settlement_id=NULL WHERE id=? AND monthly_settlement_id=?",
|
||||
(snapshot_id, monthly_settlement_id),
|
||||
)
|
||||
if result.rowcount != 1:
|
||||
conn.rollback(); return fail("validation_error", "snapshot link is stale")
|
||||
return ok(snapshot_id=snapshot_id, monthly_settlement_id=monthly_settlement_id, unlinked=True)
|
||||
except Exception:
|
||||
return fail("db_error", "account snapshot unlink failed")
|
||||
|
||||
def list_monthly_settlement_history(month: str = "", db_path: str | None = None) -> dict[str, Any]:
|
||||
return fail("unsupported", "run history unavailable")
|
||||
"""Return immutable close/correction records; lifecycle history is authoritative."""
|
||||
try:
|
||||
selected = normalize_month(month) if month else ""
|
||||
with read_db(db_path) as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT id AS run_id, settlement_month, draft_version AS version,
|
||||
COALESCE(approved_at, created_at) AS closed_at,
|
||||
correction_of_run_id AS correction_target,
|
||||
correction_reason AS reason, close_status, created_at
|
||||
FROM asset_snapshot_runs
|
||||
WHERE (? = '' OR settlement_month = ? OR (settlement_month IS NULL AND substr(basis_date,1,7)=?))
|
||||
ORDER BY COALESCE(approved_at, created_at) DESC, rowid DESC""",
|
||||
(selected, selected, selected),
|
||||
).fetchall()
|
||||
return ok(history=[dict(row) for row in rows], month=selected, read_only=True)
|
||||
except DatabaseNotInitializedError:
|
||||
return fail("db_not_initialized", "ledger database is not initialized")
|
||||
except Exception:
|
||||
return fail("operation_failed", "monthly settlement history unavailable")
|
||||
|
||||
|
||||
def get_monthly_settlement_history_record(run_id: str, db_path: str | None = None) -> dict[str, Any]:
|
||||
return fail("unsupported", "run history unavailable")
|
||||
"""Read one immutable close/correction record by run ID."""
|
||||
if not isinstance(run_id, str) or not run_id:
|
||||
return fail("validation_error", "run_id is required")
|
||||
try:
|
||||
with read_db(db_path) as conn:
|
||||
row = conn.execute(
|
||||
"""SELECT id AS run_id, settlement_month, draft_version AS version,
|
||||
COALESCE(approved_at, created_at) AS closed_at,
|
||||
correction_of_run_id AS correction_target,
|
||||
correction_reason AS reason, close_status, created_at,
|
||||
basis_date, income_total, expense_total, net_cashflow, net_worth
|
||||
FROM asset_snapshot_runs WHERE id=?""", (run_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return fail("not_found", "settlement history record not found")
|
||||
return ok(history=dict(row), read_only=True)
|
||||
except DatabaseNotInitializedError:
|
||||
return fail("db_not_initialized", "ledger database is not initialized")
|
||||
except Exception:
|
||||
return fail("operation_failed", "monthly settlement history unavailable")
|
||||
|
||||
|
||||
def _budget_overrun_count(budget_report: dict[str, Any]) -> int:
|
||||
return sum(1 for item in budget_report.get("items", []) if item.get("status") == "over")
|
||||
|
||||
@@ -137,6 +563,7 @@ def get_monthly_settlement_summary(month: str = "", db_path: str | None = None)
|
||||
|
||||
|
||||
def get_monthly_settlement_snapshot(month: str = "", run_id: str = "", db_path: str | None = None) -> dict[str, Any]:
|
||||
return fail("unsupported", "run history unavailable")
|
||||
"""Read a persisted monthly settlement close without consulting live ledger state."""
|
||||
if not isinstance(month, str) or not isinstance(run_id, str):
|
||||
return fail("validation_error", "month and run_id must be strings")
|
||||
@@ -155,26 +582,31 @@ def get_monthly_settlement_snapshot(month: str = "", run_id: str = "", db_path:
|
||||
try:
|
||||
with read_db(db_path) as conn:
|
||||
tables = {r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||
if "asset_snapshot_runs" not in tables or "asset_snapshots" not in tables:
|
||||
if "asset_snapshot_runs" not in tables or "account_snapshot" not in tables:
|
||||
return fail("not_found", "settlement snapshot not found")
|
||||
run = None
|
||||
rows = []
|
||||
legacy = False
|
||||
metadata: dict[str, Any] = {}
|
||||
run_cols = {r["name"] for r in conn.execute("PRAGMA table_info(asset_snapshot_runs)")}
|
||||
if run_id:
|
||||
# Pre-comprehensive headers are intentionally read without
|
||||
# assuming schema-16-only columns.
|
||||
run = conn.execute("SELECT rowid AS _run_rowid,* FROM asset_snapshot_runs WHERE id=?", (run_id,)).fetchone()
|
||||
if run:
|
||||
from .assets_service import _snapshot_rows_for_run
|
||||
rows, metadata = _snapshot_rows_for_run(conn, run_id)
|
||||
else:
|
||||
cols = {r["name"] for r in conn.execute("PRAGMA table_info(asset_snapshots)")}
|
||||
cols = {r["name"] for r in conn.execute("PRAGMA table_info(account_snapshot)")}
|
||||
if {"snapshot_run_id", "closed_at"} <= cols:
|
||||
rows = conn.execute("SELECT rowid AS _rowid,* FROM asset_snapshots WHERE snapshot_run_id=? AND closed_at<>'' ORDER BY rowid", (run_id,)).fetchall()
|
||||
rows = conn.execute("SELECT rowid AS _rowid,* FROM account_snapshot WHERE snapshot_run_id=? AND closed_at<>'' ORDER BY rowid", (run_id,)).fetchall()
|
||||
if rows:
|
||||
legacy = True
|
||||
metadata = {"snapshot_run_id": run_id, "legacy_closed": True, "close_status": "legacy_read_only"}
|
||||
else:
|
||||
if selected_month:
|
||||
if selected_month and "settlement_month" in run_cols:
|
||||
run = conn.execute("SELECT rowid AS _run_rowid,* FROM asset_snapshot_runs WHERE basis_date=? ORDER BY rowid DESC LIMIT 1", (_month_end_date(selected_month),)).fetchone()
|
||||
elif selected_month:
|
||||
run = conn.execute("SELECT rowid AS _run_rowid,* FROM asset_snapshot_runs WHERE basis_date=? ORDER BY rowid DESC LIMIT 1", (_month_end_date(selected_month),)).fetchone()
|
||||
if run is None:
|
||||
basis = _month_end_date(selected_month) if selected_month else ""
|
||||
@@ -218,7 +650,7 @@ def get_monthly_settlement_snapshot(month: str = "", run_id: str = "", db_path:
|
||||
legacy = not comprehensive
|
||||
cashflows = []
|
||||
if comprehensive and "settlement_cashflow_snapshots" in tables:
|
||||
cashflows = [dict(r) for r in conn.execute("SELECT cashflow_kind,category,subcategory,amount,transaction_count,currency FROM settlement_cashflow_snapshots WHERE settlement_run_id=? ORDER BY cashflow_kind,category,subcategory,currency", (rid,))]
|
||||
cashflows = [dict(r) for r in conn.execute("SELECT cashflow_kind,category,subcategory,amount,transaction_count,currency FROM settlement_cashflow_snapshots WHERE monthly_settlement_id=? ORDER BY cashflow_kind,category,subcategory,currency", (rid,))]
|
||||
totals = {"income_total": field("income_total") if comprehensive else None, "expense_total": field("expense_total") if comprehensive else None, "net_cashflow": field("net_cashflow") if comprehensive else None, "total_assets": field("total_assets") if comprehensive else None, "total_liabilities": field("total_liabilities") if comprehensive else None, "net_worth": field("net_worth") if comprehensive else None}
|
||||
safe_items = []
|
||||
for row in rows:
|
||||
|
||||
@@ -99,83 +99,24 @@ def get_balance_reconciliation_report(account_id: str = "", baseline_balance: in
|
||||
effective_at = baseline_at_norm or account["last_synced_at"] or now_iso()
|
||||
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"], account["currency"] or "KRW", effective_at[:10]),
|
||||
).fetchall()
|
||||
purposes = (
|
||||
("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
|
||||
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)
|
||||
latest = [row for row in scoped if row["basis_date"] == latest_basis]
|
||||
if len(latest) != 1:
|
||||
ambiguous_valuation = True
|
||||
else:
|
||||
valuation = latest[0]
|
||||
break
|
||||
valuation_status = "unavailable"
|
||||
calc = _account_balance_row(conn, account["id"], effective_at, False)
|
||||
if effective_baseline is None:
|
||||
difference = None
|
||||
ratio = None
|
||||
status = "BASELINE_REQUIRED"
|
||||
elif not calc["calculation_complete"]:
|
||||
difference = None
|
||||
ratio = None
|
||||
status = "INCOMPLETE_CALCULATION"
|
||||
elif account["type"] == "investment":
|
||||
difference = None
|
||||
ratio = None
|
||||
status = "VALUATION_UNAVAILABLE"
|
||||
else:
|
||||
difference = int(effective_baseline) - int(calc["calculated_balance"])
|
||||
ratio = abs(difference) / abs(int(effective_baseline)) if int(effective_baseline) else None
|
||||
status = "OK" if abs(difference) <= tolerance_int else "REVIEW_NEEDED"
|
||||
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
|
||||
items.append({
|
||||
"account_id": account["id"],
|
||||
"account_name": account["name"],
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
import hashlib
|
||||
import json
|
||||
from decimal import Decimal
|
||||
|
||||
from .shared import CASHFLOW_KIND_SQL
|
||||
|
||||
@@ -10,6 +13,33 @@ _SNAPSHOT_SCHEMA_VERSION = 1
|
||||
_ALLOWED_KINDS = {"income", "expense", "excluded"}
|
||||
|
||||
|
||||
def _canonical_value(value):
|
||||
"""Convert review inputs to a stable, JSON-safe representation."""
|
||||
if isinstance(value, Decimal):
|
||||
return format(value, "f")
|
||||
if isinstance(value, dict):
|
||||
return {str(k): _canonical_value(value[k]) for k in sorted(value)}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_canonical_value(x) for x in value]
|
||||
return value
|
||||
|
||||
|
||||
def cashflow_fingerprint(review: dict, month: str) -> str:
|
||||
"""Fingerprint policy, aggregation, and every included source row."""
|
||||
payload = {
|
||||
"month": month,
|
||||
"aggregation_version": review.get("aggregation_version"),
|
||||
"snapshot_schema_version": review.get("snapshot_schema_version"),
|
||||
"cashflow_policy": review.get("cashflow_policy", {}),
|
||||
"cashflow_items": review.get("cashflow_items", []),
|
||||
"source_snapshot": review.get("source_snapshot", {}),
|
||||
"blockers": review.get("cashflow_blockers", []),
|
||||
}
|
||||
encoded = json.dumps(_canonical_value(payload), ensure_ascii=False,
|
||||
sort_keys=True, separators=(",", ":"), allow_nan=False)
|
||||
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _normalize_cashflow_policy(payload: dict) -> dict:
|
||||
if not isinstance(payload, dict) or set(payload) - {"savings_categories", "category_decisions"}:
|
||||
raise ValueError("cashflow policy must contain only supported keys")
|
||||
@@ -120,7 +150,9 @@ def _review_cashflow(conn, month: str, policy: dict) -> dict:
|
||||
else: expense += applied
|
||||
items = [{"cashflow_kind": k, "category": c, "subcategory": s, "amount": v[0], "transaction_count": v[1], "currency": cur}
|
||||
for (k, c, s, cur), v in sorted(grouped.items())]
|
||||
return {"cashflow_policy": policy, "aggregation_version": _AGGREGATION_VERSION,
|
||||
result = {"cashflow_policy": policy, "aggregation_version": _AGGREGATION_VERSION,
|
||||
"snapshot_schema_version": _SNAPSHOT_SCHEMA_VERSION, "income_total": income,
|
||||
"expense_total": expense, "net_cashflow": income-expense, "cashflow_items": items,
|
||||
"cashflow_blockers": blockers, "cashflow_warnings": [], "source_snapshot": {"transactions": source}}
|
||||
result["cashflow_fingerprint"] = cashflow_fingerprint(result, month)
|
||||
return result
|
||||
|
||||
@@ -39,11 +39,10 @@ def _draft(conn, did):
|
||||
def _current_semantics(conn, item):
|
||||
if item['source_type']=='account':
|
||||
src=conn.execute('SELECT * FROM accounts WHERE id=?',(item['source_id'],)).fetchone()
|
||||
val=conn.execute('SELECT * FROM account_valuations WHERE id=?',(item.get('valuation_id',''),)).fetchone() if item.get('valuation_id') else None
|
||||
child=conn.execute('SELECT id FROM account_valuations WHERE supersedes_id=?',(item.get('valuation_id',''),)).fetchone() if item.get('valuation_id') else None
|
||||
val=None
|
||||
child=None
|
||||
return {'source':dict(src) if src else None,'valuation':dict(val) if val else None,'child':dict(child) if child else None}
|
||||
src=conn.execute('SELECT * FROM manual_assets WHERE id=?',(item['source_id'],)).fetchone()
|
||||
return {'source':dict(src) if src else None}
|
||||
return {'source': None}
|
||||
|
||||
|
||||
def _nonempty(value):
|
||||
@@ -108,10 +107,8 @@ def _review(conn, draft):
|
||||
if status == 'verified' and eligible_date and source.get('active') and row['currency'] == 'KRW' and not row['review_needed']:
|
||||
row['amount'] = candidate if row['included'] else None
|
||||
elif item['source_type'] == 'account':
|
||||
# Candidate visibility does not make it selected or authoritative.
|
||||
candidate = conn.execute('SELECT amount FROM account_valuations WHERE account_id=? AND basis_date<=? ORDER BY recorded_at DESC,rowid DESC LIMIT 1', (item['source_id'], draft['basis_date'])).fetchone()
|
||||
if candidate:
|
||||
row['candidate_amount'] = candidate['amount']
|
||||
row['candidate_status'] = 'missing'
|
||||
row['source_missing_warning'] = 'account valuation source unavailable'
|
||||
if row['currency'] != 'KRW':
|
||||
warnings.append({'code': 'non_krw', 'source_id': item['source_id']})
|
||||
row['status'] = 'excluded' if not row['included'] else 'ready' if row['amount'] is not None else 'missing'
|
||||
@@ -125,7 +122,7 @@ def _review(conn, draft):
|
||||
included.append(row)
|
||||
items.append(row)
|
||||
# All candidate changes and source/selected evidence are bound privately; never exposed as raw JSON.
|
||||
candidates = [dict(v) for v in conn.execute('SELECT * FROM account_valuations WHERE account_id=? ORDER BY id', (item['source_id'],))] if item['source_type'] == 'account' else []
|
||||
candidates = []
|
||||
states.append({'stored': item, 'current': current, 'candidates': candidates})
|
||||
latest = _latest_close(conn, draft['basis_date'])
|
||||
summary = _summarize_valuation_items(included)
|
||||
@@ -147,7 +144,7 @@ def _review(conn, draft):
|
||||
full_totals = {**totals}
|
||||
serial = json.dumps({'draft_id': draft['id'], 'version': draft['version'], 'basis_date': draft['basis_date'], 'states': states, 'warnings': warnings, 'latest_run': latest, 'correction_parent': draft['correction_of_run_id'], 'totals': full_totals, 'cashflow': cf}, sort_keys=True, ensure_ascii=False)
|
||||
return {'draft_id': draft['id'], 'version': draft['version'], 'review_fingerprint': hashlib.sha256(serial.encode()).hexdigest(),
|
||||
'cashflow_policy': policy, 'aggregation_version': cf['aggregation_version'], 'snapshot_schema_version': cf['snapshot_schema_version'], 'cashflow_items': cf['cashflow_items'], 'cashflow_blockers': cf['cashflow_blockers'], 'cashflow_warnings': cf['cashflow_warnings'], 'source_snapshot': cf['source_snapshot'],
|
||||
'cashflow_policy': policy, 'cashflow_fingerprint': cf['cashflow_fingerprint'], 'aggregation_version': cf['aggregation_version'], 'snapshot_schema_version': cf['snapshot_schema_version'], 'cashflow_items': cf['cashflow_items'], 'cashflow_blockers': cf['cashflow_blockers'], 'cashflow_warnings': cf['cashflow_warnings'], 'source_snapshot': cf['source_snapshot'],
|
||||
'items': items, 'included_items': included, 'excluded_items': excluded, 'missing_items': missing, 'warnings': warnings,
|
||||
'approval_required': True, 'totals': totals, **totals,
|
||||
'complete': bool(items) and not missing and not cf['cashflow_blockers'],
|
||||
@@ -177,17 +174,19 @@ def create_settlement_draft(month: str, created_by: str, idempotency_key: str, b
|
||||
if basis_date and basis_date != basis: raise ValueError('basis_date must be month end')
|
||||
if not _nonempty(created_by) or not _nonempty(idempotency_key): raise ValueError('creator and idempotency key required')
|
||||
if not all(isinstance(v, str) for v in (basis_date, memo, correction_of_run_id, correction_reason)): raise ValueError('draft metadata must be strings')
|
||||
if correction_of_run_id and not _nonempty(correction_reason): raise ValueError('correction reason required')
|
||||
if bool(correction_of_run_id) != bool(correction_reason.strip()): raise ValueError('correction run and reason must be provided together')
|
||||
with db(db_path) as c:
|
||||
c.execute('BEGIN IMMEDIATE')
|
||||
payload_hash=hashlib.sha256(json.dumps([month,created_by,basis,memo,correction_of_run_id,correction_reason],separators=(',',':')).encode()).hexdigest()
|
||||
old=c.execute('SELECT * FROM settlement_drafts WHERE create_key=?',(idempotency_key,)).fetchone()
|
||||
old=c.execute("SELECT * FROM settlement_drafts WHERE create_key=? AND status <> 'cancelled'",(idempotency_key,)).fetchone()
|
||||
if old:
|
||||
if old['original_request_hash']!=payload_hash: return _err('conflict','idempotency key payload conflict')
|
||||
return _result(c,old['id'])
|
||||
if c.execute("SELECT 1 FROM settlement_drafts WHERE month=? AND status='active'",(month,)).fetchone(): return _err('conflict','active draft already exists for month')
|
||||
latest = _latest_close(c, basis)
|
||||
if correction_of_run_id != latest:
|
||||
if not correction_of_run_id and latest:
|
||||
return _err('validation_error', 'closed month requires correction linkage and reason')
|
||||
if correction_of_run_id and correction_of_run_id != latest:
|
||||
return _err('validation_error', 'latest closed run must be linked explicitly with correction reason')
|
||||
did=uuid4().hex; ts=now_iso()
|
||||
c.execute('INSERT INTO settlement_drafts(id,month,basis_date,status,version,created_by,created_at,updated_at,memo,create_key,correction_of_run_id,correction_reason,original_request_hash) VALUES(?,?,? ,\'active\',1,?,?,?,?,?,?,?,?)',(did,month,basis,created_by,ts,ts,memo,idempotency_key,correction_of_run_id,correction_reason,payload_hash))
|
||||
@@ -199,9 +198,6 @@ def create_settlement_draft(month: str, created_by: str, idempotency_key: str, b
|
||||
vid = selected.get(a['id'], '')
|
||||
ev = _freeze(c, 'account', a['id'], vid)
|
||||
c.execute('INSERT INTO settlement_draft_items(id,draft_id,source_type,source_id,valuation_id,selection_mode,included,version,selected_by,selected_at,evidence_json) VALUES(?,?,?,?,?,?,?,?,?,?,?)',(uuid4().hex,did,'account',a['id'],vid,'exact',1,1,created_by,ts,json.dumps(ev,sort_keys=True)))
|
||||
for a in c.execute("SELECT * FROM manual_assets WHERE active=1 ORDER BY id").fetchall():
|
||||
ev = _freeze(c, 'manual_asset', a['id'])
|
||||
c.execute('INSERT INTO settlement_draft_items(id,draft_id,source_type,source_id,valuation_id,selection_mode,included,version,selected_by,selected_at,evidence_json) VALUES(?,?,?,?,?,?,?,?,?,?,?)',(uuid4().hex,did,'manual_asset',a['id'],'','exact',1,1,created_by,ts,json.dumps(ev,sort_keys=True)))
|
||||
return _result(c,did)
|
||||
except (ValueError,TypeError,sqlite3.Error) as e: return _err('validation_error' if isinstance(e,(ValueError,TypeError)) else 'db_error','invalid draft request' if isinstance(e,(ValueError,TypeError)) else 'database error')
|
||||
|
||||
@@ -266,12 +262,10 @@ def refresh_settlement_draft_targets(draft_id: str,expected_version: int,updated
|
||||
if d['status'] != 'active' or d['version'] != expected_version:
|
||||
return _err('conflict', 'draft state/version conflict')
|
||||
ts = now_iso()
|
||||
from .assets_service import _account_valuation_items
|
||||
eligible, _, _ = _account_valuation_items(c, d['basis_date'], 'exact')
|
||||
selected = {x['source_id']: x.get('valuation_id', '') for x in eligible}
|
||||
selected = {}
|
||||
existing = {(x['source_type'], x['source_id']): x for x in _items(c, draft_id)}
|
||||
active = set()
|
||||
for typ, table in [('account', 'accounts'), ('manual_asset', 'manual_assets')]:
|
||||
for typ, table in [('account', 'accounts')]:
|
||||
for source in c.execute(f'SELECT id FROM {table} WHERE active=1 ORDER BY id').fetchall():
|
||||
key = (typ, source['id'])
|
||||
active.add(key)
|
||||
@@ -298,8 +292,8 @@ def refresh_settlement_draft_targets(draft_id: str,expected_version: int,updated
|
||||
|
||||
|
||||
def set_settlement_draft_item(draft_id: str,source_type: str,source_id: str,expected_version: int,selected_by: str,valuation_id: str='',selection_mode: str='exact',selection_reason: str='',included: bool=True,exclusion_reason: str='',db_path: str|None=None):
|
||||
source_type = 'manual_asset' if source_type == 'manual' else source_type
|
||||
if type(included) is not bool or source_type not in ('account', 'manual_asset') or selection_mode not in ('exact', 'carried_before', 'manual_substitute'):
|
||||
if source_type == 'manual': source_type = 'manual_asset'
|
||||
if source_type != 'account' or type(included) is not bool or selection_mode not in ('exact', 'carried_before', 'manual_substitute'):
|
||||
return _err('validation_error', 'invalid item fields')
|
||||
if not _valid_version(expected_version) or not _nonempty(selected_by) or not _nonempty(source_id) or not isinstance(valuation_id, str) or not isinstance(selection_reason, str) or not isinstance(exclusion_reason, str):
|
||||
return _err('validation_error', 'valid version, source and actor required')
|
||||
@@ -334,7 +328,7 @@ def set_settlement_draft_item(draft_id: str,source_type: str,source_id: str,expe
|
||||
elif source_type == 'account':
|
||||
if selection_mode != 'exact': return _err('validation_error', 'an explicit valuation ID is required')
|
||||
else:
|
||||
if valuation_id or selection_mode == 'manual_substitute': return _err('validation_error', 'manual asset supports exact or carried_before only')
|
||||
return _err('unsupported', 'manual assets are no longer supported')
|
||||
if included and source.get('valuation_date'):
|
||||
delta = (date.fromisoformat(source['valuation_date']) - date.fromisoformat(d['basis_date'])).days
|
||||
if (selection_mode == 'exact' and delta != 0) or (selection_mode == 'carried_before' and delta >= 0): return _err('validation_error', 'manual evidence date does not match selection mode')
|
||||
@@ -379,6 +373,7 @@ def approve_settlement_draft(draft_id: str,expected_version: int,review_fingerpr
|
||||
|
||||
|
||||
def finalize_settlement_draft(draft_id: str,expected_version: int,review_fingerprint: str,idempotency_key: str,db_path: str|None=None):
|
||||
return fail('unsupported', 'run history unavailable')
|
||||
if not _valid_version(expected_version) or not _nonempty(idempotency_key) or not _nonempty(review_fingerprint):return _err('validation_error','version and close key required')
|
||||
try:
|
||||
with db(db_path) as c:
|
||||
@@ -409,16 +404,21 @@ def finalize_settlement_draft(draft_id: str,expected_version: int,review_fingerp
|
||||
applied = [{**x, 'draft_id': draft_id, 'draft_version': expected_version, 'review_fingerprint': review_fingerprint, 'approval': approval} for x in r['included_items']]
|
||||
excluded = [{**x, 'draft_id': draft_id, 'draft_version': expected_version, 'review_fingerprint': review_fingerprint, 'approval': approval, 'reason': 'approved_missing', 'exclusion_reason': x.get('exclusion_reason') or x.get('reason', 'missing_evidence')} for x in r['excluded_items'] + r['missing_items']]
|
||||
carried = [x for x in applied if x['selection_mode'] == 'carried_before']
|
||||
run=uuid4().hex; ts=now_iso(); basis=d['basis_date']; status='conditional' if excluded or r['contains_substitutions'] or r['contains_carried'] else 'complete_exact'
|
||||
run=uuid4().hex; monthly_settlement_id=uuid4().hex; ts=now_iso(); basis=d['basis_date']; status='conditional' if excluded or r['contains_substitutions'] or r['contains_carried'] else 'complete_exact'
|
||||
month_row=c.execute('SELECT id FROM monthly_settlement WHERE month=?',(d['month'],)).fetchone()
|
||||
if month_row: monthly_settlement_id=month_row['id']
|
||||
else:
|
||||
c.execute("INSERT INTO monthly_settlement(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) VALUES(?,?,?,?,?,?,?,?,?)",(monthly_settlement_id,d['month'],basis,'draft',1,d['approved_by'],ts,d['approved_by'],ts))
|
||||
c.execute('UPDATE monthly_settlement SET status=\'closed\',version=version+1,approved_by=?,approved_at=?,approved_version=?,approved_fingerprint=?,approval_reason=?,closed_by=?,closed_at=?,updated_by=?,updated_at=? WHERE id=?',(d['approved_by'] or 'legacy-approval',d['approved_at'] or ts,expected_version,review_fingerprint,d['approval_reason'] or 'legacy approval',d['approved_by'] or 'legacy-approval',ts,d['approved_by'] or 'legacy-approval',ts,monthly_settlement_id))
|
||||
c.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,draft_id,draft_version,review_fingerprint,settlement_month,currency,income_total,expense_total,net_cashflow,total_assets,total_liabilities,net_worth,cashflow_policy_json,aggregation_version,snapshot_schema_version,source_snapshot_json) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',(run,basis,status,'exact',idempotency_key,d['approved_by'],d['approved_at'],d['approval_reason'],d['approval_reference'],d['correction_of_run_id'],d['correction_reason'],json.dumps(applied,sort_keys=True),json.dumps(excluded,sort_keys=True),json.dumps(carried,sort_keys=True),req,ts,draft_id,expected_version,review_fingerprint,d['month'],'KRW',r['income_total'],r['expense_total'],r['net_cashflow'],r['total_assets'],r['total_liabilities'],r['net_worth'],json.dumps(r['cashflow_policy'],sort_keys=True),r['aggregation_version'],r['snapshot_schema_version'],json.dumps(r['source_snapshot'],sort_keys=True)))
|
||||
snapshot_ids = []
|
||||
for x in applied + excluded:
|
||||
sid = uuid4().hex
|
||||
snapshot_ids.append(sid)
|
||||
c.execute('INSERT INTO asset_snapshots(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,valuation_source,source_note,memo,snapshot_run_id,correction_of_run_id,correction_reason,is_correction,closed_at,created_at,valuation_id,selection_policy,verification_status,evidence_kind,exclusion_reason) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',(sid,basis,x['source_type'],x['source_id'],x.get('name',''),x['amount'],x['currency'],x['liquidity_bucket'],x['selection_mode'],'','',run,d['correction_of_run_id'],d['correction_reason'],int(bool(d['correction_of_run_id'])),ts,ts,x.get('valuation_id') or '',x['selection_mode'],x.get('verification_status','unverified'),'excluded' if x in excluded else 'applied',x.get('exclusion_reason','')))
|
||||
c.execute('INSERT INTO account_snapshot(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,valuation_source,source_note,memo,monthly_settlement_id,correction_of_run_id,correction_reason,is_correction,closed_at,created_at,valuation_id,selection_policy,verification_status,evidence_kind,exclusion_reason) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',(sid,basis,x['source_type'],x['source_id'],x.get('name',''),x['amount'],x['currency'],x['liquidity_bucket'],'','','',monthly_settlement_id,d['correction_of_run_id'],d['correction_reason'],int(bool(d['correction_of_run_id'])),ts,ts,x.get('valuation_id') or '',x['selection_mode'],x.get('verification_status','unverified'),'excluded' if x in excluded else 'applied',x.get('exclusion_reason','')))
|
||||
for item in r['cashflow_items']:
|
||||
c.execute('INSERT INTO settlement_cashflow_snapshots(id,settlement_run_id,cashflow_kind,category,subcategory,amount,transaction_count,currency) VALUES(?,?,?,?,?,?,?,?)', (uuid4().hex,run,item['cashflow_kind'],item['category'],item['subcategory'],item['amount'],item['transaction_count'],item['currency']))
|
||||
saved_groups = [dict(x) for x in c.execute('SELECT cashflow_kind,category,subcategory,amount,transaction_count,currency FROM settlement_cashflow_snapshots WHERE settlement_run_id=? ORDER BY cashflow_kind,category,subcategory,currency', (run,))]
|
||||
c.execute('INSERT INTO settlement_cashflow_snapshots(id,monthly_settlement_id,cashflow_kind,category,subcategory,amount,transaction_count,currency) VALUES(?,?,?,?,?,?,?,?)', (uuid4().hex,monthly_settlement_id,item['cashflow_kind'],item['category'],item['subcategory'],item['amount'],item['transaction_count'],item['currency']))
|
||||
saved_groups = [dict(x) for x in c.execute('SELECT cashflow_kind,category,subcategory,amount,transaction_count,currency FROM settlement_cashflow_snapshots WHERE monthly_settlement_id=? ORDER BY cashflow_kind,category,subcategory,currency', (monthly_settlement_id,))]
|
||||
expected_groups = sorted([{'cashflow_kind':x['cashflow_kind'],'category':x['category'],'subcategory':x['subcategory'],'amount':x['amount'],'transaction_count':x['transaction_count'],'currency':x['currency']} for x in r['cashflow_items']], key=lambda x:(x['cashflow_kind'],x['category'],x['subcategory'],x['currency']))
|
||||
if saved_groups != expected_groups: raise ValueError('persisted cashflow detail mismatch')
|
||||
header = c.execute('SELECT * FROM asset_snapshot_runs WHERE id=?', (run,)).fetchone()
|
||||
@@ -429,7 +429,12 @@ def finalize_settlement_draft(draft_id: str,expected_version: int,review_fingerp
|
||||
for key,value in (('source_snapshot_json',r['source_snapshot']),('cashflow_policy_json',r['cashflow_policy']),('applied_evidence',applied),('excluded_evidence',excluded),('carried_evidence',carried)):
|
||||
if json.loads(header[key]) != value:
|
||||
raise ValueError('persisted evidence mismatch')
|
||||
persisted_assets = [dict(x) for x in c.execute('SELECT * FROM asset_snapshots WHERE snapshot_run_id=? ORDER BY id', (run,))]
|
||||
# A correction shares the canonical month with its parent. Read
|
||||
# back only this run's lineage, never the parent's rows.
|
||||
if d['correction_of_run_id']:
|
||||
persisted_assets = [dict(x) for x in c.execute('SELECT * FROM account_snapshot WHERE monthly_settlement_id=? AND correction_of_run_id=? ORDER BY id', (monthly_settlement_id, d['correction_of_run_id']))]
|
||||
else:
|
||||
persisted_assets = [dict(x) for x in c.execute("SELECT * FROM account_snapshot WHERE monthly_settlement_id=? AND COALESCE(correction_of_run_id,'')='' ORDER BY id", (monthly_settlement_id,))]
|
||||
fields = ('source_type','source_id','amount','currency','liquidity_bucket')
|
||||
expected_assets = sorted(zip(snapshot_ids, applied+excluded), key=lambda pair:pair[0])
|
||||
if len(persisted_assets) != len(expected_assets):
|
||||
|
||||
@@ -29,7 +29,7 @@ from ..infrastructure.sqlite import DatabaseNotInitializedError
|
||||
from ..infrastructure.sqlite import resolve_db_path as resolve_db_path
|
||||
|
||||
VALID_TRANSACTION_TYPES = {"income", "expense", "transfer", "refund", "adjustment"}
|
||||
VALID_ACCOUNT_TYPES = {"bank", "card", "cash", "investment", "loan", "other"}
|
||||
VALID_ACCOUNT_TYPES = {"bank", "card", "cash", "investment", "loan", "other", "real_estate"}
|
||||
VALID_STATEMENT_STATUSES = {"unknown", "import_supported", "manual_only", "partial_import"}
|
||||
VALID_LIQUIDITY_BUCKETS = {"cash", "investment", "deposit", "real_estate", "loan", "other"}
|
||||
ASSET_BUCKETS = ["cash", "investment", "deposit", "real_estate", "other"]
|
||||
|
||||
@@ -68,7 +68,7 @@ def _readonly_sqlite_connection(path: Path) -> sqlite3.Connection:
|
||||
|
||||
def _safe_table_counts(conn: sqlite3.Connection) -> dict[str, int]:
|
||||
counts: dict[str, int] = {}
|
||||
for table in ("accounts", "transactions", "budgets", "import_batches", "category_rules", "ledger_categories", "manual_assets", "asset_snapshots", "review_decisions", "reconciliation_sessions"):
|
||||
for table in ("accounts", "transactions", "budgets", "import_batches", "category_rules", "ledger_categories", "account_snapshot", "review_decisions", "reconciliation_sessions"):
|
||||
exists = conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)).fetchone()
|
||||
if exists:
|
||||
counts[table] = int(conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0])
|
||||
|
||||
@@ -68,13 +68,65 @@ def get_monthly_cashflow_report(month: str = "", db_path: str | None = None) ->
|
||||
ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory
|
||||
WHERE t.status='active' AND t.transaction_date>=? AND t.transaction_date<?
|
||||
GROUP BY {CASHFLOW_KIND_SQL}""", (start, next_month)).fetchall()
|
||||
category_rows = conn.execute(f"""SELECT {CASHFLOW_KIND_SQL} AS cashflow_kind,
|
||||
t.category, t.subcategory, t.currency,
|
||||
SUM(t.amount) AS amount, COUNT(*) AS count
|
||||
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 t.transaction_date>=? AND t.transaction_date<?
|
||||
GROUP BY {CASHFLOW_KIND_SQL}, t.category, t.subcategory, t.currency
|
||||
ORDER BY cashflow_kind, amount DESC""", (start, next_month)).fetchall()
|
||||
type_totals = {r["type"]: int(r["total"] or 0) for r in type_rows}
|
||||
cashflow_totals = {r["cashflow_kind"]: int(r["total"] or 0) for r in cashflow_rows}
|
||||
income = cashflow_totals.get("income", 0)
|
||||
expenses = cashflow_totals.get("expense", 0)
|
||||
net_cashflow = income - expenses
|
||||
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)
|
||||
categories = [row_to_dict(r) for r in category_rows]
|
||||
unknown_total = {}
|
||||
unknown_category_items = []
|
||||
for row in categories:
|
||||
if row.get("cashflow_kind") == "unknown":
|
||||
currency = row.get("currency") or "미지정"
|
||||
unknown_total[currency] = unknown_total.get(currency, 0) + int(row.get("amount") or 0)
|
||||
unknown_category_items.append(row)
|
||||
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, categories=categories, unknown_total=unknown_total, unknown_category_items=unknown_category_items)
|
||||
|
||||
@read_operation
|
||||
def get_settlement_cashflow_snapshot(monthly_settlement_id: str, db_path: str | None = None) -> dict[str, Any]:
|
||||
"""Read only the persisted cashflow rows for a closed settlement."""
|
||||
with read_db(db_path) as conn:
|
||||
rows = conn.execute("""SELECT cashflow_kind, category, subcategory, amount,
|
||||
transaction_count AS count, currency
|
||||
FROM settlement_cashflow_snapshots
|
||||
WHERE monthly_settlement_id=?
|
||||
ORDER BY cashflow_kind, category, subcategory, currency""", (monthly_settlement_id,)).fetchall()
|
||||
settlement = conn.execute("SELECT status FROM monthly_settlement WHERE id=?", (monthly_settlement_id,)).fetchone()
|
||||
categories = [row_to_dict(row) for row in rows]
|
||||
if not categories:
|
||||
saved = bool(settlement and settlement["status"] == "closed")
|
||||
return ok(saved=saved, categories=[], income=0 if saved else None, expenses=0 if saved else None,
|
||||
net_cashflow=0 if saved else None, month="", message="저장된 현금흐름이 비어 있습니다." if saved else "저장된 현금흐름 스냅샷이 없습니다.")
|
||||
by_currency = {}
|
||||
for row in categories:
|
||||
cur = row.get("currency") or "미지정"
|
||||
bucket = by_currency.setdefault(cur, {"income": 0, "expenses": 0, "net_cashflow": 0})
|
||||
if row["cashflow_kind"] == "income": bucket["income"] += int(row["amount"] or 0)
|
||||
elif row["cashflow_kind"] == "expense": bucket["expenses"] += int(row["amount"] or 0)
|
||||
for bucket in by_currency.values(): bucket["net_cashflow"] = bucket["income"] - bucket["expenses"]
|
||||
only = next(iter(by_currency.values())) if len(by_currency) == 1 else None
|
||||
unknown_total = {}
|
||||
unknown_category_items = []
|
||||
for row in categories:
|
||||
if row.get("cashflow_kind") == "unknown":
|
||||
currency = row.get("currency") or "미지정"
|
||||
unknown_total[currency] = unknown_total.get(currency, 0) + int(row.get("amount") or 0)
|
||||
unknown_category_items.append(row)
|
||||
return ok(saved=True, categories=categories, unknown_total=unknown_total, unknown_category_items=unknown_category_items, by_currency=by_currency,
|
||||
income=only["income"] if only else None,
|
||||
expenses=only["expenses"] if only else None,
|
||||
net_cashflow=only["net_cashflow"] if only else None, month="")
|
||||
|
||||
@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]:
|
||||
|
||||
Reference in New Issue
Block a user