diff --git a/jarca_ledger_mcp/application/account_valuation_service.py b/jarca_ledger_mcp/application/account_valuation_service.py index 2f575fb..751172e 100644 --- a/jarca_ledger_mcp/application/account_valuation_service.py +++ b/jarca_ledger_mcp/application/account_valuation_service.py @@ -266,19 +266,19 @@ def correct_account_valuation(valuation_id: str, amount: int, correction_reason: source = conn.execute("SELECT * FROM account_valuations WHERE id=?", (valuation_id,)).fetchone() if source is None: return fail("not_found", "valuation not found") + preserved = {name: fields[name] if name in fields else source[name] for name in ( + "currency", "valuation_kind", "source_type", "source_ref", "verification_status", "memo", "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")} if idempotency_key: prior = conn.execute("SELECT * FROM account_valuations WHERE account_id=? AND idempotency_key=?", (source["account_id"], idempotency_key)).fetchone() if prior: expected = {"amount": amount, "supersedes_id": valuation_id, "correction_reason": correction_reason.strip()} - expected.update({k: fields[k] for k in fields}) + expected.update(preserved) if all(prior[k] == v for k, v in expected.items() if k in prior.keys()): return ok(valuation=_valuation(prior), reused=True) return fail("idempotency_conflict", "idempotency_key already has a different payload", valuation=_valuation(prior)) if conn.execute("SELECT 1 FROM account_valuations WHERE supersedes_id=?", (valuation_id,)).fetchone(): return fail("correction_conflict", "valuation already has a correction") - preserved = {name: fields[name] if name in fields else source[name] for name in ( - "currency", "valuation_kind", "source_type", "source_ref", "verification_status", "memo", "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")} return register_account_valuation( source["account_id"], source["basis_date"], amount, **preserved, diff --git a/jarca_ledger_mcp/application/assets_service.py b/jarca_ledger_mcp/application/assets_service.py index 9ccccce..53db0fc 100644 --- a/jarca_ledger_mcp/application/assets_service.py +++ b/jarca_ledger_mcp/application/assets_service.py @@ -573,8 +573,10 @@ def get_asset_snapshot_report(basis_date: str = "", selection_policy: str = "exa basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat() with db(db_path) as conn: rows, close_metadata = _snapshot_rows_for_latest_run(conn, basis) + has_close_run = bool(close_metadata.get("snapshot_run_id")) snapshots = _snapshot_items(conn, basis) if rows else [] - if close_metadata: + legacy_snapshots = snapshots if rows and not has_close_run else [] + if has_close_run: summary = _summarize_valuation_items(snapshots, []) return ok( basis_date=basis, @@ -590,6 +592,19 @@ def get_asset_snapshot_report(basis_date: str = "", selection_policy: str = "exa manual_items, manual_excluded = _manual_asset_valuation_items(conn, basis_date=basis, selection_policy=selection_policy) included = account_items + manual_items excluded = account_excluded + manual_excluded + if not included and not excluded and legacy_snapshots: + summary = _summarize_valuation_items(legacy_snapshots, []) + return ok( + basis_date=basis, + source="legacy_asset_snapshots", + selection_policy=selection_policy, + close_status="legacy_read_only", + is_closed_snapshot=False, + snapshot_count=len(legacy_snapshots), + legacy_snapshot_row_count=len(legacy_snapshots), + close_metadata={"legacy": True, "snapshot_run_id": ""}, + **summary, + ) summary = _summarize_valuation_items(included, excluded) return ok( basis_date=basis, @@ -598,6 +613,7 @@ def get_asset_snapshot_report(basis_date: str = "", selection_policy: str = "exa close_status="incomplete_draft" if excluded else "complete_exact", is_closed_snapshot=False, snapshot_count=0, + legacy_snapshot_row_count=len(legacy_snapshots), invalid_bucket_accounts=invalid_bucket_accounts, **summary, ) diff --git a/tests/test_asset_snapshots.py b/tests/test_asset_snapshots.py index b01ebbc..34ac763 100644 --- a/tests/test_asset_snapshots.py +++ b/tests/test_asset_snapshots.py @@ -61,7 +61,9 @@ def test_snapshot_rows_are_append_only_and_timeseries_uses_same_formula(tmp_path may = service.get_asset_snapshot_report("2026-05-31", db_path=db_path) assert may["success"] is True - assert may["source"] == "asset_snapshots" + assert may["source"] == "legacy_asset_snapshots" + assert may["is_closed_snapshot"] is False + assert may["close_status"] == "legacy_read_only" assert may["snapshot_count"] == 3 assert may["net_worth"] == 121_000_000 diff --git a/tests/test_issue_12_regressions.py b/tests/test_issue_12_regressions.py index b82d7b0..989121f 100644 --- a/tests/test_issue_12_regressions.py +++ b/tests/test_issue_12_regressions.py @@ -180,6 +180,26 @@ def test_candidate_idempotency_rejects_changed_payload(tmp_path): assert conflict["error_code"] == "idempotency_conflict" +def test_correction_idempotency_compares_effective_preserved_payload(tmp_path): + path = str(tmp_path / "ledger.sqlite3") + account = service.create_ledger_account("계좌", db_path=path)["account"]["id"] + original = service.register_account_valuation( + account, "2026-06-30", 100, memo="original", + verification_status="verified", idempotency_key="original", db_path=path, + )["valuation"] + first = service.correct_account_valuation( + original["id"], 200, "정정", memo="override", + idempotency_key="correction", db_path=path, + ) + retry_with_omitted_memo = service.correct_account_valuation( + original["id"], 200, "정정", + idempotency_key="correction", db_path=path, + ) + assert first["success"] is True + assert retry_with_omitted_memo["success"] is False + assert retry_with_omitted_memo["error_code"] == "idempotency_conflict" + + def test_valuation_currency_and_loan_kind_are_rejected(tmp_path): path = str(tmp_path / "ledger.sqlite3") bank = service.create_ledger_account("은행", currency="KRW", db_path=path)["account"]["id"] @@ -331,6 +351,22 @@ def test_snapshot_rejects_bogus_selection_policy_and_manual_usd_is_excluded(tmp_ assert any(item.get("reason") == "non_krw_without_fx" for item in report["excluded_items"]) +def test_legacy_snapshot_rows_do_not_override_live_authority_or_claim_closed(tmp_path): + path = str(tmp_path / "ledger.sqlite3") + service.create_asset_snapshot( + "2026-06-30", "account", "legacy", "legacy", 999, "cash", db_path=path, + ) + account = service.create_ledger_account("현재 계좌", db_path=path)["account"]["id"] + service.register_account_valuation( + account, "2026-06-30", 100, verification_status="verified", db_path=path, + ) + report = service.get_asset_snapshot_report("2026-06-30", db_path=path) + assert report["source"] == "live_authority" + assert report["is_closed_snapshot"] is False + assert report["total_assets"] == 100 + assert report["legacy_snapshot_row_count"] == 1 + + def test_account_write_rolls_back_when_conn_level_candidate_write_fails(tmp_path, monkeypatch): path = str(tmp_path / "ledger.sqlite3") import jarca_ledger_mcp.application.accounts_write_services as writes