Files
jarca-ledger-mcp/tests/test_settlement_draft_contract_scenarios.py
T

188 lines
12 KiB
Python

"""Clause-level acceptance uses real synthetic SQLite and public consumers."""
import sqlite3
from concurrent.futures import ThreadPoolExecutor
from jarca_ledger_mcp import service as s
from jarca_ledger_mcp.db import init_db
def ok(result):
assert result['success'], result
return result
def account(path, name, **fields):
return ok(s.create_ledger_account(name, db_path=path, **fields))['account']['id']
def value(path, aid, key, when, amount, **fields):
return ok(s.register_account_valuation(aid, when, amount, idempotency_key=key, verification_status=fields.pop('verification_status', 'verified'), db_path=path, **fields))['valuation']['id']
def choose(path, draft, aid, vid, **fields):
return ok(s.set_settlement_draft_item(draft['id'], 'account', aid, draft['version'], 'synthetic selector', valuation_id=vid, db_path=path, **fields))['draft']
def approve(path, draft, **flags):
review = ok(s.review_settlement_draft(draft['id'], db_path=path))
ok(s.approve_settlement_draft(draft['id'], draft['version'], review['review_fingerprint'], 'synthetic reviewer', 'synthetic review reason', 'synthetic reference', db_path=path, **flags))
return review
def test_date_metadata_public_contract_names_survive_all_closed_consumers(tmp_path):
path = str(tmp_path / 'dates.sqlite3')
init_db(path)
aid = account(path, 'Synthetic loan', type='loan', liquidity_bucket='loan')
vid = value(path, aid, 'loan', '2026-10-07', 40, valuation_kind='loan_balance', representative_liability_definition='principal_only')
with sqlite3.connect(path) as c:
c.execute("UPDATE account_valuations SET recorded_at='2026-10-08T00:00:00Z' WHERE id=?", (vid,))
d = ok(s.create_settlement_draft('2026-09', 'creator', 'initial', db_path=path))['draft']
d = choose(path, d, aid, vid, selection_mode='manual_substitute', selection_reason='Synthetic late loan statement')
review = approve(path, d, allow_manual_substitute=True)
final = ok(s.finalize_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'final', db_path=path))
report = ok(s.get_asset_snapshot_report('2026-09-30', db_path=path))
series = ok(s.get_net_worth_timeseries('2026-09-01', '2026-09-30', db_path=path))
monthly = ok(s.get_monthly_settlement_summary('2026-09', db_path=path))
expected = {'valuation_date': '2026-10-07', 'date_difference_days': 7, 'date_difference_direction': 'after', 'age_days': -7, 'recorded_at': '2026-10-08T00:00:00Z', 'representative_liability_definition': 'principal_only'}
rows = [review['included_items'][0], final['items'][0], report['close_metadata']['applied_evidence'][0], series['points'][0]['applied_evidence'][0], monthly['source_coverage']['assets']['applied_evidence'][0]]
for row in rows:
assert {key: row.get(key) for key in expected} == expected
for row in rows[1:]:
assert row['approval']['allow_manual_substitute'] is True
assert row['approval']['allow_missing_exclusions'] is False
assert row['approval']['allow_carried_before'] is False
def test_representative_integer_precision_no_double_sum_and_scope_rejection(tmp_path):
path = str(tmp_path / 'precision.sqlite3')
init_db(path)
aid = account(path, 'Synthetic pension', type='investment', liquidity_bucket='investment')
other = account(path, 'Synthetic other')
amount = 9007199254740993
representative = value(path, aid, 'total', '2026-09-30', amount, valuation_kind='total_value')
value(path, aid, 'cash', '2026-09-30', 100, valuation_kind='cash_balance')
wrong = value(path, other, 'other', '2026-09-30', 10)
d = ok(s.create_settlement_draft('2026-09', 'creator', 'initial', db_path=path))['draft']
review = ok(s.review_settlement_draft(d['id'], db_path=path))
item = next(x for x in review['included_items'] if x['source_id'] == aid)
assert item['valuation_id'] == representative and type(item['amount']) is int and item['amount'] == amount
assert review['totals']['net_worth'] == amount + 10
before = ok(s.get_settlement_draft(d['id'], db_path=path))
bad = s.set_settlement_draft_item(d['id'], 'account', aid, d['version'], 'selector', valuation_id=wrong, db_path=path)
assert not bad['success'] and bad['error_code'] == 'validation_error'
assert ok(s.get_settlement_draft(d['id'], db_path=path)) == before
def test_unverified_substitute_only_finalizes_as_explicit_approved_missing(tmp_path):
path = str(tmp_path / 'unverified.sqlite3')
init_db(path)
aid = account(path, 'Synthetic pension', type='investment', liquidity_bucket='investment')
vid = value(path, aid, 'candidate', '2026-10-05', 100, valuation_kind='total_value', verification_status='unverified')
d = ok(s.create_settlement_draft('2026-09', 'creator', 'initial', db_path=path))['draft']
d = choose(path, d, aid, vid, selection_mode='manual_substitute', selection_reason='Synthetic review-only candidate')
review = ok(s.review_settlement_draft(d['id'], db_path=path))
assert not review['included_items'] and review['missing_items'][0]['amount'] is None
bad = s.approve_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'reviewer', 'reason', 'ref', allow_manual_substitute=True, db_path=path)
assert not bad['success']
before = s.finalize_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'final', db_path=path)
assert not before['success']
review = approve(path, d, allow_missing_exclusions=True)
final = ok(s.finalize_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'final', db_path=path))
assert not final['items'] and final['excluded_items'][0]['amount'] is None
assert final['excluded_items'][0]['candidate_amount'] == 100
assert final['totals']['net_worth'] == 0 and final['has_approved_missing']
assert not final['contains_substitutions'] and final['close_status'] == 'conditional'
def test_transaction_readiness_is_independent_of_asset_draft_close(tmp_path):
path = str(tmp_path / 'separation.sqlite3')
init_db(path)
aid = account(path, 'Synthetic cash')
value(path, aid, 'cash', '2026-09-30', 100)
tx = ok(s.create_ledger_transaction('2026-09-15', amount=5, account_id=aid, category='미분류', db_path=path))
assert tx['success']
ok(s.upsert_review_decision('transaction', tx['transaction']['id'], status='pending', operator='synthetic reviewer', db_path=path))
before = ok(s.get_monthly_settlement_summary('2026-09', db_path=path))
assert before['unresolved_blocker_count'] > 0
with sqlite3.connect(path) as c:
original = c.execute('SELECT * FROM transactions').fetchall()
d = ok(s.create_settlement_draft('2026-09', 'creator', 'initial', db_path=path))['draft']
review = ok(s.review_settlement_draft(d['id'], db_path=path))
assert review['totals']['total_assets'] == 100
assert not review['complete'] and review['cashflow_blockers']
blocked = s.approve_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'synthetic reviewer', 'reason', 'ref', db_path=path)
assert not blocked['success']
with sqlite3.connect(path) as c:
assert c.execute('SELECT COUNT(*) FROM asset_snapshot_runs').fetchone()[0] == 0
policy = {'savings_categories': [], 'category_decisions': [{'category': '미분류', 'subcategory': '', 'cashflow_kind': 'excluded', 'reason': 'synthetic explicit exclusion'}]}
updated = s.set_settlement_cashflow_policy(d['id'], d['version'], 'synthetic reviewer', policy, db_path=path)
assert updated['success']
d = updated['draft']
assert d['version'] == 2
review = ok(s.review_settlement_draft(d['id'], db_path=path))
assert review['complete']
ok(s.approve_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'synthetic reviewer', 'reason', 'ref', db_path=path))
final = ok(s.finalize_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'final', db_path=path))
after = ok(s.get_monthly_settlement_summary('2026-09', db_path=path))
assert final['totals']['net_worth'] == 100 and after['source_coverage']['assets']['is_closed_snapshot']
assert after['unresolved_blockers'] == before['unresolved_blockers'] and after['unresolved_blocker_count'] > 0
with sqlite3.connect(path) as c:
assert c.execute('SELECT * FROM transactions').fetchall() == original
def test_synthetic_september_future_pension_loan_late_exact_warnings_stale_rollback_concurrent_retry(tmp_path):
path = str(tmp_path / 'september.sqlite3')
init_db(path)
cash = account(path, 'Synthetic cash')
pension = account(path, 'Synthetic pension', type='investment', liquidity_bucket='investment')
loan = account(path, 'Synthetic loan', type='loan', liquidity_bucket='loan')
rejected = account(path, 'Synthetic rejected')
foreign = account(path, 'Synthetic USD', currency='USD')
unknown = ok(s.create_manual_asset('Synthetic undated property', None, '', 'real_estate', db_path=path))['manual_asset']['id']
cv = value(path, cash, 'cash', '2026-09-30', 100)
pv = value(path, pension, 'pension', '2026-10-05', 120, valuation_kind='total_value')
lv = value(path, loan, 'loan', '2026-10-07', 40, valuation_kind='loan_balance', representative_liability_definition='principal_only')
value(path, rejected, 'rejected', '2026-09-30', 999, verification_status='rejected')
value(path, foreign, 'foreign', '2026-09-30', 77, currency='USD')
with sqlite3.connect(path) as c:
c.execute("UPDATE account_valuations SET recorded_at='2026-10-10T00:00:00Z' WHERE id=?", (cv,))
d = ok(s.create_settlement_draft('2026-09', 'creator', 'initial', db_path=path))['draft']
first = ok(s.review_settlement_draft(d['id'], db_path=path))
assert first['totals']['net_worth'] == 100
d = choose(path, d, pension, pv, selection_mode='manual_substitute', selection_reason='Synthetic Oct 05 pension')
d = choose(path, d, loan, lv, selection_mode='manual_substitute', selection_reason='Synthetic Oct 07 loan')
review = approve(path, d, allow_missing_exclusions=True, allow_manual_substitute=True)
assert review['totals']['net_worth'] == 180
assert {x['source_id'] for x in review['missing_items']} == {rejected, foreign, unknown}
assert all(x['amount'] is None for x in review['missing_items'])
assert any(w['code'] == 'non_krw' for w in review['warnings'])
with sqlite3.connect(path) as c:
c.execute("UPDATE account_valuations SET memo='Synthetic evidence drift' WHERE id=?", (cv,))
stale = s.finalize_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'final', db_path=path)
assert not stale['success'] and stale['error_code'] == 'conflict'
d = choose(path, d, cash, cv)
review = approve(path, d, allow_missing_exclusions=True, allow_manual_substitute=True)
with sqlite3.connect(path) as c:
c.execute("CREATE TRIGGER synthetic_failure BEFORE INSERT ON account_snapshot BEGIN SELECT RAISE(ABORT, 'synthetic rollback'); END")
failed = s.finalize_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'final', db_path=path)
assert not failed['success'] and failed['error_code'] == 'db_error'
with sqlite3.connect(path) as c:
assert c.execute('SELECT COUNT(*) FROM asset_snapshot_runs').fetchone()[0] == 0
assert c.execute('SELECT COUNT(*) FROM account_snapshot').fetchone()[0] == 0
c.execute('DROP TRIGGER synthetic_failure')
assert ok(s.get_settlement_draft(d['id'], db_path=path))['draft']['status'] == 'active'
def finish():
return s.finalize_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'final', db_path=path)
with ThreadPoolExecutor(max_workers=2) as pool:
futures = [pool.submit(finish) for _ in range(2)]
results = [ok(f.result()) for f in futures]
assert results[0] == results[1]
final = results[0]
assert final['contains_substitutions'] and final['has_approved_missing'] and not final['contains_carried']
assert final['totals']['net_worth'] == 180 and len(final['items']) == 3
assert {x['valuation_date'] for x in final['items']} == {'2026-09-30', '2026-10-05', '2026-10-07'}
assert finish() == final
with sqlite3.connect(path) as c:
assert c.execute('SELECT COUNT(*) FROM asset_snapshot_runs').fetchone()[0] == 1
assert c.execute('SELECT COUNT(*) FROM account_snapshot').fetchone()[0] == final['inserted_count']