Files
specs/openspec/changes/archive/2026-08-28-portfolio-income-analytics/design.md
T

20 KiB
Raw Blame History

Context

Account Ledger уже предоставляет защищённые cursor pages исполненных T-Bank operations, нормализует Money в точные decimal strings, обогащает доступную identity инструмента и хранит до 100 страниц в существующем 30-секундном in-process LRU. Future Cash Events уже формируются backend как полный либо ошибочный 30-дневный прогноз, а Portfolio overview загружает Portfolio, последние операции и будущие события независимыми React Query queries с локальными section states.

Новая capability должна агрегировать до трёх исторических диапазонов, но не может переносить финансовую арифметику, account authorization, cursor traversal или T-Bank credentials в браузер. Она также не должна сохранять operation history, смешивать source currencies или превращать ошибку forecast в ошибку realized analytics. HTTP contract остаётся Nest-generated OpenAPI; frontend использует только generated types и typed query client. Backend работает под действующим global T-Bank rate limiter и существующей upstream error translation.

Goals / Non-Goals

Goals:

  • Сосредоточить validation дат, complete cursor traversal, exact-type selection, decimal aggregation, comparisons и calendar buckets на backend.
  • Дать overview дешёвую current-month projection, не загружающую year-over-year, trend и drill-down data.
  • Обеспечить traceability: breakdown и exact Ledger Entries должны происходить из одного успешно собранного primary result.
  • Сохранить forecast отдельным query и presentation state, переиспользовав существующий Future Cash Events flow.
  • Встроить страницу в текущие frontend FSD, route, breadcrumb, ViewModel и section-state patterns без новой chart dependency.

Non-Goals:

  • Новый storage, background synchronization или локальная история операций.
  • Новый T-Bank/MOEX client, distributed cache, job queue либо отдельный analytics service process.
  • Изменение публичного поведения полного Account Ledger, Portfolio Snapshot или страницы «Будущие события».
  • FX conversion, historical Portfolio valuation, tax model или performance metrics.

Decisions

1. Backend предоставляет две realized projections с общей aggregation core

Появляются два защищённых read-only API:

  • GET /api/v1/brokerage-accounts/:accountId/income-summary возвращает только current-month source-currency totals для overview;
  • GET /api/v1/brokerage-accounts/:accountId/income-analytics?from=YYYY-MM-DD&to=YYYY-MM-DD возвращает applied period, selected summary, previous/year comparisons, bucket granularity и buckets, instrument breakdown и qualifying primary-period Ledger Entries.

Оба controller methods делегируют одному income analytics service. Summary вызывает только сбор current-month range; полная projection собирает primary, previous и, когда календарно и по opened date доступен, year-ago range. Это не заставляет каждое открытие Portfolio overview читать до трёх диапазонов и не создаёт query mode/configuration для фиксированного summary behavior.

Альтернатива — один всегда полный endpoint — отклонена из-за ненужной upstream нагрузки на overview. Расчёт на frontend отклонён, потому что потребовал бы cursor orchestration и дублировал бы exact decimal/calendar/security invariants за публичной trust boundary.

2. Complete-range collection расширяет существующий Account Ledger seam

BrokerageOperationsService получает внутреннюю complete-range operation, которая один раз подтверждает T-Bank connection и доступность Brokerage Account, затем последовательно проходит cursor chain через существующую page loading/mapping/cache logic. Каждый page request остаётся OPERATION_STATE_EXECUTED; server-side category income уменьшает локальную обработку, а income service после mapping принимает только exact upstream types OPERATION_TYPE_COUPON и OPERATION_TYPE_DIVIDEND.

Collector завершает range только при terminal page. hasNext без непустого нового cursor и повтор cursor считаются upstream failure, чтобы malformed chain не дал partial result или бесконечный цикл. Три независимых range chains полной аналитики могут выполняться параллельно, но каждая chain остаётся последовательной, а существующий global T-Bank limiter ограничивает фактические RPC starts. Любая необходимая range failure отклоняет realized response целиком.

Существующий page LRU остаётся единственным operation cache: access check выполняется до cache reuse, summary и full analytics могут переиспользовать равные свежие pages, а новый result cache не появляется. Прямые дополнительные вызовы TbankClient из income service отклонены как дублирование authorization, mapping, metadata fallback, error translation и cache identity.

3. Date-only calendar model отделён от instants upstream

