11 KiB
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 conventionsreferences/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
- Расширить T-Bank client RPC для accounts, portfolio, positions и instrument metadata; добавить exact decimal normalizer, cache и user-scoped Portfolio service.
- Добавить controller, Swagger DTO и backend unit/e2e проверки нормализации, агрегации, fallback, invalid token,
404и auth. - Запустить backend, перегенерировать frontend OpenAPI client и добавить portfolio entity/query.
- Заменить account placeholder state-вьюхами и loaded UI; проверить desktop/mobile.
- Создать feature documentation, прогнать backend/frontend quality gates и OpenSpec validation.
Rollback до release удаляет endpoint и frontend entity/page view. Prisma migration отсутствует; connection и его encrypted token не затрагиваются.