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
+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),
}