20 KiB
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
- Расширить reusable decimal и complete Ledger collection seams, сохранив существующее Ledger API behavior.
- Добавить backend summary/full analytics contracts и additive Events income totals, затем опубликовать обновлённый runtime OpenAPI.
- Синхронизировать committed frontend OpenAPI snapshot и generated types до подключения новых consumers.
- Добавить entity queries, account overview summary, income route/ViewModel и presentation sections; существующие Portfolio, Ledger и Events routes остаются рабочими на каждом шаге.
- После фактической поставки обновить 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. |