Issue #12: 계좌 기준일 평가 이력과 월정산 연동 구현 #13

Merged
genichin merged 32 commits from gitea-agent/issue-12-account-valuation-history into main 2026-10-08 07:14:10 +00:00
56 changed files with 5873 additions and 208 deletions
+5
View File
@@ -4,6 +4,7 @@ import sys
from pathlib import Path from pathlib import Path
import streamlit as st import streamlit as st
from jarca_ledger_mcp.adapters.streamlit import overview_gateway
# Streamlit executes this file as a script, so ensure absolute dashboard imports # Streamlit executes this file as a script, so ensure absolute dashboard imports
# resolve identically to package imports used by tests. # resolve identically to package imports used by tests.
@@ -38,7 +39,11 @@ def main() -> None:
return return
db_path = get_db_path() db_path = get_db_path()
db_status = overview_gateway.get_ledger_db_status(db_path=db_path, include_hash=False, include_integrity=False)
inject_app_css() inject_app_css()
if not db_status.get("success"):
st.error("Ledger DB를 사용할 수 없습니다: " + db_status.get("error", "ledger database is unavailable"))
return
pages = ["월정산", "요약", "자산/마감", "거래내역", "확인필요 큐", "미분류", "분류 규칙", "카테고리", "계좌", "Import"] pages = ["월정산", "요약", "자산/마감", "거래내역", "확인필요 큐", "미분류", "분류 규칙", "카테고리", "계좌", "Import"]
query_page = st.query_params.get("page") query_page = st.query_params.get("page")
page = validate_dashboard_page(query_page, pages) page = validate_dashboard_page(query_page, pages)
+59 -3
View File
@@ -30,6 +30,12 @@ ACCOUNT_TYPE_STYLES = {
"other": "#57606a", "other": "#57606a",
} }
def _account_balance_basis_date(account: dict[str, Any]) -> str:
"""Return only an explicit evidence date, never a sync timestamp."""
return str(account.get("last_balance_basis_date") or "")[:10]
def render_accounts_list_header(items: list[dict[str, Any]]) -> None: def render_accounts_list_header(items: list[dict[str, Any]]) -> None:
st.markdown( st.markdown(
f'{len(items)}개 계좌', f'{len(items)}개 계좌',
@@ -113,14 +119,19 @@ def account_edit_dialog(account: dict[str, Any], db_path: str) -> None:
format_func=lambda value: ACCOUNT_TYPE_LABELS[value], format_func=lambda value: ACCOUNT_TYPE_LABELS[value],
) )
balance_columns = st.columns(2) balance_columns = st.columns(3)
last_balance_raw = balance_columns[0].text_input( last_balance_raw = balance_columns[0].text_input(
"기준 잔액", "기준 잔액",
value="" if account.get("last_balance") is None else str(account.get("last_balance")), value="" if account.get("last_balance") is None else str(account.get("last_balance")),
help="원 단위 정수", help="원 단위 정수",
) )
last_synced_at = balance_columns[1].text_input( last_balance_basis_date = balance_columns[1].text_input(
"기준 시각", "잔액 기준일",
value=account.get("last_balance_basis_date") or "",
help="실제 증빙 기준일(YYYY-MM-DD). 미상인 경우 비워 둡니다.",
)
last_synced_at = balance_columns[2].text_input(
"동기화 시각",
value=account.get("last_synced_at") or "", value=account.get("last_synced_at") or "",
help="예: 2026-08-20 또는 2026-08-20T23:59:00+09:00", help="예: 2026-08-20 또는 2026-08-20T23:59:00+09:00",
) )
@@ -156,6 +167,7 @@ def account_edit_dialog(account: dict[str, Any], db_path: str) -> None:
institution=institution, institution=institution,
type=account_type, type=account_type,
last_balance=last_balance, last_balance=last_balance,
last_balance_basis_date=last_balance_basis_date,
last_synced_at=last_synced_at, last_synced_at=last_synced_at,
statement_status=statement_status, statement_status=statement_status,
liquidity_bucket=liquidity_bucket, liquidity_bucket=liquidity_bucket,
@@ -176,6 +188,50 @@ def page_accounts(db_path: str) -> None:
accounts_data = accounts_res.get("accounts", []) if accounts_res.get("success") else [] accounts_data = accounts_res.get("accounts", []) if accounts_res.get("success") else []
if accounts_data: if accounts_data:
render_account_list(accounts_data) render_account_list(accounts_data)
selected_id = st.selectbox("평가 이력 계좌", [a["id"] for a in accounts_data], format_func=lambda value: next((a.get("name", value) for a in accounts_data if a["id"] == value), value))
selected_account = next(a for a in accounts_data if a["id"] == selected_id)
history = accounts_gateway.list_account_valuations(selected_id, db_path=db_path)
valuations = history.get("valuations", []) if history.get("success") else []
if valuations:
st.dataframe(valuations, width="stretch", hide_index=True)
# The list is historical insertion order; a rejected/corrected
# tail is not necessarily the effective current valuation.
basis = _account_balance_basis_date(selected_account)
selected = accounts_gateway.select_account_valuation(selected_id, basis, as_of=True, db_path=db_path) if basis else {}
selected_valuation = selected.get("valuation") if selected.get("success") else None
latest = selected_valuation or next(
(item for item in reversed(valuations) if item.get("verification_status") == "verified"), valuations[-1]
)
st.caption(f"최근 실제 기준일: {latest.get('basis_date', '-')} · 상태: {latest.get('verification_status', 'unknown')} · 종류: {latest.get('valuation_kind', 'unknown')}")
else:
st.warning("평가 이력이 없습니다.")
with st.expander("계좌 평가 등록"):
with st.form(f"account_valuation_register_{selected_id}"):
v_date = st.text_input("평가 기준일", value=_account_balance_basis_date(selected_account))
v_amount = st.number_input("평가 금액", value=int(selected_account.get("last_balance") or 0), step=1)
default_kind = "loan_balance" if selected_account.get("type") == "loan" else ("total_value" if selected_account.get("type") == "investment" else "cash_balance")
v_kind = st.selectbox("평가 종류", ["cash_balance", "balance", "total_value", "loan_balance", "loan"], index=["cash_balance", "balance", "total_value", "loan_balance", "loan"].index(default_kind))
v_status = st.selectbox("검증 상태", ["unverified", "verified", "rejected"])
v_definition = st.selectbox("대표 부채 정의(대출 필수)", ["", "principal_only", "principal_interest_fees"])
v_submit = st.form_submit_button("평가 등록")
if v_submit:
result = accounts_gateway.register_account_valuation(selected_id, v_date, int(v_amount), valuation_kind=v_kind, verification_status=v_status, representative_liability_definition=v_definition or None, db_path=db_path)
if result.get("success"):
clear_cache(); st.success("평가를 등록했습니다."); st.rerun()
else:
st.error(result.get("error", result))
with st.expander("계좌 평가 정정"):
with st.form(f"account_valuation_correct_{selected_id}"):
correction_id = st.text_input("정정 대상 평가 ID")
correction_amount = st.number_input("정정 금액", value=0, step=1, key=f"correction_amount_{selected_id}")
correction_reason = st.text_input("정정 사유")
correction_submit = st.form_submit_button("평가 정정")
if correction_submit:
result = accounts_gateway.correct_account_valuation(correction_id, int(correction_amount), correction_reason, db_path=db_path)
if result.get("success"):
clear_cache(); st.success("평가를 정정했습니다."); st.rerun()
else:
st.error(result.get("error", result))
account_edit_id = st.session_state.get("account_edit_id") account_edit_id = st.session_state.get("account_edit_id")
if account_edit_id: if account_edit_id:
account = next((item for item in accounts_data if item["id"] == account_edit_id), None) account = next((item for item in accounts_data if item["id"] == account_edit_id), None)
+54 -6
View File
@@ -7,8 +7,39 @@ import pandas as pd
import streamlit as st import streamlit as st
from jarca_ledger_mcp.adapters.streamlit import assets_gateway from jarca_ledger_mcp.adapters.streamlit import assets_gateway
from jarca_ledger_mcp.adapters.streamlit import overview_gateway
from dashboard.common import * # noqa: F403, F405 from dashboard.common import * # noqa: F403, F405
def _render_asset_evidence(report: dict) -> None:
"""Render allowlisted evidence, never free-form source notes or memos."""
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("**근거 상세 (코드 / 출처 식별자)**")
if warnings:
for item in warnings:
st.markdown(f"- 경고 `{item.get('code', '-')}` · 출처 {item.get('source_type', '-')} / {item.get('source_id', '-')} · 대상 {item.get('name', '-')}")
else:
st.caption("경고 상세 없음")
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', '-')} · 근거 {item.get('evidence_kind', '-')}")
if approval:
st.markdown(f"**조건부 승인** · 승인자 {approval.get('approved_by', '-')} · 사유 {approval.get('approval_reason', '-')} · 참조 {approval.get('approval_reference', '-')}")
if correction:
st.markdown(f"**정정** · 원본 run {correction.get('correction_of_run_id', '-')} · 사유 {correction.get('correction_reason', '-')}")
st.caption(f"canonical provenance · 평가일 {provenance.get('valuation_date', '-')} · 검증 {provenance.get('verification_status', '-')} · 유형 {provenance.get('evidence_kind', '-')} · run {provenance.get('snapshot_run_id', '-')}")
def page_assets(db_path: str) -> None: def page_assets(db_path: str) -> None:
st.caption("표시 기준: preview는 live_authority 추정값, close 이후 조회는 closed snapshot 값입니다. 실운영 전 synthetic/validation DB에서 먼저 검증하세요.") st.caption("표시 기준: preview는 live_authority 추정값, close 이후 조회는 closed snapshot 값입니다. 실운영 전 synthetic/validation DB에서 먼저 검증하세요.")
@@ -39,7 +70,7 @@ def page_assets(db_path: str) -> None:
df = pd.DataFrame(assets) df = pd.DataFrame(assets)
df["bucket_label"] = df["liquidity_bucket"].map(LIQUIDITY_BUCKET_LABELS).fillna(df["liquidity_bucket"]) df["bucket_label"] = df["liquidity_bucket"].map(LIQUIDITY_BUCKET_LABELS).fillna(df["liquidity_bucket"])
df["valuation_display"] = df["valuation_amount"].map(format_krw) df["valuation_display"] = df["valuation_amount"].map(format_krw)
st.dataframe(df[["name", "active", "bucket_label", "valuation_display", "valuation_date", "source_note", "memo", "id"]], width="stretch", height=220) st.dataframe(df[["name", "active", "bucket_label", "valuation_display", "valuation_date", "id"]], width="stretch", height=220)
with st.form("manual_asset_update"): with st.form("manual_asset_update"):
asset_options = {f"{a['name']} ({a['id']})": a for a in assets} asset_options = {f"{a['name']} ({a['id']})": a for a in assets}
selected_label = st.selectbox("수정/비활성 대상", list(asset_options.keys())) selected_label = st.selectbox("수정/비활성 대상", list(asset_options.keys()))
@@ -75,15 +106,31 @@ def page_assets(db_path: str) -> None:
st.divider() st.divider()
render_section_label("🔒", "월말 스냅샷 Preview / Close") render_section_label("🔒", "월말 스냅샷 Preview / Close")
st.caption("Preview는 DB를 변경하지 않습니다. 같은 기준일을 다시 close하면 정정 사유가 필요하며 기존 행을 덮어쓰지 않고 새 correction run을 append합니다.") st.caption("Preview는 DB를 변경하지 않습니다. 같은 기준일을 다시 close하면 정정 사유가 필요하며 기존 행을 덮어쓰지 않고 새 correction run을 append합니다.")
c1, c2, c3 = st.columns([1, 1.5, 1.5]) c1, c2, c3, c4 = st.columns([1, 1, 1.5, 1.5])
basis_date = c1.text_input("마감 기준일", value=f"{current_kst_month()}-01", help="YYYY-MM-DD") basis_date = c1.text_input("마감 기준일", value=f"{current_kst_month()}-01", help="YYYY-MM-DD")
close_note = c2.text_input("Close 출처 메모", value="month-end close") selection_policy = c2.selectbox("선택 정책", ["exact", "as_of"], format_func=lambda value: "정확히 해당일" if value == "exact" else "기준일 현재(as-of)")
correction_reason = c3.text_input("정정 사유(재마감 시 필수)", value="") snapshot_report = overview_gateway.get_asset_snapshot_report(basis_date, selection_policy=selection_policy, db_path=db_path)
if snapshot_report.get("success"):
_render_asset_evidence(snapshot_report)
close_note = c3.text_input("Close 출처 메모", value="month-end close")
correction_reason = c4.text_input("정정 사유(재마감 시 필수)", value="")
p1, p2 = st.columns(2) p1, p2 = st.columns(2)
approval_required = st.checkbox("조건부 close 승인 명시", value=False)
approval_cols = st.columns(3)
approved_by = approval_cols[0].text_input("승인자")
approval_reason = approval_cols[1].text_input("승인 사유")
approval_reference = approval_cols[2].text_input("승인 근거 참조")
allow_missing = st.checkbox("누락 제외 허용", value=False)
allow_carry = st.checkbox("as-of 이월 허용", value=False)
if p1.button("스냅샷 Preview", type="secondary"): if p1.button("스냅샷 Preview", type="secondary"):
st.session_state["asset_snapshot_preview"] = assets_gateway.preview_asset_snapshot_close(basis_date, db_path=db_path) st.session_state["asset_snapshot_preview"] = assets_gateway.preview_asset_snapshot_close(basis_date, selection_policy=selection_policy, db_path=db_path)
if p2.button("스냅샷 Close 실행", type="primary"): if p2.button("스냅샷 Close 실행", type="primary"):
st.session_state["asset_snapshot_close"] = assets_gateway.close_asset_snapshot(basis_date, source_note=close_note, correction_reason=correction_reason, db_path=db_path) st.session_state["asset_snapshot_close"] = assets_gateway.close_asset_snapshot(
basis_date, source_note=close_note, correction_reason=correction_reason,
selection_policy=selection_policy, allow_missing_exclusions=allow_missing,
allow_as_of_carry_forward=allow_carry, approved_by=approved_by if approval_required else "",
approval_reason=approval_reason if approval_required else "", approval_reference=approval_reference if approval_required else "", db_path=db_path,
)
clear_cache() clear_cache()
preview = st.session_state.get("asset_snapshot_preview") preview = st.session_state.get("asset_snapshot_preview")
close_res = st.session_state.get("asset_snapshot_close") close_res = st.session_state.get("asset_snapshot_close")
@@ -97,6 +144,7 @@ def page_assets(db_path: str) -> None:
st.dataframe(pd.DataFrame(preview.get("included_items", [])), width="stretch", height=220) st.dataframe(pd.DataFrame(preview.get("included_items", [])), width="stretch", height=220)
if preview.get("warnings"): if preview.get("warnings"):
st.warning(preview.get("warnings")) st.warning(preview.get("warnings"))
_render_asset_evidence(preview)
else: else:
st.error(preview.get("error", preview)) st.error(preview.get("error", preview))
if close_res: if close_res:
+37 -2
View File
@@ -11,6 +11,32 @@ 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 reconciliation_gateway
from dashboard.common import * # noqa: F403, F405 from dashboard.common import * # noqa: F403, F405
def _render_asset_evidence(report: dict) -> None:
warnings = report.get("warnings") or []
exclusions = report.get("excluded_items") or report.get("exclusions") or []
carried = report.get("carried_items") or []
provenance = report.get("provenance") or {}
approval = report.get("approval") or provenance.get("approval") or {}
correction = report.get("correction") or provenance.get("correction") or {}
st.markdown("**근거 상세 (코드 / 출처 식별자)**")
for item in warnings:
st.markdown(f"- 경고 `{item.get('code', '-')}` · 출처 {item.get('source_type', '-')} / {item.get('source_id', '-')} · 대상 {item.get('name', '-')}")
if exclusions:
st.markdown("**제외 상세**")
for item in exclusions:
st.markdown(f"- 제외 코드 `{item.get('reason', '-')}` · 출처 {item.get('source_type', '-')} / {item.get('source_id', '-')} · 대상 {item.get('name', '-')}")
if carried:
st.markdown("**이월 항목**")
for item in carried:
st.markdown(f"- 이월 · 출처 {item.get('source_type', '-')} / {item.get('source_id', '-')} · 평가일 {item.get('valuation_date', '-')}")
if approval:
st.markdown(f"**조건부 승인** · 승인자 {approval.get('approved_by', '-')} · 사유 {approval.get('approval_reason', '-')} · 참조 {approval.get('approval_reference', '-')}")
if correction:
st.markdown(f"**정정** · 원본 run {correction.get('correction_of_run_id', '-')} · 사유 {correction.get('correction_reason', '-')}")
st.caption(f"canonical provenance · 평가일 {provenance.get('valuation_date', '-')} · 검증 {provenance.get('verification_status', '-')} · 유형 {provenance.get('evidence_kind', '-')} · run {provenance.get('snapshot_run_id', '-')}")
def page_monthly_settlement(db_path: str) -> None: def page_monthly_settlement(db_path: str) -> None:
query_month = st.query_params.get("month", current_kst_month()) query_month = st.query_params.get("month", current_kst_month())
if isinstance(query_month, list): if isinstance(query_month, list):
@@ -177,6 +203,7 @@ def page_monthly_settlement(db_path: str) -> None:
st.markdown( st.markdown(
item_line, item_line,
) )
st.markdown(f"상태: {reconciliation_status_label.get(raw_status, '정상' if idx == 1 else raw_status)}")
else: else:
st.caption("잔액대조 대상 계좌가 현재 없습니다.") st.caption("잔액대조 대상 계좌가 현재 없습니다.")
@@ -187,7 +214,11 @@ def page_monthly_settlement(db_path: str) -> None:
if asset_snapshot.get("success"): if asset_snapshot.get("success"):
latest_asset_status = "마감 완료" if bool(asset_snapshot.get("is_closed_snapshot")) else "미마감" latest_asset_status = "마감 완료" if bool(asset_snapshot.get("is_closed_snapshot")) else "미마감"
warnings = asset_snapshot.get("warnings") if isinstance(asset_snapshot, dict) else [] warnings = asset_snapshot.get("warnings") if isinstance(asset_snapshot, dict) else []
missing_valuation_items = asset_snapshot.get("missing_valuation_items") 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 = "경고 없음" warning_text = "경고 없음"
if warnings or missing_valuation_items: if warnings or missing_valuation_items:
warning_text = f"경고 {len(warnings or [])}건 / 제외 {len(missing_valuation_items or [])}건" warning_text = f"경고 {len(warnings or [])}건 / 제외 {len(missing_valuation_items or [])}건"
@@ -202,7 +233,11 @@ def page_monthly_settlement(db_path: str) -> None:
)) ))
m4.metric("자산 마감 상태", latest_asset_status) m4.metric("자산 마감 상태", latest_asset_status)
st.caption(f"자산 상세: {warning_text}") st.caption(f"자산 상세: {warning_text}")
st.caption(f"마감 기준일: {asset_snapshot.get('basis_date', basis_date)}") 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=자산/마감)") st.markdown("- [자산/마감 화면에서 스냅샷 확인](?page=자산/마감)")
render_section_label("📜", "월정산 결과 / Evidence") render_section_label("📜", "월정산 결과 / Evidence")
+176
View File
@@ -0,0 +1,176 @@
# 계좌 평가 이력과 자산 마감 데이터 모델
## 목적과 범위
계좌 평가 증거, 수동 자산, append-only 자산 마감과 기존 ledger entities의 실제 SQLite 정본을 기록한다.
- 포함 범위: `accounts`, `transactions`, `manual_assets`, `account_valuations`, `asset_snapshots`, `asset_snapshot_runs`, `reconciliation_sessions`, `review_decisions`, `import_batches`, `schema_meta`
- 제외 범위: auth cookie/session, external price/FX store, inferred transactions, actual credentials/data
## 정본 엔터티
### accounts — 기존 계좌
- 책임: 계좌 identity/current display balance와 account type/liquidity metadata
- 식별자: `id`
- 생명 주기: 기존 CRUD; valuation history는 별도 append-only
#### 필드
| 필드 | 타입 | 필수 여부 | 기본값 | 허용 값·형식 | 단위·정밀도 | 민감정보 |
|---|---|---|---|---|---|---|
| `id` | TEXT | 필수 | 없음 | generated account id | 식별자 | 아니오 |
| `name` | TEXT | 필수 | 없음 | non-empty | 없음 | 아니오 |
| `type` | TEXT | 필수 | `bank` | bank/card/cash/investment/loan/other | enum | 아니오 |
| `currency` | TEXT | 필수 | `KRW` | supported currency | ISO code | 아니오 |
| `last_balance` | INTEGER | 선택 | NULL | exact integer | currency unit | 아니오 |
| `last_balance_basis_date` | TEXT | 선택 | `''` | YYYY-MM-DD or unknown | date of the evidence represented by `last_balance`; empty/unknown is explicit | 아니오 |
| `last_synced_at` | TEXT | 선택 | `''` | ISO date/time | timestamp | 아니오 |
| `statement_status` | TEXT | 필수 | `unknown` | existing status enum | 없음 | 아니오 |
| `liquidity_bucket` | TEXT | 필수 | `other` | cash/deposit/investment/real_estate/other/loan | enum | 아니오 |
| `account_number_masked` | TEXT | 필수 | `''` | masked only | redacted | 민감 가능 |
### account-valuations — 기준일 계좌 평가
- 책임: 날짜가 지정된 평가 evidence와 선택/correction provenance. investment `principal`은 reported principal amount이고 loan `principal`은 outstanding principal이다.
- 식별자: `id`
- 생명 주기: insert-only; correction은 `supersedes_id` child
#### 필드
| 필드 | 타입 | 필수 여부 | 기본값 | 허용 값·형식 | 단위·정밀도 | 민감정보 |
|---|---|---|---|---|---|---|
| `id` | TEXT | 필수 | 없음 | generated valuation id | 식별자 | 아니오 |
| `account_id` | TEXT FK | 필수 | 없음 | accounts.id | 관계 | 아니오 |
| `basis_date` | TEXT | 필수 | 없음 | YYYY-MM-DD | date | 아니오 |
| `amount` | INTEGER | 필수 | 없음 | exact signed integer | currency unit | 아니오 |
| `currency` | TEXT | 필수 | KRW | supported currency | ISO code | 아니오 |
| `valuation_kind` | TEXT | 필수 | balance | balance/cash_balance/total_value/loan/loan_balance | purpose | 아니오 |
| `source_type` | TEXT | 필수 | manual | manual/import/account_write/reconciliation/snapshot | enum | 아니오 |
| `source_ref` | TEXT | 필수 | `''` | source/hash/reference | opaque | 민감 가능 |
| `verification_status` | TEXT | 필수 | unverified | verified/unverified/rejected | enum | 아니오 |
| `recorded_at` | TEXT | 필수 | 없음 | ISO timestamp | timestamp | 아니오 |
| `memo` | TEXT | 필수 | `''` | operator note | 없음 | synthetic only |
| `idempotency_key` | TEXT | 필수 | `''` | caller key | opaque | 아니오 |
| `supersedes_id` | TEXT FK | 선택 | NULL | parent valuation | one child | 아니오 |
| `correction_reason` | TEXT | 필수 | `''` | required for correction | 없음 | 아니오 |
| `as_of_at`/`timezone` | TEXT | 함께 선택 | NULL | timezone-aware ISO + IANA | timestamp/zone | 아니오 |
| `cost_basis`,`net_contributed`,`profit`,`principal`,`interest`,`fee` | INTEGER | 선택 | NULL | exact integer; NULL distinct from 0 | currency unit | 아니오 |
| `reported_principal_kind`,`reported_principal_label`,`return_basis` | TEXT | 선택 | NULL | source-declared | 없음 | 아니오 |
| `return_rate` | REAL | 선택 | NULL | finite number | ratio | 아니오 |
| `representative_liability_definition` | TEXT | loan 필수 | NULL | principal_only/principal_interest_fees | enum | 아니오 |
#### `select_account_valuation` 선택 규칙과 기존 인덱스 매핑
- `select_account_valuation`은 `account_id is mandatory`인 단일 평가 선택 query다. `currency and valuation_kind/purpose are optional filters`이며, `basis_date exact match or <= upper bound` 조건을 지원한다.
- 선택 결과는 `verified leaf valuations only`다. 즉 superseded parent가 아닌 leaf만 권위로 사용하며, `future-dated and unverified valuations are excluded`한다.
- `idx_account_valuations_account_date supports account/date selection`; `idx_account_valuations_status_date supports status/date selection`. `neither index covers account + currency + purpose`이므로 해당 조합의 전체 선택 조건을 기존 인덱스가 대신 보장한다고 해석하지 않는다.
- `idempotency and single_correction indexes enforce write integrity, not selection`이다. 이 인덱스들은 재기록 방지와 correction 단일 child 불변조건을 위한 것이며 `select_account_valuation`의 후보 검색 인덱스가 아니다.
### manual-assets — 수동 자산/부채
- 책임: transaction이 아닌 평가 source
- 식별자: `id`
- 생명 주기: update/deactivate; closed snapshot은 별도 immutable copy
#### 필드
| 필드 | 타입 | 필수 여부 | 기본값 | 허용 값·형식 | 단위·정밀도 | 민감정보 |
|---|---|---|---|---|---|---|
| `id` | TEXT | 필수 | 없음 | generated asset id | 식별자 | 아니오 |
| `name` | TEXT | 필수 | 없음 | non-empty | 없음 | 아니오 |
| `liquidity_bucket` | TEXT | 필수 | other | normalized bucket | enum | 아니오 |
| `valuation_amount` | INTEGER | 선택 | NULL | exact integer; NULL unvalued | currency unit | 아니오 |
| `valuation_date` | TEXT | 필수 | `''` | YYYY-MM-DD | date | 아니오 |
| `currency` | TEXT | 필수 | KRW | supported currency | ISO code | 아니오 |
| `source_note`,`memo` | TEXT | 필수 | `''` | synthetic/redacted note | 없음 | 민감 가능 |
| `active` | INTEGER | 필수 | 1 | 0/1 | boolean | 아니오 |
### asset-snapshots — closed snapshot rows
- 책임: basis-date historical immutable item copy
- 식별자: `id`
- 생명 주기: insert-only; correction run creates new rows
#### 필드
| 필드 | 타입 | 필수 여부 | 기본값 | 허용 값·형식 | 단위·정밀도 | 민감정보 |
|---|---|---|---|---|---|---|
| `id`,`basis_date`,`source_type`,`name`,`created_at` | TEXT | 필수 | 없음 | generated/date/account/manual_asset | identity/date | 아니오 |
| `source_id`,`currency`,`liquidity_bucket`,`valuation_source`,`source_note`,`memo` | TEXT | 필수 | `''`/KRW/other | source metadata | 없음 | 가능 |
| `amount` | INTEGER | 선택 | NULL | exact integer | currency unit | 아니오 |
| `snapshot_run_id` | TEXT | 필수 | `''` | asset_snapshot_runs.id | relation | 아니오 |
| `correction_of_run_id`,`correction_reason` | TEXT | 필수 | `''` | prior run/reason | provenance | 아니오 |
| `is_correction` | INTEGER | 필수 | 0 | 0/1 | boolean | 아니오 |
| `closed_at` | TEXT | 필수 | `''` | ISO timestamp | timestamp | 아니오 |
| `valuation_id`,`selection_policy`,`verification_status`,`evidence_kind`,`exclusion_reason` | TEXT | additive | empty/exact/unverified/applied/empty | provenance enum | 없음 | 아니오 |
### asset-snapshot-runs — close 실행 증거
- 책임: approval, selection, applied/excluded/carried evidence와 run identity
- 식별자: `id`
- 생명 주기: insert-only; correction points to prior run
#### 필드
| 필드 | 타입 | 필수 여부 | 기본값 | 허용 값·형식 | 단위·정밀도 | 민감정보 |
|---|---|---|---|---|---|---|
| `id`,`basis_date`,`close_status` | TEXT | 필수 | 없음 | complete_exact/incomplete_draft/conditional | identity/date/status | 아니오 |
| `selection_policy` | TEXT | 필수 | exact | exact/as_of | enum | 아니오 |
| `idempotency_key` | TEXT | 필수 | `''` | unique when non-empty | opaque | 아니오 |
| `approved_by`,`approved_at`,`approval_reason`,`approval_reference` | TEXT | 필수 | empty | explicit approval metadata | provenance | 아니오 |
| `correction_of_run_id`,`correction_reason` | TEXT | 필수 | empty | prior run/reason | provenance | 아니오 |
| `applied_evidence`,`excluded_evidence`,`carried_evidence` | TEXT JSON | 필수 | `[]` | redacted evidence arrays | JSON | 가능 |
| `created_at` | TEXT | 필수 | 없음 | ISO timestamp | timestamp | 아니오 |
### transactions, import-batches, reconciliation-sessions, review-decisions, schema-meta — 기존 entities
기존 필드와 관계를 보존한다. import는 거래를 기록할 수 있지만 balance valuation candidate는 dated `unverified` row만 추가한다. loan cache writes without an explicit liability definition do not append an `account_valuations` candidate; current cache와 transaction import는 그대로 완료하며 정의를 추정하지 않는다. reconciliation은 calculated balance provenance와 `VALUATION_MANAGED` 상태를 보존하며 investment valuation 때문에 transaction을 생성하지 않는다. `schema_meta.schema_version`은 12다.
### 현재 계좌 잔액 캐시와 기준일
- `last_balance` is the current cache value: it is the latest current balance observed by an import or account write, not a historical valuation and not opening evidence.
- `last_balance_basis_date` is the basis field for the evidence represented by that cache. It is a date when known; legacy or date-less writes store unknown/NULL and must not be inferred from `last_synced_at` or a valuation row.
- `last_synced_at` is the execution/sync timestamp: when the import or account write advanced the cache, not the evidence basis date.
- imports and account writes advance the account cache; non-loan writes may also append a dated `account_valuations` candidate. loan cache writes without an explicit liability definition do not append historical evidence, and updating the cache does not rewrite historical rows.
- `account_valuations` is the historical authority. A dated valuation remains append-only evidence even when it is older than the current cache; older evidence never rolls back the current cache.
- Every valuation attached to a `loan` account has an explicit `principal_only` or `principal_interest_fees` `representative_liability_definition`. A non-loan to loan conversion with any undefined or invalid attached valuation is rejected without changing the account or history; conversion with no valuations is allowed.
## 관계와 불변조건
### 관계
| 출발 엔터티 | 관계 | 대상 엔터티 | 카디널리티 | 필수 여부 | 소유권 | 삭제 규칙 |
|---|---|---|---|---|---|---|
| `accounts` | has valuations | `account-valuations` | 1:N | required | account service | no cascade/history preserved |
| `account-valuations` | supersedes | `account-valuations` | 0..1 child | optional | valuation service | append-only |
| `asset-snapshot-runs` | contains | `asset-snapshots` | 1:N/possibly zero | required | snapshot service | no delete |
| `account-valuations` | evidence for | `asset-snapshots` | 0..N | optional | snapshot run | copy only |
| `import-batches` | produces candidate | `account-valuations` | 0..N | optional | import service | candidate preserved |
| `accounts` | has reconciliation | `reconciliation-sessions` | 1:N | optional | reconciliation service | no valuation side effect |
### 불변조건
- `idx_account_valuations_single_correction` ensures one direct child; correction parent scope is same account/date/currency/purpose. Cyclic or self parent, second child, missing reason, and conflict are no-write failures.
- `source_ref` is account-scoped evidence identity even when an explicit key is supplied; key/source mismatch conflicts. This is evidence idempotency. There is no generic label; only source-declared `reported_principal_kind` and `reported_principal_label` are physical fields.
- only verified leaves are authority; unknown/future/unverified are excluded or warning evidence. `last_balance` is external current comparison, not opening evidence; missing balance_after/opening evidence is `INCOMPLETE_CALCULATION`.
- preview does not insert; close inserts run and rows atomically; latest run is report authority. Reports expose actual valuation_date, age, carry, verification, run, approval, correction, warnings, and exclusions across close response, live, closed, timeseries, and monthly.
- `monthly_source_snapshot_id` is a read-model alias of `snapshot_run_id`, not a persistence field. Investment unavailable and unavailable investment, loan purpose/definition/ambiguity, and reconciliation completeness remain explicit; no fake writes.
- `asset_buckets - loan` is the net-worth formula; representative loan only, no fake transactions.
## 저장과 마이그레이션
- 저장 위치: resolved SQLite path from `jarca_ledger_mcp.infrastructure.sqlite.connection`
- 직렬화 형식: SQLite tables; evidence arrays JSON UTF-8
- 스키마 버전: schema 11 → schema 12 additive
- 기존 데이터 처리: `migrate_db()` creates missing tables/columns/indexes; this is an additive migration with no guessed migration or deletion. legacy rows with unknown basis remain unknown; no inference is permitted.
- 하위 호환성: existing ledger/auth/menu/MCP contracts and legacy rows remain readable
- 롤백 조건: pre-migration backup restore boundary; do not down-migrate or delete valuation history in application code
## 검증
- 정상 데이터: `tests/test_account_valuations.py`, `tests/test_asset_snapshots.py`
- 경계 데이터: NULL/0, stale carry-forward, correction, empty snapshot tables
- 오류 데이터: invalid currency/kind, missing loan definition, idempotency/correction conflict
- 저장 후 재조회: service list/select/report and contract regression tests
- 마이그레이션: `test_*schema*` tests plus `python -m pytest tests/test_account_valuations.py tests/test_asset_snapshots.py`
+174
View File
@@ -259,3 +259,177 @@ Set-Cookie: __Host-jarca_dashboard_auth=; Max-Age=0; Secure; HttpOnly; SameSite=
AppTest는 callback, query mapping, synthetic renderer output과 error/loading event를 검증하지만 실제 browser의 document navigation 부재, paint timing, focus/scroll, network request 또는 perceived shell continuity를 증명하지 않는다. 따라서 `REQ-002.03-AC01`은 authenticated real-browser evidence가 필수이며 AppTest는 companion evidence일 뿐이다. history/back-forward와 path URL은 계약에서 제외한다. AppTest는 callback, query mapping, synthetic renderer output과 error/loading event를 검증하지만 실제 browser의 document navigation 부재, paint timing, focus/scroll, network request 또는 perceived shell continuity를 증명하지 않는다. 따라서 `REQ-002.03-AC01`은 authenticated real-browser evidence가 필수이며 AppTest는 companion evidence일 뿐이다. history/back-forward와 path URL은 계약에서 제외한다.
상세 acceptance target은 `docs/requirements/req-002_dashboard-menu-transition.md`의 `REQ-002.01-AC01`부터 `REQ-002.09-AC01`까지를 따른다. 상세 acceptance target은 `docs/requirements/req-002_dashboard-menu-transition.md`의 `REQ-002.01-AC01`부터 `REQ-002.09-AC01`까지를 따른다.
## 계좌 평가·자산 마감 계약 — REQ-003
### valuation-service
- 책임: `register_account_valuation()`, `list_account_valuations()`, `select_account_valuation()`, `correct_account_valuation()`을 통해 exact/as_of verified leaf와 append-only history를 제공한다.
- 종류·프로토콜: Python service function; `jarca_ledger_mcp.service` re-export
- 호출 대상·operation: `jarca_ledger_mcp.application.account_valuation_service`
- 버전: additive v1
- 호출 주체: Dashboard accounts gateway, MCP tool, import/account write service
- 인증 방식: service layer 자체 MCP auth는 주장하지 않음; Dashboard는 기존 `gate` 선행
- 필요 권한: caller가 DB path와 approval context를 제공
#### 입력
| 필드 | 위치 | 타입 | 필수 여부 | 기본값 | 허용 값·형식 | 의미 | 민감정보 |
|---|---|---|---|---|---|---|---|
| `account_id`/`basis_date`/`amount` | function/MCP | `string/string/int` | register 필수 | 없음 | account id, YYYY-MM-DD, exact integer | valuation identity/value | 아니오 |
| `currency`/`valuation_kind`/`source_type`/`verification_status` | function/MCP | `string` | 선택 | KRW/balance/manual/unverified | canonical enums listed below | 아니오 |
| `idempotency_key` | function/MCP | `string` | 선택 | `''` | stable caller key | retry identity | 아니오 |
| `as_of_at`/`timezone`, optional metrics, loan definition | function/MCP | nullable string/int/float | 선택 | NULL | source validation; loan definition required | evidence metrics | 아니오 |
#### 출력
| 필드 | 위치 | 타입 | 필수 여부 | 허용 값·형식 | 의미 | 민감정보 |
|---|---|---|---|---|---|---|
| `success` | envelope | `bool` | 항상 | true/false | outcome | 아니오 |
| `valuation`/`valuations` | result | object/list | 성공 시 | persisted rows | history/evidence | source_ref may be sensitive |
| `reused`, `carried_forward`, `warning`, `unverified_candidates` | result | bool/string/list | 조건부 | additive | idempotency/selection evidence | 아니오 |
#### 오류
| 코드 | 분류 | 발생 조건 | 재시도 가능 여부 | 호출자 대응 | 실패 시 상태 변경 |
|---|---|---|---|---|---|
| `validation_error` | request | invalid date/type/currency/loan definition | 수정 후 가능 | payload 수정 | no write; no row or cache mutation |
| `not_found` | lookup | account/valuation missing | 수정 후 가능 | id 확인 | 없음 |
| `idempotency_conflict` | conflict | same key different payload | 아니오 | new key or inspect | 없음 |
| `correction_conflict`/`ambiguous_valuation` | conflict | second child/multiple leaves | 수동 검토 | correct evidence | 없음 |
### snapshot-close
- 책임: `preview_asset_snapshot_close()` is read-only; `close_asset_snapshot()` writes an immutable run and rows.
- 종류·프로토콜: Python service, Streamlit `assets_gateway`, MCP tools
- 호출 대상·operation: `jarca_ledger_mcp.application.assets_service`
- 버전: additive v1
- 호출 주체: authenticated Dashboard operator or MCP caller
- 인증 방식: Dashboard existing auth gate; MCP authentication is outside this contract
- 필요 권한: close conditional approval fields when warnings require it
#### 입력
| 필드 | 위치 | 타입 | 필수 여부 | 기본값 | 허용 값·형식 | 의미 | 민감정보 |
|---|---|---|---|---|---|---|---|
| `basis_date`/`selection_policy` | function/MCP/form | `string/string` | basis optional | today/exact | YYYY-MM-DD, exact/as_of | close scope | 아니오 |
| `idempotency_key`/`correction_reason` | function/MCP/form | `string` | conditional | empty | stable key; reason on re-close | retry/correction | 아니오 |
| `allow_missing_exclusions`/`allow_as_of_carry_forward` | function/MCP | `bool` | optional | false | explicit flags | conditional approval need | 아니오 |
| `approved_by`/`approval_reason`/`approval_reference` | function/MCP/form | `string` | conditional | empty | all required together | approval evidence | 아니오 |
#### 출력
| 필드 | 위치 | 타입 | 필수 여부 | 허용 값·형식 | 의미 | 민감정보 |
|---|---|---|---|---|---|---|
| `source`/`is_closed_snapshot`/`snapshot_run_id` | result | string/bool/string | success | live_authority/snapshot | preview vs close | 아니오 |
| `close_status`/`selection_policy`/`warnings` | result | string/string/list | success | complete_exact/incomplete_draft/conditional | authority state | 아니오 |
| `total_assets`/`total_liabilities`/`net_worth` | result | integer | success | exact integer | formula summary | 아니오 |
#### 오류
| 코드 | 분류 | 발생 조건 | 재시도 가능 여부 | 호출자 대응 | 실패 시 상태 변경 |
|---|---|---|---|---|---|
| `conditional_approval_required` | policy | unsafe exclusions/carry-forward without explicit approval | 수정 후 가능 | preview, approve explicitly | 없음 |
| `idempotency_conflict` | conflict | close key payload differs | 아니오 | inspect run | 없음 |
| `validation_error` | request | invalid policy or unreasoned correction | 수정 후 가능 | provide reason | no partial rows |
#### Canonical valuation values and validation
The public contract explicitly supports these exact values: source_type: `manual`, `import`, `account_write`, `reconciliation`, `snapshot`; verification_status: `verified`, `unverified`, `rejected`; valuation_kind: `balance`, `cash_balance`, `total_value`, `loan`, `loan_balance`; representative_liability_definition: `principal_only`, `principal_interest_fees`. These are the currently supported enums, not a delegation to source code.
loan accounts require representative_liability_definition for `loan`, `loan_balance`, and `balance` aliases. Those are the only accepted valuation kinds for loan accounts; `cash_balance` and `total_value` do not represent a loan liability and are rejected. legacy valuation-kind aliases remain accepted and map to the canonical purpose; alias acceptance does not broaden the canonical enum set. Invalid values, missing loan definitions, and incompatible definitions return `validation_error` and perform no write. A validation failure returns `validation_error` and performs no write. Converting an existing non-loan account to `loan` is rejected with `validation_error` if any attached valuation lacks an explicit valid definition; the account and valuation history remain unchanged. Conversion with no valuations remains allowed.
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.
### shared contract and adapter limits
All new interfaces retain the existing `success`/`error_code` envelope. `preview_asset_snapshot_close()` and Dashboard render/report are read-only; only explicit register/update/close forms or MCP calls write. MCP tools are `register_account_valuation`, `list_account_valuations`, `select_account_valuation`, `correct_account_valuation`, `preview_asset_snapshot_close`, `close_asset_snapshot`, `get_asset_snapshot_report`, and `get_net_worth_timeseries`; actual decorators are in `jarca_ledger_mcp/adapters/mcp/server.py`. Dashboard uses `accounts_gateway.py`, `assets_gateway.py`, and `page_views/accounts.py`/`assets.py`.
## 호환성 추가 규칙 — REQ-003
- 유지할 기존 호출 방식: REQ-001 auth config/login/cleanup/logout/gate, REQ-002 exact 10 menu labels/order, `page` query sibling preservation, stable shell/background and browser evidence; existing MCP stdio/streamable-http runtime.
- 버전 변경 정책: additive fields only; no rename/removal of existing response keys.
- 폐기 정책: 없음; guessed migration, fake transactions and MCP auth claim are excluded.
- 재시도 계약: same idempotency payload reuses; changed payload conflicts; DB conflict does not retry blindly.
- 중복 처리 계약: valuation/run idempotency keys and single correction indexes.
- 순서 보장: Dashboard auth → DB path → page/gateway; preview → explicit approval → atomic close; selection → report.
## 예시 추가
### example-valuation — synthetic verified valuation
- 대상 인터페이스: `valuation-service`
#### 호출
```text
register_account_valuation(account_id="acct-synthetic", basis_date="2026-06-30", amount=1000000, verification_status="verified", idempotency_key="fixture-v1")
```
#### 결과
```text
{"success": true, "reused": false, "valuation": {"basis_date": "2026-06-30", "amount": 1000000, "verification_status": "verified"}}
```
### example-preview — read-only close preview
- 대상 인터페이스: `snapshot-close`
#### 호출
```text
preview_asset_snapshot_close(basis_date="2026-06-30", selection_policy="exact")
```
#### 결과
```text
{"success": true, "source": "live_authority", "is_closed_snapshot": false, "close_status": "complete_exact", "warnings": []}
```
## 검증 추가
- 정상 계약: `tests/test_account_valuations.py`, `tests/test_asset_snapshots.py`, `tests/test_contract_regression.py`
- 실패 계약: idempotency/correction/conditional approval tests and source validation
- 경계 계약: exact/as_of, NULL/0, future/unverified candidate, correction/re-close
- 구현·스키마·예시 정합성: `tests/test_requirement_docs.py`, actual service/MCP/gateway symbols
### canonical implementation signatures and fields
The application contracts are these actual signatures (defaults included):
```python
register_account_valuation(account_id: str, basis_date: str, amount: int, currency: str = "KRW", valuation_kind: str = "balance", source_type: str = "manual", source_ref: str = "", verification_status: str = "unverified", memo: str = "", idempotency_key: str = "", as_of_at: str | None = None, timezone: str | None = None, cost_basis: int | None = None, net_contributed: int | None = None, reported_principal_kind: str | None = None, reported_principal_label: str | None = None, profit: int | None = None, return_rate: float | None = None, return_basis: str | None = None, principal: int | None = None, interest: int | None = None, fee: int | None = None, representative_liability_definition: str | None = None, db_path: str | None = None, supersedes_id: str | None = None, correction_reason: str = "") -> dict[str, Any]
list_account_valuations(account_id: str, include_rejected: bool = True, db_path: str | None = None) -> dict[str, Any]
select_account_valuation(account_id: str, basis_date: str, as_of: bool = False, currency: str | None = None, valuation_kind: str | None = None, db_path: str | None = None) -> dict[str, Any]
correct_account_valuation(valuation_id: str, amount: int, correction_reason: str, idempotency_key: str = "", db_path: str | None = None, **fields: Any) -> dict[str, Any]
preview_asset_snapshot_close(basis_date: str = "", db_path: str | None = None, selection_policy: str = "exact") -> dict[str, Any]
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]
get_asset_snapshot_report(basis_date: str = "", db_path: str | None = None, selection_policy: str = "exact") -> dict[str, Any]
```
Every output is a dict with `success`; failures also have `error_code` and `error`. Successful close responses expose top-level `approval`, `correction`, `warnings`, `excluded_items`, and `carried_items`, plus `snapshot_run_id` and `close_status`. Their nested `provenance` contains `valuation_date`, `age_days`, `carried_forward`, `verification_status`, `evidence_kind`, `approval`, `correction`, `warnings`, and redacted `exclusions`. `last_balance` is an external current comparison, not opening evidence; absent balance_after/opening evidence returns `INCOMPLETE_CALCULATION`. `source_ref` is account-scoped evidence identity even with an explicit key; key/source mismatch is a no-write conflict. Correction requires same parent account/date/currency/purpose, rejects cyclic/self parent, permits one child, and requires reason.
### canonical read and provenance rules
`monthly_source_snapshot_id` is a read-model alias of `snapshot_run_id`, not a persistence field. The alias and actual valuation date/age/carry/verification/run/approval/correction/warnings/exclusions are exposed through each response's documented top-level fields, `included_items`, and nested `provenance` in close, live, closed, timeseries, and monthly read models. Reconciliation states investment unavailable, loan purpose/definition/ambiguity, and completeness without fake writes.
### C13 database boundary
Writer/bootstrap explicitly calls `init_db()`. `read_db()` uses SQLite `mode=ro`; missing database returns `db_not_initialized`, while initialized database read failures return `db_error`. Both failures retain the standard `success=false`, `error_code`, `error` envelope without raw SQLite details. Read paths perform no mkdir, no migrate, and no commit. Only first-use write initializes, and Dashboard auth is evaluated before DB access.
## D04 snapshot close output contract
Every successful snapshot close output has these exact top-level fields (the transport envelope may additionally include `success`): `snapshot_run_id`, `close_status`, `approval`, `correction`, `warnings`, `excluded_items`, `carried_items`, `provenance`, `valuation_date`, `age_days`, `verification_status`, `evidence_kind`, and `monthly_source_snapshot_id`. `monthly_source_snapshot_id` is an alias of `snapshot_run_id`, not a persistence field.
The top-level `provenance` object has these exact nested `provenance` fields: `valuation_date`, `age_days`, `carried_forward`, `verification_status`, `evidence_kind`, `approval`, `correction`, `warnings`, `excluded_items`, and `carried_items`. `approval` and `correction` are objects or explicit empty values when not applicable; warnings and item collections are arrays. `valuation_date` is the actual selected evidence date, `age_days` is computed from the requested basis date, and verification/evidence status describe selected evidence. Live, closed, timeseries, and monthly read models preserve these fields and alias semantics.
The preview input includes basis_date, selection_policy, included/excluded evidence state hashes, warnings, and candidate_state_hashes. preview output includes preview_fingerprint. Close accepts the exact `preview_fingerprint: str = ""` parameter shown in the signature block and recomputes the same authority fingerprint before opening the write boundary. A mismatch returns `authority_drift` and no partial write.
Replay uses replay payload equality: an existing idempotency key replays only when the persisted request fingerprint is equal; a changed payload returns `idempotency_conflict`. The replay response is reconstructed from the persisted run and its evidence, with no replay-only reused fields.
`close_metadata` is a recursive close metadata allowlist: only run, approval, correction, warnings, exclusions, carried evidence, and allowlisted evidence fields (`source_type`, `source_id`, `authority_source`, `valuation_id`, dates/statuses and `state_hash`) cross the boundary. `applied_evidence`, `excluded_evidence`, and `carried_evidence` are redacted recursively; unknown nested keys are dropped, and credentials, tokens, cookies, raw account data, and arbitrary source-note payloads are never returned. This is the recursive close_metadata allowlist and redaction contract.
+92
View File
@@ -118,3 +118,95 @@ deploy/k8s/README.md
- 정적 분석 기대 결과: exit 0 with no diagnostics - 정적 분석 기대 결과: exit 0 with no diagnostics
- 문서와 실제 트리의 정합성: every listed source/test/document path exists; dependency table has no forbidden reverse edge; REQ-002 symbols resolve to `dashboard/app.py`, `dashboard/common.py`, and named tests - 문서와 실제 트리의 정합성: every listed source/test/document path exists; dependency table has no forbidden reverse edge; REQ-002 symbols resolve to `dashboard/app.py`, `dashboard/common.py`, and named tests
## REQ-003 additive structure
### ledger-application — shared valuation and snapshot services
```text
jarca_ledger_mcp/
├── application/account_valuation_service.py
├── application/assets_service.py
├── application/accounts_write_services.py
├── application/imports_service.py
├── application/reconciliation_service.py
├── infrastructure/sqlite/migrations.py
├── adapters/mcp/server.py
└── adapters/streamlit/{accounts_gateway.py,assets_gateway.py}
```
| 경로 | 종류 | 책임 | 공개 진입점 | 내부 구현 경계 | 편집 정책 | 생성 주체 |
|---|---|---|---|---|---|---|
| `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 |
| `docs/data-model.md` | canonical doc | entity/schema authority | document | no secrets | update with schema | developer |
### 의존 방향 추가
| 출발 영역 | 대상 영역 | 허용 여부 | 규칙 | 공유 계약 위치 |
|---|---|---|---|---|
| `dashboard.page_views` | `streamlit gateways` | 허용 | page → gateway only | `docs/interface-contract.md` |
| `streamlit gateways` | `application services` | 허용 | adapter delegates; no SQL | `docs/runtime-flow.md` |
| `MCP adapters` | `application services` | 허용 | tool delegates; no duplicate business logic | `docs/interface-contract.md` |
| `application services` | `infrastructure.sqlite` | 허용 | service → shared db/schema | `docs/data-model.md` |
| `application services` | `dashboard` | 금지 | no reverse UI dependency | `docs/project-structure.md` |
### 변경 규칙 추가
| 구분 | 규칙 |
|---|---|
| 새 모듈·파일 배치 | valuation/snapshot logic stays in application; adapters remain thin; no direct SQL in page/MCP |
| 이름·패키지 | existing snake_case modules and service re-exports |
| 소스·테스트 대응 | every valuation/snapshot change paired with named synthetic tests and docs validator |
| 생성 산출물 | only redacted synthetic evidence; no credentials, cookie, token or actual account data |
## REQ-003 preservation inventory
REQ-001 auth gate, cookie/cleanup/logout and auth-before-DB; REQ-002 menu labels/order, query sibling preservation, stable shell/background and browser evidence remain in the preceding sections. Missing semantic items: 0. Ambiguous semantic items: 0.
### REQ-003 canonical module ownership and regression map
| 경로 | 책임·실패 경계 |
|---|---|
| `jarca_ledger_mcp/application/account_valuation_service.py` | register/list/select/correct; principal mapping, source_ref identity, parent scope, idempotency and no-write conflicts |
| `jarca_ledger_mcp/application/assets_service.py` | preview/close/report; valuation_date/age/carry, approval, correction, warnings/exclusions and atomic run |
| `jarca_ledger_mcp/application/asset_account_services.py` | shared account/asset evidence and reconciliation boundary |
| `jarca_ledger_mcp/infrastructure/sqlite/connection.py` | `read_db()` mode=ro and `db_not_initialized` without mkdir/migrate/commit |
| `jarca_ledger_mcp/infrastructure/sqlite/migrations.py` | explicit `init_db()` and additive migration |
| `jarca_ledger_mcp/application/monthly_settlement_service.py` | monthly read model; `monthly_source_snapshot_id` alias only, never persistence |
Named transitions are `valuation-01`→`valuation-02`→`valuation-03`→`valuation-04`, `close-01`→`close-02`→`close-03`→`close-04`, `prop-01`→`prop-02`→`prop-03`, `db-init-01`, `db-read-01`, `db-read-02`, and `write-first-use-01`; named failures are `valuation-fail`, `close-fail`, `prop-fail`, `db_not_initialized`, `conditional_approval_required`, `idempotency_conflict`, and `correction_conflict`. `auth-before-DB` precedes all reads.
Regression tests are `test_valuation_round_trip_preserves_null_zero_and_physical_amount_mapping`, `test_source_ref_is_account_scoped_evidence_identity_without_explicit_key`, `test_direct_correction_requires_matching_existing_parent_and_reason`, `test_investment_without_verified_valuation_and_without_opening_balance_is_incomplete`, `test_timeseries_and_monthly_expose_canonical_asset_provenance`, and `test_read_queries_missing_nested_db_return_stable_error_without_creating_path`. D06 requires these names and actual paths/signatures, not marker prose.
## D01 preservation matrix
| item | source evidence | test evidence |
|---|---|---|
| schema 11 legacy rows | `infrastructure/sqlite/migrations.py`: additive schema 12 migration | `tests/test_issue_12_regressions.py::test_schema12_migration_preserves_legacy_rows_indexes_and_is_idempotent` |
| auth gate and cookie lifecycle | `dashboard/server.py`: login, cleanup, logout, auth-before-DB gate | `tests/test_dashboard_authentication.py`, `tests/test_dashboard_persistent_authentication.py` |
| REQ-002 menu labels/order | `dashboard/common.py`: canonical 10-option menu and query transition | `tests/test_dashboard_menu_transition.py` |
| MCP envelope and tools | `adapters/mcp/server.py`: shared success/error response envelope and named tools | `tests/test_contract_regression.py` |
| reconciliation and monthly read models | `application/reconciliation_service.py`, `application/monthly_settlement_service.py`: read-only propagation | `tests/test_issue_12_review59_reports.py`, `tests/test_service.py::test_monthly_settlement_summary_read_model_foundation` |
Each row is an independently reviewable preservation claim: the source column names the canonical implementation boundary and the test column names executable evidence. A prose inventory without both references is not evidence.
## D05 canonical ownership
| concern | owner | boundary |
|---|---|---|
| monthly settlement | `monthly_settlement_service.py` | read-only monthly read model; `monthly_source_snapshot_id` aliases `snapshot_run_id`, never persistence |
| reconciliation | `reconciliation_service.py` | computes completeness/status and provenance; never creates fake transactions |
| policy | `assets_service.py` | owns exact/as-of, approval, exclusion/carry, correction and drift policy decisions |
| read-only | `assets_service.py` preview/report path | preview is no write: no insert, migrate, mkdir, or commit; explicit close is the write boundary |
| redaction | adapters/report serializers | only redacted evidence crosses output boundary; no credential/token/cookie/raw account data |
The owner is the canonical decision or write boundary; consumers render or read its result and do not reimplement policy.
The Dashboard gateway is `jarca_ledger_mcp/adapters/streamlit/overview_gateway.py` (including `get_asset_snapshot_report` and reconciliation/monthly read delegates). It calls shared application read services after `auth-before-DB`; page rendering is not an alternate policy or persistence boundary. A missing DB has stable unavailable semantics (`db_not_initialized`) and no-create behavior. The missing DB stable unavailable contract is no-create. An initialized DB read failure is `db_error`; initialized read has no write, migration, mkdir, or commit. The initialized DB read failure is db_error and initialized read has no write.
Reconciliation ownership remains in `reconciliation_service.py`: opening evidence and transaction coverage are required inputs. Missing opening evidence or incomplete coverage produces `INCOMPLETE_CALCULATION`/`reconciliation unavailable` with provenance, never an inferred balance or fake transaction.
@@ -0,0 +1,317 @@
---
{
"schema": "gitea-agent.requirement/v1",
"id": "REQ-003",
"title": "계좌 기준일 평가 이력과 자산 마감 연동",
"revision": "1.0",
"status": "approved",
"depends_on": [],
"applies_to": ["ledger", "dashboard", "mcp"],
"language": "ko",
"issue": "#12",
"implementation_pr": "#13"
}
---
# REQ-003 — 계좌 기준일 평가 이력과 자산 마감 연동
## 1. 목적
계좌의 기준일 평가 증거를 append-only 이력으로 저장·선택하고, 검증 가능한 자산만 자산/마감 및 월정산의 역사적 순자산에 반영한다.
## 2. 배경
구현은 SQLite schema 12를 schema 11에 additive하게 적용한다. 계좌 잔액·import 결과는 실제 증거가 없는 한 `unverified` 후보이고, 기존 거래·인증·메뉴 계약을 바꾸지 않는다.
## 3. 범위
### 포함
- `account_valuations`, `asset_snapshot_runs`, `asset_snapshots` 확장과 exact/as_of 선택
- idempotency, append-only correction, 투자 optional metrics, loan 대표 부채
- read-only preview, explicit approval conditional close, immutable evidence, correction/re-close
- 계좌·import 후보, reconciliation, MCP·Dashboard·월정산 공통 read model
### 제외
- credential/실제 금융 데이터, 자동 추정 거래 생성, fake investment transaction, 거래 원장 재작성
- 외부 자산 가격 API, 환율 변환, 자동 승인, unverified/future/unknown 값 승인
- Dashboard 인증·logout·cookie, MCP 인증의 신규 도입, 기존 메뉴 순서 변경
## 4. 용어와 기준
- **verified leaf**: `supersedes_id`로 더 최신 child가 없는 `verification_status=verified` 행.
- **exact**: 요청 `basis_date`와 일치하는 verified leaf만 선택한다.
- **as_of**: 미래를 보지 않고 요청일 이전 최신 verified leaf를 선택하며 carry-forward와 age를 표시한다.
- **last_balance**: opening evidence가 아니라 외부 현재값과 비교하는 external current comparison이다. opening evidence와 balance_after가 모두 없으면 `INCOMPLETE_CALCULATION`이다.
- **principal 물리 필드**: `principal`은 investment의 reported principal amount 또는 loan의 outstanding principal이며, `reported_principal_kind`와 `reported_principal_label`만 source-declared 의미를 보존한다. generic label 필드는 없다.
- **대표 부채**: `valuation_kind`가 `loan`/`loan_balance`이고 `representative_liability_definition`이 `principal_only` 또는 `principal_interest_fees`인 하나의 대표 금액만 합산한다. loan purpose, definition, ambiguity는 report에 보존한다.
- **정본 구현**: `jarca_ledger_mcp/application/account_valuation_service.py`, `assets_service.py`, `reconciliation_service.py`, `migrations.py` 및 named tests.
## 5. 요구사항
### REQ-003.01 — additive 이력과 legacy 보존
schema 11의 테이블·필드·거래를 삭제하거나 추측 이주하지 않고 schema 12의 `account_valuations`, `asset_snapshot_runs`를 추가하며 `asset_snapshots`에 필요한 필드를 additive하게 확장한다. migration은 idempotent다.
**영향 범위**
- 인터페이스: 기존 service/MCP 응답 envelope 유지, 신규 valuation/snapshot fields additive
- 영속성: SQLite schema 11→12, 기존 rows 보존
- 후속 사용 주체: service, MCP tool, Dashboard gateway/page, 월정산
- 마이그레이션: `migrate_db()`의 `CREATE TABLE IF NOT EXISTS`/`ADD COLUMN`
- 호환성: REQ-001 auth, cookie/logout, REQ-002 메뉴 10개/order, query/shell/browser evidence 유지
- 배포 전제 조건: SQLite write 권한과 기존 DB 백업
### REQ-003.02 — verified leaf 선택과 범위
exact/as_of 선택은 account/currency/valuation_kind scope를 적용하고 future, unverified, rejected, unknown을 authority에서 제외한다. newer unverified candidate는 warning으로 남기며 investment는 `total_value`, `balance`, `cash_balance` 우선, loan은 `loan_balance`, `loan`, `balance` 우선이다.
**영향 범위**
- 인터페이스: `select_account_valuation()`, `_account_valuation_items()`, report warnings
- 영속성: 상태·기준일·통화·kind와 candidate evidence 보존
- 후속 사용 주체: snapshot preview/close, Dashboard 계좌·자산, reconciliation
- 마이그레이션: 없음
- 호환성: 기존 `last_balance` fallback은 basis-date 없는 legacy report에서만 유지
- 배포 전제 조건: ISO date와 허용 currency/kind
### REQ-003.03 — idempotency와 append-only correction
같은 idempotency payload는 기존 row/run을 재사용하고 다른 payload는 `idempotency_conflict`다. `source_ref`와 fingerprint가 evidence idempotency를 이룬다. correction은 원본을 갱신하지 않고 단일 child를 append한다. correction parent scope는 same account/date/currency/purpose이고 cyclic parent와 self parent는 거절한다. parent당 one child만 허용하며 correction_reason은 필수다. reason 누락, parent 불일치, cyclic/self parent, 다른 child 및 DB 충돌은 no-write `correction_conflict`다. `source_ref`는 account-scoped evidence identity이며 explicit key가 있어도 evidence identity에 적용한다. key/source mismatch는 conflict다.
**영향 범위**
- 인터페이스: `register_account_valuation()`, `correct_account_valuation()`, `close_asset_snapshot()`
- 영속성: unique idempotency 및 single-correction indexes
- 후속 사용 주체: retry caller와 history/report
- 마이그레이션: unique indexes additive
- 호환성: 성공/실패 envelope와 기존 error_code 유지
- 배포 전제 조건: transaction-scoped SQLite write
### REQ-003.04 — 금액·옵션·대출 의미
amount와 principal/interest/fee/cost_basis/net_contributed/profit은 exact integer이고 NULL은 미제공, 0은 명시적 영(0)이다. 투자 optional metrics는 저장하되 total valuation을 다시 합산하지 않는다. loan은 대표 정의를 필수로 하고 principal/interest/fee의 unknown을 NULL로 보존하며 대표 금액만 총부채에 합산한다.
**영향 범위**
- 인터페이스: valuation input/output fields, validation errors
- 영속성: INTEGER/REAL return_rate와 nullable optional columns
- 후속 사용 주체: net-worth formula와 UI labels
- 마이그레이션: 없음
- 호환성: 기존 integer ledger amounts와 double-sum 금지
- 배포 전제 조건: caller가 currency/definition을 명시
### REQ-003.05 — current와 historical authority
current latest display와 historical authority를 분리한다. `last_balance`는 opening evidence가 아니라 external current comparison이며, balance_after/opening evidence가 모두 없으면 `INCOMPLETE_CALCULATION`이다. 늦게 도착한 historical row는 current를 과거로 되돌리지 않으며 cannot roll back newer current cache. account/import writes는 real dated evidence가 있을 때만 dated `unverified` candidate를 만든다. 대출 cache write는 대표 부채 정의가 없으면 이력 후보를 추가하지 않는다. 이때 current cache와 import 처리는 유지하고 정의를 추정하지 않는다. 기존 valuation이 있는 계좌를 loan으로 변환할 때는 모든 valuation에 유효한 대표 부채 정의가 있어야 하며, 위반 시 계좌와 이력을 변경하지 않고 `validation_error`를 반환한다. valuation이 없는 변환은 허용한다.
**영향 범위**
- 인터페이스: account update/import candidate, list/select/report
- 영속성: `basis_date`/`recorded_at` 분리
- 후속 사용 주체: 계좌 화면, reconciliation, monthly settlement
- 마이그레이션: 없음
- 호환성: account name-only update는 candidate를 만들지 않음
- 배포 전제 조건: source date와 source_ref/hash
### REQ-003.06 — preview·close·approval
preview는 DB를 쓰지 않고 `complete_exact`, `incomplete_draft`, `conditional` 상태와 warnings/exclusions를 반환한다. close response는 run, approval, correction, warnings, exclusions를 포함한다. close는 need-based explicit `approved_by`, `approval_reason`, `approval_reference`와 flags를 요구하며 immutable run evidence를 기록한다. explicit dated manual asset은 검증된 manual evidence로 취급할 수 있다. unverified/future/unknown/ambiguous, including future unknown, 은 승인 없이 close할 수 없다.
**영향 범위**
- 인터페이스: `preview_asset_snapshot_close()`, `close_asset_snapshot()` 및 error codes
- 영속성: run applied/excluded/carried evidence와 approval metadata
- 후속 사용 주체: Dashboard 자산/마감, MCP operator
- 마이그레이션: `asset_snapshot_runs` 신규
- 호환성: preview read-only, 기존 report response fields 유지
- 배포 전제 조건: operator approval identity와 reference
### REQ-003.07 — correction/re-close와 formula
같은 basis date close는 correction_reason을 요구하고 새 run/rows를 append한다. 최신 run만 historical report authority이며 순자산은 `asset_buckets - loan`, missing valuation은 transaction inference 없이 제외한다.
**영향 범위**
- 인터페이스: close/report/timeseries
- 영속성: `correction_of_run_id`, `is_correction`, `closed_at`
- 후속 사용 주체: timeseries와 월정산
- 마이그레이션: asset snapshot columns/indexes
- 호환성: legacy `asset_snapshots` rows are readable
- 배포 전제 조건: close transaction atomicity
### REQ-003.08 — reconciliation과 monthly propagation
investment가 verified valuation으로 관리되면 reconciliation status는 `VALUATION_MANAGED`이고 fake transaction을 만들지 않는다. report는 unavailable investment를 명시한다. investment unavailable과 reconciliation completeness를 report에 명시한다. regular ledger calculation은 `calculation_basis`/`calculation_basis_at` provenance를 유지한다. monthly settlement는 read-only로 동일 snapshot/report를 전파하며 no fake writes를 보장한다. live/current, closed, timeseries, monthly 각각은 actual valuation_date, age, carry, verification, run, approval, correction, warnings, exclusions를 보존한다. `monthly_source_snapshot_id`는 `snapshot_run_id`의 read-model alias이며 persistence field가 아니다.
**영향 범위**
- 인터페이스: `get_balance_reconciliation_report()`, `get_monthly_settlement_summary()`
- 영속성: reconciliation session/transaction은 valuation 때문에 자동 변경하지 않음
- 후속 사용 주체: 월정산·overview·reconciliation UI
- 마이그레이션: 없음
- 호환성: existing reconciliation statuses and reports additive
- 배포 전제 조건: service read model available
canonical D04-D06 contract: snapshot close aliases `valuation_date`, `age_days`, `verification_status`, `evidence_kind`, and `monthly_source_snapshot_id` at the top level while preserving the same meanings in nested provenance. Replay requires request-fingerprint equality and reconstructs from persisted evidence, never replay-only reused fields. Recursive `close_metadata` is allowlisted/redacted. Preview fingerprint input/output and authority drift rejection are mandatory. Dashboard missing DB is stable unavailable with no-create; initialized reads are no-write. Reconciliation requires opening evidence and coverage; incomplete state is `INCOMPLETE_CALCULATION`/`reconciliation unavailable`, with no inferred balance or fake transaction.
### REQ-003.09 — shared application과 보안 경계
MCP와 Dashboard는 shared service/gateway를 사용한다. 기존 success/error envelope를 유지한다. Dashboard는 auth-before-DB이며 MCP auth를 claim하지 않는다. render는 read-only이고 implicit write를 일으키지 않는다.
**영향 범위**
- 인터페이스: MCP `@mcp.tool`, Streamlit gateways/pages
- 영속성: render/preview에는 write 없음; explicit form/close만 write
- 후속 사용 주체: browser/operator/automation
- 마이그레이션: 없음
- 호환성: REQ-001 gate, cleanup/logout, MCP transport/auth scope 유지
- 배포 전제 조건: Dashboard auth config, MCP runtime config
### REQ-003.10 — privacy, compatibility, excluded scope
문서·tests·fixtures는 synthetic data만 사용하고 actual credential/account/financial data를 저장하지 않는다. Issue #12 밖의 OAuth, external prices, FX, automatic approval, transaction inference, redesign는 구현하지 않는다.
**영향 범위**
- 인터페이스: redacted evidence와 generic errors
- 영속성: synthetic source_note/memo only
- 후속 사용 주체: tests/operator
- 마이그레이션: 없음
- 호환성: 기존 auth/menu/query/shell/browser evidence semantic inventory 0 missing
- 배포 전제 조건: secret redaction과 fixture DB
## 6. 제약과 불변조건
- verified leaf만 authority이며 future/unverified/rejected/unknown은 합산하지 않는다.
- 동일 idempotency payload는 같은 ID를 반환하고 다른 payload는 conflict다.
- correction과 re-close는 append-only이고 parent당 child/run은 하나다.
- NULL과 0을 구별하고 모든 금액은 integer precision이다.
- loan representative만 liability sum에 들어가고 investment metrics는 double sum하지 않는다.
- preview와 render는 DB write가 없고 close는 하나의 transaction 경계다.
- current display와 historical basis authority는 분리된다.
- Dashboard auth gate는 DB보다 먼저이며 MCP 인증은 이 요구사항이 주장하지 않는다.
- 실제 credential·token·cookie·account data는 문서·로그·fixture에 없다.
## 7. 인수 기준
### REQ-003.01-AC01 — migration and preservation
**Cases (정상/경계/실패)**: 정상 schema 12 init twice; 경계 schema 11 legacy DB; 실패 malformed legacy DB.
**State Invariants**: legacy tables/rows remain; migration repeats without duplicate columns.
**판정 기준**: `schema_version=12`, required tables/columns/indexes exist and old rows remain.
**검증**: `tests/test_account_valuations.py::test_account_valuation_schema_is_v12_and_idempotent`, `tests/test_issue_12_regressions.py::test_schema12_migration_preserves_legacy_rows_indexes_and_is_idempotent`.
**검증 분류**: level=integration; backend=sqlite
### REQ-003.02-AC01 — selection policy
**Cases (정상/경계/실패)**: exact verified; as_of carry-forward; future/unverified/ambiguous excluded/warned.
**State Invariants**: no future/unverified row is selected.
**판정 기준**: exact returns same-date verified leaf; as_of returns latest prior verified and age/warning.
**검증**: `tests/test_account_valuations.py::test_select_exact_and_as_of_excludes_unverified_and_reports_carry_forward`, source `_account_valuation_items()`.
**검증 분류**: level=unit; backend=application
### REQ-003.03-AC01 — idempotent correction
**Cases (정상/경계/실패)**: same payload reuse; different payload conflict; second correction fails.
**State Invariants**: one row/child per operation and original immutable.
**판정 기준**: exact error codes and count 2 after one correction.
**검증**: `tests/test_account_valuations.py::test_register_same_idempotency_payload_reuses_row_and_changed_payload_conflicts`, `tests/test_account_valuations.py::test_correction_is_append_only_reasoned_and_single_child`, `tests/test_issue_12_regressions.py::test_empty_conditional_close_is_closed_and_second_close_requires_correction`.
**검증 분류**: level=integration; backend=sqlite
### REQ-003.04-AC01 — precision and representative loan
**Cases (정상/경계/실패)**: integer zero; NULL optional metrics; loan missing definition rejected.
**State Invariants**: no float amount and no double sum.
**판정 기준**: optional fields round-trip exactly and loan definition validation is enforced.
**검증**: `test_correction_preserves_optional_fields_unless_overridden`, `_validate_common()` and schema static checks.
**검증 분류**: level=static; backend=sqlite
### REQ-003.05-AC01 — authority separation
**Cases (정상/경계/실패)**: dated candidate; late historical candidate; name-only update.
**State Invariants**: current/latest display cannot roll backward and candidate is unverified.
**판정 기준**: dated account write creates only unverified candidate; name-only does not.
**검증**: `test_account_balance_write_records_unverified_candidate_only_with_real_basis_date`, `test_account_name_only_update_does_not_create_candidate`.
**검증 분류**: level=unit; backend=application
### REQ-003.06-AC01 — preview and approval
**Cases (정상/경계/실패)**: complete exact close; incomplete draft; missing approval rejected.
**State Invariants**: preview row count unchanged; no unsafe evidence approved.
**판정 기준**: preview is read-only and close returns `conditional_approval_required` without metadata.
**검증**: `test_manual_asset_update_deactivate_preview_and_close_correction_policy`, `preview_asset_snapshot_close()`.
**검증 분류**: level=integration; backend=application
### REQ-003.07-AC01 — immutable close correction
**Cases (정상/경계/실패)**: first close; same-date correction; second unreasoned close.
**State Invariants**: old run/rows remain and latest run alone reports.
**판정 기준**: `correction_of_run_id`, count, and net worth match.
**검증**: `test_manual_asset_update_deactivate_preview_and_close_correction_policy`, `assets_service.py` close path.
**검증 분류**: level=integration; backend=sqlite
### REQ-003.08-AC01 — reconciliation/monthly
**Cases (정상/경계/실패)**: regular ledger calculation; verified investment; missing baseline.
**State Invariants**: no fake transaction; provenance fields present; monthly read is non-mutating.
**판정 기준**: status `VALUATION_MANAGED` for verified investment and monthly summary reads report.
**검증**: `tests/test_service.py::test_statement_status_account_update_and_balance_reconciliation`, `tests/test_issue_12_regressions.py::test_reconciliation_uses_corrected_investment_leaf_without_transaction_count_change`, `tests/test_service.py::test_monthly_settlement_summary_read_model_foundation`.
**검증 분류**: level=unit; backend=application
### REQ-003.09-AC01 — shared adapters and auth
**Cases (정상/경계/실패)**: MCP stdio/http config; authenticated Dashboard render; unauthenticated gate.
**State Invariants**: auth precedes DB; render does not write; response envelope has `success`.
**판정 기준**: actual tool/gateway names resolve and existing auth/menu tests pass.
**검증**: `tests/test_mcp_http_runtime.py`, `tests/test_dashboard_authentication_app.py`, `tests/test_dashboard_menu_transition.py`, `server.py` and gateways.
**검증 분류**: level=integration; backend=dashboard
### REQ-003.10-AC01 — privacy and compatibility
**Cases (정상/경계/실패)**: synthetic fixture; redaction search; actual secret/data detection.
**State Invariants**: no credential/token/cookie/real financial data in docs/tests/evidence.
**판정 기준**: semantic inventory missing=0 and ambiguous=0; placeholder=0.
**검증**: `tests/test_requirement_docs.py`, auth/menu evidence tests, repository redaction/static search.
**검증 분류**: level=static; backend=dashboard
## 8. 오류 및 충돌 계약
공통 envelope는 성공 시 `success: true`, 실패 시 `success: false, error_code, error`를 유지한다. 주요 codes는 `validation_error`, `not_found`, `idempotency_conflict`, `correction_conflict`, `ambiguous_valuation`, `conditional_approval_required`, `db_error`다. SQLite unique/transaction conflict는 추가 write 없이 실패한다.
## 9. 데이터 및 인터페이스 계약
서비스는 `jarca_ledger_mcp.service` re-export를 통해 호출되고 MCP는 `jarca_ledger_mcp.adapters.mcp.server` tools를 노출한다. Dashboard는 `adapters/streamlit/accounts_gateway.py`, `assets_gateway.py`와 `dashboard/page_views/accounts.py`, `assets.py`를 사용한다. 실제 fields/signatures는 `docs/interface-contract.md`에 고정한다.
## 10. 하위 호환성
REQ-001의 auth gate·cookie/cleanup/logout, REQ-002의 메뉴 10개와 order(`월정산`, `요약`, `자산/마감`, `거래내역`, `확인필요 큐`, `미분류`, `분류 규칙`, `카테고리`, `계좌`, `Import`), query/shell/background와 browser evidence 계약을 삭제·축약하지 않고 additive하게 유지한다. 기존 MCP stdio/streamable-http transport와 success/error envelope를 유지한다.
## 11. 추적성
- 근거 이슈: `#12`
- 구현 PR: `#13`
- 관련 설계 문서: `docs/data-model.md`, `docs/interface-contract.md`, `docs/project-structure.md`, `docs/runtime-flow.md`
- 관련 요구사항: `REQ-001`, `REQ-002`
- DB lifecycle: writer/bootstrap은 명시적 `init_db()` 호출로만 생성·마이그레이션하고, `read_db()`는 SQLite `mode=ro`로 연다. missing DB는 `db_not_initialized`이며 read path는 no mkdir, no migrate, no commit이다. first-use write만 init_db를 수행하고 auth-before-DB 순서를 보존한다.
+96
View File
@@ -143,3 +143,99 @@ ASGI startup부터 login, token restoration, invalid-cookie cleanup, explicit lo
### transition verification boundary ### transition verification boundary
`tests/test_dashboard_menu_transition.py` provides exact AppTest/unit evidence for labels, query preservation, fallback/direct access, loading, local error, latest selection, and cache-clear. `tests/test_dashboard_app.py::test_streamlit_apptest_core_pages_use_synthetic_fixture_db` and `tests/test_dashboard_authentication_app.py::test_valid_cookie_allows_dashboard_and_is_not_stored_in_session_state` cover finance/auth regressions. AppTest is synthetic and cannot establish actual browser document continuity, paint timing, focus/scroll, or network behavior; `scripts/streamlit_browser_evidence.py --mode transition`의 authenticated HTTPS frame/DOM/document/shell evidence가 REQ-002.03-AC01에 필수다. `tests/test_streamlit_browser_evidence.py`는 이 manifest가 실제 teardown 이후에만 성공하고 document navigation, shell identity drift, 누락 관찰 또는 secret 포함 시 fail-closed하는지 검증한다. `tests/test_dashboard_menu_transition.py` provides exact AppTest/unit evidence for labels, query preservation, fallback/direct access, loading, local error, latest selection, and cache-clear. `tests/test_dashboard_app.py::test_streamlit_apptest_core_pages_use_synthetic_fixture_db` and `tests/test_dashboard_authentication_app.py::test_valid_cookie_allows_dashboard_and_is_not_stored_in_session_state` cover finance/auth regressions. AppTest is synthetic and cannot establish actual browser document continuity, paint timing, focus/scroll, or network behavior; `scripts/streamlit_browser_evidence.py --mode transition`의 authenticated HTTPS frame/DOM/document/shell evidence가 REQ-002.03-AC01에 필수다. `tests/test_streamlit_browser_evidence.py`는 이 manifest가 실제 teardown 이후에만 성공하고 document navigation, shell identity drift, 누락 관찰 또는 secret 포함 시 fail-closed하는지 검증한다.
## REQ-003 런타임 흐름
### valuation-register — register/select/correct
- 시작 이벤트: account form, MCP call, account/import write with dated evidence
- 선행조건: DB initialized; Dashboard path passed auth gate; account exists
- 실행 방식: synchronous SQLite transaction
- 트랜잭션·영속성 경계: one valuation insert or no write on validation/conflict
| 단계 ID | 실행 주체 | 시작 조건·입력 | 처리 | 읽기·쓰기 상태 | 외부 호출 | 성공 시 다음 단계 | 실패 시 전이 |
|---|---|---|---|---|---|---|---|
| `valuation-01` | adapter | typed request | delegate shared service | read account | none | `valuation-02` | `valuation-fail` |
| `valuation-02` | account valuation service | basis/amount/scope | validate exact integer, date, purpose, loan definition | no write | none | `valuation-03` | `valuation-fail` |
| `valuation-03` | SQLite | valid payload | reuse idempotent row or insert append-only row | account_valuations write | SQLite | `valuation-04` | `valuation-fail` |
| `valuation-04` | selector/report | requested basis | select verified leaf exact/as_of; warn candidates | read only | none | `valuation-success` | `valuation-fail` |
### snapshot-preview-close — preview/conditional close
- 시작 이벤트: assets page or MCP preview/close
- 선행조건: basis date; auth before Dashboard DB; explicit approval for conditional state
- 실행 방식: preview synchronous read; close synchronous atomic write
- 트랜잭션·영속성 경계: preview no write; close writes run and rows together
| 단계 ID | 실행 주체 | 시작 조건·입력 | 처리 | 읽기·쓰기 상태 | 외부 호출 | 성공 시 다음 단계 | 실패 시 전이 |
|---|---|---|---|---|---|---|---|
| `close-01` | assets service | basis/policy | gather verified account/manual items and warnings | read accounts/valuations/manual_assets | none | `close-02` | `close-fail` |
| `close-02` | Dashboard/MCP | preview request | return live authority and status | no write | none | `close-end` | `close-fail` |
| `close-03` | operator/service | explicit close + approval if needed | enforce conditions and correction reason | transaction begins | none | `close-04` | `close-fail` |
| `close-04` | SQLite | approved payload | insert run and immutable snapshot rows | runs/snapshots write | SQLite | `close-end` | `close-fail` |
### import-candidate-reconciliation-monthly-ui — propagation
- import updates transactions/account balance then records only dated unverified candidate; reconciliation uses valuation-managed investment without fake transaction; monthly/UI reads shared reports and explicit form actions only.
| 단계 ID | 실행 주체 | 시작 조건·입력 | 처리 | 읽기·쓰기 상태 | 외부 호출 | 성공 시 다음 단계 | 실패 시 전이 |
|---|---|---|---|---|---|---|---|
| `prop-01` | import/account write | real dated balance | `_record_*_valuation_candidate` | account_valuations unverified insert | parser | `prop-02` | `prop-fail` |
| `prop-02` | reconciliation service | account baseline | calculate signed ledger with provenance; investment verified => VALUATION_MANAGED | read only | none | `prop-03` | `prop-fail` |
| `prop-03` | monthly/UI page | month/basis query | read snapshot/report and render | read only unless explicit button | Streamlit | `prop-end` | `prop-fail` |
## 실패와 복구 추가
| 대상 단계 | 실패 조건 | 추가 쓰기 중단 | 재시도 계약 | 중복 방지 | 보상·수동 복구 | 최종 상태 |
|---|---|---|---|---|---|---|
| `valuation-03` | idempotency/correction conflict | yes | same payload inspect/reuse; changed payload no blind retry | unique indexes | operator chooses new correction | `valuation-fail` |
| `close-03` | missing/future/unverified/unknown or missing approval | yes | preview then explicit approval | close idempotency key | manual evidence review | `close-fail` |
| `close-04` | SQLite exception/concurrency | transaction rollback | retry same key after lock resolution | unique run key | restore from backup if needed | `close-fail` |
| `prop-01` | no real dated evidence | yes | no candidate write | source hash/date key | wait for evidence | `prop-end` without candidate |
### 동시성과 순서 추가
- 동시 실행 허용 여부: independent reads; SQLite writes serialized by transaction
- 잠금·직렬화 단위: account valuation/idempotency and snapshot close transaction
- 순서 보장: auth → DB → read/preview → explicit approval → close; latest run selected after correction
- 충돌 검출·처리: unique idempotency/single correction indexes and explicit error codes
## 관찰 가능성 추가
| 신호 종류 | 이름 | 발생 단계·조건 | 상관 식별자 | 진단 속성 | 민감정보 처리 |
|---|---|---|---|---|---|
| result/event | `valuation_registered` | `valuation-03` success | request/idempotency key surrogate | basis/kind/status only | no source secret |
| result/event | `snapshot_previewed` | `close-02` | request correlation | basis/status/warning codes | no account number |
| result/event | `snapshot_closed` | `close-04` | snapshot_run_id | status/count/formula | evidence redacted |
| result/event | `valuation_conflict` | validation/idempotency failure | correlation ID | error_code only | no payload secrets |
## REQ-003 검증
- 정상 흐름: `tests/test_account_valuations.py`, `tests/test_asset_snapshots.py`, `tests/test_contract_regression.py`
- 실패 흐름: conflict, conditional approval, validation tests
- 경계 흐름: exact/as_of, stale/carry-forward, correction/re-close, empty fallback
- 동시성 흐름: SQLite unique/idempotency and transaction rollback review
- 운영 진입점과 기대 결과: Dashboard authenticated `자산/마감`/`계좌`, MCP tools; preview leaves row count unchanged, explicit close returns run id
### canonical transition and failure semantics
`valuation-02` validates principal/interest/fee, source_ref account-scoped identity, parent scope, cyclic/self parent, one child, reason, and key/source mismatch before `valuation-03`; each failure is no-write with `validation_error`, `idempotency_conflict`, or `correction_conflict`. `close-01` computes actual valuation_date, age, carry, verification, reconciliation completeness, investment unavailable, and loan purpose/definition/ambiguity. `close-02` returns live authority, `close_status`, warnings, and exclusions without writing; `close-03` records explicit run/approval/correction; `close-04` atomically persists immutable rows. Drift, missing approval, unsafe evidence, and SQLite failure transition to `close-fail` with no partial write.
The close response exposes top-level run, approval, correction, warnings, `excluded_items`, and `carried_items`; its nested provenance exposes actual valuation date, age, carry, verification, approval/correction, warnings, and redacted exclusions. Live/closed/timeseries/monthly read models propagate the same meanings in their documented top-level and nested provenance fields. `monthly_source_snapshot_id` is a read-model alias of `snapshot_run_id`, not a persistence field. `last_balance` is external current comparison rather than opening evidence; missing balance_after/opening evidence transitions to `INCOMPLETE_CALCULATION`. No fake writes are made for unavailable investment or valuation-managed reconciliation.
### C13 database lifecycle transitions
`auth-before-DB` is the first Dashboard boundary. `write-first-use-01` explicitly calls `init_db()` and may migrate. `db-read-01` calls `read_db()` in SQLite `mode=ro`; a missing database becomes `db_not_initialized`. `db-read-02` succeeds without mkdir, migrate, or commit. An initialized database's schema or SQLite read failure becomes generic `db_error`, never `db_not_initialized`, and raw engine details are not exposed.
## D06 runtime decision guards
- **Current-cache no rollback:** a late historical valuation may update historical authority but cannot roll back the newer current cache (`last_balance`); current display and historical basis selection are separate decisions.
- **Reconciliation decision:** return `reconciliation complete` only when required ledger/evidence inputs are complete. If required evidence is absent or unreadable, return `reconciliation unavailable` with provenance and do not infer a balance or create a transaction.
- **Preview drift revalidation:** preview is read-only and records the authority/evidence fingerprint observed. Before close writes, revalidate current authority, approval policy, exclusions, carried items, and the preview fingerprint. Drift routes back to preview/review with a stale-preview or conflict decision and no partial write; only a revalidated preview reaches `close-04`.
The fingerprint input is exactly basis date, selection policy, included and excluded evidence state hashes, warnings (excluding the informational existing-run warning), and candidate state hashes. The preview returns `preview_fingerprint`; close recomputes it before any insert. A changed authority returns `authority_drift`, and the transaction produces no partial write.
An idempotent close is replayed only for equal request fingerprints. Replay reconstructs the response from the persisted run/evidence (no replay-only reused payload), while a different payload returns `idempotency_conflict`. Recursive close metadata is allowlisted and redacted before it reaches Dashboard/MCP.
Dashboard missing-DB reads return stable unavailable `db_not_initialized` without create/migrate/commit. The missing DB stable unavailable contract is no-create. Initialized read failures return `db_error`; successful initialized reads are read-only. The initialized DB read failure is db_error and initialized read has no write. Reconciliation requires opening evidence and coverage through the requested basis; otherwise it reports `INCOMPLETE_CALCULATION`/`reconciliation unavailable` with provenance, no inferred balance, and no fake transaction.
+35 -10
View File
@@ -102,9 +102,9 @@ 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) return contracts.get_ledger_backup_status(file_path, include_hash=include_hash, include_integrity=include_integrity)
@mcp.tool() @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") -> dict: 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.""" """Create an account/card/cash/investment/loan 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) 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() @mcp.tool()
def list_ledger_accounts(active_only: bool = True) -> dict: def list_ledger_accounts(active_only: bool = True) -> dict:
@@ -112,11 +112,31 @@ def list_ledger_accounts(active_only: bool = True) -> dict:
return contracts.list_ledger_accounts(active_only) return contracts.list_ledger_accounts(active_only)
@mcp.tool() @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) -> dict: 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:
"""Update selected account fields, including statement_status/liquidity_bucket for manual reconciliation.""" """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) return contracts.update_ledger_account(account_id, name=name, type=type, institution=institution, currency=currency, active=active, memo=memo, account_number_masked=account_number_masked, holder_name=holder_name, last_balance=last_balance, last_synced_at=last_synced_at, statement_status=statement_status, liquidity_bucket=liquidity_bucket, last_balance_basis_date=last_balance_basis_date)
@mcp.tool() @mcp.tool()
def register_account_valuation(account_id: str, basis_date: str, amount: int, currency: str = "KRW", valuation_kind: str = "balance", source_type: str = "manual", source_ref: str = "", verification_status: str = "unverified", memo: str = "", idempotency_key: str = "", as_of_at: str | None = None, timezone: str | None = None, cost_basis: int | None = None, net_contributed: int | None = None, reported_principal_kind: str | None = None, reported_principal_label: str | None = None, profit: int | None = None, return_rate: float | None = None, return_basis: str | None = None, principal: int | None = None, interest: int | None = None, fee: int | None = None, representative_liability_definition: str | None = None) -> dict:
"""Register an append-only account valuation evidence row."""
return contracts.register_account_valuation(account_id, basis_date, amount, currency, valuation_kind, source_type, source_ref, verification_status, memo, idempotency_key, as_of_at, timezone, cost_basis, net_contributed, reported_principal_kind, reported_principal_label, profit, return_rate, return_basis, principal, interest, fee, representative_liability_definition)
@mcp.tool()
def list_account_valuations(account_id: str, include_rejected: bool = True) -> dict:
"""List account valuation history."""
return contracts.list_account_valuations(account_id, include_rejected=include_rejected)
@mcp.tool()
def select_account_valuation(account_id: str, basis_date: str, as_of: bool = False, currency: str | None = None, valuation_kind: str | None = None) -> dict:
"""Select an exact verified valuation or latest verified value as of a basis date."""
return contracts.select_account_valuation(account_id, basis_date, as_of=as_of, currency=currency, valuation_kind=valuation_kind)
@mcp.tool()
def correct_account_valuation(valuation_id: str, amount: int, correction_reason: str, idempotency_key: str = "", currency: str | None = None, valuation_kind: str | None = None, source_type: str | None = None, source_ref: str | None = None, verification_status: str | None = None, memo: str | None = None, as_of_at: str | None = None, timezone: str | None = None, cost_basis: int | None = None, net_contributed: int | None = None, reported_principal_kind: str | None = None, reported_principal_label: str | None = None, profit: int | None = None, return_rate: float | None = None, return_basis: str | None = None, principal: int | None = None, interest: int | None = None, fee: int | None = None, representative_liability_definition: str | None = None) -> dict:
"""Append a reasoned correction to an account valuation."""
fields = {k: v for k, v in locals().items() if k not in {"valuation_id", "amount", "correction_reason", "idempotency_key"} and v is not None}
return contracts.correct_account_valuation(valuation_id, amount, correction_reason, idempotency_key=idempotency_key, **fields)
@mcp.tool()
def get_asset_liquidity_report() -> dict: def get_asset_liquidity_report() -> dict:
"""Return asset liquidity, debt, and net worth from shared account/manual valuation sources.""" """Return asset liquidity, debt, and net worth from shared account/manual valuation sources."""
return contracts.get_asset_liquidity_report() return contracts.get_asset_liquidity_report()
@@ -142,19 +162,19 @@ def deactivate_manual_asset(asset_id: str, hard_delete: bool = False) -> dict:
return contracts.deactivate_manual_asset(asset_id, hard_delete=hard_delete) return contracts.deactivate_manual_asset(asset_id, hard_delete=hard_delete)
@mcp.tool() @mcp.tool()
def preview_asset_snapshot_close(basis_date: str = "") -> dict: def preview_asset_snapshot_close(basis_date: str = "", selection_policy: str = "exact") -> dict:
"""Preview a month-end asset snapshot close without mutating the database.""" """Preview a month-end asset snapshot close without mutating the database."""
return contracts.preview_asset_snapshot_close(basis_date=basis_date) return contracts.preview_asset_snapshot_close(basis_date=basis_date, selection_policy=selection_policy)
@mcp.tool() @mcp.tool()
def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str = "", correction_reason: str = "") -> dict: def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str = "", correction_reason: str = "", 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 = "") -> dict:
"""Close an asset snapshot for a basis date; same-date re-close appends correction rows.""" """Close an asset snapshot for a basis date; same-date re-close appends correction rows."""
return contracts.close_asset_snapshot(basis_date=basis_date, source_note=source_note, memo=memo, correction_reason=correction_reason) return contracts.close_asset_snapshot(basis_date=basis_date, source_note=source_note, memo=memo, correction_reason=correction_reason, selection_policy=selection_policy, idempotency_key=idempotency_key, allow_missing_exclusions=allow_missing_exclusions, allow_as_of_carry_forward=allow_as_of_carry_forward, approved_by=approved_by, approval_reason=approval_reason, approval_reference=approval_reference)
@mcp.tool() @mcp.tool()
def get_asset_snapshot_report(basis_date: str = "") -> dict: def get_asset_snapshot_report(basis_date: str = "", selection_policy: str = "exact") -> dict:
"""Return basis-date asset snapshot/net-worth report with live-authority fallback.""" """Return basis-date asset snapshot/net-worth report with live-authority fallback."""
return contracts.get_asset_snapshot_report(basis_date=basis_date) return contracts.get_asset_snapshot_report(basis_date=basis_date, selection_policy=selection_policy)
@mcp.tool() @mcp.tool()
def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: int = 12) -> dict: def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: int = 12) -> dict:
@@ -246,6 +266,11 @@ def get_monthly_cashflow_report(month: str = "") -> dict:
"""Return income, expenses, net cashflow, and savings rate for a month.""" """Return income, expenses, net cashflow, and savings rate for a month."""
return contracts.get_monthly_cashflow_report(month) return contracts.get_monthly_cashflow_report(month)
@mcp.tool()
def get_monthly_settlement_summary(month: str = "") -> dict:
"""Return the read-only monthly settlement summary."""
return contracts.get_monthly_settlement_summary(month)
@mcp.tool() @mcp.tool()
def get_category_spending_report(month: str = "", start_date: str = "", end_date: str = "", person: str = "") -> dict: def get_category_spending_report(month: str = "", start_date: str = "", end_date: str = "", person: str = "") -> dict:
"""Return category/subcategory spending for a month or date range.""" """Return category/subcategory spending for a month or date range."""
@@ -25,6 +25,11 @@ from ...service import (
get_ledger_backup_status as get_ledger_backup_status, get_ledger_backup_status as get_ledger_backup_status,
get_ledger_db_status as get_ledger_db_status, get_ledger_db_status as get_ledger_db_status,
get_monthly_cashflow_report as get_monthly_cashflow_report, get_monthly_cashflow_report as get_monthly_cashflow_report,
get_monthly_settlement_summary as get_monthly_settlement_summary,
register_account_valuation as register_account_valuation,
list_account_valuations as list_account_valuations,
select_account_valuation as select_account_valuation,
correct_account_valuation as correct_account_valuation,
get_net_worth_timeseries as get_net_worth_timeseries, get_net_worth_timeseries as get_net_worth_timeseries,
get_review_operating_indicators as get_review_operating_indicators, get_review_operating_indicators as get_review_operating_indicators,
get_uncategorized_merchant_summary as get_uncategorized_merchant_summary, get_uncategorized_merchant_summary as get_uncategorized_merchant_summary,
@@ -94,6 +99,11 @@ __all__ = [
"get_ledger_backup_status", "get_ledger_backup_status",
"get_ledger_db_status", "get_ledger_db_status",
"get_monthly_cashflow_report", "get_monthly_cashflow_report",
"get_monthly_settlement_summary",
"register_account_valuation",
"list_account_valuations",
"select_account_valuation",
"correct_account_valuation",
"get_net_worth_timeseries", "get_net_worth_timeseries",
"get_review_operating_indicators", "get_review_operating_indicators",
"get_uncategorized_merchant_summary", "get_uncategorized_merchant_summary",
@@ -1,11 +1,19 @@
from __future__ import annotations from __future__ import annotations
from ...service import ( from ...service import (
correct_account_valuation as correct_account_valuation,
list_account_valuations as list_account_valuations,
list_ledger_accounts as list_ledger_accounts, list_ledger_accounts as list_ledger_accounts,
register_account_valuation as register_account_valuation,
select_account_valuation as select_account_valuation,
update_ledger_account as update_ledger_account, update_ledger_account as update_ledger_account,
) )
__all__ = [ __all__ = [
"list_ledger_accounts", "list_ledger_accounts",
"update_ledger_account", "update_ledger_account",
"register_account_valuation",
"list_account_valuations",
"select_account_valuation",
"correct_account_valuation",
] ]
@@ -6,7 +6,8 @@ from ...service import (
get_budget_vs_actual_report as get_budget_vs_actual_report, get_budget_vs_actual_report as get_budget_vs_actual_report,
get_monthly_settlement_summary as get_monthly_settlement_summary, get_monthly_settlement_summary as get_monthly_settlement_summary,
get_monthly_cashflow_report as get_monthly_cashflow_report, get_monthly_cashflow_report as get_monthly_cashflow_report,
health_check as health_check, get_ledger_db_status as get_ledger_db_status,
health_check_readonly as health_check,
list_cross_transfer_review_queue as list_cross_transfer_review_queue, list_cross_transfer_review_queue as list_cross_transfer_review_queue,
list_ledger_backups as list_ledger_backups, list_ledger_backups as list_ledger_backups,
list_ledger_import_batches as list_ledger_import_batches, list_ledger_import_batches as list_ledger_import_batches,
@@ -20,6 +21,7 @@ __all__ = [
"get_budget_vs_actual_report", "get_budget_vs_actual_report",
"get_monthly_settlement_summary", "get_monthly_settlement_summary",
"get_monthly_cashflow_report", "get_monthly_cashflow_report",
"get_ledger_db_status",
"health_check", "health_check",
"list_cross_transfer_review_queue", "list_cross_transfer_review_queue",
"list_ledger_backups", "list_ledger_backups",
+2
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from . import accounts_write_services as _accounts_write_services from . import accounts_write_services as _accounts_write_services
from . import account_valuation_service as _account_valuation_service
from . import asset_account_services as _asset_account_services from . import asset_account_services as _asset_account_services
from . import assets_service as _assets_service from . import assets_service as _assets_service
from . import budgets_write_services as _budgets_write_services from . import budgets_write_services as _budgets_write_services
@@ -20,6 +21,7 @@ from . import uncategorized_workbench_service as _uncategorized_workbench_servic
_EXPORT_MODULES = ( _EXPORT_MODULES = (
shared, shared,
_accounts_write_services, _accounts_write_services,
_account_valuation_service,
_asset_account_services, _asset_account_services,
_assets_service, _assets_service,
_budgets_write_services, _budgets_write_services,
@@ -0,0 +1,375 @@
from __future__ import annotations
from datetime import date
import math
import re
from typing import Any
import sqlite3
from .shared import db, read_db, read_operation, fail, new_id, normalize_date, now_iso, ok, row_to_dict
_VALID_STATUSES = {"verified", "unverified", "rejected"}
_VALID_LOAN_DEFINITIONS = {"principal_only", "principal_interest_fees"}
_VALID_CURRENCIES = {"KRW", "USD", "EUR", "JPY", "CNY", "GBP"}
_VALID_KINDS = {"balance", "cash_balance", "total_value", "loan", "loan_balance"}
_VALID_SOURCE_TYPES = {"manual", "import", "account_write", "reconciliation", "snapshot"}
_COLUMNS = (
"amount", "currency", "valuation_kind", "source_type", "source_ref",
"verification_status", "memo", "as_of_at", "timezone", "cost_basis",
"net_contributed", "reported_principal_kind", "reported_principal_label",
"profit", "return_rate", "return_basis", "principal", "interest", "fee",
"representative_liability_definition",
)
def _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):
return value
if isinstance(value, str) and re.fullmatch(r"[+-]?\d+", value.strip()):
return int(value.strip())
raise ValueError(f"{field} must be an integer")
def _strict_float(value: Any, field: str) -> float | None:
if value is None:
return None
if isinstance(value, bool):
raise ValueError(f"{field} must be a finite number")
try:
result = float(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{field} must be a finite number") from exc
if not math.isfinite(result):
raise ValueError(f"{field} must be a finite number")
return result
def _validate_common(account_id: str, basis_date: str, amount: int, currency: str, verification_status: str, valuation_kind: str, source_type: str, representative_liability_definition: str | None) -> tuple[str, int, str, str, str, str | None] | dict[str, Any]:
if not isinstance(account_id, str) or not account_id.strip():
return fail("validation_error", "account_id must be non-empty")
try:
basis = normalize_date(basis_date)
amount_int = _strict_int(amount, "amount", allow_none=False)
except Exception as exc:
return fail("validation_error", str(exc))
currency_norm = (currency or "KRW").strip().upper()
if currency_norm not in _VALID_CURRENCIES:
return fail("validation_error", f"currency must be one of {sorted(_VALID_CURRENCIES)}")
status = (verification_status or "unverified").strip().lower()
if status not in _VALID_STATUSES:
return fail("validation_error", "verification_status must be verified, unverified, or rejected")
if not isinstance(valuation_kind, str):
return fail("validation_error", "valuation_kind must be a string")
kind = valuation_kind.strip().lower() or "balance"
if kind not in _VALID_KINDS:
return fail("validation_error", f"valuation_kind must be one of {sorted(_VALID_KINDS)}")
source = (source_type or "manual").strip().lower() or "manual"
if source not in _VALID_SOURCE_TYPES:
return fail("validation_error", f"source_type must be one of {sorted(_VALID_SOURCE_TYPES)}")
definition = representative_liability_definition.strip() if representative_liability_definition else None
if definition is not None and definition not in _VALID_LOAN_DEFINITIONS:
return fail("validation_error", "representative liability definition is invalid")
if kind in {"loan", "loan_balance"} and definition is None:
return fail("validation_error", "loan valuation requires representative_liability_definition")
if kind not in {"loan", "loan_balance", "balance"} and definition is not None:
return fail("validation_error", "valuation kind cannot define representative liability")
if amount_int is None:
return fail("validation_error", "amount must be an integer")
return basis, amount_int, currency_norm, status, source, definition
def record_account_valuation_candidate(conn: sqlite3.Connection, account_id: str, basis_date: str, amount: int, currency: str, source_type: str, source_ref: str, idempotency_key: str, memo: str = "") -> dict[str, Any]:
"""Append an unverified candidate on a caller-owned transaction."""
account = conn.execute("SELECT type,currency FROM accounts WHERE id=?", (account_id,)).fetchone()
if account is None:
return fail("not_found", "account not found")
if (currency or "KRW").upper() != (account["currency"] or "KRW").upper():
return fail("validation_error", "valuation currency must match account currency")
if str(account["type"]).lower() == "loan":
return fail("validation_error", "loan valuation requires representative_liability_definition")
try:
amount_int = _strict_int(amount, "amount", allow_none=False)
except (TypeError, ValueError) as exc:
return fail("validation_error", str(exc))
basis = normalize_date(basis_date)
existing = conn.execute("SELECT * FROM account_valuations WHERE account_id=? AND idempotency_key=?", (account_id, idempotency_key)).fetchone()
if existing:
incoming = {
"basis_date": basis,
"amount": amount_int,
"currency": (currency or "KRW").upper(),
"valuation_kind": "balance",
"source_type": source_type,
"source_ref": source_ref or "",
"verification_status": "unverified",
"memo": memo,
}
if all(existing[key] == value for key, value in incoming.items()):
return ok(valuation=_valuation(existing), reused=True)
return fail(
"idempotency_conflict",
"idempotency_key already has a different candidate payload",
valuation=_valuation(existing),
)
valuation_id = new_id("valuation")
conn.execute("""INSERT INTO account_valuations
(id,account_id,basis_date,amount,currency,valuation_kind,source_type,source_ref,verification_status,recorded_at,memo,idempotency_key)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""", (valuation_id, account_id, basis, amount_int, (currency or "KRW").upper(), "balance", source_type, source_ref or "", "unverified", now_iso(), memo, idempotency_key))
return ok(valuation=_valuation(conn.execute("SELECT * FROM account_valuations WHERE id=?", (valuation_id,)).fetchone()), reused=False)
def register_account_valuation(
account_id: str,
basis_date: str,
amount: int,
currency: str = "KRW",
valuation_kind: str = "balance",
source_type: str = "manual",
source_ref: str = "",
verification_status: str = "unverified",
memo: str = "",
idempotency_key: str = "",
as_of_at: str | None = None,
timezone: str | None = None,
cost_basis: int | None = None,
net_contributed: int | None = None,
reported_principal_kind: str | None = None,
reported_principal_label: str | None = None,
profit: int | None = None,
return_rate: float | None = None,
return_basis: str | None = None,
principal: int | None = None,
interest: int | None = None,
fee: int | None = None,
representative_liability_definition: str | None = None,
db_path: str | None = None,
supersedes_id: str | None = None,
correction_reason: str = "",
) -> 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
try:
optional_ints = {name: _strict_int(value, name) for name, value in {
"cost_basis": cost_basis, "net_contributed": net_contributed, "profit": profit,
"principal": principal, "interest": interest, "fee": fee}.items()}
return_rate = _strict_float(return_rate, "return_rate")
except (TypeError, ValueError) as exc:
return fail("validation_error", str(exc))
if (as_of_at is None) != (timezone is None):
return fail("validation_error", "as_of_at and timezone must be supplied together")
if as_of_at is not None:
try:
from datetime import datetime
parsed = datetime.fromisoformat(as_of_at)
if parsed.tzinfo is None:
return fail("validation_error", "as_of_at must include timezone offset")
except (TypeError, ValueError) as exc:
return fail("validation_error", f"invalid as_of_at: {exc}")
if supersedes_id and not correction_reason.strip():
return fail("validation_error", "correction_reason is required")
ts = now_iso()
source_ref_norm = source_ref.strip() if isinstance(source_ref, str) else ""
values = (amount_int, currency_norm, (valuation_kind or "balance").strip().lower(), source_norm, source_ref_norm, status, memo or "", as_of_at, timezone, optional_ints["cost_basis"], optional_ints["net_contributed"], reported_principal_kind, reported_principal_label, optional_ints["profit"], return_rate, return_basis, optional_ints["principal"], optional_ints["interest"], optional_ints["fee"], definition)
with db(db_path) as conn:
conn.execute("BEGIN IMMEDIATE")
account = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone()
if account is None:
return fail("not_found", "account not found")
if currency_norm != (account["currency"] or "KRW").upper():
return fail("validation_error", "valuation currency must match account currency")
effective_kind = (valuation_kind or "balance").strip().lower()
is_loan_account = str(account["type"]).lower() == "loan"
if effective_kind in {"loan", "loan_balance"} and not is_loan_account:
return fail("validation_error", "loan valuation kind requires a loan account")
if is_loan_account and effective_kind not in {"balance", "loan", "loan_balance"}:
return fail("validation_error", "loan account valuation kind must represent a liability")
if is_loan_account and effective_kind in {"balance", "loan", "loan_balance"} and definition is None:
return fail("validation_error", "loan valuation requires representative_liability_definition")
if definition is not None and not is_loan_account:
return fail("validation_error", "representative liability definition requires a loan account")
seen: set[str] = set()
if supersedes_id:
parent = conn.execute("SELECT * FROM account_valuations WHERE id=?", (supersedes_id,)).fetchone()
if parent is None:
if idempotency_key and conn.execute("SELECT 1 FROM account_valuations WHERE account_id=? AND idempotency_key=?", (account_id, idempotency_key)).fetchone():
return fail("idempotency_conflict", "idempotency_key already has a different payload")
return fail("validation_error", "supersedes_id must reference an existing valuation")
if any(parent[field] != value for field, value in (
("account_id", account_id), ("basis_date", basis),
("currency", currency_norm), ("valuation_kind", (valuation_kind or "balance").strip().lower()),
)):
return fail("validation_error", "superseded valuation scope must match account, basis, currency, and valuation kind")
seen: set[str] = set()
cursor = parent
while cursor is not None:
cursor_id = cursor["id"]
if cursor_id in seen:
return fail("validation_error", "supersedes ancestry is cyclic")
seen.add(cursor_id)
next_id = cursor["supersedes_id"]
cursor = conn.execute("SELECT * FROM account_valuations WHERE id=?", (next_id,)).fetchone() if next_id else None
existing = None
by_key = conn.execute("SELECT * FROM account_valuations WHERE account_id=? AND idempotency_key=?", (account_id, idempotency_key)).fetchone() if idempotency_key else None
source_rows = conn.execute("SELECT * FROM account_valuations WHERE account_id=? AND source_ref=? ORDER BY recorded_at, id", (account_id, source_ref_norm)).fetchall() if source_ref_norm else []
correction_chain = seen if supersedes_id else set()
unrelated_source = next((row for row in source_rows if row["id"] not in correction_chain and (by_key is None or row["id"] != by_key["id"])), None)
if by_key is not None and unrelated_source is not None:
return fail("idempotency_conflict", "idempotency_key and source_ref identify different candidates")
if unrelated_source is not None:
by_source = unrelated_source
else:
by_source = None
existing = by_key or by_source
if existing:
current = _valuation(existing)
incoming = dict(zip(_COLUMNS, values))
incoming.update({"account_id": account_id, "basis_date": basis, "supersedes_id": supersedes_id, "correction_reason": correction_reason or ""})
comparable = set(incoming) - {"id", "recorded_at"}
if all(current.get(k) == v for k, v in incoming.items() if k in comparable):
return ok(valuation=current, reused=True)
return fail("idempotency_conflict", "idempotency_key already has a different payload", valuation=current)
valuation_id = new_id("valuation")
try:
conn.execute("""INSERT INTO account_valuations
(id,account_id,basis_date,amount,currency,valuation_kind,source_type,source_ref,verification_status,recorded_at,memo,idempotency_key,supersedes_id,correction_reason,as_of_at,timezone,cost_basis,net_contributed,reported_principal_kind,reported_principal_label,profit,return_rate,return_basis,principal,interest,fee,representative_liability_definition)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (valuation_id, account_id, basis, *values[:6], ts, values[6], idempotency_key or "", supersedes_id, correction_reason, *values[7:]))
except sqlite3.IntegrityError:
if supersedes_id:
return fail("correction_conflict", "valuation already has a correction")
return fail("idempotency_conflict", "idempotency_key already has a different payload")
except Exception as exc:
return fail("validation_error", str(exc))
row = conn.execute("SELECT * FROM account_valuations WHERE id=?", (valuation_id,)).fetchone()
return ok(valuation=_valuation(row), reused=False)
@read_operation
def list_account_valuations(account_id: str, include_rejected: bool = True, db_path: str | None = None) -> dict[str, Any]:
where = "" if include_rejected else " AND verification_status <> 'rejected'"
with read_db(db_path) as conn:
rows = conn.execute(f"SELECT * FROM account_valuations WHERE account_id=?{where} ORDER BY basis_date, recorded_at, id", (account_id,)).fetchall()
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,
)
@@ -1,66 +1,107 @@
from __future__ import annotations from __future__ import annotations
from .shared import Any, VALID_ACCOUNT_TYPES, db, fail, new_id, normalize_date, normalize_liquidity_bucket, normalize_statement_status, now_iso, ok, row_to_dict
from .shared import (
Any,
VALID_ACCOUNT_TYPES,
db,
fail,
new_id,
normalize_liquidity_bucket,
normalize_statement_status,
now_iso,
ok,
row_to_dict,
)
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) -> dict[str, Any]: def _record_balance_candidate(conn: Any, account: dict[str, Any], balance: Any = None, basis_date: str | None = None) -> None:
if not name.strip(): balance = account.get("last_balance") if balance is None else balance
return fail("validation_error", "account name is required") basis_date = account.get("last_balance_basis_date") if basis_date is None else basis_date
if type not in VALID_ACCOUNT_TYPES: if balance is None or not str(basis_date or ""):
return fail("validation_error", f"type must be one of {sorted(VALID_ACCOUNT_TYPES)}") return
try:
from .account_valuation_service import record_account_valuation_candidate
record_account_valuation_candidate(conn, account["id"], str(basis_date), int(balance), account.get("currency", "KRW"), "account_write", account.get("updated_at", ""), f"account-balance:{account['id']}:{str(basis_date)}:{balance}", "account balance write candidate")
except (ValueError, TypeError):
return
def create_ledger_account(name: str, type: str = "bank", institution: str = "", currency: str = "KRW", active: bool = True, memo: str = "", account_number_masked: str = "", holder_name: str = "", last_balance: int | None = None, last_synced_at: str = "", statement_status: str = "unknown", liquidity_bucket: str = "other", 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: try:
statement_status_norm = normalize_statement_status(statement_status) statement_status_norm = normalize_statement_status(statement_status)
liquidity_bucket_norm = normalize_liquidity_bucket(liquidity_bucket) liquidity_bucket_norm = normalize_liquidity_bucket(liquidity_bucket)
except Exception as e: except Exception as e: return fail("validation_error", str(e))
return fail("validation_error", str(e))
ts = now_iso(); account_id = new_id("acct") ts = now_iso(); account_id = new_id("acct")
try:
basis_date = normalize_date(last_balance_basis_date, default_today=False) if last_balance_basis_date else ""
except Exception as e: return fail("validation_error", str(e))
if basis_date and (last_balance is None or last_balance == ""):
return fail("validation_error", "last_balance_basis_date requires last_balance")
if last_balance is not None:
try:
from .account_valuation_service import _strict_int
last_balance = _strict_int(last_balance, "last_balance", allow_none=False)
except (ValueError, TypeError) as e:
return fail("validation_error", str(e))
with db(db_path) as conn: with db(db_path) as conn:
conn.execute("""INSERT INTO accounts(id,name,type,institution,currency,active,memo,account_number_masked,holder_name,last_balance,last_synced_at,statement_status,liquidity_bucket,created_at,updated_at) conn.execute("INSERT INTO accounts(id,name,type,institution,currency,active,memo,account_number_masked,holder_name,last_balance,last_balance_basis_date,last_synced_at,statement_status,liquidity_bucket,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", (account_id,name.strip(),type,institution,currency.upper(),int(active),memo,account_number_masked,holder_name,last_balance,basis_date,last_synced_at,statement_status_norm,liquidity_bucket_norm,ts,ts))
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (account_id, name.strip(), type, institution, currency, int(active), memo, account_number_masked, holder_name, last_balance, last_synced_at, statement_status_norm, liquidity_bucket_norm, ts, ts))
row = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone() row = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone()
return ok(account=row_to_dict(row)) account = row_to_dict(row)
_record_balance_candidate(conn, account)
return ok(account=account)
def update_ledger_account(account_id: str, db_path: str | None = None, **fields: Any) -> dict[str, Any]: def update_ledger_account(account_id: str, db_path: str | None = None, **fields: Any) -> dict[str, Any]:
allowed = {"name", "type", "institution", "currency", "active", "memo", "account_number_masked", "holder_name", "last_balance", "last_synced_at", "statement_status", "liquidity_bucket"} allowed = {"name","type","institution","currency","active","memo","account_number_masked","holder_name","last_balance","last_balance_basis_date","last_synced_at","statement_status","liquidity_bucket"}
updates: list[str] = []
params: list[Any] = []
for k, v in fields.items():
if k not in allowed or v is None:
continue
try:
if k == "type" and v not in VALID_ACCOUNT_TYPES:
return fail("validation_error", f"type must be one of {sorted(VALID_ACCOUNT_TYPES)}")
if k == "statement_status":
v = normalize_statement_status(str(v))
if k == "liquidity_bucket":
v = normalize_liquidity_bucket(str(v))
if k == "active":
v = int(bool(v))
if k == "last_balance" and v != "":
v = int(v)
except Exception as e:
return fail("validation_error", str(e))
updates.append(f"{k}=?")
params.append(v)
if not updates:
return fail("validation_error", "no fields to update")
updates.append("updated_at=?")
params.extend([now_iso(), account_id])
with db(db_path) as conn: with db(db_path) as conn:
cur = conn.execute(f"UPDATE accounts SET {', '.join(updates)} WHERE id=?", params) current = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone()
if cur.rowcount == 0: if current is None: return fail("not_found", "account not found")
return fail("not_found", "account not found") balance_update = "last_balance" in fields and fields.get("last_balance") is not None
basis_update = "last_balance_basis_date" in fields and fields.get("last_balance_basis_date") is not None
if basis_update and not balance_update:
return fail("validation_error", "last_balance_basis_date requires last_balance")
normalized_balance = None
if balance_update:
try:
from .account_valuation_service import _strict_int
normalized_balance = _strict_int(fields["last_balance"], "last_balance", allow_none=False)
except (ValueError, TypeError) as e:
return fail("validation_error", str(e))
normalized_basis = None
if basis_update:
try:
normalized_basis = normalize_date(fields["last_balance_basis_date"], default_today=False) if fields["last_balance_basis_date"] else ""
except Exception as e:
return fail("validation_error", str(e))
updates=[]; params=[]
for k,v in fields.items():
if k not in allowed or v is None: continue
try:
if k == "type" and v not in VALID_ACCOUNT_TYPES: return fail("validation_error", f"type must be one of {sorted(VALID_ACCOUNT_TYPES)}")
if k == "statement_status": v = normalize_statement_status(str(v))
if k == "liquidity_bucket": v = normalize_liquidity_bucket(str(v))
if k == "active": v = int(bool(v))
if k == "last_balance": v = normalized_balance
if k == "currency": v = str(v).upper()
if k == "last_balance_basis_date": v = normalized_basis
except Exception as e: return fail("validation_error", str(e))
updates.append(f"{k}=?"); params.append(v)
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")
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)
candidate_balance = normalized_balance if balance_update else current["last_balance"]
if historical:
kept=[(u,p) for u,p in zip(updates,params) if not u.startswith(("last_balance=","last_balance_basis_date=","last_synced_at="))]
updates=[u for u,_ in kept]; params=[p for _,p in kept]
elif balance_update and not basis_update:
# An explicit current-balance write without dated evidence advances
# the display cache but keeps its historical basis unknown.
updates.append("last_balance_basis_date=?"); params.append("")
updates.append("updated_at=?"); params.extend([now_iso(), account_id])
conn.execute(f"UPDATE accounts SET {', '.join(updates)} WHERE id=?", params)
row = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone() row = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone()
return ok(account=row_to_dict(row)) account = row_to_dict(row)
if balance_update and basis_update:
_record_balance_candidate(conn, account, candidate_balance, incoming_date)
return ok(account=account)
File diff suppressed because it is too large Load Diff
@@ -4,6 +4,8 @@ from .shared import (
Any, Any,
datetime, datetime,
db, db,
read_db,
read_operation,
fail, fail,
now_iso, now_iso,
ok, ok,
@@ -100,9 +102,10 @@ def seed_default_category_rules(overwrite: bool = False, db_path: str | None = N
return ok(created_or_updated=created, skipped=skipped) return ok(created_or_updated=created, skipped=skipped)
@read_operation
def list_category_rules(active_only: bool = True, db_path: str | None = None) -> dict[str, Any]: def list_category_rules(active_only: bool = True, db_path: str | None = None) -> dict[str, Any]:
sql="SELECT * FROM category_rules" + (" WHERE active=1" if active_only else "") + " ORDER BY priority, id" sql="SELECT * FROM category_rules" + (" WHERE active=1" if active_only else "") + " ORDER BY priority, id"
with db(db_path) as conn: with read_db(db_path) as conn:
rows=conn.execute(sql).fetchall() rows=conn.execute(sql).fetchall()
return ok(rules=[row_to_dict(r) for r in rows], count=len(rows)) return ok(rules=[row_to_dict(r) for r in rows], count=len(rows))
@@ -115,7 +118,7 @@ def _load_active_rules(conn: Any) -> list[dict[str, Any]]:
def _categorization_preview_data(limit: int = 1000, only_uncategorized: bool = True, db_path: str | None = None) -> dict[str, Any]: def _categorization_preview_data(limit: int = 1000, only_uncategorized: bool = True, db_path: str | None = None) -> dict[str, Any]:
with db(db_path) as conn: with read_db(db_path) as conn:
rules = _load_active_rules(conn) rules = _load_active_rules(conn)
where = "WHERE status='active'" + (" AND category=''" if only_uncategorized else "") where = "WHERE status='active'" + (" AND category=''" if only_uncategorized else "")
rows = [row_to_dict(r) for r in conn.execute( rows = [row_to_dict(r) for r in conn.execute(
@@ -163,8 +166,11 @@ def _categorization_preview_data(limit: int = 1000, only_uncategorized: bool = T
} }
@read_operation
def preview_categorization(limit: int = 1000, only_uncategorized: bool = True, db_path: str | None = None) -> dict[str, Any]: def preview_categorization(limit: int = 1000, only_uncategorized: bool = True, db_path: str | None = None) -> dict[str, Any]:
data = _categorization_preview_data(limit, only_uncategorized, db_path) data = _categorization_preview_data(limit, only_uncategorized, db_path)
if data.get("success") is False:
return data
return ok( return ok(
scanned=len(data["rows"]), scanned=len(data["rows"]),
matched_count=len(data["matches"]), matched_count=len(data["matches"]),
@@ -180,7 +186,7 @@ def _category_rule_match_rows(rule_id: str, limit: int = 10000, db_path: str | N
if not rule_id: if not rule_id:
return fail("validation_error", "rule_id is required") return fail("validation_error", "rule_id is required")
row_limit = max(1, min(int(limit), 50000)) row_limit = max(1, min(int(limit), 50000))
with db(db_path) as conn: with read_db(db_path) as conn:
rule_row = conn.execute("SELECT * FROM category_rules WHERE id=?", (rule_id,)).fetchone() rule_row = conn.execute("SELECT * FROM category_rules WHERE id=?", (rule_id,)).fetchone()
if not rule_row: if not rule_row:
return fail("not_found", f"category rule not found: {rule_id}") return fail("not_found", f"category rule not found: {rule_id}")
@@ -220,6 +226,7 @@ def _category_rule_match_rows(rule_id: str, limit: int = 10000, db_path: str | N
return ok(rule=rule, scanned=len(rows), matched=matched, uncategorized=uncategorized) return ok(rule=rule, scanned=len(rows), matched=matched, uncategorized=uncategorized)
@read_operation
def preview_category_rule_matches(rule_id: str, limit: int = 10000, sample_limit: int = 3, db_path: str | None = None) -> dict[str, Any]: def preview_category_rule_matches(rule_id: str, limit: int = 10000, sample_limit: int = 3, db_path: str | None = None) -> dict[str, Any]:
data = _category_rule_match_rows(rule_id, limit, db_path) data = _category_rule_match_rows(rule_id, limit, db_path)
if not data.get("success"): if not data.get("success"):
@@ -235,6 +242,8 @@ def preview_category_rule_matches(rule_id: str, limit: int = 10000, sample_limit
def apply_category_rule(rule_id: str, limit: int = 10000, db_path: str | None = None) -> dict[str, Any]: def apply_category_rule(rule_id: str, limit: int = 10000, db_path: str | None = None) -> dict[str, Any]:
with db(db_path):
pass
data = _category_rule_match_rows(rule_id, limit, db_path) data = _category_rule_match_rows(rule_id, limit, db_path)
if not data.get("success"): if not data.get("success"):
return data return data
@@ -266,7 +275,11 @@ def apply_category_rule(rule_id: str, limit: int = 10000, db_path: str | None =
) )
def apply_categorization_rules(limit: int = 1000, only_uncategorized: bool = True, db_path: str | None = None) -> dict[str, Any]: def apply_categorization_rules(limit: int = 1000, only_uncategorized: bool = True, db_path: str | None = None) -> dict[str, Any]:
with db(db_path):
pass
data = _categorization_preview_data(limit, only_uncategorized, db_path) data = _categorization_preview_data(limit, only_uncategorized, db_path)
if not data.get("success", True):
return data
preview = ok( preview = ok(
scanned=len(data["rows"]), scanned=len(data["rows"]),
matched_count=len(data["matches"]), matched_count=len(data["matches"]),
@@ -380,12 +393,13 @@ def seed_default_ledger_categories(overwrite: bool = False, db_path: str | None
return ok(created_or_updated=created) return ok(created_or_updated=created)
@read_operation
def list_ledger_categories(active_only: bool = True, include_usage: bool = True, db_path: str | None = None) -> dict[str, Any]: def list_ledger_categories(active_only: bool = True, include_usage: bool = True, db_path: str | None = None) -> dict[str, Any]:
clauses = [] clauses = []
if active_only: if active_only:
clauses.append("active=1") clauses.append("active=1")
where = " WHERE " + " AND ".join(clauses) if clauses else "" where = " WHERE " + " AND ".join(clauses) if clauses else ""
with db(db_path) as conn: with read_db(db_path) as conn:
rows = conn.execute(f"SELECT * FROM ledger_categories{where} ORDER BY sort_order, category, subcategory").fetchall() rows = conn.execute(f"SELECT * FROM ledger_categories{where} ORDER BY sort_order, category, subcategory").fetchall()
items = [row_to_dict(r) for r in rows] items = [row_to_dict(r) for r in rows]
if include_usage: if include_usage:
@@ -17,6 +17,8 @@ from .shared import (
now_iso, now_iso,
ok, ok,
re, re,
read_db,
read_operation,
row_to_dict, row_to_dict,
) )
from .review_decision_service import attach_review_decisions from .review_decision_service import attach_review_decisions
@@ -457,7 +459,8 @@ def _run_cross_account_transfer_match(limit: int = 500, dry_run: bool = True, db
review_items: list[dict[str, Any]] = [] review_items: list[dict[str, Any]] = []
matched_tx_ids: set[str] = set() matched_tx_ids: set[str] = set()
with db(db_path) as conn: connection_context = db if not dry_run else read_db
with connection_context(db_path) as conn:
kb_rows = _fetch_transfer_candidates(conn, "kb_bank_pdf") kb_rows = _fetch_transfer_candidates(conn, "kb_bank_pdf")
toss_rows = _fetch_transfer_candidates(conn, "toss_bank_pdf") toss_rows = _fetch_transfer_candidates(conn, "toss_bank_pdf")
toss_index = _build_cross_transfer_index(toss_rows) toss_index = _build_cross_transfer_index(toss_rows)
@@ -591,6 +594,7 @@ def _run_cross_account_transfer_match(limit: int = 500, dry_run: bool = True, db
) )
@read_operation
def preview_cross_account_transfers(limit: int = 500, db_path: str | None = None) -> dict[str, Any]: def preview_cross_account_transfers(limit: int = 500, db_path: str | None = None) -> dict[str, Any]:
return _run_cross_account_transfer_match(limit=limit, dry_run=True, db_path=db_path) return _run_cross_account_transfer_match(limit=limit, dry_run=True, db_path=db_path)
@@ -599,11 +603,12 @@ def run_cross_account_transfer_match(limit: int = 500, db_path: str | None = Non
return _run_cross_account_transfer_match(limit=limit, dry_run=False, db_path=db_path) return _run_cross_account_transfer_match(limit=limit, dry_run=False, db_path=db_path)
@read_operation
def list_cross_transfer_review_queue(limit: int = 300, db_path: str | None = None) -> dict[str, Any]: def list_cross_transfer_review_queue(limit: int = 300, db_path: str | None = None) -> dict[str, Any]:
clauses = ["t.status='active'", "t.source IN ('kb_bank_pdf','toss_bank_pdf')"] clauses = ["t.status='active'", "t.source IN ('kb_bank_pdf','toss_bank_pdf')"]
clauses.append("t.memo LIKE '%cross_transfer_review:%'") clauses.append("t.memo LIKE '%cross_transfer_review:%'")
where = " WHERE " + " AND ".join(clauses) where = " WHERE " + " AND ".join(clauses)
with db(db_path) as conn: with read_db(db_path) as conn:
rows = conn.execute( rows = conn.execute(
f"SELECT * FROM transactions t{where} ORDER BY t.transaction_date DESC, t.created_at DESC LIMIT ?", f"SELECT * FROM transactions t{where} ORDER BY t.transaction_date DESC, t.created_at DESC LIMIT ?",
(max(1, min(int(limit), 1000)),), (max(1, min(int(limit), 1000)),),
+38 -17
View File
@@ -13,6 +13,8 @@ from .shared import (
Path, Path,
csv, csv,
db, db,
read_db,
read_operation,
fail, fail,
json, json,
make_dedupe_key, make_dedupe_key,
@@ -31,6 +33,22 @@ from .shared import (
transaction_preview, transaction_preview,
) )
def _record_import_valuation_candidate(conn: Any, account_id: str, balance: Any, basis_date: str, source_hash: str) -> None:
if balance is None or not basis_date:
return
from .account_valuation_service import record_account_valuation_candidate
record_account_valuation_candidate(conn, account_id, basis_date, balance, "KRW", "import", source_hash, f"import-balance:{account_id}:{basis_date}:{source_hash}")
def _update_current_balance_if_newer(conn: Any, account_id: str, balance: Any, basis_date: str, synced_at: str) -> None:
if balance is None or not basis_date:
return
current = conn.execute("SELECT last_balance_basis_date FROM accounts WHERE id=?", (account_id,)).fetchone()
current_date = str(current["last_balance_basis_date"] or "")[:10] if current else ""
if not current_date or basis_date[:10] >= current_date:
conn.execute("UPDATE accounts SET last_balance=?, last_balance_basis_date=?, last_synced_at=?, updated_at=? WHERE id=?", (balance, basis_date[:10], synced_at, synced_at, account_id))
@read_operation
def preview_ledger_csv_import(file_path: str, source: str = "generic", db_path: str | None = None) -> dict[str, Any]: def preview_ledger_csv_import(file_path: str, source: str = "generic", db_path: str | None = None) -> dict[str, Any]:
path=Path(file_path).expanduser() path=Path(file_path).expanduser()
if not path.exists(): if not path.exists():
@@ -42,6 +60,7 @@ def preview_ledger_csv_import(file_path: str, source: str = "generic", db_path:
return ok(source=source, filename=redacted_source_file_label(str(path), source_hash), source_hash=source_hash, row_count=len(rows), preview=preview, required_columns=["transaction_date/date", "amount", "type optional", "category optional", "merchant/description optional"]) return ok(source=source, filename=redacted_source_file_label(str(path), source_hash), source_hash=source_hash, row_count=len(rows), preview=preview, required_columns=["transaction_date/date", "amount", "type optional", "category optional", "merchant/description optional"])
@read_operation
def preview_kb_bank_pdf_import(file_path: str, password: str, preview_limit: int = 20, db_path: str | None = None) -> dict[str, Any]: def preview_kb_bank_pdf_import(file_path: str, password: str, preview_limit: int = 20, db_path: str | None = None) -> dict[str, Any]:
try: try:
parsed = parse_kb_bank_pdf(file_path, password) parsed = parse_kb_bank_pdf(file_path, password)
@@ -55,7 +74,7 @@ def preview_kb_bank_pdf_import(file_path: str, password: str, preview_limit: int
validation = parsed["validation"] validation = parsed["validation"]
source_hash = parsed["source_hash"] source_hash = parsed["source_hash"]
duplicate_count = 0 duplicate_count = 0
with db(db_path) as conn: with read_db(db_path) as conn:
for tx in transactions: for tx in transactions:
dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, tx.counterparty, "", tx.external_id(source_hash)) dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, tx.counterparty, "", tx.external_id(source_hash))
found = conn.execute("SELECT 1 FROM transactions WHERE dedupe_key=?", (dedupe_key,)).fetchone() found = conn.execute("SELECT 1 FROM transactions WHERE dedupe_key=?", (dedupe_key,)).fetchone()
@@ -211,10 +230,9 @@ def import_kb_bank_pdf(file_path: str, password: str, account_id: str = "", skip
raise raise
conn.execute("UPDATE import_batches SET row_count=? WHERE id=?", (inserted, batch_id)) conn.execute("UPDATE import_batches SET row_count=? WHERE id=?", (inserted, batch_id))
if transactions: if transactions:
conn.execute( _update_current_balance_if_newer(conn, linked_account_id, transactions[-1].balance_after, transactions[-1].transaction_date, ts)
"UPDATE accounts SET last_balance=?, last_synced_at=?, updated_at=? WHERE id=?", if transactions and transactions[-1].balance_after is not None:
(transactions[-1].balance_after, ts, ts, linked_account_id), _record_import_valuation_candidate(conn, linked_account_id, transactions[-1].balance_after, transactions[-1].transaction_date, source_hash)
)
except ValueError as e: except ValueError as e:
return fail("validation_error", str(e)) return fail("validation_error", str(e))
except Exception as e: except Exception as e:
@@ -356,6 +374,7 @@ def _classify_toss_bank_transaction(tx: Any, owner_name: str = "") -> tuple[str,
return "수입", "캐시백", counterparty, False return "수입", "캐시백", counterparty, False
return "이체", "확인필요", counterparty, False return "이체", "확인필요", counterparty, False
@read_operation
def preview_seoulpay_xlsx_import(file_path: str, password: str = "", preview_limit: int = 20, db_path: str | None = None) -> dict[str, Any]: def preview_seoulpay_xlsx_import(file_path: str, password: str = "", preview_limit: int = 20, db_path: str | None = None) -> dict[str, Any]:
try: try:
parsed = parse_seoulpay_xlsx(file_path, password=password) parsed = parse_seoulpay_xlsx(file_path, password=password)
@@ -370,7 +389,7 @@ def preview_seoulpay_xlsx_import(file_path: str, password: str = "", preview_lim
source_hash = parsed["source_hash"] source_hash = parsed["source_hash"]
duplicate_count = 0 duplicate_count = 0
by_category: dict[str, dict[str, int]] = {} by_category: dict[str, dict[str, int]] = {}
with db(db_path) as conn: with read_db(db_path) as conn:
linked_account_id = "" linked_account_id = ""
metadata = parsed["metadata"] metadata = parsed["metadata"]
holder_name = metadata.get("holder_name") or "" holder_name = metadata.get("holder_name") or ""
@@ -523,6 +542,7 @@ def import_seoulpay_xlsx(file_path: str, password: str = "", account_id: str = "
) )
@read_operation
def preview_toss_bank_pdf_import(file_path: str, preview_limit: int = 20, db_path: str | None = None) -> dict[str, Any]: def preview_toss_bank_pdf_import(file_path: str, preview_limit: int = 20, db_path: str | None = None) -> dict[str, Any]:
try: try:
parsed = parse_toss_bank_pdf(file_path) parsed = parse_toss_bank_pdf(file_path)
@@ -536,7 +556,7 @@ def preview_toss_bank_pdf_import(file_path: str, preview_limit: int = 20, db_pat
transactions = parsed["transactions"] transactions = parsed["transactions"]
source_hash = parsed["source_hash"] source_hash = parsed["source_hash"]
duplicate_count = 0 duplicate_count = 0
with db(db_path) as conn: with read_db(db_path) as conn:
metadata = parsed["metadata"] metadata = parsed["metadata"]
account_number_masked = metadata.get("account_number_masked") or "" account_number_masked = metadata.get("account_number_masked") or ""
linked_account_id = "" linked_account_id = ""
@@ -677,10 +697,9 @@ def import_toss_bank_pdf(file_path: str, account_id: str = "", skip_duplicates:
raise raise
conn.execute("UPDATE import_batches SET row_count=? WHERE id=?", (inserted, batch_id)) conn.execute("UPDATE import_batches SET row_count=? WHERE id=?", (inserted, batch_id))
if transactions: if transactions:
conn.execute( _update_current_balance_if_newer(conn, linked_account_id, transactions[-1].balance_after, transactions[-1].transaction_date, ts)
"UPDATE accounts SET last_balance=?, last_synced_at=?, updated_at=? WHERE id=?", if transactions and transactions[-1].balance_after is not None:
(transactions[-1].balance_after, ts, ts, linked_account_id), _record_import_valuation_candidate(conn, linked_account_id, transactions[-1].balance_after, transactions[-1].transaction_date, source_hash)
)
except ValueError as e: except ValueError as e:
return fail("validation_error", str(e)) return fail("validation_error", str(e))
except Exception as e: except Exception as e:
@@ -758,6 +777,7 @@ def _classify_toss_transaction(tx: Any, owner_name: str = "") -> tuple[str, str,
return "이체", "투자이동", counterparty, False return "이체", "투자이동", counterparty, False
@read_operation
def preview_toss_securities_pdf_import(file_path: str, preview_limit: int = 20, db_path: str | None = None) -> dict[str, Any]: def preview_toss_securities_pdf_import(file_path: str, preview_limit: int = 20, db_path: str | None = None) -> dict[str, Any]:
try: try:
parsed = parse_toss_securities_pdf(file_path) parsed = parse_toss_securities_pdf(file_path)
@@ -770,7 +790,7 @@ def preview_toss_securities_pdf_import(file_path: str, preview_limit: int = 20,
transactions = parsed["transactions"] transactions = parsed["transactions"]
source_hash = parsed["source_hash"] source_hash = parsed["source_hash"]
duplicate_count = 0 duplicate_count = 0
with db(db_path) as conn: with read_db(db_path) as conn:
metadata = parsed["metadata"] metadata = parsed["metadata"]
account_number_masked = metadata.get("account_number_masked") or "" account_number_masked = metadata.get("account_number_masked") or ""
linked_account_id = "" linked_account_id = ""
@@ -902,10 +922,9 @@ def import_toss_securities_pdf(file_path: str, account_id: str = "", skip_duplic
raise raise
conn.execute("UPDATE import_batches SET row_count=? WHERE id=?", (inserted, batch_id)) conn.execute("UPDATE import_batches SET row_count=? WHERE id=?", (inserted, batch_id))
if transactions: if transactions:
conn.execute( _update_current_balance_if_newer(conn, linked_account_id, transactions[-1].balance_after, transactions[-1].transaction_date, ts)
"UPDATE accounts SET last_balance=?, last_synced_at=?, updated_at=? WHERE id=?", if transactions and transactions[-1].balance_after is not None:
(transactions[-1].balance_after, ts, ts, linked_account_id), _record_import_valuation_candidate(conn, linked_account_id, transactions[-1].balance_after, transactions[-1].transaction_date, source_hash)
)
except ValueError as e: except ValueError as e:
return fail("validation_error", str(e)) return fail("validation_error", str(e))
except Exception as e: except Exception as e:
@@ -923,6 +942,7 @@ def import_toss_securities_pdf(file_path: str, account_id: str = "", skip_duplic
) )
@read_operation
def list_ledger_import_batches( def list_ledger_import_batches(
source: str = "", source: str = "",
include_rolled_back: bool = True, include_rolled_back: bool = True,
@@ -968,6 +988,7 @@ def _batch_provenance(row: dict[str, Any]) -> dict[str, Any]:
return provenance return provenance
@read_operation
def list_ledger_import_batches_with_provenance( def list_ledger_import_batches_with_provenance(
source: str = "", source: str = "",
include_rolled_back: bool = True, include_rolled_back: bool = True,
@@ -988,7 +1009,7 @@ def list_ledger_import_batches_with_provenance(
clauses.append("status='active'") clauses.append("status='active'")
where = " WHERE " + " AND ".join(clauses) if clauses else "" where = " WHERE " + " AND ".join(clauses) if clauses else ""
params.append(max(1, min(int(limit), 500))) params.append(max(1, min(int(limit), 500)))
with db(db_path) as conn: with read_db(db_path) as conn:
rows=conn.execute(f"SELECT * FROM import_batches{where} ORDER BY imported_at DESC LIMIT ?", params).fetchall() rows=conn.execute(f"SELECT * FROM import_batches{where} ORDER BY imported_at DESC LIMIT ?", params).fetchall()
try: try:
items = [] items = []
@@ -64,6 +64,17 @@ def _source_coverage(
"source": asset_report.get("source", ""), "source": asset_report.get("source", ""),
"is_closed_snapshot": bool(asset_report.get("is_closed_snapshot", False)), "is_closed_snapshot": bool(asset_report.get("is_closed_snapshot", False)),
"net_worth": asset_report.get("net_worth"), "net_worth": asset_report.get("net_worth"),
"close_status": asset_report.get("close_status", ""),
"approval": asset_report.get("approval", {}),
"correction": asset_report.get("correction", {}),
"warnings": asset_report.get("warnings", []),
"provenance": asset_report.get(
"provenance", {"valuation_date": "", "warnings": [], "exclusions": []}
),
"missing_items": [item for item in asset_report.get("excluded_items", []) if "missing" in str(item.get("reason", ""))],
"excluded_items": asset_report.get("excluded_items", []),
"carried_items": asset_report.get("carried_items", asset_report.get("included_items", [])),
"monthly_source_snapshot_id": asset_report.get("snapshot_run_id", ""),
}, },
} }
@@ -108,6 +119,7 @@ def get_monthly_settlement_summary(month: str = "", db_path: str | None = None)
reports={ reports={
"cashflow": cashflow_report, "cashflow": cashflow_report,
"budget": budget_report, "budget": budget_report,
"assets": asset_report,
}, },
read_only=True, read_only=True,
) )
@@ -1,11 +1,14 @@
from __future__ import annotations from __future__ import annotations
from datetime import date
from .shared import ( from .shared import (
Any, Any,
BALANCE_RECONCILIATION_REFERENCE_RATIO, BALANCE_RECONCILIATION_REFERENCE_RATIO,
BALANCE_RECONCILIATION_TOLERANCE_KRW, BALANCE_RECONCILIATION_TOLERANCE_KRW,
RECONCILIATION_TARGET_STATUSES, RECONCILIATION_TARGET_STATUSES,
db, read_db,
read_operation,
fail, fail,
normalize_date, normalize_date,
normalize_snapshot_at, normalize_snapshot_at,
@@ -25,7 +28,7 @@ def _transaction_signed_amount(tx_type: str, amount: int) -> int:
return 0 return 0
def _account_balance_row(conn: sqlite3.Connection, account_id: str, baseline_at: str) -> dict[str, Any]: def _account_balance_row(conn: sqlite3.Connection, account_id: str, baseline_at: str, opening_evidence: bool = False) -> dict[str, Any]:
date_cutoff = normalize_date(baseline_at[:10]) date_cutoff = normalize_date(baseline_at[:10])
rows = conn.execute( rows = conn.execute(
"""SELECT type, amount, balance_after, transaction_date, transaction_datetime, created_at """SELECT type, amount, balance_after, transaction_date, transaction_datetime, created_at
@@ -44,15 +47,24 @@ def _account_balance_row(conn: sqlite3.Connection, account_id: str, baseline_at:
calculated_balance = int(signed_total) calculated_balance = int(signed_total)
basis = "net_transaction_delta_without_opening_balance" basis = "net_transaction_delta_without_opening_balance"
basis_at = baseline_at basis_at = baseline_at
has_transaction_coverage = bool(
rows
and rows[-1]["transaction_date"] == date_cutoff
and all(row["balance_after"] is not None for row in rows)
)
return { return {
"calculated_balance": calculated_balance, "calculated_balance": calculated_balance,
"calculation_basis": basis, "calculation_basis": basis,
"calculation_basis_at": basis_at, "calculation_basis_at": basis_at,
"transaction_count": len(rows), "transaction_count": len(rows),
"signed_transaction_total": int(signed_total), "signed_transaction_total": int(signed_total),
"calculation_complete": has_transaction_coverage and (
opening_evidence or (len(rows) == 1 and rows[0]["transaction_date"] == date_cutoff)
),
} }
@read_operation
def get_balance_reconciliation_report(account_id: str = "", baseline_balance: int | None = None, baseline_at: str = "", source_note: str = "", memo: str = "", tolerance: int = BALANCE_RECONCILIATION_TOLERANCE_KRW, db_path: str | None = None) -> dict[str, Any]: def get_balance_reconciliation_report(account_id: str = "", baseline_balance: int | None = None, baseline_at: str = "", source_note: str = "", memo: str = "", tolerance: int = BALANCE_RECONCILIATION_TOLERANCE_KRW, db_path: str | None = None) -> dict[str, Any]:
"""Preview manual/partial-import account balance reconciliation without mutating transactions.""" """Preview manual/partial-import account balance reconciliation without mutating transactions."""
try: try:
@@ -63,7 +75,7 @@ def get_balance_reconciliation_report(account_id: str = "", baseline_balance: in
baseline_balance_int = int(baseline_balance) if baseline_balance is not None else None baseline_balance_int = int(baseline_balance) if baseline_balance is not None else None
except Exception as e: except Exception as e:
return fail("validation_error", str(e)) return fail("validation_error", str(e))
with db(db_path) as conn: with read_db(db_path) as conn:
clauses = ["active=1"] clauses = ["active=1"]
params: list[Any] = [] params: list[Any] = []
if account_id: if account_id:
@@ -85,7 +97,65 @@ def get_balance_reconciliation_report(account_id: str = "", baseline_balance: in
for account in accounts: for account in accounts:
effective_baseline = baseline_balance_int if baseline_balance_int is not None else account["last_balance"] effective_baseline = baseline_balance_int if baseline_balance_int is not None else account["last_balance"]
effective_at = baseline_at_norm or account["last_synced_at"] or now_iso() effective_at = baseline_at_norm or account["last_synced_at"] or now_iso()
calc = _account_balance_row(conn, account["id"], effective_at) 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
if effective_baseline is None: if effective_baseline is None:
difference = None difference = None
ratio = None ratio = None
@@ -94,6 +164,18 @@ def get_balance_reconciliation_report(account_id: str = "", baseline_balance: in
difference = int(effective_baseline) - int(calc["calculated_balance"]) difference = int(effective_baseline) - int(calc["calculated_balance"])
ratio = abs(difference) / abs(int(effective_baseline)) if int(effective_baseline) else None ratio = abs(difference) / abs(int(effective_baseline)) if int(effective_baseline) else None
status = "OK" if abs(difference) <= tolerance_int else "REVIEW_NEEDED" status = "OK" if abs(difference) <= tolerance_int else "REVIEW_NEEDED"
if account["type"] == "investment" and ambiguous_valuation:
status = "AMBIGUOUS_VALUATION"
difference = None
elif account["type"] == "investment" and valuation is None:
status = "VALUATION_UNAVAILABLE"
difference = None
elif not calc["calculation_complete"]:
status = "INCOMPLETE_CALCULATION"
difference = None
elif account["type"] == "investment" and valuation is not None:
status = "VALUATION_MANAGED"
difference = None
items.append({ items.append({
"account_id": account["id"], "account_id": account["id"],
"account_name": account["name"], "account_name": account["name"],
@@ -107,6 +189,21 @@ def get_balance_reconciliation_report(account_id: str = "", baseline_balance: in
"reference_ratio_threshold": BALANCE_RECONCILIATION_REFERENCE_RATIO, "reference_ratio_threshold": BALANCE_RECONCILIATION_REFERENCE_RATIO,
"difference_ratio": ratio, "difference_ratio": ratio,
"status": status, "status": status,
"external_confirmed_value": int(valuation["amount"]) if valuation is not None else None,
"valuation_id": valuation["id"] if valuation is not None else "",
"valuation_kind": valuation["valuation_kind"] if valuation is not None else "",
"provenance": {
"valuation_date": valuation["basis_date"] if valuation is not None else "",
"age_days": (date.fromisoformat(effective_at[:10]) - date.fromisoformat(valuation["basis_date"])).days if valuation is not None else None,
"carried": bool(valuation is not None and valuation["basis_date"] != effective_at[:10]),
"verification_status": valuation["verification_status"] if valuation is not None else "unavailable",
"evidence_kind": "account_valuation" if valuation is not None else "none",
"warnings": [status] if status not in {"OK", "REVIEW_NEEDED", "VALUATION_MANAGED"} else [],
"exclusions": [],
"snapshot_run_id": "",
"close_status": "",
"selection_policy": "as_of",
},
"recommended_decisions": ["계좌이동", "실지출", "실수입", "투자이동", "분석제외", "확인필요"] if status == "REVIEW_NEEDED" else [], "recommended_decisions": ["계좌이동", "실지출", "실수입", "투자이동", "분석제외", "확인필요"] if status == "REVIEW_NEEDED" else [],
"reconciliation_session": latest_sessions.get(account["id"]), "reconciliation_session": latest_sessions.get(account["id"]),
"review_status": latest_decisions.get(("reconciliation_account", account["id"]), {}).get("status", "pending"), "review_status": latest_decisions.get(("reconciliation_account", account["id"]), {}).get("status", "pending"),
@@ -3,6 +3,8 @@ from __future__ import annotations
from .shared import ( from .shared import (
Any, Any,
db, db,
read_db,
read_operation,
fail, fail,
json, json,
new_id, new_id,
@@ -113,6 +115,7 @@ def attach_review_decisions(db_conn: Any, items: list[dict[str, Any]], target_ty
return enriched return enriched
@read_operation
def list_review_decisions(target_type: str = "", target_id: str = "", status: str = "", limit: int = 100, db_path: str | None = None) -> dict[str, Any]: def list_review_decisions(target_type: str = "", target_id: str = "", status: str = "", limit: int = 100, db_path: str | None = None) -> dict[str, Any]:
clauses: list[str] = [] clauses: list[str] = []
params: list[Any] = [] params: list[Any] = []
@@ -131,7 +134,7 @@ def list_review_decisions(target_type: str = "", target_id: str = "", status: st
params.append(status) params.append(status)
where = " WHERE " + " AND ".join(clauses) if clauses else "" where = " WHERE " + " AND ".join(clauses) if clauses else ""
max_limit = max(1, min(int(limit), 1000)) max_limit = max(1, min(int(limit), 1000))
with db(db_path) as conn: with read_db(db_path) as conn:
rows = conn.execute( rows = conn.execute(
f"SELECT * FROM review_decisions{where} ORDER BY decided_at DESC, updated_at DESC LIMIT ?", f"SELECT * FROM review_decisions{where} ORDER BY decided_at DESC, updated_at DESC LIMIT ?",
(*params, max_limit), (*params, max_limit),
@@ -214,6 +217,7 @@ def release_review_decision(target_type: str, target_id: str, memo: str = "", op
return upsert_review_decision(target_type, target_id, status="pending", decision_type="unknown", reason_code="released", memo=memo, operator=operator, review_after="", db_path=db_path) return upsert_review_decision(target_type, target_id, status="pending", decision_type="unknown", reason_code="released", memo=memo, operator=operator, review_after="", db_path=db_path)
@read_operation
def list_reconciliation_sessions(account_id: str = "", status: str = "", basis_date: str = "", limit: int = 100, db_path: str | None = None) -> dict[str, Any]: def list_reconciliation_sessions(account_id: str = "", status: str = "", basis_date: str = "", limit: int = 100, db_path: str | None = None) -> dict[str, Any]:
clauses: list[str] = [] clauses: list[str] = []
params: list[Any] = [] params: list[Any] = []
@@ -235,7 +239,7 @@ def list_reconciliation_sessions(account_id: str = "", status: str = "", basis_d
clauses.append("basis_date=?") clauses.append("basis_date=?")
params.append(basis_date) params.append(basis_date)
where = " WHERE " + " AND ".join(clauses) if clauses else "" where = " WHERE " + " AND ".join(clauses) if clauses else ""
with db(db_path) as conn: with read_db(db_path) as conn:
rows = conn.execute( rows = conn.execute(
f"SELECT * FROM reconciliation_sessions{where} ORDER BY basis_date DESC, updated_at DESC LIMIT ?", f"SELECT * FROM reconciliation_sessions{where} ORDER BY basis_date DESC, updated_at DESC LIMIT ?",
(*params, max(1, min(int(limit), 1000))), (*params, max(1, min(int(limit), 1000))),
@@ -338,8 +342,9 @@ def close_reconciliation_session(session_id: str = "", account_id: str = "", bas
) )
@read_operation
def get_review_operating_indicators(db_path: str | None = None) -> dict[str, Any]: def get_review_operating_indicators(db_path: str | None = None) -> dict[str, Any]:
with db(db_path) as conn: with read_db(db_path) as conn:
review_rows = conn.execute("SELECT status, COUNT(*) AS count FROM review_decisions GROUP BY status").fetchall() review_rows = conn.execute("SELECT status, COUNT(*) AS count FROM review_decisions GROUP BY status").fetchall()
pending_count = int(conn.execute("SELECT COUNT(*) FROM review_decisions WHERE status='pending'").fetchone()[0]) pending_count = int(conn.execute("SELECT COUNT(*) FROM review_decisions WHERE status='pending'").fetchone()[0])
held_recheck_count = int(conn.execute("SELECT COUNT(*) FROM review_decisions WHERE status IN ('held','needs_recheck')").fetchone()[0]) held_recheck_count = int(conn.execute("SELECT COUNT(*) FROM review_decisions WHERE status IN ('held','needs_recheck')").fetchone()[0])
+16
View File
@@ -5,6 +5,7 @@ import sqlite3 as sqlite3
import hashlib import hashlib
import json import json
import re import re
from functools import wraps
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -23,6 +24,8 @@ from ..infrastructure.parsers import (
) )
from ..infrastructure.sqlite import db as db from ..infrastructure.sqlite import db as db
from ..infrastructure.sqlite import init_db as init_db from ..infrastructure.sqlite import init_db as init_db
from ..infrastructure.sqlite import read_db as read_db
from ..infrastructure.sqlite import DatabaseNotInitializedError
from ..infrastructure.sqlite import resolve_db_path as resolve_db_path from ..infrastructure.sqlite import resolve_db_path as resolve_db_path
VALID_TRANSACTION_TYPES = {"income", "expense", "transfer", "refund", "adjustment"} VALID_TRANSACTION_TYPES = {"income", "expense", "transfer", "refund", "adjustment"}
@@ -121,6 +124,19 @@ def ok(**data: Any) -> dict[str, Any]:
def fail(error_code: str, message: str, **data: Any) -> dict[str, Any]: def fail(error_code: str, message: str, **data: Any) -> dict[str, Any]:
return {"success": False, "error_code": error_code, "error": message, **data} return {"success": False, "error_code": error_code, "error": message, **data}
def read_operation(function):
"""Translate missing/read-only SQLite failures to the public envelope."""
@wraps(function)
def wrapped(*args, **kwargs):
try:
return function(*args, **kwargs)
except DatabaseNotInitializedError:
return fail("db_not_initialized", "ledger database is not initialized")
except sqlite3.Error:
return fail("db_error", "ledger database read failed")
return wrapped
def row_to_dict(row: Any) -> dict[str, Any]: def row_to_dict(row: Any) -> dict[str, Any]:
d = dict(row) d = dict(row)
if "tags" in d: if "tags" in d:
@@ -10,6 +10,8 @@ from .shared import (
current_kst_month, current_kst_month,
datetime, datetime,
db, db,
read_db,
read_operation,
fail, fail,
hashlib, hashlib,
init_db, init_db,
@@ -29,6 +31,15 @@ def health_check(db_path: str | None = None) -> dict[str, Any]:
return ok(server="jarca-ledger-mcp", db_path=path, schema_version=schema_version, active_transactions=tx_count) return ok(server="jarca-ledger-mcp", db_path=path, schema_version=schema_version, active_transactions=tx_count)
@read_operation
def health_check_readonly(db_path: str | None = None) -> dict[str, Any]:
path = resolve_db_path(db_path)
with read_db(str(path)) as conn:
schema_version = conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0]
tx_count = conn.execute("SELECT COUNT(*) FROM transactions WHERE status='active'").fetchone()[0]
return ok(server="jarca-ledger-mcp", db_path=path, schema_version=schema_version, active_transactions=tx_count)
def _sha256_file(path: Path) -> str: def _sha256_file(path: Path) -> str:
digest = hashlib.sha256() digest = hashlib.sha256()
with path.open("rb") as f: with path.open("rb") as f:
@@ -186,8 +197,9 @@ def get_ledger_backup_status(file_path: str, include_hash: bool = True, include_
from .accounts_write_services import create_ledger_account as create_ledger_account from .accounts_write_services import create_ledger_account as create_ledger_account
@read_operation
def list_ledger_accounts(active_only: bool = True, db_path: str | None = None) -> dict[str, Any]: def list_ledger_accounts(active_only: bool = True, db_path: str | None = None) -> dict[str, Any]:
sql = "SELECT * FROM accounts" + (" WHERE active=1" if active_only else "") + " ORDER BY name" sql = "SELECT * FROM accounts" + (" WHERE active=1" if active_only else "") + " ORDER BY name"
with db(db_path) as conn: with read_db(db_path) as conn:
rows = conn.execute(sql).fetchall() rows = conn.execute(sql).fetchall()
return ok(accounts=[row_to_dict(r) for r in rows], count=len(rows)) return ok(accounts=[row_to_dict(r) for r in rows], count=len(rows))
@@ -4,7 +4,8 @@ from .shared import (
Any, Any,
CASHFLOW_KIND_SQL, CASHFLOW_KIND_SQL,
date, date,
db, read_db,
read_operation,
normalize_date, normalize_date,
normalize_month, normalize_month,
ok, ok,
@@ -16,6 +17,7 @@ from .transactions_write_services import create_ledger_transaction as create_led
from .transactions_write_services import delete_ledger_transaction as delete_ledger_transaction from .transactions_write_services import delete_ledger_transaction as delete_ledger_transaction
from .transactions_write_services import update_ledger_transaction as update_ledger_transaction from .transactions_write_services import update_ledger_transaction as update_ledger_transaction
@read_operation
def list_ledger_transactions(start_date: str = "", end_date: str = "", type: str = "", category: str = "", person: str = "", account_id: str = "", include_deleted: bool = False, limit: int = 100, db_path: str | None = None) -> dict[str, Any]: def list_ledger_transactions(start_date: str = "", end_date: str = "", type: str = "", category: str = "", person: str = "", account_id: str = "", include_deleted: bool = False, limit: int = 100, db_path: str | None = None) -> dict[str, Any]:
clauses=[]; params=[] clauses=[]; params=[]
if not include_deleted: if not include_deleted:
@@ -30,18 +32,19 @@ def list_ledger_transactions(start_date: str = "", end_date: str = "", type: str
where = " WHERE " + " AND ".join(clauses) if clauses else "" where = " WHERE " + " AND ".join(clauses) if clauses else ""
sql = f"SELECT * FROM transactions{where} ORDER BY transaction_date DESC, created_at DESC LIMIT ?" sql = f"SELECT * FROM transactions{where} ORDER BY transaction_date DESC, created_at DESC LIMIT ?"
params.append(max(1, min(int(limit), 1000))) params.append(max(1, min(int(limit), 1000)))
with db(db_path) as conn: with read_db(db_path) as conn:
rows = conn.execute(sql, params).fetchall() rows = conn.execute(sql, params).fetchall()
return ok(transactions=[row_to_dict(r) for r in rows], count=len(rows)) return ok(transactions=[row_to_dict(r) for r in rows], count=len(rows))
from .reconciliation_service import get_balance_reconciliation_report as get_balance_reconciliation_report from .reconciliation_service import get_balance_reconciliation_report as get_balance_reconciliation_report
@read_operation
def list_ledger_budgets(month: str = "", db_path: str | None = None) -> dict[str, Any]: def list_ledger_budgets(month: str = "", db_path: str | None = None) -> dict[str, Any]:
clauses=[]; params=[] clauses=[]; params=[]
if month: if month:
clauses.append("month=?"); params.append(normalize_month(month)) clauses.append("month=?"); params.append(normalize_month(month))
where = " WHERE " + " AND ".join(clauses) if clauses else "" where = " WHERE " + " AND ".join(clauses) if clauses else ""
with db(db_path) as conn: with read_db(db_path) as conn:
rows = conn.execute(f"SELECT * FROM budgets{where} ORDER BY month DESC, category", params).fetchall() rows = conn.execute(f"SELECT * FROM budgets{where} ORDER BY month DESC, category", params).fetchall()
return ok(budgets=[row_to_dict(r) for r in rows], count=len(rows)) return ok(budgets=[row_to_dict(r) for r in rows], count=len(rows))
@@ -52,9 +55,10 @@ def _month_range(month: str) -> tuple[str, str]:
end = date(y + (mo == 12), 1 if mo == 12 else mo + 1, 1) end = date(y + (mo == 12), 1 if mo == 12 else mo + 1, 1)
return start.isoformat(), end.isoformat() return start.isoformat(), end.isoformat()
@read_operation
def get_monthly_cashflow_report(month: str = "", db_path: str | None = None) -> dict[str, Any]: def get_monthly_cashflow_report(month: str = "", db_path: str | None = None) -> dict[str, Any]:
month_norm = normalize_month(month); start, next_month = _month_range(month_norm) month_norm = normalize_month(month); start, next_month = _month_range(month_norm)
with db(db_path) as conn: with read_db(db_path) as conn:
type_rows = conn.execute("""SELECT type, SUM(amount) AS total FROM transactions type_rows = conn.execute("""SELECT type, SUM(amount) AS total FROM transactions
WHERE status='active' AND transaction_date>=? AND transaction_date<? WHERE status='active' AND transaction_date>=? AND transaction_date<?
GROUP BY type""", (start, next_month)).fetchall() GROUP BY type""", (start, next_month)).fetchall()
@@ -72,6 +76,7 @@ def get_monthly_cashflow_report(month: str = "", db_path: str | None = None) ->
savings_rate = round(net_cashflow / income, 4) if income else None 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) 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)
@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]: def get_category_spending_report(month: str = "", start_date: str = "", end_date: str = "", person: str = "", db_path: str | None = None) -> dict[str, Any]:
if month: if month:
start_date, next_month = _month_range(month); end_op = "<"; end_value = next_month start_date, next_month = _month_range(month); end_op = "<"; end_value = next_month
@@ -85,15 +90,16 @@ def get_category_spending_report(month: str = "", start_date: str = "", end_date
LEFT JOIN ledger_categories lc ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory LEFT JOIN ledger_categories lc ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory
WHERE t.status='active' AND {CASHFLOW_KIND_SQL}='expense' AND t.transaction_date>=? AND t.transaction_date{end_op}?{person_clause} WHERE t.status='active' AND {CASHFLOW_KIND_SQL}='expense' AND t.transaction_date>=? AND t.transaction_date{end_op}?{person_clause}
GROUP BY t.category, t.subcategory ORDER BY total DESC""" GROUP BY t.category, t.subcategory ORDER BY total DESC"""
with db(db_path) as conn: with read_db(db_path) as conn:
rows=conn.execute(sql, params).fetchall() rows=conn.execute(sql, params).fetchall()
total=sum(int(r["total"] or 0) for r in rows) total=sum(int(r["total"] or 0) for r in rows)
items=[{"category": r["category"], "subcategory": r["subcategory"], "amount": int(r["total"] or 0), "count": r["count"], "share": round((int(r["total"] or 0)/total),4) if total else 0} for r in rows] items=[{"category": r["category"], "subcategory": r["subcategory"], "amount": int(r["total"] or 0), "count": r["count"], "share": round((int(r["total"] or 0)/total),4) if total else 0} for r in rows]
return ok(total_expense=total, items=items, count=len(items)) return ok(total_expense=total, items=items, count=len(items))
@read_operation
def get_fixed_cost_report(month: str = "", db_path: str | None = None) -> dict[str, Any]: def get_fixed_cost_report(month: str = "", db_path: str | None = None) -> dict[str, Any]:
month_norm=normalize_month(month); start,next_month=_month_range(month_norm) month_norm=normalize_month(month); start,next_month=_month_range(month_norm)
with db(db_path) as conn: with read_db(db_path) as conn:
rows=conn.execute(f"""SELECT t.category, t.merchant, t.amount, t.transaction_date, t.person, t.memo FROM transactions t rows=conn.execute(f"""SELECT t.category, t.merchant, t.amount, t.transaction_date, t.person, t.memo FROM transactions t
LEFT JOIN ledger_categories lc ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory LEFT JOIN ledger_categories lc ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory
WHERE t.status='active' AND {CASHFLOW_KIND_SQL}='expense' AND t.is_fixed=1 AND t.transaction_date>=? AND t.transaction_date<? WHERE t.status='active' AND {CASHFLOW_KIND_SQL}='expense' AND t.is_fixed=1 AND t.transaction_date>=? AND t.transaction_date<?
@@ -101,9 +107,10 @@ def get_fixed_cost_report(month: str = "", db_path: str | None = None) -> dict[s
items=[row_to_dict(r) for r in rows] items=[row_to_dict(r) for r in rows]
return ok(month=month_norm, total_fixed_cost=sum(i["amount"] for i in items), items=items, count=len(items)) return ok(month=month_norm, total_fixed_cost=sum(i["amount"] for i in items), items=items, count=len(items))
@read_operation
def get_budget_vs_actual_report(month: str = "", db_path: str | None = None) -> dict[str, Any]: def get_budget_vs_actual_report(month: str = "", db_path: str | None = None) -> dict[str, Any]:
month_norm=normalize_month(month); start,next_month=_month_range(month_norm) month_norm=normalize_month(month); start,next_month=_month_range(month_norm)
with db(db_path) as conn: with read_db(db_path) as conn:
budgets=conn.execute("SELECT * FROM budgets WHERE month=?", (month_norm,)).fetchall() budgets=conn.execute("SELECT * FROM budgets WHERE month=?", (month_norm,)).fetchall()
actuals=conn.execute(f"""SELECT t.category, t.subcategory, t.person, SUM(t.amount) AS amount FROM transactions t actuals=conn.execute(f"""SELECT t.category, t.subcategory, t.person, SUM(t.amount) AS amount FROM transactions t
LEFT JOIN ledger_categories lc ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory LEFT JOIN ledger_categories lc ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory
@@ -116,6 +123,7 @@ def get_budget_vs_actual_report(month: str = "", db_path: str | None = None) ->
items.append({"category": b["category"], "subcategory": b["subcategory"], "person": b["person"], "budget": budget, "actual": actual, "remaining": budget-actual, "usage_rate": round(actual/budget,4) if budget else None, "status": "over" if actual>budget else "ok"}) items.append({"category": b["category"], "subcategory": b["subcategory"], "person": b["person"], "budget": budget, "actual": actual, "remaining": budget-actual, "usage_rate": round(actual/budget,4) if budget else None, "status": "over" if actual>budget else "ok"})
return ok(month=month_norm, items=items, total_budget=sum(i["budget"] for i in items), total_actual=sum(i["actual"] for i in items)) return ok(month=month_norm, items=items, total_budget=sum(i["budget"] for i in items), total_actual=sum(i["actual"] for i in items))
@read_operation
def get_investable_cashflow_report(month: str = "", minimum_cash_buffer: int = 0, planned_extra_expense: int = 0, db_path: str | None = None) -> dict[str, Any]: def get_investable_cashflow_report(month: str = "", minimum_cash_buffer: int = 0, planned_extra_expense: int = 0, db_path: str | None = None) -> dict[str, Any]:
cashflow=get_monthly_cashflow_report(month, db_path) cashflow=get_monthly_cashflow_report(month, db_path)
if not cashflow.get("success"): if not cashflow.get("success"):
@@ -4,6 +4,8 @@ from .shared import (
Any, Any,
datetime, datetime,
db, db,
read_db,
read_operation,
fail, fail,
now_iso, now_iso,
ok, ok,
@@ -42,14 +44,16 @@ def _rule_matches(rule: dict[str, Any], tx: dict[str, Any]) -> bool:
return re.search(pattern, value) is not None return re.search(pattern, value) is not None
return False return False
@read_operation
def list_uncategorized_transactions(limit: int = 100, min_amount: int = 0, db_path: str | None = None) -> dict[str, Any]: def list_uncategorized_transactions(limit: int = 100, min_amount: int = 0, db_path: str | None = None) -> dict[str, Any]:
with db(db_path) as conn: with read_db(db_path) as conn:
rows=conn.execute("""SELECT * FROM transactions WHERE status='active' AND COALESCE(category,'')='' AND amount>=? ORDER BY amount DESC, transaction_datetime DESC LIMIT ?""", (int(min_amount), max(1,min(int(limit),1000)))).fetchall() rows=conn.execute("""SELECT * FROM transactions WHERE status='active' AND COALESCE(category,'')='' AND amount>=? ORDER BY amount DESC, transaction_datetime DESC LIMIT ?""", (int(min_amount), max(1,min(int(limit),1000)))).fetchall()
return ok(transactions=[row_to_dict(r) for r in rows], count=len(rows)) return ok(transactions=[row_to_dict(r) for r in rows], count=len(rows))
@read_operation
def get_uncategorized_merchant_summary(limit: int = 50, db_path: str | None = None) -> dict[str, Any]: def get_uncategorized_merchant_summary(limit: int = 50, db_path: str | None = None) -> dict[str, Any]:
with db(db_path) as conn: with read_db(db_path) as conn:
rows=conn.execute("""SELECT merchant, raw_summary, type, COUNT(*) count, SUM(amount) total_amount, MAX(amount) max_amount rows=conn.execute("""SELECT merchant, raw_summary, type, COUNT(*) count, SUM(amount) total_amount, MAX(amount) max_amount
FROM transactions WHERE status='active' AND COALESCE(category,'')='' FROM transactions WHERE status='active' AND COALESCE(category,'')=''
GROUP BY merchant, raw_summary, type ORDER BY total_amount DESC LIMIT ?""", (max(1,min(int(limit),500)),)).fetchall() GROUP BY merchant, raw_summary, type ORDER BY total_amount DESC LIMIT ?""", (max(1,min(int(limit),500)),)).fetchall()
@@ -90,6 +94,7 @@ def _recommend_category_for_transaction(tx: dict[str, Any], rules: list[dict[str
return {"category": "", "subcategory": "", "confidence": 0.0, "source": "none", "reason": "추천 근거 없음", "rule_id": ""} return {"category": "", "subcategory": "", "confidence": 0.0, "source": "none", "reason": "추천 근거 없음", "rule_id": ""}
@read_operation
def get_uncategorized_workbench( def get_uncategorized_workbench(
limit: int = 100, limit: int = 100,
min_amount: int = 0, min_amount: int = 0,
@@ -103,7 +108,7 @@ def get_uncategorized_workbench(
"date_desc": "transaction_datetime DESC, transaction_date DESC, created_at DESC, amount DESC", "date_desc": "transaction_datetime DESC, transaction_date DESC, created_at DESC, amount DESC",
} }
order_by = order_by_map.get(sort_by, order_by_map["amount_desc"]) order_by = order_by_map.get(sort_by, order_by_map["amount_desc"])
with db(db_path) as conn: with read_db(db_path) as conn:
tx_rows = [row_to_dict(r) for r in conn.execute( tx_rows = [row_to_dict(r) for r in conn.execute(
f"""SELECT id, transaction_date, transaction_datetime, type, amount, currency, merchant, raw_summary, f"""SELECT id, transaction_date, transaction_datetime, type, amount, currency, merchant, raw_summary,
counterparty, account_id, payment_method, person, is_fixed, source, memo, created_at counterparty, account_id, payment_method, person, is_fixed, source, memo, created_at
@@ -172,7 +177,7 @@ def get_uncategorized_workbench(
group["recommendation"] = rec group["recommendation"] = rec
group_items = sorted(groups.values(), key=lambda g: (int(g["count"]), int(g["total_amount"])), reverse=True) group_items = sorted(groups.values(), key=lambda g: (int(g["count"]), int(g["total_amount"])), reverse=True)
with db(db_path) as conn: with read_db(db_path) as conn:
txs = attach_review_decisions(conn, txs, "uncategorized_transaction", "id") txs = attach_review_decisions(conn, txs, "uncategorized_transaction", "id")
recommendable_count = sum(1 for tx in txs if tx.get("recommended_category")) recommendable_count = sum(1 for tx in txs if tx.get("recommended_category"))
repeated_group_count = sum(1 for g in group_items if int(g.get("count") or 0) >= 2) repeated_group_count = sum(1 for g in group_items if int(g.get("count") or 0) >= 2)
@@ -1,6 +1,9 @@
from .connection import DEFAULT_DB_PATH as DEFAULT_DB_PATH from .connection import DEFAULT_DB_PATH as DEFAULT_DB_PATH
from .connection import connect as connect from .connection import connect as connect
from .connection import connect_readonly as connect_readonly
from .connection import db as db from .connection import db as db
from .connection import read_db as read_db
from .connection import DatabaseNotInitializedError as DatabaseNotInitializedError
from .connection import resolve_db_path as resolve_db_path from .connection import resolve_db_path as resolve_db_path
from .migrations import init_db as init_db from .migrations import init_db as init_db
from .migrations import migrate_db as migrate_db from .migrations import migrate_db as migrate_db
@@ -16,7 +19,10 @@ __all__ = [
"SCHEMA_VERSION", "SCHEMA_VERSION",
"TRANSACTION_ADDITIVE_COLUMNS", "TRANSACTION_ADDITIVE_COLUMNS",
"connect", "connect",
"connect_readonly",
"db", "db",
"read_db",
"DatabaseNotInitializedError",
"init_db", "init_db",
"migrate_db", "migrate_db",
"resolve_db_path", "resolve_db_path",
@@ -5,10 +5,15 @@ import sqlite3
from contextlib import contextmanager from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from typing import Iterator from typing import Iterator
from urllib.parse import quote
DEFAULT_DB_PATH = Path.home() / ".local" / "share" / "jarca" / "ledger.sqlite3" DEFAULT_DB_PATH = Path.home() / ".local" / "share" / "jarca" / "ledger.sqlite3"
class DatabaseNotInitializedError(RuntimeError):
"""Raised when a read is requested before the ledger exists."""
def resolve_db_path(db_path: str | None = None) -> Path: def resolve_db_path(db_path: str | None = None) -> Path:
raw = db_path or os.getenv("JARCA_LEDGER_DB_PATH") or str(DEFAULT_DB_PATH) raw = db_path or os.getenv("JARCA_LEDGER_DB_PATH") or str(DEFAULT_DB_PATH)
return Path(raw).expanduser().resolve() return Path(raw).expanduser().resolve()
@@ -23,6 +28,31 @@ def connect(db_path: str | None = None) -> sqlite3.Connection:
return conn return conn
def connect_readonly(db_path: str | None = None) -> sqlite3.Connection:
"""Open an existing ledger in SQLite URI read-only mode."""
path = resolve_db_path(db_path)
if not path.is_file():
raise DatabaseNotInitializedError("ledger database is not initialized")
uri = f"file:{quote(str(path), safe='/')}?mode=ro"
try:
conn = sqlite3.connect(uri, uri=True)
except sqlite3.Error as exc:
raise DatabaseNotInitializedError("ledger database is not initialized") from exc
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys=ON")
return conn
@contextmanager
def read_db(db_path: str | None = None) -> Iterator[sqlite3.Connection]:
"""Open an existing ledger without filesystem or transaction writes."""
conn = connect_readonly(db_path)
try:
yield conn
finally:
conn.close()
@contextmanager @contextmanager
def db(db_path: str | None = None) -> Iterator[sqlite3.Connection]: def db(db_path: str | None = None) -> Iterator[sqlite3.Connection]:
from .migrations import init_db from .migrations import init_db
@@ -98,10 +98,28 @@ def migrate_db(conn: sqlite3.Connection) -> None:
"correction_reason": "TEXT NOT NULL DEFAULT ''", "correction_reason": "TEXT NOT NULL DEFAULT ''",
"is_correction": "INTEGER NOT NULL DEFAULT 0", "is_correction": "INTEGER NOT NULL DEFAULT 0",
"closed_at": "TEXT NOT NULL DEFAULT ''", "closed_at": "TEXT NOT NULL DEFAULT ''",
"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 ''",
}) })
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_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_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 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 ''"})
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_idempotency ON asset_snapshot_runs(idempotency_key) WHERE idempotency_key <> ''")
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_initial_basis ON asset_snapshot_runs(basis_date) WHERE correction_of_run_id = ''")
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_correction_parent ON asset_snapshot_runs(correction_of_run_id) WHERE correction_of_run_id <> ''")
conn.execute("""CREATE TABLE IF NOT EXISTS review_decisions ( conn.execute("""CREATE TABLE IF NOT EXISTS review_decisions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
target_type TEXT NOT NULL, target_type TEXT NOT NULL,
@@ -136,6 +154,22 @@ def migrate_db(conn: sqlite3.Connection) -> None:
)""") )""")
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_reconciliation_sessions_account_basis ON reconciliation_sessions(account_id, basis_date)") conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_reconciliation_sessions_account_basis ON reconciliation_sessions(account_id, basis_date)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_reconciliation_sessions_status ON reconciliation_sessions(status, closed_at)") conn.execute("CREATE INDEX IF NOT EXISTS idx_reconciliation_sessions_status ON reconciliation_sessions(status, closed_at)")
conn.execute("""CREATE TABLE IF NOT EXISTS account_valuations (
id TEXT PRIMARY KEY, account_id TEXT NOT NULL REFERENCES accounts(id), basis_date TEXT NOT NULL,
amount INTEGER NOT NULL, currency TEXT NOT NULL DEFAULT 'KRW', valuation_kind TEXT NOT NULL DEFAULT 'balance',
source_type TEXT NOT NULL DEFAULT 'manual', source_ref TEXT NOT NULL DEFAULT '',
verification_status TEXT NOT NULL DEFAULT 'unverified', recorded_at TEXT NOT NULL,
memo TEXT NOT NULL DEFAULT '', idempotency_key TEXT NOT NULL DEFAULT '',
supersedes_id TEXT REFERENCES account_valuations(id), correction_reason TEXT NOT NULL DEFAULT '',
as_of_at TEXT, timezone TEXT, cost_basis INTEGER, net_contributed INTEGER,
reported_principal_kind TEXT, reported_principal_label TEXT, profit INTEGER,
return_rate REAL, return_basis TEXT, principal INTEGER, interest INTEGER, fee INTEGER,
representative_liability_definition TEXT
)""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_account_valuations_account_date ON account_valuations(account_id, basis_date, recorded_at)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_account_valuations_status_date ON account_valuations(verification_status, basis_date)")
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_account_valuations_idempotency ON account_valuations(account_id, idempotency_key) WHERE idempotency_key <> ''")
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_account_valuations_single_correction ON account_valuations(supersedes_id) WHERE supersedes_id IS NOT NULL")
conn.execute("INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', ?)", (SCHEMA_VERSION,)) conn.execute("INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', ?)", (SCHEMA_VERSION,))
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
SCHEMA_VERSION = "11" SCHEMA_VERSION = "12"
SCHEMA_SQL = """ SCHEMA_SQL = """
PRAGMA journal_mode=WAL; PRAGMA journal_mode=WAL;
@@ -22,6 +22,7 @@ CREATE TABLE IF NOT EXISTS accounts (
account_number_masked TEXT NOT NULL DEFAULT '', account_number_masked TEXT NOT NULL DEFAULT '',
holder_name TEXT NOT NULL DEFAULT '', holder_name TEXT NOT NULL DEFAULT '',
last_balance INTEGER, last_balance INTEGER,
last_balance_basis_date TEXT NOT NULL DEFAULT '',
last_synced_at TEXT NOT NULL DEFAULT '', last_synced_at TEXT NOT NULL DEFAULT '',
statement_status TEXT NOT NULL DEFAULT 'unknown', statement_status TEXT NOT NULL DEFAULT 'unknown',
liquidity_bucket TEXT NOT NULL DEFAULT 'other', liquidity_bucket TEXT NOT NULL DEFAULT 'other',
@@ -163,11 +164,38 @@ CREATE TABLE IF NOT EXISTS asset_snapshots (
correction_reason TEXT NOT NULL DEFAULT '', correction_reason TEXT NOT NULL DEFAULT '',
is_correction INTEGER NOT NULL DEFAULT 0, is_correction INTEGER NOT NULL DEFAULT 0,
closed_at TEXT NOT NULL DEFAULT '', closed_at TEXT NOT NULL DEFAULT '',
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 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_basis_date ON asset_snapshots(basis_date);
CREATE INDEX IF NOT EXISTS idx_asset_snapshots_source ON asset_snapshots(source_type, source_id); 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
);
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 review_decisions ( CREATE TABLE IF NOT EXISTS review_decisions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
target_type TEXT NOT NULL, target_type TEXT NOT NULL,
@@ -204,13 +232,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 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 INDEX IF NOT EXISTS idx_reconciliation_sessions_status ON reconciliation_sessions(status, closed_at);
INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', '11'); 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', '12');
""" """
ACCOUNT_ADDITIVE_COLUMNS: dict[str, str] = { ACCOUNT_ADDITIVE_COLUMNS: dict[str, str] = {
"account_number_masked": "TEXT NOT NULL DEFAULT ''", "account_number_masked": "TEXT NOT NULL DEFAULT ''",
"holder_name": "TEXT NOT NULL DEFAULT ''", "holder_name": "TEXT NOT NULL DEFAULT ''",
"last_balance": "INTEGER", "last_balance": "INTEGER",
"last_balance_basis_date": "TEXT NOT NULL DEFAULT ''",
"last_synced_at": "TEXT NOT NULL DEFAULT ''", "last_synced_at": "TEXT NOT NULL DEFAULT ''",
"statement_status": "TEXT NOT NULL DEFAULT 'unknown'", "statement_status": "TEXT NOT NULL DEFAULT 'unknown'",
"liquidity_bucket": "TEXT NOT NULL DEFAULT 'other'", "liquidity_bucket": "TEXT NOT NULL DEFAULT 'other'",
+2
View File
@@ -4,6 +4,7 @@ import sys
from types import ModuleType from types import ModuleType
from .application import accounts_write_services as _accounts_write_services from .application import accounts_write_services as _accounts_write_services
from .application import account_valuation_service as _account_valuation_service
from .application import asset_account_services as _asset_account_services from .application import asset_account_services as _asset_account_services
from .application import assets_service as _assets_service from .application import assets_service as _assets_service
from .application import budgets_write_services as _budgets_write_services from .application import budgets_write_services as _budgets_write_services
@@ -27,6 +28,7 @@ _current_kst_datetime = _shared._current_kst_datetime
_PROXY_MODULES = ( _PROXY_MODULES = (
_shared, _shared,
_accounts_write_services, _accounts_write_services,
_account_valuation_service,
_system_services, _system_services,
_asset_account_services, _asset_account_services,
_assets_service, _assets_service,
+18
View File
@@ -42,6 +42,7 @@ def create_ci_fixture_db(db_path: str | Path) -> dict[str, Any]:
holder_name="CI Synthetic Holder", holder_name="CI Synthetic Holder",
last_balance=1_250_000, last_balance=1_250_000,
last_synced_at="2026-06-30T23:59:00+09:00", last_synced_at="2026-06-30T23:59:00+09:00",
last_balance_basis_date="2026-06-30",
statement_status="partial_import", statement_status="partial_import",
liquidity_bucket="cash", liquidity_bucket="cash",
memo="ci synthetic fixture account", memo="ci synthetic fixture account",
@@ -50,6 +51,12 @@ def create_ci_fixture_db(db_path: str | Path) -> dict[str, Any]:
if not account.get("success"): if not account.get("success"):
raise RuntimeError(f"fixture account seed failed: {account.get('error_code')}") raise RuntimeError(f"fixture account seed failed: {account.get('error_code')}")
account_id = account["account"]["id"] account_id = account["account"]["id"]
verified = service.register_account_valuation(
account_id, "2026-06-30", 1_250_000, verification_status="verified",
source_type="manual", source_ref="ci-fixture", idempotency_key="ci-checking-v1", db_path=str(path),
)
if not verified.get("success"):
raise RuntimeError(f"fixture valuation seed failed: {verified.get('error_code')}")
for account_spec in [ for account_spec in [
dict(name="CI Synthetic Investment", type="investment", institution="CI Synthetic Securities", last_balance=5_000_000, liquidity_bucket="investment"), dict(name="CI Synthetic Investment", type="investment", institution="CI Synthetic Securities", last_balance=5_000_000, liquidity_bucket="investment"),
@@ -58,12 +65,23 @@ def create_ci_fixture_db(db_path: str | Path) -> dict[str, Any]:
]: ]:
result = service.create_ledger_account( result = service.create_ledger_account(
last_synced_at="2026-06-30T23:59:00+09:00", last_synced_at="2026-06-30T23:59:00+09:00",
last_balance_basis_date="2026-06-30" if account_spec.get("last_balance") is not None else "",
memo="ci synthetic fixture account", memo="ci synthetic fixture account",
db_path=str(path), db_path=str(path),
**account_spec, **account_spec,
) )
if not result.get("success"): if not result.get("success"):
raise RuntimeError(f"fixture account seed failed: {result.get('error_code')}") raise RuntimeError(f"fixture account seed failed: {result.get('error_code')}")
if account_spec.get("last_balance") is not None:
value = service.register_account_valuation(
result["account"]["id"], "2026-06-30", account_spec["last_balance"],
valuation_kind="loan_balance" if account_spec["type"] == "loan" else "cash_balance",
representative_liability_definition="principal_only" if account_spec["type"] == "loan" else None,
verification_status="verified", source_type="manual", source_ref="ci-fixture",
idempotency_key=f"ci-{account_spec['name']}-v1", db_path=str(path),
)
if not value.get("success"):
raise RuntimeError(f"fixture valuation seed failed: {value.get('error_code')}")
budget = service.create_ledger_budget("2026-06", "식비", 50_000, memo="ci synthetic budget", db_path=str(path)) budget = service.create_ledger_budget("2026-06", "식비", 50_000, memo="ci synthetic budget", db_path=str(path))
if not budget.get("success"): if not budget.get("success"):
@@ -0,0 +1,47 @@
from __future__ import annotations
import inspect
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from dashboard.page_views import accounts as accounts_page
from jarca_ledger_mcp.adapters.mcp import server
def test_dashboard_valuation_basis_never_uses_sync_timestamp():
account = {
"last_balance_basis_date": "2026-06-30",
"last_synced_at": "2026-07-09T23:59:00+09:00",
}
assert accounts_page._account_balance_basis_date(account) == "2026-06-30"
assert accounts_page._account_balance_basis_date({"last_synced_at": account["last_synced_at"]}) == ""
assert "last_synced_at" not in inspect.getsource(accounts_page.page_accounts)
def test_mcp_account_write_tools_forward_explicit_balance_basis(monkeypatch):
calls: list[tuple[str, tuple, dict]] = []
def fake_create(*args, **kwargs):
calls.append(("create", args, kwargs))
return {"success": True}
def fake_update(*args, **kwargs):
calls.append(("update", args, kwargs))
return {"success": True}
monkeypatch.setattr(server.contracts, "create_ledger_account", fake_create)
monkeypatch.setattr(server.contracts, "update_ledger_account", fake_update)
assert server.create_ledger_account(
"dated", last_balance=100, last_synced_at="2026-07-09T00:00:00Z",
last_balance_basis_date="2026-06-30",
)["success"]
assert server.update_ledger_account(
"account-1", last_balance=200, last_synced_at="2026-07-10T00:00:00Z",
last_balance_basis_date="2026-07-01",
)["success"]
assert calls[0][2]["last_balance_basis_date"] == "2026-06-30"
assert calls[1][2]["last_balance_basis_date"] == "2026-07-01"
@@ -0,0 +1,231 @@
import sqlite3
from jarca_ledger_mcp import service
from jarca_ledger_mcp.application.account_valuation_service import record_account_valuation_candidate
from jarca_ledger_mcp.application.imports_service import _record_import_valuation_candidate
def test_bank_to_loan_conversion_rejects_existing_undefined_valuation_without_mutation(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
created = service.create_ledger_account(
"은행", type="bank", last_balance=100, last_synced_at="2026-06-30",
last_balance_basis_date="2026-06-30", db_path=path,
)
account_id = created["account"]["id"]
before = service.list_account_valuations(account_id, db_path=path)
assert before["count"] == 1
assert before["valuations"][0]["representative_liability_definition"] is None
rejected = service.update_ledger_account(account_id, type="loan", db_path=path)
assert rejected["success"] is False
assert rejected["error_code"] == "validation_error"
accounts = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"]
assert next(account for account in accounts if account["id"] == account_id)["type"] == "bank"
after = service.list_account_valuations(account_id, db_path=path)
assert after["count"] == 1
assert after["valuations"] == before["valuations"]
def test_bank_to_loan_conversion_without_valuations_remains_allowed(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account_id = service.create_ledger_account("은행", type="bank", db_path=path)["account"]["id"]
converted = service.update_ledger_account(account_id, type="loan", db_path=path)
assert converted["success"] is True
assert converted["account"]["type"] == "loan"
assert service.list_account_valuations(account_id, db_path=path)["count"] == 0
def test_loan_balance_requires_representative_liability_definition(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
bank = service.create_ledger_account("은행", type="bank", db_path=path)["account"]["id"]
loan = service.create_ledger_account("대출", type="loan", db_path=path)["account"]["id"]
missing_definition = service.register_account_valuation(
loan,
"2026-06-30",
100,
valuation_kind="balance",
verification_status="verified",
db_path=path,
)
assert missing_definition["success"] is False
assert missing_definition["error_code"] == "validation_error"
assert service.list_account_valuations(loan, db_path=path)["count"] == 0
ordinary_balance = service.register_account_valuation(
bank,
"2026-06-30",
100,
valuation_kind="balance",
verification_status="verified",
db_path=path,
)
defined_loan_balance = service.register_account_valuation(
loan,
"2026-06-30",
100,
valuation_kind="balance",
representative_liability_definition="principal_only",
verification_status="verified",
db_path=path,
)
assert ordinary_balance["success"] is True
assert defined_loan_balance["success"] is True
def test_correction_replays_by_evidence_and_conflicts_on_changed_payload(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("계좌", db_path=path)["account"]["id"]
parent = service.register_account_valuation(
account,
"2026-06-30",
100,
source_ref="statement-1",
verification_status="verified",
db_path=path,
)["valuation"]
first = service.correct_account_valuation(
parent["id"],
110,
"statement correction",
db_path=path,
)
replay = service.correct_account_valuation(
parent["id"],
110,
"statement correction",
db_path=path,
)
changed_amount = service.correct_account_valuation(
parent["id"],
111,
"statement correction",
db_path=path,
)
changed_reason = service.correct_account_valuation(
parent["id"],
110,
"different correction",
db_path=path,
)
changed_source = service.correct_account_valuation(
parent["id"],
110,
"statement correction",
source_ref="statement-2",
db_path=path,
)
assert first["success"] is True
assert replay["success"] is True
assert replay["reused"] is True
assert replay["valuation"]["id"] == first["valuation"]["id"]
for changed in (changed_amount, changed_reason, changed_source):
assert changed["success"] is False
assert changed["error_code"] == "correction_conflict"
assert service.list_account_valuations(account, db_path=path)["count"] == 2
def test_direct_loan_candidate_requires_liability_definition_and_writes_nothing(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
loan = service.create_ledger_account("대출", type="loan", db_path=path)["account"]["id"]
with sqlite3.connect(path) as conn:
conn.row_factory = sqlite3.Row
rejected = record_account_valuation_candidate(
conn, loan, "2026-06-30", 100, "KRW", "import", "source", "candidate", "memo",
)
assert rejected["success"] is False
assert rejected["error_code"] == "validation_error"
assert service.list_account_valuations(loan, db_path=path)["count"] == 0
def test_import_loan_candidate_requires_liability_definition_and_writes_nothing(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
loan = service.create_ledger_account("대출", type="loan", db_path=path)["account"]["id"]
with sqlite3.connect(path) as conn:
conn.row_factory = sqlite3.Row
assert _record_import_valuation_candidate(conn, loan, 100, "2026-06-30", "source-hash") is None
assert service.list_account_valuations(loan, db_path=path)["count"] == 0
def test_dated_loan_create_and_historical_update_preserve_current_cache_without_valuations(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
created = service.create_ledger_account(
"대출", type="loan", last_balance=900, last_synced_at="2026-06-30",
last_balance_basis_date="2026-06-30", db_path=path,
)
loan = created["account"]["id"]
assert created["account"]["last_balance"] == 900
assert created["account"]["last_synced_at"] == "2026-06-30"
assert service.list_account_valuations(loan, db_path=path)["count"] == 0
updated = service.update_ledger_account(
loan, last_balance=700, last_synced_at="2026-06-01",
last_balance_basis_date="2026-06-01", db_path=path,
)
assert updated["account"]["last_balance"] == 900
assert updated["account"]["last_synced_at"] == "2026-06-30"
assert service.list_account_valuations(loan, db_path=path)["count"] == 0
def test_loan_cash_balance_and_total_value_are_rejected_without_writes(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
loan = service.create_ledger_account("대출", type="loan", db_path=path)["account"]["id"]
for valuation_kind in ("cash_balance", "total_value"):
rejected = service.register_account_valuation(
loan, "2026-06-30", 100, valuation_kind=valuation_kind, db_path=path,
)
assert rejected["success"] is False
assert rejected["error_code"] == "validation_error"
assert service.list_account_valuations(loan, db_path=path)["count"] == 0
def test_non_loan_candidate_still_succeeds_and_reuses_idempotently(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("계좌", db_path=path)["account"]["id"]
with sqlite3.connect(path) as conn:
conn.row_factory = sqlite3.Row
first = record_account_valuation_candidate(
conn, account, "2026-06-30", 100, "KRW", "import", "source", "candidate", "memo",
)
replay = record_account_valuation_candidate(
conn, account, "2026-06-30", 100, "KRW", "import", "source", "candidate", "memo",
)
assert first["success"] is True
assert first["reused"] is False
assert replay["success"] is True
assert replay["reused"] is True
assert replay["valuation"]["id"] == first["valuation"]["id"]
assert service.list_account_valuations(account, db_path=path)["count"] == 1
def test_candidate_invalid_amount_returns_controlled_error_with_existing_row(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("계좌", db_path=path)["account"]["id"]
with sqlite3.connect(path) as conn:
conn.row_factory = sqlite3.Row
first = record_account_valuation_candidate(
conn, account, "2026-06-30", 100, "KRW", "import", "source", "candidate", "memo",
)
rejected = record_account_valuation_candidate(
conn, account, "2026-06-30", "not-an-integer", "KRW", "import", "source", "candidate", "memo",
)
assert first["success"] is True
assert rejected["success"] is False
assert rejected["error_code"] == "validation_error"
assert service.list_account_valuations(account, db_path=path)["count"] == 1
+102
View File
@@ -0,0 +1,102 @@
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 == "12"
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
+255
View File
@@ -0,0 +1,255 @@
from __future__ import annotations
import json
import sqlite3
from jarca_ledger_mcp import service
def test_replay_preserves_redacted_unverified_candidates_after_correction(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("투자 계좌", type="investment", db_path=db_path)
account_id = account["account"]["id"]
verified = service.register_account_valuation(
account_id, "2026-06-30", 100, verification_status="verified",
idempotency_key="valuation-verified", db_path=db_path,
)
candidate = service.register_account_valuation(
account_id, "2026-06-30", 110, verification_status="unverified",
idempotency_key="valuation-candidate", db_path=db_path,
)
first = service.close_asset_snapshot(
"2026-06-30", idempotency_key="candidate-close-a", db_path=db_path,
)
assert first["success"] is True
assert first["included_items"][0]["unverified_candidates"] == [{
"id": candidate["valuation"]["id"],
"basis_date": "2026-06-30",
"currency": "KRW",
"valuation_kind": "balance",
"verification_status": "unverified",
}]
assert {warning["blocking"] for warning in first["warnings"] if warning["code"] == "unverified_candidate"} == {False}
assert first["included_items"][0]["valuation_id"] == verified["valuation"]["id"]
with sqlite3.connect(db_path) as conn:
persisted = conn.execute("SELECT applied_evidence, excluded_evidence FROM asset_snapshot_runs WHERE id=?", (first["snapshot_run_id"],)).fetchone()
applied_evidence, excluded_evidence = map(json.loads, persisted)
assert applied_evidence[0]["unverified_candidates"] == first["included_items"][0]["unverified_candidates"]
candidate_evidence = [item for item in excluded_evidence if item.get("reason") == "unverified_candidate"]
assert candidate_evidence[0]["candidate"] == first["included_items"][0]["unverified_candidates"][0]
assert candidate_evidence[0]["blocking"] is False
assert candidate_evidence[0]["state_hash"]
corrected = service.close_asset_snapshot(
"2026-06-30", correction_reason="preserve evidence", idempotency_key="candidate-close-b",
db_path=db_path,
)
assert corrected["success"] is True
with sqlite3.connect(db_path) as conn:
before_replay = conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0]
before_runs = conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0]
replay = service.close_asset_snapshot(
"2026-06-30", idempotency_key="candidate-close-a", db_path=db_path,
)
with sqlite3.connect(db_path) as conn:
after_replay = conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0]
after_runs = conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0]
assert replay == first
assert replay["included_items"] == first["included_items"]
assert replay["warnings"] == first["warnings"]
assert after_replay == before_replay
assert after_runs == before_runs
def test_replay_preserves_blocking_candidate_warning_shape(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
account_id = service.create_ledger_account(
"미확인 계좌", type="investment", db_path=db_path,
)["account"]["id"]
service.register_account_valuation(
account_id, "2026-06-30", 110, verification_status="unverified",
idempotency_key="only-candidate", db_path=db_path,
)
first = service.close_asset_snapshot(
"2026-06-30", idempotency_key="blocking-candidate-close",
allow_missing_exclusions=True, approved_by="reviewer",
approval_reason="exclude unverified evidence",
approval_reference="synthetic-approval", db_path=db_path,
)
assert first["success"] is True
warning = next(item for item in first["warnings"] if item["code"] == "unverified_candidate")
assert "blocking" not in warning
replay = service.close_asset_snapshot(
"2026-06-30", idempotency_key="blocking-candidate-close",
allow_missing_exclusions=True, approved_by="reviewer",
approval_reason="exclude unverified evidence",
approval_reference="synthetic-approval", db_path=db_path,
)
assert replay == first
replay_warning = next(item for item in replay["warnings"] if item["code"] == "unverified_candidate")
assert "blocking" not in replay_warning
def test_malformed_persisted_evidence_fails_closed_without_snapshot_writes(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("현금", 100, "2026-06-30", "cash", db_path=db_path)
first = service.close_asset_snapshot(
"2026-06-30", idempotency_key="malformed-evidence-close", db_path=db_path,
)
assert first["success"] is True
with sqlite3.connect(db_path) as conn:
conn.execute(
"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_runs = conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0]
report = service.get_asset_snapshot_report("2026-06-30", db_path=db_path)
replay = service.close_asset_snapshot(
"2026-06-30", idempotency_key="malformed-evidence-close", db_path=db_path,
)
timeseries = service.get_net_worth_timeseries(
"2026-06-01", "2026-06-30", db_path=db_path,
)
for result in (report, replay, timeseries):
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 asset_snapshot_runs").fetchone()[0] == before_runs
def test_exact_run_replay_after_correction_reconstructs_original_persisted_run(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("현금", 100, "2026-06-30", "cash", db_path=db_path)
service.create_manual_asset("평가 대기", None, "2026-06-30", "other", db_path=db_path)
first = service.close_asset_snapshot(
"2026-06-30",
source_note="synthetic initial close",
memo="synthetic initial memo",
idempotency_key="snapshot-close-a",
allow_missing_exclusions=True,
approved_by="reviewer-a",
approval_reason="synthetic initial approval",
approval_reference="approval-a",
db_path=db_path,
)
assert first["success"] is True
assert first["close_status"] == "conditional"
assert first["is_correction"] is False
assert first["warnings"]
assert first["approval"]["approval_reference"] == "approval-a"
assert first["provenance"]["snapshot_run_id"] == first["snapshot_run_id"]
corrected = service.close_asset_snapshot(
"2026-06-30",
source_note="synthetic correction close",
memo="synthetic correction memo",
correction_reason="synthetic correction",
idempotency_key="snapshot-close-b",
allow_missing_exclusions=True,
approved_by="reviewer-b",
approval_reason="synthetic correction approval",
approval_reference="approval-b",
db_path=db_path,
)
assert corrected["success"] is True
assert corrected["is_correction"] is True
assert corrected["snapshot_run_id"] != first["snapshot_run_id"]
assert corrected["correction"]["correction_of_run_id"] == first["snapshot_run_id"]
assert corrected["approval"]["approval_reference"] == "approval-b"
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]
replay = service.close_asset_snapshot(
"2026-06-30",
source_note="synthetic initial close",
memo="synthetic initial memo",
idempotency_key="snapshot-close-a",
allow_missing_exclusions=True,
approved_by="reviewer-a",
approval_reason="synthetic initial approval",
approval_reference="approval-a",
db_path=db_path,
)
with sqlite3.connect(db_path) as conn:
after_replay = conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0]
assert replay == first
assert replay["snapshot_run_id"] == first["snapshot_run_id"]
assert replay["inserted_snapshot_ids"] == first["inserted_snapshot_ids"]
assert replay["correction"] == first["correction"]
assert replay["approval"] == first["approval"]
assert replay["warnings"] == first["warnings"]
assert replay["provenance"] == first["provenance"]
assert replay["included_items"] == first["included_items"]
assert replay["excluded_items"] == first["excluded_items"]
assert replay["total_assets"] == first["total_assets"]
assert replay["total_liabilities"] == first["total_liabilities"]
assert replay["net_worth"] == first["net_worth"]
assert replay["snapshot_run_id"] != corrected["snapshot_run_id"]
assert corrected["snapshot_run_id"] not in replay["inserted_snapshot_ids"]
assert after_replay == before_replay
def test_empty_run_replay_after_nonempty_correction_preserves_original_empty_payload(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
service.create_ledger_account("누락 계좌", db_path=db_path)
first = service.close_asset_snapshot(
"2026-06-30",
idempotency_key="empty-close-a",
allow_missing_exclusions=True,
approved_by="reviewer-a",
approval_reason="approve empty",
approval_reference="approval-a",
db_path=db_path,
)
assert first["success"] is True
assert first["inserted_count"] == 0
assert first["included_items"] == []
service.create_manual_asset("후속 자산", 100, "2026-06-30", "cash", db_path=db_path)
corrected = service.close_asset_snapshot(
"2026-06-30",
correction_reason="add verified asset",
idempotency_key="nonempty-close-b",
allow_missing_exclusions=True,
approved_by="reviewer-b",
approval_reason="approve correction",
approval_reference="approval-b",
db_path=db_path,
)
assert corrected["success"] is True
assert corrected["inserted_count"] == 1
# Restore the original live authority while retaining the later immutable
# correction run, so replay reaches the original-key reconstruction path.
with sqlite3.connect(db_path) as conn:
conn.execute("DELETE FROM manual_assets WHERE name=?", ("후속 자산",))
replay = service.close_asset_snapshot(
"2026-06-30",
idempotency_key="empty-close-a",
allow_missing_exclusions=True,
approved_by="reviewer-a",
approval_reason="approve empty",
approval_reference="approval-a",
db_path=db_path,
)
assert replay == first
assert replay["inserted_count"] == 0
assert replay["inserted_snapshot_ids"] == []
assert replay["included_items"] == []
+6 -1
View File
@@ -25,6 +25,8 @@ def test_manual_assets_snapshot_report_and_net_worth_formula(tmp_path):
stocks = service.create_ledger_account("투자", type="investment", last_balance=2_000_000, liquidity_bucket="investment", db_path=db_path) stocks = service.create_ledger_account("투자", type="investment", last_balance=2_000_000, liquidity_bucket="investment", db_path=db_path)
missing = service.create_ledger_account("미평가 계좌", liquidity_bucket="deposit", db_path=db_path) missing = service.create_ledger_account("미평가 계좌", liquidity_bucket="deposit", db_path=db_path)
assert cash["success"] and stocks["success"] and missing["success"] assert cash["success"] and stocks["success"] and missing["success"]
assert service.register_account_valuation(cash["account"]["id"], "2026-06-30", 1_000_000, verification_status="verified", idempotency_key="cash-v1", db_path=db_path)["success"]
assert service.register_account_valuation(stocks["account"]["id"], "2026-06-30", 2_000_000, verification_status="verified", idempotency_key="stocks-v1", db_path=db_path)["success"]
house = service.create_manual_asset("전세보증금", 300_000_000, "2026-06-30", "real_estate", source_note="synthetic appraisal", db_path=db_path) house = service.create_manual_asset("전세보증금", 300_000_000, "2026-06-30", "real_estate", source_note="synthetic appraisal", db_path=db_path)
loan = service.create_manual_asset("주담대", 120_000_000, "2026-06-30", "loan", source_note="synthetic loan balance", db_path=db_path) loan = service.create_manual_asset("주담대", 120_000_000, "2026-06-30", "loan", source_note="synthetic loan balance", db_path=db_path)
@@ -59,7 +61,9 @@ def test_snapshot_rows_are_append_only_and_timeseries_uses_same_formula(tmp_path
may = service.get_asset_snapshot_report("2026-05-31", db_path=db_path) may = service.get_asset_snapshot_report("2026-05-31", db_path=db_path)
assert may["success"] is True assert may["success"] is True
assert may["source"] == "asset_snapshots" assert may["source"] == "legacy_asset_snapshots"
assert may["is_closed_snapshot"] is False
assert may["close_status"] == "legacy_read_only"
assert may["snapshot_count"] == 3 assert may["snapshot_count"] == 3
assert may["net_worth"] == 121_000_000 assert may["net_worth"] == 121_000_000
@@ -92,6 +96,7 @@ def test_manual_asset_update_deactivate_preview_and_close_correction_policy(tmp_
loan = service.create_manual_asset("대출", 120_000_000, "2026-06-30", "loan", source_note="synthetic", db_path=db_path) loan = service.create_manual_asset("대출", 120_000_000, "2026-06-30", "loan", source_note="synthetic", db_path=db_path)
dormant = service.create_manual_asset("해지 예금", 5_000_000, "2026-06-30", "deposit", source_note="synthetic", db_path=db_path) dormant = service.create_manual_asset("해지 예금", 5_000_000, "2026-06-30", "deposit", source_note="synthetic", db_path=db_path)
assert account["success"] and asset["success"] and loan["success"] and dormant["success"] assert account["success"] and asset["success"] and loan["success"] and dormant["success"]
assert service.register_account_valuation(account["account"]["id"], "2026-06-30", 1_000_000, verification_status="verified", idempotency_key="cash-v1", db_path=db_path)["success"]
updated = service.update_manual_asset(asset["manual_asset"]["id"], valuation_amount=310_000_000, memo="updated synthetic", db_path=db_path) updated = service.update_manual_asset(asset["manual_asset"]["id"], valuation_amount=310_000_000, memo="updated synthetic", db_path=db_path)
assert updated["success"] is True assert updated["success"] is True
+7 -1
View File
@@ -67,6 +67,11 @@ def _build_contract_fixture(db_path: str) -> None:
) )
assert account["success"] is True assert account["success"] is True
account_id = account["account"]["id"] account_id = account["account"]["id"]
assert service.register_account_valuation(
account_id, "2026-06-30", 1_000_000, verification_status="verified",
source_type="manual", source_ref="contract-fixture", idempotency_key="contract-account-v1",
db_path=db_path,
)["success"] is True
assert service.create_ledger_transaction( assert service.create_ledger_transaction(
"2026-06-01", "2026-06-01",
"income", "income",
@@ -114,8 +119,9 @@ def test_list_report_and_mutation_success_envelope_contract(tmp_path: Path) -> N
db_path = str(tmp_path / "contract-fixture.sqlite3") db_path = str(tmp_path / "contract-fixture.sqlite3")
_build_contract_fixture(db_path) _build_contract_fixture(db_path)
mutation = service.create_ledger_account("Synthetic Savings", type="bank", liquidity_bucket="deposit", db_path=db_path) mutation = service.create_ledger_account("Synthetic Savings", type="bank", liquidity_bucket="deposit", last_balance=0, last_synced_at="2026-06-30", db_path=db_path)
assert mutation["success"] is True assert mutation["success"] is True
assert service.register_account_valuation(mutation["account"]["id"], "2026-06-30", 0, verification_status="verified", idempotency_key="savings-v1", db_path=db_path)["success"]
_assert_subset_schema(mutation, contract["mutation_success_envelope"], "mutation_success_envelope") _assert_subset_schema(mutation, contract["mutation_success_envelope"], "mutation_success_envelope")
manual_asset = service.create_manual_asset("Synthetic Deposit", 2_000_000, "2026-06-30", "deposit", source_note="synthetic contract", db_path=db_path) manual_asset = service.create_manual_asset("Synthetic Deposit", 2_000_000, "2026-06-30", "deposit", source_note="synthetic contract", db_path=db_path)
+87 -2
View File
@@ -61,7 +61,7 @@ def test_streamlit_apptest_core_pages_use_synthetic_fixture_db(tmp_path, monkeyp
"월정산": "월간 핵심 KPI", "월정산": "월간 핵심 KPI",
} }
for page, expected_text in page_expectations.items(): for page, expected_text in page_expectations.items():
at = AppTest.from_file(str(app_path), default_timeout=10) at = AppTest.from_file(str(app_path), default_timeout=30)
at.query_params["page"] = page at.query_params["page"] = page
if page == "월정산": if page == "월정산":
at.query_params["month"] = "2026-06" at.query_params["month"] = "2026-06"
@@ -88,7 +88,7 @@ def test_streamlit_apptest_core_pages_use_synthetic_fixture_db(tmp_path, monkeyp
assert "최근 동기화: 2026-06-30T23:59:00+09:00" in body_text assert "최근 동기화: 2026-06-30T23:59:00+09:00" in body_text
assert "잔액: 1,250,000원" in body_text assert "잔액: 1,250,000원" in body_text
assert "상태: 정상" in body_text assert "상태: 정상" in body_text
assert "자산 상세: 경고 없음" in body_text assert "자산 상세: 경고 3건 / 제외 1건" in body_text
assert "월정산 결과 / Evidence" in body_text assert "월정산 결과 / Evidence" in body_text
assert "읽기 전용 근거 요약" in body_text assert "읽기 전용 근거 요약" in body_text
assert "이번 범위에는 월정산 close 저장/실행이 포함되지 않습니다" in body_text assert "이번 범위에는 월정산 close 저장/실행이 포함되지 않습니다" in body_text
@@ -130,3 +130,88 @@ def test_streamlit_apptest_core_pages_use_synthetic_fixture_db(tmp_path, monkeyp
assert "/home/" not in body_text assert "/home/" not in body_text
assert "raw_payload" not in body_text assert "raw_payload" not in body_text
assert "🔒 읽기 전용" in body_text assert "🔒 읽기 전용" in body_text
def _dashboard_body(at):
return "\\n".join(str(item.value) for item in list(at.text) + list(at.markdown) + list(at.caption))
def test_authenticated_asset_and_monthly_pages_render_evidence_details(tmp_path, monkeypatch):
fixture = tmp_path / "fixture.sqlite3"
create_ci_fixture_db(fixture)
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(fixture))
monkeypatch.setenv(authentication.ADMIN_ID_ENV, "test-admin")
monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, "test-password")
monkeypatch.setenv(authentication.SIGNING_SECRET_ENV, "s" * 64)
settings = authentication.load_auth_settings()
token = authentication.issue_persistent_token(settings, remember_me=False).value
monkeypatch.setattr(authentication, "_request_cookie", lambda: token)
app_path = Path(__file__).resolve().parents[1] / "dashboard" / "app.py"
for page in ("자산/마감", "월정산"):
at = AppTest.from_file(str(app_path), default_timeout=30)
at.query_params["page"] = page
if page == "월정산":
at.query_params["month"] = "2026-06"
at.run()
assert not at.exception
body = _dashboard_body(at)
if page == "월정산":
assert "missing_verified_exact_valuation" in body
assert "acct_" in body
assert "제외 상세" in body
assert "source_note" not in body
assert "memo" not in body
def test_authenticated_dashboard_all_pages_are_no_write(tmp_path, monkeypatch):
fixture = tmp_path / "fixture.sqlite3"
create_ci_fixture_db(fixture)
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(fixture))
monkeypatch.setenv(authentication.ADMIN_ID_ENV, "test-admin")
monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, "test-password")
monkeypatch.setenv(authentication.SIGNING_SECRET_ENV, "s" * 64)
settings = authentication.load_auth_settings()
token = authentication.issue_persistent_token(settings, remember_me=False).value
monkeypatch.setattr(authentication, "_request_cookie", lambda: token)
app_path = Path(__file__).resolve().parents[1] / "dashboard" / "app.py"
pages = ["월정산", "요약", "자산/마감", "거래내역", "확인필요 큐", "미분류", "분류 규칙", "카테고리", "계좌", "Import"]
import sqlite3
def data_version():
with sqlite3.connect(fixture) as conn:
return conn.execute("PRAGMA data_version").fetchone()[0]
before_data_version = data_version()
for page in pages:
at = AppTest.from_file(str(app_path), default_timeout=30)
at.query_params["page"] = page
if page == "월정산":
at.query_params["month"] = "2026-06"
at.run()
assert not at.exception, (page, at.exception)
assert data_version() == before_data_version
def test_authenticated_dashboard_missing_db_is_stable_and_does_not_initialize(tmp_path, monkeypatch):
db_path = tmp_path / "missing" / "nested" / "ledger.sqlite3"
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(db_path))
monkeypatch.setenv(authentication.ADMIN_ID_ENV, "test-admin")
monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, "test-password")
monkeypatch.setenv(authentication.SIGNING_SECRET_ENV, "s" * 64)
settings = authentication.load_auth_settings()
token = authentication.issue_persistent_token(settings, remember_me=False).value
monkeypatch.setattr(authentication, "_request_cookie", lambda: token)
app_path = Path(__file__).resolve().parents[1] / "dashboard" / "app.py"
for page in ["월정산", "요약", "자산/마감", "거래내역", "확인필요 큐", "미분류", "분류 규칙", "카테고리", "계좌", "Import"]:
at = AppTest.from_file(str(app_path), default_timeout=30)
at.query_params["page"] = page
at.run()
assert not at.exception, (page, at.exception)
body = _dashboard_body(at) + "\n".join(str(item.value) for item in at.error)
assert "Ledger DB를 사용할 수 없습니다" in body
assert not db_path.exists()
assert not db_path.parent.exists()
+2
View File
@@ -53,6 +53,7 @@ def test_direct_page_query_access_renders_requested_page(monkeypatch):
selected = {} selected = {}
monkeypatch.setattr(app, "require_dashboard_authentication", lambda: True) monkeypatch.setattr(app, "require_dashboard_authentication", lambda: True)
monkeypatch.setattr(app, "get_db_path", lambda: "fixture.sqlite3") monkeypatch.setattr(app, "get_db_path", lambda: "fixture.sqlite3")
monkeypatch.setattr(app.overview_gateway, "get_ledger_db_status", lambda **kwargs: {"success": True})
monkeypatch.setattr(app, "inject_app_css", lambda: None) monkeypatch.setattr(app, "inject_app_css", lambda: None)
monkeypatch.setattr(app.st, "query_params", {"page": "계좌"}) monkeypatch.setattr(app.st, "query_params", {"page": "계좌"})
monkeypatch.setattr(app.st, "session_state", {"dashboard_page": "요약"}) monkeypatch.setattr(app.st, "session_state", {"dashboard_page": "요약"})
@@ -157,6 +158,7 @@ def test_refresh_control_preserves_cache_clear_contract(monkeypatch):
monkeypatch.setattr(app, "require_dashboard_authentication", lambda: True) monkeypatch.setattr(app, "require_dashboard_authentication", lambda: True)
monkeypatch.setattr(app, "get_db_path", lambda: "fixture.sqlite3") monkeypatch.setattr(app, "get_db_path", lambda: "fixture.sqlite3")
monkeypatch.setattr(app.overview_gateway, "get_ledger_db_status", lambda **kwargs: {"success": True})
monkeypatch.setattr(app, "inject_app_css", lambda: None) monkeypatch.setattr(app, "inject_app_css", lambda: None)
monkeypatch.setattr(app.st, "query_params", {"page": "요약"}) monkeypatch.setattr(app.st, "query_params", {"page": "요약"})
monkeypatch.setattr(app, "render_sidebar_nav", lambda pages, page: page) monkeypatch.setattr(app, "render_sidebar_nav", lambda pages, page: page)
@@ -0,0 +1,262 @@
from types import SimpleNamespace
import sqlite3
from jarca_ledger_mcp import service
from jarca_ledger_mcp.application import imports_service
def _parsed(transaction, source_hash):
return {
"transactions": [transaction],
"validation": {"ok": True},
"source_hash": source_hash,
"metadata": {"final_balance": transaction.balance_after, "account_number_masked": ""},
"file_path": "/tmp/synthetic.pdf",
"page_count": 1,
}
def _transaction(date, balance, row):
return SimpleNamespace(
transaction_date=date,
transaction_datetime=f"{date} 10:00:00",
amount=100,
counterparty="상대",
raw_summary="이체",
branch="",
remittance_memo="",
balance_after=balance,
type="income",
page=1,
action="입금",
external_id=lambda source_hash: f"kb:{source_hash}:{row}",
raw_payload=lambda: "{}",
)
def test_late_import_current_balance_is_authoritative_by_evidence_basis_date(monkeypatch, tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account(
"KB", institution="KB국민은행", db_path=path,
)["account"]["id"]
imports = iter(
(
(_transaction("2026-06-01", 700, 1), "hash-old"),
(_transaction("2026-06-30", 900, 2), "hash-new"),
)
)
monkeypatch.setattr(imports_service, "parse_kb_bank_pdf", lambda *_: _parsed(*next(imports)))
sync_times = iter(("2026-07-01T09:00:00", "2026-07-02T09:00:00"))
monkeypatch.setattr(imports_service, "now_iso", lambda: next(sync_times))
older = imports_service.import_kb_bank_pdf("older", "pw", account_id=account, db_path=path)
newer = imports_service.import_kb_bank_pdf("newer", "pw", account_id=account, db_path=path)
current = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0]
valuations = service.list_account_valuations(account, db_path=path)["valuations"]
assert older["success"] and newer["success"]
assert current["last_balance"] == 900
assert current["last_synced_at"] == "2026-07-02T09:00:00"
assert current["last_balance_basis_date"] == "2026-06-30"
assert {(item["basis_date"], item["amount"]) for item in valuations} >= {
("2026-06-01", 700),
("2026-06-30", 900),
}
def test_empty_balance_with_basis_date_is_rejected_without_mutation_before_import(tmp_path, monkeypatch):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("KB", institution="KB국민은행", db_path=path)["account"]["id"]
before = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0]
before_valuations = service.list_account_valuations(account, db_path=path)["valuations"]
rejected = service.update_ledger_account(
account,
last_balance="",
last_balance_basis_date="2026-07-01",
db_path=path,
)
after = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0]
after_valuations = service.list_account_valuations(account, db_path=path)["valuations"]
assert rejected["success"] is False
assert rejected["error_code"] == "validation_error"
assert after == before
assert after_valuations == before_valuations
monkeypatch.setattr(
imports_service,
"parse_kb_bank_pdf",
lambda *_: _parsed(_transaction("2026-06-30", 900, 1), "hash-valid"),
)
monkeypatch.setattr(imports_service, "now_iso", lambda: "2026-07-02T09:00:00")
imported = imports_service.import_kb_bank_pdf("synthetic", "pw", account_id=account, db_path=path)
current = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0]
valuations = service.list_account_valuations(account, db_path=path)["valuations"]
assert imported["success"] is True
assert current["last_balance"] == 900
assert current["last_balance_basis_date"] == "2026-06-30"
assert {(item["basis_date"], item["amount"]) for item in valuations} == {("2026-06-30", 900)}
def test_create_rejects_whitespace_or_non_integer_dated_balance_without_mutation(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
baseline = service.create_ledger_account("baseline", db_path=path)
before = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"]
before_valuations = service.list_account_valuations(baseline["account"]["id"], db_path=path)["valuations"]
for invalid_balance in (" ", "not-an-integer", True, False, 1.2, -1.2):
rejected = service.create_ledger_account(
"invalid",
last_balance=invalid_balance,
last_balance_basis_date="2026-07-01",
db_path=path,
)
assert rejected["success"] is False
assert rejected["error_code"] == "validation_error"
assert service.list_ledger_accounts(active_only=False, db_path=path)["accounts"] == before
assert service.list_account_valuations(baseline["account"]["id"], db_path=path)["valuations"] == before_valuations
def test_update_rejects_bool_or_float_dated_balance_without_mutation(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account(
"baseline",
last_balance=900,
last_balance_basis_date="2026-07-01",
db_path=path,
)["account"]["id"]
before = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0]
before_valuations = service.list_account_valuations(account, db_path=path)["valuations"]
for invalid_balance in (True, False, 1.2, -1.2):
rejected = service.update_ledger_account(
account,
last_balance=invalid_balance,
last_balance_basis_date="2026-07-02",
db_path=path,
)
assert rejected["success"] is False
assert rejected["error_code"] == "validation_error"
assert service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0] == before
assert service.list_account_valuations(account, db_path=path)["valuations"] == before_valuations
def test_update_rejects_empty_undated_balance_without_mutation(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account(
"baseline",
last_balance=900,
last_balance_basis_date="2026-07-01",
db_path=path,
)["account"]["id"]
before = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0]
before_valuations = service.list_account_valuations(account, db_path=path)["valuations"]
rejected = service.update_ledger_account(account, last_balance="", db_path=path)
assert rejected["success"] is False
assert rejected["error_code"] == "validation_error"
assert service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0] == before
assert service.list_account_valuations(account, db_path=path)["valuations"] == before_valuations
def test_update_preserves_zero_and_signed_integer_balance_semantics(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("baseline", db_path=path)["account"]["id"]
zero = service.update_ledger_account(
account,
last_balance=" +0 ",
last_balance_basis_date="2026-07-01",
db_path=path,
)
signed = service.update_ledger_account(
account,
last_balance="-12",
last_balance_basis_date="2026-07-02",
db_path=path,
)
assert zero["success"] is True
assert zero["account"]["last_balance"] == 0
assert signed["success"] is True
assert signed["account"]["last_balance"] == -12
assert signed["account"]["last_balance_basis_date"] == "2026-07-02"
assert [(item["basis_date"], item["amount"]) for item in service.list_account_valuations(account, db_path=path)["valuations"]] == [
("2026-07-01", 0),
("2026-07-02", -12),
]
def test_zero_dated_balance_and_valid_import_recovery(tmp_path, monkeypatch):
path = str(tmp_path / "ledger.sqlite3")
zero = service.create_ledger_account(
"zero",
last_balance=0,
last_balance_basis_date="2026-06-01",
db_path=path,
)
assert zero["success"] is True
assert zero["account"]["last_balance"] == 0
assert [(item["basis_date"], item["amount"]) for item in service.list_account_valuations(zero["account"]["id"], db_path=path)["valuations"]] == [("2026-06-01", 0)]
account = service.create_ledger_account("recoverable", db_path=path)["account"]["id"]
monkeypatch.setattr(
imports_service,
"parse_kb_bank_pdf",
lambda *_: _parsed(_transaction("2026-06-30", 900, 1), "hash-valid"),
)
monkeypatch.setattr(imports_service, "now_iso", lambda: "2026-07-02T09:00:00")
imported = imports_service.import_kb_bank_pdf("synthetic", "pw", account_id=account, db_path=path)
current = next(item for item in service.list_ledger_accounts(active_only=False, db_path=path)["accounts"] if item["id"] == account)
assert imported["success"] is True
assert current["last_balance"] == 900
assert current["last_balance_basis_date"] == "2026-06-30"
def test_account_write_sync_timestamp_does_not_infer_balance_basis_or_candidate(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
unknown = service.create_ledger_account(
"기준일 미상",
last_balance=500,
last_synced_at="2026-07-01T09:00:00+09:00",
db_path=path,
)
assert unknown["success"] is True
assert unknown["account"]["last_balance_basis_date"] == ""
assert service.list_account_valuations(unknown["account"]["id"], db_path=path)["count"] == 0
dated = service.create_ledger_account(
"기준일 확인",
last_balance=600,
last_synced_at="2026-07-01T09:00:00+09:00",
db_path=path,
last_balance_basis_date="2026-06-30",
)
valuations = service.list_account_valuations(dated["account"]["id"], db_path=path)["valuations"]
assert dated["account"]["last_balance_basis_date"] == "2026-06-30"
assert [(item["basis_date"], item["amount"]) for item in valuations] == [("2026-06-30", 600)]
def test_unknown_legacy_balance_basis_is_not_reported_as_valuation_date(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("legacy", db_path=path)["account"]["id"]
with sqlite3.connect(path) as conn:
conn.execute(
"UPDATE accounts SET last_balance=?, last_synced_at=?, last_balance_basis_date='' WHERE id=?",
(500, "2026-07-01T09:00:00+09:00", account),
)
report = service.get_asset_snapshot_report("2026-06-30", db_path=path)
excluded = next(item for item in report["excluded_items"] if item["source_id"] == account)
assert excluded["valuation_date"] == ""
assert excluded["last_synced_at"] == "2026-07-01T09:00:00+09:00"
+5
View File
@@ -6,6 +6,7 @@ import sqlite3
from pathlib import Path from pathlib import Path
from jarca_ledger_mcp import service from jarca_ledger_mcp import service
from jarca_ledger_mcp.infrastructure.sqlite import init_db
from jarca_ledger_mcp.provenance_policy import ( from jarca_ledger_mcp.provenance_policy import (
EXPOSURE_LEVEL_DEBUG_GATED, EXPOSURE_LEVEL_DEBUG_GATED,
EXPOSURE_LEVEL_MINIMAL, EXPOSURE_LEVEL_MINIMAL,
@@ -63,6 +64,7 @@ def test_preview_ledger_csv_import_redacts_full_path_and_raw_columns(tmp_path):
def test_preview_and_import_kb_bank_pdf_sanitize_path_and_payload(monkeypatch, tmp_path): def test_preview_and_import_kb_bank_pdf_sanitize_path_and_payload(monkeypatch, tmp_path):
db_path = str(tmp_path / "ledger.sqlite3") db_path = str(tmp_path / "ledger.sqlite3")
init_db(db_path)
parsed = { parsed = {
"file_path": SECRET_SOURCE_PATH, "file_path": SECRET_SOURCE_PATH,
"source_hash": SOURCE_HASH, "source_hash": SOURCE_HASH,
@@ -101,6 +103,7 @@ def test_preview_and_import_kb_bank_pdf_sanitize_path_and_payload(monkeypatch, t
def test_preview_and_import_seoulpay_xlsx_sanitize_path_and_payload(monkeypatch, tmp_path): def test_preview_and_import_seoulpay_xlsx_sanitize_path_and_payload(monkeypatch, tmp_path):
db_path = str(tmp_path / "ledger.sqlite3") db_path = str(tmp_path / "ledger.sqlite3")
init_db(db_path)
parsed = { parsed = {
"file_path": "/very/secret/home/alice/Documents/private/seoulpay.xlsx", "file_path": "/very/secret/home/alice/Documents/private/seoulpay.xlsx",
"source_hash": SOURCE_HASH, "source_hash": SOURCE_HASH,
@@ -144,6 +147,7 @@ def test_preview_and_import_seoulpay_xlsx_sanitize_path_and_payload(monkeypatch,
def test_preview_and_import_toss_bank_pdf_sanitize_path_and_payload(monkeypatch, tmp_path): def test_preview_and_import_toss_bank_pdf_sanitize_path_and_payload(monkeypatch, tmp_path):
db_path = str(tmp_path / "ledger.sqlite3") db_path = str(tmp_path / "ledger.sqlite3")
init_db(db_path)
parsed = { parsed = {
"file_path": SECRET_SOURCE_PATH, "file_path": SECRET_SOURCE_PATH,
"source_hash": SOURCE_HASH, "source_hash": SOURCE_HASH,
@@ -184,6 +188,7 @@ def test_preview_and_import_toss_bank_pdf_sanitize_path_and_payload(monkeypatch,
def test_preview_and_import_toss_securities_pdf_sanitize_path_and_payload(monkeypatch, tmp_path): def test_preview_and_import_toss_securities_pdf_sanitize_path_and_payload(monkeypatch, tmp_path):
db_path = str(tmp_path / "ledger.sqlite3") db_path = str(tmp_path / "ledger.sqlite3")
init_db(db_path)
parsed = { parsed = {
"file_path": SECRET_SOURCE_PATH, "file_path": SECRET_SOURCE_PATH,
"source_hash": SOURCE_HASH, "source_hash": SOURCE_HASH,
+672
View File
@@ -0,0 +1,672 @@
import sqlite3
import inspect
import json
from concurrent.futures import ThreadPoolExecutor
from jarca_ledger_mcp import service
from jarca_ledger_mcp.application import assets_service
from jarca_ledger_mcp.application.account_valuation_service import (
_public_candidate,
record_account_valuation_candidate,
)
from jarca_ledger_mcp.application.imports_service import _record_import_valuation_candidate
from jarca_ledger_mcp.db import init_db
def test_empty_conditional_close_is_closed_and_second_close_requires_correction(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
service.create_ledger_account("미확인 계좌", db_path=path)
first = service.close_asset_snapshot(
"2026-06-30", selection_policy="exact", allow_missing_exclusions=True,
approved_by="reviewer", approval_reason="approved empty close", approval_reference="APR-1", db_path=path,
)
assert first["success"] and first["close_status"] == "conditional" and first["inserted_count"] == 0
report = service.get_asset_snapshot_report("2026-06-30", db_path=path)
assert report["is_closed_snapshot"] is True and report["snapshot_count"] == 0
assert report["close_metadata"]["snapshot_run_id"] == first["snapshot_run_id"]
second = service.close_asset_snapshot(
"2026-06-30", selection_policy="exact", allow_missing_exclusions=True,
approved_by="reviewer", approval_reason="again", approval_reference="APR-2", db_path=path,
)
assert second["success"] is False and second["error_code"] == "validation_error"
def test_preview_marks_approval_eligible_missing_valuation_as_incomplete_draft(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
service.create_ledger_account("미확인 계좌", db_path=path)
preview = service.preview_asset_snapshot_close("2026-06-30", db_path=path)
assert preview["success"] is True
assert preview["close_status"] == "incomplete_draft"
assert any(warning["code"] == "missing_verified_exact_valuation" for warning in preview["warnings"])
def test_reconciliation_uses_corrected_investment_leaf_without_transaction_count_change(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("투자", type="investment", last_balance=100, last_synced_at="2026-06-30", db_path=path)["account"]["id"]
original = service.register_account_valuation(account, "2026-06-30", 100, valuation_kind="total_value", verification_status="verified", idempotency_key="v1", db_path=path)
corrected = service.correct_account_valuation(original["valuation"]["id"], 250, correction_reason="정정", idempotency_key="v2", db_path=path)
assert corrected["success"]
report = service.get_balance_reconciliation_report(account_id=account, baseline_at="2026-06-30T00:00:00+09:00", db_path=path)
assert report["items"][0]["external_confirmed_value"] == 250
assert report["items"][0]["transaction_count"] == 0
def test_reconciliation_prefers_investment_total_value_over_balance(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account(
"투자", type="investment", last_balance=100,
last_synced_at="2026-06-30", db_path=path,
)["account"]["id"]
total = service.register_account_valuation(
account, "2026-06-30", 250, valuation_kind="total_value",
verification_status="verified", idempotency_key="total", db_path=path,
)
service.register_account_valuation(
account, "2026-06-30", 100, valuation_kind="balance",
verification_status="verified", idempotency_key="balance", db_path=path,
)
item = service.get_balance_reconciliation_report(
account_id=account, baseline_at="2026-06-30T00:00:00+09:00", db_path=path,
)["items"][0]
assert item["external_confirmed_value"] == 250
assert item["valuation_id"] == total["valuation"]["id"]
assert item["valuation_kind"] == "total_value"
def test_historical_account_update_preserves_current_and_adds_one_candidate(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
created = service.create_ledger_account("계좌", last_balance=900, last_synced_at="2026-06-30", db_path=path, last_balance_basis_date="2026-06-30")
account = created["account"]["id"]
updated = service.update_ledger_account(account, 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 and updated["account"]["last_synced_at"] == "2026-06-30"
values = service.list_account_valuations(account, db_path=path)["valuations"]
assert len(values) == 2 and sum(v["basis_date"] == "2026-06-01" for v in values) == 1
def test_schema12_migration_preserves_legacy_rows_indexes_and_is_idempotent(tmp_path):
path = str(tmp_path / "legacy.sqlite3")
with sqlite3.connect(path) as conn:
conn.executescript("""
CREATE TABLE schema_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE 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 '',
created_at TEXT NOT NULL, updated_at TEXT NOT NULL
);
CREATE TABLE transactions (
id TEXT PRIMARY KEY, transaction_date TEXT NOT NULL, type TEXT NOT NULL,
amount INTEGER NOT NULL, category TEXT NOT NULL DEFAULT '',
subcategory TEXT NOT NULL DEFAULT '', person TEXT NOT NULL DEFAULT '',
account_id TEXT NOT NULL DEFAULT '', dedupe_key TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL, updated_at TEXT NOT NULL
);
CREATE INDEX legacy_transactions_date ON transactions(transaction_date);
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 '', created_at TEXT NOT NULL
);
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');
""")
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()
indexes = {r[1] for r in conn.execute("PRAGMA index_list(transactions)")}
assert {"legacy_transactions_date", "idx_transactions_datetime"} <= indexes
assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "12"
def test_snapshot_close_idempotency_fingerprint_and_concurrent_first_close(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("현금", 100, "2026-06-30", "cash", currency="KRW", db_path=path)
first = service.close_asset_snapshot(
"2026-06-30", source_note="source-a", memo="memo-a",
idempotency_key="close-1", db_path=path,
)
same = service.close_asset_snapshot(
"2026-06-30", source_note="source-a", memo="memo-a",
idempotency_key="close-1", db_path=path,
)
changed = service.close_asset_snapshot(
"2026-06-30", source_note="source-a", memo="memo-b",
idempotency_key="close-1", db_path=path,
)
assert first["success"] and same["success"] and same == first
assert changed["success"] is False and changed["error_code"] == "idempotency_conflict"
other_asset = first["included_items"][0]["source_id"]
service.update_manual_asset(other_asset, memo="changed evidence metadata", db_path=path)
changed_evidence = service.close_asset_snapshot(
"2026-06-30", source_note="source-a", memo="memo-a",
idempotency_key="close-1", db_path=path,
)
assert changed_evidence["success"] is False
assert changed_evidence["error_code"] == "idempotency_conflict"
other_path = str(tmp_path / "concurrent.sqlite3")
service.create_manual_asset("현금", 100, "2026-06-30", "cash", currency="KRW", db_path=other_path)
with ThreadPoolExecutor(max_workers=2) as pool:
results = list(pool.map(lambda _: service.close_asset_snapshot("2026-06-30", db_path=other_path), range(2)))
assert sum(bool(result.get("success")) for result in results) == 1
conflict = next(result for result in results if not result.get("success"))
assert "detail" not in conflict
with sqlite3.connect(other_path) as conn:
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs WHERE basis_date='2026-06-30'").fetchone()[0] == 1
def test_snapshot_integrity_conflict_does_not_expose_sqlite_detail(tmp_path, monkeypatch):
path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("현금", 100, "2026-06-30", "cash", currency="KRW", db_path=path)
first = service.close_asset_snapshot("2026-06-30", db_path=path)
real_new_id = assets_service.new_id
def colliding_run_id(prefix):
return first["snapshot_run_id"] if prefix == "snaprun" else real_new_id(prefix)
monkeypatch.setattr(assets_service, "new_id", colliding_run_id)
conflict = service.close_asset_snapshot(
"2026-06-30", correction_reason="retry collision", db_path=path,
)
assert conflict["success"] is False
assert conflict["error_code"] == "snapshot_conflict"
assert conflict["error"] == "another snapshot close already owns this basis date"
assert "detail" not in conflict
def test_import_candidate_helper_reuses_deterministic_duplicate_on_same_connection(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("계좌", db_path=path)["account"]["id"]
with sqlite3.connect(path) as raw:
raw.row_factory = sqlite3.Row
first = _record_import_valuation_candidate(raw, account, 100, "2026-06-30", "sha")
second = _record_import_valuation_candidate(raw, account, 100, "2026-06-30", "sha")
count = raw.execute("SELECT count(*) FROM account_valuations WHERE account_id=?", (account,)).fetchone()[0]
assert first is None and second is None and count == 1
def test_candidate_idempotency_rejects_changed_payload(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-a", "candidate-key", "memo-a",
)
conflict = record_account_valuation_candidate(
conn, account, "2026-07-01", 200, "KRW", "import",
"source-b", "candidate-key", "memo-b",
)
assert first["success"] is True
assert conflict["success"] is False
assert conflict["error_code"] == "idempotency_conflict"
def test_correction_idempotency_compares_effective_preserved_payload(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("계좌", db_path=path)["account"]["id"]
original = service.register_account_valuation(
account, "2026-06-30", 100, memo="original",
verification_status="verified", idempotency_key="original", db_path=path,
)["valuation"]
first = service.correct_account_valuation(
original["id"], 200, "정정", memo="override",
idempotency_key="correction", db_path=path,
)
retry_with_omitted_memo = service.correct_account_valuation(
original["id"], 200, "정정",
idempotency_key="correction", db_path=path,
)
assert first["success"] is True
assert retry_with_omitted_memo["success"] is False
assert retry_with_omitted_memo["error_code"] == "idempotency_conflict"
def test_concurrent_valuation_idempotency_reuses_same_payload(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("계좌", db_path=path)["account"]["id"]
def register(_):
return service.register_account_valuation(
account, "2026-06-30", 100, verification_status="verified",
idempotency_key="same", db_path=path,
)
with ThreadPoolExecutor(max_workers=2) as pool:
results = list(pool.map(register, range(2)))
assert all(result["success"] for result in results)
assert len({result["valuation"]["id"] for result in results}) == 1
assert sorted(result["reused"] for result in results) == [False, True]
def test_concurrent_correction_idempotency_reuses_same_payload(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("계좌", db_path=path)["account"]["id"]
original = service.register_account_valuation(
account, "2026-06-30", 100, verification_status="verified", db_path=path,
)["valuation"]
def correct(_):
return service.correct_account_valuation(
original["id"], 200, "정정", idempotency_key="same-correction", db_path=path,
)
with ThreadPoolExecutor(max_workers=2) as pool:
results = list(pool.map(correct, range(2)))
assert all(result["success"] for result in results)
assert len({result["valuation"]["id"] for result in results}) == 1
assert sorted(result["reused"] for result in results) == [False, True]
def test_valuation_currency_and_loan_kind_are_rejected(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
bank = service.create_ledger_account("은행", currency="KRW", db_path=path)["account"]["id"]
loan = service.create_ledger_account("대출", type="loan", db_path=path)["account"]["id"]
assert service.register_account_valuation(bank, "2026-06-30", 1, currency="USD", db_path=path)["success"] is False
assert service.register_account_valuation(bank, "2026-06-30", 1, valuation_kind="loan", representative_liability_definition="principal_only", db_path=path)["success"] is False
assert service.register_account_valuation(loan, "2026-06-30", 1, valuation_kind="loan", representative_liability_definition="principal_only", db_path=path)["success"] is True
def test_loan_representative_is_always_aggregated_as_liability(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
loan = service.create_ledger_account(
"대출", type="loan", last_balance=100,
last_synced_at="2026-06-30", db_path=path,
)["account"]["id"]
service.register_account_valuation(
loan, "2026-06-30", 100, valuation_kind="loan",
representative_liability_definition="principal_only",
verification_status="verified", db_path=path,
)
report = service.get_asset_snapshot_report("2026-06-30", db_path=path)
assert report["total_assets"] == 0
assert report["total_liabilities"] == 100
assert report["net_worth"] == -100
assert report["included_items"][0]["liquidity_bucket"] == "loan"
def test_loan_balance_without_representative_definition_is_rejected_without_write(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
loan = service.create_ledger_account(
"대출", type="loan", last_balance=100,
last_synced_at="2026-06-30", db_path=path,
)["account"]["id"]
rejected = service.register_account_valuation(
loan, "2026-06-30", 100, valuation_kind="balance",
verification_status="verified", 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
report = service.get_asset_snapshot_report("2026-06-30", db_path=path)
assert report["total_liabilities"] == 0
assert report["net_worth"] == 0
assert report["excluded_items"][0]["reason"] == "missing_verified_exact_valuation"
def test_warning_candidate_public_payload_is_minimal():
class Row(dict):
__getattr__ = dict.__getitem__
row = Row(id="v", basis_date="2026-06-30", currency="KRW", valuation_kind="balance", verification_status="unverified", memo="secret", source_ref="secret", name="secret")
payload = _public_candidate(row)
assert set(payload) == {"id", "basis_date", "currency", "valuation_kind", "verification_status"}
def test_snapshot_report_redacts_unverified_candidate_sensitive_fields(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("계좌", db_path=path)["account"]["id"]
service.register_account_valuation(
account, "2026-06-30", 10, verification_status="unverified",
source_ref="TOPSECRET", memo="PASSWORD=secret", idempotency_key="private",
db_path=path,
)
report = service.get_asset_snapshot_report("2026-06-30", db_path=path)
encoded = repr(report)
assert "TOPSECRET" not in encoded and "PASSWORD=secret" not in encoded
candidates = report["excluded_items"][0]["unverified_candidates"]
assert set(candidates[0]) == {"id", "basis_date", "currency", "valuation_kind", "verification_status"}
def test_preview_warns_about_unverified_candidate_when_verified_value_is_selected(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account(
"투자", type="investment", db_path=path,
)["account"]["id"]
service.register_account_valuation(
account, "2026-06-30", 100, valuation_kind="total_value",
verification_status="verified", idempotency_key="verified", db_path=path,
)
service.register_account_valuation(
account, "2026-06-30", 999, valuation_kind="total_value",
verification_status="unverified", idempotency_key="candidate", db_path=path,
)
preview = service.preview_asset_snapshot_close("2026-06-30", db_path=path)
warning = next(item for item in preview["warnings"] if item["code"] == "unverified_candidate")
assert warning["source_id"] == account
assert warning["candidates"][0]["verification_status"] == "unverified"
closed = service.close_asset_snapshot("2026-06-30", db_path=path)
assert closed["success"] is True
with sqlite3.connect(path) as conn:
evidence = json.loads(conn.execute(
"SELECT excluded_evidence FROM asset_snapshot_runs WHERE id=?",
(closed["snapshot_run_id"],),
).fetchone()[0])
assert evidence[0]["reason"] == "unverified_candidate"
assert evidence[0]["candidate"]["verification_status"] == "unverified"
def test_snapshot_idempotency_binds_redacted_candidate_state(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account(
"투자", type="investment", db_path=path,
)["account"]["id"]
service.register_account_valuation(
account, "2026-06-30", 100, valuation_kind="total_value",
verification_status="verified", idempotency_key="verified", db_path=path,
)
candidate = service.register_account_valuation(
account, "2026-06-30", 999, valuation_kind="total_value",
verification_status="unverified", idempotency_key="candidate", db_path=path,
)["valuation"]
first = service.close_asset_snapshot(
"2026-06-30", idempotency_key="close-candidate", db_path=path,
)
with sqlite3.connect(path) as conn:
conn.execute(
"UPDATE account_valuations SET amount=?, source_ref=?, memo=? WHERE id=?",
(1000, "changed-source", "changed-memo", candidate["id"]),
)
retry = service.close_asset_snapshot(
"2026-06-30", idempotency_key="close-candidate", db_path=path,
)
assert first["success"] is True
assert retry["success"] is False
assert retry["error_code"] == "idempotency_conflict"
def test_reconciliation_requires_balance_coverage_through_requested_basis(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account(
"계좌", last_balance=1_000, last_synced_at="2026-06-30", db_path=path,
)["account"]["id"]
service.create_ledger_transaction(
"2026-06-01", "income", 1_000, account_id=account,
balance_after=1_000, db_path=path,
)
item = service.get_balance_reconciliation_report(
account_id=account, baseline_balance=1_000,
baseline_at="2026-06-30T00:00:00+09:00", db_path=path,
)["items"][0]
assert item["calculation_basis"] == "latest_transaction_balance_after"
assert item["calculation_complete"] is False
assert item["status"] == "INCOMPLETE_CALCULATION"
assert item["difference"] is None
def test_reconciliation_accepts_balance_coverage_at_requested_basis(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account(
"계좌", last_balance=1_000, last_synced_at="2026-06-30", db_path=path,
)["account"]["id"]
service.create_ledger_transaction(
"2026-06-30", "income", 1_000, account_id=account,
balance_after=1_000, db_path=path,
)
item = service.get_balance_reconciliation_report(
account_id=account, baseline_balance=1_000,
baseline_at="2026-06-30T00:00:00+09:00", db_path=path,
)["items"][0]
assert item["calculation_complete"] is True
assert item["status"] == "OK"
assert item["difference"] == 0
def test_reconciliation_requires_start_balance_for_transaction_coverage(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account(
"계좌", last_balance=1_000, last_synced_at="2026-06-30", db_path=path,
)["account"]["id"]
service.create_ledger_transaction(
"2026-06-01", "income", 1_000, account_id=account, db_path=path,
)
service.create_ledger_transaction(
"2026-06-30", "expense", 100, account_id=account,
balance_after=900, db_path=path,
)
item = service.get_balance_reconciliation_report(
account_id=account, baseline_balance=900,
baseline_at="2026-06-30T00:00:00+09:00", db_path=path,
)["items"][0]
assert item["calculation_complete"] is False
assert item["status"] == "INCOMPLETE_CALCULATION"
assert item["difference"] is None
def test_reconciliation_requires_authoritative_opening_evidence_for_multi_transaction_coverage(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account(
"계좌", last_balance=1_000, last_synced_at="2026-06-30", db_path=path,
)["account"]["id"]
service.create_ledger_transaction(
"2026-06-15", "income", 100, account_id=account,
balance_after=900, db_path=path,
)
service.create_ledger_transaction(
"2026-06-30", "income", 100, account_id=account,
balance_after=1_000, db_path=path,
)
item = service.get_balance_reconciliation_report(
account_id=account, baseline_balance=1_000,
baseline_at="2026-06-30T00:00:00+09:00", db_path=path,
)["items"][0]
assert item["calculation_complete"] is False
assert item["status"] == "INCOMPLETE_CALCULATION"
assert item["difference"] is None
def test_reconciliation_accepts_continuous_transactions_with_verified_opening_evidence(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account(
"계좌", last_balance=1_000, last_synced_at="2026-06-30", db_path=path,
)["account"]["id"]
service.register_account_valuation(
account, "2026-06-14", 800, valuation_kind="balance",
verification_status="verified", db_path=path,
)
service.create_ledger_transaction(
"2026-06-15", "income", 100, account_id=account,
balance_after=900, db_path=path,
)
service.create_ledger_transaction(
"2026-06-30", "income", 100, account_id=account,
balance_after=1_000, db_path=path,
)
item = service.get_balance_reconciliation_report(
account_id=account, baseline_balance=1_000,
baseline_at="2026-06-30T00:00:00+09:00", db_path=path,
)["items"][0]
assert item["calculation_complete"] is True
assert item["status"] == "OK"
assert item["difference"] == 0
def test_reconciliation_fails_closed_on_ambiguous_verified_leaf(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account(
"투자", type="investment", last_balance=100,
last_synced_at="2026-06-30", db_path=path,
)["account"]["id"]
for key, amount in (("v1", 100), ("v2", 200)):
service.register_account_valuation(
account, "2026-06-30", amount, valuation_kind="total_value",
verification_status="verified", idempotency_key=key, db_path=path,
)
item = service.get_balance_reconciliation_report(
account_id=account, baseline_at="2026-06-30T00:00:00+09:00", db_path=path,
)["items"][0]
assert item["status"] == "AMBIGUOUS_VALUATION"
assert item["external_confirmed_value"] is None
assert item["valuation_id"] == ""
def test_snapshot_rejects_bogus_selection_policy_and_manual_usd_is_excluded(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
assert service.preview_asset_snapshot_close("2026-06-30", selection_policy="bogus", db_path=path)["success"] is False
service.create_manual_asset("달러", 100, "2026-06-30", "other", currency="USD", db_path=path)
report = service.get_asset_liquidity_report(db_path=path)
assert report["success"] and report["total_assets"] == 0
assert any(item.get("reason") == "non_krw_without_fx" for item in report["excluded_items"])
def test_non_krw_account_valuation_is_excluded_without_fx_conversion(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("달러 계좌", currency="USD", db_path=path)["account"]["id"]
service.register_account_valuation(
account, "2026-06-30", 100, currency="USD",
verification_status="verified", db_path=path,
)
report = service.get_asset_snapshot_report("2026-06-30", db_path=path)
assert report["total_assets"] == 0
assert any(item.get("reason") == "non_krw_without_fx" for item in report["excluded_items"])
def test_legacy_lowercase_krw_account_currency_selects_normalized_valuation(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("계좌", currency="KRW", db_path=path)["account"]["id"]
service.register_account_valuation(
account, "2026-06-30", 100, currency="KRW",
verification_status="verified", db_path=path,
)
with sqlite3.connect(path) as conn:
conn.execute("UPDATE accounts SET currency='krw' WHERE id=?", (account,))
conn.execute("UPDATE account_valuations SET currency='krw' WHERE account_id=?", (account,))
selected = service.select_account_valuation(
account, "2026-06-30", currency="KRW", db_path=path,
)
report = service.get_asset_snapshot_report("2026-06-30", db_path=path)
assert selected["valuation"]["amount"] == 100
assert report["total_assets"] == 100
assert report["excluded_items"] == []
def test_rejected_valuation_is_not_an_unverified_candidate(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("계좌", db_path=path)["account"]["id"]
service.register_account_valuation(
account, "2026-06-30", 100, verification_status="verified", db_path=path,
)
service.register_account_valuation(
account, "2026-06-30", 999, verification_status="rejected", db_path=path,
)
selected = service.select_account_valuation(account, "2026-06-30", db_path=path)
preview = service.preview_asset_snapshot_close("2026-06-30", db_path=path)
assert selected.get("warning") is None
assert selected.get("unverified_candidates", []) == []
assert not any(warning.get("code") == "unverified_candidate" for warning in preview["warnings"])
def test_legacy_snapshot_rows_do_not_override_live_authority_or_claim_closed(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
service.create_asset_snapshot(
"2026-06-30", "account", "legacy", "legacy", 999, "cash", db_path=path,
)
account = service.create_ledger_account("현재 계좌", db_path=path)["account"]["id"]
service.register_account_valuation(
account, "2026-06-30", 100, verification_status="verified", db_path=path,
)
report = service.get_asset_snapshot_report("2026-06-30", db_path=path)
assert report["source"] == "live_authority"
assert report["is_closed_snapshot"] is False
assert report["total_assets"] == 100
assert report["legacy_snapshot_row_count"] == 1
def test_snapshot_public_api_preserves_legacy_db_path_position(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
init_db(path)
preview = service.preview_asset_snapshot_close("2026-06-30", path)
closed = service.close_asset_snapshot("2026-06-30", "", "", "", path)
report = service.get_asset_snapshot_report("2026-06-30", path)
assert preview["success"] is True
assert closed["success"] is True
assert report["success"] is True
assert report["is_closed_snapshot"] is True
def test_legacy_snapshot_requires_explicit_correction_before_new_close(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
service.create_asset_snapshot(
"2026-06-30", "account", "legacy", "legacy", 999, "cash", db_path=path,
)
close = service.close_asset_snapshot("2026-06-30", db_path=path)
report = service.get_asset_snapshot_report("2026-06-30", db_path=path)
assert close["success"] is False
assert close["error_code"] == "legacy_snapshot_correction_required"
assert report["source"] == "legacy_asset_snapshots"
assert report["net_worth"] == 999
assert report["is_closed_snapshot"] is False
def test_loan_balance_fallback_with_representative_definition_is_liability(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("대출", type="loan", db_path=path)["account"]["id"]
recorded = service.register_account_valuation(
account, "2026-06-30", 100, valuation_kind="balance",
representative_liability_definition="principal_only",
verification_status="verified", db_path=path,
)
report = service.get_asset_snapshot_report("2026-06-30", db_path=path)
assert recorded["success"] is True
assert report["total_assets"] == 0
assert report["total_liabilities"] == 100
assert report["net_worth"] == -100
def test_loan_balance_fallback_rejects_unknown_representative_definition(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("대출", type="loan", db_path=path)["account"]["id"]
recorded = service.register_account_valuation(
account, "2026-06-30", 100, valuation_kind="balance",
representative_liability_definition="outstanding_principal",
verification_status="verified", db_path=path,
)
assert recorded["success"] is False
assert recorded["error_code"] == "validation_error"
def test_account_write_rolls_back_when_conn_level_candidate_write_fails(tmp_path, monkeypatch):
path = str(tmp_path / "ledger.sqlite3")
import jarca_ledger_mcp.application.accounts_write_services as writes
monkeypatch.setattr(writes, "_record_balance_candidate", lambda *_args: (_ for _ in ()).throw(RuntimeError("injected")))
try:
service.create_ledger_account("원자성", last_balance=1, last_synced_at="2026-06-30", db_path=path)
except RuntimeError:
pass
assert service.list_ledger_accounts(active_only=False, db_path=path)["count"] == 0
def test_mcp_registry_keeps_category_report_and_correction_signature_is_explicit():
from jarca_ledger_mcp.adapters.mcp import server
source = inspect.getsource(server)
assert "def get_category_spending_report" in source
signature = inspect.signature(server.correct_account_valuation)
assert not any(param.kind is inspect.Parameter.VAR_KEYWORD for param in signature.parameters.values())
+214
View File
@@ -0,0 +1,214 @@
from types import SimpleNamespace
from jarca_ledger_mcp import service
from jarca_ledger_mcp.application import imports_service
def _account(path, *, account_type="bank", currency="KRW"):
return service.create_ledger_account("계좌", type=account_type, currency=currency, db_path=path)["account"]["id"]
def test_valuation_round_trip_preserves_null_zero_and_physical_amount_mapping(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
investment = _account(path, account_type="investment")
loan = _account(path, account_type="loan")
inv = service.register_account_valuation(
investment, "2026-06-30", 1_000, valuation_kind="total_value",
principal=0, reported_principal_kind="contributed", reported_principal_label="납입원금",
interest=None, fee=None, idempotency_key="inv", db_path=path,
)
debt = service.register_account_valuation(
loan, "2026-06-30", 9_000, valuation_kind="loan",
representative_liability_definition="principal_interest_fees", principal=9_000,
interest=300, fee=20, idempotency_key="loan", db_path=path,
)
assert inv["success"] and debt["success"]
inv_value = service.list_account_valuations(investment, db_path=path)["valuations"][0]
debt_value = service.list_account_valuations(loan, db_path=path)["valuations"][0]
assert inv_value["principal"] == 0
assert inv_value["interest"] is None and inv_value["fee"] is None
assert inv_value["reported_principal_kind"] == "contributed"
assert inv_value["reported_principal_label"] == "납입원금"
assert debt_value["amount"] == 9_000
assert debt_value["principal"] == 9_000 and debt_value["interest"] == 300 and debt_value["fee"] == 20
def test_direct_correction_requires_matching_existing_parent_and_reason(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = _account(path)
parent = service.register_account_valuation(account, "2026-06-30", 100, db_path=path)["valuation"]
for kwargs in (
{"supersedes_id": "missing", "correction_reason": "정정"},
{"supersedes_id": parent["id"], "correction_reason": ""},
):
result = service.register_account_valuation(account, "2026-06-30", 200, db_path=path, **kwargs)
assert result["success"] is False
other = _account(path, currency="USD")
result = service.register_account_valuation(
other, "2026-06-30", 200, currency="USD", supersedes_id=parent["id"],
correction_reason="cross account", db_path=path,
)
assert result["success"] is False
assert service.list_account_valuations(account, db_path=path)["count"] == 1
def test_correction_rejects_currency_and_kind_override_but_preserves_valid_overrides(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = _account(path, account_type="investment")
parent = service.register_account_valuation(
account, "2026-06-30", 100, valuation_kind="total_value", memo="old", db_path=path,
)["valuation"]
bad_currency = service.correct_account_valuation(parent["id"], 110, "정정", currency="USD", db_path=path)
bad_kind = service.correct_account_valuation(parent["id"], 110, "정정", valuation_kind="balance", db_path=path)
good = service.correct_account_valuation(parent["id"], 110, "정정", memo="new", db_path=path)
assert bad_currency["success"] is False and bad_kind["success"] is False
assert good["success"] and good["valuation"]["memo"] == "new"
def _fake_tx(date, balance, *, row=1, kind="bank"):
if kind == "kb":
return SimpleNamespace(
transaction_date=date, transaction_datetime=f"{date} 10:00:00", amount=100,
counterparty="상대", raw_summary="이체", branch="", remittance_memo="",
balance_after=balance, type="income", page=1,
external_id=lambda source_hash: f"kb:{source_hash}:{row}",
raw_payload=lambda: "{}",
)
if kind == "tosssec":
return SimpleNamespace(
transaction_date=date, amount=100, action="매수", name="종목", code="A", section="거래",
quantity=1, unit_price=100, fee=0, tax=0, balance_after=balance, type="income",
external_id=lambda source_hash: f"sec:{source_hash}:{row}", raw_payload=lambda: "{}",
)
return SimpleNamespace(
transaction_date=date, transaction_datetime=f"{date} 10:00:00", amount=100,
action="입금", counterparty="상대", balance_after=balance, type="income", raw_summary="입금",
external_id=lambda source_hash: f"bank:{source_hash}:{row}", raw_payload=lambda: "{}",
)
def _parsed(tx, source, metadata):
return {
"transactions": [tx], "validation": {"ok": True}, "source_hash": f"hash-{source}",
"metadata": metadata, "file_path": "/tmp/synthetic.pdf", "page_count": 1,
}
def test_import_historical_kb_does_not_replace_newer_current_balance(monkeypatch, tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("KB", institution="KB국민은행", last_balance=900, last_synced_at="2026-06-30", db_path=path, last_balance_basis_date="2026-06-30")["account"]["id"]
monkeypatch.setattr(imports_service, "parse_kb_bank_pdf", lambda *_: _parsed(_fake_tx("2026-06-01", 700, kind="kb"), "kb", {"final_balance": 700, "account_number_masked": ""}))
result = imports_service.import_kb_bank_pdf("ignored", "pw", account_id=account, db_path=path)
current = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0]
assert result["success"] and current["last_balance"] == 900 and current["last_synced_at"] == "2026-06-30"
assert service.list_account_valuations(account, db_path=path)["count"] == 2
def test_loan_import_preserves_transactions_and_current_cache_without_valuation_candidate(monkeypatch, tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("대출", type="loan", db_path=path)["account"]["id"]
monkeypatch.setattr(
imports_service,
"parse_kb_bank_pdf",
lambda *_: _parsed(
_fake_tx("2026-06-30", 700, kind="kb"),
"loan-kb",
{"final_balance": 700, "account_number_masked": ""},
),
)
result = imports_service.import_kb_bank_pdf("ignored", "pw", account_id=account, db_path=path)
current = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0]
assert result["success"] is True
assert result["inserted_count"] == 1
assert current["last_balance"] == 700
assert current["last_balance_basis_date"] == "2026-06-30"
assert service.list_ledger_transactions(account_id=account, db_path=path)["count"] == 1
assert service.list_account_valuations(account, db_path=path)["count"] == 0
def test_import_historical_toss_bank_does_not_replace_newer_current_balance(monkeypatch, tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("Toss", institution="토스뱅크", last_balance=900, last_synced_at="2026-06-30", db_path=path, last_balance_basis_date="2026-06-30")["account"]["id"]
monkeypatch.setattr(imports_service, "parse_toss_bank_pdf", lambda *_: _parsed(_fake_tx("2026-06-01", 700), "bank", {"account_number_masked": ""}))
result = imports_service.import_toss_bank_pdf("ignored", account_id=account, db_path=path)
current = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0]
assert result["success"] and current["last_balance"] == 900 and current["last_synced_at"] == "2026-06-30"
assert service.list_account_valuations(account, db_path=path)["count"] == 2
def test_import_historical_toss_securities_does_not_replace_newer_current_balance(monkeypatch, tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("Securities", type="investment", institution="토스증권", last_balance=900, last_synced_at="2026-06-30", db_path=path, last_balance_basis_date="2026-06-30")["account"]["id"]
monkeypatch.setattr(imports_service, "parse_toss_securities_pdf", lambda *_: _parsed(_fake_tx("2026-06-01", 700, kind="tosssec"), "sec", {"account_number_masked": ""}))
result = imports_service.import_toss_securities_pdf("ignored", account_id=account, db_path=path)
current = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0]
assert result["success"] and current["last_balance"] == 900 and current["last_synced_at"] == "2026-06-30"
assert service.list_account_valuations(account, db_path=path)["count"] == 2
def test_source_ref_is_account_scoped_evidence_identity_without_explicit_key(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
first = _account(path)
second = _account(path)
one = service.register_account_valuation(first, "2026-06-30", 100, source_ref="statement", db_path=path)
same = service.register_account_valuation(first, "2026-06-30", 100, source_ref="statement", db_path=path)
changed = service.register_account_valuation(first, "2026-06-30", 101, source_ref="statement", db_path=path)
independent = service.register_account_valuation(second, "2026-06-30", 101, source_ref="statement", db_path=path)
empty_a = service.register_account_valuation(first, "2026-07-01", 1, source_ref="", db_path=path)
empty_b = service.register_account_valuation(first, "2026-07-01", 1, source_ref="", db_path=path)
assert one["success"] and same["reused"] is True
assert changed["success"] is False and changed["error_code"] == "idempotency_conflict"
assert independent["success"] and independent["reused"] is False
assert empty_a["success"] and empty_b["success"] and empty_b["reused"] is False
def test_correction_rejects_self_parent_and_cycles_without_write(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = _account(path)
parent = service.register_account_valuation(account, "2026-06-30", 100, db_path=path)["valuation"]
child = service.register_account_valuation(account, "2026-06-30", 101, supersedes_id=parent["id"], correction_reason="r", db_path=path)["valuation"]
from jarca_ledger_mcp.db import db
with db(path) as conn:
conn.execute("UPDATE account_valuations SET supersedes_id=? WHERE id=?", (child["id"], child["id"]))
cyclic = service.register_account_valuation(account, "2026-06-30", 102, supersedes_id=child["id"], correction_reason="r", db_path=path)
assert cyclic["success"] is False and cyclic["error_code"] == "validation_error"
assert service.list_account_valuations(account, db_path=path)["count"] == 2
def test_correction_preserves_source_ref_without_evidence_identity_conflict(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = _account(path)
parent = service.register_account_valuation(
account, "2026-06-30", 100, source_ref="statement-1", db_path=path,
)["valuation"]
corrected = service.correct_account_valuation(
parent["id"], 110, "정정", idempotency_key="correction-1", db_path=path,
)
replay = service.correct_account_valuation(
parent["id"], 110, "정정", idempotency_key="correction-1", db_path=path,
)
unrelated = service.register_account_valuation(
account, "2026-06-30", 120, source_ref="statement-1", idempotency_key="unrelated", db_path=path,
)
listed = service.list_account_valuations(account, db_path=path)
assert corrected["success"]
assert corrected["valuation"]["source_ref"] == "statement-1"
assert replay["success"] and replay["reused"] is True
assert replay["valuation"]["id"] == corrected["valuation"]["id"]
assert unrelated["success"] is False and unrelated["error_code"] == "idempotency_conflict"
assert listed["count"] == 2
assert sum(item["supersedes_id"] is not None for item in listed["valuations"]) == 1
def test_source_ref_conflicts_even_when_idempotency_keys_differ(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = _account(path)
first = service.register_account_valuation(account, "2026-06-30", 100, source_ref="statement", idempotency_key="a", db_path=path)
same = service.register_account_valuation(account, "2026-06-30", 100, source_ref="statement", idempotency_key="b", db_path=path)
changed = service.register_account_valuation(account, "2026-06-30", 101, source_ref="statement", idempotency_key="c", db_path=path)
assert first["success"] and same["reused"] is True
assert changed["success"] is False and changed["error_code"] == "idempotency_conflict"
+166
View File
@@ -0,0 +1,166 @@
from __future__ import annotations
import hashlib
import inspect
import sqlite3
from pathlib import Path
import pytest
from jarca_ledger_mcp.application import account_valuation_service as valuation
from jarca_ledger_mcp.application import (
assets_service,
categorization_services,
cross_transfer_service,
reconciliation_service,
system_services,
)
from jarca_ledger_mcp.infrastructure.sqlite import connection
def _sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def test_read_queries_missing_nested_db_return_stable_error_without_creating_path(tmp_path):
db_path = tmp_path / "missing" / "nested" / "ledger.sqlite3"
calls = [
lambda: system_services.list_ledger_accounts(db_path=str(db_path)),
lambda: valuation.list_account_valuations("account", db_path=str(db_path)),
lambda: assets_service.list_manual_assets(db_path=str(db_path)),
lambda: assets_service.preview_asset_snapshot_close(db_path=str(db_path)),
lambda: reconciliation_service.get_balance_reconciliation_report(db_path=str(db_path)),
]
for read in calls:
result = read()
assert result["success"] is False
assert result["error_code"] == "db_not_initialized"
assert result["error"]
assert not db_path.exists()
assert not db_path.parent.exists()
def test_read_only_connection_rejects_writes_and_does_not_commit(tmp_path):
db_path = tmp_path / "ledger.sqlite3"
system_services.create_ledger_account("checking", db_path=str(db_path)) # first-use writer initializes
before = _sha256(db_path)
with connection.read_db(str(db_path)) as conn:
with pytest.raises(sqlite3.OperationalError):
conn.execute("INSERT INTO accounts (id, name, type) VALUES ('x', 'x', 'bank')")
assert _sha256(db_path) == before
def test_read_suite_does_not_initialize_or_change_initialized_database(tmp_path, monkeypatch):
db_path = tmp_path / "ledger.sqlite3"
system_services.create_ledger_account("checking", db_path=str(db_path))
before = _sha256(db_path)
def fail_init(*args, **kwargs):
raise AssertionError("read path called init_db")
monkeypatch.setattr("jarca_ledger_mcp.infrastructure.sqlite.migrations.init_db", fail_init)
monkeypatch.setattr("jarca_ledger_mcp.infrastructure.sqlite.connection.init_db", fail_init, raising=False)
reads = [
lambda: system_services.list_ledger_accounts(db_path=str(db_path)),
lambda: valuation.list_account_valuations("account", db_path=str(db_path)),
lambda: assets_service.list_manual_assets(db_path=str(db_path)),
lambda: assets_service.preview_asset_snapshot_close(db_path=str(db_path)),
lambda: reconciliation_service.get_balance_reconciliation_report(db_path=str(db_path)),
]
for read in reads:
assert read()["success"] is True
assert _sha256(db_path) == before
def test_first_use_writer_still_initializes_database(tmp_path):
db_path = tmp_path / "new" / "ledger.sqlite3"
result = system_services.create_ledger_account("checking", db_path=str(db_path))
assert result["success"] is True
assert db_path.is_file()
def test_read_schema_error_is_not_misreported_as_uninitialized(tmp_path):
db_path = tmp_path / "empty.sqlite3"
sqlite3.connect(db_path).close()
result = system_services.list_ledger_accounts(db_path=str(db_path))
assert result["success"] is False
assert result["error_code"] == "db_error"
def test_nested_categorization_preview_propagates_missing_db_envelope(tmp_path):
db_path = tmp_path / "missing" / "ledger.sqlite3"
result = categorization_services.preview_categorization(db_path=str(db_path))
assert result["success"] is False
assert result["error_code"] == "db_not_initialized"
assert not db_path.parent.exists()
def test_public_read_signatures_remain_positional_compatible():
assert list(inspect.signature(valuation.list_account_valuations).parameters)[:2] == ["account_id", "include_rejected"]
assert list(inspect.signature(assets_service.preview_asset_snapshot_close).parameters)[:2] == ["basis_date", "db_path"]
assert list(inspect.signature(reconciliation_service.get_balance_reconciliation_report).parameters)[:2] == ["account_id", "baseline_balance"]
def test_preview_categorization_initialized_db_preserves_success_envelope(tmp_path):
db_path = tmp_path / "ledger.sqlite3"
system_services.create_ledger_account("checking", db_path=str(db_path))
result = categorization_services.preview_categorization(db_path=str(db_path))
assert result["success"] is True
assert result["matched_count"] == 0
assert result["unmatched_count"] == 0
def test_apply_categorization_rules_missing_db_initializes_writer_or_returns_stable_error(tmp_path):
db_path = tmp_path / "nested" / "ledger.sqlite3"
result = categorization_services.apply_categorization_rules(db_path=str(db_path))
assert result["success"] is True
assert result["preview"]["matched_count"] == 0
def test_cross_transfer_review_queue_missing_nested_db_returns_stable_error_without_creating_path(tmp_path):
db_path = tmp_path / "missing" / "nested" / "ledger.sqlite3"
result = cross_transfer_service.list_cross_transfer_review_queue(db_path=str(db_path))
assert result["success"] is False
assert result["error_code"] == "db_not_initialized"
assert result["error"]
assert not db_path.exists()
assert not db_path.parent.exists()
def test_cross_transfer_preview_missing_nested_db_returns_stable_error_without_creating_path(tmp_path):
db_path = tmp_path / "missing" / "nested" / "ledger.sqlite3"
result = cross_transfer_service.preview_cross_account_transfers(db_path=str(db_path))
assert result["success"] is False
assert result["error_code"] == "db_not_initialized"
assert result["error"]
assert not db_path.exists()
assert not db_path.parent.exists()
def test_cross_transfer_read_paths_do_not_initialize_or_mutate_initialized_db(tmp_path, monkeypatch):
db_path = tmp_path / "ledger.sqlite3"
system_services.create_ledger_account("checking", db_path=str(db_path))
before = _sha256(db_path)
def fail_init(*args, **kwargs):
raise AssertionError("cross-transfer read path called init_db")
monkeypatch.setattr("jarca_ledger_mcp.infrastructure.sqlite.migrations.init_db", fail_init)
monkeypatch.setattr("jarca_ledger_mcp.infrastructure.sqlite.connection.init_db", fail_init, raising=False)
queue = cross_transfer_service.list_cross_transfer_review_queue(db_path=str(db_path))
preview = cross_transfer_service.preview_cross_account_transfers(db_path=str(db_path))
assert queue["success"] is True
assert preview["success"] is True
assert _sha256(db_path) == before
+306
View File
@@ -0,0 +1,306 @@
import json
import sqlite3
from jarca_ledger_mcp import service
def test_dated_manual_asset_is_verified_and_preview_needs_approval(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("현금", 100, "2026-06-01", "cash", db_path=path)
preview = service.preview_asset_snapshot_close("2026-06-30", db_path=path, selection_policy="as_of")
assert preview["close_status"] == "incomplete_draft"
item = preview["included_items"][0]
assert item["verification_status"] == "verified"
assert item["evidence_kind"] == "manual_confirmed"
assert item["valuation_date"] == "2026-06-01"
assert item["carried_forward"] is True
assert item["age_days"] == 29
def test_approved_carried_close_is_conditional_and_persists_provenance(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("현금", 100, "2026-06-01", "cash", db_path=path)
closed = service.close_asset_snapshot(
"2026-06-30", db_path=path, selection_policy="as_of",
allow_as_of_carry_forward=True, approved_by="reviewer",
approval_reason="approved carry", approval_reference="APR-59",
)
assert closed["close_status"] == "conditional"
item = closed["included_items"][0]
assert item["verification_status"] == "verified"
assert item["evidence_kind"] == "manual_confirmed"
with sqlite3.connect(path) as conn:
conn.row_factory = sqlite3.Row
row = conn.execute("SELECT applied_evidence FROM asset_snapshot_runs WHERE id=?", (closed["snapshot_run_id"],)).fetchone()
evidence = json.loads(row["applied_evidence"])
assert evidence[0]["valuation_date"] == "2026-06-01"
assert evidence[0]["age_days"] == 29
assert evidence[0]["carried_forward"] is True
assert closed["approval"] == {
"approved_by": "reviewer",
"approved_at": closed["closed_at"],
"approval_reason": "approved carry",
"approval_reference": "APR-59",
}
assert closed["correction"]["is_correction"] is False
assert closed["excluded_items"] == []
assert closed["provenance"]["carried_forward"] is True
assert closed["provenance"]["age_days"] == 29
assert closed["provenance"]["verification_status"] == "verified"
assert closed["provenance"]["evidence_kind"] == "manual_confirmed"
assert closed["provenance"]["approval"] == closed["approval"]
assert closed["provenance"]["correction"] == closed["correction"]
def test_reconciliation_uses_currency_and_loan_priority_and_canonical_provenance(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
investment = service.create_ledger_account("투자", type="investment", currency="USD", last_balance=100, db_path=path)["account"]["id"]
loan = service.create_ledger_account("대출", type="loan", last_balance=100, db_path=path)["account"]["id"]
service.register_account_valuation(investment, "2026-06-30", 100, currency="KRW", valuation_kind="total_value", verification_status="verified", db_path=path)
loan_value = service.register_account_valuation(loan, "2026-06-30", 80, valuation_kind="loan_balance", representative_liability_definition="principal_only", verification_status="verified", db_path=path)
report = service.get_balance_reconciliation_report(account_id=loan, baseline_at="2026-06-30T00:00:00+09:00", db_path=path)
item = report["items"][0]
assert item["external_confirmed_value"] == 80
assert item["valuation_id"] == loan_value["valuation"]["id"]
assert item["valuation_kind"] == "loan_balance"
assert item["provenance"]["valuation_date"] == "2026-06-30"
assert item["provenance"]["verification_status"] == "verified"
def test_investment_without_verified_valuation_and_without_opening_balance_is_incomplete(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("투자", type="investment", db_path=path)["account"]["id"]
report = service.get_balance_reconciliation_report(account_id=account, baseline_at="2026-06-30T00:00:00+09:00", db_path=path)
item = report["items"][0]
assert item["status"] == "VALUATION_UNAVAILABLE"
assert item["calculation_complete"] is False
assert item["difference"] is None
assert item["external_confirmed_value"] is None
def test_timeseries_and_monthly_expose_canonical_asset_provenance(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("현금", 100, "2026-06-30", "cash", db_path=path)
closed = service.close_asset_snapshot("2026-06-30", db_path=path)
report = service.get_asset_snapshot_report("2026-06-30", db_path=path)
timeseries = service.get_net_worth_timeseries(start_date="2026-06-01", end_date="2026-06-30", db_path=path)
monthly = service.get_monthly_settlement_summary("2026-06", db_path=path)
for payload in (report, timeseries["points"][0], monthly["reports"]["assets"], monthly["source_coverage"]["assets"]):
assert "provenance" in payload
assert "valuation_date" in payload["provenance"]
assert "warnings" in payload["provenance"]
assert "exclusions" in payload["provenance"]
assert report["provenance"]["snapshot_run_id"] == closed["snapshot_run_id"]
def test_reconciliation_does_not_mark_last_balance_without_opening_evidence_complete(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("manual", last_balance=1000, last_synced_at="2026-06-30", db_path=path)["account"]["id"]
item = service.get_balance_reconciliation_report(account_id=account, db_path=path)["items"][0]
assert item["calculation_complete"] is False
assert item["difference"] is None
assert item["status"] == "INCOMPLETE_CALCULATION"
def test_monthly_settlement_propagates_snapshot_provenance_id_and_actual_valuation_date(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("cash", 100, "2026-06-01", "cash", db_path=path)
closed = service.close_asset_snapshot("2026-06-30", db_path=path, selection_policy="as_of", allow_as_of_carry_forward=True, approved_by="r", approval_reason="r", approval_reference="r")
monthly = service.get_monthly_settlement_summary("2026-06", db_path=path)
assets = monthly["source_coverage"]["assets"]
assert assets["monthly_source_snapshot_id"] == closed["snapshot_run_id"]
assert assets["provenance"]["valuation_date"] == "2026-06-01"
assert "warnings" in assets and "excluded_items" in assets and "carried_items" in assets
def test_approved_carry_provenance_is_identical_across_closed_downstream_reports(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("cash", 100, "2026-06-01", "cash", db_path=path)
closed = service.close_asset_snapshot(
"2026-06-30", db_path=path, selection_policy="as_of",
allow_as_of_carry_forward=True, approved_by="reviewer",
approval_reason="approved carry", approval_reference="APR-59",
)
report = service.get_asset_snapshot_report("2026-06-30", db_path=path)
point = service.get_net_worth_timeseries(start_date="2026-06-30", end_date="2026-06-30", db_path=path)["points"][0]
monthly = service.get_monthly_settlement_summary("2026-06", db_path=path)
payloads = [report, point, monthly["reports"]["assets"], monthly["source_coverage"]["assets"]]
expected = {key: closed[key] for key in ("warnings", "excluded_items", "carried_items", "approval", "correction")}
expected.update({key: closed["provenance"][key] for key in ("valuation_date", "age_days", "carried_forward", "verification_status", "evidence_kind")})
for payload in payloads:
assert payload.get("approval") == expected["approval"]
assert payload.get("correction") == expected["correction"]
assert payload.get("warnings") == expected["warnings"]
assert payload.get("excluded_items") == expected["excluded_items"]
assert payload.get("carried_items") == expected["carried_items"]
provenance = payload["provenance"]
for key in ("valuation_date", "age_days", "carried_forward", "verification_status", "evidence_kind"):
assert provenance[key] == expected[key]
assert report["approval"] == expected["approval"]
assert report["correction"] == expected["correction"]
assert point["approval"] == expected["approval"]
assert point["correction"] == expected["correction"]
assert monthly["source_coverage"]["assets"]["monthly_source_snapshot_id"] == closed["snapshot_run_id"]
def test_preview_and_live_report_redact_excluded_source_note_and_memo(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
sentinel = "SHOULD_NOT_LEAK_C10"
account = service.create_ledger_account(
"unverified account", memo=sentinel, last_balance=100, last_synced_at="2026-06-30", db_path=path,
)["account"]["id"]
service.register_account_valuation(account, "2026-06-30", 100, verification_status="unverified", db_path=path)
service.create_manual_asset("missing manual", None, "2026-06-30", "cash", source_note=sentinel, memo=sentinel, db_path=path)
preview = service.preview_asset_snapshot_close("2026-06-30", db_path=path)
live = service.get_asset_snapshot_report("2026-06-30", db_path=path)
for payload in (preview, live):
assert sentinel not in json.dumps(payload, ensure_ascii=False)
assert all(sentinel not in json.dumps(item, ensure_ascii=False) for item in payload.get("warnings", []))
assert all(sentinel not in json.dumps(item, ensure_ascii=False) for item in payload.get("excluded_items", []))
assert sentinel not in json.dumps(payload.get("provenance", {}), ensure_ascii=False)
def test_live_report_and_monthly_propagate_canonical_carry_forward_metadata(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("cash", 100, "2026-06-01", "cash", db_path=path)
live = service.get_asset_snapshot_report("2026-06-30", db_path=path, selection_policy="as_of")
monthly = service.get_monthly_settlement_summary("2026-06", db_path=path)
exact = service.get_asset_snapshot_report("2026-06-30", db_path=path)
expected_keys = {"warnings", "carried_items", "approval", "correction"}
assert expected_keys.issubset(live)
assert live["provenance"]["carried_forward"] is True
assert any(warning["code"] == "stale_valuation" for warning in live["warnings"])
assets = monthly["source_coverage"]["assets"]
assert expected_keys.issubset(assets)
assert assets["warnings"] == exact["warnings"]
assert assets["carried_items"] == exact["carried_items"]
assert assets["approval"] == exact["approval"]
assert assets["correction"] == exact["correction"]
def test_public_included_items_redact_source_note_and_memo_recursively(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
sentinel = "SHOULD_NOT_LEAK_C10_INCLUDED"
service.create_manual_asset("cash", 100, "2026-06-01", "cash", source_note=sentinel, memo=sentinel, db_path=path)
preview = service.preview_asset_snapshot_close("2026-06-30", db_path=path, selection_policy="as_of")
closed = service.close_asset_snapshot(
"2026-06-30", db_path=path, selection_policy="as_of",
allow_as_of_carry_forward=True, approved_by="reviewer",
approval_reason="approved carry", approval_reference="APR-59",
)
report = service.get_asset_snapshot_report("2026-06-30", db_path=path)
payloads = [preview, closed, report]
for payload in payloads:
assert sentinel not in json.dumps(payload.get("included_items", []), ensure_ascii=False)
assert all("source_note" not in item and "memo" not in item for item in payload.get("included_items", []))
assert sentinel not in json.dumps(report, ensure_ascii=False)
def test_snapshot_null_amount_remains_null_and_is_excluded_from_totals(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
service.create_asset_snapshot("2026-06-30", "manual_asset", "unvalued", "unvalued", None, "cash", db_path=path)
service.create_asset_snapshot("2026-06-30", "manual_asset", "zero", "zero", 0, "cash", db_path=path)
report = service.get_asset_snapshot_report("2026-06-30", db_path=path)
amounts = {item["name"]: item["amount"] for item in report["included_items"]}
assert amounts == {"unvalued": None, "zero": 0}
assert report["total_assets"] == 0
assert report["buckets"][0]["item_count"] == 1
assert report["close_status"] == "incomplete_draft"
assert any(item["name"] == "unvalued" and item["reason"] == "missing_amount" for item in report["excluded_items"])
def test_idempotent_snapshot_close_replay_returns_original_immutable_response(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("cash", 100, "2026-06-30", "cash", db_path=path)
kwargs = dict(basis_date="2026-06-30", db_path=path, idempotency_key="close-replay")
original = service.close_asset_snapshot(**kwargs)
replay = service.close_asset_snapshot(**kwargs)
assert replay == original
assert "reused" not in replay
for key in ("provenance", "approval", "correction", "warnings", "excluded_items", "carried_items", "buckets", "bucket_totals", "total_assets", "total_liabilities", "net_worth"):
assert replay[key] == original[key]
def test_asset_close_and_report_expose_provenance_aliases(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("cash", 100, "2026-06-01", "cash", db_path=path)
closed = service.close_asset_snapshot(
"2026-06-30", db_path=path, selection_policy="as_of",
allow_as_of_carry_forward=True, approved_by="reviewer",
approval_reason="approved carry", approval_reference="APR-59",
)
report = service.get_asset_snapshot_report("2026-06-30", db_path=path)
for payload in (closed, report):
assert payload["valuation_date"] == payload["provenance"]["valuation_date"] == "2026-06-01"
assert payload["age_days"] == payload["provenance"]["age_days"] == 29
assert payload["verification_status"] == payload["provenance"]["verification_status"] == "verified"
assert payload["evidence_kind"] == payload["provenance"]["evidence_kind"] == "manual_confirmed"
assert payload["monthly_source_snapshot_id"] == payload["provenance"]["monthly_source_snapshot_id"] == closed["snapshot_run_id"]
def test_report_invalid_basis_date_returns_stable_error_envelope(tmp_path):
result = service.get_asset_snapshot_report("not-a-date", db_path=str(tmp_path / "ledger.sqlite3"))
assert result == {"success": False, "error_code": "validation_error", "error": "invalid date: not-a-date"}
def test_manual_asset_evidence_has_canonical_currency_before_close(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("cash", 100, "2026-06-30", "cash", currency="krw", db_path=path)
preview = service.preview_asset_snapshot_close("2026-06-30", db_path=path)
assert preview["included_items"][0]["currency"] == "KRW"
closed = service.close_asset_snapshot("2026-06-30", db_path=path)
assert closed["included_items"][0]["currency"] == "KRW"
with sqlite3.connect(path) as conn:
evidence = json.loads(conn.execute("SELECT applied_evidence FROM asset_snapshot_runs").fetchone()[0])
assert evidence[0]["currency"] == "KRW"
def test_close_rejects_authority_drift_since_preview(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("cash", 100, "2026-06-30", "cash", db_path=path)
preview = service.preview_asset_snapshot_close("2026-06-30", db_path=path)
assert preview["preview_fingerprint"]
service.update_manual_asset("asset-does-not-exist", valuation_amount=101, db_path=path)
# Mutate the real authority row without changing the public API result shape.
with sqlite3.connect(path) as conn:
conn.execute("UPDATE manual_assets SET valuation_amount=101")
result = service.close_asset_snapshot("2026-06-30", db_path=path, preview_fingerprint=preview["preview_fingerprint"])
assert result["success"] is False
assert result["error_code"] == "authority_drift"
def test_close_metadata_is_allowlisted_recursively(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("cash", 100, "2026-06-30", "cash", db_path=path)
service.close_asset_snapshot("2026-06-30", db_path=path)
with sqlite3.connect(path) as conn:
conn.execute("UPDATE asset_snapshot_runs SET applied_evidence=?", ('[{"valuation_date":"2026-06-30","future_secret":{"source_note":"LEAK"}}]',))
report = service.get_asset_snapshot_report("2026-06-30", db_path=path)
assert "future_secret" not in json.dumps(report["close_metadata"])
assert "LEAK" not in json.dumps(report["close_metadata"])
def test_public_evidence_redacts_nested_notes_without_mutating_input_or_hash():
from jarca_ledger_mcp.application.assets_service import _evidence_state_hash, _public_evidence
item = {"amount": 0, "source_note": "top", "nested": {"memo": "deep", "keep": [1, {"source_note": "list"}]}}
before_hash = _evidence_state_hash(item)
redacted = _public_evidence(item)
assert redacted == {"amount": 0, "nested": {"keep": [1, {}]}}
assert item["nested"]["memo"] == "deep"
assert _evidence_state_hash(item) == before_hash
+76
View File
@@ -0,0 +1,76 @@
from types import SimpleNamespace
from jarca_ledger_mcp import service
from jarca_ledger_mcp.application import imports_service
def _transaction(date, balance):
return SimpleNamespace(
transaction_date=date,
transaction_datetime=f"{date} 10:00:00",
amount=100,
counterparty="상대",
raw_summary="입금",
branch="",
remittance_memo="",
balance_after=balance,
type="income",
page=1,
action="입금",
external_id=lambda source_hash: f"review63:{source_hash}",
raw_payload=lambda: "{}",
)
def test_create_rejects_basis_only_without_mutation(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
rejected = service.create_ledger_account(
"기준일만 있는 계좌",
last_balance_basis_date="2026-07-31",
db_path=path,
)
assert rejected == {
"success": False,
"error_code": "validation_error",
"error": "last_balance_basis_date requires last_balance",
}
baseline = service.create_ledger_account("정상 계좌", db_path=path)
assert baseline["success"] is True
listed = service.list_ledger_accounts(active_only=False, db_path=path)
assert listed["count"] == 1
assert listed["accounts"][0]["name"] == "정상 계좌"
def test_basis_only_create_cannot_block_later_dated_import(tmp_path, monkeypatch):
path = str(tmp_path / "ledger.sqlite3")
rejected = service.create_ledger_account(
"기준일만 있는 계좌",
last_balance_basis_date="2026-07-31",
db_path=path,
)
assert rejected["success"] is False
account = service.create_ledger_account("정상 계좌", db_path=path)["account"]["id"]
monkeypatch.setattr(
imports_service,
"parse_kb_bank_pdf",
lambda *_: {
"transactions": [_transaction("2026-06-30", 50)],
"validation": {"ok": True},
"source_hash": "review63",
"metadata": {"final_balance": 50, "account_number_masked": ""},
"file_path": "/tmp/synthetic.pdf",
"page_count": 1,
},
)
imported = imports_service.import_kb_bank_pdf(
"ignored", "pw", account_id=account, db_path=path,
)
current = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0]
assert imported["success"] is True
assert current["last_balance"] == 50
assert current["last_balance_basis_date"] == "2026-06-30"
+206
View File
@@ -0,0 +1,206 @@
from __future__ import annotations
import sqlite3
from jarca_ledger_mcp import service
def _approval_kwargs():
return {
"allow_missing_exclusions": True,
"approved_by": "reviewer",
"approval_reason": "reviewed",
"approval_reference": "review-63",
}
def test_f001_preview_fingerprint_uses_same_unredacted_authority_and_rejects_drift(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("현금", 100, "2026-06-30", "cash", db_path=db_path)
preview = service.preview_asset_snapshot_close("2026-06-30", db_path=db_path)
closed = service.close_asset_snapshot(
"2026-06-30", preview_fingerprint=preview["preview_fingerprint"], db_path=db_path
)
assert closed["success"] is True
service.update_manual_asset(
next(iter(service.list_manual_assets(db_path=db_path)["manual_assets"]))["id"],
valuation_amount=101,
db_path=db_path,
)
with sqlite3.connect(db_path) as conn:
rows_before = conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0]
drifted = service.close_asset_snapshot(
"2026-06-30", preview_fingerprint=preview["preview_fingerprint"], db_path=db_path
)
with sqlite3.connect(db_path) as conn:
rows_after = conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0]
assert drifted["success"] is False
assert drifted["error_code"] == "authority_drift"
assert rows_after == rows_before
def test_f002_close_replay_is_byte_for_byte_equal_for_mixed_account_manual_as_of(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account(
"투자계좌", type="investment", currency="KRW", db_path=db_path
)
service.register_account_valuation(
account["account"]["id"], "2026-06-29", 200, valuation_kind="total_value",
verification_status="verified", idempotency_key="v-29", db_path=db_path,
)
service.create_manual_asset("보증금", 300, "2026-06-30", "real_estate", db_path=db_path)
first = service.close_asset_snapshot(
"2026-06-30", selection_policy="as_of", idempotency_key="mixed-replay",
allow_as_of_carry_forward=True, **_approval_kwargs(), db_path=db_path,
)
assert first["success"] is True
replay = service.close_asset_snapshot(
"2026-06-30", selection_policy="as_of", idempotency_key="mixed-replay",
allow_as_of_carry_forward=True, **_approval_kwargs(), db_path=db_path,
)
assert replay == first
def test_f002_correction_replay_preserves_full_initial_response(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("현금", 100, "2026-06-30", "cash", db_path=db_path)
first = service.close_asset_snapshot(
"2026-06-30", idempotency_key="correction-initial", db_path=db_path
)
assert first["success"] is True
service.update_manual_asset(
next(iter(service.list_manual_assets(db_path=db_path)["manual_assets"]))["id"],
valuation_amount=120, db_path=db_path,
)
corrected = service.close_asset_snapshot(
"2026-06-30", correction_reason="updated valuation",
idempotency_key="correction-run", db_path=db_path,
)
assert corrected["success"] is True
assert any(warning["code"] == "existing_snapshot_rows" for warning in corrected["warnings"])
correction_replay = service.close_asset_snapshot(
"2026-06-30", correction_reason="updated valuation",
idempotency_key="correction-run", db_path=db_path,
)
assert correction_replay == corrected
initial_replay = service.close_asset_snapshot(
"2026-06-30", idempotency_key="correction-initial", db_path=db_path
)
assert initial_replay == first
def test_f006_initial_replay_survives_multiple_later_corrections(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset(
"현금 A", 100, "2026-06-30", "cash", db_path=db_path
)
service.create_manual_asset(
"현금 B", 200, "2026-06-30", "cash", db_path=db_path
)
assets = {
item["name"]: item["id"]
for item in service.list_manual_assets(db_path=db_path)["manual_assets"]
}
first = service.close_asset_snapshot(
"2026-06-30", idempotency_key="multi-initial", db_path=db_path
)
assert first["success"] is True
service.update_manual_asset(assets["현금 A"], valuation_amount=110, db_path=db_path)
first_correction = service.close_asset_snapshot(
"2026-06-30", correction_reason="first correction",
idempotency_key="multi-correction-1", db_path=db_path,
)
assert first_correction["success"] is True
service.update_manual_asset(assets["현금 A"], valuation_amount=120, db_path=db_path)
second_correction = service.close_asset_snapshot(
"2026-06-30", correction_reason="second correction",
idempotency_key="multi-correction-2", db_path=db_path,
)
assert second_correction["success"] is True
replay = service.close_asset_snapshot(
"2026-06-30", idempotency_key="multi-initial", db_path=db_path
)
assert replay == first
service.update_manual_asset(assets["현금 B"], valuation_amount=220, db_path=db_path)
drifted_replay = service.close_asset_snapshot(
"2026-06-30", idempotency_key="multi-initial", db_path=db_path
)
assert drifted_replay["success"] is False
assert drifted_replay["error_code"] == "idempotency_conflict"
def test_f003_zero_row_conditional_runs_are_timeseries_authority_with_aliases(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
service.create_ledger_account("미평가", db_path=db_path)
first = service.close_asset_snapshot(
"2026-06-30", idempotency_key="zero-initial", **_approval_kwargs(), db_path=db_path
)
assert first["success"] is True
assert first["inserted_count"] == 0
assert first["total_assets"] == first["total_liabilities"] == first["net_worth"] == 0
corrected = service.close_asset_snapshot(
"2026-06-30", correction_reason="reconfirm zero", idempotency_key="zero-correction",
**_approval_kwargs(), db_path=db_path,
)
assert corrected["success"] is True
assert corrected["inserted_count"] == 0
series = service.get_net_worth_timeseries(
start_date="2026-06-01", end_date="2026-06-30", db_path=db_path
)
assert series["success"] is True
assert len(series["points"]) == 1
point = series["points"][0]
assert point["basis_date"] == "2026-06-30"
assert point["net_worth"] == point["total_assets"] == point["total_liabilities"] == 0
assert point["close_status"] == "conditional"
assert point["approval"]["approval_reference"] == "review-63"
assert point["excluded_items"]
assert point["snapshot_run_id"] == corrected["snapshot_run_id"]
assert point["provenance"]["monthly_source_snapshot_id"] == corrected["snapshot_run_id"]
def test_f004_closed_report_restores_frozen_valuation_identity_without_legacy_invention(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("계좌", db_path=db_path)
valuation = service.register_account_valuation(
account["account"]["id"], "2026-06-29", 500, verification_status="verified",
idempotency_key="frozen-v1", db_path=db_path,
)
first = service.close_asset_snapshot(
"2026-06-30", selection_policy="as_of", idempotency_key="frozen-close",
allow_as_of_carry_forward=True, **_approval_kwargs(), db_path=db_path,
)
assert first["success"] is True
report_before = service.get_asset_snapshot_report("2026-06-30", db_path=db_path)
item_before = report_before["included_items"][0]
assert item_before["valuation_id"] == valuation["valuation"]["id"]
assert item_before["currency"] == "KRW"
service.register_account_valuation(
account["account"]["id"], "2026-06-29", 900, verification_status="verified",
idempotency_key="frozen-v2", db_path=db_path,
)
report_after = service.get_asset_snapshot_report("2026-06-30", db_path=db_path)
assert report_after["included_items"][0]["valuation_id"] == item_before["valuation_id"]
assert report_after["included_items"][0]["currency"] == item_before["currency"]
assert report_after["selection_policy"] == "as_of"
legacy_db = str(tmp_path / "legacy.sqlite3")
service.create_asset_snapshot(
"2026-06-30", "account", "legacy-account", "legacy", 10, "cash", db_path=legacy_db
)
legacy = service.get_asset_snapshot_report("2026-06-30", db_path=legacy_db)
legacy_item = legacy["included_items"][0]
assert "valuation_id" not in legacy_item
assert legacy_item["currency"] == "KRW"
+1 -1
View File
@@ -6,7 +6,7 @@ from jarca_ledger_mcp import service
def test_ledger_category_create_list_delete_reactivate(tmp_path): def test_ledger_category_create_list_delete_reactivate(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3") db_path = str(tmp_path / "ledger.sqlite3")
health = service.health_check(db_path) health = service.health_check(db_path)
assert health["schema_version"] == "11" assert health["schema_version"] == "12"
created = service.create_ledger_category("반려동물", "사료", sort_order=250, memo="test", db_path=db_path) created = service.create_ledger_category("반려동물", "사료", sort_order=250, memo="test", db_path=db_path)
assert created["success"] is True assert created["success"] is True
+146
View File
@@ -0,0 +1,146 @@
from __future__ import annotations
import hashlib
import sqlite3
from pathlib import Path
from jarca_ledger_mcp import service
from jarca_ledger_mcp.db import init_db
BASIS = "2026-06-30"
def _legacy_closed_db(tmp_path):
path = str(tmp_path / "legacy.sqlite3")
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.
conn.execute(
"""INSERT INTO asset_snapshots
(id, basis_date, source_type, source_id, name, amount, liquidity_bucket,
valuation_source, snapshot_run_id, correction_of_run_id, correction_reason,
is_correction, closed_at, created_at)
VALUES (?, ?, 'manual_asset', ?, ?, 100, 'cash', 'manual_assets', 'legacy-run-correction',
'legacy-initial', 'legacy correction', 1, '2026-06-30T12:00:00+09:00',
'2026-06-30T12:00:00+09:00')""",
("legacy-row", BASIS, asset["id"], asset["name"]),
)
conn.execute("DROP TABLE asset_snapshot_runs")
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
return path, asset["id"]
def test_schema11_closed_rows_remain_frozen_after_live_value_and_date_change(tmp_path):
path, asset_id = _legacy_closed_db(tmp_path)
service.update_manual_asset(asset_id, valuation_amount=900, valuation_date="2026-06-30", db_path=path)
before = hashlib.sha256(Path(path).read_bytes()).hexdigest()
report = service.get_asset_snapshot_report(BASIS, db_path=path)
after = hashlib.sha256(Path(path).read_bytes()).hexdigest()
assert report["success"] is True
assert report["source"] == "legacy_asset_snapshots"
assert report["is_closed_snapshot"] is True
assert report["close_status"] == "legacy_read_only"
assert report["net_worth"] == 100
assert report["included_items"][0]["amount"] == 100
assert report["correction"] == {
"is_correction": True,
"correction_of_run_id": "legacy-initial",
"correction_reason": "legacy correction",
}
assert report["snapshot_run_id"] == "legacy-run-correction"
assert report["approval"] == {}
assert "valuation_id" not in report["included_items"][0]
assert after == before
def test_schema11_closed_rows_are_frozen_in_timeseries(tmp_path):
path, asset_id = _legacy_closed_db(tmp_path)
service.update_manual_asset(asset_id, valuation_amount=900, valuation_date="2026-07-01", db_path=path)
point = service.get_net_worth_timeseries(BASIS, BASIS, db_path=path)["points"][0]
assert point["net_worth"] == 100
assert point["item_count"] == 1
assert point["close_status"] == "legacy_read_only"
assert point["snapshot_run_id"] == "legacy-run-correction"
assert point["approval"] == {}
assert point["correction"] == {
"is_correction": True,
"correction_of_run_id": "legacy-initial",
"correction_reason": "legacy correction",
}
def test_schema11_closed_rows_are_frozen_in_monthly_settlement(tmp_path):
path, asset_id = _legacy_closed_db(tmp_path)
service.update_manual_asset(asset_id, valuation_amount=900, valuation_date="2026-07-01", db_path=path)
monthly = service.get_monthly_settlement_summary("2026-06", db_path=path)
assets = monthly["reports"]["assets"]
coverage = monthly["source_coverage"]["assets"]
assert assets["net_worth"] == 100
assert assets["is_closed_snapshot"] is True
assert coverage["net_worth"] == 100
assert coverage["is_closed_snapshot"] is True
assert coverage["monthly_source_snapshot_id"] == "legacy-run-correction"
assert assets["approval"] == {}
assert coverage["approval"] == {}
assert coverage["correction"] == assets["correction"]
def test_schema11_latest_legacy_correction_run_is_selected_as_a_complete_group(tmp_path):
path = str(tmp_path / "legacy-corrections.sqlite3")
init_db(path)
with sqlite3.connect(path) as conn:
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),
("new-a", "legacy-run-2", "legacy-run-1", "correct frozen close", 1, "2026-07-01T10:00:00+09:00", 70),
("new-b", "legacy-run-2", "legacy-run-1", "correct frozen close", 1, "2026-07-01T10:00:00+09:00", 80),
]
for row_id, run_id, prior, reason, correction, closed_at, amount in rows:
conn.execute(
"""INSERT INTO asset_snapshots
(id, basis_date, source_type, source_id, name, amount, currency,
liquidity_bucket, valuation_source, snapshot_run_id,
correction_of_run_id, correction_reason, is_correction,
closed_at, created_at)
VALUES (?, ?, 'manual_asset', ?, ?, ?, 'KRW', 'cash',
'manual_assets', ?, ?, ?, ?, ?, ?)""",
(row_id, BASIS, row_id, row_id, amount, run_id, prior, reason,
correction, closed_at, closed_at),
)
conn.execute("DROP TABLE asset_snapshot_runs")
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
before = hashlib.sha256(Path(path).read_bytes()).hexdigest()
report = service.get_asset_snapshot_report(BASIS, db_path=path)
after = hashlib.sha256(Path(path).read_bytes()).hexdigest()
assert report["success"] is True
assert report["source"] == "legacy_asset_snapshots"
assert report["snapshot_run_id"] == "legacy-run-2"
assert report["snapshot_count"] == 2
assert [item["id"] for item in report["included_items"]] == ["new-a", "new-b"]
assert report["net_worth"] == 150
assert report["correction"] == {
"is_correction": True,
"correction_of_run_id": "legacy-run-1",
"correction_reason": "correct frozen close",
}
assert report["approval"] == {}
assert all("valuation_id" not in item for item in report["included_items"])
assert after == before
+334
View File
@@ -0,0 +1,334 @@
from __future__ import annotations
import json
import re
from pathlib import Path
ROOT = Path(__file__).parents[1]
DOCS = ROOT / "docs"
REQ = DOCS / "requirements" / "req-003_account-valuation-history.md"
def _text(path: str) -> str:
return (ROOT / path).read_text(encoding="utf-8")
def test_requirement_docs_are_canonical_and_traceable() -> None:
text = REQ.read_text(encoding="utf-8")
_, raw, body = text.split("---", 2)
metadata = json.loads(raw)
assert metadata == {
"schema": "gitea-agent.requirement/v1",
"id": "REQ-003",
"title": "계좌 기준일 평가 이력과 자산 마감 연동",
"revision": "1.0",
"status": "approved",
"depends_on": [],
"applies_to": ["ledger", "dashboard", "mcp"],
"language": "ko",
"issue": "#12",
"implementation_pr": "#13",
}
assert re.search(r"^# REQ-003 — ", body, re.M)
assert "${" not in text
requirements = re.findall(r"^### REQ-003\.(\d+) —", body, re.M)
assert requirements == [f"{n:02d}" for n in range(1, 11)]
acs = re.findall(r"^### REQ-003\.\d+-AC(\d+) —", body, re.M)
assert len(acs) == 10
assert body.count("**영향 범위**") == 10
assert body.count("**Cases (정상/경계/실패)**") == 10
assert body.count("**State Invariants**") == 10
assert body.count("**판정 기준**") == 10
assert body.count("**검증**") == 10
classification = re.findall(
r"^\*\*검증 분류\*\*: level=(unit|integration|static); backend=(sqlite|application|dashboard)$",
body,
re.M,
)
assert len(classification) == 10
assert {level for level, _ in classification} >= {"unit", "integration", "static"}
assert {backend for _, backend in classification} >= {"sqlite", "application", "dashboard"}
for marker in (
"v12", "idempotent", "append-only", "future", "unverified", "unknown",
"idempotency_conflict", "correction_conflict", "NULL", "VALUATION_MANAGED",
"conditional_approval_required", "MCP", "Dashboard", "auth-before-DB",
"read-only", "monthly", "synthetic", "실제 credential", "호환성",
"reported principal amount", "outstanding principal", "interest", "fee",
"same account/date/currency/purpose", "correction_reason", "no-write",
"source_ref", "evidence idempotency", "cannot roll back newer current cache",
"incomplete_draft", "explicit dated manual asset", "future unknown",
"reconciliation completeness", "unavailable investment", "loan purpose",
"no fake writes", "provenance", "writer", "bootstrap", "init_db",
"read_db", "mode=ro", "db_not_initialized", "no mkdir", "no migrate", "no commit",
):
assert marker in text, marker
def test_canonical_docs_have_required_sections_and_no_placeholders() -> None:
paths = [
"docs/data-model.md",
"docs/interface-contract.md",
"docs/project-structure.md",
"docs/runtime-flow.md",
]
for rel in paths:
text = _text(rel)
assert "${" not in text, rel
data = _text("docs/data-model.md")
for marker in (
"## 목적과 범위", "## 정본 엔터티", "## 관계와 불변조건", "## 저장과 마이그레이션", "schema 12", "account-valuations", "asset-snapshot-runs",
"reported principal amount", "outstanding principal", "NULL", "0", "source_ref", "evidence idempotency",
"correction parent", "same account/date/currency/purpose", "provenance", "reconciliation completeness",
"unavailable investment", "loan purpose", "no fake writes",
):
assert marker in data
interface = _text("docs/interface-contract.md")
for marker in ("## 목적과 범위", "## 계약", "## 호환성", "## 예시", "## 검증", "REQ-003", "register_account_valuation", "preview_asset_snapshot_close", "conditional_approval_required"):
assert marker in interface
structure = _text("docs/project-structure.md")
for marker in ("## 목적과 경계", "## 디렉터리 구조", "## 의존 방향", "## 변경 규칙", "## 검증", "account_valuation_service.py", "assets_service.py", "Missing semantic items: 0", "Ambiguous semantic items: 0"):
assert marker in structure
runtime = _text("docs/runtime-flow.md")
for marker in ("## 목적과 범위", "## 흐름", "## 실패와 복구", "### 동시성과 순서", "## 관찰 가능성", "## 검증", "valuation-register", "snapshot-preview-close", "close-04", "prop-03"):
assert marker in runtime
def test_documented_paths_and_symbols_exist() -> None:
paths = [
"jarca_ledger_mcp/application/account_valuation_service.py",
"jarca_ledger_mcp/application/assets_service.py",
"jarca_ledger_mcp/application/accounts_write_services.py",
"jarca_ledger_mcp/application/imports_service.py",
"jarca_ledger_mcp/application/reconciliation_service.py",
"jarca_ledger_mcp/infrastructure/sqlite/migrations.py",
"jarca_ledger_mcp/adapters/mcp/server.py",
"jarca_ledger_mcp/adapters/streamlit/accounts_gateway.py",
"jarca_ledger_mcp/adapters/streamlit/assets_gateway.py",
"dashboard/page_views/accounts.py",
"dashboard/page_views/assets.py",
"tests/test_account_valuations.py",
"tests/test_asset_snapshots.py",
"tests/test_contract_regression.py",
]
for rel in paths:
assert (ROOT / rel).exists(), rel
service = _text("jarca_ledger_mcp/application/account_valuation_service.py")
for symbol in ("def register_account_valuation(", "def list_account_valuations(", "def select_account_valuation(", "def correct_account_valuation("):
assert symbol in service
assets = _text("jarca_ledger_mcp/application/assets_service.py")
for symbol in ("def preview_asset_snapshot_close(", "def close_asset_snapshot(", "def get_asset_snapshot_report("):
assert symbol in assets
migrations = _text("jarca_ledger_mcp/infrastructure/sqlite/migrations.py")
for marker in ("CREATE TABLE IF NOT EXISTS account_valuations", "CREATE TABLE IF NOT EXISTS asset_snapshot_runs", "SCHEMA_VERSION"):
assert marker in migrations
def test_existing_auth_menu_evidence_inventory_is_preserved() -> None:
interface = _text("docs/interface-contract.md")
runtime = _text("docs/runtime-flow.md")
structure = _text("docs/project-structure.md")
for marker in (
"POST /api/auth/login", "GET /api/auth/cleanup", "POST /api/auth/logout",
"require_dashboard_authentication()", "__Host-jarca_dashboard_auth",
"월정산", "요약", "자산/마감", "거래내역", "확인필요 큐", "미분류",
"분류 규칙", "카테고리", "계좌", "Import", "st.query_params[\"page\"]",
"streamlit_browser_evidence.py", "auth-before-DB", "cookie/cleanup/logout",
):
assert marker in interface + runtime + structure, marker
def test_req003_has_exact_canonical_numbered_sections() -> None:
body = REQ.read_text(encoding="utf-8").split("---", 2)[2]
headings = re.findall(r"^## (\d+)\. ", body, re.M)
assert headings == [str(n) for n in range(1, 12)]
assert not re.search(r"^## (?:8|9)\.(?: D| C)", body, re.M)
assert body.count("### REQ-003.") == 20
assert "### REQ-003.01-AC01" in body
assert "### REQ-003.10-AC01" in body
def test_d01_d06_semantics_are_explicit_and_canonical() -> None:
req = REQ.read_text(encoding="utf-8")
data = _text("docs/data-model.md")
interface = _text("docs/interface-contract.md")
runtime = _text("docs/runtime-flow.md")
all_docs = req + data + interface + runtime
for marker in (
"principal", "reported_principal_kind", "reported_principal_label",
"no generic label", "parent scope", "cyclic", "self parent",
"one child", "reason", "no-write", "account-scoped evidence identity",
"explicit key", "key/source mismatch", "last_balance", "external current comparison",
"opening evidence", "INCOMPLETE_CALCULATION", "investment unavailable",
"loan purpose", "loan definition", "ambiguity", "actual valuation_date",
"age", "carry", "verification", "run", "approval", "correction",
"warnings", "exclusions", "close response", "closed", "live", "timeseries",
"monthly", "monthly_source_snapshot_id", "read-model alias", "not a persistence field",
"init_db()", "read_db()", "mode=ro", "db_not_initialized", "no mkdir", "no migrate", "no commit",
):
assert marker in all_docs, marker
assert "generic `label`" not in all_docs
assert "`monthly_source_snapshot_id`" in all_docs
assert "missing database returns `db_not_initialized`" in interface
assert "initialized database read failures return `db_error`" in interface
assert "top-level `approval`, `correction`, `warnings`, `excluded_items`, and `carried_items`" in interface
assert "nested `provenance`" in interface
def test_documented_functions_fields_and_regression_tests_are_real() -> None:
import ast
expected = {
"jarca_ledger_mcp/application/account_valuation_service.py": {
"register_account_valuation", "list_account_valuations",
"select_account_valuation", "correct_account_valuation",
},
"jarca_ledger_mcp/application/assets_service.py": {
"preview_asset_snapshot_close", "close_asset_snapshot", "get_asset_snapshot_report",
},
}
structure = _text("docs/project-structure.md")
interface = _text("docs/interface-contract.md")
runtime = _text("docs/runtime-flow.md")
for rel, names in expected.items():
tree = ast.parse((ROOT / rel).read_text(encoding="utf-8"))
actual = {n.name: n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)}
assert set(names) <= set(actual), rel
for name in names:
documented = interface
node = actual[name]
parameter_names = [a.arg for a in (*node.args.posonlyargs, *node.args.args, *node.args.kwonlyargs)]
if node.args.vararg:
parameter_names.append(node.args.vararg.arg)
if node.args.kwarg:
parameter_names.append(node.args.kwarg.arg)
signature_line = next((line for line in documented.splitlines() if line.startswith(name + "(") and ": " in line), "")
assert signature_line, name
assert all(param in signature_line for param in parameter_names), name
assert "jarca_ledger_mcp/application/asset_account_services.py" in structure
assert (ROOT / "jarca_ledger_mcp/application/asset_account_services.py").exists()
tests = "\n".join(p.read_text(encoding="utf-8") for p in (ROOT / "tests").glob("test_issue_12_review59_*.py"))
for name in (
"test_valuation_round_trip_preserves_null_zero_and_physical_amount_mapping",
"test_source_ref_is_account_scoped_evidence_identity_without_explicit_key",
"test_direct_correction_requires_matching_existing_parent_and_reason",
"test_investment_without_verified_valuation_and_without_opening_balance_is_incomplete",
"test_timeseries_and_monthly_expose_canonical_asset_provenance",
"test_read_queries_missing_nested_db_return_stable_error_without_creating_path",
):
assert f"def {name}(" in tests, name
for marker in (
"success", "error_code", "error", "close_status", "warnings", "exclusions",
"snapshot_run_id", "valuation_date", "monthly_source_snapshot_id",
):
assert marker in interface + runtime
def test_d01_preservation_matrix_is_item_by_item_and_evidenced() -> None:
structure = _text("docs/project-structure.md")
required_items = {
"schema 11 legacy rows",
"auth gate and cookie lifecycle",
"REQ-002 menu labels/order",
"MCP envelope and tools",
"reconciliation and monthly read models",
}
matrix = structure.split("## D01 preservation matrix", 1)[1].split("## ", 1)[0]
assert "| item | source evidence | test evidence |" in matrix
for item in required_items:
rows = [line for line in matrix.splitlines() if item in line]
assert len(rows) == 1, item
assert rows[0].count("|") >= 4, item
assert "`" in rows[0], item
def test_d04_close_contract_enumerates_all_fields_and_aliases() -> None:
interface = _text("docs/interface-contract.md")
section = interface.split("## D04 snapshot close output contract", 1)[1].split("## ", 1)[0]
top_level = {
"snapshot_run_id", "close_status", "approval", "correction", "warnings",
"excluded_items", "carried_items", "provenance", "valuation_date", "age_days",
"verification_status", "evidence_kind", "monthly_source_snapshot_id",
}
nested = {"approval", "correction", "warnings", "excluded_items", "carried_items", "valuation_date", "age_days", "verification_status", "evidence_kind"}
for field in top_level:
assert f"`{field}`" in section, field
assert "top-level fields" in section
assert "nested `provenance` fields" in section
for field in nested:
assert f"`{field}`" in section, field
assert "alias" in section and "not a persistence field" in section
def test_d05_structure_maps_canonical_ownership_and_boundaries() -> None:
structure = _text("docs/project-structure.md")
section = structure.split("## D05 canonical ownership", 1)[1].split("## ", 1)[0]
required = {
"monthly settlement", "reconciliation", "policy", "read-only", "redaction",
"monthly_settlement_service.py", "reconciliation_service.py", "assets_service.py",
}
for marker in required:
assert marker in section, marker
assert "owner" in section and "boundary" in section
assert "preview" in section and "no write" in section
def test_d06_runtime_flow_states_cache_reconciliation_and_preview_guards() -> None:
runtime = _text("docs/runtime-flow.md")
section = runtime.split("## D06 runtime decision guards", 1)[1].split("## ", 1)[0]
for marker in (
"current cache", "no rollback", "reconciliation complete", "reconciliation unavailable",
"preview", "drift", "revalidation", "close", "no partial write",
):
assert marker in section, marker
assert "complete" in section and "unavailable" in section
def test_d04_contract_matches_final_close_and_replay_implementation() -> None:
interface = _text("docs/interface-contract.md")
req = _text("docs/requirements/req-003_account-valuation-history.md")
section = interface.split("## D04 snapshot close output contract", 1)[1].split("## ", 1)[0]
for marker in (
"replay payload equality",
"no replay-only reused",
"recursive close_metadata allowlist",
"redaction",
"source_type", "source_id", "authority_source", "valuation_id",
"state_hash", "applied_evidence", "excluded_evidence", "carried_evidence",
):
assert marker in section + req, marker
assert "replay response is reconstructed from the persisted run" in section
assert "unknown nested keys are dropped" in section
def test_d04_preview_fingerprint_signature_and_drift_are_exact() -> None:
interface = _text("docs/interface-contract.md")
runtime = _text("docs/runtime-flow.md")
section = interface.split("## D04 snapshot close output contract", 1)[1].split("## ", 1)[0]
assert "preview_fingerprint: str = \"\"" in interface
assert "preview input includes basis_date, selection_policy, included/excluded evidence state hashes, warnings, and candidate_state_hashes" in section
assert "preview output includes preview_fingerprint" in section
assert "authority_drift" in section + runtime
assert "no partial write" in section + runtime
def test_d05_dashboard_read_contract_and_d06_reconciliation_evidence_are_exact() -> None:
interface = _text("docs/interface-contract.md")
structure = _text("docs/project-structure.md")
runtime = _text("docs/runtime-flow.md")
req = _text("docs/requirements/req-003_account-valuation-history.md")
all_docs = interface + structure + runtime + req
for marker in (
"missing DB stable unavailable",
"no-create",
"initialized DB read failure is db_error",
"initialized read has no write",
"opening evidence",
"coverage",
"INCOMPLETE_CALCULATION",
"reconciliation unavailable",
"no inferred balance",
"no fake transaction",
):
assert marker in all_docs, marker
assert "Dashboard gateway" in interface + structure
assert "read_db()" in interface and "mode=ro" in interface
+2 -2
View File
@@ -16,7 +16,7 @@ def test_review_decision_and_reconciliation_schema_are_additive_idempotent(tmp_p
assert status["integrity_check"] == "ok" assert status["integrity_check"] == "ok"
assert status["table_counts"]["review_decisions"] == 0 assert status["table_counts"]["review_decisions"] == 0
assert status["table_counts"]["reconciliation_sessions"] == 0 assert status["table_counts"]["reconciliation_sessions"] == 0
assert service.health_check(db_path)["schema_version"] == "11" assert service.health_check(db_path)["schema_version"] == "12"
def test_review_decision_round_trip_payload_sanitizes_sensitive_fields(tmp_path: Path) -> None: def test_review_decision_round_trip_payload_sanitizes_sensitive_fields(tmp_path: Path) -> None:
@@ -80,7 +80,7 @@ def test_reconciliation_session_round_trip_and_report_integration(tmp_path: Path
assert report["success"] is True assert report["success"] is True
item = report["items"][0] item = report["items"][0]
assert item["reconciliation_session"]["id"] == session["session_id"] assert item["reconciliation_session"]["id"] == session["session_id"]
assert item["status"] == "REVIEW_NEEDED" assert item["status"] == "INCOMPLETE_CALCULATION"
closed = service.close_reconciliation_session(session_id=session["session_id"], db_path=db_path) closed = service.close_reconciliation_session(session_id=session["session_id"], db_path=db_path)
assert closed["success"] is True assert closed["success"] is True
+2
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from jarca_ledger_mcp import service from jarca_ledger_mcp import service
from jarca_ledger_mcp.infrastructure.sqlite import init_db
from jarca_ledger_mcp.seoulpay_xlsx import parse_seoulpay_xlsx from jarca_ledger_mcp.seoulpay_xlsx import parse_seoulpay_xlsx
@@ -42,6 +43,7 @@ def test_parse_seoulpay_xlsx_plain_file(tmp_path):
def test_import_seoulpay_xlsx_creates_account_and_expenses(tmp_path): def test_import_seoulpay_xlsx_creates_account_and_expenses(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3") db_path = str(tmp_path / "ledger.sqlite3")
init_db(db_path)
file_path = tmp_path / "서울페이.xlsx" file_path = tmp_path / "서울페이.xlsx"
_write_sample_seoulpay_xlsx(file_path) _write_sample_seoulpay_xlsx(file_path)
+10 -10
View File
@@ -10,7 +10,7 @@ def test_health_check_initializes_db(tmp_path):
db_path = tmp_path / "ledger.sqlite3" db_path = tmp_path / "ledger.sqlite3"
result = service.health_check(str(db_path)) result = service.health_check(str(db_path))
assert result["success"] is True assert result["success"] is True
assert result["schema_version"] == "11" assert result["schema_version"] == "12"
assert db_path.exists() assert db_path.exists()
@@ -216,8 +216,8 @@ def test_statement_status_account_update_and_balance_reconciliation(tmp_path):
report = service.get_balance_reconciliation_report(account_id=account_id, db_path=db_path) report = service.get_balance_reconciliation_report(account_id=account_id, db_path=db_path)
assert report["success"] is True assert report["success"] is True
assert report["items"][0]["calculated_balance"] == 901_500 assert report["items"][0]["calculated_balance"] == 901_500
assert report["items"][0]["difference"] == 0 assert report["items"][0]["difference"] is None
assert report["items"][0]["status"] == "OK" assert report["items"][0]["status"] == "INCOMPLETE_CALCULATION"
updated = service.update_ledger_account(account_id, statement_status="partial_import", last_balance=905_000, db_path=db_path) updated = service.update_ledger_account(account_id, statement_status="partial_import", last_balance=905_000, db_path=db_path)
assert updated["success"] is True assert updated["success"] is True
@@ -226,9 +226,9 @@ def test_statement_status_account_update_and_balance_reconciliation(tmp_path):
review = service.get_balance_reconciliation_report(account_id=account_id, baseline_balance=905_000, baseline_at="2026-06", db_path=db_path) review = service.get_balance_reconciliation_report(account_id=account_id, baseline_balance=905_000, baseline_at="2026-06", db_path=db_path)
assert review["success"] is True assert review["success"] is True
assert review["baseline_at"] == "2026-06-30T23:59:00+09:00" assert review["baseline_at"] == "2026-06-30T23:59:00+09:00"
assert review["items"][0]["difference"] == 3_500 assert review["items"][0]["difference"] is None
assert review["items"][0]["status"] == "REVIEW_NEEDED" assert review["items"][0]["status"] == "INCOMPLETE_CALCULATION"
assert "확인필요" in review["items"][0]["recommended_decisions"] assert review["items"][0]["recommended_decisions"] == []
def test_balance_reconciliation_uses_account_last_synced_at_when_baseline_at_omitted(tmp_path): def test_balance_reconciliation_uses_account_last_synced_at_when_baseline_at_omitted(tmp_path):
@@ -251,8 +251,8 @@ def test_balance_reconciliation_uses_account_last_synced_at_when_baseline_at_omi
assert report["baseline_at"] is None assert report["baseline_at"] is None
assert report["items"][0]["baseline_at"] == "2026-01-31T23:59:00+09:00" assert report["items"][0]["baseline_at"] == "2026-01-31T23:59:00+09:00"
assert report["items"][0]["calculated_balance"] == 1_000 assert report["items"][0]["calculated_balance"] == 1_000
assert report["items"][0]["difference"] == 0 assert report["items"][0]["difference"] is None
assert report["items"][0]["status"] == "OK" assert report["items"][0]["status"] == "INCOMPLETE_CALCULATION"
explicit = service.get_balance_reconciliation_report( explicit = service.get_balance_reconciliation_report(
account_id=account_id, account_id=account_id,
@@ -264,8 +264,8 @@ def test_balance_reconciliation_uses_account_last_synced_at_when_baseline_at_omi
assert explicit["baseline_at"] == "2026-02-28T23:59:00+09:00" assert explicit["baseline_at"] == "2026-02-28T23:59:00+09:00"
assert explicit["items"][0]["baseline_at"] == "2026-02-28T23:59:00+09:00" assert explicit["items"][0]["baseline_at"] == "2026-02-28T23:59:00+09:00"
assert explicit["items"][0]["calculated_balance"] == 800 assert explicit["items"][0]["calculated_balance"] == 800
assert explicit["items"][0]["difference"] == 0 assert explicit["items"][0]["difference"] is None
assert explicit["items"][0]["status"] == "OK" assert explicit["items"][0]["status"] == "INCOMPLETE_CALCULATION"
def test_invalid_statement_status_rejected(tmp_path): def test_invalid_statement_status_rejected(tmp_path):
+72
View File
@@ -0,0 +1,72 @@
from pathlib import Path
ROOT = Path(__file__).parents[1]
def _text(relative_path: str) -> str:
return (ROOT / relative_path).read_text(encoding="utf-8")
def test_data_model_documents_cache_authority_and_legacy_basis_migration() -> None:
data_model = _text("docs/data-model.md")
required_contract = (
"`last_balance` is the current cache value",
"`last_balance_basis_date`",
"`last_synced_at` is the execution/sync timestamp",
"imports and account writes advance the account cache",
"`account_valuations` is the historical authority",
"older evidence never rolls back the current cache",
"additive migration",
"legacy rows with unknown basis remain unknown; no inference",
)
for marker in required_contract:
assert marker in data_model, marker
def test_interface_contract_documents_public_valuation_enums_validation_and_mapping() -> None:
interface = _text("docs/interface-contract.md")
assert "actual enums in source" not in interface
required_contract = (
"source_type: `manual`, `import`, `account_write`, `reconciliation`, `snapshot`",
"verification_status: `verified`, `unverified`, `rejected`",
"valuation_kind: `balance`, `cash_balance`, `total_value`, `loan`, `loan_balance`",
"representative_liability_definition: `principal_only`, `principal_interest_fees`",
"loan accounts require representative_liability_definition for `loan`, `loan_balance`, and `balance` aliases",
"legacy valuation-kind aliases remain accepted and map to the canonical purpose",
"validation failure returns `validation_error` and performs no write",
"loan valuations map to liabilities and non-loan valuations map to assets",
)
for marker in required_contract:
assert marker in interface, marker
def test_data_model_documents_valuation_selection_contract_and_index_mapping() -> None:
data_model = _text("docs/data-model.md")
required_contract = (
"select_account_valuation",
"account_id is mandatory",
"currency and valuation_kind/purpose are optional filters",
"basis_date exact match or <= upper bound",
"verified leaf valuations only",
"future-dated and unverified valuations are excluded",
"idx_account_valuations_account_date supports account/date selection",
"idx_account_valuations_status_date supports status/date selection",
"neither index covers account + currency + purpose",
"idempotency and single_correction indexes enforce write integrity, not selection",
)
for marker in required_contract:
assert marker in data_model, marker
def test_loan_cache_writers_document_no_inferred_valuation_history() -> None:
data_model = _text("docs/data-model.md")
interface = _text("docs/interface-contract.md")
requirement = _text("docs/requirements/req-003_account-valuation-history.md")
assert "loan cache writes without an explicit liability definition do not append" in data_model
assert "loan cache writes without an explicit liability definition do not append" in interface
assert "대출 cache write는 대표 부채 정의가 없으면 이력 후보를 추가하지 않는다" in requirement