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