feat: add persistent monthly settlement drafts and approved valuation selection

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