feat: extend comprehensive monthly settlement snapshots (#18)

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