diff --git a/dashboard/page_views/accounts.py b/dashboard/page_views/accounts.py index 49fa928..c628829 100644 --- a/dashboard/page_views/accounts.py +++ b/dashboard/page_views/accounts.py @@ -176,6 +176,50 @@ def page_accounts(db_path: str) -> None: accounts_data = accounts_res.get("accounts", []) if accounts_res.get("success") else [] if accounts_data: render_account_list(accounts_data) + selected_id = st.selectbox("평가 이력 계좌", [a["id"] for a in accounts_data], format_func=lambda value: next((a.get("name", value) for a in accounts_data if a["id"] == value), value)) + selected_account = next(a for a in accounts_data if a["id"] == selected_id) + history = accounts_gateway.list_account_valuations(selected_id, db_path=db_path) + valuations = history.get("valuations", []) if history.get("success") else [] + if valuations: + st.dataframe(valuations, width="stretch", hide_index=True) + # The list is historical insertion order; a rejected/corrected + # tail is not necessarily the effective current valuation. + basis = (selected_account.get("last_synced_at") or "")[:10] + selected = accounts_gateway.select_account_valuation(selected_id, basis, as_of=True, db_path=db_path) if basis else {} + selected_valuation = selected.get("valuation") if selected.get("success") else None + latest = selected_valuation or next( + (item for item in reversed(valuations) if item.get("verification_status") == "verified"), valuations[-1] + ) + st.caption(f"최근 실제 기준일: {latest.get('basis_date', '-')} · 상태: {latest.get('verification_status', 'unknown')} · 종류: {latest.get('valuation_kind', 'unknown')}") + else: + st.warning("평가 이력이 없습니다.") + with st.expander("계좌 평가 등록"): + with st.form(f"account_valuation_register_{selected_id}"): + v_date = st.text_input("평가 기준일", value=selected_account.get("last_synced_at", "")[:10]) + v_amount = st.number_input("평가 금액", value=int(selected_account.get("last_balance") or 0), step=1) + default_kind = "loan_balance" if selected_account.get("type") == "loan" else ("total_value" if selected_account.get("type") == "investment" else "cash_balance") + v_kind = st.selectbox("평가 종류", ["cash_balance", "balance", "total_value", "loan_balance", "loan"], index=["cash_balance", "balance", "total_value", "loan_balance", "loan"].index(default_kind)) + v_status = st.selectbox("검증 상태", ["unverified", "verified", "rejected"]) + v_definition = st.selectbox("대표 부채 정의(대출 필수)", ["", "principal_only", "principal_interest_fees"]) + v_submit = st.form_submit_button("평가 등록") + if v_submit: + result = accounts_gateway.register_account_valuation(selected_id, v_date, int(v_amount), valuation_kind=v_kind, verification_status=v_status, representative_liability_definition=v_definition or None, db_path=db_path) + if result.get("success"): + clear_cache(); st.success("평가를 등록했습니다."); st.rerun() + else: + st.error(result.get("error", result)) + with st.expander("계좌 평가 정정"): + with st.form(f"account_valuation_correct_{selected_id}"): + correction_id = st.text_input("정정 대상 평가 ID") + correction_amount = st.number_input("정정 금액", value=0, step=1, key=f"correction_amount_{selected_id}") + correction_reason = st.text_input("정정 사유") + correction_submit = st.form_submit_button("평가 정정") + if correction_submit: + result = accounts_gateway.correct_account_valuation(correction_id, int(correction_amount), correction_reason, db_path=db_path) + if result.get("success"): + clear_cache(); st.success("평가를 정정했습니다."); st.rerun() + else: + st.error(result.get("error", result)) account_edit_id = st.session_state.get("account_edit_id") if account_edit_id: account = next((item for item in accounts_data if item["id"] == account_edit_id), None) diff --git a/dashboard/page_views/assets.py b/dashboard/page_views/assets.py index 0ccefa2..51a11d5 100644 --- a/dashboard/page_views/assets.py +++ b/dashboard/page_views/assets.py @@ -75,15 +75,28 @@ def page_assets(db_path: str) -> None: st.divider() render_section_label("🔒", "월말 스냅샷 Preview / Close") st.caption("Preview는 DB를 변경하지 않습니다. 같은 기준일을 다시 close하면 정정 사유가 필요하며 기존 행을 덮어쓰지 않고 새 correction run을 append합니다.") - c1, c2, c3 = st.columns([1, 1.5, 1.5]) + c1, c2, c3, c4 = st.columns([1, 1, 1.5, 1.5]) basis_date = c1.text_input("마감 기준일", value=f"{current_kst_month()}-01", help="YYYY-MM-DD") - close_note = c2.text_input("Close 출처 메모", value="month-end close") - correction_reason = c3.text_input("정정 사유(재마감 시 필수)", value="") + selection_policy = c2.selectbox("선택 정책", ["exact", "as_of"], format_func=lambda value: "정확히 해당일" if value == "exact" else "기준일 현재(as-of)") + close_note = c3.text_input("Close 출처 메모", value="month-end close") + correction_reason = c4.text_input("정정 사유(재마감 시 필수)", value="") p1, p2 = st.columns(2) + approval_required = st.checkbox("조건부 close 승인 명시", value=False) + approval_cols = st.columns(3) + approved_by = approval_cols[0].text_input("승인자") + approval_reason = approval_cols[1].text_input("승인 사유") + approval_reference = approval_cols[2].text_input("승인 근거 참조") + allow_missing = st.checkbox("누락 제외 허용", value=False) + allow_carry = st.checkbox("as-of 이월 허용", value=False) if p1.button("스냅샷 Preview", type="secondary"): - st.session_state["asset_snapshot_preview"] = assets_gateway.preview_asset_snapshot_close(basis_date, db_path=db_path) + st.session_state["asset_snapshot_preview"] = assets_gateway.preview_asset_snapshot_close(basis_date, selection_policy=selection_policy, db_path=db_path) if p2.button("스냅샷 Close 실행", type="primary"): - st.session_state["asset_snapshot_close"] = assets_gateway.close_asset_snapshot(basis_date, source_note=close_note, correction_reason=correction_reason, db_path=db_path) + st.session_state["asset_snapshot_close"] = assets_gateway.close_asset_snapshot( + basis_date, source_note=close_note, correction_reason=correction_reason, + selection_policy=selection_policy, allow_missing_exclusions=allow_missing, + allow_as_of_carry_forward=allow_carry, approved_by=approved_by if approval_required else "", + approval_reason=approval_reason if approval_required else "", approval_reference=approval_reference if approval_required else "", db_path=db_path, + ) clear_cache() preview = st.session_state.get("asset_snapshot_preview") close_res = st.session_state.get("asset_snapshot_close") diff --git a/dashboard/page_views/monthly_settlement.py b/dashboard/page_views/monthly_settlement.py index 7d42730..6a3fb8f 100644 --- a/dashboard/page_views/monthly_settlement.py +++ b/dashboard/page_views/monthly_settlement.py @@ -202,7 +202,10 @@ 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)}") + st.caption(f"마감 기준일: {asset_snapshot.get('basis_date', basis_date)} · 선택 정책: {asset_snapshot.get('selection_policy', 'exact')} · close_status: {asset_snapshot.get('close_status', '미확인')}") + close_metadata = asset_snapshot.get("close_metadata", {}) if isinstance(asset_snapshot, dict) else {} + if close_metadata.get("approved_by") or close_metadata.get("approval_reason"): + st.caption(f"조건부 승인: {close_metadata.get('approved_by', '-')} · {close_metadata.get('approval_reason', '-')} · {close_metadata.get('approval_reference', '-')}") st.markdown("- [자산/마감 화면에서 스냅샷 확인](?page=자산/마감)") render_section_label("📜", "월정산 결과 / Evidence") diff --git a/docs/data-model.md b/docs/data-model.md new file mode 100644 index 0000000..405a532 --- /dev/null +++ b/docs/data-model.md @@ -0,0 +1,157 @@ +# 계좌 평가 이력과 자산 마감 데이터 모델 + +## 목적과 범위 + +계좌 평가 증거, 수동 자산, append-only 자산 마감과 기존 ledger entities의 실제 SQLite 정본을 기록한다. + +- 포함 범위: `accounts`, `transactions`, `manual_assets`, `account_valuations`, `asset_snapshots`, `asset_snapshot_runs`, `reconciliation_sessions`, `review_decisions`, `import_batches`, `schema_meta` +- 제외 범위: auth cookie/session, external price/FX store, inferred transactions, actual credentials/data + +## 정본 엔터티 + +### accounts — 기존 계좌 + +- 책임: 계좌 identity/current display balance와 account type/liquidity metadata +- 식별자: `id` +- 생명 주기: 기존 CRUD; valuation history는 별도 append-only + +#### 필드 + +| 필드 | 타입 | 필수 여부 | 기본값 | 허용 값·형식 | 단위·정밀도 | 민감정보 | +|---|---|---|---|---|---|---| +| `id` | TEXT | 필수 | 없음 | generated account id | 식별자 | 아니오 | +| `name` | TEXT | 필수 | 없음 | non-empty | 없음 | 아니오 | +| `type` | TEXT | 필수 | `bank` | bank/card/cash/investment/loan/other | enum | 아니오 | +| `currency` | TEXT | 필수 | `KRW` | supported currency | ISO code | 아니오 | +| `last_balance` | INTEGER | 선택 | NULL | exact integer | currency unit | 아니오 | +| `last_synced_at` | TEXT | 선택 | `''` | ISO date/time | timestamp | 아니오 | +| `statement_status` | TEXT | 필수 | `unknown` | existing status enum | 없음 | 아니오 | +| `liquidity_bucket` | TEXT | 필수 | `other` | cash/deposit/investment/real_estate/other/loan | enum | 아니오 | +| `account_number_masked` | TEXT | 필수 | `''` | masked only | redacted | 민감 가능 | + +### account-valuations — 기준일 계좌 평가 + +- 책임: 날짜가 지정된 평가 evidence와 선택/correction provenance +- 식별자: `id` +- 생명 주기: insert-only; correction은 `supersedes_id` child + +#### 필드 + +| 필드 | 타입 | 필수 여부 | 기본값 | 허용 값·형식 | 단위·정밀도 | 민감정보 | +|---|---|---|---|---|---|---| +| `id` | TEXT | 필수 | 없음 | generated valuation id | 식별자 | 아니오 | +| `account_id` | TEXT FK | 필수 | 없음 | accounts.id | 관계 | 아니오 | +| `basis_date` | TEXT | 필수 | 없음 | YYYY-MM-DD | date | 아니오 | +| `amount` | INTEGER | 필수 | 없음 | exact signed integer | currency unit | 아니오 | +| `currency` | TEXT | 필수 | KRW | supported currency | ISO code | 아니오 | +| `valuation_kind` | TEXT | 필수 | balance | balance/cash_balance/total_value/loan/loan_balance | purpose | 아니오 | +| `source_type` | TEXT | 필수 | manual | manual/import/account_write/reconciliation/snapshot | enum | 아니오 | +| `source_ref` | TEXT | 필수 | `''` | source/hash/reference | opaque | 민감 가능 | +| `verification_status` | TEXT | 필수 | unverified | verified/unverified/rejected | enum | 아니오 | +| `recorded_at` | TEXT | 필수 | 없음 | ISO timestamp | timestamp | 아니오 | +| `memo` | TEXT | 필수 | `''` | operator note | 없음 | synthetic only | +| `idempotency_key` | TEXT | 필수 | `''` | caller key | opaque | 아니오 | +| `supersedes_id` | TEXT FK | 선택 | NULL | parent valuation | one child | 아니오 | +| `correction_reason` | TEXT | 필수 | `''` | required for correction | 없음 | 아니오 | +| `as_of_at`/`timezone` | TEXT | 함께 선택 | NULL | timezone-aware ISO + IANA | timestamp/zone | 아니오 | +| `cost_basis`,`net_contributed`,`profit`,`principal`,`interest`,`fee` | INTEGER | 선택 | NULL | exact integer; NULL distinct from 0 | currency unit | 아니오 | +| `reported_principal_kind`,`reported_principal_label`,`return_basis` | TEXT | 선택 | NULL | source-declared | 없음 | 아니오 | +| `return_rate` | REAL | 선택 | NULL | finite number | ratio | 아니오 | +| `representative_liability_definition` | TEXT | loan 필수 | NULL | principal_only/principal_interest_fees | enum | 아니오 | + +### manual-assets — 수동 자산/부채 + +- 책임: transaction이 아닌 평가 source +- 식별자: `id` +- 생명 주기: update/deactivate; closed snapshot은 별도 immutable copy + +#### 필드 + +| 필드 | 타입 | 필수 여부 | 기본값 | 허용 값·형식 | 단위·정밀도 | 민감정보 | +|---|---|---|---|---|---|---| +| `id` | TEXT | 필수 | 없음 | generated asset id | 식별자 | 아니오 | +| `name` | TEXT | 필수 | 없음 | non-empty | 없음 | 아니오 | +| `liquidity_bucket` | TEXT | 필수 | other | normalized bucket | enum | 아니오 | +| `valuation_amount` | INTEGER | 선택 | NULL | exact integer; NULL unvalued | currency unit | 아니오 | +| `valuation_date` | TEXT | 필수 | `''` | YYYY-MM-DD | date | 아니오 | +| `currency` | TEXT | 필수 | KRW | supported currency | ISO code | 아니오 | +| `source_note`,`memo` | TEXT | 필수 | `''` | synthetic/redacted note | 없음 | 민감 가능 | +| `active` | INTEGER | 필수 | 1 | 0/1 | boolean | 아니오 | + +### asset-snapshots — closed snapshot rows + +- 책임: basis-date historical immutable item copy +- 식별자: `id` +- 생명 주기: insert-only; correction run creates new rows + +#### 필드 + +| 필드 | 타입 | 필수 여부 | 기본값 | 허용 값·형식 | 단위·정밀도 | 민감정보 | +|---|---|---|---|---|---|---| +| `id`,`basis_date`,`source_type`,`name`,`created_at` | TEXT | 필수 | 없음 | generated/date/account/manual_asset | identity/date | 아니오 | +| `source_id`,`currency`,`liquidity_bucket`,`valuation_source`,`source_note`,`memo` | TEXT | 필수 | `''`/KRW/other | source metadata | 없음 | 가능 | +| `amount` | INTEGER | 선택 | NULL | exact integer | currency unit | 아니오 | +| `snapshot_run_id` | TEXT | 필수 | `''` | asset_snapshot_runs.id | relation | 아니오 | +| `correction_of_run_id`,`correction_reason` | TEXT | 필수 | `''` | prior run/reason | provenance | 아니오 | +| `is_correction` | INTEGER | 필수 | 0 | 0/1 | boolean | 아니오 | +| `closed_at` | TEXT | 필수 | `''` | ISO timestamp | timestamp | 아니오 | +| `valuation_id`,`selection_policy`,`verification_status`,`evidence_kind`,`exclusion_reason` | TEXT | additive | empty/exact/unverified/applied/empty | provenance enum | 없음 | 아니오 | + +### asset-snapshot-runs — close 실행 증거 + +- 책임: approval, selection, applied/excluded/carried evidence와 run identity +- 식별자: `id` +- 생명 주기: insert-only; correction points to prior run + +#### 필드 + +| 필드 | 타입 | 필수 여부 | 기본값 | 허용 값·형식 | 단위·정밀도 | 민감정보 | +|---|---|---|---|---|---|---| +| `id`,`basis_date`,`close_status` | TEXT | 필수 | 없음 | complete_exact/incomplete_draft/conditional | identity/date/status | 아니오 | +| `selection_policy` | TEXT | 필수 | exact | exact/as_of | enum | 아니오 | +| `idempotency_key` | TEXT | 필수 | `''` | unique when non-empty | opaque | 아니오 | +| `approved_by`,`approved_at`,`approval_reason`,`approval_reference` | TEXT | 필수 | empty | explicit approval metadata | provenance | 아니오 | +| `correction_of_run_id`,`correction_reason` | TEXT | 필수 | empty | prior run/reason | provenance | 아니오 | +| `applied_evidence`,`excluded_evidence`,`carried_evidence` | TEXT JSON | 필수 | `[]` | redacted evidence arrays | JSON | 가능 | +| `created_at` | TEXT | 필수 | 없음 | ISO timestamp | timestamp | 아니오 | + +### transactions, import-batches, reconciliation-sessions, review-decisions, schema-meta — 기존 entities + +기존 필드와 관계를 보존한다. import는 거래를 기록할 수 있지만 balance valuation candidate는 dated `unverified` row만 추가한다. reconciliation은 calculated balance provenance와 `VALUATION_MANAGED` 상태를 보존하며 investment valuation 때문에 transaction을 생성하지 않는다. `schema_meta.schema_version`은 12다. + +## 관계와 불변조건 + +### 관계 + +| 출발 엔터티 | 관계 | 대상 엔터티 | 카디널리티 | 필수 여부 | 소유권 | 삭제 규칙 | +|---|---|---|---|---|---|---| +| `accounts` | has valuations | `account-valuations` | 1:N | required | account service | no cascade/history preserved | +| `account-valuations` | supersedes | `account-valuations` | 0..1 child | optional | valuation service | append-only | +| `asset-snapshot-runs` | contains | `asset-snapshots` | 1:N/possibly zero | required | snapshot service | no delete | +| `account-valuations` | evidence for | `asset-snapshots` | 0..N | optional | snapshot run | copy only | +| `import-batches` | produces candidate | `account-valuations` | 0..N | optional | import service | candidate preserved | +| `accounts` | has reconciliation | `reconciliation-sessions` | 1:N | optional | reconciliation service | no valuation side effect | + +### 불변조건 + +- `idx_account_valuations_single_correction` ensures one direct child; 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. +- `asset_buckets - loan` is the net-worth formula; representative loan only, no fake transactions. + +## 저장과 마이그레이션 + +- 저장 위치: resolved SQLite path from `jarca_ledger_mcp.infrastructure.sqlite.connection` +- 직렬화 형식: SQLite tables; evidence arrays JSON UTF-8 +- 스키마 버전: schema 11 → schema 12 additive +- 기존 데이터 처리: `migrate_db()` creates missing tables/columns/indexes; no guessed migration or deletion +- 하위 호환성: existing ledger/auth/menu/MCP contracts and legacy rows remain readable +- 롤백 조건: pre-migration backup restore boundary; do not down-migrate or delete valuation history in application code + +## 검증 + +- 정상 데이터: `tests/test_account_valuations.py`, `tests/test_asset_snapshots.py` +- 경계 데이터: NULL/0, stale carry-forward, correction, empty snapshot tables +- 오류 데이터: invalid currency/kind, missing loan definition, idempotency/correction conflict +- 저장 후 재조회: service list/select/report and contract regression tests +- 마이그레이션: `test_*schema*` tests plus `python -m pytest tests/test_account_valuations.py tests/test_asset_snapshots.py` diff --git a/docs/interface-contract.md b/docs/interface-contract.md index 21beda7..07f6048 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -259,3 +259,130 @@ Set-Cookie: __Host-jarca_dashboard_auth=; Max-Age=0; Secure; HttpOnly; SameSite= AppTest는 callback, query mapping, synthetic renderer output과 error/loading event를 검증하지만 실제 browser의 document navigation 부재, paint timing, focus/scroll, network request 또는 perceived shell continuity를 증명하지 않는다. 따라서 `REQ-002.03-AC01`은 authenticated real-browser evidence가 필수이며 AppTest는 companion evidence일 뿐이다. history/back-forward와 path URL은 계약에서 제외한다. 상세 acceptance target은 `docs/requirements/req-002_dashboard-menu-transition.md`의 `REQ-002.01-AC01`부터 `REQ-002.09-AC01`까지를 따른다. + +## 계좌 평가·자산 마감 계약 — REQ-003 + +### valuation-service + +- 책임: `register_account_valuation()`, `list_account_valuations()`, `select_account_valuation()`, `correct_account_valuation()`을 통해 exact/as_of verified leaf와 append-only history를 제공한다. +- 종류·프로토콜: Python service function; `jarca_ledger_mcp.service` re-export +- 호출 대상·operation: `jarca_ledger_mcp.application.account_valuation_service` +- 버전: additive v1 +- 호출 주체: Dashboard accounts gateway, MCP tool, import/account write service +- 인증 방식: service layer 자체 MCP auth는 주장하지 않음; Dashboard는 기존 `gate` 선행 +- 필요 권한: caller가 DB path와 approval context를 제공 + +#### 입력 + +| 필드 | 위치 | 타입 | 필수 여부 | 기본값 | 허용 값·형식 | 의미 | 민감정보 | +|---|---|---|---|---|---|---|---| +| `account_id`/`basis_date`/`amount` | function/MCP | `string/string/int` | register 필수 | 없음 | account id, YYYY-MM-DD, exact integer | valuation identity/value | 아니오 | +| `currency`/`valuation_kind`/`source_type`/`verification_status` | function/MCP | `string` | 선택 | KRW/balance/manual/unverified | actual enums in source | 아니오 | +| `idempotency_key` | function/MCP | `string` | 선택 | `''` | stable caller key | retry identity | 아니오 | +| `as_of_at`/`timezone`, optional metrics, loan definition | function/MCP | nullable string/int/float | 선택 | NULL | source validation; loan definition required | evidence metrics | 아니오 | + +#### 출력 + +| 필드 | 위치 | 타입 | 필수 여부 | 허용 값·형식 | 의미 | 민감정보 | +|---|---|---|---|---|---|---| +| `success` | envelope | `bool` | 항상 | true/false | outcome | 아니오 | +| `valuation`/`valuations` | result | object/list | 성공 시 | persisted rows | history/evidence | source_ref may be sensitive | +| `reused`, `carried_forward`, `warning`, `unverified_candidates` | result | bool/string/list | 조건부 | additive | idempotency/selection evidence | 아니오 | + +#### 오류 + +| 코드 | 분류 | 발생 조건 | 재시도 가능 여부 | 호출자 대응 | 실패 시 상태 변경 | +|---|---|---|---|---|---| +| `validation_error` | request | invalid date/type/currency/loan definition | 수정 후 가능 | payload 수정 | 없음 | +| `not_found` | lookup | account/valuation missing | 수정 후 가능 | id 확인 | 없음 | +| `idempotency_conflict` | conflict | same key different payload | 아니오 | new key or inspect | 없음 | +| `correction_conflict`/`ambiguous_valuation` | conflict | second child/multiple leaves | 수동 검토 | correct evidence | 없음 | + +### snapshot-close + +- 책임: `preview_asset_snapshot_close()` is read-only; `close_asset_snapshot()` writes an immutable run and rows. +- 종류·프로토콜: Python service, Streamlit `assets_gateway`, MCP tools +- 호출 대상·operation: `jarca_ledger_mcp.application.assets_service` +- 버전: additive v1 +- 호출 주체: authenticated Dashboard operator or MCP caller +- 인증 방식: Dashboard existing auth gate; MCP authentication is outside this contract +- 필요 권한: close conditional approval fields when warnings require it + +#### 입력 + +| 필드 | 위치 | 타입 | 필수 여부 | 기본값 | 허용 값·형식 | 의미 | 민감정보 | +|---|---|---|---|---|---|---|---| +| `basis_date`/`selection_policy` | function/MCP/form | `string/string` | basis optional | today/exact | YYYY-MM-DD, exact/as_of | close scope | 아니오 | +| `idempotency_key`/`correction_reason` | function/MCP/form | `string` | conditional | empty | stable key; reason on re-close | retry/correction | 아니오 | +| `allow_missing_exclusions`/`allow_as_of_carry_forward` | function/MCP | `bool` | optional | false | explicit flags | conditional approval need | 아니오 | +| `approved_by`/`approval_reason`/`approval_reference` | function/MCP/form | `string` | conditional | empty | all required together | approval evidence | 아니오 | + +#### 출력 + +| 필드 | 위치 | 타입 | 필수 여부 | 허용 값·형식 | 의미 | 민감정보 | +|---|---|---|---|---|---|---| +| `source`/`is_closed_snapshot`/`snapshot_run_id` | result | string/bool/string | success | live_authority/snapshot | preview vs close | 아니오 | +| `close_status`/`selection_policy`/`warnings` | result | string/string/list | success | complete_exact/incomplete_draft/conditional | authority state | 아니오 | +| `total_assets`/`total_liabilities`/`net_worth` | result | integer | success | exact integer | formula summary | 아니오 | + +#### 오류 + +| 코드 | 분류 | 발생 조건 | 재시도 가능 여부 | 호출자 대응 | 실패 시 상태 변경 | +|---|---|---|---|---|---| +| `conditional_approval_required` | policy | unsafe exclusions/carry-forward without explicit approval | 수정 후 가능 | preview, approve explicitly | 없음 | +| `idempotency_conflict` | conflict | close key payload differs | 아니오 | inspect run | 없음 | +| `validation_error` | request | invalid policy or unreasoned correction | 수정 후 가능 | provide reason | no partial rows | + +### shared contract and adapter limits + +All new interfaces retain the existing `success`/`error_code` envelope. `preview_asset_snapshot_close()` and Dashboard render/report are read-only; only explicit register/update/close forms or MCP calls write. MCP tools are `register_account_valuation`, `list_account_valuations`, `select_account_valuation`, `correct_account_valuation`, `preview_asset_snapshot_close`, `close_asset_snapshot`, `get_asset_snapshot_report`, and `get_net_worth_timeseries`; actual decorators are in `jarca_ledger_mcp/adapters/mcp/server.py`. Dashboard uses `accounts_gateway.py`, `assets_gateway.py`, and `page_views/accounts.py`/`assets.py`. + +## 호환성 추가 규칙 — REQ-003 + +- 유지할 기존 호출 방식: REQ-001 auth config/login/cleanup/logout/gate, REQ-002 exact 10 menu labels/order, `page` query sibling preservation, stable shell/background and browser evidence; existing MCP stdio/streamable-http runtime. +- 버전 변경 정책: additive fields only; no rename/removal of existing response keys. +- 폐기 정책: 없음; guessed migration, fake transactions and MCP auth claim are excluded. +- 재시도 계약: same idempotency payload reuses; changed payload conflicts; DB conflict does not retry blindly. +- 중복 처리 계약: valuation/run idempotency keys and single correction indexes. +- 순서 보장: Dashboard auth → DB path → page/gateway; preview → explicit approval → atomic close; selection → report. + +## 예시 추가 + +### example-valuation — synthetic verified valuation + +- 대상 인터페이스: `valuation-service` + +#### 호출 + +```text +register_account_valuation(account_id="acct-synthetic", basis_date="2026-06-30", amount=1000000, verification_status="verified", idempotency_key="fixture-v1") +``` + +#### 결과 + +```text +{"success": true, "reused": false, "valuation": {"basis_date": "2026-06-30", "amount": 1000000, "verification_status": "verified"}} +``` + +### example-preview — read-only close preview + +- 대상 인터페이스: `snapshot-close` + +#### 호출 + +```text +preview_asset_snapshot_close(basis_date="2026-06-30", selection_policy="exact") +``` + +#### 결과 + +```text +{"success": true, "source": "live_authority", "is_closed_snapshot": false, "close_status": "complete_exact", "warnings": []} +``` + +## 검증 추가 + +- 정상 계약: `tests/test_account_valuations.py`, `tests/test_asset_snapshots.py`, `tests/test_contract_regression.py` +- 실패 계약: idempotency/correction/conditional approval tests and source validation +- 경계 계약: exact/as_of, NULL/0, future/unverified candidate, correction/re-close +- 구현·스키마·예시 정합성: `tests/test_requirement_docs.py`, actual service/MCP/gateway symbols diff --git a/docs/project-structure.md b/docs/project-structure.md index 0c69b5a..14b52fb 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -118,3 +118,51 @@ deploy/k8s/README.md - 정적 분석 기대 결과: exit 0 with no diagnostics - 문서와 실제 트리의 정합성: every listed source/test/document path exists; dependency table has no forbidden reverse edge; REQ-002 symbols resolve to `dashboard/app.py`, `dashboard/common.py`, and named tests + +## REQ-003 additive structure + +### ledger-application — shared valuation and snapshot services + +```text +jarca_ledger_mcp/ +├── application/account_valuation_service.py +├── application/assets_service.py +├── application/accounts_write_services.py +├── application/imports_service.py +├── application/reconciliation_service.py +├── infrastructure/sqlite/migrations.py +├── adapters/mcp/server.py +└── adapters/streamlit/{accounts_gateway.py,assets_gateway.py} +``` + +| 경로 | 종류 | 책임 | 공개 진입점 | 내부 구현 경계 | 편집 정책 | 생성 주체 | +|---|---|---|---|---|---|---| +| `jarca_ledger_mcp/application/account_valuation_service.py` | source | validation, selection, idempotency, correction | named valuation functions | SQLite through shared db | source review + focused tests | developer | +| `jarca_ledger_mcp/application/assets_service.py` | source | preview, formula, close/correction | `preview_asset_snapshot_close`, `close_asset_snapshot` | snapshot transaction | source review + focused tests | developer | +| `jarca_ledger_mcp/infrastructure/sqlite/migrations.py` | source | schema 12 additive migration | `init_db`, `migrate_db` | no UI/MCP imports | migration review | developer | +| `tests/test_account_valuations.py` | test | valuation contracts | pytest | synthetic DB | paired with source | developer | +| `tests/test_asset_snapshots.py` | test | preview/close/report contracts | pytest | synthetic DB | paired with source | developer | +| `docs/data-model.md` | canonical doc | entity/schema authority | document | no secrets | update with schema | developer | + +### 의존 방향 추가 + +| 출발 영역 | 대상 영역 | 허용 여부 | 규칙 | 공유 계약 위치 | +|---|---|---|---|---| +| `dashboard.page_views` | `streamlit gateways` | 허용 | page → gateway only | `docs/interface-contract.md` | +| `streamlit gateways` | `application services` | 허용 | adapter delegates; no SQL | `docs/runtime-flow.md` | +| `MCP adapters` | `application services` | 허용 | tool delegates; no duplicate business logic | `docs/interface-contract.md` | +| `application services` | `infrastructure.sqlite` | 허용 | service → shared db/schema | `docs/data-model.md` | +| `application services` | `dashboard` | 금지 | no reverse UI dependency | `docs/project-structure.md` | + +### 변경 규칙 추가 + +| 구분 | 규칙 | +|---|---| +| 새 모듈·파일 배치 | valuation/snapshot logic stays in application; adapters remain thin; no direct SQL in page/MCP | +| 이름·패키지 | existing snake_case modules and service re-exports | +| 소스·테스트 대응 | every valuation/snapshot change paired with named synthetic tests and docs validator | +| 생성 산출물 | only redacted synthetic evidence; no credentials, cookie, token or actual account data | + +## REQ-003 preservation inventory + +REQ-001 auth gate, cookie/cleanup/logout and auth-before-DB; REQ-002 menu labels/order, query sibling preservation, stable shell/background and browser evidence remain in the preceding sections. Missing semantic items: 0. Ambiguous semantic items: 0. diff --git a/docs/requirements/req-003_account-valuation-history.md b/docs/requirements/req-003_account-valuation-history.md new file mode 100644 index 0000000..c798bc1 --- /dev/null +++ b/docs/requirements/req-003_account-valuation-history.md @@ -0,0 +1,302 @@ +--- +{ + "schema": "gitea-agent.requirement/v1", + "id": "REQ-003", + "title": "계좌 기준일 평가 이력과 자산 마감 연동", + "revision": "1.0", + "status": "approved", + "depends_on": [], + "applies_to": ["ledger", "dashboard", "mcp"], + "language": "ko", + "issue": "#12", + "implementation_pr": "미정" +} +--- + +# REQ-003 — 계좌 기준일 평가 이력과 자산 마감 연동 + +## 1. 목적 + +계좌의 기준일 평가 증거를 append-only 이력으로 저장·선택하고, 검증 가능한 자산만 자산/마감 및 월정산의 역사적 순자산에 반영한다. + +## 2. 배경 + +구현은 SQLite schema 12를 schema 11에 additive하게 적용한다. 계좌 잔액·import 결과는 실제 증거가 없는 한 `unverified` 후보이고, 기존 거래·인증·메뉴 계약을 바꾸지 않는다. + +## 3. 범위 + +### 포함 + +- `account_valuations`, `asset_snapshot_runs`, `asset_snapshots` 확장과 exact/as_of 선택 +- idempotency, append-only correction, 투자 optional metrics, loan 대표 부채 +- read-only preview, explicit approval conditional close, immutable evidence, correction/re-close +- 계좌·import 후보, reconciliation, MCP·Dashboard·월정산 공통 read model + +### 제외 + +- credential/실제 금융 데이터, 자동 추정 거래 생성, fake investment transaction, 거래 원장 재작성 +- 외부 자산 가격 API, 환율 변환, 자동 승인, unverified/future/unknown 값 승인 +- Dashboard 인증·logout·cookie, MCP 인증의 신규 도입, 기존 메뉴 순서 변경 + +## 4. 용어와 기준 + +- **verified leaf**: `supersedes_id`로 더 최신 child가 없는 `verification_status=verified` 행. +- **exact**: 요청 `basis_date`와 일치하는 verified leaf만 선택한다. +- **as_of**: 미래를 보지 않고 요청일 이전 최신 verified leaf를 선택하며 carry-forward를 표시한다. +- **대표 부채**: `valuation_kind`가 `loan`/`loan_balance`이고 `representative_liability_definition`이 `principal_only` 또는 `principal_interest_fees`인 하나의 대표 금액만 합산한다. +- **정본 구현**: `jarca_ledger_mcp/application/account_valuation_service.py`, `assets_service.py`, `reconciliation_service.py`, `migrations.py` 및 named tests. + +## 5. 요구사항 + +### REQ-003.01 — additive 이력과 legacy 보존 + +schema 11의 테이블·필드·거래를 삭제하거나 추측 이주하지 않고 schema 12의 `account_valuations`, `asset_snapshot_runs`를 추가하며 `asset_snapshots`에 필요한 필드를 additive하게 확장한다. migration은 idempotent다. + +**영향 범위** +- 인터페이스: 기존 service/MCP 응답 envelope 유지, 신규 valuation/snapshot fields additive +- 영속성: SQLite schema 11→12, 기존 rows 보존 +- 후속 사용 주체: service, MCP tool, Dashboard gateway/page, 월정산 +- 마이그레이션: `migrate_db()`의 `CREATE TABLE IF NOT EXISTS`/`ADD COLUMN` +- 호환성: REQ-001 auth, cookie/logout, REQ-002 메뉴 10개/order, query/shell/browser evidence 유지 +- 배포 전제 조건: SQLite write 권한과 기존 DB 백업 + +### REQ-003.02 — verified leaf 선택과 범위 + +exact/as_of 선택은 account/currency/valuation_kind scope를 적용하고 future, unverified, rejected, unknown을 authority에서 제외한다. newer unverified candidate는 warning으로 남기며 investment는 `total_value`, `balance`, `cash_balance` 우선, loan은 `loan_balance`, `loan`, `balance` 우선이다. + +**영향 범위** +- 인터페이스: `select_account_valuation()`, `_account_valuation_items()`, report warnings +- 영속성: 상태·기준일·통화·kind와 candidate evidence 보존 +- 후속 사용 주체: snapshot preview/close, Dashboard 계좌·자산, reconciliation +- 마이그레이션: 없음 +- 호환성: 기존 `last_balance` fallback은 basis-date 없는 legacy report에서만 유지 +- 배포 전제 조건: ISO date와 허용 currency/kind + +### REQ-003.03 — idempotency와 append-only correction + +같은 idempotency payload는 기존 row/run을 재사용하고 다른 payload는 `idempotency_conflict`다. correction은 원본을 갱신하지 않고 단일 child를 append하며 다른 child는 `correction_conflict`다. + +**영향 범위** +- 인터페이스: `register_account_valuation()`, `correct_account_valuation()`, `close_asset_snapshot()` +- 영속성: unique idempotency 및 single-correction indexes +- 후속 사용 주체: retry caller와 history/report +- 마이그레이션: unique indexes additive +- 호환성: 성공/실패 envelope와 기존 error_code 유지 +- 배포 전제 조건: transaction-scoped SQLite write + +### REQ-003.04 — 금액·옵션·대출 의미 + +amount와 principal/interest/fee/cost_basis/net_contributed/profit은 exact integer이고 NULL은 미제공, 0은 명시적 영(0)이다. 투자 optional metrics는 저장하되 total valuation을 다시 합산하지 않는다. loan은 대표 정의를 필수로 하고 principal/interest/fee의 unknown을 NULL로 보존하며 대표 금액만 총부채에 합산한다. + +**영향 범위** +- 인터페이스: valuation input/output fields, validation errors +- 영속성: INTEGER/REAL return_rate와 nullable optional columns +- 후속 사용 주체: net-worth formula와 UI labels +- 마이그레이션: 없음 +- 호환성: 기존 integer ledger amounts와 double-sum 금지 +- 배포 전제 조건: caller가 currency/definition을 명시 + +### REQ-003.05 — current와 historical authority + +current latest display와 historical authority를 분리한다. 늦게 도착한 historical row는 current를 과거로 되돌리지 않으며 account/import writes는 real dated evidence가 있을 때만 dated `unverified` candidate를 만든다. + +**영향 범위** +- 인터페이스: account update/import candidate, list/select/report +- 영속성: `basis_date`/`recorded_at` 분리 +- 후속 사용 주체: 계좌 화면, reconciliation, monthly settlement +- 마이그레이션: 없음 +- 호환성: account name-only update는 candidate를 만들지 않음 +- 배포 전제 조건: source date와 source_ref/hash + +### REQ-003.06 — preview·close·approval + +preview는 DB를 쓰지 않고 `complete_exact`, `incomplete_draft`, `conditional` 상태와 warnings/exclusions를 반환한다. close는 need-based explicit `approved_by`, `approval_reason`, `approval_reference`와 flags를 요구하며 immutable run evidence를 기록한다. unverified/future/unknown/ambiguous는 승인 없이 close할 수 없다. + +**영향 범위** +- 인터페이스: `preview_asset_snapshot_close()`, `close_asset_snapshot()` 및 error codes +- 영속성: run applied/excluded/carried evidence와 approval metadata +- 후속 사용 주체: Dashboard 자산/마감, MCP operator +- 마이그레이션: `asset_snapshot_runs` 신규 +- 호환성: preview read-only, 기존 report response fields 유지 +- 배포 전제 조건: operator approval identity와 reference + +### REQ-003.07 — correction/re-close와 formula + +같은 basis date close는 correction_reason을 요구하고 새 run/rows를 append한다. 최신 run만 historical report authority이며 순자산은 `asset_buckets - loan`, missing valuation은 transaction inference 없이 제외한다. + +**영향 범위** +- 인터페이스: close/report/timeseries +- 영속성: `correction_of_run_id`, `is_correction`, `closed_at` +- 후속 사용 주체: timeseries와 월정산 +- 마이그레이션: asset snapshot columns/indexes +- 호환성: legacy `asset_snapshots` rows are readable +- 배포 전제 조건: close transaction atomicity + +### REQ-003.08 — reconciliation과 monthly propagation + +investment가 verified valuation으로 관리되면 reconciliation status는 `VALUATION_MANAGED`이고 fake transaction을 만들지 않는다. regular ledger calculation은 `calculation_basis`/`calculation_basis_at` provenance를 유지한다. monthly settlement는 read-only로 동일 snapshot/report를 전파한다. + +**영향 범위** +- 인터페이스: `get_balance_reconciliation_report()`, `get_monthly_settlement_summary()` +- 영속성: reconciliation session/transaction은 valuation 때문에 자동 변경하지 않음 +- 후속 사용 주체: 월정산·overview·reconciliation UI +- 마이그레이션: 없음 +- 호환성: existing reconciliation statuses and reports additive +- 배포 전제 조건: service read model available + +### REQ-003.09 — shared application과 보안 경계 + +MCP와 Dashboard는 shared service/gateway를 사용한다. 기존 success/error envelope를 유지한다. Dashboard는 auth-before-DB이며 MCP auth를 claim하지 않는다. render는 read-only이고 implicit write를 일으키지 않는다. + +**영향 범위** +- 인터페이스: MCP `@mcp.tool`, Streamlit gateways/pages +- 영속성: render/preview에는 write 없음; explicit form/close만 write +- 후속 사용 주체: browser/operator/automation +- 마이그레이션: 없음 +- 호환성: REQ-001 gate, cleanup/logout, MCP transport/auth scope 유지 +- 배포 전제 조건: Dashboard auth config, MCP runtime config + +### REQ-003.10 — privacy, compatibility, excluded scope + +문서·tests·fixtures는 synthetic data만 사용하고 actual credential/account/financial data를 저장하지 않는다. Issue #12 밖의 OAuth, external prices, FX, automatic approval, transaction inference, redesign는 구현하지 않는다. + +**영향 범위** +- 인터페이스: redacted evidence와 generic errors +- 영속성: synthetic source_note/memo only +- 후속 사용 주체: tests/operator +- 마이그레이션: 없음 +- 호환성: 기존 auth/menu/query/shell/browser evidence semantic inventory 0 missing +- 배포 전제 조건: secret redaction과 fixture DB + +## 6. 제약과 불변조건 + +- verified leaf만 authority이며 future/unverified/rejected/unknown은 합산하지 않는다. +- 동일 idempotency payload는 같은 ID를 반환하고 다른 payload는 conflict다. +- correction과 re-close는 append-only이고 parent당 child/run은 하나다. +- NULL과 0을 구별하고 모든 금액은 integer precision이다. +- loan representative만 liability sum에 들어가고 investment metrics는 double sum하지 않는다. +- preview와 render는 DB write가 없고 close는 하나의 transaction 경계다. +- current display와 historical basis authority는 분리된다. +- Dashboard auth gate는 DB보다 먼저이며 MCP 인증은 이 요구사항이 주장하지 않는다. +- 실제 credential·token·cookie·account data는 문서·로그·fixture에 없다. + +## 7. 인수 기준 + +### REQ-003.01-AC01 — migration and preservation + +**Cases (정상/경계/실패)**: 정상 schema 12 init twice; 경계 schema 11 legacy DB; 실패 malformed legacy DB. + +**State Invariants**: legacy tables/rows remain; migration repeats without duplicate columns. + +**판정 기준**: `schema_version=12`, required tables/columns/indexes exist and old rows remain. + +**검증**: `tests/test_account_valuations.py::test_account_valuation_schema_is_v12_and_idempotent`, `tests/test_issue_12_regressions.py::test_schema12_migration_preserves_legacy_rows_indexes_and_is_idempotent`. + +### REQ-003.02-AC01 — selection policy + +**Cases (정상/경계/실패)**: exact verified; as_of carry-forward; future/unverified/ambiguous excluded/warned. + +**State Invariants**: no future/unverified row is selected. + +**판정 기준**: exact returns same-date verified leaf; as_of returns latest prior verified and age/warning. + +**검증**: `tests/test_account_valuations.py::test_select_exact_and_as_of_excludes_unverified_and_reports_carry_forward`, source `_account_valuation_items()`. + +### REQ-003.03-AC01 — idempotent correction + +**Cases (정상/경계/실패)**: same payload reuse; different payload conflict; second correction fails. + +**State Invariants**: one row/child per operation and original immutable. + +**판정 기준**: exact error codes and count 2 after one correction. + +**검증**: `tests/test_account_valuations.py::test_register_same_idempotency_payload_reuses_row_and_changed_payload_conflicts`, `tests/test_account_valuations.py::test_correction_is_append_only_reasoned_and_single_child`, `tests/test_issue_12_regressions.py::test_empty_conditional_close_is_closed_and_second_close_requires_correction`. + +### REQ-003.04-AC01 — precision and representative loan + +**Cases (정상/경계/실패)**: integer zero; NULL optional metrics; loan missing definition rejected. + +**State Invariants**: no float amount and no double sum. + +**판정 기준**: optional fields round-trip exactly and loan definition validation is enforced. + +**검증**: `test_correction_preserves_optional_fields_unless_overridden`, `_validate_common()` and schema static checks. + +### REQ-003.05-AC01 — authority separation + +**Cases (정상/경계/실패)**: dated candidate; late historical candidate; name-only update. + +**State Invariants**: current/latest display cannot roll backward and candidate is unverified. + +**판정 기준**: dated account write creates only unverified candidate; name-only does not. + +**검증**: `test_account_balance_write_records_unverified_candidate_only_with_real_basis_date`, `test_account_name_only_update_does_not_create_candidate`. + +### REQ-003.06-AC01 — preview and approval + +**Cases (정상/경계/실패)**: complete exact close; incomplete draft; missing approval rejected. + +**State Invariants**: preview row count unchanged; no unsafe evidence approved. + +**판정 기준**: preview is read-only and close returns `conditional_approval_required` without metadata. + +**검증**: `test_manual_asset_update_deactivate_preview_and_close_correction_policy`, `preview_asset_snapshot_close()`. + +### REQ-003.07-AC01 — immutable close correction + +**Cases (정상/경계/실패)**: first close; same-date correction; second unreasoned close. + +**State Invariants**: old run/rows remain and latest run alone reports. + +**판정 기준**: `correction_of_run_id`, count, and net worth match. + +**검증**: `test_manual_asset_update_deactivate_preview_and_close_correction_policy`, `assets_service.py` close path. + +### REQ-003.08-AC01 — reconciliation/monthly + +**Cases (정상/경계/실패)**: regular ledger calculation; verified investment; missing baseline. + +**State Invariants**: no fake transaction; provenance fields present; monthly read is non-mutating. + +**판정 기준**: status `VALUATION_MANAGED` for verified investment and monthly summary reads report. + +**검증**: `tests/test_service.py::test_statement_status_account_update_and_balance_reconciliation`, `tests/test_issue_12_regressions.py::test_reconciliation_uses_corrected_investment_leaf_without_transaction_count_change`, `tests/test_service.py::test_monthly_settlement_summary_read_model_foundation`. + +### REQ-003.09-AC01 — shared adapters and auth + +**Cases (정상/경계/실패)**: MCP stdio/http config; authenticated Dashboard render; unauthenticated gate. + +**State Invariants**: auth precedes DB; render does not write; response envelope has `success`. + +**판정 기준**: actual tool/gateway names resolve and existing auth/menu tests pass. + +**검증**: `tests/test_mcp_http_runtime.py`, `tests/test_dashboard_authentication_app.py`, `tests/test_dashboard_menu_transition.py`, `server.py` and gateways. + +### REQ-003.10-AC01 — privacy and compatibility + +**Cases (정상/경계/실패)**: synthetic fixture; redaction search; actual secret/data detection. + +**State Invariants**: no credential/token/cookie/real financial data in docs/tests/evidence. + +**판정 기준**: semantic inventory missing=0 and ambiguous=0; placeholder=0. + +**검증**: `tests/test_requirement_docs.py`, auth/menu evidence tests, repository redaction/static search. + +## 8. 오류 및 충돌 계약 + +공통 envelope는 성공 시 `success: true`, 실패 시 `success: false, error_code, error`를 유지한다. 주요 codes는 `validation_error`, `not_found`, `idempotency_conflict`, `correction_conflict`, `ambiguous_valuation`, `conditional_approval_required`, `db_error`다. SQLite unique/transaction conflict는 추가 write 없이 실패한다. + +## 9. 데이터 및 인터페이스 계약 + +서비스는 `jarca_ledger_mcp.service` re-export를 통해 호출되고 MCP는 `jarca_ledger_mcp.adapters.mcp.server` tools를 노출한다. Dashboard는 `adapters/streamlit/accounts_gateway.py`, `assets_gateway.py`와 `dashboard/page_views/accounts.py`, `assets.py`를 사용한다. 실제 fields/signatures는 `docs/interface-contract.md`에 고정한다. + +## 10. 하위 호환성 + +REQ-001의 auth gate·cookie/cleanup/logout, REQ-002의 메뉴 10개와 order(`월정산`, `요약`, `자산/마감`, `거래내역`, `확인필요 큐`, `미분류`, `분류 규칙`, `카테고리`, `계좌`, `Import`), query/shell/background와 browser evidence 계약을 삭제·축약하지 않고 additive하게 유지한다. 기존 MCP stdio/streamable-http transport와 success/error envelope를 유지한다. + +## 11. 추적성 + +- 근거 이슈: `#12` +- 구현 PR: 미정 +- 관련 설계 문서: `docs/data-model.md`, `docs/interface-contract.md`, `docs/project-structure.md`, `docs/runtime-flow.md` +- 관련 요구사항: `REQ-001`, `REQ-002` diff --git a/docs/runtime-flow.md b/docs/runtime-flow.md index 2b25cb8..1150434 100644 --- a/docs/runtime-flow.md +++ b/docs/runtime-flow.md @@ -143,3 +143,76 @@ ASGI startup부터 login, token restoration, invalid-cookie cleanup, explicit lo ### transition verification boundary `tests/test_dashboard_menu_transition.py` provides exact AppTest/unit evidence for labels, query preservation, fallback/direct access, loading, local error, latest selection, and cache-clear. `tests/test_dashboard_app.py::test_streamlit_apptest_core_pages_use_synthetic_fixture_db` and `tests/test_dashboard_authentication_app.py::test_valid_cookie_allows_dashboard_and_is_not_stored_in_session_state` cover finance/auth regressions. AppTest is synthetic and cannot establish actual browser document continuity, paint timing, focus/scroll, or network behavior; `scripts/streamlit_browser_evidence.py --mode transition`의 authenticated HTTPS frame/DOM/document/shell evidence가 REQ-002.03-AC01에 필수다. `tests/test_streamlit_browser_evidence.py`는 이 manifest가 실제 teardown 이후에만 성공하고 document navigation, shell identity drift, 누락 관찰 또는 secret 포함 시 fail-closed하는지 검증한다. + +## REQ-003 런타임 흐름 + +### valuation-register — register/select/correct + +- 시작 이벤트: account form, MCP call, account/import write with dated evidence +- 선행조건: DB initialized; Dashboard path passed auth gate; account exists +- 실행 방식: synchronous SQLite transaction +- 트랜잭션·영속성 경계: one valuation insert or no write on validation/conflict + +| 단계 ID | 실행 주체 | 시작 조건·입력 | 처리 | 읽기·쓰기 상태 | 외부 호출 | 성공 시 다음 단계 | 실패 시 전이 | +|---|---|---|---|---|---|---|---| +| `valuation-01` | adapter | typed request | delegate shared service | read account | none | `valuation-02` | `valuation-fail` | +| `valuation-02` | account valuation service | basis/amount/scope | validate exact integer, date, purpose, loan definition | no write | none | `valuation-03` | `valuation-fail` | +| `valuation-03` | SQLite | valid payload | reuse idempotent row or insert append-only row | account_valuations write | SQLite | `valuation-04` | `valuation-fail` | +| `valuation-04` | selector/report | requested basis | select verified leaf exact/as_of; warn candidates | read only | none | `valuation-success` | `valuation-fail` | + +### snapshot-preview-close — preview/conditional close + +- 시작 이벤트: assets page or MCP preview/close +- 선행조건: basis date; auth before Dashboard DB; explicit approval for conditional state +- 실행 방식: preview synchronous read; close synchronous atomic write +- 트랜잭션·영속성 경계: preview no write; close writes run and rows together + +| 단계 ID | 실행 주체 | 시작 조건·입력 | 처리 | 읽기·쓰기 상태 | 외부 호출 | 성공 시 다음 단계 | 실패 시 전이 | +|---|---|---|---|---|---|---|---| +| `close-01` | assets service | basis/policy | gather verified account/manual items and warnings | read accounts/valuations/manual_assets | none | `close-02` | `close-fail` | +| `close-02` | Dashboard/MCP | preview request | return live authority and status | no write | none | `close-end` | `close-fail` | +| `close-03` | operator/service | explicit close + approval if needed | enforce conditions and correction reason | transaction begins | none | `close-04` | `close-fail` | +| `close-04` | SQLite | approved payload | insert run and immutable snapshot rows | runs/snapshots write | SQLite | `close-end` | `close-fail` | + +### import-candidate-reconciliation-monthly-ui — propagation + +- import updates transactions/account balance then records only dated unverified candidate; reconciliation uses valuation-managed investment without fake transaction; monthly/UI reads shared reports and explicit form actions only. + +| 단계 ID | 실행 주체 | 시작 조건·입력 | 처리 | 읽기·쓰기 상태 | 외부 호출 | 성공 시 다음 단계 | 실패 시 전이 | +|---|---|---|---|---|---|---|---| +| `prop-01` | import/account write | real dated balance | `_record_*_valuation_candidate` | account_valuations unverified insert | parser | `prop-02` | `prop-fail` | +| `prop-02` | reconciliation service | account baseline | calculate signed ledger with provenance; investment verified => VALUATION_MANAGED | read only | none | `prop-03` | `prop-fail` | +| `prop-03` | monthly/UI page | month/basis query | read snapshot/report and render | read only unless explicit button | Streamlit | `prop-end` | `prop-fail` | + +## 실패와 복구 추가 + +| 대상 단계 | 실패 조건 | 추가 쓰기 중단 | 재시도 계약 | 중복 방지 | 보상·수동 복구 | 최종 상태 | +|---|---|---|---|---|---|---| +| `valuation-03` | idempotency/correction conflict | yes | same payload inspect/reuse; changed payload no blind retry | unique indexes | operator chooses new correction | `valuation-fail` | +| `close-03` | missing/future/unverified/unknown or missing approval | yes | preview then explicit approval | close idempotency key | manual evidence review | `close-fail` | +| `close-04` | SQLite exception/concurrency | transaction rollback | retry same key after lock resolution | unique run key | restore from backup if needed | `close-fail` | +| `prop-01` | no real dated evidence | yes | no candidate write | source hash/date key | wait for evidence | `prop-end` without candidate | + +### 동시성과 순서 추가 + +- 동시 실행 허용 여부: independent reads; SQLite writes serialized by transaction +- 잠금·직렬화 단위: account valuation/idempotency and snapshot close transaction +- 순서 보장: auth → DB → read/preview → explicit approval → close; latest run selected after correction +- 충돌 검출·처리: unique idempotency/single correction indexes and explicit error codes + +## 관찰 가능성 추가 + +| 신호 종류 | 이름 | 발생 단계·조건 | 상관 식별자 | 진단 속성 | 민감정보 처리 | +|---|---|---|---|---|---| +| result/event | `valuation_registered` | `valuation-03` success | request/idempotency key surrogate | basis/kind/status only | no source secret | +| result/event | `snapshot_previewed` | `close-02` | request correlation | basis/status/warning codes | no account number | +| result/event | `snapshot_closed` | `close-04` | snapshot_run_id | status/count/formula | evidence redacted | +| result/event | `valuation_conflict` | validation/idempotency failure | correlation ID | error_code only | no payload secrets | + +## REQ-003 검증 + +- 정상 흐름: `tests/test_account_valuations.py`, `tests/test_asset_snapshots.py`, `tests/test_contract_regression.py` +- 실패 흐름: conflict, conditional approval, validation tests +- 경계 흐름: exact/as_of, stale/carry-forward, correction/re-close, empty fallback +- 동시성 흐름: SQLite unique/idempotency and transaction rollback review +- 운영 진입점과 기대 결과: Dashboard authenticated `자산/마감`/`계좌`, MCP tools; preview leaves row count unchanged, explicit close returns run id diff --git a/jarca_ledger_mcp/adapters/mcp/server.py b/jarca_ledger_mcp/adapters/mcp/server.py index 18a665a..5c73524 100644 --- a/jarca_ledger_mcp/adapters/mcp/server.py +++ b/jarca_ledger_mcp/adapters/mcp/server.py @@ -117,6 +117,26 @@ def update_ledger_account(account_id: str, name: str | None = None, type: str | return contracts.update_ledger_account(account_id, name=name, type=type, institution=institution, currency=currency, active=active, memo=memo, account_number_masked=account_number_masked, holder_name=holder_name, last_balance=last_balance, last_synced_at=last_synced_at, statement_status=statement_status, liquidity_bucket=liquidity_bucket) @mcp.tool() +def register_account_valuation(account_id: str, basis_date: str, amount: int, currency: str = "KRW", valuation_kind: str = "balance", source_type: str = "manual", source_ref: str = "", verification_status: str = "unverified", memo: str = "", idempotency_key: str = "", as_of_at: str | None = None, timezone: str | None = None, cost_basis: int | None = None, net_contributed: int | None = None, reported_principal_kind: str | None = None, reported_principal_label: str | None = None, profit: int | None = None, return_rate: float | None = None, return_basis: str | None = None, principal: int | None = None, interest: int | None = None, fee: int | None = None, representative_liability_definition: str | None = None) -> dict: + """Register an append-only account valuation evidence row.""" + return contracts.register_account_valuation(account_id, basis_date, amount, currency, valuation_kind, source_type, source_ref, verification_status, memo, idempotency_key, as_of_at, timezone, cost_basis, net_contributed, reported_principal_kind, reported_principal_label, profit, return_rate, return_basis, principal, interest, fee, representative_liability_definition) + +@mcp.tool() +def list_account_valuations(account_id: str, include_rejected: bool = True) -> dict: + """List account valuation history.""" + return contracts.list_account_valuations(account_id, include_rejected=include_rejected) + +@mcp.tool() +def select_account_valuation(account_id: str, basis_date: str, as_of: bool = False, currency: str | None = None, valuation_kind: str | None = None) -> dict: + """Select an exact verified valuation or latest verified value as of a basis date.""" + return contracts.select_account_valuation(account_id, basis_date, as_of=as_of, currency=currency, valuation_kind=valuation_kind) + +@mcp.tool() +def correct_account_valuation(valuation_id: str, amount: int, correction_reason: str, idempotency_key: str = "", currency: str | None = None, valuation_kind: str | None = None, source_type: str | None = None, source_ref: str | None = None, verification_status: str | None = None, memo: str | None = None, as_of_at: str | None = None, timezone: str | None = None, cost_basis: int | None = None, net_contributed: int | None = None, reported_principal_kind: str | None = None, reported_principal_label: str | None = None, profit: int | None = None, return_rate: float | None = None, return_basis: str | None = None, principal: int | None = None, interest: int | None = None, fee: int | None = None, representative_liability_definition: str | None = None) -> dict: + """Append a reasoned correction to an account valuation.""" + fields = {k: v for k, v in locals().items() if k not in {"valuation_id", "amount", "correction_reason", "idempotency_key"} and v is not None} + return contracts.correct_account_valuation(valuation_id, amount, correction_reason, idempotency_key=idempotency_key, **fields) +@mcp.tool() def get_asset_liquidity_report() -> dict: """Return asset liquidity, debt, and net worth from shared account/manual valuation sources.""" return contracts.get_asset_liquidity_report() @@ -142,19 +162,19 @@ def deactivate_manual_asset(asset_id: str, hard_delete: bool = False) -> dict: return contracts.deactivate_manual_asset(asset_id, hard_delete=hard_delete) @mcp.tool() -def preview_asset_snapshot_close(basis_date: str = "") -> dict: +def preview_asset_snapshot_close(basis_date: str = "", selection_policy: str = "exact") -> dict: """Preview a month-end asset snapshot close without mutating the database.""" - return contracts.preview_asset_snapshot_close(basis_date=basis_date) + return contracts.preview_asset_snapshot_close(basis_date=basis_date, selection_policy=selection_policy) @mcp.tool() -def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str = "", correction_reason: str = "") -> dict: +def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str = "", correction_reason: str = "", selection_policy: str = "exact", idempotency_key: str = "", allow_missing_exclusions: bool = False, allow_as_of_carry_forward: bool = False, approved_by: str = "", approval_reason: str = "", approval_reference: str = "") -> dict: """Close an asset snapshot for a basis date; same-date re-close appends correction rows.""" - return contracts.close_asset_snapshot(basis_date=basis_date, source_note=source_note, memo=memo, correction_reason=correction_reason) + return contracts.close_asset_snapshot(basis_date=basis_date, source_note=source_note, memo=memo, correction_reason=correction_reason, selection_policy=selection_policy, idempotency_key=idempotency_key, allow_missing_exclusions=allow_missing_exclusions, allow_as_of_carry_forward=allow_as_of_carry_forward, approved_by=approved_by, approval_reason=approval_reason, approval_reference=approval_reference) @mcp.tool() -def get_asset_snapshot_report(basis_date: str = "") -> dict: +def get_asset_snapshot_report(basis_date: str = "", selection_policy: str = "exact") -> dict: """Return basis-date asset snapshot/net-worth report with live-authority fallback.""" - return contracts.get_asset_snapshot_report(basis_date=basis_date) + return contracts.get_asset_snapshot_report(basis_date=basis_date, selection_policy=selection_policy) @mcp.tool() def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: int = 12) -> dict: @@ -246,6 +266,11 @@ def get_monthly_cashflow_report(month: str = "") -> dict: """Return income, expenses, net cashflow, and savings rate for a month.""" return contracts.get_monthly_cashflow_report(month) +@mcp.tool() +def get_monthly_settlement_summary(month: str = "") -> dict: + """Return the read-only monthly settlement summary.""" + return contracts.get_monthly_settlement_summary(month) + @mcp.tool() def get_category_spending_report(month: str = "", start_date: str = "", end_date: str = "", person: str = "") -> dict: """Return category/subcategory spending for a month or date range.""" diff --git a/jarca_ledger_mcp/adapters/mcp/tool_contracts.py b/jarca_ledger_mcp/adapters/mcp/tool_contracts.py index 5a90f67..a9e3b72 100644 --- a/jarca_ledger_mcp/adapters/mcp/tool_contracts.py +++ b/jarca_ledger_mcp/adapters/mcp/tool_contracts.py @@ -25,6 +25,11 @@ from ...service import ( get_ledger_backup_status as get_ledger_backup_status, get_ledger_db_status as get_ledger_db_status, get_monthly_cashflow_report as get_monthly_cashflow_report, + get_monthly_settlement_summary as get_monthly_settlement_summary, + register_account_valuation as register_account_valuation, + list_account_valuations as list_account_valuations, + select_account_valuation as select_account_valuation, + correct_account_valuation as correct_account_valuation, get_net_worth_timeseries as get_net_worth_timeseries, get_review_operating_indicators as get_review_operating_indicators, get_uncategorized_merchant_summary as get_uncategorized_merchant_summary, @@ -94,6 +99,11 @@ __all__ = [ "get_ledger_backup_status", "get_ledger_db_status", "get_monthly_cashflow_report", + "get_monthly_settlement_summary", + "register_account_valuation", + "list_account_valuations", + "select_account_valuation", + "correct_account_valuation", "get_net_worth_timeseries", "get_review_operating_indicators", "get_uncategorized_merchant_summary", diff --git a/jarca_ledger_mcp/adapters/streamlit/accounts_gateway.py b/jarca_ledger_mcp/adapters/streamlit/accounts_gateway.py index bc64de8..727142d 100644 --- a/jarca_ledger_mcp/adapters/streamlit/accounts_gateway.py +++ b/jarca_ledger_mcp/adapters/streamlit/accounts_gateway.py @@ -1,11 +1,19 @@ from __future__ import annotations from ...service import ( + correct_account_valuation as correct_account_valuation, + list_account_valuations as list_account_valuations, list_ledger_accounts as list_ledger_accounts, + register_account_valuation as register_account_valuation, + select_account_valuation as select_account_valuation, update_ledger_account as update_ledger_account, ) __all__ = [ "list_ledger_accounts", "update_ledger_account", + "register_account_valuation", + "list_account_valuations", + "select_account_valuation", + "correct_account_valuation", ] diff --git a/jarca_ledger_mcp/application/__init__.py b/jarca_ledger_mcp/application/__init__.py index 2af3e36..b0b6a3c 100644 --- a/jarca_ledger_mcp/application/__init__.py +++ b/jarca_ledger_mcp/application/__init__.py @@ -1,6 +1,7 @@ from __future__ import annotations from . import accounts_write_services as _accounts_write_services +from . import account_valuation_service as _account_valuation_service from . import asset_account_services as _asset_account_services from . import assets_service as _assets_service from . import budgets_write_services as _budgets_write_services @@ -20,6 +21,7 @@ from . import uncategorized_workbench_service as _uncategorized_workbench_servic _EXPORT_MODULES = ( shared, _accounts_write_services, + _account_valuation_service, _asset_account_services, _assets_service, _budgets_write_services, diff --git a/jarca_ledger_mcp/application/account_valuation_service.py b/jarca_ledger_mcp/application/account_valuation_service.py new file mode 100644 index 0000000..88753b9 --- /dev/null +++ b/jarca_ledger_mcp/application/account_valuation_service.py @@ -0,0 +1,272 @@ +from __future__ import annotations + +from datetime import date +import math +import re +from typing import Any +import sqlite3 + +from .shared import db, fail, new_id, normalize_date, now_iso, ok, row_to_dict + +_VALID_STATUSES = {"verified", "unverified", "rejected"} +_VALID_LOAN_DEFINITIONS = {"principal_only", "principal_interest_fees"} +_VALID_CURRENCIES = {"KRW", "USD", "EUR", "JPY", "CNY", "GBP"} +_VALID_KINDS = {"balance", "cash_balance", "total_value", "loan", "loan_balance"} +_VALID_SOURCE_TYPES = {"manual", "import", "account_write", "reconciliation", "snapshot"} +_COLUMNS = ( + "amount", "currency", "valuation_kind", "source_type", "source_ref", + "verification_status", "memo", "as_of_at", "timezone", "cost_basis", + "net_contributed", "reported_principal_kind", "reported_principal_label", + "profit", "return_rate", "return_basis", "principal", "interest", "fee", + "representative_liability_definition", +) + + +def _valuation(row: Any) -> dict[str, Any]: + return row_to_dict(row) + + +def _public_candidate(row: Any) -> dict[str, Any]: + return {k: row[k] for k in ("id", "basis_date", "currency", "valuation_kind", "verification_status")} + + +def _strict_int(value: Any, field: str, allow_none: bool = True) -> int | None: + if value is None and allow_none: + return None + if isinstance(value, bool): + raise ValueError(f"{field} must be an integer") + if isinstance(value, int): + return value + if isinstance(value, str) and re.fullmatch(r"[+-]?\d+", value.strip()): + return int(value.strip()) + raise ValueError(f"{field} must be an integer") + + +def _strict_float(value: Any, field: str) -> float | None: + if value is None: + return None + if isinstance(value, bool): + raise ValueError(f"{field} must be a finite number") + try: + result = float(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{field} must be a finite number") from exc + if not math.isfinite(result): + raise ValueError(f"{field} must be a finite number") + return result + + +def _validate_common(account_id: str, basis_date: str, amount: int, currency: str, verification_status: str, valuation_kind: str, source_type: str, representative_liability_definition: str | None) -> tuple[str, int, str, str, str, str | None] | dict[str, Any]: + if not isinstance(account_id, str) or not account_id.strip(): + return fail("validation_error", "account_id must be non-empty") + try: + basis = normalize_date(basis_date) + amount_int = _strict_int(amount, "amount", allow_none=False) + except Exception as exc: + return fail("validation_error", str(exc)) + currency_norm = (currency or "KRW").strip().upper() + if currency_norm not in _VALID_CURRENCIES: + return fail("validation_error", f"currency must be one of {sorted(_VALID_CURRENCIES)}") + status = (verification_status or "unverified").strip().lower() + if status not in _VALID_STATUSES: + return fail("validation_error", "verification_status must be verified, unverified, or rejected") + if not isinstance(valuation_kind, str): + return fail("validation_error", "valuation_kind must be a string") + kind = valuation_kind.strip().lower() or "balance" + if kind not in _VALID_KINDS: + return fail("validation_error", f"valuation_kind must be one of {sorted(_VALID_KINDS)}") + source = (source_type or "manual").strip().lower() or "manual" + if source not in _VALID_SOURCE_TYPES: + return fail("validation_error", f"source_type must be one of {sorted(_VALID_SOURCE_TYPES)}") + definition = representative_liability_definition.strip() if representative_liability_definition else None + if kind in {"loan", "loan_balance"} and definition not in _VALID_LOAN_DEFINITIONS: + return fail("validation_error", "loan valuation requires representative_liability_definition") + if kind not in {"loan", "loan_balance"} and definition is not None: + return fail("validation_error", "non-loan valuation cannot define representative liability") + if amount_int is None: + return fail("validation_error", "amount must be an integer") + return basis, amount_int, currency_norm, status, source, definition + + +def record_account_valuation_candidate(conn: sqlite3.Connection, account_id: str, basis_date: str, amount: int, currency: str, source_type: str, source_ref: str, idempotency_key: str, memo: str = "") -> dict[str, Any]: + """Append an unverified candidate on a caller-owned transaction.""" + account = conn.execute("SELECT currency FROM accounts WHERE id=?", (account_id,)).fetchone() + if account is None: + return fail("not_found", "account not found") + if (currency or "KRW").upper() != (account["currency"] or "KRW").upper(): + return fail("validation_error", "valuation currency must match account currency") + basis = normalize_date(basis_date) + existing = conn.execute("SELECT * FROM account_valuations WHERE account_id=? AND idempotency_key=?", (account_id, idempotency_key)).fetchone() + if existing: + return ok(valuation=_valuation(existing), reused=True) + valuation_id = new_id("valuation") + conn.execute("""INSERT INTO account_valuations + (id,account_id,basis_date,amount,currency,valuation_kind,source_type,source_ref,verification_status,recorded_at,memo,idempotency_key) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""", (valuation_id, account_id, basis, int(amount), (currency or "KRW").upper(), "balance", source_type, source_ref or "", "unverified", now_iso(), memo, idempotency_key)) + return ok(valuation=_valuation(conn.execute("SELECT * FROM account_valuations WHERE id=?", (valuation_id,)).fetchone()), reused=False) + + +def register_account_valuation( + account_id: str, + basis_date: str, + amount: int, + currency: str = "KRW", + valuation_kind: str = "balance", + source_type: str = "manual", + source_ref: str = "", + verification_status: str = "unverified", + memo: str = "", + idempotency_key: str = "", + as_of_at: str | None = None, + timezone: str | None = None, + cost_basis: int | None = None, + net_contributed: int | None = None, + reported_principal_kind: str | None = None, + reported_principal_label: str | None = None, + profit: int | None = None, + return_rate: float | None = None, + return_basis: str | None = None, + principal: int | None = None, + interest: int | None = None, + fee: int | None = None, + representative_liability_definition: str | None = None, + db_path: str | None = None, + supersedes_id: str | None = None, + correction_reason: str = "", +) -> dict[str, Any]: + validated = _validate_common(account_id, basis_date, amount, currency, verification_status, valuation_kind, source_type, representative_liability_definition) + if isinstance(validated, dict): + return validated + basis, amount_int, currency_norm, status, source_norm, definition = validated + try: + optional_ints = {name: _strict_int(value, name) for name, value in { + "cost_basis": cost_basis, "net_contributed": net_contributed, "profit": profit, + "principal": principal, "interest": interest, "fee": fee}.items()} + return_rate = _strict_float(return_rate, "return_rate") + except (TypeError, ValueError) as exc: + return fail("validation_error", str(exc)) + if (as_of_at is None) != (timezone is None): + return fail("validation_error", "as_of_at and timezone must be supplied together") + if as_of_at is not None: + try: + from datetime import datetime + parsed = datetime.fromisoformat(as_of_at) + if parsed.tzinfo is None: + return fail("validation_error", "as_of_at must include timezone offset") + except (TypeError, ValueError) as exc: + return fail("validation_error", f"invalid as_of_at: {exc}") + 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) + with db(db_path) as conn: + account = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone() + if account is None: + return fail("not_found", "account not found") + if currency_norm != (account["currency"] or "KRW").upper(): + return fail("validation_error", "valuation currency must match account currency") + if (valuation_kind or "balance").strip().lower() in {"loan", "loan_balance"} and str(account["type"]).lower() != "loan": + return fail("validation_error", "loan valuation kind requires a loan account") + if idempotency_key: + existing = conn.execute("SELECT * FROM account_valuations WHERE account_id=? AND idempotency_key=?", (account_id, idempotency_key)).fetchone() + if existing: + current = _valuation(existing) + incoming = dict(zip(_COLUMNS, values)) + incoming.update({"account_id": account_id, "basis_date": basis, "supersedes_id": supersedes_id, "correction_reason": correction_reason or ""}) + comparable = set(incoming) - {"id", "recorded_at"} + if all(current.get(k) == v for k, v in incoming.items() if k in comparable): + return ok(valuation=current, reused=True) + return fail("idempotency_conflict", "idempotency_key already has a different payload", valuation=current) + valuation_id = new_id("valuation") + try: + conn.execute("""INSERT INTO account_valuations + (id,account_id,basis_date,amount,currency,valuation_kind,source_type,source_ref,verification_status,recorded_at,memo,idempotency_key,supersedes_id,correction_reason,as_of_at,timezone,cost_basis,net_contributed,reported_principal_kind,reported_principal_label,profit,return_rate,return_basis,principal,interest,fee,representative_liability_definition) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (valuation_id, account_id, basis, *values[:6], ts, values[6], idempotency_key or "", supersedes_id, correction_reason, *values[7:])) + except sqlite3.IntegrityError as exc: + if supersedes_id: + return fail("correction_conflict", "valuation already has a correction") + return fail("idempotency_conflict", str(exc)) + except Exception as exc: + return fail("validation_error", str(exc)) + row = conn.execute("SELECT * FROM account_valuations WHERE id=?", (valuation_id,)).fetchone() + return ok(valuation=_valuation(row), reused=False) + + +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: + 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)) + + +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: + clauses = ["account_id=?"] + params: list[Any] = [account_id] + if currency: + clauses.append("currency=?"); params.append(currency.strip().upper()) + if valuation_kind: + clauses.append("valuation_kind=?"); params.append(valuation_kind.strip().lower()) + rows = conn.execute(f"SELECT * FROM account_valuations WHERE {' AND '.join(clauses)} ORDER BY basis_date DESC, recorded_at DESC, id DESC", params).fetchall() + leaves = [r for r in rows if conn.execute("SELECT 1 FROM account_valuations WHERE supersedes_id=?", (r["id"],)).fetchone() is None] + verified = [r for r in leaves if r["verification_status"] == "verified" and r["basis_date"] <= basis] + exact = [r for r in verified if r["basis_date"] == basis] + # Candidates are evidence at or before the requested basis. Exact + # selection only surfaces same-date unverified evidence; as-of + # selection surfaces unverified evidence newer than the selected + # verified value, while never looking into the future. + latest_verified_date = max((x["basis_date"] for x in verified), default="") + candidate = [ + r for r in leaves + if r["verification_status"] != "verified" + and r["basis_date"] <= basis + and (r["basis_date"] == basis if not as_of else (not latest_verified_date or r["basis_date"] > latest_verified_date)) + ] + matching = exact if not as_of else [r for r in verified if r["basis_date"] == latest_verified_date] + if len(matching) > 1: + return fail("ambiguous_valuation", "multiple verified valuation leaves match the scope") + row = matching[0] if matching else None + if row is None: + result = ok(account_id=account_id, basis_date=basis, valuation=None, carried_forward=False) + if candidate: + result["warning"] = "unverified_candidate" + result["unverified_candidates"] = [_public_candidate(item) for item in candidate] + return result + age = (date.fromisoformat(basis) - date.fromisoformat(row["basis_date"])).days + warning = "unverified_candidate" if candidate else ("carry_forward" if age else None) + result = ok(account_id=account_id, basis_date=basis, valuation=_valuation(row), carried_forward=bool(age), valuation_age_days=age, warning=warning) + if candidate: + result["unverified_candidates"] = [_public_candidate(item) for item in candidate] + return result + + +def correct_account_valuation(valuation_id: str, amount: int, correction_reason: str, idempotency_key: str = "", db_path: str | None = None, **fields: Any) -> dict[str, Any]: + if not correction_reason or not correction_reason.strip(): + return fail("validation_error", "correction_reason is required") + with db(db_path) as conn: + source = conn.execute("SELECT * FROM account_valuations WHERE id=?", (valuation_id,)).fetchone() + if source is None: + return fail("not_found", "valuation not found") + if idempotency_key: + prior = conn.execute("SELECT * FROM account_valuations WHERE account_id=? AND idempotency_key=?", (source["account_id"], idempotency_key)).fetchone() + if prior: + expected = {"amount": amount, "supersedes_id": valuation_id, "correction_reason": correction_reason.strip()} + expected.update({k: fields[k] for k in fields}) + if all(prior[k] == v for k, v in expected.items() if k in prior.keys()): + return ok(valuation=_valuation(prior), reused=True) + return fail("idempotency_conflict", "idempotency_key already has a different payload", valuation=_valuation(prior)) + if conn.execute("SELECT 1 FROM account_valuations WHERE supersedes_id=?", (valuation_id,)).fetchone(): + return fail("correction_conflict", "valuation already has a correction") + 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")} + return register_account_valuation( + source["account_id"], source["basis_date"], amount, + **preserved, + idempotency_key=idempotency_key, + supersedes_id=valuation_id, correction_reason=correction_reason.strip(), + db_path=db_path, + ) diff --git a/jarca_ledger_mcp/application/accounts_write_services.py b/jarca_ledger_mcp/application/accounts_write_services.py index bdb25e0..f4ec7bb 100644 --- a/jarca_ledger_mcp/application/accounts_write_services.py +++ b/jarca_ledger_mcp/application/accounts_write_services.py @@ -1,66 +1,65 @@ from __future__ import annotations +from .shared import Any, VALID_ACCOUNT_TYPES, db, fail, new_id, normalize_liquidity_bucket, normalize_statement_status, now_iso, ok, row_to_dict -from .shared import ( - Any, - VALID_ACCOUNT_TYPES, - db, - fail, - new_id, - normalize_liquidity_bucket, - normalize_statement_status, - now_iso, - ok, - row_to_dict, -) + +def _record_balance_candidate(conn: Any, account: dict[str, Any], balance: Any = None, synced: str | None = None) -> None: + balance = account.get("last_balance") if balance is None else balance + synced = account.get("last_synced_at") if synced is None else synced + if balance is None or not str(synced or ""): + return + try: + from .account_valuation_service import record_account_valuation_candidate + record_account_valuation_candidate(conn, account["id"], str(synced), int(balance), account.get("currency", "KRW"), "account_write", account.get("updated_at", ""), f"account-balance:{account['id']}:{str(synced)[:10]}:{balance}", "account balance write candidate") + except (ValueError, TypeError): + return def create_ledger_account(name: str, type: str = "bank", institution: str = "", currency: str = "KRW", active: bool = True, memo: str = "", account_number_masked: str = "", holder_name: str = "", last_balance: int | None = None, last_synced_at: str = "", statement_status: str = "unknown", liquidity_bucket: str = "other", db_path: str | None = None) -> dict[str, Any]: - if not name.strip(): - return fail("validation_error", "account name is required") - if type not in VALID_ACCOUNT_TYPES: - return fail("validation_error", f"type must be one of {sorted(VALID_ACCOUNT_TYPES)}") + if not name.strip(): return fail("validation_error", "account name is required") + if type not in VALID_ACCOUNT_TYPES: return fail("validation_error", f"type must be one of {sorted(VALID_ACCOUNT_TYPES)}") try: statement_status_norm = normalize_statement_status(statement_status) liquidity_bucket_norm = normalize_liquidity_bucket(liquidity_bucket) - except Exception as e: - return fail("validation_error", str(e)) + except Exception as e: return fail("validation_error", str(e)) ts = now_iso(); account_id = new_id("acct") with db(db_path) as conn: - conn.execute("""INSERT INTO accounts(id,name,type,institution,currency,active,memo,account_number_masked,holder_name,last_balance,last_synced_at,statement_status,liquidity_bucket,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (account_id, name.strip(), type, institution, currency, int(active), memo, account_number_masked, holder_name, last_balance, last_synced_at, statement_status_norm, liquidity_bucket_norm, ts, ts)) + conn.execute("INSERT INTO accounts(id,name,type,institution,currency,active,memo,account_number_masked,holder_name,last_balance,last_synced_at,statement_status,liquidity_bucket,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", (account_id,name.strip(),type,institution,currency.upper(),int(active),memo,account_number_masked,holder_name,last_balance,last_synced_at,statement_status_norm,liquidity_bucket_norm,ts,ts)) row = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone() - return ok(account=row_to_dict(row)) + account = row_to_dict(row) + _record_balance_candidate(conn, account) + return ok(account=account) def update_ledger_account(account_id: str, db_path: str | None = None, **fields: Any) -> dict[str, Any]: - allowed = {"name", "type", "institution", "currency", "active", "memo", "account_number_masked", "holder_name", "last_balance", "last_synced_at", "statement_status", "liquidity_bucket"} - updates: list[str] = [] - params: list[Any] = [] - for k, v in fields.items(): - if k not in allowed or v is None: - continue - try: - if k == "type" and v not in VALID_ACCOUNT_TYPES: - return fail("validation_error", f"type must be one of {sorted(VALID_ACCOUNT_TYPES)}") - if k == "statement_status": - v = normalize_statement_status(str(v)) - if k == "liquidity_bucket": - v = normalize_liquidity_bucket(str(v)) - if k == "active": - v = int(bool(v)) - if k == "last_balance" and v != "": - v = int(v) - except Exception as e: - return fail("validation_error", str(e)) - updates.append(f"{k}=?") - params.append(v) - if not updates: - return fail("validation_error", "no fields to update") - updates.append("updated_at=?") - params.extend([now_iso(), account_id]) + allowed = {"name","type","institution","currency","active","memo","account_number_masked","holder_name","last_balance","last_synced_at","statement_status","liquidity_bucket"} with db(db_path) as conn: - cur = conn.execute(f"UPDATE accounts SET {', '.join(updates)} WHERE id=?", params) - if cur.rowcount == 0: - return fail("not_found", "account not found") + current = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone() + if current is None: return fail("not_found", "account not found") + updates=[]; params=[] + for k,v in fields.items(): + if k not in allowed or v is None: continue + try: + if k == "type" and v not in VALID_ACCOUNT_TYPES: return fail("validation_error", f"type must be one of {sorted(VALID_ACCOUNT_TYPES)}") + if k == "statement_status": v = normalize_statement_status(str(v)) + if k == "liquidity_bucket": v = normalize_liquidity_bucket(str(v)) + if k == "active": v = int(bool(v)) + if k == "last_balance" and v != "": v = int(v) + if k == "currency": v = str(v).upper() + except Exception as e: return fail("validation_error", str(e)) + updates.append(f"{k}=?"); params.append(v) + if not updates: return fail("validation_error", "no fields to update") + incoming_date = str(fields.get("last_synced_at") or current["last_synced_at"] or "")[:10] + stored_date = str(current["last_synced_at"] or "")[:10] + historical = bool(incoming_date and stored_date and incoming_date < stored_date) + candidate_balance = fields.get("last_balance", current["last_balance"]) + candidate_date = fields.get("last_synced_at", current["last_synced_at"]) + if historical: + kept=[(u,p) for u,p in zip(updates,params) if not u.startswith(("last_balance=","last_synced_at="))] + updates=[u for u,_ in kept]; params=[p for _,p in kept] + updates.append("updated_at=?"); params.extend([now_iso(), account_id]) + conn.execute(f"UPDATE accounts SET {', '.join(updates)} WHERE id=?", params) row = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone() - return ok(account=row_to_dict(row)) + account = row_to_dict(row) + if any(k in fields for k in ("last_balance", "last_synced_at")): + _record_balance_candidate(conn, account, candidate_balance, candidate_date) + return ok(account=account) diff --git a/jarca_ledger_mcp/application/assets_service.py b/jarca_ledger_mcp/application/assets_service.py index d2505e5..39dd3b7 100644 --- a/jarca_ledger_mcp/application/assets_service.py +++ b/jarca_ledger_mcp/application/assets_service.py @@ -1,5 +1,10 @@ from __future__ import annotations +import json +import hashlib + +from .account_valuation_service import _public_candidate + from .shared import ( ASSET_BUCKETS, ASSET_BUCKET_ORDER, @@ -29,19 +34,36 @@ def _valuation_policy() -> dict[str, Any]: } -def _account_valuation_items(conn: sqlite3.Connection) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]: +def _evidence_state_hash(item: dict[str, Any]) -> str: + """Bind idempotency to evidence state without exposing sensitive text.""" + fields = ( + "source_type", "source_id", "name", "amount", "currency", + "liquidity_bucket", "authority_source", "valuation_id", + "valuation_date", "verification_status", "source_note", "memo", + "active", "carried_forward", + ) + payload = {key: item.get(key) for key in fields} + return hashlib.sha256( + json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode() + ).hexdigest() + + +def _account_valuation_items(conn: sqlite3.Connection, basis_date: str = "", selection_policy: str = "exact") -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]: rows = conn.execute("SELECT * FROM accounts WHERE active=1 ORDER BY name").fetchall() included: list[dict[str, Any]] = [] excluded: list[dict[str, Any]] = [] invalid: list[dict[str, Any]] = [] for row in rows: account = row_to_dict(row) + account_type = str(account.get("type") or "other").lower() raw_bucket = str(account.get("liquidity_bucket") or "other") try: bucket = normalize_liquidity_bucket(raw_bucket) except ValueError: bucket = "other" invalid.append({"id": account.get("id", ""), "name": account.get("name", ""), "liquidity_bucket": raw_bucket}) + if account_type == "loan": + bucket = "loan" base = { "id": account.get("id", ""), "source_id": account.get("id", ""), @@ -56,6 +78,62 @@ def _account_valuation_items(conn: sqlite3.Connection) -> tuple[list[dict[str, A "memo": account.get("memo", ""), } balance = account.get("last_balance") + if basis_date: + valuation_rows = conn.execute( + "SELECT * FROM account_valuations WHERE account_id=? AND currency=? ORDER BY basis_date DESC, recorded_at DESC, id DESC", + (account["id"], account.get("currency") or "KRW"), + ).fetchall() + leaves = [v for v in valuation_rows if conn.execute("SELECT 1 FROM account_valuations WHERE supersedes_id=?", (v["id"],)).fetchone() is None] + if account_type == "investment": + # Older imports recorded investment balances as cash_balance; + # keep that as a compatibility fallback after the canonical + # total_value/balance purposes. + purposes = ("total_value", "balance", "cash_balance") + elif account_type == "loan": + purposes = ("loan_balance", "loan", "balance") + else: + purposes = ("cash_balance", "balance") + selected = None + selected_purpose = None + candidate_rows = [] + for purpose in purposes: + scoped = [v for v in leaves if v["valuation_kind"] == purpose] + if selection_policy == "exact": + verified = [v for v in scoped if v["verification_status"] == "verified" and v["basis_date"] == basis_date] + else: + eligible = [v for v in scoped if v["verification_status"] == "verified" and v["basis_date"] <= basis_date] + latest = max((v["basis_date"] for v in eligible), default="") + verified = [v for v in eligible if v["basis_date"] == latest] + if len(verified) > 1: + invalid.append({**base, "reason": "ambiguous_valuation", "valuation_kind": purpose}) + verified = [] + break + if verified: + selected, selected_purpose = verified[0], purpose + break + candidate_rows.extend(v for v in scoped if v["verification_status"] != "verified" and v["basis_date"] <= basis_date and (v["basis_date"] == basis_date if selection_policy == "exact" else True)) + if selected is None: + excluded.append({**base, "reason": "missing_verified_exact_valuation" if selection_policy == "exact" else "missing_verified_valuation", "unverified_candidates": [_public_candidate(v) for v in candidate_rows]}) + continue + valuation = selected + if account_type == "loan" and ( + selected_purpose not in {"loan", "loan_balance"} + or not valuation["representative_liability_definition"] + ): + excluded.append({ + **base, + "reason": "missing_representative_liability_definition", + "valuation_id": valuation["id"], + "valuation_kind": selected_purpose, + "valuation_date": valuation["basis_date"], + "verification_status": valuation["verification_status"], + }) + 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}) + if candidate_rows: + base["unverified_candidates"] = [_public_candidate(v) for v in candidate_rows if v["basis_date"] <= basis_date] + if balance is None: excluded.append({**base, "reason": "missing_last_balance"}) continue @@ -64,13 +142,16 @@ def _account_valuation_items(conn: sqlite3.Connection) -> tuple[list[dict[str, A return included, excluded, invalid -def _manual_asset_valuation_items(conn: sqlite3.Connection, active_only: bool = True) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: +def _manual_asset_valuation_items(conn: sqlite3.Connection, active_only: bool = True, basis_date: str = "", selection_policy: str = "exact") -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: where = "WHERE active=1" if active_only else "" rows = conn.execute(f"SELECT * FROM manual_assets {where} ORDER BY name").fetchall() included: list[dict[str, Any]] = [] excluded: list[dict[str, Any]] = [] for row in rows: asset = row_to_dict(row) + if (asset.get("currency") or "KRW").upper() != "KRW": + excluded.append({"id": asset.get("id", ""), "source_id": asset.get("id", ""), "source_type": "manual_asset", "reason": "non_krw_without_fx", "currency": asset.get("currency")}) + continue try: bucket = normalize_liquidity_bucket(asset.get("liquidity_bucket")) except ValueError: @@ -88,6 +169,12 @@ def _manual_asset_valuation_items(conn: sqlite3.Connection, active_only: bool = "active": asset.get("active", True), } value = asset.get("valuation_amount") + if basis_date: + valuation_date = str(asset.get("valuation_date") or "")[:10] + if not valuation_date or valuation_date > basis_date or (selection_policy == "exact" and valuation_date != basis_date): + 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 if value is None: excluded.append({**base, "reason": "missing_valuation_amount"}) continue @@ -96,26 +183,12 @@ def _manual_asset_valuation_items(conn: sqlite3.Connection, active_only: bool = def _snapshot_rows_for_latest_run(conn: sqlite3.Connection, basis_date: str) -> tuple[list[sqlite3.Row], dict[str, Any]]: - rows = conn.execute( - "SELECT rowid AS _rowid, * FROM asset_snapshots WHERE basis_date=? ORDER BY created_at ASC, name ASC", - (basis_date,), - ).fetchall() - if not rows: - return [], {} - run_rows = [row for row in rows if "snapshot_run_id" in row.keys() and row["snapshot_run_id"]] - if not run_rows: - return rows, {"snapshot_run_id": "", "is_correction": False, "correction_of_run_id": "", "correction_reason": "", "total_snapshot_rows_for_basis_date": len(rows)} - latest = sorted(run_rows, key=lambda row: (int(row["_rowid"]), row["closed_at"] or row["created_at"] or "", row["snapshot_run_id"] or ""))[-1] - latest_run_id = latest["snapshot_run_id"] - selected = [row for row in run_rows if row["snapshot_run_id"] == latest_run_id] - metadata = { - "snapshot_run_id": latest_run_id, - "is_correction": bool(latest["is_correction"]), - "correction_of_run_id": latest["correction_of_run_id"] or "", - "correction_reason": latest["correction_reason"] or "", - "closed_at": latest["closed_at"] or latest["created_at"] or "", - "total_snapshot_rows_for_basis_date": len(rows), - } + rows = conn.execute("SELECT rowid AS _rowid, * FROM asset_snapshots WHERE basis_date=? ORDER BY created_at ASC, name ASC", (basis_date,)).fetchall() + run = conn.execute("SELECT * FROM asset_snapshot_runs WHERE basis_date=? ORDER BY rowid DESC LIMIT 1", (basis_date,)).fetchone() + if not run: + return rows, {"snapshot_run_id": "", "is_correction": False, "correction_of_run_id": "", "correction_reason": "", "total_snapshot_rows_for_basis_date": len(rows)} if rows else {} + selected = [row for row in rows if row["snapshot_run_id"] == run["id"]] + metadata = {"snapshot_run_id": run["id"], "is_correction": bool(run["correction_of_run_id"]), "correction_of_run_id": run["correction_of_run_id"] or "", "correction_reason": run["correction_reason"] or "", "close_status": run["close_status"], "selection_policy": run["selection_policy"], "approved_by": run["approved_by"], "approved_at": run["approved_at"], "approval_reason": run["approval_reason"], "approval_reference": run["approval_reference"], "applied_evidence": json.loads(run["applied_evidence"] or "[]"), "excluded_evidence": json.loads(run["excluded_evidence"] or "[]"), "carried_evidence": json.loads(run["carried_evidence"] or "[]"), "closed_at": run["created_at"] or "", "total_snapshot_rows_for_basis_date": len(rows)} return selected, metadata @@ -277,16 +350,18 @@ def deactivate_manual_asset(asset_id: str, hard_delete: bool = False, db_path: s return ok(**payload) -def _snapshot_preview_from_conn(conn: sqlite3.Connection, basis: str) -> dict[str, Any]: - account_items, account_excluded, invalid_bucket_accounts = _account_valuation_items(conn) - manual_items, manual_excluded = _manual_asset_valuation_items(conn) +def _snapshot_preview_from_conn(conn: sqlite3.Connection, basis: str, selection_policy: str = "exact") -> dict[str, Any]: + 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 summary = _summarize_valuation_items(included, excluded) existing_rows = conn.execute("SELECT COUNT(*) FROM asset_snapshots WHERE basis_date=?", (basis,)).fetchone()[0] warnings: list[dict[str, Any]] = [] for item in excluded: - warnings.append({"code": item.get("reason", "excluded_item"), "source_type": item.get("source_type", ""), "source_id": item.get("source_id", ""), "name": item.get("name", "")}) + 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")}) + if item.get("unverified_candidates"): + warnings.append({"code": "unverified_candidate", "source_type": item.get("source_type", ""), "source_id": item.get("source_id", ""), "candidates": [{k: candidate.get(k) for k in ("id", "basis_date", "currency", "valuation_kind", "verification_status")} for candidate in item["unverified_candidates"]]}) for item in included: item_date = str(item.get("valuation_date") or "")[:10] if item_date and item_date < basis: @@ -307,17 +382,24 @@ def _snapshot_preview_from_conn(conn: sqlite3.Connection, basis: str) -> dict[st } -def preview_asset_snapshot_close(basis_date: str = "", db_path: str | None = None) -> dict[str, Any]: +def preview_asset_snapshot_close(basis_date: str = "", selection_policy: str = "exact", db_path: str | None = None) -> 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() except Exception as e: return fail("validation_error", str(e)) with db(db_path) as conn: - preview = _snapshot_preview_from_conn(conn, basis) + preview = _snapshot_preview_from_conn(conn, basis, selection_policy) + preview["selection_policy"] = selection_policy + blocking = [w for w in preview["warnings"] if w.get("code", "").startswith(("missing_", "future_", "unknown_", "unverified", "ambiguous_", "stale_"))] + preview["close_status"] = "complete_exact" if selection_policy == "exact" and not blocking else "incomplete_draft" return ok(**preview) -def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str = "", correction_reason: str = "", db_path: str | None = None) -> dict[str, Any]: +def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str = "", correction_reason: str = "", selection_policy: str = "exact", idempotency_key: str = "", allow_missing_exclusions: bool = False, allow_as_of_carry_forward: bool = False, approved_by: str = "", approval_reason: str = "", approval_reference: str = "", db_path: str | None = None) -> 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() except Exception as e: @@ -325,21 +407,39 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str ts = now_iso() run_id = new_id("snaprun") with db(db_path) as conn: - preview = _snapshot_preview_from_conn(conn, basis) + conn.execute("BEGIN IMMEDIATE") + preview = _snapshot_preview_from_conn(conn, basis, selection_policy) + missing_exclusions = [w for w in preview["warnings"] if 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"]]} + 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") + if carried_items and (not allow_as_of_carry_forward or not approved_by.strip() or not approval_reason.strip() or not approval_reference.strip()): + return fail("conditional_approval_required", "conditional close requires explicit approval metadata and flags") + if idempotency_key: + 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) + return fail("idempotency_conflict", "snapshot close idempotency payload differs") included = preview["included_items"] latest_rows, latest_meta = _snapshot_rows_for_latest_run(conn, basis) - is_correction = bool(preview["existing_snapshot_row_count"]) + # 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")) if is_correction and not correction_reason.strip(): - return fail("validation_error", "correction_reason is required when closing a basis_date that already has snapshot rows", basis_date=basis, existing_snapshot_row_count=preview["existing_snapshot_row_count"]) - correction_of_run_id = latest_meta.get("snapshot_run_id", "") if latest_rows else "" + return fail("validation_error", "correction_reason is required when closing a basis_date that already has a snapshot run", basis_date=basis, existing_snapshot_row_count=preview["existing_snapshot_row_count"]) + correction_of_run_id = latest_meta.get("snapshot_run_id", "") snapshot_ids: list[str] = [] for item in included: snapshot_id = new_id("snap") row_note = source_note or item.get("source_note", "") or "month-end close" row_memo = memo or item.get("memo", "") conn.execute( - """INSERT INTO asset_snapshots(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,valuation_source,source_note,memo,snapshot_run_id,correction_of_run_id,correction_reason,is_correction,closed_at,created_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + """INSERT INTO asset_snapshots(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,valuation_source,source_note,memo,snapshot_run_id,correction_of_run_id,correction_reason,is_correction,closed_at,valuation_id,selection_policy,verification_status,evidence_kind,exclusion_reason,created_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( snapshot_id, basis, @@ -357,12 +457,24 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str correction_reason.strip(), int(is_correction), ts, + item.get("valuation_id", ""), + selection_policy, + item.get("verification_status", "unverified"), + "carried_forward" if item.get("carried_forward") else "applied", + "", ts, ), ) snapshot_ids.append(snapshot_id) - report_items = _snapshot_items(conn, basis) - summary = _summarize_valuation_items(report_items, []) + 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] + excluded_evidence = [{k: item.get(k) for k in ("source_type", "source_id", "reason", "currency")} for item in preview.get("excluded_items", [])] + 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")] + 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 as exc: + conn.rollback() + return fail("snapshot_conflict", "another snapshot close already owns this basis date", basis_date=basis, detail=str(exc)) return ok( basis_date=basis, source="snapshot", @@ -372,6 +484,8 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str correction_reason=correction_reason.strip(), is_correction=is_correction, closed_at=ts, + close_status="conditional" if conditional else "complete_exact", + selection_policy=selection_policy, inserted_count=len(snapshot_ids), inserted_snapshot_ids=snapshot_ids, warnings=preview["warnings"], @@ -402,12 +516,14 @@ def create_asset_snapshot(basis_date: str, source_type: str, source_id: str, nam return ok(asset_snapshot=row_to_dict(row)) -def get_asset_snapshot_report(basis_date: str = "", db_path: str | None = None) -> dict[str, Any]: +def get_asset_snapshot_report(basis_date: str = "", selection_policy: str = "exact", db_path: str | None = None) -> dict[str, Any]: + if selection_policy not in {"exact", "as_of"}: + return fail("validation_error", "selection_policy must be exact or as_of") basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat() with db(db_path) as conn: rows, close_metadata = _snapshot_rows_for_latest_run(conn, basis) snapshots = _snapshot_items(conn, basis) if rows else [] - if snapshots: + if close_metadata: summary = _summarize_valuation_items(snapshots, []) return ok( basis_date=basis, @@ -415,16 +531,20 @@ def get_asset_snapshot_report(basis_date: str = "", db_path: str | None = None) is_closed_snapshot=True, snapshot_count=len(snapshots), close_metadata=close_metadata, + selection_policy=close_metadata.get("selection_policy", selection_policy), + close_status=close_metadata.get("close_status", "complete_exact"), **summary, ) - account_items, account_excluded, invalid_bucket_accounts = _account_valuation_items(conn) - manual_items, manual_excluded = _manual_asset_valuation_items(conn) + 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 summary = _summarize_valuation_items(included, excluded) return ok( basis_date=basis, source="live_authority", + selection_policy=selection_policy, + close_status="incomplete_draft" if excluded else "complete_exact", is_closed_snapshot=False, snapshot_count=0, invalid_bucket_accounts=invalid_bucket_accounts, diff --git a/jarca_ledger_mcp/application/imports_service.py b/jarca_ledger_mcp/application/imports_service.py index ef652b6..122aa05 100644 --- a/jarca_ledger_mcp/application/imports_service.py +++ b/jarca_ledger_mcp/application/imports_service.py @@ -31,6 +31,13 @@ from .shared import ( transaction_preview, ) + +def _record_import_valuation_candidate(conn: Any, account_id: str, balance: Any, basis_date: str, source_hash: str) -> None: + if balance is None or not basis_date: + return + from .account_valuation_service import record_account_valuation_candidate + record_account_valuation_candidate(conn, account_id, basis_date, balance, "KRW", "import", source_hash, f"import-balance:{account_id}:{basis_date}:{source_hash}") + def 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(): @@ -211,10 +218,9 @@ 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), - ) + conn.execute("UPDATE accounts SET last_balance=?, last_synced_at=?, updated_at=? WHERE id=?", (transactions[-1].balance_after, ts, ts, linked_account_id)) + 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: return fail("validation_error", str(e)) except Exception as e: @@ -677,10 +683,9 @@ 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), - ) + conn.execute("UPDATE accounts SET last_balance=?, last_synced_at=?, updated_at=? WHERE id=?", (transactions[-1].balance_after, ts, ts, linked_account_id)) + 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: return fail("validation_error", str(e)) except Exception as e: @@ -902,10 +907,9 @@ 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), - ) + conn.execute("UPDATE accounts SET last_balance=?, last_synced_at=?, updated_at=? WHERE id=?", (transactions[-1].balance_after, ts, ts, linked_account_id)) + 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: return fail("validation_error", str(e)) except Exception as e: diff --git a/jarca_ledger_mcp/application/monthly_settlement_service.py b/jarca_ledger_mcp/application/monthly_settlement_service.py index de8c83b..72b77de 100644 --- a/jarca_ledger_mcp/application/monthly_settlement_service.py +++ b/jarca_ledger_mcp/application/monthly_settlement_service.py @@ -64,6 +64,10 @@ def _source_coverage( "source": asset_report.get("source", ""), "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", ""), + "warnings": asset_report.get("warnings", []), + "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")], }, } @@ -108,6 +112,7 @@ def get_monthly_settlement_summary(month: str = "", db_path: str | None = None) reports={ "cashflow": cashflow_report, "budget": budget_report, + "assets": asset_report, }, read_only=True, ) diff --git a/jarca_ledger_mcp/application/reconciliation_service.py b/jarca_ledger_mcp/application/reconciliation_service.py index b5b9888..ed0b232 100644 --- a/jarca_ledger_mcp/application/reconciliation_service.py +++ b/jarca_ledger_mcp/application/reconciliation_service.py @@ -86,6 +86,24 @@ def get_balance_reconciliation_report(account_id: str = "", baseline_balance: in 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 = conn.execute( + """SELECT * FROM account_valuations v + WHERE v.account_id=? AND v.verification_status='verified' + AND v.basis_date<=? + AND NOT EXISTS ( + SELECT 1 FROM account_valuations child + WHERE child.supersedes_id=v.id + ) + ORDER BY CASE v.valuation_kind + WHEN 'total_value' THEN 0 + WHEN 'balance' THEN 1 + WHEN 'cash_balance' THEN 2 + ELSE 3 + END, + v.basis_date DESC, v.recorded_at DESC, v.id DESC + LIMIT 1""", + (account["id"], effective_at[:10]), + ).fetchone() if effective_baseline is None: difference = None ratio = None @@ -94,6 +112,9 @@ def get_balance_reconciliation_report(account_id: str = "", baseline_balance: in difference = int(effective_baseline) - int(calc["calculated_balance"]) ratio = abs(difference) / abs(int(effective_baseline)) if int(effective_baseline) else None status = "OK" if abs(difference) <= tolerance_int else "REVIEW_NEEDED" + if account["type"] == "investment" and valuation is not None: + status = "VALUATION_MANAGED" + difference = None items.append({ "account_id": account["id"], "account_name": account["name"], @@ -107,6 +128,9 @@ def get_balance_reconciliation_report(account_id: str = "", baseline_balance: in "reference_ratio_threshold": BALANCE_RECONCILIATION_REFERENCE_RATIO, "difference_ratio": ratio, "status": status, + "external_confirmed_value": int(valuation["amount"]) if valuation is not None else None, + "valuation_id": valuation["id"] if valuation is not None else "", + "valuation_kind": valuation["valuation_kind"] if valuation is not None else "", "recommended_decisions": ["계좌이동", "실지출", "실수입", "투자이동", "분석제외", "확인필요"] if status == "REVIEW_NEEDED" else [], "reconciliation_session": latest_sessions.get(account["id"]), "review_status": latest_decisions.get(("reconciliation_account", account["id"]), {}).get("status", "pending"), diff --git a/jarca_ledger_mcp/infrastructure/sqlite/migrations.py b/jarca_ledger_mcp/infrastructure/sqlite/migrations.py index b948bf6..fab9918 100644 --- a/jarca_ledger_mcp/infrastructure/sqlite/migrations.py +++ b/jarca_ledger_mcp/infrastructure/sqlite/migrations.py @@ -98,10 +98,28 @@ def migrate_db(conn: sqlite3.Connection) -> None: "correction_reason": "TEXT NOT NULL DEFAULT ''", "is_correction": "INTEGER NOT NULL DEFAULT 0", "closed_at": "TEXT NOT NULL DEFAULT ''", + "valuation_id": "TEXT NOT NULL DEFAULT ''", + "selection_policy": "TEXT NOT NULL DEFAULT 'exact'", + "verification_status": "TEXT NOT NULL DEFAULT 'unverified'", + "evidence_kind": "TEXT NOT NULL DEFAULT 'applied'", + "exclusion_reason": "TEXT NOT NULL DEFAULT ''", }) conn.execute("CREATE INDEX IF NOT EXISTS idx_asset_snapshots_basis_date ON asset_snapshots(basis_date)") conn.execute("CREATE INDEX IF NOT EXISTS idx_asset_snapshots_source ON asset_snapshots(source_type, source_id)") conn.execute("CREATE INDEX IF NOT EXISTS idx_asset_snapshots_run ON asset_snapshots(basis_date, snapshot_run_id, created_at)") + conn.execute("""CREATE TABLE IF NOT EXISTS asset_snapshot_runs ( + id TEXT PRIMARY KEY, basis_date TEXT NOT NULL, close_status TEXT NOT NULL, + selection_policy TEXT NOT NULL DEFAULT 'exact', idempotency_key TEXT NOT NULL DEFAULT '', + approved_by TEXT NOT NULL DEFAULT '', approved_at TEXT NOT NULL DEFAULT '', + approval_reason TEXT NOT NULL DEFAULT '', approval_reference TEXT NOT NULL DEFAULT '', + correction_of_run_id TEXT NOT NULL DEFAULT '', correction_reason TEXT NOT NULL DEFAULT '', + applied_evidence TEXT NOT NULL DEFAULT '[]', excluded_evidence TEXT NOT NULL DEFAULT '[]', + carried_evidence TEXT NOT NULL DEFAULT '[]', request_fingerprint TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL + )""") + _add_missing_columns(conn, "asset_snapshot_runs", {"request_fingerprint": "TEXT NOT NULL DEFAULT ''"}) + conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_idempotency ON asset_snapshot_runs(idempotency_key) WHERE idempotency_key <> ''") + conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_initial_basis ON asset_snapshot_runs(basis_date) WHERE correction_of_run_id = ''") + conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_correction_parent ON asset_snapshot_runs(correction_of_run_id) WHERE correction_of_run_id <> ''") conn.execute("""CREATE TABLE IF NOT EXISTS review_decisions ( id TEXT PRIMARY KEY, target_type TEXT NOT NULL, @@ -136,6 +154,22 @@ def migrate_db(conn: sqlite3.Connection) -> None: )""") conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_reconciliation_sessions_account_basis ON reconciliation_sessions(account_id, basis_date)") conn.execute("CREATE INDEX IF NOT EXISTS idx_reconciliation_sessions_status ON reconciliation_sessions(status, closed_at)") + conn.execute("""CREATE TABLE IF NOT EXISTS account_valuations ( + id TEXT PRIMARY KEY, account_id TEXT NOT NULL REFERENCES accounts(id), basis_date TEXT NOT NULL, + amount INTEGER NOT NULL, currency TEXT NOT NULL DEFAULT 'KRW', valuation_kind TEXT NOT NULL DEFAULT 'balance', + source_type TEXT NOT NULL DEFAULT 'manual', source_ref TEXT NOT NULL DEFAULT '', + verification_status TEXT NOT NULL DEFAULT 'unverified', recorded_at TEXT NOT NULL, + memo TEXT NOT NULL DEFAULT '', idempotency_key TEXT NOT NULL DEFAULT '', + supersedes_id TEXT REFERENCES account_valuations(id), correction_reason TEXT NOT NULL DEFAULT '', + as_of_at TEXT, timezone TEXT, cost_basis INTEGER, net_contributed INTEGER, + reported_principal_kind TEXT, reported_principal_label TEXT, profit INTEGER, + return_rate REAL, return_basis TEXT, principal INTEGER, interest INTEGER, fee INTEGER, + representative_liability_definition TEXT + )""") + conn.execute("CREATE INDEX IF NOT EXISTS idx_account_valuations_account_date ON account_valuations(account_id, basis_date, recorded_at)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_account_valuations_status_date ON account_valuations(verification_status, basis_date)") + conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_account_valuations_idempotency ON account_valuations(account_id, idempotency_key) WHERE idempotency_key <> ''") + conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_account_valuations_single_correction ON account_valuations(supersedes_id) WHERE supersedes_id IS NOT NULL") conn.execute("INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', ?)", (SCHEMA_VERSION,)) diff --git a/jarca_ledger_mcp/infrastructure/sqlite/schema.py b/jarca_ledger_mcp/infrastructure/sqlite/schema.py index 55c8bf9..583bb4c 100644 --- a/jarca_ledger_mcp/infrastructure/sqlite/schema.py +++ b/jarca_ledger_mcp/infrastructure/sqlite/schema.py @@ -1,6 +1,6 @@ from __future__ import annotations -SCHEMA_VERSION = "11" +SCHEMA_VERSION = "12" SCHEMA_SQL = """ PRAGMA journal_mode=WAL; @@ -163,11 +163,38 @@ CREATE TABLE IF NOT EXISTS asset_snapshots ( correction_reason TEXT NOT NULL DEFAULT '', is_correction INTEGER NOT NULL DEFAULT 0, closed_at TEXT NOT NULL DEFAULT '', + valuation_id TEXT NOT NULL DEFAULT '', + selection_policy TEXT NOT NULL DEFAULT 'exact', + verification_status TEXT NOT NULL DEFAULT 'unverified', + evidence_kind TEXT NOT NULL DEFAULT 'applied', + exclusion_reason TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_asset_snapshots_basis_date ON asset_snapshots(basis_date); CREATE INDEX IF NOT EXISTS idx_asset_snapshots_source ON asset_snapshots(source_type, source_id); +CREATE TABLE IF NOT EXISTS asset_snapshot_runs ( + id TEXT PRIMARY KEY, + basis_date TEXT NOT NULL, + close_status TEXT NOT NULL, + selection_policy TEXT NOT NULL DEFAULT 'exact', + idempotency_key TEXT NOT NULL DEFAULT '', + approved_by TEXT NOT NULL DEFAULT '', + approved_at TEXT NOT NULL DEFAULT '', + approval_reason TEXT NOT NULL DEFAULT '', + approval_reference TEXT NOT NULL DEFAULT '', + correction_of_run_id TEXT NOT NULL DEFAULT '', + correction_reason TEXT NOT NULL DEFAULT '', + applied_evidence TEXT NOT NULL DEFAULT '[]', + excluded_evidence TEXT NOT NULL DEFAULT '[]', + carried_evidence TEXT NOT NULL DEFAULT '[]', + request_fingerprint TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_idempotency ON asset_snapshot_runs(idempotency_key) WHERE idempotency_key <> ''; +CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_initial_basis ON asset_snapshot_runs(basis_date) WHERE correction_of_run_id = ''; +CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_correction_parent ON asset_snapshot_runs(correction_of_run_id) WHERE correction_of_run_id <> ''; + CREATE TABLE IF NOT EXISTS review_decisions ( id TEXT PRIMARY KEY, target_type TEXT NOT NULL, @@ -204,7 +231,41 @@ CREATE TABLE IF NOT EXISTS reconciliation_sessions ( CREATE UNIQUE INDEX IF NOT EXISTS idx_reconciliation_sessions_account_basis ON reconciliation_sessions(account_id, basis_date); CREATE INDEX IF NOT EXISTS idx_reconciliation_sessions_status ON reconciliation_sessions(status, closed_at); -INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', '11'); +CREATE TABLE IF NOT EXISTS account_valuations ( + id TEXT PRIMARY KEY, + account_id TEXT NOT NULL REFERENCES accounts(id), + basis_date TEXT NOT NULL, + amount INTEGER NOT NULL, + currency TEXT NOT NULL DEFAULT 'KRW', + valuation_kind TEXT NOT NULL DEFAULT 'balance', + source_type TEXT NOT NULL DEFAULT 'manual', + source_ref TEXT NOT NULL DEFAULT '', + verification_status TEXT NOT NULL DEFAULT 'unverified', + recorded_at TEXT NOT NULL, + memo TEXT NOT NULL DEFAULT '', + idempotency_key TEXT NOT NULL DEFAULT '', + supersedes_id TEXT REFERENCES account_valuations(id), + correction_reason TEXT NOT NULL DEFAULT '', + as_of_at TEXT, + timezone TEXT, + cost_basis INTEGER, + net_contributed INTEGER, + reported_principal_kind TEXT, + reported_principal_label TEXT, + profit INTEGER, + return_rate REAL, + return_basis TEXT, + principal INTEGER, + interest INTEGER, + fee INTEGER, + representative_liability_definition TEXT +); +CREATE INDEX IF NOT EXISTS idx_account_valuations_account_date ON account_valuations(account_id, basis_date, recorded_at); +CREATE INDEX IF NOT EXISTS idx_account_valuations_status_date ON account_valuations(verification_status, basis_date); +CREATE UNIQUE INDEX IF NOT EXISTS idx_account_valuations_idempotency ON account_valuations(account_id, idempotency_key) WHERE idempotency_key <> ''; +CREATE UNIQUE INDEX IF NOT EXISTS idx_account_valuations_single_correction ON account_valuations(supersedes_id) WHERE supersedes_id IS NOT NULL; + +INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', '12'); """ ACCOUNT_ADDITIVE_COLUMNS: dict[str, str] = { diff --git a/jarca_ledger_mcp/service.py b/jarca_ledger_mcp/service.py index 1788c2d..55e9ff3 100644 --- a/jarca_ledger_mcp/service.py +++ b/jarca_ledger_mcp/service.py @@ -4,6 +4,7 @@ import sys from types import ModuleType from .application import accounts_write_services as _accounts_write_services +from .application import account_valuation_service as _account_valuation_service from .application import asset_account_services as _asset_account_services from .application import assets_service as _assets_service from .application import budgets_write_services as _budgets_write_services @@ -27,6 +28,7 @@ _current_kst_datetime = _shared._current_kst_datetime _PROXY_MODULES = ( _shared, _accounts_write_services, + _account_valuation_service, _system_services, _asset_account_services, _assets_service, diff --git a/scripts/ci_fixture_db.py b/scripts/ci_fixture_db.py index 6844402..87455cc 100755 --- a/scripts/ci_fixture_db.py +++ b/scripts/ci_fixture_db.py @@ -50,6 +50,12 @@ def create_ci_fixture_db(db_path: str | Path) -> dict[str, Any]: if not account.get("success"): raise RuntimeError(f"fixture account seed failed: {account.get('error_code')}") account_id = account["account"]["id"] + verified = service.register_account_valuation( + account_id, "2026-06-30", 1_250_000, verification_status="verified", + source_type="manual", source_ref="ci-fixture", idempotency_key="ci-checking-v1", db_path=str(path), + ) + if not verified.get("success"): + raise RuntimeError(f"fixture valuation seed failed: {verified.get('error_code')}") for account_spec in [ dict(name="CI Synthetic Investment", type="investment", institution="CI Synthetic Securities", last_balance=5_000_000, liquidity_bucket="investment"), @@ -64,6 +70,16 @@ def create_ci_fixture_db(db_path: str | Path) -> dict[str, Any]: ) if not result.get("success"): raise RuntimeError(f"fixture account seed failed: {result.get('error_code')}") + if account_spec.get("last_balance") is not None: + value = service.register_account_valuation( + result["account"]["id"], "2026-06-30", account_spec["last_balance"], + valuation_kind="loan_balance" if account_spec["type"] == "loan" else "cash_balance", + representative_liability_definition="principal_only" if account_spec["type"] == "loan" else None, + verification_status="verified", source_type="manual", source_ref="ci-fixture", + idempotency_key=f"ci-{account_spec['name']}-v1", db_path=str(path), + ) + if not value.get("success"): + raise RuntimeError(f"fixture valuation seed failed: {value.get('error_code')}") budget = service.create_ledger_budget("2026-06", "식비", 50_000, memo="ci synthetic budget", db_path=str(path)) if not budget.get("success"): diff --git a/tests/test_account_valuations.py b/tests/test_account_valuations.py new file mode 100644 index 0000000..4ba9b77 --- /dev/null +++ b/tests/test_account_valuations.py @@ -0,0 +1,102 @@ +import sqlite3 + +from jarca_ledger_mcp import service +from jarca_ledger_mcp.db import init_db + + +def _account(db_path, currency="KRW"): + result = service.create_ledger_account("주계좌", currency=currency, db_path=db_path) + assert result["success"] + return result["account"]["id"] + + +def test_account_valuation_schema_is_v12_and_idempotent(tmp_path): + path = str(tmp_path / "ledger.sqlite3") + init_db(path) + init_db(path) + with sqlite3.connect(path) as conn: + cols = {row[1] for row in conn.execute("PRAGMA table_info(account_valuations)")} + version = conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] + assert version == "12" + assert {"id", "account_id", "basis_date", "amount", "currency", "valuation_kind", "source_type", "source_ref", "verification_status", "recorded_at", "memo", "idempotency_key", "supersedes_id", "correction_reason", "as_of_at", "timezone", "cost_basis", "net_contributed", "reported_principal_kind", "reported_principal_label", "profit", "return_rate", "return_basis", "principal", "interest", "fee", "representative_liability_definition"} <= cols + + +def test_register_same_idempotency_payload_reuses_row_and_changed_payload_conflicts(tmp_path): + path = str(tmp_path / "ledger.sqlite3") + account_id = _account(path) + first = service.register_account_valuation(account_id, "2026-06-30", 1000, idempotency_key="sync-1", verification_status="verified", source_type="import", source_ref="stmt-1", db_path=path) + same = service.register_account_valuation(account_id, "2026-06-30", 1000, idempotency_key="sync-1", verification_status="verified", source_type="import", source_ref="stmt-1", db_path=path) + conflict = service.register_account_valuation(account_id, "2026-06-30", 1200, idempotency_key="sync-1", verification_status="verified", source_type="import", source_ref="stmt-1", db_path=path) + assert first["success"] and same["success"] + assert same["reused"] is True and same["valuation"]["id"] == first["valuation"]["id"] + assert conflict["success"] is False and conflict["error_code"] == "idempotency_conflict" + + +def test_select_exact_and_as_of_excludes_unverified_and_reports_carry_forward(tmp_path): + path = str(tmp_path / "ledger.sqlite3") + account_id = _account(path) + service.register_account_valuation(account_id, "2026-06-01", 1000, verification_status="verified", idempotency_key="v1", db_path=path) + service.register_account_valuation(account_id, "2026-06-15", 2000, verification_status="unverified", idempotency_key="v2", db_path=path) + exact = service.select_account_valuation(account_id, "2026-06-15", db_path=path) + as_of = service.select_account_valuation(account_id, "2026-06-20", as_of=True, db_path=path) + assert exact["success"] and exact["valuation"] is None + assert exact["warning"] == "unverified_candidate" + assert as_of["success"] and as_of["valuation"]["amount"] == 1000 + assert as_of["carried_forward"] is True and as_of["valuation_age_days"] == 19 + + +def test_correction_is_append_only_reasoned_and_single_child(tmp_path): + path = str(tmp_path / "ledger.sqlite3") + account_id = _account(path) + original = service.register_account_valuation(account_id, "2026-06-30", 1000, verification_status="verified", idempotency_key="v1", db_path=path) + corrected = service.correct_account_valuation(original["valuation"]["id"], 1200, correction_reason="은행 원장 정정", idempotency_key="v2", db_path=path) + duplicate = service.correct_account_valuation(original["valuation"]["id"], 1300, correction_reason="두 번째 분기", idempotency_key="v3", db_path=path) + listed = service.list_account_valuations(account_id, db_path=path) + assert corrected["success"] and corrected["valuation"]["supersedes_id"] == original["valuation"]["id"] + assert duplicate["success"] is False and duplicate["error_code"] == "correction_conflict" + assert listed["count"] == 2 + selected = service.select_account_valuation(account_id, "2026-06-30", db_path=path) + assert selected["valuation"]["amount"] == 1200 + + +def test_account_balance_write_records_unverified_candidate_only_with_real_basis_date(tmp_path): + path = str(tmp_path / "ledger.sqlite3") + created = service.create_ledger_account("동기화 계좌", last_balance=500, last_synced_at="2026-06-30", db_path=path) + service.create_ledger_account("날짜 없는 계좌", last_balance=700, db_path=path) + values = service.list_account_valuations(created["account"]["id"], db_path=path) + assert values["count"] == 1 + assert values["valuations"][0]["verification_status"] == "unverified" + assert values["valuations"][0]["basis_date"] == "2026-06-30" + + +def test_idempotency_scope_includes_basis_and_correction_metadata(tmp_path): + path = str(tmp_path / "ledger.sqlite3") + account_id = _account(path) + first = service.register_account_valuation(account_id, "2026-06-30", 1000, idempotency_key="same", db_path=path) + changed_basis = service.register_account_valuation(account_id, "2026-07-01", 1000, idempotency_key="same", db_path=path) + changed_reason = service.register_account_valuation(account_id, "2026-06-30", 1000, idempotency_key="same", supersedes_id="other", correction_reason="정정", db_path=path) + assert first["success"] + assert changed_basis["error_code"] == "idempotency_conflict" + assert changed_reason["error_code"] == "idempotency_conflict" + + +def test_correction_preserves_optional_fields_unless_overridden(tmp_path): + path = str(tmp_path / "ledger.sqlite3") + account_id = _account(path, currency="USD") + original = service.register_account_valuation(account_id, "2026-06-30", 1000, currency="USD", cost_basis=900, net_contributed=950, profit=50, return_rate=0.055, principal=800, interest=20, fee=5, as_of_at="2026-06-30T12:00:00+09:00", timezone="Asia/Seoul", db_path=path) + corrected = service.correct_account_valuation(original["valuation"]["id"], 1100, correction_reason="정정", idempotency_key="corr", profit=200, db_path=path) + assert corrected["success"] + value = corrected["valuation"] + assert value["cost_basis"] == 900 and value["net_contributed"] == 950 + assert value["profit"] == 200 and value["return_rate"] == 0.055 + assert value["principal"] == 800 and value["interest"] == 20 and value["fee"] == 5 + assert value["as_of_at"] == "2026-06-30T12:00:00+09:00" and value["timezone"] == "Asia/Seoul" + + +def test_account_name_only_update_does_not_create_candidate(tmp_path): + path = str(tmp_path / "ledger.sqlite3") + created = service.create_ledger_account("계좌", last_balance=500, last_synced_at="2026-06-30", db_path=path) + account_id = created["account"]["id"] + updated = service.update_ledger_account(account_id, name="새 이름", db_path=path) + values = service.list_account_valuations(account_id, db_path=path) + assert updated["success"] and values["count"] == 1 diff --git a/tests/test_asset_snapshots.py b/tests/test_asset_snapshots.py index 1393413..b01ebbc 100644 --- a/tests/test_asset_snapshots.py +++ b/tests/test_asset_snapshots.py @@ -25,6 +25,8 @@ def test_manual_assets_snapshot_report_and_net_worth_formula(tmp_path): stocks = service.create_ledger_account("투자", type="investment", last_balance=2_000_000, liquidity_bucket="investment", db_path=db_path) missing = service.create_ledger_account("미평가 계좌", liquidity_bucket="deposit", db_path=db_path) assert cash["success"] and stocks["success"] and missing["success"] + assert service.register_account_valuation(cash["account"]["id"], "2026-06-30", 1_000_000, verification_status="verified", idempotency_key="cash-v1", db_path=db_path)["success"] + assert service.register_account_valuation(stocks["account"]["id"], "2026-06-30", 2_000_000, verification_status="verified", idempotency_key="stocks-v1", db_path=db_path)["success"] house = service.create_manual_asset("전세보증금", 300_000_000, "2026-06-30", "real_estate", source_note="synthetic appraisal", db_path=db_path) loan = service.create_manual_asset("주담대", 120_000_000, "2026-06-30", "loan", source_note="synthetic loan balance", db_path=db_path) @@ -92,6 +94,7 @@ def test_manual_asset_update_deactivate_preview_and_close_correction_policy(tmp_ loan = service.create_manual_asset("대출", 120_000_000, "2026-06-30", "loan", source_note="synthetic", db_path=db_path) dormant = service.create_manual_asset("해지 예금", 5_000_000, "2026-06-30", "deposit", source_note="synthetic", db_path=db_path) assert account["success"] and asset["success"] and loan["success"] and dormant["success"] + assert service.register_account_valuation(account["account"]["id"], "2026-06-30", 1_000_000, verification_status="verified", idempotency_key="cash-v1", db_path=db_path)["success"] updated = service.update_manual_asset(asset["manual_asset"]["id"], valuation_amount=310_000_000, memo="updated synthetic", db_path=db_path) assert updated["success"] is True diff --git a/tests/test_contract_regression.py b/tests/test_contract_regression.py index 2acf235..1643231 100644 --- a/tests/test_contract_regression.py +++ b/tests/test_contract_regression.py @@ -67,6 +67,11 @@ def _build_contract_fixture(db_path: str) -> None: ) assert account["success"] is True account_id = account["account"]["id"] + assert service.register_account_valuation( + account_id, "2026-06-30", 1_000_000, verification_status="verified", + source_type="manual", source_ref="contract-fixture", idempotency_key="contract-account-v1", + db_path=db_path, + )["success"] is True assert service.create_ledger_transaction( "2026-06-01", "income", @@ -114,8 +119,9 @@ def test_list_report_and_mutation_success_envelope_contract(tmp_path: Path) -> N db_path = str(tmp_path / "contract-fixture.sqlite3") _build_contract_fixture(db_path) - mutation = service.create_ledger_account("Synthetic Savings", type="bank", liquidity_bucket="deposit", db_path=db_path) + mutation = service.create_ledger_account("Synthetic Savings", type="bank", liquidity_bucket="deposit", last_balance=0, last_synced_at="2026-06-30", db_path=db_path) assert mutation["success"] is True + assert service.register_account_valuation(mutation["account"]["id"], "2026-06-30", 0, verification_status="verified", idempotency_key="savings-v1", db_path=db_path)["success"] _assert_subset_schema(mutation, contract["mutation_success_envelope"], "mutation_success_envelope") manual_asset = service.create_manual_asset("Synthetic Deposit", 2_000_000, "2026-06-30", "deposit", source_note="synthetic contract", db_path=db_path) diff --git a/tests/test_issue_12_regressions.py b/tests/test_issue_12_regressions.py new file mode 100644 index 0000000..336ef15 --- /dev/null +++ b/tests/test_issue_12_regressions.py @@ -0,0 +1,252 @@ +import sqlite3 +import inspect +from concurrent.futures import ThreadPoolExecutor + +from jarca_ledger_mcp import service +from jarca_ledger_mcp.application.account_valuation_service import _public_candidate +from jarca_ledger_mcp.application.imports_service import _record_import_valuation_candidate +from jarca_ledger_mcp.db import init_db + + +def test_empty_conditional_close_is_closed_and_second_close_requires_correction(tmp_path): + path = str(tmp_path / "ledger.sqlite3") + service.create_ledger_account("미확인 계좌", db_path=path) + first = service.close_asset_snapshot( + "2026-06-30", selection_policy="exact", allow_missing_exclusions=True, + approved_by="reviewer", approval_reason="approved empty close", approval_reference="APR-1", db_path=path, + ) + assert first["success"] and first["close_status"] == "conditional" and first["inserted_count"] == 0 + report = service.get_asset_snapshot_report("2026-06-30", db_path=path) + assert report["is_closed_snapshot"] is True and report["snapshot_count"] == 0 + assert report["close_metadata"]["snapshot_run_id"] == first["snapshot_run_id"] + second = service.close_asset_snapshot( + "2026-06-30", selection_policy="exact", allow_missing_exclusions=True, + approved_by="reviewer", approval_reason="again", approval_reference="APR-2", db_path=path, + ) + assert second["success"] is False and second["error_code"] == "validation_error" + + +def test_reconciliation_uses_corrected_investment_leaf_without_transaction_count_change(tmp_path): + path = str(tmp_path / "ledger.sqlite3") + account = service.create_ledger_account("투자", type="investment", last_balance=100, last_synced_at="2026-06-30", db_path=path)["account"]["id"] + original = service.register_account_valuation(account, "2026-06-30", 100, valuation_kind="total_value", verification_status="verified", idempotency_key="v1", db_path=path) + corrected = service.correct_account_valuation(original["valuation"]["id"], 250, correction_reason="정정", idempotency_key="v2", db_path=path) + assert corrected["success"] + report = service.get_balance_reconciliation_report(account_id=account, baseline_at="2026-06-30T00:00:00+09:00", db_path=path) + assert report["items"][0]["external_confirmed_value"] == 250 + assert report["items"][0]["transaction_count"] == 0 + + +def test_reconciliation_prefers_investment_total_value_over_balance(tmp_path): + path = str(tmp_path / "ledger.sqlite3") + account = service.create_ledger_account( + "투자", type="investment", last_balance=100, + last_synced_at="2026-06-30", db_path=path, + )["account"]["id"] + total = service.register_account_valuation( + account, "2026-06-30", 250, valuation_kind="total_value", + verification_status="verified", idempotency_key="total", db_path=path, + ) + service.register_account_valuation( + account, "2026-06-30", 100, valuation_kind="balance", + verification_status="verified", idempotency_key="balance", db_path=path, + ) + item = service.get_balance_reconciliation_report( + account_id=account, baseline_at="2026-06-30T00:00:00+09:00", db_path=path, + )["items"][0] + assert item["external_confirmed_value"] == 250 + assert item["valuation_id"] == total["valuation"]["id"] + assert item["valuation_kind"] == "total_value" + + +def test_historical_account_update_preserves_current_and_adds_one_candidate(tmp_path): + path = str(tmp_path / "ledger.sqlite3") + created = service.create_ledger_account("계좌", last_balance=900, last_synced_at="2026-06-30", db_path=path) + account = created["account"]["id"] + updated = service.update_ledger_account(account, last_balance=700, last_synced_at="2026-06-01", db_path=path) + assert updated["account"]["last_balance"] == 900 and updated["account"]["last_synced_at"] == "2026-06-30" + values = service.list_account_valuations(account, db_path=path)["valuations"] + assert len(values) == 2 and sum(v["basis_date"] == "2026-06-01" for v in values) == 1 + + +def test_schema12_migration_preserves_legacy_rows_indexes_and_is_idempotent(tmp_path): + path = str(tmp_path / "legacy.sqlite3") + with sqlite3.connect(path) as conn: + conn.executescript(""" + CREATE TABLE schema_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); + CREATE TABLE accounts ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, type TEXT NOT NULL DEFAULT 'bank', + institution TEXT NOT NULL DEFAULT '', currency TEXT NOT NULL DEFAULT 'KRW', + active INTEGER NOT NULL DEFAULT 1, memo TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, updated_at TEXT NOT NULL + ); + CREATE TABLE transactions ( + id TEXT PRIMARY KEY, transaction_date TEXT NOT NULL, type TEXT NOT NULL, + amount INTEGER NOT NULL, category TEXT NOT NULL DEFAULT '', + subcategory TEXT NOT NULL DEFAULT '', person TEXT NOT NULL DEFAULT '', + account_id TEXT NOT NULL DEFAULT '', dedupe_key TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, updated_at TEXT NOT NULL + ); + CREATE INDEX legacy_transactions_date ON transactions(transaction_date); + CREATE TABLE asset_snapshots ( + id TEXT PRIMARY KEY, basis_date TEXT NOT NULL, source_type TEXT NOT NULL, + source_id TEXT NOT NULL DEFAULT '', name TEXT NOT NULL, amount INTEGER, + currency TEXT NOT NULL DEFAULT 'KRW', liquidity_bucket TEXT NOT NULL DEFAULT 'other', + valuation_source TEXT NOT NULL DEFAULT '', source_note TEXT NOT NULL DEFAULT '', + memo TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL + ); + INSERT INTO schema_meta(key,value) VALUES('schema_version','11'); + INSERT INTO accounts(id,name,type,currency,created_at,updated_at) VALUES('a','old','bank','KRW','t','t'); + INSERT INTO transactions(id,transaction_date,type,amount,created_at,updated_at) VALUES('t','2026-01-01','expense',10,'t','t'); + INSERT INTO asset_snapshots(id,basis_date,source_type,name,amount,created_at) VALUES('s','2026-01-01','account','old',10,'t'); + """) + init_db(path) + init_db(path) + with sqlite3.connect(path) as conn: + assert conn.execute("SELECT name FROM accounts WHERE id='a'").fetchone() + assert conn.execute("SELECT id FROM transactions WHERE id='t'").fetchone() + assert conn.execute("SELECT name FROM asset_snapshots WHERE id='s'").fetchone() + indexes = {r[1] for r in conn.execute("PRAGMA index_list(transactions)")} + assert {"legacy_transactions_date", "idx_transactions_datetime"} <= indexes + assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "12" + + +def test_snapshot_close_idempotency_fingerprint_and_concurrent_first_close(tmp_path): + path = str(tmp_path / "ledger.sqlite3") + service.create_manual_asset("현금", 100, "2026-06-30", "cash", currency="KRW", db_path=path) + first = service.close_asset_snapshot( + "2026-06-30", source_note="source-a", memo="memo-a", + idempotency_key="close-1", db_path=path, + ) + same = service.close_asset_snapshot( + "2026-06-30", source_note="source-a", memo="memo-a", + idempotency_key="close-1", db_path=path, + ) + changed = service.close_asset_snapshot( + "2026-06-30", source_note="source-a", memo="memo-b", + idempotency_key="close-1", db_path=path, + ) + assert first["success"] and same["success"] and same["reused"] is True + assert changed["success"] is False and changed["error_code"] == "idempotency_conflict" + + other_asset = first["included_items"][0]["source_id"] + service.update_manual_asset(other_asset, memo="changed evidence metadata", db_path=path) + changed_evidence = service.close_asset_snapshot( + "2026-06-30", source_note="source-a", memo="memo-a", + idempotency_key="close-1", db_path=path, + ) + assert changed_evidence["success"] is False + assert changed_evidence["error_code"] == "idempotency_conflict" + + other_path = str(tmp_path / "concurrent.sqlite3") + service.create_manual_asset("현금", 100, "2026-06-30", "cash", currency="KRW", db_path=other_path) + with ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(lambda _: service.close_asset_snapshot("2026-06-30", db_path=other_path), range(2))) + assert sum(bool(result.get("success")) for result in results) == 1 + with sqlite3.connect(other_path) as conn: + assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs WHERE basis_date='2026-06-30'").fetchone()[0] == 1 + + +def test_import_candidate_helper_reuses_deterministic_duplicate_on_same_connection(tmp_path): + path = str(tmp_path / "ledger.sqlite3") + account = service.create_ledger_account("계좌", db_path=path)["account"]["id"] + with sqlite3.connect(path) as raw: + raw.row_factory = sqlite3.Row + first = _record_import_valuation_candidate(raw, account, 100, "2026-06-30", "sha") + second = _record_import_valuation_candidate(raw, account, 100, "2026-06-30", "sha") + count = raw.execute("SELECT count(*) FROM account_valuations WHERE account_id=?", (account,)).fetchone()[0] + assert first is None and second is None and count == 1 + + +def test_valuation_currency_and_loan_kind_are_rejected(tmp_path): + path = str(tmp_path / "ledger.sqlite3") + bank = service.create_ledger_account("은행", currency="KRW", db_path=path)["account"]["id"] + loan = service.create_ledger_account("대출", type="loan", db_path=path)["account"]["id"] + assert service.register_account_valuation(bank, "2026-06-30", 1, currency="USD", db_path=path)["success"] is False + assert service.register_account_valuation(bank, "2026-06-30", 1, valuation_kind="loan", representative_liability_definition="principal_only", db_path=path)["success"] is False + assert service.register_account_valuation(loan, "2026-06-30", 1, valuation_kind="loan", representative_liability_definition="principal_only", db_path=path)["success"] is True + + +def test_loan_representative_is_always_aggregated_as_liability(tmp_path): + path = str(tmp_path / "ledger.sqlite3") + loan = service.create_ledger_account( + "대출", type="loan", last_balance=100, + last_synced_at="2026-06-30", db_path=path, + )["account"]["id"] + service.register_account_valuation( + loan, "2026-06-30", 100, valuation_kind="loan", + representative_liability_definition="principal_only", + verification_status="verified", db_path=path, + ) + report = service.get_asset_snapshot_report("2026-06-30", db_path=path) + assert report["total_assets"] == 0 + assert report["total_liabilities"] == 100 + assert report["net_worth"] == -100 + assert report["included_items"][0]["liquidity_bucket"] == "loan" + + +def test_loan_balance_fallback_without_representative_definition_is_excluded(tmp_path): + path = str(tmp_path / "ledger.sqlite3") + loan = service.create_ledger_account( + "대출", type="loan", last_balance=100, + last_synced_at="2026-06-30", db_path=path, + )["account"]["id"] + service.register_account_valuation( + loan, "2026-06-30", 100, valuation_kind="balance", + verification_status="verified", db_path=path, + ) + report = service.get_asset_snapshot_report("2026-06-30", db_path=path) + assert report["total_liabilities"] == 0 + assert report["net_worth"] == 0 + assert report["excluded_items"][0]["reason"] == "missing_representative_liability_definition" + + +def test_warning_candidate_public_payload_is_minimal(): + class Row(dict): + __getattr__ = dict.__getitem__ + row = Row(id="v", basis_date="2026-06-30", currency="KRW", valuation_kind="balance", verification_status="unverified", memo="secret", source_ref="secret", name="secret") + payload = _public_candidate(row) + assert set(payload) == {"id", "basis_date", "currency", "valuation_kind", "verification_status"} + + +def test_snapshot_report_redacts_unverified_candidate_sensitive_fields(tmp_path): + path = str(tmp_path / "ledger.sqlite3") + account = service.create_ledger_account("계좌", db_path=path)["account"]["id"] + service.register_account_valuation( + account, "2026-06-30", 10, verification_status="unverified", + source_ref="TOPSECRET", memo="PASSWORD=secret", idempotency_key="private", + db_path=path, + ) + report = service.get_asset_snapshot_report("2026-06-30", db_path=path) + encoded = repr(report) + assert "TOPSECRET" not in encoded and "PASSWORD=secret" not in encoded + candidates = report["excluded_items"][0]["unverified_candidates"] + assert set(candidates[0]) == {"id", "basis_date", "currency", "valuation_kind", "verification_status"} + + +def test_snapshot_rejects_bogus_selection_policy_and_manual_usd_is_excluded(tmp_path): + path = str(tmp_path / "ledger.sqlite3") + assert service.preview_asset_snapshot_close("2026-06-30", selection_policy="bogus", db_path=path)["success"] is False + service.create_manual_asset("달러", 100, "2026-06-30", "other", currency="USD", db_path=path) + report = service.get_asset_liquidity_report(db_path=path) + assert report["success"] and report["total_assets"] == 0 + assert any(item.get("reason") == "non_krw_without_fx" for item in report["excluded_items"]) + + +def test_account_write_rolls_back_when_conn_level_candidate_write_fails(tmp_path, monkeypatch): + path = str(tmp_path / "ledger.sqlite3") + import jarca_ledger_mcp.application.accounts_write_services as writes + monkeypatch.setattr(writes, "_record_balance_candidate", lambda *_args: (_ for _ in ()).throw(RuntimeError("injected"))) + try: + service.create_ledger_account("원자성", last_balance=1, last_synced_at="2026-06-30", db_path=path) + except RuntimeError: + pass + assert service.list_ledger_accounts(active_only=False, db_path=path)["count"] == 0 + + +def test_mcp_registry_keeps_category_report_and_correction_signature_is_explicit(): + from jarca_ledger_mcp.adapters.mcp import server + source = inspect.getsource(server) + assert "def get_category_spending_report" in source + signature = inspect.signature(server.correct_account_valuation) + assert not any(param.kind is inspect.Parameter.VAR_KEYWORD for param in signature.parameters.values()) diff --git a/tests/test_ledger_categories.py b/tests/test_ledger_categories.py index 331666b..768b820 100644 --- a/tests/test_ledger_categories.py +++ b/tests/test_ledger_categories.py @@ -6,7 +6,7 @@ from jarca_ledger_mcp import service def test_ledger_category_create_list_delete_reactivate(tmp_path): db_path = str(tmp_path / "ledger.sqlite3") health = service.health_check(db_path) - assert health["schema_version"] == "11" + assert health["schema_version"] == "12" created = service.create_ledger_category("반려동물", "사료", sort_order=250, memo="test", db_path=db_path) assert created["success"] is True diff --git a/tests/test_requirement_docs.py b/tests/test_requirement_docs.py new file mode 100644 index 0000000..f6c1354 --- /dev/null +++ b/tests/test_requirement_docs.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import json +import re +from pathlib import Path + +ROOT = Path(__file__).parents[1] +DOCS = ROOT / "docs" +REQ = DOCS / "requirements" / "req-003_account-valuation-history.md" + + +def _text(path: str) -> str: + return (ROOT / path).read_text(encoding="utf-8") + + +def test_requirement_docs_are_canonical_and_traceable() -> None: + text = REQ.read_text(encoding="utf-8") + _, raw, body = text.split("---", 2) + metadata = json.loads(raw) + assert metadata == { + "schema": "gitea-agent.requirement/v1", + "id": "REQ-003", + "title": "계좌 기준일 평가 이력과 자산 마감 연동", + "revision": "1.0", + "status": "approved", + "depends_on": [], + "applies_to": ["ledger", "dashboard", "mcp"], + "language": "ko", + "issue": "#12", + "implementation_pr": "미정", + } + assert re.search(r"^# REQ-003 — ", body, re.M) + assert "${" not in text + requirements = re.findall(r"^### REQ-003\.(\d+) —", body, re.M) + assert requirements == [f"{n:02d}" for n in range(1, 11)] + acs = re.findall(r"^### REQ-003\.\d+-AC(\d+) —", body, re.M) + assert len(acs) == 10 + assert body.count("**영향 범위**") == 10 + assert body.count("**Cases (정상/경계/실패)**") == 10 + assert body.count("**State Invariants**") == 10 + assert body.count("**판정 기준**") == 10 + assert body.count("**검증**") == 10 + 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", "호환성", + ): + assert marker in text, marker + + +def test_canonical_docs_have_required_sections_and_no_placeholders() -> None: + paths = [ + "docs/data-model.md", + "docs/interface-contract.md", + "docs/project-structure.md", + "docs/runtime-flow.md", + ] + for rel in paths: + text = _text(rel) + assert "${" not in text, rel + data = _text("docs/data-model.md") + for marker in ("## 목적과 범위", "## 정본 엔터티", "## 관계와 불변조건", "## 저장과 마이그레이션", "schema 12", "account-valuations", "asset-snapshot-runs"): + assert marker in data + interface = _text("docs/interface-contract.md") + for marker in ("## 목적과 범위", "## 계약", "## 호환성", "## 예시", "## 검증", "REQ-003", "register_account_valuation", "preview_asset_snapshot_close", "conditional_approval_required"): + assert marker in interface + structure = _text("docs/project-structure.md") + for marker in ("## 목적과 경계", "## 디렉터리 구조", "## 의존 방향", "## 변경 규칙", "## 검증", "account_valuation_service.py", "assets_service.py", "Missing semantic items: 0", "Ambiguous semantic items: 0"): + assert marker in structure + runtime = _text("docs/runtime-flow.md") + for marker in ("## 목적과 범위", "## 흐름", "## 실패와 복구", "### 동시성과 순서", "## 관찰 가능성", "## 검증", "valuation-register", "snapshot-preview-close", "close-04", "prop-03"): + assert marker in runtime + + +def test_documented_paths_and_symbols_exist() -> None: + paths = [ + "jarca_ledger_mcp/application/account_valuation_service.py", + "jarca_ledger_mcp/application/assets_service.py", + "jarca_ledger_mcp/application/accounts_write_services.py", + "jarca_ledger_mcp/application/imports_service.py", + "jarca_ledger_mcp/application/reconciliation_service.py", + "jarca_ledger_mcp/infrastructure/sqlite/migrations.py", + "jarca_ledger_mcp/adapters/mcp/server.py", + "jarca_ledger_mcp/adapters/streamlit/accounts_gateway.py", + "jarca_ledger_mcp/adapters/streamlit/assets_gateway.py", + "dashboard/page_views/accounts.py", + "dashboard/page_views/assets.py", + "tests/test_account_valuations.py", + "tests/test_asset_snapshots.py", + "tests/test_contract_regression.py", + ] + for rel in paths: + assert (ROOT / rel).exists(), rel + service = _text("jarca_ledger_mcp/application/account_valuation_service.py") + for symbol in ("def register_account_valuation(", "def list_account_valuations(", "def select_account_valuation(", "def correct_account_valuation("): + assert symbol in service + assets = _text("jarca_ledger_mcp/application/assets_service.py") + for symbol in ("def preview_asset_snapshot_close(", "def close_asset_snapshot(", "def get_asset_snapshot_report("): + assert symbol in assets + migrations = _text("jarca_ledger_mcp/infrastructure/sqlite/migrations.py") + for marker in ("CREATE TABLE IF NOT EXISTS account_valuations", "CREATE TABLE IF NOT EXISTS asset_snapshot_runs", "SCHEMA_VERSION"): + assert marker in migrations + + +def test_existing_auth_menu_evidence_inventory_is_preserved() -> None: + interface = _text("docs/interface-contract.md") + runtime = _text("docs/runtime-flow.md") + structure = _text("docs/project-structure.md") + for marker in ( + "POST /api/auth/login", "GET /api/auth/cleanup", "POST /api/auth/logout", + "require_dashboard_authentication()", "__Host-jarca_dashboard_auth", + "월정산", "요약", "자산/마감", "거래내역", "확인필요 큐", "미분류", + "분류 규칙", "카테고리", "계좌", "Import", "st.query_params[\"page\"]", + "streamlit_browser_evidence.py", "auth-before-DB", "cookie/cleanup/logout", + ): + assert marker in interface + runtime + structure, marker diff --git a/tests/test_review_decision_workbench.py b/tests/test_review_decision_workbench.py index ac96097..e916161 100644 --- a/tests/test_review_decision_workbench.py +++ b/tests/test_review_decision_workbench.py @@ -16,7 +16,7 @@ def test_review_decision_and_reconciliation_schema_are_additive_idempotent(tmp_p assert status["integrity_check"] == "ok" assert status["table_counts"]["review_decisions"] == 0 assert status["table_counts"]["reconciliation_sessions"] == 0 - assert service.health_check(db_path)["schema_version"] == "11" + assert service.health_check(db_path)["schema_version"] == "12" def test_review_decision_round_trip_payload_sanitizes_sensitive_fields(tmp_path: Path) -> None: diff --git a/tests/test_service.py b/tests/test_service.py index f3e67c3..29e935c 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -10,7 +10,7 @@ def test_health_check_initializes_db(tmp_path): db_path = tmp_path / "ledger.sqlite3" result = service.health_check(str(db_path)) assert result["success"] is True - assert result["schema_version"] == "11" + assert result["schema_version"] == "12" assert db_path.exists()