feat(accounts): выключатель счёта, ручное создание брокерских счетов, брокеры в капитале

Отключённый счёт выпадает из всех скоупов, капитала и подсказок импорта, события остаются в леджере. Брокерский счёт без баланса попадает в net worth по оценке из леджера. POST /accounts заводит счёт под импорт отчёта, AccountOut отдаёт value_rub. Сверка со снапшотом не считает расхождением позиции счетов, у которых снапшота нет (отчёты Сбера и ВТБ).
This commit is contained in:
Dmitry
2026-09-19 21:54:42 +03:00
parent 1b521be35e
commit 4236993106
12 changed files with 559 additions and 13 deletions
@@ -0,0 +1,29 @@
"""account: user switch «disabled» — the account is left out of every calculation
Revision ID: d5a3c8e17f42
Revises: c41e7a9d2b05
Create Date: 2026-09-19 19:00:00.000000
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "d5a3c8e17f42"
down_revision: str | None = "c41e7a9d2b05"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"account",
sa.Column("disabled", sa.Boolean(), nullable=False, server_default=sa.false()),
)
def downgrade() -> None:
op.drop_column("account", "disabled")
+64 -5
View File
@@ -13,7 +13,8 @@ even on days with no transactions, which is the whole point of the daily series.
Accounts are bucketed by `role`. Loan and credit accounts already carry a negative balance in
ZenMoney, so `debt_rub` is negative without flipping any signs. Mirror accounts
(`mirror_of_account_id`) and accounts with `include_in_net_worth = false` are excluded — their
value comes from the broker ledger in phase 2, and counting both would double it.
value comes from the broker ledger, and counting both would double it. Broker accounts have no
balance: their ledger valuation is added to `investment_rub` (see `_ledger_value_series`).
Two ways value can leave the picture unnoticed, both reported to data quality instead of being
swallowed:
@@ -27,16 +28,24 @@ swallowed:
from __future__ import annotations
from bisect import bisect_right
from collections import defaultdict
from collections.abc import Sequence
from datetime import date, timedelta
from decimal import Decimal
from sqlalchemy import Row, delete, insert, select
from sqlalchemy import Row, delete, func, insert, select
from sqlalchemy.ext.asyncio import AsyncSession
from fintracker.analytics import FINDINGS, ledger_date_range, today_local
from fintracker.models import Account, AccountRole, CashTxn, MetricNetWorthDaily
from fintracker.models import (
Account,
AccountKind,
AccountRole,
CashTxn,
MetricNetWorthDaily,
MetricPortfolioValueDaily,
)
from fintracker.pricing.fx import FxTable
ZERO = Decimal(0)
@@ -58,6 +67,7 @@ async def rebuild_net_worth_daily(session: AsyncSession) -> None:
select(Account).where(
Account.include_in_net_worth.is_(True),
Account.archived.is_(False),
Account.disabled.is_(False),
Account.mirror_of_account_id.is_(None),
)
)
@@ -67,6 +77,9 @@ async def rebuild_net_worth_daily(session: AsyncSession) -> None:
)
usable: list[Account] = []
for acc in accounts:
if acc.kind == AccountKind.broker:
# no balance by design: what it is worth comes from the ledger (added below)
continue
if acc.balance is None:
FINDINGS.add(
"account_without_balance",
@@ -110,15 +123,20 @@ async def rebuild_net_worth_daily(session: AsyncSession) -> None:
balances[acc.id] = Decimal(acc.balance) - after_today
fx = await FxTable.load(session)
ledger_days, ledger_values = await _ledger_value_series(session)
out: list[dict[str, object]] = []
missing_ccys: set[str] = set()
missing_days = 0
# nothing to reconstruct: no account has a balance, so there is no net worth to report
d = end if usable else start - timedelta(days=1)
# nothing to reconstruct: no account has a balance and no broker has a valuation
d = end if (usable or ledger_days) else start - timedelta(days=1)
while d >= start:
buckets = dict.fromkeys(_BUCKETS.values(), ZERO)
by_currency: dict[str, Decimal] = defaultdict(Decimal)
missing = 0
ledger_rub = _value_on(ledger_days, ledger_values, d)
if ledger_rub:
buckets[_BUCKETS[AccountRole.investment]] += ledger_rub
by_currency["RUB"] += ledger_rub
for acc in usable:
native = balances[acc.id]
by_currency[acc.currency.upper()] += native
@@ -164,6 +182,47 @@ async def rebuild_net_worth_daily(session: AsyncSession) -> None:
await session.execute(insert(MetricNetWorthDaily), out)
async def _ledger_value_series(session: AsyncSession) -> tuple[list[date], list[Decimal]]:
"""What the broker accounts that count towards net worth were worth, per day.
A broker account has no `balance`: its value is the ledger valuation (positions at market
plus cash) that the `valuation` step already wrote per account scope. Summing those scopes
rather than reading `all` is what lets the account's own switches apply — «В капитал»
off, disabled, or a mirror all keep it out, exactly as for a ZenMoney account.
"""
ids = (
(
await session.execute(
select(Account.id).where(
Account.kind == AccountKind.broker,
Account.include_in_net_worth.is_(True),
Account.disabled.is_(False),
Account.mirror_of_account_id.is_(None),
)
)
)
.scalars()
.all()
)
if not ids:
return [], []
rows = (
await session.execute(
select(MetricPortfolioValueDaily.d, func.sum(MetricPortfolioValueDaily.total_rub))
.where(MetricPortfolioValueDaily.scope.in_([f"account:{i}" for i in ids]))
.group_by(MetricPortfolioValueDaily.d)
.order_by(MetricPortfolioValueDaily.d)
)
).all()
return [r[0] for r in rows], [Decimal(r[1] or 0) for r in rows]
def _value_on(days: Sequence[date], values: Sequence[Decimal], d: date) -> Decimal:
"""The last known value on or before `d`; nothing before the series starts."""
i = bisect_right(days, d)
return values[i - 1] if i else ZERO
async def _report_leaking_transfers(
session: AsyncSession,
rows: Sequence[Row[tuple[date, Decimal, int | None, Decimal, int | None]]],
@@ -71,6 +71,7 @@ async def _missing_fx(session: AsyncSession, fx: FxTable, as_of: date) -> list[F
select(Account.currency).where(
Account.include_in_net_worth.is_(True),
Account.archived.is_(False),
Account.disabled.is_(False),
Account.mirror_of_account_id.is_(None),
Account.balance.is_not(None),
)
+18 -2
View File
@@ -483,7 +483,15 @@ async def cash_balances(session: AsyncSession) -> dict[tuple[int, str], Decimal]
async def account_scopes(session: AsyncSession, ledger_accounts: set[int]) -> dict[str, set[int]]:
"""Every set of accounts the metrics are reported for: all, each one, each portfolio."""
"""Every set of accounts the metrics are reported for: all, each one, each portfolio.
A disabled account (the user's own switch) belongs to none of them, so it is in no total,
no portfolio and no per-account view; its events stay in the ledger untouched.
"""
switched_off = set(
(await session.execute(select(Account.id).where(Account.disabled.is_(True)))).scalars()
)
ledger_accounts = ledger_accounts - switched_off
scopes: dict[str, set[int]] = {"all": set(ledger_accounts)}
for account_id in sorted(ledger_accounts):
scopes[f"account:{account_id}"] = {account_id}
@@ -691,6 +699,14 @@ async def _reconcile(
).all()
}
# A derived position the snapshot does not list is only a discrepancy where there IS a
# snapshot to disagree with. Accounts fed by a broker report (Sber, VTB) have none at all:
# their positions are checked against the report's own closing balances by
# `ledger/report_import.reconcile_reports`, and would otherwise all read as «not at the broker».
covered = set(
(await session.execute(select(PositionSnapshot.account_id).distinct())).scalars()
) | set((await session.execute(select(CashSnapshot.account_id).distinct())).scalars())
mismatched: list[int] = []
seen: set[tuple[int, int]] = set()
for r in snapshots:
@@ -699,7 +715,7 @@ async def _reconcile(
if abs(derived_pos.get(key, ZERO) - Decimal(r.qty)) > QTY_TOLERANCE:
mismatched.append(r.instrument_id)
for key, qty in derived_pos.items():
if key not in seen and abs(qty) > QTY_TOLERANCE:
if key not in seen and key[0] in covered and abs(qty) > QTY_TOLERANCE:
mismatched.append(key[1])
if mismatched:
+87 -4
View File
@@ -5,19 +5,100 @@ from sqlalchemy import select
from fintracker.api.deps import CurrentUser, SessionDep
from fintracker.api.errors import Problem
from fintracker.api.schemas.accounts import AccountOut, AccountPatch
from fintracker.models import Account
from fintracker.api.schemas.accounts import AccountCreate, AccountOut, AccountPatch
from fintracker.models import (
Account,
AccountKind,
AccountRole,
Broker,
EventSource,
MetricPortfolioValueDaily,
)
router = APIRouter(prefix="/accounts", tags=["accounts"])
async def _out(session: SessionDep, accounts: list[Account]) -> list[AccountOut]:
"""Rows as the API shows them, each with its latest ledger valuation (null when none)."""
latest = (
await session.execute(
select(MetricPortfolioValueDaily.scope, MetricPortfolioValueDaily.total_rub)
.where(MetricPortfolioValueDaily.scope.like("account:%"))
.distinct(MetricPortfolioValueDaily.scope)
.order_by(MetricPortfolioValueDaily.scope, MetricPortfolioValueDaily.d.desc())
)
).all()
values = {row.scope: row.total_rub for row in latest}
out: list[AccountOut] = []
for a in accounts:
row = AccountOut.model_validate(a, from_attributes=True)
row.value_rub = values.get(f"account:{a.id}")
out.append(row)
return out
@router.get("", name="list")
async def list_accounts(session: SessionDep, _: CurrentUser) -> list[AccountOut]:
"""Every account, archived ones included — the client decides what to show."""
rows = (
await session.execute(select(Account).order_by(Account.archived, Account.name, Account.id))
).scalars()
return [AccountOut.model_validate(a, from_attributes=True) for a in rows]
return await _out(session, list(rows))
_REPORT_SOURCES = {
Broker.sber: ("report_sber", EventSource.report_sber),
Broker.vtb: ("report_vtb", EventSource.report_vtb),
Broker.other: ("csv", None),
}
"""`account.source` and `primary_event_source` for a broker account fed by report imports."""
@router.post("", name="create", status_code=status.HTTP_201_CREATED)
async def create_account(body: AccountCreate, session: SessionDep, _: CurrentUser) -> AccountOut:
"""Create the broker account a report import will write into.
`source_id` is the agreement number the report prints: it is how the import finds the
account on its own the next time.
"""
if body.broker not in _REPORT_SOURCES:
raise Problem(
status.HTTP_400_BAD_REQUEST,
"Bad request",
"Счета T-Invest создаёт синхронизация, вручную их не заводят",
)
name, source_id = body.name.strip(), body.source_id.strip()
if not name or not source_id:
raise Problem(
status.HTTP_400_BAD_REQUEST, "Bad request", "name and source_id must not be empty"
)
source, primary = _REPORT_SOURCES[body.broker]
taken = (
await session.execute(
select(Account.name).where(Account.source == source, Account.source_id == source_id)
)
).scalar_one_or_none()
if taken is not None:
raise Problem(
status.HTTP_409_CONFLICT,
"Conflict",
f"Счёт с номером договора {source_id} уже есть: «{taken}»",
)
account = Account(
kind=AccountKind.broker,
source=source,
source_id=source_id,
broker=body.broker,
name=name,
currency=body.currency.upper(),
role=AccountRole.investment,
primary_event_source=primary,
include_in_net_worth=True,
)
session.add(account)
await session.commit()
await session.refresh(account)
return (await _out(session, [account]))[0]
@router.patch("/{account_id}", name="patch")
@@ -38,6 +119,8 @@ async def patch_account(
)
if "role" in changes and changes["role"] is None:
raise Problem(status.HTTP_400_BAD_REQUEST, "Bad request", "role must not be null")
if "disabled" in changes and changes["disabled"] is None:
raise Problem(status.HTTP_400_BAD_REQUEST, "Bad request", "disabled must not be null")
mirror = changes.get("mirror_of_account_id")
if mirror is not None:
if mirror == account_id:
@@ -51,4 +134,4 @@ async def patch_account(
setattr(account, field, value)
await session.commit()
await session.refresh(account)
return AccountOut.model_validate(account, from_attributes=True)
return (await _out(session, [account]))[0]
+22 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import date, datetime
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
from fintracker.api.schemas.common import MoneyOpt
from fintracker.models import AccountKind, AccountRole, Broker, EventSource
@@ -21,12 +21,17 @@ class AccountOut(BaseModel):
mirror_of_account_id: int | None
primary_event_source: EventSource | None
archived: bool
disabled: bool
"""User switch: the account is left out of net worth, portfolios and metrics."""
opened_at: date | None
balance: MoneyOpt
"""Native currency, as the source reported it; JSON string."""
balance_as_of: datetime | None
start_balance: MoneyOpt
credit_limit: MoneyOpt
value_rub: MoneyOpt = None
"""Latest ledger valuation in RUB (`metric_portfolio_value_daily`): what a broker account is
worth, since it has no `balance`. Null when the account has no valuation."""
class AccountPatch(BaseModel):
@@ -42,3 +47,19 @@ class AccountPatch(BaseModel):
role: AccountRole | None = None
mirror_of_account_id: int | None = None
primary_event_source: EventSource | None = None
disabled: bool | None = None
class AccountCreate(BaseModel):
"""A broker account for report imports. T-Invest accounts come from the sync, not from here."""
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=256)
broker: Broker
source_id: str = Field(
min_length=1,
max_length=128,
description="Agreement number exactly as the broker's report prints it",
)
currency: str = Field(default="RUB", min_length=3, max_length=3)
@@ -236,7 +236,11 @@ async def account_suggestions(session: AsyncSession) -> list[Account]:
(
await session.execute(
select(Account)
.where(Account.kind == AccountKind.broker, Account.archived.is_(False))
.where(
Account.kind == AccountKind.broker,
Account.archived.is_(False),
Account.disabled.is_(False),
)
.order_by(Account.name)
)
)
@@ -74,6 +74,9 @@ class Account(TimestampMixin, Base):
)
opened_at: Mapped[date | None]
archived: Mapped[bool] = mapped_column(Boolean, default=False)
disabled: Mapped[bool] = mapped_column(Boolean, default=False)
"""The user switched the account off: no sync or source ever writes this. It leaves the
lists, net worth, portfolios and every metric scope; its history stays in the ledger."""
deposit_terms: Mapped[dict[str, Any] | None]
"""ZenMoney deposit/loan terms: percent, startDate, endDateOffset, capitalization…"""
balance: Mapped[Decimal | None]
+66
View File
@@ -198,3 +198,69 @@ async def test_missing_fx_days_are_named_and_total_stays_computed(app):
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}
async def _broker(name: str, amount: str = "100000", **kwargs) -> int:
from factories import make_event
from fintracker.models import AccountKind, EventKind
account = await make_account(
name=name, kind=AccountKind.broker, role=AccountRole.investment, balance=None, **kwargs
)
await make_event(
today_local() - timedelta(days=3), account_id=account, kind=EventKind.deposit, amount=amount
)
return account
async def test_broker_accounts_count_through_their_ledger_valuation(app):
t = today_local()
await make_account(name="Карта", balance="5000")
await make_txn(t - timedelta(days=5), outcome="100")
await _broker("Брокер 1", "100000")
await _broker("Брокер 2", "50000")
await refresh()
rows = await series()
assert rows[t].investment_rub == Decimal("150000")
assert rows[t].liquid_rub == Decimal("5000")
assert rows[t].total_rub == Decimal("155000")
assert rows[t].by_currency["RUB"].startswith("155000")
# before the deposits the brokers were worth nothing: only the card is left
assert rows[t - timedelta(days=4)].investment_rub == Decimal("0")
assert rows[t - timedelta(days=4)].total_rub == Decimal("5000")
async def test_broker_switches_keep_an_account_out_of_net_worth(app):
t = today_local()
await make_account(name="Карта", balance="1000")
await make_txn(t - timedelta(days=5), outcome="10")
await _broker("Считается", "10000")
await _broker("Не в капитале", "20000", include_in_net_worth=False)
await _broker("Отключён", "40000", disabled=True)
mirrored = await _broker("Оригинал", "80000")
await make_account(name="Зеркало", balance="80000", mirror_of_account_id=mirrored)
await refresh()
row = (await series())[t]
# ledger side: «Считается» + «Оригинал»; the mirror and the two switched-off ones stay out
assert row.investment_rub == Decimal("90000")
assert row.total_rub == Decimal("91000")
async def test_a_broker_account_without_balance_is_not_a_finding(app):
await make_account(name="Карта", balance=None)
await _broker("Брокер")
await refresh()
named = [f.detail for f in await findings("account_without_balance")]
assert len(named) == 1
assert "Карта" in named[0]
async def test_net_worth_of_brokers_alone_is_reported(app):
await _broker("Единственный", "70000")
await refresh()
rows = await series()
assert rows[today_local()].total_rub == Decimal("70000")
@@ -0,0 +1,121 @@
"""Derived positions against the broker's snapshot — and only where a snapshot exists."""
from datetime import UTC, datetime, timedelta
from decimal import Decimal
from sqlalchemy import select
from factories import make_account, make_event, make_instrument, refresh
from fintracker.analytics import today_local
from fintracker.db import get_sessionmaker
from fintracker.models import (
AccountKind,
AccountRole,
CashSnapshot,
EventKind,
MetricDataQuality,
PositionSnapshot,
)
D = Decimal
AS_OF = datetime(2026, 9, 19, 12, 0, tzinfo=UTC)
async def broker(name: str, *, source: str = "tinvest") -> int:
return await make_account(
name=name,
kind=AccountKind.broker,
role=AccountRole.investment,
balance=None,
source=source,
)
async def buy(account: int, instrument: int, qty: int) -> None:
await make_event(
today_local() - timedelta(days=5),
account_id=account,
kind=EventKind.buy,
instrument_id=instrument,
quantity=qty,
price=100,
amount=-100 * qty,
)
async def snapshot(account: int, instrument: int | None, qty: str = "0", cash: str = "0") -> None:
async with get_sessionmaker()() as session:
if instrument is not None:
session.add(
PositionSnapshot(
account_id=account, instrument_id=instrument, as_of=AS_OF, source="tinvest",
qty=D(qty), currency="RUB",
)
) # fmt: skip
session.add(
CashSnapshot(
account_id=account, currency="RUB", as_of=AS_OF, source="tinvest", balance=D(cash)
)
)
await session.commit()
async def flagged() -> set[int] | None:
async with get_sessionmaker()() as session:
row = (
await session.execute(
select(MetricDataQuality).where(
MetricDataQuality.check_name == "position_vs_snapshot"
)
)
).scalar_one_or_none()
if row is None or row.ref is None:
return None
return set(row.ref["instruments"])
async def test_matching_snapshot_is_no_finding(app):
account = await broker("Т")
sber = await make_instrument(ticker="SBER")
await buy(account, sber, 10)
await snapshot(account, sber, "10")
await refresh()
assert await flagged() is None
async def test_a_position_the_broker_does_not_list_is_flagged(app):
account = await broker("Т")
sber, gazp = await make_instrument(ticker="SBER"), await make_instrument(ticker="GAZP")
await buy(account, sber, 10)
await buy(account, gazp, 5)
await snapshot(account, sber, "10") # the broker lists SBER only
await refresh()
assert await flagged() == {gazp}
async def test_a_snapshot_that_lists_nothing_still_catches_a_leftover_position(app):
"""Everything sold at the broker leaves a cash snapshot and no positions: the ledger
still holding a paper is exactly what the check is for."""
account = await broker("Т")
sber = await make_instrument(ticker="SBER")
await buy(account, sber, 10)
await snapshot(account, None, cash="500")
await refresh()
assert await flagged() == {sber}
async def test_an_account_fed_by_a_report_has_no_snapshot_and_is_not_flagged(app):
"""Sber and VTB send no snapshot; their positions are reconciled against the report."""
tinvest = await broker("Т")
sber_account = await broker("Сбер", source="report_sber")
sber, mtss = await make_instrument(ticker="SBER"), await make_instrument(ticker="MTSS")
await buy(tinvest, sber, 10)
await snapshot(tinvest, sber, "10")
await buy(sber_account, sber, 20)
await buy(sber_account, mtss, 3)
await refresh()
assert await flagged() is None
+141
View File
@@ -100,3 +100,144 @@ async def test_categories_are_flat_with_parent_ids(client, auth_headers):
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
+2
View File
@@ -71,6 +71,7 @@ async def make_account(
balance_as_of: datetime | None = None,
include_in_net_worth: bool = True,
archived: bool = False,
disabled: bool = False,
mirror_of_account_id: int | None = None,
source: str = "zenmoney",
source_id: str | None = None,
@@ -87,6 +88,7 @@ async def make_account(
balance_as_of=balance_as_of or datetime.now(UTC),
include_in_net_worth=include_in_net_worth,
archived=archived,
disabled=disabled,
mirror_of_account_id=mirror_of_account_id,
)
)