Initial Jarca Ledger MCP implementation
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
# Optional. Defaults to ~/.local/share/jarca/ledger.sqlite3
|
||||
JARCA_LEDGER_DB_PATH=
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
.venv/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
*.egg-info/
|
||||
*.sqlite3
|
||||
*.sqlite
|
||||
*.db
|
||||
*.db-*
|
||||
*.pdf
|
||||
*.xls
|
||||
*.xlsx
|
||||
*.csv
|
||||
tmp_*
|
||||
.DS_Store
|
||||
@@ -0,0 +1,77 @@
|
||||
# jarca-ledger-mcp
|
||||
|
||||
Jarca 가계부 전용 MCP 서버입니다. 계좌/카드, 수입·지출 거래, 예산, 월간 현금흐름, 고정비, 투자 여력 분석을 SQLite에 저장하고 MCP 도구로 제공합니다.
|
||||
|
||||
## 설치
|
||||
|
||||
```bash
|
||||
cd /root/projects/jarca-ledger-mcp
|
||||
python3.11 -m venv .venv
|
||||
. .venv/bin/activate
|
||||
pip install -e '.[dev,dashboard]'
|
||||
```
|
||||
|
||||
기본 DB 위치는 `~/.local/share/jarca/ledger.sqlite3`입니다. 바꾸려면 `.env`에 설정하세요.
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# JARCA_LEDGER_DB_PATH=/원하는/경로/ledger.sqlite3
|
||||
```
|
||||
|
||||
## Hermes 등록
|
||||
|
||||
```bash
|
||||
hermes mcp add jarca-ledger --command /root/projects/jarca-ledger-mcp/.venv/bin/jarca-ledger-mcp
|
||||
hermes mcp test jarca-ledger
|
||||
```
|
||||
|
||||
게이트웨이/현재 세션에는 `/reload-mcp` 또는 재시작 후 반영됩니다.
|
||||
|
||||
## Streamlit Dashboard
|
||||
|
||||
로컬 웹 대시보드는 실제 기본 DB `~/.local/share/jarca/ledger.sqlite3`를 읽고 씁니다.
|
||||
|
||||
```bash
|
||||
cd /root/projects/jarca-ledger-mcp
|
||||
. .venv/bin/activate
|
||||
streamlit run dashboard/app.py --server.address 0.0.0.0 --server.port 8501
|
||||
```
|
||||
|
||||
주요 화면:
|
||||
|
||||
- 요약: 실제 수입/지출과 계좌 입출금을 분리 표시, 월별 현금흐름, 지출 카테고리 차트
|
||||
- 거래내역: 검색/필터 및 개별 거래 카테고리 수정
|
||||
- 미분류: 미분류 거래 요약, 거래별 분류, 규칙 저장
|
||||
- 분류 규칙: 규칙 seed/preview/apply, 사용자 규칙 생성
|
||||
- 카테고리: 대/소분류 추가, 비활성화/삭제, 사용 건수 확인
|
||||
- 계좌: 등록 계좌/잔액 확인
|
||||
- Import: PDF/CSV/API import batch 조회 및 rollback
|
||||
|
||||
## 주요 도구
|
||||
|
||||
- `ledger_health_check`
|
||||
- `create_ledger_account`, `list_ledger_accounts`
|
||||
- `create_ledger_transaction`, `list_ledger_transactions`, `update_ledger_transaction`, `delete_ledger_transaction`
|
||||
- `create_ledger_budget`, `list_ledger_budgets`
|
||||
- `get_monthly_cashflow_report`
|
||||
- `get_category_spending_report`
|
||||
- `get_fixed_cost_report`
|
||||
- `get_budget_vs_actual_report`
|
||||
- `get_investable_cashflow_report`
|
||||
- `preview_ledger_csv_import`
|
||||
- `preview_kb_bank_pdf_import`, `import_kb_bank_pdf`
|
||||
- `list_ledger_import_batches`, `rollback_ledger_import_batch`
|
||||
- `create_category_rule`, `seed_default_category_rules`, `list_category_rules`
|
||||
- `preview_categorization`, `apply_categorization_rules`
|
||||
- `list_uncategorized_transactions`, `get_uncategorized_merchant_summary`
|
||||
- `create_ledger_category`, `seed_default_ledger_categories`, `list_ledger_categories`, `delete_ledger_category`
|
||||
|
||||
## 설계 원칙
|
||||
|
||||
- 금액은 원화 정수 원 단위로 저장합니다.
|
||||
- 거래 금액은 양수로 넣고, `type`이 `income`, `expense`, `transfer`, `refund`, `adjustment` 중 하나로 방향을 나타냅니다.
|
||||
- 금융 데이터이므로 Jarca 본체 MCP와 분리된 DB/서버로 운영합니다.
|
||||
- 국민은행 PDF import는 암호화 PDF 비밀번호를 입력받아 PyMuPDF로 표를 추출하고, `transaction_datetime`, `counterparty`, `balance_after`, `branch`, `raw_summary`, `raw_payload`에 원본 세부 정보를 보존합니다.
|
||||
- CSV/PDF/API import는 `import_batches`에 묶음 이력을 남기고, 잘못 가져온 batch는 `rollback_ledger_import_batch`로 되돌릴 수 있습니다.
|
||||
- 카테고리에는 `cashflow_kind`를 저장해 실제 가계부 수입/지출(`income`, `expense`)과 계좌이체/투자이동/분석제외(`transfer`, `investment`, `exclude`, `debt_payment`, `unknown`)를 분리합니다.
|
||||
- CSV import는 먼저 preview 후 저장하는 흐름으로 확장합니다.
|
||||
+1040
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
"""Jarca Ledger MCP server."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,247 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
DEFAULT_DB_PATH = Path.home() / ".local" / "share" / "jarca" / "ledger.sqlite3"
|
||||
SCHEMA_VERSION = "6"
|
||||
|
||||
SCHEMA_SQL = """
|
||||
PRAGMA journal_mode=WAL;
|
||||
PRAGMA foreign_keys=ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schema_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'bank',
|
||||
institution TEXT NOT NULL DEFAULT '',
|
||||
currency TEXT NOT NULL DEFAULT 'KRW',
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
memo TEXT NOT NULL DEFAULT '',
|
||||
account_number_masked TEXT NOT NULL DEFAULT '',
|
||||
holder_name TEXT NOT NULL DEFAULT '',
|
||||
last_balance INTEGER,
|
||||
last_synced_at TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS transactions (
|
||||
id TEXT PRIMARY KEY,
|
||||
transaction_date TEXT NOT NULL,
|
||||
posted_date TEXT NOT NULL DEFAULT '',
|
||||
type TEXT NOT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
currency TEXT NOT NULL DEFAULT 'KRW',
|
||||
category TEXT NOT NULL DEFAULT '',
|
||||
subcategory TEXT NOT NULL DEFAULT '',
|
||||
merchant TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
account_id TEXT NOT NULL DEFAULT '',
|
||||
payment_method TEXT NOT NULL DEFAULT '',
|
||||
person TEXT NOT NULL DEFAULT '',
|
||||
project TEXT NOT NULL DEFAULT '',
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
is_fixed INTEGER NOT NULL DEFAULT 0,
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
import_batch_id TEXT NOT NULL DEFAULT '',
|
||||
external_id TEXT NOT NULL DEFAULT '',
|
||||
dedupe_key TEXT NOT NULL DEFAULT '',
|
||||
memo TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
transaction_datetime TEXT NOT NULL DEFAULT '',
|
||||
counterparty TEXT NOT NULL DEFAULT '',
|
||||
balance_after INTEGER,
|
||||
branch TEXT NOT NULL DEFAULT '',
|
||||
raw_summary TEXT NOT NULL DEFAULT '',
|
||||
raw_payload TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(transaction_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_category ON transactions(category, subcategory);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_person ON transactions(person);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_account ON transactions(account_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_transactions_dedupe ON transactions(dedupe_key) WHERE dedupe_key <> '';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS budgets (
|
||||
id TEXT PRIMARY KEY,
|
||||
month TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
subcategory TEXT NOT NULL DEFAULT '',
|
||||
person TEXT NOT NULL DEFAULT '',
|
||||
amount INTEGER NOT NULL,
|
||||
currency TEXT NOT NULL DEFAULT 'KRW',
|
||||
memo TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_budgets_month ON budgets(month);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_budgets_unique ON budgets(month, category, subcategory, person);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS import_batches (
|
||||
id TEXT PRIMARY KEY,
|
||||
source TEXT NOT NULL,
|
||||
filename TEXT NOT NULL DEFAULT '',
|
||||
imported_at TEXT NOT NULL,
|
||||
row_count INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
memo TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS category_rules (
|
||||
id TEXT PRIMARY KEY,
|
||||
priority INTEGER NOT NULL DEFAULT 100,
|
||||
match_field TEXT NOT NULL,
|
||||
match_type TEXT NOT NULL DEFAULT 'contains',
|
||||
pattern TEXT NOT NULL,
|
||||
amount_min INTEGER,
|
||||
amount_max INTEGER,
|
||||
day_of_month INTEGER,
|
||||
transaction_type TEXT NOT NULL DEFAULT '',
|
||||
category TEXT NOT NULL,
|
||||
subcategory TEXT NOT NULL DEFAULT '',
|
||||
person TEXT NOT NULL DEFAULT '',
|
||||
is_fixed INTEGER,
|
||||
confidence REAL NOT NULL DEFAULT 1.0,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
memo TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_category_rules_active_priority ON category_rules(active, priority);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_category_rules_unique ON category_rules(match_field, match_type, pattern, transaction_type, category, subcategory, person);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ledger_categories (
|
||||
id TEXT PRIMARY KEY,
|
||||
category TEXT NOT NULL,
|
||||
subcategory TEXT NOT NULL DEFAULT '',
|
||||
cashflow_kind TEXT NOT NULL DEFAULT 'expense',
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
sort_order INTEGER NOT NULL DEFAULT 100,
|
||||
memo TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ledger_categories_active_sort ON ledger_categories(active, sort_order, category, subcategory);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_categories_unique ON ledger_categories(category, subcategory);
|
||||
|
||||
INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', '6');
|
||||
"""
|
||||
|
||||
ACCOUNT_ADDITIVE_COLUMNS: dict[str, str] = {
|
||||
"account_number_masked": "TEXT NOT NULL DEFAULT ''",
|
||||
"holder_name": "TEXT NOT NULL DEFAULT ''",
|
||||
"last_balance": "INTEGER",
|
||||
"last_synced_at": "TEXT NOT NULL DEFAULT ''",
|
||||
}
|
||||
|
||||
TRANSACTION_ADDITIVE_COLUMNS: dict[str, str] = {
|
||||
"transaction_datetime": "TEXT NOT NULL DEFAULT ''",
|
||||
"counterparty": "TEXT NOT NULL DEFAULT ''",
|
||||
"balance_after": "INTEGER",
|
||||
"branch": "TEXT NOT NULL DEFAULT ''",
|
||||
"raw_summary": "TEXT NOT NULL DEFAULT ''",
|
||||
"raw_payload": "TEXT NOT NULL DEFAULT ''",
|
||||
}
|
||||
|
||||
|
||||
def resolve_db_path(db_path: str | None = None) -> Path:
|
||||
raw = db_path or os.getenv("JARCA_LEDGER_DB_PATH") or str(DEFAULT_DB_PATH)
|
||||
return Path(raw).expanduser().resolve()
|
||||
|
||||
|
||||
def connect(db_path: str | None = None) -> sqlite3.Connection:
|
||||
path = resolve_db_path(db_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
return conn
|
||||
|
||||
|
||||
def _existing_columns(conn: sqlite3.Connection, table: str) -> set[str]:
|
||||
return {row["name"] for row in conn.execute(f"PRAGMA table_info({table})")}
|
||||
|
||||
|
||||
def _add_missing_columns(conn: sqlite3.Connection, table: str, columns: dict[str, str]) -> None:
|
||||
existing = _existing_columns(conn, table)
|
||||
for name, definition in columns.items():
|
||||
if name not in existing:
|
||||
conn.execute(f"ALTER TABLE {table} ADD COLUMN {name} {definition}")
|
||||
|
||||
|
||||
def migrate_db(conn: sqlite3.Connection) -> None:
|
||||
"""Apply additive, idempotent migrations only."""
|
||||
_add_missing_columns(conn, "accounts", ACCOUNT_ADDITIVE_COLUMNS)
|
||||
_add_missing_columns(conn, "transactions", TRANSACTION_ADDITIVE_COLUMNS)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_transactions_datetime ON transactions(transaction_datetime)")
|
||||
conn.execute("""CREATE TABLE IF NOT EXISTS category_rules (
|
||||
id TEXT PRIMARY KEY,
|
||||
priority INTEGER NOT NULL DEFAULT 100,
|
||||
match_field TEXT NOT NULL,
|
||||
match_type TEXT NOT NULL DEFAULT 'contains',
|
||||
pattern TEXT NOT NULL,
|
||||
amount_min INTEGER,
|
||||
amount_max INTEGER,
|
||||
day_of_month INTEGER,
|
||||
transaction_type TEXT NOT NULL DEFAULT '',
|
||||
category TEXT NOT NULL,
|
||||
subcategory TEXT NOT NULL DEFAULT '',
|
||||
person TEXT NOT NULL DEFAULT '',
|
||||
is_fixed INTEGER,
|
||||
confidence REAL NOT NULL DEFAULT 1.0,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
memo TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)""")
|
||||
_add_missing_columns(conn, "category_rules", {"amount_min": "INTEGER", "amount_max": "INTEGER", "day_of_month": "INTEGER"})
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_category_rules_active_priority ON category_rules(active, priority)")
|
||||
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_category_rules_unique ON category_rules(match_field, match_type, pattern, transaction_type, category, subcategory, person)")
|
||||
conn.execute("""CREATE TABLE IF NOT EXISTS ledger_categories (
|
||||
id TEXT PRIMARY KEY,
|
||||
category TEXT NOT NULL,
|
||||
subcategory TEXT NOT NULL DEFAULT '',
|
||||
cashflow_kind TEXT NOT NULL DEFAULT 'expense',
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
sort_order INTEGER NOT NULL DEFAULT 100,
|
||||
memo TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)""")
|
||||
_add_missing_columns(conn, "ledger_categories", {"cashflow_kind": "TEXT NOT NULL DEFAULT 'expense'"})
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_ledger_categories_active_sort ON ledger_categories(active, sort_order, category, subcategory)")
|
||||
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_categories_unique ON ledger_categories(category, subcategory)")
|
||||
conn.execute("INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', ?)", (SCHEMA_VERSION,))
|
||||
|
||||
|
||||
def init_db(db_path: str | None = None) -> str:
|
||||
path = resolve_db_path(db_path)
|
||||
with connect(str(path)) as conn:
|
||||
conn.executescript(SCHEMA_SQL)
|
||||
migrate_db(conn)
|
||||
return str(path)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def db(db_path: str | None = None) -> Iterator[sqlite3.Connection]:
|
||||
init_db(db_path)
|
||||
conn = connect(db_path)
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,250 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import fitz # PyMuPDF
|
||||
except ImportError: # pragma: no cover - exercised as runtime config error
|
||||
fitz = None # type: ignore[assignment]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KbPdfTransaction:
|
||||
page: int
|
||||
transaction_datetime: str
|
||||
transaction_date: str
|
||||
raw_summary: str
|
||||
counterparty: str
|
||||
withdrawal: int
|
||||
deposit: int
|
||||
balance_after: int
|
||||
remittance_memo: str
|
||||
branch: str
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
if self.deposit > 0 and self.withdrawal == 0:
|
||||
return "income"
|
||||
if self.withdrawal > 0 and self.deposit == 0:
|
||||
return "expense"
|
||||
return "adjustment"
|
||||
|
||||
@property
|
||||
def amount(self) -> int:
|
||||
return self.deposit or self.withdrawal
|
||||
|
||||
def external_id(self, source_hash: str) -> str:
|
||||
raw = "|".join(
|
||||
[
|
||||
"kb_bank_pdf",
|
||||
source_hash[:16],
|
||||
self.transaction_datetime,
|
||||
self.raw_summary,
|
||||
self.counterparty,
|
||||
str(self.withdrawal),
|
||||
str(self.deposit),
|
||||
str(self.balance_after),
|
||||
]
|
||||
)
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
def raw_payload(self) -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
"page": self.page,
|
||||
"transaction_datetime": self.transaction_datetime,
|
||||
"raw_summary": self.raw_summary,
|
||||
"counterparty": self.counterparty,
|
||||
"withdrawal": self.withdrawal,
|
||||
"deposit": self.deposit,
|
||||
"balance_after": self.balance_after,
|
||||
"remittance_memo": self.remittance_memo,
|
||||
"branch": self.branch,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
|
||||
|
||||
def _parse_int(value: str) -> int:
|
||||
value = value.replace(",", "").replace("원", "").strip()
|
||||
return int(value) if value and re.fullmatch(r"-?\d+", value) else 0
|
||||
|
||||
|
||||
def _mask_account_number(account_number: str) -> str:
|
||||
digits = re.sub(r"\D", "", account_number)
|
||||
if len(digits) <= 4:
|
||||
return f"****{digits}" if digits else ""
|
||||
return f"****{digits[-4:]}"
|
||||
|
||||
|
||||
def _extract_metadata(first_page_text: str) -> dict[str, Any]:
|
||||
lines = [line.strip() for line in first_page_text.splitlines() if line.strip()]
|
||||
|
||||
def after_label(label: str) -> str:
|
||||
try:
|
||||
return lines[lines.index(label) + 1]
|
||||
except (ValueError, IndexError):
|
||||
return ""
|
||||
|
||||
account_number = after_label("계좌번호")
|
||||
query_period = ""
|
||||
for line in lines:
|
||||
if re.fullmatch(r"\d{4}\.\d{2}\.\d{2}\s+~\s+\d{4}\.\d{2}\.\d{2}", line):
|
||||
query_period = line
|
||||
break
|
||||
query_generated_at = ""
|
||||
for line in lines:
|
||||
if line.startswith("조회기준일시:"):
|
||||
query_generated_at = line.replace("조회기준일시:", "").strip()
|
||||
break
|
||||
return {
|
||||
"account_name": after_label("계좌명"),
|
||||
"account_number_masked": _mask_account_number(account_number),
|
||||
"holder_name": after_label("예금주"),
|
||||
"final_balance": _parse_int(after_label("총 잔액")),
|
||||
"withdrawable_amount": _parse_int(after_label("출금가능금액")),
|
||||
"header_total_deposit": _parse_int(after_label("총 입금금액")),
|
||||
"header_total_withdrawal": _parse_int(after_label("총 출금금액")),
|
||||
"query_generated_at": query_generated_at,
|
||||
"query_period": query_period,
|
||||
}
|
||||
|
||||
|
||||
def _open_pdf(path: Path, password: str) -> Any:
|
||||
if fitz is None:
|
||||
raise RuntimeError("PyMuPDF is required. Install pymupdf to parse KB Bank PDFs.")
|
||||
doc = fitz.open(path)
|
||||
if doc.needs_pass and not doc.authenticate(password):
|
||||
raise ValueError("PDF password is required or incorrect")
|
||||
return doc
|
||||
|
||||
|
||||
def parse_kb_bank_pdf(file_path: str, password: str) -> dict[str, Any]:
|
||||
path = Path(file_path).expanduser().resolve()
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"PDF file not found: {path}")
|
||||
source_hash = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
doc = _open_pdf(path, password)
|
||||
metadata = _extract_metadata(doc[0].get_text("text") if doc.page_count else "")
|
||||
columns = [
|
||||
("datetime", 0, 115),
|
||||
("summary", 115, 165),
|
||||
("counterparty", 165, 255),
|
||||
("withdrawal", 255, 315),
|
||||
("deposit", 315, 375),
|
||||
("balance", 375, 450),
|
||||
("memo", 450, 520),
|
||||
("branch", 520, 999),
|
||||
]
|
||||
transactions: list[KbPdfTransaction] = []
|
||||
for page_index, page in enumerate(doc, start=1):
|
||||
lines: dict[int, list[tuple[float, str]]] = {}
|
||||
for x0, y0, _x1, _y1, word, _block, _line, _wordno in page.get_text("words"):
|
||||
# Header rows live above 110. Footer/help text lives below roughly 735.
|
||||
if y0 < 110 or y0 > 735:
|
||||
continue
|
||||
key = round(y0 / 3) * 3
|
||||
lines.setdefault(key, []).append((x0, word))
|
||||
for y in sorted(lines):
|
||||
cells: dict[str, list[str]] = {name: [] for name, _start, _end in columns}
|
||||
for x, word in sorted(lines[y]):
|
||||
for name, start, end in columns:
|
||||
if start <= x < end:
|
||||
cells[name].append(word)
|
||||
break
|
||||
values = {key: " ".join(parts).strip() for key, parts in cells.items()}
|
||||
raw_datetime = values["datetime"]
|
||||
if not re.fullmatch(r"20\d{2}\.\d{2}\.\d{2}\s+\d{2}:\d{2}:\d{2}", raw_datetime):
|
||||
continue
|
||||
dt = datetime.strptime(raw_datetime, "%Y.%m.%d %H:%M:%S")
|
||||
transactions.append(
|
||||
KbPdfTransaction(
|
||||
page=page_index,
|
||||
transaction_datetime=dt.isoformat(),
|
||||
transaction_date=dt.date().isoformat(),
|
||||
raw_summary=values["summary"],
|
||||
counterparty=values["counterparty"],
|
||||
withdrawal=_parse_int(values["withdrawal"]),
|
||||
deposit=_parse_int(values["deposit"]),
|
||||
balance_after=_parse_int(values["balance"]),
|
||||
remittance_memo=values["memo"],
|
||||
branch=values["branch"],
|
||||
)
|
||||
)
|
||||
validation = validate_balance_flow(transactions)
|
||||
return {
|
||||
"file_path": str(path),
|
||||
"source_hash": source_hash,
|
||||
"page_count": doc.page_count,
|
||||
"metadata": metadata,
|
||||
"transactions": transactions,
|
||||
"validation": validation,
|
||||
}
|
||||
|
||||
|
||||
def validate_balance_flow(transactions: list[KbPdfTransaction]) -> dict[str, Any]:
|
||||
if not transactions:
|
||||
return {
|
||||
"ok": True,
|
||||
"row_count": 0,
|
||||
"opening_balance": None,
|
||||
"final_balance": None,
|
||||
"total_deposit": 0,
|
||||
"total_withdrawal": 0,
|
||||
"net_change": 0,
|
||||
"errors": [],
|
||||
}
|
||||
opening_balance = transactions[0].balance_after + transactions[0].withdrawal - transactions[0].deposit
|
||||
previous_balance = opening_balance
|
||||
errors = []
|
||||
total_deposit = 0
|
||||
total_withdrawal = 0
|
||||
for index, tx in enumerate(transactions, start=1):
|
||||
expected = previous_balance - tx.withdrawal + tx.deposit
|
||||
if expected != tx.balance_after:
|
||||
errors.append(
|
||||
{
|
||||
"row_number": index,
|
||||
"transaction_datetime": tx.transaction_datetime,
|
||||
"expected_balance": expected,
|
||||
"actual_balance": tx.balance_after,
|
||||
"difference": tx.balance_after - expected,
|
||||
}
|
||||
)
|
||||
previous_balance = tx.balance_after
|
||||
total_deposit += tx.deposit
|
||||
total_withdrawal += tx.withdrawal
|
||||
final_balance = transactions[-1].balance_after
|
||||
return {
|
||||
"ok": not errors,
|
||||
"row_count": len(transactions),
|
||||
"opening_balance": opening_balance,
|
||||
"final_balance": final_balance,
|
||||
"total_deposit": total_deposit,
|
||||
"total_withdrawal": total_withdrawal,
|
||||
"net_change": total_deposit - total_withdrawal,
|
||||
"balance_delta": final_balance - opening_balance,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
def transaction_preview(tx: KbPdfTransaction, source_hash: str) -> dict[str, Any]:
|
||||
return {
|
||||
"transaction_datetime": tx.transaction_datetime,
|
||||
"transaction_date": tx.transaction_date,
|
||||
"type": tx.type,
|
||||
"amount": tx.amount,
|
||||
"merchant": tx.counterparty,
|
||||
"payment_method": tx.raw_summary,
|
||||
"balance_after": tx.balance_after,
|
||||
"branch": tx.branch,
|
||||
"memo": tx.remittance_memo,
|
||||
"external_id": tx.external_id(source_hash),
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SeoulPayTransaction:
|
||||
row_index: int
|
||||
channel: str
|
||||
transaction_no: str
|
||||
transaction_datetime: str
|
||||
transaction_date: str
|
||||
merchant: str
|
||||
business_no: str
|
||||
merchant_id: str
|
||||
transaction_kind: str
|
||||
payment_kind: str
|
||||
amount: int
|
||||
supply_amount: int
|
||||
service_fee: int
|
||||
vat: int
|
||||
tax_status: str
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
text = f"{self.transaction_kind} {self.payment_kind}"
|
||||
if any(keyword in text for keyword in ("취소", "환불", "승인취소")):
|
||||
return "refund"
|
||||
return "expense"
|
||||
|
||||
def external_id(self, source_hash: str) -> str:
|
||||
base = self.transaction_no or f"{self.transaction_datetime}:{self.merchant}:{self.amount}:{self.row_index}"
|
||||
return f"seoulpay_xlsx:{source_hash}:{base}"
|
||||
|
||||
def raw_payload(self) -> str:
|
||||
import json
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"row_index": self.row_index,
|
||||
"channel": self.channel,
|
||||
"transaction_no": self.transaction_no,
|
||||
"transaction_datetime": self.transaction_datetime,
|
||||
"merchant": self.merchant,
|
||||
"business_no": self.business_no,
|
||||
"merchant_id": self.merchant_id,
|
||||
"transaction_kind": self.transaction_kind,
|
||||
"payment_kind": self.payment_kind,
|
||||
"amount": self.amount,
|
||||
"supply_amount": self.supply_amount,
|
||||
"service_fee": self.service_fee,
|
||||
"vat": self.vat,
|
||||
"tax_status": self.tax_status,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
def _cell_text(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _amount(value: Any) -> int:
|
||||
if value is None or value == "":
|
||||
return 0
|
||||
if isinstance(value, (int, float)):
|
||||
return int(round(float(value)))
|
||||
text = str(value).replace(",", "").strip()
|
||||
if not text:
|
||||
return 0
|
||||
return int(round(float(text)))
|
||||
|
||||
|
||||
def _datetime_text(value: Any) -> tuple[str, str]:
|
||||
if isinstance(value, datetime):
|
||||
dt = value.replace(microsecond=0)
|
||||
return dt.isoformat(sep=" "), dt.date().isoformat()
|
||||
text = _cell_text(value)
|
||||
if not text:
|
||||
raise ValueError("결제일 is required")
|
||||
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y.%m.%d %H:%M:%S", "%Y/%m/%d %H:%M:%S"):
|
||||
try:
|
||||
dt = datetime.strptime(text, fmt)
|
||||
return dt.isoformat(sep=" "), dt.date().isoformat()
|
||||
except ValueError:
|
||||
pass
|
||||
# Excel sometimes returns an ISO-ish string; keep date if it is already first 10 chars.
|
||||
if len(text) >= 10:
|
||||
return text, text[:10].replace(".", "-").replace("/", "-")
|
||||
raise ValueError(f"unsupported 결제일 format: {text}")
|
||||
|
||||
|
||||
def _load_workbook(file_path: str, password: str = ""):
|
||||
try:
|
||||
import openpyxl
|
||||
except ImportError as e: # pragma: no cover - environment guard
|
||||
raise RuntimeError("openpyxl is required to parse SeoulPay XLSX files") from e
|
||||
|
||||
path = Path(file_path)
|
||||
data = path.read_bytes()
|
||||
try:
|
||||
return openpyxl.load_workbook(io.BytesIO(data), data_only=True)
|
||||
except Exception as plain_error:
|
||||
if not password:
|
||||
raise ValueError("서울페이 XLSX 파일이 암호화되어 있습니다. password가 필요합니다.") from plain_error
|
||||
try:
|
||||
import msoffcrypto
|
||||
except ImportError as e: # pragma: no cover - environment guard
|
||||
raise RuntimeError("msoffcrypto-tool is required to decrypt password-protected SeoulPay files") from e
|
||||
try:
|
||||
office = msoffcrypto.OfficeFile(io.BytesIO(data))
|
||||
office.load_key(password=password)
|
||||
decrypted = io.BytesIO()
|
||||
office.decrypt(decrypted)
|
||||
decrypted.seek(0)
|
||||
return openpyxl.load_workbook(decrypted, data_only=True)
|
||||
except Exception as e:
|
||||
raise ValueError(f"서울페이 XLSX 복호화/파싱 실패: {e}") from e
|
||||
|
||||
|
||||
def parse_seoulpay_xlsx(file_path: str, password: str = "") -> dict[str, Any]:
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(str(path))
|
||||
source_hash = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
wb = _load_workbook(str(path), password=password)
|
||||
ws = wb[wb.sheetnames[0]]
|
||||
|
||||
metadata: dict[str, Any] = {"source": "seoulpay_xlsx"}
|
||||
header_row = None
|
||||
headers: list[str] = []
|
||||
for idx, row in enumerate(ws.iter_rows(values_only=True), start=1):
|
||||
first = _cell_text(row[0] if row else None)
|
||||
second = _cell_text(row[1] if row and len(row) > 1 else None)
|
||||
if first == "성명":
|
||||
metadata["holder_name"] = second
|
||||
elif first == "생년월일":
|
||||
metadata["birth_date_masked"] = "********" if second else ""
|
||||
elif first == "조회기간":
|
||||
metadata["period"] = second
|
||||
if "거래번호" in [_cell_text(v) for v in row] and "결제일" in [_cell_text(v) for v in row]:
|
||||
header_row = idx
|
||||
headers = [_cell_text(v) for v in row]
|
||||
break
|
||||
if header_row is None:
|
||||
raise ValueError("서울페이 이용내역 헤더를 찾을 수 없습니다")
|
||||
|
||||
index = {name: i for i, name in enumerate(headers) if name}
|
||||
required = ["결제채널", "거래번호", "결제일", "가맹점", "거래유형", "결제구분", "거래금액"]
|
||||
missing = [name for name in required if name not in index]
|
||||
if missing:
|
||||
raise ValueError(f"서울페이 필수 컬럼 누락: {', '.join(missing)}")
|
||||
|
||||
transactions: list[SeoulPayTransaction] = []
|
||||
errors: list[str] = []
|
||||
for row_index in range(header_row + 1, ws.max_row + 1):
|
||||
values = [cell.value for cell in ws[row_index]]
|
||||
if not any(v not in (None, "") for v in values):
|
||||
continue
|
||||
try:
|
||||
tx_dt, tx_date = _datetime_text(values[index["결제일"]])
|
||||
tx = SeoulPayTransaction(
|
||||
row_index=row_index,
|
||||
channel=_cell_text(values[index["결제채널"]]),
|
||||
transaction_no=_cell_text(values[index["거래번호"]]),
|
||||
transaction_datetime=tx_dt,
|
||||
transaction_date=tx_date,
|
||||
merchant=_cell_text(values[index["가맹점"]]),
|
||||
business_no=_cell_text(values[index.get("사업자번호", -1)]) if "사업자번호" in index else "",
|
||||
merchant_id=_cell_text(values[index.get("가맹점ID", -1)]) if "가맹점ID" in index else "",
|
||||
transaction_kind=_cell_text(values[index["거래유형"]]),
|
||||
payment_kind=_cell_text(values[index["결제구분"]]),
|
||||
amount=_amount(values[index["거래금액"]]),
|
||||
supply_amount=_amount(values[index.get("공급가액", -1)]) if "공급가액" in index else 0,
|
||||
service_fee=_amount(values[index.get("봉사료", -1)]) if "봉사료" in index else 0,
|
||||
vat=_amount(values[index.get("부가세", -1)]) if "부가세" in index else 0,
|
||||
tax_status=_cell_text(values[index.get("과세여부", -1)]) if "과세여부" in index else "",
|
||||
)
|
||||
if tx.amount <= 0:
|
||||
raise ValueError("거래금액이 0 이하입니다")
|
||||
transactions.append(tx)
|
||||
except Exception as e:
|
||||
errors.append(f"row {row_index}: {e}")
|
||||
|
||||
return {
|
||||
"file_path": str(path),
|
||||
"source_hash": source_hash,
|
||||
"sheet_name": ws.title,
|
||||
"metadata": metadata,
|
||||
"transactions": transactions,
|
||||
"validation": {"ok": bool(transactions) and not errors, "parsed_rows": len(transactions), "errors": errors},
|
||||
}
|
||||
|
||||
|
||||
def transaction_preview(tx: SeoulPayTransaction, source_hash: str) -> dict[str, Any]:
|
||||
return {
|
||||
"transaction_datetime": tx.transaction_datetime,
|
||||
"transaction_date": tx.transaction_date,
|
||||
"type": tx.type,
|
||||
"channel": tx.channel,
|
||||
"transaction_no": tx.transaction_no,
|
||||
"merchant": tx.merchant,
|
||||
"transaction_kind": tx.transaction_kind,
|
||||
"payment_kind": tx.payment_kind,
|
||||
"amount": tx.amount,
|
||||
"tax_status": tx.tax_status,
|
||||
"external_id": tx.external_id(source_hash),
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from . import service
|
||||
|
||||
load_dotenv(Path(__file__).resolve().parent.parent / ".env")
|
||||
mcp = FastMCP("jarca-ledger-mcp")
|
||||
|
||||
@mcp.tool()
|
||||
def ledger_health_check() -> dict:
|
||||
"""Verify the Jarca Ledger MCP server and SQLite database are reachable."""
|
||||
return service.health_check()
|
||||
|
||||
@mcp.tool()
|
||||
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 = "") -> dict:
|
||||
"""Create an account/card/cash/investment/loan ledger account."""
|
||||
return service.create_ledger_account(name, type, institution, currency, active, memo, account_number_masked, holder_name, last_balance, last_synced_at)
|
||||
|
||||
@mcp.tool()
|
||||
def list_ledger_accounts(active_only: bool = True) -> dict:
|
||||
"""List ledger accounts and cards."""
|
||||
return service.list_ledger_accounts(active_only)
|
||||
|
||||
@mcp.tool()
|
||||
def create_ledger_transaction(transaction_date: str = "", type: str = "expense", amount: int = 0, category: str = "", subcategory: str = "", merchant: str = "", description: str = "", account_id: str = "", payment_method: str = "", person: str = "", project: str = "", tags: list[str] | str | None = None, is_fixed: bool = False, currency: str = "KRW", posted_date: str = "", source: str = "manual", external_id: str = "", memo: str = "", dedupe: bool = True) -> dict:
|
||||
"""Create an income, expense, transfer, refund, or adjustment transaction. Amount is positive KRW minor unit; type determines direction."""
|
||||
return service.create_ledger_transaction(transaction_date, type, amount, category, subcategory, merchant, description, account_id, payment_method, person, project, tags, is_fixed, currency, posted_date, source, external_id, memo, dedupe)
|
||||
|
||||
@mcp.tool()
|
||||
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) -> dict:
|
||||
"""List ledger transactions with date/category/person/account filters."""
|
||||
return service.list_ledger_transactions(start_date, end_date, type, category, person, account_id, include_deleted, limit)
|
||||
|
||||
@mcp.tool()
|
||||
def update_ledger_transaction(transaction_id: str, transaction_date: str | None = None, type: str | None = None, amount: int | None = None, category: str | None = None, subcategory: str | None = None, merchant: str | None = None, description: str | None = None, account_id: str | None = None, payment_method: str | None = None, person: str | None = None, project: str | None = None, tags: list[str] | str | None = None, is_fixed: bool | None = None, memo: str | None = None, status: str | None = None) -> dict:
|
||||
"""Update selected fields on a ledger transaction."""
|
||||
return service.update_ledger_transaction(transaction_id, transaction_date=transaction_date, type=type, amount=amount, category=category, subcategory=subcategory, merchant=merchant, description=description, account_id=account_id, payment_method=payment_method, person=person, project=project, tags=tags, is_fixed=is_fixed, memo=memo, status=status)
|
||||
|
||||
@mcp.tool()
|
||||
def delete_ledger_transaction(transaction_id: str, hard_delete: bool = False) -> dict:
|
||||
"""Soft-delete a ledger transaction by default, or permanently delete with hard_delete=true."""
|
||||
return service.delete_ledger_transaction(transaction_id, hard_delete)
|
||||
|
||||
@mcp.tool()
|
||||
def create_ledger_budget(month: str, category: str, amount: int, subcategory: str = "", person: str = "", currency: str = "KRW", memo: str = "") -> dict:
|
||||
"""Create or update a monthly category/person budget."""
|
||||
return service.create_ledger_budget(month, category, amount, subcategory, person, currency, memo)
|
||||
|
||||
@mcp.tool()
|
||||
def list_ledger_budgets(month: str = "") -> dict:
|
||||
"""List ledger budgets, optionally for a month YYYY-MM."""
|
||||
return service.list_ledger_budgets(month)
|
||||
|
||||
@mcp.tool()
|
||||
def get_monthly_cashflow_report(month: str = "") -> dict:
|
||||
"""Return income, expenses, net cashflow, and savings rate for a month."""
|
||||
return service.get_monthly_cashflow_report(month)
|
||||
|
||||
@mcp.tool()
|
||||
def get_category_spending_report(month: str = "", start_date: str = "", end_date: str = "", person: str = "") -> dict:
|
||||
"""Return category/subcategory spending for a month or date range."""
|
||||
return service.get_category_spending_report(month, start_date, end_date, person)
|
||||
|
||||
@mcp.tool()
|
||||
def get_fixed_cost_report(month: str = "") -> dict:
|
||||
"""Return fixed expense transactions and total fixed cost for a month."""
|
||||
return service.get_fixed_cost_report(month)
|
||||
|
||||
@mcp.tool()
|
||||
def get_budget_vs_actual_report(month: str = "") -> dict:
|
||||
"""Compare monthly budgets against actual spending."""
|
||||
return service.get_budget_vs_actual_report(month)
|
||||
|
||||
@mcp.tool()
|
||||
def get_investable_cashflow_report(month: str = "", minimum_cash_buffer: int = 0, planned_extra_expense: int = 0) -> dict:
|
||||
"""Estimate cashflow available for investing after buffer and planned extra expenses."""
|
||||
return service.get_investable_cashflow_report(month, minimum_cash_buffer, planned_extra_expense)
|
||||
|
||||
@mcp.tool()
|
||||
def preview_ledger_csv_import(file_path: str, source: str = "generic") -> dict:
|
||||
"""Preview a UTF-8/UTF-8-BOM CSV import before saving transactions."""
|
||||
return service.preview_ledger_csv_import(file_path, source)
|
||||
|
||||
@mcp.tool()
|
||||
def preview_kb_bank_pdf_import(file_path: str, password: str, preview_limit: int = 20) -> dict:
|
||||
"""Preview transactions from a password-protected KB Kookmin Bank account-history PDF without saving them."""
|
||||
return service.preview_kb_bank_pdf_import(file_path, password, preview_limit)
|
||||
|
||||
@mcp.tool()
|
||||
def import_kb_bank_pdf(file_path: str, password: str, account_id: str = "", skip_duplicates: bool = True, skip_validation: bool = False) -> dict:
|
||||
"""Import transactions from a KB Kookmin Bank account-history PDF into the ledger database."""
|
||||
return service.import_kb_bank_pdf(file_path, password, account_id, skip_duplicates, skip_validation=skip_validation)
|
||||
|
||||
@mcp.tool()
|
||||
def preview_seoulpay_xlsx_import(file_path: str, password: str = "", preview_limit: int = 20) -> dict:
|
||||
"""Preview transactions from a SeoulPay usage-history XLSX before saving them."""
|
||||
return service.preview_seoulpay_xlsx_import(file_path, password, preview_limit)
|
||||
|
||||
@mcp.tool()
|
||||
def import_seoulpay_xlsx(file_path: str, password: str = "", account_id: str = "", skip_duplicates: bool = True) -> dict:
|
||||
"""Import transactions from a SeoulPay usage-history XLSX into the ledger database."""
|
||||
return service.import_seoulpay_xlsx(file_path, password, account_id, skip_duplicates)
|
||||
|
||||
@mcp.tool()
|
||||
def preview_toss_securities_pdf_import(file_path: str, preview_limit: int = 20) -> dict:
|
||||
"""Preview transactions from a Toss Securities transaction-history PDF without saving them."""
|
||||
return service.preview_toss_securities_pdf_import(file_path, preview_limit)
|
||||
|
||||
@mcp.tool()
|
||||
def import_toss_securities_pdf(file_path: str, account_id: str = "", skip_duplicates: bool = True) -> dict:
|
||||
"""Import transactions from a Toss Securities transaction-history PDF into the ledger database."""
|
||||
return service.import_toss_securities_pdf(file_path, account_id, skip_duplicates)
|
||||
|
||||
@mcp.tool()
|
||||
def preview_toss_bank_pdf_import(file_path: str, preview_limit: int = 20) -> dict:
|
||||
"""Preview transactions from a Toss Bank transaction-history PDF without saving them."""
|
||||
return service.preview_toss_bank_pdf_import(file_path, preview_limit)
|
||||
|
||||
@mcp.tool()
|
||||
def import_toss_bank_pdf(file_path: str, account_id: str = "", skip_duplicates: bool = True) -> dict:
|
||||
"""Import transactions from a Toss Bank transaction-history PDF into the ledger database."""
|
||||
return service.import_toss_bank_pdf(file_path, account_id, skip_duplicates)
|
||||
|
||||
@mcp.tool()
|
||||
def list_ledger_import_batches(source: str = "", include_rolled_back: bool = True, limit: int = 50) -> dict:
|
||||
"""List CSV/PDF/API import batches."""
|
||||
return service.list_ledger_import_batches(source, include_rolled_back, limit)
|
||||
|
||||
@mcp.tool()
|
||||
def rollback_ledger_import_batch(import_batch_id: str, hard_delete: bool = False) -> dict:
|
||||
"""Roll back all transactions created by one import batch. Soft-deletes by default."""
|
||||
return service.rollback_ledger_import_batch(import_batch_id, hard_delete)
|
||||
|
||||
@mcp.tool()
|
||||
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) -> dict:
|
||||
"""Create or update a ledger transaction categorization rule."""
|
||||
return service.create_category_rule(match_field, pattern, category, subcategory, transaction_type, match_type, priority, person, is_fixed, confidence, memo, amount_min, amount_max, day_of_month)
|
||||
|
||||
@mcp.tool()
|
||||
def seed_default_category_rules(overwrite: bool = False) -> dict:
|
||||
"""Seed default Jarca category rules for common Korean household ledger patterns."""
|
||||
return service.seed_default_category_rules(overwrite)
|
||||
|
||||
@mcp.tool()
|
||||
def list_category_rules(active_only: bool = True) -> dict:
|
||||
"""List transaction categorization rules."""
|
||||
return service.list_category_rules(active_only)
|
||||
|
||||
@mcp.tool()
|
||||
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) -> dict:
|
||||
"""Update an existing ledger transaction categorization rule."""
|
||||
return service.update_category_rule(rule_id, match_field, pattern, category, subcategory, transaction_type, match_type, priority, person, is_fixed, confidence, memo, amount_min, amount_max, day_of_month, active)
|
||||
|
||||
@mcp.tool()
|
||||
def preview_categorization(limit: int = 1000, only_uncategorized: bool = True) -> dict:
|
||||
"""Preview category-rule matches before writing categories to transactions."""
|
||||
return service.preview_categorization(limit, only_uncategorized)
|
||||
|
||||
@mcp.tool()
|
||||
def apply_categorization_rules(limit: int = 1000, only_uncategorized: bool = True) -> dict:
|
||||
"""Apply active category rules to matching ledger transactions."""
|
||||
return service.apply_categorization_rules(limit, only_uncategorized)
|
||||
|
||||
@mcp.tool()
|
||||
def list_uncategorized_transactions(limit: int = 100, min_amount: int = 0) -> dict:
|
||||
"""List active transactions that still have no category."""
|
||||
return service.list_uncategorized_transactions(limit, min_amount)
|
||||
|
||||
@mcp.tool()
|
||||
def get_uncategorized_merchant_summary(limit: int = 50) -> dict:
|
||||
"""Summarize uncategorized transactions by merchant/raw_summary/type for user review."""
|
||||
return service.get_uncategorized_merchant_summary(limit)
|
||||
|
||||
@mcp.tool()
|
||||
def create_ledger_category(category: str, subcategory: str = "", sort_order: int = 100, memo: str = "", cashflow_kind: str = "") -> dict:
|
||||
"""Create or reactivate a ledger category/subcategory used by the dashboard and transactions."""
|
||||
return service.create_ledger_category(category, subcategory, sort_order, memo, cashflow_kind)
|
||||
|
||||
@mcp.tool()
|
||||
def seed_default_ledger_categories(overwrite: bool = False) -> dict:
|
||||
"""Seed default ledger categories and subcategories."""
|
||||
return service.seed_default_ledger_categories(overwrite)
|
||||
|
||||
@mcp.tool()
|
||||
def list_ledger_categories(active_only: bool = True, include_usage: bool = True) -> dict:
|
||||
"""List ledger categories and subcategories, optionally with usage counts."""
|
||||
return service.list_ledger_categories(active_only, include_usage)
|
||||
|
||||
@mcp.tool()
|
||||
def list_cross_transfer_review_queue(limit: int = 300) -> dict:
|
||||
"""List KB↔토스 계좌 이체 확인필요 큐 항목."""
|
||||
return service.list_cross_transfer_review_queue(limit)
|
||||
|
||||
@mcp.tool()
|
||||
def preview_cross_account_transfers(limit: int = 500) -> dict:
|
||||
"""Preview KB↔토스은행 이체 매칭 후보를 반환합니다."""
|
||||
return service.preview_cross_account_transfers(limit)
|
||||
|
||||
@mcp.tool()
|
||||
def run_cross_account_transfer_match(limit: int = 500) -> dict:
|
||||
"""Run KB↔토스은행 자동 이체 매칭 및 태그/분류 반영을 수행합니다."""
|
||||
return service.run_cross_account_transfer_match(limit)
|
||||
|
||||
@mcp.tool()
|
||||
def delete_ledger_category(category: str, subcategory: str = "", hard_delete: bool = False, force: bool = False) -> dict:
|
||||
"""Deactivate or delete a ledger category/subcategory."""
|
||||
return service.delete_ledger_category(category, subcategory, hard_delete, force)
|
||||
|
||||
def main() -> None:
|
||||
mcp.run()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,227 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import fitz
|
||||
|
||||
|
||||
DATETIME_RE = re.compile(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$")
|
||||
AMOUNT_RE = re.compile(r"^[+-]?\d{1,3}(?:,\d{3})*$")
|
||||
ACCOUNT_NUMBER_RE = re.compile(r"\d{3,}-\d{4}-\d{4}")
|
||||
|
||||
|
||||
@dataclass
|
||||
class TossBankTransaction:
|
||||
row_index: int
|
||||
transaction_datetime: str
|
||||
transaction_date: str
|
||||
action: str
|
||||
counterparty: str
|
||||
amount: int
|
||||
type: str
|
||||
balance_after: int
|
||||
raw_summary: str
|
||||
raw_lines: list[str]
|
||||
|
||||
def external_id(self, source_hash: str) -> str:
|
||||
return f"toss_bank_pdf:{source_hash}:{self.row_index}:{self.transaction_datetime}:{self.amount}"
|
||||
|
||||
def raw_payload(self) -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
"row_index": self.row_index,
|
||||
"transaction_datetime": self.transaction_datetime,
|
||||
"action": self.action,
|
||||
"counterparty": self.counterparty,
|
||||
"amount": self.amount,
|
||||
"type": self.type,
|
||||
"balance_after": self.balance_after,
|
||||
"raw_summary": self.raw_summary,
|
||||
"raw_lines": self.raw_lines,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_lines(text: str) -> list[str]:
|
||||
lines: list[str] = []
|
||||
for line in text.splitlines():
|
||||
line = re.sub(r"\s+", " ", line).strip()
|
||||
if not line:
|
||||
continue
|
||||
# Toss statements include page markers like '1 / 70'
|
||||
if re.fullmatch(r"\d+\s*/\s*\d+", line):
|
||||
continue
|
||||
lines.append(line)
|
||||
return lines
|
||||
|
||||
|
||||
def _parse_amount_token(text: str) -> int | None:
|
||||
token = text.strip()
|
||||
if not AMOUNT_RE.fullmatch(token):
|
||||
return None
|
||||
try:
|
||||
return int(token.replace(",", ""))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _extract_metadata(text: str) -> dict[str, str]:
|
||||
lines = _normalize_lines(text)
|
||||
holder_name = ""
|
||||
account_name = ""
|
||||
account_number_masked = ""
|
||||
period = ""
|
||||
|
||||
for idx, line in enumerate(lines):
|
||||
if line.endswith("님의 계좌 거래내역을"):
|
||||
holder_name = line.replace("님의 계좌 거래내역을", "").strip()
|
||||
if line == "예금주" and idx + 1 < len(lines):
|
||||
if not holder_name:
|
||||
holder_name = lines[idx + 1]
|
||||
continue
|
||||
if line == "계좌번호" and idx + 1 < len(lines):
|
||||
next_line = lines[idx + 1]
|
||||
if ACCOUNT_NUMBER_RE.search(next_line):
|
||||
account_number_masked = next_line
|
||||
continue
|
||||
if line == "예금명" and idx + 1 < len(lines):
|
||||
if not account_name:
|
||||
account_name = lines[idx + 1]
|
||||
continue
|
||||
if line == "계좌종류" and idx + 1 < len(lines):
|
||||
if not account_name:
|
||||
account_name = lines[idx + 1]
|
||||
continue
|
||||
if line == "조회기간" and idx + 1 < len(lines):
|
||||
period = lines[idx + 1]
|
||||
|
||||
if not account_name:
|
||||
account_name = "토스뱅크 통장"
|
||||
|
||||
return {
|
||||
"holder_name": holder_name,
|
||||
"account_name": account_name,
|
||||
"account_number_masked": account_number_masked,
|
||||
"period": period,
|
||||
"institution": "토스뱅크",
|
||||
"source": "toss_bank_pdf",
|
||||
}
|
||||
|
||||
|
||||
def _transaction_from_rows(row_index: int, lines: list[str]) -> TossBankTransaction | None:
|
||||
if len(lines) < 4:
|
||||
return None
|
||||
transaction_datetime = lines[0].strip()
|
||||
if not DATETIME_RE.match(transaction_datetime):
|
||||
return None
|
||||
|
||||
action = lines[1].strip() if len(lines) > 1 else ""
|
||||
amount = None
|
||||
balance_after = None
|
||||
memo_lines: list[str] = []
|
||||
|
||||
for item in lines[2:]:
|
||||
value = item.strip()
|
||||
parsed_amount = _parse_amount_token(value)
|
||||
if amount is None and parsed_amount is not None:
|
||||
amount = parsed_amount
|
||||
continue
|
||||
if balance_after is None and parsed_amount is not None:
|
||||
balance_after = parsed_amount
|
||||
continue
|
||||
memo_lines.append(value)
|
||||
|
||||
if amount is None or balance_after is None:
|
||||
return None
|
||||
|
||||
counterparty = " ".join(memo_lines).strip()
|
||||
tx_type = "expense" if amount < 0 else "income"
|
||||
|
||||
return TossBankTransaction(
|
||||
row_index=row_index,
|
||||
transaction_datetime=transaction_datetime,
|
||||
transaction_date=transaction_datetime[:10],
|
||||
action=action,
|
||||
counterparty=counterparty,
|
||||
amount=abs(amount),
|
||||
type=tx_type,
|
||||
balance_after=balance_after,
|
||||
raw_summary=counterparty,
|
||||
raw_lines=lines,
|
||||
)
|
||||
|
||||
|
||||
def _parse_rows(lines: list[str]) -> list[TossBankTransaction]:
|
||||
rows: list[list[str]] = []
|
||||
current: list[str] = []
|
||||
|
||||
for line in lines:
|
||||
if DATETIME_RE.match(line):
|
||||
if current:
|
||||
rows.append(current)
|
||||
current = [line]
|
||||
elif current:
|
||||
current.append(line)
|
||||
|
||||
if current:
|
||||
rows.append(current)
|
||||
|
||||
parsed: list[TossBankTransaction] = []
|
||||
for idx, row_lines in enumerate(rows, start=1):
|
||||
tx = _transaction_from_rows(idx, row_lines)
|
||||
if tx is not None:
|
||||
parsed.append(tx)
|
||||
return parsed
|
||||
|
||||
|
||||
def parse_toss_bank_pdf(file_path: str) -> dict[str, Any]:
|
||||
path = Path(file_path).expanduser()
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Toss Bank PDF not found: {path}")
|
||||
|
||||
data = path.read_bytes()
|
||||
source_hash = hashlib.sha256(data).hexdigest()
|
||||
doc = fitz.open(stream=data, filetype="pdf")
|
||||
if doc.is_encrypted:
|
||||
raise ValueError("encrypted Toss Bank PDF is not supported yet")
|
||||
|
||||
lines: list[str] = []
|
||||
for page in doc:
|
||||
lines.extend(_normalize_lines(page.get_text("text")))
|
||||
all_text = "\n".join(lines)
|
||||
metadata = _extract_metadata(all_text)
|
||||
|
||||
transactions = _parse_rows(lines)
|
||||
validation = {
|
||||
"ok": bool(transactions),
|
||||
"parsed_rows": len(transactions),
|
||||
"errors": [] if transactions else ["no valid rows parsed"],
|
||||
}
|
||||
return {
|
||||
"file_path": str(path),
|
||||
"source_hash": source_hash,
|
||||
"page_count": doc.page_count,
|
||||
"metadata": metadata,
|
||||
"transactions": transactions,
|
||||
"validation": validation,
|
||||
}
|
||||
|
||||
|
||||
def transaction_preview(tx: TossBankTransaction, source_hash: str) -> dict[str, Any]:
|
||||
return {
|
||||
"transaction_datetime": tx.transaction_datetime,
|
||||
"transaction_date": tx.transaction_date,
|
||||
"type": tx.type,
|
||||
"action": tx.action,
|
||||
"counterparty": tx.counterparty,
|
||||
"amount": tx.amount,
|
||||
"balance_after": tx.balance_after,
|
||||
"raw_summary": tx.raw_summary,
|
||||
"external_id": tx.external_id(source_hash),
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import fitz
|
||||
|
||||
DATE_RE = re.compile(r"^\d{4}\.\d{2}\.\d{2}$")
|
||||
FOOTER_RE = re.compile(r"^\d+\s*/\s*\d+$")
|
||||
NUM_RE = re.compile(r"^-?\d{1,3}(?:,\d{3})*(?:\.\d+)?$|^-?\d+(?:\.\d+)?$")
|
||||
DOLLAR_RE = re.compile(r"^\(\$\s*-?\d+(?:,\d{3})*(?:\.\d+)?\)$")
|
||||
HEADER_LINES = {
|
||||
"거래일자",
|
||||
"거래구분",
|
||||
"종목명(종목코드)",
|
||||
"환율",
|
||||
"거래수량",
|
||||
"거래대금",
|
||||
"정산금액",
|
||||
"단가",
|
||||
"수수료",
|
||||
"거래세",
|
||||
"제세금",
|
||||
"변제/연체합",
|
||||
"잔고",
|
||||
"잔액",
|
||||
}
|
||||
|
||||
|
||||
def _clean(text: str) -> str:
|
||||
return re.sub(r"\s+", " ", text.replace("\u00a0", " ")).strip()
|
||||
|
||||
|
||||
def _parse_number(value: str) -> float:
|
||||
cleaned = _clean(value).replace(",", "")
|
||||
if cleaned.startswith("($") and cleaned.endswith(")"):
|
||||
cleaned = cleaned[2:-1].strip()
|
||||
return float(cleaned)
|
||||
|
||||
|
||||
def _parse_int(value: str) -> int:
|
||||
return int(round(_parse_number(value)))
|
||||
|
||||
|
||||
def _is_num(value: str) -> bool:
|
||||
value = _clean(value)
|
||||
return bool(NUM_RE.match(value) or DOLLAR_RE.match(value))
|
||||
|
||||
|
||||
def _split_numeric_line(line: str) -> list[str]:
|
||||
line = _clean(line)
|
||||
parts = line.split()
|
||||
if len(parts) > 1 and all(NUM_RE.match(p) for p in parts):
|
||||
return parts
|
||||
return [line]
|
||||
|
||||
|
||||
def _normalize_lines(text: str) -> list[str]:
|
||||
lines: list[str] = []
|
||||
for raw in text.splitlines():
|
||||
line = _clean(raw)
|
||||
if not line or FOOTER_RE.match(line) or line in HEADER_LINES:
|
||||
continue
|
||||
if line.startswith("수량단위") or line.startswith("금액단위") or line.startswith("발급기준"):
|
||||
continue
|
||||
lines.append(line)
|
||||
return lines
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TossSecuritiesTransaction:
|
||||
row_index: int
|
||||
section: str
|
||||
transaction_date: str
|
||||
action: str
|
||||
name: str
|
||||
code: str
|
||||
amount: int
|
||||
type: str
|
||||
quantity: float | None
|
||||
unit_price: float | None
|
||||
fee: int
|
||||
tax: int
|
||||
balance_after: int | None
|
||||
raw_numbers: list[str]
|
||||
raw_block: list[str]
|
||||
|
||||
def external_id(self, source_hash: str) -> str:
|
||||
raw = "|".join(
|
||||
[
|
||||
source_hash,
|
||||
str(self.row_index),
|
||||
self.section,
|
||||
self.transaction_date,
|
||||
self.action,
|
||||
self.name,
|
||||
str(self.amount),
|
||||
json.dumps(self.raw_numbers, ensure_ascii=False),
|
||||
]
|
||||
)
|
||||
return "tosssec:" + hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
def raw_payload(self) -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
"row_index": self.row_index,
|
||||
"section": self.section,
|
||||
"transaction_date": self.transaction_date,
|
||||
"action": self.action,
|
||||
"name": self.name,
|
||||
"code": self.code,
|
||||
"amount": self.amount,
|
||||
"type": self.type,
|
||||
"quantity": self.quantity,
|
||||
"unit_price": self.unit_price,
|
||||
"fee": self.fee,
|
||||
"tax": self.tax,
|
||||
"balance_after": self.balance_after,
|
||||
"raw_numbers": self.raw_numbers,
|
||||
"raw_block": self.raw_block,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
def _extract_metadata(text: str) -> dict[str, Any]:
|
||||
compact = _clean(text)
|
||||
holder = ""
|
||||
account = ""
|
||||
date_range = ""
|
||||
issue_no = ""
|
||||
m = re.search(r"성명\s+([^\s]+)", compact)
|
||||
if m:
|
||||
holder = m.group(1)
|
||||
m = re.search(r"계좌 번호\s+([0-9\-]+)", compact)
|
||||
if m:
|
||||
account = m.group(1)
|
||||
m = re.search(r"조회 기간\s+(\d{4}년\s+\d{1,2}월\s+\d{1,2}일\s+~\s+\d{4}년\s+\d{1,2}월\s+\d{1,2}일)", compact)
|
||||
if m:
|
||||
date_range = m.group(1)
|
||||
m = re.search(r"거래내역서\s+([0-9A-Z\-]+)\s+발급번호", compact)
|
||||
if m:
|
||||
issue_no = m.group(1)
|
||||
return {
|
||||
"holder_name": holder,
|
||||
"account_number_masked": account,
|
||||
"date_range": date_range,
|
||||
"issue_no": issue_no,
|
||||
"account_name": "토스증권 계좌",
|
||||
}
|
||||
|
||||
|
||||
def _blocks_from_lines(lines: list[str]) -> list[tuple[str, list[str]]]:
|
||||
blocks: list[tuple[str, list[str]]] = []
|
||||
section = "KRW"
|
||||
current: list[str] | None = None
|
||||
current_section = section
|
||||
for line in lines:
|
||||
if line == "원화 거래내역":
|
||||
section = "KRW"
|
||||
continue
|
||||
if line == "달러 거래내역":
|
||||
section = "USD"
|
||||
continue
|
||||
if DATE_RE.match(line):
|
||||
if current:
|
||||
blocks.append((current_section, current))
|
||||
current = [line]
|
||||
current_section = section
|
||||
elif current is not None:
|
||||
current.append(line)
|
||||
if current:
|
||||
blocks.append((current_section, current))
|
||||
return blocks
|
||||
|
||||
|
||||
def _parse_block(row_index: int, section: str, block: list[str]) -> TossSecuritiesTransaction | None:
|
||||
if len(block) < 3:
|
||||
return None
|
||||
date_iso = block[0].replace(".", "-")
|
||||
action = block[1]
|
||||
body_start = 2
|
||||
if "(" in action and ")" not in action and len(block) > 2:
|
||||
action = f"{action} {block[2]}"
|
||||
body_start = 3
|
||||
action = action.replace("케 이뱅크", "케이뱅크")
|
||||
name_parts: list[str] = []
|
||||
numeric_cells: list[str] = []
|
||||
numeric_started = False
|
||||
for line in block[body_start:]:
|
||||
pieces = _split_numeric_line(line)
|
||||
if not numeric_started and all(_is_num(p) for p in pieces):
|
||||
numeric_started = True
|
||||
if numeric_started:
|
||||
numeric_cells.extend(p for p in pieces if _is_num(p))
|
||||
else:
|
||||
name_parts.append(line)
|
||||
name = " ".join(name_parts).strip()
|
||||
code = ""
|
||||
code_match = re.search(r"\(([A-Z0-9]+)\)", name)
|
||||
if code_match:
|
||||
code = code_match.group(1)
|
||||
krw_numbers = [n for n in numeric_cells if not _clean(n).startswith("($")]
|
||||
if len(krw_numbers) < 2:
|
||||
return None
|
||||
has_exchange = section == "USD" or action.startswith("환전") or action.startswith("외화")
|
||||
amount_idx = 3 if has_exchange and len(krw_numbers) > 3 else 2 if len(krw_numbers) > 2 else 1
|
||||
amount = abs(_parse_int(krw_numbers[amount_idx]))
|
||||
quantity_idx = 1 if has_exchange and len(krw_numbers) > 1 else 0
|
||||
quantity = _parse_number(krw_numbers[quantity_idx]) if len(krw_numbers) > quantity_idx else None
|
||||
price_idx = 4 if has_exchange else 3
|
||||
unit_price = _parse_number(krw_numbers[price_idx]) if len(krw_numbers) > price_idx else None
|
||||
fee_idx = 5 if has_exchange else 4
|
||||
fee = abs(_parse_int(krw_numbers[fee_idx])) if len(krw_numbers) > fee_idx else 0
|
||||
tax_start = 6 if has_exchange else 5
|
||||
tax = sum(abs(_parse_int(x)) for x in krw_numbers[tax_start : min(tax_start + 2, len(krw_numbers))])
|
||||
balance_after = _parse_int(krw_numbers[-1]) if krw_numbers else None
|
||||
tx_type = "income" if any(k in action for k in ["입금", "판매", "환급"]) else "expense"
|
||||
if any(k in action for k in ["출금", "구매"]):
|
||||
tx_type = "expense"
|
||||
return TossSecuritiesTransaction(
|
||||
row_index=row_index,
|
||||
section=section,
|
||||
transaction_date=date_iso,
|
||||
action=action,
|
||||
name=name,
|
||||
code=code,
|
||||
amount=amount,
|
||||
type=tx_type,
|
||||
quantity=quantity,
|
||||
unit_price=unit_price,
|
||||
fee=fee,
|
||||
tax=tax,
|
||||
balance_after=balance_after,
|
||||
raw_numbers=numeric_cells,
|
||||
raw_block=block,
|
||||
)
|
||||
|
||||
|
||||
def parse_toss_securities_pdf(file_path: str) -> dict[str, Any]:
|
||||
path = Path(file_path).expanduser()
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Toss Securities PDF not found: {path}")
|
||||
data = path.read_bytes()
|
||||
source_hash = hashlib.sha256(data).hexdigest()
|
||||
doc = fitz.open(stream=data, filetype="pdf")
|
||||
if doc.is_encrypted:
|
||||
raise ValueError("encrypted Toss Securities PDF is not supported yet")
|
||||
all_text = "\n".join(page.get_text("text") for page in doc)
|
||||
metadata = _extract_metadata(all_text)
|
||||
lines: list[str] = []
|
||||
for page in doc:
|
||||
lines.extend(_normalize_lines(page.get_text("text")))
|
||||
transactions: list[TossSecuritiesTransaction] = []
|
||||
for idx, (section, block) in enumerate(_blocks_from_lines(lines), start=1):
|
||||
tx = _parse_block(idx, section, block)
|
||||
if tx and tx.amount > 0:
|
||||
transactions.append(tx)
|
||||
validation = {
|
||||
"ok": bool(transactions),
|
||||
"parsed_rows": len(transactions),
|
||||
"errors": [] if transactions else ["no transactions parsed"],
|
||||
}
|
||||
return {
|
||||
"file_path": str(path),
|
||||
"source_hash": source_hash,
|
||||
"page_count": doc.page_count,
|
||||
"metadata": metadata,
|
||||
"transactions": transactions,
|
||||
"validation": validation,
|
||||
}
|
||||
|
||||
|
||||
def transaction_preview(tx: TossSecuritiesTransaction, source_hash: str) -> dict[str, Any]:
|
||||
return {
|
||||
"transaction_date": tx.transaction_date,
|
||||
"type": tx.type,
|
||||
"amount": tx.amount,
|
||||
"action": tx.action,
|
||||
"name": tx.name,
|
||||
"code": tx.code,
|
||||
"section": tx.section,
|
||||
"quantity": tx.quantity,
|
||||
"unit_price": tx.unit_price,
|
||||
"fee": tx.fee,
|
||||
"tax": tx.tax,
|
||||
"balance_after": tx.balance_after,
|
||||
"external_id": tx.external_id(source_hash),
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "jarca-ledger-mcp"
|
||||
version = "0.1.0"
|
||||
description = "Jarca household ledger MCP server for transactions, accounts, budgets, cashflow, and investable cashflow analysis"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"mcp>=1.0.0",
|
||||
"pymupdf>=1.24.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
"openpyxl>=3.1.0",
|
||||
"msoffcrypto-tool>=5.4.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=8.0.0"]
|
||||
dashboard = [
|
||||
"pandas>=2.0.0",
|
||||
"plotly>=5.0.0",
|
||||
"streamlit>=1.30.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
jarca-ledger-mcp = "jarca_ledger_mcp.server:main"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "-q"
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["jarca_ledger_mcp*"]
|
||||
exclude = ["dashboard*"]
|
||||
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from jarca_ledger_mcp import service
|
||||
|
||||
|
||||
def test_category_rules_preview_apply(tmp_path):
|
||||
db_path = str(tmp_path / "ledger.sqlite3")
|
||||
service.create_ledger_transaction("2026-06-01", "expense", 10_000, merchant="이마트에브리데이", raw_summary="체크카드", db_path=db_path)
|
||||
service.create_ledger_transaction("2026-06-02", "expense", 5_000, merchant="알수없음", raw_summary="체크카드", db_path=db_path)
|
||||
seeded = service.seed_default_category_rules(db_path=db_path)
|
||||
assert seeded["success"] is True
|
||||
rules = service.list_category_rules(db_path=db_path)
|
||||
assert rules["count"] > 0
|
||||
|
||||
preview = service.preview_categorization(db_path=db_path)
|
||||
assert preview["matched_count"] == 1
|
||||
assert preview["unmatched_count"] == 1
|
||||
assert preview["matches"][0]["category"] == "식비"
|
||||
assert preview["matches"][0]["subcategory"] == "마트"
|
||||
|
||||
applied = service.apply_categorization_rules(db_path=db_path)
|
||||
assert applied["success"] is True
|
||||
assert applied["applied_count"] == 1
|
||||
categorized = service.list_ledger_transactions(category="식비", db_path=db_path)
|
||||
assert categorized["count"] == 1
|
||||
uncategorized = service.list_uncategorized_transactions(db_path=db_path)
|
||||
assert uncategorized["count"] == 1
|
||||
|
||||
|
||||
def test_apply_categorization_rules_applies_all_matches_not_just_preview_sample(tmp_path):
|
||||
db_path = str(tmp_path / "ledger.sqlite3")
|
||||
service.seed_default_category_rules(db_path=db_path)
|
||||
for idx in range(150):
|
||||
service.create_ledger_transaction(
|
||||
transaction_date=f"2026-06-{(idx % 28) + 1:02d}",
|
||||
type="expense",
|
||||
amount=10_000 + idx,
|
||||
merchant=f"이마트에브리데이 {idx}",
|
||||
raw_summary="체크카드",
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
preview = service.preview_categorization(limit=5000, db_path=db_path)
|
||||
assert preview["matched_count"] == 150
|
||||
assert len(preview["matches"]) == 100
|
||||
|
||||
applied = service.apply_categorization_rules(limit=5000, db_path=db_path)
|
||||
assert applied["success"] is True
|
||||
assert applied["applied_count"] == 150
|
||||
uncategorized = service.list_uncategorized_transactions(limit=200, db_path=db_path)
|
||||
assert uncategorized["count"] == 0
|
||||
@@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from jarca_ledger_mcp import service
|
||||
|
||||
|
||||
def test_category_rule_amount_and_day_filters(tmp_path):
|
||||
db_path = str(tmp_path / "ledger.sqlite3")
|
||||
service.health_check(db_path)
|
||||
rule = service.create_category_rule(
|
||||
match_field="counterparty",
|
||||
match_type="equals",
|
||||
pattern="고근수",
|
||||
transaction_type="expense",
|
||||
category="금융",
|
||||
subcategory="대출상환",
|
||||
is_fixed=True,
|
||||
amount_min=1200000,
|
||||
amount_max=1200000,
|
||||
day_of_month=5,
|
||||
db_path=db_path,
|
||||
)
|
||||
assert rule["success"] is True
|
||||
assert rule["rule"]["amount_min"] == 1200000
|
||||
assert rule["rule"]["day_of_month"] == 5
|
||||
|
||||
matching = service.create_ledger_transaction(
|
||||
transaction_date="2026-04-05",
|
||||
type="expense",
|
||||
amount=1200000,
|
||||
merchant="고근수",
|
||||
description="test",
|
||||
db_path=db_path,
|
||||
)
|
||||
non_matching_amount = service.create_ledger_transaction(
|
||||
transaction_date="2026-04-05",
|
||||
type="expense",
|
||||
amount=1000000,
|
||||
merchant="고근수",
|
||||
db_path=db_path,
|
||||
)
|
||||
non_matching_day = service.create_ledger_transaction(
|
||||
transaction_date="2026-04-06",
|
||||
type="expense",
|
||||
amount=1200000,
|
||||
merchant="고근수",
|
||||
db_path=db_path,
|
||||
)
|
||||
# Manual transaction helper maps merchant, not counterparty, so set counterparty directly for this importer-specific rule.
|
||||
from jarca_ledger_mcp.db import db
|
||||
with db(db_path) as conn:
|
||||
for tx in [matching, non_matching_amount, non_matching_day]:
|
||||
conn.execute("UPDATE transactions SET counterparty=? WHERE id=?", ("고근수", tx["transaction"]["id"]))
|
||||
|
||||
preview = service.preview_categorization(limit=100, db_path=db_path)
|
||||
assert preview["matched_count"] == 1
|
||||
assert preview["matches"][0]["transaction_id"] == matching["transaction"]["id"]
|
||||
assert preview["matches"][0]["category"] == "금융"
|
||||
assert preview["matches"][0]["subcategory"] == "대출상환"
|
||||
|
||||
|
||||
def test_update_category_rule_allows_condition_edits(tmp_path):
|
||||
db_path = str(tmp_path / "ledger.sqlite3")
|
||||
service.health_check(db_path)
|
||||
created = service.create_category_rule(
|
||||
match_field="merchant",
|
||||
match_type="contains",
|
||||
pattern="학원",
|
||||
transaction_type="expense",
|
||||
category="교육",
|
||||
subcategory="학원",
|
||||
amount_min=100000,
|
||||
db_path=db_path,
|
||||
)
|
||||
assert created["success"] is True
|
||||
|
||||
updated = service.update_category_rule(
|
||||
created["rule"]["id"],
|
||||
match_field="merchant",
|
||||
match_type="equals",
|
||||
pattern="대치학원",
|
||||
transaction_type="expense",
|
||||
category="교육",
|
||||
subcategory="학원비",
|
||||
priority=40,
|
||||
person="고재성",
|
||||
is_fixed=True,
|
||||
memo="dashboard edit",
|
||||
amount_min=120000,
|
||||
amount_max=150000,
|
||||
day_of_month=10,
|
||||
active=False,
|
||||
db_path=db_path,
|
||||
)
|
||||
assert updated["success"] is True
|
||||
rule = updated["rule"]
|
||||
assert rule["match_type"] == "equals"
|
||||
assert rule["pattern"] == "대치학원"
|
||||
assert rule["subcategory"] == "학원비"
|
||||
assert rule["priority"] == 40
|
||||
assert rule["person"] == "고재성"
|
||||
assert rule["is_fixed"] is True
|
||||
assert rule["amount_min"] == 120000
|
||||
assert rule["amount_max"] == 150000
|
||||
assert rule["day_of_month"] == 10
|
||||
assert rule["active"] is False
|
||||
@@ -0,0 +1,127 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from jarca_ledger_mcp import service
|
||||
|
||||
|
||||
def _find_tx(rows: list[dict], tx_id: str) -> dict:
|
||||
for row in rows:
|
||||
if row["id"] == tx_id:
|
||||
return row
|
||||
raise AssertionError(f"tx {tx_id} not found")
|
||||
|
||||
|
||||
def test_run_cross_transfer_match_marks_ownership_transfer_as_investment(tmp_path):
|
||||
db_path = str(tmp_path / "ledger.sqlite3")
|
||||
kb_account = service.create_ledger_account("KB 본인", "bank", institution="국민", holder_name="고진환", db_path=db_path)
|
||||
toss_account = service.create_ledger_account("토스증권/뱅크", "bank", institution="토스", holder_name="고진환", db_path=db_path)
|
||||
|
||||
kb_tx = service.create_ledger_transaction(
|
||||
transaction_date="2026-06-17",
|
||||
type="expense",
|
||||
amount=63_000,
|
||||
category="이체",
|
||||
subcategory="입금",
|
||||
merchant="출금",
|
||||
counterparty="고진환",
|
||||
account_id=kb_account["account"]["id"],
|
||||
source="kb_bank_pdf",
|
||||
db_path=db_path,
|
||||
)
|
||||
toss_tx = service.create_ledger_transaction(
|
||||
transaction_date="2026-06-17",
|
||||
type="income",
|
||||
amount=63_000,
|
||||
category="이체",
|
||||
subcategory="입금",
|
||||
merchant="입금",
|
||||
counterparty="고진환",
|
||||
account_id=toss_account["account"]["id"],
|
||||
source="toss_bank_pdf",
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
assert kb_tx["success"] is True
|
||||
assert toss_tx["success"] is True
|
||||
|
||||
result = service.run_cross_account_transfer_match(db_path=db_path)
|
||||
assert result["success"] is True
|
||||
assert result["matched_count"] >= 1
|
||||
transactions = service.list_ledger_transactions(limit=20, db_path=db_path)["transactions"]
|
||||
kb = _find_tx(transactions, kb_tx["transaction"]["id"])
|
||||
toss = _find_tx(transactions, toss_tx["transaction"]["id"])
|
||||
|
||||
assert kb["category"] == "이체"
|
||||
assert kb["subcategory"] == "투자이동"
|
||||
assert toss["category"] == "이체"
|
||||
assert toss["subcategory"] == "투자이동"
|
||||
|
||||
|
||||
def test_run_cross_transfer_match_marks_foreign_name_as_review_needed(tmp_path):
|
||||
db_path = str(tmp_path / "ledger.sqlite3")
|
||||
kb_account = service.create_ledger_account("KB 본인", "bank", institution="국민", holder_name="고진환", db_path=db_path)
|
||||
toss_account = service.create_ledger_account("토스뱅크", "bank", institution="토스", holder_name="고진환", db_path=db_path)
|
||||
|
||||
kb_tx = service.create_ledger_transaction(
|
||||
transaction_date="2026-06-20",
|
||||
type="expense",
|
||||
amount=5_000_000,
|
||||
category="이체",
|
||||
subcategory="출금",
|
||||
merchant="출금",
|
||||
counterparty="최진선",
|
||||
account_id=kb_account["account"]["id"],
|
||||
source="kb_bank_pdf",
|
||||
db_path=db_path,
|
||||
)
|
||||
toss_tx = service.create_ledger_transaction(
|
||||
transaction_date="2026-06-20",
|
||||
type="income",
|
||||
amount=5_000_000,
|
||||
category="이체",
|
||||
subcategory="입금",
|
||||
merchant="입금",
|
||||
counterparty="최진선",
|
||||
account_id=toss_account["account"]["id"],
|
||||
source="toss_bank_pdf",
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
assert kb_tx["success"] is True
|
||||
assert toss_tx["success"] is True
|
||||
|
||||
result = service.run_cross_account_transfer_match(db_path=db_path)
|
||||
assert result["success"] is True
|
||||
|
||||
transactions = service.list_ledger_transactions(limit=20, db_path=db_path)["transactions"]
|
||||
kb = _find_tx(transactions, kb_tx["transaction"]["id"])
|
||||
toss = _find_tx(transactions, toss_tx["transaction"]["id"])
|
||||
assert kb["subcategory"] == "확인필요"
|
||||
assert toss["subcategory"] == "확인필요"
|
||||
|
||||
|
||||
def test_preview_cross_transfer_marks_unmatched_as_review_item(tmp_path):
|
||||
db_path = str(tmp_path / "ledger.sqlite3")
|
||||
kb_account = service.create_ledger_account("KB 본인", "bank", institution="국민", holder_name="고진환", db_path=db_path)
|
||||
|
||||
kb_tx = service.create_ledger_transaction(
|
||||
transaction_date="2026-06-22",
|
||||
type="expense",
|
||||
amount=12_000,
|
||||
category="이체",
|
||||
subcategory="출금",
|
||||
merchant="출금",
|
||||
counterparty="고진환",
|
||||
account_id=kb_account["account"]["id"],
|
||||
source="kb_bank_pdf",
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
assert kb_tx["success"] is True
|
||||
|
||||
result = service.run_cross_account_transfer_match(limit=100, db_path=db_path)
|
||||
assert result["success"] is True
|
||||
assert result["review_count"] >= 1
|
||||
|
||||
rows = service.list_cross_transfer_review_queue(db_path=db_path)["transactions"]
|
||||
row = _find_tx(rows, kb_tx["transaction"]["id"])
|
||||
assert row["memo"].find("cross_transfer_review") != -1
|
||||
@@ -0,0 +1,66 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from jarca_ledger_mcp import service
|
||||
|
||||
PDF_PATH = Path("/root/.hermes/cache/documents/doc_5a12af727ae0_KB거래내역조회_5704_2606171043.pdf")
|
||||
PDF_PASSWORD = os.environ.get("KB_BANK_PDF_TEST_PASSWORD", "")
|
||||
SAMPLE_AVAILABLE = PDF_PATH.exists() and bool(PDF_PASSWORD)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not SAMPLE_AVAILABLE, reason="sample KB Bank PDF/password is not available")
|
||||
def test_preview_kb_bank_pdf_import_real_sample(tmp_path):
|
||||
result = service.preview_kb_bank_pdf_import(str(PDF_PATH), PDF_PASSWORD, preview_limit=3, db_path=str(tmp_path / "ledger.sqlite3"))
|
||||
assert result["success"] is True
|
||||
assert result["row_count"] == 499
|
||||
assert result["page_count"] == 20
|
||||
assert result["metadata"]["account_number_masked"] == "****5704"
|
||||
assert result["metadata"]["final_balance"] == 3_041_678
|
||||
assert result["validation"]["ok"] is True
|
||||
assert result["validation"]["total_deposit"] == 125_730_389
|
||||
assert result["validation"]["total_withdrawal"] == 123_565_861
|
||||
assert result["preview"][0]["transaction_datetime"] == "2026-01-03T09:05:10"
|
||||
assert result["preview"][0]["type"] == "expense"
|
||||
assert result["preview"][0]["amount"] == 200
|
||||
|
||||
|
||||
@pytest.mark.skipif(not SAMPLE_AVAILABLE, reason="sample KB Bank PDF/password is not available")
|
||||
def test_import_kb_bank_pdf_real_sample_and_rollback(tmp_path):
|
||||
db_path = str(tmp_path / "ledger.sqlite3")
|
||||
result = service.import_kb_bank_pdf(str(PDF_PATH), PDF_PASSWORD, db_path=db_path)
|
||||
assert result["success"] is True
|
||||
assert result["parsed_row_count"] == 499
|
||||
assert result["inserted_count"] == 499
|
||||
assert result["skipped_duplicate_count"] == 0
|
||||
assert result["metadata"]["account_number_masked"] == "****5704"
|
||||
|
||||
duplicated = service.import_kb_bank_pdf(str(PDF_PATH), PDF_PASSWORD, db_path=db_path)
|
||||
assert duplicated["success"] is True
|
||||
assert duplicated["inserted_count"] == 0
|
||||
assert duplicated["skipped_duplicate_count"] == 499
|
||||
|
||||
listed = service.list_ledger_transactions(start_date="2026-01-01", end_date="2026-06-17", limit=1000, db_path=db_path)
|
||||
assert listed["success"] is True
|
||||
assert listed["count"] == 499
|
||||
sample = next(tx for tx in listed["transactions"] if tx["transaction_datetime"] == "2026-06-15T16:50:50")
|
||||
assert sample["merchant"] == "오아시스마켓"
|
||||
assert sample["balance_after"] == 3_041_678
|
||||
assert sample["branch"] == "KB카드"
|
||||
|
||||
accounts = service.list_ledger_accounts(db_path=db_path)
|
||||
assert accounts["count"] == 1
|
||||
assert accounts["accounts"][0]["institution"] == "KB국민은행"
|
||||
assert accounts["accounts"][0]["last_balance"] == 3_041_678
|
||||
|
||||
batches = service.list_ledger_import_batches(source="kb_bank_pdf", db_path=db_path)
|
||||
assert batches["count"] == 2
|
||||
|
||||
rollback = service.rollback_ledger_import_batch(result["import_batch_id"], db_path=db_path)
|
||||
assert rollback["success"] is True
|
||||
assert rollback["affected_transactions"] == 499
|
||||
after = service.list_ledger_transactions(start_date="2026-01-01", end_date="2026-06-17", limit=1000, db_path=db_path)
|
||||
assert after["count"] == 0
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from jarca_ledger_mcp import service
|
||||
|
||||
|
||||
def test_ledger_category_create_list_delete_reactivate(tmp_path):
|
||||
db_path = str(tmp_path / "ledger.sqlite3")
|
||||
health = service.health_check(db_path)
|
||||
assert health["schema_version"] == "6"
|
||||
|
||||
created = service.create_ledger_category("반려동물", "사료", sort_order=250, memo="test", db_path=db_path)
|
||||
assert created["success"] is True
|
||||
assert created["category"]["category"] == "반려동물"
|
||||
assert created["category"]["subcategory"] == "사료"
|
||||
assert created["category"]["cashflow_kind"] == "expense"
|
||||
assert created["category"]["active"] is True
|
||||
|
||||
listed = service.list_ledger_categories(db_path=db_path)
|
||||
assert listed["count"] == 1
|
||||
assert listed["grouped"] == {"반려동물": ["사료"]}
|
||||
|
||||
deleted = service.delete_ledger_category("반려동물", "사료", db_path=db_path)
|
||||
assert deleted["success"] is True
|
||||
assert service.list_ledger_categories(db_path=db_path)["count"] == 0
|
||||
assert service.list_ledger_categories(active_only=False, db_path=db_path)["categories"][0]["active"] is False
|
||||
|
||||
reactivated = service.create_ledger_category("반려동물", "사료", sort_order=260, db_path=db_path)
|
||||
assert reactivated["success"] is True
|
||||
assert reactivated["category"]["active"] is True
|
||||
assert reactivated["category"]["sort_order"] == 260
|
||||
|
||||
|
||||
def test_ledger_category_cashflow_kind(tmp_path):
|
||||
db_path = str(tmp_path / "ledger.sqlite3")
|
||||
income = service.create_ledger_category("기타수입", "상금", cashflow_kind="income", db_path=db_path)
|
||||
assert income["success"] is True
|
||||
assert income["category"]["cashflow_kind"] == "income"
|
||||
invalid = service.create_ledger_category("테스트", "", cashflow_kind="bad", db_path=db_path)
|
||||
assert invalid["success"] is False
|
||||
assert invalid["error_code"] == "validation_error"
|
||||
|
||||
|
||||
def test_ledger_category_hard_delete_requires_force_when_in_use(tmp_path):
|
||||
db_path = str(tmp_path / "ledger.sqlite3")
|
||||
service.create_ledger_category("식비", "마트", db_path=db_path)
|
||||
service.create_ledger_transaction("2026-06-01", "expense", 10000, category="식비", subcategory="마트", merchant="테스트마트", db_path=db_path)
|
||||
|
||||
blocked = service.delete_ledger_category("식비", "마트", hard_delete=True, db_path=db_path)
|
||||
assert blocked["success"] is False
|
||||
assert blocked["error_code"] == "category_in_use"
|
||||
|
||||
soft = service.delete_ledger_category("식비", "마트", db_path=db_path)
|
||||
assert soft["success"] is True
|
||||
assert soft["transaction_count"] == 1
|
||||
|
||||
txs = service.list_ledger_transactions(category="식비", db_path=db_path)
|
||||
assert txs["count"] == 1
|
||||
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from jarca_ledger_mcp import service
|
||||
from jarca_ledger_mcp.seoulpay_xlsx import parse_seoulpay_xlsx
|
||||
|
||||
|
||||
def _write_sample_seoulpay_xlsx(path: Path) -> None:
|
||||
import openpyxl
|
||||
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "결제내역"
|
||||
ws.append(["성명", "최진선"])
|
||||
ws.append(["생년월일", "19791010"])
|
||||
ws.append(["조회기간", "20260601~20260620"])
|
||||
ws.append([])
|
||||
ws.append([])
|
||||
ws.append(["결제채널", "거래번호", "결제일", "가맹점", "사업자번호", "가맹점ID", "거래유형", "결제구분", "거래금액", "공급가액", "봉사료", "부가세", "과세여부"])
|
||||
ws.append(["서울페이", "260618ZP00534273", "2026-06-18 18:27:46", "땡겨요", "2028102637", "AF2402815444", "온라인 결제 ", "결제", 42000, 38182, 0, 3818, "과세"])
|
||||
ws.append(["서울페이", "ABP0123761927", "2026-06-10 15:14:05", "리엘바이올린음악교습소", "8259301583", "AF2402827183", "비대면 결제 ", "결제", 260000, 260000, 0, 0, "면세"])
|
||||
wb.save(path)
|
||||
|
||||
|
||||
def test_parse_seoulpay_xlsx_plain_file(tmp_path):
|
||||
file_path = tmp_path / "서울페이.xlsx"
|
||||
_write_sample_seoulpay_xlsx(file_path)
|
||||
|
||||
parsed = parse_seoulpay_xlsx(str(file_path))
|
||||
|
||||
assert parsed["metadata"]["holder_name"] == "최진선"
|
||||
assert parsed["metadata"]["period"] == "20260601~20260620"
|
||||
assert parsed["validation"]["ok"] is True
|
||||
assert len(parsed["transactions"]) == 2
|
||||
first = parsed["transactions"][0]
|
||||
assert first.transaction_date == "2026-06-18"
|
||||
assert first.merchant == "땡겨요"
|
||||
assert first.amount == 42000
|
||||
assert first.type == "expense"
|
||||
|
||||
|
||||
def test_import_seoulpay_xlsx_creates_account_and_expenses(tmp_path):
|
||||
db_path = str(tmp_path / "ledger.sqlite3")
|
||||
file_path = tmp_path / "서울페이.xlsx"
|
||||
_write_sample_seoulpay_xlsx(file_path)
|
||||
|
||||
preview = service.preview_seoulpay_xlsx_import(str(file_path), preview_limit=10, db_path=db_path)
|
||||
assert preview["success"] is True
|
||||
assert preview["row_count"] == 2
|
||||
assert preview["duplicate_count"] == 0
|
||||
assert preview["by_category"]["식비/배달"]["amount"] == 42000
|
||||
assert preview["by_category"]["교육/학원"]["amount"] == 260000
|
||||
|
||||
imported = service.import_seoulpay_xlsx(str(file_path), db_path=db_path)
|
||||
assert imported["success"] is True
|
||||
assert imported["inserted_count"] == 2
|
||||
assert imported["skipped_duplicate_count"] == 0
|
||||
|
||||
duplicate = service.import_seoulpay_xlsx(str(file_path), db_path=db_path)
|
||||
assert duplicate["success"] is True
|
||||
assert duplicate["inserted_count"] == 0
|
||||
assert duplicate["skipped_duplicate_count"] == 2
|
||||
|
||||
cashflow = service.get_monthly_cashflow_report("2026-06", db_path)
|
||||
assert cashflow["expenses"] == 302000
|
||||
categories = service.get_category_spending_report(month="2026-06", db_path=db_path)
|
||||
assert categories["total_expense"] == 302000
|
||||
assert categories["items"][0]["category"] == "교육"
|
||||
@@ -0,0 +1,137 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from jarca_ledger_mcp import service
|
||||
|
||||
|
||||
def test_health_check_initializes_db(tmp_path):
|
||||
db_path = tmp_path / "ledger.sqlite3"
|
||||
result = service.health_check(str(db_path))
|
||||
assert result["success"] is True
|
||||
assert result["schema_version"] == "6"
|
||||
assert db_path.exists()
|
||||
|
||||
|
||||
def test_account_transaction_cashflow_and_category_reports(tmp_path):
|
||||
db_path = str(tmp_path / "ledger.sqlite3")
|
||||
acct = service.create_ledger_account("신한카드", type="card", institution="신한", db_path=db_path)
|
||||
assert acct["success"] is True
|
||||
account_id = acct["account"]["id"]
|
||||
|
||||
income = service.create_ledger_transaction(
|
||||
"2026-06-01",
|
||||
"income",
|
||||
8_000_000,
|
||||
category="수입",
|
||||
merchant="회사",
|
||||
account_id=account_id,
|
||||
db_path=db_path,
|
||||
)
|
||||
assert income["success"] is True
|
||||
|
||||
expense = service.create_ledger_transaction(
|
||||
"2026-06-03",
|
||||
"expense",
|
||||
120_000,
|
||||
category="식비",
|
||||
subcategory="마트",
|
||||
merchant="이마트",
|
||||
account_id=account_id,
|
||||
person="공통",
|
||||
tags=["장보기"],
|
||||
db_path=db_path,
|
||||
)
|
||||
assert expense["success"] is True
|
||||
|
||||
fixed = service.create_ledger_transaction(
|
||||
"2026-06-05",
|
||||
"expense",
|
||||
900_000,
|
||||
category="교육",
|
||||
subcategory="학원",
|
||||
merchant="물리학원",
|
||||
person="고재성",
|
||||
is_fixed=True,
|
||||
db_path=db_path,
|
||||
)
|
||||
assert fixed["success"] is True
|
||||
|
||||
investment_in = service.create_ledger_transaction(
|
||||
"2026-06-06",
|
||||
"income",
|
||||
5_000_000,
|
||||
category="이체",
|
||||
subcategory="투자이동",
|
||||
merchant="토스증권 매도/이체입금",
|
||||
db_path=db_path,
|
||||
)
|
||||
assert investment_in["success"] is True
|
||||
|
||||
investment_out = service.create_ledger_transaction(
|
||||
"2026-06-07",
|
||||
"expense",
|
||||
3_000_000,
|
||||
category="이체",
|
||||
subcategory="투자이동",
|
||||
merchant="토스증권 매수/이체출금",
|
||||
is_fixed=True,
|
||||
db_path=db_path,
|
||||
)
|
||||
assert investment_out["success"] is True
|
||||
|
||||
cashflow = service.get_monthly_cashflow_report("2026-06", db_path)
|
||||
assert cashflow["success"] is True
|
||||
assert cashflow["income"] == 8_000_000
|
||||
assert cashflow["expenses"] == 1_020_000
|
||||
assert cashflow["net_cashflow"] == 6_980_000
|
||||
assert cashflow["by_type"]["income"] == 13_000_000
|
||||
assert cashflow["by_type"]["expense"] == 4_020_000
|
||||
assert cashflow["by_cashflow_kind"]["investment"] == 8_000_000
|
||||
|
||||
categories = service.get_category_spending_report(month="2026-06", db_path=db_path)
|
||||
assert categories["total_expense"] == 1_020_000
|
||||
assert categories["items"][0]["category"] == "교육"
|
||||
|
||||
fixed_report = service.get_fixed_cost_report("2026-06", db_path)
|
||||
assert fixed_report["total_fixed_cost"] == 900_000
|
||||
assert fixed_report["count"] == 1
|
||||
|
||||
|
||||
def test_budget_vs_actual_and_investable_cashflow(tmp_path):
|
||||
db_path = str(tmp_path / "ledger.sqlite3")
|
||||
service.create_ledger_budget("2026-06", "식비", 500_000, db_path=db_path)
|
||||
service.create_ledger_budget("2026-06", "교육", 800_000, person="고재준", db_path=db_path)
|
||||
service.create_ledger_transaction("2026-06-01", "income", 4_000_000, category="수입", db_path=db_path)
|
||||
service.create_ledger_transaction("2026-06-02", "expense", 550_000, category="식비", db_path=db_path)
|
||||
service.create_ledger_transaction("2026-06-03", "expense", 700_000, category="교육", person="고재준", db_path=db_path)
|
||||
service.create_ledger_transaction("2026-06-04", "expense", 2_000_000, category="이체", subcategory="투자이동", db_path=db_path)
|
||||
service.create_ledger_transaction("2026-06-05", "income", 1_000_000, category="이체", subcategory="투자이동", db_path=db_path)
|
||||
|
||||
report = service.get_budget_vs_actual_report("2026-06", db_path)
|
||||
assert report["success"] is True
|
||||
food = next(i for i in report["items"] if i["category"] == "식비")
|
||||
edu = next(i for i in report["items"] if i["category"] == "교육")
|
||||
assert food["status"] == "over"
|
||||
assert food["remaining"] == -50_000
|
||||
assert edu["status"] == "ok"
|
||||
assert edu["remaining"] == 100_000
|
||||
|
||||
investable = service.get_investable_cashflow_report("2026-06", minimum_cash_buffer=1_000_000, planned_extra_expense=500_000, db_path=db_path)
|
||||
assert investable["investable_cashflow"] == 1_250_000
|
||||
|
||||
|
||||
def test_duplicate_detection_and_soft_delete(tmp_path):
|
||||
db_path = str(tmp_path / "ledger.sqlite3")
|
||||
first = service.create_ledger_transaction("2026-06-10", "expense", 10_000, merchant="카페", account_id="card1", db_path=db_path)
|
||||
second = service.create_ledger_transaction("2026-06-10", "expense", 10_000, merchant="카페", account_id="card1", db_path=db_path)
|
||||
assert first["success"] is True
|
||||
assert second["success"] is False
|
||||
assert second["error_code"] == "duplicate_transaction"
|
||||
|
||||
tx_id = first["transaction"]["id"]
|
||||
deleted = service.delete_ledger_transaction(tx_id, db_path=db_path)
|
||||
assert deleted["success"] is True
|
||||
listed = service.list_ledger_transactions(include_deleted=False, db_path=db_path)
|
||||
assert listed["count"] == 0
|
||||
listed_all = service.list_ledger_transactions(include_deleted=True, db_path=db_path)
|
||||
assert listed_all["count"] == 1
|
||||
assert listed_all["transactions"][0]["status"] == "deleted"
|
||||
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from jarca_ledger_mcp import service
|
||||
from jarca_ledger_mcp.toss_securities_pdf import parse_toss_securities_pdf
|
||||
|
||||
SAMPLE = Path("/root/.hermes/cache/documents/doc_f4cfe8e0221f_거래내역서_20250618_20260617_1.pdf")
|
||||
|
||||
|
||||
@pytest.mark.skipif(not SAMPLE.exists(), reason="sample Toss Securities PDF is not available")
|
||||
def test_parse_toss_securities_pdf_sample():
|
||||
parsed = parse_toss_securities_pdf(str(SAMPLE))
|
||||
assert parsed["metadata"]["holder_name"] == "고진환"
|
||||
assert parsed["metadata"]["account_number_masked"] == "123-01-048829"
|
||||
assert parsed["page_count"] == 15
|
||||
assert len(parsed["transactions"]) == 207
|
||||
assert any(tx.action == "구매" and tx.code == "A012510" for tx in parsed["transactions"])
|
||||
assert any(tx.action == "이체입금(주식회사 케이뱅크)" for tx in parsed["transactions"])
|
||||
assert any(tx.section == "USD" and tx.action == "외화증권배당금입금" for tx in parsed["transactions"])
|
||||
|
||||
|
||||
@pytest.mark.skipif(not SAMPLE.exists(), reason="sample Toss Securities PDF is not available")
|
||||
def test_preview_and_import_toss_securities_pdf_sample(tmp_path):
|
||||
db_path = str(tmp_path / "ledger.sqlite3")
|
||||
preview = service.preview_toss_securities_pdf_import(str(SAMPLE), db_path=db_path)
|
||||
assert preview["success"] is True
|
||||
assert preview["row_count"] == 207
|
||||
assert preview["duplicate_count"] == 0
|
||||
|
||||
imported = service.import_toss_securities_pdf(str(SAMPLE), db_path=db_path)
|
||||
assert imported["success"] is True
|
||||
assert imported["inserted_count"] == 207
|
||||
assert imported["skipped_duplicate_count"] == 0
|
||||
|
||||
imported_again = service.import_toss_securities_pdf(str(SAMPLE), db_path=db_path)
|
||||
assert imported_again["success"] is True
|
||||
assert imported_again["inserted_count"] == 0
|
||||
assert imported_again["skipped_duplicate_count"] == 207
|
||||
|
||||
txs = service.list_ledger_transactions(account_id=imported["account_id"], limit=300, db_path=db_path)
|
||||
assert txs["count"] == 207
|
||||
assert any(t["category"] == "수입" and t["subcategory"] == "배당" for t in txs["transactions"])
|
||||
assert any(t["category"] == "이체" and t["subcategory"] == "투자이동" for t in txs["transactions"])
|
||||
assert any(
|
||||
t["merchant"] == "토스증권 이체출금(국민은행) 조상문" and t["category"] == "이체" and t["subcategory"] == "확인필요"
|
||||
for t in txs["transactions"]
|
||||
)
|
||||
assert any(
|
||||
t["merchant"] == "토스증권 이체입금(국민은행) 고진환" and t["category"] == "이체" and t["subcategory"] == "투자이동"
|
||||
for t in txs["transactions"]
|
||||
)
|
||||
Reference in New Issue
Block a user