108 lines
7.5 KiB
Python
108 lines
7.5 KiB
Python
from __future__ import annotations
|
|
from .shared import Any, VALID_ACCOUNT_TYPES, db, fail, new_id, normalize_date, normalize_liquidity_bucket, normalize_statement_status, now_iso, ok, row_to_dict
|
|
|
|
|
|
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
|
|
|
|
|
|
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]:
|
|
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)
|
|
except Exception as e: return fail("validation_error", str(e))
|
|
ts = now_iso(); account_id = new_id("acct")
|
|
try:
|
|
basis_date = normalize_date(last_balance_basis_date, default_today=False) if last_balance_basis_date else ""
|
|
except Exception as e: return fail("validation_error", str(e))
|
|
if basis_date and (last_balance is None or last_balance == ""):
|
|
return fail("validation_error", "last_balance_basis_date requires last_balance")
|
|
if last_balance is not None:
|
|
try:
|
|
from .account_valuation_service import _strict_int
|
|
last_balance = _strict_int(last_balance, "last_balance", allow_none=False)
|
|
except (ValueError, TypeError) as e:
|
|
return fail("validation_error", str(e))
|
|
with db(db_path) as conn:
|
|
conn.execute("INSERT INTO accounts(id,name,type,institution,currency,active,memo,account_number_masked,holder_name,last_balance,last_balance_basis_date,last_synced_at,statement_status,liquidity_bucket,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", (account_id,name.strip(),type,institution,currency.upper(),int(active),memo,account_number_masked,holder_name,last_balance,basis_date,last_synced_at,statement_status_norm,liquidity_bucket_norm,ts,ts))
|
|
row = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone()
|
|
account = row_to_dict(row)
|
|
_record_balance_candidate(conn, account)
|
|
return ok(account=account)
|
|
|
|
|
|
def update_ledger_account(account_id: str, db_path: str | None = None, **fields: Any) -> dict[str, Any]:
|
|
allowed = {"name","type","institution","currency","active","memo","account_number_masked","holder_name","last_balance","last_balance_basis_date","last_synced_at","statement_status","liquidity_bucket"}
|
|
with db(db_path) as conn:
|
|
current = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone()
|
|
if current is None: return fail("not_found", "account not found")
|
|
balance_update = "last_balance" in fields and fields.get("last_balance") is not None
|
|
basis_update = "last_balance_basis_date" in fields and fields.get("last_balance_basis_date") is not None
|
|
if basis_update and not balance_update:
|
|
return fail("validation_error", "last_balance_basis_date requires last_balance")
|
|
normalized_balance = None
|
|
if balance_update:
|
|
try:
|
|
from .account_valuation_service import _strict_int
|
|
normalized_balance = _strict_int(fields["last_balance"], "last_balance", allow_none=False)
|
|
except (ValueError, TypeError) as e:
|
|
return fail("validation_error", str(e))
|
|
normalized_basis = None
|
|
if basis_update:
|
|
try:
|
|
normalized_basis = normalize_date(fields["last_balance_basis_date"], default_today=False) if fields["last_balance_basis_date"] else ""
|
|
except Exception as e:
|
|
return fail("validation_error", str(e))
|
|
updates=[]; params=[]
|
|
for k,v in fields.items():
|
|
if k not in allowed or v is None: continue
|
|
try:
|
|
if k == "type" and v not in VALID_ACCOUNT_TYPES: return fail("validation_error", f"type must be one of {sorted(VALID_ACCOUNT_TYPES)}")
|
|
if k == "statement_status": v = normalize_statement_status(str(v))
|
|
if k == "liquidity_bucket": v = normalize_liquidity_bucket(str(v))
|
|
if k == "active": v = int(bool(v))
|
|
if k == "last_balance": v = normalized_balance
|
|
if k == "currency": v = str(v).upper()
|
|
if k == "last_balance_basis_date": v = normalized_basis
|
|
except Exception as e: return fail("validation_error", str(e))
|
|
updates.append(f"{k}=?"); params.append(v)
|
|
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")
|
|
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)
|
|
candidate_balance = normalized_balance if balance_update else current["last_balance"]
|
|
if historical:
|
|
kept=[(u,p) for u,p in zip(updates,params) if not u.startswith(("last_balance=","last_balance_basis_date=","last_synced_at="))]
|
|
updates=[u for u,_ in kept]; params=[p for _,p in kept]
|
|
elif balance_update and not basis_update:
|
|
# An explicit current-balance write without dated evidence advances
|
|
# the display cache but keeps its historical basis unknown.
|
|
updates.append("last_balance_basis_date=?"); params.append("")
|
|
updates.append("updated_at=?"); params.extend([now_iso(), account_id])
|
|
conn.execute(f"UPDATE accounts SET {', '.join(updates)} WHERE id=?", params)
|
|
row = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone()
|
|
account = row_to_dict(row)
|
|
if balance_update and basis_update:
|
|
_record_balance_candidate(conn, account, candidate_balance, incoming_date)
|
|
return ok(account=account)
|