fix: address account valuation review findings

This commit is contained in:
2026-10-06 14:43:29 +00:00
parent 399d0923be
commit 6a8a27a74e
34 changed files with 1940 additions and 131 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)
+36 -1
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()))
@@ -78,6 +109,9 @@ def page_assets(db_path: str) -> None:
c1, c2, c3, c4 = st.columns([1, 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")
selection_policy = c2.selectbox("선택 정책", ["exact", "as_of"], format_func=lambda value: "정확히 해당일" if value == "exact" else "기준일 현재(as-of)") selection_policy = c2.selectbox("선택 정책", ["exact", "as_of"], format_func=lambda value: "정확히 해당일" if value == "exact" else "기준일 현재(as-of)")
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") close_note = c3.text_input("Close 출처 메모", value="month-end close")
correction_reason = c4.text_input("정정 사유(재마감 시 필수)", value="") correction_reason = c4.text_input("정정 사유(재마감 시 필수)", value="")
p1, p2 = st.columns(2) p1, p2 = st.columns(2)
@@ -110,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:
+33 -1
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 [])}건"
@@ -203,6 +234,7 @@ 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)} · 선택 정책: {asset_snapshot.get('selection_policy', 'exact')} · close_status: {asset_snapshot.get('close_status', '미확인')}") 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 {} 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"): 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.caption(f"조건부 승인: {close_metadata.get('approved_by', '-')} · {close_metadata.get('approval_reason', '-')} · {close_metadata.get('approval_reference', '-')}")
+6 -4
View File
@@ -31,7 +31,7 @@
### account-valuations — 기준일 계좌 평가 ### account-valuations — 기준일 계좌 평가
- 책임: 날짜가 지정된 평가 evidence와 선택/correction provenance - 책임: 날짜가 지정된 평가 evidence와 선택/correction provenance. investment `principal`은 reported principal amount이고 loan `principal`은 outstanding principal이다.
- 식별자: `id` - 식별자: `id`
- 생명 주기: insert-only; correction은 `supersedes_id` child - 생명 주기: insert-only; correction은 `supersedes_id` child
@@ -134,9 +134,11 @@
### 불변조건 ### 불변조건
- `idx_account_valuations_single_correction` ensures one direct child; idempotency index ensures one non-empty key per account. - `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.
- only verified leaves are authority; unknown/future/unverified are excluded or warning evidence. - `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.
- preview does not insert; close inserts run and rows atomically; latest run is report authority. - 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. - `asset_buckets - loan` is the net-worth formula; representative loan only, no fake transactions.
## 저장과 마이그레이션 ## 저장과 마이그레이션
+37
View File
@@ -386,3 +386,40 @@ preview_asset_snapshot_close(basis_date="2026-06-30", selection_policy="exact")
- 실패 계약: idempotency/correction/conditional approval tests and source validation - 실패 계약: idempotency/correction/conditional approval tests and source validation
- 경계 계약: exact/as_of, NULL/0, future/unverified candidate, correction/re-close - 경계 계약: exact/as_of, NULL/0, future/unverified candidate, correction/re-close
- 구현·스키마·예시 정합성: `tests/test_requirement_docs.py`, actual service/MCP/gateway symbols - 구현·스키마·예시 정합성: `tests/test_requirement_docs.py`, actual service/MCP/gateway symbols
### 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.
+44
View File
@@ -166,3 +166,47 @@ jarca_ledger_mcp/
## REQ-003 preservation inventory ## 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-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.
@@ -42,8 +42,10 @@
- **verified leaf**: `supersedes_id`로 더 최신 child가 없는 `verification_status=verified` 행. - **verified leaf**: `supersedes_id`로 더 최신 child가 없는 `verification_status=verified` 행.
- **exact**: 요청 `basis_date`와 일치하는 verified leaf만 선택한다. - **exact**: 요청 `basis_date`와 일치하는 verified leaf만 선택한다.
- **as_of**: 미래를 보지 않고 요청일 이전 최신 verified leaf를 선택하며 carry-forward를 표시한다. - **as_of**: 미래를 보지 않고 요청일 이전 최신 verified leaf를 선택하며 carry-forward와 age를 표시한다.
- **대표 부채**: `valuation_kind`가 `loan`/`loan_balance`이고 `representative_liability_definition`이 `principal_only` 또는 `principal_interest_fees`인 하나의 대표 금액만 합산한다. - **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. - **정본 구현**: `jarca_ledger_mcp/application/account_valuation_service.py`, `assets_service.py`, `reconciliation_service.py`, `migrations.py` 및 named tests.
## 5. 요구사항 ## 5. 요구사항
@@ -74,7 +76,7 @@ exact/as_of 선택은 account/currency/valuation_kind scope를 적용하고 futu
### REQ-003.03 — idempotency와 append-only correction ### REQ-003.03 — idempotency와 append-only correction
같은 idempotency payload는 기존 row/run을 재사용하고 다른 payload는 `idempotency_conflict`다. correction은 원본을 갱신하지 않고 단일 child를 append하며 다른 child는 `correction_conflict`다. 같은 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()` - 인터페이스: `register_account_valuation()`, `correct_account_valuation()`, `close_asset_snapshot()`
@@ -98,7 +100,7 @@ amount와 principal/interest/fee/cost_basis/net_contributed/profit은 exact inte
### REQ-003.05 — current와 historical authority ### REQ-003.05 — current와 historical authority
current latest display와 historical authority를 분리한다. 늦게 도착한 historical row는 current를 과거로 되돌리지 않으며 account/import writes는 real dated evidence가 있을 때만 dated `unverified` candidate를 만든다. 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를 만든다.
**영향 범위** **영향 범위**
- 인터페이스: account update/import candidate, list/select/report - 인터페이스: account update/import candidate, list/select/report
@@ -110,7 +112,7 @@ current latest display와 historical authority를 분리한다. 늦게 도착한
### REQ-003.06 — preview·close·approval ### REQ-003.06 — preview·close·approval
preview는 DB를 쓰지 않고 `complete_exact`, `incomplete_draft`, `conditional` 상태와 warnings/exclusions를 반환한다. close는 need-based explicit `approved_by`, `approval_reason`, `approval_reference`와 flags를 요구하며 immutable run evidence를 기록한다. unverified/future/unknown/ambiguous는 승인 없이 close할 수 없다. 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 - 인터페이스: `preview_asset_snapshot_close()`, `close_asset_snapshot()` 및 error codes
@@ -134,7 +136,7 @@ preview는 DB를 쓰지 않고 `complete_exact`, `incomplete_draft`, `conditiona
### REQ-003.08 — reconciliation과 monthly propagation ### REQ-003.08 — reconciliation과 monthly propagation
investment가 verified valuation으로 관리되면 reconciliation status는 `VALUATION_MANAGED`이고 fake transaction을 만들지 않는다. regular ledger calculation은 `calculation_basis`/`calculation_basis_at` provenance를 유지한다. monthly settlement는 read-only로 동일 snapshot/report를 전파한다. 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()` - 인터페이스: `get_balance_reconciliation_report()`, `get_monthly_settlement_summary()`
@@ -144,6 +146,8 @@ investment가 verified valuation으로 관리되면 reconciliation status는 `VA
- 호환성: existing reconciliation statuses and reports additive - 호환성: existing reconciliation statuses and reports additive
- 배포 전제 조건: service read model available - 배포 전제 조건: 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과 보안 경계 ### 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와 Dashboard는 shared service/gateway를 사용한다. 기존 success/error envelope를 유지한다. Dashboard는 auth-before-DB이며 MCP auth를 claim하지 않는다. render는 read-only이고 implicit write를 일으키지 않는다.
@@ -191,6 +195,7 @@ MCP와 Dashboard는 shared service/gateway를 사용한다. 기존 success/error
**판정 기준**: `schema_version=12`, required tables/columns/indexes exist and old rows remain. **판정 기준**: `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`. **검증**: `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 ### REQ-003.02-AC01 — selection policy
@@ -201,6 +206,7 @@ MCP와 Dashboard는 shared service/gateway를 사용한다. 기존 success/error
**판정 기준**: exact returns same-date verified leaf; as_of returns latest prior verified and age/warning. **판정 기준**: 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()`. **검증**: `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 ### REQ-003.03-AC01 — idempotent correction
@@ -211,6 +217,7 @@ MCP와 Dashboard는 shared service/gateway를 사용한다. 기존 success/error
**판정 기준**: exact error codes and count 2 after one correction. **판정 기준**: 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`. **검증**: `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 ### REQ-003.04-AC01 — precision and representative loan
@@ -221,6 +228,7 @@ MCP와 Dashboard는 shared service/gateway를 사용한다. 기존 success/error
**판정 기준**: optional fields round-trip exactly and loan definition validation is enforced. **판정 기준**: optional fields round-trip exactly and loan definition validation is enforced.
**검증**: `test_correction_preserves_optional_fields_unless_overridden`, `_validate_common()` and schema static checks. **검증**: `test_correction_preserves_optional_fields_unless_overridden`, `_validate_common()` and schema static checks.
**검증 분류**: level=static; backend=sqlite
### REQ-003.05-AC01 — authority separation ### REQ-003.05-AC01 — authority separation
@@ -231,6 +239,7 @@ MCP와 Dashboard는 shared service/gateway를 사용한다. 기존 success/error
**판정 기준**: dated account write creates only unverified candidate; name-only does not. **판정 기준**: 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`. **검증**: `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 ### REQ-003.06-AC01 — preview and approval
@@ -241,6 +250,7 @@ MCP와 Dashboard는 shared service/gateway를 사용한다. 기존 success/error
**판정 기준**: preview is read-only and close returns `conditional_approval_required` without metadata. **판정 기준**: 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()`. **검증**: `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 ### REQ-003.07-AC01 — immutable close correction
@@ -251,6 +261,7 @@ MCP와 Dashboard는 shared service/gateway를 사용한다. 기존 success/error
**판정 기준**: `correction_of_run_id`, count, and net worth match. **판정 기준**: `correction_of_run_id`, count, and net worth match.
**검증**: `test_manual_asset_update_deactivate_preview_and_close_correction_policy`, `assets_service.py` close path. **검증**: `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 ### REQ-003.08-AC01 — reconciliation/monthly
@@ -261,6 +272,7 @@ MCP와 Dashboard는 shared service/gateway를 사용한다. 기존 success/error
**판정 기준**: status `VALUATION_MANAGED` for verified investment and monthly summary reads report. **판정 기준**: 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`. **검증**: `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 ### REQ-003.09-AC01 — shared adapters and auth
@@ -271,6 +283,7 @@ MCP와 Dashboard는 shared service/gateway를 사용한다. 기존 success/error
**판정 기준**: actual tool/gateway names resolve and existing auth/menu tests pass. **판정 기준**: 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. **검증**: `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 ### REQ-003.10-AC01 — privacy and compatibility
@@ -281,6 +294,7 @@ MCP와 Dashboard는 shared service/gateway를 사용한다. 기존 success/error
**판정 기준**: semantic inventory missing=0 and ambiguous=0; placeholder=0. **판정 기준**: semantic inventory missing=0 and ambiguous=0; placeholder=0.
**검증**: `tests/test_requirement_docs.py`, auth/menu evidence tests, repository redaction/static search. **검증**: `tests/test_requirement_docs.py`, auth/menu evidence tests, repository redaction/static search.
**검증 분류**: level=static; backend=dashboard
## 8. 오류 및 충돌 계약 ## 8. 오류 및 충돌 계약
@@ -300,3 +314,4 @@ REQ-001의 auth gate·cookie/cleanup/logout, REQ-002의 메뉴 10개와 order(`
- 구현 PR: `#13` - 구현 PR: `#13`
- 관련 설계 문서: `docs/data-model.md`, `docs/interface-contract.md`, `docs/project-structure.md`, `docs/runtime-flow.md` - 관련 설계 문서: `docs/data-model.md`, `docs/interface-contract.md`, `docs/project-structure.md`, `docs/runtime-flow.md`
- 관련 요구사항: `REQ-001`, `REQ-002` - 관련 요구사항: `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 순서를 보존한다.
+23
View File
@@ -216,3 +216,26 @@ ASGI startup부터 login, token restoration, invalid-cookie cleanup, explicit lo
- 경계 흐름: exact/as_of, stale/carry-forward, correction/re-close, empty fallback - 경계 흐름: exact/as_of, stale/carry-forward, correction/re-close, empty fallback
- 동시성 흐름: SQLite unique/idempotency and transaction rollback review - 동시성 흐름: SQLite unique/idempotency and transaction rollback review
- 운영 진입점과 기대 결과: Dashboard authenticated `자산/마감`/`계좌`, MCP tools; preview leaves row count unchanged, explicit close returns run id - 운영 진입점과 기대 결과: 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.
@@ -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",
@@ -6,7 +6,7 @@ import re
from typing import Any from typing import Any
import sqlite3 import sqlite3
from .shared import db, fail, new_id, normalize_date, now_iso, ok, row_to_dict 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_STATUSES = {"verified", "unverified", "rejected"}
_VALID_LOAN_DEFINITIONS = {"principal_only", "principal_interest_fees"} _VALID_LOAN_DEFINITIONS = {"principal_only", "principal_interest_fees"}
@@ -173,8 +173,11 @@ def register_account_valuation(
return fail("validation_error", "as_of_at must include timezone offset") return fail("validation_error", "as_of_at must include timezone offset")
except (TypeError, ValueError) as exc: except (TypeError, ValueError) as exc:
return fail("validation_error", f"invalid as_of_at: {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() ts = now_iso()
values = (amount_int, currency_norm, (valuation_kind or "balance").strip().lower(), source_norm, source_ref or "", 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) 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: with db(db_path) as conn:
conn.execute("BEGIN IMMEDIATE") conn.execute("BEGIN IMMEDIATE")
account = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone() account = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone()
@@ -186,8 +189,39 @@ def register_account_valuation(
return fail("validation_error", "loan valuation kind requires a loan account") return fail("validation_error", "loan valuation kind requires a loan account")
if definition is not None and str(account["type"]).lower() != "loan": if definition is not None and str(account["type"]).lower() != "loan":
return fail("validation_error", "representative liability definition requires a loan account") return fail("validation_error", "representative liability definition requires a loan account")
if idempotency_key: seen: set[str] = set()
existing = conn.execute("SELECT * FROM account_valuations WHERE account_id=? AND idempotency_key=?", (account_id, idempotency_key)).fetchone() 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: if existing:
current = _valuation(existing) current = _valuation(existing)
incoming = dict(zip(_COLUMNS, values)) incoming = dict(zip(_COLUMNS, values))
@@ -211,20 +245,22 @@ def register_account_valuation(
return ok(valuation=_valuation(row), reused=False) 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]: 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'" where = "" if include_rejected else " AND verification_status <> 'rejected'"
with db(db_path) as conn: 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() 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] values = [_valuation(row) for row in rows]
return ok(account_id=account_id, valuations=values, count=len(values)) 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]: 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: try:
basis = normalize_date(basis_date) basis = normalize_date(basis_date)
except Exception as exc: except Exception as exc:
return fail("validation_error", str(exc)) return fail("validation_error", str(exc))
with db(db_path) as conn: with read_db(db_path) as conn:
clauses = ["account_id=?"] clauses = ["account_id=?"]
params: list[Any] = [account_id] params: list[Any] = [account_id]
if currency: if currency:
@@ -271,6 +307,10 @@ def correct_account_valuation(valuation_id: str, amount: int, correction_reason:
source = conn.execute("SELECT * FROM account_valuations WHERE id=?", (valuation_id,)).fetchone() source = conn.execute("SELECT * FROM account_valuations WHERE id=?", (valuation_id,)).fetchone()
if source is None: if source is None:
return fail("not_found", "valuation not found") 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 ( 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", "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")} "cost_basis", "net_contributed", "reported_principal_kind", "reported_principal_label", "profit", "return_rate", "return_basis", "principal", "interest", "fee", "representative_liability_definition")}
+339 -48
View File
@@ -13,6 +13,8 @@ from .shared import (
current_kst_date, current_kst_date,
date, date,
db, db,
read_db,
read_operation,
fail, fail,
new_id, new_id,
normalize_date, normalize_date,
@@ -34,6 +36,61 @@ def _valuation_policy() -> dict[str, Any]:
} }
def _canonical_provenance(*, valuation_date: str = "", basis_date: str = "", carried: bool = False,
verification_status: str = "", evidence_kind: str = "", warnings: list[dict[str, Any]] | None = None,
exclusions: list[dict[str, Any]] | None = None, snapshot_run_id: str = "", close_status: str = "",
selection_policy: str = "", approval: dict[str, Any] | None = None,
correction: dict[str, Any] | None = None, age_days: int | None = None) -> dict[str, Any]:
valuation_date = str(valuation_date or "")[:10]
basis_date = str(basis_date or "")[:10]
calculated_age_days = None
if valuation_date and basis_date:
try:
calculated_age_days = (date.fromisoformat(basis_date) - date.fromisoformat(valuation_date)).days
except ValueError:
pass
if age_days is None:
age_days = calculated_age_days
return {"valuation_date": valuation_date, "age_days": age_days, "carried": bool(carried), "carried_forward": bool(carried),
"verification_status": verification_status, "evidence_kind": evidence_kind,
"warnings": warnings or [], "exclusions": exclusions or [], "snapshot_run_id": snapshot_run_id,
"monthly_source_snapshot_id": snapshot_run_id,
"close_status": close_status, "selection_policy": selection_policy,
"approval": approval or {}, "correction": correction or {}}
def _public_evidence(item: dict[str, Any], strict: bool = False) -> dict[str, Any]:
"""Return evidence metadata using only the canonical public shape."""
allowed = {
"id", "source_id", "source_type", "authority_source", "valuation_source", "name", "type", "amount", "currency",
"liquidity_bucket", "valuation_date", "basis_date", "age_days", "carried", "carried_forward", "verification_status", "evidence_kind",
"valuation_id", "valuation_kind", "active", "last_synced_at", "last_balance", "valuation_amount", "reason",
"unverified_candidates", "candidate", "candidates", "state_hash", "blocking", "snapshot_run_id", "is_correction",
"correction_of_run_id", "correction_reason",
}
if isinstance(item, dict):
return {key: _public_evidence(value, strict) for key, value in item.items() if key in allowed or (not strict and isinstance(value, (dict, list)))}
if isinstance(item, list):
return [_public_evidence(value, strict) for value in item]
return item
def _evidence_provenance(evidence: list[dict[str, Any]], basis_date: str) -> dict[str, Any]:
values = [item for item in evidence if item]
dates = {str(item.get("valuation_date") or "")[:10] for item in values if item.get("valuation_date")}
ages = {item.get("age_days") for item in values if item.get("age_days") is not None}
carried = {bool(item.get("carried_forward")) for item in values}
statuses = {str(item.get("verification_status") or "") for item in values}
kinds = {str(item.get("evidence_kind") or "") for item in values}
return {
"valuation_date": next(iter(dates), "") if len(dates) == 1 else "mixed" if dates else "",
"age_days": next(iter(ages), None) if len(ages) == 1 else None,
"carried_forward": next(iter(carried), False) if len(carried) == 1 else bool(any(carried)),
"verification_status": next(iter(statuses), "") if len(statuses) == 1 else "mixed" if statuses else "",
"evidence_kind": next(iter(kinds), "") if len(kinds) == 1 else "mixed" if kinds else "",
}
def _evidence_state_hash(item: dict[str, Any]) -> str: def _evidence_state_hash(item: dict[str, Any]) -> str:
"""Bind idempotency to evidence state without exposing sensitive text.""" """Bind idempotency to evidence state without exposing sensitive text."""
fields = ( fields = (
@@ -84,6 +141,7 @@ def _account_valuation_items(conn: sqlite3.Connection, basis_date: str = "", sel
"last_synced_at": account.get("last_synced_at", ""), "last_synced_at": account.get("last_synced_at", ""),
"source_note": account.get("memo", ""), "source_note": account.get("memo", ""),
"memo": account.get("memo", ""), "memo": account.get("memo", ""),
"evidence_kind": "account_balance",
} }
if base["currency"] != "KRW": if base["currency"] != "KRW":
excluded.append({**base, "reason": "non_krw_without_fx"}) excluded.append({**base, "reason": "non_krw_without_fx"})
@@ -145,7 +203,7 @@ def _account_valuation_items(conn: sqlite3.Connection, basis_date: str = "", sel
}) })
continue continue
balance = valuation["amount"] balance = valuation["amount"]
base.update({"valuation_id": valuation["id"], "verification_status": "verified", "valuation_kind": selected_purpose, "valuation_date": valuation["basis_date"], "currency": valuation["currency"], "carried_forward": valuation["basis_date"] != basis_date}) base.update({"valuation_id": valuation["id"], "verification_status": "verified", "valuation_kind": selected_purpose, "valuation_date": valuation["basis_date"], "currency": valuation["currency"], "carried_forward": valuation["basis_date"] != basis_date, "evidence_kind": "account_valuation", "age_days": (date.fromisoformat(basis_date) - date.fromisoformat(str(valuation["basis_date"])[:10])).days})
if candidate_rows: if candidate_rows:
base["unverified_candidates"] = [_public_candidate(v) for v in candidate_rows if v["basis_date"] <= basis_date] base["unverified_candidates"] = [_public_candidate(v) for v in candidate_rows if v["basis_date"] <= basis_date]
@@ -177,11 +235,14 @@ def _manual_asset_valuation_items(conn: sqlite3.Connection, active_only: bool =
"source_type": "manual_asset", "source_type": "manual_asset",
"authority_source": "manual_assets", "authority_source": "manual_assets",
"name": asset.get("name", ""), "name": asset.get("name", ""),
"currency": str(asset.get("currency") or "KRW").upper(),
"liquidity_bucket": bucket, "liquidity_bucket": bucket,
"valuation_date": asset.get("valuation_date", ""), "valuation_date": asset.get("valuation_date", ""),
"source_note": asset.get("source_note", ""), "source_note": asset.get("source_note", ""),
"memo": asset.get("memo", ""), "memo": asset.get("memo", ""),
"active": asset.get("active", True), "active": asset.get("active", True),
"verification_status": "verified",
"evidence_kind": "manual_confirmed",
} }
value = asset.get("valuation_amount") value = asset.get("valuation_amount")
if basis_date: if basis_date:
@@ -190,6 +251,7 @@ def _manual_asset_valuation_items(conn: sqlite3.Connection, active_only: bool =
excluded.append({**base, "reason": "missing_exact_manual_asset_valuation" if selection_policy == "exact" else "future_or_unknown_manual_asset_valuation"}) excluded.append({**base, "reason": "missing_exact_manual_asset_valuation" if selection_policy == "exact" else "future_or_unknown_manual_asset_valuation"})
continue continue
base["carried_forward"] = valuation_date != basis_date base["carried_forward"] = valuation_date != basis_date
base["age_days"] = (date.fromisoformat(basis_date) - date.fromisoformat(valuation_date)).days
if value is None: if value is None:
excluded.append({**base, "reason": "missing_valuation_amount"}) excluded.append({**base, "reason": "missing_valuation_amount"})
continue continue
@@ -208,7 +270,11 @@ def _snapshot_rows_for_latest_run(conn: sqlite3.Connection, basis_date: str) ->
def _snapshot_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str, Any]]: def _snapshot_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str, Any]]:
rows, _metadata = _snapshot_rows_for_latest_run(conn, basis_date) rows, metadata = _snapshot_rows_for_latest_run(conn, basis_date)
evidence_by_key = {}
for evidence in metadata.get("applied_evidence", []):
key = (evidence.get("source_type", ""), evidence.get("source_id", ""), evidence.get("valuation_id", ""))
evidence_by_key[key] = evidence
items: list[dict[str, Any]] = [] items: list[dict[str, Any]] = []
for row in rows: for row in rows:
snap = row_to_dict(row) snap = row_to_dict(row)
@@ -216,8 +282,14 @@ def _snapshot_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str,
bucket = normalize_liquidity_bucket(snap.get("liquidity_bucket")) bucket = normalize_liquidity_bucket(snap.get("liquidity_bucket"))
except ValueError: except ValueError:
bucket = "other" bucket = "other"
evidence = evidence_by_key.get(
(snap.get("source_type", ""), snap.get("source_id", ""), snap.get("valuation_id", "")),
evidence_by_key.get((snap.get("source_type", ""), snap.get("source_id", ""), ""), {}),
)
valuation_date = evidence.get("valuation_date") or snap.get("basis_date", "")
carried_forward = bool(evidence.get("carried_forward", False))
items.append( items.append(
{ _public_evidence({
"id": snap.get("id", ""), "id": snap.get("id", ""),
"source_id": snap.get("source_id", ""), "source_id": snap.get("source_id", ""),
"source_type": snap.get("source_type", ""), "source_type": snap.get("source_type", ""),
@@ -225,25 +297,98 @@ def _snapshot_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str,
"valuation_source": snap.get("valuation_source", ""), "valuation_source": snap.get("valuation_source", ""),
"name": snap.get("name", ""), "name": snap.get("name", ""),
"liquidity_bucket": bucket, "liquidity_bucket": bucket,
"amount": int(snap.get("amount") or 0), "amount": int(snap["amount"]) if snap.get("amount") is not None else None,
"valuation_date": snap.get("basis_date", ""), "valuation_date": valuation_date,
"age_days": evidence.get("age_days"),
"carried_forward": carried_forward,
"verification_status": evidence.get("verification_status") or snap.get("verification_status", ""),
"evidence_kind": evidence.get("evidence_kind") or snap.get("evidence_kind", ""),
"source_note": snap.get("source_note", ""), "source_note": snap.get("source_note", ""),
"memo": snap.get("memo", ""), "memo": snap.get("memo", ""),
"snapshot_run_id": snap.get("snapshot_run_id", ""), "snapshot_run_id": snap.get("snapshot_run_id", ""),
"is_correction": bool(snap.get("is_correction", False)), "is_correction": bool(snap.get("is_correction", False)),
"correction_of_run_id": snap.get("correction_of_run_id", ""), "correction_of_run_id": snap.get("correction_of_run_id", ""),
"correction_reason": snap.get("correction_reason", ""), "correction_reason": snap.get("correction_reason", ""),
} })
) )
return items return items
def _closed_run_metadata(metadata: dict[str, Any]) -> dict[str, Any]:
"""Expose immutable run evidence without source notes or memos."""
warnings = []
exclusions = [_public_evidence(item, strict=True) for item in metadata.get("excluded_evidence", [])]
candidate_warnings: dict[tuple[str, str], dict[str, Any]] = {}
for item in exclusions:
if item.get("candidate"):
key = (item.get("source_type", ""), item.get("source_id", ""))
warning = candidate_warnings.setdefault(key, {"code": "unverified_candidate", "source_type": key[0], "source_id": key[1], "candidates": []})
warning["candidates"].append(item["candidate"])
else:
warnings.append({"code": item.get("reason", "excluded_item"), "source_type": item.get("source_type", ""), "source_id": item.get("source_id", ""), "currency": item.get("currency", "KRW")})
warnings.extend(candidate_warnings.values())
return {
"warnings": warnings,
"excluded_items": exclusions,
"carried_items": [_public_evidence(item, strict=True) for item in metadata.get("carried_evidence", [])],
"snapshot_run_id": metadata.get("snapshot_run_id", ""),
"close_status": metadata.get("close_status", ""),
"selection_policy": metadata.get("selection_policy", ""),
"approval": {k: metadata.get(k, "") for k in ("approved_by", "approved_at", "approval_reason", "approval_reference")},
"correction": {k: metadata.get(k, "") for k in ("is_correction", "correction_of_run_id", "correction_reason")},
}
def _preview_fingerprint(preview: dict[str, Any], basis: str, selection_policy: str) -> str:
"""Opaque authority-state token shared by preview and close."""
included = preview.get("_included_items", preview.get("included_items", []))
payload = {
"basis_date": basis,
"selection_policy": selection_policy,
"included": [{**{k: item.get(k) for k in ("source_type", "source_id", "amount", "currency", "valuation_id", "valuation_date", "verification_status")}, "state_hash": _evidence_state_hash(item)} for item in included],
"excluded": [{**{k: item.get(k) for k in ("source_type", "source_id", "reason", "valuation_date", "verification_status")}, "state_hash": _evidence_state_hash(item)} for item in preview.get("excluded_items", [])],
"warnings": [warning for warning in preview.get("warnings", []) if warning.get("code") != "existing_snapshot_rows"],
"candidate_state_hashes": preview.get("_candidate_state_hashes", []),
}
return hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()).hexdigest()
def _provenance_aliases(provenance: dict[str, Any], snapshot_run_id: str = "") -> dict[str, Any]:
return {key: provenance.get(key) for key in ("valuation_date", "age_days", "verification_status", "evidence_kind")} | {
"monthly_source_snapshot_id": provenance.get("monthly_source_snapshot_id") or snapshot_run_id,
}
def _ok_with_provenance_aliases(**payload: Any) -> dict[str, Any]:
result = ok(**payload)
provenance = result.get("provenance")
if isinstance(provenance, dict):
result.update(_provenance_aliases(provenance, result.get("snapshot_run_id", "")))
return result
def _closed_warnings(metadata: dict[str, Any], basis_date: str, rows: list[sqlite3.Row]) -> list[dict[str, Any]]:
warnings = _closed_run_metadata(metadata)["warnings"]
row_by_key = {(row["source_type"], row["source_id"]): row_to_dict(row) for row in rows}
for evidence in metadata.get("applied_evidence", []):
if not evidence.get("carried_forward"):
continue
row = row_by_key.get((evidence.get("source_type", ""), evidence.get("source_id", "")), {})
valuation_date = str(evidence.get("valuation_date") or "")[:10]
if valuation_date and valuation_date < basis_date:
warnings.append({"code": "stale_valuation", "source_type": evidence.get("source_type", ""), "source_id": evidence.get("source_id", ""), "name": row.get("name", ""), "valuation_date": valuation_date, "basis_date": basis_date})
return warnings
def _summarize_valuation_items(included_items: list[dict[str, Any]], excluded_items: list[dict[str, Any]] | None = None) -> dict[str, Any]: def _summarize_valuation_items(included_items: list[dict[str, Any]], excluded_items: list[dict[str, Any]] | None = None) -> dict[str, Any]:
bucket_totals = {bucket: 0 for bucket in ASSET_BUCKET_ORDER} bucket_totals = {bucket: 0 for bucket in ASSET_BUCKET_ORDER}
bucket_counts = {bucket: 0 for bucket in ASSET_BUCKET_ORDER} bucket_counts = {bucket: 0 for bucket in ASSET_BUCKET_ORDER}
for item in included_items: for item in included_items:
bucket = normalize_liquidity_bucket(item.get("liquidity_bucket")) bucket = normalize_liquidity_bucket(item.get("liquidity_bucket"))
amount = int(item.get("amount") or 0) raw_amount = item.get("amount")
if raw_amount is None:
continue
amount = int(raw_amount)
bucket_counts[bucket] += 1 bucket_counts[bucket] += 1
bucket_totals[bucket] += amount bucket_totals[bucket] += amount
total_assets = sum(bucket_totals[bucket] for bucket in ASSET_BUCKETS) total_assets = sum(bucket_totals[bucket] for bucket in ASSET_BUCKETS)
@@ -298,9 +443,10 @@ def create_manual_asset(name: str, valuation_amount: int | None, valuation_date:
return ok(manual_asset={**row_to_dict(row), "authority_source": "manual_assets"}) return ok(manual_asset={**row_to_dict(row), "authority_source": "manual_assets"})
@read_operation
def list_manual_assets(active_only: bool = True, db_path: str | None = None) -> dict[str, Any]: def list_manual_assets(active_only: bool = True, db_path: str | None = None) -> dict[str, Any]:
where = "WHERE active=1" if active_only else "" where = "WHERE active=1" if active_only else ""
with db(db_path) as conn: with read_db(db_path) as conn:
rows = conn.execute(f"SELECT * FROM manual_assets {where} ORDER BY name").fetchall() rows = conn.execute(f"SELECT * FROM manual_assets {where} ORDER BY name").fetchall()
assets = [{**row_to_dict(row), "authority_source": "manual_assets"} for row in rows] assets = [{**row_to_dict(row), "authority_source": "manual_assets"} for row in rows]
return ok(manual_assets=assets, count=len(assets), valuation_policy=_valuation_policy()) return ok(manual_assets=assets, count=len(assets), valuation_policy=_valuation_policy())
@@ -404,19 +550,23 @@ def _snapshot_preview_from_conn(conn: sqlite3.Connection, basis: str, selection_
"id": candidate_id, "id": candidate_id,
"state_hash": _valuation_row_state_hash(candidate_row), "state_hash": _valuation_row_state_hash(candidate_row),
}) })
public_excluded = [_public_evidence(item) for item in excluded]
return { return {
"basis_date": basis, "basis_date": basis,
"source": "live_authority", "source": "live_authority",
"provenance": _canonical_provenance(basis_date=basis, warnings=warnings, exclusions=public_excluded, selection_policy=selection_policy),
"is_closed_snapshot": False, "is_closed_snapshot": False,
"snapshot_count": 0, "snapshot_count": 0,
"existing_snapshot_row_count": int(existing_rows), "existing_snapshot_row_count": int(existing_rows),
"would_be_correction": bool(existing_rows), "would_be_correction": bool(existing_rows),
"warnings": warnings, "warnings": warnings,
"_candidate_state_hashes": candidate_state_hashes, "_candidate_state_hashes": candidate_state_hashes,
**summary, "_included_items": included,
**{**summary, "included_items": [_public_evidence(item) for item in included], "excluded_items": public_excluded},
} }
@read_operation
def preview_asset_snapshot_close(basis_date: str = "", db_path: str | None = None, selection_policy: str = "exact") -> dict[str, Any]: def preview_asset_snapshot_close(basis_date: str = "", db_path: str | None = None, selection_policy: str = "exact") -> dict[str, Any]:
if selection_policy not in {"exact", "as_of"}: if selection_policy not in {"exact", "as_of"}:
return fail("validation_error", "selection_policy must be exact or as_of") return fail("validation_error", "selection_policy must be exact or as_of")
@@ -424,8 +574,9 @@ def preview_asset_snapshot_close(basis_date: str = "", db_path: str | None = Non
basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat() basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat()
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:
preview = _snapshot_preview_from_conn(conn, basis, selection_policy) preview = _snapshot_preview_from_conn(conn, basis, selection_policy)
preview.pop("_included_items", None)
preview["selection_policy"] = selection_policy preview["selection_policy"] = selection_policy
approval_eligible = [w for w in preview["warnings"] if w.get("blocking", True) and w.get("code", "").startswith(("missing_", "future_", "unknown_", "unverified", "ambiguous_"))] approval_eligible = [w for w in preview["warnings"] if w.get("blocking", True) and w.get("code", "").startswith(("missing_", "future_", "unknown_", "unverified", "ambiguous_"))]
carried_items = [item for item in preview["included_items"] if item.get("carried_forward")] carried_items = [item for item in preview["included_items"] if item.get("carried_forward")]
@@ -433,14 +584,15 @@ def preview_asset_snapshot_close(basis_date: str = "", db_path: str | None = Non
if other_blocking: if other_blocking:
preview["close_status"] = "incomplete_draft" preview["close_status"] = "incomplete_draft"
elif approval_eligible or carried_items: elif approval_eligible or carried_items:
preview["close_status"] = "conditional" preview["close_status"] = "incomplete_draft"
else: else:
preview["close_status"] = "complete_exact" preview["close_status"] = "complete_exact"
preview["preview_fingerprint"] = _preview_fingerprint(preview, basis, selection_policy)
preview.pop("_candidate_state_hashes", None) preview.pop("_candidate_state_hashes", None)
return ok(**preview) return ok(**preview)
def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str = "", correction_reason: str = "", db_path: str | None = None, selection_policy: str = "exact", idempotency_key: str = "", allow_missing_exclusions: bool = False, allow_as_of_carry_forward: bool = False, approved_by: str = "", approval_reason: str = "", approval_reference: str = "") -> dict[str, Any]: def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str = "", correction_reason: str = "", db_path: str | None = None, selection_policy: str = "exact", idempotency_key: str = "", allow_missing_exclusions: bool = False, allow_as_of_carry_forward: bool = False, approved_by: str = "", approval_reason: str = "", approval_reference: str = "", preview_fingerprint: str = "") -> dict[str, Any]:
if selection_policy not in {"exact", "as_of"}: if selection_policy not in {"exact", "as_of"}:
return fail("validation_error", "selection_policy must be exact or as_of") return fail("validation_error", "selection_policy must be exact or as_of")
try: try:
@@ -452,6 +604,8 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
with db(db_path) as conn: with db(db_path) as conn:
conn.execute("BEGIN IMMEDIATE") conn.execute("BEGIN IMMEDIATE")
preview = _snapshot_preview_from_conn(conn, basis, selection_policy) preview = _snapshot_preview_from_conn(conn, basis, selection_policy)
if preview_fingerprint and preview_fingerprint != _preview_fingerprint(preview, basis, selection_policy):
return fail("authority_drift", "asset authority changed since preview", basis_date=basis)
latest_rows, latest_meta = _snapshot_rows_for_latest_run(conn, basis) latest_rows, latest_meta = _snapshot_rows_for_latest_run(conn, basis)
legacy_snapshot_rows = bool(latest_rows and not latest_meta.get("snapshot_run_id")) legacy_snapshot_rows = bool(latest_rows and not latest_meta.get("snapshot_run_id"))
if legacy_snapshot_rows and not correction_reason.strip(): if legacy_snapshot_rows and not correction_reason.strip():
@@ -464,7 +618,8 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
missing_exclusions = [w for w in preview["warnings"] if w.get("blocking", True) and w.get("code", "").startswith(("missing_", "future_", "unknown_", "unverified", "ambiguous_"))] missing_exclusions = [w for w in preview["warnings"] if w.get("blocking", True) and w.get("code", "").startswith(("missing_", "future_", "unknown_", "unverified", "ambiguous_"))]
carried_items = [item for item in preview["included_items"] if item.get("carried_forward")] carried_items = [item for item in preview["included_items"] if item.get("carried_forward")]
conditional = bool(missing_exclusions or carried_items) conditional = bool(missing_exclusions or carried_items)
fingerprint_payload = {"basis_date": basis, "source_note": source_note, "memo": memo, "correction_reason": correction_reason, "selection_policy": selection_policy, "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, "included": [{**{k: i.get(k) for k in ("source_type", "source_id", "amount", "currency", "valuation_id", "valuation_date", "verification_status")}, "state_hash": _evidence_state_hash(i)} for i in preview["included_items"]], "excluded": [{**{k: i.get(k) for k in ("source_type", "source_id", "reason", "valuation_date", "verification_status")}, "state_hash": _evidence_state_hash(i)} for i in preview["excluded_items"]], "warnings": [warning for warning in preview["warnings"] if warning.get("code") != "existing_snapshot_rows"], "candidate_state_hashes": preview["_candidate_state_hashes"]} fingerprint_items = preview.get("_included_items", preview["included_items"])
fingerprint_payload = {"basis_date": basis, "source_note": source_note, "memo": memo, "correction_reason": correction_reason, "selection_policy": selection_policy, "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, "included": [{**{k: i.get(k) for k in ("source_type", "source_id", "amount", "currency", "valuation_id", "valuation_date", "verification_status")}, "state_hash": _evidence_state_hash(i)} for i in fingerprint_items], "excluded": [{**{k: i.get(k) for k in ("source_type", "source_id", "reason", "valuation_date", "verification_status")}, "state_hash": _evidence_state_hash(i)} for i in preview["excluded_items"]], "warnings": [warning for warning in preview["warnings"] if warning.get("code") != "existing_snapshot_rows"], "candidate_state_hashes": preview["_candidate_state_hashes"]}
fingerprint = hashlib.sha256(json.dumps(fingerprint_payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()).hexdigest() fingerprint = hashlib.sha256(json.dumps(fingerprint_payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()).hexdigest()
if missing_exclusions and (not allow_missing_exclusions or not approved_by.strip() or not approval_reason.strip() or not approval_reference.strip()): if missing_exclusions and (not allow_missing_exclusions or not approved_by.strip() or not approval_reason.strip() or not approval_reference.strip()):
return fail("conditional_approval_required", "missing exclusions require explicit approval metadata and allow_missing_exclusions") return fail("conditional_approval_required", "missing exclusions require explicit approval metadata and allow_missing_exclusions")
@@ -474,9 +629,46 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
existing_run = conn.execute("SELECT * FROM asset_snapshot_runs WHERE idempotency_key=?", (idempotency_key,)).fetchone() existing_run = conn.execute("SELECT * FROM asset_snapshot_runs WHERE idempotency_key=?", (idempotency_key,)).fetchone()
if existing_run: if existing_run:
if existing_run["request_fingerprint"] == fingerprint: if existing_run["request_fingerprint"] == fingerprint:
return ok(reused=True, snapshot_run_id=existing_run["id"], close_status=existing_run["close_status"], basis_date=basis) replay_rows, replay_meta = _snapshot_rows_for_latest_run(conn, basis)
replay_items = [_public_evidence(item) for item in replay_meta.get("applied_evidence", [])] or _snapshot_items(conn, basis)
replay_warnings = _closed_warnings(replay_meta, basis, replay_rows)
replay_public = _closed_run_metadata(replay_meta)
replay_public["warnings"] = replay_warnings
replay_summary = _summarize_valuation_items(replay_items, replay_public["excluded_items"])
replay_provenance = _evidence_provenance(replay_meta.get("applied_evidence", []), basis)
approval = replay_public["approval"]
correction = replay_public["correction"]
return _ok_with_provenance_aliases(
basis_date=basis, source="snapshot", is_closed_snapshot=True,
snapshot_run_id=existing_run["id"],
correction_of_run_id=correction["correction_of_run_id"],
correction_reason=correction["correction_reason"],
is_correction=correction["is_correction"],
closed_at=existing_run["created_at"],
close_status=existing_run["close_status"],
selection_policy=existing_run["selection_policy"],
inserted_count=len(replay_rows),
inserted_snapshot_ids=[row["id"] for row in replay_rows],
warnings=replay_warnings,
carried_items=replay_public["carried_items"],
approval=approval, correction=correction,
provenance=_canonical_provenance(
valuation_date=replay_provenance["valuation_date"],
age_days=replay_provenance["age_days"],
carried=replay_provenance["carried_forward"],
verification_status=replay_provenance["verification_status"],
evidence_kind=replay_provenance["evidence_kind"],
basis_date=basis, snapshot_run_id=existing_run["id"],
close_status=existing_run["close_status"],
selection_policy=existing_run["selection_policy"],
warnings=replay_warnings,
exclusions=replay_public["excluded_items"],
approval=approval, correction=correction,
),
**replay_summary,
)
return fail("idempotency_conflict", "snapshot close idempotency payload differs") return fail("idempotency_conflict", "snapshot close idempotency payload differs")
included = preview["included_items"] included = preview.pop("_included_items", preview["included_items"])
# A conditional close may legitimately contain zero snapshot rows. # A conditional close may legitimately contain zero snapshot rows.
# The run record, not row count, is the authoritative close marker. # The run record, not row count, is the authoritative close marker.
is_correction = bool(latest_meta.get("snapshot_run_id") or legacy_snapshot_rows) is_correction = bool(latest_meta.get("snapshot_run_id") or legacy_snapshot_rows)
@@ -497,7 +689,7 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
item.get("source_type", ""), item.get("source_type", ""),
item.get("source_id", item.get("id", "")), item.get("source_id", item.get("id", "")),
item.get("name", ""), item.get("name", ""),
int(item.get("amount") or 0), int(item["amount"]) if item.get("amount") is not None else None,
item.get("currency", "KRW"), item.get("currency", "KRW"),
normalize_liquidity_bucket(item.get("liquidity_bucket")), normalize_liquidity_bucket(item.get("liquidity_bucket")),
item.get("authority_source", item.get("source_type", "")), item.get("authority_source", item.get("source_type", "")),
@@ -517,8 +709,7 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
), ),
) )
snapshot_ids.append(snapshot_id) snapshot_ids.append(snapshot_id)
summary = _summarize_valuation_items(included, []) applied_evidence = [{k: item.get(k) for k in ("id", "source_type", "source_id", "authority_source", "name", "type", "liquidity_bucket", "active", "amount", "valuation_amount", "last_balance", "currency", "valuation_id", "valuation_date", "age_days", "carried_forward", "verification_status", "evidence_kind") if item.get(k) is not None} for item in included]
applied_evidence = [{k: item.get(k) for k in ("source_type", "source_id", "amount", "currency", "valuation_id", "valuation_date", "verification_status")} for item in included]
excluded_evidence = [{k: item.get(k) for k in ("source_type", "source_id", "reason", "currency")} for item in preview.get("excluded_items", [])] excluded_evidence = [{k: item.get(k) for k in ("source_type", "source_id", "reason", "currency")} for item in preview.get("excluded_items", [])]
excluded_evidence.extend( excluded_evidence.extend(
{ {
@@ -536,13 +727,27 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
if warning.get("code") == "unverified_candidate" if warning.get("code") == "unverified_candidate"
for candidate in warning.get("candidates", []) for candidate in warning.get("candidates", [])
) )
carried_evidence = [{k: item.get(k) for k in ("source_type", "source_id", "amount", "currency", "valuation_id", "valuation_date", "verification_status")} for item in included if item.get("carried_forward")] carried_evidence = [{k: item.get(k) for k in ("source_type", "source_id", "amount", "currency", "valuation_id", "valuation_date", "age_days", "carried_forward", "verification_status", "evidence_kind")} for item in included if item.get("carried_forward")]
summary = _summarize_valuation_items([_public_evidence(item) for item in included], excluded_evidence)
try: try:
conn.execute("INSERT INTO asset_snapshot_runs(id,basis_date,close_status,selection_policy,idempotency_key,approved_by,approved_at,approval_reason,approval_reference,correction_of_run_id,correction_reason,applied_evidence,excluded_evidence,carried_evidence,request_fingerprint,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", (run_id, basis, "conditional" if conditional else "complete_exact", selection_policy, idempotency_key, approved_by, ts if approved_by else "", approval_reason, approval_reference, correction_of_run_id, correction_reason.strip(), json.dumps(applied_evidence, ensure_ascii=False, sort_keys=True), json.dumps(excluded_evidence, ensure_ascii=False, sort_keys=True), json.dumps(carried_evidence, ensure_ascii=False, sort_keys=True), fingerprint, ts)) conn.execute("INSERT INTO asset_snapshot_runs(id,basis_date,close_status,selection_policy,idempotency_key,approved_by,approved_at,approval_reason,approval_reference,correction_of_run_id,correction_reason,applied_evidence,excluded_evidence,carried_evidence,request_fingerprint,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", (run_id, basis, "conditional" if conditional else "complete_exact", selection_policy, idempotency_key, approved_by, ts if approved_by else "", approval_reason, approval_reference, correction_of_run_id, correction_reason.strip(), json.dumps(applied_evidence, ensure_ascii=False, sort_keys=True), json.dumps(excluded_evidence, ensure_ascii=False, sort_keys=True), json.dumps(carried_evidence, ensure_ascii=False, sort_keys=True), fingerprint, ts))
except sqlite3.IntegrityError: except sqlite3.IntegrityError:
conn.rollback() conn.rollback()
return fail("snapshot_conflict", "another snapshot close already owns this basis date", basis_date=basis) return fail("snapshot_conflict", "another snapshot close already owns this basis date", basis_date=basis)
return ok( approval = {
"approved_by": approved_by,
"approved_at": ts if approved_by else "",
"approval_reason": approval_reason,
"approval_reference": approval_reference,
}
correction = {
"is_correction": is_correction,
"correction_of_run_id": correction_of_run_id,
"correction_reason": correction_reason.strip(),
}
verification_statuses = {str(item.get("verification_status") or "") for item in included}
evidence_kinds = {str(item.get("evidence_kind") or "") for item in included}
return _ok_with_provenance_aliases(
basis_date=basis, basis_date=basis,
source="snapshot", source="snapshot",
is_closed_snapshot=True, is_closed_snapshot=True,
@@ -556,6 +761,23 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
inserted_count=len(snapshot_ids), inserted_count=len(snapshot_ids),
inserted_snapshot_ids=snapshot_ids, inserted_snapshot_ids=snapshot_ids,
warnings=preview["warnings"], warnings=preview["warnings"],
carried_items=carried_evidence,
approval=approval,
correction=correction,
provenance=_canonical_provenance(
valuation_date=(preview["included_items"][0].get("valuation_date", "") if preview["included_items"] else ""),
basis_date=basis,
carried=bool(carried_evidence),
verification_status=(verification_statuses.pop() if len(verification_statuses) == 1 else "mixed" if verification_statuses else ""),
evidence_kind=(evidence_kinds.pop() if len(evidence_kinds) == 1 else "mixed" if evidence_kinds else ""),
snapshot_run_id=run_id,
close_status="conditional" if conditional else "complete_exact",
selection_policy=selection_policy,
warnings=preview["warnings"],
exclusions=excluded_evidence,
approval=approval,
correction=correction,
),
**summary, **summary,
) )
@@ -583,58 +805,99 @@ def create_asset_snapshot(basis_date: str, source_type: str, source_id: str, nam
return ok(asset_snapshot=row_to_dict(row)) return ok(asset_snapshot=row_to_dict(row))
@read_operation
def get_asset_snapshot_report(basis_date: str = "", db_path: str | None = None, selection_policy: str = "exact") -> dict[str, Any]: def get_asset_snapshot_report(basis_date: str = "", db_path: str | None = None, selection_policy: str = "exact") -> dict[str, Any]:
if selection_policy not in {"exact", "as_of"}: if selection_policy not in {"exact", "as_of"}:
return fail("validation_error", "selection_policy must be exact or as_of") return fail("validation_error", "selection_policy must be exact or as_of")
try:
basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat() basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat()
with db(db_path) as conn: except Exception:
return fail("validation_error", f"invalid date: {basis_date}")
with read_db(db_path) as conn:
rows, close_metadata = _snapshot_rows_for_latest_run(conn, basis) rows, close_metadata = _snapshot_rows_for_latest_run(conn, basis)
has_close_run = bool(close_metadata.get("snapshot_run_id")) has_close_run = bool(close_metadata.get("snapshot_run_id"))
snapshots = _snapshot_items(conn, basis) if rows else [] snapshots = _snapshot_items(conn, basis) if rows else []
legacy_snapshots = snapshots if rows and not has_close_run else [] legacy_snapshots = snapshots if rows and not has_close_run else []
if has_close_run: if has_close_run:
summary = _summarize_valuation_items(snapshots, []) run_public = _closed_run_metadata(close_metadata)
return ok( run_public["warnings"] = _closed_warnings(close_metadata, basis, rows)
summary = _summarize_valuation_items(snapshots, run_public["excluded_items"])
evidence_provenance = _evidence_provenance(close_metadata.get("applied_evidence", []), basis)
return _ok_with_provenance_aliases(
basis_date=basis, basis_date=basis,
source="asset_snapshots", source="asset_snapshots",
is_closed_snapshot=True, is_closed_snapshot=True,
snapshot_run_id=close_metadata.get("snapshot_run_id", ""),
snapshot_count=len(snapshots), snapshot_count=len(snapshots),
close_metadata=close_metadata, close_metadata=run_public,
selection_policy=close_metadata.get("selection_policy", selection_policy), selection_policy=close_metadata.get("selection_policy", selection_policy),
close_status=close_metadata.get("close_status", "complete_exact"), close_status=close_metadata.get("close_status", "complete_exact"),
warnings=run_public["warnings"],
carried_items=run_public["carried_items"],
approval=run_public["approval"],
correction=run_public["correction"],
provenance=_canonical_provenance(
valuation_date=evidence_provenance["valuation_date"],
age_days=evidence_provenance["age_days"],
carried=evidence_provenance["carried_forward"],
verification_status=evidence_provenance["verification_status"],
evidence_kind=evidence_provenance["evidence_kind"],
basis_date=basis, snapshot_run_id=close_metadata.get("snapshot_run_id", ""),
close_status=close_metadata.get("close_status", "complete_exact"),
selection_policy=close_metadata.get("selection_policy", selection_policy),
warnings=run_public["warnings"], exclusions=run_public["excluded_items"],
approval=run_public["approval"], correction=run_public["correction"],
),
**summary, **summary,
) )
account_items, account_excluded, invalid_bucket_accounts = _account_valuation_items(conn, basis, selection_policy) preview = _snapshot_preview_from_conn(conn, basis, selection_policy)
manual_items, manual_excluded = _manual_asset_valuation_items(conn, basis_date=basis, selection_policy=selection_policy) included = preview.get("_included_items", [])
included = account_items + manual_items if not included and not preview["excluded_items"] and legacy_snapshots:
excluded = account_excluded + manual_excluded public_legacy = [_public_evidence(item) for item in legacy_snapshots]
if not included and not excluded and legacy_snapshots: null_items = [item for item in public_legacy if item.get("amount") is None]
summary = _summarize_valuation_items(legacy_snapshots, []) null_excluded = [{**item, "reason": "missing_amount"} for item in null_items]
return ok( summary = _summarize_valuation_items(public_legacy, null_excluded)
basis_date=basis, return _ok_with_provenance_aliases(
source="legacy_asset_snapshots", basis_date=basis, source="legacy_asset_snapshots", selection_policy=selection_policy,
selection_policy=selection_policy, close_status="incomplete_draft" if null_items else close_metadata.get("close_status", "legacy_read_only"), is_closed_snapshot=False,
close_status="legacy_read_only", provenance=_canonical_provenance(basis_date=basis, snapshot_run_id="", close_status="legacy_read_only", selection_policy=selection_policy),
is_closed_snapshot=False, snapshot_count=len(legacy_snapshots), legacy_snapshot_row_count=len(legacy_snapshots),
snapshot_count=len(legacy_snapshots), close_metadata={"legacy": True, "snapshot_run_id": ""}, **summary,
legacy_snapshot_row_count=len(legacy_snapshots),
close_metadata={"legacy": True, "snapshot_run_id": ""},
**summary,
) )
summary = _summarize_valuation_items(included, excluded) public_included = [_public_evidence(item) for item in included]
return ok( public_excluded = preview["excluded_items"]
carried_items = [item for item in public_included if item.get("carried_forward")]
warnings = preview["warnings"]
approval = {}
correction = {}
evidence_provenance = _evidence_provenance(included, basis)
summary = _summarize_valuation_items(public_included, public_excluded)
return _ok_with_provenance_aliases(
basis_date=basis, basis_date=basis,
source="live_authority", source="live_authority",
selection_policy=selection_policy, selection_policy=selection_policy,
close_status="incomplete_draft" if excluded else "complete_exact", close_status=preview["close_status"] if "close_status" in preview else ("incomplete_draft" if (public_excluded or carried_items) else "complete_exact"),
provenance=_canonical_provenance(
valuation_date=evidence_provenance["valuation_date"], age_days=evidence_provenance["age_days"],
carried=evidence_provenance["carried_forward"], verification_status=evidence_provenance["verification_status"],
evidence_kind=evidence_provenance["evidence_kind"], basis_date=basis,
close_status=preview["close_status"] if "close_status" in preview else "",
selection_policy=selection_policy, warnings=warnings, exclusions=public_excluded,
approval=approval, correction=correction,
),
is_closed_snapshot=False, is_closed_snapshot=False,
snapshot_count=0, snapshot_count=0,
legacy_snapshot_row_count=len(legacy_snapshots), legacy_snapshot_row_count=len(legacy_snapshots),
invalid_bucket_accounts=invalid_bucket_accounts, invalid_bucket_accounts=[warning for warning in warnings if warning.get("code") == "invalid_liquidity_bucket"],
**summary, warnings=warnings,
carried_items=carried_items,
approval=approval,
correction=correction,
**{**summary, "included_items": public_included, "excluded_items": public_excluded},
) )
@read_operation
def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: int = 12, db_path: str | None = None) -> dict[str, Any]: def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: int = 12, db_path: str | None = None) -> dict[str, Any]:
try: try:
months_int = max(1, min(int(months), 120)) months_int = max(1, min(int(months), 120))
@@ -649,7 +912,7 @@ def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: i
start_norm = date(year, month, 1).isoformat() start_norm = date(year, month, 1).isoformat()
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:
basis_rows = conn.execute( basis_rows = conn.execute(
"""SELECT DISTINCT basis_date FROM asset_snapshots """SELECT DISTINCT basis_date FROM asset_snapshots
WHERE basis_date>=? AND basis_date<=? WHERE basis_date>=? AND basis_date<=?
@@ -660,7 +923,11 @@ def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: i
for row in basis_rows: for row in basis_rows:
basis = row["basis_date"] basis = row["basis_date"]
items = _snapshot_items(conn, basis) items = _snapshot_items(conn, basis)
summary = _summarize_valuation_items(items, []) _, metadata = _snapshot_rows_for_latest_run(conn, basis)
run_public = _closed_run_metadata(metadata)
run_public["warnings"] = _closed_warnings(metadata, basis, conn.execute("SELECT rowid AS _rowid, * FROM asset_snapshots WHERE basis_date=? ORDER BY created_at ASC, name ASC", (basis,)).fetchall())
run_provenance = _evidence_provenance(metadata.get("applied_evidence", []), basis)
summary = _summarize_valuation_items(items, run_public["excluded_items"])
points.append( points.append(
{ {
"basis_date": basis, "basis_date": basis,
@@ -669,18 +936,42 @@ def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: i
"net_worth": summary["net_worth"], "net_worth": summary["net_worth"],
"source": "asset_snapshots", "source": "asset_snapshots",
"item_count": len(items), "item_count": len(items),
"snapshot_run_id": metadata.get("snapshot_run_id", ""),
"close_status": metadata.get("close_status", ""),
"selection_policy": metadata.get("selection_policy", ""),
"warnings": run_public["warnings"],
"excluded_items": run_public["excluded_items"],
"carried_items": run_public["carried_items"],
"approval": run_public["approval"],
"correction": run_public["correction"],
"provenance": _canonical_provenance(
valuation_date=run_provenance["valuation_date"],
age_days=run_provenance["age_days"],
carried=run_provenance["carried_forward"],
verification_status=run_provenance["verification_status"],
evidence_kind=run_provenance["evidence_kind"],
basis_date=basis,
snapshot_run_id=metadata.get("snapshot_run_id", ""),
close_status=metadata.get("close_status", ""),
selection_policy=metadata.get("selection_policy", ""),
warnings=run_public["warnings"],
exclusions=run_public["excluded_items"],
approval=run_public["approval"],
correction=run_public["correction"],
),
} }
) )
return ok(start_date=start_norm, end_date=end_norm, months=months_int, points=points, count=len(points), valuation_policy=_valuation_policy()) return ok(start_date=start_norm, end_date=end_norm, months=months_int, points=points, count=len(points), valuation_policy=_valuation_policy())
@read_operation
def get_asset_liquidity_report(db_path: str | None = None) -> dict[str, Any]: def get_asset_liquidity_report(db_path: str | None = None) -> dict[str, Any]:
"""Summarize assets/liabilities from shared valuation authority sources. """Summarize assets/liabilities from shared valuation authority sources.
Existing REQ-005 keys are preserved. When manual_assets is empty, totals and Existing REQ-005 keys are preserved. When manual_assets is empty, totals and
source remain equivalent to the original accounts.last_balance behavior. source remain equivalent to the original accounts.last_balance behavior.
""" """
with db(db_path) as conn: with read_db(db_path) as conn:
account_items, account_excluded, invalid_bucket_accounts = _account_valuation_items(conn) account_items, account_excluded, invalid_bucket_accounts = _account_valuation_items(conn)
manual_items, manual_excluded = _manual_asset_valuation_items(conn) manual_items, manual_excluded = _manual_asset_valuation_items(conn)
included = account_items + manual_items included = account_items + manual_items
@@ -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)),),
@@ -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,
@@ -38,6 +40,24 @@ def _record_import_valuation_candidate(conn: Any, account_id: str, balance: Any,
from .account_valuation_service import record_account_valuation_candidate 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}") 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_synced_at FROM accounts WHERE id=?", (account_id,)).fetchone()
latest_evidence = conn.execute(
"""SELECT basis_date FROM account_valuations
WHERE account_id=? AND verification_status IN ('verified', 'unverified')
ORDER BY basis_date DESC, recorded_at DESC, id DESC LIMIT 1""",
(account_id,),
).fetchone()
current_date = max(
str(current["last_synced_at"] or "")[:10] if current else "",
str(latest_evidence["basis_date"] or "")[:10] if latest_evidence else "",
)
if not current_date or basis_date[:10] >= current_date:
conn.execute("UPDATE accounts SET last_balance=?, last_synced_at=?, updated_at=? WHERE id=?", (balance, 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():
@@ -49,6 +69,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)
@@ -62,7 +83,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()
@@ -218,7 +239,7 @@ 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 accounts SET last_balance=?, last_synced_at=?, updated_at=? WHERE id=?", (transactions[-1].balance_after, ts, ts, linked_account_id)) _update_current_balance_if_newer(conn, linked_account_id, transactions[-1].balance_after, transactions[-1].transaction_date, ts)
if transactions and transactions[-1].balance_after is not None: if transactions and transactions[-1].balance_after is not None:
_record_import_valuation_candidate(conn, linked_account_id, transactions[-1].balance_after, transactions[-1].transaction_date, source_hash) _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:
@@ -362,6 +383,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)
@@ -376,7 +398,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 ""
@@ -529,6 +551,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)
@@ -542,7 +565,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 = ""
@@ -683,7 +706,7 @@ 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 accounts SET last_balance=?, last_synced_at=?, updated_at=? WHERE id=?", (transactions[-1].balance_after, ts, ts, linked_account_id)) _update_current_balance_if_newer(conn, linked_account_id, transactions[-1].balance_after, transactions[-1].transaction_date, ts)
if transactions and transactions[-1].balance_after is not None: if transactions and transactions[-1].balance_after is not None:
_record_import_valuation_candidate(conn, linked_account_id, transactions[-1].balance_after, transactions[-1].transaction_date, source_hash) _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:
@@ -763,6 +786,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)
@@ -775,7 +799,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 = ""
@@ -907,7 +931,7 @@ 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 accounts SET last_balance=?, last_synced_at=?, updated_at=? WHERE id=?", (transactions[-1].balance_after, ts, ts, linked_account_id)) _update_current_balance_if_newer(conn, linked_account_id, transactions[-1].balance_after, transactions[-1].transaction_date, ts)
if transactions and transactions[-1].balance_after is not None: if transactions and transactions[-1].balance_after is not None:
_record_import_valuation_candidate(conn, linked_account_id, transactions[-1].balance_after, transactions[-1].transaction_date, source_hash) _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:
@@ -927,6 +951,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,
@@ -972,6 +997,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,
@@ -992,7 +1018,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 = []
@@ -65,9 +65,16 @@ def _source_coverage(
"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", ""), "close_status": asset_report.get("close_status", ""),
"approval": asset_report.get("approval", {}),
"correction": asset_report.get("correction", {}),
"warnings": asset_report.get("warnings", []), "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", ""))], "missing_items": [item for item in asset_report.get("excluded_items", []) if "missing" in str(item.get("reason", ""))],
"carried_items": [item for item in asset_report.get("included_items", []) if item.get("carried_forward")], "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", ""),
}, },
} }
@@ -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,27 +97,56 @@ 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 valuation = None
ambiguous_valuation = False ambiguous_valuation = False
valuation_rows = conn.execute( valuation_rows = conn.execute(
"""SELECT * FROM account_valuations v """SELECT * FROM account_valuations v
WHERE v.account_id=? AND v.verification_status='verified' WHERE v.account_id=? AND v.verification_status='verified'
AND UPPER(v.currency)=UPPER(?)
AND v.basis_date<=? AND v.basis_date<=?
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM account_valuations child SELECT 1 FROM account_valuations child
WHERE child.supersedes_id=v.id WHERE child.supersedes_id=v.id
) )
ORDER BY v.basis_date DESC, v.recorded_at DESC, v.id DESC""", ORDER BY v.basis_date DESC, v.recorded_at DESC, v.id DESC""",
(account["id"], effective_at[:10]), (account["id"], account["currency"] or "KRW", effective_at[:10]),
).fetchall() ).fetchall()
purposes = ( purposes = (
("total_value", "balance", "cash_balance") ("loan_balance", "loan", "balance")
if account["type"] == "investment" if account["type"] == "loan"
else ("cash_balance", "balance") 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: for purpose in purposes:
scoped = [row for row in valuation_rows if row["valuation_kind"] == purpose] 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: if not scoped:
continue continue
latest_basis = max(row["basis_date"] for row in scoped) latest_basis = max(row["basis_date"] for row in scoped)
@@ -126,6 +167,12 @@ def get_balance_reconciliation_report(account_id: str = "", baseline_balance: in
if account["type"] == "investment" and ambiguous_valuation: if account["type"] == "investment" and ambiguous_valuation:
status = "AMBIGUOUS_VALUATION" status = "AMBIGUOUS_VALUATION"
difference = None 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: elif account["type"] == "investment" and valuation is not None:
status = "VALUATION_MANAGED" status = "VALUATION_MANAGED"
difference = None difference = None
@@ -145,6 +192,18 @@ def get_balance_reconciliation_report(account_id: str = "", baseline_balance: in
"external_confirmed_value": int(valuation["amount"]) if valuation is not None else None, "external_confirmed_value": int(valuation["amount"]) if valuation is not None else None,
"valuation_id": valuation["id"] if valuation is not None else "", "valuation_id": valuation["id"] if valuation is not None else "",
"valuation_kind": valuation["valuation_kind"] 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
+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)
+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,
+120 -3
View File
@@ -31,12 +31,12 @@ def test_empty_conditional_close_is_closed_and_second_close_requires_correction(
assert second["success"] is False and second["error_code"] == "validation_error" assert second["success"] is False and second["error_code"] == "validation_error"
def test_preview_marks_approval_eligible_missing_valuation_as_conditional(tmp_path): def test_preview_marks_approval_eligible_missing_valuation_as_incomplete_draft(tmp_path):
path = str(tmp_path / "ledger.sqlite3") path = str(tmp_path / "ledger.sqlite3")
service.create_ledger_account("미확인 계좌", db_path=path) service.create_ledger_account("미확인 계좌", db_path=path)
preview = service.preview_asset_snapshot_close("2026-06-30", db_path=path) preview = service.preview_asset_snapshot_close("2026-06-30", db_path=path)
assert preview["success"] is True assert preview["success"] is True
assert preview["close_status"] == "conditional" assert preview["close_status"] == "incomplete_draft"
assert any(warning["code"] == "missing_verified_exact_valuation" for warning in preview["warnings"]) assert any(warning["code"] == "missing_verified_exact_valuation" for warning in preview["warnings"])
@@ -140,7 +140,7 @@ def test_snapshot_close_idempotency_fingerprint_and_concurrent_first_close(tmp_p
"2026-06-30", source_note="source-a", memo="memo-b", "2026-06-30", source_note="source-a", memo="memo-b",
idempotency_key="close-1", db_path=path, idempotency_key="close-1", db_path=path,
) )
assert first["success"] and same["success"] and same["reused"] is True assert first["success"] and same["success"] and same == first
assert changed["success"] is False and changed["error_code"] == "idempotency_conflict" assert changed["success"] is False and changed["error_code"] == "idempotency_conflict"
other_asset = first["included_items"][0]["source_id"] other_asset = first["included_items"][0]["source_id"]
@@ -390,6 +390,122 @@ def test_snapshot_idempotency_binds_redacted_candidate_state(tmp_path):
assert retry["error_code"] == "idempotency_conflict" 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): def test_reconciliation_fails_closed_on_ambiguous_verified_leaf(tmp_path):
path = str(tmp_path / "ledger.sqlite3") path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account( account = service.create_ledger_account(
@@ -483,6 +599,7 @@ def test_legacy_snapshot_rows_do_not_override_live_authority_or_claim_closed(tmp
def test_snapshot_public_api_preserves_legacy_db_path_position(tmp_path): def test_snapshot_public_api_preserves_legacy_db_path_position(tmp_path):
path = str(tmp_path / "ledger.sqlite3") path = str(tmp_path / "ledger.sqlite3")
init_db(path)
preview = service.preview_asset_snapshot_close("2026-06-30", path) preview = service.preview_asset_snapshot_close("2026-06-30", path)
closed = service.close_asset_snapshot("2026-06-30", "", "", "", path) closed = service.close_asset_snapshot("2026-06-30", "", "", "", path)
report = service.get_asset_snapshot_report("2026-06-30", path) report = service.get_asset_snapshot_report("2026-06-30", path)
+190
View File
@@ -0,0 +1,190 @@
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)["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_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)["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)["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
+218 -1
View File
@@ -40,11 +40,26 @@ def test_requirement_docs_are_canonical_and_traceable() -> None:
assert body.count("**State Invariants**") == 10 assert body.count("**State Invariants**") == 10
assert body.count("**판정 기준**") == 10 assert body.count("**판정 기준**") == 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 ( for marker in (
"v12", "idempotent", "append-only", "future", "unverified", "unknown", "v12", "idempotent", "append-only", "future", "unverified", "unknown",
"idempotency_conflict", "correction_conflict", "NULL", "VALUATION_MANAGED", "idempotency_conflict", "correction_conflict", "NULL", "VALUATION_MANAGED",
"conditional_approval_required", "MCP", "Dashboard", "auth-before-DB", "conditional_approval_required", "MCP", "Dashboard", "auth-before-DB",
"read-only", "monthly", "synthetic", "실제 credential", "호환성", "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 assert marker in text, marker
@@ -60,7 +75,12 @@ def test_canonical_docs_have_required_sections_and_no_placeholders() -> None:
text = _text(rel) text = _text(rel)
assert "${" not in text, rel assert "${" not in text, rel
data = _text("docs/data-model.md") data = _text("docs/data-model.md")
for marker in ("## 목적과 범위", "## 정본 엔터티", "## 관계와 불변조건", "## 저장과 마이그레이션", "schema 12", "account-valuations", "asset-snapshot-runs"): 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 assert marker in data
interface = _text("docs/interface-contract.md") interface = _text("docs/interface-contract.md")
for marker in ("## 목적과 범위", "## 계약", "## 호환성", "## 예시", "## 검증", "REQ-003", "register_account_valuation", "preview_asset_snapshot_close", "conditional_approval_required"): for marker in ("## 목적과 범위", "## 계약", "## 호환성", "## 예시", "## 검증", "REQ-003", "register_account_valuation", "preview_asset_snapshot_close", "conditional_approval_required"):
@@ -115,3 +135,200 @@ def test_existing_auth_menu_evidence_inventory_is_preserved() -> None:
"streamlit_browser_evidence.py", "auth-before-DB", "cookie/cleanup/logout", "streamlit_browser_evidence.py", "auth-before-DB", "cookie/cleanup/logout",
): ):
assert marker in interface + runtime + structure, marker 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
+1 -1
View File
@@ -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)
+9 -9
View File
@@ -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):