Files
jarca-ledger-mcp/jarca_ledger_mcp/application/import_transfer_services.py
T

1514 lines
68 KiB
Python

from __future__ import annotations
from .shared import *
from .shared import _append_memo_tag, _memo_has_tag, _normalize_counterparty_for_match, _party_match_score
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 _build_cross_transfer_index(transactions: list[dict[str, Any]]) -> dict[tuple[str, int, str], list[dict[str, Any]]]:
index: dict[tuple[str, int, str], list[dict[str, Any]]] = {}
for tx in transactions:
if tx.get("status") != "active":
continue
key = (tx["transaction_date"], int(tx["amount"]), tx["type"])
index.setdefault(key, []).append(tx)
return index
def _fetch_transfer_candidates(db_conn: Any, source: str) -> list[dict[str, Any]]:
rows = db_conn.execute(
"""
SELECT t.id, t.transaction_datetime, t.transaction_date, t.type, t.amount, t.category, t.subcategory,
t.counterparty, t.memo, t.person, t.account_id, t.payment_method, t.status,
a.holder_name, t.merchant
FROM transactions t
LEFT JOIN accounts a ON a.id = t.account_id
WHERE t.source=?
AND t.type IN ('income','expense')
AND (
t.category='이체'
OR t.subcategory IN ('확인필요', '본인계좌이동', '투자이동')
OR t.payment_method IN ('내계좌간자동이전', '내계좌간자동이체', '오픈뱅킹', '펌뱅킹출금', '펌뱅킹입금', '입금', '출금', 'ATM출금', '적금개설')
OR t.merchant LIKE '%이체%'
)
""",
(source,),
).fetchall()
return [dict(r) for r in rows]
def _parse_tx_datetime(value: str | None) -> datetime | None:
if not value:
return None
text = value.strip()
for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"):
try:
return datetime.strptime(text, fmt)
except ValueError:
continue
try:
return datetime.fromisoformat(text)
except ValueError:
return None
def _self_transfer_direction_score(source_type: str, candidate_type: str) -> int:
if source_type == "expense":
if candidate_type == "income":
return 3
if candidate_type == "transfer":
return 2
elif source_type == "income":
if candidate_type == "expense":
return 3
if candidate_type == "transfer":
return 2
elif source_type == "transfer":
if candidate_type in {"income", "expense"}:
return 2
if candidate_type == "transfer":
return 1
return 0
def _normalize_cancel_lookup_text(value: str) -> str:
if not value:
return ""
text = value.lower().strip()
replacements = [
"kb체크취소", "승인취소", "취소", "환불", "환급", "refund", "reversal",
"카드입금", "카드환급", "매출취소", "입금", "출금", "체크카드", "신용카드",
]
for token in replacements:
text = text.replace(token, " ")
text = re.sub(r"\s+", " ", text)
return _normalize_counterparty_for_match(text)
def _cancel_channel(value: str) -> str:
text = (value or "").lower()
if any(token in text for token in ("카드", "check", "credit")):
return "card"
if any(token in text for token in ("이체", "오픈뱅킹", "출금", "입금", "bank")):
return "bank"
return "other"
def _is_cancel_like_transaction(tx: dict[str, Any]) -> bool:
if str(tx.get("type") or "") == "refund":
return True
combined = " ".join(
str(tx.get(key) or "") for key in ("merchant", "raw_summary", "payment_method", "memo", "description")
).lower()
keywords = ("취소", "환불", "환급", "refund", "reversal", "카드입금", "매출취소")
return str(tx.get("type") or "") == "income" and any(token in combined for token in keywords)
def find_cancel_original_candidates(transaction_id: str, limit: int = 20, lookback_days: int = 365, db_path: str | None = None) -> dict[str, Any]:
limit = max(1, min(int(limit), 50))
lookback_days = max(1, min(int(lookback_days), 730))
with db(db_path) as conn:
source_row = conn.execute(
"""
SELECT t.*, a.name AS account_name, a.institution, a.holder_name
FROM transactions t
LEFT JOIN accounts a ON a.id = t.account_id
WHERE t.id=?
""",
(transaction_id,),
).fetchone()
if not source_row:
return fail("not_found", f"transaction not found: {transaction_id}")
source = row_to_dict(source_row)
explicit_cancel_like = _is_cancel_like_transaction(source)
source_type = str(source.get("type") or "")
if source_type not in {"income", "refund"} and not explicit_cancel_like:
return fail("not_cancel_like", "cancel/original lookup is intended for 취소·환급성 거래")
source_date = normalize_date(source.get("transaction_date"), default_today=False)
source_dt = _parse_tx_datetime(source.get("transaction_datetime"))
source_day = date.fromisoformat(source_date)
start_date = date.fromordinal(source_day.toordinal() - lookback_days).isoformat()
source_text = " ".join(str(source.get(key) or "") for key in ("merchant", "counterparty", "raw_summary", "payment_method"))
source_norm_text = _normalize_cancel_lookup_text(source_text)
source_channel = _cancel_channel(str(source.get("payment_method") or "") + " " + str(source.get("raw_summary") or "") + " " + str(source.get("merchant") or ""))
candidate_rows = conn.execute(
"""
SELECT t.*, a.name AS account_name, a.institution, a.holder_name
FROM transactions t
LEFT JOIN accounts a ON a.id = t.account_id
WHERE t.status='active'
AND t.id<>?
AND t.amount=?
AND t.transaction_date BETWEEN ? AND ?
AND t.type IN ('expense', 'adjustment')
ORDER BY t.transaction_date DESC, t.transaction_datetime DESC, t.created_at DESC
""",
(transaction_id, int(source.get("amount") or 0), start_date, source_date),
).fetchall()
candidates: list[dict[str, Any]] = []
for row in candidate_rows:
candidate = row_to_dict(row)
candidate_date = normalize_date(candidate.get("transaction_date"), default_today=False)
candidate_day = date.fromisoformat(candidate_date)
day_delta = (source_day - candidate_day).days
if day_delta < 0:
continue
candidate_dt = _parse_tx_datetime(candidate.get("transaction_datetime"))
time_delta_seconds = abs(int((source_dt - candidate_dt).total_seconds())) if source_dt and candidate_dt else None
same_account = bool(source.get("account_id") and source.get("account_id") == candidate.get("account_id"))
source_same = bool(source.get("source") and source.get("source") == candidate.get("source"))
candidate_text = " ".join(str(candidate.get(key) or "") for key in ("merchant", "counterparty", "raw_summary", "payment_method"))
candidate_norm_text = _normalize_cancel_lookup_text(candidate_text)
merchant_score = 0
if source_norm_text and candidate_norm_text:
if source_norm_text == candidate_norm_text:
merchant_score = 4
elif source_norm_text in candidate_norm_text or candidate_norm_text in source_norm_text:
merchant_score = 2
channel_score = 0
candidate_channel = _cancel_channel(str(candidate.get("payment_method") or "") + " " + str(candidate.get("raw_summary") or "") + " " + str(candidate.get("merchant") or ""))
if source_channel != "other" and source_channel == candidate_channel:
channel_score = 2
if not explicit_cancel_like and not (same_account and merchant_score >= 2 and channel_score >= 1):
continue
recency_score = 0
if day_delta == 0:
recency_score = 5
elif day_delta <= 7:
recency_score = 4
elif day_delta <= 30:
recency_score = 3
elif day_delta <= 90:
recency_score = 2
else:
recency_score = 1
time_score = 0
if time_delta_seconds is not None:
if time_delta_seconds <= 3600:
time_score = 2
elif time_delta_seconds <= 86400:
time_score = 1
score = merchant_score + channel_score + recency_score + time_score + (3 if same_account else 0) + (1 if source_same else 0)
reason_bits = [
f"same_account={int(same_account)}",
f"merchant={merchant_score}",
f"channel={channel_score}",
f"days={day_delta}",
]
if time_delta_seconds is not None:
reason_bits.append(f"time_delta={time_delta_seconds}s")
candidates.append({
"id": candidate.get("id"),
"transaction_date": candidate.get("transaction_date"),
"transaction_datetime": candidate.get("transaction_datetime"),
"source": candidate.get("source"),
"type": candidate.get("type"),
"amount": candidate.get("amount"),
"category": candidate.get("category"),
"subcategory": candidate.get("subcategory"),
"merchant": candidate.get("merchant"),
"raw_summary": candidate.get("raw_summary"),
"counterparty": candidate.get("counterparty"),
"account_id": candidate.get("account_id"),
"account_name": candidate.get("account_name"),
"institution": candidate.get("institution"),
"payment_method": candidate.get("payment_method"),
"memo": candidate.get("memo"),
"score": score,
"day_delta": day_delta,
"time_delta_seconds": time_delta_seconds,
"reason": ", ".join(reason_bits),
})
candidates.sort(key=lambda item: (-int(item.get("score", 0)), int(item.get("day_delta", 9999)), int(item.get("time_delta_seconds") or 99999999), str(item.get("transaction_datetime") or "")))
return ok(source_tx=source, count=len(candidates[:limit]), candidates=candidates[:limit], source="cancel_original_candidate_search")
def find_self_transfer_candidates(transaction_id: str, limit: int = 20, db_path: str | None = None) -> dict[str, Any]:
limit = max(1, min(int(limit), 50))
with db(db_path) as conn:
source_row = conn.execute(
"""
SELECT t.*, a.name AS account_name, a.institution, a.holder_name
FROM transactions t
LEFT JOIN accounts a ON a.id = t.account_id
WHERE t.id=?
""",
(transaction_id,),
).fetchone()
if not source_row:
return fail("not_found", f"transaction not found: {transaction_id}")
source = row_to_dict(source_row)
source_date = normalize_date(source.get("transaction_date"), default_today=False)
source_dt = _parse_tx_datetime(source.get("transaction_datetime"))
source_holder = _normalize_counterparty_for_match(source.get("holder_name", ""))
anchor_date = date.fromisoformat(source_date) if source_date else current_kst_date()
window_start = anchor_date.toordinal() - 1
window_end = anchor_date.toordinal() + 1
candidate_rows = conn.execute(
"""
SELECT t.*, a.name AS account_name, a.institution, a.holder_name
FROM transactions t
LEFT JOIN accounts a ON a.id = t.account_id
WHERE t.status='active'
AND t.id<>?
AND COALESCE(t.account_id, '')<>COALESCE(?, '')
AND t.amount=?
AND t.transaction_date BETWEEN ? AND ?
ORDER BY t.transaction_date, t.transaction_datetime, t.created_at
""",
(
transaction_id,
source.get("account_id", ""),
int(source.get("amount") or 0),
date.fromordinal(window_start).isoformat(),
date.fromordinal(window_end).isoformat(),
),
).fetchall()
candidates: list[dict[str, Any]] = []
for row in candidate_rows:
candidate = row_to_dict(row)
direction_score = _self_transfer_direction_score(str(source.get("type", "")), str(candidate.get("type", "")))
if direction_score == 0:
continue
candidate_dt = _parse_tx_datetime(candidate.get("transaction_datetime"))
day_delta = abs((date.fromisoformat(candidate.get("transaction_date", source_date)) - date.fromisoformat(source_date)).days)
time_delta_seconds = abs(int((candidate_dt - source_dt).total_seconds())) if candidate_dt and source_dt else None
same_owner = bool(source_holder and source_holder == _normalize_counterparty_for_match(candidate.get("holder_name", "")))
party_score = max(
_party_match_score(str(source.get("counterparty", "")), str(candidate.get("counterparty", ""))),
_party_match_score(str(source.get("counterparty", "")), str(candidate.get("holder_name", ""))),
_party_match_score(str(source.get("merchant", "")), str(candidate.get("counterparty", ""))),
_party_match_score(str(source.get("merchant", "")), str(candidate.get("holder_name", ""))),
)
source_gap_score = 1 if source.get("source") != candidate.get("source") else 0
day_score = 3 if day_delta == 0 else 1
time_score = 0
if time_delta_seconds is not None:
if time_delta_seconds <= 120:
time_score = 3
elif time_delta_seconds <= 3600:
time_score = 2
elif time_delta_seconds <= 86400:
time_score = 1
score = direction_score + party_score + source_gap_score + day_score + time_score + (3 if same_owner else 0)
reason_bits = [
f"direction={direction_score}",
f"party={party_score}",
f"same_owner={int(same_owner)}",
f"same_source={int(source.get('source') == candidate.get('source'))}",
f"day_delta={day_delta}",
]
if time_delta_seconds is not None:
reason_bits.append(f"time_delta={time_delta_seconds}s")
candidates.append(
{
"id": candidate.get("id"),
"transaction_date": candidate.get("transaction_date"),
"transaction_datetime": candidate.get("transaction_datetime"),
"source": candidate.get("source"),
"type": candidate.get("type"),
"amount": candidate.get("amount"),
"category": candidate.get("category"),
"subcategory": candidate.get("subcategory"),
"merchant": candidate.get("merchant"),
"counterparty": candidate.get("counterparty"),
"account_id": candidate.get("account_id"),
"account_name": candidate.get("account_name"),
"institution": candidate.get("institution"),
"holder_name": candidate.get("holder_name"),
"memo": candidate.get("memo"),
"score": score,
"same_owner": same_owner,
"day_delta": day_delta,
"time_delta_seconds": time_delta_seconds,
"reason": ", ".join(reason_bits),
}
)
candidates.sort(key=lambda item: (-int(item.get("score", 0)), int(item.get("day_delta", 99)), int(item.get("time_delta_seconds") or 999999), str(item.get("transaction_datetime") or "")))
return ok(source_tx=source, count=len(candidates[:limit]), candidates=candidates[:limit], source="self_transfer_candidate_search")
def link_self_transfer_pair(source_transaction_id: str, candidate_transaction_id: str, db_path: str | None = None) -> dict[str, Any]:
if not source_transaction_id or not candidate_transaction_id:
return fail("validation_error", "both transaction ids are required")
if source_transaction_id == candidate_transaction_id:
return fail("validation_error", "source and candidate transaction must differ")
preview = find_self_transfer_candidates(source_transaction_id, limit=50, db_path=db_path)
if not preview.get("success"):
return preview
candidates = preview.get("candidates") or []
candidate = next((item for item in candidates if item.get("id") == candidate_transaction_id), None)
if candidate is None:
return fail("candidate_not_found", f"candidate not found for source transaction: {candidate_transaction_id}")
link_tag = f"manual_self_transfer_link:{source_transaction_id}|{candidate_transaction_id}"
ts = now_iso()
with db(db_path) as conn:
source_row = conn.execute("SELECT * FROM transactions WHERE id=?", (source_transaction_id,)).fetchone()
candidate_row = conn.execute("SELECT * FROM transactions WHERE id=?", (candidate_transaction_id,)).fetchone()
if not source_row or not candidate_row:
return fail("not_found", "source or candidate transaction not found")
source_memo = _append_memo_tag(source_row["memo"], link_tag)
candidate_memo = _append_memo_tag(candidate_row["memo"], link_tag)
conn.execute(
"UPDATE transactions SET category='이체', subcategory='본인계좌이동', memo=?, updated_at=? WHERE id=?",
(source_memo, ts, source_transaction_id),
)
conn.execute(
"UPDATE transactions SET category='이체', subcategory='본인계좌이동', memo=?, updated_at=? WHERE id=?",
(candidate_memo, ts, candidate_transaction_id),
)
updated_source = conn.execute("SELECT * FROM transactions WHERE id=?", (source_transaction_id,)).fetchone()
updated_candidate = conn.execute("SELECT * FROM transactions WHERE id=?", (candidate_transaction_id,)).fetchone()
return ok(
source_transaction=row_to_dict(updated_source),
candidate_transaction=row_to_dict(updated_candidate),
link_tag=link_tag,
score=candidate.get("score"),
reason=candidate.get("reason"),
source="manual_self_transfer_link",
)
def link_cancel_to_original(cancel_transaction_id: str, original_transaction_id: str, db_path: str | None = None) -> dict[str, Any]:
if not cancel_transaction_id or not original_transaction_id:
return fail("validation_error", "both transaction ids are required")
if cancel_transaction_id == original_transaction_id:
return fail("validation_error", "cancel and original transaction must differ")
preview = find_cancel_original_candidates(cancel_transaction_id, limit=50, db_path=db_path)
if not preview.get("success"):
return preview
candidates = preview.get("candidates") or []
original = next((item for item in candidates if item.get("id") == original_transaction_id), None)
if original is None:
return fail("candidate_not_found", f"original transaction candidate not found: {original_transaction_id}")
link_tag = f"manual_cancel_link:cancel={cancel_transaction_id}|original={original_transaction_id}"
ts = now_iso()
with db(db_path) as conn:
cancel_row = conn.execute("SELECT * FROM transactions WHERE id=?", (cancel_transaction_id,)).fetchone()
original_row = conn.execute("SELECT * FROM transactions WHERE id=?", (original_transaction_id,)).fetchone()
if not cancel_row or not original_row:
return fail("not_found", "cancel or original transaction not found")
cancel_memo = _append_memo_tag(cancel_row["memo"], link_tag)
original_memo = _append_memo_tag(original_row["memo"], link_tag)
original_category = original_row["category"] or "수입"
original_subcategory = original_row["subcategory"] or "환급"
original_person = original_row["person"] or ""
conn.execute(
"UPDATE transactions SET type='refund', category=?, subcategory=?, person=?, memo=?, updated_at=? WHERE id=?",
(original_category, original_subcategory, original_person, cancel_memo, ts, cancel_transaction_id),
)
conn.execute(
"UPDATE transactions SET memo=?, updated_at=? WHERE id=?",
(original_memo, ts, original_transaction_id),
)
updated_cancel = conn.execute("SELECT * FROM transactions WHERE id=?", (cancel_transaction_id,)).fetchone()
updated_original = conn.execute("SELECT * FROM transactions WHERE id=?", (original_transaction_id,)).fetchone()
return ok(
cancel_transaction=row_to_dict(updated_cancel),
original_transaction=row_to_dict(updated_original),
link_tag=link_tag,
score=original.get("score"),
reason=original.get("reason"),
source="manual_cancel_link",
)
def _tag_memos(conn: Any, tx_id: str, tag: str) -> None:
existing = conn.execute("SELECT memo FROM transactions WHERE id=?", (tx_id,)).fetchone()
if not existing:
return
memo = _append_memo_tag(existing["memo"], tag)
conn.execute("UPDATE transactions SET memo=?, updated_at=? WHERE id=?", (memo, now_iso(), tx_id))
def _run_cross_account_transfer_match(limit: int = 500, dry_run: bool = True, db_path: str | None = None) -> dict[str, Any]:
limit = max(1, min(int(limit), 1000))
matched_pairs: list[dict[str, Any]] = []
review_items: list[dict[str, Any]] = []
matched_tx_ids: set[str] = set()
with db(db_path) as conn:
kb_rows = _fetch_transfer_candidates(conn, "kb_bank_pdf")
toss_rows = _fetch_transfer_candidates(conn, "toss_bank_pdf")
toss_index = _build_cross_transfer_index(toss_rows)
for kb in kb_rows:
if kb["status"] != "active":
continue
if _memo_has_tag(kb.get("memo", ""), CROSS_TRANSFER_MATCH_TAG):
continue
required_type = "income" if kb["type"] == "expense" else "expense"
candidates = [
t
for t in toss_index.get((kb["transaction_date"], int(kb["amount"]), required_type), [])
if t.get("status") == "active" and not _memo_has_tag(t.get("memo", ""), CROSS_TRANSFER_MATCH_TAG)
]
if not candidates:
review_items.append(
{
"source_tx_id": kb["id"],
"source": "kb_bank_pdf",
"target_tx_id": "",
"reason": "no_candidate_found",
"type": kb["type"],
"amount": kb["amount"],
"transaction_date": kb["transaction_date"],
"counterparty": kb["counterparty"],
}
)
continue
scored = []
for candidate in candidates:
score = _party_match_score(kb.get("counterparty", ""), candidate.get("counterparty", ""))
scored.append((score, candidate["transaction_datetime"], candidate))
scored.sort(key=lambda item: (item[0], item[1]), reverse=True)
best_score = scored[0][0]
top = [c for c in scored if c[0] == best_score]
if best_score == 0 or len(top) != 1:
review_items.append(
{
"source_tx_id": kb["id"],
"source": "kb_bank_pdf",
"target_tx_id": "",
"reason": "ambiguous_or_name_mismatch",
"candidate_count": len(candidates),
"best_name_score": best_score,
"type": kb["type"],
"amount": kb["amount"],
"transaction_date": kb["transaction_date"],
"counterparty": kb["counterparty"],
}
)
continue
toss = top[0][2]
if toss["id"] in matched_tx_ids:
review_items.append(
{
"source_tx_id": kb["id"],
"source": "kb_bank_pdf",
"target_tx_id": toss["id"],
"reason": "target_already_matched",
"type": kb["type"],
"amount": kb["amount"],
"transaction_date": kb["transaction_date"],
"counterparty": kb["counterparty"],
}
)
continue
kb_party = _normalize_counterparty_for_match(kb.get("counterparty", ""))
toss_party = _normalize_counterparty_for_match(toss.get("counterparty", ""))
owner_candidates = {
_normalize_counterparty_for_match(kb.get("holder_name", "")),
_normalize_counterparty_for_match(toss.get("holder_name", "")),
}
if (kb_party and kb_party in owner_candidates) or (toss_party and toss_party in owner_candidates):
subcategory = "투자이동"
else:
subcategory = "확인필요"
matched_pairs.append(
{
"kb_tx_id": kb["id"],
"toss_tx_id": toss["id"],
"transaction_date": kb["transaction_date"],
"amount": kb["amount"],
"counterparty_kb": kb["counterparty"],
"counterparty_toss": toss["counterparty"],
"subcategory": subcategory,
"name_score": best_score,
"reason": "paired_by_date_amount_party",
}
)
if not dry_run:
review_tag = f"{CROSS_TRANSFER_MATCH_TAG}kb={kb['id']}|toss={toss['id']}"
conn.execute(
"UPDATE transactions SET category='이체', subcategory=?, memo=?, updated_at=? WHERE id=?",
(subcategory, _append_memo_tag(kb.get("memo", ""), review_tag), now_iso(), kb["id"]),
)
conn.execute(
"UPDATE transactions SET category='이체', subcategory=?, memo=?, updated_at=? WHERE id=?",
(subcategory, _append_memo_tag(toss.get("memo", ""), review_tag), now_iso(), toss["id"]),
)
if not dry_run:
_tag_memos(conn, kb["id"], f"{CROSS_TRANSFER_REVIEW_TAG}paired")
_tag_memos(conn, toss["id"], f"{CROSS_TRANSFER_REVIEW_TAG}paired")
matched_tx_ids.add(kb["id"])
matched_tx_ids.add(toss["id"])
if len(matched_pairs) >= limit:
break
for review in review_items:
if review["source_tx_id"] in matched_tx_ids:
continue
if not dry_run:
_tag_memos(conn, review["source_tx_id"], f"{CROSS_TRANSFER_REVIEW_TAG}{review['reason']}")
return ok(
dry_run=dry_run,
matched_count=len(matched_pairs),
review_count=len(review_items),
matched_pairs=matched_pairs[:limit],
review_items=review_items[:limit],
source="kb_toss_cross_transfer_match",
)
def preview_cross_account_transfers(limit: int = 500, db_path: str | None = None) -> dict[str, Any]:
return _run_cross_account_transfer_match(limit=limit, dry_run=True, db_path=db_path)
def run_cross_account_transfer_match(limit: int = 500, db_path: str | None = None) -> dict[str, Any]:
return _run_cross_account_transfer_match(limit=limit, dry_run=False, db_path=db_path)
def list_cross_transfer_review_queue(limit: int = 300, db_path: str | None = None) -> dict[str, Any]:
clauses = ["t.status='active'", "t.source IN ('kb_bank_pdf','toss_bank_pdf')"]
clauses.append("t.memo LIKE '%cross_transfer_review:%'")
where = " WHERE " + " AND ".join(clauses)
with db(db_path) as conn:
rows = conn.execute(
f"SELECT * FROM transactions t{where} ORDER BY t.transaction_date DESC, t.created_at DESC LIMIT ?",
(max(1, min(int(limit), 1000)),),
).fetchall()
return ok(transactions=[row_to_dict(r) for r in rows], count=len(rows), source="cross_transfer_review_queue")
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)