"""ZenMoney transfer <-> broker deposit matching: the pure core on synthetic candidates, plus one pass over the real tables.""" from datetime import date, timedelta from decimal import Decimal from sqlalchemy import select from factories import make_account, make_event, make_txn from fintracker.analytics import today_local from fintracker.db import get_sessionmaker from fintracker.ledger.matching import ( BrokerCandidate, FlowDirection, ZmCandidate, business_days_between, match_flows, rebuild_flow_links, ) from fintracker.models import ( AccountKind, AccountLink, AccountRole, CashTxn, EventKind, FlowLink, FlowLinkKind, FlowType, ) D = Decimal BROKER = 42 _seq = iter(range(1, 10_000)) def zm(day: str, amount, *, currency="RUB", direction=FlowDirection.to_broker, account=BROKER): return ZmCandidate( cash_txn_id=next(_seq), date=date.fromisoformat(day), amount=D(amount), currency=currency, direction=direction, broker_account_id=account, route="account_link", ) def broker(day: str, amount, *, currency="RUB", direction=FlowDirection.to_broker, account=BROKER): return BrokerCandidate( event_id=next(_seq), account_id=account, date=date.fromisoformat(day), amount=D(amount), currency=currency, direction=direction, ) def test_exact_pair_matches_with_full_confidence(): a, b = zm("2026-03-10", "10000"), broker("2026-03-10", "10000") result = match_flows([a], [b]) assert len(result.matches) == 1 match = result.matches[0] assert (match.zm, match.broker) == (a, b) assert match.amount_delta == D(0) assert match.day_gap == 0 assert match.confidence == D(1) assert result.unmatched_zm == [] and result.unmatched_broker == [] def test_small_difference_is_still_the_same_money(): # 40 ₽ on 10 000 is inside 0.5 %, and a day later is inside the window result = match_flows([zm("2026-03-10", "10000")], [broker("2026-03-11", "9960")]) assert len(result.matches) == 1 assert result.matches[0].amount_delta == D(40) assert result.matches[0].day_gap == 1 assert D(0) < result.matches[0].confidence < D(1) def test_a_difference_over_the_tolerance_is_not_a_pair(): # 60 ₽ on 10 000 is 0.6 % — past the relative tolerance, and past 1 ₽ absolute result = match_flows([zm("2026-03-10", "10000")], [broker("2026-03-10", "9940")]) assert result.matches == [] assert len(result.unmatched_zm) == 1 and len(result.unmatched_broker) == 1 def test_different_currencies_never_pair(): result = match_flows([zm("2026-03-10", "1000", currency="USD")], [broker("2026-03-10", "1000")]) assert result.matches == [] def test_another_broker_account_never_pairs(): result = match_flows([zm("2026-03-10", "1000")], [broker("2026-03-10", "1000", account=99)]) assert result.matches == [] def test_weekend_does_not_count_towards_the_gap(): # 2026-03-13 is a Friday, 2026-03-18 the next Wednesday: Mon Tue Wed == 3 business days assert business_days_between(date(2026, 3, 13), date(2026, 3, 18)) == 3 assert business_days_between(date(2026, 3, 13), date(2026, 3, 16)) == 1 inside = match_flows([zm("2026-03-13", "5000")], [broker("2026-03-18", "5000")]) assert len(inside.matches) == 1 # one weekday further is four business days — out of the window outside = match_flows([zm("2026-03-13", "5000")], [broker("2026-03-19", "5000")]) assert outside.matches == [] def test_two_candidates_for_one_amount_produce_one_link(): first, second = zm("2026-03-10", "10000"), zm("2026-03-11", "10000") result = match_flows([first, second], [broker("2026-03-10", "10000")]) assert len(result.matches) == 1 # the same-day candidate wins the tie: equal delta, smaller gap assert result.matches[0].zm is first assert [c.cash_txn_id for c in result.unmatched_zm] == [second.cash_txn_id] assert result.unmatched_broker == [] def test_the_closer_amount_wins_over_the_closer_date(): exact_later = zm("2026-03-12", "10000") approximate_same_day = zm("2026-03-10", "9990") result = match_flows([exact_later, approximate_same_day], [broker("2026-03-10", "10000")]) assert result.matches[0].zm is exact_later def test_withdrawal_matches_the_other_direction_only(): out = zm("2026-03-10", "7000", direction=FlowDirection.from_broker) paired = match_flows([out], [broker("2026-03-10", "7000", direction=FlowDirection.from_broker)]) assert len(paired.matches) == 1 crossed = match_flows([out], [broker("2026-03-10", "7000")]) assert crossed.matches == [] async def test_rebuild_links_a_real_transfer_and_reclassifies_it(app): card = await make_account(name="Карта") broker_account = await make_account( name="ИИС", kind=AccountKind.broker, role=AccountRole.investment, source="tinvest" ) mirror = await make_account( name="ИИС (зеркало)", include_in_net_worth=False, mirror_of_account_id=broker_account ) d = today_local() - timedelta(days=10) linked = await make_txn( d, income="10000", income_account_id=mirror, outcome="10000", outcome_account_id=card ) # same shape, but no broker event within reach — stays a leftover orphan = await make_txn( d - timedelta(days=60), income="3333", income_account_id=mirror, outcome="3333", outcome_account_id=card, ) event_id = await make_event( d + timedelta(days=1), account_id=broker_account, kind=EventKind.deposit, amount="10000" ) # a withdrawal nobody transferred back — the other kind of leftover await make_event(d, account_id=broker_account, kind=EventKind.withdrawal, amount="-500") async with get_sessionmaker()() as session: await rebuild_flow_links(session) await session.commit() async with get_sessionmaker()() as session: links = (await session.execute(select(FlowLink))).scalars().all() assert len(links) == 1 link = links[0] assert (link.cash_txn_id, link.event_id) == (linked, event_id) assert link.kind == FlowLinkKind.auto assert link.amount_delta == D(0) assert link.day_gap == 1 rows = (await session.execute(select(CashTxn.id, CashTxn.flow_type))).all() flows = {r.id: r.flow_type for r in rows} assert flows[linked] == FlowType.internal_transfer # an unmatched transfer keeps whatever `classify` decided; here nothing ran, so the # default stands — the matcher must not touch rows it cannot explain assert flows[orphan] == FlowType.other async def test_account_link_maps_an_account_without_a_mirror(app): card = await make_account(name="Карта") broker_account = await make_account( name="Брокер", kind=AccountKind.broker, role=AccountRole.investment, source="tinvest" ) zm_twin = await make_account(name="Брокерский (ZM)") async with get_sessionmaker()() as session: session.add(AccountLink(zm_account_id=zm_twin, broker_account_id=broker_account)) await session.commit() d = today_local() - timedelta(days=5) txn_id = await make_txn( d, income="25000", income_account_id=zm_twin, outcome="25000", outcome_account_id=card ) event_id = await make_event( d, account_id=broker_account, kind=EventKind.deposit, amount="25000" ) async with get_sessionmaker()() as session: await rebuild_flow_links(session) await session.commit() async with get_sessionmaker()() as session: link = (await session.execute(select(FlowLink))).scalar_one() assert (link.cash_txn_id, link.event_id) == (txn_id, event_id) assert link.note == "account_link/to_broker"