fix: address account valuation review findings

This commit is contained in:
2026-10-07 00:12:45 +00:00
parent 6a8a27a74e
commit f25b30c750
23 changed files with 1506 additions and 62 deletions
+17 -5
View File
@@ -30,6 +30,12 @@ ACCOUNT_TYPE_STYLES = {
"other": "#57606a",
}
def _account_balance_basis_date(account: dict[str, Any]) -> str:
"""Return only an explicit evidence date, never a sync timestamp."""
return str(account.get("last_balance_basis_date") or "")[:10]
def render_accounts_list_header(items: list[dict[str, Any]]) -> None:
st.markdown(
f'{len(items)}개 계좌',
@@ -113,14 +119,19 @@ def account_edit_dialog(account: dict[str, Any], db_path: str) -> None:
format_func=lambda value: ACCOUNT_TYPE_LABELS[value],
)
balance_columns = st.columns(2)
balance_columns = st.columns(3)
last_balance_raw = balance_columns[0].text_input(
"기준 잔액",
value="" if account.get("last_balance") is None else str(account.get("last_balance")),
help="원 단위 정수",
)
last_synced_at = balance_columns[1].text_input(
"기준 시각",
last_balance_basis_date = balance_columns[1].text_input(
"잔액 기준일",
value=account.get("last_balance_basis_date") or "",
help="실제 증빙 기준일(YYYY-MM-DD). 미상인 경우 비워 둡니다.",
)
last_synced_at = balance_columns[2].text_input(
"동기화 시각",
value=account.get("last_synced_at") or "",
help="예: 2026-08-20 또는 2026-08-20T23:59:00+09:00",
)
@@ -156,6 +167,7 @@ def account_edit_dialog(account: dict[str, Any], db_path: str) -> None:
institution=institution,
type=account_type,
last_balance=last_balance,
last_balance_basis_date=last_balance_basis_date,
last_synced_at=last_synced_at,
statement_status=statement_status,
liquidity_bucket=liquidity_bucket,
@@ -184,7 +196,7 @@ def page_accounts(db_path: str) -> None:
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]
basis = _account_balance_basis_date(selected_account)
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(
@@ -195,7 +207,7 @@ def page_accounts(db_path: str) -> None:
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_date = st.text_input("평가 기준일", value=_account_balance_basis_date(selected_account))
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))
+10 -1
View File
@@ -24,6 +24,7 @@
| `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_balance_basis_date` | TEXT | 선택 | `''` | YYYY-MM-DD or unknown | date of the evidence represented by `last_balance`; empty/unknown is explicit | 아니오 |
| `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 | 아니오 |
@@ -119,6 +120,14 @@
기존 필드와 관계를 보존한다. import는 거래를 기록할 수 있지만 balance valuation candidate는 dated `unverified` row만 추가한다. reconciliation은 calculated balance provenance와 `VALUATION_MANAGED` 상태를 보존하며 investment valuation 때문에 transaction을 생성하지 않는다. `schema_meta.schema_version`은 12다.
### 현재 계좌 잔액 캐시와 기준일
- `last_balance` is the current cache value: it is the latest current balance observed by an import or account write, not a historical valuation and not opening evidence.
- `last_balance_basis_date` is the basis field for the evidence represented by that cache. It is a date when known; legacy or date-less writes store unknown/NULL and must not be inferred from `last_synced_at` or a valuation row.
- `last_synced_at` is the execution/sync timestamp: when the import or account write advanced the cache, not the evidence basis date.
- imports and account writes advance the account cache; they may also append a dated `account_valuations` candidate, but updating the cache does not rewrite historical rows.
- `account_valuations` is the historical authority. A dated valuation remains append-only evidence even when it is older than the current cache; older evidence never rolls back the current cache.
## 관계와 불변조건
### 관계
@@ -146,7 +155,7 @@
- 저장 위치: 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
- 기존 데이터 처리: `migrate_db()` creates missing tables/columns/indexes; this is an additive migration with no guessed migration or deletion. legacy rows with unknown basis remain unknown; no inference is permitted.
- 하위 호환성: 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
+12 -2
View File
@@ -277,7 +277,7 @@ AppTest는 callback, query mapping, synthetic renderer output과 error/loading e
| 필드 | 위치 | 타입 | 필수 여부 | 기본값 | 허용 값·형식 | 의미 | 민감정보 |
|---|---|---|---|---|---|---|---|
| `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 | 아니오 |
| `currency`/`valuation_kind`/`source_type`/`verification_status` | function/MCP | `string` | 선택 | KRW/balance/manual/unverified | canonical enums listed below | 아니오 |
| `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 | 아니오 |
@@ -293,7 +293,7 @@ AppTest는 callback, query mapping, synthetic renderer output과 error/loading e
| 코드 | 분류 | 발생 조건 | 재시도 가능 여부 | 호출자 대응 | 실패 시 상태 변경 |
|---|---|---|---|---|---|
| `validation_error` | request | invalid date/type/currency/loan definition | 수정 후 가능 | payload 수정 | 없음 |
| `validation_error` | request | invalid date/type/currency/loan definition | 수정 후 가능 | payload 수정 | no write; no row or cache mutation |
| `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 | 없음 |
@@ -333,6 +333,16 @@ AppTest는 callback, query mapping, synthetic renderer output과 error/loading e
| `idempotency_conflict` | conflict | close key payload differs | 아니오 | inspect run | 없음 |
| `validation_error` | request | invalid policy or unreasoned correction | 수정 후 가능 | provide reason | no partial rows |
#### Canonical valuation values and validation
The public contract explicitly supports these exact values: source_type: `manual`, `import`, `account_write`, `reconciliation`, `snapshot`; verification_status: `verified`, `unverified`, `rejected`; valuation_kind: `balance`, `cash_balance`, `total_value`, `loan`, `loan_balance`; representative_liability_definition: `principal_only`, `principal_interest_fees`. These are the currently supported enums, not a delegation to source code.
loan accounts require representative_liability_definition for `loan`, `loan_balance`, and `balance` aliases. legacy valuation-kind aliases remain accepted and map to the canonical purpose; alias acceptance does not broaden the canonical enum set. Invalid values, missing loan definitions, and incompatible definitions return `validation_error` and perform no write. A validation failure returns `validation_error` and performs no write.
loan valuations map to liabilities and non-loan valuations map to assets. The output mapping preserves the existing envelopes and signatures: `success` remains the outcome field, failures include `error_code` and `error`, and validation or conflict failures do not partially write.
Account current-cache writes keep `last_balance_basis_date` (optional `YYYY-MM-DD`) separate from `last_synced_at` (execution/sync timestamp). `create_ledger_account` keeps the established positional `db_path` argument and adds `last_balance_basis_date` after it as an optional keyword. `update_ledger_account` accepts the same field through its existing keyword-field boundary. A balance with no explicit basis advances only the current display cache, leaves the basis unknown, and creates no historical valuation candidate; an explicit balance+basis pair may advance the cache and append an unverified candidate. Older explicit evidence is retained as history and does not roll back a newer dated cache.
### 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`.
@@ -0,0 +1,486 @@
{
"baseline_revision": "5813dd484196239ab1fac4ba066c5b4922fb33e6",
"reviewed_head": "6a8a27a74ece9a9cce6f9772259677813d91ddf8",
"items": [
{
"path": "docs/requirements/req-003_account-valuation-history.md",
"baseline_id": "REQ-003.01",
"semantic_key": "additive_schema_migration_and_legacy_preservation",
"meaning": "Schema 11 tables, fields, rows, and transactions remain readable; schema 12 adds valuation and snapshot structures and migration is idempotent.",
"reviewed_ids": [
"REQ-003.01",
"REQ-003.01-AC01"
],
"reviewed_status": "present_and_approved",
"planned_ids": [
"N01"
],
"planned_status": "strengthen_verification_only; semantics_unchanged",
"authorization": "Preserve additive migration, old-row retention, and repeatability; rework may make evidence stricter but may not delete, infer-migrate, or rename legacy data.",
"evidence_paths": [
"jarca_ledger_mcp/infrastructure/sqlite/migrations.py",
"docs/data-model.md",
"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"
],
"verification": "Reviewed head contains schema-12 documentation and named tests; REQ-001/REQ-002 requirement files are unchanged from baseline."
},
{
"path": "docs/requirements/req-003_account-valuation-history.md",
"baseline_id": "REQ-003.02",
"semantic_key": "verified_leaf_exact_as_of_selection",
"meaning": "Selection is scoped by account, currency, and valuation kind; exact selects same-date verified leaf, as_of selects latest prior verified leaf, and future/unverified/rejected/unknown evidence is excluded or warned.",
"reviewed_ids": [
"REQ-003.02",
"REQ-003.02-AC01"
],
"reviewed_status": "present_and_approved",
"planned_ids": [
"C05"
],
"planned_status": "strengthen_authority_and_warning_contract; semantics_unchanged",
"authorization": "Only verified leaves remain authoritative; rework may expose more explicit warnings, exclusions, age, and scope but cannot broaden authority.",
"evidence_paths": [
"jarca_ledger_mcp/application/account_valuation_service.py",
"jarca_ledger_mcp/application/assets_service.py",
"docs/interface-contract.md",
"tests/test_account_valuations.py::test_select_exact_and_as_of_excludes_unverified_and_reports_carry_forward",
"tests/test_issue_12_regressions.py::test_preview_warns_about_unverified_candidate_when_verified_value_is_selected"
],
"verification": "Selection terminology, scope, and exclusion rules are documented and backed by focused tests."
},
{
"path": "docs/requirements/req-003_account-valuation-history.md",
"baseline_id": "REQ-003.03",
"semantic_key": "idempotency_and_append_only_correction",
"meaning": "Equal payloads reuse existing rows or runs; changed payloads conflict; corrections append one reasoned child without mutating the parent; invalid parent scope, cycles, self-parent, duplicate child, and source/key conflicts are no-write failures.",
"reviewed_ids": [
"REQ-003.03",
"REQ-003.03-AC01"
],
"reviewed_status": "present_and_approved",
"planned_ids": [
"C06",
"D03"
],
"planned_status": "strengthen_conflict_and_replay_evidence; semantics_unchanged",
"authorization": "Preserve immutable history, one-child cardinality, request-fingerprint equality, and no-write conflict behavior.",
"evidence_paths": [
"jarca_ledger_mcp/application/account_valuation_service.py",
"jarca_ledger_mcp/application/assets_service.py",
"docs/data-model.md",
"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_review59_account.py::test_direct_correction_requires_matching_existing_parent_and_reason",
"tests/test_issue_12_review59_account.py::test_correction_rejects_self_parent_and_cycles_without_write",
"tests/test_issue_12_review59_reports.py::test_idempotent_snapshot_close_replay_returns_original_immutable_response"
],
"verification": "Current source and tests cover valuation correction, evidence identity, close replay, and no-write conflicts."
},
{
"path": "docs/requirements/req-003_account-valuation-history.md",
"baseline_id": "REQ-003.04",
"semantic_key": "integer_amounts_nullable_optional_metrics_and_representative_liability",
"meaning": "Amounts and optional monetary fields use exact integers; NULL means not provided and zero means explicit zero; investment metrics are not double-counted; only the defined representative loan amount enters liabilities.",
"reviewed_ids": [
"REQ-003.04",
"REQ-003.04-AC01"
],
"reviewed_status": "present_and_approved",
"planned_ids": [
"N02"
],
"planned_status": "strengthen_field_mapping_and_formula_assertions; semantics_unchanged",
"authorization": "Preserve physical principal fields, NULL/zero distinction, loan definition requirement, and no double sum.",
"evidence_paths": [
"docs/data-model.md",
"docs/interface-contract.md",
"jarca_ledger_mcp/application/account_valuation_service.py",
"jarca_ledger_mcp/application/assets_service.py",
"tests/test_issue_12_review59_account.py::test_valuation_round_trip_preserves_null_zero_and_physical_amount_mapping",
"tests/test_issue_12_regressions.py::test_loan_representative_is_always_aggregated_as_liability",
"tests/test_issue_12_regressions.py::test_loan_balance_fallback_without_representative_definition_is_excluded"
],
"verification": "The reviewed contract explicitly distinguishes NULL, zero, principal meanings, and representative liability."
},
{
"path": "docs/requirements/req-003_account-valuation-history.md",
"baseline_id": "REQ-003.05",
"semantic_key": "current_display_and_historical_authority_separation",
"meaning": "Historical dated evidence does not roll back newer current balance display; account/import writes create only dated unverified candidates when real evidence exists; name-only updates create none.",
"reviewed_ids": [
"REQ-003.05",
"REQ-003.05-AC01"
],
"reviewed_status": "present_and_approved",
"planned_ids": [
"N01",
"C05"
],
"planned_status": "strengthen_current_cache_and_candidate_provenance; semantics_unchanged",
"authorization": "Preserve separate current and historical decisions, unverified candidate status, and no inferred evidence.",
"evidence_paths": [
"jarca_ledger_mcp/application/accounts_write_services.py",
"jarca_ledger_mcp/application/imports_service.py",
"docs/runtime-flow.md",
"tests/test_account_valuations.py::test_account_balance_write_records_unverified_candidate_only_with_real_basis_date",
"tests/test_account_valuations.py::test_account_name_only_update_does_not_create_candidate",
"tests/test_issue_12_regressions.py::test_historical_account_update_preserves_current_and_adds_one_candidate",
"tests/test_issue_12_review59_account.py::test_import_historical_kb_does_not_replace_newer_current_balance"
],
"verification": "The reviewed head has explicit late-history/current-cache regression tests and runtime decision guards."
},
{
"path": "docs/requirements/req-003_account-valuation-history.md",
"baseline_id": "REQ-003.06",
"semantic_key": "read_only_preview_and_explicit_conditional_close",
"meaning": "Preview performs no database write and reports complete, incomplete, or conditional state; unsafe evidence requires explicit approval metadata; close persists immutable run evidence atomically.",
"reviewed_ids": [
"REQ-003.06",
"REQ-003.06-AC01"
],
"reviewed_status": "present_and_approved",
"planned_ids": [
"D03",
"D04"
],
"planned_status": "strengthen_preview_fingerprint_and_approval_boundary; semantics_unchanged",
"authorization": "Preserve no-write preview, explicit approval, rejection of unsafe evidence, and atomic close.",
"evidence_paths": [
"jarca_ledger_mcp/application/assets_service.py",
"jarca_ledger_mcp/adapters/streamlit/assets_gateway.py",
"docs/interface-contract.md",
"tests/test_issue_12_regressions.py::test_preview_marks_approval_eligible_missing_valuation_as_incomplete_draft",
"tests/test_issue_12_review59_reports.py::test_dated_manual_asset_is_verified_and_preview_needs_approval",
"tests/test_issue_12_review59_reports.py::test_approved_carried_close_is_conditional_and_persists_provenance",
"tests/test_issue_12_review59_reports.py::test_close_rejects_authority_drift_since_preview"
],
"verification": "Preview/close boundaries, approval fields, drift rejection, and no-partial-write behavior are documented and tested."
},
{
"path": "docs/requirements/req-003_account-valuation-history.md",
"baseline_id": "REQ-003.07",
"semantic_key": "immutable_close_correction_and_net_worth_formula",
"meaning": "Same-basis re-close requires a reason and creates a new run and rows; old runs remain; latest run is report authority; net worth is asset buckets minus representative loan and excludes missing valuations.",
"reviewed_ids": [
"REQ-003.07",
"REQ-003.07-AC01"
],
"reviewed_status": "present_and_approved",
"planned_ids": [
"C06",
"D03"
],
"planned_status": "strengthen_latest-run_and_formula_provenance; semantics_unchanged",
"authorization": "Preserve append-only runs, correction linkage, latest-run authority, and exact formula.",
"evidence_paths": [
"jarca_ledger_mcp/application/assets_service.py",
"docs/data-model.md",
"tests/test_issue_12_regressions.py::test_empty_conditional_close_is_closed_and_second_close_requires_correction",
"tests/test_issue_12_regressions.py::test_legacy_snapshot_requires_explicit_correction_before_new_close",
"tests/test_issue_12_review59_reports.py::test_asset_close_and_report_expose_provenance_aliases"
],
"verification": "Run correction fields, latest authority, and formula semantics are present in canonical docs and tests."
},
{
"path": "docs/requirements/req-003_account-valuation-history.md",
"baseline_id": "REQ-003.08",
"semantic_key": "reconciliation_and_monthly_read_model_propagation",
"meaning": "Verified investment uses VALUATION_MANAGED without fake transactions; incomplete evidence reports unavailable/incomplete calculation; monthly and downstream reports propagate actual valuation date, age, carry, verification, run, approval, correction, warnings, and exclusions.",
"reviewed_ids": [
"REQ-003.08",
"REQ-003.08-AC01"
],
"reviewed_status": "present_and_approved",
"planned_ids": [
"C05",
"D04"
],
"planned_status": "strengthen_cross_read_model_alias_and_completeness_contract; semantics_unchanged",
"authorization": "Preserve read-only propagation, explicit incompleteness, no inferred balance, and monthly_source_snapshot_id as a read-model alias only.",
"evidence_paths": [
"jarca_ledger_mcp/application/reconciliation_service.py",
"jarca_ledger_mcp/application/monthly_settlement_service.py",
"jarca_ledger_mcp/adapters/streamlit/overview_gateway.py",
"docs/data-model.md",
"tests/test_issue_12_regressions.py::test_reconciliation_uses_corrected_investment_leaf_without_transaction_count_change",
"tests/test_issue_12_review59_reports.py::test_investment_without_verified_valuation_and_without_opening_balance_is_incomplete",
"tests/test_issue_12_review59_reports.py::test_timeseries_and_monthly_expose_canonical_asset_provenance",
"tests/test_issue_12_review59_reports.py::test_monthly_settlement_propagates_snapshot_provenance_id_and_actual_valuation_date"
],
"verification": "Named source owners and report propagation tests cover reconciliation and monthly semantics."
},
{
"path": "docs/requirements/req-003_account-valuation-history.md",
"baseline_id": "REQ-003.09",
"semantic_key": "shared_application_adapters_and_auth_boundary",
"meaning": "MCP and Dashboard delegate to shared services, retain success/error envelopes, require Dashboard authentication before DB access, and keep render/read paths non-mutating.",
"reviewed_ids": [
"REQ-003.09",
"REQ-003.09-AC01"
],
"reviewed_status": "present_and_approved",
"planned_ids": [
"N01",
"D04"
],
"planned_status": "strengthen_adapter_and_auth-order assertions; semantics_unchanged",
"authorization": "Preserve shared policy ownership, thin adapters, auth-before-DB, and existing MCP authentication scope.",
"evidence_paths": [
"jarca_ledger_mcp/adapters/mcp/server.py",
"jarca_ledger_mcp/adapters/streamlit/accounts_gateway.py",
"jarca_ledger_mcp/adapters/streamlit/overview_gateway.py",
"dashboard/app.py",
"tests/test_contract_regression.py",
"tests/test_dashboard_app.py",
"tests/test_dashboard_menu_transition.py"
],
"verification": "The reviewed head retains named adapter paths and existing REQ-001/REQ-002 gates; requirement files are unchanged from baseline."
},
{
"path": "docs/requirements/req-003_account-valuation-history.md",
"baseline_id": "REQ-003.10",
"semantic_key": "synthetic_privacy_and_excluded_scope",
"meaning": "Docs, tests, fixtures, and evidence contain only synthetic/redacted data; credentials, tokens, cookies, real financial data, external prices, FX, automatic approval, inferred transactions, and redesign remain out of scope.",
"reviewed_ids": [
"REQ-003.10",
"REQ-003.10-AC01"
],
"reviewed_status": "present_and_approved",
"planned_ids": [
"N02",
"D04"
],
"planned_status": "strengthen_recursive_redaction_and_static_inventory; semantics_unchanged",
"authorization": "Preserve privacy boundary and excluded scope; rework may add redaction checks but may not introduce real data or new financial inference.",
"evidence_paths": [
"docs/project-structure.md",
"docs/interface-contract.md",
"tests/test_requirement_docs.py",
"tests/test_issue_12_review59_reports.py::test_close_metadata_is_allowlisted_recursively",
"tests/test_issue_12_review59_reports.py::test_public_included_items_redact_source_note_and_memo_recursively"
],
"verification": "Canonical docs state synthetic-only and recursive redaction; focused tests validate metadata and evidence redaction."
},
{
"path": "docs/requirements/req-003_account-valuation-history.md",
"baseline_id": "REQ-003-INV01",
"semantic_key": "authority_and_safety_invariants",
"meaning": "Only verified leaves are authority; future/unverified/rejected/unknown evidence is not summed; preview/render do not write; close is transactional; current and historical authority remain separate.",
"reviewed_ids": [
"REQ-003-INV01"
],
"reviewed_status": "present_and_approved",
"planned_ids": [
"C05",
"D03",
"D04"
],
"planned_status": "strengthen explicit invariant checks; semantics_unchanged",
"authorization": "All listed invariants are preservation constraints, not permission to alter business meaning.",
"evidence_paths": [
"docs/requirements/req-003_account-valuation-history.md",
"docs/data-model.md",
"docs/runtime-flow.md",
"tests/test_issue_12_regressions.py",
"tests/test_issue_12_review59_reports.py"
],
"verification": "The requirement, data model, and runtime flow repeat the same authority and no-write invariants."
},
{
"path": "docs/requirements/req-003_account-valuation-history.md",
"baseline_id": "REQ-003-INV02",
"semantic_key": "database_read_lifecycle_boundary",
"meaning": "Writer/bootstrap explicitly calls init_db; read_db uses SQLite mode=ro; missing DB returns db_not_initialized without mkdir/migrate/commit; initialized read failure returns db_error.",
"reviewed_ids": [
"REQ-003-INV02"
],
"reviewed_status": "present_and_approved",
"planned_ids": [
"N01"
],
"planned_status": "strengthen_no_create_and_error_classification; semantics_unchanged",
"authorization": "Preserve explicit initialization and stable read-only failure envelopes.",
"evidence_paths": [
"jarca_ledger_mcp/infrastructure/sqlite/connection.py",
"jarca_ledger_mcp/infrastructure/sqlite/migrations.py",
"docs/interface-contract.md",
"docs/runtime-flow.md",
"tests/test_issue_12_review59_readonly.py::test_read_queries_missing_nested_db_return_stable_error_without_creating_path",
"tests/test_issue_12_review59_readonly.py::test_read_schema_error_is_not_misreported_as_uninitialized"
],
"verification": "C13 database-boundary documentation and focused read-only tests exist at reviewed head."
},
{
"path": "docs/requirements/req-001_dashboard-administrator-authentication.md",
"baseline_id": "REQ-001.01-REQ-001.11",
"semantic_key": "dashboard_auth_gate_cookie_cleanup_logout_and_rotation",
"meaning": "REQ-001 authentication remains fail-closed before DB/page dispatch, with server-only credential binding, hardened persistent cookie, absolute TTL, invalid-cookie cleanup, explicit logout, rotation invalidation, and no DB-backed session.",
"reviewed_ids": [
"REQ-001.01",
"REQ-001.05",
"REQ-001.06",
"REQ-001.07",
"REQ-001.08",
"REQ-001.09",
"REQ-001.10"
],
"reviewed_status": "unchanged_from_baseline",
"planned_ids": [
"N01"
],
"planned_status": "must_remain_unchanged",
"authorization": "Issue #12 rework is additive and cannot modify authentication semantics or claim MCP authentication.",
"evidence_paths": [
"docs/requirements/req-001_dashboard-administrator-authentication.md",
"docs/interface-contract.md",
"dashboard/authentication.py",
"dashboard/auth_server.py",
"tests/test_dashboard_authentication.py",
"tests/test_dashboard_persistent_authentication.py",
"tests/test_dashboard_auth_server.py",
"tests/test_dashboard_authentication_app.py"
],
"verification": "Git diff confirms REQ-001 file has no changes between baseline and reviewed head."
},
{
"path": "docs/requirements/req-002_dashboard-menu-transition.md",
"baseline_id": "REQ-002.01-REQ-002.09",
"semantic_key": "dashboard_menu_identity_query_and_stable_shell",
"meaning": "The exact ten menu labels/order, selected state, page-only query update with sibling preservation, stable shell/background, local loading/error/retry, latest serialized selection, auth gate, finance pages, and clear_cache contract remain unchanged.",
"reviewed_ids": [
"REQ-002.01",
"REQ-002.02",
"REQ-002.03",
"REQ-002.04",
"REQ-002.05",
"REQ-002.06",
"REQ-002.07",
"REQ-002.08",
"REQ-002.09"
],
"reviewed_status": "unchanged_from_baseline",
"planned_ids": [
"N01"
],
"planned_status": "must_remain_unchanged",
"authorization": "Issue #12 rework may add valuation content behind existing pages but may not alter menu identity, query semantics, shell continuity, or browser evidence requirements.",
"evidence_paths": [
"docs/requirements/req-002_dashboard-menu-transition.md",
"docs/interface-contract.md",
"dashboard/app.py",
"dashboard/common.py",
"tests/test_dashboard_menu_transition.py",
"tests/test_dashboard_app.py",
"tests/test_dashboard_authentication_app.py",
"docs/evidence/issue-9-dashboard-menu-transition/"
],
"verification": "Git diff confirms REQ-002 file has no changes between baseline and reviewed head."
},
{
"path": "docs/interface-contract.md",
"baseline_id": "COMMON-IFACE-01",
"semantic_key": "success_error_envelope_and_public_service_signatures",
"meaning": "Existing success/error envelopes remain; valuation, snapshot, MCP, Dashboard gateway, and service signatures are additive and re-exported through the established service boundary.",
"reviewed_ids": [
"COMMON-IFACE-01"
],
"reviewed_status": "preserved_with_additive_REQ003_contract",
"planned_ids": [
"D04"
],
"planned_status": "strengthen_exact_output_and_replay_contract; semantics_unchanged",
"authorization": "No existing response key may be renamed or removed.",
"evidence_paths": [
"docs/interface-contract.md",
"jarca_ledger_mcp/service.py",
"jarca_ledger_mcp/adapters/mcp/server.py",
"tests/test_contract_regression.py"
],
"verification": "Reviewed contract explicitly lists signatures, envelopes, top-level aliases, nested provenance, and replay rules."
},
{
"path": "docs/data-model.md",
"baseline_id": "COMMON-DATA-01",
"semantic_key": "legacy_entities_and_storage_ownership",
"meaning": "Existing ledger entities and relationships remain; valuation and snapshot history are append-only; monthly alias is not a persistence field; application services own policy and SQLite owns storage.",
"reviewed_ids": [
"COMMON-DATA-01"
],
"reviewed_status": "preserved_with_additive_REQ003_model",
"planned_ids": [
"N02",
"D03"
],
"planned_status": "strengthen_physical_field_and_ownership_checks; semantics_unchanged",
"authorization": "No guessed migration, cascade deletion, fake transaction, or alternate policy boundary is authorized.",
"evidence_paths": [
"docs/data-model.md",
"jarca_ledger_mcp/infrastructure/sqlite/schema.py",
"jarca_ledger_mcp/application/assets_service.py",
"jarca_ledger_mcp/application/reconciliation_service.py"
],
"verification": "Data model names legacy preservation, append-only relationships, physical fields, and canonical owners."
},
{
"path": "docs/runtime-flow.md",
"baseline_id": "COMMON-RUNTIME-01",
"semantic_key": "auth_before_db_and_explicit_write_boundaries",
"meaning": "Dashboard auth precedes DB access; read/preview paths are read-only; first-use writer initialization is explicit; approval precedes atomic close; downstream reports are reads.",
"reviewed_ids": [
"COMMON-RUNTIME-01"
],
"reviewed_status": "preserved_with_additive_REQ003_flow",
"planned_ids": [
"N01",
"D03",
"D04"
],
"planned_status": "strengthen_transition_guards_and_drift_revalidation; semantics_unchanged",
"authorization": "Preserve ordering, transaction boundaries, stable missing-DB behavior, and no implicit writes.",
"evidence_paths": [
"docs/runtime-flow.md",
"dashboard/app.py",
"jarca_ledger_mcp/infrastructure/sqlite/connection.py",
"jarca_ledger_mcp/application/assets_service.py",
"tests/test_issue_12_review59_readonly.py"
],
"verification": "Runtime flow explicitly records auth-before-DB, read-only database lifecycle, preview drift revalidation, and atomic close."
},
{
"path": "docs/project-structure.md",
"baseline_id": "COMMON-STRUCTURE-01",
"semantic_key": "canonical_module_ownership_and_regression_map",
"meaning": "Valuation/snapshot policy remains in application services; adapters remain thin; Dashboard pages do not own SQL or policy; named tests and documentation validators are required evidence.",
"reviewed_ids": [
"COMMON-STRUCTURE-01"
],
"reviewed_status": "preserved_with_REQ003_ownership_map",
"planned_ids": [
"N01",
"C06"
],
"planned_status": "strengthen ownership and named-test traceability; semantics_unchanged",
"authorization": "Rework must preserve canonical owners and may not introduce duplicate business logic in adapters.",
"evidence_paths": [
"docs/project-structure.md",
"jarca_ledger_mcp/application/account_valuation_service.py",
"jarca_ledger_mcp/application/assets_service.py",
"jarca_ledger_mcp/application/monthly_settlement_service.py",
"jarca_ledger_mcp/infrastructure/sqlite/connection.py",
"tests/test_requirement_docs.py"
],
"verification": "Reviewed structure document contains explicit ownership, dependency, preservation, and named regression-test sections."
}
],
"missing_count": 0,
"ambiguous_count": 0,
"notes": [
"REQ-001 and REQ-002 requirement files are byte-for-byte unchanged from baseline; common docs were extended additively for REQ-003.",
"Source contract digest supplied by the caller is 6d86b591b022fcc722611bfec6d24e54a64216ab6f7c4098f357e25420218818.",
"The checked-out repository does not define a canonical glossary mapping finding labels N01, C05, C06, N02, D03, and D04 to independent issue descriptions; planned mappings above are therefore preservation-oriented semantic associations inferred from the reviewed contract text.",
"No files were created or modified; no commit, push, fetch, or Gitea call was performed.",
"Main-agent resolution: Review #60 body is the authoritative glossary for N01/C05/C06/N02/D03/D04; each label and prose was read from admission-snapshot.json, so the delegate-reported label-mapping ambiguity is resolved without changing contract meaning."
],
"schema": "gitea-agent.contract-preservation-inventory/v1"
}
@@ -0,0 +1,181 @@
{
"environment": [
"os",
"kernel",
"architecture",
"python",
"uv"
],
"command_env_allowlist": [
"PATH",
"HOME",
"TMPDIR",
"LANG",
"VIRTUAL_ENV",
"UV_CACHE_DIR"
],
"secret_env_names": [],
"forbidden_regexes": [],
"hash_paths": [
"dashboard/page_views/accounts.py",
"docs/data-model.md",
"docs/interface-contract.md",
"jarca_ledger_mcp/application/account_valuation_service.py",
"jarca_ledger_mcp/application/accounts_write_services.py",
"jarca_ledger_mcp/application/assets_service.py",
"jarca_ledger_mcp/application/imports_service.py",
"jarca_ledger_mcp/infrastructure/sqlite/schema.py",
"scripts/ci_fixture_db.py",
"tests/test_account_valuations.py",
"tests/test_issue_12_regressions.py",
"tests/test_issue_12_review59_account.py",
"tests/test_account_valuation_edge_contracts.py",
"tests/test_asset_snapshot_idempotency.py",
"tests/test_import_current_balance_authority.py",
"tests/test_valuation_contract_docs.py",
"jarca_ledger_mcp/adapters/mcp/server.py",
"tests/test_account_basis_public_consumers.py"
],
"commands": [
{
"id": "review60-regressions",
"purpose": "Run Review 60 and all independent-review named RED-to-GREEN regressions",
"argv": [
"uv",
"run",
"--frozen",
"--extra",
"dev",
"--extra",
"dashboard",
"pytest",
"-q",
"tests/test_account_valuation_edge_contracts.py",
"tests/test_import_current_balance_authority.py",
"tests/test_asset_snapshot_idempotency.py",
"tests/test_valuation_contract_docs.py",
"tests/test_account_basis_public_consumers.py",
"tests/test_issue_12_regressions.py"
],
"expected_exit_code": 0,
"max_output_bytes": 65536,
"claims": [
"review60-and-independent-regressions-green"
]
},
{
"id": "adversarial-compatibility",
"purpose": "Run correction, replay, current authority, read-only, provenance, contract and documentation compatibility coverage",
"argv": [
"uv",
"run",
"--frozen",
"--extra",
"dev",
"--extra",
"dashboard",
"pytest",
"-q",
"tests/test_account_valuation_edge_contracts.py",
"tests/test_import_current_balance_authority.py",
"tests/test_asset_snapshot_idempotency.py",
"tests/test_valuation_contract_docs.py",
"tests/test_issue_12_regressions.py",
"tests/test_account_valuations.py",
"tests/test_asset_snapshots.py",
"tests/test_issue_12_review59_account.py",
"tests/test_issue_12_review59_reports.py",
"tests/test_issue_12_review59_readonly.py",
"tests/test_contract_regression.py",
"tests/test_requirement_docs.py"
],
"expected_exit_code": 0,
"max_output_bytes": 65536,
"claims": [
"adversarial-compatibility-green"
]
},
{
"id": "full-suite",
"purpose": "Run the repository full test suite with declared optional dependencies",
"argv": [
"uv",
"run",
"--frozen",
"--extra",
"dev",
"--extra",
"dashboard",
"pytest",
"-q",
"-rs"
],
"expected_exit_code": 0,
"max_output_bytes": 65536,
"claims": [
"full-suite-green"
]
},
{
"id": "ruff",
"purpose": "Run repository lint gate",
"argv": [
"uv",
"run",
"--frozen",
"--extra",
"dev",
"--extra",
"dashboard",
"ruff",
"check",
"."
],
"expected_exit_code": 0,
"max_output_bytes": 65536,
"claims": [
"ruff-green"
]
},
{
"id": "compileall",
"purpose": "Compile Python sources, dashboard, tests and scripts",
"argv": [
"python3",
"-m",
"compileall",
"-q",
"jarca_ledger_mcp",
"dashboard",
"tests",
"scripts"
],
"expected_exit_code": 0,
"max_output_bytes": 65536,
"claims": [
"compileall-green"
]
},
{
"id": "commit-diff-check",
"purpose": "Validate committed rework diff whitespace",
"argv": [
"git",
"diff",
"--check",
"HEAD^",
"HEAD"
],
"expected_exit_code": 0,
"max_output_bytes": 65536,
"claims": [
"diff-check-green"
]
}
],
"limitations": [
"Four external PDF fixture tests are expected to skip when private sample files/passwords are unavailable.",
"No actual financial data or production credential is used; tests use synthetic fixtures.",
"Review 60 does not require a new real-browser observation; existing synthetic Streamlit/AppTest regressions remain in the full suite."
]
}
@@ -0,0 +1,16 @@
{
"specification_gate": {
"passed": true,
"findings": [],
"summary": "Exact-current-diff specification gate passed. Review60 semantics N01, C05, C06, N02, D03, D04; prior defects; documentation, adapters, dashboard consumers, fixtures, and named regressions were rechecked. Full pytest suite, Ruff, compileall, and git diff --check passed."
},
"code_security_gate": {
"passed": true,
"security_concerns": [],
"logic_errors": [],
"suggestions": [],
"summary": "Final current-diff code/security gate passed. Full pytest suite passed with four fixture-dependent skips and one existing Starlette deprecation warning; Ruff, compileall, diff-check, migration/idempotency/consumer tests, and added-line security scan passed."
},
"reviewed_parent_sha": "6a8a27a74ece9a9cce6f9772259677813d91ddf8",
"delegation_id": "deleg_49abf596"
}
@@ -0,0 +1,148 @@
{
"schema": "gitea-agent.finding-matrix/v1",
"review_id": 60,
"review_author": "gitea-spec-agent",
"review_state": "REQUEST_CHANGES",
"reviewed_head": "6a8a27a74ece9a9cce6f9772259677813d91ddf8",
"contract_sha256": "6d86b591b022fcc722611bfec6d24e54a64216ab6f7c4098f357e25420218818",
"findings": [
{
"finding": "60/N01-loan-balance-definition",
"reviewed_head": "6a8a27a74ece9a9cce6f9772259677813d91ddf8",
"contract": "Issue #12 확정 결정 8; REQ-003.04/05",
"classification": "valid_fix",
"implementation_paths": [
"jarca_ledger_mcp/application/account_valuation_service.py"
],
"test_paths": [
"tests/test_account_valuation_edge_contracts.py"
],
"verification": "named RED→GREEN test plus consolidated adversarial suite",
"result": "pending",
"reason": "Loan account balance alias bypasses mandatory representative liability definition."
},
{
"finding": "60/C05-current-import",
"reviewed_head": "6a8a27a74ece9a9cce6f9772259677813d91ddf8",
"contract": "Issue #12 현재값과 과거값; REQ-003.05",
"classification": "valid_fix",
"implementation_paths": [
"jarca_ledger_mcp/application/imports_service.py",
"jarca_ledger_mcp/application/accounts_write_services.py",
"jarca_ledger_mcp/infrastructure/sqlite/schema.py",
"jarca_ledger_mcp/infrastructure/sqlite/migrations.py"
],
"test_paths": [
"tests/test_import_current_balance_authority.py"
],
"verification": "named RED→GREEN sequential late-import test plus migration/full suite",
"result": "pending",
"reason": "Import compares actual evidence date against synchronization timestamp and loses current-cache basis authority."
},
{
"finding": "60/C06-idempotency",
"reviewed_head": "6a8a27a74ece9a9cce6f9772259677813d91ddf8",
"contract": "Issue #12 중복 및 정정; REQ-003.03",
"classification": "valid_fix",
"implementation_paths": [
"jarca_ledger_mcp/application/account_valuation_service.py"
],
"test_paths": [
"tests/test_account_valuation_edge_contracts.py"
],
"verification": "named RED→GREEN same-evidence correction replay and changed-payload conflict tests",
"result": "pending",
"reason": "A same source_ref/parent/effective payload correction retry without explicit key conflicts instead of reusing the child."
},
{
"finding": "60/N02-close-replay-run-binding",
"reviewed_head": "6a8a27a74ece9a9cce6f9772259677813d91ddf8",
"contract": "Issue #12 마감 불변성; REQ-003.03/07",
"classification": "valid_fix",
"implementation_paths": [
"jarca_ledger_mcp/application/assets_service.py"
],
"test_paths": [
"tests/test_asset_snapshot_idempotency.py"
],
"verification": "named RED→GREEN old-key replay-after-correction exact response binding test",
"result": "pending",
"reason": "Replay identifies the old run but loads latest-run rows and metadata."
},
{
"finding": "60/D03-data-model",
"reviewed_head": "6a8a27a74ece9a9cce6f9772259677813d91ddf8",
"contract": "Issue #12 데이터 모델 산출물; REQ-003.05",
"classification": "evidence_gap",
"implementation_paths": [
"docs/data-model.md"
],
"test_paths": [
"tests/test_valuation_contract_docs.py"
],
"verification": "named RED→GREEN canonical data-model contract assertion",
"result": "pending",
"reason": "Data model does not fix actual cache basis versus sync timestamp authority and no-rollback owner."
},
{
"finding": "60/D04-interface-contract",
"reviewed_head": "6a8a27a74ece9a9cce6f9772259677813d91ddf8",
"contract": "Issue #12 인터페이스 계약 산출물; REQ-003.04/09",
"classification": "evidence_gap",
"implementation_paths": [
"docs/interface-contract.md"
],
"test_paths": [
"tests/test_valuation_contract_docs.py"
],
"verification": "named RED→GREEN exact enum/conditional/output mapping assertion",
"result": "pending",
"reason": "Public interface delegates allowed values and loan definition/output mapping to source."
},
{
"finding": "independent-spec/C05-account-write-no-inference",
"reviewed_head": "6a8a27a74ece9a9cce6f9772259677813d91ddf8",
"contract": "Issue #12 current/history separation and no inferred basis; REQ-003.05",
"classification": "valid_fix",
"implementation_paths": [
"jarca_ledger_mcp/application/accounts_write_services.py"
],
"test_paths": [
"tests/test_import_current_balance_authority.py"
],
"verification": "named RED→GREEN explicit-basis and no-candidate account-write regression",
"result": "pending",
"reason": "Account writes inferred evidence basis and historical candidate from sync timestamp."
},
{
"finding": "independent-code/legacy-unknown-basis-reporting",
"reviewed_head": "6a8a27a74ece9a9cce6f9772259677813d91ddf8",
"contract": "Issue #12 unknown-basis non-retroactivity; REQ-003.05",
"classification": "valid_fix",
"implementation_paths": [
"jarca_ledger_mcp/application/assets_service.py"
],
"test_paths": [
"tests/test_import_current_balance_authority.py"
],
"verification": "named RED→GREEN legacy unknown-basis report regression",
"result": "pending",
"reason": "Live report exposed last_synced_at as valuation_date despite unknown evidence basis."
},
{
"finding": "independent-code/zero-row-run-replay",
"reviewed_head": "6a8a27a74ece9a9cce6f9772259677813d91ddf8",
"contract": "Issue #12 immutable close evidence; REQ-003.03/07",
"classification": "valid_fix",
"implementation_paths": [
"jarca_ledger_mcp/application/assets_service.py"
],
"test_paths": [
"tests/test_asset_snapshot_idempotency.py"
],
"verification": "named RED→GREEN empty-run replay after nonempty correction exact equality",
"result": "pending",
"reason": "Empty persisted applied_evidence triggered fallback to latest correction rows."
}
]
}
@@ -0,0 +1,82 @@
{
"schema": "gitea-agent.red-regression-evidence/v1",
"reviewed_head": "6a8a27a74ece9a9cce6f9772259677813d91ddf8",
"environment": {
"runner": "uv run --frozen --extra dev --extra dashboard pytest",
"fixture_policy": "synthetic only"
},
"results": [
{
"finding": "60/N01-loan-balance-definition",
"test": "tests/test_account_valuation_edge_contracts.py::test_loan_balance_requires_representative_liability_definition",
"exit_code": 1,
"failure": "missing_definition success was True; expected validation_error and no persisted valuation"
},
{
"finding": "60/C06-idempotency",
"test": "tests/test_account_valuation_edge_contracts.py::test_correction_replays_by_evidence_and_conflicts_on_changed_payload",
"exit_code": 1,
"failure": "identical correction replay success was False; expected reused=True with the original child"
},
{
"finding": "60/C05-current-import",
"test": "tests/test_import_current_balance_authority.py::test_late_import_current_balance_is_authoritative_by_evidence_basis_date",
"exit_code": 1,
"failure": "current last_balance was 700 after newer-basis late import; expected 900"
},
{
"finding": "60/N02-close-replay-run-binding",
"test": "tests/test_asset_snapshot_idempotency.py::test_exact_run_replay_after_correction_reconstructs_original_persisted_run",
"exit_code": 1,
"failure": "old-key replay returned correction-run row IDs, approval and provenance instead of exact original response"
},
{
"finding": "60/D03-data-model",
"test": "tests/test_valuation_contract_docs.py::test_data_model_documents_cache_authority_and_legacy_basis_migration",
"exit_code": 1,
"failure": "canonical last_balance current-cache contract marker was absent"
},
{
"finding": "60/D04-interface-contract",
"test": "tests/test_valuation_contract_docs.py::test_interface_contract_documents_public_valuation_enums_validation_and_mapping",
"exit_code": 1,
"failure": "interface contract still delegated allowed values to 'actual enums in source'"
},
{
"finding": "independent-spec/C05-account-write-no-inference",
"test": "tests/test_import_current_balance_authority.py::test_account_write_sync_timestamp_does_not_infer_balance_basis_or_candidate",
"exit_code": 1,
"failure": "account creation inferred last_balance_basis_date=2026-07-01 from last_synced_at; expected unknown basis and no candidate"
},
{
"finding": "independent-code/legacy-unknown-basis-reporting",
"test": "tests/test_import_current_balance_authority.py::test_unknown_legacy_balance_basis_is_not_reported_as_valuation_date",
"exit_code": 1,
"failure": "excluded valuation_date exposed last_synced_at timestamp; expected empty unknown basis"
},
{
"finding": "independent-code/zero-row-run-replay",
"test": "tests/test_asset_snapshot_idempotency.py::test_empty_run_replay_after_nonempty_correction_preserves_original_empty_payload",
"exit_code": 1,
"failure": "old empty run replay fell back to later run items and returned total_assets/net_worth=100 instead of exact original empty response"
},
{
"finding": "independent-code/historical-explicit-basis-no-rollback",
"test": "tests/test_issue_12_regressions.py::test_historical_account_update_preserves_current_and_adds_one_candidate",
"exit_code": 1,
"failure": "historical explicit update replaced the newer current cache instead of retaining current state while adding one historical candidate"
},
{
"finding": "independent-code/dashboard-basis-consumer",
"test": "tests/test_account_basis_public_consumers.py::test_dashboard_valuation_basis_never_uses_sync_timestamp",
"exit_code": 1,
"failure": "Dashboard page_accounts used last_synced_at for valuation selection and registration defaults"
},
{
"finding": "independent-code/mcp-basis-forwarding",
"test": "tests/test_account_basis_public_consumers.py::test_mcp_account_write_tools_forward_explicit_balance_basis",
"exit_code": 1,
"failure": "MCP account-write tools rejected/dropped last_balance_basis_date"
}
]
}
+4 -4
View File
@@ -102,9 +102,9 @@ def get_ledger_backup_status(file_path: str, include_hash: bool = True, include_
return contracts.get_ledger_backup_status(file_path, include_hash=include_hash, include_integrity=include_integrity)
@mcp.tool()
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") -> dict:
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", last_balance_basis_date: str = "") -> dict:
"""Create an account/card/cash/investment/loan ledger account."""
return contracts.create_ledger_account(name, type, institution, currency, active, memo, account_number_masked, holder_name, last_balance, last_synced_at, statement_status, liquidity_bucket)
return contracts.create_ledger_account(name, type, institution, currency, active, memo, account_number_masked, holder_name, last_balance, last_synced_at, statement_status, liquidity_bucket, last_balance_basis_date=last_balance_basis_date)
@mcp.tool()
def list_ledger_accounts(active_only: bool = True) -> dict:
@@ -112,9 +112,9 @@ def list_ledger_accounts(active_only: bool = True) -> dict:
return contracts.list_ledger_accounts(active_only)
@mcp.tool()
def update_ledger_account(account_id: str, name: str | None = None, type: str | None = None, institution: str | None = None, currency: str | None = None, active: bool | None = None, memo: str | None = None, account_number_masked: str | None = None, holder_name: str | None = None, last_balance: int | None = None, last_synced_at: str | None = None, statement_status: str | None = None, liquidity_bucket: str | None = None) -> dict:
def update_ledger_account(account_id: str, name: str | None = None, type: str | None = None, institution: str | None = None, currency: str | None = None, active: bool | None = None, memo: str | None = None, account_number_masked: str | None = None, holder_name: str | None = None, last_balance: int | None = None, last_synced_at: str | None = None, statement_status: str | None = None, liquidity_bucket: str | None = None, last_balance_basis_date: str | None = None) -> dict:
"""Update selected account fields, including statement_status/liquidity_bucket for manual reconciliation."""
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)
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, last_balance_basis_date=last_balance_basis_date)
@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:
@@ -185,9 +185,13 @@ def register_account_valuation(
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":
effective_kind = (valuation_kind or "balance").strip().lower()
is_loan_account = str(account["type"]).lower() == "loan"
if effective_kind in {"loan", "loan_balance"} and not is_loan_account:
return fail("validation_error", "loan valuation kind requires a loan account")
if definition is not None and str(account["type"]).lower() != "loan":
if is_loan_account and effective_kind in {"balance", "loan", "loan_balance"} and definition is None:
return fail("validation_error", "loan valuation requires representative_liability_definition")
if definition is not None and not is_loan_account:
return fail("validation_error", "representative liability definition requires a loan account")
seen: set[str] = set()
if supersedes_id:
@@ -314,16 +318,46 @@ def correct_account_valuation(valuation_id: str, amount: int, correction_reason:
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")}
try:
effective = {
"amount": _strict_int(amount, "amount", allow_none=False),
"account_id": source["account_id"],
"basis_date": source["basis_date"],
"currency": (preserved["currency"] or "KRW").strip().upper(),
"valuation_kind": (preserved["valuation_kind"] or "balance").strip().lower(),
"source_type": (preserved["source_type"] or "manual").strip().lower() or "manual",
"source_ref": preserved["source_ref"].strip() if isinstance(preserved["source_ref"], str) else "",
"verification_status": (preserved["verification_status"] or "unverified").strip().lower(),
"memo": preserved["memo"] or "",
"as_of_at": preserved["as_of_at"],
"timezone": preserved["timezone"],
"cost_basis": _strict_int(preserved["cost_basis"], "cost_basis"),
"net_contributed": _strict_int(preserved["net_contributed"], "net_contributed"),
"reported_principal_kind": preserved["reported_principal_kind"],
"reported_principal_label": preserved["reported_principal_label"],
"profit": _strict_int(preserved["profit"], "profit"),
"return_rate": _strict_float(preserved["return_rate"], "return_rate"),
"return_basis": preserved["return_basis"],
"principal": _strict_int(preserved["principal"], "principal"),
"interest": _strict_int(preserved["interest"], "interest"),
"fee": _strict_int(preserved["fee"], "fee"),
"representative_liability_definition": preserved["representative_liability_definition"].strip() if preserved["representative_liability_definition"] else None,
"supersedes_id": valuation_id,
"correction_reason": correction_reason.strip(),
}
except (TypeError, ValueError, AttributeError) as exc:
return fail("validation_error", str(exc))
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(preserved)
if all(prior[k] == v for k, v in expected.items() if k in prior.keys()):
if all(prior[key] == value for key, value in effective.items()):
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")
child = conn.execute("SELECT * FROM account_valuations WHERE supersedes_id=?", (valuation_id,)).fetchone()
if child is not None:
if all(child[key] == value for key, value in effective.items()):
return ok(valuation=_valuation(child), reused=True)
return fail("correction_conflict", "correction payload conflicts with existing correction", valuation=_valuation(child))
return register_account_valuation(
source["account_id"], source["basis_date"], amount,
**preserved,
@@ -1,20 +1,20 @@
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_date, 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:
def _record_balance_candidate(conn: Any, account: dict[str, Any], balance: Any = None, basis_date: 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 ""):
basis_date = account.get("last_balance_basis_date") if basis_date is None else basis_date
if balance is None or not str(basis_date 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")
record_account_valuation_candidate(conn, account["id"], str(basis_date), int(balance), account.get("currency", "KRW"), "account_write", account.get("updated_at", ""), f"account-balance:{account['id']}:{str(basis_date)}:{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]:
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, last_balance_basis_date: str = "") -> 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)}")
try:
@@ -22,8 +22,11 @@ def create_ledger_account(name: str, type: str = "bank", institution: str = "",
liquidity_bucket_norm = normalize_liquidity_bucket(liquidity_bucket)
except Exception as e: return fail("validation_error", str(e))
ts = now_iso(); account_id = new_id("acct")
try:
basis_date = normalize_date(last_balance_basis_date, default_today=False) if last_balance_basis_date else ""
except Exception as e: return fail("validation_error", str(e))
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.upper(),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_balance_basis_date,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,basis_date,last_synced_at,statement_status_norm,liquidity_bucket_norm,ts,ts))
row = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone()
account = row_to_dict(row)
_record_balance_candidate(conn, account)
@@ -31,10 +34,20 @@ def create_ledger_account(name: str, type: str = "bank", institution: str = "",
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"}
allowed = {"name","type","institution","currency","active","memo","account_number_masked","holder_name","last_balance","last_balance_basis_date","last_synced_at","statement_status","liquidity_bucket"}
with db(db_path) as conn:
current = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone()
if current is None: return fail("not_found", "account not found")
balance_update = "last_balance" in fields and fields.get("last_balance") is not None
basis_update = "last_balance_basis_date" in fields and fields.get("last_balance_basis_date") is not None
if basis_update and not balance_update:
return fail("validation_error", "last_balance_basis_date requires last_balance")
normalized_basis = None
if basis_update:
try:
normalized_basis = normalize_date(fields["last_balance_basis_date"], default_today=False) if fields["last_balance_basis_date"] else ""
except Exception as e:
return fail("validation_error", str(e))
updates=[]; params=[]
for k,v in fields.items():
if k not in allowed or v is None: continue
@@ -45,21 +58,25 @@ def update_ledger_account(account_id: str, db_path: str | None = None, **fields:
if k == "active": v = int(bool(v))
if k == "last_balance" and v != "": v = int(v)
if k == "currency": v = str(v).upper()
if k == "last_balance_basis_date": v = normalized_basis
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)
stored_date = str(current["last_balance_basis_date"] or "")[:10]
incoming_date = normalized_basis if basis_update else ""
historical = bool(balance_update and 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="))]
kept=[(u,p) for u,p in zip(updates,params) if not u.startswith(("last_balance=","last_balance_basis_date=","last_synced_at="))]
updates=[u for u,_ in kept]; params=[p for _,p in kept]
elif balance_update and not basis_update:
# An explicit current-balance write without dated evidence advances
# the display cache but keeps its historical basis unknown.
updates.append("last_balance_basis_date=?"); params.append("")
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()
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)
if balance_update and basis_update:
_record_balance_candidate(conn, account, candidate_balance, incoming_date)
return ok(account=account)
+37 -6
View File
@@ -137,7 +137,7 @@ def _account_valuation_items(conn: sqlite3.Connection, basis_date: str = "", sel
"type": account.get("type", ""),
"currency": (account.get("currency") or "KRW").upper(),
"liquidity_bucket": bucket,
"valuation_date": account.get("last_synced_at", ""),
"valuation_date": account.get("last_balance_basis_date", ""),
"last_synced_at": account.get("last_synced_at", ""),
"source_note": account.get("memo", ""),
"memo": account.get("memo", ""),
@@ -259,14 +259,45 @@ def _manual_asset_valuation_items(conn: sqlite3.Connection, active_only: bool =
return included, excluded
def _snapshot_rows_for_run(conn: sqlite3.Connection, run_id: str) -> tuple[list[sqlite3.Row], dict[str, Any]]:
"""Load one immutable close run and only the rows persisted by that run."""
run = conn.execute("SELECT * FROM asset_snapshot_runs WHERE id=?", (run_id,)).fetchone()
if not run:
return [], {}
basis_date = run["basis_date"]
rows = conn.execute(
"SELECT rowid AS _rowid, * FROM asset_snapshots WHERE basis_date=? AND snapshot_run_id=? ORDER BY created_at ASC, name ASC",
(basis_date, run["id"]),
).fetchall()
total_rows = conn.execute(
"SELECT COUNT(*) FROM asset_snapshots WHERE basis_date=?", (basis_date,)
).fetchone()[0]
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": total_rows,
}
return rows, metadata
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()
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
return _snapshot_rows_for_run(conn, run["id"])
def _snapshot_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str, Any]]:
@@ -629,8 +660,8 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
existing_run = conn.execute("SELECT * FROM asset_snapshot_runs WHERE idempotency_key=?", (idempotency_key,)).fetchone()
if existing_run:
if existing_run["request_fingerprint"] == fingerprint:
replay_rows, replay_meta = _snapshot_rows_for_latest_run(conn, basis)
replay_items = [_public_evidence(item) for item in replay_meta.get("applied_evidence", [])] or _snapshot_items(conn, basis)
replay_rows, replay_meta = _snapshot_rows_for_run(conn, existing_run["id"])
replay_items = [_public_evidence(item) for item in replay_meta.get("applied_evidence", [])]
replay_warnings = _closed_warnings(replay_meta, basis, replay_rows)
replay_public = _closed_run_metadata(replay_meta)
replay_public["warnings"] = replay_warnings
@@ -43,19 +43,10 @@ def _record_import_valuation_candidate(conn: Any, account_id: str, balance: Any,
def _update_current_balance_if_newer(conn: Any, account_id: str, balance: Any, basis_date: str, synced_at: str) -> None:
if balance is None or not basis_date:
return
current = conn.execute("SELECT last_synced_at FROM accounts WHERE id=?", (account_id,)).fetchone()
latest_evidence = conn.execute(
"""SELECT basis_date FROM account_valuations
WHERE account_id=? AND verification_status IN ('verified', 'unverified')
ORDER BY basis_date DESC, recorded_at DESC, id DESC LIMIT 1""",
(account_id,),
).fetchone()
current_date = max(
str(current["last_synced_at"] or "")[:10] if current else "",
str(latest_evidence["basis_date"] or "")[:10] if latest_evidence else "",
)
current = conn.execute("SELECT last_balance_basis_date FROM accounts WHERE id=?", (account_id,)).fetchone()
current_date = str(current["last_balance_basis_date"] or "")[:10] if current else ""
if not current_date or basis_date[:10] >= current_date:
conn.execute("UPDATE accounts SET last_balance=?, last_synced_at=?, updated_at=? WHERE id=?", (balance, synced_at, synced_at, account_id))
conn.execute("UPDATE accounts SET last_balance=?, last_balance_basis_date=?, last_synced_at=?, updated_at=? WHERE id=?", (balance, basis_date[:10], synced_at, synced_at, account_id))
@read_operation
def preview_ledger_csv_import(file_path: str, source: str = "generic", db_path: str | None = None) -> dict[str, Any]:
@@ -22,6 +22,7 @@ CREATE TABLE IF NOT EXISTS accounts (
account_number_masked TEXT NOT NULL DEFAULT '',
holder_name TEXT NOT NULL DEFAULT '',
last_balance INTEGER,
last_balance_basis_date TEXT NOT NULL DEFAULT '',
last_synced_at TEXT NOT NULL DEFAULT '',
statement_status TEXT NOT NULL DEFAULT 'unknown',
liquidity_bucket TEXT NOT NULL DEFAULT 'other',
@@ -272,6 +273,7 @@ ACCOUNT_ADDITIVE_COLUMNS: dict[str, str] = {
"account_number_masked": "TEXT NOT NULL DEFAULT ''",
"holder_name": "TEXT NOT NULL DEFAULT ''",
"last_balance": "INTEGER",
"last_balance_basis_date": "TEXT NOT NULL DEFAULT ''",
"last_synced_at": "TEXT NOT NULL DEFAULT ''",
"statement_status": "TEXT NOT NULL DEFAULT 'unknown'",
"liquidity_bucket": "TEXT NOT NULL DEFAULT 'other'",
+2
View File
@@ -42,6 +42,7 @@ def create_ci_fixture_db(db_path: str | Path) -> dict[str, Any]:
holder_name="CI Synthetic Holder",
last_balance=1_250_000,
last_synced_at="2026-06-30T23:59:00+09:00",
last_balance_basis_date="2026-06-30",
statement_status="partial_import",
liquidity_bucket="cash",
memo="ci synthetic fixture account",
@@ -64,6 +65,7 @@ def create_ci_fixture_db(db_path: str | Path) -> dict[str, Any]:
]:
result = service.create_ledger_account(
last_synced_at="2026-06-30T23:59:00+09:00",
last_balance_basis_date="2026-06-30" if account_spec.get("last_balance") is not None else "",
memo="ci synthetic fixture account",
db_path=str(path),
**account_spec,
@@ -0,0 +1,47 @@
from __future__ import annotations
import inspect
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from dashboard.page_views import accounts as accounts_page
from jarca_ledger_mcp.adapters.mcp import server
def test_dashboard_valuation_basis_never_uses_sync_timestamp():
account = {
"last_balance_basis_date": "2026-06-30",
"last_synced_at": "2026-07-09T23:59:00+09:00",
}
assert accounts_page._account_balance_basis_date(account) == "2026-06-30"
assert accounts_page._account_balance_basis_date({"last_synced_at": account["last_synced_at"]}) == ""
assert "last_synced_at" not in inspect.getsource(accounts_page.page_accounts)
def test_mcp_account_write_tools_forward_explicit_balance_basis(monkeypatch):
calls: list[tuple[str, tuple, dict]] = []
def fake_create(*args, **kwargs):
calls.append(("create", args, kwargs))
return {"success": True}
def fake_update(*args, **kwargs):
calls.append(("update", args, kwargs))
return {"success": True}
monkeypatch.setattr(server.contracts, "create_ledger_account", fake_create)
monkeypatch.setattr(server.contracts, "update_ledger_account", fake_update)
assert server.create_ledger_account(
"dated", last_balance=100, last_synced_at="2026-07-09T00:00:00Z",
last_balance_basis_date="2026-06-30",
)["success"]
assert server.update_ledger_account(
"account-1", last_balance=200, last_synced_at="2026-07-10T00:00:00Z",
last_balance_basis_date="2026-07-01",
)["success"]
assert calls[0][2]["last_balance_basis_date"] == "2026-06-30"
assert calls[1][2]["last_balance_basis_date"] == "2026-07-01"
@@ -0,0 +1,93 @@
from jarca_ledger_mcp import service
def test_loan_balance_requires_representative_liability_definition(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
bank = service.create_ledger_account("은행", type="bank", db_path=path)["account"]["id"]
loan = service.create_ledger_account("대출", type="loan", db_path=path)["account"]["id"]
missing_definition = service.register_account_valuation(
loan,
"2026-06-30",
100,
valuation_kind="balance",
verification_status="verified",
db_path=path,
)
assert missing_definition["success"] is False
assert missing_definition["error_code"] == "validation_error"
assert service.list_account_valuations(loan, db_path=path)["count"] == 0
ordinary_balance = service.register_account_valuation(
bank,
"2026-06-30",
100,
valuation_kind="balance",
verification_status="verified",
db_path=path,
)
defined_loan_balance = service.register_account_valuation(
loan,
"2026-06-30",
100,
valuation_kind="balance",
representative_liability_definition="principal_only",
verification_status="verified",
db_path=path,
)
assert ordinary_balance["success"] is True
assert defined_loan_balance["success"] is True
def test_correction_replays_by_evidence_and_conflicts_on_changed_payload(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("계좌", db_path=path)["account"]["id"]
parent = service.register_account_valuation(
account,
"2026-06-30",
100,
source_ref="statement-1",
verification_status="verified",
db_path=path,
)["valuation"]
first = service.correct_account_valuation(
parent["id"],
110,
"statement correction",
db_path=path,
)
replay = service.correct_account_valuation(
parent["id"],
110,
"statement correction",
db_path=path,
)
changed_amount = service.correct_account_valuation(
parent["id"],
111,
"statement correction",
db_path=path,
)
changed_reason = service.correct_account_valuation(
parent["id"],
110,
"different correction",
db_path=path,
)
changed_source = service.correct_account_valuation(
parent["id"],
110,
"statement correction",
source_ref="statement-2",
db_path=path,
)
assert first["success"] is True
assert replay["success"] is True
assert replay["reused"] is True
assert replay["valuation"]["id"] == first["valuation"]["id"]
for changed in (changed_amount, changed_reason, changed_source):
assert changed["success"] is False
assert changed["error_code"] == "correction_conflict"
assert service.list_account_valuations(account, db_path=path)["count"] == 2
+2 -2
View File
@@ -61,7 +61,7 @@ def test_correction_is_append_only_reasoned_and_single_child(tmp_path):
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)
created = service.create_ledger_account("동기화 계좌", last_balance=500, last_synced_at="2026-06-30", db_path=path, last_balance_basis_date="2026-06-30")
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
@@ -95,7 +95,7 @@ def test_correction_preserves_optional_fields_unless_overridden(tmp_path):
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)
created = service.create_ledger_account("계좌", last_balance=500, last_synced_at="2026-06-30", db_path=path, last_balance_basis_date="2026-06-30")
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)
+132
View File
@@ -0,0 +1,132 @@
from __future__ import annotations
import sqlite3
from jarca_ledger_mcp import service
def test_exact_run_replay_after_correction_reconstructs_original_persisted_run(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("현금", 100, "2026-06-30", "cash", db_path=db_path)
service.create_manual_asset("평가 대기", None, "2026-06-30", "other", db_path=db_path)
first = service.close_asset_snapshot(
"2026-06-30",
source_note="synthetic initial close",
memo="synthetic initial memo",
idempotency_key="snapshot-close-a",
allow_missing_exclusions=True,
approved_by="reviewer-a",
approval_reason="synthetic initial approval",
approval_reference="approval-a",
db_path=db_path,
)
assert first["success"] is True
assert first["close_status"] == "conditional"
assert first["is_correction"] is False
assert first["warnings"]
assert first["approval"]["approval_reference"] == "approval-a"
assert first["provenance"]["snapshot_run_id"] == first["snapshot_run_id"]
corrected = service.close_asset_snapshot(
"2026-06-30",
source_note="synthetic correction close",
memo="synthetic correction memo",
correction_reason="synthetic correction",
idempotency_key="snapshot-close-b",
allow_missing_exclusions=True,
approved_by="reviewer-b",
approval_reason="synthetic correction approval",
approval_reference="approval-b",
db_path=db_path,
)
assert corrected["success"] is True
assert corrected["is_correction"] is True
assert corrected["snapshot_run_id"] != first["snapshot_run_id"]
assert corrected["correction"]["correction_of_run_id"] == first["snapshot_run_id"]
assert corrected["approval"]["approval_reference"] == "approval-b"
assert corrected["snapshot_run_id"] not in first["inserted_snapshot_ids"]
with sqlite3.connect(db_path) as conn:
before_replay = conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0]
replay = service.close_asset_snapshot(
"2026-06-30",
source_note="synthetic initial close",
memo="synthetic initial memo",
idempotency_key="snapshot-close-a",
allow_missing_exclusions=True,
approved_by="reviewer-a",
approval_reason="synthetic initial approval",
approval_reference="approval-a",
db_path=db_path,
)
with sqlite3.connect(db_path) as conn:
after_replay = conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0]
assert replay == first
assert replay["snapshot_run_id"] == first["snapshot_run_id"]
assert replay["inserted_snapshot_ids"] == first["inserted_snapshot_ids"]
assert replay["correction"] == first["correction"]
assert replay["approval"] == first["approval"]
assert replay["warnings"] == first["warnings"]
assert replay["provenance"] == first["provenance"]
assert replay["included_items"] == first["included_items"]
assert replay["excluded_items"] == first["excluded_items"]
assert replay["total_assets"] == first["total_assets"]
assert replay["total_liabilities"] == first["total_liabilities"]
assert replay["net_worth"] == first["net_worth"]
assert replay["snapshot_run_id"] != corrected["snapshot_run_id"]
assert corrected["snapshot_run_id"] not in replay["inserted_snapshot_ids"]
assert after_replay == before_replay
def test_empty_run_replay_after_nonempty_correction_preserves_original_empty_payload(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
service.create_ledger_account("누락 계좌", db_path=db_path)
first = service.close_asset_snapshot(
"2026-06-30",
idempotency_key="empty-close-a",
allow_missing_exclusions=True,
approved_by="reviewer-a",
approval_reason="approve empty",
approval_reference="approval-a",
db_path=db_path,
)
assert first["success"] is True
assert first["inserted_count"] == 0
assert first["included_items"] == []
service.create_manual_asset("후속 자산", 100, "2026-06-30", "cash", db_path=db_path)
corrected = service.close_asset_snapshot(
"2026-06-30",
correction_reason="add verified asset",
idempotency_key="nonempty-close-b",
allow_missing_exclusions=True,
approved_by="reviewer-b",
approval_reason="approve correction",
approval_reference="approval-b",
db_path=db_path,
)
assert corrected["success"] is True
assert corrected["inserted_count"] == 1
# Restore the original live authority while retaining the later immutable
# correction run, so replay reaches the original-key reconstruction path.
with sqlite3.connect(db_path) as conn:
conn.execute("DELETE FROM manual_assets WHERE name=?", ("후속 자산",))
replay = service.close_asset_snapshot(
"2026-06-30",
idempotency_key="empty-close-a",
allow_missing_exclusions=True,
approved_by="reviewer-a",
approval_reason="approve empty",
approval_reference="approval-a",
db_path=db_path,
)
assert replay == first
assert replay["inserted_count"] == 0
assert replay["inserted_snapshot_ids"] == []
assert replay["included_items"] == []
@@ -0,0 +1,105 @@
from types import SimpleNamespace
import sqlite3
from jarca_ledger_mcp import service
from jarca_ledger_mcp.application import imports_service
def _parsed(transaction, source_hash):
return {
"transactions": [transaction],
"validation": {"ok": True},
"source_hash": source_hash,
"metadata": {"final_balance": transaction.balance_after, "account_number_masked": ""},
"file_path": "/tmp/synthetic.pdf",
"page_count": 1,
}
def _transaction(date, balance, row):
return SimpleNamespace(
transaction_date=date,
transaction_datetime=f"{date} 10:00:00",
amount=100,
counterparty="상대",
raw_summary="이체",
branch="",
remittance_memo="",
balance_after=balance,
type="income",
page=1,
action="입금",
external_id=lambda source_hash: f"kb:{source_hash}:{row}",
raw_payload=lambda: "{}",
)
def test_late_import_current_balance_is_authoritative_by_evidence_basis_date(monkeypatch, tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account(
"KB", institution="KB국민은행", db_path=path,
)["account"]["id"]
imports = iter(
(
(_transaction("2026-06-01", 700, 1), "hash-old"),
(_transaction("2026-06-30", 900, 2), "hash-new"),
)
)
monkeypatch.setattr(imports_service, "parse_kb_bank_pdf", lambda *_: _parsed(*next(imports)))
sync_times = iter(("2026-07-01T09:00:00", "2026-07-02T09:00:00"))
monkeypatch.setattr(imports_service, "now_iso", lambda: next(sync_times))
older = imports_service.import_kb_bank_pdf("older", "pw", account_id=account, db_path=path)
newer = imports_service.import_kb_bank_pdf("newer", "pw", account_id=account, db_path=path)
current = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0]
valuations = service.list_account_valuations(account, db_path=path)["valuations"]
assert older["success"] and newer["success"]
assert current["last_balance"] == 900
assert current["last_synced_at"] == "2026-07-02T09:00:00"
assert current["last_balance_basis_date"] == "2026-06-30"
assert {(item["basis_date"], item["amount"]) for item in valuations} >= {
("2026-06-01", 700),
("2026-06-30", 900),
}
def test_account_write_sync_timestamp_does_not_infer_balance_basis_or_candidate(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
unknown = service.create_ledger_account(
"기준일 미상",
last_balance=500,
last_synced_at="2026-07-01T09:00:00+09:00",
db_path=path,
)
assert unknown["success"] is True
assert unknown["account"]["last_balance_basis_date"] == ""
assert service.list_account_valuations(unknown["account"]["id"], db_path=path)["count"] == 0
dated = service.create_ledger_account(
"기준일 확인",
last_balance=600,
last_synced_at="2026-07-01T09:00:00+09:00",
db_path=path,
last_balance_basis_date="2026-06-30",
)
valuations = service.list_account_valuations(dated["account"]["id"], db_path=path)["valuations"]
assert dated["account"]["last_balance_basis_date"] == "2026-06-30"
assert [(item["basis_date"], item["amount"]) for item in valuations] == [("2026-06-30", 600)]
def test_unknown_legacy_balance_basis_is_not_reported_as_valuation_date(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("legacy", db_path=path)["account"]["id"]
with sqlite3.connect(path) as conn:
conn.execute(
"UPDATE accounts SET last_balance=?, last_synced_at=?, last_balance_basis_date='' WHERE id=?",
(500, "2026-07-01T09:00:00+09:00", account),
)
report = service.get_asset_snapshot_report("2026-06-30", db_path=path)
excluded = next(item for item in report["excluded_items"] if item["source_id"] == account)
assert excluded["valuation_date"] == ""
assert excluded["last_synced_at"] == "2026-07-01T09:00:00+09:00"
+8 -5
View File
@@ -75,9 +75,9 @@ def test_reconciliation_prefers_investment_total_value_over_balance(tmp_path):
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)
created = service.create_ledger_account("계좌", last_balance=900, last_synced_at="2026-06-30", db_path=path, last_balance_basis_date="2026-06-30")
account = created["account"]["id"]
updated = service.update_ledger_account(account, last_balance=700, last_synced_at="2026-06-01", db_path=path)
updated = service.update_ledger_account(account, last_balance=700, last_synced_at="2026-06-01", last_balance_basis_date="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
@@ -294,20 +294,23 @@ def test_loan_representative_is_always_aggregated_as_liability(tmp_path):
assert report["included_items"][0]["liquidity_bucket"] == "loan"
def test_loan_balance_fallback_without_representative_definition_is_excluded(tmp_path):
def test_loan_balance_without_representative_definition_is_rejected_without_write(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(
rejected = service.register_account_valuation(
loan, "2026-06-30", 100, valuation_kind="balance",
verification_status="verified", db_path=path,
)
assert rejected["success"] is False
assert rejected["error_code"] == "validation_error"
assert service.list_account_valuations(loan, db_path=path)["count"] == 0
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"
assert report["excluded_items"][0]["reason"] == "missing_verified_exact_valuation"
def test_warning_candidate_public_payload_is_minimal():
+3 -3
View File
@@ -96,7 +96,7 @@ def _parsed(tx, source, metadata):
def test_import_historical_kb_does_not_replace_newer_current_balance(monkeypatch, tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("KB", institution="KB국민은행", last_balance=900, last_synced_at="2026-06-30", db_path=path)["account"]["id"]
account = service.create_ledger_account("KB", institution="KB국민은행", last_balance=900, last_synced_at="2026-06-30", db_path=path, last_balance_basis_date="2026-06-30")["account"]["id"]
monkeypatch.setattr(imports_service, "parse_kb_bank_pdf", lambda *_: _parsed(_fake_tx("2026-06-01", 700, kind="kb"), "kb", {"final_balance": 700, "account_number_masked": ""}))
result = imports_service.import_kb_bank_pdf("ignored", "pw", account_id=account, db_path=path)
current = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0]
@@ -106,7 +106,7 @@ def test_import_historical_kb_does_not_replace_newer_current_balance(monkeypatch
def test_import_historical_toss_bank_does_not_replace_newer_current_balance(monkeypatch, tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("Toss", institution="토스뱅크", last_balance=900, last_synced_at="2026-06-30", db_path=path)["account"]["id"]
account = service.create_ledger_account("Toss", institution="토스뱅크", last_balance=900, last_synced_at="2026-06-30", db_path=path, last_balance_basis_date="2026-06-30")["account"]["id"]
monkeypatch.setattr(imports_service, "parse_toss_bank_pdf", lambda *_: _parsed(_fake_tx("2026-06-01", 700), "bank", {"account_number_masked": ""}))
result = imports_service.import_toss_bank_pdf("ignored", account_id=account, db_path=path)
current = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0]
@@ -116,7 +116,7 @@ def test_import_historical_toss_bank_does_not_replace_newer_current_balance(monk
def test_import_historical_toss_securities_does_not_replace_newer_current_balance(monkeypatch, tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("Securities", type="investment", institution="토스증권", last_balance=900, last_synced_at="2026-06-30", db_path=path)["account"]["id"]
account = service.create_ledger_account("Securities", type="investment", institution="토스증권", last_balance=900, last_synced_at="2026-06-30", db_path=path, last_balance_basis_date="2026-06-30")["account"]["id"]
monkeypatch.setattr(imports_service, "parse_toss_securities_pdf", lambda *_: _parsed(_fake_tx("2026-06-01", 700, kind="tosssec"), "sec", {"account_number_masked": ""}))
result = imports_service.import_toss_securities_pdf("ignored", account_id=account, db_path=path)
current = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0]
+43
View File
@@ -0,0 +1,43 @@
from pathlib import Path
ROOT = Path(__file__).parents[1]
def _text(relative_path: str) -> str:
return (ROOT / relative_path).read_text(encoding="utf-8")
def test_data_model_documents_cache_authority_and_legacy_basis_migration() -> None:
data_model = _text("docs/data-model.md")
required_contract = (
"`last_balance` is the current cache value",
"`last_balance_basis_date`",
"`last_synced_at` is the execution/sync timestamp",
"imports and account writes advance the account cache",
"`account_valuations` is the historical authority",
"older evidence never rolls back the current cache",
"additive migration",
"legacy rows with unknown basis remain unknown; no inference",
)
for marker in required_contract:
assert marker in data_model, marker
def test_interface_contract_documents_public_valuation_enums_validation_and_mapping() -> None:
interface = _text("docs/interface-contract.md")
assert "actual enums in source" not in interface
required_contract = (
"source_type: `manual`, `import`, `account_write`, `reconciliation`, `snapshot`",
"verification_status: `verified`, `unverified`, `rejected`",
"valuation_kind: `balance`, `cash_balance`, `total_value`, `loan`, `loan_balance`",
"representative_liability_definition: `principal_only`, `principal_interest_fees`",
"loan accounts require representative_liability_definition for `loan`, `loan_balance`, and `balance` aliases",
"legacy valuation-kind aliases remain accepted and map to the canonical purpose",
"validation failure returns `validation_error` and performs no write",
"loan valuations map to liabilities and non-loan valuations map to assets",
)
for marker in required_contract:
assert marker in interface, marker