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

127 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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. |