diff --git a/README.md b/README.md
index 885484d..a7a42cc 100644
--- a/README.md
+++ b/README.md
@@ -123,6 +123,12 @@ Streamlit이 SQLite에 직접 접근하지 않고 MCP/API만 호출하도록 변
## 설계 원칙
+### 작업 규칙
+
+- 스키마 변경 작업 중에는 schema version 자동 증가를 금지합니다. 현재 코드·DB·schema의 버전은 **16으로 동결**하며, 사용자가 마지막에 명시적으로 올리라고 지시한 경우에만 증가합니다.
+- 버전 동결은 기존 DB 반영과 멱등성 검증 의무를 면제하지 않습니다. 스키마 변경 시 기존 DB 업그레이드·재실행 안전성을 계속 검증합니다.
+- 자동 bump를 전제로 한 테스트 업데이트도 금지합니다. 기존 미커밋 변경은 보존하고, 이 규칙 기록만으로 코드·DB·schema 자체를 변경하지 않습니다.
+
- 금액은 원화 정수 원 단위로 저장합니다.
- 거래 금액은 양수로 넣고, `type`이 `income`, `expense`, `transfer`, `refund`, `adjustment` 중 하나로 방향을 나타냅니다.
- 금융 데이터이므로 Jarca 본체 MCP와 분리된 DB/서버로 운영합니다.
diff --git a/dashboard/app.py b/dashboard/app.py
index 22a3fbf..ea8ecee 100644
--- a/dashboard/app.py
+++ b/dashboard/app.py
@@ -46,6 +46,8 @@ def main() -> None:
return
pages = ["월정산", "요약", "자산/마감", "거래내역", "확인필요 큐", "미분류", "분류 규칙", "카테고리", "계좌", "Import"]
query_page = st.query_params.get("page")
+ if query_page == "monthly_settlement":
+ query_page = "월정산"
page = validate_dashboard_page(query_page, pages)
st.session_state["dashboard_page"] = page
diff --git a/dashboard/authentication.py b/dashboard/authentication.py
index 006d3c9..94fa202 100644
--- a/dashboard/authentication.py
+++ b/dashboard/authentication.py
@@ -315,6 +315,15 @@ def require_dashboard_authentication() -> bool:
_render_login(clear_cookie=cookie is not None)
return False
+ # The configured server-side admin identity is the only actor source. Do
+ # not derive it from query parameters, client headers, or dashboard forms.
+ st.session_state["authenticated_actor"] = settings.admin_id
log_auth_event("token_accepted", correlation_id)
_render_logout()
return True
+
+
+def get_authenticated_actor() -> str | None:
+ """Return the actor established by successful server-side authentication."""
+ actor = st.session_state.get("authenticated_actor")
+ return actor if isinstance(actor, str) and actor.strip() else None
diff --git a/dashboard/common.py b/dashboard/common.py
index 23c66a5..f8d010b 100644
--- a/dashboard/common.py
+++ b/dashboard/common.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import logging
import sqlite3
from datetime import datetime
from html import escape
@@ -38,6 +39,7 @@ JARCA_COLORS = {
}
JARCA_PLOTLY_TEMPLATE = "jarca_clean"
+logger = logging.getLogger(__name__)
pio.templates[JARCA_PLOTLY_TEMPLATE] = pio.templates["plotly_white"].update(
{
"layout": {
@@ -362,7 +364,8 @@ def validate_dashboard_page(page: str | None, pages: list[str]) -> str:
def _sync_dashboard_page_query() -> None:
- st.query_params["page"] = st.session_state["dashboard_page"]
+ page = st.session_state["dashboard_page"]
+ st.query_params["page"] = {"월정산": "monthly_settlement"}.get(page, page)
def render_sidebar_nav(pages: list[str], current_page: str) -> str:
@@ -391,6 +394,7 @@ def render_dashboard_body(page: str, renderer) -> None:
renderer()
loading.empty()
except Exception:
+ logger.exception("Dashboard page render failed: page=%s", page)
body.empty()
with body.container():
st.error("페이지를 불러오지 못했습니다. 다시 시도해 주세요.")
diff --git a/dashboard/page_views/accounts.py b/dashboard/page_views/accounts.py
index 481181d..8a8cdcb 100644
--- a/dashboard/page_views/accounts.py
+++ b/dashboard/page_views/accounts.py
@@ -19,6 +19,7 @@ ACCOUNT_TYPE_LABELS = {
"investment": "투자",
"loan": "대출",
"other": "기타",
+ "real_estate": "부동산",
}
ACCOUNT_TYPE_STYLES = {
@@ -28,6 +29,7 @@ ACCOUNT_TYPE_STYLES = {
"investment": "#8250df",
"loan": "#bc4c00",
"other": "#57606a",
+ "real_estate": "#9a6700",
}
diff --git a/dashboard/page_views/assets.py b/dashboard/page_views/assets.py
index a143061..38d2622 100644
--- a/dashboard/page_views/assets.py
+++ b/dashboard/page_views/assets.py
@@ -44,65 +44,7 @@ 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에서 먼저 검증하세요.")
- 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.info("수기 자산/부채 입력·조회·수정은 schema 16에서 지원하지 않습니다. 계정의 last_balance를 사용하세요.")
st.divider()
render_section_label("🔒", "월말 스냅샷 Preview / Close")
diff --git a/dashboard/page_views/monthly_settlement.py b/dashboard/page_views/monthly_settlement.py
index 3d3854c..91d852e 100644
--- a/dashboard/page_views/monthly_settlement.py
+++ b/dashboard/page_views/monthly_settlement.py
@@ -2,301 +2,670 @@ from __future__ import annotations
# ruff: noqa: F405
-from datetime import datetime
+from datetime import date, 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 _query_value(name: str, default: str = "") -> str:
+ value = st.query_params.get(name, default)
+ if isinstance(value, list):
+ value = value[0] if value else default
+ return str(value or default)
+
+
+def _valid_month(value: str) -> bool:
+ try:
+ datetime.strptime(value, "%Y-%m")
+ return len(value) == 7
+ except (TypeError, ValueError):
+ return False
+
+
+def _monthly_list_url(month: str | None, year: str, status: str) -> str:
+ params = ["page=월정산"]
+ if month:
+ params.append(f"month={month}")
+ if year and year != "전체":
+ params.append(f"year={year}")
+ if status and status != "전체":
+ params.append(f"status={status}")
+ return "?" + "&".join(params)
+
+
+def _set_monthly_detail(settlement_id: str, year: str, status: str) -> None:
+ # The sidebar radio owns this widget key and may already have been
+ # instantiated in the current run. Mutating it here raises
+ # StreamlitAPIException during a real sidebar-backed navigation event.
+ if "dashboard_page" not in st.session_state:
+ st.session_state["dashboard_page"] = "월정산"
+ st.query_params["page"] = "monthly_settlement"
+ st.query_params["id"] = settlement_id
+ st.query_params.pop("month", None)
+ if year and year != "전체":
+ st.query_params["year"] = year
+ else:
+ st.query_params.pop("year", None)
+ if status and status != "전체":
+ st.query_params["status"] = status
+ else:
+ st.query_params.pop("status", None)
+
+
+def _clear_monthly_detail(year: str, status: str) -> None:
+ # Keep the sidebar-owned widget state untouched; query params drive the
+ # rerun and the existing radio value is already 월정산.
+ if "dashboard_page" not in st.session_state:
+ st.session_state["dashboard_page"] = "월정산"
+ # Clear any detail selection before rerunning; otherwise the app's
+ # query-to-session restore can resurrect the stale detail on this run.
+ st.session_state.pop("monthly_settlement_detail_id", None)
+ st.session_state.pop("monthly_settlement_detail_month", None)
+ # Do not let the previous overview selection fire again when the list
+ # widget is recreated after returning from detail.
+ st.session_state.pop("monthly_settlement_overview_table", None)
+ st.query_params["page"] = "monthly_settlement"
+ st.query_params.pop("id", None)
+ st.query_params.pop("month", None)
+ if year and year != "전체":
+ st.query_params["year"] = year
+ else:
+ st.query_params.pop("year", None)
+ if status and status != "전체":
+ st.query_params["status"] = status
+ else:
+ st.query_params.pop("status", None)
+ st.rerun()
+
+
+def _open_monthly_settlement_dialog(db_path: str) -> None:
+ @st.dialog("월정산 추가")
+ def dialog() -> None:
+ target_month = st.text_input("대상 월", placeholder="YYYY-MM", key="monthly_settlement_dialog_month")
+ if st.button("추가", type="primary", key="monthly_settlement_dialog_submit"):
+ result = settlement_gateway.create_monthly_settlement(target_month, db_path=db_path)
+ settlement_id = result.get("settlement_id")
+ if result.get("success") and settlement_id:
+ st.query_params["page"] = "monthly_settlement"
+ st.query_params["id"] = settlement_id
+ st.query_params.pop("month", None)
+ st.rerun()
+ elif result.get("error_code") == "duplicate_month" and settlement_id:
+ st.error("이미 생성된 월정산입니다. 기존 상세로 이동합니다.")
+ st.query_params["page"] = "monthly_settlement"
+ st.query_params["id"] = settlement_id
+ st.rerun()
+ else:
+ st.error(result.get("message", "월정산을 추가할 수 없습니다."))
+
+ dialog()
+
+
+def _render_monthly_list(db_path: str) -> None:
+ overview = overview_gateway.list_monthly_settlement_overview(db_path=db_path)
+ title_col, action_col = st.columns([7, 1])
+ with title_col:
+ st.header("월정산")
+ with action_col:
+ if st.button("+", type="primary", key="monthly_settlement_add", help="월정산 추가"):
+ _open_monthly_settlement_dialog(db_path)
+ st.caption("월을 선택하면 해당 월의 독립 상세 화면으로 이동합니다. 조회만으로 초안을 만들지 않습니다.")
+ years = sorted({str(r.get("month", ""))[:4] for r in overview.get("months", []) if r.get("month")}, reverse=True)
+ year = st.selectbox("연도", ["전체"] + years, index=(["전체"] + years).index(_query_value("year", "전체")) if _query_value("year", "전체") in (["전체"] + years) else 0, key="settlement_overview_year")
+ statuses = ["전체", "마감완료", "미마감", "정정진행중"]
+ status = st.selectbox("상태", statuses, index=statuses.index(_query_value("status", "전체")) if _query_value("status", "전체") in statuses else 0, key="settlement_overview_status")
+ rows = overview.get("months", []) if overview.get("success") else []
+ if year != "전체":
+ rows = [r for r in rows if str(r.get("month", "")).startswith(year)]
+ if status != "전체":
+ rows = [r for r in rows if status in str(r.get("status_label", ""))]
+ if not rows:
+ st.info("표시할 월정산이 없습니다.")
+ return
+ # Keep the seven-column table shape while using an in-app button only for
+ # the month cell. A markdown href reloads the parent document and flashes
+ # the authenticated iframe; the callback changes query params in-place.
+ st.caption("월 텍스트를 클릭하면 해당 월정산 상세 화면으로 이동합니다.")
+ st.markdown(
+ """""",
+ unsafe_allow_html=True,
+ )
+ widths = [1.25, 1.55, 1.25, 1.25, 1.5, 1.45, 1.65]
+ headers = ["월", "정산상태", "수입", "지출", "순현금흐름", "순자산", "최종마감일"]
+ with st.container(key="monthly_settlement_table"):
+ header_cells = st.columns(widths)
+ for cell, label in zip(header_cells, headers):
+ with cell:
+ st.markdown(label)
+ for row_index, row in enumerate(rows):
+ month = str(row.get("month", ""))
+ settlement_id = row.get("settlement_id")
+ cells = st.columns(widths)
+ with cells[0]:
+ if settlement_id:
+ st.button(
+ month,
+ type="tertiary",
+ key=f"monthly_settlement_month_{row_index}_{settlement_id}",
+ on_click=_set_monthly_detail,
+ args=(str(settlement_id), year, status),
+ )
+ else:
+ st.markdown(month)
+ values = [
+ row.get("status_label", ""),
+ format_krw(row.get("income")) if row.get("income") is not None else "미확정",
+ format_krw(row.get("expense")) if row.get("expense") is not None else "미확정",
+ format_krw(row.get("net_cashflow_display")) if row.get("net_cashflow_display") is not None else "미확정",
+ format_krw(row.get("net_worth_display")) if row.get("net_worth_display") is not None else "미확정",
+ row.get("closed_at") or "미마감",
+ ]
+ for cell, value in zip(cells[1:], values):
+ with cell:
+ st.markdown(str(value))
def page_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_id = _query_value("id")
+ legacy_month = _query_value("month")
+ if not settlement_id and legacy_month:
+ overview = overview_gateway.list_monthly_settlement_overview(db_path=db_path)
+ settlement_id = next((str(row.get("settlement_id")) for row in overview.get("months", []) if row.get("month") == legacy_month and row.get("settlement_id")), "")
+ if settlement_id:
+ st.query_params["page"] = "monthly_settlement"
+ st.query_params["id"] = settlement_id
+ st.query_params.pop("month", None)
+ else:
+ st.error("월정산을 찾을 수 없습니다.")
+ return
+ if settlement_id:
+ year = _query_value("year", "전체")
+ status = _query_value("status", "전체")
+ parent = settlement_gateway.get_monthly_settlement_record(settlement_id, db_path=db_path)
+ if not parent.get("success"):
+ st.error("월정산을 찾을 수 없습니다.")
+ return
+ month = str(parent["settlement"]["month"])
+ settlement_row = parent.get("settlement") or {}
+ settlement_status = settlement_row.get("status")
+ settlement_version = settlement_row.get("version")
+ # Fetch the review payload once and feed the same read-only result to
+ # both the header action and the cashflow renderer.
+ review_payload = None
+ if settlement_status in {"draft", "in_review"}:
+ review_payload = settlement_gateway.review_monthly_settlement(settlement_id, db_path=db_path)
+ with st.container(key="monthly-settlement-detail-header"):
+ st.markdown(
+ """""",
+ unsafe_allow_html=True,
+ )
+ try:
+ back_col, title_col, menu_col = st.columns([1.5, 6, 1], vertical_alignment="center")
+ except TypeError:
+ # Keep lightweight navigation fakes compatible; real Streamlit
+ # supports vertical_alignment (verified in the active env).
+ back_col, title_col, menu_col = st.columns([1.5, 6, 1])
+ with back_col:
+ st.button(
+ "← 월정산 목록",
+ key="monthly_settlement_back",
+ on_click=_clear_monthly_detail,
+ args=(year, status),
+ )
+ with title_col:
+ st.title(f"월정산 상세 · {month}")
+ with menu_col:
+ with st.popover("⋯", type="tertiary", help="월정산 메뉴"):
+ if settlement_status in {"draft", "in_review"}:
+ approve_disabled = not bool((review_payload or {}).get("success"))
+ if st.button("검토승인", key=f"monthly_approve_{settlement_id}", disabled=approve_disabled):
+ fingerprint = (review_payload or {}).get("review_fingerprint")
+ if settlement_version is None or not fingerprint:
+ st.error("검토 승인에 필요한 정보가 없습니다.")
+ result = {"success": False}
+ else:
+ result = settlement_gateway.approve_monthly_settlement(
+ settlement_id, int(settlement_version), str(fingerprint), db_path=db_path,
+ )
+ if result.get("success"):
+ st.success("승인되었습니다.")
+ st.rerun()
+ else:
+ st.error(result.get("error", "승인에 실패했습니다."))
+ elif settlement_status == "approved":
+ if st.button("승인 취소", key=f"monthly_cancel_approval_{settlement_id}"):
+ _open_monthly_approval_cancel_dialog(db_path, settlement_id, month, settlement_version)
+ if st.button("마감", key=f"monthly_close_{settlement_id}"):
+ _open_monthly_close_dialog(db_path, settlement_id, month, settlement_version)
+ try:
+ _render_monthly_detail(db_path, month, settlement_id, review_payload=review_payload)
+ except TypeError as exc:
+ # Keep lightweight navigation fakes that predate the payload arg usable.
+ if "review_payload" not in str(exc):
+ raise
+ _render_monthly_detail(db_path, month)
+ return
+ _render_monthly_list(db_path)
+
+
+def _account_evaluation_totals(rows: list[dict]) -> dict:
+ """Aggregate linked snapshots into signed assets, debt, and net assets.
+
+ Unknown categories stay out of the net calculation; NULL amounts stay unknown.
+ Debt follows the existing signed debt-positive contract (no unconditional abs()).
+ """
+ registered = [row for row in rows if row.get("snapshot_id")]
+ registered_accounts = {row.get("account_id") or row.get("source_id") for row in registered}
+ all_accounts = {row.get("account_id") or row.get("source_id") for row in rows}
+ unregistered_accounts = all_accounts - registered_accounts
+ metrics: dict[str, dict[str, int]] = {}
+ legacy_totals: dict[str, int] = {}
+ unknown_count = 0
+ unknown_by_currency: dict[str, int] = {}
+ for row in registered:
+ currency = str(row.get("currency") or "미지정")
+ amount = row.get("amount")
+ if amount is None:
+ unknown_count += 1
+ continue
+ legacy_totals[currency] = legacy_totals.get(currency, 0) + int(amount)
+ account_type = str(row.get("account_type") or "").lower()
+ category = str(row.get("snapshot_account_category") or row.get("account_liquidity_bucket") or "").lower()
+ is_debt = account_type in {"loan", "card", "liability", "debt", "credit_card"} or category in {"loan", "card", "liability", "debt"}
+ is_asset = account_type in {"asset", "bank", "cash", "checking", "savings", "investment", "securities"} or category in {"asset", "cash", "bank", "investment"}
+ bucket = metrics.setdefault(currency, {"assets": 0, "liabilities": 0, "net_assets": 0})
+ if is_debt:
+ bucket["liabilities"] += int(amount)
+ elif is_asset:
+ bucket["assets"] += int(amount)
+ else:
+ if account_type or category:
+ unknown_by_currency[currency] = unknown_by_currency.get(currency, 0) + 1
+ continue
+ bucket["net_assets"] = bucket["assets"] - bucket["liabilities"]
+ return {
+ "metrics": metrics,
+ "totals": legacy_totals,
+ "unknown_count": unknown_count + sum(unknown_by_currency.values()),
+ "unknown_by_currency": unknown_by_currency,
+ "registered_count": len(registered_accounts),
+ "unregistered_count": len(unregistered_accounts),
+ "total_count": len(all_accounts),
+ "has_amount": bool(metrics),
+ }
+
+
+def _format_evaluation_total(currency: str, amount: int) -> str:
+ return f"{amount:,}원" if currency == "KRW" else f"{amount:,} {currency}"
+
+
+_ACCOUNT_EVALUATION_BUCKETS = (
+ ("cash", "현금(cash)"),
+ ("deposit", "예금(deposit)"),
+ ("investment", "투자(investment)"),
+ ("real_estate", "부동산(real_estate)"),
+ ("loan", "대출·부채(loan)"),
+ ("other", "기타(other)"),
+)
+_ACCOUNT_EVALUATION_BUCKET_CODES = {code for code, _ in _ACCOUNT_EVALUATION_BUCKETS}
+
+
+def _snapshot_display_bucket(item: dict) -> str:
+ """Use frozen snapshot classification; unknown/missing values are other."""
+ raw = item.get("snapshot_account_category")
+ if raw is None:
+ raw = item.get("account_liquidity_bucket")
+ value = str(raw or "").strip().lower()
+ return value if value in _ACCOUNT_EVALUATION_BUCKET_CODES else "other"
+
+
+def _open_monthly_approval_cancel_dialog(db_path: str, settlement_id: str, month: str, expected_version: int | None) -> None:
+ @st.dialog("승인 취소")
+ def dialog() -> None:
+ st.markdown(f"**대상 월:** {month}")
+ st.warning("승인을 취소하면 검토중으로 돌아가며 재승인이 필요합니다.")
+ cancel_col, confirm_col = st.columns(2)
+ with cancel_col:
+ st.button("취소", key=f"monthly_approval_cancel_abort_{settlement_id}")
+ with confirm_col:
+ if st.button("승인 취소", key=f"monthly_approval_cancel_confirm_{settlement_id}", type="primary"):
+ if expected_version is None:
+ st.error("승인 취소에 필요한 버전 정보가 없습니다.")
+ return
+ result = settlement_gateway.cancel_monthly_settlement_approval(settlement_id, int(expected_version), db_path=db_path)
+ if result.get("success"):
+ st.success("승인이 취소되었습니다. 검토 후 다시 승인해야 합니다.")
+ st.rerun()
+ else:
+ st.error(result.get("error", "승인 취소에 실패했습니다."))
+ dialog()
+
+
+def _open_monthly_close_dialog(db_path: str, settlement_id: str, month: str, expected_version: int | None) -> None:
+ @st.dialog("월정산 마감")
+ def dialog() -> None:
+ st.markdown(f"**대상 월:** {month}")
+ st.warning("마감 후에는 이 월정산의 평가 등록·분류 수정·연결 해제 등을 변경할 수 없습니다.")
+ cancel_col, confirm_col = st.columns(2)
+ with cancel_col:
+ st.button("취소", key=f"monthly_close_cancel_{settlement_id}")
+ with confirm_col:
+ if st.button("마감", key=f"monthly_close_confirm_{settlement_id}", type="primary"):
+ if expected_version is None:
+ st.error("마감에 필요한 버전 정보가 없습니다.")
+ return
+ result = settlement_gateway.close_monthly_settlement(settlement_id, int(expected_version), db_path=db_path)
+ if result.get("success"):
+ st.success("마감되었습니다.")
+ st.rerun()
+ else:
+ st.error(result.get("error", "마감에 실패했습니다."))
+ dialog()
+
+
+def _render_monthly_detail(db_path: str, month: str, settlement_id: str | None = None, *, review_payload: dict | None = None) -> None:
+ settlement_id = settlement_id or _query_value("id")
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
+ st.subheader("계정별 평가 내역")
+ # The summary is a month-level report and does not carry lifecycle fields.
+ # Read the canonical parent row by the selected settlement id instead of
+ # guessing status/version from the nested report payload.
+ settlement_record = settlement_gateway.get_monthly_settlement_record(settlement_id, db_path=db_path) if settlement_id else {}
+ parent = settlement_record.get("settlement") or {}
+ settlement_status = parent.get("status")
+ settlement_version = parent.get("version")
+ snapshots = settlement_gateway.list_account_snapshots_for_settlement(settlement_id, db_path=db_path)
+ snapshot_rows = snapshots.get("snapshots", []) if snapshots.get("success") else []
+ rows = []
+ for item in snapshot_rows:
+ amount = item.get("amount")
+ status = item.get("display_status") or ("미등록" if not item.get("snapshot_id") else ("미확인" if amount is None else "확인"))
+ rows.append({
+ "snapshot_id": item.get("snapshot_id"),
+ "account_id": item.get("account_id") or item.get("source_id"),
+ "account_name": item.get("account_name") or item.get("name") or "",
+ "currency": item.get("currency") or "",
+ "amount": amount,
+ "basis_date": item.get("basis_date") or "",
+ "previous_ledger": item.get("previous_ledger"),
+ "next_ledger": item.get("next_ledger"),
+ "unlinked_candidates": item.get("unlinked_candidates") or [],
+ "account_type": item.get("account_type"),
+ "account_liquidity_bucket": item.get("account_liquidity_bucket"),
+ "snapshot_account_category": item.get("snapshot_account_category"),
+ "display_bucket": _snapshot_display_bucket(item),
+ "projection": item.get("projection"),
+ "계정명": item.get("account_name") or item.get("name") or "",
+ "평가금액": "—" if status == "미등록" else ("미확인" if amount is None else f"{amount:,}"),
+ "통화": item.get("currency") or "",
+ "기준일": item.get("basis_date") or "",
+ "확인상태": status,
+ "등록가능": not snapshots.get("closed", False) and status == "미등록",
+ "연결해제가능": snapshots.get("status") in {"draft", "in_review"} and status != "미등록" and bool(item.get("snapshot_id")),
+ })
+ registered = [r for r in rows if r.get("snapshot_id")]
+ unregistered = [r for r in rows if not r.get("snapshot_id")]
+ totals = _account_evaluation_totals(rows)
+ if snapshots.get("success"):
+ st.caption(f"평가등록 {totals['registered_count']} / 전체 {totals['total_count']} · 금액 미확인 {totals['unknown_count']} · 미등록 {totals['unregistered_count']}")
+ st.caption("미검증 평가금액이 포함될 수 있습니다.")
+ for currency, values in sorted(totals["metrics"].items()):
+ suffix = "원" if currency == "KRW" else currency
+ metric_columns = st.columns(3)
+ with metric_columns[0]:
+ st.metric(f"자산 합계 ({currency})", f"{values['assets']:,}{suffix}")
+ with metric_columns[1]:
+ st.metric(f"부채 합계 ({currency})", f"{values['liabilities']:,}{suffix}")
+ with metric_columns[2]:
+ st.metric(f"순자산 ({currency})", f"{values['net_assets']:,}{suffix}")
- 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)
+ st.markdown("### 평가 등록 계좌")
+ if not registered:
+ st.caption("등록된 평가 내역이 없습니다.")
else:
- st.info("해당 월 현금흐름 정보가 아직 없습니다.")
+ # Keep the group header and every row on the same four-column grid.
+ widths = [2.6, 1.5, 0.45, 0.45]
+ st.markdown("""""", unsafe_allow_html=True)
+ for bucket, label in _ACCOUNT_EVALUATION_BUCKETS:
+ group_rows = [item for item in registered if item["display_bucket"] == bucket]
+ if not group_rows:
+ continue
+ group_totals = _account_evaluation_totals(group_rows)["totals"]
+ subtotal = " · ".join(_format_evaluation_total(currency, amount) for currency, amount in sorted(group_totals.items())) or "—"
+ with st.container(key=f"monthly-account-evaluation-group-{bucket}"):
+ header_cells = st.columns(widths)
+ with header_cells[0]: st.markdown(label)
+ with header_cells[1]: st.markdown(f"**{subtotal}**")
+ for cell in header_cells[2:]:
+ with cell: st.markdown("")
+ for item in group_rows:
+ cells = st.columns(widths)
+ with cells[0]: st.markdown(f'
{item["계정명"]}
', unsafe_allow_html=True)
+ amount = item["amount"]
+ amount_display = "미확인" if amount is None else f'{amount:,}{"원" if item["currency"] == "KRW" else " " + str(item["currency"] or "")}'
+ with cells[1]: st.markdown(amount_display)
+ with cells[2]:
+ if item["연결해제가능"] and st.button("×", key=f"monthly_unlink_{settlement_id}_{item['snapshot_id']}", help="연결 해제", type="tertiary", use_container_width=True):
+ _render_snapshot_unlink_dialog(db_path, settlement_id, item)
+ with cells[3]:
+ if item["연결해제가능"] and st.button("✎", key=f"monthly_category_{settlement_id}_{item['snapshot_id']}", help="분류 수정", type="tertiary", use_container_width=True):
+ _render_snapshot_category_dialog(db_path, settlement_id, item)
- 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,
- )
+ st.markdown("### 평가 미등록 계좌")
+ if not unregistered:
+ st.caption("모든 계좌의 평가가 등록되었습니다.")
else:
- st.info(f"{selected_month} 예산 항목이 없습니다.")
+ for item in unregistered:
+ if item["등록가능"]:
+ if st.button(item["계정명"], type="tertiary", key=f"monthly_evaluation_account_{settlement_id}_{item['account_id']}"):
+ _render_account_registration_dialog(db_path, settlement_id, settlement, item)
+ else:
+ st.markdown(item["계정명"])
- 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_monthly_cashflow(db_path, month, settlement_id, bool(snapshots.get("closed", False)), settlement_status, settlement_version, review_payload=review_payload)
- 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") + "건")
+def _render_monthly_cashflow(db_path: str, month: str, settlement_id: str, closed: bool, settlement_status: str | None = None, settlement_version: int | None = None, *, review_payload: dict | None = None) -> None:
+ report = settlement_gateway.get_monthly_cashflow(month, settlement_id=settlement_id, closed=closed, db_path=db_path)
+ if not report.get("success"):
+ st.warning("현금흐름을 불러오지 못했습니다.")
+ return
+ st.subheader("수입·지출")
+ st.caption("정산 스냅샷 기준" if closed else "현재 원장 기준")
+ if not report.get("saved", True):
+ st.info(report.get("message", "저장된 현금흐름 스냅샷이 없습니다."))
+ return
- 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 배치가 없습니다.")
+ cols = st.columns(3)
+ for col, label, key in zip(cols, ("수입", "지출", "수지"), ("income", "expenses", "net_cashflow")):
+ with col:
+ value = report.get(key)
+ st.metric(label, "통화별" if value is None and report.get("by_currency") else ("미확정" if value is None else f"{int(value):,}"))
+ if report.get("by_currency"):
+ for currency, totals in report["by_currency"].items():
+ st.caption(f"{currency}: 수입 {totals['income']:,} · 지출 {totals['expenses']:,} · 순 {totals['net_cashflow']:,}")
+ unknown_rows = report.get("unknown_category_items") or [r for r in (report.get("categories") or []) if r.get("cashflow_kind") == "unknown"]
+ if unknown_rows:
+ unknown_count = sum(int(r.get("count") or 0) for r in unknown_rows)
+ st.warning(f"분류가 필요한 거래가 {unknown_count}건 있습니다. 미분류 금액은 수입·지출 합계에 포함되지 않습니다.")
+ st.markdown("### 미분류 내역")
+ st.dataframe([{"카테고리": r.get("category") or "미지정", "하위카테고리": r.get("subcategory") or "미지정", "금액": r.get("amount"), "거래수": int(r.get("count") or 0), "통화": r.get("currency") or "미지정"} for r in unknown_rows], hide_index=True, use_container_width=True)
+ st.markdown("### 카테고리별 내역")
+ categories = report.get("categories") or []
+ for kind, title in (("income", "수입"), ("expense", "지출")):
+ rows = [r for r in categories if r.get("cashflow_kind") == kind]
+ st.markdown(f"**{title}**")
+ if not rows:
+ st.caption("내역이 없습니다.")
+ continue
+ st.dataframe([{"카테고리": r.get("category") or "미분류", "하위카테고리": r.get("subcategory") or "미분류", "금액": int(r.get("amount") or 0), "거래수": int(r.get("count") or 0), "통화": r.get("currency") or "미지정"} for r in rows], hide_index=True, use_container_width=True)
- 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)}"
+def _render_snapshot_unlink_dialog(db_path: str, settlement_id: str, item: dict) -> None:
+ @st.dialog("평가 연결 해제")
+ def dialog() -> None:
+ account_name = str(item.get("account_name") or item.get("name") or "")
+ amount = item.get("amount")
+ amount_display = "미확인" if amount is None else f"{amount:,}"
+ st.markdown(f"**계좌명:** {account_name}")
+ st.markdown(f"**평가금액:** {amount_display}")
+ st.info("스냅샷은 보존되고 이 월정산과의 연결만 해제됩니다.")
+ confirm, cancel = st.columns(2)
+ if cancel.button("닫기", key=f"monthly_unlink_cancel_{settlement_id}_{item['snapshot_id']}"):
+ st.rerun()
+ if confirm.button("연결해제", key=f"monthly_unlink_confirm_{settlement_id}_{item['snapshot_id']}"):
+ result = settlement_gateway.unlink_account_snapshot(
+ snapshot_id=str(item["snapshot_id"]), monthly_settlement_id=str(settlement_id), db_path=db_path,
)
- st.markdown(
- item_line,
+ if result.get("success"):
+ st.rerun()
+ else:
+ st.error(result.get("error", "연결 해제에 실패했습니다."))
+ dialog()
+
+
+def _render_snapshot_category_dialog(db_path: str, settlement_id: str, item: dict) -> None:
+ @st.dialog("평가 분류 수정")
+ def dialog() -> None:
+ buckets = ["cash", "deposit", "investment", "real_estate", "loan", "other"]
+ current = item.get("snapshot_account_category") or item.get("account_liquidity_bucket") or "other"
+ value = st.selectbox("분류", buckets, index=buckets.index(current) if current in buckets else 5, key=f"monthly_category_value_{settlement_id}_{item['snapshot_id']}")
+ if st.button("저장", key=f"monthly_category_save_{settlement_id}_{item['snapshot_id']}"):
+ result = settlement_gateway.update_account_snapshot_category(str(item["snapshot_id"]), str(settlement_id), value, db_path=db_path)
+ if result.get("success"):
+ st.rerun()
+ else:
+ st.error(result.get("error", "분류 수정에 실패했습니다."))
+ dialog()
+
+
+def _render_account_registration_dialog(db_path: str, settlement_id: str, settlement: dict, item: dict) -> None:
+ @st.dialog("미등록 계정 평가 등록")
+ def dialog() -> None:
+ account_id = str(item.get("account_id") or item.get("source_id"))
+ account_name = str(item.get("account_name") or item.get("name") or account_id)
+ prefix = f"monthly_evaluation_{settlement_id}_{account_id}"
+ st.text_input("계정", value=account_name, disabled=True, key=f"{prefix}_account")
+ candidates = item.get("unlinked_candidates") or []
+ modes = ["기존 스냅샷 연결", "새 평가 등록"] if candidates else ["새 평가 등록"]
+ mode = st.radio("등록 방식", modes, index=0, horizontal=True, key=f"{prefix}_mode")
+ if mode == "기존 스냅샷 연결":
+ labels = []
+ by_label = {}
+ for candidate in candidates:
+ amount = "미확인" if candidate.get("amount") is None else f"{candidate['amount']:,}"
+ label = f"기준일 {candidate.get('basis_date','')} · {amount} {candidate.get('currency','')} · {candidate.get('verification_status') or '미확인'}"
+ labels.append(label); by_label[label] = candidate
+ selected_label = st.selectbox("기존 스냅샷", labels, key=f"{prefix}_candidate")
+ selected = by_label[selected_label]
+ st.text_input("선택 금액", value="미확인" if selected.get("amount") is None else f"{selected['amount']:,}", disabled=True, key=f"{prefix}_candidate_amount")
+ mismatch = str(selected.get("basis_date")) != str(settlement.get("basis_date"))
+ allow_mismatch = False
+ if mismatch:
+ st.warning("기준일이 다른 스냅샷을 연결합니다")
+ allow_mismatch = st.checkbox("기준일 불일치를 확인하고 연결", key=f"{prefix}_mismatch")
+ connect, cancel = st.columns(2)
+ if cancel.button("취소", key=f"{prefix}_candidate_cancel"):
+ st.rerun()
+ if connect.button("연결", key=f"{prefix}_connect"):
+ if mismatch and not allow_mismatch:
+ st.error("기준일 불일치 동의가 필요합니다.")
+ return
+ result = settlement_gateway.link_account_snapshot(str(selected["snapshot_id"]), str(settlement_id), allow_basis_date_mismatch=allow_mismatch, db_path=db_path)
+ if result.get("success"):
+ st.rerun()
+ else:
+ st.error(result.get("error", "스냅샷 연결에 실패했습니다."))
+ return
+ settlement_basis_date = settlement.get("basis_date") or settlement.get("selected_basis_date")
+ if not settlement_basis_date:
+ settlement_basis_date = f"{settlement.get('month') or settlement.get('selected_month')}-01"
+ basis_date = st.date_input("기준일", value=date.fromisoformat(str(settlement_basis_date)[:10]), key=f"{prefix}_basis_date")
+ currency = st.text_input("통화", value=str(item.get("currency") or ""), disabled=True, key=f"{prefix}_currency")
+ previous = item.get("previous_ledger")
+ following = item.get("next_ledger")
+ choices = []
+ choice_map = {}
+ if previous:
+ label = f"이전 기록 · {previous['transaction_date']} · 잔액 {previous['balance_after']} {previous['currency']}"
+ choices.append(label); choice_map[label] = ("previous", previous)
+ if following:
+ label = f"이후 기록 · {following['transaction_date']} · 잔액 {following['balance_after']} {following['currency']}"
+ choices.append(label); choice_map[label] = ("next", following)
+ choices.append("수동 기입")
+ selected = st.selectbox("평가 출처", choices, index=0 if previous else len(choices)-1, key=f"{prefix}_source")
+ policy, ledger = choice_map.get(selected, ("manual", None))
+ if ledger:
+ amount_text = str(ledger["balance_after"])
+ st.text_input("평가금액 (정수)", value=amount_text, disabled=True, key=f"{prefix}_amount_ledger")
+ else:
+ amount_text = st.text_input("평가금액 (정수)", key=f"{prefix}_amount_manual")
+ if policy == "next":
+ st.caption("기준일 이후 기록입니다.")
+ buckets = ["cash", "deposit", "investment", "real_estate", "loan", "other"]
+ account_bucket = item.get("account_liquidity_bucket") or "other"
+ liquidity_bucket = st.selectbox("분류", buckets, index=buckets.index(account_bucket) if account_bucket in buckets else 5, key=f"{prefix}_liquidity_bucket")
+ memo = st.text_area("메모 (선택)", key=f"{prefix}_memo")
+ st.caption("초기 등록은 draft이며 검증 완료가 아닙니다. 금액이 있어도 확인 상태로 바뀌지 않습니다.")
+ save, cancel = st.columns(2)
+ if cancel.button("취소", key=f"{prefix}_cancel"):
+ st.rerun()
+ if save.button("저장", key=f"{prefix}_save"):
+ if not amount_text.strip():
+ st.error("평가금액을 입력하세요.")
+ return
+ try:
+ amount = int(amount_text.strip())
+ except ValueError:
+ st.error("평가금액은 정수여야 합니다.")
+ return
+ result = settlement_gateway.register_account_valuation(
+ account_id=account_id, monthly_settlement_id=settlement_id,
+ basis_date=basis_date.isoformat(), amount=amount, currency=currency,
+ memo=memo, selection_policy=policy, valuation_source="ledger" if ledger else "manual",
+ source_note=ledger["id"] if ledger else "", db_path=db_path,
+ liquidity_bucket=liquidity_bucket,
)
- 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=요약)")
+ if result.get("success"):
+ st.rerun()
+ else:
+ st.error(result.get("error", "평가 등록에 실패했습니다."))
+ dialog()
diff --git a/dashboard/page_views/overview.py b/dashboard/page_views/overview.py
index 223368e..4e8845f 100644
--- a/dashboard/page_views/overview.py
+++ b/dashboard/page_views/overview.py
@@ -35,7 +35,7 @@ def page_overview(db_path: str) -> None:
c4.metric("저축률", format_percent(cashflow.get("savings_rate")))
render_section_label("🏦", "자산 유동성 현황")
- st.caption("기준: service 공통 평가 헬퍼(accounts.last_balance + manual_assets 보강). 거래 합계로 비거래 자산을 추정하지 않습니다.")
+ st.caption("기준: service 공통 평가 헬퍼(accounts.last_balance). 거래 합계로 비거래 자산을 추정하지 않습니다.")
a1, a2, a3 = st.columns(3)
a1.metric("자산 합계(부채 제외)", format_krw(asset_report.get("total_assets", 0)))
a2.metric("총부채", format_krw(asset_report.get("total_liabilities", 0)))
@@ -52,7 +52,6 @@ def page_overview(db_path: str) -> None:
render_section_label("🧭", "자산 구성")
source_accounts = pd.DataFrame(asset_report.get("accounts", []))
- manual_assets = pd.DataFrame(asset_report.get("manual_assets", []))
if not source_accounts.empty:
source_accounts["bucket_label"] = source_accounts["liquidity_bucket"].map(LIQUIDITY_BUCKET_LABELS).fillna(source_accounts["liquidity_bucket"])
source_accounts["last_balance_display"] = source_accounts["last_balance"].map(format_krw)
@@ -60,11 +59,6 @@ def page_overview(db_path: str) -> None:
st.dataframe(source_accounts[show_cols], width="stretch", height=220)
else:
st.info("last_balance가 입력된 활성 계좌/자산 항목이 없습니다.")
- if not manual_assets.empty:
- manual_assets["bucket_label"] = manual_assets["liquidity_bucket"].map(LIQUIDITY_BUCKET_LABELS).fillna(manual_assets["liquidity_bucket"])
- manual_assets["valuation_display"] = manual_assets["amount"].map(format_krw)
- st.caption("수기 자산/부채(manual_assets) — 비거래 자산 권위 소스")
- st.dataframe(manual_assets[["name", "bucket_label", "valuation_display", "valuation_date", "source_note", "memo"]], width="stretch", height=180)
render_section_label("📉", "지출 구조/예산")
budgets = pd.DataFrame(budget_report.get("items", []))
diff --git a/dashboard/page_views/settlement_drafts.py b/dashboard/page_views/settlement_drafts.py
index 5285960..d3035e8 100644
--- a/dashboard/page_views/settlement_drafts.py
+++ b/dashboard/page_views/settlement_drafts.py
@@ -26,20 +26,70 @@ def _invalidate_and_rerun(draft_id: str, result: dict) -> None:
st.rerun()
+def _authenticated_actor() -> str | None:
+ from dashboard.authentication import get_authenticated_actor
+ return get_authenticated_actor()
+
+
+def _create_settlement_dialog(db_path: str | None, month: str, correction_run: str = "") -> None:
+ @st.dialog("정산 진행")
+ def dialog() -> None:
+ target_month = st.text_input("대상 월", value=month, key="settlement_dialog_month")
+ memo = st.text_area("메모 (선택)", key="settlement_dialog_memo")
+ reason = st.text_area("정정 사유 (필수)", key="settlement_dialog_reason") if correction_run else ""
+ if correction_run:
+ st.caption(f"정정 연결 마감 run: {correction_run}")
+ left, right = st.columns(2)
+ if left.button("취소", key="settlement_dialog_cancel"):
+ st.session_state.pop("settlement_dialog_open", None)
+ st.rerun()
+ if right.button("초안 만들기", type="primary", key="settlement_dialog_submit"):
+ actor = _authenticated_actor()
+ if not actor:
+ st.error("인증된 작업자 identity를 확인할 수 없습니다. 다시 로그인하세요.")
+ return
+ if correction_run and not reason.strip():
+ st.error("정정 사유를 입력하세요.")
+ return
+ # Avoid creating a database when the read/list path is unavailable.
+ listing_probe = gateway.list_settlement_drafts(target_month, db_path=db_path)
+ if not listing_probe.get("success"):
+ _safe_result(listing_probe)
+ return
+ # A cancelled draft must not reserve the next create operation's
+ # idempotency key. Scope the key to this explicit dialog submit.
+ import hashlib
+ import uuid
+ operation = "correction" if correction_run else "initial"
+ nonce = uuid.uuid4().hex
+ key = hashlib.sha256(f"dashboard:{operation}:{target_month}:{correction_run}:{nonce}".encode()).hexdigest()
+ 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"] = "월정산"
+ st.query_params["month"] = target_month
+ st.query_params.pop("year", None)
+ st.query_params.pop("status", None)
+ st.rerun()
+ _safe_result(result)
+ dialog()
+
+
def render_settlement_drafts(db_path: str | None = None, month: str = "") -> None:
- st.header("월정산 초안 lifecycle")
- st.caption("자산 마감과 거래 정산 상태는 별도입니다. 조회/렌더링은 쓰기를 수행하지 않습니다.")
- month_value = month or st.text_input("초안 월", value="2026-09", key="settlement_month")
- actor = st.text_input("작업자", value="operator", key="settlement_actor")
- with st.form("settlement_create"):
- memo = st.text_input("메모")
- idem = st.text_input("생성 키", value="dashboard-create")
- correction_run = st.text_input("정정 대상 run ID (선택)")
- correction_reason = st.text_input("정정 사유")
- create = st.form_submit_button("초안 생성 / 정정 초안")
- if create:
- _safe_result(gateway.create_settlement_draft(month_value, actor, idem, memo=memo,
- correction_of_run_id=correction_run, correction_reason=correction_reason, db_path=db_path))
+ st.header("정산 진행")
+ st.caption("월별 정산을 시작하고, 활성 초안을 이어서 작업합니다.")
+ month_value = month or "2026-09"
+ start = st.button("정산 시작", type="primary", key="settlement_start")
+ if start:
+ st.session_state["settlement_dialog_open"] = True
+ if start or st.session_state.get("settlement_dialog_open"):
+ _create_settlement_dialog(db_path, month_value)
+ actor = _authenticated_actor()
+ if not actor:
+ st.warning("인증된 identity가 없으므로 조회만 가능합니다. 정산 작업을 시작하려면 다시 로그인하세요.")
listed = gateway.list_settlement_drafts(month_value, db_path=db_path)
drafts = listed.get("drafts", []) if listed.get("success") else []
@@ -47,19 +97,20 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
st.error(f"초안 목록을 불러오지 못했습니다 ({listed.get('error_code', 'operation_failed')}).")
if not isinstance(drafts, list): drafts = []
ids = [str(d.get("id", "")) for d in drafts if isinstance(d, dict) and d.get("id")]
+ if ids:
+ preferred = st.session_state.get("settlement_selected_draft")
+ if preferred in ids:
+ st.session_state["settlement_draft_id"] = preferred
+ elif st.session_state.get("settlement_draft_id") not in ids:
+ st.session_state["settlement_draft_id"] = ids[0]
selected_id = st.selectbox("초안 선택", ids, key="settlement_draft_id") if ids else ""
if not selected_id:
if listed.get("success"):
st.info("초안이 없습니다. 생성은 명시적 버튼으로 수행됩니다.")
- st.caption("완료 초안은 이력에서 불변으로 조회하고, 정정은 이전 마감 ID와 사유를 지정해 새 초안으로 만듭니다.")
- st.caption("목록은 활성·완료·취소 이력을 포함합니다. 평가자료 조회·등록·정정은 초안 변경과 별도 작업입니다.")
- st.button("검토", disabled=True, key="settlement_review_disabled")
- st.button("승인", disabled=True, key="settlement_approve_disabled")
- st.button("최종 마감", disabled=True, key="settlement_finalize_disabled")
- st.button("초안 취소", disabled=True, key="settlement_cancel_disabled")
- st.button("평가자료 조회 / 등록 / 정정", disabled=True, key="settlement_valuation_disabled")
+ st.caption("목록은 활성·완료·취소 초안을 포함합니다. 평가자료 조회·등록·정정은 초안 변경과 별도 작업입니다.")
+ st.button("정산시작", disabled=not actor, key="settlement_start_empty")
st.caption("제외 사유는 항목별로 명시 입력합니다.")
- st.caption("거래 정산 상태(transaction readiness)는 자산 마감 준비와 별도입니다.")
+ st.caption("거래 정산 상태(transaction readiness)는 자산 마감 준비와 별도입니다. 기존 lifecycle 작업: 검토·승인·최종·취소·평가자료.")
return
detail = gateway.get_settlement_draft(selected_id, db_path=db_path)
feedback = st.session_state.pop(f"settlement_mutation_feedback_{selected_id}", None)
@@ -70,7 +121,6 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
draft = detail.get("draft", {}) if detail.get("success") else {}
if not isinstance(draft, dict): draft = {}
version = draft.get("version")
- draft_version = version
fingerprint = detail.get("review", {}).get("review_fingerprint", "") if isinstance(detail.get("review"), dict) else ""
state_key = "settlement_bound_state"
bound = (selected_id, version, fingerprint)
@@ -79,11 +129,15 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
st.session_state[state_key] = bound
st.session_state.pop(f"review_result_{selected_id}", None)
st.session_state.pop(f"approved_{selected_id}", None)
- st.caption(f"ID {draft.get('id', selected_id)} · 월 {draft.get('month', month_value)} · 기준일 {draft.get('basis_date', '-')} · 상태 {draft.get('status', '-')} · 버전 {version} · draft_version={draft_version}")
- st.caption(f"review_fingerprint={fingerprint or '미검토'}")
- st.caption(f"생성자 {draft.get('created_by', '-')} · 생성 {draft.get('created_at', '-')} · 변경 {draft.get('updated_at', '-')} · 메모 {draft.get('memo', '')}")
+ st.caption(f"월 {draft.get('month', month_value)} · 기준일 {draft.get('basis_date', '-')} · 상태 {draft.get('status', '-')} · 버전 {version}")
+ st.caption(f"draft_version={draft.get('draft_version', version)} · created_by={draft.get('created_by', '')} · created_at={draft.get('created_at', '')} · updated_at={draft.get('updated_at', '')}")
+ st.caption(f"final_run_id={draft.get('final_run_id', '')} · correction_of_run_id={draft.get('correction_of_run_id', '')} · correction_reason={draft.get('correction_reason', '')}")
+ st.caption(f"검토 상태: {'검토 완료' if fingerprint else '미검토'} · review_fingerprint={fingerprint} · 메모 {draft.get('memo', '')}")
+ if draft.get("status") == "active":
+ action_label = "정정이어서작업" if draft.get("correction_of_run_id") else "이어서작업"
+ st.button(action_label, disabled=not actor, key=f"continue_{selected_id}")
if draft.get("status") == "completed":
- st.success(f"완료 — run {draft.get('final_run_id', '-')}; 변경 불가, 정정은 새 초안으로 생성합니다.")
+ st.success("완료 — 변경 불가, 정정은 새 초안으로 생성합니다.")
if draft.get("status") in {"completed", "cancelled"}:
final = detail.get("final_response") if isinstance(detail.get("final_response"), dict) else {}
if final:
@@ -93,10 +147,8 @@ 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", []))
+ 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("종료된 초안의 증거는 수정하지 않습니다. 정정이 필요하면 이전 마감과 사유를 연결해 새 초안을 생성하세요.")
return
items = detail.get("items", []) if isinstance(detail.get("items"), list) else []
@@ -116,7 +168,7 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
policy_now = draft.get("cashflow_policy") if isinstance(draft.get("cashflow_policy"), dict) else {"savings_categories": [], "category_decisions": []}
with st.form(f"cashflow_policy_{selected_id}"):
policy_text = st.text_area("정책 JSON (savings_categories 및 category_decisions)", value=json.dumps(policy_now, ensure_ascii=False, indent=2), height=220)
- policy_submit = st.form_submit_button("정책 저장")
+ policy_submit = st.form_submit_button("정책 저장", disabled=not actor)
if policy_submit:
try:
policy = json.loads(policy_text)
@@ -138,7 +190,7 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
selection_reason = st.text_input("선택 사유")
included = st.checkbox("포함", value=True)
exclusion_reason = st.text_input("제외 사유")
- save = st.form_submit_button("저장 / 지정 / 포함·제외")
+ save = st.form_submit_button("저장 / 지정 / 포함·제외", disabled=not actor)
if save:
result = gateway.update_settlement_draft(selected_id, version, actor, memo_edit, db_path=db_path)
current_version = (result.get("draft") or {}).get("version", version) if result.get("success") else version
@@ -164,7 +216,7 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
verification = st.selectbox("확인 상태", ["unverified", "verified", "rejected"])
valuation_memo = st.text_input("평가 메모")
registration_key = st.text_input("등록 idempotency key")
- register = st.form_submit_button("평가자료 등록")
+ register = st.form_submit_button("평가자료 등록", disabled=not actor)
if register:
result = gateway.register_account_valuation(account_id, valuation_date, int(amount), currency=currency,
valuation_kind=valuation_kind, source_ref=source_ref, verification_status=verification,
@@ -173,7 +225,7 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
if result.get("success") and any(item.get("source_id") == account_id for item in items if isinstance(item, dict)):
_invalidate_and_rerun(selected_id, result)
_safe_result(result)
- if st.button("평가자료 조회", key=f"valuation_list_{selected_id}") and source_id:
+ if st.button("평가자료 조회", disabled=not actor, key=f"valuation_list_{selected_id}") and source_id:
vals = gateway.list_account_valuations(source_id, db_path=db_path)
if vals.get("success"):
st.json([{k: value.get(k) for k in ("id", "account_id", "basis_date", "recorded_at", "amount", "currency", "valuation_kind", "verification_status", "source_ref", "supersedes_id", "representative_liability_definition")} for value in vals.get("valuations", [])])
@@ -185,7 +237,7 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
correction_key = st.text_input("평가 정정 키")
correction_verification = st.selectbox("정정 확인 상태", ["unverified", "verified", "rejected"])
correction_source_ref = st.text_input("정정 증빙 참조 ID")
- correct = st.form_submit_button("평가자료 정정")
+ correct = st.form_submit_button("평가자료 정정", disabled=not actor)
if correct:
result = gateway.correct_account_valuation(valuation_id_correct, int(corrected_amount), correction_reason,
idempotency_key=correction_key, verification_status=correction_verification,
@@ -195,10 +247,10 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
_invalidate_and_rerun(selected_id, result)
_safe_result(result)
review_result = None
- if st.button("검토 / 새로고침 대상", key=f"review_{selected_id}"):
+ if st.button("검토 / 새로고침 대상", disabled=not actor, key=f"review_{selected_id}"):
review_result = gateway.review_settlement_draft(selected_id, db_path=db_path)
st.session_state[f"review_result_{selected_id}"] = review_result
- if st.button("대상 명시적 갱신", key=f"refresh_{selected_id}"):
+ if st.button("대상 명시적 갱신", disabled=not actor, key=f"refresh_{selected_id}"):
result = gateway.refresh_settlement_draft_targets(selected_id, version, actor, db_path=db_path)
if result.get("success"):
_invalidate_and_rerun(selected_id, result)
@@ -229,7 +281,7 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
allow_missing = st.checkbox("누락 승인", value=False)
allow_carried = st.checkbox("이월 승인", value=False)
allow_substitute = st.checkbox("대체자료 승인", value=False)
- approve = st.form_submit_button("검토 결과 승인", disabled=bool(review.get("cashflow_blockers")))
+ approve = st.form_submit_button("검토 결과 승인", disabled=(not actor or bool(review.get("cashflow_blockers"))))
if review.get("cashflow_blockers"): st.caption("현금흐름 블로커를 해소해야 승인할 수 있습니다.")
if approve:
if st.session_state.get(state_key) != (selected_id, review.get("version", version), fingerprint):
@@ -237,7 +289,7 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
return
_safe_result(gateway.approve_settlement_draft(selected_id, version, fingerprint, actor,
approval_reason, approval_reference, allow_missing, allow_carried, allow_substitute, db_path=db_path))
- if st.button("최종 마감", key=f"finalize_{selected_id}"):
+ if st.button("최종 마감", disabled=not actor, key=f"finalize_{selected_id}"):
if st.session_state.get(state_key) != (selected_id, review.get("version", version), fingerprint):
st.error("선택한 초안/버전/검토 fingerprint가 변경되었습니다. 다시 검토하세요.")
return
@@ -248,7 +300,7 @@ def render_settlement_drafts(db_path: str | None = None, month: str = "") -> Non
_safe_result(result)
with st.form(f"cancel_{selected_id}"):
cancel_reason = st.text_input("취소 사유")
- cancel = st.form_submit_button("초안 취소")
+ cancel = st.form_submit_button("초안 취소", disabled=not actor)
if cancel:
result = gateway.cancel_settlement_draft(selected_id, version, actor, cancel_reason, db_path=db_path)
if result.get("success"):
diff --git a/docs/data-model.md b/docs/data-model.md
index 0d5d6a8..01c9e78 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`, `asset_snapshots`, `asset_snapshot_runs`, `reconciliation_sessions`, `review_decisions`, `import_batches`, `schema_meta`
+- 포함 범위: `accounts`, `transactions`, `manual_assets`, `account_valuations`, `account_snapshot`, `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 | enum | 아니오 |
+| `type` | TEXT | 필수 | `bank` | bank/card/cash/investment/loan/other/real_estate | 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_valuations.py`, `tests/test_asset_snapshots.py`
+- 정상 데이터: `tests/test_account_valuation_service_actual.py`, `tests/test_account_snapshot_linking.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_valuations.py tests/test_asset_snapshots.py`
+- 마이그레이션: `test_*schema*` tests plus `python -m pytest tests/test_account_valuation_service_actual.py tests/test_account_snapshot_linking.py`
## settlement-drafts — 월정산 초안
@@ -193,6 +193,12 @@ 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
### asset_snapshot_runs cashflow field contract
@@ -226,7 +232,7 @@ All twelve comprehensive-run columns are nullable with no SQL default. This pres
| Column | Type | Nullability | Default |
|---|---|---|---|
| `id` | TEXT | NULL | none |
-| `settlement_run_id` | TEXT | NOT NULL | none |
+| `monthly_settlement_id` | TEXT | NOT NULL | none |
| `cashflow_kind` | TEXT | NOT NULL | none |
| `category` | TEXT | NOT NULL | `''` |
| `subcategory` | TEXT | NOT NULL | `''` |
@@ -234,7 +240,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. `settlement_run_id` references `asset_snapshot_runs(id)` ON DELETE RESTRICT. The unique key is `(settlement_run_id, cashflow_kind, category, subcategory, currency)`. `transaction_count > 0` and amount/count are integers; `idx_settlement_cashflow_snapshots_run` indexes the run.
+`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.
### Comprehensive snapshot evidence inputs
@@ -244,6 +250,10 @@ 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`, `settlement_run_id`, `cashflow_kind`, `category`, `subcategory`, `amount`, `transaction_count`, `currency`; `cashflow_kind` is income/expense/excluded, `amount` signed INTEGER, count positive, category fields NOT NULL (empty string is explicit), FK to `asset_snapshot_runs.id` with RESTRICT, unique `(settlement_run_id,cashflow_kind,category,subcategory,currency)`, and run lookup index. Rows are grouped details for exactly one run.
+`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.
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`.
diff --git a/docs/interface-contract.md b/docs/interface-contract.md
index f2dbe22..b7f36c3 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). `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). 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.
### shared contract and adapter limits
@@ -392,7 +392,7 @@ preview_asset_snapshot_close(basis_date="2026-06-30", selection_policy="exact")
## 검증 추가
-- 정상 계약: `tests/test_account_valuations.py`, `tests/test_asset_snapshots.py`, `tests/test_contract_regression.py`
+- 정상 계약: `tests/test_account_valuation_service_actual.py`, `tests/test_account_snapshot_linking.py`, `tests/test_comprehensive_settlement_adapters.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
diff --git a/docs/project-structure.md b/docs/project-structure.md
index 17316aa..0228c64 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_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 |
+| `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 |
| `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 ce8835d..7880d3a 100644
--- a/docs/requirements/req-003_account-valuation-history.md
+++ b/docs/requirements/req-003_account-valuation-history.md
@@ -15,6 +15,8 @@
# 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 이력으로 저장·선택하고, 검증 가능한 자산만 자산/마감 및 월정산의 역사적 순자산에 반영한다.
@@ -27,7 +29,7 @@
### 포함
-- `account_valuations`, `asset_snapshot_runs`, `asset_snapshots` 확장과 exact/as_of 선택
+- `account_valuations`, `asset_snapshot_runs`, `account_snapshot` 확장과 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
@@ -52,7 +54,7 @@
### REQ-003.01 — additive 이력과 legacy 보존
-schema 11의 테이블·필드·거래를 삭제하거나 추측 이주하지 않고 schema 13의 `account_valuations`, `asset_snapshot_runs`를 추가하며 `asset_snapshots`에 필요한 필드를 additive하게 확장한다. migration은 idempotent다. Issue #18의 유일한 schema 확장은 additive/idempotent 13→14이며 기존 자산 전용 run의 미저장 종합 총계는 NULL로 유지하고 backfill하지 않는다.
+schema 11의 테이블·필드·거래를 삭제하거나 추측 이주하지 않고 schema 13의 `account_valuations`, `asset_snapshot_runs`를 추가하며 `account_snapshot`에 필요한 필드를 additive하게 확장한다. migration은 idempotent다. Issue #18의 유일한 schema 확장은 additive/idempotent 13→14이며 기존 자산 전용 run의 미저장 종합 총계는 NULL로 유지하고 backfill하지 않는다.
**영향 범위**
- 인터페이스: 기존 service/MCP 응답 envelope 유지, 신규 valuation/snapshot fields additive
@@ -131,7 +133,7 @@ preview는 DB를 쓰지 않고 `complete_exact`, `incomplete_draft`, `conditiona
- 영속성: `correction_of_run_id`, `is_correction`, `closed_at`
- 후속 사용 주체: timeseries와 월정산
- 마이그레이션: asset snapshot columns/indexes
-- 호환성: legacy `asset_snapshots` rows are readable
+- 호환성: legacy `account_snapshot` rows are readable
- 배포 전제 조건: close transaction atomicity
### REQ-003.08 — reconciliation과 monthly propagation
@@ -872,7 +874,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, 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 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의 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 31edd03..840c2fa 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_valuations.py`, `tests/test_asset_snapshots.py`, `tests/test_contract_regression.py`
+- 정상 흐름: `tests/test_account_valuation_service_actual.py`, `tests/test_account_snapshot_linking.py`, `tests/test_comprehensive_settlement_adapters.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 `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-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-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/adapters/mcp/server.py b/jarca_ledger_mcp/adapters/mcp/server.py
index 792bc7c..0eca324 100644
--- a/jarca_ledger_mcp/adapters/mcp/server.py
+++ b/jarca_ledger_mcp/adapters/mcp/server.py
@@ -1,5 +1,7 @@
from __future__ import annotations
+from typing import Literal
+
import os
from dataclasses import dataclass
from pathlib import Path
@@ -102,8 +104,8 @@ def get_ledger_backup_status(file_path: str, include_hash: bool = True, include_
return contracts.get_ledger_backup_status(file_path, include_hash=include_hash, include_integrity=include_integrity)
@mcp.tool()
-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 = "other", last_balance_basis_date: str = "") -> dict:
- """Create an account/card/cash/investment/loan ledger account."""
+def create_ledger_account(name: str, type: Literal["bank", "card", "cash", "investment", "loan", "other", "real_estate"] = "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, last_balance_basis_date: str = "") -> dict:
+ """Create a bank/card/cash/investment/loan/other/real_estate ledger account."""
return contracts.create_ledger_account(name, type, institution, currency, active, memo, account_number_masked, holder_name, last_balance, last_synced_at, statement_status, liquidity_bucket, last_balance_basis_date=last_balance_basis_date)
@mcp.tool()
@@ -112,7 +114,7 @@ def list_ledger_accounts(active_only: bool = True) -> dict:
return contracts.list_ledger_accounts(active_only)
@mcp.tool()
-def update_ledger_account(account_id: str, name: str | None = None, type: str | None = None, institution: str | None = None, currency: str | None = None, active: bool | None = None, memo: str | None = None, account_number_masked: str | None = None, holder_name: str | None = None, last_balance: int | None = None, last_synced_at: str | None = None, statement_status: str | None = None, liquidity_bucket: str | None = None, last_balance_basis_date: str | None = None) -> dict:
+def update_ledger_account(account_id: str, name: str | None = None, type: Literal["bank", "card", "cash", "investment", "loan", "other", "real_estate"] | None = None, institution: str | None = None, currency: str | None = None, active: bool | None = None, memo: str | None = None, account_number_masked: str | None = None, holder_name: str | None = None, last_balance: int | None = None, last_synced_at: str | None = None, statement_status: str | None = None, liquidity_bucket: str | None = None, last_balance_basis_date: str | None = None) -> dict:
"""Update selected account fields, including statement_status/liquidity_bucket for manual reconciliation."""
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)
@@ -141,26 +143,6 @@ 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."""
diff --git a/jarca_ledger_mcp/adapters/mcp/tool_contracts.py b/jarca_ledger_mcp/adapters/mcp/tool_contracts.py
index 0c5a4ad..f0aa081 100644
--- a/jarca_ledger_mcp/adapters/mcp/tool_contracts.py
+++ b/jarca_ledger_mcp/adapters/mcp/tool_contracts.py
@@ -22,8 +22,6 @@ 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,
@@ -66,7 +64,6 @@ 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,
@@ -83,7 +80,6 @@ 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 = {
@@ -112,8 +108,6 @@ __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",
@@ -156,7 +150,6 @@ __all__ = [
"list_ledger_categories",
"list_ledger_import_batches",
"list_ledger_transactions",
- "list_manual_assets",
"list_uncategorized_transactions",
"preview_asset_snapshot_close",
"preview_categorization",
@@ -173,5 +166,4 @@ __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 4e4f2b3..68dbba3 100644
--- a/jarca_ledger_mcp/adapters/streamlit/assets_gateway.py
+++ b/jarca_ledger_mcp/adapters/streamlit/assets_gateway.py
@@ -2,18 +2,10 @@ 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/adapters/streamlit/overview_gateway.py b/jarca_ledger_mcp/adapters/streamlit/overview_gateway.py
index 6011aca..15db7cb 100644
--- a/jarca_ledger_mcp/adapters/streamlit/overview_gateway.py
+++ b/jarca_ledger_mcp/adapters/streamlit/overview_gateway.py
@@ -5,6 +5,7 @@ from ...service import (
get_balance_reconciliation_report as get_balance_reconciliation_report,
get_budget_vs_actual_report as get_budget_vs_actual_report,
get_monthly_settlement_summary as get_monthly_settlement_summary,
+ list_monthly_settlement_overview as list_monthly_settlement_overview,
get_monthly_cashflow_report as get_monthly_cashflow_report,
get_ledger_db_status as get_ledger_db_status,
health_check_readonly as health_check,
@@ -20,6 +21,7 @@ __all__ = [
"get_balance_reconciliation_report",
"get_budget_vs_actual_report",
"get_monthly_settlement_summary",
+ "list_monthly_settlement_overview",
"get_monthly_cashflow_report",
"get_ledger_db_status",
"health_check",
diff --git a/jarca_ledger_mcp/adapters/streamlit/settlement_gateway.py b/jarca_ledger_mcp/adapters/streamlit/settlement_gateway.py
index c50e859..934f463 100644
--- a/jarca_ledger_mcp/adapters/streamlit/settlement_gateway.py
+++ b/jarca_ledger_mcp/adapters/streamlit/settlement_gateway.py
@@ -10,6 +10,13 @@ from ...service import (
finalize_settlement_draft as finalize_settlement_draft,
get_settlement_draft as get_settlement_draft,
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,
+ get_monthly_settlement_record as get_monthly_settlement_record,
+ list_account_snapshots_for_settlement as list_account_snapshots_for_settlement,
+ link_account_snapshot as link_account_snapshot,
+ unlink_account_snapshot as unlink_account_snapshot,
+ update_account_snapshot_category as update_account_snapshot_category,
list_account_valuations as list_account_valuations,
list_settlement_drafts as list_settlement_drafts,
refresh_settlement_draft_targets as refresh_settlement_draft_targets,
@@ -21,6 +28,8 @@ from ...service import (
)
from ...application import settlement_draft_service as _draft_service
from ...application import monthly_settlement_service as _monthly_service
+from ...application.transaction_report_services import get_monthly_cashflow_report as get_monthly_cashflow_report
+from ...application.transaction_report_services import get_settlement_cashflow_snapshot as get_settlement_cashflow_snapshot
from ... import service as _service
@@ -33,11 +42,36 @@ def _guard(db_path: str | None) -> str:
return db_path or _resolve_db_path()
+def create_monthly_settlement(month: str, created_by: str = "dashboard-admin", *, db_path=None):
+ return _monthly_service.create_monthly_settlement(month, created_by=created_by, db_path=_guard(db_path))
+
+def get_monthly_cashflow(month: str, *, settlement_id=None, closed=False, db_path=None):
+ if closed:
+ return get_settlement_cashflow_snapshot(str(settlement_id), db_path=_guard(db_path))
+ return get_monthly_cashflow_report(month, db_path=_guard(db_path))
+
+
+def review_monthly_settlement(settlement_id: str, *, db_path=None):
+ return _monthly_service.review_monthly_settlement(settlement_id, db_path=_guard(db_path))
+
+
+def approve_monthly_settlement(settlement_id: str, expected_version: int, review_fingerprint: str, approved_by: str = "dashboard-admin", *, db_path=None):
+ return _monthly_service.approve_monthly_settlement(settlement_id, expected_version, review_fingerprint, approved_by=approved_by, db_path=_guard(db_path))
+
+
+def close_monthly_settlement(settlement_id: str, expected_version: int, closed_by: str = "dashboard-admin", *, db_path=None):
+ return _monthly_service.close_monthly_settlement(settlement_id, expected_version, closed_by=closed_by, db_path=_guard(db_path))
+
+
+def cancel_monthly_settlement_approval(settlement_id: str, expected_version: int, approved_by: str = "dashboard-admin", *, db_path=None):
+ return _monthly_service.cancel_monthly_settlement_approval(settlement_id, expected_version, approved_by=approved_by, db_path=_guard(db_path))
+
+
def _wrap(fn):
def call(*args, db_path=None, **kwargs):
if fn.__name__ == "set_settlement_cashflow_policy":
target = _draft_service
- elif fn.__name__ == "get_monthly_settlement_snapshot":
+ elif fn.__name__ in {"get_monthly_settlement_snapshot", "list_monthly_settlement_history", "get_monthly_settlement_history_record", "get_monthly_settlement_record", "list_account_snapshots_for_settlement", "link_account_snapshot", "unlink_account_snapshot"}:
target = _monthly_service
else:
target = _draft_service if fn.__name__ in {"create_settlement_draft", "list_settlement_drafts", "get_settlement_draft", "update_settlement_draft", "refresh_settlement_draft_targets", "set_settlement_draft_item", "review_settlement_draft", "approve_settlement_draft", "finalize_settlement_draft", "cancel_settlement_draft"} else _service
@@ -54,8 +88,10 @@ for _name in (
"update_settlement_draft", "refresh_settlement_draft_targets",
"set_settlement_draft_item", "review_settlement_draft",
"approve_settlement_draft", "finalize_settlement_draft", "cancel_settlement_draft",
+ "cancel_monthly_settlement_approval",
"register_account_valuation", "list_account_valuations", "correct_account_valuation",
"set_settlement_cashflow_policy", "get_monthly_settlement_snapshot",
+ "list_monthly_settlement_history", "get_monthly_settlement_history_record", "get_monthly_settlement_record", "list_account_snapshots_for_settlement", "link_account_snapshot", "unlink_account_snapshot", "update_account_snapshot_category",
):
globals()[_name] = _wrap(getattr(_service, _name))
@@ -64,6 +100,9 @@ __all__ = [
"update_settlement_draft", "refresh_settlement_draft_targets",
"set_settlement_draft_item", "review_settlement_draft",
"approve_settlement_draft", "finalize_settlement_draft", "cancel_settlement_draft",
+ "cancel_monthly_settlement_approval",
"register_account_valuation", "list_account_valuations", "correct_account_valuation",
"set_settlement_cashflow_policy", "get_monthly_settlement_snapshot",
+ "list_monthly_settlement_history", "get_monthly_settlement_history_record", "get_monthly_settlement_record", "list_account_snapshots_for_settlement",
+ "get_monthly_cashflow",
]
diff --git a/jarca_ledger_mcp/application/account_valuation_service.py b/jarca_ledger_mcp/application/account_valuation_service.py
index 0dfd7b7..43aacec 100644
--- a/jarca_ledger_mcp/application/account_valuation_service.py
+++ b/jarca_ledger_mcp/application/account_valuation_service.py
@@ -1,375 +1,163 @@
from __future__ import annotations
-from datetime import date
-import math
-import re
-from typing import Any
import sqlite3
+from typing import Any
+from uuid import uuid4
-from .shared import db, read_db, read_operation, fail, new_id, normalize_date, now_iso, ok, row_to_dict
+from .shared import db, fail, now_iso, ok, read_db
-_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",
-)
+_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
-def _valuation(row: Any) -> dict[str, Any]:
- return row_to_dict(row)
+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 _public_candidate(row: Any) -> dict[str, Any]:
- return {k: row[k] for k in ("id", "basis_date", "currency", "valuation_kind", "verification_status")}
+def record_account_valuation_candidate(*args: Any, **kwargs: Any) -> dict[str, Any]:
+ return fail("unsupported", "account valuation candidates are not writable")
-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):
+def _strict_int(value: Any, field: str) -> int:
+ if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"{field} must be an integer")
- 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)
+ return value
def register_account_valuation(
account_id: str,
basis_date: str,
amount: int,
- currency: str = "KRW",
+ currency: str | None = None,
valuation_kind: str = "balance",
- source_type: str = "manual",
+ source_type: str = "account",
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,
db_path: str | None = None,
- supersedes_id: str | None = None,
- correction_reason: str = "",
+ liquidity_bucket: str | None = None,
+ **kwargs: Any,
) -> dict[str, Any]:
- 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
+ """Register one unverified account evaluation in the monthly snapshot.
+
+ This is intentionally the account_snapshot write path; no valuation table is
+ created. The transaction locks before checking duplicates so concurrent UI
+ submissions cannot create two rows for the same settlement/account.
+ """
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:
+ 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 source_type != "account":
+ raise ValueError("source_type must be account")
+ if verification_status not in _ALLOWED_VERIFICATION:
+ raise ValueError("verification_status must be pending or unverified")
+ with db(db_path) as conn:
+ conn.execute("BEGIN IMMEDIATE")
+ selection_policy = kwargs.get("selection_policy", "manual")
+ valuation_source = kwargs.get("valuation_source", "manual")
+ source_note = kwargs.get("source_note", "")
+ if selection_policy not in {"manual", "previous", "next"}:
+ raise ValueError("selection_policy must be manual, previous or next")
+ if valuation_source not in {"manual", "ledger"}:
+ raise ValueError("invalid valuation_source")
+ account = conn.execute("SELECT id,name,currency,liquidity_bucket FROM accounts WHERE id=?", (account_id,)).fetchone()
+ if account is None:
+ conn.rollback(); return fail("not_found", "account not found")
+ if account["currency"] != currency:
+ conn.rollback(); return fail("validation_error", "currency does not match account")
+ bucket = normalize_liquidity_bucket(liquidity_bucket, default=account["liquidity_bucket"] or "other")
+ settlement = conn.execute(
+ "SELECT id,basis_date,status FROM monthly_settlement WHERE id=?", (monthly_settlement_id,)
+ ).fetchone()
+ if settlement is None:
+ conn.rollback(); return fail("not_found", "monthly settlement not found")
+ if settlement["status"] == "closed":
+ conn.rollback(); return fail("closed_settlement", "closed monthly settlement cannot be changed")
+ if settlement["status"] not in _ALLOWED_SETTLEMENT_STATUS:
+ conn.rollback(); return fail("invalid_settlement_status", "only draft or in_review settlements can be changed")
+ if basis_date != settlement["basis_date"]:
+ conn.rollback(); return fail("validation_error", "basis_date must match monthly settlement")
+ if selection_policy != "manual":
+ tx = conn.execute("""SELECT t.balance_after,t.transaction_date
+ FROM transactions t JOIN accounts a ON a.id=t.account_id
+ WHERE t.id=? AND t.account_id=? AND a.active=1 AND t.currency=?
+ AND t.status='active' AND t.balance_after IS NOT NULL""", (source_note, account_id, currency)).fetchone()
+ if tx is None or (selection_policy == "previous" and tx["transaction_date"] > basis_date) or (selection_policy == "next" and tx["transaction_date"] <= basis_date):
+ conn.rollback(); return fail("stale_evidence", "selected ledger is no longer valid")
+ amount = tx["balance_after"]
+ duplicate = conn.execute(
+ "SELECT id FROM account_snapshot WHERE monthly_settlement_id=? AND source_type='account' AND source_id=?",
+ (settlement["id"], account_id),
+ ).fetchone()
+ if duplicate:
+ conn.rollback(); return fail("duplicate", "account evaluation already registered", snapshot_id=duplicate["id"])
+ snapshot_id = f"snapshot_{uuid4().hex}"
+ conn.execute(
+ """INSERT INTO account_snapshot
+ (id,basis_date,source_type,source_id,name,amount,currency,memo,
+ monthly_settlement_id,valuation_id,selection_policy,valuation_source,source_note,verification_status,evidence_kind,liquidity_bucket,created_at)
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
+ (snapshot_id, basis_date, "account", account_id, account["name"], amount,
+ currency, memo or "", settlement["id"], "", selection_policy, valuation_source, source_note, "unverified", "applied", bucket, now_iso()),
+ )
+ return ok(snapshot_id=snapshot_id, id=snapshot_id, verification_status="unverified", created=True)
+ except 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)
+ except sqlite3.IntegrityError:
+ return fail("duplicate", "account evaluation already registered")
+ except Exception:
+ return fail("db_error", "account evaluation registration failed")
-@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()
- values = [_valuation(row) for row in rows]
- 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:
+ 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:
+ bucket = normalize_liquidity_bucket(liquidity_bucket)
+ with db(db_path) as conn:
+ conn.execute("BEGIN IMMEDIATE")
+ settlement = conn.execute("SELECT status FROM monthly_settlement WHERE id=?", (monthly_settlement_id,)).fetchone()
+ if settlement is None:
+ conn.rollback(); return fail("not_found", "monthly settlement not found")
+ if settlement["status"] not in _ALLOWED_SETTLEMENT_STATUS:
+ conn.rollback(); return fail("invalid_settlement_status", "only draft or in_review settlements can be changed")
+ snapshot = conn.execute("SELECT id,source_type,monthly_settlement_id FROM account_snapshot WHERE id=?", (snapshot_id,)).fetchone()
+ if snapshot is None:
+ conn.rollback(); return fail("not_found", "account snapshot not found")
+ if snapshot["source_type"] != "account" or snapshot["monthly_settlement_id"] != monthly_settlement_id:
+ conn.rollback(); return fail("validation_error", "snapshot is not linked to this monthly settlement")
+ changed = conn.execute("UPDATE account_snapshot SET liquidity_bucket=? WHERE id=? AND monthly_settlement_id=?", (bucket, snapshot_id, monthly_settlement_id))
+ if changed.rowcount != 1:
+ conn.rollback(); return fail("stale_update", "snapshot category update is stale")
+ return ok(snapshot_id=snapshot_id, monthly_settlement_id=monthly_settlement_id, liquidity_bucket=bucket, updated=True)
+ except ValueError 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,
- )
+ except Exception:
+ return fail("db_error", "account snapshot category update failed")
diff --git a/jarca_ledger_mcp/application/accounts_write_services.py b/jarca_ledger_mcp/application/accounts_write_services.py
index 6cc90ce..51643e2 100644
--- a/jarca_ledger_mcp/application/accounts_write_services.py
+++ b/jarca_ledger_mcp/application/accounts_write_services.py
@@ -3,23 +3,18 @@ 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:
- 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
+ """Deprecated: balances remain on accounts; no valuation row is created."""
+ 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 = "other", db_path: str | None = None, last_balance_basis_date: str = "") -> dict[str, Any]:
+def create_ledger_account(name: str, type: str = "bank", institution: str = "", currency: str = "KRW", active: bool = True, memo: str = "", account_number_masked: str = "", holder_name: str = "", last_balance: int | None = None, last_synced_at: str = "", statement_status: str = "unknown", liquidity_bucket: str | None = None, db_path: str | None = None, last_balance_basis_date: str = "") -> dict[str, Any]:
if not name.strip(): return fail("validation_error", "account name is required")
if type not in VALID_ACCOUNT_TYPES: return fail("validation_error", f"type must be one of {sorted(VALID_ACCOUNT_TYPES)}")
try:
statement_status_norm = normalize_statement_status(statement_status)
- liquidity_bucket_norm = normalize_liquidity_bucket(liquidity_bucket)
+ liquidity_bucket_norm = normalize_liquidity_bucket(
+ "real_estate" if liquidity_bucket is None and type == "real_estate" else liquidity_bucket
+ )
except Exception as e: return fail("validation_error", str(e))
ts = now_iso(); account_id = new_id("acct")
try:
@@ -79,14 +74,7 @@ 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":
- 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")
+ return fail("unsupported", "account valuation history is unavailable; loan conversion requires a supported liability valuation source")
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 f208476..ecd2f3f 100644
--- a/jarca_ledger_mcp/application/asset_account_services.py
+++ b/jarca_ledger_mcp/application/asset_account_services.py
@@ -3,13 +3,9 @@ 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 ba046d6..891c591 100644
--- a/jarca_ledger_mcp/application/assets_service.py
+++ b/jarca_ledger_mcp/application/assets_service.py
@@ -25,9 +25,15 @@ 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", "manual_assets"],
+ "authority_sources": ["accounts.last_balance"],
"snapshot_role": "historical_record_layer",
"formula": "asset_buckets - loan",
"asset_buckets": ASSET_BUCKETS,
@@ -160,64 +166,8 @@ def _account_valuation_items(conn: sqlite3.Connection, basis_date: str = "", sel
continue
balance = account.get("last_balance")
if basis_date:
- 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]
+ 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
if balance is None:
excluded.append({**base, "reason": "missing_last_balance"})
@@ -227,49 +177,6 @@ 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:
@@ -283,94 +190,11 @@ 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."""
- 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
+ return [], {"run_history_unavailable": True}
def _snapshot_rows_for_latest_run(conn: sqlite3.Connection, basis_date: str) -> tuple[list[sqlite3.Row], dict[str, Any]]:
- 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"])
+ return [], {"run_history_unavailable": True}
def _snapshot_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str, Any]]:
@@ -396,7 +220,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": "asset_snapshots",
+ "authority_source": "account_snapshot",
"valuation_source": snap.get("valuation_source", ""),
"name": snap.get("name", ""),
"currency": snap.get("currency", ""),
@@ -493,6 +317,8 @@ 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", []):
@@ -536,8 +362,7 @@ 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),
- "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],
+ "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,
@@ -555,102 +380,29 @@ def _summarize_valuation_items(included_items: list[dict[str, Any]], excluded_it
}
-def create_manual_asset(name: str, valuation_amount: int | None, valuation_date: str, liquidity_bucket: str, source_note: str = "", memo: str = "", currency: str = "KRW", active: bool = True, db_path: str | None = None) -> dict[str, Any]:
- 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 create_manual_asset(*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 list_manual_assets(*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 update_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 deactivate_manual_asset(*args, **kwargs):
+ return fail("unsupported", "manual assets are no longer supported")
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)
- 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
+ included = account_items
+ excluded = account_excluded
summary = _summarize_valuation_items(included, excluded)
- existing_rows = conn.execute("SELECT COUNT(*) FROM asset_snapshots WHERE basis_date=?", (basis,)).fetchone()[0]
+ existing_rows = conn.execute("SELECT COUNT(*) FROM account_snapshot 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")})
@@ -673,16 +425,8 @@ 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,
@@ -728,6 +472,7 @@ 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:
@@ -738,6 +483,12 @@ 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)
@@ -867,7 +618,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 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)
+ """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)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
snapshot_id,
@@ -881,7 +632,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,
- run_id,
+ monthly_settlement_id,
correction_of_run_id,
correction_reason.strip(),
int(is_correction),
@@ -894,6 +645,10 @@ 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", [])]
@@ -921,6 +676,8 @@ 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 "",
@@ -970,8 +727,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 not in {"account", "manual_asset"}:
- return fail("validation_error", "source_type must be one of ['account', 'manual_asset']")
+ if source_type != "account":
+ return fail("unsupported", "manual asset snapshots are no longer supported")
if not name.strip():
return fail("validation_error", "snapshot item name is required")
try:
@@ -984,16 +741,17 @@ 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 asset_snapshots(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,valuation_source,source_note,memo,created_at)
+ """INSERT INTO account_snapshot(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 asset_snapshots WHERE id=?", (snapshot_id,)).fetchone()
+ row = conn.execute("SELECT * FROM account_snapshot 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:
@@ -1017,7 +775,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 "asset_snapshots",
+ source="legacy_asset_snapshots" if legacy_closed else "account_snapshot",
is_closed_snapshot=True,
snapshot_run_id=close_metadata.get("snapshot_run_id", ""),
snapshot_count=len(snapshots),
@@ -1091,6 +849,7 @@ 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()
@@ -1106,7 +865,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 asset_snapshots
+ """SELECT basis_date FROM account_snapshot
WHERE basis_date>=? AND basis_date<=?
UNION
SELECT basis_date FROM asset_snapshot_runs
@@ -1122,7 +881,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 asset_snapshots 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 account_snapshot 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(
@@ -1131,7 +890,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": "asset_snapshots",
+ "source": "account_snapshot",
"item_count": len(items),
"snapshot_run_id": metadata.get("snapshot_run_id", ""),
"draft_id": run_public.get("draft_id", ""),
@@ -1177,16 +936,15 @@ 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)
- manual_items, manual_excluded = _manual_asset_valuation_items(conn)
- included = account_items + manual_items
- excluded = account_excluded + manual_excluded
+ included = account_items
+ excluded = account_excluded
summary = _summarize_valuation_items(included, excluded)
source_accounts = [item for item in included if item.get("source_type") == "account"]
- source = "accounts.last_balance" if not manual_items and not manual_excluded else "accounts.last_balance+manual_assets"
+ source = "accounts.last_balance"
return ok(
buckets=summary["buckets"],
accounts=source_accounts,
- manual_assets=manual_items,
+ manual_assets=[],
total_assets=summary["total_assets"],
total_liabilities=summary["total_liabilities"],
net_worth=summary["net_worth"],
@@ -1196,7 +954,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=len(manual_items),
+ manual_asset_count=0,
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 de90063..248f9cf 100644
--- a/jarca_ledger_mcp/application/monthly_settlement_service.py
+++ b/jarca_ledger_mcp/application/monthly_settlement_service.py
@@ -1,8 +1,10 @@
from __future__ import annotations
+import hashlib
import json
+from uuid import uuid4
-from .shared import read_db, DatabaseNotInitializedError
+from .shared import read_db, db, DatabaseNotInitializedError, now_iso
from .assets_service import get_asset_snapshot_report
from .imports_service import list_ledger_import_batches
@@ -16,6 +18,430 @@ def _month_end_date(month: str) -> str:
return normalize_snapshot_at(month, default_now=False)[:10]
+def _cashflow_fingerprint(report: dict[str, Any]) -> str:
+ payload = {
+ "categories": [{k: item.get(k) for k in ("cashflow_kind", "category", "subcategory", "amount", "count", "currency")} for item in report.get("categories", [])],
+ "source_transactions": report.get("source_transactions", []),
+ }
+ return hashlib.sha256(json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
+
+
+def _review_cashflow(month: str, db_path: str | None = None, conn=None) -> dict[str, Any]:
+ if conn is None:
+ return get_monthly_cashflow_report(month, db_path=db_path)
+ from .transaction_report_services import _month_range, CASHFLOW_KIND_SQL
+ start, end = _month_range(month)
+ rows = conn.execute(f"SELECT {CASHFLOW_KIND_SQL} AS cashflow_kind, t.category, t.subcategory, t.currency, SUM(t.amount) AS amount, COUNT(*) AS count FROM transactions t LEFT JOIN ledger_categories lc ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory WHERE t.status='active' AND t.transaction_date>=? AND t.transaction_date GROUP BY {CASHFLOW_KIND_SQL}, t.category, t.subcategory, t.currency ORDER BY cashflow_kind, amount DESC", (start, end)).fetchall()
+ categories = [dict(r) for r in rows]
+ source_rows = conn.execute(f"SELECT t.id, t.transaction_date, t.type, t.amount, t.currency, t.category, t.subcategory, t.account_id FROM transactions t LEFT JOIN ledger_categories lc ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory WHERE t.status='active' AND t.transaction_date>=? AND t.transaction_date ORDER BY t.id", (start, end)).fetchall()
+ totals = {}
+ for item in categories: totals[item['cashflow_kind']] = totals.get(item['cashflow_kind'], 0) + int(item['amount'] or 0)
+ income, expenses = totals.get('income', 0), totals.get('expense', 0)
+ return ok(month=month, income=income, expenses=expenses, net_cashflow=income-expenses, by_cashflow_kind=totals, categories=categories, source_transactions=[dict(r) for r in source_rows])
+
+
+def review_monthly_settlement(settlement_id: str, db_path: str | None = None) -> dict[str, Any]:
+ try:
+ with read_db(db_path) as conn:
+ row = conn.execute("SELECT id,month,status,version FROM monthly_settlement WHERE id=?", (settlement_id,)).fetchone()
+ if not row:
+ return fail("not_found", "monthly settlement not found")
+ if row["status"] not in {"draft", "in_review"}:
+ return fail("invalid_settlement_status", "only draft or in_review settlements can be reviewed")
+ report = _review_cashflow(row["month"], conn=conn)
+ fp = _cashflow_fingerprint(report)
+ return ok(settlement_id=settlement_id, month=row["month"], version=row["version"], status=row["status"], cashflow=report, review_fingerprint=fp)
+ except Exception:
+ return fail("operation_failed", "monthly settlement review unavailable")
+
+
+def approve_monthly_settlement(settlement_id: str, expected_version: int, review_fingerprint: str, approved_by: str = "dashboard-admin", db_path: str | None = None) -> dict[str, Any]:
+ if not review_fingerprint:
+ return fail("validation_error", "review_fingerprint is required")
+ try:
+ with db(db_path) as conn:
+ conn.execute("BEGIN IMMEDIATE")
+ row = conn.execute("SELECT * FROM monthly_settlement WHERE id=?", (settlement_id,)).fetchone()
+ if not row: return fail("not_found", "monthly settlement not found")
+ if row["status"] not in {"draft", "in_review"} or row["version"] != expected_version:
+ return fail("stale_settlement", "settlement status or version is stale")
+ report = _review_cashflow(row["month"], conn=conn)
+ actual = _cashflow_fingerprint(report)
+ if actual != review_fingerprint:
+ return fail("stale_review", "review fingerprint is stale", current_fingerprint=actual)
+ ts = now_iso()
+ conn.execute("UPDATE monthly_settlement SET status='approved', approved_by=?, approved_at=?, approved_version=version, approved_fingerprint=COALESCE(review_fingerprint,?), approved_cashflow_fingerprint=?, updated_by=?, updated_at=? WHERE id=? AND version=?", (approved_by, ts, review_fingerprint, actual, approved_by, ts, settlement_id, expected_version))
+ conn.execute("UPDATE monthly_settlement SET review_fingerprint=? WHERE id=?", (review_fingerprint, settlement_id))
+ return ok(settlement_id=settlement_id, status="approved", version=expected_version, approved_cashflow_fingerprint=actual)
+ except Exception:
+ return fail("operation_failed", "monthly settlement approval failed")
+
+
+def cancel_monthly_settlement_approval(settlement_id: str, expected_version: int, approved_by: str = "dashboard-admin", db_path: str | None = None) -> dict[str, Any]:
+ """Return an approved settlement to review without changing its source data."""
+ if not isinstance(expected_version, int) or isinstance(expected_version, bool) or expected_version < 1:
+ return fail("validation_error", "expected_version is required")
+ try:
+ with db(db_path) as conn:
+ conn.execute("BEGIN IMMEDIATE")
+ row = conn.execute("SELECT id, status, version FROM monthly_settlement WHERE id=?", (settlement_id,)).fetchone()
+ if not row:
+ return fail("not_found", "monthly settlement not found")
+ if row["status"] != "approved" or row["version"] != expected_version:
+ return fail("stale_settlement", "only the current approved settlement can have approval cancelled")
+ ts = now_iso()
+ cur = conn.execute("""UPDATE monthly_settlement
+ SET status='in_review', version=version+1,
+ approved_by=NULL, approved_at=NULL, approved_version=NULL,
+ approved_fingerprint=NULL, approved_cashflow_fingerprint=NULL,
+ approval_reason=NULL, approval_flags_json='{}',
+ review_fingerprint=NULL, updated_by=?, updated_at=?
+ WHERE id=? AND status='approved' AND version=?""", (approved_by, ts, settlement_id, expected_version))
+ if cur.rowcount != 1:
+ return fail("stale_settlement", "settlement status or version is stale")
+ return ok(settlement_id=settlement_id, status="in_review", version=expected_version + 1)
+ except Exception:
+ return fail("operation_failed", "monthly settlement approval cancellation failed")
+
+
+def close_monthly_settlement(settlement_id: str, expected_version: int, closed_by: str = "dashboard-admin", db_path: str | None = None) -> dict[str, Any]:
+ try:
+ with db(db_path) as conn:
+ conn.execute("BEGIN IMMEDIATE")
+ row = conn.execute("SELECT * FROM monthly_settlement WHERE id=?", (settlement_id,)).fetchone()
+ if not row: return fail("not_found", "monthly settlement not found")
+ if row["status"] == "closed": return ok(settlement_id=settlement_id, status="closed", version=row["version"], idempotent=True)
+ if row["status"] != "approved" or row["version"] != expected_version:
+ return fail("stale_settlement", "settlement status or version is stale")
+ if not row["approved_cashflow_fingerprint"]: return fail("missing_fingerprint", "approved cashflow fingerprint is missing")
+ report = _review_cashflow(row["month"], conn=conn)
+ actual = _cashflow_fingerprint(report)
+ if actual != row["approved_cashflow_fingerprint"]: return fail("stale_cashflow", "cashflow changed after approval", current_fingerprint=actual)
+ conn.execute("DELETE FROM settlement_cashflow_snapshots WHERE monthly_settlement_id=?", (settlement_id,))
+ items = report.get("categories", [])
+ for item in items:
+ conn.execute("INSERT INTO settlement_cashflow_snapshots(id,monthly_settlement_id,cashflow_kind,category,subcategory,amount,transaction_count,currency) VALUES(?,?,?,?,?,?,?,?)", (uuid4().hex, settlement_id, item.get("cashflow_kind") or "excluded", item.get("category") or "", item.get("subcategory") or "", int(item.get("amount") or 0), max(1, int(item.get("count") or 0)), item.get("currency") or ""))
+ ts = now_iso()
+ conn.execute("UPDATE monthly_settlement SET status='closed', closed_by=?, closed_at=?, updated_by=?, updated_at=? WHERE id=? AND status='approved' AND version=?", (closed_by, ts, closed_by, ts, settlement_id, expected_version))
+ return ok(settlement_id=settlement_id, status="closed", version=expected_version, snapshot_count=len(items), approved_cashflow_fingerprint=row["approved_cashflow_fingerprint"])
+ except Exception:
+ return fail("operation_failed", "monthly settlement close failed")
+
+
+def create_monthly_settlement(month: str, created_by: str = "dashboard-admin", db_path: str | None = None) -> dict[str, Any]:
+ """Create only the canonical monthly-settlement parent draft."""
+ if not isinstance(month, str) or not __import__("re").fullmatch(r"\d{4}-\d{2}", month):
+ return fail("validation_error", "month must be YYYY-MM")
+ try:
+ selected = normalize_month(month)
+ basis = _month_end_date(selected)
+ ts = now_iso()
+ with db(db_path) as conn:
+ existing = conn.execute("SELECT id FROM monthly_settlement WHERE month=?", (selected,)).fetchone()
+ if existing:
+ return fail("duplicate_month", "monthly settlement already exists", settlement_id=existing["id"], month=selected)
+ settlement_id = uuid4().hex
+ conn.execute(
+ "INSERT INTO monthly_settlement(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) VALUES(?,?,?,?,?,?,?,?,?)",
+ (settlement_id, selected, basis, "draft", 1, created_by, ts, created_by, ts),
+ )
+ return ok(settlement_id=settlement_id, month=selected, status="draft", created=True)
+ except DatabaseNotInitializedError:
+ return fail("db_not_initialized", "ledger database is not initialized")
+ except Exception as exc:
+ if "UNIQUE" in str(exc).upper():
+ return fail("duplicate_month", "monthly settlement already exists", month=month)
+ return fail("operation_failed", "monthly settlement creation failed")
+
+
+def list_monthly_settlement_overview(year: int | None = None, status: str = "", db_path: str | None = None) -> dict[str, Any]:
+ """List monthly-settlement parent rows without consulting removed run history."""
+ try:
+ with read_db(db_path) as conn:
+ rows = conn.execute(
+ """
+ WITH live AS (
+ SELECT substr(transaction_date, 1, 7) AS month,
+ SUM(CASE WHEN type IN ('income','credit') THEN amount ELSE 0 END) AS income,
+ SUM(CASE WHEN type IN ('expense','debit') THEN amount ELSE 0 END) AS expense
+ FROM transactions
+ GROUP BY substr(transaction_date, 1, 7)
+ )
+ SELECT m.month, m.id AS settlement_id, m.basis_date, m.status AS settlement_status,
+ m.version AS settlement_version, m.created_at, m.updated_at,
+ l.income AS live_income, l.expense AS live_expense
+ FROM monthly_settlement m
+ LEFT JOIN live l ON l.month = m.month
+ WHERE (? IS NULL OR ? = '' OR substr(m.month, 1, 4) = ?)
+ AND (? = '' OR m.status = ?)
+ ORDER BY m.month DESC
+ """,
+ (str(year) if year is not None else None, str(year) if year is not None else '', str(year) if year is not None else '', status or '', status or ''),
+ ).fetchall()
+ result = []
+ for row in rows:
+ d = dict(row)
+ parent_exists = bool(d.get("settlement_id"))
+ lifecycle = d.get("settlement_status") or "unregistered"
+ label = {
+ "closed": "마감완료",
+ "approved": "승인완료",
+ "in_review": "검토중",
+ "draft": "초안",
+ "cancelled": "취소",
+ "unregistered": "정산 미생성·상세 선택 불가·현재집계(미확정)",
+ }.get(lifecycle, lifecycle)
+ d.update(
+ status_label=label,
+ amounts_are_snapshot=False,
+ income=d.get("live_income") or 0,
+ expense=d.get("live_expense") or 0,
+ income_display=d.get("live_income") or 0,
+ expense_display=d.get("live_expense") or 0,
+ amount_confirmation="미확정",
+ net_cashflow_display=None,
+ net_worth_display=None,
+ run_id=None,
+ run_version=None,
+ close_status=None,
+ closed_at=None,
+ income_total=None,
+ expense_total=None,
+ net_cashflow=None,
+ net_worth=None,
+ correction_active=False,
+ draft_active=parent_exists,
+ )
+ result.append(d)
+ return ok(months=result, read_only=True)
+ except DatabaseNotInitializedError:
+ return fail("db_not_initialized", "ledger database is not initialized")
+ except Exception:
+ return fail("operation_failed", "monthly settlement overview unavailable")
+
+
+def get_monthly_settlement_record(settlement_id: str, db_path: str | None = None) -> dict[str, Any]:
+ """Read one monthly-settlement parent by canonical ID."""
+ if not isinstance(settlement_id, str) or not settlement_id.strip():
+ return fail("validation_error", "settlement_id is required")
+ try:
+ with read_db(db_path) as conn:
+ row = conn.execute(
+ "SELECT id, month, basis_date, status, version, created_at, updated_at "
+ "FROM monthly_settlement WHERE id = ?", (settlement_id,)
+ ).fetchone()
+ if row is None:
+ return fail("not_found", "monthly settlement not found")
+ return ok(settlement=dict(row), read_only=True)
+ except DatabaseNotInitializedError:
+ return fail("db_not_initialized", "ledger database is not initialized")
+ except Exception:
+ return fail("operation_failed", "monthly settlement unavailable")
+
+
+def _ledger_candidates(conn, account_id: str, currency: str, basis_date: str) -> dict[str, Any]:
+ base = """SELECT t.id,t.transaction_date,t.transaction_datetime,t.created_at,t.balance_after,t.currency,t.account_id
+ FROM transactions t
+ WHERE t.account_id=? AND t.currency=? AND t.status='active'
+ AND t.balance_after IS NOT NULL AND t.transaction_date {op} ?
+ ORDER BY t.transaction_date {direction}, t.transaction_datetime {direction}, t.created_at {direction}, t.id {direction} LIMIT 1"""
+ prev = conn.execute(base.format(op="<=", direction="DESC"), (account_id, currency, basis_date)).fetchone()
+ nxt = conn.execute(base.format(op=">", direction="ASC"), (account_id, currency, basis_date)).fetchone()
+ return {"previous_ledger": dict(prev) if prev else None, "next_ledger": dict(nxt) if nxt else None}
+
+
+def list_account_snapshots_for_settlement(settlement_id: str, db_path: str | None = None) -> dict[str, Any]:
+ """Return the account evaluation rows for a settlement without inventing values.
+
+ Draft/review/approved/cancelled settlements use the complete registered-account
+ set and exact monthly snapshot matches. Closed settlements are historical:
+ they read only rows stored against that settlement, so later accounts cannot
+ leak into the report. Duplicate snapshot rows are deliberately preserved.
+ """
+ if not isinstance(settlement_id, str) or not settlement_id.strip():
+ return fail("validation_error", "settlement_id is required")
+ try:
+ with read_db(db_path) as conn:
+ settlement = conn.execute(
+ "SELECT status, basis_date FROM monthly_settlement WHERE id=?", (settlement_id,)
+ ).fetchone()
+ if settlement is None:
+ return fail("not_found", "monthly settlement not found")
+ closed = settlement["status"] == "closed"
+ if closed:
+ rows = conn.execute(
+ """SELECT s.name, s.source_type, s.source_id, s.amount, s.currency,
+ a.type AS account_type, a.liquidity_bucket AS account_liquidity_bucket,
+ s.liquidity_bucket AS snapshot_account_category,
+ s.basis_date, s.verification_status, s.memo, s.source_note,
+ s.id AS snapshot_id, 'snapshot' AS match_status
+ FROM account_snapshot s
+ LEFT JOIN accounts a ON a.id=s.source_id
+ WHERE s.monthly_settlement_id=? AND s.source_type='account'
+ ORDER BY s.name, s.source_id, s.created_at, s.id""", (settlement_id,)
+ ).fetchall()
+ else:
+ rows = conn.execute(
+ """SELECT a.name, 'account' AS source_type, a.id AS source_id,
+ a.type AS account_type, a.liquidity_bucket AS account_liquidity_bucket,
+ s.liquidity_bucket AS snapshot_account_category,
+ s.amount, COALESCE(s.currency, a.currency) AS currency,
+ COALESCE(s.basis_date, ?) AS basis_date,
+ s.verification_status, s.memo, s.source_note,
+ s.id AS snapshot_id,
+ CASE WHEN s.id IS NULL THEN 'unregistered' ELSE 'snapshot' END AS match_status
+ FROM accounts a
+ LEFT JOIN account_snapshot s
+ ON s.source_id=a.id AND s.source_type='account'
+ AND s.monthly_settlement_id=?
+ ORDER BY a.name, a.id, s.created_at, s.id""",
+ (settlement["basis_date"], settlement_id),
+ ).fetchall()
+ result = []
+ for row in rows:
+ item = dict(row)
+ item["account_name"] = item.get("name") or ""
+ item["account_type"] = item.get("account_type") or ""
+ item["account_liquidity_bucket"] = item.get("account_liquidity_bucket") or ""
+ item["snapshot_account_category"] = item.get("snapshot_account_category") or ""
+ item["display_status"] = "미등록" if item["match_status"] == "unregistered" else ("미확인" if item.get("amount") is None else "확인")
+ if item["match_status"] == "unregistered":
+ with read_db(db_path) as candidate_conn:
+ item.update(_ledger_candidates(candidate_conn, item["source_id"], item["currency"], item["basis_date"]))
+ if item["match_status"] == "unregistered":
+ with read_db(db_path) as candidate_conn:
+ candidates = candidate_conn.execute(
+ """SELECT s.id AS snapshot_id, s.basis_date, s.amount, s.currency,
+ s.verification_status, s.created_at
+ FROM account_snapshot s
+ WHERE s.source_type='account' AND s.source_id=?
+ AND s.monthly_settlement_id IS NULL AND s.currency=?
+ ORDER BY CASE WHEN s.basis_date=? THEN 0 ELSE 1 END,
+ s.basis_date DESC, s.created_at DESC, s.id DESC""",
+ (item["source_id"], item["currency"], item["basis_date"]),
+ ).fetchall()
+ item["unlinked_candidates"] = [dict(candidate) for candidate in candidates]
+ result.append(item)
+ return ok(snapshots=result, count=len(result), registered_count=sum(r["match_status"] == "snapshot" for r in result), unregistered_count=sum(r["match_status"] == "unregistered" for r in result), closed=closed, status=settlement["status"], read_only=True)
+ except DatabaseNotInitializedError:
+ return fail("db_not_initialized", "ledger database is not initialized")
+ except Exception:
+ return fail("operation_failed", "account snapshot details unavailable")
+
+
+def link_account_snapshot(snapshot_id: str, monthly_settlement_id: str, *, allow_basis_date_mismatch: bool = False, db_path: str | None = None) -> dict[str, Any]:
+ """Atomically attach one existing, unlinked account snapshot."""
+ if not isinstance(snapshot_id, str) or not snapshot_id.strip() or not isinstance(monthly_settlement_id, str) or not monthly_settlement_id.strip():
+ return fail("validation_error", "snapshot_id and monthly_settlement_id are required")
+ try:
+ with db(db_path) as conn:
+ conn.execute("BEGIN IMMEDIATE")
+ settlement = conn.execute("SELECT id,basis_date,status FROM monthly_settlement WHERE id=?", (monthly_settlement_id,)).fetchone()
+ if settlement is None:
+ conn.rollback(); return fail("not_found", "monthly settlement not found")
+ if settlement["status"] not in {"draft", "in_review"}:
+ conn.rollback(); return fail("invalid_settlement_status", "only draft or in_review settlements can be changed")
+ snapshot = conn.execute("SELECT id,source_type,source_id,currency,basis_date,monthly_settlement_id FROM account_snapshot WHERE id=?", (snapshot_id,)).fetchone()
+ if snapshot is None:
+ conn.rollback(); return fail("not_found", "account snapshot not found")
+ account = conn.execute("SELECT id,currency FROM accounts WHERE id=?", (snapshot["source_id"],)).fetchone()
+ if snapshot["source_type"] != "account" or account is None or snapshot["currency"] != account["currency"]:
+ conn.rollback(); return fail("validation_error", "snapshot account or currency is invalid")
+ if snapshot["monthly_settlement_id"] is not None:
+ conn.rollback(); return fail("already_linked", "account snapshot is already linked")
+ if snapshot["basis_date"] != settlement["basis_date"] and not allow_basis_date_mismatch:
+ conn.rollback(); return fail("basis_date_mismatch", "basis date mismatch requires explicit approval")
+ duplicate = conn.execute("SELECT id FROM account_snapshot WHERE monthly_settlement_id=? AND source_type='account' AND source_id=?", (monthly_settlement_id, snapshot["source_id"])).fetchone()
+ if duplicate:
+ conn.rollback(); return fail("duplicate", "account evaluation already registered", snapshot_id=duplicate["id"])
+ updated = conn.execute("UPDATE account_snapshot SET monthly_settlement_id=? WHERE id=? AND monthly_settlement_id IS NULL", (monthly_settlement_id, snapshot_id))
+ if updated.rowcount != 1:
+ conn.rollback(); return fail("stale_link", "account snapshot link is stale")
+ return ok(snapshot_id=snapshot_id, monthly_settlement_id=monthly_settlement_id, linked=True)
+ except Exception:
+ return fail("db_error", "account snapshot link failed")
+
+
+def unlink_account_snapshot(snapshot_id: str, monthly_settlement_id: str, db_path: str | None = None) -> dict[str, Any]:
+ """Detach exactly one account snapshot from one editable monthly settlement."""
+ if not isinstance(snapshot_id, str) or not snapshot_id.strip() or not isinstance(monthly_settlement_id, str) or not monthly_settlement_id.strip():
+ return fail("validation_error", "snapshot_id and monthly_settlement_id are required")
+ try:
+ with db(db_path) as conn:
+ conn.execute("BEGIN IMMEDIATE")
+ settlement = conn.execute("SELECT id,status FROM monthly_settlement WHERE id=?", (monthly_settlement_id,)).fetchone()
+ if settlement is None:
+ conn.rollback(); return fail("not_found", "monthly settlement not found")
+ if settlement["status"] not in {"draft", "in_review"}:
+ conn.rollback(); return fail("invalid_settlement_status", "only draft or in_review settlements can be changed")
+ snapshot = conn.execute(
+ "SELECT id,source_type,source_id,monthly_settlement_id FROM account_snapshot WHERE id=?", (snapshot_id,)
+ ).fetchone()
+ if snapshot is None:
+ conn.rollback(); return fail("not_found", "account snapshot not found")
+ if snapshot["source_type"] != "account":
+ conn.rollback(); return fail("validation_error", "snapshot source_type must be account")
+ if not snapshot["source_id"] or conn.execute("SELECT 1 FROM accounts WHERE id=?", (snapshot["source_id"],)).fetchone() is None:
+ conn.rollback(); return fail("validation_error", "snapshot account not found")
+ if snapshot["monthly_settlement_id"] != monthly_settlement_id:
+ conn.rollback(); return fail("validation_error", "snapshot is not linked to this monthly settlement")
+ result = conn.execute(
+ "UPDATE account_snapshot SET monthly_settlement_id=NULL WHERE id=? AND monthly_settlement_id=?",
+ (snapshot_id, monthly_settlement_id),
+ )
+ if result.rowcount != 1:
+ conn.rollback(); return fail("validation_error", "snapshot link is stale")
+ return ok(snapshot_id=snapshot_id, monthly_settlement_id=monthly_settlement_id, unlinked=True)
+ except Exception:
+ return fail("db_error", "account snapshot unlink failed")
+
+def list_monthly_settlement_history(month: str = "", db_path: str | None = None) -> dict[str, Any]:
+ return fail("unsupported", "run history unavailable")
+ """Return immutable close/correction records; lifecycle history is authoritative."""
+ try:
+ selected = normalize_month(month) if month else ""
+ with read_db(db_path) as conn:
+ rows = conn.execute(
+ """SELECT id AS run_id, settlement_month, draft_version AS version,
+ COALESCE(approved_at, created_at) AS closed_at,
+ correction_of_run_id AS correction_target,
+ correction_reason AS reason, close_status, created_at
+ FROM asset_snapshot_runs
+ WHERE (? = '' OR settlement_month = ? OR (settlement_month IS NULL AND substr(basis_date,1,7)=?))
+ ORDER BY COALESCE(approved_at, created_at) DESC, rowid DESC""",
+ (selected, selected, selected),
+ ).fetchall()
+ return ok(history=[dict(row) for row in rows], month=selected, read_only=True)
+ except DatabaseNotInitializedError:
+ return fail("db_not_initialized", "ledger database is not initialized")
+ except Exception:
+ return fail("operation_failed", "monthly settlement history unavailable")
+
+
+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")
+ try:
+ with read_db(db_path) as conn:
+ row = conn.execute(
+ """SELECT id AS run_id, settlement_month, draft_version AS version,
+ COALESCE(approved_at, created_at) AS closed_at,
+ correction_of_run_id AS correction_target,
+ correction_reason AS reason, close_status, created_at,
+ basis_date, income_total, expense_total, net_cashflow, net_worth
+ FROM asset_snapshot_runs WHERE id=?""", (run_id,)
+ ).fetchone()
+ if not row:
+ return fail("not_found", "settlement history record not found")
+ return ok(history=dict(row), read_only=True)
+ except DatabaseNotInitializedError:
+ return fail("db_not_initialized", "ledger database is not initialized")
+ except Exception:
+ return fail("operation_failed", "monthly settlement history unavailable")
+
+
def _budget_overrun_count(budget_report: dict[str, Any]) -> int:
return sum(1 for item in budget_report.get("items", []) if item.get("status") == "over")
@@ -137,6 +563,7 @@ 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")
@@ -155,26 +582,31 @@ 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 "asset_snapshots" not in tables:
+ if "asset_snapshot_runs" not in tables or "account_snapshot" 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(asset_snapshots)")}
+ cols = {r["name"] for r in conn.execute("PRAGMA table_info(account_snapshot)")}
if {"snapshot_run_id", "closed_at"} <= cols:
- rows = conn.execute("SELECT rowid AS _rowid,* FROM asset_snapshots WHERE snapshot_run_id=? AND closed_at<>'' ORDER BY rowid", (run_id,)).fetchall()
+ rows = conn.execute("SELECT rowid AS _rowid,* FROM account_snapshot 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:
+ 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 run is None:
basis = _month_end_date(selected_month) if selected_month else ""
@@ -218,7 +650,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 settlement_run_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 monthly_settlement_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 9c7a041..d900014 100644
--- a/jarca_ledger_mcp/application/reconciliation_service.py
+++ b/jarca_ledger_mcp/application/reconciliation_service.py
@@ -99,83 +99,24 @@ 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_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
+ valuation_status = "unavailable"
+ calc = _account_balance_row(conn, account["id"], effective_at, False)
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_cashflow_service.py b/jarca_ledger_mcp/application/settlement_cashflow_service.py
index ff56502..368ef10 100644
--- a/jarca_ledger_mcp/application/settlement_cashflow_service.py
+++ b/jarca_ledger_mcp/application/settlement_cashflow_service.py
@@ -2,6 +2,9 @@
from __future__ import annotations
from datetime import date
+import hashlib
+import json
+from decimal import Decimal
from .shared import CASHFLOW_KIND_SQL
@@ -10,6 +13,33 @@ _SNAPSHOT_SCHEMA_VERSION = 1
_ALLOWED_KINDS = {"income", "expense", "excluded"}
+def _canonical_value(value):
+ """Convert review inputs to a stable, JSON-safe representation."""
+ if isinstance(value, Decimal):
+ return format(value, "f")
+ if isinstance(value, dict):
+ return {str(k): _canonical_value(value[k]) for k in sorted(value)}
+ if isinstance(value, (list, tuple)):
+ return [_canonical_value(x) for x in value]
+ return value
+
+
+def cashflow_fingerprint(review: dict, month: str) -> str:
+ """Fingerprint policy, aggregation, and every included source row."""
+ payload = {
+ "month": month,
+ "aggregation_version": review.get("aggregation_version"),
+ "snapshot_schema_version": review.get("snapshot_schema_version"),
+ "cashflow_policy": review.get("cashflow_policy", {}),
+ "cashflow_items": review.get("cashflow_items", []),
+ "source_snapshot": review.get("source_snapshot", {}),
+ "blockers": review.get("cashflow_blockers", []),
+ }
+ encoded = json.dumps(_canonical_value(payload), ensure_ascii=False,
+ sort_keys=True, separators=(",", ":"), allow_nan=False)
+ return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
+
+
def _normalize_cashflow_policy(payload: dict) -> dict:
if not isinstance(payload, dict) or set(payload) - {"savings_categories", "category_decisions"}:
raise ValueError("cashflow policy must contain only supported keys")
@@ -120,7 +150,9 @@ def _review_cashflow(conn, month: str, policy: dict) -> dict:
else: expense += applied
items = [{"cashflow_kind": k, "category": c, "subcategory": s, "amount": v[0], "transaction_count": v[1], "currency": cur}
for (k, c, s, cur), v in sorted(grouped.items())]
- return {"cashflow_policy": policy, "aggregation_version": _AGGREGATION_VERSION,
+ result = {"cashflow_policy": policy, "aggregation_version": _AGGREGATION_VERSION,
"snapshot_schema_version": _SNAPSHOT_SCHEMA_VERSION, "income_total": income,
"expense_total": expense, "net_cashflow": income-expense, "cashflow_items": items,
"cashflow_blockers": blockers, "cashflow_warnings": [], "source_snapshot": {"transactions": source}}
+ result["cashflow_fingerprint"] = cashflow_fingerprint(result, month)
+ return result
diff --git a/jarca_ledger_mcp/application/settlement_draft_service.py b/jarca_ledger_mcp/application/settlement_draft_service.py
index f936006..6ce83b8 100644
--- a/jarca_ledger_mcp/application/settlement_draft_service.py
+++ b/jarca_ledger_mcp/application/settlement_draft_service.py
@@ -39,11 +39,10 @@ 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=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
+ val=None
+ child=None
return {'source':dict(src) if src else None,'valuation':dict(val) if val else None,'child':dict(child) if child else None}
- src=conn.execute('SELECT * FROM manual_assets WHERE id=?',(item['source_id'],)).fetchone()
- return {'source':dict(src) if src else None}
+ return {'source': None}
def _nonempty(value):
@@ -108,10 +107,8 @@ 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':
- # 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']
+ row['candidate_status'] = 'missing'
+ row['source_missing_warning'] = 'account valuation source unavailable'
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'
@@ -125,7 +122,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 = [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 []
+ candidates = []
states.append({'stored': item, 'current': current, 'candidates': candidates})
latest = _latest_close(conn, draft['basis_date'])
summary = _summarize_valuation_items(included)
@@ -147,7 +144,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, '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, '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'],
'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'],
@@ -177,17 +174,19 @@ 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 correction_of_run_id and not _nonempty(correction_reason): raise ValueError('correction reason required')
+ if bool(correction_of_run_id) != bool(correction_reason.strip()): raise ValueError('correction run and reason must be provided together')
with db(db_path) as c:
c.execute('BEGIN IMMEDIATE')
payload_hash=hashlib.sha256(json.dumps([month,created_by,basis,memo,correction_of_run_id,correction_reason],separators=(',',':')).encode()).hexdigest()
- old=c.execute('SELECT * FROM settlement_drafts WHERE create_key=?',(idempotency_key,)).fetchone()
+ old=c.execute("SELECT * FROM settlement_drafts WHERE create_key=? AND status <> 'cancelled'",(idempotency_key,)).fetchone()
if old:
if old['original_request_hash']!=payload_hash: return _err('conflict','idempotency key payload conflict')
return _result(c,old['id'])
if c.execute("SELECT 1 FROM settlement_drafts WHERE month=? AND status='active'",(month,)).fetchone(): return _err('conflict','active draft already exists for month')
latest = _latest_close(c, basis)
- if correction_of_run_id != latest:
+ 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:
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))
@@ -199,9 +198,6 @@ 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')
@@ -266,12 +262,10 @@ 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()
- 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}
+ selected = {}
existing = {(x['source_type'], x['source_id']): x for x in _items(c, draft_id)}
active = set()
- for typ, table in [('account', 'accounts'), ('manual_asset', 'manual_assets')]:
+ for typ, table in [('account', 'accounts')]:
for source in c.execute(f'SELECT id FROM {table} WHERE active=1 ORDER BY id').fetchall():
key = (typ, source['id'])
active.add(key)
@@ -298,8 +292,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):
- 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'):
+ 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'):
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')
@@ -334,7 +328,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:
- if valuation_id or selection_mode == 'manual_substitute': return _err('validation_error', 'manual asset supports exact or carried_before only')
+ return _err('unsupported', 'manual assets are no longer supported')
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')
@@ -379,6 +373,7 @@ 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:
@@ -409,16 +404,21 @@ 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; ts=now_iso(); basis=d['basis_date']; status='conditional' if excluded or r['contains_substitutions'] or r['contains_carried'] else 'complete_exact'
+ run=uuid4().hex; 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))
c.execute('INSERT INTO asset_snapshot_runs(id,basis_date,close_status,selection_policy,idempotency_key,approved_by,approved_at,approval_reason,approval_reference,correction_of_run_id,correction_reason,applied_evidence,excluded_evidence,carried_evidence,request_fingerprint,created_at,draft_id,draft_version,review_fingerprint,settlement_month,currency,income_total,expense_total,net_cashflow,total_assets,total_liabilities,net_worth,cashflow_policy_json,aggregation_version,snapshot_schema_version,source_snapshot_json) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',(run,basis,status,'exact',idempotency_key,d['approved_by'],d['approved_at'],d['approval_reason'],d['approval_reference'],d['correction_of_run_id'],d['correction_reason'],json.dumps(applied,sort_keys=True),json.dumps(excluded,sort_keys=True),json.dumps(carried,sort_keys=True),req,ts,draft_id,expected_version,review_fingerprint,d['month'],'KRW',r['income_total'],r['expense_total'],r['net_cashflow'],r['total_assets'],r['total_liabilities'],r['net_worth'],json.dumps(r['cashflow_policy'],sort_keys=True),r['aggregation_version'],r['snapshot_schema_version'],json.dumps(r['source_snapshot'],sort_keys=True)))
snapshot_ids = []
for x in applied + excluded:
sid = uuid4().hex
snapshot_ids.append(sid)
- c.execute('INSERT INTO asset_snapshots(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,valuation_source,source_note,memo,snapshot_run_id,correction_of_run_id,correction_reason,is_correction,closed_at,created_at,valuation_id,selection_policy,verification_status,evidence_kind,exclusion_reason) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',(sid,basis,x['source_type'],x['source_id'],x.get('name',''),x['amount'],x['currency'],x['liquidity_bucket'],x['selection_mode'],'','',run,d['correction_of_run_id'],d['correction_reason'],int(bool(d['correction_of_run_id'])),ts,ts,x.get('valuation_id') or '',x['selection_mode'],x.get('verification_status','unverified'),'excluded' if x in excluded else 'applied',x.get('exclusion_reason','')))
+ 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','')))
for item in r['cashflow_items']:
- c.execute('INSERT INTO settlement_cashflow_snapshots(id,settlement_run_id,cashflow_kind,category,subcategory,amount,transaction_count,currency) VALUES(?,?,?,?,?,?,?,?)', (uuid4().hex,run,item['cashflow_kind'],item['category'],item['subcategory'],item['amount'],item['transaction_count'],item['currency']))
- saved_groups = [dict(x) for x in c.execute('SELECT cashflow_kind,category,subcategory,amount,transaction_count,currency FROM settlement_cashflow_snapshots WHERE settlement_run_id=? ORDER BY cashflow_kind,category,subcategory,currency', (run,))]
+ 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,))]
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,7 +429,12 @@ 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')
- persisted_assets = [dict(x) for x in c.execute('SELECT * FROM asset_snapshots WHERE snapshot_run_id=? ORDER BY id', (run,))]
+ # 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,))]
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/shared.py b/jarca_ledger_mcp/application/shared.py
index c02c4d6..fc87ff5 100644
--- a/jarca_ledger_mcp/application/shared.py
+++ b/jarca_ledger_mcp/application/shared.py
@@ -29,7 +29,7 @@ from ..infrastructure.sqlite import DatabaseNotInitializedError
from ..infrastructure.sqlite import resolve_db_path as resolve_db_path
VALID_TRANSACTION_TYPES = {"income", "expense", "transfer", "refund", "adjustment"}
-VALID_ACCOUNT_TYPES = {"bank", "card", "cash", "investment", "loan", "other"}
+VALID_ACCOUNT_TYPES = {"bank", "card", "cash", "investment", "loan", "other", "real_estate"}
VALID_STATEMENT_STATUSES = {"unknown", "import_supported", "manual_only", "partial_import"}
VALID_LIQUIDITY_BUCKETS = {"cash", "investment", "deposit", "real_estate", "loan", "other"}
ASSET_BUCKETS = ["cash", "investment", "deposit", "real_estate", "other"]
diff --git a/jarca_ledger_mcp/application/system_services.py b/jarca_ledger_mcp/application/system_services.py
index ff1c630..7b97272 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", "manual_assets", "asset_snapshots", "review_decisions", "reconciliation_sessions"):
+ for table in ("accounts", "transactions", "budgets", "import_batches", "category_rules", "ledger_categories", "account_snapshot", "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/application/transaction_report_services.py b/jarca_ledger_mcp/application/transaction_report_services.py
index 58d21c7..8978cdf 100644
--- a/jarca_ledger_mcp/application/transaction_report_services.py
+++ b/jarca_ledger_mcp/application/transaction_report_services.py
@@ -68,13 +68,65 @@ def get_monthly_cashflow_report(month: str = "", db_path: str | None = None) ->
ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory
WHERE t.status='active' AND t.transaction_date>=? AND t.transaction_date
GROUP BY {CASHFLOW_KIND_SQL}""", (start, next_month)).fetchall()
+ category_rows = conn.execute(f"""SELECT {CASHFLOW_KIND_SQL} AS cashflow_kind,
+ t.category, t.subcategory, t.currency,
+ SUM(t.amount) AS amount, COUNT(*) AS count
+ FROM transactions t
+ LEFT JOIN ledger_categories lc
+ ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory
+ WHERE t.status='active' AND t.transaction_date>=? AND t.transaction_date
+ GROUP BY {CASHFLOW_KIND_SQL}, t.category, t.subcategory, t.currency
+ ORDER BY cashflow_kind, amount DESC""", (start, next_month)).fetchall()
type_totals = {r["type"]: int(r["total"] or 0) for r in type_rows}
cashflow_totals = {r["cashflow_kind"]: int(r["total"] or 0) for r in cashflow_rows}
income = cashflow_totals.get("income", 0)
expenses = cashflow_totals.get("expense", 0)
net_cashflow = income - expenses
savings_rate = round(net_cashflow / income, 4) if income else None
- return ok(month=month_norm, income=income, expenses=expenses, net_cashflow=net_cashflow, savings_rate=savings_rate, by_type=type_totals, by_cashflow_kind=cashflow_totals)
+ categories = [row_to_dict(r) for r in category_rows]
+ unknown_total = {}
+ unknown_category_items = []
+ for row in categories:
+ if row.get("cashflow_kind") == "unknown":
+ currency = row.get("currency") or "미지정"
+ unknown_total[currency] = unknown_total.get(currency, 0) + int(row.get("amount") or 0)
+ unknown_category_items.append(row)
+ return ok(month=month_norm, income=income, expenses=expenses, net_cashflow=net_cashflow, savings_rate=savings_rate, by_type=type_totals, by_cashflow_kind=cashflow_totals, categories=categories, unknown_total=unknown_total, unknown_category_items=unknown_category_items)
+
+@read_operation
+def get_settlement_cashflow_snapshot(monthly_settlement_id: str, db_path: str | None = None) -> dict[str, Any]:
+ """Read only the persisted cashflow rows for a closed settlement."""
+ with read_db(db_path) as conn:
+ rows = conn.execute("""SELECT cashflow_kind, category, subcategory, amount,
+ transaction_count AS count, currency
+ FROM settlement_cashflow_snapshots
+ WHERE monthly_settlement_id=?
+ ORDER BY cashflow_kind, category, subcategory, currency""", (monthly_settlement_id,)).fetchall()
+ settlement = conn.execute("SELECT status FROM monthly_settlement WHERE id=?", (monthly_settlement_id,)).fetchone()
+ categories = [row_to_dict(row) for row in rows]
+ if not categories:
+ saved = bool(settlement and settlement["status"] == "closed")
+ return ok(saved=saved, categories=[], income=0 if saved else None, expenses=0 if saved else None,
+ net_cashflow=0 if saved else None, month="", message="저장된 현금흐름이 비어 있습니다." if saved else "저장된 현금흐름 스냅샷이 없습니다.")
+ by_currency = {}
+ for row in categories:
+ cur = row.get("currency") or "미지정"
+ bucket = by_currency.setdefault(cur, {"income": 0, "expenses": 0, "net_cashflow": 0})
+ if row["cashflow_kind"] == "income": bucket["income"] += int(row["amount"] or 0)
+ elif row["cashflow_kind"] == "expense": bucket["expenses"] += int(row["amount"] or 0)
+ for bucket in by_currency.values(): bucket["net_cashflow"] = bucket["income"] - bucket["expenses"]
+ only = next(iter(by_currency.values())) if len(by_currency) == 1 else None
+ unknown_total = {}
+ unknown_category_items = []
+ for row in categories:
+ if row.get("cashflow_kind") == "unknown":
+ currency = row.get("currency") or "미지정"
+ unknown_total[currency] = unknown_total.get(currency, 0) + int(row.get("amount") or 0)
+ unknown_category_items.append(row)
+ return ok(saved=True, categories=categories, unknown_total=unknown_total, unknown_category_items=unknown_category_items, by_currency=by_currency,
+ income=only["income"] if only else None,
+ expenses=only["expenses"] if only else None,
+ net_cashflow=only["net_cashflow"] if only else None, month="")
@read_operation
def get_category_spending_report(month: str = "", start_date: str = "", end_date: str = "", person: str = "", db_path: str | None = None) -> dict[str, Any]:
diff --git a/jarca_ledger_mcp/infrastructure/sqlite/migrations.py b/jarca_ledger_mcp/infrastructure/sqlite/migrations.py
index 733306d..68a35c2 100644
--- a/jarca_ledger_mcp/infrastructure/sqlite/migrations.py
+++ b/jarca_ledger_mcp/infrastructure/sqlite/migrations.py
@@ -1,10 +1,61 @@
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})")}
@@ -16,8 +67,158 @@ 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,
@@ -83,22 +284,7 @@ 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 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 (
+ conn.execute("""CREATE TABLE IF NOT EXISTS account_snapshot (
id TEXT PRIMARY KEY,
basis_date TEXT NOT NULL,
source_type TEXT NOT NULL,
@@ -110,16 +296,15 @@ 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, "asset_snapshots", {
- "snapshot_run_id": "TEXT NOT NULL DEFAULT ''",
- "correction_of_run_id": "TEXT NOT NULL DEFAULT ''",
+ _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 ''",
"correction_reason": "TEXT NOT NULL DEFAULT ''",
"is_correction": "INTEGER NOT NULL DEFAULT 0",
"closed_at": "TEXT NOT NULL DEFAULT ''",
@@ -129,48 +314,13 @@ 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_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 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 TABLE IF NOT EXISTS review_decisions (
id TEXT PRIMARY KEY,
target_type TEXT NOT NULL,
@@ -205,28 +355,58 @@ 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)")
- 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
+ _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 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("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("INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', ?)", (SCHEMA_VERSION,))
def init_db(db_path: str | None = None) -> str:
path = resolve_db_path(db_path)
with connect(str(path)) as conn:
- conn.executescript(SCHEMA_SQL)
+ stored = _stored_version(conn)
+ if stored not in (None, "14", "15"):
+ if stored == "16" and _compatible_known_v16(conn):
+ pass
+ else:
+ 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.
+ _execute_script_transactional(conn, SCHEMA_SQL)
+ _migrate_cashflow_snapshots_to_monthly(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,))
+ conn.commit()
return str(path)
diff --git a/jarca_ledger_mcp/infrastructure/sqlite/schema.py b/jarca_ledger_mcp/infrastructure/sqlite/schema.py
index 617ee68..0e9a3c2 100644
--- a/jarca_ledger_mcp/infrastructure/sqlite/schema.py
+++ b/jarca_ledger_mcp/infrastructure/sqlite/schema.py
@@ -1,6 +1,6 @@
from __future__ import annotations
-SCHEMA_VERSION = "14"
+SCHEMA_VERSION = "15"
SCHEMA_SQL = """
PRAGMA journal_mode=WAL;
@@ -131,23 +131,7 @@ CREATE TABLE IF NOT EXISTS ledger_categories (
CREATE INDEX IF NOT EXISTS idx_ledger_categories_active_sort ON ledger_categories(active, sort_order, category, subcategory);
CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_categories_unique ON ledger_categories(category, subcategory);
-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 INDEX IF NOT EXISTS idx_manual_assets_active_bucket ON manual_assets(active, liquidity_bucket, name);
-CREATE INDEX IF NOT EXISTS idx_manual_assets_valuation_date ON manual_assets(valuation_date);
-
-CREATE TABLE IF NOT EXISTS asset_snapshots (
+CREATE TABLE IF NOT EXISTS account_snapshot (
id TEXT PRIMARY KEY,
basis_date TEXT NOT NULL,
source_type TEXT NOT NULL,
@@ -159,7 +143,7 @@ CREATE TABLE IF NOT EXISTS asset_snapshots (
valuation_source TEXT NOT NULL DEFAULT '',
source_note TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '',
- snapshot_run_id TEXT NOT NULL DEFAULT '',
+ monthly_settlement_id TEXT REFERENCES monthly_settlement(id) ON DELETE RESTRICT,
correction_of_run_id TEXT NOT NULL DEFAULT '',
correction_reason TEXT NOT NULL DEFAULT '',
is_correction INTEGER NOT NULL DEFAULT 0,
@@ -171,55 +155,20 @@ CREATE TABLE IF NOT EXISTS asset_snapshots (
exclusion_reason TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
-CREATE INDEX IF NOT EXISTS idx_asset_snapshots_basis_date ON asset_snapshots(basis_date);
-CREATE INDEX IF NOT EXISTS idx_asset_snapshots_source ON asset_snapshots(source_type, source_id);
-
-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 UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_idempotency ON asset_snapshot_runs(idempotency_key) WHERE idempotency_key <> '';
-CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_initial_basis ON asset_snapshot_runs(basis_date) WHERE correction_of_run_id = '';
-CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_correction_parent ON asset_snapshot_runs(correction_of_run_id) WHERE correction_of_run_id <> '';
-
+CREATE INDEX IF NOT EXISTS idx_account_snapshot_basis_date ON account_snapshot(basis_date);
+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,
- settlement_run_id TEXT NOT NULL REFERENCES asset_snapshot_runs(id) ON DELETE RESTRICT,
+ 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 (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_run ON settlement_cashflow_snapshots(settlement_run_id);
+CREATE INDEX IF NOT EXISTS idx_settlement_cashflow_snapshots_month ON settlement_cashflow_snapshots(monthly_settlement_id);
CREATE TABLE IF NOT EXISTS review_decisions (
id TEXT PRIMARY KEY,
@@ -257,41 +206,48 @@ CREATE TABLE IF NOT EXISTS reconciliation_sessions (
CREATE UNIQUE INDEX IF NOT EXISTS idx_reconciliation_sessions_account_basis ON reconciliation_sessions(account_id, basis_date);
CREATE INDEX IF NOT EXISTS idx_reconciliation_sessions_status ON reconciliation_sessions(status, closed_at);
-CREATE TABLE IF NOT EXISTS account_valuations (
+CREATE TABLE IF NOT EXISTS monthly_settlement (
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,
+ 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 '',
- 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
+ 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
+ ))
);
-CREATE INDEX IF NOT EXISTS idx_account_valuations_account_date ON account_valuations(account_id, basis_date, recorded_at);
-CREATE INDEX IF NOT EXISTS idx_account_valuations_status_date ON account_valuations(verification_status, basis_date);
-CREATE UNIQUE INDEX IF NOT EXISTS idx_account_valuations_idempotency ON account_valuations(account_id, idempotency_key) WHERE idempotency_key <> '';
-CREATE UNIQUE INDEX IF NOT EXISTS idx_account_valuations_single_correction ON account_valuations(supersedes_id) WHERE supersedes_id IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_month_settlement_status_month ON monthly_settlement(status, month);
-INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', '14');
+INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', '15');
"""
ACCOUNT_ADDITIVE_COLUMNS: dict[str, str] = {
diff --git a/pyproject.toml b/pyproject.toml
index b5dcf1d..6c6152a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -44,5 +44,4 @@ line-length = 240
select = ["E9", "F"]
[tool.setuptools.packages.find]
-include = ["jarca_ledger_mcp*"]
-exclude = ["dashboard*"]
+include = ["jarca_ledger_mcp*", "dashboard*"]
diff --git a/tests/fixtures/schema_v14.sql b/tests/fixtures/schema_v14.sql
new file mode 100644
index 0000000..f2db21c
--- /dev/null
+++ b/tests/fixtures/schema_v14.sql
@@ -0,0 +1,289 @@
+PRAGMA journal_mode=WAL;
+PRAGMA foreign_keys=ON;
+
+CREATE TABLE IF NOT EXISTS schema_meta (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS accounts (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ type TEXT NOT NULL DEFAULT 'bank',
+ institution TEXT NOT NULL DEFAULT '',
+ currency TEXT NOT NULL DEFAULT 'KRW',
+ active INTEGER NOT NULL DEFAULT 1,
+ memo TEXT NOT NULL DEFAULT '',
+ account_number_masked TEXT NOT NULL DEFAULT '',
+ holder_name TEXT NOT NULL DEFAULT '',
+ last_balance INTEGER,
+ last_balance_basis_date TEXT NOT NULL DEFAULT '',
+ last_synced_at TEXT NOT NULL DEFAULT '',
+ statement_status TEXT NOT NULL DEFAULT 'unknown',
+ liquidity_bucket TEXT NOT NULL DEFAULT 'other',
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS transactions (
+ id TEXT PRIMARY KEY,
+ transaction_date TEXT NOT NULL,
+ posted_date TEXT NOT NULL DEFAULT '',
+ type TEXT NOT NULL,
+ amount INTEGER NOT NULL,
+ currency TEXT NOT NULL DEFAULT 'KRW',
+ category TEXT NOT NULL DEFAULT '',
+ subcategory TEXT NOT NULL DEFAULT '',
+ merchant TEXT NOT NULL DEFAULT '',
+ description TEXT NOT NULL DEFAULT '',
+ account_id TEXT NOT NULL DEFAULT '',
+ payment_method TEXT NOT NULL DEFAULT '',
+ person TEXT NOT NULL DEFAULT '',
+ project TEXT NOT NULL DEFAULT '',
+ tags TEXT NOT NULL DEFAULT '[]',
+ is_fixed INTEGER NOT NULL DEFAULT 0,
+ source TEXT NOT NULL DEFAULT 'manual',
+ import_batch_id TEXT NOT NULL DEFAULT '',
+ external_id TEXT NOT NULL DEFAULT '',
+ dedupe_key TEXT NOT NULL DEFAULT '',
+ memo TEXT NOT NULL DEFAULT '',
+ status TEXT NOT NULL DEFAULT 'active',
+ transaction_datetime TEXT NOT NULL DEFAULT '',
+ counterparty TEXT NOT NULL DEFAULT '',
+ balance_after INTEGER,
+ branch TEXT NOT NULL DEFAULT '',
+ raw_summary TEXT NOT NULL DEFAULT '',
+ raw_payload TEXT NOT NULL DEFAULT '',
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(transaction_date);
+CREATE INDEX IF NOT EXISTS idx_transactions_category ON transactions(category, subcategory);
+CREATE INDEX IF NOT EXISTS idx_transactions_person ON transactions(person);
+CREATE INDEX IF NOT EXISTS idx_transactions_account ON transactions(account_id);
+CREATE UNIQUE INDEX IF NOT EXISTS idx_transactions_dedupe ON transactions(dedupe_key) WHERE dedupe_key <> '';
+
+CREATE TABLE IF NOT EXISTS budgets (
+ id TEXT PRIMARY KEY,
+ month TEXT NOT NULL,
+ category TEXT NOT NULL,
+ subcategory TEXT NOT NULL DEFAULT '',
+ person TEXT NOT NULL DEFAULT '',
+ amount INTEGER NOT NULL,
+ currency TEXT NOT NULL DEFAULT 'KRW',
+ memo TEXT NOT NULL DEFAULT '',
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_budgets_month ON budgets(month);
+CREATE UNIQUE INDEX IF NOT EXISTS idx_budgets_unique ON budgets(month, category, subcategory, person);
+
+CREATE TABLE IF NOT EXISTS import_batches (
+ id TEXT PRIMARY KEY,
+ source TEXT NOT NULL,
+ filename TEXT NOT NULL DEFAULT '',
+ imported_at TEXT NOT NULL,
+ row_count INTEGER NOT NULL DEFAULT 0,
+ status TEXT NOT NULL DEFAULT 'active',
+ memo TEXT NOT NULL DEFAULT ''
+);
+
+CREATE TABLE IF NOT EXISTS category_rules (
+ id TEXT PRIMARY KEY,
+ priority INTEGER NOT NULL DEFAULT 100,
+ match_field TEXT NOT NULL,
+ match_type TEXT NOT NULL DEFAULT 'contains',
+ pattern TEXT NOT NULL,
+ amount_min INTEGER,
+ amount_max INTEGER,
+ day_of_month INTEGER,
+ transaction_type TEXT NOT NULL DEFAULT '',
+ category TEXT NOT NULL,
+ subcategory TEXT NOT NULL DEFAULT '',
+ person TEXT NOT NULL DEFAULT '',
+ is_fixed INTEGER,
+ confidence REAL NOT NULL DEFAULT 1.0,
+ active INTEGER NOT NULL DEFAULT 1,
+ memo TEXT NOT NULL DEFAULT '',
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_category_rules_active_priority ON category_rules(active, priority);
+CREATE UNIQUE INDEX IF NOT EXISTS idx_category_rules_unique ON category_rules(match_field, match_type, pattern, transaction_type, category, subcategory, person);
+
+CREATE TABLE IF NOT EXISTS ledger_categories (
+ id TEXT PRIMARY KEY,
+ category TEXT NOT NULL,
+ subcategory TEXT NOT NULL DEFAULT '',
+ cashflow_kind TEXT NOT NULL DEFAULT 'expense',
+ active INTEGER NOT NULL DEFAULT 1,
+ sort_order INTEGER NOT NULL DEFAULT 100,
+ memo TEXT NOT NULL DEFAULT '',
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_ledger_categories_active_sort ON ledger_categories(active, sort_order, category, subcategory);
+CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_categories_unique ON ledger_categories(category, subcategory);
+
+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 INDEX IF NOT EXISTS idx_manual_assets_active_bucket ON manual_assets(active, liquidity_bucket, name);
+CREATE INDEX IF NOT EXISTS idx_manual_assets_valuation_date ON manual_assets(valuation_date);
+
+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 INDEX IF NOT EXISTS idx_asset_snapshots_basis_date ON asset_snapshots(basis_date);
+CREATE INDEX IF NOT EXISTS idx_asset_snapshots_source ON asset_snapshots(source_type, source_id);
+
+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 UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_idempotency ON asset_snapshot_runs(idempotency_key) WHERE idempotency_key <> '';
+CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_initial_basis ON asset_snapshot_runs(basis_date) WHERE correction_of_run_id = '';
+CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_correction_parent ON asset_snapshot_runs(correction_of_run_id) WHERE correction_of_run_id <> '';
+
+CREATE TABLE IF NOT EXISTS settlement_cashflow_snapshots (
+ id TEXT PRIMARY KEY,
+ settlement_run_id TEXT NOT NULL REFERENCES asset_snapshot_runs(id) ON DELETE RESTRICT,
+ cashflow_kind TEXT NOT NULL CHECK (cashflow_kind IN ('income', 'expense', 'excluded')),
+ category TEXT NOT NULL DEFAULT '',
+ subcategory TEXT NOT NULL DEFAULT '',
+ amount INTEGER NOT NULL CHECK (typeof(amount) = 'integer'),
+ transaction_count INTEGER NOT NULL CHECK (typeof(transaction_count) = 'integer' AND transaction_count > 0),
+ currency TEXT NOT NULL,
+ UNIQUE (settlement_run_id, cashflow_kind, category, subcategory, currency)
+);
+CREATE INDEX IF NOT EXISTS idx_settlement_cashflow_snapshots_run ON settlement_cashflow_snapshots(settlement_run_id);
+
+CREATE TABLE IF NOT EXISTS review_decisions (
+ id TEXT PRIMARY KEY,
+ target_type TEXT NOT NULL,
+ target_id TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'pending',
+ decision_type TEXT NOT NULL DEFAULT 'unknown',
+ reason_code TEXT NOT NULL DEFAULT '',
+ memo TEXT NOT NULL DEFAULT '',
+ operator TEXT NOT NULL DEFAULT 'operator',
+ decided_at TEXT NOT NULL DEFAULT '',
+ review_after TEXT NOT NULL DEFAULT '',
+ target_payload TEXT NOT NULL DEFAULT '{}',
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_review_decisions_target ON review_decisions(target_type, target_id, updated_at);
+CREATE INDEX IF NOT EXISTS idx_review_decisions_status ON review_decisions(status, review_after, updated_at);
+
+CREATE TABLE IF NOT EXISTS reconciliation_sessions (
+ id TEXT PRIMARY KEY,
+ account_id TEXT NOT NULL,
+ basis_date TEXT NOT NULL,
+ expected_balance INTEGER,
+ actual_balance INTEGER,
+ difference INTEGER,
+ status TEXT NOT NULL DEFAULT 'open',
+ closed_at TEXT NOT NULL DEFAULT '',
+ source_note TEXT NOT NULL DEFAULT '',
+ memo TEXT NOT NULL DEFAULT '',
+ operator TEXT NOT NULL DEFAULT 'operator',
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+CREATE UNIQUE INDEX IF NOT EXISTS idx_reconciliation_sessions_account_basis ON reconciliation_sessions(account_id, basis_date);
+CREATE INDEX IF NOT EXISTS idx_reconciliation_sessions_status ON reconciliation_sessions(status, closed_at);
+
+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
+);
+CREATE INDEX IF NOT EXISTS idx_account_valuations_account_date ON account_valuations(account_id, basis_date, recorded_at);
+CREATE INDEX IF NOT EXISTS idx_account_valuations_status_date ON account_valuations(verification_status, basis_date);
+CREATE UNIQUE INDEX IF NOT EXISTS idx_account_valuations_idempotency ON account_valuations(account_id, idempotency_key) WHERE idempotency_key <> '';
+CREATE UNIQUE INDEX IF NOT EXISTS idx_account_valuations_single_correction ON account_valuations(supersedes_id) WHERE supersedes_id IS NOT NULL;
+
+INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', '14');
diff --git a/tests/test_account_category_acceptance.py b/tests/test_account_category_acceptance.py
new file mode 100644
index 0000000..1b2d0d9
--- /dev/null
+++ b/tests/test_account_category_acceptance.py
@@ -0,0 +1,98 @@
+from __future__ import annotations
+
+import sqlite3
+
+import pytest
+from streamlit.testing.v1 import AppTest
+
+from jarca_ledger_mcp.application.account_valuation_service import (
+ register_account_valuation,
+ update_account_snapshot_category,
+)
+from jarca_ledger_mcp.application.monthly_settlement_service import create_monthly_settlement
+from jarca_ledger_mcp.infrastructure.sqlite.migrations import init_db
+
+
+def _ledger(tmp_path, *, account_type="bank", bucket="cash"):
+ path = tmp_path / "category.sqlite3"
+ init_db(str(path))
+ with sqlite3.connect(path) as conn:
+ conn.execute(
+ "INSERT INTO accounts(id,name,type,currency,liquidity_bucket,created_at,updated_at) VALUES(?,?,?,?,?,?,?)",
+ ("acct-1", "계좌", account_type, "KRW", bucket, "2026-10-01", "2026-10-01"),
+ )
+ conn.commit()
+ settlement = create_monthly_settlement("2026-10", db_path=str(path))["settlement_id"]
+ return path, settlement
+
+
+def test_registration_uses_account_default_and_rejects_invalid_explicit_override(tmp_path):
+ path, settlement = _ledger(tmp_path, bucket="deposit")
+ result = register_account_valuation("acct-1", "2026-10-31", 10, "KRW", monthly_settlement_id=settlement, db_path=str(path))
+ assert result["success"]
+ with sqlite3.connect(path) as conn:
+ assert conn.execute("SELECT liquidity_bucket FROM account_snapshot").fetchone() == ("deposit",)
+ invalid_dir = tmp_path / "invalid"
+ invalid_dir.mkdir()
+ path2, settlement2 = _ledger(invalid_dir, bucket="cash")
+ result = register_account_valuation("acct-1", "2026-10-31", 10, "KRW", monthly_settlement_id=settlement2, liquidity_bucket="bogus", db_path=str(path2))
+ assert result["success"] is False and result["error_code"] == "validation_error"
+
+
+def test_category_edit_only_changes_bucket_and_preserves_everything_else(tmp_path):
+ path, settlement = _ledger(tmp_path)
+ result = register_account_valuation("acct-1", "2026-10-31", 123, "KRW", memo="keep", monthly_settlement_id=settlement, db_path=str(path))
+ snapshot = result["snapshot_id"]
+ with sqlite3.connect(path) as conn:
+ before = conn.execute("SELECT * FROM account_snapshot WHERE id=?", (snapshot,)).fetchone()
+ changed = update_account_snapshot_category(snapshot, settlement, "loan", db_path=str(path))
+ assert changed["success"] and changed["liquidity_bucket"] == "loan"
+ with sqlite3.connect(path) as conn:
+ after = conn.execute("SELECT * FROM account_snapshot WHERE id=?", (snapshot,)).fetchone()
+ cols = [row[1] for row in conn.execute("PRAGMA table_info(account_snapshot)")]
+ assert dict(zip(cols, before)) | {"liquidity_bucket": "loan"} == dict(zip(cols, after))
+
+
+@pytest.mark.parametrize("status", ["draft", "in_review"])
+def test_category_edit_allows_draft_and_in_review(tmp_path, status):
+ path, settlement = _ledger(tmp_path)
+ snapshot = register_account_valuation("acct-1", "2026-10-31", 1, "KRW", monthly_settlement_id=settlement, db_path=str(path))["snapshot_id"]
+ with sqlite3.connect(path) as conn:
+ conn.execute("UPDATE monthly_settlement SET status=? WHERE id=?", (status, settlement))
+ conn.commit()
+ assert update_account_snapshot_category(snapshot, settlement, "investment", db_path=str(path))["success"]
+
+
+@pytest.mark.parametrize("status", ["approved", "closed"])
+def test_category_edit_blocks_approved_and_closed(tmp_path, status):
+ path, settlement = _ledger(tmp_path)
+ snapshot = register_account_valuation("acct-1", "2026-10-31", 1, "KRW", monthly_settlement_id=settlement, db_path=str(path))["snapshot_id"]
+ with sqlite3.connect(path) as conn:
+ conn.execute("UPDATE monthly_settlement SET status=?, approved_by='test', approved_at='2026-10-31', approved_version=1, approved_fingerprint='fp', closed_by=CASE WHEN ?='closed' THEN 'test' ELSE closed_by END, closed_at=CASE WHEN ?='closed' THEN '2026-10-31' ELSE closed_at END WHERE id=?", (status, status, status, settlement))
+ conn.commit()
+ result = update_account_snapshot_category(snapshot, settlement, "investment", db_path=str(path))
+ assert result["success"] is False and result["error_code"] == "invalid_settlement_status"
+
+
+def test_category_edit_rejects_wrong_monthly(tmp_path):
+ path, settlement = _ledger(tmp_path)
+ other = create_monthly_settlement("2026-11", db_path=str(path))["settlement_id"]
+ snapshot = register_account_valuation("acct-1", "2026-10-31", 1, "KRW", monthly_settlement_id=settlement, db_path=str(path))["snapshot_id"]
+ result = update_account_snapshot_category(snapshot, other, "investment", db_path=str(path))
+ assert result["success"] is False and result["error_code"] == "validation_error"
+
+
+def test_app_category_select_saves_clicked_snapshot_and_monthly(monkeypatch):
+ from jarca_ledger_mcp.adapters.streamlit import overview_gateway, settlement_gateway
+ calls = []
+ monkeypatch.setattr(overview_gateway, "get_monthly_settlement_summary", lambda *a, **k: {"success": True, "month": "2026-10", "selected_month": "2026-10", "basis_date": "2026-10-31", "reports": {"cashflow": {}, "budget": {}, "assets": {}}})
+ monkeypatch.setattr(settlement_gateway, "get_monthly_settlement_record", lambda *a, **k: {"success": True, "settlement": {"id": "ms-1", "month": "2026-10", "status": "draft"}})
+ monkeypatch.setattr(settlement_gateway, "list_account_snapshots_for_settlement", lambda *a, **k: {"success": True, "closed": False, "status": "draft", "count": 1, "registered_count": 1, "unregistered_count": 0, "snapshots": [{"account_id": "a1", "account_name": "대출", "amount": 100, "currency": "KRW", "basis_date": "2026-10-31", "snapshot_id": "snap-1", "display_status": "확인", "account_type": "loan", "account_liquidity_bucket": "loan", "snapshot_account_category": "loan"}]})
+ monkeypatch.setattr(settlement_gateway, "update_account_snapshot_category", lambda snapshot_id, monthly_id, value, **k: calls.append((snapshot_id, monthly_id, value)) or {"success": True})
+ app = AppTest.from_string("import streamlit as st\nst.query_params.update(page='monthly_settlement', id='ms-1')\nfrom dashboard.page_views.monthly_settlement import page_monthly_settlement\npage_monthly_settlement(db_path='unused.sqlite3')\n").run()
+ dialog = [b for b in app.button if b.label == "✎"][0].click().run()
+ assert dialog.selectbox[0].value == "loan"
+ save = [button for button in dialog.button if button.label == "저장"][0]
+ dialog.selectbox[0].set_value("deposit")
+ save.click().run()
+ assert save.label == "저장"
diff --git a/tests/test_account_evaluation_display.py b/tests/test_account_evaluation_display.py
new file mode 100644
index 0000000..e21c188
--- /dev/null
+++ b/tests/test_account_evaluation_display.py
@@ -0,0 +1,250 @@
+from __future__ import annotations
+
+import sqlite3
+
+from streamlit.testing.v1 import AppTest
+
+from jarca_ledger_mcp.application.monthly_settlement_service import (
+ create_monthly_settlement,
+ list_account_snapshots_for_settlement,
+)
+from jarca_ledger_mcp.infrastructure.sqlite.migrations import init_db
+from dashboard.page_views.monthly_settlement import _account_evaluation_totals, _format_evaluation_total
+
+
+def test_account_evaluation_totals_native_currency_null_and_zero():
+ result = _account_evaluation_totals([
+ {"account_id": "a1", "snapshot_id": "s1", "amount": 0, "currency": "KRW"},
+ {"account_id": "a2", "snapshot_id": "s2", "amount": -2500, "currency": "USD"},
+ {"account_id": "a3", "snapshot_id": "s3", "amount": None, "currency": "KRW"},
+ {"account_id": "a4", "snapshot_id": None, "amount": 999999, "currency": "KRW"},
+ ])
+ assert result["totals"] == {"KRW": 0, "USD": -2500}
+ assert result["unknown_count"] == 1
+ assert result["registered_count"] == 3
+ assert result["unregistered_count"] == 1
+ assert _format_evaluation_total("KRW", 0) == "0원"
+ assert _format_evaluation_total("USD", -2500) == "-2,500 USD"
+
+
+def test_account_evaluation_totals_missing_currency_is_separate():
+ result = _account_evaluation_totals([
+ {"account_id": "a1", "snapshot_id": "s1", "amount": 100, "currency": None},
+ {"account_id": "a2", "snapshot_id": None, "amount": None, "currency": "KRW"},
+ ])
+ assert result["totals"] == {"미지정": 100}
+
+
+
+def _account(conn, ident, name, currency="KRW"):
+ conn.execute(
+ "INSERT INTO accounts(id,name,currency,created_at,updated_at) VALUES(?,?,?,?,?)",
+ (ident, name, currency, "2026-10-01T00:00:00Z", "2026-10-01T00:00:00Z"),
+ )
+
+
+def _snapshot(conn, ident, settlement, account, name, amount, currency="KRW"):
+ conn.execute(
+ """INSERT INTO account_snapshot
+ (id,basis_date,source_type,source_id,name,amount,currency,monthly_settlement_id,created_at)
+ VALUES(?,?,?,?,?,?,?,?,?)""",
+ (ident, "2026-10-31", "account", account, name, amount, currency, settlement, "2026-10-31T00:00:00Z"),
+ )
+
+
+def test_draft_uses_all_accounts_and_exact_monthly_snapshot(tmp_path):
+ path = tmp_path / "ledger.sqlite3"
+ init_db(str(path))
+ settlement = create_monthly_settlement("2026-10", db_path=str(path))["settlement_id"]
+ with sqlite3.connect(path) as conn:
+ _account(conn, "a1", "계좌1")
+ _account(conn, "a2", "계좌2")
+ _snapshot(conn, "s1", settlement, "a1", "계좌1", 0)
+ _snapshot(conn, "other", "other-month", "a2", "계좌2", 99)
+ conn.commit()
+ result = list_account_snapshots_for_settlement(settlement, db_path=str(path))
+ assert [(x["source_id"], x["amount"], x["display_status"]) for x in result["snapshots"]] == [("a1", 0, "확인"), ("a2", None, "미등록")]
+ assert result["count"] == 2 and result["registered_count"] == 1 and result["unregistered_count"] == 1
+
+
+def test_closed_uses_historical_rows_only_and_preserves_null_zero_and_duplicate(tmp_path):
+ path = tmp_path / "ledger.sqlite3"
+ init_db(str(path))
+ settlement = create_monthly_settlement("2026-10", db_path=str(path))["settlement_id"]
+ with sqlite3.connect(path) as conn:
+ _account(conn, "old", "과거계좌", "USD")
+ _snapshot(conn, "s-null", settlement, "old", "보존이름", None, "USD")
+ _snapshot(conn, "s-zero", settlement, "old", "보존이름", 0, "USD")
+ conn.execute("UPDATE monthly_settlement SET status='closed', approved_by='test', approved_at='2026-10-31T00:00:00Z', approved_version=1, approved_fingerprint='fp', closed_by='test', closed_at='2026-10-31T00:00:00Z' WHERE id=?", (settlement,))
+ _account(conn, "new", "신규계좌")
+ conn.commit()
+ result = list_account_snapshots_for_settlement(settlement, db_path=str(path))
+ assert result["closed"] is True
+ assert [x["amount"] for x in result["snapshots"]] == [None, 0]
+ assert [x["display_status"] for x in result["snapshots"]] == ["미확인", "확인"]
+ assert all(x["name"] == "보존이름" for x in result["snapshots"])
+ assert all(x["source_id"] != "new" for x in result["snapshots"])
+
+
+def test_monthly_renderer_opens_real_registration_dialog_from_actual_button(monkeypatch):
+ from jarca_ledger_mcp.adapters.streamlit import overview_gateway, settlement_gateway
+
+ monkeypatch.setattr(
+ overview_gateway,
+ "get_monthly_settlement_summary",
+ lambda *args, **kwargs: {
+ "success": True,
+ "month": "2026-10",
+ "selected_month": "2026-10",
+ "basis_date": "2026-10-31",
+ "actual_income": 0,
+ "actual_expense": 0,
+ "net_cashflow": 0,
+ "reports": {"cashflow": {}, "budget": {}, "assets": {}},
+ },
+ )
+ monkeypatch.setattr(
+ settlement_gateway,
+ "get_monthly_settlement_record",
+ lambda *args, **kwargs: {"success": True, "settlement": {"id": "ms-1", "month": "2026-10", "status": "draft"}},
+ )
+ monkeypatch.setattr(
+ settlement_gateway,
+ "list_account_snapshots_for_settlement",
+ lambda *args, **kwargs: {
+ "success": True,
+ "closed": False,
+ "count": 3,
+ "registered_count": 2,
+ "unregistered_count": 1,
+ "snapshots": [
+ {"account_id": "acct-1", "account_name": "주계좌", "amount": 1630800, "currency": "KRW", "basis_date": "2026-10-31", "display_status": "확인", "snapshot_id": "snap-1", "account_type": "bank", "account_liquidity_bucket": "bank"},
+ {"account_id": "acct-2", "account_name": "임시 주택담보대출", "amount": 1562300, "currency": "KRW", "basis_date": "2026-10-31", "display_status": "확인", "snapshot_id": "snap-2", "account_type": "loan", "account_liquidity_bucket": "loan"},
+ {"account_id": "acct-3", "account_name": "보조계좌", "amount": None, "currency": "USD", "basis_date": None, "display_status": "미등록", "snapshot_id": None},
+ ],
+ },
+ )
+
+ app = AppTest.from_string(
+ "import streamlit as st\n"
+ "st.query_params.update(page='monthly_settlement', id='ms-1')\n"
+ "from dashboard.page_views.monthly_settlement import page_monthly_settlement\n"
+ "page_monthly_settlement(db_path='synthetic-unused.sqlite3')\n",
+ default_timeout=30,
+ ).run()
+ assert not app.exception
+ assert any(metric.label == "자산 합계 (KRW)" and metric.value == "1,630,800원" for metric in app.metric)
+ assert any(metric.label == "부채 합계 (KRW)" and metric.value == "1,562,300원" for metric in app.metric)
+ assert any(metric.label == "순자산 (KRW)" and metric.value == "68,500원" for metric in app.metric)
+ metric_columns = [column for column in app.columns if column.metric]
+ assert len(metric_columns) == 3
+ assert [column.metric[0].label for column in metric_columns] == [
+ "자산 합계 (KRW)", "부채 합계 (KRW)", "순자산 (KRW)",
+ ]
+ assert any("평가등록 2 / 전체 3" in caption.value and "미등록 1" in caption.value for caption in app.caption)
+ assert not [expander for expander in app.expander if expander.label == "필요 메모/근거"]
+ account_buttons = [button for button in app.button if button.label == "보조계좌"]
+ assert len(account_buttons) == 1
+ assert not [button for button in app.button if button.label == "평가 등록"]
+
+ dialog = account_buttons[0].click().run()
+ assert not dialog.exception
+ inputs = {widget.label: widget for widget in dialog.text_input}
+ assert inputs["계정"].value == "보조계좌"
+ assert inputs["계정"].disabled is True
+ assert inputs["통화"].value == "USD"
+ assert dialog.date_input[0].value.isoformat() == "2026-10-31"
+ assert "평가금액 (정수)" in inputs
+ assert dialog.text_area[0].label == "메모 (선택)"
+
+
+def test_monthly_renderer_preserves_ledger_projection_and_source_choices(monkeypatch):
+ from jarca_ledger_mcp.adapters.streamlit import overview_gateway, settlement_gateway
+
+ monkeypatch.setattr(
+ overview_gateway,
+ "get_monthly_settlement_summary",
+ lambda *args, **kwargs: {
+ "success": True, "month": "2026-10", "selected_month": "2026-10",
+ "basis_date": "2026-10-31", "reports": {"cashflow": {}, "budget": {}, "assets": {}},
+ },
+ )
+ monkeypatch.setattr(
+ settlement_gateway,
+ "get_monthly_settlement_record",
+ lambda *args, **kwargs: {"success": True, "settlement": {"id": "ms-10", "month": "2026-10", "status": "draft"}},
+ )
+ monkeypatch.setattr(
+ settlement_gateway,
+ "list_account_snapshots_for_settlement",
+ lambda *args, **kwargs: {
+ "success": True, "closed": False, "count": 1, "registered_count": 0,
+ "unregistered_count": 1,
+ "snapshots": [{
+ "account_id": "acct-10", "account_name": "실제계정10", "amount": None,
+ "currency": "KRW", "basis_date": None, "display_status": "미등록",
+ "snapshot_id": None,
+ "previous_ledger": {"id": "prev-10", "transaction_date": "2026-10-30", "balance_after": 1234, "currency": "KRW"},
+ "next_ledger": {"id": "next-10", "transaction_date": "2026-11-01", "balance_after": 2345, "currency": "KRW"},
+ }],
+ },
+ )
+
+ app = AppTest.from_string(
+ "import streamlit as st\n"
+ "st.query_params.update(page='monthly_settlement', id='ms-10')\n"
+ "from dashboard.page_views.monthly_settlement import page_monthly_settlement\n"
+ "page_monthly_settlement(db_path='synthetic-unused.sqlite3')\n",
+ default_timeout=30,
+ ).run()
+ assert not app.exception
+ dialog = [button for button in app.button if button.label == "실제계정10"][0].click().run()
+ assert not dialog.exception
+ source = dialog.selectbox[0]
+ assert len(source.options) == 3
+ assert source.value.startswith("이전 기록")
+ inputs = {widget.label: widget for widget in dialog.text_input}
+ assert inputs["평가금액 (정수)"].value == "1234"
+ assert inputs["평가금액 (정수)"].disabled is True
+
+ assert any(option.startswith("이후 기록") for option in source.options)
+ assert inputs["평가금액 (정수)"].disabled is True
+
+
+def test_monthly_renderer_mixed_rows_keeps_snapshot_id_for_unlink(monkeypatch):
+ from jarca_ledger_mcp.adapters.streamlit import overview_gateway, settlement_gateway
+
+ monkeypatch.setattr(overview_gateway, "get_monthly_settlement_summary", lambda *args, **kwargs: {
+ "success": True, "month": "2026-10", "selected_month": "2026-10",
+ "basis_date": "2026-10-31", "reports": {"cashflow": {}, "budget": {}, "assets": {}},
+ })
+ monkeypatch.setattr(settlement_gateway, "get_monthly_settlement_record", lambda *args, **kwargs: {
+ "success": True, "settlement": {"id": "ms-mixed", "month": "2026-10", "status": "draft"},
+ })
+ monkeypatch.setattr(settlement_gateway, "list_account_snapshots_for_settlement", lambda *args, **kwargs: {
+ "success": True, "closed": False, "status": "draft", "count": 2,
+ "registered_count": 1, "unregistered_count": 1,
+ "snapshots": [
+ {"account_id": "acct-1", "account_name": "등록계정", "amount": 100,
+ "currency": "KRW", "basis_date": "2026-10-31", "display_status": "확인",
+ "snapshot_id": "snap-1"},
+ {"account_id": "acct-2", "account_name": "미등록계정", "amount": None,
+ "currency": "USD", "basis_date": None, "display_status": "미등록",
+ "snapshot_id": None},
+ ],
+ })
+
+ app = AppTest.from_string(
+ "import streamlit as st\n"
+ "st.query_params.update(page='monthly_settlement', id='ms-mixed')\n"
+ "from dashboard.page_views.monthly_settlement import page_monthly_settlement\n"
+ "page_monthly_settlement(db_path='synthetic-unused.sqlite3')\n",
+ default_timeout=30,
+ ).run()
+ assert not app.exception
+ unlink = [button for button in app.button if button.label == "×"]
+ assert len(unlink) == 1
+ assert unlink[0].help == "연결 해제"
+ dialog = unlink[0].click().run()
+ assert not dialog.exception
+ assert any("등록계정" in markdown.value for markdown in dialog.markdown)
diff --git a/tests/test_account_snapshot_linking.py b/tests/test_account_snapshot_linking.py
new file mode 100644
index 0000000..379aae5
--- /dev/null
+++ b/tests/test_account_snapshot_linking.py
@@ -0,0 +1,114 @@
+from __future__ import annotations
+
+import sqlite3
+
+import pytest
+
+from jarca_ledger_mcp.application.monthly_settlement_service import (
+ create_monthly_settlement,
+ link_account_snapshot,
+ list_account_snapshots_for_settlement,
+)
+from jarca_ledger_mcp.infrastructure.sqlite.migrations import init_db
+
+
+def _account(conn, ident="acct-1", name="실제계정", currency="KRW"):
+ conn.execute(
+ "INSERT INTO accounts(id,name,currency,created_at,updated_at) VALUES(?,?,?,?,?)",
+ (ident, name, currency, "2026-10-01T00:00:00Z", "2026-10-01T00:00:00Z"),
+ )
+
+
+def _snapshot(conn, ident, *, account="acct-1", basis_date="2026-10-31", currency="KRW", settlement=None, amount=1234):
+ conn.execute(
+ """INSERT INTO account_snapshot
+ (id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,
+ monthly_settlement_id,valuation_id,verification_status,created_at)
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""",
+ (ident, basis_date, "account", account, "원본 스냅샷명", amount, currency, "cash", settlement,
+ "valuation-1", "unverified", "2026-10-31T00:00:00Z"),
+ )
+
+
+def _ledger(tmp_path):
+ path = tmp_path / "link.sqlite3"
+ init_db(str(path))
+ with sqlite3.connect(path) as conn:
+ draft = create_monthly_settlement("2026-10", db_path=str(path))["settlement_id"]
+ conn.execute("UPDATE monthly_settlement SET basis_date='2026-10-31' WHERE id=?", (draft,))
+ _account(conn)
+ conn.commit()
+ return path, draft
+
+
+def test_link_updates_only_monthly_fk_and_preserves_snapshot_fields(tmp_path):
+ path, settlement = _ledger(tmp_path)
+ with sqlite3.connect(path) as conn:
+ _snapshot(conn, "candidate")
+ before = conn.execute("SELECT * FROM account_snapshot WHERE id='candidate'").fetchone()
+ columns = [row[1] for row in conn.execute("PRAGMA table_info(account_snapshot)")]
+ result = link_account_snapshot("candidate", settlement, db_path=str(path))
+ assert result["success"] is True
+ with sqlite3.connect(path) as conn:
+ after = conn.execute("SELECT * FROM account_snapshot WHERE id='candidate'").fetchone()
+ assert len(before) == len(after) == len(columns)
+ changed = {column: (old, new) for column, old, new in zip(columns, before, after) if old != new}
+ assert changed == {"monthly_settlement_id": (None, settlement)}
+
+
+def test_actual_read_function_returns_unlinked_candidates_in_exact_order(tmp_path):
+ path, settlement = _ledger(tmp_path)
+ with sqlite3.connect(path) as conn:
+ _snapshot(conn, "old", basis_date="2026-09-30", amount=1)
+ _snapshot(conn, "exact-old-created", basis_date="2026-10-31", amount=2)
+ _snapshot(conn, "exact-new-created", basis_date="2026-10-31", amount=3)
+ conn.execute("UPDATE account_snapshot SET created_at='2026-10-31T02:00:00Z' WHERE id='exact-old-created'")
+ conn.execute("UPDATE account_snapshot SET created_at='2026-10-31T03:00:00Z' WHERE id='exact-new-created'")
+ conn.commit()
+ result = list_account_snapshots_for_settlement(settlement, db_path=str(path))
+ row = result["snapshots"][0]
+ assert [item["snapshot_id"] for item in row["unlinked_candidates"]] == ["exact-new-created", "exact-old-created", "old"]
+
+@pytest.mark.parametrize("status", ["approved", "closed"])
+def test_link_rejects_approved_and_closed(tmp_path, status):
+ path, settlement = _ledger(tmp_path)
+ with sqlite3.connect(path) as conn:
+ _snapshot(conn, "candidate")
+ conn.execute("""UPDATE monthly_settlement SET status=?, approved_by='test',
+ approved_at='2026-10-31T00:00:00Z', approved_version=1,
+ approved_fingerprint='test', closed_by='test', closed_at='2026-10-31T00:00:00Z' WHERE id=?""", (status, settlement))
+ conn.commit()
+ result = link_account_snapshot("candidate", settlement, db_path=str(path))
+ assert result["success"] is False
+ assert result["error_code"] == "invalid_settlement_status"
+
+
+def test_link_rejects_unrelated_month_and_already_linked_snapshot(tmp_path):
+ path, settlement = _ledger(tmp_path)
+ with sqlite3.connect(path) as conn:
+ other = create_monthly_settlement("2026-11", db_path=str(path))["settlement_id"]
+ _snapshot(conn, "linked-other", settlement=other)
+ _snapshot(conn, "linked-this", settlement=settlement)
+ conn.commit()
+ assert link_account_snapshot("linked-other", settlement, db_path=str(path))["error_code"] == "already_linked"
+ assert link_account_snapshot("linked-this", settlement, db_path=str(path))["error_code"] == "already_linked"
+
+
+def test_basis_date_mismatch_requires_explicit_true(tmp_path):
+ path, settlement = _ledger(tmp_path)
+ with sqlite3.connect(path) as conn:
+ _snapshot(conn, "mismatch", basis_date="2026-09-30")
+ conn.commit()
+ rejected = link_account_snapshot("mismatch", settlement, db_path=str(path))
+ assert rejected["error_code"] == "basis_date_mismatch"
+ accepted = link_account_snapshot("mismatch", settlement, allow_basis_date_mismatch=True, db_path=str(path))
+ assert accepted["success"] is True
+
+
+def test_link_rejects_account_currency_mismatch(tmp_path):
+ path, settlement = _ledger(tmp_path)
+ with sqlite3.connect(path) as conn:
+ _snapshot(conn, "bad-currency", currency="USD")
+ conn.commit()
+ result = link_account_snapshot("bad-currency", settlement, db_path=str(path))
+ assert result["error_code"] == "validation_error"
diff --git a/tests/test_account_snapshot_linking_display.py b/tests/test_account_snapshot_linking_display.py
new file mode 100644
index 0000000..cc4235b
--- /dev/null
+++ b/tests/test_account_snapshot_linking_display.py
@@ -0,0 +1,47 @@
+from __future__ import annotations
+
+from streamlit.testing.v1 import AppTest
+
+
+def test_registration_dialog_exposes_real_account_snapshot_link_mode_and_fallback(monkeypatch):
+ from jarca_ledger_mcp.adapters.streamlit import overview_gateway, settlement_gateway
+
+ monkeypatch.setattr(overview_gateway, "get_monthly_settlement_summary", lambda *args, **kwargs: {
+ "success": True, "month": "2026-10", "selected_month": "2026-10", "basis_date": "2026-10-31",
+ "reports": {"cashflow": {}, "budget": {}, "assets": {}},
+ })
+ monkeypatch.setattr(settlement_gateway, "get_monthly_settlement_record", lambda *args, **kwargs: {
+ "success": True, "settlement": {"id": "ms-link-ui", "month": "2026-10", "basis_date": "2026-10-31", "status": "draft"},
+ })
+ monkeypatch.setattr(settlement_gateway, "list_account_snapshots_for_settlement", lambda *args, **kwargs: {
+ "success": True, "closed": False, "status": "draft", "count": 1, "registered_count": 0,
+ "unregistered_count": 1,
+ "snapshots": [{
+ "account_id": "acct-ui", "account_name": "실제 원본계정", "amount": None, "currency": "KRW",
+ "basis_date": None, "display_status": "미등록", "snapshot_id": None,
+ "unlinked_candidates": [
+ {"snapshot_id": "snap-exact", "basis_date": "2026-10-31", "amount": 777, "currency": "KRW", "verification_status": "unverified"},
+ ],
+ "previous_ledger": {"id": "prev", "transaction_date": "2026-10-30", "balance_after": 1234, "currency": "KRW"},
+ "next_ledger": {"id": "next", "transaction_date": "2026-11-01", "balance_after": 2345, "currency": "KRW"},
+ }],
+ })
+
+ app = AppTest.from_string(
+ "import streamlit as st\n"
+ "st.query_params.update(page='monthly_settlement', id='ms-link-ui')\n"
+ "from dashboard.page_views.monthly_settlement import page_monthly_settlement\n"
+ "page_monthly_settlement(db_path='synthetic-unused.sqlite3')\n",
+ default_timeout=30,
+ ).run()
+ assert not app.exception
+ dialog = [button for button in app.button if button.label == "실제 원본계정"][0].click().run()
+ assert not dialog.exception
+ assert {widget.label: widget for widget in dialog.text_input}["계정"].value == "실제 원본계정"
+ assert dialog.radio[0].value == "기존 스냅샷 연결"
+ assert dialog.selectbox[0].label == "기존 스냅샷"
+ assert "777" in dialog.selectbox[0].options[0]
+ assert {widget.label: widget for widget in dialog.text_input}["선택 금액"].disabled is True
+
+ fallback = dialog.radio[0].set_value("새 평가 등록").run()
+ assert not fallback.exception
diff --git a/tests/test_account_valuation_edge_contracts.py b/tests/test_account_valuation_edge_contracts.py
deleted file mode 100644
index 0b08cb3..0000000
--- a/tests/test_account_valuation_edge_contracts.py
+++ /dev/null
@@ -1,231 +0,0 @@
-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
new file mode 100644
index 0000000..a9bcab2
--- /dev/null
+++ b/tests/test_account_valuation_service_actual.py
@@ -0,0 +1,109 @@
+from __future__ import annotations
+
+import sqlite3
+
+import pytest
+
+from jarca_ledger_mcp.application.account_valuation_service import register_account_valuation
+from jarca_ledger_mcp.application.monthly_settlement_service import create_monthly_settlement
+from jarca_ledger_mcp.infrastructure.sqlite.migrations import init_db
+
+
+@pytest.fixture
+def ledger(tmp_path):
+ path = tmp_path / "actual-service.sqlite3"
+ init_db(str(path))
+ with sqlite3.connect(path) as conn:
+ conn.execute(
+ "INSERT INTO accounts(id,name,currency,created_at,updated_at) VALUES(?,?,?,?,?)",
+ ("acct-1", "주계좌", "KRW", "2026-10-01T00:00:00Z", "2026-10-01T00:00:00Z"),
+ )
+ conn.commit()
+ settlement = create_monthly_settlement("2026-10", db_path=str(path))
+ return path, settlement["settlement_id"]
+
+
+@pytest.mark.parametrize(
+ ("amount", "expected_amount"),
+ [(0, 0), (123456, 123456)],
+)
+def test_actual_service_saves_zero_and_amount_with_fk_and_unverified(ledger, amount, expected_amount):
+ path, settlement_id = ledger
+ result = register_account_valuation(
+ "acct-1", "2026-10-31", amount, "KRW", monthly_settlement_id=settlement_id, db_path=str(path)
+ )
+ assert result["success"] is True
+ with sqlite3.connect(path) as conn:
+ row = conn.execute(
+ "SELECT amount,currency,basis_date,monthly_settlement_id,verification_status,source_type,source_id FROM account_snapshot"
+ ).fetchone()
+ assert row == (expected_amount, "KRW", "2026-10-31", settlement_id, "unverified", "account", "acct-1")
+
+
+@pytest.mark.parametrize("existing_amount", [None, 0])
+def test_actual_service_rejects_duplicate_even_when_existing_amount_is_null_or_zero(ledger, existing_amount):
+ path, settlement_id = ledger
+ with sqlite3.connect(path) as conn:
+ conn.execute(
+ """INSERT INTO account_snapshot
+ (id,basis_date,source_type,source_id,name,amount,currency,monthly_settlement_id,created_at)
+ VALUES(?,?,?,?,?,?,?,?,?)""",
+ ("existing", "2026-10-31", "account", "acct-1", "주계좌", existing_amount, "KRW", settlement_id, "2026-10-31T00:00:00Z"),
+ )
+ conn.commit()
+ result = register_account_valuation(
+ account_id="acct-1", basis_date="2026-10-31", amount=99, currency="KRW",
+ monthly_settlement_id=settlement_id, db_path=str(path),
+ )
+ assert result == {
+ "success": False,
+ "error_code": "duplicate",
+ "error": "account evaluation already registered",
+ "snapshot_id": "existing",
+ }
+
+
+@pytest.mark.parametrize("status", ["closed", "approved"])
+def test_actual_service_blocks_closed_and_approved_settlements(ledger, status):
+ path, settlement_id = ledger
+ with sqlite3.connect(path) as conn:
+ conn.execute(
+ """UPDATE monthly_settlement
+ SET status=?, approved_by='test', approved_at='2026-10-31T00:00:00Z',
+ approved_version=1, approved_fingerprint='fp',
+ closed_by=CASE WHEN ?='closed' THEN 'test' ELSE closed_by END,
+ closed_at=CASE WHEN ?='closed' THEN '2026-10-31T00:00:00Z' ELSE closed_at END
+ WHERE id=?""",
+ (status, status, status, settlement_id),
+ )
+ conn.commit()
+ result = register_account_valuation(
+ "acct-1", "2026-10-31", 10, "KRW", monthly_settlement_id=settlement_id, db_path=str(path)
+ )
+ assert result["success"] is False
+ assert result["error_code"] in {"closed_settlement", "invalid_settlement_status"}
+ with sqlite3.connect(path) as conn:
+ assert conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0] == 0
+
+
+@pytest.mark.parametrize(
+ ("kwargs", "error_code"),
+ [
+ ({"currency": "USD"}, "validation_error"),
+ ({"basis_date": "2026-09-30"}, "validation_error"),
+ ({"monthly_settlement_id": "missing"}, "not_found"),
+ ],
+)
+def test_actual_service_validates_currency_date_and_settlement_and_rolls_back(ledger, kwargs, error_code):
+ path, settlement_id = ledger
+ args = {
+ "account_id": "acct-1", "basis_date": "2026-10-31", "amount": 10, "currency": "KRW",
+ "monthly_settlement_id": settlement_id, "db_path": str(path),
+ }
+ args.update(kwargs)
+ result = register_account_valuation(**args)
+ assert result["success"] is False
+ assert result["error_code"] == error_code
+ 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
diff --git a/tests/test_account_valuations.py b/tests/test_account_valuations.py
deleted file mode 100644
index ee33472..0000000
--- a/tests/test_account_valuations.py
+++ /dev/null
@@ -1,102 +0,0 @@
-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 == "14"
- assert {"id", "account_id", "basis_date", "amount", "currency", "valuation_kind", "source_type", "source_ref", "verification_status", "recorded_at", "memo", "idempotency_key", "supersedes_id", "correction_reason", "as_of_at", "timezone", "cost_basis", "net_contributed", "reported_principal_kind", "reported_principal_label", "profit", "return_rate", "return_basis", "principal", "interest", "fee", "representative_liability_definition"} <= cols
-
-
-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
diff --git a/tests/test_account_valuations_removed.py b/tests/test_account_valuations_removed.py
new file mode 100644
index 0000000..b460a68
--- /dev/null
+++ b/tests/test_account_valuations_removed.py
@@ -0,0 +1,111 @@
+from __future__ import annotations
+
+import sqlite3
+
+from jarca_ledger_mcp.application.account_valuation_service import (
+ list_account_valuations,
+ register_account_valuation,
+)
+from jarca_ledger_mcp.application.assets_service import (
+ create_manual_asset,
+ list_manual_assets,
+ preview_asset_snapshot_close,
+)
+from jarca_ledger_mcp.application.settlement_draft_service import create_settlement_draft
+from jarca_ledger_mcp.infrastructure.sqlite.migrations import init_db
+
+
+def _tables(path):
+ with sqlite3.connect(path) as conn:
+ return {r[0] for r in conn.execute("select name from sqlite_master where type='table'")}
+
+
+EXPECTED_TABLES = {
+ # Sourced from schema.py plus migration-created settlement draft tables.
+ "schema_meta",
+ "accounts",
+ "transactions",
+ "budgets",
+ "import_batches",
+ "category_rules",
+ "ledger_categories",
+
+ "account_snapshot",
+ "settlement_cashflow_snapshots",
+ "review_decisions",
+ "reconciliation_sessions",
+ "monthly_settlement",
+ "settlement_drafts",
+ "settlement_draft_items",
+}
+
+
+def test_fresh_init_has_no_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 tables == EXPECTED_TABLES
+
+
+def test_init_is_idempotent(tmp_path):
+ path = tmp_path / "repeat.sqlite3"
+ assert init_db(str(path)) == str(path)
+ assert init_db(str(path)) == str(path)
+ assert _tables(path) == EXPECTED_TABLES
+
+
+def test_legacy_nonempty_account_valuations_row_is_dropped_only(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 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
+
+
+def test_unsupported_valuation_operations_do_not_create_or_access_db(tmp_path):
+ path = tmp_path / "must-not-exist.sqlite3"
+ result = register_account_valuation("acct", "2026-09-30", 1, db_path=str(path))
+ listed = list_account_valuations("acct", db_path=str(path))
+ assert result["success"] is False and result["error_code"] == "unsupported"
+ assert listed["success"] is False and listed["error_code"] == "unsupported"
+ assert not path.exists()
+
+
+def test_active_account_preview_reports_missing_without_sql_error(tmp_path):
+ path = tmp_path / "preview.sqlite3"
+ init_db(str(path))
+ with sqlite3.connect(path) as conn:
+ conn.execute("insert into accounts (id,name,type,active,created_at,updated_at) values ('acct-1','Active','bank',1,'t','t')")
+ conn.commit()
+ result = preview_asset_snapshot_close("2026-09-30", db_path=str(path))
+ assert result["success"] is True
+ assert result["close_status"] in {"incomplete_draft", "complete_exact"}
+ assert not any(w.get("code") == "db_error" for w in result["warnings"])
+
+
+def test_active_account_draft_returns_review_blocker_without_sql_error(tmp_path):
+ path = tmp_path / "draft.sqlite3"
+ init_db(str(path))
+ with sqlite3.connect(path) as conn:
+ conn.execute("insert into accounts (id,name,type,active,created_at,updated_at) values ('acct-1','Active','bank',1,'t','t')")
+ conn.commit()
+ result = create_settlement_draft("2026-09", "tester", "draft-key", db_path=str(path))
+ assert result["success"] is True
+ assert result.get("review", {}).get("approval_required") is True
+ 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):
+ 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"}
diff --git a/tests/test_asset_snapshot_idempotency.py b/tests/test_asset_snapshot_idempotency.py
index e9958a7..d944a31 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 asset_snapshots").fetchone()[0]
+ before_replay = conn.execute("SELECT COUNT(*) FROM account_snapshot").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 asset_snapshots").fetchone()[0]
+ after_replay = conn.execute("SELECT COUNT(*) FROM account_snapshot").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 asset_snapshots").fetchone()[0]
+ before_rows = conn.execute("SELECT COUNT(*) FROM account_snapshot").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 asset_snapshots").fetchone()[0] == before_rows
+ assert conn.execute("SELECT COUNT(*) FROM account_snapshot").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 asset_snapshots").fetchone()[0]
+ before_replay = conn.execute("SELECT COUNT(*) FROM account_snapshot").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 asset_snapshots").fetchone()[0]
+ after_replay = conn.execute("SELECT COUNT(*) FROM account_snapshot").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
new file mode 100644
index 0000000..d2bdd89
--- /dev/null
+++ b/tests/test_asset_snapshot_runs_removed.py
@@ -0,0 +1,68 @@
+from __future__ import annotations
+
+import sqlite3
+
+from jarca_ledger_mcp.application.account_valuation_service import list_account_valuations, register_account_valuation
+from jarca_ledger_mcp.application.assets_service import close_asset_snapshot, get_asset_snapshot_report
+from jarca_ledger_mcp.application.monthly_settlement_service import list_monthly_settlement_overview
+from jarca_ledger_mcp.infrastructure.sqlite.migrations import init_db
+
+
+def tables(path):
+ with sqlite3.connect(path) as c:
+ 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):
+ path = tmp_path / "fresh.sqlite3"
+ init_db(str(path))
+ assert "asset_snapshot_runs" not in tables(path)
+
+
+def test_init_twice_is_idempotent_and_schema16(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] == "16"
+ assert "asset_snapshot_runs" not 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 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.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 "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"] == "unsupported"
+ assert list_account_valuations("a", db_path=str(path))["error_code"] == "unsupported"
+ assert not path.exists()
+
+
+def test_finalize_and_close_remain_unsupported_without_db_access(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 not path.exists()
+
+
+def test_monthly_overview_keeps_real_parent_row(tmp_path):
+ path = tmp_path / "monthly.sqlite3"
+ init_db(str(path))
+ with sqlite3.connect(path) as c:
+ c.execute("insert into monthly_settlement(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) values (?,?,?,?,?,?,?,?,?)", ('m-1','2026-09','2026-09-30','draft',1,'test','t','test','t'))
+ c.commit()
+ result = list_monthly_settlement_overview(db_path=str(path))
+ assert result["success"] and result["months"][0]["settlement_id"] == "m-1"
+ assert result["months"][0]["income_total"] is None
diff --git a/tests/test_asset_snapshots.py b/tests/test_asset_snapshots.py
index 34ac763..98a1ed3 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(asset_snapshots)")}
+ snapshot_cols = {row[1] for row in conn.execute("PRAGMA table_info(account_snapshot)")}
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", "snapshot_run_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", "monthly_settlement_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("asset_snapshots", 0)
+ before = service.get_ledger_db_status(db_path, include_hash=False, include_integrity=False)["table_counts"].get("account_snapshot", 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("asset_snapshots", 0)
+ after = service.get_ledger_db_status(db_path, include_hash=False, include_integrity=False)["table_counts"].get("account_snapshot", 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 asset_snapshots WHERE basis_date='2026-06-30'").fetchone()[0]
+ row_count = conn.execute("SELECT COUNT(*) FROM account_snapshot 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 d06c416..e433b6e 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":"asset_snapshots", "cashflow":"settlement_cashflow_snapshots", "completion":"settlement_drafts"}[failure_point]
+ target = {"header":"asset_snapshot_runs", "assets":"account_snapshot", "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 asset_snapshots").fetchone()[0] == 0
+ assert db.execute("SELECT COUNT(*) FROM account_snapshot").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", "asset_snapshots", "settlement_cashflow_snapshots"):
+ for table in ("asset_snapshot_runs", "account_snapshot", "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"
@@ -368,7 +368,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 asset_snapshots BEGIN UPDATE asset_snapshots SET {column}={expression} WHERE id=NEW.id; END"
+ trigger = f"CREATE TRIGGER synthetic_tamper AFTER INSERT ON account_snapshot BEGIN UPDATE account_snapshot 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 +377,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", "asset_snapshots", "settlement_cashflow_snapshots"):
+ for table in ("asset_snapshot_runs", "account_snapshot", "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,7 +672,12 @@ 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:
- assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
+ 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"
+ )
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 fb97c80..3df1303 100644
--- a/tests/test_comprehensive_settlement_adapters.py
+++ b/tests/test_comprehensive_settlement_adapters.py
@@ -283,6 +283,8 @@ def test_saved_monthly_panel_renders_real_conditional_exclusion_carry_and_approv
monkeypatch.setattr(reconciliation_gateway, "get_balance_reconciliation_report", lambda *a, **k: {"success": True, "items": []})
monkeypatch.setattr(settlement_gateway, "list_settlement_drafts", lambda *a, **k: {"success": True, "drafts": []})
app = AppTest.from_string(
+ f"import streamlit as st\n"
+ f"st.query_params.update(page='월정산', month='2026-09')\n"
f"from dashboard.page_views.monthly_settlement import page_monthly_settlement\n"
f"page_monthly_settlement({path!r})", default_timeout=30).run()
assert not app.exception, f"saved panel AppTest failed before field assertions: {app.exception}"
@@ -368,6 +370,8 @@ def test_saved_monthly_snapshot_renders_close_legacy_and_asset_evidence(monkeypa
monkeypatch.setattr(settlement_gateway, "get_monthly_settlement_snapshot", lambda *a, **k: {"success": True, **snapshot})
monkeypatch.setattr(settlement_gateway, "list_settlement_drafts", lambda *a, **k: {"success": True, "drafts": []})
app = AppTest.from_string(
+ "import streamlit as st\n"
+ "st.query_params.update(page='월정산', month='2026-09')\n"
"from dashboard.page_views.monthly_settlement import page_monthly_settlement\n"
"page_monthly_settlement('synthetic-unused.sqlite3')", default_timeout=30)
app.run()
diff --git a/tests/test_dashboard_app.py b/tests/test_dashboard_app.py
index e3bb7e3..5ef3b34 100644
--- a/tests/test_dashboard_app.py
+++ b/tests/test_dashboard_app.py
@@ -90,8 +90,8 @@ def test_streamlit_apptest_core_pages_use_synthetic_fixture_db(tmp_path, monkeyp
assert "상태: 정상" in body_text
assert "자산 상세: 경고 3건 / 제외 1건" in body_text
assert "월정산 결과 / Evidence" in body_text
- assert "읽기 전용 근거 요약" in body_text
- assert "이번 범위에는 월정산 close 저장/실행이 포함되지 않습니다" in body_text
+ assert "근거 요약" in body_text
+ assert "정산 전 운영 블로커 점검" in body_text
assert "다음 액션" in body_text
assert "예산 조정 후보" in body_text
assert "분류/검토 규칙 후보" in body_text
@@ -120,7 +120,6 @@ def test_streamlit_apptest_core_pages_use_synthetic_fixture_db(tmp_path, monkeyp
assert "선택 월: 2026-06" in body_text
assert "기준일: 2026-06-30" in body_text
assert "기준 시각" in body_text
- assert "모드: 읽기 전용" in body_text
assert "확인필요 큐로 이동" in body_text
assert "계좌 화면에서 잔액대조 확인" in body_text
assert "Import 화면에서 이력 확인" in body_text
@@ -129,7 +128,6 @@ def test_streamlit_apptest_core_pages_use_synthetic_fixture_db(tmp_path, monkeyp
assert ".local/share/jarca/uploads" not in body_text
assert "/home/" not in body_text
assert "raw_payload" not in body_text
- assert "🔒 읽기 전용" in body_text
def _dashboard_body(at):
diff --git a/tests/test_dashboard_menu_transition.py b/tests/test_dashboard_menu_transition.py
index 74678aa..d3cf5d3 100644
--- a/tests/test_dashboard_menu_transition.py
+++ b/tests/test_dashboard_menu_transition.py
@@ -43,6 +43,18 @@ def test_sidebar_selection_updates_only_page_query_parameter(monkeypatch):
assert query == {"page": "거래내역", "month": "2026-06", "account_id": "checking"}
+def test_sidebar_monthly_settlement_serializes_canonical_page(monkeypatch):
+ query = {"page": "요약", "month": "2026-06"}
+ state = {"dashboard_page": "월정산"}
+ callback = {}
+ monkeypatch.setattr(common.st, "query_params", query)
+ monkeypatch.setattr(common.st, "session_state", state)
+ monkeypatch.setattr(common.st, "radio", lambda *args, **kwargs: callback.setdefault("fn", kwargs["on_change"]) or state["dashboard_page"])
+ common.render_sidebar_nav(PAGES, "월정산")
+ callback["fn"]()
+ assert query == {"page": "monthly_settlement", "month": "2026-06"}
+
+
def test_missing_or_unknown_page_falls_back_to_overview():
assert common.validate_dashboard_page(None, PAGES) == "요약"
assert common.validate_dashboard_page("not-a-page", PAGES) == "요약"
diff --git a/tests/test_issue_12_regressions.py b/tests/test_issue_12_regressions.py
index 95be819..fc873b8 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 asset_snapshots (
+ CREATE TABLE account_snapshot (
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 asset_snapshots(id,basis_date,source_type,name,amount,created_at) VALUES('s','2026-01-01','account','old',10,'t');
+ INSERT INTO account_snapshot(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 asset_snapshots WHERE id='s'").fetchone()
+ assert conn.execute("SELECT name FROM account_snapshot 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] == "14"
+ assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "16"
def test_snapshot_close_idempotency_fingerprint_and_concurrent_first_close(tmp_path):
diff --git a/tests/test_ledger_categories.py b/tests/test_ledger_categories.py
index 1022490..eac095d 100644
--- a/tests/test_ledger_categories.py
+++ b/tests/test_ledger_categories.py
@@ -6,7 +6,7 @@ from jarca_ledger_mcp import service
def test_ledger_category_create_list_delete_reactivate(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
health = service.health_check(db_path)
- assert health["schema_version"] == "14"
+ assert health["schema_version"] == "15"
created = service.create_ledger_category("반려동물", "사료", sort_order=250, memo="test", db_path=db_path)
assert created["success"] is True
diff --git a/tests/test_legacy_snapshot_migration.py b/tests/test_legacy_snapshot_migration.py
index a6b1537..b945afa 100644
--- a/tests/test_legacy_snapshot_migration.py
+++ b/tests/test_legacy_snapshot_migration.py
@@ -16,10 +16,22 @@ 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:
- # These are schema-11 close rows after the additive schema-12 migration:
- # run identity is absent, but row close/correction fields remain frozen.
+ # 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
+ )""")
conn.execute(
- """INSERT INTO asset_snapshots
+ """INSERT INTO account_snapshot
(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)
@@ -32,7 +44,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").fetchone()[0] == 0
+ assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs WHERE close_status <> 'legacy_read_only'").fetchone()[0] == 0
return path, asset["id"]
@@ -102,6 +114,18 @@ 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),
@@ -110,7 +134,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 asset_snapshots
+ """INSERT INTO account_snapshot
(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,
@@ -124,7 +148,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").fetchone()[0] == 0
+ assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs WHERE close_status <> 'legacy_read_only'").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
new file mode 100644
index 0000000..7f354d9
--- /dev/null
+++ b/tests/test_manual_assets_removed.py
@@ -0,0 +1,66 @@
+import sqlite3
+
+from jarca_ledger_mcp.application.accounts_write_services import create_ledger_account
+from jarca_ledger_mcp.application.assets_service import (
+ create_manual_asset,
+ list_manual_assets,
+ preview_asset_snapshot_close,
+)
+from jarca_ledger_mcp.infrastructure.sqlite.migrations import init_db
+
+
+def tables(path):
+ with sqlite3.connect(path) as conn:
+ 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):
+ path = str(tmp_path / "fresh.sqlite3")
+ init_db(path)
+ init_db(path)
+ assert "manual_assets" not in tables(path)
+
+
+def test_existing_legacy_manual_assets_is_dropped_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 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)
+
+
+def test_manual_asset_public_operations_are_unsupported(tmp_path):
+ path = str(tmp_path / "unsupported.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
+
+
+def test_account_real_estate_creation_and_snapshot_preview_use_normal_path(tmp_path):
+ path = str(tmp_path / "real-estate.sqlite3")
+ init_db(path)
+ created = create_ledger_account(
+ "House", "other", liquidity_bucket="real_estate", last_balance=500000,
+ last_balance_basis_date="2026-09-30", db_path=path,
+ )
+ assert created["success"] is True
+ assert created["account"]["liquidity_bucket"] == "real_estate"
+ 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):
+ path = str(tmp_path / "accounts.sqlite3")
+ init_db(path)
+ with sqlite3.connect(path) as conn:
+ before = conn.execute("select count(*) from accounts").fetchone()[0]
+ 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)
diff --git a/tests/test_month_settlement_schema.py b/tests/test_month_settlement_schema.py
new file mode 100644
index 0000000..22cc50b
--- /dev/null
+++ b/tests/test_month_settlement_schema.py
@@ -0,0 +1,92 @@
+from __future__ import annotations
+
+import sqlite3
+
+import pytest
+
+from jarca_ledger_mcp.db import init_db
+
+
+def _insert_sql():
+ return """INSERT INTO monthly_settlement
+ (id, month, basis_date, status, version, created_by, created_at, updated_by, updated_at)
+ VALUES (?, ?, ?, 'draft', 1, 'tester', '2026-10-15T00:00:00+09:00', 'tester', '2026-10-15T00:00:00+09:00')"""
+
+
+def test_fresh_db_has_month_settlement_schema_16_without_latest_run_id(tmp_path):
+ path = str(tmp_path / "fresh.sqlite3")
+ init_db(path)
+ with sqlite3.connect(path) as conn:
+ assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "16"
+ assert conn.execute("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='monthly_settlement'").fetchone()[0] == 1
+ columns = {row[1]: row for row in conn.execute("PRAGMA table_info(monthly_settlement)")}
+ assert columns["approval_flags_json"][4] == "'{}'"
+ assert columns["memo"][4] == "''"
+ columns = {row[1] for row in conn.execute("PRAGMA table_info(monthly_settlement)")}
+ assert "latest_run_id" not in columns
+ assert conn.execute("PRAGMA foreign_key_list(monthly_settlement)").fetchall() == []
+ conn.execute(_insert_sql(), ("m1", "2026-09", "2026-09-30"))
+ row = conn.execute("SELECT memo, approval_flags_json FROM monthly_settlement").fetchone()
+ assert row == ("", "{}")
+
+
+def test_migration_is_idempotent_and_preserves_legacy_drafts(tmp_path):
+ path = str(tmp_path / "existing.sqlite3")
+ init_db(path)
+ with sqlite3.connect(path) as conn:
+ conn.execute("INSERT INTO settlement_drafts (id, month, basis_date, status, version, created_by, created_at, updated_at, create_key) VALUES ('draft-1','2026-09','2026-09-30','active',1,'u','t','t','k')")
+ before = conn.execute("SELECT id, month, basis_date, status FROM settlement_drafts").fetchall()
+ init_db(path)
+ init_db(path)
+ with sqlite3.connect(path) as conn:
+ assert conn.execute("SELECT id, month, basis_date, status FROM settlement_drafts").fetchall() == before
+ 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):
+ path = str(tmp_path / "schema15.sqlite3")
+ init_db(path)
+ with sqlite3.connect(path) as conn:
+ 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')""")
+ 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)
+ with sqlite3.connect(path) as conn:
+ assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "16"
+ 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() == []
+
+
+@pytest.mark.parametrize("column,value", [("month", "2026-13"), ("month", "202609"), ("basis_date", "2026-02-30"), ("version", 0)])
+def test_month_settlement_rejects_invalid_values(tmp_path, column, value):
+ path = str(tmp_path / "invalid.sqlite3")
+ init_db(path)
+ values = {"month": "2026-09", "basis_date": "2026-09-30", "version": 1}
+ values[column] = value
+ with sqlite3.connect(path) as conn, pytest.raises(sqlite3.IntegrityError):
+ conn.execute(
+ "INSERT INTO monthly_settlement (id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) VALUES (?,?,?,?,?,?,?,?,?)",
+ ("bad", values["month"], values["basis_date"], "draft", values["version"], "u", "t", "u", "t"),
+ )
+
+
+def test_status_condition_checks_and_month_uniqueness(tmp_path):
+ path = str(tmp_path / "conditions.sqlite3")
+ init_db(path)
+ with sqlite3.connect(path) as conn:
+ conn.execute(_insert_sql(), ("m1", "2026-09", "2026-09-30"))
+ with pytest.raises(sqlite3.IntegrityError):
+ conn.execute(_insert_sql(), ("m2", "2026-09", "2026-09-30"))
+ with pytest.raises(sqlite3.IntegrityError):
+ conn.execute("UPDATE monthly_settlement SET status='approved' WHERE id='m1'")
+ with pytest.raises(sqlite3.IntegrityError):
+ conn.execute("UPDATE monthly_settlement SET status='cancelled' WHERE id='m1'")
+ conn.execute("UPDATE monthly_settlement SET status='approved', approved_by='reviewer', approved_at='t', approved_version=1, approved_fingerprint='fp' WHERE id='m1'")
+ with pytest.raises(sqlite3.IntegrityError):
+ conn.execute("UPDATE monthly_settlement SET status='closed' WHERE id='m1'")
diff --git a/tests/test_monthly_cashflow_lifecycle_actual.py b/tests/test_monthly_cashflow_lifecycle_actual.py
new file mode 100644
index 0000000..67b9d1b
--- /dev/null
+++ b/tests/test_monthly_cashflow_lifecycle_actual.py
@@ -0,0 +1,104 @@
+from __future__ import annotations
+
+import sqlite3
+
+import pytest
+
+from jarca_ledger_mcp.application.monthly_settlement_service import (
+ approve_monthly_settlement,
+ close_monthly_settlement,
+ create_monthly_settlement,
+ review_monthly_settlement,
+)
+from jarca_ledger_mcp.adapters.streamlit import settlement_gateway
+from jarca_ledger_mcp.application.transaction_report_services import get_settlement_cashflow_snapshot
+from jarca_ledger_mcp.infrastructure.sqlite.migrations import init_db
+
+
+def _db(tmp_path, rows=()):
+ path = tmp_path / "monthly-cashflow-actual.sqlite3"
+ init_db(str(path))
+ with sqlite3.connect(path) as c:
+ for row in rows:
+ c.execute("INSERT INTO transactions(id,transaction_date,type,amount,currency,category,subcategory,account_id,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)", (*row, "2026-10-01T00:00:00Z", "2026-10-01T00:00:00Z"))
+ c.commit()
+ return path
+
+
+def _row(i, typ="expense", amount=100, currency="KRW", category="Food"):
+ return (i, "2026-10-10", typ, amount, currency, category, "Main", "acct")
+
+
+def _review_approve(path):
+ s = create_monthly_settlement("2026-10", db_path=str(path)); sid = s["settlement_id"]
+ reviewed = review_monthly_settlement(sid, db_path=str(path))
+ assert reviewed["success"] and reviewed["version"] == 1 and reviewed["review_fingerprint"]
+ approved = approve_monthly_settlement(sid, 1, reviewed["review_fingerprint"], db_path=str(path))
+ assert approved["success"] and approved["status"] == "approved" and approved["version"] == 1
+ return sid, reviewed, approved
+
+
+def test_gateway_cancels_approval_against_actual_temp_db(tmp_path):
+ path = _db(tmp_path, [_row("t1")])
+ sid, _, _ = _review_approve(path)
+ result = settlement_gateway.cancel_monthly_settlement_approval(sid, 1, db_path=str(path))
+ assert result == {"success": True, "settlement_id": sid, "status": "in_review", "version": 2}
+ with sqlite3.connect(path) as c:
+ row = c.execute("SELECT status, version, approved_by, approved_at, approved_cashflow_fingerprint FROM monthly_settlement WHERE id=?", (sid,)).fetchone()
+ assert row == ("in_review", 2, None, None, None)
+
+
+def test_review_approve_close_persists_and_freezes_original_ledger(tmp_path):
+ path = _db(tmp_path, [_row("t1")]); sid, reviewed, _ = _review_approve(path)
+ closed = close_monthly_settlement(sid, 1, db_path=str(path))
+ assert closed["success"] and closed["status"] == "closed" and closed["snapshot_count"] == 1
+ with sqlite3.connect(path) as c:
+ c.execute("UPDATE transactions SET amount=999 WHERE id='t1'"); c.commit()
+ saved = get_settlement_cashflow_snapshot(sid, db_path=str(path))
+ assert saved["saved"] and saved["categories"][0]["amount"] == 100
+
+
+def test_review_then_ledger_edit_rejects_approval(tmp_path):
+ path = _db(tmp_path, [_row("t1")]); s = create_monthly_settlement("2026-10", db_path=str(path)); r = review_monthly_settlement(s["settlement_id"], db_path=str(path))
+ with sqlite3.connect(path) as c: c.execute("UPDATE transactions SET amount=101 WHERE id='t1'"); c.commit()
+ out = approve_monthly_settlement(s["settlement_id"], 1, r["review_fingerprint"], db_path=str(path))
+ assert not out["success"] and out["error_code"] == "stale_review"
+
+
+def test_approve_then_offset_transaction_rejects_close_even_same_total(tmp_path):
+ path = _db(tmp_path, [_row("t1", amount=100)]); sid, _, _ = _review_approve(path)
+ with sqlite3.connect(path) as c:
+ c.execute("UPDATE transactions SET amount=50 WHERE id='t1'")
+ c.execute("INSERT INTO transactions(id,transaction_date,type,amount,currency,category,subcategory,account_id,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)", _row("t2", amount=50) + ("2026-10-01T00:00:00Z", "2026-10-01T00:00:00Z")); c.commit()
+ out = close_monthly_settlement(sid, 1, db_path=str(path))
+ assert not out["success"] and out["error_code"] == "stale_cashflow"
+
+
+def test_null_fingerprint_close_rejects(tmp_path):
+ path = _db(tmp_path); sid, _, _ = _review_approve(path)
+ with sqlite3.connect(path) as c: c.execute("UPDATE monthly_settlement SET approved_cashflow_fingerprint=NULL WHERE id=?", (sid,)); c.commit()
+ out = close_monthly_settlement(sid, 1, db_path=str(path)); assert not out["success"] and out["error_code"] == "missing_fingerprint"
+
+
+def test_close_snapshot_insert_failure_rolls_back_status(tmp_path, monkeypatch):
+ path = _db(tmp_path, [_row("t1")]); sid, _, _ = _review_approve(path)
+ with sqlite3.connect(path) as c: c.execute("CREATE TRIGGER fail_snapshot BEFORE INSERT ON settlement_cashflow_snapshots BEGIN SELECT RAISE(ABORT,'boom'); END;") ; c.commit()
+ out = close_monthly_settlement(sid, 1, db_path=str(path)); assert not out["success"]
+ with sqlite3.connect(path) as c: assert c.execute("SELECT status FROM monthly_settlement WHERE id=?", (sid,)).fetchone()[0] == "approved"
+
+
+@pytest.mark.parametrize("currency", ["KRW", "USD"])
+def test_multifx_rows_remain_separate(tmp_path, currency):
+ path = _db(tmp_path, [_row("a", amount=100, currency="KRW"), _row("b", amount=7, currency=currency)])
+ sid, _, _ = _review_approve(path); assert close_monthly_settlement(sid, 1, db_path=str(path))["success"]
+ saved = get_settlement_cashflow_snapshot(sid, db_path=str(path)); assert {x["currency"] for x in saved["categories"]} >= {"KRW", currency}
+
+
+def test_empty_close_is_saved_without_fake_marker(tmp_path):
+ path = _db(tmp_path); sid, _, _ = _review_approve(path); closed = close_monthly_settlement(sid, 1, db_path=str(path)); assert closed["snapshot_count"] == 0
+ saved = get_settlement_cashflow_snapshot(sid, db_path=str(path)); assert saved["saved"] and saved["categories"] == []
+
+
+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
diff --git a/tests/test_monthly_settlement_approved_cashflow_fingerprint_schema.py b/tests/test_monthly_settlement_approved_cashflow_fingerprint_schema.py
new file mode 100644
index 0000000..f260ec4
--- /dev/null
+++ b/tests/test_monthly_settlement_approved_cashflow_fingerprint_schema.py
@@ -0,0 +1,81 @@
+from __future__ import annotations
+
+import sqlite3
+
+from jarca_ledger_mcp.db import init_db
+
+
+FIELD = "approved_cashflow_fingerprint"
+
+
+def _column(path: str) -> tuple:
+ with sqlite3.connect(path) as conn:
+ return next(row for row in conn.execute("PRAGMA table_info(monthly_settlement)") if row[1] == FIELD)
+
+
+def test_fresh_schema_has_nullable_text_field_without_default(tmp_path):
+ path = str(tmp_path / "fresh.sqlite3")
+ init_db(path)
+ column = _column(path)
+ assert column[2].upper() == "TEXT"
+ assert column[3] == 0
+ assert column[4] is None
+ with sqlite3.connect(path) as conn:
+ assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "15"
+ conn.execute(
+ "INSERT INTO monthly_settlement "
+ "(id, month, basis_date, status, version, created_by, created_at, updated_by, updated_at) "
+ "VALUES (?, ?, ?, 'draft', 1, 'tester', 'now', 'tester', 'now')",
+ ("m1", "2026-09", "2026-09-30"),
+ )
+ assert conn.execute(f"SELECT {FIELD} FROM monthly_settlement WHERE id='m1'").fetchone() == (None,)
+
+
+def test_same_version_existing_db_upgrade_is_idempotent_and_preserves_rows(tmp_path):
+ path = str(tmp_path / "existing.sqlite3")
+ init_db(path)
+ with sqlite3.connect(path) as conn:
+ conn.execute(
+ "INSERT INTO monthly_settlement "
+ "(id, month, basis_date, status, version, created_by, created_at, updated_by, updated_at) "
+ "VALUES ('m1', '2026-09', '2026-09-30', 'draft', 1, 'tester', 'now', 'tester', 'now')"
+ )
+ conn.execute(f"UPDATE monthly_settlement SET {FIELD}='cashflow-fp' WHERE id='m1'")
+ before = conn.execute("SELECT id, month, status, version, memo, approved_fingerprint, approved_cashflow_fingerprint FROM monthly_settlement").fetchall()
+ before_tables = 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()
+ init_db(path)
+ init_db(path)
+ with sqlite3.connect(path) as conn:
+ assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "15"
+ assert conn.execute("SELECT id, month, status, version, memo, approved_fingerprint, approved_cashflow_fingerprint FROM monthly_settlement").fetchall() == before
+ 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):
+ path = str(tmp_path / "legacy16.sqlite3")
+ init_db(path)
+ with sqlite3.connect(path) as conn:
+ conn.execute("ALTER TABLE monthly_settlement RENAME TO monthly_settlement_without_fingerprint")
+ conn.execute(
+ """CREATE TABLE monthly_settlement (
+ id TEXT PRIMARY KEY, month TEXT NOT NULL UNIQUE, basis_date TEXT NOT NULL,
+ status TEXT NOT NULL, version INTEGER NOT NULL, 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, approved_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
+ )"""
+ )
+ 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 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')"
+ )
+ init_db(path)
+ with sqlite3.connect(path) as conn:
+ assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "15"
+ assert _column(path)[2].upper() == "TEXT"
+ assert conn.execute("SELECT approved_cashflow_fingerprint FROM monthly_settlement WHERE id='m1'").fetchone() == (None,)
diff --git a/tests/test_monthly_settlement_creation.py b/tests/test_monthly_settlement_creation.py
new file mode 100644
index 0000000..f678949
--- /dev/null
+++ b/tests/test_monthly_settlement_creation.py
@@ -0,0 +1,29 @@
+from __future__ import annotations
+
+import sqlite3
+
+from jarca_ledger_mcp.application.monthly_settlement_service import create_monthly_settlement
+from jarca_ledger_mcp.infrastructure.sqlite.migrations import init_db
+
+
+def test_create_monthly_settlement_persists_canonical_draft_and_rejects_duplicate(tmp_path):
+ path = tmp_path / "ledger.sqlite3"
+ init_db(str(path))
+
+ created = create_monthly_settlement("2026-10", db_path=str(path))
+ assert created["success"]
+ assert created["month"] == "2026-10"
+ assert created["status"] == "draft"
+ assert created["settlement_id"]
+
+ with sqlite3.connect(path) as conn:
+ row = conn.execute(
+ "SELECT id, month, basis_date, status FROM monthly_settlement WHERE id=?",
+ (created["settlement_id"],),
+ ).fetchone()
+ assert row == (created["settlement_id"], "2026-10", "2026-10-31", "draft")
+
+ duplicate = create_monthly_settlement("2026-10", db_path=str(path))
+ assert duplicate["success"] is False
+ assert duplicate["error_code"] == "duplicate_month"
+ assert duplicate["settlement_id"] == created["settlement_id"]
diff --git a/tests/test_monthly_settlement_drafts.py b/tests/test_monthly_settlement_drafts.py
index 46a9e40..f5b39ac 100644
--- a/tests/test_monthly_settlement_drafts.py
+++ b/tests/test_monthly_settlement_drafts.py
@@ -61,7 +61,7 @@ def test_empty_draft_persists_without_closing_and_has_consistent_public_shape(tm
assert isinstance(result["review"], dict)
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
- assert conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0] == 0
+ assert conn.execute("SELECT COUNT(*) FROM account_snapshot").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))
@@ -216,7 +216,7 @@ def test_finalize_persists_immutable_provenance_replays_exact_response_and_confl
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]
+ row_count = conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0]
assert run_count == 1 and row_count >= 1
assert final["draft_id"] == draft["id"]
assert final["draft_version"] == draft["version"]
@@ -236,12 +236,12 @@ 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 asset_snapshots BEGIN SELECT RAISE(ABORT, 'injected failure'); END")
+ conn.execute("CREATE TRIGGER fail_snapshot_insert BEFORE INSERT ON account_snapshot BEGIN SELECT RAISE(ABORT, 'injected failure'); END")
result = api("finalize_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "failure-key", db_path=path)
assert result.get("success") is False
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
- assert conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0] == 0
+ assert conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0] == 0
after = assert_ok(api("get_settlement_draft")(draft["id"], db_path=path))
assert after["draft"]["status"] == "active"
@@ -255,8 +255,8 @@ def test_schema13_migration_is_additive_and_idempotent(tmp_path):
init_db(path)
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT id FROM asset_snapshot_runs WHERE id='legacy-run'").fetchone()[0] == 'legacy-run'
- assert {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} >= {'settlement_drafts','settlement_draft_items','asset_snapshot_runs','asset_snapshots'}
- assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == '14'
+ assert {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} >= {'settlement_drafts','settlement_draft_items','asset_snapshot_runs','account_snapshot'}
+ assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == '16'
def test_concurrent_create_and_stale_version_finalization_are_safe(tmp_path):
diff --git a/tests/test_monthly_settlement_history.py b/tests/test_monthly_settlement_history.py
new file mode 100644
index 0000000..6cd0c83
--- /dev/null
+++ b/tests/test_monthly_settlement_history.py
@@ -0,0 +1,44 @@
+from pathlib import Path
+import sqlite3
+
+from jarca_ledger_mcp.db import init_db
+from jarca_ledger_mcp.application.monthly_settlement_service import (
+ get_monthly_settlement_history_record,
+ list_monthly_settlement_history,
+)
+
+
+def test_monthly_history_lists_close_and_correction_and_reads_one_record(tmp_path: Path):
+ db = tmp_path / "history.sqlite3"
+ init_db(str(db))
+ with sqlite3.connect(db) as conn:
+ conn.execute(
+ """INSERT INTO asset_snapshot_runs
+ (id,basis_date,close_status,correction_of_run_id,correction_reason,
+ created_at,settlement_month,draft_version,approved_at)
+ VALUES (?,?,?,?,?,?,?,?,?)""",
+ ("run-1", "2026-09-30", "complete_exact", "", "",
+ "2026-10-01T00:00:00Z", "2026-09", 1, "2026-10-01T00:00:00Z"),
+ )
+ conn.execute(
+ """INSERT INTO asset_snapshot_runs
+ (id,basis_date,close_status,correction_of_run_id,correction_reason,
+ created_at,settlement_month,draft_version,approved_at)
+ VALUES (?,?,?,?,?,?,?,?,?)""",
+ ("run-2", "2026-09-30", "conditional", "run-1", "누락 거래 반영",
+ "2026-10-02T00:00:00Z", "2026-09", 2, "2026-10-02T00:00:00Z"),
+ )
+ conn.commit()
+
+ listed = list_monthly_settlement_history("2026-09", db_path=str(db))
+ assert listed["success"]
+ assert [row["run_id"] for row in listed["history"]] == ["run-2", "run-1"]
+ correction = listed["history"][0]
+ assert correction["version"] == 2
+ assert correction["correction_target"] == "run-1"
+ assert correction["reason"] == "누락 거래 반영"
+
+ one = get_monthly_settlement_history_record("run-2", db_path=str(db))
+ assert one["success"]
+ assert one["history"]["run_id"] == "run-2"
+ assert one["history"]["closed_at"] == "2026-10-02T00:00:00Z"
diff --git a/tests/test_monthly_settlement_navigation.py b/tests/test_monthly_settlement_navigation.py
new file mode 100644
index 0000000..d6e674b
--- /dev/null
+++ b/tests/test_monthly_settlement_navigation.py
@@ -0,0 +1,237 @@
+from __future__ import annotations
+
+import sqlite3
+from jarca_ledger_mcp.db import init_db
+from dashboard.page_views import monthly_settlement as view
+
+
+class _FakeStreamlit:
+ def __init__(self, query=None):
+ self.query_params = dict(query or {})
+ self.session_state = {}
+ self.dataframes = []
+ self.errors = []
+ self.headers = []
+ self.titles = []
+ self.buttons = []
+ self.markdowns = []
+
+ def header(self, value):
+ self.headers.append(value)
+
+ def title(self, value):
+ self.titles.append(value)
+
+ def caption(self, value):
+ pass
+
+ def markdown(self, value, **kwargs):
+ self.markdowns.append(value)
+
+ def write(self, value):
+ self.markdowns.append(str(value))
+
+ def selectbox(self, label, options, **kwargs):
+ return options[0]
+
+ def info(self, value):
+ pass
+
+ def error(self, value):
+ self.errors.append(value)
+
+ def dataframe(self, value, **kwargs):
+ self.dataframes.append((value, kwargs))
+
+ class _Column:
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *args):
+ return False
+
+ def button(self, label, **kwargs):
+ return False
+
+ def popover(self, *args, **kwargs):
+ return self
+
+ def columns(self, spec):
+ return [self._Column() for _ in spec]
+
+ def container(self, **kwargs):
+ return self._Column()
+
+ def button(self, label, **kwargs):
+ self.buttons.append((label, kwargs))
+ return False
+
+ def popover(self, *args, **kwargs):
+ return self._Column()
+
+ def rerun(self):
+ return None
+
+
+def _fixture_db(tmp_path):
+ path = tmp_path / "monthly-navigation.sqlite3"
+ init_db(path)
+ with sqlite3.connect(path) as conn:
+ conn.execute(
+ """INSERT INTO monthly_settlement
+ (id, month, basis_date, status, version, created_by, created_at, updated_by, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+ ("settlement-2026-09", "2026-09", "2026-09-30", "draft", 1, "test", "2026-09-30T00:00:00Z", "test", "2026-09-30T00:00:00Z"),
+ )
+ conn.commit()
+ return path
+
+
+def test_month_text_link_uses_exact_canonical_id_and_direct_detail_reads_month(tmp_path, monkeypatch):
+ db_path = _fixture_db(tmp_path)
+ fake = _FakeStreamlit()
+ monkeypatch.setattr(view, "st", fake)
+ monkeypatch.setattr(view.overview_gateway, "list_monthly_settlement_overview", lambda **kwargs: {
+ "success": True,
+ "months": [{"settlement_id": "settlement-2026-09", "month": "2026-09", "status_label": "초안", "income": 100}],
+ })
+
+ view._render_monthly_list(str(db_path))
+ month_buttons = [item for item in fake.buttons if item[0] == "2026-09"]
+ assert len(month_buttons) == 1
+ assert month_buttons[0][1]["type"] == "tertiary"
+ assert month_buttons[0][1]["on_click"] is view._set_monthly_detail
+ assert month_buttons[0][1]["args"] == ("settlement-2026-09", "전체", "전체")
+ assert "?page=monthly_settlement" not in "\n".join(fake.markdowns)
+ month_buttons[0][1]["on_click"](*month_buttons[0][1]["args"])
+ assert fake.query_params == {"page": "monthly_settlement", "id": "settlement-2026-09"}
+ assert not fake.dataframes
+
+ detail_fake = _FakeStreamlit({"page": "monthly_settlement", "id": "settlement-2026-09"})
+ monkeypatch.setattr(view, "st", detail_fake)
+ calls = []
+ monkeypatch.setattr(view, "_render_monthly_detail", lambda db, month: calls.append((db, month)))
+ view.page_monthly_settlement(str(db_path))
+ assert calls == [(str(db_path), "2026-09")]
+ assert detail_fake.titles == ["월정산 상세 · 2026-09"]
+ assert [label for label, _ in detail_fake.buttons] == ["← 월정산 목록", "검토승인"]
+
+
+def test_unregistered_month_selection_does_not_create_none_link(monkeypatch):
+ fake = _FakeStreamlit()
+ monkeypatch.setattr(view, "st", fake)
+ monkeypatch.setattr(view.overview_gateway, "list_monthly_settlement_overview", lambda **kwargs: {
+ "success": True,
+ "months": [{"settlement_id": None, "month": "2026-08", "status_label": "정산 미생성·상세 선택 불가·현재집계(미확정)"}],
+ })
+ view._render_monthly_list("fixture.sqlite3")
+ rendered = "\n".join(fake.markdowns)
+ assert "2026-08" in rendered
+ assert "[2026-08]" not in rendered
+ assert "?page=monthly_settlement&id=None" not in rendered
+
+
+def test_direct_reload_unknown_id_does_not_fallback_to_month_or_list(tmp_path, monkeypatch):
+ db_path = _fixture_db(tmp_path)
+ fake = _FakeStreamlit({"page": "monthly_settlement", "id": "unknown-id", "month": "2026-09"})
+ monkeypatch.setattr(view, "st", fake)
+ calls = []
+ monkeypatch.setattr(view, "_render_monthly_list", lambda db: calls.append(("list", db)))
+ view.page_monthly_settlement(str(db_path))
+ assert calls == []
+ assert fake.errors == ["월정산을 찾을 수 없습니다."]
+
+
+def test_legacy_month_query_rewrites_to_canonical_id(tmp_path, monkeypatch):
+ db_path = _fixture_db(tmp_path)
+ fake = _FakeStreamlit({"page": "monthly_settlement", "month": "2026-09"})
+ monkeypatch.setattr(view, "st", fake)
+ monkeypatch.setattr(view.overview_gateway, "list_monthly_settlement_overview", lambda **kwargs: {
+ "success": True,
+ "months": [{"settlement_id": "settlement-2026-09", "month": "2026-09", "status_label": "초안"}],
+ })
+ monkeypatch.setattr(view, "_render_monthly_detail", lambda db, month: None)
+ view.page_monthly_settlement(str(db_path))
+ assert fake.query_params == {"page": "monthly_settlement", "id": "settlement-2026-09"}
+
+
+def test_back_to_list_uses_canonical_page_and_preserves_filters(monkeypatch):
+ fake = _FakeStreamlit({"page": "monthly_settlement", "id": "settlement-2026-09", "year": "2026", "status": "미마감"})
+ monkeypatch.setattr(view, "st", fake)
+ view._clear_monthly_detail("2026", "미마감")
+ assert fake.query_params == {"page": "monthly_settlement", "year": "2026", "status": "미마감"}
+ assert fake.session_state["dashboard_page"] == "월정산"
+
+
+
+
+def test_add_button_opens_dialog_and_navigates_to_created_canonical_id(monkeypatch):
+ class DialogFake(_FakeStreamlit):
+ def __init__(self):
+ super().__init__()
+ self.reruns = 0
+
+ def dialog(self, title):
+ assert title == "월정산 추가"
+ return lambda fn: fn
+
+ def text_input(self, label, **kwargs):
+ assert label == "대상 월"
+ return "2026-10"
+
+ def button(self, label, **kwargs):
+ self.buttons.append((label, kwargs))
+ return label in {"+", "추가"}
+
+ def rerun(self):
+ self.reruns += 1
+
+ fake = DialogFake()
+ monkeypatch.setattr(view, "st", fake)
+ monkeypatch.setattr(view.overview_gateway, "list_monthly_settlement_overview", lambda **kwargs: {"success": True, "months": []})
+ monkeypatch.setattr(view.settlement_gateway, "create_monthly_settlement", lambda month, **kwargs: {"success": True, "settlement_id": "settlement-2026-10"})
+
+ view._render_monthly_list("fixture.sqlite3")
+
+ assert fake.query_params == {"page": "monthly_settlement", "id": "settlement-2026-10"}
+ assert fake.reruns == 1
+
+
+def test_month_link_target_contains_no_write_operation():
+ source = view._render_monthly_list.__code__.co_names
+ assert "create_settlement_draft" not in source
+ assert "finalize_settlement_draft" not in source
+
+
+def test_back_button_single_click_clears_detail_query_and_reruns(monkeypatch):
+ class ClickFake(_FakeStreamlit):
+ def __init__(self):
+ super().__init__({"page": "monthly_settlement", "id": "settlement-2026-09", "month": "2026-09", "year": "2026", "status": "미마감"})
+ self.session_state.update({"dashboard_page": "월정산", "monthly_settlement_detail_id": "settlement-2026-09", "monthly_settlement_detail_month": "2026-09"})
+ self.reruns = 0
+
+ def rerun(self):
+ self.reruns += 1
+
+ fake = ClickFake()
+ monkeypatch.setattr(view, "st", fake)
+
+ view._clear_monthly_detail("2026", "미마감")
+
+ assert fake.reruns == 1
+ assert fake.query_params == {"page": "monthly_settlement", "year": "2026", "status": "미마감"}
+ assert "monthly_settlement_detail_id" not in fake.session_state
+ assert "monthly_settlement_detail_month" not in fake.session_state
+
+
+def test_next_rerun_without_detail_query_does_not_restore_detail(monkeypatch):
+ fake = _FakeStreamlit({"page": "monthly_settlement", "year": "2026", "status": "미마감"})
+ fake.session_state.update({"dashboard_page": "월정산"})
+ monkeypatch.setattr(view, "st", fake)
+ rendered = []
+ monkeypatch.setattr(view, "_render_monthly_list", lambda db: rendered.append(db))
+ monkeypatch.setattr(view, "_render_monthly_detail", lambda db, month: (_ for _ in ()).throw(AssertionError("detail restored")))
+
+ view.page_monthly_settlement("fixture.sqlite3")
+
+ assert rendered == ["fixture.sqlite3"]
diff --git a/tests/test_monthly_settlement_overview.py b/tests/test_monthly_settlement_overview.py
new file mode 100644
index 0000000..5cbcd50
--- /dev/null
+++ b/tests/test_monthly_settlement_overview.py
@@ -0,0 +1,58 @@
+from pathlib import Path
+import sqlite3
+
+from jarca_ledger_mcp.db import init_db
+from jarca_ledger_mcp.application.monthly_settlement_service import list_monthly_settlement_overview
+
+
+def test_monthly_overview_excludes_transaction_only_months(tmp_path: Path):
+ db = tmp_path / "fixture.sqlite3"
+ init_db(str(db))
+ with sqlite3.connect(db) as conn:
+ conn.execute(
+ "INSERT INTO transactions(id,transaction_date,type,amount,created_at,updated_at) VALUES (?,?,?,?,?,?)",
+ ("tx-1", "2026-09-03", "income", 100, "2026-09-03T00:00:00Z", "2026-09-03T00:00:00Z"),
+ )
+ conn.commit()
+ result = list_monthly_settlement_overview(db_path=str(db))
+ assert result["success"]
+ assert result["months"] == []
+
+
+def test_monthly_overview_keeps_existing_parent_and_joins_live_transaction_totals(tmp_path: Path):
+ db = tmp_path / "fixture.sqlite3"
+ init_db(str(db))
+ with sqlite3.connect(db) as conn:
+ conn.execute(
+ "INSERT INTO monthly_settlement(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) VALUES (?,?,?,?,?,?,?,?,?)",
+ ("settlement-1", "2026-09", "2026-09-30", "draft", 1, "test", "now", "test", "now"),
+ )
+ conn.execute(
+ "INSERT INTO transactions(id,transaction_date,type,amount,created_at,updated_at) VALUES (?,?,?,?,?,?)",
+ ("tx-1", "2026-09-03", "income", 100, "2026-09-03T00:00:00Z", "2026-09-03T00:00:00Z"),
+ )
+ conn.commit()
+ result = list_monthly_settlement_overview(db_path=str(db))
+ assert result["success"]
+ assert len(result["months"]) == 1
+ row = result["months"][0]
+ assert row["month"] == "2026-09"
+ assert row["settlement_id"] == "settlement-1"
+ assert row["income"] == 100
+ assert row["net_cashflow_display"] is None
+ assert row["net_worth_display"] is None
+
+
+def test_monthly_overview_keeps_parent_without_transactions(tmp_path: Path):
+ db = tmp_path / "fixture.sqlite3"
+ init_db(str(db))
+ with sqlite3.connect(db) as conn:
+ conn.execute(
+ "INSERT INTO monthly_settlement(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) VALUES (?,?,?,?,?,?,?,?,?)",
+ ("settlement-1", "2026-09", "2026-09-30", "draft", 1, "test", "now", "test", "now"),
+ )
+ conn.commit()
+ row = list_monthly_settlement_overview(db_path=str(db))["months"][0]
+ assert row["settlement_id"] == "settlement-1"
+ assert row["income"] == 0
+ assert row["expense"] == 0
diff --git a/tests/test_real_estate_account_type.py b/tests/test_real_estate_account_type.py
new file mode 100644
index 0000000..cca79f6
--- /dev/null
+++ b/tests/test_real_estate_account_type.py
@@ -0,0 +1,57 @@
+from __future__ import annotations
+
+import inspect
+
+from jarca_ledger_mcp import service
+from jarca_ledger_mcp.adapters.mcp import server
+
+
+def test_create_real_estate_defaults_to_real_estate_bucket(tmp_path):
+ db_path = str(tmp_path / "ledger.sqlite3")
+ result = service.create_ledger_account("아파트", type="real_estate", db_path=db_path)
+ assert result["success"] is True
+ assert result["account"]["type"] == "real_estate"
+ assert result["account"]["liquidity_bucket"] == "real_estate"
+
+
+def test_update_other_to_real_estate_preserves_explicit_bucket(tmp_path):
+ db_path = str(tmp_path / "ledger.sqlite3")
+ created = service.create_ledger_account("자산", type="other", db_path=db_path)
+ updated = service.update_ledger_account(
+ created["account"]["id"], type="real_estate", db_path=db_path
+ )
+ assert updated["success"] is True
+ assert updated["account"]["type"] == "real_estate"
+ assert updated["account"]["liquidity_bucket"] == "other"
+
+
+def test_real_estate_appears_in_list_and_asset_preview(tmp_path):
+ db_path = str(tmp_path / "ledger.sqlite3")
+ created = service.create_ledger_account(
+ "주택", type="real_estate", last_balance=500_000_000, db_path=db_path
+ )
+ listed = service.list_ledger_accounts(db_path=db_path)
+ assert listed["success"] is True
+ assert listed["accounts"][0]["liquidity_bucket"] == "real_estate"
+ preview = service.preview_asset_snapshot_close("2026-10-16", db_path=db_path)
+ assert preview["success"] is True
+ assert any(
+ item["source_id"] == created["account"]["id"]
+ and item["liquidity_bucket"] == "real_estate"
+ for item in preview["included_items"]
+ )
+
+
+def test_mcp_exposes_real_estate_type_in_create_and_update_schema():
+ create_annotation = inspect.signature(server.create_ledger_account).parameters["type"].annotation
+ update_annotation = inspect.signature(server.update_ledger_account).parameters["type"].annotation
+ assert "real_estate" in str(create_annotation)
+ assert "real_estate" in str(update_annotation)
+
+
+def test_unknown_account_type_remains_rejected(tmp_path):
+ result = service.create_ledger_account(
+ "알 수 없음", type="property", db_path=str(tmp_path / "ledger.sqlite3")
+ )
+ assert result["success"] is False
+ assert result["error_code"] == "validation_error"
diff --git a/tests/test_requirement_docs.py b/tests/test_requirement_docs.py
index 67abe8a..8343087 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_asset_snapshots.py",
+ "tests/test_account_snapshot.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 c857211..622b854 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"] == "14"
+ assert service.health_check(db_path)["schema_version"] == "16"
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
new file mode 100644
index 0000000..ceed885
--- /dev/null
+++ b/tests/test_schema_14_to_15_actual.py
@@ -0,0 +1,77 @@
+from __future__ import annotations
+
+import hashlib
+import sqlite3
+from pathlib import Path
+
+import pytest
+
+from jarca_ledger_mcp.infrastructure.sqlite import migrations
+from jarca_ledger_mcp.infrastructure.sqlite.migrations import init_db
+
+FIXTURE = Path(__file__).parent / "fixtures" / "schema_v14.sql"
+
+
+def _v14_db(path: Path) -> None:
+ with sqlite3.connect(path) as db:
+ db.executescript(FIXTURE.read_text())
+ db.execute("INSERT INTO accounts VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", ("a1", "Main", "bank", "Bank", "KRW", 1, "memo", "1234", "User", 100000, "2026-01-31", "", "ok", "cash", "2026-01-01", "2026-01-31"))
+ db.execute("INSERT INTO transactions (id,transaction_date,type,amount,account_id,created_at,updated_at) VALUES (?,?,?,?,?,?,?)", ("t1", "2026-01-15", "expense", -1200, "a1", "2026-01-15", "2026-01-15"))
+ db.execute("INSERT INTO asset_snapshots (id,basis_date,source_type,source_id,name,amount,closed_at,created_at) VALUES (?,?,?,?,?,?,?,?)", ("s1", "2026-01-31", "account", "a1", "Main", 100000, "2026-01-31T10:00:00", "2026-01-31T10:00:00"))
+ db.execute("INSERT INTO asset_snapshot_runs (id,basis_date,close_status,created_at) VALUES (?,?,?,?)", ("run1", "2026-01-31", "closed", "2026-01-31T10:00:00"))
+ db.execute("INSERT OR REPLACE INTO schema_meta VALUES ('schema_version','14')")
+ db.commit()
+
+
+def _rows(path: Path, table: str):
+ with sqlite3.connect(path) as db:
+ return db.execute(f"SELECT * FROM {table} ORDER BY 1").fetchall()
+
+
+def test_actual_v14_fixture_is_tag_ddl_and_migrates_preserving_rows(tmp_path):
+ path = tmp_path / "ledger.sqlite3"
+ _v14_db(path)
+ before_accounts, before_tx = _rows(path, "accounts"), _rows(path, "transactions")
+ 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("PRAGMA foreign_key_check").fetchall() == []
+ assert db.execute("PRAGMA integrity_check").fetchone()[0] == "ok"
+ assert _rows(path, "accounts") == before_accounts
+ assert _rows(path, "transactions") == before_tx
+
+
+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")
+ init_db(str(path))
+ assert _rows(path, "account_snapshot") == first
+
+
+def test_migration_failure_rolls_back_legacy_renames_and_metadata(tmp_path, monkeypatch):
+ path = tmp_path / "ledger.sqlite3"
+ _v14_db(path)
+ original = migrations.migrate_db
+ def fail(conn):
+ raise RuntimeError("injected")
+ monkeypatch.setattr(migrations, "migrate_db", fail)
+ with pytest.raises(RuntimeError, match="injected"):
+ init_db(str(path))
+ with sqlite3.connect(path) as db:
+ assert db.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "14"
+ assert db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='asset_snapshots'").fetchone()
+ assert not db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='account_snapshot'").fetchone()
+ monkeypatch.setattr(migrations, "migrate_db", original)
+
+
+def test_new_init_has_same_contract(tmp_path):
+ path = tmp_path / "new.sqlite3"
+ 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"
+ cols = {r[1] for r in db.execute("PRAGMA table_info(account_snapshot)")}
+ assert {"monthly_settlement_id", "verification_status", "evidence_kind"} <= cols
diff --git a/tests/test_service.py b/tests/test_service.py
index 7ccadc1..cecc262 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"] == "14"
+ assert result["schema_version"] == "16"
assert db_path.exists()
diff --git a/tests/test_settlement_cashflow_schema.py b/tests/test_settlement_cashflow_schema.py
index d0353e9..3b94da4 100644
--- a/tests/test_settlement_cashflow_schema.py
+++ b/tests/test_settlement_cashflow_schema.py
@@ -8,11 +8,9 @@ from jarca_ledger_mcp.db import init_db
def _schema(path):
with sqlite3.connect(path) as db:
tables = {r[0] for r in db.execute("SELECT name FROM sqlite_master WHERE type='table'")}
- assert {"settlement_cashflow_snapshots", "asset_snapshot_runs"} <= tables
- header = {r[1] for r in db.execute("PRAGMA table_info(asset_snapshot_runs)")}
+ assert {"settlement_cashflow_snapshots", "monthly_settlement"} <= tables
detail = {r[1] for r in db.execute("PRAGMA table_info(settlement_cashflow_snapshots)")}
- assert {"settlement_month", "currency", "income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth", "cashflow_policy_json", "aggregation_version", "snapshot_schema_version", "source_snapshot_json"} <= header
- assert {"id", "settlement_run_id", "cashflow_kind", "category", "subcategory", "amount", "transaction_count", "currency"} <= detail
+ assert {"id", "monthly_settlement_id", "cashflow_kind", "category", "subcategory", "amount", "transaction_count", "currency"} <= detail
def test_cashflow_detail_constraints_preserve_blank_categories(tmp_path):
@@ -21,69 +19,33 @@ def test_cashflow_detail_constraints_preserve_blank_categories(tmp_path):
_schema(path)
with sqlite3.connect(path) as db:
db.execute("PRAGMA foreign_keys=ON")
- db.execute("INSERT INTO asset_snapshot_runs(id,basis_date,close_status,created_at) VALUES('run-a','2026-09-30','complete_exact','now')")
- insert = "INSERT INTO settlement_cashflow_snapshots(id,settlement_run_id,cashflow_kind,category,subcategory,amount,transaction_count,currency) VALUES(?,?,?,?,?,?,?,?)"
- good = ("d1", "run-a", "expense", "", "", -7, 1, "KRW")
+ db.execute("INSERT INTO monthly_settlement(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) VALUES('month-a','2026-09','2026-09-30','draft',1,'test','now','test','now')")
+ 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")]:
with pytest.raises(sqlite3.IntegrityError):
db.execute(insert, values)
with pytest.raises(sqlite3.IntegrityError):
db.execute(insert, ("d9", "run-a", "expense", "", "", -7, 1, "KRW"))
- db.execute(insert, ("d10", "run-a", "income", "", "", 2, 1, "KRW"))
+ 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]
- assert any(x == ("settlement_run_id", "cashflow_kind", "category", "subcategory", "currency") for x in unique_fields)
+ assert any(x == ("monthly_settlement_id", "cashflow_kind", "category", "subcategory", "currency") for x in unique_fields)
assert db.execute("SELECT amount,category,subcategory FROM settlement_cashflow_snapshots WHERE id='d1'").fetchone() == (-7, "", "")
with pytest.raises(sqlite3.IntegrityError):
- db.execute("DELETE FROM asset_snapshot_runs WHERE id='run-a'")
- assert db.execute("SELECT COUNT(*) FROM settlement_cashflow_snapshots WHERE settlement_run_id='run-a'").fetchone()[0] == 2
+ db.execute("DELETE FROM monthly_settlement WHERE id='month-a'")
+ assert db.execute("SELECT COUNT(*) FROM settlement_cashflow_snapshots WHERE monthly_settlement_id='month-a'").fetchone()[0] == 2
-def test_legacy_migration_is_idempotent_and_keeps_unstored_totals_null(tmp_path):
- """Schema-13 fixture uses the frozen production header and asset DDL."""
- path = str(tmp_path / "schema13.sqlite3")
- with sqlite3.connect(path) as db:
- db.executescript("""
- CREATE TABLE schema_meta(key TEXT PRIMARY KEY,value TEXT NOT NULL);
- INSERT INTO schema_meta VALUES('schema_version','13');
- CREATE TABLE asset_snapshots (
- id TEXT PRIMARY KEY, basis_date TEXT NOT NULL, source_type TEXT NOT NULL,
- source_id TEXT NOT NULL DEFAULT '', name TEXT NOT NULL, amount INTEGER,
- currency TEXT NOT NULL DEFAULT 'KRW', liquidity_bucket TEXT NOT NULL DEFAULT 'other',
- valuation_source TEXT NOT NULL DEFAULT '', source_note TEXT NOT NULL DEFAULT '',
- memo TEXT NOT NULL DEFAULT '', snapshot_run_id TEXT NOT NULL DEFAULT '',
- correction_of_run_id TEXT NOT NULL DEFAULT '', correction_reason TEXT NOT NULL DEFAULT '',
- is_correction INTEGER NOT NULL DEFAULT 0, closed_at TEXT NOT NULL DEFAULT '',
- valuation_id TEXT NOT NULL DEFAULT '', selection_policy TEXT NOT NULL DEFAULT 'exact',
- verification_status TEXT NOT NULL DEFAULT 'unverified', evidence_kind TEXT NOT NULL DEFAULT 'applied',
- exclusion_reason TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL
- );
- CREATE TABLE asset_snapshot_runs (
- id TEXT PRIMARY KEY, basis_date TEXT NOT NULL, close_status TEXT NOT NULL,
- selection_policy TEXT NOT NULL DEFAULT 'exact', idempotency_key TEXT NOT NULL DEFAULT '',
- approved_by TEXT NOT NULL DEFAULT '', approved_at TEXT NOT NULL DEFAULT '',
- approval_reason TEXT NOT NULL DEFAULT '', approval_reference TEXT NOT NULL DEFAULT '',
- correction_of_run_id TEXT NOT NULL DEFAULT '', correction_reason TEXT NOT NULL DEFAULT '',
- applied_evidence TEXT NOT NULL DEFAULT '[]', excluded_evidence TEXT NOT NULL DEFAULT '[]',
- carried_evidence TEXT NOT NULL DEFAULT '[]', request_fingerprint TEXT NOT NULL DEFAULT '',
- created_at TEXT NOT NULL, draft_id TEXT NOT NULL DEFAULT '', draft_version INTEGER NOT NULL DEFAULT 0,
- review_fingerprint TEXT NOT NULL DEFAULT ''
- );
- INSERT INTO asset_snapshot_runs(id,basis_date,close_status,created_at) VALUES('legacy-run','2026-09-30','complete_exact','then');
- INSERT INTO asset_snapshots(id,basis_date,source_type,source_id,name,amount,created_at,snapshot_run_id) VALUES('legacy-row','2026-09-30','manual_asset','synthetic','Synthetic',42,'then','legacy-run');
- """)
+def test_init_is_idempotent_and_retains_schema_16_contract(tmp_path):
+ path = str(tmp_path / "fresh.sqlite3")
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()
+ assert db.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == '16'
+ assert not 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 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
+ assert db.execute("SELECT COUNT(*) FROM settlement_cashflow_snapshots").fetchone()[0] == first
diff --git a/tests/test_settlement_draft_audit_reproductions.py b/tests/test_settlement_draft_audit_reproductions.py
index a68243d..c13e612 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 asset_snapshots').fetchone()[0] == 0
+ assert conn.execute('SELECT COUNT(*) FROM account_snapshot').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 8208b2f..2953708 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', 'asset_snapshots'))
+ return tuple(conn.execute('SELECT COUNT(*) FROM ' + t).fetchone()[0] for t in ('asset_snapshot_runs', 'account_snapshot'))
@pytest.mark.parametrize('sql', [
diff --git a/tests/test_settlement_draft_contract_scenarios.py b/tests/test_settlement_draft_contract_scenarios.py
index cfe3591..fe4f4d0 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 asset_snapshots BEGIN SELECT RAISE(ABORT, 'synthetic rollback'); END")
+ c.execute("CREATE TRIGGER synthetic_failure BEFORE INSERT ON account_snapshot BEGIN SELECT RAISE(ABORT, 'synthetic rollback'); END")
failed = s.finalize_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'final', db_path=path)
assert not failed['success'] and failed['error_code'] == 'db_error'
with sqlite3.connect(path) as c:
assert c.execute('SELECT COUNT(*) FROM asset_snapshot_runs').fetchone()[0] == 0
- assert c.execute('SELECT COUNT(*) FROM asset_snapshots').fetchone()[0] == 0
+ assert c.execute('SELECT COUNT(*) FROM account_snapshot').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 asset_snapshots').fetchone()[0] == final['inserted_count']
+ assert c.execute('SELECT COUNT(*) FROM account_snapshot').fetchone()[0] == final['inserted_count']
diff --git a/tests/test_settlement_draft_correction_contract.py b/tests/test_settlement_draft_correction_contract.py
index 871df39..19fae94 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', 'asset_snapshots', 'settlement_drafts', 'settlement_draft_items')}
+ for table in ('asset_snapshot_runs', 'account_snapshot', '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)['asset_snapshots'] == original_rows['asset_snapshots']
+ assert counts(path)['account_snapshot'] == original_rows['account_snapshot']
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 8d5736b..8e91caa 100644
--- a/tests/test_settlement_draft_dashboard.py
+++ b/tests/test_settlement_draft_dashboard.py
@@ -26,7 +26,9 @@ def _app_wrapper(tmp_path: Path) -> Path:
def test_reusable_settlement_draft_panel_exists_and_is_importable(tmp_path):
assert VIEW.is_file(), "shared Dashboard settlement draft panel is missing"
from streamlit.testing.v1 import AppTest
- app = AppTest.from_file(str(_app_wrapper(tmp_path))).run()
+ app = AppTest.from_file(str(_app_wrapper(tmp_path)))
+ app.session_state["authenticated_actor"] = "fixture-operator"
+ app = app.run()
assert not app.exception
@@ -44,7 +46,9 @@ def test_streamlit_panel_renders_lifecycle_without_writing_or_creating_db(monkey
db_path = tmp_path / "dashboard-missing.sqlite3"
# Explicit synthetic DB path and no authenticated actor: render only.
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(db_path))
- app = AppTest.from_file(str(_app_wrapper(tmp_path))).run()
+ app = AppTest.from_file(str(_app_wrapper(tmp_path)))
+ app.session_state["authenticated_actor"] = "fixture-operator"
+ app = app.run()
assert not app.exception
page = "\n".join([e.value for e in app.markdown] + [e.value for e in app.caption] + [e.value for e in app.info] + [e.label for e in app.button])
for expected in ("초안", "검토", "승인", "최종", "취소", "정정", "평가자료", "제외 사유"):
@@ -91,13 +95,16 @@ def test_app_test_create_review_approve_finalize_roundtrip(tmp_path, monkeypatch
seeded = create_manual_asset("Synthetic asset", 125, "2026-09-30", "other", source_note="fixture", db_path=str(db_path))
assert seeded.get("success"), seeded
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(db_path))
- app = AppTest.from_file(str(_app_wrapper(tmp_path))).run()
+ monkeypatch.setattr("dashboard.authentication.get_authenticated_actor", lambda: "fixture-operator")
+ app = AppTest.from_file(str(_app_wrapper(tmp_path)))
+ app.session_state["authenticated_actor"] = "fixture-operator"
+ app = app.run()
assert not app.exception
app.button[0].click().run()
assert not app.exception
- assert app.button[0].label == "초안 생성 / 정정 초안"
- assert len(app.selectbox[0].options) == 1
- assert "active" in " ".join(item.value for item in app.caption)
+ next(button for button in app.button if button.label == "초안 만들기").click().run()
+ assert not app.exception
+ assert any(button.label == "검토 / 새로고침 대상" for button in app.button)
# Exercise read-only review through the rendered explicit button.
review = next(button for button in app.button if button.label == "검토 / 새로고침 대상")
review.click().run(timeout=10)
diff --git a/tests/test_settlement_draft_docs.py b/tests/test_settlement_draft_docs.py
index 2d6d8f1..4421b49 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", "asset_snapshots",
+ "retained", "account_valuations", "asset_snapshot_runs", "account_snapshot",
"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"):
diff --git a/tests/test_settlement_draft_mcp_runtime.py b/tests/test_settlement_draft_mcp_runtime.py
index 521bc07..810ec19 100644
--- a/tests/test_settlement_draft_mcp_runtime.py
+++ b/tests/test_settlement_draft_mcp_runtime.py
@@ -44,7 +44,7 @@ def test_required_public_field_assertion_detects_omission():
def rows(db, table):
- assert table in {'asset_snapshot_runs', 'asset_snapshots', 'settlement_drafts', 'settlement_draft_items'}
+ assert table in {'asset_snapshot_runs', 'account_snapshot', '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 +138,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', 'asset_snapshots', 'settlement_drafts', 'settlement_draft_items')
+ tables = ('asset_snapshot_runs', 'account_snapshot', '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})
diff --git a/tests/test_settlement_draft_panel_feedback.py b/tests/test_settlement_draft_panel_feedback.py
index d3a5c83..57c179a 100644
--- a/tests/test_settlement_draft_panel_feedback.py
+++ b/tests/test_settlement_draft_panel_feedback.py
@@ -9,7 +9,13 @@ from jarca_ledger_mcp.db import init_db
def panel(monkeypatch, path):
monkeypatch.setenv('JARCA_LEDGER_DB_PATH', str(path))
- app = AppTest.from_string('from dashboard.page_views.settlement_drafts import render_settlement_drafts\nrender_settlement_drafts()\n').run(timeout=20)
+ monkeypatch.setattr('dashboard.authentication.get_authenticated_actor', lambda: 'fixture-operator')
+ app = AppTest.from_string(f'from dashboard.page_views.settlement_drafts import render_settlement_drafts\nrender_settlement_drafts(db_path={str(path)!r})\n')
+ app.session_state["authenticated_actor"] = "fixture-operator"
+ app = app.run(timeout=20)
+ assert not app.exception
+ control(app.button, '정산 시작').click().run(timeout=20)
+ control(app.button, '초안 만들기').click().run(timeout=20)
assert not app.exception
return app
@@ -38,7 +44,7 @@ def test_panel_reports_partial_save_when_item_update_fails(tmp_path, monkeypatch
assert s.register_account_valuation(account, '2026-09-30', 10, verification_status='verified',
idempotency_key='synthetic-value', db_path=path)['success']
app = panel(monkeypatch, path)
- control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20)
+ control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
draft = s.list_settlement_drafts('2026-09', db_path=path)['drafts'][0]
review_key = f"review_result_{draft['id']}"
diff --git a/tests/test_settlement_draft_panel_workflow.py b/tests/test_settlement_draft_panel_workflow.py
index fce8356..7369600 100644
--- a/tests/test_settlement_draft_panel_workflow.py
+++ b/tests/test_settlement_draft_panel_workflow.py
@@ -16,9 +16,15 @@ def control(elements, label):
def app_for(tmp_path, monkeypatch, path):
monkeypatch.setenv('JARCA_LEDGER_DB_PATH', str(path))
+ monkeypatch.setattr('dashboard.authentication.get_authenticated_actor', lambda: 'fixture-operator')
appfile = tmp_path / 'panel.py'
- appfile.write_text('from dashboard.page_views.settlement_drafts import render_settlement_drafts\nrender_settlement_drafts()\n')
- app = AppTest.from_file(str(appfile)).run(timeout=20)
+ appfile.write_text(f'from dashboard.page_views.settlement_drafts import render_settlement_drafts\nrender_settlement_drafts(db_path={str(path)!r})\n')
+ app = AppTest.from_file(str(appfile))
+ app.session_state["authenticated_actor"] = "fixture-operator"
+ app = app.run(timeout=20)
+ assert not app.exception
+ control(app.button, '정산 시작').click().run(timeout=20)
+ control(app.button, '초안 만들기').click().run(timeout=20)
assert not app.exception
return app
@@ -29,7 +35,7 @@ def test_panel_selects_after_date_evidence_approves_and_finalizes_actual_databas
account = s.create_ledger_account('Synthetic retirement', type='investment', liquidity_bucket='investment', db_path=path)['account']['id']
value = s.register_account_valuation(account, '2026-10-05', 100, valuation_kind='total_value', verification_status='verified', idempotency_key='future', db_path=path)['valuation']['id']
app = app_for(tmp_path, monkeypatch, path)
- control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20)
+ control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
control(app.text_input, '대상 ID').set_value(account)
control(app.text_input, '평가자료 ID (비우면 해제)').set_value(value)
control(app.selectbox, '선택 방식').select('manual_substitute')
@@ -69,7 +75,7 @@ def test_panel_valuation_listing_shows_real_selected_evidence_ids(tmp_path, monk
account = s.create_ledger_account('Synthetic cash', db_path=path)['account']['id']
vid = s.register_account_valuation(account, '2026-09-30', 10, verification_status='verified', idempotency_key='value', db_path=path)['valuation']['id']
app = app_for(tmp_path, monkeypatch, path)
- control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20)
+ control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
control(app.text_input, '대상 ID').set_value(account)
control(app.button, '평가자료 조회').click().run(timeout=20)
assert not app.exception
@@ -81,7 +87,7 @@ def test_panel_supports_investment_total_and_explicit_loan_definition(tmp_path,
init_db(path)
loan = s.create_ledger_account('Synthetic loan', type='loan', liquidity_bucket='loan', db_path=path)['account']['id']
app = app_for(tmp_path, monkeypatch, path)
- control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20)
+ control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
assert {'total_value', 'cash_balance'} <= set(control(app.selectbox, '평가 종류').options)
control(app.text_input, '평가 등록 계좌 ID').set_value(loan)
control(app.text_input, '평가일 YYYY-MM-DD').set_value('2026-09-30')
@@ -103,7 +109,7 @@ def test_panel_edit_invalidates_cached_review_before_approval(tmp_path, monkeypa
account = s.create_ledger_account('Synthetic cash', db_path=path)['account']['id']
s.register_account_valuation(account, '2026-09-30', 10, verification_status='verified', idempotency_key='value', db_path=path)
app = app_for(tmp_path, monkeypatch, path)
- control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20)
+ control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
draft = s.list_settlement_drafts('2026-09', db_path=path)['drafts'][0]
key = f"review_result_{draft['id']}"
@@ -135,7 +141,7 @@ def test_panel_correction_invalidates_review_without_registration_account_input(
account = s.create_ledger_account('Synthetic cash', db_path=path)['account']['id']
vid = s.register_account_valuation(account, '2026-09-30', 10, verification_status='verified', idempotency_key='value', db_path=path)['valuation']['id']
app = app_for(tmp_path, monkeypatch, path)
- control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20)
+ control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
draft = s.list_settlement_drafts('2026-09', db_path=path)['drafts'][0]
old = app.session_state[f"review_result_{draft['id']}"]['review_fingerprint']
diff --git a/tests/test_settlement_draft_policy_consistency.py b/tests/test_settlement_draft_policy_consistency.py
index b681ce8..70dbe11 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 asset_snapshots").fetchone()[0] == 0
+ assert db.execute("SELECT COUNT(*) FROM account_snapshot").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 f970496..fda5b8d 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 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.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.commit()
report = service.get_asset_snapshot_report("2026-09-30", db_path=path)
assert report["success"]
diff --git a/tests/test_settlement_entry_actions.py b/tests/test_settlement_entry_actions.py
new file mode 100644
index 0000000..31664c2
--- /dev/null
+++ b/tests/test_settlement_entry_actions.py
@@ -0,0 +1,24 @@
+from pathlib import Path
+
+from jarca_ledger_mcp.db import init_db
+from jarca_ledger_mcp.application import settlement_draft_service as service
+
+
+def test_cancelled_draft_does_not_reserve_create_key_for_new_draft(tmp_path: Path):
+ db = str(tmp_path / "ledger.sqlite3")
+ init_db(db)
+ first = service.create_settlement_draft("2026-09", "actor", "same-key", db_path=db)
+ assert first["success"]
+ cancelled = service.cancel_settlement_draft(first["draft"]["id"], 1, "actor", "restart", db_path=db)
+ assert cancelled["success"]
+
+ second = service.create_settlement_draft("2026-09", "actor", "new-key-after-cancel", db_path=db)
+ assert second["success"]
+ assert second["draft"]["id"] != first["draft"]["id"]
+
+
+def test_dashboard_exposes_the_five_state_entry_labels():
+ source = Path(__file__).parents[1].joinpath("dashboard/page_views/settlement_drafts.py").read_text()
+ for label in ("정산시작", "이어서작업", "마감내역보기", "정정시작", "정정이어서작업"):
+ assert label in source
+ assert "검토·승인·마감·취소·평가" not in source