fx_rate_daily получает строку на каждый календарный день: котировки ЦБ тянутся вперёд (и назад до первой), is_carried это помечает, RUB = 1.0 всегда. Дальше любая сумма конвертируется по курсу СВОЕЙ даты, а не сегодняшнему. Net worth восстанавливается назад от текущего account.balance по транзакциям — ZenMoney отдаёт остаток, а не историю; поэтому сегодняшняя строка совпадает с тем, что показывает ZenMoney, а каждая прошлая с ней согласована. Нет курса — не подстановка, а NULL и строка в metric_data_quality. Туда же попадает то, что шаги заметили по дороге: правило без совпадений, счёт без баланса, перевод через границу net worth.
95 lines
3.3 KiB
Python
95 lines
3.3 KiB
Python
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() == {}
|