2958 lines
147 KiB
Python
2958 lines
147 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 zoneinfo import ZoneInfo
|
|
|
|
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_STATEMENT_STATUSES = {"unknown", "import_supported", "manual_only", "partial_import"}
|
|
VALID_LIQUIDITY_BUCKETS = {"cash", "investment", "deposit", "real_estate", "loan", "other"}
|
|
RECONCILIATION_TARGET_STATUSES = {"manual_only", "partial_import"}
|
|
BALANCE_RECONCILIATION_TOLERANCE_KRW = 1_000
|
|
BALANCE_RECONCILIATION_REFERENCE_RATIO = 0.001
|
|
VALID_CASHFLOW_KINDS = {"income", "expense", "transfer", "investment", "debt_payment", "exclude", "unknown"}
|
|
CASHFLOW_KIND_SQL = """COALESCE(lc.cashflow_kind, CASE
|
|
WHEN t.category='수입' THEN 'income'
|
|
WHEN t.category='이체' AND t.subcategory='투자이동' THEN 'investment'
|
|
WHEN t.category='이체' THEN 'transfer'
|
|
WHEN t.category='금융' AND t.subcategory='대출상환' THEN 'debt_payment'
|
|
WHEN t.category='금융' THEN 'exclude'
|
|
WHEN t.category='미분류' THEN 'unknown'
|
|
WHEN COALESCE(t.category, '')='' THEN 'unknown'
|
|
ELSE 'expense'
|
|
END)"""
|
|
|
|
CROSS_TRANSFER_MATCH_TAG = "cross_transfer_match:"
|
|
CROSS_TRANSFER_REVIEW_TAG = "cross_transfer_review:"
|
|
DEFAULT_LEDGER_BACKUP_DIR = Path.home() / ".local" / "share" / "jarca" / "backups" / "ledger"
|
|
KST = ZoneInfo("Asia/Seoul")
|
|
BACKUP_RESTORE_POLICY = {
|
|
"mcp_restore_exposed": False,
|
|
"restore_requires_explicit_user_approval": True,
|
|
"restore_entrypoint": "scripts/ledger_restore_smoke.py 또는 docs/runbooks/ledger-backup-restore.md",
|
|
}
|
|
|
|
|
|
def _normalize_counterparty_for_match(value: str) -> str:
|
|
if not value:
|
|
return ""
|
|
# normalize party names for fuzzy matching across KB/토스뱅크 records
|
|
normalized = re.sub(r"\\([^)]*\\)", "", value)
|
|
normalized = normalized.lower().strip()
|
|
normalized = normalized.replace("토스뱅크", "").replace("토스", "")
|
|
normalized = normalized.replace("kb", "").replace("국민은행", "")
|
|
normalized = re.sub(r"\\s+", "", normalized)
|
|
normalized = re.sub(r"[^0-9가-힣a-zA-Z]", "", normalized)
|
|
return normalized
|
|
|
|
|
|
def _party_match_score(left: str, right: str) -> int:
|
|
left_norm = _normalize_counterparty_for_match(left)
|
|
right_norm = _normalize_counterparty_for_match(right)
|
|
if not left_norm or not right_norm:
|
|
return 0
|
|
if left_norm == right_norm:
|
|
return 2
|
|
if left_norm in right_norm or right_norm in left_norm:
|
|
return 1
|
|
return 0
|
|
|
|
|
|
def _append_memo_tag(raw_memo: str, tag: str) -> str:
|
|
memo = (raw_memo or "").strip()
|
|
if not memo:
|
|
return tag
|
|
if tag in memo:
|
|
return memo
|
|
return f"{memo}; {tag}"
|
|
|
|
|
|
def _memo_has_tag(raw_memo: str, tag_prefix: str) -> bool:
|
|
memo = (raw_memo or "").strip()
|
|
return tag_prefix in memo
|
|
|
|
|
|
def _current_kst_datetime() -> datetime:
|
|
return datetime.now(KST).replace(microsecond=0)
|
|
|
|
|
|
def current_kst_date() -> date:
|
|
return _current_kst_datetime().date()
|
|
|
|
|
|
def current_kst_month() -> str:
|
|
return current_kst_date().strftime("%Y-%m")
|
|
|
|
|
|
def now_iso() -> str:
|
|
return _current_kst_datetime().isoformat()
|
|
|
|
def new_id(prefix: str) -> str:
|
|
return f"{prefix}_{uuid4().hex[:12]}"
|
|
|
|
def ok(**data: Any) -> dict[str, Any]:
|
|
return {"success": True, **data}
|
|
|
|
def fail(error_code: str, message: str, **data: Any) -> dict[str, Any]:
|
|
return {"success": False, "error_code": error_code, "error": message, **data}
|
|
|
|
def row_to_dict(row: Any) -> dict[str, Any]:
|
|
d = dict(row)
|
|
if "tags" in d:
|
|
try:
|
|
d["tags"] = json.loads(d["tags"] or "[]")
|
|
except json.JSONDecodeError:
|
|
d["tags"] = []
|
|
for k in ("active", "is_fixed"):
|
|
if k in d:
|
|
d[k] = bool(d[k])
|
|
return d
|
|
|
|
def normalize_date(value: str | None, default_today: bool = False) -> str:
|
|
if not value:
|
|
if default_today:
|
|
return current_kst_date().isoformat()
|
|
raise ValueError("date is required")
|
|
return date.fromisoformat(value[:10]).isoformat()
|
|
|
|
def normalize_month(value: str | None) -> str:
|
|
if not value:
|
|
return current_kst_month()
|
|
if re.fullmatch(r"\d{4}-\d{2}", value):
|
|
return value
|
|
return date.fromisoformat(value[:10]).strftime("%Y-%m")
|
|
|
|
|
|
def normalize_statement_status(value: str | None) -> str:
|
|
status = (value or "unknown").strip() or "unknown"
|
|
if status not in VALID_STATEMENT_STATUSES:
|
|
raise ValueError(f"statement_status must be one of {sorted(VALID_STATEMENT_STATUSES)}")
|
|
return status
|
|
|
|
|
|
def normalize_liquidity_bucket(value: str | None) -> str:
|
|
bucket = (value or "other").strip() or "other"
|
|
if bucket not in VALID_LIQUIDITY_BUCKETS:
|
|
raise ValueError(f"liquidity_bucket must be one of {sorted(VALID_LIQUIDITY_BUCKETS)}")
|
|
return bucket
|
|
|
|
|
|
def normalize_snapshot_at(value: str | None, default_now: bool = True) -> str:
|
|
if not value:
|
|
if default_now:
|
|
return now_iso()
|
|
raise ValueError("baseline_at is required")
|
|
raw = value.strip()
|
|
if re.fullmatch(r"\d{4}-\d{2}", raw):
|
|
month_start = date.fromisoformat(raw + "-01")
|
|
if month_start.month == 12:
|
|
next_month = date(month_start.year + 1, 1, 1)
|
|
else:
|
|
next_month = date(month_start.year, month_start.month + 1, 1)
|
|
last_day = next_month - timedelta(days=1)
|
|
return f"{last_day.isoformat()}T23:59:00+09:00"
|
|
if "T" not in raw and len(raw) == 10:
|
|
return f"{normalize_date(raw)}T23:59:00+09:00"
|
|
datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
|
return raw
|
|
|
|
def normalize_tags(tags: Any) -> str:
|
|
if tags is None or tags == "":
|
|
return "[]"
|
|
if isinstance(tags, str):
|
|
parts = [p.strip() for p in re.split(r"[,#]", tags) if p.strip()]
|
|
return json.dumps(parts, ensure_ascii=False)
|
|
if isinstance(tags, list):
|
|
return json.dumps([str(x).strip() for x in tags if str(x).strip()], ensure_ascii=False)
|
|
raise ValueError("tags must be a list or comma-separated string")
|
|
|
|
def 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 = current_kst_month()
|
|
start, next_month = _month_range(current_month)
|
|
rows = conn.execute(
|
|
f"""SELECT {CASHFLOW_KIND_SQL} AS cashflow_kind, SUM(t.amount) AS total, COUNT(*) AS count
|
|
FROM transactions t
|
|
LEFT JOIN ledger_categories lc ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory
|
|
WHERE t.status='active' AND t.transaction_date>=? AND t.transaction_date<?
|
|
GROUP BY {CASHFLOW_KIND_SQL}""",
|
|
(start, next_month),
|
|
).fetchall()
|
|
return {
|
|
"month": current_month,
|
|
"by_cashflow_kind": {
|
|
str(row["cashflow_kind"]): {"amount": int(row["total"] or 0), "count": int(row["count"] or 0)}
|
|
for row in rows
|
|
},
|
|
}
|
|
|
|
|
|
def _ledger_db_status_for_path(path: Path, include_hash: bool = True, include_integrity: bool = True) -> dict[str, Any]:
|
|
status: dict[str, Any] = {"db_path": str(path.resolve()), "exists": path.exists()}
|
|
if not path.exists():
|
|
return status
|
|
status.update(_file_metadata(path, include_hash=include_hash))
|
|
try:
|
|
with _readonly_sqlite_connection(path) as conn:
|
|
if include_integrity:
|
|
status["integrity_check"] = str(conn.execute("PRAGMA integrity_check").fetchone()[0])
|
|
status["table_counts"] = _safe_table_counts(conn)
|
|
status["aggregate_totals"] = _safe_monthly_aggregates(conn)
|
|
except Exception as e:
|
|
status["integrity_check"] = "error"
|
|
status["error"] = str(e)
|
|
return status
|
|
|
|
|
|
def get_ledger_db_status(db_path: str | None = None, include_hash: bool = True, include_integrity: bool = True) -> dict[str, Any]:
|
|
"""Return non-sensitive metadata evidence for the selected ledger DB."""
|
|
path = resolve_db_path(db_path)
|
|
if not path.exists():
|
|
return fail("not_found", f"ledger DB not found: {path}", db_path=str(path), exists=False)
|
|
return ok(**_ledger_db_status_for_path(path, include_hash=include_hash, include_integrity=include_integrity))
|
|
|
|
|
|
def _resolve_backup_dir(backup_dir: str | None = None) -> Path:
|
|
return Path(backup_dir).expanduser().resolve() if backup_dir else DEFAULT_LEDGER_BACKUP_DIR.resolve()
|
|
|
|
|
|
def _backup_file_metadata(path: Path, include_hash: bool = False) -> dict[str, Any]:
|
|
metadata = _file_metadata(path, include_hash=include_hash)
|
|
metadata["name"] = path.name
|
|
metadata["restore_policy"] = BACKUP_RESTORE_POLICY
|
|
return metadata
|
|
|
|
|
|
def _apply_backup_retention(backup_dir: Path, retention_days: int = 30, retention_count: int = 20) -> list[str]:
|
|
backups = sorted(backup_dir.glob("*.sqlite3"), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
keep_count = max(1, int(retention_count))
|
|
# Retention comparison needs sub-second precision for retention_days=0:
|
|
# the timestamp used for backup names is second-truncated, but filesystem mtimes
|
|
# can identify files created just before retention runs as older than "now".
|
|
cutoff = datetime.now(KST) - timedelta(days=max(0, int(retention_days)))
|
|
pruned: list[str] = []
|
|
for idx, path in enumerate(backups):
|
|
mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=KST)
|
|
should_keep = idx < keep_count or mtime >= cutoff
|
|
if should_keep:
|
|
continue
|
|
path.unlink()
|
|
pruned.append(str(path.resolve()))
|
|
return pruned
|
|
|
|
|
|
def create_ledger_backup(db_path: str | None = None, backup_dir: str | None = None, retention_days: int = 30, retention_count: int = 20) -> dict[str, Any]:
|
|
"""Create a timestamped SQLite backup and return metadata-only evidence."""
|
|
source_path = resolve_db_path(db_path)
|
|
if not source_path.exists():
|
|
return fail("not_found", f"ledger DB not found: {source_path}", db_path=str(source_path), exists=False)
|
|
backup_root = _resolve_backup_dir(backup_dir)
|
|
backup_root.mkdir(parents=True, exist_ok=True)
|
|
timestamp = _current_kst_datetime().strftime("%Y%m%d%H%M%S%f")
|
|
backup_path = backup_root / f"ledger_{timestamp}.sqlite3"
|
|
try:
|
|
# Use SQLite's online backup API so WAL-mode databases are copied consistently.
|
|
with sqlite3.connect(f"file:{source_path}?mode=ro", uri=True) as source_conn:
|
|
with sqlite3.connect(backup_path) as backup_conn:
|
|
source_conn.backup(backup_conn)
|
|
except Exception:
|
|
if backup_path.exists():
|
|
backup_path.unlink()
|
|
raise
|
|
source_status = _ledger_db_status_for_path(source_path, include_hash=True, include_integrity=True)
|
|
backup_status = _ledger_db_status_for_path(backup_path, include_hash=True, include_integrity=True)
|
|
pruned_paths = _apply_backup_retention(backup_root, retention_days=retention_days, retention_count=retention_count)
|
|
return ok(
|
|
source_db=source_status,
|
|
backup=backup_status,
|
|
backup_dir=str(backup_root),
|
|
retention={"retention_days": int(retention_days), "retention_count": int(retention_count), "pruned_paths": pruned_paths},
|
|
restore_policy=BACKUP_RESTORE_POLICY,
|
|
)
|
|
|
|
|
|
def list_ledger_backups(backup_dir: str | None = None, include_hash: bool = False, limit: int = 50) -> dict[str, Any]:
|
|
backup_root = _resolve_backup_dir(backup_dir)
|
|
if not backup_root.exists():
|
|
return ok(backup_dir=str(backup_root), backups=[], count=0, restore_policy=BACKUP_RESTORE_POLICY)
|
|
max_items = max(1, min(int(limit), 500))
|
|
files = sorted(backup_root.glob("*.sqlite3"), key=lambda p: p.stat().st_mtime, reverse=True)[:max_items]
|
|
backups = [_backup_file_metadata(path, include_hash=include_hash) for path in files]
|
|
return ok(backup_dir=str(backup_root), backups=backups, count=len(backups), restore_policy=BACKUP_RESTORE_POLICY)
|
|
|
|
|
|
def get_ledger_backup_status(file_path: str, include_hash: bool = True, include_integrity: bool = True) -> dict[str, Any]:
|
|
path = Path(file_path).expanduser().resolve()
|
|
if not path.exists():
|
|
return fail("not_found", f"backup file not found: {path}", path=str(path), exists=False)
|
|
return ok(backup=_ledger_db_status_for_path(path, include_hash=include_hash, include_integrity=include_integrity), restore_policy=BACKUP_RESTORE_POLICY)
|
|
|
|
def create_ledger_account(name: str, type: str = "bank", institution: str = "", currency: str = "KRW", active: bool = True, memo: str = "", account_number_masked: str = "", holder_name: str = "", last_balance: int | None = None, last_synced_at: str = "", statement_status: str = "unknown", liquidity_bucket: str = "other", db_path: str | None = None) -> dict[str, Any]:
|
|
if not name.strip():
|
|
return fail("validation_error", "account name is required")
|
|
if type not in VALID_ACCOUNT_TYPES:
|
|
return fail("validation_error", f"type must be one of {sorted(VALID_ACCOUNT_TYPES)}")
|
|
try:
|
|
statement_status_norm = normalize_statement_status(statement_status)
|
|
liquidity_bucket_norm = normalize_liquidity_bucket(liquidity_bucket)
|
|
except Exception as e:
|
|
return fail("validation_error", str(e))
|
|
ts = now_iso(); account_id = new_id("acct")
|
|
with db(db_path) as conn:
|
|
conn.execute("""INSERT INTO accounts(id,name,type,institution,currency,active,memo,account_number_masked,holder_name,last_balance,last_synced_at,statement_status,liquidity_bucket,created_at,updated_at)
|
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (account_id, name.strip(), type, institution, currency, int(active), memo, account_number_masked, holder_name, last_balance, last_synced_at, statement_status_norm, liquidity_bucket_norm, ts, ts))
|
|
row = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone()
|
|
return ok(account=row_to_dict(row))
|
|
|
|
def list_ledger_accounts(active_only: bool = True, db_path: str | None = None) -> dict[str, Any]:
|
|
sql = "SELECT * FROM accounts" + (" WHERE active=1" if active_only else "") + " ORDER BY name"
|
|
with db(db_path) as conn:
|
|
rows = conn.execute(sql).fetchall()
|
|
return ok(accounts=[row_to_dict(r) for r in rows], count=len(rows))
|
|
|
|
|
|
def get_asset_liquidity_report(db_path: str | None = None) -> dict[str, Any]:
|
|
"""Summarize assets/liabilities from account-level liquidity_bucket and last_balance only."""
|
|
with db(db_path) as conn:
|
|
rows = conn.execute("SELECT * FROM accounts WHERE active=1 ORDER BY name").fetchall()
|
|
accounts = [row_to_dict(r) for r in rows]
|
|
bucket_order = ["cash", "investment", "deposit", "real_estate", "other", "loan"]
|
|
bucket_totals = {bucket: 0 for bucket in bucket_order}
|
|
bucket_counts = {bucket: 0 for bucket in bucket_order}
|
|
missing_balance_accounts: list[dict[str, Any]] = []
|
|
invalid_bucket_accounts: list[dict[str, Any]] = []
|
|
source_accounts: list[dict[str, Any]] = []
|
|
|
|
for account in accounts:
|
|
raw_bucket = str(account.get("liquidity_bucket") or "other")
|
|
try:
|
|
bucket = normalize_liquidity_bucket(raw_bucket)
|
|
except ValueError:
|
|
bucket = "other"
|
|
invalid_bucket_accounts.append({"id": account.get("id", ""), "name": account.get("name", ""), "liquidity_bucket": raw_bucket})
|
|
|
|
balance = account.get("last_balance")
|
|
if balance is None:
|
|
missing_balance_accounts.append({"id": account.get("id", ""), "name": account.get("name", ""), "liquidity_bucket": bucket})
|
|
continue
|
|
|
|
amount = int(balance)
|
|
bucket_counts[bucket] += 1
|
|
bucket_totals[bucket] += amount
|
|
source_accounts.append(
|
|
{
|
|
"id": account.get("id", ""),
|
|
"name": account.get("name", ""),
|
|
"type": account.get("type", ""),
|
|
"liquidity_bucket": bucket,
|
|
"last_balance": amount,
|
|
"last_synced_at": account.get("last_synced_at", ""),
|
|
"memo": account.get("memo", ""),
|
|
}
|
|
)
|
|
|
|
asset_buckets = ["cash", "investment", "deposit", "real_estate", "other"]
|
|
total_assets = sum(bucket_totals[bucket] for bucket in asset_buckets)
|
|
total_liabilities = abs(bucket_totals["loan"])
|
|
net_worth = total_assets - total_liabilities
|
|
buckets = []
|
|
for bucket in bucket_order:
|
|
amount = bucket_totals[bucket]
|
|
denominator = total_liabilities if bucket == "loan" else total_assets
|
|
buckets.append(
|
|
{
|
|
"liquidity_bucket": bucket,
|
|
"amount": amount,
|
|
"account_count": bucket_counts[bucket],
|
|
"asset_share": round(amount / total_assets, 4) if bucket != "loan" and total_assets else None,
|
|
"liability_share": round(abs(amount) / total_liabilities, 4) if bucket == "loan" and total_liabilities else None,
|
|
"share": round(abs(amount) / denominator, 4) if denominator else None,
|
|
}
|
|
)
|
|
return ok(
|
|
buckets=buckets,
|
|
accounts=source_accounts,
|
|
total_assets=total_assets,
|
|
total_liabilities=total_liabilities,
|
|
net_worth=net_worth,
|
|
missing_balance_accounts=missing_balance_accounts,
|
|
invalid_bucket_accounts=invalid_bucket_accounts,
|
|
source="accounts.last_balance",
|
|
)
|
|
|
|
|
|
def update_ledger_account(account_id: str, db_path: str | None = None, **fields: Any) -> dict[str, Any]:
|
|
allowed = {"name", "type", "institution", "currency", "active", "memo", "account_number_masked", "holder_name", "last_balance", "last_synced_at", "statement_status", "liquidity_bucket"}
|
|
updates: list[str] = []
|
|
params: list[Any] = []
|
|
for k, v in fields.items():
|
|
if k not in allowed or v is None:
|
|
continue
|
|
try:
|
|
if k == "type" and v not in VALID_ACCOUNT_TYPES:
|
|
return fail("validation_error", f"type must be one of {sorted(VALID_ACCOUNT_TYPES)}")
|
|
if k == "statement_status":
|
|
v = normalize_statement_status(str(v))
|
|
if k == "liquidity_bucket":
|
|
v = normalize_liquidity_bucket(str(v))
|
|
if k == "active":
|
|
v = int(bool(v))
|
|
if k == "last_balance" and v != "":
|
|
v = int(v)
|
|
except Exception as e:
|
|
return fail("validation_error", str(e))
|
|
updates.append(f"{k}=?")
|
|
params.append(v)
|
|
if not updates:
|
|
return fail("validation_error", "no fields to update")
|
|
updates.append("updated_at=?")
|
|
params.extend([now_iso(), account_id])
|
|
with db(db_path) as conn:
|
|
cur = conn.execute(f"UPDATE accounts SET {', '.join(updates)} WHERE id=?", params)
|
|
if cur.rowcount == 0:
|
|
return fail("not_found", "account not found")
|
|
row = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone()
|
|
return ok(account=row_to_dict(row))
|
|
|
|
def create_ledger_transaction(transaction_date: str = "", type: str = "expense", amount: int | float = 0, category: str = "", subcategory: str = "", merchant: str = "", description: str = "", account_id: str = "", payment_method: str = "", person: str = "", project: str = "", tags: Any = None, is_fixed: bool = False, currency: str = "KRW", posted_date: str = "", source: str = "manual", external_id: str = "", memo: str = "", dedupe: bool = True, transaction_datetime: str = "", counterparty: str = "", balance_after: int | None = None, branch: str = "", raw_summary: str = "", raw_payload: str = "", db_path: str | None = None) -> dict[str, Any]:
|
|
try:
|
|
tx_date = normalize_date(transaction_date, default_today=True)
|
|
posted = normalize_date(posted_date, default_today=False) if posted_date else ""
|
|
amount_int = int(amount)
|
|
tags_json = normalize_tags(tags)
|
|
except Exception as e:
|
|
return fail("validation_error", str(e))
|
|
if type not in VALID_TRANSACTION_TYPES:
|
|
return fail("validation_error", f"type must be one of {sorted(VALID_TRANSACTION_TYPES)}")
|
|
if amount_int <= 0:
|
|
return fail("validation_error", "amount must be a positive integer in minor units; type determines direction")
|
|
dedupe_key = make_dedupe_key(tx_date, amount_int, merchant, account_id, external_id) if dedupe else ""
|
|
ts = now_iso(); tx_id = new_id("tx")
|
|
try:
|
|
with db(db_path) as conn:
|
|
conn.execute("""INSERT INTO transactions(id,transaction_date,posted_date,type,amount,currency,category,subcategory,merchant,description,account_id,payment_method,person,project,tags,is_fixed,source,external_id,dedupe_key,memo,transaction_datetime,counterparty,balance_after,branch,raw_summary,raw_payload,created_at,updated_at)
|
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (tx_id, tx_date, posted, type, amount_int, currency, category, subcategory, merchant, description, account_id, payment_method, person, project, tags_json, int(is_fixed), source, external_id, dedupe_key, memo, transaction_datetime, counterparty, balance_after, branch, raw_summary, raw_payload, ts, ts))
|
|
row = conn.execute("SELECT * FROM transactions WHERE id=?", (tx_id,)).fetchone()
|
|
except Exception as e:
|
|
if "UNIQUE constraint failed" in str(e):
|
|
return fail("duplicate_transaction", "transaction appears to be duplicated", dedupe_key=dedupe_key)
|
|
return fail("db_error", str(e))
|
|
return ok(transaction=row_to_dict(row))
|
|
|
|
def list_ledger_transactions(start_date: str = "", end_date: str = "", type: str = "", category: str = "", person: str = "", account_id: str = "", include_deleted: bool = False, limit: int = 100, db_path: str | None = None) -> dict[str, Any]:
|
|
clauses=[]; params=[]
|
|
if not include_deleted:
|
|
clauses.append("status='active'")
|
|
for field, value in [("type", type), ("category", category), ("person", person), ("account_id", account_id)]:
|
|
if value:
|
|
clauses.append(f"{field}=?"); params.append(value)
|
|
if start_date:
|
|
clauses.append("transaction_date>=?"); params.append(normalize_date(start_date))
|
|
if end_date:
|
|
clauses.append("transaction_date<=?"); params.append(normalize_date(end_date))
|
|
where = " WHERE " + " AND ".join(clauses) if clauses else ""
|
|
sql = f"SELECT * FROM transactions{where} ORDER BY transaction_date DESC, created_at DESC LIMIT ?"
|
|
params.append(max(1, min(int(limit), 1000)))
|
|
with db(db_path) as conn:
|
|
rows = conn.execute(sql, params).fetchall()
|
|
return ok(transactions=[row_to_dict(r) for r in rows], count=len(rows))
|
|
|
|
|
|
def _transaction_signed_amount(tx_type: str, amount: int) -> int:
|
|
if tx_type in {"income", "refund", "adjustment"}:
|
|
return int(amount)
|
|
if tx_type in {"expense", "transfer"}:
|
|
return -int(amount)
|
|
return 0
|
|
|
|
|
|
def _account_balance_row(conn: sqlite3.Connection, account_id: str, baseline_at: str) -> dict[str, Any]:
|
|
date_cutoff = normalize_date(baseline_at[:10])
|
|
rows = conn.execute(
|
|
"""SELECT type, amount, balance_after, transaction_date, transaction_datetime, created_at
|
|
FROM transactions
|
|
WHERE status='active' AND account_id=? AND transaction_date<=?
|
|
ORDER BY transaction_date ASC, transaction_datetime ASC, created_at ASC""",
|
|
(account_id, date_cutoff),
|
|
).fetchall()
|
|
signed_total = sum(_transaction_signed_amount(row["type"], int(row["amount"] or 0)) for row in rows)
|
|
latest_balance_row = next((row for row in reversed(rows) if row["balance_after"] is not None), None)
|
|
if latest_balance_row is not None:
|
|
calculated_balance = int(latest_balance_row["balance_after"])
|
|
basis = "latest_transaction_balance_after"
|
|
basis_at = latest_balance_row["transaction_datetime"] or latest_balance_row["transaction_date"]
|
|
else:
|
|
calculated_balance = int(signed_total)
|
|
basis = "net_transaction_delta_without_opening_balance"
|
|
basis_at = baseline_at
|
|
return {
|
|
"calculated_balance": calculated_balance,
|
|
"calculation_basis": basis,
|
|
"calculation_basis_at": basis_at,
|
|
"transaction_count": len(rows),
|
|
"signed_transaction_total": int(signed_total),
|
|
}
|
|
|
|
|
|
def get_balance_reconciliation_report(account_id: str = "", baseline_balance: int | None = None, baseline_at: str = "", source_note: str = "", memo: str = "", tolerance: int = BALANCE_RECONCILIATION_TOLERANCE_KRW, db_path: str | None = None) -> dict[str, Any]:
|
|
"""Preview manual/partial-import account balance reconciliation without mutating transactions."""
|
|
try:
|
|
baseline_at_norm = normalize_snapshot_at(baseline_at.strip(), default_now=False) if baseline_at and baseline_at.strip() else None
|
|
tolerance_int = int(tolerance)
|
|
if tolerance_int < 0:
|
|
raise ValueError("tolerance must be non-negative")
|
|
baseline_balance_int = int(baseline_balance) if baseline_balance is not None else None
|
|
except Exception as e:
|
|
return fail("validation_error", str(e))
|
|
with db(db_path) as conn:
|
|
clauses = ["active=1"]
|
|
params: list[Any] = []
|
|
if account_id:
|
|
clauses.append("id=?")
|
|
params.append(account_id)
|
|
else:
|
|
placeholders = ",".join("?" for _ in RECONCILIATION_TARGET_STATUSES)
|
|
clauses.append(f"statement_status IN ({placeholders})")
|
|
params.extend(sorted(RECONCILIATION_TARGET_STATUSES))
|
|
accounts = conn.execute(f"SELECT * FROM accounts WHERE {' AND '.join(clauses)} ORDER BY name", params).fetchall()
|
|
items: list[dict[str, Any]] = []
|
|
for account in accounts:
|
|
effective_baseline = baseline_balance_int if baseline_balance_int is not None else account["last_balance"]
|
|
effective_at = baseline_at_norm or account["last_synced_at"] or now_iso()
|
|
calc = _account_balance_row(conn, account["id"], effective_at)
|
|
if effective_baseline is None:
|
|
difference = None
|
|
ratio = None
|
|
status = "BASELINE_REQUIRED"
|
|
else:
|
|
difference = int(effective_baseline) - int(calc["calculated_balance"])
|
|
ratio = abs(difference) / abs(int(effective_baseline)) if int(effective_baseline) else None
|
|
status = "OK" if abs(difference) <= tolerance_int else "REVIEW_NEEDED"
|
|
items.append({
|
|
"account_id": account["id"],
|
|
"account_name": account["name"],
|
|
"statement_status": account["statement_status"],
|
|
"is_reconciliation_target": account["statement_status"] in RECONCILIATION_TARGET_STATUSES,
|
|
"baseline_balance": effective_baseline,
|
|
"baseline_at": effective_at,
|
|
"calculated_balance": calc["calculated_balance"],
|
|
"difference": difference,
|
|
"tolerance": tolerance_int,
|
|
"reference_ratio_threshold": BALANCE_RECONCILIATION_REFERENCE_RATIO,
|
|
"difference_ratio": ratio,
|
|
"status": status,
|
|
"recommended_decisions": ["계좌이동", "실지출", "실수입", "투자이동", "분석제외", "확인필요"] if status == "REVIEW_NEEDED" else [],
|
|
"source_note": source_note,
|
|
"memo": memo,
|
|
**calc,
|
|
})
|
|
return ok(items=items, count=len(items), tolerance=tolerance_int, baseline_at=baseline_at_norm, routing="preview_only_manual_confirmation_required")
|
|
|
|
def update_ledger_transaction(transaction_id: str, db_path: str | None = None, **fields: Any) -> dict[str, Any]:
|
|
allowed = {"transaction_date","posted_date","type","amount","currency","category","subcategory","merchant","description","account_id","payment_method","person","project","tags","is_fixed","source","external_id","memo","status","transaction_datetime","counterparty","balance_after","branch","raw_summary","raw_payload"}
|
|
updates=[]; params=[]
|
|
for k,v in fields.items():
|
|
if k not in allowed or v is None:
|
|
continue
|
|
if k == "tags": v = normalize_tags(v)
|
|
if k in {"transaction_date","posted_date"} and v: v = normalize_date(v)
|
|
if k == "amount": v = int(v)
|
|
if k == "is_fixed": v = int(bool(v))
|
|
updates.append(f"{k}=?"); params.append(v)
|
|
if not updates:
|
|
return fail("validation_error", "no fields to update")
|
|
updates.append("updated_at=?"); params.append(now_iso()); params.append(transaction_id)
|
|
with db(db_path) as conn:
|
|
cur = conn.execute(f"UPDATE transactions SET {', '.join(updates)} WHERE id=?", params)
|
|
if cur.rowcount == 0:
|
|
return fail("not_found", "transaction not found")
|
|
row = conn.execute("SELECT * FROM transactions WHERE id=?", (transaction_id,)).fetchone()
|
|
return ok(transaction=row_to_dict(row))
|
|
|
|
def delete_ledger_transaction(transaction_id: str, hard_delete: bool = False, db_path: str | None = None) -> dict[str, Any]:
|
|
with db(db_path) as conn:
|
|
if hard_delete:
|
|
cur = conn.execute("DELETE FROM transactions WHERE id=?", (transaction_id,))
|
|
else:
|
|
cur = conn.execute("UPDATE transactions SET status='deleted', updated_at=? WHERE id=?", (now_iso(), transaction_id))
|
|
if cur.rowcount == 0:
|
|
return fail("not_found", "transaction not found")
|
|
return ok(transaction_id=transaction_id, deleted=True, hard_delete=hard_delete)
|
|
|
|
def create_ledger_budget(month: str, category: str, amount: int | float, subcategory: str = "", person: str = "", currency: str = "KRW", memo: str = "", db_path: str | None = None) -> dict[str, Any]:
|
|
try:
|
|
month_norm = normalize_month(month); amount_int = int(amount)
|
|
except Exception as e:
|
|
return fail("validation_error", str(e))
|
|
if not category:
|
|
return fail("validation_error", "category is required")
|
|
if amount_int < 0:
|
|
return fail("validation_error", "amount must be non-negative")
|
|
ts = now_iso(); budget_id = new_id("bud")
|
|
with db(db_path) as conn:
|
|
conn.execute("""INSERT INTO budgets(id,month,category,subcategory,person,amount,currency,memo,created_at,updated_at)
|
|
VALUES(?,?,?,?,?,?,?,?,?,?)
|
|
ON CONFLICT(month,category,subcategory,person) DO UPDATE SET amount=excluded.amount,currency=excluded.currency,memo=excluded.memo,updated_at=excluded.updated_at""", (budget_id, month_norm, category, subcategory, person, amount_int, currency, memo, ts, ts))
|
|
row = conn.execute("SELECT * FROM budgets WHERE month=? AND category=? AND subcategory=? AND person=?", (month_norm, category, subcategory, person)).fetchone()
|
|
return ok(budget=row_to_dict(row))
|
|
|
|
def list_ledger_budgets(month: str = "", db_path: str | None = None) -> dict[str, Any]:
|
|
clauses=[]; params=[]
|
|
if month:
|
|
clauses.append("month=?"); params.append(normalize_month(month))
|
|
where = " WHERE " + " AND ".join(clauses) if clauses else ""
|
|
with db(db_path) as conn:
|
|
rows = conn.execute(f"SELECT * FROM budgets{where} ORDER BY month DESC, category", params).fetchall()
|
|
return ok(budgets=[row_to_dict(r) for r in rows], count=len(rows))
|
|
|
|
def _month_range(month: str) -> tuple[str, str]:
|
|
m = normalize_month(month)
|
|
y, mo = map(int, m.split("-"))
|
|
start = date(y, mo, 1)
|
|
end = date(y + (mo == 12), 1 if mo == 12 else mo + 1, 1)
|
|
return start.isoformat(), end.isoformat()
|
|
|
|
def get_monthly_cashflow_report(month: str = "", db_path: str | None = None) -> dict[str, Any]:
|
|
month_norm = normalize_month(month); start, next_month = _month_range(month_norm)
|
|
with db(db_path) as conn:
|
|
type_rows = conn.execute("""SELECT type, SUM(amount) AS total FROM transactions
|
|
WHERE status='active' AND transaction_date>=? AND transaction_date<?
|
|
GROUP BY type""", (start, next_month)).fetchall()
|
|
cashflow_rows = conn.execute(f"""SELECT {CASHFLOW_KIND_SQL} AS cashflow_kind, SUM(t.amount) AS total
|
|
FROM transactions t
|
|
LEFT JOIN ledger_categories lc
|
|
ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory
|
|
WHERE t.status='active' AND t.transaction_date>=? AND t.transaction_date<?
|
|
GROUP BY {CASHFLOW_KIND_SQL}""", (start, next_month)).fetchall()
|
|
type_totals = {r["type"]: int(r["total"] or 0) for r in type_rows}
|
|
cashflow_totals = {r["cashflow_kind"]: int(r["total"] or 0) for r in cashflow_rows}
|
|
income = cashflow_totals.get("income", 0)
|
|
expenses = cashflow_totals.get("expense", 0)
|
|
net_cashflow = income - expenses
|
|
savings_rate = round(net_cashflow / income, 4) if income else None
|
|
return ok(month=month_norm, income=income, expenses=expenses, net_cashflow=net_cashflow, savings_rate=savings_rate, by_type=type_totals, by_cashflow_kind=cashflow_totals)
|
|
|
|
def get_category_spending_report(month: str = "", start_date: str = "", end_date: str = "", person: str = "", db_path: str | None = None) -> dict[str, Any]:
|
|
if month:
|
|
start_date, next_month = _month_range(month); end_op = "<"; end_value = next_month
|
|
else:
|
|
start_date = normalize_date(start_date, default_today=False) if start_date else "0001-01-01"
|
|
end_value = normalize_date(end_date, default_today=False) if end_date else "9999-12-31"; end_op = "<="
|
|
params=[start_date, end_value]; person_clause=""
|
|
if person:
|
|
person_clause=" AND t.person=?"; params.append(person)
|
|
sql=f"""SELECT t.category, t.subcategory, SUM(t.amount) AS total, COUNT(*) AS count FROM transactions t
|
|
LEFT JOIN ledger_categories lc ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory
|
|
WHERE t.status='active' AND {CASHFLOW_KIND_SQL}='expense' AND t.transaction_date>=? AND t.transaction_date{end_op}?{person_clause}
|
|
GROUP BY t.category, t.subcategory ORDER BY total DESC"""
|
|
with db(db_path) as conn:
|
|
rows=conn.execute(sql, params).fetchall()
|
|
total=sum(int(r["total"] or 0) for r in rows)
|
|
items=[{"category": r["category"], "subcategory": r["subcategory"], "amount": int(r["total"] or 0), "count": r["count"], "share": round((int(r["total"] or 0)/total),4) if total else 0} for r in rows]
|
|
return ok(total_expense=total, items=items, count=len(items))
|
|
|
|
def get_fixed_cost_report(month: str = "", db_path: str | None = None) -> dict[str, Any]:
|
|
month_norm=normalize_month(month); start,next_month=_month_range(month_norm)
|
|
with db(db_path) as conn:
|
|
rows=conn.execute(f"""SELECT t.category, t.merchant, t.amount, t.transaction_date, t.person, t.memo FROM transactions t
|
|
LEFT JOIN ledger_categories lc ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory
|
|
WHERE t.status='active' AND {CASHFLOW_KIND_SQL}='expense' AND t.is_fixed=1 AND t.transaction_date>=? AND t.transaction_date<?
|
|
ORDER BY t.amount DESC""", (start,next_month)).fetchall()
|
|
items=[row_to_dict(r) for r in rows]
|
|
return ok(month=month_norm, total_fixed_cost=sum(i["amount"] for i in items), items=items, count=len(items))
|
|
|
|
def get_budget_vs_actual_report(month: str = "", db_path: str | None = None) -> dict[str, Any]:
|
|
month_norm=normalize_month(month); start,next_month=_month_range(month_norm)
|
|
with db(db_path) as conn:
|
|
budgets=conn.execute("SELECT * FROM budgets WHERE month=?", (month_norm,)).fetchall()
|
|
actuals=conn.execute(f"""SELECT t.category, t.subcategory, t.person, SUM(t.amount) AS amount FROM transactions t
|
|
LEFT JOIN ledger_categories lc ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory
|
|
WHERE t.status='active' AND {CASHFLOW_KIND_SQL}='expense' AND t.transaction_date>=? AND t.transaction_date<?
|
|
GROUP BY t.category, t.subcategory, t.person""", (start,next_month)).fetchall()
|
|
actual_map={(r["category"], r["subcategory"], r["person"]): int(r["amount"] or 0) for r in actuals}
|
|
items=[]
|
|
for b in budgets:
|
|
key=(b["category"], b["subcategory"], b["person"]); actual=actual_map.get(key,0); budget=int(b["amount"])
|
|
items.append({"category": b["category"], "subcategory": b["subcategory"], "person": b["person"], "budget": budget, "actual": actual, "remaining": budget-actual, "usage_rate": round(actual/budget,4) if budget else None, "status": "over" if actual>budget else "ok"})
|
|
return ok(month=month_norm, items=items, total_budget=sum(i["budget"] for i in items), total_actual=sum(i["actual"] for i in items))
|
|
|
|
def get_investable_cashflow_report(month: str = "", minimum_cash_buffer: int = 0, planned_extra_expense: int = 0, db_path: str | None = None) -> dict[str, Any]:
|
|
cashflow=get_monthly_cashflow_report(month, db_path)
|
|
if not cashflow.get("success"):
|
|
return cashflow
|
|
investable=max(0, int(cashflow["net_cashflow"]) - int(minimum_cash_buffer) - int(planned_extra_expense))
|
|
return ok(month=cashflow["month"], income=cashflow["income"], expenses=cashflow["expenses"], net_cashflow=cashflow["net_cashflow"], minimum_cash_buffer=int(minimum_cash_buffer), planned_extra_expense=int(planned_extra_expense), investable_cashflow=investable)
|
|
|
|
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", ""))
|
|
anchor_date = date.fromisoformat(source_date) if source_date else current_kst_date()
|
|
window_start = anchor_date.toordinal() - 1
|
|
window_end = anchor_date.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 _category_rule_match_rows(rule_id: str, limit: int = 10000, db_path: str | None = None) -> dict[str, Any]:
|
|
if not rule_id:
|
|
return fail("validation_error", "rule_id is required")
|
|
row_limit = max(1, min(int(limit), 50000))
|
|
with db(db_path) as conn:
|
|
rule_row = conn.execute("SELECT * FROM category_rules WHERE id=?", (rule_id,)).fetchone()
|
|
if not rule_row:
|
|
return fail("not_found", f"category rule not found: {rule_id}")
|
|
rule = row_to_dict(rule_row)
|
|
rows = [row_to_dict(r) for r in conn.execute(
|
|
"""SELECT * FROM transactions
|
|
WHERE status='active'
|
|
ORDER BY transaction_datetime DESC, transaction_date DESC, created_at DESC
|
|
LIMIT ?""",
|
|
(row_limit,),
|
|
).fetchall()]
|
|
|
|
matched = []
|
|
uncategorized = []
|
|
for tx in rows:
|
|
if not _rule_matches(rule, tx):
|
|
continue
|
|
item = {
|
|
"transaction_id": tx["id"],
|
|
"transaction_date": tx.get("transaction_date", ""),
|
|
"transaction_datetime": tx.get("transaction_datetime", ""),
|
|
"type": tx.get("type", ""),
|
|
"amount": tx.get("amount"),
|
|
"merchant": tx.get("merchant", ""),
|
|
"raw_summary": tx.get("raw_summary", ""),
|
|
"counterparty": tx.get("counterparty", ""),
|
|
"current_category": tx.get("category", "") or "",
|
|
"current_subcategory": tx.get("subcategory", "") or "",
|
|
"new_category": rule.get("category", ""),
|
|
"new_subcategory": rule.get("subcategory", ""),
|
|
"rule_id": rule_id,
|
|
"rule_pattern": rule.get("pattern", ""),
|
|
}
|
|
matched.append(item)
|
|
if not (tx.get("category") or ""):
|
|
uncategorized.append(item)
|
|
return ok(rule=rule, scanned=len(rows), matched=matched, uncategorized=uncategorized)
|
|
|
|
|
|
def preview_category_rule_matches(rule_id: str, limit: int = 10000, sample_limit: int = 3, db_path: str | None = None) -> dict[str, Any]:
|
|
data = _category_rule_match_rows(rule_id, limit, db_path)
|
|
if not data.get("success"):
|
|
return data
|
|
sample_n = max(1, min(int(sample_limit), 20))
|
|
return ok(
|
|
rule=data["rule"],
|
|
scanned=data["scanned"],
|
|
matched_count=len(data["matched"]),
|
|
uncategorized_matched_count=len(data["uncategorized"]),
|
|
sample=data["matched"][:sample_n],
|
|
)
|
|
|
|
|
|
def apply_category_rule(rule_id: str, limit: int = 10000, db_path: str | None = None) -> dict[str, Any]:
|
|
data = _category_rule_match_rows(rule_id, limit, db_path)
|
|
if not data.get("success"):
|
|
return data
|
|
rule = data["rule"]
|
|
ts = now_iso()
|
|
applied = 0
|
|
with db(db_path) as conn:
|
|
for m in data["uncategorized"]:
|
|
conn.execute(
|
|
"""UPDATE transactions
|
|
SET category=?, subcategory=?,
|
|
person=CASE WHEN ?<>'' THEN ? ELSE person END,
|
|
is_fixed=CASE WHEN ? IS NULL THEN is_fixed ELSE ? END,
|
|
updated_at=?
|
|
WHERE id=? AND status='active' AND COALESCE(category,'')=''""",
|
|
(
|
|
rule.get("category", ""), rule.get("subcategory", ""),
|
|
rule.get("person", ""), rule.get("person", ""),
|
|
rule.get("is_fixed"), rule.get("is_fixed"),
|
|
ts, m["transaction_id"],
|
|
),
|
|
)
|
|
applied += 1
|
|
return ok(
|
|
applied_count=applied,
|
|
matched_count=len(data["matched"]),
|
|
uncategorized_matched_count=len(data["uncategorized"]),
|
|
rule=rule,
|
|
)
|
|
|
|
def apply_categorization_rules(limit: int = 1000, only_uncategorized: bool = True, db_path: str | None = None) -> dict[str, Any]:
|
|
data = _categorization_preview_data(limit, only_uncategorized, db_path)
|
|
preview = ok(
|
|
scanned=len(data["rows"]),
|
|
matched_count=len(data["matches"]),
|
|
unmatched_count=len(data["unmatched"]),
|
|
by_category=data["by_category"],
|
|
matches=data["matches"][:100],
|
|
unmatched=data["unmatched"][:100],
|
|
)
|
|
applied = 0
|
|
ts = now_iso()
|
|
with db(db_path) as conn:
|
|
for m in data["matches"]:
|
|
conn.execute("UPDATE transactions SET category=?, subcategory=?, person=CASE WHEN ?<>'' THEN ? ELSE person END, is_fixed=CASE WHEN ? IS NULL THEN is_fixed ELSE ? END, updated_at=? WHERE id=?", (m["category"], m["subcategory"], m["person"], m["person"], m["is_fixed"], m["is_fixed"], ts, m["transaction_id"]))
|
|
applied += 1
|
|
return ok(applied_count=applied, preview=preview)
|
|
|
|
|
|
def list_uncategorized_transactions(limit: int = 100, min_amount: int = 0, db_path: str | None = None) -> dict[str, Any]:
|
|
with db(db_path) as conn:
|
|
rows=conn.execute("""SELECT * FROM transactions WHERE status='active' AND COALESCE(category,'')='' AND amount>=? ORDER BY amount DESC, transaction_datetime DESC LIMIT ?""", (int(min_amount), max(1,min(int(limit),1000)))).fetchall()
|
|
return ok(transactions=[row_to_dict(r) for r in rows], count=len(rows))
|
|
|
|
|
|
def get_uncategorized_merchant_summary(limit: int = 50, db_path: str | None = None) -> dict[str, Any]:
|
|
with db(db_path) as conn:
|
|
rows=conn.execute("""SELECT merchant, raw_summary, type, COUNT(*) count, SUM(amount) total_amount, MAX(amount) max_amount
|
|
FROM transactions WHERE status='active' AND COALESCE(category,'')=''
|
|
GROUP BY merchant, raw_summary, type ORDER BY total_amount DESC LIMIT ?""", (max(1,min(int(limit),500)),)).fetchall()
|
|
return ok(items=[row_to_dict(r) for r in rows], count=len(rows))
|
|
|
|
|
|
def _recommend_category_for_transaction(tx: dict[str, Any], rules: list[dict[str, Any]], history: dict[tuple[str, str], list[dict[str, Any]]]) -> dict[str, Any]:
|
|
"""Recommend a category for one transaction using deterministic, explainable signals."""
|
|
for rule in rules:
|
|
try:
|
|
if _rule_matches(rule, tx):
|
|
return {
|
|
"category": rule.get("category", ""),
|
|
"subcategory": rule.get("subcategory", ""),
|
|
"confidence": round(float(rule.get("confidence") or 1.0), 2),
|
|
"source": "rule",
|
|
"reason": f"활성 규칙 `{rule.get('pattern', '')}` 매칭",
|
|
"rule_id": rule.get("id", ""),
|
|
}
|
|
except re.error:
|
|
continue
|
|
|
|
key = (tx.get("merchant", "") or "", tx.get("type", "") or "")
|
|
candidates = history.get(key, [])
|
|
if candidates:
|
|
best = candidates[0]
|
|
count = int(best.get("count") or 0)
|
|
confidence = min(0.9, 0.55 + min(count, 5) * 0.07)
|
|
return {
|
|
"category": best.get("category", ""),
|
|
"subcategory": best.get("subcategory", ""),
|
|
"confidence": round(confidence, 2),
|
|
"source": "history",
|
|
"reason": f"같은 사용처의 기존 분류 {count}건",
|
|
"rule_id": "",
|
|
}
|
|
|
|
return {"category": "", "subcategory": "", "confidence": 0.0, "source": "none", "reason": "추천 근거 없음", "rule_id": ""}
|
|
|
|
|
|
def get_uncategorized_workbench(
|
|
limit: int = 100,
|
|
min_amount: int = 0,
|
|
sort_by: str = "amount_desc",
|
|
db_path: str | None = None,
|
|
) -> dict[str, Any]:
|
|
row_limit = max(1, min(int(limit), 1000))
|
|
minimum_amount = int(min_amount)
|
|
order_by_map = {
|
|
"amount_desc": "amount DESC, transaction_datetime DESC, transaction_date DESC, created_at DESC",
|
|
"date_desc": "transaction_datetime DESC, transaction_date DESC, created_at DESC, amount DESC",
|
|
}
|
|
order_by = order_by_map.get(sort_by, order_by_map["amount_desc"])
|
|
with db(db_path) as conn:
|
|
tx_rows = [row_to_dict(r) for r in conn.execute(
|
|
f"""SELECT id, transaction_date, transaction_datetime, type, amount, currency, merchant, raw_summary,
|
|
counterparty, account_id, payment_method, person, is_fixed, source, memo, created_at
|
|
FROM transactions
|
|
WHERE status='active' AND COALESCE(category,'')='' AND amount>=?
|
|
ORDER BY {order_by}
|
|
LIMIT ?""",
|
|
(minimum_amount, row_limit),
|
|
).fetchall()]
|
|
total_row = conn.execute(
|
|
"""SELECT COUNT(*) AS count, COALESCE(SUM(amount),0) AS total_amount,
|
|
COUNT(DISTINCT COALESCE(NULLIF(merchant,''), raw_summary, counterparty, '')) AS merchant_group_count
|
|
FROM transactions
|
|
WHERE status='active' AND COALESCE(category,'')='' AND amount>=?""",
|
|
(minimum_amount,),
|
|
).fetchone()
|
|
history_rows = [row_to_dict(r) for r in conn.execute(
|
|
"""SELECT merchant, type, category, subcategory, COUNT(*) AS count,
|
|
SUM(amount) AS total_amount, MAX(transaction_date) AS latest_date
|
|
FROM transactions
|
|
WHERE status='active' AND COALESCE(category,'')<>'' AND COALESCE(merchant,'')<>''
|
|
GROUP BY merchant, type, category, subcategory
|
|
ORDER BY merchant, type, count DESC, total_amount DESC"""
|
|
).fetchall()]
|
|
rules = [row_to_dict(r) for r in conn.execute(
|
|
"SELECT * FROM category_rules WHERE active=1 ORDER BY priority, id"
|
|
).fetchall()]
|
|
|
|
history: dict[tuple[str, str], list[dict[str, Any]]] = {}
|
|
for row in history_rows:
|
|
history.setdefault((row.get("merchant", "") or "", row.get("type", "") or ""), []).append(row)
|
|
|
|
txs: list[dict[str, Any]] = []
|
|
groups: dict[tuple[str, str, str], dict[str, Any]] = {}
|
|
for tx in tx_rows:
|
|
rec = _recommend_category_for_transaction(tx, rules, history)
|
|
public_tx = {
|
|
**tx,
|
|
"recommendation": rec,
|
|
"recommended_category": rec.get("category", ""),
|
|
"recommended_subcategory": rec.get("subcategory", ""),
|
|
"recommendation_confidence": rec.get("confidence", 0.0),
|
|
"recommendation_reason": rec.get("reason", ""),
|
|
"recommendation_source": rec.get("source", "none"),
|
|
}
|
|
txs.append(public_tx)
|
|
gkey = (tx.get("merchant", "") or "", tx.get("raw_summary", "") or "", tx.get("type", "") or "")
|
|
group = groups.setdefault(gkey, {
|
|
"merchant": gkey[0],
|
|
"raw_summary": gkey[1],
|
|
"type": gkey[2],
|
|
"count": 0,
|
|
"total_amount": 0,
|
|
"max_amount": 0,
|
|
"latest_date": "",
|
|
"sample_transaction_id": tx.get("id", ""),
|
|
"recommendation": rec,
|
|
})
|
|
group["count"] += 1
|
|
group["total_amount"] += int(tx.get("amount") or 0)
|
|
group["max_amount"] = max(int(group["max_amount"] or 0), int(tx.get("amount") or 0))
|
|
date_value = tx.get("transaction_datetime") or tx.get("transaction_date") or ""
|
|
if date_value > (group.get("latest_date") or ""):
|
|
group["latest_date"] = date_value
|
|
if not group["recommendation"].get("category") and rec.get("category"):
|
|
group["recommendation"] = rec
|
|
|
|
group_items = sorted(groups.values(), key=lambda g: (int(g["count"]), int(g["total_amount"])), reverse=True)
|
|
recommendable_count = sum(1 for tx in txs if tx.get("recommended_category"))
|
|
repeated_group_count = sum(1 for g in group_items if int(g.get("count") or 0) >= 2)
|
|
total = row_to_dict(total_row) if total_row else {"count": 0, "total_amount": 0, "merchant_group_count": 0}
|
|
return ok(
|
|
summary={
|
|
"uncategorized_count": int(total.get("count") or 0),
|
|
"uncategorized_total_amount": int(total.get("total_amount") or 0),
|
|
"merchant_group_count": int(total.get("merchant_group_count") or 0),
|
|
"recommendable_count": recommendable_count,
|
|
"repeated_group_count": repeated_group_count,
|
|
},
|
|
groups=group_items,
|
|
transactions=txs,
|
|
count=len(txs),
|
|
)
|
|
|
|
|
|
def apply_uncategorized_recommendation(transaction_id: str, db_path: str | None = None) -> dict[str, Any]:
|
|
if not transaction_id:
|
|
return fail("validation_error", "transaction_id is required")
|
|
with db(db_path) as conn:
|
|
row = conn.execute("SELECT * FROM transactions WHERE id=? AND status='active'", (transaction_id,)).fetchone()
|
|
if not row:
|
|
return fail("not_found", "transaction not found")
|
|
tx = row_to_dict(row)
|
|
if tx.get("category"):
|
|
return fail("validation_error", "transaction is already categorized")
|
|
rules = [row_to_dict(r) for r in conn.execute("SELECT * FROM category_rules WHERE active=1 ORDER BY priority, id").fetchall()]
|
|
history_rows = [row_to_dict(r) for r in conn.execute(
|
|
"""SELECT merchant, type, category, subcategory, COUNT(*) AS count, SUM(amount) AS total_amount
|
|
FROM transactions
|
|
WHERE status='active' AND COALESCE(category,'')<>'' AND COALESCE(merchant,'')<>''
|
|
GROUP BY merchant, type, category, subcategory
|
|
ORDER BY count DESC, total_amount DESC"""
|
|
).fetchall()]
|
|
history: dict[tuple[str, str], list[dict[str, Any]]] = {}
|
|
for h in history_rows:
|
|
history.setdefault((h.get("merchant", "") or "", h.get("type", "") or ""), []).append(h)
|
|
rec = _recommend_category_for_transaction(tx, rules, history)
|
|
if not rec.get("category"):
|
|
return fail("no_recommendation", "추천 분류가 없습니다")
|
|
cur = conn.execute(
|
|
"""UPDATE transactions
|
|
SET category=?, subcategory=?, updated_at=?
|
|
WHERE id=? AND status='active' AND COALESCE(category,'')=''""",
|
|
(rec.get("category", ""), rec.get("subcategory", ""), now_iso(), transaction_id),
|
|
)
|
|
return ok(applied_count=cur.rowcount, transaction_id=transaction_id, recommendation=rec)
|
|
|
|
|
|
def apply_uncategorized_group_recommendation(merchant: str, raw_summary: str = "", transaction_type: str = "", db_path: str | None = None) -> dict[str, Any]:
|
|
with db(db_path) as conn:
|
|
sample = conn.execute(
|
|
"""SELECT * FROM transactions
|
|
WHERE status='active' AND COALESCE(category,'')='' AND merchant=? AND raw_summary=? AND type=?
|
|
ORDER BY amount DESC, transaction_datetime DESC, transaction_date DESC LIMIT 1""",
|
|
(merchant, raw_summary, transaction_type),
|
|
).fetchone()
|
|
if not sample:
|
|
return fail("not_found", "uncategorized group not found")
|
|
tx = row_to_dict(sample)
|
|
rules = [row_to_dict(r) for r in conn.execute("SELECT * FROM category_rules WHERE active=1 ORDER BY priority, id").fetchall()]
|
|
history_rows = [row_to_dict(r) for r in conn.execute(
|
|
"""SELECT merchant, type, category, subcategory, COUNT(*) AS count, SUM(amount) AS total_amount
|
|
FROM transactions
|
|
WHERE status='active' AND COALESCE(category,'')<>'' AND COALESCE(merchant,'')<>''
|
|
GROUP BY merchant, type, category, subcategory
|
|
ORDER BY count DESC, total_amount DESC"""
|
|
).fetchall()]
|
|
history: dict[tuple[str, str], list[dict[str, Any]]] = {}
|
|
for h in history_rows:
|
|
history.setdefault((h.get("merchant", "") or "", h.get("type", "") or ""), []).append(h)
|
|
rec = _recommend_category_for_transaction(tx, rules, history)
|
|
if not rec.get("category"):
|
|
return fail("no_recommendation", "추천 분류가 없습니다")
|
|
cur = conn.execute(
|
|
"""UPDATE transactions
|
|
SET category=?, subcategory=?, updated_at=?
|
|
WHERE status='active' AND COALESCE(category,'')='' AND merchant=? AND raw_summary=? AND type=?""",
|
|
(rec.get("category", ""), rec.get("subcategory", ""), now_iso(), merchant, raw_summary, transaction_type),
|
|
)
|
|
return ok(applied_count=cur.rowcount, merchant=merchant, raw_summary=raw_summary, type=transaction_type, recommendation=rec)
|
|
|
|
|
|
DEFAULT_LEDGER_CATEGORIES: list[dict[str, Any]] = [
|
|
{"category": "수입", "subcategory": "급여", "sort_order": 10},
|
|
{"category": "수입", "subcategory": "이자", "sort_order": 11},
|
|
{"category": "수입", "subcategory": "환급", "sort_order": 12},
|
|
{"category": "수입", "subcategory": "배당", "sort_order": 13},
|
|
{"category": "수입", "subcategory": "기타수입", "sort_order": 19},
|
|
{"category": "이체", "subcategory": "본인계좌이동", "sort_order": 20},
|
|
{"category": "이체", "subcategory": "현금입출금", "sort_order": 21},
|
|
{"category": "이체", "subcategory": "가족송금", "sort_order": 22},
|
|
{"category": "이체", "subcategory": "투자이동", "sort_order": 23},
|
|
{"category": "이체", "subcategory": "확인필요", "sort_order": 29},
|
|
{"category": "식비", "subcategory": "마트", "sort_order": 30},
|
|
{"category": "식비", "subcategory": "외식", "sort_order": 31},
|
|
{"category": "식비", "subcategory": "카페", "sort_order": 32},
|
|
{"category": "식비", "subcategory": "배달", "sort_order": 33},
|
|
{"category": "주거", "subcategory": "관리비", "sort_order": 40},
|
|
{"category": "주거", "subcategory": "전기", "sort_order": 41},
|
|
{"category": "주거", "subcategory": "가스", "sort_order": 42},
|
|
{"category": "주거", "subcategory": "수도", "sort_order": 43},
|
|
{"category": "주거", "subcategory": "인터넷", "sort_order": 44},
|
|
{"category": "통신", "subcategory": "휴대폰", "sort_order": 50},
|
|
{"category": "통신", "subcategory": "인터넷", "sort_order": 51},
|
|
{"category": "통신", "subcategory": "통신요금", "sort_order": 52},
|
|
{"category": "교통", "subcategory": "대중교통", "sort_order": 60},
|
|
{"category": "교통", "subcategory": "택시", "sort_order": 61},
|
|
{"category": "교통", "subcategory": "주유", "sort_order": 62},
|
|
{"category": "교통", "subcategory": "주차", "sort_order": 63},
|
|
{"category": "교통", "subcategory": "차량정비", "sort_order": 64},
|
|
{"category": "교육", "subcategory": "학원", "sort_order": 70},
|
|
{"category": "교육", "subcategory": "교재", "sort_order": 71},
|
|
{"category": "교육", "subcategory": "학교", "sort_order": 72},
|
|
{"category": "교육", "subcategory": "예체능", "sort_order": 73},
|
|
{"category": "의료", "subcategory": "병원", "sort_order": 80},
|
|
{"category": "의료", "subcategory": "약국", "sort_order": 81},
|
|
{"category": "의료", "subcategory": "치과", "sort_order": 82},
|
|
{"category": "의료", "subcategory": "보험", "sort_order": 83},
|
|
{"category": "생활", "subcategory": "생필품", "sort_order": 90},
|
|
{"category": "생활", "subcategory": "의류", "sort_order": 91},
|
|
{"category": "생활", "subcategory": "미용", "sort_order": 92},
|
|
{"category": "생활", "subcategory": "세탁", "sort_order": 93},
|
|
{"category": "문화/여가", "subcategory": "여행", "sort_order": 100},
|
|
{"category": "문화/여가", "subcategory": "숙박", "sort_order": 101},
|
|
{"category": "문화/여가", "subcategory": "공연", "sort_order": 102},
|
|
{"category": "문화/여가", "subcategory": "취미", "sort_order": 103},
|
|
{"category": "문화/여가", "subcategory": "스포츠", "sort_order": 104},
|
|
{"category": "금융", "subcategory": "카드대금", "sort_order": 110},
|
|
{"category": "금융", "subcategory": "대출상환", "sort_order": 111},
|
|
{"category": "금융", "subcategory": "수수료", "sort_order": 112},
|
|
{"category": "금융", "subcategory": "보험료", "sort_order": 113},
|
|
{"category": "가족/용돈", "subcategory": "부모님", "sort_order": 120},
|
|
{"category": "가족/용돈", "subcategory": "자녀", "sort_order": 121},
|
|
{"category": "가족/용돈", "subcategory": "가족송금", "sort_order": 122},
|
|
{"category": "미분류", "subcategory": "확인필요", "sort_order": 999},
|
|
]
|
|
|
|
|
|
def infer_cashflow_kind(category: str, subcategory: str = "") -> str:
|
|
if category == "수입":
|
|
return "income"
|
|
if category == "이체":
|
|
if subcategory == "투자이동":
|
|
return "investment"
|
|
return "transfer"
|
|
if category == "금융":
|
|
if subcategory == "대출상환":
|
|
return "debt_payment"
|
|
if subcategory in {"카드대금", "수수료"}:
|
|
return "exclude"
|
|
return "exclude"
|
|
if category == "미분류":
|
|
return "unknown"
|
|
return "expense"
|
|
|
|
|
|
def create_ledger_category(category: str, subcategory: str = "", sort_order: int = 100, memo: str = "", cashflow_kind: str = "", db_path: str | None = None) -> dict[str, Any]:
|
|
category = (category or "").strip()
|
|
subcategory = (subcategory or "").strip()
|
|
cashflow_kind = (cashflow_kind or infer_cashflow_kind(category, subcategory)).strip()
|
|
if not category:
|
|
return fail("validation_error", "category is required")
|
|
if cashflow_kind not in VALID_CASHFLOW_KINDS:
|
|
return fail("validation_error", f"cashflow_kind must be one of {sorted(VALID_CASHFLOW_KINDS)}")
|
|
ts = now_iso(); category_id = new_id("cat")
|
|
with db(db_path) as conn:
|
|
conn.execute("""INSERT INTO ledger_categories(id,category,subcategory,cashflow_kind,active,sort_order,memo,created_at,updated_at)
|
|
VALUES(?,?,?,?,?,?,?,?,?)
|
|
ON CONFLICT(category, subcategory) DO UPDATE SET active=1, cashflow_kind=excluded.cashflow_kind, sort_order=excluded.sort_order, memo=excluded.memo, updated_at=excluded.updated_at""",
|
|
(category_id, category, subcategory, cashflow_kind, 1, int(sort_order), memo, ts, ts))
|
|
row = conn.execute("SELECT * FROM ledger_categories WHERE category=? AND subcategory=?", (category, subcategory)).fetchone()
|
|
return ok(category=row_to_dict(row))
|
|
|
|
|
|
def seed_default_ledger_categories(overwrite: bool = False, db_path: str | None = None) -> dict[str, Any]:
|
|
if overwrite:
|
|
with db(db_path) as conn:
|
|
conn.execute("UPDATE ledger_categories SET active=0 WHERE memo LIKE 'default:%'")
|
|
created = 0
|
|
for item in DEFAULT_LEDGER_CATEGORIES:
|
|
res = create_ledger_category(memo="default: Jarca initial category", db_path=db_path, **item)
|
|
if res.get("success"):
|
|
created += 1
|
|
return ok(created_or_updated=created)
|
|
|
|
|
|
def list_ledger_categories(active_only: bool = True, include_usage: bool = True, db_path: str | None = None) -> dict[str, Any]:
|
|
clauses = []
|
|
if active_only:
|
|
clauses.append("active=1")
|
|
where = " WHERE " + " AND ".join(clauses) if clauses else ""
|
|
with db(db_path) as conn:
|
|
rows = conn.execute(f"SELECT * FROM ledger_categories{where} ORDER BY sort_order, category, subcategory").fetchall()
|
|
items = [row_to_dict(r) for r in rows]
|
|
if include_usage:
|
|
usage_rows = conn.execute("""SELECT category, subcategory, COUNT(*) AS transaction_count, SUM(amount) AS total_amount
|
|
FROM transactions WHERE status='active'
|
|
GROUP BY category, subcategory""").fetchall()
|
|
usage = {(r["category"], r["subcategory"]): {"transaction_count": int(r["transaction_count"] or 0), "total_amount": int(r["total_amount"] or 0)} for r in usage_rows}
|
|
for item in items:
|
|
item.update(usage.get((item["category"], item["subcategory"]), {"transaction_count": 0, "total_amount": 0}))
|
|
grouped: dict[str, list[str]] = {}
|
|
for item in items:
|
|
grouped.setdefault(item["category"], [])
|
|
if item["subcategory"] and item["subcategory"] not in grouped[item["category"]]:
|
|
grouped[item["category"]].append(item["subcategory"])
|
|
return ok(categories=items, grouped=grouped, count=len(items))
|
|
|
|
|
|
def delete_ledger_category(category: str, subcategory: str = "", hard_delete: bool = False, force: bool = False, db_path: str | None = None) -> dict[str, Any]:
|
|
category = (category or "").strip()
|
|
subcategory = (subcategory or "").strip()
|
|
if not category:
|
|
return fail("validation_error", "category is required")
|
|
with db(db_path) as conn:
|
|
row = conn.execute("SELECT * FROM ledger_categories WHERE category=? AND subcategory=?", (category, subcategory)).fetchone()
|
|
if not row:
|
|
return fail("not_found", "category not found")
|
|
tx_count = conn.execute("SELECT COUNT(*) FROM transactions WHERE status='active' AND category=? AND subcategory=?", (category, subcategory)).fetchone()[0]
|
|
budget_count = conn.execute("SELECT COUNT(*) FROM budgets WHERE category=? AND subcategory=?", (category, subcategory)).fetchone()[0]
|
|
rule_count = conn.execute("SELECT COUNT(*) FROM category_rules WHERE active=1 AND category=? AND subcategory=?", (category, subcategory)).fetchone()[0]
|
|
in_use = int(tx_count or 0) + int(budget_count or 0) + int(rule_count or 0)
|
|
if in_use and hard_delete and not force:
|
|
return fail("category_in_use", "category is referenced; use soft delete or force=true", transaction_count=tx_count, budget_count=budget_count, rule_count=rule_count)
|
|
if hard_delete:
|
|
conn.execute("DELETE FROM ledger_categories WHERE id=?", (row["id"],))
|
|
else:
|
|
conn.execute("UPDATE ledger_categories SET active=0, updated_at=? WHERE id=?", (now_iso(), row["id"]))
|
|
return ok(category=category, subcategory=subcategory, deleted=True, hard_delete=hard_delete, transaction_count=tx_count, budget_count=budget_count, rule_count=rule_count)
|