Files
jarca-ledger-mcp/jarca_ledger_mcp/service.py
T
Jarca 67d8d4871b feat: add ledger backup safety workflow
confirm dcy-002 and add REQ/BAT evidence

add ledger backup/status metadata workflow

document restore as runbook/script-only guarded operation

add synthetic DB backup/restore smoke tests
2026-06-27 10:40:06 +09:00

2411 lines
121 KiB
Python

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 .db 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_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"
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 now_iso() -> str:
return datetime.now().replace(microsecond=0).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"):
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 date.today().isoformat()
raise ValueError("date is required")
return date.fromisoformat(value[:10]).isoformat()
def normalize_month(value: str | None) -> str:
if not value:
return date.today().strftime("%Y-%m")
if re.fullmatch(r"\d{4}-\d{2}", value):
return value
return date.fromisoformat(value[:10]).strftime("%Y-%m")
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 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()
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"):
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 = date.today().strftime("%Y-%m")
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))
cutoff = datetime.now() - timedelta(days=max(0, int(retention_days)))
pruned: list[str] = []
for idx, path in enumerate(backups):
mtime = datetime.fromtimestamp(path.stat().st_mtime)
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 = datetime.now().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 = "", 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)}")
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,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, 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))
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 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)
def preview_ledger_csv_import(file_path: str, source: str = "generic", db_path: str | None = None) -> dict[str, Any]:
path=Path(file_path).expanduser()
if not path.exists():
return fail("not_found", f"CSV file not found: {path}")
with path.open("r", encoding="utf-8-sig", newline="") as f:
rows=list(csv.DictReader(f))
preview=[]
for r in rows[:20]:
preview.append(r)
return ok(source=source, filename=str(path), row_count=len(rows), preview=preview, required_columns=["transaction_date/date", "amount", "type optional", "category optional", "merchant/description optional"])
def preview_kb_bank_pdf_import(file_path: str, password: str, preview_limit: int = 20, db_path: str | None = None) -> dict[str, Any]:
try:
parsed = parse_kb_bank_pdf(file_path, password)
except FileNotFoundError as e:
return fail("not_found", str(e))
except ValueError as e:
return fail("pdf_password_error", str(e))
except Exception as e:
return fail("pdf_parse_error", str(e))
transactions = parsed["transactions"]
validation = parsed["validation"]
source_hash = parsed["source_hash"]
duplicate_count = 0
with db(db_path) as conn:
for tx in transactions:
dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, tx.counterparty, "", tx.external_id(source_hash))
found = conn.execute("SELECT 1 FROM transactions WHERE dedupe_key=?", (dedupe_key,)).fetchone()
duplicate_count += 1 if found else 0
preview = [transaction_preview(tx, source_hash) for tx in transactions[: max(1, min(int(preview_limit), 100))]]
return ok(
source="kb_bank_pdf",
file_path=parsed["file_path"],
source_hash=source_hash,
page_count=parsed["page_count"],
metadata=parsed["metadata"],
row_count=len(transactions),
duplicate_count=duplicate_count,
validation=validation,
preview=preview,
)
def _find_or_create_kb_account(conn: Any, metadata: dict[str, Any], account_id: str = "") -> str:
if account_id:
row = conn.execute("SELECT id FROM accounts WHERE id=?", (account_id,)).fetchone()
if row:
return account_id
raise ValueError(f"account_id not found: {account_id}")
account_number_masked = metadata.get("account_number_masked") or ""
row = None
if account_number_masked:
row = conn.execute(
"SELECT id FROM accounts WHERE institution=? AND account_number_masked=? AND active=1 ORDER BY created_at LIMIT 1",
("KB국민은행", account_number_masked),
).fetchone()
if row:
return row["id"]
ts = now_iso()
new_account_id = new_id("acct")
account_name = metadata.get("account_name") or "KB국민은행 계좌"
display_name = f"KB국민은행 {account_name} {account_number_masked}".strip()
conn.execute(
"""INSERT INTO accounts(id,name,type,institution,currency,active,memo,account_number_masked,holder_name,last_balance,last_synced_at,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
new_account_id,
display_name,
"bank",
"KB국민은행",
"KRW",
1,
"국민은행 거래내역 PDF import로 자동 생성",
account_number_masked,
metadata.get("holder_name") or "",
metadata.get("final_balance"),
ts,
ts,
ts,
),
)
return new_account_id
def import_kb_bank_pdf(file_path: str, password: str, account_id: str = "", skip_duplicates: bool = True, db_path: str | None = None, skip_validation: bool = False) -> dict[str, Any]:
try:
parsed = parse_kb_bank_pdf(file_path, password)
except FileNotFoundError as e:
return fail("not_found", str(e))
except ValueError as e:
return fail("pdf_password_error", str(e))
except Exception as e:
return fail("pdf_parse_error", str(e))
transactions = parsed["transactions"]
validation = parsed["validation"]
if not validation.get("ok") and not skip_validation:
return fail("balance_validation_failed", "KB Bank PDF balance flow validation failed", validation=validation)
source_hash = parsed["source_hash"]
metadata = parsed["metadata"]
ts = now_iso()
batch_id = new_id("imp")
inserted = 0
skipped_duplicates = 0
inserted_ids: list[str] = []
try:
with db(db_path) as conn:
linked_account_id = _find_or_create_kb_account(conn, metadata, account_id)
conn.execute(
"""INSERT INTO import_batches(id,source,filename,imported_at,row_count,status,memo)
VALUES(?,?,?,?,?,?,?)""",
(
batch_id,
"kb_bank_pdf",
parsed["file_path"],
ts,
0,
"active",
json.dumps(
{
"source_hash": source_hash,
"page_count": parsed["page_count"],
"metadata": metadata,
"validation": validation,
"parsed_row_count": len(transactions),
},
ensure_ascii=False,
),
),
)
for tx in transactions:
tx_id = new_id("tx")
external_id = tx.external_id(source_hash)
dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, tx.counterparty, linked_account_id, external_id)
payload = tx.raw_payload()
description = f"국민은행 PDF import; 적요={tx.raw_summary}; 거래점={tx.branch}; 잔액={tx.balance_after}"
try:
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,import_batch_id,external_id,dedupe_key,memo,transaction_datetime,counterparty,balance_after,branch,raw_summary,raw_payload,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
tx_id,
tx.transaction_date,
"",
tx.type,
tx.amount,
"KRW",
"",
"",
tx.counterparty,
description,
linked_account_id,
tx.raw_summary,
"",
"",
normalize_tags(["kb_bank_pdf", tx.raw_summary] if tx.raw_summary else ["kb_bank_pdf"]),
0,
"kb_bank_pdf",
batch_id,
external_id,
dedupe_key,
tx.remittance_memo,
tx.transaction_datetime,
tx.counterparty,
tx.balance_after,
tx.branch,
tx.raw_summary,
payload,
ts,
ts,
),
)
inserted += 1
inserted_ids.append(tx_id)
except Exception as e:
if "UNIQUE constraint failed" in str(e) and skip_duplicates:
skipped_duplicates += 1
continue
raise
conn.execute("UPDATE import_batches SET row_count=? WHERE id=?", (inserted, batch_id))
if transactions:
conn.execute(
"UPDATE accounts SET last_balance=?, last_synced_at=?, updated_at=? WHERE id=?",
(transactions[-1].balance_after, ts, ts, linked_account_id),
)
except ValueError as e:
return fail("validation_error", str(e))
except Exception as e:
return fail("db_error", str(e))
return ok(
import_batch_id=batch_id,
account_id=linked_account_id,
source="kb_bank_pdf",
parsed_row_count=len(transactions),
inserted_count=inserted,
skipped_duplicate_count=skipped_duplicates,
inserted_transaction_ids=inserted_ids[:20],
validation=validation,
metadata=metadata,
)
def _find_or_create_seoulpay_account(conn: Any, metadata: dict[str, Any], account_id: str = "") -> str:
if account_id:
row = conn.execute("SELECT id FROM accounts WHERE id=?", (account_id,)).fetchone()
if row:
return account_id
raise ValueError(f"account_id not found: {account_id}")
holder_name = metadata.get("holder_name") or ""
row = conn.execute(
"SELECT id FROM accounts WHERE institution=? AND name=? AND active=1 ORDER BY created_at LIMIT 1",
("서울페이", f"서울페이 이용내역 {holder_name}".strip()),
).fetchone()
if row:
return row["id"]
ts = now_iso()
new_account_id = new_id("acct")
display_name = f"서울페이 이용내역 {holder_name}".strip()
conn.execute(
"""INSERT INTO accounts(id,name,type,institution,currency,active,memo,account_number_masked,holder_name,last_balance,last_synced_at,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
new_account_id,
display_name,
"cash",
"서울페이",
"KRW",
1,
"서울페이 이용내역 XLSX import로 자동 생성",
"",
holder_name,
None,
ts,
ts,
ts,
),
)
return new_account_id
def _classify_seoulpay_transaction(tx: Any) -> tuple[str, str, str, bool]:
merchant = (tx.merchant or "").strip()
text = f"{merchant} {tx.transaction_kind} {tx.payment_kind}"
if tx.type == "refund":
return "수입", "환급", merchant, False
if "땡겨요" in text or "배달" in text:
return "식비", "배달", merchant, False
if any(keyword in text for keyword in ("교습소", "학원", "바이올린", "피아노")):
return "교육", "학원", merchant, False
if any(keyword in text for keyword in ("김영모", "베이커리", "제과", "카페", "커피")):
return "식비", "카페", merchant, False
if "온라인" in text:
return "쇼핑", "온라인", merchant, False
return "쇼핑", "생활용품", merchant, False
def _find_or_create_toss_bank_account(conn: Any, metadata: dict[str, Any], account_id: str = "") -> str:
if account_id:
row = conn.execute("SELECT id FROM accounts WHERE id=?", (account_id,)).fetchone()
if row:
return account_id
raise ValueError(f"account_id not found: {account_id}")
account_number_masked = metadata.get("account_number_masked") or ""
row = None
if account_number_masked:
row = conn.execute(
"SELECT id FROM accounts WHERE institution=? AND account_number_masked=? AND active=1 ORDER BY created_at LIMIT 1",
("토스뱅크", account_number_masked),
).fetchone()
if row:
return row["id"]
ts = now_iso()
new_account_id = new_id("acct")
account_name = metadata.get("account_name") or "토스뱅크 계좌"
display_name = f"토스뱅크 {account_name} {account_number_masked}".strip()
conn.execute(
"""INSERT INTO accounts(id,name,type,institution,currency,active,memo,account_number_masked,holder_name,last_balance,last_synced_at,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
new_account_id,
display_name,
"bank",
"토스뱅크",
"KRW",
1,
"토스뱅크 거래내역 PDF import로 자동 생성",
account_number_masked,
metadata.get("holder_name") or "",
metadata.get("final_balance"),
ts,
ts,
ts,
),
)
return new_account_id
def _classify_toss_bank_transaction(tx: Any, owner_name: str = "") -> tuple[str, str, str, bool]:
action = (tx.action or "").strip()
counterparty = (tx.counterparty or "").strip()
owner_name = (owner_name or "").strip()
if "이자" in action:
return "수입", "이자", counterparty, False
if "캐시백" in action:
return "수입", "캐시백", counterparty, False
if "체크카드결제" in action:
return "식비", "카드결제", counterparty, False
transfer_like = {
"이체",
"내계좌간자동이체",
"오픈뱅킹",
"펌뱅킹출금",
"펌뱅킹입금",
"적금 개설",
"출금",
"입금",
"ATM출금",
}
if any(keyword in action for keyword in transfer_like):
if counterparty and owner_name and counterparty != owner_name:
return "이체", "확인필요", counterparty, False
return "이체", "본인계좌이동", counterparty, False
if "캐시백" in action:
return "수입", "캐시백", counterparty, False
return "이체", "확인필요", counterparty, False
def _build_cross_transfer_index(transactions: list[dict[str, Any]]) -> dict[tuple[str, int, str], list[dict[str, Any]]]:
index: dict[tuple[str, int, str], list[dict[str, Any]]] = {}
for tx in transactions:
if tx.get("status") != "active":
continue
key = (tx["transaction_date"], int(tx["amount"]), tx["type"])
index.setdefault(key, []).append(tx)
return index
def _fetch_transfer_candidates(db_conn: Any, source: str) -> list[dict[str, Any]]:
rows = db_conn.execute(
"""
SELECT t.id, t.transaction_datetime, t.transaction_date, t.type, t.amount, t.category, t.subcategory,
t.counterparty, t.memo, t.person, t.account_id, t.payment_method, t.status,
a.holder_name, t.merchant
FROM transactions t
LEFT JOIN accounts a ON a.id = t.account_id
WHERE t.source=?
AND t.type IN ('income','expense')
AND (
t.category='이체'
OR t.subcategory IN ('확인필요', '본인계좌이동', '투자이동')
OR t.payment_method IN ('내계좌간자동이전', '내계좌간자동이체', '오픈뱅킹', '펌뱅킹출금', '펌뱅킹입금', '입금', '출금', 'ATM출금', '적금개설')
OR t.merchant LIKE '%이체%'
)
""",
(source,),
).fetchall()
return [dict(r) for r in rows]
def _parse_tx_datetime(value: str | None) -> datetime | None:
if not value:
return None
text = value.strip()
for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"):
try:
return datetime.strptime(text, fmt)
except ValueError:
continue
try:
return datetime.fromisoformat(text)
except ValueError:
return None
def _self_transfer_direction_score(source_type: str, candidate_type: str) -> int:
if source_type == "expense":
if candidate_type == "income":
return 3
if candidate_type == "transfer":
return 2
elif source_type == "income":
if candidate_type == "expense":
return 3
if candidate_type == "transfer":
return 2
elif source_type == "transfer":
if candidate_type in {"income", "expense"}:
return 2
if candidate_type == "transfer":
return 1
return 0
def _normalize_cancel_lookup_text(value: str) -> str:
if not value:
return ""
text = value.lower().strip()
replacements = [
"kb체크취소", "승인취소", "취소", "환불", "환급", "refund", "reversal",
"카드입금", "카드환급", "매출취소", "입금", "출금", "체크카드", "신용카드",
]
for token in replacements:
text = text.replace(token, " ")
text = re.sub(r"\s+", " ", text)
return _normalize_counterparty_for_match(text)
def _cancel_channel(value: str) -> str:
text = (value or "").lower()
if any(token in text for token in ("카드", "check", "credit")):
return "card"
if any(token in text for token in ("이체", "오픈뱅킹", "출금", "입금", "bank")):
return "bank"
return "other"
def _is_cancel_like_transaction(tx: dict[str, Any]) -> bool:
if str(tx.get("type") or "") == "refund":
return True
combined = " ".join(
str(tx.get(key) or "") for key in ("merchant", "raw_summary", "payment_method", "memo", "description")
).lower()
keywords = ("취소", "환불", "환급", "refund", "reversal", "카드입금", "매출취소")
return str(tx.get("type") or "") == "income" and any(token in combined for token in keywords)
def find_cancel_original_candidates(transaction_id: str, limit: int = 20, lookback_days: int = 365, db_path: str | None = None) -> dict[str, Any]:
limit = max(1, min(int(limit), 50))
lookback_days = max(1, min(int(lookback_days), 730))
with db(db_path) as conn:
source_row = conn.execute(
"""
SELECT t.*, a.name AS account_name, a.institution, a.holder_name
FROM transactions t
LEFT JOIN accounts a ON a.id = t.account_id
WHERE t.id=?
""",
(transaction_id,),
).fetchone()
if not source_row:
return fail("not_found", f"transaction not found: {transaction_id}")
source = row_to_dict(source_row)
explicit_cancel_like = _is_cancel_like_transaction(source)
source_type = str(source.get("type") or "")
if source_type not in {"income", "refund"} and not explicit_cancel_like:
return fail("not_cancel_like", "cancel/original lookup is intended for 취소·환급성 거래")
source_date = normalize_date(source.get("transaction_date"), default_today=False)
source_dt = _parse_tx_datetime(source.get("transaction_datetime"))
source_day = date.fromisoformat(source_date)
start_date = date.fromordinal(source_day.toordinal() - lookback_days).isoformat()
source_text = " ".join(str(source.get(key) or "") for key in ("merchant", "counterparty", "raw_summary", "payment_method"))
source_norm_text = _normalize_cancel_lookup_text(source_text)
source_channel = _cancel_channel(str(source.get("payment_method") or "") + " " + str(source.get("raw_summary") or "") + " " + str(source.get("merchant") or ""))
candidate_rows = conn.execute(
"""
SELECT t.*, a.name AS account_name, a.institution, a.holder_name
FROM transactions t
LEFT JOIN accounts a ON a.id = t.account_id
WHERE t.status='active'
AND t.id<>?
AND t.amount=?
AND t.transaction_date BETWEEN ? AND ?
AND t.type IN ('expense', 'adjustment')
ORDER BY t.transaction_date DESC, t.transaction_datetime DESC, t.created_at DESC
""",
(transaction_id, int(source.get("amount") or 0), start_date, source_date),
).fetchall()
candidates: list[dict[str, Any]] = []
for row in candidate_rows:
candidate = row_to_dict(row)
candidate_date = normalize_date(candidate.get("transaction_date"), default_today=False)
candidate_day = date.fromisoformat(candidate_date)
day_delta = (source_day - candidate_day).days
if day_delta < 0:
continue
candidate_dt = _parse_tx_datetime(candidate.get("transaction_datetime"))
time_delta_seconds = abs(int((source_dt - candidate_dt).total_seconds())) if source_dt and candidate_dt else None
same_account = bool(source.get("account_id") and source.get("account_id") == candidate.get("account_id"))
source_same = bool(source.get("source") and source.get("source") == candidate.get("source"))
candidate_text = " ".join(str(candidate.get(key) or "") for key in ("merchant", "counterparty", "raw_summary", "payment_method"))
candidate_norm_text = _normalize_cancel_lookup_text(candidate_text)
merchant_score = 0
if source_norm_text and candidate_norm_text:
if source_norm_text == candidate_norm_text:
merchant_score = 4
elif source_norm_text in candidate_norm_text or candidate_norm_text in source_norm_text:
merchant_score = 2
channel_score = 0
candidate_channel = _cancel_channel(str(candidate.get("payment_method") or "") + " " + str(candidate.get("raw_summary") or "") + " " + str(candidate.get("merchant") or ""))
if source_channel != "other" and source_channel == candidate_channel:
channel_score = 2
if not explicit_cancel_like and not (same_account and merchant_score >= 2 and channel_score >= 1):
continue
recency_score = 0
if day_delta == 0:
recency_score = 5
elif day_delta <= 7:
recency_score = 4
elif day_delta <= 30:
recency_score = 3
elif day_delta <= 90:
recency_score = 2
else:
recency_score = 1
time_score = 0
if time_delta_seconds is not None:
if time_delta_seconds <= 3600:
time_score = 2
elif time_delta_seconds <= 86400:
time_score = 1
score = merchant_score + channel_score + recency_score + time_score + (3 if same_account else 0) + (1 if source_same else 0)
reason_bits = [
f"same_account={int(same_account)}",
f"merchant={merchant_score}",
f"channel={channel_score}",
f"days={day_delta}",
]
if time_delta_seconds is not None:
reason_bits.append(f"time_delta={time_delta_seconds}s")
candidates.append({
"id": candidate.get("id"),
"transaction_date": candidate.get("transaction_date"),
"transaction_datetime": candidate.get("transaction_datetime"),
"source": candidate.get("source"),
"type": candidate.get("type"),
"amount": candidate.get("amount"),
"category": candidate.get("category"),
"subcategory": candidate.get("subcategory"),
"merchant": candidate.get("merchant"),
"raw_summary": candidate.get("raw_summary"),
"counterparty": candidate.get("counterparty"),
"account_id": candidate.get("account_id"),
"account_name": candidate.get("account_name"),
"institution": candidate.get("institution"),
"payment_method": candidate.get("payment_method"),
"memo": candidate.get("memo"),
"score": score,
"day_delta": day_delta,
"time_delta_seconds": time_delta_seconds,
"reason": ", ".join(reason_bits),
})
candidates.sort(key=lambda item: (-int(item.get("score", 0)), int(item.get("day_delta", 9999)), int(item.get("time_delta_seconds") or 99999999), str(item.get("transaction_datetime") or "")))
return ok(source_tx=source, count=len(candidates[:limit]), candidates=candidates[:limit], source="cancel_original_candidate_search")
def find_self_transfer_candidates(transaction_id: str, limit: int = 20, db_path: str | None = None) -> dict[str, Any]:
limit = max(1, min(int(limit), 50))
with db(db_path) as conn:
source_row = conn.execute(
"""
SELECT t.*, a.name AS account_name, a.institution, a.holder_name
FROM transactions t
LEFT JOIN accounts a ON a.id = t.account_id
WHERE t.id=?
""",
(transaction_id,),
).fetchone()
if not source_row:
return fail("not_found", f"transaction not found: {transaction_id}")
source = row_to_dict(source_row)
source_date = normalize_date(source.get("transaction_date"), default_today=False)
source_dt = _parse_tx_datetime(source.get("transaction_datetime"))
source_holder = _normalize_counterparty_for_match(source.get("holder_name", ""))
window_start = (date.fromisoformat(source_date) if source_date else date.today()).toordinal() - 1
window_end = (date.fromisoformat(source_date) if source_date else date.today()).toordinal() + 1
candidate_rows = conn.execute(
"""
SELECT t.*, a.name AS account_name, a.institution, a.holder_name
FROM transactions t
LEFT JOIN accounts a ON a.id = t.account_id
WHERE t.status='active'
AND t.id<>?
AND COALESCE(t.account_id, '')<>COALESCE(?, '')
AND t.amount=?
AND t.transaction_date BETWEEN ? AND ?
ORDER BY t.transaction_date, t.transaction_datetime, t.created_at
""",
(
transaction_id,
source.get("account_id", ""),
int(source.get("amount") or 0),
date.fromordinal(window_start).isoformat(),
date.fromordinal(window_end).isoformat(),
),
).fetchall()
candidates: list[dict[str, Any]] = []
for row in candidate_rows:
candidate = row_to_dict(row)
direction_score = _self_transfer_direction_score(str(source.get("type", "")), str(candidate.get("type", "")))
if direction_score == 0:
continue
candidate_dt = _parse_tx_datetime(candidate.get("transaction_datetime"))
day_delta = abs((date.fromisoformat(candidate.get("transaction_date", source_date)) - date.fromisoformat(source_date)).days)
time_delta_seconds = abs(int((candidate_dt - source_dt).total_seconds())) if candidate_dt and source_dt else None
same_owner = bool(source_holder and source_holder == _normalize_counterparty_for_match(candidate.get("holder_name", "")))
party_score = max(
_party_match_score(str(source.get("counterparty", "")), str(candidate.get("counterparty", ""))),
_party_match_score(str(source.get("counterparty", "")), str(candidate.get("holder_name", ""))),
_party_match_score(str(source.get("merchant", "")), str(candidate.get("counterparty", ""))),
_party_match_score(str(source.get("merchant", "")), str(candidate.get("holder_name", ""))),
)
source_gap_score = 1 if source.get("source") != candidate.get("source") else 0
day_score = 3 if day_delta == 0 else 1
time_score = 0
if time_delta_seconds is not None:
if time_delta_seconds <= 120:
time_score = 3
elif time_delta_seconds <= 3600:
time_score = 2
elif time_delta_seconds <= 86400:
time_score = 1
score = direction_score + party_score + source_gap_score + day_score + time_score + (3 if same_owner else 0)
reason_bits = [
f"direction={direction_score}",
f"party={party_score}",
f"same_owner={int(same_owner)}",
f"same_source={int(source.get('source') == candidate.get('source'))}",
f"day_delta={day_delta}",
]
if time_delta_seconds is not None:
reason_bits.append(f"time_delta={time_delta_seconds}s")
candidates.append(
{
"id": candidate.get("id"),
"transaction_date": candidate.get("transaction_date"),
"transaction_datetime": candidate.get("transaction_datetime"),
"source": candidate.get("source"),
"type": candidate.get("type"),
"amount": candidate.get("amount"),
"category": candidate.get("category"),
"subcategory": candidate.get("subcategory"),
"merchant": candidate.get("merchant"),
"counterparty": candidate.get("counterparty"),
"account_id": candidate.get("account_id"),
"account_name": candidate.get("account_name"),
"institution": candidate.get("institution"),
"holder_name": candidate.get("holder_name"),
"memo": candidate.get("memo"),
"score": score,
"same_owner": same_owner,
"day_delta": day_delta,
"time_delta_seconds": time_delta_seconds,
"reason": ", ".join(reason_bits),
}
)
candidates.sort(key=lambda item: (-int(item.get("score", 0)), int(item.get("day_delta", 99)), int(item.get("time_delta_seconds") or 999999), str(item.get("transaction_datetime") or "")))
return ok(source_tx=source, count=len(candidates[:limit]), candidates=candidates[:limit], source="self_transfer_candidate_search")
def link_self_transfer_pair(source_transaction_id: str, candidate_transaction_id: str, db_path: str | None = None) -> dict[str, Any]:
if not source_transaction_id or not candidate_transaction_id:
return fail("validation_error", "both transaction ids are required")
if source_transaction_id == candidate_transaction_id:
return fail("validation_error", "source and candidate transaction must differ")
preview = find_self_transfer_candidates(source_transaction_id, limit=50, db_path=db_path)
if not preview.get("success"):
return preview
candidates = preview.get("candidates") or []
candidate = next((item for item in candidates if item.get("id") == candidate_transaction_id), None)
if candidate is None:
return fail("candidate_not_found", f"candidate not found for source transaction: {candidate_transaction_id}")
link_tag = f"manual_self_transfer_link:{source_transaction_id}|{candidate_transaction_id}"
ts = now_iso()
with db(db_path) as conn:
source_row = conn.execute("SELECT * FROM transactions WHERE id=?", (source_transaction_id,)).fetchone()
candidate_row = conn.execute("SELECT * FROM transactions WHERE id=?", (candidate_transaction_id,)).fetchone()
if not source_row or not candidate_row:
return fail("not_found", "source or candidate transaction not found")
source_memo = _append_memo_tag(source_row["memo"], link_tag)
candidate_memo = _append_memo_tag(candidate_row["memo"], link_tag)
conn.execute(
"UPDATE transactions SET category='이체', subcategory='본인계좌이동', memo=?, updated_at=? WHERE id=?",
(source_memo, ts, source_transaction_id),
)
conn.execute(
"UPDATE transactions SET category='이체', subcategory='본인계좌이동', memo=?, updated_at=? WHERE id=?",
(candidate_memo, ts, candidate_transaction_id),
)
updated_source = conn.execute("SELECT * FROM transactions WHERE id=?", (source_transaction_id,)).fetchone()
updated_candidate = conn.execute("SELECT * FROM transactions WHERE id=?", (candidate_transaction_id,)).fetchone()
return ok(
source_transaction=row_to_dict(updated_source),
candidate_transaction=row_to_dict(updated_candidate),
link_tag=link_tag,
score=candidate.get("score"),
reason=candidate.get("reason"),
source="manual_self_transfer_link",
)
def link_cancel_to_original(cancel_transaction_id: str, original_transaction_id: str, db_path: str | None = None) -> dict[str, Any]:
if not cancel_transaction_id or not original_transaction_id:
return fail("validation_error", "both transaction ids are required")
if cancel_transaction_id == original_transaction_id:
return fail("validation_error", "cancel and original transaction must differ")
preview = find_cancel_original_candidates(cancel_transaction_id, limit=50, db_path=db_path)
if not preview.get("success"):
return preview
candidates = preview.get("candidates") or []
original = next((item for item in candidates if item.get("id") == original_transaction_id), None)
if original is None:
return fail("candidate_not_found", f"original transaction candidate not found: {original_transaction_id}")
link_tag = f"manual_cancel_link:cancel={cancel_transaction_id}|original={original_transaction_id}"
ts = now_iso()
with db(db_path) as conn:
cancel_row = conn.execute("SELECT * FROM transactions WHERE id=?", (cancel_transaction_id,)).fetchone()
original_row = conn.execute("SELECT * FROM transactions WHERE id=?", (original_transaction_id,)).fetchone()
if not cancel_row or not original_row:
return fail("not_found", "cancel or original transaction not found")
cancel_memo = _append_memo_tag(cancel_row["memo"], link_tag)
original_memo = _append_memo_tag(original_row["memo"], link_tag)
original_category = original_row["category"] or "수입"
original_subcategory = original_row["subcategory"] or "환급"
original_person = original_row["person"] or ""
conn.execute(
"UPDATE transactions SET type='refund', category=?, subcategory=?, person=?, memo=?, updated_at=? WHERE id=?",
(original_category, original_subcategory, original_person, cancel_memo, ts, cancel_transaction_id),
)
conn.execute(
"UPDATE transactions SET memo=?, updated_at=? WHERE id=?",
(original_memo, ts, original_transaction_id),
)
updated_cancel = conn.execute("SELECT * FROM transactions WHERE id=?", (cancel_transaction_id,)).fetchone()
updated_original = conn.execute("SELECT * FROM transactions WHERE id=?", (original_transaction_id,)).fetchone()
return ok(
cancel_transaction=row_to_dict(updated_cancel),
original_transaction=row_to_dict(updated_original),
link_tag=link_tag,
score=original.get("score"),
reason=original.get("reason"),
source="manual_cancel_link",
)
def _tag_memos(conn: Any, tx_id: str, tag: str) -> None:
existing = conn.execute("SELECT memo FROM transactions WHERE id=?", (tx_id,)).fetchone()
if not existing:
return
memo = _append_memo_tag(existing["memo"], tag)
conn.execute("UPDATE transactions SET memo=?, updated_at=? WHERE id=?", (memo, now_iso(), tx_id))
def _run_cross_account_transfer_match(limit: int = 500, dry_run: bool = True, db_path: str | None = None) -> dict[str, Any]:
limit = max(1, min(int(limit), 1000))
matched_pairs: list[dict[str, Any]] = []
review_items: list[dict[str, Any]] = []
matched_tx_ids: set[str] = set()
with db(db_path) as conn:
kb_rows = _fetch_transfer_candidates(conn, "kb_bank_pdf")
toss_rows = _fetch_transfer_candidates(conn, "toss_bank_pdf")
toss_index = _build_cross_transfer_index(toss_rows)
for kb in kb_rows:
if kb["status"] != "active":
continue
if _memo_has_tag(kb.get("memo", ""), CROSS_TRANSFER_MATCH_TAG):
continue
required_type = "income" if kb["type"] == "expense" else "expense"
candidates = [
t
for t in toss_index.get((kb["transaction_date"], int(kb["amount"]), required_type), [])
if t.get("status") == "active" and not _memo_has_tag(t.get("memo", ""), CROSS_TRANSFER_MATCH_TAG)
]
if not candidates:
review_items.append(
{
"source_tx_id": kb["id"],
"source": "kb_bank_pdf",
"target_tx_id": "",
"reason": "no_candidate_found",
"type": kb["type"],
"amount": kb["amount"],
"transaction_date": kb["transaction_date"],
"counterparty": kb["counterparty"],
}
)
continue
scored = []
for candidate in candidates:
score = _party_match_score(kb.get("counterparty", ""), candidate.get("counterparty", ""))
scored.append((score, candidate["transaction_datetime"], candidate))
scored.sort(key=lambda item: (item[0], item[1]), reverse=True)
best_score = scored[0][0]
top = [c for c in scored if c[0] == best_score]
if best_score == 0 or len(top) != 1:
review_items.append(
{
"source_tx_id": kb["id"],
"source": "kb_bank_pdf",
"target_tx_id": "",
"reason": "ambiguous_or_name_mismatch",
"candidate_count": len(candidates),
"best_name_score": best_score,
"type": kb["type"],
"amount": kb["amount"],
"transaction_date": kb["transaction_date"],
"counterparty": kb["counterparty"],
}
)
continue
toss = top[0][2]
if toss["id"] in matched_tx_ids:
review_items.append(
{
"source_tx_id": kb["id"],
"source": "kb_bank_pdf",
"target_tx_id": toss["id"],
"reason": "target_already_matched",
"type": kb["type"],
"amount": kb["amount"],
"transaction_date": kb["transaction_date"],
"counterparty": kb["counterparty"],
}
)
continue
kb_party = _normalize_counterparty_for_match(kb.get("counterparty", ""))
toss_party = _normalize_counterparty_for_match(toss.get("counterparty", ""))
owner_candidates = {
_normalize_counterparty_for_match(kb.get("holder_name", "")),
_normalize_counterparty_for_match(toss.get("holder_name", "")),
}
if (kb_party and kb_party in owner_candidates) or (toss_party and toss_party in owner_candidates):
subcategory = "투자이동"
else:
subcategory = "확인필요"
matched_pairs.append(
{
"kb_tx_id": kb["id"],
"toss_tx_id": toss["id"],
"transaction_date": kb["transaction_date"],
"amount": kb["amount"],
"counterparty_kb": kb["counterparty"],
"counterparty_toss": toss["counterparty"],
"subcategory": subcategory,
"name_score": best_score,
"reason": "paired_by_date_amount_party",
}
)
if not dry_run:
review_tag = f"{CROSS_TRANSFER_MATCH_TAG}kb={kb['id']}|toss={toss['id']}"
conn.execute(
"UPDATE transactions SET category='이체', subcategory=?, memo=?, updated_at=? WHERE id=?",
(subcategory, _append_memo_tag(kb.get("memo", ""), review_tag), now_iso(), kb["id"]),
)
conn.execute(
"UPDATE transactions SET category='이체', subcategory=?, memo=?, updated_at=? WHERE id=?",
(subcategory, _append_memo_tag(toss.get("memo", ""), review_tag), now_iso(), toss["id"]),
)
if not dry_run:
_tag_memos(conn, kb["id"], f"{CROSS_TRANSFER_REVIEW_TAG}paired")
_tag_memos(conn, toss["id"], f"{CROSS_TRANSFER_REVIEW_TAG}paired")
matched_tx_ids.add(kb["id"])
matched_tx_ids.add(toss["id"])
if len(matched_pairs) >= limit:
break
for review in review_items:
if review["source_tx_id"] in matched_tx_ids:
continue
if not dry_run:
_tag_memos(conn, review["source_tx_id"], f"{CROSS_TRANSFER_REVIEW_TAG}{review['reason']}")
return ok(
dry_run=dry_run,
matched_count=len(matched_pairs),
review_count=len(review_items),
matched_pairs=matched_pairs[:limit],
review_items=review_items[:limit],
source="kb_toss_cross_transfer_match",
)
def preview_cross_account_transfers(limit: int = 500, db_path: str | None = None) -> dict[str, Any]:
return _run_cross_account_transfer_match(limit=limit, dry_run=True, db_path=db_path)
def run_cross_account_transfer_match(limit: int = 500, db_path: str | None = None) -> dict[str, Any]:
return _run_cross_account_transfer_match(limit=limit, dry_run=False, db_path=db_path)
def list_cross_transfer_review_queue(limit: int = 300, db_path: str | None = None) -> dict[str, Any]:
clauses = ["t.status='active'", "t.source IN ('kb_bank_pdf','toss_bank_pdf')"]
clauses.append("t.memo LIKE '%cross_transfer_review:%'")
where = " WHERE " + " AND ".join(clauses)
with db(db_path) as conn:
rows = conn.execute(
f"SELECT * FROM transactions t{where} ORDER BY t.transaction_date DESC, t.created_at DESC LIMIT ?",
(max(1, min(int(limit), 1000)),),
).fetchall()
return ok(transactions=[row_to_dict(r) for r in rows], count=len(rows), source="cross_transfer_review_queue")
def preview_seoulpay_xlsx_import(file_path: str, password: str = "", preview_limit: int = 20, db_path: str | None = None) -> dict[str, Any]:
try:
parsed = parse_seoulpay_xlsx(file_path, password=password)
except FileNotFoundError as e:
return fail("not_found", str(e))
except ValueError as e:
return fail("xlsx_parse_error", str(e))
except Exception as e:
return fail("xlsx_parse_error", str(e))
transactions = parsed["transactions"]
source_hash = parsed["source_hash"]
duplicate_count = 0
by_category: dict[str, dict[str, int]] = {}
with db(db_path) as conn:
linked_account_id = ""
metadata = parsed["metadata"]
holder_name = metadata.get("holder_name") or ""
if holder_name:
row = conn.execute(
"SELECT id FROM accounts WHERE institution=? AND name=? AND active=1 ORDER BY created_at LIMIT 1",
("서울페이", f"서울페이 이용내역 {holder_name}".strip()),
).fetchone()
linked_account_id = row["id"] if row else ""
for tx in transactions:
category, subcategory, _, _ = _classify_seoulpay_transaction(tx)
key = f"{category}/{subcategory}"
item = by_category.setdefault(key, {"count": 0, "amount": 0})
item["count"] += 1
item["amount"] += int(tx.amount)
merchant = f"서울페이 {tx.merchant}".strip()
dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, merchant, linked_account_id, tx.external_id(source_hash))
found = conn.execute("SELECT 1 FROM transactions WHERE dedupe_key=?", (dedupe_key,)).fetchone()
duplicate_count += 1 if found else 0
preview = [seoulpay_transaction_preview(tx, source_hash) for tx in transactions[: max(1, min(int(preview_limit), 100))]]
return ok(
source="seoulpay_xlsx",
file_path=parsed["file_path"],
source_hash=source_hash,
sheet_name=parsed["sheet_name"],
metadata=parsed["metadata"],
row_count=len(transactions),
duplicate_count=duplicate_count,
validation=parsed["validation"],
by_category=by_category,
preview=preview,
)
def import_seoulpay_xlsx(file_path: str, password: str = "", account_id: str = "", skip_duplicates: bool = True, db_path: str | None = None) -> dict[str, Any]:
try:
parsed = parse_seoulpay_xlsx(file_path, password=password)
except FileNotFoundError as e:
return fail("not_found", str(e))
except ValueError as e:
return fail("xlsx_parse_error", str(e))
except Exception as e:
return fail("xlsx_parse_error", str(e))
transactions = parsed["transactions"]
validation = parsed["validation"]
if not validation.get("ok"):
return fail("xlsx_parse_error", "SeoulPay XLSX parsing produced invalid transactions", validation=validation)
source_hash = parsed["source_hash"]
metadata = parsed["metadata"]
ts = now_iso()
batch_id = new_id("imp")
inserted = 0
skipped_duplicates = 0
inserted_ids: list[str] = []
try:
with db(db_path) as conn:
linked_account_id = _find_or_create_seoulpay_account(conn, metadata, account_id)
conn.execute(
"""INSERT INTO import_batches(id,source,filename,imported_at,row_count,status,memo)
VALUES(?,?,?,?,?,?,?)""",
(
batch_id,
"seoulpay_xlsx",
parsed["file_path"],
ts,
0,
"active",
json.dumps(
{
"source_hash": source_hash,
"sheet_name": parsed["sheet_name"],
"metadata": metadata,
"validation": validation,
"parsed_row_count": len(transactions),
},
ensure_ascii=False,
),
),
)
for tx in transactions:
tx_id = new_id("tx")
external_id = tx.external_id(source_hash)
merchant = f"서울페이 {tx.merchant}".strip()
category, subcategory, person, is_fixed = _classify_seoulpay_transaction(tx)
dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, merchant, linked_account_id, external_id)
description = f"서울페이 XLSX import; 거래유형={tx.transaction_kind}; 결제구분={tx.payment_kind}; 거래번호={tx.transaction_no}; 가맹점ID={tx.merchant_id}"
try:
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,import_batch_id,external_id,dedupe_key,memo,transaction_datetime,counterparty,balance_after,branch,raw_summary,raw_payload,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
tx_id,
tx.transaction_date,
"",
tx.type,
tx.amount,
"KRW",
category,
subcategory,
merchant,
description,
linked_account_id,
tx.payment_kind,
person,
"",
normalize_tags(["seoulpay_xlsx", tx.channel, tx.payment_kind, tx.transaction_kind]),
int(is_fixed),
"seoulpay_xlsx",
batch_id,
external_id,
dedupe_key,
tx.payment_kind,
tx.transaction_datetime,
tx.merchant,
None,
tx.channel,
tx.transaction_kind,
tx.raw_payload(),
ts,
ts,
),
)
inserted += 1
inserted_ids.append(tx_id)
except Exception as e:
if "UNIQUE constraint failed" in str(e) and skip_duplicates:
skipped_duplicates += 1
continue
raise
conn.execute("UPDATE import_batches SET row_count=? WHERE id=?", (inserted, batch_id))
except ValueError as e:
return fail("validation_error", str(e))
except Exception as e:
return fail("db_error", str(e))
return ok(
import_batch_id=batch_id,
account_id=linked_account_id,
source="seoulpay_xlsx",
parsed_row_count=len(transactions),
inserted_count=inserted,
skipped_duplicate_count=skipped_duplicates,
inserted_transaction_ids=inserted_ids[:20],
validation=validation,
metadata=metadata,
)
def preview_toss_bank_pdf_import(file_path: str, preview_limit: int = 20, db_path: str | None = None) -> dict[str, Any]:
try:
parsed = parse_toss_bank_pdf(file_path)
except FileNotFoundError as e:
return fail("not_found", str(e))
except ValueError as e:
return fail("pdf_parse_error", str(e))
except Exception as e:
return fail("pdf_parse_error", str(e))
transactions = parsed["transactions"]
source_hash = parsed["source_hash"]
duplicate_count = 0
with db(db_path) as conn:
metadata = parsed["metadata"]
account_number_masked = metadata.get("account_number_masked") or ""
linked_account_id = ""
if account_number_masked:
row = conn.execute(
"SELECT id FROM accounts WHERE institution=? AND account_number_masked=? AND active=1 ORDER BY created_at LIMIT 1",
("토스뱅크", account_number_masked),
).fetchone()
linked_account_id = row["id"] if row else ""
for tx in transactions:
merchant = f"토스뱅크 {tx.action}" + (f" {tx.counterparty}" if tx.counterparty else "")
dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, merchant, linked_account_id, tx.external_id(source_hash))
found = conn.execute("SELECT 1 FROM transactions WHERE dedupe_key=?", (dedupe_key,)).fetchone()
duplicate_count += 1 if found else 0
preview = [toss_bank_transaction_preview(tx, source_hash) for tx in transactions[: max(1, min(int(preview_limit), 100))]]
by_action: dict[str, dict[str, int]] = {}
for tx in transactions:
item = by_action.setdefault(tx.action, {"count": 0, "amount": 0})
item["count"] += 1
item["amount"] += int(tx.amount)
return ok(
source="toss_bank_pdf",
file_path=parsed["file_path"],
source_hash=source_hash,
page_count=parsed["page_count"],
metadata=parsed["metadata"],
row_count=len(transactions),
duplicate_count=duplicate_count,
validation=parsed["validation"],
by_action=by_action,
preview=preview,
)
def import_toss_bank_pdf(file_path: str, account_id: str = "", skip_duplicates: bool = True, db_path: str | None = None) -> dict[str, Any]:
try:
parsed = parse_toss_bank_pdf(file_path)
except FileNotFoundError as e:
return fail("not_found", str(e))
except ValueError as e:
return fail("pdf_parse_error", str(e))
except Exception as e:
return fail("pdf_parse_error", str(e))
transactions = parsed["transactions"]
validation = parsed["validation"]
if not validation.get("ok"):
return fail("pdf_parse_error", "Toss Bank PDF parsing produced no valid transactions", validation=validation)
source_hash = parsed["source_hash"]
metadata = parsed["metadata"]
ts = now_iso()
batch_id = new_id("imp")
inserted = 0
skipped_duplicates = 0
inserted_ids: list[str] = []
try:
with db(db_path) as conn:
linked_account_id = _find_or_create_toss_bank_account(conn, metadata, account_id)
conn.execute(
"""INSERT INTO import_batches(id,source,filename,imported_at,row_count,status,memo)
VALUES(?,?,?,?,?,?,?)""",
(
batch_id,
"toss_bank_pdf",
parsed["file_path"],
ts,
0,
"active",
json.dumps(
{
"source_hash": source_hash,
"page_count": parsed["page_count"],
"metadata": metadata,
"validation": validation,
"parsed_row_count": len(transactions),
},
ensure_ascii=False,
),
),
)
for tx in transactions:
tx_id = new_id("tx")
external_id = tx.external_id(source_hash)
merchant = f"토스뱅크 {tx.action}" + (f" {tx.counterparty}" if tx.counterparty else "")
category, subcategory, person, is_fixed = _classify_toss_bank_transaction(tx, metadata.get("holder_name") or "")
if category == "이체":
tx_type = "transfer"
else:
tx_type = tx.type
dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, merchant, linked_account_id, external_id)
description = f"토스뱅크 PDF import; 구분={tx.action}; 상대={tx.counterparty}; 잔액={tx.balance_after}"
payload = tx.raw_payload()
try:
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,import_batch_id,external_id,dedupe_key,memo,transaction_datetime,counterparty,balance_after,branch,raw_summary,raw_payload,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
tx_id,
tx.transaction_date,
"",
tx_type,
tx.amount,
"KRW",
category,
subcategory,
merchant,
description,
linked_account_id,
tx.action,
person,
"",
normalize_tags(["toss_bank_pdf", tx.action] + ([tx.counterparty] if tx.counterparty else [])),
int(is_fixed),
"toss_bank_pdf",
batch_id,
external_id,
dedupe_key,
tx.action,
tx.transaction_datetime,
tx.counterparty,
tx.balance_after,
"토스뱅크",
tx.raw_summary,
payload,
ts,
ts,
),
)
inserted += 1
inserted_ids.append(tx_id)
except Exception as e:
if "UNIQUE constraint failed" in str(e) and skip_duplicates:
skipped_duplicates += 1
continue
raise
conn.execute("UPDATE import_batches SET row_count=? WHERE id=?", (inserted, batch_id))
if transactions:
conn.execute(
"UPDATE accounts SET last_balance=?, last_synced_at=?, updated_at=? WHERE id=?",
(transactions[-1].balance_after, ts, ts, linked_account_id),
)
except ValueError as e:
return fail("validation_error", str(e))
except Exception as e:
return fail("db_error", str(e))
return ok(
import_batch_id=batch_id,
account_id=linked_account_id,
source="toss_bank_pdf",
parsed_row_count=len(transactions),
inserted_count=inserted,
skipped_duplicate_count=skipped_duplicates,
inserted_transaction_ids=inserted_ids[:20],
validation=validation,
metadata=metadata,
)
def _find_or_create_toss_securities_account(conn: Any, metadata: dict[str, Any], account_id: str = "") -> str:
if account_id:
row = conn.execute("SELECT id FROM accounts WHERE id=?", (account_id,)).fetchone()
if row:
return account_id
raise ValueError(f"account_id not found: {account_id}")
account_number_masked = metadata.get("account_number_masked") or ""
row = None
if account_number_masked:
row = conn.execute(
"SELECT id FROM accounts WHERE institution=? AND account_number_masked=? AND active=1 ORDER BY created_at LIMIT 1",
("토스증권", account_number_masked),
).fetchone()
if row:
return row["id"]
ts = now_iso()
new_account_id = new_id("acct")
display_name = f"토스증권 {metadata.get('account_name') or '계좌'} {account_number_masked}".strip()
conn.execute(
"""INSERT INTO accounts(id,name,type,institution,currency,active,memo,account_number_masked,holder_name,last_balance,last_synced_at,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
new_account_id,
display_name,
"investment",
"토스증권",
"KRW",
1,
"토스증권 거래내역 PDF import로 자동 생성",
account_number_masked,
metadata.get("holder_name") or "",
None,
ts,
ts,
ts,
),
)
return new_account_id
def _classify_toss_transaction(tx: Any, owner_name: str = "") -> tuple[str, str, str, bool]:
action = tx.action
counterparty = (tx.name or "").strip()
owner_name = (owner_name or "").strip()
if "배당" in action:
return "수입", "배당", counterparty, False
if "이자" in action:
return "수입", "이자", counterparty, False
if "환급" in action:
return "수입", "환급", counterparty, False
if "이체" in action:
# 토스증권 입출금에서 상대가 계좌 소유자와 다르면 본인 투자 이동으로 확정하지 않는다.
# 예: 이체출금(국민은행) 조상문 -> 외부/타인 송금 가능성이 있어 확인필요.
if counterparty and owner_name and counterparty != owner_name:
return "이체", "확인필요", counterparty, False
return "이체", "투자이동", counterparty, False
return "이체", "투자이동", counterparty, False
def preview_toss_securities_pdf_import(file_path: str, preview_limit: int = 20, db_path: str | None = None) -> dict[str, Any]:
try:
parsed = parse_toss_securities_pdf(file_path)
except FileNotFoundError as e:
return fail("not_found", str(e))
except ValueError as e:
return fail("pdf_parse_error", str(e))
except Exception as e:
return fail("pdf_parse_error", str(e))
transactions = parsed["transactions"]
source_hash = parsed["source_hash"]
duplicate_count = 0
with db(db_path) as conn:
metadata = parsed["metadata"]
account_number_masked = metadata.get("account_number_masked") or ""
linked_account_id = ""
if account_number_masked:
row = conn.execute(
"SELECT id FROM accounts WHERE institution=? AND account_number_masked=? AND active=1 ORDER BY created_at LIMIT 1",
("토스증권", account_number_masked),
).fetchone()
linked_account_id = row["id"] if row else ""
for tx in transactions:
merchant = f"토스증권 {tx.action}" + (f" {tx.name}" if tx.name else "")
dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, merchant, linked_account_id, tx.external_id(source_hash))
found = conn.execute("SELECT 1 FROM transactions WHERE dedupe_key=?", (dedupe_key,)).fetchone()
duplicate_count += 1 if found else 0
preview = [toss_transaction_preview(tx, source_hash) for tx in transactions[: max(1, min(int(preview_limit), 100))]]
by_action: dict[str, dict[str, int]] = {}
for tx in transactions:
item = by_action.setdefault(tx.action, {"count": 0, "amount": 0})
item["count"] += 1
item["amount"] += int(tx.amount)
return ok(
source="toss_securities_pdf",
file_path=parsed["file_path"],
source_hash=source_hash,
page_count=parsed["page_count"],
metadata=parsed["metadata"],
row_count=len(transactions),
duplicate_count=duplicate_count,
validation=parsed["validation"],
by_action=by_action,
preview=preview,
)
def import_toss_securities_pdf(file_path: str, account_id: str = "", skip_duplicates: bool = True, db_path: str | None = None) -> dict[str, Any]:
try:
parsed = parse_toss_securities_pdf(file_path)
except FileNotFoundError as e:
return fail("not_found", str(e))
except ValueError as e:
return fail("pdf_parse_error", str(e))
except Exception as e:
return fail("pdf_parse_error", str(e))
transactions = parsed["transactions"]
validation = parsed["validation"]
if not validation.get("ok"):
return fail("pdf_parse_error", "Toss Securities PDF parsing produced no valid transactions", validation=validation)
source_hash = parsed["source_hash"]
metadata = parsed["metadata"]
ts = now_iso()
batch_id = new_id("imp")
inserted = 0
skipped_duplicates = 0
inserted_ids: list[str] = []
try:
with db(db_path) as conn:
linked_account_id = _find_or_create_toss_securities_account(conn, metadata, account_id)
conn.execute(
"""INSERT INTO import_batches(id,source,filename,imported_at,row_count,status,memo)
VALUES(?,?,?,?,?,?,?)""",
(
batch_id,
"toss_securities_pdf",
parsed["file_path"],
ts,
0,
"active",
json.dumps(
{
"source_hash": source_hash,
"page_count": parsed["page_count"],
"metadata": metadata,
"validation": validation,
"parsed_row_count": len(transactions),
},
ensure_ascii=False,
),
),
)
for tx in transactions:
tx_id = new_id("tx")
external_id = tx.external_id(source_hash)
merchant = f"토스증권 {tx.action}" + (f" {tx.name}" if tx.name else "")
category, subcategory, person, is_fixed = _classify_toss_transaction(tx, metadata.get("holder_name") or "")
dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, merchant, linked_account_id, external_id)
description = f"토스증권 PDF import; 구분={tx.action}; 종목/상대={tx.name}; 수량={tx.quantity}; 단가={tx.unit_price}; 수수료={tx.fee}; 세금={tx.tax}; 잔액={tx.balance_after}"
try:
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,import_batch_id,external_id,dedupe_key,memo,transaction_datetime,counterparty,balance_after,branch,raw_summary,raw_payload,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
tx_id,
tx.transaction_date,
"",
tx.type,
tx.amount,
"KRW",
category,
subcategory,
merchant,
description,
linked_account_id,
tx.action,
person,
"",
normalize_tags(["toss_securities_pdf", tx.section, tx.action, tx.code] if tx.code else ["toss_securities_pdf", tx.section, tx.action]),
int(is_fixed),
"toss_securities_pdf",
batch_id,
external_id,
dedupe_key,
tx.code,
tx.transaction_date,
tx.name,
tx.balance_after,
"토스증권",
tx.action,
tx.raw_payload(),
ts,
ts,
),
)
inserted += 1
inserted_ids.append(tx_id)
except Exception as e:
if "UNIQUE constraint failed" in str(e) and skip_duplicates:
skipped_duplicates += 1
continue
raise
conn.execute("UPDATE import_batches SET row_count=? WHERE id=?", (inserted, batch_id))
if transactions:
conn.execute(
"UPDATE accounts SET last_balance=?, last_synced_at=?, updated_at=? WHERE id=?",
(transactions[-1].balance_after, ts, ts, linked_account_id),
)
except ValueError as e:
return fail("validation_error", str(e))
except Exception as e:
return fail("db_error", str(e))
return ok(
import_batch_id=batch_id,
account_id=linked_account_id,
source="toss_securities_pdf",
parsed_row_count=len(transactions),
inserted_count=inserted,
skipped_duplicate_count=skipped_duplicates,
inserted_transaction_ids=inserted_ids[:20],
validation=validation,
metadata=metadata,
)
def list_ledger_import_batches(source: str = "", include_rolled_back: bool = True, limit: int = 50, db_path: str | None = None) -> dict[str, Any]:
clauses=[]; params=[]
if source:
clauses.append("source=?"); params.append(source)
if not include_rolled_back:
clauses.append("status='active'")
where = " WHERE " + " AND ".join(clauses) if clauses else ""
params.append(max(1, min(int(limit), 500)))
with db(db_path) as conn:
rows=conn.execute(f"SELECT * FROM import_batches{where} ORDER BY imported_at DESC LIMIT ?", params).fetchall()
return ok(import_batches=[row_to_dict(r) for r in rows], count=len(rows))
def rollback_ledger_import_batch(import_batch_id: str, hard_delete: bool = False, db_path: str | None = None) -> dict[str, Any]:
with db(db_path) as conn:
batch = conn.execute("SELECT * FROM import_batches WHERE id=?", (import_batch_id,)).fetchone()
if not batch:
return fail("not_found", "import batch not found")
if hard_delete:
cur = conn.execute("DELETE FROM transactions WHERE import_batch_id=?", (import_batch_id,))
else:
cur = conn.execute("UPDATE transactions SET status='deleted', updated_at=? WHERE import_batch_id=? AND status='active'", (now_iso(), import_batch_id))
conn.execute("UPDATE import_batches SET status='rolled_back' WHERE id=?", (import_batch_id,))
return ok(import_batch_id=import_batch_id, affected_transactions=cur.rowcount, hard_delete=hard_delete)
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 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 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 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))
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)