From dd35e0785f286c5fca7d5f54e466b032092c97d7 Mon Sep 17 00:00:00 2001 From: gitea-impl-agent Date: Tue, 13 Oct 2026 04:36:38 +0000 Subject: [PATCH 1/5] feat: extend comprehensive monthly settlement snapshots (#18) --- dashboard/page_views/monthly_settlement.py | 15 + dashboard/page_views/settlement_drafts.py | 36 +- docs/data-model.md | 7 + docs/interface-contract.md | 7 + docs/project-structure.md | 3 + .../req-003_account-valuation-history.md | 230 ++++++++- docs/runtime-flow.md | 5 + jarca_ledger_mcp/adapters/mcp/server.py | 11 + .../adapters/mcp/tool_contracts.py | 4 + .../adapters/streamlit/settlement_gateway.py | 12 +- .../application/monthly_settlement_service.py | 123 +++++ .../settlement_cashflow_service.py | 126 +++++ .../application/settlement_draft_service.py | 88 +++- .../infrastructure/sqlite/migrations.py | 28 +- .../infrastructure/sqlite/schema.py | 31 +- tests/test_account_valuations.py | 2 +- tests/test_comprehensive_settlement.py | 478 ++++++++++++++++++ .../test_comprehensive_settlement_adapters.py | 254 ++++++++++ tests/test_issue_12_regressions.py | 2 +- tests/test_ledger_categories.py | 2 +- tests/test_monthly_settlement_drafts.py | 2 +- tests/test_requirement_docs.py | 24 +- tests/test_review_decision_workbench.py | 2 +- tests/test_service.py | 2 +- tests/test_settlement_cashflow_policy.py | 246 +++++++++ tests/test_settlement_cashflow_schema.py | 89 ++++ ...st_settlement_draft_audit_reproductions.py | 2 +- ...est_settlement_draft_contract_scenarios.py | 16 +- tests/test_settlement_draft_docs.py | 16 +- ...est_settlement_draft_policy_consistency.py | 2 +- 30 files changed, 1817 insertions(+), 48 deletions(-) create mode 100644 jarca_ledger_mcp/application/settlement_cashflow_service.py create mode 100644 tests/test_comprehensive_settlement.py create mode 100644 tests/test_comprehensive_settlement_adapters.py create mode 100644 tests/test_settlement_cashflow_policy.py create mode 100644 tests/test_settlement_cashflow_schema.py diff --git a/dashboard/page_views/monthly_settlement.py b/dashboard/page_views/monthly_settlement.py index 004cfbe..17afae0 100644 --- a/dashboard/page_views/monthly_settlement.py +++ b/dashboard/page_views/monthly_settlement.py @@ -9,6 +9,7 @@ import streamlit as st from jarca_ledger_mcp.adapters.streamlit import overview_gateway from jarca_ledger_mcp.adapters.streamlit import reconciliation_gateway +from jarca_ledger_mcp.adapters.streamlit import settlement_gateway from dashboard.page_views.settlement_drafts import render_settlement_drafts from dashboard.common import * # noqa: F403, F405 @@ -267,6 +268,20 @@ def page_monthly_settlement(db_path: str) -> None: st.markdown(f"- {action}") st.divider() + st.markdown("### 저장된 월정산 스냅샷") + st.caption("아래는 저장 시점의 불변 기록이며 위의 live KPI/리포트와 별도입니다.") + exact_run = st.text_input("정확한 run ID (선택)", key="saved_settlement_run_id") + saved = settlement_gateway.get_monthly_settlement_snapshot(month=selected_month, run_id=exact_run, db_path=db_path) + 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', '미보존')}") + 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)]) + st.divider() render_settlement_drafts(db_path=db_path, month=selected_month) st.divider() diff --git a/dashboard/page_views/settlement_drafts.py b/dashboard/page_views/settlement_drafts.py index c9323b0..66f24dc 100644 --- a/dashboard/page_views/settlement_drafts.py +++ b/dashboard/page_views/settlement_drafts.py @@ -1,5 +1,7 @@ from __future__ import annotations +import json + import streamlit as st from jarca_ledger_mcp.adapters.streamlit import settlement_gateway as gateway @@ -83,6 +85,14 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non if draft.get("status") == "completed": st.success(f"완료 — run {draft.get('final_run_id', '-')}; 변경 불가, 정정은 새 초안으로 생성합니다.") if draft.get("status") in {"completed", "cancelled"}: + final = detail.get("final_response") if isinstance(detail.get("final_response"), dict) else {} + if final: + st.markdown("### 기록된 종합 월정산 결과") + st.caption(f"정책 버전: {final.get('aggregation_version', 'legacy')}") + for key, label in (("income_total", "수입"), ("expense_total", "지출"), ("net_cashflow", "순현금흐름"), ("total_assets", "자산"), ("total_liabilities", "부채"), ("net_worth", "순자산")): + st.write(label, final.get(key) if final.get(key) is not None else "미보존") + rows = final.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)]) st.markdown("### 불변 이력") st.write("final_run_id", draft.get("final_run_id")) st.write("correction_of_run_id", draft.get("correction_of_run_id")) @@ -102,6 +112,23 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non except ValueError: st.caption("날짜 차이를 계산할 수 없습니다.") if item.get("status") == "missing": st.warning("누락 — 값은 0원이 아닙니다.") + st.markdown("### 종합 월정산 정책") + policy_now = draft.get("cashflow_policy") if isinstance(draft.get("cashflow_policy"), dict) else {"savings_categories": [], "category_decisions": []} + with st.form(f"cashflow_policy_{selected_id}"): + policy_text = st.text_area("정책 JSON (savings_categories 및 category_decisions)", value=json.dumps(policy_now, ensure_ascii=False, indent=2), height=220) + policy_submit = st.form_submit_button("정책 저장") + if policy_submit: + try: + policy = json.loads(policy_text) + if not isinstance(policy, dict) or not isinstance(policy.get("savings_categories", []), list) or not isinstance(policy.get("category_decisions", []), list): raise ValueError + for decision in policy.get("category_decisions", []): + if not isinstance(decision, dict) or decision.get("cashflow_kind") not in {"income", "expense", "excluded"}: raise ValueError + result = gateway.set_settlement_cashflow_policy(selected_id, version, actor, policy, db_path=db_path) + if result.get("success"): _invalidate_and_rerun(selected_id, result) + _safe_result(result) + except (ValueError, TypeError): + st.error("정책은 유효한 JSON이어야 하며 각 분류는 income, expense 또는 excluded로 지정하세요.") + with st.form(f"settlement_edit_{selected_id}"): memo_edit = st.text_input("초안 메모 저장", value=draft.get("memo", "")) source_type = st.selectbox("대상 유형", ["account", "manual_asset"]) @@ -179,7 +206,11 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non review_result = review_result or st.session_state.get(f"review_result_{selected_id}") if isinstance(review_result, dict) and review_result.get("success"): review = review_result.get("review", review_result) - st.write("검토 합계 (잠정)", review.get("total_assets"), review.get("total_liabilities"), review.get("net_worth")) + st.write("검토 합계 (잠정)", *[review.get(k) for k in ("income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth")]) + 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 "현금흐름 블로커") st.write("누락", review.get("missing_items", [])) if review.get("contains_substitutions"): st.warning("대체 평가자료를 포함한 월정산") @@ -192,7 +223,8 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non allow_missing = st.checkbox("누락 승인", value=False) allow_carried = st.checkbox("이월 승인", value=False) allow_substitute = st.checkbox("대체자료 승인", value=False) - approve = st.form_submit_button("검토 결과 승인") + approve = st.form_submit_button("검토 결과 승인", disabled=bool(review.get("cashflow_blockers"))) + if review.get("cashflow_blockers"): st.caption("현금흐름 블로커를 해소해야 승인할 수 있습니다.") if approve: if st.session_state.get(state_key) != (selected_id, review.get("version", version), fingerprint): st.error("선택한 초안/버전/검토 fingerprint가 변경되었습니다. 다시 검토하세요.") diff --git a/docs/data-model.md b/docs/data-model.md index 704712e..91d5d10 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -193,3 +193,10 @@ Frozen/public evidence retains `valuation_date`, original `recorded_at`, derived Review fingerprint binds draft version, target/inclusion state, full valuation/manual semantic evidence, correction child, warnings, amounts/classification/date and prior run. Approved fingerprint/version become invalid on mutation or external drift. Finalize checks idempotency replay before active status, then validates version, approval, fingerprint, current evidence and correction parent, and inserts run+rows+draft completion/link in one `BEGIN IMMEDIATE`. Rollback on any SQLite failure; replay returns persisted response. 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` 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. + +Policy is stored on draft and header. Savings means only explicit category/subcategory pairs; transfer/voucher exclusion has priority; debt_payment is expense; refund is negative expense in refund month/original category. Unknown unresolved blocks approval; asset conditional flags cannot override it. KRW only/no FX. Source JSON freezes transaction values/classification/policy/decision and selected asset evidence/provenance. Six equations are validated against same-run details; excluded/duplicate/unknown amount do not sum. Header, both detail areas, evidence, completed draft linkage/response commit atomically; rollback all on failure. Runs are immutable; corrections append after latest-parent/single-child validation. diff --git a/docs/interface-contract.md b/docs/interface-contract.md index ca2ef27..01cbbff 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -464,3 +464,10 @@ Create validates strict YYYY-MM and month-end date, nonempty actor/key and paylo Nested draft `approval` retains `allow_missing_exclusions`, `allow_carried_before`, and `allow_manual_substitute` alongside actor/time/reason/reference. The three approval flags and the three result flags (`contains_substitutions`, `contains_carried`, `has_approved_missing`) are independent. Unverified evidence can be visible as `candidate_amount` but cannot become applied monetary authority; only an explicitly approved missing/excluded row may persist with NULL amount. No lineage default keys are injected into legacy persisted evidence arrays. Recursive allowlisting excludes raw source notes, memos, and unknown nested fields. Executable field-contract regression: `tests/test_settlement_draft_contract_scenarios.py::test_date_metadata_public_contract_names_survive_all_closed_consumers`. Related clause tests cover representative integer precision, transaction readiness independent of asset close, approved unresolved candidates, and the synthetic September pension/loan dates with stale, rollback, and concurrent idempotent retry. +# Issue #18 additive public contracts (schema 14) + +Shared service setter: `set_settlement_cashflow_policy(draft_id,expected_version,updated_by,cashflow_policy,db_path=None)`; stored run getter: `get_monthly_settlement_snapshot(month='',run_id='',db_path=None)`. MCP signatures omit internal `db_path`. Draft policy is `draft.cashflow_policy`; policy edits increment version and invalidate approval. Review/finalize results expose fields at response root; active get nests `review`, completed get has root `final_response`. Stored grouped details use `cashflow_kind` (income/expense/excluded), not `kind`. + +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. diff --git a/docs/project-structure.md b/docs/project-structure.md index 6917124..fd46dae 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -250,3 +250,6 @@ Never document invented `jarca_ledger_mcp.mcp` or `monthly.py` modules. `transac - 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. - Dependencies remain page → gateway → public facade → application → SQLite, with MCP server/tool contracts → the same facade. No UI/MCP direct SQL, application-to-Dashboard reverse dependency or duplicated business validation is introduced. - Paired synthetic coverage: `tests/test_monthly_settlement_drafts.py` (month-end, active-month, unverified/reselection, cancellation and atomicity), `tests/test_settlement_draft_adapters.py` (facade/gateway/MCP signatures and no-create reads), `tests/test_settlement_draft_dashboard.py` and `tests/test_settlement_draft_panel_workflow.py` (shared panel/actions), `tests/test_settlement_draft_mcp_runtime.py` (real stdio responses), and `tests/test_settlement_draft_docs.py` (this mapping). Existing authentication/menu/legacy regression tests remain unchanged. +# Issue #18 ownership additions + +SQLite `schema.py`/`migrations.py` own additive/idempotent schema 13→14, nullable legacy totals, FK/unique/run-index constraints. `application/settlement_draft_service.py` owns durable draft policy, category-pair selection, grouped computation, complete review fingerprint, unknown blocker, approval guard, six-equation verification, atomic finalize/replay/correction. `monthly_settlement_service.py` owns readonly stored-run lookup and live-versus-stored distinction; `assets_service.py` retains existing asset authority and asset-only API semantics. MCP tool contracts/server and Streamlit settlement gateway delegate to shared services; Dashboard displays shared results only after auth. No adapter duplicates policy or runs SQL. Synthetic coverage extends lifecycle, migration, adapter, rollback, drift and correction regressions; no test run is claimed here. diff --git a/docs/requirements/req-003_account-valuation-history.md b/docs/requirements/req-003_account-valuation-history.md index e3f94e2..3ba89bd 100644 --- a/docs/requirements/req-003_account-valuation-history.md +++ b/docs/requirements/req-003_account-valuation-history.md @@ -3,13 +3,13 @@ "schema": "gitea-agent.requirement/v1", "id": "REQ-003", "title": "계좌 기준일 평가 이력과 자산 마감 연동", - "revision": "1.1", + "revision": "1.2", "status": "approved", "depends_on": [], "applies_to": ["ledger", "dashboard", "mcp"], "language": "ko", - "issue": "#15", - "implementation_pr": "https://gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp/pulls/16" + "issue": "#18", + "implementation_pr": "미정" } --- @@ -21,7 +21,7 @@ ## 2. 배경 -구현은 SQLite schema 13를 schema 11에 additive하게 적용한다. 계좌 잔액·import 결과는 실제 증거가 없는 한 `unverified` 후보이고, 기존 거래·인증·메뉴 계약을 바꾸지 않는다. +기존 schema 11→13의 additive 이력을 보존하고 이번 종합 정산 확장은 schema 14를 additive하게 적용한다. 계좌 잔액·import 결과는 실제 증거가 없는 한 `unverified` 후보이고, 기존 거래·인증·메뉴 계약을 바꾸지 않는다. ## 3. 범위 @@ -52,7 +52,7 @@ ### REQ-003.01 — additive 이력과 legacy 보존 -schema 11의 테이블·필드·거래를 삭제하거나 추측 이주하지 않고 schema 13의 `account_valuations`, `asset_snapshot_runs`를 추가하며 `asset_snapshots`에 필요한 필드를 additive하게 확장한다. migration은 idempotent다. +schema 11의 테이블·필드·거래를 삭제하거나 추측 이주하지 않고 schema 13의 `account_valuations`, `asset_snapshot_runs`를 추가하며 `asset_snapshots`에 필요한 필드를 additive하게 확장한다. migration은 idempotent다. Issue #18의 유일한 schema 확장은 additive/idempotent 13→14이며 기존 자산 전용 run의 미저장 종합 총계는 NULL로 유지하고 backfill하지 않는다. **영향 범위** - 인터페이스: 기존 service/MCP 응답 envelope 유지, 신규 valuation/snapshot fields additive @@ -172,7 +172,7 @@ MCP와 Dashboard는 shared service/gateway를 사용한다. 기존 success/error - 호환성: 기존 auth/menu/query/shell/browser evidence semantic inventory 0 missing - 배포 전제 조건: secret redaction과 fixture DB -### REQ-003.11 — 월정산 초안·승인·원자적 마감 (Issue #15) +### REQ-003.11 — 월정산 초안·승인·원자적 종합 마감 (Issue #15, #18) 월말 기준일의 durable draft는 자료가 없어도 생성되며 생성 자체로 run/rows를 만들지 않는다. 월별 활성 초안은 일반·정정 공통으로 하나다. 대상 구성은 명시적 refresh만 반영하고 검토는 frozen evidence의 version/fingerprint에 묶인다. Finalize는 drift를 거절하고 `BEGIN IMMEDIATE`에서 run, rows, completion/link를 함께 기록하며 동일 요청은 persisted response로 replay한다. @@ -418,7 +418,7 @@ SQLite write 실패 시 run, rows, completed 상태가 모두 rollback된다. 부분 마감 결과는 남지 않는다. -### REQ-003.11-AC12 — additive schema 13 migration +### REQ-003.11-AC12 — additive schema 14 migration **판정 기준** @@ -540,11 +540,11 @@ New draft operations are additive and legacy entrypoints remain available. ### REQ-003.01-AC01 — migration and preservation -**Cases (정상/경계/실패)**: 정상 schema 13 init twice; 경계 schema 11 legacy DB; 실패 malformed legacy DB. +**Cases (정상/경계/실패)**: 정상 schema 14 init twice; 경계 schema 11/13 legacy DB; 실패 malformed legacy DB. **State Invariants**: legacy tables/rows remain; migration repeats without duplicate columns. -**판정 기준**: `schema_version=13`, required tables/columns/indexes exist and old rows remain. +**판정 기준**: `schema_version=14`, 기존 및 종합 정산 tables/columns/indexes가 존재하고 old rows와 구버전 미보존 NULL을 유지한다. **검증**: `tests/test_account_valuations.py::test_account_valuation_schema_is_v12_and_idempotent`, `tests/test_issue_12_regressions.py::test_schema12_migration_preserves_legacy_rows_indexes_and_is_idempotent`. **검증 분류**: level=integration; backend=sqlite @@ -648,6 +648,206 @@ New draft operations are additive and legacy entrypoints remain available. **검증**: `tests/test_requirement_docs.py`, auth/menu evidence tests, repository redaction/static search. **검증 분류**: level=static; backend=dashboard +### REQ-003.11-AC18 — 종합 저장 구조와 여섯 총계 + +**판정 기준** + +같은 run의 헤더에 여섯 총계·월·통화·정책·버전·근거가 저장되고 양 상세는 같은 ID에 결속한다. 현금흐름 상세의 signed INTEGER, positive count, FK RESTRICT, unique5, NOT NULL 빈 분류를 검증한다. + +**검증** + +`tests/test_comprehensive_settlement.py::test_closed_header_persists_six_totals_policy_and_versions`; `tests/test_settlement_cashflow_schema.py::test_cashflow_detail_constraints_preserve_blank_categories` (실제 실행 기록과 최종 head에 결속하며 문서 기재 자체는 PASS가 아니다.) + +**검증 분류** + +- 수준: integration +- 백엔드: sqlite +- 케이스: 정상·경계·실패 synthetic 자료 + +**상태 불변조건** + +기존 REQ-003 권위·승인·호환성 의미를 유지하며 미검증 또는 실패 상태는 완료로 판정하지 않는다. + +### REQ-003.11-AC19 — 환급·저축·이동·대출상환 정책 + +**판정 기준** + +환급월의 원래 지출 분류에 음수 지출을 반영한다. 명시적 category/subcategory 저축 목록만 expense에 포함하고 debt_payment는 expense로 매핑한다. 일반 이체·초기잔액 조정·명시적 상품권 충전 제외를 우선하고 investment 전체를 포함하지 않는다. + +**검증** + +`tests/test_settlement_cashflow_policy.py::test_explicit_savings_refund_and_movement_precedence`; `tests/test_settlement_cashflow_policy.py::test_refund_reduces_debt_payment_and_selected_savings`; `tests/test_settlement_cashflow_policy.py::test_month_boundaries_refund_month_negative_total_and_fx_blocker` (실제 실행 기록과 최종 head에 결속하며 문서 기재 자체는 PASS가 아니다.) + +**검증 분류** + +- 수준: integration +- 백엔드: sqlite +- 케이스: 정상·경계·실패 synthetic 자료 + +**상태 불변조건** + +기존 REQ-003 권위·승인·호환성 의미를 유지하며 미검증 또는 실패 상태는 완료로 판정하지 않는다. + +### REQ-003.11-AC20 — 미결정 분류 승인 차단 + +**판정 기준** + +대응 분류 없는 환급과 미결정 unknown은 종합 승인·마감을 차단한다. 자산의 세 조건부 승인 플래그는 이 차단을 우회하지 못한다. 재분류 또는 명시적 포함/제외 판단 뒤 재검토하며 확정 제외는 차단하지 않는다. + +**검증** + +`tests/test_settlement_cashflow_policy.py::test_unknown_classification_blocks_and_explicit_exclusion_unblocks`; `tests/test_settlement_cashflow_policy.py::test_unknown_cashflow_source_is_not_recorded_as_excluded` (실제 실행 기록과 최종 head에 결속하며 문서 기재 자체는 PASS가 아니다.) + +**검증 분류** + +- 수준: integration +- 백엔드: sqlite +- 케이스: 정상·경계·실패 synthetic 자료 + +**상태 불변조건** + +기존 REQ-003 권위·승인·호환성 의미를 유지하며 미검증 또는 실패 상태는 완료로 판정하지 않는다. + +### REQ-003.11-AC21 — 당시 계산 근거와 부채 부호 + +**판정 기준** + +거래 원본 ID/날짜/type/금액/통화/분류와 적용 판단·금액, 선택 자산 평가 ID/원본 날짜/출처/적용 방식/포함·제외를 고정한다. 부채 총계는 기존 abs(sum(raw loans))이며 상세 applied_amount 기여도 합이 이에 일치하고 원본을 덮어쓰지 않는다. + +**검증** + +`tests/test_comprehensive_settlement.py::test_asset_detail_preserves_raw_evidence_and_signed_liability_contributions`; `tests/test_comprehensive_settlement.py::test_source_snapshot_preserves_reproducible_values_without_raw_secrets` (실제 실행 기록과 최종 head에 결속하며 문서 기재 자체는 PASS가 아니다.) + +**검증 분류** + +- 수준: integration +- 백엔드: sqlite +- 케이스: 정상·경계·실패 synthetic 자료 + +**상태 불변조건** + +기존 REQ-003 권위·승인·호환성 의미를 유지하며 미검증 또는 실패 상태는 완료로 판정하지 않는다. + +### REQ-003.11-AC22 — 승인 지문의 모든 변경 입력 + +**판정 기준** + +거래 추가·삭제·금액/분류 변경, 정책·집계 버전 변경, 선택 평가자료·포함/제외/미확정 변경을 지문에 결속한다. stale 승인으로는 마감하지 않고 정책 편집은 version 증가와 승인 무효화를 수행한다. + +**검증** + +`tests/test_comprehensive_settlement.py::test_source_policy_and_category_drift_invalidate_approval`; `tests/test_settlement_cashflow_policy.py::test_policy_edit_persists_versions_and_invalidates_approval` (실제 실행 기록과 최종 head에 결속하며 문서 기재 자체는 PASS가 아니다.) + +**검증 분류** + +- 수준: integration +- 백엔드: sqlite +- 케이스: 정상·경계·실패 synthetic 자료 + +**상태 불변조건** + +기존 REQ-003 권위·승인·호환성 의미를 유지하며 미검증 또는 실패 상태는 완료로 판정하지 않는다. + +### REQ-003.11-AC23 — 원자적 저장과 실제 저장 일치 + +**판정 기준** + +income/expense 상세 합, net_cashflow 차, 반영 자산/부채 상세 합, net_worth 차의 여섯 식과 승인 결과를 실제 저장된 헤더·상세·근거와 대조한다. 불일치·중간 실패는 run/양 상세/완료 상태/응답 전체를 롤백한다. + +**검증** + +`tests/test_comprehensive_settlement.py::test_mid_close_failure_rolls_back_all_settlement_state`; `tests/test_comprehensive_settlement.py::test_close_rejects_stored_detail_sum_tampering` (실제 실행 기록과 최종 head에 결속하며 문서 기재 자체는 PASS가 아니다.) + +**검증 분류** + +- 수준: integration +- 백엔드: sqlite +- 케이스: 정상·경계·실패 synthetic 자료 + +**상태 불변조건** + +기존 REQ-003 권위·승인·호환성 의미를 유지하며 미검증 또는 실패 상태는 완료로 판정하지 않는다. + +### REQ-003.11-AC24 — 멱등 재시도와 동시 마감 + +**판정 기준** + +동일 승인 요청·멱등 키는 하나의 run과 동일 저장 응답을 반환한다. 변경된 요청은 충돌하며 동시 요청에서도 중복 run이나 부분 완료가 없다. + +**검증** + +`tests/test_comprehensive_settlement.py::test_concurrent_close_retries_return_one_saved_result`; `tests/test_comprehensive_settlement.py::test_replay_and_correction_keep_each_run_immutable` (실제 실행 기록과 최종 head에 결속하며 문서 기재 자체는 PASS가 아니다.) + +**검증 분류** + +- 수준: integration +- 백엔드: sqlite +- 케이스: 정상·경계·실패 synthetic 자료 + +**상태 불변조건** + +기존 REQ-003 권위·승인·호환성 의미를 유지하며 미검증 또는 실패 상태는 완료로 판정하지 않는다. + +### REQ-003.11-AC25 — 불변 이력과 정정 분리 + +**판정 기준** + +완료 이후 원장 변경으로 기존 헤더·양 상세·근거·저장 응답을 바꾸지 않는다. 최신 parent·단일 child 계약에 따라 정정 run을 append하며 최초와 정정을 exact run으로 각각 조회한다. + +**검증** + +`tests/test_comprehensive_settlement.py::test_completed_get_and_saved_snapshot_ignore_later_live_ledger_changes`; `tests/test_comprehensive_settlement.py::test_replay_and_correction_keep_each_run_immutable` (실제 실행 기록과 최종 head에 결속하며 문서 기재 자체는 PASS가 아니다.) + +**검증 분류** + +- 수준: integration +- 백엔드: sqlite +- 케이스: 정상·경계·실패 synthetic 자료 + +**상태 불변조건** + +기존 REQ-003 권위·승인·호환성 의미를 유지하며 미검증 또는 실패 상태는 완료로 판정하지 않는다. + +### REQ-003.11-AC26 — legacy NULL과 기존 초안 재승인 + +**판정 기준** + +schema14 additive/idempotent 이관은 legacy 미보존 값을 NULL로 두며 현재 원장 소급 backfill이나 fake0을 하지 않는다. 기존 초안과 이력을 유지하지만 자산-only 과거 승인으로 종합 마감할 수 없고 새 검토/승인이 필요하다. + +**검증** + +`tests/test_settlement_cashflow_schema.py::test_legacy_migration_is_idempotent_and_keeps_unstored_totals_null`; `tests/test_settlement_cashflow_schema.py::test_cashflow_detail_constraints_preserve_blank_categories`; `tests/test_comprehensive_settlement.py::test_legacy_asset_approval_requires_comprehensive_review_before_close`; `tests/test_comprehensive_settlement.py::test_legacy_reader_accepts_original_header_without_migration` (실제 실행 기록과 최종 head에 결속하며 문서 기재 자체는 PASS가 아니다.) + +**검증 분류** + +- 수준: integration +- 백엔드: sqlite +- 케이스: 정상·경계·실패 synthetic 자료 + +**상태 불변조건** + +기존 REQ-003 권위·승인·호환성 의미를 유지하며 미검증 또는 실패 상태는 완료로 판정하지 않는다. + +### REQ-003.11-AC27 — 공개 계약·출처 구분·읽기 보안 + +**판정 기준** + +shared service→MCP/gateway→인증된 UI에서 여섯 총계·분류/경고와 저장 run/버전을 표시한다. 기존 자산-only 직접 API와 live 보고는 그대로이며 저장 조회는 readonly/no-create다. ROOT review/finalize, active get.review, completed get.final_response를 유지하고 인증정보/원본 민감 payload를 공개하지 않는다. + +**검증** + +`tests/test_comprehensive_settlement_adapters.py::test_shared_adapters_expose_policy_and_saved_snapshot_contracts`; `tests/test_comprehensive_settlement.py::test_stored_reader_allowlists_source_root_and_nested_decision`; `tests/test_comprehensive_settlement.py::test_stored_reader_keeps_required_transaction_date`; `tests/test_comprehensive_settlement.py::test_saved_reader_rejects_unsupported_comprehensive_versions_without_writes`; `tests/test_comprehensive_settlement.py::test_saved_reader_does_not_mislabel_partial_version_metadata_as_legacy`; `tests/test_comprehensive_settlement_adapters.py::test_real_comprehensive_stdio_policy_close_and_saved_snapshot`; `tests/test_comprehensive_settlement_adapters.py::test_authenticated_dashboard_renders_saved_comprehensive_snapshot` (실제 실행 기록과 최종 head에 결속하며 문서 기재 자체는 PASS가 아니다.) + +**검증 분류** + +- 수준: integration +- 백엔드: sqlite +- 케이스: 정상·경계·실패 synthetic 자료 + +**상태 불변조건** + +기존 REQ-003 권위·승인·호환성 의미를 유지하며 미검증 또는 실패 상태는 완료로 판정하지 않는다. + ## 8. 오류 및 충돌 계약 공통 envelope는 성공 시 `success: true`, 실패 시 `success: false, error_code, error`를 유지한다. 주요 codes는 `validation_error`, `not_found`, `idempotency_conflict`, `correction_conflict`, `ambiguous_valuation`, `conditional_approval_required`, `db_error`다. SQLite unique/transaction conflict는 추가 write 없이 실패한다. @@ -662,9 +862,19 @@ REQ-001의 auth gate·cookie/cleanup/logout, REQ-002의 메뉴 10개와 order(` ## 11. 추적성 -- 근거 이슈: `#12` +- 근거 이슈: `#12`, `#15`, `#18` - 구현 PR: `#13` - Issue #15 구현 PR: https://gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp/pulls/16 - 관련 설계 문서: `docs/data-model.md`, `docs/interface-contract.md`, `docs/project-structure.md`, `docs/runtime-flow.md` - 관련 요구사항: `REQ-001`, `REQ-002` + +### Issue #18 종합 정산 확장 (revision 1.2; 구현 PR 미정) + +기존 요구사항·AC 식별자 및 공통 섹션은 유지하고 REQ-003.11만 확장한다. 월 수입·지출은 활성 거래의 [월초, 다음 월초) 범위, 자산·부채는 기존 월말 기준이다. 초안 정책은 `draft.cashflow_policy`에 저장하며 savings는 사용자가 명시한 category/subcategory pair만 인정한다. 생활비·선택 savings·`debt_payment`은 expense; 일반 transfer와 voucher top-up 제외가 우선하고 `investment` 전체 포함은 금지한다. Refund는 환급월 원래 지출 분류에 signed negative expense로 기록하며 과거 run을 수정하지 않는다. Unresolved unknown은 승인 blocker이며 asset conditional flag로 우회할 수 없고, 확정 제외는 blocker가 아니다. KRW only, no FX. + +Run header는 settlement month/currency, 여섯 총계, policy JSON, aggregation/schema version, 당시 source JSON을 보존한다. `settlement_cashflow_snapshots`는 `id, settlement_run_id, cashflow_kind, category, subcategory, amount, transaction_count, currency` 8개 필드, signed INTEGER amount, positive count, FK RESTRICT, 5-field unique(run, cashflow_kind, category, subcategory, currency), run index를 갖는다. category/subcategory는 NOT NULL이며 빈 문자열은 그대로 구별한다. 기존 자산 상세와 authority/verified·exact/carry/substitute·missing/exclusion 근거를 유지한다. + +동일 run의 income/expense grouped 합이 각 총계와 일치하고 `net_cashflow=income_total-expense_total`; 자산·부채는 포함 상세 합이며 `net_worth=total_assets-total_liabilities`다. Review fingerprint는 거래 당시 값·분류·정책/버전·grouped 결과/여섯 총계·선택 평가자료·포함/제외/미결정 상태를 포함하고 drift 시 finalize를 거부한다. Header, 두 상세 영역, evidence 및 draft completion/final response는 한 transaction이며 오류 시 모두 rollback. Equal idempotent request replays persisted response; changed request conflicts. Correction validates latest single parent and appends run/details; no historical overwrite. Legacy totals stay NULL, incomplete draft needs fresh review/approval. + +Public service additions are `set_settlement_cashflow_policy(draft_id,expected_version,updated_by,cashflow_policy,db_path=None)` and `get_monthly_settlement_snapshot(month='',run_id='',db_path=None)`; MCP omits `db_path`. Review/finalize fields are at response ROOT; active get nests review; completed get exposes ROOT `final_response`. Group key is `cashflow_kind`. Stored run snapshot differs from live report; existing asset-only API and direct-close meaning remain unchanged. Reads are readonly/no-create; Dashboard authenticates before DB. Synthetic tests cover all Issue inclusion cases; no product test result is asserted here. - DB lifecycle: writer/bootstrap은 명시적 `init_db()` 호출로만 생성·마이그레이션하고, `read_db()`는 SQLite `mode=ro`로 연다. missing DB는 `db_not_initialized`이며 read path는 no mkdir, no migrate, no commit이다. first-use write만 init_db를 수행하고 auth-before-DB 순서를 보존한다. diff --git a/docs/runtime-flow.md b/docs/runtime-flow.md index 88c3526..31edd03 100644 --- a/docs/runtime-flow.md +++ b/docs/runtime-flow.md @@ -272,3 +272,8 @@ Draft create/update/finalize use transaction-scoped writes; any DB failure rolls Cancellation reason is stored in `cancellation_reason`, not memo; the cancellation actor and timestamp are persisted, not inferred from a later caller. Approval invalidation prevents reuse of the cancelled draft's approval. Reopening creates new draft/item identity and requires its own review/approval; finalization rechecks the correction parent and preserves prior immutable run/rows. Cancellation itself is not an idempotent finalization replay: retrying cancellation after its successful terminal transition returns conflict. Coverage: `tests/test_monthly_settlement_drafts.py` checks cancellation/reopen, version conflicts and lifecycle persistence; `tests/test_settlement_draft_authority.py` checks cancellation approval invalidation; `tests/test_settlement_draft_correction_contract.py` checks latest-run linkage, reason, immutable history and persisted replay; `tests/test_settlement_draft_docs.py` checks these documented transitions. Existing auth/menu and read-only recovery flows above remain unchanged. +## Issue #18 comprehensive settlement flow + +Explicit policy setter validates expected draft version, saves `draft.cashflow_policy`, increments version, invalidates approval. Read-only review freezes [month start,next month start) active transactions, policy/version and same-month-end selected asset/liability evidence; generates grouped income/expense/excluded details and six totals. Savings only explicit category/subcategory pairs, debt_payment expense; transfer/voucher exclusion priority. Refund applies signed negative expense to original category in refund month. Unresolved unknown returns warning and blocks comprehensive approval regardless of asset conditional flags; confirmed exclusions do not block. KRW only; no FX. Review fingerprint binds source values/classification, policy, version, grouped details/totals and selected valuation evidence; any drift requires review/approval again. + +Finalize under one `BEGIN IMMEDIATE` revalidates version/fingerprint/current sources and six equations, writes run header, asset/liability details, cashflow details/source evidence, completion link and persisted response atomically. Any mismatch/write failure rolls back all. Equal idempotent request replays stored result; changed request conflicts. Correction checks latest parent and single-child rule, appends new run/details, never edits history. Getter is readonly and resolves stored run/month separately from live report; asset direct-close remains asset-only. Dashboard auth precedes DB and missing DB reads never create/migrate. Responses: review/finalize root fields, active get nested review, completed get root `final_response`; groups named `cashflow_kind`. MCP hides `db_path`. diff --git a/jarca_ledger_mcp/adapters/mcp/server.py b/jarca_ledger_mcp/adapters/mcp/server.py index bf010a0..792bc7c 100644 --- a/jarca_ledger_mcp/adapters/mcp/server.py +++ b/jarca_ledger_mcp/adapters/mcp/server.py @@ -484,3 +484,14 @@ def finalize_settlement_draft(draft_id: str, expected_version: int, review_finge @mcp.tool() def cancel_settlement_draft(draft_id: str, expected_version: int, cancelled_by: str, reason: str) -> dict: return contracts.cancel_settlement_draft(draft_id, expected_version, cancelled_by, reason) + + +@mcp.tool() +def set_settlement_cashflow_policy(draft_id: str, expected_version: int, updated_by: str, cashflow_policy: dict) -> dict: + """Set explicit monthly cashflow classification policy for a settlement draft.""" + return contracts.set_settlement_cashflow_policy(draft_id, expected_version, updated_by, cashflow_policy) + +@mcp.tool() +def get_monthly_settlement_snapshot(month: str = "", run_id: str = "") -> dict: + """Read a saved monthly settlement snapshot, optionally selecting an exact run.""" + return contracts.get_monthly_settlement_snapshot(month, run_id) diff --git a/jarca_ledger_mcp/adapters/mcp/tool_contracts.py b/jarca_ledger_mcp/adapters/mcp/tool_contracts.py index f48b89f..0c5a4ad 100644 --- a/jarca_ledger_mcp/adapters/mcp/tool_contracts.py +++ b/jarca_ledger_mcp/adapters/mcp/tool_contracts.py @@ -10,6 +10,8 @@ from ...service import ( refresh_settlement_draft_targets as refresh_settlement_draft_targets, review_settlement_draft as review_settlement_draft, set_settlement_draft_item as set_settlement_draft_item, + set_settlement_cashflow_policy as set_settlement_cashflow_policy, + get_monthly_settlement_snapshot as get_monthly_settlement_snapshot, update_settlement_draft as update_settlement_draft, annotations as annotations, apply_categorization_rules as apply_categorization_rules, @@ -91,6 +93,7 @@ TOOL_CONTRACTS = { "update_settlement_draft", "refresh_settlement_draft_targets", "set_settlement_draft_item", "review_settlement_draft", "approve_settlement_draft", "finalize_settlement_draft", "cancel_settlement_draft", + "set_settlement_cashflow_policy", "get_monthly_settlement_snapshot", ) } @@ -99,6 +102,7 @@ __all__ = [ "finalize_settlement_draft", "get_settlement_draft", "list_settlement_drafts", "refresh_settlement_draft_targets", "review_settlement_draft", "set_settlement_draft_item", "update_settlement_draft", + "set_settlement_cashflow_policy", "get_monthly_settlement_snapshot", "annotations", "apply_categorization_rules", "close_asset_snapshot", diff --git a/jarca_ledger_mcp/adapters/streamlit/settlement_gateway.py b/jarca_ledger_mcp/adapters/streamlit/settlement_gateway.py index 970b8e5..c50e859 100644 --- a/jarca_ledger_mcp/adapters/streamlit/settlement_gateway.py +++ b/jarca_ledger_mcp/adapters/streamlit/settlement_gateway.py @@ -9,15 +9,18 @@ from ...service import ( create_settlement_draft as create_settlement_draft, finalize_settlement_draft as finalize_settlement_draft, get_settlement_draft as get_settlement_draft, + get_monthly_settlement_snapshot as get_monthly_settlement_snapshot, list_account_valuations as list_account_valuations, list_settlement_drafts as list_settlement_drafts, refresh_settlement_draft_targets as refresh_settlement_draft_targets, register_account_valuation as register_account_valuation, review_settlement_draft as review_settlement_draft, set_settlement_draft_item as set_settlement_draft_item, + set_settlement_cashflow_policy as set_settlement_cashflow_policy, update_settlement_draft as update_settlement_draft, ) from ...application import settlement_draft_service as _draft_service +from ...application import monthly_settlement_service as _monthly_service from ... import service as _service @@ -32,7 +35,12 @@ def _guard(db_path: str | None) -> str: def _wrap(fn): def call(*args, db_path=None, **kwargs): - target = _draft_service if fn.__name__ in {"create_settlement_draft", "list_settlement_drafts", "get_settlement_draft", "update_settlement_draft", "refresh_settlement_draft_targets", "set_settlement_draft_item", "review_settlement_draft", "approve_settlement_draft", "finalize_settlement_draft", "cancel_settlement_draft"} else _service + if fn.__name__ == "set_settlement_cashflow_policy": + target = _draft_service + elif fn.__name__ == "get_monthly_settlement_snapshot": + target = _monthly_service + else: + target = _draft_service if fn.__name__ in {"create_settlement_draft", "list_settlement_drafts", "get_settlement_draft", "update_settlement_draft", "refresh_settlement_draft_targets", "set_settlement_draft_item", "review_settlement_draft", "approve_settlement_draft", "finalize_settlement_draft", "cancel_settlement_draft"} else _service return getattr(target, fn.__name__)(*args, db_path=_guard(db_path), **kwargs) call.__name__ = fn.__name__ call.__doc__ = fn.__doc__ @@ -47,6 +55,7 @@ for _name in ( "set_settlement_draft_item", "review_settlement_draft", "approve_settlement_draft", "finalize_settlement_draft", "cancel_settlement_draft", "register_account_valuation", "list_account_valuations", "correct_account_valuation", + "set_settlement_cashflow_policy", "get_monthly_settlement_snapshot", ): globals()[_name] = _wrap(getattr(_service, _name)) @@ -56,4 +65,5 @@ __all__ = [ "set_settlement_draft_item", "review_settlement_draft", "approve_settlement_draft", "finalize_settlement_draft", "cancel_settlement_draft", "register_account_valuation", "list_account_valuations", "correct_account_valuation", + "set_settlement_cashflow_policy", "get_monthly_settlement_snapshot", ] diff --git a/jarca_ledger_mcp/application/monthly_settlement_service.py b/jarca_ledger_mcp/application/monthly_settlement_service.py index d3b624f..ddd8b0f 100644 --- a/jarca_ledger_mcp/application/monthly_settlement_service.py +++ b/jarca_ledger_mcp/application/monthly_settlement_service.py @@ -1,5 +1,9 @@ from __future__ import annotations +import json + +from .shared import read_db, DatabaseNotInitializedError + from .assets_service import get_asset_snapshot_report from .imports_service import list_ledger_import_batches from .reconciliation_service import get_balance_reconciliation_report @@ -130,3 +134,122 @@ def get_monthly_settlement_summary(month: str = "", db_path: str | None = None) }, read_only=True, ) + + +def get_monthly_settlement_snapshot(month: str = "", run_id: str = "", db_path: str | None = None) -> dict[str, Any]: + """Read a persisted monthly settlement close without consulting live ledger state.""" + if not isinstance(month, str) or not isinstance(run_id, str): + return fail("validation_error", "month and run_id must be strings") + try: + selected_month = normalize_month(month) if month else "" + if selected_month and (len(selected_month) != 7 or selected_month[4] != "-"): + raise ValueError("invalid month") + except (TypeError, ValueError): + return fail("validation_error", "invalid settlement month") + + def parsed(value: Any, default: Any): + if value in (None, ""): + return default + return json.loads(value) + + try: + with read_db(db_path) as conn: + tables = {r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} + if "asset_snapshot_runs" not in tables or "asset_snapshots" not in tables: + return fail("not_found", "settlement snapshot not found") + run = None + rows = [] + legacy = False + metadata: dict[str, Any] = {} + if run_id: + run = conn.execute("SELECT rowid AS _run_rowid,* FROM asset_snapshot_runs WHERE id=?", (run_id,)).fetchone() + if run: + from .assets_service import _snapshot_rows_for_run + rows, metadata = _snapshot_rows_for_run(conn, run_id) + else: + cols = {r["name"] for r in conn.execute("PRAGMA table_info(asset_snapshots)")} + if {"snapshot_run_id", "closed_at"} <= cols: + rows = conn.execute("SELECT rowid AS _rowid,* FROM asset_snapshots WHERE snapshot_run_id=? AND closed_at<>'' ORDER BY rowid", (run_id,)).fetchall() + if rows: + legacy = True + metadata = {"snapshot_run_id": run_id, "legacy_closed": True, "close_status": "legacy_read_only"} + else: + if selected_month: + run = conn.execute("SELECT rowid AS _run_rowid,* FROM asset_snapshot_runs WHERE basis_date=? ORDER BY rowid DESC LIMIT 1", (_month_end_date(selected_month),)).fetchone() + if run is None: + basis = _month_end_date(selected_month) if selected_month else "" + if basis: + from .assets_service import _snapshot_rows_for_latest_run + rows, metadata = _snapshot_rows_for_latest_run(conn, basis) + legacy = bool(metadata.get("legacy_closed")) + if legacy: + rows = [r for r in rows if r["snapshot_run_id"] == metadata.get("snapshot_run_id") and r["closed_at"]] + elif not metadata.get("snapshot_run_id"): + rows = [] + if metadata.get("snapshot_run_id"): + run = conn.execute("SELECT rowid AS _run_rowid,* FROM asset_snapshot_runs WHERE id=?", (metadata["snapshot_run_id"],)).fetchone() + if not run and not rows: + return fail("not_found", "settlement snapshot not found") + if run: + rid = run["id"] + basis_date = run["basis_date"] + run_month = run["settlement_month"] if "settlement_month" in run.keys() else None + if selected_month and ((run_month and run_month != selected_month) or (not run_month and basis_date[:7] != selected_month)): + return fail("validation_error", "run does not match settlement month") + if not metadata: + from .assets_service import _snapshot_rows_for_run + rows, metadata = _snapshot_rows_for_run(conn, rid) + else: + rid = metadata.get("snapshot_run_id", run_id) + basis_date = rows[0]["basis_date"] + if selected_month and basis_date[:7] != selected_month: + return fail("validation_error", "run does not match settlement month") + legacy = True + keys = set(run.keys()) if run else set() + def field(name): return run[name] if run and name in keys else None + from .settlement_cashflow_service import _AGGREGATION_VERSION + if (field("snapshot_schema_version") is not None or field("aggregation_version") is not None) and ( + field("snapshot_schema_version") != 1 or field("aggregation_version") != _AGGREGATION_VERSION + ): + raise ValueError("unsupported comprehensive snapshot version") + comprehensive = bool(field("snapshot_schema_version") == 1 and not legacy) + legacy = not comprehensive + cashflows = [] + if comprehensive and "settlement_cashflow_snapshots" in tables: + cashflows = [dict(r) for r in conn.execute("SELECT cashflow_kind,category,subcategory,amount,transaction_count,currency FROM settlement_cashflow_snapshots WHERE settlement_run_id=? ORDER BY cashflow_kind,category,subcategory,currency", (rid,))] + totals = {"income_total": field("income_total") if comprehensive else None, "expense_total": field("expense_total") if comprehensive else None, "net_cashflow": field("net_cashflow") if comprehensive else None, "total_assets": field("total_assets") if comprehensive else None, "total_liabilities": field("total_liabilities") if comprehensive else None, "net_worth": field("net_worth") if comprehensive else None} + safe_items = [] + for row in rows: + d = dict(row) + safe_items.append({k: d.get(k) for k in ("id","source_type","source_id","name","amount","currency","liquidity_bucket","valuation_source","snapshot_run_id","correction_of_run_id","correction_reason","is_correction","closed_at","valuation_id","selection_policy","verification_status","evidence_kind","exclusion_reason","created_at") if k in d}) + source = parsed(field("source_snapshot_json"), {}) if comprehensive else {} + if not isinstance(source, dict): raise ValueError() + if comprehensive: + tx_fields = ("id", "transaction_date", "type", "amount", "currency", "category", "subcategory", "original_cashflow_kind", "originalcashflow_kind", "cashflow_kind", "applied_amount", "sourceprovenance", "category_cashflow_kind", "category_active") + asset_fields = ("id", "source_id", "source_type", "valuation_id", "valuation_date", "basis_date", "amount", "currency", "liquidity_bucket", "selection_mode", "applied_amount", "authority_source", "is_liability", "status", "verified", "verification_status", "evidence_kind", "exclusion_reason", "state_hash") + def project(records, fields): + if not isinstance(records, list) or any(not isinstance(x, dict) for x in records): + raise ValueError() + result = [] + for record in records: + item = {k: record[k] for k in fields if k in record} + if any(value is not None and not isinstance(value, (str, int, bool)) for value in item.values()): + raise ValueError() + for key, allowed in (("decision", ("category", "subcategory", "cashflow_kind", "reason")), ("source_classification", ("id", "category", "subcategory", "cashflow_kind", "active"))): + value = record.get(key) + if isinstance(value, dict): + item[key] = {k: value[k] for k in allowed if k in value and (value[k] is None or isinstance(value[k], (str, int, bool)))} + result.append(item) + return result + source = {"transactions": project(source.get("transactions", []), tx_fields), "assets": project(source.get("assets", []), asset_fields)} + policy = parsed(field("cashflow_policy_json"), {}) if comprehensive else None + if comprehensive: + from .settlement_cashflow_service import _normalize_cashflow_policy + if not isinstance(policy, dict): + raise ValueError() + 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", "")}) + except DatabaseNotInitializedError: + return fail("db_not_initialized", "ledger database is not initialized") + except Exception: + return fail("db_error", "settlement snapshot read failed") diff --git a/jarca_ledger_mcp/application/settlement_cashflow_service.py b/jarca_ledger_mcp/application/settlement_cashflow_service.py new file mode 100644 index 0000000..313dff2 --- /dev/null +++ b/jarca_ledger_mcp/application/settlement_cashflow_service.py @@ -0,0 +1,126 @@ +"""Private, deterministic aggregation for comprehensive settlement previews.""" +from __future__ import annotations + +from datetime import date + +from .shared import CASHFLOW_KIND_SQL + +_AGGREGATION_VERSION = "comprehensive-v1" +_SNAPSHOT_SCHEMA_VERSION = 1 +_ALLOWED_KINDS = {"income", "expense", "excluded"} + + +def _normalize_cashflow_policy(payload: dict) -> dict: + if not isinstance(payload, dict) or set(payload) - {"savings_categories", "category_decisions"}: + raise ValueError("cashflow policy must contain only supported keys") + savings = payload.get("savings_categories", []) + decisions = payload.get("category_decisions", []) + if not isinstance(savings, list) or not isinstance(decisions, list): + raise ValueError("policy categories and decisions must be lists") + def pair(row, required): + if not isinstance(row, dict) or set(row) != required: + raise ValueError("invalid category policy entry") + if any(not isinstance(row[k], str) for k in ("category", "subcategory")): + raise ValueError("category fields must be strings") + return row["category"], row["subcategory"] + normalized_savings = [pair(x, {"category", "subcategory"}) for x in savings] + if len(set(normalized_savings)) != len(normalized_savings): + raise ValueError("duplicate savings category") + normalized_decisions = [] + for row in decisions: + cat, sub = pair(row, {"category", "subcategory", "cashflow_kind", "reason"}) + kind, reason = row["cashflow_kind"], row["reason"] + if not isinstance(kind, str) or kind not in _ALLOWED_KINDS: + raise ValueError("invalid cashflow decision kind") + if not isinstance(reason, str) or not reason.strip(): + raise ValueError("decision reason must be nonempty") + normalized_decisions.append((cat, sub, kind, reason)) + keys = [(x[0], x[1]) for x in normalized_decisions] + if len(set(keys)) != len(keys): + raise ValueError("duplicate category decision") + return {"savings_categories": [{"category": c, "subcategory": s} for c, s in sorted(normalized_savings)], + "category_decisions": [{"category": c, "subcategory": s, "cashflow_kind": k, "reason": r} + for c, s, k, r in sorted(normalized_decisions)]} + + +def _review_cashflow(conn, month: str, policy: dict) -> dict: + policy = _normalize_cashflow_policy(policy) + start = date.fromisoformat(month + "-01") + end = date(start.year + (start.month == 12), 1 if start.month == 12 else start.month + 1, 1) + rows = conn.execute(f"""SELECT t.id,t.transaction_date,t.type,t.amount,t.currency,t.category,t.subcategory, + {CASHFLOW_KIND_SQL} AS originalcashflow_kind, lc.cashflow_kind AS category_cashflow_kind, + lc.active AS category_active + FROM transactions t LEFT JOIN ledger_categories lc + ON lc.category=t.category AND lc.subcategory=t.subcategory + WHERE t.status='active' AND t.transaction_date>=? AND t.transaction_date 0 else 0 + asset_snapshot = [] + for x in items: + raw = x.get('candidate_amount') + applied_amount = raw * loan_sign if x.get('amount') is not None and x.get('is_liability') else x.get('amount') + asset_snapshot.append({k: x.get(k) for k in ('source_id','source_type','valuation_id','valuation_date','basis_date','currency','authority_source','liquidity_bucket','is_liability','selection_mode','exclusion_reason','status','verification_status')} | {'amount': raw, 'applied_amount': applied_amount}) + cf['source_snapshot']['assets'] = asset_snapshot + totals.update({k: cf[k] for k in ('income_total','expense_total','net_cashflow')}) + full_totals = {**totals} + serial = json.dumps({'draft_id': draft['id'], 'version': draft['version'], 'basis_date': draft['basis_date'], 'states': states, 'warnings': warnings, 'latest_run': latest, 'correction_parent': draft['correction_of_run_id'], 'totals': full_totals, 'cashflow': cf}, sort_keys=True, ensure_ascii=False) return {'draft_id': draft['id'], 'version': draft['version'], 'review_fingerprint': hashlib.sha256(serial.encode()).hexdigest(), + 'cashflow_policy': policy, 'aggregation_version': cf['aggregation_version'], 'snapshot_schema_version': cf['snapshot_schema_version'], 'cashflow_items': cf['cashflow_items'], 'cashflow_blockers': cf['cashflow_blockers'], 'cashflow_warnings': cf['cashflow_warnings'], 'source_snapshot': cf['source_snapshot'], 'items': items, 'included_items': included, 'excluded_items': excluded, 'missing_items': missing, 'warnings': warnings, - 'approval_required': True, 'totals': totals, **totals, 'complete': bool(items) and not missing, + 'approval_required': True, 'totals': totals, **totals, + 'complete': bool(items) and not missing and not cf['cashflow_blockers'], 'unregistered_liabilities': not any(x['is_liability'] for x in items), 'contains_substitutions': any(x['selection_mode'] == 'manual_substitute' for x in included), 'contains_carried': any(x['selection_mode'] == 'carried_before' for x in included), @@ -143,11 +160,15 @@ def _review(conn, draft): def _result(conn,did): d=_draft(conn,did) if not d:return _err('not_found','settlement draft not found') + draft_out = {k: v for k, v in d.items() if k != 'final_response'} + if draft_out.get('cashflow_policy_json'): + from .settlement_cashflow_service import _normalize_cashflow_policy + draft_out['cashflow_policy'] = _normalize_cashflow_policy(json.loads(draft_out['cashflow_policy_json'])) if d['status'] == 'completed' and d['final_response']: final = json.loads(d['final_response']) - return ok(draft={k: v for k, v in d.items() if k != 'final_response'}, items=final.get('items', []) + final.get('excluded_items', []), final_response=final) + return ok(draft=draft_out, items=final.get('items', []) + final.get('excluded_items', []), final_response=final) review = _review(conn, d) - return ok(draft={k: v for k, v in d.items() if k != 'final_response'}, items=review['items'], review=review) + return ok(draft=draft_out, items=review['items'], review=review) def create_settlement_draft(month: str, created_by: str, idempotency_key: str, basis_date: str = '', memo: str = '', correction_of_run_id: str = '', correction_reason: str = '', db_path: str|None=None): @@ -189,7 +210,12 @@ def list_settlement_drafts(month: str='',db_path: str|None=None): try: with read_db(db_path) as c: rows=c.execute('SELECT * FROM settlement_drafts'+(' WHERE month=?' if month else '')+' ORDER BY created_at DESC',(month,) if month else ()).fetchall() - return ok(drafts=[dict(x) for x in rows]) + drafts = [dict(x) for x in rows] + for item in drafts: + if item.get('cashflow_policy_json'): + from .settlement_cashflow_service import _normalize_cashflow_policy + item['cashflow_policy'] = _normalize_cashflow_policy(json.loads(item['cashflow_policy_json'])) + return ok(drafts=drafts) except Exception as e:return _err('db_not_initialized' if 'not initialized' in str(e).lower() else 'db_error','ledger database is not initialized' if 'not initialized' in str(e).lower() else 'database error') @@ -211,6 +237,19 @@ def _mutate(did, version, actor, sql, params, db_path): except Exception as e:return _err('db_not_initialized' if 'not initialized' in str(e).lower() else 'db_error','ledger database is not initialized' if 'not initialized' in str(e).lower() else 'database error') +def set_settlement_cashflow_policy(draft_id: str, expected_version: int, updated_by: str, cashflow_policy: dict, db_path: str|None=None): + from .settlement_cashflow_service import _normalize_cashflow_policy + if not _valid_version(expected_version) or not _nonempty(updated_by) or not isinstance(draft_id, str) or not draft_id: + return _err('validation_error', 'valid version, draft and actor required') + try: + policy = _normalize_cashflow_policy(cashflow_policy) + encoded = json.dumps(policy, sort_keys=True, separators=(',', ':'), ensure_ascii=False) + except (ValueError, TypeError): + return _err('validation_error', 'invalid cashflow policy') + return _mutate(draft_id, expected_version, updated_by, + "UPDATE settlement_drafts SET cashflow_policy_json=?,aggregation_version='comprehensive-v1' WHERE id=?", (encoded,), db_path) + + def update_settlement_draft(draft_id: str,expected_version: int,updated_by: str,memo: str,db_path: str|None=None): if not isinstance(memo, str): return _err('validation_error', 'memo must be a string') return _mutate(draft_id,expected_version,updated_by,'UPDATE settlement_drafts SET memo=? WHERE id=?',(memo,),db_path) @@ -312,6 +351,9 @@ def review_settlement_draft(draft_id: str,db_path: str|None=None): with read_db(db_path) as c: d=_draft(c,draft_id) if not d:return _err('not_found','draft not found') + if d['status'] == 'completed' and d['final_response']: + saved = json.loads(d['final_response']) + return {**saved, 'version': d['version']} return ok(**_review(c,d)) except Exception as e:return _err('db_not_initialized' if 'not initialized' in str(e).lower() else 'db_error','ledger database is not initialized' if 'not initialized' in str(e).lower() else 'database error') @@ -326,6 +368,7 @@ def approve_settlement_draft(draft_id: str,expected_version: int,review_fingerpr r=_review(c,d) if r['review_fingerprint']!=review_fingerprint:return _err('conflict','review fingerprint changed') if any(x['included'] and x.get('review_needed') for x in r['items']): return _err('conflict', 'changed evidence requires explicit selection or exclusion') + if r['cashflow_blockers']: return _err('validation_error', 'cashflow review blockers must be resolved') if r['has_approved_missing'] and not allow_missing_exclusions:return _err('validation_error','missing/excluded items require approval') if r['contains_carried'] and not allow_carried_before:return _err('validation_error','carried values require approval') if r['contains_substitutions'] and not allow_manual_substitute:return _err('validation_error','substitutes require approval') @@ -352,6 +395,7 @@ def finalize_settlement_draft(draft_id: str,expected_version: int,review_fingerp r=_review(c,d) if not r['items']: return _err('validation_error', 'an empty draft cannot create a close run') if any(x['included'] and x.get('review_needed') for x in r['items']): return _err('conflict', 'changed evidence requires explicit selection or exclusion') + if r['cashflow_blockers']: return _err('validation_error', 'cashflow review blockers must be resolved') if _latest_close(c, d['basis_date']) != d['correction_of_run_id']: return _err('conflict', 'correction parent changed') if r['review_fingerprint']!=review_fingerprint or d['approval_fingerprint']!=review_fingerprint or d['approval_version']!=expected_version:return _err('conflict','review/approval stale') flags=json.loads(d['approval_flags'] or '{}') @@ -366,12 +410,39 @@ def finalize_settlement_draft(draft_id: str,expected_version: int,review_fingerp excluded = [{**x, 'draft_id': draft_id, 'draft_version': expected_version, 'review_fingerprint': review_fingerprint, 'approval': approval, 'reason': 'approved_missing', 'exclusion_reason': x.get('exclusion_reason') or x.get('reason', 'missing_evidence')} for x in r['excluded_items'] + r['missing_items']] carried = [x for x in applied if x['selection_mode'] == 'carried_before'] run=uuid4().hex; ts=now_iso(); basis=d['basis_date']; status='conditional' if excluded or r['contains_substitutions'] or r['contains_carried'] else 'complete_exact' - c.execute('INSERT INTO asset_snapshot_runs(id,basis_date,close_status,selection_policy,idempotency_key,approved_by,approved_at,approval_reason,approval_reference,correction_of_run_id,correction_reason,applied_evidence,excluded_evidence,carried_evidence,request_fingerprint,created_at,draft_id,draft_version,review_fingerprint) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',(run,basis,status,'exact',idempotency_key,d['approved_by'],d['approved_at'],d['approval_reason'],d['approval_reference'],d['correction_of_run_id'],d['correction_reason'],json.dumps(applied,sort_keys=True),json.dumps(excluded,sort_keys=True),json.dumps(carried,sort_keys=True),req,ts,draft_id,expected_version,review_fingerprint)) + c.execute('INSERT INTO asset_snapshot_runs(id,basis_date,close_status,selection_policy,idempotency_key,approved_by,approved_at,approval_reason,approval_reference,correction_of_run_id,correction_reason,applied_evidence,excluded_evidence,carried_evidence,request_fingerprint,created_at,draft_id,draft_version,review_fingerprint,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) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',(run,basis,status,'exact',idempotency_key,d['approved_by'],d['approved_at'],d['approval_reason'],d['approval_reference'],d['correction_of_run_id'],d['correction_reason'],json.dumps(applied,sort_keys=True),json.dumps(excluded,sort_keys=True),json.dumps(carried,sort_keys=True),req,ts,draft_id,expected_version,review_fingerprint,d['month'],'KRW',r['income_total'],r['expense_total'],r['net_cashflow'],r['total_assets'],r['total_liabilities'],r['net_worth'],json.dumps(r['cashflow_policy'],sort_keys=True),r['aggregation_version'],r['snapshot_schema_version'],json.dumps(r['source_snapshot'],sort_keys=True))) snapshot_ids = [] for x in applied + excluded: sid = uuid4().hex snapshot_ids.append(sid) c.execute('INSERT INTO asset_snapshots(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,valuation_source,source_note,memo,snapshot_run_id,correction_of_run_id,correction_reason,is_correction,closed_at,created_at,valuation_id,selection_policy,verification_status,evidence_kind,exclusion_reason) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',(sid,basis,x['source_type'],x['source_id'],x.get('name',''),x['amount'],x['currency'],x['liquidity_bucket'],x['selection_mode'],'','',run,d['correction_of_run_id'],d['correction_reason'],int(bool(d['correction_of_run_id'])),ts,ts,x.get('valuation_id') or '',x['selection_mode'],x.get('verification_status','unverified'),'excluded' if x in excluded else 'applied',x.get('exclusion_reason',''))) + for item in r['cashflow_items']: + c.execute('INSERT INTO settlement_cashflow_snapshots(id,settlement_run_id,cashflow_kind,category,subcategory,amount,transaction_count,currency) VALUES(?,?,?,?,?,?,?,?)', (uuid4().hex,run,item['cashflow_kind'],item['category'],item['subcategory'],item['amount'],item['transaction_count'],item['currency'])) + saved_groups = [dict(x) for x in c.execute('SELECT cashflow_kind,category,subcategory,amount,transaction_count,currency FROM settlement_cashflow_snapshots WHERE settlement_run_id=? ORDER BY cashflow_kind,category,subcategory,currency', (run,))] + expected_groups = sorted([{'cashflow_kind':x['cashflow_kind'],'category':x['category'],'subcategory':x['subcategory'],'amount':x['amount'],'transaction_count':x['transaction_count'],'currency':x['currency']} for x in r['cashflow_items']], key=lambda x:(x['cashflow_kind'],x['category'],x['subcategory'],x['currency'])) + if saved_groups != expected_groups: raise ValueError('persisted cashflow detail mismatch') + header = c.execute('SELECT * FROM asset_snapshot_runs WHERE id=?', (run,)).fetchone() + expected_header = {k:r[k] for k in ('income_total','expense_total','net_cashflow','total_assets','total_liabilities','net_worth','aggregation_version','snapshot_schema_version')} + expected_header.update(settlement_month=d['month'],currency='KRW',basis_date=basis,draft_id=draft_id,draft_version=expected_version,review_fingerprint=review_fingerprint,request_fingerprint=req,close_status=status,idempotency_key=idempotency_key,correction_of_run_id=d['correction_of_run_id'],correction_reason=d['correction_reason']) + if not header or any(header[k] != v for k,v in expected_header.items()): + raise ValueError('persisted header mismatch') + for key,value in (('source_snapshot_json',r['source_snapshot']),('cashflow_policy_json',r['cashflow_policy']),('applied_evidence',applied),('excluded_evidence',excluded),('carried_evidence',carried)): + if json.loads(header[key]) != value: + raise ValueError('persisted evidence mismatch') + persisted_assets = [dict(x) for x in c.execute('SELECT * FROM asset_snapshots WHERE snapshot_run_id=? ORDER BY id', (run,))] + fields = ('source_type','source_id','amount','currency','liquidity_bucket') + expected_assets = sorted(zip(snapshot_ids, applied+excluded), key=lambda pair:pair[0]) + if len(persisted_assets) != len(expected_assets): + raise ValueError('persisted asset count mismatch') + for row,(sid,item) in zip(persisted_assets,expected_assets): + if row['id'] != sid or any(row[k] != item[k] for k in fields) or row['basis_date'] != basis or row['valuation_id'] != (item.get('valuation_id') or '') or row['selection_policy'] != item['selection_mode'] or row['verification_status'] != item.get('verification_status','unverified') or row['evidence_kind'] != ('excluded' if item in excluded else 'applied') or row['exclusion_reason'] != item.get('exclusion_reason',''): + raise ValueError('persisted asset detail mismatch') + if sum(x['amount'] for x in saved_groups if x['cashflow_kind']=='income') != r['income_total'] or sum(x['amount'] for x in saved_groups if x['cashflow_kind']=='expense') != r['expense_total'] or r['net_cashflow'] != r['income_total']-r['expense_total']: + raise ValueError('cashflow totals mismatch') + asset_check = _summarize_valuation_items([x for x in persisted_assets if x['evidence_kind']=='applied']) + if (asset_check['total_assets'] != r['total_assets'] or asset_check['total_liabilities'] != r['total_liabilities'] + or r['net_worth'] != r['total_assets']-r['total_liabilities']): + raise ValueError('asset totals mismatch') evidence = _evidence_provenance(applied, basis) response = _ok_with_provenance_aliases( draft_id=draft_id, draft_version=expected_version, review_fingerprint=review_fingerprint, @@ -379,11 +450,14 @@ def finalize_settlement_draft(draft_id: str,expected_version: int,review_fingerp source='snapshot', is_closed_snapshot=True, selection_policy='exact', inserted_count=len(snapshot_ids), inserted_snapshot_ids=snapshot_ids, items=applied, excluded_items=excluded, totals=r['totals'], + income_total=r['income_total'], expense_total=r['expense_total'], net_cashflow=r['net_cashflow'], + total_assets=r['total_assets'], total_liabilities=r['total_liabilities'], net_worth=r['net_worth'], + cashflow_policy=r['cashflow_policy'], aggregation_version=r['aggregation_version'], snapshot_schema_version=r['snapshot_schema_version'], cashflow_items=r['cashflow_items'], cashflow_blockers=r['cashflow_blockers'], source_snapshot=r['source_snapshot'], contains_substitutions=r['contains_substitutions'], contains_carried=r['contains_carried'], has_approved_missing=bool(excluded), settlement_label='대체 평가자료를 포함한 월정산' if r['contains_substitutions'] else '조건부 월정산' if status == 'conditional' else '정확한 월말 평가 월정산', warnings=r['warnings'], carried_items=carried, approval=approval, correction=correction, **correction, provenance=_canonical_provenance(valuation_date=evidence['valuation_date'], age_days=evidence['age_days'], carried=bool(carried), verification_status=evidence['verification_status'], evidence_kind=evidence['evidence_kind'], basis_date=basis, snapshot_run_id=run, close_status=status, selection_policy='exact', warnings=r['warnings'], exclusions=excluded, approval=approval, correction=correction), - **{k: v for k, v in _summarize_valuation_items(applied, excluded).items() if k != 'excluded_items'}, + **{k: v for k, v in _summarize_valuation_items(applied, excluded).items() if k not in ('excluded_items','total_assets','total_liabilities','net_worth')}, ) c.execute("UPDATE settlement_drafts SET status='completed',version=version+1,final_run_id=?,final_response=?,updated_at=? WHERE id=?",(run,json.dumps(response),ts,draft_id)) return response diff --git a/jarca_ledger_mcp/infrastructure/sqlite/migrations.py b/jarca_ledger_mcp/infrastructure/sqlite/migrations.py index 0207292..733306d 100644 --- a/jarca_ledger_mcp/infrastructure/sqlite/migrations.py +++ b/jarca_ledger_mcp/infrastructure/sqlite/migrations.py @@ -33,7 +33,7 @@ def migrate_db(conn: sqlite3.Connection) -> None: )""") conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_settlement_drafts_active_month ON settlement_drafts(month) WHERE status='active'") conn.execute("CREATE INDEX IF NOT EXISTS idx_settlement_drafts_month ON settlement_drafts(month,created_at)") - _add_missing_columns(conn, "settlement_drafts", {"original_request_hash": "TEXT NOT NULL DEFAULT ''"}) + _add_missing_columns(conn, "settlement_drafts", {"original_request_hash": "TEXT NOT NULL DEFAULT ''", "cashflow_policy_json": "TEXT NOT NULL DEFAULT '{}'", "aggregation_version": "TEXT NOT NULL DEFAULT 'comprehensive-v1'"}) conn.execute("""CREATE TABLE IF NOT EXISTS settlement_draft_items ( id TEXT PRIMARY KEY, draft_id TEXT NOT NULL REFERENCES settlement_drafts(id), source_type TEXT NOT NULL, source_id TEXT NOT NULL, valuation_id TEXT NOT NULL DEFAULT '', @@ -142,6 +142,32 @@ def migrate_db(conn: sqlite3.Connection) -> None: carried_evidence TEXT NOT NULL DEFAULT '[]', request_fingerprint TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL )""") _add_missing_columns(conn, "asset_snapshot_runs", {"request_fingerprint": "TEXT NOT NULL DEFAULT ''", "draft_id": "TEXT NOT NULL DEFAULT ''", "draft_version": "INTEGER NOT NULL DEFAULT 0", "review_fingerprint": "TEXT NOT NULL DEFAULT ''"}) + _add_missing_columns(conn, "asset_snapshot_runs", { + "settlement_month": "TEXT", + "currency": "TEXT", + "income_total": "INTEGER", + "expense_total": "INTEGER", + "net_cashflow": "INTEGER", + "total_assets": "INTEGER", + "total_liabilities": "INTEGER", + "net_worth": "INTEGER", + "cashflow_policy_json": "TEXT", + "aggregation_version": "TEXT", + "snapshot_schema_version": "INTEGER", + "source_snapshot_json": "TEXT", + }) + conn.execute("""CREATE TABLE IF NOT EXISTS settlement_cashflow_snapshots ( + id TEXT PRIMARY KEY, + settlement_run_id TEXT NOT NULL REFERENCES asset_snapshot_runs(id) ON DELETE RESTRICT, + cashflow_kind TEXT NOT NULL CHECK (cashflow_kind IN ('income', 'expense', 'excluded')), + category TEXT NOT NULL DEFAULT '', + subcategory TEXT NOT NULL DEFAULT '', + amount INTEGER NOT NULL CHECK (typeof(amount) = 'integer'), + transaction_count INTEGER NOT NULL CHECK (typeof(transaction_count) = 'integer' AND transaction_count > 0), + currency TEXT NOT NULL, + UNIQUE (settlement_run_id, cashflow_kind, category, subcategory, currency) + )""") + conn.execute("CREATE INDEX IF NOT EXISTS idx_settlement_cashflow_snapshots_run ON settlement_cashflow_snapshots(settlement_run_id)") conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_idempotency ON asset_snapshot_runs(idempotency_key) WHERE idempotency_key <> ''") conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_initial_basis ON asset_snapshot_runs(basis_date) WHERE correction_of_run_id = ''") conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_correction_parent ON asset_snapshot_runs(correction_of_run_id) WHERE correction_of_run_id <> ''") diff --git a/jarca_ledger_mcp/infrastructure/sqlite/schema.py b/jarca_ledger_mcp/infrastructure/sqlite/schema.py index 570b651..617ee68 100644 --- a/jarca_ledger_mcp/infrastructure/sqlite/schema.py +++ b/jarca_ledger_mcp/infrastructure/sqlite/schema.py @@ -1,6 +1,6 @@ from __future__ import annotations -SCHEMA_VERSION = "13" +SCHEMA_VERSION = "14" SCHEMA_SQL = """ PRAGMA journal_mode=WAL; @@ -190,12 +190,37 @@ CREATE TABLE IF NOT EXISTS asset_snapshot_runs ( excluded_evidence TEXT NOT NULL DEFAULT '[]', carried_evidence TEXT NOT NULL DEFAULT '[]', request_fingerprint TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL + created_at TEXT NOT NULL, + settlement_month TEXT, + currency TEXT, + income_total INTEGER, + expense_total INTEGER, + net_cashflow INTEGER, + total_assets INTEGER, + total_liabilities INTEGER, + net_worth INTEGER, + cashflow_policy_json TEXT, + aggregation_version TEXT, + snapshot_schema_version INTEGER, + source_snapshot_json TEXT ); CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_idempotency ON asset_snapshot_runs(idempotency_key) WHERE idempotency_key <> ''; CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_initial_basis ON asset_snapshot_runs(basis_date) WHERE correction_of_run_id = ''; CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_correction_parent ON asset_snapshot_runs(correction_of_run_id) WHERE correction_of_run_id <> ''; +CREATE TABLE IF NOT EXISTS settlement_cashflow_snapshots ( + id TEXT PRIMARY KEY, + settlement_run_id TEXT NOT NULL REFERENCES asset_snapshot_runs(id) ON DELETE RESTRICT, + cashflow_kind TEXT NOT NULL CHECK (cashflow_kind IN ('income', 'expense', 'excluded')), + category TEXT NOT NULL DEFAULT '', + subcategory TEXT NOT NULL DEFAULT '', + amount INTEGER NOT NULL CHECK (typeof(amount) = 'integer'), + transaction_count INTEGER NOT NULL CHECK (typeof(transaction_count) = 'integer' AND transaction_count > 0), + currency TEXT NOT NULL, + UNIQUE (settlement_run_id, cashflow_kind, category, subcategory, currency) +); +CREATE INDEX IF NOT EXISTS idx_settlement_cashflow_snapshots_run ON settlement_cashflow_snapshots(settlement_run_id); + CREATE TABLE IF NOT EXISTS review_decisions ( id TEXT PRIMARY KEY, target_type TEXT NOT NULL, @@ -266,7 +291,7 @@ CREATE INDEX IF NOT EXISTS idx_account_valuations_status_date ON account_valuati CREATE UNIQUE INDEX IF NOT EXISTS idx_account_valuations_idempotency ON account_valuations(account_id, idempotency_key) WHERE idempotency_key <> ''; CREATE UNIQUE INDEX IF NOT EXISTS idx_account_valuations_single_correction ON account_valuations(supersedes_id) WHERE supersedes_id IS NOT NULL; -INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', '12'); +INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', '14'); """ ACCOUNT_ADDITIVE_COLUMNS: dict[str, str] = { diff --git a/tests/test_account_valuations.py b/tests/test_account_valuations.py index dd165e4..ee33472 100644 --- a/tests/test_account_valuations.py +++ b/tests/test_account_valuations.py @@ -17,7 +17,7 @@ def test_account_valuation_schema_is_v12_and_idempotent(tmp_path): with sqlite3.connect(path) as conn: cols = {row[1] for row in conn.execute("PRAGMA table_info(account_valuations)")} version = conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] - assert version == "13" + assert version == "14" assert {"id", "account_id", "basis_date", "amount", "currency", "valuation_kind", "source_type", "source_ref", "verification_status", "recorded_at", "memo", "idempotency_key", "supersedes_id", "correction_reason", "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"} <= cols diff --git a/tests/test_comprehensive_settlement.py b/tests/test_comprehensive_settlement.py new file mode 100644 index 0000000..c4be1fc --- /dev/null +++ b/tests/test_comprehensive_settlement.py @@ -0,0 +1,478 @@ +"""Issue 18 comprehensive settlement acceptance contracts (synthetic SQLite only). + +Public APIs are resolved at call time so the pre-implementation suite collects and +fails with an actionable assertion rather than an import/attribute error. +""" +import hashlib +import json +import sqlite3 +from concurrent.futures import ThreadPoolExecutor + +import pytest + +from jarca_ledger_mcp import service +from jarca_ledger_mcp.db import init_db + + +def api(name): + fn = getattr(service, name, None) + assert callable(fn), f"required comprehensive settlement API missing: service.{name}" + return fn + + +def dbfile(tmp_path): + path = str(tmp_path / "comprehensive-synthetic.sqlite3") + init_db(path) + return path + + +def ok(value): + assert value.get("success") is True, value + return value + + +def new_draft(path, month="2026-09", key="draft"): + created = service.create_manual_asset("Synthetic cash", 100, "2026-09-30", "cash", db_path=path) + assert created.get("success") is True, created + return ok(api("create_settlement_draft")(month, "synthetic-operator", key, db_path=path))["draft"] + + +def close_draft(path, draft, idem="close"): + review_result = ok(api("review_settlement_draft")(draft["id"], db_path=path)) + review = review_result + approved = api("approve_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "synthetic-reviewer", "synthetic approval", "ref-synthetic", db_path=path) + ok(approved) + fresh = ok(api("get_settlement_draft")(draft["id"], db_path=path))["draft"] + return api("finalize_settlement_draft")(draft["id"], fresh["version"], review["review_fingerprint"], idem, db_path=path) + + +def test_draft_previews_six_totals_without_creating_run(tmp_path): + path = dbfile(tmp_path) + draft = new_draft(path) + result = ok(api("review_settlement_draft")(draft["id"], db_path=path)) + review = result + required = {"income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth"} + assert required <= review.keys() and required <= review["totals"].keys() + assert review["aggregation_version"] == "comprehensive-v1" + assert review["snapshot_schema_version"] == 1 + assert isinstance(review["cashflow_items"], list) + with sqlite3.connect(path) as db: + assert db.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0 + assert db.execute("SELECT COUNT(*) FROM settlement_drafts WHERE id=?", (draft["id"],)).fetchone()[0] == 1 + + +def test_closed_header_persists_six_totals_policy_and_versions(tmp_path): + path = dbfile(tmp_path) + draft = new_draft(path) + result = ok(close_draft(path, draft)) + assert result.get("run_id") + saved = result + for key in ("income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth"): + assert key in saved + assert saved["aggregation_version"] == "comprehensive-v1" + assert saved["snapshot_schema_version"] == 1 + with sqlite3.connect(path) as db: + columns = {row[1] for row in db.execute("PRAGMA table_info(asset_snapshot_runs)")} + assert {"income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth", "cashflow_policy_json", "aggregation_version", "snapshot_schema_version", "source_snapshot_json"} <= columns + row = db.execute("SELECT income_total,expense_total,net_cashflow,total_assets,total_liabilities,net_worth FROM asset_snapshot_runs WHERE id=?", (saved["run_id"],)).fetchone() + assert len(row) == 6 + + +def test_asset_detail_preserves_raw_evidence_and_signed_liability_contributions(tmp_path): + path = dbfile(tmp_path) + a = service.create_ledger_account("Synthetic debt A", type="loan", liquidity_bucket="loan", db_path=path)["account"]["id"] + b = service.create_ledger_account("Synthetic debt B", type="loan", liquidity_bucket="loan", db_path=path)["account"]["id"] + for account, amount, key in ((a, -900, "loan-a"), (b, 200, "loan-b")): + v = service.register_account_valuation(account, "2026-09-30", amount, valuation_kind="loan_balance", representative_liability_definition="principal_only", verification_status="verified", idempotency_key=key, db_path=path) + assert v["success"], v + draft = new_draft(path) + review_result = ok(api("review_settlement_draft")(draft["id"], db_path=path)) + review = review_result + entries = review.get("items", []) + assert entries + assert any(x.get("amount") in (-900, 200) for x in entries) + result = ok(close_draft(path, draft)) + evidence = json.dumps(result, sort_keys=True) + assert "applied_amount" in evidence + assert "-900" in evidence and "200" in evidence + + +def test_source_snapshot_preserves_reproducible_values_without_raw_secrets(tmp_path): + path = dbfile(tmp_path) + account = service.create_ledger_account("Synthetic evidence", last_balance=42, liquidity_bucket="cash", db_path=path)["account"]["id"] + # Evidence is synthetic; no credentials or user-provided freeform payloads. + v = service.register_account_valuation(account, "2026-09-30", 42, valuation_kind="balance", verification_status="verified", idempotency_key="value", source_ref="synthetic-ref", db_path=path) + assert v["success"], v + result = ok(close_draft(path, new_draft(path))) + saved = result + assert saved.get("source_snapshot") or saved.get("source_snapshot_json") + encoded = json.dumps(saved, sort_keys=True) + assert "synthetic-ref" in encoded + for secret in ("raw_payload", "password", "cookie", "token"): + assert secret not in encoded.lower() + + +@pytest.mark.parametrize("mutation", ["ledger_amount", "category_classification"]) +def test_source_policy_and_category_drift_invalidate_approval(tmp_path, mutation): + path = dbfile(tmp_path) + category = service.create_ledger_category("Food", "Meal", cashflow_kind="expense", db_path=path) + assert category.get("success") is True, category + transaction = service.create_ledger_transaction(transaction_date="2026-09-12", type="expense", amount=17, category="Food", subcategory="Meal", description="synthetic drift", dedupe=False, db_path=path) + assert transaction.get("success") is True, transaction + txid = transaction.get("transaction", transaction).get("id") + draft = new_draft(path) + review_result = ok(api("review_settlement_draft")(draft["id"], db_path=path)) + review = review_result + ok(api("approve_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "synthetic-reviewer", "approved", "ref", db_path=path)) + with sqlite3.connect(path) as db: + if mutation == "ledger_amount": + db.execute("UPDATE transactions SET amount=amount+1 WHERE id=?", (txid,)) + else: + tables = {r[0] for r in db.execute("SELECT name FROM sqlite_master WHERE type='table'")} + assert "ledger_categories" in tables + cols = {r[1] for r in db.execute("PRAGMA table_info(ledger_categories)")} + assert {"category", "subcategory", "cashflow_kind"} <= cols + db.execute("UPDATE ledger_categories SET cashflow_kind='income' WHERE category=? AND subcategory=?", ("Food", "Meal")) + db.commit() + closed = api("finalize_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "close", db_path=path) + assert closed.get("success") is False, closed + with sqlite3.connect(path) as db: + assert db.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0 + + +@pytest.mark.parametrize("failure_point", ["header", "assets", "cashflow", "completion"]) +def test_mid_close_failure_rolls_back_all_settlement_state(tmp_path, failure_point): + path = dbfile(tmp_path) + ok(service.create_ledger_category("Work", "Salary", cashflow_kind="income", db_path=path)) + ok(service.create_ledger_transaction("2026-09-10", "income", 50, "Work", "Salary", db_path=path)) + draft = new_draft(path) + review_result = ok(api("review_settlement_draft")(draft["id"], db_path=path)) + review = review_result + ok(api("approve_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "synthetic-reviewer", "approved", "ref", db_path=path)) + with sqlite3.connect(path) as db: + target = {"header":"asset_snapshot_runs", "assets":"asset_snapshots", "cashflow":"settlement_cashflow_snapshots", "completion":"settlement_drafts"}[failure_point] + # Missing future table is an explicit RED assertion, not a SQL error masquerading as rollback. + assert db.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (target,)).fetchone(), f"required comprehensive table missing: {target}" + event = "UPDATE" if failure_point == "completion" else "INSERT" + condition = " WHEN NEW.status='completed'" if failure_point == "completion" else "" + db.execute(f"CREATE TRIGGER fail_{failure_point} BEFORE {event} ON {target}{condition} BEGIN SELECT RAISE(ABORT,'synthetic injected failure'); END") + failed = api("finalize_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "close", db_path=path) + assert failed.get("success") is False + with sqlite3.connect(path) as db: + assert db.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0 + assert db.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0] == 0 + assert db.execute("SELECT COUNT(*) FROM settlement_cashflow_snapshots").fetchone()[0] == 0 + assert db.execute("SELECT status FROM settlement_drafts WHERE id=?", (draft["id"],)).fetchone()[0] == "active" + + +def test_close_rejects_stored_detail_sum_tampering(tmp_path): + path = dbfile(tmp_path) + ok(service.create_ledger_category("Work", "Salary", cashflow_kind="income", db_path=path)) + ok(service.create_ledger_transaction("2026-09-10", "income", 50, "Work", "Salary", db_path=path)) + draft = new_draft(path) + with sqlite3.connect(path) as db: + assert db.execute("SELECT 1 FROM sqlite_master WHERE name='settlement_cashflow_snapshots'").fetchone(), "cashflow detail storage is required" + db.execute("CREATE TRIGGER corrupt_saved_detail AFTER INSERT ON settlement_cashflow_snapshots BEGIN UPDATE settlement_cashflow_snapshots SET amount=amount+1 WHERE id=NEW.id; END") + result = close_draft(path, draft) + assert result.get("success") is False, result + with sqlite3.connect(path) as db: + for table in ("asset_snapshot_runs", "asset_snapshots", "settlement_cashflow_snapshots"): + assert db.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] == 0 + assert db.execute("SELECT status FROM settlement_drafts WHERE id=?", (draft["id"],)).fetchone()[0] == "active" + + +def test_replay_and_correction_keep_each_run_immutable(tmp_path): + path = dbfile(tmp_path) + first = new_draft(path) + initial = ok(close_draft(path, first, "first-close")) + replay = ok(api("finalize_settlement_draft")(first["id"], first["version"], initial["review_fingerprint"], "first-close", db_path=path)) + assert replay == initial + correction = ok(api("create_settlement_draft")("2026-09", "synthetic-operator", "correction", correction_of_run_id=initial["run_id"], correction_reason="synthetic correction", db_path=path))["draft"] + corrected = ok(close_draft(path, correction, "correction-close")) + assert corrected["run_id"] != initial["run_id"] + old = ok(api("get_monthly_settlement_snapshot")(run_id=initial["run_id"], db_path=path)) + new = ok(api("get_monthly_settlement_snapshot")(run_id=corrected["run_id"], db_path=path)) + assert old["run_id"] == initial["run_id"] and new["run_id"] == corrected["run_id"] + + +def test_concurrent_close_retries_return_one_saved_result(tmp_path): + path = dbfile(tmp_path) + draft = new_draft(path) + review_result = ok(api("review_settlement_draft")(draft["id"], db_path=path)) + review = review_result + ok(api("approve_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "synthetic-reviewer", "approved", "ref", db_path=path)) + def close(): + return api("finalize_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "same-key", db_path=path) + with ThreadPoolExecutor(max_workers=4) as pool: + results = list(pool.map(lambda _: close(), range(4))) + successes = [r for r in results if r.get("success")] + assert len(successes) == 4 + assert len({r["run_id"] for r in successes}) == 1 + + +def test_readonly_snapshot_missing_database_does_not_create_file(tmp_path): + path = tmp_path / "missing.sqlite3" + result = api("get_monthly_settlement_snapshot")(month="2026-09", db_path=str(path)) + assert result.get("success") is False + assert not path.exists() + + +def test_saved_reader_has_six_totals_and_legacy_null_is_not_live_zero(tmp_path): + path = dbfile(tmp_path) + # Empty database has no run; a saved reader must identify no snapshot, never fabricate current totals. + result = api("get_monthly_settlement_snapshot")(month="2026-09", db_path=path) + assert result.get("success") is False or result.get("source") == "settlement_snapshot" + for key in ("income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth"): + assert key not in result or result[key] is None + + +def test_cashflow_and_legacy_api_shapes_remain_distinct(tmp_path): + path = dbfile(tmp_path) + before = service.get_monthly_cashflow_report("2026-09", db_path=path) + assert before.get("success") is True + live_keys = set(before) + draft = new_draft(path) + review_result = ok(api("review_settlement_draft")(draft["id"], db_path=path)) + review = review_result + assert {"income_total", "expense_total", "net_cashflow"} <= review.keys() + after = service.get_monthly_cashflow_report("2026-09", db_path=path) + assert set(after) == live_keys + + +def test_policy_setter_persists_explicit_lists_and_changes_review_fingerprint(tmp_path): + path = dbfile(tmp_path) + draft = new_draft(path) + before = ok(api("review_settlement_draft")(draft["id"], db_path=path))["review_fingerprint"] + policy = {"savings_categories":[{"category":"Savings","subcategory":"Reserve"}], "category_decisions":[]} + changed = ok(api("set_settlement_cashflow_policy")(draft["id"], draft["version"], "synthetic-operator", policy, db_path=path)) + updated = changed["draft"] + after = ok(api("review_settlement_draft")(draft["id"], db_path=path))["review_fingerprint"] + assert updated["version"] == draft["version"] + 1 + assert before != after + + +def test_completed_get_and_saved_snapshot_ignore_later_live_ledger_changes(tmp_path): + path = dbfile(tmp_path) + draft = new_draft(path) + saved = ok(close_draft(path, draft)) + ok(service.create_ledger_category("Later", "Income", cashflow_kind="income", db_path=path)) + ok(service.create_ledger_transaction("2026-09-20", "income", 99, "Later", "Income", db_path=path)) + history = ok(api("get_settlement_draft")(draft["id"], db_path=path)) + fetched = ok(api("get_monthly_settlement_snapshot")(run_id=saved["run_id"], db_path=path)) + assert history["final_response"] == saved + assert fetched["income_total"] == saved["income_total"] + + +def _historical_asset_review_fingerprint(path, draft, review): + """Exact f67 asset-only serial, with no new cashflow/policy/version inputs.""" + from jarca_ledger_mcp.application import settlement_draft_service as old + with sqlite3.connect(path) as conn: + conn.row_factory = sqlite3.Row + stored = old._items(conn, draft["id"]) + states = [] + for item in stored: + candidates = [dict(v) for v in conn.execute("SELECT * FROM account_valuations WHERE account_id=? ORDER BY id", (item["source_id"],))] if item["source_type"] == "account" else [] + states.append({"stored": item, "current": old._current_semantics(conn, item), "candidates": candidates}) + serial = {"draft_id": draft["id"], "version": draft["version"], "basis_date": draft["basis_date"], "states": states, + "warnings": [], "latest_run": old._latest_close(conn, draft["basis_date"]), "correction_parent": draft["correction_of_run_id"], + "totals": {k: review[k] for k in ("total_assets", "total_liabilities", "net_worth")}} + return hashlib.sha256(json.dumps(serial, sort_keys=True, ensure_ascii=False).encode()).hexdigest() + + +def test_legacy_asset_approval_requires_comprehensive_review_before_close(tmp_path): + path = dbfile(tmp_path) + draft = new_draft(path) + review = ok(service.review_settlement_draft(draft["id"], db_path=path)) + assert not review["warnings"], "this historical serial fixture is deliberately warning-free" + historic = _historical_asset_review_fingerprint(path, draft, review) + if "aggregation_version" not in review: + assert historic == review["review_fingerprint"], "historical fixture must exactly match the original baseline approval" + with sqlite3.connect(path) as conn: + conn.execute("UPDATE settlement_drafts SET approval_fingerprint=?,approval_version=?,approved_by='synthetic-reviewer',approved_at='2026-09-30',approval_reason='historic asset review',approval_reference='historic-ref',approval_flags=? WHERE id=?", + (historic, draft["version"], json.dumps({"allow_missing_exclusions": False, "allow_carried_before": False, "allow_manual_substitute": False}), draft["id"])) + refused = service.finalize_settlement_draft(draft["id"], draft["version"], historic, "historic-close", db_path=path) + assert refused["success"] is False, "an asset-only historical approval cannot authorize comprehensive cashflow interpretation" + with sqlite3.connect(path) as conn: + assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0 + assert conn.execute("SELECT status,final_response FROM settlement_drafts WHERE id=?", (draft["id"],)).fetchone() == ("active", "") + fresh = ok(service.review_settlement_draft(draft["id"], db_path=path)) + assert fresh["review_fingerprint"] != historic + ok(service.approve_settlement_draft(draft["id"], draft["version"], fresh["review_fingerprint"], "synthetic-reviewer", "comprehensive reapproval", "new-ref", db_path=path)) + saved = ok(service.finalize_settlement_draft(draft["id"], draft["version"], fresh["review_fingerprint"], "fresh-close", db_path=path)) + ok(service.create_ledger_category("Later", "Income", cashflow_kind="income", db_path=path)) + ok(service.create_ledger_transaction("2026-09-20", "income", 99, "Later", "Income", db_path=path)) + assert ok(service.get_settlement_draft(draft["id"], db_path=path))["final_response"] == saved + assert ok(service.finalize_settlement_draft(draft["id"], draft["version"], fresh["review_fingerprint"], "fresh-close", db_path=path)) == saved + + +def _seed_reader_run(path, comprehensive=False, source=None): + init_db(path) + with sqlite3.connect(path) as conn: + conn.execute("INSERT INTO asset_snapshot_runs(id,basis_date,close_status,created_at) VALUES ('synthetic-run','2026-09-30','complete','2026-09-30')") + if comprehensive: + conn.execute("UPDATE asset_snapshot_runs SET settlement_month='2026-09',currency='KRW',income_total=20,expense_total=0,net_cashflow=20,total_assets=0,total_liabilities=0,net_worth=0,cashflow_policy_json='{}',aggregation_version='comprehensive-v1',snapshot_schema_version=1,source_snapshot_json=? WHERE id='synthetic-run'", (json.dumps(source or {}),)) + conn.execute("INSERT INTO settlement_cashflow_snapshots(id,settlement_run_id,cashflow_kind,category,subcategory,amount,transaction_count,currency) VALUES ('synthetic-detail','synthetic-run','income','Salary','Monthly',20,1,'KRW')") + + +def test_legacy_reader_preserves_unrecorded_currency_and_marks_legacy(tmp_path): + from jarca_ledger_mcp.application.monthly_settlement_service import get_monthly_settlement_snapshot + path = str(tmp_path / "reader-legacy-null.sqlite3") + _seed_reader_run(path) + result = ok(get_monthly_settlement_snapshot(run_id="synthetic-run", db_path=path)) + assert result["legacy"] is True and result["is_comprehensive"] is False + assert result["currency"] is None, "legacy currency must not be invented as KRW" + assert all(value is None for value in result["totals"].values()) + + +def test_legacy_reader_accepts_original_header_without_migration(tmp_path): + from jarca_ledger_mcp.application.monthly_settlement_service import get_monthly_settlement_snapshot + path = str(tmp_path / "reader-old-columns.sqlite3") + _seed_reader_run(path) + with sqlite3.connect(path) as conn: + for column in ("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"): + conn.execute(f"ALTER TABLE asset_snapshot_runs DROP COLUMN {column}") + for kwargs in ({"run_id": "synthetic-run"}, {"month": "2026-09"}): + result = get_monthly_settlement_snapshot(db_path=path, **kwargs) + assert result["success"], "pre-comprehensive headers must be read without new-column SQL" + assert all(value is None for value in result["totals"].values()) + with sqlite3.connect(path) as conn: + assert "settlement_month" not in {row[1] for row in conn.execute("PRAGMA table_info(asset_snapshot_runs)")} + + +def test_stored_reader_keeps_required_transaction_date(tmp_path): + from jarca_ledger_mcp.application.monthly_settlement_service import get_monthly_settlement_snapshot + path = str(tmp_path / "reader-source-date.sqlite3") + tx = {"id": "synthetic-tx", "transaction_date": "2026-09-01", "type": "income", "amount": 20, "currency": "KRW", "category": "Salary", "subcategory": "Monthly", "cashflow_kind": "income", "applied_amount": 20} + _seed_reader_run(path, True, {"transactions": [tx], "assets": []}) + result = ok(get_monthly_settlement_snapshot(run_id="synthetic-run", db_path=path)) + assert result["source_snapshot"]["transactions"] == [tx] + + +def test_stored_reader_allowlists_source_root_and_nested_decision(tmp_path): + from jarca_ledger_mcp.application.monthly_settlement_service import get_monthly_settlement_snapshot + path = str(tmp_path / "reader-source-private.sqlite3") + marker = "SYNTHETIC_PRIVATE_SNAPSHOT" + tx = {"id": "synthetic-tx", "transaction_date": "2026-09-01", "type": "income", "amount": 20, "currency": "KRW", "category": "Salary", "subcategory": "Monthly", "cashflow_kind": "income", "applied_amount": 20, "decision": {"category": "Salary", "subcategory": "Monthly", "cashflow_kind": "income", "reason": "synthetic decision", "private_note": marker}} + _seed_reader_run(path, True, {"transactions": [tx], "assets": [], "private_note": marker}) + result = ok(get_monthly_settlement_snapshot(run_id="synthetic-run", db_path=path)) + assert marker not in json.dumps(result), "unknown root and nested source fields are not public financial evidence" + + +@pytest.mark.parametrize("target", ["header_total", "asset_amount", "asset_currency", "header_source", "header_policy", "header_version"]) +def test_close_rejects_persisted_header_and_asset_tampering(tmp_path, target): + path = dbfile(tmp_path) + ok(service.create_ledger_category("Salary", "Monthly", cashflow_kind="income", db_path=path)) + ok(service.create_ledger_transaction("2026-09-01", "income", 20, "Salary", "Monthly", db_path=path)) + draft = new_draft(path) + review = ok(service.review_settlement_draft(draft["id"], db_path=path)) + ok(service.approve_settlement_draft(draft["id"], draft["version"], review["review_fingerprint"], "reviewer", "synthetic", "ref", db_path=path)) + if target.startswith("asset_"): + column, expression = ("amount", "amount+1") if target == "asset_amount" else ("currency", "'USD'") + trigger = f"CREATE TRIGGER synthetic_tamper AFTER INSERT ON asset_snapshots BEGIN UPDATE asset_snapshots SET {column}={expression} WHERE id=NEW.id; END" + else: + column, expression = {"header_total": ("income_total", "income_total+1"), "header_source": ("source_snapshot_json", "'{}'"), "header_policy": ("cashflow_policy_json", "'{\"savings_categories\":[{\"category\":\"Other\",\"subcategory\":\"Pair\"}],\"category_decisions\":[]}'"), "header_version": ("aggregation_version", "'unknown-version'")}[target] + trigger = f"CREATE TRIGGER synthetic_tamper AFTER INSERT ON settlement_cashflow_snapshots BEGIN UPDATE asset_snapshot_runs SET {column}={expression} WHERE id=NEW.settlement_run_id; END" + with sqlite3.connect(path) as conn: + conn.execute(trigger) + result = service.finalize_settlement_draft(draft["id"], draft["version"], review["review_fingerprint"], "tamper-close", db_path=path) + assert result["success"] is False, "persisted header/detail must match the approved result before commit" + with sqlite3.connect(path) as conn: + for table in ("asset_snapshot_runs", "asset_snapshots", "settlement_cashflow_snapshots"): + assert conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] == 0 + assert conn.execute("SELECT status,final_run_id,final_response FROM settlement_drafts WHERE id=?", (draft["id"],)).fetchone() == ("active", "", "") + + +def test_stored_aggregation_version_drift_invalidates_approval(tmp_path): + path = dbfile(tmp_path) + draft = new_draft(path) + review = ok(service.review_settlement_draft(draft["id"], db_path=path)) + ok(service.approve_settlement_draft(draft["id"], draft["version"], review["review_fingerprint"], "reviewer", "synthetic", "ref", db_path=path)) + with sqlite3.connect(path) as conn: + conn.execute("UPDATE settlement_drafts SET aggregation_version='unknown-version' WHERE id=?", (draft["id"],)) + result = service.finalize_settlement_draft(draft["id"], draft["version"], review["review_fingerprint"], "version-drift", db_path=path) + assert result["success"] is False, "unknown stored logic version cannot silently use current aggregation" + + +def test_completed_review_returns_saved_totals_and_fingerprint(tmp_path): + path = dbfile(tmp_path) + draft = new_draft(path) + saved = ok(close_draft(path, draft)) + ok(service.create_ledger_category("Later", "Income", cashflow_kind="income", db_path=path)) + ok(service.create_ledger_transaction("2026-09-20", "income", 99, "Later", "Income", db_path=path)) + review = ok(service.review_settlement_draft(draft["id"], db_path=path)) + assert review["totals"] == saved["totals"] + assert review["review_fingerprint"] == saved["review_fingerprint"] + + +def test_excluded_loan_source_has_no_applied_contribution(tmp_path): + path = dbfile(tmp_path) + values = [] + for name, amount in (("Synthetic A", -900), ("Synthetic B", 200)): + account = ok(service.create_ledger_account(name, type="loan", liquidity_bucket="loan", db_path=path))["account"]["id"] + value = ok(service.register_account_valuation(account, "2026-09-30", amount, valuation_kind="loan_balance", representative_liability_definition="principal_only", verification_status="verified", idempotency_key=name, db_path=path))["valuation"] + values.append((account, value["id"])) + draft = new_draft(path) + edited = ok(service.set_settlement_draft_item(draft["id"], "account", values[1][0], draft["version"], "operator", valuation_id=values[1][1], included=False, exclusion_reason="synthetic exclusion", db_path=path)) + review = edited["review"] + assert review["total_liabilities"] == 900 + excluded = next(x for x in review["source_snapshot"]["assets"] if x["source_id"] == values[1][0]) + assert excluded["amount"] == 200 and excluded["applied_amount"] is None + + +def test_cashflow_detail_currency_matches_header_without_losing_original(tmp_path): + path = dbfile(tmp_path) + ok(service.create_ledger_category("Salary", "Monthly", cashflow_kind="income", db_path=path)) + tx = ok(service.create_ledger_transaction("2026-09-01", "income", 20, "Salary", "Monthly", db_path=path))["transaction"] + with sqlite3.connect(path) as conn: + conn.execute("UPDATE transactions SET currency='krw' WHERE id=?", (tx["id"],)) + saved = ok(close_draft(path, new_draft(path))) + assert saved["cashflow_items"][0]["currency"] == "KRW" + assert saved["source_snapshot"]["transactions"][0]["currency"] == "krw" + + +def test_saved_reader_retains_asset_authority_and_liability_flags(tmp_path): + path = dbfile(tmp_path) + saved = ok(close_draft(path, new_draft(path))) + snapshot = ok(service.get_monthly_settlement_snapshot(run_id=saved["run_id"], db_path=path)) + asset = snapshot["source_snapshot"]["assets"][0] + assert asset.get("authority_source") == "manual_assets" + assert asset.get("is_liability") is False + assert asset.get("status") == "ready" + + +@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) + saved = ok(close_draft(path, new_draft(path))) + with sqlite3.connect(path) as conn: + conn.execute("UPDATE asset_snapshot_runs SET snapshot_schema_version=?,aggregation_version=? WHERE id=?", (schema_version, aggregation_version, saved["run_id"])) + conn.commit() + before = list(conn.iterdump()) + for query in ({"run_id": saved["run_id"]}, {"month": "2026-09"}): + result = service.get_monthly_settlement_snapshot(db_path=path, **query) + assert result["success"] is False, "unsupported comprehensive logic/schema is not a recognized authoritative result" + assert result["error_code"] == "db_error" + assert "totals" not in result and "cashflow_items" not in result + with sqlite3.connect(path) as conn: + assert list(conn.iterdump()) == before + + +@pytest.mark.parametrize("aggregation_version", ["unknown-version", "comprehensive-v1", ""]) +def test_saved_reader_does_not_mislabel_partial_version_metadata_as_legacy(tmp_path, aggregation_version): + path = dbfile(tmp_path) + saved = ok(close_draft(path, new_draft(path))) + with sqlite3.connect(path) as conn: + conn.execute("UPDATE asset_snapshot_runs SET snapshot_schema_version=NULL,aggregation_version=? WHERE id=?", (aggregation_version, saved["run_id"])) + conn.commit() + before = list(conn.iterdump()) + for query in ({"run_id": saved["run_id"]}, {"month": "2026-09"}): + result = service.get_monthly_settlement_snapshot(db_path=path, **query) + assert result["success"] is False, "partial version metadata is not a genuine NULL-version legacy run" + assert result["error_code"] == "db_error" + assert "totals" not in result + with sqlite3.connect(path) as conn: + assert list(conn.iterdump()) == before + conn.execute("UPDATE asset_snapshot_runs SET aggregation_version=NULL WHERE id=?", (saved["run_id"],)) + 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()) diff --git a/tests/test_comprehensive_settlement_adapters.py b/tests/test_comprehensive_settlement_adapters.py new file mode 100644 index 0000000..62b4b4e --- /dev/null +++ b/tests/test_comprehensive_settlement_adapters.py @@ -0,0 +1,254 @@ +"""Issue 18 adapter/UI contracts; synthetic paths and identities only.""" +import importlib +import inspect +from pathlib import Path + +from jarca_ledger_mcp import service + +POLICY = "set_settlement_cashflow_policy" +READER = "get_monthly_settlement_snapshot" + + +def _api(name): + fn = getattr(service, name, None) + assert callable(fn), f"required comprehensive settlement API missing: service.{name}" + return fn + + +def test_shared_adapters_expose_policy_and_saved_snapshot_contracts(monkeypatch, tmp_path): + gateway = importlib.import_module("jarca_ledger_mcp.adapters.streamlit.settlement_gateway") + contracts = importlib.import_module("jarca_ledger_mcp.adapters.mcp.tool_contracts") + server = importlib.import_module("jarca_ledger_mcp.adapters.mcp.server") + setter_owner = importlib.import_module("jarca_ledger_mcp.application.settlement_draft_service") + reader_owner = importlib.import_module("jarca_ledger_mcp.application.monthly_settlement_service") + expected = {POLICY: ["draft_id", "expected_version", "updated_by", "cashflow_policy", "db_path"], READER: ["month", "run_id", "db_path"]} + monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(tmp_path / "synthetic.sqlite3")) + calls = [] + for name, parameters in expected.items(): + fn = _api(name) + assert list(inspect.signature(fn).parameters) == parameters + gateway_fn = getattr(gateway, name, None) + assert callable(gateway_fn), f"gateway missing {name}" + assert inspect.signature(gateway_fn) == inspect.signature(fn) + marker = {"success": True, "operation": name} + def stub(*args, _name=name, _marker=marker, **kwargs): + calls.append((_name, kwargs.get("db_path"))) + return _marker + monkeypatch.setattr(setter_owner if name == POLICY else reader_owner, name, stub) + kwargs = {key: "synthetic" for key in parameters if key != "db_path"} + if name == POLICY: + kwargs["expected_version"] = 1 + kwargs["cashflow_policy"] = {"savings_categories": [], "category_decisions": []} + path = str(tmp_path / "synthetic.sqlite3") + kwargs["db_path"] = path + assert gateway_fn(**kwargs) == marker + assert calls[-1] == (name, path) + assert name in contracts.TOOL_CONTRACTS + tools = getattr(server, "mcp", getattr(server, "server", None))._tool_manager._tools + assert name in tools + tool = tools[name] + call = getattr(tool, "fn", getattr(tool, "function", None)) + assert callable(call) + tool_kwargs = dict(kwargs) + tool_kwargs.pop("db_path") + boundary_calls = [] + def boundary(*args, **forwarded): + bound = inspect.signature(fn).bind(*args, **forwarded) + boundary_calls.append(dict(bound.arguments)) + return marker + # Existing MCP contracts bind facade callables at import time. Test the + # actual MCP/contract boundary, not a later owner-module monkeypatch. + monkeypatch.setattr(contracts, name, boundary) + assert call(**tool_kwargs) == marker + assert boundary_calls[-1] == tool_kwargs + schema = contracts.TOOL_CONTRACTS[name] + assert callable(schema) + tool_schema = tool.parameters if hasattr(tool, "parameters") else tool.inputSchema + assert "db_path" not in str(tool_schema) + + +def test_readonly_reader_on_missing_path_does_not_create_database(tmp_path): + path = tmp_path / "absent.sqlite3" + result = _api(READER)(month="2026-09", db_path=str(path)) + assert result.get("success") is False + assert not path.exists() + + +def test_dashboard_exposes_additive_policy_and_saved_history_without_implicit_mutation(): + root = Path(__file__).parents[1] + drafts = root / "dashboard/page_views/settlement_drafts.py" + monthly = root / "dashboard/page_views/monthly_settlement.py" + source = drafts.read_text(encoding="utf-8") + monthly_source = monthly.read_text(encoding="utf-8") + combined = source + "\n" + monthly_source + assert "종합 월정산 정책" in combined + assert "저장된 월정산 스냅샷" in combined + assert POLICY in source or POLICY in monthly_source + assert READER in source or READER in monthly_source + assert "settlement_gateway" in combined + assert "sqlite3.connect" not in combined and "CREATE TABLE" not in combined + assert "form_submit_button" in source or "if st.button" in source + + +def test_authenticated_dashboard_entrypoint_keeps_authentication_before_database_resolution(): + app = (Path(__file__).parents[1] / "dashboard/app.py").read_text(encoding="utf-8") + assert app.index("require_dashboard_authentication()") < app.index("get_db_path()") + + +def test_new_contracts_keep_asset_direct_close_signatures_and_live_report_api(): + assets = importlib.import_module("jarca_ledger_mcp.application.assets_service") + report = importlib.import_module("jarca_ledger_mcp.application.transaction_report_services") + direct = inspect.signature(assets.close_asset_snapshot) + assert "cashflow_policy" not in direct.parameters + assert list(inspect.signature(report.get_monthly_cashflow_report).parameters)[0] == "month" + assert callable(_api(POLICY)) and callable(_api(READER)) + + +def _draft_page(monkeypatch, status="active"): + from streamlit.testing.v1 import AppTest + from jarca_ledger_mcp.adapters.streamlit import settlement_gateway + policy = {"savings_categories": [{"category": "Savings", "subcategory": "Monthly"}], "category_decisions": []} + draft = {"id": "synthetic-draft", "month": "2026-09", "basis_date": "2026-09-30", "status": status, "version": 1, "cashflow_policy": policy, "final_run_id": "synthetic-run"} + final = {"income_total": 101, "expense_total": 0, "net_cashflow": 101, "total_assets": 0, "total_liabilities": 0, "net_worth": 0, "aggregation_version": "comprehensive-v1", "cashflow_items": [{"cashflow_kind": "income", "category": "Salary", "subcategory": "Monthly", "amount": 101, "transaction_count": 1, "currency": "KRW"}]} + 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": "synthetic-fp"}, "final_response": final if status == "completed" else None}) + page = AppTest.from_string("from dashboard.page_views.settlement_drafts import render_settlement_drafts\nrender_settlement_drafts(db_path='synthetic-unused.sqlite3', month='2026-09')", default_timeout=30).run() + assert not page.exception + return page, policy + + +def test_draft_policy_editor_uses_saved_draft_policy(monkeypatch): + import json + page, policy = _draft_page(monkeypatch) + assert json.loads(page.text_area[0].value) == policy, "rendering must not replace persisted policy with an empty default" + + +def test_completed_draft_page_displays_saved_response_and_cashflow_kind(monkeypatch): + page, _ = _draft_page(monkeypatch, "completed") + assert len(page.dataframe) == 1, "completed get exposes saved final_response at the response root" + frame = page.dataframe[0].value + assert "cashflow_kind" in frame.columns + assert frame.iloc[0]["cashflow_kind"] == "income" + assert sum(item.value == "### 불변 이력" for item in page.markdown) == 1 + + +# Real transport/UI acceptance coverage. All identities and ledger values are synthetic. +def test_real_comprehensive_stdio_policy_close_and_saved_snapshot(tmp_path): + import asyncio, json, os, sqlite3, sys + from contextlib import closing + from mcp import ClientSession, StdioServerParameters + from mcp.client.stdio import stdio_client + from jarca_ledger_mcp.db import init_db + + db = tmp_path / "stdio-comprehensive.sqlite3" + init_db(str(db)) + category = service.create_ledger_category("Synthetic salary", "Monthly", cashflow_kind="income", db_path=str(db)) + assert category["success"] + assert service.create_ledger_transaction("2026-09-05", "income", 20, "Synthetic salary", "Monthly", db_path=str(db))["success"] + assert service.create_ledger_category("Synthetic food", "Meal", cashflow_kind="expense", db_path=str(db))["success"] + assert service.create_ledger_transaction("2026-09-06", "expense", 10, "Synthetic food", "Meal", db_path=str(db))["success"] + assert service.create_ledger_category("Synthetic refund", "Meal", cashflow_kind="expense", db_path=str(db))["success"] + assert service.create_ledger_transaction("2026-09-07", "refund", 3, "Synthetic refund", "Meal", db_path=str(db))["success"] + assert service.create_ledger_category("Synthetic savings", "Reserve", cashflow_kind="investment", db_path=str(db))["success"] + assert service.create_ledger_transaction("2026-09-08", "expense", 5, "Synthetic savings", "Reserve", db_path=str(db))["success"] + acct = service.create_ledger_account("Synthetic checking", last_balance=100, liquidity_bucket="cash", db_path=str(db))["account"]["id"] + assert service.register_account_valuation(acct, "2026-09-30", 100, verification_status="verified", idempotency_key="stdio-valuation", db_path=str(db))["success"] + root = Path(__file__).resolve().parents[1] + # Deliberately construct a minimal environment; no caller auth/Gitea variables survive. + env = {k: os.environ[k] for k in ("PATH", "HOME", "LANG") if k in os.environ} + env.update(JARCA_LEDGER_DB_PATH=str(db), JARCA_MCP_TRANSPORT="stdio", PYTHONDONTWRITEBYTECODE="1") + params = StdioServerParameters(command=sys.executable, args=["-B", "-m", "jarca_ledger_mcp.server"], env=env, cwd=str(root)) + + async def exercise(): + async with stdio_client(params) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + tools = {t.name: t for t in (await session.list_tools()).tools} + for name in (POLICY, READER): + assert name in tools + schema = tools[name].inputSchema + assert "db_path" not in schema.get("properties", {}) + assert set(schema["properties"]) == set(inspect.signature(getattr(service, name)).parameters) - {"db_path"} + async def call(name, args): + result = await session.call_tool(name, args) + assert not result.isError + return json.loads(result.content[0].text) + made = await call("create_settlement_draft", {"month":"2026-09", "created_by":"synthetic", "idempotency_key":"stdio-draft"}) + draft = made["draft"] + policy = {"savings_categories":[{"category":"Synthetic savings", "subcategory":"Reserve"}], "category_decisions":[]} + changed = await call(POLICY, {"draft_id":draft["id"], "expected_version":draft["version"], "updated_by":"synthetic", "cashflow_policy":policy}) + draft = changed["draft"] + assert draft["version"] == 2 and draft["cashflow_policy"] == policy + review = await call("review_settlement_draft", {"draft_id":draft["id"]}) + for key in ("income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth"): + assert key in review + assert review["cashflow_items"] and all("cashflow_kind" in row for row in review["cashflow_items"]) + assert any(t.get("applied_amount") is not None for t in review.get("source_snapshot", {}).get("transactions", [])) + approved = await call("approve_settlement_draft", {"draft_id":draft["id"], "expected_version":draft["version"], "review_fingerprint":review["review_fingerprint"], "approved_by":"synthetic", "approval_reason":"acceptance", "approval_reference":"synthetic-ref"}) + closed = await call("finalize_settlement_draft", {"draft_id":draft["id"], "expected_version":approved["draft"]["version"], "review_fingerprint":review["review_fingerprint"], "idempotency_key":"stdio-close"}) + run = closed["run_id"] + exact = await call(READER, {"run_id":run}) + monthly = await call(READER, {"month":"2026-09"}) + assert exact["run_id"] == run and monthly["run_id"] == run + before = (await call("get_settlement_draft", {"draft_id":draft["id"]})) + assert await call("finalize_settlement_draft", {"draft_id":draft["id"], "expected_version":approved["draft"]["version"], "review_fingerprint":review["review_fingerprint"], "idempotency_key":"stdio-close"}) == closed + assert (await call("get_settlement_draft", {"draft_id":draft["id"]})) == before + assert await call(READER, {"run_id":run}) == exact + # Later ledger writes must not rewrite either saved surface. + service.create_ledger_category("Later", "Synthetic", cashflow_kind="income", db_path=str(db)) + service.create_ledger_transaction("2026-09-20", "income", 99, "Later", "Synthetic", db_path=str(db)) + assert await call(READER, {"run_id":run}) == exact + assert (await call("get_settlement_draft", {"draft_id":draft["id"]})) == before + return run + run = asyncio.run(asyncio.wait_for(exercise(), timeout=60)) + with closing(sqlite3.connect(db)) as conn: + columns = {r[1] for r in conn.execute("PRAGMA table_info(asset_snapshot_runs)")} + assert {"income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth", "cashflow_policy_json", "aggregation_version", "snapshot_schema_version", "source_snapshot_json"} <= columns + assert conn.execute("SELECT COUNT(*) FROM settlement_cashflow_snapshots WHERE settlement_run_id=?", (run,)).fetchone()[0] > 0 + + +def test_authenticated_dashboard_renders_saved_comprehensive_snapshot(tmp_path, monkeypatch): + import sqlite3 + from streamlit.testing.v1 import AppTest + from dashboard import authentication + from jarca_ledger_mcp.db import init_db + db = tmp_path / "dashboard-comprehensive.sqlite3" + init_db(str(db)) + assert service.create_ledger_category("Synthetic salary", "Monthly", cashflow_kind="income", db_path=str(db))["success"] + assert service.create_ledger_transaction("2026-09-05", "income", 20, "Synthetic salary", "Monthly", db_path=str(db))["success"] + asset = service.create_manual_asset("Synthetic cash", 100, "2026-09-30", "cash", db_path=str(db)) + assert asset["success"] + monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(db)) + monkeypatch.setenv(authentication.ADMIN_ID_ENV, "test-admin") + monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, "test-password") + monkeypatch.setenv(authentication.SIGNING_SECRET_ENV, "s" * 64) + settings = authentication.load_auth_settings() + assert settings is not None + token = authentication.issue_persistent_token(settings, remember_me=False).value + monkeypatch.setattr(authentication, "_request_cookie", lambda: token) + draft = service.create_settlement_draft("2026-09", "synthetic", "dashboard-draft", db_path=str(db))["draft"] + policy = {"savings_categories":[{"category":"Synthetic savings", "subcategory":"Reserve"}], "category_decisions":[]} + changed = service.set_settlement_cashflow_policy(draft["id"], draft["version"], "synthetic", policy, db_path=str(db))["draft"] + reviewed = service.review_settlement_draft(draft["id"], db_path=str(db)) + assert reviewed["success"] + assert service.approve_settlement_draft(draft["id"], changed["version"], reviewed["review_fingerprint"], "synthetic", "acceptance", "synthetic-ref", db_path=str(db))["success"] + saved = service.finalize_settlement_draft(draft["id"], changed["version"], reviewed["review_fingerprint"], "dashboard-close", db_path=str(db)) + assert saved["success"] + with sqlite3.connect(db) as conn: + before = conn.execute("SELECT name, sql FROM sqlite_master ORDER BY type,name").fetchall() + before_rows = conn.execute("SELECT * FROM asset_snapshot_runs ORDER BY id").fetchall() + before_detail = conn.execute("SELECT * FROM settlement_cashflow_snapshots ORDER BY id").fetchall() + app = AppTest.from_file(str(Path(__file__).resolve().parents[1] / "dashboard" / "app.py"), default_timeout=30) + app.query_params["page"] = "월정산" + app.query_params["month"] = "2026-09" + app.run() + assert not app.exception, app.exception + body = "\n".join(str(x.value) for x in list(app.text) + list(app.markdown) + list(app.caption)) + assert "저장된 월정산 스냅샷" in body and f"run {saved['run_id']}" in body + assert any(metric.label == "자산 합계" for metric in app.metric) + frames = [item.value for item in app.dataframe] + assert any("cashflow_kind" in frame.columns and "income" in set(frame["cashflow_kind"]) for frame in frames) + with sqlite3.connect(db) as conn: + 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 diff --git a/tests/test_issue_12_regressions.py b/tests/test_issue_12_regressions.py index 0be9af9..95be819 100644 --- a/tests/test_issue_12_regressions.py +++ b/tests/test_issue_12_regressions.py @@ -122,7 +122,7 @@ def test_schema12_migration_preserves_legacy_rows_indexes_and_is_idempotent(tmp_ assert conn.execute("SELECT name FROM asset_snapshots WHERE id='s'").fetchone() indexes = {r[1] for r in conn.execute("PRAGMA index_list(transactions)")} assert {"legacy_transactions_date", "idx_transactions_datetime"} <= indexes - assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "13" + assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "14" def test_snapshot_close_idempotency_fingerprint_and_concurrent_first_close(tmp_path): diff --git a/tests/test_ledger_categories.py b/tests/test_ledger_categories.py index 1a2f092..1022490 100644 --- a/tests/test_ledger_categories.py +++ b/tests/test_ledger_categories.py @@ -6,7 +6,7 @@ from jarca_ledger_mcp import service def test_ledger_category_create_list_delete_reactivate(tmp_path): db_path = str(tmp_path / "ledger.sqlite3") health = service.health_check(db_path) - assert health["schema_version"] == "13" + assert health["schema_version"] == "14" created = service.create_ledger_category("반려동물", "사료", sort_order=250, memo="test", db_path=db_path) assert created["success"] is True diff --git a/tests/test_monthly_settlement_drafts.py b/tests/test_monthly_settlement_drafts.py index 52c3c7b..46a9e40 100644 --- a/tests/test_monthly_settlement_drafts.py +++ b/tests/test_monthly_settlement_drafts.py @@ -256,7 +256,7 @@ def test_schema13_migration_is_additive_and_idempotent(tmp_path): with sqlite3.connect(path) as conn: assert conn.execute("SELECT id FROM asset_snapshot_runs WHERE id='legacy-run'").fetchone()[0] == 'legacy-run' assert {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} >= {'settlement_drafts','settlement_draft_items','asset_snapshot_runs','asset_snapshots'} - assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == '13' + assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == '14' def test_concurrent_create_and_stale_version_finalization_are_safe(tmp_path): diff --git a/tests/test_requirement_docs.py b/tests/test_requirement_docs.py index f96fba4..db4926a 100644 --- a/tests/test_requirement_docs.py +++ b/tests/test_requirement_docs.py @@ -17,39 +17,43 @@ def test_requirement_docs_are_canonical_and_traceable() -> None: text = REQ.read_text(encoding="utf-8") _, raw, body = text.split("---", 2) metadata = json.loads(raw) + assert metadata["implementation_pr"] == "미정" or re.fullmatch(r"https://gitea\.jsfamily\.quest/jsfamily/jarca-ledger-mcp/pulls/\d+|#\d+", metadata["implementation_pr"]) assert metadata == { "schema": "gitea-agent.requirement/v1", "id": "REQ-003", "title": "계좌 기준일 평가 이력과 자산 마감 연동", - "revision": "1.1", + "revision": "1.2", "status": "approved", "depends_on": [], "applies_to": ["ledger", "dashboard", "mcp"], "language": "ko", - "issue": "#15", - "implementation_pr": "https://gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp/pulls/16", + "issue": "#18", + "implementation_pr": metadata["implementation_pr"], } assert re.search(r"^# REQ-003 — ", body, re.M) assert "${" not in text requirements = re.findall(r"^### REQ-003\.(\d+) —", body, re.M) assert requirements == [f"{n:02d}" for n in range(1, 12)] - acs = re.findall(r"^### REQ-003\.\d+-AC(\d+) —", body, re.M) - assert len(acs) == 27 + acs = re.findall(r"^### (REQ-003\.\d+-AC\d+) —", body, re.M) + expected_acs = {f"REQ-003.{n:02d}-AC01" for n in range(1, 11)} | {f"REQ-003.11-AC{n:02d}" for n in range(1, 28)} + assert len(acs) == len(expected_acs) + assert len(set(acs)) == len(acs) + assert set(acs) == expected_acs baseline_acs = re.findall(r"^### REQ-003\.(0[1-9]|10)-AC01 —", body, re.M) assert baseline_acs == [f"{n:02d}" for n in range(1, 11)] assert body.count("**영향 범위**") == 11 # Impact is per requirement, not per AC. assert body.count("**Cases (정상/경계/실패)**") == 10 assert body.count("**State Invariants**") == 10 - assert body.count("**판정 기준**") == 27 - assert body.count("**검증**") == 27 + assert body.count("**판정 기준**") == 37 + assert body.count("**검증**") == 37 classification = re.findall( r"^\*\*검증 분류\*\*: level=(unit|integration|static); backend=(sqlite|application|dashboard)$", body, re.M, ) assert len(classification) == 10 - assert body.count("- 수준: integration") == 17 - assert len(re.findall(r"^- 백엔드: (sqlite|application|dashboard)$", body, re.M)) == 17 + assert body.count("- 수준: integration") == 27 + assert len(re.findall(r"^- 백엔드: (sqlite|application|dashboard)$", body, re.M)) == 27 assert {level for level, _ in classification} >= {"unit", "integration", "static"} assert {backend for _, backend in classification} >= {"sqlite", "application", "dashboard"} for marker in ( @@ -146,7 +150,7 @@ def test_req003_has_exact_canonical_numbered_sections() -> None: headings = re.findall(r"^## (\d+)\. ", body, re.M) assert headings == [str(n) for n in range(1, 12)] assert not re.search(r"^## (?:8|9)\.(?: D| C)", body, re.M) - assert body.count("### REQ-003.") == 38 + assert body.count("### REQ-003.") == 48 assert "### REQ-003.01-AC01" in body assert "### REQ-003.10-AC01" in body diff --git a/tests/test_review_decision_workbench.py b/tests/test_review_decision_workbench.py index 090eb30..c857211 100644 --- a/tests/test_review_decision_workbench.py +++ b/tests/test_review_decision_workbench.py @@ -16,7 +16,7 @@ def test_review_decision_and_reconciliation_schema_are_additive_idempotent(tmp_p assert status["integrity_check"] == "ok" assert status["table_counts"]["review_decisions"] == 0 assert status["table_counts"]["reconciliation_sessions"] == 0 - assert service.health_check(db_path)["schema_version"] == "13" + assert service.health_check(db_path)["schema_version"] == "14" def test_review_decision_round_trip_payload_sanitizes_sensitive_fields(tmp_path: Path) -> None: diff --git a/tests/test_service.py b/tests/test_service.py index 18afd76..7ccadc1 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -10,7 +10,7 @@ def test_health_check_initializes_db(tmp_path): db_path = tmp_path / "ledger.sqlite3" result = service.health_check(str(db_path)) assert result["success"] is True - assert result["schema_version"] == "13" + assert result["schema_version"] == "14" assert db_path.exists() diff --git a/tests/test_settlement_cashflow_policy.py b/tests/test_settlement_cashflow_policy.py new file mode 100644 index 0000000..9f7434c --- /dev/null +++ b/tests/test_settlement_cashflow_policy.py @@ -0,0 +1,246 @@ +"""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" diff --git a/tests/test_settlement_cashflow_schema.py b/tests/test_settlement_cashflow_schema.py new file mode 100644 index 0000000..d0353e9 --- /dev/null +++ b/tests/test_settlement_cashflow_schema.py @@ -0,0 +1,89 @@ +"""Cashflow schema and legacy migration contract regressions.""" +import sqlite3 + +import pytest +from jarca_ledger_mcp.db import init_db + + +def _schema(path): + with sqlite3.connect(path) as db: + tables = {r[0] for r in db.execute("SELECT name FROM sqlite_master WHERE type='table'")} + assert {"settlement_cashflow_snapshots", "asset_snapshot_runs"} <= tables + header = {r[1] for r in db.execute("PRAGMA table_info(asset_snapshot_runs)")} + detail = {r[1] for r in db.execute("PRAGMA table_info(settlement_cashflow_snapshots)")} + assert {"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"} <= header + assert {"id", "settlement_run_id", "cashflow_kind", "category", "subcategory", "amount", "transaction_count", "currency"} <= detail + + +def test_cashflow_detail_constraints_preserve_blank_categories(tmp_path): + path = str(tmp_path / "detail-constraints.sqlite3") + init_db(path) + _schema(path) + with sqlite3.connect(path) as db: + db.execute("PRAGMA foreign_keys=ON") + db.execute("INSERT INTO asset_snapshot_runs(id,basis_date,close_status,created_at) VALUES('run-a','2026-09-30','complete_exact','now')") + insert = "INSERT INTO settlement_cashflow_snapshots(id,settlement_run_id,cashflow_kind,category,subcategory,amount,transaction_count,currency) VALUES(?,?,?,?,?,?,?,?)" + good = ("d1", "run-a", "expense", "", "", -7, 1, "KRW") + db.execute(insert, good) + for values in [("d2", "missing", "expense", "x", "y", 1, 1, "KRW"), ("d3", "run-a", "other", "x", "y", 1, 1, "KRW"), ("d4", "run-a", "expense", "", "", 7, 1, "KRW"), ("d5", "run-a", "expense", "", "", None, 1, "KRW"), ("d6", "run-a", "expense", "", "", 1, 0, "KRW"), ("d7", "run-a", "expense", None, "", 1, 1, "KRW"), ("d8", "run-a", "expense", "", None, 1, 1, "KRW")]: + with pytest.raises(sqlite3.IntegrityError): + db.execute(insert, values) + with pytest.raises(sqlite3.IntegrityError): + db.execute(insert, ("d9", "run-a", "expense", "", "", -7, 1, "KRW")) + db.execute(insert, ("d10", "run-a", "income", "", "", 2, 1, "KRW")) + indexes = {r[1]: r[2] for r in db.execute("PRAGMA index_list(settlement_cashflow_snapshots)")} + unique_fields = [tuple(r[2] for r in db.execute(f"PRAGMA index_info('{name}')")) for name, unique in indexes.items() if unique] + assert any(x == ("settlement_run_id", "cashflow_kind", "category", "subcategory", "currency") for x in unique_fields) + assert db.execute("SELECT amount,category,subcategory FROM settlement_cashflow_snapshots WHERE id='d1'").fetchone() == (-7, "", "") + with pytest.raises(sqlite3.IntegrityError): + db.execute("DELETE FROM asset_snapshot_runs WHERE id='run-a'") + assert db.execute("SELECT COUNT(*) FROM settlement_cashflow_snapshots WHERE settlement_run_id='run-a'").fetchone()[0] == 2 + + +def test_legacy_migration_is_idempotent_and_keeps_unstored_totals_null(tmp_path): + """Schema-13 fixture uses the frozen production header and asset DDL.""" + path = str(tmp_path / "schema13.sqlite3") + with sqlite3.connect(path) as db: + db.executescript(""" + CREATE TABLE schema_meta(key TEXT PRIMARY KEY,value TEXT NOT NULL); + INSERT INTO schema_meta VALUES('schema_version','13'); + CREATE TABLE asset_snapshots ( + id TEXT PRIMARY KEY, basis_date TEXT NOT NULL, source_type TEXT NOT NULL, + source_id TEXT NOT NULL DEFAULT '', name TEXT NOT NULL, amount INTEGER, + currency TEXT NOT NULL DEFAULT 'KRW', liquidity_bucket TEXT NOT NULL DEFAULT 'other', + valuation_source TEXT NOT NULL DEFAULT '', source_note TEXT NOT NULL DEFAULT '', + memo TEXT NOT NULL DEFAULT '', snapshot_run_id TEXT NOT NULL DEFAULT '', + correction_of_run_id TEXT NOT NULL DEFAULT '', correction_reason TEXT NOT NULL DEFAULT '', + is_correction INTEGER NOT NULL DEFAULT 0, closed_at TEXT NOT NULL DEFAULT '', + valuation_id TEXT NOT NULL DEFAULT '', selection_policy TEXT NOT NULL DEFAULT 'exact', + verification_status TEXT NOT NULL DEFAULT 'unverified', evidence_kind TEXT NOT NULL DEFAULT 'applied', + exclusion_reason TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL + ); + CREATE TABLE asset_snapshot_runs ( + id TEXT PRIMARY KEY, basis_date TEXT NOT NULL, close_status TEXT NOT NULL, + selection_policy TEXT NOT NULL DEFAULT 'exact', idempotency_key TEXT NOT NULL DEFAULT '', + approved_by TEXT NOT NULL DEFAULT '', approved_at TEXT NOT NULL DEFAULT '', + approval_reason TEXT NOT NULL DEFAULT '', approval_reference TEXT NOT NULL DEFAULT '', + correction_of_run_id TEXT NOT NULL DEFAULT '', correction_reason TEXT NOT NULL DEFAULT '', + applied_evidence TEXT NOT NULL DEFAULT '[]', excluded_evidence TEXT NOT NULL DEFAULT '[]', + carried_evidence TEXT NOT NULL DEFAULT '[]', request_fingerprint TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, draft_id TEXT NOT NULL DEFAULT '', draft_version INTEGER NOT NULL DEFAULT 0, + review_fingerprint TEXT NOT NULL DEFAULT '' + ); + INSERT INTO asset_snapshot_runs(id,basis_date,close_status,created_at) VALUES('legacy-run','2026-09-30','complete_exact','then'); + INSERT INTO asset_snapshots(id,basis_date,source_type,source_id,name,amount,created_at,snapshot_run_id) VALUES('legacy-row','2026-09-30','manual_asset','synthetic','Synthetic',42,'then','legacy-run'); + """) + init_db(path) + _schema(path) + with sqlite3.connect(path) as db: + columns = {r[1] for r in db.execute("PRAGMA table_info(asset_snapshot_runs)")} + assert "draft_id" in columns and "income_total" in columns + first = db.execute("SELECT id,basis_date,close_status,created_at,income_total,expense_total,net_cashflow,total_assets,total_liabilities,net_worth FROM asset_snapshot_runs WHERE id='legacy-run'").fetchone() + assert first[:4] == ("legacy-run", "2026-09-30", "complete_exact", "then") + assert first[4:] == (None, None, None, None, None, None) + old = db.execute("SELECT id,basis_date,source_type,source_id,name,amount,snapshot_run_id FROM asset_snapshots").fetchall() + init_db(path) + with sqlite3.connect(path) as db: + assert db.execute("SELECT id,basis_date,close_status,created_at,income_total,expense_total,net_cashflow,total_assets,total_liabilities,net_worth FROM asset_snapshot_runs WHERE id='legacy-run'").fetchone() == first + assert db.execute("SELECT id,basis_date,source_type,source_id,name,amount,snapshot_run_id FROM asset_snapshots").fetchall() == old + assert db.execute("SELECT COUNT(*) FROM settlement_cashflow_snapshots").fetchone()[0] == 0 diff --git a/tests/test_settlement_draft_audit_reproductions.py b/tests/test_settlement_draft_audit_reproductions.py index a98af57..a68243d 100644 --- a/tests/test_settlement_draft_audit_reproductions.py +++ b/tests/test_settlement_draft_audit_reproductions.py @@ -18,7 +18,7 @@ def test_empty_draft_persists_without_closing_and_has_consistent_public_shape(tm assert draft['status'] == 'active' and draft['version'] == 1 assert draft['basis_date'] == '2026-09-30' and draft['correction_of_run_id'] == '' assert result['items'] == [] and not result['review']['complete'] - assert result['review']['totals'] == {'total_assets': 0, 'total_liabilities': 0, 'net_worth': 0} + assert result['review']['totals'] == {'total_assets': 0, 'total_liabilities': 0, 'net_worth': 0, 'income_total': 0, 'expense_total': 0, 'net_cashflow': 0} assert ok(s.get_settlement_draft(draft['id'], db_path=path)) == result with sqlite3.connect(path) as conn: assert conn.execute('SELECT COUNT(*) FROM settlement_drafts').fetchone()[0] == 1 diff --git a/tests/test_settlement_draft_contract_scenarios.py b/tests/test_settlement_draft_contract_scenarios.py index ca92ac0..cfe3591 100644 --- a/tests/test_settlement_draft_contract_scenarios.py +++ b/tests/test_settlement_draft_contract_scenarios.py @@ -107,7 +107,21 @@ def test_transaction_readiness_is_independent_of_asset_draft_close(tmp_path): with sqlite3.connect(path) as c: original = c.execute('SELECT * FROM transactions').fetchall() d = ok(s.create_settlement_draft('2026-09', 'creator', 'initial', db_path=path))['draft'] - review = approve(path, d) + review = ok(s.review_settlement_draft(d['id'], db_path=path)) + assert review['totals']['total_assets'] == 100 + assert not review['complete'] and review['cashflow_blockers'] + blocked = s.approve_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'synthetic reviewer', 'reason', 'ref', db_path=path) + assert not blocked['success'] + with sqlite3.connect(path) as c: + assert c.execute('SELECT COUNT(*) FROM asset_snapshot_runs').fetchone()[0] == 0 + policy = {'savings_categories': [], 'category_decisions': [{'category': '미분류', 'subcategory': '', 'cashflow_kind': 'excluded', 'reason': 'synthetic explicit exclusion'}]} + updated = s.set_settlement_cashflow_policy(d['id'], d['version'], 'synthetic reviewer', policy, db_path=path) + assert updated['success'] + d = updated['draft'] + assert d['version'] == 2 + review = ok(s.review_settlement_draft(d['id'], db_path=path)) + assert review['complete'] + ok(s.approve_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'synthetic reviewer', 'reason', 'ref', db_path=path)) final = ok(s.finalize_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'final', db_path=path)) after = ok(s.get_monthly_settlement_summary('2026-09', db_path=path)) assert final['totals']['net_worth'] == 100 and after['source_coverage']['assets']['is_closed_snapshot'] diff --git a/tests/test_settlement_draft_docs.py b/tests/test_settlement_draft_docs.py index 5de5e90..5c68c6d 100644 --- a/tests/test_settlement_draft_docs.py +++ b/tests/test_settlement_draft_docs.py @@ -19,9 +19,9 @@ def test_settlement_requirement_revision_is_additive_and_traceable() -> None: _, raw, body = source.split("---", 2) metadata = json.loads(raw) assert metadata["id"] == "REQ-003" - assert metadata["revision"] == "1.1" - assert metadata["issue"] == "#15" - assert metadata["implementation_pr"] == "https://gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp/pulls/16" + assert metadata["revision"] == "1.2" + assert metadata["issue"] == "#18" + assert metadata["implementation_pr"] == "미정" or re.fullmatch(r"https://gitea\.jsfamily\.quest/jsfamily/jarca-ledger-mcp/pulls/\d+|#\d+", metadata["implementation_pr"]) assert "#12" in body and "#13" in body assert re.findall(r"^### REQ-003\.(\d+) —", body, re.M) == [f"{i:02d}" for i in range(1, 12)] assert re.findall(r"^## (\d+)\. ", body, re.M) == [str(i) for i in range(1, 12)] @@ -50,13 +50,19 @@ def test_new_public_operations_are_fully_listed_from_contract() -> None: assert "review_fingerprint" in interface and "selection_mode" in interface +def test_comprehensive_migration_acceptance_has_explicit_legacy_test_mapping() -> None: + text = REQ.read_text(encoding="utf-8") + case = text.split("### REQ-003.11-AC26 —", 1)[1].split("### REQ-003.11-AC27 —", 1)[0] + assert "`tests/test_settlement_cashflow_schema.py::test_legacy_migration_is_idempotent_and_keeps_unstored_totals_null`" in case + + def test_settlement_acceptance_cases_are_individually_named_and_real() -> None: import ast req = REQ.read_text(encoding="utf-8").split("---", 2)[2] section = req.split("## 7. 인수 기준", 1)[1].split("## 8. 오류", 1)[0] cases = re.findall(r"^### REQ-003\.11-AC(\d+) — (.+)$", section, re.M) - assert len(cases) == 17 - assert [n for n, _ in cases] == [f"{i:02}" for i in range(1, 18)] + assert len(cases) == 27 + assert [n for n, _ in cases] == [f"{i:02}" for i in range(1, 28)] files = {str(p.relative_to(ROOT)): p for p in (ROOT / "tests").glob("test_*.py")} for block in re.split(r"(?=^### REQ-003\.11-AC)", section, flags=re.M)[1:]: ref = re.search(r"`(tests/[^`]+\.py)::(test_[A-Za-z0-9_]+)`", block) diff --git a/tests/test_settlement_draft_policy_consistency.py b/tests/test_settlement_draft_policy_consistency.py index e06e995..b681ce8 100644 --- a/tests/test_settlement_draft_policy_consistency.py +++ b/tests/test_settlement_draft_policy_consistency.py @@ -266,7 +266,7 @@ def test_legacy_amount_approval_requires_fresh_review_after_policy_correction(tm reviewed = approve(path, d) assert reviewed["review_fingerprint"] != legacy_fingerprint final = ok(s.finalize_settlement_draft(d["id"], d["version"], reviewed["review_fingerprint"], "legacy-close", db_path=path)) - assert final["totals"] == {"total_assets": 100, "total_liabilities": 40, "net_worth": 60} + assert final["totals"] == {"total_assets": 100, "total_liabilities": 40, "net_worth": 60, "income_total": 0, "expense_total": 0, "net_cashflow": 0} assert ok(s.get_settlement_draft(d["id"], db_path=path))["final_response"] == final assert ok(s.finalize_settlement_draft(d["id"], d["version"], reviewed["review_fingerprint"], "legacy-close", db_path=path)) == final assert frozen_items(path, d["id"]) == frozen_before -- 2.54.0 From 616bb7a744bf5cb200f5aa367a0555db49bd84ed Mon Sep 17 00:00:00 2001 From: gitea-impl-agent Date: Tue, 13 Oct 2026 05:08:07 +0000 Subject: [PATCH 2/5] docs: bind settlement requirement to PR #19 --- docs/requirements/req-003_account-valuation-history.md | 4 ++-- tests/test_requirement_docs.py | 4 ++-- tests/test_settlement_draft_docs.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/requirements/req-003_account-valuation-history.md b/docs/requirements/req-003_account-valuation-history.md index 3ba89bd..ce8835d 100644 --- a/docs/requirements/req-003_account-valuation-history.md +++ b/docs/requirements/req-003_account-valuation-history.md @@ -9,7 +9,7 @@ "applies_to": ["ledger", "dashboard", "mcp"], "language": "ko", "issue": "#18", - "implementation_pr": "미정" + "implementation_pr": "https://gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp/pulls/19" } --- @@ -868,7 +868,7 @@ REQ-001의 auth gate·cookie/cleanup/logout, REQ-002의 메뉴 10개와 order(` - 관련 설계 문서: `docs/data-model.md`, `docs/interface-contract.md`, `docs/project-structure.md`, `docs/runtime-flow.md` - 관련 요구사항: `REQ-001`, `REQ-002` -### Issue #18 종합 정산 확장 (revision 1.2; 구현 PR 미정) +### Issue #18 종합 정산 확장 (revision 1.2; 구현 PR #19) 기존 요구사항·AC 식별자 및 공통 섹션은 유지하고 REQ-003.11만 확장한다. 월 수입·지출은 활성 거래의 [월초, 다음 월초) 범위, 자산·부채는 기존 월말 기준이다. 초안 정책은 `draft.cashflow_policy`에 저장하며 savings는 사용자가 명시한 category/subcategory pair만 인정한다. 생활비·선택 savings·`debt_payment`은 expense; 일반 transfer와 voucher top-up 제외가 우선하고 `investment` 전체 포함은 금지한다. Refund는 환급월 원래 지출 분류에 signed negative expense로 기록하며 과거 run을 수정하지 않는다. Unresolved unknown은 승인 blocker이며 asset conditional flag로 우회할 수 없고, 확정 제외는 blocker가 아니다. KRW only, no FX. diff --git a/tests/test_requirement_docs.py b/tests/test_requirement_docs.py index db4926a..67abe8a 100644 --- a/tests/test_requirement_docs.py +++ b/tests/test_requirement_docs.py @@ -17,7 +17,7 @@ def test_requirement_docs_are_canonical_and_traceable() -> None: text = REQ.read_text(encoding="utf-8") _, raw, body = text.split("---", 2) metadata = json.loads(raw) - assert metadata["implementation_pr"] == "미정" or re.fullmatch(r"https://gitea\.jsfamily\.quest/jsfamily/jarca-ledger-mcp/pulls/\d+|#\d+", metadata["implementation_pr"]) + assert metadata["implementation_pr"] == "https://gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp/pulls/19" assert metadata == { "schema": "gitea-agent.requirement/v1", "id": "REQ-003", @@ -28,7 +28,7 @@ def test_requirement_docs_are_canonical_and_traceable() -> None: "applies_to": ["ledger", "dashboard", "mcp"], "language": "ko", "issue": "#18", - "implementation_pr": metadata["implementation_pr"], + "implementation_pr": "https://gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp/pulls/19", } assert re.search(r"^# REQ-003 — ", body, re.M) assert "${" not in text diff --git a/tests/test_settlement_draft_docs.py b/tests/test_settlement_draft_docs.py index 5c68c6d..0d0f025 100644 --- a/tests/test_settlement_draft_docs.py +++ b/tests/test_settlement_draft_docs.py @@ -21,7 +21,7 @@ def test_settlement_requirement_revision_is_additive_and_traceable() -> None: assert metadata["id"] == "REQ-003" assert metadata["revision"] == "1.2" assert metadata["issue"] == "#18" - assert metadata["implementation_pr"] == "미정" or re.fullmatch(r"https://gitea\.jsfamily\.quest/jsfamily/jarca-ledger-mcp/pulls/\d+|#\d+", metadata["implementation_pr"]) + assert metadata["implementation_pr"] == "https://gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp/pulls/19" assert "#12" in body and "#13" in body assert re.findall(r"^### REQ-003\.(\d+) —", body, re.M) == [f"{i:02d}" for i in range(1, 12)] assert re.findall(r"^## (\d+)\. ", body, re.M) == [str(i) for i in range(1, 12)] -- 2.54.0 From ae4383ca2cf5cf576d9b370f6d62828b616d9418 Mon Sep 17 00:00:00 2001 From: gitea-impl-agent Date: Tue, 13 Oct 2026 12:31:47 +0000 Subject: [PATCH 3/5] 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. --- dashboard/page_views/monthly_settlement.py | 16 +++- dashboard/page_views/settlement_drafts.py | 8 +- docs/data-model.md | 47 +++++++++ docs/interface-contract.md | 41 ++++++++ docs/project-structure.md | 4 + .../test_comprehensive_settlement_adapters.py | 96 +++++++++++++++++++ tests/test_settlement_draft_docs.py | 87 +++++++++++++++++ 7 files changed, 296 insertions(+), 3 deletions(-) diff --git a/dashboard/page_views/monthly_settlement.py b/dashboard/page_views/monthly_settlement.py index 17afae0..a56fb3c 100644 --- a/dashboard/page_views/monthly_settlement.py +++ b/dashboard/page_views/monthly_settlement.py @@ -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) diff --git a/dashboard/page_views/settlement_drafts.py b/dashboard/page_views/settlement_drafts.py index 66f24dc..5285960 100644 --- a/dashboard/page_views/settlement_drafts.py +++ b/dashboard/page_views/settlement_drafts.py @@ -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("대체 평가자료를 포함한 월정산") diff --git a/docs/data-model.md b/docs/data-model.md index 91d5d10..0d5d6a8 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -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. diff --git a/docs/interface-contract.md b/docs/interface-contract.md index 01cbbff..62543da 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -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":"","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. diff --git a/docs/project-structure.md b/docs/project-structure.md index fd46dae..17316aa 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -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. diff --git a/tests/test_comprehensive_settlement_adapters.py b/tests/test_comprehensive_settlement_adapters.py index 62b4b4e..9dc6f8e 100644 --- a/tests/test_comprehensive_settlement_adapters.py +++ b/tests/test_comprehensive_settlement_adapters.py @@ -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 diff --git a/tests/test_settlement_draft_docs.py b/tests/test_settlement_draft_docs.py index 0d0f025..2d6d8f1 100644 --- a/tests/test_settlement_draft_docs.py +++ b/tests/test_settlement_draft_docs.py @@ -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 -- 2.54.0 From 10201cdb7573546daaa11a4e3df2a8f628a1493e Mon Sep 17 00:00:00 2001 From: gitea-impl-agent Date: Tue, 13 Oct 2026 23:17:12 +0000 Subject: [PATCH 4/5] Preserve inactive-category fallback and saved settlement header evidence --- dashboard/page_views/monthly_settlement.py | 2 + docs/interface-contract.md | 15 ++ .../application/monthly_settlement_service.py | 5 +- .../settlement_cashflow_service.py | 2 +- tests/test_comprehensive_settlement.py | 134 ++++++++++++++++++ .../test_comprehensive_settlement_adapters.py | 38 +++++ tests/test_settlement_cashflow_policy.py | 64 +++++++++ 7 files changed, 258 insertions(+), 2 deletions(-) diff --git a/dashboard/page_views/monthly_settlement.py b/dashboard/page_views/monthly_settlement.py index a56fb3c..3d3854c 100644 --- a/dashboard/page_views/monthly_settlement.py +++ b/dashboard/page_views/monthly_settlement.py @@ -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 "미보존") 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)]) + 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 {} 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 [] diff --git a/docs/interface-contract.md b/docs/interface-contract.md index 62543da..f2dbe22 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -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. +#### 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): ```json diff --git a/jarca_ledger_mcp/application/monthly_settlement_service.py b/jarca_ledger_mcp/application/monthly_settlement_service.py index ddd8b0f..0c083e2 100644 --- a/jarca_ledger_mcp/application/monthly_settlement_service.py +++ b/jarca_ledger_mcp/application/monthly_settlement_service.py @@ -248,7 +248,10 @@ def get_monthly_settlement_snapshot(month: str = "", run_id: str = "", db_path: if not isinstance(policy, dict): raise ValueError() 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: return fail("db_not_initialized", "ledger database is not initialized") except Exception: diff --git a/jarca_ledger_mcp/application/settlement_cashflow_service.py b/jarca_ledger_mcp/application/settlement_cashflow_service.py index 313dff2..ff56502 100644 --- a/jarca_ledger_mcp/application/settlement_cashflow_service.py +++ b/jarca_ledger_mcp/application/settlement_cashflow_service.py @@ -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, lc.active AS category_active 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 Date: Wed, 14 Oct 2026 04:44:28 +0000 Subject: [PATCH 5/5] Reject malformed persisted settlement header evidence Preserve safe shared error envelopes and read-only legacy defaults. Add exact/latest evidence-integrity regressions for service, gateway and MCP consumers. --- .../application/monthly_settlement_service.py | 2 + tests/test_comprehensive_settlement.py | 75 +++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/jarca_ledger_mcp/application/monthly_settlement_service.py b/jarca_ledger_mcp/application/monthly_settlement_service.py index 0c083e2..de90063 100644 --- a/jarca_ledger_mcp/application/monthly_settlement_service.py +++ b/jarca_ledger_mcp/application/monthly_settlement_service.py @@ -205,6 +205,8 @@ def get_monthly_settlement_snapshot(month: str = "", run_id: str = "", db_path: if selected_month and basis_date[:7] != selected_month: return fail("validation_error", "run does not match settlement month") legacy = True + if metadata.get("evidence_integrity_error"): + return fail("data_integrity_error", "snapshot evidence is malformed", snapshot_run_id=rid) keys = set(run.keys()) if run else set() def field(name): return run[name] if run and name in keys else None from .settlement_cashflow_service import _AGGREGATION_VERSION diff --git a/tests/test_comprehensive_settlement.py b/tests/test_comprehensive_settlement.py index 4cc8c7b..d06c416 100644 --- a/tests/test_comprehensive_settlement.py +++ b/tests/test_comprehensive_settlement.py @@ -610,3 +610,78 @@ def test_saved_evidence_privacy_contract_filters_keys_not_allowlisted_scalar_val 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 + + +@pytest.mark.parametrize("field", ["excluded_evidence", "carried_evidence", "applied_evidence"]) +@pytest.mark.parametrize("corrupt", ["malformed_json", "wrong_json_object"]) +@pytest.mark.parametrize("lookup", ["exact", "latest"]) +def test_saved_reader_rejects_corrupt_header_evidence_without_writes(tmp_path, monkeypatch, field, corrupt, lookup): + path = dbfile(tmp_path) + saved = ok(close_draft(path, new_draft(path))) + marker = "SYNTHETIC_CORRUPT_EVIDENCE_MARKER" + payload = "{" + marker if corrupt == "malformed_json" else json.dumps({"private": marker}) + with sqlite3.connect(path) as conn: + columns = {row[1] for row in conn.execute("PRAGMA table_info(asset_snapshot_runs)")} + assert field in columns, f"required settlement evidence column missing: {field}" + conn.execute(f"UPDATE asset_snapshot_runs SET {field}=? WHERE id=?", (payload, saved["run_id"])) + conn.commit() + before = list(conn.iterdump()) + + query = {"run_id": saved["run_id"]} if lookup == "exact" else {"month": "2026-09"} + result = service.get_monthly_settlement_snapshot(db_path=path, **query) + expected = {"success": False, "error_code": "data_integrity_error", "error": "snapshot evidence is malformed", "snapshot_run_id": saved["run_id"]} + assert result == expected + from jarca_ledger_mcp.adapters.streamlit import settlement_gateway + gateway = settlement_gateway.get_monthly_settlement_snapshot(db_path=path, **query) + assert gateway == result + from jarca_ledger_mcp.adapters.mcp import server + monkeypatch.setenv("JARCA_LEDGER_DB_PATH", path) + tool = server.mcp._tool_manager._tools["get_monthly_settlement_snapshot"] + assert tool.fn(**query) == result + assert service.get_asset_snapshot_report("2026-09-30", db_path=path) == expected + assert marker not in json.dumps(result) + assert not ({"run_id", "totals", "income_total", "source_snapshot", "cashflow_items", "excluded_items", "carried_items", "applied_evidence"} & result.keys()) + with sqlite3.connect(path) as conn: + assert list(conn.iterdump()) == before + + +@pytest.mark.parametrize("field", ["excluded_evidence", "carried_evidence", "applied_evidence"]) +def test_saved_reader_accepts_empty_header_evidence_without_changing_legacy_api_shapes(tmp_path, field): + path = dbfile(tmp_path) + saved = ok(close_draft(path, new_draft(path))) + with sqlite3.connect(path) as conn: + columns = {row[1] for row in conn.execute("PRAGMA table_info(asset_snapshot_runs)")} + assert field in columns, f"required settlement evidence column missing: {field}" + conn.execute(f"UPDATE asset_snapshot_runs SET {field}='[]' WHERE id=?", (saved["run_id"],)) + conn.commit() + with sqlite3.connect(path) as conn: + 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 + public_field = {"excluded_evidence": "excluded_items", "carried_evidence": "carried_items", "applied_evidence": "applied_evidence"}[field] + assert exact[public_field] == [] + assert exact["source"] == "settlement_snapshot" and exact["is_comprehensive"] is True + cashflow = ok(service.get_monthly_cashflow_report("2026-09", db_path=path)) + assert "run_id" not in cashflow and "totals" not in cashflow and "cashflow_items" not in cashflow + with sqlite3.connect(path) as conn: + assert list(conn.iterdump()) == before + + +def test_schema_eleven_legacy_reader_returns_empty_evidence_without_writes(tmp_path): + from test_legacy_snapshot_migration import _legacy_closed_db + path, _ = _legacy_closed_db(tmp_path) + with sqlite3.connect(path) as conn: + assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0 + before = list(conn.iterdump()) + exact = ok(service.get_monthly_settlement_snapshot(run_id="legacy-run-correction", db_path=path)) + latest = ok(service.get_monthly_settlement_snapshot(month="2026-06", db_path=path)) + # Headerless legacy correction metadata already differs by lookup mode; + # this rework preserves that separate existing behavior rather than changing it. + for result in (exact, latest): + assert result["legacy"] is True and result["is_comprehensive"] is False + assert result["source_snapshot"] == {} + assert result["excluded_items"] == [] and result["carried_items"] == [] and result["applied_evidence"] == [] + assert all(value is None for value in result["totals"].values()) + with sqlite3.connect(path) as conn: + assert list(conn.iterdump()) == before -- 2.54.0