Public query принимает только строгие ISO calendar dates YYYY-MM-DD. Backend валидирует реальную дату, from <= to, inclusive duration не более 366 days и отсутствие будущих дат относительно Europe/Moscow. Today получается через Intl.DateTimeFormat с Europe/Moscow; date arithmetic выполняется над date-only values через UTC-safe calendar helpers, а только на T-Bank boundary диапазон преобразуется в точные Moscow start/end instants.

Полный календарный месяц распознаётся по первому и последнему дню; previous range для него является предыдущим полным месяцем. В остальных случаях previous range имеет ту же inclusive duration. Year-ago range использует те же month/day; невозможная дата, например 29 февраля в невисокосном году, либо range до Brokerage Account.openedDate даёт yearComparison: null. Успешно полученный полный range без операций означает zero, тогда как отсутствующая comparison не подменяется нулём.

Timeline buckets рассчитываются backend по Europe/Moscow: day для 1–45 days, ISO calendar week Monday–Sunday для 46–180 days и calendar month для более длинного периода. Первый и последний week/month bucket обрезаются applied range, а все промежуточные empty buckets создаются явно.

Альтернатива с browser-local timezone отклонена: она дала бы разные границы API, comparison и chart при иной системной timezone User.

4. Exact Money и grouping остаются server-side

Существующий decimal module расширяется parse operation для decimal strings, чтобы income aggregation и Future Cash Events totals использовали одну BigInt-based реализацию без number. Response хранит Money как ISO currency плюс canonical decimal string. Comparison использует union валют двух успешно рассчитанных ranges; отсутствие выплат одной стороны становится exact zero только после полного успешного чтения этой стороны.

Instrument group key выбирается в порядке instrument UID, FIGI, ticker с instrument type, затем operation-specific fallback. Последний вариант не объединяет неизвестные инструменты только из-за одинакового пустого identifier. Display identity предпочитает доступные mapped instrument данные и сохраняет понятный operation fallback. Сортировка combined totals выполняется exact decimal comparator, не floating-point conversion.

Primary response включает только qualifying coupon/dividend entries выбранного периода и связывает breakdown rows с их group key. Frontend раскрывает эти уже полученные entries и не делает второй upstream request. Это гарантирует, что drill-down воспроизводит тот же immutable response, включая отрицательные corrections. Альтернатива с отдельным drill-down endpoint отклонена из-за дополнительной нагрузки и риска получить другой срез после истечения cache.

5. Forecast использует существующий Events contract как отдельный query

UpcomingEventsDto получает additive incomeTotalsByCurrency, рассчитанный backend только из coupon/dividend events с известным projectedAmount. Existing events list, sorting, completeness и error semantics не меняются; текущая полная Events page продолжает показывать список без total. Analytics ViewModel использует тот же /events query, выбирает три ближайших coupon/dividend events и получает exact totals из нового поля.

Так realized endpoint не зависит от Portfolio/MOEX forecast path, а forecast query может самостоятельно перейти в loading, empty или error state. Новый отдельный forecast endpoint отклонён как дублирующая projection поверх уже подходящего полного Events response; frontend aggregation отклонена, чтобы Money arithmetic не покидала backend.

6. Frontend следует FSD и URL-as-applied-state

Новый income entity slice выводит generated response types и два React Query hooks для summary и full analytics. Account overview ViewModel подключает summary query независимо от Portfolio, operations и events и передаёт отдельной section view discriminated local state. Section присутствует и в empty, и в loaded Portfolio outcomes; её failure не меняет top-level Portfolio state.

Account-scoped route /accounts/:accountId/income получает page container, один ViewModel hook и отдельные loading/error/empty/loaded section views. Breadcrumb строится как Accounts → selected account → «Пассивный доход»; глобальная navigation не меняется.

Applied from/to принадлежат URL search params. ViewModel нормализует отсутствующий period к Moscow current month, а initial/default/reset state записывает canonical explicit dates через history replace. Draft period живёт в react-hook-form с Zod validation и не меняет query key до submit. Apply обновляет URL; browser Back/Forward восстанавливает applied query и синхронизирует draft. React Query использует 30-second stale time, отключённые automatic retry/focus refetch и Client Cached Snapshot feedback в соответствии с существующей policy.

7. Visualization строится без новой chart dependency

Backend-provided buckets отображаются лёгкой semantic section view: CSS/SVG stacked columns имеют общую zero baseline, отдельные positive/negative coupon и dividend segments и меняются явным currency control. Масштабирование в presentation использует только derived ratios; authoritative decimal strings форматируются отдельно и не пересчитываются через floating point.

