diff --git a/dashboard/page_views/assets.py b/dashboard/page_views/assets.py index 0a0c63b..a143061 100644 --- a/dashboard/page_views/assets.py +++ b/dashboard/page_views/assets.py @@ -8,6 +8,7 @@ import streamlit as st from jarca_ledger_mcp.adapters.streamlit import assets_gateway from jarca_ledger_mcp.adapters.streamlit import overview_gateway +from dashboard.page_views.settlement_drafts import render_settlement_drafts from dashboard.common import * # noqa: F403, F405 @@ -153,3 +154,6 @@ def page_assets(db_path: str) -> None: st.json({k: close_res.get(k) for k in ["basis_date", "source", "is_closed_snapshot", "snapshot_run_id", "correction_of_run_id", "correction_reason", "closed_at", "net_worth"]}) else: st.error(close_res.get("error", close_res)) + + st.divider() + render_settlement_drafts(db_path=db_path) diff --git a/dashboard/page_views/monthly_settlement.py b/dashboard/page_views/monthly_settlement.py index 1eb10c1..004cfbe 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 dashboard.page_views.settlement_drafts import render_settlement_drafts from dashboard.common import * # noqa: F403, F405 @@ -265,5 +266,8 @@ def page_monthly_settlement(db_path: str) -> None: for action in action_candidates: st.markdown(f"- {action}") + st.divider() + render_settlement_drafts(db_path=db_path, month=selected_month) + st.divider() st.markdown("- [요약 페이지로 이동](?page=요약)") diff --git a/dashboard/page_views/settlement_drafts.py b/dashboard/page_views/settlement_drafts.py new file mode 100644 index 0000000..c9323b0 --- /dev/null +++ b/dashboard/page_views/settlement_drafts.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +import streamlit as st +from jarca_ledger_mcp.adapters.streamlit import settlement_gateway as gateway + + +def _safe_result(result: dict) -> None: + if result.get("success"): + st.success("요청을 처리했습니다.") + else: + # Never expose exception details, arbitrary evidence, or backend secrets. + st.error(f"요청을 처리하지 못했습니다 ({result.get('error_code', 'operation_failed')}).") + + +def _invalidate_and_rerun(draft_id: str, result: dict) -> None: + st.session_state[f"settlement_mutation_feedback_{draft_id}"] = { + "success": bool(result.get("success")), + "error_code": result.get("error_code", "operation_failed"), + "memo_saved": bool(result.get("memo_saved")), + } + st.session_state.pop(f"review_result_{draft_id}", None) + st.session_state.pop(f"approved_{draft_id}", None) + st.session_state.pop("settlement_bound_state", None) + st.rerun() + + +def render_settlement_drafts(db_path: str | None = None, month: str = "") -> None: + st.header("월정산 초안 lifecycle") + st.caption("자산 마감과 거래 정산 상태는 별도입니다. 조회/렌더링은 쓰기를 수행하지 않습니다.") + month_value = month or st.text_input("초안 월", value="2026-09", key="settlement_month") + actor = st.text_input("작업자", value="operator", key="settlement_actor") + with st.form("settlement_create"): + memo = st.text_input("메모") + idem = st.text_input("생성 키", value="dashboard-create") + correction_run = st.text_input("정정 대상 run ID (선택)") + correction_reason = st.text_input("정정 사유") + create = st.form_submit_button("초안 생성 / 정정 초안") + if create: + _safe_result(gateway.create_settlement_draft(month_value, actor, idem, memo=memo, + correction_of_run_id=correction_run, correction_reason=correction_reason, db_path=db_path)) + + listed = gateway.list_settlement_drafts(month_value, db_path=db_path) + drafts = listed.get("drafts", []) if listed.get("success") else [] + if not listed.get("success"): + st.error(f"초안 목록을 불러오지 못했습니다 ({listed.get('error_code', 'operation_failed')}).") + if not isinstance(drafts, list): drafts = [] + ids = [str(d.get("id", "")) for d in drafts if isinstance(d, dict) and d.get("id")] + selected_id = st.selectbox("초안 선택", ids, key="settlement_draft_id") if ids else "" + if not selected_id: + if listed.get("success"): + st.info("초안이 없습니다. 생성은 명시적 버튼으로 수행됩니다.") + st.caption("완료 초안은 이력에서 불변으로 조회하고, 정정은 이전 마감 ID와 사유를 지정해 새 초안으로 만듭니다.") + st.caption("목록은 활성·완료·취소 이력을 포함합니다. 평가자료 조회·등록·정정은 초안 변경과 별도 작업입니다.") + st.button("검토", disabled=True, key="settlement_review_disabled") + st.button("승인", disabled=True, key="settlement_approve_disabled") + st.button("최종 마감", disabled=True, key="settlement_finalize_disabled") + st.button("초안 취소", disabled=True, key="settlement_cancel_disabled") + st.button("평가자료 조회 / 등록 / 정정", disabled=True, key="settlement_valuation_disabled") + st.caption("제외 사유는 항목별로 명시 입력합니다.") + st.caption("거래 정산 상태(transaction readiness)는 자산 마감 준비와 별도입니다.") + return + detail = gateway.get_settlement_draft(selected_id, db_path=db_path) + feedback = st.session_state.pop(f"settlement_mutation_feedback_{selected_id}", None) + if isinstance(feedback, dict): + if feedback.get("memo_saved"): + st.warning("메모는 저장되었지만 항목 변경은 실패했습니다") + _safe_result(feedback) + draft = detail.get("draft", {}) if detail.get("success") else {} + if not isinstance(draft, dict): draft = {} + version = draft.get("version") + draft_version = version + fingerprint = detail.get("review", {}).get("review_fingerprint", "") if isinstance(detail.get("review"), dict) else "" + state_key = "settlement_bound_state" + bound = (selected_id, version, fingerprint) + previous = st.session_state.get(state_key) + if previous != bound: + st.session_state[state_key] = bound + st.session_state.pop(f"review_result_{selected_id}", None) + st.session_state.pop(f"approved_{selected_id}", None) + st.caption(f"ID {draft.get('id', selected_id)} · 월 {draft.get('month', month_value)} · 기준일 {draft.get('basis_date', '-')} · 상태 {draft.get('status', '-')} · 버전 {version} · draft_version={draft_version}") + st.caption(f"review_fingerprint={fingerprint or '미검토'}") + st.caption(f"생성자 {draft.get('created_by', '-')} · 생성 {draft.get('created_at', '-')} · 변경 {draft.get('updated_at', '-')} · 메모 {draft.get('memo', '')}") + if draft.get("status") == "completed": + st.success(f"완료 — run {draft.get('final_run_id', '-')}; 변경 불가, 정정은 새 초안으로 생성합니다.") + if draft.get("status") in {"completed", "cancelled"}: + st.markdown("### 불변 이력") + st.write("final_run_id", draft.get("final_run_id")) + st.write("correction_of_run_id", draft.get("correction_of_run_id")) + st.json(detail.get("items", [])) + st.caption("종료된 초안의 증거는 수정하지 않습니다. 정정이 필요하면 이전 마감과 사유를 연결해 새 초안을 생성하세요.") + return + items = detail.get("items", []) if isinstance(detail.get("items"), list) else [] + for item in items: + if not isinstance(item, dict): continue + st.write(f"{item.get('source_type', '')} {item.get('source_id', '')} · {item.get('status', '')} · 평가 {item.get('valuation_date', '-')}") + basis, actual = item.get("basis_date", draft.get("basis_date", "")), item.get("valuation_date", "") + if basis and actual: + try: + from datetime import date + difference = (date.fromisoformat(actual) - date.fromisoformat(basis)).days + st.caption(f"기준일 대비 날짜 차이: {difference:+}일 ({'이후 대체' if difference > 0 else '이전' if difference < 0 else '동일'})") + except ValueError: + st.caption("날짜 차이를 계산할 수 없습니다.") + if item.get("status") == "missing": st.warning("누락 — 값은 0원이 아닙니다.") + with st.form(f"settlement_edit_{selected_id}"): + memo_edit = st.text_input("초안 메모 저장", value=draft.get("memo", "")) + source_type = st.selectbox("대상 유형", ["account", "manual_asset"]) + source_id = st.text_input("대상 ID") + valuation_id = st.text_input("평가자료 ID (비우면 해제)") + mode = st.selectbox("선택 방식", ["exact", "carried_before", "manual_substitute"]) + selection_reason = st.text_input("선택 사유") + included = st.checkbox("포함", value=True) + exclusion_reason = st.text_input("제외 사유") + save = st.form_submit_button("저장 / 지정 / 포함·제외") + if save: + result = gateway.update_settlement_draft(selected_id, version, actor, memo_edit, db_path=db_path) + current_version = (result.get("draft") or {}).get("version", version) if result.get("success") else version + memo_updated = bool(result.get("success")) + if result.get("success") and source_id: + result = gateway.set_settlement_draft_item(selected_id, source_type, source_id, current_version, actor, + valuation_id=valuation_id, selection_mode=mode, selection_reason=selection_reason, + included=included, exclusion_reason=exclusion_reason, db_path=db_path) + if not result.get("success"): + result = {**result, "memo_saved": True} + if memo_updated or result.get("success"): + _invalidate_and_rerun(selected_id, result) + _safe_result(result) + + with st.form(f"valuation_{selected_id}"): + account_id = st.text_input("평가 등록 계좌 ID") + valuation_date = st.text_input("평가일 YYYY-MM-DD") + amount = st.number_input("금액", value=0, step=1) + currency = st.text_input("통화", value="KRW") + valuation_kind = st.selectbox("평가 종류", ["balance", "total_value", "cash_balance", "loan", "loan_balance"]) + liability_definition = st.selectbox("대표 부채 정의", ["", "principal_only", "principal_interest_fees"]) + source_ref = st.text_input("증빙 참조 ID") + verification = st.selectbox("확인 상태", ["unverified", "verified", "rejected"]) + valuation_memo = st.text_input("평가 메모") + registration_key = st.text_input("등록 idempotency key") + register = st.form_submit_button("평가자료 등록") + if register: + result = gateway.register_account_valuation(account_id, valuation_date, int(amount), currency=currency, + valuation_kind=valuation_kind, source_ref=source_ref, verification_status=verification, + memo=valuation_memo, idempotency_key=registration_key, + representative_liability_definition=liability_definition or None, db_path=db_path) + if result.get("success") and any(item.get("source_id") == account_id for item in items if isinstance(item, dict)): + _invalidate_and_rerun(selected_id, result) + _safe_result(result) + if st.button("평가자료 조회", key=f"valuation_list_{selected_id}") and source_id: + vals = gateway.list_account_valuations(source_id, db_path=db_path) + if vals.get("success"): + st.json([{k: value.get(k) for k in ("id", "account_id", "basis_date", "recorded_at", "amount", "currency", "valuation_kind", "verification_status", "source_ref", "supersedes_id", "representative_liability_definition")} for value in vals.get("valuations", [])]) + else: _safe_result(vals) + with st.form(f"valuation_correct_{selected_id}"): + valuation_id_correct = st.text_input("정정할 valuation ID") + corrected_amount = st.number_input("정정 금액", value=0, step=1) + correction_reason = st.text_input("평가 정정 사유") + correction_key = st.text_input("평가 정정 키") + correction_verification = st.selectbox("정정 확인 상태", ["unverified", "verified", "rejected"]) + correction_source_ref = st.text_input("정정 증빙 참조 ID") + correct = st.form_submit_button("평가자료 정정") + if correct: + result = gateway.correct_account_valuation(valuation_id_correct, int(corrected_amount), correction_reason, + idempotency_key=correction_key, verification_status=correction_verification, + source_ref=correction_source_ref, db_path=db_path) + corrected_account_id = (result.get("valuation") or {}).get("account_id", "") + if result.get("success") and any(item.get("source_id") == corrected_account_id for item in items if isinstance(item, dict)): + _invalidate_and_rerun(selected_id, result) + _safe_result(result) + review_result = None + if st.button("검토 / 새로고침 대상", key=f"review_{selected_id}"): + review_result = gateway.review_settlement_draft(selected_id, db_path=db_path) + st.session_state[f"review_result_{selected_id}"] = review_result + if st.button("대상 명시적 갱신", key=f"refresh_{selected_id}"): + result = gateway.refresh_settlement_draft_targets(selected_id, version, actor, db_path=db_path) + if result.get("success"): + _invalidate_and_rerun(selected_id, result) + _safe_result(result) + 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("missing_items", [])) + if review.get("contains_substitutions"): + st.warning("대체 평가자료를 포함한 월정산") + fingerprint = review.get("review_fingerprint", "") + st.caption(f"현재 승인 대상 fingerprint: {fingerprint}") + st.session_state[state_key] = (selected_id, review.get("version", version), fingerprint) + with st.form(f"approve_{selected_id}"): + approval_reason = st.text_input("승인 사유") + approval_reference = st.text_input("승인 근거") + allow_missing = st.checkbox("누락 승인", value=False) + allow_carried = st.checkbox("이월 승인", value=False) + allow_substitute = st.checkbox("대체자료 승인", value=False) + approve = st.form_submit_button("검토 결과 승인") + if approve: + if st.session_state.get(state_key) != (selected_id, review.get("version", version), fingerprint): + st.error("선택한 초안/버전/검토 fingerprint가 변경되었습니다. 다시 검토하세요.") + return + _safe_result(gateway.approve_settlement_draft(selected_id, version, fingerprint, actor, + approval_reason, approval_reference, allow_missing, allow_carried, allow_substitute, db_path=db_path)) + if st.button("최종 마감", key=f"finalize_{selected_id}"): + if st.session_state.get(state_key) != (selected_id, review.get("version", version), fingerprint): + st.error("선택한 초안/버전/검토 fingerprint가 변경되었습니다. 다시 검토하세요.") + return + result = gateway.finalize_settlement_draft(selected_id, version, fingerprint, + f"dashboard-finalize-{selected_id}-{version}", db_path=db_path) + if result.get("success"): + _invalidate_and_rerun(selected_id, result) + _safe_result(result) + with st.form(f"cancel_{selected_id}"): + cancel_reason = st.text_input("취소 사유") + cancel = st.form_submit_button("초안 취소") + if cancel: + result = gateway.cancel_settlement_draft(selected_id, version, actor, cancel_reason, db_path=db_path) + if result.get("success"): + _invalidate_and_rerun(selected_id, result) + _safe_result(result) diff --git a/docs/data-model.md b/docs/data-model.md index 7234106..704712e 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -174,3 +174,22 @@ - 오류 데이터: invalid currency/kind, missing loan definition, idempotency/correction conflict - 저장 후 재조회: service list/select/report and contract regression tests - 마이그레이션: `test_*schema*` tests plus `python -m pytest tests/test_account_valuations.py tests/test_asset_snapshots.py` + +## settlement-drafts — 월정산 초안 + +Schema 13 adds two additive tables; `init_db()`/`migrate_db()` remain the only schema writers and schema 11→12 history is unchanged. No draft backfill is inferred. Exact migrated columns (from `infrastructure/sqlite/migrations.py`) are listed below; `SCHEMA_SQL` and migration DDL are the schema authority. + +| Table | Columns (SQLite types; `?` means nullable) | Indexes / constraints | +|---|---|---| +| `settlement_drafts` | `id TEXT PK`, `month TEXT NOT NULL`, `basis_date TEXT NOT NULL`, `status TEXT NOT NULL`, `version INTEGER NOT NULL`, `created_by TEXT NOT NULL`, `created_at TEXT NOT NULL`, `updated_at TEXT NOT NULL`, `memo TEXT DEFAULT ''`, `create_key TEXT UNIQUE NOT NULL`, `correction_of_run_id TEXT DEFAULT ''`, `correction_reason TEXT DEFAULT ''`, `final_run_id TEXT DEFAULT ''`, `final_response TEXT DEFAULT ''`, `cancelled_by TEXT DEFAULT ''`, `cancellation_reason TEXT DEFAULT ''`, `cancelled_at TEXT DEFAULT ''`, `approval_fingerprint TEXT DEFAULT ''`, `approval_version INTEGER DEFAULT 0`, `approved_by TEXT DEFAULT ''`, `approved_at TEXT DEFAULT ''`, `approval_reason TEXT DEFAULT ''`, `approval_reference TEXT DEFAULT ''`, `approval_flags TEXT DEFAULT '{}'`, additive `original_request_hash TEXT DEFAULT ''` | `idx_settlement_drafts_active_month(month) WHERE status='active'` unique; `idx_settlement_drafts_month(month,created_at)` | +| `settlement_draft_items` | `id TEXT PK`, `draft_id TEXT NOT NULL FK`, `source_type TEXT NOT NULL`, `source_id TEXT NOT NULL`, `valuation_id TEXT DEFAULT ''`, `selection_mode TEXT DEFAULT 'exact'`, `selection_reason TEXT DEFAULT ''`, `included INTEGER DEFAULT 1`, `exclusion_reason TEXT DEFAULT ''`, `version INTEGER DEFAULT 1`, `selected_by TEXT DEFAULT ''`, `selected_at TEXT DEFAULT ''`, `evidence_json TEXT DEFAULT '{}'` | `UNIQUE(draft_id,source_type,source_id)` | + +`settlement_drafts` therefore owns generated draft ID, YYYY-MM month, fixed month-end `basis_date`, active/completed/cancelled status, positive version, creator/timestamps/memo, create idempotency key+request fingerprint, correction parent/reason, approval actor/time/reason/reference/flags/fingerprint/version, final run ID and persisted response. The partial unique active-month constraint is shared by normal/correction drafts. `settlement_draft_items` owns stable target type/ID, inclusion/reason, selected valuation and selection mode/reason, item version/audit, frozen source evidence/hash and correction-child identity. Monetary authority is derived from eligible frozen evidence, not editable amount columns. + +Modes are `exact`, `carried_before`, and `manual_substitute`. `exact` requires confirmed same-day evidence; `carried_before` requires a verified prior valuation and reason; `manual_substitute` requires explicit valuation ID, reason and review/approval even when valuation date is future. Preserve `date_difference_days = valuation_date - basis_date`; `age_days` retains its historical basis-minus-valuation meaning. Unverified candidates remain visible with `candidate_amount` but authority amount NULL. Missing is distinct from substitution/exclusion. Manual evidence freezes semantic fields and hash; only explicit refresh/reselection changes it. + +Frozen/public evidence retains `valuation_date`, original `recorded_at`, derived `date_difference_direction` (`after`/`before`/`same`/`unknown`) and `age_days`; loan evidence retains `representative_liability_definition`. `recorded_at` may be later than month end while `valuation_date` is exactly month end. These names are preserved through finalized response, reports, timeseries and monthly coverage; date differences and semantic hashes are derived from frozen JSON rather than separate draft-item columns. Actor/version/timestamps of approval are cleared together on draft mutation. + +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. diff --git a/docs/interface-contract.md b/docs/interface-contract.md index 4304427..5602738 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -424,7 +424,7 @@ Writer/bootstrap explicitly calls `init_db()`. `read_db()` uses SQLite `mode=ro` ## D04 snapshot close output contract -Every successful snapshot close output has these exact top-level fields (the transport envelope may additionally include `success`): `snapshot_run_id`, `close_status`, `approval`, `correction`, `warnings`, `excluded_items`, `carried_items`, `provenance`, `valuation_date`, `age_days`, `verification_status`, `evidence_kind`, and `monthly_source_snapshot_id`. `monthly_source_snapshot_id` is an alias of `snapshot_run_id`, not a persistence field. +Every successful **legacy direct close** output has these exact top-level fields (the transport envelope may additionally include `success`); this field list does not define the additive settlement-draft finalize response: `snapshot_run_id`, `close_status`, `approval`, `correction`, `warnings`, `excluded_items`, `carried_items`, `provenance`, `valuation_date`, `age_days`, `verification_status`, `evidence_kind`, and `monthly_source_snapshot_id`. `monthly_source_snapshot_id` is an alias of `snapshot_run_id`, not a persistence field. The top-level `provenance` object has these exact nested `provenance` fields: `valuation_date`, `age_days`, `carried_forward`, `verification_status`, `evidence_kind`, `approval`, `correction`, `warnings`, `excluded_items`, and `carried_items`. `approval` and `correction` are objects or explicit empty values when not applicable; warnings and item collections are arrays. `valuation_date` is the actual selected evidence date, `age_days` is computed from the requested basis date, and verification/evidence status describe selected evidence. Live, closed, timeseries, and monthly read models preserve these fields and alias semantics. @@ -433,3 +433,32 @@ The preview input includes basis_date, selection_policy, included/excluded evide Replay uses replay payload equality: an existing idempotency key replays only when the persisted request fingerprint is equal; a changed payload returns `idempotency_conflict`. The replay response is reconstructed from the persisted run and its evidence, with no replay-only reused fields. `close_metadata` is a recursive close metadata allowlist: only run, approval, correction, warnings, exclusions, carried evidence, and allowlisted evidence fields (`source_type`, `source_id`, `authority_source`, `valuation_id`, dates/statuses and `state_hash`) cross the boundary. `applied_evidence`, `excluded_evidence`, and `carried_evidence` are redacted recursively; unknown nested keys are dropped, and credentials, tokens, cookies, raw account data, and arbitrary source-note payloads are never returned. This is the recursive close_metadata allowlist and redaction contract. + +## settlement-draft-service — 월정산 초안 API (Issue #15) + +Common application owner: `jarca_ledger_mcp/application/settlement_draft_service.py`; re-export through `jarca_ledger_mcp.service`; service and gateway signatures below include internal `db_path`, which is omitted from MCP tool inputSchema. All other MCP-exposed parameters retain their documented names, defaults, and requiredness. MCP exposes the same operation names, not the same signatures; Dashboard uses existing gateway layering. Draft finalize adds draft-specific response fields while preserving common aliases; it is not a claim that its response has the legacy direct-close shape. Each returns the existing `success`/`error_code`/`error` envelope. Dashboard auth gate precedes DB access; this adds no MCP authentication claim. + +```python +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) +list_settlement_drafts(month: str = "", db_path: str | None = None) +get_settlement_draft(draft_id: str, db_path: str | None = None) +update_settlement_draft(draft_id: str, expected_version: int, updated_by: str, memo: str, db_path: str | None = None) +refresh_settlement_draft_targets(draft_id: str, expected_version: int, updated_by: str, db_path: str | None = None) +set_settlement_draft_item(draft_id: str, source_type: str, source_id: str, expected_version: int, selected_by: str, valuation_id: str = "", selection_mode: str = "exact", selection_reason: str = "", included: bool = True, exclusion_reason: str = "", db_path: str | None = None) +review_settlement_draft(draft_id: str, db_path: str | None = None) +approve_settlement_draft(draft_id: str, expected_version: int, review_fingerprint: str, approved_by: str, approval_reason: str, approval_reference: str, allow_missing_exclusions: bool = False, allow_carried_before: bool = False, allow_manual_substitute: bool = False, db_path: str | None = None) +finalize_settlement_draft(draft_id: str, expected_version: int, review_fingerprint: str, idempotency_key: str, db_path: str | None = None) +cancel_settlement_draft(draft_id: str, expected_version: int, cancelled_by: str, reason: str, db_path: str | None = None) +``` + +Create validates strict YYYY-MM and month-end date, nonempty actor/key and payload replay equality; same key+payload returns same draft, changed payload or active duplicate conflicts. Versions are positive strict integers (bool/fraction/None rejected); inclusion flags actual bool. `review` is read-only and returns draft/version/fingerprint, items and included/excluded/missing/warnings, approval_required, provisional totals, completeness, substitution and unregistered-liability flags. `candidate_amount` may be shown for unverified evidence but authority amount NULL. Approval binds fingerprint+version and explicit flags. Finalize requires approval even for exact complete data; same request replays the persisted response, conflicting key/payload errors, and failed transaction leaves no partial state. + +`read_db()` remains SQLite `mode=ro`: missing DB returns `db_not_initialized`, no-create/no mkdir/no migrate/no commit; initialized read failures return `db_error`. Existing auth cookie/logout, MCP transport/scope, menu, query, shell and browser behavior are unchanged. Existing D04 close aliases and recursive metadata allowlist/redaction remain authoritative. + +### Draft evidence public fields + +`review.included_items`, finalized `items`, asset report `close_metadata.applied_evidence`, timeseries point `applied_evidence`, and monthly `source_coverage.assets.applied_evidence` preserve the same selected-evidence field names: `valuation_date` (actual valuation date), `recorded_at` (original recording timestamp, not the basis date), `date_difference_days = valuation_date - basis_date`, `date_difference_direction` (`after`, `before`, `same`, or `unknown`), and `age_days` (basis-minus-valuation days; negative for a future substitute). A loan additionally preserves `representative_liability_definition`; selection audit includes `selection_mode`, `selection_reason`, `selected_by`, `selected_at`, `item_version`, and draft/fingerprint lineage. These are derived or frozen response fields, not additional database columns. + +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. diff --git a/docs/project-structure.md b/docs/project-structure.md index 7278dbf..8c19cf4 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -210,3 +210,17 @@ The owner is the canonical decision or write boundary; consumers render or read The Dashboard gateway is `jarca_ledger_mcp/adapters/streamlit/overview_gateway.py` (including `get_asset_snapshot_report` and reconciliation/monthly read delegates). It calls shared application read services after `auth-before-DB`; page rendering is not an alternate policy or persistence boundary. A missing DB has stable unavailable semantics (`db_not_initialized`) and no-create behavior. The missing DB stable unavailable contract is no-create. An initialized DB read failure is `db_error`; initialized read has no write, migration, mkdir, or commit. The initialized DB read failure is db_error and initialized read has no write. Reconciliation ownership remains in `reconciliation_service.py`: opening evidence and transaction coverage are required inputs. Missing opening evidence or incomplete coverage produces `INCOMPLETE_CALCULATION`/`reconciliation unavailable` with provenance, never an inferred balance or fake transaction. + +## Issue #15 settlement draft ownership + +| Path | Ownership / boundary | +|---|---| +| `jarca_ledger_mcp/application/settlement_draft_service.py` | Shared business validation, frozen selection/review/approval/finalize; owns no UI or transport behavior. | +| `jarca_ledger_mcp/application/monthly_settlement_service.py` | Read-only monthly aggregate; never draft mutation or transaction readiness write. | +| `jarca_ledger_mcp/application/assets_service.py` | Existing close/report authority; draft finalization does not call live-select close as shortcut. | +| `jarca_ledger_mcp/infrastructure/sqlite/migrations.py` | Additive schema 12→13 migration; schema initializer and migration constraints agree. | +| `jarca_ledger_mcp/adapters/mcp/server.py` | Thin same-name/same-signature MCP tools; no duplicate business logic, no new auth claim. | +| `jarca_ledger_mcp/adapters/streamlit/` | Existing Dashboard gateway boundary; auth-before-DB, explicit user actions only. | +| `tests/test_monthly_settlement_drafts.py` | Synthetic DB lifecycle, failure injection, replay and no-write regressions. | + +Never document invented `jarca_ledger_mcp.mcp` or `monthly.py` modules. `transaction readiness` is separate from asset completeness and monthly reads remain read-only. diff --git a/docs/requirements/req-003_account-valuation-history.md b/docs/requirements/req-003_account-valuation-history.md index 2a7be4d..506987c 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.0", + "revision": "1.1", "status": "approved", "depends_on": [], "applies_to": ["ledger", "dashboard", "mcp"], "language": "ko", - "issue": "#12", - "implementation_pr": "#13" + "issue": "#15", + "implementation_pr": "미정" } --- @@ -21,7 +21,7 @@ ## 2. 배경 -구현은 SQLite schema 12를 schema 11에 additive하게 적용한다. 계좌 잔액·import 결과는 실제 증거가 없는 한 `unverified` 후보이고, 기존 거래·인증·메뉴 계약을 바꾸지 않는다. +구현은 SQLite schema 13를 schema 11에 additive하게 적용한다. 계좌 잔액·import 결과는 실제 증거가 없는 한 `unverified` 후보이고, 기존 거래·인증·메뉴 계약을 바꾸지 않는다. ## 3. 범위 @@ -52,7 +52,7 @@ ### REQ-003.01 — additive 이력과 legacy 보존 -schema 11의 테이블·필드·거래를 삭제하거나 추측 이주하지 않고 schema 12의 `account_valuations`, `asset_snapshot_runs`를 추가하며 `asset_snapshots`에 필요한 필드를 additive하게 확장한다. migration은 idempotent다. +schema 11의 테이블·필드·거래를 삭제하거나 추측 이주하지 않고 schema 13의 `account_valuations`, `asset_snapshot_runs`를 추가하며 `asset_snapshots`에 필요한 필드를 additive하게 확장한다. migration은 idempotent다. **영향 범위** - 인터페이스: 기존 service/MCP 응답 envelope 유지, 신규 valuation/snapshot fields additive @@ -172,9 +172,21 @@ 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) + +월말 기준일의 durable draft는 자료가 없어도 생성되며 생성 자체로 run/rows를 만들지 않는다. 월별 활성 초안은 일반·정정 공통으로 하나다. 대상 구성은 명시적 refresh만 반영하고 검토는 frozen evidence의 version/fingerprint에 묶인다. Finalize는 drift를 거절하고 `BEGIN IMMEDIATE`에서 run, rows, completion/link를 함께 기록하며 동일 요청은 persisted response로 replay한다. + +**영향 범위** +- 인터페이스: shared settlement draft service, MCP tools, authenticated Dashboard gateway; monthly summary read-only +- 영속성: schema 12→13 additive `settlement_drafts`, `settlement_draft_items`; legacy 11→12 보존 +- 후속 사용 주체: Dashboard operator, MCP caller, close reports/timeseries/monthly read model +- 마이그레이션: additive/idempotent; historical drafts backfill 없음 +- 호환성: existing valuation/auth/menu/query/shell/MCP contracts 유지 +- 배포 전제 조건: explicit writer init, SQLite transaction, synthetic tests + ## 6. 제약과 불변조건 -- verified leaf만 authority이며 future/unverified/rejected/unknown은 합산하지 않는다. +- Automatic `exact`/`as_of` selection and legacy direct close exclude future, unverified, rejected, and unknown valuations from authority. The sole future-valuation exception is an explicitly selected, verified `manual_substitute` in a settlement draft, with reason, review, approval, and original valuation/basis dates preserved; a future valuation is never chosen automatically. Unverified, rejected, or unknown amounts are never authority on any path. - 동일 idempotency payload는 같은 ID를 반환하고 다른 payload는 conflict다. - correction과 re-close는 append-only이고 parent당 child/run은 하나다. - NULL과 0을 구별하고 모든 금액은 integer precision이다. @@ -186,13 +198,353 @@ MCP와 Dashboard는 shared service/gateway를 사용한다. 기존 success/error ## 7. 인수 기준 +### REQ-003.11-AC01 — 빈 월 초안 영속 생성 + +**판정 기준** + +자료가 없어도 월말 기준일의 초안이 저장되고 생성만으로 snapshot run/rows가 생기지 않는다. + +**검증** + +`tests/test_monthly_settlement_drafts.py::test_empty_draft_persists_without_closing_and_has_consistent_public_shape` + +**검증 분류** + +- 수준: integration +- 백엔드: sqlite +- 케이스: 정상 empty draft; 경계 no targets; 실패 invalid month + +**상태 불변조건** + +Create는 close side effect가 없다. + +### REQ-003.11-AC02 — 월말 기준일 및 create replay + +**판정 기준** + +월말만 허용하고 같은 key/payload는 idempotent, 다른 payload는 충돌한다. + +**검증** + +`tests/test_monthly_settlement_drafts.py::test_basis_must_be_calendar_month_end_and_create_key_is_idempotent` + +**검증 분류** + +- 수준: integration +- 백엔드: sqlite +- 케이스: normal month-end; boundary leap month; failure non-month-end + +**상태 불변조건** + +거절된 create는 초안을 추가하지 않는다. + +### REQ-003.11-AC03 — 월별 단일 active 초안과 correction link + +**판정 기준** + +Normal/correction 초안을 합쳐 월별 active는 하나이며 취소 후 월 재개가 가능하다. + +**검증** + +`tests/test_monthly_settlement_drafts.py::test_one_active_draft_across_normal_and_correction_and_cancellation_reopens_month` + +**검증 분류** + +- 수준: integration +- 백엔드: sqlite +- 케이스: normal competing create; boundary correction lineage; failure second active + +**상태 불변조건** + +Correction lineage는 명시적이고 유일하다. + +### REQ-003.11-AC04 — 입력·버전·행위자 검증 + +**판정 기준** + +잘못된 값과 stale version을 거절하고 include/exclude 사유 및 actor를 기록한다. + +**검증** + +`tests/test_monthly_settlement_drafts.py::test_input_validation_versions_actors_and_include_exclude_reason` + +**검증 분류** + +- 수준: integration +- 백엔드: sqlite +- 케이스: normal valid mutation; boundary version; failure invalid type + +**상태 불변조건** + +거절된 mutation은 상태와 version을 변경하지 않는다. + +### REQ-003.11-AC05 — 명시적 target refresh와 read-only review + +**판정 기준** + +대상 membership은 refresh 전 고정되고 review는 읽기 전용이다. + +**검증** + +`tests/test_monthly_settlement_drafts.py::test_targets_are_frozen_until_explicit_refresh_and_review_is_read_only` + +**검증 분류** + +- 수준: integration +- 백엔드: sqlite +- 케이스: normal refresh; boundary changed sources; failure implicit refresh + +**상태 불변조건** + +Review는 DB 상태를 쓰지 않는다. + +### REQ-003.11-AC06 — exact, carry, manual substitute 날짜 구분 + +**판정 기준** + +선택 모드는 valuation date와 basis date 차이를 보존하며 future substitute는 명시적이다. + +**검증** + +`tests/test_monthly_settlement_drafts.py::test_explicit_future_substitute_and_late_recorded_exact_are_distinguished` + +**검증 분류** + +- 수준: integration +- 백엔드: sqlite +- 케이스: normal exact; boundary future substitute; failure implicit future choice + +**상태 불변조건** + +`date_difference_days = valuation_date - basis_date`를 보존한다. + +### REQ-003.11-AC07 — unverified 후보는 authority 아님 + +**판정 기준** + +Unverified 후보 금액은 표시 가능하나 authority amount는 NULL이며 verified child는 재선택을 요구한다. + +**검증** + +`tests/test_monthly_settlement_drafts.py::test_unverified_candidate_has_no_authority_and_verified_child_requires_reselection` + +**검증 분류** + +- 수준: integration +- 백엔드: sqlite +- 케이스: normal verified selection; boundary candidate; failure candidate authority + +**상태 불변조건** + +NULL은 0이나 verified 금액으로 대체되지 않는다. + +### REQ-003.11-AC08 — 수동 evidence drift와 future 차단 + +**판정 기준** + +수동 source 변경은 명시적 refresh가 필요하고 future evidence가 자동 선택되지 않는다. + +**검증** + +`tests/test_monthly_settlement_drafts.py::test_manual_sources_drift_requires_explicit_refresh_and_future_not_auto_selected` + +**검증 분류** + +- 수준: integration +- 백엔드: sqlite +- 케이스: normal unchanged source; boundary drift; failure auto-select future + +**상태 불변조건** + +Frozen source hash는 refresh 전까지 불변이다. + +### REQ-003.11-AC09 — 승인 fingerprint 및 drift gate + +**판정 기준** + +Mutation/source drift는 approval을 무효화하고 finalize를 막는다. + +**검증** + +`tests/test_monthly_settlement_drafts.py::test_approval_fingerprint_mutation_and_external_drift_block_finalize` + +**검증 분류** + +- 수준: integration +- 백엔드: sqlite +- 케이스: normal unchanged approval; boundary drift; failure stale finalize + +**상태 불변조건** + +Finalize는 승인된 version/fingerprint만 받는다. + +### REQ-003.11-AC10 — 원자적 finalize 및 persisted replay + +**판정 기준** + +Finalize는 immutable provenance를 저장하고 동일 요청에 persisted response를 반환한다. + +**검증** + +`tests/test_monthly_settlement_drafts.py::test_finalize_persists_immutable_provenance_replays_exact_response_and_conflicts_key` + +**검증 분류** + +- 수준: integration +- 백엔드: sqlite +- 케이스: normal finalize; boundary replay; failure conflicting key + +**상태 불변조건** + +Replay는 저장된 결과를 재구성하며 중복 run을 만들지 않는다. + +### REQ-003.11-AC11 — DB 실패 rollback + +**판정 기준** + +SQLite write 실패 시 run, rows, completed 상태가 모두 rollback된다. + +**검증** + +`tests/test_monthly_settlement_drafts.py::test_sqlite_trigger_failure_rolls_back_run_rows_and_completion` + +**검증 분류** + +- 수준: integration +- 백엔드: sqlite +- 케이스: normal successful commit; boundary trigger failure; failure mid-transaction + +**상태 불변조건** + +부분 마감 결과는 남지 않는다. + +### REQ-003.11-AC12 — additive schema 13 migration + +**판정 기준** + +Migration은 legacy rows를 보존하고 반복 실행에 안전하다. + +**검증** + +`tests/test_monthly_settlement_drafts.py::test_schema13_migration_is_additive_and_idempotent` + +**검증 분류** + +- 수준: integration +- 백엔드: sqlite +- 케이스: normal new DB; boundary schema 12; failure repeated migration + +**상태 불변조건** + +Schema 11→12 의미와 데이터는 보존된다. + +### REQ-003.11-AC13 — 동시 create와 stale finalize + +**판정 기준** + +동시 create는 active uniqueness를 지키고 stale version finalize는 거절한다. + +**검증** + +`tests/test_monthly_settlement_drafts.py::test_concurrent_create_and_stale_version_finalization_are_safe` + +**검증 분류** + +- 수준: integration +- 백엔드: sqlite +- 케이스: normal sequential mutation; boundary concurrent create; failure stale finalize + +**상태 불변조건** + +한 달에 active 초안은 최대 하나다. + +### REQ-003.11-AC14 — draft adapter public contract + +**판정 기준** + +Gateway/MCP는 실제 service signature와 결과를 그대로 노출한다. + +**검증** + +`tests/test_settlement_draft_adapters.py::test_gateway_exports_exact_contract_signature_and_delegates` + +**검증 분류** + +- 수준: integration +- 백엔드: application +- 케이스: normal delegation; boundary signature; failure service error + +**상태 불변조건** + +Adapter는 authority 판단이나 응답 변형을 하지 않는다. + +### REQ-003.11-AC15 — Dashboard auth 및 render 경계 + +**판정 기준** + +인증은 DB 접근 전 수행되고 render는 lifecycle mutation을 하지 않는다. + +**검증** + +`tests/test_settlement_draft_adapters.py::test_dashboard_authentication_entrypoint_precedes_database_resolution` + +**검증 분류** + +- 수준: integration +- 백엔드: dashboard +- 케이스: normal authenticated read; boundary unauthenticated; failure DB access before auth + +**상태 불변조건** + +인증 없는 요청은 DB에 접근하지 않는다. + +### REQ-003.11-AC16 — missing DB read no-create + +**판정 기준** + +Read-only gateway missing database를 생성하거나 migrate하지 않는다. + +**검증** + +`tests/test_settlement_draft_adapters.py::test_read_only_gateway_calls_do_not_create_missing_database` + +**검증 분류** + +- 수준: integration +- 백엔드: sqlite +- 케이스: normal initialized read; boundary missing DB; failure implicit create + +**상태 불변조건** + +Read path는 no mkdir, no migrate, no commit이다. + +### REQ-003.11-AC17 — legacy contract regression + +**판정 기준** + +Valuation correction, monthly summary read-only, menu/auth compatibility regressions remain green. + +**검증** + +`tests/test_settlement_draft_adapters.py::test_service_reexports_every_lifecycle_operation` + +**검증 분류** + +- 수준: integration +- 백엔드: application +- 케이스: normal legacy call; boundary old schema; failure incompatible export + +**상태 불변조건** + +New draft operations are additive and legacy entrypoints remain available. + ### REQ-003.01-AC01 — migration and preservation -**Cases (정상/경계/실패)**: 정상 schema 12 init twice; 경계 schema 11 legacy DB; 실패 malformed legacy DB. +**Cases (정상/경계/실패)**: 정상 schema 13 init twice; 경계 schema 11 legacy DB; 실패 malformed legacy DB. **State Invariants**: legacy tables/rows remain; migration repeats without duplicate columns. -**판정 기준**: `schema_version=12`, required tables/columns/indexes exist and old rows remain. +**판정 기준**: `schema_version=13`, required tables/columns/indexes exist and old rows remain. **검증**: `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 diff --git a/docs/runtime-flow.md b/docs/runtime-flow.md index c534272..5c9e352 100644 --- a/docs/runtime-flow.md +++ b/docs/runtime-flow.md @@ -239,3 +239,21 @@ The fingerprint input is exactly basis date, selection policy, included and excl An idempotent close is replayed only for equal request fingerprints. Replay reconstructs the response from the persisted run/evidence (no replay-only reused payload), while a different payload returns `idempotency_conflict`. Recursive close metadata is allowlisted and redacted before it reaches Dashboard/MCP. Dashboard missing-DB reads return stable unavailable `db_not_initialized` without create/migrate/commit. The missing DB stable unavailable contract is no-create. Initialized read failures return `db_error`; successful initialized reads are read-only. The initialized DB read failure is db_error and initialized read has no write. Reconciliation requires opening evidence and coverage through the requested basis; otherwise it reports `INCOMPLETE_CALCULATION`/`reconciliation unavailable` with provenance, no inferred balance, and no fake transaction. + +## settlement-draft-create — 월정산 초안 lifecycle (Issue #15) + +### create → edit → review → approve → finalize + +| 단계 ID | 실행 주체 | 처리 / guard | 상태 경계 | 실패 전이 | +|---|---|---|---|---| +| `settlement-draft-create` | adapter/service | Strict month/date/actor/key validation; explicit correction prior run/reason; acquire `BEGIN IMMEDIATE`; idempotent equal replay; enforce one active draft/month. | Persist draft and initial target items only; no snapshot run/rows/close. | validation/conflict; rollback | +| `settlement-draft-edit` | shared service | Expected version; targets change only through explicit refresh; item include/exclude and valuation selection audited; mutation clears approval. | Increment draft/item versions; retain history/reasons. | stale version or invalid scope, no write | +| `settlement-draft-review` | shared service | Read frozen source/valuation evidence, warning and correction-child drift; compute review fingerprint/totals/missing; no auto future selection. | Read-only; unverified candidate_amount with authority amount NULL. | drift/missing warnings; no authority mutation | +| `settlement-draft-approve` | authorized caller | Require matching current fingerprint/version and explicit actor/reason/reference/flags. | Persist approval binding and flags. | mismatch/unsafe unknown/rejected evidence rejected | +| `settlement-draft-finalize` | shared service/SQLite | Idempotency persisted-response lookup first; then active/version/approval/fingerprint/drift/correction-parent checks; insert run, rows and completed/link within one `BEGIN IMMEDIATE`. | Atomic immutable close and draft completion. | conflict or injected DB failure rolls back all writes | + +Exact requires same-date verified evidence; carried_before uses verified prior with reason; manual_substitute requires explicit ID and reason, with signed `date_difference_days = valuation_date - basis_date`. `age_days` keeps its existing direction/meaning. Corrected valuation children are never followed automatically: explicit reselection then fresh review/approval. Missing and substitution remain orthogonal; manual evidence drift requires explicit refresh. Existing row-only legacy close uses established latest row-group ordering and available run identity; never infer a registry row or fall back to live authority. + +### failure/recovery and read-only guards + +Draft create/update/finalize use transaction-scoped writes; any DB failure rolls back run, rows and completion. Same finalize request returns persisted response, not a replay-only `reused` field; different request conflicts. Monthly summary and review do not mutate; transaction readiness remains independent. `read_db()` missing path is `db_not_initialized`, with no-create/no mkdir/no migrate/no commit. Dashboard gate precedes every protected DB access; existing auth cookie lifecycle and exact menu/query/shell/browser semantics remain unchanged. diff --git a/jarca_ledger_mcp/adapters/mcp/server.py b/jarca_ledger_mcp/adapters/mcp/server.py index 20028ab..bf010a0 100644 --- a/jarca_ledger_mcp/adapters/mcp/server.py +++ b/jarca_ledger_mcp/adapters/mcp/server.py @@ -443,3 +443,44 @@ def main() -> None: if __name__ == "__main__": main() + + +@mcp.tool() +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 = "") -> dict: + return contracts.create_settlement_draft(month, created_by, idempotency_key, basis_date, memo, correction_of_run_id, correction_reason) + +@mcp.tool() +def list_settlement_drafts(month: str = "") -> dict: + return contracts.list_settlement_drafts(month) + +@mcp.tool() +def get_settlement_draft(draft_id: str) -> dict: + return contracts.get_settlement_draft(draft_id) + +@mcp.tool() +def update_settlement_draft(draft_id: str, expected_version: int, updated_by: str, memo: str) -> dict: + return contracts.update_settlement_draft(draft_id, expected_version, updated_by, memo) + +@mcp.tool() +def refresh_settlement_draft_targets(draft_id: str, expected_version: int, updated_by: str) -> dict: + return contracts.refresh_settlement_draft_targets(draft_id, expected_version, updated_by) + +@mcp.tool() +def set_settlement_draft_item(draft_id: str, source_type: str, source_id: str, expected_version: int, selected_by: str, valuation_id: str = "", selection_mode: str = "exact", selection_reason: str = "", included: bool = True, exclusion_reason: str = "") -> dict: + return contracts.set_settlement_draft_item(draft_id, source_type, source_id, expected_version, selected_by, valuation_id, selection_mode, selection_reason, included, exclusion_reason) + +@mcp.tool() +def review_settlement_draft(draft_id: str) -> dict: + return contracts.review_settlement_draft(draft_id) + +@mcp.tool() +def approve_settlement_draft(draft_id: str, expected_version: int, review_fingerprint: str, approved_by: str, approval_reason: str, approval_reference: str, allow_missing_exclusions: bool = False, allow_carried_before: bool = False, allow_manual_substitute: bool = False) -> dict: + return contracts.approve_settlement_draft(draft_id, expected_version, review_fingerprint, approved_by, approval_reason, approval_reference, allow_missing_exclusions, allow_carried_before, allow_manual_substitute) + +@mcp.tool() +def finalize_settlement_draft(draft_id: str, expected_version: int, review_fingerprint: str, idempotency_key: str) -> dict: + return contracts.finalize_settlement_draft(draft_id, expected_version, review_fingerprint, idempotency_key) + +@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) diff --git a/jarca_ledger_mcp/adapters/mcp/tool_contracts.py b/jarca_ledger_mcp/adapters/mcp/tool_contracts.py index a9e3b72..f48b89f 100644 --- a/jarca_ledger_mcp/adapters/mcp/tool_contracts.py +++ b/jarca_ledger_mcp/adapters/mcp/tool_contracts.py @@ -1,6 +1,16 @@ from __future__ import annotations from ...service import ( + approve_settlement_draft as approve_settlement_draft, + cancel_settlement_draft as cancel_settlement_draft, + create_settlement_draft as create_settlement_draft, + finalize_settlement_draft as finalize_settlement_draft, + get_settlement_draft as get_settlement_draft, + list_settlement_drafts as list_settlement_drafts, + 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, + update_settlement_draft as update_settlement_draft, annotations as annotations, apply_categorization_rules as apply_categorization_rules, close_asset_snapshot as close_asset_snapshot, @@ -74,7 +84,21 @@ from ...service import ( update_manual_asset as update_manual_asset, ) +TOOL_CONTRACTS = { + name: globals()[name] + for 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", + ) +} + __all__ = [ + "approve_settlement_draft", "cancel_settlement_draft", "create_settlement_draft", + "finalize_settlement_draft", "get_settlement_draft", "list_settlement_drafts", + "refresh_settlement_draft_targets", "review_settlement_draft", + "set_settlement_draft_item", "update_settlement_draft", "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 new file mode 100644 index 0000000..970b8e5 --- /dev/null +++ b/jarca_ledger_mcp/adapters/streamlit/settlement_gateway.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import inspect + +from ...service import ( + approve_settlement_draft as approve_settlement_draft, + cancel_settlement_draft as cancel_settlement_draft, + correct_account_valuation as correct_account_valuation, + create_settlement_draft as create_settlement_draft, + finalize_settlement_draft as finalize_settlement_draft, + get_settlement_draft as get_settlement_draft, + 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, + update_settlement_draft as update_settlement_draft, +) +from ...application import settlement_draft_service as _draft_service +from ... import service as _service + + +def _resolve_db_path() -> str: + from ...db import resolve_db_path + return str(resolve_db_path()) + + +def _guard(db_path: str | None) -> str: + return db_path or _resolve_db_path() + + +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 + return getattr(target, fn.__name__)(*args, db_path=_guard(db_path), **kwargs) + call.__name__ = fn.__name__ + call.__doc__ = fn.__doc__ + call.__signature__ = inspect.signature(fn) + return call + + +# Keep the service signatures unchanged; path resolution/auth is adapter policy. +for _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", + "register_account_valuation", "list_account_valuations", "correct_account_valuation", +): + globals()[_name] = _wrap(getattr(_service, _name)) + +__all__ = [ + "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", + "register_account_valuation", "list_account_valuations", "correct_account_valuation", +] diff --git a/jarca_ledger_mcp/application/assets_service.py b/jarca_ledger_mcp/application/assets_service.py index 660c674..ba046d6 100644 --- a/jarca_ledger_mcp/application/assets_service.py +++ b/jarca_ledger_mcp/application/assets_service.py @@ -67,6 +67,18 @@ def _public_evidence(item: dict[str, Any], strict: bool = False) -> dict[str, An "valuation_id", "valuation_kind", "active", "last_synced_at", "last_balance", "valuation_amount", "reason", "unverified_candidates", "candidate", "candidates", "state_hash", "blocking", "snapshot_run_id", "is_correction", "correction_of_run_id", "correction_reason", + # Draft-close provenance is intentionally an exact allowlist. Nested + # objects are recursively filtered by these same keys. + "date_difference_days", "direction", "original_recorded_at", "selection_mode", + "date_difference_direction", "recorded_at", "representative_liability_definition", + "allow_missing_exclusions", "allow_carried_before", "allow_manual_substitute", + "reason", "selection_reason", "selected_by", "selected_at", "item_version", "source_ref", + "source_attributes", "approval", "approval_reference", "approved_by", + "approved_at", "approval_reason", "draft_id", "draft_version", + "review_fingerprint", "contains_substitutions", "contains_carried", + "has_approved_missing", "carried_forward", "source_type", "source_id", + "amount", "currency", "valuation_id", "valuation_date", "age_days", + "verification_status", "evidence_kind", "carried_forward", } if isinstance(item, dict): return {key: _public_evidence(value, strict) for key, value in item.items() if key in allowed or (not strict and isinstance(value, (dict, list)))} @@ -304,6 +316,9 @@ def _snapshot_rows_for_run(conn: sqlite3.Connection, run_id: str) -> tuple[list[ integrity_error = any(value is None for value in evidence.values()) metadata = { "snapshot_run_id": run["id"], + "draft_id": run["draft_id"] if "draft_id" in run.keys() else "", + "draft_version": run["draft_version"] if "draft_version" in run.keys() else 0, + "review_fingerprint": run["review_fingerprint"] if "review_fingerprint" in run.keys() else "", "is_correction": bool(run["correction_of_run_id"]), "correction_of_run_id": run["correction_of_run_id"] or "", "correction_reason": run["correction_reason"] or "", @@ -408,10 +423,18 @@ def _snapshot_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str, def _closed_run_metadata(metadata: dict[str, Any]) -> dict[str, Any]: """Expose immutable run evidence without source notes or memos.""" + applied = [_public_evidence(item, strict=True) for item in metadata.get("applied_evidence", [])] + excluded = [_public_evidence(item, strict=True) for item in metadata.get("excluded_evidence", [])] + carried = [_public_evidence(item, strict=True) for item in metadata.get("carried_evidence", [])] + contains_substitutions = any(item.get("selection_mode") == "manual_substitute" for item in applied) + contains_carried = bool(carried) or any(item.get("selection_mode") == "carried_before" or item.get("carried_forward") for item in applied) + has_approved_missing = any(item.get("reason") == "approved_missing" for item in excluded) + # Evidence is immutable: do not inject absent draft keys into legacy + # exclusions/carried rows. Draft finalization persists its own lineage. warnings = [] - exclusions = [_public_evidence(item, strict=True) for item in metadata.get("excluded_evidence", [])] + candidate_warnings: dict[tuple[str, str], dict[str, Any]] = {} - for item in exclusions: + for item in excluded: if item.get("candidate"): key = (item.get("source_type", ""), item.get("source_id", "")) warning_shape = {"code": "unverified_candidate", "source_type": key[0], "source_id": key[1], "candidates": []} @@ -424,9 +447,16 @@ def _closed_run_metadata(metadata: dict[str, Any]) -> dict[str, Any]: warnings.extend(candidate_warnings.values()) return { "warnings": warnings, - "excluded_items": exclusions, - "carried_items": [_public_evidence(item, strict=True) for item in metadata.get("carried_evidence", [])], + "excluded_items": excluded, + "applied_evidence": applied, + "carried_items": carried, + "contains_substitutions": contains_substitutions, + "contains_carried": contains_carried, + "has_approved_missing": has_approved_missing, "snapshot_run_id": metadata.get("snapshot_run_id", ""), + "draft_id": metadata.get("draft_id", ""), + "draft_version": metadata.get("draft_version", 0), + "review_fingerprint": metadata.get("review_fingerprint", ""), "close_status": metadata.get("close_status", ""), "selection_policy": metadata.get("selection_policy", ""), "approval": {} if metadata.get("legacy_closed") else {k: metadata.get(k, "") for k in ("approved_by", "approved_at", "approval_reason", "approval_reference")}, @@ -1104,6 +1134,13 @@ def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: i "source": "asset_snapshots", "item_count": len(items), "snapshot_run_id": metadata.get("snapshot_run_id", ""), + "draft_id": run_public.get("draft_id", ""), + "draft_version": run_public.get("draft_version", 0), + "review_fingerprint": run_public.get("review_fingerprint", ""), + "contains_substitutions": run_public.get("contains_substitutions", False), + "contains_carried": run_public.get("contains_carried", False), + "has_approved_missing": run_public.get("has_approved_missing", False), + "applied_evidence": run_public.get("applied_evidence", []), "close_status": metadata.get("close_status", ""), "selection_policy": metadata.get("selection_policy", ""), "warnings": run_public["warnings"], diff --git a/jarca_ledger_mcp/application/monthly_settlement_service.py b/jarca_ledger_mcp/application/monthly_settlement_service.py index 94be924..d3b624f 100644 --- a/jarca_ledger_mcp/application/monthly_settlement_service.py +++ b/jarca_ledger_mcp/application/monthly_settlement_service.py @@ -75,6 +75,13 @@ def _source_coverage( "excluded_items": asset_report.get("excluded_items", []), "carried_items": asset_report.get("carried_items", asset_report.get("included_items", [])), "monthly_source_snapshot_id": asset_report.get("snapshot_run_id", ""), + "draft_id": asset_report.get("close_metadata", {}).get("draft_id", ""), + "draft_version": asset_report.get("close_metadata", {}).get("draft_version", 0), + "review_fingerprint": asset_report.get("close_metadata", {}).get("review_fingerprint", ""), + "contains_substitutions": asset_report.get("close_metadata", {}).get("contains_substitutions", False), + "contains_carried": asset_report.get("close_metadata", {}).get("contains_carried", False), + "has_approved_missing": asset_report.get("close_metadata", {}).get("has_approved_missing", False), + "applied_evidence": asset_report.get("close_metadata", {}).get("applied_evidence", []), }, } diff --git a/jarca_ledger_mcp/application/settlement_draft_service.py b/jarca_ledger_mcp/application/settlement_draft_service.py new file mode 100644 index 0000000..ceae856 --- /dev/null +++ b/jarca_ledger_mcp/application/settlement_draft_service.py @@ -0,0 +1,396 @@ +from __future__ import annotations + +import calendar +import hashlib +import json +import sqlite3 +from datetime import date +from uuid import uuid4 + +from .shared import db, read_db, now_iso, ok, fail + + +def _valid_version(v): + return isinstance(v, int) and not isinstance(v, bool) and v > 0 + + +def _month_end(month): + if not isinstance(month, str) or len(month) != 7: + raise ValueError('month must be YYYY-MM') + y, m = map(int, month.split('-')) + if date(y, m, 1).strftime('%Y-%m') != month: + raise ValueError('month must be YYYY-MM') + return f'{month}-{calendar.monthrange(y,m)[1]:02d}' + + +def _err(code, message): return fail(code, message) + + +def _items(conn, draft_id): + rows = conn.execute('SELECT * FROM settlement_draft_items WHERE draft_id=? ORDER BY source_type,source_id', (draft_id,)).fetchall() + return [dict(r) for r in rows] + + +def _draft(conn, did): + r=conn.execute('SELECT * FROM settlement_drafts WHERE id=?',(did,)).fetchone() + return dict(r) if r else None + + +def _current_semantics(conn, item): + if item['source_type']=='account': + src=conn.execute('SELECT * FROM accounts WHERE id=?',(item['source_id'],)).fetchone() + val=conn.execute('SELECT * FROM account_valuations WHERE id=?',(item.get('valuation_id',''),)).fetchone() if item.get('valuation_id') else None + child=conn.execute('SELECT id FROM account_valuations WHERE supersedes_id=?',(item.get('valuation_id',''),)).fetchone() if item.get('valuation_id') else None + return {'source':dict(src) if src else None,'valuation':dict(val) if val else None,'child':dict(child) if child else None} + src=conn.execute('SELECT * FROM manual_assets WHERE id=?',(item['source_id'],)).fetchone() + return {'source':dict(src) if src else None} + + +def _nonempty(value): + return isinstance(value, str) and bool(value.strip()) + + +def _latest_close(conn, basis): + from .assets_service import _snapshot_rows_for_latest_run + _, metadata = _snapshot_rows_for_latest_run(conn, basis) + return metadata.get('snapshot_run_id', '') + + +def _freeze(conn, source_type, source_id, valuation_id=''): + return _current_semantics(conn, {'source_type': source_type, 'source_id': source_id, 'valuation_id': valuation_id}) + + +def _review(conn, draft): + stored = _items(conn, draft['id']) + items, included, excluded, missing, warnings, states = [], [], [], [], [], [] + for item in stored: + current = _current_semantics(conn, item) + frozen = json.loads(item.get('evidence_json') or '{}') + # No permissive fallback from malformed/absent frozen evidence to live authority. + source = frozen.get('source') + if source is None and item['source_type'] != 'account' and 'valuation_amount' in frozen: + source = frozen # development draft compatibility, not legacy run backfill + source = source or {} + row = {k: v for k, v in item.items() if k != 'evidence_json'} + row['item_version'] = item['version'] + row.update(source_type='account' if item['source_type'] == 'account' else 'manual_asset', + included=bool(item['included']), basis_date=draft['basis_date'], + name=source.get('name', ''), currency=source.get('currency', 'KRW'), + liquidity_bucket='loan' if source.get('type') == 'loan' else source.get('liquidity_bucket', 'other'), + authority_source='account_valuations' if item['source_type'] == 'account' else 'manual_assets') + row['is_liability'] = row['liquidity_bucket'] == 'loan' + row['state_hash'] = hashlib.sha256(json.dumps(frozen, sort_keys=True).encode()).hexdigest() + source_drift = source != (current.get('source') or {}) + value = frozen.get('valuation') if item['source_type'] == 'account' else source + drift = source_drift or (item['source_type'] == 'account' and value != current.get('valuation')) + child = current.get('child') + row.update(drift=drift, review_needed=bool(drift or child), amount=None, candidate_amount=None) + if child: + row['correction_child_id'] = child['id'] + warnings.append({'code': 'correction_child', 'source_id': item['source_id']}) + if drift: + warnings.append({'code': 'source_drift', 'source_id': item['source_id']}) + mode = item['selection_mode'] + if value: + actual = value.get('basis_date', '') if item['source_type'] == 'account' else value.get('valuation_date', '') + delta = (date.fromisoformat(actual) - date.fromisoformat(draft['basis_date'])).days if actual else None + candidate = value.get('amount') if item['source_type'] == 'account' else value.get('valuation_amount') + status = value.get('verification_status', '') if item['source_type'] == 'account' else 'verified' + row.update(valuation_date=actual, date_difference_days=delta, + date_difference_direction='after' if delta is not None and delta > 0 else 'before' if delta is not None and delta < 0 else 'same' if delta == 0 else 'unknown', + age_days=-delta if delta is not None else None, candidate_amount=candidate, + verification_status=status, recorded_at=value.get('recorded_at', ''), + source_ref=value.get('source_ref', ''), valuation_kind=value.get('valuation_kind', ''), + representative_liability_definition=value.get('representative_liability_definition'), + carried_forward=mode == 'carried_before', evidence_kind='manual_substitute' if mode == 'manual_substitute' else 'carried_before' if mode == 'carried_before' else 'exact') + eligible_date = delta == 0 if mode == 'exact' else delta is not None and delta < 0 if mode == 'carried_before' else item['source_type'] == 'account' and delta is not None + if status == 'verified' and eligible_date and source.get('active') and row['currency'] == 'KRW' and not row['review_needed']: + row['amount'] = candidate if row['included'] else None + elif item['source_type'] == 'account': + # Candidate visibility does not make it selected or authoritative. + candidate = conn.execute('SELECT amount FROM account_valuations WHERE account_id=? AND basis_date<=? ORDER BY recorded_at DESC,rowid DESC LIMIT 1', (item['source_id'], draft['basis_date'])).fetchone() + if candidate: + row['candidate_amount'] = candidate['amount'] + if row['currency'] != 'KRW': + warnings.append({'code': 'non_krw', 'source_id': item['source_id']}) + row['status'] = 'excluded' if not row['included'] else 'ready' if row['amount'] is not None else 'missing' + if row['status'] == 'missing': + row['reason'] = 'review_needed' if row['review_needed'] else 'missing_evidence' + missing.append(row) + elif row['status'] == 'excluded': + row['reason'] = row['exclusion_reason'] + excluded.append(row) + else: + included.append(row) + items.append(row) + # All candidate changes and source/selected evidence are bound privately; never exposed as raw JSON. + 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': current, 'candidates': candidates}) + assets = sum(x['amount'] for x in included if not x['is_liability']) + liabilities = sum(x['amount'] for x in included if x['is_liability']) + latest = _latest_close(conn, draft['basis_date']) + 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']}, sort_keys=True, ensure_ascii=False) + totals = {'total_assets': assets, 'total_liabilities': liabilities, 'net_worth': assets - liabilities} + return {'draft_id': draft['id'], 'version': draft['version'], 'review_fingerprint': hashlib.sha256(serial.encode()).hexdigest(), + '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, + '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), + 'has_approved_missing': bool(missing or excluded)} + + +def _result(conn,did): + d=_draft(conn,did) + if not d:return _err('not_found','settlement draft not found') + 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) + review = _review(conn, d) + return ok(draft={k: v for k, v in d.items() if k != 'final_response'}, 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): + try: + basis=_month_end(month) + if basis_date and basis_date != basis: raise ValueError('basis_date must be month end') + if not _nonempty(created_by) or not _nonempty(idempotency_key): raise ValueError('creator and idempotency key required') + if not all(isinstance(v, str) for v in (basis_date, memo, correction_of_run_id, correction_reason)): raise ValueError('draft metadata must be strings') + if correction_of_run_id and not _nonempty(correction_reason): raise ValueError('correction reason required') + with db(db_path) as c: + c.execute('BEGIN IMMEDIATE') + payload_hash=hashlib.sha256(json.dumps([month,created_by,basis,memo,correction_of_run_id,correction_reason],separators=(',',':')).encode()).hexdigest() + old=c.execute('SELECT * FROM settlement_drafts WHERE create_key=?',(idempotency_key,)).fetchone() + if old: + if old['original_request_hash']!=payload_hash: return _err('conflict','idempotency key payload conflict') + return _result(c,old['id']) + if c.execute("SELECT 1 FROM settlement_drafts WHERE month=? AND status='active'",(month,)).fetchone(): return _err('conflict','active draft already exists for month') + latest = _latest_close(c, basis) + if correction_of_run_id != latest: + return _err('validation_error', 'latest closed run must be linked explicitly with correction reason') + did=uuid4().hex; ts=now_iso() + c.execute('INSERT INTO settlement_drafts(id,month,basis_date,status,version,created_by,created_at,updated_at,memo,create_key,correction_of_run_id,correction_reason,original_request_hash) VALUES(?,?,? ,\'active\',1,?,?,?,?,?,?,?,?)',(did,month,basis,created_by,ts,ts,memo,idempotency_key,correction_of_run_id,correction_reason,payload_hash)) + # Freeze initial membership; account selections may use only verified, non-future evidence. + from .assets_service import _account_valuation_items + eligible, _, _ = _account_valuation_items(c, basis, 'exact') + selected = {x['source_id']: x.get('valuation_id', '') for x in eligible} + for a in c.execute("SELECT * FROM accounts WHERE active=1 ORDER BY id").fetchall(): + vid = selected.get(a['id'], '') + ev = _freeze(c, 'account', a['id'], vid) + c.execute('INSERT INTO settlement_draft_items(id,draft_id,source_type,source_id,valuation_id,selection_mode,included,version,selected_by,selected_at,evidence_json) VALUES(?,?,?,?,?,?,?,?,?,?,?)',(uuid4().hex,did,'account',a['id'],vid,'exact',1,1,created_by,ts,json.dumps(ev,sort_keys=True))) + for a in c.execute("SELECT * FROM manual_assets WHERE active=1 ORDER BY id").fetchall(): + ev = _freeze(c, 'manual_asset', a['id']) + c.execute('INSERT INTO settlement_draft_items(id,draft_id,source_type,source_id,valuation_id,selection_mode,included,version,selected_by,selected_at,evidence_json) VALUES(?,?,?,?,?,?,?,?,?,?,?)',(uuid4().hex,did,'manual_asset',a['id'],'','exact',1,1,created_by,ts,json.dumps(ev,sort_keys=True))) + return _result(c,did) + except (ValueError,TypeError,sqlite3.Error) as e: return _err('validation_error' if isinstance(e,(ValueError,TypeError)) else 'db_error','invalid draft request' if isinstance(e,(ValueError,TypeError)) else 'database error') + + +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]) + 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 get_settlement_draft(draft_id: str,db_path: str|None=None): + try: + with read_db(db_path) as c:return _result(c,draft_id) + 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 _mutate(did, version, actor, sql, params, db_path): + if not _valid_version(version) or not _nonempty(actor):return _err('validation_error','positive integer version and actor required') + try: + with db(db_path) as c: + c.execute('BEGIN IMMEDIATE'); d=_draft(c,did) + if not d:return _err('not_found','draft not found') + if d['status']!='active' or d['version']!=version:return _err('conflict','draft state/version conflict') + c.execute(sql,(*params,did)); c.execute("UPDATE settlement_drafts SET version=version+1,updated_at=?,approval_fingerprint='',approval_version=0,approved_by='',approved_at='',approval_reason='',approval_reference='',approval_flags='{}' WHERE id=?",(now_iso(),did)) + return _result(c,did) + 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 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) + + +def refresh_settlement_draft_targets(draft_id: str,expected_version: int,updated_by: str,db_path: str|None=None): + if not _valid_version(expected_version) or not _nonempty(updated_by): + return _err('validation_error', 'version and actor required') + try: + with db(db_path) as c: + c.execute('BEGIN IMMEDIATE') + d = _draft(c, draft_id) + if not d: return _err('not_found', 'draft not found') + if d['status'] != 'active' or d['version'] != expected_version: + return _err('conflict', 'draft state/version conflict') + ts = now_iso() + from .assets_service import _account_valuation_items + eligible, _, _ = _account_valuation_items(c, d['basis_date'], 'exact') + selected = {x['source_id']: x.get('valuation_id', '') for x in eligible} + existing = {(x['source_type'], x['source_id']): x for x in _items(c, draft_id)} + active = set() + for typ, table in [('account', 'accounts'), ('manual_asset', 'manual_assets')]: + for source in c.execute(f'SELECT id FROM {table} WHERE active=1 ORDER BY id').fetchall(): + key = (typ, source['id']) + active.add(key) + if key not in existing: + vid = selected.get(source['id'], '') if typ == 'account' else '' + ev = _freeze(c, typ, source['id'], vid) + c.execute('INSERT INTO settlement_draft_items(id,draft_id,source_type,source_id,valuation_id,selection_mode,included,version,selected_by,selected_at,evidence_json) VALUES(?,?,?,?,?,?,1,1,?,?,?)', + (uuid4().hex, draft_id, typ, source['id'], vid, 'exact', updated_by, ts, json.dumps(ev, sort_keys=True))) + elif typ == 'manual_asset': + # This explicit action can refresh manual values; it must never + # silently replace an account's selected valuation with a child. + ev = _freeze(c, typ, source['id']) + c.execute('UPDATE settlement_draft_items SET evidence_json=?,version=version+1,selected_by=?,selected_at=? WHERE id=?', + (json.dumps(ev, sort_keys=True), updated_by, ts, existing[key]['id'])) + for key, item in existing.items(): + if key not in active: + # Preserve the retired row and frozen evidence as an audited exclusion. + c.execute("UPDATE settlement_draft_items SET included=0,exclusion_reason='target retired by explicit refresh',version=version+1,selected_by=?,selected_at=? WHERE id=?", + (updated_by, ts, item['id'])) + c.execute("UPDATE settlement_drafts SET version=version+1,updated_at=?,approval_fingerprint='',approval_version=0,approved_by='',approved_at='',approval_reason='',approval_reference='',approval_flags='{}' WHERE id=?", (ts, draft_id)) + return _result(c, draft_id) + except sqlite3.Error: return _err('db_error', 'database error') + except (TypeError, ValueError): return _err('validation_error', 'invalid persisted draft evidence') + + +def set_settlement_draft_item(draft_id: str,source_type: str,source_id: str,expected_version: int,selected_by: str,valuation_id: str='',selection_mode: str='exact',selection_reason: str='',included: bool=True,exclusion_reason: str='',db_path: str|None=None): + source_type = 'manual_asset' if source_type == 'manual' else source_type + if type(included) is not bool or source_type not in ('account', 'manual_asset') or selection_mode not in ('exact', 'carried_before', 'manual_substitute'): + return _err('validation_error', 'invalid item fields') + if not _valid_version(expected_version) or not _nonempty(selected_by) or not _nonempty(source_id) or not isinstance(valuation_id, str) or not isinstance(selection_reason, str) or not isinstance(exclusion_reason, str): + return _err('validation_error', 'valid version, source and actor required') + if not included and not _nonempty(exclusion_reason): return _err('validation_error', 'exclusion reason required') + if selection_mode in ('carried_before', 'manual_substitute') and not _nonempty(selection_reason): return _err('validation_error', 'selection reason required') + try: + with db(db_path) as c: + c.execute('BEGIN IMMEDIATE') + d = _draft(c, draft_id) + if not d: return _err('not_found', 'draft not found') + if d['status'] != 'active' or d['version'] != expected_version: return _err('conflict', 'draft state/version conflict') + item = c.execute('SELECT * FROM settlement_draft_items WHERE draft_id=? AND source_type=? AND source_id=?', (draft_id, source_type, source_id)).fetchone() + if not item: return _err('validation_error', 'source is not a draft target; refresh targets first') + ev = _freeze(c, source_type, source_id, valuation_id) + source = ev.get('source') + if included and (not source or not source.get('active')): return _err('validation_error', 'included target must be active') + if source_type == 'account' and valuation_id: + v = ev.get('valuation') + if not v or v['account_id'] != source_id: return _err('validation_error', 'valuation does not belong to account') + if v['currency'] != source['currency']: return _err('validation_error', 'valuation currency mismatch') + kind, loan = v['valuation_kind'], source['type'] == 'loan' + valid_kind = kind in ('loan', 'loan_balance', 'balance') if loan else kind in ('total_value', 'cash_balance', 'balance') if source['type'] == 'investment' else kind == 'balance' + if not valid_kind or (loan and v['representative_liability_definition'] not in ('principal_only', 'principal_interest_fees')) or (not loan and v['representative_liability_definition']): + return _err('validation_error', 'valuation purpose or representative liability is invalid') + status = v['verification_status'] + if status not in ('verified', 'unverified') or (status != 'verified' and selection_mode != 'manual_substitute'): + return _err('validation_error', 'selection requires confirmed evidence except review-only manual substitute') + delta = (date.fromisoformat(v['basis_date']) - date.fromisoformat(d['basis_date'])).days + if selection_mode == 'exact' and delta != 0: return _err('validation_error', 'exact requires basis-date valuation') + if selection_mode == 'carried_before' and delta >= 0: return _err('validation_error', 'carried_before requires earlier valuation') + if ev.get('child') and included: return _err('conflict', 'select corrected child explicitly or exclude target') + elif source_type == 'account': + if selection_mode != 'exact': return _err('validation_error', 'an explicit valuation ID is required') + else: + if valuation_id or selection_mode == 'manual_substitute': return _err('validation_error', 'manual asset supports exact or carried_before only') + if included and source.get('valuation_date'): + delta = (date.fromisoformat(source['valuation_date']) - date.fromisoformat(d['basis_date'])).days + if (selection_mode == 'exact' and delta != 0) or (selection_mode == 'carried_before' and delta >= 0): return _err('validation_error', 'manual evidence date does not match selection mode') + ts = now_iso() + c.execute('UPDATE settlement_draft_items SET valuation_id=?,selection_mode=?,selection_reason=?,included=?,exclusion_reason=?,selected_by=?,selected_at=?,version=version+1,evidence_json=? WHERE id=?', (valuation_id, selection_mode, selection_reason, int(included), exclusion_reason, selected_by, ts, json.dumps(ev, sort_keys=True), item['id'])) + c.execute("UPDATE settlement_drafts SET version=version+1,updated_at=?,approval_fingerprint='',approval_version=0,approved_by='',approved_at='',approval_reason='',approval_reference='',approval_flags='{}' WHERE id=?", (ts, draft_id)) + return _result(c, draft_id) + except (ValueError, TypeError): return _err('validation_error', 'invalid selection evidence') + except sqlite3.Error: return _err('db_error', 'database error') + + +def review_settlement_draft(draft_id: str,db_path: str|None=None): + try: + with read_db(db_path) as c: + d=_draft(c,draft_id) + if not d:return _err('not_found','draft not found') + 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') + + +def approve_settlement_draft(draft_id: str,expected_version: int,review_fingerprint: str,approved_by: str,approval_reason: str,approval_reference: str,allow_missing_exclusions: bool=False,allow_carried_before: bool=False,allow_manual_substitute: bool=False,db_path: str|None=None): + if not _valid_version(expected_version) or not all(_nonempty(x) for x in (approved_by, approval_reason, approval_reference, review_fingerprint)):return _err('validation_error','approval metadata required') + if any(type(x) is not bool for x in (allow_missing_exclusions, allow_carried_before, allow_manual_substitute)): return _err('validation_error', 'approval flags must be booleans') + try: + with db(db_path) as c: + c.execute('BEGIN IMMEDIATE'); d=_draft(c,draft_id) + if not d or d['version']!=expected_version or d['status']!='active':return _err('conflict','draft version/state conflict') + 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['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') + flags=json.dumps({'allow_missing_exclusions':allow_missing_exclusions,'allow_carried_before':allow_carried_before,'allow_manual_substitute':allow_manual_substitute}) + c.execute('UPDATE settlement_drafts SET approval_fingerprint=?,approval_version=?,approved_by=?,approved_at=?,approval_reason=?,approval_reference=?,approval_flags=? WHERE id=?',(review_fingerprint,expected_version,approved_by,now_iso(),approval_reason,approval_reference,flags,draft_id)) + return _result(c,draft_id) + 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 finalize_settlement_draft(draft_id: str,expected_version: int,review_fingerprint: str,idempotency_key: str,db_path: str|None=None): + if not _valid_version(expected_version) or not _nonempty(idempotency_key) or not _nonempty(review_fingerprint):return _err('validation_error','version and close key required') + try: + with db(db_path) as c: + c.execute('BEGIN IMMEDIATE') + previous=c.execute('SELECT id,request_fingerprint FROM asset_snapshot_runs WHERE idempotency_key=?',(idempotency_key,)).fetchone() + d=_draft(c,draft_id) + req=hashlib.sha256(f'{draft_id}:{expected_version}:{review_fingerprint}'.encode()).hexdigest() + if previous: + if previous['request_fingerprint']!=req:return _err('conflict','idempotency key payload conflict') + saved=c.execute('SELECT final_response FROM settlement_drafts WHERE id=?',(draft_id,)).fetchone() + if saved and saved[0]:return json.loads(saved[0]) + return _err('conflict','persisted replay unavailable') + if not d or d['status']!='active' or d['version']!=expected_version:return _err('conflict','draft state/version conflict') + 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 _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 '{}') + if r['missing_items'] and not flags.get('allow_missing_exclusions'):return _err('validation_error','missing items not approved') + if r['contains_substitutions'] and not flags.get('allow_manual_substitute'):return _err('validation_error','substitutes not approved') + if r['contains_carried'] and not flags.get('allow_carried_before'):return _err('validation_error','carried items not approved') + from .assets_service import _canonical_provenance, _evidence_provenance, _ok_with_provenance_aliases, _summarize_valuation_items + approval = {k: d[k] for k in ('approved_by', 'approved_at', 'approval_reason', 'approval_reference')} + approval.update(flags) + correction = {'is_correction': bool(d['correction_of_run_id']), 'correction_of_run_id': d['correction_of_run_id'], 'correction_reason': d['correction_reason']} + applied = [{**x, 'draft_id': draft_id, 'draft_version': expected_version, 'review_fingerprint': review_fingerprint, 'approval': approval} for x in r['included_items']] + 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)) + 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',''))) + evidence = _evidence_provenance(applied, basis) + response = _ok_with_provenance_aliases( + draft_id=draft_id, draft_version=expected_version, review_fingerprint=review_fingerprint, + snapshot_run_id=run, run_id=run, basis_date=basis, close_status=status, closed_at=ts, + 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'], + 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'}, + ) + 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 + except sqlite3.Error:return _err('db_error','database error') + except (TypeError, ValueError):return _err('validation_error','invalid persisted draft evidence') + + +def cancel_settlement_draft(draft_id: str,expected_version: int,cancelled_by: str,reason: str,db_path: str|None=None): + if not _nonempty(reason):return _err('validation_error','cancellation reason required') + return _mutate(draft_id,expected_version,cancelled_by,"UPDATE settlement_drafts SET status='cancelled',cancelled_by=?,cancellation_reason=?,cancelled_at=? WHERE id=?",(cancelled_by,reason,now_iso()),db_path) diff --git a/jarca_ledger_mcp/infrastructure/sqlite/migrations.py b/jarca_ledger_mcp/infrastructure/sqlite/migrations.py index fab9918..0207292 100644 --- a/jarca_ledger_mcp/infrastructure/sqlite/migrations.py +++ b/jarca_ledger_mcp/infrastructure/sqlite/migrations.py @@ -18,6 +18,31 @@ def _add_missing_columns(conn: sqlite3.Connection, table: str, columns: dict[str def migrate_db(conn: sqlite3.Connection) -> None: """Apply additive, idempotent migrations only.""" + conn.execute("""CREATE TABLE IF NOT EXISTS settlement_drafts ( + id TEXT PRIMARY KEY, month TEXT NOT NULL, basis_date TEXT NOT NULL, + status TEXT NOT NULL, version INTEGER NOT NULL, created_by TEXT NOT NULL, + created_at TEXT NOT NULL, updated_at TEXT NOT NULL, memo TEXT NOT NULL DEFAULT '', + create_key TEXT NOT NULL UNIQUE, correction_of_run_id TEXT NOT NULL DEFAULT '', + correction_reason TEXT NOT NULL DEFAULT '', final_run_id TEXT NOT NULL DEFAULT '', + final_response TEXT NOT NULL DEFAULT '', cancelled_by TEXT NOT NULL DEFAULT '', + cancellation_reason TEXT NOT NULL DEFAULT '', cancelled_at TEXT NOT NULL DEFAULT '', + approval_fingerprint TEXT NOT NULL DEFAULT '', approval_version INTEGER NOT NULL DEFAULT 0, + approved_by TEXT NOT NULL DEFAULT '', approved_at TEXT NOT NULL DEFAULT '', + approval_reason TEXT NOT NULL DEFAULT '', approval_reference TEXT NOT NULL DEFAULT '', + approval_flags TEXT NOT NULL DEFAULT '{}' + )""") + 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 ''"}) + 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 '', + selection_mode TEXT NOT NULL DEFAULT 'exact', selection_reason TEXT NOT NULL DEFAULT '', + included INTEGER NOT NULL DEFAULT 1, exclusion_reason TEXT NOT NULL DEFAULT '', + version INTEGER NOT NULL DEFAULT 1, selected_by TEXT NOT NULL DEFAULT '', + selected_at TEXT NOT NULL DEFAULT '', evidence_json TEXT NOT NULL DEFAULT '{}', + UNIQUE(draft_id,source_type,source_id) + )""") _add_missing_columns(conn, "accounts", ACCOUNT_ADDITIVE_COLUMNS) _add_missing_columns(conn, "transactions", TRANSACTION_ADDITIVE_COLUMNS) conn.execute("CREATE INDEX IF NOT EXISTS idx_transactions_datetime ON transactions(transaction_datetime)") @@ -116,7 +141,7 @@ def migrate_db(conn: sqlite3.Connection) -> None: 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 )""") - _add_missing_columns(conn, "asset_snapshot_runs", {"request_fingerprint": "TEXT NOT NULL DEFAULT ''"}) + _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 ''"}) 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 aad592c..570b651 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 = "12" +SCHEMA_VERSION = "13" SCHEMA_SQL = """ PRAGMA journal_mode=WAL; diff --git a/jarca_ledger_mcp/service.py b/jarca_ledger_mcp/service.py index 55e9ff3..b8373a5 100644 --- a/jarca_ledger_mcp/service.py +++ b/jarca_ledger_mcp/service.py @@ -14,6 +14,7 @@ from .application import cross_transfer_service as _cross_transfer_service from .application import imports_service as _imports_service from .application import import_transfer_services as _import_transfer_services from .application import monthly_settlement_service as _monthly_settlement_service +from .application import settlement_draft_service as _settlement_draft_service from .application import reconciliation_service as _reconciliation_service from .application import review_decision_service as _review_decision_service from .application import shared as _shared @@ -38,6 +39,7 @@ _PROXY_MODULES = ( _reconciliation_service, _review_decision_service, _monthly_settlement_service, + _settlement_draft_service, _imports_service, _import_transfer_services, _cross_transfer_service, diff --git a/tests/test_account_valuations.py b/tests/test_account_valuations.py index 1e18454..dd165e4 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 == "12" + assert version == "13" 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_issue_12_regressions.py b/tests/test_issue_12_regressions.py index 83c76f6..0be9af9 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] == "12" + assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "13" 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 768b820..1a2f092 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"] == "12" + assert health["schema_version"] == "13" 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 new file mode 100644 index 0000000..52c3c7b --- /dev/null +++ b/tests/test_monthly_settlement_drafts.py @@ -0,0 +1,288 @@ +from __future__ import annotations + +"""Acceptance contract for the approved monthly settlement draft lifecycle. + +These tests intentionally resolve the API at runtime: before implementation the +suite must fail as an assertion (RED), not fail during pytest collection. +All data is synthetic and each test owns a temporary SQLite database. +""" +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 + +API = ( + "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", +) + + +def api(name): + # Deliberately runtime checked so absent API produces a clear RED assertion. + fn = getattr(service, name, None) + assert callable(fn), f"required settlement-draft API missing: service.{name}" + return fn + + +def dbfile(tmp_path): + path = str(tmp_path / "synthetic-ledger.sqlite3") + init_db(path) + return path + + +def create(path, month="2026-09", key="create-1", **kwargs): + return api("create_settlement_draft")(month, "tester", key, db_path=path, **kwargs) + + +def assert_ok(result): + assert result.get("success") is True, result + return result + + +def draft_of(result): + assert isinstance(result.get("draft"), dict), result + return result["draft"] + + +def test_empty_draft_persists_without_closing_and_has_consistent_public_shape(tmp_path): + path = dbfile(tmp_path) + result = assert_ok(create(path)) + draft = draft_of(result) + assert draft["month"] == "2026-09" and draft["basis_date"] == "2026-09-30" + assert draft["status"] == "active" and draft["version"] == 1 + assert result["items"] == [] + assert isinstance(result["review"], dict) + with sqlite3.connect(path) as conn: + assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0 + assert conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0] == 0 + fetched = assert_ok(api("get_settlement_draft")(draft["id"], db_path=path)) + assert fetched["draft"]["id"] == draft["id"] + listed = assert_ok(api("list_settlement_drafts")(month="2026-09", db_path=path)) + assert any(row.get("id") == draft["id"] for row in listed.get("drafts", [])) + + +@pytest.mark.parametrize("month", ["", "2026-9", "2026-13", "2026/09", "2026-09-30", " 2026-09"]) +def test_month_and_basis_are_strictly_validated(tmp_path, month): + path = dbfile(tmp_path) + result = api("create_settlement_draft")(month, "tester", "strict", db_path=path) + assert result.get("success") is False + assert result.get("error_code") in {"validation_error", "invalid_argument"} + + +def test_basis_must_be_calendar_month_end_and_create_key_is_idempotent(tmp_path): + path = dbfile(tmp_path) + bad = api("create_settlement_draft")("2026-09", "tester", "bad-date", basis_date="2026-09-29", db_path=path) + assert bad.get("success") is False + first = assert_ok(create(path, key="same-key", memo="first")) + replay = assert_ok(create(path, key="same-key", memo="first")) + assert draft_of(first)["id"] == draft_of(replay)["id"] + conflict = create(path, key="same-key", memo="different payload") + assert conflict.get("success") is False + + +def test_one_active_draft_across_normal_and_correction_and_cancellation_reopens_month(tmp_path): + path = dbfile(tmp_path) + first = draft_of(assert_ok(create(path, key="one"))) + duplicate = create(path, key="two") + assert duplicate.get("success") is False + assert api("cancel_settlement_draft")(first["id"], first["version"], "tester", "test cancellation", db_path=path).get("success") is True + reopened = draft_of(assert_ok(create(path, key="three"))) + assert reopened["id"] != first["id"] + assert reopened["correction_of_run_id"] in (None, "") + + +def test_input_validation_versions_actors_and_include_exclude_reason(tmp_path): + path = dbfile(tmp_path) + draft = draft_of(assert_ok(create(path))) + update = api("update_settlement_draft") + for bad_version in (0, -1, True, 1.5, None): + result = update(draft["id"], bad_version, "tester", "memo", db_path=path) + assert result.get("success") is False + for actor in ("", " "): + result = update(draft["id"], 1, actor, "memo", db_path=path) + assert result.get("success") is False + assert api("refresh_settlement_draft_targets")(draft["id"], 1, "tester", db_path=path).get("success") is True + current = assert_ok(api("get_settlement_draft")(draft["id"], db_path=path))["draft"] + assert current["version"] >= 2 + + +def test_targets_are_frozen_until_explicit_refresh_and_review_is_read_only(tmp_path): + path = dbfile(tmp_path) + acct = service.create_ledger_account("Synthetic cash", last_balance=10, liquidity_bucket="cash", db_path=path) + assert acct["success"] + draft = draft_of(assert_ok(create(path))) + before = assert_ok(api("get_settlement_draft")(draft["id"], db_path=path)) + service.create_ledger_account("Added later", last_balance=20, liquidity_bucket="cash", db_path=path) + with sqlite3.connect(path) as conn: + snapshot = conn.execute("SELECT COUNT(*) FROM settlement_draft_items").fetchone()[0] if conn.execute("SELECT 1 FROM sqlite_master WHERE name='settlement_draft_items'").fetchone() else None + review = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path)) + assert {"version", "review_fingerprint", "items", "included_items", "excluded_items", "missing_items", "warnings", "approval_required", "totals"} <= review.keys() + assert {"total_assets", "total_liabilities", "net_worth"} <= review["totals"].keys() + assert_ok(api("get_settlement_draft")(draft["id"], db_path=path)) + assert before["draft"]["version"] == draft["version"] + with sqlite3.connect(path) as conn: + assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0 + if snapshot is not None: + assert conn.execute("SELECT COUNT(*) FROM settlement_draft_items").fetchone()[0] == snapshot + + +def test_explicit_future_substitute_and_late_recorded_exact_are_distinguished(tmp_path): + path = dbfile(tmp_path) + a = service.create_ledger_account("Future account", type="investment", liquidity_bucket="investment", db_path=path)["account"]["id"] + b = service.create_ledger_account("Late exact", type="investment", liquidity_bucket="investment", db_path=path)["account"]["id"] + future = service.register_account_valuation(a, "2026-10-05", 500, valuation_kind="total_value", verification_status="verified", idempotency_key="future", db_path=path) + late = service.register_account_valuation(b, "2026-09-30", 700, valuation_kind="total_value", verification_status="verified", idempotency_key="late-exact", db_path=path) + assert late.get("success") + with sqlite3.connect(path) as conn: + conn.execute("UPDATE account_valuations SET recorded_at=? WHERE id=?", ("2026-10-08T00:00:00Z", late["valuation"]["id"])) + d = draft_of(assert_ok(create(path))) + review = assert_ok(api("review_settlement_draft")(d["id"], db_path=path)) + rows = review["items"] + auto_future = [x for x in rows if x.get("source_id") == a] + assert not any(x.get("valuation_id") == future.get("valuation", {}).get("id") and x.get("selection_mode") == "exact" for x in auto_future) + item = next(x for x in rows if x.get("source_id") == b) + assert item.get("valuation_id") == late["valuation"]["id"] + assert item.get("selection_mode", "exact") == "exact" + assert api("set_settlement_draft_item")(d["id"], "account", a, d["version"], "tester", valuation_id=future["valuation"]["id"], selection_mode="manual_substitute", selection_reason="synthetic future evidence", db_path=path).get("success") is True + revised = assert_ok(api("review_settlement_draft")(d["id"], db_path=path)) + selected = next(x for x in revised["items"] if x.get("source_id") == a) + assert selected.get("valuation_date") == "2026-10-05" + assert selected.get("date_difference_days") == 5 + assert selected.get("selection_mode") == "manual_substitute" + + +def test_unverified_candidate_has_no_authority_and_verified_child_requires_reselection(tmp_path): + path = dbfile(tmp_path) + acct = service.create_ledger_account("Unverified", type="investment", liquidity_bucket="investment", db_path=path)["account"]["id"] + unv = service.register_account_valuation(acct, "2026-09-30", 900, valuation_kind="total_value", verification_status="unverified", idempotency_key="unv", db_path=path) + draft = draft_of(assert_ok(create(path))) + review = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path)) + row = next(x for x in review["items"] if x.get("source_id") == acct) + assert row.get("amount") is None + assert row.get("candidate_amount") == 900 + child = service.correct_account_valuation(unv["valuation"]["id"], amount=950, verification_status="verified", correction_reason="synthetic verification", idempotency_key="verified-child", db_path=path) + assert child.get("success") + changed = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path)) + assert any(x.get("source_id") == acct for x in changed["missing_items"]) + assert api("set_settlement_draft_item")(draft["id"], "account", acct, draft["version"], "tester", valuation_id=child["valuation"]["id"], selection_mode="exact", db_path=path).get("success") is True + + +def test_manual_sources_drift_requires_explicit_refresh_and_future_not_auto_selected(tmp_path): + path = dbfile(tmp_path) + manual = service.create_manual_asset("Synthetic asset", 100, "2026-09-30", "real_estate", db_path=path)["manual_asset"] + future = service.create_manual_asset("Future asset", 200, "2026-10-07", "deposit", db_path=path)["manual_asset"] + draft = draft_of(assert_ok(create(path))) + review = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path)) + assert not any(x.get("source_id") == future["id"] and x.get("amount") is not None for x in review["items"]) + service.update_manual_asset(manual["id"], valuation_amount=110, db_path=path) + drift = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path)) + assert any("drift" in json.dumps(x).lower() or x.get("review_needed") for x in drift["items"]) + refreshed = api("refresh_settlement_draft_targets")(draft["id"], draft["version"], "tester", db_path=path) + assert refreshed.get("success") is True + + +def test_approval_fingerprint_mutation_and_external_drift_block_finalize(tmp_path): + path = dbfile(tmp_path) + draft = draft_of(assert_ok(create(path))) + review = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path)) + approved = api("approve_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "approver", "reviewed synthetic evidence", "ref-1", allow_missing_exclusions=True, db_path=path) + assert approved.get("success") is True + mutated = api("update_settlement_draft")(draft["id"], draft["version"], "tester", "changed after approval", db_path=path) + assert mutated.get("success") is True + stale = api("finalize_settlement_draft")(draft["id"], mutated["draft"]["version"], review["review_fingerprint"], "close-key", db_path=path) + assert stale.get("success") is False + assert stale.get("error_code") in {"conflict", "validation_error"} + + +def test_finalize_persists_immutable_provenance_replays_exact_response_and_conflicts_key(tmp_path): + path = dbfile(tmp_path) + assert_ok(service.create_ledger_account("Synthetic cash", last_balance=321, liquidity_bucket="cash", db_path=path)) + draft = draft_of(assert_ok(create(path))) + review = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path)) + approval = api("approve_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "approver", "approved", "approval-1", allow_missing_exclusions=True, db_path=path) + assert approval.get("success") + final = api("finalize_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "final-key", db_path=path) + assert final.get("success") + replay = api("finalize_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "final-key", db_path=path) + assert replay == final + conflict = api("finalize_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "final-key", db_path=path) + assert conflict == final + with sqlite3.connect(path) as conn: + run_count = conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] + row_count = conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0] + assert run_count == 1 and row_count >= 1 + assert final["draft_id"] == draft["id"] + assert final["draft_version"] == draft["version"] + assert final["review_fingerprint"] == review["review_fingerprint"] + report = service.get_asset_snapshot_report("2026-09-30", db_path=path) + series = service.get_net_worth_timeseries(start_date="2026-09-01", end_date="2026-09-30", db_path=path) + monthly = service.get_monthly_settlement_summary("2026-09", db_path=path) + assert report["success"] and series["success"] and monthly["success"] + assert report["close_metadata"].get("draft_id") == draft["id"] + + +def test_sqlite_trigger_failure_rolls_back_run_rows_and_completion(tmp_path): + path = dbfile(tmp_path) + assert_ok(service.create_ledger_account("Synthetic", last_balance=5, liquidity_bucket="cash", db_path=path)) + draft = draft_of(assert_ok(create(path))) + review = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path)) + approved = api("approve_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "approver", "approved", "ref", allow_missing_exclusions=True, db_path=path) + assert approved.get("success") + with sqlite3.connect(path) as conn: + conn.execute("CREATE TRIGGER fail_snapshot_insert BEFORE INSERT ON asset_snapshots BEGIN SELECT RAISE(ABORT, 'injected failure'); END") + result = api("finalize_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "failure-key", db_path=path) + assert result.get("success") is False + with sqlite3.connect(path) as conn: + assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0 + assert conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0] == 0 + after = assert_ok(api("get_settlement_draft")(draft["id"], db_path=path)) + assert after["draft"]["status"] == "active" + + +def test_schema13_migration_is_additive_and_idempotent(tmp_path): + path = dbfile(tmp_path) + with sqlite3.connect(path) as conn: + conn.execute("INSERT INTO asset_snapshot_runs(id,basis_date,close_status,created_at) VALUES('legacy-run','2026-08-31','complete_exact','2026-09-01T00:00:00Z')") + conn.commit() + init_db(path) + init_db(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' + + +def test_concurrent_create_and_stale_version_finalization_are_safe(tmp_path): + path = dbfile(tmp_path) + fn = api("create_settlement_draft") + def attempt(i): + return fn("2026-09", "tester", f"concurrent-{i}", db_path=path) + with ThreadPoolExecutor(max_workers=4) as pool: + results = list(pool.map(attempt, range(4))) + successes = [r for r in results if r.get("success")] + assert len(successes) == 1 + assert all(r.get("success") or r.get("error_code") in {"conflict", "validation_error"} for r in results) + d = draft_of(successes[0]) + review = assert_ok(api("review_settlement_draft")(d["id"], db_path=path)) + approved = api("approve_settlement_draft")(d["id"], d["version"], review["review_fingerprint"], "approver", "approved", "r", allow_missing_exclusions=True, db_path=path) + assert approved.get("success") + stale = api("finalize_settlement_draft")(d["id"], d["version"] + 1, review["review_fingerprint"], "stale", db_path=path) + assert stale.get("success") is False + + +def test_approval_is_mandatory_even_for_complete_exact_evidence(tmp_path): + path = dbfile(tmp_path) + draft = draft_of(assert_ok(create(path))) + review = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path)) + finalized = api("finalize_settlement_draft")( + draft["id"], draft["version"], review["review_fingerprint"], "unapproved", + db_path=path, + ) + assert finalized.get("success") is False diff --git a/tests/test_requirement_docs.py b/tests/test_requirement_docs.py index 78eda11..242be93 100644 --- a/tests/test_requirement_docs.py +++ b/tests/test_requirement_docs.py @@ -21,31 +21,35 @@ def test_requirement_docs_are_canonical_and_traceable() -> None: "schema": "gitea-agent.requirement/v1", "id": "REQ-003", "title": "계좌 기준일 평가 이력과 자산 마감 연동", - "revision": "1.0", + "revision": "1.1", "status": "approved", "depends_on": [], "applies_to": ["ledger", "dashboard", "mcp"], "language": "ko", - "issue": "#12", - "implementation_pr": "#13", + "issue": "#15", + "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, 11)] + 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) == 10 - assert body.count("**영향 범위**") == 10 + assert len(acs) == 27 + 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("**판정 기준**") == 10 - assert body.count("**검증**") == 10 + assert body.count("**판정 기준**") == 27 + assert body.count("**검증**") == 27 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 {level for level, _ in classification} >= {"unit", "integration", "static"} assert {backend for _, backend in classification} >= {"sqlite", "application", "dashboard"} for marker in ( @@ -142,7 +146,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.") == 20 + assert body.count("### REQ-003.") == 38 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 6f49b0c..090eb30 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"] == "12" + assert service.health_check(db_path)["schema_version"] == "13" 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 b56d418..18afd76 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"] == "12" + assert result["schema_version"] == "13" assert db_path.exists() diff --git a/tests/test_settlement_draft_adapters.py b/tests/test_settlement_draft_adapters.py new file mode 100644 index 0000000..4e88be5 --- /dev/null +++ b/tests/test_settlement_draft_adapters.py @@ -0,0 +1,112 @@ +"""Contract tests for the Issue 15 Streamlit gateway and MCP surface. + +Fixtures are synthetic and isolated: each test receives an empty temporary SQLite +path; no production DB or external service is used. These assertions intentionally +fail until the approved adapter/API is implemented. +""" +from __future__ import annotations + +import importlib +import inspect +from pathlib import Path + +import pytest + + +OPERATIONS = { + "create_settlement_draft": ["month", "created_by", "idempotency_key", "basis_date", "memo", "correction_of_run_id", "correction_reason", "db_path"], + "list_settlement_drafts": ["month", "db_path"], + "get_settlement_draft": ["draft_id", "db_path"], + "update_settlement_draft": ["draft_id", "expected_version", "updated_by", "memo", "db_path"], + "refresh_settlement_draft_targets": ["draft_id", "expected_version", "updated_by", "db_path"], + "set_settlement_draft_item": ["draft_id", "source_type", "source_id", "expected_version", "selected_by", "valuation_id", "selection_mode", "selection_reason", "included", "exclusion_reason", "db_path"], + "review_settlement_draft": ["draft_id", "db_path"], + "approve_settlement_draft": ["draft_id", "expected_version", "review_fingerprint", "approved_by", "approval_reason", "approval_reference", "allow_missing_exclusions", "allow_carried_before", "allow_manual_substitute", "db_path"], + "finalize_settlement_draft": ["draft_id", "expected_version", "review_fingerprint", "idempotency_key", "db_path"], + "cancel_settlement_draft": ["draft_id", "expected_version", "cancelled_by", "reason", "db_path"], +} + + +@pytest.fixture +def db_path(tmp_path: Path) -> Path: + return tmp_path / "synthetic-settlement.sqlite3" + + +def test_gateway_exports_exact_contract_signature_and_delegates(monkeypatch, db_path): + module = importlib.import_module("jarca_ledger_mcp.adapters.streamlit.settlement_gateway") + service = importlib.import_module("jarca_ledger_mcp.application.settlement_draft_service") + monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(db_path)) + calls = [] + for name, expected_parameters in OPERATIONS.items(): + gateway_fn = getattr(module, name) + service_fn = getattr(service, name) + assert list(inspect.signature(gateway_fn).parameters) == expected_parameters + assert inspect.signature(gateway_fn) == inspect.signature(service_fn) + marker = {"success": True, "operation": name} + def stub(*args, _marker=marker, _name=name, **kwargs): + calls.append((_name, kwargs.get("db_path"))) + return _marker + monkeypatch.setattr(service, name, stub) + kwargs = {p: "synthetic" for p in expected_parameters} + kwargs.update(db_path=str(db_path)) + kwargs["correction_of_run_id"] = "" + kwargs["correction_reason"] = "" + if "expected_version" in kwargs: + kwargs["expected_version"] = 1 + if "included" in kwargs: + kwargs["included"] = True + if name == "create_settlement_draft": + kwargs.update(month="2026-09", basis_date="2026-09-30") + assert gateway_fn(**kwargs) == marker + assert calls[-1] == (name, str(db_path)) + + +def test_gateway_exposes_existing_valuation_evidence_operations(): + gateway = importlib.import_module("jarca_ledger_mcp.adapters.streamlit.settlement_gateway") + for name in ("register_account_valuation", "list_account_valuations", "correct_account_valuation"): + assert callable(getattr(gateway, name)), name + + +def test_mcp_contract_and_fastmcp_tool_calls_preserve_service_result(monkeypatch, db_path): + contracts = importlib.import_module("jarca_ledger_mcp.adapters.mcp.tool_contracts") + server = importlib.import_module("jarca_ledger_mcp.adapters.mcp.server") + monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(db_path)) + calls = [] + tools = getattr(server, "mcp", getattr(server, "server", None))._tool_manager._tools + for name in OPERATIONS: + assert name in contracts.TOOL_CONTRACTS + assert name in tools + marker = {"success": True, "draft": {"id": "fixture-draft", "version": 1}} + def stub(*args, _marker=marker, _name=name, **kwargs): + calls.append(_name) + return _marker + monkeypatch.setattr(contracts, name, stub) + tool = tools[name] + call = getattr(tool, "fn", getattr(tool, "function", None)) + assert call is not None + kwargs = {p: "synthetic" for p in OPERATIONS[name]} + kwargs.pop("db_path", None) + if "expected_version" in kwargs: kwargs["expected_version"] = 1 + if "included" in kwargs: kwargs["included"] = True + if name == "create_settlement_draft": kwargs.update(month="2026-09", basis_date="2026-09-30") + assert call(**kwargs) == marker + assert calls[-1] == name + + +def test_read_only_gateway_calls_do_not_create_missing_database(monkeypatch, db_path): + gateway = importlib.import_module("jarca_ledger_mcp.adapters.streamlit.settlement_gateway") + # Real delegation is required here: read paths must not create a missing file. + for name in ("list_settlement_drafts",): + getattr(gateway, name)(db_path=str(db_path)) + assert not db_path.exists() + + + +def test_dashboard_authentication_entrypoint_precedes_database_resolution(): + app_source = (Path(__file__).parents[1] / "dashboard" / "app.py").read_text(encoding="utf-8") + assert app_source.index("require_dashboard_authentication()") < app_source.index("get_db_path()") + + +def test_service_reexports_every_lifecycle_operation(): + service = importlib.import_module("jarca_ledger_mcp.application.settlement_draft_service") + assert all(callable(getattr(service, name, None)) for name in OPERATIONS) diff --git a/tests/test_settlement_draft_audit_reproductions.py b/tests/test_settlement_draft_audit_reproductions.py new file mode 100644 index 0000000..a98af57 --- /dev/null +++ b/tests/test_settlement_draft_audit_reproductions.py @@ -0,0 +1,63 @@ +"""Executable reproductions reconcile static audit hypotheses with real behavior.""" +import sqlite3 + +from jarca_ledger_mcp import service as s +from jarca_ledger_mcp.db import init_db + + +def ok(result): + assert result['success'], result + return result + + +def test_empty_draft_persists_without_closing_and_has_consistent_public_shape(tmp_path): + path = str(tmp_path / 'empty.sqlite3') + init_db(path) + result = ok(s.create_settlement_draft('2026-09', 'creator', 'initial', db_path=path)) + draft = result['draft'] + 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 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 + assert conn.execute('SELECT COUNT(*) FROM asset_snapshot_runs').fetchone()[0] == 0 + assert conn.execute('SELECT COUNT(*) FROM asset_snapshots').fetchone()[0] == 0 + + +def test_initial_draft_freezes_exact_verified_selector_compatibly(tmp_path): + path = str(tmp_path / 'selection.sqlite3') + init_db(path) + account = ok(s.create_ledger_account('Synthetic cash', db_path=path))['account']['id'] + verified = ok(s.register_account_valuation(account, '2026-09-30', 100, verification_status='verified', idempotency_key='verified', db_path=path))['valuation']['id'] + unverified = ok(s.register_account_valuation(account, '2026-09-30', 999, verification_status='unverified', idempotency_key='unverified', db_path=path))['valuation']['id'] + with sqlite3.connect(path) as conn: + conn.execute("UPDATE account_valuations SET recorded_at='2026-10-01T00:00:00Z' WHERE id=?", (verified,)) + conn.execute("UPDATE account_valuations SET recorded_at='2026-10-02T00:00:00Z' WHERE id=?", (unverified,)) + selected = ok(s.select_account_valuation(account, '2026-09-30', as_of=False, db_path=path)) + assert selected['valuation']['id'] == verified + draft = ok(s.create_settlement_draft('2026-09', 'creator', 'initial', db_path=path)) + item = next(x for x in draft['items'] if x['source_id'] == account) + assert item['valuation_id'] == verified and item['amount'] == 100 and item['verification_status'] == 'verified' + + +def test_refresh_manual_target_preserves_retired_history_and_allows_explicit_refreeze(tmp_path): + path = str(tmp_path / 'retired.sqlite3') + init_db(path) + asset = ok(s.create_manual_asset('Synthetic property', 100, '2026-09-30', 'real_estate', db_path=path))['manual_asset']['id'] + created = ok(s.create_settlement_draft('2026-09', 'creator', 'initial', db_path=path)) + draft, original = created['draft'], created['items'][0] + ok(s.deactivate_manual_asset(asset, db_path=path)) + retired = ok(s.refresh_settlement_draft_targets(draft['id'], draft['version'], 'refresher', db_path=path)) + row = next(x for x in retired['items'] if x['source_id'] == asset) + assert row['id'] == original['id'] and not row['included'] and row['exclusion_reason'] + assert row['state_hash'] == original['state_hash'] and row['candidate_amount'] == 100 + refrozen = ok(s.set_settlement_draft_item(draft['id'], 'manual_asset', asset, retired['draft']['version'], 'refreezer', included=False, exclusion_reason='Synthetic retired source confirmed', db_path=path)) + row = refrozen['items'][0] + assert row['id'] == original['id'] and not row['included'] and row['amount'] is None + assert row['state_hash'] != original['state_hash'] and not row['review_needed'] + assert row['selected_by'] == 'refreezer' + invalid = s.set_settlement_draft_item(draft['id'], 'manual_asset', asset, refrozen['draft']['version'], 'selector', included=True, db_path=path) + assert not invalid['success'] + assert ok(s.get_settlement_draft(draft['id'], db_path=path))['draft']['version'] == refrozen['draft']['version'] diff --git a/tests/test_settlement_draft_authority.py b/tests/test_settlement_draft_authority.py new file mode 100644 index 0000000..8208b2f --- /dev/null +++ b/tests/test_settlement_draft_authority.py @@ -0,0 +1,181 @@ +"""Synthetic regressions for frozen evidence and reviewed authority.""" +import sqlite3 + +import pytest + +from jarca_ledger_mcp import service as s +from jarca_ledger_mcp.db import init_db + + +def ok(result): + assert result['success'], result + return result + + +@pytest.fixture +def case(tmp_path): + path = str(tmp_path / 'authority.sqlite3') + init_db(path) + account = ok(s.create_ledger_account('Synthetic investment', type='investment', liquidity_bucket='investment', db_path=path))['account']['id'] + val = ok(s.register_account_valuation(account, '2026-09-30', 500, valuation_kind='total_value', verification_status='verified', idempotency_key='evidence', db_path=path))['valuation']['id'] + draft = ok(s.create_settlement_draft('2026-09', 'creator', 'create', db_path=path))['draft'] + return path, account, val, draft + + +def approve(path, draft, **flags): + review = ok(s.review_settlement_draft(draft['id'], db_path=path)) + ok(s.approve_settlement_draft(draft['id'], draft['version'], review['review_fingerprint'], 'reviewer', 'synthetic reviewed', 'ref', db_path=path, **flags)) + return review + + +def counts(path): + with sqlite3.connect(path) as conn: + return tuple(conn.execute('SELECT COUNT(*) FROM ' + t).fetchone()[0] for t in ('asset_snapshot_runs', 'asset_snapshots')) + + +@pytest.mark.parametrize('sql', [ + "UPDATE account_valuations SET amount=600 WHERE id=?", + "UPDATE account_valuations SET memo='changed' WHERE id=?", + "UPDATE account_valuations SET principal=123 WHERE id=?", +]) +def test_full_valuation_drift_changes_fingerprint_and_blocks_write(case, sql): + path, account, val, draft = case + review = approve(path, draft) + with sqlite3.connect(path) as conn: + conn.execute(sql, (val,)) + changed = ok(s.review_settlement_draft(draft['id'], db_path=path)) + assert changed['review_fingerprint'] != review['review_fingerprint'] + assert any(x.get('review_needed') for x in changed['items']) + result = s.finalize_settlement_draft(draft['id'], draft['version'], review['review_fingerprint'], 'final', db_path=path) + assert not result['success'], result + assert counts(path) == (0, 0) + + +def test_classification_drift_requires_explicit_reselection(case): + path, account, val, draft = case + review = approve(path, draft) + with sqlite3.connect(path) as conn: + conn.execute("UPDATE accounts SET liquidity_bucket='cash' WHERE id=?", (account,)) + changed = ok(s.review_settlement_draft(draft['id'], db_path=path)) + assert changed['review_fingerprint'] != review['review_fingerprint'] + assert any(x.get('review_needed') for x in changed['items']) + assert not s.finalize_settlement_draft(draft['id'], draft['version'], review['review_fingerprint'], 'final', db_path=path)['success'] + assert counts(path) == (0, 0) + + +def test_corrected_parent_cannot_finalize_without_explicit_child_decision(case): + path, account, val, draft = case + previous = approve(path, draft) + child = ok(s.correct_account_valuation(val, amount=550, verification_status='verified', correction_reason='synthetic correction', idempotency_key='child', db_path=path))['valuation']['id'] + review = ok(s.review_settlement_draft(draft['id'], db_path=path)) + assert review['review_fingerprint'] != previous['review_fingerprint'] + result = s.approve_settlement_draft(draft['id'], draft['version'], review['review_fingerprint'], 'reviewer', 'review again', 'ref2', db_path=path) + if result['success']: + assert not s.finalize_settlement_draft(draft['id'], draft['version'], review['review_fingerprint'], 'final', db_path=path)['success'] + assert counts(path) == (0, 0) + updated = ok(s.set_settlement_draft_item(draft['id'], 'account', account, draft['version'], 'selector', valuation_id=child, db_path=path))['draft'] + fresh = approve(path, updated) + final = ok(s.finalize_settlement_draft(updated['id'], updated['version'], fresh['review_fingerprint'], 'final2', db_path=path)) + assert final['totals']['net_worth'] == 550 + + +def test_create_replay_uses_original_payload_after_memo_edit(case): + path, account, val, draft = case + ok(s.update_settlement_draft(draft['id'], draft['version'], 'editor', 'edited', db_path=path)) + replay = ok(s.create_settlement_draft('2026-09', 'creator', 'create', db_path=path)) + assert replay['draft']['id'] == draft['id'] + assert not s.create_settlement_draft('2026-09', 'creator', 'create', memo='different original request', db_path=path)['success'] + + +def test_empty_draft_cannot_create_empty_close(tmp_path): + path = str(tmp_path / 'empty.sqlite3') + init_db(path) + draft = ok(s.create_settlement_draft('2026-09', 'creator', 'empty', db_path=path))['draft'] + review = approve(path, draft, allow_missing_exclusions=True) + assert review['complete'] is False + assert not s.finalize_settlement_draft(draft['id'], draft['version'], review['review_fingerprint'], 'empty-final', db_path=path)['success'] + assert counts(path) == (0, 0) + + +def test_manual_reselection_explicitly_refreezes_semantic_evidence(tmp_path): + path = str(tmp_path / 'manual.sqlite3') + init_db(path) + asset = ok(s.create_manual_asset('Synthetic home', 100, '2026-09-30', 'real_estate', db_path=path))['manual_asset']['id'] + draft = ok(s.create_settlement_draft('2026-09', 'creator', 'manual', db_path=path))['draft'] + old = approve(path, draft) + ok(s.update_manual_asset(asset, valuation_amount=110, db_path=path)) + drift = ok(s.review_settlement_draft(draft['id'], db_path=path)) + assert drift['review_fingerprint'] != old['review_fingerprint'] + assert not s.finalize_settlement_draft(draft['id'], draft['version'], old['review_fingerprint'], 'stale', db_path=path)['success'] + refreshed = ok(s.set_settlement_draft_item(draft['id'], 'manual', asset, draft['version'], 'selector', db_path=path))['draft'] + new = approve(path, refreshed) + assert not any(x.get('review_needed') for x in new['items']) + final = ok(s.finalize_settlement_draft(refreshed['id'], refreshed['version'], new['review_fingerprint'], 'manual-final', db_path=path)) + assert final['totals']['net_worth'] == 110 + + +@pytest.mark.parametrize('operation,args', [ + ('list_settlement_drafts', ()), ('get_settlement_draft', ('missing',)), ('review_settlement_draft', ('missing',)), +]) +def test_draft_reads_preserve_missing_db_error_and_no_create(tmp_path, operation, args): + path = tmp_path / 'absent-parent' / 'db.sqlite3' + result = getattr(s, operation)(*args, db_path=str(path)) + assert result['error_code'] == 'db_not_initialized', result + assert not path.parent.exists() + + +def test_target_refresh_refreezes_manual_retains_account_selection_and_retires_targets(tmp_path): + path = str(tmp_path / 'refresh.sqlite3') + init_db(path) + manual = ok(s.create_manual_asset('Synthetic property', 100, '2026-09-30', 'real_estate', db_path=path))['manual_asset']['id'] + account = ok(s.create_ledger_account('Synthetic investment', type='investment', liquidity_bucket='investment', db_path=path))['account']['id'] + val = ok(s.register_account_valuation(account, '2026-09-30', 50, valuation_kind='total_value', verification_status='verified', idempotency_key='initial', db_path=path))['valuation']['id'] + d = ok(s.create_settlement_draft('2026-09', 'creator', 'create', db_path=path))['draft'] + ok(s.update_manual_asset(manual, valuation_amount=110, db_path=path)) + child = ok(s.correct_account_valuation(val, amount=60, verification_status='verified', correction_reason='synthetic', idempotency_key='child', db_path=path))['valuation']['id'] + added = ok(s.create_manual_asset('Synthetic new', 20, '2026-09-30', 'other', db_path=path))['manual_asset']['id'] + result = ok(s.refresh_settlement_draft_targets(d['id'], d['version'], 'refresh actor', db_path=path)) + review = result['review'] + property_row = next(x for x in review['items'] if x['source_id'] == manual) + assert property_row['amount'] == 110 and not property_row['review_needed'] + account_row = next(x for x in review['items'] if x['source_id'] == account) + assert account_row['valuation_id'] == val and account_row['valuation_id'] != child + assert account_row['review_needed'] + assert any(x['source_id'] == added and x['amount'] == 20 for x in review['items']) + ok(s.deactivate_manual_asset(added, db_path=path)) + refreshed = ok(s.refresh_settlement_draft_targets(d['id'], result['draft']['version'], 'refresh actor', db_path=path)) + retired = next(x for x in refreshed['items'] if x['source_id'] == added) + assert retired['included'] is False and retired['exclusion_reason'] + assert retired['selected_by'] == 'refresh actor' + + +@pytest.mark.parametrize('action', ['memo', 'cancel', 'refresh', 'selection']) +def test_any_draft_mutation_clears_all_approval_version_state(case, action): + path, account, val, draft = case + approve(path, draft) + before = ok(s.get_settlement_draft(draft['id'], db_path=path))['draft'] + assert before['approval_version'] == draft['version'] and before['approved_at'] + if action == 'memo': + result = s.update_settlement_draft(draft['id'], draft['version'], 'editor', 'edited', db_path=path) + elif action == 'cancel': + result = s.cancel_settlement_draft(draft['id'], draft['version'], 'editor', 'synthetic cancellation', db_path=path) + elif action == 'refresh': + result = s.refresh_settlement_draft_targets(draft['id'], draft['version'], 'editor', db_path=path) + else: + result = s.set_settlement_draft_item(draft['id'], 'account', account, draft['version'], 'editor', valuation_id=val, db_path=path) + changed = ok(result)['draft'] + assert changed['version'] == draft['version'] + 1 + assert changed['approval_version'] == 0 and changed['approved_at'] == '' + assert all(changed[key] == '' for key in ('approved_by', 'approval_fingerprint', 'approval_reason', 'approval_reference')) + assert changed['approval_flags'] == '{}' + + +def test_finalize_same_key_different_payload_conflicts_without_partial_write(case): + path, account, val, draft = case + review = approve(path, draft) + final = ok(s.finalize_settlement_draft(draft['id'], draft['version'], review['review_fingerprint'], 'final-key', db_path=path)) + before = counts(path) + assert s.finalize_settlement_draft(draft['id'], draft['version'], review['review_fingerprint'], 'final-key', db_path=path) == final + conflict = s.finalize_settlement_draft(draft['id'], draft['version'] + 1, review['review_fingerprint'], 'final-key', db_path=path) + assert not conflict['success'] and conflict['error_code'] == 'conflict' + assert counts(path) == before diff --git a/tests/test_settlement_draft_contract_scenarios.py b/tests/test_settlement_draft_contract_scenarios.py new file mode 100644 index 0000000..ca92ac0 --- /dev/null +++ b/tests/test_settlement_draft_contract_scenarios.py @@ -0,0 +1,173 @@ +"""Clause-level acceptance uses real synthetic SQLite and public consumers.""" +import sqlite3 +from concurrent.futures import ThreadPoolExecutor + +from jarca_ledger_mcp import service as s +from jarca_ledger_mcp.db import init_db + + +def ok(result): + assert result['success'], result + return result + + +def account(path, name, **fields): + return ok(s.create_ledger_account(name, db_path=path, **fields))['account']['id'] + + +def value(path, aid, key, when, amount, **fields): + return ok(s.register_account_valuation(aid, when, amount, idempotency_key=key, verification_status=fields.pop('verification_status', 'verified'), db_path=path, **fields))['valuation']['id'] + + +def choose(path, draft, aid, vid, **fields): + return ok(s.set_settlement_draft_item(draft['id'], 'account', aid, draft['version'], 'synthetic selector', valuation_id=vid, db_path=path, **fields))['draft'] + + +def approve(path, draft, **flags): + review = ok(s.review_settlement_draft(draft['id'], db_path=path)) + ok(s.approve_settlement_draft(draft['id'], draft['version'], review['review_fingerprint'], 'synthetic reviewer', 'synthetic review reason', 'synthetic reference', db_path=path, **flags)) + return review + + +def test_date_metadata_public_contract_names_survive_all_closed_consumers(tmp_path): + path = str(tmp_path / 'dates.sqlite3') + init_db(path) + aid = account(path, 'Synthetic loan', type='loan', liquidity_bucket='loan') + vid = value(path, aid, 'loan', '2026-10-07', 40, valuation_kind='loan_balance', representative_liability_definition='principal_only') + with sqlite3.connect(path) as c: + c.execute("UPDATE account_valuations SET recorded_at='2026-10-08T00:00:00Z' WHERE id=?", (vid,)) + d = ok(s.create_settlement_draft('2026-09', 'creator', 'initial', db_path=path))['draft'] + d = choose(path, d, aid, vid, selection_mode='manual_substitute', selection_reason='Synthetic late loan statement') + review = approve(path, d, allow_manual_substitute=True) + final = ok(s.finalize_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'final', db_path=path)) + report = ok(s.get_asset_snapshot_report('2026-09-30', db_path=path)) + series = ok(s.get_net_worth_timeseries('2026-09-01', '2026-09-30', db_path=path)) + monthly = ok(s.get_monthly_settlement_summary('2026-09', db_path=path)) + expected = {'valuation_date': '2026-10-07', 'date_difference_days': 7, 'date_difference_direction': 'after', 'age_days': -7, 'recorded_at': '2026-10-08T00:00:00Z', 'representative_liability_definition': 'principal_only'} + rows = [review['included_items'][0], final['items'][0], report['close_metadata']['applied_evidence'][0], series['points'][0]['applied_evidence'][0], monthly['source_coverage']['assets']['applied_evidence'][0]] + for row in rows: + assert {key: row.get(key) for key in expected} == expected + for row in rows[1:]: + assert row['approval']['allow_manual_substitute'] is True + assert row['approval']['allow_missing_exclusions'] is False + assert row['approval']['allow_carried_before'] is False + + +def test_representative_integer_precision_no_double_sum_and_scope_rejection(tmp_path): + path = str(tmp_path / 'precision.sqlite3') + init_db(path) + aid = account(path, 'Synthetic pension', type='investment', liquidity_bucket='investment') + other = account(path, 'Synthetic other') + amount = 9007199254740993 + representative = value(path, aid, 'total', '2026-09-30', amount, valuation_kind='total_value') + value(path, aid, 'cash', '2026-09-30', 100, valuation_kind='cash_balance') + wrong = value(path, other, 'other', '2026-09-30', 10) + d = ok(s.create_settlement_draft('2026-09', 'creator', 'initial', db_path=path))['draft'] + review = ok(s.review_settlement_draft(d['id'], db_path=path)) + item = next(x for x in review['included_items'] if x['source_id'] == aid) + assert item['valuation_id'] == representative and type(item['amount']) is int and item['amount'] == amount + assert review['totals']['net_worth'] == amount + 10 + before = ok(s.get_settlement_draft(d['id'], db_path=path)) + bad = s.set_settlement_draft_item(d['id'], 'account', aid, d['version'], 'selector', valuation_id=wrong, db_path=path) + assert not bad['success'] and bad['error_code'] == 'validation_error' + assert ok(s.get_settlement_draft(d['id'], db_path=path)) == before + + +def test_unverified_substitute_only_finalizes_as_explicit_approved_missing(tmp_path): + path = str(tmp_path / 'unverified.sqlite3') + init_db(path) + aid = account(path, 'Synthetic pension', type='investment', liquidity_bucket='investment') + vid = value(path, aid, 'candidate', '2026-10-05', 100, valuation_kind='total_value', verification_status='unverified') + d = ok(s.create_settlement_draft('2026-09', 'creator', 'initial', db_path=path))['draft'] + d = choose(path, d, aid, vid, selection_mode='manual_substitute', selection_reason='Synthetic review-only candidate') + review = ok(s.review_settlement_draft(d['id'], db_path=path)) + assert not review['included_items'] and review['missing_items'][0]['amount'] is None + bad = s.approve_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'reviewer', 'reason', 'ref', allow_manual_substitute=True, db_path=path) + assert not bad['success'] + before = s.finalize_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'final', db_path=path) + assert not before['success'] + review = approve(path, d, allow_missing_exclusions=True) + final = ok(s.finalize_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'final', db_path=path)) + assert not final['items'] and final['excluded_items'][0]['amount'] is None + assert final['excluded_items'][0]['candidate_amount'] == 100 + assert final['totals']['net_worth'] == 0 and final['has_approved_missing'] + assert not final['contains_substitutions'] and final['close_status'] == 'conditional' + + +def test_transaction_readiness_is_independent_of_asset_draft_close(tmp_path): + path = str(tmp_path / 'separation.sqlite3') + init_db(path) + aid = account(path, 'Synthetic cash') + value(path, aid, 'cash', '2026-09-30', 100) + tx = ok(s.create_ledger_transaction('2026-09-15', amount=5, account_id=aid, category='미분류', db_path=path)) + assert tx['success'] + ok(s.upsert_review_decision('transaction', tx['transaction']['id'], status='pending', operator='synthetic reviewer', db_path=path)) + before = ok(s.get_monthly_settlement_summary('2026-09', db_path=path)) + assert before['unresolved_blocker_count'] > 0 + 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) + 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'] + assert after['unresolved_blockers'] == before['unresolved_blockers'] and after['unresolved_blocker_count'] > 0 + with sqlite3.connect(path) as c: + assert c.execute('SELECT * FROM transactions').fetchall() == original + + +def test_synthetic_september_future_pension_loan_late_exact_warnings_stale_rollback_concurrent_retry(tmp_path): + path = str(tmp_path / 'september.sqlite3') + init_db(path) + cash = account(path, 'Synthetic cash') + pension = account(path, 'Synthetic pension', type='investment', liquidity_bucket='investment') + loan = account(path, 'Synthetic loan', type='loan', liquidity_bucket='loan') + rejected = account(path, 'Synthetic rejected') + foreign = account(path, 'Synthetic USD', currency='USD') + unknown = ok(s.create_manual_asset('Synthetic undated property', None, '', 'real_estate', db_path=path))['manual_asset']['id'] + cv = value(path, cash, 'cash', '2026-09-30', 100) + pv = value(path, pension, 'pension', '2026-10-05', 120, valuation_kind='total_value') + lv = value(path, loan, 'loan', '2026-10-07', 40, valuation_kind='loan_balance', representative_liability_definition='principal_only') + value(path, rejected, 'rejected', '2026-09-30', 999, verification_status='rejected') + value(path, foreign, 'foreign', '2026-09-30', 77, currency='USD') + with sqlite3.connect(path) as c: + c.execute("UPDATE account_valuations SET recorded_at='2026-10-10T00:00:00Z' WHERE id=?", (cv,)) + d = ok(s.create_settlement_draft('2026-09', 'creator', 'initial', db_path=path))['draft'] + first = ok(s.review_settlement_draft(d['id'], db_path=path)) + assert first['totals']['net_worth'] == 100 + d = choose(path, d, pension, pv, selection_mode='manual_substitute', selection_reason='Synthetic Oct 05 pension') + d = choose(path, d, loan, lv, selection_mode='manual_substitute', selection_reason='Synthetic Oct 07 loan') + review = approve(path, d, allow_missing_exclusions=True, allow_manual_substitute=True) + assert review['totals']['net_worth'] == 180 + assert {x['source_id'] for x in review['missing_items']} == {rejected, foreign, unknown} + assert all(x['amount'] is None for x in review['missing_items']) + assert any(w['code'] == 'non_krw' for w in review['warnings']) + with sqlite3.connect(path) as c: + c.execute("UPDATE account_valuations SET memo='Synthetic evidence drift' WHERE id=?", (cv,)) + stale = s.finalize_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'final', db_path=path) + assert not stale['success'] and stale['error_code'] == 'conflict' + d = choose(path, d, cash, cv) + review = approve(path, d, allow_missing_exclusions=True, allow_manual_substitute=True) + with sqlite3.connect(path) as c: + c.execute("CREATE TRIGGER synthetic_failure BEFORE INSERT ON asset_snapshots BEGIN SELECT RAISE(ABORT, 'synthetic rollback'); END") + failed = s.finalize_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'final', db_path=path) + assert not failed['success'] and failed['error_code'] == 'db_error' + with sqlite3.connect(path) as c: + assert c.execute('SELECT COUNT(*) FROM asset_snapshot_runs').fetchone()[0] == 0 + assert c.execute('SELECT COUNT(*) FROM asset_snapshots').fetchone()[0] == 0 + c.execute('DROP TRIGGER synthetic_failure') + assert ok(s.get_settlement_draft(d['id'], db_path=path))['draft']['status'] == 'active' + def finish(): + return s.finalize_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'final', db_path=path) + with ThreadPoolExecutor(max_workers=2) as pool: + futures = [pool.submit(finish) for _ in range(2)] + results = [ok(f.result()) for f in futures] + assert results[0] == results[1] + final = results[0] + assert final['contains_substitutions'] and final['has_approved_missing'] and not final['contains_carried'] + assert final['totals']['net_worth'] == 180 and len(final['items']) == 3 + assert {x['valuation_date'] for x in final['items']} == {'2026-09-30', '2026-10-05', '2026-10-07'} + assert finish() == final + with sqlite3.connect(path) as c: + assert c.execute('SELECT COUNT(*) FROM asset_snapshot_runs').fetchone()[0] == 1 + assert c.execute('SELECT COUNT(*) FROM asset_snapshots').fetchone()[0] == final['inserted_count'] diff --git a/tests/test_settlement_draft_correction_contract.py b/tests/test_settlement_draft_correction_contract.py new file mode 100644 index 0000000..871df39 --- /dev/null +++ b/tests/test_settlement_draft_correction_contract.py @@ -0,0 +1,67 @@ +"""Acceptance contract for immutable, reason-linked settlement corrections.""" +import sqlite3 + +from jarca_ledger_mcp import service as s +from jarca_ledger_mcp.db import init_db + + +def ok(r): + assert r['success'], r + return r + + +def counts(path): + with sqlite3.connect(path) as c: + return {table: c.execute(f'SELECT * FROM {table} ORDER BY rowid').fetchall() + for table in ('asset_snapshot_runs', 'asset_snapshots', 'settlement_drafts', 'settlement_draft_items')} + + +def finish(path, draft): + review = ok(s.review_settlement_draft(draft['id'], db_path=path)) + approved = ok(s.approve_settlement_draft(draft['id'], draft['version'], review['review_fingerprint'], 'synthetic reviewer', 'approved synthetic correction', 'acceptance', db_path=path)) + return ok(s.finalize_settlement_draft(draft['id'], approved['draft']['version'], review['review_fingerprint'], 'close-' + draft['id'], db_path=path)) + + +def test_correction_links_reason_preserves_prior_rows_and_replays_after_later_correction(tmp_path): + path = str(tmp_path / 'correction.sqlite3') + init_db(path) + aid = ok(s.create_ledger_account('Synthetic cash', db_path=path))['account']['id'] + first_value = ok(s.register_account_valuation(aid, '2026-09-30', 100, verification_status='verified', idempotency_key='v1', db_path=path))['valuation']['id'] + original_draft = ok(s.create_settlement_draft('2026-09', 'synthetic', 'draft-1', db_path=path))['draft'] + original = finish(path, original_draft) + original_rows = counts(path) + corrected_value = ok(s.correct_account_valuation(first_value, 125, 'synthetic valuation correction', idempotency_key='v2', db_path=path))['valuation']['id'] + assert counts(path)['asset_snapshot_runs'] == original_rows['asset_snapshot_runs'] + assert counts(path)['asset_snapshots'] == original_rows['asset_snapshots'] + + run_id = original['snapshot_run_id'] + bad_requests = [('', 'reason'), (run_id, ''), ('does-not-exist', 'reason')] + before = counts(path) + for i, (parent, reason) in enumerate(bad_requests): + result = s.create_settlement_draft('2026-09', 'synthetic', f'invalid-{i}', correction_of_run_id=parent, correction_reason=reason, db_path=path) + assert not result['success'] + assert counts(path) == before + ordinary = s.create_settlement_draft('2026-09', 'synthetic', 'ordinary-after-close', db_path=path) + assert not ordinary['success'] and ordinary['error_code'] == 'validation_error' + assert counts(path) == before + + correction = ok(s.create_settlement_draft('2026-09', 'synthetic', 'correction-1', correction_of_run_id=run_id, correction_reason='synthetic close correction', db_path=path))['draft'] + active_conflict = s.create_settlement_draft('2026-09', 'synthetic', 'active-conflict', correction_of_run_id=run_id, correction_reason='another linked correction', db_path=path) + assert not active_conflict['success'] and active_conflict['error_code'] == 'conflict' + assert counts(path)['settlement_drafts'] == before['settlement_drafts'] + [counts(path)['settlement_drafts'][-1]] + assert correction['correction_of_run_id'] == run_id + selected = ok(s.set_settlement_draft_item(correction['id'], 'account', aid, correction['version'], 'synthetic selector', valuation_id=corrected_value, db_path=path))['draft'] + corrected = finish(path, selected) + assert corrected['correction']['correction_of_run_id'] == run_id + assert corrected['correction']['correction_reason'] == 'synthetic close correction' + for table, rows in original_rows.items(): + assert counts(path)[table][:len(rows)] == rows + + third_value = ok(s.correct_account_valuation(corrected_value, 150, 'second synthetic correction', idempotency_key='v3', db_path=path))['valuation']['id'] + later = ok(s.create_settlement_draft('2026-09', 'synthetic', 'correction-2', correction_of_run_id=corrected['snapshot_run_id'], correction_reason='second synthetic close correction', db_path=path))['draft'] + later = ok(s.set_settlement_draft_item(later['id'], 'account', aid, later['version'], 'synthetic selector', valuation_id=third_value, db_path=path))['draft'] + finish(path, later) + before_replay = counts(path) + replay = s.finalize_settlement_draft(correction['id'], selected['version'], corrected['review_fingerprint'], 'close-' + correction['id'], db_path=path) + assert replay == corrected + assert counts(path) == before_replay diff --git a/tests/test_settlement_draft_dashboard.py b/tests/test_settlement_draft_dashboard.py new file mode 100644 index 0000000..8d5736b --- /dev/null +++ b/tests/test_settlement_draft_dashboard.py @@ -0,0 +1,106 @@ +"""Dashboard lifecycle contract tests using Streamlit AppTest when available. + +All data is synthetic; renders must not mutate/create state or databases. This +suite deliberately asserts the Issue 15 lifecycle UI is present before it exists. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + + +VIEW = Path(__file__).parents[1] / "dashboard" / "page_views" / "settlement_drafts.py" + + +def _app_wrapper(tmp_path: Path) -> Path: + wrapper = tmp_path / "settlement_panel_app.py" + wrapper.write_text( + "import os\nfrom dashboard.page_views.settlement_drafts import render_settlement_drafts\n" + "render_settlement_drafts(db_path=os.environ.get(\"JARCA_LEDGER_DB_PATH\"))\n", + encoding="utf-8", + ) + return wrapper + + +def test_reusable_settlement_draft_panel_exists_and_is_importable(tmp_path): + assert VIEW.is_file(), "shared Dashboard settlement draft panel is missing" + from streamlit.testing.v1 import AppTest + app = AppTest.from_file(str(_app_wrapper(tmp_path))).run() + assert not app.exception + + +def test_monthly_and_assets_pages_invoke_shared_panel(): + root = VIEW.parents[1] + pages = [p for p in root.rglob("*.py") if p.name in ("monthly_settlement.py", "assets.py")] + assert len(pages) == 2 + for page in pages: + assert "render_settlement_drafts" in page.read_text(encoding="utf-8") + + +def test_streamlit_panel_renders_lifecycle_without_writing_or_creating_db(monkeypatch, tmp_path): + pytest.importorskip("streamlit.testing.v1") + from streamlit.testing.v1 import AppTest + db_path = tmp_path / "dashboard-missing.sqlite3" + # Explicit synthetic DB path and no authenticated actor: render only. + monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(db_path)) + app = AppTest.from_file(str(_app_wrapper(tmp_path))).run() + assert not app.exception + page = "\n".join([e.value for e in app.markdown] + [e.value for e in app.caption] + [e.value for e in app.info] + [e.label for e in app.button]) + for expected in ("초안", "검토", "승인", "최종", "취소", "정정", "평가자료", "제외 사유"): + assert expected in page + assert not db_path.exists(), "render-only Dashboard created a database" + + +def test_panel_actions_are_explicit_and_selection_state_bound_to_draft_version(): + source = VIEW.read_text(encoding="utf-8") + for action in ("create_settlement_draft", "update_settlement_draft", "cancel_settlement_draft", + "refresh_settlement_draft_targets", "set_settlement_draft_item", "review_settlement_draft", + "approve_settlement_draft", "finalize_settlement_draft", "register_account_valuation", + "list_account_valuations", "correct_account_valuation"): + assert action in source, f"missing explicit action {action}" + assert "session_state" in source + assert "review_fingerprint" in source + assert "expected_version" in source or "version, actor" in source + assert "transaction" in source.lower() and "거래" in source + + +def test_closed_draft_view_exposes_immutable_evidence_and_correction_creation(): + source = VIEW.read_text(encoding="utf-8") + for immutable in ("draft_version", "review_fingerprint", "final_run_id", "correction_of_run_id", + "basis_date", "created_by", "created_at", "updated_at", "memo"): + assert immutable in source, f"closed view omits {immutable}" + assert "correction_reason" in source + assert "completion" in source or "completed" in source + + +def test_render_path_contains_no_implicit_lifecycle_mutations(): + source = VIEW.read_text(encoding="utf-8") + # All lifecycle mutations must be behind explicit submit/button branches; + # import/render must never invoke them unconditionally. + assert "if st.button" in source or "form_submit_button" in source + assert "render_settlement_drafts" in source + + +def test_app_test_create_review_approve_finalize_roundtrip(tmp_path, monkeypatch): + from streamlit.testing.v1 import AppTest + from jarca_ledger_mcp.db import init_db + from jarca_ledger_mcp.application.assets_service import create_manual_asset + db_path = tmp_path / "lifecycle.sqlite3" + init_db(str(db_path)) + seeded = create_manual_asset("Synthetic asset", 125, "2026-09-30", "other", source_note="fixture", db_path=str(db_path)) + assert seeded.get("success"), seeded + monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(db_path)) + app = AppTest.from_file(str(_app_wrapper(tmp_path))).run() + assert not app.exception + app.button[0].click().run() + assert not app.exception + assert app.button[0].label == "초안 생성 / 정정 초안" + assert len(app.selectbox[0].options) == 1 + assert "active" in " ".join(item.value for item in app.caption) + # Exercise read-only review through the rendered explicit button. + review = next(button for button in app.button if button.label == "검토 / 새로고침 대상") + review.click().run(timeout=10) + assert not app.exception + assert any("review_fingerprint" in item.value for item in app.caption) + assert not app.exception diff --git a/tests/test_settlement_draft_docs.py b/tests/test_settlement_draft_docs.py new file mode 100644 index 0000000..a9c9cc2 --- /dev/null +++ b/tests/test_settlement_draft_docs.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import ast +import json +import re +from pathlib import Path + +ROOT = Path(__file__).parents[1] +DOCS = ROOT / "docs" +REQ = DOCS / "requirements/req-003_account-valuation-history.md" + + +def text(path: str) -> str: + return (ROOT / path).read_text(encoding="utf-8") + + +def test_settlement_requirement_revision_is_additive_and_traceable() -> None: + source = REQ.read_text(encoding="utf-8") + _, 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"] == "미정" + 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)] + assert "schema 11→12" in body and "schema 12→13" in body + + +def test_draft_docs_cover_authority_dates_atomicity_and_read_only_contracts() -> None: + docs = "\n".join(text(path) for path in ( + "docs/requirements/req-003_account-valuation-history.md", "docs/data-model.md", + "docs/interface-contract.md", "docs/project-structure.md", "docs/runtime-flow.md", + )) + for marker in ("date_difference_days = valuation_date - basis_date", "age_days", "manual_substitute", + "candidate_amount", "authority amount NULL", "refresh_settlement_draft_targets", + "BEGIN IMMEDIATE", "persisted response", "one active draft", "correction_reason", + "unverified", "no-create", "transaction readiness"): + assert marker in docs, marker + + +def test_new_public_operations_are_fully_listed_from_contract() -> None: + interface = text("docs/interface-contract.md") + for operation 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"): + assert f"{operation}(" in interface, operation + assert "review_fingerprint" in interface and "selection_mode" in interface + + +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)] + 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) + assert ref, block.splitlines()[0] + path, func = ref.groups() + assert path in files, path + names = {n.name for n in ast.walk(ast.parse(files[path].read_text(encoding="utf-8"))) if isinstance(n, ast.FunctionDef)} + assert func in names, (path, func) + for label in ("판정 기준", "검증 분류", "상태 불변조건"): + assert label in block, (block.splitlines()[0], label) + assert "## 12." not in req + requirements = req.split("## 5. 요구사항", 1)[1].split("## 6.", 1)[0] + assert requirements.count("**영향 범위**") == 11 + new_requirement = requirements.split("### REQ-003.11 —", 1)[1] + for dimension in ("인터페이스", "영속성", "후속 사용 주체", "마이그레이션", "호환성", "배포 전제 조건"): + assert f"- {dimension}:" in new_requirement + + +def test_settlement_metadata_names_match_actual_public_contract() -> None: + interface = text('docs/interface-contract.md').split('## settlement-draft-service', 1)[1] + model = text('docs/data-model.md').split('## settlement-drafts', 1)[1] + for field in ('valuation_date', 'recorded_at', 'date_difference_days', 'date_difference_direction', 'age_days', 'representative_liability_definition'): + assert field in interface and field in model, field + for field in ('allow_missing_exclusions', 'allow_carried_before', 'allow_manual_substitute'): + assert field in interface + assert 'test_date_metadata_public_contract_names_survive_all_closed_consumers' in interface + + +def test_draft_mcp_signature_documents_internal_database_boundary() -> None: + interface = text("docs/interface-contract.md").split("## settlement-draft-service", 1)[1] + assert re.search(r"db_path.*(?:internal|service|gateway).*(?:not|omitted|제외).*(?:MCP|inputSchema)", interface, re.I | re.S), \ + "document must distinguish internal db_path from MCP inputSchema" + assert "db_path: str | None = None" in interface + service = ast.parse(text("jarca_ledger_mcp/application/settlement_draft_service.py")) + names = {n.name for n in ast.walk(service) if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))} + assert "create_settlement_draft" in names + + +def test_legacy_close_fields_are_scoped_separately_from_draft_finalize() -> None: + interface = text("docs/interface-contract.md") + legacy = interface.split("## D04 snapshot close output contract", 1)[1].split("## settlement-draft-service", 1)[0] + draft = interface.split("## settlement-draft-service", 1)[1] + assert re.search(r"legacy direct close", legacy, re.I), "exact field list must be scoped to legacy close" + assert re.search(r"additive settlement-draft finalize response", legacy, re.I) and re.search(r"preserving common aliases", draft, re.I), "draft finalize output extension and retained common aliases must be distinguished" + assert not re.search(r"new.*(?:identical|same).*shape", draft, re.I) + + +def test_requirement_authority_scope_distinguishes_approved_drafts_and_automatic_selection() -> None: + req = REQ.read_text(encoding="utf-8") + constraints = req.split("## 6. 제약과 불변조건", 1)[1].split("## 7.", 1)[0] + assert re.search(r"automatic.*`exact`.*`as_of`", constraints, re.I), "future exclusion must explicitly scope automatic exact/as_of selection" + assert re.search(r"legacy direct close", constraints, re.I), \ + "future exclusion must explicitly scope legacy direct close" + assert re.search(r"verified.*future.*manual_substitute", constraints, re.I | re.S), \ + "verified future valuation may only enter an explicitly approved draft substitute" + assert all(term in constraints for term in ("reason", "review", "approval", "original")), \ + "draft substitute must preserve reason, review, approval, and original dates" + assert re.search(r"unverified.*rejected.*unknown.*(?:any|all).*path", constraints, re.I | re.S), \ + "unverified, rejected, or unknown amounts are never authority on any path" + assert "future_manual_asset" not in constraints diff --git a/tests/test_settlement_draft_legacy_shapes.py b/tests/test_settlement_draft_legacy_shapes.py new file mode 100644 index 0000000..72c11fd --- /dev/null +++ b/tests/test_settlement_draft_legacy_shapes.py @@ -0,0 +1,24 @@ +"""Additive draft lineage must preserve legacy persisted evidence shapes.""" +import pytest + +from jarca_ledger_mcp.application.assets_service import _closed_run_metadata + + +@pytest.mark.parametrize('field,public_field', [ + ('excluded_evidence', 'excluded_items'), ('carried_evidence', 'carried_items'), +]) +def test_legacy_run_evidence_does_not_gain_draft_identity_keys(field, public_field): + evidence = {'source_type': 'manual_asset', 'source_id': 'synthetic', 'reason': 'missing_valuation_amount', 'currency': 'KRW'} + metadata = {'snapshot_run_id': 'legacy-run', 'draft_id': '', 'draft_version': 0, 'review_fingerprint': '', field: [evidence]} + result = _closed_run_metadata(metadata) + assert result[public_field] == [evidence] + assert 'blocking' not in result[public_field][0] + assert not {'draft_id', 'draft_version', 'review_fingerprint'} & result[public_field][0].keys() + + +def test_draft_evidence_retains_its_persisted_lineage_and_selection_reason(): + evidence = {'source_type': 'account', 'source_id': 'synthetic', 'draft_id': 'draft', 'draft_version': 2, 'review_fingerprint': 'fingerprint', 'selection_mode': 'manual_substitute', 'selection_reason': 'synthetic statement', 'item_version': 2} + metadata = {'snapshot_run_id': 'draft-run', 'applied_evidence': [evidence]} + result = _closed_run_metadata(metadata) + assert result['applied_evidence'] == [evidence] + assert result['contains_substitutions'] diff --git a/tests/test_settlement_draft_mcp_runtime.py b/tests/test_settlement_draft_mcp_runtime.py new file mode 100644 index 0000000..b71435a --- /dev/null +++ b/tests/test_settlement_draft_mcp_runtime.py @@ -0,0 +1,104 @@ +"""Exercise settlement MCP lifecycle through the repository's real stdio server.""" +import asyncio +from contextlib import closing +import inspect +import json +import os +import sqlite3 +import sys +from pathlib import Path + +import pytest + +mcp = pytest.importorskip('mcp') +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client + +OPS = ('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') +ROOT = Path(__file__).resolve().parents[1] + + +def payload(result): + assert not result.isError, result + assert len(result.content) == 1 and result.content[0].type == 'text' + data = json.loads(result.content[0].text) + assert isinstance(data, dict) and isinstance(data.get('success'), bool) + return data + + +def rows(db, table): + assert table in {'asset_snapshot_runs', 'asset_snapshots', 'settlement_drafts', 'settlement_draft_items'} + with closing(sqlite3.connect(db)) as connection: + return connection.execute(f'SELECT * FROM {table} ORDER BY rowid').fetchall() + + +def test_real_stdio_startup_and_all_ten_lifecycle_tools(tmp_path): + asyncio.run(_exercise_stdio_lifecycle(tmp_path)) + + +async def _exercise_stdio_lifecycle(tmp_path): + from jarca_ledger_mcp import service as service + db = tmp_path / 'mcp-runtime.sqlite3' + env = os.environ.copy() + env.update(JARCA_LEDGER_DB_PATH=str(db), JARCA_MCP_TRANSPORT='stdio', PYTHONDONTWRITEBYTECODE='1') + # The test owns this bounded stdio child; stdio_client reaps it on context exit. + params = StdioServerParameters(command=sys.executable, args=['-B', '-m', 'jarca_ledger_mcp.server'], env=env, cwd=str(ROOT)) + async with stdio_client(params) as (read, write): + async with ClientSession(read, write) as session: + initialized = await session.initialize() + assert initialized.serverInfo.name == 'jarca-ledger-mcp' + listed = await session.list_tools() + tool_map = {tool.name: tool for tool in listed.tools} + assert set(OPS) <= set(tool_map) + for name in OPS: + schema = tool_map[name].inputSchema + props = schema.get('properties', {}) + assert 'db_path' not in props + parameters = inspect.signature(getattr(service, name)).parameters + assert set(props) == set(parameters) - {'db_path'} + assert set(schema.get('required', [])) == {key for key, value in parameters.items() + if key != 'db_path' and value.default is inspect.Parameter.empty} + + # Seed through a real service database setup, never through MCP stubs. + from jarca_ledger_mcp.db import init_db + + init_db(str(db)) + account = service.create_ledger_account('Synthetic MCP cash', db_path=str(db))['account']['id'] + service.register_account_valuation(account, '2026-09-30', 100, verification_status='verified', idempotency_key='mcp-value', db_path=str(db)) + + async def call(name, args): + return payload(await session.call_tool(name, args)) + + made = await call('create_settlement_draft', {'month':'2026-09','created_by':'stdio-test','idempotency_key':'mcp-draft'}) + assert made['success'] + draft_id = made['draft']['id'] + assert (await call('list_settlement_drafts', {'month':'2026-09'}))['drafts'][0]['id'] == draft_id + fetched = await call('get_settlement_draft', {'draft_id':draft_id}) + draft = fetched['draft'] + updated = await call('update_settlement_draft', {'draft_id':draft_id,'expected_version':draft['version'],'updated_by':'stdio-test','memo':'transport lifecycle'}) + draft = updated['draft'] + refreshed = await call('refresh_settlement_draft_targets', {'draft_id':draft_id,'expected_version':draft['version'],'updated_by':'stdio-test'}) + draft = refreshed['draft'] + changed = await call('set_settlement_draft_item', {'draft_id':draft_id,'source_type':'account','source_id':account,'expected_version':draft['version'],'selected_by':'stdio-test','valuation_id':service.list_account_valuations(account, db_path=str(db))['valuations'][0]['id']}) + draft = changed['draft'] + reviewed = await call('review_settlement_draft', {'draft_id':draft_id}) + assert reviewed['success'], reviewed + fingerprint = reviewed['review_fingerprint'] + approved = await call('approve_settlement_draft', {'draft_id':draft_id,'expected_version':draft['version'],'review_fingerprint':fingerprint,'approved_by':'stdio-test','approval_reason':'transport acceptance','approval_reference':'test'}) + assert approved['success'] + closed = await call('finalize_settlement_draft', {'draft_id':draft_id,'expected_version':draft['version'],'review_fingerprint':fingerprint,'idempotency_key':'mcp-close'}) + assert closed['success'] and closed['totals']['net_worth'] == 100 + before = (await call('get_settlement_draft', {'draft_id':draft_id})) + tables = ('asset_snapshot_runs', 'asset_snapshots', 'settlement_drafts', 'settlement_draft_items') + before_replay = {table: rows(db, table) for table in tables} + assert await call('finalize_settlement_draft', {'draft_id':draft_id,'expected_version':draft['version'],'review_fingerprint':fingerprint,'idempotency_key':'mcp-close'}) == closed + assert await call('get_settlement_draft', {'draft_id':draft_id}) == before + assert {table: rows(db, table) for table in tables} == before_replay + + second = await call('create_settlement_draft', {'month':'2026-10','created_by':'stdio-test','idempotency_key':'mcp-cancel'}) + assert second['success'] + cancelled = await call('cancel_settlement_draft', {'draft_id':second['draft']['id'],'expected_version':second['draft']['version'],'cancelled_by':'stdio-test','reason':'synthetic cancellation'}) + assert cancelled['success'] and cancelled['draft']['status'] == 'cancelled' diff --git a/tests/test_settlement_draft_panel_feedback.py b/tests/test_settlement_draft_panel_feedback.py new file mode 100644 index 0000000..d3a5c83 --- /dev/null +++ b/tests/test_settlement_draft_panel_feedback.py @@ -0,0 +1,58 @@ +"""Operator feedback distinguishes read failures and partially committed saves.""" +import pytest +from streamlit.testing.v1 import AppTest + +from jarca_ledger_mcp import service as s +from jarca_ledger_mcp.adapters.streamlit import settlement_gateway +from jarca_ledger_mcp.db import init_db + + +def panel(monkeypatch, path): + monkeypatch.setenv('JARCA_LEDGER_DB_PATH', str(path)) + app = AppTest.from_string('from dashboard.page_views.settlement_drafts import render_settlement_drafts\nrender_settlement_drafts()\n').run(timeout=20) + assert not app.exception + return app + + +def control(elements, label): + return next(element for element in elements if element.label == label) + + +def test_panel_shows_safe_error_when_draft_listing_fails(tmp_path, monkeypatch): + path = tmp_path / 'missing.sqlite3' + monkeypatch.setattr(settlement_gateway, 'list_settlement_drafts', lambda *args, **kwargs: { + 'success': False, 'error_code': 'storage_error', 'message': 'SYNTHETIC_PRIVATE_DIAGNOSTIC', + }) + app = panel(monkeypatch, path) + assert app.error and 'storage_error' in ' '.join(element.value for element in app.error) + assert not any('초안이 없습니다' in element.value for element in app.info) + visible = ' '.join(element.value for elements in (app.error, app.info, app.caption) for element in elements) + assert 'SYNTHETIC_PRIVATE_DIAGNOSTIC' not in visible + assert not path.exists() + + +def test_panel_reports_partial_save_when_item_update_fails(tmp_path, monkeypatch): + path = str(tmp_path / 'partial.sqlite3') + init_db(path) + account = s.create_ledger_account('Synthetic cash', db_path=path)['account']['id'] + assert s.register_account_valuation(account, '2026-09-30', 10, verification_status='verified', + idempotency_key='synthetic-value', db_path=path)['success'] + app = panel(monkeypatch, path) + control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20) + control(app.button, '검토 / 새로고침 대상').click().run(timeout=20) + draft = s.list_settlement_drafts('2026-09', db_path=path)['drafts'][0] + review_key = f"review_result_{draft['id']}" + assert app.session_state[review_key]['success'] + control(app.text_input, '초안 메모 저장').set_value('Synthetic persisted memo') + control(app.text_input, '대상 ID').set_value('synthetic-nonexistent-target') + control(app.button, '저장 / 지정 / 포함·제외').click().run(timeout=20) + assert not app.exception + current = s.get_settlement_draft(draft['id'], db_path=path) + assert current['draft']['memo'] == 'Synthetic persisted memo' + assert current['draft']['version'] == draft['version'] + 1 + assert len(current['items']) == 1 and current['items'][0]['source_id'] == account + assert app.error + assert any('메모는 저장되었지만 항목 변경은 실패했습니다' in element.value for element in app.warning) + with pytest.raises(KeyError): + app.session_state[review_key] + assert not any(element.label in {'검토 결과 승인', '최종 마감'} for element in app.button) diff --git a/tests/test_settlement_draft_panel_workflow.py b/tests/test_settlement_draft_panel_workflow.py new file mode 100644 index 0000000..fce8356 --- /dev/null +++ b/tests/test_settlement_draft_panel_workflow.py @@ -0,0 +1,154 @@ +"""Real AppTest controls use synthetic SQLite, not mocked gateway results. + +These are lower-level panel tests; existing dashboard authentication suites own +entrypoint authentication acceptance. +""" +import pytest +from streamlit.testing.v1 import AppTest + +from jarca_ledger_mcp import service as s +from jarca_ledger_mcp.db import init_db + + +def control(elements, label): + return next(e for e in elements if e.label == label) + + +def app_for(tmp_path, monkeypatch, path): + monkeypatch.setenv('JARCA_LEDGER_DB_PATH', str(path)) + appfile = tmp_path / 'panel.py' + appfile.write_text('from dashboard.page_views.settlement_drafts import render_settlement_drafts\nrender_settlement_drafts()\n') + app = AppTest.from_file(str(appfile)).run(timeout=20) + assert not app.exception + return app + + +def test_panel_selects_after_date_evidence_approves_and_finalizes_actual_database(tmp_path, monkeypatch): + path = str(tmp_path / 'panel.sqlite3') + init_db(path) + account = s.create_ledger_account('Synthetic retirement', type='investment', liquidity_bucket='investment', db_path=path)['account']['id'] + value = s.register_account_valuation(account, '2026-10-05', 100, valuation_kind='total_value', verification_status='verified', idempotency_key='future', db_path=path)['valuation']['id'] + app = app_for(tmp_path, monkeypatch, path) + control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20) + control(app.text_input, '대상 ID').set_value(account) + control(app.text_input, '평가자료 ID (비우면 해제)').set_value(value) + control(app.selectbox, '선택 방식').select('manual_substitute') + control(app.text_input, '선택 사유').set_value('Synthetic late statement') + control(app.button, '저장 / 지정 / 포함·제외').click().run(timeout=20) + assert not app.exception and not app.error + control(app.button, '검토 / 새로고침 대상').click().run(timeout=20) + assert any('대체 평가자료를 포함한 월정산' in w.value for w in app.warning) + control(app.text_input, '승인 사유').set_value('Synthetic reviewer accepted date') + control(app.text_input, '승인 근거').set_value('Synthetic reference') + control(app.checkbox, '대체자료 승인').check() + control(app.button, '검토 결과 승인').click().run(timeout=20) + assert not app.exception and not app.error + control(app.button, '최종 마감').click().run(timeout=20) + assert not app.exception and not app.error + # AppTest 1.58 retains pre-rerun controls in the click-run delta tree, + # including in a flat synthetic app. Check immediate history here; + # control absence below uses a fresh instance, not a claim about live DOM. + assert any('불변 이력' in e.value for e in app.markdown) + drafts = s.list_settlement_drafts('2026-09', db_path=path)['drafts'] + assert len(drafts) == 1 and drafts[0]['status'] == 'completed' + final = s.get_settlement_draft(drafts[0]['id'], db_path=path)['final_response'] + assert final['totals']['net_worth'] == 100 and final['contains_substitutions'] + assert final['items'][0]['selection_reason'] == 'Synthetic late statement' + # Reusing the stale tree also raises KeyError for removed selectbox state. + # Reopen the real panel against the same completed SQLite draft instead. + app = app_for(tmp_path, monkeypatch, path) + assert not app.exception and '불변 이력' in ' '.join(e.value for e in app.markdown) + assert not any(e.label == '최종 마감' for e in app.button) + assert not any(e.label == '검토 결과 승인' for e in app.button) + assert not any('현재 승인 대상 fingerprint:' in e.value for e in app.caption) + + +def test_panel_valuation_listing_shows_real_selected_evidence_ids(tmp_path, monkeypatch): + path = str(tmp_path / 'list.sqlite3') + init_db(path) + account = s.create_ledger_account('Synthetic cash', db_path=path)['account']['id'] + vid = s.register_account_valuation(account, '2026-09-30', 10, verification_status='verified', idempotency_key='value', db_path=path)['valuation']['id'] + app = app_for(tmp_path, monkeypatch, path) + control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20) + control(app.text_input, '대상 ID').set_value(account) + control(app.button, '평가자료 조회').click().run(timeout=20) + assert not app.exception + assert vid in ' '.join(e.value for e in app.json) + + +def test_panel_supports_investment_total_and_explicit_loan_definition(tmp_path, monkeypatch): + path = str(tmp_path / 'register.sqlite3') + init_db(path) + loan = s.create_ledger_account('Synthetic loan', type='loan', liquidity_bucket='loan', db_path=path)['account']['id'] + app = app_for(tmp_path, monkeypatch, path) + control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20) + assert {'total_value', 'cash_balance'} <= set(control(app.selectbox, '평가 종류').options) + control(app.text_input, '평가 등록 계좌 ID').set_value(loan) + control(app.text_input, '평가일 YYYY-MM-DD').set_value('2026-09-30') + control(app.number_input, '금액').set_value(40) + control(app.selectbox, '평가 종류').select('loan_balance') + control(app.selectbox, '대표 부채 정의').select('principal_only') + control(app.selectbox, '확인 상태').select('verified') + control(app.text_input, '등록 idempotency key').set_value('synthetic-loan-value') + control(app.button, '평가자료 등록').click().run(timeout=20) + assert not app.exception and not app.error + values = s.list_account_valuations(loan, db_path=path)['valuations'] + assert len(values) == 1 and values[0]['representative_liability_definition'] == 'principal_only' + assert values[0]['amount'] == 40 + + +def test_panel_edit_invalidates_cached_review_before_approval(tmp_path, monkeypatch): + path = str(tmp_path / 'edit.sqlite3') + init_db(path) + account = s.create_ledger_account('Synthetic cash', db_path=path)['account']['id'] + s.register_account_valuation(account, '2026-09-30', 10, verification_status='verified', idempotency_key='value', db_path=path) + app = app_for(tmp_path, monkeypatch, path) + control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20) + control(app.button, '검토 / 새로고침 대상').click().run(timeout=20) + draft = s.list_settlement_drafts('2026-09', db_path=path)['drafts'][0] + key = f"review_result_{draft['id']}" + old = app.session_state[key]['review_fingerprint'] + assert any(old in e.value and '현재 승인 대상' in e.value for e in app.caption) + control(app.text_input, '초안 메모 저장').set_value('Synthetic edited memo') + control(app.button, '저장 / 지정 / 포함·제외').click().run(timeout=20) + assert not app.exception and not app.error + assert not any(old in e.value and '현재 승인 대상' in e.value for e in app.caption) + assert not any(e.label == '검토 결과 승인' for e in app.button) + assert not any(e.label == '최종 마감' for e in app.button) + with pytest.raises(KeyError): + app.session_state[key] + current = s.get_settlement_draft(draft['id'], db_path=path)['draft'] + assert current['version'] == draft['version'] + 1 and current['memo'] == 'Synthetic edited memo' + control(app.button, '검토 / 새로고침 대상').click().run(timeout=20) + control(app.text_input, '승인 사유').set_value('Synthetic fresh review') + control(app.text_input, '승인 근거').set_value('Synthetic reference') + control(app.button, '검토 결과 승인').click().run(timeout=20) + assert not app.exception and not app.error + control(app.button, '최종 마감').click().run(timeout=20) + assert not app.exception and not app.error + assert s.get_settlement_draft(draft['id'], db_path=path)['draft']['status'] == 'completed' + + +def test_panel_correction_invalidates_review_without_registration_account_input(tmp_path, monkeypatch): + path = str(tmp_path / 'correction.sqlite3') + init_db(path) + account = s.create_ledger_account('Synthetic cash', db_path=path)['account']['id'] + vid = s.register_account_valuation(account, '2026-09-30', 10, verification_status='verified', idempotency_key='value', db_path=path)['valuation']['id'] + app = app_for(tmp_path, monkeypatch, path) + control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20) + control(app.button, '검토 / 새로고침 대상').click().run(timeout=20) + draft = s.list_settlement_drafts('2026-09', db_path=path)['drafts'][0] + old = app.session_state[f"review_result_{draft['id']}"]['review_fingerprint'] + assert control(app.text_input, '평가 등록 계좌 ID').value == '' + control(app.text_input, '정정할 valuation ID').set_value(vid) + control(app.number_input, '정정 금액').set_value(20) + control(app.text_input, '평가 정정 사유').set_value('Synthetic correction') + control(app.text_input, '평가 정정 키').set_value('synthetic-child') + control(app.selectbox, '정정 확인 상태').select('verified') + control(app.button, '평가자료 정정').click().run(timeout=20) + assert not app.exception and not app.error + assert not any(old in e.value and '현재 승인 대상' in e.value for e in app.caption) + assert not any(e.label == '검토 결과 승인' for e in app.button) + detail = s.get_settlement_draft(draft['id'], db_path=path) + assert detail['items'][0]['review_needed'] and detail['items'][0]['valuation_id'] == vid + assert len(s.list_account_valuations(account, db_path=path)['valuations']) == 2 diff --git a/tests/test_settlement_draft_read_models.py b/tests/test_settlement_draft_read_models.py new file mode 100644 index 0000000..f970496 --- /dev/null +++ b/tests/test_settlement_draft_read_models.py @@ -0,0 +1,42 @@ +"""Consumer contracts for immutable draft-close evidence.""" +import json +import sqlite3 + +from jarca_ledger_mcp import service +from jarca_ledger_mcp.db import init_db + + +def test_closed_draft_evidence_is_additive_and_strictly_redacted(tmp_path): + path = str(tmp_path / "synthetic.sqlite3") + init_db(path) + with sqlite3.connect(path) as conn: + conn.execute("INSERT INTO asset_snapshot_runs(id,basis_date,close_status,selection_policy,draft_id,draft_version,review_fingerprint,applied_evidence,excluded_evidence,carried_evidence,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)", ( + "run-draft", "2026-09-30", "conditional", "exact", "draft-1", 7, "fingerprint-1", + json.dumps([{"source_type":"manual_asset","source_id":"asset-1","name":"Synthetic","amount":100,"currency":"KRW","valuation_date":"2026-09-29","age_days":1,"carried_forward":True,"selection_mode":"carried_before","reason":"accepted","selected_by":"reviewer","selected_at":"2026-09-30T01:00:00Z","item_version":3,"verification_status":"verified","source_ref":"ref-1","source_attributes":{"bucket":"real_estate","secret":"hidden"},"approval":{"reference":"approval-1","memo":"hidden"},"unknown":{"credential":"hidden"}}]), + json.dumps([{"source_type":"account","source_id":"acct-1","reason":"approved_missing","approval_reference":"ref-2","unknown_secret":"hidden"}]), + json.dumps([{"source_type":"manual_asset","source_id":"asset-1","valuation_date":"2026-09-29","age_days":1,"carried_forward":True,"selection_mode":"carried_before","reason":"accepted","selected_by":"reviewer","selected_at":"2026-09-30T01:00:00Z","item_version":3,"verification_status":"verified","source_ref":"ref-1"}]), + "2026-09-30T02:00:00Z")) + conn.execute("INSERT INTO asset_snapshots(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,snapshot_run_id,valuation_id,created_at) VALUES('snap-1','2026-09-30','manual_asset','asset-1','Synthetic',100,'KRW','real_estate','run-draft','','2026-09-30T02:00:00Z')") + conn.commit() + report = service.get_asset_snapshot_report("2026-09-30", db_path=path) + assert report["success"] + metadata = report["close_metadata"] + assert (metadata["draft_id"], metadata["draft_version"], metadata["review_fingerprint"]) == ("draft-1", 7, "fingerprint-1") + assert metadata["contains_carried"] is True and metadata["contains_substitutions"] is False and metadata["has_approved_missing"] is True + evidence = metadata["applied_evidence"][0] + for key in ("valuation_date", "age_days", "selection_mode", "reason", "selected_by", "selected_at", "item_version", "verification_status", "source_ref", "source_attributes", "approval"): + assert key in evidence + serialized = json.dumps(report) + assert "hidden" not in serialized and "credential" not in serialized and "unknown" not in serialized + series = service.get_net_worth_timeseries("2026-09-01", "2026-09-30", db_path=path) + assert series["points"][0]["draft_id"] == "draft-1" + assert series["points"][0]["contains_carried"] is True + monthly = service.get_monthly_settlement_summary("2026-09", db_path=path) + assert monthly["source_coverage"]["assets"]["draft_id"] == "draft-1" + + +def test_active_cancelled_drafts_do_not_create_timeseries_points(tmp_path): + path = str(tmp_path / "synthetic.sqlite3") + init_db(path) + result = service.get_net_worth_timeseries("2026-09-01", "2026-09-30", db_path=path) + assert result["success"] and result["points"] == [] diff --git a/tests/test_settlement_draft_selection.py b/tests/test_settlement_draft_selection.py new file mode 100644 index 0000000..7b65686 --- /dev/null +++ b/tests/test_settlement_draft_selection.py @@ -0,0 +1,129 @@ +"""Strict selection, conditional authority and public close regressions.""" +import pytest + +from jarca_ledger_mcp import service as s +from jarca_ledger_mcp.db import init_db + + +def ok(r): + assert r['success'], r + return r + + +def setup(tmp_path, currency='KRW'): + path = str(tmp_path / 'scope.sqlite3') + init_db(path) + account = ok(s.create_ledger_account('Synthetic investment', type='investment', currency=currency, liquidity_bucket='investment', db_path=path))['account']['id'] + return path, account + + +def valuation(path, account, key='v', **kwargs): + values = dict(basis_date='2026-09-30', amount=100, valuation_kind='total_value', verification_status='verified', idempotency_key=key, db_path=path) + values.update(kwargs) + return ok(s.register_account_valuation(account, **values))['valuation']['id'] + + +def draft(path): + return ok(s.create_settlement_draft('2026-09', 'creator', 'create', db_path=path))['draft'] + + +def select(path, d, account, value, **kwargs): + return s.set_settlement_draft_item(d['id'], 'account', account, d['version'], 'selector', valuation_id=value, db_path=path, **kwargs) + + +def test_automatic_selection_preserves_representative_priority_and_verified_status(tmp_path): + path, account = setup(tmp_path) + representative = valuation(path, account) + valuation(path, account, 'cash', amount=30, valuation_kind='cash_balance') + valuation(path, account, 'candidate', amount=900, verification_status='unverified') + d = draft(path) + review = ok(s.review_settlement_draft(d['id'], db_path=path)) + row = next(x for x in review['items'] if x['source_id'] == account) + assert row['valuation_id'] == representative + assert review['totals']['net_worth'] == 100 + + +def test_automatic_ambiguous_representative_remains_missing_until_explicit_choice(tmp_path): + path, account = setup(tmp_path) + chosen = valuation(path, account) + valuation(path, account, 'other', amount=200) + d = draft(path) + r = ok(s.review_settlement_draft(d['id'], db_path=path)) + assert r['missing_items'] and r['totals']['net_worth'] == 0 + selected = ok(select(path, d, account, chosen))['draft'] + assert ok(s.review_settlement_draft(selected['id'], db_path=path))['totals']['net_worth'] == 100 + + +@pytest.mark.parametrize('status,mode', [('unverified','exact'), ('unverified','carried_before'), ('rejected','manual_substitute')]) +def test_invalid_verification_never_selected_as_confirmed_authority(tmp_path, status, mode): + path, account = setup(tmp_path) + value = valuation(path, account, verification_status=status, basis_date='2026-09-29' if mode == 'carried_before' else '2026-09-30') + d = draft(path) + result = select(path, d, account, value, selection_mode=mode, selection_reason='synthetic reason') + assert not result['success'], result + assert ok(s.get_settlement_draft(d['id'], db_path=path))['draft']['version'] == d['version'] + + +def test_non_krw_candidate_is_missing_null_authority_not_converted(tmp_path): + path, account = setup(tmp_path, currency='USD') + value = valuation(path, account, currency='USD') + d = draft(path) + ok(select(path, d, account, value)) + r = ok(s.review_settlement_draft(d['id'], db_path=path)) + assert r['missing_items'] and not r['included_items'] + assert r['items'][0]['amount'] is None + assert r['items'][0]['candidate_amount'] == 100 + assert r['totals']['net_worth'] == 0 and r['warnings'] + + +def test_manual_loan_is_representative_liability_and_future_manual_is_not_substitute(tmp_path): + path = str(tmp_path / 'manual-loan.sqlite3') + init_db(path) + ok(s.create_manual_asset('Synthetic liability', 40, '2026-09-30', 'loan', db_path=path)) + future = ok(s.create_manual_asset('Future property', 100, '2026-10-05', 'real_estate', db_path=path))['manual_asset']['id'] + d = draft(path) + r = ok(s.review_settlement_draft(d['id'], db_path=path)) + assert r['totals']['total_assets'] == 0 and r['totals']['total_liabilities'] == 40 + result = s.set_settlement_draft_item(d['id'], 'manual', future, d['version'], 'selector', selection_mode='manual_substitute', selection_reason='not a valuation id', db_path=path) + assert not result['success'], result + + +@pytest.mark.parametrize('bad', [None, True, 1, [], {}]) +def test_invalid_creator_is_rejected_before_database_creation(tmp_path, bad): + path = tmp_path / 'absent' / 'db.sqlite3' + result = s.create_settlement_draft('2026-09', bad, 'key', db_path=str(path)) + assert not result['success'] and result['error_code'] == 'validation_error', result + assert not path.parent.exists() + + +@pytest.mark.parametrize('bad', ['yes', 1, None]) +def test_approval_flags_require_actual_boolean_and_preserve_state(tmp_path, bad): + path, account = setup(tmp_path) + valuation(path, account) + d = draft(path) + r = ok(s.review_settlement_draft(d['id'], db_path=path)) + result = s.approve_settlement_draft(d['id'], d['version'], r['review_fingerprint'], 'reviewer', 'synthetic reason', 'ref', allow_missing_exclusions=bad, db_path=path) + assert not result['success'] and result['error_code'] == 'validation_error', result + assert not ok(s.get_settlement_draft(d['id'], db_path=path))['draft']['approved_by'] + + +def test_finalized_substitute_retains_dates_flags_and_public_consumer_evidence(tmp_path): + path, account = setup(tmp_path) + v = valuation(path, account, basis_date='2026-10-05') + d = draft(path) + d = ok(select(path, d, account, v, selection_mode='manual_substitute', selection_reason='synthetic after-date'))['draft'] + r = ok(s.review_settlement_draft(d['id'], db_path=path)) + ok(s.approve_settlement_draft(d['id'], d['version'], r['review_fingerprint'], 'reviewer', 'substitution accepted', 'ref', allow_manual_substitute=True, db_path=path)) + final = ok(s.finalize_settlement_draft(d['id'], d['version'], r['review_fingerprint'], 'final', db_path=path)) + assert final['contains_substitutions'] is True + assert final['has_approved_missing'] is False + assert final['contains_carried'] is False + assert final['valuation_date'] and 'age_days' in final and 'verification_status' in final and 'evidence_kind' in final + report = ok(s.get_asset_snapshot_report('2026-09-30', db_path=path)) + assert report['net_worth'] == 100 + assert report['close_metadata']['contains_substitutions'] is True + evidence = report['close_metadata']['applied_evidence'][0] + assert evidence['valuation_date'] == '2026-10-05' + assert evidence['date_difference_days'] == 5 and evidence['selection_mode'] == 'manual_substitute' + assert evidence['selection_reason'] == 'synthetic after-date' + assert s.finalize_settlement_draft(d['id'], d['version'], r['review_fingerprint'], 'final', db_path=path) == final