from datetime import timedelta from decimal import Decimal from sqlalchemy import select from factories import make_account, make_txn, month_back, refresh from fintracker.db import get_sessionmaker from fintracker.models import AccountRole, MetricRunway async def row() -> MetricRunway | None: async with get_sessionmaker()() as session: return (await session.execute(select(MetricRunway))).scalars().one_or_none() async def test_reserve_and_three_month_average(app): card = await make_account(name="Карта", balance="200000") await make_account(name="Вклад", balance="100000", role=AccountRole.savings) await make_account(name="Брокер", balance="900000", role=AccountRole.investment) for months_ago, amount in ((1, "10000"), (2, "20000"), (3, "30000"), (4, "999999")): await make_txn( month_back(months_ago) + timedelta(days=2), outcome=amount, outcome_account_id=card ) await refresh() r = await row() assert r is not None # reserve = liquid + savings only; investments are not runway assert r.liquid_reserve_rub == Decimal("300000") # last three COMPLETE months: 10000, 20000, 30000 -> 20000 (the 4th is out of window) assert r.avg_baseline_3m_rub == Decimal("20000") assert r.runway_months == Decimal("15") async def test_one_off_is_out_of_the_divisor(app): from factories import make_rule from fintracker.models import RuleKind, RuleMatchType card = await make_account(name="Карта", balance="100000") m = month_back(1) await make_txn(m + timedelta(days=1), outcome="10000", outcome_account_id=card, payee="Лента") await make_txn(m + timedelta(days=2), outcome="90000", outcome_account_id=card, payee="Отпуск") await make_rule(kind=RuleKind.one_off, match_type=RuleMatchType.payee, pattern="Отпуск") await refresh() r = await row() assert r is not None # 10000 baseline in the previous month, 0 in the two before it: (10000 + 0 + 0) / 3 assert r.avg_baseline_3m_rub == Decimal("3333.3333333333") assert r.runway_months == Decimal("30") async def test_a_month_without_spending_counts_as_zero(app): """The divisor is the three named complete months, not the last three rows that exist: a gap month spent nothing and must dilute the average.""" card = await make_account(name="Карта", balance="90000") for months_ago, amount in ((1, "30000"), (3, "30000")): # month -2 has no transactions await make_txn( month_back(months_ago) + timedelta(days=3), outcome=amount, outcome_account_id=card ) await refresh() r = await row() assert r is not None # (30000 + 0 + 30000) / 3 = 20000, not the 30000 an average over existing rows would give assert r.avg_baseline_3m_rub == Decimal("20000") assert r.runway_months == Decimal("4.5") async def test_months_older_than_the_window_are_ignored(app): card = await make_account(name="Карта", balance="60000") await make_txn(month_back(4) + timedelta(days=3), outcome="90000", outcome_account_id=card) await refresh() r = await row() assert r is not None assert r.avg_baseline_3m_rub == Decimal("0") assert r.runway_months is None async def test_no_history_gives_null_runway(app): await make_account(name="Карта", balance="50000") await refresh() r = await row() assert r is not None assert r.avg_baseline_3m_rub == Decimal("0") assert r.runway_months is None