feat(accounts): выключатель счёта, ручное создание брокерских счетов, брокеры в капитале
Отключённый счёт выпадает из всех скоупов, капитала и подсказок импорта, события остаются в леджере. Брокерский счёт без баланса попадает в net worth по оценке из леджера. POST /accounts заводит счёт под импорт отчёта, AccountOut отдаёт value_rub. Сверка со снапшотом не считает расхождением позиции счетов, у которых снапшота нет (отчёты Сбера и ВТБ).
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user