from datetime import timedelta from sqlalchemy import select from factories import make_account, make_category, make_rule, make_trip, make_txn, refresh from fintracker.analytics import today_local from fintracker.db import get_sessionmaker from fintracker.models import CashTxn, FlowType, MetricDataQuality, Rule, RuleKind, RuleMatchType async def txn(source_id: str) -> CashTxn: async with get_sessionmaker()() as session: return ( await session.execute(select(CashTxn).where(CashTxn.source_id == source_id)) ).scalar_one() async def test_transfer_stays_internal_transfer(app): card = await make_account(name="Карта") deposit = await make_account(name="Вклад") d = today_local() - timedelta(days=3) await make_txn( d, income="10000", income_account_id=deposit, outcome="10000", outcome_account_id=card, source_id="transfer", ) await refresh() assert (await txn("transfer")).flow_type == FlowType.internal_transfer async def test_savings_rule_moves_expense_to_savings_transfer(app): card = await make_account() d = today_local() - timedelta(days=3) await make_txn(d, outcome="5000", outcome_account_id=card, payee="Копилка", source_id="save") await make_txn(d, outcome="700", outcome_account_id=card, payee="Пятёрочка", source_id="food") rule_id = await make_rule( kind=RuleKind.savings, match_type=RuleMatchType.payee, pattern="копилка" ) await refresh() assert (await txn("save")).flow_type == FlowType.savings_transfer assert (await txn("food")).flow_type == FlowType.expense async with get_sessionmaker()() as session: rule = await session.get(Rule, rule_id) assert rule is not None assert rule.match_count == 1 assert rule.last_matched_at is not None async def test_one_off_and_payee_and_trip(app): card = await make_account() d = today_local() - timedelta(days=2) trip_id = await make_trip("Тбилиси", d - timedelta(days=1), d + timedelta(days=1)) await make_txn( d, outcome="42000", outcome_account_id=card, payee="AIRLINE TICKETS 123", source_id="fly" ) await make_rule(kind=RuleKind.one_off, match_type=RuleMatchType.payee, pattern="AIRLINE%") await make_rule( kind=RuleKind.payee, match_type=RuleMatchType.payee, pattern="airline%", value="Авиабилеты" ) await refresh() row = await txn("fly") assert row.is_one_off is True assert row.payee_canonical == "Авиабилеты" assert row.trip_id == trip_id async def test_category_rule_and_unknown_category_reported(app): card = await make_account() food = await make_category("Еда") groceries = await make_category("Продукты", parent_id=food) d = today_local() - timedelta(days=1) await make_txn( d, outcome="800", outcome_account_id=card, payee="Ozon", primary_category_id=food, source_id="ozon", ) await make_txn(d, outcome="100", outcome_account_id=card, payee="Wildberries", source_id="wb") await make_rule( kind=RuleKind.category, match_type=RuleMatchType.payee, pattern="ozon", value="продукты", ) await make_rule( kind=RuleKind.category, match_type=RuleMatchType.payee, pattern="wildberries", value="Нет такой категории", ) await refresh() assert (await txn("ozon")).category_id == groceries assert (await txn("wb")).category_id is None async with get_sessionmaker()() as session: rows = ( ( await session.execute( select(MetricDataQuality).where( MetricDataQuality.check_name == "rule_unknown_category" ) ) ) .scalars() .all() ) assert len(rows) == 1 assert rows[0].severity == "warn" async def test_category_match_type_sees_root_parent(app): card = await make_account() food = await make_category("Еда") groceries = await make_category("Продукты", parent_id=food) d = today_local() - timedelta(days=1) await make_txn( d, outcome="800", outcome_account_id=card, primary_category_id=groceries, source_id="root", ) await make_rule(kind=RuleKind.one_off, match_type=RuleMatchType.category, pattern="Еда") await refresh() assert (await txn("root")).is_one_off is True async def test_classification_is_idempotent(app): card = await make_account() d = today_local() - timedelta(days=4) await make_txn(d, outcome="5000", outcome_account_id=card, payee="Копилка", source_id="save") rule_id = await make_rule( kind=RuleKind.savings, match_type=RuleMatchType.payee, pattern="Копилка" ) await refresh() first = await txn("save") await refresh() second = await txn("save") assert (first.flow_type, first.category_id, first.payee_canonical) == ( second.flow_type, second.category_id, second.payee_canonical, ) async with get_sessionmaker()() as session: rule = await session.get(Rule, rule_id) assert rule is not None and rule.match_count == 1 # set, not accumulated async def test_deleted_transactions_are_marked_deleted(app): card = await make_account() d = today_local() - timedelta(days=5) await make_txn(d, outcome="100", outcome_account_id=card, deleted=True, source_id="gone") await refresh() assert (await txn("gone")).flow_type == FlowType.deleted async def test_ignore_and_account_and_mcc_rules(app): card = await make_account() d = today_local() - timedelta(days=1) await make_txn(d, outcome="100", outcome_account_id=card, mcc=6011, source_id="atm") await make_rule(kind=RuleKind.ignore, match_type=RuleMatchType.mcc, pattern="6011") await refresh() assert (await txn("atm")).flow_type == FlowType.other async def test_ignore_is_terminal_for_later_rules(app): """An ignore match stops rule application: a later broker_target on the same payee must not pull the transaction back into a counted flow.""" card = await make_account() d = today_local() - timedelta(days=1) await make_txn(d, outcome="100", outcome_account_id=card, payee="Мимо кассы", source_id="skip") await make_rule( kind=RuleKind.ignore, match_type=RuleMatchType.payee, pattern="Мимо кассы", priority=10 ) await make_rule( kind=RuleKind.broker_target, match_type=RuleMatchType.payee, pattern="Мимо кассы", value="1", priority=20, ) await refresh() assert (await txn("skip")).flow_type == FlowType.other async def test_disabled_rule_match_count_is_reset(app): card = await make_account() d = today_local() - timedelta(days=1) await make_txn(d, outcome="5000", outcome_account_id=card, payee="Копилка", source_id="save") rule_id = await make_rule( kind=RuleKind.savings, match_type=RuleMatchType.payee, pattern="Копилка" ) await refresh() async with get_sessionmaker()() as session: rule = await session.get(Rule, rule_id) assert rule is not None and rule.match_count == 1 rule.enabled = False await session.commit() await refresh() async with get_sessionmaker()() as session: rule = await session.get(Rule, rule_id) assert rule is not None and rule.match_count == 0