feat: deliver schema 15 monthly settlement lifecycle

This commit is contained in:
2026-10-17 15:19:46 +09:00
parent dda76b3bbd
commit 38a854d525
79 changed files with 4003 additions and 1831 deletions
+6
View File
@@ -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/서버로 운영합니다.
+2
View File
@@ -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
+9
View File
@@ -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
+5 -1
View File
@@ -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("페이지를 불러오지 못했습니다. 다시 시도해 주세요.")
+2
View File
@@ -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",
}
+1 -59
View File
@@ -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")
+637 -268
View File
@@ -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(
"""<style>
.st-key-monthly-settlement-table [data-testid="stHorizontalBlock"] {
align-items: stretch; gap: 0 !important; border-bottom: 1px solid var(--secondary-background-color);
}
.st-key-monthly-settlement-table [data-testid="column"] {
min-width: 0; padding: 0.52rem 0.65rem; display: flex; align-items: center;
}
.st-key-monthly-settlement-table [data-testid="column"]:not(:first-child) { justify-content: flex-end; text-align: right; }
.st-key-monthly-settlement-table [data-testid="stHorizontalBlock"]:first-child {
background: var(--secondary-background-color); border-top: 1px solid var(--border-color);
font-weight: 700; white-space: nowrap;
}
.st-key-monthly-settlement-table [data-testid="stButton"] { width: 100%; }
.st-key-monthly-settlement-table [data-testid="stButton"] button {
width: 100%; justify-content: flex-start; padding: 0; min-height: 0;
font-weight: 700; color: var(--text-color);
}
.st-key-monthly-settlement-table [data-testid="stMarkdownContainer"] p { margin: 0; line-height: 1.35; }
</style>""",
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(
"""<style>
.st-key-monthly-settlement-detail-header h1 { margin: 0; padding: 0; }
.st-key-monthly-settlement-detail-header [data-testid="stPopover"] svg { display: none; }
</style>""",
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(
"<span style='display:inline-block; padding:0.15rem 0.7rem; border-radius:9999px; "
"background:#dbeafe; color:#1e40af; font-weight:600; border:1px solid #93c5fd;'>"
"🔒 읽기 전용</span>",
unsafe_allow_html=True,
)
st.info("거래/예산 수정은 각 운영 화면(거래내역, 요약)에서 처리하세요.")
st.markdown("- [거래내역으로 이동](?page=거래내역)")
st.markdown("- [예산 확인으로 이동](?page=요약)")
st.markdown("월간 핵심 KPI")
st.caption("월간 핵심 지표를 통해 수입·지출·잔고 흐름을 점검하세요.")
k1, k2, k3, k4 = st.columns(4)
k1.metric("실제 수입", format_krw(settlement.get("actual_income", 0)))
k2.metric("실제 지출", format_krw(settlement.get("actual_expense", 0)))
k3.metric("순현금흐름", format_krw(settlement.get("net_cashflow", 0)))
k4.metric("저축률", format_percent(settlement.get("savings_rate")))
st.markdown("현금흐름 / 지출 구조")
render_section_label("💸", "현금흐름 / 지출 구조")
cashflow = settlement.get("reports", {}).get("cashflow", {}) if isinstance(settlement.get("reports"), dict) else {}
if cashflow:
c1, c2 = st.columns(2)
by_type = cashflow.get("by_type", {}) or {}
by_type_df = pd.DataFrame(
[{"구분": str(key), "금액": int(value)} for key, value in by_type.items()]
)
if not by_type_df.empty:
by_type_df["금액"] = by_type_df["금액"].map(format_krw)
by_type_df = by_type_df.sort_values("금액", ascending=False)
c1.dataframe(by_type_df, width="stretch", height=170)
by_kind = cashflow.get("by_cashflow_kind", {}) or {}
by_kind_df = pd.DataFrame(
[{"현금흐름 구분": str(key), "금액": int(value)} for key, value in by_kind.items()]
)
if not by_kind_df.empty:
by_kind_df["금액"] = by_kind_df["금액"].map(format_krw)
c2.dataframe(by_kind_df, width="stretch", height=170)
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("""<style>
[class*="monthly-account-evaluation-group"] [data-testid="stHorizontalBlock"] { align-items: stretch; gap: 0 !important; border-bottom: 1px solid var(--border-color); border-left: 1px solid var(--border-color); border-right: 1px solid var(--border-color); }
[class*="monthly-account-evaluation-group"] [data-testid="stHorizontalBlock"] [data-testid="column"] { min-width: 0; padding: 0.42rem 0.55rem; display: flex; align-items: center; border-right: 1px solid var(--border-color); }
[class*="monthly-account-evaluation-group"] [data-testid="column"]:last-child { border-right: 0; }
[class*="monthly-account-evaluation-group"] [data-testid="stHorizontalBlock"]:first-child { background: var(--secondary-background-color); border-top: 1px solid var(--border-color); font-weight: 700; white-space: nowrap; }
[class*="monthly-account-evaluation-group"] [data-testid="stHorizontalBlock"]:not(:first-child) [data-testid="column"]:nth-child(2) { justify-content: flex-end; text-align: right; }
[class*="monthly-account-evaluation-group"] [data-testid="stHorizontalBlock"]:not(:first-child) [data-testid="column"]:nth-child(3),
[class*="monthly-account-evaluation-group"] [data-testid="stHorizontalBlock"]:not(:first-child) [data-testid="column"]:nth-child(4) { justify-content: center; }
[class*="monthly-account-evaluation-group"] .monthly-account-name { padding-left: 1.1rem; }
</style>""", 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'<div class="monthly-account-name">{item["계정명"]}</div>', 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))}건")
st.markdown("### 평가 미등록 계좌")
if not unregistered:
st.caption("모든 계좌의 평가가 등록되었습니다.")
else:
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["계정명"])
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,
_render_monthly_cashflow(db_path, month, settlement_id, bool(snapshots.get("closed", False)), settlement_status, settlement_version, review_payload=review_payload)
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
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)
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,
)
if result.get("success"):
st.rerun()
else:
st.info(f"{selected_month} 예산 항목이 없습니다.")
st.error(result.get("error", "연결 해제에 실패했습니다."))
dialog()
render_section_label("⚠️", "정산 전 운영 블로커 점검")
unresolved_blocker_count = int(settlement.get("unresolved_blocker_count", 0))
unresolved_detail_count = unresolved_counts.get("unresolved_count", unresolved_blocker_count)
st.caption(f"미해결 블로커: {int(unresolved_detail_count)}건")
render_section_label("🧯", "정산 전 검토")
review_count = int(unresolved_counts.get("pending_review_count", len(review_queue_items) if isinstance(review_queue_items, list) else 0))
unreconciled_count = int(unresolved_counts.get("unreconciled_account_count", len(reconciliation_attention_items)) if isinstance(unresolved_counts, dict) else len(reconciliation_attention_items))
uncategorized_backlog = int(unresolved_counts.get("uncategorized_recommendation_backlog", 0)) if isinstance(unresolved_counts, dict) else 0
x1, x2, x3 = st.columns(3)
x1.metric("미처리 review", f"{review_count}건")
x2.metric("미대조 계좌", f"{unreconciled_count}건")
x3.metric("미분류 추천 대기", f"{uncategorized_backlog}건")
if review_queue_items:
st.markdown("확인필요 큐 최근 항목")
for item in review_queue_items[:5]:
tx_date = item.get("transaction_date") or item.get("reviewed_at") or "-"
merchant = item.get("merchant") or item.get("counterparty") or "-"
amount = format_krw(item.get("amount") if item.get("amount") is not None else item.get("difference"))
status = item.get("review_status") or item.get("status") or "pending"
st.markdown(f"- {tx_date} · {merchant} · {amount} · {status}")
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.caption("확인필요 큐는 비어 있습니다.")
st.markdown("- [확인필요 큐로 이동](?page=확인필요 큐)")
st.error(result.get("error", "분류 수정에 실패했습니다."))
dialog()
render_section_label("📥", "거래 반영 / 계좌 상태")
source_imported_at = source_coverage.get("imports", {}).get("latest_imported_at")
t1, t2, t3 = st.columns(3)
t1.metric("최근 Import 건수", f"{int(source_coverage.get('imports', {}).get('recent_batch_count', 0))}건")
t2.metric("최근 Import 시각", source_imported_at or "없음")
t3.metric("보류 후 처리", str(review_indicators.get("held_recheck_count", "-") or "0") + "건")
if import_batches:
latest_import = import_batches[0]
st.caption(f"최근 Import: 출처 {latest_import.get('source', '알 수 없음')} / 수집일 {latest_import.get('imported_at', '미기록')}")
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.info("최근 Import 배치가 없습니다.")
st.markdown("- [계좌 화면에서 잔액대조 확인](?page=계좌)")
st.markdown("- [Import 화면에서 이력 확인](?page=Import)")
representative_items = list(reconciliation_items[:3]) if isinstance(reconciliation_items, list) else []
st.markdown("대표 계좌 반영 상태")
if representative_items:
reconciliation_status_label = {
"OK": "정상",
"REVIEW_NEEDED": "미대조",
"BASELINE_REQUIRED": "기준 잔액 미입력",
"NEEDS_RECHECK": "재확인 필요",
"PENDING": "대기",
}
for idx, item in enumerate(representative_items, start=1):
raw_status = str(item.get("status", "-") or "-")
synced_at = item.get("last_synced_at") or item.get("baseline_at") or item.get("calculation_basis_at") or "-"
baseline_balance = item.get("baseline_balance")
display_balance = baseline_balance if baseline_balance is not None else item.get("calculated_balance")
item_line = (
f"- [{idx}] 계좌명: {item.get('account_name', '-')}, "
f"기관: {item.get('institution', item.get('bank', '미기록'))}, "
f"유형: {item.get('account_type', item.get('type', '미기록'))}, "
f"최근 동기화: {synced_at}, "
f"잔액: {format_krw(display_balance)}, "
f"상태: {reconciliation_status_label.get(raw_status, raw_status)}"
st.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(
item_line,
)
st.markdown(f"상태: {reconciliation_status_label.get(raw_status, '정상' if idx == 1 else raw_status)}")
if result.get("success"):
st.rerun()
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=요약)")
st.error(result.get("error", "평가 등록에 실패했습니다."))
dialog()
+1 -7
View File
@@ -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", []))
+92 -40
View File
@@ -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"):
+17 -7
View File
@@ -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`.
+2 -2
View File
@@ -341,7 +341,7 @@ loan accounts require representative_liability_definition for `loan`, `loan_bala
loan valuations map to liabilities and non-loan valuations map to assets. The output mapping preserves the existing envelopes and signatures: `success` remains the outcome field, failures include `error_code` and `error`, and validation or conflict failures do not partially write.
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
+2 -2
View File
@@ -140,8 +140,8 @@ jarca_ledger_mcp/
| `jarca_ledger_mcp/application/account_valuation_service.py` | source | validation, selection, idempotency, correction | named valuation functions | SQLite through shared db | source review + focused tests | developer |
| `jarca_ledger_mcp/application/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 |
### 의존 방향 추가
@@ -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.
+2 -2
View File
@@ -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.
+5 -23
View File
@@ -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."""
@@ -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",
]
@@ -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",
]
@@ -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",
@@ -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",
]
@@ -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 _valuation(row: Any) -> dict[str, Any]:
return row_to_dict(row)
def _public_candidate(row: Any) -> dict[str, Any]:
return {k: row[k] for k in ("id", "basis_date", "currency", "valuation_kind", "verification_status")}
def _strict_int(value: Any, field: str, allow_none: bool = True) -> int | None:
if value is None and allow_none:
return None
if isinstance(value, bool):
raise ValueError(f"{field} must be an integer")
if isinstance(value, int):
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
if isinstance(value, str) and re.fullmatch(r"[+-]?\d+", value.strip()):
return int(value.strip())
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 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) -> int:
if isinstance(value, bool) or not isinstance(value, int):
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:
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)
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")
account = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone()
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:
return fail("not_found", "account not found")
if currency_norm != (account["currency"] or "KRW").upper():
return fail("validation_error", "valuation currency must match account currency")
effective_kind = (valuation_kind or "balance").strip().lower()
is_loan_account = str(account["type"]).lower() == "loan"
if effective_kind in {"loan", "loan_balance"} and not is_loan_account:
return fail("validation_error", "loan valuation kind requires a loan account")
if is_loan_account and effective_kind not in {"balance", "loan", "loan_balance"}:
return fail("validation_error", "loan account valuation kind must represent a liability")
if is_loan_account and effective_kind in {"balance", "loan", "loan_balance"} and definition is None:
return fail("validation_error", "loan valuation requires representative_liability_definition")
if definition is not None and not is_loan_account:
return fail("validation_error", "representative liability definition requires a loan account")
seen: set[str] = set()
if supersedes_id:
parent = conn.execute("SELECT * FROM account_valuations WHERE id=?", (supersedes_id,)).fetchone()
if parent is None:
if idempotency_key and conn.execute("SELECT 1 FROM account_valuations WHERE account_id=? AND idempotency_key=?", (account_id, idempotency_key)).fetchone():
return fail("idempotency_conflict", "idempotency_key already has a different payload")
return fail("validation_error", "supersedes_id must reference an existing valuation")
if any(parent[field] != value for field, value in (
("account_id", account_id), ("basis_date", basis),
("currency", currency_norm), ("valuation_kind", (valuation_kind or "balance").strip().lower()),
)):
return fail("validation_error", "superseded valuation scope must match account, basis, currency, and valuation kind")
seen: set[str] = set()
cursor = parent
while cursor is not None:
cursor_id = cursor["id"]
if cursor_id in seen:
return fail("validation_error", "supersedes ancestry is cyclic")
seen.add(cursor_id)
next_id = cursor["supersedes_id"]
cursor = conn.execute("SELECT * FROM account_valuations WHERE id=?", (next_id,)).fetchone() if next_id else None
existing = None
by_key = conn.execute("SELECT * FROM account_valuations WHERE account_id=? AND idempotency_key=?", (account_id, idempotency_key)).fetchone() if idempotency_key else None
source_rows = conn.execute("SELECT * FROM account_valuations WHERE account_id=? AND source_ref=? ORDER BY recorded_at, id", (account_id, source_ref_norm)).fetchall() if source_ref_norm else []
correction_chain = seen if supersedes_id else set()
unrelated_source = next((row for row in source_rows if row["id"] not in correction_chain and (by_key is None or row["id"] != by_key["id"])), None)
if by_key is not None and unrelated_source is not None:
return fail("idempotency_conflict", "idempotency_key and source_ref identify different candidates")
if unrelated_source is not None:
by_source = unrelated_source
else:
by_source = None
existing = by_key or by_source
if existing:
current = _valuation(existing)
incoming = dict(zip(_COLUMNS, values))
incoming.update({"account_id": account_id, "basis_date": basis, "supersedes_id": supersedes_id, "correction_reason": correction_reason or ""})
comparable = set(incoming) - {"id", "recorded_at"}
if all(current.get(k) == v for k, v in incoming.items() if k in comparable):
return ok(valuation=current, reused=True)
return fail("idempotency_conflict", "idempotency_key already has a different payload", valuation=current)
valuation_id = new_id("valuation")
try:
conn.execute("""INSERT INTO account_valuations
(id,account_id,basis_date,amount,currency,valuation_kind,source_type,source_ref,verification_status,recorded_at,memo,idempotency_key,supersedes_id,correction_reason,as_of_at,timezone,cost_basis,net_contributed,reported_principal_kind,reported_principal_label,profit,return_rate,return_basis,principal,interest,fee,representative_liability_definition)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (valuation_id, account_id, basis, *values[:6], ts, values[6], idempotency_key or "", supersedes_id, correction_reason, *values[7:]))
except sqlite3.IntegrityError:
if supersedes_id:
return fail("correction_conflict", "valuation already has a correction")
return fail("idempotency_conflict", "idempotency_key already has a different payload")
except Exception as exc:
return fail("validation_error", str(exc))
row = conn.execute("SELECT * FROM account_valuations WHERE id=?", (valuation_id,)).fetchone()
return ok(valuation=_valuation(row), reused=False)
@read_operation
def list_account_valuations(account_id: str, include_rejected: bool = True, db_path: str | None = None) -> dict[str, Any]:
where = "" if include_rejected else " AND verification_status <> 'rejected'"
with read_db(db_path) as conn:
rows = conn.execute(f"SELECT * FROM account_valuations WHERE account_id=?{where} ORDER BY basis_date, recorded_at, id", (account_id,)).fetchall()
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:
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,
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))
except sqlite3.IntegrityError:
return fail("duplicate", "account evaluation already registered")
except Exception:
return fail("db_error", "account evaluation registration failed")
def list_account_valuations(account_id: str, include_rejected: bool = True, db_path: str | None = None) -> dict[str, Any]:
try:
with read_db(db_path) as conn:
rows = conn.execute("SELECT * FROM account_snapshot WHERE source_type='account' AND source_id=? ORDER BY created_at,id", (account_id,)).fetchall()
return ok(valuations=[dict(r) for r in rows], count=len(rows), read_only=True)
except Exception:
return fail("db_error", "account evaluations unavailable")
def select_account_valuation(*args: Any, **kwargs: Any) -> dict[str, Any]:
return fail("unsupported", "account valuation selection is unavailable")
def correct_account_valuation(*args: Any, **kwargs: Any) -> dict[str, Any]:
return fail("unsupported", "account valuation correction is unavailable")
def update_account_snapshot_category(snapshot_id: str, monthly_settlement_id: str, liquidity_bucket: str, db_path: str | None = None) -> dict[str, Any]:
try:
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))
except Exception:
return fail("db_error", "account snapshot category update failed")
@@ -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):
"""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)
@@ -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
+57 -299
View File
@@ -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,7 +362,6 @@ 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],
"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,
@@ -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"],
@@ -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:
@@ -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"],
@@ -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
@@ -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):
+1 -1
View File
@@ -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"]
@@ -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])
@@ -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]:
@@ -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,15 +296,14 @@ 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 ''",
_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",
@@ -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)
@@ -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] = {
+1 -2
View File
@@ -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*"]
+289
View File
@@ -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');
+98
View File
@@ -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 == "저장"
+250
View File
@@ -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)
+114
View File
@@ -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"
@@ -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
@@ -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
@@ -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
-102
View File
@@ -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
+111
View File
@@ -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"}
+6 -6
View File
@@ -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"]
+68
View File
@@ -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
+5 -5
View File
@@ -13,10 +13,10 @@ def test_asset_snapshot_schema_is_additive_and_idempotent(tmp_path):
with sqlite3.connect(db_path) as conn:
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
+11 -6
View File
@@ -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))
@@ -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()
+2 -4
View File
@@ -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):
+12
View File
@@ -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) == "요약"
+4 -4
View File
@@ -102,7 +102,7 @@ def test_schema12_migration_preserves_legacy_rows_indexes_and_is_idempotent(tmp_
created_at TEXT NOT NULL, updated_at TEXT NOT NULL
);
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):
+1 -1
View File
@@ -6,7 +6,7 @@ from jarca_ledger_mcp import service
def test_ledger_category_create_list_delete_reactivate(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
health = service.health_check(db_path)
assert health["schema_version"] == "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
+30 -6
View File
@@ -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)
+66
View File
@@ -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)
+92
View File
@@ -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'")
@@ -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
@@ -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,)
+29
View File
@@ -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"]
+6 -6
View File
@@ -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):
+44
View File
@@ -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"
+237
View File
@@ -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"]
+58
View File
@@ -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
+57
View File
@@ -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"
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -16,7 +16,7 @@ def test_review_decision_and_reconciliation_schema_are_additive_idempotent(tmp_p
assert status["integrity_check"] == "ok"
assert status["table_counts"]["review_decisions"] == 0
assert status["table_counts"]["reconciliation_sessions"] == 0
assert service.health_check(db_path)["schema_version"] == "14"
assert service.health_check(db_path)["schema_version"] == "16"
def test_review_decision_round_trip_payload_sanitizes_sensitive_fields(tmp_path: Path) -> None:
+77
View File
@@ -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
+1 -1
View File
@@ -10,7 +10,7 @@ def test_health_check_initializes_db(tmp_path):
db_path = tmp_path / "ledger.sqlite3"
result = service.health_check(str(db_path))
assert result["success"] is True
assert result["schema_version"] == "14"
assert result["schema_version"] == "16"
assert db_path.exists()
+15 -53
View File
@@ -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
@@ -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):
+1 -1
View File
@@ -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', [
@@ -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']
@@ -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')]
+13 -6
View File
@@ -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)
+1 -1
View File
@@ -174,7 +174,7 @@ def test_settlement_draft_runtime_cancel_and_recovery_contract() -> None:
for clause in ("cancel_settlement_draft(draft_id, expected_version, cancelled_by, reason, db_path=None)",
"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"):
+2 -2
View File
@@ -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})
@@ -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']}"
+13 -7
View File
@@ -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']
@@ -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
+1 -1
View File
@@ -16,7 +16,7 @@ def test_closed_draft_evidence_is_additive_and_strictly_redacted(tmp_path):
json.dumps([{"source_type":"account","source_id":"acct-1","reason":"approved_missing","approval_reference":"ref-2","unknown_secret":"hidden"}]),
json.dumps([{"source_type":"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"]
+24
View File
@@ -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