diff --git a/backend/src/fintracker/ledger/matching.py b/backend/src/fintracker/ledger/matching.py new file mode 100644 index 0000000..e314d57 --- /dev/null +++ b/backend/src/fintracker/ledger/matching.py @@ -0,0 +1,526 @@ +"""Pair a ZenMoney transfer with the broker deposit/withdrawal it actually was (plan §1.6 C). + +The same money arrives twice: ZenMoney sees a transfer leaving a card, the broker sees a +`deposit` on its own ledger. Nothing joins them, so net worth counts both, and a top-up of a +brokerage account reads as an expense — the asymmetry "I invested, therefore I got poorer" +that fin-dashboard could only report. A `flow_link` row is the join, and once it exists the +ZenMoney side is reclassified as `internal_transfer`. + +There is no shared identifier to join on — ZenMoney does not know the broker's operation id — +so the pairing is a scored guess over three facts, in the order they matter: + +* **the broker account is known**, from `account_link`, from `account.mirror_of_account_id` + or from a `broker_target` rule. A pair is never considered across accounts; +* **the amount**, within `max(1 ₽, 0.5 %)` — a transfer can lose a rounding kopeck, and a + currency-conversion top-up arrives a little off; +* **the date, in BUSINESS days**. Money sent on Friday lands on Monday: counting calendar + days would either reject that pair or, with a wider window, start matching a different + week's top-up of the same round sum. + +Matching is greedy and strictly 1:1 (both unique constraints on `flow_link` enforce it): +pairs are ranked best-first and taken while both sides are still free. Two candidates for the +same 10 000 ₽ therefore produce one link and one leftover, never two half-links — and the +leftover is reported, not guessed at. Everything that stays unmatched is a FINDING and, later, +`GET /links/unmatched` for manual pairing. + +Direction, read off the live data rather than assumed: a ZenMoney account that mirrors a +broker account holds the broker's cash, so money moving INTO it is a broker `deposit` and +money leaving it is a `withdrawal`. The `broker_target` rule covers the opposite shape — a +brokerage that ZenMoney does not model at all — where the transfer is a plain outflow from an +ordinary card, and there an outcome is the deposit. + +`match_flows` is pure: two sequences of light dataclasses in, pairs out, no session. All the +rules above are tested on synthetic candidates; `rebuild_flow_links` below is the thin I/O +wrapper that loads candidates, runs it, and replaces the automatic links. +""" + +from __future__ import annotations + +import enum +import logging +from collections.abc import Sequence +from dataclasses import dataclass, field +from datetime import date, timedelta +from decimal import Decimal + +from sqlalchemy import delete, insert, or_, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from fintracker.analytics import FINDINGS +from fintracker.models import ( + Account, + AccountKind, + AccountLink, + CashTxn, + Event, + EventKind, + EventStatus, + FlowLink, + FlowLinkKind, + FlowType, + Rule, + RuleKind, + RuleMatchType, +) + +log = logging.getLogger(__name__) + +ZERO = Decimal(0) +ONE = Decimal(1) + +MIN_TOLERANCE = Decimal("1") +"""Absolute amount tolerance: a rounding kopeck must not cost a link.""" +REL_TOLERANCE = Decimal("0.005") +"""Relative amount tolerance (0.5 %), which takes over on large transfers.""" +MAX_GAP_BUSINESS_DAYS = 3 +"""Settlement window. Business days only — nothing reaches a broker over a weekend.""" + + +class FlowDirection(enum.StrEnum): + """Which way the money crossed the brokerage boundary.""" + + to_broker = "to_broker" + """ZenMoney transfer in, broker `deposit`.""" + from_broker = "from_broker" + """Broker `withdrawal`, ZenMoney money coming back.""" + + +@dataclass(frozen=True) +class ZmCandidate: + """What the matcher needs from a `cash_txn` — deliberately not the ORM object.""" + + cash_txn_id: int + date: date + amount: Decimal + """Always positive: the magnitude that crossed the boundary.""" + currency: str + direction: FlowDirection + broker_account_id: int + """Which broker account this transfer is about; resolved by `route`.""" + route: str + """account_link | mirror | rule — how the broker account was identified.""" + + +@dataclass(frozen=True) +class BrokerCandidate: + """A `deposit` / `withdrawal` event with no link yet.""" + + event_id: int + account_id: int + date: date + amount: Decimal + """Always positive; the sign lives in `direction`.""" + currency: str + direction: FlowDirection + + +@dataclass(frozen=True) +class FlowMatch: + zm: ZmCandidate + broker: BrokerCandidate + amount_delta: Decimal + day_gap: int + """Business days between the two dates.""" + confidence: Decimal + """0..1; 1 is the same day and the same kopeck.""" + + +@dataclass +class MatchResult: + matches: list[FlowMatch] = field(default_factory=list) + unmatched_zm: list[ZmCandidate] = field(default_factory=list) + unmatched_broker: list[BrokerCandidate] = field(default_factory=list) + + +def business_days_between(a: date, b: date) -> int: + """Weekdays strictly after the earlier date, up to and including the later one. + + Friday -> Monday is 1, not 3: that is the whole point of counting this way. Bank holidays + are not modelled — the window is small and an extra holiday only makes the matcher + slightly stricter, which is the safe direction for a guess. + """ + lo, hi = (a, b) if a <= b else (b, a) + days = 0 + current = lo + while current < hi: + current += timedelta(days=1) + if current.weekday() < 5: + days += 1 + return days + + +def amount_tolerance(amount: Decimal) -> Decimal: + """How far two amounts may differ and still be the same money.""" + return max(MIN_TOLERANCE, abs(amount) * REL_TOLERANCE) + + +def score( + zm: ZmCandidate, broker: BrokerCandidate, *, max_gap: int = MAX_GAP_BUSINESS_DAYS +) -> FlowMatch | None: + """A pair, or None when the two cannot be the same money. + + Confidence is only a ranking and review aid — the hard filters below are what decide. + """ + if zm.broker_account_id != broker.account_id: + return None + if zm.direction is not broker.direction: + return None + if zm.currency != broker.currency: + return None + + delta = abs(zm.amount - broker.amount) + tolerance = amount_tolerance(max(zm.amount, broker.amount)) + if delta > tolerance: + return None + + gap = business_days_between(zm.date, broker.date) + if gap > max_gap: + return None + + # a coefficient, so the arithmetic may be sloppy — but it is stored, hence Decimal + confidence = ( + ONE + - Decimal("0.4") * (delta / tolerance) + - Decimal("0.3") * (Decimal(gap) / Decimal(max_gap) if max_gap else ZERO) + ) + return FlowMatch( + zm=zm, + broker=broker, + amount_delta=delta, + day_gap=gap, + confidence=confidence.quantize(Decimal("0.0001")), + ) + + +def match_flows( + zm_candidates: Sequence[ZmCandidate], + broker_candidates: Sequence[BrokerCandidate], + *, + max_gap: int = MAX_GAP_BUSINESS_DAYS, +) -> MatchResult: + """Greedy 1:1 pairing, best pairs first: smaller amount delta, then smaller day gap. + + Ordering by delta before gap is deliberate. An exact sum three days later is the same + money far more often than an approximate sum on the same day — people transfer round + numbers, and two round numbers on one day are genuinely ambiguous. + """ + pairs = [ + match + for zm in zm_candidates + for broker in broker_candidates + if (match := score(zm, broker, max_gap=max_gap)) is not None + ] + pairs.sort(key=lambda m: (m.amount_delta, m.day_gap, m.zm.cash_txn_id, m.broker.event_id)) + + result = MatchResult() + used_zm: set[int] = set() + used_broker: set[int] = set() + for pair in pairs: + if pair.zm.cash_txn_id in used_zm or pair.broker.event_id in used_broker: + continue + used_zm.add(pair.zm.cash_txn_id) + used_broker.add(pair.broker.event_id) + result.matches.append(pair) + + result.unmatched_zm = [c for c in zm_candidates if c.cash_txn_id not in used_zm] + result.unmatched_broker = [c for c in broker_candidates if c.event_id not in used_broker] + return result + + +# --------------------------------------------------------------------------- I/O + + +async def rebuild_flow_links(session: AsyncSession) -> None: + """Replace the automatic links and reclassify the transfers they explain. + + Manual links are the user's judgement and are never rebuilt: both their sides are taken + out of the candidate pools before matching, so the matcher can only ever fill the gaps + around them. + """ + manual_txns, manual_events = await _manual_links(session) + zm_candidates = [ + c for c in await _load_zm_candidates(session) if c.cash_txn_id not in manual_txns + ] + broker_candidates = [ + c for c in await _load_broker_candidates(session) if c.event_id not in manual_events + ] + + result = match_flows(zm_candidates, broker_candidates) + + await session.execute(delete(FlowLink).where(FlowLink.kind == FlowLinkKind.auto)) + if result.matches: + await session.execute( + insert(FlowLink), + [ + { + "cash_txn_id": m.zm.cash_txn_id, + "event_id": m.broker.event_id, + "kind": FlowLinkKind.auto, + "confidence": m.confidence, + "amount_delta": m.amount_delta, + "currency": m.zm.currency, + "day_gap": m.day_gap, + "note": f"{m.zm.route}/{m.zm.direction.value}", + } + for m in result.matches + ], + ) + + # the point of the whole module: a linked transfer is not spending. `classify` has + # already rebuilt every flow_type from the rules, so this overwrite is the last word + # for exactly the transactions a link explains — including the manual ones. + linked_txns = {m.zm.cash_txn_id for m in result.matches} | manual_txns + if linked_txns: + await session.execute( + update(CashTxn) + .where(CashTxn.id.in_(linked_txns), CashTxn.deleted.is_(False)) + .values(flow_type=FlowType.internal_transfer) + .execution_options(synchronize_session=False) + ) + + _report(result) + log.info( + "flow links: %s matched, %s ZenMoney and %s broker candidates left over", + len(result.matches), + len(result.unmatched_zm), + len(result.unmatched_broker), + ) + + +async def _manual_links(session: AsyncSession) -> tuple[set[int], set[int]]: + rows = ( + await session.execute( + select(FlowLink.cash_txn_id, FlowLink.event_id).where( + FlowLink.kind == FlowLinkKind.manual + ) + ) + ).all() + return {r.cash_txn_id for r in rows}, {r.event_id for r in rows} + + +async def _broker_targets(session: AsyncSession) -> dict[int, tuple[int, str]]: + """ZenMoney account -> (broker account, route), explicit mapping winning over the mirror.""" + targets: dict[int, tuple[int, str]] = {} + mirrors = ( + await session.execute( + select(Account.id, Account.mirror_of_account_id).where( + Account.mirror_of_account_id.is_not(None) + ) + ) + ).all() + for row in mirrors: + if row.mirror_of_account_id is not None: + targets[row.id] = (row.mirror_of_account_id, "mirror") + + links = (await session.execute(select(AccountLink))).scalars().all() + for link in links: + targets[link.zm_account_id] = (link.broker_account_id, "account_link") + return targets + + +async def _rule_targets(session: AsyncSession) -> list[tuple[Rule, int]]: + """Enabled `broker_target` rules whose value names a real broker account.""" + rules = ( + ( + await session.execute( + select(Rule) + .where(Rule.kind == RuleKind.broker_target, Rule.enabled.is_(True)) + .order_by(Rule.priority, Rule.id) + ) + ) + .scalars() + .all() + ) + if not rules: + return [] + + broker_ids = set( + (await session.execute(select(Account.id).where(Account.kind == AccountKind.broker))) + .scalars() + .all() + ) + resolved: list[tuple[Rule, int]] = [] + for rule in rules: + try: + account_id = int(rule.value or "") + except ValueError: + account_id = 0 + if account_id in broker_ids: + resolved.append((rule, account_id)) + else: + FINDINGS.add( + "flow_link_bad_rule", + "warn", + f"Правило broker_target #{rule.id} указывает на несуществующий " + f"брокерский счёт {rule.value!r}", + ref={"rule_id": rule.id, "value": rule.value}, + ) + return resolved + + +async def _load_zm_candidates(session: AsyncSession) -> list[ZmCandidate]: + """Every transfer that could be the ZenMoney half of a broker cash flow. + + Two shapes, and a transaction may legitimately produce two candidates — a transfer from + one brokerage mirror straight into another is a withdrawal and a deposit at once. + """ + targets = await _broker_targets(session) + rules = await _rule_targets(session) + if not targets and not rules: + return [] + + conditions = [] + if targets: + conditions.append(CashTxn.income_account_id.in_(targets)) + conditions.append(CashTxn.outcome_account_id.in_(targets)) + query = select(CashTxn).where(CashTxn.deleted.is_(False)) + if not rules: + # without rules only the mapped accounts can produce a candidate, so let Postgres + # do the filtering; a rule may match on payee or comment and has to be evaluated row + # by row, and the personal volume (10^4 rows) makes that cheap enough + query = query.where(or_(*conditions)) + txns = (await session.execute(query)).scalars().all() + + candidates: list[ZmCandidate] = [] + for txn in txns: + mapped = False + income_target = targets.get(txn.income_account_id or 0) + if income_target and txn.income > ZERO: + # money landed on the ZenMoney twin of a broker account: that is a deposit + candidates.append( + ZmCandidate( + cash_txn_id=txn.id, + date=txn.date, + amount=txn.income, + currency=txn.income_currency or "RUB", + direction=FlowDirection.to_broker, + broker_account_id=income_target[0], + route=income_target[1], + ) + ) + mapped = True + outcome_target = targets.get(txn.outcome_account_id or 0) + if outcome_target and txn.outcome > ZERO: + candidates.append( + ZmCandidate( + cash_txn_id=txn.id, + date=txn.date, + amount=txn.outcome, + currency=txn.outcome_currency or "RUB", + direction=FlowDirection.from_broker, + broker_account_id=outcome_target[0], + route=outcome_target[1], + ) + ) + mapped = True + if mapped: + continue + + rule_target = _first_matching_rule(rules, txn) + if rule_target is None: + continue + # the brokerage is invisible to ZenMoney, so the transfer is a one-sided outflow + # (or refund) on an ordinary account and the rule says where it went + if txn.outcome > ZERO: + candidates.append( + ZmCandidate( + cash_txn_id=txn.id, + date=txn.date, + amount=txn.outcome, + currency=txn.outcome_currency or "RUB", + direction=FlowDirection.to_broker, + broker_account_id=rule_target, + route="rule", + ) + ) + elif txn.income > ZERO: + candidates.append( + ZmCandidate( + cash_txn_id=txn.id, + date=txn.date, + amount=txn.income, + currency=txn.income_currency or "RUB", + direction=FlowDirection.from_broker, + broker_account_id=rule_target, + route="rule", + ) + ) + return candidates + + +def _first_matching_rule(rules: list[tuple[Rule, int]], txn: CashTxn) -> int | None: + """The broker account of the first `broker_target` rule this transaction matches.""" + from fintracker.analytics.classify import like_match + + for rule, account_id in rules: + match rule.match_type: + case RuleMatchType.id: + matched = txn.source_id == rule.pattern + case RuleMatchType.payee: + matched = like_match(rule.pattern, txn.payee) + case RuleMatchType.comment: + matched = like_match(rule.pattern, txn.comment) + case RuleMatchType.account: + matched = rule.pattern.isdigit() and int(rule.pattern) in ( + txn.outcome_account_id, + txn.income_account_id, + ) + case _: + # category and mcc rules need the tag tree `classify` builds; a broker + # top-up is identified by payee, comment or account in practice + matched = False + if matched: + return account_id + return None + + +async def _load_broker_candidates(session: AsyncSession) -> list[BrokerCandidate]: + """Confirmed deposits and withdrawals — the broker's own view of the same money.""" + rows = ( + await session.execute( + select(Event.id, Event.account_id, Event.trade_date, Event.amount, Event.currency) + .where( + Event.status == EventStatus.confirmed, + Event.kind.in_((EventKind.deposit, EventKind.withdrawal)), + ) + .order_by(Event.trade_date, Event.id) + ) + ).all() + return [ + BrokerCandidate( + event_id=r.id, + account_id=r.account_id, + date=r.trade_date, + amount=abs(r.amount), + currency=r.currency, + # the kind and the sign agree in every mapper, but `amount` is the cash effect + # and therefore the one that cannot be wrong + direction=(FlowDirection.to_broker if r.amount > ZERO else FlowDirection.from_broker), + ) + for r in rows + if r.amount != ZERO + ] + + +def _report(result: MatchResult) -> None: + """Leftovers are the interesting part: each one is money counted twice somewhere.""" + if result.unmatched_zm: + total = sum((c.amount for c in result.unmatched_zm), start=ZERO) + FINDINGS.add( + "flow_link_unmatched_zm", + "warn", + f"{len(result.unmatched_zm)} переводов ZenMoney на брокерские счета " + f"({total:.2f}) не нашли парного пополнения/вывода", + count=len(result.unmatched_zm), + ref={"cash_txn_ids": sorted(c.cash_txn_id for c in result.unmatched_zm)[:50]}, + ) + if result.unmatched_broker: + total = sum((c.amount for c in result.unmatched_broker), start=ZERO) + FINDINGS.add( + "flow_link_unmatched_broker", + "warn", + f"{len(result.unmatched_broker)} пополнений/выводов брокера ({total:.2f}) " + "не нашли парного перевода в ZenMoney", + count=len(result.unmatched_broker), + ref={"event_ids": sorted(c.event_id for c in result.unmatched_broker)[:50]}, + ) diff --git a/backend/tests/ledger/test_matching.py b/backend/tests/ledger/test_matching.py new file mode 100644 index 0000000..cd1d97c --- /dev/null +++ b/backend/tests/ledger/test_matching.py @@ -0,0 +1,220 @@ +"""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"