feat: extend comprehensive monthly settlement snapshots (#18)
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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가 변경되었습니다. 다시 검토하세요.")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 순서를 보존한다.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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,6 +35,11 @@ def _guard(db_path: str | None) -> str:
|
||||
|
||||
def _wrap(fn):
|
||||
def call(*args, db_path=None, **kwargs):
|
||||
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__
|
||||
@@ -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] = {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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']
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user