76 lines
11 KiB
Markdown
76 lines
11 KiB
Markdown
## Context
|
||
|
||
См. мотивацию в `proposal.md` и поведение в `specs/portfolio-view/spec.md`. Backend уже хранит один зашифрованный T-Bank token на User, централизует TLS gRPC, limiter, deadline и retry, а также публикует authenticated OpenAPI. `BrokerageAccount` уже является нормализованной границей доступного открытого инвестиционного счёта. Маршрут `/accounts/:accountId` существует как placeholder; frontend использует FSD, Mantine, React Query и generated OpenAPI types.
|
||
|
||
T-Bank `GetPortfolio` предоставляет обычные позиции с количеством и ценами, а `GetPositions` -- доступные и заблокированные денежные остатки. Обе protobuf-модели используют `int64`/`nano`; JavaScript `number` не обеспечивает безопасной финансовой арифметики.
|
||
|
||
## Goals / Non-Goals
|
||
|
||
**Goals:**
|
||
|
||
- Расширить существующую server-side T-Bank boundary полным read-path одного Portfolio.
|
||
- Нормализовать upstream-данные в стабильный публичный HTTP contract с точными decimal strings.
|
||
- Кэшировать полный user-scoped Portfolio Snapshot и глобальные metadata инструментов без новой инфраструктуры.
|
||
- Реализовать адаптивный detail UI в визуальном направлении `references/fin-tracker`, следуя FSD и code conventions `references/frontend-ai-template`, без переноса кода из референсов.
|
||
- Документировать фактическое поведение в `docs/features/portfolio-view.md` после реализации.
|
||
|
||
**Non-Goals:**
|
||
|
||
- Новые зависимости, persistent/distributed cache, ограничение размера in-memory cache или поддержка нескольких backend instances.
|
||
- История Portfolio, операции, виртуальные позиции, заблокированные средства, вывод средств, графики, polling, ручное обновление и валютная конвертация.
|
||
- Изменение Prisma schema, T-Bank connection lifecycle или существующей общей resilience policy.
|
||
|
||
## Decisions
|
||
|
||
### Read-path, авторизация счёта и error mapping
|
||
|
||
`GET /api/v1/brokerage-accounts/:accountId/portfolio` читает connection User и использует `GetAccounts` для проверки, что requested id остаётся открытым поддерживаемым Brokerage Account. Отсутствующий, закрытый, неподдерживаемый или недоступный id получает `404` до финансовых RPC. При валидном счёте `GetPortfolio` и `GetPositions` выполняются параллельно; новый Snapshot создаётся только при успехе обоих вызовов. `UNAUTHENTICATED` и `PERMISSION_DENIED` остаются `400 INVALID_TBANK_TOKEN`, отсутствие connection -- `409 TBANK_NOT_CONNECTED`, остальные upstream-сбои -- `503 TBANK_UNAVAILABLE`.
|
||
|
||
Это переиспользует единственную T-Bank policy и не допускает частичного финансового состояния. Прямая доверенность `accountId` позволила бы запрашивать устаревшие или чужие ids; частичный ответ смешивал бы разные моменты upstream-состояния.
|
||
|
||
### Нормализованная модель и точная арифметика
|
||
|
||
Backend добавляет публичные Portfolio DTO и один сервис нормализации. Он берёт обычные позиции только из `GetPortfolio.positions`, доступные деньги только из `GetPositions.money`, исключает virtual и blocked данные, удаляет нулевые элементы и сохраняет upstream-порядок. Инструментные типы переводятся в фиксированный union с `OTHER` fallback. Metadata по FIGI обогащает отображаемый идентификатор, но не входит в критерий успешности Snapshot.
|
||
|
||
Десятичные protobuf-пары преобразуются в signed integer nanos и обратно в canonical decimal strings через Node `BigInt`. Этим же представлением вычисляются `currentValue`, `absoluteReturn`, Cash Balance и агрегаты валют; API никогда не передаёт финансовые `number`. `absoluteReturn` публикуется только при одинаковой известной валюте текущей и средней цены.
|
||
|
||
Альтернативы: `number` теряет точность `int64` и дробных значений; decimal dependency увеличивает surface там, где достаточно `BigInt`; использование `GetPositions` для ценных бумаг создало бы дубли и не содержит требуемых оценок.
|
||
|
||
### Snapshot и metadata caches
|
||
|
||
Один in-memory Portfolio cache ключуется парой User и Brokerage Account. Успешный полный Snapshot считается свежим 30 секунд и пригоден как fallback до пяти минут. Если обновление временно неуспешно, сервис возвращает сохранённый Snapshot с `isStale: true`; invalid token никогда не маскируется fallback. Successful replace/delete connection очищает snapshots этого User.
|
||
|
||
Отдельный общий in-memory cache по FIGI хранит successful metadata и подтверждённое отсутствие 24 часа. Для нового Snapshot отсутствующие metadata запрашиваются параллельно и ожидаются в пределах gRPC deadline: при успешном ответе используется name. Ошибки metadata lookup не кэшируются. Cache metadata не содержит user data и не очищается при изменении connection.
|
||
|
||
Ограничение количества записей не добавляется: MVP рассчитан на единственного User, Snapshot живут не более пяти минут. При multi-user deployment заменить эти caches на shared bounded cache вместе с distributed rate-limit, а не добавлять частичный LRU сейчас.
|
||
|
||
### OpenAPI и frontend detail page
|
||
|
||
Controller документирует DTO через Nest Swagger; frontend генерирует schema из работающего backend и выводит transport aliases из `paths[...]`. Новый `portfolio` entity инкапсулирует query с `staleTime: 30_000` и отключённым retry; page импортирует entity только через public barrel. После stale time previous data не показывается до нового результата.
|
||
|
||
`AccountPage` выбирает loading, empty, unavailable-account, known-error, generic-error или loaded view. Loaded view использует локальные Mantine/CSS-module компоненты: агрегаты по валютам, отдельные секции акций и облигаций с upstream-порядком внутри каждой, общую секцию остальных позиций, Cash Balance и freshness warning. Название и тип счёта берутся из уже имеющегося account-list cache; при прямом переходе остаётся заголовок «Портфель». Денежный formatter выводит RUB как `₽`. Дизайн сохраняет светлые поверхности, тонкие slate-границы, синий accent и читаемый single-column mobile layout референса, без его кода и placeholder-метрик.
|
||
|
||
Альтернативы: ручные frontend DTO расходятся с OpenAPI; дополнительный account endpoint ради заголовка добавляет вызов без доменной пользы; перестановка строк по сумме сравнивала бы разные валюты.
|
||
|
||
### Documentation and validation
|
||
|
||
`docs/features/portfolio-view.md` фиксирует пользовательский поток, endpoint, публичную модель, точность значений, cache/fallback, ошибки и Mermaid sequence diagram. ADR не создаётся: server-side boundary, in-memory resilience и frontend query policy уже определены существующими ADR; конкретные TTL и normalisation относятся к feature. ADR 005 уточняется только если его общая формулировка противоречит фактической cache policy после реализации.
|
||
|
||
## Risks / Trade-offs
|
||
|
||
- [Snapshot может быть до 30 секунд старым] → это согласованный TTL; после него счёт заново проверяется через T-Bank.
|
||
- [Fallback может показать данные до пяти минут] → клиент явно показывает `isStale` и исходный `lastSuccessfulAt`.
|
||
- [Metadata lookup расходует upstream quota] → результат и отсутствие кэшируются 24 часа; финансовый ответ не блокируется его ошибкой.
|
||
- [In-memory cache теряется при restart и не масштабируется] → MVP использует один instance; перед горизонтальным масштабированием нужны shared cache и limiter.
|
||
- [T-Bank вводит новый тип инструмента] → он отображается как `OTHER`, не скрывается.
|
||
|
||
## Migration Plan
|
||
|
||
1. Расширить T-Bank client RPC для accounts, portfolio, positions и instrument metadata; добавить exact decimal normalizer, cache и user-scoped Portfolio service.
|
||
2. Добавить controller, Swagger DTO и backend unit/e2e проверки нормализации, агрегации, fallback, invalid token, `404` и auth.
|
||
3. Запустить backend, перегенерировать frontend OpenAPI client и добавить portfolio entity/query.
|
||
4. Заменить account placeholder state-вьюхами и loaded UI; проверить desktop/mobile.
|
||
5. Создать feature documentation, прогнать backend/frontend quality gates и OpenSpec validation.
|
||
|
||
Rollback до release удаляет endpoint и frontend entity/page view. Prisma migration отсутствует; connection и его encrypted token не затрагиваются.
|