fix: address account valuation review findings
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
## 저장과 마이그레이션
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 순서를 보존한다.
|
||||
|
||||
@@ -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,8 +189,39 @@ 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()
|
||||
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))
|
||||
@@ -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")}
|
||||
|
||||
@@ -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")
|
||||
try:
|
||||
basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat()
|
||||
with db(db_path) as conn:
|
||||
except Exception:
|
||||
return fail("validation_error", f"invalid date: {basis_date}")
|
||||
with read_db(db_path) as conn:
|
||||
rows, close_metadata = _snapshot_rows_for_latest_run(conn, basis)
|
||||
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])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user