## 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 не затрагиваются.