Files
Dmitry 4236993106 feat(accounts): выключатель счёта, ручное создание брокерских счетов, брокеры в капитале
Отключённый счёт выпадает из всех скоупов, капитала и подсказок импорта, события остаются в леджере. Брокерский счёт без баланса попадает в net worth по оценке из леджера. POST /accounts заводит счёт под импорт отчёта, AccountOut отдаёт value_rub. Сверка со снапшотом не считает расхождением позиции счетов, у которых снапшота нет (отчёты Сбера и ВТБ).
2026-09-19 21:54:42 +03:00

244 lines
9.0 KiB
Python

from factories import make_account
from fintracker.models import AccountRole
async def test_auth_is_required(client):
for method, url in (
("get", "/api/v1/accounts"),
("get", "/api/v1/categories"),
("get", "/api/v1/transactions"),
("get", "/api/v1/rules"),
("get", "/api/v1/networth/series"),
("get", "/api/v1/cashflow/monthly"),
("get", "/api/v1/runway"),
("get", "/api/v1/data-quality"),
("get", "/api/v1/metrics/status"),
("post", "/api/v1/metrics/refresh"),
):
r = await getattr(client, method)(url)
assert r.status_code == 401, (url, r.status_code)
assert r.headers["content-type"].startswith("application/problem+json")
async def test_list_accounts_exposes_balance_as_string(client, auth_headers):
await make_account(name="Карта", balance="1234.56")
r = await client.get("/api/v1/accounts", headers=auth_headers)
assert r.status_code == 200
(row,) = r.json()
assert row["name"] == "Карта"
assert isinstance(row["balance"], str)
assert row["balance"].startswith("1234.56")
assert row["role"] == "liquid"
assert row["include_in_net_worth"] is True
async def test_patch_account(client, auth_headers):
broker = await make_account(name="Брокер", role=AccountRole.investment)
zm = await make_account(name="Зеркало")
r = await client.patch(
f"/api/v1/accounts/{zm}",
headers=auth_headers,
json={"include_in_net_worth": False, "mirror_of_account_id": broker, "role": "investment"},
)
assert r.status_code == 200
assert r.json()["include_in_net_worth"] is False
assert r.json()["mirror_of_account_id"] == broker
assert r.json()["role"] == "investment"
# unset fields are untouched
r = await client.patch(
f"/api/v1/accounts/{zm}", headers=auth_headers, json={"name": "Зеркало+"}
)
assert r.json()["mirror_of_account_id"] == broker
assert r.json()["name"] == "Зеркало+"
async def test_patch_account_validation(client, auth_headers):
account_id = await make_account()
r = await client.patch("/api/v1/accounts/999999", headers=auth_headers, json={"name": "x"})
assert r.status_code == 404
r = await client.patch(
f"/api/v1/accounts/{account_id}",
headers=auth_headers,
json={"mirror_of_account_id": account_id},
)
assert r.status_code == 400
r = await client.patch(
f"/api/v1/accounts/{account_id}", headers=auth_headers, json={"mirror_of_account_id": 4242}
)
assert r.status_code == 400
r = await client.patch(
f"/api/v1/accounts/{account_id}", headers=auth_headers, json={"role": "nonsense"}
)
assert r.status_code == 422
r = await client.patch(
f"/api/v1/accounts/{account_id}", headers=auth_headers, json={"currency": "USD"}
)
assert r.status_code == 422 # extra="forbid": source-owned fields are not patchable
r = await client.patch(
f"/api/v1/accounts/{account_id}", headers=auth_headers, json={"name": " "}
)
assert r.status_code == 400
async def test_categories_are_flat_with_parent_ids(client, auth_headers):
from factories import make_category
food = await make_category("Еда")
await make_category("Продукты", parent_id=food)
r = await client.get("/api/v1/categories", headers=auth_headers)
assert r.status_code == 200
by_name = {c["name"]: c for c in r.json()}
assert by_name["Еда"]["parent_id"] is None
assert by_name["Продукты"]["parent_id"] == food
async def test_create_broker_account_for_report_imports(client, auth_headers):
r = await client.post(
"/api/v1/accounts",
json={"name": " ИИС-Сбер ", "broker": "sber", "source_id": " 1234567 "},
headers=auth_headers,
)
assert r.status_code == 201, r.text
row = r.json()
assert row["name"] == "ИИС-Сбер"
assert row["kind"] == "broker"
assert row["source"] == "report_sber"
assert row["source_id"] == "1234567"
assert row["broker"] == "sber"
assert row["currency"] == "RUB"
assert row["role"] == "investment"
assert row["primary_event_source"] == "report_sber"
assert row["include_in_net_worth"] is True
listed = (await client.get("/api/v1/accounts", headers=auth_headers)).json()
assert [a["id"] for a in listed] == [row["id"]]
async def test_create_account_for_another_broker_and_currency(client, auth_headers):
r = await client.post(
"/api/v1/accounts",
json={"name": "ВТБ", "broker": "vtb", "source_id": "42", "currency": "usd"},
headers=auth_headers,
)
assert r.status_code == 201
assert (r.json()["source"], r.json()["currency"]) == ("report_vtb", "USD")
r = await client.post(
"/api/v1/accounts",
json={"name": "Прочий", "broker": "other", "source_id": "7"},
headers=auth_headers,
)
assert r.json()["source"] == "csv"
assert r.json()["primary_event_source"] is None
async def test_create_account_rejects_tinvest_blanks_and_duplicates(client, auth_headers):
url = "/api/v1/accounts"
body = {"name": "ИИС", "broker": "sber", "source_id": "1"}
r = await client.post(url, json={**body, "broker": "tinvest"}, headers=auth_headers)
assert r.status_code == 400
r = await client.post(url, json={**body, "name": " "}, headers=auth_headers)
assert r.status_code == 400
r = await client.post(url, json={**body, "source_id": " "}, headers=auth_headers)
assert r.status_code == 400
assert (await client.post(url, json=body, headers=auth_headers)).status_code == 201
r = await client.post(url, json={**body, "name": "Другое имя"}, headers=auth_headers)
assert r.status_code == 409
assert r.headers["content-type"].startswith("application/problem+json")
assert "ИИС" in r.json()["detail"]
# the same agreement number at another broker is a different account
r = await client.post(url, json={**body, "broker": "vtb"}, headers=auth_headers)
assert r.status_code == 201
async def test_disabled_flag_is_a_user_switch_that_defaults_off(client, auth_headers):
a = await make_account(name="Карта")
assert (await client.get("/api/v1/accounts", headers=auth_headers)).json()[0][
"disabled"
] is False
r = await client.patch(f"/api/v1/accounts/{a}", json={"disabled": True}, headers=auth_headers)
assert r.status_code == 200
assert r.json()["disabled"] is True
r = await client.patch(f"/api/v1/accounts/{a}", json={"disabled": None}, headers=auth_headers)
assert r.status_code == 400
r = await client.patch(f"/api/v1/accounts/{a}", json={"disabled": False}, headers=auth_headers)
assert r.json()["disabled"] is False
async def test_a_disabled_account_leaves_scopes_and_returns_when_switched_on(client, auth_headers):
from datetime import date
from factories import make_event, refresh
from fintracker.models import AccountKind, AccountRole, EventKind
kept = await make_account(
name="Оставить", kind=AccountKind.broker, role=AccountRole.investment, balance=None
)
dropped = await make_account(
name="Отключить", kind=AccountKind.broker, role=AccountRole.investment, balance=None
)
for account in (kept, dropped):
await make_event(date(2026, 1, 5), account_id=account, kind=EventKind.deposit, amount=1000)
await refresh()
async def scopes() -> set[str]:
r = await client.get("/api/v1/analytics/scopes", headers=auth_headers)
return {s["scope"] for s in r.json()}
assert {f"account:{kept}", f"account:{dropped}"} <= await scopes()
await client.patch(f"/api/v1/accounts/{dropped}", json={"disabled": True}, headers=auth_headers)
await refresh()
now = await scopes()
assert f"account:{kept}" in now
assert f"account:{dropped}" not in now
(row,) = [
a
for a in (await client.get("/api/v1/accounts", headers=auth_headers)).json()
if a["id"] == dropped
]
assert row["disabled"] is True # still listed, so it can be switched back on
assert row["value_rub"] is None # no scope, so no valuation any more
await client.patch(
f"/api/v1/accounts/{dropped}", json={"disabled": False}, headers=auth_headers
)
await refresh()
assert f"account:{dropped}" in await scopes()
async def test_broker_account_shows_its_ledger_valuation(client, auth_headers):
from datetime import date
from factories import make_event, refresh
from fintracker.models import AccountKind, AccountRole, EventKind
broker = await make_account(
name="Брокер", kind=AccountKind.broker, role=AccountRole.investment, balance=None
)
card = await make_account(name="Карта", balance="500")
await make_event(date(2026, 1, 5), account_id=broker, kind=EventKind.deposit, amount=1000)
await refresh()
rows = {a["id"]: a for a in (await client.get("/api/v1/accounts", headers=auth_headers)).json()}
assert float(rows[broker]["value_rub"]) == 1000.0
assert rows[card]["value_rub"] is None