Compare commits
24
Commits
5e721b0ddd
..
v0.1.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68212e773b | ||
|
|
f5de4118d7 | ||
|
|
2ab68f9278 | ||
|
|
4b8b74ba2a | ||
|
|
432e6f851a | ||
|
|
63fba3670c | ||
|
|
31d1c11dcf | ||
|
|
3bf9b77c6c | ||
|
|
f49568273e | ||
|
|
8caf1f8379 | ||
|
|
fc2d2a6fae | ||
|
|
d258cfdeb4 | ||
|
|
e41db959e4 | ||
|
|
c7a9cfa50d | ||
|
|
3db086a7fd | ||
|
|
e6ca9a7ff9 | ||
|
|
bce25f9106 | ||
|
|
ce9c9f7326 | ||
|
|
15dd9f92a7 | ||
|
|
77070b8164 | ||
|
|
3b5101128d | ||
|
|
7fd5c1e0d7 | ||
|
|
1b9eda683c | ||
|
|
b8b8811a2a |
@@ -34,7 +34,7 @@ spec:
|
|||||||
type: RuntimeDefault
|
type: RuntimeDefault
|
||||||
initContainers:
|
initContainers:
|
||||||
- name: init-db
|
- name: init-db
|
||||||
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.4
|
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.5
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
command:
|
command:
|
||||||
- python
|
- python
|
||||||
@@ -62,7 +62,7 @@ spec:
|
|||||||
mountPath: /tmp
|
mountPath: /tmp
|
||||||
containers:
|
containers:
|
||||||
- name: mcp
|
- name: mcp
|
||||||
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.4
|
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.5
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
env:
|
env:
|
||||||
- name: JARCA_LEDGER_DB_PATH
|
- name: JARCA_LEDGER_DB_PATH
|
||||||
@@ -104,7 +104,7 @@ spec:
|
|||||||
- name: mcp-tmp
|
- name: mcp-tmp
|
||||||
mountPath: /tmp
|
mountPath: /tmp
|
||||||
- name: dashboard
|
- name: dashboard
|
||||||
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.4
|
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.5
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
command:
|
command:
|
||||||
- uvicorn
|
- uvicorn
|
||||||
|
|||||||
+10
-2
@@ -60,6 +60,13 @@
|
|||||||
| `return_rate` | REAL | 선택 | NULL | finite number | ratio | 아니오 |
|
| `return_rate` | REAL | 선택 | NULL | finite number | ratio | 아니오 |
|
||||||
| `representative_liability_definition` | TEXT | loan 필수 | NULL | principal_only/principal_interest_fees | enum | 아니오 |
|
| `representative_liability_definition` | TEXT | loan 필수 | NULL | principal_only/principal_interest_fees | enum | 아니오 |
|
||||||
|
|
||||||
|
#### `select_account_valuation` 선택 규칙과 기존 인덱스 매핑
|
||||||
|
|
||||||
|
- `select_account_valuation`은 `account_id is mandatory`인 단일 평가 선택 query다. `currency and valuation_kind/purpose are optional filters`이며, `basis_date exact match or <= upper bound` 조건을 지원한다.
|
||||||
|
- 선택 결과는 `verified leaf valuations only`다. 즉 superseded parent가 아닌 leaf만 권위로 사용하며, `future-dated and unverified valuations are excluded`한다.
|
||||||
|
- `idx_account_valuations_account_date supports account/date selection`; `idx_account_valuations_status_date supports status/date selection`. `neither index covers account + currency + purpose`이므로 해당 조합의 전체 선택 조건을 기존 인덱스가 대신 보장한다고 해석하지 않는다.
|
||||||
|
- `idempotency and single_correction indexes enforce write integrity, not selection`이다. 이 인덱스들은 재기록 방지와 correction 단일 child 불변조건을 위한 것이며 `select_account_valuation`의 후보 검색 인덱스가 아니다.
|
||||||
|
|
||||||
### manual-assets — 수동 자산/부채
|
### manual-assets — 수동 자산/부채
|
||||||
|
|
||||||
- 책임: transaction이 아닌 평가 source
|
- 책임: transaction이 아닌 평가 source
|
||||||
@@ -118,15 +125,16 @@
|
|||||||
|
|
||||||
### transactions, import-batches, reconciliation-sessions, review-decisions, schema-meta — 기존 entities
|
### 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다.
|
기존 필드와 관계를 보존한다. import는 거래를 기록할 수 있지만 balance valuation candidate는 dated `unverified` row만 추가한다. loan cache writes without an explicit liability definition do not append an `account_valuations` candidate; current cache와 transaction import는 그대로 완료하며 정의를 추정하지 않는다. 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` 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_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.
|
- `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.
|
- imports and account writes advance the account cache; non-loan writes may also append a dated `account_valuations` candidate. loan cache writes without an explicit liability definition do not append historical evidence, and 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.
|
- `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.
|
||||||
|
- Every valuation attached to a `loan` account has an explicit `principal_only` or `principal_interest_fees` `representative_liability_definition`. A non-loan to loan conversion with any undefined or invalid attached valuation is rejected without changing the account or history; conversion with no valuations is allowed.
|
||||||
|
|
||||||
## 관계와 불변조건
|
## 관계와 불변조건
|
||||||
|
|
||||||
|
|||||||
@@ -337,11 +337,11 @@ AppTest는 callback, query mapping, synthetic renderer output과 error/loading e
|
|||||||
|
|
||||||
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.
|
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 accounts require representative_liability_definition for `loan`, `loan_balance`, and `balance` aliases. Those are the only accepted valuation kinds for loan accounts; `cash_balance` and `total_value` do not represent a loan liability and are rejected. 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. Converting an existing non-loan account to `loan` is rejected with `validation_error` if any attached valuation lacks an explicit valid definition; the account and valuation history remain unchanged. Conversion with no valuations remains allowed.
|
||||||
|
|
||||||
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.
|
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.
|
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 candidate. A dated older non-loan balance appends historical evidence but cannot roll back a newer current cache. loan cache writes without an explicit liability definition do not append a historical candidate; they preserve current-cache and import behavior without inferring a definition. Import and account writers use deterministic candidate keys.
|
||||||
|
|
||||||
### shared contract and adapter limits
|
### shared contract and adapter limits
|
||||||
|
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ amount와 principal/interest/fee/cost_basis/net_contributed/profit은 exact inte
|
|||||||
|
|
||||||
### REQ-003.05 — current와 historical authority
|
### REQ-003.05 — current와 historical authority
|
||||||
|
|
||||||
current latest display와 historical authority를 분리한다. `last_balance`는 opening evidence가 아니라 external current comparison이며, balance_after/opening evidence가 모두 없으면 `INCOMPLETE_CALCULATION`이다. 늦게 도착한 historical row는 current를 과거로 되돌리지 않으며 cannot roll back newer current cache. account/import writes는 real dated evidence가 있을 때만 dated `unverified` candidate를 만든다.
|
current latest display와 historical authority를 분리한다. `last_balance`는 opening evidence가 아니라 external current comparison이며, balance_after/opening evidence가 모두 없으면 `INCOMPLETE_CALCULATION`이다. 늦게 도착한 historical row는 current를 과거로 되돌리지 않으며 cannot roll back newer current cache. account/import writes는 real dated evidence가 있을 때만 dated `unverified` candidate를 만든다. 대출 cache write는 대표 부채 정의가 없으면 이력 후보를 추가하지 않는다. 이때 current cache와 import 처리는 유지하고 정의를 추정하지 않는다. 기존 valuation이 있는 계좌를 loan으로 변환할 때는 모든 valuation에 유효한 대표 부채 정의가 있어야 하며, 위반 시 계좌와 이력을 변경하지 않고 `validation_error`를 반환한다. valuation이 없는 변환은 허용한다.
|
||||||
|
|
||||||
**영향 범위**
|
**영향 범위**
|
||||||
- 인터페이스: account update/import candidate, list/select/report
|
- 인터페이스: account update/import candidate, list/select/report
|
||||||
|
|||||||
@@ -1,486 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,181 +0,0 @@
|
|||||||
{
|
|
||||||
"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."
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
{
|
|
||||||
"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."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
"""Jarca Ledger MCP server."""
|
"""Jarca Ledger MCP server."""
|
||||||
|
|
||||||
__version__ = "0.1.4"
|
__version__ = "0.1.5"
|
||||||
|
|||||||
@@ -92,17 +92,23 @@ def _validate_common(account_id: str, basis_date: str, amount: int, currency: st
|
|||||||
|
|
||||||
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]:
|
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."""
|
"""Append an unverified candidate on a caller-owned transaction."""
|
||||||
account = conn.execute("SELECT currency FROM accounts WHERE id=?", (account_id,)).fetchone()
|
account = conn.execute("SELECT type,currency FROM accounts WHERE id=?", (account_id,)).fetchone()
|
||||||
if account is None:
|
if account is None:
|
||||||
return fail("not_found", "account not found")
|
return fail("not_found", "account not found")
|
||||||
if (currency or "KRW").upper() != (account["currency"] or "KRW").upper():
|
if (currency or "KRW").upper() != (account["currency"] or "KRW").upper():
|
||||||
return fail("validation_error", "valuation currency must match account currency")
|
return fail("validation_error", "valuation currency must match account currency")
|
||||||
|
if str(account["type"]).lower() == "loan":
|
||||||
|
return fail("validation_error", "loan valuation requires representative_liability_definition")
|
||||||
|
try:
|
||||||
|
amount_int = _strict_int(amount, "amount", allow_none=False)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
return fail("validation_error", str(exc))
|
||||||
basis = normalize_date(basis_date)
|
basis = normalize_date(basis_date)
|
||||||
existing = conn.execute("SELECT * FROM account_valuations WHERE account_id=? AND idempotency_key=?", (account_id, idempotency_key)).fetchone()
|
existing = conn.execute("SELECT * FROM account_valuations WHERE account_id=? AND idempotency_key=?", (account_id, idempotency_key)).fetchone()
|
||||||
if existing:
|
if existing:
|
||||||
incoming = {
|
incoming = {
|
||||||
"basis_date": basis,
|
"basis_date": basis,
|
||||||
"amount": int(amount),
|
"amount": amount_int,
|
||||||
"currency": (currency or "KRW").upper(),
|
"currency": (currency or "KRW").upper(),
|
||||||
"valuation_kind": "balance",
|
"valuation_kind": "balance",
|
||||||
"source_type": source_type,
|
"source_type": source_type,
|
||||||
@@ -120,7 +126,7 @@ def record_account_valuation_candidate(conn: sqlite3.Connection, account_id: str
|
|||||||
valuation_id = new_id("valuation")
|
valuation_id = new_id("valuation")
|
||||||
conn.execute("""INSERT INTO account_valuations
|
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)
|
(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))
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""", (valuation_id, account_id, basis, amount_int, (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)
|
return ok(valuation=_valuation(conn.execute("SELECT * FROM account_valuations WHERE id=?", (valuation_id,)).fetchone()), reused=False)
|
||||||
|
|
||||||
|
|
||||||
@@ -189,6 +195,8 @@ def register_account_valuation(
|
|||||||
is_loan_account = str(account["type"]).lower() == "loan"
|
is_loan_account = str(account["type"]).lower() == "loan"
|
||||||
if effective_kind in {"loan", "loan_balance"} and not is_loan_account:
|
if effective_kind in {"loan", "loan_balance"} and not is_loan_account:
|
||||||
return fail("validation_error", "loan valuation kind requires a loan account")
|
return fail("validation_error", "loan valuation kind requires a loan account")
|
||||||
|
if is_loan_account and effective_kind not in {"balance", "loan", "loan_balance"}:
|
||||||
|
return fail("validation_error", "loan account valuation kind must represent a liability")
|
||||||
if is_loan_account and effective_kind in {"balance", "loan", "loan_balance"} and definition is None:
|
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")
|
return fail("validation_error", "loan valuation requires representative_liability_definition")
|
||||||
if definition is not None and not is_loan_account:
|
if definition is not None and not is_loan_account:
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ def create_ledger_account(name: str, type: str = "bank", institution: str = "",
|
|||||||
try:
|
try:
|
||||||
basis_date = normalize_date(last_balance_basis_date, default_today=False) if last_balance_basis_date else ""
|
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))
|
except Exception as e: return fail("validation_error", str(e))
|
||||||
|
if basis_date and (last_balance is None or last_balance == ""):
|
||||||
|
return fail("validation_error", "last_balance_basis_date requires last_balance")
|
||||||
|
if last_balance is not None:
|
||||||
|
try:
|
||||||
|
from .account_valuation_service import _strict_int
|
||||||
|
last_balance = _strict_int(last_balance, "last_balance", allow_none=False)
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
return fail("validation_error", str(e))
|
||||||
with db(db_path) as conn:
|
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_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))
|
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()
|
row = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone()
|
||||||
@@ -42,6 +50,13 @@ def update_ledger_account(account_id: str, db_path: str | None = None, **fields:
|
|||||||
basis_update = "last_balance_basis_date" in fields and fields.get("last_balance_basis_date") 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:
|
if basis_update and not balance_update:
|
||||||
return fail("validation_error", "last_balance_basis_date requires last_balance")
|
return fail("validation_error", "last_balance_basis_date requires last_balance")
|
||||||
|
normalized_balance = None
|
||||||
|
if balance_update:
|
||||||
|
try:
|
||||||
|
from .account_valuation_service import _strict_int
|
||||||
|
normalized_balance = _strict_int(fields["last_balance"], "last_balance", allow_none=False)
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
return fail("validation_error", str(e))
|
||||||
normalized_basis = None
|
normalized_basis = None
|
||||||
if basis_update:
|
if basis_update:
|
||||||
try:
|
try:
|
||||||
@@ -56,16 +71,26 @@ def update_ledger_account(account_id: str, db_path: str | None = None, **fields:
|
|||||||
if k == "statement_status": v = normalize_statement_status(str(v))
|
if k == "statement_status": v = normalize_statement_status(str(v))
|
||||||
if k == "liquidity_bucket": v = normalize_liquidity_bucket(str(v))
|
if k == "liquidity_bucket": v = normalize_liquidity_bucket(str(v))
|
||||||
if k == "active": v = int(bool(v))
|
if k == "active": v = int(bool(v))
|
||||||
if k == "last_balance" and v != "": v = int(v)
|
if k == "last_balance": v = normalized_balance
|
||||||
if k == "currency": v = str(v).upper()
|
if k == "currency": v = str(v).upper()
|
||||||
if k == "last_balance_basis_date": v = normalized_basis
|
if k == "last_balance_basis_date": v = normalized_basis
|
||||||
except Exception as e: return fail("validation_error", str(e))
|
except Exception as e: return fail("validation_error", str(e))
|
||||||
updates.append(f"{k}=?"); params.append(v)
|
updates.append(f"{k}=?"); params.append(v)
|
||||||
if not updates: return fail("validation_error", "no fields to update")
|
if not updates: return fail("validation_error", "no fields to update")
|
||||||
|
target_type = fields.get("type", current["type"])
|
||||||
|
if str(current["type"]).lower() != "loan" and target_type == "loan":
|
||||||
|
invalid_valuation = conn.execute(
|
||||||
|
"SELECT 1 FROM account_valuations "
|
||||||
|
"WHERE account_id=? AND (representative_liability_definition IS NULL "
|
||||||
|
"OR representative_liability_definition NOT IN (?, ?)) LIMIT 1",
|
||||||
|
(account_id, "principal_only", "principal_interest_fees"),
|
||||||
|
).fetchone()
|
||||||
|
if invalid_valuation is not None:
|
||||||
|
return fail("validation_error", "loan account requires a valid representative liability definition for every valuation")
|
||||||
stored_date = str(current["last_balance_basis_date"] or "")[:10]
|
stored_date = str(current["last_balance_basis_date"] or "")[:10]
|
||||||
incoming_date = normalized_basis if basis_update else ""
|
incoming_date = normalized_basis if basis_update else ""
|
||||||
historical = bool(balance_update and incoming_date and stored_date and incoming_date < stored_date)
|
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_balance = normalized_balance if balance_update else current["last_balance"]
|
||||||
if historical:
|
if historical:
|
||||||
kept=[(u,p) for u,p in zip(updates,params) if not u.startswith(("last_balance=","last_balance_basis_date=","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]
|
updates=[u for u,_ in kept]; params=[p for _,p in kept]
|
||||||
|
|||||||
@@ -259,9 +259,21 @@ def _manual_asset_valuation_items(conn: sqlite3.Connection, active_only: bool =
|
|||||||
return included, excluded
|
return included, excluded
|
||||||
|
|
||||||
|
|
||||||
|
def _persisted_evidence_array(value: Any) -> list[dict[str, Any]] | None:
|
||||||
|
try:
|
||||||
|
decoded = json.loads(value or "[]")
|
||||||
|
except (TypeError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
if not isinstance(decoded, list) or any(not isinstance(item, dict) for item in decoded):
|
||||||
|
return None
|
||||||
|
return decoded
|
||||||
|
|
||||||
|
|
||||||
def _snapshot_rows_for_run(conn: sqlite3.Connection, run_id: str) -> tuple[list[sqlite3.Row], dict[str, Any]]:
|
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."""
|
"""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()
|
run = conn.execute(
|
||||||
|
"SELECT rowid AS _run_rowid, * FROM asset_snapshot_runs WHERE id=?", (run_id,)
|
||||||
|
).fetchone()
|
||||||
if not run:
|
if not run:
|
||||||
return [], {}
|
return [], {}
|
||||||
basis_date = run["basis_date"]
|
basis_date = run["basis_date"]
|
||||||
@@ -272,6 +284,24 @@ def _snapshot_rows_for_run(conn: sqlite3.Connection, run_id: str) -> tuple[list[
|
|||||||
total_rows = conn.execute(
|
total_rows = conn.execute(
|
||||||
"SELECT COUNT(*) FROM asset_snapshots WHERE basis_date=?", (basis_date,)
|
"SELECT COUNT(*) FROM asset_snapshots WHERE basis_date=?", (basis_date,)
|
||||||
).fetchone()[0]
|
).fetchone()[0]
|
||||||
|
previous_rows = conn.execute(
|
||||||
|
"""SELECT COUNT(*)
|
||||||
|
FROM asset_snapshots AS snapshot
|
||||||
|
LEFT JOIN asset_snapshot_runs AS snapshot_run
|
||||||
|
ON snapshot_run.id=snapshot.snapshot_run_id
|
||||||
|
WHERE snapshot.basis_date=?
|
||||||
|
AND (
|
||||||
|
snapshot.snapshot_run_id IS NULL
|
||||||
|
OR snapshot.snapshot_run_id=''
|
||||||
|
OR snapshot_run.rowid < ?
|
||||||
|
)""",
|
||||||
|
(basis_date, run["_run_rowid"]),
|
||||||
|
).fetchone()[0]
|
||||||
|
evidence = {
|
||||||
|
field: _persisted_evidence_array(run[field])
|
||||||
|
for field in ("applied_evidence", "excluded_evidence", "carried_evidence")
|
||||||
|
}
|
||||||
|
integrity_error = any(value is None for value in evidence.values())
|
||||||
metadata = {
|
metadata = {
|
||||||
"snapshot_run_id": run["id"],
|
"snapshot_run_id": run["id"],
|
||||||
"is_correction": bool(run["correction_of_run_id"]),
|
"is_correction": bool(run["correction_of_run_id"]),
|
||||||
@@ -283,10 +313,12 @@ def _snapshot_rows_for_run(conn: sqlite3.Connection, run_id: str) -> tuple[list[
|
|||||||
"approved_at": run["approved_at"],
|
"approved_at": run["approved_at"],
|
||||||
"approval_reason": run["approval_reason"],
|
"approval_reason": run["approval_reason"],
|
||||||
"approval_reference": run["approval_reference"],
|
"approval_reference": run["approval_reference"],
|
||||||
"applied_evidence": json.loads(run["applied_evidence"] or "[]"),
|
"applied_evidence": evidence["applied_evidence"] or [],
|
||||||
"excluded_evidence": json.loads(run["excluded_evidence"] or "[]"),
|
"excluded_evidence": evidence["excluded_evidence"] or [],
|
||||||
"carried_evidence": json.loads(run["carried_evidence"] or "[]"),
|
"carried_evidence": evidence["carried_evidence"] or [],
|
||||||
|
"evidence_integrity_error": integrity_error,
|
||||||
"closed_at": run["created_at"] or "",
|
"closed_at": run["created_at"] or "",
|
||||||
|
"previous_snapshot_row_count": previous_rows,
|
||||||
"total_snapshot_rows_for_basis_date": total_rows,
|
"total_snapshot_rows_for_basis_date": total_rows,
|
||||||
}
|
}
|
||||||
return rows, metadata
|
return rows, metadata
|
||||||
@@ -296,6 +328,32 @@ def _snapshot_rows_for_latest_run(conn: sqlite3.Connection, basis_date: str) ->
|
|||||||
rows = conn.execute("SELECT rowid AS _rowid, * FROM asset_snapshots WHERE basis_date=? ORDER BY created_at ASC, name ASC", (basis_date,)).fetchall()
|
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()
|
run = conn.execute("SELECT * FROM asset_snapshot_runs WHERE basis_date=? ORDER BY rowid DESC LIMIT 1", (basis_date,)).fetchone()
|
||||||
if not run:
|
if not run:
|
||||||
|
legacy_run_rows = [row for row in rows if row["closed_at"] and row["snapshot_run_id"]]
|
||||||
|
if legacy_run_rows:
|
||||||
|
# Schema 11 persisted run identity only on the snapshot rows. Keep
|
||||||
|
# its rowid-based latest-run rule rather than falling back to live
|
||||||
|
# authority merely because schema 12 has no matching run record.
|
||||||
|
latest = max(
|
||||||
|
legacy_run_rows,
|
||||||
|
key=lambda row: (
|
||||||
|
int(row["_rowid"]),
|
||||||
|
row["closed_at"] or row["created_at"] or "",
|
||||||
|
row["snapshot_run_id"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
latest_run_id = latest["snapshot_run_id"]
|
||||||
|
latest_rows = [row for row in legacy_run_rows if row["snapshot_run_id"] == latest_run_id]
|
||||||
|
return latest_rows, {
|
||||||
|
"snapshot_run_id": latest_run_id,
|
||||||
|
"legacy_closed": True,
|
||||||
|
"is_correction": bool(latest["is_correction"]),
|
||||||
|
"correction_of_run_id": latest["correction_of_run_id"] or "",
|
||||||
|
"correction_reason": latest["correction_reason"] or "",
|
||||||
|
"close_status": "legacy_read_only",
|
||||||
|
"selection_policy": latest["selection_policy"] or "exact",
|
||||||
|
"closed_at": latest["closed_at"] or latest["created_at"] or "",
|
||||||
|
"total_snapshot_rows_for_basis_date": len(rows),
|
||||||
|
}
|
||||||
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 {}
|
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 {}
|
||||||
return _snapshot_rows_for_run(conn, run["id"])
|
return _snapshot_rows_for_run(conn, run["id"])
|
||||||
|
|
||||||
@@ -319,14 +377,15 @@ def _snapshot_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str,
|
|||||||
)
|
)
|
||||||
valuation_date = evidence.get("valuation_date") or snap.get("basis_date", "")
|
valuation_date = evidence.get("valuation_date") or snap.get("basis_date", "")
|
||||||
carried_forward = bool(evidence.get("carried_forward", False))
|
carried_forward = bool(evidence.get("carried_forward", False))
|
||||||
items.append(
|
public_item = _public_evidence({
|
||||||
_public_evidence({
|
|
||||||
"id": snap.get("id", ""),
|
"id": snap.get("id", ""),
|
||||||
"source_id": snap.get("source_id", ""),
|
"source_id": snap.get("source_id", ""),
|
||||||
"source_type": snap.get("source_type", ""),
|
"source_type": snap.get("source_type", ""),
|
||||||
"authority_source": "asset_snapshots",
|
"authority_source": "asset_snapshots",
|
||||||
"valuation_source": snap.get("valuation_source", ""),
|
"valuation_source": snap.get("valuation_source", ""),
|
||||||
"name": snap.get("name", ""),
|
"name": snap.get("name", ""),
|
||||||
|
"currency": snap.get("currency", ""),
|
||||||
|
"valuation_id": snap.get("valuation_id", ""),
|
||||||
"liquidity_bucket": bucket,
|
"liquidity_bucket": bucket,
|
||||||
"amount": int(snap["amount"]) if snap.get("amount") is not None else None,
|
"amount": int(snap["amount"]) if snap.get("amount") is not None else None,
|
||||||
"valuation_date": valuation_date,
|
"valuation_date": valuation_date,
|
||||||
@@ -341,7 +400,9 @@ def _snapshot_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str,
|
|||||||
"correction_of_run_id": snap.get("correction_of_run_id", ""),
|
"correction_of_run_id": snap.get("correction_of_run_id", ""),
|
||||||
"correction_reason": snap.get("correction_reason", ""),
|
"correction_reason": snap.get("correction_reason", ""),
|
||||||
})
|
})
|
||||||
)
|
if not snap.get("valuation_id"):
|
||||||
|
public_item.pop("valuation_id", None)
|
||||||
|
items.append(public_item)
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
@@ -353,7 +414,10 @@ def _closed_run_metadata(metadata: dict[str, Any]) -> dict[str, Any]:
|
|||||||
for item in exclusions:
|
for item in exclusions:
|
||||||
if item.get("candidate"):
|
if item.get("candidate"):
|
||||||
key = (item.get("source_type", ""), item.get("source_id", ""))
|
key = (item.get("source_type", ""), item.get("source_id", ""))
|
||||||
warning = candidate_warnings.setdefault(key, {"code": "unverified_candidate", "source_type": key[0], "source_id": key[1], "candidates": []})
|
warning_shape = {"code": "unverified_candidate", "source_type": key[0], "source_id": key[1], "candidates": []}
|
||||||
|
if item.get("blocking") is False:
|
||||||
|
warning_shape["blocking"] = False
|
||||||
|
warning = candidate_warnings.setdefault(key, warning_shape)
|
||||||
warning["candidates"].append(item["candidate"])
|
warning["candidates"].append(item["candidate"])
|
||||||
else:
|
else:
|
||||||
warnings.append({"code": item.get("reason", "excluded_item"), "source_type": item.get("source_type", ""), "source_id": item.get("source_id", ""), "currency": item.get("currency", "KRW")})
|
warnings.append({"code": item.get("reason", "excluded_item"), "source_type": item.get("source_type", ""), "source_id": item.get("source_id", ""), "currency": item.get("currency", "KRW")})
|
||||||
@@ -365,7 +429,7 @@ def _closed_run_metadata(metadata: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"snapshot_run_id": metadata.get("snapshot_run_id", ""),
|
"snapshot_run_id": metadata.get("snapshot_run_id", ""),
|
||||||
"close_status": metadata.get("close_status", ""),
|
"close_status": metadata.get("close_status", ""),
|
||||||
"selection_policy": metadata.get("selection_policy", ""),
|
"selection_policy": metadata.get("selection_policy", ""),
|
||||||
"approval": {k: metadata.get(k, "") for k in ("approved_by", "approved_at", "approval_reason", "approval_reference")},
|
"approval": {} if metadata.get("legacy_closed") else {k: metadata.get(k, "") for k in ("approved_by", "approved_at", "approval_reason", "approval_reference")},
|
||||||
"correction": {k: metadata.get(k, "") for k in ("is_correction", "correction_of_run_id", "correction_reason")},
|
"correction": {k: metadata.get(k, "") for k in ("is_correction", "correction_of_run_id", "correction_reason")},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,6 +472,14 @@ def _closed_warnings(metadata: dict[str, Any], basis_date: str, rows: list[sqlit
|
|||||||
valuation_date = str(evidence.get("valuation_date") or "")[:10]
|
valuation_date = str(evidence.get("valuation_date") or "")[:10]
|
||||||
if valuation_date and valuation_date < basis_date:
|
if valuation_date and valuation_date < basis_date:
|
||||||
warnings.append({"code": "stale_valuation", "source_type": evidence.get("source_type", ""), "source_id": evidence.get("source_id", ""), "name": row.get("name", ""), "valuation_date": valuation_date, "basis_date": basis_date})
|
warnings.append({"code": "stale_valuation", "source_type": evidence.get("source_type", ""), "source_id": evidence.get("source_id", ""), "name": row.get("name", ""), "valuation_date": valuation_date, "basis_date": basis_date})
|
||||||
|
previous_rows = int(metadata.get("previous_snapshot_row_count") or 0)
|
||||||
|
if previous_rows:
|
||||||
|
warnings.append({
|
||||||
|
"code": "existing_snapshot_rows",
|
||||||
|
"basis_date": basis_date,
|
||||||
|
"snapshot_row_count": previous_rows,
|
||||||
|
"policy": "append_only_correction_required",
|
||||||
|
})
|
||||||
return warnings
|
return warnings
|
||||||
|
|
||||||
|
|
||||||
@@ -607,7 +679,6 @@ def preview_asset_snapshot_close(basis_date: str = "", db_path: str | None = Non
|
|||||||
return fail("validation_error", str(e))
|
return fail("validation_error", str(e))
|
||||||
with read_db(db_path) as conn:
|
with read_db(db_path) as conn:
|
||||||
preview = _snapshot_preview_from_conn(conn, basis, selection_policy)
|
preview = _snapshot_preview_from_conn(conn, basis, selection_policy)
|
||||||
preview.pop("_included_items", None)
|
|
||||||
preview["selection_policy"] = selection_policy
|
preview["selection_policy"] = selection_policy
|
||||||
approval_eligible = [w for w in preview["warnings"] if w.get("blocking", True) and w.get("code", "").startswith(("missing_", "future_", "unknown_", "unverified", "ambiguous_"))]
|
approval_eligible = [w for w in preview["warnings"] if w.get("blocking", True) and w.get("code", "").startswith(("missing_", "future_", "unknown_", "unverified", "ambiguous_"))]
|
||||||
carried_items = [item for item in preview["included_items"] if item.get("carried_forward")]
|
carried_items = [item for item in preview["included_items"] if item.get("carried_forward")]
|
||||||
@@ -618,7 +689,10 @@ def preview_asset_snapshot_close(basis_date: str = "", db_path: str | None = Non
|
|||||||
preview["close_status"] = "incomplete_draft"
|
preview["close_status"] = "incomplete_draft"
|
||||||
else:
|
else:
|
||||||
preview["close_status"] = "complete_exact"
|
preview["close_status"] = "complete_exact"
|
||||||
|
# Fingerprint the private authority collection before redacting it from
|
||||||
|
# the public preview. Close performs the same calculation.
|
||||||
preview["preview_fingerprint"] = _preview_fingerprint(preview, basis, selection_policy)
|
preview["preview_fingerprint"] = _preview_fingerprint(preview, basis, selection_policy)
|
||||||
|
preview.pop("_included_items", None)
|
||||||
preview.pop("_candidate_state_hashes", None)
|
preview.pop("_candidate_state_hashes", None)
|
||||||
return ok(**preview)
|
return ok(**preview)
|
||||||
|
|
||||||
@@ -638,6 +712,8 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
|
|||||||
if preview_fingerprint and preview_fingerprint != _preview_fingerprint(preview, basis, selection_policy):
|
if preview_fingerprint and preview_fingerprint != _preview_fingerprint(preview, basis, selection_policy):
|
||||||
return fail("authority_drift", "asset authority changed since preview", basis_date=basis)
|
return fail("authority_drift", "asset authority changed since preview", basis_date=basis)
|
||||||
latest_rows, latest_meta = _snapshot_rows_for_latest_run(conn, basis)
|
latest_rows, latest_meta = _snapshot_rows_for_latest_run(conn, basis)
|
||||||
|
if latest_meta.get("evidence_integrity_error"):
|
||||||
|
return fail("data_integrity_error", "snapshot evidence is malformed", snapshot_run_id=latest_meta.get("snapshot_run_id", ""))
|
||||||
legacy_snapshot_rows = bool(latest_rows and not latest_meta.get("snapshot_run_id"))
|
legacy_snapshot_rows = bool(latest_rows and not latest_meta.get("snapshot_run_id"))
|
||||||
if legacy_snapshot_rows and not correction_reason.strip():
|
if legacy_snapshot_rows and not correction_reason.strip():
|
||||||
return fail(
|
return fail(
|
||||||
@@ -649,9 +725,10 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
|
|||||||
missing_exclusions = [w for w in preview["warnings"] if w.get("blocking", True) and w.get("code", "").startswith(("missing_", "future_", "unknown_", "unverified", "ambiguous_"))]
|
missing_exclusions = [w for w in preview["warnings"] if w.get("blocking", True) and w.get("code", "").startswith(("missing_", "future_", "unknown_", "unverified", "ambiguous_"))]
|
||||||
carried_items = [item for item in preview["included_items"] if item.get("carried_forward")]
|
carried_items = [item for item in preview["included_items"] if item.get("carried_forward")]
|
||||||
conditional = bool(missing_exclusions or carried_items)
|
conditional = bool(missing_exclusions or carried_items)
|
||||||
fingerprint_items = preview.get("_included_items", preview["included_items"])
|
authority_fingerprint = _preview_fingerprint(preview, basis, selection_policy)
|
||||||
fingerprint_payload = {"basis_date": basis, "source_note": source_note, "memo": memo, "correction_reason": correction_reason, "selection_policy": selection_policy, "allow_missing_exclusions": allow_missing_exclusions, "allow_as_of_carry_forward": allow_as_of_carry_forward, "approved_by": approved_by, "approval_reason": approval_reason, "approval_reference": approval_reference, "included": [{**{k: i.get(k) for k in ("source_type", "source_id", "amount", "currency", "valuation_id", "valuation_date", "verification_status")}, "state_hash": _evidence_state_hash(i)} for i in fingerprint_items], "excluded": [{**{k: i.get(k) for k in ("source_type", "source_id", "reason", "valuation_date", "verification_status")}, "state_hash": _evidence_state_hash(i)} for i in preview["excluded_items"]], "warnings": [warning for warning in preview["warnings"] if warning.get("code") != "existing_snapshot_rows"], "candidate_state_hashes": preview["_candidate_state_hashes"]}
|
request_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}
|
||||||
fingerprint = hashlib.sha256(json.dumps(fingerprint_payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()).hexdigest()
|
request_fingerprint = hashlib.sha256(json.dumps(request_payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()).hexdigest()
|
||||||
|
fingerprint = json.dumps({"authority": authority_fingerprint, "request": request_fingerprint}, sort_keys=True, separators=(",", ":"))
|
||||||
if missing_exclusions and (not allow_missing_exclusions or not approved_by.strip() or not approval_reason.strip() or not approval_reference.strip()):
|
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")
|
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()):
|
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()):
|
||||||
@@ -659,8 +736,53 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
|
|||||||
if idempotency_key:
|
if idempotency_key:
|
||||||
existing_run = conn.execute("SELECT * FROM asset_snapshot_runs WHERE idempotency_key=?", (idempotency_key,)).fetchone()
|
existing_run = conn.execute("SELECT * FROM asset_snapshot_runs WHERE idempotency_key=?", (idempotency_key,)).fetchone()
|
||||||
if existing_run:
|
if existing_run:
|
||||||
if existing_run["request_fingerprint"] == fingerprint:
|
try:
|
||||||
|
stored_fingerprint = json.loads(existing_run["request_fingerprint"])
|
||||||
|
except (TypeError, json.JSONDecodeError):
|
||||||
|
stored_fingerprint = {"authority": existing_run["request_fingerprint"], "request": ""}
|
||||||
|
latest_basis_run = conn.execute(
|
||||||
|
"SELECT id, correction_of_run_id, request_fingerprint FROM asset_snapshot_runs WHERE basis_date=? ORDER BY rowid DESC LIMIT 1",
|
||||||
|
(basis,),
|
||||||
|
).fetchone()
|
||||||
|
run_in_correction_chain = False
|
||||||
|
seen_run_ids: set[str] = set()
|
||||||
|
chain_run = latest_basis_run
|
||||||
|
while chain_run and chain_run["id"] not in seen_run_ids:
|
||||||
|
if chain_run["id"] == existing_run["id"]:
|
||||||
|
run_in_correction_chain = True
|
||||||
|
break
|
||||||
|
seen_run_ids.add(chain_run["id"])
|
||||||
|
parent_id = chain_run["correction_of_run_id"]
|
||||||
|
chain_run = (
|
||||||
|
conn.execute(
|
||||||
|
"SELECT id, correction_of_run_id FROM asset_snapshot_runs WHERE id=?",
|
||||||
|
(parent_id,),
|
||||||
|
).fetchone()
|
||||||
|
if parent_id
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
latest_authority_fingerprint = json.loads(
|
||||||
|
latest_basis_run["request_fingerprint"]
|
||||||
|
).get("authority") if latest_basis_run else None
|
||||||
|
except (TypeError, json.JSONDecodeError, AttributeError):
|
||||||
|
latest_authority_fingerprint = None
|
||||||
|
immutable_replay_run = bool(
|
||||||
|
run_in_correction_chain
|
||||||
|
and latest_basis_run
|
||||||
|
and latest_authority_fingerprint == authority_fingerprint
|
||||||
|
and (
|
||||||
|
latest_basis_run["id"] != existing_run["id"]
|
||||||
|
or existing_run["correction_of_run_id"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if stored_fingerprint.get("request") == request_fingerprint and (
|
||||||
|
stored_fingerprint.get("authority") == authority_fingerprint
|
||||||
|
or immutable_replay_run
|
||||||
|
):
|
||||||
replay_rows, replay_meta = _snapshot_rows_for_run(conn, existing_run["id"])
|
replay_rows, replay_meta = _snapshot_rows_for_run(conn, existing_run["id"])
|
||||||
|
if replay_meta.get("evidence_integrity_error"):
|
||||||
|
return fail("data_integrity_error", "snapshot evidence is malformed", snapshot_run_id=existing_run["id"])
|
||||||
replay_items = [_public_evidence(item) for item in replay_meta.get("applied_evidence", [])]
|
replay_items = [_public_evidence(item) for item in replay_meta.get("applied_evidence", [])]
|
||||||
replay_warnings = _closed_warnings(replay_meta, basis, replay_rows)
|
replay_warnings = _closed_warnings(replay_meta, basis, replay_rows)
|
||||||
replay_public = _closed_run_metadata(replay_meta)
|
replay_public = _closed_run_metadata(replay_meta)
|
||||||
@@ -679,13 +801,16 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
|
|||||||
close_status=existing_run["close_status"],
|
close_status=existing_run["close_status"],
|
||||||
selection_policy=existing_run["selection_policy"],
|
selection_policy=existing_run["selection_policy"],
|
||||||
inserted_count=len(replay_rows),
|
inserted_count=len(replay_rows),
|
||||||
inserted_snapshot_ids=[row["id"] for row in replay_rows],
|
inserted_snapshot_ids=[
|
||||||
|
next((row["id"] for row in replay_rows if row["source_type"] == item.get("source_type") and row["source_id"] == item.get("source_id")), "")
|
||||||
|
for item in replay_meta.get("applied_evidence", [])
|
||||||
|
],
|
||||||
warnings=replay_warnings,
|
warnings=replay_warnings,
|
||||||
carried_items=replay_public["carried_items"],
|
carried_items=replay_public["carried_items"],
|
||||||
approval=approval, correction=correction,
|
approval=approval, correction=correction,
|
||||||
provenance=_canonical_provenance(
|
provenance=_canonical_provenance(
|
||||||
valuation_date=replay_provenance["valuation_date"],
|
valuation_date=(replay_items[0].get("valuation_date", "") if replay_items else ""),
|
||||||
age_days=replay_provenance["age_days"],
|
age_days=(replay_items[0].get("age_days") if replay_items else None),
|
||||||
carried=replay_provenance["carried_forward"],
|
carried=replay_provenance["carried_forward"],
|
||||||
verification_status=replay_provenance["verification_status"],
|
verification_status=replay_provenance["verification_status"],
|
||||||
evidence_kind=replay_provenance["evidence_kind"],
|
evidence_kind=replay_provenance["evidence_kind"],
|
||||||
@@ -740,7 +865,7 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
snapshot_ids.append(snapshot_id)
|
snapshot_ids.append(snapshot_id)
|
||||||
applied_evidence = [{k: item.get(k) for k in ("id", "source_type", "source_id", "authority_source", "name", "type", "liquidity_bucket", "active", "amount", "valuation_amount", "last_balance", "currency", "valuation_id", "valuation_date", "age_days", "carried_forward", "verification_status", "evidence_kind") if item.get(k) is not None} for item in included]
|
applied_evidence = [{k: item.get(k) for k in ("id", "source_type", "source_id", "authority_source", "name", "type", "liquidity_bucket", "active", "amount", "valuation_amount", "last_balance", "last_synced_at", "currency", "valuation_id", "valuation_kind", "valuation_date", "age_days", "carried_forward", "verification_status", "evidence_kind", "unverified_candidates") if item.get(k) is not None} for item in included]
|
||||||
excluded_evidence = [{k: item.get(k) for k in ("source_type", "source_id", "reason", "currency")} for item in preview.get("excluded_items", [])]
|
excluded_evidence = [{k: item.get(k) for k in ("source_type", "source_id", "reason", "currency")} for item in preview.get("excluded_items", [])]
|
||||||
excluded_evidence.extend(
|
excluded_evidence.extend(
|
||||||
{
|
{
|
||||||
@@ -748,6 +873,7 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
|
|||||||
"source_id": warning.get("source_id", ""),
|
"source_id": warning.get("source_id", ""),
|
||||||
"reason": "unverified_candidate",
|
"reason": "unverified_candidate",
|
||||||
"candidate": candidate,
|
"candidate": candidate,
|
||||||
|
"blocking": warning.get("blocking", True),
|
||||||
"state_hash": next(
|
"state_hash": next(
|
||||||
state["state_hash"]
|
state["state_hash"]
|
||||||
for state in preview["_candidate_state_hashes"]
|
for state in preview["_candidate_state_hashes"]
|
||||||
@@ -846,17 +972,22 @@ def get_asset_snapshot_report(basis_date: str = "", db_path: str | None = None,
|
|||||||
return fail("validation_error", f"invalid date: {basis_date}")
|
return fail("validation_error", f"invalid date: {basis_date}")
|
||||||
with read_db(db_path) as conn:
|
with read_db(db_path) as conn:
|
||||||
rows, close_metadata = _snapshot_rows_for_latest_run(conn, basis)
|
rows, close_metadata = _snapshot_rows_for_latest_run(conn, basis)
|
||||||
|
if close_metadata.get("evidence_integrity_error"):
|
||||||
|
return fail("data_integrity_error", "snapshot evidence is malformed", snapshot_run_id=close_metadata.get("snapshot_run_id", ""))
|
||||||
has_close_run = bool(close_metadata.get("snapshot_run_id"))
|
has_close_run = bool(close_metadata.get("snapshot_run_id"))
|
||||||
|
legacy_closed = bool(close_metadata.get("legacy_closed"))
|
||||||
snapshots = _snapshot_items(conn, basis) if rows else []
|
snapshots = _snapshot_items(conn, basis) if rows else []
|
||||||
legacy_snapshots = snapshots if rows and not has_close_run else []
|
legacy_snapshots = snapshots if rows and not has_close_run else []
|
||||||
if has_close_run:
|
if has_close_run or legacy_closed:
|
||||||
run_public = _closed_run_metadata(close_metadata)
|
run_public = _closed_run_metadata(close_metadata)
|
||||||
run_public["warnings"] = _closed_warnings(close_metadata, basis, rows)
|
run_public["warnings"] = _closed_warnings(close_metadata, basis, rows)
|
||||||
|
if legacy_closed:
|
||||||
|
run_public["approval"] = {}
|
||||||
summary = _summarize_valuation_items(snapshots, run_public["excluded_items"])
|
summary = _summarize_valuation_items(snapshots, run_public["excluded_items"])
|
||||||
evidence_provenance = _evidence_provenance(close_metadata.get("applied_evidence", []), basis)
|
evidence_provenance = _evidence_provenance(close_metadata.get("applied_evidence", []), basis)
|
||||||
return _ok_with_provenance_aliases(
|
return _ok_with_provenance_aliases(
|
||||||
basis_date=basis,
|
basis_date=basis,
|
||||||
source="asset_snapshots",
|
source="legacy_asset_snapshots" if legacy_closed else "asset_snapshots",
|
||||||
is_closed_snapshot=True,
|
is_closed_snapshot=True,
|
||||||
snapshot_run_id=close_metadata.get("snapshot_run_id", ""),
|
snapshot_run_id=close_metadata.get("snapshot_run_id", ""),
|
||||||
snapshot_count=len(snapshots),
|
snapshot_count=len(snapshots),
|
||||||
@@ -945,16 +1076,21 @@ def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: i
|
|||||||
return fail("validation_error", str(e))
|
return fail("validation_error", str(e))
|
||||||
with read_db(db_path) as conn:
|
with read_db(db_path) as conn:
|
||||||
basis_rows = conn.execute(
|
basis_rows = conn.execute(
|
||||||
"""SELECT DISTINCT basis_date FROM asset_snapshots
|
"""SELECT basis_date FROM asset_snapshots
|
||||||
|
WHERE basis_date>=? AND basis_date<=?
|
||||||
|
UNION
|
||||||
|
SELECT basis_date FROM asset_snapshot_runs
|
||||||
WHERE basis_date>=? AND basis_date<=?
|
WHERE basis_date>=? AND basis_date<=?
|
||||||
ORDER BY basis_date ASC""",
|
ORDER BY basis_date ASC""",
|
||||||
(start_norm, end_norm),
|
(start_norm, end_norm, start_norm, end_norm),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
points = []
|
points = []
|
||||||
for row in basis_rows:
|
for row in basis_rows:
|
||||||
basis = row["basis_date"]
|
basis = row["basis_date"]
|
||||||
items = _snapshot_items(conn, basis)
|
|
||||||
_, metadata = _snapshot_rows_for_latest_run(conn, basis)
|
_, metadata = _snapshot_rows_for_latest_run(conn, basis)
|
||||||
|
if metadata.get("evidence_integrity_error"):
|
||||||
|
return fail("data_integrity_error", "snapshot evidence is malformed", snapshot_run_id=metadata.get("snapshot_run_id", ""))
|
||||||
|
items = _snapshot_items(conn, basis)
|
||||||
run_public = _closed_run_metadata(metadata)
|
run_public = _closed_run_metadata(metadata)
|
||||||
run_public["warnings"] = _closed_warnings(metadata, basis, conn.execute("SELECT rowid AS _rowid, * FROM asset_snapshots WHERE basis_date=? ORDER BY created_at ASC, name ASC", (basis,)).fetchall())
|
run_public["warnings"] = _closed_warnings(metadata, basis, conn.execute("SELECT rowid AS _rowid, * FROM asset_snapshots WHERE basis_date=? ORDER BY created_at ASC, name ASC", (basis,)).fetchall())
|
||||||
run_provenance = _evidence_provenance(metadata.get("applied_evidence", []), basis)
|
run_provenance = _evidence_provenance(metadata.get("applied_evidence", []), basis)
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "jarca-ledger-mcp"
|
name = "jarca-ledger-mcp"
|
||||||
version = "0.1.4"
|
version = "0.1.5"
|
||||||
description = "Jarca household ledger MCP server for transactions, accounts, budgets, cashflow, and investable cashflow analysis"
|
description = "Jarca household ledger MCP server for transactions, accounts, budgets, cashflow, and investable cashflow analysis"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@@ -1,4 +1,41 @@
|
|||||||
|
import sqlite3
|
||||||
|
|
||||||
from jarca_ledger_mcp import service
|
from jarca_ledger_mcp import service
|
||||||
|
from jarca_ledger_mcp.application.account_valuation_service import record_account_valuation_candidate
|
||||||
|
from jarca_ledger_mcp.application.imports_service import _record_import_valuation_candidate
|
||||||
|
|
||||||
|
|
||||||
|
def test_bank_to_loan_conversion_rejects_existing_undefined_valuation_without_mutation(tmp_path):
|
||||||
|
path = str(tmp_path / "ledger.sqlite3")
|
||||||
|
created = service.create_ledger_account(
|
||||||
|
"은행", type="bank", last_balance=100, last_synced_at="2026-06-30",
|
||||||
|
last_balance_basis_date="2026-06-30", db_path=path,
|
||||||
|
)
|
||||||
|
account_id = created["account"]["id"]
|
||||||
|
before = service.list_account_valuations(account_id, db_path=path)
|
||||||
|
assert before["count"] == 1
|
||||||
|
assert before["valuations"][0]["representative_liability_definition"] is None
|
||||||
|
|
||||||
|
rejected = service.update_ledger_account(account_id, type="loan", db_path=path)
|
||||||
|
|
||||||
|
assert rejected["success"] is False
|
||||||
|
assert rejected["error_code"] == "validation_error"
|
||||||
|
accounts = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"]
|
||||||
|
assert next(account for account in accounts if account["id"] == account_id)["type"] == "bank"
|
||||||
|
after = service.list_account_valuations(account_id, db_path=path)
|
||||||
|
assert after["count"] == 1
|
||||||
|
assert after["valuations"] == before["valuations"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_bank_to_loan_conversion_without_valuations_remains_allowed(tmp_path):
|
||||||
|
path = str(tmp_path / "ledger.sqlite3")
|
||||||
|
account_id = service.create_ledger_account("은행", type="bank", db_path=path)["account"]["id"]
|
||||||
|
|
||||||
|
converted = service.update_ledger_account(account_id, type="loan", db_path=path)
|
||||||
|
|
||||||
|
assert converted["success"] is True
|
||||||
|
assert converted["account"]["type"] == "loan"
|
||||||
|
assert service.list_account_valuations(account_id, db_path=path)["count"] == 0
|
||||||
|
|
||||||
|
|
||||||
def test_loan_balance_requires_representative_liability_definition(tmp_path):
|
def test_loan_balance_requires_representative_liability_definition(tmp_path):
|
||||||
@@ -91,3 +128,104 @@ def test_correction_replays_by_evidence_and_conflicts_on_changed_payload(tmp_pat
|
|||||||
assert changed["success"] is False
|
assert changed["success"] is False
|
||||||
assert changed["error_code"] == "correction_conflict"
|
assert changed["error_code"] == "correction_conflict"
|
||||||
assert service.list_account_valuations(account, db_path=path)["count"] == 2
|
assert service.list_account_valuations(account, db_path=path)["count"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_direct_loan_candidate_requires_liability_definition_and_writes_nothing(tmp_path):
|
||||||
|
path = str(tmp_path / "ledger.sqlite3")
|
||||||
|
loan = service.create_ledger_account("대출", type="loan", db_path=path)["account"]["id"]
|
||||||
|
|
||||||
|
with sqlite3.connect(path) as conn:
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
rejected = record_account_valuation_candidate(
|
||||||
|
conn, loan, "2026-06-30", 100, "KRW", "import", "source", "candidate", "memo",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert rejected["success"] is False
|
||||||
|
assert rejected["error_code"] == "validation_error"
|
||||||
|
assert service.list_account_valuations(loan, db_path=path)["count"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_loan_candidate_requires_liability_definition_and_writes_nothing(tmp_path):
|
||||||
|
path = str(tmp_path / "ledger.sqlite3")
|
||||||
|
loan = service.create_ledger_account("대출", type="loan", db_path=path)["account"]["id"]
|
||||||
|
|
||||||
|
with sqlite3.connect(path) as conn:
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
assert _record_import_valuation_candidate(conn, loan, 100, "2026-06-30", "source-hash") is None
|
||||||
|
|
||||||
|
assert service.list_account_valuations(loan, db_path=path)["count"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_dated_loan_create_and_historical_update_preserve_current_cache_without_valuations(tmp_path):
|
||||||
|
path = str(tmp_path / "ledger.sqlite3")
|
||||||
|
created = service.create_ledger_account(
|
||||||
|
"대출", type="loan", last_balance=900, last_synced_at="2026-06-30",
|
||||||
|
last_balance_basis_date="2026-06-30", db_path=path,
|
||||||
|
)
|
||||||
|
loan = created["account"]["id"]
|
||||||
|
assert created["account"]["last_balance"] == 900
|
||||||
|
assert created["account"]["last_synced_at"] == "2026-06-30"
|
||||||
|
assert service.list_account_valuations(loan, db_path=path)["count"] == 0
|
||||||
|
|
||||||
|
updated = service.update_ledger_account(
|
||||||
|
loan, 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
|
||||||
|
assert updated["account"]["last_synced_at"] == "2026-06-30"
|
||||||
|
assert service.list_account_valuations(loan, db_path=path)["count"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_loan_cash_balance_and_total_value_are_rejected_without_writes(tmp_path):
|
||||||
|
path = str(tmp_path / "ledger.sqlite3")
|
||||||
|
loan = service.create_ledger_account("대출", type="loan", db_path=path)["account"]["id"]
|
||||||
|
|
||||||
|
for valuation_kind in ("cash_balance", "total_value"):
|
||||||
|
rejected = service.register_account_valuation(
|
||||||
|
loan, "2026-06-30", 100, valuation_kind=valuation_kind, 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
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_loan_candidate_still_succeeds_and_reuses_idempotently(tmp_path):
|
||||||
|
path = str(tmp_path / "ledger.sqlite3")
|
||||||
|
account = service.create_ledger_account("계좌", db_path=path)["account"]["id"]
|
||||||
|
|
||||||
|
with sqlite3.connect(path) as conn:
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
first = record_account_valuation_candidate(
|
||||||
|
conn, account, "2026-06-30", 100, "KRW", "import", "source", "candidate", "memo",
|
||||||
|
)
|
||||||
|
replay = record_account_valuation_candidate(
|
||||||
|
conn, account, "2026-06-30", 100, "KRW", "import", "source", "candidate", "memo",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert first["success"] is True
|
||||||
|
assert first["reused"] is False
|
||||||
|
assert replay["success"] is True
|
||||||
|
assert replay["reused"] is True
|
||||||
|
assert replay["valuation"]["id"] == first["valuation"]["id"]
|
||||||
|
assert service.list_account_valuations(account, db_path=path)["count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_candidate_invalid_amount_returns_controlled_error_with_existing_row(tmp_path):
|
||||||
|
path = str(tmp_path / "ledger.sqlite3")
|
||||||
|
account = service.create_ledger_account("계좌", db_path=path)["account"]["id"]
|
||||||
|
|
||||||
|
with sqlite3.connect(path) as conn:
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
first = record_account_valuation_candidate(
|
||||||
|
conn, account, "2026-06-30", 100, "KRW", "import", "source", "candidate", "memo",
|
||||||
|
)
|
||||||
|
rejected = record_account_valuation_candidate(
|
||||||
|
conn, account, "2026-06-30", "not-an-integer", "KRW", "import", "source", "candidate", "memo",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert first["success"] is True
|
||||||
|
assert rejected["success"] is False
|
||||||
|
assert rejected["error_code"] == "validation_error"
|
||||||
|
assert service.list_account_valuations(account, db_path=path)["count"] == 1
|
||||||
|
|||||||
@@ -1,10 +1,133 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
from jarca_ledger_mcp import service
|
from jarca_ledger_mcp import service
|
||||||
|
|
||||||
|
|
||||||
|
def test_replay_preserves_redacted_unverified_candidates_after_correction(tmp_path):
|
||||||
|
db_path = str(tmp_path / "ledger.sqlite3")
|
||||||
|
account = service.create_ledger_account("투자 계좌", type="investment", db_path=db_path)
|
||||||
|
account_id = account["account"]["id"]
|
||||||
|
verified = service.register_account_valuation(
|
||||||
|
account_id, "2026-06-30", 100, verification_status="verified",
|
||||||
|
idempotency_key="valuation-verified", db_path=db_path,
|
||||||
|
)
|
||||||
|
candidate = service.register_account_valuation(
|
||||||
|
account_id, "2026-06-30", 110, verification_status="unverified",
|
||||||
|
idempotency_key="valuation-candidate", db_path=db_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
first = service.close_asset_snapshot(
|
||||||
|
"2026-06-30", idempotency_key="candidate-close-a", db_path=db_path,
|
||||||
|
)
|
||||||
|
assert first["success"] is True
|
||||||
|
assert first["included_items"][0]["unverified_candidates"] == [{
|
||||||
|
"id": candidate["valuation"]["id"],
|
||||||
|
"basis_date": "2026-06-30",
|
||||||
|
"currency": "KRW",
|
||||||
|
"valuation_kind": "balance",
|
||||||
|
"verification_status": "unverified",
|
||||||
|
}]
|
||||||
|
assert {warning["blocking"] for warning in first["warnings"] if warning["code"] == "unverified_candidate"} == {False}
|
||||||
|
assert first["included_items"][0]["valuation_id"] == verified["valuation"]["id"]
|
||||||
|
with sqlite3.connect(db_path) as conn:
|
||||||
|
persisted = conn.execute("SELECT applied_evidence, excluded_evidence FROM asset_snapshot_runs WHERE id=?", (first["snapshot_run_id"],)).fetchone()
|
||||||
|
applied_evidence, excluded_evidence = map(json.loads, persisted)
|
||||||
|
assert applied_evidence[0]["unverified_candidates"] == first["included_items"][0]["unverified_candidates"]
|
||||||
|
candidate_evidence = [item for item in excluded_evidence if item.get("reason") == "unverified_candidate"]
|
||||||
|
assert candidate_evidence[0]["candidate"] == first["included_items"][0]["unverified_candidates"][0]
|
||||||
|
assert candidate_evidence[0]["blocking"] is False
|
||||||
|
assert candidate_evidence[0]["state_hash"]
|
||||||
|
|
||||||
|
corrected = service.close_asset_snapshot(
|
||||||
|
"2026-06-30", correction_reason="preserve evidence", idempotency_key="candidate-close-b",
|
||||||
|
db_path=db_path,
|
||||||
|
)
|
||||||
|
assert corrected["success"] is True
|
||||||
|
|
||||||
|
with sqlite3.connect(db_path) as conn:
|
||||||
|
before_replay = conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0]
|
||||||
|
before_runs = conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0]
|
||||||
|
replay = service.close_asset_snapshot(
|
||||||
|
"2026-06-30", idempotency_key="candidate-close-a", db_path=db_path,
|
||||||
|
)
|
||||||
|
with sqlite3.connect(db_path) as conn:
|
||||||
|
after_replay = conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0]
|
||||||
|
after_runs = conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0]
|
||||||
|
|
||||||
|
assert replay == first
|
||||||
|
assert replay["included_items"] == first["included_items"]
|
||||||
|
assert replay["warnings"] == first["warnings"]
|
||||||
|
assert after_replay == before_replay
|
||||||
|
assert after_runs == before_runs
|
||||||
|
|
||||||
|
|
||||||
|
def test_replay_preserves_blocking_candidate_warning_shape(tmp_path):
|
||||||
|
db_path = str(tmp_path / "ledger.sqlite3")
|
||||||
|
account_id = service.create_ledger_account(
|
||||||
|
"미확인 계좌", type="investment", db_path=db_path,
|
||||||
|
)["account"]["id"]
|
||||||
|
service.register_account_valuation(
|
||||||
|
account_id, "2026-06-30", 110, verification_status="unverified",
|
||||||
|
idempotency_key="only-candidate", db_path=db_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
first = service.close_asset_snapshot(
|
||||||
|
"2026-06-30", idempotency_key="blocking-candidate-close",
|
||||||
|
allow_missing_exclusions=True, approved_by="reviewer",
|
||||||
|
approval_reason="exclude unverified evidence",
|
||||||
|
approval_reference="synthetic-approval", db_path=db_path,
|
||||||
|
)
|
||||||
|
assert first["success"] is True
|
||||||
|
warning = next(item for item in first["warnings"] if item["code"] == "unverified_candidate")
|
||||||
|
assert "blocking" not in warning
|
||||||
|
|
||||||
|
replay = service.close_asset_snapshot(
|
||||||
|
"2026-06-30", idempotency_key="blocking-candidate-close",
|
||||||
|
allow_missing_exclusions=True, approved_by="reviewer",
|
||||||
|
approval_reason="exclude unverified evidence",
|
||||||
|
approval_reference="synthetic-approval", db_path=db_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert replay == first
|
||||||
|
replay_warning = next(item for item in replay["warnings"] if item["code"] == "unverified_candidate")
|
||||||
|
assert "blocking" not in replay_warning
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_persisted_evidence_fails_closed_without_snapshot_writes(tmp_path):
|
||||||
|
db_path = str(tmp_path / "ledger.sqlite3")
|
||||||
|
service.create_manual_asset("현금", 100, "2026-06-30", "cash", db_path=db_path)
|
||||||
|
first = service.close_asset_snapshot(
|
||||||
|
"2026-06-30", idempotency_key="malformed-evidence-close", db_path=db_path,
|
||||||
|
)
|
||||||
|
assert first["success"] is True
|
||||||
|
|
||||||
|
with sqlite3.connect(db_path) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE asset_snapshot_runs SET excluded_evidence=? WHERE id=?",
|
||||||
|
("{malformed", first["snapshot_run_id"]),
|
||||||
|
)
|
||||||
|
before_rows = conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0]
|
||||||
|
before_runs = conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0]
|
||||||
|
|
||||||
|
report = service.get_asset_snapshot_report("2026-06-30", db_path=db_path)
|
||||||
|
replay = service.close_asset_snapshot(
|
||||||
|
"2026-06-30", idempotency_key="malformed-evidence-close", db_path=db_path,
|
||||||
|
)
|
||||||
|
timeseries = service.get_net_worth_timeseries(
|
||||||
|
"2026-06-01", "2026-06-30", db_path=db_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
for result in (report, replay, timeseries):
|
||||||
|
assert result["success"] is False
|
||||||
|
assert result["error_code"] == "data_integrity_error"
|
||||||
|
with sqlite3.connect(db_path) as conn:
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0] == before_rows
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == before_runs
|
||||||
|
|
||||||
|
|
||||||
def test_exact_run_replay_after_correction_reconstructs_original_persisted_run(tmp_path):
|
def test_exact_run_replay_after_correction_reconstructs_original_persisted_run(tmp_path):
|
||||||
db_path = str(tmp_path / "ledger.sqlite3")
|
db_path = str(tmp_path / "ledger.sqlite3")
|
||||||
service.create_manual_asset("현금", 100, "2026-06-30", "cash", db_path=db_path)
|
service.create_manual_asset("현금", 100, "2026-06-30", "cash", db_path=db_path)
|
||||||
|
|||||||
@@ -65,6 +65,163 @@ def test_late_import_current_balance_is_authoritative_by_evidence_basis_date(mon
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_balance_with_basis_date_is_rejected_without_mutation_before_import(tmp_path, monkeypatch):
|
||||||
|
path = str(tmp_path / "ledger.sqlite3")
|
||||||
|
account = service.create_ledger_account("KB", institution="KB국민은행", db_path=path)["account"]["id"]
|
||||||
|
before = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0]
|
||||||
|
before_valuations = service.list_account_valuations(account, db_path=path)["valuations"]
|
||||||
|
|
||||||
|
rejected = service.update_ledger_account(
|
||||||
|
account,
|
||||||
|
last_balance="",
|
||||||
|
last_balance_basis_date="2026-07-01",
|
||||||
|
db_path=path,
|
||||||
|
)
|
||||||
|
|
||||||
|
after = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0]
|
||||||
|
after_valuations = service.list_account_valuations(account, db_path=path)["valuations"]
|
||||||
|
assert rejected["success"] is False
|
||||||
|
assert rejected["error_code"] == "validation_error"
|
||||||
|
assert after == before
|
||||||
|
assert after_valuations == before_valuations
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
imports_service,
|
||||||
|
"parse_kb_bank_pdf",
|
||||||
|
lambda *_: _parsed(_transaction("2026-06-30", 900, 1), "hash-valid"),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(imports_service, "now_iso", lambda: "2026-07-02T09:00:00")
|
||||||
|
imported = imports_service.import_kb_bank_pdf("synthetic", "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 imported["success"] is True
|
||||||
|
assert current["last_balance"] == 900
|
||||||
|
assert current["last_balance_basis_date"] == "2026-06-30"
|
||||||
|
assert {(item["basis_date"], item["amount"]) for item in valuations} == {("2026-06-30", 900)}
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_rejects_whitespace_or_non_integer_dated_balance_without_mutation(tmp_path):
|
||||||
|
path = str(tmp_path / "ledger.sqlite3")
|
||||||
|
baseline = service.create_ledger_account("baseline", db_path=path)
|
||||||
|
before = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"]
|
||||||
|
before_valuations = service.list_account_valuations(baseline["account"]["id"], db_path=path)["valuations"]
|
||||||
|
|
||||||
|
for invalid_balance in (" ", "not-an-integer", True, False, 1.2, -1.2):
|
||||||
|
rejected = service.create_ledger_account(
|
||||||
|
"invalid",
|
||||||
|
last_balance=invalid_balance,
|
||||||
|
last_balance_basis_date="2026-07-01",
|
||||||
|
db_path=path,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert rejected["success"] is False
|
||||||
|
assert rejected["error_code"] == "validation_error"
|
||||||
|
assert service.list_ledger_accounts(active_only=False, db_path=path)["accounts"] == before
|
||||||
|
assert service.list_account_valuations(baseline["account"]["id"], db_path=path)["valuations"] == before_valuations
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_rejects_bool_or_float_dated_balance_without_mutation(tmp_path):
|
||||||
|
path = str(tmp_path / "ledger.sqlite3")
|
||||||
|
account = service.create_ledger_account(
|
||||||
|
"baseline",
|
||||||
|
last_balance=900,
|
||||||
|
last_balance_basis_date="2026-07-01",
|
||||||
|
db_path=path,
|
||||||
|
)["account"]["id"]
|
||||||
|
before = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0]
|
||||||
|
before_valuations = service.list_account_valuations(account, db_path=path)["valuations"]
|
||||||
|
|
||||||
|
for invalid_balance in (True, False, 1.2, -1.2):
|
||||||
|
rejected = service.update_ledger_account(
|
||||||
|
account,
|
||||||
|
last_balance=invalid_balance,
|
||||||
|
last_balance_basis_date="2026-07-02",
|
||||||
|
db_path=path,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert rejected["success"] is False
|
||||||
|
assert rejected["error_code"] == "validation_error"
|
||||||
|
assert service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0] == before
|
||||||
|
assert service.list_account_valuations(account, db_path=path)["valuations"] == before_valuations
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_rejects_empty_undated_balance_without_mutation(tmp_path):
|
||||||
|
path = str(tmp_path / "ledger.sqlite3")
|
||||||
|
account = service.create_ledger_account(
|
||||||
|
"baseline",
|
||||||
|
last_balance=900,
|
||||||
|
last_balance_basis_date="2026-07-01",
|
||||||
|
db_path=path,
|
||||||
|
)["account"]["id"]
|
||||||
|
before = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0]
|
||||||
|
before_valuations = service.list_account_valuations(account, db_path=path)["valuations"]
|
||||||
|
|
||||||
|
rejected = service.update_ledger_account(account, last_balance="", db_path=path)
|
||||||
|
|
||||||
|
assert rejected["success"] is False
|
||||||
|
assert rejected["error_code"] == "validation_error"
|
||||||
|
assert service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0] == before
|
||||||
|
assert service.list_account_valuations(account, db_path=path)["valuations"] == before_valuations
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_preserves_zero_and_signed_integer_balance_semantics(tmp_path):
|
||||||
|
path = str(tmp_path / "ledger.sqlite3")
|
||||||
|
account = service.create_ledger_account("baseline", db_path=path)["account"]["id"]
|
||||||
|
|
||||||
|
zero = service.update_ledger_account(
|
||||||
|
account,
|
||||||
|
last_balance=" +0 ",
|
||||||
|
last_balance_basis_date="2026-07-01",
|
||||||
|
db_path=path,
|
||||||
|
)
|
||||||
|
signed = service.update_ledger_account(
|
||||||
|
account,
|
||||||
|
last_balance="-12",
|
||||||
|
last_balance_basis_date="2026-07-02",
|
||||||
|
db_path=path,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert zero["success"] is True
|
||||||
|
assert zero["account"]["last_balance"] == 0
|
||||||
|
assert signed["success"] is True
|
||||||
|
assert signed["account"]["last_balance"] == -12
|
||||||
|
assert signed["account"]["last_balance_basis_date"] == "2026-07-02"
|
||||||
|
assert [(item["basis_date"], item["amount"]) for item in service.list_account_valuations(account, db_path=path)["valuations"]] == [
|
||||||
|
("2026-07-01", 0),
|
||||||
|
("2026-07-02", -12),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_dated_balance_and_valid_import_recovery(tmp_path, monkeypatch):
|
||||||
|
path = str(tmp_path / "ledger.sqlite3")
|
||||||
|
zero = service.create_ledger_account(
|
||||||
|
"zero",
|
||||||
|
last_balance=0,
|
||||||
|
last_balance_basis_date="2026-06-01",
|
||||||
|
db_path=path,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert zero["success"] is True
|
||||||
|
assert zero["account"]["last_balance"] == 0
|
||||||
|
assert [(item["basis_date"], item["amount"]) for item in service.list_account_valuations(zero["account"]["id"], db_path=path)["valuations"]] == [("2026-06-01", 0)]
|
||||||
|
|
||||||
|
account = service.create_ledger_account("recoverable", db_path=path)["account"]["id"]
|
||||||
|
monkeypatch.setattr(
|
||||||
|
imports_service,
|
||||||
|
"parse_kb_bank_pdf",
|
||||||
|
lambda *_: _parsed(_transaction("2026-06-30", 900, 1), "hash-valid"),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(imports_service, "now_iso", lambda: "2026-07-02T09:00:00")
|
||||||
|
|
||||||
|
imported = imports_service.import_kb_bank_pdf("synthetic", "pw", account_id=account, db_path=path)
|
||||||
|
|
||||||
|
current = next(item for item in service.list_ledger_accounts(active_only=False, db_path=path)["accounts"] if item["id"] == account)
|
||||||
|
assert imported["success"] is True
|
||||||
|
assert current["last_balance"] == 900
|
||||||
|
assert current["last_balance_basis_date"] == "2026-06-30"
|
||||||
|
|
||||||
|
|
||||||
def test_account_write_sync_timestamp_does_not_infer_balance_basis_or_candidate(tmp_path):
|
def test_account_write_sync_timestamp_does_not_infer_balance_basis_or_candidate(tmp_path):
|
||||||
path = str(tmp_path / "ledger.sqlite3")
|
path = str(tmp_path / "ledger.sqlite3")
|
||||||
unknown = service.create_ledger_account(
|
unknown = service.create_ledger_account(
|
||||||
|
|||||||
@@ -104,6 +104,30 @@ def test_import_historical_kb_does_not_replace_newer_current_balance(monkeypatch
|
|||||||
assert service.list_account_valuations(account, db_path=path)["count"] == 2
|
assert service.list_account_valuations(account, db_path=path)["count"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_loan_import_preserves_transactions_and_current_cache_without_valuation_candidate(monkeypatch, tmp_path):
|
||||||
|
path = str(tmp_path / "ledger.sqlite3")
|
||||||
|
account = service.create_ledger_account("대출", type="loan", db_path=path)["account"]["id"]
|
||||||
|
monkeypatch.setattr(
|
||||||
|
imports_service,
|
||||||
|
"parse_kb_bank_pdf",
|
||||||
|
lambda *_: _parsed(
|
||||||
|
_fake_tx("2026-06-30", 700, kind="kb"),
|
||||||
|
"loan-kb",
|
||||||
|
{"final_balance": 700, "account_number_masked": ""},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = imports_service.import_kb_bank_pdf("ignored", "pw", account_id=account, db_path=path)
|
||||||
|
current = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0]
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["inserted_count"] == 1
|
||||||
|
assert current["last_balance"] == 700
|
||||||
|
assert current["last_balance_basis_date"] == "2026-06-30"
|
||||||
|
assert service.list_ledger_transactions(account_id=account, db_path=path)["count"] == 1
|
||||||
|
assert service.list_account_valuations(account, db_path=path)["count"] == 0
|
||||||
|
|
||||||
|
|
||||||
def test_import_historical_toss_bank_does_not_replace_newer_current_balance(monkeypatch, tmp_path):
|
def test_import_historical_toss_bank_does_not_replace_newer_current_balance(monkeypatch, tmp_path):
|
||||||
path = str(tmp_path / "ledger.sqlite3")
|
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, last_balance_basis_date="2026-06-30")["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"]
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from jarca_ledger_mcp import service
|
||||||
|
from jarca_ledger_mcp.application import imports_service
|
||||||
|
|
||||||
|
|
||||||
|
def _transaction(date, balance):
|
||||||
|
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"review63:{source_hash}",
|
||||||
|
raw_payload=lambda: "{}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_rejects_basis_only_without_mutation(tmp_path):
|
||||||
|
path = str(tmp_path / "ledger.sqlite3")
|
||||||
|
|
||||||
|
rejected = service.create_ledger_account(
|
||||||
|
"기준일만 있는 계좌",
|
||||||
|
last_balance_basis_date="2026-07-31",
|
||||||
|
db_path=path,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert rejected == {
|
||||||
|
"success": False,
|
||||||
|
"error_code": "validation_error",
|
||||||
|
"error": "last_balance_basis_date requires last_balance",
|
||||||
|
}
|
||||||
|
baseline = service.create_ledger_account("정상 계좌", db_path=path)
|
||||||
|
assert baseline["success"] is True
|
||||||
|
listed = service.list_ledger_accounts(active_only=False, db_path=path)
|
||||||
|
assert listed["count"] == 1
|
||||||
|
assert listed["accounts"][0]["name"] == "정상 계좌"
|
||||||
|
|
||||||
|
|
||||||
|
def test_basis_only_create_cannot_block_later_dated_import(tmp_path, monkeypatch):
|
||||||
|
path = str(tmp_path / "ledger.sqlite3")
|
||||||
|
rejected = service.create_ledger_account(
|
||||||
|
"기준일만 있는 계좌",
|
||||||
|
last_balance_basis_date="2026-07-31",
|
||||||
|
db_path=path,
|
||||||
|
)
|
||||||
|
assert rejected["success"] is False
|
||||||
|
|
||||||
|
account = service.create_ledger_account("정상 계좌", db_path=path)["account"]["id"]
|
||||||
|
monkeypatch.setattr(
|
||||||
|
imports_service,
|
||||||
|
"parse_kb_bank_pdf",
|
||||||
|
lambda *_: {
|
||||||
|
"transactions": [_transaction("2026-06-30", 50)],
|
||||||
|
"validation": {"ok": True},
|
||||||
|
"source_hash": "review63",
|
||||||
|
"metadata": {"final_balance": 50, "account_number_masked": ""},
|
||||||
|
"file_path": "/tmp/synthetic.pdf",
|
||||||
|
"page_count": 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
imported = imports_service.import_kb_bank_pdf(
|
||||||
|
"ignored", "pw", account_id=account, db_path=path,
|
||||||
|
)
|
||||||
|
|
||||||
|
current = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0]
|
||||||
|
assert imported["success"] is True
|
||||||
|
assert current["last_balance"] == 50
|
||||||
|
assert current["last_balance_basis_date"] == "2026-06-30"
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from jarca_ledger_mcp import service
|
||||||
|
|
||||||
|
|
||||||
|
def _approval_kwargs():
|
||||||
|
return {
|
||||||
|
"allow_missing_exclusions": True,
|
||||||
|
"approved_by": "reviewer",
|
||||||
|
"approval_reason": "reviewed",
|
||||||
|
"approval_reference": "review-63",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_f001_preview_fingerprint_uses_same_unredacted_authority_and_rejects_drift(tmp_path):
|
||||||
|
db_path = str(tmp_path / "ledger.sqlite3")
|
||||||
|
service.create_manual_asset("현금", 100, "2026-06-30", "cash", db_path=db_path)
|
||||||
|
|
||||||
|
preview = service.preview_asset_snapshot_close("2026-06-30", db_path=db_path)
|
||||||
|
closed = service.close_asset_snapshot(
|
||||||
|
"2026-06-30", preview_fingerprint=preview["preview_fingerprint"], db_path=db_path
|
||||||
|
)
|
||||||
|
assert closed["success"] is True
|
||||||
|
|
||||||
|
service.update_manual_asset(
|
||||||
|
next(iter(service.list_manual_assets(db_path=db_path)["manual_assets"]))["id"],
|
||||||
|
valuation_amount=101,
|
||||||
|
db_path=db_path,
|
||||||
|
)
|
||||||
|
with sqlite3.connect(db_path) as conn:
|
||||||
|
rows_before = conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0]
|
||||||
|
drifted = service.close_asset_snapshot(
|
||||||
|
"2026-06-30", preview_fingerprint=preview["preview_fingerprint"], db_path=db_path
|
||||||
|
)
|
||||||
|
with sqlite3.connect(db_path) as conn:
|
||||||
|
rows_after = conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0]
|
||||||
|
assert drifted["success"] is False
|
||||||
|
assert drifted["error_code"] == "authority_drift"
|
||||||
|
assert rows_after == rows_before
|
||||||
|
|
||||||
|
|
||||||
|
def test_f002_close_replay_is_byte_for_byte_equal_for_mixed_account_manual_as_of(tmp_path):
|
||||||
|
db_path = str(tmp_path / "ledger.sqlite3")
|
||||||
|
account = service.create_ledger_account(
|
||||||
|
"투자계좌", type="investment", currency="KRW", db_path=db_path
|
||||||
|
)
|
||||||
|
service.register_account_valuation(
|
||||||
|
account["account"]["id"], "2026-06-29", 200, valuation_kind="total_value",
|
||||||
|
verification_status="verified", idempotency_key="v-29", db_path=db_path,
|
||||||
|
)
|
||||||
|
service.create_manual_asset("보증금", 300, "2026-06-30", "real_estate", db_path=db_path)
|
||||||
|
|
||||||
|
first = service.close_asset_snapshot(
|
||||||
|
"2026-06-30", selection_policy="as_of", idempotency_key="mixed-replay",
|
||||||
|
allow_as_of_carry_forward=True, **_approval_kwargs(), db_path=db_path,
|
||||||
|
)
|
||||||
|
assert first["success"] is True
|
||||||
|
replay = service.close_asset_snapshot(
|
||||||
|
"2026-06-30", selection_policy="as_of", idempotency_key="mixed-replay",
|
||||||
|
allow_as_of_carry_forward=True, **_approval_kwargs(), db_path=db_path,
|
||||||
|
)
|
||||||
|
assert replay == first
|
||||||
|
|
||||||
|
|
||||||
|
def test_f002_correction_replay_preserves_full_initial_response(tmp_path):
|
||||||
|
db_path = str(tmp_path / "ledger.sqlite3")
|
||||||
|
service.create_manual_asset("현금", 100, "2026-06-30", "cash", db_path=db_path)
|
||||||
|
first = service.close_asset_snapshot(
|
||||||
|
"2026-06-30", idempotency_key="correction-initial", db_path=db_path
|
||||||
|
)
|
||||||
|
assert first["success"] is True
|
||||||
|
service.update_manual_asset(
|
||||||
|
next(iter(service.list_manual_assets(db_path=db_path)["manual_assets"]))["id"],
|
||||||
|
valuation_amount=120, db_path=db_path,
|
||||||
|
)
|
||||||
|
corrected = service.close_asset_snapshot(
|
||||||
|
"2026-06-30", correction_reason="updated valuation",
|
||||||
|
idempotency_key="correction-run", db_path=db_path,
|
||||||
|
)
|
||||||
|
assert corrected["success"] is True
|
||||||
|
assert any(warning["code"] == "existing_snapshot_rows" for warning in corrected["warnings"])
|
||||||
|
|
||||||
|
correction_replay = service.close_asset_snapshot(
|
||||||
|
"2026-06-30", correction_reason="updated valuation",
|
||||||
|
idempotency_key="correction-run", db_path=db_path,
|
||||||
|
)
|
||||||
|
assert correction_replay == corrected
|
||||||
|
|
||||||
|
initial_replay = service.close_asset_snapshot(
|
||||||
|
"2026-06-30", idempotency_key="correction-initial", db_path=db_path
|
||||||
|
)
|
||||||
|
assert initial_replay == first
|
||||||
|
|
||||||
|
|
||||||
|
def test_f006_initial_replay_survives_multiple_later_corrections(tmp_path):
|
||||||
|
db_path = str(tmp_path / "ledger.sqlite3")
|
||||||
|
service.create_manual_asset(
|
||||||
|
"현금 A", 100, "2026-06-30", "cash", db_path=db_path
|
||||||
|
)
|
||||||
|
service.create_manual_asset(
|
||||||
|
"현금 B", 200, "2026-06-30", "cash", db_path=db_path
|
||||||
|
)
|
||||||
|
assets = {
|
||||||
|
item["name"]: item["id"]
|
||||||
|
for item in service.list_manual_assets(db_path=db_path)["manual_assets"]
|
||||||
|
}
|
||||||
|
first = service.close_asset_snapshot(
|
||||||
|
"2026-06-30", idempotency_key="multi-initial", db_path=db_path
|
||||||
|
)
|
||||||
|
assert first["success"] is True
|
||||||
|
|
||||||
|
service.update_manual_asset(assets["현금 A"], valuation_amount=110, db_path=db_path)
|
||||||
|
first_correction = service.close_asset_snapshot(
|
||||||
|
"2026-06-30", correction_reason="first correction",
|
||||||
|
idempotency_key="multi-correction-1", db_path=db_path,
|
||||||
|
)
|
||||||
|
assert first_correction["success"] is True
|
||||||
|
|
||||||
|
service.update_manual_asset(assets["현금 A"], valuation_amount=120, db_path=db_path)
|
||||||
|
second_correction = service.close_asset_snapshot(
|
||||||
|
"2026-06-30", correction_reason="second correction",
|
||||||
|
idempotency_key="multi-correction-2", db_path=db_path,
|
||||||
|
)
|
||||||
|
assert second_correction["success"] is True
|
||||||
|
|
||||||
|
replay = service.close_asset_snapshot(
|
||||||
|
"2026-06-30", idempotency_key="multi-initial", db_path=db_path
|
||||||
|
)
|
||||||
|
assert replay == first
|
||||||
|
|
||||||
|
service.update_manual_asset(assets["현금 B"], valuation_amount=220, db_path=db_path)
|
||||||
|
drifted_replay = service.close_asset_snapshot(
|
||||||
|
"2026-06-30", idempotency_key="multi-initial", db_path=db_path
|
||||||
|
)
|
||||||
|
assert drifted_replay["success"] is False
|
||||||
|
assert drifted_replay["error_code"] == "idempotency_conflict"
|
||||||
|
|
||||||
|
|
||||||
|
def test_f003_zero_row_conditional_runs_are_timeseries_authority_with_aliases(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="zero-initial", **_approval_kwargs(), db_path=db_path
|
||||||
|
)
|
||||||
|
assert first["success"] is True
|
||||||
|
assert first["inserted_count"] == 0
|
||||||
|
assert first["total_assets"] == first["total_liabilities"] == first["net_worth"] == 0
|
||||||
|
|
||||||
|
corrected = service.close_asset_snapshot(
|
||||||
|
"2026-06-30", correction_reason="reconfirm zero", idempotency_key="zero-correction",
|
||||||
|
**_approval_kwargs(), db_path=db_path,
|
||||||
|
)
|
||||||
|
assert corrected["success"] is True
|
||||||
|
assert corrected["inserted_count"] == 0
|
||||||
|
|
||||||
|
series = service.get_net_worth_timeseries(
|
||||||
|
start_date="2026-06-01", end_date="2026-06-30", db_path=db_path
|
||||||
|
)
|
||||||
|
assert series["success"] is True
|
||||||
|
assert len(series["points"]) == 1
|
||||||
|
point = series["points"][0]
|
||||||
|
assert point["basis_date"] == "2026-06-30"
|
||||||
|
assert point["net_worth"] == point["total_assets"] == point["total_liabilities"] == 0
|
||||||
|
assert point["close_status"] == "conditional"
|
||||||
|
assert point["approval"]["approval_reference"] == "review-63"
|
||||||
|
assert point["excluded_items"]
|
||||||
|
assert point["snapshot_run_id"] == corrected["snapshot_run_id"]
|
||||||
|
assert point["provenance"]["monthly_source_snapshot_id"] == corrected["snapshot_run_id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_f004_closed_report_restores_frozen_valuation_identity_without_legacy_invention(tmp_path):
|
||||||
|
db_path = str(tmp_path / "ledger.sqlite3")
|
||||||
|
account = service.create_ledger_account("계좌", db_path=db_path)
|
||||||
|
valuation = service.register_account_valuation(
|
||||||
|
account["account"]["id"], "2026-06-29", 500, verification_status="verified",
|
||||||
|
idempotency_key="frozen-v1", db_path=db_path,
|
||||||
|
)
|
||||||
|
first = service.close_asset_snapshot(
|
||||||
|
"2026-06-30", selection_policy="as_of", idempotency_key="frozen-close",
|
||||||
|
allow_as_of_carry_forward=True, **_approval_kwargs(), db_path=db_path,
|
||||||
|
)
|
||||||
|
assert first["success"] is True
|
||||||
|
report_before = service.get_asset_snapshot_report("2026-06-30", db_path=db_path)
|
||||||
|
item_before = report_before["included_items"][0]
|
||||||
|
assert item_before["valuation_id"] == valuation["valuation"]["id"]
|
||||||
|
assert item_before["currency"] == "KRW"
|
||||||
|
|
||||||
|
service.register_account_valuation(
|
||||||
|
account["account"]["id"], "2026-06-29", 900, verification_status="verified",
|
||||||
|
idempotency_key="frozen-v2", db_path=db_path,
|
||||||
|
)
|
||||||
|
report_after = service.get_asset_snapshot_report("2026-06-30", db_path=db_path)
|
||||||
|
assert report_after["included_items"][0]["valuation_id"] == item_before["valuation_id"]
|
||||||
|
assert report_after["included_items"][0]["currency"] == item_before["currency"]
|
||||||
|
assert report_after["selection_policy"] == "as_of"
|
||||||
|
|
||||||
|
legacy_db = str(tmp_path / "legacy.sqlite3")
|
||||||
|
service.create_asset_snapshot(
|
||||||
|
"2026-06-30", "account", "legacy-account", "legacy", 10, "cash", db_path=legacy_db
|
||||||
|
)
|
||||||
|
legacy = service.get_asset_snapshot_report("2026-06-30", db_path=legacy_db)
|
||||||
|
legacy_item = legacy["included_items"][0]
|
||||||
|
assert "valuation_id" not in legacy_item
|
||||||
|
assert legacy_item["currency"] == "KRW"
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from jarca_ledger_mcp import service
|
||||||
|
from jarca_ledger_mcp.db import init_db
|
||||||
|
|
||||||
|
|
||||||
|
BASIS = "2026-06-30"
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_closed_db(tmp_path):
|
||||||
|
path = str(tmp_path / "legacy.sqlite3")
|
||||||
|
init_db(path)
|
||||||
|
asset = service.create_manual_asset("legacy cash", 100, "2026-06-01", "cash", db_path=path)["manual_asset"]
|
||||||
|
with sqlite3.connect(path) as conn:
|
||||||
|
# These are schema-11 close rows after the additive schema-12 migration:
|
||||||
|
# run identity is absent, but row close/correction fields remain frozen.
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO asset_snapshots
|
||||||
|
(id, basis_date, source_type, source_id, name, amount, liquidity_bucket,
|
||||||
|
valuation_source, snapshot_run_id, correction_of_run_id, correction_reason,
|
||||||
|
is_correction, closed_at, created_at)
|
||||||
|
VALUES (?, ?, 'manual_asset', ?, ?, 100, 'cash', 'manual_assets', 'legacy-run-correction',
|
||||||
|
'legacy-initial', 'legacy correction', 1, '2026-06-30T12:00:00+09:00',
|
||||||
|
'2026-06-30T12:00:00+09:00')""",
|
||||||
|
("legacy-row", BASIS, asset["id"], asset["name"]),
|
||||||
|
)
|
||||||
|
conn.execute("DROP TABLE asset_snapshot_runs")
|
||||||
|
conn.execute("UPDATE schema_meta SET value='11' WHERE key='schema_version'")
|
||||||
|
init_db(path)
|
||||||
|
with sqlite3.connect(path) as conn:
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
|
||||||
|
return path, asset["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_schema11_closed_rows_remain_frozen_after_live_value_and_date_change(tmp_path):
|
||||||
|
path, asset_id = _legacy_closed_db(tmp_path)
|
||||||
|
service.update_manual_asset(asset_id, valuation_amount=900, valuation_date="2026-06-30", db_path=path)
|
||||||
|
before = hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
report = service.get_asset_snapshot_report(BASIS, db_path=path)
|
||||||
|
|
||||||
|
after = hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
assert report["success"] is True
|
||||||
|
assert report["source"] == "legacy_asset_snapshots"
|
||||||
|
assert report["is_closed_snapshot"] is True
|
||||||
|
assert report["close_status"] == "legacy_read_only"
|
||||||
|
assert report["net_worth"] == 100
|
||||||
|
assert report["included_items"][0]["amount"] == 100
|
||||||
|
assert report["correction"] == {
|
||||||
|
"is_correction": True,
|
||||||
|
"correction_of_run_id": "legacy-initial",
|
||||||
|
"correction_reason": "legacy correction",
|
||||||
|
}
|
||||||
|
assert report["snapshot_run_id"] == "legacy-run-correction"
|
||||||
|
assert report["approval"] == {}
|
||||||
|
assert "valuation_id" not in report["included_items"][0]
|
||||||
|
assert after == before
|
||||||
|
|
||||||
|
|
||||||
|
def test_schema11_closed_rows_are_frozen_in_timeseries(tmp_path):
|
||||||
|
path, asset_id = _legacy_closed_db(tmp_path)
|
||||||
|
service.update_manual_asset(asset_id, valuation_amount=900, valuation_date="2026-07-01", db_path=path)
|
||||||
|
|
||||||
|
point = service.get_net_worth_timeseries(BASIS, BASIS, db_path=path)["points"][0]
|
||||||
|
|
||||||
|
assert point["net_worth"] == 100
|
||||||
|
assert point["item_count"] == 1
|
||||||
|
assert point["close_status"] == "legacy_read_only"
|
||||||
|
assert point["snapshot_run_id"] == "legacy-run-correction"
|
||||||
|
assert point["approval"] == {}
|
||||||
|
assert point["correction"] == {
|
||||||
|
"is_correction": True,
|
||||||
|
"correction_of_run_id": "legacy-initial",
|
||||||
|
"correction_reason": "legacy correction",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_schema11_closed_rows_are_frozen_in_monthly_settlement(tmp_path):
|
||||||
|
path, asset_id = _legacy_closed_db(tmp_path)
|
||||||
|
service.update_manual_asset(asset_id, valuation_amount=900, valuation_date="2026-07-01", db_path=path)
|
||||||
|
|
||||||
|
monthly = service.get_monthly_settlement_summary("2026-06", db_path=path)
|
||||||
|
assets = monthly["reports"]["assets"]
|
||||||
|
coverage = monthly["source_coverage"]["assets"]
|
||||||
|
|
||||||
|
assert assets["net_worth"] == 100
|
||||||
|
assert assets["is_closed_snapshot"] is True
|
||||||
|
assert coverage["net_worth"] == 100
|
||||||
|
assert coverage["is_closed_snapshot"] is True
|
||||||
|
assert coverage["monthly_source_snapshot_id"] == "legacy-run-correction"
|
||||||
|
assert assets["approval"] == {}
|
||||||
|
assert coverage["approval"] == {}
|
||||||
|
assert coverage["correction"] == assets["correction"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_schema11_latest_legacy_correction_run_is_selected_as_a_complete_group(tmp_path):
|
||||||
|
path = str(tmp_path / "legacy-corrections.sqlite3")
|
||||||
|
init_db(path)
|
||||||
|
with sqlite3.connect(path) as conn:
|
||||||
|
rows = [
|
||||||
|
("old-a", "legacy-run-1", "", "", 0, "2026-06-30T10:00:00+09:00", 40),
|
||||||
|
("old-b", "legacy-run-1", "", "", 0, "2026-06-30T10:00:00+09:00", 60),
|
||||||
|
("new-a", "legacy-run-2", "legacy-run-1", "correct frozen close", 1, "2026-07-01T10:00:00+09:00", 70),
|
||||||
|
("new-b", "legacy-run-2", "legacy-run-1", "correct frozen close", 1, "2026-07-01T10:00:00+09:00", 80),
|
||||||
|
]
|
||||||
|
for row_id, run_id, prior, reason, correction, closed_at, amount in rows:
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO asset_snapshots
|
||||||
|
(id, basis_date, source_type, source_id, name, amount, currency,
|
||||||
|
liquidity_bucket, valuation_source, snapshot_run_id,
|
||||||
|
correction_of_run_id, correction_reason, is_correction,
|
||||||
|
closed_at, created_at)
|
||||||
|
VALUES (?, ?, 'manual_asset', ?, ?, ?, 'KRW', 'cash',
|
||||||
|
'manual_assets', ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(row_id, BASIS, row_id, row_id, amount, run_id, prior, reason,
|
||||||
|
correction, closed_at, closed_at),
|
||||||
|
)
|
||||||
|
conn.execute("DROP TABLE asset_snapshot_runs")
|
||||||
|
conn.execute("UPDATE schema_meta SET value='11' WHERE key='schema_version'")
|
||||||
|
init_db(path)
|
||||||
|
with sqlite3.connect(path) as conn:
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
|
||||||
|
|
||||||
|
before = hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
||||||
|
report = service.get_asset_snapshot_report(BASIS, db_path=path)
|
||||||
|
after = hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
assert report["success"] is True
|
||||||
|
assert report["source"] == "legacy_asset_snapshots"
|
||||||
|
assert report["snapshot_run_id"] == "legacy-run-2"
|
||||||
|
assert report["snapshot_count"] == 2
|
||||||
|
assert [item["id"] for item in report["included_items"]] == ["new-a", "new-b"]
|
||||||
|
assert report["net_worth"] == 150
|
||||||
|
assert report["correction"] == {
|
||||||
|
"is_correction": True,
|
||||||
|
"correction_of_run_id": "legacy-run-1",
|
||||||
|
"correction_reason": "correct frozen close",
|
||||||
|
}
|
||||||
|
assert report["approval"] == {}
|
||||||
|
assert all("valuation_id" not in item for item in report["included_items"])
|
||||||
|
assert after == before
|
||||||
@@ -41,3 +41,32 @@ def test_interface_contract_documents_public_valuation_enums_validation_and_mapp
|
|||||||
)
|
)
|
||||||
for marker in required_contract:
|
for marker in required_contract:
|
||||||
assert marker in interface, marker
|
assert marker in interface, marker
|
||||||
|
|
||||||
|
|
||||||
|
def test_data_model_documents_valuation_selection_contract_and_index_mapping() -> None:
|
||||||
|
data_model = _text("docs/data-model.md")
|
||||||
|
|
||||||
|
required_contract = (
|
||||||
|
"select_account_valuation",
|
||||||
|
"account_id is mandatory",
|
||||||
|
"currency and valuation_kind/purpose are optional filters",
|
||||||
|
"basis_date exact match or <= upper bound",
|
||||||
|
"verified leaf valuations only",
|
||||||
|
"future-dated and unverified valuations are excluded",
|
||||||
|
"idx_account_valuations_account_date supports account/date selection",
|
||||||
|
"idx_account_valuations_status_date supports status/date selection",
|
||||||
|
"neither index covers account + currency + purpose",
|
||||||
|
"idempotency and single_correction indexes enforce write integrity, not selection",
|
||||||
|
)
|
||||||
|
for marker in required_contract:
|
||||||
|
assert marker in data_model, marker
|
||||||
|
|
||||||
|
|
||||||
|
def test_loan_cache_writers_document_no_inferred_valuation_history() -> None:
|
||||||
|
data_model = _text("docs/data-model.md")
|
||||||
|
interface = _text("docs/interface-contract.md")
|
||||||
|
requirement = _text("docs/requirements/req-003_account-valuation-history.md")
|
||||||
|
|
||||||
|
assert "loan cache writes without an explicit liability definition do not append" in data_model
|
||||||
|
assert "loan cache writes without an explicit liability definition do not append" in interface
|
||||||
|
assert "대출 cache write는 대표 부채 정의가 없으면 이력 후보를 추가하지 않는다" in requirement
|
||||||
|
|||||||
@@ -469,7 +469,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jarca-ledger-mcp"
|
name = "jarca-ledger-mcp"
|
||||||
version = "0.1.4"
|
version = "0.1.5"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "mcp" },
|
{ name = "mcp" },
|
||||||
|
|||||||
Reference in New Issue
Block a user