4 Commits
14 changed files with 613 additions and 8 deletions
+3 -2
View File
@@ -118,15 +118,16 @@
### 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_basis_date` is the basis field for the evidence represented by that cache. It is a date when known; legacy or date-less writes store unknown/NULL and must not be inferred from `last_synced_at` or a valuation row.
- `last_synced_at` is the execution/sync timestamp: when the import or account write advanced the cache, not the evidence basis date.
- imports and account writes advance the account cache; they may also append a dated `account_valuations` candidate, but updating the cache does not rewrite historical rows.
- 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.
- 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.
## 관계와 불변조건
+2 -2
View File
@@ -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.
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.
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
@@ -100,7 +100,7 @@ amount와 principal/interest/fee/cost_basis/net_contributed/profit은 exact inte
### 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
@@ -0,0 +1,50 @@
{
"schema": "gitea-agent.contract-preservation-inventory/v1",
"owner": "jsfamily",
"repository": "jarca-ledger-mcp",
"issue": 12,
"pull_request": 13,
"contract_sha256": "6d86b591b022fcc722611bfec6d24e54a64216ab6f7c4098f357e25420218818",
"review_id": 61,
"reviewed_parent_sha": "5e721b0ddd648f3e80f341a3c304d1cc3ca47ee8",
"reviewed_head": "1b9eda683c66d3c7129716b9899eddf61df3362d",
"preserved_semantics": [
{
"criterion": "N01-loan-balance-definition",
"required": "Every valuation attached to a loan account explicitly records principal_only or principal_interest_fees; no writer or account-type transition may infer or bypass it.",
"evidence": [
"tests/test_account_valuation_edge_contracts.py",
"tests/test_issue_12_review59_account.py"
]
},
{
"criterion": "C05-current-import",
"required": "Loan cache and import operations remain successful without creating definition-less history, and late evidence never rolls back newer current cache.",
"evidence": [
"test_dated_loan_create_and_historical_update_preserve_current_cache_without_valuations",
"test_loan_import_preserves_transactions_and_current_cache_without_valuation_candidate"
]
},
{
"criterion": "C13-read-only-and-no-partial-write",
"required": "Validation failure changes neither account state nor append-only valuation history.",
"evidence": [
"test_bank_to_loan_conversion_rejects_existing_undefined_valuation_without_mutation"
]
},
{
"criterion": "compatibility",
"required": "A non-loan account with no valuation history may still be converted to loan; non-loan candidate insertion and replay are unchanged.",
"evidence": [
"test_bank_to_loan_conversion_without_valuations_remains_allowed",
"test_non_loan_candidate_still_succeeds_and_reuses_idempotently"
]
}
],
"excluded_scope": [
"No inferred or automatic liability-definition migration.",
"No schema change.",
"No changes to snapshot, reconciliation, dashboard, authentication, or menu behavior.",
"No merge, Issue close, or release operation."
]
}
@@ -0,0 +1,208 @@
{
"schema": "gitea-agent.evidence-manifest/v1",
"owner": "jsfamily",
"repository": "jarca-ledger-mcp",
"issue": 12,
"pull_request": 13,
"review": 61,
"reviewed_parent_sha": "5e721b0ddd648f3e80f341a3c304d1cc3ca47ee8",
"source_head_sha": "1b9eda683c66d3c7129716b9899eddf61df3362d",
"contract_sha256": "6d86b591b022fcc722611bfec6d24e54a64216ab6f7c4098f357e25420218818",
"environment": {
"platform": "Linux-6.18.34+rpt-rpi-v8-aarch64-with-glibc2.41",
"python": "3.13.5",
"uv": "uv 0.11.6 (aarch64-unknown-linux-musl)",
"fixture_policy": "synthetic repository fixtures only; no credentials or real financial data"
},
"commands": [
{
"id": "conversion-regression",
"argv": [
"uv",
"run",
"--frozen",
"--extra",
"dev",
"--extra",
"dashboard",
"pytest",
"-q",
"-rs",
"tests/test_account_valuation_edge_contracts.py::test_bank_to_loan_conversion_rejects_existing_undefined_valuation_without_mutation",
"tests/test_account_valuation_edge_contracts.py::test_bank_to_loan_conversion_without_valuations_remains_allowed"
],
"purpose": "Verify conversion rejection is atomic and history-free conversion remains compatible",
"started_at_utc": "2026-10-07T03:59:49.130624Z",
"finished_at_utc": "2026-10-07T03:59:51.888407Z",
"duration_seconds": 2.757882494479418,
"exit_code": 0,
"expected_exit_code": 0,
"output": ".. [100%]\n"
},
{
"id": "review61-regressions",
"argv": [
"uv",
"run",
"--frozen",
"--extra",
"dev",
"--extra",
"dashboard",
"pytest",
"-q",
"-rs",
"tests/test_account_valuation_edge_contracts.py",
"tests/test_issue_12_review59_account.py",
"tests/test_valuation_contract_docs.py"
],
"purpose": "Run Review 61 and independent-review named regressions",
"started_at_utc": "2026-10-07T03:59:51.888570Z",
"finished_at_utc": "2026-10-07T03:59:58.351942Z",
"duration_seconds": 6.463426006026566,
"exit_code": 0,
"expected_exit_code": 0,
"output": "........................ [100%]\n"
},
{
"id": "full-suite",
"argv": [
"uv",
"run",
"--frozen",
"--extra",
"dev",
"--extra",
"dashboard",
"pytest",
"-q",
"-rs"
],
"purpose": "Run the complete repository test suite at the exact source head",
"started_at_utc": "2026-10-07T03:59:58.352023Z",
"finished_at_utc": "2026-10-07T04:01:03.222283Z",
"duration_seconds": 64.87031899858266,
"exit_code": 0,
"expected_exit_code": 0,
"output": "........................................................................ [ 25%]\n........................................................................ [ 51%]\n...............................................................ss....... [ 77%]\n.....................................................ss...... [100%]\n=============================== warnings summary ===============================\ntests/test_dashboard_auth_server.py:11\n /opt/data/gitea-agent/repositories/jsfamily/jarca-ledger-mcp/worktrees/issue-12/checkout/tests/test_dashboard_auth_server.py:11: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.\n from starlette.testclient import TestClient\n\n-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html\n=========================== short test summary info ============================\nSKIPPED [1] tests/test_kb_bank_pdf.py:26: sample KB Bank PDF/password is not available\nSKIPPED [1] tests/test_kb_bank_pdf.py:42: sample KB Bank PDF/password is not available\nSKIPPED [1] tests/test_toss_securities_pdf.py:20: sample Toss Securities PDF is not available\nSKIPPED [1] tests/test_toss_securities_pdf.py:32: sample Toss Securities PDF is not available\n"
},
{
"id": "ruff",
"argv": [
"uv",
"run",
"--frozen",
"--extra",
"dev",
"--extra",
"dashboard",
"ruff",
"check",
"."
],
"purpose": "Run Python lint checks",
"started_at_utc": "2026-10-07T04:01:03.222360Z",
"finished_at_utc": "2026-10-07T04:01:03.426930Z",
"duration_seconds": 0.20462407637387514,
"exit_code": 0,
"expected_exit_code": 0,
"output": "All checks passed!\n"
},
{
"id": "compileall",
"argv": [
"python3",
"-m",
"compileall",
"-q",
"jarca_ledger_mcp",
"dashboard",
"tests",
"scripts"
],
"purpose": "Compile production, dashboard, test, and script Python surfaces",
"started_at_utc": "2026-10-07T04:01:03.427003Z",
"finished_at_utc": "2026-10-07T04:01:03.684488Z",
"duration_seconds": 0.25754371751099825,
"exit_code": 0,
"expected_exit_code": 0,
"output": ""
},
{
"id": "diff-check",
"argv": [
"git",
"diff",
"--check",
"5e721b0ddd648f3e80f341a3c304d1cc3ca47ee8..1b9eda683c66d3c7129716b9899eddf61df3362d"
],
"purpose": "Check the complete rework diff for whitespace errors",
"started_at_utc": "2026-10-07T04:01:03.684567Z",
"finished_at_utc": "2026-10-07T04:01:03.730543Z",
"duration_seconds": 0.04603164829313755,
"exit_code": 0,
"expected_exit_code": 0,
"output": ""
}
],
"changed_files": [
{
"path": "docs/data-model.md",
"sha256": "f9932a04f3c74907da1c21d754a16e1401f64fd948945017d410de7d91494dca",
"git_blob": "70f26ce1522b8609a93471655c9506040a4078f1"
},
{
"path": "docs/interface-contract.md",
"sha256": "01a3f37e23ee17b2b928b44e3e07d4d6982faead1ab6f0298889225b0c5ce916",
"git_blob": "4304427a604949b8295b4f93cb6054357805fe92"
},
{
"path": "docs/requirements/req-003_account-valuation-history.md",
"sha256": "f5a3bf9f69d622451a1a08854062218382f03355a3464538a35b1e270ca5b70f",
"git_blob": "2a7be4d780999fce47402f15f0b05d0b92990715"
},
{
"path": "docs/verification/issue-12-review-61/red-evidence.json",
"sha256": "567c03d28e6de74b6adfc0f6141dd96e9e129bab9d4455761615a1655870b4bf",
"git_blob": "b5f4bc9286b4ea443671ab0744935a11c295b124"
},
{
"path": "jarca_ledger_mcp/application/account_valuation_service.py",
"sha256": "f7f5bac1e224401dfd02efb321b7e7bd87e02875b727a5366bcc223bf633fb57",
"git_blob": "0dfd7b7ce94a699b855e40b336afa7bf5cdc2595"
},
{
"path": "jarca_ledger_mcp/application/accounts_write_services.py",
"sha256": "41dd6579dc422549316da05347c6d5e33d89da1e1502d6065b5b78a0160776d3",
"git_blob": "48a1dd5acfe782c9cc35e8bd615dc71671dadf6f"
},
{
"path": "tests/test_account_valuation_edge_contracts.py",
"sha256": "fed17aec3518e2380d00b46252402ad69a71e7d265961a6264b03d790d579f42",
"git_blob": "0b08cb32af63084225e01642d333ca291c7445c4"
},
{
"path": "tests/test_issue_12_review59_account.py",
"sha256": "a8a7491c7b78841c2be0dbc66913c1e076bdd231281fd64818493a0f3a1de550",
"git_blob": "f5cee3e993f189e688f8e8ab3829b5c4ab34cf5a"
},
{
"path": "tests/test_valuation_contract_docs.py",
"sha256": "7443b8425d6b733e0e95d21b3c460a42dceeb2f99f84e1fb8fadcd88f6b77514",
"git_blob": "c473ee14a530496e11c28e33008b0d8a50d774cb"
}
],
"added_line_security_scan": {
"hardcoded_secret": [],
"shell_injection": [],
"dynamic_eval": [],
"pickle": [],
"interpolated_sql": []
},
"worktree_clean_after": true,
"limitations": [
"Four external private PDF fixture tests may be skipped when fixtures are unavailable.",
"Gitea status/readiness is not functional acceptance evidence."
],
"changed_files_scope": "Behavior-bearing source, tests, normative docs, and immutable RED evidence at source_head_sha; mutable verification metadata and generated GREEN/manifest artifacts are intentionally excluded from hash binding."
}
@@ -0,0 +1,61 @@
{
"schema": "gitea-agent.evidence-plan/v1",
"owner": "jsfamily",
"repository": "jarca-ledger-mcp",
"issue": 12,
"pull_request": 13,
"review_id": 61,
"reviewed_parent_sha": "5e721b0ddd648f3e80f341a3c304d1cc3ca47ee8",
"reviewed_head": "1b9eda683c66d3c7129716b9899eddf61df3362d",
"implementation_parent": "b8b8811a2a50371795a02e4fa65f097c9d9508ac",
"contract_sha256": "6d86b591b022fcc722611bfec6d24e54a64216ab6f7c4098f357e25420218818",
"manifest_generator": {
"path": "/opt/data/gitea-agent/.run/i12-r61/run_final_evidence.py",
"execution": "python3 /opt/data/gitea-agent/.run/i12-r61/run_final_evidence.py"
},
"commands": [
{
"id": "conversion-regression",
"argv": ["uv", "run", "--frozen", "--extra", "dev", "--extra", "dashboard", "pytest", "-q", "-rs", "tests/test_account_valuation_edge_contracts.py::test_bank_to_loan_conversion_rejects_existing_undefined_valuation_without_mutation", "tests/test_account_valuation_edge_contracts.py::test_bank_to_loan_conversion_without_valuations_remains_allowed"]
},
{
"id": "review61-regressions",
"argv": ["uv", "run", "--frozen", "--extra", "dev", "--extra", "dashboard", "pytest", "-q", "-rs", "tests/test_account_valuation_edge_contracts.py", "tests/test_issue_12_review59_account.py", "tests/test_valuation_contract_docs.py"]
},
{
"id": "full-suite",
"argv": ["uv", "run", "--frozen", "--extra", "dev", "--extra", "dashboard", "pytest", "-q", "-rs"]
},
{
"id": "ruff",
"argv": ["uv", "run", "--frozen", "--extra", "dev", "--extra", "dashboard", "ruff", "check", "."]
},
{
"id": "compileall",
"argv": ["python3", "-m", "compileall", "-q", "jarca_ledger_mcp", "dashboard", "tests", "scripts"]
},
{
"id": "diff-check",
"argv": ["git", "diff", "--check", "5e721b0ddd648f3e80f341a3c304d1cc3ca47ee8..1b9eda683c66d3c7129716b9899eddf61df3362d"]
}
],
"bound_paths": [
"jarca_ledger_mcp/application/account_valuation_service.py",
"jarca_ledger_mcp/application/accounts_write_services.py",
"tests/test_account_valuation_edge_contracts.py",
"tests/test_issue_12_review59_account.py",
"tests/test_valuation_contract_docs.py",
"docs/data-model.md",
"docs/interface-contract.md",
"docs/requirements/req-003_account-valuation-history.md",
"docs/verification/issue-12-review-61/red-evidence.json",
"docs/verification/issue-12-review-61/finding-matrix.json",
"docs/verification/issue-12-review-61/contract-preservation-inventory.json",
"docs/verification/issue-12-review-61/evidence-plan.json"
],
"redaction": "Synthetic fixtures only; no credentials, real account identifiers, financial documents, or environment secrets.",
"limitations": [
"External private PDF fixtures may be skipped when unavailable.",
"Gitea commit status is readiness metadata, not functional acceptance evidence."
]
}
@@ -0,0 +1,56 @@
{
"schema": "gitea-agent.finding-matrix/v1",
"owner": "jsfamily",
"repository": "jarca-ledger-mcp",
"issue": 12,
"pull_request": 13,
"review_id": 61,
"reviewed_parent_sha": "5e721b0ddd648f3e80f341a3c304d1cc3ca47ee8",
"reviewed_head": "1b9eda683c66d3c7129716b9899eddf61df3362d",
"implementation_parent": "b8b8811a2a50371795a02e4fa65f097c9d9508ac",
"findings": [
{
"id": "N01-loan-balance-definition",
"classification": "implementation_defect",
"status": "resolved_and_verified_at_source_head",
"root_cause": "Internal candidate writers could persist loan balance evidence without an explicit representative liability definition, and unsupported loan purposes could bypass the definition rule.",
"tests": [
"test_direct_loan_candidate_requires_liability_definition_and_writes_nothing",
"test_import_loan_candidate_requires_liability_definition_and_writes_nothing",
"test_dated_loan_create_and_historical_update_preserve_current_cache_without_valuations",
"test_loan_cash_balance_and_total_value_are_rejected_without_writes",
"test_loan_import_preserves_transactions_and_current_cache_without_valuation_candidate"
],
"changed_paths": [
"jarca_ledger_mcp/application/account_valuation_service.py",
"tests/test_account_valuation_edge_contracts.py",
"tests/test_issue_12_review59_account.py",
"docs/data-model.md",
"docs/interface-contract.md",
"docs/requirements/req-003_account-valuation-history.md"
]
},
{
"id": "N01-account-type-conversion-bypass",
"classification": "implementation_defect",
"status": "resolved",
"root_cause": "update_ledger_account allowed a non-loan account with definition-less historical valuations to become a loan account without validating the persisted history.",
"named_regression": "test_bank_to_loan_conversion_rejects_existing_undefined_valuation_without_mutation",
"positive_regression": "test_bank_to_loan_conversion_without_valuations_remains_allowed",
"red_evidence": "docs/verification/issue-12-review-61/red-evidence.json",
"minimal_fix": "Reject non-loan to loan conversion before mutation when any attached valuation lacks one of the two explicit valid representative liability definitions.",
"changed_paths": [
"jarca_ledger_mcp/application/accounts_write_services.py",
"tests/test_account_valuation_edge_contracts.py",
"docs/data-model.md",
"docs/interface-contract.md",
"docs/requirements/req-003_account-valuation-history.md"
],
"risks": [
"Do not infer or backfill a liability definition.",
"Do not reject conversion when no valuation history exists.",
"Do not partially mutate account or valuation history on rejection."
]
}
]
}
@@ -0,0 +1,30 @@
{
"schema": "gitea-agent.green-evidence/v1",
"finding": "N01-account-type-conversion-bypass",
"source_commit": "1b9eda683c66d3c7129716b9899eddf61df3362d",
"tests": [
"test_bank_to_loan_conversion_rejects_existing_undefined_valuation_without_mutation",
"test_bank_to_loan_conversion_without_valuations_remains_allowed"
],
"argv": [
"uv",
"run",
"--frozen",
"--extra",
"dev",
"--extra",
"dashboard",
"pytest",
"-q",
"-rs",
"tests/test_account_valuation_edge_contracts.py::test_bank_to_loan_conversion_rejects_existing_undefined_valuation_without_mutation",
"tests/test_account_valuation_edge_contracts.py::test_bank_to_loan_conversion_without_valuations_remains_allowed"
],
"started_at_utc": "2026-10-07T03:59:49.130624Z",
"finished_at_utc": "2026-10-07T03:59:51.888407Z",
"exit_code": 0,
"expected_exit_code": 0,
"result": "GREEN",
"output": ".. [100%]\n",
"fixture_policy": "synthetic only; no credentials or real financial data"
}
@@ -0,0 +1,9 @@
{
"gate": "independent-bank-to-loan-conversion",
"source_sha": "b8b8811a2a50371795a02e4fa65f097c9d9508ac",
"test": "tests/test_account_valuation_edge_contracts.py::test_bank_to_loan_conversion_rejects_existing_undefined_valuation_without_mutation",
"command": "uv run --frozen --extra dev --extra dashboard pytest tests/test_account_valuation_edge_contracts.py::test_bank_to_loan_conversion_rejects_existing_undefined_valuation_without_mutation -q",
"exit_code": 1,
"result": "RED",
"output": "F [100%]\n=================================== FAILURES ===================================\n_ test_bank_to_loan_conversion_rejects_existing_undefined_valuation_without_mutation _\n\n assert rejected[\"success\"] is False\nE assert True is False\n=========================== short test summary info ============================\nFAILED tests/test_account_valuation_edge_contracts.py::test_bank_to_loan_conversion_rejects_existing_undefined_valuation_without_mutation"
}
@@ -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]:
"""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:
return fail("not_found", "account not found")
if (currency or "KRW").upper() != (account["currency"] or "KRW").upper():
return fail("validation_error", "valuation currency must match account currency")
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)
existing = conn.execute("SELECT * FROM account_valuations WHERE account_id=? AND idempotency_key=?", (account_id, idempotency_key)).fetchone()
if existing:
incoming = {
"basis_date": basis,
"amount": int(amount),
"amount": amount_int,
"currency": (currency or "KRW").upper(),
"valuation_kind": "balance",
"source_type": source_type,
@@ -120,7 +126,7 @@ def record_account_valuation_candidate(conn: sqlite3.Connection, account_id: str
valuation_id = new_id("valuation")
conn.execute("""INSERT INTO account_valuations
(id,account_id,basis_date,amount,currency,valuation_kind,source_type,source_ref,verification_status,recorded_at,memo,idempotency_key)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""", (valuation_id, account_id, basis, int(amount), (currency or "KRW").upper(), "balance", source_type, source_ref or "", "unverified", now_iso(), memo, idempotency_key))
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)
@@ -189,6 +195,8 @@ def register_account_valuation(
is_loan_account = str(account["type"]).lower() == "loan"
if effective_kind in {"loan", "loan_balance"} and not is_loan_account:
return fail("validation_error", "loan valuation kind requires a loan account")
if 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:
return fail("validation_error", "loan valuation requires representative_liability_definition")
if definition is not None and not is_loan_account:
@@ -62,6 +62,16 @@ def update_ledger_account(account_id: str, db_path: str | None = None, **fields:
except Exception as e: return fail("validation_error", str(e))
updates.append(f"{k}=?"); params.append(v)
if not updates: return fail("validation_error", "no fields to update")
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]
incoming_date = normalized_basis if basis_update else ""
historical = bool(balance_update and incoming_date and stored_date and incoming_date < stored_date)
@@ -1,4 +1,41 @@
import sqlite3
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):
@@ -91,3 +128,104 @@ def test_correction_replays_by_evidence_and_conflicts_on_changed_payload(tmp_pat
assert changed["success"] is False
assert changed["error_code"] == "correction_conflict"
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
+24
View File
@@ -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
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):
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"]
+10
View File
@@ -41,3 +41,13 @@ def test_interface_contract_documents_public_valuation_enums_validation_and_mapp
)
for marker in required_contract:
assert marker in interface, 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