feat: deliver schema 15 monthly settlement lifecycle

This commit is contained in:
2026-10-17 15:19:46 +09:00
parent dda76b3bbd
commit 38a854d525
79 changed files with 4003 additions and 1831 deletions
+289
View File
@@ -0,0 +1,289 @@
PRAGMA journal_mode=WAL;
PRAGMA foreign_keys=ON;
CREATE TABLE IF NOT EXISTS schema_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'bank',
institution TEXT NOT NULL DEFAULT '',
currency TEXT NOT NULL DEFAULT 'KRW',
active INTEGER NOT NULL DEFAULT 1,
memo TEXT NOT NULL DEFAULT '',
account_number_masked TEXT NOT NULL DEFAULT '',
holder_name TEXT NOT NULL DEFAULT '',
last_balance INTEGER,
last_balance_basis_date TEXT NOT NULL DEFAULT '',
last_synced_at TEXT NOT NULL DEFAULT '',
statement_status TEXT NOT NULL DEFAULT 'unknown',
liquidity_bucket TEXT NOT NULL DEFAULT 'other',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS transactions (
id TEXT PRIMARY KEY,
transaction_date TEXT NOT NULL,
posted_date TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL,
amount INTEGER NOT NULL,
currency TEXT NOT NULL DEFAULT 'KRW',
category TEXT NOT NULL DEFAULT '',
subcategory TEXT NOT NULL DEFAULT '',
merchant TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
account_id TEXT NOT NULL DEFAULT '',
payment_method TEXT NOT NULL DEFAULT '',
person TEXT NOT NULL DEFAULT '',
project TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '[]',
is_fixed INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL DEFAULT 'manual',
import_batch_id TEXT NOT NULL DEFAULT '',
external_id TEXT NOT NULL DEFAULT '',
dedupe_key TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active',
transaction_datetime TEXT NOT NULL DEFAULT '',
counterparty TEXT NOT NULL DEFAULT '',
balance_after INTEGER,
branch TEXT NOT NULL DEFAULT '',
raw_summary TEXT NOT NULL DEFAULT '',
raw_payload TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(transaction_date);
CREATE INDEX IF NOT EXISTS idx_transactions_category ON transactions(category, subcategory);
CREATE INDEX IF NOT EXISTS idx_transactions_person ON transactions(person);
CREATE INDEX IF NOT EXISTS idx_transactions_account ON transactions(account_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_transactions_dedupe ON transactions(dedupe_key) WHERE dedupe_key <> '';
CREATE TABLE IF NOT EXISTS budgets (
id TEXT PRIMARY KEY,
month TEXT NOT NULL,
category TEXT NOT NULL,
subcategory TEXT NOT NULL DEFAULT '',
person TEXT NOT NULL DEFAULT '',
amount INTEGER NOT NULL,
currency TEXT NOT NULL DEFAULT 'KRW',
memo TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_budgets_month ON budgets(month);
CREATE UNIQUE INDEX IF NOT EXISTS idx_budgets_unique ON budgets(month, category, subcategory, person);
CREATE TABLE IF NOT EXISTS import_batches (
id TEXT PRIMARY KEY,
source TEXT NOT NULL,
filename TEXT NOT NULL DEFAULT '',
imported_at TEXT NOT NULL,
row_count INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active',
memo TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS category_rules (
id TEXT PRIMARY KEY,
priority INTEGER NOT NULL DEFAULT 100,
match_field TEXT NOT NULL,
match_type TEXT NOT NULL DEFAULT 'contains',
pattern TEXT NOT NULL,
amount_min INTEGER,
amount_max INTEGER,
day_of_month INTEGER,
transaction_type TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL,
subcategory TEXT NOT NULL DEFAULT '',
person TEXT NOT NULL DEFAULT '',
is_fixed INTEGER,
confidence REAL NOT NULL DEFAULT 1.0,
active INTEGER NOT NULL DEFAULT 1,
memo TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_category_rules_active_priority ON category_rules(active, priority);
CREATE UNIQUE INDEX IF NOT EXISTS idx_category_rules_unique ON category_rules(match_field, match_type, pattern, transaction_type, category, subcategory, person);
CREATE TABLE IF NOT EXISTS ledger_categories (
id TEXT PRIMARY KEY,
category TEXT NOT NULL,
subcategory TEXT NOT NULL DEFAULT '',
cashflow_kind TEXT NOT NULL DEFAULT 'expense',
active INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 100,
memo TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_ledger_categories_active_sort ON ledger_categories(active, sort_order, category, subcategory);
CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_categories_unique ON ledger_categories(category, subcategory);
CREATE TABLE IF NOT EXISTS manual_assets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
liquidity_bucket TEXT NOT NULL DEFAULT 'other',
valuation_amount INTEGER,
valuation_date TEXT NOT NULL DEFAULT '',
currency TEXT NOT NULL DEFAULT 'KRW',
source_note TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '',
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_manual_assets_active_bucket ON manual_assets(active, liquidity_bucket, name);
CREATE INDEX IF NOT EXISTS idx_manual_assets_valuation_date ON manual_assets(valuation_date);
CREATE TABLE IF NOT EXISTS asset_snapshots (
id TEXT PRIMARY KEY,
basis_date TEXT NOT NULL,
source_type TEXT NOT NULL,
source_id TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
amount INTEGER,
currency TEXT NOT NULL DEFAULT 'KRW',
liquidity_bucket TEXT NOT NULL DEFAULT 'other',
valuation_source TEXT NOT NULL DEFAULT '',
source_note TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '',
snapshot_run_id TEXT NOT NULL DEFAULT '',
correction_of_run_id TEXT NOT NULL DEFAULT '',
correction_reason TEXT NOT NULL DEFAULT '',
is_correction INTEGER NOT NULL DEFAULT 0,
closed_at TEXT NOT NULL DEFAULT '',
valuation_id TEXT NOT NULL DEFAULT '',
selection_policy TEXT NOT NULL DEFAULT 'exact',
verification_status TEXT NOT NULL DEFAULT 'unverified',
evidence_kind TEXT NOT NULL DEFAULT 'applied',
exclusion_reason TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_asset_snapshots_basis_date ON asset_snapshots(basis_date);
CREATE INDEX IF NOT EXISTS idx_asset_snapshots_source ON asset_snapshots(source_type, source_id);
CREATE TABLE IF NOT EXISTS asset_snapshot_runs (
id TEXT PRIMARY KEY,
basis_date TEXT NOT NULL,
close_status TEXT NOT NULL,
selection_policy TEXT NOT NULL DEFAULT 'exact',
idempotency_key TEXT NOT NULL DEFAULT '',
approved_by TEXT NOT NULL DEFAULT '',
approved_at TEXT NOT NULL DEFAULT '',
approval_reason TEXT NOT NULL DEFAULT '',
approval_reference TEXT NOT NULL DEFAULT '',
correction_of_run_id TEXT NOT NULL DEFAULT '',
correction_reason TEXT NOT NULL DEFAULT '',
applied_evidence TEXT NOT NULL DEFAULT '[]',
excluded_evidence TEXT NOT NULL DEFAULT '[]',
carried_evidence TEXT NOT NULL DEFAULT '[]',
request_fingerprint TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
settlement_month TEXT,
currency TEXT,
income_total INTEGER,
expense_total INTEGER,
net_cashflow INTEGER,
total_assets INTEGER,
total_liabilities INTEGER,
net_worth INTEGER,
cashflow_policy_json TEXT,
aggregation_version TEXT,
snapshot_schema_version INTEGER,
source_snapshot_json TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_idempotency ON asset_snapshot_runs(idempotency_key) WHERE idempotency_key <> '';
CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_initial_basis ON asset_snapshot_runs(basis_date) WHERE correction_of_run_id = '';
CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_correction_parent ON asset_snapshot_runs(correction_of_run_id) WHERE correction_of_run_id <> '';
CREATE TABLE IF NOT EXISTS settlement_cashflow_snapshots (
id TEXT PRIMARY KEY,
settlement_run_id TEXT NOT NULL REFERENCES asset_snapshot_runs(id) ON DELETE RESTRICT,
cashflow_kind TEXT NOT NULL CHECK (cashflow_kind IN ('income', 'expense', 'excluded')),
category TEXT NOT NULL DEFAULT '',
subcategory TEXT NOT NULL DEFAULT '',
amount INTEGER NOT NULL CHECK (typeof(amount) = 'integer'),
transaction_count INTEGER NOT NULL CHECK (typeof(transaction_count) = 'integer' AND transaction_count > 0),
currency TEXT NOT NULL,
UNIQUE (settlement_run_id, cashflow_kind, category, subcategory, currency)
);
CREATE INDEX IF NOT EXISTS idx_settlement_cashflow_snapshots_run ON settlement_cashflow_snapshots(settlement_run_id);
CREATE TABLE IF NOT EXISTS review_decisions (
id TEXT PRIMARY KEY,
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
decision_type TEXT NOT NULL DEFAULT 'unknown',
reason_code TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '',
operator TEXT NOT NULL DEFAULT 'operator',
decided_at TEXT NOT NULL DEFAULT '',
review_after TEXT NOT NULL DEFAULT '',
target_payload TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_review_decisions_target ON review_decisions(target_type, target_id, updated_at);
CREATE INDEX IF NOT EXISTS idx_review_decisions_status ON review_decisions(status, review_after, updated_at);
CREATE TABLE IF NOT EXISTS reconciliation_sessions (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL,
basis_date TEXT NOT NULL,
expected_balance INTEGER,
actual_balance INTEGER,
difference INTEGER,
status TEXT NOT NULL DEFAULT 'open',
closed_at TEXT NOT NULL DEFAULT '',
source_note TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '',
operator TEXT NOT NULL DEFAULT 'operator',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_reconciliation_sessions_account_basis ON reconciliation_sessions(account_id, basis_date);
CREATE INDEX IF NOT EXISTS idx_reconciliation_sessions_status ON reconciliation_sessions(status, closed_at);
CREATE TABLE IF NOT EXISTS account_valuations (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL REFERENCES accounts(id),
basis_date TEXT NOT NULL,
amount INTEGER NOT NULL,
currency TEXT NOT NULL DEFAULT 'KRW',
valuation_kind TEXT NOT NULL DEFAULT 'balance',
source_type TEXT NOT NULL DEFAULT 'manual',
source_ref TEXT NOT NULL DEFAULT '',
verification_status TEXT NOT NULL DEFAULT 'unverified',
recorded_at TEXT NOT NULL,
memo TEXT NOT NULL DEFAULT '',
idempotency_key TEXT NOT NULL DEFAULT '',
supersedes_id TEXT REFERENCES account_valuations(id),
correction_reason TEXT NOT NULL DEFAULT '',
as_of_at TEXT,
timezone TEXT,
cost_basis INTEGER,
net_contributed INTEGER,
reported_principal_kind TEXT,
reported_principal_label TEXT,
profit INTEGER,
return_rate REAL,
return_basis TEXT,
principal INTEGER,
interest INTEGER,
fee INTEGER,
representative_liability_definition TEXT
);
CREATE INDEX IF NOT EXISTS idx_account_valuations_account_date ON account_valuations(account_id, basis_date, recorded_at);
CREATE INDEX IF NOT EXISTS idx_account_valuations_status_date ON account_valuations(verification_status, basis_date);
CREATE UNIQUE INDEX IF NOT EXISTS idx_account_valuations_idempotency ON account_valuations(account_id, idempotency_key) WHERE idempotency_key <> '';
CREATE UNIQUE INDEX IF NOT EXISTS idx_account_valuations_single_correction ON account_valuations(supersedes_id) WHERE supersedes_id IS NOT NULL;
INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', '14');
+98
View File
@@ -0,0 +1,98 @@
from __future__ import annotations
import sqlite3
import pytest
from streamlit.testing.v1 import AppTest
from jarca_ledger_mcp.application.account_valuation_service import (
register_account_valuation,
update_account_snapshot_category,
)
from jarca_ledger_mcp.application.monthly_settlement_service import create_monthly_settlement
from jarca_ledger_mcp.infrastructure.sqlite.migrations import init_db
def _ledger(tmp_path, *, account_type="bank", bucket="cash"):
path = tmp_path / "category.sqlite3"
init_db(str(path))
with sqlite3.connect(path) as conn:
conn.execute(
"INSERT INTO accounts(id,name,type,currency,liquidity_bucket,created_at,updated_at) VALUES(?,?,?,?,?,?,?)",
("acct-1", "계좌", account_type, "KRW", bucket, "2026-10-01", "2026-10-01"),
)
conn.commit()
settlement = create_monthly_settlement("2026-10", db_path=str(path))["settlement_id"]
return path, settlement
def test_registration_uses_account_default_and_rejects_invalid_explicit_override(tmp_path):
path, settlement = _ledger(tmp_path, bucket="deposit")
result = register_account_valuation("acct-1", "2026-10-31", 10, "KRW", monthly_settlement_id=settlement, db_path=str(path))
assert result["success"]
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT liquidity_bucket FROM account_snapshot").fetchone() == ("deposit",)
invalid_dir = tmp_path / "invalid"
invalid_dir.mkdir()
path2, settlement2 = _ledger(invalid_dir, bucket="cash")
result = register_account_valuation("acct-1", "2026-10-31", 10, "KRW", monthly_settlement_id=settlement2, liquidity_bucket="bogus", db_path=str(path2))
assert result["success"] is False and result["error_code"] == "validation_error"
def test_category_edit_only_changes_bucket_and_preserves_everything_else(tmp_path):
path, settlement = _ledger(tmp_path)
result = register_account_valuation("acct-1", "2026-10-31", 123, "KRW", memo="keep", monthly_settlement_id=settlement, db_path=str(path))
snapshot = result["snapshot_id"]
with sqlite3.connect(path) as conn:
before = conn.execute("SELECT * FROM account_snapshot WHERE id=?", (snapshot,)).fetchone()
changed = update_account_snapshot_category(snapshot, settlement, "loan", db_path=str(path))
assert changed["success"] and changed["liquidity_bucket"] == "loan"
with sqlite3.connect(path) as conn:
after = conn.execute("SELECT * FROM account_snapshot WHERE id=?", (snapshot,)).fetchone()
cols = [row[1] for row in conn.execute("PRAGMA table_info(account_snapshot)")]
assert dict(zip(cols, before)) | {"liquidity_bucket": "loan"} == dict(zip(cols, after))
@pytest.mark.parametrize("status", ["draft", "in_review"])
def test_category_edit_allows_draft_and_in_review(tmp_path, status):
path, settlement = _ledger(tmp_path)
snapshot = register_account_valuation("acct-1", "2026-10-31", 1, "KRW", monthly_settlement_id=settlement, db_path=str(path))["snapshot_id"]
with sqlite3.connect(path) as conn:
conn.execute("UPDATE monthly_settlement SET status=? WHERE id=?", (status, settlement))
conn.commit()
assert update_account_snapshot_category(snapshot, settlement, "investment", db_path=str(path))["success"]
@pytest.mark.parametrize("status", ["approved", "closed"])
def test_category_edit_blocks_approved_and_closed(tmp_path, status):
path, settlement = _ledger(tmp_path)
snapshot = register_account_valuation("acct-1", "2026-10-31", 1, "KRW", monthly_settlement_id=settlement, db_path=str(path))["snapshot_id"]
with sqlite3.connect(path) as conn:
conn.execute("UPDATE monthly_settlement SET status=?, approved_by='test', approved_at='2026-10-31', approved_version=1, approved_fingerprint='fp', closed_by=CASE WHEN ?='closed' THEN 'test' ELSE closed_by END, closed_at=CASE WHEN ?='closed' THEN '2026-10-31' ELSE closed_at END WHERE id=?", (status, status, status, settlement))
conn.commit()
result = update_account_snapshot_category(snapshot, settlement, "investment", db_path=str(path))
assert result["success"] is False and result["error_code"] == "invalid_settlement_status"
def test_category_edit_rejects_wrong_monthly(tmp_path):
path, settlement = _ledger(tmp_path)
other = create_monthly_settlement("2026-11", db_path=str(path))["settlement_id"]
snapshot = register_account_valuation("acct-1", "2026-10-31", 1, "KRW", monthly_settlement_id=settlement, db_path=str(path))["snapshot_id"]
result = update_account_snapshot_category(snapshot, other, "investment", db_path=str(path))
assert result["success"] is False and result["error_code"] == "validation_error"
def test_app_category_select_saves_clicked_snapshot_and_monthly(monkeypatch):
from jarca_ledger_mcp.adapters.streamlit import overview_gateway, settlement_gateway
calls = []
monkeypatch.setattr(overview_gateway, "get_monthly_settlement_summary", lambda *a, **k: {"success": True, "month": "2026-10", "selected_month": "2026-10", "basis_date": "2026-10-31", "reports": {"cashflow": {}, "budget": {}, "assets": {}}})
monkeypatch.setattr(settlement_gateway, "get_monthly_settlement_record", lambda *a, **k: {"success": True, "settlement": {"id": "ms-1", "month": "2026-10", "status": "draft"}})
monkeypatch.setattr(settlement_gateway, "list_account_snapshots_for_settlement", lambda *a, **k: {"success": True, "closed": False, "status": "draft", "count": 1, "registered_count": 1, "unregistered_count": 0, "snapshots": [{"account_id": "a1", "account_name": "대출", "amount": 100, "currency": "KRW", "basis_date": "2026-10-31", "snapshot_id": "snap-1", "display_status": "확인", "account_type": "loan", "account_liquidity_bucket": "loan", "snapshot_account_category": "loan"}]})
monkeypatch.setattr(settlement_gateway, "update_account_snapshot_category", lambda snapshot_id, monthly_id, value, **k: calls.append((snapshot_id, monthly_id, value)) or {"success": True})
app = AppTest.from_string("import streamlit as st\nst.query_params.update(page='monthly_settlement', id='ms-1')\nfrom dashboard.page_views.monthly_settlement import page_monthly_settlement\npage_monthly_settlement(db_path='unused.sqlite3')\n").run()
dialog = [b for b in app.button if b.label == "✎"][0].click().run()
assert dialog.selectbox[0].value == "loan"
save = [button for button in dialog.button if button.label == "저장"][0]
dialog.selectbox[0].set_value("deposit")
save.click().run()
assert save.label == "저장"
+250
View File
@@ -0,0 +1,250 @@
from __future__ import annotations
import sqlite3
from streamlit.testing.v1 import AppTest
from jarca_ledger_mcp.application.monthly_settlement_service import (
create_monthly_settlement,
list_account_snapshots_for_settlement,
)
from jarca_ledger_mcp.infrastructure.sqlite.migrations import init_db
from dashboard.page_views.monthly_settlement import _account_evaluation_totals, _format_evaluation_total
def test_account_evaluation_totals_native_currency_null_and_zero():
result = _account_evaluation_totals([
{"account_id": "a1", "snapshot_id": "s1", "amount": 0, "currency": "KRW"},
{"account_id": "a2", "snapshot_id": "s2", "amount": -2500, "currency": "USD"},
{"account_id": "a3", "snapshot_id": "s3", "amount": None, "currency": "KRW"},
{"account_id": "a4", "snapshot_id": None, "amount": 999999, "currency": "KRW"},
])
assert result["totals"] == {"KRW": 0, "USD": -2500}
assert result["unknown_count"] == 1
assert result["registered_count"] == 3
assert result["unregistered_count"] == 1
assert _format_evaluation_total("KRW", 0) == "0원"
assert _format_evaluation_total("USD", -2500) == "-2,500 USD"
def test_account_evaluation_totals_missing_currency_is_separate():
result = _account_evaluation_totals([
{"account_id": "a1", "snapshot_id": "s1", "amount": 100, "currency": None},
{"account_id": "a2", "snapshot_id": None, "amount": None, "currency": "KRW"},
])
assert result["totals"] == {"미지정": 100}
def _account(conn, ident, name, currency="KRW"):
conn.execute(
"INSERT INTO accounts(id,name,currency,created_at,updated_at) VALUES(?,?,?,?,?)",
(ident, name, currency, "2026-10-01T00:00:00Z", "2026-10-01T00:00:00Z"),
)
def _snapshot(conn, ident, settlement, account, name, amount, currency="KRW"):
conn.execute(
"""INSERT INTO account_snapshot
(id,basis_date,source_type,source_id,name,amount,currency,monthly_settlement_id,created_at)
VALUES(?,?,?,?,?,?,?,?,?)""",
(ident, "2026-10-31", "account", account, name, amount, currency, settlement, "2026-10-31T00:00:00Z"),
)
def test_draft_uses_all_accounts_and_exact_monthly_snapshot(tmp_path):
path = tmp_path / "ledger.sqlite3"
init_db(str(path))
settlement = create_monthly_settlement("2026-10", db_path=str(path))["settlement_id"]
with sqlite3.connect(path) as conn:
_account(conn, "a1", "계좌1")
_account(conn, "a2", "계좌2")
_snapshot(conn, "s1", settlement, "a1", "계좌1", 0)
_snapshot(conn, "other", "other-month", "a2", "계좌2", 99)
conn.commit()
result = list_account_snapshots_for_settlement(settlement, db_path=str(path))
assert [(x["source_id"], x["amount"], x["display_status"]) for x in result["snapshots"]] == [("a1", 0, "확인"), ("a2", None, "미등록")]
assert result["count"] == 2 and result["registered_count"] == 1 and result["unregistered_count"] == 1
def test_closed_uses_historical_rows_only_and_preserves_null_zero_and_duplicate(tmp_path):
path = tmp_path / "ledger.sqlite3"
init_db(str(path))
settlement = create_monthly_settlement("2026-10", db_path=str(path))["settlement_id"]
with sqlite3.connect(path) as conn:
_account(conn, "old", "과거계좌", "USD")
_snapshot(conn, "s-null", settlement, "old", "보존이름", None, "USD")
_snapshot(conn, "s-zero", settlement, "old", "보존이름", 0, "USD")
conn.execute("UPDATE monthly_settlement SET status='closed', approved_by='test', approved_at='2026-10-31T00:00:00Z', approved_version=1, approved_fingerprint='fp', closed_by='test', closed_at='2026-10-31T00:00:00Z' WHERE id=?", (settlement,))
_account(conn, "new", "신규계좌")
conn.commit()
result = list_account_snapshots_for_settlement(settlement, db_path=str(path))
assert result["closed"] is True
assert [x["amount"] for x in result["snapshots"]] == [None, 0]
assert [x["display_status"] for x in result["snapshots"]] == ["미확인", "확인"]
assert all(x["name"] == "보존이름" for x in result["snapshots"])
assert all(x["source_id"] != "new" for x in result["snapshots"])
def test_monthly_renderer_opens_real_registration_dialog_from_actual_button(monkeypatch):
from jarca_ledger_mcp.adapters.streamlit import overview_gateway, settlement_gateway
monkeypatch.setattr(
overview_gateway,
"get_monthly_settlement_summary",
lambda *args, **kwargs: {
"success": True,
"month": "2026-10",
"selected_month": "2026-10",
"basis_date": "2026-10-31",
"actual_income": 0,
"actual_expense": 0,
"net_cashflow": 0,
"reports": {"cashflow": {}, "budget": {}, "assets": {}},
},
)
monkeypatch.setattr(
settlement_gateway,
"get_monthly_settlement_record",
lambda *args, **kwargs: {"success": True, "settlement": {"id": "ms-1", "month": "2026-10", "status": "draft"}},
)
monkeypatch.setattr(
settlement_gateway,
"list_account_snapshots_for_settlement",
lambda *args, **kwargs: {
"success": True,
"closed": False,
"count": 3,
"registered_count": 2,
"unregistered_count": 1,
"snapshots": [
{"account_id": "acct-1", "account_name": "주계좌", "amount": 1630800, "currency": "KRW", "basis_date": "2026-10-31", "display_status": "확인", "snapshot_id": "snap-1", "account_type": "bank", "account_liquidity_bucket": "bank"},
{"account_id": "acct-2", "account_name": "임시 주택담보대출", "amount": 1562300, "currency": "KRW", "basis_date": "2026-10-31", "display_status": "확인", "snapshot_id": "snap-2", "account_type": "loan", "account_liquidity_bucket": "loan"},
{"account_id": "acct-3", "account_name": "보조계좌", "amount": None, "currency": "USD", "basis_date": None, "display_status": "미등록", "snapshot_id": None},
],
},
)
app = AppTest.from_string(
"import streamlit as st\n"
"st.query_params.update(page='monthly_settlement', id='ms-1')\n"
"from dashboard.page_views.monthly_settlement import page_monthly_settlement\n"
"page_monthly_settlement(db_path='synthetic-unused.sqlite3')\n",
default_timeout=30,
).run()
assert not app.exception
assert any(metric.label == "자산 합계 (KRW)" and metric.value == "1,630,800원" for metric in app.metric)
assert any(metric.label == "부채 합계 (KRW)" and metric.value == "1,562,300원" for metric in app.metric)
assert any(metric.label == "순자산 (KRW)" and metric.value == "68,500원" for metric in app.metric)
metric_columns = [column for column in app.columns if column.metric]
assert len(metric_columns) == 3
assert [column.metric[0].label for column in metric_columns] == [
"자산 합계 (KRW)", "부채 합계 (KRW)", "순자산 (KRW)",
]
assert any("평가등록 2 / 전체 3" in caption.value and "미등록 1" in caption.value for caption in app.caption)
assert not [expander for expander in app.expander if expander.label == "필요 메모/근거"]
account_buttons = [button for button in app.button if button.label == "보조계좌"]
assert len(account_buttons) == 1
assert not [button for button in app.button if button.label == "평가 등록"]
dialog = account_buttons[0].click().run()
assert not dialog.exception
inputs = {widget.label: widget for widget in dialog.text_input}
assert inputs["계정"].value == "보조계좌"
assert inputs["계정"].disabled is True
assert inputs["통화"].value == "USD"
assert dialog.date_input[0].value.isoformat() == "2026-10-31"
assert "평가금액 (정수)" in inputs
assert dialog.text_area[0].label == "메모 (선택)"
def test_monthly_renderer_preserves_ledger_projection_and_source_choices(monkeypatch):
from jarca_ledger_mcp.adapters.streamlit import overview_gateway, settlement_gateway
monkeypatch.setattr(
overview_gateway,
"get_monthly_settlement_summary",
lambda *args, **kwargs: {
"success": True, "month": "2026-10", "selected_month": "2026-10",
"basis_date": "2026-10-31", "reports": {"cashflow": {}, "budget": {}, "assets": {}},
},
)
monkeypatch.setattr(
settlement_gateway,
"get_monthly_settlement_record",
lambda *args, **kwargs: {"success": True, "settlement": {"id": "ms-10", "month": "2026-10", "status": "draft"}},
)
monkeypatch.setattr(
settlement_gateway,
"list_account_snapshots_for_settlement",
lambda *args, **kwargs: {
"success": True, "closed": False, "count": 1, "registered_count": 0,
"unregistered_count": 1,
"snapshots": [{
"account_id": "acct-10", "account_name": "실제계정10", "amount": None,
"currency": "KRW", "basis_date": None, "display_status": "미등록",
"snapshot_id": None,
"previous_ledger": {"id": "prev-10", "transaction_date": "2026-10-30", "balance_after": 1234, "currency": "KRW"},
"next_ledger": {"id": "next-10", "transaction_date": "2026-11-01", "balance_after": 2345, "currency": "KRW"},
}],
},
)
app = AppTest.from_string(
"import streamlit as st\n"
"st.query_params.update(page='monthly_settlement', id='ms-10')\n"
"from dashboard.page_views.monthly_settlement import page_monthly_settlement\n"
"page_monthly_settlement(db_path='synthetic-unused.sqlite3')\n",
default_timeout=30,
).run()
assert not app.exception
dialog = [button for button in app.button if button.label == "실제계정10"][0].click().run()
assert not dialog.exception
source = dialog.selectbox[0]
assert len(source.options) == 3
assert source.value.startswith("이전 기록")
inputs = {widget.label: widget for widget in dialog.text_input}
assert inputs["평가금액 (정수)"].value == "1234"
assert inputs["평가금액 (정수)"].disabled is True
assert any(option.startswith("이후 기록") for option in source.options)
assert inputs["평가금액 (정수)"].disabled is True
def test_monthly_renderer_mixed_rows_keeps_snapshot_id_for_unlink(monkeypatch):
from jarca_ledger_mcp.adapters.streamlit import overview_gateway, settlement_gateway
monkeypatch.setattr(overview_gateway, "get_monthly_settlement_summary", lambda *args, **kwargs: {
"success": True, "month": "2026-10", "selected_month": "2026-10",
"basis_date": "2026-10-31", "reports": {"cashflow": {}, "budget": {}, "assets": {}},
})
monkeypatch.setattr(settlement_gateway, "get_monthly_settlement_record", lambda *args, **kwargs: {
"success": True, "settlement": {"id": "ms-mixed", "month": "2026-10", "status": "draft"},
})
monkeypatch.setattr(settlement_gateway, "list_account_snapshots_for_settlement", lambda *args, **kwargs: {
"success": True, "closed": False, "status": "draft", "count": 2,
"registered_count": 1, "unregistered_count": 1,
"snapshots": [
{"account_id": "acct-1", "account_name": "등록계정", "amount": 100,
"currency": "KRW", "basis_date": "2026-10-31", "display_status": "확인",
"snapshot_id": "snap-1"},
{"account_id": "acct-2", "account_name": "미등록계정", "amount": None,
"currency": "USD", "basis_date": None, "display_status": "미등록",
"snapshot_id": None},
],
})
app = AppTest.from_string(
"import streamlit as st\n"
"st.query_params.update(page='monthly_settlement', id='ms-mixed')\n"
"from dashboard.page_views.monthly_settlement import page_monthly_settlement\n"
"page_monthly_settlement(db_path='synthetic-unused.sqlite3')\n",
default_timeout=30,
).run()
assert not app.exception
unlink = [button for button in app.button if button.label == "×"]
assert len(unlink) == 1
assert unlink[0].help == "연결 해제"
dialog = unlink[0].click().run()
assert not dialog.exception
assert any("등록계정" in markdown.value for markdown in dialog.markdown)
+114
View File
@@ -0,0 +1,114 @@
from __future__ import annotations
import sqlite3
import pytest
from jarca_ledger_mcp.application.monthly_settlement_service import (
create_monthly_settlement,
link_account_snapshot,
list_account_snapshots_for_settlement,
)
from jarca_ledger_mcp.infrastructure.sqlite.migrations import init_db
def _account(conn, ident="acct-1", name="실제계정", currency="KRW"):
conn.execute(
"INSERT INTO accounts(id,name,currency,created_at,updated_at) VALUES(?,?,?,?,?)",
(ident, name, currency, "2026-10-01T00:00:00Z", "2026-10-01T00:00:00Z"),
)
def _snapshot(conn, ident, *, account="acct-1", basis_date="2026-10-31", currency="KRW", settlement=None, amount=1234):
conn.execute(
"""INSERT INTO account_snapshot
(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,
monthly_settlement_id,valuation_id,verification_status,created_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""",
(ident, basis_date, "account", account, "원본 스냅샷명", amount, currency, "cash", settlement,
"valuation-1", "unverified", "2026-10-31T00:00:00Z"),
)
def _ledger(tmp_path):
path = tmp_path / "link.sqlite3"
init_db(str(path))
with sqlite3.connect(path) as conn:
draft = create_monthly_settlement("2026-10", db_path=str(path))["settlement_id"]
conn.execute("UPDATE monthly_settlement SET basis_date='2026-10-31' WHERE id=?", (draft,))
_account(conn)
conn.commit()
return path, draft
def test_link_updates_only_monthly_fk_and_preserves_snapshot_fields(tmp_path):
path, settlement = _ledger(tmp_path)
with sqlite3.connect(path) as conn:
_snapshot(conn, "candidate")
before = conn.execute("SELECT * FROM account_snapshot WHERE id='candidate'").fetchone()
columns = [row[1] for row in conn.execute("PRAGMA table_info(account_snapshot)")]
result = link_account_snapshot("candidate", settlement, db_path=str(path))
assert result["success"] is True
with sqlite3.connect(path) as conn:
after = conn.execute("SELECT * FROM account_snapshot WHERE id='candidate'").fetchone()
assert len(before) == len(after) == len(columns)
changed = {column: (old, new) for column, old, new in zip(columns, before, after) if old != new}
assert changed == {"monthly_settlement_id": (None, settlement)}
def test_actual_read_function_returns_unlinked_candidates_in_exact_order(tmp_path):
path, settlement = _ledger(tmp_path)
with sqlite3.connect(path) as conn:
_snapshot(conn, "old", basis_date="2026-09-30", amount=1)
_snapshot(conn, "exact-old-created", basis_date="2026-10-31", amount=2)
_snapshot(conn, "exact-new-created", basis_date="2026-10-31", amount=3)
conn.execute("UPDATE account_snapshot SET created_at='2026-10-31T02:00:00Z' WHERE id='exact-old-created'")
conn.execute("UPDATE account_snapshot SET created_at='2026-10-31T03:00:00Z' WHERE id='exact-new-created'")
conn.commit()
result = list_account_snapshots_for_settlement(settlement, db_path=str(path))
row = result["snapshots"][0]
assert [item["snapshot_id"] for item in row["unlinked_candidates"]] == ["exact-new-created", "exact-old-created", "old"]
@pytest.mark.parametrize("status", ["approved", "closed"])
def test_link_rejects_approved_and_closed(tmp_path, status):
path, settlement = _ledger(tmp_path)
with sqlite3.connect(path) as conn:
_snapshot(conn, "candidate")
conn.execute("""UPDATE monthly_settlement SET status=?, approved_by='test',
approved_at='2026-10-31T00:00:00Z', approved_version=1,
approved_fingerprint='test', closed_by='test', closed_at='2026-10-31T00:00:00Z' WHERE id=?""", (status, settlement))
conn.commit()
result = link_account_snapshot("candidate", settlement, db_path=str(path))
assert result["success"] is False
assert result["error_code"] == "invalid_settlement_status"
def test_link_rejects_unrelated_month_and_already_linked_snapshot(tmp_path):
path, settlement = _ledger(tmp_path)
with sqlite3.connect(path) as conn:
other = create_monthly_settlement("2026-11", db_path=str(path))["settlement_id"]
_snapshot(conn, "linked-other", settlement=other)
_snapshot(conn, "linked-this", settlement=settlement)
conn.commit()
assert link_account_snapshot("linked-other", settlement, db_path=str(path))["error_code"] == "already_linked"
assert link_account_snapshot("linked-this", settlement, db_path=str(path))["error_code"] == "already_linked"
def test_basis_date_mismatch_requires_explicit_true(tmp_path):
path, settlement = _ledger(tmp_path)
with sqlite3.connect(path) as conn:
_snapshot(conn, "mismatch", basis_date="2026-09-30")
conn.commit()
rejected = link_account_snapshot("mismatch", settlement, db_path=str(path))
assert rejected["error_code"] == "basis_date_mismatch"
accepted = link_account_snapshot("mismatch", settlement, allow_basis_date_mismatch=True, db_path=str(path))
assert accepted["success"] is True
def test_link_rejects_account_currency_mismatch(tmp_path):
path, settlement = _ledger(tmp_path)
with sqlite3.connect(path) as conn:
_snapshot(conn, "bad-currency", currency="USD")
conn.commit()
result = link_account_snapshot("bad-currency", settlement, db_path=str(path))
assert result["error_code"] == "validation_error"
@@ -0,0 +1,47 @@
from __future__ import annotations
from streamlit.testing.v1 import AppTest
def test_registration_dialog_exposes_real_account_snapshot_link_mode_and_fallback(monkeypatch):
from jarca_ledger_mcp.adapters.streamlit import overview_gateway, settlement_gateway
monkeypatch.setattr(overview_gateway, "get_monthly_settlement_summary", lambda *args, **kwargs: {
"success": True, "month": "2026-10", "selected_month": "2026-10", "basis_date": "2026-10-31",
"reports": {"cashflow": {}, "budget": {}, "assets": {}},
})
monkeypatch.setattr(settlement_gateway, "get_monthly_settlement_record", lambda *args, **kwargs: {
"success": True, "settlement": {"id": "ms-link-ui", "month": "2026-10", "basis_date": "2026-10-31", "status": "draft"},
})
monkeypatch.setattr(settlement_gateway, "list_account_snapshots_for_settlement", lambda *args, **kwargs: {
"success": True, "closed": False, "status": "draft", "count": 1, "registered_count": 0,
"unregistered_count": 1,
"snapshots": [{
"account_id": "acct-ui", "account_name": "실제 원본계정", "amount": None, "currency": "KRW",
"basis_date": None, "display_status": "미등록", "snapshot_id": None,
"unlinked_candidates": [
{"snapshot_id": "snap-exact", "basis_date": "2026-10-31", "amount": 777, "currency": "KRW", "verification_status": "unverified"},
],
"previous_ledger": {"id": "prev", "transaction_date": "2026-10-30", "balance_after": 1234, "currency": "KRW"},
"next_ledger": {"id": "next", "transaction_date": "2026-11-01", "balance_after": 2345, "currency": "KRW"},
}],
})
app = AppTest.from_string(
"import streamlit as st\n"
"st.query_params.update(page='monthly_settlement', id='ms-link-ui')\n"
"from dashboard.page_views.monthly_settlement import page_monthly_settlement\n"
"page_monthly_settlement(db_path='synthetic-unused.sqlite3')\n",
default_timeout=30,
).run()
assert not app.exception
dialog = [button for button in app.button if button.label == "실제 원본계정"][0].click().run()
assert not dialog.exception
assert {widget.label: widget for widget in dialog.text_input}["계정"].value == "실제 원본계정"
assert dialog.radio[0].value == "기존 스냅샷 연결"
assert dialog.selectbox[0].label == "기존 스냅샷"
assert "777" in dialog.selectbox[0].options[0]
assert {widget.label: widget for widget in dialog.text_input}["선택 금액"].disabled is True
fallback = dialog.radio[0].set_value("새 평가 등록").run()
assert not fallback.exception
@@ -1,231 +0,0 @@
import sqlite3
from jarca_ledger_mcp import service
from jarca_ledger_mcp.application.account_valuation_service import record_account_valuation_candidate
from jarca_ledger_mcp.application.imports_service import _record_import_valuation_candidate
def test_bank_to_loan_conversion_rejects_existing_undefined_valuation_without_mutation(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
created = service.create_ledger_account(
"은행", type="bank", last_balance=100, last_synced_at="2026-06-30",
last_balance_basis_date="2026-06-30", db_path=path,
)
account_id = created["account"]["id"]
before = service.list_account_valuations(account_id, db_path=path)
assert before["count"] == 1
assert before["valuations"][0]["representative_liability_definition"] is None
rejected = service.update_ledger_account(account_id, type="loan", db_path=path)
assert rejected["success"] is False
assert rejected["error_code"] == "validation_error"
accounts = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"]
assert next(account for account in accounts if account["id"] == account_id)["type"] == "bank"
after = service.list_account_valuations(account_id, db_path=path)
assert after["count"] == 1
assert after["valuations"] == before["valuations"]
def test_bank_to_loan_conversion_without_valuations_remains_allowed(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account_id = service.create_ledger_account("은행", type="bank", db_path=path)["account"]["id"]
converted = service.update_ledger_account(account_id, type="loan", db_path=path)
assert converted["success"] is True
assert converted["account"]["type"] == "loan"
assert service.list_account_valuations(account_id, db_path=path)["count"] == 0
def test_loan_balance_requires_representative_liability_definition(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
bank = service.create_ledger_account("은행", type="bank", db_path=path)["account"]["id"]
loan = service.create_ledger_account("대출", type="loan", db_path=path)["account"]["id"]
missing_definition = service.register_account_valuation(
loan,
"2026-06-30",
100,
valuation_kind="balance",
verification_status="verified",
db_path=path,
)
assert missing_definition["success"] is False
assert missing_definition["error_code"] == "validation_error"
assert service.list_account_valuations(loan, db_path=path)["count"] == 0
ordinary_balance = service.register_account_valuation(
bank,
"2026-06-30",
100,
valuation_kind="balance",
verification_status="verified",
db_path=path,
)
defined_loan_balance = service.register_account_valuation(
loan,
"2026-06-30",
100,
valuation_kind="balance",
representative_liability_definition="principal_only",
verification_status="verified",
db_path=path,
)
assert ordinary_balance["success"] is True
assert defined_loan_balance["success"] is True
def test_correction_replays_by_evidence_and_conflicts_on_changed_payload(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("계좌", db_path=path)["account"]["id"]
parent = service.register_account_valuation(
account,
"2026-06-30",
100,
source_ref="statement-1",
verification_status="verified",
db_path=path,
)["valuation"]
first = service.correct_account_valuation(
parent["id"],
110,
"statement correction",
db_path=path,
)
replay = service.correct_account_valuation(
parent["id"],
110,
"statement correction",
db_path=path,
)
changed_amount = service.correct_account_valuation(
parent["id"],
111,
"statement correction",
db_path=path,
)
changed_reason = service.correct_account_valuation(
parent["id"],
110,
"different correction",
db_path=path,
)
changed_source = service.correct_account_valuation(
parent["id"],
110,
"statement correction",
source_ref="statement-2",
db_path=path,
)
assert first["success"] is True
assert replay["success"] is True
assert replay["reused"] is True
assert replay["valuation"]["id"] == first["valuation"]["id"]
for changed in (changed_amount, changed_reason, changed_source):
assert changed["success"] is False
assert changed["error_code"] == "correction_conflict"
assert service.list_account_valuations(account, db_path=path)["count"] == 2
def test_direct_loan_candidate_requires_liability_definition_and_writes_nothing(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
loan = service.create_ledger_account("대출", type="loan", db_path=path)["account"]["id"]
with sqlite3.connect(path) as conn:
conn.row_factory = sqlite3.Row
rejected = record_account_valuation_candidate(
conn, loan, "2026-06-30", 100, "KRW", "import", "source", "candidate", "memo",
)
assert rejected["success"] is False
assert rejected["error_code"] == "validation_error"
assert service.list_account_valuations(loan, db_path=path)["count"] == 0
def test_import_loan_candidate_requires_liability_definition_and_writes_nothing(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
loan = service.create_ledger_account("대출", type="loan", db_path=path)["account"]["id"]
with sqlite3.connect(path) as conn:
conn.row_factory = sqlite3.Row
assert _record_import_valuation_candidate(conn, loan, 100, "2026-06-30", "source-hash") is None
assert service.list_account_valuations(loan, db_path=path)["count"] == 0
def test_dated_loan_create_and_historical_update_preserve_current_cache_without_valuations(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
created = service.create_ledger_account(
"대출", type="loan", last_balance=900, last_synced_at="2026-06-30",
last_balance_basis_date="2026-06-30", db_path=path,
)
loan = created["account"]["id"]
assert created["account"]["last_balance"] == 900
assert created["account"]["last_synced_at"] == "2026-06-30"
assert service.list_account_valuations(loan, db_path=path)["count"] == 0
updated = service.update_ledger_account(
loan, last_balance=700, last_synced_at="2026-06-01",
last_balance_basis_date="2026-06-01", db_path=path,
)
assert updated["account"]["last_balance"] == 900
assert updated["account"]["last_synced_at"] == "2026-06-30"
assert service.list_account_valuations(loan, db_path=path)["count"] == 0
def test_loan_cash_balance_and_total_value_are_rejected_without_writes(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
loan = service.create_ledger_account("대출", type="loan", db_path=path)["account"]["id"]
for valuation_kind in ("cash_balance", "total_value"):
rejected = service.register_account_valuation(
loan, "2026-06-30", 100, valuation_kind=valuation_kind, db_path=path,
)
assert rejected["success"] is False
assert rejected["error_code"] == "validation_error"
assert service.list_account_valuations(loan, db_path=path)["count"] == 0
def test_non_loan_candidate_still_succeeds_and_reuses_idempotently(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("계좌", db_path=path)["account"]["id"]
with sqlite3.connect(path) as conn:
conn.row_factory = sqlite3.Row
first = record_account_valuation_candidate(
conn, account, "2026-06-30", 100, "KRW", "import", "source", "candidate", "memo",
)
replay = record_account_valuation_candidate(
conn, account, "2026-06-30", 100, "KRW", "import", "source", "candidate", "memo",
)
assert first["success"] is True
assert first["reused"] is False
assert replay["success"] is True
assert replay["reused"] is True
assert replay["valuation"]["id"] == first["valuation"]["id"]
assert service.list_account_valuations(account, db_path=path)["count"] == 1
def test_candidate_invalid_amount_returns_controlled_error_with_existing_row(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("계좌", db_path=path)["account"]["id"]
with sqlite3.connect(path) as conn:
conn.row_factory = sqlite3.Row
first = record_account_valuation_candidate(
conn, account, "2026-06-30", 100, "KRW", "import", "source", "candidate", "memo",
)
rejected = record_account_valuation_candidate(
conn, account, "2026-06-30", "not-an-integer", "KRW", "import", "source", "candidate", "memo",
)
assert first["success"] is True
assert rejected["success"] is False
assert rejected["error_code"] == "validation_error"
assert service.list_account_valuations(account, db_path=path)["count"] == 1
@@ -0,0 +1,109 @@
from __future__ import annotations
import sqlite3
import pytest
from jarca_ledger_mcp.application.account_valuation_service import register_account_valuation
from jarca_ledger_mcp.application.monthly_settlement_service import create_monthly_settlement
from jarca_ledger_mcp.infrastructure.sqlite.migrations import init_db
@pytest.fixture
def ledger(tmp_path):
path = tmp_path / "actual-service.sqlite3"
init_db(str(path))
with sqlite3.connect(path) as conn:
conn.execute(
"INSERT INTO accounts(id,name,currency,created_at,updated_at) VALUES(?,?,?,?,?)",
("acct-1", "주계좌", "KRW", "2026-10-01T00:00:00Z", "2026-10-01T00:00:00Z"),
)
conn.commit()
settlement = create_monthly_settlement("2026-10", db_path=str(path))
return path, settlement["settlement_id"]
@pytest.mark.parametrize(
("amount", "expected_amount"),
[(0, 0), (123456, 123456)],
)
def test_actual_service_saves_zero_and_amount_with_fk_and_unverified(ledger, amount, expected_amount):
path, settlement_id = ledger
result = register_account_valuation(
"acct-1", "2026-10-31", amount, "KRW", monthly_settlement_id=settlement_id, db_path=str(path)
)
assert result["success"] is True
with sqlite3.connect(path) as conn:
row = conn.execute(
"SELECT amount,currency,basis_date,monthly_settlement_id,verification_status,source_type,source_id FROM account_snapshot"
).fetchone()
assert row == (expected_amount, "KRW", "2026-10-31", settlement_id, "unverified", "account", "acct-1")
@pytest.mark.parametrize("existing_amount", [None, 0])
def test_actual_service_rejects_duplicate_even_when_existing_amount_is_null_or_zero(ledger, existing_amount):
path, settlement_id = ledger
with sqlite3.connect(path) as conn:
conn.execute(
"""INSERT INTO account_snapshot
(id,basis_date,source_type,source_id,name,amount,currency,monthly_settlement_id,created_at)
VALUES(?,?,?,?,?,?,?,?,?)""",
("existing", "2026-10-31", "account", "acct-1", "주계좌", existing_amount, "KRW", settlement_id, "2026-10-31T00:00:00Z"),
)
conn.commit()
result = register_account_valuation(
account_id="acct-1", basis_date="2026-10-31", amount=99, currency="KRW",
monthly_settlement_id=settlement_id, db_path=str(path),
)
assert result == {
"success": False,
"error_code": "duplicate",
"error": "account evaluation already registered",
"snapshot_id": "existing",
}
@pytest.mark.parametrize("status", ["closed", "approved"])
def test_actual_service_blocks_closed_and_approved_settlements(ledger, status):
path, settlement_id = ledger
with sqlite3.connect(path) as conn:
conn.execute(
"""UPDATE monthly_settlement
SET status=?, approved_by='test', approved_at='2026-10-31T00:00:00Z',
approved_version=1, approved_fingerprint='fp',
closed_by=CASE WHEN ?='closed' THEN 'test' ELSE closed_by END,
closed_at=CASE WHEN ?='closed' THEN '2026-10-31T00:00:00Z' ELSE closed_at END
WHERE id=?""",
(status, status, status, settlement_id),
)
conn.commit()
result = register_account_valuation(
"acct-1", "2026-10-31", 10, "KRW", monthly_settlement_id=settlement_id, db_path=str(path)
)
assert result["success"] is False
assert result["error_code"] in {"closed_settlement", "invalid_settlement_status"}
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0] == 0
@pytest.mark.parametrize(
("kwargs", "error_code"),
[
({"currency": "USD"}, "validation_error"),
({"basis_date": "2026-09-30"}, "validation_error"),
({"monthly_settlement_id": "missing"}, "not_found"),
],
)
def test_actual_service_validates_currency_date_and_settlement_and_rolls_back(ledger, kwargs, error_code):
path, settlement_id = ledger
args = {
"account_id": "acct-1", "basis_date": "2026-10-31", "amount": 10, "currency": "KRW",
"monthly_settlement_id": settlement_id, "db_path": str(path),
}
args.update(kwargs)
result = register_account_valuation(**args)
assert result["success"] is False
assert result["error_code"] == error_code
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM monthly_settlement").fetchone()[0] == 1
-102
View File
@@ -1,102 +0,0 @@
import sqlite3
from jarca_ledger_mcp import service
from jarca_ledger_mcp.db import init_db
def _account(db_path, currency="KRW"):
result = service.create_ledger_account("주계좌", currency=currency, db_path=db_path)
assert result["success"]
return result["account"]["id"]
def test_account_valuation_schema_is_v12_and_idempotent(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
init_db(path)
init_db(path)
with sqlite3.connect(path) as conn:
cols = {row[1] for row in conn.execute("PRAGMA table_info(account_valuations)")}
version = conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0]
assert version == "14"
assert {"id", "account_id", "basis_date", "amount", "currency", "valuation_kind", "source_type", "source_ref", "verification_status", "recorded_at", "memo", "idempotency_key", "supersedes_id", "correction_reason", "as_of_at", "timezone", "cost_basis", "net_contributed", "reported_principal_kind", "reported_principal_label", "profit", "return_rate", "return_basis", "principal", "interest", "fee", "representative_liability_definition"} <= cols
def test_register_same_idempotency_payload_reuses_row_and_changed_payload_conflicts(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account_id = _account(path)
first = service.register_account_valuation(account_id, "2026-06-30", 1000, idempotency_key="sync-1", verification_status="verified", source_type="import", source_ref="stmt-1", db_path=path)
same = service.register_account_valuation(account_id, "2026-06-30", 1000, idempotency_key="sync-1", verification_status="verified", source_type="import", source_ref="stmt-1", db_path=path)
conflict = service.register_account_valuation(account_id, "2026-06-30", 1200, idempotency_key="sync-1", verification_status="verified", source_type="import", source_ref="stmt-1", db_path=path)
assert first["success"] and same["success"]
assert same["reused"] is True and same["valuation"]["id"] == first["valuation"]["id"]
assert conflict["success"] is False and conflict["error_code"] == "idempotency_conflict"
def test_select_exact_and_as_of_excludes_unverified_and_reports_carry_forward(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account_id = _account(path)
service.register_account_valuation(account_id, "2026-06-01", 1000, verification_status="verified", idempotency_key="v1", db_path=path)
service.register_account_valuation(account_id, "2026-06-15", 2000, verification_status="unverified", idempotency_key="v2", db_path=path)
exact = service.select_account_valuation(account_id, "2026-06-15", db_path=path)
as_of = service.select_account_valuation(account_id, "2026-06-20", as_of=True, db_path=path)
assert exact["success"] and exact["valuation"] is None
assert exact["warning"] == "unverified_candidate"
assert as_of["success"] and as_of["valuation"]["amount"] == 1000
assert as_of["carried_forward"] is True and as_of["valuation_age_days"] == 19
def test_correction_is_append_only_reasoned_and_single_child(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account_id = _account(path)
original = service.register_account_valuation(account_id, "2026-06-30", 1000, verification_status="verified", idempotency_key="v1", db_path=path)
corrected = service.correct_account_valuation(original["valuation"]["id"], 1200, correction_reason="은행 원장 정정", idempotency_key="v2", db_path=path)
duplicate = service.correct_account_valuation(original["valuation"]["id"], 1300, correction_reason="두 번째 분기", idempotency_key="v3", db_path=path)
listed = service.list_account_valuations(account_id, db_path=path)
assert corrected["success"] and corrected["valuation"]["supersedes_id"] == original["valuation"]["id"]
assert duplicate["success"] is False and duplicate["error_code"] == "correction_conflict"
assert listed["count"] == 2
selected = service.select_account_valuation(account_id, "2026-06-30", db_path=path)
assert selected["valuation"]["amount"] == 1200
def test_account_balance_write_records_unverified_candidate_only_with_real_basis_date(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
created = service.create_ledger_account("동기화 계좌", last_balance=500, last_synced_at="2026-06-30", db_path=path, last_balance_basis_date="2026-06-30")
service.create_ledger_account("날짜 없는 계좌", last_balance=700, db_path=path)
values = service.list_account_valuations(created["account"]["id"], db_path=path)
assert values["count"] == 1
assert values["valuations"][0]["verification_status"] == "unverified"
assert values["valuations"][0]["basis_date"] == "2026-06-30"
def test_idempotency_scope_includes_basis_and_correction_metadata(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account_id = _account(path)
first = service.register_account_valuation(account_id, "2026-06-30", 1000, idempotency_key="same", db_path=path)
changed_basis = service.register_account_valuation(account_id, "2026-07-01", 1000, idempotency_key="same", db_path=path)
changed_reason = service.register_account_valuation(account_id, "2026-06-30", 1000, idempotency_key="same", supersedes_id="other", correction_reason="정정", db_path=path)
assert first["success"]
assert changed_basis["error_code"] == "idempotency_conflict"
assert changed_reason["error_code"] == "idempotency_conflict"
def test_correction_preserves_optional_fields_unless_overridden(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account_id = _account(path, currency="USD")
original = service.register_account_valuation(account_id, "2026-06-30", 1000, currency="USD", cost_basis=900, net_contributed=950, profit=50, return_rate=0.055, principal=800, interest=20, fee=5, as_of_at="2026-06-30T12:00:00+09:00", timezone="Asia/Seoul", db_path=path)
corrected = service.correct_account_valuation(original["valuation"]["id"], 1100, correction_reason="정정", idempotency_key="corr", profit=200, db_path=path)
assert corrected["success"]
value = corrected["valuation"]
assert value["cost_basis"] == 900 and value["net_contributed"] == 950
assert value["profit"] == 200 and value["return_rate"] == 0.055
assert value["principal"] == 800 and value["interest"] == 20 and value["fee"] == 5
assert value["as_of_at"] == "2026-06-30T12:00:00+09:00" and value["timezone"] == "Asia/Seoul"
def test_account_name_only_update_does_not_create_candidate(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
created = service.create_ledger_account("계좌", last_balance=500, last_synced_at="2026-06-30", db_path=path, last_balance_basis_date="2026-06-30")
account_id = created["account"]["id"]
updated = service.update_ledger_account(account_id, name="새 이름", db_path=path)
values = service.list_account_valuations(account_id, db_path=path)
assert updated["success"] and values["count"] == 1
+111
View File
@@ -0,0 +1,111 @@
from __future__ import annotations
import sqlite3
from jarca_ledger_mcp.application.account_valuation_service import (
list_account_valuations,
register_account_valuation,
)
from jarca_ledger_mcp.application.assets_service import (
create_manual_asset,
list_manual_assets,
preview_asset_snapshot_close,
)
from jarca_ledger_mcp.application.settlement_draft_service import create_settlement_draft
from jarca_ledger_mcp.infrastructure.sqlite.migrations import init_db
def _tables(path):
with sqlite3.connect(path) as conn:
return {r[0] for r in conn.execute("select name from sqlite_master where type='table'")}
EXPECTED_TABLES = {
# Sourced from schema.py plus migration-created settlement draft tables.
"schema_meta",
"accounts",
"transactions",
"budgets",
"import_batches",
"category_rules",
"ledger_categories",
"account_snapshot",
"settlement_cashflow_snapshots",
"review_decisions",
"reconciliation_sessions",
"monthly_settlement",
"settlement_drafts",
"settlement_draft_items",
}
def test_fresh_init_has_no_account_valuations_and_exact_schema_tables(tmp_path):
path = tmp_path / "fresh.sqlite3"
init_db(str(path))
tables = _tables(path)
assert "account_valuations" not in tables
assert tables == EXPECTED_TABLES
def test_init_is_idempotent(tmp_path):
path = tmp_path / "repeat.sqlite3"
assert init_db(str(path)) == str(path)
assert init_db(str(path)) == str(path)
assert _tables(path) == EXPECTED_TABLES
def test_legacy_nonempty_account_valuations_row_is_dropped_only(tmp_path):
path = tmp_path / "legacy.sqlite3"
init_db(str(path))
with sqlite3.connect(path) as conn:
conn.execute("create table account_valuations (id text primary key, account_id text, amount integer)")
conn.execute("insert into account_valuations values ('legacy-1','acct-1',123)")
conn.execute("insert into accounts (id,name,type,active,created_at,updated_at) values ('acct-1','Keep me','bank',1,'t','t')")
conn.commit()
init_db(str(path))
with sqlite3.connect(path) as conn:
assert conn.execute("select count(*) from accounts where id='acct-1'").fetchone()[0] == 1
assert conn.execute("select name from sqlite_master where type='table' and name='account_valuations'").fetchone() is None
def test_unsupported_valuation_operations_do_not_create_or_access_db(tmp_path):
path = tmp_path / "must-not-exist.sqlite3"
result = register_account_valuation("acct", "2026-09-30", 1, db_path=str(path))
listed = list_account_valuations("acct", db_path=str(path))
assert result["success"] is False and result["error_code"] == "unsupported"
assert listed["success"] is False and listed["error_code"] == "unsupported"
assert not path.exists()
def test_active_account_preview_reports_missing_without_sql_error(tmp_path):
path = tmp_path / "preview.sqlite3"
init_db(str(path))
with sqlite3.connect(path) as conn:
conn.execute("insert into accounts (id,name,type,active,created_at,updated_at) values ('acct-1','Active','bank',1,'t','t')")
conn.commit()
result = preview_asset_snapshot_close("2026-09-30", db_path=str(path))
assert result["success"] is True
assert result["close_status"] in {"incomplete_draft", "complete_exact"}
assert not any(w.get("code") == "db_error" for w in result["warnings"])
def test_active_account_draft_returns_review_blocker_without_sql_error(tmp_path):
path = tmp_path / "draft.sqlite3"
init_db(str(path))
with sqlite3.connect(path) as conn:
conn.execute("insert into accounts (id,name,type,active,created_at,updated_at) values ('acct-1','Active','bank',1,'t','t')")
conn.commit()
result = create_settlement_draft("2026-09", "tester", "draft-key", db_path=str(path))
assert result["success"] is True
assert result.get("review", {}).get("approval_required") is True
assert not any("sql" in str(v).lower() for v in result.get("review", {}).values())
def test_manual_asset_flow_is_unsupported_after_removal(tmp_path):
path = tmp_path / "manual.sqlite3"
init_db(str(path))
created = create_manual_asset("Emergency fund", 100000, "2026-09-30", "cash", db_path=str(path))
listed = list_manual_assets(db_path=str(path))
assert created == {"success": False, "error_code": "unsupported", "error": "manual assets are no longer supported"}
assert listed == {"success": False, "error_code": "unsupported", "error": "manual assets are no longer supported"}
+6 -6
View File
@@ -48,13 +48,13 @@ def test_replay_preserves_redacted_unverified_candidates_after_correction(tmp_pa
assert corrected["success"] is True
with sqlite3.connect(db_path) as conn:
before_replay = conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0]
before_replay = conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0]
before_runs = conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0]
replay = service.close_asset_snapshot(
"2026-06-30", idempotency_key="candidate-close-a", db_path=db_path,
)
with sqlite3.connect(db_path) as conn:
after_replay = conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0]
after_replay = conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0]
after_runs = conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0]
assert replay == first
@@ -109,7 +109,7 @@ def test_malformed_persisted_evidence_fails_closed_without_snapshot_writes(tmp_p
"UPDATE asset_snapshot_runs SET excluded_evidence=? WHERE id=?",
("{malformed", first["snapshot_run_id"]),
)
before_rows = conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0]
before_rows = conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0]
before_runs = conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0]
report = service.get_asset_snapshot_report("2026-06-30", db_path=db_path)
@@ -124,7 +124,7 @@ def test_malformed_persisted_evidence_fails_closed_without_snapshot_writes(tmp_p
assert result["success"] is False
assert result["error_code"] == "data_integrity_error"
with sqlite3.connect(db_path) as conn:
assert conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0] == before_rows
assert conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0] == before_rows
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == before_runs
@@ -171,7 +171,7 @@ def test_exact_run_replay_after_correction_reconstructs_original_persisted_run(t
assert corrected["snapshot_run_id"] not in first["inserted_snapshot_ids"]
with sqlite3.connect(db_path) as conn:
before_replay = conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0]
before_replay = conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0]
replay = service.close_asset_snapshot(
"2026-06-30",
@@ -186,7 +186,7 @@ def test_exact_run_replay_after_correction_reconstructs_original_persisted_run(t
)
with sqlite3.connect(db_path) as conn:
after_replay = conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0]
after_replay = conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0]
assert replay == first
assert replay["snapshot_run_id"] == first["snapshot_run_id"]
+68
View File
@@ -0,0 +1,68 @@
from __future__ import annotations
import sqlite3
from jarca_ledger_mcp.application.account_valuation_service import list_account_valuations, register_account_valuation
from jarca_ledger_mcp.application.assets_service import close_asset_snapshot, get_asset_snapshot_report
from jarca_ledger_mcp.application.monthly_settlement_service import list_monthly_settlement_overview
from jarca_ledger_mcp.infrastructure.sqlite.migrations import init_db
def tables(path):
with sqlite3.connect(path) as c:
return {r[0] for r in c.execute("select name from sqlite_master where type='table'")}
def test_fresh_init_has_no_asset_snapshot_runs(tmp_path):
path = tmp_path / "fresh.sqlite3"
init_db(str(path))
assert "asset_snapshot_runs" not in tables(path)
def test_init_twice_is_idempotent_and_schema16(tmp_path):
path = tmp_path / "repeat.sqlite3"
init_db(str(path)); init_db(str(path))
with sqlite3.connect(path) as c:
assert c.execute("select value from schema_meta where key='schema_version'").fetchone()[0] == "16"
assert "asset_snapshot_runs" not in tables(path)
def test_legacy_child_rows_require_deterministic_parent_mapping(tmp_path):
path = tmp_path / "legacy.sqlite3"
init_db(str(path))
with sqlite3.connect(path) as c:
c.execute("create table asset_snapshot_runs (id text primary key, basis_date text not null)")
c.execute("insert into asset_snapshot_runs values ('run-1','2026-09-30')")
c.execute("insert into monthly_settlement(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) values ('month-1','2026-09','2026-09-30','draft',1,'test','now','test','now')")
c.execute("insert into settlement_cashflow_snapshots values ('cf-1','month-1','income','','',10,1,'KRW')")
c.commit()
init_db(str(path))
with sqlite3.connect(path) as c:
assert c.execute("select monthly_settlement_id, amount from settlement_cashflow_snapshots").fetchone() == ('month-1', 10)
assert "asset_snapshot_runs" not in tables(path)
assert "monthly_settlement_id" in (c.execute("select sql from sqlite_master where name='settlement_cashflow_snapshots'").fetchone()[0] or "")
def test_unsupported_legacy_operations_do_not_touch_missing_db(tmp_path):
path = tmp_path / "missing.sqlite3"
assert register_account_valuation("a", "2026-09-30", 1, db_path=str(path))["error_code"] == "unsupported"
assert list_account_valuations("a", db_path=str(path))["error_code"] == "unsupported"
assert not path.exists()
def test_finalize_and_close_remain_unsupported_without_db_access(tmp_path):
path = tmp_path / "missing.sqlite3"
assert close_asset_snapshot("2026-09-30", db_path=str(path))["error_code"] == "unsupported"
assert get_asset_snapshot_report("2026-09-30", db_path=str(path))["error_code"] == "unsupported"
assert not path.exists()
def test_monthly_overview_keeps_real_parent_row(tmp_path):
path = tmp_path / "monthly.sqlite3"
init_db(str(path))
with sqlite3.connect(path) as c:
c.execute("insert into monthly_settlement(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) values (?,?,?,?,?,?,?,?,?)", ('m-1','2026-09','2026-09-30','draft',1,'test','t','test','t'))
c.commit()
result = list_monthly_settlement_overview(db_path=str(path))
assert result["success"] and result["months"][0]["settlement_id"] == "m-1"
assert result["months"][0]["income_total"] is None
+5 -5
View File
@@ -13,10 +13,10 @@ def test_asset_snapshot_schema_is_additive_and_idempotent(tmp_path):
with sqlite3.connect(db_path) as conn:
manual_cols = {row[1] for row in conn.execute("PRAGMA table_info(manual_assets)")}
snapshot_cols = {row[1] for row in conn.execute("PRAGMA table_info(asset_snapshots)")}
snapshot_cols = {row[1] for row in conn.execute("PRAGMA table_info(account_snapshot)")}
assert {"id", "name", "valuation_amount", "valuation_date", "liquidity_bucket", "source_note", "memo", "active"} <= manual_cols
assert {"id", "basis_date", "source_type", "source_id", "name", "amount", "liquidity_bucket", "valuation_source", "source_note", "memo", "snapshot_run_id", "correction_of_run_id", "correction_reason", "is_correction", "closed_at"} <= snapshot_cols
assert {"id", "basis_date", "source_type", "source_id", "name", "amount", "liquidity_bucket", "valuation_source", "source_note", "memo", "monthly_settlement_id", "correction_of_run_id", "correction_reason", "is_correction", "closed_at"} <= snapshot_cols
def test_manual_assets_snapshot_report_and_net_worth_formula(tmp_path):
@@ -105,9 +105,9 @@ def test_manual_asset_update_deactivate_preview_and_close_correction_policy(tmp_
assert deactivated["success"] is True
assert deactivated["manual_asset"]["active"] is False
before = service.get_ledger_db_status(db_path, include_hash=False, include_integrity=False)["table_counts"].get("asset_snapshots", 0)
before = service.get_ledger_db_status(db_path, include_hash=False, include_integrity=False)["table_counts"].get("account_snapshot", 0)
preview = service.preview_asset_snapshot_close("2026-06-30", db_path=db_path)
after = service.get_ledger_db_status(db_path, include_hash=False, include_integrity=False)["table_counts"].get("asset_snapshots", 0)
after = service.get_ledger_db_status(db_path, include_hash=False, include_integrity=False)["table_counts"].get("account_snapshot", 0)
assert preview["success"] is True
assert preview["source"] == "live_authority"
assert preview["is_closed_snapshot"] is False
@@ -146,5 +146,5 @@ def test_manual_asset_update_deactivate_preview_and_close_correction_policy(tmp_
assert corrected_report["close_metadata"]["snapshot_run_id"] == corrected["snapshot_run_id"]
with sqlite3.connect(db_path) as conn:
row_count = conn.execute("SELECT COUNT(*) FROM asset_snapshots WHERE basis_date='2026-06-30'").fetchone()[0]
row_count = conn.execute("SELECT COUNT(*) FROM account_snapshot WHERE basis_date='2026-06-30'").fetchone()[0]
assert row_count == 6
+11 -6
View File
@@ -150,7 +150,7 @@ def test_mid_close_failure_rolls_back_all_settlement_state(tmp_path, failure_poi
review = review_result
ok(api("approve_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "synthetic-reviewer", "approved", "ref", db_path=path))
with sqlite3.connect(path) as db:
target = {"header":"asset_snapshot_runs", "assets":"asset_snapshots", "cashflow":"settlement_cashflow_snapshots", "completion":"settlement_drafts"}[failure_point]
target = {"header":"asset_snapshot_runs", "assets":"account_snapshot", "cashflow":"settlement_cashflow_snapshots", "completion":"settlement_drafts"}[failure_point]
# Missing future table is an explicit RED assertion, not a SQL error masquerading as rollback.
assert db.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (target,)).fetchone(), f"required comprehensive table missing: {target}"
event = "UPDATE" if failure_point == "completion" else "INSERT"
@@ -160,7 +160,7 @@ def test_mid_close_failure_rolls_back_all_settlement_state(tmp_path, failure_poi
assert failed.get("success") is False
with sqlite3.connect(path) as db:
assert db.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
assert db.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0] == 0
assert db.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0] == 0
assert db.execute("SELECT COUNT(*) FROM settlement_cashflow_snapshots").fetchone()[0] == 0
assert db.execute("SELECT status FROM settlement_drafts WHERE id=?", (draft["id"],)).fetchone()[0] == "active"
@@ -176,7 +176,7 @@ def test_close_rejects_stored_detail_sum_tampering(tmp_path):
result = close_draft(path, draft)
assert result.get("success") is False, result
with sqlite3.connect(path) as db:
for table in ("asset_snapshot_runs", "asset_snapshots", "settlement_cashflow_snapshots"):
for table in ("asset_snapshot_runs", "account_snapshot", "settlement_cashflow_snapshots"):
assert db.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] == 0
assert db.execute("SELECT status FROM settlement_drafts WHERE id=?", (draft["id"],)).fetchone()[0] == "active"
@@ -368,7 +368,7 @@ def test_close_rejects_persisted_header_and_asset_tampering(tmp_path, target):
ok(service.approve_settlement_draft(draft["id"], draft["version"], review["review_fingerprint"], "reviewer", "synthetic", "ref", db_path=path))
if target.startswith("asset_"):
column, expression = ("amount", "amount+1") if target == "asset_amount" else ("currency", "'USD'")
trigger = f"CREATE TRIGGER synthetic_tamper AFTER INSERT ON asset_snapshots BEGIN UPDATE asset_snapshots SET {column}={expression} WHERE id=NEW.id; END"
trigger = f"CREATE TRIGGER synthetic_tamper AFTER INSERT ON account_snapshot BEGIN UPDATE account_snapshot SET {column}={expression} WHERE id=NEW.id; END"
else:
column, expression = {"header_total": ("income_total", "income_total+1"), "header_source": ("source_snapshot_json", "'{}'"), "header_policy": ("cashflow_policy_json", "'{\"savings_categories\":[{\"category\":\"Other\",\"subcategory\":\"Pair\"}],\"category_decisions\":[]}'"), "header_version": ("aggregation_version", "'unknown-version'")}[target]
trigger = f"CREATE TRIGGER synthetic_tamper AFTER INSERT ON settlement_cashflow_snapshots BEGIN UPDATE asset_snapshot_runs SET {column}={expression} WHERE id=NEW.settlement_run_id; END"
@@ -377,7 +377,7 @@ def test_close_rejects_persisted_header_and_asset_tampering(tmp_path, target):
result = service.finalize_settlement_draft(draft["id"], draft["version"], review["review_fingerprint"], "tamper-close", db_path=path)
assert result["success"] is False, "persisted header/detail must match the approved result before commit"
with sqlite3.connect(path) as conn:
for table in ("asset_snapshot_runs", "asset_snapshots", "settlement_cashflow_snapshots"):
for table in ("asset_snapshot_runs", "account_snapshot", "settlement_cashflow_snapshots"):
assert conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] == 0
assert conn.execute("SELECT status,final_run_id,final_response FROM settlement_drafts WHERE id=?", (draft["id"],)).fetchone() == ("active", "", "")
@@ -672,7 +672,12 @@ def test_schema_eleven_legacy_reader_returns_empty_evidence_without_writes(tmp_p
from test_legacy_snapshot_migration import _legacy_closed_db
path, _ = _legacy_closed_db(tmp_path)
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
header = conn.execute(
"SELECT id, close_status, correction_of_run_id, correction_reason FROM asset_snapshot_runs"
).fetchone()
assert tuple(header) == (
"legacy-run-correction", "legacy_read_only", "legacy-initial", "legacy correction"
)
before = list(conn.iterdump())
exact = ok(service.get_monthly_settlement_snapshot(run_id="legacy-run-correction", db_path=path))
latest = ok(service.get_monthly_settlement_snapshot(month="2026-06", db_path=path))
@@ -283,6 +283,8 @@ def test_saved_monthly_panel_renders_real_conditional_exclusion_carry_and_approv
monkeypatch.setattr(reconciliation_gateway, "get_balance_reconciliation_report", lambda *a, **k: {"success": True, "items": []})
monkeypatch.setattr(settlement_gateway, "list_settlement_drafts", lambda *a, **k: {"success": True, "drafts": []})
app = AppTest.from_string(
f"import streamlit as st\n"
f"st.query_params.update(page='월정산', month='2026-09')\n"
f"from dashboard.page_views.monthly_settlement import page_monthly_settlement\n"
f"page_monthly_settlement({path!r})", default_timeout=30).run()
assert not app.exception, f"saved panel AppTest failed before field assertions: {app.exception}"
@@ -368,6 +370,8 @@ def test_saved_monthly_snapshot_renders_close_legacy_and_asset_evidence(monkeypa
monkeypatch.setattr(settlement_gateway, "get_monthly_settlement_snapshot", lambda *a, **k: {"success": True, **snapshot})
monkeypatch.setattr(settlement_gateway, "list_settlement_drafts", lambda *a, **k: {"success": True, "drafts": []})
app = AppTest.from_string(
"import streamlit as st\n"
"st.query_params.update(page='월정산', month='2026-09')\n"
"from dashboard.page_views.monthly_settlement import page_monthly_settlement\n"
"page_monthly_settlement('synthetic-unused.sqlite3')", default_timeout=30)
app.run()
+2 -4
View File
@@ -90,8 +90,8 @@ def test_streamlit_apptest_core_pages_use_synthetic_fixture_db(tmp_path, monkeyp
assert "상태: 정상" in body_text
assert "자산 상세: 경고 3건 / 제외 1건" in body_text
assert "월정산 결과 / Evidence" in body_text
assert "읽기 전용 근거 요약" in body_text
assert "이번 범위에는 월정산 close 저장/실행이 포함되지 않습니다" in body_text
assert "근거 요약" in body_text
assert "정산 전 운영 블로커 점검" in body_text
assert "다음 액션" in body_text
assert "예산 조정 후보" in body_text
assert "분류/검토 규칙 후보" in body_text
@@ -120,7 +120,6 @@ def test_streamlit_apptest_core_pages_use_synthetic_fixture_db(tmp_path, monkeyp
assert "선택 월: 2026-06" in body_text
assert "기준일: 2026-06-30" in body_text
assert "기준 시각" in body_text
assert "모드: 읽기 전용" in body_text
assert "확인필요 큐로 이동" in body_text
assert "계좌 화면에서 잔액대조 확인" in body_text
assert "Import 화면에서 이력 확인" in body_text
@@ -129,7 +128,6 @@ def test_streamlit_apptest_core_pages_use_synthetic_fixture_db(tmp_path, monkeyp
assert ".local/share/jarca/uploads" not in body_text
assert "/home/" not in body_text
assert "raw_payload" not in body_text
assert "🔒 읽기 전용" in body_text
def _dashboard_body(at):
+12
View File
@@ -43,6 +43,18 @@ def test_sidebar_selection_updates_only_page_query_parameter(monkeypatch):
assert query == {"page": "거래내역", "month": "2026-06", "account_id": "checking"}
def test_sidebar_monthly_settlement_serializes_canonical_page(monkeypatch):
query = {"page": "요약", "month": "2026-06"}
state = {"dashboard_page": "월정산"}
callback = {}
monkeypatch.setattr(common.st, "query_params", query)
monkeypatch.setattr(common.st, "session_state", state)
monkeypatch.setattr(common.st, "radio", lambda *args, **kwargs: callback.setdefault("fn", kwargs["on_change"]) or state["dashboard_page"])
common.render_sidebar_nav(PAGES, "월정산")
callback["fn"]()
assert query == {"page": "monthly_settlement", "month": "2026-06"}
def test_missing_or_unknown_page_falls_back_to_overview():
assert common.validate_dashboard_page(None, PAGES) == "요약"
assert common.validate_dashboard_page("not-a-page", PAGES) == "요약"
+4 -4
View File
@@ -102,7 +102,7 @@ def test_schema12_migration_preserves_legacy_rows_indexes_and_is_idempotent(tmp_
created_at TEXT NOT NULL, updated_at TEXT NOT NULL
);
CREATE INDEX legacy_transactions_date ON transactions(transaction_date);
CREATE TABLE asset_snapshots (
CREATE TABLE account_snapshot (
id TEXT PRIMARY KEY, basis_date TEXT NOT NULL, source_type TEXT NOT NULL,
source_id TEXT NOT NULL DEFAULT '', name TEXT NOT NULL, amount INTEGER,
currency TEXT NOT NULL DEFAULT 'KRW', liquidity_bucket TEXT NOT NULL DEFAULT 'other',
@@ -112,17 +112,17 @@ def test_schema12_migration_preserves_legacy_rows_indexes_and_is_idempotent(tmp_
INSERT INTO schema_meta(key,value) VALUES('schema_version','11');
INSERT INTO accounts(id,name,type,currency,created_at,updated_at) VALUES('a','old','bank','KRW','t','t');
INSERT INTO transactions(id,transaction_date,type,amount,created_at,updated_at) VALUES('t','2026-01-01','expense',10,'t','t');
INSERT INTO asset_snapshots(id,basis_date,source_type,name,amount,created_at) VALUES('s','2026-01-01','account','old',10,'t');
INSERT INTO account_snapshot(id,basis_date,source_type,name,amount,created_at) VALUES('s','2026-01-01','account','old',10,'t');
""")
init_db(path)
init_db(path)
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT name FROM accounts WHERE id='a'").fetchone()
assert conn.execute("SELECT id FROM transactions WHERE id='t'").fetchone()
assert conn.execute("SELECT name FROM asset_snapshots WHERE id='s'").fetchone()
assert conn.execute("SELECT name FROM account_snapshot WHERE id='s'").fetchone()
indexes = {r[1] for r in conn.execute("PRAGMA index_list(transactions)")}
assert {"legacy_transactions_date", "idx_transactions_datetime"} <= indexes
assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "14"
assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "16"
def test_snapshot_close_idempotency_fingerprint_and_concurrent_first_close(tmp_path):
+1 -1
View File
@@ -6,7 +6,7 @@ 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"] == "14"
assert health["schema_version"] == "15"
created = service.create_ledger_category("반려동물", "사료", sort_order=250, memo="test", db_path=db_path)
assert created["success"] is True
+30 -6
View File
@@ -16,10 +16,22 @@ def _legacy_closed_db(tmp_path):
init_db(path)
asset = service.create_manual_asset("legacy cash", 100, "2026-06-01", "cash", db_path=path)["manual_asset"]
with sqlite3.connect(path) as conn:
# These are schema-11 close rows after the additive schema-12 migration:
# run identity is absent, but row close/correction fields remain frozen.
# Rebuild the historical schema-11 table independently; do not insert
# removed columns into the current schema-16 table.
conn.execute("DROP TABLE account_snapshot")
conn.execute("""CREATE TABLE account_snapshot (
id TEXT PRIMARY KEY, basis_date TEXT NOT NULL, source_type TEXT NOT NULL,
source_id TEXT NOT NULL, name TEXT NOT NULL, amount INTEGER,
currency TEXT NOT NULL DEFAULT 'KRW', liquidity_bucket TEXT NOT NULL DEFAULT 'other',
valuation_source TEXT NOT NULL DEFAULT '', source_note TEXT NOT NULL DEFAULT '', memo TEXT NOT NULL DEFAULT '',
snapshot_run_id TEXT NOT NULL DEFAULT '', correction_of_run_id TEXT NOT NULL DEFAULT '',
correction_reason TEXT NOT NULL DEFAULT '', is_correction INTEGER NOT NULL DEFAULT 0,
closed_at TEXT NOT NULL DEFAULT '', valuation_id TEXT NOT NULL DEFAULT '',
selection_policy TEXT NOT NULL DEFAULT 'exact', verification_status TEXT NOT NULL DEFAULT 'unverified',
evidence_kind TEXT NOT NULL DEFAULT 'applied', exclusion_reason TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL
)""")
conn.execute(
"""INSERT INTO asset_snapshots
"""INSERT INTO account_snapshot
(id, basis_date, source_type, source_id, name, amount, liquidity_bucket,
valuation_source, snapshot_run_id, correction_of_run_id, correction_reason,
is_correction, closed_at, created_at)
@@ -32,7 +44,7 @@ def _legacy_closed_db(tmp_path):
conn.execute("UPDATE schema_meta SET value='11' WHERE key='schema_version'")
init_db(path)
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs WHERE close_status <> 'legacy_read_only'").fetchone()[0] == 0
return path, asset["id"]
@@ -102,6 +114,18 @@ def test_schema11_latest_legacy_correction_run_is_selected_as_a_complete_group(t
path = str(tmp_path / "legacy-corrections.sqlite3")
init_db(path)
with sqlite3.connect(path) as conn:
conn.execute("DROP TABLE account_snapshot")
conn.execute("""CREATE TABLE account_snapshot (
id TEXT PRIMARY KEY, basis_date TEXT NOT NULL, source_type TEXT NOT NULL,
source_id TEXT NOT NULL, name TEXT NOT NULL, amount INTEGER,
currency TEXT NOT NULL DEFAULT 'KRW', liquidity_bucket TEXT NOT NULL DEFAULT 'other',
valuation_source TEXT NOT NULL DEFAULT '', source_note TEXT NOT NULL DEFAULT '', memo TEXT NOT NULL DEFAULT '',
snapshot_run_id TEXT NOT NULL DEFAULT '', correction_of_run_id TEXT NOT NULL DEFAULT '',
correction_reason TEXT NOT NULL DEFAULT '', is_correction INTEGER NOT NULL DEFAULT 0,
closed_at TEXT NOT NULL DEFAULT '', valuation_id TEXT NOT NULL DEFAULT '',
selection_policy TEXT NOT NULL DEFAULT 'exact', verification_status TEXT NOT NULL DEFAULT 'unverified',
evidence_kind TEXT NOT NULL DEFAULT 'applied', exclusion_reason TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL
)""")
rows = [
("old-a", "legacy-run-1", "", "", 0, "2026-06-30T10:00:00+09:00", 40),
("old-b", "legacy-run-1", "", "", 0, "2026-06-30T10:00:00+09:00", 60),
@@ -110,7 +134,7 @@ def test_schema11_latest_legacy_correction_run_is_selected_as_a_complete_group(t
]
for row_id, run_id, prior, reason, correction, closed_at, amount in rows:
conn.execute(
"""INSERT INTO asset_snapshots
"""INSERT INTO account_snapshot
(id, basis_date, source_type, source_id, name, amount, currency,
liquidity_bucket, valuation_source, snapshot_run_id,
correction_of_run_id, correction_reason, is_correction,
@@ -124,7 +148,7 @@ def test_schema11_latest_legacy_correction_run_is_selected_as_a_complete_group(t
conn.execute("UPDATE schema_meta SET value='11' WHERE key='schema_version'")
init_db(path)
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs WHERE close_status <> 'legacy_read_only'").fetchone()[0] == 0
before = hashlib.sha256(Path(path).read_bytes()).hexdigest()
report = service.get_asset_snapshot_report(BASIS, db_path=path)
+66
View File
@@ -0,0 +1,66 @@
import sqlite3
from jarca_ledger_mcp.application.accounts_write_services import create_ledger_account
from jarca_ledger_mcp.application.assets_service import (
create_manual_asset,
list_manual_assets,
preview_asset_snapshot_close,
)
from jarca_ledger_mcp.infrastructure.sqlite.migrations import init_db
def tables(path):
with sqlite3.connect(path) as conn:
return {r[0] for r in conn.execute("select name from sqlite_master where type='table'")}
def test_fresh_and_repeated_init_never_create_manual_assets(tmp_path):
path = str(tmp_path / "fresh.sqlite3")
init_db(path)
init_db(path)
assert "manual_assets" not in tables(path)
def test_existing_legacy_manual_assets_is_dropped_without_other_row_loss(tmp_path):
path = str(tmp_path / "legacy.sqlite3")
init_db(path)
with sqlite3.connect(path) as conn:
conn.execute("create table manual_assets (id text primary key, name text)")
conn.execute("insert into manual_assets values ('old', 'legacy')")
conn.execute("insert into accounts (id,name,type,active,created_at,updated_at) values ('a','Keep','bank',1,'t','t')")
conn.commit()
init_db(path)
with sqlite3.connect(path) as conn:
assert conn.execute("select count(*) from accounts where id='a'").fetchone()[0] == 1
assert "manual_assets" not in tables(path)
def test_manual_asset_public_operations_are_unsupported(tmp_path):
path = str(tmp_path / "unsupported.sqlite3")
init_db(path)
expected = {"success": False, "error_code": "unsupported", "error": "manual assets are no longer supported"}
assert create_manual_asset("Emergency", 100, "2026-09-30", "cash", db_path=path) == expected
assert list_manual_assets(db_path=path) == expected
def test_account_real_estate_creation_and_snapshot_preview_use_normal_path(tmp_path):
path = str(tmp_path / "real-estate.sqlite3")
init_db(path)
created = create_ledger_account(
"House", "other", liquidity_bucket="real_estate", last_balance=500000,
last_balance_basis_date="2026-09-30", db_path=path,
)
assert created["success"] is True
assert created["account"]["liquidity_bucket"] == "real_estate"
assert preview_asset_snapshot_close("2026-09-30", db_path=path)["success"] is True
def test_manual_assets_absent_after_initialization_with_existing_accounts(tmp_path):
path = str(tmp_path / "accounts.sqlite3")
init_db(path)
with sqlite3.connect(path) as conn:
before = conn.execute("select count(*) from accounts").fetchone()[0]
init_db(path)
with sqlite3.connect(path) as conn:
assert conn.execute("select count(*) from accounts").fetchone()[0] == before
assert "manual_assets" not in tables(path)
+92
View File
@@ -0,0 +1,92 @@
from __future__ import annotations
import sqlite3
import pytest
from jarca_ledger_mcp.db import init_db
def _insert_sql():
return """INSERT INTO monthly_settlement
(id, month, basis_date, status, version, created_by, created_at, updated_by, updated_at)
VALUES (?, ?, ?, 'draft', 1, 'tester', '2026-10-15T00:00:00+09:00', 'tester', '2026-10-15T00:00:00+09:00')"""
def test_fresh_db_has_month_settlement_schema_16_without_latest_run_id(tmp_path):
path = str(tmp_path / "fresh.sqlite3")
init_db(path)
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "16"
assert conn.execute("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='monthly_settlement'").fetchone()[0] == 1
columns = {row[1]: row for row in conn.execute("PRAGMA table_info(monthly_settlement)")}
assert columns["approval_flags_json"][4] == "'{}'"
assert columns["memo"][4] == "''"
columns = {row[1] for row in conn.execute("PRAGMA table_info(monthly_settlement)")}
assert "latest_run_id" not in columns
assert conn.execute("PRAGMA foreign_key_list(monthly_settlement)").fetchall() == []
conn.execute(_insert_sql(), ("m1", "2026-09", "2026-09-30"))
row = conn.execute("SELECT memo, approval_flags_json FROM monthly_settlement").fetchone()
assert row == ("", "{}")
def test_migration_is_idempotent_and_preserves_legacy_drafts(tmp_path):
path = str(tmp_path / "existing.sqlite3")
init_db(path)
with sqlite3.connect(path) as conn:
conn.execute("INSERT INTO settlement_drafts (id, month, basis_date, status, version, created_by, created_at, updated_at, create_key) VALUES ('draft-1','2026-09','2026-09-30','active',1,'u','t','t','k')")
before = conn.execute("SELECT id, month, basis_date, status FROM settlement_drafts").fetchall()
init_db(path)
init_db(path)
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT id, month, basis_date, status FROM settlement_drafts").fetchall() == before
assert conn.execute("SELECT COUNT(*) FROM monthly_settlement").fetchone()[0] == 0
def test_schema15_upgrade_drops_only_latest_run_id_and_preserves_closed_row(tmp_path):
path = str(tmp_path / "schema15.sqlite3")
init_db(path)
with sqlite3.connect(path) as conn:
conn.execute("ALTER TABLE monthly_settlement ADD COLUMN latest_run_id TEXT REFERENCES asset_snapshot_runs(id) ON DELETE RESTRICT")
conn.execute("UPDATE schema_meta SET value='15' WHERE key='schema_version'")
conn.execute("""INSERT INTO monthly_settlement
(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at,
approved_by,approved_at,approved_version,approved_fingerprint,closed_by,closed_at,latest_run_id)
VALUES ('m1','2026-09','2026-09-30','closed',2,'u','t','u','t','r','t',1,'fp','c','t','run-1')""")
before = conn.execute("SELECT id,month,basis_date,status,version,memo,approved_by,closed_by FROM monthly_settlement").fetchall()
init_db(path)
init_db(path)
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "16"
assert conn.execute("SELECT id,month,basis_date,status,version,memo,approved_by,closed_by FROM monthly_settlement").fetchall() == before
assert "latest_run_id" not in {row[1] for row in conn.execute("PRAGMA table_info(monthly_settlement)")}
assert conn.execute("PRAGMA foreign_key_list(monthly_settlement)").fetchall() == []
@pytest.mark.parametrize("column,value", [("month", "2026-13"), ("month", "202609"), ("basis_date", "2026-02-30"), ("version", 0)])
def test_month_settlement_rejects_invalid_values(tmp_path, column, value):
path = str(tmp_path / "invalid.sqlite3")
init_db(path)
values = {"month": "2026-09", "basis_date": "2026-09-30", "version": 1}
values[column] = value
with sqlite3.connect(path) as conn, pytest.raises(sqlite3.IntegrityError):
conn.execute(
"INSERT INTO monthly_settlement (id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) VALUES (?,?,?,?,?,?,?,?,?)",
("bad", values["month"], values["basis_date"], "draft", values["version"], "u", "t", "u", "t"),
)
def test_status_condition_checks_and_month_uniqueness(tmp_path):
path = str(tmp_path / "conditions.sqlite3")
init_db(path)
with sqlite3.connect(path) as conn:
conn.execute(_insert_sql(), ("m1", "2026-09", "2026-09-30"))
with pytest.raises(sqlite3.IntegrityError):
conn.execute(_insert_sql(), ("m2", "2026-09", "2026-09-30"))
with pytest.raises(sqlite3.IntegrityError):
conn.execute("UPDATE monthly_settlement SET status='approved' WHERE id='m1'")
with pytest.raises(sqlite3.IntegrityError):
conn.execute("UPDATE monthly_settlement SET status='cancelled' WHERE id='m1'")
conn.execute("UPDATE monthly_settlement SET status='approved', approved_by='reviewer', approved_at='t', approved_version=1, approved_fingerprint='fp' WHERE id='m1'")
with pytest.raises(sqlite3.IntegrityError):
conn.execute("UPDATE monthly_settlement SET status='closed' WHERE id='m1'")
@@ -0,0 +1,104 @@
from __future__ import annotations
import sqlite3
import pytest
from jarca_ledger_mcp.application.monthly_settlement_service import (
approve_monthly_settlement,
close_monthly_settlement,
create_monthly_settlement,
review_monthly_settlement,
)
from jarca_ledger_mcp.adapters.streamlit import settlement_gateway
from jarca_ledger_mcp.application.transaction_report_services import get_settlement_cashflow_snapshot
from jarca_ledger_mcp.infrastructure.sqlite.migrations import init_db
def _db(tmp_path, rows=()):
path = tmp_path / "monthly-cashflow-actual.sqlite3"
init_db(str(path))
with sqlite3.connect(path) as c:
for row in rows:
c.execute("INSERT INTO transactions(id,transaction_date,type,amount,currency,category,subcategory,account_id,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)", (*row, "2026-10-01T00:00:00Z", "2026-10-01T00:00:00Z"))
c.commit()
return path
def _row(i, typ="expense", amount=100, currency="KRW", category="Food"):
return (i, "2026-10-10", typ, amount, currency, category, "Main", "acct")
def _review_approve(path):
s = create_monthly_settlement("2026-10", db_path=str(path)); sid = s["settlement_id"]
reviewed = review_monthly_settlement(sid, db_path=str(path))
assert reviewed["success"] and reviewed["version"] == 1 and reviewed["review_fingerprint"]
approved = approve_monthly_settlement(sid, 1, reviewed["review_fingerprint"], db_path=str(path))
assert approved["success"] and approved["status"] == "approved" and approved["version"] == 1
return sid, reviewed, approved
def test_gateway_cancels_approval_against_actual_temp_db(tmp_path):
path = _db(tmp_path, [_row("t1")])
sid, _, _ = _review_approve(path)
result = settlement_gateway.cancel_monthly_settlement_approval(sid, 1, db_path=str(path))
assert result == {"success": True, "settlement_id": sid, "status": "in_review", "version": 2}
with sqlite3.connect(path) as c:
row = c.execute("SELECT status, version, approved_by, approved_at, approved_cashflow_fingerprint FROM monthly_settlement WHERE id=?", (sid,)).fetchone()
assert row == ("in_review", 2, None, None, None)
def test_review_approve_close_persists_and_freezes_original_ledger(tmp_path):
path = _db(tmp_path, [_row("t1")]); sid, reviewed, _ = _review_approve(path)
closed = close_monthly_settlement(sid, 1, db_path=str(path))
assert closed["success"] and closed["status"] == "closed" and closed["snapshot_count"] == 1
with sqlite3.connect(path) as c:
c.execute("UPDATE transactions SET amount=999 WHERE id='t1'"); c.commit()
saved = get_settlement_cashflow_snapshot(sid, db_path=str(path))
assert saved["saved"] and saved["categories"][0]["amount"] == 100
def test_review_then_ledger_edit_rejects_approval(tmp_path):
path = _db(tmp_path, [_row("t1")]); s = create_monthly_settlement("2026-10", db_path=str(path)); r = review_monthly_settlement(s["settlement_id"], db_path=str(path))
with sqlite3.connect(path) as c: c.execute("UPDATE transactions SET amount=101 WHERE id='t1'"); c.commit()
out = approve_monthly_settlement(s["settlement_id"], 1, r["review_fingerprint"], db_path=str(path))
assert not out["success"] and out["error_code"] == "stale_review"
def test_approve_then_offset_transaction_rejects_close_even_same_total(tmp_path):
path = _db(tmp_path, [_row("t1", amount=100)]); sid, _, _ = _review_approve(path)
with sqlite3.connect(path) as c:
c.execute("UPDATE transactions SET amount=50 WHERE id='t1'")
c.execute("INSERT INTO transactions(id,transaction_date,type,amount,currency,category,subcategory,account_id,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)", _row("t2", amount=50) + ("2026-10-01T00:00:00Z", "2026-10-01T00:00:00Z")); c.commit()
out = close_monthly_settlement(sid, 1, db_path=str(path))
assert not out["success"] and out["error_code"] == "stale_cashflow"
def test_null_fingerprint_close_rejects(tmp_path):
path = _db(tmp_path); sid, _, _ = _review_approve(path)
with sqlite3.connect(path) as c: c.execute("UPDATE monthly_settlement SET approved_cashflow_fingerprint=NULL WHERE id=?", (sid,)); c.commit()
out = close_monthly_settlement(sid, 1, db_path=str(path)); assert not out["success"] and out["error_code"] == "missing_fingerprint"
def test_close_snapshot_insert_failure_rolls_back_status(tmp_path, monkeypatch):
path = _db(tmp_path, [_row("t1")]); sid, _, _ = _review_approve(path)
with sqlite3.connect(path) as c: c.execute("CREATE TRIGGER fail_snapshot BEFORE INSERT ON settlement_cashflow_snapshots BEGIN SELECT RAISE(ABORT,'boom'); END;") ; c.commit()
out = close_monthly_settlement(sid, 1, db_path=str(path)); assert not out["success"]
with sqlite3.connect(path) as c: assert c.execute("SELECT status FROM monthly_settlement WHERE id=?", (sid,)).fetchone()[0] == "approved"
@pytest.mark.parametrize("currency", ["KRW", "USD"])
def test_multifx_rows_remain_separate(tmp_path, currency):
path = _db(tmp_path, [_row("a", amount=100, currency="KRW"), _row("b", amount=7, currency=currency)])
sid, _, _ = _review_approve(path); assert close_monthly_settlement(sid, 1, db_path=str(path))["success"]
saved = get_settlement_cashflow_snapshot(sid, db_path=str(path)); assert {x["currency"] for x in saved["categories"]} >= {"KRW", currency}
def test_empty_close_is_saved_without_fake_marker(tmp_path):
path = _db(tmp_path); sid, _, _ = _review_approve(path); closed = close_monthly_settlement(sid, 1, db_path=str(path)); assert closed["snapshot_count"] == 0
saved = get_settlement_cashflow_snapshot(sid, db_path=str(path)); assert saved["saved"] and saved["categories"] == []
def test_double_close_is_idempotent(tmp_path):
path = _db(tmp_path, [_row("t1")]); sid, _, _ = _review_approve(path); first = close_monthly_settlement(sid, 1, db_path=str(path)); second = close_monthly_settlement(sid, 999, db_path=str(path))
assert first["success"] and second["success"] and second["idempotent"] is True
@@ -0,0 +1,81 @@
from __future__ import annotations
import sqlite3
from jarca_ledger_mcp.db import init_db
FIELD = "approved_cashflow_fingerprint"
def _column(path: str) -> tuple:
with sqlite3.connect(path) as conn:
return next(row for row in conn.execute("PRAGMA table_info(monthly_settlement)") if row[1] == FIELD)
def test_fresh_schema_has_nullable_text_field_without_default(tmp_path):
path = str(tmp_path / "fresh.sqlite3")
init_db(path)
column = _column(path)
assert column[2].upper() == "TEXT"
assert column[3] == 0
assert column[4] is None
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "15"
conn.execute(
"INSERT INTO monthly_settlement "
"(id, month, basis_date, status, version, created_by, created_at, updated_by, updated_at) "
"VALUES (?, ?, ?, 'draft', 1, 'tester', 'now', 'tester', 'now')",
("m1", "2026-09", "2026-09-30"),
)
assert conn.execute(f"SELECT {FIELD} FROM monthly_settlement WHERE id='m1'").fetchone() == (None,)
def test_same_version_existing_db_upgrade_is_idempotent_and_preserves_rows(tmp_path):
path = str(tmp_path / "existing.sqlite3")
init_db(path)
with sqlite3.connect(path) as conn:
conn.execute(
"INSERT INTO monthly_settlement "
"(id, month, basis_date, status, version, created_by, created_at, updated_by, updated_at) "
"VALUES ('m1', '2026-09', '2026-09-30', 'draft', 1, 'tester', 'now', 'tester', 'now')"
)
conn.execute(f"UPDATE monthly_settlement SET {FIELD}='cashflow-fp' WHERE id='m1'")
before = conn.execute("SELECT id, month, status, version, memo, approved_fingerprint, approved_cashflow_fingerprint FROM monthly_settlement").fetchall()
before_tables = conn.execute("SELECT name, sql FROM sqlite_master WHERE type IN ('table', 'index') AND name NOT LIKE 'monthly_settlement%' AND name <> 'sqlite_sequence' ORDER BY name").fetchall()
init_db(path)
init_db(path)
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "15"
assert conn.execute("SELECT id, month, status, version, memo, approved_fingerprint, approved_cashflow_fingerprint FROM monthly_settlement").fetchall() == before
assert conn.execute("SELECT name, sql FROM sqlite_master WHERE type IN ('table', 'index') AND name NOT LIKE 'monthly_settlement%' AND name <> 'sqlite_sequence' ORDER BY name").fetchall() == before_tables
def test_schema16_db_missing_field_gets_only_targeted_additive_column(tmp_path):
path = str(tmp_path / "legacy16.sqlite3")
init_db(path)
with sqlite3.connect(path) as conn:
conn.execute("ALTER TABLE monthly_settlement RENAME TO monthly_settlement_without_fingerprint")
conn.execute(
"""CREATE TABLE monthly_settlement (
id TEXT PRIMARY KEY, month TEXT NOT NULL UNIQUE, basis_date TEXT NOT NULL,
status TEXT NOT NULL, version INTEGER NOT NULL, memo TEXT NOT NULL DEFAULT '',
created_by TEXT NOT NULL, created_at TEXT NOT NULL, updated_by TEXT NOT NULL,
updated_at TEXT NOT NULL, reviewed_by TEXT, reviewed_at TEXT, review_fingerprint TEXT,
approved_by TEXT, approved_at TEXT, approved_version INTEGER, approved_fingerprint TEXT,
approval_reason TEXT, approval_flags_json TEXT NOT NULL DEFAULT '{}', closed_by TEXT,
closed_at TEXT, cancelled_by TEXT, cancelled_at TEXT, cancellation_reason TEXT
)"""
)
conn.execute("INSERT INTO monthly_settlement SELECT id,month,basis_date,status,version,memo,created_by,created_at,updated_by,updated_at,reviewed_by,reviewed_at,review_fingerprint,approved_by,approved_at,approved_version,approved_fingerprint,approval_reason,approval_flags_json,closed_by,closed_at,cancelled_by,cancelled_at,cancellation_reason FROM monthly_settlement_without_fingerprint")
conn.execute("DROP TABLE monthly_settlement_without_fingerprint")
conn.execute("INSERT INTO schema_meta(key,value) VALUES('schema_version','16') ON CONFLICT(key) DO UPDATE SET value='16'")
conn.execute(
"INSERT INTO monthly_settlement(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) "
"VALUES ('m1','2026-10','2026-10-31','draft',1,'u','t','u','t')"
)
init_db(path)
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "15"
assert _column(path)[2].upper() == "TEXT"
assert conn.execute("SELECT approved_cashflow_fingerprint FROM monthly_settlement WHERE id='m1'").fetchone() == (None,)
+29
View File
@@ -0,0 +1,29 @@
from __future__ import annotations
import sqlite3
from jarca_ledger_mcp.application.monthly_settlement_service import create_monthly_settlement
from jarca_ledger_mcp.infrastructure.sqlite.migrations import init_db
def test_create_monthly_settlement_persists_canonical_draft_and_rejects_duplicate(tmp_path):
path = tmp_path / "ledger.sqlite3"
init_db(str(path))
created = create_monthly_settlement("2026-10", db_path=str(path))
assert created["success"]
assert created["month"] == "2026-10"
assert created["status"] == "draft"
assert created["settlement_id"]
with sqlite3.connect(path) as conn:
row = conn.execute(
"SELECT id, month, basis_date, status FROM monthly_settlement WHERE id=?",
(created["settlement_id"],),
).fetchone()
assert row == (created["settlement_id"], "2026-10", "2026-10-31", "draft")
duplicate = create_monthly_settlement("2026-10", db_path=str(path))
assert duplicate["success"] is False
assert duplicate["error_code"] == "duplicate_month"
assert duplicate["settlement_id"] == created["settlement_id"]
+6 -6
View File
@@ -61,7 +61,7 @@ def test_empty_draft_persists_without_closing_and_has_consistent_public_shape(tm
assert isinstance(result["review"], dict)
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0] == 0
fetched = assert_ok(api("get_settlement_draft")(draft["id"], db_path=path))
assert fetched["draft"]["id"] == draft["id"]
listed = assert_ok(api("list_settlement_drafts")(month="2026-09", db_path=path))
@@ -216,7 +216,7 @@ def test_finalize_persists_immutable_provenance_replays_exact_response_and_confl
assert conflict == final
with sqlite3.connect(path) as conn:
run_count = conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0]
row_count = conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0]
row_count = conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0]
assert run_count == 1 and row_count >= 1
assert final["draft_id"] == draft["id"]
assert final["draft_version"] == draft["version"]
@@ -236,12 +236,12 @@ def test_sqlite_trigger_failure_rolls_back_run_rows_and_completion(tmp_path):
approved = api("approve_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "approver", "approved", "ref", allow_missing_exclusions=True, db_path=path)
assert approved.get("success")
with sqlite3.connect(path) as conn:
conn.execute("CREATE TRIGGER fail_snapshot_insert BEFORE INSERT ON asset_snapshots BEGIN SELECT RAISE(ABORT, 'injected failure'); END")
conn.execute("CREATE TRIGGER fail_snapshot_insert BEFORE INSERT ON account_snapshot BEGIN SELECT RAISE(ABORT, 'injected failure'); END")
result = api("finalize_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "failure-key", db_path=path)
assert result.get("success") is False
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0] == 0
after = assert_ok(api("get_settlement_draft")(draft["id"], db_path=path))
assert after["draft"]["status"] == "active"
@@ -255,8 +255,8 @@ def test_schema13_migration_is_additive_and_idempotent(tmp_path):
init_db(path)
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT id FROM asset_snapshot_runs WHERE id='legacy-run'").fetchone()[0] == 'legacy-run'
assert {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} >= {'settlement_drafts','settlement_draft_items','asset_snapshot_runs','asset_snapshots'}
assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == '14'
assert {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} >= {'settlement_drafts','settlement_draft_items','asset_snapshot_runs','account_snapshot'}
assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == '16'
def test_concurrent_create_and_stale_version_finalization_are_safe(tmp_path):
+44
View File
@@ -0,0 +1,44 @@
from pathlib import Path
import sqlite3
from jarca_ledger_mcp.db import init_db
from jarca_ledger_mcp.application.monthly_settlement_service import (
get_monthly_settlement_history_record,
list_monthly_settlement_history,
)
def test_monthly_history_lists_close_and_correction_and_reads_one_record(tmp_path: Path):
db = tmp_path / "history.sqlite3"
init_db(str(db))
with sqlite3.connect(db) as conn:
conn.execute(
"""INSERT INTO asset_snapshot_runs
(id,basis_date,close_status,correction_of_run_id,correction_reason,
created_at,settlement_month,draft_version,approved_at)
VALUES (?,?,?,?,?,?,?,?,?)""",
("run-1", "2026-09-30", "complete_exact", "", "",
"2026-10-01T00:00:00Z", "2026-09", 1, "2026-10-01T00:00:00Z"),
)
conn.execute(
"""INSERT INTO asset_snapshot_runs
(id,basis_date,close_status,correction_of_run_id,correction_reason,
created_at,settlement_month,draft_version,approved_at)
VALUES (?,?,?,?,?,?,?,?,?)""",
("run-2", "2026-09-30", "conditional", "run-1", "누락 거래 반영",
"2026-10-02T00:00:00Z", "2026-09", 2, "2026-10-02T00:00:00Z"),
)
conn.commit()
listed = list_monthly_settlement_history("2026-09", db_path=str(db))
assert listed["success"]
assert [row["run_id"] for row in listed["history"]] == ["run-2", "run-1"]
correction = listed["history"][0]
assert correction["version"] == 2
assert correction["correction_target"] == "run-1"
assert correction["reason"] == "누락 거래 반영"
one = get_monthly_settlement_history_record("run-2", db_path=str(db))
assert one["success"]
assert one["history"]["run_id"] == "run-2"
assert one["history"]["closed_at"] == "2026-10-02T00:00:00Z"
+237
View File
@@ -0,0 +1,237 @@
from __future__ import annotations
import sqlite3
from jarca_ledger_mcp.db import init_db
from dashboard.page_views import monthly_settlement as view
class _FakeStreamlit:
def __init__(self, query=None):
self.query_params = dict(query or {})
self.session_state = {}
self.dataframes = []
self.errors = []
self.headers = []
self.titles = []
self.buttons = []
self.markdowns = []
def header(self, value):
self.headers.append(value)
def title(self, value):
self.titles.append(value)
def caption(self, value):
pass
def markdown(self, value, **kwargs):
self.markdowns.append(value)
def write(self, value):
self.markdowns.append(str(value))
def selectbox(self, label, options, **kwargs):
return options[0]
def info(self, value):
pass
def error(self, value):
self.errors.append(value)
def dataframe(self, value, **kwargs):
self.dataframes.append((value, kwargs))
class _Column:
def __enter__(self):
return self
def __exit__(self, *args):
return False
def button(self, label, **kwargs):
return False
def popover(self, *args, **kwargs):
return self
def columns(self, spec):
return [self._Column() for _ in spec]
def container(self, **kwargs):
return self._Column()
def button(self, label, **kwargs):
self.buttons.append((label, kwargs))
return False
def popover(self, *args, **kwargs):
return self._Column()
def rerun(self):
return None
def _fixture_db(tmp_path):
path = tmp_path / "monthly-navigation.sqlite3"
init_db(path)
with sqlite3.connect(path) as conn:
conn.execute(
"""INSERT INTO monthly_settlement
(id, month, basis_date, status, version, created_by, created_at, updated_by, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
("settlement-2026-09", "2026-09", "2026-09-30", "draft", 1, "test", "2026-09-30T00:00:00Z", "test", "2026-09-30T00:00:00Z"),
)
conn.commit()
return path
def test_month_text_link_uses_exact_canonical_id_and_direct_detail_reads_month(tmp_path, monkeypatch):
db_path = _fixture_db(tmp_path)
fake = _FakeStreamlit()
monkeypatch.setattr(view, "st", fake)
monkeypatch.setattr(view.overview_gateway, "list_monthly_settlement_overview", lambda **kwargs: {
"success": True,
"months": [{"settlement_id": "settlement-2026-09", "month": "2026-09", "status_label": "초안", "income": 100}],
})
view._render_monthly_list(str(db_path))
month_buttons = [item for item in fake.buttons if item[0] == "2026-09"]
assert len(month_buttons) == 1
assert month_buttons[0][1]["type"] == "tertiary"
assert month_buttons[0][1]["on_click"] is view._set_monthly_detail
assert month_buttons[0][1]["args"] == ("settlement-2026-09", "전체", "전체")
assert "?page=monthly_settlement" not in "\n".join(fake.markdowns)
month_buttons[0][1]["on_click"](*month_buttons[0][1]["args"])
assert fake.query_params == {"page": "monthly_settlement", "id": "settlement-2026-09"}
assert not fake.dataframes
detail_fake = _FakeStreamlit({"page": "monthly_settlement", "id": "settlement-2026-09"})
monkeypatch.setattr(view, "st", detail_fake)
calls = []
monkeypatch.setattr(view, "_render_monthly_detail", lambda db, month: calls.append((db, month)))
view.page_monthly_settlement(str(db_path))
assert calls == [(str(db_path), "2026-09")]
assert detail_fake.titles == ["월정산 상세 · 2026-09"]
assert [label for label, _ in detail_fake.buttons] == ["← 월정산 목록", "검토승인"]
def test_unregistered_month_selection_does_not_create_none_link(monkeypatch):
fake = _FakeStreamlit()
monkeypatch.setattr(view, "st", fake)
monkeypatch.setattr(view.overview_gateway, "list_monthly_settlement_overview", lambda **kwargs: {
"success": True,
"months": [{"settlement_id": None, "month": "2026-08", "status_label": "정산 미생성·상세 선택 불가·현재집계(미확정)"}],
})
view._render_monthly_list("fixture.sqlite3")
rendered = "\n".join(fake.markdowns)
assert "2026-08" in rendered
assert "[2026-08]" not in rendered
assert "?page=monthly_settlement&id=None" not in rendered
def test_direct_reload_unknown_id_does_not_fallback_to_month_or_list(tmp_path, monkeypatch):
db_path = _fixture_db(tmp_path)
fake = _FakeStreamlit({"page": "monthly_settlement", "id": "unknown-id", "month": "2026-09"})
monkeypatch.setattr(view, "st", fake)
calls = []
monkeypatch.setattr(view, "_render_monthly_list", lambda db: calls.append(("list", db)))
view.page_monthly_settlement(str(db_path))
assert calls == []
assert fake.errors == ["월정산을 찾을 수 없습니다."]
def test_legacy_month_query_rewrites_to_canonical_id(tmp_path, monkeypatch):
db_path = _fixture_db(tmp_path)
fake = _FakeStreamlit({"page": "monthly_settlement", "month": "2026-09"})
monkeypatch.setattr(view, "st", fake)
monkeypatch.setattr(view.overview_gateway, "list_monthly_settlement_overview", lambda **kwargs: {
"success": True,
"months": [{"settlement_id": "settlement-2026-09", "month": "2026-09", "status_label": "초안"}],
})
monkeypatch.setattr(view, "_render_monthly_detail", lambda db, month: None)
view.page_monthly_settlement(str(db_path))
assert fake.query_params == {"page": "monthly_settlement", "id": "settlement-2026-09"}
def test_back_to_list_uses_canonical_page_and_preserves_filters(monkeypatch):
fake = _FakeStreamlit({"page": "monthly_settlement", "id": "settlement-2026-09", "year": "2026", "status": "미마감"})
monkeypatch.setattr(view, "st", fake)
view._clear_monthly_detail("2026", "미마감")
assert fake.query_params == {"page": "monthly_settlement", "year": "2026", "status": "미마감"}
assert fake.session_state["dashboard_page"] == "월정산"
def test_add_button_opens_dialog_and_navigates_to_created_canonical_id(monkeypatch):
class DialogFake(_FakeStreamlit):
def __init__(self):
super().__init__()
self.reruns = 0
def dialog(self, title):
assert title == "월정산 추가"
return lambda fn: fn
def text_input(self, label, **kwargs):
assert label == "대상 월"
return "2026-10"
def button(self, label, **kwargs):
self.buttons.append((label, kwargs))
return label in {"+", "추가"}
def rerun(self):
self.reruns += 1
fake = DialogFake()
monkeypatch.setattr(view, "st", fake)
monkeypatch.setattr(view.overview_gateway, "list_monthly_settlement_overview", lambda **kwargs: {"success": True, "months": []})
monkeypatch.setattr(view.settlement_gateway, "create_monthly_settlement", lambda month, **kwargs: {"success": True, "settlement_id": "settlement-2026-10"})
view._render_monthly_list("fixture.sqlite3")
assert fake.query_params == {"page": "monthly_settlement", "id": "settlement-2026-10"}
assert fake.reruns == 1
def test_month_link_target_contains_no_write_operation():
source = view._render_monthly_list.__code__.co_names
assert "create_settlement_draft" not in source
assert "finalize_settlement_draft" not in source
def test_back_button_single_click_clears_detail_query_and_reruns(monkeypatch):
class ClickFake(_FakeStreamlit):
def __init__(self):
super().__init__({"page": "monthly_settlement", "id": "settlement-2026-09", "month": "2026-09", "year": "2026", "status": "미마감"})
self.session_state.update({"dashboard_page": "월정산", "monthly_settlement_detail_id": "settlement-2026-09", "monthly_settlement_detail_month": "2026-09"})
self.reruns = 0
def rerun(self):
self.reruns += 1
fake = ClickFake()
monkeypatch.setattr(view, "st", fake)
view._clear_monthly_detail("2026", "미마감")
assert fake.reruns == 1
assert fake.query_params == {"page": "monthly_settlement", "year": "2026", "status": "미마감"}
assert "monthly_settlement_detail_id" not in fake.session_state
assert "monthly_settlement_detail_month" not in fake.session_state
def test_next_rerun_without_detail_query_does_not_restore_detail(monkeypatch):
fake = _FakeStreamlit({"page": "monthly_settlement", "year": "2026", "status": "미마감"})
fake.session_state.update({"dashboard_page": "월정산"})
monkeypatch.setattr(view, "st", fake)
rendered = []
monkeypatch.setattr(view, "_render_monthly_list", lambda db: rendered.append(db))
monkeypatch.setattr(view, "_render_monthly_detail", lambda db, month: (_ for _ in ()).throw(AssertionError("detail restored")))
view.page_monthly_settlement("fixture.sqlite3")
assert rendered == ["fixture.sqlite3"]
+58
View File
@@ -0,0 +1,58 @@
from pathlib import Path
import sqlite3
from jarca_ledger_mcp.db import init_db
from jarca_ledger_mcp.application.monthly_settlement_service import list_monthly_settlement_overview
def test_monthly_overview_excludes_transaction_only_months(tmp_path: Path):
db = tmp_path / "fixture.sqlite3"
init_db(str(db))
with sqlite3.connect(db) as conn:
conn.execute(
"INSERT INTO transactions(id,transaction_date,type,amount,created_at,updated_at) VALUES (?,?,?,?,?,?)",
("tx-1", "2026-09-03", "income", 100, "2026-09-03T00:00:00Z", "2026-09-03T00:00:00Z"),
)
conn.commit()
result = list_monthly_settlement_overview(db_path=str(db))
assert result["success"]
assert result["months"] == []
def test_monthly_overview_keeps_existing_parent_and_joins_live_transaction_totals(tmp_path: Path):
db = tmp_path / "fixture.sqlite3"
init_db(str(db))
with sqlite3.connect(db) as conn:
conn.execute(
"INSERT INTO monthly_settlement(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) VALUES (?,?,?,?,?,?,?,?,?)",
("settlement-1", "2026-09", "2026-09-30", "draft", 1, "test", "now", "test", "now"),
)
conn.execute(
"INSERT INTO transactions(id,transaction_date,type,amount,created_at,updated_at) VALUES (?,?,?,?,?,?)",
("tx-1", "2026-09-03", "income", 100, "2026-09-03T00:00:00Z", "2026-09-03T00:00:00Z"),
)
conn.commit()
result = list_monthly_settlement_overview(db_path=str(db))
assert result["success"]
assert len(result["months"]) == 1
row = result["months"][0]
assert row["month"] == "2026-09"
assert row["settlement_id"] == "settlement-1"
assert row["income"] == 100
assert row["net_cashflow_display"] is None
assert row["net_worth_display"] is None
def test_monthly_overview_keeps_parent_without_transactions(tmp_path: Path):
db = tmp_path / "fixture.sqlite3"
init_db(str(db))
with sqlite3.connect(db) as conn:
conn.execute(
"INSERT INTO monthly_settlement(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) VALUES (?,?,?,?,?,?,?,?,?)",
("settlement-1", "2026-09", "2026-09-30", "draft", 1, "test", "now", "test", "now"),
)
conn.commit()
row = list_monthly_settlement_overview(db_path=str(db))["months"][0]
assert row["settlement_id"] == "settlement-1"
assert row["income"] == 0
assert row["expense"] == 0
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
import inspect
from jarca_ledger_mcp import service
from jarca_ledger_mcp.adapters.mcp import server
def test_create_real_estate_defaults_to_real_estate_bucket(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
result = service.create_ledger_account("아파트", type="real_estate", db_path=db_path)
assert result["success"] is True
assert result["account"]["type"] == "real_estate"
assert result["account"]["liquidity_bucket"] == "real_estate"
def test_update_other_to_real_estate_preserves_explicit_bucket(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
created = service.create_ledger_account("자산", type="other", db_path=db_path)
updated = service.update_ledger_account(
created["account"]["id"], type="real_estate", db_path=db_path
)
assert updated["success"] is True
assert updated["account"]["type"] == "real_estate"
assert updated["account"]["liquidity_bucket"] == "other"
def test_real_estate_appears_in_list_and_asset_preview(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
created = service.create_ledger_account(
"주택", type="real_estate", last_balance=500_000_000, db_path=db_path
)
listed = service.list_ledger_accounts(db_path=db_path)
assert listed["success"] is True
assert listed["accounts"][0]["liquidity_bucket"] == "real_estate"
preview = service.preview_asset_snapshot_close("2026-10-16", db_path=db_path)
assert preview["success"] is True
assert any(
item["source_id"] == created["account"]["id"]
and item["liquidity_bucket"] == "real_estate"
for item in preview["included_items"]
)
def test_mcp_exposes_real_estate_type_in_create_and_update_schema():
create_annotation = inspect.signature(server.create_ledger_account).parameters["type"].annotation
update_annotation = inspect.signature(server.update_ledger_account).parameters["type"].annotation
assert "real_estate" in str(create_annotation)
assert "real_estate" in str(update_annotation)
def test_unknown_account_type_remains_rejected(tmp_path):
result = service.create_ledger_account(
"알 수 없음", type="property", db_path=str(tmp_path / "ledger.sqlite3")
)
assert result["success"] is False
assert result["error_code"] == "validation_error"
+1 -1
View File
@@ -115,7 +115,7 @@ def test_documented_paths_and_symbols_exist() -> None:
"dashboard/page_views/accounts.py",
"dashboard/page_views/assets.py",
"tests/test_account_valuations.py",
"tests/test_asset_snapshots.py",
"tests/test_account_snapshot.py",
"tests/test_contract_regression.py",
]
for rel in paths:
+1 -1
View File
@@ -16,7 +16,7 @@ def test_review_decision_and_reconciliation_schema_are_additive_idempotent(tmp_p
assert status["integrity_check"] == "ok"
assert status["table_counts"]["review_decisions"] == 0
assert status["table_counts"]["reconciliation_sessions"] == 0
assert service.health_check(db_path)["schema_version"] == "14"
assert service.health_check(db_path)["schema_version"] == "16"
def test_review_decision_round_trip_payload_sanitizes_sensitive_fields(tmp_path: Path) -> None:
+77
View File
@@ -0,0 +1,77 @@
from __future__ import annotations
import hashlib
import sqlite3
from pathlib import Path
import pytest
from jarca_ledger_mcp.infrastructure.sqlite import migrations
from jarca_ledger_mcp.infrastructure.sqlite.migrations import init_db
FIXTURE = Path(__file__).parent / "fixtures" / "schema_v14.sql"
def _v14_db(path: Path) -> None:
with sqlite3.connect(path) as db:
db.executescript(FIXTURE.read_text())
db.execute("INSERT INTO accounts VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", ("a1", "Main", "bank", "Bank", "KRW", 1, "memo", "1234", "User", 100000, "2026-01-31", "", "ok", "cash", "2026-01-01", "2026-01-31"))
db.execute("INSERT INTO transactions (id,transaction_date,type,amount,account_id,created_at,updated_at) VALUES (?,?,?,?,?,?,?)", ("t1", "2026-01-15", "expense", -1200, "a1", "2026-01-15", "2026-01-15"))
db.execute("INSERT INTO asset_snapshots (id,basis_date,source_type,source_id,name,amount,closed_at,created_at) VALUES (?,?,?,?,?,?,?,?)", ("s1", "2026-01-31", "account", "a1", "Main", 100000, "2026-01-31T10:00:00", "2026-01-31T10:00:00"))
db.execute("INSERT INTO asset_snapshot_runs (id,basis_date,close_status,created_at) VALUES (?,?,?,?)", ("run1", "2026-01-31", "closed", "2026-01-31T10:00:00"))
db.execute("INSERT OR REPLACE INTO schema_meta VALUES ('schema_version','14')")
db.commit()
def _rows(path: Path, table: str):
with sqlite3.connect(path) as db:
return db.execute(f"SELECT * FROM {table} ORDER BY 1").fetchall()
def test_actual_v14_fixture_is_tag_ddl_and_migrates_preserving_rows(tmp_path):
path = tmp_path / "ledger.sqlite3"
_v14_db(path)
before_accounts, before_tx = _rows(path, "accounts"), _rows(path, "transactions")
init_db(str(path))
with sqlite3.connect(path) as db:
assert db.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "15"
assert db.execute("SELECT amount, closed_at FROM account_snapshot WHERE id='s1'").fetchone() == (100000, "2026-01-31T10:00:00")
assert db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='asset_snapshot_runs'").fetchone() is None
assert db.execute("PRAGMA foreign_key_check").fetchall() == []
assert db.execute("PRAGMA integrity_check").fetchone()[0] == "ok"
assert _rows(path, "accounts") == before_accounts
assert _rows(path, "transactions") == before_tx
def test_actual_v14_migration_is_idempotent_and_snapshot_unchanged(tmp_path):
path = tmp_path / "ledger.sqlite3"
_v14_db(path)
init_db(str(path))
first = _rows(path, "account_snapshot")
init_db(str(path))
assert _rows(path, "account_snapshot") == first
def test_migration_failure_rolls_back_legacy_renames_and_metadata(tmp_path, monkeypatch):
path = tmp_path / "ledger.sqlite3"
_v14_db(path)
original = migrations.migrate_db
def fail(conn):
raise RuntimeError("injected")
monkeypatch.setattr(migrations, "migrate_db", fail)
with pytest.raises(RuntimeError, match="injected"):
init_db(str(path))
with sqlite3.connect(path) as db:
assert db.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "14"
assert db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='asset_snapshots'").fetchone()
assert not db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='account_snapshot'").fetchone()
monkeypatch.setattr(migrations, "migrate_db", original)
def test_new_init_has_same_contract(tmp_path):
path = tmp_path / "new.sqlite3"
init_db(str(path))
with sqlite3.connect(path) as db:
assert db.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "15"
cols = {r[1] for r in db.execute("PRAGMA table_info(account_snapshot)")}
assert {"monthly_settlement_id", "verification_status", "evidence_kind"} <= cols
+1 -1
View File
@@ -10,7 +10,7 @@ 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"] == "14"
assert result["schema_version"] == "16"
assert db_path.exists()
+15 -53
View File
@@ -8,11 +8,9 @@ from jarca_ledger_mcp.db import init_db
def _schema(path):
with sqlite3.connect(path) as db:
tables = {r[0] for r in db.execute("SELECT name FROM sqlite_master WHERE type='table'")}
assert {"settlement_cashflow_snapshots", "asset_snapshot_runs"} <= tables
header = {r[1] for r in db.execute("PRAGMA table_info(asset_snapshot_runs)")}
assert {"settlement_cashflow_snapshots", "monthly_settlement"} <= tables
detail = {r[1] for r in db.execute("PRAGMA table_info(settlement_cashflow_snapshots)")}
assert {"settlement_month", "currency", "income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth", "cashflow_policy_json", "aggregation_version", "snapshot_schema_version", "source_snapshot_json"} <= header
assert {"id", "settlement_run_id", "cashflow_kind", "category", "subcategory", "amount", "transaction_count", "currency"} <= detail
assert {"id", "monthly_settlement_id", "cashflow_kind", "category", "subcategory", "amount", "transaction_count", "currency"} <= detail
def test_cashflow_detail_constraints_preserve_blank_categories(tmp_path):
@@ -21,69 +19,33 @@ def test_cashflow_detail_constraints_preserve_blank_categories(tmp_path):
_schema(path)
with sqlite3.connect(path) as db:
db.execute("PRAGMA foreign_keys=ON")
db.execute("INSERT INTO asset_snapshot_runs(id,basis_date,close_status,created_at) VALUES('run-a','2026-09-30','complete_exact','now')")
insert = "INSERT INTO settlement_cashflow_snapshots(id,settlement_run_id,cashflow_kind,category,subcategory,amount,transaction_count,currency) VALUES(?,?,?,?,?,?,?,?)"
good = ("d1", "run-a", "expense", "", "", -7, 1, "KRW")
db.execute("INSERT INTO monthly_settlement(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) VALUES('month-a','2026-09','2026-09-30','draft',1,'test','now','test','now')")
insert = "INSERT INTO settlement_cashflow_snapshots(id,monthly_settlement_id,cashflow_kind,category,subcategory,amount,transaction_count,currency) VALUES(?,?,?,?,?,?,?,?)"
good = ("d1", "month-a", "expense", "", "", -7, 1, "KRW")
db.execute(insert, good)
for values in [("d2", "missing", "expense", "x", "y", 1, 1, "KRW"), ("d3", "run-a", "other", "x", "y", 1, 1, "KRW"), ("d4", "run-a", "expense", "", "", 7, 1, "KRW"), ("d5", "run-a", "expense", "", "", None, 1, "KRW"), ("d6", "run-a", "expense", "", "", 1, 0, "KRW"), ("d7", "run-a", "expense", None, "", 1, 1, "KRW"), ("d8", "run-a", "expense", "", None, 1, 1, "KRW")]:
with pytest.raises(sqlite3.IntegrityError):
db.execute(insert, values)
with pytest.raises(sqlite3.IntegrityError):
db.execute(insert, ("d9", "run-a", "expense", "", "", -7, 1, "KRW"))
db.execute(insert, ("d10", "run-a", "income", "", "", 2, 1, "KRW"))
db.execute(insert, ("d10", "month-a", "income", "", "", 2, 1, "KRW"))
indexes = {r[1]: r[2] for r in db.execute("PRAGMA index_list(settlement_cashflow_snapshots)")}
unique_fields = [tuple(r[2] for r in db.execute(f"PRAGMA index_info('{name}')")) for name, unique in indexes.items() if unique]
assert any(x == ("settlement_run_id", "cashflow_kind", "category", "subcategory", "currency") for x in unique_fields)
assert any(x == ("monthly_settlement_id", "cashflow_kind", "category", "subcategory", "currency") for x in unique_fields)
assert db.execute("SELECT amount,category,subcategory FROM settlement_cashflow_snapshots WHERE id='d1'").fetchone() == (-7, "", "")
with pytest.raises(sqlite3.IntegrityError):
db.execute("DELETE FROM asset_snapshot_runs WHERE id='run-a'")
assert db.execute("SELECT COUNT(*) FROM settlement_cashflow_snapshots WHERE settlement_run_id='run-a'").fetchone()[0] == 2
db.execute("DELETE FROM monthly_settlement WHERE id='month-a'")
assert db.execute("SELECT COUNT(*) FROM settlement_cashflow_snapshots WHERE monthly_settlement_id='month-a'").fetchone()[0] == 2
def test_legacy_migration_is_idempotent_and_keeps_unstored_totals_null(tmp_path):
"""Schema-13 fixture uses the frozen production header and asset DDL."""
path = str(tmp_path / "schema13.sqlite3")
with sqlite3.connect(path) as db:
db.executescript("""
CREATE TABLE schema_meta(key TEXT PRIMARY KEY,value TEXT NOT NULL);
INSERT INTO schema_meta VALUES('schema_version','13');
CREATE TABLE asset_snapshots (
id TEXT PRIMARY KEY, basis_date TEXT NOT NULL, source_type TEXT NOT NULL,
source_id TEXT NOT NULL DEFAULT '', name TEXT NOT NULL, amount INTEGER,
currency TEXT NOT NULL DEFAULT 'KRW', liquidity_bucket TEXT NOT NULL DEFAULT 'other',
valuation_source TEXT NOT NULL DEFAULT '', source_note TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '', snapshot_run_id TEXT NOT NULL DEFAULT '',
correction_of_run_id TEXT NOT NULL DEFAULT '', correction_reason TEXT NOT NULL DEFAULT '',
is_correction INTEGER NOT NULL DEFAULT 0, closed_at TEXT NOT NULL DEFAULT '',
valuation_id TEXT NOT NULL DEFAULT '', selection_policy TEXT NOT NULL DEFAULT 'exact',
verification_status TEXT NOT NULL DEFAULT 'unverified', evidence_kind TEXT NOT NULL DEFAULT 'applied',
exclusion_reason TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL
);
CREATE TABLE asset_snapshot_runs (
id TEXT PRIMARY KEY, basis_date TEXT NOT NULL, close_status TEXT NOT NULL,
selection_policy TEXT NOT NULL DEFAULT 'exact', idempotency_key TEXT NOT NULL DEFAULT '',
approved_by TEXT NOT NULL DEFAULT '', approved_at TEXT NOT NULL DEFAULT '',
approval_reason TEXT NOT NULL DEFAULT '', approval_reference TEXT NOT NULL DEFAULT '',
correction_of_run_id TEXT NOT NULL DEFAULT '', correction_reason TEXT NOT NULL DEFAULT '',
applied_evidence TEXT NOT NULL DEFAULT '[]', excluded_evidence TEXT NOT NULL DEFAULT '[]',
carried_evidence TEXT NOT NULL DEFAULT '[]', request_fingerprint TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL, draft_id TEXT NOT NULL DEFAULT '', draft_version INTEGER NOT NULL DEFAULT 0,
review_fingerprint TEXT NOT NULL DEFAULT ''
);
INSERT INTO asset_snapshot_runs(id,basis_date,close_status,created_at) VALUES('legacy-run','2026-09-30','complete_exact','then');
INSERT INTO asset_snapshots(id,basis_date,source_type,source_id,name,amount,created_at,snapshot_run_id) VALUES('legacy-row','2026-09-30','manual_asset','synthetic','Synthetic',42,'then','legacy-run');
""")
def test_init_is_idempotent_and_retains_schema_16_contract(tmp_path):
path = str(tmp_path / "fresh.sqlite3")
init_db(path)
_schema(path)
with sqlite3.connect(path) as db:
columns = {r[1] for r in db.execute("PRAGMA table_info(asset_snapshot_runs)")}
assert "draft_id" in columns and "income_total" in columns
first = db.execute("SELECT id,basis_date,close_status,created_at,income_total,expense_total,net_cashflow,total_assets,total_liabilities,net_worth FROM asset_snapshot_runs WHERE id='legacy-run'").fetchone()
assert first[:4] == ("legacy-run", "2026-09-30", "complete_exact", "then")
assert first[4:] == (None, None, None, None, None, None)
old = db.execute("SELECT id,basis_date,source_type,source_id,name,amount,snapshot_run_id FROM asset_snapshots").fetchall()
assert db.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == '16'
assert not db.execute("SELECT 1 FROM sqlite_master WHERE name='asset_snapshot_runs'").fetchone()
first = db.execute("SELECT COUNT(*) FROM settlement_cashflow_snapshots").fetchone()[0]
init_db(path)
with sqlite3.connect(path) as db:
assert db.execute("SELECT id,basis_date,close_status,created_at,income_total,expense_total,net_cashflow,total_assets,total_liabilities,net_worth FROM asset_snapshot_runs WHERE id='legacy-run'").fetchone() == first
assert db.execute("SELECT id,basis_date,source_type,source_id,name,amount,snapshot_run_id FROM asset_snapshots").fetchall() == old
assert db.execute("SELECT COUNT(*) FROM settlement_cashflow_snapshots").fetchone()[0] == 0
assert db.execute("SELECT COUNT(*) FROM settlement_cashflow_snapshots").fetchone()[0] == first
@@ -23,7 +23,7 @@ def test_empty_draft_persists_without_closing_and_has_consistent_public_shape(tm
with sqlite3.connect(path) as conn:
assert conn.execute('SELECT COUNT(*) FROM settlement_drafts').fetchone()[0] == 1
assert conn.execute('SELECT COUNT(*) FROM asset_snapshot_runs').fetchone()[0] == 0
assert conn.execute('SELECT COUNT(*) FROM asset_snapshots').fetchone()[0] == 0
assert conn.execute('SELECT COUNT(*) FROM account_snapshot').fetchone()[0] == 0
def test_initial_draft_freezes_exact_verified_selector_compatibly(tmp_path):
+1 -1
View File
@@ -30,7 +30,7 @@ def approve(path, draft, **flags):
def counts(path):
with sqlite3.connect(path) as conn:
return tuple(conn.execute('SELECT COUNT(*) FROM ' + t).fetchone()[0] for t in ('asset_snapshot_runs', 'asset_snapshots'))
return tuple(conn.execute('SELECT COUNT(*) FROM ' + t).fetchone()[0] for t in ('asset_snapshot_runs', 'account_snapshot'))
@pytest.mark.parametrize('sql', [
@@ -163,12 +163,12 @@ def test_synthetic_september_future_pension_loan_late_exact_warnings_stale_rollb
d = choose(path, d, cash, cv)
review = approve(path, d, allow_missing_exclusions=True, allow_manual_substitute=True)
with sqlite3.connect(path) as c:
c.execute("CREATE TRIGGER synthetic_failure BEFORE INSERT ON asset_snapshots BEGIN SELECT RAISE(ABORT, 'synthetic rollback'); END")
c.execute("CREATE TRIGGER synthetic_failure BEFORE INSERT ON account_snapshot BEGIN SELECT RAISE(ABORT, 'synthetic rollback'); END")
failed = s.finalize_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'final', db_path=path)
assert not failed['success'] and failed['error_code'] == 'db_error'
with sqlite3.connect(path) as c:
assert c.execute('SELECT COUNT(*) FROM asset_snapshot_runs').fetchone()[0] == 0
assert c.execute('SELECT COUNT(*) FROM asset_snapshots').fetchone()[0] == 0
assert c.execute('SELECT COUNT(*) FROM account_snapshot').fetchone()[0] == 0
c.execute('DROP TRIGGER synthetic_failure')
assert ok(s.get_settlement_draft(d['id'], db_path=path))['draft']['status'] == 'active'
def finish():
@@ -184,4 +184,4 @@ def test_synthetic_september_future_pension_loan_late_exact_warnings_stale_rollb
assert finish() == final
with sqlite3.connect(path) as c:
assert c.execute('SELECT COUNT(*) FROM asset_snapshot_runs').fetchone()[0] == 1
assert c.execute('SELECT COUNT(*) FROM asset_snapshots').fetchone()[0] == final['inserted_count']
assert c.execute('SELECT COUNT(*) FROM account_snapshot').fetchone()[0] == final['inserted_count']
@@ -13,7 +13,7 @@ def ok(r):
def counts(path):
with sqlite3.connect(path) as c:
return {table: c.execute(f'SELECT * FROM {table} ORDER BY rowid').fetchall()
for table in ('asset_snapshot_runs', 'asset_snapshots', 'settlement_drafts', 'settlement_draft_items')}
for table in ('asset_snapshot_runs', 'account_snapshot', 'settlement_drafts', 'settlement_draft_items')}
def finish(path, draft):
@@ -32,7 +32,7 @@ def test_correction_links_reason_preserves_prior_rows_and_replays_after_later_co
original_rows = counts(path)
corrected_value = ok(s.correct_account_valuation(first_value, 125, 'synthetic valuation correction', idempotency_key='v2', db_path=path))['valuation']['id']
assert counts(path)['asset_snapshot_runs'] == original_rows['asset_snapshot_runs']
assert counts(path)['asset_snapshots'] == original_rows['asset_snapshots']
assert counts(path)['account_snapshot'] == original_rows['account_snapshot']
run_id = original['snapshot_run_id']
bad_requests = [('', 'reason'), (run_id, ''), ('does-not-exist', 'reason')]
+13 -6
View File
@@ -26,7 +26,9 @@ def _app_wrapper(tmp_path: Path) -> Path:
def test_reusable_settlement_draft_panel_exists_and_is_importable(tmp_path):
assert VIEW.is_file(), "shared Dashboard settlement draft panel is missing"
from streamlit.testing.v1 import AppTest
app = AppTest.from_file(str(_app_wrapper(tmp_path))).run()
app = AppTest.from_file(str(_app_wrapper(tmp_path)))
app.session_state["authenticated_actor"] = "fixture-operator"
app = app.run()
assert not app.exception
@@ -44,7 +46,9 @@ def test_streamlit_panel_renders_lifecycle_without_writing_or_creating_db(monkey
db_path = tmp_path / "dashboard-missing.sqlite3"
# Explicit synthetic DB path and no authenticated actor: render only.
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(db_path))
app = AppTest.from_file(str(_app_wrapper(tmp_path))).run()
app = AppTest.from_file(str(_app_wrapper(tmp_path)))
app.session_state["authenticated_actor"] = "fixture-operator"
app = app.run()
assert not app.exception
page = "\n".join([e.value for e in app.markdown] + [e.value for e in app.caption] + [e.value for e in app.info] + [e.label for e in app.button])
for expected in ("초안", "검토", "승인", "최종", "취소", "정정", "평가자료", "제외 사유"):
@@ -91,13 +95,16 @@ def test_app_test_create_review_approve_finalize_roundtrip(tmp_path, monkeypatch
seeded = create_manual_asset("Synthetic asset", 125, "2026-09-30", "other", source_note="fixture", db_path=str(db_path))
assert seeded.get("success"), seeded
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(db_path))
app = AppTest.from_file(str(_app_wrapper(tmp_path))).run()
monkeypatch.setattr("dashboard.authentication.get_authenticated_actor", lambda: "fixture-operator")
app = AppTest.from_file(str(_app_wrapper(tmp_path)))
app.session_state["authenticated_actor"] = "fixture-operator"
app = app.run()
assert not app.exception
app.button[0].click().run()
assert not app.exception
assert app.button[0].label == "초안 생성 / 정정 초안"
assert len(app.selectbox[0].options) == 1
assert "active" in " ".join(item.value for item in app.caption)
next(button for button in app.button if button.label == "초안 만들기").click().run()
assert not app.exception
assert any(button.label == "검토 / 새로고침 대상" for button in app.button)
# Exercise read-only review through the rendered explicit button.
review = next(button for button in app.button if button.label == "검토 / 새로고침 대상")
review.click().run(timeout=10)
+1 -1
View File
@@ -174,7 +174,7 @@ def test_settlement_draft_runtime_cancel_and_recovery_contract() -> None:
for clause in ("cancel_settlement_draft(draft_id, expected_version, cancelled_by, reason, db_path=None)",
"active", "nonempty", "cancelled_by", "cancellation_reason", "cancelled_at",
"approval_fingerprint", "approval_version", "version", "updated_at", "BEGIN IMMEDIATE",
"retained", "account_valuations", "asset_snapshot_runs", "asset_snapshots",
"retained", "account_valuations", "asset_snapshot_runs", "account_snapshot",
"rollback", "no-write", "completed", "cancelled", "fresh create key",
"same create key", "latest prior run", "correction_of_run_id", "correction_reason",
"tests/test_settlement_draft_correction_contract.py"):
+2 -2
View File
@@ -44,7 +44,7 @@ def test_required_public_field_assertion_detects_omission():
def rows(db, table):
assert table in {'asset_snapshot_runs', 'asset_snapshots', 'settlement_drafts', 'settlement_draft_items'}
assert table in {'asset_snapshot_runs', 'account_snapshot', 'settlement_drafts', 'settlement_draft_items'}
with closing(sqlite3.connect(db)) as connection:
return connection.execute(f'SELECT * FROM {table} ORDER BY rowid').fetchall()
@@ -138,7 +138,7 @@ async def _exercise_stdio_lifecycle(tmp_path):
assert field in provenance, f'final provenance missing {field}'
assert provenance['valuation_date'] == item['valuation_date'] and provenance['age_days'] == item['age_days']
before = (await call('get_settlement_draft', {'draft_id':draft_id}))
tables = ('asset_snapshot_runs', 'asset_snapshots', 'settlement_drafts', 'settlement_draft_items')
tables = ('asset_snapshot_runs', 'account_snapshot', 'settlement_drafts', 'settlement_draft_items')
before_replay = {table: rows(db, table) for table in tables}
assert await call('finalize_settlement_draft', {'draft_id':draft_id,'expected_version':draft['version'],'review_fingerprint':fingerprint,'idempotency_key':'mcp-close'}) == closed
archived = await call('get_settlement_draft', {'draft_id':draft_id})
@@ -9,7 +9,13 @@ from jarca_ledger_mcp.db import init_db
def panel(monkeypatch, path):
monkeypatch.setenv('JARCA_LEDGER_DB_PATH', str(path))
app = AppTest.from_string('from dashboard.page_views.settlement_drafts import render_settlement_drafts\nrender_settlement_drafts()\n').run(timeout=20)
monkeypatch.setattr('dashboard.authentication.get_authenticated_actor', lambda: 'fixture-operator')
app = AppTest.from_string(f'from dashboard.page_views.settlement_drafts import render_settlement_drafts\nrender_settlement_drafts(db_path={str(path)!r})\n')
app.session_state["authenticated_actor"] = "fixture-operator"
app = app.run(timeout=20)
assert not app.exception
control(app.button, '정산 시작').click().run(timeout=20)
control(app.button, '초안 만들기').click().run(timeout=20)
assert not app.exception
return app
@@ -38,7 +44,7 @@ def test_panel_reports_partial_save_when_item_update_fails(tmp_path, monkeypatch
assert s.register_account_valuation(account, '2026-09-30', 10, verification_status='verified',
idempotency_key='synthetic-value', db_path=path)['success']
app = panel(monkeypatch, path)
control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20)
control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
draft = s.list_settlement_drafts('2026-09', db_path=path)['drafts'][0]
review_key = f"review_result_{draft['id']}"
+13 -7
View File
@@ -16,9 +16,15 @@ def control(elements, label):
def app_for(tmp_path, monkeypatch, path):
monkeypatch.setenv('JARCA_LEDGER_DB_PATH', str(path))
monkeypatch.setattr('dashboard.authentication.get_authenticated_actor', lambda: 'fixture-operator')
appfile = tmp_path / 'panel.py'
appfile.write_text('from dashboard.page_views.settlement_drafts import render_settlement_drafts\nrender_settlement_drafts()\n')
app = AppTest.from_file(str(appfile)).run(timeout=20)
appfile.write_text(f'from dashboard.page_views.settlement_drafts import render_settlement_drafts\nrender_settlement_drafts(db_path={str(path)!r})\n')
app = AppTest.from_file(str(appfile))
app.session_state["authenticated_actor"] = "fixture-operator"
app = app.run(timeout=20)
assert not app.exception
control(app.button, '정산 시작').click().run(timeout=20)
control(app.button, '초안 만들기').click().run(timeout=20)
assert not app.exception
return app
@@ -29,7 +35,7 @@ def test_panel_selects_after_date_evidence_approves_and_finalizes_actual_databas
account = s.create_ledger_account('Synthetic retirement', type='investment', liquidity_bucket='investment', db_path=path)['account']['id']
value = s.register_account_valuation(account, '2026-10-05', 100, valuation_kind='total_value', verification_status='verified', idempotency_key='future', db_path=path)['valuation']['id']
app = app_for(tmp_path, monkeypatch, path)
control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20)
control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
control(app.text_input, '대상 ID').set_value(account)
control(app.text_input, '평가자료 ID (비우면 해제)').set_value(value)
control(app.selectbox, '선택 방식').select('manual_substitute')
@@ -69,7 +75,7 @@ def test_panel_valuation_listing_shows_real_selected_evidence_ids(tmp_path, monk
account = s.create_ledger_account('Synthetic cash', db_path=path)['account']['id']
vid = s.register_account_valuation(account, '2026-09-30', 10, verification_status='verified', idempotency_key='value', db_path=path)['valuation']['id']
app = app_for(tmp_path, monkeypatch, path)
control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20)
control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
control(app.text_input, '대상 ID').set_value(account)
control(app.button, '평가자료 조회').click().run(timeout=20)
assert not app.exception
@@ -81,7 +87,7 @@ def test_panel_supports_investment_total_and_explicit_loan_definition(tmp_path,
init_db(path)
loan = s.create_ledger_account('Synthetic loan', type='loan', liquidity_bucket='loan', db_path=path)['account']['id']
app = app_for(tmp_path, monkeypatch, path)
control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20)
control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
assert {'total_value', 'cash_balance'} <= set(control(app.selectbox, '평가 종류').options)
control(app.text_input, '평가 등록 계좌 ID').set_value(loan)
control(app.text_input, '평가일 YYYY-MM-DD').set_value('2026-09-30')
@@ -103,7 +109,7 @@ def test_panel_edit_invalidates_cached_review_before_approval(tmp_path, monkeypa
account = s.create_ledger_account('Synthetic cash', db_path=path)['account']['id']
s.register_account_valuation(account, '2026-09-30', 10, verification_status='verified', idempotency_key='value', db_path=path)
app = app_for(tmp_path, monkeypatch, path)
control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20)
control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
draft = s.list_settlement_drafts('2026-09', db_path=path)['drafts'][0]
key = f"review_result_{draft['id']}"
@@ -135,7 +141,7 @@ def test_panel_correction_invalidates_review_without_registration_account_input(
account = s.create_ledger_account('Synthetic cash', db_path=path)['account']['id']
vid = s.register_account_valuation(account, '2026-09-30', 10, verification_status='verified', idempotency_key='value', db_path=path)['valuation']['id']
app = app_for(tmp_path, monkeypatch, path)
control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20)
control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
draft = s.list_settlement_drafts('2026-09', db_path=path)['drafts'][0]
old = app.session_state[f"review_result_{draft['id']}"]['review_fingerprint']
@@ -259,7 +259,7 @@ def test_legacy_amount_approval_requires_fresh_review_after_policy_correction(tm
assert rejected["error_code"] == "conflict"
with sqlite3.connect(path) as db:
assert db.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
assert db.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0] == 0
assert db.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0] == 0
row = db.execute("SELECT status,version,final_run_id FROM settlement_drafts WHERE id=?", (d["id"],)).fetchone()
assert row == ("active", d["version"], "")
assert frozen_items(path, d["id"]) == frozen_before
+1 -1
View File
@@ -16,7 +16,7 @@ def test_closed_draft_evidence_is_additive_and_strictly_redacted(tmp_path):
json.dumps([{"source_type":"account","source_id":"acct-1","reason":"approved_missing","approval_reference":"ref-2","unknown_secret":"hidden"}]),
json.dumps([{"source_type":"manual_asset","source_id":"asset-1","valuation_date":"2026-09-29","age_days":1,"carried_forward":True,"selection_mode":"carried_before","reason":"accepted","selected_by":"reviewer","selected_at":"2026-09-30T01:00:00Z","item_version":3,"verification_status":"verified","source_ref":"ref-1"}]),
"2026-09-30T02:00:00Z"))
conn.execute("INSERT INTO asset_snapshots(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,snapshot_run_id,valuation_id,created_at) VALUES('snap-1','2026-09-30','manual_asset','asset-1','Synthetic',100,'KRW','real_estate','run-draft','','2026-09-30T02:00:00Z')")
conn.execute("INSERT INTO account_snapshot(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,monthly_settlement_id,valuation_id,created_at) VALUES('snap-1','2026-09-30','manual_asset','asset-1','Synthetic',100,'KRW','real_estate',NULL,'','2026-09-30T02:00:00Z')")
conn.commit()
report = service.get_asset_snapshot_report("2026-09-30", db_path=path)
assert report["success"]
+24
View File
@@ -0,0 +1,24 @@
from pathlib import Path
from jarca_ledger_mcp.db import init_db
from jarca_ledger_mcp.application import settlement_draft_service as service
def test_cancelled_draft_does_not_reserve_create_key_for_new_draft(tmp_path: Path):
db = str(tmp_path / "ledger.sqlite3")
init_db(db)
first = service.create_settlement_draft("2026-09", "actor", "same-key", db_path=db)
assert first["success"]
cancelled = service.cancel_settlement_draft(first["draft"]["id"], 1, "actor", "restart", db_path=db)
assert cancelled["success"]
second = service.create_settlement_draft("2026-09", "actor", "new-key-after-cancel", db_path=db)
assert second["success"]
assert second["draft"]["id"] != first["draft"]["id"]
def test_dashboard_exposes_the_five_state_entry_labels():
source = Path(__file__).parents[1].joinpath("dashboard/page_views/settlement_drafts.py").read_text()
for label in ("정산시작", "이어서작업", "마감내역보기", "정정시작", "정정이어서작업"):
assert label in source
assert "검토·승인·마감·취소·평가" not in source