188 lines
12 KiB
Python
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']
|