fix: address settlement display and documentation review findings

Rework Issue 18 / PR 19, source review 76: C11-F01 and D02-F02/D03-F03/D04-F04. Preserve the approved contract and add named display, schema, policy, ownership and signature regressions.
This commit is contained in:
2026-10-13 13:09:16 +00:00
parent 616bb7a744
commit ae4383ca2c
7 changed files with 296 additions and 3 deletions
+14 -2
View File
@@ -275,12 +275,24 @@ def page_monthly_settlement(db_path: str) -> None:
if not saved.get("success"):
st.info("저장된 스냅샷이 없거나 조회할 수 없습니다.")
else:
snapshot = saved.get("snapshot", saved)
st.caption(f"run {snapshot.get('run_id', '미보존')} · 버전 {snapshot.get('aggregation_version', 'legacy')} · 출처 {snapshot.get('source', '미보존')}")
snapshot = saved.get("snapshot") if isinstance(saved.get("snapshot"), dict) else saved
st.caption(f"run {snapshot.get('run_id', snapshot.get('snapshot_run_id', '미보존'))} · 버전 {snapshot.get('aggregation_version') or 'legacy'} · 출처 {snapshot.get('source', '미보존')}")
st.caption(f"close_status: {snapshot.get('close_status', '미보존')} · legacy: {snapshot.get('legacy', '미보존')} · comprehensive: {snapshot.get('is_comprehensive', '미보존')}")
if snapshot.get("close_status") == "conditional":
st.caption("조건부 마감 상태 (부분 승인 조건 적용)")
for key, label in (("income_total", "수입"), ("expense_total", "지출"), ("net_cashflow", "순현금흐름"), ("total_assets", "자산"), ("total_liabilities", "부채"), ("net_worth", "순자산")):
st.write(label, snapshot.get(key) if snapshot.get(key) is not None else "미보존")
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)])
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 []
source_assets = source_snapshot.get("assets") if isinstance(source_snapshot.get("assets"), list) else []
safe_asset_fields = ("source_type", "source_id", "status", "valuation_id", "valuation_date", "amount", "applied_amount", "verification_status", "authority_source", "exclusion_reason", "currency", "name", "evidence_kind", "valuation_source", "selection_mode", "selection_policy")
st.markdown("**저장 자산 근거**")
st.caption("누락(missing)·제외(excluded) 항목과 저장된 사유를 표시합니다. 미보존 근거는 추정하지 않습니다.")
asset_rows = list(asset_evidence)
asset_rows.extend(source_assets)
st.dataframe([{k: row.get(k) for k in safe_asset_fields} for row in asset_rows if isinstance(row, dict)])
st.divider()
render_settlement_drafts(db_path=db_path, month=selected_month)
+7 -1
View File
@@ -210,7 +210,13 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
items_cf = review.get("cashflow_items", [])
if isinstance(items_cf, list): st.dataframe([{k: row.get(k) for k in ("cashflow_kind", "category", "subcategory", "amount", "transaction_count", "currency")} for row in items_cf if isinstance(row, dict)])
blockers = review.get("cashflow_blockers", [])
for blocker in blockers if isinstance(blockers, list) else []: st.warning(str(blocker.get("code", "cashflow_blocked")) if isinstance(blocker, dict) else "현금흐름 블로커")
for blocker in blockers if isinstance(blockers, list) else []:
if isinstance(blocker, dict):
transaction_id = blocker.get("transaction_id")
reason = blocker.get("reason")
st.warning(f"거래 {transaction_id if isinstance(transaction_id, str) else '-'} · {reason if isinstance(reason, str) else '현금흐름 블로커'}")
else:
st.warning("현금흐름 블로커")
st.write("누락", review.get("missing_items", []))
if review.get("contains_substitutions"):
st.warning("대체 평가자료를 포함한 월정산")
+47
View File
@@ -195,6 +195,53 @@ Review fingerprint binds draft version, target/inclusion state, full valuation/m
KRW only aggregation; non-KRW is warned/excluded, never converted. NULL is not zero; no targets yields provisional zero plus completeness and unregistered-liability warning, not a complete close. Existing snapshot rows preserve full frozen evidence, approval, date/mode/difference and actors; substitution, carried and approved-missing flags are orthogonal. `monthly_source_snapshot_id` remains a read-model alias, never a persistence field.
# Issue #18 — schema 14 comprehensive settlement additions
### asset_snapshot_runs cashflow field contract
All twelve comprehensive-run columns are nullable with no SQL default. This preserves NULL on historical asset-only runs; a new comprehensive write supplies all twelve fields. SQLite reports `id` (TEXT PRIMARY KEY) nullable because DDL omits explicit NOT NULL, although a run logically requires its identifier.
| Column | Type | Nullability | Default |
|---|---|---|---|
| `settlement_month` | TEXT | NULL | none |
| `currency` | TEXT | NULL | none |
| `income_total` | INTEGER | NULL | none |
| `expense_total` | INTEGER | NULL | none |
| `net_cashflow` | INTEGER | NULL | none |
| `total_assets` | INTEGER | NULL | none |
| `total_liabilities` | INTEGER | NULL | none |
| `net_worth` | INTEGER | NULL | none |
| `cashflow_policy_json` | TEXT | NULL | none |
| `aggregation_version` | TEXT | NULL | none |
| `snapshot_schema_version` | INTEGER | NULL | none |
| `source_snapshot_json` | TEXT | NULL | none |
### settlement_drafts cashflow field contract
| Column | Type | Nullability | Default |
|---|---|---|---|
| `cashflow_policy_json` | TEXT | NOT NULL | `'{}'` |
| `aggregation_version` | TEXT | NOT NULL | `'comprehensive-v1'` |
### settlement_cashflow_snapshots cashflow field contract
| Column | Type | Nullability | Default |
|---|---|---|---|
| `id` | TEXT | NULL | none |
| `settlement_run_id` | TEXT | NOT NULL | none |
| `cashflow_kind` | TEXT | NOT NULL | none |
| `category` | TEXT | NOT NULL | `''` |
| `subcategory` | TEXT | NOT NULL | `''` |
| `amount` | INTEGER | NOT NULL | none |
| `transaction_count` | INTEGER | NOT NULL | none |
| `currency` | TEXT | NOT NULL | none |
`id` is logically required although SQLite reports no explicit NOT NULL flag for this TEXT primary key. `settlement_run_id` references `asset_snapshot_runs(id)` ON DELETE RESTRICT. The unique key is `(settlement_run_id, cashflow_kind, category, subcategory, currency)`. `transaction_count > 0` and amount/count are integers; `idx_settlement_cashflow_snapshots_run` indexes the run.
### Comprehensive snapshot evidence inputs
`source_snapshot_json` stores an object with `transactions` and `assets` arrays. Transaction inputs preserve `id`, `transaction_date`, `type`, original signed `amount`, `currency`, `category`, `subcategory`, `originalcashflow_kind`, applied `cashflow_kind`, `applied_amount`, `decision`, `sourceprovenance`, `category_cashflow_kind` and `category_active`. Refunds keep their original positive amount while the expense contribution in `applied_amount` is negative; unresolved classifications keep `applied_amount=NULL` and block approval. The actual stored alias is `originalcashflow_kind`, not a renamed persistence field.
Asset inputs preserve `source_id`, `source_type`, `valuation_id`, `valuation_date`, `basis_date`, `currency`, `authority_source`, `liquidity_bucket`, `is_liability`, `selection_mode`, `exclusion_reason`, `status`, `verification_status`, original `amount` and `applied_amount`. Missing/excluded evidence retains NULL applied amounts and its recorded status/reason; fixed included rows remain linked to the same run. The run header's `cashflow_policy_json`, `aggregation_version`, `snapshot_schema_version` and these source inputs together reproduce the original selection/aggregation decision without rereading today's ledger. A legacy asset-only run retains NULL comprehensive fields and is reported as legacy: no historical backfill or invented zero. The existing approval, draft, correction and idempotency links remain unchanged.
`asset_snapshot_runs` remains the run header. Schema 13→14 adds settlement month, currency, six nullable legacy-compatible INTEGER totals (`income_total`, `expense_total`, `net_cashflow`, `total_assets`, `total_liabilities`, `net_worth`), `cashflow_policy_json`, `aggregation_version`, `snapshot_schema_version`, and `source_snapshot_json`; preserve existing basis/approval/draft/correction/idempotency fields. Old asset-only run totals remain NULL; no current-ledger backfill. Migration is additive/idempotent; old unfinished drafts require fresh comprehensive review/approval.
`settlement_cashflow_snapshots`: eight columns `id`, `settlement_run_id`, `cashflow_kind`, `category`, `subcategory`, `amount`, `transaction_count`, `currency`; `cashflow_kind` is income/expense/excluded, `amount` signed INTEGER, count positive, category fields NOT NULL (empty string is explicit), FK to `asset_snapshot_runs.id` with RESTRICT, unique `(settlement_run_id,cashflow_kind,category,subcategory,currency)`, and run lookup index. Rows are grouped details for exactly one run.
+41
View File
@@ -471,3 +471,44 @@ Shared service setter: `set_settlement_cashflow_policy(draft_id,expected_version
The stored reader recognizes `snapshot_schema_version=1` only with `aggregation_version=comprehensive-v1`. Any non-NULL version metadata requires both recognized versions; a partial pair, unsupported snapshot schema, or absent/unknown aggregation version returns the existing `success=false`, `error_code=db_error`, `error` envelope without authoritative totals/details or DB writes. Legacy NULL schema headers remain legacy reads with unrecorded totals NULL; this does not reinterpret unknown comprehensive versions as legacy.
Getter distinguishes immutable stored run/evidence (exact run ID, schema/aggregation version, legacy/partial/missing/excluded indicators) from live current-ledger report. Existing asset-only API inputs/responses/errors/aliases and direct-close meaning remain unchanged and are not comprehensive close. Reads/render are readonly/no-create; authenticated Dashboard gate precedes DB. Existing envelope, stale approval/drift rejection, idempotent persisted replay, conflict, atomic rollback, append-only correction, and privacy contracts remain.
### Comprehensive cashflow policy input
`set_settlement_cashflow_policy(draft_id, expected_version, updated_by, cashflow_policy, db_path=None)` accepts this normalized input (omitted lists normalize to empty lists):
```json
{"savings_categories":[{"category":"Savings","subcategory":"Deposit"}],"category_decisions":[{"category":"Food","subcategory":"Dining","cashflow_kind":"expense","reason":"Reviewed classification"}]}
```
Only `savings_categories` and `category_decisions` are allowed top-level keys; each is a list. Savings entries have exactly `category` and `subcategory`; decision entries have exactly those keys plus `cashflow_kind` and `reason`. Category fields are strings and blank strings are valid. Kinds are `income`, `expense`, or `excluded`; reason must be a nonempty string. Within each list, duplicate category/subcategory pairs are rejected. Normalization sorts each list and supplies omitted lists as empty.
The setter requires matching `expected_version`; successful mutation increments version and clears prior approval/review fingerprint. Review computes a new fingerprint; approval applies only to the reviewed version/fingerprint. Changed policy or transactions require re-review and re-approval. `aggregation_version` is `comprehensive-v1`; policy editing increments the draft version, clears approval fields and invalidates the prior `review_fingerprint` for approval.
### Comprehensive settlement response contract
The six totals are integer root fields in comprehensive review and saved-run reader responses. Legacy readers return NULL for totals they never stored.
| Field | Type | Presence | Legacy |
|---|---|---|---|
| `income_total` | integer | required | null |
| `expense_total` | integer | required | null |
| `net_cashflow` | integer | required | null |
| `total_assets` | integer | required | null |
| `total_liabilities` | integer | required | null |
| `net_worth` | integer | required | null |
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.
Successful root review example, including a negative signed expense (refund):
```json
{"success":true,"income_total":120000,"expense_total":-5000,"net_cashflow":125000,"total_assets":900000,"total_liabilities":200000,"net_worth":700000,"review_fingerprint":"<fingerprint>","cashflow_policy":{"savings_categories":[],"category_decisions":[]},"cashflow_items":[{"cashflow_kind":"expense","category":"Food","subcategory":"Dining","amount":-5000,"transaction_count":1,"currency":"KRW"}],"cashflow_blockers":[{"transaction_id":"txn-example","reason":"unknown cashflow classification requires an explicit decision"}],"source_snapshot":{"transactions":[],"assets":[]}}
```
Saved reader root example:
```json
{"success":true,"source":"settlement_snapshot","run_id":"run-example","income_total":120000,"expense_total":-5000,"net_cashflow":125000,"total_assets":900000,"total_liabilities":200000,"net_worth":700000,"cashflow_policy":{"savings_categories":[],"category_decisions":[]},"source_snapshot":{"transactions":[],"assets":[]}}
```
A `validation_error` results when cashflow review blockers must be resolved before approval/finalize. Remediate policy or transaction classification, then re-review and re-approve. Examples use synthetic identifiers, not raw secrets.
+4
View File
@@ -245,6 +245,10 @@ Never document invented `jarca_ledger_mcp.mcp` or `monthly.py` modules. `transac
### Shared settlement-draft policies
| Module | Visibility | Responsibility / caller | Verification |
|---|---|---|---|
| `jarca_ledger_mcp/application/settlement_cashflow_service.py` | private | `_normalize_cashflow_policy` and `_review_cashflow` provide policy normalization and deterministic aggregation through `settlement_draft_service.py`; not an adapter/public API | `tests/test_settlement_cashflow_policy.py`, `tests/test_comprehensive_settlement_adapters.py` |
- `jarca_ledger_mcp/application/settlement_draft_service.py` checks one active draft per month for ordinary and correction drafts; SQLite independently enforces it with `idx_settlement_drafts_active_month` on month `WHERE status='active'`. Completed and cancelled history is retained, not deleted to release that slot.
- The same application service validates strict month syntax and month-end basis-date equality. Dashboard and MCP cannot supply an alternate date policy.
- The shared review service exposes unverified `candidate_amount` with authority amount NULL; no adapter may sum it as verified evidence. A confirmed child requires explicit reselection, fresh review and approval; correction children are not silently followed.
@@ -252,3 +252,99 @@ def test_authenticated_dashboard_renders_saved_comprehensive_snapshot(tmp_path,
assert conn.execute("SELECT name, sql FROM sqlite_master ORDER BY type,name").fetchall() == before
assert conn.execute("SELECT * FROM asset_snapshot_runs ORDER BY id").fetchall() == before_rows
assert conn.execute("SELECT * FROM settlement_cashflow_snapshots ORDER BY id").fetchall() == before_detail
import pytest
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
# Public review contract: cashflow_blockers is a list of safe dictionaries;
# these fields are emitted by the service, not renderer-invented `code`.
draft = {"id": "red-draft", "month": "2026-09", "basis_date": "2026-09-30",
"status": "active", "version": 1, "cashflow_policy": {"savings_categories": [], "category_decisions": []}}
blocker = {"transaction_id": "txn-safe-17", "reason": "unknown cashflow classification requires an explicit decision"}
monkeypatch.setattr(settlement_gateway, "list_settlement_drafts",
lambda *a, **k: {"success": True, "drafts": [draft]})
monkeypatch.setattr(settlement_gateway, "get_settlement_draft",
lambda *a, **k: {"success": True, "draft": draft, "items": [], "review": {"review_fingerprint": "fp"}})
monkeypatch.setattr(settlement_gateway, "review_settlement_draft",
lambda *a, **k: {"success": True, "version": 1, "review_fingerprint": "fp",
"cashflow_blockers": [blocker], "cashflow_items": [], "missing_items": []})
app = AppTest.from_string(
"import streamlit as st\n"
"from dashboard.page_views.settlement_drafts import render_settlement_drafts\n"
"render_settlement_drafts(db_path='synthetic-unused.sqlite3', month='2026-09')",
default_timeout=30,
)
app.run()
assert not app.exception, f"synthetic renderer fixture failed before assertion: {app.exception}"
next(button for button in app.button if button.label == "검토 / 새로고침 대상").click().run()
assert not app.exception
assert next(button for button in app.button if button.label == "검토 결과 승인").disabled
shown = "\n".join(str(x.value) for x in list(app.warning) + list(app.error) + list(app.caption))
assert "txn-safe-17" in shown and blocker["reason"] in shown, (
"public renderer must show safe transaction_id and reason from each blocker; "
"a failure here is the renderer dropping the declared public fields"
)
@pytest.mark.parametrize("comprehensive", [False, True])
def test_saved_monthly_snapshot_renders_close_legacy_and_asset_evidence(monkeypatch, comprehensive):
from streamlit.testing.v1 import AppTest
from jarca_ledger_mcp.adapters.streamlit import overview_gateway, reconciliation_gateway, settlement_gateway
# Mirrors get_monthly_settlement_snapshot's declared response envelope/keys.
# Legacy/partial record is read-only; excluded assets carry reason in `items`.
snapshot = {"source": "settlement_snapshot", "run_id": "saved-run-9", "snapshot_run_id": "saved-run-9",
"settlement_month": "2026-09", "basis_date": "2026-09-30", "is_closed_snapshot": True,
"is_comprehensive": False, "legacy": True, "aggregation_version": None,
"snapshot_schema_version": None, "close_status": "legacy_read_only", "cashflow_items": [],
"items": [{"source_type": "manual_asset", "source_id": "asset-opaque-3", "name": "Synthetic asset",
"exclusion_reason": "valuation_missing", "verification_status": "unverified"}],
"source_snapshot": {"transactions": [], "assets": []}, "totals": {},
"income_total": None, "expense_total": None, "net_cashflow": None,
"total_assets": None, "total_liabilities": None, "net_worth": None,
"correction": {"is_correction": False, "correction_of_run_id": "", "correction_reason": ""}}
if comprehensive:
snapshot.update(is_comprehensive=True, legacy=False, aggregation_version="comprehensive-v1",
snapshot_schema_version=1, close_status="conditional")
for key in ("income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth"):
snapshot[key] = 0
snapshot["totals"] = {key:snapshot[key] for key in ("income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth")}
snapshot["source_snapshot"]["assets"] = [
{"source_type":"account", "source_id":"missing-source-7", "status":"missing",
"amount":None, "applied_amount":None, "valuation_id":None,
"verification_status":"unverified", "exclusion_reason":""},
{"source_type":"account", "source_id":"excluded-source-8", "status":"excluded",
"amount":10, "applied_amount":None, "exclusion_reason":"synthetic exclusion",
"unexpected_evidence":"not-public-evidence"},
]
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, "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(
"from dashboard.page_views.monthly_settlement import page_monthly_settlement\n"
"page_monthly_settlement('synthetic-unused.sqlite3')", default_timeout=30)
app.run()
assert not app.exception, f"synthetic renderer fixture failed before assertion: {app.exception}"
import json
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 snapshot["close_status"] in body and (comprehensive or "legacy" in body.lower()), (
"saved snapshot renderer must expose close_status and legacy/partial provenance"
)
assert "asset-opaque-3" in body and "valuation_missing" in body and "제외" in body, (
"saved snapshot renderer must expose each missing/excluded asset status and reason"
)
if comprehensive:
assert "missing-source-7" in body and "missing" in body and "누락" in body
assert "excluded-source-8" in body and "synthetic exclusion" in body
assert "not-public-evidence" not in body
assert not app.exception
+87
View File
@@ -182,3 +182,90 @@ def test_settlement_draft_runtime_cancel_and_recovery_contract() -> None:
source = ast.parse(text("jarca_ledger_mcp/application/settlement_draft_service.py"))
cancel = next(n for n in source.body if isinstance(n, ast.FunctionDef) and n.name == "cancel_settlement_draft")
assert [arg.arg for arg in cancel.args.args] == ["draft_id", "expected_version", "cancelled_by", "reason", "db_path"]
def _comprehensive_section(doc, heading):
assert heading in doc, f"missing documented contract section: {heading}"
return doc.split(heading, 1)[1].split("\n### ", 1)[0]
def _comprehensive_table(section):
rows = {}
for line in section.splitlines():
if not line.startswith("|") or re.match(r"^\|\s*:?-+", line):
continue
cells = [c.strip().strip("` ") for c in line.strip().strip("|").split("|")]
if len(cells) == 4 and cells[0] not in {"Column", "Field", "field"}:
rows[cells[0]] = cells[1:]
return rows
def test_settlement_cashflow_model_documents_schema_and_legacy_reproduction(tmp_path):
import sqlite3
from jarca_ledger_mcp.db import init_db
doc = (ROOT / "docs/data-model.md").read_text(encoding="utf-8")
names = {
"asset_snapshot_runs": ("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"),
"settlement_drafts": ("cashflow_policy_json", "aggregation_version"),
"settlement_cashflow_snapshots": ("id", "settlement_run_id", "cashflow_kind", "category", "subcategory", "amount", "transaction_count", "currency"),
}
database = tmp_path / "synthetic-doc-schema.sqlite3"
init_db(str(database))
with sqlite3.connect(database) as conn:
for table, columns in names.items():
section = _comprehensive_section(doc, f"### {table} cashflow field contract")
observed = _comprehensive_table(section)
ddl = {r[1]: [r[2], "NOT NULL" if r[3] else "NULL", r[4] if r[4] is not None else "none"] for r in conn.execute(f"PRAGMA table_info({table})") if r[1] in columns}
assert observed == ddl, f"field types/nullability/defaults differ from actual DDL: {table}"
reproduction = _comprehensive_section(doc, "### Comprehensive snapshot evidence inputs")
for field in ("source_snapshot_json", "transactions", "assets", "transaction_date", "originalcashflow_kind", "applied_amount", "valuation_id", "valuation_date", "authority_source", "cashflow_policy_json", "legacy", "NULL"):
assert field in reproduction, field
for constraint in ("ON DELETE RESTRICT", "UNIQUE", "transaction_count > 0", "idx_settlement_cashflow_snapshots_run"):
assert constraint in doc, constraint
def test_settlement_cashflow_interface_specifies_valid_policy_review_and_response_examples():
from jarca_ledger_mcp.application.settlement_cashflow_service import _normalize_cashflow_policy
doc = (ROOT / "docs/interface-contract.md").read_text(encoding="utf-8")
policy_section = _comprehensive_section(doc, "### Comprehensive cashflow policy input")
match = re.search(r"```json\s*(.*?)\s*```", policy_section, re.S)
assert match, "policy input needs an executable JSON example"
policy = json.loads(match.group(1))
assert _normalize_cashflow_policy(policy) == policy
assert set(policy["savings_categories"][0]) == {"category", "subcategory"}
assert set(policy["category_decisions"][0]) == {"category", "subcategory", "cashflow_kind", "reason"}
for field in ("income", "expense", "excluded", "duplicate", "nonempty", "expected_version", "review_fingerprint", "aggregation_version", "comprehensive-v1"):
assert field in policy_section, field
outputs = _comprehensive_section(doc, "### Comprehensive settlement response contract")
totals = _comprehensive_table(outputs)
keys = ("income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth")
assert set(keys) <= set(totals)
for key in keys:
assert totals[key] == ["integer", "required", "null"], key
for field in ("cashflow_items", "cashflow_blockers", "transaction_id", "reason", "validation_error", "cashflow review blockers must be resolved", "final_response", "cashflow_policy", "source_snapshot", "refund"):
assert field in outputs, field
examples = [json.loads(s) for s in re.findall(r"```json\s*(.*?)\s*```", outputs, re.S)]
review = next((x for x in examples if isinstance(x, dict) and "cashflow_blockers" in x and "review_fingerprint" in x), None)
assert review is not None, "review response example must use actual root fields"
assert review["success"] is True and all(type(review[key]) is int for key in keys)
assert review["expense_total"] < 0, "signed-refund example must retain a negative expense total"
assert all(set(x) == {"transaction_id", "reason"} for x in review["cashflow_blockers"])
saved = next((x for x in examples if isinstance(x, dict) and x.get("source") == "settlement_snapshot"), None)
assert saved is not None and saved["success"] is True and "run_id" in saved
assert all(key in saved for key in keys)
def test_settlement_cashflow_private_service_has_explicit_ownership_and_test_mapping():
doc = (ROOT / "docs/project-structure.md").read_text(encoding="utf-8")
rows = [line for line in doc.splitlines() if line.startswith("|") and "settlement_cashflow_service.py" in line]
assert len(rows) == 1, "private aggregation owner must have an explicit responsibility row"
for field in ("private", "_normalize_cashflow_policy", "_review_cashflow", "settlement_draft_service.py", "test_settlement_cashflow_policy.py", "test_comprehensive_settlement_adapters.py"):
assert field in rows[0], field
def test_cashflow_policy_documented_signature_matches_public_api():
from jarca_ledger_mcp import service
import inspect
section = _comprehensive_section((ROOT / "docs/interface-contract.md").read_text(), "### Comprehensive cashflow policy input")
assert list(inspect.signature(service.set_settlement_cashflow_policy).parameters) == ["draft_id", "expected_version", "updated_by", "cashflow_policy", "db_path"]
assert "set_settlement_cashflow_policy(draft_id, expected_version, updated_by, cashflow_policy, db_path=None)" in section
assert "set_settlement_draft_cashflow_policy(" not in section