diff --git a/dashboard/page_views/assets.py b/dashboard/page_views/assets.py index 38d2622..a143061 100644 --- a/dashboard/page_views/assets.py +++ b/dashboard/page_views/assets.py @@ -44,7 +44,65 @@ def _render_asset_evidence(report: dict) -> None: def page_assets(db_path: str) -> None: 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() render_section_label("🔒", "월말 스냅샷 Preview / Close") diff --git a/dashboard/page_views/legacy_monthly_settlement.py b/dashboard/page_views/legacy_monthly_settlement.py new file mode 100644 index 0000000..5fb040a --- /dev/null +++ b/dashboard/page_views/legacy_monthly_settlement.py @@ -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( + "" + "🔒 읽기 전용", + 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=요약)") diff --git a/dashboard/page_views/monthly_settlement.py b/dashboard/page_views/monthly_settlement.py index 91d852e..919028a 100644 --- a/dashboard/page_views/monthly_settlement.py +++ b/dashboard/page_views/monthly_settlement.py @@ -188,6 +188,10 @@ def _render_monthly_list(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") legacy_month = _query_value("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.pop("month", None) else: - st.error("월정산을 찾을 수 없습니다.") + from .legacy_monthly_settlement import page_legacy_monthly_settlement + page_legacy_monthly_settlement(db_path) return if settlement_id: year = _query_value("year", "전체") @@ -271,6 +276,7 @@ def page_monthly_settlement(db_path: str) -> None: _render_monthly_detail(db_path, month) return _render_monthly_list(db_path) + st.link_button("기존 자산 마감 · 초안 및 이력", "?page=monthly_settlement&legacy=1") def _account_evaluation_totals(rows: list[dict]) -> dict: diff --git a/dashboard/page_views/settlement_drafts.py b/dashboard/page_views/settlement_drafts.py index d3035e8..0da9912 100644 --- a/dashboard/page_views/settlement_drafts.py +++ b/dashboard/page_views/settlement_drafts.py @@ -31,6 +31,39 @@ def _authenticated_actor() -> str | None: 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: @st.dialog("정산 진행") 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, correction_of_run_id=correction_run, correction_reason=reason, db_path=db_path) if result.get("success"): - created_id = (result.get("draft") or {}).get("id", "") # The selectbox is the authoritative selection on the next # rerun; seed its widget state, not a separate shadow key. 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 "미보존") rows = final.get("cashflow_items", []) if isinstance(rows, list): st.dataframe([{k: row.get(k) for k in ("cashflow_kind", "category", "subcategory", "amount", "transaction_count", "currency")} for row in rows if isinstance(row, dict)]) + st.markdown("### 불변 이력") + st.write("final_run_id", draft.get("final_run_id")) + st.write("correction_of_run_id", draft.get("correction_of_run_id")) + st.json(detail.get("items", [])) + st.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}"): _create_settlement_dialog(db_path, draft.get("month", month_value), str(draft["final_run_id"])) st.caption("종료된 초안의 증거는 수정하지 않습니다. 정정이 필요하면 이전 마감과 사유를 연결해 새 초안을 생성하세요.") diff --git a/deploy/k8s/base/deployment.yaml b/deploy/k8s/base/deployment.yaml index 4113091..08c541b 100644 --- a/deploy/k8s/base/deployment.yaml +++ b/deploy/k8s/base/deployment.yaml @@ -34,7 +34,7 @@ spec: type: RuntimeDefault initContainers: - 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 command: - python @@ -62,7 +62,7 @@ spec: mountPath: /tmp containers: - 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 env: - name: JARCA_LEDGER_DB_PATH @@ -104,7 +104,7 @@ spec: - name: mcp-tmp mountPath: /tmp - name: dashboard - image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.7 + image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.8 imagePullPolicy: IfNotPresent command: - uvicorn diff --git a/docs/data-model.md b/docs/data-model.md index 01c9e78..94082de 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -4,7 +4,7 @@ 계좌 평가 증거, 수동 자산, 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 ## 정본 엔터티 @@ -21,7 +21,7 @@ |---|---|---|---|---|---|---| | `id` | TEXT | 필수 | 없음 | generated account id | 식별자 | 아니오 | | `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 | 아니오 | | `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 | 아니오 | @@ -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 - 오류 데이터: invalid currency/kind, missing loan definition, idempotency/correction conflict - 저장 후 재조회: service list/select/report and contract regression tests -- 마이그레이션: `test_*schema*` tests plus `python -m pytest tests/test_account_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 — 월정산 초안 @@ -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. 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 - -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 +# Issue #18 — schema 15 comprehensive settlement additions ### 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 | |---|---|---|---| | `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 | | `category` | 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 | | `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 @@ -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. -`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. -### 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. diff --git a/docs/interface-contract.md b/docs/interface-contract.md index b7f36c3..376968f 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -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. -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 @@ -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 - 경계 계약: exact/as_of, NULL/0, future/unverified candidate, correction/re-close - 구현·스키마·예시 정합성: `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): ```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] 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] @@ -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. + + +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. diff --git a/docs/project-structure.md b/docs/project-structure.md index 0228c64..17316aa 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -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/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 | -| `tests/test_account_valuation_service_actual.py` | test | valuation and account snapshot 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_account_valuations.py` | test | valuation 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 | ### 의존 방향 추가 diff --git a/docs/requirements/req-003_account-valuation-history.md b/docs/requirements/req-003_account-valuation-history.md index 7880d3a..ce8835d 100644 --- a/docs/requirements/req-003_account-valuation-history.md +++ b/docs/requirements/req-003_account-valuation-history.md @@ -15,8 +15,6 @@ # 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. 목적 계좌의 기준일 평가 증거를 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 대표 부채 - read-only preview, explicit approval conditional close, immutable evidence, correction/re-close - 계좌·import 후보, reconciliation, MCP·Dashboard·월정산 공통 read model @@ -54,7 +52,7 @@ ### 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 @@ -133,7 +131,7 @@ preview는 DB를 쓰지 않고 `complete_exact`, `incomplete_draft`, `conditiona - 영속성: `correction_of_run_id`, `is_correction`, `closed_at` - 후속 사용 주체: timeseries와 월정산 - 마이그레이션: asset snapshot columns/indexes -- 호환성: legacy `account_snapshot` rows are readable +- 호환성: legacy `asset_snapshots` rows are readable - 배포 전제 조건: close transaction atomicity ### 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. -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. diff --git a/docs/runtime-flow.md b/docs/runtime-flow.md index 840c2fa..31edd03 100644 --- a/docs/runtime-flow.md +++ b/docs/runtime-flow.md @@ -211,7 +211,7 @@ ASGI startup부터 login, token restoration, invalid-cookie cleanup, explicit lo ## 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 - 경계 흐름: exact/as_of, stale/carry-forward, correction/re-close, empty fallback - 동시성 흐름: 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 | |---|---|---| | `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. | 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. diff --git a/jarca_ledger_mcp/__init__.py b/jarca_ledger_mcp/__init__.py index 8c98d07..5c9bea8 100644 --- a/jarca_ledger_mcp/__init__.py +++ b/jarca_ledger_mcp/__init__.py @@ -1,3 +1,3 @@ """Jarca Ledger MCP server.""" -__version__ = "0.1.7" +__version__ = "0.1.8" diff --git a/jarca_ledger_mcp/adapters/mcp/server.py b/jarca_ledger_mcp/adapters/mcp/server.py index 0eca324..f65f0cc 100644 --- a/jarca_ledger_mcp/adapters/mcp/server.py +++ b/jarca_ledger_mcp/adapters/mcp/server.py @@ -5,7 +5,6 @@ from typing import Literal import os from dataclasses import dataclass from pathlib import Path -from typing import Literal from dotenv import load_dotenv 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) @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: - """Register an append-only account valuation evidence row.""" - 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) +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 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, monthly_settlement_id=monthly_settlement_id, liquidity_bucket=liquidity_bucket, selection_policy=selection_policy, valuation_source=valuation_source, source_note=source_note) @mcp.tool() 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 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() def preview_asset_snapshot_close(basis_date: str = "", selection_policy: str = "exact") -> dict: """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: """Read a saved monthly settlement snapshot, optionally selecting an exact run.""" 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) diff --git a/jarca_ledger_mcp/adapters/mcp/tool_contracts.py b/jarca_ledger_mcp/adapters/mcp/tool_contracts.py index f0aa081..a0f0a16 100644 --- a/jarca_ledger_mcp/adapters/mcp/tool_contracts.py +++ b/jarca_ledger_mcp/adapters/mcp/tool_contracts.py @@ -12,6 +12,8 @@ from ...service import ( set_settlement_draft_item as set_settlement_draft_item, set_settlement_cashflow_policy as set_settlement_cashflow_policy, get_monthly_settlement_snapshot as get_monthly_settlement_snapshot, + 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, annotations as annotations, apply_categorization_rules as apply_categorization_rules, @@ -22,6 +24,8 @@ from ...service import ( create_ledger_budget as create_ledger_budget, create_ledger_category as create_ledger_category, 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_ledger_category as delete_ledger_category, delete_ledger_transaction as delete_ledger_transaction, @@ -64,6 +68,7 @@ from ...service import ( list_ledger_categories as list_ledger_categories, list_ledger_import_batches as list_ledger_import_batches, list_ledger_transactions as list_ledger_transactions, + list_manual_assets as list_manual_assets, list_uncategorized_transactions as list_uncategorized_transactions, preview_asset_snapshot_close as preview_asset_snapshot_close, preview_categorization as preview_categorization, @@ -80,6 +85,7 @@ from ...service import ( update_category_rule as update_category_rule, update_ledger_account as update_ledger_account, update_ledger_transaction as update_ledger_transaction, + update_manual_asset as update_manual_asset, ) TOOL_CONTRACTS = { @@ -90,6 +96,7 @@ TOOL_CONTRACTS = { "set_settlement_draft_item", "review_settlement_draft", "approve_settlement_draft", "finalize_settlement_draft", "cancel_settlement_draft", "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", "set_settlement_draft_item", "update_settlement_draft", "set_settlement_cashflow_policy", "get_monthly_settlement_snapshot", + "list_monthly_settlement_history", "get_monthly_settlement_history_record", "annotations", "apply_categorization_rules", "close_asset_snapshot", @@ -108,6 +116,8 @@ __all__ = [ "create_ledger_budget", "create_ledger_category", "create_ledger_transaction", + "create_manual_asset", + "deactivate_manual_asset", "delete_category_rule", "delete_ledger_category", "delete_ledger_transaction", @@ -150,6 +160,7 @@ __all__ = [ "list_ledger_categories", "list_ledger_import_batches", "list_ledger_transactions", + "list_manual_assets", "list_uncategorized_transactions", "preview_asset_snapshot_close", "preview_categorization", @@ -166,4 +177,5 @@ __all__ = [ "update_category_rule", "update_ledger_account", "update_ledger_transaction", + "update_manual_asset", ] diff --git a/jarca_ledger_mcp/adapters/streamlit/assets_gateway.py b/jarca_ledger_mcp/adapters/streamlit/assets_gateway.py index 68dbba3..4e4f2b3 100644 --- a/jarca_ledger_mcp/adapters/streamlit/assets_gateway.py +++ b/jarca_ledger_mcp/adapters/streamlit/assets_gateway.py @@ -2,10 +2,18 @@ from __future__ import annotations from ...service import ( 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, + update_manual_asset as update_manual_asset, ) __all__ = [ "close_asset_snapshot", + "create_manual_asset", + "deactivate_manual_asset", + "list_manual_assets", "preview_asset_snapshot_close", + "update_manual_asset", ] diff --git a/jarca_ledger_mcp/application/account_valuation_service.py b/jarca_ledger_mcp/application/account_valuation_service.py index 43aacec..6228f87 100644 --- a/jarca_ledger_mcp/application/account_valuation_service.py +++ b/jarca_ledger_mcp/application/account_valuation_service.py @@ -1,40 +1,418 @@ from __future__ import annotations -import sqlite3 -from typing import Any +from datetime import date 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"} -_ALLOWED_SETTLEMENT_STATUS = {"draft", "in_review"} -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 +_VALID_STATUSES = {"verified", "unverified", "rejected"} +_VALID_LOAN_DEFINITIONS = {"principal_only", "principal_interest_fees"} +_VALID_CURRENCIES = {"KRW", "USD", "EUR", "JPY", "CNY", "GBP"} +_VALID_KINDS = {"balance", "cash_balance", "total_value", "loan", "loan_balance"} +_VALID_SOURCE_TYPES = {"manual", "import", "account_write", "reconciliation", "snapshot"} +_COLUMNS = ( + "amount", "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", +) -def _public_candidate(item: dict[str, Any], strict: bool = False) -> dict[str, Any]: - """Compatibility projection used by asset evidence readers.""" - 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 _valuation(row: Any) -> dict[str, Any]: + return row_to_dict(row) -def record_account_valuation_candidate(*args: Any, **kwargs: Any) -> dict[str, Any]: - return fail("unsupported", "account valuation candidates are not writable") +def _public_candidate(row: Any) -> dict[str, Any]: + return {k: row[k] for k in ("id", "basis_date", "currency", "valuation_kind", "verification_status")} -def _strict_int(value: Any, field: str) -> int: - if isinstance(value, bool) or not isinstance(value, int): +def _strict_int(value: Any, field: str, allow_none: bool = True) -> int | None: + if value is None and allow_none: + return None + if isinstance(value, bool): 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( + 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, basis_date: str, amount: int, @@ -59,7 +437,8 @@ def register_account_valuation( try: if not account_id or not basis_date or not currency: 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": raise ValueError("source_type must be account") if verification_status not in _ALLOWED_VERIFICATION: @@ -122,21 +501,6 @@ def register_account_valuation( 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]: try: @@ -161,3 +525,12 @@ def update_account_snapshot_category(snapshot_id: str, monthly_settlement_id: st return fail("validation_error", str(exc)) except Exception: 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 diff --git a/jarca_ledger_mcp/application/accounts_write_services.py b/jarca_ledger_mcp/application/accounts_write_services.py index 2af1ff8..2305a58 100644 --- a/jarca_ledger_mcp/application/accounts_write_services.py +++ b/jarca_ledger_mcp/application/accounts_write_services.py @@ -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: - """Deprecated: balances remain on accounts; no valuation row is created.""" - return + balance = account.get("last_balance") if balance is None else balance + 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]: @@ -25,7 +32,7 @@ def create_ledger_account(name: str, type: str = "bank", institution: str = "", if last_balance is not None: try: 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: return fail("validation_error", str(e)) 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: try: 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: return fail("validation_error", str(e)) 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") target_type = fields.get("type", current["type"]) 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] incoming_date = normalized_basis if basis_update else "" historical = bool(balance_update and incoming_date and stored_date and incoming_date < stored_date) diff --git a/jarca_ledger_mcp/application/asset_account_services.py b/jarca_ledger_mcp/application/asset_account_services.py index ecd2f3f..f208476 100644 --- a/jarca_ledger_mcp/application/asset_account_services.py +++ b/jarca_ledger_mcp/application/asset_account_services.py @@ -3,9 +3,13 @@ from __future__ import annotations from .assets_service import ( close_asset_snapshot as close_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_snapshot_report as get_asset_snapshot_report, 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, + update_manual_asset as update_manual_asset, ) from .accounts_write_services import update_ledger_account as update_ledger_account diff --git a/jarca_ledger_mcp/application/assets_service.py b/jarca_ledger_mcp/application/assets_service.py index 891c591..ba046d6 100644 --- a/jarca_ledger_mcp/application/assets_service.py +++ b/jarca_ledger_mcp/application/assets_service.py @@ -25,15 +25,9 @@ from .shared import ( 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]: return { - "authority_sources": ["accounts.last_balance"], + "authority_sources": ["accounts.last_balance", "manual_assets"], "snapshot_role": "historical_record_layer", "formula": "asset_buckets - loan", "asset_buckets": ASSET_BUCKETS, @@ -166,8 +160,64 @@ def _account_valuation_items(conn: sqlite3.Connection, basis_date: str = "", sel continue balance = account.get("last_balance") 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"}) - balance = 0 + valuation_rows = conn.execute( + "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: 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 +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: 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]]: """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]]: - 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]]: @@ -220,7 +396,7 @@ def _snapshot_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str, "id": snap.get("id", ""), "source_id": snap.get("source_id", ""), "source_type": snap.get("source_type", ""), - "authority_source": "account_snapshot", + "authority_source": "asset_snapshots", "valuation_source": snap.get("valuation_source", ""), "name": snap.get("name", ""), "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]]: - if isinstance(metadata.get("frozen_warnings"), list): - return metadata["frozen_warnings"] warnings = _closed_run_metadata(metadata)["warnings"] row_by_key = {(row["source_type"], row["source_id"]): row_to_dict(row) for row in rows} 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, "amount": amount, "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, "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, @@ -380,29 +555,102 @@ def _summarize_valuation_items(included_items: list[dict[str, Any]], excluded_it } - -def create_manual_asset(*args, **kwargs): - return fail("unsupported", "manual assets are no longer supported") +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]: + if not name.strip(): + 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): - return fail("unsupported", "manual assets are no longer supported") +@read_operation +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): - return fail("unsupported", "manual assets are no longer supported") +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]: + 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): - return fail("unsupported", "manual assets are no longer supported") +def deactivate_manual_asset(asset_id: str, hard_delete: bool = False, db_path: str | None = None) -> dict[str, Any]: + 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]: account_items, account_excluded, invalid_bucket_accounts = _account_valuation_items(conn, basis, selection_policy) - included = account_items - excluded = account_excluded + manual_items, manual_excluded = _manual_asset_valuation_items(conn, basis_date=basis, selection_policy=selection_policy) + included = account_items + manual_items + excluded = account_excluded + manual_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]] = [] 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")}) @@ -425,8 +673,16 @@ def _snapshot_preview_from_conn(conn: sqlite3.Connection, basis: str, selection_ for candidate in warning.get("candidates", []) if candidate.get("id") }) - # The removed valuation table is never queried, including for preview 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] return { "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]: - return _snapshot_run_history_unavailable() if selection_policy not in {"exact", "as_of"}: return fail("validation_error", "selection_policy must be exact or as_of") try: @@ -483,12 +738,6 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str run_id = new_id("snaprun") with db(db_path) as conn: 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) if preview_fingerprint and preview_fingerprint != _preview_fingerprint(preview, basis, selection_policy): 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_memo = memo or item.get("memo", "") 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(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( 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", "")), row_note, row_memo, - monthly_settlement_id, + run_id, correction_of_run_id, correction_reason.strip(), int(is_correction), @@ -645,10 +894,6 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str 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) 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", [])] @@ -676,8 +921,6 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str except sqlite3.IntegrityError: conn.rollback() 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 = { "approved_by": approved_by, "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]: - if source_type != "account": - return fail("unsupported", "manual asset snapshots are no longer supported") + if source_type not in {"account", "manual_asset"}: + return fail("validation_error", "source_type must be one of ['account', 'manual_asset']") if not name.strip(): return fail("validation_error", "snapshot item name is required") try: @@ -741,17 +984,16 @@ def create_asset_snapshot(basis_date: str, source_type: str, source_id: str, nam snapshot_id = new_id("snap") with db(db_path) as conn: 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(?,?,?,?,?,?,?,?,?,?,?,?)""", (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)) @read_operation 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"}: return fail("validation_error", "selection_policy must be exact or as_of") 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) return _ok_with_provenance_aliases( 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, snapshot_run_id=close_metadata.get("snapshot_run_id", ""), snapshot_count=len(snapshots), @@ -849,7 +1091,6 @@ def get_asset_snapshot_report(basis_date: str = "", db_path: str | None = None, @read_operation 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: 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() @@ -865,7 +1106,7 @@ def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: i return fail("validation_error", str(e)) with read_db(db_path) as conn: basis_rows = conn.execute( - """SELECT basis_date FROM account_snapshot + """SELECT basis_date FROM asset_snapshots WHERE basis_date>=? AND basis_date<=? UNION 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", "")) items = _snapshot_items(conn, basis) 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) summary = _summarize_valuation_items(items, run_public["excluded_items"]) 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_liabilities": summary["total_liabilities"], "net_worth": summary["net_worth"], - "source": "account_snapshot", + "source": "asset_snapshots", "item_count": len(items), "snapshot_run_id": metadata.get("snapshot_run_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: account_items, account_excluded, invalid_bucket_accounts = _account_valuation_items(conn) - included = account_items - excluded = account_excluded + manual_items, manual_excluded = _manual_asset_valuation_items(conn) + included = account_items + manual_items + excluded = account_excluded + manual_excluded summary = _summarize_valuation_items(included, excluded) 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( buckets=summary["buckets"], accounts=source_accounts, - manual_assets=[], + manual_assets=manual_items, total_assets=summary["total_assets"], total_liabilities=summary["total_liabilities"], 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, invalid_bucket_accounts=invalid_bucket_accounts, - manual_asset_count=0, + manual_asset_count=len(manual_items), included_items=included, excluded_items=excluded, valuation_policy=summary["valuation_policy"], diff --git a/jarca_ledger_mcp/application/monthly_settlement_service.py b/jarca_ledger_mcp/application/monthly_settlement_service.py index 248f9cf..4e5ec16 100644 --- a/jarca_ledger_mcp/application/monthly_settlement_service.py +++ b/jarca_ledger_mcp/application/monthly_settlement_service.py @@ -33,7 +33,7 @@ def _review_cashflow(month: str, db_path: str | None = None, conn=None) -> dict[ 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=? AND t.transaction_date=? AND t.transaction_date dict[str, Any]: - return fail("unsupported", "run history unavailable") """Return immutable close/correction records; lifecycle history is authoritative.""" try: 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]: - return fail("unsupported", "run history unavailable") """Read one immutable close/correction record by run ID.""" if not isinstance(run_id, str) or not run_id: 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]: - return fail("unsupported", "run history unavailable") """Read a persisted monthly settlement close without consulting live ledger state.""" if not isinstance(month, str) or not isinstance(run_id, str): return fail("validation_error", "month and run_id must be strings") @@ -582,32 +579,38 @@ def get_monthly_settlement_snapshot(month: str = "", run_id: str = "", db_path: try: with read_db(db_path) as conn: tables = {r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} - if "asset_snapshot_runs" not in tables or "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") run = None rows = [] legacy = False metadata: dict[str, Any] = {} - run_cols = {r["name"] for r in conn.execute("PRAGMA table_info(asset_snapshot_runs)")} 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() if run: from .assets_service import _snapshot_rows_for_run rows, metadata = _snapshot_rows_for_run(conn, run_id) else: - cols = {r["name"] for r in conn.execute("PRAGMA table_info(account_snapshot)")} + cols = {r["name"] for r in conn.execute("PRAGMA table_info(asset_snapshots)")} if {"snapshot_run_id", "closed_at"} <= cols: - rows = conn.execute("SELECT rowid AS _rowid,* FROM 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: legacy = True metadata = {"snapshot_run_id": run_id, "legacy_closed": True, "close_status": "legacy_read_only"} else: - if selected_month and "settlement_month" in run_cols: - 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() - elif selected_month: - run = conn.execute("SELECT rowid AS _run_rowid,* FROM asset_snapshot_runs WHERE basis_date=? ORDER BY rowid DESC LIMIT 1", (_month_end_date(selected_month),)).fetchone() + if selected_month: + run_columns = {r["name"] for r in conn.execute("PRAGMA table_info(asset_snapshot_runs)")} + comprehensive_columns = {"snapshot_schema_version", "aggregation_version"} + 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: basis = _month_end_date(selected_month) if selected_month else "" if basis: @@ -650,7 +653,7 @@ def get_monthly_settlement_snapshot(month: str = "", run_id: str = "", db_path: legacy = not comprehensive cashflows = [] if comprehensive and "settlement_cashflow_snapshots" in tables: - cashflows = [dict(r) for r in conn.execute("SELECT cashflow_kind,category,subcategory,amount,transaction_count,currency FROM settlement_cashflow_snapshots WHERE 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} safe_items = [] for row in rows: diff --git a/jarca_ledger_mcp/application/reconciliation_service.py b/jarca_ledger_mcp/application/reconciliation_service.py index d900014..9c7a041 100644 --- a/jarca_ledger_mcp/application/reconciliation_service.py +++ b/jarca_ledger_mcp/application/reconciliation_service.py @@ -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() valuation = None ambiguous_valuation = False - valuation_status = "unavailable" - calc = _account_balance_row(conn, account["id"], effective_at, False) + valuation_rows = conn.execute( + """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: difference = None ratio = None 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: difference = int(effective_baseline) - int(calc["calculated_balance"]) ratio = abs(difference) / abs(int(effective_baseline)) if int(effective_baseline) else None 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({ "account_id": account["id"], "account_name": account["name"], diff --git a/jarca_ledger_mcp/application/settlement_draft_service.py b/jarca_ledger_mcp/application/settlement_draft_service.py index 6ce83b8..f936006 100644 --- a/jarca_ledger_mcp/application/settlement_draft_service.py +++ b/jarca_ledger_mcp/application/settlement_draft_service.py @@ -39,10 +39,11 @@ def _draft(conn, did): def _current_semantics(conn, item): if item['source_type']=='account': src=conn.execute('SELECT * FROM accounts WHERE id=?',(item['source_id'],)).fetchone() - val=None - child=None + val=conn.execute('SELECT * FROM account_valuations WHERE id=?',(item.get('valuation_id',''),)).fetchone() if item.get('valuation_id') else None + child=conn.execute('SELECT id FROM account_valuations WHERE supersedes_id=?',(item.get('valuation_id',''),)).fetchone() if item.get('valuation_id') else None return {'source':dict(src) if src else None,'valuation':dict(val) if val else None,'child':dict(child) if child else None} - 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): @@ -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']: row['amount'] = candidate if row['included'] else None elif item['source_type'] == 'account': - row['candidate_status'] = 'missing' - row['source_missing_warning'] = 'account valuation source unavailable' + # Candidate visibility does not make it selected or authoritative. + candidate = conn.execute('SELECT amount FROM account_valuations WHERE account_id=? AND basis_date<=? ORDER BY recorded_at DESC,rowid DESC LIMIT 1', (item['source_id'], draft['basis_date'])).fetchone() + if candidate: + row['candidate_amount'] = candidate['amount'] if row['currency'] != 'KRW': warnings.append({'code': 'non_krw', 'source_id': item['source_id']}) row['status'] = 'excluded' if not row['included'] else 'ready' if row['amount'] is not None else 'missing' @@ -122,7 +125,7 @@ def _review(conn, draft): included.append(row) items.append(row) # 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}) latest = _latest_close(conn, draft['basis_date']) summary = _summarize_valuation_items(included) @@ -144,7 +147,7 @@ def _review(conn, draft): full_totals = {**totals} serial = json.dumps({'draft_id': draft['id'], 'version': draft['version'], 'basis_date': draft['basis_date'], 'states': states, 'warnings': warnings, 'latest_run': latest, 'correction_parent': draft['correction_of_run_id'], 'totals': full_totals, 'cashflow': cf}, sort_keys=True, ensure_ascii=False) return {'draft_id': draft['id'], 'version': draft['version'], 'review_fingerprint': hashlib.sha256(serial.encode()).hexdigest(), - 'cashflow_policy': policy, '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, 'approval_required': True, 'totals': totals, **totals, '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 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 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: c.execute('BEGIN IMMEDIATE') payload_hash=hashlib.sha256(json.dumps([month,created_by,basis,memo,correction_of_run_id,correction_reason],separators=(',',':')).encode()).hexdigest() - old=c.execute("SELECT * FROM settlement_drafts WHERE create_key=? AND status <> 'cancelled'",(idempotency_key,)).fetchone() + old=c.execute('SELECT * FROM settlement_drafts WHERE create_key=?',(idempotency_key,)).fetchone() if old: if old['original_request_hash']!=payload_hash: return _err('conflict','idempotency key payload conflict') return _result(c,old['id']) if c.execute("SELECT 1 FROM settlement_drafts WHERE month=? AND status='active'",(month,)).fetchone(): return _err('conflict','active draft already exists for month') latest = _latest_close(c, basis) - if not correction_of_run_id and latest: - return _err('validation_error', 'closed month requires correction linkage and reason') - if correction_of_run_id and correction_of_run_id != latest: + if correction_of_run_id != latest: return _err('validation_error', 'latest closed run must be linked explicitly with correction reason') did=uuid4().hex; ts=now_iso() c.execute('INSERT INTO settlement_drafts(id,month,basis_date,status,version,created_by,created_at,updated_at,memo,create_key,correction_of_run_id,correction_reason,original_request_hash) VALUES(?,?,? ,\'active\',1,?,?,?,?,?,?,?,?)',(did,month,basis,created_by,ts,ts,memo,idempotency_key,correction_of_run_id,correction_reason,payload_hash)) @@ -198,6 +199,9 @@ def create_settlement_draft(month: str, created_by: str, idempotency_key: str, b vid = selected.get(a['id'], '') ev = _freeze(c, 'account', a['id'], vid) c.execute('INSERT INTO settlement_draft_items(id,draft_id,source_type,source_id,valuation_id,selection_mode,included,version,selected_by,selected_at,evidence_json) VALUES(?,?,?,?,?,?,?,?,?,?,?)',(uuid4().hex,did,'account',a['id'],vid,'exact',1,1,created_by,ts,json.dumps(ev,sort_keys=True))) + for a in c.execute("SELECT * FROM manual_assets WHERE active=1 ORDER BY id").fetchall(): + ev = _freeze(c, 'manual_asset', a['id']) + c.execute('INSERT INTO settlement_draft_items(id,draft_id,source_type,source_id,valuation_id,selection_mode,included,version,selected_by,selected_at,evidence_json) VALUES(?,?,?,?,?,?,?,?,?,?,?)',(uuid4().hex,did,'manual_asset',a['id'],'','exact',1,1,created_by,ts,json.dumps(ev,sort_keys=True))) return _result(c,did) except (ValueError,TypeError,sqlite3.Error) as e: return _err('validation_error' if isinstance(e,(ValueError,TypeError)) else 'db_error','invalid draft request' if isinstance(e,(ValueError,TypeError)) else 'database error') @@ -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: return _err('conflict', 'draft state/version conflict') 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)} 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(): key = (typ, source['id']) 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): - if source_type == 'manual': source_type = 'manual_asset' - if source_type != 'account' or type(included) is not bool or selection_mode not in ('exact', 'carried_before', 'manual_substitute'): + source_type = 'manual_asset' if source_type == 'manual' else source_type + if type(included) is not bool or source_type not in ('account', 'manual_asset') or selection_mode not in ('exact', 'carried_before', 'manual_substitute'): return _err('validation_error', 'invalid item fields') if not _valid_version(expected_version) or not _nonempty(selected_by) or not _nonempty(source_id) or not isinstance(valuation_id, str) or not isinstance(selection_reason, str) or not isinstance(exclusion_reason, str): return _err('validation_error', 'valid version, source and actor required') @@ -328,7 +334,7 @@ def set_settlement_draft_item(draft_id: str,source_type: str,source_id: str,expe elif source_type == 'account': if selection_mode != 'exact': return _err('validation_error', 'an explicit valuation ID is required') 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'): 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') @@ -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): - 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') try: 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']] excluded = [{**x, 'draft_id': draft_id, 'draft_version': expected_version, 'review_fingerprint': review_fingerprint, 'approval': approval, 'reason': 'approved_missing', 'exclusion_reason': x.get('exclusion_reason') or x.get('reason', 'missing_evidence')} for x in r['excluded_items'] + r['missing_items']] carried = [x for x in applied if x['selection_mode'] == 'carried_before'] - run=uuid4().hex; 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' - 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)) + run=uuid4().hex; ts=now_iso(); basis=d['basis_date']; status='conditional' if excluded or r['contains_substitutions'] or r['contains_carried'] else 'complete_exact' c.execute('INSERT INTO asset_snapshot_runs(id,basis_date,close_status,selection_policy,idempotency_key,approved_by,approved_at,approval_reason,approval_reference,correction_of_run_id,correction_reason,applied_evidence,excluded_evidence,carried_evidence,request_fingerprint,created_at,draft_id,draft_version,review_fingerprint,settlement_month,currency,income_total,expense_total,net_cashflow,total_assets,total_liabilities,net_worth,cashflow_policy_json,aggregation_version,snapshot_schema_version,source_snapshot_json) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',(run,basis,status,'exact',idempotency_key,d['approved_by'],d['approved_at'],d['approval_reason'],d['approval_reference'],d['correction_of_run_id'],d['correction_reason'],json.dumps(applied,sort_keys=True),json.dumps(excluded,sort_keys=True),json.dumps(carried,sort_keys=True),req,ts,draft_id,expected_version,review_fingerprint,d['month'],'KRW',r['income_total'],r['expense_total'],r['net_cashflow'],r['total_assets'],r['total_liabilities'],r['net_worth'],json.dumps(r['cashflow_policy'],sort_keys=True),r['aggregation_version'],r['snapshot_schema_version'],json.dumps(r['source_snapshot'],sort_keys=True))) snapshot_ids = [] for x in applied + excluded: sid = uuid4().hex snapshot_ids.append(sid) - c.execute('INSERT INTO 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']: - 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'])) - 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,))] + c.execute('INSERT INTO settlement_cashflow_snapshots(id,settlement_run_id,cashflow_kind,category,subcategory,amount,transaction_count,currency) VALUES(?,?,?,?,?,?,?,?)', (uuid4().hex,run,item['cashflow_kind'],item['category'],item['subcategory'],item['amount'],item['transaction_count'],item['currency'])) + saved_groups = [dict(x) for x in c.execute('SELECT cashflow_kind,category,subcategory,amount,transaction_count,currency FROM settlement_cashflow_snapshots WHERE settlement_run_id=? ORDER BY cashflow_kind,category,subcategory,currency', (run,))] expected_groups = sorted([{'cashflow_kind':x['cashflow_kind'],'category':x['category'],'subcategory':x['subcategory'],'amount':x['amount'],'transaction_count':x['transaction_count'],'currency':x['currency']} for x in r['cashflow_items']], key=lambda x:(x['cashflow_kind'],x['category'],x['subcategory'],x['currency'])) if saved_groups != expected_groups: raise ValueError('persisted cashflow detail mismatch') header = c.execute('SELECT * FROM asset_snapshot_runs WHERE id=?', (run,)).fetchone() @@ -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)): if json.loads(header[key]) != value: raise ValueError('persisted evidence mismatch') - # A correction shares the canonical month with its parent. Read - # 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,))] + persisted_assets = [dict(x) for x in c.execute('SELECT * FROM asset_snapshots WHERE snapshot_run_id=? ORDER BY id', (run,))] fields = ('source_type','source_id','amount','currency','liquidity_bucket') expected_assets = sorted(zip(snapshot_ids, applied+excluded), key=lambda pair:pair[0]) if len(persisted_assets) != len(expected_assets): diff --git a/jarca_ledger_mcp/application/system_services.py b/jarca_ledger_mcp/application/system_services.py index 7b97272..5bdba35 100644 --- a/jarca_ledger_mcp/application/system_services.py +++ b/jarca_ledger_mcp/application/system_services.py @@ -68,7 +68,7 @@ def _readonly_sqlite_connection(path: Path) -> sqlite3.Connection: def _safe_table_counts(conn: sqlite3.Connection) -> 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() if exists: counts[table] = int(conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]) diff --git a/jarca_ledger_mcp/infrastructure/sqlite/migrations.py b/jarca_ledger_mcp/infrastructure/sqlite/migrations.py index 68a35c2..dc4ba93 100644 --- a/jarca_ledger_mcp/infrastructure/sqlite/migrations.py +++ b/jarca_ledger_mcp/infrastructure/sqlite/migrations.py @@ -1,61 +1,10 @@ from __future__ import annotations import sqlite3 -import json from .connection import connect, resolve_db_path 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]: 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}") -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: """Apply additive, idempotent migrations only.""" - _drop_account_valuations(conn) - conn.execute("""CREATE TABLE IF NOT EXISTS settlement_drafts ( id TEXT PRIMARY KEY, month TEXT NOT NULL, basis_date TEXT NOT NULL, status TEXT NOT NULL, version INTEGER NOT NULL, created_by TEXT NOT NULL, @@ -284,7 +83,22 @@ def migrate_db(conn: sqlite3.Connection) -> None: _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 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, basis_date 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 '', 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 '', created_at TEXT NOT NULL )""") - _add_missing_columns(conn, "account_snapshot", { - "monthly_settlement_id": "TEXT REFERENCES monthly_settlement(id) ON DELETE RESTRICT", - "correction_of_run_id": "TEXT NOT NULL DEFAULT ''", + _add_missing_columns(conn, "asset_snapshots", { + "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 ''", @@ -314,13 +129,48 @@ def migrate_db(conn: sqlite3.Connection) -> None: "evidence_kind": "TEXT NOT NULL DEFAULT 'applied'", "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_account_snapshot_source ON account_snapshot(source_type, source_id)") - if "monthly_settlement_id" in _existing_columns(conn, "account_snapshot"): - 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 INDEX IF NOT EXISTS idx_settlement_cashflow_snapshots_month ON settlement_cashflow_snapshots(monthly_settlement_id)") + 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_asset_snapshots_source ON asset_snapshots(source_type, source_id)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_asset_snapshots_run ON asset_snapshots(basis_date, snapshot_run_id, 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 '', + 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 ( id TEXT PRIMARY KEY, 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 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 monthly_settlement ( - 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)) + conn.execute("""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 )""") - conn.execute("CREATE INDEX IF NOT EXISTS idx_month_settlement_status_month ON monthly_settlement(status, month)") - _add_missing_columns(conn, "monthly_settlement", {"approved_cashflow_fingerprint": "TEXT"}) - _drop_legacy_snapshot_storage(conn) - # schema version 16 retires manual_assets without creating replacement rows. - conn.execute("DROP TABLE IF EXISTS manual_assets") + conn.execute("CREATE INDEX IF NOT EXISTS idx_account_valuations_account_date ON account_valuations(account_id, basis_date, recorded_at)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_account_valuations_status_date ON account_valuations(verification_status, basis_date)") + conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_account_valuations_idempotency ON account_valuations(account_id, idempotency_key) WHERE idempotency_key <> ''") + 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("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: path = resolve_db_path(db_path) with connect(str(path)) as conn: stored = _stored_version(conn) - if stored not in (None, "14", "15"): - if stored == "16" and _compatible_known_v16(conn): - pass - else: + if stored not in (None, "11", "12", "13", "14", "15"): + if stored != "16" or not _compatible_known_v16(conn): raise sqlite3.DatabaseError(f"unsupported schema version {stored}; refusing implicit downgrade") - # Keep the entire migration atomic. In particular, executescript() commits - # implicitly, which would leave half-renamed legacy tables after a failure. - 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. + conn.execute("BEGIN IMMEDIATE") + # SQLite DDL and all row copies participate in this transaction. _execute_script_transactional(conn, SCHEMA_SQL) - _migrate_cashflow_snapshots_to_monthly(conn) + _upgrade_cashflow_storage(conn) migrate_db(conn) - conn.execute("PRAGMA foreign_keys=ON") - conn.execute("INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', ?)", (SCHEMA_VERSION,)) + _add_missing_columns(conn, "monthly_settlement", {"approved_cashflow_fingerprint": "TEXT"}) + _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() return str(path) diff --git a/jarca_ledger_mcp/infrastructure/sqlite/schema.py b/jarca_ledger_mcp/infrastructure/sqlite/schema.py index 0e9a3c2..52e978d 100644 --- a/jarca_ledger_mcp/infrastructure/sqlite/schema.py +++ b/jarca_ledger_mcp/infrastructure/sqlite/schema.py @@ -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 TABLE IF NOT EXISTS settlement_cashflow_snapshots ( 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')), 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, + 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) ); 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 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'); """ diff --git a/pyproject.toml b/pyproject.toml index 6c6152a..2dc31da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] 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" readme = "README.md" requires-python = ">=3.11" diff --git a/tests/test_account_valuation_edge_contracts.py b/tests/test_account_valuation_edge_contracts.py new file mode 100644 index 0000000..0b08cb3 --- /dev/null +++ b/tests/test_account_valuation_edge_contracts.py @@ -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 diff --git a/tests/test_account_valuation_service_actual.py b/tests/test_account_valuation_service_actual.py index a9bcab2..93de046 100644 --- a/tests/test_account_valuation_service_actual.py +++ b/tests/test_account_valuation_service_actual.py @@ -107,3 +107,39 @@ def test_actual_service_validates_currency_date_and_settlement_and_rolls_back(le with sqlite3.connect(path) as conn: assert conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0] == 0 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() diff --git a/tests/test_account_valuations.py b/tests/test_account_valuations.py new file mode 100644 index 0000000..fe509f1 --- /dev/null +++ b/tests/test_account_valuations.py @@ -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" diff --git a/tests/test_account_valuations_removed.py b/tests/test_account_valuations_removed.py index 2bad9ea..f69539d 100644 --- a/tests/test_account_valuations_removed.py +++ b/tests/test_account_valuations_removed.py @@ -22,7 +22,7 @@ def _tables(path): EXPECTED_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", "transactions", "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" init_db(str(path)) tables = _tables(path) - assert "account_valuations" not in tables + assert "account_valuations" in tables assert tables == EXPECTED_TABLES @@ -55,26 +55,25 @@ def test_init_is_idempotent(tmp_path): 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" init_db(str(path)) 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 values ('legacy-1','acct-1',123)") + 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 accounts (id,name,type,active,created_at,updated_at) values ('acct-1','Keep me','bank',1,'t','t')") conn.commit() init_db(str(path)) with sqlite3.connect(path) as conn: 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" - 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)) 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() @@ -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()) -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" init_db(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)) - assert created == {"success": False, "error_code": "unsupported", "error": "manual assets are no longer supported"} - assert listed == {"success": False, "error_code": "unsupported", "error": "manual assets are no longer supported"} + assert created["success"] and listed["success"] + assert listed["manual_assets"][0]["id"] == created["manual_asset"]["id"] + assert listed["manual_assets"][0]["valuation_amount"] == 100000 diff --git a/tests/test_asset_snapshot_idempotency.py b/tests/test_asset_snapshot_idempotency.py index d944a31..e9958a7 100644 --- a/tests/test_asset_snapshot_idempotency.py +++ b/tests/test_asset_snapshot_idempotency.py @@ -48,13 +48,13 @@ def test_replay_preserves_redacted_unverified_candidates_after_correction(tmp_pa assert corrected["success"] is True 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] replay = service.close_asset_snapshot( "2026-06-30", idempotency_key="candidate-close-a", db_path=db_path, ) 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] 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=?", ("{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] 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["error_code"] == "data_integrity_error" 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 @@ -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"] 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( "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: - 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["snapshot_run_id"] == first["snapshot_run_id"] diff --git a/tests/test_asset_snapshot_runs_removed.py b/tests/test_asset_snapshot_runs_removed.py index b0e53de..3f0213b 100644 --- a/tests/test_asset_snapshot_runs_removed.py +++ b/tests/test_asset_snapshot_runs_removed.py @@ -13,47 +13,46 @@ def tables(path): 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" 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" init_db(str(path)); init_db(str(path)) with sqlite3.connect(path) as c: 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): path = tmp_path / "legacy.sqlite3" init_db(str(path)) 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 values ('run-1','2026-09-30')") + 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 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() init_db(str(path)) with sqlite3.connect(path) as c: 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 "") def test_unsupported_legacy_operations_do_not_touch_missing_db(tmp_path): path = tmp_path / "missing.sqlite3" - assert register_account_valuation("a", "2026-09-30", 1, db_path=str(path))["error_code"] == "validation_error" - assert list_account_valuations("a", db_path=str(path))["error_code"] == "db_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_not_initialized" 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" - assert close_asset_snapshot("2026-09-30", db_path=str(path))["error_code"] == "unsupported" - assert get_asset_snapshot_report("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"] == "db_not_initialized" assert not path.exists() diff --git a/tests/test_asset_snapshots.py b/tests/test_asset_snapshots.py index 98a1ed3..34ac763 100644 --- a/tests/test_asset_snapshots.py +++ b/tests/test_asset_snapshots.py @@ -13,10 +13,10 @@ def test_asset_snapshot_schema_is_additive_and_idempotent(tmp_path): with sqlite3.connect(db_path) as conn: 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", "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): @@ -105,9 +105,9 @@ def test_manual_asset_update_deactivate_preview_and_close_correction_policy(tmp_ assert deactivated["success"] is True 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) - 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["source"] == "live_authority" 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"] 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 diff --git a/tests/test_comprehensive_settlement.py b/tests/test_comprehensive_settlement.py index e433b6e..24d12b3 100644 --- a/tests/test_comprehensive_settlement.py +++ b/tests/test_comprehensive_settlement.py @@ -150,7 +150,7 @@ def test_mid_close_failure_rolls_back_all_settlement_state(tmp_path, failure_poi review = review_result ok(api("approve_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "synthetic-reviewer", "approved", "ref", db_path=path)) with sqlite3.connect(path) as db: - target = {"header":"asset_snapshot_runs", "assets":"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. 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" @@ -160,7 +160,7 @@ def test_mid_close_failure_rolls_back_all_settlement_state(tmp_path, failure_poi assert failed.get("success") is False with sqlite3.connect(path) as db: assert db.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0 - assert db.execute("SELECT COUNT(*) FROM 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 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) assert result.get("success") is False, result 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("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()) +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): from jarca_ledger_mcp.application.monthly_settlement_service import get_monthly_settlement_snapshot 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)) if target.startswith("asset_"): column, expression = ("amount", "amount+1") if target == "asset_amount" else ("currency", "'USD'") - trigger = f"CREATE TRIGGER synthetic_tamper AFTER INSERT ON 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: 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" @@ -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) assert result["success"] is False, "persisted header/detail must match the approved result before commit" with sqlite3.connect(path) as conn: - for table in ("asset_snapshot_runs", "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("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 path, _ = _legacy_closed_db(tmp_path) with sqlite3.connect(path) as conn: - header = conn.execute( - "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" - ) + assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0 before = list(conn.iterdump()) exact = ok(service.get_monthly_settlement_snapshot(run_id="legacy-run-correction", db_path=path)) latest = ok(service.get_monthly_settlement_snapshot(month="2026-06", db_path=path)) diff --git a/tests/test_comprehensive_settlement_adapters.py b/tests/test_comprehensive_settlement_adapters.py index 3df1303..d47f3c6 100644 --- a/tests/test_comprehensive_settlement_adapters.py +++ b/tests/test_comprehensive_settlement_adapters.py @@ -82,6 +82,9 @@ def test_dashboard_exposes_additive_policy_and_saved_history_without_implicit_mu source = drafts.read_text(encoding="utf-8") monthly_source = monthly.read_text(encoding="utf-8") 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 POLICY in source or POLICY in monthly_source diff --git a/tests/test_issue_12_regressions.py b/tests/test_issue_12_regressions.py index fc873b8..e3b6c93 100644 --- a/tests/test_issue_12_regressions.py +++ b/tests/test_issue_12_regressions.py @@ -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 ); 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, 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', @@ -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 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 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) with sqlite3.connect(path) as conn: 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 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)")} 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): diff --git a/tests/test_legacy_snapshot_migration.py b/tests/test_legacy_snapshot_migration.py index b945afa..a6b1537 100644 --- a/tests/test_legacy_snapshot_migration.py +++ b/tests/test_legacy_snapshot_migration.py @@ -16,22 +16,10 @@ def _legacy_closed_db(tmp_path): init_db(path) asset = service.create_manual_asset("legacy cash", 100, "2026-06-01", "cash", db_path=path)["manual_asset"] with sqlite3.connect(path) as conn: - # Rebuild the historical schema-11 table independently; do not insert - # removed columns into the current schema-16 table. - 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 - )""") + # These are schema-11 close rows after the additive schema-12 migration: + # run identity is absent, but row close/correction fields remain frozen. conn.execute( - """INSERT INTO account_snapshot + """INSERT INTO asset_snapshots (id, basis_date, source_type, source_id, name, amount, liquidity_bucket, valuation_source, snapshot_run_id, correction_of_run_id, correction_reason, 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'") init_db(path) 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"] @@ -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") init_db(path) 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 = [ ("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), @@ -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: conn.execute( - """INSERT INTO account_snapshot + """INSERT INTO asset_snapshots (id, basis_date, source_type, source_id, name, amount, currency, liquidity_bucket, valuation_source, snapshot_run_id, 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'") init_db(path) 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() report = service.get_asset_snapshot_report(BASIS, db_path=path) diff --git a/tests/test_manual_assets_removed.py b/tests/test_manual_assets_removed.py index 7f354d9..d9f6f7d 100644 --- a/tests/test_manual_assets_removed.py +++ b/tests/test_manual_assets_removed.py @@ -14,33 +14,34 @@ def tables(path): 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") 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") init_db(path) with sqlite3.connect(path) as conn: - conn.execute("create table manual_assets (id text primary key, name text)") - conn.execute("insert into manual_assets values ('old', 'legacy')") + conn.execute("insert into manual_assets (id,name,created_at,updated_at) values ('old', 'legacy','t','t')") conn.execute("insert into accounts (id,name,type,active,created_at,updated_at) values ('a','Keep','bank',1,'t','t')") conn.commit() init_db(path) with sqlite3.connect(path) as conn: 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): - path = str(tmp_path / "unsupported.sqlite3") +def test_manual_asset_public_operations_are_supported(tmp_path): + path = str(tmp_path / "supported.sqlite3") init_db(path) - expected = {"success": False, "error_code": "unsupported", "error": "manual assets are no longer supported"} - assert create_manual_asset("Emergency", 100, "2026-09-30", "cash", db_path=path) == expected - assert list_manual_assets(db_path=path) == expected + created = create_manual_asset("Emergency", 100, "2026-09-30", "cash", db_path=path) + listed = list_manual_assets(db_path=path) + 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): @@ -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 -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") init_db(path) 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) with sqlite3.connect(path) as conn: assert conn.execute("select count(*) from accounts").fetchone()[0] == before - assert "manual_assets" not in tables(path) + assert "manual_assets" in tables(path) diff --git a/tests/test_month_settlement_schema.py b/tests/test_month_settlement_schema.py index 4795033..0f58d96 100644 --- a/tests/test_month_settlement_schema.py +++ b/tests/test_month_settlement_schema.py @@ -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 -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") init_db(path) 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("UPDATE schema_meta SET value='15' WHERE key='schema_version'") conn.execute("""INSERT INTO monthly_settlement (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) 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() 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 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 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)]) diff --git a/tests/test_monthly_cashflow_lifecycle_actual.py b/tests/test_monthly_cashflow_lifecycle_actual.py index 67b9d1b..5017df4 100644 --- a/tests/test_monthly_cashflow_lifecycle_actual.py +++ b/tests/test_monthly_cashflow_lifecycle_actual.py @@ -102,3 +102,40 @@ def test_empty_close_is_saved_without_fake_marker(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)) 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() == [] diff --git a/tests/test_monthly_settlement_approved_cashflow_fingerprint_schema.py b/tests/test_monthly_settlement_approved_cashflow_fingerprint_schema.py index f260ec4..3e1cb4a 100644 --- a/tests/test_monthly_settlement_approved_cashflow_fingerprint_schema.py +++ b/tests/test_monthly_settlement_approved_cashflow_fingerprint_schema.py @@ -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 -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") init_db(path) 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("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( "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')" diff --git a/tests/test_monthly_settlement_drafts.py b/tests/test_monthly_settlement_drafts.py index c138352..28be219 100644 --- a/tests/test_monthly_settlement_drafts.py +++ b/tests/test_monthly_settlement_drafts.py @@ -34,14 +34,10 @@ def api(name): def dbfile(tmp_path): path = str(tmp_path / "synthetic-ledger.sqlite3") 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 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) @@ -64,8 +60,8 @@ def test_empty_draft_persists_without_closing_and_has_consistent_public_shape(tm assert result["items"] == [] assert isinstance(result["review"], dict) 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 asset_snapshot_runs").fetchone()[0] == 0 + assert conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0] == 0 fetched = assert_ok(api("get_settlement_draft")(draft["id"], db_path=path)) assert fetched["draft"]["id"] == draft["id"] listed = assert_ok(api("list_settlement_drafts")(month="2026-09", db_path=path)) @@ -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 before["draft"]["version"] == draft["version"] 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: assert conn.execute("SELECT COUNT(*) FROM settlement_draft_items").fetchone()[0] == snapshot def test_explicit_future_substitute_and_late_recorded_exact_are_distinguished(tmp_path): path = dbfile(tmp_path) - acct = service.create_ledger_account("Exact 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) - assert result["success"] is False - assert result["error_code"] == "not_found" + a = service.create_ledger_account("Future account", type="investment", liquidity_bucket="investment", db_path=path)["account"]["id"] + b = service.create_ledger_account("Late exact", type="investment", liquidity_bucket="investment", db_path=path)["account"]["id"] + future = service.register_account_valuation(a, "2026-10-05", 500, valuation_kind="total_value", verification_status="verified", idempotency_key="future", db_path=path) + late = service.register_account_valuation(b, "2026-09-30", 700, valuation_kind="total_value", verification_status="verified", idempotency_key="late-exact", db_path=path) + assert late.get("success") + with sqlite3.connect(path) as conn: + conn.execute("UPDATE account_valuations SET recorded_at=? WHERE id=?", ("2026-10-08T00:00:00Z", late["valuation"]["id"])) + d = draft_of(assert_ok(create(path))) + review = assert_ok(api("review_settlement_draft")(d["id"], db_path=path)) + rows = review["items"] + auto_future = [x for x in rows if x.get("source_id") == a] + assert not any(x.get("valuation_id") == future.get("valuation", {}).get("id") and x.get("selection_mode") == "exact" for x in auto_future) + item = next(x for x in rows if x.get("source_id") == b) + assert item.get("valuation_id") == late["valuation"]["id"] + assert item.get("selection_mode", "exact") == "exact" + assert api("set_settlement_draft_item")(d["id"], "account", a, d["version"], "tester", valuation_id=future["valuation"]["id"], selection_mode="manual_substitute", selection_reason="synthetic future evidence", db_path=path).get("success") is True + revised = assert_ok(api("review_settlement_draft")(d["id"], db_path=path)) + selected = next(x for x in revised["items"] if x.get("source_id") == a) + assert selected.get("valuation_date") == "2026-10-05" + assert selected.get("date_difference_days") == 5 + assert selected.get("selection_mode") == "manual_substitute" def test_unverified_candidate_has_no_authority_and_verified_child_requires_reselection(tmp_path): path = dbfile(tmp_path) acct = service.create_ledger_account("Unverified", type="investment", liquidity_bucket="investment", db_path=path)["account"]["id"] - result = service.register_account_valuation(acct, "2026-09-30", 900, db_path=path) - assert result["success"] is False - assert result["error_code"] == "validation_error" - assert "currency" in result["error"] + unv = service.register_account_valuation(acct, "2026-09-30", 900, valuation_kind="total_value", verification_status="unverified", idempotency_key="unv", db_path=path) + draft = draft_of(assert_ok(create(path))) + review = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path)) + row = next(x for x in review["items"] if x.get("source_id") == acct) + assert row.get("amount") is None + assert row.get("candidate_amount") == 900 + child = service.correct_account_valuation(unv["valuation"]["id"], amount=950, verification_status="verified", correction_reason="synthetic verification", idempotency_key="verified-child", db_path=path) + assert child.get("success") + changed = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path)) + assert any(x.get("source_id") == acct for x in changed["missing_items"]) + assert api("set_settlement_draft_item")(draft["id"], "account", acct, draft["version"], "tester", valuation_id=child["valuation"]["id"], selection_mode="exact", db_path=path).get("success") is True def test_manual_sources_drift_requires_explicit_refresh_and_future_not_auto_selected(tmp_path): path = dbfile(tmp_path) - result = service.create_manual_asset("Synthetic asset", 100, "2026-09-30", "real_estate", db_path=path) - assert result == {"success": False, "error_code": "unsupported", "error": "manual assets are no longer supported"} + manual = service.create_manual_asset("Synthetic asset", 100, "2026-09-30", "real_estate", db_path=path)["manual_asset"] + future = service.create_manual_asset("Future asset", 200, "2026-10-07", "deposit", db_path=path)["manual_asset"] + draft = draft_of(assert_ok(create(path))) + review = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path)) + assert not any(x.get("source_id") == future["id"] and x.get("amount") is not None for x in review["items"]) + service.update_manual_asset(manual["id"], valuation_amount=110, db_path=path) + drift = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path)) + assert any("drift" in json.dumps(x).lower() or x.get("review_needed") for x in drift["items"]) + refreshed = api("refresh_settlement_draft_targets")(draft["id"], draft["version"], "tester", db_path=path) + assert refreshed.get("success") is True def test_approval_fingerprint_mutation_and_external_drift_block_finalize(tmp_path): @@ -170,10 +198,10 @@ def test_approval_fingerprint_mutation_and_external_drift_block_finalize(tmp_pat assert mutated.get("success") is True stale = api("finalize_settlement_draft")(draft["id"], mutated["draft"]["version"], review["review_fingerprint"], "close-key", db_path=path) assert stale.get("success") is False - assert stale.get("error_code") == "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) assert_ok(service.create_ledger_account("Synthetic cash", last_balance=321, liquidity_bucket="cash", db_path=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) assert approval.get("success") 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): @@ -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) assert approved.get("success") 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) assert result.get("success") is False 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 asset_snapshot_runs").fetchone()[0] == 0 + assert conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0] == 0 after = assert_ok(api("get_settlement_draft")(draft["id"], db_path=path)) assert after["draft"]["status"] == "active" def test_schema13_migration_is_additive_and_idempotent(tmp_path): path = dbfile(tmp_path) + with sqlite3.connect(path) as conn: + conn.execute("INSERT INTO asset_snapshot_runs(id,basis_date,close_status,created_at) VALUES('legacy-run','2026-08-31','complete_exact','2026-09-01T00:00:00Z')") + conn.commit() init_db(path) init_db(path) with sqlite3.connect(path) as conn: + assert conn.execute("SELECT id FROM asset_snapshot_runs WHERE id='legacy-run'").fetchone()[0] == 'legacy-run' + assert {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} >= {'settlement_drafts','settlement_draft_items','asset_snapshot_runs','asset_snapshots'} assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == '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): @@ -220,7 +268,7 @@ def test_concurrent_create_and_stale_version_finalization_are_safe(tmp_path): results = list(pool.map(attempt, range(4))) successes = [r for r in results if r.get("success")] assert len(successes) == 1 - assert all(r.get("success") or r.get("error_code") in {"conflict", "validation_error", "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]) 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) diff --git a/tests/test_monthly_settlement_history.py b/tests/test_monthly_settlement_history.py index a7c6237..d17db4c 100644 --- a/tests/test_monthly_settlement_history.py +++ b/tests/test_monthly_settlement_history.py @@ -1,4 +1,5 @@ from pathlib import Path +import sqlite3 from jarca_ledger_mcp.db import init_db 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") - init_db(path) - result = list_monthly_settlement_history(db_path=path) - assert result["success"] is False - assert result["error_code"] == "unsupported" + _seed_history(path) + + result = list_monthly_settlement_history("2026-09", db_path=path) + + 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") - init_db(path) + _seed_history(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["error_code"] == "unsupported" + assert result["error_code"] == "not_found" diff --git a/tests/test_monthly_settlement_navigation.py b/tests/test_monthly_settlement_navigation.py index d6e674b..7e4c4c8 100644 --- a/tests/test_monthly_settlement_navigation.py +++ b/tests/test_monthly_settlement_navigation.py @@ -69,6 +69,10 @@ class _FakeStreamlit: def popover(self, *args, **kwargs): return self._Column() + def link_button(self, label, url, **kwargs): + self.buttons.append((label, {"url": url, **kwargs})) + return False + def rerun(self): return None diff --git a/tests/test_monthly_settlement_public_history.py b/tests/test_monthly_settlement_public_history.py new file mode 100644 index 0000000..46347fa --- /dev/null +++ b/tests/test_monthly_settlement_public_history.py @@ -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 diff --git a/tests/test_real_estate_account_type.py b/tests/test_real_estate_account_type.py index cca79f6..0254210 100644 --- a/tests/test_real_estate_account_type.py +++ b/tests/test_real_estate_account_type.py @@ -1,6 +1,7 @@ from __future__ import annotations import inspect +from pathlib import Path from jarca_ledger_mcp import service 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) assert listed["success"] is True 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) assert preview["success"] is True assert any( @@ -55,3 +60,9 @@ def test_unknown_account_type_remains_rejected(tmp_path): ) assert result["success"] is False 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 diff --git a/tests/test_requirement_docs.py b/tests/test_requirement_docs.py index 8343087..67abe8a 100644 --- a/tests/test_requirement_docs.py +++ b/tests/test_requirement_docs.py @@ -115,7 +115,7 @@ def test_documented_paths_and_symbols_exist() -> None: "dashboard/page_views/accounts.py", "dashboard/page_views/assets.py", "tests/test_account_valuations.py", - "tests/test_account_snapshot.py", + "tests/test_asset_snapshots.py", "tests/test_contract_regression.py", ] for rel in paths: diff --git a/tests/test_review_decision_workbench.py b/tests/test_review_decision_workbench.py index 622b854..38279ff 100644 --- a/tests/test_review_decision_workbench.py +++ b/tests/test_review_decision_workbench.py @@ -16,7 +16,7 @@ def test_review_decision_and_reconciliation_schema_are_additive_idempotent(tmp_p assert status["integrity_check"] == "ok" assert status["table_counts"]["review_decisions"] == 0 assert status["table_counts"]["reconciliation_sessions"] == 0 - assert service.health_check(db_path)["schema_version"] == "16" + assert service.health_check(db_path)["schema_version"] == "15" def test_review_decision_round_trip_payload_sanitizes_sensitive_fields(tmp_path: Path) -> None: diff --git a/tests/test_schema_14_to_15_actual.py b/tests/test_schema_14_to_15_actual.py index ceed885..eb75c2e 100644 --- a/tests/test_schema_14_to_15_actual.py +++ b/tests/test_schema_14_to_15_actual.py @@ -1,6 +1,5 @@ from __future__ import annotations -import hashlib import sqlite3 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)) 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 amount, closed_at FROM account_snapshot 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 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 not None assert db.execute("PRAGMA foreign_key_check").fetchall() == [] assert db.execute("PRAGMA integrity_check").fetchone()[0] == "ok" 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" _v14_db(path) init_db(str(path)) - first = _rows(path, "account_snapshot") + first = _rows(path, "asset_snapshots") 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): @@ -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" cols = {r[1] for r in db.execute("PRAGMA table_info(account_snapshot)")} 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() diff --git a/tests/test_service.py b/tests/test_service.py index cecc262..671dec7 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -10,7 +10,7 @@ def test_health_check_initializes_db(tmp_path): db_path = tmp_path / "ledger.sqlite3" result = service.health_check(str(db_path)) assert result["success"] is True - assert result["schema_version"] == "16" + assert result["schema_version"] == "15" assert db_path.exists() diff --git a/tests/test_settlement_cashflow_policy.py b/tests/test_settlement_cashflow_policy.py index 96e1337..a2e8c4c 100644 --- a/tests/test_settlement_cashflow_policy.py +++ b/tests/test_settlement_cashflow_policy.py @@ -36,22 +36,6 @@ def _add_tx(day, typ, amount, category, subcategory, path): 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): path = str(tmp_path / "policy-precedence.sqlite3") 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): path = str(tmp_path / "policy-approval.sqlite3") 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) reviewed = _ok(service.review_settlement_draft(draft["id"], db_path=path)) assert not reviewed["cashflow_blockers"] - _ok(service.approve_settlement_draft(draft["id"], draft["version"], reviewed["review_fingerprint"], "reviewer", "synthetic", "ref", 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)) saved = _ok(service.get_settlement_draft(draft["id"], db_path=path))["draft"] 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) reader = _capability("get_monthly_settlement_snapshot") empty = reader(month="2026-09", db_path=path) - assert empty["success"] is False and empty["error_code"] == "unsupported" - _ok(_add_snapshot("Synthetic cash", 100, "2026-09-30", "cash", path)) + assert empty["success"] is False and empty["error_code"] == "not_found" + _ok(service.create_manual_asset("Synthetic cash", 100, "2026-09-30", "cash", db_path=path)) draft = _draft(path) reviewed = _ok(service.review_settlement_draft(draft["id"], db_path=path)) assert "review_fingerprint" in reviewed and "review" not in reviewed - approved = _ok(service.approve_settlement_draft(draft["id"], draft["version"], reviewed["review_fingerprint"], "reviewer", "synthetic", "ref", allow_missing_exclusions=True, db_path=path)) - final = 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"} + approved = _ok(service.approve_settlement_draft(draft["id"], draft["version"], reviewed["review_fingerprint"], "reviewer", "synthetic", "ref", db_path=path)) + final = _ok(service.finalize_settlement_draft(draft["id"], approved["draft"]["version"], reviewed["review_fingerprint"], "close-key", db_path=path)) + assert "final_response" not in final and "snapshot_run_id" in final + snapshot = _ok(reader(run_id=final["snapshot_run_id"], db_path=path)) + assert snapshot["is_closed_snapshot"] is True + assert snapshot["snapshot_run_id"] == final["snapshot_run_id"] + assert snapshot["total_assets"] == 100 + assert _ok(reader(month="2026-09", db_path=path))["snapshot_run_id"] == final["snapshot_run_id"] + _ok(service.create_manual_asset("Later live cash", 900, "2026-09-30", "cash", db_path=path)) + persisted = _ok(reader(run_id=final["snapshot_run_id"], db_path=path)) + assert persisted["total_assets"] == 100 and persisted["snapshot_run_id"] == final["snapshot_run_id"] + assert "live_authority" not in persisted.values() def test_month_boundaries_refund_month_negative_total_and_fx_blocker(tmp_path): @@ -199,7 +192,7 @@ def test_policy_capabilities_are_checked_at_call_time(tmp_path): init_db(path) assert callable(_capability("set_settlement_cashflow_policy")) assert callable(_capability("get_monthly_settlement_snapshot")) - assert _capability("get_monthly_settlement_snapshot")(month="", run_id="", db_path=path)["error_code"] == "unsupported" + assert _capability("get_monthly_settlement_snapshot")(month="", run_id="", db_path=path)["error_code"] == "not_found" 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") init_db(path) # 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 = [ # SQL fallback is category-based: ordinary legacy categories become expense, # 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 reviewed["expense_total"] == 123 # 11 + 13 + 17 + 19 - 5 + 31 + 37 assert reviewed["income_total"] == 29 - approved = _ok(service.approve_settlement_draft(draft["id"], draft["version"], reviewed["review_fingerprint"], "reviewer", "synthetic", "ref", allow_missing_exclusions=True, db_path=path)) - closed = 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"} + approved = _ok(service.approve_settlement_draft(draft["id"], draft["version"], reviewed["review_fingerprint"], "reviewer", "synthetic", "ref", db_path=path)) + closed = _ok(service.finalize_settlement_draft(draft["id"], approved["draft"]["version"], reviewed["review_fingerprint"], "inactive-category-close", db_path=path)) + snapshot = _ok(service.get_monthly_settlement_snapshot(run_id=closed["snapshot_run_id"], db_path=path)) + persisted = {row["id"]: row for row in snapshot["source_snapshot"]["transactions"]} + for txid in ids: + assert persisted[txid]["sourceprovenance"] == "fallback" + assert persisted[txid]["category_active"] is None + assert persisted[txid]["category_cashflow_kind"] is None + assert snapshot["expense_total"] == 123 and snapshot["income_total"] == 29 def test_explicit_decision_classifies_only_unresolved_inactive_fallback(tmp_path): diff --git a/tests/test_settlement_cashflow_schema.py b/tests/test_settlement_cashflow_schema.py index f3eb261..cd868cf 100644 --- a/tests/test_settlement_cashflow_schema.py +++ b/tests/test_settlement_cashflow_schema.py @@ -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(?,?,?,?,?,?,?,?)" good = ("d1", "month-a", "expense", "", "", -7, 1, "KRW") db.execute(insert, good) - for values in [("d2", "missing", "expense", "x", "y", 1, 1, "KRW"), ("d3", "run-a", "other", "x", "y", 1, 1, "KRW"), ("d4", "run-a", "expense", "", "", 7, 1, "KRW"), ("d5", "run-a", "expense", "", "", None, 1, "KRW"), ("d6", "run-a", "expense", "", "", 1, 0, "KRW"), ("d7", "run-a", "expense", None, "", 1, 1, "KRW"), ("d8", "run-a", "expense", "", None, 1, 1, "KRW")]: + 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): db.execute(insert, values) 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")) 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] @@ -44,8 +44,57 @@ def test_init_is_idempotent_and_retains_schema_15_contract(tmp_path): _schema(path) with sqlite3.connect(path) as db: 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] init_db(path) with sqlite3.connect(path) as db: 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 diff --git a/tests/test_settlement_draft_audit_reproductions.py b/tests/test_settlement_draft_audit_reproductions.py index c13e612..a68243d 100644 --- a/tests/test_settlement_draft_audit_reproductions.py +++ b/tests/test_settlement_draft_audit_reproductions.py @@ -23,7 +23,7 @@ def test_empty_draft_persists_without_closing_and_has_consistent_public_shape(tm with sqlite3.connect(path) as conn: assert conn.execute('SELECT COUNT(*) FROM settlement_drafts').fetchone()[0] == 1 assert conn.execute('SELECT COUNT(*) FROM asset_snapshot_runs').fetchone()[0] == 0 - assert conn.execute('SELECT COUNT(*) FROM 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): diff --git a/tests/test_settlement_draft_authority.py b/tests/test_settlement_draft_authority.py index 2953708..8208b2f 100644 --- a/tests/test_settlement_draft_authority.py +++ b/tests/test_settlement_draft_authority.py @@ -30,7 +30,7 @@ def approve(path, draft, **flags): def counts(path): with sqlite3.connect(path) as conn: - return tuple(conn.execute('SELECT COUNT(*) FROM ' + t).fetchone()[0] for t in ('asset_snapshot_runs', 'account_snapshot')) + return tuple(conn.execute('SELECT COUNT(*) FROM ' + t).fetchone()[0] for t in ('asset_snapshot_runs', 'asset_snapshots')) @pytest.mark.parametrize('sql', [ diff --git a/tests/test_settlement_draft_contract_scenarios.py b/tests/test_settlement_draft_contract_scenarios.py index fe4f4d0..cfe3591 100644 --- a/tests/test_settlement_draft_contract_scenarios.py +++ b/tests/test_settlement_draft_contract_scenarios.py @@ -163,12 +163,12 @@ def test_synthetic_september_future_pension_loan_late_exact_warnings_stale_rollb d = choose(path, d, cash, cv) review = approve(path, d, allow_missing_exclusions=True, allow_manual_substitute=True) with sqlite3.connect(path) as c: - c.execute("CREATE TRIGGER synthetic_failure BEFORE INSERT ON 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) assert not failed['success'] and failed['error_code'] == 'db_error' with sqlite3.connect(path) as c: assert c.execute('SELECT COUNT(*) FROM asset_snapshot_runs').fetchone()[0] == 0 - assert c.execute('SELECT COUNT(*) FROM account_snapshot').fetchone()[0] == 0 + assert c.execute('SELECT COUNT(*) FROM asset_snapshots').fetchone()[0] == 0 c.execute('DROP TRIGGER synthetic_failure') assert ok(s.get_settlement_draft(d['id'], db_path=path))['draft']['status'] == 'active' def finish(): @@ -184,4 +184,4 @@ def test_synthetic_september_future_pension_loan_late_exact_warnings_stale_rollb assert finish() == final with sqlite3.connect(path) as c: assert c.execute('SELECT COUNT(*) FROM asset_snapshot_runs').fetchone()[0] == 1 - assert c.execute('SELECT COUNT(*) FROM account_snapshot').fetchone()[0] == final['inserted_count'] + assert c.execute('SELECT COUNT(*) FROM asset_snapshots').fetchone()[0] == final['inserted_count'] diff --git a/tests/test_settlement_draft_correction_contract.py b/tests/test_settlement_draft_correction_contract.py index 19fae94..871df39 100644 --- a/tests/test_settlement_draft_correction_contract.py +++ b/tests/test_settlement_draft_correction_contract.py @@ -13,7 +13,7 @@ def ok(r): def counts(path): with sqlite3.connect(path) as c: return {table: c.execute(f'SELECT * FROM {table} ORDER BY rowid').fetchall() - for table in ('asset_snapshot_runs', 'account_snapshot', 'settlement_drafts', 'settlement_draft_items')} + for table in ('asset_snapshot_runs', 'asset_snapshots', 'settlement_drafts', 'settlement_draft_items')} 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) 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)['account_snapshot'] == original_rows['account_snapshot'] + assert counts(path)['asset_snapshots'] == original_rows['asset_snapshots'] run_id = original['snapshot_run_id'] bad_requests = [('', 'reason'), (run_id, ''), ('does-not-exist', 'reason')] diff --git a/tests/test_settlement_draft_dashboard.py b/tests/test_settlement_draft_dashboard.py index 8e91caa..22095d2 100644 --- a/tests/test_settlement_draft_dashboard.py +++ b/tests/test_settlement_draft_dashboard.py @@ -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")] assert len(pages) == 2 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): diff --git a/tests/test_settlement_draft_docs.py b/tests/test_settlement_draft_docs.py index 4421b49..2ec3ab3 100644 --- a/tests/test_settlement_draft_docs.py +++ b/tests/test_settlement_draft_docs.py @@ -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)", "active", "nonempty", "cancelled_by", "cancellation_reason", "cancelled_at", "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", "same create key", "latest prior run", "correction_of_run_id", "correction_reason", "tests/test_settlement_draft_correction_contract.py"): @@ -207,7 +207,7 @@ def test_settlement_cashflow_model_documents_schema_and_legacy_reproduction(tmp_ names = { "asset_snapshot_runs": ("settlement_month", "currency", "income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth", "cashflow_policy_json", "aggregation_version", "snapshot_schema_version", "source_snapshot_json"), "settlement_drafts": ("cashflow_policy_json", "aggregation_version"), - "settlement_cashflow_snapshots": ("id", "settlement_run_id", "cashflow_kind", "category", "subcategory", "amount", "transaction_count", "currency"), + "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" 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") for field in ("source_snapshot_json", "transactions", "assets", "transaction_date", "originalcashflow_kind", "applied_amount", "valuation_id", "valuation_date", "authority_source", "cashflow_policy_json", "legacy", "NULL"): assert field in reproduction, field - for constraint in ("ON DELETE RESTRICT", "UNIQUE", "transaction_count > 0", "idx_settlement_cashflow_snapshots_run"): + 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 diff --git a/tests/test_settlement_draft_mcp_runtime.py b/tests/test_settlement_draft_mcp_runtime.py index 810ec19..7bd47c5 100644 --- a/tests/test_settlement_draft_mcp_runtime.py +++ b/tests/test_settlement_draft_mcp_runtime.py @@ -17,7 +17,8 @@ from mcp.client.stdio import stdio_client OPS = ('create_settlement_draft', 'list_settlement_drafts', 'get_settlement_draft', 'update_settlement_draft', 'refresh_settlement_draft_targets', 'set_settlement_draft_item', 'review_settlement_draft', - 'approve_settlement_draft', 'finalize_settlement_draft', 'cancel_settlement_draft') + 'approve_settlement_draft', 'finalize_settlement_draft', 'cancel_settlement_draft', + 'list_monthly_settlement_history', 'get_monthly_settlement_history_record') ROOT = Path(__file__).resolve().parents[1] @@ -44,7 +45,7 @@ def test_required_public_field_assertion_detects_omission(): 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: 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 provenance['valuation_date'] == item['valuation_date'] and provenance['age_days'] == item['age_days'] before = (await call('get_settlement_draft', {'draft_id':draft_id})) - tables = ('asset_snapshot_runs', '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} 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}) @@ -147,6 +148,16 @@ async def _exercise_stdio_lifecycle(tmp_path): assert archived == before 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'}) 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'}) diff --git a/tests/test_settlement_draft_policy_consistency.py b/tests/test_settlement_draft_policy_consistency.py index 70dbe11..b681ce8 100644 --- a/tests/test_settlement_draft_policy_consistency.py +++ b/tests/test_settlement_draft_policy_consistency.py @@ -259,7 +259,7 @@ def test_legacy_amount_approval_requires_fresh_review_after_policy_correction(tm assert rejected["error_code"] == "conflict" with sqlite3.connect(path) as db: assert db.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0 - assert db.execute("SELECT COUNT(*) FROM 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() assert row == ("active", d["version"], "") assert frozen_items(path, d["id"]) == frozen_before diff --git a/tests/test_settlement_draft_read_models.py b/tests/test_settlement_draft_read_models.py index fda5b8d..f970496 100644 --- a/tests/test_settlement_draft_read_models.py +++ b/tests/test_settlement_draft_read_models.py @@ -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":"manual_asset","source_id":"asset-1","valuation_date":"2026-09-29","age_days":1,"carried_forward":True,"selection_mode":"carried_before","reason":"accepted","selected_by":"reviewer","selected_at":"2026-09-30T01:00:00Z","item_version":3,"verification_status":"verified","source_ref":"ref-1"}]), "2026-09-30T02:00:00Z")) - conn.execute("INSERT INTO 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() report = service.get_asset_snapshot_report("2026-09-30", db_path=path) assert report["success"] diff --git a/uv.lock b/uv.lock index 738b6f7..1b2a2f7 100644 --- a/uv.lock +++ b/uv.lock @@ -469,7 +469,7 @@ wheels = [ [[package]] name = "jarca-ledger-mcp" -version = "0.1.7" +version = "0.1.8" source = { editable = "." } dependencies = [ { name = "mcp" },