Compare commits
6
Commits
577bd0b912
..
v0.1.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f67c07adf0 | ||
|
|
0954d47767 | ||
|
|
565ed8779c | ||
|
|
fa51b4dbb5 | ||
|
|
bae137dce7 | ||
|
|
4c5cb0627c |
@@ -34,7 +34,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
initContainers:
|
||||
- name: init-db
|
||||
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.5
|
||||
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.6
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- python
|
||||
@@ -62,7 +62,7 @@ spec:
|
||||
mountPath: /tmp
|
||||
containers:
|
||||
- name: mcp
|
||||
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.5
|
||||
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.6
|
||||
imagePullPolicy: IfNotPresent
|
||||
env:
|
||||
- name: JARCA_LEDGER_DB_PATH
|
||||
@@ -104,7 +104,7 @@ spec:
|
||||
- name: mcp-tmp
|
||||
mountPath: /tmp
|
||||
- name: dashboard
|
||||
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.5
|
||||
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.6
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- uvicorn
|
||||
|
||||
@@ -224,3 +224,29 @@ Reconciliation ownership remains in `reconciliation_service.py`: opening evidenc
|
||||
| `tests/test_monthly_settlement_drafts.py` | Synthetic DB lifecycle, failure injection, replay and no-write regressions. |
|
||||
|
||||
Never document invented `jarca_ledger_mcp.mcp` or `monthly.py` modules. `transaction readiness` is separate from asset completeness and monthly reads remain read-only.
|
||||
|
||||
|
||||
### Shared settlement-draft entrypoints
|
||||
|
||||
| Path | Responsibility / entrypoint and dependency boundary |
|
||||
|---|---|
|
||||
| `jarca_ledger_mcp/service.py` | Existing public re-export facade exports the ten lifecycle operations from `application/settlement_draft_service.py`; adapters share these functions, not separate business implementations. |
|
||||
| `jarca_ledger_mcp/adapters/mcp/server.py` | Same-name MCP lifecycle tools delegate to the shared facade, retaining existing success/error envelopes. |
|
||||
| `jarca_ledger_mcp/adapters/mcp/tool_contracts.py` | Registers the same facade callables and their inputSchema contracts; internal `db_path` remains outside public MCP input. |
|
||||
| `jarca_ledger_mcp/adapters/streamlit/settlement_gateway.py` | Wraps shared lifecycle functions plus valuation list/register/correct delegates, preserving auth-before-DB and internal database-path resolution. |
|
||||
| `dashboard/page_views/settlement_drafts.py` | `render_settlement_drafts` owns reusable presentation and explicit user-action controls through the gateway; no direct SQL or duplicated approval policy. |
|
||||
| `dashboard/page_views/monthly_settlement.py` | `page_monthly_settlement` invokes the shared panel beside the existing read-only monthly summary. |
|
||||
| `dashboard/page_views/assets.py` | `page_assets` invokes the same panel beside existing valuation and immutable asset-history views. |
|
||||
| `jarca_ledger_mcp/application/account_valuation_service.py` | `register_account_valuation`, `list_account_valuations`, and `correct_account_valuation` retain registration, validation and append-only correction ownership; the draft service reuses their evidence. |
|
||||
| `jarca_ledger_mcp/application/assets_service.py` | Owns established valuation/amount authority and asset close/report semantics; draft finalization does not bypass review by calling legacy live-select close. |
|
||||
| `jarca_ledger_mcp/application/monthly_settlement_service.py` | `get_monthly_settlement_summary` owns read-only propagation of finalized evidence, not draft mutation or transaction-readiness writes. |
|
||||
| `jarca_ledger_mcp/infrastructure/sqlite/schema.py` | schema 13 initialization uses the additive migration path; no adapter owns persistence constraints. |
|
||||
| `jarca_ledger_mcp/infrastructure/sqlite/migrations.py` | Creates persistent draft/item tables and indexes, preserving existing schema and rows on repeated initialization. |
|
||||
|
||||
### Shared settlement-draft policies
|
||||
|
||||
- `jarca_ledger_mcp/application/settlement_draft_service.py` checks one active draft per month for ordinary and correction drafts; SQLite independently enforces it with `idx_settlement_drafts_active_month` on month `WHERE status='active'`. Completed and cancelled history is retained, not deleted to release that slot.
|
||||
- The same application service validates strict month syntax and month-end basis-date equality. Dashboard and MCP cannot supply an alternate date policy.
|
||||
- The shared review service exposes unverified `candidate_amount` with authority amount NULL; no adapter may sum it as verified evidence. A confirmed child requires explicit reselection, fresh review and approval; correction children are not silently followed.
|
||||
- Dependencies remain page → gateway → public facade → application → SQLite, with MCP server/tool contracts → the same facade. No UI/MCP direct SQL, application-to-Dashboard reverse dependency or duplicated business validation is introduced.
|
||||
- Paired synthetic coverage: `tests/test_monthly_settlement_drafts.py` (month-end, active-month, unverified/reselection, cancellation and atomicity), `tests/test_settlement_draft_adapters.py` (facade/gateway/MCP signatures and no-create reads), `tests/test_settlement_draft_dashboard.py` and `tests/test_settlement_draft_panel_workflow.py` (shared panel/actions), `tests/test_settlement_draft_mcp_runtime.py` (real stdio responses), and `tests/test_settlement_draft_docs.py` (this mapping). Existing authentication/menu/legacy regression tests remain unchanged.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"applies_to": ["ledger", "dashboard", "mcp"],
|
||||
"language": "ko",
|
||||
"issue": "#15",
|
||||
"implementation_pr": "미정"
|
||||
"implementation_pr": "https://gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp/pulls/16"
|
||||
}
|
||||
---
|
||||
|
||||
@@ -664,6 +664,7 @@ REQ-001의 auth gate·cookie/cleanup/logout, REQ-002의 메뉴 10개와 order(`
|
||||
|
||||
- 근거 이슈: `#12`
|
||||
- 구현 PR: `#13`
|
||||
- Issue #15 구현 PR: https://gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp/pulls/16
|
||||
- 관련 설계 문서: `docs/data-model.md`, `docs/interface-contract.md`, `docs/project-structure.md`, `docs/runtime-flow.md`
|
||||
- 관련 요구사항: `REQ-001`, `REQ-002`
|
||||
- DB lifecycle: writer/bootstrap은 명시적 `init_db()` 호출로만 생성·마이그레이션하고, `read_db()`는 SQLite `mode=ro`로 연다. missing DB는 `db_not_initialized`이며 read path는 no mkdir, no migrate, no commit이다. first-use write만 init_db를 수행하고 auth-before-DB 순서를 보존한다.
|
||||
|
||||
@@ -257,3 +257,18 @@ Exact requires same-date verified evidence; carried_before uses verified prior w
|
||||
### failure/recovery and read-only guards
|
||||
|
||||
Draft create/update/finalize use transaction-scoped writes; any DB failure rolls back run, rows and completion. Same finalize request returns persisted response, not a replay-only `reused` field; different request conflicts. Monthly summary and review do not mutate; transaction readiness remains independent. `read_db()` missing path is `db_not_initialized`, with no-create/no mkdir/no migrate/no commit. Dashboard gate precedes every protected DB access; existing auth cookie lifecycle and exact menu/query/shell/browser semantics remain unchanged.
|
||||
|
||||
|
||||
### Cancellation and reopening
|
||||
|
||||
- Entry: authenticated Dashboard explicit action or the existing MCP tool delegates through the shared service facade to `cancel_settlement_draft(draft_id, expected_version, cancelled_by, reason, db_path=None)`. This adds no MCP authentication claim and does not alter Dashboard authentication.
|
||||
|
||||
| Stage ID | Owner / input and guard | State, success and failure transition |
|
||||
|---|---|---|
|
||||
| `settlement-draft-cancel` | Shared `_mutate` requires an existing active draft, matching positive-integer `expected_version`, nonempty `cancelled_by` actor and nonempty reason. | In `BEGIN IMMEDIATE`, persist status cancelled plus `cancelled_by`, `cancellation_reason`, `cancelled_at`; increment draft version and `updated_at`. Clear `approval_fingerprint`, `approval_version`, approver/time/reason/reference and flags. Success terminates in cancelled; state/version conflicts return conflict with no-write to the existing draft. Invalid input returns validation_error. |
|
||||
| `settlement-draft-cancel-recovery` | Cancellation is a transaction-scoped metadata mutation, not evidence deletion or an asset close. | Existing draft item/evidence rows, `account_valuations`, `asset_snapshot_runs`, and `asset_snapshots` are retained. DB failures rollback both cancellation and approval invalidation and return db_error; correct input or reread current state/version before retry. Completed/already cancelled drafts cannot be cancelled again; stale expected versions never overwrite current state. |
|
||||
| `settlement-draft-reopen` | Only completed or cancelled drafts release the active-month slot. Start a new explicit create request with a fresh create key; ordinary and correction drafts still share the one-active-month restriction. | The same create key with its original equal payload returns the previous draft, even after completion/cancellation; changed payload conflicts. A fresh key is not permission to overwrite history. No close exists: create without a correction parent. A close exists: explicitly link the latest prior run using `correction_of_run_id` and nonempty `correction_reason`, still at month-end. Continue at settlement-draft-create; missing/wrong parent or reason is rejected. |
|
||||
|
||||
Cancellation reason is stored in `cancellation_reason`, not memo; the cancellation actor and timestamp are persisted, not inferred from a later caller. Approval invalidation prevents reuse of the cancelled draft's approval. Reopening creates new draft/item identity and requires its own review/approval; finalization rechecks the correction parent and preserves prior immutable run/rows. Cancellation itself is not an idempotent finalization replay: retrying cancellation after its successful terminal transition returns conflict.
|
||||
|
||||
Coverage: `tests/test_monthly_settlement_drafts.py` checks cancellation/reopen, version conflicts and lifecycle persistence; `tests/test_settlement_draft_authority.py` checks cancellation approval invalidation; `tests/test_settlement_draft_correction_contract.py` checks latest-run linkage, reason, immutable history and persisted replay; `tests/test_settlement_draft_docs.py` checks these documented transitions. Existing auth/menu and read-only recovery flows above remain unchanged.
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Jarca Ledger MCP server."""
|
||||
|
||||
__version__ = "0.1.5"
|
||||
__version__ = "0.1.6"
|
||||
|
||||
@@ -61,6 +61,7 @@ def _freeze(conn, source_type, source_id, valuation_id=''):
|
||||
|
||||
|
||||
def _review(conn, draft):
|
||||
from .assets_service import _summarize_valuation_items
|
||||
stored = _items(conn, draft['id'])
|
||||
items, included, excluded, missing, warnings, states = [], [], [], [], [], []
|
||||
for item in stored:
|
||||
@@ -75,7 +76,7 @@ def _review(conn, draft):
|
||||
row['item_version'] = item['version']
|
||||
row.update(source_type='account' if item['source_type'] == 'account' else 'manual_asset',
|
||||
included=bool(item['included']), basis_date=draft['basis_date'],
|
||||
name=source.get('name', ''), currency=source.get('currency', 'KRW'),
|
||||
name=source.get('name', ''), currency=(source.get('currency') or 'KRW').upper(),
|
||||
liquidity_bucket='loan' if source.get('type') == 'loan' else source.get('liquidity_bucket', 'other'),
|
||||
authority_source='account_valuations' if item['source_type'] == 'account' else 'manual_assets')
|
||||
row['is_liability'] = row['liquidity_bucket'] == 'loan'
|
||||
@@ -126,11 +127,10 @@ def _review(conn, draft):
|
||||
# All candidate changes and source/selected evidence are bound privately; never exposed as raw JSON.
|
||||
candidates = [dict(v) for v in conn.execute('SELECT * FROM account_valuations WHERE account_id=? ORDER BY id', (item['source_id'],))] if item['source_type'] == 'account' else []
|
||||
states.append({'stored': item, 'current': current, 'candidates': candidates})
|
||||
assets = sum(x['amount'] for x in included if not x['is_liability'])
|
||||
liabilities = sum(x['amount'] for x in included if x['is_liability'])
|
||||
latest = _latest_close(conn, draft['basis_date'])
|
||||
serial = json.dumps({'draft_id': draft['id'], 'version': draft['version'], 'basis_date': draft['basis_date'], 'states': states, 'warnings': warnings, 'latest_run': latest, 'correction_parent': draft['correction_of_run_id']}, sort_keys=True, ensure_ascii=False)
|
||||
totals = {'total_assets': assets, 'total_liabilities': liabilities, 'net_worth': assets - liabilities}
|
||||
summary = _summarize_valuation_items(included)
|
||||
totals = {key: summary[key] for key in ('total_assets', 'total_liabilities', 'net_worth')}
|
||||
serial = json.dumps({'draft_id': draft['id'], 'version': draft['version'], 'basis_date': draft['basis_date'], 'states': states, 'warnings': warnings, 'latest_run': latest, 'correction_parent': draft['correction_of_run_id'], 'totals': totals}, sort_keys=True, ensure_ascii=False)
|
||||
return {'draft_id': draft['id'], 'version': draft['version'], 'review_fingerprint': hashlib.sha256(serial.encode()).hexdigest(),
|
||||
'items': items, 'included_items': included, 'excluded_items': excluded, 'missing_items': missing, 'warnings': warnings,
|
||||
'approval_required': True, 'totals': totals, **totals, 'complete': bool(items) and not missing,
|
||||
@@ -282,7 +282,7 @@ def set_settlement_draft_item(draft_id: str,source_type: str,source_id: str,expe
|
||||
if not v or v['account_id'] != source_id: return _err('validation_error', 'valuation does not belong to account')
|
||||
if v['currency'] != source['currency']: return _err('validation_error', 'valuation currency mismatch')
|
||||
kind, loan = v['valuation_kind'], source['type'] == 'loan'
|
||||
valid_kind = kind in ('loan', 'loan_balance', 'balance') if loan else kind in ('total_value', 'cash_balance', 'balance') if source['type'] == 'investment' else kind == 'balance'
|
||||
valid_kind = kind in ('loan', 'loan_balance', 'balance') if loan else kind in ('total_value', 'cash_balance', 'balance') if source['type'] == 'investment' else kind in ('balance', 'cash_balance')
|
||||
if not valid_kind or (loan and v['representative_liability_definition'] not in ('principal_only', 'principal_interest_fees')) or (not loan and v['representative_liability_definition']):
|
||||
return _err('validation_error', 'valuation purpose or representative liability is invalid')
|
||||
status = v['verification_status']
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "jarca-ledger-mcp"
|
||||
version = "0.1.5"
|
||||
version = "0.1.6"
|
||||
description = "Jarca household ledger MCP server for transactions, accounts, budgets, cashflow, and investable cashflow analysis"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -27,7 +27,7 @@ def test_requirement_docs_are_canonical_and_traceable() -> None:
|
||||
"applies_to": ["ledger", "dashboard", "mcp"],
|
||||
"language": "ko",
|
||||
"issue": "#15",
|
||||
"implementation_pr": "미정",
|
||||
"implementation_pr": "https://gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp/pulls/16",
|
||||
}
|
||||
assert re.search(r"^# REQ-003 — ", body, re.M)
|
||||
assert "${" not in text
|
||||
|
||||
@@ -21,7 +21,7 @@ def test_settlement_requirement_revision_is_additive_and_traceable() -> None:
|
||||
assert metadata["id"] == "REQ-003"
|
||||
assert metadata["revision"] == "1.1"
|
||||
assert metadata["issue"] == "#15"
|
||||
assert metadata["implementation_pr"] == "미정"
|
||||
assert metadata["implementation_pr"] == "https://gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp/pulls/16"
|
||||
assert "#12" in body and "#13" in body
|
||||
assert re.findall(r"^### REQ-003\.(\d+) —", body, re.M) == [f"{i:02d}" for i in range(1, 12)]
|
||||
assert re.findall(r"^## (\d+)\. ", body, re.M) == [str(i) for i in range(1, 12)]
|
||||
@@ -135,3 +135,44 @@ def test_legacy_metadata_field_lists_are_required_retained_fields_not_exhaustive
|
||||
assert re.search(r"required top-level metadata fields", legacy, re.I)
|
||||
assert re.search(r"not (?:an )?exhaustive", legacy, re.I)
|
||||
assert "additive settlement-draft finalize response" in legacy
|
||||
|
||||
|
||||
def test_settlement_draft_project_ownership_and_shared_policy_wiring() -> None:
|
||||
doc = text("docs/project-structure.md")
|
||||
required_rows = {
|
||||
"jarca_ledger_mcp/service.py": "public re-export",
|
||||
"jarca_ledger_mcp/adapters/mcp/tool_contracts.py": "inputSchema",
|
||||
"jarca_ledger_mcp/adapters/streamlit/settlement_gateway.py": "auth-before-DB",
|
||||
"dashboard/page_views/settlement_drafts.py": "render_settlement_drafts",
|
||||
"dashboard/page_views/monthly_settlement.py": "page_monthly_settlement",
|
||||
"dashboard/page_views/assets.py": "page_assets",
|
||||
"jarca_ledger_mcp/application/account_valuation_service.py": "register_account_valuation",
|
||||
"jarca_ledger_mcp/infrastructure/sqlite/schema.py": "schema 13",
|
||||
}
|
||||
for path, responsibility in required_rows.items():
|
||||
rows = [row for row in doc.splitlines() if row.startswith("|") and f"`{path}`" in row]
|
||||
assert rows and any(responsibility in row for row in rows), (path, responsibility)
|
||||
assert (ROOT / path).is_file(), path
|
||||
section = doc.split("### Shared settlement-draft policies", 1)[1]
|
||||
for clause in ("one active draft per month", "idx_settlement_drafts_active_month", "WHERE status='active'",
|
||||
"settlement_draft_service.py", "month-end", "unverified", "authority amount NULL",
|
||||
"confirmed child", "explicit reselection", "review", "approval",
|
||||
"tests/test_settlement_draft_adapters.py", "tests/test_monthly_settlement_drafts.py"):
|
||||
assert clause in section, clause
|
||||
|
||||
|
||||
def test_settlement_draft_runtime_cancel_and_recovery_contract() -> None:
|
||||
doc = text("docs/runtime-flow.md")
|
||||
assert "### Cancellation and reopening" in doc, "cancel/reopen lifecycle must be documented"
|
||||
section = doc.split("### Cancellation and reopening", 1)[1]
|
||||
for clause in ("cancel_settlement_draft(draft_id, expected_version, cancelled_by, reason, db_path=None)",
|
||||
"active", "nonempty", "cancelled_by", "cancellation_reason", "cancelled_at",
|
||||
"approval_fingerprint", "approval_version", "version", "updated_at", "BEGIN IMMEDIATE",
|
||||
"retained", "account_valuations", "asset_snapshot_runs", "asset_snapshots",
|
||||
"rollback", "no-write", "completed", "cancelled", "fresh create key",
|
||||
"same create key", "latest prior run", "correction_of_run_id", "correction_reason",
|
||||
"tests/test_settlement_draft_correction_contract.py"):
|
||||
assert clause in section, clause
|
||||
source = ast.parse(text("jarca_ledger_mcp/application/settlement_draft_service.py"))
|
||||
cancel = next(n for n in source.body if isinstance(n, ast.FunctionDef) and n.name == "cancel_settlement_draft")
|
||||
assert [arg.arg for arg in cancel.args.args] == ["draft_id", "expected_version", "cancelled_by", "reason", "db_path"]
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Public-API regressions for frozen settlement-draft policy consistency."""
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
from jarca_ledger_mcp import service as s
|
||||
from jarca_ledger_mcp.db import init_db
|
||||
|
||||
|
||||
def ok(value):
|
||||
assert value["success"], value
|
||||
return value
|
||||
|
||||
|
||||
def draft(path, month="2026-09"):
|
||||
return ok(s.create_settlement_draft(month, "synthetic creator", "draft-key", db_path=path))["draft"]
|
||||
|
||||
|
||||
def frozen_items(path, did):
|
||||
with sqlite3.connect(path) as db:
|
||||
return db.execute("SELECT source_id, evidence_json FROM settlement_draft_items WHERE draft_id=? ORDER BY source_id", (did,)).fetchall()
|
||||
|
||||
|
||||
def approve(path, d, **flags):
|
||||
r = ok(s.review_settlement_draft(d["id"], db_path=path))
|
||||
ok(s.approve_settlement_draft(d["id"], d["version"], r["review_fingerprint"], "reviewer", "synthetic approval", "ref", db_path=path, **flags))
|
||||
return r
|
||||
|
||||
|
||||
@pytest.mark.parametrize("debts", [(-40,), (40,), (40, -10, -15), (10, -40)])
|
||||
def test_signed_manual_loan_totals_remain_consistent_across_close_and_reports(tmp_path, debts):
|
||||
path = str(tmp_path / "signed-loans.sqlite3")
|
||||
init_db(path)
|
||||
ids = [ok(s.create_manual_asset(f"debt {i}", amount, "2026-09-30", "loan", db_path=path))["manual_asset"]["id"] for i, amount in enumerate(debts)]
|
||||
ok(s.create_manual_asset("asset", 100, "2026-09-30", "real_estate", db_path=path))
|
||||
with sqlite3.connect(path) as db:
|
||||
before = db.execute("SELECT id, valuation_amount, currency, source_note, memo, valuation_date FROM manual_assets ORDER BY id").fetchall()
|
||||
d = draft(path)
|
||||
frozen_before = frozen_items(path, d["id"])
|
||||
review = ok(s.review_settlement_draft(d["id"], db_path=path))
|
||||
liabilities = abs(sum(debts)); net = 100 - liabilities
|
||||
for totals in (review["totals"], review):
|
||||
assert totals["total_assets"] == 100
|
||||
assert totals["total_liabilities"] == liabilities
|
||||
assert totals["net_worth"] == net
|
||||
ok(s.approve_settlement_draft(d["id"], d["version"], review["review_fingerprint"], "reviewer", "synthetic review", "ref", db_path=path))
|
||||
closed = ok(s.finalize_settlement_draft(d["id"], d["version"], review["review_fingerprint"], "close-key", db_path=path))
|
||||
for totals in (closed["totals"], closed):
|
||||
assert totals["total_assets"] == 100 and totals["total_liabilities"] == liabilities and totals["net_worth"] == net
|
||||
lookup = ok(s.get_settlement_draft(d["id"], db_path=path))
|
||||
assert lookup["final_response"]["totals"] == closed["totals"]
|
||||
replay = s.finalize_settlement_draft(d["id"], d["version"], review["review_fingerprint"], "close-key", db_path=path)
|
||||
assert {k:v for k,v in replay.items() if k != "already_existing"} == {k:v for k,v in closed.items() if k != "already_existing"}
|
||||
report = ok(s.get_asset_snapshot_report("2026-09-30", db_path=path))
|
||||
assert (report["total_assets"], report["total_liabilities"], report["net_worth"]) == (100, liabilities, net)
|
||||
assert report["close_metadata"]["draft_id"] == d["id"]
|
||||
point = ok(s.get_net_worth_timeseries("2026-09-01", "2026-09-30", db_path=path))["points"][0]
|
||||
assert (point["total_assets"], point["total_liabilities"], point["net_worth"]) == (100, liabilities, net)
|
||||
monthly = ok(s.get_monthly_settlement_summary("2026-09", db_path=path))
|
||||
assert monthly["source_coverage"]["assets"]["draft_id"] == d["id"]
|
||||
with sqlite3.connect(path) as db:
|
||||
after = db.execute("SELECT id, valuation_amount, currency, source_note, memo, valuation_date FROM manual_assets ORDER BY id").fetchall()
|
||||
assert after == before and len(ids) == len(debts)
|
||||
assert frozen_items(path, d["id"]) == frozen_before
|
||||
assert "evidence_json" not in closed
|
||||
|
||||
|
||||
def test_lowercase_supported_currency_normalizes_without_mutating_frozen_evidence(tmp_path):
|
||||
path = str(tmp_path / "lowercase-currency.sqlite3")
|
||||
init_db(path)
|
||||
asset = ok(s.create_manual_asset("Synthetic KRW asset", 25, "2026-09-30", "real_estate", currency="krw", db_path=path))["manual_asset"]["id"]
|
||||
with sqlite3.connect(path) as db:
|
||||
raw_before = db.execute("SELECT valuation_amount, currency, source_note, memo FROM manual_assets WHERE id=?", (asset,)).fetchone()
|
||||
assert raw_before[1] == "krw"
|
||||
d = draft(path)
|
||||
r = ok(s.review_settlement_draft(d["id"], db_path=path))
|
||||
item = next(x for x in r["items"] if x["source_id"] == asset)
|
||||
assert item["currency"] == "KRW" and item["amount"] == 25
|
||||
assert not r["missing_items"] and not any(w.get("code") == "non_krw" for w in r["warnings"])
|
||||
ok(s.approve_settlement_draft(d["id"], d["version"], r["review_fingerprint"], "reviewer", "approve", "ref", db_path=path))
|
||||
closed = ok(s.finalize_settlement_draft(d["id"], d["version"], r["review_fingerprint"], "close", db_path=path))
|
||||
frozen = dict(frozen_items(path, d["id"]))
|
||||
assert json.loads(frozen[asset])["source"]["currency"] == "krw"
|
||||
assert "evidence_json" not in closed
|
||||
assert closed["totals"]["total_assets"] == closed["total_assets"] == 25
|
||||
assert ok(s.get_settlement_draft(d["id"], db_path=path))["final_response"] == closed
|
||||
assert ok(s.finalize_settlement_draft(d["id"], d["version"], r["review_fingerprint"], "close", db_path=path)) == closed
|
||||
assert ok(s.get_asset_snapshot_report("2026-09-30", db_path=path))["total_assets"] == 25
|
||||
with sqlite3.connect(path) as db:
|
||||
assert db.execute("SELECT valuation_amount, currency, source_note, memo FROM manual_assets WHERE id=?", (asset,)).fetchone() == raw_before
|
||||
|
||||
|
||||
def test_verified_cash_balance_can_be_explicitly_reselected_without_losing_authority(tmp_path):
|
||||
path = str(tmp_path / "cash-reselection.sqlite3")
|
||||
init_db(path)
|
||||
account = ok(s.create_ledger_account("Synthetic bank", type="bank", liquidity_bucket="cash", db_path=path))["account"]["id"]
|
||||
val = ok(s.register_account_valuation(account, "2026-09-30", 20, valuation_kind="cash_balance", verification_status="verified", idempotency_key="cash", db_path=path))["valuation"]["id"]
|
||||
d = draft(path)
|
||||
r = ok(s.review_settlement_draft(d["id"], db_path=path))
|
||||
row = next(x for x in r["included_items"] if x["source_id"] == account)
|
||||
assert row["valuation_id"] == val and row["amount"] == 20
|
||||
selected = ok(s.set_settlement_draft_item(d["id"], "account", account, d["version"], "selector", valuation_id=val, db_path=path))["draft"]
|
||||
r2 = ok(s.review_settlement_draft(selected["id"], db_path=path))
|
||||
row2 = next(x for x in r2["included_items"] if x["source_id"] == account)
|
||||
assert row2["valuation_id"] == val and row2["amount"] == 20
|
||||
ok(s.approve_settlement_draft(selected["id"], selected["version"], r2["review_fingerprint"], "reviewer", "approve", "ref", db_path=path))
|
||||
final = ok(s.finalize_settlement_draft(selected["id"], selected["version"], r2["review_fingerprint"], "cash-close", db_path=path))
|
||||
assert final["totals"]["total_assets"] == 20
|
||||
assert ok(s.get_settlement_draft(selected["id"], db_path=path))["final_response"]["totals"] == final["totals"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode,when,flags", [("exact", "2026-09-30", {}), ("carried_before", "2026-09-29", {"allow_carried_before": True}), ("manual_substitute", "2026-10-02", {"allow_manual_substitute": True})])
|
||||
@pytest.mark.parametrize("kind", ["balance", "cash_balance"])
|
||||
def test_bank_cash_balance_selection_modes_preserve_explicit_authority(tmp_path, mode, when, flags, kind):
|
||||
path = str(tmp_path / (mode + ".sqlite3"))
|
||||
init_db(path)
|
||||
account = ok(s.create_ledger_account("Synthetic bank", type="bank", liquidity_bucket="cash", db_path=path))["account"]["id"]
|
||||
val = ok(s.register_account_valuation(account, when, 20, valuation_kind=kind, verification_status="verified", idempotency_key="cash", db_path=path))["valuation"]["id"]
|
||||
d = draft(path)
|
||||
kwargs = {"selection_mode": mode, "selection_reason": "dated bank statement"} if mode != "exact" else {}
|
||||
d = ok(s.set_settlement_draft_item(d["id"], "account", account, d["version"], "selector", valuation_id=val, db_path=path, **kwargs))["draft"]
|
||||
r = ok(s.review_settlement_draft(d["id"], db_path=path))
|
||||
row = next(x for x in r["included_items"] if x["source_id"] == account)
|
||||
assert row["valuation_id"] == val and row["amount"] == 20
|
||||
frozen_before = frozen_items(path, d["id"])
|
||||
assert row["valuation_date"] == when
|
||||
ok(s.approve_settlement_draft(d["id"], d["version"], r["review_fingerprint"], "reviewer", "accepted dated evidence", "ref", db_path=path, **flags))
|
||||
final = ok(s.finalize_settlement_draft(d["id"], d["version"], r["review_fingerprint"], "mode-close", db_path=path))
|
||||
assert final["totals"]["total_assets"] == final["total_assets"] == 20
|
||||
assert final["contains_substitutions"] == (mode == "manual_substitute")
|
||||
assert final["contains_carried"] == (mode == "carried_before")
|
||||
assert frozen_items(path, d["id"]) == frozen_before
|
||||
assert ok(s.get_settlement_draft(d["id"], db_path=path))["final_response"] == final
|
||||
assert ok(s.finalize_settlement_draft(d["id"], d["version"], r["review_fingerprint"], "mode-close", db_path=path)) == final
|
||||
|
||||
|
||||
def test_invalid_future_exact_and_unverified_bank_candidate_cannot_supply_authority(tmp_path):
|
||||
path = str(tmp_path / "invalid-bank.sqlite3")
|
||||
init_db(path)
|
||||
account = ok(s.create_ledger_account("Synthetic bank", type="bank", liquidity_bucket="cash", db_path=path))["account"]["id"]
|
||||
future = ok(s.register_account_valuation(account, "2026-10-02", 20, valuation_kind="cash_balance", verification_status="verified", idempotency_key="future", db_path=path))["valuation"]["id"]
|
||||
d = draft(path)
|
||||
bad = s.set_settlement_draft_item(d["id"], "account", account, d["version"], "selector", valuation_id=future, selection_mode="exact", db_path=path)
|
||||
assert not bad["success"] and bad["error_code"] == "validation_error"
|
||||
assert ok(s.get_settlement_draft(d["id"], db_path=path))["draft"]["version"] == d["version"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("currency,amount,expected", [("USD", 25, None), ("KRW", 0, 0), ("KRW", None, None), (" krw ", 25, None)])
|
||||
def test_manual_currency_and_missing_value_boundaries_match_legacy_policy(tmp_path, currency, amount, expected):
|
||||
path = str(tmp_path / "manual-boundary.sqlite3")
|
||||
init_db(path)
|
||||
asset = ok(s.create_manual_asset("Synthetic boundary", amount, "2026-09-30", "real_estate", currency=currency, db_path=path))["manual_asset"]["id"]
|
||||
d = draft(path)
|
||||
before = frozen_items(path, d["id"])
|
||||
r = ok(s.review_settlement_draft(d["id"], db_path=path))
|
||||
row = next(x for x in r["items"] if x["source_id"] == asset)
|
||||
assert row["amount"] == expected
|
||||
assert row["status"] == ("missing" if expected is None else "ready")
|
||||
assert r["total_assets"] == (expected or 0)
|
||||
assert frozen_items(path, d["id"]) == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode,when,reason", [("exact", "2026-10-02", ""), ("carried_before", "2026-09-30", "carry"), ("carried_before", "2026-10-02", "carry"), ("manual_substitute", "2026-10-02", "")])
|
||||
def test_invalid_bank_date_or_missing_reason_keeps_draft_unchanged(tmp_path, mode, when, reason):
|
||||
path = str(tmp_path / "invalid-selection.sqlite3")
|
||||
init_db(path)
|
||||
account = ok(s.create_ledger_account("Synthetic bank", db_path=path))["account"]["id"]
|
||||
val = ok(s.register_account_valuation(account, when, 20, valuation_kind="cash_balance", verification_status="verified", db_path=path))["valuation"]["id"]
|
||||
d = draft(path)
|
||||
before = ok(s.get_settlement_draft(d["id"], db_path=path))
|
||||
raw = frozen_items(path, d["id"])
|
||||
result = s.set_settlement_draft_item(d["id"], "account", account, d["version"], "selector", valuation_id=val, selection_mode=mode, selection_reason=reason, db_path=path)
|
||||
assert not result["success"] and result["error_code"] == "validation_error"
|
||||
assert ok(s.get_settlement_draft(d["id"], db_path=path)) == before
|
||||
assert frozen_items(path, d["id"]) == raw
|
||||
|
||||
|
||||
def test_unverified_bank_evidence_remains_missing_after_explicit_review_selection(tmp_path):
|
||||
path = str(tmp_path / "unverified.sqlite3")
|
||||
init_db(path)
|
||||
account = ok(s.create_ledger_account("Synthetic bank", db_path=path))["account"]["id"]
|
||||
val = ok(s.register_account_valuation(account, "2026-09-30", 20, valuation_kind="cash_balance", verification_status="unverified", db_path=path))["valuation"]["id"]
|
||||
d = draft(path)
|
||||
r = ok(s.review_settlement_draft(d["id"], db_path=path))
|
||||
assert r["items"][0]["valuation_id"] == "" and r["items"][0]["candidate_amount"] == 20
|
||||
assert r["items"][0]["amount"] is None
|
||||
rejected = s.set_settlement_draft_item(d["id"], "account", account, d["version"], "selector", valuation_id=val, db_path=path)
|
||||
assert not rejected["success"] and rejected["error_code"] == "validation_error"
|
||||
d = ok(s.set_settlement_draft_item(d["id"], "account", account, d["version"], "selector", valuation_id=val, selection_mode="manual_substitute", selection_reason="review only", db_path=path))["draft"]
|
||||
r = approve(path, d, allow_missing_exclusions=True, allow_manual_substitute=True)
|
||||
assert r["items"][0]["amount"] is None and r["total_assets"] == 0
|
||||
closed = ok(s.finalize_settlement_draft(d["id"], d["version"], r["review_fingerprint"], "unverified-close", db_path=path))
|
||||
assert closed["total_assets"] == closed["totals"]["total_assets"] == 0
|
||||
assert closed["excluded_items"][0]["amount"] is None
|
||||
with sqlite3.connect(path) as db:
|
||||
assert db.execute("SELECT verification_status FROM account_valuations WHERE id=?", (val,)).fetchone()[0] == "unverified"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("account_type,kind,definition,registration_allowed,selection_allowed", [("bank", "total_value", None, True, False), ("investment", "loan_balance", "principal_only", False, False), ("loan", "total_value", None, False, False), ("loan", "balance", None, False, False), ("loan", "balance", "principal_only", True, True), ("investment", "total_value", None, True, True)])
|
||||
def test_account_type_purpose_and_representative_liability_boundaries(tmp_path, account_type, kind, definition, registration_allowed, selection_allowed):
|
||||
path = str(tmp_path / "purpose.sqlite3")
|
||||
init_db(path)
|
||||
account = ok(s.create_ledger_account("Synthetic purpose", type=account_type, db_path=path))["account"]["id"]
|
||||
registered = s.register_account_valuation(account, "2026-09-30", 20, valuation_kind=kind, representative_liability_definition=definition, verification_status="verified", db_path=path)
|
||||
assert registered["success"] == registration_allowed
|
||||
if not registration_allowed:
|
||||
assert registered["error_code"] == "validation_error"
|
||||
with sqlite3.connect(path) as db:
|
||||
assert db.execute("SELECT COUNT(*) FROM account_valuations").fetchone()[0] == 0
|
||||
return
|
||||
d = draft(path)
|
||||
before = ok(s.get_settlement_draft(d["id"], db_path=path))
|
||||
selected = s.set_settlement_draft_item(d["id"], "account", account, d["version"], "selector", valuation_id=registered["valuation"]["id"], db_path=path)
|
||||
assert selected["success"] == selection_allowed
|
||||
if not selection_allowed:
|
||||
assert selected["error_code"] == "validation_error"
|
||||
assert ok(s.get_settlement_draft(d["id"], db_path=path)) == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize("amount", [None, True, 1.5])
|
||||
def test_account_valuation_amount_requires_a_strict_integer(tmp_path, amount):
|
||||
path = str(tmp_path / "strict-amount.sqlite3")
|
||||
init_db(path)
|
||||
account = ok(s.create_ledger_account("Synthetic strict", db_path=path))["account"]["id"]
|
||||
rejected = s.register_account_valuation(account, "2026-09-30", amount, verification_status="verified", db_path=path)
|
||||
assert not rejected["success"] and rejected["error_code"] == "validation_error"
|
||||
with sqlite3.connect(path) as db:
|
||||
assert db.execute("SELECT COUNT(*) FROM account_valuations").fetchone()[0] == 0
|
||||
|
||||
|
||||
|
||||
def test_legacy_amount_approval_requires_fresh_review_after_policy_correction(tmp_path):
|
||||
import hashlib
|
||||
|
||||
path = str(tmp_path / "legacy-approval.sqlite3")
|
||||
init_db(path)
|
||||
ok(s.create_manual_asset("Synthetic asset", 100, "2026-09-30", "real_estate", db_path=path))
|
||||
ok(s.create_manual_asset("Synthetic signed debt", -40, "2026-09-30", "loan", db_path=path))
|
||||
d = draft(path)
|
||||
frozen_before = frozen_items(path, d["id"])
|
||||
# Historical persisted fingerprint schema: immutable baseline _review serial
|
||||
# did not include normalized totals. Reconstruct its exact manual-only inputs,
|
||||
# rather than treating the current fingerprint as a historical approval.
|
||||
with sqlite3.connect(path) as db:
|
||||
db.row_factory = sqlite3.Row
|
||||
stored = [dict(x) for x in db.execute("SELECT * FROM settlement_draft_items WHERE draft_id=? ORDER BY source_type,source_id", (d["id"],))]
|
||||
states = []
|
||||
for item in stored:
|
||||
current = {"source": dict(db.execute("SELECT * FROM manual_assets WHERE id=?", (item["source_id"],)).fetchone())}
|
||||
states.append({"stored": item, "current": current, "candidates": []})
|
||||
assert db.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
|
||||
serial = json.dumps({"draft_id": d["id"], "version": d["version"], "basis_date": d["basis_date"], "states": states, "warnings": [], "latest_run": "", "correction_parent": d["correction_of_run_id"]}, sort_keys=True, ensure_ascii=False)
|
||||
legacy_fingerprint = hashlib.sha256(serial.encode()).hexdigest()
|
||||
flags = {"allow_missing_exclusions": False, "allow_carried_before": False, "allow_manual_substitute": False}
|
||||
with sqlite3.connect(path) as db:
|
||||
db.execute("UPDATE settlement_drafts SET approval_fingerprint=?,approval_version=?,approved_by=?,approved_at=?,approval_reason=?,approval_reference=?,approval_flags=? WHERE id=?", (legacy_fingerprint, d["version"], "synthetic legacy reviewer", "2026-10-10T00:00:00Z", "reviewed legacy net 140", "synthetic reference", json.dumps(flags), d["id"]))
|
||||
rejected = s.finalize_settlement_draft(d["id"], d["version"], legacy_fingerprint, "legacy-close", db_path=path)
|
||||
assert not rejected["success"], "a historical amount approval must not authorize corrected totals"
|
||||
assert rejected["error_code"] == "conflict"
|
||||
with sqlite3.connect(path) as db:
|
||||
assert db.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
|
||||
assert db.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0] == 0
|
||||
row = db.execute("SELECT status,version,final_run_id FROM settlement_drafts WHERE id=?", (d["id"],)).fetchone()
|
||||
assert row == ("active", d["version"], "")
|
||||
assert frozen_items(path, d["id"]) == frozen_before
|
||||
reviewed = approve(path, d)
|
||||
assert reviewed["review_fingerprint"] != legacy_fingerprint
|
||||
final = ok(s.finalize_settlement_draft(d["id"], d["version"], reviewed["review_fingerprint"], "legacy-close", db_path=path))
|
||||
assert final["totals"] == {"total_assets": 100, "total_liabilities": 40, "net_worth": 60}
|
||||
assert ok(s.get_settlement_draft(d["id"], db_path=path))["final_response"] == final
|
||||
assert ok(s.finalize_settlement_draft(d["id"], d["version"], reviewed["review_fingerprint"], "legacy-close", db_path=path)) == final
|
||||
assert frozen_items(path, d["id"]) == frozen_before
|
||||
|
||||
|
||||
def test_completed_close_replay_uses_immutable_response_without_revalidation(tmp_path, monkeypatch):
|
||||
from jarca_ledger_mcp.application import settlement_draft_service
|
||||
|
||||
path = str(tmp_path / "completed-replay.sqlite3")
|
||||
init_db(path)
|
||||
ok(s.create_manual_asset("Synthetic asset", 100, "2026-09-30", "real_estate", db_path=path))
|
||||
d = draft(path)
|
||||
reviewed = approve(path, d)
|
||||
final = ok(s.finalize_settlement_draft(d["id"], d["version"], reviewed["review_fingerprint"], "completed-close", db_path=path))
|
||||
|
||||
def forbidden_review(*args):
|
||||
raise AssertionError("completed history must not be reinterpreted")
|
||||
|
||||
monkeypatch.setattr(settlement_draft_service, "_review", forbidden_review)
|
||||
assert ok(s.finalize_settlement_draft(d["id"], d["version"], reviewed["review_fingerprint"], "completed-close", db_path=path)) == final
|
||||
assert ok(s.get_settlement_draft(d["id"], db_path=path))["final_response"] == final
|
||||
Reference in New Issue
Block a user