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
@@ -284,6 +284,8 @@ def page_monthly_settlement(db_path: str) -> None:
st.write(label, snapshot.get(key) if snapshot.get(key) is not None else "미보존") st.write(label, snapshot.get(key) if snapshot.get(key) is not None else "미보존")
rows = snapshot.get("cashflow_items", []) rows = snapshot.get("cashflow_items", [])
if isinstance(rows, list): st.dataframe([{k: row.get(k) for k in ("cashflow_kind", "category", "subcategory", "amount", "transaction_count", "currency")} for row in rows if isinstance(row, dict)]) if isinstance(rows, list): st.dataframe([{k: row.get(k) for k in ("cashflow_kind", "category", "subcategory", "amount", "transaction_count", "currency")} for row in rows if isinstance(row, dict)])
st.markdown("**저장된 헤더 근거**")
_render_asset_evidence({key: snapshot.get(key) for key in ("excluded_items", "carried_items", "approval")})
source_snapshot = snapshot.get("source_snapshot") if isinstance(snapshot.get("source_snapshot"), dict) else {} source_snapshot = snapshot.get("source_snapshot") if isinstance(snapshot.get("source_snapshot"), dict) else {}
asset_evidence = snapshot.get("items") if isinstance(snapshot.get("items"), list) else [] asset_evidence = snapshot.get("items") if isinstance(snapshot.get("items"), list) else []
source_assets = source_snapshot.get("assets") if isinstance(source_snapshot.get("assets"), list) else [] source_assets = source_snapshot.get("assets") if isinstance(source_snapshot.get("assets"), list) else []
+15
View File
@@ -499,6 +499,21 @@ The six totals are integer root fields in comprehensive review and saved-run rea
A review response has root-level `review_fingerprint`, `cashflow_policy`, `cashflow_items`, `cashflow_blockers`, and `source_snapshot`. The active draft getter nests its current review under `review`. For a completed draft, `get_settlement_draft` exposes the persisted close response under `final_response`; `finalize_settlement_draft` returns that close response at root, including on idempotent replay. A saved-run reader instead returns the stored snapshot at root with `source: settlement_snapshot`, `run_id`, and six totals. `cashflow_items` is a required list in comprehensive review, close and saved-reader results (empty is valid): each entry has `cashflow_kind` (income/expense/excluded string), `category` and `subcategory` (strings, including blanks), signed integer `amount`, positive integer `transaction_count`, and string `currency`. `cashflow_blockers` is a required review/close list with string `transaction_id` and `reason` entries; successful close requires it to be empty. The saved reader does not synthesize a blocker list from old records. `cashflow_policy` is the normalized policy object for comprehensive responses, but null for the legacy saved reader. `source_snapshot` is the preserved transaction/asset input object for comprehensive responses and an empty object for legacy reads. Version metadata is preserved, not inferred from today's ledger. The following JSON examples show selected fields rather than exhaustive response payloads. A review response has root-level `review_fingerprint`, `cashflow_policy`, `cashflow_items`, `cashflow_blockers`, and `source_snapshot`. The active draft getter nests its current review under `review`. For a completed draft, `get_settlement_draft` exposes the persisted close response under `final_response`; `finalize_settlement_draft` returns that close response at root, including on idempotent replay. A saved-run reader instead returns the stored snapshot at root with `source: settlement_snapshot`, `run_id`, and six totals. `cashflow_items` is a required list in comprehensive review, close and saved-reader results (empty is valid): each entry has `cashflow_kind` (income/expense/excluded string), `category` and `subcategory` (strings, including blanks), signed integer `amount`, positive integer `transaction_count`, and string `currency`. `cashflow_blockers` is a required review/close list with string `transaction_id` and `reason` entries; successful close requires it to be empty. The saved reader does not synthesize a blocker list from old records. `cashflow_policy` is the normalized policy object for comprehensive responses, but null for the legacy saved reader. `source_snapshot` is the preserved transaction/asset input object for comprehensive responses and an empty object for legacy reads. Version metadata is preserved, not inferred from today's ledger. The following JSON examples show selected fields rather than exhaustive response payloads.
#### Saved-reader additive asset metadata
`get_monthly_settlement_snapshot(month="", run_id="", db_path=None)` adds the following four root fields to a successful saved-run response, using the same strict public evidence projection as the asset close reader:
| Field | Type | Meaning / privacy |
|---|---|---|
| `excluded_items` | list of objects | Persisted excluded evidence projected through the public evidence allowlist; may include source identity, reason/status/date, candidate metadata and state hash when present. |
| `carried_items` | list of objects | Persisted carried evidence projected through the same allowlist. This is the persisted carried-evidence collection, not a recomputation from current valuations. |
| `applied_evidence` | list of objects | Persisted applied evidence projected through the same allowlist, including only allowlisted evidence attributes when present. |
| `approval` | object | Approval projection: `approved_by`, `approved_at`, `approval_reason`, `approval_reference`; legacy row-only records without run-header approval metadata return `{}`. |
Evidence objects are not a fixed full-row schema: keys are optional. `_closed_run_metadata` uses the same key allowlist recursively for nested dictionaries and lists; unknown keys (including `source_note`, `memo`, credential and raw-payload keys) are dropped, while allowlisted scalar values are preserved. Candidate metadata and source attributes may therefore retain persisted values under allowlisted keys. This is not a content-based secret redactor: callers must not persist sensitive values under public keys such as `name` or `reason`. Do not treat these fields as permission to expose a complete database row. The approval object is limited to `approved_by`, `approved_at`, `approval_reason`, and `approval_reference`; row-only legacy records return `{}`, while run headers preserve the stored approval values, including empty or null values.
For run-header-backed records, these collections and approval are projected from the immutable run metadata, rather than inferred from snapshot rows. Thus rows are not the origin of `applied_evidence`, `excluded_items`, or `carried_items`. Exact `run_id` and latest-month reads use the saved run/rows and stored metadata only: they do not write, initialize/migrate the database, consult live ledger authority, or recompute current selections. Legacy schema-13 snapshots with row-only evidence and no run header remain readable: the additive four fields use sensible empty arrays (`excluded_items`, `carried_items`, `applied_evidence`) and empty approval object, while preserving legacy status/version behavior and the six stored-total fields as `null` (they were not persisted). This additive metadata does not change the asset-only `get_asset_snapshot_report` result or its existing schema/version contract.
Successful root review example, including a negative signed expense (refund): Successful root review example, including a negative signed expense (refund):
```json ```json
@@ -248,7 +248,10 @@ def get_monthly_settlement_snapshot(month: str = "", run_id: str = "", db_path:
if not isinstance(policy, dict): if not isinstance(policy, dict):
raise ValueError() raise ValueError()
policy = _normalize_cashflow_policy(policy) policy = _normalize_cashflow_policy(policy)
return ok(source="settlement_snapshot", snapshot_run_id=rid, run_id=rid, settlement_month=field("settlement_month") or basis_date[:7], basis_date=basis_date, currency=field("currency"), is_closed_snapshot=True, is_comprehensive=comprehensive, legacy=legacy, aggregation_version=field("aggregation_version") if comprehensive else None, snapshot_schema_version=field("snapshot_schema_version") if comprehensive else None, cashflow_policy=policy, cashflow_items=cashflows, items=safe_items, source_snapshot=source, totals=totals, **totals, close_status=(field("close_status") or metadata.get("close_status", "legacy_read_only")), correction={"is_correction": bool(field("correction_of_run_id") or metadata.get("is_correction")), "correction_of_run_id": field("correction_of_run_id") or metadata.get("correction_of_run_id", ""), "correction_reason": field("correction_reason") or metadata.get("correction_reason", "")}) from .assets_service import _closed_run_metadata
public_metadata = _closed_run_metadata(metadata)
header_evidence = {key: public_metadata[key] for key in ("excluded_items", "carried_items", "applied_evidence", "approval")}
return ok(**header_evidence, source="settlement_snapshot", snapshot_run_id=rid, run_id=rid, settlement_month=field("settlement_month") or basis_date[:7], basis_date=basis_date, currency=field("currency"), is_closed_snapshot=True, is_comprehensive=comprehensive, legacy=legacy, aggregation_version=field("aggregation_version") if comprehensive else None, snapshot_schema_version=field("snapshot_schema_version") if comprehensive else None, cashflow_policy=policy, cashflow_items=cashflows, items=safe_items, source_snapshot=source, totals=totals, **totals, close_status=(field("close_status") or metadata.get("close_status", "legacy_read_only")), correction={"is_correction": bool(field("correction_of_run_id") or metadata.get("is_correction")), "correction_of_run_id": field("correction_of_run_id") or metadata.get("correction_of_run_id", ""), "correction_reason": field("correction_reason") or metadata.get("correction_reason", "")})
except DatabaseNotInitializedError: except DatabaseNotInitializedError:
return fail("db_not_initialized", "ledger database is not initialized") return fail("db_not_initialized", "ledger database is not initialized")
except Exception: except Exception:
@@ -51,7 +51,7 @@ def _review_cashflow(conn, month: str, policy: dict) -> dict:
{CASHFLOW_KIND_SQL} AS originalcashflow_kind, lc.cashflow_kind AS category_cashflow_kind, {CASHFLOW_KIND_SQL} AS originalcashflow_kind, lc.cashflow_kind AS category_cashflow_kind,
lc.active AS category_active lc.active AS category_active
FROM transactions t LEFT JOIN ledger_categories lc FROM transactions t LEFT JOIN ledger_categories lc
ON lc.category=t.category AND lc.subcategory=t.subcategory ON lc.category=t.category AND lc.subcategory=t.subcategory AND lc.active=1
WHERE t.status='active' AND t.transaction_date>=? AND t.transaction_date<? WHERE t.status='active' AND t.transaction_date>=? AND t.transaction_date<?
ORDER BY t.transaction_date,t.id""", (start.isoformat(), end.isoformat())).fetchall() ORDER BY t.transaction_date,t.id""", (start.isoformat(), end.isoformat())).fetchall()
savings = {(x["category"], x["subcategory"]) for x in policy["savings_categories"]} savings = {(x["category"], x["subcategory"]) for x in policy["savings_categories"]}
+134
View File
@@ -440,6 +440,37 @@ def test_saved_reader_retains_asset_authority_and_liability_flags(tmp_path):
assert asset.get("status") == "ready" 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")]) @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): def test_saved_reader_rejects_unsupported_comprehensive_versions_without_writes(tmp_path, schema_version, aggregation_version):
path = dbfile(tmp_path) 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)) legacy = ok(service.get_monthly_settlement_snapshot(run_id=saved["run_id"], db_path=path))
assert legacy["legacy"] and not legacy["is_comprehensive"] assert legacy["legacy"] and not legacy["is_comprehensive"]
assert all(value is None for value in legacy["totals"].values()) 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 pathlib import Path
from jarca_ledger_mcp import service from jarca_ledger_mcp import service
from test_comprehensive_settlement import schema_thirteen_conditional_close # noqa: F401
POLICY = "set_settlement_cashflow_policy" POLICY = "set_settlement_cashflow_policy"
READER = "get_monthly_settlement_snapshot" READER = "get_monthly_settlement_snapshot"
@@ -256,6 +257,43 @@ def test_authenticated_dashboard_renders_saved_comprehensive_snapshot(tmp_path,
import pytest 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): def test_draft_cashflow_blocker_renders_safe_transaction_id_and_reason(monkeypatch):
from streamlit.testing.v1 import AppTest from streamlit.testing.v1 import AppTest
from jarca_ledger_mcp.adapters.streamlit import settlement_gateway 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: except Exception as caught:
error = caught error = caught
assert isinstance(error, ValueError), "malformed kind must have the validator's ValueError contract, not an unhandled TypeError" 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