feat(analytics): метрики фазы 1 — классификация, net worth, потоки, расходы, runway

fx_rate_daily получает строку на каждый календарный день: котировки ЦБ тянутся
вперёд (и назад до первой), is_carried это помечает, RUB = 1.0 всегда. Дальше
любая сумма конвертируется по курсу СВОЕЙ даты, а не сегодняшнему.

Net worth восстанавливается назад от текущего account.balance по транзакциям —
ZenMoney отдаёт остаток, а не историю; поэтому сегодняшняя строка совпадает с
тем, что показывает ZenMoney, а каждая прошлая с ней согласована.

Нет курса — не подстановка, а NULL и строка в metric_data_quality. Туда же
попадает то, что шаги заметили по дороге: правило без совпадений, счёт без
баланса, перевод через границу net worth.
This commit is contained in:
Dmitry
2026-09-18 13:44:09 +03:00
parent c55fe19e48
commit b9c12fa1a1
18 changed files with 2232 additions and 0 deletions
View File
+94
View File
@@ -0,0 +1,94 @@
from datetime import date, timedelta
from decimal import Decimal
from sqlalchemy import select
from factories import make_account, make_cbr_rate, make_rule, make_txn, month_back, refresh
from fintracker.analytics import today_local
from fintracker.db import get_sessionmaker
from fintracker.models import MetricCashFlowMonthly, RuleKind, RuleMatchType
async def months() -> dict[date, MetricCashFlowMonthly]:
async with get_sessionmaker()() as session:
rows = (
(
await session.execute(
select(MetricCashFlowMonthly).order_by(MetricCashFlowMonthly.month)
)
)
.scalars()
.all()
)
return {r.month: r for r in rows}
async def test_month_totals_baseline_and_savings_rate(app):
card = await make_account(balance="0")
m = month_back(1)
await make_txn(m + timedelta(days=5), income="100000", income_account_id=card)
await make_txn(m + timedelta(days=6), outcome="30000", outcome_account_id=card, payee="Лента")
await make_txn(m + timedelta(days=7), outcome="20000", outcome_account_id=card, payee="Отпуск")
await make_txn(m + timedelta(days=8), outcome="10000", outcome_account_id=card, payee="Копилка")
await make_rule(kind=RuleKind.one_off, match_type=RuleMatchType.payee, pattern="Отпуск")
await make_rule(kind=RuleKind.savings, match_type=RuleMatchType.payee, pattern="Копилка")
await refresh()
row = (await months())[m]
assert row.income_rub == Decimal("100000")
assert row.expense_rub == Decimal("50000")
assert row.one_off_rub == Decimal("20000")
assert row.baseline_rub == Decimal("30000")
assert row.savings_transfer_rub == Decimal("10000")
assert row.savings_rate == Decimal("0.5")
assert row.txn_count == 4
async def test_transfers_and_ignored_flows_do_not_count(app):
card = await make_account(balance="0")
other = await make_account(name="Вклад", balance="0")
m = month_back(1)
await make_txn(
m + timedelta(days=2),
income="50000",
income_account_id=other,
outcome="50000",
outcome_account_id=card,
)
await refresh()
assert await months() == {}
async def test_foreign_expense_uses_the_rate_of_its_own_date(app):
card = await make_account(balance="0")
m = month_back(1)
d = m + timedelta(days=10)
await make_txn(d, outcome="10", outcome_currency="USD", outcome_account_id=card)
await make_cbr_rate(d, "USD", "90")
await make_cbr_rate(today_local(), "USD", "100")
await refresh()
assert (await months())[m].expense_rub == Decimal("900")
async def test_income_zero_gives_null_savings_rate(app):
card = await make_account(balance="0")
m = month_back(1)
await make_txn(m + timedelta(days=3), outcome="1000", outcome_account_id=card)
await refresh()
assert (await months())[m].savings_rate is None
async def test_unconvertible_expense_leaves_no_phantom_month(app):
"""The month row is created by a successful conversion, not by the attempt: a single
unquoted expense must not produce an all-zero month."""
card = await make_account(balance="0")
m = month_back(1)
await make_txn(
m + timedelta(days=4), outcome="1", outcome_currency="XBT", outcome_account_id=card
)
await refresh()
assert await months() == {}
+219
View File
@@ -0,0 +1,219 @@
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
+113
View File
@@ -0,0 +1,113 @@
from datetime import date, timedelta
from decimal import Decimal
from sqlalchemy import select
from factories import make_cbr_rate, make_txn
from fintracker.analytics import today_local
from fintracker.db import get_sessionmaker
from fintracker.models import FxRateDaily
from fintracker.pricing.fx import FxTable, rebuild_fx_daily
def last_friday(before_days: int = 7) -> date:
d = today_local() - timedelta(days=before_days)
return d - timedelta(days=(d.weekday() - 4) % 7)
async def rebuild() -> None:
async with get_sessionmaker()() as session:
await rebuild_fx_daily(session)
await session.commit()
async def rates_on(d: date) -> dict[str, tuple[Decimal, bool]]:
async with get_sessionmaker()() as session:
rows = (await session.execute(select(FxRateDaily).where(FxRateDaily.d == d))).scalars()
return {r.ccy: (r.rate_rub, r.is_carried) for r in rows}
async def test_nominal_is_divided_out(app):
friday = last_friday()
await make_cbr_rate(friday, "JPY", "65.0", nominal=100)
await rebuild()
rate, is_carried = (await rates_on(friday))["JPY"]
assert rate == Decimal("0.65")
assert is_carried is False
async def test_weekend_carries_friday_forward(app):
friday = last_friday()
await make_cbr_rate(friday, "USD", "90.5")
await rebuild()
for offset in (1, 2): # Saturday, Sunday
rate, is_carried = (await rates_on(friday + timedelta(days=offset)))["USD"]
assert rate == Decimal("90.5")
assert is_carried is True
async def test_days_before_the_first_quote_are_back_filled(app):
friday = last_friday()
earlier = friday - timedelta(days=10)
await make_txn(earlier, outcome="100", outcome_currency="USD")
await make_cbr_rate(friday, "USD", "90.5")
await rebuild()
rate, is_carried = (await rates_on(earlier))["USD"]
assert rate == Decimal("90.5")
assert is_carried is True
async def test_rub_is_one_on_every_day_and_outside_the_spine(app):
friday = last_friday()
await make_cbr_rate(friday, "USD", "90.5")
await rebuild()
assert (await rates_on(friday))["RUB"] == (Decimal(1), False)
assert (await rates_on(today_local()))["RUB"] == (Decimal(1), False)
async with get_sessionmaker()() as session:
fx = await FxTable.load(session)
assert fx.rate(date(1999, 1, 1), "RUB") == Decimal(1)
assert fx.rate(date(1999, 1, 1), "USD") is None
assert fx.to_rub(Decimal("10"), "USD", friday) == Decimal("905.0")
assert fx.to_rub(Decimal("10"), "XBT", friday) is None
async def test_spine_covers_future_rates_and_future_transactions(app):
"""The CBR publishes tomorrow's rate the evening before, and a transaction may be dated
in the future — both days must be convertible."""
t = today_local()
await make_cbr_rate(t, "USD", "90")
await make_cbr_rate(t + timedelta(days=1), "USD", "95")
await make_txn(t + timedelta(days=3), outcome="10", outcome_currency="USD")
await rebuild()
assert (await rates_on(t + timedelta(days=1)))["USD"] == (Decimal("95"), False)
# the txn is dated past the last quote: the spine still reaches it, carried forward
assert (await rates_on(t + timedelta(days=3)))["USD"] == (Decimal("95"), True)
async with get_sessionmaker()() as session:
fx = await FxTable.load(session)
assert fx.to_rub(Decimal("10"), "USD", t + timedelta(days=3)) == Decimal("950")
async def test_deleted_txn_does_not_stretch_the_spine(app):
"""ZenMoney hands out a zero date (1970-01-01) for some deleted rows.
Counting it would build the daily grid over five extra decades of carried-forward rates.
"""
friday = last_friday()
await make_cbr_rate(friday, "USD", "90.5")
await make_txn(friday, outcome=100, outcome_currency="RUB")
await make_txn(date(1970, 1, 1), income=15000, income_currency="RUB", deleted=True)
await rebuild()
async with get_sessionmaker()() as session:
earliest = (
(await session.execute(select(FxRateDaily.d).order_by(FxRateDaily.d))).scalars().first()
)
assert earliest is not None
assert earliest >= friday - timedelta(days=1)
+200
View File
@@ -0,0 +1,200 @@
from datetime import timedelta
from decimal import Decimal
from sqlalchemy import select
from factories import make_account, make_cbr_rate, make_txn, refresh
from fintracker.analytics import today_local
from fintracker.db import get_sessionmaker
from fintracker.models import AccountRole, MetricDataQuality, MetricNetWorthDaily
async def series() -> dict:
async with get_sessionmaker()() as session:
rows = (
(await session.execute(select(MetricNetWorthDaily).order_by(MetricNetWorthDaily.d)))
.scalars()
.all()
)
return {r.d: r for r in rows}
async def test_series_is_reconstructed_backwards_from_the_current_balance(app):
t = today_local()
card = await make_account(name="Карта", balance="10000")
await make_txn(t - timedelta(days=5), outcome="1000", outcome_account_id=card)
await make_txn(t - timedelta(days=3), income="5000", income_account_id=card)
await make_txn(t - timedelta(days=1), outcome="500", outcome_account_id=card)
await refresh()
rows = await series()
assert min(rows) == t - timedelta(days=5)
assert max(rows) == t
expected = {
t: Decimal("10000"),
t - timedelta(days=1): Decimal("10000"),
t - timedelta(days=2): Decimal("10500"),
t - timedelta(days=3): Decimal("10500"),
t - timedelta(days=4): Decimal("5500"),
t - timedelta(days=5): Decimal("5500"),
}
assert {d: r.total_rub for d, r in rows.items()} == expected
assert rows[t].liquid_rub == Decimal("10000")
assert rows[t].by_currency == {"RUB": "10000.0000000000"}
async def test_foreign_account_converts_at_each_days_rate(app):
t = today_local()
await make_account(name="Валютный", currency="USD", balance="100")
await make_txn(t - timedelta(days=2), outcome="10", outcome_currency="USD")
await make_cbr_rate(t - timedelta(days=2), "USD", "90")
await make_cbr_rate(t, "USD", "100")
await refresh()
rows = await series()
assert rows[t - timedelta(days=2)].total_rub == Decimal("9000")
assert rows[t].total_rub == Decimal("10000")
assert rows[t].by_currency == {"USD": "100.0000000000"}
async def test_unquoted_currency_is_excluded_and_counted(app):
t = today_local()
await make_account(name="Рубли", balance="1000")
await make_account(name="Биток", currency="XBT", balance="2")
await make_txn(t - timedelta(days=1), outcome="100")
await refresh()
rows = await series()
assert rows[t].total_rub == Decimal("1000")
assert rows[t].missing_fx_count == 1
assert rows[t].by_currency == {"RUB": "1000.0000000000", "XBT": "2.0000000000"}
async with get_sessionmaker()() as session:
checks = {
r.check_name for r in (await session.execute(select(MetricDataQuality))).scalars().all()
}
assert "unquoted_currency" in checks
assert "missing_fx" in checks
async def test_debt_bucket_is_negative_and_lowers_the_total(app):
t = today_local()
await make_account(name="Карта", balance="10000")
await make_account(name="Кредитка", balance="-3000", role=AccountRole.debt)
await make_account(name="Вклад", balance="50000", role=AccountRole.savings)
await make_txn(t - timedelta(days=1), outcome="100")
await refresh()
row = (await series())[t]
assert row.debt_rub == Decimal("-3000")
assert row.savings_rub == Decimal("50000")
assert row.total_rub == Decimal("57000")
async def test_account_without_balance_is_skipped_and_reported(app):
t = today_local()
await make_account(name="Карта", balance="1000")
await make_account(name="Без баланса", balance=None)
await make_txn(t - timedelta(days=1), outcome="100")
await refresh()
assert (await series())[t].total_rub == Decimal("1000")
async with get_sessionmaker()() as session:
rows = (
(
await session.execute(
select(MetricDataQuality).where(
MetricDataQuality.check_name == "account_without_balance"
)
)
)
.scalars()
.all()
)
assert len(rows) == 1
async def test_mirror_and_excluded_accounts_are_ignored(app):
t = today_local()
broker = await make_account(name="Брокер", balance="100000", role=AccountRole.investment)
await make_account(name="Зеркало", balance="100000", mirror_of_account_id=broker)
await make_account(name="Скрытый", balance="5000", include_in_net_worth=False)
await make_txn(t - timedelta(days=1), outcome="100")
await refresh()
row = (await series())[t]
assert row.total_rub == Decimal("100000")
assert row.investment_rub == Decimal("100000")
async def findings(check_name: str) -> list[MetricDataQuality]:
async with get_sessionmaker()() as session:
return list(
(
await session.execute(
select(MetricDataQuality).where(MetricDataQuality.check_name == check_name)
)
)
.scalars()
.all()
)
async def test_transfer_to_an_excluded_account_is_reported(app):
"""One leg inside net worth, one leg on an excluded account: the value did not leave the
household, but the series shows it leaving."""
t = today_local()
card = await make_account(name="Карта", balance="10000")
hidden = await make_account(name="Скрытый", balance="5000", include_in_net_worth=False)
await make_txn(
t - timedelta(days=1),
income="1000",
income_account_id=hidden,
outcome="1000",
outcome_account_id=card,
)
await make_txn(t - timedelta(days=2), outcome="100", outcome_account_id=card)
await refresh()
rows = await findings("transfer_out_of_net_worth")
assert len(rows) == 1 # aggregated once per refresh, not per day
assert rows[0].severity == "info"
assert rows[0].count == 1
assert rows[0].ref == {"account_ids": [hidden]}
async def test_transfer_between_two_included_accounts_is_not_reported(app):
t = today_local()
card = await make_account(name="Карта", balance="10000")
deposit = await make_account(name="Вклад", balance="5000", role=AccountRole.savings)
await make_txn(
t - timedelta(days=1),
income="1000",
income_account_id=deposit,
outcome="1000",
outcome_account_id=card,
)
await refresh()
assert await findings("transfer_out_of_net_worth") == []
async def test_missing_fx_days_are_named_and_total_stays_computed(app):
"""`total_rub` keeps the convertible buckets (a NULL would break the chart); the silent
understatement is reported instead."""
t = today_local()
await make_account(name="Рубли", balance="1000")
await make_account(name="Биток", currency="XBT", balance="2")
await make_txn(t - timedelta(days=2), outcome="100")
await refresh()
rows = await series()
assert len(rows) == 3
assert rows[t].total_rub == Decimal("1000")
assert rows[t].missing_fx_count == 1
found = await findings("networth_missing_fx")
assert len(found) == 1
assert found[0].severity == "warn"
assert found[0].count == 3 # every day of the series is affected
assert found[0].ref == {"currencies": ["XBT"], "days": 3}
+88
View File
@@ -0,0 +1,88 @@
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
+71
View File
@@ -0,0 +1,71 @@
from datetime import timedelta
from decimal import Decimal
from sqlalchemy import select
from factories import make_account, make_category, make_txn, month_back, refresh
from fintracker.db import get_sessionmaker
from fintracker.models import MetricSpendingByCategory
async def rows() -> list[MetricSpendingByCategory]:
async with get_sessionmaker()() as session:
return list((await session.execute(select(MetricSpendingByCategory))).scalars().all())
async def test_root_category_rollup_and_uncategorised_row(app):
card = await make_account(balance="0")
food = await make_category("Еда")
groceries = await make_category("Продукты", parent_id=food)
cafe = await make_category("Кафе", parent_id=food)
m = month_back(1)
await make_txn(
m + timedelta(days=1), outcome="700", outcome_account_id=card, primary_category_id=groceries
)
await make_txn(
m + timedelta(days=2), outcome="300", outcome_account_id=card, primary_category_id=cafe
)
await make_txn(m + timedelta(days=3), outcome="150", outcome_account_id=card)
await refresh()
by_category = {r.category_id: r for r in await rows()}
assert by_category[groceries].amount_rub == Decimal("700")
assert by_category[groceries].root_category_id == food
assert by_category[cafe].root_category_id == food
assert by_category[None].amount_rub == Decimal("150")
assert by_category[None].root_category_id is None
assert sum(r.amount_rub for r in await rows()) == Decimal("1150")
assert {r.month for r in await rows()} == {m}
async def test_only_expenses_are_counted(app):
card = await make_account(balance="0")
savings = await make_account(name="Вклад", balance="0")
m = month_back(1)
await make_txn(m + timedelta(days=1), income="1000", income_account_id=card)
await make_txn(
m + timedelta(days=2),
income="500",
income_account_id=savings,
outcome="500",
outcome_account_id=card,
)
await refresh()
assert await rows() == []
async def test_top_level_category_is_its_own_root(app):
card = await make_account(balance="0")
transport = await make_category("Транспорт")
m = month_back(1)
await make_txn(
m + timedelta(days=4),
outcome="90",
outcome_account_id=card,
primary_category_id=transport,
)
await refresh()
(row,) = await rows()
assert (row.category_id, row.root_category_id) == (transport, transport)