Initial Jarca Ledger MCP implementation

This commit is contained in:
Jarca
2026-06-26 22:15:15 +09:00
commit d8e36f6aa8
22 changed files with 7152 additions and 0 deletions
+51
View File
@@ -0,0 +1,51 @@
from __future__ import annotations
from jarca_ledger_mcp import service
def test_category_rules_preview_apply(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
service.create_ledger_transaction("2026-06-01", "expense", 10_000, merchant="이마트에브리데이", raw_summary="체크카드", db_path=db_path)
service.create_ledger_transaction("2026-06-02", "expense", 5_000, merchant="알수없음", raw_summary="체크카드", db_path=db_path)
seeded = service.seed_default_category_rules(db_path=db_path)
assert seeded["success"] is True
rules = service.list_category_rules(db_path=db_path)
assert rules["count"] > 0
preview = service.preview_categorization(db_path=db_path)
assert preview["matched_count"] == 1
assert preview["unmatched_count"] == 1
assert preview["matches"][0]["category"] == "식비"
assert preview["matches"][0]["subcategory"] == "마트"
applied = service.apply_categorization_rules(db_path=db_path)
assert applied["success"] is True
assert applied["applied_count"] == 1
categorized = service.list_ledger_transactions(category="식비", db_path=db_path)
assert categorized["count"] == 1
uncategorized = service.list_uncategorized_transactions(db_path=db_path)
assert uncategorized["count"] == 1
def test_apply_categorization_rules_applies_all_matches_not_just_preview_sample(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
service.seed_default_category_rules(db_path=db_path)
for idx in range(150):
service.create_ledger_transaction(
transaction_date=f"2026-06-{(idx % 28) + 1:02d}",
type="expense",
amount=10_000 + idx,
merchant=f"이마트에브리데이 {idx}",
raw_summary="체크카드",
db_path=db_path,
)
preview = service.preview_categorization(limit=5000, db_path=db_path)
assert preview["matched_count"] == 150
assert len(preview["matches"]) == 100
applied = service.apply_categorization_rules(limit=5000, db_path=db_path)
assert applied["success"] is True
assert applied["applied_count"] == 150
uncategorized = service.list_uncategorized_transactions(limit=200, db_path=db_path)
assert uncategorized["count"] == 0
+105
View File
@@ -0,0 +1,105 @@
from __future__ import annotations
from jarca_ledger_mcp import service
def test_category_rule_amount_and_day_filters(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
service.health_check(db_path)
rule = service.create_category_rule(
match_field="counterparty",
match_type="equals",
pattern="고근수",
transaction_type="expense",
category="금융",
subcategory="대출상환",
is_fixed=True,
amount_min=1200000,
amount_max=1200000,
day_of_month=5,
db_path=db_path,
)
assert rule["success"] is True
assert rule["rule"]["amount_min"] == 1200000
assert rule["rule"]["day_of_month"] == 5
matching = service.create_ledger_transaction(
transaction_date="2026-04-05",
type="expense",
amount=1200000,
merchant="고근수",
description="test",
db_path=db_path,
)
non_matching_amount = service.create_ledger_transaction(
transaction_date="2026-04-05",
type="expense",
amount=1000000,
merchant="고근수",
db_path=db_path,
)
non_matching_day = service.create_ledger_transaction(
transaction_date="2026-04-06",
type="expense",
amount=1200000,
merchant="고근수",
db_path=db_path,
)
# Manual transaction helper maps merchant, not counterparty, so set counterparty directly for this importer-specific rule.
from jarca_ledger_mcp.db import db
with db(db_path) as conn:
for tx in [matching, non_matching_amount, non_matching_day]:
conn.execute("UPDATE transactions SET counterparty=? WHERE id=?", ("고근수", tx["transaction"]["id"]))
preview = service.preview_categorization(limit=100, db_path=db_path)
assert preview["matched_count"] == 1
assert preview["matches"][0]["transaction_id"] == matching["transaction"]["id"]
assert preview["matches"][0]["category"] == "금융"
assert preview["matches"][0]["subcategory"] == "대출상환"
def test_update_category_rule_allows_condition_edits(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
service.health_check(db_path)
created = service.create_category_rule(
match_field="merchant",
match_type="contains",
pattern="학원",
transaction_type="expense",
category="교육",
subcategory="학원",
amount_min=100000,
db_path=db_path,
)
assert created["success"] is True
updated = service.update_category_rule(
created["rule"]["id"],
match_field="merchant",
match_type="equals",
pattern="대치학원",
transaction_type="expense",
category="교육",
subcategory="학원비",
priority=40,
person="고재성",
is_fixed=True,
memo="dashboard edit",
amount_min=120000,
amount_max=150000,
day_of_month=10,
active=False,
db_path=db_path,
)
assert updated["success"] is True
rule = updated["rule"]
assert rule["match_type"] == "equals"
assert rule["pattern"] == "대치학원"
assert rule["subcategory"] == "학원비"
assert rule["priority"] == 40
assert rule["person"] == "고재성"
assert rule["is_fixed"] is True
assert rule["amount_min"] == 120000
assert rule["amount_max"] == 150000
assert rule["day_of_month"] == 10
assert rule["active"] is False
@@ -0,0 +1,127 @@
from __future__ import annotations
from jarca_ledger_mcp import service
def _find_tx(rows: list[dict], tx_id: str) -> dict:
for row in rows:
if row["id"] == tx_id:
return row
raise AssertionError(f"tx {tx_id} not found")
def test_run_cross_transfer_match_marks_ownership_transfer_as_investment(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
kb_account = service.create_ledger_account("KB 본인", "bank", institution="국민", holder_name="고진환", db_path=db_path)
toss_account = service.create_ledger_account("토스증권/뱅크", "bank", institution="토스", holder_name="고진환", db_path=db_path)
kb_tx = service.create_ledger_transaction(
transaction_date="2026-06-17",
type="expense",
amount=63_000,
category="이체",
subcategory="입금",
merchant="출금",
counterparty="고진환",
account_id=kb_account["account"]["id"],
source="kb_bank_pdf",
db_path=db_path,
)
toss_tx = service.create_ledger_transaction(
transaction_date="2026-06-17",
type="income",
amount=63_000,
category="이체",
subcategory="입금",
merchant="입금",
counterparty="고진환",
account_id=toss_account["account"]["id"],
source="toss_bank_pdf",
db_path=db_path,
)
assert kb_tx["success"] is True
assert toss_tx["success"] is True
result = service.run_cross_account_transfer_match(db_path=db_path)
assert result["success"] is True
assert result["matched_count"] >= 1
transactions = service.list_ledger_transactions(limit=20, db_path=db_path)["transactions"]
kb = _find_tx(transactions, kb_tx["transaction"]["id"])
toss = _find_tx(transactions, toss_tx["transaction"]["id"])
assert kb["category"] == "이체"
assert kb["subcategory"] == "투자이동"
assert toss["category"] == "이체"
assert toss["subcategory"] == "투자이동"
def test_run_cross_transfer_match_marks_foreign_name_as_review_needed(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
kb_account = service.create_ledger_account("KB 본인", "bank", institution="국민", holder_name="고진환", db_path=db_path)
toss_account = service.create_ledger_account("토스뱅크", "bank", institution="토스", holder_name="고진환", db_path=db_path)
kb_tx = service.create_ledger_transaction(
transaction_date="2026-06-20",
type="expense",
amount=5_000_000,
category="이체",
subcategory="출금",
merchant="출금",
counterparty="최진선",
account_id=kb_account["account"]["id"],
source="kb_bank_pdf",
db_path=db_path,
)
toss_tx = service.create_ledger_transaction(
transaction_date="2026-06-20",
type="income",
amount=5_000_000,
category="이체",
subcategory="입금",
merchant="입금",
counterparty="최진선",
account_id=toss_account["account"]["id"],
source="toss_bank_pdf",
db_path=db_path,
)
assert kb_tx["success"] is True
assert toss_tx["success"] is True
result = service.run_cross_account_transfer_match(db_path=db_path)
assert result["success"] is True
transactions = service.list_ledger_transactions(limit=20, db_path=db_path)["transactions"]
kb = _find_tx(transactions, kb_tx["transaction"]["id"])
toss = _find_tx(transactions, toss_tx["transaction"]["id"])
assert kb["subcategory"] == "확인필요"
assert toss["subcategory"] == "확인필요"
def test_preview_cross_transfer_marks_unmatched_as_review_item(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
kb_account = service.create_ledger_account("KB 본인", "bank", institution="국민", holder_name="고진환", db_path=db_path)
kb_tx = service.create_ledger_transaction(
transaction_date="2026-06-22",
type="expense",
amount=12_000,
category="이체",
subcategory="출금",
merchant="출금",
counterparty="고진환",
account_id=kb_account["account"]["id"],
source="kb_bank_pdf",
db_path=db_path,
)
assert kb_tx["success"] is True
result = service.run_cross_account_transfer_match(limit=100, db_path=db_path)
assert result["success"] is True
assert result["review_count"] >= 1
rows = service.list_cross_transfer_review_queue(db_path=db_path)["transactions"]
row = _find_tx(rows, kb_tx["transaction"]["id"])
assert row["memo"].find("cross_transfer_review") != -1
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
from pathlib import Path
import os
import pytest
from jarca_ledger_mcp import service
PDF_PATH = Path("/root/.hermes/cache/documents/doc_5a12af727ae0_KB거래내역조회_5704_2606171043.pdf")
PDF_PASSWORD = os.environ.get("KB_BANK_PDF_TEST_PASSWORD", "")
SAMPLE_AVAILABLE = PDF_PATH.exists() and bool(PDF_PASSWORD)
@pytest.mark.skipif(not SAMPLE_AVAILABLE, reason="sample KB Bank PDF/password is not available")
def test_preview_kb_bank_pdf_import_real_sample(tmp_path):
result = service.preview_kb_bank_pdf_import(str(PDF_PATH), PDF_PASSWORD, preview_limit=3, db_path=str(tmp_path / "ledger.sqlite3"))
assert result["success"] is True
assert result["row_count"] == 499
assert result["page_count"] == 20
assert result["metadata"]["account_number_masked"] == "****5704"
assert result["metadata"]["final_balance"] == 3_041_678
assert result["validation"]["ok"] is True
assert result["validation"]["total_deposit"] == 125_730_389
assert result["validation"]["total_withdrawal"] == 123_565_861
assert result["preview"][0]["transaction_datetime"] == "2026-01-03T09:05:10"
assert result["preview"][0]["type"] == "expense"
assert result["preview"][0]["amount"] == 200
@pytest.mark.skipif(not SAMPLE_AVAILABLE, reason="sample KB Bank PDF/password is not available")
def test_import_kb_bank_pdf_real_sample_and_rollback(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
result = service.import_kb_bank_pdf(str(PDF_PATH), PDF_PASSWORD, db_path=db_path)
assert result["success"] is True
assert result["parsed_row_count"] == 499
assert result["inserted_count"] == 499
assert result["skipped_duplicate_count"] == 0
assert result["metadata"]["account_number_masked"] == "****5704"
duplicated = service.import_kb_bank_pdf(str(PDF_PATH), PDF_PASSWORD, db_path=db_path)
assert duplicated["success"] is True
assert duplicated["inserted_count"] == 0
assert duplicated["skipped_duplicate_count"] == 499
listed = service.list_ledger_transactions(start_date="2026-01-01", end_date="2026-06-17", limit=1000, db_path=db_path)
assert listed["success"] is True
assert listed["count"] == 499
sample = next(tx for tx in listed["transactions"] if tx["transaction_datetime"] == "2026-06-15T16:50:50")
assert sample["merchant"] == "오아시스마켓"
assert sample["balance_after"] == 3_041_678
assert sample["branch"] == "KB카드"
accounts = service.list_ledger_accounts(db_path=db_path)
assert accounts["count"] == 1
assert accounts["accounts"][0]["institution"] == "KB국민은행"
assert accounts["accounts"][0]["last_balance"] == 3_041_678
batches = service.list_ledger_import_batches(source="kb_bank_pdf", db_path=db_path)
assert batches["count"] == 2
rollback = service.rollback_ledger_import_batch(result["import_batch_id"], db_path=db_path)
assert rollback["success"] is True
assert rollback["affected_transactions"] == 499
after = service.list_ledger_transactions(start_date="2026-01-01", end_date="2026-06-17", limit=1000, db_path=db_path)
assert after["count"] == 0
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
from jarca_ledger_mcp import service
def test_ledger_category_create_list_delete_reactivate(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
health = service.health_check(db_path)
assert health["schema_version"] == "6"
created = service.create_ledger_category("반려동물", "사료", sort_order=250, memo="test", db_path=db_path)
assert created["success"] is True
assert created["category"]["category"] == "반려동물"
assert created["category"]["subcategory"] == "사료"
assert created["category"]["cashflow_kind"] == "expense"
assert created["category"]["active"] is True
listed = service.list_ledger_categories(db_path=db_path)
assert listed["count"] == 1
assert listed["grouped"] == {"반려동물": ["사료"]}
deleted = service.delete_ledger_category("반려동물", "사료", db_path=db_path)
assert deleted["success"] is True
assert service.list_ledger_categories(db_path=db_path)["count"] == 0
assert service.list_ledger_categories(active_only=False, db_path=db_path)["categories"][0]["active"] is False
reactivated = service.create_ledger_category("반려동물", "사료", sort_order=260, db_path=db_path)
assert reactivated["success"] is True
assert reactivated["category"]["active"] is True
assert reactivated["category"]["sort_order"] == 260
def test_ledger_category_cashflow_kind(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
income = service.create_ledger_category("기타수입", "상금", cashflow_kind="income", db_path=db_path)
assert income["success"] is True
assert income["category"]["cashflow_kind"] == "income"
invalid = service.create_ledger_category("테스트", "", cashflow_kind="bad", db_path=db_path)
assert invalid["success"] is False
assert invalid["error_code"] == "validation_error"
def test_ledger_category_hard_delete_requires_force_when_in_use(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
service.create_ledger_category("식비", "마트", db_path=db_path)
service.create_ledger_transaction("2026-06-01", "expense", 10000, category="식비", subcategory="마트", merchant="테스트마트", db_path=db_path)
blocked = service.delete_ledger_category("식비", "마트", hard_delete=True, db_path=db_path)
assert blocked["success"] is False
assert blocked["error_code"] == "category_in_use"
soft = service.delete_ledger_category("식비", "마트", db_path=db_path)
assert soft["success"] is True
assert soft["transaction_count"] == 1
txs = service.list_ledger_transactions(category="식비", db_path=db_path)
assert txs["count"] == 1
+69
View File
@@ -0,0 +1,69 @@
from __future__ import annotations
from pathlib import Path
from jarca_ledger_mcp import service
from jarca_ledger_mcp.seoulpay_xlsx import parse_seoulpay_xlsx
def _write_sample_seoulpay_xlsx(path: Path) -> None:
import openpyxl
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "결제내역"
ws.append(["성명", "최진선"])
ws.append(["생년월일", "19791010"])
ws.append(["조회기간", "20260601~20260620"])
ws.append([])
ws.append([])
ws.append(["결제채널", "거래번호", "결제일", "가맹점", "사업자번호", "가맹점ID", "거래유형", "결제구분", "거래금액", "공급가액", "봉사료", "부가세", "과세여부"])
ws.append(["서울페이", "260618ZP00534273", "2026-06-18 18:27:46", "땡겨요", "2028102637", "AF2402815444", "온라인 결제 ", "결제", 42000, 38182, 0, 3818, "과세"])
ws.append(["서울페이", "ABP0123761927", "2026-06-10 15:14:05", "리엘바이올린음악교습소", "8259301583", "AF2402827183", "비대면 결제 ", "결제", 260000, 260000, 0, 0, "면세"])
wb.save(path)
def test_parse_seoulpay_xlsx_plain_file(tmp_path):
file_path = tmp_path / "서울페이.xlsx"
_write_sample_seoulpay_xlsx(file_path)
parsed = parse_seoulpay_xlsx(str(file_path))
assert parsed["metadata"]["holder_name"] == "최진선"
assert parsed["metadata"]["period"] == "20260601~20260620"
assert parsed["validation"]["ok"] is True
assert len(parsed["transactions"]) == 2
first = parsed["transactions"][0]
assert first.transaction_date == "2026-06-18"
assert first.merchant == "땡겨요"
assert first.amount == 42000
assert first.type == "expense"
def test_import_seoulpay_xlsx_creates_account_and_expenses(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
file_path = tmp_path / "서울페이.xlsx"
_write_sample_seoulpay_xlsx(file_path)
preview = service.preview_seoulpay_xlsx_import(str(file_path), preview_limit=10, db_path=db_path)
assert preview["success"] is True
assert preview["row_count"] == 2
assert preview["duplicate_count"] == 0
assert preview["by_category"]["식비/배달"]["amount"] == 42000
assert preview["by_category"]["교육/학원"]["amount"] == 260000
imported = service.import_seoulpay_xlsx(str(file_path), db_path=db_path)
assert imported["success"] is True
assert imported["inserted_count"] == 2
assert imported["skipped_duplicate_count"] == 0
duplicate = service.import_seoulpay_xlsx(str(file_path), db_path=db_path)
assert duplicate["success"] is True
assert duplicate["inserted_count"] == 0
assert duplicate["skipped_duplicate_count"] == 2
cashflow = service.get_monthly_cashflow_report("2026-06", db_path)
assert cashflow["expenses"] == 302000
categories = service.get_category_spending_report(month="2026-06", db_path=db_path)
assert categories["total_expense"] == 302000
assert categories["items"][0]["category"] == "교육"
+137
View File
@@ -0,0 +1,137 @@
from __future__ import annotations
from jarca_ledger_mcp import service
def test_health_check_initializes_db(tmp_path):
db_path = tmp_path / "ledger.sqlite3"
result = service.health_check(str(db_path))
assert result["success"] is True
assert result["schema_version"] == "6"
assert db_path.exists()
def test_account_transaction_cashflow_and_category_reports(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
acct = service.create_ledger_account("신한카드", type="card", institution="신한", db_path=db_path)
assert acct["success"] is True
account_id = acct["account"]["id"]
income = service.create_ledger_transaction(
"2026-06-01",
"income",
8_000_000,
category="수입",
merchant="회사",
account_id=account_id,
db_path=db_path,
)
assert income["success"] is True
expense = service.create_ledger_transaction(
"2026-06-03",
"expense",
120_000,
category="식비",
subcategory="마트",
merchant="이마트",
account_id=account_id,
person="공통",
tags=["장보기"],
db_path=db_path,
)
assert expense["success"] is True
fixed = service.create_ledger_transaction(
"2026-06-05",
"expense",
900_000,
category="교육",
subcategory="학원",
merchant="물리학원",
person="고재성",
is_fixed=True,
db_path=db_path,
)
assert fixed["success"] is True
investment_in = service.create_ledger_transaction(
"2026-06-06",
"income",
5_000_000,
category="이체",
subcategory="투자이동",
merchant="토스증권 매도/이체입금",
db_path=db_path,
)
assert investment_in["success"] is True
investment_out = service.create_ledger_transaction(
"2026-06-07",
"expense",
3_000_000,
category="이체",
subcategory="투자이동",
merchant="토스증권 매수/이체출금",
is_fixed=True,
db_path=db_path,
)
assert investment_out["success"] is True
cashflow = service.get_monthly_cashflow_report("2026-06", db_path)
assert cashflow["success"] is True
assert cashflow["income"] == 8_000_000
assert cashflow["expenses"] == 1_020_000
assert cashflow["net_cashflow"] == 6_980_000
assert cashflow["by_type"]["income"] == 13_000_000
assert cashflow["by_type"]["expense"] == 4_020_000
assert cashflow["by_cashflow_kind"]["investment"] == 8_000_000
categories = service.get_category_spending_report(month="2026-06", db_path=db_path)
assert categories["total_expense"] == 1_020_000
assert categories["items"][0]["category"] == "교육"
fixed_report = service.get_fixed_cost_report("2026-06", db_path)
assert fixed_report["total_fixed_cost"] == 900_000
assert fixed_report["count"] == 1
def test_budget_vs_actual_and_investable_cashflow(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
service.create_ledger_budget("2026-06", "식비", 500_000, db_path=db_path)
service.create_ledger_budget("2026-06", "교육", 800_000, person="고재준", db_path=db_path)
service.create_ledger_transaction("2026-06-01", "income", 4_000_000, category="수입", db_path=db_path)
service.create_ledger_transaction("2026-06-02", "expense", 550_000, category="식비", db_path=db_path)
service.create_ledger_transaction("2026-06-03", "expense", 700_000, category="교육", person="고재준", db_path=db_path)
service.create_ledger_transaction("2026-06-04", "expense", 2_000_000, category="이체", subcategory="투자이동", db_path=db_path)
service.create_ledger_transaction("2026-06-05", "income", 1_000_000, category="이체", subcategory="투자이동", db_path=db_path)
report = service.get_budget_vs_actual_report("2026-06", db_path)
assert report["success"] is True
food = next(i for i in report["items"] if i["category"] == "식비")
edu = next(i for i in report["items"] if i["category"] == "교육")
assert food["status"] == "over"
assert food["remaining"] == -50_000
assert edu["status"] == "ok"
assert edu["remaining"] == 100_000
investable = service.get_investable_cashflow_report("2026-06", minimum_cash_buffer=1_000_000, planned_extra_expense=500_000, db_path=db_path)
assert investable["investable_cashflow"] == 1_250_000
def test_duplicate_detection_and_soft_delete(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
first = service.create_ledger_transaction("2026-06-10", "expense", 10_000, merchant="카페", account_id="card1", db_path=db_path)
second = service.create_ledger_transaction("2026-06-10", "expense", 10_000, merchant="카페", account_id="card1", db_path=db_path)
assert first["success"] is True
assert second["success"] is False
assert second["error_code"] == "duplicate_transaction"
tx_id = first["transaction"]["id"]
deleted = service.delete_ledger_transaction(tx_id, db_path=db_path)
assert deleted["success"] is True
listed = service.list_ledger_transactions(include_deleted=False, db_path=db_path)
assert listed["count"] == 0
listed_all = service.list_ledger_transactions(include_deleted=True, db_path=db_path)
assert listed_all["count"] == 1
assert listed_all["transactions"][0]["status"] == "deleted"
+54
View File
@@ -0,0 +1,54 @@
from __future__ import annotations
from pathlib import Path
import pytest
from jarca_ledger_mcp import service
from jarca_ledger_mcp.toss_securities_pdf import parse_toss_securities_pdf
SAMPLE = Path("/root/.hermes/cache/documents/doc_f4cfe8e0221f_거래내역서_20250618_20260617_1.pdf")
@pytest.mark.skipif(not SAMPLE.exists(), reason="sample Toss Securities PDF is not available")
def test_parse_toss_securities_pdf_sample():
parsed = parse_toss_securities_pdf(str(SAMPLE))
assert parsed["metadata"]["holder_name"] == "고진환"
assert parsed["metadata"]["account_number_masked"] == "123-01-048829"
assert parsed["page_count"] == 15
assert len(parsed["transactions"]) == 207
assert any(tx.action == "구매" and tx.code == "A012510" for tx in parsed["transactions"])
assert any(tx.action == "이체입금(주식회사 케이뱅크)" for tx in parsed["transactions"])
assert any(tx.section == "USD" and tx.action == "외화증권배당금입금" for tx in parsed["transactions"])
@pytest.mark.skipif(not SAMPLE.exists(), reason="sample Toss Securities PDF is not available")
def test_preview_and_import_toss_securities_pdf_sample(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
preview = service.preview_toss_securities_pdf_import(str(SAMPLE), db_path=db_path)
assert preview["success"] is True
assert preview["row_count"] == 207
assert preview["duplicate_count"] == 0
imported = service.import_toss_securities_pdf(str(SAMPLE), db_path=db_path)
assert imported["success"] is True
assert imported["inserted_count"] == 207
assert imported["skipped_duplicate_count"] == 0
imported_again = service.import_toss_securities_pdf(str(SAMPLE), db_path=db_path)
assert imported_again["success"] is True
assert imported_again["inserted_count"] == 0
assert imported_again["skipped_duplicate_count"] == 207
txs = service.list_ledger_transactions(account_id=imported["account_id"], limit=300, db_path=db_path)
assert txs["count"] == 207
assert any(t["category"] == "수입" and t["subcategory"] == "배당" for t in txs["transactions"])
assert any(t["category"] == "이체" and t["subcategory"] == "투자이동" for t in txs["transactions"])
assert any(
t["merchant"] == "토스증권 이체출금(국민은행) 조상문" and t["category"] == "이체" and t["subcategory"] == "확인필요"
for t in txs["transactions"]
)
assert any(
t["merchant"] == "토스증권 이체입금(국민은행) 고진환" and t["category"] == "이체" and t["subcategory"] == "투자이동"
for t in txs["transactions"]
)