fix: preserve schema 15 compatibility for v0.1.8

This commit is contained in:
2026-10-17 18:27:53 +09:00
parent a97548c36b
commit 1134f058cc
61 changed files with 2650 additions and 644 deletions
@@ -1,40 +1,418 @@
from __future__ import annotations
import sqlite3
from typing import Any
from datetime import date
from uuid import uuid4
import math
import re
from typing import Any
import sqlite3
from .shared import db, fail, now_iso, ok, read_db
from .shared import db, read_db, read_operation, fail, new_id, normalize_date, now_iso, ok, row_to_dict
_ALLOWED_VERIFICATION = {"pending", "unverified"}
_ALLOWED_SETTLEMENT_STATUS = {"draft", "in_review"}
LIQUIDITY_BUCKETS = ("cash", "deposit", "investment", "real_estate", "loan", "other")
def normalize_liquidity_bucket(value: Any, *, default: str = "other") -> str:
value = default if value is None or str(value).strip() == "" else str(value).strip().lower()
if value not in LIQUIDITY_BUCKETS:
raise ValueError("liquidity_bucket must be cash, deposit, investment, real_estate, loan or other")
return value
_VALID_STATUSES = {"verified", "unverified", "rejected"}
_VALID_LOAN_DEFINITIONS = {"principal_only", "principal_interest_fees"}
_VALID_CURRENCIES = {"KRW", "USD", "EUR", "JPY", "CNY", "GBP"}
_VALID_KINDS = {"balance", "cash_balance", "total_value", "loan", "loan_balance"}
_VALID_SOURCE_TYPES = {"manual", "import", "account_write", "reconciliation", "snapshot"}
_COLUMNS = (
"amount", "currency", "valuation_kind", "source_type", "source_ref",
"verification_status", "memo", "as_of_at", "timezone", "cost_basis",
"net_contributed", "reported_principal_kind", "reported_principal_label",
"profit", "return_rate", "return_basis", "principal", "interest", "fee",
"representative_liability_definition",
)
def _public_candidate(item: dict[str, Any], strict: bool = False) -> dict[str, Any]:
"""Compatibility projection used by asset evidence readers."""
allowed = {"id", "source_id", "source_type", "name", "amount", "currency", "basis_date",
"valuation_id", "valuation_kind", "verification_status", "evidence_kind", "memo"}
return {key: value for key, value in item.items() if key in allowed}
def _valuation(row: Any) -> dict[str, Any]:
return row_to_dict(row)
def record_account_valuation_candidate(*args: Any, **kwargs: Any) -> dict[str, Any]:
return fail("unsupported", "account valuation candidates are not writable")
def _public_candidate(row: Any) -> dict[str, Any]:
return {k: row[k] for k in ("id", "basis_date", "currency", "valuation_kind", "verification_status")}
def _strict_int(value: Any, field: str) -> int:
if isinstance(value, bool) or not isinstance(value, int):
def _strict_int(value: Any, field: str, allow_none: bool = True) -> int | None:
if value is None and allow_none:
return None
if isinstance(value, bool):
raise ValueError(f"{field} must be an integer")
return value
if isinstance(value, int):
return value
if isinstance(value, str) and re.fullmatch(r"[+-]?\d+", value.strip()):
return int(value.strip())
raise ValueError(f"{field} must be an integer")
def _strict_float(value: Any, field: str) -> float | None:
if value is None:
return None
if isinstance(value, bool):
raise ValueError(f"{field} must be a finite number")
try:
result = float(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{field} must be a finite number") from exc
if not math.isfinite(result):
raise ValueError(f"{field} must be a finite number")
return result
def _validate_common(account_id: str, basis_date: str, amount: int, currency: str, verification_status: str, valuation_kind: str, source_type: str, representative_liability_definition: str | None) -> tuple[str, int, str, str, str, str | None] | dict[str, Any]:
if not isinstance(account_id, str) or not account_id.strip():
return fail("validation_error", "account_id must be non-empty")
try:
basis = normalize_date(basis_date)
amount_int = _strict_int(amount, "amount", allow_none=False)
except Exception as exc:
return fail("validation_error", str(exc))
currency_norm = (currency or "KRW").strip().upper()
if currency_norm not in _VALID_CURRENCIES:
return fail("validation_error", f"currency must be one of {sorted(_VALID_CURRENCIES)}")
status = (verification_status or "unverified").strip().lower()
if status not in _VALID_STATUSES:
return fail("validation_error", "verification_status must be verified, unverified, or rejected")
if not isinstance(valuation_kind, str):
return fail("validation_error", "valuation_kind must be a string")
kind = valuation_kind.strip().lower() or "balance"
if kind not in _VALID_KINDS:
return fail("validation_error", f"valuation_kind must be one of {sorted(_VALID_KINDS)}")
source = (source_type or "manual").strip().lower() or "manual"
if source not in _VALID_SOURCE_TYPES:
return fail("validation_error", f"source_type must be one of {sorted(_VALID_SOURCE_TYPES)}")
definition = representative_liability_definition.strip() if representative_liability_definition else None
if definition is not None and definition not in _VALID_LOAN_DEFINITIONS:
return fail("validation_error", "representative liability definition is invalid")
if kind in {"loan", "loan_balance"} and definition is None:
return fail("validation_error", "loan valuation requires representative_liability_definition")
if kind not in {"loan", "loan_balance", "balance"} and definition is not None:
return fail("validation_error", "valuation kind cannot define representative liability")
if amount_int is None:
return fail("validation_error", "amount must be an integer")
return basis, amount_int, currency_norm, status, source, definition
def record_account_valuation_candidate(conn: sqlite3.Connection, account_id: str, basis_date: str, amount: int, currency: str, source_type: str, source_ref: str, idempotency_key: str, memo: str = "") -> dict[str, Any]:
"""Append an unverified candidate on a caller-owned transaction."""
account = conn.execute("SELECT type,currency FROM accounts WHERE id=?", (account_id,)).fetchone()
if account is None:
return fail("not_found", "account not found")
if (currency or "KRW").upper() != (account["currency"] or "KRW").upper():
return fail("validation_error", "valuation currency must match account currency")
if str(account["type"]).lower() == "loan":
return fail("validation_error", "loan valuation requires representative_liability_definition")
try:
amount_int = _strict_int(amount, "amount", allow_none=False)
except (TypeError, ValueError) as exc:
return fail("validation_error", str(exc))
basis = normalize_date(basis_date)
existing = conn.execute("SELECT * FROM account_valuations WHERE account_id=? AND idempotency_key=?", (account_id, idempotency_key)).fetchone()
if existing:
incoming = {
"basis_date": basis,
"amount": amount_int,
"currency": (currency or "KRW").upper(),
"valuation_kind": "balance",
"source_type": source_type,
"source_ref": source_ref or "",
"verification_status": "unverified",
"memo": memo,
}
if all(existing[key] == value for key, value in incoming.items()):
return ok(valuation=_valuation(existing), reused=True)
return fail(
"idempotency_conflict",
"idempotency_key already has a different candidate payload",
valuation=_valuation(existing),
)
valuation_id = new_id("valuation")
conn.execute("""INSERT INTO account_valuations
(id,account_id,basis_date,amount,currency,valuation_kind,source_type,source_ref,verification_status,recorded_at,memo,idempotency_key)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""", (valuation_id, account_id, basis, amount_int, (currency or "KRW").upper(), "balance", source_type, source_ref or "", "unverified", now_iso(), memo, idempotency_key))
return ok(valuation=_valuation(conn.execute("SELECT * FROM account_valuations WHERE id=?", (valuation_id,)).fetchone()), reused=False)
def register_account_valuation(
account_id: str,
basis_date: str,
amount: int,
currency: str = "KRW",
valuation_kind: str = "balance",
source_type: str = "manual",
source_ref: str = "",
verification_status: str = "unverified",
memo: str = "",
idempotency_key: str = "",
as_of_at: str | None = None,
timezone: str | None = None,
cost_basis: int | None = None,
net_contributed: int | None = None,
reported_principal_kind: str | None = None,
reported_principal_label: str | None = None,
profit: int | None = None,
return_rate: float | None = None,
return_basis: str | None = None,
principal: int | None = None,
interest: int | None = None,
fee: int | None = None,
representative_liability_definition: str | None = None,
db_path: str | None = None,
supersedes_id: str | None = None,
correction_reason: str = "",
*, monthly_settlement_id: str | None = None, liquidity_bucket: str | None = None,
selection_policy: str = "manual", valuation_source: str = "manual", source_note: str = "",
) -> dict[str, Any]:
if monthly_settlement_id is not None:
return _register_monthly_account_valuation(
account_id, basis_date, amount, currency, valuation_kind,
"account" if source_type == "manual" else source_type, source_ref,
verification_status, memo, idempotency_key, monthly_settlement_id,
db_path, liquidity_bucket, selection_policy=selection_policy,
valuation_source=valuation_source, source_note=source_note,
)
validated = _validate_common(account_id, basis_date, amount, currency, verification_status, valuation_kind, source_type, representative_liability_definition)
if isinstance(validated, dict):
return validated
basis, amount_int, currency_norm, status, source_norm, definition = validated
try:
optional_ints = {name: _strict_int(value, name) for name, value in {
"cost_basis": cost_basis, "net_contributed": net_contributed, "profit": profit,
"principal": principal, "interest": interest, "fee": fee}.items()}
return_rate = _strict_float(return_rate, "return_rate")
except (TypeError, ValueError) as exc:
return fail("validation_error", str(exc))
if (as_of_at is None) != (timezone is None):
return fail("validation_error", "as_of_at and timezone must be supplied together")
if as_of_at is not None:
try:
from datetime import datetime
parsed = datetime.fromisoformat(as_of_at)
if parsed.tzinfo is None:
return fail("validation_error", "as_of_at must include timezone offset")
except (TypeError, ValueError) as exc:
return fail("validation_error", f"invalid as_of_at: {exc}")
if supersedes_id and not correction_reason.strip():
return fail("validation_error", "correction_reason is required")
ts = now_iso()
source_ref_norm = source_ref.strip() if isinstance(source_ref, str) else ""
values = (amount_int, currency_norm, (valuation_kind or "balance").strip().lower(), source_norm, source_ref_norm, status, memo or "", as_of_at, timezone, optional_ints["cost_basis"], optional_ints["net_contributed"], reported_principal_kind, reported_principal_label, optional_ints["profit"], return_rate, return_basis, optional_ints["principal"], optional_ints["interest"], optional_ints["fee"], definition)
with db(db_path) as conn:
conn.execute("BEGIN IMMEDIATE")
account = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone()
if account is None:
return fail("not_found", "account not found")
if currency_norm != (account["currency"] or "KRW").upper():
return fail("validation_error", "valuation currency must match account currency")
effective_kind = (valuation_kind or "balance").strip().lower()
is_loan_account = str(account["type"]).lower() == "loan"
if effective_kind in {"loan", "loan_balance"} and not is_loan_account:
return fail("validation_error", "loan valuation kind requires a loan account")
if is_loan_account and effective_kind not in {"balance", "loan", "loan_balance"}:
return fail("validation_error", "loan account valuation kind must represent a liability")
if is_loan_account and effective_kind in {"balance", "loan", "loan_balance"} and definition is None:
return fail("validation_error", "loan valuation requires representative_liability_definition")
if definition is not None and not is_loan_account:
return fail("validation_error", "representative liability definition requires a loan account")
seen: set[str] = set()
if supersedes_id:
parent = conn.execute("SELECT * FROM account_valuations WHERE id=?", (supersedes_id,)).fetchone()
if parent is None:
if idempotency_key and conn.execute("SELECT 1 FROM account_valuations WHERE account_id=? AND idempotency_key=?", (account_id, idempotency_key)).fetchone():
return fail("idempotency_conflict", "idempotency_key already has a different payload")
return fail("validation_error", "supersedes_id must reference an existing valuation")
if any(parent[field] != value for field, value in (
("account_id", account_id), ("basis_date", basis),
("currency", currency_norm), ("valuation_kind", (valuation_kind or "balance").strip().lower()),
)):
return fail("validation_error", "superseded valuation scope must match account, basis, currency, and valuation kind")
seen: set[str] = set()
cursor = parent
while cursor is not None:
cursor_id = cursor["id"]
if cursor_id in seen:
return fail("validation_error", "supersedes ancestry is cyclic")
seen.add(cursor_id)
next_id = cursor["supersedes_id"]
cursor = conn.execute("SELECT * FROM account_valuations WHERE id=?", (next_id,)).fetchone() if next_id else None
existing = None
by_key = conn.execute("SELECT * FROM account_valuations WHERE account_id=? AND idempotency_key=?", (account_id, idempotency_key)).fetchone() if idempotency_key else None
source_rows = conn.execute("SELECT * FROM account_valuations WHERE account_id=? AND source_ref=? ORDER BY recorded_at, id", (account_id, source_ref_norm)).fetchall() if source_ref_norm else []
correction_chain = seen if supersedes_id else set()
unrelated_source = next((row for row in source_rows if row["id"] not in correction_chain and (by_key is None or row["id"] != by_key["id"])), None)
if by_key is not None and unrelated_source is not None:
return fail("idempotency_conflict", "idempotency_key and source_ref identify different candidates")
if unrelated_source is not None:
by_source = unrelated_source
else:
by_source = None
existing = by_key or by_source
if existing:
current = _valuation(existing)
incoming = dict(zip(_COLUMNS, values))
incoming.update({"account_id": account_id, "basis_date": basis, "supersedes_id": supersedes_id, "correction_reason": correction_reason or ""})
comparable = set(incoming) - {"id", "recorded_at"}
if all(current.get(k) == v for k, v in incoming.items() if k in comparable):
return ok(valuation=current, reused=True)
return fail("idempotency_conflict", "idempotency_key already has a different payload", valuation=current)
valuation_id = new_id("valuation")
try:
conn.execute("""INSERT INTO account_valuations
(id,account_id,basis_date,amount,currency,valuation_kind,source_type,source_ref,verification_status,recorded_at,memo,idempotency_key,supersedes_id,correction_reason,as_of_at,timezone,cost_basis,net_contributed,reported_principal_kind,reported_principal_label,profit,return_rate,return_basis,principal,interest,fee,representative_liability_definition)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (valuation_id, account_id, basis, *values[:6], ts, values[6], idempotency_key or "", supersedes_id, correction_reason, *values[7:]))
except sqlite3.IntegrityError:
if supersedes_id:
return fail("correction_conflict", "valuation already has a correction")
return fail("idempotency_conflict", "idempotency_key already has a different payload")
except Exception as exc:
return fail("validation_error", str(exc))
row = conn.execute("SELECT * FROM account_valuations WHERE id=?", (valuation_id,)).fetchone()
return ok(valuation=_valuation(row), reused=False)
@read_operation
def list_account_valuations(account_id: str, include_rejected: bool = True, db_path: str | None = None) -> dict[str, Any]:
where = "" if include_rejected else " AND verification_status <> 'rejected'"
with read_db(db_path) as conn:
rows = conn.execute(
f"SELECT * FROM account_valuations WHERE account_id=?{where} ORDER BY basis_date, recorded_at, id",
(account_id,),
).fetchall()
snapshots = conn.execute(
f"""SELECT * FROM account_snapshot
WHERE source_type='account' AND source_id=?{where}
ORDER BY basis_date, created_at, id""",
(account_id,),
).fetchall()
values = [_valuation(row) for row in rows]
for row in snapshots:
value = _valuation(row)
value.update(
account_id=value["source_id"],
recorded_at=value["created_at"],
valuation_kind="balance",
source_ref=value.get("source_note", ""),
)
for key in _COLUMNS:
value.setdefault(key, None)
value.setdefault("idempotency_key", "")
value.setdefault("supersedes_id", None)
value.setdefault("correction_reason", "")
values.append(value)
values.sort(key=lambda value: (value["basis_date"], value["recorded_at"], value["id"]))
return ok(account_id=account_id, valuations=values, count=len(values))
@read_operation
def select_account_valuation(account_id: str, basis_date: str, as_of: bool = False, currency: str | None = None, valuation_kind: str | None = None, db_path: str | None = None) -> dict[str, Any]:
try:
basis = normalize_date(basis_date)
except Exception as exc:
return fail("validation_error", str(exc))
with read_db(db_path) as conn:
clauses = ["account_id=?"]
params: list[Any] = [account_id]
if currency:
clauses.append("UPPER(currency)=?"); params.append(currency.strip().upper())
if valuation_kind:
clauses.append("valuation_kind=?"); params.append(valuation_kind.strip().lower())
rows = conn.execute(f"SELECT * FROM account_valuations WHERE {' AND '.join(clauses)} ORDER BY basis_date DESC, recorded_at DESC, id DESC", params).fetchall()
leaves = [r for r in rows if conn.execute("SELECT 1 FROM account_valuations WHERE supersedes_id=?", (r["id"],)).fetchone() is None]
verified = [r for r in leaves if r["verification_status"] == "verified" and r["basis_date"] <= basis]
exact = [r for r in verified if r["basis_date"] == basis]
# Candidates are evidence at or before the requested basis. Exact
# selection only surfaces same-date unverified evidence; as-of
# selection surfaces unverified evidence newer than the selected
# verified value, while never looking into the future.
latest_verified_date = max((x["basis_date"] for x in verified), default="")
candidate = [
r for r in leaves
if r["verification_status"] == "unverified"
and r["basis_date"] <= basis
and (r["basis_date"] == basis if not as_of else (not latest_verified_date or r["basis_date"] > latest_verified_date))
]
matching = exact if not as_of else [r for r in verified if r["basis_date"] == latest_verified_date]
if len(matching) > 1:
return fail("ambiguous_valuation", "multiple verified valuation leaves match the scope")
row = matching[0] if matching else None
if row is None:
result = ok(account_id=account_id, basis_date=basis, valuation=None, carried_forward=False)
if candidate:
result["warning"] = "unverified_candidate"
result["unverified_candidates"] = [_public_candidate(item) for item in candidate]
return result
age = (date.fromisoformat(basis) - date.fromisoformat(row["basis_date"])).days
warning = "unverified_candidate" if candidate else ("carry_forward" if age else None)
result = ok(account_id=account_id, basis_date=basis, valuation=_valuation(row), carried_forward=bool(age), valuation_age_days=age, warning=warning)
if candidate:
result["unverified_candidates"] = [_public_candidate(item) for item in candidate]
return result
def correct_account_valuation(valuation_id: str, amount: int, correction_reason: str, idempotency_key: str = "", db_path: str | None = None, **fields: Any) -> dict[str, Any]:
if not correction_reason or not correction_reason.strip():
return fail("validation_error", "correction_reason is required")
with db(db_path) as conn:
source = conn.execute("SELECT * FROM account_valuations WHERE id=?", (valuation_id,)).fetchone()
if source is None:
return fail("not_found", "valuation not found")
if "currency" in fields and fields["currency"] is not None and fields["currency"].strip().upper() != source["currency"].upper():
return fail("validation_error", "correction currency must match parent valuation")
if "valuation_kind" in fields and fields["valuation_kind"] is not None and fields["valuation_kind"].strip().lower() != source["valuation_kind"]:
return fail("validation_error", "correction valuation_kind must match parent valuation")
preserved = {name: fields[name] if name in fields else source[name] for name in (
"currency", "valuation_kind", "source_type", "source_ref", "verification_status", "memo", "as_of_at", "timezone",
"cost_basis", "net_contributed", "reported_principal_kind", "reported_principal_label", "profit", "return_rate", "return_basis", "principal", "interest", "fee", "representative_liability_definition")}
try:
effective = {
"amount": _strict_int(amount, "amount", allow_none=False),
"account_id": source["account_id"],
"basis_date": source["basis_date"],
"currency": (preserved["currency"] or "KRW").strip().upper(),
"valuation_kind": (preserved["valuation_kind"] or "balance").strip().lower(),
"source_type": (preserved["source_type"] or "manual").strip().lower() or "manual",
"source_ref": preserved["source_ref"].strip() if isinstance(preserved["source_ref"], str) else "",
"verification_status": (preserved["verification_status"] or "unverified").strip().lower(),
"memo": preserved["memo"] or "",
"as_of_at": preserved["as_of_at"],
"timezone": preserved["timezone"],
"cost_basis": _strict_int(preserved["cost_basis"], "cost_basis"),
"net_contributed": _strict_int(preserved["net_contributed"], "net_contributed"),
"reported_principal_kind": preserved["reported_principal_kind"],
"reported_principal_label": preserved["reported_principal_label"],
"profit": _strict_int(preserved["profit"], "profit"),
"return_rate": _strict_float(preserved["return_rate"], "return_rate"),
"return_basis": preserved["return_basis"],
"principal": _strict_int(preserved["principal"], "principal"),
"interest": _strict_int(preserved["interest"], "interest"),
"fee": _strict_int(preserved["fee"], "fee"),
"representative_liability_definition": preserved["representative_liability_definition"].strip() if preserved["representative_liability_definition"] else None,
"supersedes_id": valuation_id,
"correction_reason": correction_reason.strip(),
}
except (TypeError, ValueError, AttributeError) as exc:
return fail("validation_error", str(exc))
if idempotency_key:
prior = conn.execute("SELECT * FROM account_valuations WHERE account_id=? AND idempotency_key=?", (source["account_id"], idempotency_key)).fetchone()
if prior:
if all(prior[key] == value for key, value in effective.items()):
return ok(valuation=_valuation(prior), reused=True)
return fail("idempotency_conflict", "idempotency_key already has a different payload", valuation=_valuation(prior))
child = conn.execute("SELECT * FROM account_valuations WHERE supersedes_id=?", (valuation_id,)).fetchone()
if child is not None:
if all(child[key] == value for key, value in effective.items()):
return ok(valuation=_valuation(child), reused=True)
return fail("correction_conflict", "correction payload conflicts with existing correction", valuation=_valuation(child))
return register_account_valuation(
source["account_id"], source["basis_date"], amount,
**preserved,
idempotency_key=idempotency_key,
supersedes_id=valuation_id, correction_reason=correction_reason.strip(),
db_path=db_path,
)
_ALLOWED_VERIFICATION = {"pending", "unverified"}
_ALLOWED_SETTLEMENT_STATUS = {"draft", "in_review"}
def _register_monthly_account_valuation(
account_id: str,
basis_date: str,
amount: int,
@@ -59,7 +437,8 @@ def register_account_valuation(
try:
if not account_id or not basis_date or not currency:
raise ValueError("account_id, basis_date and currency are required")
amount = _strict_int(amount, "amount")
if isinstance(amount, bool) or not isinstance(amount, int):
raise ValueError("amount must be an integer")
if source_type != "account":
raise ValueError("source_type must be account")
if verification_status not in _ALLOWED_VERIFICATION:
@@ -122,21 +501,6 @@ def register_account_valuation(
return fail("db_error", "account evaluation registration failed")
def list_account_valuations(account_id: str, include_rejected: bool = True, db_path: str | None = None) -> dict[str, Any]:
try:
with read_db(db_path) as conn:
rows = conn.execute("SELECT * FROM account_snapshot WHERE source_type='account' AND source_id=? ORDER BY created_at,id", (account_id,)).fetchall()
return ok(valuations=[dict(r) for r in rows], count=len(rows), read_only=True)
except Exception:
return fail("db_error", "account evaluations unavailable")
def select_account_valuation(*args: Any, **kwargs: Any) -> dict[str, Any]:
return fail("unsupported", "account valuation selection is unavailable")
def correct_account_valuation(*args: Any, **kwargs: Any) -> dict[str, Any]:
return fail("unsupported", "account valuation correction is unavailable")
def update_account_snapshot_category(snapshot_id: str, monthly_settlement_id: str, liquidity_bucket: str, db_path: str | None = None) -> dict[str, Any]:
try:
@@ -161,3 +525,12 @@ def update_account_snapshot_category(snapshot_id: str, monthly_settlement_id: st
return fail("validation_error", str(exc))
except Exception:
return fail("db_error", "account snapshot category update failed")
LIQUIDITY_BUCKETS = ("cash", "deposit", "investment", "real_estate", "loan", "other")
def normalize_liquidity_bucket(value: Any, *, default: str = "other") -> str:
value = default if value is None or str(value).strip() == "" else str(value).strip().lower()
if value not in LIQUIDITY_BUCKETS:
raise ValueError("liquidity_bucket must be cash, deposit, investment, real_estate, loan or other")
return value