From 6a8a27a74ece9a9cce6f9772259677813d91ddf8 Mon Sep 17 00:00:00 2001 From: gitea-impl-agent Date: Tue, 6 Oct 2026 14:43:29 +0000 Subject: [PATCH] fix: address account valuation review findings --- dashboard/app.py | 5 + dashboard/page_views/assets.py | 37 +- dashboard/page_views/monthly_settlement.py | 34 +- docs/data-model.md | 10 +- docs/interface-contract.md | 37 ++ docs/project-structure.md | 44 ++ .../req-003_account-valuation-history.md | 27 +- docs/runtime-flow.md | 23 ++ .../adapters/streamlit/overview_gateway.py | 4 +- .../application/account_valuation_service.py | 68 ++- .../application/assets_service.py | 389 +++++++++++++++--- .../application/categorization_services.py | 22 +- .../application/cross_transfer_service.py | 9 +- .../application/imports_service.py | 42 +- .../application/monthly_settlement_service.py | 9 +- .../application/reconciliation_service.py | 77 +++- .../application/review_decision_service.py | 11 +- jarca_ledger_mcp/application/shared.py | 16 + .../application/system_services.py | 14 +- .../transaction_report_services.py | 22 +- .../uncategorized_workbench_service.py | 13 +- .../infrastructure/sqlite/__init__.py | 6 + .../infrastructure/sqlite/connection.py | 30 ++ tests/test_dashboard_app.py | 89 +++- tests/test_dashboard_menu_transition.py | 2 + tests/test_import_provenance_policy.py | 5 + tests/test_issue_12_regressions.py | 123 +++++- tests/test_issue_12_review59_account.py | 190 +++++++++ tests/test_issue_12_review59_readonly.py | 166 ++++++++ tests/test_issue_12_review59_reports.py | 306 ++++++++++++++ tests/test_requirement_docs.py | 219 +++++++++- tests/test_review_decision_workbench.py | 2 +- tests/test_seoulpay_xlsx.py | 2 + tests/test_service.py | 18 +- 34 files changed, 1940 insertions(+), 131 deletions(-) create mode 100644 tests/test_issue_12_review59_account.py create mode 100644 tests/test_issue_12_review59_readonly.py create mode 100644 tests/test_issue_12_review59_reports.py diff --git a/dashboard/app.py b/dashboard/app.py index 6b231cf..22a3fbf 100644 --- a/dashboard/app.py +++ b/dashboard/app.py @@ -4,6 +4,7 @@ import sys from pathlib import Path 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 # resolve identically to package imports used by tests. @@ -38,7 +39,11 @@ def main() -> None: return 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() + if not db_status.get("success"): + st.error("Ledger DB를 사용할 수 없습니다: " + db_status.get("error", "ledger database is unavailable")) + return pages = ["월정산", "요약", "자산/마감", "거래내역", "확인필요 큐", "미분류", "분류 규칙", "카테고리", "계좌", "Import"] query_page = st.query_params.get("page") page = validate_dashboard_page(query_page, pages) diff --git a/dashboard/page_views/assets.py b/dashboard/page_views/assets.py index 51a11d5..0a0c63b 100644 --- a/dashboard/page_views/assets.py +++ b/dashboard/page_views/assets.py @@ -7,8 +7,39 @@ import pandas as pd import streamlit as st 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 + +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: 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["bucket_label"] = df["liquidity_bucket"].map(LIQUIDITY_BUCKET_LABELS).fillna(df["liquidity_bucket"]) df["valuation_display"] = df["valuation_amount"].map(format_krw) - st.dataframe(df[["name", "active", "bucket_label", "valuation_display", "valuation_date", "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"): asset_options = {f"{a['name']} ({a['id']})": a for a in assets} 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]) 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)") + snapshot_report = overview_gateway.get_asset_snapshot_report(basis_date, selection_policy=selection_policy, db_path=db_path) + if snapshot_report.get("success"): + _render_asset_evidence(snapshot_report) close_note = c3.text_input("Close 출처 메모", value="month-end close") correction_reason = c4.text_input("정정 사유(재마감 시 필수)", value="") p1, p2 = st.columns(2) @@ -110,6 +144,7 @@ def page_assets(db_path: str) -> None: st.dataframe(pd.DataFrame(preview.get("included_items", [])), width="stretch", height=220) if preview.get("warnings"): st.warning(preview.get("warnings")) + _render_asset_evidence(preview) else: st.error(preview.get("error", preview)) if close_res: diff --git a/dashboard/page_views/monthly_settlement.py b/dashboard/page_views/monthly_settlement.py index 6a3fb8f..1eb10c1 100644 --- a/dashboard/page_views/monthly_settlement.py +++ b/dashboard/page_views/monthly_settlement.py @@ -11,6 +11,32 @@ from jarca_ledger_mcp.adapters.streamlit import overview_gateway from jarca_ledger_mcp.adapters.streamlit import reconciliation_gateway 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: query_month = st.query_params.get("month", current_kst_month()) if isinstance(query_month, list): @@ -177,6 +203,7 @@ def page_monthly_settlement(db_path: str) -> None: st.markdown( item_line, ) + st.markdown(f"상태: {reconciliation_status_label.get(raw_status, '정상' if idx == 1 else raw_status)}") else: st.caption("잔액대조 대상 계좌가 현재 없습니다.") @@ -187,7 +214,11 @@ def page_monthly_settlement(db_path: str) -> None: if asset_snapshot.get("success"): latest_asset_status = "마감 완료" if bool(asset_snapshot.get("is_closed_snapshot")) else "미마감" warnings = asset_snapshot.get("warnings") if isinstance(asset_snapshot, dict) else [] - missing_valuation_items = asset_snapshot.get("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 = "경고 없음" if warnings or missing_valuation_items: 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) st.caption(f"자산 상세: {warning_text}") st.caption(f"마감 기준일: {asset_snapshot.get('basis_date', basis_date)} · 선택 정책: {asset_snapshot.get('selection_policy', 'exact')} · close_status: {asset_snapshot.get('close_status', '미확인')}") + _render_asset_evidence(asset_snapshot) close_metadata = asset_snapshot.get("close_metadata", {}) if isinstance(asset_snapshot, dict) else {} if close_metadata.get("approved_by") or close_metadata.get("approval_reason"): st.caption(f"조건부 승인: {close_metadata.get('approved_by', '-')} · {close_metadata.get('approval_reason', '-')} · {close_metadata.get('approval_reference', '-')}") diff --git a/docs/data-model.md b/docs/data-model.md index 405a532..593830f 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -31,7 +31,7 @@ ### account-valuations — 기준일 계좌 평가 -- 책임: 날짜가 지정된 평가 evidence와 선택/correction provenance +- 책임: 날짜가 지정된 평가 evidence와 선택/correction provenance. investment `principal`은 reported principal amount이고 loan `principal`은 outstanding principal이다. - 식별자: `id` - 생명 주기: 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. -- only verified leaves are authority; unknown/future/unverified are excluded or warning evidence. -- preview does not insert; close inserts run and rows atomically; latest run is report authority. +- `idx_account_valuations_single_correction` ensures one direct child; correction parent scope is same account/date/currency/purpose. Cyclic or self parent, second child, missing reason, and conflict are no-write failures. +- `source_ref` is account-scoped evidence identity even when an explicit key is supplied; key/source mismatch conflicts. This is evidence idempotency. There is no generic label; only source-declared `reported_principal_kind` and `reported_principal_label` are physical fields. +- only verified leaves are authority; unknown/future/unverified are excluded or warning evidence. `last_balance` is external current comparison, not opening evidence; missing balance_after/opening evidence is `INCOMPLETE_CALCULATION`. +- preview does not insert; close inserts run and rows atomically; latest run is report authority. Reports expose actual valuation_date, age, carry, verification, run, approval, correction, warnings, and exclusions across close response, live, closed, timeseries, and monthly. +- `monthly_source_snapshot_id` is a read-model alias of `snapshot_run_id`, not a persistence field. Investment unavailable and unavailable investment, loan purpose/definition/ambiguity, and reconciliation completeness remain explicit; no fake writes. - `asset_buckets - loan` is the net-worth formula; representative loan only, no fake transactions. ## 저장과 마이그레이션 diff --git a/docs/interface-contract.md b/docs/interface-contract.md index 07f6048..770d531 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -386,3 +386,40 @@ preview_asset_snapshot_close(basis_date="2026-06-30", selection_policy="exact") - 실패 계약: idempotency/correction/conditional approval tests and source validation - 경계 계약: exact/as_of, NULL/0, future/unverified candidate, correction/re-close - 구현·스키마·예시 정합성: `tests/test_requirement_docs.py`, actual service/MCP/gateway symbols + + +### canonical implementation signatures and fields + +The application contracts are these actual signatures (defaults included): + +```python +register_account_valuation(account_id: str, basis_date: str, amount: int, currency: str = "KRW", valuation_kind: str = "balance", source_type: str = "manual", source_ref: str = "", verification_status: str = "unverified", memo: str = "", idempotency_key: str = "", as_of_at: str | None = None, timezone: str | None = None, cost_basis: int | None = None, net_contributed: int | None = None, reported_principal_kind: str | None = None, reported_principal_label: str | None = None, profit: int | None = None, return_rate: float | None = None, return_basis: str | None = None, principal: int | None = None, interest: int | None = None, fee: int | None = None, representative_liability_definition: str | None = None, db_path: str | None = None, supersedes_id: str | None = None, correction_reason: str = "") -> dict[str, Any] +list_account_valuations(account_id: str, include_rejected: bool = True, db_path: str | None = None) -> dict[str, Any] +select_account_valuation(account_id: str, basis_date: str, as_of: bool = False, currency: str | None = None, valuation_kind: str | None = None, db_path: str | None = None) -> dict[str, Any] +correct_account_valuation(valuation_id: str, amount: int, correction_reason: str, idempotency_key: str = "", db_path: str | None = None, **fields: Any) -> dict[str, Any] +preview_asset_snapshot_close(basis_date: str = "", db_path: str | None = None, selection_policy: str = "exact") -> dict[str, Any] +close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str = "", correction_reason: str = "", db_path: str | None = None, selection_policy: str = "exact", idempotency_key: str = "", allow_missing_exclusions: bool = False, allow_as_of_carry_forward: bool = False, approved_by: str = "", approval_reason: str = "", approval_reference: str = "", preview_fingerprint: str = "") -> dict[str, Any] +get_asset_snapshot_report(basis_date: str = "", db_path: str | None = None, selection_policy: str = "exact") -> dict[str, Any] +``` + +Every output is a dict with `success`; failures also have `error_code` and `error`. Successful close responses expose top-level `approval`, `correction`, `warnings`, `excluded_items`, and `carried_items`, plus `snapshot_run_id` and `close_status`. Their nested `provenance` contains `valuation_date`, `age_days`, `carried_forward`, `verification_status`, `evidence_kind`, `approval`, `correction`, `warnings`, and redacted `exclusions`. `last_balance` is an external current comparison, not opening evidence; absent balance_after/opening evidence returns `INCOMPLETE_CALCULATION`. `source_ref` is account-scoped evidence identity even with an explicit key; key/source mismatch is a no-write conflict. Correction requires same parent account/date/currency/purpose, rejects cyclic/self parent, permits one child, and requires reason. + +### canonical read and provenance rules + +`monthly_source_snapshot_id` is a read-model alias of `snapshot_run_id`, not a persistence field. The alias and actual valuation date/age/carry/verification/run/approval/correction/warnings/exclusions are exposed through each response's documented top-level fields, `included_items`, and nested `provenance` in close, live, closed, timeseries, and monthly read models. Reconciliation states investment unavailable, loan purpose/definition/ambiguity, and completeness without fake writes. + +### C13 database boundary + +Writer/bootstrap explicitly calls `init_db()`. `read_db()` uses SQLite `mode=ro`; missing database returns `db_not_initialized`, while initialized database read failures return `db_error`. Both failures retain the standard `success=false`, `error_code`, `error` envelope without raw SQLite details. Read paths perform no mkdir, no migrate, and no commit. Only first-use write initializes, and Dashboard auth is evaluated before DB access. + +## D04 snapshot close output contract + +Every successful snapshot close output has these exact top-level fields (the transport envelope may additionally include `success`): `snapshot_run_id`, `close_status`, `approval`, `correction`, `warnings`, `excluded_items`, `carried_items`, `provenance`, `valuation_date`, `age_days`, `verification_status`, `evidence_kind`, and `monthly_source_snapshot_id`. `monthly_source_snapshot_id` is an alias of `snapshot_run_id`, not a persistence field. + +The top-level `provenance` object has these exact nested `provenance` fields: `valuation_date`, `age_days`, `carried_forward`, `verification_status`, `evidence_kind`, `approval`, `correction`, `warnings`, `excluded_items`, and `carried_items`. `approval` and `correction` are objects or explicit empty values when not applicable; warnings and item collections are arrays. `valuation_date` is the actual selected evidence date, `age_days` is computed from the requested basis date, and verification/evidence status describe selected evidence. Live, closed, timeseries, and monthly read models preserve these fields and alias semantics. + +The preview input includes basis_date, selection_policy, included/excluded evidence state hashes, warnings, and candidate_state_hashes. preview output includes preview_fingerprint. Close accepts the exact `preview_fingerprint: str = ""` parameter shown in the signature block and recomputes the same authority fingerprint before opening the write boundary. A mismatch returns `authority_drift` and no partial write. + +Replay uses replay payload equality: an existing idempotency key replays only when the persisted request fingerprint is equal; a changed payload returns `idempotency_conflict`. The replay response is reconstructed from the persisted run and its evidence, with no replay-only reused fields. + +`close_metadata` is a recursive close metadata allowlist: only run, approval, correction, warnings, exclusions, carried evidence, and allowlisted evidence fields (`source_type`, `source_id`, `authority_source`, `valuation_id`, dates/statuses and `state_hash`) cross the boundary. `applied_evidence`, `excluded_evidence`, and `carried_evidence` are redacted recursively; unknown nested keys are dropped, and credentials, tokens, cookies, raw account data, and arbitrary source-note payloads are never returned. This is the recursive close_metadata allowlist and redaction contract. diff --git a/docs/project-structure.md b/docs/project-structure.md index 14b52fb..7278dbf 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -166,3 +166,47 @@ jarca_ledger_mcp/ ## REQ-003 preservation inventory REQ-001 auth gate, cookie/cleanup/logout and auth-before-DB; REQ-002 menu labels/order, query sibling preservation, stable shell/background and browser evidence remain in the preceding sections. Missing semantic items: 0. Ambiguous semantic items: 0. + + +### REQ-003 canonical module ownership and regression map + +| 경로 | 책임·실패 경계 | +|---|---| +| `jarca_ledger_mcp/application/account_valuation_service.py` | register/list/select/correct; principal mapping, source_ref identity, parent scope, idempotency and no-write conflicts | +| `jarca_ledger_mcp/application/assets_service.py` | preview/close/report; valuation_date/age/carry, approval, correction, warnings/exclusions and atomic run | +| `jarca_ledger_mcp/application/asset_account_services.py` | shared account/asset evidence and reconciliation boundary | +| `jarca_ledger_mcp/infrastructure/sqlite/connection.py` | `read_db()` mode=ro and `db_not_initialized` without mkdir/migrate/commit | +| `jarca_ledger_mcp/infrastructure/sqlite/migrations.py` | explicit `init_db()` and additive migration | +| `jarca_ledger_mcp/application/monthly_settlement_service.py` | monthly read model; `monthly_source_snapshot_id` alias only, never persistence | + +Named transitions are `valuation-01`→`valuation-02`→`valuation-03`→`valuation-04`, `close-01`→`close-02`→`close-03`→`close-04`, `prop-01`→`prop-02`→`prop-03`, `db-init-01`, `db-read-01`, `db-read-02`, and `write-first-use-01`; named failures are `valuation-fail`, `close-fail`, `prop-fail`, `db_not_initialized`, `conditional_approval_required`, `idempotency_conflict`, and `correction_conflict`. `auth-before-DB` precedes all reads. + +Regression tests are `test_valuation_round_trip_preserves_null_zero_and_physical_amount_mapping`, `test_source_ref_is_account_scoped_evidence_identity_without_explicit_key`, `test_direct_correction_requires_matching_existing_parent_and_reason`, `test_investment_without_verified_valuation_and_without_opening_balance_is_incomplete`, `test_timeseries_and_monthly_expose_canonical_asset_provenance`, and `test_read_queries_missing_nested_db_return_stable_error_without_creating_path`. D06 requires these names and actual paths/signatures, not marker prose. + +## D01 preservation matrix + +| item | source evidence | test evidence | +|---|---|---| +| schema 11 legacy rows | `infrastructure/sqlite/migrations.py`: additive schema 12 migration | `tests/test_issue_12_regressions.py::test_schema12_migration_preserves_legacy_rows_indexes_and_is_idempotent` | +| auth gate and cookie lifecycle | `dashboard/server.py`: login, cleanup, logout, auth-before-DB gate | `tests/test_dashboard_authentication.py`, `tests/test_dashboard_persistent_authentication.py` | +| REQ-002 menu labels/order | `dashboard/common.py`: canonical 10-option menu and query transition | `tests/test_dashboard_menu_transition.py` | +| MCP envelope and tools | `adapters/mcp/server.py`: shared success/error response envelope and named tools | `tests/test_contract_regression.py` | +| reconciliation and monthly read models | `application/reconciliation_service.py`, `application/monthly_settlement_service.py`: read-only propagation | `tests/test_issue_12_review59_reports.py`, `tests/test_service.py::test_monthly_settlement_summary_read_model_foundation` | + +Each row is an independently reviewable preservation claim: the source column names the canonical implementation boundary and the test column names executable evidence. A prose inventory without both references is not evidence. + +## D05 canonical ownership + +| concern | owner | boundary | +|---|---|---| +| monthly settlement | `monthly_settlement_service.py` | read-only monthly read model; `monthly_source_snapshot_id` aliases `snapshot_run_id`, never persistence | +| reconciliation | `reconciliation_service.py` | computes completeness/status and provenance; never creates fake transactions | +| policy | `assets_service.py` | owns exact/as-of, approval, exclusion/carry, correction and drift policy decisions | +| read-only | `assets_service.py` preview/report path | preview is no write: no insert, migrate, mkdir, or commit; explicit close is the write boundary | +| redaction | adapters/report serializers | only redacted evidence crosses output boundary; no credential/token/cookie/raw account data | + +The owner is the canonical decision or write boundary; consumers render or read its result and do not reimplement policy. + +The Dashboard gateway is `jarca_ledger_mcp/adapters/streamlit/overview_gateway.py` (including `get_asset_snapshot_report` and reconciliation/monthly read delegates). It calls shared application read services after `auth-before-DB`; page rendering is not an alternate policy or persistence boundary. A missing DB has stable unavailable semantics (`db_not_initialized`) and no-create behavior. The missing DB stable unavailable contract is no-create. An initialized DB read failure is `db_error`; initialized read has no write, migration, mkdir, or commit. The initialized DB read failure is db_error and initialized read has no write. + +Reconciliation ownership remains in `reconciliation_service.py`: opening evidence and transaction coverage are required inputs. Missing opening evidence or incomplete coverage produces `INCOMPLETE_CALCULATION`/`reconciliation unavailable` with provenance, never an inferred balance or fake transaction. diff --git a/docs/requirements/req-003_account-valuation-history.md b/docs/requirements/req-003_account-valuation-history.md index 9b533c2..815a35c 100644 --- a/docs/requirements/req-003_account-valuation-history.md +++ b/docs/requirements/req-003_account-valuation-history.md @@ -42,8 +42,10 @@ - **verified leaf**: `supersedes_id`로 더 최신 child가 없는 `verification_status=verified` 행. - **exact**: 요청 `basis_date`와 일치하는 verified leaf만 선택한다. -- **as_of**: 미래를 보지 않고 요청일 이전 최신 verified leaf를 선택하며 carry-forward를 표시한다. -- **대표 부채**: `valuation_kind`가 `loan`/`loan_balance`이고 `representative_liability_definition`이 `principal_only` 또는 `principal_interest_fees`인 하나의 대표 금액만 합산한다. +- **as_of**: 미래를 보지 않고 요청일 이전 최신 verified leaf를 선택하며 carry-forward와 age를 표시한다. +- **last_balance**: opening evidence가 아니라 외부 현재값과 비교하는 external current comparison이다. opening evidence와 balance_after가 모두 없으면 `INCOMPLETE_CALCULATION`이다. +- **principal 물리 필드**: `principal`은 investment의 reported principal amount 또는 loan의 outstanding principal이며, `reported_principal_kind`와 `reported_principal_label`만 source-declared 의미를 보존한다. generic label 필드는 없다. +- **대표 부채**: `valuation_kind`가 `loan`/`loan_balance`이고 `representative_liability_definition`이 `principal_only` 또는 `principal_interest_fees`인 하나의 대표 금액만 합산한다. loan purpose, definition, ambiguity는 report에 보존한다. - **정본 구현**: `jarca_ledger_mcp/application/account_valuation_service.py`, `assets_service.py`, `reconciliation_service.py`, `migrations.py` 및 named tests. ## 5. 요구사항 @@ -74,7 +76,7 @@ exact/as_of 선택은 account/currency/valuation_kind scope를 적용하고 futu ### 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()` @@ -98,7 +100,7 @@ amount와 principal/interest/fee/cost_basis/net_contributed/profit은 exact inte ### 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 @@ -110,7 +112,7 @@ current latest display와 historical authority를 분리한다. 늦게 도착한 ### 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 @@ -134,7 +136,7 @@ preview는 DB를 쓰지 않고 `complete_exact`, `incomplete_draft`, `conditiona ### 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()` @@ -144,6 +146,8 @@ investment가 verified valuation으로 관리되면 reconciliation status는 `VA - 호환성: existing reconciliation statuses and reports additive - 배포 전제 조건: service read model available +canonical D04-D06 contract: snapshot close aliases `valuation_date`, `age_days`, `verification_status`, `evidence_kind`, and `monthly_source_snapshot_id` at the top level while preserving the same meanings in nested provenance. Replay requires request-fingerprint equality and reconstructs from persisted evidence, never replay-only reused fields. Recursive `close_metadata` is allowlisted/redacted. Preview fingerprint input/output and authority drift rejection are mandatory. Dashboard missing DB is stable unavailable with no-create; initialized reads are no-write. Reconciliation requires opening evidence and coverage; incomplete state is `INCOMPLETE_CALCULATION`/`reconciliation unavailable`, with no inferred balance or fake transaction. + ### REQ-003.09 — shared application과 보안 경계 MCP와 Dashboard는 shared service/gateway를 사용한다. 기존 success/error envelope를 유지한다. Dashboard는 auth-before-DB이며 MCP auth를 claim하지 않는다. render는 read-only이고 implicit write를 일으키지 않는다. @@ -191,6 +195,7 @@ MCP와 Dashboard는 shared service/gateway를 사용한다. 기존 success/error **판정 기준**: `schema_version=12`, required tables/columns/indexes exist and old rows remain. **검증**: `tests/test_account_valuations.py::test_account_valuation_schema_is_v12_and_idempotent`, `tests/test_issue_12_regressions.py::test_schema12_migration_preserves_legacy_rows_indexes_and_is_idempotent`. +**검증 분류**: level=integration; backend=sqlite ### REQ-003.02-AC01 — selection policy @@ -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. **검증**: `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 @@ -211,6 +217,7 @@ MCP와 Dashboard는 shared service/gateway를 사용한다. 기존 success/error **판정 기준**: exact error codes and count 2 after one correction. **검증**: `tests/test_account_valuations.py::test_register_same_idempotency_payload_reuses_row_and_changed_payload_conflicts`, `tests/test_account_valuations.py::test_correction_is_append_only_reasoned_and_single_child`, `tests/test_issue_12_regressions.py::test_empty_conditional_close_is_closed_and_second_close_requires_correction`. +**검증 분류**: level=integration; backend=sqlite ### REQ-003.04-AC01 — precision and representative loan @@ -221,6 +228,7 @@ MCP와 Dashboard는 shared service/gateway를 사용한다. 기존 success/error **판정 기준**: optional fields round-trip exactly and loan definition validation is enforced. **검증**: `test_correction_preserves_optional_fields_unless_overridden`, `_validate_common()` and schema static checks. +**검증 분류**: level=static; backend=sqlite ### REQ-003.05-AC01 — authority separation @@ -231,6 +239,7 @@ MCP와 Dashboard는 shared service/gateway를 사용한다. 기존 success/error **판정 기준**: dated account write creates only unverified candidate; name-only does not. **검증**: `test_account_balance_write_records_unverified_candidate_only_with_real_basis_date`, `test_account_name_only_update_does_not_create_candidate`. +**검증 분류**: level=unit; backend=application ### REQ-003.06-AC01 — preview and approval @@ -241,6 +250,7 @@ MCP와 Dashboard는 shared service/gateway를 사용한다. 기존 success/error **판정 기준**: preview is read-only and close returns `conditional_approval_required` without metadata. **검증**: `test_manual_asset_update_deactivate_preview_and_close_correction_policy`, `preview_asset_snapshot_close()`. +**검증 분류**: level=integration; backend=application ### REQ-003.07-AC01 — immutable close correction @@ -251,6 +261,7 @@ MCP와 Dashboard는 shared service/gateway를 사용한다. 기존 success/error **판정 기준**: `correction_of_run_id`, count, and net worth match. **검증**: `test_manual_asset_update_deactivate_preview_and_close_correction_policy`, `assets_service.py` close path. +**검증 분류**: level=integration; backend=sqlite ### REQ-003.08-AC01 — reconciliation/monthly @@ -261,6 +272,7 @@ MCP와 Dashboard는 shared service/gateway를 사용한다. 기존 success/error **판정 기준**: status `VALUATION_MANAGED` for verified investment and monthly summary reads report. **검증**: `tests/test_service.py::test_statement_status_account_update_and_balance_reconciliation`, `tests/test_issue_12_regressions.py::test_reconciliation_uses_corrected_investment_leaf_without_transaction_count_change`, `tests/test_service.py::test_monthly_settlement_summary_read_model_foundation`. +**검증 분류**: level=unit; backend=application ### REQ-003.09-AC01 — shared adapters and auth @@ -271,6 +283,7 @@ MCP와 Dashboard는 shared service/gateway를 사용한다. 기존 success/error **판정 기준**: actual tool/gateway names resolve and existing auth/menu tests pass. **검증**: `tests/test_mcp_http_runtime.py`, `tests/test_dashboard_authentication_app.py`, `tests/test_dashboard_menu_transition.py`, `server.py` and gateways. +**검증 분류**: level=integration; backend=dashboard ### REQ-003.10-AC01 — privacy and compatibility @@ -281,6 +294,7 @@ MCP와 Dashboard는 shared service/gateway를 사용한다. 기존 success/error **판정 기준**: semantic inventory missing=0 and ambiguous=0; placeholder=0. **검증**: `tests/test_requirement_docs.py`, auth/menu evidence tests, repository redaction/static search. +**검증 분류**: level=static; backend=dashboard ## 8. 오류 및 충돌 계약 @@ -300,3 +314,4 @@ REQ-001의 auth gate·cookie/cleanup/logout, REQ-002의 메뉴 10개와 order(` - 구현 PR: `#13` - 관련 설계 문서: `docs/data-model.md`, `docs/interface-contract.md`, `docs/project-structure.md`, `docs/runtime-flow.md` - 관련 요구사항: `REQ-001`, `REQ-002` +- DB lifecycle: writer/bootstrap은 명시적 `init_db()` 호출로만 생성·마이그레이션하고, `read_db()`는 SQLite `mode=ro`로 연다. missing DB는 `db_not_initialized`이며 read path는 no mkdir, no migrate, no commit이다. first-use write만 init_db를 수행하고 auth-before-DB 순서를 보존한다. diff --git a/docs/runtime-flow.md b/docs/runtime-flow.md index 1150434..c534272 100644 --- a/docs/runtime-flow.md +++ b/docs/runtime-flow.md @@ -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 - 동시성 흐름: SQLite unique/idempotency and transaction rollback review - 운영 진입점과 기대 결과: Dashboard authenticated `자산/마감`/`계좌`, MCP tools; preview leaves row count unchanged, explicit close returns run id + + +### canonical transition and failure semantics + +`valuation-02` validates principal/interest/fee, source_ref account-scoped identity, parent scope, cyclic/self parent, one child, reason, and key/source mismatch before `valuation-03`; each failure is no-write with `validation_error`, `idempotency_conflict`, or `correction_conflict`. `close-01` computes actual valuation_date, age, carry, verification, reconciliation completeness, investment unavailable, and loan purpose/definition/ambiguity. `close-02` returns live authority, `close_status`, warnings, and exclusions without writing; `close-03` records explicit run/approval/correction; `close-04` atomically persists immutable rows. Drift, missing approval, unsafe evidence, and SQLite failure transition to `close-fail` with no partial write. + +The close response exposes top-level run, approval, correction, warnings, `excluded_items`, and `carried_items`; its nested provenance exposes actual valuation date, age, carry, verification, approval/correction, warnings, and redacted exclusions. Live/closed/timeseries/monthly read models propagate the same meanings in their documented top-level and nested provenance fields. `monthly_source_snapshot_id` is a read-model alias of `snapshot_run_id`, not a persistence field. `last_balance` is external current comparison rather than opening evidence; missing balance_after/opening evidence transitions to `INCOMPLETE_CALCULATION`. No fake writes are made for unavailable investment or valuation-managed reconciliation. + +### C13 database lifecycle transitions + +`auth-before-DB` is the first Dashboard boundary. `write-first-use-01` explicitly calls `init_db()` and may migrate. `db-read-01` calls `read_db()` in SQLite `mode=ro`; a missing database becomes `db_not_initialized`. `db-read-02` succeeds without mkdir, migrate, or commit. An initialized database's schema or SQLite read failure becomes generic `db_error`, never `db_not_initialized`, and raw engine details are not exposed. + +## D06 runtime decision guards + +- **Current-cache no rollback:** a late historical valuation may update historical authority but cannot roll back the newer current cache (`last_balance`); current display and historical basis selection are separate decisions. +- **Reconciliation decision:** return `reconciliation complete` only when required ledger/evidence inputs are complete. If required evidence is absent or unreadable, return `reconciliation unavailable` with provenance and do not infer a balance or create a transaction. +- **Preview drift revalidation:** preview is read-only and records the authority/evidence fingerprint observed. Before close writes, revalidate current authority, approval policy, exclusions, carried items, and the preview fingerprint. Drift routes back to preview/review with a stale-preview or conflict decision and no partial write; only a revalidated preview reaches `close-04`. + +The fingerprint input is exactly basis date, selection policy, included and excluded evidence state hashes, warnings (excluding the informational existing-run warning), and candidate state hashes. The preview returns `preview_fingerprint`; close recomputes it before any insert. A changed authority returns `authority_drift`, and the transaction produces no partial write. + +An idempotent close is replayed only for equal request fingerprints. Replay reconstructs the response from the persisted run/evidence (no replay-only reused payload), while a different payload returns `idempotency_conflict`. Recursive close metadata is allowlisted and redacted before it reaches Dashboard/MCP. + +Dashboard missing-DB reads return stable unavailable `db_not_initialized` without create/migrate/commit. The missing DB stable unavailable contract is no-create. Initialized read failures return `db_error`; successful initialized reads are read-only. The initialized DB read failure is db_error and initialized read has no write. Reconciliation requires opening evidence and coverage through the requested basis; otherwise it reports `INCOMPLETE_CALCULATION`/`reconciliation unavailable` with provenance, no inferred balance, and no fake transaction. diff --git a/jarca_ledger_mcp/adapters/streamlit/overview_gateway.py b/jarca_ledger_mcp/adapters/streamlit/overview_gateway.py index af4940f..6011aca 100644 --- a/jarca_ledger_mcp/adapters/streamlit/overview_gateway.py +++ b/jarca_ledger_mcp/adapters/streamlit/overview_gateway.py @@ -6,7 +6,8 @@ from ...service import ( get_budget_vs_actual_report as get_budget_vs_actual_report, get_monthly_settlement_summary as get_monthly_settlement_summary, 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_ledger_backups as list_ledger_backups, list_ledger_import_batches as list_ledger_import_batches, @@ -20,6 +21,7 @@ __all__ = [ "get_budget_vs_actual_report", "get_monthly_settlement_summary", "get_monthly_cashflow_report", + "get_ledger_db_status", "health_check", "list_cross_transfer_review_queue", "list_ledger_backups", diff --git a/jarca_ledger_mcp/application/account_valuation_service.py b/jarca_ledger_mcp/application/account_valuation_service.py index 88ab185..2b19d19 100644 --- a/jarca_ledger_mcp/application/account_valuation_service.py +++ b/jarca_ledger_mcp/application/account_valuation_service.py @@ -6,7 +6,7 @@ import re from typing import Any 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_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") except (TypeError, ValueError) as exc: return fail("validation_error", f"invalid as_of_at: {exc}") + if supersedes_id and not correction_reason.strip(): + return fail("validation_error", "correction_reason is required") ts = now_iso() - 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: conn.execute("BEGIN IMMEDIATE") account = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone() @@ -186,16 +189,47 @@ def register_account_valuation( return fail("validation_error", "loan valuation kind requires a loan account") if definition is not None and str(account["type"]).lower() != "loan": return fail("validation_error", "representative liability definition requires a loan account") - if idempotency_key: - existing = conn.execute("SELECT * FROM account_valuations WHERE account_id=? AND idempotency_key=?", (account_id, idempotency_key)).fetchone() - if existing: - current = _valuation(existing) - incoming = dict(zip(_COLUMNS, values)) - incoming.update({"account_id": account_id, "basis_date": basis, "supersedes_id": supersedes_id, "correction_reason": correction_reason or ""}) - comparable = set(incoming) - {"id", "recorded_at"} - if all(current.get(k) == v for k, v in incoming.items() if k in comparable): - return ok(valuation=current, reused=True) - return fail("idempotency_conflict", "idempotency_key already has a different payload", valuation=current) + seen: set[str] = set() + if supersedes_id: + parent = conn.execute("SELECT * FROM account_valuations WHERE id=?", (supersedes_id,)).fetchone() + if parent is None: + if idempotency_key and conn.execute("SELECT 1 FROM account_valuations WHERE account_id=? AND idempotency_key=?", (account_id, idempotency_key)).fetchone(): + return fail("idempotency_conflict", "idempotency_key already has a different payload") + return fail("validation_error", "supersedes_id must reference an existing valuation") + if any(parent[field] != value for field, value in ( + ("account_id", account_id), ("basis_date", basis), + ("currency", currency_norm), ("valuation_kind", (valuation_kind or "balance").strip().lower()), + )): + return fail("validation_error", "superseded valuation scope must match account, basis, currency, and valuation kind") + seen: set[str] = set() + cursor = parent + while cursor is not None: + cursor_id = cursor["id"] + if cursor_id in seen: + return fail("validation_error", "supersedes ancestry is cyclic") + seen.add(cursor_id) + next_id = cursor["supersedes_id"] + cursor = conn.execute("SELECT * FROM account_valuations WHERE id=?", (next_id,)).fetchone() if next_id else None + existing = None + by_key = conn.execute("SELECT * FROM account_valuations WHERE account_id=? AND idempotency_key=?", (account_id, idempotency_key)).fetchone() if idempotency_key else None + source_rows = conn.execute("SELECT * FROM account_valuations WHERE account_id=? AND source_ref=? ORDER BY recorded_at, id", (account_id, source_ref_norm)).fetchall() if source_ref_norm else [] + correction_chain = seen if supersedes_id else set() + unrelated_source = next((row for row in source_rows if row["id"] not in correction_chain and (by_key is None or row["id"] != by_key["id"])), None) + if by_key is not None and unrelated_source is not None: + return fail("idempotency_conflict", "idempotency_key and source_ref identify different candidates") + if unrelated_source is not None: + by_source = unrelated_source + else: + by_source = None + existing = by_key or by_source + if existing: + current = _valuation(existing) + incoming = dict(zip(_COLUMNS, values)) + incoming.update({"account_id": account_id, "basis_date": basis, "supersedes_id": supersedes_id, "correction_reason": correction_reason or ""}) + comparable = set(incoming) - {"id", "recorded_at"} + if all(current.get(k) == v for k, v in incoming.items() if k in comparable): + return ok(valuation=current, reused=True) + return fail("idempotency_conflict", "idempotency_key already has a different payload", valuation=current) valuation_id = new_id("valuation") try: conn.execute("""INSERT INTO account_valuations @@ -211,20 +245,22 @@ def register_account_valuation( return ok(valuation=_valuation(row), reused=False) +@read_operation def list_account_valuations(account_id: str, include_rejected: bool = True, db_path: str | None = None) -> dict[str, Any]: where = "" if include_rejected else " AND verification_status <> 'rejected'" - with 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() values = [_valuation(row) for row in rows] return ok(account_id=account_id, valuations=values, count=len(values)) +@read_operation def select_account_valuation(account_id: str, basis_date: str, as_of: bool = False, currency: str | None = None, valuation_kind: str | None = None, db_path: str | None = None) -> dict[str, Any]: try: basis = normalize_date(basis_date) except Exception as exc: return fail("validation_error", str(exc)) - with db(db_path) as conn: + with read_db(db_path) as conn: clauses = ["account_id=?"] params: list[Any] = [account_id] 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() if source is None: return fail("not_found", "valuation not found") + if "currency" in fields and fields["currency"] is not None and fields["currency"].strip().upper() != source["currency"].upper(): + return fail("validation_error", "correction currency must match parent valuation") + if "valuation_kind" in fields and fields["valuation_kind"] is not None and fields["valuation_kind"].strip().lower() != source["valuation_kind"]: + return fail("validation_error", "correction valuation_kind must match parent valuation") preserved = {name: fields[name] if name in fields else source[name] for name in ( "currency", "valuation_kind", "source_type", "source_ref", "verification_status", "memo", "as_of_at", "timezone", "cost_basis", "net_contributed", "reported_principal_kind", "reported_principal_label", "profit", "return_rate", "return_basis", "principal", "interest", "fee", "representative_liability_definition")} diff --git a/jarca_ledger_mcp/application/assets_service.py b/jarca_ledger_mcp/application/assets_service.py index 319fed4..5fb1a4f 100644 --- a/jarca_ledger_mcp/application/assets_service.py +++ b/jarca_ledger_mcp/application/assets_service.py @@ -13,6 +13,8 @@ from .shared import ( current_kst_date, date, db, + read_db, + read_operation, fail, new_id, 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: """Bind idempotency to evidence state without exposing sensitive text.""" fields = ( @@ -84,6 +141,7 @@ def _account_valuation_items(conn: sqlite3.Connection, basis_date: str = "", sel "last_synced_at": account.get("last_synced_at", ""), "source_note": account.get("memo", ""), "memo": account.get("memo", ""), + "evidence_kind": "account_balance", } if base["currency"] != "KRW": excluded.append({**base, "reason": "non_krw_without_fx"}) @@ -145,7 +203,7 @@ def _account_valuation_items(conn: sqlite3.Connection, basis_date: str = "", sel }) continue balance = valuation["amount"] - base.update({"valuation_id": valuation["id"], "verification_status": "verified", "valuation_kind": selected_purpose, "valuation_date": valuation["basis_date"], "currency": valuation["currency"], "carried_forward": valuation["basis_date"] != basis_date}) + base.update({"valuation_id": valuation["id"], "verification_status": "verified", "valuation_kind": selected_purpose, "valuation_date": valuation["basis_date"], "currency": valuation["currency"], "carried_forward": valuation["basis_date"] != basis_date, "evidence_kind": "account_valuation", "age_days": (date.fromisoformat(basis_date) - date.fromisoformat(str(valuation["basis_date"])[:10])).days}) if candidate_rows: base["unverified_candidates"] = [_public_candidate(v) for v in candidate_rows if v["basis_date"] <= basis_date] @@ -177,11 +235,14 @@ def _manual_asset_valuation_items(conn: sqlite3.Connection, active_only: bool = "source_type": "manual_asset", "authority_source": "manual_assets", "name": asset.get("name", ""), + "currency": str(asset.get("currency") or "KRW").upper(), "liquidity_bucket": bucket, "valuation_date": asset.get("valuation_date", ""), "source_note": asset.get("source_note", ""), "memo": asset.get("memo", ""), "active": asset.get("active", True), + "verification_status": "verified", + "evidence_kind": "manual_confirmed", } value = asset.get("valuation_amount") if basis_date: @@ -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"}) continue base["carried_forward"] = valuation_date != basis_date + base["age_days"] = (date.fromisoformat(basis_date) - date.fromisoformat(valuation_date)).days if value is None: excluded.append({**base, "reason": "missing_valuation_amount"}) continue @@ -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]]: - 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]] = [] for row in rows: 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")) except ValueError: 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( - { + _public_evidence({ "id": snap.get("id", ""), "source_id": snap.get("source_id", ""), "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", ""), "name": snap.get("name", ""), "liquidity_bucket": bucket, - "amount": int(snap.get("amount") or 0), - "valuation_date": snap.get("basis_date", ""), + "amount": int(snap["amount"]) if snap.get("amount") is not None else None, + "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", ""), "memo": snap.get("memo", ""), "snapshot_run_id": snap.get("snapshot_run_id", ""), "is_correction": bool(snap.get("is_correction", False)), "correction_of_run_id": snap.get("correction_of_run_id", ""), "correction_reason": snap.get("correction_reason", ""), - } + }) ) 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]: bucket_totals = {bucket: 0 for bucket in ASSET_BUCKET_ORDER} bucket_counts = {bucket: 0 for bucket in ASSET_BUCKET_ORDER} for item in included_items: 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_totals[bucket] += amount 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"}) +@read_operation def list_manual_assets(active_only: bool = True, db_path: str | None = None) -> dict[str, Any]: where = "WHERE active=1" if active_only else "" - with 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() 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()) @@ -404,19 +550,23 @@ def _snapshot_preview_from_conn(conn: sqlite3.Connection, basis: str, selection_ "id": candidate_id, "state_hash": _valuation_row_state_hash(candidate_row), }) + public_excluded = [_public_evidence(item) for item in excluded] return { "basis_date": basis, "source": "live_authority", + "provenance": _canonical_provenance(basis_date=basis, warnings=warnings, exclusions=public_excluded, selection_policy=selection_policy), "is_closed_snapshot": False, "snapshot_count": 0, "existing_snapshot_row_count": int(existing_rows), "would_be_correction": bool(existing_rows), "warnings": warnings, "_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]: if selection_policy not in {"exact", "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() except Exception as 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.pop("_included_items", None) 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_"))] 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: preview["close_status"] = "incomplete_draft" elif approval_eligible or carried_items: - preview["close_status"] = "conditional" + preview["close_status"] = "incomplete_draft" else: preview["close_status"] = "complete_exact" + preview["preview_fingerprint"] = _preview_fingerprint(preview, basis, selection_policy) preview.pop("_candidate_state_hashes", None) 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"}: return fail("validation_error", "selection_policy must be exact or as_of") try: @@ -452,6 +604,8 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str with db(db_path) as conn: conn.execute("BEGIN IMMEDIATE") 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) legacy_snapshot_rows = bool(latest_rows and not latest_meta.get("snapshot_run_id")) 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_"))] carried_items = [item for item in preview["included_items"] if item.get("carried_forward")] 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() 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") @@ -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() if existing_run: 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") - included = preview["included_items"] + included = preview.pop("_included_items", preview["included_items"]) # A conditional close may legitimately contain zero snapshot rows. # The run record, not row count, is the authoritative close marker. 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_id", item.get("id", "")), 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"), normalize_liquidity_bucket(item.get("liquidity_bucket")), 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) - summary = _summarize_valuation_items(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] + 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] 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( { @@ -536,13 +727,27 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str if warning.get("code") == "unverified_candidate" 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: 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: conn.rollback() 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, source="snapshot", 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_snapshot_ids=snapshot_ids, 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, ) @@ -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)) +@read_operation 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"}: return fail("validation_error", "selection_policy must be exact or as_of") - basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat() - with db(db_path) as conn: + try: + basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat() + 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) has_close_run = bool(close_metadata.get("snapshot_run_id")) snapshots = _snapshot_items(conn, basis) if rows else [] legacy_snapshots = snapshots if rows and not has_close_run else [] if has_close_run: - summary = _summarize_valuation_items(snapshots, []) - return ok( + run_public = _closed_run_metadata(close_metadata) + 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, source="asset_snapshots", is_closed_snapshot=True, + snapshot_run_id=close_metadata.get("snapshot_run_id", ""), snapshot_count=len(snapshots), - close_metadata=close_metadata, + close_metadata=run_public, selection_policy=close_metadata.get("selection_policy", selection_policy), 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, ) - account_items, account_excluded, invalid_bucket_accounts = _account_valuation_items(conn, basis, selection_policy) - manual_items, manual_excluded = _manual_asset_valuation_items(conn, basis_date=basis, selection_policy=selection_policy) - included = account_items + manual_items - excluded = account_excluded + manual_excluded - if not included and not excluded and legacy_snapshots: - summary = _summarize_valuation_items(legacy_snapshots, []) - return ok( - basis_date=basis, - source="legacy_asset_snapshots", - selection_policy=selection_policy, - close_status="legacy_read_only", - is_closed_snapshot=False, - snapshot_count=len(legacy_snapshots), - legacy_snapshot_row_count=len(legacy_snapshots), - close_metadata={"legacy": True, "snapshot_run_id": ""}, - **summary, + preview = _snapshot_preview_from_conn(conn, basis, selection_policy) + included = preview.get("_included_items", []) + if not included and not preview["excluded_items"] and legacy_snapshots: + public_legacy = [_public_evidence(item) for item in legacy_snapshots] + null_items = [item for item in public_legacy if item.get("amount") is None] + null_excluded = [{**item, "reason": "missing_amount"} for item in null_items] + summary = _summarize_valuation_items(public_legacy, null_excluded) + return _ok_with_provenance_aliases( + basis_date=basis, source="legacy_asset_snapshots", selection_policy=selection_policy, + close_status="incomplete_draft" if null_items else close_metadata.get("close_status", "legacy_read_only"), is_closed_snapshot=False, + provenance=_canonical_provenance(basis_date=basis, snapshot_run_id="", close_status="legacy_read_only", selection_policy=selection_policy), + snapshot_count=len(legacy_snapshots), legacy_snapshot_row_count=len(legacy_snapshots), + close_metadata={"legacy": True, "snapshot_run_id": ""}, **summary, ) - summary = _summarize_valuation_items(included, excluded) - return ok( + public_included = [_public_evidence(item) for item in included] + 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, source="live_authority", 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, snapshot_count=0, legacy_snapshot_row_count=len(legacy_snapshots), - invalid_bucket_accounts=invalid_bucket_accounts, - **summary, + invalid_bucket_accounts=[warning for warning in warnings if warning.get("code") == "invalid_liquidity_bucket"], + 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]: try: 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() except Exception as e: return fail("validation_error", str(e)) - with db(db_path) as conn: + with read_db(db_path) as conn: basis_rows = conn.execute( """SELECT DISTINCT basis_date FROM asset_snapshots 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: basis = row["basis_date"] 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( { "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"], "source": "asset_snapshots", "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()) +@read_operation def get_asset_liquidity_report(db_path: str | None = None) -> dict[str, Any]: """Summarize assets/liabilities from shared valuation authority sources. Existing REQ-005 keys are preserved. When manual_assets is empty, totals and 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) manual_items, manual_excluded = _manual_asset_valuation_items(conn) included = account_items + manual_items diff --git a/jarca_ledger_mcp/application/categorization_services.py b/jarca_ledger_mcp/application/categorization_services.py index 75e83f3..6d652b2 100644 --- a/jarca_ledger_mcp/application/categorization_services.py +++ b/jarca_ledger_mcp/application/categorization_services.py @@ -4,6 +4,8 @@ from .shared import ( Any, datetime, db, + read_db, + read_operation, fail, now_iso, 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) +@read_operation 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" - with db(db_path) as conn: + with read_db(db_path) as conn: rows=conn.execute(sql).fetchall() 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]: - with db(db_path) as conn: + with read_db(db_path) as conn: rules = _load_active_rules(conn) where = "WHERE status='active'" + (" AND category=''" if only_uncategorized else "") 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]: data = _categorization_preview_data(limit, only_uncategorized, db_path) + if data.get("success") is False: + return data return ok( scanned=len(data["rows"]), 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: return fail("validation_error", "rule_id is required") 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() if not rule_row: 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) +@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]: data = _category_rule_match_rows(rule_id, limit, db_path) 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]: + with db(db_path): + pass data = _category_rule_match_rows(rule_id, limit, db_path) if not data.get("success"): 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]: + with db(db_path): + pass data = _categorization_preview_data(limit, only_uncategorized, db_path) + if not data.get("success", True): + return data preview = ok( scanned=len(data["rows"]), 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) +@read_operation def list_ledger_categories(active_only: bool = True, include_usage: bool = True, db_path: str | None = None) -> dict[str, Any]: clauses = [] if active_only: clauses.append("active=1") 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() items = [row_to_dict(r) for r in rows] if include_usage: diff --git a/jarca_ledger_mcp/application/cross_transfer_service.py b/jarca_ledger_mcp/application/cross_transfer_service.py index 44ec1e2..ea79143 100644 --- a/jarca_ledger_mcp/application/cross_transfer_service.py +++ b/jarca_ledger_mcp/application/cross_transfer_service.py @@ -17,6 +17,8 @@ from .shared import ( now_iso, ok, re, + read_db, + read_operation, row_to_dict, ) 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]] = [] 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") toss_rows = _fetch_transfer_candidates(conn, "toss_bank_pdf") 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]: 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) +@read_operation 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.append("t.memo LIKE '%cross_transfer_review:%'") where = " WHERE " + " AND ".join(clauses) - with db(db_path) as conn: + with read_db(db_path) as conn: rows = conn.execute( f"SELECT * FROM transactions t{where} ORDER BY t.transaction_date DESC, t.created_at DESC LIMIT ?", (max(1, min(int(limit), 1000)),), diff --git a/jarca_ledger_mcp/application/imports_service.py b/jarca_ledger_mcp/application/imports_service.py index 122aa05..a3eae85 100644 --- a/jarca_ledger_mcp/application/imports_service.py +++ b/jarca_ledger_mcp/application/imports_service.py @@ -13,6 +13,8 @@ from .shared import ( Path, csv, db, + read_db, + read_operation, fail, json, 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 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]: path=Path(file_path).expanduser() 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"]) +@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]: try: 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"] source_hash = parsed["source_hash"] duplicate_count = 0 - with db(db_path) as conn: + with read_db(db_path) as conn: for tx in transactions: 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() @@ -218,7 +239,7 @@ def import_kb_bank_pdf(file_path: str, password: str, account_id: str = "", skip raise conn.execute("UPDATE import_batches SET row_count=? WHERE id=?", (inserted, batch_id)) 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: _record_import_valuation_candidate(conn, linked_account_id, transactions[-1].balance_after, transactions[-1].transaction_date, source_hash) 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 +@read_operation def preview_seoulpay_xlsx_import(file_path: str, password: str = "", preview_limit: int = 20, db_path: str | None = None) -> dict[str, Any]: try: 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"] duplicate_count = 0 by_category: dict[str, dict[str, int]] = {} - with db(db_path) as conn: + with read_db(db_path) as conn: linked_account_id = "" metadata = parsed["metadata"] 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]: try: 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"] source_hash = parsed["source_hash"] duplicate_count = 0 - with db(db_path) as conn: + with read_db(db_path) as conn: metadata = parsed["metadata"] account_number_masked = metadata.get("account_number_masked") or "" linked_account_id = "" @@ -683,7 +706,7 @@ def import_toss_bank_pdf(file_path: str, account_id: str = "", skip_duplicates: raise conn.execute("UPDATE import_batches SET row_count=? WHERE id=?", (inserted, batch_id)) 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: _record_import_valuation_candidate(conn, linked_account_id, transactions[-1].balance_after, transactions[-1].transaction_date, source_hash) except ValueError as e: @@ -763,6 +786,7 @@ def _classify_toss_transaction(tx: Any, owner_name: str = "") -> tuple[str, str, 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]: try: 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"] source_hash = parsed["source_hash"] duplicate_count = 0 - with db(db_path) as conn: + with read_db(db_path) as conn: metadata = parsed["metadata"] account_number_masked = metadata.get("account_number_masked") or "" linked_account_id = "" @@ -907,7 +931,7 @@ def import_toss_securities_pdf(file_path: str, account_id: str = "", skip_duplic raise conn.execute("UPDATE import_batches SET row_count=? WHERE id=?", (inserted, batch_id)) 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: _record_import_valuation_candidate(conn, linked_account_id, transactions[-1].balance_after, transactions[-1].transaction_date, source_hash) 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( source: str = "", include_rolled_back: bool = True, @@ -972,6 +997,7 @@ def _batch_provenance(row: dict[str, Any]) -> dict[str, Any]: return provenance +@read_operation def list_ledger_import_batches_with_provenance( source: str = "", include_rolled_back: bool = True, @@ -992,7 +1018,7 @@ def list_ledger_import_batches_with_provenance( clauses.append("status='active'") where = " WHERE " + " AND ".join(clauses) if clauses else "" 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() try: items = [] diff --git a/jarca_ledger_mcp/application/monthly_settlement_service.py b/jarca_ledger_mcp/application/monthly_settlement_service.py index 72b77de..94be924 100644 --- a/jarca_ledger_mcp/application/monthly_settlement_service.py +++ b/jarca_ledger_mcp/application/monthly_settlement_service.py @@ -65,9 +65,16 @@ def _source_coverage( "is_closed_snapshot": bool(asset_report.get("is_closed_snapshot", False)), "net_worth": asset_report.get("net_worth"), "close_status": asset_report.get("close_status", ""), + "approval": asset_report.get("approval", {}), + "correction": asset_report.get("correction", {}), "warnings": asset_report.get("warnings", []), + "provenance": asset_report.get( + "provenance", {"valuation_date": "", "warnings": [], "exclusions": []} + ), "missing_items": [item for item in asset_report.get("excluded_items", []) if "missing" in str(item.get("reason", ""))], - "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", ""), }, } diff --git a/jarca_ledger_mcp/application/reconciliation_service.py b/jarca_ledger_mcp/application/reconciliation_service.py index 9d8c8f0..9c7a041 100644 --- a/jarca_ledger_mcp/application/reconciliation_service.py +++ b/jarca_ledger_mcp/application/reconciliation_service.py @@ -1,11 +1,14 @@ from __future__ import annotations +from datetime import date + from .shared import ( Any, BALANCE_RECONCILIATION_REFERENCE_RATIO, BALANCE_RECONCILIATION_TOLERANCE_KRW, RECONCILIATION_TARGET_STATUSES, - db, + read_db, + read_operation, fail, normalize_date, normalize_snapshot_at, @@ -25,7 +28,7 @@ def _transaction_signed_amount(tx_type: str, amount: int) -> int: 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]) rows = conn.execute( """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) basis = "net_transaction_delta_without_opening_balance" 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 { "calculated_balance": calculated_balance, "calculation_basis": basis, "calculation_basis_at": basis_at, "transaction_count": len(rows), "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]: """Preview manual/partial-import account balance reconciliation without mutating transactions.""" 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 except Exception as e: return fail("validation_error", str(e)) - with db(db_path) as conn: + with read_db(db_path) as conn: clauses = ["active=1"] params: list[Any] = [] if account_id: @@ -85,27 +97,56 @@ def get_balance_reconciliation_report(account_id: str = "", baseline_balance: in for account in accounts: 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() - calc = _account_balance_row(conn, account["id"], effective_at) valuation = None ambiguous_valuation = False valuation_rows = conn.execute( """SELECT * FROM account_valuations v WHERE v.account_id=? AND v.verification_status='verified' + AND UPPER(v.currency)=UPPER(?) AND v.basis_date<=? AND NOT EXISTS ( SELECT 1 FROM account_valuations child WHERE child.supersedes_id=v.id ) ORDER BY v.basis_date DESC, v.recorded_at DESC, v.id DESC""", - (account["id"], effective_at[:10]), + (account["id"], account["currency"] or "KRW", effective_at[:10]), ).fetchall() purposes = ( - ("total_value", "balance", "cash_balance") - if account["type"] == "investment" - else ("cash_balance", "balance") + ("loan_balance", "loan", "balance") + if account["type"] == "loan" + else (("total_value", "balance", "cash_balance") if account["type"] == "investment" else ("cash_balance", "balance")) ) + transaction_rows = conn.execute( + """SELECT transaction_date FROM transactions + WHERE status='active' AND account_id=? AND transaction_date<=? + ORDER BY transaction_date ASC, transaction_datetime ASC, created_at ASC""", + (account["id"], effective_at[:10]), + ).fetchall() + first_transaction_date = transaction_rows[0]["transaction_date"] if transaction_rows else None + has_opening_evidence = bool( + first_transaction_date + and any( + row["basis_date"] < first_transaction_date + and row["valuation_kind"] in purposes + and ( + account["type"] != "loan" + or row["representative_liability_definition"] + in {"principal_only", "principal_interest_fees"} + ) + for row in valuation_rows + ) + ) + calc = _account_balance_row(conn, account["id"], effective_at, has_opening_evidence) for purpose in purposes: - scoped = [row for row in valuation_rows if row["valuation_kind"] == purpose] + scoped = [ + row for row in valuation_rows + if row["valuation_kind"] == purpose + and ( + account["type"] != "loan" + or row["representative_liability_definition"] + in {"principal_only", "principal_interest_fees"} + ) + ] if not scoped: continue latest_basis = max(row["basis_date"] for row in scoped) @@ -126,6 +167,12 @@ def get_balance_reconciliation_report(account_id: str = "", baseline_balance: in if account["type"] == "investment" and ambiguous_valuation: status = "AMBIGUOUS_VALUATION" difference = None + elif account["type"] == "investment" and valuation is None: + status = "VALUATION_UNAVAILABLE" + difference = None + elif not calc["calculation_complete"]: + status = "INCOMPLETE_CALCULATION" + difference = None elif account["type"] == "investment" and valuation is not None: status = "VALUATION_MANAGED" difference = None @@ -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, "valuation_id": valuation["id"] if valuation is not None else "", "valuation_kind": valuation["valuation_kind"] if valuation is not None else "", + "provenance": { + "valuation_date": valuation["basis_date"] if valuation is not None else "", + "age_days": (date.fromisoformat(effective_at[:10]) - date.fromisoformat(valuation["basis_date"])).days if valuation is not None else None, + "carried": bool(valuation is not None and valuation["basis_date"] != effective_at[:10]), + "verification_status": valuation["verification_status"] if valuation is not None else "unavailable", + "evidence_kind": "account_valuation" if valuation is not None else "none", + "warnings": [status] if status not in {"OK", "REVIEW_NEEDED", "VALUATION_MANAGED"} else [], + "exclusions": [], + "snapshot_run_id": "", + "close_status": "", + "selection_policy": "as_of", + }, "recommended_decisions": ["계좌이동", "실지출", "실수입", "투자이동", "분석제외", "확인필요"] if status == "REVIEW_NEEDED" else [], "reconciliation_session": latest_sessions.get(account["id"]), "review_status": latest_decisions.get(("reconciliation_account", account["id"]), {}).get("status", "pending"), diff --git a/jarca_ledger_mcp/application/review_decision_service.py b/jarca_ledger_mcp/application/review_decision_service.py index d8cf4db..22a3e8e 100644 --- a/jarca_ledger_mcp/application/review_decision_service.py +++ b/jarca_ledger_mcp/application/review_decision_service.py @@ -3,6 +3,8 @@ from __future__ import annotations from .shared import ( Any, db, + read_db, + read_operation, fail, json, new_id, @@ -113,6 +115,7 @@ def attach_review_decisions(db_conn: Any, items: list[dict[str, Any]], target_ty 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]: clauses: list[str] = [] params: list[Any] = [] @@ -131,7 +134,7 @@ def list_review_decisions(target_type: str = "", target_id: str = "", status: st params.append(status) where = " WHERE " + " AND ".join(clauses) if clauses else "" max_limit = max(1, min(int(limit), 1000)) - with db(db_path) as conn: + with read_db(db_path) as conn: rows = conn.execute( f"SELECT * FROM review_decisions{where} ORDER BY decided_at DESC, updated_at DESC 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) +@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]: clauses: list[str] = [] params: list[Any] = [] @@ -235,7 +239,7 @@ def list_reconciliation_sessions(account_id: str = "", status: str = "", basis_d clauses.append("basis_date=?") params.append(basis_date) 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 reconciliation_sessions{where} ORDER BY basis_date DESC, updated_at DESC LIMIT ?", (*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]: - 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() 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]) diff --git a/jarca_ledger_mcp/application/shared.py b/jarca_ledger_mcp/application/shared.py index bbbcc21..c02c4d6 100644 --- a/jarca_ledger_mcp/application/shared.py +++ b/jarca_ledger_mcp/application/shared.py @@ -5,6 +5,7 @@ import sqlite3 as sqlite3 import hashlib import json import re +from functools import wraps from datetime import date, datetime, timedelta from pathlib import Path from typing import Any @@ -23,6 +24,8 @@ from ..infrastructure.parsers import ( ) from ..infrastructure.sqlite import db as 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 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]: 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]: d = dict(row) if "tags" in d: diff --git a/jarca_ledger_mcp/application/system_services.py b/jarca_ledger_mcp/application/system_services.py index aa97db5..ff1c630 100644 --- a/jarca_ledger_mcp/application/system_services.py +++ b/jarca_ledger_mcp/application/system_services.py @@ -10,6 +10,8 @@ from .shared import ( current_kst_month, datetime, db, + read_db, + read_operation, fail, hashlib, 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) +@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: digest = hashlib.sha256() 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 +@read_operation 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" - with db(db_path) as conn: + with read_db(db_path) as conn: rows = conn.execute(sql).fetchall() return ok(accounts=[row_to_dict(r) for r in rows], count=len(rows)) diff --git a/jarca_ledger_mcp/application/transaction_report_services.py b/jarca_ledger_mcp/application/transaction_report_services.py index 36d8a60..58d21c7 100644 --- a/jarca_ledger_mcp/application/transaction_report_services.py +++ b/jarca_ledger_mcp/application/transaction_report_services.py @@ -4,7 +4,8 @@ from .shared import ( Any, CASHFLOW_KIND_SQL, date, - db, + read_db, + read_operation, normalize_date, normalize_month, 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 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]: clauses=[]; params=[] 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 "" sql = f"SELECT * FROM transactions{where} ORDER BY transaction_date DESC, created_at DESC LIMIT ?" 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() 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 +@read_operation def list_ledger_budgets(month: str = "", db_path: str | None = None) -> dict[str, Any]: clauses=[]; params=[] if month: clauses.append("month=?"); params.append(normalize_month(month)) 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() 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) return start.isoformat(), end.isoformat() +@read_operation 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) - 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 WHERE status='active' AND transaction_date>=? AND transaction_date 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) +@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]: if 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 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""" - with db(db_path) as conn: + with read_db(db_path) as conn: rows=conn.execute(sql, params).fetchall() 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] 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]: 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 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 dict[s 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)) +@read_operation 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) - with db(db_path) as conn: + with read_db(db_path) as conn: 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 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"}) 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]: cashflow=get_monthly_cashflow_report(month, db_path) if not cashflow.get("success"): diff --git a/jarca_ledger_mcp/application/uncategorized_workbench_service.py b/jarca_ledger_mcp/application/uncategorized_workbench_service.py index 2bebd0c..99d47c6 100644 --- a/jarca_ledger_mcp/application/uncategorized_workbench_service.py +++ b/jarca_ledger_mcp/application/uncategorized_workbench_service.py @@ -4,6 +4,8 @@ from .shared import ( Any, datetime, db, + read_db, + read_operation, fail, now_iso, 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 False +@read_operation 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() 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]: - 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 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() @@ -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": ""} +@read_operation def get_uncategorized_workbench( limit: int = 100, 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", } 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( 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 @@ -172,7 +177,7 @@ def get_uncategorized_workbench( group["recommendation"] = rec 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") 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) diff --git a/jarca_ledger_mcp/infrastructure/sqlite/__init__.py b/jarca_ledger_mcp/infrastructure/sqlite/__init__.py index d341ca3..8e5f64e 100644 --- a/jarca_ledger_mcp/infrastructure/sqlite/__init__.py +++ b/jarca_ledger_mcp/infrastructure/sqlite/__init__.py @@ -1,6 +1,9 @@ from .connection import DEFAULT_DB_PATH as DEFAULT_DB_PATH from .connection import connect as connect +from .connection import connect_readonly as connect_readonly 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 .migrations import init_db as init_db from .migrations import migrate_db as migrate_db @@ -16,7 +19,10 @@ __all__ = [ "SCHEMA_VERSION", "TRANSACTION_ADDITIVE_COLUMNS", "connect", + "connect_readonly", "db", + "read_db", + "DatabaseNotInitializedError", "init_db", "migrate_db", "resolve_db_path", diff --git a/jarca_ledger_mcp/infrastructure/sqlite/connection.py b/jarca_ledger_mcp/infrastructure/sqlite/connection.py index 098d6bf..b16743d 100644 --- a/jarca_ledger_mcp/infrastructure/sqlite/connection.py +++ b/jarca_ledger_mcp/infrastructure/sqlite/connection.py @@ -5,10 +5,15 @@ import sqlite3 from contextlib import contextmanager from pathlib import Path from typing import Iterator +from urllib.parse import quote 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: raw = db_path or os.getenv("JARCA_LEDGER_DB_PATH") or str(DEFAULT_DB_PATH) return Path(raw).expanduser().resolve() @@ -23,6 +28,31 @@ def connect(db_path: str | None = None) -> sqlite3.Connection: 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 def db(db_path: str | None = None) -> Iterator[sqlite3.Connection]: from .migrations import init_db diff --git a/tests/test_dashboard_app.py b/tests/test_dashboard_app.py index b2e9d34..e3bb7e3 100644 --- a/tests/test_dashboard_app.py +++ b/tests/test_dashboard_app.py @@ -61,7 +61,7 @@ def test_streamlit_apptest_core_pages_use_synthetic_fixture_db(tmp_path, monkeyp "월정산": "월간 핵심 KPI", } 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 if page == "월정산": 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 "잔액: 1,250,000원" in body_text assert "상태: 정상" in body_text - assert "자산 상세: 경고 없음" in body_text + assert "자산 상세: 경고 3건 / 제외 1건" in body_text assert "월정산 결과 / Evidence" in body_text assert "읽기 전용 근거 요약" 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 "raw_payload" not 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() diff --git a/tests/test_dashboard_menu_transition.py b/tests/test_dashboard_menu_transition.py index 9fb3d79..74678aa 100644 --- a/tests/test_dashboard_menu_transition.py +++ b/tests/test_dashboard_menu_transition.py @@ -53,6 +53,7 @@ def test_direct_page_query_access_renders_requested_page(monkeypatch): selected = {} monkeypatch.setattr(app, "require_dashboard_authentication", lambda: True) 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.st, "query_params", {"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, "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.st, "query_params", {"page": "요약"}) monkeypatch.setattr(app, "render_sidebar_nav", lambda pages, page: page) diff --git a/tests/test_import_provenance_policy.py b/tests/test_import_provenance_policy.py index 646fddb..026bb0f 100644 --- a/tests/test_import_provenance_policy.py +++ b/tests/test_import_provenance_policy.py @@ -6,6 +6,7 @@ import sqlite3 from pathlib import Path from jarca_ledger_mcp import service +from jarca_ledger_mcp.infrastructure.sqlite import init_db from jarca_ledger_mcp.provenance_policy import ( EXPOSURE_LEVEL_DEBUG_GATED, 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): db_path = str(tmp_path / "ledger.sqlite3") + init_db(db_path) parsed = { "file_path": SECRET_SOURCE_PATH, "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): db_path = str(tmp_path / "ledger.sqlite3") + init_db(db_path) parsed = { "file_path": "/very/secret/home/alice/Documents/private/seoulpay.xlsx", "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): db_path = str(tmp_path / "ledger.sqlite3") + init_db(db_path) parsed = { "file_path": SECRET_SOURCE_PATH, "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): db_path = str(tmp_path / "ledger.sqlite3") + init_db(db_path) parsed = { "file_path": SECRET_SOURCE_PATH, "source_hash": SOURCE_HASH, diff --git a/tests/test_issue_12_regressions.py b/tests/test_issue_12_regressions.py index b6a3543..7412e0a 100644 --- a/tests/test_issue_12_regressions.py +++ b/tests/test_issue_12_regressions.py @@ -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" -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") service.create_ledger_account("미확인 계좌", db_path=path) preview = service.preview_asset_snapshot_close("2026-06-30", db_path=path) assert preview["success"] is True - assert preview["close_status"] == "conditional" + assert preview["close_status"] == "incomplete_draft" 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", 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" 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" +def test_reconciliation_requires_balance_coverage_through_requested_basis(tmp_path): + path = str(tmp_path / "ledger.sqlite3") + account = service.create_ledger_account( + "계좌", last_balance=1_000, last_synced_at="2026-06-30", db_path=path, + )["account"]["id"] + service.create_ledger_transaction( + "2026-06-01", "income", 1_000, account_id=account, + balance_after=1_000, db_path=path, + ) + + item = service.get_balance_reconciliation_report( + account_id=account, baseline_balance=1_000, + baseline_at="2026-06-30T00:00:00+09:00", db_path=path, + )["items"][0] + + assert item["calculation_basis"] == "latest_transaction_balance_after" + assert item["calculation_complete"] is False + assert item["status"] == "INCOMPLETE_CALCULATION" + assert item["difference"] is None + + +def test_reconciliation_accepts_balance_coverage_at_requested_basis(tmp_path): + path = str(tmp_path / "ledger.sqlite3") + account = service.create_ledger_account( + "계좌", last_balance=1_000, last_synced_at="2026-06-30", db_path=path, + )["account"]["id"] + service.create_ledger_transaction( + "2026-06-30", "income", 1_000, account_id=account, + balance_after=1_000, db_path=path, + ) + + item = service.get_balance_reconciliation_report( + account_id=account, baseline_balance=1_000, + baseline_at="2026-06-30T00:00:00+09:00", db_path=path, + )["items"][0] + + assert item["calculation_complete"] is True + assert item["status"] == "OK" + assert item["difference"] == 0 + + +def test_reconciliation_requires_start_balance_for_transaction_coverage(tmp_path): + path = str(tmp_path / "ledger.sqlite3") + account = service.create_ledger_account( + "계좌", last_balance=1_000, last_synced_at="2026-06-30", db_path=path, + )["account"]["id"] + service.create_ledger_transaction( + "2026-06-01", "income", 1_000, account_id=account, db_path=path, + ) + service.create_ledger_transaction( + "2026-06-30", "expense", 100, account_id=account, + balance_after=900, db_path=path, + ) + + item = service.get_balance_reconciliation_report( + account_id=account, baseline_balance=900, + baseline_at="2026-06-30T00:00:00+09:00", db_path=path, + )["items"][0] + + assert item["calculation_complete"] is False + assert item["status"] == "INCOMPLETE_CALCULATION" + assert item["difference"] is None + + +def test_reconciliation_requires_authoritative_opening_evidence_for_multi_transaction_coverage(tmp_path): + path = str(tmp_path / "ledger.sqlite3") + account = service.create_ledger_account( + "계좌", last_balance=1_000, last_synced_at="2026-06-30", db_path=path, + )["account"]["id"] + service.create_ledger_transaction( + "2026-06-15", "income", 100, account_id=account, + balance_after=900, db_path=path, + ) + service.create_ledger_transaction( + "2026-06-30", "income", 100, account_id=account, + balance_after=1_000, db_path=path, + ) + + item = service.get_balance_reconciliation_report( + account_id=account, baseline_balance=1_000, + baseline_at="2026-06-30T00:00:00+09:00", db_path=path, + )["items"][0] + + assert item["calculation_complete"] is False + assert item["status"] == "INCOMPLETE_CALCULATION" + assert item["difference"] is None + + +def test_reconciliation_accepts_continuous_transactions_with_verified_opening_evidence(tmp_path): + path = str(tmp_path / "ledger.sqlite3") + account = service.create_ledger_account( + "계좌", last_balance=1_000, last_synced_at="2026-06-30", db_path=path, + )["account"]["id"] + service.register_account_valuation( + account, "2026-06-14", 800, valuation_kind="balance", + verification_status="verified", db_path=path, + ) + service.create_ledger_transaction( + "2026-06-15", "income", 100, account_id=account, + balance_after=900, db_path=path, + ) + service.create_ledger_transaction( + "2026-06-30", "income", 100, account_id=account, + balance_after=1_000, db_path=path, + ) + + item = service.get_balance_reconciliation_report( + account_id=account, baseline_balance=1_000, + baseline_at="2026-06-30T00:00:00+09:00", db_path=path, + )["items"][0] + + assert item["calculation_complete"] is True + assert item["status"] == "OK" + assert item["difference"] == 0 + + def test_reconciliation_fails_closed_on_ambiguous_verified_leaf(tmp_path): path = str(tmp_path / "ledger.sqlite3") account = service.create_ledger_account( @@ -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): path = str(tmp_path / "ledger.sqlite3") + init_db(path) preview = service.preview_asset_snapshot_close("2026-06-30", path) closed = service.close_asset_snapshot("2026-06-30", "", "", "", path) report = service.get_asset_snapshot_report("2026-06-30", path) diff --git a/tests/test_issue_12_review59_account.py b/tests/test_issue_12_review59_account.py new file mode 100644 index 0000000..70db24f --- /dev/null +++ b/tests/test_issue_12_review59_account.py @@ -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" diff --git a/tests/test_issue_12_review59_readonly.py b/tests/test_issue_12_review59_readonly.py new file mode 100644 index 0000000..1e860cf --- /dev/null +++ b/tests/test_issue_12_review59_readonly.py @@ -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 diff --git a/tests/test_issue_12_review59_reports.py b/tests/test_issue_12_review59_reports.py new file mode 100644 index 0000000..c88cf10 --- /dev/null +++ b/tests/test_issue_12_review59_reports.py @@ -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 diff --git a/tests/test_requirement_docs.py b/tests/test_requirement_docs.py index 0b4c0bb..78eda11 100644 --- a/tests/test_requirement_docs.py +++ b/tests/test_requirement_docs.py @@ -40,11 +40,26 @@ def test_requirement_docs_are_canonical_and_traceable() -> None: assert body.count("**State Invariants**") == 10 assert body.count("**판정 기준**") == 10 assert body.count("**검증**") == 10 + classification = re.findall( + r"^\*\*검증 분류\*\*: level=(unit|integration|static); backend=(sqlite|application|dashboard)$", + body, + re.M, + ) + assert len(classification) == 10 + assert {level for level, _ in classification} >= {"unit", "integration", "static"} + assert {backend for _, backend in classification} >= {"sqlite", "application", "dashboard"} for marker in ( "v12", "idempotent", "append-only", "future", "unverified", "unknown", "idempotency_conflict", "correction_conflict", "NULL", "VALUATION_MANAGED", "conditional_approval_required", "MCP", "Dashboard", "auth-before-DB", "read-only", "monthly", "synthetic", "실제 credential", "호환성", + "reported principal amount", "outstanding principal", "interest", "fee", + "same account/date/currency/purpose", "correction_reason", "no-write", + "source_ref", "evidence idempotency", "cannot roll back newer current cache", + "incomplete_draft", "explicit dated manual asset", "future unknown", + "reconciliation completeness", "unavailable investment", "loan purpose", + "no fake writes", "provenance", "writer", "bootstrap", "init_db", + "read_db", "mode=ro", "db_not_initialized", "no mkdir", "no migrate", "no commit", ): assert marker in text, marker @@ -60,7 +75,12 @@ def test_canonical_docs_have_required_sections_and_no_placeholders() -> None: text = _text(rel) assert "${" not in text, rel 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 interface = _text("docs/interface-contract.md") 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", ): 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 diff --git a/tests/test_review_decision_workbench.py b/tests/test_review_decision_workbench.py index e916161..6f49b0c 100644 --- a/tests/test_review_decision_workbench.py +++ b/tests/test_review_decision_workbench.py @@ -80,7 +80,7 @@ def test_reconciliation_session_round_trip_and_report_integration(tmp_path: Path assert report["success"] is True item = report["items"][0] 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) assert closed["success"] is True diff --git a/tests/test_seoulpay_xlsx.py b/tests/test_seoulpay_xlsx.py index db780b1..40dc601 100644 --- a/tests/test_seoulpay_xlsx.py +++ b/tests/test_seoulpay_xlsx.py @@ -3,6 +3,7 @@ from __future__ import annotations from pathlib import Path 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 @@ -42,6 +43,7 @@ def test_parse_seoulpay_xlsx_plain_file(tmp_path): def test_import_seoulpay_xlsx_creates_account_and_expenses(tmp_path): db_path = str(tmp_path / "ledger.sqlite3") + init_db(db_path) file_path = tmp_path / "서울페이.xlsx" _write_sample_seoulpay_xlsx(file_path) diff --git a/tests/test_service.py b/tests/test_service.py index 29e935c..b56d418 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -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) assert report["success"] is True assert report["items"][0]["calculated_balance"] == 901_500 - assert report["items"][0]["difference"] == 0 - assert report["items"][0]["status"] == "OK" + assert report["items"][0]["difference"] is None + 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) 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) assert review["success"] is True assert review["baseline_at"] == "2026-06-30T23:59:00+09:00" - assert review["items"][0]["difference"] == 3_500 - assert review["items"][0]["status"] == "REVIEW_NEEDED" - assert "확인필요" in review["items"][0]["recommended_decisions"] + assert review["items"][0]["difference"] is None + assert review["items"][0]["status"] == "INCOMPLETE_CALCULATION" + assert review["items"][0]["recommended_decisions"] == [] 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["items"][0]["baseline_at"] == "2026-01-31T23:59:00+09:00" assert report["items"][0]["calculated_balance"] == 1_000 - assert report["items"][0]["difference"] == 0 - assert report["items"][0]["status"] == "OK" + assert report["items"][0]["difference"] is None + assert report["items"][0]["status"] == "INCOMPLETE_CALCULATION" explicit = service.get_balance_reconciliation_report( 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["items"][0]["baseline_at"] == "2026-02-28T23:59:00+09:00" assert explicit["items"][0]["calculated_balance"] == 800 - assert explicit["items"][0]["difference"] == 0 - assert explicit["items"][0]["status"] == "OK" + assert explicit["items"][0]["difference"] is None + assert explicit["items"][0]["status"] == "INCOMPLETE_CALCULATION" def test_invalid_statement_status_rejected(tmp_path):