Merge BAT-017 / REQ-021 implementation after delivery-runner implementation, independent QC, and passing GitHub Actions CI.
1020 lines
43 KiB
Python
1020 lines
43 KiB
Python
from __future__ import annotations
|
|
|
|
from ..provenance_policy import (
|
|
EXPOSURE_LEVEL_MINIMAL,
|
|
file_sha256,
|
|
redacted_source_file_label,
|
|
sanitize_csv_preview_rows,
|
|
sanitize_provenance_for_exposure,
|
|
)
|
|
|
|
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))
|
|
source_hash = file_sha256(path)
|
|
preview = sanitize_csv_preview_rows(rows, limit=20)
|
|
return ok(source=source, filename=redacted_source_file_label(str(path), source_hash), source_hash=source_hash, 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=redacted_source_file_label(parsed["file_path"], source_hash),
|
|
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",
|
|
redacted_source_file_label(parsed["file_path"], source_hash),
|
|
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=redacted_source_file_label(parsed["file_path"], source_hash),
|
|
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",
|
|
redacted_source_file_label(parsed["file_path"], source_hash),
|
|
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=redacted_source_file_label(parsed["file_path"], source_hash),
|
|
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",
|
|
redacted_source_file_label(parsed["file_path"], source_hash),
|
|
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=redacted_source_file_label(parsed["file_path"], source_hash),
|
|
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",
|
|
redacted_source_file_label(parsed["file_path"], source_hash),
|
|
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,
|
|
provenance_exposure: str = EXPOSURE_LEVEL_MINIMAL,
|
|
debug_provenance_gate: bool = False,
|
|
db_path: str | None = None,
|
|
) -> dict[str, Any]:
|
|
return list_ledger_import_batches_with_provenance(
|
|
source=source,
|
|
include_rolled_back=include_rolled_back,
|
|
limit=limit,
|
|
provenance_exposure=provenance_exposure,
|
|
debug_provenance_gate=debug_provenance_gate,
|
|
db_path=db_path,
|
|
)
|
|
|
|
|
|
def _load_batch_memo(memo: Any) -> dict[str, Any]:
|
|
if isinstance(memo, dict):
|
|
return memo
|
|
if not isinstance(memo, str) or not memo.strip():
|
|
return {}
|
|
try:
|
|
parsed = json.loads(memo)
|
|
except json.JSONDecodeError:
|
|
return {}
|
|
return parsed if isinstance(parsed, dict) else {}
|
|
|
|
|
|
def _batch_provenance(row: dict[str, Any]) -> dict[str, Any]:
|
|
memo = _load_batch_memo(row.get("memo"))
|
|
provenance = {
|
|
"source": row.get("source") or "",
|
|
"parser": row.get("source") or "",
|
|
"filename": row.get("filename") or "",
|
|
"import_batch_id": row.get("id") or "",
|
|
"source_hash": memo.get("source_hash") or "",
|
|
"validation": memo.get("validation") if isinstance(memo.get("validation"), dict) else {},
|
|
}
|
|
if memo.get("sheet_name"):
|
|
provenance["sheet_name"] = memo["sheet_name"]
|
|
return provenance
|
|
|
|
|
|
def list_ledger_import_batches_with_provenance(
|
|
source: str = "",
|
|
include_rolled_back: bool = True,
|
|
limit: int = 50,
|
|
provenance_exposure: str = EXPOSURE_LEVEL_MINIMAL,
|
|
debug_provenance_gate: bool = False,
|
|
db_path: str | None = None,
|
|
) -> dict[str, Any]:
|
|
if provenance_exposure == "debug-gated" and not debug_provenance_gate:
|
|
return fail(
|
|
"forbidden",
|
|
"debug-gated provenance requires explicit debug_provenance_gate=True",
|
|
)
|
|
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()
|
|
try:
|
|
items = []
|
|
for row in rows:
|
|
item = row_to_dict(row)
|
|
provenance = _batch_provenance(item)
|
|
item["memo"] = ""
|
|
item["provenance"] = sanitize_provenance_for_exposure(
|
|
provenance,
|
|
provenance_exposure,
|
|
)
|
|
items.append(item)
|
|
except ValueError as exc:
|
|
return fail("validation_error", str(exc))
|
|
return ok(import_batches=items, count=len(items))
|
|
|
|
|
|
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)
|