Initial Jarca Ledger MCP implementation
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"] == "교육"
|
||||
@@ -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"
|
||||
@@ -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"]
|
||||
)
|
||||
Reference in New Issue
Block a user