Fix Review 63 snapshot and balance regressions
This commit is contained in:
@@ -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,162 @@
|
||||
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_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"
|
||||
Reference in New Issue
Block a user