refactor(jarca-ledger): implement BAT-010 backend core layering

This commit is contained in:
Jarca
2026-07-03 07:03:17 +09:00
parent 02380a88ab
commit 7ac25a6cf7
36 changed files with 4892 additions and 4113 deletions
+6
View File
@@ -0,0 +1,6 @@
from . import shared
from .asset_account_services import *
from .categorization_services import *
from .import_transfer_services import *
from .system_services import *
from .transaction_report_services import *
@@ -0,0 +1,524 @@
from __future__ import annotations
from .shared import *
def _valuation_policy() -> dict[str, Any]:
return {
"authority_sources": ["accounts.last_balance", "manual_assets"],
"snapshot_role": "historical_record_layer",
"formula": "asset_buckets - loan",
"asset_buckets": ASSET_BUCKETS,
"liability_buckets": LIABILITY_BUCKETS,
"missing_valuation_policy": "exclude_without_transaction_inference",
}
def _account_valuation_items(conn: sqlite3.Connection) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]:
rows = conn.execute("SELECT * FROM accounts WHERE active=1 ORDER BY name").fetchall()
included: list[dict[str, Any]] = []
excluded: list[dict[str, Any]] = []
invalid: list[dict[str, Any]] = []
for row in rows:
account = row_to_dict(row)
raw_bucket = str(account.get("liquidity_bucket") or "other")
try:
bucket = normalize_liquidity_bucket(raw_bucket)
except ValueError:
bucket = "other"
invalid.append({"id": account.get("id", ""), "name": account.get("name", ""), "liquidity_bucket": raw_bucket})
base = {
"id": account.get("id", ""),
"source_id": account.get("id", ""),
"source_type": "account",
"authority_source": "accounts.last_balance",
"name": account.get("name", ""),
"type": account.get("type", ""),
"liquidity_bucket": bucket,
"valuation_date": account.get("last_synced_at", ""),
"last_synced_at": account.get("last_synced_at", ""),
"source_note": account.get("memo", ""),
"memo": account.get("memo", ""),
}
balance = account.get("last_balance")
if balance is None:
excluded.append({**base, "reason": "missing_last_balance"})
continue
amount = int(balance)
included.append({**base, "amount": amount, "last_balance": amount})
return included, excluded, invalid
def _manual_asset_valuation_items(conn: sqlite3.Connection, active_only: bool = True) -> 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)
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", ""),
"liquidity_bucket": bucket,
"valuation_date": asset.get("valuation_date", ""),
"source_note": asset.get("source_note", ""),
"memo": asset.get("memo", ""),
"active": asset.get("active", True),
}
value = asset.get("valuation_amount")
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 _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()
if not rows:
return [], {}
run_rows = [row for row in rows if "snapshot_run_id" in row.keys() and row["snapshot_run_id"]]
if not run_rows:
return rows, {"snapshot_run_id": "", "is_correction": False, "correction_of_run_id": "", "correction_reason": "", "total_snapshot_rows_for_basis_date": len(rows)}
latest = sorted(run_rows, key=lambda row: (int(row["_rowid"]), row["closed_at"] or row["created_at"] or "", row["snapshot_run_id"] or ""))[-1]
latest_run_id = latest["snapshot_run_id"]
selected = [row for row in run_rows if row["snapshot_run_id"] == latest_run_id]
metadata = {
"snapshot_run_id": latest_run_id,
"is_correction": bool(latest["is_correction"]),
"correction_of_run_id": latest["correction_of_run_id"] or "",
"correction_reason": latest["correction_reason"] or "",
"closed_at": latest["closed_at"] or latest["created_at"] or "",
"total_snapshot_rows_for_basis_date": len(rows),
}
return selected, metadata
def _snapshot_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str, Any]]:
rows, _metadata = _snapshot_rows_for_latest_run(conn, basis_date)
items: list[dict[str, Any]] = []
for row in rows:
snap = row_to_dict(row)
try:
bucket = normalize_liquidity_bucket(snap.get("liquidity_bucket"))
except ValueError:
bucket = "other"
items.append(
{
"id": snap.get("id", ""),
"source_id": snap.get("source_id", ""),
"source_type": snap.get("source_type", ""),
"authority_source": "asset_snapshots",
"valuation_source": snap.get("valuation_source", ""),
"name": snap.get("name", ""),
"liquidity_bucket": bucket,
"amount": int(snap.get("amount") or 0),
"valuation_date": snap.get("basis_date", ""),
"source_note": snap.get("source_note", ""),
"memo": snap.get("memo", ""),
"snapshot_run_id": snap.get("snapshot_run_id", ""),
"is_correction": bool(snap.get("is_correction", False)),
"correction_of_run_id": snap.get("correction_of_run_id", ""),
"correction_reason": snap.get("correction_reason", ""),
}
)
return items
def _summarize_valuation_items(included_items: list[dict[str, Any]], excluded_items: list[dict[str, Any]] | None = None) -> dict[str, Any]:
bucket_totals = {bucket: 0 for bucket in ASSET_BUCKET_ORDER}
bucket_counts = {bucket: 0 for bucket in ASSET_BUCKET_ORDER}
for item in included_items:
bucket = normalize_liquidity_bucket(item.get("liquidity_bucket"))
amount = int(item.get("amount") or 0)
bucket_counts[bucket] += 1
bucket_totals[bucket] += amount
total_assets = sum(bucket_totals[bucket] for bucket in ASSET_BUCKETS)
total_liabilities = sum(abs(bucket_totals[bucket]) for bucket in LIABILITY_BUCKETS)
net_worth = total_assets - total_liabilities
buckets = []
for bucket in ASSET_BUCKET_ORDER:
amount = bucket_totals[bucket]
denominator = total_liabilities if bucket in LIABILITY_BUCKETS else total_assets
buckets.append(
{
"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],
"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,
}
)
return {
"buckets": buckets,
"bucket_totals": bucket_totals,
"total_assets": total_assets,
"total_liabilities": total_liabilities,
"net_worth": net_worth,
"included_items": included_items,
"excluded_items": excluded_items or [],
"valuation_policy": _valuation_policy(),
}
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 list_manual_assets(active_only: bool = True, db_path: str | None = None) -> dict[str, Any]:
where = "WHERE active=1" if active_only else ""
with db(db_path) as conn:
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 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 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 _snapshot_preview_from_conn(conn: sqlite3.Connection, basis: str) -> dict[str, Any]:
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
summary = _summarize_valuation_items(included, excluded)
existing_rows = conn.execute("SELECT COUNT(*) FROM asset_snapshots 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", ""), "name": item.get("name", "")})
for item in included:
item_date = str(item.get("valuation_date") or "")[:10]
if item_date and item_date < basis:
warnings.append({"code": "stale_valuation", "source_type": item.get("source_type", ""), "source_id": item.get("source_id", ""), "name": item.get("name", ""), "valuation_date": item_date, "basis_date": basis})
for item in invalid_bucket_accounts:
warnings.append({"code": "invalid_liquidity_bucket", **item})
if existing_rows:
warnings.append({"code": "existing_snapshot_rows", "basis_date": basis, "snapshot_row_count": int(existing_rows), "policy": "append_only_correction_required"})
return {
"basis_date": basis,
"source": "live_authority",
"is_closed_snapshot": False,
"snapshot_count": 0,
"existing_snapshot_row_count": int(existing_rows),
"would_be_correction": bool(existing_rows),
"warnings": warnings,
**summary,
}
def preview_asset_snapshot_close(basis_date: str = "", db_path: str | None = None) -> dict[str, Any]:
try:
basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat()
except Exception as e:
return fail("validation_error", str(e))
with db(db_path) as conn:
preview = _snapshot_preview_from_conn(conn, basis)
return ok(**preview)
def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str = "", correction_reason: str = "", db_path: str | None = None) -> dict[str, Any]:
try:
basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat()
except Exception as e:
return fail("validation_error", str(e))
ts = now_iso()
run_id = new_id("snaprun")
with db(db_path) as conn:
preview = _snapshot_preview_from_conn(conn, basis)
included = preview["included_items"]
latest_rows, latest_meta = _snapshot_rows_for_latest_run(conn, basis)
is_correction = bool(preview["existing_snapshot_row_count"])
if is_correction and not correction_reason.strip():
return fail("validation_error", "correction_reason is required when closing a basis_date that already has snapshot rows", basis_date=basis, existing_snapshot_row_count=preview["existing_snapshot_row_count"])
correction_of_run_id = latest_meta.get("snapshot_run_id", "") if latest_rows else ""
snapshot_ids: list[str] = []
for item in included:
snapshot_id = new_id("snap")
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,created_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
snapshot_id,
basis,
item.get("source_type", ""),
item.get("source_id", item.get("id", "")),
item.get("name", ""),
int(item.get("amount") or 0),
item.get("currency", "KRW"),
normalize_liquidity_bucket(item.get("liquidity_bucket")),
item.get("authority_source", item.get("source_type", "")),
row_note,
row_memo,
run_id,
correction_of_run_id,
correction_reason.strip(),
int(is_correction),
ts,
ts,
),
)
snapshot_ids.append(snapshot_id)
report_items = _snapshot_items(conn, basis)
summary = _summarize_valuation_items(report_items, [])
return ok(
basis_date=basis,
source="snapshot",
is_closed_snapshot=True,
snapshot_run_id=run_id,
correction_of_run_id=correction_of_run_id,
correction_reason=correction_reason.strip(),
is_correction=is_correction,
closed_at=ts,
inserted_count=len(snapshot_ids),
inserted_snapshot_ids=snapshot_ids,
warnings=preview["warnings"],
**summary,
)
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 not name.strip():
return fail("validation_error", "snapshot item name is required")
try:
basis = normalize_date(basis_date, default_today=True)
bucket = normalize_liquidity_bucket(liquidity_bucket)
amount_int = int(amount) if amount is not None else None
except Exception as e:
return fail("validation_error", str(e))
ts = now_iso()
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)
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()
return ok(asset_snapshot=row_to_dict(row))
def get_asset_snapshot_report(basis_date: str = "", db_path: str | None = None) -> dict[str, Any]:
basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat()
with db(db_path) as conn:
rows, close_metadata = _snapshot_rows_for_latest_run(conn, basis)
snapshots = _snapshot_items(conn, basis) if rows else []
if snapshots:
summary = _summarize_valuation_items(snapshots, [])
return ok(
basis_date=basis,
source="asset_snapshots",
is_closed_snapshot=True,
snapshot_count=len(snapshots),
close_metadata=close_metadata,
**summary,
)
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
summary = _summarize_valuation_items(included, excluded)
return ok(
basis_date=basis,
source="live_authority",
is_closed_snapshot=False,
snapshot_count=0,
invalid_bucket_accounts=invalid_bucket_accounts,
**summary,
)
def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: int = 12, db_path: str | None = None) -> dict[str, Any]:
try:
months_int = max(1, min(int(months), 120))
end_norm = normalize_date(end_date, default_today=True) if end_date else current_kst_date().isoformat()
if start_date:
start_norm = normalize_date(start_date)
else:
end_dt = date.fromisoformat(end_norm)
start_month = end_dt.month - months_int + 1
year = end_dt.year + (start_month - 1) // 12
month = (start_month - 1) % 12 + 1
start_norm = date(year, month, 1).isoformat()
except Exception as e:
return fail("validation_error", str(e))
with db(db_path) as conn:
basis_rows = conn.execute(
"""SELECT DISTINCT basis_date FROM asset_snapshots
WHERE basis_date>=? AND basis_date<=?
ORDER BY basis_date ASC""",
(start_norm, end_norm),
).fetchall()
points = []
for row in basis_rows:
basis = row["basis_date"]
items = _snapshot_items(conn, basis)
summary = _summarize_valuation_items(items, [])
points.append(
{
"basis_date": basis,
"total_assets": summary["total_assets"],
"total_liabilities": summary["total_liabilities"],
"net_worth": summary["net_worth"],
"source": "asset_snapshots",
"item_count": len(items),
}
)
return ok(start_date=start_norm, end_date=end_norm, months=months_int, points=points, count=len(points), valuation_policy=_valuation_policy())
def get_asset_liquidity_report(db_path: str | None = None) -> dict[str, Any]:
"""Summarize assets/liabilities from shared valuation authority sources.
Existing REQ-005 keys are preserved. When manual_assets is empty, totals and
source remain equivalent to the original accounts.last_balance behavior.
"""
with db(db_path) as conn:
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
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"
return ok(
buckets=summary["buckets"],
accounts=source_accounts,
manual_assets=manual_items,
total_assets=summary["total_assets"],
total_liabilities=summary["total_liabilities"],
net_worth=summary["net_worth"],
missing_balance_accounts=[
{"id": item.get("id", ""), "name": item.get("name", ""), "liquidity_bucket": item.get("liquidity_bucket", "")}
for item in account_excluded
],
missing_valuation_items=excluded,
invalid_bucket_accounts=invalid_bucket_accounts,
manual_asset_count=len(manual_items),
included_items=included,
excluded_items=excluded,
valuation_policy=summary["valuation_policy"],
source=source,
)
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_synced_at", "statement_status", "liquidity_bucket"}
updates: list[str] = []
params: list[Any] = []
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" and v != "":
v = int(v)
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")
updates.append("updated_at=?")
params.extend([now_iso(), account_id])
with db(db_path) as conn:
cur = conn.execute(f"UPDATE accounts SET {', '.join(updates)} WHERE id=?", params)
if cur.rowcount == 0:
return fail("not_found", "account not found")
row = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone()
return ok(account=row_to_dict(row))
@@ -0,0 +1,703 @@
from __future__ import annotations
from .shared import *
RULE_FIELDS = {"merchant", "counterparty", "description", "payment_method", "raw_summary", "branch", "memo"}
RULE_MATCH_TYPES = {"contains", "equals", "regex", "starts_with", "ends_with"}
DEFAULT_CATEGORY_RULES: list[dict[str, Any]] = [
{"priority": 10, "match_field": "merchant", "pattern": "급여", "transaction_type": "income", "category": "수입", "subcategory": "급여"},
{"priority": 10, "match_field": "merchant", "pattern": "이자", "transaction_type": "income", "category": "수입", "subcategory": "이자"},
{"priority": 10, "match_field": "merchant", "pattern": "환급", "transaction_type": "income", "category": "수입", "subcategory": "환급"},
{"priority": 20, "match_field": "raw_summary", "match_type": "equals", "pattern": "국민카드", "transaction_type": "expense", "category": "금융", "subcategory": "카드대금", "is_fixed": 1},
{"priority": 20, "match_field": "merchant", "pattern": "KB카드출금", "transaction_type": "expense", "category": "금융", "subcategory": "카드대금", "is_fixed": 1},
{"priority": 30, "match_field": "merchant", "pattern": "SK텔레콤", "transaction_type": "expense", "category": "통신", "subcategory": "휴대폰", "is_fixed": 1},
{"priority": 30, "match_field": "merchant", "pattern": "KT통신", "transaction_type": "expense", "category": "통신", "subcategory": "통신요금", "is_fixed": 1},
{"priority": 40, "match_field": "merchant", "pattern": "학원", "transaction_type": "expense", "category": "교육", "subcategory": "학원"},
{"priority": 40, "match_field": "merchant", "pattern": "교습소", "transaction_type": "expense", "category": "교육", "subcategory": "예체능"},
{"priority": 40, "match_field": "merchant", "pattern": "연세대학교", "transaction_type": "expense", "category": "의료", "subcategory": "병원"},
{"priority": 40, "match_field": "merchant", "pattern": "병원", "transaction_type": "expense", "category": "의료", "subcategory": "병원"},
{"priority": 40, "match_field": "merchant", "pattern": "의원", "transaction_type": "expense", "category": "의료", "subcategory": "병원"},
{"priority": 40, "match_field": "merchant", "pattern": "치과", "transaction_type": "expense", "category": "의료", "subcategory": "치과"},
{"priority": 40, "match_field": "merchant", "pattern": "약국", "transaction_type": "expense", "category": "의료", "subcategory": "약국"},
{"priority": 50, "match_field": "merchant", "pattern": "이마트", "transaction_type": "expense", "category": "식비", "subcategory": "마트"},
{"priority": 50, "match_field": "merchant", "pattern": "오아시스마켓", "transaction_type": "expense", "category": "식비", "subcategory": "마트"},
{"priority": 50, "match_field": "merchant", "pattern": "생협", "transaction_type": "expense", "category": "식비", "subcategory": "마트"},
{"priority": 50, "match_field": "merchant", "pattern": "스타벅스", "transaction_type": "expense", "category": "식비", "subcategory": "카페"},
{"priority": 50, "match_field": "merchant", "pattern": "메가MGC", "transaction_type": "expense", "category": "식비", "subcategory": "카페"},
{"priority": 50, "match_field": "merchant", "pattern": "매머드", "transaction_type": "expense", "category": "식비", "subcategory": "카페"},
{"priority": 50, "match_field": "merchant", "pattern": "커피", "transaction_type": "expense", "category": "식비", "subcategory": "카페"},
{"priority": 60, "match_field": "merchant", "pattern": "KFC", "transaction_type": "expense", "category": "식비", "subcategory": "외식"},
{"priority": 60, "match_field": "merchant", "pattern": "스시", "transaction_type": "expense", "category": "식비", "subcategory": "외식"},
{"priority": 60, "match_field": "merchant", "pattern": "쌀국수", "transaction_type": "expense", "category": "식비", "subcategory": "외식"},
{"priority": 60, "match_field": "merchant", "pattern": "짜장", "transaction_type": "expense", "category": "식비", "subcategory": "외식"},
{"priority": 80, "match_field": "raw_summary", "match_type": "equals", "pattern": "스마트출금", "category": "이체", "subcategory": "현금/계좌이동"},
{"priority": 80, "match_field": "raw_summary", "match_type": "equals", "pattern": "ATM입금", "category": "이체", "subcategory": "현금/계좌이동"},
]
def _rule_matches(rule: dict[str, Any], tx: dict[str, Any]) -> bool:
if rule.get("transaction_type") and rule.get("transaction_type") != tx.get("type", ""):
return False
amount = int(tx.get("amount") or 0)
if rule.get("amount_min") is not None and amount < int(rule["amount_min"]):
return False
if rule.get("amount_max") is not None and amount > int(rule["amount_max"]):
return False
if rule.get("day_of_month") is not None:
tx_date = tx.get("transaction_datetime") or tx.get("transaction_date") or ""
try:
if datetime.fromisoformat(str(tx_date)[:10]).day != int(rule["day_of_month"]):
return False
except ValueError:
return False
value = str(tx.get(rule["match_field"], "") or "")
pattern = str(rule.get("pattern", ""))
match_type = rule.get("match_type") or "contains"
if match_type == "contains":
return pattern.lower() in value.lower()
if match_type == "equals":
return value == pattern
if match_type == "starts_with":
return value.startswith(pattern)
if match_type == "ends_with":
return value.endswith(pattern)
if match_type == "regex":
return re.search(pattern, value) is not None
return False
def create_category_rule(match_field: str, pattern: str, category: str, subcategory: str = "", transaction_type: str = "", match_type: str = "contains", priority: int = 100, person: str = "", is_fixed: bool | None = None, confidence: float = 1.0, memo: str = "", amount_min: int | None = None, amount_max: int | None = None, day_of_month: int | None = None, db_path: str | None = None) -> dict[str, Any]:
if match_field not in RULE_FIELDS:
return fail("validation_error", f"match_field must be one of {sorted(RULE_FIELDS)}")
if match_type not in RULE_MATCH_TYPES:
return fail("validation_error", f"match_type must be one of {sorted(RULE_MATCH_TYPES)}")
if transaction_type and transaction_type not in VALID_TRANSACTION_TYPES:
return fail("validation_error", f"transaction_type must be empty or one of {sorted(VALID_TRANSACTION_TYPES)}")
if day_of_month is not None and not 1 <= int(day_of_month) <= 31:
return fail("validation_error", "day_of_month must be between 1 and 31")
if amount_min is not None and amount_max is not None and int(amount_min) > int(amount_max):
return fail("validation_error", "amount_min must be <= amount_max")
if not pattern or not category:
return fail("validation_error", "pattern and category are required")
ts=now_iso(); rule_id=new_id("rule")
with db(db_path) as conn:
conn.execute("""INSERT INTO category_rules(id,priority,match_field,match_type,pattern,amount_min,amount_max,day_of_month,transaction_type,category,subcategory,person,is_fixed,confidence,active,memo,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(match_field,match_type,pattern,transaction_type,category,subcategory,person) DO UPDATE SET priority=excluded.priority,amount_min=excluded.amount_min,amount_max=excluded.amount_max,day_of_month=excluded.day_of_month,is_fixed=excluded.is_fixed,confidence=excluded.confidence,active=1,memo=excluded.memo,updated_at=excluded.updated_at""",
(rule_id, int(priority), match_field, match_type, pattern, None if amount_min is None else int(amount_min), None if amount_max is None else int(amount_max), None if day_of_month is None else int(day_of_month), transaction_type, category, subcategory, person, None if is_fixed is None else int(is_fixed), float(confidence), 1, memo, ts, ts))
row=conn.execute("SELECT * FROM category_rules WHERE match_field=? AND match_type=? AND pattern=? AND transaction_type=? AND category=? AND subcategory=? AND person=?", (match_field, match_type, pattern, transaction_type, category, subcategory, person)).fetchone()
return ok(rule=row_to_dict(row))
def seed_default_category_rules(overwrite: bool = False, db_path: str | None = None) -> dict[str, Any]:
created=0; skipped=0
with db(db_path) as conn:
if overwrite:
conn.execute("UPDATE category_rules SET active=0 WHERE memo LIKE 'default:%'")
for rule in DEFAULT_CATEGORY_RULES:
memo = "default: initial Jarca ledger categorization"
res=create_category_rule(memo=memo, db_path=db_path, **rule)
if res.get("success"):
created += 1
else:
skipped += 1
return ok(created_or_updated=created, skipped=skipped)
def list_category_rules(active_only: bool = True, db_path: str | None = None) -> dict[str, Any]:
sql="SELECT * FROM category_rules" + (" WHERE active=1" if active_only else "") + " ORDER BY priority, id"
with db(db_path) as conn:
rows=conn.execute(sql).fetchall()
return ok(rules=[row_to_dict(r) for r in rows], count=len(rows))
def update_category_rule(rule_id: str, match_field: str, pattern: str, category: str, subcategory: str = "", transaction_type: str = "", match_type: str = "contains", priority: int = 100, person: str = "", is_fixed: bool | None = None, confidence: float = 1.0, memo: str = "", amount_min: int | None = None, amount_max: int | None = None, day_of_month: int | None = None, active: bool = True, db_path: str | None = None) -> dict[str, Any]:
if match_field not in RULE_FIELDS:
return fail("validation_error", f"match_field must be one of {sorted(RULE_FIELDS)}")
if match_type not in RULE_MATCH_TYPES:
return fail("validation_error", f"match_type must be one of {sorted(RULE_MATCH_TYPES)}")
if transaction_type and transaction_type not in VALID_TRANSACTION_TYPES:
return fail("validation_error", f"transaction_type must be empty or one of {sorted(VALID_TRANSACTION_TYPES)}")
if day_of_month is not None and not 1 <= int(day_of_month) <= 31:
return fail("validation_error", "day_of_month must be between 1 and 31")
if amount_min is not None and amount_max is not None and int(amount_min) > int(amount_max):
return fail("validation_error", "amount_min must be <= amount_max")
if not pattern or not category:
return fail("validation_error", "pattern and category are required")
ts = now_iso()
with db(db_path) as conn:
existing = conn.execute("SELECT * FROM category_rules WHERE id=?", (rule_id,)).fetchone()
if not existing:
return fail("not_found", f"category rule not found: {rule_id}")
duplicate = conn.execute(
"SELECT id FROM category_rules WHERE match_field=? AND match_type=? AND pattern=? AND transaction_type=? AND category=? AND subcategory=? AND person=? AND id<>?",
(match_field, match_type, pattern, transaction_type, category, subcategory, person, rule_id),
).fetchone()
if duplicate:
return fail("conflict", f"another rule already exists with the same unique fields: {duplicate['id']}")
conn.execute(
"""UPDATE category_rules
SET priority=?, match_field=?, match_type=?, pattern=?, amount_min=?, amount_max=?, day_of_month=?,
transaction_type=?, category=?, subcategory=?, person=?, is_fixed=?, confidence=?, active=?, memo=?, updated_at=?
WHERE id=?""",
(
int(priority), match_field, match_type, pattern,
None if amount_min is None else int(amount_min),
None if amount_max is None else int(amount_max),
None if day_of_month is None else int(day_of_month),
transaction_type, category, subcategory, person,
None if is_fixed is None else int(is_fixed),
float(confidence), int(bool(active)), memo, ts, rule_id,
),
)
row = conn.execute("SELECT * FROM category_rules WHERE id=?", (rule_id,)).fetchone()
return ok(rule=row_to_dict(row))
def delete_category_rule(rule_id: str, db_path: str | None = None) -> dict[str, Any]:
if not rule_id:
return fail("validation_error", "rule_id is required")
with db(db_path) as conn:
existing = conn.execute("SELECT * FROM category_rules WHERE id=?", (rule_id,)).fetchone()
if not existing:
return fail("not_found", f"category rule not found: {rule_id}")
conn.execute("DELETE FROM category_rules WHERE id=?", (rule_id,))
return ok(deleted_rule_id=rule_id)
def _load_active_rules(conn: Any) -> list[dict[str, Any]]:
return [row_to_dict(r) for r in conn.execute("SELECT * FROM category_rules WHERE active=1 ORDER BY priority, id").fetchall()]
def _categorization_preview_data(limit: int = 1000, only_uncategorized: bool = True, db_path: str | None = None) -> dict[str, Any]:
with db(db_path) as conn:
rules = _load_active_rules(conn)
where = "WHERE status='active'" + (" AND category=''" if only_uncategorized else "")
rows = [row_to_dict(r) for r in conn.execute(
f"SELECT * FROM transactions {where} ORDER BY transaction_datetime, transaction_date LIMIT ?",
(max(1, min(int(limit), 5000)),),
).fetchall()]
matches = []
unmatched = []
for tx in rows:
match = next((r for r in rules if _rule_matches(r, tx)), None)
if match:
matches.append({
"transaction_id": tx["id"],
"merchant": tx["merchant"],
"type": tx["type"],
"amount": tx["amount"],
"transaction_datetime": tx.get("transaction_datetime", ""),
"category": match["category"],
"subcategory": match["subcategory"],
"person": match["person"],
"is_fixed": match["is_fixed"],
"rule_id": match["id"],
"rule_pattern": match["pattern"],
})
else:
unmatched.append({
"transaction_id": tx["id"],
"merchant": tx["merchant"],
"raw_summary": tx.get("raw_summary", ""),
"type": tx["type"],
"amount": tx["amount"],
"transaction_datetime": tx.get("transaction_datetime", ""),
})
by_category = {}
for m in matches:
key = f"{m['category']}/{m['subcategory']}" if m['subcategory'] else m['category']
by_category.setdefault(key, {"count": 0, "amount": 0})
by_category[key]["count"] += 1
by_category[key]["amount"] += int(m["amount"])
return {
"rows": rows,
"matches": matches,
"unmatched": unmatched,
"by_category": by_category,
}
def preview_categorization(limit: int = 1000, only_uncategorized: bool = True, db_path: str | None = None) -> dict[str, Any]:
data = _categorization_preview_data(limit, only_uncategorized, db_path)
return ok(
scanned=len(data["rows"]),
matched_count=len(data["matches"]),
unmatched_count=len(data["unmatched"]),
by_category=data["by_category"],
matches=data["matches"][:100],
unmatched=data["unmatched"][:100],
)
def _category_rule_match_rows(rule_id: str, limit: int = 10000, db_path: str | None = None) -> dict[str, Any]:
if not rule_id:
return fail("validation_error", "rule_id is required")
row_limit = max(1, min(int(limit), 50000))
with db(db_path) as conn:
rule_row = conn.execute("SELECT * FROM category_rules WHERE id=?", (rule_id,)).fetchone()
if not rule_row:
return fail("not_found", f"category rule not found: {rule_id}")
rule = row_to_dict(rule_row)
rows = [row_to_dict(r) for r in conn.execute(
"""SELECT * FROM transactions
WHERE status='active'
ORDER BY transaction_datetime DESC, transaction_date DESC, created_at DESC
LIMIT ?""",
(row_limit,),
).fetchall()]
matched = []
uncategorized = []
for tx in rows:
if not _rule_matches(rule, tx):
continue
item = {
"transaction_id": tx["id"],
"transaction_date": tx.get("transaction_date", ""),
"transaction_datetime": tx.get("transaction_datetime", ""),
"type": tx.get("type", ""),
"amount": tx.get("amount"),
"merchant": tx.get("merchant", ""),
"raw_summary": tx.get("raw_summary", ""),
"counterparty": tx.get("counterparty", ""),
"current_category": tx.get("category", "") or "",
"current_subcategory": tx.get("subcategory", "") or "",
"new_category": rule.get("category", ""),
"new_subcategory": rule.get("subcategory", ""),
"rule_id": rule_id,
"rule_pattern": rule.get("pattern", ""),
}
matched.append(item)
if not (tx.get("category") or ""):
uncategorized.append(item)
return ok(rule=rule, scanned=len(rows), matched=matched, uncategorized=uncategorized)
def preview_category_rule_matches(rule_id: str, limit: int = 10000, sample_limit: int = 3, db_path: str | None = None) -> dict[str, Any]:
data = _category_rule_match_rows(rule_id, limit, db_path)
if not data.get("success"):
return data
sample_n = max(1, min(int(sample_limit), 20))
return ok(
rule=data["rule"],
scanned=data["scanned"],
matched_count=len(data["matched"]),
uncategorized_matched_count=len(data["uncategorized"]),
sample=data["matched"][:sample_n],
)
def apply_category_rule(rule_id: str, limit: int = 10000, db_path: str | None = None) -> dict[str, Any]:
data = _category_rule_match_rows(rule_id, limit, db_path)
if not data.get("success"):
return data
rule = data["rule"]
ts = now_iso()
applied = 0
with db(db_path) as conn:
for m in data["uncategorized"]:
conn.execute(
"""UPDATE transactions
SET category=?, subcategory=?,
person=CASE WHEN ?<>'' THEN ? ELSE person END,
is_fixed=CASE WHEN ? IS NULL THEN is_fixed ELSE ? END,
updated_at=?
WHERE id=? AND status='active' AND COALESCE(category,'')=''""",
(
rule.get("category", ""), rule.get("subcategory", ""),
rule.get("person", ""), rule.get("person", ""),
rule.get("is_fixed"), rule.get("is_fixed"),
ts, m["transaction_id"],
),
)
applied += 1
return ok(
applied_count=applied,
matched_count=len(data["matched"]),
uncategorized_matched_count=len(data["uncategorized"]),
rule=rule,
)
def apply_categorization_rules(limit: int = 1000, only_uncategorized: bool = True, db_path: str | None = None) -> dict[str, Any]:
data = _categorization_preview_data(limit, only_uncategorized, db_path)
preview = ok(
scanned=len(data["rows"]),
matched_count=len(data["matches"]),
unmatched_count=len(data["unmatched"]),
by_category=data["by_category"],
matches=data["matches"][:100],
unmatched=data["unmatched"][:100],
)
applied = 0
ts = now_iso()
with db(db_path) as conn:
for m in data["matches"]:
conn.execute("UPDATE transactions SET category=?, subcategory=?, person=CASE WHEN ?<>'' THEN ? ELSE person END, is_fixed=CASE WHEN ? IS NULL THEN is_fixed ELSE ? END, updated_at=? WHERE id=?", (m["category"], m["subcategory"], m["person"], m["person"], m["is_fixed"], m["is_fixed"], ts, m["transaction_id"]))
applied += 1
return ok(applied_count=applied, preview=preview)
def list_uncategorized_transactions(limit: int = 100, min_amount: int = 0, db_path: str | None = None) -> dict[str, Any]:
with db(db_path) as conn:
rows=conn.execute("""SELECT * FROM transactions WHERE status='active' AND COALESCE(category,'')='' AND amount>=? ORDER BY amount DESC, transaction_datetime DESC LIMIT ?""", (int(min_amount), max(1,min(int(limit),1000)))).fetchall()
return ok(transactions=[row_to_dict(r) for r in rows], count=len(rows))
def get_uncategorized_merchant_summary(limit: int = 50, db_path: str | None = None) -> dict[str, Any]:
with db(db_path) as conn:
rows=conn.execute("""SELECT merchant, raw_summary, type, COUNT(*) count, SUM(amount) total_amount, MAX(amount) max_amount
FROM transactions WHERE status='active' AND COALESCE(category,'')=''
GROUP BY merchant, raw_summary, type ORDER BY total_amount DESC LIMIT ?""", (max(1,min(int(limit),500)),)).fetchall()
return ok(items=[row_to_dict(r) for r in rows], count=len(rows))
def _recommend_category_for_transaction(tx: dict[str, Any], rules: list[dict[str, Any]], history: dict[tuple[str, str], list[dict[str, Any]]]) -> dict[str, Any]:
"""Recommend a category for one transaction using deterministic, explainable signals."""
for rule in rules:
try:
if _rule_matches(rule, tx):
return {
"category": rule.get("category", ""),
"subcategory": rule.get("subcategory", ""),
"confidence": round(float(rule.get("confidence") or 1.0), 2),
"source": "rule",
"reason": f"활성 규칙 `{rule.get('pattern', '')}` 매칭",
"rule_id": rule.get("id", ""),
}
except re.error:
continue
key = (tx.get("merchant", "") or "", tx.get("type", "") or "")
candidates = history.get(key, [])
if candidates:
best = candidates[0]
count = int(best.get("count") or 0)
confidence = min(0.9, 0.55 + min(count, 5) * 0.07)
return {
"category": best.get("category", ""),
"subcategory": best.get("subcategory", ""),
"confidence": round(confidence, 2),
"source": "history",
"reason": f"같은 사용처의 기존 분류 {count}건",
"rule_id": "",
}
return {"category": "", "subcategory": "", "confidence": 0.0, "source": "none", "reason": "추천 근거 없음", "rule_id": ""}
def get_uncategorized_workbench(
limit: int = 100,
min_amount: int = 0,
sort_by: str = "amount_desc",
db_path: str | None = None,
) -> dict[str, Any]:
row_limit = max(1, min(int(limit), 1000))
minimum_amount = int(min_amount)
order_by_map = {
"amount_desc": "amount DESC, transaction_datetime DESC, transaction_date DESC, created_at DESC",
"date_desc": "transaction_datetime DESC, transaction_date DESC, created_at DESC, amount DESC",
}
order_by = order_by_map.get(sort_by, order_by_map["amount_desc"])
with db(db_path) as conn:
tx_rows = [row_to_dict(r) for r in conn.execute(
f"""SELECT id, transaction_date, transaction_datetime, type, amount, currency, merchant, raw_summary,
counterparty, account_id, payment_method, person, is_fixed, source, memo, created_at
FROM transactions
WHERE status='active' AND COALESCE(category,'')='' AND amount>=?
ORDER BY {order_by}
LIMIT ?""",
(minimum_amount, row_limit),
).fetchall()]
total_row = conn.execute(
"""SELECT COUNT(*) AS count, COALESCE(SUM(amount),0) AS total_amount,
COUNT(DISTINCT COALESCE(NULLIF(merchant,''), raw_summary, counterparty, '')) AS merchant_group_count
FROM transactions
WHERE status='active' AND COALESCE(category,'')='' AND amount>=?""",
(minimum_amount,),
).fetchone()
history_rows = [row_to_dict(r) for r in conn.execute(
"""SELECT merchant, type, category, subcategory, COUNT(*) AS count,
SUM(amount) AS total_amount, MAX(transaction_date) AS latest_date
FROM transactions
WHERE status='active' AND COALESCE(category,'')<>'' AND COALESCE(merchant,'')<>''
GROUP BY merchant, type, category, subcategory
ORDER BY merchant, type, count DESC, total_amount DESC"""
).fetchall()]
rules = [row_to_dict(r) for r in conn.execute(
"SELECT * FROM category_rules WHERE active=1 ORDER BY priority, id"
).fetchall()]
history: dict[tuple[str, str], list[dict[str, Any]]] = {}
for row in history_rows:
history.setdefault((row.get("merchant", "") or "", row.get("type", "") or ""), []).append(row)
txs: list[dict[str, Any]] = []
groups: dict[tuple[str, str, str], dict[str, Any]] = {}
for tx in tx_rows:
rec = _recommend_category_for_transaction(tx, rules, history)
public_tx = {
**tx,
"recommendation": rec,
"recommended_category": rec.get("category", ""),
"recommended_subcategory": rec.get("subcategory", ""),
"recommendation_confidence": rec.get("confidence", 0.0),
"recommendation_reason": rec.get("reason", ""),
"recommendation_source": rec.get("source", "none"),
}
txs.append(public_tx)
gkey = (tx.get("merchant", "") or "", tx.get("raw_summary", "") or "", tx.get("type", "") or "")
group = groups.setdefault(gkey, {
"merchant": gkey[0],
"raw_summary": gkey[1],
"type": gkey[2],
"count": 0,
"total_amount": 0,
"max_amount": 0,
"latest_date": "",
"sample_transaction_id": tx.get("id", ""),
"recommendation": rec,
})
group["count"] += 1
group["total_amount"] += int(tx.get("amount") or 0)
group["max_amount"] = max(int(group["max_amount"] or 0), int(tx.get("amount") or 0))
date_value = tx.get("transaction_datetime") or tx.get("transaction_date") or ""
if date_value > (group.get("latest_date") or ""):
group["latest_date"] = date_value
if not group["recommendation"].get("category") and rec.get("category"):
group["recommendation"] = rec
group_items = sorted(groups.values(), key=lambda g: (int(g["count"]), int(g["total_amount"])), reverse=True)
recommendable_count = sum(1 for tx in txs if tx.get("recommended_category"))
repeated_group_count = sum(1 for g in group_items if int(g.get("count") or 0) >= 2)
total = row_to_dict(total_row) if total_row else {"count": 0, "total_amount": 0, "merchant_group_count": 0}
return ok(
summary={
"uncategorized_count": int(total.get("count") or 0),
"uncategorized_total_amount": int(total.get("total_amount") or 0),
"merchant_group_count": int(total.get("merchant_group_count") or 0),
"recommendable_count": recommendable_count,
"repeated_group_count": repeated_group_count,
},
groups=group_items,
transactions=txs,
count=len(txs),
)
def apply_uncategorized_recommendation(transaction_id: str, db_path: str | None = None) -> dict[str, Any]:
if not transaction_id:
return fail("validation_error", "transaction_id is required")
with db(db_path) as conn:
row = conn.execute("SELECT * FROM transactions WHERE id=? AND status='active'", (transaction_id,)).fetchone()
if not row:
return fail("not_found", "transaction not found")
tx = row_to_dict(row)
if tx.get("category"):
return fail("validation_error", "transaction is already categorized")
rules = [row_to_dict(r) for r in conn.execute("SELECT * FROM category_rules WHERE active=1 ORDER BY priority, id").fetchall()]
history_rows = [row_to_dict(r) for r in conn.execute(
"""SELECT merchant, type, category, subcategory, COUNT(*) AS count, SUM(amount) AS total_amount
FROM transactions
WHERE status='active' AND COALESCE(category,'')<>'' AND COALESCE(merchant,'')<>''
GROUP BY merchant, type, category, subcategory
ORDER BY count DESC, total_amount DESC"""
).fetchall()]
history: dict[tuple[str, str], list[dict[str, Any]]] = {}
for h in history_rows:
history.setdefault((h.get("merchant", "") or "", h.get("type", "") or ""), []).append(h)
rec = _recommend_category_for_transaction(tx, rules, history)
if not rec.get("category"):
return fail("no_recommendation", "추천 분류가 없습니다")
cur = conn.execute(
"""UPDATE transactions
SET category=?, subcategory=?, updated_at=?
WHERE id=? AND status='active' AND COALESCE(category,'')=''""",
(rec.get("category", ""), rec.get("subcategory", ""), now_iso(), transaction_id),
)
return ok(applied_count=cur.rowcount, transaction_id=transaction_id, recommendation=rec)
def apply_uncategorized_group_recommendation(merchant: str, raw_summary: str = "", transaction_type: str = "", db_path: str | None = None) -> dict[str, Any]:
with db(db_path) as conn:
sample = conn.execute(
"""SELECT * FROM transactions
WHERE status='active' AND COALESCE(category,'')='' AND merchant=? AND raw_summary=? AND type=?
ORDER BY amount DESC, transaction_datetime DESC, transaction_date DESC LIMIT 1""",
(merchant, raw_summary, transaction_type),
).fetchone()
if not sample:
return fail("not_found", "uncategorized group not found")
tx = row_to_dict(sample)
rules = [row_to_dict(r) for r in conn.execute("SELECT * FROM category_rules WHERE active=1 ORDER BY priority, id").fetchall()]
history_rows = [row_to_dict(r) for r in conn.execute(
"""SELECT merchant, type, category, subcategory, COUNT(*) AS count, SUM(amount) AS total_amount
FROM transactions
WHERE status='active' AND COALESCE(category,'')<>'' AND COALESCE(merchant,'')<>''
GROUP BY merchant, type, category, subcategory
ORDER BY count DESC, total_amount DESC"""
).fetchall()]
history: dict[tuple[str, str], list[dict[str, Any]]] = {}
for h in history_rows:
history.setdefault((h.get("merchant", "") or "", h.get("type", "") or ""), []).append(h)
rec = _recommend_category_for_transaction(tx, rules, history)
if not rec.get("category"):
return fail("no_recommendation", "추천 분류가 없습니다")
cur = conn.execute(
"""UPDATE transactions
SET category=?, subcategory=?, updated_at=?
WHERE status='active' AND COALESCE(category,'')='' AND merchant=? AND raw_summary=? AND type=?""",
(rec.get("category", ""), rec.get("subcategory", ""), now_iso(), merchant, raw_summary, transaction_type),
)
return ok(applied_count=cur.rowcount, merchant=merchant, raw_summary=raw_summary, type=transaction_type, recommendation=rec)
DEFAULT_LEDGER_CATEGORIES: list[dict[str, Any]] = [
{"category": "수입", "subcategory": "급여", "sort_order": 10},
{"category": "수입", "subcategory": "이자", "sort_order": 11},
{"category": "수입", "subcategory": "환급", "sort_order": 12},
{"category": "수입", "subcategory": "배당", "sort_order": 13},
{"category": "수입", "subcategory": "기타수입", "sort_order": 19},
{"category": "이체", "subcategory": "본인계좌이동", "sort_order": 20},
{"category": "이체", "subcategory": "현금입출금", "sort_order": 21},
{"category": "이체", "subcategory": "가족송금", "sort_order": 22},
{"category": "이체", "subcategory": "투자이동", "sort_order": 23},
{"category": "이체", "subcategory": "확인필요", "sort_order": 29},
{"category": "식비", "subcategory": "마트", "sort_order": 30},
{"category": "식비", "subcategory": "외식", "sort_order": 31},
{"category": "식비", "subcategory": "카페", "sort_order": 32},
{"category": "식비", "subcategory": "배달", "sort_order": 33},
{"category": "주거", "subcategory": "관리비", "sort_order": 40},
{"category": "주거", "subcategory": "전기", "sort_order": 41},
{"category": "주거", "subcategory": "가스", "sort_order": 42},
{"category": "주거", "subcategory": "수도", "sort_order": 43},
{"category": "주거", "subcategory": "인터넷", "sort_order": 44},
{"category": "통신", "subcategory": "휴대폰", "sort_order": 50},
{"category": "통신", "subcategory": "인터넷", "sort_order": 51},
{"category": "통신", "subcategory": "통신요금", "sort_order": 52},
{"category": "교통", "subcategory": "대중교통", "sort_order": 60},
{"category": "교통", "subcategory": "택시", "sort_order": 61},
{"category": "교통", "subcategory": "주유", "sort_order": 62},
{"category": "교통", "subcategory": "주차", "sort_order": 63},
{"category": "교통", "subcategory": "차량정비", "sort_order": 64},
{"category": "교육", "subcategory": "학원", "sort_order": 70},
{"category": "교육", "subcategory": "교재", "sort_order": 71},
{"category": "교육", "subcategory": "학교", "sort_order": 72},
{"category": "교육", "subcategory": "예체능", "sort_order": 73},
{"category": "의료", "subcategory": "병원", "sort_order": 80},
{"category": "의료", "subcategory": "약국", "sort_order": 81},
{"category": "의료", "subcategory": "치과", "sort_order": 82},
{"category": "의료", "subcategory": "보험", "sort_order": 83},
{"category": "생활", "subcategory": "생필품", "sort_order": 90},
{"category": "생활", "subcategory": "의류", "sort_order": 91},
{"category": "생활", "subcategory": "미용", "sort_order": 92},
{"category": "생활", "subcategory": "세탁", "sort_order": 93},
{"category": "문화/여가", "subcategory": "여행", "sort_order": 100},
{"category": "문화/여가", "subcategory": "숙박", "sort_order": 101},
{"category": "문화/여가", "subcategory": "공연", "sort_order": 102},
{"category": "문화/여가", "subcategory": "취미", "sort_order": 103},
{"category": "문화/여가", "subcategory": "스포츠", "sort_order": 104},
{"category": "금융", "subcategory": "카드대금", "sort_order": 110},
{"category": "금융", "subcategory": "대출상환", "sort_order": 111},
{"category": "금융", "subcategory": "수수료", "sort_order": 112},
{"category": "금융", "subcategory": "보험료", "sort_order": 113},
{"category": "가족/용돈", "subcategory": "부모님", "sort_order": 120},
{"category": "가족/용돈", "subcategory": "자녀", "sort_order": 121},
{"category": "가족/용돈", "subcategory": "가족송금", "sort_order": 122},
{"category": "미분류", "subcategory": "확인필요", "sort_order": 999},
]
def infer_cashflow_kind(category: str, subcategory: str = "") -> str:
if category == "수입":
return "income"
if category == "이체":
if subcategory == "투자이동":
return "investment"
return "transfer"
if category == "금융":
if subcategory == "대출상환":
return "debt_payment"
if subcategory in {"카드대금", "수수료"}:
return "exclude"
return "exclude"
if category == "미분류":
return "unknown"
return "expense"
def create_ledger_category(category: str, subcategory: str = "", sort_order: int = 100, memo: str = "", cashflow_kind: str = "", db_path: str | None = None) -> dict[str, Any]:
category = (category or "").strip()
subcategory = (subcategory or "").strip()
cashflow_kind = (cashflow_kind or infer_cashflow_kind(category, subcategory)).strip()
if not category:
return fail("validation_error", "category is required")
if cashflow_kind not in VALID_CASHFLOW_KINDS:
return fail("validation_error", f"cashflow_kind must be one of {sorted(VALID_CASHFLOW_KINDS)}")
ts = now_iso(); category_id = new_id("cat")
with db(db_path) as conn:
conn.execute("""INSERT INTO ledger_categories(id,category,subcategory,cashflow_kind,active,sort_order,memo,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?)
ON CONFLICT(category, subcategory) DO UPDATE SET active=1, cashflow_kind=excluded.cashflow_kind, sort_order=excluded.sort_order, memo=excluded.memo, updated_at=excluded.updated_at""",
(category_id, category, subcategory, cashflow_kind, 1, int(sort_order), memo, ts, ts))
row = conn.execute("SELECT * FROM ledger_categories WHERE category=? AND subcategory=?", (category, subcategory)).fetchone()
return ok(category=row_to_dict(row))
def seed_default_ledger_categories(overwrite: bool = False, db_path: str | None = None) -> dict[str, Any]:
if overwrite:
with db(db_path) as conn:
conn.execute("UPDATE ledger_categories SET active=0 WHERE memo LIKE 'default:%'")
created = 0
for item in DEFAULT_LEDGER_CATEGORIES:
res = create_ledger_category(memo="default: Jarca initial category", db_path=db_path, **item)
if res.get("success"):
created += 1
return ok(created_or_updated=created)
def list_ledger_categories(active_only: bool = True, include_usage: bool = True, db_path: str | None = None) -> dict[str, Any]:
clauses = []
if active_only:
clauses.append("active=1")
where = " WHERE " + " AND ".join(clauses) if clauses else ""
with db(db_path) as conn:
rows = conn.execute(f"SELECT * FROM ledger_categories{where} ORDER BY sort_order, category, subcategory").fetchall()
items = [row_to_dict(r) for r in rows]
if include_usage:
usage_rows = conn.execute("""SELECT category, subcategory, COUNT(*) AS transaction_count, SUM(amount) AS total_amount
FROM transactions WHERE status='active'
GROUP BY category, subcategory""").fetchall()
usage = {(r["category"], r["subcategory"]): {"transaction_count": int(r["transaction_count"] or 0), "total_amount": int(r["total_amount"] or 0)} for r in usage_rows}
for item in items:
item.update(usage.get((item["category"], item["subcategory"]), {"transaction_count": 0, "total_amount": 0}))
grouped: dict[str, list[str]] = {}
for item in items:
grouped.setdefault(item["category"], [])
if item["subcategory"] and item["subcategory"] not in grouped[item["category"]]:
grouped[item["category"]].append(item["subcategory"])
return ok(categories=items, grouped=grouped, count=len(items))
def delete_ledger_category(category: str, subcategory: str = "", hard_delete: bool = False, force: bool = False, db_path: str | None = None) -> dict[str, Any]:
category = (category or "").strip()
subcategory = (subcategory or "").strip()
if not category:
return fail("validation_error", "category is required")
with db(db_path) as conn:
row = conn.execute("SELECT * FROM ledger_categories WHERE category=? AND subcategory=?", (category, subcategory)).fetchone()
if not row:
return fail("not_found", "category not found")
tx_count = conn.execute("SELECT COUNT(*) FROM transactions WHERE status='active' AND category=? AND subcategory=?", (category, subcategory)).fetchone()[0]
budget_count = conn.execute("SELECT COUNT(*) FROM budgets WHERE category=? AND subcategory=?", (category, subcategory)).fetchone()[0]
rule_count = conn.execute("SELECT COUNT(*) FROM category_rules WHERE active=1 AND category=? AND subcategory=?", (category, subcategory)).fetchone()[0]
in_use = int(tx_count or 0) + int(budget_count or 0) + int(rule_count or 0)
if in_use and hard_delete and not force:
return fail("category_in_use", "category is referenced; use soft delete or force=true", transaction_count=tx_count, budget_count=budget_count, rule_count=rule_count)
if hard_delete:
conn.execute("DELETE FROM ledger_categories WHERE id=?", (row["id"],))
else:
conn.execute("UPDATE ledger_categories SET active=0, updated_at=? WHERE id=?", (now_iso(), row["id"]))
return ok(category=category, subcategory=subcategory, deleted=True, hard_delete=hard_delete, transaction_count=tx_count, budget_count=budget_count, rule_count=rule_count)
File diff suppressed because it is too large Load Diff
+199
View File
@@ -0,0 +1,199 @@
from __future__ import annotations
import csv
import hashlib
import json
import re
import sqlite3
from datetime import date, datetime, timedelta
from pathlib import Path
from typing import Any
from uuid import uuid4
from zoneinfo import ZoneInfo
from ..infrastructure.sqlite import db, init_db, resolve_db_path
from ..kb_bank_pdf import parse_kb_bank_pdf, transaction_preview
from ..toss_bank_pdf import parse_toss_bank_pdf, transaction_preview as toss_bank_transaction_preview
from ..toss_securities_pdf import parse_toss_securities_pdf, transaction_preview as toss_transaction_preview
from ..seoulpay_xlsx import parse_seoulpay_xlsx, transaction_preview as seoulpay_transaction_preview
VALID_TRANSACTION_TYPES = {"income", "expense", "transfer", "refund", "adjustment"}
VALID_ACCOUNT_TYPES = {"bank", "card", "cash", "investment", "loan", "other"}
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"]
LIABILITY_BUCKETS = ["loan"]
ASSET_BUCKET_ORDER = [*ASSET_BUCKETS, *LIABILITY_BUCKETS]
RECONCILIATION_TARGET_STATUSES = {"manual_only", "partial_import"}
BALANCE_RECONCILIATION_TOLERANCE_KRW = 1_000
BALANCE_RECONCILIATION_REFERENCE_RATIO = 0.001
VALID_CASHFLOW_KINDS = {"income", "expense", "transfer", "investment", "debt_payment", "exclude", "unknown"}
CASHFLOW_KIND_SQL = """COALESCE(lc.cashflow_kind, CASE
WHEN t.category='수입' THEN 'income'
WHEN t.category='이체' AND t.subcategory='투자이동' THEN 'investment'
WHEN t.category='이체' THEN 'transfer'
WHEN t.category='금융' AND t.subcategory='대출상환' THEN 'debt_payment'
WHEN t.category='금융' THEN 'exclude'
WHEN t.category='미분류' THEN 'unknown'
WHEN COALESCE(t.category, '')='' THEN 'unknown'
ELSE 'expense'
END)"""
CROSS_TRANSFER_MATCH_TAG = "cross_transfer_match:"
CROSS_TRANSFER_REVIEW_TAG = "cross_transfer_review:"
DEFAULT_LEDGER_BACKUP_DIR = Path.home() / ".local" / "share" / "jarca" / "backups" / "ledger"
KST = ZoneInfo("Asia/Seoul")
BACKUP_RESTORE_POLICY = {
"mcp_restore_exposed": False,
"restore_requires_explicit_user_approval": True,
"restore_entrypoint": "scripts/ledger_restore_smoke.py 또는 docs/runbooks/ledger-backup-restore.md",
}
def _normalize_counterparty_for_match(value: str) -> str:
if not value:
return ""
# normalize party names for fuzzy matching across KB/토스뱅크 records
normalized = re.sub(r"\\([^)]*\\)", "", value)
normalized = normalized.lower().strip()
normalized = normalized.replace("토스뱅크", "").replace("토스", "")
normalized = normalized.replace("kb", "").replace("국민은행", "")
normalized = re.sub(r"\\s+", "", normalized)
normalized = re.sub(r"[^0-9가-힣a-zA-Z]", "", normalized)
return normalized
def _party_match_score(left: str, right: str) -> int:
left_norm = _normalize_counterparty_for_match(left)
right_norm = _normalize_counterparty_for_match(right)
if not left_norm or not right_norm:
return 0
if left_norm == right_norm:
return 2
if left_norm in right_norm or right_norm in left_norm:
return 1
return 0
def _append_memo_tag(raw_memo: str, tag: str) -> str:
memo = (raw_memo or "").strip()
if not memo:
return tag
if tag in memo:
return memo
return f"{memo}; {tag}"
def _memo_has_tag(raw_memo: str, tag_prefix: str) -> bool:
memo = (raw_memo or "").strip()
return tag_prefix in memo
def _current_kst_datetime() -> datetime:
return datetime.now(KST).replace(microsecond=0)
def current_kst_date() -> date:
return _current_kst_datetime().date()
def current_kst_month() -> str:
return current_kst_date().strftime("%Y-%m")
def now_iso() -> str:
return _current_kst_datetime().isoformat()
def new_id(prefix: str) -> str:
return f"{prefix}_{uuid4().hex[:12]}"
def ok(**data: Any) -> dict[str, Any]:
return {"success": True, **data}
def fail(error_code: str, message: str, **data: Any) -> dict[str, Any]:
return {"success": False, "error_code": error_code, "error": message, **data}
def row_to_dict(row: Any) -> dict[str, Any]:
d = dict(row)
if "tags" in d:
try:
d["tags"] = json.loads(d["tags"] or "[]")
except json.JSONDecodeError:
d["tags"] = []
for k in ("active", "is_fixed", "is_correction"):
if k in d:
d[k] = bool(d[k])
return d
def normalize_date(value: str | None, default_today: bool = False) -> str:
if not value:
if default_today:
return current_kst_date().isoformat()
raise ValueError("date is required")
return date.fromisoformat(value[:10]).isoformat()
def normalize_month(value: str | None) -> str:
if not value:
return current_kst_month()
if re.fullmatch(r"\d{4}-\d{2}", value):
return value
return date.fromisoformat(value[:10]).strftime("%Y-%m")
def normalize_statement_status(value: str | None) -> str:
status = (value or "unknown").strip() or "unknown"
if status not in VALID_STATEMENT_STATUSES:
raise ValueError(f"statement_status must be one of {sorted(VALID_STATEMENT_STATUSES)}")
return status
def normalize_liquidity_bucket(value: str | None) -> str:
bucket = (value or "other").strip() or "other"
if bucket not in VALID_LIQUIDITY_BUCKETS:
raise ValueError(f"liquidity_bucket must be one of {sorted(VALID_LIQUIDITY_BUCKETS)}")
return bucket
def normalize_snapshot_at(value: str | None, default_now: bool = True) -> str:
if not value:
if default_now:
return now_iso()
raise ValueError("baseline_at is required")
raw = value.strip()
if re.fullmatch(r"\d{4}-\d{2}", raw):
month_start = date.fromisoformat(raw + "-01")
if month_start.month == 12:
next_month = date(month_start.year + 1, 1, 1)
else:
next_month = date(month_start.year, month_start.month + 1, 1)
last_day = next_month - timedelta(days=1)
return f"{last_day.isoformat()}T23:59:00+09:00"
if "T" not in raw and len(raw) == 10:
return f"{normalize_date(raw)}T23:59:00+09:00"
datetime.fromisoformat(raw.replace("Z", "+00:00"))
return raw
def normalize_tags(tags: Any) -> str:
if tags is None or tags == "":
return "[]"
if isinstance(tags, str):
parts = [p.strip() for p in re.split(r"[,#]", tags) if p.strip()]
return json.dumps(parts, ensure_ascii=False)
if isinstance(tags, list):
return json.dumps([str(x).strip() for x in tags if str(x).strip()], ensure_ascii=False)
raise ValueError("tags must be a list or comma-separated string")
def _month_range(month: str) -> tuple[str, str]:
m = normalize_month(month)
start = date.fromisoformat(f"{m}-01")
next_month = date(start.year + (start.month // 12), ((start.month % 12) + 1), 1)
return start.isoformat(), next_month.isoformat()
def make_dedupe_key(transaction_date: str, amount: int, merchant: str = "", account_id: str = "", external_id: str = "") -> str:
if external_id:
raw = f"external:{external_id}"
else:
raw = f"{transaction_date}|{amount}|{merchant.strip().lower()}|{account_id.strip().lower()}"
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
@@ -0,0 +1,190 @@
from __future__ import annotations
from .shared import *
from .shared import _current_kst_datetime, _month_range
def health_check(db_path: str | None = None) -> dict[str, Any]:
path = init_db(db_path)
with db(path) as conn:
schema_version = conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0]
tx_count = conn.execute("SELECT COUNT(*) FROM transactions WHERE status='active'").fetchone()[0]
return ok(server="jarca-ledger-mcp", db_path=path, schema_version=schema_version, active_transactions=tx_count)
def _sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _file_metadata(path: Path, include_hash: bool = True) -> dict[str, Any]:
stat = path.stat()
metadata: dict[str, Any] = {
"path": str(path.resolve()),
"size_bytes": stat.st_size,
"mtime": datetime.fromtimestamp(stat.st_mtime).replace(microsecond=0).isoformat(),
}
if include_hash:
metadata["sha256"] = _sha256_file(path)
return metadata
def _readonly_sqlite_connection(path: Path) -> sqlite3.Connection:
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
conn.row_factory = sqlite3.Row
return conn
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"):
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])
return counts
def _safe_monthly_aggregates(conn: sqlite3.Connection) -> dict[str, Any]:
current_month = current_kst_month()
start, next_month = _month_range(current_month)
rows = conn.execute(
f"""SELECT {CASHFLOW_KIND_SQL} AS cashflow_kind, SUM(t.amount) AS total, 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}""",
(start, next_month),
).fetchall()
return {
"month": current_month,
"by_cashflow_kind": {
str(row["cashflow_kind"]): {"amount": int(row["total"] or 0), "count": int(row["count"] or 0)}
for row in rows
},
}
def _ledger_db_status_for_path(path: Path, include_hash: bool = True, include_integrity: bool = True) -> dict[str, Any]:
status: dict[str, Any] = {"db_path": str(path.resolve()), "exists": path.exists()}
if not path.exists():
return status
status.update(_file_metadata(path, include_hash=include_hash))
try:
with _readonly_sqlite_connection(path) as conn:
if include_integrity:
status["integrity_check"] = str(conn.execute("PRAGMA integrity_check").fetchone()[0])
status["table_counts"] = _safe_table_counts(conn)
status["aggregate_totals"] = _safe_monthly_aggregates(conn)
except Exception as e:
status["integrity_check"] = "error"
status["error"] = str(e)
return status
def get_ledger_db_status(db_path: str | None = None, include_hash: bool = True, include_integrity: bool = True) -> dict[str, Any]:
"""Return non-sensitive metadata evidence for the selected ledger DB."""
path = resolve_db_path(db_path)
if not path.exists():
return fail("not_found", f"ledger DB not found: {path}", db_path=str(path), exists=False)
return ok(**_ledger_db_status_for_path(path, include_hash=include_hash, include_integrity=include_integrity))
def _resolve_backup_dir(backup_dir: str | None = None) -> Path:
return Path(backup_dir).expanduser().resolve() if backup_dir else DEFAULT_LEDGER_BACKUP_DIR.resolve()
def _backup_file_metadata(path: Path, include_hash: bool = False) -> dict[str, Any]:
metadata = _file_metadata(path, include_hash=include_hash)
metadata["name"] = path.name
metadata["restore_policy"] = BACKUP_RESTORE_POLICY
return metadata
def _apply_backup_retention(backup_dir: Path, retention_days: int = 30, retention_count: int = 20) -> list[str]:
backups = sorted(backup_dir.glob("*.sqlite3"), key=lambda p: p.stat().st_mtime, reverse=True)
keep_count = max(1, int(retention_count))
# Retention comparison needs sub-second precision for retention_days=0:
# the timestamp used for backup names is second-truncated, but filesystem mtimes
# can identify files created just before retention runs as older than "now".
cutoff = datetime.now(KST) - timedelta(days=max(0, int(retention_days)))
pruned: list[str] = []
for idx, path in enumerate(backups):
mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=KST)
should_keep = idx < keep_count or mtime >= cutoff
if should_keep:
continue
path.unlink()
pruned.append(str(path.resolve()))
return pruned
def create_ledger_backup(db_path: str | None = None, backup_dir: str | None = None, retention_days: int = 30, retention_count: int = 20) -> dict[str, Any]:
"""Create a timestamped SQLite backup and return metadata-only evidence."""
source_path = resolve_db_path(db_path)
if not source_path.exists():
return fail("not_found", f"ledger DB not found: {source_path}", db_path=str(source_path), exists=False)
backup_root = _resolve_backup_dir(backup_dir)
backup_root.mkdir(parents=True, exist_ok=True)
timestamp = _current_kst_datetime().strftime("%Y%m%d%H%M%S%f")
backup_path = backup_root / f"ledger_{timestamp}.sqlite3"
try:
# Use SQLite's online backup API so WAL-mode databases are copied consistently.
with sqlite3.connect(f"file:{source_path}?mode=ro", uri=True) as source_conn:
with sqlite3.connect(backup_path) as backup_conn:
source_conn.backup(backup_conn)
except Exception:
if backup_path.exists():
backup_path.unlink()
raise
source_status = _ledger_db_status_for_path(source_path, include_hash=True, include_integrity=True)
backup_status = _ledger_db_status_for_path(backup_path, include_hash=True, include_integrity=True)
pruned_paths = _apply_backup_retention(backup_root, retention_days=retention_days, retention_count=retention_count)
return ok(
source_db=source_status,
backup=backup_status,
backup_dir=str(backup_root),
retention={"retention_days": int(retention_days), "retention_count": int(retention_count), "pruned_paths": pruned_paths},
restore_policy=BACKUP_RESTORE_POLICY,
)
def list_ledger_backups(backup_dir: str | None = None, include_hash: bool = False, limit: int = 50) -> dict[str, Any]:
backup_root = _resolve_backup_dir(backup_dir)
if not backup_root.exists():
return ok(backup_dir=str(backup_root), backups=[], count=0, restore_policy=BACKUP_RESTORE_POLICY)
max_items = max(1, min(int(limit), 500))
files = sorted(backup_root.glob("*.sqlite3"), key=lambda p: p.stat().st_mtime, reverse=True)[:max_items]
backups = [_backup_file_metadata(path, include_hash=include_hash) for path in files]
return ok(backup_dir=str(backup_root), backups=backups, count=len(backups), restore_policy=BACKUP_RESTORE_POLICY)
def get_ledger_backup_status(file_path: str, include_hash: bool = True, include_integrity: bool = True) -> dict[str, Any]:
path = Path(file_path).expanduser().resolve()
if not path.exists():
return fail("not_found", f"backup file not found: {path}", path=str(path), exists=False)
return ok(backup=_ledger_db_status_for_path(path, include_hash=include_hash, include_integrity=include_integrity), restore_policy=BACKUP_RESTORE_POLICY)
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) -> 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")
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_synced_at,statement_status,liquidity_bucket,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (account_id, name.strip(), type, institution, currency, int(active), memo, account_number_masked, holder_name, last_balance, last_synced_at, statement_status_norm, liquidity_bucket_norm, ts, ts))
row = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone()
return ok(account=row_to_dict(row))
def list_ledger_accounts(active_only: bool = True, db_path: str | None = None) -> dict[str, Any]:
sql = "SELECT * FROM accounts" + (" WHERE active=1" if active_only else "") + " ORDER BY name"
with db(db_path) as conn:
rows = conn.execute(sql).fetchall()
return ok(accounts=[row_to_dict(r) for r in rows], count=len(rows))
@@ -0,0 +1,272 @@
from __future__ import annotations
from .shared import *
def create_ledger_transaction(transaction_date: str = "", type: str = "expense", amount: int | float = 0, category: str = "", subcategory: str = "", merchant: str = "", description: str = "", account_id: str = "", payment_method: str = "", person: str = "", project: str = "", tags: Any = None, is_fixed: bool = False, currency: str = "KRW", posted_date: str = "", source: str = "manual", external_id: str = "", memo: str = "", dedupe: bool = True, transaction_datetime: str = "", counterparty: str = "", balance_after: int | None = None, branch: str = "", raw_summary: str = "", raw_payload: str = "", db_path: str | None = None) -> dict[str, Any]:
try:
tx_date = normalize_date(transaction_date, default_today=True)
posted = normalize_date(posted_date, default_today=False) if posted_date else ""
amount_int = int(amount)
tags_json = normalize_tags(tags)
except Exception as e:
return fail("validation_error", str(e))
if type not in VALID_TRANSACTION_TYPES:
return fail("validation_error", f"type must be one of {sorted(VALID_TRANSACTION_TYPES)}")
if amount_int <= 0:
return fail("validation_error", "amount must be a positive integer in minor units; type determines direction")
dedupe_key = make_dedupe_key(tx_date, amount_int, merchant, account_id, external_id) if dedupe else ""
ts = now_iso(); tx_id = new_id("tx")
try:
with db(db_path) as conn:
conn.execute("""INSERT INTO transactions(id,transaction_date,posted_date,type,amount,currency,category,subcategory,merchant,description,account_id,payment_method,person,project,tags,is_fixed,source,external_id,dedupe_key,memo,transaction_datetime,counterparty,balance_after,branch,raw_summary,raw_payload,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (tx_id, tx_date, posted, type, amount_int, currency, category, subcategory, merchant, description, account_id, payment_method, person, project, tags_json, int(is_fixed), source, external_id, dedupe_key, memo, transaction_datetime, counterparty, balance_after, branch, raw_summary, raw_payload, ts, ts))
row = conn.execute("SELECT * FROM transactions WHERE id=?", (tx_id,)).fetchone()
except Exception as e:
if "UNIQUE constraint failed" in str(e):
return fail("duplicate_transaction", "transaction appears to be duplicated", dedupe_key=dedupe_key)
return fail("db_error", str(e))
return ok(transaction=row_to_dict(row))
def list_ledger_transactions(start_date: str = "", end_date: str = "", type: str = "", category: str = "", person: str = "", account_id: str = "", include_deleted: bool = False, limit: int = 100, db_path: str | None = None) -> dict[str, Any]:
clauses=[]; params=[]
if not include_deleted:
clauses.append("status='active'")
for field, value in [("type", type), ("category", category), ("person", person), ("account_id", account_id)]:
if value:
clauses.append(f"{field}=?"); params.append(value)
if start_date:
clauses.append("transaction_date>=?"); params.append(normalize_date(start_date))
if end_date:
clauses.append("transaction_date<=?"); params.append(normalize_date(end_date))
where = " WHERE " + " AND ".join(clauses) if clauses else ""
sql = f"SELECT * FROM transactions{where} ORDER BY transaction_date DESC, created_at DESC LIMIT ?"
params.append(max(1, min(int(limit), 1000)))
with db(db_path) as conn:
rows = conn.execute(sql, params).fetchall()
return ok(transactions=[row_to_dict(r) for r in rows], count=len(rows))
def _transaction_signed_amount(tx_type: str, amount: int) -> int:
if tx_type in {"income", "refund", "adjustment"}:
return int(amount)
if tx_type in {"expense", "transfer"}:
return -int(amount)
return 0
def _account_balance_row(conn: sqlite3.Connection, account_id: str, baseline_at: str) -> dict[str, Any]:
date_cutoff = normalize_date(baseline_at[:10])
rows = conn.execute(
"""SELECT type, amount, balance_after, transaction_date, transaction_datetime, created_at
FROM transactions
WHERE status='active' AND account_id=? AND transaction_date<=?
ORDER BY transaction_date ASC, transaction_datetime ASC, created_at ASC""",
(account_id, date_cutoff),
).fetchall()
signed_total = sum(_transaction_signed_amount(row["type"], int(row["amount"] or 0)) for row in rows)
latest_balance_row = next((row for row in reversed(rows) if row["balance_after"] is not None), None)
if latest_balance_row is not None:
calculated_balance = int(latest_balance_row["balance_after"])
basis = "latest_transaction_balance_after"
basis_at = latest_balance_row["transaction_datetime"] or latest_balance_row["transaction_date"]
else:
calculated_balance = int(signed_total)
basis = "net_transaction_delta_without_opening_balance"
basis_at = baseline_at
return {
"calculated_balance": calculated_balance,
"calculation_basis": basis,
"calculation_basis_at": basis_at,
"transaction_count": len(rows),
"signed_transaction_total": int(signed_total),
}
def get_balance_reconciliation_report(account_id: str = "", baseline_balance: int | None = None, baseline_at: str = "", source_note: str = "", memo: str = "", tolerance: int = BALANCE_RECONCILIATION_TOLERANCE_KRW, db_path: str | None = None) -> dict[str, Any]:
"""Preview manual/partial-import account balance reconciliation without mutating transactions."""
try:
baseline_at_norm = normalize_snapshot_at(baseline_at.strip(), default_now=False) if baseline_at and baseline_at.strip() else None
tolerance_int = int(tolerance)
if tolerance_int < 0:
raise ValueError("tolerance must be non-negative")
baseline_balance_int = int(baseline_balance) if baseline_balance is not None else None
except Exception as e:
return fail("validation_error", str(e))
with db(db_path) as conn:
clauses = ["active=1"]
params: list[Any] = []
if account_id:
clauses.append("id=?")
params.append(account_id)
else:
placeholders = ",".join("?" for _ in RECONCILIATION_TARGET_STATUSES)
clauses.append(f"statement_status IN ({placeholders})")
params.extend(sorted(RECONCILIATION_TARGET_STATUSES))
accounts = conn.execute(f"SELECT * FROM accounts WHERE {' AND '.join(clauses)} ORDER BY name", params).fetchall()
items: list[dict[str, Any]] = []
for account in accounts:
effective_baseline = baseline_balance_int if baseline_balance_int is not None else account["last_balance"]
effective_at = baseline_at_norm or account["last_synced_at"] or now_iso()
calc = _account_balance_row(conn, account["id"], effective_at)
if effective_baseline is None:
difference = None
ratio = None
status = "BASELINE_REQUIRED"
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"
items.append({
"account_id": account["id"],
"account_name": account["name"],
"statement_status": account["statement_status"],
"is_reconciliation_target": account["statement_status"] in RECONCILIATION_TARGET_STATUSES,
"baseline_balance": effective_baseline,
"baseline_at": effective_at,
"calculated_balance": calc["calculated_balance"],
"difference": difference,
"tolerance": tolerance_int,
"reference_ratio_threshold": BALANCE_RECONCILIATION_REFERENCE_RATIO,
"difference_ratio": ratio,
"status": status,
"recommended_decisions": ["계좌이동", "실지출", "실수입", "투자이동", "분석제외", "확인필요"] if status == "REVIEW_NEEDED" else [],
"source_note": source_note,
"memo": memo,
**calc,
})
return ok(items=items, count=len(items), tolerance=tolerance_int, baseline_at=baseline_at_norm, routing="preview_only_manual_confirmation_required")
def update_ledger_transaction(transaction_id: str, db_path: str | None = None, **fields: Any) -> dict[str, Any]:
allowed = {"transaction_date","posted_date","type","amount","currency","category","subcategory","merchant","description","account_id","payment_method","person","project","tags","is_fixed","source","external_id","memo","status","transaction_datetime","counterparty","balance_after","branch","raw_summary","raw_payload"}
updates=[]; params=[]
for k,v in fields.items():
if k not in allowed or v is None:
continue
if k == "tags": v = normalize_tags(v)
if k in {"transaction_date","posted_date"} and v: v = normalize_date(v)
if k == "amount": v = int(v)
if k == "is_fixed": v = int(bool(v))
updates.append(f"{k}=?"); params.append(v)
if not updates:
return fail("validation_error", "no fields to update")
updates.append("updated_at=?"); params.append(now_iso()); params.append(transaction_id)
with db(db_path) as conn:
cur = conn.execute(f"UPDATE transactions SET {', '.join(updates)} WHERE id=?", params)
if cur.rowcount == 0:
return fail("not_found", "transaction not found")
row = conn.execute("SELECT * FROM transactions WHERE id=?", (transaction_id,)).fetchone()
return ok(transaction=row_to_dict(row))
def delete_ledger_transaction(transaction_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 transactions WHERE id=?", (transaction_id,))
else:
cur = conn.execute("UPDATE transactions SET status='deleted', updated_at=? WHERE id=?", (now_iso(), transaction_id))
if cur.rowcount == 0:
return fail("not_found", "transaction not found")
return ok(transaction_id=transaction_id, deleted=True, hard_delete=hard_delete)
def create_ledger_budget(month: str, category: str, amount: int | float, subcategory: str = "", person: str = "", currency: str = "KRW", memo: str = "", db_path: str | None = None) -> dict[str, Any]:
try:
month_norm = normalize_month(month); amount_int = int(amount)
except Exception as e:
return fail("validation_error", str(e))
if not category:
return fail("validation_error", "category is required")
if amount_int < 0:
return fail("validation_error", "amount must be non-negative")
ts = now_iso(); budget_id = new_id("bud")
with db(db_path) as conn:
conn.execute("""INSERT INTO budgets(id,month,category,subcategory,person,amount,currency,memo,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(month,category,subcategory,person) DO UPDATE SET amount=excluded.amount,currency=excluded.currency,memo=excluded.memo,updated_at=excluded.updated_at""", (budget_id, month_norm, category, subcategory, person, amount_int, currency, memo, ts, ts))
row = conn.execute("SELECT * FROM budgets WHERE month=? AND category=? AND subcategory=? AND person=?", (month_norm, category, subcategory, person)).fetchone()
return ok(budget=row_to_dict(row))
def list_ledger_budgets(month: str = "", db_path: str | None = None) -> dict[str, Any]:
clauses=[]; params=[]
if month:
clauses.append("month=?"); params.append(normalize_month(month))
where = " WHERE " + " AND ".join(clauses) if clauses else ""
with db(db_path) as conn:
rows = conn.execute(f"SELECT * FROM budgets{where} ORDER BY month DESC, category", params).fetchall()
return ok(budgets=[row_to_dict(r) for r in rows], count=len(rows))
def _month_range(month: str) -> tuple[str, str]:
m = normalize_month(month)
y, mo = map(int, m.split("-"))
start = date(y, mo, 1)
end = date(y + (mo == 12), 1 if mo == 12 else mo + 1, 1)
return start.isoformat(), end.isoformat()
def get_monthly_cashflow_report(month: str = "", db_path: str | None = None) -> dict[str, Any]:
month_norm = normalize_month(month); start, next_month = _month_range(month_norm)
with db(db_path) as conn:
type_rows = conn.execute("""SELECT type, SUM(amount) AS total FROM transactions
WHERE status='active' AND transaction_date>=? AND transaction_date<?
GROUP BY type""", (start, next_month)).fetchall()
cashflow_rows = conn.execute(f"""SELECT {CASHFLOW_KIND_SQL} AS cashflow_kind, SUM(t.amount) AS total
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}""", (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)
def get_category_spending_report(month: str = "", start_date: str = "", end_date: str = "", person: str = "", db_path: str | None = None) -> dict[str, Any]:
if month:
start_date, next_month = _month_range(month); end_op = "<"; end_value = next_month
else:
start_date = normalize_date(start_date, default_today=False) if start_date else "0001-01-01"
end_value = normalize_date(end_date, default_today=False) if end_date else "9999-12-31"; end_op = "<="
params=[start_date, end_value]; person_clause=""
if person:
person_clause=" AND t.person=?"; params.append(person)
sql=f"""SELECT t.category, t.subcategory, SUM(t.amount) AS total, 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 {CASHFLOW_KIND_SQL}='expense' AND t.transaction_date>=? AND t.transaction_date{end_op}?{person_clause}
GROUP BY t.category, t.subcategory ORDER BY total DESC"""
with db(db_path) as conn:
rows=conn.execute(sql, params).fetchall()
total=sum(int(r["total"] or 0) for r in rows)
items=[{"category": r["category"], "subcategory": r["subcategory"], "amount": int(r["total"] or 0), "count": r["count"], "share": round((int(r["total"] or 0)/total),4) if total else 0} for r in rows]
return ok(total_expense=total, items=items, count=len(items))
def get_fixed_cost_report(month: str = "", db_path: str | None = None) -> dict[str, Any]:
month_norm=normalize_month(month); start,next_month=_month_range(month_norm)
with db(db_path) as conn:
rows=conn.execute(f"""SELECT t.category, t.merchant, t.amount, t.transaction_date, t.person, t.memo FROM transactions t
LEFT JOIN ledger_categories lc ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory
WHERE t.status='active' AND {CASHFLOW_KIND_SQL}='expense' AND t.is_fixed=1 AND t.transaction_date>=? AND t.transaction_date<?
ORDER BY t.amount DESC""", (start,next_month)).fetchall()
items=[row_to_dict(r) for r in rows]
return ok(month=month_norm, total_fixed_cost=sum(i["amount"] for i in items), items=items, count=len(items))
def get_budget_vs_actual_report(month: str = "", db_path: str | None = None) -> dict[str, Any]:
month_norm=normalize_month(month); start,next_month=_month_range(month_norm)
with db(db_path) as conn:
budgets=conn.execute("SELECT * FROM budgets WHERE month=?", (month_norm,)).fetchall()
actuals=conn.execute(f"""SELECT t.category, t.subcategory, t.person, SUM(t.amount) AS amount FROM transactions t
LEFT JOIN ledger_categories lc ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory
WHERE t.status='active' AND {CASHFLOW_KIND_SQL}='expense' AND t.transaction_date>=? AND t.transaction_date<?
GROUP BY t.category, t.subcategory, t.person""", (start,next_month)).fetchall()
actual_map={(r["category"], r["subcategory"], r["person"]): int(r["amount"] or 0) for r in actuals}
items=[]
for b in budgets:
key=(b["category"], b["subcategory"], b["person"]); actual=actual_map.get(key,0); budget=int(b["amount"])
items.append({"category": b["category"], "subcategory": b["subcategory"], "person": b["person"], "budget": budget, "actual": actual, "remaining": budget-actual, "usage_rate": round(actual/budget,4) if budget else None, "status": "over" if actual>budget else "ok"})
return ok(month=month_norm, items=items, total_budget=sum(i["budget"] for i in items), total_actual=sum(i["actual"] for i in items))
def get_investable_cashflow_report(month: str = "", minimum_cash_buffer: int = 0, planned_extra_expense: int = 0, db_path: str | None = None) -> dict[str, Any]:
cashflow=get_monthly_cashflow_report(month, db_path)
if not cashflow.get("success"):
return cashflow
investable=max(0, int(cashflow["net_cashflow"]) - int(minimum_cash_buffer) - int(planned_extra_expense))
return ok(month=cashflow["month"], income=cashflow["income"], expenses=cashflow["expenses"], net_cashflow=cashflow["net_cashflow"], minimum_cash_buffer=int(minimum_cash_buffer), planned_extra_expense=int(planned_extra_expense), investable_cashflow=investable)