[verified] Implement account valuation history for issue 12
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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`
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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] = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user