Preserve inactive-category fallback and saved settlement header evidence

This commit is contained in:
2026-10-13 23:17:12 +00:00
parent ae4383ca2c
commit 10201cdb75
7 changed files with 258 additions and 2 deletions
+134
View File
@@ -440,6 +440,37 @@ def test_saved_reader_retains_asset_authority_and_liability_flags(tmp_path):
assert asset.get("status") == "ready"
def test_saved_reader_projects_safe_header_exclusion_carry_and_approval_evidence(tmp_path):
path = dbfile(tmp_path)
saved = ok(close_draft(path, new_draft(path)))
excluded = [{"source_type":"account", "source_id":"missing-account-41", "reason":"missing_verified_exact_valuation",
"valuation_date":"2026-09-27", "arbitrary_metadata":{"password":"DO-NOT-LEAK"}}]
carried = [{"source_type":"account", "source_id":"carried-account-42", "reason":"as_of_carry_forward",
"valuation_date":"2026-09-27", "arbitrary_metadata":{"token":"DO-NOT-LEAK"}}]
approval = {"approved_by":"synthetic-reviewer", "approval_reason":"synthetic conditional approval",
"approval_reference":"synthetic-ref", "allow_missing_exclusions":True,
"allow_as_of_carry_forward":True, "private_note":"DO-NOT-LEAK"}
with sqlite3.connect(path) as conn:
conn.execute("UPDATE asset_snapshot_runs SET close_status='conditional',excluded_evidence=?,carried_evidence=?,approved_by=?,approval_reason=?,approval_reference=? WHERE id=?",
(json.dumps(excluded), json.dumps(carried), approval["approved_by"], approval["approval_reason"], approval["approval_reference"], saved["run_id"]))
conn.commit()
before = list(conn.iterdump())
exact = ok(service.get_monthly_settlement_snapshot(run_id=saved["run_id"], db_path=path))
latest = ok(service.get_monthly_settlement_snapshot(month="2026-09", db_path=path))
assert exact == latest
assert exact["run_id"] == saved["run_id"] and exact["close_status"] == "conditional"
assert exact["excluded_items"][0]["source_id"] == "missing-account-41"
assert exact["excluded_items"][0]["reason"] == "missing_verified_exact_valuation"
assert exact["carried_items"][0]["source_id"] == "carried-account-42"
assert exact["carried_items"][0]["valuation_date"] == "2026-09-27"
assert exact["applied_evidence"]
assert exact["approval"]["approval_reason"] == "synthetic conditional approval"
encoded = json.dumps(exact, sort_keys=True)
assert "DO-NOT-LEAK" not in encoded and "private_note" not in encoded and "arbitrary_metadata" not in encoded
with sqlite3.connect(path) as conn:
assert list(conn.iterdump()) == before
@pytest.mark.parametrize("schema_version,aggregation_version", [(1, None), (1, ""), (1, "unknown-version"), (2, "comprehensive-v1")])
def test_saved_reader_rejects_unsupported_comprehensive_versions_without_writes(tmp_path, schema_version, aggregation_version):
path = dbfile(tmp_path)
@@ -476,3 +507,106 @@ def test_saved_reader_does_not_mislabel_partial_version_metadata_as_legacy(tmp_p
legacy = ok(service.get_monthly_settlement_snapshot(run_id=saved["run_id"], db_path=path))
assert legacy["legacy"] and not legacy["is_comprehensive"]
assert all(value is None for value in legacy["totals"].values())
@pytest.fixture
def schema_thirteen_conditional_close(tmp_path):
"""Execute immutable pre-comprehensive public APIs, without another checkout."""
import importlib
import importlib.abc
import importlib.util
from pathlib import Path
import subprocess
import sys
revision = "f67c07adf01d1f17415bf7cf836f46997ca6f21c"
root = Path(__file__).resolve().parents[1]
namespace = "_legacy_schema_thirteen"
names = subprocess.check_output(
["git", "ls-tree", "-r", "--name-only", revision, "jarca_ledger_mcp"],
cwd=root, text=True,
).splitlines()
blobs = {
name: subprocess.check_output(["git", "show", f"{revision}:{name}"], cwd=root)
for name in names if name.endswith(".py")
}
class BaselineModules(importlib.abc.MetaPathFinder, importlib.abc.Loader):
def find_spec(self, fullname, path=None, target=None):
if fullname != namespace and not fullname.startswith(namespace + "."):
return None
relative = fullname[len(namespace):].replace(".", "/")
candidate = "jarca_ledger_mcp" + relative
package = candidate + "/__init__.py"
module = candidate + ".py"
if package in blobs:
return importlib.util.spec_from_loader(fullname, self, is_package=True)
if module in blobs:
return importlib.util.spec_from_loader(fullname, self)
return None
def create_module(self, spec):
return None
def exec_module(self, module):
relative = module.__name__[len(namespace):].replace(".", "/")
candidate = "jarca_ledger_mcp" + relative
filename = candidate + ("/__init__.py" if hasattr(module, "__path__") else ".py")
module.__file__ = f"git:{revision}:{filename}"
exec(compile(blobs[filename], module.__file__, "exec"), module.__dict__)
finder = BaselineModules()
sys.meta_path.insert(0, finder)
try:
old = importlib.import_module(namespace + ".service")
old_db = importlib.import_module(namespace + ".db")
path = str(tmp_path / "baseline-schema-thirteen.sqlite3")
old_db.init_db(path)
missing = ok(old.create_ledger_account("Synthetic missing", "bank", liquidity_bucket="cash", db_path=path))["account"]["id"]
carried = ok(old.create_ledger_account("Synthetic carry", "bank", liquidity_bucket="cash", db_path=path))["account"]["id"]
ok(old.register_account_valuation(carried, "2026-09-27", 100, verification_status="verified", idempotency_key="baseline-carry", db_path=path))
closed = ok(old.close_asset_snapshot(
"2026-09-30", selection_policy="as_of", idempotency_key="baseline-close",
allow_missing_exclusions=True, allow_as_of_carry_forward=True,
approved_by="synthetic-reviewer", approval_reason="synthetic conditional approval",
approval_reference="synthetic-ref", db_path=path,
))
original = ok(old.get_asset_snapshot_report("2026-09-30", db_path=path))
yield path, missing, carried, closed, original
finally:
sys.meta_path.remove(finder)
for name in list(sys.modules):
if name == namespace or name.startswith(namespace + "."):
del sys.modules[name]
def test_schema_thirteen_saved_reader_retains_real_conditional_header_without_writes(schema_thirteen_conditional_close):
path, missing, carried, closed, original = schema_thirteen_conditional_close
init_db(path)
assert ok(service.get_asset_snapshot_report("2026-09-30", db_path=path)) == original
with sqlite3.connect(path) as conn:
before = list(conn.iterdump())
exact = ok(service.get_monthly_settlement_snapshot(run_id=closed["snapshot_run_id"], db_path=path))
latest = ok(service.get_monthly_settlement_snapshot(month="2026-09", db_path=path))
assert exact == latest
assert exact["legacy"] and not exact["is_comprehensive"]
assert all(value is None for value in exact["totals"].values())
assert exact["close_status"] == "conditional"
excluded = next(item for item in exact["excluded_items"] if item["source_id"] == missing)
assert excluded["reason"]
carry = next(item for item in exact["carried_items"] if item["source_id"] == carried)
assert carry["valuation_date"] == "2026-09-27"
assert exact["applied_evidence"]
assert exact["approval"]["approved_by"] == "synthetic-reviewer"
with sqlite3.connect(path) as conn:
assert list(conn.iterdump()) == before
def test_saved_evidence_privacy_contract_filters_keys_not_allowlisted_scalar_values():
from pathlib import Path
from jarca_ledger_mcp.application.assets_service import _public_evidence
value = {"candidate": {"name": "synthetic public scalar", "memo": "private", "source_attributes": {"reason": "synthetic reason", "private_key": "private"}}, "unrecognized": "private"}
assert _public_evidence(value, strict=True) == {"candidate": {"name": "synthetic public scalar", "source_attributes": {"reason": "synthetic reason"}}}
contract = (Path(__file__).resolve().parents[1] / "docs/interface-contract.md").read_text()
assert "allowlisted scalar values are preserved" in contract
assert "not a content-based secret redactor" in contract
@@ -4,6 +4,7 @@ import inspect
from pathlib import Path
from jarca_ledger_mcp import service
from test_comprehensive_settlement import schema_thirteen_conditional_close # noqa: F401
POLICY = "set_settlement_cashflow_policy"
READER = "get_monthly_settlement_snapshot"
@@ -256,6 +257,43 @@ def test_authenticated_dashboard_renders_saved_comprehensive_snapshot(tmp_path,
import pytest
def test_saved_monthly_panel_renders_real_conditional_exclusion_carry_and_approval(schema_thirteen_conditional_close, monkeypatch): # noqa: F811 - imported pytest fixture
import json
from streamlit.testing.v1 import AppTest
from jarca_ledger_mcp.db import init_db
from jarca_ledger_mcp.adapters.streamlit import overview_gateway, reconciliation_gateway, settlement_gateway
path, missing, carried, closed, _ = schema_thirteen_conditional_close
# Migrate the authentic schema-13 baseline in place, then exercise the real
# service through its public Streamlit gateway; no saved response is mocked.
init_db(path)
snapshot = settlement_gateway.get_monthly_settlement_snapshot(run_id=closed["snapshot_run_id"], db_path=path)
assert snapshot["success"]
excluded = next(row for row in snapshot["excluded_items"] if row["source_id"] == missing)
carry = next(row for row in snapshot["carried_items"] if row["source_id"] == carried)
approval = snapshot["approval"]
assert approval
monkeypatch.setattr(overview_gateway, "get_monthly_settlement_summary", lambda *a, **k: {"success": True, "month": "2026-09", "reports": {}})
monkeypatch.setattr(overview_gateway, "list_cross_transfer_review_queue", lambda *a, **k: {"success": True, "items": []})
monkeypatch.setattr(overview_gateway, "list_ledger_import_batches", lambda *a, **k: {"success": True, "import_batches": []})
monkeypatch.setattr(overview_gateway, "get_asset_snapshot_report", lambda *a, **k: {"success": False})
monkeypatch.setattr(reconciliation_gateway, "get_review_operating_indicators", lambda *a, **k: {"success": True, "indicators": {}})
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"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}"
body = "\\n".join(str(x.value) for x in list(app.caption) + list(app.markdown) + list(app.text) + list(app.warning) + list(app.info))
body += "\\n" + json.dumps([frame.value.to_dict("records") for frame in app.dataframe], ensure_ascii=False, default=str)
assert missing in body and excluded["reason"] in body, "saved panel must render excluded_items source_id and reason"
assert carried in body and carry["valuation_date"] in body, "saved panel must render carried_items source_id and historical valuation_date"
for key in ("approved_by", "approval_reason", "approval_reference"):
assert approval[key] in body, f"saved panel must render approval.{key} from the saved response"
def test_draft_cashflow_blocker_renders_safe_transaction_id_and_reason(monkeypatch):
from streamlit.testing.v1 import AppTest
from jarca_ledger_mcp.adapters.streamlit import settlement_gateway
+64
View File
@@ -244,3 +244,67 @@ def test_nonstring_cashflow_decision_kind_has_validation_error():
except Exception as caught:
error = caught
assert isinstance(error, ValueError), "malformed kind must have the validator's ValueError contract, not an unhandled TypeError"
def test_inactive_category_uses_raw_fallback_through_public_close(tmp_path):
path = str(tmp_path / "inactive-category-fallback.sqlite3")
init_db(path)
# A close run requires a nonempty asset valuation; cashflow-only drafts cannot finalize.
_ok(service.create_manual_asset("Synthetic close cash", 100, "2026-09-30", "cash", db_path=path))
cases = [
# SQL fallback is category-based: ordinary legacy categories become expense,
# while canonical legacy categories retain their established classifications.
("Old exclude", "A", "exclude", "expense", 11, "expense"),
("Old income", "B", "income", "expense", 13, "expense"),
("Old transfer", "C", "transfer", "expense", 17, "expense"),
("Old unknown", "D", "unknown", "income", 19, "expense"),
("Old refund", "E", "exclude", "refund", 5, "expense"),
("Old movement", "F", "exclude", "transfer", 7, "excluded"),
("수입", "Legacy", "exclude", "income", 29, "income"),
("Old expense", "H", "expense", "expense", 37, "expense"),
("Old investment", "G", "investment", "expense", 31, "expense"),
]
ids = {}
for index, (category, subcategory, kind, raw_type, amount, expected) in enumerate(cases):
_add_category(category, subcategory, kind, path)
txid = _add_tx(f"2026-09-{index + 1:02d}", raw_type, amount, category, subcategory, path)
assert service.delete_ledger_category(category, subcategory, force=True, db_path=path)["success"]
ids[txid] = (expected, kind, raw_type, amount)
draft = _draft(path)
reviewed = _ok(service.review_settlement_draft(draft["id"], db_path=path))
by_id = {row["id"]: row for row in reviewed["source_snapshot"]["transactions"]}
for txid, (expected, _kind, raw_type, amount) in ids.items():
row = by_id[txid]
applied = -amount if raw_type == "refund" else amount
assert row["cashflow_kind"] == expected
assert row["originalcashflow_kind"] == ("income" if row["category"] == "수입" else ("unknown" if row["category"] == "미분류" else "expense"))
assert row["applied_amount"] == applied # excluded movement retains its amount as evidence
assert row["sourceprovenance"] == "fallback"
assert row["category_cashflow_kind"] is None and row["category_active"] is None
assert reviewed["expense_total"] == 123 # 11 + 13 + 17 + 19 - 5 + 31 + 37
assert reviewed["income_total"] == 29
approved = _ok(service.approve_settlement_draft(draft["id"], draft["version"], reviewed["review_fingerprint"], "reviewer", "synthetic", "ref", db_path=path))
closed = _ok(service.finalize_settlement_draft(draft["id"], approved["draft"]["version"], reviewed["review_fingerprint"], "inactive-category-close", db_path=path))
snapshot = _ok(service.get_monthly_settlement_snapshot(run_id=closed["snapshot_run_id"], db_path=path))
persisted = {row["id"]: row for row in snapshot["source_snapshot"]["transactions"]}
for txid in ids:
assert persisted[txid]["sourceprovenance"] == "fallback"
assert persisted[txid]["category_active"] is None
assert persisted[txid]["category_cashflow_kind"] is None
assert snapshot["expense_total"] == 123 and snapshot["income_total"] == 29
def test_explicit_decision_classifies_only_unresolved_inactive_fallback(tmp_path):
path = str(tmp_path / "inactive-explicit-decision.sqlite3")
init_db(path)
_add_category("미분류", "Retired", "unknown", path)
unresolved = _add_tx("2026-09-10", "expense", 23, "미분류", "Retired", path)
assert service.delete_ledger_category("미분류", "Retired", force=True, db_path=path)["success"]
draft = _draft(path)
policy = _policy([{"category": "미분류", "subcategory": "Retired", "cashflow_kind": "income", "reason": "synthetic explicit decision"}])
_ok(_capability("set_settlement_cashflow_policy")(draft["id"], draft["version"], "operator", policy, db_path=path))
reviewed = _ok(service.review_settlement_draft(draft["id"], db_path=path))
row = next(x for x in reviewed["source_snapshot"]["transactions"] if x["id"] == unresolved)
assert row["cashflow_kind"] == "income" and row["decision"]["cashflow_kind"] == "income"
assert row["originalcashflow_kind"] == "unknown" and row["sourceprovenance"] == "fallback"
assert reviewed["income_total"] == 23