from __future__ import annotations from .shared import ( Any, Path, csv, db, fail, json, make_dedupe_key, new_id, normalize_tags, now_iso, ok, parse_kb_bank_pdf, parse_seoulpay_xlsx, parse_toss_bank_pdf, parse_toss_securities_pdf, row_to_dict, seoulpay_transaction_preview, toss_bank_transaction_preview, toss_transaction_preview, transaction_preview, ) def preview_ledger_csv_import(file_path: str, source: str = "generic", db_path: str | None = None) -> dict[str, Any]: path=Path(file_path).expanduser() if not path.exists(): return fail("not_found", f"CSV file not found: {path}") with path.open("r", encoding="utf-8-sig", newline="") as f: rows=list(csv.DictReader(f)) preview=[] for r in rows[:20]: preview.append(r) return ok(source=source, filename=str(path), row_count=len(rows), preview=preview, required_columns=["transaction_date/date", "amount", "type optional", "category optional", "merchant/description optional"]) def preview_kb_bank_pdf_import(file_path: str, password: str, preview_limit: int = 20, db_path: str | None = None) -> dict[str, Any]: try: parsed = parse_kb_bank_pdf(file_path, password) except FileNotFoundError as e: return fail("not_found", str(e)) except ValueError as e: return fail("pdf_password_error", str(e)) except Exception as e: return fail("pdf_parse_error", str(e)) transactions = parsed["transactions"] validation = parsed["validation"] source_hash = parsed["source_hash"] duplicate_count = 0 with db(db_path) as conn: for tx in transactions: dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, tx.counterparty, "", tx.external_id(source_hash)) found = conn.execute("SELECT 1 FROM transactions WHERE dedupe_key=?", (dedupe_key,)).fetchone() duplicate_count += 1 if found else 0 preview = [transaction_preview(tx, source_hash) for tx in transactions[: max(1, min(int(preview_limit), 100))]] return ok( source="kb_bank_pdf", file_path=parsed["file_path"], source_hash=source_hash, page_count=parsed["page_count"], metadata=parsed["metadata"], row_count=len(transactions), duplicate_count=duplicate_count, validation=validation, preview=preview, ) def _find_or_create_kb_account(conn: Any, metadata: dict[str, Any], account_id: str = "") -> str: if account_id: row = conn.execute("SELECT id FROM accounts WHERE id=?", (account_id,)).fetchone() if row: return account_id raise ValueError(f"account_id not found: {account_id}") account_number_masked = metadata.get("account_number_masked") or "" row = None if account_number_masked: row = conn.execute( "SELECT id FROM accounts WHERE institution=? AND account_number_masked=? AND active=1 ORDER BY created_at LIMIT 1", ("KB국민은행", account_number_masked), ).fetchone() if row: return row["id"] ts = now_iso() new_account_id = new_id("acct") account_name = metadata.get("account_name") or "KB국민은행 계좌" display_name = f"KB국민은행 {account_name} {account_number_masked}".strip() conn.execute( """INSERT INTO accounts(id,name,type,institution,currency,active,memo,account_number_masked,holder_name,last_balance,last_synced_at,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( new_account_id, display_name, "bank", "KB국민은행", "KRW", 1, "국민은행 거래내역 PDF import로 자동 생성", account_number_masked, metadata.get("holder_name") or "", metadata.get("final_balance"), ts, ts, ts, ), ) return new_account_id def import_kb_bank_pdf(file_path: str, password: str, account_id: str = "", skip_duplicates: bool = True, db_path: str | None = None, skip_validation: bool = False) -> dict[str, Any]: try: parsed = parse_kb_bank_pdf(file_path, password) except FileNotFoundError as e: return fail("not_found", str(e)) except ValueError as e: return fail("pdf_password_error", str(e)) except Exception as e: return fail("pdf_parse_error", str(e)) transactions = parsed["transactions"] validation = parsed["validation"] if not validation.get("ok") and not skip_validation: return fail("balance_validation_failed", "KB Bank PDF balance flow validation failed", validation=validation) source_hash = parsed["source_hash"] metadata = parsed["metadata"] ts = now_iso() batch_id = new_id("imp") inserted = 0 skipped_duplicates = 0 inserted_ids: list[str] = [] try: with db(db_path) as conn: linked_account_id = _find_or_create_kb_account(conn, metadata, account_id) conn.execute( """INSERT INTO import_batches(id,source,filename,imported_at,row_count,status,memo) VALUES(?,?,?,?,?,?,?)""", ( batch_id, "kb_bank_pdf", parsed["file_path"], ts, 0, "active", json.dumps( { "source_hash": source_hash, "page_count": parsed["page_count"], "metadata": metadata, "validation": validation, "parsed_row_count": len(transactions), }, ensure_ascii=False, ), ), ) for tx in transactions: tx_id = new_id("tx") external_id = tx.external_id(source_hash) dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, tx.counterparty, linked_account_id, external_id) payload = tx.raw_payload() description = f"국민은행 PDF import; 적요={tx.raw_summary}; 거래점={tx.branch}; 잔액={tx.balance_after}" try: conn.execute( """INSERT INTO transactions(id,transaction_date,posted_date,type,amount,currency,category,subcategory,merchant,description,account_id,payment_method,person,project,tags,is_fixed,source,import_batch_id,external_id,dedupe_key,memo,transaction_datetime,counterparty,balance_after,branch,raw_summary,raw_payload,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( tx_id, tx.transaction_date, "", tx.type, tx.amount, "KRW", "", "", tx.counterparty, description, linked_account_id, tx.raw_summary, "", "", normalize_tags(["kb_bank_pdf", tx.raw_summary] if tx.raw_summary else ["kb_bank_pdf"]), 0, "kb_bank_pdf", batch_id, external_id, dedupe_key, tx.remittance_memo, tx.transaction_datetime, tx.counterparty, tx.balance_after, tx.branch, tx.raw_summary, payload, ts, ts, ), ) inserted += 1 inserted_ids.append(tx_id) except Exception as e: if "UNIQUE constraint failed" in str(e) and skip_duplicates: skipped_duplicates += 1 continue raise conn.execute("UPDATE import_batches SET row_count=? WHERE id=?", (inserted, batch_id)) if transactions: conn.execute( "UPDATE accounts SET last_balance=?, last_synced_at=?, updated_at=? WHERE id=?", (transactions[-1].balance_after, ts, ts, linked_account_id), ) except ValueError as e: return fail("validation_error", str(e)) except Exception as e: return fail("db_error", str(e)) return ok( import_batch_id=batch_id, account_id=linked_account_id, source="kb_bank_pdf", parsed_row_count=len(transactions), inserted_count=inserted, skipped_duplicate_count=skipped_duplicates, inserted_transaction_ids=inserted_ids[:20], validation=validation, metadata=metadata, ) def _find_or_create_seoulpay_account(conn: Any, metadata: dict[str, Any], account_id: str = "") -> str: if account_id: row = conn.execute("SELECT id FROM accounts WHERE id=?", (account_id,)).fetchone() if row: return account_id raise ValueError(f"account_id not found: {account_id}") holder_name = metadata.get("holder_name") or "" row = conn.execute( "SELECT id FROM accounts WHERE institution=? AND name=? AND active=1 ORDER BY created_at LIMIT 1", ("서울페이", f"서울페이 이용내역 {holder_name}".strip()), ).fetchone() if row: return row["id"] ts = now_iso() new_account_id = new_id("acct") display_name = f"서울페이 이용내역 {holder_name}".strip() conn.execute( """INSERT INTO accounts(id,name,type,institution,currency,active,memo,account_number_masked,holder_name,last_balance,last_synced_at,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( new_account_id, display_name, "cash", "서울페이", "KRW", 1, "서울페이 이용내역 XLSX import로 자동 생성", "", holder_name, None, ts, ts, ts, ), ) return new_account_id def _classify_seoulpay_transaction(tx: Any) -> tuple[str, str, str, bool]: merchant = (tx.merchant or "").strip() text = f"{merchant} {tx.transaction_kind} {tx.payment_kind}" if tx.type == "refund": return "수입", "환급", merchant, False if "땡겨요" in text or "배달" in text: return "식비", "배달", merchant, False if any(keyword in text for keyword in ("교습소", "학원", "바이올린", "피아노")): return "교육", "학원", merchant, False if any(keyword in text for keyword in ("김영모", "베이커리", "제과", "카페", "커피")): return "식비", "카페", merchant, False if "온라인" in text: return "쇼핑", "온라인", merchant, False return "쇼핑", "생활용품", merchant, False def _find_or_create_toss_bank_account(conn: Any, metadata: dict[str, Any], account_id: str = "") -> str: if account_id: row = conn.execute("SELECT id FROM accounts WHERE id=?", (account_id,)).fetchone() if row: return account_id raise ValueError(f"account_id not found: {account_id}") account_number_masked = metadata.get("account_number_masked") or "" row = None if account_number_masked: row = conn.execute( "SELECT id FROM accounts WHERE institution=? AND account_number_masked=? AND active=1 ORDER BY created_at LIMIT 1", ("토스뱅크", account_number_masked), ).fetchone() if row: return row["id"] ts = now_iso() new_account_id = new_id("acct") account_name = metadata.get("account_name") or "토스뱅크 계좌" display_name = f"토스뱅크 {account_name} {account_number_masked}".strip() conn.execute( """INSERT INTO accounts(id,name,type,institution,currency,active,memo,account_number_masked,holder_name,last_balance,last_synced_at,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( new_account_id, display_name, "bank", "토스뱅크", "KRW", 1, "토스뱅크 거래내역 PDF import로 자동 생성", account_number_masked, metadata.get("holder_name") or "", metadata.get("final_balance"), ts, ts, ts, ), ) return new_account_id def _classify_toss_bank_transaction(tx: Any, owner_name: str = "") -> tuple[str, str, str, bool]: action = (tx.action or "").strip() counterparty = (tx.counterparty or "").strip() owner_name = (owner_name or "").strip() if "이자" in action: return "수입", "이자", counterparty, False if "캐시백" in action: return "수입", "캐시백", counterparty, False if "체크카드결제" in action: return "식비", "카드결제", counterparty, False transfer_like = { "이체", "내계좌간자동이체", "오픈뱅킹", "펌뱅킹출금", "펌뱅킹입금", "적금 개설", "출금", "입금", "ATM출금", } if any(keyword in action for keyword in transfer_like): if counterparty and owner_name and counterparty != owner_name: return "이체", "확인필요", counterparty, False return "이체", "본인계좌이동", counterparty, False if "캐시백" in action: return "수입", "캐시백", counterparty, False return "이체", "확인필요", counterparty, False def preview_seoulpay_xlsx_import(file_path: str, password: str = "", preview_limit: int = 20, db_path: str | None = None) -> dict[str, Any]: try: parsed = parse_seoulpay_xlsx(file_path, password=password) except FileNotFoundError as e: return fail("not_found", str(e)) except ValueError as e: return fail("xlsx_parse_error", str(e)) except Exception as e: return fail("xlsx_parse_error", str(e)) transactions = parsed["transactions"] source_hash = parsed["source_hash"] duplicate_count = 0 by_category: dict[str, dict[str, int]] = {} with db(db_path) as conn: linked_account_id = "" metadata = parsed["metadata"] holder_name = metadata.get("holder_name") or "" if holder_name: row = conn.execute( "SELECT id FROM accounts WHERE institution=? AND name=? AND active=1 ORDER BY created_at LIMIT 1", ("서울페이", f"서울페이 이용내역 {holder_name}".strip()), ).fetchone() linked_account_id = row["id"] if row else "" for tx in transactions: category, subcategory, _, _ = _classify_seoulpay_transaction(tx) key = f"{category}/{subcategory}" item = by_category.setdefault(key, {"count": 0, "amount": 0}) item["count"] += 1 item["amount"] += int(tx.amount) merchant = f"서울페이 {tx.merchant}".strip() dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, merchant, linked_account_id, tx.external_id(source_hash)) found = conn.execute("SELECT 1 FROM transactions WHERE dedupe_key=?", (dedupe_key,)).fetchone() duplicate_count += 1 if found else 0 preview = [seoulpay_transaction_preview(tx, source_hash) for tx in transactions[: max(1, min(int(preview_limit), 100))]] return ok( source="seoulpay_xlsx", file_path=parsed["file_path"], source_hash=source_hash, sheet_name=parsed["sheet_name"], metadata=parsed["metadata"], row_count=len(transactions), duplicate_count=duplicate_count, validation=parsed["validation"], by_category=by_category, preview=preview, ) def import_seoulpay_xlsx(file_path: str, password: str = "", account_id: str = "", skip_duplicates: bool = True, db_path: str | None = None) -> dict[str, Any]: try: parsed = parse_seoulpay_xlsx(file_path, password=password) except FileNotFoundError as e: return fail("not_found", str(e)) except ValueError as e: return fail("xlsx_parse_error", str(e)) except Exception as e: return fail("xlsx_parse_error", str(e)) transactions = parsed["transactions"] validation = parsed["validation"] if not validation.get("ok"): return fail("xlsx_parse_error", "SeoulPay XLSX parsing produced invalid transactions", validation=validation) source_hash = parsed["source_hash"] metadata = parsed["metadata"] ts = now_iso() batch_id = new_id("imp") inserted = 0 skipped_duplicates = 0 inserted_ids: list[str] = [] try: with db(db_path) as conn: linked_account_id = _find_or_create_seoulpay_account(conn, metadata, account_id) conn.execute( """INSERT INTO import_batches(id,source,filename,imported_at,row_count,status,memo) VALUES(?,?,?,?,?,?,?)""", ( batch_id, "seoulpay_xlsx", parsed["file_path"], ts, 0, "active", json.dumps( { "source_hash": source_hash, "sheet_name": parsed["sheet_name"], "metadata": metadata, "validation": validation, "parsed_row_count": len(transactions), }, ensure_ascii=False, ), ), ) for tx in transactions: tx_id = new_id("tx") external_id = tx.external_id(source_hash) merchant = f"서울페이 {tx.merchant}".strip() category, subcategory, person, is_fixed = _classify_seoulpay_transaction(tx) dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, merchant, linked_account_id, external_id) description = f"서울페이 XLSX import; 거래유형={tx.transaction_kind}; 결제구분={tx.payment_kind}; 거래번호={tx.transaction_no}; 가맹점ID={tx.merchant_id}" try: conn.execute( """INSERT INTO transactions(id,transaction_date,posted_date,type,amount,currency,category,subcategory,merchant,description,account_id,payment_method,person,project,tags,is_fixed,source,import_batch_id,external_id,dedupe_key,memo,transaction_datetime,counterparty,balance_after,branch,raw_summary,raw_payload,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( tx_id, tx.transaction_date, "", tx.type, tx.amount, "KRW", category, subcategory, merchant, description, linked_account_id, tx.payment_kind, person, "", normalize_tags(["seoulpay_xlsx", tx.channel, tx.payment_kind, tx.transaction_kind]), int(is_fixed), "seoulpay_xlsx", batch_id, external_id, dedupe_key, tx.payment_kind, tx.transaction_datetime, tx.merchant, None, tx.channel, tx.transaction_kind, tx.raw_payload(), ts, ts, ), ) inserted += 1 inserted_ids.append(tx_id) except Exception as e: if "UNIQUE constraint failed" in str(e) and skip_duplicates: skipped_duplicates += 1 continue raise conn.execute("UPDATE import_batches SET row_count=? WHERE id=?", (inserted, batch_id)) except ValueError as e: return fail("validation_error", str(e)) except Exception as e: return fail("db_error", str(e)) return ok( import_batch_id=batch_id, account_id=linked_account_id, source="seoulpay_xlsx", parsed_row_count=len(transactions), inserted_count=inserted, skipped_duplicate_count=skipped_duplicates, inserted_transaction_ids=inserted_ids[:20], validation=validation, metadata=metadata, ) def preview_toss_bank_pdf_import(file_path: str, preview_limit: int = 20, db_path: str | None = None) -> dict[str, Any]: try: parsed = parse_toss_bank_pdf(file_path) except FileNotFoundError as e: return fail("not_found", str(e)) except ValueError as e: return fail("pdf_parse_error", str(e)) except Exception as e: return fail("pdf_parse_error", str(e)) transactions = parsed["transactions"] source_hash = parsed["source_hash"] duplicate_count = 0 with db(db_path) as conn: metadata = parsed["metadata"] account_number_masked = metadata.get("account_number_masked") or "" linked_account_id = "" if account_number_masked: row = conn.execute( "SELECT id FROM accounts WHERE institution=? AND account_number_masked=? AND active=1 ORDER BY created_at LIMIT 1", ("토스뱅크", account_number_masked), ).fetchone() linked_account_id = row["id"] if row else "" for tx in transactions: merchant = f"토스뱅크 {tx.action}" + (f" {tx.counterparty}" if tx.counterparty else "") dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, merchant, linked_account_id, tx.external_id(source_hash)) found = conn.execute("SELECT 1 FROM transactions WHERE dedupe_key=?", (dedupe_key,)).fetchone() duplicate_count += 1 if found else 0 preview = [toss_bank_transaction_preview(tx, source_hash) for tx in transactions[: max(1, min(int(preview_limit), 100))]] by_action: dict[str, dict[str, int]] = {} for tx in transactions: item = by_action.setdefault(tx.action, {"count": 0, "amount": 0}) item["count"] += 1 item["amount"] += int(tx.amount) return ok( source="toss_bank_pdf", file_path=parsed["file_path"], source_hash=source_hash, page_count=parsed["page_count"], metadata=parsed["metadata"], row_count=len(transactions), duplicate_count=duplicate_count, validation=parsed["validation"], by_action=by_action, preview=preview, ) def import_toss_bank_pdf(file_path: str, account_id: str = "", skip_duplicates: bool = True, db_path: str | None = None) -> dict[str, Any]: try: parsed = parse_toss_bank_pdf(file_path) except FileNotFoundError as e: return fail("not_found", str(e)) except ValueError as e: return fail("pdf_parse_error", str(e)) except Exception as e: return fail("pdf_parse_error", str(e)) transactions = parsed["transactions"] validation = parsed["validation"] if not validation.get("ok"): return fail("pdf_parse_error", "Toss Bank PDF parsing produced no valid transactions", validation=validation) source_hash = parsed["source_hash"] metadata = parsed["metadata"] ts = now_iso() batch_id = new_id("imp") inserted = 0 skipped_duplicates = 0 inserted_ids: list[str] = [] try: with db(db_path) as conn: linked_account_id = _find_or_create_toss_bank_account(conn, metadata, account_id) conn.execute( """INSERT INTO import_batches(id,source,filename,imported_at,row_count,status,memo) VALUES(?,?,?,?,?,?,?)""", ( batch_id, "toss_bank_pdf", parsed["file_path"], ts, 0, "active", json.dumps( { "source_hash": source_hash, "page_count": parsed["page_count"], "metadata": metadata, "validation": validation, "parsed_row_count": len(transactions), }, ensure_ascii=False, ), ), ) for tx in transactions: tx_id = new_id("tx") external_id = tx.external_id(source_hash) merchant = f"토스뱅크 {tx.action}" + (f" {tx.counterparty}" if tx.counterparty else "") category, subcategory, person, is_fixed = _classify_toss_bank_transaction(tx, metadata.get("holder_name") or "") if category == "이체": tx_type = "transfer" else: tx_type = tx.type dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, merchant, linked_account_id, external_id) description = f"토스뱅크 PDF import; 구분={tx.action}; 상대={tx.counterparty}; 잔액={tx.balance_after}" payload = tx.raw_payload() try: conn.execute( """INSERT INTO transactions(id,transaction_date,posted_date,type,amount,currency,category,subcategory,merchant,description,account_id,payment_method,person,project,tags,is_fixed,source,import_batch_id,external_id,dedupe_key,memo,transaction_datetime,counterparty,balance_after,branch,raw_summary,raw_payload,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( tx_id, tx.transaction_date, "", tx_type, tx.amount, "KRW", category, subcategory, merchant, description, linked_account_id, tx.action, person, "", normalize_tags(["toss_bank_pdf", tx.action] + ([tx.counterparty] if tx.counterparty else [])), int(is_fixed), "toss_bank_pdf", batch_id, external_id, dedupe_key, tx.action, tx.transaction_datetime, tx.counterparty, tx.balance_after, "토스뱅크", tx.raw_summary, payload, ts, ts, ), ) inserted += 1 inserted_ids.append(tx_id) except Exception as e: if "UNIQUE constraint failed" in str(e) and skip_duplicates: skipped_duplicates += 1 continue raise conn.execute("UPDATE import_batches SET row_count=? WHERE id=?", (inserted, batch_id)) if transactions: conn.execute( "UPDATE accounts SET last_balance=?, last_synced_at=?, updated_at=? WHERE id=?", (transactions[-1].balance_after, ts, ts, linked_account_id), ) except ValueError as e: return fail("validation_error", str(e)) except Exception as e: return fail("db_error", str(e)) return ok( import_batch_id=batch_id, account_id=linked_account_id, source="toss_bank_pdf", parsed_row_count=len(transactions), inserted_count=inserted, skipped_duplicate_count=skipped_duplicates, inserted_transaction_ids=inserted_ids[:20], validation=validation, metadata=metadata, ) def _find_or_create_toss_securities_account(conn: Any, metadata: dict[str, Any], account_id: str = "") -> str: if account_id: row = conn.execute("SELECT id FROM accounts WHERE id=?", (account_id,)).fetchone() if row: return account_id raise ValueError(f"account_id not found: {account_id}") account_number_masked = metadata.get("account_number_masked") or "" row = None if account_number_masked: row = conn.execute( "SELECT id FROM accounts WHERE institution=? AND account_number_masked=? AND active=1 ORDER BY created_at LIMIT 1", ("토스증권", account_number_masked), ).fetchone() if row: return row["id"] ts = now_iso() new_account_id = new_id("acct") display_name = f"토스증권 {metadata.get('account_name') or '계좌'} {account_number_masked}".strip() conn.execute( """INSERT INTO accounts(id,name,type,institution,currency,active,memo,account_number_masked,holder_name,last_balance,last_synced_at,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( new_account_id, display_name, "investment", "토스증권", "KRW", 1, "토스증권 거래내역 PDF import로 자동 생성", account_number_masked, metadata.get("holder_name") or "", None, ts, ts, ts, ), ) return new_account_id def _classify_toss_transaction(tx: Any, owner_name: str = "") -> tuple[str, str, str, bool]: action = tx.action counterparty = (tx.name or "").strip() owner_name = (owner_name or "").strip() if "배당" in action: return "수입", "배당", counterparty, False if "이자" in action: return "수입", "이자", counterparty, False if "환급" in action: return "수입", "환급", counterparty, False if "이체" in action: # 토스증권 입출금에서 상대가 계좌 소유자와 다르면 본인 투자 이동으로 확정하지 않는다. # 예: 이체출금(국민은행) 조상문 -> 외부/타인 송금 가능성이 있어 확인필요. if counterparty and owner_name and counterparty != owner_name: return "이체", "확인필요", counterparty, False return "이체", "투자이동", counterparty, False return "이체", "투자이동", counterparty, False def preview_toss_securities_pdf_import(file_path: str, preview_limit: int = 20, db_path: str | None = None) -> dict[str, Any]: try: parsed = parse_toss_securities_pdf(file_path) except FileNotFoundError as e: return fail("not_found", str(e)) except ValueError as e: return fail("pdf_parse_error", str(e)) except Exception as e: return fail("pdf_parse_error", str(e)) transactions = parsed["transactions"] source_hash = parsed["source_hash"] duplicate_count = 0 with db(db_path) as conn: metadata = parsed["metadata"] account_number_masked = metadata.get("account_number_masked") or "" linked_account_id = "" if account_number_masked: row = conn.execute( "SELECT id FROM accounts WHERE institution=? AND account_number_masked=? AND active=1 ORDER BY created_at LIMIT 1", ("토스증권", account_number_masked), ).fetchone() linked_account_id = row["id"] if row else "" for tx in transactions: merchant = f"토스증권 {tx.action}" + (f" {tx.name}" if tx.name else "") dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, merchant, linked_account_id, tx.external_id(source_hash)) found = conn.execute("SELECT 1 FROM transactions WHERE dedupe_key=?", (dedupe_key,)).fetchone() duplicate_count += 1 if found else 0 preview = [toss_transaction_preview(tx, source_hash) for tx in transactions[: max(1, min(int(preview_limit), 100))]] by_action: dict[str, dict[str, int]] = {} for tx in transactions: item = by_action.setdefault(tx.action, {"count": 0, "amount": 0}) item["count"] += 1 item["amount"] += int(tx.amount) return ok( source="toss_securities_pdf", file_path=parsed["file_path"], source_hash=source_hash, page_count=parsed["page_count"], metadata=parsed["metadata"], row_count=len(transactions), duplicate_count=duplicate_count, validation=parsed["validation"], by_action=by_action, preview=preview, ) def import_toss_securities_pdf(file_path: str, account_id: str = "", skip_duplicates: bool = True, db_path: str | None = None) -> dict[str, Any]: try: parsed = parse_toss_securities_pdf(file_path) except FileNotFoundError as e: return fail("not_found", str(e)) except ValueError as e: return fail("pdf_parse_error", str(e)) except Exception as e: return fail("pdf_parse_error", str(e)) transactions = parsed["transactions"] validation = parsed["validation"] if not validation.get("ok"): return fail("pdf_parse_error", "Toss Securities PDF parsing produced no valid transactions", validation=validation) source_hash = parsed["source_hash"] metadata = parsed["metadata"] ts = now_iso() batch_id = new_id("imp") inserted = 0 skipped_duplicates = 0 inserted_ids: list[str] = [] try: with db(db_path) as conn: linked_account_id = _find_or_create_toss_securities_account(conn, metadata, account_id) conn.execute( """INSERT INTO import_batches(id,source,filename,imported_at,row_count,status,memo) VALUES(?,?,?,?,?,?,?)""", ( batch_id, "toss_securities_pdf", parsed["file_path"], ts, 0, "active", json.dumps( { "source_hash": source_hash, "page_count": parsed["page_count"], "metadata": metadata, "validation": validation, "parsed_row_count": len(transactions), }, ensure_ascii=False, ), ), ) for tx in transactions: tx_id = new_id("tx") external_id = tx.external_id(source_hash) merchant = f"토스증권 {tx.action}" + (f" {tx.name}" if tx.name else "") category, subcategory, person, is_fixed = _classify_toss_transaction(tx, metadata.get("holder_name") or "") dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, merchant, linked_account_id, external_id) description = f"토스증권 PDF import; 구분={tx.action}; 종목/상대={tx.name}; 수량={tx.quantity}; 단가={tx.unit_price}; 수수료={tx.fee}; 세금={tx.tax}; 잔액={tx.balance_after}" try: conn.execute( """INSERT INTO transactions(id,transaction_date,posted_date,type,amount,currency,category,subcategory,merchant,description,account_id,payment_method,person,project,tags,is_fixed,source,import_batch_id,external_id,dedupe_key,memo,transaction_datetime,counterparty,balance_after,branch,raw_summary,raw_payload,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( tx_id, tx.transaction_date, "", tx.type, tx.amount, "KRW", category, subcategory, merchant, description, linked_account_id, tx.action, person, "", normalize_tags(["toss_securities_pdf", tx.section, tx.action, tx.code] if tx.code else ["toss_securities_pdf", tx.section, tx.action]), int(is_fixed), "toss_securities_pdf", batch_id, external_id, dedupe_key, tx.code, tx.transaction_date, tx.name, tx.balance_after, "토스증권", tx.action, tx.raw_payload(), ts, ts, ), ) inserted += 1 inserted_ids.append(tx_id) except Exception as e: if "UNIQUE constraint failed" in str(e) and skip_duplicates: skipped_duplicates += 1 continue raise conn.execute("UPDATE import_batches SET row_count=? WHERE id=?", (inserted, batch_id)) if transactions: conn.execute( "UPDATE accounts SET last_balance=?, last_synced_at=?, updated_at=? WHERE id=?", (transactions[-1].balance_after, ts, ts, linked_account_id), ) except ValueError as e: return fail("validation_error", str(e)) except Exception as e: return fail("db_error", str(e)) return ok( import_batch_id=batch_id, account_id=linked_account_id, source="toss_securities_pdf", parsed_row_count=len(transactions), inserted_count=inserted, skipped_duplicate_count=skipped_duplicates, inserted_transaction_ids=inserted_ids[:20], validation=validation, metadata=metadata, ) def list_ledger_import_batches(source: str = "", include_rolled_back: bool = True, limit: int = 50, db_path: str | None = None) -> dict[str, Any]: clauses=[]; params=[] if source: clauses.append("source=?"); params.append(source) if not include_rolled_back: clauses.append("status='active'") where = " WHERE " + " AND ".join(clauses) if clauses else "" params.append(max(1, min(int(limit), 500))) with db(db_path) as conn: rows=conn.execute(f"SELECT * FROM import_batches{where} ORDER BY imported_at DESC LIMIT ?", params).fetchall() return ok(import_batches=[row_to_dict(r) for r in rows], count=len(rows)) def rollback_ledger_import_batch(import_batch_id: str, hard_delete: bool = False, db_path: str | None = None) -> dict[str, Any]: with db(db_path) as conn: batch = conn.execute("SELECT * FROM import_batches WHERE id=?", (import_batch_id,)).fetchone() if not batch: return fail("not_found", "import batch not found") if hard_delete: cur = conn.execute("DELETE FROM transactions WHERE import_batch_id=?", (import_batch_id,)) else: cur = conn.execute("UPDATE transactions SET status='deleted', updated_at=? WHERE import_batch_id=? AND status='active'", (now_iso(), import_batch_id)) conn.execute("UPDATE import_batches SET status='rolled_back' WHERE id=?", (import_batch_id,)) return ok(import_batch_id=import_batch_id, affected_transactions=cur.rowcount, hard_delete=hard_delete)