fix: preserve correction and legacy authority semantics
This commit is contained in:
@@ -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()
|
source = conn.execute("SELECT * FROM account_valuations WHERE id=?", (valuation_id,)).fetchone()
|
||||||
if source is None:
|
if source is None:
|
||||||
return fail("not_found", "valuation not found")
|
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:
|
if idempotency_key:
|
||||||
prior = conn.execute("SELECT * FROM account_valuations WHERE account_id=? AND idempotency_key=?", (source["account_id"], idempotency_key)).fetchone()
|
prior = conn.execute("SELECT * FROM account_valuations WHERE account_id=? AND idempotency_key=?", (source["account_id"], idempotency_key)).fetchone()
|
||||||
if prior:
|
if prior:
|
||||||
expected = {"amount": amount, "supersedes_id": valuation_id, "correction_reason": correction_reason.strip()}
|
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()):
|
if all(prior[k] == v for k, v in expected.items() if k in prior.keys()):
|
||||||
return ok(valuation=_valuation(prior), reused=True)
|
return ok(valuation=_valuation(prior), reused=True)
|
||||||
return fail("idempotency_conflict", "idempotency_key already has a different payload", valuation=_valuation(prior))
|
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():
|
if conn.execute("SELECT 1 FROM account_valuations WHERE supersedes_id=?", (valuation_id,)).fetchone():
|
||||||
return fail("correction_conflict", "valuation already has a correction")
|
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(
|
return register_account_valuation(
|
||||||
source["account_id"], source["basis_date"], amount,
|
source["account_id"], source["basis_date"], amount,
|
||||||
**preserved,
|
**preserved,
|
||||||
|
|||||||
@@ -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()
|
basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat()
|
||||||
with db(db_path) as conn:
|
with db(db_path) as conn:
|
||||||
rows, close_metadata = _snapshot_rows_for_latest_run(conn, basis)
|
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 []
|
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, [])
|
summary = _summarize_valuation_items(snapshots, [])
|
||||||
return ok(
|
return ok(
|
||||||
basis_date=basis,
|
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)
|
manual_items, manual_excluded = _manual_asset_valuation_items(conn, basis_date=basis, selection_policy=selection_policy)
|
||||||
included = account_items + manual_items
|
included = account_items + manual_items
|
||||||
excluded = account_excluded + manual_excluded
|
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)
|
summary = _summarize_valuation_items(included, excluded)
|
||||||
return ok(
|
return ok(
|
||||||
basis_date=basis,
|
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",
|
close_status="incomplete_draft" if excluded else "complete_exact",
|
||||||
is_closed_snapshot=False,
|
is_closed_snapshot=False,
|
||||||
snapshot_count=0,
|
snapshot_count=0,
|
||||||
|
legacy_snapshot_row_count=len(legacy_snapshots),
|
||||||
invalid_bucket_accounts=invalid_bucket_accounts,
|
invalid_bucket_accounts=invalid_bucket_accounts,
|
||||||
**summary,
|
**summary,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
may = service.get_asset_snapshot_report("2026-05-31", db_path=db_path)
|
||||||
assert may["success"] is True
|
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["snapshot_count"] == 3
|
||||||
assert may["net_worth"] == 121_000_000
|
assert may["net_worth"] == 121_000_000
|
||||||
|
|
||||||
|
|||||||
@@ -180,6 +180,26 @@ def test_candidate_idempotency_rejects_changed_payload(tmp_path):
|
|||||||
assert conflict["error_code"] == "idempotency_conflict"
|
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):
|
def test_valuation_currency_and_loan_kind_are_rejected(tmp_path):
|
||||||
path = str(tmp_path / "ledger.sqlite3")
|
path = str(tmp_path / "ledger.sqlite3")
|
||||||
bank = service.create_ledger_account("은행", currency="KRW", db_path=path)["account"]["id"]
|
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"])
|
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):
|
def test_account_write_rolls_back_when_conn_level_candidate_write_fails(tmp_path, monkeypatch):
|
||||||
path = str(tmp_path / "ledger.sqlite3")
|
path = str(tmp_path / "ledger.sqlite3")
|
||||||
import jarca_ledger_mcp.application.accounts_write_services as writes
|
import jarca_ledger_mcp.application.accounts_write_services as writes
|
||||||
|
|||||||
Reference in New Issue
Block a user