feat: add persistent monthly settlement drafts and approved valuation selection
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
"""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 = approve(path, d)
|
||||
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 asset_snapshots 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 asset_snapshots').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 asset_snapshots').fetchone()[0] == final['inserted_count']
|
||||
Reference in New Issue
Block a user