Files
jarca-ledger-mcp/jarca_ledger_mcp/toss_bank_pdf.py
genichin d9182f28f2 feat: enforce import provenance exposure policy (#2)
Merge BAT-017 / REQ-021 implementation after delivery-runner implementation, independent QC, and passing GitHub Actions CI.
2026-07-09 18:44:19 +09:00

229 lines
6.7 KiB
Python

from __future__ import annotations
import hashlib
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import fitz
from .provenance_policy import allowlisted_json_payload
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 allowlisted_json_payload(
{
"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,
},
allowed_keys=("row_index",),
)
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),
}