Графика скрыта от accessibility tree как дублирующее представление. Рядом остаётся структурированный текстовый список buckets с датой/периодом и exact coupon/dividend values; currency control имеет доступное имя и keyboard behavior текущих Mantine controls. Empty buckets сохраняются в обеих формах. Instrument rows раскрывают existing LedgerEntryRow instances для exact entries.

Установленная frontend dependency не предоставляет chart component. Добавление chart library отклонено: один stacked-column use case с заранее рассчитанными buckets не оправдывает новый runtime dependency и bundle/maintenance surface.

Delivery Map

Area Status Architectural outcome or N/A reason
frontend affected Generated API types и income entity queries; account income route/ViewModel/state views; URL-bound period form; accessible per-currency trend, breakdown/drill-down и forecast sections; independent overview summary.
backend affected Complete-range Ledger seam, date/decimal aggregation core, summary и full analytics endpoints, additive Future Cash Events income totals и Nest OpenAPI DTOs.
specs affected Canonical Proposal, Delta Spec, Design и последующий Tasks/Apply lifecycle для brokerage-account-income-analytics.
docs affected После поставки as-built feature flow, API boundaries, domain terms и system overview отражают realized analytics, overview summary и separate forecast без копирования planning artifacts.

Contract Gate Decision

Gate Status Reason
runtime-openapi required Backend добавляет два endpoints и поле Events response, а frontend добавляет их generated contract consumers; runtime OpenAPI, committed snapshot и generated types должны совпасть.

Risks / Trade-offs

  • Один full analytics request может прочитать много upstream pages для primary, previous и year-ago ranges. Максимум 366 days, exact-type filtering, page size и global limiter ограничивают работу, но latency остаётся выше обычной Ledger page; UI поэтому имеет structural loading и cached-snapshot feedback.
  • LRU хранит pages, а не атомарный multi-page snapshot. Complete cursor traversal предотвращает partial result, но upstream correction между pages теоретически может изменить историю; response фиксирует собственный calculation time и не обещает более сильную snapshot isolation, которой source contract не предоставляет.
  • T-Bank не публикует гарантированную глубину history. Backend считает успешно пройденный range доступным; known pre-opened-date year range становится unavailable. Он не пытается угадать скрытую retention boundary.
  • Qualifying primary entries увеличивают response size, зато обеспечивают exact drill-down без второго запроса. Scope ограничен только coupon/dividend entries максимум за 366 days; при подтверждённом payload pressure отдельный paginated drill-down потребует нового Design.
  • Additive Events total расширяет public contract, хотя существующая Events page его не показывает. Это уменьшает endpoints и повторную source orchestration ценой одного неиспользуемого поля для старого consumer.
  • Signed corrections требуют chart baseline с negative segments. Текстовый bucket list остаётся canonical accessible representation, поэтому визуальная компактность не скрывает точные значения.

Migration Plan

  1. Расширить reusable decimal и complete Ledger collection seams, сохранив существующее Ledger API behavior.
  2. Добавить backend summary/full analytics contracts и additive Events income totals, затем опубликовать обновлённый runtime OpenAPI.
  3. Синхронизировать committed frontend OpenAPI snapshot и generated types до подключения новых consumers.
  4. Добавить entity queries, account overview summary, income route/ViewModel и presentation sections; существующие Portfolio, Ledger и Events routes остаются рабочими на каждом шаге.
  5. После фактической поставки обновить as-built docs и canonical main spec через обычный verify/archive flow.

Rollback Contract

Field Decision
Status required
Reason Change расширяет публичный backend/frontend OpenAPI contract и выполняет денежную агрегацию через новую пользовательскую поверхность.
Trigger Contract mismatch, incorrect Money/date aggregation, unacceptable upstream load либо regression существующих Portfolio/Ledger/Events journeys.
Actions Сначала убрать frontend income route, overview consumer и зависимость от additive Events field; затем откатить synchronized frontend contract artifacts и backend analytics endpoints/Events field. Complete-range/decimal refactor откатывается либо сохраняется только при доказанном сохранении существующего Ledger behavior.
Data safety Analytics read-only и ничего не сохраняет; rollback не мигрирует и не удаляет User, connection, Portfolio, operations или event data. Existing short-lived caches можно безопасно потерять при restart.
Verification Подтвердить совпадение runtime OpenAPI с frontend snapshot, отсутствие новых route/field consumers и сохранение прежних Account overview, full Ledger и Future Cash Events behaviors через affected canonical gates.