"""Cashflow policy lifecycle and explicit-classification contract tests.""" import json import sqlite3 from jarca_ledger_mcp import service from jarca_ledger_mcp.db import init_db def _capability(name): candidate = getattr(service, name, None) assert callable(candidate), f"required public service capability {name} is absent" return candidate def _ok(result): assert result["success"], result return result def _draft(path, month="2026-09"): return _ok(service.create_settlement_draft(month, "synthetic creator", "synthetic-key", db_path=path))["draft"] def _policy(decisions=None): return {"savings_categories": [{"category": "Savings", "subcategory": "Monthly"}], "category_decisions": decisions or []} def _add_category(name, subcategory, kind, path): return _ok(service.create_ledger_category(name, subcategory, cashflow_kind=kind, db_path=path)) def _add_tx(day, typ, amount, category, subcategory, path): result = service.create_ledger_transaction(day, typ, amount, category, subcategory, db_path=path) assert result["success"], result assert isinstance(result["transaction"], dict) return result["transaction"]["id"] def test_explicit_savings_refund_and_movement_precedence(tmp_path): path = str(tmp_path / "policy-precedence.sqlite3") init_db(path) draft = _draft(path) setter = _capability("set_settlement_cashflow_policy") _capability("get_monthly_settlement_snapshot") _add_category("Savings", "Monthly", "investment", path) _add_category("Savings", "Fund", "investment", path) _add_category("Movement", "Transfer", "transfer", path) _add_category("Voucher", "Load", "expense", path) _add_category("Voucher", "Consumption", "expense", path) _add_category("Loan", "Debt payment", "debt_payment", path) _add_category("Food", "Meals", "expense", path) ids = { "save": _add_tx("2026-09-01", "expense", 100, "Savings", "Monthly", path), "unlisted_investment": _add_tx("2026-09-02", "expense", 50, "Savings", "Fund", path), "transfer": _add_tx("2026-09-03", "transfer", 200, "Movement", "Transfer", path), "voucher_load": _add_tx("2026-09-04", "expense", 300, "Voucher", "Load", path), "voucher_consumption": _add_tx("2026-09-05", "expense", 30, "Voucher", "Consumption", path), "debt": _add_tx("2026-09-06", "expense", 40, "Loan", "Debt payment", path), "food": _add_tx("2026-09-07", "expense", 80, "Food", "Meals", path), "refund": _add_tx("2026-09-30", "refund", 20, "Food", "Meals", path), } policy = _policy([{"category": "Voucher", "subcategory": "Load", "cashflow_kind": "excluded", "reason": "synthetic asset movement"}]) changed = _ok(setter(draft["id"], draft["version"], "synthetic operator", policy, db_path=path)) saved = _ok(service.get_settlement_draft(draft["id"], db_path=path))["draft"] assert saved["cashflow_policy"] == policy and changed["draft"]["version"] == draft["version"] + 1 reviewed = _ok(service.review_settlement_draft(draft["id"], db_path=path)) assert "cashflow_items" in reviewed grouped = {(x["cashflow_kind"], x["category"], x["subcategory"]): x for x in reviewed["cashflow_items"]} assert grouped[("expense", "Savings", "Monthly")]["amount"] == 100 assert grouped[("expense", "Savings", "Monthly")]["transaction_count"] == 1 assert grouped[("excluded", "Voucher", "Load")]["transaction_count"] == 1 assert grouped[("expense", "Voucher", "Consumption")]["amount"] == 30 assert grouped[("expense", "Loan", "Debt payment")]["amount"] == 40 assert grouped[("expense", "Food", "Meals")]["amount"] == 60 assert not reviewed["cashflow_blockers"] source = reviewed["source_snapshot"]["transactions"] by_id = {x["id"]: x for x in source} for txid in ids.values(): assert {"id", "transaction_date", "type", "amount", "currency", "category", "subcategory", "cashflow_kind", "applied_amount"} <= by_id[txid].keys() assert by_id[ids["refund"]]["amount"] == 20 assert by_id[ids["refund"]]["applied_amount"] == -20 assert by_id[ids["transfer"]]["cashflow_kind"] == "excluded" assert by_id[ids["unlisted_investment"]]["cashflow_kind"] == "excluded" assert reviewed["expense_total"] == 230 def test_unknown_classification_blocks_and_explicit_exclusion_unblocks(tmp_path): path = str(tmp_path / "unknown-blocker.sqlite3") init_db(path) draft = _draft(path) _add_category("Mystery", "Other", "unknown", path) _add_tx("2026-09-14", "expense", 60, "Mystery", "Other", path) reviewed = _ok(service.review_settlement_draft(draft["id"], db_path=path)) assert reviewed["cashflow_blockers"] assert not service.approve_settlement_draft(draft["id"], draft["version"], reviewed["review_fingerprint"], "reviewer", "synthetic", "ref", db_path=path, allow_missing_exclusions=True)["success"] explicit = _policy([{"category": "Mystery", "subcategory": "Other", "cashflow_kind": "excluded", "reason": "synthetic operator exclusion"}]) edited = _ok(_capability("set_settlement_cashflow_policy")(draft["id"], draft["version"], "operator", explicit, db_path=path))["draft"] reviewed = _ok(service.review_settlement_draft(draft["id"], db_path=path)) assert not reviewed["cashflow_blockers"] assert reviewed["version"] == edited["version"] assert service.approve_settlement_draft(draft["id"], edited["version"], reviewed["review_fingerprint"], "reviewer", "synthetic", "ref", db_path=path)["success"] def test_policy_edit_persists_versions_and_invalidates_approval(tmp_path): path = str(tmp_path / "policy-approval.sqlite3") init_db(path) _ok(service.create_manual_asset("Synthetic cash", 100, "2026-09-30", "cash", db_path=path)) draft = _draft(path) reviewed = _ok(service.review_settlement_draft(draft["id"], db_path=path)) assert not reviewed["cashflow_blockers"] _ok(service.approve_settlement_draft(draft["id"], draft["version"], reviewed["review_fingerprint"], "reviewer", "synthetic", "ref", db_path=path)) result = _ok(_capability("set_settlement_cashflow_policy")(draft["id"], draft["version"], "editor", _policy(), db_path=path)) saved = _ok(service.get_settlement_draft(draft["id"], db_path=path))["draft"] assert saved["cashflow_policy"] == _policy() assert result["draft"]["version"] == draft["version"] + 1 assert saved["approved_by"] == "" def test_invalid_policy_payload_is_rejected_without_write(tmp_path): path = str(tmp_path / "policy-invalid.sqlite3") init_db(path) draft = _draft(path) before = _ok(service.get_settlement_draft(draft["id"], db_path=path)) invalid = {"savings_categories": [{"category": "Savings"}], "category_decisions": []} result = _capability("set_settlement_cashflow_policy")(draft["id"], draft["version"], "operator", invalid, db_path=path) assert not result["success"] assert _ok(service.get_settlement_draft(draft["id"], db_path=path)) == before def test_review_contract_and_stored_reader_never_recomputes_live_preview(tmp_path): path = str(tmp_path / "stored-reader.sqlite3") init_db(path) reader = _capability("get_monthly_settlement_snapshot") empty = reader(month="2026-09", db_path=path) assert empty["success"] is False and empty["error_code"] == "not_found" _ok(service.create_manual_asset("Synthetic cash", 100, "2026-09-30", "cash", db_path=path)) draft = _draft(path) reviewed = _ok(service.review_settlement_draft(draft["id"], db_path=path)) assert "review_fingerprint" in reviewed and "review" not in reviewed approved = _ok(service.approve_settlement_draft(draft["id"], draft["version"], reviewed["review_fingerprint"], "reviewer", "synthetic", "ref", db_path=path)) final = _ok(service.finalize_settlement_draft(draft["id"], approved["draft"]["version"], reviewed["review_fingerprint"], "close-key", db_path=path)) assert "final_response" not in final and "snapshot_run_id" in final snapshot = _ok(reader(run_id=final["snapshot_run_id"], db_path=path)) assert snapshot["is_closed_snapshot"] is True assert snapshot["snapshot_run_id"] == final["snapshot_run_id"] assert snapshot["total_assets"] == 100 assert _ok(reader(month="2026-09", db_path=path))["snapshot_run_id"] == final["snapshot_run_id"] _ok(service.create_manual_asset("Later live cash", 900, "2026-09-30", "cash", db_path=path)) persisted = _ok(reader(run_id=final["snapshot_run_id"], db_path=path)) assert persisted["total_assets"] == 100 and persisted["snapshot_run_id"] == final["snapshot_run_id"] assert "live_authority" not in persisted.values() def test_month_boundaries_refund_month_negative_total_and_fx_blocker(tmp_path): path = str(tmp_path / "month-boundaries.sqlite3") init_db(path) _add_category("Food", "Meals", "expense", path) _add_tx("2026-08-31", "expense", 10, "Food", "Meals", path) _add_tx("2026-09-01", "expense", 5, "Food", "Meals", path) _add_tx("2026-10-01", "expense", 7, "Food", "Meals", path) _add_tx("2026-09-15", "refund", 20, "Food", "Meals", path) _add_tx("2026-09-16", "expense", 3, "Food", "Meals", path) d = _draft(path) reviewed = _ok(service.review_settlement_draft(d["id"], db_path=path)) food = next(x for x in reviewed["cashflow_items"] if x["cashflow_kind"] == "expense" and x["category"] == "Food") assert food["amount"] == -12 assert reviewed["expense_total"] == -12 assert reviewed["cashflow_blockers"] == [] _add_category("Foreign", "Purchase", "expense", path) _add_tx("2026-09-20", "expense", 12, "Foreign", "Purchase", path) with sqlite3.connect(path) as db: db.execute("UPDATE transactions SET currency='USD' WHERE category='Foreign'") reviewed = _ok(service.review_settlement_draft(d["id"], db_path=path)) assert reviewed["cashflow_blockers"] assert any("fx" in json.dumps(x).lower() or "currency" in json.dumps(x).lower() for x in reviewed["cashflow_blockers"]) assert reviewed["expense_total"] == -12 def test_known_refund_only_category_is_retained_at_zero(tmp_path): path = str(tmp_path / "zero-refund.sqlite3") init_db(path) _add_category("Food", "Meals", "expense", path) _add_tx("2026-09-01", "expense", 20, "Food", "Meals", path) _add_tx("2026-09-02", "refund", 20, "Food", "Meals", path) reviewed = _ok(service.review_settlement_draft(_draft(path)["id"], db_path=path)) food = next(x for x in reviewed["cashflow_items"] if x["cashflow_kind"] == "expense" and x["category"] == "Food") assert food["amount"] == 0 and food["transaction_count"] == 2 def test_policy_capabilities_are_checked_at_call_time(tmp_path): path = str(tmp_path / "future-capabilities.sqlite3") init_db(path) assert callable(_capability("set_settlement_cashflow_policy")) assert callable(_capability("get_monthly_settlement_snapshot")) assert _capability("get_monthly_settlement_snapshot")(month="", run_id="", db_path=path)["error_code"] == "not_found" def _private_cashflow_review(path, policy=None): from jarca_ledger_mcp.application.settlement_cashflow_service import _review_cashflow with sqlite3.connect(path) as conn: conn.row_factory = sqlite3.Row return _review_cashflow(conn, "2026-09", policy or {}) def test_cashflow_group_field_matches_persisted_detail_contract(tmp_path): path = str(tmp_path / "group-field.sqlite3") init_db(path) _add_category("Food", "Meals", "expense", path) _add_tx("2026-09-01", "expense", 20, "Food", "Meals", path) result = _private_cashflow_review(path) assert len(result["cashflow_items"]) == 1 assert "cashflow_kind" in result["cashflow_items"][0], "group shape must use the persisted/public cashflow_kind field" def test_refund_reduces_debt_payment_and_selected_savings(tmp_path): for kind, category, subcategory in [("debt_payment", "Loan", "Payment"), ("investment", "Savings", "Monthly")]: path = str(tmp_path / (kind + "-refund.sqlite3")) init_db(path) _add_category(category, subcategory, kind, path) txid = _add_tx("2026-09-01", "refund", 20, category, subcategory, path) result = _private_cashflow_review(path, _policy()) assert not result["cashflow_blockers"] assert result["expense_total"] == -20, "refund must reduce expense even for debt payment or selected savings" assert result["source_snapshot"]["transactions"][0]["id"] == txid assert result["source_snapshot"]["transactions"][0]["applied_amount"] == -20 def test_unknown_cashflow_source_is_not_recorded_as_excluded(tmp_path): path = str(tmp_path / "unknown-source.sqlite3") init_db(path) _add_category("Mystery", "Other", "unknown", path) _add_tx("2026-09-01", "expense", 20, "Mystery", "Other", path) result = _private_cashflow_review(path) assert result["cashflow_blockers"] and not result["cashflow_items"] assert result["source_snapshot"]["transactions"][0]["cashflow_kind"] != "excluded", "unknown is not an authorized exclusion" def test_nonstring_cashflow_decision_kind_has_validation_error(): from jarca_ledger_mcp.application.settlement_cashflow_service import _normalize_cashflow_policy malformed = {"category_decisions": [{"category": "Food", "subcategory": "Meals", "cashflow_kind": [], "reason": "synthetic"}]} error = None try: _normalize_cashflow_policy(malformed) except Exception as caught: error = caught assert isinstance(error, ValueError), "malformed kind must have the validator's ValueError contract, not an unhandled TypeError"