Initial Jarca Ledger MCP implementation

This commit is contained in:
Jarca
2026-06-26 22:15:15 +09:00
commit d8e36f6aa8
22 changed files with 7152 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
# Optional. Defaults to ~/.local/share/jarca/ledger.sqlite3
JARCA_LEDGER_DB_PATH=
+18
View File
@@ -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
+77
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
"""Jarca Ledger MCP server."""
__version__ = "0.1.0"
+247
View File
@@ -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()
+250
View File
@@ -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),
}
+215
View File
@@ -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),
}
+216
View File
@@ -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
+227
View File
@@ -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),
}
+293
View File
@@ -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),
}
+36
View File
@@ -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*"]
+51
View File
@@ -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
+105
View File
@@ -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
+66
View File
@@ -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
+57
View File
@@ -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
+69
View File
@@ -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"] == "교육"
+137
View File
@@ -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"
+54
View File
@@ -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"]
)
Generated
+1612
View File
File diff suppressed because it is too large Load Diff