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