Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dda76b3bbd | ||
|
|
ffc5151c6b | ||
|
|
58a87b8d0d | ||
|
|
53ce20956d | ||
|
|
10201cdb75 | ||
|
|
ae4383ca2c | ||
|
|
616bb7a744 | ||
|
|
dd35e0785f | ||
|
|
f67c07adf0 | ||
|
|
0954d47767 | ||
|
|
565ed8779c | ||
|
|
fa51b4dbb5 | ||
|
|
bae137dce7 | ||
|
|
4c5cb0627c | ||
|
|
577bd0b912 | ||
|
|
6fbe81a5d3 | ||
|
|
92f44661c3 |
@@ -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,8 @@ import streamlit as st
|
||||
|
||||
from jarca_ledger_mcp.adapters.streamlit import overview_gateway
|
||||
from jarca_ledger_mcp.adapters.streamlit import reconciliation_gateway
|
||||
from jarca_ledger_mcp.adapters.streamlit import settlement_gateway
|
||||
from dashboard.page_views.settlement_drafts import render_settlement_drafts
|
||||
from dashboard.common import * # noqa: F403, F405
|
||||
|
||||
|
||||
@@ -265,5 +267,36 @@ def page_monthly_settlement(db_path: str) -> None:
|
||||
for action in action_candidates:
|
||||
st.markdown(f"- {action}")
|
||||
|
||||
st.divider()
|
||||
st.markdown("### 저장된 월정산 스냅샷")
|
||||
st.caption("아래는 저장 시점의 불변 기록이며 위의 live KPI/리포트와 별도입니다.")
|
||||
exact_run = st.text_input("정확한 run ID (선택)", key="saved_settlement_run_id")
|
||||
saved = settlement_gateway.get_monthly_settlement_snapshot(month=selected_month, run_id=exact_run, db_path=db_path)
|
||||
if not saved.get("success"):
|
||||
st.info("저장된 스냅샷이 없거나 조회할 수 없습니다.")
|
||||
else:
|
||||
snapshot = saved.get("snapshot") if isinstance(saved.get("snapshot"), dict) else saved
|
||||
st.caption(f"run {snapshot.get('run_id', snapshot.get('snapshot_run_id', '미보존'))} · 버전 {snapshot.get('aggregation_version') or 'legacy'} · 출처 {snapshot.get('source', '미보존')}")
|
||||
st.caption(f"close_status: {snapshot.get('close_status', '미보존')} · legacy: {snapshot.get('legacy', '미보존')} · comprehensive: {snapshot.get('is_comprehensive', '미보존')}")
|
||||
if snapshot.get("close_status") == "conditional":
|
||||
st.caption("조건부 마감 상태 (부분 승인 조건 적용)")
|
||||
for key, label in (("income_total", "수입"), ("expense_total", "지출"), ("net_cashflow", "순현금흐름"), ("total_assets", "자산"), ("total_liabilities", "부채"), ("net_worth", "순자산")):
|
||||
st.write(label, snapshot.get(key) if snapshot.get(key) is not None else "미보존")
|
||||
rows = snapshot.get("cashflow_items", [])
|
||||
if isinstance(rows, list): st.dataframe([{k: row.get(k) for k in ("cashflow_kind", "category", "subcategory", "amount", "transaction_count", "currency")} for row in rows if isinstance(row, dict)])
|
||||
st.markdown("**저장된 헤더 근거**")
|
||||
_render_asset_evidence({key: snapshot.get(key) for key in ("excluded_items", "carried_items", "approval")})
|
||||
source_snapshot = snapshot.get("source_snapshot") if isinstance(snapshot.get("source_snapshot"), dict) else {}
|
||||
asset_evidence = snapshot.get("items") if isinstance(snapshot.get("items"), list) else []
|
||||
source_assets = source_snapshot.get("assets") if isinstance(source_snapshot.get("assets"), list) else []
|
||||
safe_asset_fields = ("source_type", "source_id", "status", "valuation_id", "valuation_date", "amount", "applied_amount", "verification_status", "authority_source", "exclusion_reason", "currency", "name", "evidence_kind", "valuation_source", "selection_mode", "selection_policy")
|
||||
st.markdown("**저장 자산 근거**")
|
||||
st.caption("누락(missing)·제외(excluded) 항목과 저장된 사유를 표시합니다. 미보존 근거는 추정하지 않습니다.")
|
||||
asset_rows = list(asset_evidence)
|
||||
asset_rows.extend(source_assets)
|
||||
st.dataframe([{k: row.get(k) for k in safe_asset_fields} for row in asset_rows if isinstance(row, dict)])
|
||||
st.divider()
|
||||
render_settlement_drafts(db_path=db_path, month=selected_month)
|
||||
|
||||
st.divider()
|
||||
st.markdown("- [요약 페이지로 이동](?page=요약)")
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
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"}:
|
||||
final = detail.get("final_response") if isinstance(detail.get("final_response"), dict) else {}
|
||||
if final:
|
||||
st.markdown("### 기록된 종합 월정산 결과")
|
||||
st.caption(f"정책 버전: {final.get('aggregation_version', 'legacy')}")
|
||||
for key, label in (("income_total", "수입"), ("expense_total", "지출"), ("net_cashflow", "순현금흐름"), ("total_assets", "자산"), ("total_liabilities", "부채"), ("net_worth", "순자산")):
|
||||
st.write(label, final.get(key) if final.get(key) is not None else "미보존")
|
||||
rows = final.get("cashflow_items", [])
|
||||
if isinstance(rows, list): st.dataframe([{k: row.get(k) for k in ("cashflow_kind", "category", "subcategory", "amount", "transaction_count", "currency")} for row in rows if isinstance(row, dict)])
|
||||
st.markdown("### 불변 이력")
|
||||
st.write("final_run_id", draft.get("final_run_id"))
|
||||
st.write("correction_of_run_id", draft.get("correction_of_run_id"))
|
||||
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원이 아닙니다.")
|
||||
st.markdown("### 종합 월정산 정책")
|
||||
policy_now = draft.get("cashflow_policy") if isinstance(draft.get("cashflow_policy"), dict) else {"savings_categories": [], "category_decisions": []}
|
||||
with st.form(f"cashflow_policy_{selected_id}"):
|
||||
policy_text = st.text_area("정책 JSON (savings_categories 및 category_decisions)", value=json.dumps(policy_now, ensure_ascii=False, indent=2), height=220)
|
||||
policy_submit = st.form_submit_button("정책 저장")
|
||||
if policy_submit:
|
||||
try:
|
||||
policy = json.loads(policy_text)
|
||||
if not isinstance(policy, dict) or not isinstance(policy.get("savings_categories", []), list) or not isinstance(policy.get("category_decisions", []), list): raise ValueError
|
||||
for decision in policy.get("category_decisions", []):
|
||||
if not isinstance(decision, dict) or decision.get("cashflow_kind") not in {"income", "expense", "excluded"}: raise ValueError
|
||||
result = gateway.set_settlement_cashflow_policy(selected_id, version, actor, policy, db_path=db_path)
|
||||
if result.get("success"): _invalidate_and_rerun(selected_id, result)
|
||||
_safe_result(result)
|
||||
except (ValueError, TypeError):
|
||||
st.error("정책은 유효한 JSON이어야 하며 각 분류는 income, expense 또는 excluded로 지정하세요.")
|
||||
|
||||
with st.form(f"settlement_edit_{selected_id}"):
|
||||
memo_edit = st.text_input("초안 메모 저장", value=draft.get("memo", ""))
|
||||
source_type = st.selectbox("대상 유형", ["account", "manual_asset"])
|
||||
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(k) for k in ("income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth")])
|
||||
items_cf = review.get("cashflow_items", [])
|
||||
if isinstance(items_cf, list): st.dataframe([{k: row.get(k) for k in ("cashflow_kind", "category", "subcategory", "amount", "transaction_count", "currency")} for row in items_cf if isinstance(row, dict)])
|
||||
blockers = review.get("cashflow_blockers", [])
|
||||
for blocker in blockers if isinstance(blockers, list) else []:
|
||||
if isinstance(blocker, dict):
|
||||
transaction_id = blocker.get("transaction_id")
|
||||
reason = blocker.get("reason")
|
||||
st.warning(f"거래 {transaction_id if isinstance(transaction_id, str) else '-'} · {reason if isinstance(reason, str) else '현금흐름 블로커'}")
|
||||
else:
|
||||
st.warning("현금흐름 블로커")
|
||||
st.write("누락", review.get("missing_items", []))
|
||||
if review.get("contains_substitutions"):
|
||||
st.warning("대체 평가자료를 포함한 월정산")
|
||||
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("검토 결과 승인", disabled=bool(review.get("cashflow_blockers")))
|
||||
if review.get("cashflow_blockers"): st.caption("현금흐름 블로커를 해소해야 승인할 수 있습니다.")
|
||||
if approve:
|
||||
if st.session_state.get(state_key) != (selected_id, review.get("version", version), fingerprint):
|
||||
st.error("선택한 초안/버전/검토 fingerprint가 변경되었습니다. 다시 검토하세요.")
|
||||
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)
|
||||
@@ -34,7 +34,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
initContainers:
|
||||
- name: init-db
|
||||
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.5
|
||||
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.7
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- python
|
||||
@@ -62,7 +62,7 @@ spec:
|
||||
mountPath: /tmp
|
||||
containers:
|
||||
- name: mcp
|
||||
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.5
|
||||
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.7
|
||||
imagePullPolicy: IfNotPresent
|
||||
env:
|
||||
- name: JARCA_LEDGER_DB_PATH
|
||||
@@ -104,7 +104,7 @@ spec:
|
||||
- name: mcp-tmp
|
||||
mountPath: /tmp
|
||||
- name: dashboard
|
||||
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.5
|
||||
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.7
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- uvicorn
|
||||
|
||||
@@ -174,3 +174,76 @@
|
||||
- 오류 데이터: 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.
|
||||
# Issue #18 — schema 14 comprehensive settlement additions
|
||||
|
||||
### asset_snapshot_runs cashflow field contract
|
||||
|
||||
All twelve comprehensive-run columns are nullable with no SQL default. This preserves NULL on historical asset-only runs; a new comprehensive write supplies all twelve fields. SQLite reports `id` (TEXT PRIMARY KEY) nullable because DDL omits explicit NOT NULL, although a run logically requires its identifier.
|
||||
|
||||
| Column | Type | Nullability | Default |
|
||||
|---|---|---|---|
|
||||
| `settlement_month` | TEXT | NULL | none |
|
||||
| `currency` | TEXT | NULL | none |
|
||||
| `income_total` | INTEGER | NULL | none |
|
||||
| `expense_total` | INTEGER | NULL | none |
|
||||
| `net_cashflow` | INTEGER | NULL | none |
|
||||
| `total_assets` | INTEGER | NULL | none |
|
||||
| `total_liabilities` | INTEGER | NULL | none |
|
||||
| `net_worth` | INTEGER | NULL | none |
|
||||
| `cashflow_policy_json` | TEXT | NULL | none |
|
||||
| `aggregation_version` | TEXT | NULL | none |
|
||||
| `snapshot_schema_version` | INTEGER | NULL | none |
|
||||
| `source_snapshot_json` | TEXT | NULL | none |
|
||||
|
||||
### settlement_drafts cashflow field contract
|
||||
|
||||
| Column | Type | Nullability | Default |
|
||||
|---|---|---|---|
|
||||
| `cashflow_policy_json` | TEXT | NOT NULL | `'{}'` |
|
||||
| `aggregation_version` | TEXT | NOT NULL | `'comprehensive-v1'` |
|
||||
|
||||
### settlement_cashflow_snapshots cashflow field contract
|
||||
|
||||
| Column | Type | Nullability | Default |
|
||||
|---|---|---|---|
|
||||
| `id` | TEXT | NULL | none |
|
||||
| `settlement_run_id` | TEXT | NOT NULL | none |
|
||||
| `cashflow_kind` | TEXT | NOT NULL | none |
|
||||
| `category` | TEXT | NOT NULL | `''` |
|
||||
| `subcategory` | TEXT | NOT NULL | `''` |
|
||||
| `amount` | INTEGER | NOT NULL | none |
|
||||
| `transaction_count` | INTEGER | NOT NULL | none |
|
||||
| `currency` | TEXT | NOT NULL | none |
|
||||
|
||||
`id` is logically required although SQLite reports no explicit NOT NULL flag for this TEXT primary key. `settlement_run_id` references `asset_snapshot_runs(id)` ON DELETE RESTRICT. The unique key is `(settlement_run_id, cashflow_kind, category, subcategory, currency)`. `transaction_count > 0` and amount/count are integers; `idx_settlement_cashflow_snapshots_run` indexes the run.
|
||||
|
||||
### Comprehensive snapshot evidence inputs
|
||||
|
||||
`source_snapshot_json` stores an object with `transactions` and `assets` arrays. Transaction inputs preserve `id`, `transaction_date`, `type`, original signed `amount`, `currency`, `category`, `subcategory`, `originalcashflow_kind`, applied `cashflow_kind`, `applied_amount`, `decision`, `sourceprovenance`, `category_cashflow_kind` and `category_active`. Refunds keep their original positive amount while the expense contribution in `applied_amount` is negative; unresolved classifications keep `applied_amount=NULL` and block approval. The actual stored alias is `originalcashflow_kind`, not a renamed persistence field.
|
||||
|
||||
Asset inputs preserve `source_id`, `source_type`, `valuation_id`, `valuation_date`, `basis_date`, `currency`, `authority_source`, `liquidity_bucket`, `is_liability`, `selection_mode`, `exclusion_reason`, `status`, `verification_status`, original `amount` and `applied_amount`. Missing/excluded evidence retains NULL applied amounts and its recorded status/reason; fixed included rows remain linked to the same run. The run header's `cashflow_policy_json`, `aggregation_version`, `snapshot_schema_version` and these source inputs together reproduce the original selection/aggregation decision without rereading today's ledger. A legacy asset-only run retains NULL comprehensive fields and is reported as legacy: no historical backfill or invented zero. The existing approval, draft, correction and idempotency links remain unchanged.
|
||||
|
||||
`asset_snapshot_runs` remains the run header. Schema 13→14 adds settlement month, currency, six nullable legacy-compatible INTEGER totals (`income_total`, `expense_total`, `net_cashflow`, `total_assets`, `total_liabilities`, `net_worth`), `cashflow_policy_json`, `aggregation_version`, `snapshot_schema_version`, and `source_snapshot_json`; preserve existing basis/approval/draft/correction/idempotency fields. Old asset-only run totals remain NULL; no current-ledger backfill. Migration is additive/idempotent; old unfinished drafts require fresh comprehensive review/approval.
|
||||
|
||||
`settlement_cashflow_snapshots`: eight columns `id`, `settlement_run_id`, `cashflow_kind`, `category`, `subcategory`, `amount`, `transaction_count`, `currency`; `cashflow_kind` is income/expense/excluded, `amount` signed INTEGER, count positive, category fields NOT NULL (empty string is explicit), FK to `asset_snapshot_runs.id` with RESTRICT, unique `(settlement_run_id,cashflow_kind,category,subcategory,currency)`, and run lookup index. Rows are grouped details for exactly one run.
|
||||
|
||||
Policy is stored on draft and header. Savings means only explicit category/subcategory pairs; transfer/voucher exclusion has priority; debt_payment is expense; refund is negative expense in refund month/original category. Unknown unresolved blocks approval; asset conditional flags cannot override it. KRW only/no FX. Source JSON freezes transaction values/classification/policy/decision and selected asset evidence/provenance. Six equations are validated against same-run details; excluded/duplicate/unknown amount do not sum. Header, both detail areas, evidence, completed draft linkage/response commit atomically; rollback all on failure. Runs are immutable; corrections append after latest-parent/single-child validation.
|
||||
|
||||
@@ -424,12 +424,106 @@ 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 retains these required top-level fields as required top-level metadata 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`. This is a required retained metadata-field list, not an exhaustive inventory of all keys in a successful legacy response; additional legacy response keys remain part of the legacy output. The list does not define the additive settlement-draft finalize response. `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.
|
||||
The top-level `provenance` object retains these required nested `provenance` fields (metadata, not an exhaustive inventory of all nested response keys): `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.
|
||||
|
||||
The preview input includes basis_date, selection_policy, included/excluded evidence state hashes, warnings, and candidate_state_hashes. preview output includes preview_fingerprint. Close accepts the exact `preview_fingerprint: str = ""` parameter shown in the signature block and recomputes the same authority fingerprint before opening the write boundary. A mismatch returns `authority_drift` and no partial write.
|
||||
|
||||
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
|
||||
|
||||
`unregistered_liabilities` is true when the frozen target set contains no registered liability source. It does not assert unknown real-world debt or prove the operator is debt-free. Exclusion and missing evidence remain separate completeness states; neither changes the registration meaning of this flag.
|
||||
|
||||
`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.
|
||||
# Issue #18 additive public contracts (schema 14)
|
||||
|
||||
Shared service setter: `set_settlement_cashflow_policy(draft_id,expected_version,updated_by,cashflow_policy,db_path=None)`; stored run getter: `get_monthly_settlement_snapshot(month='',run_id='',db_path=None)`. MCP signatures omit internal `db_path`. Draft policy is `draft.cashflow_policy`; policy edits increment version and invalidate approval. Review/finalize results expose fields at response root; active get nests `review`, completed get has root `final_response`. Stored grouped details use `cashflow_kind` (income/expense/excluded), not `kind`.
|
||||
|
||||
The stored reader recognizes `snapshot_schema_version=1` only with `aggregation_version=comprehensive-v1`. Any non-NULL version metadata requires both recognized versions; a partial pair, unsupported snapshot schema, or absent/unknown aggregation version returns the existing `success=false`, `error_code=db_error`, `error` envelope without authoritative totals/details or DB writes. Legacy NULL schema headers remain legacy reads with unrecorded totals NULL; this does not reinterpret unknown comprehensive versions as legacy.
|
||||
|
||||
Getter distinguishes immutable stored run/evidence (exact run ID, schema/aggregation version, legacy/partial/missing/excluded indicators) from live current-ledger report. Existing asset-only API inputs/responses/errors/aliases and direct-close meaning remain unchanged and are not comprehensive close. Reads/render are readonly/no-create; authenticated Dashboard gate precedes DB. Existing envelope, stale approval/drift rejection, idempotent persisted replay, conflict, atomic rollback, append-only correction, and privacy contracts remain.
|
||||
|
||||
### Comprehensive cashflow policy input
|
||||
|
||||
`set_settlement_cashflow_policy(draft_id, expected_version, updated_by, cashflow_policy, db_path=None)` accepts this normalized input (omitted lists normalize to empty lists):
|
||||
|
||||
```json
|
||||
{"savings_categories":[{"category":"Savings","subcategory":"Deposit"}],"category_decisions":[{"category":"Food","subcategory":"Dining","cashflow_kind":"expense","reason":"Reviewed classification"}]}
|
||||
```
|
||||
|
||||
Only `savings_categories` and `category_decisions` are allowed top-level keys; each is a list. Savings entries have exactly `category` and `subcategory`; decision entries have exactly those keys plus `cashflow_kind` and `reason`. Category fields are strings and blank strings are valid. Kinds are `income`, `expense`, or `excluded`; reason must be a nonempty string. Within each list, duplicate category/subcategory pairs are rejected. Normalization sorts each list and supplies omitted lists as empty.
|
||||
|
||||
The setter requires matching `expected_version`; successful mutation increments version and clears prior approval/review fingerprint. Review computes a new fingerprint; approval applies only to the reviewed version/fingerprint. Changed policy or transactions require re-review and re-approval. `aggregation_version` is `comprehensive-v1`; policy editing increments the draft version, clears approval fields and invalidates the prior `review_fingerprint` for approval.
|
||||
|
||||
### Comprehensive settlement response contract
|
||||
|
||||
The six totals are integer root fields in comprehensive review and saved-run reader responses. Legacy readers return NULL for totals they never stored.
|
||||
|
||||
| Field | Type | Presence | Legacy |
|
||||
|---|---|---|---|
|
||||
| `income_total` | integer | required | null |
|
||||
| `expense_total` | integer | required | null |
|
||||
| `net_cashflow` | integer | required | null |
|
||||
| `total_assets` | integer | required | null |
|
||||
| `total_liabilities` | integer | required | null |
|
||||
| `net_worth` | integer | required | null |
|
||||
|
||||
A review response has root-level `review_fingerprint`, `cashflow_policy`, `cashflow_items`, `cashflow_blockers`, and `source_snapshot`. The active draft getter nests its current review under `review`. For a completed draft, `get_settlement_draft` exposes the persisted close response under `final_response`; `finalize_settlement_draft` returns that close response at root, including on idempotent replay. A saved-run reader instead returns the stored snapshot at root with `source: settlement_snapshot`, `run_id`, and six totals. `cashflow_items` is a required list in comprehensive review, close and saved-reader results (empty is valid): each entry has `cashflow_kind` (income/expense/excluded string), `category` and `subcategory` (strings, including blanks), signed integer `amount`, positive integer `transaction_count`, and string `currency`. `cashflow_blockers` is a required review/close list with string `transaction_id` and `reason` entries; successful close requires it to be empty. The saved reader does not synthesize a blocker list from old records. `cashflow_policy` is the normalized policy object for comprehensive responses, but null for the legacy saved reader. `source_snapshot` is the preserved transaction/asset input object for comprehensive responses and an empty object for legacy reads. Version metadata is preserved, not inferred from today's ledger. The following JSON examples show selected fields rather than exhaustive response payloads.
|
||||
|
||||
#### Saved-reader additive asset metadata
|
||||
|
||||
`get_monthly_settlement_snapshot(month="", run_id="", db_path=None)` adds the following four root fields to a successful saved-run response, using the same strict public evidence projection as the asset close reader:
|
||||
|
||||
| Field | Type | Meaning / privacy |
|
||||
|---|---|---|
|
||||
| `excluded_items` | list of objects | Persisted excluded evidence projected through the public evidence allowlist; may include source identity, reason/status/date, candidate metadata and state hash when present. |
|
||||
| `carried_items` | list of objects | Persisted carried evidence projected through the same allowlist. This is the persisted carried-evidence collection, not a recomputation from current valuations. |
|
||||
| `applied_evidence` | list of objects | Persisted applied evidence projected through the same allowlist, including only allowlisted evidence attributes when present. |
|
||||
| `approval` | object | Approval projection: `approved_by`, `approved_at`, `approval_reason`, `approval_reference`; legacy row-only records without run-header approval metadata return `{}`. |
|
||||
|
||||
Evidence objects are not a fixed full-row schema: keys are optional. `_closed_run_metadata` uses the same key allowlist recursively for nested dictionaries and lists; unknown keys (including `source_note`, `memo`, credential and raw-payload keys) are dropped, while allowlisted scalar values are preserved. Candidate metadata and source attributes may therefore retain persisted values under allowlisted keys. This is not a content-based secret redactor: callers must not persist sensitive values under public keys such as `name` or `reason`. Do not treat these fields as permission to expose a complete database row. The approval object is limited to `approved_by`, `approved_at`, `approval_reason`, and `approval_reference`; row-only legacy records return `{}`, while run headers preserve the stored approval values, including empty or null values.
|
||||
|
||||
For run-header-backed records, these collections and approval are projected from the immutable run metadata, rather than inferred from snapshot rows. Thus rows are not the origin of `applied_evidence`, `excluded_items`, or `carried_items`. Exact `run_id` and latest-month reads use the saved run/rows and stored metadata only: they do not write, initialize/migrate the database, consult live ledger authority, or recompute current selections. Legacy schema-13 snapshots with row-only evidence and no run header remain readable: the additive four fields use sensible empty arrays (`excluded_items`, `carried_items`, `applied_evidence`) and empty approval object, while preserving legacy status/version behavior and the six stored-total fields as `null` (they were not persisted). This additive metadata does not change the asset-only `get_asset_snapshot_report` result or its existing schema/version contract.
|
||||
|
||||
Successful root review example, including a negative signed expense (refund):
|
||||
|
||||
```json
|
||||
{"success":true,"income_total":120000,"expense_total":-5000,"net_cashflow":125000,"total_assets":900000,"total_liabilities":200000,"net_worth":700000,"review_fingerprint":"<fingerprint>","cashflow_policy":{"savings_categories":[],"category_decisions":[]},"cashflow_items":[{"cashflow_kind":"expense","category":"Food","subcategory":"Dining","amount":-5000,"transaction_count":1,"currency":"KRW"}],"cashflow_blockers":[{"transaction_id":"txn-example","reason":"unknown cashflow classification requires an explicit decision"}],"source_snapshot":{"transactions":[],"assets":[]}}
|
||||
```
|
||||
|
||||
Saved reader root example:
|
||||
|
||||
```json
|
||||
{"success":true,"source":"settlement_snapshot","run_id":"run-example","income_total":120000,"expense_total":-5000,"net_cashflow":125000,"total_assets":900000,"total_liabilities":200000,"net_worth":700000,"cashflow_policy":{"savings_categories":[],"category_decisions":[]},"source_snapshot":{"transactions":[],"assets":[]}}
|
||||
```
|
||||
|
||||
A `validation_error` results when cashflow review blockers must be resolved before approval/finalize. Remediate policy or transaction classification, then re-review and re-approve. Examples use synthetic identifiers, not raw secrets.
|
||||
|
||||
@@ -210,3 +210,50 @@ 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.
|
||||
|
||||
|
||||
### Shared settlement-draft entrypoints
|
||||
|
||||
| Path | Responsibility / entrypoint and dependency boundary |
|
||||
|---|---|
|
||||
| `jarca_ledger_mcp/service.py` | Existing public re-export facade exports the ten lifecycle operations from `application/settlement_draft_service.py`; adapters share these functions, not separate business implementations. |
|
||||
| `jarca_ledger_mcp/adapters/mcp/server.py` | Same-name MCP lifecycle tools delegate to the shared facade, retaining existing success/error envelopes. |
|
||||
| `jarca_ledger_mcp/adapters/mcp/tool_contracts.py` | Registers the same facade callables and their inputSchema contracts; internal `db_path` remains outside public MCP input. |
|
||||
| `jarca_ledger_mcp/adapters/streamlit/settlement_gateway.py` | Wraps shared lifecycle functions plus valuation list/register/correct delegates, preserving auth-before-DB and internal database-path resolution. |
|
||||
| `dashboard/page_views/settlement_drafts.py` | `render_settlement_drafts` owns reusable presentation and explicit user-action controls through the gateway; no direct SQL or duplicated approval policy. |
|
||||
| `dashboard/page_views/monthly_settlement.py` | `page_monthly_settlement` invokes the shared panel beside the existing read-only monthly summary. |
|
||||
| `dashboard/page_views/assets.py` | `page_assets` invokes the same panel beside existing valuation and immutable asset-history views. |
|
||||
| `jarca_ledger_mcp/application/account_valuation_service.py` | `register_account_valuation`, `list_account_valuations`, and `correct_account_valuation` retain registration, validation and append-only correction ownership; the draft service reuses their evidence. |
|
||||
| `jarca_ledger_mcp/application/assets_service.py` | Owns established valuation/amount authority and asset close/report semantics; draft finalization does not bypass review by calling legacy live-select close. |
|
||||
| `jarca_ledger_mcp/application/monthly_settlement_service.py` | `get_monthly_settlement_summary` owns read-only propagation of finalized evidence, not draft mutation or transaction-readiness writes. |
|
||||
| `jarca_ledger_mcp/infrastructure/sqlite/schema.py` | schema 13 initialization uses the additive migration path; no adapter owns persistence constraints. |
|
||||
| `jarca_ledger_mcp/infrastructure/sqlite/migrations.py` | Creates persistent draft/item tables and indexes, preserving existing schema and rows on repeated initialization. |
|
||||
|
||||
### Shared settlement-draft policies
|
||||
|
||||
| Module | Visibility | Responsibility / caller | Verification |
|
||||
|---|---|---|---|
|
||||
| `jarca_ledger_mcp/application/settlement_cashflow_service.py` | private | `_normalize_cashflow_policy` and `_review_cashflow` provide policy normalization and deterministic aggregation through `settlement_draft_service.py`; not an adapter/public API | `tests/test_settlement_cashflow_policy.py`, `tests/test_comprehensive_settlement_adapters.py` |
|
||||
|
||||
- `jarca_ledger_mcp/application/settlement_draft_service.py` checks one active draft per month for ordinary and correction drafts; SQLite independently enforces it with `idx_settlement_drafts_active_month` on month `WHERE status='active'`. Completed and cancelled history is retained, not deleted to release that slot.
|
||||
- The same application service validates strict month syntax and month-end basis-date equality. Dashboard and MCP cannot supply an alternate date policy.
|
||||
- The shared review service exposes unverified `candidate_amount` with authority amount NULL; no adapter may sum it as verified evidence. A confirmed child requires explicit reselection, fresh review and approval; correction children are not silently followed.
|
||||
- Dependencies remain page → gateway → public facade → application → SQLite, with MCP server/tool contracts → the same facade. No UI/MCP direct SQL, application-to-Dashboard reverse dependency or duplicated business validation is introduced.
|
||||
- Paired synthetic coverage: `tests/test_monthly_settlement_drafts.py` (month-end, active-month, unverified/reselection, cancellation and atomicity), `tests/test_settlement_draft_adapters.py` (facade/gateway/MCP signatures and no-create reads), `tests/test_settlement_draft_dashboard.py` and `tests/test_settlement_draft_panel_workflow.py` (shared panel/actions), `tests/test_settlement_draft_mcp_runtime.py` (real stdio responses), and `tests/test_settlement_draft_docs.py` (this mapping). Existing authentication/menu/legacy regression tests remain unchanged.
|
||||
# Issue #18 ownership additions
|
||||
|
||||
SQLite `schema.py`/`migrations.py` own additive/idempotent schema 13→14, nullable legacy totals, FK/unique/run-index constraints. `application/settlement_draft_service.py` owns durable draft policy, category-pair selection, grouped computation, complete review fingerprint, unknown blocker, approval guard, six-equation verification, atomic finalize/replay/correction. `monthly_settlement_service.py` owns readonly stored-run lookup and live-versus-stored distinction; `assets_service.py` retains existing asset authority and asset-only API semantics. MCP tool contracts/server and Streamlit settlement gateway delegate to shared services; Dashboard displays shared results only after auth. No adapter duplicates policy or runs SQL. Synthetic coverage extends lifecycle, migration, adapter, rollback, drift and correction regressions; no test run is claimed here.
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
"schema": "gitea-agent.requirement/v1",
|
||||
"id": "REQ-003",
|
||||
"title": "계좌 기준일 평가 이력과 자산 마감 연동",
|
||||
"revision": "1.0",
|
||||
"revision": "1.2",
|
||||
"status": "approved",
|
||||
"depends_on": [],
|
||||
"applies_to": ["ledger", "dashboard", "mcp"],
|
||||
"language": "ko",
|
||||
"issue": "#12",
|
||||
"implementation_pr": "#13"
|
||||
"issue": "#18",
|
||||
"implementation_pr": "https://gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp/pulls/19"
|
||||
}
|
||||
---
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
## 2. 배경
|
||||
|
||||
구현은 SQLite schema 12를 schema 11에 additive하게 적용한다. 계좌 잔액·import 결과는 실제 증거가 없는 한 `unverified` 후보이고, 기존 거래·인증·메뉴 계약을 바꾸지 않는다.
|
||||
기존 schema 11→13의 additive 이력을 보존하고 이번 종합 정산 확장은 schema 14를 additive하게 적용한다. 계좌 잔액·import 결과는 실제 증거가 없는 한 `unverified` 후보이고, 기존 거래·인증·메뉴 계약을 바꾸지 않는다.
|
||||
|
||||
## 3. 범위
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
|
||||
### REQ-003.01 — additive 이력과 legacy 보존
|
||||
|
||||
schema 11의 테이블·필드·거래를 삭제하거나 추측 이주하지 않고 schema 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다. Issue #18의 유일한 schema 확장은 additive/idempotent 13→14이며 기존 자산 전용 run의 미저장 종합 총계는 NULL로 유지하고 backfill하지 않는다.
|
||||
|
||||
**영향 범위**
|
||||
- 인터페이스: 기존 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, #18)
|
||||
|
||||
월말 기준일의 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 14 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 14 init twice; 경계 schema 11/13 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=14`, 기존 및 종합 정산 tables/columns/indexes가 존재하고 old rows와 구버전 미보존 NULL을 유지한다.
|
||||
|
||||
**검증**: `tests/test_account_valuations.py::test_account_valuation_schema_is_v12_and_idempotent`, `tests/test_issue_12_regressions.py::test_schema12_migration_preserves_legacy_rows_indexes_and_is_idempotent`.
|
||||
**검증 분류**: level=integration; backend=sqlite
|
||||
@@ -296,6 +648,206 @@ MCP와 Dashboard는 shared service/gateway를 사용한다. 기존 success/error
|
||||
**검증**: `tests/test_requirement_docs.py`, auth/menu evidence tests, repository redaction/static search.
|
||||
**검증 분류**: level=static; backend=dashboard
|
||||
|
||||
### REQ-003.11-AC18 — 종합 저장 구조와 여섯 총계
|
||||
|
||||
**판정 기준**
|
||||
|
||||
같은 run의 헤더에 여섯 총계·월·통화·정책·버전·근거가 저장되고 양 상세는 같은 ID에 결속한다. 현금흐름 상세의 signed INTEGER, positive count, FK RESTRICT, unique5, NOT NULL 빈 분류를 검증한다.
|
||||
|
||||
**검증**
|
||||
|
||||
`tests/test_comprehensive_settlement.py::test_closed_header_persists_six_totals_policy_and_versions`; `tests/test_settlement_cashflow_schema.py::test_cashflow_detail_constraints_preserve_blank_categories` (실제 실행 기록과 최종 head에 결속하며 문서 기재 자체는 PASS가 아니다.)
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: integration
|
||||
- 백엔드: sqlite
|
||||
- 케이스: 정상·경계·실패 synthetic 자료
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
기존 REQ-003 권위·승인·호환성 의미를 유지하며 미검증 또는 실패 상태는 완료로 판정하지 않는다.
|
||||
|
||||
### REQ-003.11-AC19 — 환급·저축·이동·대출상환 정책
|
||||
|
||||
**판정 기준**
|
||||
|
||||
환급월의 원래 지출 분류에 음수 지출을 반영한다. 명시적 category/subcategory 저축 목록만 expense에 포함하고 debt_payment는 expense로 매핑한다. 일반 이체·초기잔액 조정·명시적 상품권 충전 제외를 우선하고 investment 전체를 포함하지 않는다.
|
||||
|
||||
**검증**
|
||||
|
||||
`tests/test_settlement_cashflow_policy.py::test_explicit_savings_refund_and_movement_precedence`; `tests/test_settlement_cashflow_policy.py::test_refund_reduces_debt_payment_and_selected_savings`; `tests/test_settlement_cashflow_policy.py::test_month_boundaries_refund_month_negative_total_and_fx_blocker` (실제 실행 기록과 최종 head에 결속하며 문서 기재 자체는 PASS가 아니다.)
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: integration
|
||||
- 백엔드: sqlite
|
||||
- 케이스: 정상·경계·실패 synthetic 자료
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
기존 REQ-003 권위·승인·호환성 의미를 유지하며 미검증 또는 실패 상태는 완료로 판정하지 않는다.
|
||||
|
||||
### REQ-003.11-AC20 — 미결정 분류 승인 차단
|
||||
|
||||
**판정 기준**
|
||||
|
||||
대응 분류 없는 환급과 미결정 unknown은 종합 승인·마감을 차단한다. 자산의 세 조건부 승인 플래그는 이 차단을 우회하지 못한다. 재분류 또는 명시적 포함/제외 판단 뒤 재검토하며 확정 제외는 차단하지 않는다.
|
||||
|
||||
**검증**
|
||||
|
||||
`tests/test_settlement_cashflow_policy.py::test_unknown_classification_blocks_and_explicit_exclusion_unblocks`; `tests/test_settlement_cashflow_policy.py::test_unknown_cashflow_source_is_not_recorded_as_excluded` (실제 실행 기록과 최종 head에 결속하며 문서 기재 자체는 PASS가 아니다.)
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: integration
|
||||
- 백엔드: sqlite
|
||||
- 케이스: 정상·경계·실패 synthetic 자료
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
기존 REQ-003 권위·승인·호환성 의미를 유지하며 미검증 또는 실패 상태는 완료로 판정하지 않는다.
|
||||
|
||||
### REQ-003.11-AC21 — 당시 계산 근거와 부채 부호
|
||||
|
||||
**판정 기준**
|
||||
|
||||
거래 원본 ID/날짜/type/금액/통화/분류와 적용 판단·금액, 선택 자산 평가 ID/원본 날짜/출처/적용 방식/포함·제외를 고정한다. 부채 총계는 기존 abs(sum(raw loans))이며 상세 applied_amount 기여도 합이 이에 일치하고 원본을 덮어쓰지 않는다.
|
||||
|
||||
**검증**
|
||||
|
||||
`tests/test_comprehensive_settlement.py::test_asset_detail_preserves_raw_evidence_and_signed_liability_contributions`; `tests/test_comprehensive_settlement.py::test_source_snapshot_preserves_reproducible_values_without_raw_secrets` (실제 실행 기록과 최종 head에 결속하며 문서 기재 자체는 PASS가 아니다.)
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: integration
|
||||
- 백엔드: sqlite
|
||||
- 케이스: 정상·경계·실패 synthetic 자료
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
기존 REQ-003 권위·승인·호환성 의미를 유지하며 미검증 또는 실패 상태는 완료로 판정하지 않는다.
|
||||
|
||||
### REQ-003.11-AC22 — 승인 지문의 모든 변경 입력
|
||||
|
||||
**판정 기준**
|
||||
|
||||
거래 추가·삭제·금액/분류 변경, 정책·집계 버전 변경, 선택 평가자료·포함/제외/미확정 변경을 지문에 결속한다. stale 승인으로는 마감하지 않고 정책 편집은 version 증가와 승인 무효화를 수행한다.
|
||||
|
||||
**검증**
|
||||
|
||||
`tests/test_comprehensive_settlement.py::test_source_policy_and_category_drift_invalidate_approval`; `tests/test_settlement_cashflow_policy.py::test_policy_edit_persists_versions_and_invalidates_approval` (실제 실행 기록과 최종 head에 결속하며 문서 기재 자체는 PASS가 아니다.)
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: integration
|
||||
- 백엔드: sqlite
|
||||
- 케이스: 정상·경계·실패 synthetic 자료
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
기존 REQ-003 권위·승인·호환성 의미를 유지하며 미검증 또는 실패 상태는 완료로 판정하지 않는다.
|
||||
|
||||
### REQ-003.11-AC23 — 원자적 저장과 실제 저장 일치
|
||||
|
||||
**판정 기준**
|
||||
|
||||
income/expense 상세 합, net_cashflow 차, 반영 자산/부채 상세 합, net_worth 차의 여섯 식과 승인 결과를 실제 저장된 헤더·상세·근거와 대조한다. 불일치·중간 실패는 run/양 상세/완료 상태/응답 전체를 롤백한다.
|
||||
|
||||
**검증**
|
||||
|
||||
`tests/test_comprehensive_settlement.py::test_mid_close_failure_rolls_back_all_settlement_state`; `tests/test_comprehensive_settlement.py::test_close_rejects_stored_detail_sum_tampering` (실제 실행 기록과 최종 head에 결속하며 문서 기재 자체는 PASS가 아니다.)
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: integration
|
||||
- 백엔드: sqlite
|
||||
- 케이스: 정상·경계·실패 synthetic 자료
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
기존 REQ-003 권위·승인·호환성 의미를 유지하며 미검증 또는 실패 상태는 완료로 판정하지 않는다.
|
||||
|
||||
### REQ-003.11-AC24 — 멱등 재시도와 동시 마감
|
||||
|
||||
**판정 기준**
|
||||
|
||||
동일 승인 요청·멱등 키는 하나의 run과 동일 저장 응답을 반환한다. 변경된 요청은 충돌하며 동시 요청에서도 중복 run이나 부분 완료가 없다.
|
||||
|
||||
**검증**
|
||||
|
||||
`tests/test_comprehensive_settlement.py::test_concurrent_close_retries_return_one_saved_result`; `tests/test_comprehensive_settlement.py::test_replay_and_correction_keep_each_run_immutable` (실제 실행 기록과 최종 head에 결속하며 문서 기재 자체는 PASS가 아니다.)
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: integration
|
||||
- 백엔드: sqlite
|
||||
- 케이스: 정상·경계·실패 synthetic 자료
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
기존 REQ-003 권위·승인·호환성 의미를 유지하며 미검증 또는 실패 상태는 완료로 판정하지 않는다.
|
||||
|
||||
### REQ-003.11-AC25 — 불변 이력과 정정 분리
|
||||
|
||||
**판정 기준**
|
||||
|
||||
완료 이후 원장 변경으로 기존 헤더·양 상세·근거·저장 응답을 바꾸지 않는다. 최신 parent·단일 child 계약에 따라 정정 run을 append하며 최초와 정정을 exact run으로 각각 조회한다.
|
||||
|
||||
**검증**
|
||||
|
||||
`tests/test_comprehensive_settlement.py::test_completed_get_and_saved_snapshot_ignore_later_live_ledger_changes`; `tests/test_comprehensive_settlement.py::test_replay_and_correction_keep_each_run_immutable` (실제 실행 기록과 최종 head에 결속하며 문서 기재 자체는 PASS가 아니다.)
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: integration
|
||||
- 백엔드: sqlite
|
||||
- 케이스: 정상·경계·실패 synthetic 자료
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
기존 REQ-003 권위·승인·호환성 의미를 유지하며 미검증 또는 실패 상태는 완료로 판정하지 않는다.
|
||||
|
||||
### REQ-003.11-AC26 — legacy NULL과 기존 초안 재승인
|
||||
|
||||
**판정 기준**
|
||||
|
||||
schema14 additive/idempotent 이관은 legacy 미보존 값을 NULL로 두며 현재 원장 소급 backfill이나 fake0을 하지 않는다. 기존 초안과 이력을 유지하지만 자산-only 과거 승인으로 종합 마감할 수 없고 새 검토/승인이 필요하다.
|
||||
|
||||
**검증**
|
||||
|
||||
`tests/test_settlement_cashflow_schema.py::test_legacy_migration_is_idempotent_and_keeps_unstored_totals_null`; `tests/test_settlement_cashflow_schema.py::test_cashflow_detail_constraints_preserve_blank_categories`; `tests/test_comprehensive_settlement.py::test_legacy_asset_approval_requires_comprehensive_review_before_close`; `tests/test_comprehensive_settlement.py::test_legacy_reader_accepts_original_header_without_migration` (실제 실행 기록과 최종 head에 결속하며 문서 기재 자체는 PASS가 아니다.)
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: integration
|
||||
- 백엔드: sqlite
|
||||
- 케이스: 정상·경계·실패 synthetic 자료
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
기존 REQ-003 권위·승인·호환성 의미를 유지하며 미검증 또는 실패 상태는 완료로 판정하지 않는다.
|
||||
|
||||
### REQ-003.11-AC27 — 공개 계약·출처 구분·읽기 보안
|
||||
|
||||
**판정 기준**
|
||||
|
||||
shared service→MCP/gateway→인증된 UI에서 여섯 총계·분류/경고와 저장 run/버전을 표시한다. 기존 자산-only 직접 API와 live 보고는 그대로이며 저장 조회는 readonly/no-create다. ROOT review/finalize, active get.review, completed get.final_response를 유지하고 인증정보/원본 민감 payload를 공개하지 않는다.
|
||||
|
||||
**검증**
|
||||
|
||||
`tests/test_comprehensive_settlement_adapters.py::test_shared_adapters_expose_policy_and_saved_snapshot_contracts`; `tests/test_comprehensive_settlement.py::test_stored_reader_allowlists_source_root_and_nested_decision`; `tests/test_comprehensive_settlement.py::test_stored_reader_keeps_required_transaction_date`; `tests/test_comprehensive_settlement.py::test_saved_reader_rejects_unsupported_comprehensive_versions_without_writes`; `tests/test_comprehensive_settlement.py::test_saved_reader_does_not_mislabel_partial_version_metadata_as_legacy`; `tests/test_comprehensive_settlement_adapters.py::test_real_comprehensive_stdio_policy_close_and_saved_snapshot`; `tests/test_comprehensive_settlement_adapters.py::test_authenticated_dashboard_renders_saved_comprehensive_snapshot` (실제 실행 기록과 최종 head에 결속하며 문서 기재 자체는 PASS가 아니다.)
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: integration
|
||||
- 백엔드: sqlite
|
||||
- 케이스: 정상·경계·실패 synthetic 자료
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
기존 REQ-003 권위·승인·호환성 의미를 유지하며 미검증 또는 실패 상태는 완료로 판정하지 않는다.
|
||||
|
||||
## 8. 오류 및 충돌 계약
|
||||
|
||||
공통 envelope는 성공 시 `success: true`, 실패 시 `success: false, error_code, error`를 유지한다. 주요 codes는 `validation_error`, `not_found`, `idempotency_conflict`, `correction_conflict`, `ambiguous_valuation`, `conditional_approval_required`, `db_error`다. SQLite unique/transaction conflict는 추가 write 없이 실패한다.
|
||||
@@ -310,8 +862,19 @@ REQ-001의 auth gate·cookie/cleanup/logout, REQ-002의 메뉴 10개와 order(`
|
||||
|
||||
## 11. 추적성
|
||||
|
||||
- 근거 이슈: `#12`
|
||||
- 근거 이슈: `#12`, `#15`, `#18`
|
||||
- 구현 PR: `#13`
|
||||
- Issue #15 구현 PR: https://gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp/pulls/16
|
||||
- 관련 설계 문서: `docs/data-model.md`, `docs/interface-contract.md`, `docs/project-structure.md`, `docs/runtime-flow.md`
|
||||
- 관련 요구사항: `REQ-001`, `REQ-002`
|
||||
|
||||
### Issue #18 종합 정산 확장 (revision 1.2; 구현 PR #19)
|
||||
|
||||
기존 요구사항·AC 식별자 및 공통 섹션은 유지하고 REQ-003.11만 확장한다. 월 수입·지출은 활성 거래의 [월초, 다음 월초) 범위, 자산·부채는 기존 월말 기준이다. 초안 정책은 `draft.cashflow_policy`에 저장하며 savings는 사용자가 명시한 category/subcategory pair만 인정한다. 생활비·선택 savings·`debt_payment`은 expense; 일반 transfer와 voucher top-up 제외가 우선하고 `investment` 전체 포함은 금지한다. Refund는 환급월 원래 지출 분류에 signed negative expense로 기록하며 과거 run을 수정하지 않는다. Unresolved unknown은 승인 blocker이며 asset conditional flag로 우회할 수 없고, 확정 제외는 blocker가 아니다. KRW only, no FX.
|
||||
|
||||
Run header는 settlement month/currency, 여섯 총계, policy JSON, aggregation/schema version, 당시 source JSON을 보존한다. `settlement_cashflow_snapshots`는 `id, settlement_run_id, cashflow_kind, category, subcategory, amount, transaction_count, currency` 8개 필드, signed INTEGER amount, positive count, FK RESTRICT, 5-field unique(run, cashflow_kind, category, subcategory, currency), run index를 갖는다. category/subcategory는 NOT NULL이며 빈 문자열은 그대로 구별한다. 기존 자산 상세와 authority/verified·exact/carry/substitute·missing/exclusion 근거를 유지한다.
|
||||
|
||||
동일 run의 income/expense grouped 합이 각 총계와 일치하고 `net_cashflow=income_total-expense_total`; 자산·부채는 포함 상세 합이며 `net_worth=total_assets-total_liabilities`다. Review fingerprint는 거래 당시 값·분류·정책/버전·grouped 결과/여섯 총계·선택 평가자료·포함/제외/미결정 상태를 포함하고 drift 시 finalize를 거부한다. Header, 두 상세 영역, evidence 및 draft completion/final response는 한 transaction이며 오류 시 모두 rollback. Equal idempotent request replays persisted response; changed request conflicts. Correction validates latest single parent and appends run/details; no historical overwrite. Legacy totals stay NULL, incomplete draft needs fresh review/approval.
|
||||
|
||||
Public service additions are `set_settlement_cashflow_policy(draft_id,expected_version,updated_by,cashflow_policy,db_path=None)` and `get_monthly_settlement_snapshot(month='',run_id='',db_path=None)`; MCP omits `db_path`. Review/finalize fields are at response ROOT; active get nests review; completed get exposes ROOT `final_response`. Group key is `cashflow_kind`. Stored run snapshot differs from live report; existing asset-only API and direct-close meaning remain unchanged. Reads are readonly/no-create; Dashboard authenticates before DB. Synthetic tests cover all Issue inclusion cases; no product test result is asserted here.
|
||||
- DB lifecycle: writer/bootstrap은 명시적 `init_db()` 호출로만 생성·마이그레이션하고, `read_db()`는 SQLite `mode=ro`로 연다. missing DB는 `db_not_initialized`이며 read path는 no mkdir, no migrate, no commit이다. first-use write만 init_db를 수행하고 auth-before-DB 순서를 보존한다.
|
||||
|
||||
@@ -239,3 +239,41 @@ 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.
|
||||
|
||||
|
||||
### Cancellation and reopening
|
||||
|
||||
- Entry: authenticated Dashboard explicit action or the existing MCP tool delegates through the shared service facade to `cancel_settlement_draft(draft_id, expected_version, cancelled_by, reason, db_path=None)`. This adds no MCP authentication claim and does not alter Dashboard authentication.
|
||||
|
||||
| Stage ID | Owner / input and guard | State, success and failure transition |
|
||||
|---|---|---|
|
||||
| `settlement-draft-cancel` | Shared `_mutate` requires an existing active draft, matching positive-integer `expected_version`, nonempty `cancelled_by` actor and nonempty reason. | In `BEGIN IMMEDIATE`, persist status cancelled plus `cancelled_by`, `cancellation_reason`, `cancelled_at`; increment draft version and `updated_at`. Clear `approval_fingerprint`, `approval_version`, approver/time/reason/reference and flags. Success terminates in cancelled; state/version conflicts return conflict with no-write to the existing draft. Invalid input returns validation_error. |
|
||||
| `settlement-draft-cancel-recovery` | Cancellation is a transaction-scoped metadata mutation, not evidence deletion or an asset close. | Existing draft item/evidence rows, `account_valuations`, `asset_snapshot_runs`, and `asset_snapshots` are retained. DB failures rollback both cancellation and approval invalidation and return db_error; correct input or reread current state/version before retry. Completed/already cancelled drafts cannot be cancelled again; stale expected versions never overwrite current state. |
|
||||
| `settlement-draft-reopen` | Only completed or cancelled drafts release the active-month slot. Start a new explicit create request with a fresh create key; ordinary and correction drafts still share the one-active-month restriction. | The same create key with its original equal payload returns the previous draft, even after completion/cancellation; changed payload conflicts. A fresh key is not permission to overwrite history. No close exists: create without a correction parent. A close exists: explicitly link the latest prior run using `correction_of_run_id` and nonempty `correction_reason`, still at month-end. Continue at settlement-draft-create; missing/wrong parent or reason is rejected. |
|
||||
|
||||
Cancellation reason is stored in `cancellation_reason`, not memo; the cancellation actor and timestamp are persisted, not inferred from a later caller. Approval invalidation prevents reuse of the cancelled draft's approval. Reopening creates new draft/item identity and requires its own review/approval; finalization rechecks the correction parent and preserves prior immutable run/rows. Cancellation itself is not an idempotent finalization replay: retrying cancellation after its successful terminal transition returns conflict.
|
||||
|
||||
Coverage: `tests/test_monthly_settlement_drafts.py` checks cancellation/reopen, version conflicts and lifecycle persistence; `tests/test_settlement_draft_authority.py` checks cancellation approval invalidation; `tests/test_settlement_draft_correction_contract.py` checks latest-run linkage, reason, immutable history and persisted replay; `tests/test_settlement_draft_docs.py` checks these documented transitions. Existing auth/menu and read-only recovery flows above remain unchanged.
|
||||
## Issue #18 comprehensive settlement flow
|
||||
|
||||
Explicit policy setter validates expected draft version, saves `draft.cashflow_policy`, increments version, invalidates approval. Read-only review freezes [month start,next month start) active transactions, policy/version and same-month-end selected asset/liability evidence; generates grouped income/expense/excluded details and six totals. Savings only explicit category/subcategory pairs, debt_payment expense; transfer/voucher exclusion priority. Refund applies signed negative expense to original category in refund month. Unresolved unknown returns warning and blocks comprehensive approval regardless of asset conditional flags; confirmed exclusions do not block. KRW only; no FX. Review fingerprint binds source values/classification, policy, version, grouped details/totals and selected valuation evidence; any drift requires review/approval again.
|
||||
|
||||
Finalize under one `BEGIN IMMEDIATE` revalidates version/fingerprint/current sources and six equations, writes run header, asset/liability details, cashflow details/source evidence, completion link and persisted response atomically. Any mismatch/write failure rolls back all. Equal idempotent request replays stored result; changed request conflicts. Correction checks latest parent and single-child rule, appends new run/details, never edits history. Getter is readonly and resolves stored run/month separately from live report; asset direct-close remains asset-only. Dashboard auth precedes DB and missing DB reads never create/migrate. Responses: review/finalize root fields, active get nested review, completed get root `final_response`; groups named `cashflow_kind`. MCP hides `db_path`.
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Jarca Ledger MCP server."""
|
||||
|
||||
__version__ = "0.1.5"
|
||||
__version__ = "0.1.7"
|
||||
|
||||
@@ -443,3 +443,55 @@ 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)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def set_settlement_cashflow_policy(draft_id: str, expected_version: int, updated_by: str, cashflow_policy: dict) -> dict:
|
||||
"""Set explicit monthly cashflow classification policy for a settlement draft."""
|
||||
return contracts.set_settlement_cashflow_policy(draft_id, expected_version, updated_by, cashflow_policy)
|
||||
|
||||
@mcp.tool()
|
||||
def get_monthly_settlement_snapshot(month: str = "", run_id: str = "") -> dict:
|
||||
"""Read a saved monthly settlement snapshot, optionally selecting an exact run."""
|
||||
return contracts.get_monthly_settlement_snapshot(month, run_id)
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
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,
|
||||
set_settlement_cashflow_policy as set_settlement_cashflow_policy,
|
||||
get_monthly_settlement_snapshot as get_monthly_settlement_snapshot,
|
||||
update_settlement_draft as update_settlement_draft,
|
||||
annotations as annotations,
|
||||
apply_categorization_rules as apply_categorization_rules,
|
||||
close_asset_snapshot as close_asset_snapshot,
|
||||
@@ -74,7 +86,23 @@ 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",
|
||||
"set_settlement_cashflow_policy", "get_monthly_settlement_snapshot",
|
||||
)
|
||||
}
|
||||
|
||||
__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",
|
||||
"set_settlement_cashflow_policy", "get_monthly_settlement_snapshot",
|
||||
"annotations",
|
||||
"apply_categorization_rules",
|
||||
"close_asset_snapshot",
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
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,
|
||||
get_monthly_settlement_snapshot as get_monthly_settlement_snapshot,
|
||||
list_account_valuations as list_account_valuations,
|
||||
list_settlement_drafts as list_settlement_drafts,
|
||||
refresh_settlement_draft_targets as refresh_settlement_draft_targets,
|
||||
register_account_valuation as register_account_valuation,
|
||||
review_settlement_draft as review_settlement_draft,
|
||||
set_settlement_draft_item as set_settlement_draft_item,
|
||||
set_settlement_cashflow_policy as set_settlement_cashflow_policy,
|
||||
update_settlement_draft as update_settlement_draft,
|
||||
)
|
||||
from ...application import settlement_draft_service as _draft_service
|
||||
from ...application import monthly_settlement_service as _monthly_service
|
||||
from ... import service as _service
|
||||
|
||||
|
||||
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):
|
||||
if fn.__name__ == "set_settlement_cashflow_policy":
|
||||
target = _draft_service
|
||||
elif fn.__name__ == "get_monthly_settlement_snapshot":
|
||||
target = _monthly_service
|
||||
else:
|
||||
target = _draft_service if fn.__name__ in {"create_settlement_draft", "list_settlement_drafts", "get_settlement_draft", "update_settlement_draft", "refresh_settlement_draft_targets", "set_settlement_draft_item", "review_settlement_draft", "approve_settlement_draft", "finalize_settlement_draft", "cancel_settlement_draft"} else _service
|
||||
return getattr(target, fn.__name__)(*args, db_path=_guard(db_path), **kwargs)
|
||||
call.__name__ = fn.__name__
|
||||
call.__doc__ = fn.__doc__
|
||||
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",
|
||||
"set_settlement_cashflow_policy", "get_monthly_settlement_snapshot",
|
||||
):
|
||||
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",
|
||||
"set_settlement_cashflow_policy", "get_monthly_settlement_snapshot",
|
||||
]
|
||||
@@ -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"],
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from .shared import read_db, DatabaseNotInitializedError
|
||||
|
||||
from .assets_service import get_asset_snapshot_report
|
||||
from .imports_service import list_ledger_import_batches
|
||||
from .reconciliation_service import get_balance_reconciliation_report
|
||||
@@ -75,6 +79,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", []),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -123,3 +134,127 @@ def get_monthly_settlement_summary(month: str = "", db_path: str | None = None)
|
||||
},
|
||||
read_only=True,
|
||||
)
|
||||
|
||||
|
||||
def get_monthly_settlement_snapshot(month: str = "", run_id: str = "", db_path: str | None = None) -> dict[str, Any]:
|
||||
"""Read a persisted monthly settlement close without consulting live ledger state."""
|
||||
if not isinstance(month, str) or not isinstance(run_id, str):
|
||||
return fail("validation_error", "month and run_id must be strings")
|
||||
try:
|
||||
selected_month = normalize_month(month) if month else ""
|
||||
if selected_month and (len(selected_month) != 7 or selected_month[4] != "-"):
|
||||
raise ValueError("invalid month")
|
||||
except (TypeError, ValueError):
|
||||
return fail("validation_error", "invalid settlement month")
|
||||
|
||||
def parsed(value: Any, default: Any):
|
||||
if value in (None, ""):
|
||||
return default
|
||||
return json.loads(value)
|
||||
|
||||
try:
|
||||
with read_db(db_path) as conn:
|
||||
tables = {r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||
if "asset_snapshot_runs" not in tables or "asset_snapshots" not in tables:
|
||||
return fail("not_found", "settlement snapshot not found")
|
||||
run = None
|
||||
rows = []
|
||||
legacy = False
|
||||
metadata: dict[str, Any] = {}
|
||||
if run_id:
|
||||
run = conn.execute("SELECT rowid AS _run_rowid,* FROM asset_snapshot_runs WHERE id=?", (run_id,)).fetchone()
|
||||
if run:
|
||||
from .assets_service import _snapshot_rows_for_run
|
||||
rows, metadata = _snapshot_rows_for_run(conn, run_id)
|
||||
else:
|
||||
cols = {r["name"] for r in conn.execute("PRAGMA table_info(asset_snapshots)")}
|
||||
if {"snapshot_run_id", "closed_at"} <= cols:
|
||||
rows = conn.execute("SELECT rowid AS _rowid,* FROM asset_snapshots WHERE snapshot_run_id=? AND closed_at<>'' ORDER BY rowid", (run_id,)).fetchall()
|
||||
if rows:
|
||||
legacy = True
|
||||
metadata = {"snapshot_run_id": run_id, "legacy_closed": True, "close_status": "legacy_read_only"}
|
||||
else:
|
||||
if selected_month:
|
||||
run = conn.execute("SELECT rowid AS _run_rowid,* FROM asset_snapshot_runs WHERE basis_date=? ORDER BY rowid DESC LIMIT 1", (_month_end_date(selected_month),)).fetchone()
|
||||
if run is None:
|
||||
basis = _month_end_date(selected_month) if selected_month else ""
|
||||
if basis:
|
||||
from .assets_service import _snapshot_rows_for_latest_run
|
||||
rows, metadata = _snapshot_rows_for_latest_run(conn, basis)
|
||||
legacy = bool(metadata.get("legacy_closed"))
|
||||
if legacy:
|
||||
rows = [r for r in rows if r["snapshot_run_id"] == metadata.get("snapshot_run_id") and r["closed_at"]]
|
||||
elif not metadata.get("snapshot_run_id"):
|
||||
rows = []
|
||||
if metadata.get("snapshot_run_id"):
|
||||
run = conn.execute("SELECT rowid AS _run_rowid,* FROM asset_snapshot_runs WHERE id=?", (metadata["snapshot_run_id"],)).fetchone()
|
||||
if not run and not rows:
|
||||
return fail("not_found", "settlement snapshot not found")
|
||||
if run:
|
||||
rid = run["id"]
|
||||
basis_date = run["basis_date"]
|
||||
run_month = run["settlement_month"] if "settlement_month" in run.keys() else None
|
||||
if selected_month and ((run_month and run_month != selected_month) or (not run_month and basis_date[:7] != selected_month)):
|
||||
return fail("validation_error", "run does not match settlement month")
|
||||
if not metadata:
|
||||
from .assets_service import _snapshot_rows_for_run
|
||||
rows, metadata = _snapshot_rows_for_run(conn, rid)
|
||||
else:
|
||||
rid = metadata.get("snapshot_run_id", run_id)
|
||||
basis_date = rows[0]["basis_date"]
|
||||
if selected_month and basis_date[:7] != selected_month:
|
||||
return fail("validation_error", "run does not match settlement month")
|
||||
legacy = True
|
||||
if metadata.get("evidence_integrity_error"):
|
||||
return fail("data_integrity_error", "snapshot evidence is malformed", snapshot_run_id=rid)
|
||||
keys = set(run.keys()) if run else set()
|
||||
def field(name): return run[name] if run and name in keys else None
|
||||
from .settlement_cashflow_service import _AGGREGATION_VERSION
|
||||
if (field("snapshot_schema_version") is not None or field("aggregation_version") is not None) and (
|
||||
field("snapshot_schema_version") != 1 or field("aggregation_version") != _AGGREGATION_VERSION
|
||||
):
|
||||
raise ValueError("unsupported comprehensive snapshot version")
|
||||
comprehensive = bool(field("snapshot_schema_version") == 1 and not legacy)
|
||||
legacy = not comprehensive
|
||||
cashflows = []
|
||||
if comprehensive and "settlement_cashflow_snapshots" in tables:
|
||||
cashflows = [dict(r) for r in conn.execute("SELECT cashflow_kind,category,subcategory,amount,transaction_count,currency FROM settlement_cashflow_snapshots WHERE settlement_run_id=? ORDER BY cashflow_kind,category,subcategory,currency", (rid,))]
|
||||
totals = {"income_total": field("income_total") if comprehensive else None, "expense_total": field("expense_total") if comprehensive else None, "net_cashflow": field("net_cashflow") if comprehensive else None, "total_assets": field("total_assets") if comprehensive else None, "total_liabilities": field("total_liabilities") if comprehensive else None, "net_worth": field("net_worth") if comprehensive else None}
|
||||
safe_items = []
|
||||
for row in rows:
|
||||
d = dict(row)
|
||||
safe_items.append({k: d.get(k) for k in ("id","source_type","source_id","name","amount","currency","liquidity_bucket","valuation_source","snapshot_run_id","correction_of_run_id","correction_reason","is_correction","closed_at","valuation_id","selection_policy","verification_status","evidence_kind","exclusion_reason","created_at") if k in d})
|
||||
source = parsed(field("source_snapshot_json"), {}) if comprehensive else {}
|
||||
if not isinstance(source, dict): raise ValueError()
|
||||
if comprehensive:
|
||||
tx_fields = ("id", "transaction_date", "type", "amount", "currency", "category", "subcategory", "original_cashflow_kind", "originalcashflow_kind", "cashflow_kind", "applied_amount", "sourceprovenance", "category_cashflow_kind", "category_active")
|
||||
asset_fields = ("id", "source_id", "source_type", "valuation_id", "valuation_date", "basis_date", "amount", "currency", "liquidity_bucket", "selection_mode", "applied_amount", "authority_source", "is_liability", "status", "verified", "verification_status", "evidence_kind", "exclusion_reason", "state_hash")
|
||||
def project(records, fields):
|
||||
if not isinstance(records, list) or any(not isinstance(x, dict) for x in records):
|
||||
raise ValueError()
|
||||
result = []
|
||||
for record in records:
|
||||
item = {k: record[k] for k in fields if k in record}
|
||||
if any(value is not None and not isinstance(value, (str, int, bool)) for value in item.values()):
|
||||
raise ValueError()
|
||||
for key, allowed in (("decision", ("category", "subcategory", "cashflow_kind", "reason")), ("source_classification", ("id", "category", "subcategory", "cashflow_kind", "active"))):
|
||||
value = record.get(key)
|
||||
if isinstance(value, dict):
|
||||
item[key] = {k: value[k] for k in allowed if k in value and (value[k] is None or isinstance(value[k], (str, int, bool)))}
|
||||
result.append(item)
|
||||
return result
|
||||
source = {"transactions": project(source.get("transactions", []), tx_fields), "assets": project(source.get("assets", []), asset_fields)}
|
||||
policy = parsed(field("cashflow_policy_json"), {}) if comprehensive else None
|
||||
if comprehensive:
|
||||
from .settlement_cashflow_service import _normalize_cashflow_policy
|
||||
if not isinstance(policy, dict):
|
||||
raise ValueError()
|
||||
policy = _normalize_cashflow_policy(policy)
|
||||
from .assets_service import _closed_run_metadata
|
||||
public_metadata = _closed_run_metadata(metadata)
|
||||
header_evidence = {key: public_metadata[key] for key in ("excluded_items", "carried_items", "applied_evidence", "approval")}
|
||||
return ok(**header_evidence, source="settlement_snapshot", snapshot_run_id=rid, run_id=rid, settlement_month=field("settlement_month") or basis_date[:7], basis_date=basis_date, currency=field("currency"), is_closed_snapshot=True, is_comprehensive=comprehensive, legacy=legacy, aggregation_version=field("aggregation_version") if comprehensive else None, snapshot_schema_version=field("snapshot_schema_version") if comprehensive else None, cashflow_policy=policy, cashflow_items=cashflows, items=safe_items, source_snapshot=source, totals=totals, **totals, close_status=(field("close_status") or metadata.get("close_status", "legacy_read_only")), correction={"is_correction": bool(field("correction_of_run_id") or metadata.get("is_correction")), "correction_of_run_id": field("correction_of_run_id") or metadata.get("correction_of_run_id", ""), "correction_reason": field("correction_reason") or metadata.get("correction_reason", "")})
|
||||
except DatabaseNotInitializedError:
|
||||
return fail("db_not_initialized", "ledger database is not initialized")
|
||||
except Exception:
|
||||
return fail("db_error", "settlement snapshot read failed")
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Private, deterministic aggregation for comprehensive settlement previews."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from .shared import CASHFLOW_KIND_SQL
|
||||
|
||||
_AGGREGATION_VERSION = "comprehensive-v1"
|
||||
_SNAPSHOT_SCHEMA_VERSION = 1
|
||||
_ALLOWED_KINDS = {"income", "expense", "excluded"}
|
||||
|
||||
|
||||
def _normalize_cashflow_policy(payload: dict) -> dict:
|
||||
if not isinstance(payload, dict) or set(payload) - {"savings_categories", "category_decisions"}:
|
||||
raise ValueError("cashflow policy must contain only supported keys")
|
||||
savings = payload.get("savings_categories", [])
|
||||
decisions = payload.get("category_decisions", [])
|
||||
if not isinstance(savings, list) or not isinstance(decisions, list):
|
||||
raise ValueError("policy categories and decisions must be lists")
|
||||
def pair(row, required):
|
||||
if not isinstance(row, dict) or set(row) != required:
|
||||
raise ValueError("invalid category policy entry")
|
||||
if any(not isinstance(row[k], str) for k in ("category", "subcategory")):
|
||||
raise ValueError("category fields must be strings")
|
||||
return row["category"], row["subcategory"]
|
||||
normalized_savings = [pair(x, {"category", "subcategory"}) for x in savings]
|
||||
if len(set(normalized_savings)) != len(normalized_savings):
|
||||
raise ValueError("duplicate savings category")
|
||||
normalized_decisions = []
|
||||
for row in decisions:
|
||||
cat, sub = pair(row, {"category", "subcategory", "cashflow_kind", "reason"})
|
||||
kind, reason = row["cashflow_kind"], row["reason"]
|
||||
if not isinstance(kind, str) or kind not in _ALLOWED_KINDS:
|
||||
raise ValueError("invalid cashflow decision kind")
|
||||
if not isinstance(reason, str) or not reason.strip():
|
||||
raise ValueError("decision reason must be nonempty")
|
||||
normalized_decisions.append((cat, sub, kind, reason))
|
||||
keys = [(x[0], x[1]) for x in normalized_decisions]
|
||||
if len(set(keys)) != len(keys):
|
||||
raise ValueError("duplicate category decision")
|
||||
return {"savings_categories": [{"category": c, "subcategory": s} for c, s in sorted(normalized_savings)],
|
||||
"category_decisions": [{"category": c, "subcategory": s, "cashflow_kind": k, "reason": r}
|
||||
for c, s, k, r in sorted(normalized_decisions)]}
|
||||
|
||||
|
||||
def _review_cashflow(conn, month: str, policy: dict) -> dict:
|
||||
policy = _normalize_cashflow_policy(policy)
|
||||
start = date.fromisoformat(month + "-01")
|
||||
end = date(start.year + (start.month == 12), 1 if start.month == 12 else start.month + 1, 1)
|
||||
rows = conn.execute(f"""SELECT t.id,t.transaction_date,t.type,t.amount,t.currency,t.category,t.subcategory,
|
||||
{CASHFLOW_KIND_SQL} AS originalcashflow_kind, lc.cashflow_kind AS category_cashflow_kind,
|
||||
lc.active AS category_active
|
||||
FROM transactions t LEFT JOIN ledger_categories lc
|
||||
ON lc.category=t.category AND lc.subcategory=t.subcategory AND lc.active=1
|
||||
WHERE t.status='active' AND t.transaction_date>=? AND t.transaction_date<?
|
||||
ORDER BY t.transaction_date,t.id""", (start.isoformat(), end.isoformat())).fetchall()
|
||||
savings = {(x["category"], x["subcategory"]) for x in policy["savings_categories"]}
|
||||
decisions = {(x["category"], x["subcategory"]): x for x in policy["category_decisions"]}
|
||||
grouped, source, blockers = {}, [], []
|
||||
income = expense = 0
|
||||
for row in rows:
|
||||
t = dict(row)
|
||||
original = t["originalcashflow_kind"]
|
||||
pair = (t["category"], t["subcategory"])
|
||||
decision = decisions.get(pair)
|
||||
typ, catkind = t["type"], original
|
||||
amount = t["amount"]
|
||||
raw_valid = isinstance(amount, int) and not isinstance(amount, bool)
|
||||
reason = None
|
||||
# Explicit movement classes are immutable exclusions; investment is only selected by pair.
|
||||
general_transfer = catkind == "transfer" or (typ == "transfer" and catkind != "investment")
|
||||
initial_adjustment = typ == "adjustment"
|
||||
explicit_exclusion = decision and decision["cashflow_kind"] == "excluded"
|
||||
kind = "excluded"
|
||||
applied = amount if raw_valid else None
|
||||
if general_transfer or initial_adjustment or catkind in {"exclude", "transfer"} or explicit_exclusion:
|
||||
kind = "excluded"
|
||||
elif typ == "refund":
|
||||
if catkind in {"expense", "debt_payment"} or pair in savings or (decision and decision["cashflow_kind"] == "expense"):
|
||||
kind, applied = "expense", -amount if raw_valid else None
|
||||
else:
|
||||
reason = "refund requires an expense classification or explicit expense decision"
|
||||
elif catkind == "investment":
|
||||
if pair in savings:
|
||||
kind = "expense"
|
||||
else:
|
||||
kind = "excluded"
|
||||
elif catkind == "debt_payment":
|
||||
kind = "expense"
|
||||
elif catkind in {"income", "expense"}:
|
||||
kind = catkind
|
||||
elif decision and decision["cashflow_kind"] in {"income", "expense"}:
|
||||
kind = decision["cashflow_kind"]
|
||||
else:
|
||||
reason = "unknown cashflow classification requires an explicit decision"
|
||||
if not raw_valid and kind != "excluded":
|
||||
reason = "transaction amount is not an integer"
|
||||
if reason:
|
||||
blockers.append({"transaction_id": t["id"], "reason": reason})
|
||||
kind, applied = "unknown", None
|
||||
if kind != "excluded" and t["currency"].upper() != "KRW":
|
||||
blockers.append({"transaction_id": t["id"], "reason": "non-KRW currency requires FX review"})
|
||||
applied = None
|
||||
provenance = "category" if t["category_active"] else "fallback"
|
||||
evidence = {k: t[k] for k in ("id", "transaction_date", "type", "amount", "currency", "category", "subcategory")}
|
||||
evidence.update(originalcashflow_kind=original, cashflow_kind=kind, applied_amount=applied,
|
||||
decision=decision, sourceprovenance=provenance,
|
||||
category_cashflow_kind=t["category_cashflow_kind"], category_active=t["category_active"])
|
||||
source.append(evidence)
|
||||
if applied is None or kind not in {"income", "expense"}:
|
||||
if kind == "excluded" and applied is not None:
|
||||
key = (kind, pair[0], pair[1], t["currency"].upper())
|
||||
grp = grouped.setdefault(key, [0, 0]); grp[0] += applied; grp[1] += 1
|
||||
continue
|
||||
if t["currency"].upper() != "KRW":
|
||||
continue
|
||||
key = (kind, pair[0], pair[1], t["currency"].upper())
|
||||
grp = grouped.setdefault(key, [0, 0]); grp[0] += applied; grp[1] += 1
|
||||
if kind == "income": income += applied
|
||||
else: expense += applied
|
||||
items = [{"cashflow_kind": k, "category": c, "subcategory": s, "amount": v[0], "transaction_count": v[1], "currency": cur}
|
||||
for (k, c, s, cur), v in sorted(grouped.items())]
|
||||
return {"cashflow_policy": policy, "aggregation_version": _AGGREGATION_VERSION,
|
||||
"snapshot_schema_version": _SNAPSHOT_SCHEMA_VERSION, "income_total": income,
|
||||
"expense_total": expense, "net_cashflow": income-expense, "cashflow_items": items,
|
||||
"cashflow_blockers": blockers, "cashflow_warnings": [], "source_snapshot": {"transactions": source}}
|
||||
@@ -0,0 +1,470 @@
|
||||
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):
|
||||
from .assets_service import _summarize_valuation_items
|
||||
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') or 'KRW').upper(),
|
||||
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})
|
||||
latest = _latest_close(conn, draft['basis_date'])
|
||||
summary = _summarize_valuation_items(included)
|
||||
totals = {key: summary[key] for key in ('total_assets', 'total_liabilities', 'net_worth')}
|
||||
from .settlement_cashflow_service import _normalize_cashflow_policy, _review_cashflow, _AGGREGATION_VERSION
|
||||
if draft.get('aggregation_version') != _AGGREGATION_VERSION:
|
||||
raise ValueError('unsupported stored aggregation version')
|
||||
policy = _normalize_cashflow_policy(json.loads(draft.get('cashflow_policy_json') or '{}'))
|
||||
cf = _review_cashflow(conn, draft['month'], policy)
|
||||
loan_sum = sum((x.get('candidate_amount') or 0) for x in included if x.get('is_liability'))
|
||||
loan_sign = -1 if loan_sum < 0 else 1 if loan_sum > 0 else 0
|
||||
asset_snapshot = []
|
||||
for x in items:
|
||||
raw = x.get('candidate_amount')
|
||||
applied_amount = raw * loan_sign if x.get('amount') is not None and x.get('is_liability') else x.get('amount')
|
||||
asset_snapshot.append({k: x.get(k) for k in ('source_id','source_type','valuation_id','valuation_date','basis_date','currency','authority_source','liquidity_bucket','is_liability','selection_mode','exclusion_reason','status','verification_status')} | {'amount': raw, 'applied_amount': applied_amount})
|
||||
cf['source_snapshot']['assets'] = asset_snapshot
|
||||
totals.update({k: cf[k] for k in ('income_total','expense_total','net_cashflow')})
|
||||
full_totals = {**totals}
|
||||
serial = json.dumps({'draft_id': draft['id'], 'version': draft['version'], 'basis_date': draft['basis_date'], 'states': states, 'warnings': warnings, 'latest_run': latest, 'correction_parent': draft['correction_of_run_id'], 'totals': full_totals, 'cashflow': cf}, sort_keys=True, ensure_ascii=False)
|
||||
return {'draft_id': draft['id'], 'version': draft['version'], 'review_fingerprint': hashlib.sha256(serial.encode()).hexdigest(),
|
||||
'cashflow_policy': policy, 'aggregation_version': cf['aggregation_version'], 'snapshot_schema_version': cf['snapshot_schema_version'], 'cashflow_items': cf['cashflow_items'], 'cashflow_blockers': cf['cashflow_blockers'], 'cashflow_warnings': cf['cashflow_warnings'], 'source_snapshot': cf['source_snapshot'],
|
||||
'items': items, 'included_items': included, 'excluded_items': excluded, 'missing_items': missing, 'warnings': warnings,
|
||||
'approval_required': True, 'totals': totals, **totals,
|
||||
'complete': bool(items) and not missing and not cf['cashflow_blockers'],
|
||||
'unregistered_liabilities': not any(x['is_liability'] for x in items),
|
||||
'contains_substitutions': any(x['selection_mode'] == 'manual_substitute' for x in included),
|
||||
'contains_carried': any(x['selection_mode'] == 'carried_before' for x in included),
|
||||
'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')
|
||||
draft_out = {k: v for k, v in d.items() if k != 'final_response'}
|
||||
if draft_out.get('cashflow_policy_json'):
|
||||
from .settlement_cashflow_service import _normalize_cashflow_policy
|
||||
draft_out['cashflow_policy'] = _normalize_cashflow_policy(json.loads(draft_out['cashflow_policy_json']))
|
||||
if d['status'] == 'completed' and d['final_response']:
|
||||
final = json.loads(d['final_response'])
|
||||
return ok(draft=draft_out, items=final.get('items', []) + final.get('excluded_items', []), final_response=final)
|
||||
review = _review(conn, d)
|
||||
return ok(draft=draft_out, items=review['items'], review=review)
|
||||
|
||||
|
||||
def create_settlement_draft(month: str, created_by: str, idempotency_key: str, basis_date: str = '', memo: str = '', correction_of_run_id: str = '', correction_reason: str = '', db_path: str|None=None):
|
||||
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()
|
||||
drafts = [dict(x) for x in rows]
|
||||
for item in drafts:
|
||||
if item.get('cashflow_policy_json'):
|
||||
from .settlement_cashflow_service import _normalize_cashflow_policy
|
||||
item['cashflow_policy'] = _normalize_cashflow_policy(json.loads(item['cashflow_policy_json']))
|
||||
return ok(drafts=drafts)
|
||||
except Exception as e:return _err('db_not_initialized' if 'not initialized' in str(e).lower() else 'db_error','ledger database is not initialized' if 'not initialized' in str(e).lower() else 'database error')
|
||||
|
||||
|
||||
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 set_settlement_cashflow_policy(draft_id: str, expected_version: int, updated_by: str, cashflow_policy: dict, db_path: str|None=None):
|
||||
from .settlement_cashflow_service import _normalize_cashflow_policy
|
||||
if not _valid_version(expected_version) or not _nonempty(updated_by) or not isinstance(draft_id, str) or not draft_id:
|
||||
return _err('validation_error', 'valid version, draft and actor required')
|
||||
try:
|
||||
policy = _normalize_cashflow_policy(cashflow_policy)
|
||||
encoded = json.dumps(policy, sort_keys=True, separators=(',', ':'), ensure_ascii=False)
|
||||
except (ValueError, TypeError):
|
||||
return _err('validation_error', 'invalid cashflow policy')
|
||||
return _mutate(draft_id, expected_version, updated_by,
|
||||
"UPDATE settlement_drafts SET cashflow_policy_json=?,aggregation_version='comprehensive-v1' WHERE id=?", (encoded,), db_path)
|
||||
|
||||
|
||||
def update_settlement_draft(draft_id: str,expected_version: int,updated_by: str,memo: str,db_path: str|None=None):
|
||||
if not isinstance(memo, str): return _err('validation_error', 'memo must be a string')
|
||||
return _mutate(draft_id,expected_version,updated_by,'UPDATE settlement_drafts SET memo=? WHERE id=?',(memo,),db_path)
|
||||
|
||||
|
||||
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 in ('balance', 'cash_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')
|
||||
if d['status'] == 'completed' and d['final_response']:
|
||||
saved = json.loads(d['final_response'])
|
||||
return {**saved, 'version': d['version']}
|
||||
return ok(**_review(c,d))
|
||||
except Exception as e:return _err('db_not_initialized' if 'not initialized' in str(e).lower() else 'db_error','ledger database is not initialized' if 'not initialized' in str(e).lower() else 'database error')
|
||||
|
||||
|
||||
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['cashflow_blockers']: return _err('validation_error', 'cashflow review blockers must be resolved')
|
||||
if r['has_approved_missing'] and not allow_missing_exclusions:return _err('validation_error','missing/excluded items require approval')
|
||||
if r['contains_carried'] and not allow_carried_before:return _err('validation_error','carried values require approval')
|
||||
if r['contains_substitutions'] and not allow_manual_substitute:return _err('validation_error','substitutes require approval')
|
||||
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 r['cashflow_blockers']: return _err('validation_error', 'cashflow review blockers must be resolved')
|
||||
if _latest_close(c, d['basis_date']) != d['correction_of_run_id']: return _err('conflict', 'correction parent changed')
|
||||
if r['review_fingerprint']!=review_fingerprint or d['approval_fingerprint']!=review_fingerprint or d['approval_version']!=expected_version:return _err('conflict','review/approval stale')
|
||||
flags=json.loads(d['approval_flags'] or '{}')
|
||||
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,settlement_month,currency,income_total,expense_total,net_cashflow,total_assets,total_liabilities,net_worth,cashflow_policy_json,aggregation_version,snapshot_schema_version,source_snapshot_json) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',(run,basis,status,'exact',idempotency_key,d['approved_by'],d['approved_at'],d['approval_reason'],d['approval_reference'],d['correction_of_run_id'],d['correction_reason'],json.dumps(applied,sort_keys=True),json.dumps(excluded,sort_keys=True),json.dumps(carried,sort_keys=True),req,ts,draft_id,expected_version,review_fingerprint,d['month'],'KRW',r['income_total'],r['expense_total'],r['net_cashflow'],r['total_assets'],r['total_liabilities'],r['net_worth'],json.dumps(r['cashflow_policy'],sort_keys=True),r['aggregation_version'],r['snapshot_schema_version'],json.dumps(r['source_snapshot'],sort_keys=True)))
|
||||
snapshot_ids = []
|
||||
for x in applied + excluded:
|
||||
sid = uuid4().hex
|
||||
snapshot_ids.append(sid)
|
||||
c.execute('INSERT INTO asset_snapshots(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,valuation_source,source_note,memo,snapshot_run_id,correction_of_run_id,correction_reason,is_correction,closed_at,created_at,valuation_id,selection_policy,verification_status,evidence_kind,exclusion_reason) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',(sid,basis,x['source_type'],x['source_id'],x.get('name',''),x['amount'],x['currency'],x['liquidity_bucket'],x['selection_mode'],'','',run,d['correction_of_run_id'],d['correction_reason'],int(bool(d['correction_of_run_id'])),ts,ts,x.get('valuation_id') or '',x['selection_mode'],x.get('verification_status','unverified'),'excluded' if x in excluded else 'applied',x.get('exclusion_reason','')))
|
||||
for item in r['cashflow_items']:
|
||||
c.execute('INSERT INTO settlement_cashflow_snapshots(id,settlement_run_id,cashflow_kind,category,subcategory,amount,transaction_count,currency) VALUES(?,?,?,?,?,?,?,?)', (uuid4().hex,run,item['cashflow_kind'],item['category'],item['subcategory'],item['amount'],item['transaction_count'],item['currency']))
|
||||
saved_groups = [dict(x) for x in c.execute('SELECT cashflow_kind,category,subcategory,amount,transaction_count,currency FROM settlement_cashflow_snapshots WHERE settlement_run_id=? ORDER BY cashflow_kind,category,subcategory,currency', (run,))]
|
||||
expected_groups = sorted([{'cashflow_kind':x['cashflow_kind'],'category':x['category'],'subcategory':x['subcategory'],'amount':x['amount'],'transaction_count':x['transaction_count'],'currency':x['currency']} for x in r['cashflow_items']], key=lambda x:(x['cashflow_kind'],x['category'],x['subcategory'],x['currency']))
|
||||
if saved_groups != expected_groups: raise ValueError('persisted cashflow detail mismatch')
|
||||
header = c.execute('SELECT * FROM asset_snapshot_runs WHERE id=?', (run,)).fetchone()
|
||||
expected_header = {k:r[k] for k in ('income_total','expense_total','net_cashflow','total_assets','total_liabilities','net_worth','aggregation_version','snapshot_schema_version')}
|
||||
expected_header.update(settlement_month=d['month'],currency='KRW',basis_date=basis,draft_id=draft_id,draft_version=expected_version,review_fingerprint=review_fingerprint,request_fingerprint=req,close_status=status,idempotency_key=idempotency_key,correction_of_run_id=d['correction_of_run_id'],correction_reason=d['correction_reason'])
|
||||
if not header or any(header[k] != v for k,v in expected_header.items()):
|
||||
raise ValueError('persisted header mismatch')
|
||||
for key,value in (('source_snapshot_json',r['source_snapshot']),('cashflow_policy_json',r['cashflow_policy']),('applied_evidence',applied),('excluded_evidence',excluded),('carried_evidence',carried)):
|
||||
if json.loads(header[key]) != value:
|
||||
raise ValueError('persisted evidence mismatch')
|
||||
persisted_assets = [dict(x) for x in c.execute('SELECT * FROM asset_snapshots WHERE snapshot_run_id=? ORDER BY id', (run,))]
|
||||
fields = ('source_type','source_id','amount','currency','liquidity_bucket')
|
||||
expected_assets = sorted(zip(snapshot_ids, applied+excluded), key=lambda pair:pair[0])
|
||||
if len(persisted_assets) != len(expected_assets):
|
||||
raise ValueError('persisted asset count mismatch')
|
||||
for row,(sid,item) in zip(persisted_assets,expected_assets):
|
||||
if row['id'] != sid or any(row[k] != item[k] for k in fields) or row['basis_date'] != basis or row['valuation_id'] != (item.get('valuation_id') or '') or row['selection_policy'] != item['selection_mode'] or row['verification_status'] != item.get('verification_status','unverified') or row['evidence_kind'] != ('excluded' if item in excluded else 'applied') or row['exclusion_reason'] != item.get('exclusion_reason',''):
|
||||
raise ValueError('persisted asset detail mismatch')
|
||||
if sum(x['amount'] for x in saved_groups if x['cashflow_kind']=='income') != r['income_total'] or sum(x['amount'] for x in saved_groups if x['cashflow_kind']=='expense') != r['expense_total'] or r['net_cashflow'] != r['income_total']-r['expense_total']:
|
||||
raise ValueError('cashflow totals mismatch')
|
||||
asset_check = _summarize_valuation_items([x for x in persisted_assets if x['evidence_kind']=='applied'])
|
||||
if (asset_check['total_assets'] != r['total_assets'] or asset_check['total_liabilities'] != r['total_liabilities']
|
||||
or r['net_worth'] != r['total_assets']-r['total_liabilities']):
|
||||
raise ValueError('asset totals mismatch')
|
||||
evidence = _evidence_provenance(applied, basis)
|
||||
response = _ok_with_provenance_aliases(
|
||||
draft_id=draft_id, draft_version=expected_version, review_fingerprint=review_fingerprint,
|
||||
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'],
|
||||
income_total=r['income_total'], expense_total=r['expense_total'], net_cashflow=r['net_cashflow'],
|
||||
total_assets=r['total_assets'], total_liabilities=r['total_liabilities'], net_worth=r['net_worth'],
|
||||
cashflow_policy=r['cashflow_policy'], aggregation_version=r['aggregation_version'], snapshot_schema_version=r['snapshot_schema_version'], cashflow_items=r['cashflow_items'], cashflow_blockers=r['cashflow_blockers'], source_snapshot=r['source_snapshot'],
|
||||
contains_substitutions=r['contains_substitutions'], contains_carried=r['contains_carried'], has_approved_missing=bool(excluded),
|
||||
settlement_label='대체 평가자료를 포함한 월정산' if r['contains_substitutions'] else '조건부 월정산' if status == 'conditional' else '정확한 월말 평가 월정산',
|
||||
warnings=r['warnings'], carried_items=carried, approval=approval, correction=correction, **correction,
|
||||
provenance=_canonical_provenance(valuation_date=evidence['valuation_date'], age_days=evidence['age_days'], carried=bool(carried), verification_status=evidence['verification_status'], evidence_kind=evidence['evidence_kind'], basis_date=basis, snapshot_run_id=run, close_status=status, selection_policy='exact', warnings=r['warnings'], exclusions=excluded, approval=approval, correction=correction),
|
||||
**{k: v for k, v in _summarize_valuation_items(applied, excluded).items() if k not in ('excluded_items','total_assets','total_liabilities','net_worth')},
|
||||
)
|
||||
c.execute("UPDATE settlement_drafts SET status='completed',version=version+1,final_run_id=?,final_response=?,updated_at=? WHERE id=?",(run,json.dumps(response),ts,draft_id))
|
||||
return response
|
||||
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 ''", "cashflow_policy_json": "TEXT NOT NULL DEFAULT '{}'", "aggregation_version": "TEXT NOT NULL DEFAULT 'comprehensive-v1'"})
|
||||
conn.execute("""CREATE TABLE IF NOT EXISTS settlement_draft_items (
|
||||
id TEXT PRIMARY KEY, draft_id TEXT NOT NULL REFERENCES settlement_drafts(id),
|
||||
source_type TEXT NOT NULL, source_id TEXT NOT NULL, valuation_id TEXT NOT NULL DEFAULT '',
|
||||
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,33 @@ 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 ''"})
|
||||
_add_missing_columns(conn, "asset_snapshot_runs", {
|
||||
"settlement_month": "TEXT",
|
||||
"currency": "TEXT",
|
||||
"income_total": "INTEGER",
|
||||
"expense_total": "INTEGER",
|
||||
"net_cashflow": "INTEGER",
|
||||
"total_assets": "INTEGER",
|
||||
"total_liabilities": "INTEGER",
|
||||
"net_worth": "INTEGER",
|
||||
"cashflow_policy_json": "TEXT",
|
||||
"aggregation_version": "TEXT",
|
||||
"snapshot_schema_version": "INTEGER",
|
||||
"source_snapshot_json": "TEXT",
|
||||
})
|
||||
conn.execute("""CREATE TABLE IF NOT EXISTS settlement_cashflow_snapshots (
|
||||
id TEXT PRIMARY KEY,
|
||||
settlement_run_id TEXT NOT NULL REFERENCES asset_snapshot_runs(id) ON DELETE RESTRICT,
|
||||
cashflow_kind TEXT NOT NULL CHECK (cashflow_kind IN ('income', 'expense', 'excluded')),
|
||||
category TEXT NOT NULL DEFAULT '',
|
||||
subcategory TEXT NOT NULL DEFAULT '',
|
||||
amount INTEGER NOT NULL CHECK (typeof(amount) = 'integer'),
|
||||
transaction_count INTEGER NOT NULL CHECK (typeof(transaction_count) = 'integer' AND transaction_count > 0),
|
||||
currency TEXT NOT NULL,
|
||||
UNIQUE (settlement_run_id, cashflow_kind, category, subcategory, currency)
|
||||
)""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_settlement_cashflow_snapshots_run ON settlement_cashflow_snapshots(settlement_run_id)")
|
||||
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_idempotency ON asset_snapshot_runs(idempotency_key) WHERE idempotency_key <> ''")
|
||||
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_initial_basis ON asset_snapshot_runs(basis_date) WHERE correction_of_run_id = ''")
|
||||
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_correction_parent ON asset_snapshot_runs(correction_of_run_id) WHERE correction_of_run_id <> ''")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
SCHEMA_VERSION = "12"
|
||||
SCHEMA_VERSION = "14"
|
||||
|
||||
SCHEMA_SQL = """
|
||||
PRAGMA journal_mode=WAL;
|
||||
@@ -190,12 +190,37 @@ CREATE TABLE IF NOT EXISTS asset_snapshot_runs (
|
||||
excluded_evidence TEXT NOT NULL DEFAULT '[]',
|
||||
carried_evidence TEXT NOT NULL DEFAULT '[]',
|
||||
request_fingerprint TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
created_at TEXT NOT NULL,
|
||||
settlement_month TEXT,
|
||||
currency TEXT,
|
||||
income_total INTEGER,
|
||||
expense_total INTEGER,
|
||||
net_cashflow INTEGER,
|
||||
total_assets INTEGER,
|
||||
total_liabilities INTEGER,
|
||||
net_worth INTEGER,
|
||||
cashflow_policy_json TEXT,
|
||||
aggregation_version TEXT,
|
||||
snapshot_schema_version INTEGER,
|
||||
source_snapshot_json TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_idempotency ON asset_snapshot_runs(idempotency_key) WHERE idempotency_key <> '';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_initial_basis ON asset_snapshot_runs(basis_date) WHERE correction_of_run_id = '';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_correction_parent ON asset_snapshot_runs(correction_of_run_id) WHERE correction_of_run_id <> '';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settlement_cashflow_snapshots (
|
||||
id TEXT PRIMARY KEY,
|
||||
settlement_run_id TEXT NOT NULL REFERENCES asset_snapshot_runs(id) ON DELETE RESTRICT,
|
||||
cashflow_kind TEXT NOT NULL CHECK (cashflow_kind IN ('income', 'expense', 'excluded')),
|
||||
category TEXT NOT NULL DEFAULT '',
|
||||
subcategory TEXT NOT NULL DEFAULT '',
|
||||
amount INTEGER NOT NULL CHECK (typeof(amount) = 'integer'),
|
||||
transaction_count INTEGER NOT NULL CHECK (typeof(transaction_count) = 'integer' AND transaction_count > 0),
|
||||
currency TEXT NOT NULL,
|
||||
UNIQUE (settlement_run_id, cashflow_kind, category, subcategory, currency)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_settlement_cashflow_snapshots_run ON settlement_cashflow_snapshots(settlement_run_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS review_decisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
target_type TEXT NOT NULL,
|
||||
@@ -266,7 +291,7 @@ CREATE INDEX IF NOT EXISTS idx_account_valuations_status_date ON account_valuati
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_account_valuations_idempotency ON account_valuations(account_id, idempotency_key) WHERE idempotency_key <> '';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_account_valuations_single_correction ON account_valuations(supersedes_id) WHERE supersedes_id IS NOT NULL;
|
||||
|
||||
INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', '12');
|
||||
INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', '14');
|
||||
"""
|
||||
|
||||
ACCOUNT_ADDITIVE_COLUMNS: dict[str, str] = {
|
||||
|
||||
@@ -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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "jarca-ledger-mcp"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
description = "Jarca household ledger MCP server for transactions, accounts, budgets, cashflow, and investable cashflow analysis"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -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 == "14"
|
||||
assert {"id", "account_id", "basis_date", "amount", "currency", "valuation_kind", "source_type", "source_ref", "verification_status", "recorded_at", "memo", "idempotency_key", "supersedes_id", "correction_reason", "as_of_at", "timezone", "cost_basis", "net_contributed", "reported_principal_kind", "reported_principal_label", "profit", "return_rate", "return_basis", "principal", "interest", "fee", "representative_liability_definition"} <= cols
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,687 @@
|
||||
"""Issue 18 comprehensive settlement acceptance contracts (synthetic SQLite only).
|
||||
|
||||
Public APIs are resolved at call time so the pre-implementation suite collects and
|
||||
fails with an actionable assertion rather than an import/attribute error.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import pytest
|
||||
|
||||
from jarca_ledger_mcp import service
|
||||
from jarca_ledger_mcp.db import init_db
|
||||
|
||||
|
||||
def api(name):
|
||||
fn = getattr(service, name, None)
|
||||
assert callable(fn), f"required comprehensive settlement API missing: service.{name}"
|
||||
return fn
|
||||
|
||||
|
||||
def dbfile(tmp_path):
|
||||
path = str(tmp_path / "comprehensive-synthetic.sqlite3")
|
||||
init_db(path)
|
||||
return path
|
||||
|
||||
|
||||
def ok(value):
|
||||
assert value.get("success") is True, value
|
||||
return value
|
||||
|
||||
|
||||
def new_draft(path, month="2026-09", key="draft"):
|
||||
created = service.create_manual_asset("Synthetic cash", 100, "2026-09-30", "cash", db_path=path)
|
||||
assert created.get("success") is True, created
|
||||
return ok(api("create_settlement_draft")(month, "synthetic-operator", key, db_path=path))["draft"]
|
||||
|
||||
|
||||
def close_draft(path, draft, idem="close"):
|
||||
review_result = ok(api("review_settlement_draft")(draft["id"], db_path=path))
|
||||
review = review_result
|
||||
approved = api("approve_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "synthetic-reviewer", "synthetic approval", "ref-synthetic", db_path=path)
|
||||
ok(approved)
|
||||
fresh = ok(api("get_settlement_draft")(draft["id"], db_path=path))["draft"]
|
||||
return api("finalize_settlement_draft")(draft["id"], fresh["version"], review["review_fingerprint"], idem, db_path=path)
|
||||
|
||||
|
||||
def test_draft_previews_six_totals_without_creating_run(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
draft = new_draft(path)
|
||||
result = ok(api("review_settlement_draft")(draft["id"], db_path=path))
|
||||
review = result
|
||||
required = {"income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth"}
|
||||
assert required <= review.keys() and required <= review["totals"].keys()
|
||||
assert review["aggregation_version"] == "comprehensive-v1"
|
||||
assert review["snapshot_schema_version"] == 1
|
||||
assert isinstance(review["cashflow_items"], list)
|
||||
with sqlite3.connect(path) as db:
|
||||
assert db.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
|
||||
assert db.execute("SELECT COUNT(*) FROM settlement_drafts WHERE id=?", (draft["id"],)).fetchone()[0] == 1
|
||||
|
||||
|
||||
def test_closed_header_persists_six_totals_policy_and_versions(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
draft = new_draft(path)
|
||||
result = ok(close_draft(path, draft))
|
||||
assert result.get("run_id")
|
||||
saved = result
|
||||
for key in ("income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth"):
|
||||
assert key in saved
|
||||
assert saved["aggregation_version"] == "comprehensive-v1"
|
||||
assert saved["snapshot_schema_version"] == 1
|
||||
with sqlite3.connect(path) as db:
|
||||
columns = {row[1] for row in db.execute("PRAGMA table_info(asset_snapshot_runs)")}
|
||||
assert {"income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth", "cashflow_policy_json", "aggregation_version", "snapshot_schema_version", "source_snapshot_json"} <= columns
|
||||
row = db.execute("SELECT income_total,expense_total,net_cashflow,total_assets,total_liabilities,net_worth FROM asset_snapshot_runs WHERE id=?", (saved["run_id"],)).fetchone()
|
||||
assert len(row) == 6
|
||||
|
||||
|
||||
def test_asset_detail_preserves_raw_evidence_and_signed_liability_contributions(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
a = service.create_ledger_account("Synthetic debt A", type="loan", liquidity_bucket="loan", db_path=path)["account"]["id"]
|
||||
b = service.create_ledger_account("Synthetic debt B", type="loan", liquidity_bucket="loan", db_path=path)["account"]["id"]
|
||||
for account, amount, key in ((a, -900, "loan-a"), (b, 200, "loan-b")):
|
||||
v = service.register_account_valuation(account, "2026-09-30", amount, valuation_kind="loan_balance", representative_liability_definition="principal_only", verification_status="verified", idempotency_key=key, db_path=path)
|
||||
assert v["success"], v
|
||||
draft = new_draft(path)
|
||||
review_result = ok(api("review_settlement_draft")(draft["id"], db_path=path))
|
||||
review = review_result
|
||||
entries = review.get("items", [])
|
||||
assert entries
|
||||
assert any(x.get("amount") in (-900, 200) for x in entries)
|
||||
result = ok(close_draft(path, draft))
|
||||
evidence = json.dumps(result, sort_keys=True)
|
||||
assert "applied_amount" in evidence
|
||||
assert "-900" in evidence and "200" in evidence
|
||||
|
||||
|
||||
def test_source_snapshot_preserves_reproducible_values_without_raw_secrets(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
account = service.create_ledger_account("Synthetic evidence", last_balance=42, liquidity_bucket="cash", db_path=path)["account"]["id"]
|
||||
# Evidence is synthetic; no credentials or user-provided freeform payloads.
|
||||
v = service.register_account_valuation(account, "2026-09-30", 42, valuation_kind="balance", verification_status="verified", idempotency_key="value", source_ref="synthetic-ref", db_path=path)
|
||||
assert v["success"], v
|
||||
result = ok(close_draft(path, new_draft(path)))
|
||||
saved = result
|
||||
assert saved.get("source_snapshot") or saved.get("source_snapshot_json")
|
||||
encoded = json.dumps(saved, sort_keys=True)
|
||||
assert "synthetic-ref" in encoded
|
||||
for secret in ("raw_payload", "password", "cookie", "token"):
|
||||
assert secret not in encoded.lower()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mutation", ["ledger_amount", "category_classification"])
|
||||
def test_source_policy_and_category_drift_invalidate_approval(tmp_path, mutation):
|
||||
path = dbfile(tmp_path)
|
||||
category = service.create_ledger_category("Food", "Meal", cashflow_kind="expense", db_path=path)
|
||||
assert category.get("success") is True, category
|
||||
transaction = service.create_ledger_transaction(transaction_date="2026-09-12", type="expense", amount=17, category="Food", subcategory="Meal", description="synthetic drift", dedupe=False, db_path=path)
|
||||
assert transaction.get("success") is True, transaction
|
||||
txid = transaction.get("transaction", transaction).get("id")
|
||||
draft = new_draft(path)
|
||||
review_result = ok(api("review_settlement_draft")(draft["id"], db_path=path))
|
||||
review = review_result
|
||||
ok(api("approve_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "synthetic-reviewer", "approved", "ref", db_path=path))
|
||||
with sqlite3.connect(path) as db:
|
||||
if mutation == "ledger_amount":
|
||||
db.execute("UPDATE transactions SET amount=amount+1 WHERE id=?", (txid,))
|
||||
else:
|
||||
tables = {r[0] for r in db.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||
assert "ledger_categories" in tables
|
||||
cols = {r[1] for r in db.execute("PRAGMA table_info(ledger_categories)")}
|
||||
assert {"category", "subcategory", "cashflow_kind"} <= cols
|
||||
db.execute("UPDATE ledger_categories SET cashflow_kind='income' WHERE category=? AND subcategory=?", ("Food", "Meal"))
|
||||
db.commit()
|
||||
closed = api("finalize_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "close", db_path=path)
|
||||
assert closed.get("success") is False, closed
|
||||
with sqlite3.connect(path) as db:
|
||||
assert db.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure_point", ["header", "assets", "cashflow", "completion"])
|
||||
def test_mid_close_failure_rolls_back_all_settlement_state(tmp_path, failure_point):
|
||||
path = dbfile(tmp_path)
|
||||
ok(service.create_ledger_category("Work", "Salary", cashflow_kind="income", db_path=path))
|
||||
ok(service.create_ledger_transaction("2026-09-10", "income", 50, "Work", "Salary", db_path=path))
|
||||
draft = new_draft(path)
|
||||
review_result = ok(api("review_settlement_draft")(draft["id"], db_path=path))
|
||||
review = review_result
|
||||
ok(api("approve_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "synthetic-reviewer", "approved", "ref", db_path=path))
|
||||
with sqlite3.connect(path) as db:
|
||||
target = {"header":"asset_snapshot_runs", "assets":"asset_snapshots", "cashflow":"settlement_cashflow_snapshots", "completion":"settlement_drafts"}[failure_point]
|
||||
# Missing future table is an explicit RED assertion, not a SQL error masquerading as rollback.
|
||||
assert db.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (target,)).fetchone(), f"required comprehensive table missing: {target}"
|
||||
event = "UPDATE" if failure_point == "completion" else "INSERT"
|
||||
condition = " WHEN NEW.status='completed'" if failure_point == "completion" else ""
|
||||
db.execute(f"CREATE TRIGGER fail_{failure_point} BEFORE {event} ON {target}{condition} BEGIN SELECT RAISE(ABORT,'synthetic injected failure'); END")
|
||||
failed = api("finalize_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "close", db_path=path)
|
||||
assert failed.get("success") is False
|
||||
with sqlite3.connect(path) as db:
|
||||
assert db.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
|
||||
assert db.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0] == 0
|
||||
assert db.execute("SELECT COUNT(*) FROM settlement_cashflow_snapshots").fetchone()[0] == 0
|
||||
assert db.execute("SELECT status FROM settlement_drafts WHERE id=?", (draft["id"],)).fetchone()[0] == "active"
|
||||
|
||||
|
||||
def test_close_rejects_stored_detail_sum_tampering(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
ok(service.create_ledger_category("Work", "Salary", cashflow_kind="income", db_path=path))
|
||||
ok(service.create_ledger_transaction("2026-09-10", "income", 50, "Work", "Salary", db_path=path))
|
||||
draft = new_draft(path)
|
||||
with sqlite3.connect(path) as db:
|
||||
assert db.execute("SELECT 1 FROM sqlite_master WHERE name='settlement_cashflow_snapshots'").fetchone(), "cashflow detail storage is required"
|
||||
db.execute("CREATE TRIGGER corrupt_saved_detail AFTER INSERT ON settlement_cashflow_snapshots BEGIN UPDATE settlement_cashflow_snapshots SET amount=amount+1 WHERE id=NEW.id; END")
|
||||
result = close_draft(path, draft)
|
||||
assert result.get("success") is False, result
|
||||
with sqlite3.connect(path) as db:
|
||||
for table in ("asset_snapshot_runs", "asset_snapshots", "settlement_cashflow_snapshots"):
|
||||
assert db.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] == 0
|
||||
assert db.execute("SELECT status FROM settlement_drafts WHERE id=?", (draft["id"],)).fetchone()[0] == "active"
|
||||
|
||||
|
||||
def test_replay_and_correction_keep_each_run_immutable(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
first = new_draft(path)
|
||||
initial = ok(close_draft(path, first, "first-close"))
|
||||
replay = ok(api("finalize_settlement_draft")(first["id"], first["version"], initial["review_fingerprint"], "first-close", db_path=path))
|
||||
assert replay == initial
|
||||
correction = ok(api("create_settlement_draft")("2026-09", "synthetic-operator", "correction", correction_of_run_id=initial["run_id"], correction_reason="synthetic correction", db_path=path))["draft"]
|
||||
corrected = ok(close_draft(path, correction, "correction-close"))
|
||||
assert corrected["run_id"] != initial["run_id"]
|
||||
old = ok(api("get_monthly_settlement_snapshot")(run_id=initial["run_id"], db_path=path))
|
||||
new = ok(api("get_monthly_settlement_snapshot")(run_id=corrected["run_id"], db_path=path))
|
||||
assert old["run_id"] == initial["run_id"] and new["run_id"] == corrected["run_id"]
|
||||
|
||||
|
||||
def test_concurrent_close_retries_return_one_saved_result(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
draft = new_draft(path)
|
||||
review_result = ok(api("review_settlement_draft")(draft["id"], db_path=path))
|
||||
review = review_result
|
||||
ok(api("approve_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "synthetic-reviewer", "approved", "ref", db_path=path))
|
||||
def close():
|
||||
return api("finalize_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "same-key", db_path=path)
|
||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||
results = list(pool.map(lambda _: close(), range(4)))
|
||||
successes = [r for r in results if r.get("success")]
|
||||
assert len(successes) == 4
|
||||
assert len({r["run_id"] for r in successes}) == 1
|
||||
|
||||
|
||||
def test_readonly_snapshot_missing_database_does_not_create_file(tmp_path):
|
||||
path = tmp_path / "missing.sqlite3"
|
||||
result = api("get_monthly_settlement_snapshot")(month="2026-09", db_path=str(path))
|
||||
assert result.get("success") is False
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
def test_saved_reader_has_six_totals_and_legacy_null_is_not_live_zero(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
# Empty database has no run; a saved reader must identify no snapshot, never fabricate current totals.
|
||||
result = api("get_monthly_settlement_snapshot")(month="2026-09", db_path=path)
|
||||
assert result.get("success") is False or result.get("source") == "settlement_snapshot"
|
||||
for key in ("income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth"):
|
||||
assert key not in result or result[key] is None
|
||||
|
||||
|
||||
def test_cashflow_and_legacy_api_shapes_remain_distinct(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
before = service.get_monthly_cashflow_report("2026-09", db_path=path)
|
||||
assert before.get("success") is True
|
||||
live_keys = set(before)
|
||||
draft = new_draft(path)
|
||||
review_result = ok(api("review_settlement_draft")(draft["id"], db_path=path))
|
||||
review = review_result
|
||||
assert {"income_total", "expense_total", "net_cashflow"} <= review.keys()
|
||||
after = service.get_monthly_cashflow_report("2026-09", db_path=path)
|
||||
assert set(after) == live_keys
|
||||
|
||||
|
||||
def test_policy_setter_persists_explicit_lists_and_changes_review_fingerprint(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
draft = new_draft(path)
|
||||
before = ok(api("review_settlement_draft")(draft["id"], db_path=path))["review_fingerprint"]
|
||||
policy = {"savings_categories":[{"category":"Savings","subcategory":"Reserve"}], "category_decisions":[]}
|
||||
changed = ok(api("set_settlement_cashflow_policy")(draft["id"], draft["version"], "synthetic-operator", policy, db_path=path))
|
||||
updated = changed["draft"]
|
||||
after = ok(api("review_settlement_draft")(draft["id"], db_path=path))["review_fingerprint"]
|
||||
assert updated["version"] == draft["version"] + 1
|
||||
assert before != after
|
||||
|
||||
|
||||
def test_completed_get_and_saved_snapshot_ignore_later_live_ledger_changes(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
draft = new_draft(path)
|
||||
saved = ok(close_draft(path, draft))
|
||||
ok(service.create_ledger_category("Later", "Income", cashflow_kind="income", db_path=path))
|
||||
ok(service.create_ledger_transaction("2026-09-20", "income", 99, "Later", "Income", db_path=path))
|
||||
history = ok(api("get_settlement_draft")(draft["id"], db_path=path))
|
||||
fetched = ok(api("get_monthly_settlement_snapshot")(run_id=saved["run_id"], db_path=path))
|
||||
assert history["final_response"] == saved
|
||||
assert fetched["income_total"] == saved["income_total"]
|
||||
|
||||
|
||||
def _historical_asset_review_fingerprint(path, draft, review):
|
||||
"""Exact f67 asset-only serial, with no new cashflow/policy/version inputs."""
|
||||
from jarca_ledger_mcp.application import settlement_draft_service as old
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
stored = old._items(conn, draft["id"])
|
||||
states = []
|
||||
for item in stored:
|
||||
candidates = [dict(v) for v in conn.execute("SELECT * FROM account_valuations WHERE account_id=? ORDER BY id", (item["source_id"],))] if item["source_type"] == "account" else []
|
||||
states.append({"stored": item, "current": old._current_semantics(conn, item), "candidates": candidates})
|
||||
serial = {"draft_id": draft["id"], "version": draft["version"], "basis_date": draft["basis_date"], "states": states,
|
||||
"warnings": [], "latest_run": old._latest_close(conn, draft["basis_date"]), "correction_parent": draft["correction_of_run_id"],
|
||||
"totals": {k: review[k] for k in ("total_assets", "total_liabilities", "net_worth")}}
|
||||
return hashlib.sha256(json.dumps(serial, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
|
||||
|
||||
|
||||
def test_legacy_asset_approval_requires_comprehensive_review_before_close(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
draft = new_draft(path)
|
||||
review = ok(service.review_settlement_draft(draft["id"], db_path=path))
|
||||
assert not review["warnings"], "this historical serial fixture is deliberately warning-free"
|
||||
historic = _historical_asset_review_fingerprint(path, draft, review)
|
||||
if "aggregation_version" not in review:
|
||||
assert historic == review["review_fingerprint"], "historical fixture must exactly match the original baseline approval"
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute("UPDATE settlement_drafts SET approval_fingerprint=?,approval_version=?,approved_by='synthetic-reviewer',approved_at='2026-09-30',approval_reason='historic asset review',approval_reference='historic-ref',approval_flags=? WHERE id=?",
|
||||
(historic, draft["version"], json.dumps({"allow_missing_exclusions": False, "allow_carried_before": False, "allow_manual_substitute": False}), draft["id"]))
|
||||
refused = service.finalize_settlement_draft(draft["id"], draft["version"], historic, "historic-close", db_path=path)
|
||||
assert refused["success"] is False, "an asset-only historical approval cannot authorize comprehensive cashflow interpretation"
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
|
||||
assert conn.execute("SELECT status,final_response FROM settlement_drafts WHERE id=?", (draft["id"],)).fetchone() == ("active", "")
|
||||
fresh = ok(service.review_settlement_draft(draft["id"], db_path=path))
|
||||
assert fresh["review_fingerprint"] != historic
|
||||
ok(service.approve_settlement_draft(draft["id"], draft["version"], fresh["review_fingerprint"], "synthetic-reviewer", "comprehensive reapproval", "new-ref", db_path=path))
|
||||
saved = ok(service.finalize_settlement_draft(draft["id"], draft["version"], fresh["review_fingerprint"], "fresh-close", db_path=path))
|
||||
ok(service.create_ledger_category("Later", "Income", cashflow_kind="income", db_path=path))
|
||||
ok(service.create_ledger_transaction("2026-09-20", "income", 99, "Later", "Income", db_path=path))
|
||||
assert ok(service.get_settlement_draft(draft["id"], db_path=path))["final_response"] == saved
|
||||
assert ok(service.finalize_settlement_draft(draft["id"], draft["version"], fresh["review_fingerprint"], "fresh-close", db_path=path)) == saved
|
||||
|
||||
|
||||
def _seed_reader_run(path, comprehensive=False, source=None):
|
||||
init_db(path)
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute("INSERT INTO asset_snapshot_runs(id,basis_date,close_status,created_at) VALUES ('synthetic-run','2026-09-30','complete','2026-09-30')")
|
||||
if comprehensive:
|
||||
conn.execute("UPDATE asset_snapshot_runs SET settlement_month='2026-09',currency='KRW',income_total=20,expense_total=0,net_cashflow=20,total_assets=0,total_liabilities=0,net_worth=0,cashflow_policy_json='{}',aggregation_version='comprehensive-v1',snapshot_schema_version=1,source_snapshot_json=? WHERE id='synthetic-run'", (json.dumps(source or {}),))
|
||||
conn.execute("INSERT INTO settlement_cashflow_snapshots(id,settlement_run_id,cashflow_kind,category,subcategory,amount,transaction_count,currency) VALUES ('synthetic-detail','synthetic-run','income','Salary','Monthly',20,1,'KRW')")
|
||||
|
||||
|
||||
def test_legacy_reader_preserves_unrecorded_currency_and_marks_legacy(tmp_path):
|
||||
from jarca_ledger_mcp.application.monthly_settlement_service import get_monthly_settlement_snapshot
|
||||
path = str(tmp_path / "reader-legacy-null.sqlite3")
|
||||
_seed_reader_run(path)
|
||||
result = ok(get_monthly_settlement_snapshot(run_id="synthetic-run", db_path=path))
|
||||
assert result["legacy"] is True and result["is_comprehensive"] is False
|
||||
assert result["currency"] is None, "legacy currency must not be invented as KRW"
|
||||
assert all(value is None for value in result["totals"].values())
|
||||
|
||||
|
||||
def test_legacy_reader_accepts_original_header_without_migration(tmp_path):
|
||||
from jarca_ledger_mcp.application.monthly_settlement_service import get_monthly_settlement_snapshot
|
||||
path = str(tmp_path / "reader-old-columns.sqlite3")
|
||||
_seed_reader_run(path)
|
||||
with sqlite3.connect(path) as conn:
|
||||
for column in ("settlement_month", "currency", "income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth", "cashflow_policy_json", "aggregation_version", "snapshot_schema_version", "source_snapshot_json"):
|
||||
conn.execute(f"ALTER TABLE asset_snapshot_runs DROP COLUMN {column}")
|
||||
for kwargs in ({"run_id": "synthetic-run"}, {"month": "2026-09"}):
|
||||
result = get_monthly_settlement_snapshot(db_path=path, **kwargs)
|
||||
assert result["success"], "pre-comprehensive headers must be read without new-column SQL"
|
||||
assert all(value is None for value in result["totals"].values())
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert "settlement_month" not in {row[1] for row in conn.execute("PRAGMA table_info(asset_snapshot_runs)")}
|
||||
|
||||
|
||||
def test_stored_reader_keeps_required_transaction_date(tmp_path):
|
||||
from jarca_ledger_mcp.application.monthly_settlement_service import get_monthly_settlement_snapshot
|
||||
path = str(tmp_path / "reader-source-date.sqlite3")
|
||||
tx = {"id": "synthetic-tx", "transaction_date": "2026-09-01", "type": "income", "amount": 20, "currency": "KRW", "category": "Salary", "subcategory": "Monthly", "cashflow_kind": "income", "applied_amount": 20}
|
||||
_seed_reader_run(path, True, {"transactions": [tx], "assets": []})
|
||||
result = ok(get_monthly_settlement_snapshot(run_id="synthetic-run", db_path=path))
|
||||
assert result["source_snapshot"]["transactions"] == [tx]
|
||||
|
||||
|
||||
def test_stored_reader_allowlists_source_root_and_nested_decision(tmp_path):
|
||||
from jarca_ledger_mcp.application.monthly_settlement_service import get_monthly_settlement_snapshot
|
||||
path = str(tmp_path / "reader-source-private.sqlite3")
|
||||
marker = "SYNTHETIC_PRIVATE_SNAPSHOT"
|
||||
tx = {"id": "synthetic-tx", "transaction_date": "2026-09-01", "type": "income", "amount": 20, "currency": "KRW", "category": "Salary", "subcategory": "Monthly", "cashflow_kind": "income", "applied_amount": 20, "decision": {"category": "Salary", "subcategory": "Monthly", "cashflow_kind": "income", "reason": "synthetic decision", "private_note": marker}}
|
||||
_seed_reader_run(path, True, {"transactions": [tx], "assets": [], "private_note": marker})
|
||||
result = ok(get_monthly_settlement_snapshot(run_id="synthetic-run", db_path=path))
|
||||
assert marker not in json.dumps(result), "unknown root and nested source fields are not public financial evidence"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target", ["header_total", "asset_amount", "asset_currency", "header_source", "header_policy", "header_version"])
|
||||
def test_close_rejects_persisted_header_and_asset_tampering(tmp_path, target):
|
||||
path = dbfile(tmp_path)
|
||||
ok(service.create_ledger_category("Salary", "Monthly", cashflow_kind="income", db_path=path))
|
||||
ok(service.create_ledger_transaction("2026-09-01", "income", 20, "Salary", "Monthly", db_path=path))
|
||||
draft = new_draft(path)
|
||||
review = ok(service.review_settlement_draft(draft["id"], db_path=path))
|
||||
ok(service.approve_settlement_draft(draft["id"], draft["version"], review["review_fingerprint"], "reviewer", "synthetic", "ref", db_path=path))
|
||||
if target.startswith("asset_"):
|
||||
column, expression = ("amount", "amount+1") if target == "asset_amount" else ("currency", "'USD'")
|
||||
trigger = f"CREATE TRIGGER synthetic_tamper AFTER INSERT ON asset_snapshots BEGIN UPDATE asset_snapshots SET {column}={expression} WHERE id=NEW.id; END"
|
||||
else:
|
||||
column, expression = {"header_total": ("income_total", "income_total+1"), "header_source": ("source_snapshot_json", "'{}'"), "header_policy": ("cashflow_policy_json", "'{\"savings_categories\":[{\"category\":\"Other\",\"subcategory\":\"Pair\"}],\"category_decisions\":[]}'"), "header_version": ("aggregation_version", "'unknown-version'")}[target]
|
||||
trigger = f"CREATE TRIGGER synthetic_tamper AFTER INSERT ON settlement_cashflow_snapshots BEGIN UPDATE asset_snapshot_runs SET {column}={expression} WHERE id=NEW.settlement_run_id; END"
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute(trigger)
|
||||
result = service.finalize_settlement_draft(draft["id"], draft["version"], review["review_fingerprint"], "tamper-close", db_path=path)
|
||||
assert result["success"] is False, "persisted header/detail must match the approved result before commit"
|
||||
with sqlite3.connect(path) as conn:
|
||||
for table in ("asset_snapshot_runs", "asset_snapshots", "settlement_cashflow_snapshots"):
|
||||
assert conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] == 0
|
||||
assert conn.execute("SELECT status,final_run_id,final_response FROM settlement_drafts WHERE id=?", (draft["id"],)).fetchone() == ("active", "", "")
|
||||
|
||||
|
||||
def test_stored_aggregation_version_drift_invalidates_approval(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
draft = new_draft(path)
|
||||
review = ok(service.review_settlement_draft(draft["id"], db_path=path))
|
||||
ok(service.approve_settlement_draft(draft["id"], draft["version"], review["review_fingerprint"], "reviewer", "synthetic", "ref", db_path=path))
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute("UPDATE settlement_drafts SET aggregation_version='unknown-version' WHERE id=?", (draft["id"],))
|
||||
result = service.finalize_settlement_draft(draft["id"], draft["version"], review["review_fingerprint"], "version-drift", db_path=path)
|
||||
assert result["success"] is False, "unknown stored logic version cannot silently use current aggregation"
|
||||
|
||||
|
||||
def test_completed_review_returns_saved_totals_and_fingerprint(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
draft = new_draft(path)
|
||||
saved = ok(close_draft(path, draft))
|
||||
ok(service.create_ledger_category("Later", "Income", cashflow_kind="income", db_path=path))
|
||||
ok(service.create_ledger_transaction("2026-09-20", "income", 99, "Later", "Income", db_path=path))
|
||||
review = ok(service.review_settlement_draft(draft["id"], db_path=path))
|
||||
assert review["totals"] == saved["totals"]
|
||||
assert review["review_fingerprint"] == saved["review_fingerprint"]
|
||||
|
||||
|
||||
def test_excluded_loan_source_has_no_applied_contribution(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
values = []
|
||||
for name, amount in (("Synthetic A", -900), ("Synthetic B", 200)):
|
||||
account = ok(service.create_ledger_account(name, type="loan", liquidity_bucket="loan", db_path=path))["account"]["id"]
|
||||
value = ok(service.register_account_valuation(account, "2026-09-30", amount, valuation_kind="loan_balance", representative_liability_definition="principal_only", verification_status="verified", idempotency_key=name, db_path=path))["valuation"]
|
||||
values.append((account, value["id"]))
|
||||
draft = new_draft(path)
|
||||
edited = ok(service.set_settlement_draft_item(draft["id"], "account", values[1][0], draft["version"], "operator", valuation_id=values[1][1], included=False, exclusion_reason="synthetic exclusion", db_path=path))
|
||||
review = edited["review"]
|
||||
assert review["total_liabilities"] == 900
|
||||
excluded = next(x for x in review["source_snapshot"]["assets"] if x["source_id"] == values[1][0])
|
||||
assert excluded["amount"] == 200 and excluded["applied_amount"] is None
|
||||
|
||||
|
||||
def test_cashflow_detail_currency_matches_header_without_losing_original(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
ok(service.create_ledger_category("Salary", "Monthly", cashflow_kind="income", db_path=path))
|
||||
tx = ok(service.create_ledger_transaction("2026-09-01", "income", 20, "Salary", "Monthly", db_path=path))["transaction"]
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute("UPDATE transactions SET currency='krw' WHERE id=?", (tx["id"],))
|
||||
saved = ok(close_draft(path, new_draft(path)))
|
||||
assert saved["cashflow_items"][0]["currency"] == "KRW"
|
||||
assert saved["source_snapshot"]["transactions"][0]["currency"] == "krw"
|
||||
|
||||
|
||||
def test_saved_reader_retains_asset_authority_and_liability_flags(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
saved = ok(close_draft(path, new_draft(path)))
|
||||
snapshot = ok(service.get_monthly_settlement_snapshot(run_id=saved["run_id"], db_path=path))
|
||||
asset = snapshot["source_snapshot"]["assets"][0]
|
||||
assert asset.get("authority_source") == "manual_assets"
|
||||
assert asset.get("is_liability") is False
|
||||
assert asset.get("status") == "ready"
|
||||
|
||||
|
||||
def test_saved_reader_projects_safe_header_exclusion_carry_and_approval_evidence(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
saved = ok(close_draft(path, new_draft(path)))
|
||||
excluded = [{"source_type":"account", "source_id":"missing-account-41", "reason":"missing_verified_exact_valuation",
|
||||
"valuation_date":"2026-09-27", "arbitrary_metadata":{"password":"DO-NOT-LEAK"}}]
|
||||
carried = [{"source_type":"account", "source_id":"carried-account-42", "reason":"as_of_carry_forward",
|
||||
"valuation_date":"2026-09-27", "arbitrary_metadata":{"token":"DO-NOT-LEAK"}}]
|
||||
approval = {"approved_by":"synthetic-reviewer", "approval_reason":"synthetic conditional approval",
|
||||
"approval_reference":"synthetic-ref", "allow_missing_exclusions":True,
|
||||
"allow_as_of_carry_forward":True, "private_note":"DO-NOT-LEAK"}
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute("UPDATE asset_snapshot_runs SET close_status='conditional',excluded_evidence=?,carried_evidence=?,approved_by=?,approval_reason=?,approval_reference=? WHERE id=?",
|
||||
(json.dumps(excluded), json.dumps(carried), approval["approved_by"], approval["approval_reason"], approval["approval_reference"], saved["run_id"]))
|
||||
conn.commit()
|
||||
before = list(conn.iterdump())
|
||||
exact = ok(service.get_monthly_settlement_snapshot(run_id=saved["run_id"], db_path=path))
|
||||
latest = ok(service.get_monthly_settlement_snapshot(month="2026-09", db_path=path))
|
||||
assert exact == latest
|
||||
assert exact["run_id"] == saved["run_id"] and exact["close_status"] == "conditional"
|
||||
assert exact["excluded_items"][0]["source_id"] == "missing-account-41"
|
||||
assert exact["excluded_items"][0]["reason"] == "missing_verified_exact_valuation"
|
||||
assert exact["carried_items"][0]["source_id"] == "carried-account-42"
|
||||
assert exact["carried_items"][0]["valuation_date"] == "2026-09-27"
|
||||
assert exact["applied_evidence"]
|
||||
assert exact["approval"]["approval_reason"] == "synthetic conditional approval"
|
||||
encoded = json.dumps(exact, sort_keys=True)
|
||||
assert "DO-NOT-LEAK" not in encoded and "private_note" not in encoded and "arbitrary_metadata" not in encoded
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert list(conn.iterdump()) == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize("schema_version,aggregation_version", [(1, None), (1, ""), (1, "unknown-version"), (2, "comprehensive-v1")])
|
||||
def test_saved_reader_rejects_unsupported_comprehensive_versions_without_writes(tmp_path, schema_version, aggregation_version):
|
||||
path = dbfile(tmp_path)
|
||||
saved = ok(close_draft(path, new_draft(path)))
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute("UPDATE asset_snapshot_runs SET snapshot_schema_version=?,aggregation_version=? WHERE id=?", (schema_version, aggregation_version, saved["run_id"]))
|
||||
conn.commit()
|
||||
before = list(conn.iterdump())
|
||||
for query in ({"run_id": saved["run_id"]}, {"month": "2026-09"}):
|
||||
result = service.get_monthly_settlement_snapshot(db_path=path, **query)
|
||||
assert result["success"] is False, "unsupported comprehensive logic/schema is not a recognized authoritative result"
|
||||
assert result["error_code"] == "db_error"
|
||||
assert "totals" not in result and "cashflow_items" not in result
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert list(conn.iterdump()) == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize("aggregation_version", ["unknown-version", "comprehensive-v1", ""])
|
||||
def test_saved_reader_does_not_mislabel_partial_version_metadata_as_legacy(tmp_path, aggregation_version):
|
||||
path = dbfile(tmp_path)
|
||||
saved = ok(close_draft(path, new_draft(path)))
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute("UPDATE asset_snapshot_runs SET snapshot_schema_version=NULL,aggregation_version=? WHERE id=?", (aggregation_version, saved["run_id"]))
|
||||
conn.commit()
|
||||
before = list(conn.iterdump())
|
||||
for query in ({"run_id": saved["run_id"]}, {"month": "2026-09"}):
|
||||
result = service.get_monthly_settlement_snapshot(db_path=path, **query)
|
||||
assert result["success"] is False, "partial version metadata is not a genuine NULL-version legacy run"
|
||||
assert result["error_code"] == "db_error"
|
||||
assert "totals" not in result
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert list(conn.iterdump()) == before
|
||||
conn.execute("UPDATE asset_snapshot_runs SET aggregation_version=NULL WHERE id=?", (saved["run_id"],))
|
||||
legacy = ok(service.get_monthly_settlement_snapshot(run_id=saved["run_id"], db_path=path))
|
||||
assert legacy["legacy"] and not legacy["is_comprehensive"]
|
||||
assert all(value is None for value in legacy["totals"].values())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def schema_thirteen_conditional_close(tmp_path):
|
||||
"""Execute immutable pre-comprehensive public APIs, without another checkout."""
|
||||
import importlib
|
||||
import importlib.abc
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
revision = "f67c07adf01d1f17415bf7cf836f46997ca6f21c"
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
namespace = "_legacy_schema_thirteen"
|
||||
names = subprocess.check_output(
|
||||
["git", "ls-tree", "-r", "--name-only", revision, "jarca_ledger_mcp"],
|
||||
cwd=root, text=True,
|
||||
).splitlines()
|
||||
blobs = {
|
||||
name: subprocess.check_output(["git", "show", f"{revision}:{name}"], cwd=root)
|
||||
for name in names if name.endswith(".py")
|
||||
}
|
||||
|
||||
class BaselineModules(importlib.abc.MetaPathFinder, importlib.abc.Loader):
|
||||
def find_spec(self, fullname, path=None, target=None):
|
||||
if fullname != namespace and not fullname.startswith(namespace + "."):
|
||||
return None
|
||||
relative = fullname[len(namespace):].replace(".", "/")
|
||||
candidate = "jarca_ledger_mcp" + relative
|
||||
package = candidate + "/__init__.py"
|
||||
module = candidate + ".py"
|
||||
if package in blobs:
|
||||
return importlib.util.spec_from_loader(fullname, self, is_package=True)
|
||||
if module in blobs:
|
||||
return importlib.util.spec_from_loader(fullname, self)
|
||||
return None
|
||||
|
||||
def create_module(self, spec):
|
||||
return None
|
||||
|
||||
def exec_module(self, module):
|
||||
relative = module.__name__[len(namespace):].replace(".", "/")
|
||||
candidate = "jarca_ledger_mcp" + relative
|
||||
filename = candidate + ("/__init__.py" if hasattr(module, "__path__") else ".py")
|
||||
module.__file__ = f"git:{revision}:{filename}"
|
||||
exec(compile(blobs[filename], module.__file__, "exec"), module.__dict__)
|
||||
|
||||
finder = BaselineModules()
|
||||
sys.meta_path.insert(0, finder)
|
||||
try:
|
||||
old = importlib.import_module(namespace + ".service")
|
||||
old_db = importlib.import_module(namespace + ".db")
|
||||
path = str(tmp_path / "baseline-schema-thirteen.sqlite3")
|
||||
old_db.init_db(path)
|
||||
missing = ok(old.create_ledger_account("Synthetic missing", "bank", liquidity_bucket="cash", db_path=path))["account"]["id"]
|
||||
carried = ok(old.create_ledger_account("Synthetic carry", "bank", liquidity_bucket="cash", db_path=path))["account"]["id"]
|
||||
ok(old.register_account_valuation(carried, "2026-09-27", 100, verification_status="verified", idempotency_key="baseline-carry", db_path=path))
|
||||
closed = ok(old.close_asset_snapshot(
|
||||
"2026-09-30", selection_policy="as_of", idempotency_key="baseline-close",
|
||||
allow_missing_exclusions=True, allow_as_of_carry_forward=True,
|
||||
approved_by="synthetic-reviewer", approval_reason="synthetic conditional approval",
|
||||
approval_reference="synthetic-ref", db_path=path,
|
||||
))
|
||||
original = ok(old.get_asset_snapshot_report("2026-09-30", db_path=path))
|
||||
yield path, missing, carried, closed, original
|
||||
finally:
|
||||
sys.meta_path.remove(finder)
|
||||
for name in list(sys.modules):
|
||||
if name == namespace or name.startswith(namespace + "."):
|
||||
del sys.modules[name]
|
||||
|
||||
|
||||
def test_schema_thirteen_saved_reader_retains_real_conditional_header_without_writes(schema_thirteen_conditional_close):
|
||||
path, missing, carried, closed, original = schema_thirteen_conditional_close
|
||||
init_db(path)
|
||||
assert ok(service.get_asset_snapshot_report("2026-09-30", db_path=path)) == original
|
||||
with sqlite3.connect(path) as conn:
|
||||
before = list(conn.iterdump())
|
||||
exact = ok(service.get_monthly_settlement_snapshot(run_id=closed["snapshot_run_id"], db_path=path))
|
||||
latest = ok(service.get_monthly_settlement_snapshot(month="2026-09", db_path=path))
|
||||
assert exact == latest
|
||||
assert exact["legacy"] and not exact["is_comprehensive"]
|
||||
assert all(value is None for value in exact["totals"].values())
|
||||
assert exact["close_status"] == "conditional"
|
||||
excluded = next(item for item in exact["excluded_items"] if item["source_id"] == missing)
|
||||
assert excluded["reason"]
|
||||
carry = next(item for item in exact["carried_items"] if item["source_id"] == carried)
|
||||
assert carry["valuation_date"] == "2026-09-27"
|
||||
assert exact["applied_evidence"]
|
||||
assert exact["approval"]["approved_by"] == "synthetic-reviewer"
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert list(conn.iterdump()) == before
|
||||
|
||||
|
||||
def test_saved_evidence_privacy_contract_filters_keys_not_allowlisted_scalar_values():
|
||||
from pathlib import Path
|
||||
from jarca_ledger_mcp.application.assets_service import _public_evidence
|
||||
value = {"candidate": {"name": "synthetic public scalar", "memo": "private", "source_attributes": {"reason": "synthetic reason", "private_key": "private"}}, "unrecognized": "private"}
|
||||
assert _public_evidence(value, strict=True) == {"candidate": {"name": "synthetic public scalar", "source_attributes": {"reason": "synthetic reason"}}}
|
||||
contract = (Path(__file__).resolve().parents[1] / "docs/interface-contract.md").read_text()
|
||||
assert "allowlisted scalar values are preserved" in contract
|
||||
assert "not a content-based secret redactor" in contract
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["excluded_evidence", "carried_evidence", "applied_evidence"])
|
||||
@pytest.mark.parametrize("corrupt", ["malformed_json", "wrong_json_object"])
|
||||
@pytest.mark.parametrize("lookup", ["exact", "latest"])
|
||||
def test_saved_reader_rejects_corrupt_header_evidence_without_writes(tmp_path, monkeypatch, field, corrupt, lookup):
|
||||
path = dbfile(tmp_path)
|
||||
saved = ok(close_draft(path, new_draft(path)))
|
||||
marker = "SYNTHETIC_CORRUPT_EVIDENCE_MARKER"
|
||||
payload = "{" + marker if corrupt == "malformed_json" else json.dumps({"private": marker})
|
||||
with sqlite3.connect(path) as conn:
|
||||
columns = {row[1] for row in conn.execute("PRAGMA table_info(asset_snapshot_runs)")}
|
||||
assert field in columns, f"required settlement evidence column missing: {field}"
|
||||
conn.execute(f"UPDATE asset_snapshot_runs SET {field}=? WHERE id=?", (payload, saved["run_id"]))
|
||||
conn.commit()
|
||||
before = list(conn.iterdump())
|
||||
|
||||
query = {"run_id": saved["run_id"]} if lookup == "exact" else {"month": "2026-09"}
|
||||
result = service.get_monthly_settlement_snapshot(db_path=path, **query)
|
||||
expected = {"success": False, "error_code": "data_integrity_error", "error": "snapshot evidence is malformed", "snapshot_run_id": saved["run_id"]}
|
||||
assert result == expected
|
||||
from jarca_ledger_mcp.adapters.streamlit import settlement_gateway
|
||||
gateway = settlement_gateway.get_monthly_settlement_snapshot(db_path=path, **query)
|
||||
assert gateway == result
|
||||
from jarca_ledger_mcp.adapters.mcp import server
|
||||
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", path)
|
||||
tool = server.mcp._tool_manager._tools["get_monthly_settlement_snapshot"]
|
||||
assert tool.fn(**query) == result
|
||||
assert service.get_asset_snapshot_report("2026-09-30", db_path=path) == expected
|
||||
assert marker not in json.dumps(result)
|
||||
assert not ({"run_id", "totals", "income_total", "source_snapshot", "cashflow_items", "excluded_items", "carried_items", "applied_evidence"} & result.keys())
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert list(conn.iterdump()) == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["excluded_evidence", "carried_evidence", "applied_evidence"])
|
||||
def test_saved_reader_accepts_empty_header_evidence_without_changing_legacy_api_shapes(tmp_path, field):
|
||||
path = dbfile(tmp_path)
|
||||
saved = ok(close_draft(path, new_draft(path)))
|
||||
with sqlite3.connect(path) as conn:
|
||||
columns = {row[1] for row in conn.execute("PRAGMA table_info(asset_snapshot_runs)")}
|
||||
assert field in columns, f"required settlement evidence column missing: {field}"
|
||||
conn.execute(f"UPDATE asset_snapshot_runs SET {field}='[]' WHERE id=?", (saved["run_id"],))
|
||||
conn.commit()
|
||||
with sqlite3.connect(path) as conn:
|
||||
before = list(conn.iterdump())
|
||||
exact = ok(service.get_monthly_settlement_snapshot(run_id=saved["run_id"], db_path=path))
|
||||
latest = ok(service.get_monthly_settlement_snapshot(month="2026-09", db_path=path))
|
||||
assert exact == latest
|
||||
public_field = {"excluded_evidence": "excluded_items", "carried_evidence": "carried_items", "applied_evidence": "applied_evidence"}[field]
|
||||
assert exact[public_field] == []
|
||||
assert exact["source"] == "settlement_snapshot" and exact["is_comprehensive"] is True
|
||||
cashflow = ok(service.get_monthly_cashflow_report("2026-09", db_path=path))
|
||||
assert "run_id" not in cashflow and "totals" not in cashflow and "cashflow_items" not in cashflow
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert list(conn.iterdump()) == before
|
||||
|
||||
|
||||
def test_schema_eleven_legacy_reader_returns_empty_evidence_without_writes(tmp_path):
|
||||
from test_legacy_snapshot_migration import _legacy_closed_db
|
||||
path, _ = _legacy_closed_db(tmp_path)
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
|
||||
before = list(conn.iterdump())
|
||||
exact = ok(service.get_monthly_settlement_snapshot(run_id="legacy-run-correction", db_path=path))
|
||||
latest = ok(service.get_monthly_settlement_snapshot(month="2026-06", db_path=path))
|
||||
# Headerless legacy correction metadata already differs by lookup mode;
|
||||
# this rework preserves that separate existing behavior rather than changing it.
|
||||
for result in (exact, latest):
|
||||
assert result["legacy"] is True and result["is_comprehensive"] is False
|
||||
assert result["source_snapshot"] == {}
|
||||
assert result["excluded_items"] == [] and result["carried_items"] == [] and result["applied_evidence"] == []
|
||||
assert all(value is None for value in result["totals"].values())
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert list(conn.iterdump()) == before
|
||||
@@ -0,0 +1,388 @@
|
||||
"""Issue 18 adapter/UI contracts; synthetic paths and identities only."""
|
||||
import importlib
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
|
||||
from jarca_ledger_mcp import service
|
||||
from test_comprehensive_settlement import schema_thirteen_conditional_close # noqa: F401
|
||||
|
||||
POLICY = "set_settlement_cashflow_policy"
|
||||
READER = "get_monthly_settlement_snapshot"
|
||||
|
||||
|
||||
def _api(name):
|
||||
fn = getattr(service, name, None)
|
||||
assert callable(fn), f"required comprehensive settlement API missing: service.{name}"
|
||||
return fn
|
||||
|
||||
|
||||
def test_shared_adapters_expose_policy_and_saved_snapshot_contracts(monkeypatch, tmp_path):
|
||||
gateway = importlib.import_module("jarca_ledger_mcp.adapters.streamlit.settlement_gateway")
|
||||
contracts = importlib.import_module("jarca_ledger_mcp.adapters.mcp.tool_contracts")
|
||||
server = importlib.import_module("jarca_ledger_mcp.adapters.mcp.server")
|
||||
setter_owner = importlib.import_module("jarca_ledger_mcp.application.settlement_draft_service")
|
||||
reader_owner = importlib.import_module("jarca_ledger_mcp.application.monthly_settlement_service")
|
||||
expected = {POLICY: ["draft_id", "expected_version", "updated_by", "cashflow_policy", "db_path"], READER: ["month", "run_id", "db_path"]}
|
||||
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(tmp_path / "synthetic.sqlite3"))
|
||||
calls = []
|
||||
for name, parameters in expected.items():
|
||||
fn = _api(name)
|
||||
assert list(inspect.signature(fn).parameters) == parameters
|
||||
gateway_fn = getattr(gateway, name, None)
|
||||
assert callable(gateway_fn), f"gateway missing {name}"
|
||||
assert inspect.signature(gateway_fn) == inspect.signature(fn)
|
||||
marker = {"success": True, "operation": name}
|
||||
def stub(*args, _name=name, _marker=marker, **kwargs):
|
||||
calls.append((_name, kwargs.get("db_path")))
|
||||
return _marker
|
||||
monkeypatch.setattr(setter_owner if name == POLICY else reader_owner, name, stub)
|
||||
kwargs = {key: "synthetic" for key in parameters if key != "db_path"}
|
||||
if name == POLICY:
|
||||
kwargs["expected_version"] = 1
|
||||
kwargs["cashflow_policy"] = {"savings_categories": [], "category_decisions": []}
|
||||
path = str(tmp_path / "synthetic.sqlite3")
|
||||
kwargs["db_path"] = path
|
||||
assert gateway_fn(**kwargs) == marker
|
||||
assert calls[-1] == (name, path)
|
||||
assert name in contracts.TOOL_CONTRACTS
|
||||
tools = getattr(server, "mcp", getattr(server, "server", None))._tool_manager._tools
|
||||
assert name in tools
|
||||
tool = tools[name]
|
||||
call = getattr(tool, "fn", getattr(tool, "function", None))
|
||||
assert callable(call)
|
||||
tool_kwargs = dict(kwargs)
|
||||
tool_kwargs.pop("db_path")
|
||||
boundary_calls = []
|
||||
def boundary(*args, **forwarded):
|
||||
bound = inspect.signature(fn).bind(*args, **forwarded)
|
||||
boundary_calls.append(dict(bound.arguments))
|
||||
return marker
|
||||
# Existing MCP contracts bind facade callables at import time. Test the
|
||||
# actual MCP/contract boundary, not a later owner-module monkeypatch.
|
||||
monkeypatch.setattr(contracts, name, boundary)
|
||||
assert call(**tool_kwargs) == marker
|
||||
assert boundary_calls[-1] == tool_kwargs
|
||||
schema = contracts.TOOL_CONTRACTS[name]
|
||||
assert callable(schema)
|
||||
tool_schema = tool.parameters if hasattr(tool, "parameters") else tool.inputSchema
|
||||
assert "db_path" not in str(tool_schema)
|
||||
|
||||
|
||||
def test_readonly_reader_on_missing_path_does_not_create_database(tmp_path):
|
||||
path = tmp_path / "absent.sqlite3"
|
||||
result = _api(READER)(month="2026-09", db_path=str(path))
|
||||
assert result.get("success") is False
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
def test_dashboard_exposes_additive_policy_and_saved_history_without_implicit_mutation():
|
||||
root = Path(__file__).parents[1]
|
||||
drafts = root / "dashboard/page_views/settlement_drafts.py"
|
||||
monthly = root / "dashboard/page_views/monthly_settlement.py"
|
||||
source = drafts.read_text(encoding="utf-8")
|
||||
monthly_source = monthly.read_text(encoding="utf-8")
|
||||
combined = source + "\n" + monthly_source
|
||||
assert "종합 월정산 정책" in combined
|
||||
assert "저장된 월정산 스냅샷" in combined
|
||||
assert POLICY in source or POLICY in monthly_source
|
||||
assert READER in source or READER in monthly_source
|
||||
assert "settlement_gateway" in combined
|
||||
assert "sqlite3.connect" not in combined and "CREATE TABLE" not in combined
|
||||
assert "form_submit_button" in source or "if st.button" in source
|
||||
|
||||
|
||||
def test_authenticated_dashboard_entrypoint_keeps_authentication_before_database_resolution():
|
||||
app = (Path(__file__).parents[1] / "dashboard/app.py").read_text(encoding="utf-8")
|
||||
assert app.index("require_dashboard_authentication()") < app.index("get_db_path()")
|
||||
|
||||
|
||||
def test_new_contracts_keep_asset_direct_close_signatures_and_live_report_api():
|
||||
assets = importlib.import_module("jarca_ledger_mcp.application.assets_service")
|
||||
report = importlib.import_module("jarca_ledger_mcp.application.transaction_report_services")
|
||||
direct = inspect.signature(assets.close_asset_snapshot)
|
||||
assert "cashflow_policy" not in direct.parameters
|
||||
assert list(inspect.signature(report.get_monthly_cashflow_report).parameters)[0] == "month"
|
||||
assert callable(_api(POLICY)) and callable(_api(READER))
|
||||
|
||||
|
||||
def _draft_page(monkeypatch, status="active"):
|
||||
from streamlit.testing.v1 import AppTest
|
||||
from jarca_ledger_mcp.adapters.streamlit import settlement_gateway
|
||||
policy = {"savings_categories": [{"category": "Savings", "subcategory": "Monthly"}], "category_decisions": []}
|
||||
draft = {"id": "synthetic-draft", "month": "2026-09", "basis_date": "2026-09-30", "status": status, "version": 1, "cashflow_policy": policy, "final_run_id": "synthetic-run"}
|
||||
final = {"income_total": 101, "expense_total": 0, "net_cashflow": 101, "total_assets": 0, "total_liabilities": 0, "net_worth": 0, "aggregation_version": "comprehensive-v1", "cashflow_items": [{"cashflow_kind": "income", "category": "Salary", "subcategory": "Monthly", "amount": 101, "transaction_count": 1, "currency": "KRW"}]}
|
||||
monkeypatch.setattr(settlement_gateway, "list_settlement_drafts", lambda *a, **k: {"success": True, "drafts": [draft]})
|
||||
monkeypatch.setattr(settlement_gateway, "get_settlement_draft", lambda *a, **k: {"success": True, "draft": draft, "items": [], "review": {"review_fingerprint": "synthetic-fp"}, "final_response": final if status == "completed" else None})
|
||||
page = AppTest.from_string("from dashboard.page_views.settlement_drafts import render_settlement_drafts\nrender_settlement_drafts(db_path='synthetic-unused.sqlite3', month='2026-09')", default_timeout=30).run()
|
||||
assert not page.exception
|
||||
return page, policy
|
||||
|
||||
|
||||
def test_draft_policy_editor_uses_saved_draft_policy(monkeypatch):
|
||||
import json
|
||||
page, policy = _draft_page(monkeypatch)
|
||||
assert json.loads(page.text_area[0].value) == policy, "rendering must not replace persisted policy with an empty default"
|
||||
|
||||
|
||||
def test_completed_draft_page_displays_saved_response_and_cashflow_kind(monkeypatch):
|
||||
page, _ = _draft_page(monkeypatch, "completed")
|
||||
assert len(page.dataframe) == 1, "completed get exposes saved final_response at the response root"
|
||||
frame = page.dataframe[0].value
|
||||
assert "cashflow_kind" in frame.columns
|
||||
assert frame.iloc[0]["cashflow_kind"] == "income"
|
||||
assert sum(item.value == "### 불변 이력" for item in page.markdown) == 1
|
||||
|
||||
|
||||
# Real transport/UI acceptance coverage. All identities and ledger values are synthetic.
|
||||
def test_real_comprehensive_stdio_policy_close_and_saved_snapshot(tmp_path):
|
||||
import asyncio, json, os, sqlite3, sys
|
||||
from contextlib import closing
|
||||
from mcp import ClientSession, StdioServerParameters
|
||||
from mcp.client.stdio import stdio_client
|
||||
from jarca_ledger_mcp.db import init_db
|
||||
|
||||
db = tmp_path / "stdio-comprehensive.sqlite3"
|
||||
init_db(str(db))
|
||||
category = service.create_ledger_category("Synthetic salary", "Monthly", cashflow_kind="income", db_path=str(db))
|
||||
assert category["success"]
|
||||
assert service.create_ledger_transaction("2026-09-05", "income", 20, "Synthetic salary", "Monthly", db_path=str(db))["success"]
|
||||
assert service.create_ledger_category("Synthetic food", "Meal", cashflow_kind="expense", db_path=str(db))["success"]
|
||||
assert service.create_ledger_transaction("2026-09-06", "expense", 10, "Synthetic food", "Meal", db_path=str(db))["success"]
|
||||
assert service.create_ledger_category("Synthetic refund", "Meal", cashflow_kind="expense", db_path=str(db))["success"]
|
||||
assert service.create_ledger_transaction("2026-09-07", "refund", 3, "Synthetic refund", "Meal", db_path=str(db))["success"]
|
||||
assert service.create_ledger_category("Synthetic savings", "Reserve", cashflow_kind="investment", db_path=str(db))["success"]
|
||||
assert service.create_ledger_transaction("2026-09-08", "expense", 5, "Synthetic savings", "Reserve", db_path=str(db))["success"]
|
||||
acct = service.create_ledger_account("Synthetic checking", last_balance=100, liquidity_bucket="cash", db_path=str(db))["account"]["id"]
|
||||
assert service.register_account_valuation(acct, "2026-09-30", 100, verification_status="verified", idempotency_key="stdio-valuation", db_path=str(db))["success"]
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
# Deliberately construct a minimal environment; no caller auth/Gitea variables survive.
|
||||
env = {k: os.environ[k] for k in ("PATH", "HOME", "LANG") if k in os.environ}
|
||||
env.update(JARCA_LEDGER_DB_PATH=str(db), JARCA_MCP_TRANSPORT="stdio", PYTHONDONTWRITEBYTECODE="1")
|
||||
params = StdioServerParameters(command=sys.executable, args=["-B", "-m", "jarca_ledger_mcp.server"], env=env, cwd=str(root))
|
||||
|
||||
async def exercise():
|
||||
async with stdio_client(params) as (read, write):
|
||||
async with ClientSession(read, write) as session:
|
||||
await session.initialize()
|
||||
tools = {t.name: t for t in (await session.list_tools()).tools}
|
||||
for name in (POLICY, READER):
|
||||
assert name in tools
|
||||
schema = tools[name].inputSchema
|
||||
assert "db_path" not in schema.get("properties", {})
|
||||
assert set(schema["properties"]) == set(inspect.signature(getattr(service, name)).parameters) - {"db_path"}
|
||||
async def call(name, args):
|
||||
result = await session.call_tool(name, args)
|
||||
assert not result.isError
|
||||
return json.loads(result.content[0].text)
|
||||
made = await call("create_settlement_draft", {"month":"2026-09", "created_by":"synthetic", "idempotency_key":"stdio-draft"})
|
||||
draft = made["draft"]
|
||||
policy = {"savings_categories":[{"category":"Synthetic savings", "subcategory":"Reserve"}], "category_decisions":[]}
|
||||
changed = await call(POLICY, {"draft_id":draft["id"], "expected_version":draft["version"], "updated_by":"synthetic", "cashflow_policy":policy})
|
||||
draft = changed["draft"]
|
||||
assert draft["version"] == 2 and draft["cashflow_policy"] == policy
|
||||
review = await call("review_settlement_draft", {"draft_id":draft["id"]})
|
||||
for key in ("income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth"):
|
||||
assert key in review
|
||||
assert review["cashflow_items"] and all("cashflow_kind" in row for row in review["cashflow_items"])
|
||||
assert any(t.get("applied_amount") is not None for t in review.get("source_snapshot", {}).get("transactions", []))
|
||||
approved = await call("approve_settlement_draft", {"draft_id":draft["id"], "expected_version":draft["version"], "review_fingerprint":review["review_fingerprint"], "approved_by":"synthetic", "approval_reason":"acceptance", "approval_reference":"synthetic-ref"})
|
||||
closed = await call("finalize_settlement_draft", {"draft_id":draft["id"], "expected_version":approved["draft"]["version"], "review_fingerprint":review["review_fingerprint"], "idempotency_key":"stdio-close"})
|
||||
run = closed["run_id"]
|
||||
exact = await call(READER, {"run_id":run})
|
||||
monthly = await call(READER, {"month":"2026-09"})
|
||||
assert exact["run_id"] == run and monthly["run_id"] == run
|
||||
before = (await call("get_settlement_draft", {"draft_id":draft["id"]}))
|
||||
assert await call("finalize_settlement_draft", {"draft_id":draft["id"], "expected_version":approved["draft"]["version"], "review_fingerprint":review["review_fingerprint"], "idempotency_key":"stdio-close"}) == closed
|
||||
assert (await call("get_settlement_draft", {"draft_id":draft["id"]})) == before
|
||||
assert await call(READER, {"run_id":run}) == exact
|
||||
# Later ledger writes must not rewrite either saved surface.
|
||||
service.create_ledger_category("Later", "Synthetic", cashflow_kind="income", db_path=str(db))
|
||||
service.create_ledger_transaction("2026-09-20", "income", 99, "Later", "Synthetic", db_path=str(db))
|
||||
assert await call(READER, {"run_id":run}) == exact
|
||||
assert (await call("get_settlement_draft", {"draft_id":draft["id"]})) == before
|
||||
return run
|
||||
run = asyncio.run(asyncio.wait_for(exercise(), timeout=60))
|
||||
with closing(sqlite3.connect(db)) as conn:
|
||||
columns = {r[1] for r in conn.execute("PRAGMA table_info(asset_snapshot_runs)")}
|
||||
assert {"income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth", "cashflow_policy_json", "aggregation_version", "snapshot_schema_version", "source_snapshot_json"} <= columns
|
||||
assert conn.execute("SELECT COUNT(*) FROM settlement_cashflow_snapshots WHERE settlement_run_id=?", (run,)).fetchone()[0] > 0
|
||||
|
||||
|
||||
def test_authenticated_dashboard_renders_saved_comprehensive_snapshot(tmp_path, monkeypatch):
|
||||
import sqlite3
|
||||
from streamlit.testing.v1 import AppTest
|
||||
from dashboard import authentication
|
||||
from jarca_ledger_mcp.db import init_db
|
||||
db = tmp_path / "dashboard-comprehensive.sqlite3"
|
||||
init_db(str(db))
|
||||
assert service.create_ledger_category("Synthetic salary", "Monthly", cashflow_kind="income", db_path=str(db))["success"]
|
||||
assert service.create_ledger_transaction("2026-09-05", "income", 20, "Synthetic salary", "Monthly", db_path=str(db))["success"]
|
||||
asset = service.create_manual_asset("Synthetic cash", 100, "2026-09-30", "cash", db_path=str(db))
|
||||
assert asset["success"]
|
||||
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(db))
|
||||
monkeypatch.setenv(authentication.ADMIN_ID_ENV, "test-admin")
|
||||
monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, "test-password")
|
||||
monkeypatch.setenv(authentication.SIGNING_SECRET_ENV, "s" * 64)
|
||||
settings = authentication.load_auth_settings()
|
||||
assert settings is not None
|
||||
token = authentication.issue_persistent_token(settings, remember_me=False).value
|
||||
monkeypatch.setattr(authentication, "_request_cookie", lambda: token)
|
||||
draft = service.create_settlement_draft("2026-09", "synthetic", "dashboard-draft", db_path=str(db))["draft"]
|
||||
policy = {"savings_categories":[{"category":"Synthetic savings", "subcategory":"Reserve"}], "category_decisions":[]}
|
||||
changed = service.set_settlement_cashflow_policy(draft["id"], draft["version"], "synthetic", policy, db_path=str(db))["draft"]
|
||||
reviewed = service.review_settlement_draft(draft["id"], db_path=str(db))
|
||||
assert reviewed["success"]
|
||||
assert service.approve_settlement_draft(draft["id"], changed["version"], reviewed["review_fingerprint"], "synthetic", "acceptance", "synthetic-ref", db_path=str(db))["success"]
|
||||
saved = service.finalize_settlement_draft(draft["id"], changed["version"], reviewed["review_fingerprint"], "dashboard-close", db_path=str(db))
|
||||
assert saved["success"]
|
||||
with sqlite3.connect(db) as conn:
|
||||
before = conn.execute("SELECT name, sql FROM sqlite_master ORDER BY type,name").fetchall()
|
||||
before_rows = conn.execute("SELECT * FROM asset_snapshot_runs ORDER BY id").fetchall()
|
||||
before_detail = conn.execute("SELECT * FROM settlement_cashflow_snapshots ORDER BY id").fetchall()
|
||||
app = AppTest.from_file(str(Path(__file__).resolve().parents[1] / "dashboard" / "app.py"), default_timeout=30)
|
||||
app.query_params["page"] = "월정산"
|
||||
app.query_params["month"] = "2026-09"
|
||||
app.run()
|
||||
assert not app.exception, app.exception
|
||||
body = "\n".join(str(x.value) for x in list(app.text) + list(app.markdown) + list(app.caption))
|
||||
assert "저장된 월정산 스냅샷" in body and f"run {saved['run_id']}" in body
|
||||
assert any(metric.label == "자산 합계" for metric in app.metric)
|
||||
frames = [item.value for item in app.dataframe]
|
||||
assert any("cashflow_kind" in frame.columns and "income" in set(frame["cashflow_kind"]) for frame in frames)
|
||||
with sqlite3.connect(db) as conn:
|
||||
assert conn.execute("SELECT name, sql FROM sqlite_master ORDER BY type,name").fetchall() == before
|
||||
assert conn.execute("SELECT * FROM asset_snapshot_runs ORDER BY id").fetchall() == before_rows
|
||||
assert conn.execute("SELECT * FROM settlement_cashflow_snapshots ORDER BY id").fetchall() == before_detail
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_saved_monthly_panel_renders_real_conditional_exclusion_carry_and_approval(schema_thirteen_conditional_close, monkeypatch): # noqa: F811 - imported pytest fixture
|
||||
import json
|
||||
from streamlit.testing.v1 import AppTest
|
||||
from jarca_ledger_mcp.db import init_db
|
||||
from jarca_ledger_mcp.adapters.streamlit import overview_gateway, reconciliation_gateway, settlement_gateway
|
||||
|
||||
path, missing, carried, closed, _ = schema_thirteen_conditional_close
|
||||
# Migrate the authentic schema-13 baseline in place, then exercise the real
|
||||
# service through its public Streamlit gateway; no saved response is mocked.
|
||||
init_db(path)
|
||||
snapshot = settlement_gateway.get_monthly_settlement_snapshot(run_id=closed["snapshot_run_id"], db_path=path)
|
||||
assert snapshot["success"]
|
||||
excluded = next(row for row in snapshot["excluded_items"] if row["source_id"] == missing)
|
||||
carry = next(row for row in snapshot["carried_items"] if row["source_id"] == carried)
|
||||
approval = snapshot["approval"]
|
||||
assert approval
|
||||
|
||||
monkeypatch.setattr(overview_gateway, "get_monthly_settlement_summary", lambda *a, **k: {"success": True, "month": "2026-09", "reports": {}})
|
||||
monkeypatch.setattr(overview_gateway, "list_cross_transfer_review_queue", lambda *a, **k: {"success": True, "items": []})
|
||||
monkeypatch.setattr(overview_gateway, "list_ledger_import_batches", lambda *a, **k: {"success": True, "import_batches": []})
|
||||
monkeypatch.setattr(overview_gateway, "get_asset_snapshot_report", lambda *a, **k: {"success": False})
|
||||
monkeypatch.setattr(reconciliation_gateway, "get_review_operating_indicators", lambda *a, **k: {"success": True, "indicators": {}})
|
||||
monkeypatch.setattr(reconciliation_gateway, "get_balance_reconciliation_report", lambda *a, **k: {"success": True, "items": []})
|
||||
monkeypatch.setattr(settlement_gateway, "list_settlement_drafts", lambda *a, **k: {"success": True, "drafts": []})
|
||||
app = AppTest.from_string(
|
||||
f"from dashboard.page_views.monthly_settlement import page_monthly_settlement\n"
|
||||
f"page_monthly_settlement({path!r})", default_timeout=30).run()
|
||||
assert not app.exception, f"saved panel AppTest failed before field assertions: {app.exception}"
|
||||
body = "\\n".join(str(x.value) for x in list(app.caption) + list(app.markdown) + list(app.text) + list(app.warning) + list(app.info))
|
||||
body += "\\n" + json.dumps([frame.value.to_dict("records") for frame in app.dataframe], ensure_ascii=False, default=str)
|
||||
assert missing in body and excluded["reason"] in body, "saved panel must render excluded_items source_id and reason"
|
||||
assert carried in body and carry["valuation_date"] in body, "saved panel must render carried_items source_id and historical valuation_date"
|
||||
for key in ("approved_by", "approval_reason", "approval_reference"):
|
||||
assert approval[key] in body, f"saved panel must render approval.{key} from the saved response"
|
||||
|
||||
|
||||
def test_draft_cashflow_blocker_renders_safe_transaction_id_and_reason(monkeypatch):
|
||||
from streamlit.testing.v1 import AppTest
|
||||
from jarca_ledger_mcp.adapters.streamlit import settlement_gateway
|
||||
|
||||
# Public review contract: cashflow_blockers is a list of safe dictionaries;
|
||||
# these fields are emitted by the service, not renderer-invented `code`.
|
||||
draft = {"id": "red-draft", "month": "2026-09", "basis_date": "2026-09-30",
|
||||
"status": "active", "version": 1, "cashflow_policy": {"savings_categories": [], "category_decisions": []}}
|
||||
blocker = {"transaction_id": "txn-safe-17", "reason": "unknown cashflow classification requires an explicit decision"}
|
||||
monkeypatch.setattr(settlement_gateway, "list_settlement_drafts",
|
||||
lambda *a, **k: {"success": True, "drafts": [draft]})
|
||||
monkeypatch.setattr(settlement_gateway, "get_settlement_draft",
|
||||
lambda *a, **k: {"success": True, "draft": draft, "items": [], "review": {"review_fingerprint": "fp"}})
|
||||
monkeypatch.setattr(settlement_gateway, "review_settlement_draft",
|
||||
lambda *a, **k: {"success": True, "version": 1, "review_fingerprint": "fp",
|
||||
"cashflow_blockers": [blocker], "cashflow_items": [], "missing_items": []})
|
||||
app = AppTest.from_string(
|
||||
"import streamlit as st\n"
|
||||
"from dashboard.page_views.settlement_drafts import render_settlement_drafts\n"
|
||||
"render_settlement_drafts(db_path='synthetic-unused.sqlite3', month='2026-09')",
|
||||
default_timeout=30,
|
||||
)
|
||||
app.run()
|
||||
assert not app.exception, f"synthetic renderer fixture failed before assertion: {app.exception}"
|
||||
next(button for button in app.button if button.label == "검토 / 새로고침 대상").click().run()
|
||||
assert not app.exception
|
||||
assert next(button for button in app.button if button.label == "검토 결과 승인").disabled
|
||||
shown = "\n".join(str(x.value) for x in list(app.warning) + list(app.error) + list(app.caption))
|
||||
assert "txn-safe-17" in shown and blocker["reason"] in shown, (
|
||||
"public renderer must show safe transaction_id and reason from each blocker; "
|
||||
"a failure here is the renderer dropping the declared public fields"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("comprehensive", [False, True])
|
||||
def test_saved_monthly_snapshot_renders_close_legacy_and_asset_evidence(monkeypatch, comprehensive):
|
||||
from streamlit.testing.v1 import AppTest
|
||||
from jarca_ledger_mcp.adapters.streamlit import overview_gateway, reconciliation_gateway, settlement_gateway
|
||||
|
||||
# Mirrors get_monthly_settlement_snapshot's declared response envelope/keys.
|
||||
# Legacy/partial record is read-only; excluded assets carry reason in `items`.
|
||||
snapshot = {"source": "settlement_snapshot", "run_id": "saved-run-9", "snapshot_run_id": "saved-run-9",
|
||||
"settlement_month": "2026-09", "basis_date": "2026-09-30", "is_closed_snapshot": True,
|
||||
"is_comprehensive": False, "legacy": True, "aggregation_version": None,
|
||||
"snapshot_schema_version": None, "close_status": "legacy_read_only", "cashflow_items": [],
|
||||
"items": [{"source_type": "manual_asset", "source_id": "asset-opaque-3", "name": "Synthetic asset",
|
||||
"exclusion_reason": "valuation_missing", "verification_status": "unverified"}],
|
||||
"source_snapshot": {"transactions": [], "assets": []}, "totals": {},
|
||||
"income_total": None, "expense_total": None, "net_cashflow": None,
|
||||
"total_assets": None, "total_liabilities": None, "net_worth": None,
|
||||
"correction": {"is_correction": False, "correction_of_run_id": "", "correction_reason": ""}}
|
||||
if comprehensive:
|
||||
snapshot.update(is_comprehensive=True, legacy=False, aggregation_version="comprehensive-v1",
|
||||
snapshot_schema_version=1, close_status="conditional")
|
||||
for key in ("income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth"):
|
||||
snapshot[key] = 0
|
||||
snapshot["totals"] = {key:snapshot[key] for key in ("income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth")}
|
||||
snapshot["source_snapshot"]["assets"] = [
|
||||
{"source_type":"account", "source_id":"missing-source-7", "status":"missing",
|
||||
"amount":None, "applied_amount":None, "valuation_id":None,
|
||||
"verification_status":"unverified", "exclusion_reason":""},
|
||||
{"source_type":"account", "source_id":"excluded-source-8", "status":"excluded",
|
||||
"amount":10, "applied_amount":None, "exclusion_reason":"synthetic exclusion",
|
||||
"unexpected_evidence":"not-public-evidence"},
|
||||
]
|
||||
monkeypatch.setattr(overview_gateway, "get_monthly_settlement_summary", lambda *a, **k: {"success": True, "month": "2026-09", "reports": {}})
|
||||
monkeypatch.setattr(overview_gateway, "list_cross_transfer_review_queue", lambda *a, **k: {"success": True, "items": []})
|
||||
monkeypatch.setattr(overview_gateway, "list_ledger_import_batches", lambda *a, **k: {"success": True, "import_batches": []})
|
||||
monkeypatch.setattr(overview_gateway, "get_asset_snapshot_report", lambda *a, **k: {"success": False})
|
||||
monkeypatch.setattr(reconciliation_gateway, "get_review_operating_indicators", lambda *a, **k: {"success": True, "indicators": {}})
|
||||
monkeypatch.setattr(reconciliation_gateway, "get_balance_reconciliation_report", lambda *a, **k: {"success": True, "items": []})
|
||||
monkeypatch.setattr(settlement_gateway, "get_monthly_settlement_snapshot", lambda *a, **k: {"success": True, **snapshot})
|
||||
monkeypatch.setattr(settlement_gateway, "list_settlement_drafts", lambda *a, **k: {"success": True, "drafts": []})
|
||||
app = AppTest.from_string(
|
||||
"from dashboard.page_views.monthly_settlement import page_monthly_settlement\n"
|
||||
"page_monthly_settlement('synthetic-unused.sqlite3')", default_timeout=30)
|
||||
app.run()
|
||||
assert not app.exception, f"synthetic renderer fixture failed before assertion: {app.exception}"
|
||||
import json
|
||||
body = "\n".join(str(x.value) for x in list(app.caption) + list(app.markdown) + list(app.text) + list(app.warning) + list(app.info))
|
||||
body += "\n" + json.dumps([frame.value.to_dict("records") for frame in app.dataframe], ensure_ascii=False, default=str)
|
||||
assert snapshot["close_status"] in body and (comprehensive or "legacy" in body.lower()), (
|
||||
"saved snapshot renderer must expose close_status and legacy/partial provenance"
|
||||
)
|
||||
assert "asset-opaque-3" in body and "valuation_missing" in body and "제외" in body, (
|
||||
"saved snapshot renderer must expose each missing/excluded asset status and reason"
|
||||
)
|
||||
if comprehensive:
|
||||
assert "missing-source-7" in body and "missing" in body and "누락" in body
|
||||
assert "excluded-source-8" in body and "synthetic exclusion" in body
|
||||
assert "not-public-evidence" not in body
|
||||
assert not app.exception
|
||||
@@ -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] == "14"
|
||||
|
||||
|
||||
def test_snapshot_close_idempotency_fingerprint_and_concurrent_first_close(tmp_path):
|
||||
|
||||
@@ -6,7 +6,7 @@ from jarca_ledger_mcp import service
|
||||
def test_ledger_category_create_list_delete_reactivate(tmp_path):
|
||||
db_path = str(tmp_path / "ledger.sqlite3")
|
||||
health = service.health_check(db_path)
|
||||
assert health["schema_version"] == "12"
|
||||
assert health["schema_version"] == "14"
|
||||
|
||||
created = service.create_ledger_category("반려동물", "사료", sort_order=250, memo="test", db_path=db_path)
|
||||
assert created["success"] is True
|
||||
|
||||
@@ -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] == '14'
|
||||
|
||||
|
||||
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
|
||||
@@ -17,35 +17,43 @@ def test_requirement_docs_are_canonical_and_traceable() -> None:
|
||||
text = REQ.read_text(encoding="utf-8")
|
||||
_, raw, body = text.split("---", 2)
|
||||
metadata = json.loads(raw)
|
||||
assert metadata["implementation_pr"] == "https://gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp/pulls/19"
|
||||
assert metadata == {
|
||||
"schema": "gitea-agent.requirement/v1",
|
||||
"id": "REQ-003",
|
||||
"title": "계좌 기준일 평가 이력과 자산 마감 연동",
|
||||
"revision": "1.0",
|
||||
"revision": "1.2",
|
||||
"status": "approved",
|
||||
"depends_on": [],
|
||||
"applies_to": ["ledger", "dashboard", "mcp"],
|
||||
"language": "ko",
|
||||
"issue": "#12",
|
||||
"implementation_pr": "#13",
|
||||
"issue": "#18",
|
||||
"implementation_pr": "https://gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp/pulls/19",
|
||||
}
|
||||
assert re.search(r"^# REQ-003 — ", body, re.M)
|
||||
assert "${" not in text
|
||||
requirements = re.findall(r"^### REQ-003\.(\d+) —", body, re.M)
|
||||
assert requirements == [f"{n:02d}" for n in range(1, 11)]
|
||||
acs = re.findall(r"^### REQ-003\.\d+-AC(\d+) —", body, re.M)
|
||||
assert len(acs) == 10
|
||||
assert body.count("**영향 범위**") == 10
|
||||
assert requirements == [f"{n:02d}" for n in range(1, 12)]
|
||||
acs = re.findall(r"^### (REQ-003\.\d+-AC\d+) —", body, re.M)
|
||||
expected_acs = {f"REQ-003.{n:02d}-AC01" for n in range(1, 11)} | {f"REQ-003.11-AC{n:02d}" for n in range(1, 28)}
|
||||
assert len(acs) == len(expected_acs)
|
||||
assert len(set(acs)) == len(acs)
|
||||
assert set(acs) == expected_acs
|
||||
baseline_acs = re.findall(r"^### REQ-003\.(0[1-9]|10)-AC01 —", body, re.M)
|
||||
assert baseline_acs == [f"{n:02d}" for n in range(1, 11)]
|
||||
assert body.count("**영향 범위**") == 11 # Impact is per requirement, not per AC.
|
||||
assert body.count("**Cases (정상/경계/실패)**") == 10
|
||||
assert body.count("**State Invariants**") == 10
|
||||
assert body.count("**판정 기준**") == 10
|
||||
assert body.count("**검증**") == 10
|
||||
assert body.count("**판정 기준**") == 37
|
||||
assert body.count("**검증**") == 37
|
||||
classification = re.findall(
|
||||
r"^\*\*검증 분류\*\*: level=(unit|integration|static); backend=(sqlite|application|dashboard)$",
|
||||
body,
|
||||
re.M,
|
||||
)
|
||||
assert len(classification) == 10
|
||||
assert body.count("- 수준: integration") == 27
|
||||
assert len(re.findall(r"^- 백엔드: (sqlite|application|dashboard)$", body, re.M)) == 27
|
||||
assert {level for level, _ in classification} >= {"unit", "integration", "static"}
|
||||
assert {backend for _, backend in classification} >= {"sqlite", "application", "dashboard"}
|
||||
for marker in (
|
||||
@@ -142,7 +150,7 @@ def test_req003_has_exact_canonical_numbered_sections() -> None:
|
||||
headings = re.findall(r"^## (\d+)\. ", body, re.M)
|
||||
assert headings == [str(n) for n in range(1, 12)]
|
||||
assert not re.search(r"^## (?:8|9)\.(?: D| C)", body, re.M)
|
||||
assert body.count("### REQ-003.") == 20
|
||||
assert body.count("### REQ-003.") == 48
|
||||
assert "### REQ-003.01-AC01" in body
|
||||
assert "### REQ-003.10-AC01" in body
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ def test_review_decision_and_reconciliation_schema_are_additive_idempotent(tmp_p
|
||||
assert status["integrity_check"] == "ok"
|
||||
assert status["table_counts"]["review_decisions"] == 0
|
||||
assert status["table_counts"]["reconciliation_sessions"] == 0
|
||||
assert service.health_check(db_path)["schema_version"] == "12"
|
||||
assert service.health_check(db_path)["schema_version"] == "14"
|
||||
|
||||
|
||||
def test_review_decision_round_trip_payload_sanitizes_sensitive_fields(tmp_path: Path) -> None:
|
||||
|
||||
@@ -10,7 +10,7 @@ def test_health_check_initializes_db(tmp_path):
|
||||
db_path = tmp_path / "ledger.sqlite3"
|
||||
result = service.health_check(str(db_path))
|
||||
assert result["success"] is True
|
||||
assert result["schema_version"] == "12"
|
||||
assert result["schema_version"] == "14"
|
||||
assert db_path.exists()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
"""Cashflow policy lifecycle and explicit-classification contract tests."""
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
from jarca_ledger_mcp import service
|
||||
from jarca_ledger_mcp.db import init_db
|
||||
|
||||
|
||||
def _capability(name):
|
||||
candidate = getattr(service, name, None)
|
||||
assert callable(candidate), f"required public service capability {name} is absent"
|
||||
return candidate
|
||||
|
||||
|
||||
def _ok(result):
|
||||
assert result["success"], result
|
||||
return result
|
||||
|
||||
|
||||
def _draft(path, month="2026-09"):
|
||||
return _ok(service.create_settlement_draft(month, "synthetic creator", "synthetic-key", db_path=path))["draft"]
|
||||
|
||||
|
||||
def _policy(decisions=None):
|
||||
return {"savings_categories": [{"category": "Savings", "subcategory": "Monthly"}], "category_decisions": decisions or []}
|
||||
|
||||
|
||||
def _add_category(name, subcategory, kind, path):
|
||||
return _ok(service.create_ledger_category(name, subcategory, cashflow_kind=kind, db_path=path))
|
||||
|
||||
|
||||
def _add_tx(day, typ, amount, category, subcategory, path):
|
||||
result = service.create_ledger_transaction(day, typ, amount, category, subcategory, db_path=path)
|
||||
assert result["success"], result
|
||||
assert isinstance(result["transaction"], dict)
|
||||
return result["transaction"]["id"]
|
||||
|
||||
|
||||
def test_explicit_savings_refund_and_movement_precedence(tmp_path):
|
||||
path = str(tmp_path / "policy-precedence.sqlite3")
|
||||
init_db(path)
|
||||
draft = _draft(path)
|
||||
setter = _capability("set_settlement_cashflow_policy")
|
||||
_capability("get_monthly_settlement_snapshot")
|
||||
_add_category("Savings", "Monthly", "investment", path)
|
||||
_add_category("Savings", "Fund", "investment", path)
|
||||
_add_category("Movement", "Transfer", "transfer", path)
|
||||
_add_category("Voucher", "Load", "expense", path)
|
||||
_add_category("Voucher", "Consumption", "expense", path)
|
||||
_add_category("Loan", "Debt payment", "debt_payment", path)
|
||||
_add_category("Food", "Meals", "expense", path)
|
||||
ids = {
|
||||
"save": _add_tx("2026-09-01", "expense", 100, "Savings", "Monthly", path),
|
||||
"unlisted_investment": _add_tx("2026-09-02", "expense", 50, "Savings", "Fund", path),
|
||||
"transfer": _add_tx("2026-09-03", "transfer", 200, "Movement", "Transfer", path),
|
||||
"voucher_load": _add_tx("2026-09-04", "expense", 300, "Voucher", "Load", path),
|
||||
"voucher_consumption": _add_tx("2026-09-05", "expense", 30, "Voucher", "Consumption", path),
|
||||
"debt": _add_tx("2026-09-06", "expense", 40, "Loan", "Debt payment", path),
|
||||
"food": _add_tx("2026-09-07", "expense", 80, "Food", "Meals", path),
|
||||
"refund": _add_tx("2026-09-30", "refund", 20, "Food", "Meals", path),
|
||||
}
|
||||
policy = _policy([{"category": "Voucher", "subcategory": "Load", "cashflow_kind": "excluded", "reason": "synthetic asset movement"}])
|
||||
changed = _ok(setter(draft["id"], draft["version"], "synthetic operator", policy, db_path=path))
|
||||
saved = _ok(service.get_settlement_draft(draft["id"], db_path=path))["draft"]
|
||||
assert saved["cashflow_policy"] == policy and changed["draft"]["version"] == draft["version"] + 1
|
||||
reviewed = _ok(service.review_settlement_draft(draft["id"], db_path=path))
|
||||
assert "cashflow_items" in reviewed
|
||||
grouped = {(x["cashflow_kind"], x["category"], x["subcategory"]): x for x in reviewed["cashflow_items"]}
|
||||
assert grouped[("expense", "Savings", "Monthly")]["amount"] == 100
|
||||
assert grouped[("expense", "Savings", "Monthly")]["transaction_count"] == 1
|
||||
assert grouped[("excluded", "Voucher", "Load")]["transaction_count"] == 1
|
||||
assert grouped[("expense", "Voucher", "Consumption")]["amount"] == 30
|
||||
assert grouped[("expense", "Loan", "Debt payment")]["amount"] == 40
|
||||
assert grouped[("expense", "Food", "Meals")]["amount"] == 60
|
||||
assert not reviewed["cashflow_blockers"]
|
||||
source = reviewed["source_snapshot"]["transactions"]
|
||||
by_id = {x["id"]: x for x in source}
|
||||
for txid in ids.values():
|
||||
assert {"id", "transaction_date", "type", "amount", "currency", "category", "subcategory", "cashflow_kind", "applied_amount"} <= by_id[txid].keys()
|
||||
assert by_id[ids["refund"]]["amount"] == 20
|
||||
assert by_id[ids["refund"]]["applied_amount"] == -20
|
||||
assert by_id[ids["transfer"]]["cashflow_kind"] == "excluded"
|
||||
assert by_id[ids["unlisted_investment"]]["cashflow_kind"] == "excluded"
|
||||
assert reviewed["expense_total"] == 230
|
||||
|
||||
|
||||
def test_unknown_classification_blocks_and_explicit_exclusion_unblocks(tmp_path):
|
||||
path = str(tmp_path / "unknown-blocker.sqlite3")
|
||||
init_db(path)
|
||||
draft = _draft(path)
|
||||
_add_category("Mystery", "Other", "unknown", path)
|
||||
_add_tx("2026-09-14", "expense", 60, "Mystery", "Other", path)
|
||||
reviewed = _ok(service.review_settlement_draft(draft["id"], db_path=path))
|
||||
assert reviewed["cashflow_blockers"]
|
||||
assert not service.approve_settlement_draft(draft["id"], draft["version"], reviewed["review_fingerprint"], "reviewer", "synthetic", "ref", db_path=path, allow_missing_exclusions=True)["success"]
|
||||
explicit = _policy([{"category": "Mystery", "subcategory": "Other", "cashflow_kind": "excluded", "reason": "synthetic operator exclusion"}])
|
||||
edited = _ok(_capability("set_settlement_cashflow_policy")(draft["id"], draft["version"], "operator", explicit, db_path=path))["draft"]
|
||||
reviewed = _ok(service.review_settlement_draft(draft["id"], db_path=path))
|
||||
assert not reviewed["cashflow_blockers"]
|
||||
assert reviewed["version"] == edited["version"]
|
||||
assert service.approve_settlement_draft(draft["id"], edited["version"], reviewed["review_fingerprint"], "reviewer", "synthetic", "ref", db_path=path)["success"]
|
||||
|
||||
|
||||
def test_policy_edit_persists_versions_and_invalidates_approval(tmp_path):
|
||||
path = str(tmp_path / "policy-approval.sqlite3")
|
||||
init_db(path)
|
||||
_ok(service.create_manual_asset("Synthetic cash", 100, "2026-09-30", "cash", db_path=path))
|
||||
draft = _draft(path)
|
||||
reviewed = _ok(service.review_settlement_draft(draft["id"], db_path=path))
|
||||
assert not reviewed["cashflow_blockers"]
|
||||
_ok(service.approve_settlement_draft(draft["id"], draft["version"], reviewed["review_fingerprint"], "reviewer", "synthetic", "ref", db_path=path))
|
||||
result = _ok(_capability("set_settlement_cashflow_policy")(draft["id"], draft["version"], "editor", _policy(), db_path=path))
|
||||
saved = _ok(service.get_settlement_draft(draft["id"], db_path=path))["draft"]
|
||||
assert saved["cashflow_policy"] == _policy()
|
||||
assert result["draft"]["version"] == draft["version"] + 1
|
||||
assert saved["approved_by"] == ""
|
||||
|
||||
|
||||
def test_invalid_policy_payload_is_rejected_without_write(tmp_path):
|
||||
path = str(tmp_path / "policy-invalid.sqlite3")
|
||||
init_db(path)
|
||||
draft = _draft(path)
|
||||
before = _ok(service.get_settlement_draft(draft["id"], db_path=path))
|
||||
invalid = {"savings_categories": [{"category": "Savings"}], "category_decisions": []}
|
||||
result = _capability("set_settlement_cashflow_policy")(draft["id"], draft["version"], "operator", invalid, db_path=path)
|
||||
assert not result["success"]
|
||||
assert _ok(service.get_settlement_draft(draft["id"], db_path=path)) == before
|
||||
|
||||
|
||||
def test_review_contract_and_stored_reader_never_recomputes_live_preview(tmp_path):
|
||||
path = str(tmp_path / "stored-reader.sqlite3")
|
||||
init_db(path)
|
||||
reader = _capability("get_monthly_settlement_snapshot")
|
||||
empty = reader(month="2026-09", db_path=path)
|
||||
assert empty["success"] is False and empty["error_code"] == "not_found"
|
||||
_ok(service.create_manual_asset("Synthetic cash", 100, "2026-09-30", "cash", db_path=path))
|
||||
draft = _draft(path)
|
||||
reviewed = _ok(service.review_settlement_draft(draft["id"], db_path=path))
|
||||
assert "review_fingerprint" in reviewed and "review" not in reviewed
|
||||
approved = _ok(service.approve_settlement_draft(draft["id"], draft["version"], reviewed["review_fingerprint"], "reviewer", "synthetic", "ref", db_path=path))
|
||||
final = _ok(service.finalize_settlement_draft(draft["id"], approved["draft"]["version"], reviewed["review_fingerprint"], "close-key", db_path=path))
|
||||
assert "final_response" not in final and "snapshot_run_id" in final
|
||||
snapshot = _ok(reader(run_id=final["snapshot_run_id"], db_path=path))
|
||||
assert snapshot["is_closed_snapshot"] is True
|
||||
assert snapshot["snapshot_run_id"] == final["snapshot_run_id"]
|
||||
assert snapshot["total_assets"] == 100
|
||||
assert _ok(reader(month="2026-09", db_path=path))["snapshot_run_id"] == final["snapshot_run_id"]
|
||||
_ok(service.create_manual_asset("Later live cash", 900, "2026-09-30", "cash", db_path=path))
|
||||
persisted = _ok(reader(run_id=final["snapshot_run_id"], db_path=path))
|
||||
assert persisted["total_assets"] == 100 and persisted["snapshot_run_id"] == final["snapshot_run_id"]
|
||||
assert "live_authority" not in persisted.values()
|
||||
|
||||
|
||||
def test_month_boundaries_refund_month_negative_total_and_fx_blocker(tmp_path):
|
||||
path = str(tmp_path / "month-boundaries.sqlite3")
|
||||
init_db(path)
|
||||
_add_category("Food", "Meals", "expense", path)
|
||||
_add_tx("2026-08-31", "expense", 10, "Food", "Meals", path)
|
||||
_add_tx("2026-09-01", "expense", 5, "Food", "Meals", path)
|
||||
_add_tx("2026-10-01", "expense", 7, "Food", "Meals", path)
|
||||
_add_tx("2026-09-15", "refund", 20, "Food", "Meals", path)
|
||||
_add_tx("2026-09-16", "expense", 3, "Food", "Meals", path)
|
||||
d = _draft(path)
|
||||
reviewed = _ok(service.review_settlement_draft(d["id"], db_path=path))
|
||||
food = next(x for x in reviewed["cashflow_items"] if x["cashflow_kind"] == "expense" and x["category"] == "Food")
|
||||
assert food["amount"] == -12
|
||||
assert reviewed["expense_total"] == -12
|
||||
assert reviewed["cashflow_blockers"] == []
|
||||
_add_category("Foreign", "Purchase", "expense", path)
|
||||
_add_tx("2026-09-20", "expense", 12, "Foreign", "Purchase", path)
|
||||
with sqlite3.connect(path) as db:
|
||||
db.execute("UPDATE transactions SET currency='USD' WHERE category='Foreign'")
|
||||
reviewed = _ok(service.review_settlement_draft(d["id"], db_path=path))
|
||||
assert reviewed["cashflow_blockers"]
|
||||
assert any("fx" in json.dumps(x).lower() or "currency" in json.dumps(x).lower() for x in reviewed["cashflow_blockers"])
|
||||
assert reviewed["expense_total"] == -12
|
||||
|
||||
|
||||
def test_known_refund_only_category_is_retained_at_zero(tmp_path):
|
||||
path = str(tmp_path / "zero-refund.sqlite3")
|
||||
init_db(path)
|
||||
_add_category("Food", "Meals", "expense", path)
|
||||
_add_tx("2026-09-01", "expense", 20, "Food", "Meals", path)
|
||||
_add_tx("2026-09-02", "refund", 20, "Food", "Meals", path)
|
||||
reviewed = _ok(service.review_settlement_draft(_draft(path)["id"], db_path=path))
|
||||
food = next(x for x in reviewed["cashflow_items"] if x["cashflow_kind"] == "expense" and x["category"] == "Food")
|
||||
assert food["amount"] == 0 and food["transaction_count"] == 2
|
||||
|
||||
|
||||
def test_policy_capabilities_are_checked_at_call_time(tmp_path):
|
||||
path = str(tmp_path / "future-capabilities.sqlite3")
|
||||
init_db(path)
|
||||
assert callable(_capability("set_settlement_cashflow_policy"))
|
||||
assert callable(_capability("get_monthly_settlement_snapshot"))
|
||||
assert _capability("get_monthly_settlement_snapshot")(month="", run_id="", db_path=path)["error_code"] == "not_found"
|
||||
|
||||
|
||||
def _private_cashflow_review(path, policy=None):
|
||||
from jarca_ledger_mcp.application.settlement_cashflow_service import _review_cashflow
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
return _review_cashflow(conn, "2026-09", policy or {})
|
||||
|
||||
|
||||
def test_cashflow_group_field_matches_persisted_detail_contract(tmp_path):
|
||||
path = str(tmp_path / "group-field.sqlite3")
|
||||
init_db(path)
|
||||
_add_category("Food", "Meals", "expense", path)
|
||||
_add_tx("2026-09-01", "expense", 20, "Food", "Meals", path)
|
||||
result = _private_cashflow_review(path)
|
||||
assert len(result["cashflow_items"]) == 1
|
||||
assert "cashflow_kind" in result["cashflow_items"][0], "group shape must use the persisted/public cashflow_kind field"
|
||||
|
||||
|
||||
def test_refund_reduces_debt_payment_and_selected_savings(tmp_path):
|
||||
for kind, category, subcategory in [("debt_payment", "Loan", "Payment"), ("investment", "Savings", "Monthly")]:
|
||||
path = str(tmp_path / (kind + "-refund.sqlite3"))
|
||||
init_db(path)
|
||||
_add_category(category, subcategory, kind, path)
|
||||
txid = _add_tx("2026-09-01", "refund", 20, category, subcategory, path)
|
||||
result = _private_cashflow_review(path, _policy())
|
||||
assert not result["cashflow_blockers"]
|
||||
assert result["expense_total"] == -20, "refund must reduce expense even for debt payment or selected savings"
|
||||
assert result["source_snapshot"]["transactions"][0]["id"] == txid
|
||||
assert result["source_snapshot"]["transactions"][0]["applied_amount"] == -20
|
||||
|
||||
|
||||
def test_unknown_cashflow_source_is_not_recorded_as_excluded(tmp_path):
|
||||
path = str(tmp_path / "unknown-source.sqlite3")
|
||||
init_db(path)
|
||||
_add_category("Mystery", "Other", "unknown", path)
|
||||
_add_tx("2026-09-01", "expense", 20, "Mystery", "Other", path)
|
||||
result = _private_cashflow_review(path)
|
||||
assert result["cashflow_blockers"] and not result["cashflow_items"]
|
||||
assert result["source_snapshot"]["transactions"][0]["cashflow_kind"] != "excluded", "unknown is not an authorized exclusion"
|
||||
|
||||
|
||||
def test_nonstring_cashflow_decision_kind_has_validation_error():
|
||||
from jarca_ledger_mcp.application.settlement_cashflow_service import _normalize_cashflow_policy
|
||||
malformed = {"category_decisions": [{"category": "Food", "subcategory": "Meals", "cashflow_kind": [], "reason": "synthetic"}]}
|
||||
error = None
|
||||
try:
|
||||
_normalize_cashflow_policy(malformed)
|
||||
except Exception as caught:
|
||||
error = caught
|
||||
assert isinstance(error, ValueError), "malformed kind must have the validator's ValueError contract, not an unhandled TypeError"
|
||||
|
||||
|
||||
def test_inactive_category_uses_raw_fallback_through_public_close(tmp_path):
|
||||
path = str(tmp_path / "inactive-category-fallback.sqlite3")
|
||||
init_db(path)
|
||||
# A close run requires a nonempty asset valuation; cashflow-only drafts cannot finalize.
|
||||
_ok(service.create_manual_asset("Synthetic close cash", 100, "2026-09-30", "cash", db_path=path))
|
||||
cases = [
|
||||
# SQL fallback is category-based: ordinary legacy categories become expense,
|
||||
# while canonical legacy categories retain their established classifications.
|
||||
("Old exclude", "A", "exclude", "expense", 11, "expense"),
|
||||
("Old income", "B", "income", "expense", 13, "expense"),
|
||||
("Old transfer", "C", "transfer", "expense", 17, "expense"),
|
||||
("Old unknown", "D", "unknown", "income", 19, "expense"),
|
||||
("Old refund", "E", "exclude", "refund", 5, "expense"),
|
||||
("Old movement", "F", "exclude", "transfer", 7, "excluded"),
|
||||
("수입", "Legacy", "exclude", "income", 29, "income"),
|
||||
("Old expense", "H", "expense", "expense", 37, "expense"),
|
||||
("Old investment", "G", "investment", "expense", 31, "expense"),
|
||||
]
|
||||
ids = {}
|
||||
for index, (category, subcategory, kind, raw_type, amount, expected) in enumerate(cases):
|
||||
_add_category(category, subcategory, kind, path)
|
||||
txid = _add_tx(f"2026-09-{index + 1:02d}", raw_type, amount, category, subcategory, path)
|
||||
assert service.delete_ledger_category(category, subcategory, force=True, db_path=path)["success"]
|
||||
ids[txid] = (expected, kind, raw_type, amount)
|
||||
draft = _draft(path)
|
||||
reviewed = _ok(service.review_settlement_draft(draft["id"], db_path=path))
|
||||
by_id = {row["id"]: row for row in reviewed["source_snapshot"]["transactions"]}
|
||||
for txid, (expected, _kind, raw_type, amount) in ids.items():
|
||||
row = by_id[txid]
|
||||
applied = -amount if raw_type == "refund" else amount
|
||||
assert row["cashflow_kind"] == expected
|
||||
assert row["originalcashflow_kind"] == ("income" if row["category"] == "수입" else ("unknown" if row["category"] == "미분류" else "expense"))
|
||||
assert row["applied_amount"] == applied # excluded movement retains its amount as evidence
|
||||
assert row["sourceprovenance"] == "fallback"
|
||||
assert row["category_cashflow_kind"] is None and row["category_active"] is None
|
||||
assert reviewed["expense_total"] == 123 # 11 + 13 + 17 + 19 - 5 + 31 + 37
|
||||
assert reviewed["income_total"] == 29
|
||||
approved = _ok(service.approve_settlement_draft(draft["id"], draft["version"], reviewed["review_fingerprint"], "reviewer", "synthetic", "ref", db_path=path))
|
||||
closed = _ok(service.finalize_settlement_draft(draft["id"], approved["draft"]["version"], reviewed["review_fingerprint"], "inactive-category-close", db_path=path))
|
||||
snapshot = _ok(service.get_monthly_settlement_snapshot(run_id=closed["snapshot_run_id"], db_path=path))
|
||||
persisted = {row["id"]: row for row in snapshot["source_snapshot"]["transactions"]}
|
||||
for txid in ids:
|
||||
assert persisted[txid]["sourceprovenance"] == "fallback"
|
||||
assert persisted[txid]["category_active"] is None
|
||||
assert persisted[txid]["category_cashflow_kind"] is None
|
||||
assert snapshot["expense_total"] == 123 and snapshot["income_total"] == 29
|
||||
|
||||
|
||||
def test_explicit_decision_classifies_only_unresolved_inactive_fallback(tmp_path):
|
||||
path = str(tmp_path / "inactive-explicit-decision.sqlite3")
|
||||
init_db(path)
|
||||
_add_category("미분류", "Retired", "unknown", path)
|
||||
unresolved = _add_tx("2026-09-10", "expense", 23, "미분류", "Retired", path)
|
||||
assert service.delete_ledger_category("미분류", "Retired", force=True, db_path=path)["success"]
|
||||
draft = _draft(path)
|
||||
policy = _policy([{"category": "미분류", "subcategory": "Retired", "cashflow_kind": "income", "reason": "synthetic explicit decision"}])
|
||||
_ok(_capability("set_settlement_cashflow_policy")(draft["id"], draft["version"], "operator", policy, db_path=path))
|
||||
reviewed = _ok(service.review_settlement_draft(draft["id"], db_path=path))
|
||||
row = next(x for x in reviewed["source_snapshot"]["transactions"] if x["id"] == unresolved)
|
||||
assert row["cashflow_kind"] == "income" and row["decision"]["cashflow_kind"] == "income"
|
||||
assert row["originalcashflow_kind"] == "unknown" and row["sourceprovenance"] == "fallback"
|
||||
assert reviewed["income_total"] == 23
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Cashflow schema and legacy migration contract regressions."""
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
from jarca_ledger_mcp.db import init_db
|
||||
|
||||
|
||||
def _schema(path):
|
||||
with sqlite3.connect(path) as db:
|
||||
tables = {r[0] for r in db.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||
assert {"settlement_cashflow_snapshots", "asset_snapshot_runs"} <= tables
|
||||
header = {r[1] for r in db.execute("PRAGMA table_info(asset_snapshot_runs)")}
|
||||
detail = {r[1] for r in db.execute("PRAGMA table_info(settlement_cashflow_snapshots)")}
|
||||
assert {"settlement_month", "currency", "income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth", "cashflow_policy_json", "aggregation_version", "snapshot_schema_version", "source_snapshot_json"} <= header
|
||||
assert {"id", "settlement_run_id", "cashflow_kind", "category", "subcategory", "amount", "transaction_count", "currency"} <= detail
|
||||
|
||||
|
||||
def test_cashflow_detail_constraints_preserve_blank_categories(tmp_path):
|
||||
path = str(tmp_path / "detail-constraints.sqlite3")
|
||||
init_db(path)
|
||||
_schema(path)
|
||||
with sqlite3.connect(path) as db:
|
||||
db.execute("PRAGMA foreign_keys=ON")
|
||||
db.execute("INSERT INTO asset_snapshot_runs(id,basis_date,close_status,created_at) VALUES('run-a','2026-09-30','complete_exact','now')")
|
||||
insert = "INSERT INTO settlement_cashflow_snapshots(id,settlement_run_id,cashflow_kind,category,subcategory,amount,transaction_count,currency) VALUES(?,?,?,?,?,?,?,?)"
|
||||
good = ("d1", "run-a", "expense", "", "", -7, 1, "KRW")
|
||||
db.execute(insert, good)
|
||||
for values in [("d2", "missing", "expense", "x", "y", 1, 1, "KRW"), ("d3", "run-a", "other", "x", "y", 1, 1, "KRW"), ("d4", "run-a", "expense", "", "", 7, 1, "KRW"), ("d5", "run-a", "expense", "", "", None, 1, "KRW"), ("d6", "run-a", "expense", "", "", 1, 0, "KRW"), ("d7", "run-a", "expense", None, "", 1, 1, "KRW"), ("d8", "run-a", "expense", "", None, 1, 1, "KRW")]:
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
db.execute(insert, values)
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
db.execute(insert, ("d9", "run-a", "expense", "", "", -7, 1, "KRW"))
|
||||
db.execute(insert, ("d10", "run-a", "income", "", "", 2, 1, "KRW"))
|
||||
indexes = {r[1]: r[2] for r in db.execute("PRAGMA index_list(settlement_cashflow_snapshots)")}
|
||||
unique_fields = [tuple(r[2] for r in db.execute(f"PRAGMA index_info('{name}')")) for name, unique in indexes.items() if unique]
|
||||
assert any(x == ("settlement_run_id", "cashflow_kind", "category", "subcategory", "currency") for x in unique_fields)
|
||||
assert db.execute("SELECT amount,category,subcategory FROM settlement_cashflow_snapshots WHERE id='d1'").fetchone() == (-7, "", "")
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
db.execute("DELETE FROM asset_snapshot_runs WHERE id='run-a'")
|
||||
assert db.execute("SELECT COUNT(*) FROM settlement_cashflow_snapshots WHERE settlement_run_id='run-a'").fetchone()[0] == 2
|
||||
|
||||
|
||||
def test_legacy_migration_is_idempotent_and_keeps_unstored_totals_null(tmp_path):
|
||||
"""Schema-13 fixture uses the frozen production header and asset DDL."""
|
||||
path = str(tmp_path / "schema13.sqlite3")
|
||||
with sqlite3.connect(path) as db:
|
||||
db.executescript("""
|
||||
CREATE TABLE schema_meta(key TEXT PRIMARY KEY,value TEXT NOT NULL);
|
||||
INSERT INTO schema_meta VALUES('schema_version','13');
|
||||
CREATE TABLE asset_snapshots (
|
||||
id TEXT PRIMARY KEY, basis_date TEXT NOT NULL, source_type TEXT NOT NULL,
|
||||
source_id TEXT NOT NULL DEFAULT '', name TEXT NOT NULL, amount INTEGER,
|
||||
currency TEXT NOT NULL DEFAULT 'KRW', liquidity_bucket TEXT NOT NULL DEFAULT 'other',
|
||||
valuation_source TEXT NOT NULL DEFAULT '', source_note TEXT NOT NULL DEFAULT '',
|
||||
memo TEXT NOT NULL DEFAULT '', snapshot_run_id TEXT NOT NULL DEFAULT '',
|
||||
correction_of_run_id TEXT NOT NULL DEFAULT '', correction_reason TEXT NOT NULL DEFAULT '',
|
||||
is_correction INTEGER NOT NULL DEFAULT 0, closed_at TEXT NOT NULL DEFAULT '',
|
||||
valuation_id TEXT NOT NULL DEFAULT '', selection_policy TEXT NOT NULL DEFAULT 'exact',
|
||||
verification_status TEXT NOT NULL DEFAULT 'unverified', evidence_kind TEXT NOT NULL DEFAULT 'applied',
|
||||
exclusion_reason TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE asset_snapshot_runs (
|
||||
id TEXT PRIMARY KEY, basis_date TEXT NOT NULL, close_status TEXT NOT NULL,
|
||||
selection_policy TEXT NOT NULL DEFAULT 'exact', idempotency_key TEXT NOT NULL DEFAULT '',
|
||||
approved_by TEXT NOT NULL DEFAULT '', approved_at TEXT NOT NULL DEFAULT '',
|
||||
approval_reason TEXT NOT NULL DEFAULT '', approval_reference TEXT NOT NULL DEFAULT '',
|
||||
correction_of_run_id TEXT NOT NULL DEFAULT '', correction_reason TEXT NOT NULL DEFAULT '',
|
||||
applied_evidence TEXT NOT NULL DEFAULT '[]', excluded_evidence TEXT NOT NULL DEFAULT '[]',
|
||||
carried_evidence TEXT NOT NULL DEFAULT '[]', request_fingerprint TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL, draft_id TEXT NOT NULL DEFAULT '', draft_version INTEGER NOT NULL DEFAULT 0,
|
||||
review_fingerprint TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
INSERT INTO asset_snapshot_runs(id,basis_date,close_status,created_at) VALUES('legacy-run','2026-09-30','complete_exact','then');
|
||||
INSERT INTO asset_snapshots(id,basis_date,source_type,source_id,name,amount,created_at,snapshot_run_id) VALUES('legacy-row','2026-09-30','manual_asset','synthetic','Synthetic',42,'then','legacy-run');
|
||||
""")
|
||||
init_db(path)
|
||||
_schema(path)
|
||||
with sqlite3.connect(path) as db:
|
||||
columns = {r[1] for r in db.execute("PRAGMA table_info(asset_snapshot_runs)")}
|
||||
assert "draft_id" in columns and "income_total" in columns
|
||||
first = db.execute("SELECT id,basis_date,close_status,created_at,income_total,expense_total,net_cashflow,total_assets,total_liabilities,net_worth FROM asset_snapshot_runs WHERE id='legacy-run'").fetchone()
|
||||
assert first[:4] == ("legacy-run", "2026-09-30", "complete_exact", "then")
|
||||
assert first[4:] == (None, None, None, None, None, None)
|
||||
old = db.execute("SELECT id,basis_date,source_type,source_id,name,amount,snapshot_run_id FROM asset_snapshots").fetchall()
|
||||
init_db(path)
|
||||
with sqlite3.connect(path) as db:
|
||||
assert db.execute("SELECT id,basis_date,close_status,created_at,income_total,expense_total,net_cashflow,total_assets,total_liabilities,net_worth FROM asset_snapshot_runs WHERE id='legacy-run'").fetchone() == first
|
||||
assert db.execute("SELECT id,basis_date,source_type,source_id,name,amount,snapshot_run_id FROM asset_snapshots").fetchall() == old
|
||||
assert db.execute("SELECT COUNT(*) FROM settlement_cashflow_snapshots").fetchone()[0] == 0
|
||||
@@ -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, 'income_total': 0, 'expense_total': 0, 'net_cashflow': 0}
|
||||
assert ok(s.get_settlement_draft(draft['id'], db_path=path)) == result
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert conn.execute('SELECT COUNT(*) FROM settlement_drafts').fetchone()[0] == 1
|
||||
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']
|
||||
@@ -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,187 @@
|
||||
"""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 = ok(s.review_settlement_draft(d['id'], db_path=path))
|
||||
assert review['totals']['total_assets'] == 100
|
||||
assert not review['complete'] and review['cashflow_blockers']
|
||||
blocked = s.approve_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'synthetic reviewer', 'reason', 'ref', db_path=path)
|
||||
assert not blocked['success']
|
||||
with sqlite3.connect(path) as c:
|
||||
assert c.execute('SELECT COUNT(*) FROM asset_snapshot_runs').fetchone()[0] == 0
|
||||
policy = {'savings_categories': [], 'category_decisions': [{'category': '미분류', 'subcategory': '', 'cashflow_kind': 'excluded', 'reason': 'synthetic explicit exclusion'}]}
|
||||
updated = s.set_settlement_cashflow_policy(d['id'], d['version'], 'synthetic reviewer', policy, db_path=path)
|
||||
assert updated['success']
|
||||
d = updated['draft']
|
||||
assert d['version'] == 2
|
||||
review = ok(s.review_settlement_draft(d['id'], db_path=path))
|
||||
assert review['complete']
|
||||
ok(s.approve_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'synthetic reviewer', 'reason', 'ref', db_path=path))
|
||||
final = ok(s.finalize_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'final', db_path=path))
|
||||
after = ok(s.get_monthly_settlement_summary('2026-09', db_path=path))
|
||||
assert final['totals']['net_worth'] == 100 and after['source_coverage']['assets']['is_closed_snapshot']
|
||||
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
|
||||
@@ -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
|
||||
@@ -0,0 +1,271 @@
|
||||
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.2"
|
||||
assert metadata["issue"] == "#18"
|
||||
assert metadata["implementation_pr"] == "https://gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp/pulls/19"
|
||||
assert "#12" in body and "#13" in body
|
||||
assert re.findall(r"^### REQ-003\.(\d+) —", body, re.M) == [f"{i:02d}" for i in range(1, 12)]
|
||||
assert re.findall(r"^## (\d+)\. ", body, re.M) == [str(i) for i in range(1, 12)]
|
||||
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_comprehensive_migration_acceptance_has_explicit_legacy_test_mapping() -> None:
|
||||
text = REQ.read_text(encoding="utf-8")
|
||||
case = text.split("### REQ-003.11-AC26 —", 1)[1].split("### REQ-003.11-AC27 —", 1)[0]
|
||||
assert "`tests/test_settlement_cashflow_schema.py::test_legacy_migration_is_idempotent_and_keeps_unstored_totals_null`" in case
|
||||
|
||||
|
||||
def test_settlement_acceptance_cases_are_individually_named_and_real() -> None:
|
||||
import ast
|
||||
req = REQ.read_text(encoding="utf-8").split("---", 2)[2]
|
||||
section = req.split("## 7. 인수 기준", 1)[1].split("## 8. 오류", 1)[0]
|
||||
cases = re.findall(r"^### REQ-003\.11-AC(\d+) — (.+)$", section, re.M)
|
||||
assert len(cases) == 27
|
||||
assert [n for n, _ in cases] == [f"{i:02}" for i in range(1, 28)]
|
||||
files = {str(p.relative_to(ROOT)): p for p in (ROOT / "tests").glob("test_*.py")}
|
||||
for block in re.split(r"(?=^### REQ-003\.11-AC)", section, flags=re.M)[1:]:
|
||||
ref = re.search(r"`(tests/[^`]+\.py)::(test_[A-Za-z0-9_]+)`", block)
|
||||
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_unregistered_liability_contract_separates_registration_from_unknown_debt() -> None:
|
||||
interface = text("docs/interface-contract.md")
|
||||
clause = interface.split("### Draft evidence public fields", 1)[1].split("## ", 1)[0]
|
||||
assert re.search(r"unregistered_liabilities.*(?:no|not).*registered.*liabilit", clause, re.I | re.S)
|
||||
assert re.search(r"not.*(?:unknown|real).*debt", clause, re.I | re.S)
|
||||
assert re.search(r"not.*(?:proof.*debt.free|prove.*debt.free)", clause, re.I | re.S)
|
||||
assert re.search(r"exclu.*(?:missing|completeness)|(?:missing|completeness).*exclu", clause, re.I | re.S)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def test_legacy_metadata_field_lists_are_required_retained_fields_not_exhaustive_response_keys() -> None:
|
||||
interface = text("docs/interface-contract.md")
|
||||
legacy = interface.split("## D04 snapshot close output contract", 1)[1].split("## settlement-draft-service", 1)[0]
|
||||
assert re.search(r"legacy direct close", legacy, re.I)
|
||||
assert re.search(r"required top-level metadata fields", legacy, re.I)
|
||||
assert re.search(r"not (?:an )?exhaustive", legacy, re.I)
|
||||
assert "additive settlement-draft finalize response" in legacy
|
||||
|
||||
|
||||
def test_settlement_draft_project_ownership_and_shared_policy_wiring() -> None:
|
||||
doc = text("docs/project-structure.md")
|
||||
required_rows = {
|
||||
"jarca_ledger_mcp/service.py": "public re-export",
|
||||
"jarca_ledger_mcp/adapters/mcp/tool_contracts.py": "inputSchema",
|
||||
"jarca_ledger_mcp/adapters/streamlit/settlement_gateway.py": "auth-before-DB",
|
||||
"dashboard/page_views/settlement_drafts.py": "render_settlement_drafts",
|
||||
"dashboard/page_views/monthly_settlement.py": "page_monthly_settlement",
|
||||
"dashboard/page_views/assets.py": "page_assets",
|
||||
"jarca_ledger_mcp/application/account_valuation_service.py": "register_account_valuation",
|
||||
"jarca_ledger_mcp/infrastructure/sqlite/schema.py": "schema 13",
|
||||
}
|
||||
for path, responsibility in required_rows.items():
|
||||
rows = [row for row in doc.splitlines() if row.startswith("|") and f"`{path}`" in row]
|
||||
assert rows and any(responsibility in row for row in rows), (path, responsibility)
|
||||
assert (ROOT / path).is_file(), path
|
||||
section = doc.split("### Shared settlement-draft policies", 1)[1]
|
||||
for clause in ("one active draft per month", "idx_settlement_drafts_active_month", "WHERE status='active'",
|
||||
"settlement_draft_service.py", "month-end", "unverified", "authority amount NULL",
|
||||
"confirmed child", "explicit reselection", "review", "approval",
|
||||
"tests/test_settlement_draft_adapters.py", "tests/test_monthly_settlement_drafts.py"):
|
||||
assert clause in section, clause
|
||||
|
||||
|
||||
def test_settlement_draft_runtime_cancel_and_recovery_contract() -> None:
|
||||
doc = text("docs/runtime-flow.md")
|
||||
assert "### Cancellation and reopening" in doc, "cancel/reopen lifecycle must be documented"
|
||||
section = doc.split("### Cancellation and reopening", 1)[1]
|
||||
for clause in ("cancel_settlement_draft(draft_id, expected_version, cancelled_by, reason, db_path=None)",
|
||||
"active", "nonempty", "cancelled_by", "cancellation_reason", "cancelled_at",
|
||||
"approval_fingerprint", "approval_version", "version", "updated_at", "BEGIN IMMEDIATE",
|
||||
"retained", "account_valuations", "asset_snapshot_runs", "asset_snapshots",
|
||||
"rollback", "no-write", "completed", "cancelled", "fresh create key",
|
||||
"same create key", "latest prior run", "correction_of_run_id", "correction_reason",
|
||||
"tests/test_settlement_draft_correction_contract.py"):
|
||||
assert clause in section, clause
|
||||
source = ast.parse(text("jarca_ledger_mcp/application/settlement_draft_service.py"))
|
||||
cancel = next(n for n in source.body if isinstance(n, ast.FunctionDef) and n.name == "cancel_settlement_draft")
|
||||
assert [arg.arg for arg in cancel.args.args] == ["draft_id", "expected_version", "cancelled_by", "reason", "db_path"]
|
||||
|
||||
|
||||
def _comprehensive_section(doc, heading):
|
||||
assert heading in doc, f"missing documented contract section: {heading}"
|
||||
return doc.split(heading, 1)[1].split("\n### ", 1)[0]
|
||||
|
||||
|
||||
def _comprehensive_table(section):
|
||||
rows = {}
|
||||
for line in section.splitlines():
|
||||
if not line.startswith("|") or re.match(r"^\|\s*:?-+", line):
|
||||
continue
|
||||
cells = [c.strip().strip("` ") for c in line.strip().strip("|").split("|")]
|
||||
if len(cells) == 4 and cells[0] not in {"Column", "Field", "field"}:
|
||||
rows[cells[0]] = cells[1:]
|
||||
return rows
|
||||
|
||||
|
||||
def test_settlement_cashflow_model_documents_schema_and_legacy_reproduction(tmp_path):
|
||||
import sqlite3
|
||||
from jarca_ledger_mcp.db import init_db
|
||||
doc = (ROOT / "docs/data-model.md").read_text(encoding="utf-8")
|
||||
names = {
|
||||
"asset_snapshot_runs": ("settlement_month", "currency", "income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth", "cashflow_policy_json", "aggregation_version", "snapshot_schema_version", "source_snapshot_json"),
|
||||
"settlement_drafts": ("cashflow_policy_json", "aggregation_version"),
|
||||
"settlement_cashflow_snapshots": ("id", "settlement_run_id", "cashflow_kind", "category", "subcategory", "amount", "transaction_count", "currency"),
|
||||
}
|
||||
database = tmp_path / "synthetic-doc-schema.sqlite3"
|
||||
init_db(str(database))
|
||||
with sqlite3.connect(database) as conn:
|
||||
for table, columns in names.items():
|
||||
section = _comprehensive_section(doc, f"### {table} cashflow field contract")
|
||||
observed = _comprehensive_table(section)
|
||||
ddl = {r[1]: [r[2], "NOT NULL" if r[3] else "NULL", r[4] if r[4] is not None else "none"] for r in conn.execute(f"PRAGMA table_info({table})") if r[1] in columns}
|
||||
assert observed == ddl, f"field types/nullability/defaults differ from actual DDL: {table}"
|
||||
reproduction = _comprehensive_section(doc, "### Comprehensive snapshot evidence inputs")
|
||||
for field in ("source_snapshot_json", "transactions", "assets", "transaction_date", "originalcashflow_kind", "applied_amount", "valuation_id", "valuation_date", "authority_source", "cashflow_policy_json", "legacy", "NULL"):
|
||||
assert field in reproduction, field
|
||||
for constraint in ("ON DELETE RESTRICT", "UNIQUE", "transaction_count > 0", "idx_settlement_cashflow_snapshots_run"):
|
||||
assert constraint in doc, constraint
|
||||
|
||||
|
||||
def test_settlement_cashflow_interface_specifies_valid_policy_review_and_response_examples():
|
||||
from jarca_ledger_mcp.application.settlement_cashflow_service import _normalize_cashflow_policy
|
||||
doc = (ROOT / "docs/interface-contract.md").read_text(encoding="utf-8")
|
||||
policy_section = _comprehensive_section(doc, "### Comprehensive cashflow policy input")
|
||||
match = re.search(r"```json\s*(.*?)\s*```", policy_section, re.S)
|
||||
assert match, "policy input needs an executable JSON example"
|
||||
policy = json.loads(match.group(1))
|
||||
assert _normalize_cashflow_policy(policy) == policy
|
||||
assert set(policy["savings_categories"][0]) == {"category", "subcategory"}
|
||||
assert set(policy["category_decisions"][0]) == {"category", "subcategory", "cashflow_kind", "reason"}
|
||||
for field in ("income", "expense", "excluded", "duplicate", "nonempty", "expected_version", "review_fingerprint", "aggregation_version", "comprehensive-v1"):
|
||||
assert field in policy_section, field
|
||||
outputs = _comprehensive_section(doc, "### Comprehensive settlement response contract")
|
||||
totals = _comprehensive_table(outputs)
|
||||
keys = ("income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth")
|
||||
assert set(keys) <= set(totals)
|
||||
for key in keys:
|
||||
assert totals[key] == ["integer", "required", "null"], key
|
||||
for field in ("cashflow_items", "cashflow_blockers", "transaction_id", "reason", "validation_error", "cashflow review blockers must be resolved", "final_response", "cashflow_policy", "source_snapshot", "refund"):
|
||||
assert field in outputs, field
|
||||
examples = [json.loads(s) for s in re.findall(r"```json\s*(.*?)\s*```", outputs, re.S)]
|
||||
review = next((x for x in examples if isinstance(x, dict) and "cashflow_blockers" in x and "review_fingerprint" in x), None)
|
||||
assert review is not None, "review response example must use actual root fields"
|
||||
assert review["success"] is True and all(type(review[key]) is int for key in keys)
|
||||
assert review["expense_total"] < 0, "signed-refund example must retain a negative expense total"
|
||||
assert all(set(x) == {"transaction_id", "reason"} for x in review["cashflow_blockers"])
|
||||
saved = next((x for x in examples if isinstance(x, dict) and x.get("source") == "settlement_snapshot"), None)
|
||||
assert saved is not None and saved["success"] is True and "run_id" in saved
|
||||
assert all(key in saved for key in keys)
|
||||
|
||||
|
||||
def test_settlement_cashflow_private_service_has_explicit_ownership_and_test_mapping():
|
||||
doc = (ROOT / "docs/project-structure.md").read_text(encoding="utf-8")
|
||||
rows = [line for line in doc.splitlines() if line.startswith("|") and "settlement_cashflow_service.py" in line]
|
||||
assert len(rows) == 1, "private aggregation owner must have an explicit responsibility row"
|
||||
for field in ("private", "_normalize_cashflow_policy", "_review_cashflow", "settlement_draft_service.py", "test_settlement_cashflow_policy.py", "test_comprehensive_settlement_adapters.py"):
|
||||
assert field in rows[0], field
|
||||
|
||||
def test_cashflow_policy_documented_signature_matches_public_api():
|
||||
from jarca_ledger_mcp import service
|
||||
import inspect
|
||||
section = _comprehensive_section((ROOT / "docs/interface-contract.md").read_text(), "### Comprehensive cashflow policy input")
|
||||
assert list(inspect.signature(service.set_settlement_cashflow_policy).parameters) == ["draft_id", "expected_version", "updated_by", "cashflow_policy", "db_path"]
|
||||
assert "set_settlement_cashflow_policy(draft_id, expected_version, updated_by, cashflow_policy, db_path=None)" in section
|
||||
assert "set_settlement_draft_cashflow_policy(" not in section
|
||||
@@ -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']
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Registration semantics for draft unregistered-liability status."""
|
||||
|
||||
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_unregistered_liabilities_tracks_registered_targets_not_evidence_completeness(tmp_path):
|
||||
path = str(tmp_path / "synthetic-liability-registration.sqlite3")
|
||||
init_db(path)
|
||||
|
||||
empty = ok(s.create_settlement_draft("2026-09", "synthetic", "empty", db_path=path))["draft"]
|
||||
empty_review = ok(s.review_settlement_draft(empty["id"], db_path=path))
|
||||
assert empty_review["unregistered_liabilities"] is True
|
||||
|
||||
loan = ok(s.create_ledger_account("Synthetic loan", type="loan", liquidity_bucket="loan", db_path=path))["account"]["id"]
|
||||
loan_draft = ok(s.create_settlement_draft("2026-10", "synthetic", "loan", db_path=path))["draft"]
|
||||
loan_review = ok(s.review_settlement_draft(loan_draft["id"], db_path=path))
|
||||
loan_item = next(item for item in loan_review["items"] if item["source_id"] == loan)
|
||||
assert loan_item["amount"] is None
|
||||
assert loan_review["unregistered_liabilities"] is False
|
||||
|
||||
manual = ok(s.create_manual_asset("Synthetic manual liability", 50, "2026-10-31", "loan", db_path=path))["manual_asset"]["id"]
|
||||
refreshed = ok(s.refresh_settlement_draft_targets(loan_draft["id"], loan_draft["version"], "synthetic", db_path=path))["draft"]
|
||||
manual_item = next(item for item in ok(s.get_settlement_draft(refreshed["id"], db_path=path))["items"] if item["source_id"] == manual)
|
||||
excluded = ok(s.set_settlement_draft_item(refreshed["id"], "manual_asset", manual, refreshed["version"], "synthetic", included=False, exclusion_reason="synthetic excluded", db_path=path))["draft"]
|
||||
excluded_review = ok(s.review_settlement_draft(excluded["id"], db_path=path))
|
||||
assert manual_item["amount"] == 50
|
||||
assert next(item for item in excluded_review["excluded_items"] if item["source_id"] == manual)
|
||||
assert excluded_review["unregistered_liabilities"] is False
|
||||
assert excluded_review["missing_items"]
|
||||
|
||||
|
||||
def test_manual_only_frozen_liability_registration_and_missing_exclusion_are_independent(tmp_path):
|
||||
path = str(tmp_path / "manual-only-frozen-liability.sqlite3")
|
||||
init_db(path)
|
||||
manual = ok(s.create_manual_asset("Manual-only debt", 75, "2026-10-31", "loan", db_path=path))["manual_asset"]["id"]
|
||||
draft = ok(s.create_settlement_draft("2026-10", "synthetic", "manual-only", db_path=path))["draft"]
|
||||
# Freeze the target list without any registered loan account.
|
||||
frozen = ok(s.refresh_settlement_draft_targets(draft["id"], draft["version"], "synthetic", db_path=path))["draft"]
|
||||
frozen_items = ok(s.get_settlement_draft(frozen["id"], db_path=path))["items"]
|
||||
item = next(item for item in frozen_items if item["source_id"] == manual)
|
||||
assert item["amount"] == 75
|
||||
assert not any(item.get("source_type") == "account" for item in frozen_items)
|
||||
review = ok(s.review_settlement_draft(frozen["id"], db_path=path))
|
||||
assert review["unregistered_liabilities"] is False
|
||||
assert isinstance(review["missing_items"], list)
|
||||
excluded = ok(s.set_settlement_draft_item(frozen["id"], "manual_asset", manual, frozen["version"], "synthetic", included=False, exclusion_reason="not registered", db_path=path))["draft"]
|
||||
excluded_review = ok(s.review_settlement_draft(excluded["id"], db_path=path))
|
||||
assert any(item["source_id"] == manual for item in excluded_review["excluded_items"])
|
||||
assert excluded_review["unregistered_liabilities"] is False
|
||||
assert isinstance(excluded_review["missing_items"], list)
|
||||
@@ -0,0 +1,166 @@
|
||||
"""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 require_fields(value, fields, context):
|
||||
missing = set(fields) - set(value)
|
||||
assert not missing, f'{context} missing required public fields: {sorted(missing)}'
|
||||
|
||||
|
||||
def test_required_public_field_assertion_detects_omission():
|
||||
synthetic_contract_result = {'success': True, 'draft': {}}
|
||||
require_fields(synthetic_contract_result, {'success', 'draft'}, 'stdio result')
|
||||
dropped_field_copy = dict(synthetic_contract_result)
|
||||
dropped_field_copy.pop('draft')
|
||||
with pytest.raises(AssertionError, match='missing required public fields.*draft'):
|
||||
require_fields(dropped_field_copy, {'success', 'draft'}, 'dropped-field mutation control')
|
||||
|
||||
|
||||
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']
|
||||
require_fields(made, {'success', 'draft', 'items', 'review'}, 'create stdio result')
|
||||
assert made['draft']['status'] == 'active' and made['draft']['version'] == 1
|
||||
draft_id = made['draft']['id']
|
||||
listed_drafts = await call('list_settlement_drafts', {'month':'2026-09'})
|
||||
require_fields(listed_drafts, {'success', 'drafts'}, 'list stdio result')
|
||||
assert listed_drafts['success'] and listed_drafts['drafts'][0]['id'] == draft_id
|
||||
fetched = await call('get_settlement_draft', {'draft_id':draft_id})
|
||||
require_fields(fetched, {'success', 'draft', 'items', 'review'}, 'get stdio result')
|
||||
assert fetched['success'] and fetched['draft']['id'] == draft_id and fetched['draft']['version'] == 1
|
||||
draft = fetched['draft']
|
||||
updated = await call('update_settlement_draft', {'draft_id':draft_id,'expected_version':draft['version'],'updated_by':'stdio-test','memo':'transport lifecycle'})
|
||||
require_fields(updated, {'success', 'draft', 'items', 'review'}, 'update stdio result')
|
||||
assert updated['success'] and updated['draft']['memo'] == 'transport lifecycle' and updated['draft']['version'] == 2
|
||||
draft = updated['draft']
|
||||
refreshed = await call('refresh_settlement_draft_targets', {'draft_id':draft_id,'expected_version':draft['version'],'updated_by':'stdio-test'})
|
||||
require_fields(refreshed, {'success', 'draft', 'items', 'review'}, 'refresh stdio result')
|
||||
assert refreshed['success'] and refreshed['draft']['version'] == 3
|
||||
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']})
|
||||
require_fields(changed, {'success', 'draft', 'items', 'review'}, 'set-item stdio result')
|
||||
assert changed['success'] and changed['draft']['version'] == 4
|
||||
draft = changed['draft']
|
||||
reviewed = await call('review_settlement_draft', {'draft_id':draft_id})
|
||||
assert reviewed['success'], reviewed
|
||||
require_fields(reviewed, {'success', 'draft_id', 'version', 'review_fingerprint', 'items', 'included_items', 'excluded_items', 'missing_items', 'warnings', 'approval_required', 'totals', 'complete', 'contains_substitutions', 'contains_carried', 'has_approved_missing', 'unregistered_liabilities'}, 'review stdio result')
|
||||
selected = reviewed['included_items'][0]
|
||||
for field in ('valuation_date', 'recorded_at', 'date_difference_days', 'date_difference_direction', 'age_days', 'verification_status', 'evidence_kind', 'selection_mode', 'selection_reason', 'selected_by', 'selected_at', 'item_version'):
|
||||
assert field in selected, f'review selected evidence missing {field}'
|
||||
assert selected['valuation_date'] == '2026-09-30' and selected['age_days'] == 0 and selected['date_difference_days'] == 0
|
||||
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'})
|
||||
require_fields(approved, {'success', 'draft', 'items', 'review'}, 'approve stdio result')
|
||||
require_fields(approved['draft'], {'approved_by', 'approved_at', 'approval_reason', 'approval_reference', 'approval_flags'}, 'approved draft')
|
||||
assert approved['success']
|
||||
assert approved['draft']['approved_by'] == 'stdio-test' and approved['draft']['approval_reason'] == 'transport acceptance' and approved['draft']['approval_reference'] == 'test'
|
||||
closed = await call('finalize_settlement_draft', {'draft_id':draft_id,'expected_version':draft['version'],'review_fingerprint':fingerprint,'idempotency_key':'mcp-close'})
|
||||
require_fields(closed, {'success', 'draft_id', 'draft_version', 'review_fingerprint', 'snapshot_run_id', 'run_id', 'monthly_source_snapshot_id', 'basis_date', 'close_status', 'closed_at', 'source', 'is_closed_snapshot', 'selection_policy', 'inserted_count', 'inserted_snapshot_ids', 'items', 'excluded_items', 'totals', 'contains_substitutions', 'contains_carried', 'has_approved_missing', 'settlement_label', 'warnings', 'carried_items', 'approval', 'correction', 'is_correction', 'correction_of_run_id', 'correction_reason', 'provenance', 'total_assets', 'total_liabilities', 'net_worth'}, 'finalize stdio result')
|
||||
assert closed['success'] and closed['totals']['net_worth'] == 100
|
||||
assert closed['source'] == 'snapshot' and closed['is_closed_snapshot'] is True
|
||||
assert closed['snapshot_run_id'] == closed['run_id'] == closed['monthly_source_snapshot_id']
|
||||
assert closed['approval']['approved_by'] == 'stdio-test'
|
||||
assert closed['approval']['allow_missing_exclusions'] is False and closed['approval']['allow_carried_before'] is False and closed['approval']['allow_manual_substitute'] is False
|
||||
item = closed['items'][0]
|
||||
for field in ('valuation_date', 'recorded_at', 'date_difference_days', 'date_difference_direction', 'age_days', 'verification_status', 'evidence_kind', 'approval', 'draft_id', 'draft_version', 'review_fingerprint'):
|
||||
assert field in item, f'finalized selected evidence missing {field}'
|
||||
provenance = closed['provenance']
|
||||
for field in ('valuation_date', 'age_days', 'carried_forward', 'verification_status', 'evidence_kind', 'approval', 'correction', 'warnings', 'exclusions'):
|
||||
assert field in provenance, f'final provenance missing {field}'
|
||||
assert provenance['valuation_date'] == item['valuation_date'] and provenance['age_days'] == item['age_days']
|
||||
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
|
||||
archived = await call('get_settlement_draft', {'draft_id':draft_id})
|
||||
require_fields(archived, {'success', 'draft', 'items', 'final_response'}, 'archived get stdio result')
|
||||
assert archived['final_response'] == closed
|
||||
assert archived == 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'})
|
||||
require_fields(cancelled, {'success', 'draft', 'items', 'review'}, 'cancel stdio result')
|
||||
assert cancelled['success'] and cancelled['draft']['status'] == 'cancelled'
|
||||
assert cancelled['draft']['cancelled_by'] == 'stdio-test' and cancelled['draft']['cancellation_reason'] == 'synthetic cancellation'
|
||||
|
||||
# Legacy close retains required contract fields; this is a subset check, not exact whole-key equality.
|
||||
legacy_account = service.create_ledger_account('Synthetic legacy cash', db_path=str(db))['account']['id']
|
||||
service.register_account_valuation(legacy_account, '2026-11-30', 31, verification_status='verified', idempotency_key='legacy-value', db_path=str(db))
|
||||
preview = await call('preview_asset_snapshot_close', {'basis_date':'2026-11-30','selection_policy':'exact'})
|
||||
assert preview['success'] and preview['source'] == 'live_authority' and preview['is_closed_snapshot'] is False
|
||||
legacy = await call('close_asset_snapshot', {'basis_date':'2026-11-30','selection_policy':'exact','idempotency_key':'legacy-close','allow_missing_exclusions':True,'approved_by':'stdio-test','approval_reason':'legacy transport test','approval_reference':'legacy-test'})
|
||||
legacy_fields = {'snapshot_run_id', 'close_status', 'approval', 'correction', 'warnings', 'excluded_items', 'carried_items', 'provenance', 'valuation_date', 'age_days', 'verification_status', 'evidence_kind', 'monthly_source_snapshot_id'}
|
||||
require_fields(legacy, {'success'} | legacy_fields, 'legacy close stdio result')
|
||||
assert not ({'draft_id', 'draft_version', 'review_fingerprint', 'contains_substitutions', 'contains_carried', 'has_approved_missing'} & set(legacy))
|
||||
assert legacy['monthly_source_snapshot_id'] == legacy['snapshot_run_id']
|
||||
@@ -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,290 @@
|
||||
"""Public-API regressions for frozen settlement-draft policy consistency."""
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
from jarca_ledger_mcp import service as s
|
||||
from jarca_ledger_mcp.db import init_db
|
||||
|
||||
|
||||
def ok(value):
|
||||
assert value["success"], value
|
||||
return value
|
||||
|
||||
|
||||
def draft(path, month="2026-09"):
|
||||
return ok(s.create_settlement_draft(month, "synthetic creator", "draft-key", db_path=path))["draft"]
|
||||
|
||||
|
||||
def frozen_items(path, did):
|
||||
with sqlite3.connect(path) as db:
|
||||
return db.execute("SELECT source_id, evidence_json FROM settlement_draft_items WHERE draft_id=? ORDER BY source_id", (did,)).fetchall()
|
||||
|
||||
|
||||
def approve(path, d, **flags):
|
||||
r = ok(s.review_settlement_draft(d["id"], db_path=path))
|
||||
ok(s.approve_settlement_draft(d["id"], d["version"], r["review_fingerprint"], "reviewer", "synthetic approval", "ref", db_path=path, **flags))
|
||||
return r
|
||||
|
||||
|
||||
@pytest.mark.parametrize("debts", [(-40,), (40,), (40, -10, -15), (10, -40)])
|
||||
def test_signed_manual_loan_totals_remain_consistent_across_close_and_reports(tmp_path, debts):
|
||||
path = str(tmp_path / "signed-loans.sqlite3")
|
||||
init_db(path)
|
||||
ids = [ok(s.create_manual_asset(f"debt {i}", amount, "2026-09-30", "loan", db_path=path))["manual_asset"]["id"] for i, amount in enumerate(debts)]
|
||||
ok(s.create_manual_asset("asset", 100, "2026-09-30", "real_estate", db_path=path))
|
||||
with sqlite3.connect(path) as db:
|
||||
before = db.execute("SELECT id, valuation_amount, currency, source_note, memo, valuation_date FROM manual_assets ORDER BY id").fetchall()
|
||||
d = draft(path)
|
||||
frozen_before = frozen_items(path, d["id"])
|
||||
review = ok(s.review_settlement_draft(d["id"], db_path=path))
|
||||
liabilities = abs(sum(debts)); net = 100 - liabilities
|
||||
for totals in (review["totals"], review):
|
||||
assert totals["total_assets"] == 100
|
||||
assert totals["total_liabilities"] == liabilities
|
||||
assert totals["net_worth"] == net
|
||||
ok(s.approve_settlement_draft(d["id"], d["version"], review["review_fingerprint"], "reviewer", "synthetic review", "ref", db_path=path))
|
||||
closed = ok(s.finalize_settlement_draft(d["id"], d["version"], review["review_fingerprint"], "close-key", db_path=path))
|
||||
for totals in (closed["totals"], closed):
|
||||
assert totals["total_assets"] == 100 and totals["total_liabilities"] == liabilities and totals["net_worth"] == net
|
||||
lookup = ok(s.get_settlement_draft(d["id"], db_path=path))
|
||||
assert lookup["final_response"]["totals"] == closed["totals"]
|
||||
replay = s.finalize_settlement_draft(d["id"], d["version"], review["review_fingerprint"], "close-key", db_path=path)
|
||||
assert {k:v for k,v in replay.items() if k != "already_existing"} == {k:v for k,v in closed.items() if k != "already_existing"}
|
||||
report = ok(s.get_asset_snapshot_report("2026-09-30", db_path=path))
|
||||
assert (report["total_assets"], report["total_liabilities"], report["net_worth"]) == (100, liabilities, net)
|
||||
assert report["close_metadata"]["draft_id"] == d["id"]
|
||||
point = ok(s.get_net_worth_timeseries("2026-09-01", "2026-09-30", db_path=path))["points"][0]
|
||||
assert (point["total_assets"], point["total_liabilities"], point["net_worth"]) == (100, liabilities, net)
|
||||
monthly = ok(s.get_monthly_settlement_summary("2026-09", db_path=path))
|
||||
assert monthly["source_coverage"]["assets"]["draft_id"] == d["id"]
|
||||
with sqlite3.connect(path) as db:
|
||||
after = db.execute("SELECT id, valuation_amount, currency, source_note, memo, valuation_date FROM manual_assets ORDER BY id").fetchall()
|
||||
assert after == before and len(ids) == len(debts)
|
||||
assert frozen_items(path, d["id"]) == frozen_before
|
||||
assert "evidence_json" not in closed
|
||||
|
||||
|
||||
def test_lowercase_supported_currency_normalizes_without_mutating_frozen_evidence(tmp_path):
|
||||
path = str(tmp_path / "lowercase-currency.sqlite3")
|
||||
init_db(path)
|
||||
asset = ok(s.create_manual_asset("Synthetic KRW asset", 25, "2026-09-30", "real_estate", currency="krw", db_path=path))["manual_asset"]["id"]
|
||||
with sqlite3.connect(path) as db:
|
||||
raw_before = db.execute("SELECT valuation_amount, currency, source_note, memo FROM manual_assets WHERE id=?", (asset,)).fetchone()
|
||||
assert raw_before[1] == "krw"
|
||||
d = draft(path)
|
||||
r = ok(s.review_settlement_draft(d["id"], db_path=path))
|
||||
item = next(x for x in r["items"] if x["source_id"] == asset)
|
||||
assert item["currency"] == "KRW" and item["amount"] == 25
|
||||
assert not r["missing_items"] and not any(w.get("code") == "non_krw" for w in r["warnings"])
|
||||
ok(s.approve_settlement_draft(d["id"], d["version"], r["review_fingerprint"], "reviewer", "approve", "ref", db_path=path))
|
||||
closed = ok(s.finalize_settlement_draft(d["id"], d["version"], r["review_fingerprint"], "close", db_path=path))
|
||||
frozen = dict(frozen_items(path, d["id"]))
|
||||
assert json.loads(frozen[asset])["source"]["currency"] == "krw"
|
||||
assert "evidence_json" not in closed
|
||||
assert closed["totals"]["total_assets"] == closed["total_assets"] == 25
|
||||
assert ok(s.get_settlement_draft(d["id"], db_path=path))["final_response"] == closed
|
||||
assert ok(s.finalize_settlement_draft(d["id"], d["version"], r["review_fingerprint"], "close", db_path=path)) == closed
|
||||
assert ok(s.get_asset_snapshot_report("2026-09-30", db_path=path))["total_assets"] == 25
|
||||
with sqlite3.connect(path) as db:
|
||||
assert db.execute("SELECT valuation_amount, currency, source_note, memo FROM manual_assets WHERE id=?", (asset,)).fetchone() == raw_before
|
||||
|
||||
|
||||
def test_verified_cash_balance_can_be_explicitly_reselected_without_losing_authority(tmp_path):
|
||||
path = str(tmp_path / "cash-reselection.sqlite3")
|
||||
init_db(path)
|
||||
account = ok(s.create_ledger_account("Synthetic bank", type="bank", liquidity_bucket="cash", db_path=path))["account"]["id"]
|
||||
val = ok(s.register_account_valuation(account, "2026-09-30", 20, valuation_kind="cash_balance", verification_status="verified", idempotency_key="cash", db_path=path))["valuation"]["id"]
|
||||
d = draft(path)
|
||||
r = ok(s.review_settlement_draft(d["id"], db_path=path))
|
||||
row = next(x for x in r["included_items"] if x["source_id"] == account)
|
||||
assert row["valuation_id"] == val and row["amount"] == 20
|
||||
selected = ok(s.set_settlement_draft_item(d["id"], "account", account, d["version"], "selector", valuation_id=val, db_path=path))["draft"]
|
||||
r2 = ok(s.review_settlement_draft(selected["id"], db_path=path))
|
||||
row2 = next(x for x in r2["included_items"] if x["source_id"] == account)
|
||||
assert row2["valuation_id"] == val and row2["amount"] == 20
|
||||
ok(s.approve_settlement_draft(selected["id"], selected["version"], r2["review_fingerprint"], "reviewer", "approve", "ref", db_path=path))
|
||||
final = ok(s.finalize_settlement_draft(selected["id"], selected["version"], r2["review_fingerprint"], "cash-close", db_path=path))
|
||||
assert final["totals"]["total_assets"] == 20
|
||||
assert ok(s.get_settlement_draft(selected["id"], db_path=path))["final_response"]["totals"] == final["totals"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode,when,flags", [("exact", "2026-09-30", {}), ("carried_before", "2026-09-29", {"allow_carried_before": True}), ("manual_substitute", "2026-10-02", {"allow_manual_substitute": True})])
|
||||
@pytest.mark.parametrize("kind", ["balance", "cash_balance"])
|
||||
def test_bank_cash_balance_selection_modes_preserve_explicit_authority(tmp_path, mode, when, flags, kind):
|
||||
path = str(tmp_path / (mode + ".sqlite3"))
|
||||
init_db(path)
|
||||
account = ok(s.create_ledger_account("Synthetic bank", type="bank", liquidity_bucket="cash", db_path=path))["account"]["id"]
|
||||
val = ok(s.register_account_valuation(account, when, 20, valuation_kind=kind, verification_status="verified", idempotency_key="cash", db_path=path))["valuation"]["id"]
|
||||
d = draft(path)
|
||||
kwargs = {"selection_mode": mode, "selection_reason": "dated bank statement"} if mode != "exact" else {}
|
||||
d = ok(s.set_settlement_draft_item(d["id"], "account", account, d["version"], "selector", valuation_id=val, db_path=path, **kwargs))["draft"]
|
||||
r = ok(s.review_settlement_draft(d["id"], db_path=path))
|
||||
row = next(x for x in r["included_items"] if x["source_id"] == account)
|
||||
assert row["valuation_id"] == val and row["amount"] == 20
|
||||
frozen_before = frozen_items(path, d["id"])
|
||||
assert row["valuation_date"] == when
|
||||
ok(s.approve_settlement_draft(d["id"], d["version"], r["review_fingerprint"], "reviewer", "accepted dated evidence", "ref", db_path=path, **flags))
|
||||
final = ok(s.finalize_settlement_draft(d["id"], d["version"], r["review_fingerprint"], "mode-close", db_path=path))
|
||||
assert final["totals"]["total_assets"] == final["total_assets"] == 20
|
||||
assert final["contains_substitutions"] == (mode == "manual_substitute")
|
||||
assert final["contains_carried"] == (mode == "carried_before")
|
||||
assert frozen_items(path, d["id"]) == frozen_before
|
||||
assert ok(s.get_settlement_draft(d["id"], db_path=path))["final_response"] == final
|
||||
assert ok(s.finalize_settlement_draft(d["id"], d["version"], r["review_fingerprint"], "mode-close", db_path=path)) == final
|
||||
|
||||
|
||||
def test_invalid_future_exact_and_unverified_bank_candidate_cannot_supply_authority(tmp_path):
|
||||
path = str(tmp_path / "invalid-bank.sqlite3")
|
||||
init_db(path)
|
||||
account = ok(s.create_ledger_account("Synthetic bank", type="bank", liquidity_bucket="cash", db_path=path))["account"]["id"]
|
||||
future = ok(s.register_account_valuation(account, "2026-10-02", 20, valuation_kind="cash_balance", verification_status="verified", idempotency_key="future", db_path=path))["valuation"]["id"]
|
||||
d = draft(path)
|
||||
bad = s.set_settlement_draft_item(d["id"], "account", account, d["version"], "selector", valuation_id=future, selection_mode="exact", db_path=path)
|
||||
assert not bad["success"] and bad["error_code"] == "validation_error"
|
||||
assert ok(s.get_settlement_draft(d["id"], db_path=path))["draft"]["version"] == d["version"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("currency,amount,expected", [("USD", 25, None), ("KRW", 0, 0), ("KRW", None, None), (" krw ", 25, None)])
|
||||
def test_manual_currency_and_missing_value_boundaries_match_legacy_policy(tmp_path, currency, amount, expected):
|
||||
path = str(tmp_path / "manual-boundary.sqlite3")
|
||||
init_db(path)
|
||||
asset = ok(s.create_manual_asset("Synthetic boundary", amount, "2026-09-30", "real_estate", currency=currency, db_path=path))["manual_asset"]["id"]
|
||||
d = draft(path)
|
||||
before = frozen_items(path, d["id"])
|
||||
r = ok(s.review_settlement_draft(d["id"], db_path=path))
|
||||
row = next(x for x in r["items"] if x["source_id"] == asset)
|
||||
assert row["amount"] == expected
|
||||
assert row["status"] == ("missing" if expected is None else "ready")
|
||||
assert r["total_assets"] == (expected or 0)
|
||||
assert frozen_items(path, d["id"]) == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode,when,reason", [("exact", "2026-10-02", ""), ("carried_before", "2026-09-30", "carry"), ("carried_before", "2026-10-02", "carry"), ("manual_substitute", "2026-10-02", "")])
|
||||
def test_invalid_bank_date_or_missing_reason_keeps_draft_unchanged(tmp_path, mode, when, reason):
|
||||
path = str(tmp_path / "invalid-selection.sqlite3")
|
||||
init_db(path)
|
||||
account = ok(s.create_ledger_account("Synthetic bank", db_path=path))["account"]["id"]
|
||||
val = ok(s.register_account_valuation(account, when, 20, valuation_kind="cash_balance", verification_status="verified", db_path=path))["valuation"]["id"]
|
||||
d = draft(path)
|
||||
before = ok(s.get_settlement_draft(d["id"], db_path=path))
|
||||
raw = frozen_items(path, d["id"])
|
||||
result = s.set_settlement_draft_item(d["id"], "account", account, d["version"], "selector", valuation_id=val, selection_mode=mode, selection_reason=reason, db_path=path)
|
||||
assert not result["success"] and result["error_code"] == "validation_error"
|
||||
assert ok(s.get_settlement_draft(d["id"], db_path=path)) == before
|
||||
assert frozen_items(path, d["id"]) == raw
|
||||
|
||||
|
||||
def test_unverified_bank_evidence_remains_missing_after_explicit_review_selection(tmp_path):
|
||||
path = str(tmp_path / "unverified.sqlite3")
|
||||
init_db(path)
|
||||
account = ok(s.create_ledger_account("Synthetic bank", db_path=path))["account"]["id"]
|
||||
val = ok(s.register_account_valuation(account, "2026-09-30", 20, valuation_kind="cash_balance", verification_status="unverified", db_path=path))["valuation"]["id"]
|
||||
d = draft(path)
|
||||
r = ok(s.review_settlement_draft(d["id"], db_path=path))
|
||||
assert r["items"][0]["valuation_id"] == "" and r["items"][0]["candidate_amount"] == 20
|
||||
assert r["items"][0]["amount"] is None
|
||||
rejected = s.set_settlement_draft_item(d["id"], "account", account, d["version"], "selector", valuation_id=val, db_path=path)
|
||||
assert not rejected["success"] and rejected["error_code"] == "validation_error"
|
||||
d = ok(s.set_settlement_draft_item(d["id"], "account", account, d["version"], "selector", valuation_id=val, selection_mode="manual_substitute", selection_reason="review only", db_path=path))["draft"]
|
||||
r = approve(path, d, allow_missing_exclusions=True, allow_manual_substitute=True)
|
||||
assert r["items"][0]["amount"] is None and r["total_assets"] == 0
|
||||
closed = ok(s.finalize_settlement_draft(d["id"], d["version"], r["review_fingerprint"], "unverified-close", db_path=path))
|
||||
assert closed["total_assets"] == closed["totals"]["total_assets"] == 0
|
||||
assert closed["excluded_items"][0]["amount"] is None
|
||||
with sqlite3.connect(path) as db:
|
||||
assert db.execute("SELECT verification_status FROM account_valuations WHERE id=?", (val,)).fetchone()[0] == "unverified"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("account_type,kind,definition,registration_allowed,selection_allowed", [("bank", "total_value", None, True, False), ("investment", "loan_balance", "principal_only", False, False), ("loan", "total_value", None, False, False), ("loan", "balance", None, False, False), ("loan", "balance", "principal_only", True, True), ("investment", "total_value", None, True, True)])
|
||||
def test_account_type_purpose_and_representative_liability_boundaries(tmp_path, account_type, kind, definition, registration_allowed, selection_allowed):
|
||||
path = str(tmp_path / "purpose.sqlite3")
|
||||
init_db(path)
|
||||
account = ok(s.create_ledger_account("Synthetic purpose", type=account_type, db_path=path))["account"]["id"]
|
||||
registered = s.register_account_valuation(account, "2026-09-30", 20, valuation_kind=kind, representative_liability_definition=definition, verification_status="verified", db_path=path)
|
||||
assert registered["success"] == registration_allowed
|
||||
if not registration_allowed:
|
||||
assert registered["error_code"] == "validation_error"
|
||||
with sqlite3.connect(path) as db:
|
||||
assert db.execute("SELECT COUNT(*) FROM account_valuations").fetchone()[0] == 0
|
||||
return
|
||||
d = draft(path)
|
||||
before = ok(s.get_settlement_draft(d["id"], db_path=path))
|
||||
selected = s.set_settlement_draft_item(d["id"], "account", account, d["version"], "selector", valuation_id=registered["valuation"]["id"], db_path=path)
|
||||
assert selected["success"] == selection_allowed
|
||||
if not selection_allowed:
|
||||
assert selected["error_code"] == "validation_error"
|
||||
assert ok(s.get_settlement_draft(d["id"], db_path=path)) == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize("amount", [None, True, 1.5])
|
||||
def test_account_valuation_amount_requires_a_strict_integer(tmp_path, amount):
|
||||
path = str(tmp_path / "strict-amount.sqlite3")
|
||||
init_db(path)
|
||||
account = ok(s.create_ledger_account("Synthetic strict", db_path=path))["account"]["id"]
|
||||
rejected = s.register_account_valuation(account, "2026-09-30", amount, verification_status="verified", db_path=path)
|
||||
assert not rejected["success"] and rejected["error_code"] == "validation_error"
|
||||
with sqlite3.connect(path) as db:
|
||||
assert db.execute("SELECT COUNT(*) FROM account_valuations").fetchone()[0] == 0
|
||||
|
||||
|
||||
|
||||
def test_legacy_amount_approval_requires_fresh_review_after_policy_correction(tmp_path):
|
||||
import hashlib
|
||||
|
||||
path = str(tmp_path / "legacy-approval.sqlite3")
|
||||
init_db(path)
|
||||
ok(s.create_manual_asset("Synthetic asset", 100, "2026-09-30", "real_estate", db_path=path))
|
||||
ok(s.create_manual_asset("Synthetic signed debt", -40, "2026-09-30", "loan", db_path=path))
|
||||
d = draft(path)
|
||||
frozen_before = frozen_items(path, d["id"])
|
||||
# Historical persisted fingerprint schema: immutable baseline _review serial
|
||||
# did not include normalized totals. Reconstruct its exact manual-only inputs,
|
||||
# rather than treating the current fingerprint as a historical approval.
|
||||
with sqlite3.connect(path) as db:
|
||||
db.row_factory = sqlite3.Row
|
||||
stored = [dict(x) for x in db.execute("SELECT * FROM settlement_draft_items WHERE draft_id=? ORDER BY source_type,source_id", (d["id"],))]
|
||||
states = []
|
||||
for item in stored:
|
||||
current = {"source": dict(db.execute("SELECT * FROM manual_assets WHERE id=?", (item["source_id"],)).fetchone())}
|
||||
states.append({"stored": item, "current": current, "candidates": []})
|
||||
assert db.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
|
||||
serial = json.dumps({"draft_id": d["id"], "version": d["version"], "basis_date": d["basis_date"], "states": states, "warnings": [], "latest_run": "", "correction_parent": d["correction_of_run_id"]}, sort_keys=True, ensure_ascii=False)
|
||||
legacy_fingerprint = hashlib.sha256(serial.encode()).hexdigest()
|
||||
flags = {"allow_missing_exclusions": False, "allow_carried_before": False, "allow_manual_substitute": False}
|
||||
with sqlite3.connect(path) as db:
|
||||
db.execute("UPDATE settlement_drafts SET approval_fingerprint=?,approval_version=?,approved_by=?,approved_at=?,approval_reason=?,approval_reference=?,approval_flags=? WHERE id=?", (legacy_fingerprint, d["version"], "synthetic legacy reviewer", "2026-10-10T00:00:00Z", "reviewed legacy net 140", "synthetic reference", json.dumps(flags), d["id"]))
|
||||
rejected = s.finalize_settlement_draft(d["id"], d["version"], legacy_fingerprint, "legacy-close", db_path=path)
|
||||
assert not rejected["success"], "a historical amount approval must not authorize corrected totals"
|
||||
assert rejected["error_code"] == "conflict"
|
||||
with sqlite3.connect(path) as db:
|
||||
assert db.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
|
||||
assert db.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0] == 0
|
||||
row = db.execute("SELECT status,version,final_run_id FROM settlement_drafts WHERE id=?", (d["id"],)).fetchone()
|
||||
assert row == ("active", d["version"], "")
|
||||
assert frozen_items(path, d["id"]) == frozen_before
|
||||
reviewed = approve(path, d)
|
||||
assert reviewed["review_fingerprint"] != legacy_fingerprint
|
||||
final = ok(s.finalize_settlement_draft(d["id"], d["version"], reviewed["review_fingerprint"], "legacy-close", db_path=path))
|
||||
assert final["totals"] == {"total_assets": 100, "total_liabilities": 40, "net_worth": 60, "income_total": 0, "expense_total": 0, "net_cashflow": 0}
|
||||
assert ok(s.get_settlement_draft(d["id"], db_path=path))["final_response"] == final
|
||||
assert ok(s.finalize_settlement_draft(d["id"], d["version"], reviewed["review_fingerprint"], "legacy-close", db_path=path)) == final
|
||||
assert frozen_items(path, d["id"]) == frozen_before
|
||||
|
||||
|
||||
def test_completed_close_replay_uses_immutable_response_without_revalidation(tmp_path, monkeypatch):
|
||||
from jarca_ledger_mcp.application import settlement_draft_service
|
||||
|
||||
path = str(tmp_path / "completed-replay.sqlite3")
|
||||
init_db(path)
|
||||
ok(s.create_manual_asset("Synthetic asset", 100, "2026-09-30", "real_estate", db_path=path))
|
||||
d = draft(path)
|
||||
reviewed = approve(path, d)
|
||||
final = ok(s.finalize_settlement_draft(d["id"], d["version"], reviewed["review_fingerprint"], "completed-close", db_path=path))
|
||||
|
||||
def forbidden_review(*args):
|
||||
raise AssertionError("completed history must not be reinterpreted")
|
||||
|
||||
monkeypatch.setattr(settlement_draft_service, "_review", forbidden_review)
|
||||
assert ok(s.finalize_settlement_draft(d["id"], d["version"], reviewed["review_fingerprint"], "completed-close", db_path=path)) == final
|
||||
assert ok(s.get_settlement_draft(d["id"], db_path=path))["final_response"] == final
|
||||
@@ -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"] == []
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user