[verified] Implement account valuation history for issue 12

This commit is contained in:
2026-10-06 08:25:43 +00:00
parent 5813dd4841
commit 8df7c26c84
30 changed files with 1950 additions and 121 deletions
+44
View File
@@ -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)
+18 -5
View File
@@ -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")
+4 -1
View File
@@ -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")
+157
View File
@@ -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`
+127
View File
@@ -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
+48
View File
@@ -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.
@@ -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`
+73
View File
@@ -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
+31 -6
View File
@@ -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."""
@@ -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",
@@ -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",
]
+2
View File
@@ -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,
@@ -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,
)
@@ -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])
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)
+161 -41
View File
@@ -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,
+16 -12
View File
@@ -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:
@@ -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,
)
@@ -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"),
@@ -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,))
@@ -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] = {
+2
View File
@@ -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,
+16
View File
@@ -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"):
+102
View File
@@ -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
+3
View File
@@ -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
+7 -1
View File
@@ -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)
+252
View File
@@ -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())
+1 -1
View File
@@ -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
+117
View File
@@ -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
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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()