feat: deliver schema 15 monthly settlement lifecycle

This commit is contained in:
2026-10-17 15:19:46 +09:00
parent dda76b3bbd
commit 38a854d525
79 changed files with 4003 additions and 1831 deletions
+5 -23
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
from typing import Literal
import os
from dataclasses import dataclass
from pathlib import Path
@@ -102,8 +104,8 @@ def get_ledger_backup_status(file_path: str, include_hash: bool = True, include_
return contracts.get_ledger_backup_status(file_path, include_hash=include_hash, include_integrity=include_integrity)
@mcp.tool()
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", last_balance_basis_date: str = "") -> dict:
"""Create an account/card/cash/investment/loan ledger account."""
def create_ledger_account(name: str, type: Literal["bank", "card", "cash", "investment", "loan", "other", "real_estate"] = "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, last_balance_basis_date: str = "") -> dict:
"""Create a bank/card/cash/investment/loan/other/real_estate ledger account."""
return contracts.create_ledger_account(name, type, institution, currency, active, memo, account_number_masked, holder_name, last_balance, last_synced_at, statement_status, liquidity_bucket, last_balance_basis_date=last_balance_basis_date)
@mcp.tool()
@@ -112,7 +114,7 @@ def list_ledger_accounts(active_only: bool = True) -> dict:
return contracts.list_ledger_accounts(active_only)
@mcp.tool()
def update_ledger_account(account_id: str, name: str | None = None, type: str | None = None, institution: str | None = None, currency: str | None = None, active: bool | None = None, memo: str | None = None, account_number_masked: str | None = None, holder_name: str | None = None, last_balance: int | None = None, last_synced_at: str | None = None, statement_status: str | None = None, liquidity_bucket: str | None = None, last_balance_basis_date: str | None = None) -> dict:
def update_ledger_account(account_id: str, name: str | None = None, type: Literal["bank", "card", "cash", "investment", "loan", "other", "real_estate"] | None = None, institution: str | None = None, currency: str | None = None, active: bool | None = None, memo: str | None = None, account_number_masked: str | None = None, holder_name: str | None = None, last_balance: int | None = None, last_synced_at: str | None = None, statement_status: str | None = None, liquidity_bucket: str | None = None, last_balance_basis_date: str | None = None) -> dict:
"""Update selected account fields, including statement_status/liquidity_bucket for manual reconciliation."""
return contracts.update_ledger_account(account_id, name=name, type=type, institution=institution, currency=currency, active=active, memo=memo, account_number_masked=account_number_masked, holder_name=holder_name, last_balance=last_balance, last_synced_at=last_synced_at, statement_status=statement_status, liquidity_bucket=liquidity_bucket, last_balance_basis_date=last_balance_basis_date)
@@ -141,26 +143,6 @@ def get_asset_liquidity_report() -> dict:
"""Return asset liquidity, debt, and net worth from shared account/manual valuation sources."""
return contracts.get_asset_liquidity_report()
@mcp.tool()
def list_manual_assets(active_only: bool = True) -> dict:
"""List manual non-transaction assets/liabilities used for net-worth valuation."""
return contracts.list_manual_assets(active_only=active_only)
@mcp.tool()
def create_manual_asset(name: str, valuation_amount: int, valuation_date: str, liquidity_bucket: str, source_note: str, memo: str = "", currency: str = "KRW", active: bool = True) -> dict:
"""Create a manual non-transaction asset/liability valuation row."""
return contracts.create_manual_asset(name, valuation_amount, valuation_date, liquidity_bucket, source_note=source_note, memo=memo, currency=currency, active=active)
@mcp.tool()
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) -> dict:
"""Update selected fields on a manual asset/liability valuation row."""
return contracts.update_manual_asset(asset_id, name=name, valuation_amount=valuation_amount, valuation_date=valuation_date, liquidity_bucket=liquidity_bucket, source_note=source_note, memo=memo, currency=currency, active=active)
@mcp.tool()
def deactivate_manual_asset(asset_id: str, hard_delete: bool = False) -> dict:
"""Deactivate a manual asset/liability by default; hard delete only for exceptional cleanup."""
return contracts.deactivate_manual_asset(asset_id, hard_delete=hard_delete)
@mcp.tool()
def preview_asset_snapshot_close(basis_date: str = "", selection_policy: str = "exact") -> dict:
"""Preview a month-end asset snapshot close without mutating the database."""
@@ -22,8 +22,6 @@ from ...service import (
create_ledger_budget as create_ledger_budget,
create_ledger_category as create_ledger_category,
create_ledger_transaction as create_ledger_transaction,
create_manual_asset as create_manual_asset,
deactivate_manual_asset as deactivate_manual_asset,
delete_category_rule as delete_category_rule,
delete_ledger_category as delete_ledger_category,
delete_ledger_transaction as delete_ledger_transaction,
@@ -66,7 +64,6 @@ from ...service import (
list_ledger_categories as list_ledger_categories,
list_ledger_import_batches as list_ledger_import_batches,
list_ledger_transactions as list_ledger_transactions,
list_manual_assets as list_manual_assets,
list_uncategorized_transactions as list_uncategorized_transactions,
preview_asset_snapshot_close as preview_asset_snapshot_close,
preview_categorization as preview_categorization,
@@ -83,7 +80,6 @@ from ...service import (
update_category_rule as update_category_rule,
update_ledger_account as update_ledger_account,
update_ledger_transaction as update_ledger_transaction,
update_manual_asset as update_manual_asset,
)
TOOL_CONTRACTS = {
@@ -112,8 +108,6 @@ __all__ = [
"create_ledger_budget",
"create_ledger_category",
"create_ledger_transaction",
"create_manual_asset",
"deactivate_manual_asset",
"delete_category_rule",
"delete_ledger_category",
"delete_ledger_transaction",
@@ -156,7 +150,6 @@ __all__ = [
"list_ledger_categories",
"list_ledger_import_batches",
"list_ledger_transactions",
"list_manual_assets",
"list_uncategorized_transactions",
"preview_asset_snapshot_close",
"preview_categorization",
@@ -173,5 +166,4 @@ __all__ = [
"update_category_rule",
"update_ledger_account",
"update_ledger_transaction",
"update_manual_asset",
]
@@ -2,18 +2,10 @@ from __future__ import annotations
from ...service import (
close_asset_snapshot as close_asset_snapshot,
create_manual_asset as create_manual_asset,
deactivate_manual_asset as deactivate_manual_asset,
list_manual_assets as list_manual_assets,
preview_asset_snapshot_close as preview_asset_snapshot_close,
update_manual_asset as update_manual_asset,
)
__all__ = [
"close_asset_snapshot",
"create_manual_asset",
"deactivate_manual_asset",
"list_manual_assets",
"preview_asset_snapshot_close",
"update_manual_asset",
]
@@ -5,6 +5,7 @@ from ...service import (
get_balance_reconciliation_report as get_balance_reconciliation_report,
get_budget_vs_actual_report as get_budget_vs_actual_report,
get_monthly_settlement_summary as get_monthly_settlement_summary,
list_monthly_settlement_overview as list_monthly_settlement_overview,
get_monthly_cashflow_report as get_monthly_cashflow_report,
get_ledger_db_status as get_ledger_db_status,
health_check_readonly as health_check,
@@ -20,6 +21,7 @@ __all__ = [
"get_balance_reconciliation_report",
"get_budget_vs_actual_report",
"get_monthly_settlement_summary",
"list_monthly_settlement_overview",
"get_monthly_cashflow_report",
"get_ledger_db_status",
"health_check",
@@ -10,6 +10,13 @@ from ...service import (
finalize_settlement_draft as finalize_settlement_draft,
get_settlement_draft as get_settlement_draft,
get_monthly_settlement_snapshot as get_monthly_settlement_snapshot,
list_monthly_settlement_history as list_monthly_settlement_history,
get_monthly_settlement_history_record as get_monthly_settlement_history_record,
get_monthly_settlement_record as get_monthly_settlement_record,
list_account_snapshots_for_settlement as list_account_snapshots_for_settlement,
link_account_snapshot as link_account_snapshot,
unlink_account_snapshot as unlink_account_snapshot,
update_account_snapshot_category as update_account_snapshot_category,
list_account_valuations as list_account_valuations,
list_settlement_drafts as list_settlement_drafts,
refresh_settlement_draft_targets as refresh_settlement_draft_targets,
@@ -21,6 +28,8 @@ from ...service import (
)
from ...application import settlement_draft_service as _draft_service
from ...application import monthly_settlement_service as _monthly_service
from ...application.transaction_report_services import get_monthly_cashflow_report as get_monthly_cashflow_report
from ...application.transaction_report_services import get_settlement_cashflow_snapshot as get_settlement_cashflow_snapshot
from ... import service as _service
@@ -33,11 +42,36 @@ def _guard(db_path: str | None) -> str:
return db_path or _resolve_db_path()
def create_monthly_settlement(month: str, created_by: str = "dashboard-admin", *, db_path=None):
return _monthly_service.create_monthly_settlement(month, created_by=created_by, db_path=_guard(db_path))
def get_monthly_cashflow(month: str, *, settlement_id=None, closed=False, db_path=None):
if closed:
return get_settlement_cashflow_snapshot(str(settlement_id), db_path=_guard(db_path))
return get_monthly_cashflow_report(month, db_path=_guard(db_path))
def review_monthly_settlement(settlement_id: str, *, db_path=None):
return _monthly_service.review_monthly_settlement(settlement_id, db_path=_guard(db_path))
def approve_monthly_settlement(settlement_id: str, expected_version: int, review_fingerprint: str, approved_by: str = "dashboard-admin", *, db_path=None):
return _monthly_service.approve_monthly_settlement(settlement_id, expected_version, review_fingerprint, approved_by=approved_by, db_path=_guard(db_path))
def close_monthly_settlement(settlement_id: str, expected_version: int, closed_by: str = "dashboard-admin", *, db_path=None):
return _monthly_service.close_monthly_settlement(settlement_id, expected_version, closed_by=closed_by, db_path=_guard(db_path))
def cancel_monthly_settlement_approval(settlement_id: str, expected_version: int, approved_by: str = "dashboard-admin", *, db_path=None):
return _monthly_service.cancel_monthly_settlement_approval(settlement_id, expected_version, approved_by=approved_by, db_path=_guard(db_path))
def _wrap(fn):
def call(*args, db_path=None, **kwargs):
if fn.__name__ == "set_settlement_cashflow_policy":
target = _draft_service
elif fn.__name__ == "get_monthly_settlement_snapshot":
elif fn.__name__ in {"get_monthly_settlement_snapshot", "list_monthly_settlement_history", "get_monthly_settlement_history_record", "get_monthly_settlement_record", "list_account_snapshots_for_settlement", "link_account_snapshot", "unlink_account_snapshot"}:
target = _monthly_service
else:
target = _draft_service if fn.__name__ in {"create_settlement_draft", "list_settlement_drafts", "get_settlement_draft", "update_settlement_draft", "refresh_settlement_draft_targets", "set_settlement_draft_item", "review_settlement_draft", "approve_settlement_draft", "finalize_settlement_draft", "cancel_settlement_draft"} else _service
@@ -54,8 +88,10 @@ for _name in (
"update_settlement_draft", "refresh_settlement_draft_targets",
"set_settlement_draft_item", "review_settlement_draft",
"approve_settlement_draft", "finalize_settlement_draft", "cancel_settlement_draft",
"cancel_monthly_settlement_approval",
"register_account_valuation", "list_account_valuations", "correct_account_valuation",
"set_settlement_cashflow_policy", "get_monthly_settlement_snapshot",
"list_monthly_settlement_history", "get_monthly_settlement_history_record", "get_monthly_settlement_record", "list_account_snapshots_for_settlement", "link_account_snapshot", "unlink_account_snapshot", "update_account_snapshot_category",
):
globals()[_name] = _wrap(getattr(_service, _name))
@@ -64,6 +100,9 @@ __all__ = [
"update_settlement_draft", "refresh_settlement_draft_targets",
"set_settlement_draft_item", "review_settlement_draft",
"approve_settlement_draft", "finalize_settlement_draft", "cancel_settlement_draft",
"cancel_monthly_settlement_approval",
"register_account_valuation", "list_account_valuations", "correct_account_valuation",
"set_settlement_cashflow_policy", "get_monthly_settlement_snapshot",
"list_monthly_settlement_history", "get_monthly_settlement_history_record", "get_monthly_settlement_record", "list_account_snapshots_for_settlement",
"get_monthly_cashflow",
]
@@ -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
+58 -300
View File
@@ -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):
+1 -1
View File
@@ -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]:
@@ -1,10 +1,61 @@
from __future__ import annotations
import sqlite3
import json
from .connection import connect, resolve_db_path
from .schema import ACCOUNT_ADDITIVE_COLUMNS, SCHEMA_SQL, SCHEMA_VERSION, TRANSACTION_ADDITIVE_COLUMNS
def _execute_script_transactional(conn: sqlite3.Connection, script: str) -> None:
"""Execute a schema script without executescript's implicit COMMIT."""
statement = ""
for line in script.splitlines():
statement += line + "\n"
if sqlite3.complete_statement(statement):
_execute_one_schema_statement(conn, statement)
statement = ""
if statement.strip():
_execute_one_schema_statement(conn, statement)
def _execute_one_schema_statement(conn: sqlite3.Connection, statement: str) -> None:
if statement.lstrip().upper().startswith("PRAGMA JOURNAL_MODE=WAL"):
return
try:
conn.execute(statement)
except sqlite3.OperationalError as exc:
if "no such column" not in str(exc):
raise
def _stored_version(conn: sqlite3.Connection) -> str | None:
try:
row = conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()
except sqlite3.OperationalError as exc:
if "no such table" in str(exc):
return None
raise
return row["value"] if row else None
def _compatible_known_v16(conn: sqlite3.Connection) -> bool:
"""Allow only the already-approved v16 development shape to normalize to v15."""
required = {
"accounts": {"id", "name", "type", "liquidity_bucket"},
"transactions": {"id", "transaction_date", "amount", "raw_payload"},
"account_snapshot": {"id", "monthly_settlement_id", "amount", "verification_status"},
"monthly_settlement": {"id", "month", "status"},
"settlement_cashflow_snapshots": {"id", "monthly_settlement_id", "amount"},
}
for table, columns in required.items():
if not conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)).fetchone():
return False
actual = _existing_columns(conn, table)
if not columns <= actual:
return False
return not conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name IN ('asset_snapshot_runs','manual_assets','asset_snapshots','account_valuations')").fetchone()
def _existing_columns(conn: sqlite3.Connection, table: str) -> set[str]:
return {row["name"] for row in conn.execute(f"PRAGMA table_info({table})")}
@@ -16,8 +67,158 @@ def _add_missing_columns(conn: sqlite3.Connection, table: str, columns: dict[str
conn.execute(f"ALTER TABLE {table} ADD COLUMN {name} {definition}")
def _drop_month_settlement_latest_run_id(conn: sqlite3.Connection) -> None:
"""Rebuild monthly_settlement so SQLite removes its FK-bearing column safely."""
if "latest_run_id" not in _existing_columns(conn, "monthly_settlement"):
return
# The canonical child table references this parent; SQLite otherwise
# rejects the deliberate parent-table rebuild even though child rows stay.
foreign_keys = conn.execute("PRAGMA foreign_keys").fetchone()[0]
conn.execute("PRAGMA defer_foreign_keys=ON")
conn.execute("PRAGMA foreign_keys=OFF")
try:
_drop_month_settlement_latest_run_id_impl(conn)
finally:
conn.execute(f"PRAGMA foreign_keys={'ON' if foreign_keys else 'OFF'}")
def _drop_month_settlement_latest_run_id_impl(conn: sqlite3.Connection) -> None:
conn.execute("""CREATE TABLE month_settlement__v16 (
id TEXT PRIMARY KEY,
month TEXT NOT NULL UNIQUE CHECK (
length(month) = 7 AND substr(month, 5, 1) = '-' AND
CAST(substr(month, 1, 4) AS INTEGER) BETWEEN 1 AND 9999 AND
CAST(substr(month, 6, 2) AS INTEGER) BETWEEN 1 AND 12
),
basis_date TEXT NOT NULL CHECK (date(basis_date) = basis_date),
status TEXT NOT NULL CHECK (status IN ('draft', 'in_review', 'approved', 'closed', 'cancelled')),
version INTEGER NOT NULL CHECK (typeof(version) = 'integer' AND version > 0),
memo TEXT NOT NULL DEFAULT '', created_by TEXT NOT NULL, created_at TEXT NOT NULL,
updated_by TEXT NOT NULL, updated_at TEXT NOT NULL, reviewed_by TEXT, reviewed_at TEXT,
review_fingerprint TEXT, approved_by TEXT, approved_at TEXT,
approved_version INTEGER CHECK (approved_version IS NULL OR (typeof(approved_version) = 'integer' AND approved_version > 0)),
approved_fingerprint TEXT, approved_cashflow_fingerprint TEXT, approval_reason TEXT, approval_flags_json TEXT NOT NULL DEFAULT '{}',
closed_by TEXT, closed_at TEXT, cancelled_by TEXT, cancelled_at TEXT, cancellation_reason TEXT,
CHECK (status NOT IN ('approved', 'closed') OR (approved_by IS NOT NULL AND approved_at IS NOT NULL AND approved_version IS NOT NULL AND approved_fingerprint IS NOT NULL)),
CHECK (status <> 'closed' OR (closed_by IS NOT NULL AND closed_at IS NOT NULL)),
CHECK (status <> 'cancelled' OR (cancelled_by IS NOT NULL AND cancelled_at IS NOT NULL AND cancellation_reason IS NOT NULL))
)""")
columns = [row[1] for row in conn.execute("PRAGMA table_info(monthly_settlement)") if row[1] != "latest_run_id"]
names = ", ".join(columns)
conn.execute(f"INSERT INTO month_settlement__v16 ({names}) SELECT {names} FROM monthly_settlement")
conn.execute("PRAGMA defer_foreign_keys=ON")
conn.execute("PRAGMA foreign_keys=OFF")
try:
conn.execute("DROP TABLE monthly_settlement")
conn.execute("ALTER TABLE month_settlement__v16 RENAME TO monthly_settlement")
finally:
conn.execute("PRAGMA foreign_keys=ON")
conn.execute("CREATE INDEX IF NOT EXISTS idx_month_settlement_status_month ON monthly_settlement(status, month)")
def _migrate_cashflow_snapshots_to_monthly(conn: sqlite3.Connection) -> None:
"""Replace the retired run FK only when every legacy row maps uniquely."""
parent = conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='asset_snapshot_runs'").fetchone()
child = conn.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='settlement_cashflow_snapshots'").fetchone()
if not child or "monthly_settlement_id" in (child["sql"] or ""):
return
if not parent:
if conn.execute("SELECT 1 FROM settlement_cashflow_snapshots LIMIT 1").fetchone() is None:
conn.execute("DROP TABLE settlement_cashflow_snapshots")
conn.execute("""CREATE TABLE settlement_cashflow_snapshots (
id TEXT PRIMARY KEY, monthly_settlement_id TEXT NOT NULL REFERENCES monthly_settlement(id) ON DELETE RESTRICT,
cashflow_kind TEXT NOT NULL, category TEXT NOT NULL DEFAULT '', subcategory TEXT NOT NULL DEFAULT '',
amount INTEGER NOT NULL, transaction_count INTEGER NOT NULL, currency TEXT NOT NULL,
UNIQUE (monthly_settlement_id, cashflow_kind, category, subcategory, currency)
)""")
return
raise sqlite3.DatabaseError("cannot migrate settlement cashflow snapshots: legacy run parent is missing")
run_columns = _existing_columns(conn, "asset_snapshot_runs")
period_column = next((c for c in ("settlement_month", "month", "period", "basis_date") if c in run_columns), None)
if period_column is None:
raise sqlite3.DatabaseError("cannot migrate settlement cashflow snapshots: legacy run has no month metadata")
rows = conn.execute(f"SELECT id, {period_column} AS period FROM asset_snapshot_runs").fetchall()
mapping = {}
for row in rows:
period = row["period"]
month = period[:7] if period and len(period) >= 7 else period
matches = conn.execute("SELECT id FROM monthly_settlement WHERE month=?", (month,)).fetchall()
if len(matches) != 1:
if conn.execute("SELECT 1 FROM settlement_cashflow_snapshots WHERE settlement_run_id=?", (row["id"],)).fetchone():
raise sqlite3.DatabaseError(f"ambiguous legacy settlement run mapping: {row['id']}")
else:
mapping[row["id"]] = matches[0]["id"]
conn.execute("ALTER TABLE settlement_cashflow_snapshots RENAME TO settlement_cashflow_snapshots__legacy")
conn.execute("""CREATE TABLE settlement_cashflow_snapshots (
id TEXT PRIMARY KEY, monthly_settlement_id TEXT NOT NULL REFERENCES monthly_settlement(id) ON DELETE RESTRICT,
cashflow_kind TEXT NOT NULL CHECK (cashflow_kind IN ('income', 'expense', 'excluded')),
category TEXT NOT NULL DEFAULT '', subcategory TEXT NOT NULL DEFAULT '',
amount INTEGER NOT NULL CHECK (typeof(amount) = 'integer'),
transaction_count INTEGER NOT NULL CHECK (typeof(transaction_count) = 'integer' AND transaction_count > 0),
currency TEXT NOT NULL,
UNIQUE (monthly_settlement_id, cashflow_kind, category, subcategory, currency)
)""")
for row in conn.execute("SELECT * FROM settlement_cashflow_snapshots__legacy"):
target = mapping.get(row["settlement_run_id"])
if target is None:
raise sqlite3.DatabaseError(f"cannot migrate legacy settlement run without unique monthly mapping: {row['settlement_run_id']}")
conn.execute("INSERT INTO settlement_cashflow_snapshots VALUES(?,?,?,?,?,?,?,?)", (row["id"], target, row["cashflow_kind"], row["category"], row["subcategory"], row["amount"], row["transaction_count"], row["currency"]))
conn.execute("DROP TABLE settlement_cashflow_snapshots__legacy")
conn.execute("CREATE INDEX IF NOT EXISTS idx_settlement_cashflow_snapshots_month ON settlement_cashflow_snapshots(monthly_settlement_id)")
def _drop_legacy_snapshot_storage(conn: sqlite3.Connection) -> None:
_migrate_cashflow_snapshots_to_monthly(conn)
conn.execute("DROP TABLE IF EXISTS asset_snapshot_runs")
def _rename_legacy_month_settlement(conn: sqlite3.Connection) -> None:
"""Rename the legacy parent before fresh schema creation updates child FKs."""
old = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='month_settlement'"
).fetchone()
new = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='monthly_settlement'"
).fetchone()
if old and new:
raise sqlite3.DatabaseError("both month_settlement and monthly_settlement exist")
if old:
conn.execute("ALTER TABLE month_settlement RENAME TO monthly_settlement")
def _rename_legacy_settlement_column(conn: sqlite3.Connection) -> None:
"""Rename the child FK column without rebuilding rows or changing schema version."""
columns = _existing_columns(conn, "account_snapshot")
if "month_settlement_id" in columns and "monthly_settlement_id" in columns:
raise sqlite3.DatabaseError("both settlement FK columns exist")
if "month_settlement_id" in columns:
conn.execute(
"ALTER TABLE account_snapshot RENAME COLUMN month_settlement_id TO monthly_settlement_id"
)
def _rename_legacy_snapshot_table(conn: sqlite3.Connection) -> None:
"""Rename the historical table in place, preserving rows and objects."""
old = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='asset_snapshots'"
).fetchone()
new = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='account_snapshot'"
).fetchone()
if old and new:
raise sqlite3.DatabaseError("both asset_snapshots and account_snapshot exist")
if old:
conn.execute("ALTER TABLE asset_snapshots RENAME TO account_snapshot")
def _drop_account_valuations(conn: sqlite3.Connection) -> None:
"""Remove the deprecated valuation table without touching other tables."""
conn.execute("DROP TABLE IF EXISTS account_valuations")
def migrate_db(conn: sqlite3.Connection) -> None:
"""Apply additive, idempotent migrations only."""
_drop_account_valuations(conn)
conn.execute("""CREATE TABLE IF NOT EXISTS settlement_drafts (
id TEXT PRIMARY KEY, month TEXT NOT NULL, basis_date TEXT NOT NULL,
status TEXT NOT NULL, version INTEGER NOT NULL, created_by TEXT NOT NULL,
@@ -83,22 +284,7 @@ def migrate_db(conn: sqlite3.Connection) -> None:
_add_missing_columns(conn, "ledger_categories", {"cashflow_kind": "TEXT NOT NULL DEFAULT 'expense'"})
conn.execute("CREATE INDEX IF NOT EXISTS idx_ledger_categories_active_sort ON ledger_categories(active, sort_order, category, subcategory)")
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_categories_unique ON ledger_categories(category, subcategory)")
conn.execute("""CREATE TABLE IF NOT EXISTS manual_assets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
liquidity_bucket TEXT NOT NULL DEFAULT 'other',
valuation_amount INTEGER,
valuation_date TEXT NOT NULL DEFAULT '',
currency TEXT NOT NULL DEFAULT 'KRW',
source_note TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '',
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_manual_assets_active_bucket ON manual_assets(active, liquidity_bucket, name)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_manual_assets_valuation_date ON manual_assets(valuation_date)")
conn.execute("""CREATE TABLE IF NOT EXISTS asset_snapshots (
conn.execute("""CREATE TABLE IF NOT EXISTS account_snapshot (
id TEXT PRIMARY KEY,
basis_date TEXT NOT NULL,
source_type TEXT NOT NULL,
@@ -110,16 +296,15 @@ def migrate_db(conn: sqlite3.Connection) -> None:
valuation_source TEXT NOT NULL DEFAULT '',
source_note TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '',
snapshot_run_id TEXT NOT NULL DEFAULT '',
correction_of_run_id TEXT NOT NULL DEFAULT '',
correction_reason TEXT NOT NULL DEFAULT '',
is_correction INTEGER NOT NULL DEFAULT 0,
closed_at TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
)""")
_add_missing_columns(conn, "asset_snapshots", {
"snapshot_run_id": "TEXT NOT NULL DEFAULT ''",
"correction_of_run_id": "TEXT NOT NULL DEFAULT ''",
_add_missing_columns(conn, "account_snapshot", {
"monthly_settlement_id": "TEXT REFERENCES monthly_settlement(id) ON DELETE RESTRICT",
"correction_of_run_id": "TEXT NOT NULL DEFAULT ''",
"correction_reason": "TEXT NOT NULL DEFAULT ''",
"is_correction": "INTEGER NOT NULL DEFAULT 0",
"closed_at": "TEXT NOT NULL DEFAULT ''",
@@ -129,48 +314,13 @@ def migrate_db(conn: sqlite3.Connection) -> None:
"evidence_kind": "TEXT NOT NULL DEFAULT 'applied'",
"exclusion_reason": "TEXT NOT NULL DEFAULT ''",
})
conn.execute("CREATE INDEX IF NOT EXISTS idx_asset_snapshots_basis_date ON asset_snapshots(basis_date)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_asset_snapshots_source ON asset_snapshots(source_type, source_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_asset_snapshots_run ON asset_snapshots(basis_date, snapshot_run_id, created_at)")
conn.execute("""CREATE TABLE IF NOT EXISTS asset_snapshot_runs (
id TEXT PRIMARY KEY, basis_date TEXT NOT NULL, close_status TEXT NOT NULL,
selection_policy TEXT NOT NULL DEFAULT 'exact', idempotency_key TEXT NOT NULL DEFAULT '',
approved_by TEXT NOT NULL DEFAULT '', approved_at TEXT NOT NULL DEFAULT '',
approval_reason TEXT NOT NULL DEFAULT '', approval_reference TEXT NOT NULL DEFAULT '',
correction_of_run_id TEXT NOT NULL DEFAULT '', correction_reason TEXT NOT NULL DEFAULT '',
applied_evidence TEXT NOT NULL DEFAULT '[]', excluded_evidence TEXT NOT NULL DEFAULT '[]',
carried_evidence TEXT NOT NULL DEFAULT '[]', request_fingerprint TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL
)""")
_add_missing_columns(conn, "asset_snapshot_runs", {"request_fingerprint": "TEXT NOT NULL DEFAULT ''", "draft_id": "TEXT NOT NULL DEFAULT ''", "draft_version": "INTEGER NOT NULL DEFAULT 0", "review_fingerprint": "TEXT NOT NULL DEFAULT ''"})
_add_missing_columns(conn, "asset_snapshot_runs", {
"settlement_month": "TEXT",
"currency": "TEXT",
"income_total": "INTEGER",
"expense_total": "INTEGER",
"net_cashflow": "INTEGER",
"total_assets": "INTEGER",
"total_liabilities": "INTEGER",
"net_worth": "INTEGER",
"cashflow_policy_json": "TEXT",
"aggregation_version": "TEXT",
"snapshot_schema_version": "INTEGER",
"source_snapshot_json": "TEXT",
})
conn.execute("""CREATE TABLE IF NOT EXISTS settlement_cashflow_snapshots (
id TEXT PRIMARY KEY,
settlement_run_id TEXT NOT NULL REFERENCES asset_snapshot_runs(id) ON DELETE RESTRICT,
cashflow_kind TEXT NOT NULL CHECK (cashflow_kind IN ('income', 'expense', 'excluded')),
category TEXT NOT NULL DEFAULT '',
subcategory TEXT NOT NULL DEFAULT '',
amount INTEGER NOT NULL CHECK (typeof(amount) = 'integer'),
transaction_count INTEGER NOT NULL CHECK (typeof(transaction_count) = 'integer' AND transaction_count > 0),
currency TEXT NOT NULL,
UNIQUE (settlement_run_id, cashflow_kind, category, subcategory, currency)
)""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_settlement_cashflow_snapshots_run ON settlement_cashflow_snapshots(settlement_run_id)")
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_idempotency ON asset_snapshot_runs(idempotency_key) WHERE idempotency_key <> ''")
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_initial_basis ON asset_snapshot_runs(basis_date) WHERE correction_of_run_id = ''")
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_correction_parent ON asset_snapshot_runs(correction_of_run_id) WHERE correction_of_run_id <> ''")
conn.execute("CREATE INDEX IF NOT EXISTS idx_account_snapshot_basis_date ON account_snapshot(basis_date)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_account_snapshot_source ON account_snapshot(source_type, source_id)")
if "monthly_settlement_id" in _existing_columns(conn, "account_snapshot"):
conn.execute("CREATE INDEX IF NOT EXISTS idx_account_snapshot_month_settlement ON account_snapshot(monthly_settlement_id, basis_date, created_at)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_settlement_cashflow_snapshots_month ON settlement_cashflow_snapshots(monthly_settlement_id)")
conn.execute("""CREATE TABLE IF NOT EXISTS review_decisions (
id TEXT PRIMARY KEY,
target_type TEXT NOT NULL,
@@ -205,28 +355,58 @@ def migrate_db(conn: sqlite3.Connection) -> None:
)""")
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_reconciliation_sessions_account_basis ON reconciliation_sessions(account_id, basis_date)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_reconciliation_sessions_status ON reconciliation_sessions(status, closed_at)")
conn.execute("""CREATE TABLE IF NOT EXISTS account_valuations (
id TEXT PRIMARY KEY, account_id TEXT NOT NULL REFERENCES accounts(id), basis_date TEXT NOT NULL,
amount INTEGER NOT NULL, currency TEXT NOT NULL DEFAULT 'KRW', valuation_kind TEXT NOT NULL DEFAULT 'balance',
source_type TEXT NOT NULL DEFAULT 'manual', source_ref TEXT NOT NULL DEFAULT '',
verification_status TEXT NOT NULL DEFAULT 'unverified', recorded_at TEXT NOT NULL,
memo TEXT NOT NULL DEFAULT '', idempotency_key TEXT NOT NULL DEFAULT '',
supersedes_id TEXT REFERENCES account_valuations(id), correction_reason TEXT NOT NULL DEFAULT '',
as_of_at TEXT, timezone TEXT, cost_basis INTEGER, net_contributed INTEGER,
reported_principal_kind TEXT, reported_principal_label TEXT, profit INTEGER,
return_rate REAL, return_basis TEXT, principal INTEGER, interest INTEGER, fee INTEGER,
representative_liability_definition TEXT
_drop_month_settlement_latest_run_id(conn)
conn.execute("""CREATE TABLE IF NOT EXISTS monthly_settlement (
id TEXT PRIMARY KEY,
month TEXT NOT NULL UNIQUE CHECK (
length(month) = 7 AND substr(month, 5, 1) = '-' AND
CAST(substr(month, 1, 4) AS INTEGER) BETWEEN 1 AND 9999 AND
CAST(substr(month, 6, 2) AS INTEGER) BETWEEN 1 AND 12
),
basis_date TEXT NOT NULL CHECK (date(basis_date) = basis_date),
status TEXT NOT NULL CHECK (status IN ('draft', 'in_review', 'approved', 'closed', 'cancelled')),
version INTEGER NOT NULL CHECK (typeof(version) = 'integer' AND version > 0),
memo TEXT NOT NULL DEFAULT '', created_by TEXT NOT NULL, created_at TEXT NOT NULL,
updated_by TEXT NOT NULL, updated_at TEXT NOT NULL, reviewed_by TEXT, reviewed_at TEXT,
review_fingerprint TEXT, approved_by TEXT, approved_at TEXT,
approved_version INTEGER CHECK (approved_version IS NULL OR (typeof(approved_version) = 'integer' AND approved_version > 0)),
approved_fingerprint TEXT, approved_cashflow_fingerprint TEXT, approval_reason TEXT, approval_flags_json TEXT NOT NULL DEFAULT '{}',
closed_by TEXT, closed_at TEXT, cancelled_by TEXT, cancelled_at TEXT, cancellation_reason TEXT,
CHECK (status NOT IN ('approved', 'closed') OR (approved_by IS NOT NULL AND approved_at IS NOT NULL AND approved_version IS NOT NULL AND approved_fingerprint IS NOT NULL)),
CHECK (status <> 'closed' OR (closed_by IS NOT NULL AND closed_at IS NOT NULL)),
CHECK (status <> 'cancelled' OR (cancelled_by IS NOT NULL AND cancelled_at IS NOT NULL AND cancellation_reason IS NOT NULL))
)""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_account_valuations_account_date ON account_valuations(account_id, basis_date, recorded_at)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_account_valuations_status_date ON account_valuations(verification_status, basis_date)")
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_account_valuations_idempotency ON account_valuations(account_id, idempotency_key) WHERE idempotency_key <> ''")
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_account_valuations_single_correction ON account_valuations(supersedes_id) WHERE supersedes_id IS NOT NULL")
conn.execute("CREATE INDEX IF NOT EXISTS idx_month_settlement_status_month ON monthly_settlement(status, month)")
_add_missing_columns(conn, "monthly_settlement", {"approved_cashflow_fingerprint": "TEXT"})
_drop_legacy_snapshot_storage(conn)
# schema version 16 retires manual_assets without creating replacement rows.
conn.execute("DROP TABLE IF EXISTS manual_assets")
conn.execute("INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', ?)", (SCHEMA_VERSION,))
def init_db(db_path: str | None = None) -> str:
path = resolve_db_path(db_path)
with connect(str(path)) as conn:
conn.executescript(SCHEMA_SQL)
stored = _stored_version(conn)
if stored not in (None, "14", "15"):
if stored == "16" and _compatible_known_v16(conn):
pass
else:
raise sqlite3.DatabaseError(f"unsupported schema version {stored}; refusing implicit downgrade")
# Keep the entire migration atomic. In particular, executescript() commits
# implicitly, which would leave half-renamed legacy tables after a failure.
conn.execute("BEGIN")
conn.execute("PRAGMA defer_foreign_keys=ON")
conn.execute("PRAGMA foreign_keys=OFF")
_rename_legacy_month_settlement(conn)
_rename_legacy_snapshot_table(conn)
_rename_legacy_settlement_column(conn)
# The canonical schema creates monthly_settlement before legacy cashflow
# rows are mapped from asset_snapshot_runs.
_execute_script_transactional(conn, SCHEMA_SQL)
_migrate_cashflow_snapshots_to_monthly(conn)
migrate_db(conn)
conn.execute("PRAGMA foreign_keys=ON")
conn.execute("INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', ?)", (SCHEMA_VERSION,))
conn.commit()
return str(path)
@@ -1,6 +1,6 @@
from __future__ import annotations
SCHEMA_VERSION = "14"
SCHEMA_VERSION = "15"
SCHEMA_SQL = """
PRAGMA journal_mode=WAL;
@@ -131,23 +131,7 @@ CREATE TABLE IF NOT EXISTS ledger_categories (
CREATE INDEX IF NOT EXISTS idx_ledger_categories_active_sort ON ledger_categories(active, sort_order, category, subcategory);
CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_categories_unique ON ledger_categories(category, subcategory);
CREATE TABLE IF NOT EXISTS manual_assets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
liquidity_bucket TEXT NOT NULL DEFAULT 'other',
valuation_amount INTEGER,
valuation_date TEXT NOT NULL DEFAULT '',
currency TEXT NOT NULL DEFAULT 'KRW',
source_note TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '',
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_manual_assets_active_bucket ON manual_assets(active, liquidity_bucket, name);
CREATE INDEX IF NOT EXISTS idx_manual_assets_valuation_date ON manual_assets(valuation_date);
CREATE TABLE IF NOT EXISTS asset_snapshots (
CREATE TABLE IF NOT EXISTS account_snapshot (
id TEXT PRIMARY KEY,
basis_date TEXT NOT NULL,
source_type TEXT NOT NULL,
@@ -159,7 +143,7 @@ CREATE TABLE IF NOT EXISTS asset_snapshots (
valuation_source TEXT NOT NULL DEFAULT '',
source_note TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '',
snapshot_run_id TEXT NOT NULL DEFAULT '',
monthly_settlement_id TEXT REFERENCES monthly_settlement(id) ON DELETE RESTRICT,
correction_of_run_id TEXT NOT NULL DEFAULT '',
correction_reason TEXT NOT NULL DEFAULT '',
is_correction INTEGER NOT NULL DEFAULT 0,
@@ -171,55 +155,20 @@ CREATE TABLE IF NOT EXISTS asset_snapshots (
exclusion_reason TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_asset_snapshots_basis_date ON asset_snapshots(basis_date);
CREATE INDEX IF NOT EXISTS idx_asset_snapshots_source ON asset_snapshots(source_type, source_id);
CREATE TABLE IF NOT EXISTS asset_snapshot_runs (
id TEXT PRIMARY KEY,
basis_date TEXT NOT NULL,
close_status TEXT NOT NULL,
selection_policy TEXT NOT NULL DEFAULT 'exact',
idempotency_key TEXT NOT NULL DEFAULT '',
approved_by TEXT NOT NULL DEFAULT '',
approved_at TEXT NOT NULL DEFAULT '',
approval_reason TEXT NOT NULL DEFAULT '',
approval_reference TEXT NOT NULL DEFAULT '',
correction_of_run_id TEXT NOT NULL DEFAULT '',
correction_reason TEXT NOT NULL DEFAULT '',
applied_evidence TEXT NOT NULL DEFAULT '[]',
excluded_evidence TEXT NOT NULL DEFAULT '[]',
carried_evidence TEXT NOT NULL DEFAULT '[]',
request_fingerprint TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
settlement_month TEXT,
currency TEXT,
income_total INTEGER,
expense_total INTEGER,
net_cashflow INTEGER,
total_assets INTEGER,
total_liabilities INTEGER,
net_worth INTEGER,
cashflow_policy_json TEXT,
aggregation_version TEXT,
snapshot_schema_version INTEGER,
source_snapshot_json TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_idempotency ON asset_snapshot_runs(idempotency_key) WHERE idempotency_key <> '';
CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_initial_basis ON asset_snapshot_runs(basis_date) WHERE correction_of_run_id = '';
CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_correction_parent ON asset_snapshot_runs(correction_of_run_id) WHERE correction_of_run_id <> '';
CREATE INDEX IF NOT EXISTS idx_account_snapshot_basis_date ON account_snapshot(basis_date);
CREATE INDEX IF NOT EXISTS idx_account_snapshot_source ON account_snapshot(source_type, source_id);
CREATE TABLE IF NOT EXISTS settlement_cashflow_snapshots (
id TEXT PRIMARY KEY,
settlement_run_id TEXT NOT NULL REFERENCES asset_snapshot_runs(id) ON DELETE RESTRICT,
monthly_settlement_id TEXT NOT NULL REFERENCES monthly_settlement(id) ON DELETE RESTRICT,
cashflow_kind TEXT NOT NULL CHECK (cashflow_kind IN ('income', 'expense', 'excluded')),
category TEXT NOT NULL DEFAULT '',
subcategory TEXT NOT NULL DEFAULT '',
amount INTEGER NOT NULL CHECK (typeof(amount) = 'integer'),
transaction_count INTEGER NOT NULL CHECK (typeof(transaction_count) = 'integer' AND transaction_count > 0),
currency TEXT NOT NULL,
UNIQUE (settlement_run_id, cashflow_kind, category, subcategory, currency)
UNIQUE (monthly_settlement_id, cashflow_kind, category, subcategory, currency)
);
CREATE INDEX IF NOT EXISTS idx_settlement_cashflow_snapshots_run ON settlement_cashflow_snapshots(settlement_run_id);
CREATE INDEX IF NOT EXISTS idx_settlement_cashflow_snapshots_month ON settlement_cashflow_snapshots(monthly_settlement_id);
CREATE TABLE IF NOT EXISTS review_decisions (
id TEXT PRIMARY KEY,
@@ -257,41 +206,48 @@ CREATE TABLE IF NOT EXISTS reconciliation_sessions (
CREATE UNIQUE INDEX IF NOT EXISTS idx_reconciliation_sessions_account_basis ON reconciliation_sessions(account_id, basis_date);
CREATE INDEX IF NOT EXISTS idx_reconciliation_sessions_status ON reconciliation_sessions(status, closed_at);
CREATE TABLE IF NOT EXISTS account_valuations (
CREATE TABLE IF NOT EXISTS monthly_settlement (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL REFERENCES accounts(id),
basis_date TEXT NOT NULL,
amount INTEGER NOT NULL,
currency TEXT NOT NULL DEFAULT 'KRW',
valuation_kind TEXT NOT NULL DEFAULT 'balance',
source_type TEXT NOT NULL DEFAULT 'manual',
source_ref TEXT NOT NULL DEFAULT '',
verification_status TEXT NOT NULL DEFAULT 'unverified',
recorded_at TEXT NOT NULL,
month TEXT NOT NULL UNIQUE CHECK (
length(month) = 7 AND substr(month, 5, 1) = '-' AND
CAST(substr(month, 1, 4) AS INTEGER) BETWEEN 1 AND 9999 AND
CAST(substr(month, 6, 2) AS INTEGER) BETWEEN 1 AND 12
),
basis_date TEXT NOT NULL CHECK (date(basis_date) = basis_date),
status TEXT NOT NULL CHECK (status IN ('draft', 'in_review', 'approved', 'closed', 'cancelled')),
version INTEGER NOT NULL CHECK (typeof(version) = 'integer' AND version > 0),
memo TEXT NOT NULL DEFAULT '',
idempotency_key TEXT NOT NULL DEFAULT '',
supersedes_id TEXT REFERENCES account_valuations(id),
correction_reason TEXT NOT NULL DEFAULT '',
as_of_at TEXT,
timezone TEXT,
cost_basis INTEGER,
net_contributed INTEGER,
reported_principal_kind TEXT,
reported_principal_label TEXT,
profit INTEGER,
return_rate REAL,
return_basis TEXT,
principal INTEGER,
interest INTEGER,
fee INTEGER,
representative_liability_definition TEXT
created_by TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_by TEXT NOT NULL,
updated_at TEXT NOT NULL,
reviewed_by TEXT,
reviewed_at TEXT,
review_fingerprint TEXT,
approved_by TEXT,
approved_at TEXT,
approved_version INTEGER CHECK (approved_version IS NULL OR (typeof(approved_version) = 'integer' AND approved_version > 0)),
approved_fingerprint TEXT,
approved_cashflow_fingerprint TEXT,
approval_reason TEXT,
approval_flags_json TEXT NOT NULL DEFAULT '{}',
closed_by TEXT,
closed_at TEXT,
cancelled_by TEXT,
cancelled_at TEXT,
cancellation_reason TEXT,
CHECK (status NOT IN ('approved', 'closed') OR (
approved_by IS NOT NULL AND approved_at IS NOT NULL AND
approved_version IS NOT NULL AND approved_fingerprint IS NOT NULL
)),
CHECK (status <> 'closed' OR (closed_by IS NOT NULL AND closed_at IS NOT NULL)),
CHECK (status <> 'cancelled' OR (
cancelled_by IS NOT NULL AND cancelled_at IS NOT NULL AND cancellation_reason IS NOT NULL
))
);
CREATE INDEX IF NOT EXISTS idx_account_valuations_account_date ON account_valuations(account_id, basis_date, recorded_at);
CREATE INDEX IF NOT EXISTS idx_account_valuations_status_date ON account_valuations(verification_status, basis_date);
CREATE UNIQUE INDEX IF NOT EXISTS idx_account_valuations_idempotency ON account_valuations(account_id, idempotency_key) WHERE idempotency_key <> '';
CREATE UNIQUE INDEX IF NOT EXISTS idx_account_valuations_single_correction ON account_valuations(supersedes_id) WHERE supersedes_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_month_settlement_status_month ON monthly_settlement(status, month);
INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', '14');
INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', '15');
"""
ACCOUNT_ADDITIVE_COLUMNS: dict[str, str] = {