fix: address account valuation review findings

This commit is contained in:
2026-10-06 14:43:29 +00:00
parent 399d0923be
commit 6a8a27a74e
34 changed files with 1940 additions and 131 deletions
+5
View File
@@ -4,6 +4,7 @@ import sys
from pathlib import Path
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)
+36 -1
View File
@@ -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:
+33 -1
View File
@@ -11,6 +11,32 @@ from jarca_ledger_mcp.adapters.streamlit import overview_gateway
from jarca_ledger_mcp.adapters.streamlit import reconciliation_gateway
from 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', '-')}")
+6 -4
View File
@@ -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.
## 저장과 마이그레이션
+37
View File
@@ -386,3 +386,40 @@ preview_asset_snapshot_close(basis_date="2026-06-30", selection_policy="exact")
- 실패 계약: idempotency/correction/conditional approval tests and source validation
- 경계 계약: 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.
+44
View File
@@ -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.
@@ -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 순서를 보존한다.
+23
View File
@@ -216,3 +216,26 @@ ASGI startup부터 login, token restoration, invalid-cookie cleanup, explicit lo
- 경계 흐름: exact/as_of, stale/carry-forward, correction/re-close, empty fallback
- 동시성 흐름: 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.
@@ -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",
@@ -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")}
+340 -49
View File
@@ -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
@@ -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:
@@ -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)),),
@@ -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 = []
@@ -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", ""),
},
}
@@ -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"),
@@ -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])
+16
View File
@@ -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:
@@ -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))
@@ -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<?
GROUP BY type""", (start, next_month)).fetchall()
@@ -72,6 +76,7 @@ def get_monthly_cashflow_report(month: str = "", db_path: str | None = None) ->
savings_rate = round(net_cashflow / income, 4) if income else None
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<?
@@ -101,9 +107,10 @@ def get_fixed_cost_report(month: str = "", db_path: str | None = None) -> dict[s
items=[row_to_dict(r) for r in rows]
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"):
@@ -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)
@@ -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",
@@ -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
+87 -2
View File
@@ -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()
+2
View File
@@ -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)
+5
View File
@@ -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,
+120 -3
View File
@@ -31,12 +31,12 @@ def test_empty_conditional_close_is_closed_and_second_close_requires_correction(
assert second["success"] is False and second["error_code"] == "validation_error"
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)
+190
View File
@@ -0,0 +1,190 @@
from types import SimpleNamespace
from jarca_ledger_mcp import service
from jarca_ledger_mcp.application import imports_service
def _account(path, *, account_type="bank", currency="KRW"):
return service.create_ledger_account("계좌", type=account_type, currency=currency, db_path=path)["account"]["id"]
def test_valuation_round_trip_preserves_null_zero_and_physical_amount_mapping(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
investment = _account(path, account_type="investment")
loan = _account(path, account_type="loan")
inv = service.register_account_valuation(
investment, "2026-06-30", 1_000, valuation_kind="total_value",
principal=0, reported_principal_kind="contributed", reported_principal_label="납입원금",
interest=None, fee=None, idempotency_key="inv", db_path=path,
)
debt = service.register_account_valuation(
loan, "2026-06-30", 9_000, valuation_kind="loan",
representative_liability_definition="principal_interest_fees", principal=9_000,
interest=300, fee=20, idempotency_key="loan", db_path=path,
)
assert inv["success"] and debt["success"]
inv_value = service.list_account_valuations(investment, db_path=path)["valuations"][0]
debt_value = service.list_account_valuations(loan, db_path=path)["valuations"][0]
assert inv_value["principal"] == 0
assert inv_value["interest"] is None and inv_value["fee"] is None
assert inv_value["reported_principal_kind"] == "contributed"
assert inv_value["reported_principal_label"] == "납입원금"
assert debt_value["amount"] == 9_000
assert debt_value["principal"] == 9_000 and debt_value["interest"] == 300 and debt_value["fee"] == 20
def test_direct_correction_requires_matching_existing_parent_and_reason(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = _account(path)
parent = service.register_account_valuation(account, "2026-06-30", 100, db_path=path)["valuation"]
for kwargs in (
{"supersedes_id": "missing", "correction_reason": "정정"},
{"supersedes_id": parent["id"], "correction_reason": ""},
):
result = service.register_account_valuation(account, "2026-06-30", 200, db_path=path, **kwargs)
assert result["success"] is False
other = _account(path, currency="USD")
result = service.register_account_valuation(
other, "2026-06-30", 200, currency="USD", supersedes_id=parent["id"],
correction_reason="cross account", db_path=path,
)
assert result["success"] is False
assert service.list_account_valuations(account, db_path=path)["count"] == 1
def test_correction_rejects_currency_and_kind_override_but_preserves_valid_overrides(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = _account(path, account_type="investment")
parent = service.register_account_valuation(
account, "2026-06-30", 100, valuation_kind="total_value", memo="old", db_path=path,
)["valuation"]
bad_currency = service.correct_account_valuation(parent["id"], 110, "정정", currency="USD", db_path=path)
bad_kind = service.correct_account_valuation(parent["id"], 110, "정정", valuation_kind="balance", db_path=path)
good = service.correct_account_valuation(parent["id"], 110, "정정", memo="new", db_path=path)
assert bad_currency["success"] is False and bad_kind["success"] is False
assert good["success"] and good["valuation"]["memo"] == "new"
def _fake_tx(date, balance, *, row=1, kind="bank"):
if kind == "kb":
return SimpleNamespace(
transaction_date=date, transaction_datetime=f"{date} 10:00:00", amount=100,
counterparty="상대", raw_summary="이체", branch="", remittance_memo="",
balance_after=balance, type="income", page=1,
external_id=lambda source_hash: f"kb:{source_hash}:{row}",
raw_payload=lambda: "{}",
)
if kind == "tosssec":
return SimpleNamespace(
transaction_date=date, amount=100, action="매수", name="종목", code="A", section="거래",
quantity=1, unit_price=100, fee=0, tax=0, balance_after=balance, type="income",
external_id=lambda source_hash: f"sec:{source_hash}:{row}", raw_payload=lambda: "{}",
)
return SimpleNamespace(
transaction_date=date, transaction_datetime=f"{date} 10:00:00", amount=100,
action="입금", counterparty="상대", balance_after=balance, type="income", raw_summary="입금",
external_id=lambda source_hash: f"bank:{source_hash}:{row}", raw_payload=lambda: "{}",
)
def _parsed(tx, source, metadata):
return {
"transactions": [tx], "validation": {"ok": True}, "source_hash": f"hash-{source}",
"metadata": metadata, "file_path": "/tmp/synthetic.pdf", "page_count": 1,
}
def test_import_historical_kb_does_not_replace_newer_current_balance(monkeypatch, tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("KB", institution="KB국민은행", last_balance=900, last_synced_at="2026-06-30", db_path=path)["account"]["id"]
monkeypatch.setattr(imports_service, "parse_kb_bank_pdf", lambda *_: _parsed(_fake_tx("2026-06-01", 700, kind="kb"), "kb", {"final_balance": 700, "account_number_masked": ""}))
result = imports_service.import_kb_bank_pdf("ignored", "pw", account_id=account, db_path=path)
current = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0]
assert result["success"] and current["last_balance"] == 900 and current["last_synced_at"] == "2026-06-30"
assert service.list_account_valuations(account, db_path=path)["count"] == 2
def test_import_historical_toss_bank_does_not_replace_newer_current_balance(monkeypatch, tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("Toss", institution="토스뱅크", last_balance=900, last_synced_at="2026-06-30", db_path=path)["account"]["id"]
monkeypatch.setattr(imports_service, "parse_toss_bank_pdf", lambda *_: _parsed(_fake_tx("2026-06-01", 700), "bank", {"account_number_masked": ""}))
result = imports_service.import_toss_bank_pdf("ignored", account_id=account, db_path=path)
current = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0]
assert result["success"] and current["last_balance"] == 900 and current["last_synced_at"] == "2026-06-30"
assert service.list_account_valuations(account, db_path=path)["count"] == 2
def test_import_historical_toss_securities_does_not_replace_newer_current_balance(monkeypatch, tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("Securities", type="investment", institution="토스증권", last_balance=900, last_synced_at="2026-06-30", db_path=path)["account"]["id"]
monkeypatch.setattr(imports_service, "parse_toss_securities_pdf", lambda *_: _parsed(_fake_tx("2026-06-01", 700, kind="tosssec"), "sec", {"account_number_masked": ""}))
result = imports_service.import_toss_securities_pdf("ignored", account_id=account, db_path=path)
current = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0]
assert result["success"] and current["last_balance"] == 900 and current["last_synced_at"] == "2026-06-30"
assert service.list_account_valuations(account, db_path=path)["count"] == 2
def test_source_ref_is_account_scoped_evidence_identity_without_explicit_key(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
first = _account(path)
second = _account(path)
one = service.register_account_valuation(first, "2026-06-30", 100, source_ref="statement", db_path=path)
same = service.register_account_valuation(first, "2026-06-30", 100, source_ref="statement", db_path=path)
changed = service.register_account_valuation(first, "2026-06-30", 101, source_ref="statement", db_path=path)
independent = service.register_account_valuation(second, "2026-06-30", 101, source_ref="statement", db_path=path)
empty_a = service.register_account_valuation(first, "2026-07-01", 1, source_ref="", db_path=path)
empty_b = service.register_account_valuation(first, "2026-07-01", 1, source_ref="", db_path=path)
assert one["success"] and same["reused"] is True
assert changed["success"] is False and changed["error_code"] == "idempotency_conflict"
assert independent["success"] and independent["reused"] is False
assert empty_a["success"] and empty_b["success"] and empty_b["reused"] is False
def test_correction_rejects_self_parent_and_cycles_without_write(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = _account(path)
parent = service.register_account_valuation(account, "2026-06-30", 100, db_path=path)["valuation"]
child = service.register_account_valuation(account, "2026-06-30", 101, supersedes_id=parent["id"], correction_reason="r", db_path=path)["valuation"]
from jarca_ledger_mcp.db import db
with db(path) as conn:
conn.execute("UPDATE account_valuations SET supersedes_id=? WHERE id=?", (child["id"], child["id"]))
cyclic = service.register_account_valuation(account, "2026-06-30", 102, supersedes_id=child["id"], correction_reason="r", db_path=path)
assert cyclic["success"] is False and cyclic["error_code"] == "validation_error"
assert service.list_account_valuations(account, db_path=path)["count"] == 2
def test_correction_preserves_source_ref_without_evidence_identity_conflict(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = _account(path)
parent = service.register_account_valuation(
account, "2026-06-30", 100, source_ref="statement-1", db_path=path,
)["valuation"]
corrected = service.correct_account_valuation(
parent["id"], 110, "정정", idempotency_key="correction-1", db_path=path,
)
replay = service.correct_account_valuation(
parent["id"], 110, "정정", idempotency_key="correction-1", db_path=path,
)
unrelated = service.register_account_valuation(
account, "2026-06-30", 120, source_ref="statement-1", idempotency_key="unrelated", db_path=path,
)
listed = service.list_account_valuations(account, db_path=path)
assert corrected["success"]
assert corrected["valuation"]["source_ref"] == "statement-1"
assert replay["success"] and replay["reused"] is True
assert replay["valuation"]["id"] == corrected["valuation"]["id"]
assert unrelated["success"] is False and unrelated["error_code"] == "idempotency_conflict"
assert listed["count"] == 2
assert sum(item["supersedes_id"] is not None for item in listed["valuations"]) == 1
def test_source_ref_conflicts_even_when_idempotency_keys_differ(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = _account(path)
first = service.register_account_valuation(account, "2026-06-30", 100, source_ref="statement", idempotency_key="a", db_path=path)
same = service.register_account_valuation(account, "2026-06-30", 100, source_ref="statement", idempotency_key="b", db_path=path)
changed = service.register_account_valuation(account, "2026-06-30", 101, source_ref="statement", idempotency_key="c", db_path=path)
assert first["success"] and same["reused"] is True
assert changed["success"] is False and changed["error_code"] == "idempotency_conflict"
+166
View File
@@ -0,0 +1,166 @@
from __future__ import annotations
import hashlib
import inspect
import sqlite3
from pathlib import Path
import pytest
from jarca_ledger_mcp.application import account_valuation_service as valuation
from jarca_ledger_mcp.application import (
assets_service,
categorization_services,
cross_transfer_service,
reconciliation_service,
system_services,
)
from jarca_ledger_mcp.infrastructure.sqlite import connection
def _sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def test_read_queries_missing_nested_db_return_stable_error_without_creating_path(tmp_path):
db_path = tmp_path / "missing" / "nested" / "ledger.sqlite3"
calls = [
lambda: system_services.list_ledger_accounts(db_path=str(db_path)),
lambda: valuation.list_account_valuations("account", db_path=str(db_path)),
lambda: assets_service.list_manual_assets(db_path=str(db_path)),
lambda: assets_service.preview_asset_snapshot_close(db_path=str(db_path)),
lambda: reconciliation_service.get_balance_reconciliation_report(db_path=str(db_path)),
]
for read in calls:
result = read()
assert result["success"] is False
assert result["error_code"] == "db_not_initialized"
assert result["error"]
assert not db_path.exists()
assert not db_path.parent.exists()
def test_read_only_connection_rejects_writes_and_does_not_commit(tmp_path):
db_path = tmp_path / "ledger.sqlite3"
system_services.create_ledger_account("checking", db_path=str(db_path)) # first-use writer initializes
before = _sha256(db_path)
with connection.read_db(str(db_path)) as conn:
with pytest.raises(sqlite3.OperationalError):
conn.execute("INSERT INTO accounts (id, name, type) VALUES ('x', 'x', 'bank')")
assert _sha256(db_path) == before
def test_read_suite_does_not_initialize_or_change_initialized_database(tmp_path, monkeypatch):
db_path = tmp_path / "ledger.sqlite3"
system_services.create_ledger_account("checking", db_path=str(db_path))
before = _sha256(db_path)
def fail_init(*args, **kwargs):
raise AssertionError("read path called init_db")
monkeypatch.setattr("jarca_ledger_mcp.infrastructure.sqlite.migrations.init_db", fail_init)
monkeypatch.setattr("jarca_ledger_mcp.infrastructure.sqlite.connection.init_db", fail_init, raising=False)
reads = [
lambda: system_services.list_ledger_accounts(db_path=str(db_path)),
lambda: valuation.list_account_valuations("account", db_path=str(db_path)),
lambda: assets_service.list_manual_assets(db_path=str(db_path)),
lambda: assets_service.preview_asset_snapshot_close(db_path=str(db_path)),
lambda: reconciliation_service.get_balance_reconciliation_report(db_path=str(db_path)),
]
for read in reads:
assert read()["success"] is True
assert _sha256(db_path) == before
def test_first_use_writer_still_initializes_database(tmp_path):
db_path = tmp_path / "new" / "ledger.sqlite3"
result = system_services.create_ledger_account("checking", db_path=str(db_path))
assert result["success"] is True
assert db_path.is_file()
def test_read_schema_error_is_not_misreported_as_uninitialized(tmp_path):
db_path = tmp_path / "empty.sqlite3"
sqlite3.connect(db_path).close()
result = system_services.list_ledger_accounts(db_path=str(db_path))
assert result["success"] is False
assert result["error_code"] == "db_error"
def test_nested_categorization_preview_propagates_missing_db_envelope(tmp_path):
db_path = tmp_path / "missing" / "ledger.sqlite3"
result = categorization_services.preview_categorization(db_path=str(db_path))
assert result["success"] is False
assert result["error_code"] == "db_not_initialized"
assert not db_path.parent.exists()
def test_public_read_signatures_remain_positional_compatible():
assert list(inspect.signature(valuation.list_account_valuations).parameters)[:2] == ["account_id", "include_rejected"]
assert list(inspect.signature(assets_service.preview_asset_snapshot_close).parameters)[:2] == ["basis_date", "db_path"]
assert list(inspect.signature(reconciliation_service.get_balance_reconciliation_report).parameters)[:2] == ["account_id", "baseline_balance"]
def test_preview_categorization_initialized_db_preserves_success_envelope(tmp_path):
db_path = tmp_path / "ledger.sqlite3"
system_services.create_ledger_account("checking", db_path=str(db_path))
result = categorization_services.preview_categorization(db_path=str(db_path))
assert result["success"] is True
assert result["matched_count"] == 0
assert result["unmatched_count"] == 0
def test_apply_categorization_rules_missing_db_initializes_writer_or_returns_stable_error(tmp_path):
db_path = tmp_path / "nested" / "ledger.sqlite3"
result = categorization_services.apply_categorization_rules(db_path=str(db_path))
assert result["success"] is True
assert result["preview"]["matched_count"] == 0
def test_cross_transfer_review_queue_missing_nested_db_returns_stable_error_without_creating_path(tmp_path):
db_path = tmp_path / "missing" / "nested" / "ledger.sqlite3"
result = cross_transfer_service.list_cross_transfer_review_queue(db_path=str(db_path))
assert result["success"] is False
assert result["error_code"] == "db_not_initialized"
assert result["error"]
assert not db_path.exists()
assert not db_path.parent.exists()
def test_cross_transfer_preview_missing_nested_db_returns_stable_error_without_creating_path(tmp_path):
db_path = tmp_path / "missing" / "nested" / "ledger.sqlite3"
result = cross_transfer_service.preview_cross_account_transfers(db_path=str(db_path))
assert result["success"] is False
assert result["error_code"] == "db_not_initialized"
assert result["error"]
assert not db_path.exists()
assert not db_path.parent.exists()
def test_cross_transfer_read_paths_do_not_initialize_or_mutate_initialized_db(tmp_path, monkeypatch):
db_path = tmp_path / "ledger.sqlite3"
system_services.create_ledger_account("checking", db_path=str(db_path))
before = _sha256(db_path)
def fail_init(*args, **kwargs):
raise AssertionError("cross-transfer read path called init_db")
monkeypatch.setattr("jarca_ledger_mcp.infrastructure.sqlite.migrations.init_db", fail_init)
monkeypatch.setattr("jarca_ledger_mcp.infrastructure.sqlite.connection.init_db", fail_init, raising=False)
queue = cross_transfer_service.list_cross_transfer_review_queue(db_path=str(db_path))
preview = cross_transfer_service.preview_cross_account_transfers(db_path=str(db_path))
assert queue["success"] is True
assert preview["success"] is True
assert _sha256(db_path) == before
+306
View File
@@ -0,0 +1,306 @@
import json
import sqlite3
from jarca_ledger_mcp import service
def test_dated_manual_asset_is_verified_and_preview_needs_approval(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("현금", 100, "2026-06-01", "cash", db_path=path)
preview = service.preview_asset_snapshot_close("2026-06-30", db_path=path, selection_policy="as_of")
assert preview["close_status"] == "incomplete_draft"
item = preview["included_items"][0]
assert item["verification_status"] == "verified"
assert item["evidence_kind"] == "manual_confirmed"
assert item["valuation_date"] == "2026-06-01"
assert item["carried_forward"] is True
assert item["age_days"] == 29
def test_approved_carried_close_is_conditional_and_persists_provenance(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("현금", 100, "2026-06-01", "cash", db_path=path)
closed = service.close_asset_snapshot(
"2026-06-30", db_path=path, selection_policy="as_of",
allow_as_of_carry_forward=True, approved_by="reviewer",
approval_reason="approved carry", approval_reference="APR-59",
)
assert closed["close_status"] == "conditional"
item = closed["included_items"][0]
assert item["verification_status"] == "verified"
assert item["evidence_kind"] == "manual_confirmed"
with sqlite3.connect(path) as conn:
conn.row_factory = sqlite3.Row
row = conn.execute("SELECT applied_evidence FROM asset_snapshot_runs WHERE id=?", (closed["snapshot_run_id"],)).fetchone()
evidence = json.loads(row["applied_evidence"])
assert evidence[0]["valuation_date"] == "2026-06-01"
assert evidence[0]["age_days"] == 29
assert evidence[0]["carried_forward"] is True
assert closed["approval"] == {
"approved_by": "reviewer",
"approved_at": closed["closed_at"],
"approval_reason": "approved carry",
"approval_reference": "APR-59",
}
assert closed["correction"]["is_correction"] is False
assert closed["excluded_items"] == []
assert closed["provenance"]["carried_forward"] is True
assert closed["provenance"]["age_days"] == 29
assert closed["provenance"]["verification_status"] == "verified"
assert closed["provenance"]["evidence_kind"] == "manual_confirmed"
assert closed["provenance"]["approval"] == closed["approval"]
assert closed["provenance"]["correction"] == closed["correction"]
def test_reconciliation_uses_currency_and_loan_priority_and_canonical_provenance(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
investment = service.create_ledger_account("투자", type="investment", currency="USD", last_balance=100, db_path=path)["account"]["id"]
loan = service.create_ledger_account("대출", type="loan", last_balance=100, db_path=path)["account"]["id"]
service.register_account_valuation(investment, "2026-06-30", 100, currency="KRW", valuation_kind="total_value", verification_status="verified", db_path=path)
loan_value = service.register_account_valuation(loan, "2026-06-30", 80, valuation_kind="loan_balance", representative_liability_definition="principal_only", verification_status="verified", db_path=path)
report = service.get_balance_reconciliation_report(account_id=loan, baseline_at="2026-06-30T00:00:00+09:00", db_path=path)
item = report["items"][0]
assert item["external_confirmed_value"] == 80
assert item["valuation_id"] == loan_value["valuation"]["id"]
assert item["valuation_kind"] == "loan_balance"
assert item["provenance"]["valuation_date"] == "2026-06-30"
assert item["provenance"]["verification_status"] == "verified"
def test_investment_without_verified_valuation_and_without_opening_balance_is_incomplete(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("투자", type="investment", db_path=path)["account"]["id"]
report = service.get_balance_reconciliation_report(account_id=account, baseline_at="2026-06-30T00:00:00+09:00", db_path=path)
item = report["items"][0]
assert item["status"] == "VALUATION_UNAVAILABLE"
assert item["calculation_complete"] is False
assert item["difference"] is None
assert item["external_confirmed_value"] is None
def test_timeseries_and_monthly_expose_canonical_asset_provenance(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("현금", 100, "2026-06-30", "cash", db_path=path)
closed = service.close_asset_snapshot("2026-06-30", db_path=path)
report = service.get_asset_snapshot_report("2026-06-30", db_path=path)
timeseries = service.get_net_worth_timeseries(start_date="2026-06-01", end_date="2026-06-30", db_path=path)
monthly = service.get_monthly_settlement_summary("2026-06", db_path=path)
for payload in (report, timeseries["points"][0], monthly["reports"]["assets"], monthly["source_coverage"]["assets"]):
assert "provenance" in payload
assert "valuation_date" in payload["provenance"]
assert "warnings" in payload["provenance"]
assert "exclusions" in payload["provenance"]
assert report["provenance"]["snapshot_run_id"] == closed["snapshot_run_id"]
def test_reconciliation_does_not_mark_last_balance_without_opening_evidence_complete(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("manual", last_balance=1000, last_synced_at="2026-06-30", db_path=path)["account"]["id"]
item = service.get_balance_reconciliation_report(account_id=account, db_path=path)["items"][0]
assert item["calculation_complete"] is False
assert item["difference"] is None
assert item["status"] == "INCOMPLETE_CALCULATION"
def test_monthly_settlement_propagates_snapshot_provenance_id_and_actual_valuation_date(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("cash", 100, "2026-06-01", "cash", db_path=path)
closed = service.close_asset_snapshot("2026-06-30", db_path=path, selection_policy="as_of", allow_as_of_carry_forward=True, approved_by="r", approval_reason="r", approval_reference="r")
monthly = service.get_monthly_settlement_summary("2026-06", db_path=path)
assets = monthly["source_coverage"]["assets"]
assert assets["monthly_source_snapshot_id"] == closed["snapshot_run_id"]
assert assets["provenance"]["valuation_date"] == "2026-06-01"
assert "warnings" in assets and "excluded_items" in assets and "carried_items" in assets
def test_approved_carry_provenance_is_identical_across_closed_downstream_reports(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("cash", 100, "2026-06-01", "cash", db_path=path)
closed = service.close_asset_snapshot(
"2026-06-30", db_path=path, selection_policy="as_of",
allow_as_of_carry_forward=True, approved_by="reviewer",
approval_reason="approved carry", approval_reference="APR-59",
)
report = service.get_asset_snapshot_report("2026-06-30", db_path=path)
point = service.get_net_worth_timeseries(start_date="2026-06-30", end_date="2026-06-30", db_path=path)["points"][0]
monthly = service.get_monthly_settlement_summary("2026-06", db_path=path)
payloads = [report, point, monthly["reports"]["assets"], monthly["source_coverage"]["assets"]]
expected = {key: closed[key] for key in ("warnings", "excluded_items", "carried_items", "approval", "correction")}
expected.update({key: closed["provenance"][key] for key in ("valuation_date", "age_days", "carried_forward", "verification_status", "evidence_kind")})
for payload in payloads:
assert payload.get("approval") == expected["approval"]
assert payload.get("correction") == expected["correction"]
assert payload.get("warnings") == expected["warnings"]
assert payload.get("excluded_items") == expected["excluded_items"]
assert payload.get("carried_items") == expected["carried_items"]
provenance = payload["provenance"]
for key in ("valuation_date", "age_days", "carried_forward", "verification_status", "evidence_kind"):
assert provenance[key] == expected[key]
assert report["approval"] == expected["approval"]
assert report["correction"] == expected["correction"]
assert point["approval"] == expected["approval"]
assert point["correction"] == expected["correction"]
assert monthly["source_coverage"]["assets"]["monthly_source_snapshot_id"] == closed["snapshot_run_id"]
def test_preview_and_live_report_redact_excluded_source_note_and_memo(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
sentinel = "SHOULD_NOT_LEAK_C10"
account = service.create_ledger_account(
"unverified account", memo=sentinel, last_balance=100, last_synced_at="2026-06-30", db_path=path,
)["account"]["id"]
service.register_account_valuation(account, "2026-06-30", 100, verification_status="unverified", db_path=path)
service.create_manual_asset("missing manual", None, "2026-06-30", "cash", source_note=sentinel, memo=sentinel, db_path=path)
preview = service.preview_asset_snapshot_close("2026-06-30", db_path=path)
live = service.get_asset_snapshot_report("2026-06-30", db_path=path)
for payload in (preview, live):
assert sentinel not in json.dumps(payload, ensure_ascii=False)
assert all(sentinel not in json.dumps(item, ensure_ascii=False) for item in payload.get("warnings", []))
assert all(sentinel not in json.dumps(item, ensure_ascii=False) for item in payload.get("excluded_items", []))
assert sentinel not in json.dumps(payload.get("provenance", {}), ensure_ascii=False)
def test_live_report_and_monthly_propagate_canonical_carry_forward_metadata(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("cash", 100, "2026-06-01", "cash", db_path=path)
live = service.get_asset_snapshot_report("2026-06-30", db_path=path, selection_policy="as_of")
monthly = service.get_monthly_settlement_summary("2026-06", db_path=path)
exact = service.get_asset_snapshot_report("2026-06-30", db_path=path)
expected_keys = {"warnings", "carried_items", "approval", "correction"}
assert expected_keys.issubset(live)
assert live["provenance"]["carried_forward"] is True
assert any(warning["code"] == "stale_valuation" for warning in live["warnings"])
assets = monthly["source_coverage"]["assets"]
assert expected_keys.issubset(assets)
assert assets["warnings"] == exact["warnings"]
assert assets["carried_items"] == exact["carried_items"]
assert assets["approval"] == exact["approval"]
assert assets["correction"] == exact["correction"]
def test_public_included_items_redact_source_note_and_memo_recursively(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
sentinel = "SHOULD_NOT_LEAK_C10_INCLUDED"
service.create_manual_asset("cash", 100, "2026-06-01", "cash", source_note=sentinel, memo=sentinel, db_path=path)
preview = service.preview_asset_snapshot_close("2026-06-30", db_path=path, selection_policy="as_of")
closed = service.close_asset_snapshot(
"2026-06-30", db_path=path, selection_policy="as_of",
allow_as_of_carry_forward=True, approved_by="reviewer",
approval_reason="approved carry", approval_reference="APR-59",
)
report = service.get_asset_snapshot_report("2026-06-30", db_path=path)
payloads = [preview, closed, report]
for payload in payloads:
assert sentinel not in json.dumps(payload.get("included_items", []), ensure_ascii=False)
assert all("source_note" not in item and "memo" not in item for item in payload.get("included_items", []))
assert sentinel not in json.dumps(report, ensure_ascii=False)
def test_snapshot_null_amount_remains_null_and_is_excluded_from_totals(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
service.create_asset_snapshot("2026-06-30", "manual_asset", "unvalued", "unvalued", None, "cash", db_path=path)
service.create_asset_snapshot("2026-06-30", "manual_asset", "zero", "zero", 0, "cash", db_path=path)
report = service.get_asset_snapshot_report("2026-06-30", db_path=path)
amounts = {item["name"]: item["amount"] for item in report["included_items"]}
assert amounts == {"unvalued": None, "zero": 0}
assert report["total_assets"] == 0
assert report["buckets"][0]["item_count"] == 1
assert report["close_status"] == "incomplete_draft"
assert any(item["name"] == "unvalued" and item["reason"] == "missing_amount" for item in report["excluded_items"])
def test_idempotent_snapshot_close_replay_returns_original_immutable_response(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("cash", 100, "2026-06-30", "cash", db_path=path)
kwargs = dict(basis_date="2026-06-30", db_path=path, idempotency_key="close-replay")
original = service.close_asset_snapshot(**kwargs)
replay = service.close_asset_snapshot(**kwargs)
assert replay == original
assert "reused" not in replay
for key in ("provenance", "approval", "correction", "warnings", "excluded_items", "carried_items", "buckets", "bucket_totals", "total_assets", "total_liabilities", "net_worth"):
assert replay[key] == original[key]
def test_asset_close_and_report_expose_provenance_aliases(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("cash", 100, "2026-06-01", "cash", db_path=path)
closed = service.close_asset_snapshot(
"2026-06-30", db_path=path, selection_policy="as_of",
allow_as_of_carry_forward=True, approved_by="reviewer",
approval_reason="approved carry", approval_reference="APR-59",
)
report = service.get_asset_snapshot_report("2026-06-30", db_path=path)
for payload in (closed, report):
assert payload["valuation_date"] == payload["provenance"]["valuation_date"] == "2026-06-01"
assert payload["age_days"] == payload["provenance"]["age_days"] == 29
assert payload["verification_status"] == payload["provenance"]["verification_status"] == "verified"
assert payload["evidence_kind"] == payload["provenance"]["evidence_kind"] == "manual_confirmed"
assert payload["monthly_source_snapshot_id"] == payload["provenance"]["monthly_source_snapshot_id"] == closed["snapshot_run_id"]
def test_report_invalid_basis_date_returns_stable_error_envelope(tmp_path):
result = service.get_asset_snapshot_report("not-a-date", db_path=str(tmp_path / "ledger.sqlite3"))
assert result == {"success": False, "error_code": "validation_error", "error": "invalid date: not-a-date"}
def test_manual_asset_evidence_has_canonical_currency_before_close(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("cash", 100, "2026-06-30", "cash", currency="krw", db_path=path)
preview = service.preview_asset_snapshot_close("2026-06-30", db_path=path)
assert preview["included_items"][0]["currency"] == "KRW"
closed = service.close_asset_snapshot("2026-06-30", db_path=path)
assert closed["included_items"][0]["currency"] == "KRW"
with sqlite3.connect(path) as conn:
evidence = json.loads(conn.execute("SELECT applied_evidence FROM asset_snapshot_runs").fetchone()[0])
assert evidence[0]["currency"] == "KRW"
def test_close_rejects_authority_drift_since_preview(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("cash", 100, "2026-06-30", "cash", db_path=path)
preview = service.preview_asset_snapshot_close("2026-06-30", db_path=path)
assert preview["preview_fingerprint"]
service.update_manual_asset("asset-does-not-exist", valuation_amount=101, db_path=path)
# Mutate the real authority row without changing the public API result shape.
with sqlite3.connect(path) as conn:
conn.execute("UPDATE manual_assets SET valuation_amount=101")
result = service.close_asset_snapshot("2026-06-30", db_path=path, preview_fingerprint=preview["preview_fingerprint"])
assert result["success"] is False
assert result["error_code"] == "authority_drift"
def test_close_metadata_is_allowlisted_recursively(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("cash", 100, "2026-06-30", "cash", db_path=path)
service.close_asset_snapshot("2026-06-30", db_path=path)
with sqlite3.connect(path) as conn:
conn.execute("UPDATE asset_snapshot_runs SET applied_evidence=?", ('[{"valuation_date":"2026-06-30","future_secret":{"source_note":"LEAK"}}]',))
report = service.get_asset_snapshot_report("2026-06-30", db_path=path)
assert "future_secret" not in json.dumps(report["close_metadata"])
assert "LEAK" not in json.dumps(report["close_metadata"])
def test_public_evidence_redacts_nested_notes_without_mutating_input_or_hash():
from jarca_ledger_mcp.application.assets_service import _evidence_state_hash, _public_evidence
item = {"amount": 0, "source_note": "top", "nested": {"memo": "deep", "keep": [1, {"source_note": "list"}]}}
before_hash = _evidence_state_hash(item)
redacted = _public_evidence(item)
assert redacted == {"amount": 0, "nested": {"keep": [1, {}]}}
assert item["nested"]["memo"] == "deep"
assert _evidence_state_hash(item) == before_hash
+218 -1
View File
@@ -40,11 +40,26 @@ def test_requirement_docs_are_canonical_and_traceable() -> None:
assert body.count("**State Invariants**") == 10
assert body.count("**판정 기준**") == 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
+1 -1
View File
@@ -80,7 +80,7 @@ def test_reconciliation_session_round_trip_and_report_integration(tmp_path: Path
assert report["success"] is True
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
+2
View File
@@ -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)
+9 -9
View File
@@ -216,8 +216,8 @@ def test_statement_status_account_update_and_balance_reconciliation(tmp_path):
report = service.get_balance_reconciliation_report(account_id=account_id, db_path=db_path)
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):