From 42369931065d4840e47748818bc8a1ac0af4269c Mon Sep 17 00:00:00 2001 From: Dmitry Date: Sat, 19 Sep 2026 21:54:42 +0300 Subject: [PATCH] =?UTF-8?q?feat(accounts):=20=D0=B2=D1=8B=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=D0=B0=D1=82=D0=B5=D0=BB=D1=8C=20=D1=81=D1=87=D1=91?= =?UTF-8?q?=D1=82=D0=B0,=20=D1=80=D1=83=D1=87=D0=BD=D0=BE=D0=B5=20=D1=81?= =?UTF-8?q?=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B1=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=D0=B5=D1=80=D1=81=D0=BA=D0=B8=D1=85=20=D1=81=D1=87=D0=B5?= =?UTF-8?q?=D1=82=D0=BE=D0=B2,=20=D0=B1=D1=80=D0=BE=D0=BA=D0=B5=D1=80?= =?UTF-8?q?=D1=8B=20=D0=B2=20=D0=BA=D0=B0=D0=BF=D0=B8=D1=82=D0=B0=D0=BB?= =?UTF-8?q?=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Отключённый счёт выпадает из всех скоупов, капитала и подсказок импорта, события остаются в леджере. Брокерский счёт без баланса попадает в net worth по оценке из леджера. POST /accounts заводит счёт под импорт отчёта, AccountOut отдаёт value_rub. Сверка со снапшотом не считает расхождением позиции счетов, у которых снапшота нет (отчёты Сбера и ВТБ). --- .../versions/d5a3c8e17f42_account_disabled.py | 29 ++++ backend/src/fintracker/analytics/networth.py | 69 ++++++++- backend/src/fintracker/analytics/quality.py | 1 + backend/src/fintracker/analytics/valuation.py | 20 ++- .../src/fintracker/api/routers/accounts.py | 91 ++++++++++- .../src/fintracker/api/schemas/accounts.py | 23 ++- .../src/fintracker/ledger/report_import.py | 6 +- backend/src/fintracker/models/accounts.py | 3 + backend/tests/analytics/test_networth.py | 66 ++++++++ .../analytics/test_snapshot_reconcile.py | 121 +++++++++++++++ backend/tests/api/test_accounts_api.py | 141 ++++++++++++++++++ backend/tests/factories.py | 2 + 12 files changed, 559 insertions(+), 13 deletions(-) create mode 100644 backend/alembic/versions/d5a3c8e17f42_account_disabled.py create mode 100644 backend/tests/analytics/test_snapshot_reconcile.py diff --git a/backend/alembic/versions/d5a3c8e17f42_account_disabled.py b/backend/alembic/versions/d5a3c8e17f42_account_disabled.py new file mode 100644 index 0000000..07d2829 --- /dev/null +++ b/backend/alembic/versions/d5a3c8e17f42_account_disabled.py @@ -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") diff --git a/backend/src/fintracker/analytics/networth.py b/backend/src/fintracker/analytics/networth.py index fc6d8db..ad7035d 100644 --- a/backend/src/fintracker/analytics/networth.py +++ b/backend/src/fintracker/analytics/networth.py @@ -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]]], diff --git a/backend/src/fintracker/analytics/quality.py b/backend/src/fintracker/analytics/quality.py index 2542900..57e90f0 100644 --- a/backend/src/fintracker/analytics/quality.py +++ b/backend/src/fintracker/analytics/quality.py @@ -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), ) diff --git a/backend/src/fintracker/analytics/valuation.py b/backend/src/fintracker/analytics/valuation.py index facf31c..898c7ca 100644 --- a/backend/src/fintracker/analytics/valuation.py +++ b/backend/src/fintracker/analytics/valuation.py @@ -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: diff --git a/backend/src/fintracker/api/routers/accounts.py b/backend/src/fintracker/api/routers/accounts.py index 8a76935..af8e27a 100644 --- a/backend/src/fintracker/api/routers/accounts.py +++ b/backend/src/fintracker/api/routers/accounts.py @@ -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] diff --git a/backend/src/fintracker/api/schemas/accounts.py b/backend/src/fintracker/api/schemas/accounts.py index 64daf7b..fb0c080 100644 --- a/backend/src/fintracker/api/schemas/accounts.py +++ b/backend/src/fintracker/api/schemas/accounts.py @@ -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) diff --git a/backend/src/fintracker/ledger/report_import.py b/backend/src/fintracker/ledger/report_import.py index a29692d..ac30d51 100644 --- a/backend/src/fintracker/ledger/report_import.py +++ b/backend/src/fintracker/ledger/report_import.py @@ -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) ) ) diff --git a/backend/src/fintracker/models/accounts.py b/backend/src/fintracker/models/accounts.py index 70b8d4a..c52edb5 100644 --- a/backend/src/fintracker/models/accounts.py +++ b/backend/src/fintracker/models/accounts.py @@ -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] diff --git a/backend/tests/analytics/test_networth.py b/backend/tests/analytics/test_networth.py index 3bfca76..bd9c2c9 100644 --- a/backend/tests/analytics/test_networth.py +++ b/backend/tests/analytics/test_networth.py @@ -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") diff --git a/backend/tests/analytics/test_snapshot_reconcile.py b/backend/tests/analytics/test_snapshot_reconcile.py new file mode 100644 index 0000000..20b0f9a --- /dev/null +++ b/backend/tests/analytics/test_snapshot_reconcile.py @@ -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 diff --git a/backend/tests/api/test_accounts_api.py b/backend/tests/api/test_accounts_api.py index d82f99b..9663a01 100644 --- a/backend/tests/api/test_accounts_api.py +++ b/backend/tests/api/test_accounts_api.py @@ -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 diff --git a/backend/tests/factories.py b/backend/tests/factories.py index 9e55835..b617ebd 100644 --- a/backend/tests/factories.py +++ b/backend/tests/factories.py @@ -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, ) )