## 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. |