292 lines
13 KiB
Python
292 lines
13 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from zoneinfo import ZoneInfo
|
|
|
|
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"] == "11"
|
|
assert db_path.exists()
|
|
|
|
|
|
def test_service_default_dates_and_monthly_reports_use_kst_boundary(tmp_path, monkeypatch):
|
|
db_path = str(tmp_path / "ledger.sqlite3")
|
|
boundary_instant = datetime(2026, 7, 1, 0, 30, tzinfo=ZoneInfo("Asia/Seoul"))
|
|
monkeypatch.setattr(service, "_current_kst_datetime", lambda: boundary_instant)
|
|
|
|
assert service.now_iso() == "2026-07-01T00:30:00+09:00"
|
|
assert service.normalize_date(None, default_today=True) == "2026-07-01"
|
|
assert service.normalize_month("") == "2026-07"
|
|
|
|
service.create_ledger_transaction("2026-06-30", "expense", 10_000, category="식비", db_path=db_path)
|
|
service.create_ledger_transaction("2026-07-01", "income", 30_000, category="수입", db_path=db_path)
|
|
service.create_ledger_transaction("2026-07-02", "expense", 5_000, category="식비", db_path=db_path)
|
|
|
|
cashflow = service.get_monthly_cashflow_report("", db_path)
|
|
assert cashflow["success"] is True
|
|
assert cashflow["month"] == "2026-07"
|
|
assert cashflow["income"] == 30_000
|
|
assert cashflow["expenses"] == 5_000
|
|
|
|
status = service.get_ledger_db_status(db_path, include_hash=False, include_integrity=False)
|
|
assert status["aggregate_totals"]["month"] == "2026-07"
|
|
assert status["aggregate_totals"]["by_cashflow_kind"]["income"] == {"amount": 30_000, "count": 1}
|
|
assert status["aggregate_totals"]["by_cashflow_kind"]["expense"] == {"amount": 5_000, "count": 1}
|
|
|
|
|
|
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"
|
|
|
|
|
|
def test_statement_status_account_update_and_balance_reconciliation(tmp_path):
|
|
db_path = str(tmp_path / "ledger.sqlite3")
|
|
acct = service.create_ledger_account(
|
|
"수동 계좌",
|
|
statement_status="manual_only",
|
|
last_balance=901_500,
|
|
last_synced_at="2026-06-30T23:59:00+09:00",
|
|
db_path=db_path,
|
|
)
|
|
assert acct["success"] is True
|
|
assert acct["account"]["statement_status"] == "manual_only"
|
|
account_id = acct["account"]["id"]
|
|
|
|
service.create_ledger_transaction("2026-06-01", "income", 1_000_000, account_id=account_id, db_path=db_path)
|
|
service.create_ledger_transaction("2026-06-10", "expense", 98_500, account_id=account_id, db_path=db_path)
|
|
|
|
report = service.get_balance_reconciliation_report(account_id=account_id, db_path=db_path)
|
|
assert report["success"] is True
|
|
assert report["items"][0]["calculated_balance"] == 901_500
|
|
assert report["items"][0]["difference"] == 0
|
|
assert report["items"][0]["status"] == "OK"
|
|
|
|
updated = service.update_ledger_account(account_id, statement_status="partial_import", last_balance=905_000, db_path=db_path)
|
|
assert updated["success"] is True
|
|
assert updated["account"]["statement_status"] == "partial_import"
|
|
|
|
review = service.get_balance_reconciliation_report(account_id=account_id, baseline_balance=905_000, baseline_at="2026-06", db_path=db_path)
|
|
assert review["success"] is True
|
|
assert review["baseline_at"] == "2026-06-30T23:59:00+09:00"
|
|
assert review["items"][0]["difference"] == 3_500
|
|
assert review["items"][0]["status"] == "REVIEW_NEEDED"
|
|
assert "확인필요" in review["items"][0]["recommended_decisions"]
|
|
|
|
|
|
def test_balance_reconciliation_uses_account_last_synced_at_when_baseline_at_omitted(tmp_path):
|
|
db_path = str(tmp_path / "ledger.sqlite3")
|
|
acct = service.create_ledger_account(
|
|
"수동 계좌 기준일",
|
|
statement_status="manual_only",
|
|
last_balance=1_000,
|
|
last_synced_at="2026-01-31T23:59:00+09:00",
|
|
db_path=db_path,
|
|
)
|
|
assert acct["success"] is True
|
|
account_id = acct["account"]["id"]
|
|
|
|
service.create_ledger_transaction("2026-01-15", "income", 1_000, account_id=account_id, db_path=db_path)
|
|
service.create_ledger_transaction("2026-02-01", "expense", 200, account_id=account_id, db_path=db_path)
|
|
|
|
report = service.get_balance_reconciliation_report(account_id=account_id, db_path=db_path)
|
|
assert report["success"] is True
|
|
assert report["baseline_at"] is None
|
|
assert report["items"][0]["baseline_at"] == "2026-01-31T23:59:00+09:00"
|
|
assert report["items"][0]["calculated_balance"] == 1_000
|
|
assert report["items"][0]["difference"] == 0
|
|
assert report["items"][0]["status"] == "OK"
|
|
|
|
explicit = service.get_balance_reconciliation_report(
|
|
account_id=account_id,
|
|
baseline_balance=800,
|
|
baseline_at="2026-02",
|
|
db_path=db_path,
|
|
)
|
|
assert explicit["success"] is True
|
|
assert explicit["baseline_at"] == "2026-02-28T23:59:00+09:00"
|
|
assert explicit["items"][0]["baseline_at"] == "2026-02-28T23:59:00+09:00"
|
|
assert explicit["items"][0]["calculated_balance"] == 800
|
|
assert explicit["items"][0]["difference"] == 0
|
|
assert explicit["items"][0]["status"] == "OK"
|
|
|
|
|
|
def test_invalid_statement_status_rejected(tmp_path):
|
|
db_path = str(tmp_path / "ledger.sqlite3")
|
|
created = service.create_ledger_account("오류 계좌", statement_status="unsupported", db_path=db_path)
|
|
assert created["success"] is False
|
|
assert created["error_code"] == "validation_error"
|
|
|
|
acct = service.create_ledger_account("정상 계좌", db_path=db_path)
|
|
updated = service.update_ledger_account(acct["account"]["id"], statement_status="unsupported", db_path=db_path)
|
|
assert updated["success"] is False
|
|
assert updated["error_code"] == "validation_error"
|
|
|
|
|
|
def test_liquidity_bucket_account_flow_and_asset_report(tmp_path):
|
|
db_path = str(tmp_path / "ledger.sqlite3")
|
|
cash = service.create_ledger_account("현금성 계좌", last_balance=1_000_000, liquidity_bucket="cash", db_path=db_path)
|
|
investment = service.create_ledger_account("투자 계좌", type="investment", last_balance=3_000_000, liquidity_bucket="investment", db_path=db_path)
|
|
loan = service.create_ledger_account("대출", type="loan", last_balance=500_000, liquidity_bucket="loan", db_path=db_path)
|
|
missing = service.create_ledger_account("평가 대기 부동산", liquidity_bucket="real_estate", db_path=db_path)
|
|
default_bucket = service.create_ledger_account("기본 계좌", db_path=db_path)
|
|
|
|
assert cash["success"] is True
|
|
assert investment["success"] is True
|
|
assert loan["success"] is True
|
|
assert missing["success"] is True
|
|
assert default_bucket["account"]["liquidity_bucket"] == "other"
|
|
|
|
updated = service.update_ledger_account(default_bucket["account"]["id"], liquidity_bucket="deposit", last_balance=2_000_000, db_path=db_path)
|
|
assert updated["success"] is True
|
|
assert updated["account"]["liquidity_bucket"] == "deposit"
|
|
|
|
accounts = service.list_ledger_accounts(db_path=db_path)
|
|
assert {account["liquidity_bucket"] for account in accounts["accounts"]} >= {"cash", "investment", "deposit", "real_estate", "loan"}
|
|
|
|
report = service.get_asset_liquidity_report(db_path=db_path)
|
|
assert report["success"] is True
|
|
assert report["total_assets"] == 6_000_000
|
|
assert report["total_liabilities"] == 500_000
|
|
assert report["net_worth"] == 5_500_000
|
|
assert report["missing_balance_accounts"] == [{"id": missing["account"]["id"], "name": "평가 대기 부동산", "liquidity_bucket": "real_estate"}]
|
|
loan_bucket = next(item for item in report["buckets"] if item["liquidity_bucket"] == "loan")
|
|
assert loan_bucket["asset_share"] is None
|
|
|
|
|
|
def test_invalid_liquidity_bucket_rejected(tmp_path):
|
|
db_path = str(tmp_path / "ledger.sqlite3")
|
|
created = service.create_ledger_account("오류 계좌", liquidity_bucket="liquid", db_path=db_path)
|
|
assert created["success"] is False
|
|
assert created["error_code"] == "validation_error"
|
|
|
|
acct = service.create_ledger_account("정상 계좌", db_path=db_path)
|
|
updated = service.update_ledger_account(acct["account"]["id"], liquidity_bucket="liquid", db_path=db_path)
|
|
assert updated["success"] is False
|
|
assert updated["error_code"] == "validation_error"
|