Merge pull request 'Release v0.1.8' (#21) from release/v0.1.8 into main

This commit was merged in pull request #21.
This commit is contained in:
2026-10-17 09:35:01 +00:00
61 changed files with 2650 additions and 644 deletions
+59 -1
View File
@@ -44,7 +44,65 @@ def _render_asset_evidence(report: dict) -> None:
def page_assets(db_path: str) -> None: def page_assets(db_path: str) -> None:
st.caption("표시 기준: preview는 live_authority 추정값, close 이후 조회는 closed snapshot 값입니다. 실운영 전 synthetic/validation DB에서 먼저 검증하세요.") st.caption("표시 기준: preview는 live_authority 추정값, close 이후 조회는 closed snapshot 값입니다. 실운영 전 synthetic/validation DB에서 먼저 검증하세요.")
st.info("수기 자산/부채 입력·조회·수정은 schema 16에서 지원하지 않습니다. 계정의 last_balance를 사용하세요.") render_section_label("✍️", "수기 자산/부채 등록")
with st.form("manual_asset_create"):
c1, c2, c3 = st.columns([1.3, 1, 1])
name = c1.text_input("자산/부채명", placeholder="예: 전세보증금, 주택담보대출")
amount = c2.number_input("평가금액(원)", min_value=0, value=0, step=10000)
valuation_date = c3.date_input("평가 기준일")
c4, c5 = st.columns([1, 2])
bucket = c4.selectbox("유동성 버킷", LIQUIDITY_BUCKET_OPTIONS, format_func=lambda x: LIQUIDITY_BUCKET_LABELS.get(x, x))
source_note = c5.text_input("출처 메모", placeholder="예: 월말 앱 잔액, 계약서, synthetic")
memo = st.text_input("운영 메모", value="")
submitted = st.form_submit_button("수기 자산 추가", type="primary")
if submitted:
res = assets_gateway.create_manual_asset(name, int(amount), valuation_date.isoformat(), bucket, source_note=source_note, memo=memo, db_path=db_path)
if res.get("success"):
clear_cache()
st.success("수기 자산을 추가했습니다.")
st.rerun()
else:
st.error(res.get("error", res))
assets_res = assets_gateway.list_manual_assets(active_only=False, db_path=db_path)
assets = assets_res.get("manual_assets", []) if assets_res.get("success") else []
render_section_label("📋", "수기 자산/부채 목록")
if assets:
df = pd.DataFrame(assets)
df["bucket_label"] = df["liquidity_bucket"].map(LIQUIDITY_BUCKET_LABELS).fillna(df["liquidity_bucket"])
df["valuation_display"] = df["valuation_amount"].map(format_krw)
st.dataframe(df[["name", "active", "bucket_label", "valuation_display", "valuation_date", "id"]], width="stretch", height=220)
with st.form("manual_asset_update"):
asset_options = {f"{a['name']} ({a['id']})": a for a in assets}
selected_label = st.selectbox("수정/비활성 대상", list(asset_options.keys()))
selected = asset_options[selected_label]
u1, u2, u3 = st.columns([1.3, 1, 1])
new_name = u1.text_input("이름", value=selected.get("name", ""))
new_amount = u2.number_input("평가금액(원)", min_value=0, value=int(selected.get("valuation_amount") or 0), step=10000, key="asset_update_amount")
new_date = u3.text_input("평가 기준일", value=selected.get("valuation_date", ""))
u4, u5 = st.columns([1, 2])
current_bucket = selected.get("liquidity_bucket", "other") if selected.get("liquidity_bucket", "other") in LIQUIDITY_BUCKET_OPTIONS else "other"
new_bucket = u4.selectbox("버킷", LIQUIDITY_BUCKET_OPTIONS, index=LIQUIDITY_BUCKET_OPTIONS.index(current_bucket), format_func=lambda x: LIQUIDITY_BUCKET_LABELS.get(x, x), key="asset_update_bucket")
new_source = u5.text_input("출처 메모", value=selected.get("source_note", ""))
new_memo = st.text_input("메모", value=selected.get("memo", ""), key="asset_update_memo")
make_active = st.checkbox("활성", value=bool(selected.get("active", True)))
col_update, col_deactivate = st.columns(2)
do_update = col_update.form_submit_button("선택 항목 수정", type="primary")
do_deactivate = col_deactivate.form_submit_button("선택 항목 비활성", type="secondary")
if do_update:
res = assets_gateway.update_manual_asset(selected["id"], name=new_name, valuation_amount=int(new_amount), valuation_date=new_date, liquidity_bucket=new_bucket, source_note=new_source, memo=new_memo, active=make_active, db_path=db_path)
if res.get("success"):
clear_cache(); st.success("수정했습니다."); st.rerun()
else:
st.error(res.get("error", res))
if do_deactivate:
res = assets_gateway.deactivate_manual_asset(selected["id"], db_path=db_path)
if res.get("success"):
clear_cache(); st.warning("비활성 처리했습니다."); st.rerun()
else:
st.error(res.get("error", res))
else:
st.info("등록된 수기 자산/부채가 없습니다.")
st.divider() st.divider()
render_section_label("🔒", "월말 스냅샷 Preview / Close") render_section_label("🔒", "월말 스냅샷 Preview / Close")
@@ -0,0 +1,302 @@
from __future__ import annotations
# ruff: noqa: F405
from datetime import datetime
import pandas as pd
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
def _render_asset_evidence(report: dict) -> None:
warnings = report.get("warnings") or []
exclusions = report.get("excluded_items") or report.get("exclusions") or []
carried = report.get("carried_items") or []
provenance = report.get("provenance") or {}
approval = report.get("approval") or provenance.get("approval") or {}
correction = report.get("correction") or provenance.get("correction") or {}
st.markdown("**근거 상세 (코드 / 출처 식별자)**")
for item in warnings:
st.markdown(f"- 경고 `{item.get('code', '-')}` · 출처 {item.get('source_type', '-')} / {item.get('source_id', '-')} · 대상 {item.get('name', '-')}")
if exclusions:
st.markdown("**제외 상세**")
for item in exclusions:
st.markdown(f"- 제외 코드 `{item.get('reason', '-')}` · 출처 {item.get('source_type', '-')} / {item.get('source_id', '-')} · 대상 {item.get('name', '-')}")
if carried:
st.markdown("**이월 항목**")
for item in carried:
st.markdown(f"- 이월 · 출처 {item.get('source_type', '-')} / {item.get('source_id', '-')} · 평가일 {item.get('valuation_date', '-')}")
if approval:
st.markdown(f"**조건부 승인** · 승인자 {approval.get('approved_by', '-')} · 사유 {approval.get('approval_reason', '-')} · 참조 {approval.get('approval_reference', '-')}")
if correction:
st.markdown(f"**정정** · 원본 run {correction.get('correction_of_run_id', '-')} · 사유 {correction.get('correction_reason', '-')}")
st.caption(f"canonical provenance · 평가일 {provenance.get('valuation_date', '-')} · 검증 {provenance.get('verification_status', '-')} · 유형 {provenance.get('evidence_kind', '-')} · run {provenance.get('snapshot_run_id', '-')}")
def page_legacy_monthly_settlement(db_path: str) -> None:
query_month = st.query_params.get("month", current_kst_month())
if isinstance(query_month, list):
query_month = query_month[0]
month = st.text_input("대상 월", value=str(query_month), key="monthly_settlement_month", help="YYYY-MM 형식")
settlement = overview_gateway.get_monthly_settlement_summary(month, db_path=db_path)
if not settlement.get("success"):
st.error(settlement.get("error", settlement))
st.caption(f"입력한 월: {month}")
return
selected_month = settlement.get("month") or settlement.get("selected_month") or month
basis_date = settlement.get("basis_date") or f"{selected_month}-01"
source_coverage = settlement.get("source_coverage", {}) if isinstance(settlement.get("source_coverage"), dict) else {}
unresolved_blockers = settlement.get("unresolved_blockers", {}) if isinstance(settlement.get("unresolved_blockers"), dict) else {}
unresolved_counts = unresolved_blockers.get("counts", {}) if isinstance(unresolved_blockers.get("counts"), dict) else {}
review_queue_res = overview_gateway.list_cross_transfer_review_queue(limit=300, db_path=db_path)
review_queue_items = get_transfer_review_items(review_queue_res)
review_indicators_res = reconciliation_gateway.get_review_operating_indicators(db_path=db_path)
review_indicators = review_indicators_res.get("indicators", {}) if review_indicators_res.get("success") else {}
reconciliation_res = reconciliation_gateway.get_balance_reconciliation_report(db_path=db_path)
reconciliation_items = reconciliation_res.get("items", []) if reconciliation_res.get("success") else []
reconciliation_attention_items = [
item
for item in reconciliation_items
if str(item.get("status")) in {"REVIEW_NEEDED", "BASELINE_REQUIRED", "NEEDS_RECHECK", "PENDING"}
]
imports_res = overview_gateway.list_ledger_import_batches(limit=1, db_path=db_path)
import_batches = imports_res.get("import_batches", []) if imports_res.get("success") else []
asset_snapshot = overview_gateway.get_asset_snapshot_report(basis_date=basis_date, db_path=db_path)
read_only_stamp = datetime.now(ZoneInfo("Asia/Seoul")).strftime("%Y-%m-%d %H:%M:%S KST")
render_section_label("📘", "월정산 셸")
st.caption(f"선택 월: {selected_month} / 기준일: {basis_date} / 기준 시각: {read_only_stamp} / 모드: 읽기 전용")
st.markdown(
"<span style='display:inline-block; padding:0.15rem 0.7rem; border-radius:9999px; "
"background:#dbeafe; color:#1e40af; font-weight:600; border:1px solid #93c5fd;'>"
"🔒 읽기 전용</span>",
unsafe_allow_html=True,
)
st.info("거래/예산 수정은 각 운영 화면(거래내역, 요약)에서 처리하세요.")
st.markdown("- [거래내역으로 이동](?page=거래내역)")
st.markdown("- [예산 확인으로 이동](?page=요약)")
st.markdown("월간 핵심 KPI")
st.caption("월간 핵심 지표를 통해 수입·지출·잔고 흐름을 점검하세요.")
k1, k2, k3, k4 = st.columns(4)
k1.metric("실제 수입", format_krw(settlement.get("actual_income", 0)))
k2.metric("실제 지출", format_krw(settlement.get("actual_expense", 0)))
k3.metric("순현금흐름", format_krw(settlement.get("net_cashflow", 0)))
k4.metric("저축률", format_percent(settlement.get("savings_rate")))
st.markdown("현금흐름 / 지출 구조")
render_section_label("💸", "현금흐름 / 지출 구조")
cashflow = settlement.get("reports", {}).get("cashflow", {}) if isinstance(settlement.get("reports"), dict) else {}
if cashflow:
c1, c2 = st.columns(2)
by_type = cashflow.get("by_type", {}) or {}
by_type_df = pd.DataFrame(
[{"구분": str(key), "금액": int(value)} for key, value in by_type.items()]
)
if not by_type_df.empty:
by_type_df["금액"] = by_type_df["금액"].map(format_krw)
by_type_df = by_type_df.sort_values("금액", ascending=False)
c1.dataframe(by_type_df, width="stretch", height=170)
by_kind = cashflow.get("by_cashflow_kind", {}) or {}
by_kind_df = pd.DataFrame(
[{"현금흐름 구분": str(key), "금액": int(value)} for key, value in by_kind.items()]
)
if not by_kind_df.empty:
by_kind_df["금액"] = by_kind_df["금액"].map(format_krw)
c2.dataframe(by_kind_df, width="stretch", height=170)
else:
st.info("해당 월 현금흐름 정보가 아직 없습니다.")
st.markdown("예산 대비 점검")
render_section_label("🧮", "예산 대비 점검")
budget_report = settlement.get("reports", {}).get("budget", {}) if isinstance(settlement.get("reports"), dict) else {}
b1, b2, b3 = st.columns(3)
b1.metric("예산 항목 수", f"{int(budget_report.get('count', 0))}건")
b2.metric("월 예산", format_krw(budget_report.get("total_budget", 0)))
b3.metric("예산 초과 항목", f"{int(settlement.get('budget_overrun_count', 0))}건")
budget_items = budget_report.get("items", []) if isinstance(budget_report, dict) else []
if budget_items:
budget_df = pd.DataFrame(budget_items)
budget_df["예산"] = budget_df["budget"].map(format_krw)
budget_df["실제"] = budget_df["actual"].map(format_krw)
budget_df["잔여"] = budget_df["remaining"].map(format_krw)
budget_df["사용률"] = budget_df["usage_rate"].map(format_percent)
st.dataframe(
budget_df[["category", "subcategory", "person", "예산", "실제", "잔여", "사용률", "status"]],
width="stretch",
height=220,
)
else:
st.info(f"{selected_month} 예산 항목이 없습니다.")
render_section_label("⚠️", "정산 전 운영 블로커 점검")
unresolved_blocker_count = int(settlement.get("unresolved_blocker_count", 0))
unresolved_detail_count = unresolved_counts.get("unresolved_count", unresolved_blocker_count)
st.caption(f"미해결 블로커: {int(unresolved_detail_count)}건")
render_section_label("🧯", "정산 전 검토")
review_count = int(unresolved_counts.get("pending_review_count", len(review_queue_items) if isinstance(review_queue_items, list) else 0))
unreconciled_count = int(unresolved_counts.get("unreconciled_account_count", len(reconciliation_attention_items)) if isinstance(unresolved_counts, dict) else len(reconciliation_attention_items))
uncategorized_backlog = int(unresolved_counts.get("uncategorized_recommendation_backlog", 0)) if isinstance(unresolved_counts, dict) else 0
x1, x2, x3 = st.columns(3)
x1.metric("미처리 review", f"{review_count}건")
x2.metric("미대조 계좌", f"{unreconciled_count}건")
x3.metric("미분류 추천 대기", f"{uncategorized_backlog}건")
if review_queue_items:
st.markdown("확인필요 큐 최근 항목")
for item in review_queue_items[:5]:
tx_date = item.get("transaction_date") or item.get("reviewed_at") or "-"
merchant = item.get("merchant") or item.get("counterparty") or "-"
amount = format_krw(item.get("amount") if item.get("amount") is not None else item.get("difference"))
status = item.get("review_status") or item.get("status") or "pending"
st.markdown(f"- {tx_date} · {merchant} · {amount} · {status}")
else:
st.caption("확인필요 큐는 비어 있습니다.")
st.markdown("- [확인필요 큐로 이동](?page=확인필요 큐)")
render_section_label("📥", "거래 반영 / 계좌 상태")
source_imported_at = source_coverage.get("imports", {}).get("latest_imported_at")
t1, t2, t3 = st.columns(3)
t1.metric("최근 Import 건수", f"{int(source_coverage.get('imports', {}).get('recent_batch_count', 0))}건")
t2.metric("최근 Import 시각", source_imported_at or "없음")
t3.metric("보류 후 처리", str(review_indicators.get("held_recheck_count", "-") or "0") + "건")
if import_batches:
latest_import = import_batches[0]
st.caption(f"최근 Import: 출처 {latest_import.get('source', '알 수 없음')} / 수집일 {latest_import.get('imported_at', '미기록')}")
else:
st.info("최근 Import 배치가 없습니다.")
st.markdown("- [계좌 화면에서 잔액대조 확인](?page=계좌)")
st.markdown("- [Import 화면에서 이력 확인](?page=Import)")
representative_items = list(reconciliation_items[:3]) if isinstance(reconciliation_items, list) else []
st.markdown("대표 계좌 반영 상태")
if representative_items:
reconciliation_status_label = {
"OK": "정상",
"REVIEW_NEEDED": "미대조",
"BASELINE_REQUIRED": "기준 잔액 미입력",
"NEEDS_RECHECK": "재확인 필요",
"PENDING": "대기",
}
for idx, item in enumerate(representative_items, start=1):
raw_status = str(item.get("status", "-") or "-")
synced_at = item.get("last_synced_at") or item.get("baseline_at") or item.get("calculation_basis_at") or "-"
baseline_balance = item.get("baseline_balance")
display_balance = baseline_balance if baseline_balance is not None else item.get("calculated_balance")
item_line = (
f"- [{idx}] 계좌명: {item.get('account_name', '-')}, "
f"기관: {item.get('institution', item.get('bank', '미기록'))}, "
f"유형: {item.get('account_type', item.get('type', '미기록'))}, "
f"최근 동기화: {synced_at}, "
f"잔액: {format_krw(display_balance)}, "
f"상태: {reconciliation_status_label.get(raw_status, raw_status)}"
)
st.markdown(
item_line,
)
st.markdown(f"상태: {reconciliation_status_label.get(raw_status, '정상' if idx == 1 else raw_status)}")
else:
st.caption("잔액대조 대상 계좌가 현재 없습니다.")
st.markdown("- [미분류 페이지](?page=미분류)")
render_section_label("🏦", "자산 / 순자산 마감 상태")
latest_asset_status = "미확인"
if asset_snapshot.get("success"):
latest_asset_status = "마감 완료" if bool(asset_snapshot.get("is_closed_snapshot")) else "미마감"
warnings = asset_snapshot.get("warnings") if isinstance(asset_snapshot, dict) else []
missing_valuation_items = (
asset_snapshot.get("excluded_items", asset_snapshot.get("missing_valuation_items", []))
if isinstance(asset_snapshot, dict)
else []
)
warning_text = "경고 없음"
if warnings or missing_valuation_items:
warning_text = f"경고 {len(warnings or [])}건 / 제외 {len(missing_valuation_items or [])}건"
m1, m2, m3, m4 = st.columns(4)
m1.metric("자산 합계", format_krw(asset_snapshot.get("total_assets") if isinstance(asset_snapshot, dict) else source_coverage.get("assets", {}).get("total_assets")))
m2.metric("총부채", format_krw(asset_snapshot.get("total_liabilities") if isinstance(asset_snapshot, dict) else source_coverage.get("assets", {}).get("total_liabilities")))
m3.metric("순자산", format_krw(
asset_snapshot.get("net_worth")
if isinstance(asset_snapshot, dict)
else source_coverage.get("assets", {}).get("net_worth")
))
m4.metric("자산 마감 상태", latest_asset_status)
st.caption(f"자산 상세: {warning_text}")
st.caption(f"마감 기준일: {asset_snapshot.get('basis_date', basis_date)} · 선택 정책: {asset_snapshot.get('selection_policy', 'exact')} · close_status: {asset_snapshot.get('close_status', '미확인')}")
_render_asset_evidence(asset_snapshot)
close_metadata = asset_snapshot.get("close_metadata", {}) if isinstance(asset_snapshot, dict) else {}
if close_metadata.get("approved_by") or close_metadata.get("approval_reason"):
st.caption(f"조건부 승인: {close_metadata.get('approved_by', '-')} · {close_metadata.get('approval_reason', '-')} · {close_metadata.get('approval_reference', '-')}")
st.markdown("- [자산/마감 화면에서 스냅샷 확인](?page=자산/마감)")
render_section_label("📜", "월정산 결과 / Evidence")
st.markdown("### 읽기 전용 근거 요약")
evidence_close_status = "마감 완료" if bool(asset_snapshot.get("is_closed_snapshot")) else "미마감"
import_summary = source_coverage.get("imports", {}) if isinstance(source_coverage, dict) else {}
evidence_rows = [
f"- close 준비 상태: {evidence_close_status}",
f"- source coverage: 최근 Import 배치 {int(import_summary.get('recent_batch_count', 0))}건",
f"- 최근 Import: {import_summary.get('latest_imported_at') or '없음'}",
f"- 자산/마감: {evidence_close_status} ({asset_snapshot.get('basis_date', basis_date)})",
f"- 미해결 블로커: {int(unresolved_detail_count)}건",
]
for row in evidence_rows:
st.markdown(row)
st.markdown("### 다음 액션")
st.caption("이번 범위에는 월정산 close 저장/실행이 포함되지 않습니다")
action_candidates = [
"예산 조정 후보: 월말 예산 초과/부족 항목이 있는지 점검하고 요약 페이지에서 예산을 갱신하세요.",
"분류/검토 규칙 후보: 미분류 추천 및 확인필요 큐에서 규칙 후보를 먼저 점검하세요.",
"대조/Import/백업 후속 작업: 계좌 잔액대조, 최근 Import 이력, 백업 최신화 상태를 함께 확인하세요.",
"수기 자산/마감 화면에서 기준일·평가 상태를 정합성 확인 후 운영 승인 여부를 결정하세요.",
]
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=요약)")
+7 -1
View File
@@ -188,6 +188,10 @@ def _render_monthly_list(db_path: str) -> None:
def page_monthly_settlement(db_path: str) -> None: def page_monthly_settlement(db_path: str) -> None:
if _query_value("legacy") == "1":
from .legacy_monthly_settlement import page_legacy_monthly_settlement
page_legacy_monthly_settlement(db_path)
return
settlement_id = _query_value("id") settlement_id = _query_value("id")
legacy_month = _query_value("month") legacy_month = _query_value("month")
if not settlement_id and legacy_month: if not settlement_id and legacy_month:
@@ -198,7 +202,8 @@ def page_monthly_settlement(db_path: str) -> None:
st.query_params["id"] = settlement_id st.query_params["id"] = settlement_id
st.query_params.pop("month", None) st.query_params.pop("month", None)
else: else:
st.error("월정산을 찾을 수 없습니다.") from .legacy_monthly_settlement import page_legacy_monthly_settlement
page_legacy_monthly_settlement(db_path)
return return
if settlement_id: if settlement_id:
year = _query_value("year", "전체") year = _query_value("year", "전체")
@@ -271,6 +276,7 @@ def page_monthly_settlement(db_path: str) -> None:
_render_monthly_detail(db_path, month) _render_monthly_detail(db_path, month)
return return
_render_monthly_list(db_path) _render_monthly_list(db_path)
st.link_button("기존 자산 마감 · 초안 및 이력", "?page=monthly_settlement&legacy=1")
def _account_evaluation_totals(rows: list[dict]) -> dict: def _account_evaluation_totals(rows: list[dict]) -> dict:
+45 -1
View File
@@ -31,6 +31,39 @@ def _authenticated_actor() -> str | None:
return get_authenticated_actor() return get_authenticated_actor()
def _open_settlement_history(draft_id: str, run_id: str = "") -> None:
"""Open the read-only history panel and prefer the completed draft's run."""
st.session_state["settlement_history_open_draft"] = draft_id
if run_id:
st.session_state[f"settlement_history_run_{draft_id}"] = run_id
def _render_settlement_history(db_path: str | None, month: str, draft_id: str) -> None:
"""List immutable runs and fetch the exact record selected by the operator."""
listed = gateway.list_monthly_settlement_history(month, db_path=db_path)
if not listed.get("success"):
st.error(f"마감 이력을 불러오지 못했습니다 ({listed.get('error_code', 'operation_failed')}).")
return
history = listed.get("history", [])
if not isinstance(history, list):
history = []
runs = [str(item.get("run_id")) for item in history if isinstance(item, dict) and item.get("run_id")]
if not runs:
st.info("저장된 불변 마감 이력이 없습니다.")
return
selection_key = f"settlement_history_run_{draft_id}"
if st.session_state.get(selection_key) not in runs:
st.session_state[selection_key] = runs[0]
selected_run = st.selectbox("불변 마감 run 선택", runs, key=selection_key)
st.dataframe(history, hide_index=True, use_container_width=True)
record = gateway.get_monthly_settlement_history_record(selected_run, db_path=db_path)
if not record.get("success"):
st.error(f"마감 기록을 불러오지 못했습니다 ({record.get('error_code', 'operation_failed')}).")
return
st.caption("선택한 run의 저장 시점 불변 기록")
st.json(record.get("history", {}))
def _create_settlement_dialog(db_path: str | None, month: str, correction_run: str = "") -> None: def _create_settlement_dialog(db_path: str | None, month: str, correction_run: str = "") -> None:
@st.dialog("정산 진행") @st.dialog("정산 진행")
def dialog() -> None: def dialog() -> None:
@@ -66,7 +99,6 @@ def _create_settlement_dialog(db_path: str | None, month: str, correction_run: s
result = gateway.create_settlement_draft(target_month, actor, key, memo=memo, result = gateway.create_settlement_draft(target_month, actor, key, memo=memo,
correction_of_run_id=correction_run, correction_reason=reason, db_path=db_path) correction_of_run_id=correction_run, correction_reason=reason, db_path=db_path)
if result.get("success"): if result.get("success"):
created_id = (result.get("draft") or {}).get("id", "")
# The selectbox is the authoritative selection on the next # The selectbox is the authoritative selection on the next
# rerun; seed its widget state, not a separate shadow key. # rerun; seed its widget state, not a separate shadow key.
st.query_params["page"] = "월정산" st.query_params["page"] = "월정산"
@@ -147,6 +179,18 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
st.write(label, final.get(key) if final.get(key) is not None else "미보존") st.write(label, final.get(key) if final.get(key) is not None else "미보존")
rows = final.get("cashflow_items", []) 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)]) 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.button(
"마감내역보기",
key=f"closed_history_{selected_id}",
on_click=_open_settlement_history,
args=(selected_id, str(draft.get("final_run_id") or "")),
)
if st.session_state.get("settlement_history_open_draft") == selected_id:
_render_settlement_history(db_path, str(draft.get("month") or month_value), selected_id)
if draft.get("final_run_id") and st.button("정정시작", disabled=not actor, key=f"correction_start_{selected_id}"): if draft.get("final_run_id") and st.button("정정시작", disabled=not actor, key=f"correction_start_{selected_id}"):
_create_settlement_dialog(db_path, draft.get("month", month_value), str(draft["final_run_id"])) _create_settlement_dialog(db_path, draft.get("month", month_value), str(draft["final_run_id"]))
st.caption("종료된 초안의 증거는 수정하지 않습니다. 정정이 필요하면 이전 마감과 사유를 연결해 새 초안을 생성하세요.") st.caption("종료된 초안의 증거는 수정하지 않습니다. 정정이 필요하면 이전 마감과 사유를 연결해 새 초안을 생성하세요.")
+3 -3
View File
@@ -34,7 +34,7 @@ spec:
type: RuntimeDefault type: RuntimeDefault
initContainers: initContainers:
- name: init-db - name: init-db
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.7 image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.8
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
command: command:
- python - python
@@ -62,7 +62,7 @@ spec:
mountPath: /tmp mountPath: /tmp
containers: containers:
- name: mcp - name: mcp
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.7 image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.8
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
env: env:
- name: JARCA_LEDGER_DB_PATH - name: JARCA_LEDGER_DB_PATH
@@ -104,7 +104,7 @@ spec:
- name: mcp-tmp - name: mcp-tmp
mountPath: /tmp mountPath: /tmp
- name: dashboard - name: dashboard
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.7 image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.8
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
command: command:
- uvicorn - uvicorn
+33 -16
View File
@@ -4,7 +4,7 @@
계좌 평가 증거, 수동 자산, append-only 자산 마감과 기존 ledger entities의 실제 SQLite 정본을 기록한다. 계좌 평가 증거, 수동 자산, append-only 자산 마감과 기존 ledger entities의 실제 SQLite 정본을 기록한다.
- 포함 범위: `accounts`, `transactions`, `manual_assets`, `account_valuations`, `account_snapshot`, `asset_snapshot_runs`, `reconciliation_sessions`, `review_decisions`, `import_batches`, `schema_meta` - 포함 범위: `accounts`, `transactions`, `manual_assets`, `account_valuations`, `asset_snapshots`, `asset_snapshot_runs`, `reconciliation_sessions`, `review_decisions`, `import_batches`, `schema_meta`
- 제외 범위: auth cookie/session, external price/FX store, inferred transactions, actual credentials/data - 제외 범위: auth cookie/session, external price/FX store, inferred transactions, actual credentials/data
## 정본 엔터티 ## 정본 엔터티
@@ -21,7 +21,7 @@
|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|
| `id` | TEXT | 필수 | 없음 | generated account id | 식별자 | 아니오 | | `id` | TEXT | 필수 | 없음 | generated account id | 식별자 | 아니오 |
| `name` | TEXT | 필수 | 없음 | non-empty | 없음 | 아니오 | | `name` | TEXT | 필수 | 없음 | non-empty | 없음 | 아니오 |
| `type` | TEXT | 필수 | `bank` | bank/card/cash/investment/loan/other/real_estate | enum | 아니오 | | `type` | TEXT | 필수 | `bank` | bank/card/cash/investment/loan/real_estate/other | enum | 아니오 |
| `currency` | TEXT | 필수 | `KRW` | supported currency | ISO code | 아니오 | | `currency` | TEXT | 필수 | `KRW` | supported currency | ISO code | 아니오 |
| `last_balance` | INTEGER | 선택 | NULL | exact integer | currency unit | 아니오 | | `last_balance` | INTEGER | 선택 | NULL | exact integer | currency unit | 아니오 |
| `last_balance_basis_date` | TEXT | 선택 | `''` | YYYY-MM-DD or unknown | date of the evidence represented by `last_balance`; empty/unknown is explicit | 아니오 | | `last_balance_basis_date` | TEXT | 선택 | `''` | YYYY-MM-DD or unknown | date of the evidence represented by `last_balance`; empty/unknown is explicit | 아니오 |
@@ -169,11 +169,11 @@
## 검증 ## 검증
- 정상 데이터: `tests/test_account_valuation_service_actual.py`, `tests/test_account_snapshot_linking.py` - 정상 데이터: `tests/test_account_valuations.py`, `tests/test_asset_snapshots.py`
- 경계 데이터: NULL/0, stale carry-forward, correction, empty snapshot tables - 경계 데이터: NULL/0, stale carry-forward, correction, empty snapshot tables
- 오류 데이터: invalid currency/kind, missing loan definition, idempotency/correction conflict - 오류 데이터: invalid currency/kind, missing loan definition, idempotency/correction conflict
- 저장 후 재조회: service list/select/report and contract regression tests - 저장 후 재조회: service list/select/report and contract regression tests
- 마이그레이션: `test_*schema*` tests plus `python -m pytest tests/test_account_valuation_service_actual.py tests/test_account_snapshot_linking.py` - 마이그레이션: `test_*schema*` tests plus `python -m pytest tests/test_account_valuations.py tests/test_asset_snapshots.py`
## settlement-drafts — 월정산 초안 ## settlement-drafts — 월정산 초안
@@ -193,13 +193,7 @@ Frozen/public evidence retains `valuation_date`, original `recorded_at`, derived
Review fingerprint binds draft version, target/inclusion state, full valuation/manual semantic evidence, correction child, warnings, amounts/classification/date and prior run. Approved fingerprint/version become invalid on mutation or external drift. Finalize checks idempotency replay before active status, then validates version, approval, fingerprint, current evidence and correction parent, and inserts run+rows+draft completion/link in one `BEGIN IMMEDIATE`. Rollback on any SQLite failure; replay returns persisted response. 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. 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.
# Current schema 15 and historical compatibility # Issue #18 — schema 15 comprehensive settlement additions
The local development source currently declares `SCHEMA_VERSION = "15"`. The published `v0.1.7` tag remains schema 14; this working tree is a dirty development state and does not establish a published schema-15 release. Historical schema 11–14 notes below are retained as migration context, not current schema claims.
Schema 15 renames `account_snapshot.month_settlement_id` in place to nullable `monthly_settlement_id`, preserving rows, and maps settlement cashflow rows to the canonical `monthly_settlement(id)` parent. The mapping is single-month and fails transactionally when ambiguous or missing. `approved_cashflow_fingerprint` is nullable legacy-compatible approval metadata; it is not a reserved future physical column.
# Issue #18 — schema 14 comprehensive settlement additions
### asset_snapshot_runs cashflow field contract ### asset_snapshot_runs cashflow field contract
@@ -232,7 +226,8 @@ All twelve comprehensive-run columns are nullable with no SQL default. This pres
| Column | Type | Nullability | Default | | Column | Type | Nullability | Default |
|---|---|---|---| |---|---|---|---|
| `id` | TEXT | NULL | none | | `id` | TEXT | NULL | none |
| `monthly_settlement_id` | TEXT | NOT NULL | none | | `monthly_settlement_id` | TEXT | NULL | none |
| `settlement_run_id` | TEXT | NULL | none |
| `cashflow_kind` | TEXT | NOT NULL | none | | `cashflow_kind` | TEXT | NOT NULL | none |
| `category` | TEXT | NOT NULL | `''` | | `category` | TEXT | NOT NULL | `''` |
| `subcategory` | TEXT | NOT NULL | `''` | | `subcategory` | TEXT | NOT NULL | `''` |
@@ -240,7 +235,7 @@ All twelve comprehensive-run columns are nullable with no SQL default. This pres
| `transaction_count` | INTEGER | NOT NULL | none | | `transaction_count` | INTEGER | NOT NULL | none |
| `currency` | TEXT | 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. `monthly_settlement_id` references `monthly_settlement(id)` ON DELETE RESTRICT. The unique key is `(monthly_settlement_id, cashflow_kind, category, subcategory, currency)`. `transaction_count > 0` and amount/count are integers; `idx_settlement_cashflow_snapshots_month` indexes the canonical month. `id` is logically required although SQLite reports no explicit NOT NULL flag for this TEXT primary key. Both parent columns are individually nullable: `monthly_settlement_id` references `monthly_settlement(id)` ON DELETE RESTRICT, and `settlement_run_id` references `asset_snapshot_runs(id)` ON DELETE RESTRICT. An exclusive-parent CHECK requires exactly one parent to be non-NULL. Separate UNIQUE constraints apply to `(monthly_settlement_id,cashflow_kind,category,subcategory,currency)` and `(settlement_run_id,cashflow_kind,category,subcategory,currency)`. `transaction_count > 0` and amount/count are integers. Fresh schema uses `idx_settlement_cashflow_snapshots_month` for canonical-month lookup; upgraded run-parent storage retains `idx_settlement_cashflow_snapshots_run` for legacy-run lookup.
### Comprehensive snapshot evidence inputs ### Comprehensive snapshot evidence inputs
@@ -250,10 +245,32 @@ Asset inputs preserve `source_id`, `source_type`, `valuation_id`, `valuation_dat
`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. `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`, `monthly_settlement_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 `monthly_settlement.id` with RESTRICT, unique `(monthly_settlement_id,cashflow_kind,category,subcategory,currency)`, and month lookup index. Rows are grouped details for exactly one canonical month. `settlement_cashflow_snapshots`: nine columns `id`, `monthly_settlement_id`, `settlement_run_id`, `cashflow_kind`, `category`, `subcategory`, `amount`, `transaction_count`, `currency`; `cashflow_kind` is income/expense/excluded, `amount` signed INTEGER, count positive, and category fields are NOT NULL (empty string is explicit). The nullable parent columns reference `monthly_settlement.id` and `asset_snapshot_runs.id` with RESTRICT, while the exclusive-parent CHECK requires exactly one parent. The table has separate UNIQUE constraints on `(monthly_settlement_id,cashflow_kind,category,subcategory,currency)` and `(settlement_run_id,cashflow_kind,category,subcategory,currency)`, so rows remain grouped under exactly one canonical month or legacy run without merging the two parent domains.
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. 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.
### monthly_settlement approval cashflow fingerprint
`monthly_settlement.approved_cashflow_fingerprint` is a nullable `TEXT` field used by the current approval/finalization flow to retain the approved cashflow fingerprint. It has no default; historical monthly settlements may remain `NULL`. It is distinct from the draft `review_fingerprint`. ### Additive schema 15 monthly lifecycle
`monthly_settlement` owns canonical month status, version, approvals and nullable
`approved_cashflow_fingerprint`. `account_snapshot.monthly_settlement_id` is a
nullable FK for canonical account evaluations. Legacy `asset_snapshots`,
`asset_snapshot_runs`, `manual_assets`, and `account_valuations` retain their
rows and contracts. `settlement_cashflow_snapshots` has nullable
`settlement_run_id` and `monthly_settlement_id` foreign keys, with exactly one
parent required and separate uniqueness constraints. Run corrections retain
separate frozen groups; migration never guesses a monthly parent from dates.
Schema 11–14 upgrades and schema 15 initialization are atomic and idempotent;
failed copies or foreign-key checks roll back all DDL and metadata.
### Immutable monthly settlement history read model
`asset_snapshot_runs` is the header source for append-only immutable settlement history.
Each public history row identifies `run_id`, `settlement_month`, frozen draft
`version`, `closed_at`, `close_status`, and optional correction lineage through
`correction_of_run_id` (published as `correction_target`) plus
`correction_reason` (published as `reason`). The exact-record read additionally
returns the frozen basis date and persisted cashflow/net-worth totals. Reads are
ordered newest-first and never synthesize totals from current transactions,
account values, or mutable draft state. A correction appends a new run and
retains the referenced run unchanged.
+32 -3
View File
@@ -341,7 +341,7 @@ loan accounts require representative_liability_definition for `loan`, `loan_bala
loan valuations map to liabilities and non-loan valuations map to assets. The output mapping preserves the existing envelopes and signatures: `success` remains the outcome field, failures include `error_code` and `error`, and validation or conflict failures do not partially write. loan valuations map to liabilities and non-loan valuations map to assets. The output mapping preserves the existing envelopes and signatures: `success` remains the outcome field, failures include `error_code` and `error`, and validation or conflict failures do not partially write.
Account current-cache writes keep `last_balance_basis_date` (optional `YYYY-MM-DD`) separate from `last_synced_at` (execution/sync timestamp). Account types include `bank`, `card`, `cash`, `investment`, `loan`, `other`, and `real_estate`; a new `real_estate` account defaults its liquidity bucket to `real_estate` when no bucket is supplied. `create_ledger_account` keeps the established positional `db_path` argument and adds `last_balance_basis_date` after it as an optional keyword. `update_ledger_account` accepts the same field through its existing keyword-field boundary. A balance with no explicit basis advances only the current display cache, leaves the basis unknown, and creates no historical candidate. A dated older non-loan balance appends historical evidence but cannot roll back a newer current cache. loan cache writes without an explicit liability definition do not append a historical candidate; they preserve current-cache and import behavior without inferring a definition. Import and account writers use deterministic candidate keys. Account current-cache writes keep `last_balance_basis_date` (optional `YYYY-MM-DD`) separate from `last_synced_at` (execution/sync timestamp). `create_ledger_account` keeps the established positional `db_path` argument and adds `last_balance_basis_date` after it as an optional keyword. `update_ledger_account` accepts the same field through its existing keyword-field boundary. A balance with no explicit basis advances only the current display cache, leaves the basis unknown, and creates no historical candidate. A dated older non-loan balance appends historical evidence but cannot roll back a newer current cache. loan cache writes without an explicit liability definition do not append a historical candidate; they preserve current-cache and import behavior without inferring a definition. Import and account writers use deterministic candidate keys.
### shared contract and adapter limits ### shared contract and adapter limits
@@ -392,7 +392,7 @@ preview_asset_snapshot_close(basis_date="2026-06-30", selection_policy="exact")
## 검증 추가 ## 검증 추가
- 정상 계약: `tests/test_account_valuation_service_actual.py`, `tests/test_account_snapshot_linking.py`, `tests/test_comprehensive_settlement_adapters.py` - 정상 계약: `tests/test_account_valuations.py`, `tests/test_asset_snapshots.py`, `tests/test_contract_regression.py`
- 실패 계약: idempotency/correction/conditional approval tests and source validation - 실패 계약: idempotency/correction/conditional approval tests and source validation
- 경계 계약: exact/as_of, NULL/0, future/unverified candidate, correction/re-close - 경계 계약: exact/as_of, NULL/0, future/unverified candidate, correction/re-close
- 구현·스키마·예시 정합성: `tests/test_requirement_docs.py`, actual service/MCP/gateway symbols - 구현·스키마·예시 정합성: `tests/test_requirement_docs.py`, actual service/MCP/gateway symbols
@@ -403,7 +403,7 @@ preview_asset_snapshot_close(basis_date="2026-06-30", selection_policy="exact")
The application contracts are these actual signatures (defaults included): The application contracts are these actual signatures (defaults included):
```python ```python
register_account_valuation(account_id: str, basis_date: str, amount: int, currency: str = "KRW", valuation_kind: str = "balance", source_type: str = "manual", source_ref: str = "", verification_status: str = "unverified", memo: str = "", idempotency_key: str = "", as_of_at: str | None = None, timezone: str | None = None, cost_basis: int | None = None, net_contributed: int | None = None, reported_principal_kind: str | None = None, reported_principal_label: str | None = None, profit: int | None = None, return_rate: float | None = None, return_basis: str | None = None, principal: int | None = None, interest: int | None = None, fee: int | None = None, representative_liability_definition: str | None = None, db_path: str | None = None, supersedes_id: str | None = None, correction_reason: str = "") -> dict[str, Any] register_account_valuation(account_id: str, basis_date: str, amount: int, currency: str = "KRW", valuation_kind: str = "balance", source_type: str = "manual", source_ref: str = "", verification_status: str = "unverified", memo: str = "", idempotency_key: str = "", as_of_at: str | None = None, timezone: str | None = None, cost_basis: int | None = None, net_contributed: int | None = None, reported_principal_kind: str | None = None, reported_principal_label: str | None = None, profit: int | None = None, return_rate: float | None = None, return_basis: str | None = None, principal: int | None = None, interest: int | None = None, fee: int | None = None, representative_liability_definition: str | None = None, db_path: str | None = None, supersedes_id: str | None = None, correction_reason: str = "", *, monthly_settlement_id: str | None = None, liquidity_bucket: str | None = None, selection_policy: str = "manual", valuation_source: str = "manual", source_note: str = "") -> dict[str, Any]
list_account_valuations(account_id: str, include_rejected: bool = True, db_path: str | None = None) -> dict[str, Any] list_account_valuations(account_id: str, include_rejected: bool = True, db_path: str | None = None) -> dict[str, Any]
select_account_valuation(account_id: str, basis_date: str, as_of: bool = False, currency: str | None = None, valuation_kind: str | None = None, db_path: str | None = None) -> dict[str, Any] select_account_valuation(account_id: str, basis_date: str, as_of: bool = False, currency: str | None = None, valuation_kind: str | None = None, db_path: str | None = None) -> dict[str, Any]
correct_account_valuation(valuation_id: str, amount: int, correction_reason: str, idempotency_key: str = "", db_path: str | None = None, **fields: Any) -> dict[str, Any] correct_account_valuation(valuation_id: str, amount: int, correction_reason: str, idempotency_key: str = "", db_path: str | None = None, **fields: Any) -> dict[str, Any]
@@ -527,3 +527,32 @@ Saved reader root example:
``` ```
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. 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.
Schema 15 preserves the positional v0.1.7 valuation API and manual asset and
snapshot run APIs. `register_account_valuation(..., monthly_settlement_id=...)`
explicitly selects canonical unverified account snapshot registration; omitted
monthly identity uses legacy valuation evidence and correction semantics.
Canonical monthly review/approval/close APIs remain separate from legacy draft
finalization. Neither finalizer rewrites the other's frozen records.
## Public immutable monthly settlement history
The read-only application contracts are:
```python
list_monthly_settlement_history(month: str = "", db_path: str | None = None) -> dict[str, Any]
get_monthly_settlement_history_record(run_id: str, db_path: str | None = None) -> dict[str, Any]
```
Both operations are public MCP tools. MCP signatures omit internal `db_path`:
the list schema exposes optional `month`, while the exact-record schema requires
`run_id`. Successful list responses contain `history`, normalized `month`, and
`read_only=true`; successful exact reads contain one `history` record and
`read_only=true`. The list is newest-first and exposes run/version, close time,
close status, and correction linkage without consulting or rewriting live data.
The authenticated Dashboard wrapper is unchanged. In the completed/cancelled
draft panel, `마감내역보기` opens read-only history state, lists immutable runs
for the draft month, lets the operator select a run, and fetches that exact
record. Opening or changing this selection performs no lifecycle mutation.
+2 -2
View File
@@ -140,8 +140,8 @@ jarca_ledger_mcp/
| `jarca_ledger_mcp/application/account_valuation_service.py` | source | validation, selection, idempotency, correction | named valuation functions | SQLite through shared db | source review + focused tests | developer | | `jarca_ledger_mcp/application/account_valuation_service.py` | source | validation, selection, idempotency, correction | named valuation functions | SQLite through shared db | source review + focused tests | developer |
| `jarca_ledger_mcp/application/assets_service.py` | source | preview, formula, close/correction | `preview_asset_snapshot_close`, `close_asset_snapshot` | snapshot transaction | source review + focused tests | developer | | `jarca_ledger_mcp/application/assets_service.py` | source | preview, formula, close/correction | `preview_asset_snapshot_close`, `close_asset_snapshot` | snapshot transaction | source review + focused tests | developer |
| `jarca_ledger_mcp/infrastructure/sqlite/migrations.py` | source | schema 12 additive migration | `init_db`, `migrate_db` | no UI/MCP imports | migration review | developer | | `jarca_ledger_mcp/infrastructure/sqlite/migrations.py` | source | schema 12 additive migration | `init_db`, `migrate_db` | no UI/MCP imports | migration review | developer |
| `tests/test_account_valuation_service_actual.py` | test | valuation and account snapshot contracts | pytest | synthetic DB | paired with source | developer | | `tests/test_account_valuations.py` | test | valuation contracts | pytest | synthetic DB | paired with source | developer |
| `tests/test_account_snapshot_linking.py` | test | preview/close/report contracts | pytest | synthetic DB | paired with source | developer | | `tests/test_asset_snapshots.py` | test | preview/close/report contracts | pytest | synthetic DB | paired with source | developer |
| `docs/data-model.md` | canonical doc | entity/schema authority | document | no secrets | update with schema | developer | | `docs/data-model.md` | canonical doc | entity/schema authority | document | no secrets | update with schema | developer |
### 의존 방향 추가 ### 의존 방향 추가
@@ -15,8 +15,6 @@
# REQ-003 — 계좌 기준일 평가 이력과 자산 마감 연동 # REQ-003 — 계좌 기준일 평가 이력과 자산 마감 연동
> **현재 구현 정합성 안내 (비규범적):** 승인된 REQ-003와 acceptance criteria는 보존한다. 현재 local dirty 개발 상태는 `SCHEMA_VERSION = "15"`이며, `v0.1.7` tag는 schema 14를 가리킨다. 따라서 이 문서는 published schema-15 release를 주장하지 않는다. `account_snapshot` 및 `monthly_settlement_id`는 현재 정본 명칭이고, schema 11–14 및 이전 명칭은 역사적 호환성 맥락이다. 이 안내는 새 acceptance criterion이 아니다.
## 1. 목적 ## 1. 목적
계좌의 기준일 평가 증거를 append-only 이력으로 저장·선택하고, 검증 가능한 자산만 자산/마감 및 월정산의 역사적 순자산에 반영한다. 계좌의 기준일 평가 증거를 append-only 이력으로 저장·선택하고, 검증 가능한 자산만 자산/마감 및 월정산의 역사적 순자산에 반영한다.
@@ -29,7 +27,7 @@
### 포함 ### 포함
- `account_valuations`, `asset_snapshot_runs`, `account_snapshot` 확장과 exact/as_of 선택 - `account_valuations`, `asset_snapshot_runs`, `asset_snapshots` 확장과 exact/as_of 선택
- idempotency, append-only correction, 투자 optional metrics, loan 대표 부채 - idempotency, append-only correction, 투자 optional metrics, loan 대표 부채
- read-only preview, explicit approval conditional close, immutable evidence, correction/re-close - read-only preview, explicit approval conditional close, immutable evidence, correction/re-close
- 계좌·import 후보, reconciliation, MCP·Dashboard·월정산 공통 read model - 계좌·import 후보, reconciliation, MCP·Dashboard·월정산 공통 read model
@@ -54,7 +52,7 @@
### REQ-003.01 — additive 이력과 legacy 보존 ### REQ-003.01 — additive 이력과 legacy 보존
schema 11의 테이블·필드·거래를 삭제하거나 추측 이주하지 않고 schema 13의 `account_valuations`, `asset_snapshot_runs`를 추가하며 `account_snapshot`에 필요한 필드를 additive하게 확장한다. migration은 idempotent다. Issue #18의 유일한 schema 확장은 additive/idempotent 13→14이며 기존 자산 전용 run의 미저장 종합 총계는 NULL로 유지하고 backfill하지 않는다. 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 - 인터페이스: 기존 service/MCP 응답 envelope 유지, 신규 valuation/snapshot fields additive
@@ -133,7 +131,7 @@ preview는 DB를 쓰지 않고 `complete_exact`, `incomplete_draft`, `conditiona
- 영속성: `correction_of_run_id`, `is_correction`, `closed_at` - 영속성: `correction_of_run_id`, `is_correction`, `closed_at`
- 후속 사용 주체: timeseries와 월정산 - 후속 사용 주체: timeseries와 월정산
- 마이그레이션: asset snapshot columns/indexes - 마이그레이션: asset snapshot columns/indexes
- 호환성: legacy `account_snapshot` rows are readable - 호환성: legacy `asset_snapshots` rows are readable
- 배포 전제 조건: close transaction atomicity - 배포 전제 조건: close transaction atomicity
### REQ-003.08 — reconciliation과 monthly propagation ### REQ-003.08 — reconciliation과 monthly propagation
@@ -874,7 +872,7 @@ REQ-001의 auth gate·cookie/cleanup/logout, REQ-002의 메뉴 10개와 order(`
기존 요구사항·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. 기존 요구사항·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, monthly_settlement_id, cashflow_kind, category, subcategory, amount, transaction_count, currency` 8개 필드, signed INTEGER amount, positive count, `monthly_settlement(id)` FK RESTRICT, 5-field unique(monthly_settlement_id, cashflow_kind, category, subcategory, currency), month index를 갖는다. category/subcategory는 NOT NULL이며 빈 문자열은 그대로 구별한다. 기존 자산 상세와 authority/verified·exact/carry/substitute·missing/exclusion 근거를 유지한다. 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. 동일 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.
+2 -2
View File
@@ -211,7 +211,7 @@ ASGI startup부터 login, token restoration, invalid-cookie cleanup, explicit lo
## REQ-003 검증 ## REQ-003 검증
- 정상 흐름: `tests/test_account_valuation_service_actual.py`, `tests/test_account_snapshot_linking.py`, `tests/test_comprehensive_settlement_adapters.py` - 정상 흐름: `tests/test_account_valuations.py`, `tests/test_asset_snapshots.py`, `tests/test_contract_regression.py`
- 실패 흐름: conflict, conditional approval, validation tests - 실패 흐름: conflict, conditional approval, validation tests
- 경계 흐름: exact/as_of, stale/carry-forward, correction/re-close, empty fallback - 경계 흐름: exact/as_of, stale/carry-forward, correction/re-close, empty fallback
- 동시성 흐름: SQLite unique/idempotency and transaction rollback review - 동시성 흐름: SQLite unique/idempotency and transaction rollback review
@@ -266,7 +266,7 @@ Draft create/update/finalize use transaction-scoped writes; any DB failure rolls
| Stage ID | Owner / input and guard | State, success and failure transition | | 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` | 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 `account_snapshot` 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-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. | | `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. 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.
+1 -1
View File
@@ -1,3 +1,3 @@
"""Jarca Ledger MCP server.""" """Jarca Ledger MCP server."""
__version__ = "0.1.7" __version__ = "0.1.8"
+35 -4
View File
@@ -5,7 +5,6 @@ from typing import Literal
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Literal
from dotenv import load_dotenv from dotenv import load_dotenv
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
@@ -119,9 +118,9 @@ def update_ledger_account(account_id: str, name: str | None = None, type: Litera
return contracts.update_ledger_account(account_id, name=name, type=type, institution=institution, currency=currency, active=active, memo=memo, account_number_masked=account_number_masked, holder_name=holder_name, last_balance=last_balance, last_synced_at=last_synced_at, statement_status=statement_status, liquidity_bucket=liquidity_bucket, last_balance_basis_date=last_balance_basis_date) return contracts.update_ledger_account(account_id, name=name, type=type, institution=institution, currency=currency, active=active, memo=memo, account_number_masked=account_number_masked, holder_name=holder_name, last_balance=last_balance, last_synced_at=last_synced_at, statement_status=statement_status, liquidity_bucket=liquidity_bucket, last_balance_basis_date=last_balance_basis_date)
@mcp.tool() @mcp.tool()
def register_account_valuation(account_id: str, basis_date: str, amount: int, currency: str = "KRW", valuation_kind: str = "balance", source_type: str = "manual", source_ref: str = "", verification_status: str = "unverified", memo: str = "", idempotency_key: str = "", as_of_at: str | None = None, timezone: str | None = None, cost_basis: int | None = None, net_contributed: int | None = None, reported_principal_kind: str | None = None, reported_principal_label: str | None = None, profit: int | None = None, return_rate: float | None = None, return_basis: str | None = None, principal: int | None = None, interest: int | None = None, fee: int | None = None, representative_liability_definition: str | None = None) -> dict: def register_account_valuation(account_id: str, basis_date: str, amount: int, currency: str = "KRW", valuation_kind: str = "balance", source_type: str = "manual", source_ref: str = "", verification_status: str = "unverified", memo: str = "", idempotency_key: str = "", as_of_at: str | None = None, timezone: str | None = None, cost_basis: int | None = None, net_contributed: int | None = None, reported_principal_kind: str | None = None, reported_principal_label: str | None = None, profit: int | None = None, return_rate: float | None = None, return_basis: str | None = None, principal: int | None = None, interest: int | None = None, fee: int | None = None, representative_liability_definition: str | None = None, monthly_settlement_id: str | None = None, liquidity_bucket: str | None = None, selection_policy: str = "manual", valuation_source: str = "manual", source_note: str = "") -> dict:
"""Register an append-only account valuation evidence row.""" """Register legacy valuation evidence, or an unverified canonical snapshot when monthly_settlement_id is supplied."""
return contracts.register_account_valuation(account_id, basis_date, amount, currency, valuation_kind, source_type, source_ref, verification_status, memo, idempotency_key, 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) return contracts.register_account_valuation(account_id, basis_date, amount, currency, valuation_kind, source_type, source_ref, verification_status, memo, idempotency_key, 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, monthly_settlement_id=monthly_settlement_id, liquidity_bucket=liquidity_bucket, selection_policy=selection_policy, valuation_source=valuation_source, source_note=source_note)
@mcp.tool() @mcp.tool()
def list_account_valuations(account_id: str, include_rejected: bool = True) -> dict: def list_account_valuations(account_id: str, include_rejected: bool = True) -> dict:
@@ -143,6 +142,26 @@ def get_asset_liquidity_report() -> dict:
"""Return asset liquidity, debt, and net worth from shared account/manual valuation sources.""" """Return asset liquidity, debt, and net worth from shared account/manual valuation sources."""
return contracts.get_asset_liquidity_report() return contracts.get_asset_liquidity_report()
@mcp.tool()
def list_manual_assets(active_only: bool = True) -> dict:
"""List manual non-transaction assets/liabilities used for net-worth valuation."""
return contracts.list_manual_assets(active_only=active_only)
@mcp.tool()
def create_manual_asset(name: str, valuation_amount: int, valuation_date: str, liquidity_bucket: str, source_note: str, memo: str = "", currency: str = "KRW", active: bool = True) -> dict:
"""Create a manual non-transaction asset/liability valuation row."""
return contracts.create_manual_asset(name, valuation_amount, valuation_date, liquidity_bucket, source_note=source_note, memo=memo, currency=currency, active=active)
@mcp.tool()
def update_manual_asset(asset_id: str, name: str | None = None, valuation_amount: int | None = None, valuation_date: str | None = None, liquidity_bucket: str | None = None, source_note: str | None = None, memo: str | None = None, currency: str | None = None, active: bool | None = None) -> dict:
"""Update selected fields on a manual asset/liability valuation row."""
return contracts.update_manual_asset(asset_id, name=name, valuation_amount=valuation_amount, valuation_date=valuation_date, liquidity_bucket=liquidity_bucket, source_note=source_note, memo=memo, currency=currency, active=active)
@mcp.tool()
def deactivate_manual_asset(asset_id: str, hard_delete: bool = False) -> dict:
"""Deactivate a manual asset/liability by default; hard delete only for exceptional cleanup."""
return contracts.deactivate_manual_asset(asset_id, hard_delete=hard_delete)
@mcp.tool() @mcp.tool()
def preview_asset_snapshot_close(basis_date: str = "", selection_policy: str = "exact") -> dict: def preview_asset_snapshot_close(basis_date: str = "", selection_policy: str = "exact") -> dict:
"""Preview a month-end asset snapshot close without mutating the database.""" """Preview a month-end asset snapshot close without mutating the database."""
@@ -477,3 +496,15 @@ def set_settlement_cashflow_policy(draft_id: str, expected_version: int, updated
def get_monthly_settlement_snapshot(month: str = "", run_id: str = "") -> dict: def get_monthly_settlement_snapshot(month: str = "", run_id: str = "") -> dict:
"""Read a saved monthly settlement snapshot, optionally selecting an exact run.""" """Read a saved monthly settlement snapshot, optionally selecting an exact run."""
return contracts.get_monthly_settlement_snapshot(month, run_id) return contracts.get_monthly_settlement_snapshot(month, run_id)
@mcp.tool()
def list_monthly_settlement_history(month: str = "") -> dict:
"""List immutable monthly settlement close and correction runs."""
return contracts.list_monthly_settlement_history(month)
@mcp.tool()
def get_monthly_settlement_history_record(run_id: str) -> dict:
"""Read one immutable monthly settlement history record by run ID."""
return contracts.get_monthly_settlement_history_record(run_id)
@@ -12,6 +12,8 @@ from ...service import (
set_settlement_draft_item as set_settlement_draft_item, set_settlement_draft_item as set_settlement_draft_item,
set_settlement_cashflow_policy as set_settlement_cashflow_policy, set_settlement_cashflow_policy as set_settlement_cashflow_policy,
get_monthly_settlement_snapshot as get_monthly_settlement_snapshot, get_monthly_settlement_snapshot as get_monthly_settlement_snapshot,
list_monthly_settlement_history as list_monthly_settlement_history,
get_monthly_settlement_history_record as get_monthly_settlement_history_record,
update_settlement_draft as update_settlement_draft, update_settlement_draft as update_settlement_draft,
annotations as annotations, annotations as annotations,
apply_categorization_rules as apply_categorization_rules, apply_categorization_rules as apply_categorization_rules,
@@ -22,6 +24,8 @@ from ...service import (
create_ledger_budget as create_ledger_budget, create_ledger_budget as create_ledger_budget,
create_ledger_category as create_ledger_category, create_ledger_category as create_ledger_category,
create_ledger_transaction as create_ledger_transaction, create_ledger_transaction as create_ledger_transaction,
create_manual_asset as create_manual_asset,
deactivate_manual_asset as deactivate_manual_asset,
delete_category_rule as delete_category_rule, delete_category_rule as delete_category_rule,
delete_ledger_category as delete_ledger_category, delete_ledger_category as delete_ledger_category,
delete_ledger_transaction as delete_ledger_transaction, delete_ledger_transaction as delete_ledger_transaction,
@@ -64,6 +68,7 @@ from ...service import (
list_ledger_categories as list_ledger_categories, list_ledger_categories as list_ledger_categories,
list_ledger_import_batches as list_ledger_import_batches, list_ledger_import_batches as list_ledger_import_batches,
list_ledger_transactions as list_ledger_transactions, list_ledger_transactions as list_ledger_transactions,
list_manual_assets as list_manual_assets,
list_uncategorized_transactions as list_uncategorized_transactions, list_uncategorized_transactions as list_uncategorized_transactions,
preview_asset_snapshot_close as preview_asset_snapshot_close, preview_asset_snapshot_close as preview_asset_snapshot_close,
preview_categorization as preview_categorization, preview_categorization as preview_categorization,
@@ -80,6 +85,7 @@ from ...service import (
update_category_rule as update_category_rule, update_category_rule as update_category_rule,
update_ledger_account as update_ledger_account, update_ledger_account as update_ledger_account,
update_ledger_transaction as update_ledger_transaction, update_ledger_transaction as update_ledger_transaction,
update_manual_asset as update_manual_asset,
) )
TOOL_CONTRACTS = { TOOL_CONTRACTS = {
@@ -90,6 +96,7 @@ TOOL_CONTRACTS = {
"set_settlement_draft_item", "review_settlement_draft", "set_settlement_draft_item", "review_settlement_draft",
"approve_settlement_draft", "finalize_settlement_draft", "cancel_settlement_draft", "approve_settlement_draft", "finalize_settlement_draft", "cancel_settlement_draft",
"set_settlement_cashflow_policy", "get_monthly_settlement_snapshot", "set_settlement_cashflow_policy", "get_monthly_settlement_snapshot",
"list_monthly_settlement_history", "get_monthly_settlement_history_record",
) )
} }
@@ -99,6 +106,7 @@ __all__ = [
"refresh_settlement_draft_targets", "review_settlement_draft", "refresh_settlement_draft_targets", "review_settlement_draft",
"set_settlement_draft_item", "update_settlement_draft", "set_settlement_draft_item", "update_settlement_draft",
"set_settlement_cashflow_policy", "get_monthly_settlement_snapshot", "set_settlement_cashflow_policy", "get_monthly_settlement_snapshot",
"list_monthly_settlement_history", "get_monthly_settlement_history_record",
"annotations", "annotations",
"apply_categorization_rules", "apply_categorization_rules",
"close_asset_snapshot", "close_asset_snapshot",
@@ -108,6 +116,8 @@ __all__ = [
"create_ledger_budget", "create_ledger_budget",
"create_ledger_category", "create_ledger_category",
"create_ledger_transaction", "create_ledger_transaction",
"create_manual_asset",
"deactivate_manual_asset",
"delete_category_rule", "delete_category_rule",
"delete_ledger_category", "delete_ledger_category",
"delete_ledger_transaction", "delete_ledger_transaction",
@@ -150,6 +160,7 @@ __all__ = [
"list_ledger_categories", "list_ledger_categories",
"list_ledger_import_batches", "list_ledger_import_batches",
"list_ledger_transactions", "list_ledger_transactions",
"list_manual_assets",
"list_uncategorized_transactions", "list_uncategorized_transactions",
"preview_asset_snapshot_close", "preview_asset_snapshot_close",
"preview_categorization", "preview_categorization",
@@ -166,4 +177,5 @@ __all__ = [
"update_category_rule", "update_category_rule",
"update_ledger_account", "update_ledger_account",
"update_ledger_transaction", "update_ledger_transaction",
"update_manual_asset",
] ]
@@ -2,10 +2,18 @@ from __future__ import annotations
from ...service import ( from ...service import (
close_asset_snapshot as close_asset_snapshot, close_asset_snapshot as close_asset_snapshot,
create_manual_asset as create_manual_asset,
deactivate_manual_asset as deactivate_manual_asset,
list_manual_assets as list_manual_assets,
preview_asset_snapshot_close as preview_asset_snapshot_close, preview_asset_snapshot_close as preview_asset_snapshot_close,
update_manual_asset as update_manual_asset,
) )
__all__ = [ __all__ = [
"close_asset_snapshot", "close_asset_snapshot",
"create_manual_asset",
"deactivate_manual_asset",
"list_manual_assets",
"preview_asset_snapshot_close", "preview_asset_snapshot_close",
"update_manual_asset",
] ]
@@ -1,40 +1,418 @@
from __future__ import annotations from __future__ import annotations
import sqlite3 from datetime import date
from typing import Any
from uuid import uuid4 from uuid import uuid4
import math
import re
from typing import Any
import sqlite3
from .shared import db, fail, now_iso, ok, read_db from .shared import db, read_db, read_operation, fail, new_id, normalize_date, now_iso, ok, row_to_dict
_ALLOWED_VERIFICATION = {"pending", "unverified"} _VALID_STATUSES = {"verified", "unverified", "rejected"}
_ALLOWED_SETTLEMENT_STATUS = {"draft", "in_review"} _VALID_LOAN_DEFINITIONS = {"principal_only", "principal_interest_fees"}
LIQUIDITY_BUCKETS = ("cash", "deposit", "investment", "real_estate", "loan", "other") _VALID_CURRENCIES = {"KRW", "USD", "EUR", "JPY", "CNY", "GBP"}
_VALID_KINDS = {"balance", "cash_balance", "total_value", "loan", "loan_balance"}
def normalize_liquidity_bucket(value: Any, *, default: str = "other") -> str: _VALID_SOURCE_TYPES = {"manual", "import", "account_write", "reconciliation", "snapshot"}
value = default if value is None or str(value).strip() == "" else str(value).strip().lower() _COLUMNS = (
if value not in LIQUIDITY_BUCKETS: "amount", "currency", "valuation_kind", "source_type", "source_ref",
raise ValueError("liquidity_bucket must be cash, deposit, investment, real_estate, loan or other") "verification_status", "memo", "as_of_at", "timezone", "cost_basis",
return value "net_contributed", "reported_principal_kind", "reported_principal_label",
"profit", "return_rate", "return_basis", "principal", "interest", "fee",
"representative_liability_definition",
)
def _public_candidate(item: dict[str, Any], strict: bool = False) -> dict[str, Any]: def _valuation(row: Any) -> dict[str, Any]:
"""Compatibility projection used by asset evidence readers.""" return row_to_dict(row)
allowed = {"id", "source_id", "source_type", "name", "amount", "currency", "basis_date",
"valuation_id", "valuation_kind", "verification_status", "evidence_kind", "memo"}
return {key: value for key, value in item.items() if key in allowed}
def record_account_valuation_candidate(*args: Any, **kwargs: Any) -> dict[str, Any]: def _public_candidate(row: Any) -> dict[str, Any]:
return fail("unsupported", "account valuation candidates are not writable") return {k: row[k] for k in ("id", "basis_date", "currency", "valuation_kind", "verification_status")}
def _strict_int(value: Any, field: str) -> int: def _strict_int(value: Any, field: str, allow_none: bool = True) -> int | None:
if isinstance(value, bool) or not isinstance(value, int): if value is None and allow_none:
return None
if isinstance(value, bool):
raise ValueError(f"{field} must be an integer") raise ValueError(f"{field} must be an integer")
return value if isinstance(value, int):
return value
if isinstance(value, str) and re.fullmatch(r"[+-]?\d+", value.strip()):
return int(value.strip())
raise ValueError(f"{field} must be an integer")
def _strict_float(value: Any, field: str) -> float | None:
if value is None:
return None
if isinstance(value, bool):
raise ValueError(f"{field} must be a finite number")
try:
result = float(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{field} must be a finite number") from exc
if not math.isfinite(result):
raise ValueError(f"{field} must be a finite number")
return result
def _validate_common(account_id: str, basis_date: str, amount: int, currency: str, verification_status: str, valuation_kind: str, source_type: str, representative_liability_definition: str | None) -> tuple[str, int, str, str, str, str | None] | dict[str, Any]:
if not isinstance(account_id, str) or not account_id.strip():
return fail("validation_error", "account_id must be non-empty")
try:
basis = normalize_date(basis_date)
amount_int = _strict_int(amount, "amount", allow_none=False)
except Exception as exc:
return fail("validation_error", str(exc))
currency_norm = (currency or "KRW").strip().upper()
if currency_norm not in _VALID_CURRENCIES:
return fail("validation_error", f"currency must be one of {sorted(_VALID_CURRENCIES)}")
status = (verification_status or "unverified").strip().lower()
if status not in _VALID_STATUSES:
return fail("validation_error", "verification_status must be verified, unverified, or rejected")
if not isinstance(valuation_kind, str):
return fail("validation_error", "valuation_kind must be a string")
kind = valuation_kind.strip().lower() or "balance"
if kind not in _VALID_KINDS:
return fail("validation_error", f"valuation_kind must be one of {sorted(_VALID_KINDS)}")
source = (source_type or "manual").strip().lower() or "manual"
if source not in _VALID_SOURCE_TYPES:
return fail("validation_error", f"source_type must be one of {sorted(_VALID_SOURCE_TYPES)}")
definition = representative_liability_definition.strip() if representative_liability_definition else None
if definition is not None and definition not in _VALID_LOAN_DEFINITIONS:
return fail("validation_error", "representative liability definition is invalid")
if kind in {"loan", "loan_balance"} and definition is None:
return fail("validation_error", "loan valuation requires representative_liability_definition")
if kind not in {"loan", "loan_balance", "balance"} and definition is not None:
return fail("validation_error", "valuation kind cannot define representative liability")
if amount_int is None:
return fail("validation_error", "amount must be an integer")
return basis, amount_int, currency_norm, status, source, definition
def record_account_valuation_candidate(conn: sqlite3.Connection, account_id: str, basis_date: str, amount: int, currency: str, source_type: str, source_ref: str, idempotency_key: str, memo: str = "") -> dict[str, Any]:
"""Append an unverified candidate on a caller-owned transaction."""
account = conn.execute("SELECT type,currency FROM accounts WHERE id=?", (account_id,)).fetchone()
if account is None:
return fail("not_found", "account not found")
if (currency or "KRW").upper() != (account["currency"] or "KRW").upper():
return fail("validation_error", "valuation currency must match account currency")
if str(account["type"]).lower() == "loan":
return fail("validation_error", "loan valuation requires representative_liability_definition")
try:
amount_int = _strict_int(amount, "amount", allow_none=False)
except (TypeError, ValueError) as exc:
return fail("validation_error", str(exc))
basis = normalize_date(basis_date)
existing = conn.execute("SELECT * FROM account_valuations WHERE account_id=? AND idempotency_key=?", (account_id, idempotency_key)).fetchone()
if existing:
incoming = {
"basis_date": basis,
"amount": amount_int,
"currency": (currency or "KRW").upper(),
"valuation_kind": "balance",
"source_type": source_type,
"source_ref": source_ref or "",
"verification_status": "unverified",
"memo": memo,
}
if all(existing[key] == value for key, value in incoming.items()):
return ok(valuation=_valuation(existing), reused=True)
return fail(
"idempotency_conflict",
"idempotency_key already has a different candidate payload",
valuation=_valuation(existing),
)
valuation_id = new_id("valuation")
conn.execute("""INSERT INTO account_valuations
(id,account_id,basis_date,amount,currency,valuation_kind,source_type,source_ref,verification_status,recorded_at,memo,idempotency_key)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""", (valuation_id, account_id, basis, amount_int, (currency or "KRW").upper(), "balance", source_type, source_ref or "", "unverified", now_iso(), memo, idempotency_key))
return ok(valuation=_valuation(conn.execute("SELECT * FROM account_valuations WHERE id=?", (valuation_id,)).fetchone()), reused=False)
def register_account_valuation( def register_account_valuation(
account_id: str,
basis_date: str,
amount: int,
currency: str = "KRW",
valuation_kind: str = "balance",
source_type: str = "manual",
source_ref: str = "",
verification_status: str = "unverified",
memo: str = "",
idempotency_key: str = "",
as_of_at: str | None = None,
timezone: str | None = None,
cost_basis: int | None = None,
net_contributed: int | None = None,
reported_principal_kind: str | None = None,
reported_principal_label: str | None = None,
profit: int | None = None,
return_rate: float | None = None,
return_basis: str | None = None,
principal: int | None = None,
interest: int | None = None,
fee: int | None = None,
representative_liability_definition: str | None = None,
db_path: str | None = None,
supersedes_id: str | None = None,
correction_reason: str = "",
*, monthly_settlement_id: str | None = None, liquidity_bucket: str | None = None,
selection_policy: str = "manual", valuation_source: str = "manual", source_note: str = "",
) -> dict[str, Any]:
if monthly_settlement_id is not None:
return _register_monthly_account_valuation(
account_id, basis_date, amount, currency, valuation_kind,
"account" if source_type == "manual" else source_type, source_ref,
verification_status, memo, idempotency_key, monthly_settlement_id,
db_path, liquidity_bucket, selection_policy=selection_policy,
valuation_source=valuation_source, source_note=source_note,
)
validated = _validate_common(account_id, basis_date, amount, currency, verification_status, valuation_kind, source_type, representative_liability_definition)
if isinstance(validated, dict):
return validated
basis, amount_int, currency_norm, status, source_norm, definition = validated
try:
optional_ints = {name: _strict_int(value, name) for name, value in {
"cost_basis": cost_basis, "net_contributed": net_contributed, "profit": profit,
"principal": principal, "interest": interest, "fee": fee}.items()}
return_rate = _strict_float(return_rate, "return_rate")
except (TypeError, ValueError) as exc:
return fail("validation_error", str(exc))
if (as_of_at is None) != (timezone is None):
return fail("validation_error", "as_of_at and timezone must be supplied together")
if as_of_at is not None:
try:
from datetime import datetime
parsed = datetime.fromisoformat(as_of_at)
if parsed.tzinfo is None:
return fail("validation_error", "as_of_at must include timezone offset")
except (TypeError, ValueError) as exc:
return fail("validation_error", f"invalid as_of_at: {exc}")
if supersedes_id and not correction_reason.strip():
return fail("validation_error", "correction_reason is required")
ts = now_iso()
source_ref_norm = source_ref.strip() if isinstance(source_ref, str) else ""
values = (amount_int, currency_norm, (valuation_kind or "balance").strip().lower(), source_norm, source_ref_norm, status, memo or "", as_of_at, timezone, optional_ints["cost_basis"], optional_ints["net_contributed"], reported_principal_kind, reported_principal_label, optional_ints["profit"], return_rate, return_basis, optional_ints["principal"], optional_ints["interest"], optional_ints["fee"], definition)
with db(db_path) as conn:
conn.execute("BEGIN IMMEDIATE")
account = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone()
if account is None:
return fail("not_found", "account not found")
if currency_norm != (account["currency"] or "KRW").upper():
return fail("validation_error", "valuation currency must match account currency")
effective_kind = (valuation_kind or "balance").strip().lower()
is_loan_account = str(account["type"]).lower() == "loan"
if effective_kind in {"loan", "loan_balance"} and not is_loan_account:
return fail("validation_error", "loan valuation kind requires a loan account")
if is_loan_account and effective_kind not in {"balance", "loan", "loan_balance"}:
return fail("validation_error", "loan account valuation kind must represent a liability")
if is_loan_account and effective_kind in {"balance", "loan", "loan_balance"} and definition is None:
return fail("validation_error", "loan valuation requires representative_liability_definition")
if definition is not None and not is_loan_account:
return fail("validation_error", "representative liability definition requires a loan account")
seen: set[str] = set()
if supersedes_id:
parent = conn.execute("SELECT * FROM account_valuations WHERE id=?", (supersedes_id,)).fetchone()
if parent is None:
if idempotency_key and conn.execute("SELECT 1 FROM account_valuations WHERE account_id=? AND idempotency_key=?", (account_id, idempotency_key)).fetchone():
return fail("idempotency_conflict", "idempotency_key already has a different payload")
return fail("validation_error", "supersedes_id must reference an existing valuation")
if any(parent[field] != value for field, value in (
("account_id", account_id), ("basis_date", basis),
("currency", currency_norm), ("valuation_kind", (valuation_kind or "balance").strip().lower()),
)):
return fail("validation_error", "superseded valuation scope must match account, basis, currency, and valuation kind")
seen: set[str] = set()
cursor = parent
while cursor is not None:
cursor_id = cursor["id"]
if cursor_id in seen:
return fail("validation_error", "supersedes ancestry is cyclic")
seen.add(cursor_id)
next_id = cursor["supersedes_id"]
cursor = conn.execute("SELECT * FROM account_valuations WHERE id=?", (next_id,)).fetchone() if next_id else None
existing = None
by_key = conn.execute("SELECT * FROM account_valuations WHERE account_id=? AND idempotency_key=?", (account_id, idempotency_key)).fetchone() if idempotency_key else None
source_rows = conn.execute("SELECT * FROM account_valuations WHERE account_id=? AND source_ref=? ORDER BY recorded_at, id", (account_id, source_ref_norm)).fetchall() if source_ref_norm else []
correction_chain = seen if supersedes_id else set()
unrelated_source = next((row for row in source_rows if row["id"] not in correction_chain and (by_key is None or row["id"] != by_key["id"])), None)
if by_key is not None and unrelated_source is not None:
return fail("idempotency_conflict", "idempotency_key and source_ref identify different candidates")
if unrelated_source is not None:
by_source = unrelated_source
else:
by_source = None
existing = by_key or by_source
if existing:
current = _valuation(existing)
incoming = dict(zip(_COLUMNS, values))
incoming.update({"account_id": account_id, "basis_date": basis, "supersedes_id": supersedes_id, "correction_reason": correction_reason or ""})
comparable = set(incoming) - {"id", "recorded_at"}
if all(current.get(k) == v for k, v in incoming.items() if k in comparable):
return ok(valuation=current, reused=True)
return fail("idempotency_conflict", "idempotency_key already has a different payload", valuation=current)
valuation_id = new_id("valuation")
try:
conn.execute("""INSERT INTO account_valuations
(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)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (valuation_id, account_id, basis, *values[:6], ts, values[6], idempotency_key or "", supersedes_id, correction_reason, *values[7:]))
except sqlite3.IntegrityError:
if supersedes_id:
return fail("correction_conflict", "valuation already has a correction")
return fail("idempotency_conflict", "idempotency_key already has a different payload")
except Exception as exc:
return fail("validation_error", str(exc))
row = conn.execute("SELECT * FROM account_valuations WHERE id=?", (valuation_id,)).fetchone()
return ok(valuation=_valuation(row), reused=False)
@read_operation
def list_account_valuations(account_id: str, include_rejected: bool = True, db_path: str | None = None) -> dict[str, Any]:
where = "" if include_rejected else " AND verification_status <> 'rejected'"
with read_db(db_path) as conn:
rows = conn.execute(
f"SELECT * FROM account_valuations WHERE account_id=?{where} ORDER BY basis_date, recorded_at, id",
(account_id,),
).fetchall()
snapshots = conn.execute(
f"""SELECT * FROM account_snapshot
WHERE source_type='account' AND source_id=?{where}
ORDER BY basis_date, created_at, id""",
(account_id,),
).fetchall()
values = [_valuation(row) for row in rows]
for row in snapshots:
value = _valuation(row)
value.update(
account_id=value["source_id"],
recorded_at=value["created_at"],
valuation_kind="balance",
source_ref=value.get("source_note", ""),
)
for key in _COLUMNS:
value.setdefault(key, None)
value.setdefault("idempotency_key", "")
value.setdefault("supersedes_id", None)
value.setdefault("correction_reason", "")
values.append(value)
values.sort(key=lambda value: (value["basis_date"], value["recorded_at"], value["id"]))
return ok(account_id=account_id, valuations=values, count=len(values))
@read_operation
def select_account_valuation(account_id: str, basis_date: str, as_of: bool = False, currency: str | None = None, valuation_kind: str | None = None, db_path: str | None = None) -> dict[str, Any]:
try:
basis = normalize_date(basis_date)
except Exception as exc:
return fail("validation_error", str(exc))
with read_db(db_path) as conn:
clauses = ["account_id=?"]
params: list[Any] = [account_id]
if currency:
clauses.append("UPPER(currency)=?"); params.append(currency.strip().upper())
if valuation_kind:
clauses.append("valuation_kind=?"); params.append(valuation_kind.strip().lower())
rows = conn.execute(f"SELECT * FROM account_valuations WHERE {' AND '.join(clauses)} ORDER BY basis_date DESC, recorded_at DESC, id DESC", params).fetchall()
leaves = [r for r in rows if conn.execute("SELECT 1 FROM account_valuations WHERE supersedes_id=?", (r["id"],)).fetchone() is None]
verified = [r for r in leaves if r["verification_status"] == "verified" and r["basis_date"] <= basis]
exact = [r for r in verified if r["basis_date"] == basis]
# Candidates are evidence at or before the requested basis. Exact
# selection only surfaces same-date unverified evidence; as-of
# selection surfaces unverified evidence newer than the selected
# verified value, while never looking into the future.
latest_verified_date = max((x["basis_date"] for x in verified), default="")
candidate = [
r for r in leaves
if r["verification_status"] == "unverified"
and r["basis_date"] <= basis
and (r["basis_date"] == basis if not as_of else (not latest_verified_date or r["basis_date"] > latest_verified_date))
]
matching = exact if not as_of else [r for r in verified if r["basis_date"] == latest_verified_date]
if len(matching) > 1:
return fail("ambiguous_valuation", "multiple verified valuation leaves match the scope")
row = matching[0] if matching else None
if row is None:
result = ok(account_id=account_id, basis_date=basis, valuation=None, carried_forward=False)
if candidate:
result["warning"] = "unverified_candidate"
result["unverified_candidates"] = [_public_candidate(item) for item in candidate]
return result
age = (date.fromisoformat(basis) - date.fromisoformat(row["basis_date"])).days
warning = "unverified_candidate" if candidate else ("carry_forward" if age else None)
result = ok(account_id=account_id, basis_date=basis, valuation=_valuation(row), carried_forward=bool(age), valuation_age_days=age, warning=warning)
if candidate:
result["unverified_candidates"] = [_public_candidate(item) for item in candidate]
return result
def correct_account_valuation(valuation_id: str, amount: int, correction_reason: str, idempotency_key: str = "", db_path: str | None = None, **fields: Any) -> dict[str, Any]:
if not correction_reason or not correction_reason.strip():
return fail("validation_error", "correction_reason is required")
with db(db_path) as conn:
source = conn.execute("SELECT * FROM account_valuations WHERE id=?", (valuation_id,)).fetchone()
if source is None:
return fail("not_found", "valuation not found")
if "currency" in fields and fields["currency"] is not None and fields["currency"].strip().upper() != source["currency"].upper():
return fail("validation_error", "correction currency must match parent valuation")
if "valuation_kind" in fields and fields["valuation_kind"] is not None and fields["valuation_kind"].strip().lower() != source["valuation_kind"]:
return fail("validation_error", "correction valuation_kind must match parent valuation")
preserved = {name: fields[name] if name in fields else source[name] for name in (
"currency", "valuation_kind", "source_type", "source_ref", "verification_status", "memo", "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")}
try:
effective = {
"amount": _strict_int(amount, "amount", allow_none=False),
"account_id": source["account_id"],
"basis_date": source["basis_date"],
"currency": (preserved["currency"] or "KRW").strip().upper(),
"valuation_kind": (preserved["valuation_kind"] or "balance").strip().lower(),
"source_type": (preserved["source_type"] or "manual").strip().lower() or "manual",
"source_ref": preserved["source_ref"].strip() if isinstance(preserved["source_ref"], str) else "",
"verification_status": (preserved["verification_status"] or "unverified").strip().lower(),
"memo": preserved["memo"] or "",
"as_of_at": preserved["as_of_at"],
"timezone": preserved["timezone"],
"cost_basis": _strict_int(preserved["cost_basis"], "cost_basis"),
"net_contributed": _strict_int(preserved["net_contributed"], "net_contributed"),
"reported_principal_kind": preserved["reported_principal_kind"],
"reported_principal_label": preserved["reported_principal_label"],
"profit": _strict_int(preserved["profit"], "profit"),
"return_rate": _strict_float(preserved["return_rate"], "return_rate"),
"return_basis": preserved["return_basis"],
"principal": _strict_int(preserved["principal"], "principal"),
"interest": _strict_int(preserved["interest"], "interest"),
"fee": _strict_int(preserved["fee"], "fee"),
"representative_liability_definition": preserved["representative_liability_definition"].strip() if preserved["representative_liability_definition"] else None,
"supersedes_id": valuation_id,
"correction_reason": correction_reason.strip(),
}
except (TypeError, ValueError, AttributeError) as exc:
return fail("validation_error", str(exc))
if idempotency_key:
prior = conn.execute("SELECT * FROM account_valuations WHERE account_id=? AND idempotency_key=?", (source["account_id"], idempotency_key)).fetchone()
if prior:
if all(prior[key] == value for key, value in effective.items()):
return ok(valuation=_valuation(prior), reused=True)
return fail("idempotency_conflict", "idempotency_key already has a different payload", valuation=_valuation(prior))
child = conn.execute("SELECT * FROM account_valuations WHERE supersedes_id=?", (valuation_id,)).fetchone()
if child is not None:
if all(child[key] == value for key, value in effective.items()):
return ok(valuation=_valuation(child), reused=True)
return fail("correction_conflict", "correction payload conflicts with existing correction", valuation=_valuation(child))
return register_account_valuation(
source["account_id"], source["basis_date"], amount,
**preserved,
idempotency_key=idempotency_key,
supersedes_id=valuation_id, correction_reason=correction_reason.strip(),
db_path=db_path,
)
_ALLOWED_VERIFICATION = {"pending", "unverified"}
_ALLOWED_SETTLEMENT_STATUS = {"draft", "in_review"}
def _register_monthly_account_valuation(
account_id: str, account_id: str,
basis_date: str, basis_date: str,
amount: int, amount: int,
@@ -59,7 +437,8 @@ def register_account_valuation(
try: try:
if not account_id or not basis_date or not currency: if not account_id or not basis_date or not currency:
raise ValueError("account_id, basis_date and currency are required") raise ValueError("account_id, basis_date and currency are required")
amount = _strict_int(amount, "amount") if isinstance(amount, bool) or not isinstance(amount, int):
raise ValueError("amount must be an integer")
if source_type != "account": if source_type != "account":
raise ValueError("source_type must be account") raise ValueError("source_type must be account")
if verification_status not in _ALLOWED_VERIFICATION: if verification_status not in _ALLOWED_VERIFICATION:
@@ -122,21 +501,6 @@ def register_account_valuation(
return fail("db_error", "account evaluation registration failed") return fail("db_error", "account evaluation registration failed")
def list_account_valuations(account_id: str, include_rejected: bool = True, db_path: str | None = None) -> dict[str, Any]:
try:
with read_db(db_path) as conn:
rows = conn.execute("SELECT * FROM account_snapshot WHERE source_type='account' AND source_id=? ORDER BY created_at,id", (account_id,)).fetchall()
return ok(valuations=[dict(r) for r in rows], count=len(rows), read_only=True)
except Exception:
return fail("db_error", "account evaluations unavailable")
def select_account_valuation(*args: Any, **kwargs: Any) -> dict[str, Any]:
return fail("unsupported", "account valuation selection is unavailable")
def correct_account_valuation(*args: Any, **kwargs: Any) -> dict[str, Any]:
return fail("unsupported", "account valuation correction is unavailable")
def update_account_snapshot_category(snapshot_id: str, monthly_settlement_id: str, liquidity_bucket: str, db_path: str | None = None) -> dict[str, Any]: def update_account_snapshot_category(snapshot_id: str, monthly_settlement_id: str, liquidity_bucket: str, db_path: str | None = None) -> dict[str, Any]:
try: try:
@@ -161,3 +525,12 @@ def update_account_snapshot_category(snapshot_id: str, monthly_settlement_id: st
return fail("validation_error", str(exc)) return fail("validation_error", str(exc))
except Exception: except Exception:
return fail("db_error", "account snapshot category update failed") return fail("db_error", "account snapshot category update failed")
LIQUIDITY_BUCKETS = ("cash", "deposit", "investment", "real_estate", "loan", "other")
def normalize_liquidity_bucket(value: Any, *, default: str = "other") -> str:
value = default if value is None or str(value).strip() == "" else str(value).strip().lower()
if value not in LIQUIDITY_BUCKETS:
raise ValueError("liquidity_bucket must be cash, deposit, investment, real_estate, loan or other")
return value
@@ -3,8 +3,15 @@ from .shared import Any, VALID_ACCOUNT_TYPES, db, fail, new_id, normalize_date,
def _record_balance_candidate(conn: Any, account: dict[str, Any], balance: Any = None, basis_date: str | None = None) -> None: def _record_balance_candidate(conn: Any, account: dict[str, Any], balance: Any = None, basis_date: str | None = None) -> None:
"""Deprecated: balances remain on accounts; no valuation row is created.""" balance = account.get("last_balance") if balance is None else balance
return basis_date = account.get("last_balance_basis_date") if basis_date is None else basis_date
if balance is None or not str(basis_date or ""):
return
try:
from .account_valuation_service import record_account_valuation_candidate
record_account_valuation_candidate(conn, account["id"], str(basis_date), int(balance), account.get("currency", "KRW"), "account_write", account.get("updated_at", ""), f"account-balance:{account['id']}:{str(basis_date)}:{balance}", "account balance write candidate")
except (ValueError, TypeError):
return
def create_ledger_account(name: str, type: str = "bank", institution: str = "", currency: str = "KRW", active: bool = True, memo: str = "", account_number_masked: str = "", holder_name: str = "", last_balance: int | None = None, last_synced_at: str = "", statement_status: str = "unknown", liquidity_bucket: str | None = None, db_path: str | None = None, last_balance_basis_date: str = "") -> dict[str, Any]: def create_ledger_account(name: str, type: str = "bank", institution: str = "", currency: str = "KRW", active: bool = True, memo: str = "", account_number_masked: str = "", holder_name: str = "", last_balance: int | None = None, last_synced_at: str = "", statement_status: str = "unknown", liquidity_bucket: str | None = None, db_path: str | None = None, last_balance_basis_date: str = "") -> dict[str, Any]:
@@ -25,7 +32,7 @@ def create_ledger_account(name: str, type: str = "bank", institution: str = "",
if last_balance is not None: if last_balance is not None:
try: try:
from .account_valuation_service import _strict_int from .account_valuation_service import _strict_int
last_balance = _strict_int(last_balance, "last_balance") last_balance = _strict_int(last_balance, "last_balance", allow_none=False)
except (ValueError, TypeError) as e: except (ValueError, TypeError) as e:
return fail("validation_error", str(e)) return fail("validation_error", str(e))
with db(db_path) as conn: with db(db_path) as conn:
@@ -49,7 +56,7 @@ def update_ledger_account(account_id: str, db_path: str | None = None, **fields:
if balance_update: if balance_update:
try: try:
from .account_valuation_service import _strict_int from .account_valuation_service import _strict_int
normalized_balance = _strict_int(fields["last_balance"], "last_balance") normalized_balance = _strict_int(fields["last_balance"], "last_balance", allow_none=False)
except (ValueError, TypeError) as e: except (ValueError, TypeError) as e:
return fail("validation_error", str(e)) return fail("validation_error", str(e))
normalized_basis = None normalized_basis = None
@@ -74,7 +81,14 @@ def update_ledger_account(account_id: str, db_path: str | None = None, **fields:
if not updates: return fail("validation_error", "no fields to update") if not updates: return fail("validation_error", "no fields to update")
target_type = fields.get("type", current["type"]) target_type = fields.get("type", current["type"])
if str(current["type"]).lower() != "loan" and target_type == "loan": if str(current["type"]).lower() != "loan" and target_type == "loan":
return fail("unsupported", "account valuation history is unavailable; loan conversion requires a supported liability valuation source") invalid_valuation = conn.execute(
"SELECT 1 FROM account_valuations "
"WHERE account_id=? AND (representative_liability_definition IS NULL "
"OR representative_liability_definition NOT IN (?, ?)) LIMIT 1",
(account_id, "principal_only", "principal_interest_fees"),
).fetchone()
if invalid_valuation is not None:
return fail("validation_error", "loan account requires a valid representative liability definition for every valuation")
stored_date = str(current["last_balance_basis_date"] or "")[:10] stored_date = str(current["last_balance_basis_date"] or "")[:10]
incoming_date = normalized_basis if basis_update else "" incoming_date = normalized_basis if basis_update else ""
historical = bool(balance_update and incoming_date and stored_date and incoming_date < stored_date) historical = bool(balance_update and incoming_date and stored_date and incoming_date < stored_date)
@@ -3,9 +3,13 @@ from __future__ import annotations
from .assets_service import ( from .assets_service import (
close_asset_snapshot as close_asset_snapshot, close_asset_snapshot as close_asset_snapshot,
create_asset_snapshot as create_asset_snapshot, create_asset_snapshot as create_asset_snapshot,
create_manual_asset as create_manual_asset,
deactivate_manual_asset as deactivate_manual_asset,
get_asset_liquidity_report as get_asset_liquidity_report, get_asset_liquidity_report as get_asset_liquidity_report,
get_asset_snapshot_report as get_asset_snapshot_report, get_asset_snapshot_report as get_asset_snapshot_report,
get_net_worth_timeseries as get_net_worth_timeseries, get_net_worth_timeseries as get_net_worth_timeseries,
list_manual_assets as list_manual_assets,
preview_asset_snapshot_close as preview_asset_snapshot_close, preview_asset_snapshot_close as preview_asset_snapshot_close,
update_manual_asset as update_manual_asset,
) )
from .accounts_write_services import update_ledger_account as update_ledger_account from .accounts_write_services import update_ledger_account as update_ledger_account
+300 -58
View File
@@ -25,15 +25,9 @@ from .shared import (
sqlite3, sqlite3,
) )
_RUN_HISTORY_UNAVAILABLE = "run history unavailable"
def _snapshot_run_history_unavailable() -> dict[str, Any]:
return fail("unsupported", _RUN_HISTORY_UNAVAILABLE)
def _valuation_policy() -> dict[str, Any]: def _valuation_policy() -> dict[str, Any]:
return { return {
"authority_sources": ["accounts.last_balance"], "authority_sources": ["accounts.last_balance", "manual_assets"],
"snapshot_role": "historical_record_layer", "snapshot_role": "historical_record_layer",
"formula": "asset_buckets - loan", "formula": "asset_buckets - loan",
"asset_buckets": ASSET_BUCKETS, "asset_buckets": ASSET_BUCKETS,
@@ -166,8 +160,64 @@ def _account_valuation_items(conn: sqlite3.Connection, basis_date: str = "", sel
continue continue
balance = account.get("last_balance") balance = account.get("last_balance")
if basis_date: if basis_date:
base.update({"valuation_id": "", "verification_status": "unavailable", "valuation_kind": "", "valuation_date": "", "evidence_kind": "account_valuation_missing", "source_missing_warning": "account valuation source unavailable"}) valuation_rows = conn.execute(
balance = 0 "SELECT * FROM account_valuations WHERE account_id=? AND UPPER(currency)=? ORDER BY basis_date DESC, recorded_at DESC, id DESC",
(account["id"], base["currency"]),
).fetchall()
leaves = [v for v in valuation_rows if conn.execute("SELECT 1 FROM account_valuations WHERE supersedes_id=?", (v["id"],)).fetchone() is None]
if account_type == "investment":
# Older imports recorded investment balances as cash_balance;
# keep that as a compatibility fallback after the canonical
# total_value/balance purposes.
purposes = ("total_value", "balance", "cash_balance")
elif account_type == "loan":
purposes = ("loan_balance", "loan", "balance")
else:
purposes = ("cash_balance", "balance")
selected = None
selected_purpose = None
candidate_rows = []
for purpose in purposes:
scoped = [v for v in leaves if v["valuation_kind"] == purpose]
if selection_policy == "exact":
verified = [v for v in scoped if v["verification_status"] == "verified" and v["basis_date"] == basis_date]
else:
eligible = [v for v in scoped if v["verification_status"] == "verified" and v["basis_date"] <= basis_date]
latest = max((v["basis_date"] for v in eligible), default="")
verified = [v for v in eligible if v["basis_date"] == latest]
if len(verified) > 1:
invalid.append({**base, "reason": "ambiguous_valuation", "valuation_kind": purpose})
verified = []
break
if verified:
selected, selected_purpose = verified[0], purpose
break
candidate_rows.extend(v for v in scoped if v["verification_status"] == "unverified" and v["basis_date"] <= basis_date and (v["basis_date"] == basis_date if selection_policy == "exact" else True))
if selected is None:
excluded.append({**base, "reason": "missing_verified_exact_valuation" if selection_policy == "exact" else "missing_verified_valuation", "unverified_candidates": [_public_candidate(v) for v in candidate_rows]})
continue
valuation = selected
candidate_rows.extend(
v for v in leaves
if v["valuation_kind"] == selected_purpose
and v["verification_status"] == "unverified"
and v["basis_date"] <= basis_date
and (v["basis_date"] == basis_date if selection_policy == "exact" else True)
)
if account_type == "loan" and not valuation["representative_liability_definition"]:
excluded.append({
**base,
"reason": "missing_representative_liability_definition",
"valuation_id": valuation["id"],
"valuation_kind": selected_purpose,
"valuation_date": valuation["basis_date"],
"verification_status": valuation["verification_status"],
})
continue
balance = valuation["amount"]
base.update({"valuation_id": valuation["id"], "verification_status": "verified", "valuation_kind": selected_purpose, "valuation_date": valuation["basis_date"], "currency": valuation["currency"], "carried_forward": valuation["basis_date"] != basis_date, "evidence_kind": "account_valuation", "age_days": (date.fromisoformat(basis_date) - date.fromisoformat(str(valuation["basis_date"])[:10])).days})
if candidate_rows:
base["unverified_candidates"] = [_public_candidate(v) for v in candidate_rows if v["basis_date"] <= basis_date]
if balance is None: if balance is None:
excluded.append({**base, "reason": "missing_last_balance"}) excluded.append({**base, "reason": "missing_last_balance"})
@@ -177,6 +227,49 @@ def _account_valuation_items(conn: sqlite3.Connection, basis_date: str = "", sel
return included, excluded, invalid return included, excluded, invalid
def _manual_asset_valuation_items(conn: sqlite3.Connection, active_only: bool = True, basis_date: str = "", selection_policy: str = "exact") -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
where = "WHERE active=1" if active_only else ""
rows = conn.execute(f"SELECT * FROM manual_assets {where} ORDER BY name").fetchall()
included: list[dict[str, Any]] = []
excluded: list[dict[str, Any]] = []
for row in rows:
asset = row_to_dict(row)
if (asset.get("currency") or "KRW").upper() != "KRW":
excluded.append({"id": asset.get("id", ""), "source_id": asset.get("id", ""), "source_type": "manual_asset", "reason": "non_krw_without_fx", "currency": asset.get("currency")})
continue
try:
bucket = normalize_liquidity_bucket(asset.get("liquidity_bucket"))
except ValueError:
bucket = "other"
base = {
"id": asset.get("id", ""),
"source_id": asset.get("id", ""),
"source_type": "manual_asset",
"authority_source": "manual_assets",
"name": asset.get("name", ""),
"currency": str(asset.get("currency") or "KRW").upper(),
"liquidity_bucket": bucket,
"valuation_date": asset.get("valuation_date", ""),
"source_note": asset.get("source_note", ""),
"memo": asset.get("memo", ""),
"active": asset.get("active", True),
"verification_status": "verified",
"evidence_kind": "manual_confirmed",
}
value = asset.get("valuation_amount")
if basis_date:
valuation_date = str(asset.get("valuation_date") or "")[:10]
if not valuation_date or valuation_date > basis_date or (selection_policy == "exact" and valuation_date != basis_date):
excluded.append({**base, "reason": "missing_exact_manual_asset_valuation" if selection_policy == "exact" else "future_or_unknown_manual_asset_valuation"})
continue
base["carried_forward"] = valuation_date != basis_date
base["age_days"] = (date.fromisoformat(basis_date) - date.fromisoformat(valuation_date)).days
if value is None:
excluded.append({**base, "reason": "missing_valuation_amount"})
continue
included.append({**base, "amount": int(value), "valuation_amount": int(value)})
return included, excluded
def _persisted_evidence_array(value: Any) -> list[dict[str, Any]] | None: def _persisted_evidence_array(value: Any) -> list[dict[str, Any]] | None:
try: try:
@@ -190,11 +283,94 @@ def _persisted_evidence_array(value: Any) -> list[dict[str, Any]] | None:
def _snapshot_rows_for_run(conn: sqlite3.Connection, run_id: str) -> tuple[list[sqlite3.Row], dict[str, Any]]: def _snapshot_rows_for_run(conn: sqlite3.Connection, run_id: str) -> tuple[list[sqlite3.Row], dict[str, Any]]:
"""Load one immutable close run and only the rows persisted by that run.""" """Load one immutable close run and only the rows persisted by that run."""
return [], {"run_history_unavailable": True} run = conn.execute(
"SELECT rowid AS _run_rowid, * FROM asset_snapshot_runs WHERE id=?", (run_id,)
).fetchone()
if not run:
return [], {}
basis_date = run["basis_date"]
rows = conn.execute(
"SELECT rowid AS _rowid, * FROM asset_snapshots WHERE basis_date=? AND snapshot_run_id=? ORDER BY created_at ASC, name ASC",
(basis_date, run["id"]),
).fetchall()
total_rows = conn.execute(
"SELECT COUNT(*) FROM asset_snapshots WHERE basis_date=?", (basis_date,)
).fetchone()[0]
previous_rows = conn.execute(
"""SELECT COUNT(*)
FROM asset_snapshots AS snapshot
LEFT JOIN asset_snapshot_runs AS snapshot_run
ON snapshot_run.id=snapshot.snapshot_run_id
WHERE snapshot.basis_date=?
AND (
snapshot.snapshot_run_id IS NULL
OR snapshot.snapshot_run_id=''
OR snapshot_run.rowid < ?
)""",
(basis_date, run["_run_rowid"]),
).fetchone()[0]
evidence = {
field: _persisted_evidence_array(run[field])
for field in ("applied_evidence", "excluded_evidence", "carried_evidence")
}
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 "",
"close_status": run["close_status"],
"selection_policy": run["selection_policy"],
"approved_by": run["approved_by"],
"approved_at": run["approved_at"],
"approval_reason": run["approval_reason"],
"approval_reference": run["approval_reference"],
"applied_evidence": evidence["applied_evidence"] or [],
"excluded_evidence": evidence["excluded_evidence"] or [],
"carried_evidence": evidence["carried_evidence"] or [],
"evidence_integrity_error": integrity_error,
"closed_at": run["created_at"] or "",
"previous_snapshot_row_count": previous_rows,
"total_snapshot_rows_for_basis_date": total_rows,
}
return rows, metadata
def _snapshot_rows_for_latest_run(conn: sqlite3.Connection, basis_date: str) -> tuple[list[sqlite3.Row], dict[str, Any]]: def _snapshot_rows_for_latest_run(conn: sqlite3.Connection, basis_date: str) -> tuple[list[sqlite3.Row], dict[str, Any]]:
return [], {"run_history_unavailable": True} rows = conn.execute("SELECT rowid AS _rowid, * FROM asset_snapshots WHERE basis_date=? ORDER BY created_at ASC, name ASC", (basis_date,)).fetchall()
run = conn.execute("SELECT * FROM asset_snapshot_runs WHERE basis_date=? ORDER BY rowid DESC LIMIT 1", (basis_date,)).fetchone()
if not run:
legacy_run_rows = [row for row in rows if row["closed_at"] and row["snapshot_run_id"]]
if legacy_run_rows:
# Schema 11 persisted run identity only on the snapshot rows. Keep
# its rowid-based latest-run rule rather than falling back to live
# authority merely because schema 12 has no matching run record.
latest = max(
legacy_run_rows,
key=lambda row: (
int(row["_rowid"]),
row["closed_at"] or row["created_at"] or "",
row["snapshot_run_id"],
),
)
latest_run_id = latest["snapshot_run_id"]
latest_rows = [row for row in legacy_run_rows if row["snapshot_run_id"] == latest_run_id]
return latest_rows, {
"snapshot_run_id": latest_run_id,
"legacy_closed": True,
"is_correction": bool(latest["is_correction"]),
"correction_of_run_id": latest["correction_of_run_id"] or "",
"correction_reason": latest["correction_reason"] or "",
"close_status": "legacy_read_only",
"selection_policy": latest["selection_policy"] or "exact",
"closed_at": latest["closed_at"] or latest["created_at"] or "",
"total_snapshot_rows_for_basis_date": len(rows),
}
return rows, {"snapshot_run_id": "", "is_correction": False, "correction_of_run_id": "", "correction_reason": "", "total_snapshot_rows_for_basis_date": len(rows)} if rows else {}
return _snapshot_rows_for_run(conn, run["id"])
def _snapshot_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str, Any]]: def _snapshot_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str, Any]]:
@@ -220,7 +396,7 @@ def _snapshot_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str,
"id": snap.get("id", ""), "id": snap.get("id", ""),
"source_id": snap.get("source_id", ""), "source_id": snap.get("source_id", ""),
"source_type": snap.get("source_type", ""), "source_type": snap.get("source_type", ""),
"authority_source": "account_snapshot", "authority_source": "asset_snapshots",
"valuation_source": snap.get("valuation_source", ""), "valuation_source": snap.get("valuation_source", ""),
"name": snap.get("name", ""), "name": snap.get("name", ""),
"currency": snap.get("currency", ""), "currency": snap.get("currency", ""),
@@ -317,8 +493,6 @@ def _ok_with_provenance_aliases(**payload: Any) -> dict[str, Any]:
def _closed_warnings(metadata: dict[str, Any], basis_date: str, rows: list[sqlite3.Row]) -> list[dict[str, Any]]: def _closed_warnings(metadata: dict[str, Any], basis_date: str, rows: list[sqlite3.Row]) -> list[dict[str, Any]]:
if isinstance(metadata.get("frozen_warnings"), list):
return metadata["frozen_warnings"]
warnings = _closed_run_metadata(metadata)["warnings"] warnings = _closed_run_metadata(metadata)["warnings"]
row_by_key = {(row["source_type"], row["source_id"]): row_to_dict(row) for row in rows} row_by_key = {(row["source_type"], row["source_id"]): row_to_dict(row) for row in rows}
for evidence in metadata.get("applied_evidence", []): for evidence in metadata.get("applied_evidence", []):
@@ -362,7 +536,8 @@ def _summarize_valuation_items(included_items: list[dict[str, Any]], excluded_it
"liquidity_bucket": bucket, "liquidity_bucket": bucket,
"amount": amount, "amount": amount,
"account_count": sum(1 for item in included_items if item.get("source_type") == "account" and item.get("liquidity_bucket") == bucket), "account_count": sum(1 for item in included_items if item.get("source_type") == "account" and item.get("liquidity_bucket") == bucket),
"item_count": bucket_counts[bucket], "manual_asset_count": sum(1 for item in included_items if item.get("source_type") == "manual_asset" and item.get("liquidity_bucket") == bucket),
"item_count": bucket_counts[bucket],
"asset_share": round(amount / total_assets, 4) if bucket not in LIABILITY_BUCKETS and total_assets else None, "asset_share": round(amount / total_assets, 4) if bucket not in LIABILITY_BUCKETS and total_assets else None,
"liability_share": round(abs(amount) / total_liabilities, 4) if bucket in LIABILITY_BUCKETS and total_liabilities else None, "liability_share": round(abs(amount) / total_liabilities, 4) if bucket in LIABILITY_BUCKETS and total_liabilities else None,
"share": round(abs(amount) / denominator, 4) if denominator else None, "share": round(abs(amount) / denominator, 4) if denominator else None,
@@ -380,29 +555,102 @@ def _summarize_valuation_items(included_items: list[dict[str, Any]], excluded_it
} }
def create_manual_asset(name: str, valuation_amount: int | None, valuation_date: str, liquidity_bucket: str, source_note: str = "", memo: str = "", currency: str = "KRW", active: bool = True, db_path: str | None = None) -> dict[str, Any]:
def create_manual_asset(*args, **kwargs): if not name.strip():
return fail("unsupported", "manual assets are no longer supported") return fail("validation_error", "manual asset name is required")
try:
bucket = normalize_liquidity_bucket(liquidity_bucket)
amount = int(valuation_amount) if valuation_amount is not None else None
vdate = normalize_date(valuation_date, default_today=True)
except Exception as e:
return fail("validation_error", str(e))
ts = now_iso()
asset_id = new_id("asset")
with db(db_path) as conn:
conn.execute(
"""INSERT INTO manual_assets(id,name,liquidity_bucket,valuation_amount,valuation_date,currency,source_note,memo,active,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?)""",
(asset_id, name.strip(), bucket, amount, vdate, currency, source_note, memo, int(active), ts, ts),
)
row = conn.execute("SELECT * FROM manual_assets WHERE id=?", (asset_id,)).fetchone()
return ok(manual_asset={**row_to_dict(row), "authority_source": "manual_assets"})
def list_manual_assets(*args, **kwargs): @read_operation
return fail("unsupported", "manual assets are no longer supported") def list_manual_assets(active_only: bool = True, db_path: str | None = None) -> dict[str, Any]:
where = "WHERE active=1" if active_only else ""
with read_db(db_path) as conn:
rows = conn.execute(f"SELECT * FROM manual_assets {where} ORDER BY name").fetchall()
assets = [{**row_to_dict(row), "authority_source": "manual_assets"} for row in rows]
return ok(manual_assets=assets, count=len(assets), valuation_policy=_valuation_policy())
def update_manual_asset(*args, **kwargs): def update_manual_asset(asset_id: str, name: str | None = None, valuation_amount: int | None = None, valuation_date: str | None = None, liquidity_bucket: str | None = None, source_note: str | None = None, memo: str | None = None, currency: str | None = None, active: bool | None = None, db_path: str | None = None) -> dict[str, Any]:
return fail("unsupported", "manual assets are no longer supported") allowed_updates: list[str] = []
params: list[Any] = []
try:
if name is not None:
if not name.strip():
return fail("validation_error", "manual asset name is required")
allowed_updates.append("name=?")
params.append(name.strip())
if valuation_amount is not None:
allowed_updates.append("valuation_amount=?")
params.append(int(valuation_amount))
if valuation_date is not None:
allowed_updates.append("valuation_date=?")
params.append(normalize_date(valuation_date, default_today=True))
if liquidity_bucket is not None:
allowed_updates.append("liquidity_bucket=?")
params.append(normalize_liquidity_bucket(liquidity_bucket))
if source_note is not None:
allowed_updates.append("source_note=?")
params.append(source_note)
if memo is not None:
allowed_updates.append("memo=?")
params.append(memo)
if currency is not None:
allowed_updates.append("currency=?")
params.append(currency or "KRW")
if active is not None:
allowed_updates.append("active=?")
params.append(int(bool(active)))
except Exception as e:
return fail("validation_error", str(e))
if not allowed_updates:
return fail("validation_error", "no fields to update")
allowed_updates.append("updated_at=?")
params.extend([now_iso(), asset_id])
with db(db_path) as conn:
cur = conn.execute(f"UPDATE manual_assets SET {', '.join(allowed_updates)} WHERE id=?", params)
if cur.rowcount == 0:
return fail("not_found", "manual asset not found")
row = conn.execute("SELECT * FROM manual_assets WHERE id=?", (asset_id,)).fetchone()
return ok(manual_asset={**row_to_dict(row), "authority_source": "manual_assets"})
def deactivate_manual_asset(*args, **kwargs): def deactivate_manual_asset(asset_id: str, hard_delete: bool = False, db_path: str | None = None) -> dict[str, Any]:
return fail("unsupported", "manual assets are no longer supported") with db(db_path) as conn:
if hard_delete:
cur = conn.execute("DELETE FROM manual_assets WHERE id=?", (asset_id,))
else:
cur = conn.execute("UPDATE manual_assets SET active=0, updated_at=? WHERE id=?", (now_iso(), asset_id))
if cur.rowcount == 0:
return fail("not_found", "manual asset not found")
row = None if hard_delete else conn.execute("SELECT * FROM manual_assets WHERE id=?", (asset_id,)).fetchone()
payload = {"manual_asset_id": asset_id, "deactivated": True, "hard_delete": hard_delete}
if row is not None:
payload["manual_asset"] = {**row_to_dict(row), "authority_source": "manual_assets"}
return ok(**payload)
def _snapshot_preview_from_conn(conn: sqlite3.Connection, basis: str, selection_policy: str = "exact") -> dict[str, Any]: def _snapshot_preview_from_conn(conn: sqlite3.Connection, basis: str, selection_policy: str = "exact") -> dict[str, Any]:
account_items, account_excluded, invalid_bucket_accounts = _account_valuation_items(conn, basis, selection_policy) account_items, account_excluded, invalid_bucket_accounts = _account_valuation_items(conn, basis, selection_policy)
included = account_items manual_items, manual_excluded = _manual_asset_valuation_items(conn, basis_date=basis, selection_policy=selection_policy)
excluded = account_excluded included = account_items + manual_items
excluded = account_excluded + manual_excluded
summary = _summarize_valuation_items(included, excluded) summary = _summarize_valuation_items(included, excluded)
existing_rows = conn.execute("SELECT COUNT(*) FROM account_snapshot WHERE basis_date=?", (basis,)).fetchone()[0] existing_rows = conn.execute("SELECT COUNT(*) FROM asset_snapshots WHERE basis_date=?", (basis,)).fetchone()[0]
warnings: list[dict[str, Any]] = [] warnings: list[dict[str, Any]] = []
for item in excluded: for item in excluded:
warnings.append({"code": item.get("reason", "excluded_item"), "source_type": item.get("source_type", ""), "source_id": item.get("source_id", ""), "currency": item.get("currency", "KRW")}) warnings.append({"code": item.get("reason", "excluded_item"), "source_type": item.get("source_type", ""), "source_id": item.get("source_id", ""), "currency": item.get("currency", "KRW")})
@@ -425,8 +673,16 @@ def _snapshot_preview_from_conn(conn: sqlite3.Connection, basis: str, selection_
for candidate in warning.get("candidates", []) for candidate in warning.get("candidates", [])
if candidate.get("id") if candidate.get("id")
}) })
# The removed valuation table is never queried, including for preview hashes.
candidate_state_hashes = [] candidate_state_hashes = []
for candidate_id in candidate_ids:
candidate_row = conn.execute(
"SELECT * FROM account_valuations WHERE id=?", (candidate_id,),
).fetchone()
if candidate_row is not None:
candidate_state_hashes.append({
"id": candidate_id,
"state_hash": _valuation_row_state_hash(candidate_row),
})
public_excluded = [_public_evidence(item) for item in excluded] public_excluded = [_public_evidence(item) for item in excluded]
return { return {
"basis_date": basis, "basis_date": basis,
@@ -472,7 +728,6 @@ def preview_asset_snapshot_close(basis_date: str = "", db_path: str | None = Non
def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str = "", correction_reason: str = "", db_path: str | None = None, selection_policy: str = "exact", idempotency_key: str = "", allow_missing_exclusions: bool = False, allow_as_of_carry_forward: bool = False, approved_by: str = "", approval_reason: str = "", approval_reference: str = "", preview_fingerprint: str = "") -> dict[str, Any]: def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str = "", correction_reason: str = "", db_path: str | None = None, selection_policy: str = "exact", idempotency_key: str = "", allow_missing_exclusions: bool = False, allow_as_of_carry_forward: bool = False, approved_by: str = "", approval_reason: str = "", approval_reference: str = "", preview_fingerprint: str = "") -> dict[str, Any]:
return _snapshot_run_history_unavailable()
if selection_policy not in {"exact", "as_of"}: if selection_policy not in {"exact", "as_of"}:
return fail("validation_error", "selection_policy must be exact or as_of") return fail("validation_error", "selection_policy must be exact or as_of")
try: try:
@@ -483,12 +738,6 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
run_id = new_id("snaprun") run_id = new_id("snaprun")
with db(db_path) as conn: with db(db_path) as conn:
conn.execute("BEGIN IMMEDIATE") conn.execute("BEGIN IMMEDIATE")
month_row = conn.execute("SELECT id FROM monthly_settlement WHERE month=?", (basis[:7],)).fetchone()
if month_row:
monthly_settlement_id = month_row["id"]
else:
monthly_settlement_id = new_id("month")
conn.execute("INSERT INTO monthly_settlement(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) VALUES(?,?,?,?,?,?,?,?,?)", (monthly_settlement_id,basis[:7],basis,"draft",1,approved_by or "asset-close",ts,approved_by or "asset-close",ts))
preview = _snapshot_preview_from_conn(conn, basis, selection_policy) preview = _snapshot_preview_from_conn(conn, basis, selection_policy)
if preview_fingerprint and preview_fingerprint != _preview_fingerprint(preview, basis, selection_policy): if preview_fingerprint and preview_fingerprint != _preview_fingerprint(preview, basis, selection_policy):
return fail("authority_drift", "asset authority changed since preview", basis_date=basis) return fail("authority_drift", "asset authority changed since preview", basis_date=basis)
@@ -618,7 +867,7 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
row_note = source_note or item.get("source_note", "") or "month-end close" row_note = source_note or item.get("source_note", "") or "month-end close"
row_memo = memo or item.get("memo", "") row_memo = memo or item.get("memo", "")
conn.execute( conn.execute(
"""INSERT INTO account_snapshot(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,valuation_source,source_note,memo,monthly_settlement_id,correction_of_run_id,correction_reason,is_correction,closed_at,valuation_id,selection_policy,verification_status,evidence_kind,exclusion_reason,created_at) """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,valuation_id,selection_policy,verification_status,evidence_kind,exclusion_reason,created_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
( (
snapshot_id, snapshot_id,
@@ -632,7 +881,7 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
item.get("authority_source", item.get("source_type", "")), item.get("authority_source", item.get("source_type", "")),
row_note, row_note,
row_memo, row_memo,
monthly_settlement_id, run_id,
correction_of_run_id, correction_of_run_id,
correction_reason.strip(), correction_reason.strip(),
int(is_correction), int(is_correction),
@@ -645,10 +894,6 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
ts, ts,
), ),
) )
conn.execute(
"UPDATE account_snapshot SET monthly_settlement_id=? WHERE id=? AND monthly_settlement_id IS NULL",
(monthly_settlement_id, snapshot_id),
)
snapshot_ids.append(snapshot_id) snapshot_ids.append(snapshot_id)
applied_evidence = [{k: item.get(k) for k in ("id", "source_type", "source_id", "authority_source", "name", "type", "liquidity_bucket", "active", "amount", "valuation_amount", "last_balance", "last_synced_at", "currency", "valuation_id", "valuation_kind", "valuation_date", "age_days", "carried_forward", "verification_status", "evidence_kind", "unverified_candidates") if item.get(k) is not None} for item in included] applied_evidence = [{k: item.get(k) for k in ("id", "source_type", "source_id", "authority_source", "name", "type", "liquidity_bucket", "active", "amount", "valuation_amount", "last_balance", "last_synced_at", "currency", "valuation_id", "valuation_kind", "valuation_date", "age_days", "carried_forward", "verification_status", "evidence_kind", "unverified_candidates") if item.get(k) is not None} for item in included]
excluded_evidence = [{k: item.get(k) for k in ("source_type", "source_id", "reason", "currency")} for item in preview.get("excluded_items", [])] excluded_evidence = [{k: item.get(k) for k in ("source_type", "source_id", "reason", "currency")} for item in preview.get("excluded_items", [])]
@@ -676,8 +921,6 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
except sqlite3.IntegrityError: except sqlite3.IntegrityError:
conn.rollback() conn.rollback()
return fail("snapshot_conflict", "another snapshot close already owns this basis date", basis_date=basis) return fail("snapshot_conflict", "another snapshot close already owns this basis date", basis_date=basis)
if "source_snapshot_json" in {r["name"] for r in conn.execute("PRAGMA table_info(asset_snapshot_runs)") }:
conn.execute("UPDATE asset_snapshot_runs SET source_snapshot_json=? WHERE id=?", (json.dumps({"snapshot_row_ids": snapshot_ids, "frozen_warnings": preview["warnings"]}, ensure_ascii=False, sort_keys=True), run_id))
approval = { approval = {
"approved_by": approved_by, "approved_by": approved_by,
"approved_at": ts if approved_by else "", "approved_at": ts if approved_by else "",
@@ -727,8 +970,8 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
def create_asset_snapshot(basis_date: str, source_type: str, source_id: str, name: str, amount: int | None, liquidity_bucket: str, valuation_source: str = "", source_note: str = "", memo: str = "", currency: str = "KRW", db_path: str | None = None) -> dict[str, Any]: def create_asset_snapshot(basis_date: str, source_type: str, source_id: str, name: str, amount: int | None, liquidity_bucket: str, valuation_source: str = "", source_note: str = "", memo: str = "", currency: str = "KRW", db_path: str | None = None) -> dict[str, Any]:
if source_type != "account": if source_type not in {"account", "manual_asset"}:
return fail("unsupported", "manual asset snapshots are no longer supported") return fail("validation_error", "source_type must be one of ['account', 'manual_asset']")
if not name.strip(): if not name.strip():
return fail("validation_error", "snapshot item name is required") return fail("validation_error", "snapshot item name is required")
try: try:
@@ -741,17 +984,16 @@ def create_asset_snapshot(basis_date: str, source_type: str, source_id: str, nam
snapshot_id = new_id("snap") snapshot_id = new_id("snap")
with db(db_path) as conn: with db(db_path) as conn:
conn.execute( conn.execute(
"""INSERT INTO account_snapshot(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,valuation_source,source_note,memo,created_at) """INSERT INTO asset_snapshots(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,valuation_source,source_note,memo,created_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""", VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""",
(snapshot_id, basis, source_type, source_id, name.strip(), amount_int, currency, bucket, valuation_source or source_type, source_note, memo, ts), (snapshot_id, basis, source_type, source_id, name.strip(), amount_int, currency, bucket, valuation_source or source_type, source_note, memo, ts),
) )
row = conn.execute("SELECT * FROM account_snapshot WHERE id=?", (snapshot_id,)).fetchone() row = conn.execute("SELECT * FROM asset_snapshots WHERE id=?", (snapshot_id,)).fetchone()
return ok(asset_snapshot=row_to_dict(row)) return ok(asset_snapshot=row_to_dict(row))
@read_operation @read_operation
def get_asset_snapshot_report(basis_date: str = "", db_path: str | None = None, selection_policy: str = "exact") -> dict[str, Any]: def get_asset_snapshot_report(basis_date: str = "", db_path: str | None = None, selection_policy: str = "exact") -> dict[str, Any]:
return _snapshot_run_history_unavailable()
if selection_policy not in {"exact", "as_of"}: if selection_policy not in {"exact", "as_of"}:
return fail("validation_error", "selection_policy must be exact or as_of") return fail("validation_error", "selection_policy must be exact or as_of")
try: try:
@@ -775,7 +1017,7 @@ def get_asset_snapshot_report(basis_date: str = "", db_path: str | None = None,
evidence_provenance = _evidence_provenance(close_metadata.get("applied_evidence", []), basis) evidence_provenance = _evidence_provenance(close_metadata.get("applied_evidence", []), basis)
return _ok_with_provenance_aliases( return _ok_with_provenance_aliases(
basis_date=basis, basis_date=basis,
source="legacy_asset_snapshots" if legacy_closed else "account_snapshot", source="legacy_asset_snapshots" if legacy_closed else "asset_snapshots",
is_closed_snapshot=True, is_closed_snapshot=True,
snapshot_run_id=close_metadata.get("snapshot_run_id", ""), snapshot_run_id=close_metadata.get("snapshot_run_id", ""),
snapshot_count=len(snapshots), snapshot_count=len(snapshots),
@@ -849,7 +1091,6 @@ def get_asset_snapshot_report(basis_date: str = "", db_path: str | None = None,
@read_operation @read_operation
def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: int = 12, db_path: str | None = None) -> dict[str, Any]: def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: int = 12, db_path: str | None = None) -> dict[str, Any]:
return ok(timeseries=[], warnings=[{"code": "run_history_unavailable", "message": _RUN_HISTORY_UNAVAILABLE}])
try: try:
months_int = max(1, min(int(months), 120)) months_int = max(1, min(int(months), 120))
end_norm = normalize_date(end_date, default_today=True) if end_date else current_kst_date().isoformat() end_norm = normalize_date(end_date, default_today=True) if end_date else current_kst_date().isoformat()
@@ -865,7 +1106,7 @@ def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: i
return fail("validation_error", str(e)) return fail("validation_error", str(e))
with read_db(db_path) as conn: with read_db(db_path) as conn:
basis_rows = conn.execute( basis_rows = conn.execute(
"""SELECT basis_date FROM account_snapshot """SELECT basis_date FROM asset_snapshots
WHERE basis_date>=? AND basis_date<=? WHERE basis_date>=? AND basis_date<=?
UNION UNION
SELECT basis_date FROM asset_snapshot_runs SELECT basis_date FROM asset_snapshot_runs
@@ -881,7 +1122,7 @@ def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: i
return fail("data_integrity_error", "snapshot evidence is malformed", snapshot_run_id=metadata.get("snapshot_run_id", "")) return fail("data_integrity_error", "snapshot evidence is malformed", snapshot_run_id=metadata.get("snapshot_run_id", ""))
items = _snapshot_items(conn, basis) items = _snapshot_items(conn, basis)
run_public = _closed_run_metadata(metadata) run_public = _closed_run_metadata(metadata)
run_public["warnings"] = _closed_warnings(metadata, basis, conn.execute("SELECT rowid AS _rowid, * FROM account_snapshot WHERE basis_date=? ORDER BY created_at ASC, name ASC", (basis,)).fetchall()) run_public["warnings"] = _closed_warnings(metadata, basis, conn.execute("SELECT rowid AS _rowid, * FROM asset_snapshots WHERE basis_date=? ORDER BY created_at ASC, name ASC", (basis,)).fetchall())
run_provenance = _evidence_provenance(metadata.get("applied_evidence", []), basis) run_provenance = _evidence_provenance(metadata.get("applied_evidence", []), basis)
summary = _summarize_valuation_items(items, run_public["excluded_items"]) summary = _summarize_valuation_items(items, run_public["excluded_items"])
points.append( points.append(
@@ -890,7 +1131,7 @@ def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: i
"total_assets": summary["total_assets"], "total_assets": summary["total_assets"],
"total_liabilities": summary["total_liabilities"], "total_liabilities": summary["total_liabilities"],
"net_worth": summary["net_worth"], "net_worth": summary["net_worth"],
"source": "account_snapshot", "source": "asset_snapshots",
"item_count": len(items), "item_count": len(items),
"snapshot_run_id": metadata.get("snapshot_run_id", ""), "snapshot_run_id": metadata.get("snapshot_run_id", ""),
"draft_id": run_public.get("draft_id", ""), "draft_id": run_public.get("draft_id", ""),
@@ -936,15 +1177,16 @@ def get_asset_liquidity_report(db_path: str | None = None) -> dict[str, Any]:
""" """
with read_db(db_path) as conn: with read_db(db_path) as conn:
account_items, account_excluded, invalid_bucket_accounts = _account_valuation_items(conn) account_items, account_excluded, invalid_bucket_accounts = _account_valuation_items(conn)
included = account_items manual_items, manual_excluded = _manual_asset_valuation_items(conn)
excluded = account_excluded included = account_items + manual_items
excluded = account_excluded + manual_excluded
summary = _summarize_valuation_items(included, excluded) summary = _summarize_valuation_items(included, excluded)
source_accounts = [item for item in included if item.get("source_type") == "account"] source_accounts = [item for item in included if item.get("source_type") == "account"]
source = "accounts.last_balance" source = "accounts.last_balance" if not manual_items and not manual_excluded else "accounts.last_balance+manual_assets"
return ok( return ok(
buckets=summary["buckets"], buckets=summary["buckets"],
accounts=source_accounts, accounts=source_accounts,
manual_assets=[], manual_assets=manual_items,
total_assets=summary["total_assets"], total_assets=summary["total_assets"],
total_liabilities=summary["total_liabilities"], total_liabilities=summary["total_liabilities"],
net_worth=summary["net_worth"], net_worth=summary["net_worth"],
@@ -954,7 +1196,7 @@ def get_asset_liquidity_report(db_path: str | None = None) -> dict[str, Any]:
], ],
missing_valuation_items=excluded, missing_valuation_items=excluded,
invalid_bucket_accounts=invalid_bucket_accounts, invalid_bucket_accounts=invalid_bucket_accounts,
manual_asset_count=0, manual_asset_count=len(manual_items),
included_items=included, included_items=included,
excluded_items=excluded, excluded_items=excluded,
valuation_policy=summary["valuation_policy"], valuation_policy=summary["valuation_policy"],
@@ -33,7 +33,7 @@ def _review_cashflow(month: str, db_path: str | None = None, conn=None) -> dict[
start, end = _month_range(month) start, end = _month_range(month)
rows = conn.execute(f"SELECT {CASHFLOW_KIND_SQL} AS cashflow_kind, t.category, t.subcategory, t.currency, SUM(t.amount) AS amount, COUNT(*) AS count FROM transactions t LEFT JOIN ledger_categories lc ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory WHERE t.status='active' AND t.transaction_date>=? AND t.transaction_date<? GROUP BY {CASHFLOW_KIND_SQL}, t.category, t.subcategory, t.currency ORDER BY cashflow_kind, amount DESC", (start, end)).fetchall() rows = conn.execute(f"SELECT {CASHFLOW_KIND_SQL} AS cashflow_kind, t.category, t.subcategory, t.currency, SUM(t.amount) AS amount, COUNT(*) AS count FROM transactions t LEFT JOIN ledger_categories lc ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory WHERE t.status='active' AND t.transaction_date>=? AND t.transaction_date<? GROUP BY {CASHFLOW_KIND_SQL}, t.category, t.subcategory, t.currency ORDER BY cashflow_kind, amount DESC", (start, end)).fetchall()
categories = [dict(r) for r in rows] categories = [dict(r) for r in rows]
source_rows = conn.execute(f"SELECT t.id, t.transaction_date, t.type, t.amount, t.currency, t.category, t.subcategory, t.account_id FROM transactions t LEFT JOIN ledger_categories lc ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory WHERE t.status='active' AND t.transaction_date>=? AND t.transaction_date<? ORDER BY t.id", (start, end)).fetchall() source_rows = conn.execute("SELECT t.id, t.transaction_date, t.type, t.amount, t.currency, t.category, t.subcategory, t.account_id FROM transactions t LEFT JOIN ledger_categories lc ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory WHERE t.status='active' AND t.transaction_date>=? AND t.transaction_date<? ORDER BY t.id", (start, end)).fetchall()
totals = {} totals = {}
for item in categories: totals[item['cashflow_kind']] = totals.get(item['cashflow_kind'], 0) + int(item['amount'] or 0) for item in categories: totals[item['cashflow_kind']] = totals.get(item['cashflow_kind'], 0) + int(item['amount'] or 0)
income, expenses = totals.get('income', 0), totals.get('expense', 0) income, expenses = totals.get('income', 0), totals.get('expense', 0)
@@ -396,7 +396,6 @@ def unlink_account_snapshot(snapshot_id: str, monthly_settlement_id: str, db_pat
return fail("db_error", "account snapshot unlink failed") return fail("db_error", "account snapshot unlink failed")
def list_monthly_settlement_history(month: str = "", db_path: str | None = None) -> dict[str, Any]: def list_monthly_settlement_history(month: str = "", db_path: str | None = None) -> dict[str, Any]:
return fail("unsupported", "run history unavailable")
"""Return immutable close/correction records; lifecycle history is authoritative.""" """Return immutable close/correction records; lifecycle history is authoritative."""
try: try:
selected = normalize_month(month) if month else "" selected = normalize_month(month) if month else ""
@@ -419,7 +418,6 @@ def list_monthly_settlement_history(month: str = "", db_path: str | None = None)
def get_monthly_settlement_history_record(run_id: str, db_path: str | None = None) -> dict[str, Any]: def get_monthly_settlement_history_record(run_id: str, db_path: str | None = None) -> dict[str, Any]:
return fail("unsupported", "run history unavailable")
"""Read one immutable close/correction record by run ID.""" """Read one immutable close/correction record by run ID."""
if not isinstance(run_id, str) or not run_id: if not isinstance(run_id, str) or not run_id:
return fail("validation_error", "run_id is required") return fail("validation_error", "run_id is required")
@@ -563,7 +561,6 @@ def get_monthly_settlement_summary(month: str = "", db_path: str | None = None)
def get_monthly_settlement_snapshot(month: str = "", run_id: str = "", db_path: str | None = None) -> dict[str, Any]: def get_monthly_settlement_snapshot(month: str = "", run_id: str = "", db_path: str | None = None) -> dict[str, Any]:
return fail("unsupported", "run history unavailable")
"""Read a persisted monthly settlement close without consulting live ledger state.""" """Read a persisted monthly settlement close without consulting live ledger state."""
if not isinstance(month, str) or not isinstance(run_id, str): if not isinstance(month, str) or not isinstance(run_id, str):
return fail("validation_error", "month and run_id must be strings") return fail("validation_error", "month and run_id must be strings")
@@ -582,32 +579,38 @@ def get_monthly_settlement_snapshot(month: str = "", run_id: str = "", db_path:
try: try:
with read_db(db_path) as conn: with read_db(db_path) as conn:
tables = {r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} tables = {r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
if "asset_snapshot_runs" not in tables or "account_snapshot" not in tables: if "asset_snapshot_runs" not in tables or "asset_snapshots" not in tables:
return fail("not_found", "settlement snapshot not found") return fail("not_found", "settlement snapshot not found")
run = None run = None
rows = [] rows = []
legacy = False legacy = False
metadata: dict[str, Any] = {} metadata: dict[str, Any] = {}
run_cols = {r["name"] for r in conn.execute("PRAGMA table_info(asset_snapshot_runs)")}
if run_id: if run_id:
# Pre-comprehensive headers are intentionally read without
# assuming schema-16-only columns.
run = conn.execute("SELECT rowid AS _run_rowid,* FROM asset_snapshot_runs WHERE id=?", (run_id,)).fetchone() run = conn.execute("SELECT rowid AS _run_rowid,* FROM asset_snapshot_runs WHERE id=?", (run_id,)).fetchone()
if run: if run:
from .assets_service import _snapshot_rows_for_run from .assets_service import _snapshot_rows_for_run
rows, metadata = _snapshot_rows_for_run(conn, run_id) rows, metadata = _snapshot_rows_for_run(conn, run_id)
else: else:
cols = {r["name"] for r in conn.execute("PRAGMA table_info(account_snapshot)")} cols = {r["name"] for r in conn.execute("PRAGMA table_info(asset_snapshots)")}
if {"snapshot_run_id", "closed_at"} <= cols: if {"snapshot_run_id", "closed_at"} <= cols:
rows = conn.execute("SELECT rowid AS _rowid,* FROM account_snapshot WHERE snapshot_run_id=? AND closed_at<>'' ORDER BY rowid", (run_id,)).fetchall() 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: if rows:
legacy = True legacy = True
metadata = {"snapshot_run_id": run_id, "legacy_closed": True, "close_status": "legacy_read_only"} metadata = {"snapshot_run_id": run_id, "legacy_closed": True, "close_status": "legacy_read_only"}
else: else:
if selected_month and "settlement_month" in run_cols: 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() run_columns = {r["name"] for r in conn.execute("PRAGMA table_info(asset_snapshot_runs)")}
elif selected_month: comprehensive_columns = {"snapshot_schema_version", "aggregation_version"}
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 comprehensive_columns <= run_columns:
run = conn.execute(
"""SELECT rowid AS _run_rowid,* FROM asset_snapshot_runs
WHERE basis_date=? AND snapshot_schema_version=1
AND aggregation_version='comprehensive-v1'
ORDER BY rowid DESC LIMIT 1""",
(_month_end_date(selected_month),),
).fetchone()
if run is None:
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: if run is None:
basis = _month_end_date(selected_month) if selected_month else "" basis = _month_end_date(selected_month) if selected_month else ""
if basis: if basis:
@@ -650,7 +653,7 @@ def get_monthly_settlement_snapshot(month: str = "", run_id: str = "", db_path:
legacy = not comprehensive legacy = not comprehensive
cashflows = [] cashflows = []
if comprehensive and "settlement_cashflow_snapshots" in tables: 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 monthly_settlement_id=? ORDER BY cashflow_kind,category,subcategory,currency", (rid,))] 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} 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 = [] safe_items = []
for row in rows: for row in rows:
@@ -99,24 +99,83 @@ def get_balance_reconciliation_report(account_id: str = "", baseline_balance: in
effective_at = baseline_at_norm or account["last_synced_at"] or now_iso() effective_at = baseline_at_norm or account["last_synced_at"] or now_iso()
valuation = None valuation = None
ambiguous_valuation = False ambiguous_valuation = False
valuation_status = "unavailable" valuation_rows = conn.execute(
calc = _account_balance_row(conn, account["id"], effective_at, False) """SELECT * FROM account_valuations v
WHERE v.account_id=? AND v.verification_status='verified'
AND UPPER(v.currency)=UPPER(?)
AND v.basis_date<=?
AND NOT EXISTS (
SELECT 1 FROM account_valuations child
WHERE child.supersedes_id=v.id
)
ORDER BY v.basis_date DESC, v.recorded_at DESC, v.id DESC""",
(account["id"], account["currency"] or "KRW", effective_at[:10]),
).fetchall()
purposes = (
("loan_balance", "loan", "balance")
if account["type"] == "loan"
else (("total_value", "balance", "cash_balance") if account["type"] == "investment" else ("cash_balance", "balance"))
)
transaction_rows = conn.execute(
"""SELECT transaction_date FROM transactions
WHERE status='active' AND account_id=? AND transaction_date<=?
ORDER BY transaction_date ASC, transaction_datetime ASC, created_at ASC""",
(account["id"], effective_at[:10]),
).fetchall()
first_transaction_date = transaction_rows[0]["transaction_date"] if transaction_rows else None
has_opening_evidence = bool(
first_transaction_date
and any(
row["basis_date"] < first_transaction_date
and row["valuation_kind"] in purposes
and (
account["type"] != "loan"
or row["representative_liability_definition"]
in {"principal_only", "principal_interest_fees"}
)
for row in valuation_rows
)
)
calc = _account_balance_row(conn, account["id"], effective_at, has_opening_evidence)
for purpose in purposes:
scoped = [
row for row in valuation_rows
if row["valuation_kind"] == purpose
and (
account["type"] != "loan"
or row["representative_liability_definition"]
in {"principal_only", "principal_interest_fees"}
)
]
if not scoped:
continue
latest_basis = max(row["basis_date"] for row in scoped)
latest = [row for row in scoped if row["basis_date"] == latest_basis]
if len(latest) != 1:
ambiguous_valuation = True
else:
valuation = latest[0]
break
if effective_baseline is None: if effective_baseline is None:
difference = None difference = None
ratio = None ratio = None
status = "BASELINE_REQUIRED" status = "BASELINE_REQUIRED"
elif not calc["calculation_complete"]:
difference = None
ratio = None
status = "INCOMPLETE_CALCULATION"
elif account["type"] == "investment":
difference = None
ratio = None
status = "VALUATION_UNAVAILABLE"
else: else:
difference = int(effective_baseline) - int(calc["calculated_balance"]) difference = int(effective_baseline) - int(calc["calculated_balance"])
ratio = abs(difference) / abs(int(effective_baseline)) if int(effective_baseline) else None ratio = abs(difference) / abs(int(effective_baseline)) if int(effective_baseline) else None
status = "OK" if abs(difference) <= tolerance_int else "REVIEW_NEEDED" status = "OK" if abs(difference) <= tolerance_int else "REVIEW_NEEDED"
if account["type"] == "investment" and ambiguous_valuation:
status = "AMBIGUOUS_VALUATION"
difference = None
elif account["type"] == "investment" and valuation is None:
status = "VALUATION_UNAVAILABLE"
difference = None
elif not calc["calculation_complete"]:
status = "INCOMPLETE_CALCULATION"
difference = None
elif account["type"] == "investment" and valuation is not None:
status = "VALUATION_MANAGED"
difference = None
items.append({ items.append({
"account_id": account["id"], "account_id": account["id"],
"account_name": account["name"], "account_name": account["name"],
@@ -39,10 +39,11 @@ def _draft(conn, did):
def _current_semantics(conn, item): def _current_semantics(conn, item):
if item['source_type']=='account': if item['source_type']=='account':
src=conn.execute('SELECT * FROM accounts WHERE id=?',(item['source_id'],)).fetchone() src=conn.execute('SELECT * FROM accounts WHERE id=?',(item['source_id'],)).fetchone()
val=None val=conn.execute('SELECT * FROM account_valuations WHERE id=?',(item.get('valuation_id',''),)).fetchone() if item.get('valuation_id') else None
child=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} return {'source':dict(src) if src else None,'valuation':dict(val) if val else None,'child':dict(child) if child else None}
return {'source': 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): def _nonempty(value):
@@ -107,8 +108,10 @@ def _review(conn, draft):
if status == 'verified' and eligible_date and source.get('active') and row['currency'] == 'KRW' and not row['review_needed']: 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 row['amount'] = candidate if row['included'] else None
elif item['source_type'] == 'account': elif item['source_type'] == 'account':
row['candidate_status'] = 'missing' # Candidate visibility does not make it selected or authoritative.
row['source_missing_warning'] = 'account valuation source unavailable' 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': if row['currency'] != 'KRW':
warnings.append({'code': 'non_krw', 'source_id': item['source_id']}) 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' row['status'] = 'excluded' if not row['included'] else 'ready' if row['amount'] is not None else 'missing'
@@ -122,7 +125,7 @@ def _review(conn, draft):
included.append(row) included.append(row)
items.append(row) items.append(row)
# All candidate changes and source/selected evidence are bound privately; never exposed as raw JSON. # All candidate changes and source/selected evidence are bound privately; never exposed as raw JSON.
candidates = [] 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}) states.append({'stored': item, 'current': current, 'candidates': candidates})
latest = _latest_close(conn, draft['basis_date']) latest = _latest_close(conn, draft['basis_date'])
summary = _summarize_valuation_items(included) summary = _summarize_valuation_items(included)
@@ -144,7 +147,7 @@ def _review(conn, draft):
full_totals = {**totals} 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) 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(), return {'draft_id': draft['id'], 'version': draft['version'], 'review_fingerprint': hashlib.sha256(serial.encode()).hexdigest(),
'cashflow_policy': policy, 'cashflow_fingerprint': cf['cashflow_fingerprint'], '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'], '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, 'items': items, 'included_items': included, 'excluded_items': excluded, 'missing_items': missing, 'warnings': warnings,
'approval_required': True, 'totals': totals, **totals, 'approval_required': True, 'totals': totals, **totals,
'complete': bool(items) and not missing and not cf['cashflow_blockers'], 'complete': bool(items) and not missing and not cf['cashflow_blockers'],
@@ -174,19 +177,17 @@ def create_settlement_draft(month: str, created_by: str, idempotency_key: str, b
if basis_date and basis_date != basis: raise ValueError('basis_date must be month end') 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 _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 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 bool(correction_of_run_id) != bool(correction_reason.strip()): raise ValueError('correction run and reason must be provided together') if correction_of_run_id and not _nonempty(correction_reason): raise ValueError('correction reason required')
with db(db_path) as c: with db(db_path) as c:
c.execute('BEGIN IMMEDIATE') c.execute('BEGIN IMMEDIATE')
payload_hash=hashlib.sha256(json.dumps([month,created_by,basis,memo,correction_of_run_id,correction_reason],separators=(',',':')).encode()).hexdigest() 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=? AND status <> 'cancelled'",(idempotency_key,)).fetchone() old=c.execute('SELECT * FROM settlement_drafts WHERE create_key=?',(idempotency_key,)).fetchone()
if old: if old:
if old['original_request_hash']!=payload_hash: return _err('conflict','idempotency key payload conflict') if old['original_request_hash']!=payload_hash: return _err('conflict','idempotency key payload conflict')
return _result(c,old['id']) 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') 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) latest = _latest_close(c, basis)
if not correction_of_run_id and latest: if correction_of_run_id != latest:
return _err('validation_error', 'closed month requires correction linkage and reason')
if correction_of_run_id and correction_of_run_id != latest:
return _err('validation_error', 'latest closed run must be linked explicitly with correction reason') return _err('validation_error', 'latest closed run must be linked explicitly with correction reason')
did=uuid4().hex; ts=now_iso() 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)) 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))
@@ -198,6 +199,9 @@ def create_settlement_draft(month: str, created_by: str, idempotency_key: str, b
vid = selected.get(a['id'], '') vid = selected.get(a['id'], '')
ev = _freeze(c, 'account', a['id'], vid) 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))) 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) 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') 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')
@@ -262,10 +266,12 @@ def refresh_settlement_draft_targets(draft_id: str,expected_version: int,updated
if d['status'] != 'active' or d['version'] != expected_version: if d['status'] != 'active' or d['version'] != expected_version:
return _err('conflict', 'draft state/version conflict') return _err('conflict', 'draft state/version conflict')
ts = now_iso() ts = now_iso()
selected = {} 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)} existing = {(x['source_type'], x['source_id']): x for x in _items(c, draft_id)}
active = set() active = set()
for typ, table in [('account', 'accounts')]: 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(): for source in c.execute(f'SELECT id FROM {table} WHERE active=1 ORDER BY id').fetchall():
key = (typ, source['id']) key = (typ, source['id'])
active.add(key) active.add(key)
@@ -292,8 +298,8 @@ def refresh_settlement_draft_targets(draft_id: str,expected_version: int,updated
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): 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):
if source_type == 'manual': source_type = 'manual_asset' source_type = 'manual_asset' if source_type == 'manual' else source_type
if source_type != 'account' or type(included) is not bool or selection_mode not in ('exact', 'carried_before', 'manual_substitute'): 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') 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): 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') return _err('validation_error', 'valid version, source and actor required')
@@ -328,7 +334,7 @@ def set_settlement_draft_item(draft_id: str,source_type: str,source_id: str,expe
elif source_type == 'account': elif source_type == 'account':
if selection_mode != 'exact': return _err('validation_error', 'an explicit valuation ID is required') if selection_mode != 'exact': return _err('validation_error', 'an explicit valuation ID is required')
else: else:
return _err('unsupported', 'manual assets are no longer supported') 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'): if included and source.get('valuation_date'):
delta = (date.fromisoformat(source['valuation_date']) - date.fromisoformat(d['basis_date'])).days 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') 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')
@@ -373,7 +379,6 @@ def approve_settlement_draft(draft_id: str,expected_version: int,review_fingerpr
def finalize_settlement_draft(draft_id: str,expected_version: int,review_fingerprint: str,idempotency_key: str,db_path: str|None=None): def finalize_settlement_draft(draft_id: str,expected_version: int,review_fingerprint: str,idempotency_key: str,db_path: str|None=None):
return fail('unsupported', 'run history unavailable')
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') 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: try:
with db(db_path) as c: with db(db_path) as c:
@@ -404,21 +409,16 @@ def finalize_settlement_draft(draft_id: str,expected_version: int,review_fingerp
applied = [{**x, 'draft_id': draft_id, 'draft_version': expected_version, 'review_fingerprint': review_fingerprint, 'approval': approval} for x in r['included_items']] 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']] 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'] carried = [x for x in applied if x['selection_mode'] == 'carried_before']
run=uuid4().hex; monthly_settlement_id=uuid4().hex; ts=now_iso(); basis=d['basis_date']; status='conditional' if excluded or r['contains_substitutions'] or r['contains_carried'] else 'complete_exact' 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'
month_row=c.execute('SELECT id FROM monthly_settlement WHERE month=?',(d['month'],)).fetchone()
if month_row: monthly_settlement_id=month_row['id']
else:
c.execute("INSERT INTO monthly_settlement(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) VALUES(?,?,?,?,?,?,?,?,?)",(monthly_settlement_id,d['month'],basis,'draft',1,d['approved_by'],ts,d['approved_by'],ts))
c.execute('UPDATE monthly_settlement SET status=\'closed\',version=version+1,approved_by=?,approved_at=?,approved_version=?,approved_fingerprint=?,approval_reason=?,closed_by=?,closed_at=?,updated_by=?,updated_at=? WHERE id=?',(d['approved_by'] or 'legacy-approval',d['approved_at'] or ts,expected_version,review_fingerprint,d['approval_reason'] or 'legacy approval',d['approved_by'] or 'legacy-approval',ts,d['approved_by'] or 'legacy-approval',ts,monthly_settlement_id))
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))) 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 = [] snapshot_ids = []
for x in applied + excluded: for x in applied + excluded:
sid = uuid4().hex sid = uuid4().hex
snapshot_ids.append(sid) snapshot_ids.append(sid)
c.execute('INSERT INTO account_snapshot(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,valuation_source,source_note,memo,monthly_settlement_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'],'','','',monthly_settlement_id,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',''))) 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']: for item in r['cashflow_items']:
c.execute('INSERT INTO settlement_cashflow_snapshots(id,monthly_settlement_id,cashflow_kind,category,subcategory,amount,transaction_count,currency) VALUES(?,?,?,?,?,?,?,?)', (uuid4().hex,monthly_settlement_id,item['cashflow_kind'],item['category'],item['subcategory'],item['amount'],item['transaction_count'],item['currency'])) 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 monthly_settlement_id=? ORDER BY cashflow_kind,category,subcategory,currency', (monthly_settlement_id,))] 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'])) 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') if saved_groups != expected_groups: raise ValueError('persisted cashflow detail mismatch')
header = c.execute('SELECT * FROM asset_snapshot_runs WHERE id=?', (run,)).fetchone() header = c.execute('SELECT * FROM asset_snapshot_runs WHERE id=?', (run,)).fetchone()
@@ -429,12 +429,7 @@ def finalize_settlement_draft(draft_id: str,expected_version: int,review_fingerp
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)): 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: if json.loads(header[key]) != value:
raise ValueError('persisted evidence mismatch') raise ValueError('persisted evidence mismatch')
# A correction shares the canonical month with its parent. Read persisted_assets = [dict(x) for x in c.execute('SELECT * FROM asset_snapshots WHERE snapshot_run_id=? ORDER BY id', (run,))]
# back only this run's lineage, never the parent's rows.
if d['correction_of_run_id']:
persisted_assets = [dict(x) for x in c.execute('SELECT * FROM account_snapshot WHERE monthly_settlement_id=? AND correction_of_run_id=? ORDER BY id', (monthly_settlement_id, d['correction_of_run_id']))]
else:
persisted_assets = [dict(x) for x in c.execute("SELECT * FROM account_snapshot WHERE monthly_settlement_id=? AND COALESCE(correction_of_run_id,'')='' ORDER BY id", (monthly_settlement_id,))]
fields = ('source_type','source_id','amount','currency','liquidity_bucket') fields = ('source_type','source_id','amount','currency','liquidity_bucket')
expected_assets = sorted(zip(snapshot_ids, applied+excluded), key=lambda pair:pair[0]) expected_assets = sorted(zip(snapshot_ids, applied+excluded), key=lambda pair:pair[0])
if len(persisted_assets) != len(expected_assets): if len(persisted_assets) != len(expected_assets):
@@ -68,7 +68,7 @@ def _readonly_sqlite_connection(path: Path) -> sqlite3.Connection:
def _safe_table_counts(conn: sqlite3.Connection) -> dict[str, int]: def _safe_table_counts(conn: sqlite3.Connection) -> dict[str, int]:
counts: dict[str, int] = {} counts: dict[str, int] = {}
for table in ("accounts", "transactions", "budgets", "import_batches", "category_rules", "ledger_categories", "account_snapshot", "review_decisions", "reconciliation_sessions"): for table in ("accounts", "transactions", "budgets", "import_batches", "category_rules", "ledger_categories", "manual_assets", "asset_snapshots", "asset_snapshot_runs", "account_valuations", "account_snapshot", "monthly_settlement", "review_decisions", "reconciliation_sessions"):
exists = conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)).fetchone() exists = conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)).fetchone()
if exists: if exists:
counts[table] = int(conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]) counts[table] = int(conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0])
@@ -1,61 +1,10 @@
from __future__ import annotations from __future__ import annotations
import sqlite3 import sqlite3
import json
from .connection import connect, resolve_db_path from .connection import connect, resolve_db_path
from .schema import ACCOUNT_ADDITIVE_COLUMNS, SCHEMA_SQL, SCHEMA_VERSION, TRANSACTION_ADDITIVE_COLUMNS from .schema import ACCOUNT_ADDITIVE_COLUMNS, SCHEMA_SQL, SCHEMA_VERSION, TRANSACTION_ADDITIVE_COLUMNS
def _execute_script_transactional(conn: sqlite3.Connection, script: str) -> None:
"""Execute a schema script without executescript's implicit COMMIT."""
statement = ""
for line in script.splitlines():
statement += line + "\n"
if sqlite3.complete_statement(statement):
_execute_one_schema_statement(conn, statement)
statement = ""
if statement.strip():
_execute_one_schema_statement(conn, statement)
def _execute_one_schema_statement(conn: sqlite3.Connection, statement: str) -> None:
if statement.lstrip().upper().startswith("PRAGMA JOURNAL_MODE=WAL"):
return
try:
conn.execute(statement)
except sqlite3.OperationalError as exc:
if "no such column" not in str(exc):
raise
def _stored_version(conn: sqlite3.Connection) -> str | None:
try:
row = conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()
except sqlite3.OperationalError as exc:
if "no such table" in str(exc):
return None
raise
return row["value"] if row else None
def _compatible_known_v16(conn: sqlite3.Connection) -> bool:
"""Allow only the already-approved v16 development shape to normalize to v15."""
required = {
"accounts": {"id", "name", "type", "liquidity_bucket"},
"transactions": {"id", "transaction_date", "amount", "raw_payload"},
"account_snapshot": {"id", "monthly_settlement_id", "amount", "verification_status"},
"monthly_settlement": {"id", "month", "status"},
"settlement_cashflow_snapshots": {"id", "monthly_settlement_id", "amount"},
}
for table, columns in required.items():
if not conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)).fetchone():
return False
actual = _existing_columns(conn, table)
if not columns <= actual:
return False
return not conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name IN ('asset_snapshot_runs','manual_assets','asset_snapshots','account_valuations')").fetchone()
def _existing_columns(conn: sqlite3.Connection, table: str) -> set[str]: def _existing_columns(conn: sqlite3.Connection, table: str) -> set[str]:
return {row["name"] for row in conn.execute(f"PRAGMA table_info({table})")} return {row["name"] for row in conn.execute(f"PRAGMA table_info({table})")}
@@ -67,158 +16,8 @@ def _add_missing_columns(conn: sqlite3.Connection, table: str, columns: dict[str
conn.execute(f"ALTER TABLE {table} ADD COLUMN {name} {definition}") conn.execute(f"ALTER TABLE {table} ADD COLUMN {name} {definition}")
def _drop_month_settlement_latest_run_id(conn: sqlite3.Connection) -> None:
"""Rebuild monthly_settlement so SQLite removes its FK-bearing column safely."""
if "latest_run_id" not in _existing_columns(conn, "monthly_settlement"):
return
# The canonical child table references this parent; SQLite otherwise
# rejects the deliberate parent-table rebuild even though child rows stay.
foreign_keys = conn.execute("PRAGMA foreign_keys").fetchone()[0]
conn.execute("PRAGMA defer_foreign_keys=ON")
conn.execute("PRAGMA foreign_keys=OFF")
try:
_drop_month_settlement_latest_run_id_impl(conn)
finally:
conn.execute(f"PRAGMA foreign_keys={'ON' if foreign_keys else 'OFF'}")
def _drop_month_settlement_latest_run_id_impl(conn: sqlite3.Connection) -> None:
conn.execute("""CREATE TABLE month_settlement__v16 (
id TEXT PRIMARY KEY,
month TEXT NOT NULL UNIQUE CHECK (
length(month) = 7 AND substr(month, 5, 1) = '-' AND
CAST(substr(month, 1, 4) AS INTEGER) BETWEEN 1 AND 9999 AND
CAST(substr(month, 6, 2) AS INTEGER) BETWEEN 1 AND 12
),
basis_date TEXT NOT NULL CHECK (date(basis_date) = basis_date),
status TEXT NOT NULL CHECK (status IN ('draft', 'in_review', 'approved', 'closed', 'cancelled')),
version INTEGER NOT NULL CHECK (typeof(version) = 'integer' AND version > 0),
memo TEXT NOT NULL DEFAULT '', created_by TEXT NOT NULL, created_at TEXT NOT NULL,
updated_by TEXT NOT NULL, updated_at TEXT NOT NULL, reviewed_by TEXT, reviewed_at TEXT,
review_fingerprint TEXT, approved_by TEXT, approved_at TEXT,
approved_version INTEGER CHECK (approved_version IS NULL OR (typeof(approved_version) = 'integer' AND approved_version > 0)),
approved_fingerprint TEXT, approved_cashflow_fingerprint TEXT, approval_reason TEXT, approval_flags_json TEXT NOT NULL DEFAULT '{}',
closed_by TEXT, closed_at TEXT, cancelled_by TEXT, cancelled_at TEXT, cancellation_reason TEXT,
CHECK (status NOT IN ('approved', 'closed') OR (approved_by IS NOT NULL AND approved_at IS NOT NULL AND approved_version IS NOT NULL AND approved_fingerprint IS NOT NULL)),
CHECK (status <> 'closed' OR (closed_by IS NOT NULL AND closed_at IS NOT NULL)),
CHECK (status <> 'cancelled' OR (cancelled_by IS NOT NULL AND cancelled_at IS NOT NULL AND cancellation_reason IS NOT NULL))
)""")
columns = [row[1] for row in conn.execute("PRAGMA table_info(monthly_settlement)") if row[1] != "latest_run_id"]
names = ", ".join(columns)
conn.execute(f"INSERT INTO month_settlement__v16 ({names}) SELECT {names} FROM monthly_settlement")
conn.execute("PRAGMA defer_foreign_keys=ON")
conn.execute("PRAGMA foreign_keys=OFF")
try:
conn.execute("DROP TABLE monthly_settlement")
conn.execute("ALTER TABLE month_settlement__v16 RENAME TO monthly_settlement")
finally:
conn.execute("PRAGMA foreign_keys=ON")
conn.execute("CREATE INDEX IF NOT EXISTS idx_month_settlement_status_month ON monthly_settlement(status, month)")
def _migrate_cashflow_snapshots_to_monthly(conn: sqlite3.Connection) -> None:
"""Replace the retired run FK only when every legacy row maps uniquely."""
parent = conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='asset_snapshot_runs'").fetchone()
child = conn.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='settlement_cashflow_snapshots'").fetchone()
if not child or "monthly_settlement_id" in (child["sql"] or ""):
return
if not parent:
if conn.execute("SELECT 1 FROM settlement_cashflow_snapshots LIMIT 1").fetchone() is None:
conn.execute("DROP TABLE settlement_cashflow_snapshots")
conn.execute("""CREATE TABLE settlement_cashflow_snapshots (
id TEXT PRIMARY KEY, monthly_settlement_id TEXT NOT NULL REFERENCES monthly_settlement(id) ON DELETE RESTRICT,
cashflow_kind TEXT NOT NULL, category TEXT NOT NULL DEFAULT '', subcategory TEXT NOT NULL DEFAULT '',
amount INTEGER NOT NULL, transaction_count INTEGER NOT NULL, currency TEXT NOT NULL,
UNIQUE (monthly_settlement_id, cashflow_kind, category, subcategory, currency)
)""")
return
raise sqlite3.DatabaseError("cannot migrate settlement cashflow snapshots: legacy run parent is missing")
run_columns = _existing_columns(conn, "asset_snapshot_runs")
period_column = next((c for c in ("settlement_month", "month", "period", "basis_date") if c in run_columns), None)
if period_column is None:
raise sqlite3.DatabaseError("cannot migrate settlement cashflow snapshots: legacy run has no month metadata")
rows = conn.execute(f"SELECT id, {period_column} AS period FROM asset_snapshot_runs").fetchall()
mapping = {}
for row in rows:
period = row["period"]
month = period[:7] if period and len(period) >= 7 else period
matches = conn.execute("SELECT id FROM monthly_settlement WHERE month=?", (month,)).fetchall()
if len(matches) != 1:
if conn.execute("SELECT 1 FROM settlement_cashflow_snapshots WHERE settlement_run_id=?", (row["id"],)).fetchone():
raise sqlite3.DatabaseError(f"ambiguous legacy settlement run mapping: {row['id']}")
else:
mapping[row["id"]] = matches[0]["id"]
conn.execute("ALTER TABLE settlement_cashflow_snapshots RENAME TO settlement_cashflow_snapshots__legacy")
conn.execute("""CREATE TABLE settlement_cashflow_snapshots (
id TEXT PRIMARY KEY, monthly_settlement_id TEXT NOT NULL REFERENCES monthly_settlement(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 (monthly_settlement_id, cashflow_kind, category, subcategory, currency)
)""")
for row in conn.execute("SELECT * FROM settlement_cashflow_snapshots__legacy"):
target = mapping.get(row["settlement_run_id"])
if target is None:
raise sqlite3.DatabaseError(f"cannot migrate legacy settlement run without unique monthly mapping: {row['settlement_run_id']}")
conn.execute("INSERT INTO settlement_cashflow_snapshots VALUES(?,?,?,?,?,?,?,?)", (row["id"], target, row["cashflow_kind"], row["category"], row["subcategory"], row["amount"], row["transaction_count"], row["currency"]))
conn.execute("DROP TABLE settlement_cashflow_snapshots__legacy")
conn.execute("CREATE INDEX IF NOT EXISTS idx_settlement_cashflow_snapshots_month ON settlement_cashflow_snapshots(monthly_settlement_id)")
def _drop_legacy_snapshot_storage(conn: sqlite3.Connection) -> None:
_migrate_cashflow_snapshots_to_monthly(conn)
conn.execute("DROP TABLE IF EXISTS asset_snapshot_runs")
def _rename_legacy_month_settlement(conn: sqlite3.Connection) -> None:
"""Rename the legacy parent before fresh schema creation updates child FKs."""
old = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='month_settlement'"
).fetchone()
new = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='monthly_settlement'"
).fetchone()
if old and new:
raise sqlite3.DatabaseError("both month_settlement and monthly_settlement exist")
if old:
conn.execute("ALTER TABLE month_settlement RENAME TO monthly_settlement")
def _rename_legacy_settlement_column(conn: sqlite3.Connection) -> None:
"""Rename the child FK column without rebuilding rows or changing schema version."""
columns = _existing_columns(conn, "account_snapshot")
if "month_settlement_id" in columns and "monthly_settlement_id" in columns:
raise sqlite3.DatabaseError("both settlement FK columns exist")
if "month_settlement_id" in columns:
conn.execute(
"ALTER TABLE account_snapshot RENAME COLUMN month_settlement_id TO monthly_settlement_id"
)
def _rename_legacy_snapshot_table(conn: sqlite3.Connection) -> None:
"""Rename the historical table in place, preserving rows and objects."""
old = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='asset_snapshots'"
).fetchone()
new = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='account_snapshot'"
).fetchone()
if old and new:
raise sqlite3.DatabaseError("both asset_snapshots and account_snapshot exist")
if old:
conn.execute("ALTER TABLE asset_snapshots RENAME TO account_snapshot")
def _drop_account_valuations(conn: sqlite3.Connection) -> None:
"""Remove the deprecated valuation table without touching other tables."""
conn.execute("DROP TABLE IF EXISTS account_valuations")
def migrate_db(conn: sqlite3.Connection) -> None: def migrate_db(conn: sqlite3.Connection) -> None:
"""Apply additive, idempotent migrations only.""" """Apply additive, idempotent migrations only."""
_drop_account_valuations(conn)
conn.execute("""CREATE TABLE IF NOT EXISTS settlement_drafts ( conn.execute("""CREATE TABLE IF NOT EXISTS settlement_drafts (
id TEXT PRIMARY KEY, month TEXT NOT NULL, basis_date TEXT NOT NULL, 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, status TEXT NOT NULL, version INTEGER NOT NULL, created_by TEXT NOT NULL,
@@ -284,7 +83,22 @@ def migrate_db(conn: sqlite3.Connection) -> None:
_add_missing_columns(conn, "ledger_categories", {"cashflow_kind": "TEXT NOT NULL DEFAULT 'expense'"}) _add_missing_columns(conn, "ledger_categories", {"cashflow_kind": "TEXT NOT NULL DEFAULT 'expense'"})
conn.execute("CREATE INDEX IF NOT EXISTS idx_ledger_categories_active_sort ON ledger_categories(active, sort_order, category, subcategory)") conn.execute("CREATE INDEX IF NOT EXISTS idx_ledger_categories_active_sort ON ledger_categories(active, sort_order, category, subcategory)")
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_categories_unique ON ledger_categories(category, subcategory)") conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_categories_unique ON ledger_categories(category, subcategory)")
conn.execute("""CREATE TABLE IF NOT EXISTS account_snapshot ( conn.execute("""CREATE TABLE IF NOT EXISTS manual_assets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
liquidity_bucket TEXT NOT NULL DEFAULT 'other',
valuation_amount INTEGER,
valuation_date TEXT NOT NULL DEFAULT '',
currency TEXT NOT NULL DEFAULT 'KRW',
source_note TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '',
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_manual_assets_active_bucket ON manual_assets(active, liquidity_bucket, name)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_manual_assets_valuation_date ON manual_assets(valuation_date)")
conn.execute("""CREATE TABLE IF NOT EXISTS asset_snapshots (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
basis_date TEXT NOT NULL, basis_date TEXT NOT NULL,
source_type TEXT NOT NULL, source_type TEXT NOT NULL,
@@ -296,15 +110,16 @@ def migrate_db(conn: sqlite3.Connection) -> None:
valuation_source TEXT NOT NULL DEFAULT '', valuation_source TEXT NOT NULL DEFAULT '',
source_note TEXT NOT NULL DEFAULT '', source_note TEXT NOT NULL DEFAULT '',
memo 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_of_run_id TEXT NOT NULL DEFAULT '',
correction_reason TEXT NOT NULL DEFAULT '', correction_reason TEXT NOT NULL DEFAULT '',
is_correction INTEGER NOT NULL DEFAULT 0, is_correction INTEGER NOT NULL DEFAULT 0,
closed_at TEXT NOT NULL DEFAULT '', closed_at TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL created_at TEXT NOT NULL
)""") )""")
_add_missing_columns(conn, "account_snapshot", { _add_missing_columns(conn, "asset_snapshots", {
"monthly_settlement_id": "TEXT REFERENCES monthly_settlement(id) ON DELETE RESTRICT", "snapshot_run_id": "TEXT NOT NULL DEFAULT ''",
"correction_of_run_id": "TEXT NOT NULL DEFAULT ''", "correction_of_run_id": "TEXT NOT NULL DEFAULT ''",
"correction_reason": "TEXT NOT NULL DEFAULT ''", "correction_reason": "TEXT NOT NULL DEFAULT ''",
"is_correction": "INTEGER NOT NULL DEFAULT 0", "is_correction": "INTEGER NOT NULL DEFAULT 0",
"closed_at": "TEXT NOT NULL DEFAULT ''", "closed_at": "TEXT NOT NULL DEFAULT ''",
@@ -314,13 +129,48 @@ def migrate_db(conn: sqlite3.Connection) -> None:
"evidence_kind": "TEXT NOT NULL DEFAULT 'applied'", "evidence_kind": "TEXT NOT NULL DEFAULT 'applied'",
"exclusion_reason": "TEXT NOT NULL DEFAULT ''", "exclusion_reason": "TEXT NOT NULL DEFAULT ''",
}) })
conn.execute("CREATE INDEX IF NOT EXISTS idx_account_snapshot_basis_date ON account_snapshot(basis_date)") conn.execute("CREATE INDEX IF NOT EXISTS idx_asset_snapshots_basis_date ON asset_snapshots(basis_date)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_account_snapshot_source ON account_snapshot(source_type, source_id)") conn.execute("CREATE INDEX IF NOT EXISTS idx_asset_snapshots_source ON asset_snapshots(source_type, source_id)")
if "monthly_settlement_id" in _existing_columns(conn, "account_snapshot"): conn.execute("CREATE INDEX IF NOT EXISTS idx_asset_snapshots_run ON asset_snapshots(basis_date, snapshot_run_id, created_at)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_account_snapshot_month_settlement ON account_snapshot(monthly_settlement_id, basis_date, created_at)") conn.execute("""CREATE TABLE IF NOT EXISTS 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 '',
conn.execute("CREATE INDEX IF NOT EXISTS idx_settlement_cashflow_snapshots_month ON settlement_cashflow_snapshots(monthly_settlement_id)") 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
)""")
_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 <> ''")
conn.execute("""CREATE TABLE IF NOT EXISTS review_decisions ( conn.execute("""CREATE TABLE IF NOT EXISTS review_decisions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
target_type TEXT NOT NULL, target_type TEXT NOT NULL,
@@ -355,58 +205,125 @@ def migrate_db(conn: sqlite3.Connection) -> None:
)""") )""")
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_reconciliation_sessions_account_basis ON reconciliation_sessions(account_id, basis_date)") conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_reconciliation_sessions_account_basis ON reconciliation_sessions(account_id, basis_date)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_reconciliation_sessions_status ON reconciliation_sessions(status, closed_at)") conn.execute("CREATE INDEX IF NOT EXISTS idx_reconciliation_sessions_status ON reconciliation_sessions(status, closed_at)")
_drop_month_settlement_latest_run_id(conn) conn.execute("""CREATE TABLE IF NOT EXISTS account_valuations (
conn.execute("""CREATE TABLE IF NOT EXISTS monthly_settlement ( id TEXT PRIMARY KEY, account_id TEXT NOT NULL REFERENCES accounts(id), basis_date TEXT NOT NULL,
id TEXT PRIMARY KEY, amount INTEGER NOT NULL, currency TEXT NOT NULL DEFAULT 'KRW', valuation_kind TEXT NOT NULL DEFAULT 'balance',
month TEXT NOT NULL UNIQUE CHECK ( source_type TEXT NOT NULL DEFAULT 'manual', source_ref TEXT NOT NULL DEFAULT '',
length(month) = 7 AND substr(month, 5, 1) = '-' AND verification_status TEXT NOT NULL DEFAULT 'unverified', recorded_at TEXT NOT NULL,
CAST(substr(month, 1, 4) AS INTEGER) BETWEEN 1 AND 9999 AND memo TEXT NOT NULL DEFAULT '', idempotency_key TEXT NOT NULL DEFAULT '',
CAST(substr(month, 6, 2) AS INTEGER) BETWEEN 1 AND 12 supersedes_id TEXT REFERENCES account_valuations(id), correction_reason TEXT NOT NULL DEFAULT '',
), as_of_at TEXT, timezone TEXT, cost_basis INTEGER, net_contributed INTEGER,
basis_date TEXT NOT NULL CHECK (date(basis_date) = basis_date), reported_principal_kind TEXT, reported_principal_label TEXT, profit INTEGER,
status TEXT NOT NULL CHECK (status IN ('draft', 'in_review', 'approved', 'closed', 'cancelled')), return_rate REAL, return_basis TEXT, principal INTEGER, interest INTEGER, fee INTEGER,
version INTEGER NOT NULL CHECK (typeof(version) = 'integer' AND version > 0), representative_liability_definition TEXT
memo TEXT NOT NULL DEFAULT '', created_by TEXT NOT NULL, created_at TEXT NOT NULL,
updated_by TEXT NOT NULL, updated_at TEXT NOT NULL, reviewed_by TEXT, reviewed_at TEXT,
review_fingerprint TEXT, approved_by TEXT, approved_at TEXT,
approved_version INTEGER CHECK (approved_version IS NULL OR (typeof(approved_version) = 'integer' AND approved_version > 0)),
approved_fingerprint TEXT, approved_cashflow_fingerprint TEXT, approval_reason TEXT, approval_flags_json TEXT NOT NULL DEFAULT '{}',
closed_by TEXT, closed_at TEXT, cancelled_by TEXT, cancelled_at TEXT, cancellation_reason TEXT,
CHECK (status NOT IN ('approved', 'closed') OR (approved_by IS NOT NULL AND approved_at IS NOT NULL AND approved_version IS NOT NULL AND approved_fingerprint IS NOT NULL)),
CHECK (status <> 'closed' OR (closed_by IS NOT NULL AND closed_at IS NOT NULL)),
CHECK (status <> 'cancelled' OR (cancelled_by IS NOT NULL AND cancelled_at IS NOT NULL AND cancellation_reason IS NOT NULL))
)""") )""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_month_settlement_status_month ON monthly_settlement(status, month)") conn.execute("CREATE INDEX IF NOT EXISTS idx_account_valuations_account_date ON account_valuations(account_id, basis_date, recorded_at)")
_add_missing_columns(conn, "monthly_settlement", {"approved_cashflow_fingerprint": "TEXT"}) conn.execute("CREATE INDEX IF NOT EXISTS idx_account_valuations_status_date ON account_valuations(verification_status, basis_date)")
_drop_legacy_snapshot_storage(conn) conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_account_valuations_idempotency ON account_valuations(account_id, idempotency_key) WHERE idempotency_key <> ''")
# schema version 16 retires manual_assets without creating replacement rows. conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_account_valuations_single_correction ON account_valuations(supersedes_id) WHERE supersedes_id IS NOT NULL")
conn.execute("DROP TABLE IF EXISTS manual_assets")
conn.execute("INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', ?)", (SCHEMA_VERSION,)) conn.execute("INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', ?)", (SCHEMA_VERSION,))
def _execute_script_transactional(conn: sqlite3.Connection, script: str) -> None:
"""Execute a schema script without executescript's implicit COMMIT."""
statement = ""
for line in script.splitlines():
statement += line + "\n"
if sqlite3.complete_statement(statement):
_execute_one_schema_statement(conn, statement)
statement = ""
if statement.strip():
_execute_one_schema_statement(conn, statement)
def _execute_one_schema_statement(conn: sqlite3.Connection, statement: str) -> None:
if statement.lstrip().upper().startswith("PRAGMA JOURNAL_MODE=WAL"):
return
if statement.lstrip().upper().startswith("CREATE INDEX") or statement.lstrip().upper().startswith("CREATE UNIQUE INDEX"):
return
conn.execute(statement)
def _stored_version(conn: sqlite3.Connection) -> str | None:
try:
row = conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()
except sqlite3.OperationalError as exc:
if "no such table" in str(exc):
return None
raise
return row["value"] if row else None
def _compatible_known_v16(conn: sqlite3.Connection) -> bool:
"""Allow only the already-approved v16 development shape to normalize to v15."""
required = {
"accounts": {"id", "name", "type", "liquidity_bucket"},
"transactions": {"id", "transaction_date", "amount", "raw_payload"},
"account_snapshot": {"id", "monthly_settlement_id", "amount", "verification_status"},
"monthly_settlement": {"id", "month", "status"},
"settlement_cashflow_snapshots": {"id", "monthly_settlement_id", "amount"},
}
for table, columns in required.items():
if not conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)).fetchone():
return False
actual = _existing_columns(conn, table)
if not columns <= actual:
return False
return not conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name IN ('asset_snapshot_runs','manual_assets','asset_snapshots','account_valuations')").fetchone()
def _upgrade_cashflow_storage(conn):
"""Preserve run identity: corrections cannot be merged into a single month.
Each frozen group belongs to exactly one legacy run or canonical month.
No inferred dates or fabricated approvals are needed to retain legacy rows.
"""
columns = _existing_columns(conn, "settlement_cashflow_snapshots")
if {"settlement_run_id", "monthly_settlement_id"} <= columns:
return
objects = conn.execute(
"SELECT sql FROM sqlite_master WHERE tbl_name='settlement_cashflow_snapshots' AND type IN ('index','trigger') AND sql IS NOT NULL"
).fetchall()
if columns:
conn.execute("ALTER TABLE settlement_cashflow_snapshots RENAME TO cashflow_upgrade_source")
start = SCHEMA_SQL.index("CREATE TABLE IF NOT EXISTS settlement_cashflow_snapshots")
end = SCHEMA_SQL.index(";", start) + 1
conn.execute(SCHEMA_SQL[start:end])
if columns:
names = ",".join(sorted(columns))
conn.execute(f"INSERT INTO settlement_cashflow_snapshots ({names}) SELECT {names} FROM cashflow_upgrade_source")
conn.execute("DROP TABLE cashflow_upgrade_source")
for row in objects:
conn.execute(row["sql"])
def _normalize_monthly_settlement_schema_15(conn: sqlite3.Connection) -> None:
"""Remove the development-only schema-16 run pointer from schema 15."""
if "latest_run_id" in _existing_columns(conn, "monthly_settlement"):
conn.execute("ALTER TABLE monthly_settlement DROP COLUMN latest_run_id")
def init_db(db_path: str | None = None) -> str: def init_db(db_path: str | None = None) -> str:
path = resolve_db_path(db_path) path = resolve_db_path(db_path)
with connect(str(path)) as conn: with connect(str(path)) as conn:
stored = _stored_version(conn) stored = _stored_version(conn)
if stored not in (None, "14", "15"): if stored not in (None, "11", "12", "13", "14", "15"):
if stored == "16" and _compatible_known_v16(conn): if stored != "16" or not _compatible_known_v16(conn):
pass
else:
raise sqlite3.DatabaseError(f"unsupported schema version {stored}; refusing implicit downgrade") raise sqlite3.DatabaseError(f"unsupported schema version {stored}; refusing implicit downgrade")
# Keep the entire migration atomic. In particular, executescript() commits conn.execute("BEGIN IMMEDIATE")
# implicitly, which would leave half-renamed legacy tables after a failure. # SQLite DDL and all row copies participate in this transaction.
conn.execute("BEGIN")
conn.execute("PRAGMA defer_foreign_keys=ON")
conn.execute("PRAGMA foreign_keys=OFF")
_rename_legacy_month_settlement(conn)
_rename_legacy_snapshot_table(conn)
_rename_legacy_settlement_column(conn)
# The canonical schema creates monthly_settlement before legacy cashflow
# rows are mapped from asset_snapshot_runs.
_execute_script_transactional(conn, SCHEMA_SQL) _execute_script_transactional(conn, SCHEMA_SQL)
_migrate_cashflow_snapshots_to_monthly(conn) _upgrade_cashflow_storage(conn)
migrate_db(conn) migrate_db(conn)
conn.execute("PRAGMA foreign_keys=ON") _add_missing_columns(conn, "monthly_settlement", {"approved_cashflow_fingerprint": "TEXT"})
conn.execute("INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', ?)", (SCHEMA_VERSION,)) _normalize_monthly_settlement_schema_15(conn)
# Re-run indexes only after all older-version columns have been added.
for statement in SCHEMA_SQL.split(";"):
if statement.strip().upper().startswith(("CREATE INDEX", "CREATE UNIQUE INDEX")):
conn.execute(statement)
if conn.execute("PRAGMA foreign_key_check").fetchone():
raise sqlite3.IntegrityError("migration foreign key check failed")
conn.commit() conn.commit()
return str(path) return str(path)
@@ -159,13 +159,16 @@ CREATE INDEX IF NOT EXISTS idx_account_snapshot_basis_date ON account_snapshot(b
CREATE INDEX IF NOT EXISTS idx_account_snapshot_source ON account_snapshot(source_type, source_id); CREATE INDEX IF NOT EXISTS idx_account_snapshot_source ON account_snapshot(source_type, source_id);
CREATE TABLE IF NOT EXISTS settlement_cashflow_snapshots ( CREATE TABLE IF NOT EXISTS settlement_cashflow_snapshots (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
monthly_settlement_id TEXT NOT NULL REFERENCES monthly_settlement(id) ON DELETE RESTRICT, monthly_settlement_id TEXT REFERENCES monthly_settlement(id) ON DELETE RESTRICT,
settlement_run_id TEXT REFERENCES asset_snapshot_runs(id) ON DELETE RESTRICT,
cashflow_kind TEXT NOT NULL CHECK (cashflow_kind IN ('income', 'expense', 'excluded')), cashflow_kind TEXT NOT NULL CHECK (cashflow_kind IN ('income', 'expense', 'excluded')),
category TEXT NOT NULL DEFAULT '', category TEXT NOT NULL DEFAULT '',
subcategory TEXT NOT NULL DEFAULT '', subcategory TEXT NOT NULL DEFAULT '',
amount INTEGER NOT NULL CHECK (typeof(amount) = 'integer'), amount INTEGER NOT NULL CHECK (typeof(amount) = 'integer'),
transaction_count INTEGER NOT NULL CHECK (typeof(transaction_count) = 'integer' AND transaction_count > 0), transaction_count INTEGER NOT NULL CHECK (typeof(transaction_count) = 'integer' AND transaction_count > 0),
currency TEXT NOT NULL, currency TEXT NOT NULL,
CHECK ((monthly_settlement_id IS NOT NULL) <> (settlement_run_id IS NOT NULL)),
UNIQUE (settlement_run_id, cashflow_kind, category, subcategory, currency),
UNIQUE (monthly_settlement_id, cashflow_kind, category, subcategory, currency) UNIQUE (monthly_settlement_id, cashflow_kind, category, subcategory, currency)
); );
CREATE INDEX IF NOT EXISTS idx_settlement_cashflow_snapshots_month ON settlement_cashflow_snapshots(monthly_settlement_id); CREATE INDEX IF NOT EXISTS idx_settlement_cashflow_snapshots_month ON settlement_cashflow_snapshots(monthly_settlement_id);
@@ -247,6 +250,106 @@ CREATE TABLE IF NOT EXISTS monthly_settlement (
); );
CREATE INDEX IF NOT EXISTS idx_month_settlement_status_month ON monthly_settlement(status, month); CREATE INDEX IF NOT EXISTS idx_month_settlement_status_month ON monthly_settlement(status, month);
CREATE TABLE IF NOT EXISTS manual_assets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
liquidity_bucket TEXT NOT NULL DEFAULT 'other',
valuation_amount INTEGER,
valuation_date TEXT NOT NULL DEFAULT '',
currency TEXT NOT NULL DEFAULT 'KRW',
source_note TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '',
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS 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,
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 TABLE IF NOT EXISTS account_valuations (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL REFERENCES accounts(id),
basis_date TEXT NOT NULL,
amount INTEGER NOT NULL,
currency TEXT NOT NULL DEFAULT 'KRW',
valuation_kind TEXT NOT NULL DEFAULT 'balance',
source_type TEXT NOT NULL DEFAULT 'manual',
source_ref TEXT NOT NULL DEFAULT '',
verification_status TEXT NOT NULL DEFAULT 'unverified',
recorded_at TEXT NOT NULL,
memo TEXT NOT NULL DEFAULT '',
idempotency_key TEXT NOT NULL DEFAULT '',
supersedes_id TEXT REFERENCES account_valuations(id),
correction_reason TEXT NOT NULL DEFAULT '',
as_of_at TEXT,
timezone TEXT,
cost_basis INTEGER,
net_contributed INTEGER,
reported_principal_kind TEXT,
reported_principal_label TEXT,
profit INTEGER,
return_rate REAL,
return_basis TEXT,
principal INTEGER,
interest INTEGER,
fee INTEGER,
representative_liability_definition TEXT
);
INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', '15'); INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', '15');
""" """
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jarca-ledger-mcp" name = "jarca-ledger-mcp"
version = "0.1.7" version = "0.1.8"
description = "Jarca household ledger MCP server for transactions, accounts, budgets, cashflow, and investable cashflow analysis" description = "Jarca household ledger MCP server for transactions, accounts, budgets, cashflow, and investable cashflow analysis"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
@@ -0,0 +1,231 @@
import sqlite3
from jarca_ledger_mcp import service
from jarca_ledger_mcp.application.account_valuation_service import record_account_valuation_candidate
from jarca_ledger_mcp.application.imports_service import _record_import_valuation_candidate
def test_bank_to_loan_conversion_rejects_existing_undefined_valuation_without_mutation(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
created = service.create_ledger_account(
"은행", type="bank", last_balance=100, last_synced_at="2026-06-30",
last_balance_basis_date="2026-06-30", db_path=path,
)
account_id = created["account"]["id"]
before = service.list_account_valuations(account_id, db_path=path)
assert before["count"] == 1
assert before["valuations"][0]["representative_liability_definition"] is None
rejected = service.update_ledger_account(account_id, type="loan", db_path=path)
assert rejected["success"] is False
assert rejected["error_code"] == "validation_error"
accounts = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"]
assert next(account for account in accounts if account["id"] == account_id)["type"] == "bank"
after = service.list_account_valuations(account_id, db_path=path)
assert after["count"] == 1
assert after["valuations"] == before["valuations"]
def test_bank_to_loan_conversion_without_valuations_remains_allowed(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account_id = service.create_ledger_account("은행", type="bank", db_path=path)["account"]["id"]
converted = service.update_ledger_account(account_id, type="loan", db_path=path)
assert converted["success"] is True
assert converted["account"]["type"] == "loan"
assert service.list_account_valuations(account_id, db_path=path)["count"] == 0
def test_loan_balance_requires_representative_liability_definition(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
bank = service.create_ledger_account("은행", type="bank", db_path=path)["account"]["id"]
loan = service.create_ledger_account("대출", type="loan", db_path=path)["account"]["id"]
missing_definition = service.register_account_valuation(
loan,
"2026-06-30",
100,
valuation_kind="balance",
verification_status="verified",
db_path=path,
)
assert missing_definition["success"] is False
assert missing_definition["error_code"] == "validation_error"
assert service.list_account_valuations(loan, db_path=path)["count"] == 0
ordinary_balance = service.register_account_valuation(
bank,
"2026-06-30",
100,
valuation_kind="balance",
verification_status="verified",
db_path=path,
)
defined_loan_balance = service.register_account_valuation(
loan,
"2026-06-30",
100,
valuation_kind="balance",
representative_liability_definition="principal_only",
verification_status="verified",
db_path=path,
)
assert ordinary_balance["success"] is True
assert defined_loan_balance["success"] is True
def test_correction_replays_by_evidence_and_conflicts_on_changed_payload(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("계좌", db_path=path)["account"]["id"]
parent = service.register_account_valuation(
account,
"2026-06-30",
100,
source_ref="statement-1",
verification_status="verified",
db_path=path,
)["valuation"]
first = service.correct_account_valuation(
parent["id"],
110,
"statement correction",
db_path=path,
)
replay = service.correct_account_valuation(
parent["id"],
110,
"statement correction",
db_path=path,
)
changed_amount = service.correct_account_valuation(
parent["id"],
111,
"statement correction",
db_path=path,
)
changed_reason = service.correct_account_valuation(
parent["id"],
110,
"different correction",
db_path=path,
)
changed_source = service.correct_account_valuation(
parent["id"],
110,
"statement correction",
source_ref="statement-2",
db_path=path,
)
assert first["success"] is True
assert replay["success"] is True
assert replay["reused"] is True
assert replay["valuation"]["id"] == first["valuation"]["id"]
for changed in (changed_amount, changed_reason, changed_source):
assert changed["success"] is False
assert changed["error_code"] == "correction_conflict"
assert service.list_account_valuations(account, db_path=path)["count"] == 2
def test_direct_loan_candidate_requires_liability_definition_and_writes_nothing(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
loan = service.create_ledger_account("대출", type="loan", db_path=path)["account"]["id"]
with sqlite3.connect(path) as conn:
conn.row_factory = sqlite3.Row
rejected = record_account_valuation_candidate(
conn, loan, "2026-06-30", 100, "KRW", "import", "source", "candidate", "memo",
)
assert rejected["success"] is False
assert rejected["error_code"] == "validation_error"
assert service.list_account_valuations(loan, db_path=path)["count"] == 0
def test_import_loan_candidate_requires_liability_definition_and_writes_nothing(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
loan = service.create_ledger_account("대출", type="loan", db_path=path)["account"]["id"]
with sqlite3.connect(path) as conn:
conn.row_factory = sqlite3.Row
assert _record_import_valuation_candidate(conn, loan, 100, "2026-06-30", "source-hash") is None
assert service.list_account_valuations(loan, db_path=path)["count"] == 0
def test_dated_loan_create_and_historical_update_preserve_current_cache_without_valuations(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
created = service.create_ledger_account(
"대출", type="loan", last_balance=900, last_synced_at="2026-06-30",
last_balance_basis_date="2026-06-30", db_path=path,
)
loan = created["account"]["id"]
assert created["account"]["last_balance"] == 900
assert created["account"]["last_synced_at"] == "2026-06-30"
assert service.list_account_valuations(loan, db_path=path)["count"] == 0
updated = service.update_ledger_account(
loan, last_balance=700, last_synced_at="2026-06-01",
last_balance_basis_date="2026-06-01", db_path=path,
)
assert updated["account"]["last_balance"] == 900
assert updated["account"]["last_synced_at"] == "2026-06-30"
assert service.list_account_valuations(loan, db_path=path)["count"] == 0
def test_loan_cash_balance_and_total_value_are_rejected_without_writes(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
loan = service.create_ledger_account("대출", type="loan", db_path=path)["account"]["id"]
for valuation_kind in ("cash_balance", "total_value"):
rejected = service.register_account_valuation(
loan, "2026-06-30", 100, valuation_kind=valuation_kind, db_path=path,
)
assert rejected["success"] is False
assert rejected["error_code"] == "validation_error"
assert service.list_account_valuations(loan, db_path=path)["count"] == 0
def test_non_loan_candidate_still_succeeds_and_reuses_idempotently(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("계좌", db_path=path)["account"]["id"]
with sqlite3.connect(path) as conn:
conn.row_factory = sqlite3.Row
first = record_account_valuation_candidate(
conn, account, "2026-06-30", 100, "KRW", "import", "source", "candidate", "memo",
)
replay = record_account_valuation_candidate(
conn, account, "2026-06-30", 100, "KRW", "import", "source", "candidate", "memo",
)
assert first["success"] is True
assert first["reused"] is False
assert replay["success"] is True
assert replay["reused"] is True
assert replay["valuation"]["id"] == first["valuation"]["id"]
assert service.list_account_valuations(account, db_path=path)["count"] == 1
def test_candidate_invalid_amount_returns_controlled_error_with_existing_row(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("계좌", db_path=path)["account"]["id"]
with sqlite3.connect(path) as conn:
conn.row_factory = sqlite3.Row
first = record_account_valuation_candidate(
conn, account, "2026-06-30", 100, "KRW", "import", "source", "candidate", "memo",
)
rejected = record_account_valuation_candidate(
conn, account, "2026-06-30", "not-an-integer", "KRW", "import", "source", "candidate", "memo",
)
assert first["success"] is True
assert rejected["success"] is False
assert rejected["error_code"] == "validation_error"
assert service.list_account_valuations(account, db_path=path)["count"] == 1
@@ -107,3 +107,39 @@ def test_actual_service_validates_currency_date_and_settlement_and_rolls_back(le
with sqlite3.connect(path) as conn: with sqlite3.connect(path) as conn:
assert conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0] == 0 assert conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM monthly_settlement").fetchone()[0] == 1 assert conn.execute("SELECT COUNT(*) FROM monthly_settlement").fetchone()[0] == 1
def test_mcp_preserves_legacy_full_argument_contract_and_monthly_dispatch(ledger, monkeypatch):
from jarca_ledger_mcp.adapters.mcp import server
path, settlement_id = ledger
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(path))
legacy = server.register_account_valuation(
"acct-1", "2026-10-31", 150, "KRW", "balance", "manual", "statement-1",
"verified", "legacy memo", "legacy-key", "2026-10-31T10:00:00+09:00",
"Asia/Seoul", 100, 100, "cost_basis", "Initial cost", 50, 0.5,
"cost_basis", None, None, None, None,
)
assert legacy["success"], legacy
assert legacy["valuation"]["cost_basis"] == 100
assert legacy["valuation"]["source_ref"] == "statement-1"
monthly = server.register_account_valuation(
"acct-1", "2026-10-31", 150, monthly_settlement_id=settlement_id,
liquidity_bucket="cash",
)
assert monthly["success"], monthly
assert monthly["verification_status"] == "unverified"
valuations = server.list_account_valuations("acct-1")["valuations"]
assert {item["id"] for item in valuations} == {
legacy["valuation"]["id"], monthly["snapshot_id"],
}
assert valuations == sorted(
valuations,
key=lambda item: (item["basis_date"], item["recorded_at"], item["id"]),
)
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT COUNT(*) FROM account_valuations").fetchone() == (1,)
assert conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone() == (1,)
advertised = server.mcp._tool_manager._tools["register_account_valuation"].parameters
assert {"source_ref", "cost_basis", "monthly_settlement_id"} <= advertised["properties"].keys()
assert "db_path" not in advertised["properties"]
assert {"create_manual_asset", "list_manual_assets", "update_manual_asset", "deactivate_manual_asset"} <= server.mcp._tool_manager._tools.keys()
+158
View File
@@ -0,0 +1,158 @@
import sqlite3
from jarca_ledger_mcp import service
from jarca_ledger_mcp.db import init_db
def _account(db_path, currency="KRW"):
result = service.create_ledger_account("주계좌", currency=currency, db_path=db_path)
assert result["success"]
return result["account"]["id"]
def test_account_valuation_schema_is_v12_and_idempotent(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
init_db(path)
init_db(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 == "15"
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
def test_register_same_idempotency_payload_reuses_row_and_changed_payload_conflicts(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account_id = _account(path)
first = service.register_account_valuation(account_id, "2026-06-30", 1000, idempotency_key="sync-1", verification_status="verified", source_type="import", source_ref="stmt-1", db_path=path)
same = service.register_account_valuation(account_id, "2026-06-30", 1000, idempotency_key="sync-1", verification_status="verified", source_type="import", source_ref="stmt-1", db_path=path)
conflict = service.register_account_valuation(account_id, "2026-06-30", 1200, idempotency_key="sync-1", verification_status="verified", source_type="import", source_ref="stmt-1", db_path=path)
assert first["success"] and same["success"]
assert same["reused"] is True and same["valuation"]["id"] == first["valuation"]["id"]
assert conflict["success"] is False and conflict["error_code"] == "idempotency_conflict"
def test_select_exact_and_as_of_excludes_unverified_and_reports_carry_forward(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account_id = _account(path)
service.register_account_valuation(account_id, "2026-06-01", 1000, verification_status="verified", idempotency_key="v1", db_path=path)
service.register_account_valuation(account_id, "2026-06-15", 2000, verification_status="unverified", idempotency_key="v2", db_path=path)
exact = service.select_account_valuation(account_id, "2026-06-15", db_path=path)
as_of = service.select_account_valuation(account_id, "2026-06-20", as_of=True, db_path=path)
assert exact["success"] and exact["valuation"] is None
assert exact["warning"] == "unverified_candidate"
assert as_of["success"] and as_of["valuation"]["amount"] == 1000
assert as_of["carried_forward"] is True and as_of["valuation_age_days"] == 19
def test_correction_is_append_only_reasoned_and_single_child(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account_id = _account(path)
original = service.register_account_valuation(account_id, "2026-06-30", 1000, verification_status="verified", idempotency_key="v1", db_path=path)
corrected = service.correct_account_valuation(original["valuation"]["id"], 1200, correction_reason="은행 원장 정정", idempotency_key="v2", db_path=path)
duplicate = service.correct_account_valuation(original["valuation"]["id"], 1300, correction_reason="두 번째 분기", idempotency_key="v3", db_path=path)
listed = service.list_account_valuations(account_id, db_path=path)
assert corrected["success"] and corrected["valuation"]["supersedes_id"] == original["valuation"]["id"]
assert duplicate["success"] is False and duplicate["error_code"] == "correction_conflict"
assert listed["count"] == 2
selected = service.select_account_valuation(account_id, "2026-06-30", db_path=path)
assert selected["valuation"]["amount"] == 1200
def test_account_balance_write_records_unverified_candidate_only_with_real_basis_date(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
created = service.create_ledger_account("동기화 계좌", last_balance=500, last_synced_at="2026-06-30", db_path=path, last_balance_basis_date="2026-06-30")
service.create_ledger_account("날짜 없는 계좌", last_balance=700, db_path=path)
values = service.list_account_valuations(created["account"]["id"], db_path=path)
assert values["count"] == 1
assert values["valuations"][0]["verification_status"] == "unverified"
assert values["valuations"][0]["basis_date"] == "2026-06-30"
def test_idempotency_scope_includes_basis_and_correction_metadata(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account_id = _account(path)
first = service.register_account_valuation(account_id, "2026-06-30", 1000, idempotency_key="same", db_path=path)
changed_basis = service.register_account_valuation(account_id, "2026-07-01", 1000, idempotency_key="same", db_path=path)
changed_reason = service.register_account_valuation(account_id, "2026-06-30", 1000, idempotency_key="same", supersedes_id="other", correction_reason="정정", db_path=path)
assert first["success"]
assert changed_basis["error_code"] == "idempotency_conflict"
assert changed_reason["error_code"] == "idempotency_conflict"
def test_correction_preserves_optional_fields_unless_overridden(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account_id = _account(path, currency="USD")
original = service.register_account_valuation(account_id, "2026-06-30", 1000, currency="USD", cost_basis=900, net_contributed=950, profit=50, return_rate=0.055, principal=800, interest=20, fee=5, as_of_at="2026-06-30T12:00:00+09:00", timezone="Asia/Seoul", db_path=path)
corrected = service.correct_account_valuation(original["valuation"]["id"], 1100, correction_reason="정정", idempotency_key="corr", profit=200, db_path=path)
assert corrected["success"]
value = corrected["valuation"]
assert value["cost_basis"] == 900 and value["net_contributed"] == 950
assert value["profit"] == 200 and value["return_rate"] == 0.055
assert value["principal"] == 800 and value["interest"] == 20 and value["fee"] == 5
assert value["as_of_at"] == "2026-06-30T12:00:00+09:00" and value["timezone"] == "Asia/Seoul"
def test_account_name_only_update_does_not_create_candidate(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
created = service.create_ledger_account("계좌", last_balance=500, last_synced_at="2026-06-30", db_path=path, last_balance_basis_date="2026-06-30")
account_id = created["account"]["id"]
updated = service.update_ledger_account(account_id, name="새 이름", db_path=path)
values = service.list_account_valuations(account_id, db_path=path)
assert updated["success"] and values["count"] == 1
def test_list_account_valuations_includes_legacy_and_monthly_snapshot_rows(tmp_path):
path = str(tmp_path / "coexisting-valuations.sqlite3")
account_id = _account(path)
legacy = service.register_account_valuation(
account_id, "2026-08-31", 900, verification_status="verified",
idempotency_key="legacy-value", db_path=path,
)
settlement = service.create_monthly_settlement("2026-09", db_path=path)
canonical = service.register_account_valuation(
account_id, "2026-09-30", 1000, monthly_settlement_id=settlement["settlement_id"],
verification_status="unverified", db_path=path,
)
listed = service.list_account_valuations(account_id, db_path=path)
assert legacy["success"] and canonical["success"] and listed["success"]
assert listed["count"] == 2
assert [item["id"] for item in listed["valuations"]] == [
legacy["valuation"]["id"], canonical["snapshot_id"],
]
snapshot = listed["valuations"][1]
assert set(listed["valuations"][0]) <= set(snapshot)
assert snapshot["account_id"] == account_id
assert snapshot["monthly_settlement_id"] == settlement["settlement_id"]
assert snapshot["recorded_at"] == snapshot["created_at"]
def test_list_account_valuations_globally_orders_canonical_before_newer_legacy(tmp_path):
path = str(tmp_path / "chronological-valuations.sqlite3")
account_id = _account(path)
settlement = service.create_monthly_settlement("2026-09", db_path=path)
canonical = service.register_account_valuation(
account_id, "2026-09-30", 900,
monthly_settlement_id=settlement["settlement_id"],
source_note="monthly-close", db_path=path,
)
legacy = service.register_account_valuation(
account_id, "2026-10-31", 1000,
verification_status="verified", source_ref="legacy-statement",
idempotency_key="legacy-october", db_path=path,
)
listed = service.list_account_valuations(account_id, db_path=path)
assert listed["success"] and listed["count"] == 2
assert [item["id"] for item in listed["valuations"]] == [
canonical["snapshot_id"], legacy["valuation"]["id"],
]
canonical_row, legacy_row = listed["valuations"]
assert canonical_row["monthly_settlement_id"] == settlement["settlement_id"]
assert canonical_row["source_note"] == "monthly-close"
assert canonical_row["recorded_at"] == canonical_row["created_at"]
assert legacy_row["source_ref"] == "legacy-statement"
assert legacy_row["idempotency_key"] == "legacy-october"
+13 -13
View File
@@ -22,7 +22,7 @@ def _tables(path):
EXPECTED_TABLES = { EXPECTED_TABLES = {
# Sourced from schema.py plus migration-created settlement draft tables. # Sourced from schema.py plus migration-created settlement draft tables.
"schema_meta", "schema_meta", "manual_assets", "asset_snapshots", "asset_snapshot_runs", "account_valuations",
"accounts", "accounts",
"transactions", "transactions",
"budgets", "budgets",
@@ -40,11 +40,11 @@ EXPECTED_TABLES = {
} }
def test_fresh_init_has_no_account_valuations_and_exact_schema_tables(tmp_path): def test_fresh_init_preserves_account_valuations_and_exact_schema_tables(tmp_path):
path = tmp_path / "fresh.sqlite3" path = tmp_path / "fresh.sqlite3"
init_db(str(path)) init_db(str(path))
tables = _tables(path) tables = _tables(path)
assert "account_valuations" not in tables assert "account_valuations" in tables
assert tables == EXPECTED_TABLES assert tables == EXPECTED_TABLES
@@ -55,26 +55,25 @@ def test_init_is_idempotent(tmp_path):
assert _tables(path) == EXPECTED_TABLES assert _tables(path) == EXPECTED_TABLES
def test_legacy_nonempty_account_valuations_row_is_dropped_only(tmp_path): def test_legacy_nonempty_account_valuations_row_is_preserved(tmp_path):
path = tmp_path / "legacy.sqlite3" path = tmp_path / "legacy.sqlite3"
init_db(str(path)) init_db(str(path))
with sqlite3.connect(path) as conn: with sqlite3.connect(path) as conn:
conn.execute("create table account_valuations (id text primary key, account_id text, amount integer)") conn.execute("insert into account_valuations (id,account_id,amount,basis_date,recorded_at) values ('legacy-1','acct-1',123,'2026-09-30','t')")
conn.execute("insert into account_valuations values ('legacy-1','acct-1',123)")
conn.execute("insert into accounts (id,name,type,active,created_at,updated_at) values ('acct-1','Keep me','bank',1,'t','t')") conn.execute("insert into accounts (id,name,type,active,created_at,updated_at) values ('acct-1','Keep me','bank',1,'t','t')")
conn.commit() conn.commit()
init_db(str(path)) init_db(str(path))
with sqlite3.connect(path) as conn: with sqlite3.connect(path) as conn:
assert conn.execute("select count(*) from accounts where id='acct-1'").fetchone()[0] == 1 assert conn.execute("select count(*) from accounts where id='acct-1'").fetchone()[0] == 1
assert conn.execute("select name from sqlite_master where type='table' and name='account_valuations'").fetchone() is None assert conn.execute("select amount from account_valuations where id='legacy-1'").fetchone() == (123,)
def test_unsupported_valuation_operations_do_not_create_or_access_db(tmp_path): def test_invalid_valuation_operations_do_not_create_db(tmp_path):
path = tmp_path / "must-not-exist.sqlite3" path = tmp_path / "must-not-exist.sqlite3"
result = register_account_valuation("acct", "2026-09-30", 1, db_path=str(path)) result = register_account_valuation("acct", "invalid-date", 1, db_path=str(path))
listed = list_account_valuations("acct", db_path=str(path)) listed = list_account_valuations("acct", db_path=str(path))
assert result["success"] is False and result["error_code"] == "validation_error" assert result["success"] is False and result["error_code"] == "validation_error"
assert listed["success"] is False and listed["error_code"] == "db_error" assert listed["success"] is False and listed["error_code"] == "db_not_initialized"
assert not path.exists() assert not path.exists()
@@ -102,10 +101,11 @@ def test_active_account_draft_returns_review_blocker_without_sql_error(tmp_path)
assert not any("sql" in str(v).lower() for v in result.get("review", {}).values()) assert not any("sql" in str(v).lower() for v in result.get("review", {}).values())
def test_manual_asset_flow_is_unsupported_after_removal(tmp_path): def test_manual_asset_flow_remains_supported(tmp_path):
path = tmp_path / "manual.sqlite3" path = tmp_path / "manual.sqlite3"
init_db(str(path)) init_db(str(path))
created = create_manual_asset("Emergency fund", 100000, "2026-09-30", "cash", db_path=str(path)) created = create_manual_asset("Emergency fund", 100000, "2026-09-30", "cash", db_path=str(path))
listed = list_manual_assets(db_path=str(path)) listed = list_manual_assets(db_path=str(path))
assert created == {"success": False, "error_code": "unsupported", "error": "manual assets are no longer supported"} assert created["success"] and listed["success"]
assert listed == {"success": False, "error_code": "unsupported", "error": "manual assets are no longer supported"} assert listed["manual_assets"][0]["id"] == created["manual_asset"]["id"]
assert listed["manual_assets"][0]["valuation_amount"] == 100000
+6 -6
View File
@@ -48,13 +48,13 @@ def test_replay_preserves_redacted_unverified_candidates_after_correction(tmp_pa
assert corrected["success"] is True assert corrected["success"] is True
with sqlite3.connect(db_path) as conn: with sqlite3.connect(db_path) as conn:
before_replay = conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0] before_replay = conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0]
before_runs = conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] before_runs = conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0]
replay = service.close_asset_snapshot( replay = service.close_asset_snapshot(
"2026-06-30", idempotency_key="candidate-close-a", db_path=db_path, "2026-06-30", idempotency_key="candidate-close-a", db_path=db_path,
) )
with sqlite3.connect(db_path) as conn: with sqlite3.connect(db_path) as conn:
after_replay = conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0] after_replay = conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0]
after_runs = conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] after_runs = conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0]
assert replay == first assert replay == first
@@ -109,7 +109,7 @@ def test_malformed_persisted_evidence_fails_closed_without_snapshot_writes(tmp_p
"UPDATE asset_snapshot_runs SET excluded_evidence=? WHERE id=?", "UPDATE asset_snapshot_runs SET excluded_evidence=? WHERE id=?",
("{malformed", first["snapshot_run_id"]), ("{malformed", first["snapshot_run_id"]),
) )
before_rows = conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0] before_rows = conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0]
before_runs = conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] before_runs = conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0]
report = service.get_asset_snapshot_report("2026-06-30", db_path=db_path) report = service.get_asset_snapshot_report("2026-06-30", db_path=db_path)
@@ -124,7 +124,7 @@ def test_malformed_persisted_evidence_fails_closed_without_snapshot_writes(tmp_p
assert result["success"] is False assert result["success"] is False
assert result["error_code"] == "data_integrity_error" assert result["error_code"] == "data_integrity_error"
with sqlite3.connect(db_path) as conn: with sqlite3.connect(db_path) as conn:
assert conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0] == before_rows assert conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0] == before_rows
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == before_runs assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == before_runs
@@ -171,7 +171,7 @@ def test_exact_run_replay_after_correction_reconstructs_original_persisted_run(t
assert corrected["snapshot_run_id"] not in first["inserted_snapshot_ids"] assert corrected["snapshot_run_id"] not in first["inserted_snapshot_ids"]
with sqlite3.connect(db_path) as conn: with sqlite3.connect(db_path) as conn:
before_replay = conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0] before_replay = conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0]
replay = service.close_asset_snapshot( replay = service.close_asset_snapshot(
"2026-06-30", "2026-06-30",
@@ -186,7 +186,7 @@ def test_exact_run_replay_after_correction_reconstructs_original_persisted_run(t
) )
with sqlite3.connect(db_path) as conn: with sqlite3.connect(db_path) as conn:
after_replay = conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0] after_replay = conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0]
assert replay == first assert replay == first
assert replay["snapshot_run_id"] == first["snapshot_run_id"] assert replay["snapshot_run_id"] == first["snapshot_run_id"]
+12 -13
View File
@@ -13,47 +13,46 @@ def tables(path):
return {r[0] for r in c.execute("select name from sqlite_master where type='table'")} return {r[0] for r in c.execute("select name from sqlite_master where type='table'")}
def test_fresh_init_has_no_asset_snapshot_runs(tmp_path): def test_fresh_init_retains_asset_snapshot_runs(tmp_path):
path = tmp_path / "fresh.sqlite3" path = tmp_path / "fresh.sqlite3"
init_db(str(path)) init_db(str(path))
assert "asset_snapshot_runs" not in tables(path) assert "asset_snapshot_runs" in tables(path)
def test_init_twice_is_idempotent_and_schema16(tmp_path): def test_init_twice_is_idempotent_and_schema15(tmp_path):
path = tmp_path / "repeat.sqlite3" path = tmp_path / "repeat.sqlite3"
init_db(str(path)); init_db(str(path)) init_db(str(path)); init_db(str(path))
with sqlite3.connect(path) as c: with sqlite3.connect(path) as c:
assert c.execute("select value from schema_meta where key='schema_version'").fetchone()[0] == "15" assert c.execute("select value from schema_meta where key='schema_version'").fetchone()[0] == "15"
assert "asset_snapshot_runs" not in tables(path) assert "asset_snapshot_runs" in tables(path)
def test_legacy_child_rows_require_deterministic_parent_mapping(tmp_path): def test_legacy_child_rows_require_deterministic_parent_mapping(tmp_path):
path = tmp_path / "legacy.sqlite3" path = tmp_path / "legacy.sqlite3"
init_db(str(path)) init_db(str(path))
with sqlite3.connect(path) as c: with sqlite3.connect(path) as c:
c.execute("create table asset_snapshot_runs (id text primary key, basis_date text not null)") c.execute("insert into asset_snapshot_runs (id,basis_date,close_status,created_at) values ('run-1','2026-09-30','complete_exact','t')")
c.execute("insert into asset_snapshot_runs values ('run-1','2026-09-30')")
c.execute("insert into monthly_settlement(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) values ('month-1','2026-09','2026-09-30','draft',1,'test','now','test','now')") c.execute("insert into monthly_settlement(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) values ('month-1','2026-09','2026-09-30','draft',1,'test','now','test','now')")
c.execute("insert into settlement_cashflow_snapshots values ('cf-1','month-1','income','','',10,1,'KRW')") c.execute("insert into settlement_cashflow_snapshots (id,monthly_settlement_id,cashflow_kind,category,subcategory,amount,transaction_count,currency) values ('cf-1','month-1','income','','',10,1,'KRW')")
c.commit() c.commit()
init_db(str(path)) init_db(str(path))
with sqlite3.connect(path) as c: with sqlite3.connect(path) as c:
assert c.execute("select monthly_settlement_id, amount from settlement_cashflow_snapshots").fetchone() == ('month-1', 10) assert c.execute("select monthly_settlement_id, amount from settlement_cashflow_snapshots").fetchone() == ('month-1', 10)
assert "asset_snapshot_runs" not in tables(path) assert "asset_snapshot_runs" in tables(path)
assert "monthly_settlement_id" in (c.execute("select sql from sqlite_master where name='settlement_cashflow_snapshots'").fetchone()[0] or "") assert "monthly_settlement_id" in (c.execute("select sql from sqlite_master where name='settlement_cashflow_snapshots'").fetchone()[0] or "")
def test_unsupported_legacy_operations_do_not_touch_missing_db(tmp_path): def test_unsupported_legacy_operations_do_not_touch_missing_db(tmp_path):
path = tmp_path / "missing.sqlite3" path = tmp_path / "missing.sqlite3"
assert register_account_valuation("a", "2026-09-30", 1, db_path=str(path))["error_code"] == "validation_error" assert register_account_valuation("a", "invalid-date", 1, db_path=str(path))["error_code"] == "validation_error"
assert list_account_valuations("a", db_path=str(path))["error_code"] == "db_error" assert list_account_valuations("a", db_path=str(path))["error_code"] == "db_not_initialized"
assert not path.exists() assert not path.exists()
def test_finalize_and_close_remain_unsupported_without_db_access(tmp_path): def test_invalid_close_and_read_do_not_create_db(tmp_path):
path = tmp_path / "missing.sqlite3" path = tmp_path / "missing.sqlite3"
assert close_asset_snapshot("2026-09-30", db_path=str(path))["error_code"] == "unsupported" assert close_asset_snapshot("invalid-date", db_path=str(path))["error_code"] == "validation_error"
assert get_asset_snapshot_report("2026-09-30", db_path=str(path))["error_code"] == "unsupported" assert get_asset_snapshot_report("2026-09-30", db_path=str(path))["error_code"] == "db_not_initialized"
assert not path.exists() assert not path.exists()
+5 -5
View File
@@ -13,10 +13,10 @@ def test_asset_snapshot_schema_is_additive_and_idempotent(tmp_path):
with sqlite3.connect(db_path) as conn: with sqlite3.connect(db_path) as conn:
manual_cols = {row[1] for row in conn.execute("PRAGMA table_info(manual_assets)")} manual_cols = {row[1] for row in conn.execute("PRAGMA table_info(manual_assets)")}
snapshot_cols = {row[1] for row in conn.execute("PRAGMA table_info(account_snapshot)")} snapshot_cols = {row[1] for row in conn.execute("PRAGMA table_info(asset_snapshots)")}
assert {"id", "name", "valuation_amount", "valuation_date", "liquidity_bucket", "source_note", "memo", "active"} <= manual_cols assert {"id", "name", "valuation_amount", "valuation_date", "liquidity_bucket", "source_note", "memo", "active"} <= manual_cols
assert {"id", "basis_date", "source_type", "source_id", "name", "amount", "liquidity_bucket", "valuation_source", "source_note", "memo", "monthly_settlement_id", "correction_of_run_id", "correction_reason", "is_correction", "closed_at"} <= snapshot_cols assert {"id", "basis_date", "source_type", "source_id", "name", "amount", "liquidity_bucket", "valuation_source", "source_note", "memo", "snapshot_run_id", "correction_of_run_id", "correction_reason", "is_correction", "closed_at"} <= snapshot_cols
def test_manual_assets_snapshot_report_and_net_worth_formula(tmp_path): def test_manual_assets_snapshot_report_and_net_worth_formula(tmp_path):
@@ -105,9 +105,9 @@ def test_manual_asset_update_deactivate_preview_and_close_correction_policy(tmp_
assert deactivated["success"] is True assert deactivated["success"] is True
assert deactivated["manual_asset"]["active"] is False assert deactivated["manual_asset"]["active"] is False
before = service.get_ledger_db_status(db_path, include_hash=False, include_integrity=False)["table_counts"].get("account_snapshot", 0) before = service.get_ledger_db_status(db_path, include_hash=False, include_integrity=False)["table_counts"].get("asset_snapshots", 0)
preview = service.preview_asset_snapshot_close("2026-06-30", db_path=db_path) preview = service.preview_asset_snapshot_close("2026-06-30", db_path=db_path)
after = service.get_ledger_db_status(db_path, include_hash=False, include_integrity=False)["table_counts"].get("account_snapshot", 0) after = service.get_ledger_db_status(db_path, include_hash=False, include_integrity=False)["table_counts"].get("asset_snapshots", 0)
assert preview["success"] is True assert preview["success"] is True
assert preview["source"] == "live_authority" assert preview["source"] == "live_authority"
assert preview["is_closed_snapshot"] is False assert preview["is_closed_snapshot"] is False
@@ -146,5 +146,5 @@ def test_manual_asset_update_deactivate_preview_and_close_correction_policy(tmp_
assert corrected_report["close_metadata"]["snapshot_run_id"] == corrected["snapshot_run_id"] assert corrected_report["close_metadata"]["snapshot_run_id"] == corrected["snapshot_run_id"]
with sqlite3.connect(db_path) as conn: with sqlite3.connect(db_path) as conn:
row_count = conn.execute("SELECT COUNT(*) FROM account_snapshot WHERE basis_date='2026-06-30'").fetchone()[0] row_count = conn.execute("SELECT COUNT(*) FROM asset_snapshots WHERE basis_date='2026-06-30'").fetchone()[0]
assert row_count == 6 assert row_count == 6
+25 -11
View File
@@ -150,7 +150,7 @@ def test_mid_close_failure_rolls_back_all_settlement_state(tmp_path, failure_poi
review = review_result review = review_result
ok(api("approve_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "synthetic-reviewer", "approved", "ref", db_path=path)) 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: with sqlite3.connect(path) as db:
target = {"header":"asset_snapshot_runs", "assets":"account_snapshot", "cashflow":"settlement_cashflow_snapshots", "completion":"settlement_drafts"}[failure_point] 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. # 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}" 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" event = "UPDATE" if failure_point == "completion" else "INSERT"
@@ -160,7 +160,7 @@ def test_mid_close_failure_rolls_back_all_settlement_state(tmp_path, failure_poi
assert failed.get("success") is False assert failed.get("success") is False
with sqlite3.connect(path) as db: 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_snapshot_runs").fetchone()[0] == 0
assert db.execute("SELECT COUNT(*) FROM account_snapshot").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 COUNT(*) FROM settlement_cashflow_snapshots").fetchone()[0] == 0
assert db.execute("SELECT status FROM settlement_drafts WHERE id=?", (draft["id"],)).fetchone()[0] == "active" assert db.execute("SELECT status FROM settlement_drafts WHERE id=?", (draft["id"],)).fetchone()[0] == "active"
@@ -176,7 +176,7 @@ def test_close_rejects_stored_detail_sum_tampering(tmp_path):
result = close_draft(path, draft) result = close_draft(path, draft)
assert result.get("success") is False, result assert result.get("success") is False, result
with sqlite3.connect(path) as db: with sqlite3.connect(path) as db:
for table in ("asset_snapshot_runs", "account_snapshot", "settlement_cashflow_snapshots"): 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(f"SELECT COUNT(*) FROM {table}").fetchone()[0] == 0
assert db.execute("SELECT status FROM settlement_drafts WHERE id=?", (draft["id"],)).fetchone()[0] == "active" assert db.execute("SELECT status FROM settlement_drafts WHERE id=?", (draft["id"],)).fetchone()[0] == "active"
@@ -324,6 +324,25 @@ def test_legacy_reader_preserves_unrecorded_currency_and_marks_legacy(tmp_path):
assert all(value is None for value in result["totals"].values()) assert all(value is None for value in result["totals"].values())
def test_month_lookup_ignores_later_direct_asset_correction(tmp_path):
path = str(tmp_path / "reader-interleaved-runs.sqlite3")
_seed_reader_run(path, True, {"transactions": [], "assets": []})
with sqlite3.connect(path) as conn:
conn.execute(
"""INSERT INTO asset_snapshot_runs
(id,basis_date,close_status,correction_of_run_id,correction_reason,created_at)
VALUES ('direct-correction','2026-09-30','conditional','synthetic-run','asset correction','2026-10-01')"""
)
monthly = ok(service.get_monthly_settlement_snapshot(month="2026-09", db_path=path))
exact = ok(service.get_monthly_settlement_snapshot(run_id="direct-correction", db_path=path))
assert monthly["run_id"] == "synthetic-run"
assert monthly["is_comprehensive"] is True
assert exact["run_id"] == "direct-correction"
assert exact["legacy"] is True
def test_legacy_reader_accepts_original_header_without_migration(tmp_path): def test_legacy_reader_accepts_original_header_without_migration(tmp_path):
from jarca_ledger_mcp.application.monthly_settlement_service import get_monthly_settlement_snapshot from jarca_ledger_mcp.application.monthly_settlement_service import get_monthly_settlement_snapshot
path = str(tmp_path / "reader-old-columns.sqlite3") path = str(tmp_path / "reader-old-columns.sqlite3")
@@ -368,7 +387,7 @@ def test_close_rejects_persisted_header_and_asset_tampering(tmp_path, target):
ok(service.approve_settlement_draft(draft["id"], draft["version"], review["review_fingerprint"], "reviewer", "synthetic", "ref", 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_"): if target.startswith("asset_"):
column, expression = ("amount", "amount+1") if target == "asset_amount" else ("currency", "'USD'") column, expression = ("amount", "amount+1") if target == "asset_amount" else ("currency", "'USD'")
trigger = f"CREATE TRIGGER synthetic_tamper AFTER INSERT ON account_snapshot BEGIN UPDATE account_snapshot SET {column}={expression} WHERE id=NEW.id; END" trigger = f"CREATE TRIGGER synthetic_tamper AFTER INSERT ON asset_snapshots BEGIN UPDATE asset_snapshots SET {column}={expression} WHERE id=NEW.id; END"
else: 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] 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" 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"
@@ -377,7 +396,7 @@ def test_close_rejects_persisted_header_and_asset_tampering(tmp_path, target):
result = service.finalize_settlement_draft(draft["id"], draft["version"], review["review_fingerprint"], "tamper-close", db_path=path) 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" assert result["success"] is False, "persisted header/detail must match the approved result before commit"
with sqlite3.connect(path) as conn: with sqlite3.connect(path) as conn:
for table in ("asset_snapshot_runs", "account_snapshot", "settlement_cashflow_snapshots"): 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(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", "", "") assert conn.execute("SELECT status,final_run_id,final_response FROM settlement_drafts WHERE id=?", (draft["id"],)).fetchone() == ("active", "", "")
@@ -672,12 +691,7 @@ def test_schema_eleven_legacy_reader_returns_empty_evidence_without_writes(tmp_p
from test_legacy_snapshot_migration import _legacy_closed_db from test_legacy_snapshot_migration import _legacy_closed_db
path, _ = _legacy_closed_db(tmp_path) path, _ = _legacy_closed_db(tmp_path)
with sqlite3.connect(path) as conn: with sqlite3.connect(path) as conn:
header = conn.execute( assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
"SELECT id, close_status, correction_of_run_id, correction_reason FROM asset_snapshot_runs"
).fetchone()
assert tuple(header) == (
"legacy-run-correction", "legacy_read_only", "legacy-initial", "legacy correction"
)
before = list(conn.iterdump()) before = list(conn.iterdump())
exact = ok(service.get_monthly_settlement_snapshot(run_id="legacy-run-correction", db_path=path)) 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)) latest = ok(service.get_monthly_settlement_snapshot(month="2026-06", db_path=path))
@@ -82,6 +82,9 @@ def test_dashboard_exposes_additive_policy_and_saved_history_without_implicit_mu
source = drafts.read_text(encoding="utf-8") source = drafts.read_text(encoding="utf-8")
monthly_source = monthly.read_text(encoding="utf-8") monthly_source = monthly.read_text(encoding="utf-8")
combined = source + "\n" + monthly_source combined = source + "\n" + monthly_source
legacy_source = (Path(__file__).parents[1] / "dashboard/page_views/legacy_monthly_settlement.py").read_text()
combined += legacy_source
monthly_source += legacy_source
assert "종합 월정산 정책" in combined assert "종합 월정산 정책" in combined
assert "저장된 월정산 스냅샷" in combined assert "저장된 월정산 스냅샷" in combined
assert POLICY in source or POLICY in monthly_source assert POLICY in source or POLICY in monthly_source
+4 -4
View File
@@ -102,7 +102,7 @@ def test_schema12_migration_preserves_legacy_rows_indexes_and_is_idempotent(tmp_
created_at TEXT NOT NULL, updated_at TEXT NOT NULL created_at TEXT NOT NULL, updated_at TEXT NOT NULL
); );
CREATE INDEX legacy_transactions_date ON transactions(transaction_date); CREATE INDEX legacy_transactions_date ON transactions(transaction_date);
CREATE TABLE account_snapshot ( CREATE TABLE asset_snapshots (
id TEXT PRIMARY KEY, basis_date TEXT NOT NULL, source_type TEXT NOT NULL, 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, 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', currency TEXT NOT NULL DEFAULT 'KRW', liquidity_bucket TEXT NOT NULL DEFAULT 'other',
@@ -112,17 +112,17 @@ def test_schema12_migration_preserves_legacy_rows_indexes_and_is_idempotent(tmp_
INSERT INTO schema_meta(key,value) VALUES('schema_version','11'); INSERT INTO schema_meta(key,value) VALUES('schema_version','11');
INSERT INTO accounts(id,name,type,currency,created_at,updated_at) VALUES('a','old','bank','KRW','t','t'); INSERT INTO accounts(id,name,type,currency,created_at,updated_at) VALUES('a','old','bank','KRW','t','t');
INSERT INTO transactions(id,transaction_date,type,amount,created_at,updated_at) VALUES('t','2026-01-01','expense',10,'t','t'); INSERT INTO transactions(id,transaction_date,type,amount,created_at,updated_at) VALUES('t','2026-01-01','expense',10,'t','t');
INSERT INTO account_snapshot(id,basis_date,source_type,name,amount,created_at) VALUES('s','2026-01-01','account','old',10,'t'); INSERT INTO asset_snapshots(id,basis_date,source_type,name,amount,created_at) VALUES('s','2026-01-01','account','old',10,'t');
""") """)
init_db(path) init_db(path)
init_db(path) init_db(path)
with sqlite3.connect(path) as conn: with sqlite3.connect(path) as conn:
assert conn.execute("SELECT name FROM accounts WHERE id='a'").fetchone() assert conn.execute("SELECT name FROM accounts WHERE id='a'").fetchone()
assert conn.execute("SELECT id FROM transactions WHERE id='t'").fetchone() assert conn.execute("SELECT id FROM transactions WHERE id='t'").fetchone()
assert conn.execute("SELECT name FROM account_snapshot WHERE id='s'").fetchone() assert conn.execute("SELECT name FROM asset_snapshots WHERE id='s'").fetchone()
indexes = {r[1] for r in conn.execute("PRAGMA index_list(transactions)")} indexes = {r[1] for r in conn.execute("PRAGMA index_list(transactions)")}
assert {"legacy_transactions_date", "idx_transactions_datetime"} <= indexes assert {"legacy_transactions_date", "idx_transactions_datetime"} <= indexes
assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "16" assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "15"
def test_snapshot_close_idempotency_fingerprint_and_concurrent_first_close(tmp_path): def test_snapshot_close_idempotency_fingerprint_and_concurrent_first_close(tmp_path):
+6 -30
View File
@@ -16,22 +16,10 @@ def _legacy_closed_db(tmp_path):
init_db(path) init_db(path)
asset = service.create_manual_asset("legacy cash", 100, "2026-06-01", "cash", db_path=path)["manual_asset"] asset = service.create_manual_asset("legacy cash", 100, "2026-06-01", "cash", db_path=path)["manual_asset"]
with sqlite3.connect(path) as conn: with sqlite3.connect(path) as conn:
# Rebuild the historical schema-11 table independently; do not insert # These are schema-11 close rows after the additive schema-12 migration:
# removed columns into the current schema-16 table. # run identity is absent, but row close/correction fields remain frozen.
conn.execute("DROP TABLE account_snapshot")
conn.execute("""CREATE TABLE account_snapshot (
id TEXT PRIMARY KEY, basis_date TEXT NOT NULL, source_type TEXT NOT NULL,
source_id TEXT NOT NULL, 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
)""")
conn.execute( conn.execute(
"""INSERT INTO account_snapshot """INSERT INTO asset_snapshots
(id, basis_date, source_type, source_id, name, amount, liquidity_bucket, (id, basis_date, source_type, source_id, name, amount, liquidity_bucket,
valuation_source, snapshot_run_id, correction_of_run_id, correction_reason, valuation_source, snapshot_run_id, correction_of_run_id, correction_reason,
is_correction, closed_at, created_at) is_correction, closed_at, created_at)
@@ -44,7 +32,7 @@ def _legacy_closed_db(tmp_path):
conn.execute("UPDATE schema_meta SET value='11' WHERE key='schema_version'") conn.execute("UPDATE schema_meta SET value='11' WHERE key='schema_version'")
init_db(path) init_db(path)
with sqlite3.connect(path) as conn: with sqlite3.connect(path) as conn:
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs WHERE close_status <> 'legacy_read_only'").fetchone()[0] == 0 assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
return path, asset["id"] return path, asset["id"]
@@ -114,18 +102,6 @@ def test_schema11_latest_legacy_correction_run_is_selected_as_a_complete_group(t
path = str(tmp_path / "legacy-corrections.sqlite3") path = str(tmp_path / "legacy-corrections.sqlite3")
init_db(path) init_db(path)
with sqlite3.connect(path) as conn: with sqlite3.connect(path) as conn:
conn.execute("DROP TABLE account_snapshot")
conn.execute("""CREATE TABLE account_snapshot (
id TEXT PRIMARY KEY, basis_date TEXT NOT NULL, source_type TEXT NOT NULL,
source_id TEXT NOT NULL, 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
)""")
rows = [ rows = [
("old-a", "legacy-run-1", "", "", 0, "2026-06-30T10:00:00+09:00", 40), ("old-a", "legacy-run-1", "", "", 0, "2026-06-30T10:00:00+09:00", 40),
("old-b", "legacy-run-1", "", "", 0, "2026-06-30T10:00:00+09:00", 60), ("old-b", "legacy-run-1", "", "", 0, "2026-06-30T10:00:00+09:00", 60),
@@ -134,7 +110,7 @@ def test_schema11_latest_legacy_correction_run_is_selected_as_a_complete_group(t
] ]
for row_id, run_id, prior, reason, correction, closed_at, amount in rows: for row_id, run_id, prior, reason, correction, closed_at, amount in rows:
conn.execute( conn.execute(
"""INSERT INTO account_snapshot """INSERT INTO asset_snapshots
(id, basis_date, source_type, source_id, name, amount, currency, (id, basis_date, source_type, source_id, name, amount, currency,
liquidity_bucket, valuation_source, snapshot_run_id, liquidity_bucket, valuation_source, snapshot_run_id,
correction_of_run_id, correction_reason, is_correction, correction_of_run_id, correction_reason, is_correction,
@@ -148,7 +124,7 @@ def test_schema11_latest_legacy_correction_run_is_selected_as_a_complete_group(t
conn.execute("UPDATE schema_meta SET value='11' WHERE key='schema_version'") conn.execute("UPDATE schema_meta SET value='11' WHERE key='schema_version'")
init_db(path) init_db(path)
with sqlite3.connect(path) as conn: with sqlite3.connect(path) as conn:
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs WHERE close_status <> 'legacy_read_only'").fetchone()[0] == 0 assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
before = hashlib.sha256(Path(path).read_bytes()).hexdigest() before = hashlib.sha256(Path(path).read_bytes()).hexdigest()
report = service.get_asset_snapshot_report(BASIS, db_path=path) report = service.get_asset_snapshot_report(BASIS, db_path=path)
+14 -13
View File
@@ -14,33 +14,34 @@ def tables(path):
return {r[0] for r in conn.execute("select name from sqlite_master where type='table'")} return {r[0] for r in conn.execute("select name from sqlite_master where type='table'")}
def test_fresh_and_repeated_init_never_create_manual_assets(tmp_path): def test_fresh_and_repeated_init_retain_manual_assets(tmp_path):
path = str(tmp_path / "fresh.sqlite3") path = str(tmp_path / "fresh.sqlite3")
init_db(path) init_db(path)
init_db(path) init_db(path)
assert "manual_assets" not in tables(path) assert "manual_assets" in tables(path)
def test_existing_legacy_manual_assets_is_dropped_without_other_row_loss(tmp_path): def test_existing_legacy_manual_assets_is_preserved_without_other_row_loss(tmp_path):
path = str(tmp_path / "legacy.sqlite3") path = str(tmp_path / "legacy.sqlite3")
init_db(path) init_db(path)
with sqlite3.connect(path) as conn: with sqlite3.connect(path) as conn:
conn.execute("create table manual_assets (id text primary key, name text)") conn.execute("insert into manual_assets (id,name,created_at,updated_at) values ('old', 'legacy','t','t')")
conn.execute("insert into manual_assets values ('old', 'legacy')")
conn.execute("insert into accounts (id,name,type,active,created_at,updated_at) values ('a','Keep','bank',1,'t','t')") conn.execute("insert into accounts (id,name,type,active,created_at,updated_at) values ('a','Keep','bank',1,'t','t')")
conn.commit() conn.commit()
init_db(path) init_db(path)
with sqlite3.connect(path) as conn: with sqlite3.connect(path) as conn:
assert conn.execute("select count(*) from accounts where id='a'").fetchone()[0] == 1 assert conn.execute("select count(*) from accounts where id='a'").fetchone()[0] == 1
assert "manual_assets" not in tables(path) assert conn.execute("select name from manual_assets where id='old'").fetchone() == ("legacy",)
assert "manual_assets" in tables(path)
def test_manual_asset_public_operations_are_unsupported(tmp_path): def test_manual_asset_public_operations_are_supported(tmp_path):
path = str(tmp_path / "unsupported.sqlite3") path = str(tmp_path / "supported.sqlite3")
init_db(path) init_db(path)
expected = {"success": False, "error_code": "unsupported", "error": "manual assets are no longer supported"} created = create_manual_asset("Emergency", 100, "2026-09-30", "cash", db_path=path)
assert create_manual_asset("Emergency", 100, "2026-09-30", "cash", db_path=path) == expected listed = list_manual_assets(db_path=path)
assert list_manual_assets(db_path=path) == expected assert created["success"] and listed["success"]
assert listed["manual_assets"][0]["id"] == created["manual_asset"]["id"]
def test_account_real_estate_creation_and_snapshot_preview_use_normal_path(tmp_path): def test_account_real_estate_creation_and_snapshot_preview_use_normal_path(tmp_path):
@@ -55,7 +56,7 @@ def test_account_real_estate_creation_and_snapshot_preview_use_normal_path(tmp_p
assert preview_asset_snapshot_close("2026-09-30", db_path=path)["success"] is True assert preview_asset_snapshot_close("2026-09-30", db_path=path)["success"] is True
def test_manual_assets_absent_after_initialization_with_existing_accounts(tmp_path): def test_manual_assets_present_after_initialization_with_existing_accounts(tmp_path):
path = str(tmp_path / "accounts.sqlite3") path = str(tmp_path / "accounts.sqlite3")
init_db(path) init_db(path)
with sqlite3.connect(path) as conn: with sqlite3.connect(path) as conn:
@@ -63,4 +64,4 @@ def test_manual_assets_absent_after_initialization_with_existing_accounts(tmp_pa
init_db(path) init_db(path)
with sqlite3.connect(path) as conn: with sqlite3.connect(path) as conn:
assert conn.execute("select count(*) from accounts").fetchone()[0] == before assert conn.execute("select count(*) from accounts").fetchone()[0] == before
assert "manual_assets" not in tables(path) assert "manual_assets" in tables(path)
+16 -2
View File
@@ -43,16 +43,22 @@ def test_migration_is_idempotent_and_preserves_legacy_drafts(tmp_path):
assert conn.execute("SELECT COUNT(*) FROM monthly_settlement").fetchone()[0] == 0 assert conn.execute("SELECT COUNT(*) FROM monthly_settlement").fetchone()[0] == 0
def test_schema15_upgrade_drops_only_latest_run_id_and_preserves_closed_row(tmp_path): def test_schema15_upgrade_removes_schema16_run_link_and_preserves_rows(tmp_path):
path = str(tmp_path / "schema15.sqlite3") path = str(tmp_path / "schema15.sqlite3")
init_db(path) init_db(path)
with sqlite3.connect(path) as conn: with sqlite3.connect(path) as conn:
conn.execute("INSERT INTO asset_snapshot_runs(id,basis_date,close_status,created_at) VALUES('run-1','2026-09-30','complete_exact','t')")
conn.execute("ALTER TABLE monthly_settlement ADD COLUMN latest_run_id TEXT REFERENCES asset_snapshot_runs(id) ON DELETE RESTRICT") conn.execute("ALTER TABLE monthly_settlement ADD COLUMN latest_run_id TEXT REFERENCES asset_snapshot_runs(id) ON DELETE RESTRICT")
conn.execute("UPDATE schema_meta SET value='15' WHERE key='schema_version'") conn.execute("UPDATE schema_meta SET value='15' WHERE key='schema_version'")
conn.execute("""INSERT INTO monthly_settlement conn.execute("""INSERT INTO monthly_settlement
(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at, (id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at,
approved_by,approved_at,approved_version,approved_fingerprint,closed_by,closed_at,latest_run_id) approved_by,approved_at,approved_version,approved_fingerprint,closed_by,closed_at,latest_run_id)
VALUES ('m1','2026-09','2026-09-30','closed',2,'u','t','u','t','r','t',1,'fp','c','t','run-1')""") VALUES ('m1','2026-09','2026-09-30','closed',2,'u','t','u','t','r','t',1,'fp','c','t','run-1')""")
conn.execute(
"""INSERT INTO account_snapshot
(id,basis_date,source_type,source_id,name,amount,monthly_settlement_id,created_at)
VALUES ('snapshot-1','2026-09-30','account','account-1','Main',100,'m1','t')"""
)
before = conn.execute("SELECT id,month,basis_date,status,version,memo,approved_by,closed_by FROM monthly_settlement").fetchall() before = conn.execute("SELECT id,month,basis_date,status,version,memo,approved_by,closed_by FROM monthly_settlement").fetchall()
init_db(path) init_db(path)
init_db(path) init_db(path)
@@ -60,7 +66,15 @@ def test_schema15_upgrade_drops_only_latest_run_id_and_preserves_closed_row(tmp_
assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "15" assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "15"
assert conn.execute("SELECT id,month,basis_date,status,version,memo,approved_by,closed_by FROM monthly_settlement").fetchall() == before assert conn.execute("SELECT id,month,basis_date,status,version,memo,approved_by,closed_by FROM monthly_settlement").fetchall() == before
assert "latest_run_id" not in {row[1] for row in conn.execute("PRAGMA table_info(monthly_settlement)")} assert "latest_run_id" not in {row[1] for row in conn.execute("PRAGMA table_info(monthly_settlement)")}
assert conn.execute("PRAGMA foreign_key_list(monthly_settlement)").fetchall() == [] assert conn.execute("SELECT id FROM asset_snapshot_runs").fetchall() == [("run-1",)]
assert conn.execute("SELECT id,monthly_settlement_id FROM account_snapshot").fetchall() == [("snapshot-1", "m1")]
assert conn.execute("PRAGMA foreign_key_check").fetchall() == []
migrated_shape = [row[1:] for row in conn.execute("PRAGMA table_info(monthly_settlement)")]
canonical_path = str(tmp_path / "canonical.sqlite3")
init_db(canonical_path)
with sqlite3.connect(canonical_path) as conn:
canonical_shape = [row[1:] for row in conn.execute("PRAGMA table_info(monthly_settlement)")]
assert migrated_shape == canonical_shape
@pytest.mark.parametrize("column,value", [("month", "2026-13"), ("month", "202609"), ("basis_date", "2026-02-30"), ("version", 0)]) @pytest.mark.parametrize("column,value", [("month", "2026-13"), ("month", "202609"), ("basis_date", "2026-02-30"), ("version", 0)])
@@ -102,3 +102,40 @@ def test_empty_close_is_saved_without_fake_marker(tmp_path):
def test_double_close_is_idempotent(tmp_path): def test_double_close_is_idempotent(tmp_path):
path = _db(tmp_path, [_row("t1")]); sid, _, _ = _review_approve(path); first = close_monthly_settlement(sid, 1, db_path=str(path)); second = close_monthly_settlement(sid, 999, db_path=str(path)) path = _db(tmp_path, [_row("t1")]); sid, _, _ = _review_approve(path); first = close_monthly_settlement(sid, 1, db_path=str(path)); second = close_monthly_settlement(sid, 999, db_path=str(path))
assert first["success"] and second["success"] and second["idempotent"] is True assert first["success"] and second["success"] and second["idempotent"] is True
def test_legacy_finalization_and_correction_preserve_closed_canonical_month(tmp_path):
from jarca_ledger_mcp import service
path = _db(tmp_path, [_row("t1")])
sid, _, _ = _review_approve(path)
assert close_monthly_settlement(sid, 1, db_path=str(path))["success"]
canonical = get_settlement_cashflow_snapshot(sid, db_path=str(path))
with sqlite3.connect(path) as conn:
parent = conn.execute("SELECT * FROM monthly_settlement WHERE id=?", (sid,)).fetchone()
asset = service.create_manual_asset("Legacy cash", 500, "2026-10-31", "cash", db_path=str(path))
assert asset["success"]
previous = ""
for number in range(2):
draft = service.create_settlement_draft(
"2026-10", "tester", f"legacy-draft-{number}", db_path=str(path),
correction_of_run_id=previous, correction_reason="correct" if previous else "",
)
assert draft["success"], draft
did = draft["draft"]["id"]
review = service.review_settlement_draft(did, db_path=str(path))
assert review["success"]
approved = service.approve_settlement_draft(
did, review["version"], review["review_fingerprint"], "tester", "reviewed", "fixture", db_path=str(path),
)
assert approved["success"], approved
closed = service.finalize_settlement_draft(
did, review["version"], review["review_fingerprint"], f"legacy-close-{number}", db_path=str(path),
)
assert closed["success"], closed
previous = closed["snapshot_run_id"]
assert get_settlement_cashflow_snapshot(sid, db_path=str(path)) == canonical
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT * FROM monthly_settlement WHERE id=?", (sid,)).fetchone() == parent
assert conn.execute("SELECT COUNT(DISTINCT settlement_run_id) FROM settlement_cashflow_snapshots").fetchone() == (2,)
assert conn.execute("SELECT COUNT(*) FROM settlement_cashflow_snapshots WHERE monthly_settlement_id=?", (sid,)).fetchone() == (1,)
assert conn.execute("PRAGMA foreign_key_check").fetchall() == []
@@ -51,7 +51,7 @@ def test_same_version_existing_db_upgrade_is_idempotent_and_preserves_rows(tmp_p
assert conn.execute("SELECT name, sql FROM sqlite_master WHERE type IN ('table', 'index') AND name NOT LIKE 'monthly_settlement%' AND name <> 'sqlite_sequence' ORDER BY name").fetchall() == before_tables assert conn.execute("SELECT name, sql FROM sqlite_master WHERE type IN ('table', 'index') AND name NOT LIKE 'monthly_settlement%' AND name <> 'sqlite_sequence' ORDER BY name").fetchall() == before_tables
def test_schema16_db_missing_field_gets_only_targeted_additive_column(tmp_path): def test_schema15_db_missing_field_gets_only_targeted_additive_column(tmp_path):
path = str(tmp_path / "legacy16.sqlite3") path = str(tmp_path / "legacy16.sqlite3")
init_db(path) init_db(path)
with sqlite3.connect(path) as conn: with sqlite3.connect(path) as conn:
@@ -69,7 +69,7 @@ def test_schema16_db_missing_field_gets_only_targeted_additive_column(tmp_path):
) )
conn.execute("INSERT INTO monthly_settlement SELECT id,month,basis_date,status,version,memo,created_by,created_at,updated_by,updated_at,reviewed_by,reviewed_at,review_fingerprint,approved_by,approved_at,approved_version,approved_fingerprint,approval_reason,approval_flags_json,closed_by,closed_at,cancelled_by,cancelled_at,cancellation_reason FROM monthly_settlement_without_fingerprint") conn.execute("INSERT INTO monthly_settlement SELECT id,month,basis_date,status,version,memo,created_by,created_at,updated_by,updated_at,reviewed_by,reviewed_at,review_fingerprint,approved_by,approved_at,approved_version,approved_fingerprint,approval_reason,approval_flags_json,closed_by,closed_at,cancelled_by,cancelled_at,cancellation_reason FROM monthly_settlement_without_fingerprint")
conn.execute("DROP TABLE monthly_settlement_without_fingerprint") conn.execute("DROP TABLE monthly_settlement_without_fingerprint")
conn.execute("INSERT INTO schema_meta(key,value) VALUES('schema_version','16') ON CONFLICT(key) DO UPDATE SET value='16'") conn.execute("INSERT INTO schema_meta(key,value) VALUES('schema_version','15') ON CONFLICT(key) DO UPDATE SET value='15'")
conn.execute( conn.execute(
"INSERT INTO monthly_settlement(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) " "INSERT INTO monthly_settlement(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) "
"VALUES ('m1','2026-10','2026-10-31','draft',1,'u','t','u','t')" "VALUES ('m1','2026-10','2026-10-31','draft',1,'u','t','u','t')"
+73 -25
View File
@@ -34,14 +34,10 @@ def api(name):
def dbfile(tmp_path): def dbfile(tmp_path):
path = str(tmp_path / "synthetic-ledger.sqlite3") path = str(tmp_path / "synthetic-ledger.sqlite3")
init_db(path) init_db(path)
with sqlite3.connect(path) as conn:
conn.execute("INSERT OR IGNORE INTO monthly_settlement(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) VALUES(?,?,?,?,?,?,?,?,?)", ("monthly-2026-09", "2026-09", "2026-09-30", "draft", 1, "tester", "now", "tester", "now"))
return path return path
def create(path, month="2026-09", key="create-1", **kwargs): def create(path, month="2026-09", key="create-1", **kwargs):
with sqlite3.connect(path) as conn:
conn.execute("INSERT OR IGNORE INTO monthly_settlement(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) VALUES(?,?,?,?,?,?,?,?,?)", ("monthly-" + month, month, month + "-30", "draft", 1, "tester", "now", "tester", "now"))
return api("create_settlement_draft")(month, "tester", key, db_path=path, **kwargs) return api("create_settlement_draft")(month, "tester", key, db_path=path, **kwargs)
@@ -64,8 +60,8 @@ def test_empty_draft_persists_without_closing_and_has_consistent_public_shape(tm
assert result["items"] == [] assert result["items"] == []
assert isinstance(result["review"], dict) assert isinstance(result["review"], dict)
with sqlite3.connect(path) as conn: with sqlite3.connect(path) as conn:
assert conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0] == 0 assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM account_snapshot").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)) fetched = assert_ok(api("get_settlement_draft")(draft["id"], db_path=path))
assert fetched["draft"]["id"] == draft["id"] assert fetched["draft"]["id"] == draft["id"]
listed = assert_ok(api("list_settlement_drafts")(month="2026-09", db_path=path)) listed = assert_ok(api("list_settlement_drafts")(month="2026-09", db_path=path))
@@ -132,32 +128,64 @@ def test_targets_are_frozen_until_explicit_refresh_and_review_is_read_only(tmp_p
assert_ok(api("get_settlement_draft")(draft["id"], db_path=path)) assert_ok(api("get_settlement_draft")(draft["id"], db_path=path))
assert before["draft"]["version"] == draft["version"] assert before["draft"]["version"] == draft["version"]
with sqlite3.connect(path) as conn: with sqlite3.connect(path) as conn:
assert conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0] == 0 assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
if snapshot is not None: if snapshot is not None:
assert conn.execute("SELECT COUNT(*) FROM settlement_draft_items").fetchone()[0] == snapshot 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): def test_explicit_future_substitute_and_late_recorded_exact_are_distinguished(tmp_path):
path = dbfile(tmp_path) path = dbfile(tmp_path)
acct = service.create_ledger_account("Exact account", type="investment", liquidity_bucket="investment", db_path=path)["account"]["id"] a = service.create_ledger_account("Future account", type="investment", liquidity_bucket="investment", db_path=path)["account"]["id"]
result = service.register_account_valuation(acct, "2026-10-05", 700, currency="KRW", monthly_settlement_id="missing", verification_status="pending", idempotency_key="future", db_path=path) b = service.create_ledger_account("Late exact", type="investment", liquidity_bucket="investment", db_path=path)["account"]["id"]
assert result["success"] is False future = service.register_account_valuation(a, "2026-10-05", 500, valuation_kind="total_value", verification_status="verified", idempotency_key="future", db_path=path)
assert result["error_code"] == "not_found" 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): def test_unverified_candidate_has_no_authority_and_verified_child_requires_reselection(tmp_path):
path = dbfile(tmp_path) path = dbfile(tmp_path)
acct = service.create_ledger_account("Unverified", type="investment", liquidity_bucket="investment", db_path=path)["account"]["id"] acct = service.create_ledger_account("Unverified", type="investment", liquidity_bucket="investment", db_path=path)["account"]["id"]
result = service.register_account_valuation(acct, "2026-09-30", 900, db_path=path) unv = service.register_account_valuation(acct, "2026-09-30", 900, valuation_kind="total_value", verification_status="unverified", idempotency_key="unv", db_path=path)
assert result["success"] is False draft = draft_of(assert_ok(create(path)))
assert result["error_code"] == "validation_error" review = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path))
assert "currency" in result["error"] 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): def test_manual_sources_drift_requires_explicit_refresh_and_future_not_auto_selected(tmp_path):
path = dbfile(tmp_path) path = dbfile(tmp_path)
result = service.create_manual_asset("Synthetic asset", 100, "2026-09-30", "real_estate", db_path=path) manual = service.create_manual_asset("Synthetic asset", 100, "2026-09-30", "real_estate", db_path=path)["manual_asset"]
assert result == {"success": False, "error_code": "unsupported", "error": "manual assets are no longer supported"} 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): def test_approval_fingerprint_mutation_and_external_drift_block_finalize(tmp_path):
@@ -170,10 +198,10 @@ def test_approval_fingerprint_mutation_and_external_drift_block_finalize(tmp_pat
assert mutated.get("success") is True assert mutated.get("success") is True
stale = api("finalize_settlement_draft")(draft["id"], mutated["draft"]["version"], review["review_fingerprint"], "close-key", db_path=path) 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("success") is False
assert stale.get("error_code") == "unsupported" assert stale.get("error_code") in {"conflict", "validation_error"}
def test_finalize_is_explicitly_unsupported_after_canonical_close_migration(tmp_path): def test_finalize_persists_immutable_provenance_replays_exact_response_and_conflicts_key(tmp_path):
path = dbfile(tmp_path) path = dbfile(tmp_path)
assert_ok(service.create_ledger_account("Synthetic cash", last_balance=321, liquidity_bucket="cash", db_path=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))) draft = draft_of(assert_ok(create(path)))
@@ -181,7 +209,23 @@ def test_finalize_is_explicitly_unsupported_after_canonical_close_migration(tmp_
approval = api("approve_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "approver", "approved", "approval-1", allow_missing_exclusions=True, 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") assert approval.get("success")
final = api("finalize_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "final-key", db_path=path) final = api("finalize_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "final-key", db_path=path)
assert final == {"success": False, "error_code": "unsupported", "error": "run history unavailable"} 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): def test_sqlite_trigger_failure_rolls_back_run_rows_and_completion(tmp_path):
@@ -192,23 +236,27 @@ def test_sqlite_trigger_failure_rolls_back_run_rows_and_completion(tmp_path):
approved = api("approve_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "approver", "approved", "ref", allow_missing_exclusions=True, 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") assert approved.get("success")
with sqlite3.connect(path) as conn: with sqlite3.connect(path) as conn:
conn.execute("CREATE TRIGGER fail_snapshot_insert BEFORE INSERT ON account_snapshot BEGIN SELECT RAISE(ABORT, 'injected failure'); END") 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) result = api("finalize_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "failure-key", db_path=path)
assert result.get("success") is False assert result.get("success") is False
with sqlite3.connect(path) as conn: with sqlite3.connect(path) as conn:
assert conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0] == 0 assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM account_snapshot").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)) after = assert_ok(api("get_settlement_draft")(draft["id"], db_path=path))
assert after["draft"]["status"] == "active" assert after["draft"]["status"] == "active"
def test_schema13_migration_is_additive_and_idempotent(tmp_path): def test_schema13_migration_is_additive_and_idempotent(tmp_path):
path = dbfile(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)
init_db(path) init_db(path)
with sqlite3.connect(path) as conn: 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] == '15' assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == '15'
assert conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='asset_snapshot_runs'").fetchone() is None
def test_concurrent_create_and_stale_version_finalization_are_safe(tmp_path): def test_concurrent_create_and_stale_version_finalization_are_safe(tmp_path):
@@ -220,7 +268,7 @@ def test_concurrent_create_and_stale_version_finalization_are_safe(tmp_path):
results = list(pool.map(attempt, range(4))) results = list(pool.map(attempt, range(4)))
successes = [r for r in results if r.get("success")] successes = [r for r in results if r.get("success")]
assert len(successes) == 1 assert len(successes) == 1
assert all(r.get("success") or r.get("error_code") in {"conflict", "validation_error", "db_error"} for r in results) assert all(r.get("success") or r.get("error_code") in {"conflict", "validation_error"} for r in results)
d = draft_of(successes[0]) d = draft_of(successes[0])
review = assert_ok(api("review_settlement_draft")(d["id"], db_path=path)) 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) approved = api("approve_settlement_draft")(d["id"], d["version"], review["review_fingerprint"], "approver", "approved", "r", allow_missing_exclusions=True, db_path=path)
+48 -8
View File
@@ -1,4 +1,5 @@
from pathlib import Path from pathlib import Path
import sqlite3
from jarca_ledger_mcp.db import init_db from jarca_ledger_mcp.db import init_db
from jarca_ledger_mcp.application.monthly_settlement_service import ( from jarca_ledger_mcp.application.monthly_settlement_service import (
@@ -7,17 +8,56 @@ from jarca_ledger_mcp.application.monthly_settlement_service import (
) )
def test_monthly_history_is_explicitly_unsupported_in_schema15(tmp_path: Path): def _seed_history(path: str) -> None:
init_db(path)
with sqlite3.connect(path) as conn:
conn.execute(
"""INSERT INTO asset_snapshot_runs
(id,basis_date,close_status,draft_version,approved_at,correction_of_run_id,
correction_reason,created_at,settlement_month,income_total,expense_total,
net_cashflow,net_worth)
VALUES ('run-1','2026-09-30','complete_exact',3,'2026-10-01','','',
'2026-10-01','2026-09',100,40,60,1000)"""
)
conn.execute(
"""INSERT INTO asset_snapshot_runs
(id,basis_date,close_status,draft_version,approved_at,correction_of_run_id,
correction_reason,created_at,settlement_month,income_total,expense_total,
net_cashflow,net_worth)
VALUES ('run-2','2026-09-30','conditional',4,'2026-10-02','run-1','fix',
'2026-10-02','2026-09',100,45,55,995)"""
)
def test_monthly_history_lists_close_and_correction_records(tmp_path: Path):
path = str(tmp_path / "history.sqlite3") path = str(tmp_path / "history.sqlite3")
init_db(path) _seed_history(path)
result = list_monthly_settlement_history(db_path=path)
assert result["success"] is False result = list_monthly_settlement_history("2026-09", db_path=path)
assert result["error_code"] == "unsupported"
assert result["success"] is True
assert [row["run_id"] for row in result["history"]] == ["run-2", "run-1"]
assert result["history"][0]["correction_target"] == "run-1"
assert result["read_only"] is True
def test_monthly_history_record_read_is_explicitly_unsupported(tmp_path: Path): def test_monthly_history_record_reads_exact_run(tmp_path: Path):
path = str(tmp_path / "history-read.sqlite3") path = str(tmp_path / "history-read.sqlite3")
init_db(path) _seed_history(path)
result = get_monthly_settlement_history_record("run-1", db_path=path) result = get_monthly_settlement_history_record("run-1", db_path=path)
assert result["success"] is True
assert result["history"]["run_id"] == "run-1"
assert result["history"]["net_cashflow"] == 60
assert result["history"]["net_worth"] == 1000
def test_monthly_history_record_reports_missing_run(tmp_path: Path):
path = str(tmp_path / "history-missing.sqlite3")
init_db(path)
result = get_monthly_settlement_history_record("missing", db_path=path)
assert result["success"] is False assert result["success"] is False
assert result["error_code"] == "unsupported" assert result["error_code"] == "not_found"
@@ -69,6 +69,10 @@ class _FakeStreamlit:
def popover(self, *args, **kwargs): def popover(self, *args, **kwargs):
return self._Column() return self._Column()
def link_button(self, label, url, **kwargs):
self.buttons.append((label, {"url": url, **kwargs}))
return False
def rerun(self): def rerun(self):
return None return None
@@ -0,0 +1,107 @@
from __future__ import annotations
import inspect
from pathlib import Path
from dashboard.page_views import settlement_drafts as view
from jarca_ledger_mcp import service
from jarca_ledger_mcp.adapters.mcp import server, tool_contracts
HISTORY_TOOLS = (
"list_monthly_settlement_history",
"get_monthly_settlement_history_record",
)
class _FakeStreamlit:
def __init__(self) -> None:
self.session_state: dict[str, object] = {}
self.selectbox_calls: list[tuple[str, list[str], dict]] = []
self.captions: list[str] = []
self.json_values: list[object] = []
self.dataframes: list[object] = []
self.info_values: list[str] = []
self.error_values: list[str] = []
def selectbox(self, label, options, **kwargs):
values = list(options)
self.selectbox_calls.append((label, values, kwargs))
return values[-1]
def caption(self, value):
self.captions.append(str(value))
def json(self, value):
self.json_values.append(value)
def dataframe(self, value, **kwargs):
self.dataframes.append(value)
def info(self, value):
self.info_values.append(str(value))
def error(self, value):
self.error_values.append(str(value))
def test_history_tools_are_registered_with_public_no_db_path_schemas():
tools = server.mcp._tool_manager._tools
for name in HISTORY_TOOLS:
assert name in tool_contracts.TOOL_CONTRACTS
assert name in tool_contracts.__all__
assert name in tools
schema = tools[name].parameters if hasattr(tools[name], "parameters") else tools[name].inputSchema
properties = schema.get("properties", {})
assert "db_path" not in properties
assert set(properties) == set(inspect.signature(getattr(service, name)).parameters) - {"db_path"}
def test_history_control_opens_selected_run_and_render_fetches_exact_record(monkeypatch):
fake = _FakeStreamlit()
monkeypatch.setattr(view, "st", fake)
calls: list[tuple] = []
def list_history(month="", *, db_path=None):
calls.append(("list", month, db_path))
return {
"success": True,
"history": [
{"run_id": "run-old", "settlement_month": month, "version": 2, "closed_at": "2026-10-01", "close_status": "complete_exact"},
{"run_id": "run-new", "settlement_month": month, "version": 3, "closed_at": "2026-10-02", "close_status": "conditional", "correction_target": "run-old", "reason": "fixed"},
],
}
def get_record(run_id, *, db_path=None):
calls.append(("get", run_id, db_path))
return {"success": True, "history": {"run_id": run_id, "net_cashflow": 55, "net_worth": 995}}
monkeypatch.setattr(view.gateway, "list_monthly_settlement_history", list_history)
monkeypatch.setattr(view.gateway, "get_monthly_settlement_history_record", get_record)
view._open_settlement_history("draft-1", "run-old")
assert fake.session_state["settlement_history_open_draft"] == "draft-1"
assert fake.session_state["settlement_history_run_draft-1"] == "run-old"
view._render_settlement_history("fixture.sqlite3", "2026-09", "draft-1")
assert calls == [("list", "2026-09", "fixture.sqlite3"), ("get", "run-new", "fixture.sqlite3")]
assert fake.selectbox_calls[0][0] == "불변 마감 run 선택"
assert fake.selectbox_calls[0][1] == ["run-old", "run-new"]
assert fake.json_values == [{"run_id": "run-new", "net_cashflow": 55, "net_worth": 995}]
def test_public_history_contract_and_immutable_run_model_are_documented():
root = Path(__file__).parents[1]
interface = (root / "docs/interface-contract.md").read_text(encoding="utf-8")
data_model = (root / "docs/data-model.md").read_text(encoding="utf-8")
for operation in HISTORY_TOOLS:
assert operation in interface
assert "MCP signatures omit internal `db_path`" in interface
assert "마감내역보기" in interface
assert "append-only immutable settlement history" in data_model
assert "correction_of_run_id" in data_model
+11
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import inspect import inspect
from pathlib import Path
from jarca_ledger_mcp import service from jarca_ledger_mcp import service
from jarca_ledger_mcp.adapters.mcp import server from jarca_ledger_mcp.adapters.mcp import server
@@ -33,6 +34,10 @@ def test_real_estate_appears_in_list_and_asset_preview(tmp_path):
listed = service.list_ledger_accounts(db_path=db_path) listed = service.list_ledger_accounts(db_path=db_path)
assert listed["success"] is True assert listed["success"] is True
assert listed["accounts"][0]["liquidity_bucket"] == "real_estate" assert listed["accounts"][0]["liquidity_bucket"] == "real_estate"
assert service.register_account_valuation(
created["account"]["id"], "2026-10-16", 500_000_000,
verification_status="verified", db_path=db_path,
)["success"]
preview = service.preview_asset_snapshot_close("2026-10-16", db_path=db_path) preview = service.preview_asset_snapshot_close("2026-10-16", db_path=db_path)
assert preview["success"] is True assert preview["success"] is True
assert any( assert any(
@@ -55,3 +60,9 @@ def test_unknown_account_type_remains_rejected(tmp_path):
) )
assert result["success"] is False assert result["success"] is False
assert result["error_code"] == "validation_error" assert result["error_code"] == "validation_error"
def test_data_model_documents_real_estate_account_type():
data_model = (Path(__file__).parents[1] / "docs" / "data-model.md").read_text()
type_row = next(line for line in data_model.splitlines() if line.startswith("| `type`"))
assert "real_estate" in type_row
+1 -1
View File
@@ -115,7 +115,7 @@ def test_documented_paths_and_symbols_exist() -> None:
"dashboard/page_views/accounts.py", "dashboard/page_views/accounts.py",
"dashboard/page_views/assets.py", "dashboard/page_views/assets.py",
"tests/test_account_valuations.py", "tests/test_account_valuations.py",
"tests/test_account_snapshot.py", "tests/test_asset_snapshots.py",
"tests/test_contract_regression.py", "tests/test_contract_regression.py",
] ]
for rel in paths: for rel in paths:
+1 -1
View File
@@ -16,7 +16,7 @@ def test_review_decision_and_reconciliation_schema_are_additive_idempotent(tmp_p
assert status["integrity_check"] == "ok" assert status["integrity_check"] == "ok"
assert status["table_counts"]["review_decisions"] == 0 assert status["table_counts"]["review_decisions"] == 0
assert status["table_counts"]["reconciliation_sessions"] == 0 assert status["table_counts"]["reconciliation_sessions"] == 0
assert service.health_check(db_path)["schema_version"] == "16" assert service.health_check(db_path)["schema_version"] == "15"
def test_review_decision_round_trip_payload_sanitizes_sensitive_fields(tmp_path: Path) -> None: def test_review_decision_round_trip_payload_sanitizes_sensitive_fields(tmp_path: Path) -> None:
+49 -5
View File
@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import sqlite3 import sqlite3
from pathlib import Path from pathlib import Path
@@ -35,8 +34,8 @@ def test_actual_v14_fixture_is_tag_ddl_and_migrates_preserving_rows(tmp_path):
init_db(str(path)) init_db(str(path))
with sqlite3.connect(path) as db: with sqlite3.connect(path) as db:
assert db.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "15" assert db.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "15"
assert db.execute("SELECT amount, closed_at FROM account_snapshot WHERE id='s1'").fetchone() == (100000, "2026-01-31T10:00:00") assert db.execute("SELECT amount, closed_at FROM asset_snapshots WHERE id='s1'").fetchone() == (100000, "2026-01-31T10:00:00")
assert db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='asset_snapshot_runs'").fetchone() is None assert db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='asset_snapshot_runs'").fetchone() is not None
assert db.execute("PRAGMA foreign_key_check").fetchall() == [] assert db.execute("PRAGMA foreign_key_check").fetchall() == []
assert db.execute("PRAGMA integrity_check").fetchone()[0] == "ok" assert db.execute("PRAGMA integrity_check").fetchone()[0] == "ok"
assert _rows(path, "accounts") == before_accounts assert _rows(path, "accounts") == before_accounts
@@ -47,9 +46,9 @@ def test_actual_v14_migration_is_idempotent_and_snapshot_unchanged(tmp_path):
path = tmp_path / "ledger.sqlite3" path = tmp_path / "ledger.sqlite3"
_v14_db(path) _v14_db(path)
init_db(str(path)) init_db(str(path))
first = _rows(path, "account_snapshot") first = _rows(path, "asset_snapshots")
init_db(str(path)) init_db(str(path))
assert _rows(path, "account_snapshot") == first assert _rows(path, "asset_snapshots") == first
def test_migration_failure_rolls_back_legacy_renames_and_metadata(tmp_path, monkeypatch): def test_migration_failure_rolls_back_legacy_renames_and_metadata(tmp_path, monkeypatch):
@@ -75,3 +74,48 @@ def test_new_init_has_same_contract(tmp_path):
assert db.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "15" assert db.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "15"
cols = {r[1] for r in db.execute("PRAGMA table_info(account_snapshot)")} cols = {r[1] for r in db.execute("PRAGMA table_info(account_snapshot)")}
assert {"monthly_settlement_id", "verification_status", "evidence_kind"} <= cols assert {"monthly_settlement_id", "verification_status", "evidence_kind"} <= cols
def test_populated_v14_upgrade_preserves_evidence_corrections_and_cashflow(tmp_path):
path = tmp_path / "populated.sqlite3"
_v14_db(path)
with sqlite3.connect(path) as db:
db.execute("INSERT INTO manual_assets(id,name,valuation_amount,created_at,updated_at) VALUES('manual','House',500,'t','t')")
db.execute("INSERT INTO account_valuations(id,account_id,basis_date,amount,recorded_at) VALUES('v1','a1','2026-01-31',100,'t')")
db.execute("INSERT INTO account_valuations(id,account_id,basis_date,amount,recorded_at,supersedes_id,correction_reason) VALUES('v2','a1','2026-01-31',110,'t','v1','correct')")
db.execute("UPDATE asset_snapshots SET snapshot_run_id='run1' WHERE id='s1'")
# A direct asset close can have no settlement_month. A correction shares
# its basis date, but its frozen cashflow must never merge with run1.
db.execute("INSERT INTO asset_snapshot_runs(id,basis_date,close_status,created_at,correction_of_run_id,correction_reason) VALUES('run2','2026-01-31','conditional','t','run1','correct')")
for run, amount in [("run1", 10), ("run2", 20)]:
db.execute("INSERT INTO settlement_cashflow_snapshots VALUES(?,?,'income','','',?,1,'KRW')", ("cf-" + run, run, amount))
db.execute("CREATE INDEX custom_valuation_amount ON account_valuations(amount)")
tables = ("accounts", "transactions", "manual_assets", "account_valuations", "asset_snapshots", "asset_snapshot_runs")
with sqlite3.connect(path) as db:
columns = {table: ",".join(row[1] for row in db.execute(f"PRAGMA table_info({table})")) for table in tables}
before = {table: db.execute(f"SELECT {columns[table]} FROM {table} ORDER BY id").fetchall() for table in tables}
init_db(str(path))
init_db(str(path))
with sqlite3.connect(path) as db:
assert {table: db.execute(f"SELECT {columns[table]} FROM {table} ORDER BY id").fetchall() for table in tables} == before
with sqlite3.connect(path) as db:
assert db.execute("SELECT settlement_run_id,monthly_settlement_id,amount FROM settlement_cashflow_snapshots ORDER BY id").fetchall() == [("run1", None, 10), ("run2", None, 20)]
assert db.execute("SELECT COUNT(*) FROM monthly_settlement").fetchone() == (0,)
assert db.execute("SELECT COUNT(*) FROM account_snapshot").fetchone() == (0,)
assert db.execute("SELECT name FROM sqlite_master WHERE name='custom_valuation_amount'").fetchone()
assert db.execute("PRAGMA integrity_check").fetchone() == ("ok",)
assert db.execute("PRAGMA foreign_key_check").fetchall() == []
def test_orphan_cashflow_migration_rolls_back_without_deleting_legacy_data(tmp_path):
path = tmp_path / "orphan.sqlite3"
_v14_db(path)
with sqlite3.connect(path) as db:
db.execute("INSERT INTO settlement_cashflow_snapshots VALUES('orphan','missing','income','','',1,1,'KRW')")
before = _rows(path, "settlement_cashflow_snapshots")
with pytest.raises(sqlite3.IntegrityError):
init_db(str(path))
assert _rows(path, "settlement_cashflow_snapshots") == before
with sqlite3.connect(path) as db:
assert db.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone() == ("14",)
assert not db.execute("SELECT name FROM sqlite_master WHERE name='account_snapshot'").fetchone()
+1 -1
View File
@@ -10,7 +10,7 @@ def test_health_check_initializes_db(tmp_path):
db_path = tmp_path / "ledger.sqlite3" db_path = tmp_path / "ledger.sqlite3"
result = service.health_check(str(db_path)) result = service.health_check(str(db_path))
assert result["success"] is True assert result["success"] is True
assert result["schema_version"] == "16" assert result["schema_version"] == "15"
assert db_path.exists() assert db_path.exists()
+27 -28
View File
@@ -36,22 +36,6 @@ def _add_tx(day, typ, amount, category, subcategory, path):
return result["transaction"]["id"] return result["transaction"]["id"]
def _add_snapshot(name, amount, basis_date, bucket, path):
"""Create the schema-15 account snapshot evidence used by settlement review."""
account = _ok(service.create_ledger_account(name, type="bank", currency="KRW", liquidity_bucket=bucket, db_path=path))["account"]
with sqlite3.connect(path) as conn:
conn.execute(
"INSERT OR IGNORE INTO monthly_settlement "
"(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) "
"VALUES (?,?,?,?,?,?,?,?,?)",
("monthly-2026-09", basis_date[:7], basis_date, "draft", 1, "test", "now", "test", "now"),
)
return _ok(service.register_account_valuation(
account["id"], basis_date, amount, currency="KRW", monthly_settlement_id="monthly-2026-09",
liquidity_bucket=bucket, db_path=path,
))
def test_explicit_savings_refund_and_movement_precedence(tmp_path): def test_explicit_savings_refund_and_movement_precedence(tmp_path):
path = str(tmp_path / "policy-precedence.sqlite3") path = str(tmp_path / "policy-precedence.sqlite3")
init_db(path) init_db(path)
@@ -120,11 +104,11 @@ def test_unknown_classification_blocks_and_explicit_exclusion_unblocks(tmp_path)
def test_policy_edit_persists_versions_and_invalidates_approval(tmp_path): def test_policy_edit_persists_versions_and_invalidates_approval(tmp_path):
path = str(tmp_path / "policy-approval.sqlite3") path = str(tmp_path / "policy-approval.sqlite3")
init_db(path) init_db(path)
_ok(_add_snapshot("Synthetic cash", 100, "2026-09-30", "cash", path)) _ok(service.create_manual_asset("Synthetic cash", 100, "2026-09-30", "cash", db_path=path))
draft = _draft(path) draft = _draft(path)
reviewed = _ok(service.review_settlement_draft(draft["id"], db_path=path)) reviewed = _ok(service.review_settlement_draft(draft["id"], db_path=path))
assert not reviewed["cashflow_blockers"] assert not reviewed["cashflow_blockers"]
_ok(service.approve_settlement_draft(draft["id"], draft["version"], reviewed["review_fingerprint"], "reviewer", "synthetic", "ref", allow_missing_exclusions=True, db_path=path)) _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)) 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"] saved = _ok(service.get_settlement_draft(draft["id"], db_path=path))["draft"]
assert saved["cashflow_policy"] == _policy() assert saved["cashflow_policy"] == _policy()
@@ -148,14 +132,23 @@ def test_review_contract_and_stored_reader_never_recomputes_live_preview(tmp_pat
init_db(path) init_db(path)
reader = _capability("get_monthly_settlement_snapshot") reader = _capability("get_monthly_settlement_snapshot")
empty = reader(month="2026-09", db_path=path) empty = reader(month="2026-09", db_path=path)
assert empty["success"] is False and empty["error_code"] == "unsupported" assert empty["success"] is False and empty["error_code"] == "not_found"
_ok(_add_snapshot("Synthetic cash", 100, "2026-09-30", "cash", path)) _ok(service.create_manual_asset("Synthetic cash", 100, "2026-09-30", "cash", db_path=path))
draft = _draft(path) draft = _draft(path)
reviewed = _ok(service.review_settlement_draft(draft["id"], db_path=path)) reviewed = _ok(service.review_settlement_draft(draft["id"], db_path=path))
assert "review_fingerprint" in reviewed and "review" not in reviewed 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", allow_missing_exclusions=True, db_path=path)) approved = _ok(service.approve_settlement_draft(draft["id"], draft["version"], reviewed["review_fingerprint"], "reviewer", "synthetic", "ref", db_path=path))
final = service.finalize_settlement_draft(draft["id"], approved["draft"]["version"], reviewed["review_fingerprint"], "close-key", db_path=path) final = _ok(service.finalize_settlement_draft(draft["id"], approved["draft"]["version"], reviewed["review_fingerprint"], "close-key", db_path=path))
assert final == {"success": False, "error_code": "unsupported", "error": "run history unavailable"} 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): def test_month_boundaries_refund_month_negative_total_and_fx_blocker(tmp_path):
@@ -199,7 +192,7 @@ def test_policy_capabilities_are_checked_at_call_time(tmp_path):
init_db(path) init_db(path)
assert callable(_capability("set_settlement_cashflow_policy")) assert callable(_capability("set_settlement_cashflow_policy"))
assert callable(_capability("get_monthly_settlement_snapshot")) assert callable(_capability("get_monthly_settlement_snapshot"))
assert _capability("get_monthly_settlement_snapshot")(month="", run_id="", db_path=path)["error_code"] == "unsupported" assert _capability("get_monthly_settlement_snapshot")(month="", run_id="", db_path=path)["error_code"] == "not_found"
def _private_cashflow_review(path, policy=None): def _private_cashflow_review(path, policy=None):
@@ -257,7 +250,7 @@ def test_inactive_category_uses_raw_fallback_through_public_close(tmp_path):
path = str(tmp_path / "inactive-category-fallback.sqlite3") path = str(tmp_path / "inactive-category-fallback.sqlite3")
init_db(path) init_db(path)
# A close run requires a nonempty asset valuation; cashflow-only drafts cannot finalize. # A close run requires a nonempty asset valuation; cashflow-only drafts cannot finalize.
_ok(_add_snapshot("Synthetic close cash", 100, "2026-09-30", "cash", path)) _ok(service.create_manual_asset("Synthetic close cash", 100, "2026-09-30", "cash", db_path=path))
cases = [ cases = [
# SQL fallback is category-based: ordinary legacy categories become expense, # SQL fallback is category-based: ordinary legacy categories become expense,
# while canonical legacy categories retain their established classifications. # while canonical legacy categories retain their established classifications.
@@ -290,9 +283,15 @@ def test_inactive_category_uses_raw_fallback_through_public_close(tmp_path):
assert row["category_cashflow_kind"] is None and row["category_active"] is None 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["expense_total"] == 123 # 11 + 13 + 17 + 19 - 5 + 31 + 37
assert reviewed["income_total"] == 29 assert reviewed["income_total"] == 29
approved = _ok(service.approve_settlement_draft(draft["id"], draft["version"], reviewed["review_fingerprint"], "reviewer", "synthetic", "ref", allow_missing_exclusions=True, db_path=path)) approved = _ok(service.approve_settlement_draft(draft["id"], draft["version"], reviewed["review_fingerprint"], "reviewer", "synthetic", "ref", db_path=path))
closed = service.finalize_settlement_draft(draft["id"], approved["draft"]["version"], reviewed["review_fingerprint"], "inactive-category-close", db_path=path) closed = _ok(service.finalize_settlement_draft(draft["id"], approved["draft"]["version"], reviewed["review_fingerprint"], "inactive-category-close", db_path=path))
assert closed == {"success": False, "error_code": "unsupported", "error": "run history unavailable"} 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): def test_explicit_decision_classifies_only_unresolved_inactive_fallback(tmp_path):
+52 -3
View File
@@ -23,11 +23,11 @@ def test_cashflow_detail_constraints_preserve_blank_categories(tmp_path):
insert = "INSERT INTO settlement_cashflow_snapshots(id,monthly_settlement_id,cashflow_kind,category,subcategory,amount,transaction_count,currency) VALUES(?,?,?,?,?,?,?,?)" insert = "INSERT INTO settlement_cashflow_snapshots(id,monthly_settlement_id,cashflow_kind,category,subcategory,amount,transaction_count,currency) VALUES(?,?,?,?,?,?,?,?)"
good = ("d1", "month-a", "expense", "", "", -7, 1, "KRW") good = ("d1", "month-a", "expense", "", "", -7, 1, "KRW")
db.execute(insert, good) 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")]: for values in [("d2", "missing", "expense", "x", "y", 1, 1, "KRW"), ("d3", "month-a", "other", "x", "y", 1, 1, "KRW"), ("d4", "month-a", "expense", "", "", 7, 1, "KRW"), ("d5", "month-a", "expense", "", "", None, 1, "KRW"), ("d6", "month-a", "expense", "", "", 1, 0, "KRW"), ("d7", "month-a", "expense", None, "", 1, 1, "KRW"), ("d8", "month-a", "expense", "", None, 1, 1, "KRW")]:
with pytest.raises(sqlite3.IntegrityError): with pytest.raises(sqlite3.IntegrityError):
db.execute(insert, values) db.execute(insert, values)
with pytest.raises(sqlite3.IntegrityError): with pytest.raises(sqlite3.IntegrityError):
db.execute(insert, ("d9", "run-a", "expense", "", "", -7, 1, "KRW")) db.execute(insert, ("d9", "month-a", "expense", "", "", -7, 1, "KRW"))
db.execute(insert, ("d10", "month-a", "income", "", "", 2, 1, "KRW")) db.execute(insert, ("d10", "month-a", "income", "", "", 2, 1, "KRW"))
indexes = {r[1]: r[2] for r in db.execute("PRAGMA index_list(settlement_cashflow_snapshots)")} 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] unique_fields = [tuple(r[2] for r in db.execute(f"PRAGMA index_info('{name}')")) for name, unique in indexes.items() if unique]
@@ -44,8 +44,57 @@ def test_init_is_idempotent_and_retains_schema_15_contract(tmp_path):
_schema(path) _schema(path)
with sqlite3.connect(path) as db: with sqlite3.connect(path) as db:
assert db.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == '15' assert db.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == '15'
assert not db.execute("SELECT 1 FROM sqlite_master WHERE name='asset_snapshot_runs'").fetchone() assert db.execute("SELECT 1 FROM sqlite_master WHERE name='asset_snapshot_runs'").fetchone()
first = db.execute("SELECT COUNT(*) FROM settlement_cashflow_snapshots").fetchone()[0] first = db.execute("SELECT COUNT(*) FROM settlement_cashflow_snapshots").fetchone()[0]
init_db(path) init_db(path)
with sqlite3.connect(path) as db: with sqlite3.connect(path) as db:
assert db.execute("SELECT COUNT(*) FROM settlement_cashflow_snapshots").fetchone()[0] == first assert db.execute("SELECT COUNT(*) FROM settlement_cashflow_snapshots").fetchone()[0] == first
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
@@ -23,7 +23,7 @@ def test_empty_draft_persists_without_closing_and_has_consistent_public_shape(tm
with sqlite3.connect(path) as conn: with sqlite3.connect(path) as conn:
assert conn.execute('SELECT COUNT(*) FROM settlement_drafts').fetchone()[0] == 1 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_snapshot_runs').fetchone()[0] == 0
assert conn.execute('SELECT COUNT(*) FROM account_snapshot').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): def test_initial_draft_freezes_exact_verified_selector_compatibly(tmp_path):
+1 -1
View File
@@ -30,7 +30,7 @@ def approve(path, draft, **flags):
def counts(path): def counts(path):
with sqlite3.connect(path) as conn: with sqlite3.connect(path) as conn:
return tuple(conn.execute('SELECT COUNT(*) FROM ' + t).fetchone()[0] for t in ('asset_snapshot_runs', 'account_snapshot')) return tuple(conn.execute('SELECT COUNT(*) FROM ' + t).fetchone()[0] for t in ('asset_snapshot_runs', 'asset_snapshots'))
@pytest.mark.parametrize('sql', [ @pytest.mark.parametrize('sql', [
@@ -163,12 +163,12 @@ def test_synthetic_september_future_pension_loan_late_exact_warnings_stale_rollb
d = choose(path, d, cash, cv) d = choose(path, d, cash, cv)
review = approve(path, d, allow_missing_exclusions=True, allow_manual_substitute=True) review = approve(path, d, allow_missing_exclusions=True, allow_manual_substitute=True)
with sqlite3.connect(path) as c: with sqlite3.connect(path) as c:
c.execute("CREATE TRIGGER synthetic_failure BEFORE INSERT ON account_snapshot BEGIN SELECT RAISE(ABORT, 'synthetic rollback'); END") 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) 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' assert not failed['success'] and failed['error_code'] == 'db_error'
with sqlite3.connect(path) as c: 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_snapshot_runs').fetchone()[0] == 0
assert c.execute('SELECT COUNT(*) FROM account_snapshot').fetchone()[0] == 0 assert c.execute('SELECT COUNT(*) FROM asset_snapshots').fetchone()[0] == 0
c.execute('DROP TRIGGER synthetic_failure') c.execute('DROP TRIGGER synthetic_failure')
assert ok(s.get_settlement_draft(d['id'], db_path=path))['draft']['status'] == 'active' assert ok(s.get_settlement_draft(d['id'], db_path=path))['draft']['status'] == 'active'
def finish(): def finish():
@@ -184,4 +184,4 @@ def test_synthetic_september_future_pension_loan_late_exact_warnings_stale_rollb
assert finish() == final assert finish() == final
with sqlite3.connect(path) as c: 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_snapshot_runs').fetchone()[0] == 1
assert c.execute('SELECT COUNT(*) FROM account_snapshot').fetchone()[0] == final['inserted_count'] assert c.execute('SELECT COUNT(*) FROM asset_snapshots').fetchone()[0] == final['inserted_count']
@@ -13,7 +13,7 @@ def ok(r):
def counts(path): def counts(path):
with sqlite3.connect(path) as c: with sqlite3.connect(path) as c:
return {table: c.execute(f'SELECT * FROM {table} ORDER BY rowid').fetchall() return {table: c.execute(f'SELECT * FROM {table} ORDER BY rowid').fetchall()
for table in ('asset_snapshot_runs', 'account_snapshot', 'settlement_drafts', 'settlement_draft_items')} for table in ('asset_snapshot_runs', 'asset_snapshots', 'settlement_drafts', 'settlement_draft_items')}
def finish(path, draft): def finish(path, draft):
@@ -32,7 +32,7 @@ def test_correction_links_reason_preserves_prior_rows_and_replays_after_later_co
original_rows = counts(path) 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'] 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_snapshot_runs'] == original_rows['asset_snapshot_runs']
assert counts(path)['account_snapshot'] == original_rows['account_snapshot'] assert counts(path)['asset_snapshots'] == original_rows['asset_snapshots']
run_id = original['snapshot_run_id'] run_id = original['snapshot_run_id']
bad_requests = [('', 'reason'), (run_id, ''), ('does-not-exist', 'reason')] bad_requests = [('', 'reason'), (run_id, ''), ('does-not-exist', 'reason')]
+5 -1
View File
@@ -37,7 +37,11 @@ def test_monthly_and_assets_pages_invoke_shared_panel():
pages = [p for p in root.rglob("*.py") if p.name in ("monthly_settlement.py", "assets.py")] pages = [p for p in root.rglob("*.py") if p.name in ("monthly_settlement.py", "assets.py")]
assert len(pages) == 2 assert len(pages) == 2
for page in pages: for page in pages:
assert "render_settlement_drafts" in page.read_text(encoding="utf-8") source = page.read_text(encoding="utf-8")
if page.name == "monthly_settlement.py":
assert "page_legacy_monthly_settlement" in source
source += (page.parent / "legacy_monthly_settlement.py").read_text(encoding="utf-8")
assert "render_settlement_drafts" in source
def test_streamlit_panel_renders_lifecycle_without_writing_or_creating_db(monkeypatch, tmp_path): def test_streamlit_panel_renders_lifecycle_without_writing_or_creating_db(monkeypatch, tmp_path):
+8 -3
View File
@@ -174,7 +174,7 @@ def test_settlement_draft_runtime_cancel_and_recovery_contract() -> None:
for clause in ("cancel_settlement_draft(draft_id, expected_version, cancelled_by, reason, db_path=None)", for clause in ("cancel_settlement_draft(draft_id, expected_version, cancelled_by, reason, db_path=None)",
"active", "nonempty", "cancelled_by", "cancellation_reason", "cancelled_at", "active", "nonempty", "cancelled_by", "cancellation_reason", "cancelled_at",
"approval_fingerprint", "approval_version", "version", "updated_at", "BEGIN IMMEDIATE", "approval_fingerprint", "approval_version", "version", "updated_at", "BEGIN IMMEDIATE",
"retained", "account_valuations", "asset_snapshot_runs", "account_snapshot", "retained", "account_valuations", "asset_snapshot_runs", "asset_snapshots",
"rollback", "no-write", "completed", "cancelled", "fresh create key", "rollback", "no-write", "completed", "cancelled", "fresh create key",
"same create key", "latest prior run", "correction_of_run_id", "correction_reason", "same create key", "latest prior run", "correction_of_run_id", "correction_reason",
"tests/test_settlement_draft_correction_contract.py"): "tests/test_settlement_draft_correction_contract.py"):
@@ -207,7 +207,7 @@ def test_settlement_cashflow_model_documents_schema_and_legacy_reproduction(tmp_
names = { 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"), "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_drafts": ("cashflow_policy_json", "aggregation_version"),
"settlement_cashflow_snapshots": ("id", "settlement_run_id", "cashflow_kind", "category", "subcategory", "amount", "transaction_count", "currency"), "settlement_cashflow_snapshots": ("id", "monthly_settlement_id", "settlement_run_id", "cashflow_kind", "category", "subcategory", "amount", "transaction_count", "currency"),
} }
database = tmp_path / "synthetic-doc-schema.sqlite3" database = tmp_path / "synthetic-doc-schema.sqlite3"
init_db(str(database)) init_db(str(database))
@@ -220,7 +220,12 @@ def test_settlement_cashflow_model_documents_schema_and_legacy_reproduction(tmp_
reproduction = _comprehensive_section(doc, "### Comprehensive snapshot evidence inputs") 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"): 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 assert field in reproduction, field
for constraint in ("ON DELETE RESTRICT", "UNIQUE", "transaction_count > 0", "idx_settlement_cashflow_snapshots_run"): for constraint in (
"ON DELETE RESTRICT", "UNIQUE", "transaction_count > 0",
"idx_settlement_cashflow_snapshots_run", "exactly one parent",
"(settlement_run_id,cashflow_kind,category,subcategory,currency)",
"(monthly_settlement_id,cashflow_kind,category,subcategory,currency)",
):
assert constraint in doc, constraint assert constraint in doc, constraint
+14 -3
View File
@@ -17,7 +17,8 @@ from mcp.client.stdio import stdio_client
OPS = ('create_settlement_draft', 'list_settlement_drafts', 'get_settlement_draft', OPS = ('create_settlement_draft', 'list_settlement_drafts', 'get_settlement_draft',
'update_settlement_draft', 'refresh_settlement_draft_targets', 'update_settlement_draft', 'refresh_settlement_draft_targets',
'set_settlement_draft_item', 'review_settlement_draft', 'set_settlement_draft_item', 'review_settlement_draft',
'approve_settlement_draft', 'finalize_settlement_draft', 'cancel_settlement_draft') 'approve_settlement_draft', 'finalize_settlement_draft', 'cancel_settlement_draft',
'list_monthly_settlement_history', 'get_monthly_settlement_history_record')
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
@@ -44,7 +45,7 @@ def test_required_public_field_assertion_detects_omission():
def rows(db, table): def rows(db, table):
assert table in {'asset_snapshot_runs', 'account_snapshot', 'settlement_drafts', 'settlement_draft_items'} assert table in {'asset_snapshot_runs', 'asset_snapshots', 'settlement_drafts', 'settlement_draft_items'}
with closing(sqlite3.connect(db)) as connection: with closing(sqlite3.connect(db)) as connection:
return connection.execute(f'SELECT * FROM {table} ORDER BY rowid').fetchall() return connection.execute(f'SELECT * FROM {table} ORDER BY rowid').fetchall()
@@ -138,7 +139,7 @@ async def _exercise_stdio_lifecycle(tmp_path):
assert field in provenance, f'final provenance missing {field}' assert field in provenance, f'final provenance missing {field}'
assert provenance['valuation_date'] == item['valuation_date'] and provenance['age_days'] == item['age_days'] assert provenance['valuation_date'] == item['valuation_date'] and provenance['age_days'] == item['age_days']
before = (await call('get_settlement_draft', {'draft_id':draft_id})) before = (await call('get_settlement_draft', {'draft_id':draft_id}))
tables = ('asset_snapshot_runs', 'account_snapshot', 'settlement_drafts', 'settlement_draft_items') tables = ('asset_snapshot_runs', 'asset_snapshots', 'settlement_drafts', 'settlement_draft_items')
before_replay = {table: rows(db, table) for table in tables} before_replay = {table: rows(db, table) for table in tables}
assert await call('finalize_settlement_draft', {'draft_id':draft_id,'expected_version':draft['version'],'review_fingerprint':fingerprint,'idempotency_key':'mcp-close'}) == closed assert await call('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}) archived = await call('get_settlement_draft', {'draft_id':draft_id})
@@ -147,6 +148,16 @@ async def _exercise_stdio_lifecycle(tmp_path):
assert archived == before assert archived == before
assert {table: rows(db, table) for table in tables} == before_replay assert {table: rows(db, table) for table in tables} == before_replay
history = await call('list_monthly_settlement_history', {'month': '2026-09'})
require_fields(history, {'success', 'history', 'month', 'read_only'}, 'history list stdio result')
assert history['success'] and history['read_only'] is True
assert [item['run_id'] for item in history['history']] == [closed['run_id']]
record = await call('get_monthly_settlement_history_record', {'run_id': closed['run_id']})
require_fields(record, {'success', 'history', 'read_only'}, 'history record stdio result')
assert record['success'] and record['read_only'] is True
assert record['history']['run_id'] == closed['run_id']
assert record['history']['net_worth'] == 100
second = await call('create_settlement_draft', {'month':'2026-10','created_by':'stdio-test','idempotency_key':'mcp-cancel'}) second = await call('create_settlement_draft', {'month':'2026-10','created_by':'stdio-test','idempotency_key':'mcp-cancel'})
assert second['success'] 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'}) cancelled = await call('cancel_settlement_draft', {'draft_id':second['draft']['id'],'expected_version':second['draft']['version'],'cancelled_by':'stdio-test','reason':'synthetic cancellation'})
@@ -259,7 +259,7 @@ def test_legacy_amount_approval_requires_fresh_review_after_policy_correction(tm
assert rejected["error_code"] == "conflict" assert rejected["error_code"] == "conflict"
with sqlite3.connect(path) as db: 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_snapshot_runs").fetchone()[0] == 0
assert db.execute("SELECT COUNT(*) FROM account_snapshot").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() row = db.execute("SELECT status,version,final_run_id FROM settlement_drafts WHERE id=?", (d["id"],)).fetchone()
assert row == ("active", d["version"], "") assert row == ("active", d["version"], "")
assert frozen_items(path, d["id"]) == frozen_before assert frozen_items(path, d["id"]) == frozen_before
+1 -1
View File
@@ -16,7 +16,7 @@ def test_closed_draft_evidence_is_additive_and_strictly_redacted(tmp_path):
json.dumps([{"source_type":"account","source_id":"acct-1","reason":"approved_missing","approval_reference":"ref-2","unknown_secret":"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"}]), 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")) "2026-09-30T02:00:00Z"))
conn.execute("INSERT INTO account_snapshot(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,monthly_settlement_id,valuation_id,created_at) VALUES('snap-1','2026-09-30','manual_asset','asset-1','Synthetic',100,'KRW','real_estate',NULL,'','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() conn.commit()
report = service.get_asset_snapshot_report("2026-09-30", db_path=path) report = service.get_asset_snapshot_report("2026-09-30", db_path=path)
assert report["success"] assert report["success"]
Generated
+1 -1
View File
@@ -469,7 +469,7 @@ wheels = [
[[package]] [[package]]
name = "jarca-ledger-mcp" name = "jarca-ledger-mcp"
version = "0.1.7" version = "0.1.8"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "mcp" }, { name = "mcp" },