From bd8d6c11feb823766dd02d89aa976d632fe67ad9 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 17 Jun 2026 13:15:01 +0300 Subject: [PATCH] feat: improove ui --- .../pages/broker/BrokerAccountDetailPage.tsx | 142 +- .../pages/broker/BrokerOperationsTable.tsx | 205 + .../src/pages/broker/BrokerPages.test.tsx | 304 +- .../pages/broker/BrokerPositionsSection.tsx | 151 + .../src/pages/broker/brokerDisplay.test.ts | 201 + .../src/pages/broker/brokerDisplay.ts | 176 + .../2026-06-16-tbank-broker-portfolios.md | 3552 +++++++++++++++++ .../2026-06-17-broker-portfolio-display.md | 1339 +++++++ .../2026-06-17-broker-portfolio-display.md | 165 + 9 files changed, 6125 insertions(+), 110 deletions(-) create mode 100644 apps/frontend/src/pages/broker/BrokerOperationsTable.tsx create mode 100644 apps/frontend/src/pages/broker/BrokerPositionsSection.tsx create mode 100644 apps/frontend/src/pages/broker/brokerDisplay.test.ts create mode 100644 apps/frontend/src/pages/broker/brokerDisplay.ts create mode 100644 docs/superpowers/plans/2026-06-16-tbank-broker-portfolios.md create mode 100644 docs/superpowers/plans/2026-06-17-broker-portfolio-display.md create mode 100644 docs/superpowers/specs/2026-06-17-broker-portfolio-display.md diff --git a/apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx b/apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx index f1759a6..ae2e562 100644 --- a/apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx +++ b/apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx @@ -1,7 +1,10 @@ +import { useState } from 'react'; import { useParams } from 'react-router-dom'; import type { BrokerMoney } from '../../api/responses'; import { useBrokerOperations } from '../../hooks/useBrokerOperations'; import { useBrokerPortfolio } from '../../hooks/useBrokerPortfolio'; +import { BrokerOperationsTable } from './BrokerOperationsTable'; +import { BrokerPositionsSection } from './BrokerPositionsSection'; function formatMoney(value: BrokerMoney | null | undefined) { if (!value) return '-'; @@ -13,41 +16,35 @@ function formatMoney(value: BrokerMoney | null | undefined) { }).format(value.value); } -function formatDate(value: string | null) { - if (!value) return '-'; - - return new Date(value).toLocaleString('ru-RU'); -} - -const tableStyle = { - width: '100%', - borderCollapse: 'collapse', - fontSize: 14, -} satisfies React.CSSProperties; - -const thStyle = { - borderBottom: '1px solid #e0e0e0', - color: 'var(--color-text-secondary)', - fontWeight: 600, - padding: '10px 8px', -} satisfies React.CSSProperties; - -const tdStyle = { - borderBottom: '1px solid #eeeeee', - padding: '10px 8px', - verticalAlign: 'top', -} satisfies React.CSSProperties; - export function BrokerAccountDetailPage() { const { accountId } = useParams(); + const [operationCursor, setOperationCursor] = useState(undefined); + const [operationCursorStack, setOperationCursorStack] = useState>([]); const portfolio = useBrokerPortfolio(accountId); - const operations = useBrokerOperations(accountId, { limit: 100 }); + const operations = useBrokerOperations(accountId, { limit: 10, cursor: operationCursor }); if (portfolio.isLoading) return

Загрузка портфеля...

; if (portfolio.error || !portfolio.data) { return

Не удалось загрузить портфель

; } + function handleNextOperationsPage() { + const nextCursor = operations.data?.nextCursor; + if (!nextCursor || !operations.data?.hasNext) return; + + setOperationCursorStack((previous) => [...previous, operationCursor]); + setOperationCursor(nextCursor); + } + + function handlePreviousOperationsPage() { + if (operationCursorStack.length === 0) return; + + const nextStack = operationCursorStack.slice(0, -1); + const previousCursor = operationCursorStack[operationCursorStack.length - 1]; + setOperationCursorStack(nextStack); + setOperationCursor(previousCursor); + } + return (
@@ -90,90 +87,17 @@ export function BrokerAccountDetailPage() { ))} -
-

Позиции

-
- - - - - - - - - - - {portfolio.data.positions.map((position) => ( - - - - - - - ))} - -
- Инструмент - - Количество - - Стоимость - - Доходность -
- {position.ticker || position.name || position.figi} - {position.name && ( -
{position.name}
- )} -
- {position.quantity ?? '-'} - - {formatMoney(position.currentValue)} - - {position.expectedYieldPercent ?? '-'}% -
-
-
+ -
-

Операции

- {operations.isLoading ? ( -

Загрузка операций...

- ) : ( -
- - - - - - - - - - - {(operations.data?.items ?? []).map((operation) => ( - - - - - - - ))} - -
- Дата - - Тип - - Инструмент - - Сумма -
{formatDate(operation.date)}{operation.type}{operation.ticker || operation.description || '-'} - {formatMoney(operation.payment)} -
-
- )} -
+ 0} + canGoForward={Boolean(operations.data?.hasNext && operations.data.nextCursor)} + onPrevious={handlePreviousOperationsPage} + onNext={handleNextOperationsPage} + />
); } diff --git a/apps/frontend/src/pages/broker/BrokerOperationsTable.tsx b/apps/frontend/src/pages/broker/BrokerOperationsTable.tsx new file mode 100644 index 0000000..b5f56df --- /dev/null +++ b/apps/frontend/src/pages/broker/BrokerOperationsTable.tsx @@ -0,0 +1,205 @@ +import { Link } from 'react-router-dom'; +import type { BrokerMoney, BrokerOperation, BrokerOperationsPage } from '../../api/responses'; +import { + getBrokerInstrumentPath, + getBrokerOperationImpact, + getBrokerOperationImpactLabel, + getBrokerOperationTypeLabel, + type BrokerOperationImpact, +} from './brokerDisplay'; + +const tableStyle = { + width: '100%', + borderCollapse: 'collapse', + fontSize: 14, +} satisfies React.CSSProperties; + +const thStyle = { + borderBottom: '1px solid #e0e0e0', + color: 'var(--color-text-secondary)', + fontWeight: 600, + padding: '10px 8px', +} satisfies React.CSSProperties; + +const tdStyle = { + borderBottom: '1px solid #eeeeee', + padding: '10px 8px', + verticalAlign: 'top', +} satisfies React.CSSProperties; + +const impactStyles: Record = { + adds: { + background: 'rgba(46, 125, 50, 0.1)', + color: 'var(--color-positive)', + }, + reduces: { + background: 'rgba(198, 40, 40, 0.1)', + color: 'var(--color-negative)', + }, + neutral: { + background: 'rgba(25, 118, 210, 0.1)', + color: 'var(--color-primary)', + }, + unknown: { + background: 'rgba(102, 102, 102, 0.12)', + color: 'var(--color-text-secondary)', + }, +}; + +function formatMoney(value: BrokerMoney | null | undefined) { + if (!value) return '-'; + + return new Intl.NumberFormat('ru-RU', { + style: 'currency', + currency: value.currency || 'RUB', + maximumFractionDigits: 2, + }).format(value.value); +} + +function formatDate(value: string | null) { + if (!value) return '-'; + + return new Date(value).toLocaleString('ru-RU'); +} + +function moneyColor(impact: BrokerOperationImpact): string { + if (impact === 'adds') return 'var(--color-positive)'; + if (impact === 'reduces') return 'var(--color-negative)'; + + return 'var(--color-text)'; +} + +function OperationInstrument({ operation }: { operation: BrokerOperation }) { + const label = operation.ticker || operation.description || '-'; + const path = getBrokerInstrumentPath({ + ticker: operation.ticker, + instrumentType: operation.instrumentType, + classCode: operation.classCode, + }); + + if (!path || label === '-') { + return {label}; + } + + return {label}; +} + +function OperationType({ operation }: { operation: BrokerOperation }) { + const impact = getBrokerOperationImpact(operation); + + return ( +
+ {getBrokerOperationTypeLabel(operation)} + + {getBrokerOperationImpactLabel(impact)} + +
+ ); +} + +export function BrokerOperationsTable({ + isLoading, + page, + pageNumber, + canGoBack, + canGoForward, + onPrevious, + onNext, +}: { + isLoading: boolean; + page: BrokerOperationsPage | undefined; + pageNumber: number; + canGoBack: boolean; + canGoForward: boolean; + onPrevious: () => void; + onNext: () => void; +}) { + const operations = page?.items ?? []; + + return ( +
+
+

Операции

+
+ + + Страница {pageNumber} + + +
+
+ + {isLoading ? ( +

Загрузка операций...

+ ) : operations.length === 0 ? ( +

Операций за выбранный период нет

+ ) : ( +
+ + + + + + + + + + + {operations.map((operation) => { + const impact = getBrokerOperationImpact(operation); + + return ( + + + + + + + ); + })} + +
+ Дата + + Тип + + Инструмент + + Сумма +
{formatDate(operation.date)} + + + + + {formatMoney(operation.payment)} +
+
+ )} +
+ ); +} diff --git a/apps/frontend/src/pages/broker/BrokerPages.test.tsx b/apps/frontend/src/pages/broker/BrokerPages.test.tsx index c4f19f5..7335e59 100644 --- a/apps/frontend/src/pages/broker/BrokerPages.test.tsx +++ b/apps/frontend/src/pages/broker/BrokerPages.test.tsx @@ -1,5 +1,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { type ReactElement } from 'react'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { describe, expect, it, vi } from 'vitest'; @@ -132,6 +133,307 @@ describe('Broker pages', () => { ); expect(screen.getAllByText('SBER').length).toBeGreaterThan(0); - expect(screen.getByText('OPERATION_TYPE_BUY')).toBeInTheDocument(); + expect(screen.getByText('Покупка')).toBeInTheDocument(); + }); + + it('renders broker positions as separate linked stock and bond tables with current price', () => { + vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ + data: { + account: { + id: 'acc-1', + type: 'brokerage', + name: 'Broker', + status: 'ACCOUNT_STATUS_OPEN', + openedAt: null, + accessLevel: null, + }, + totals: { portfolio: { currency: 'RUB', units: '10000', nano: 0, value: 10000 } }, + yields: { expectedPercent: 5, daily: null, dailyPercent: null }, + cash: [], + blockedCash: [], + positions: [ + { + figi: null, + instrumentUid: 'share-uid', + positionUid: null, + ticker: 'SBER', + classCode: 'TQBR', + instrumentType: 'share', + name: 'Sberbank', + quantity: 10, + blockedLots: null, + currentPrice: { currency: 'RUB', units: '250', nano: 0, value: 250 }, + currentValue: { currency: 'RUB', units: '2500', nano: 0, value: 2500 }, + averagePositionPrice: null, + expectedYieldPercent: 20, + dailyYield: null, + }, + { + figi: null, + instrumentUid: 'bond-uid', + positionUid: null, + ticker: 'SU26238RMFS5', + classCode: 'TQOB', + instrumentType: 'bond', + name: 'ОФЗ 26238', + quantity: 2, + blockedLots: null, + currentPrice: { currency: 'RUB', units: '900', nano: 0, value: 900 }, + currentValue: { currency: 'RUB', units: '1800', nano: 0, value: 1800 }, + averagePositionPrice: null, + expectedYieldPercent: 10, + dailyYield: null, + }, + ], + asOf: '2026-06-17T00:00:00.000Z', + }, + isLoading: false, + error: null, + } as any); + vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({ + data: { + accountId: 'acc-1', + items: [], + nextCursor: null, + hasNext: false, + asOf: '2026-06-17T00:00:00.000Z', + }, + isLoading: false, + error: null, + } as any); + + renderWithClient( + + } /> + , + ['/broker/acc-1'], + ); + + expect(screen.getByRole('heading', { name: 'Акции' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Облигации' })).toBeInTheDocument(); + expect(screen.queryByRole('columnheader', { name: 'Доходность' })).not.toBeInTheDocument(); + expect(screen.getAllByRole('columnheader', { name: 'Цена' })).toHaveLength(2); + expect(screen.getByRole('link', { name: 'SBER' })).toHaveAttribute('href', '/stocks/SBER'); + expect(screen.getByRole('link', { name: 'SU26238RMFS5' })).toHaveAttribute( + 'href', + '/bonds/SU26238RMFS5', + ); + expect(screen.getByText(/250,00/)).toBeInTheDocument(); + expect(screen.getByText(/900,00/)).toBeInTheDocument(); + }); + + it('renders broker operations with Russian labels, linked instruments and impact badges', () => { + vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ + data: { + account: { + id: 'acc-1', + type: 'brokerage', + name: 'Broker', + status: 'ACCOUNT_STATUS_OPEN', + openedAt: null, + accessLevel: null, + }, + totals: { portfolio: { currency: 'RUB', units: '10000', nano: 0, value: 10000 } }, + yields: { expectedPercent: null, daily: null, dailyPercent: null }, + cash: [], + blockedCash: [], + positions: [], + asOf: '2026-06-17T00:00:00.000Z', + }, + isLoading: false, + error: null, + } as any); + vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({ + data: { + accountId: 'acc-1', + items: [ + { + cursor: 'op-1', + accountId: 'acc-1', + id: 'op-1', + parentOperationId: null, + date: '2026-06-17T10:00:00.000Z', + category: 'income', + type: 'OPERATION_TYPE_COUPON', + description: 'Coupon', + state: 'OPERATION_STATE_EXECUTED', + instrumentUid: 'bond-uid', + figi: null, + ticker: 'SU26238RMFS5', + classCode: 'TQOB', + instrumentType: 'bond', + payment: { currency: 'RUB', units: '120', nano: 0, value: 120 }, + price: null, + commission: null, + yield: null, + accruedInt: null, + quantity: 2, + quantityDone: 2, + }, + { + cursor: 'op-2', + accountId: 'acc-1', + id: 'op-2', + parentOperationId: null, + date: '2026-06-17T11:00:00.000Z', + category: 'tax', + type: 'OPERATION_TYPE_TAX', + description: 'Tax', + state: 'OPERATION_STATE_EXECUTED', + instrumentUid: null, + figi: null, + ticker: null, + classCode: null, + instrumentType: null, + payment: { currency: 'RUB', units: '-13', nano: 0, value: -13 }, + price: null, + commission: null, + yield: null, + accruedInt: null, + quantity: null, + quantityDone: null, + }, + ], + nextCursor: null, + hasNext: false, + asOf: '2026-06-17T00:00:00.000Z', + }, + isLoading: false, + error: null, + } as any); + + renderWithClient( + + } /> + , + ['/broker/acc-1'], + ); + + expect(screen.getByText('Выплата купона')).toBeInTheDocument(); + expect(screen.getByText('Налог')).toBeInTheDocument(); + expect(screen.getByText('Пополняет')).toBeInTheDocument(); + expect(screen.getByText('Списывает')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'SU26238RMFS5' })).toHaveAttribute( + 'href', + '/bonds/SU26238RMFS5', + ); + }); + + it('requests broker operations by cursor with a page size of 10', async () => { + const user = userEvent.setup(); + vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ + data: { + account: { + id: 'acc-1', + type: 'brokerage', + name: 'Broker', + status: 'ACCOUNT_STATUS_OPEN', + openedAt: null, + accessLevel: null, + }, + totals: { portfolio: { currency: 'RUB', units: '10000', nano: 0, value: 10000 } }, + yields: { expectedPercent: null, daily: null, dailyPercent: null }, + cash: [], + blockedCash: [], + positions: [], + asOf: '2026-06-17T00:00:00.000Z', + }, + isLoading: false, + error: null, + } as any); + const operationsSpy = vi.spyOn(operationsHook, 'useBrokerOperations').mockImplementation( + (_accountId, query) => + ({ + data: + query.cursor === 'cursor-page-2' + ? { + accountId: 'acc-1', + items: [ + { + cursor: 'op-page-2', + accountId: 'acc-1', + id: 'op-page-2', + parentOperationId: null, + date: '2026-06-17T12:00:00.000Z', + category: 'trade', + type: 'OPERATION_TYPE_SELL', + description: 'Sell', + state: 'OPERATION_STATE_EXECUTED', + instrumentUid: 'share-uid', + figi: null, + ticker: 'SBER', + classCode: 'TQBR', + instrumentType: 'share', + payment: { currency: 'RUB', units: '1000', nano: 0, value: 1000 }, + price: null, + commission: null, + yield: null, + accruedInt: null, + quantity: 1, + quantityDone: 1, + }, + ], + nextCursor: null, + hasNext: false, + asOf: '2026-06-17T00:00:00.000Z', + } + : { + accountId: 'acc-1', + items: [ + { + cursor: 'op-page-1', + accountId: 'acc-1', + id: 'op-page-1', + parentOperationId: null, + date: '2026-06-17T10:00:00.000Z', + category: 'trade', + type: 'OPERATION_TYPE_BUY', + description: 'Buy', + state: 'OPERATION_STATE_EXECUTED', + instrumentUid: 'share-uid', + figi: null, + ticker: 'SBER', + classCode: 'TQBR', + instrumentType: 'share', + payment: { currency: 'RUB', units: '-1000', nano: 0, value: -1000 }, + price: null, + commission: null, + yield: null, + accruedInt: null, + quantity: 1, + quantityDone: 1, + }, + ], + nextCursor: 'cursor-page-2', + hasNext: true, + asOf: '2026-06-17T00:00:00.000Z', + }, + isLoading: false, + error: null, + }) as any, + ); + + renderWithClient( + + } /> + , + ['/broker/acc-1'], + ); + + expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined }); + expect(screen.getByText('Страница 1')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Вперед' })); + + expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { + limit: 10, + cursor: 'cursor-page-2', + }); + expect(screen.getByText('Страница 2')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Назад' })); + + expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined }); + expect(screen.getByText('Страница 1')).toBeInTheDocument(); }); }); diff --git a/apps/frontend/src/pages/broker/BrokerPositionsSection.tsx b/apps/frontend/src/pages/broker/BrokerPositionsSection.tsx new file mode 100644 index 0000000..5c58ce5 --- /dev/null +++ b/apps/frontend/src/pages/broker/BrokerPositionsSection.tsx @@ -0,0 +1,151 @@ +import { Link } from 'react-router-dom'; +import type { BrokerMoney, BrokerPosition } from '../../api/responses'; +import { getBrokerInstrumentPath, getBrokerPositionGroup } from './brokerDisplay'; + +type BrokerPositionGroupConfig = { + key: 'shares' | 'bonds' | 'other'; + title: string; +}; + +const GROUPS: BrokerPositionGroupConfig[] = [ + { key: 'shares', title: 'Акции' }, + { key: 'bonds', title: 'Облигации' }, + { key: 'other', title: 'Другие инструменты' }, +]; + +const tableStyle = { + width: '100%', + borderCollapse: 'collapse', + fontSize: 14, +} satisfies React.CSSProperties; + +const thStyle = { + borderBottom: '1px solid #e0e0e0', + color: 'var(--color-text-secondary)', + fontWeight: 600, + padding: '10px 8px', +} satisfies React.CSSProperties; + +const tdStyle = { + borderBottom: '1px solid #eeeeee', + padding: '10px 8px', + verticalAlign: 'top', +} satisfies React.CSSProperties; + +function formatMoney(value: BrokerMoney | null | undefined) { + if (!value) return '-'; + + return new Intl.NumberFormat('ru-RU', { + style: 'currency', + currency: value.currency || 'RUB', + maximumFractionDigits: 2, + }).format(value.value); +} + +function formatQuantity(value: number | null | undefined) { + return value == null ? '-' : value.toLocaleString('ru-RU'); +} + +function PositionTicker({ position }: { position: BrokerPosition }) { + const label = position.ticker || position.figi || '-'; + const path = getBrokerInstrumentPath({ + ticker: position.ticker, + instrumentType: position.instrumentType, + classCode: position.classCode, + }); + + if (!path || label === '-') { + return {label}; + } + + return ( + + {label} + + ); +} + +function PositionTable({ title, positions }: { title: string; positions: BrokerPosition[] }) { + return ( +
+

{title}

+
+ + + + + + + + + + + + {positions.map((position) => ( + + + + + + + + ))} + +
+ Тикер + + Название + + Количество + + Цена + + Стоимость +
+ + + + {position.name || '-'} + + + {formatQuantity(position.quantity)} + + {formatMoney(position.currentPrice)} + + {formatMoney(position.currentValue)} +
+
+
+ ); +} + +export function BrokerPositionsSection({ positions }: { positions: BrokerPosition[] }) { + const grouped = GROUPS.map((group) => ({ + ...group, + positions: positions.filter((position) => getBrokerPositionGroup(position) === group.key), + })).filter((group) => group.positions.length > 0); + + if (grouped.length === 0) { + return ( +
+

Позиции

+

В портфеле нет позиций

+
+ ); + } + + return ( +
+

Позиции

+
+ {grouped.map((group) => ( + + ))} +
+
+ ); +} diff --git a/apps/frontend/src/pages/broker/brokerDisplay.test.ts b/apps/frontend/src/pages/broker/brokerDisplay.test.ts new file mode 100644 index 0000000..eabb2f0 --- /dev/null +++ b/apps/frontend/src/pages/broker/brokerDisplay.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from 'vitest'; +import type { BrokerOperation, BrokerPosition } from '../../api/responses'; +import { + getBrokerInstrumentPath, + getBrokerOperationImpact, + getBrokerOperationImpactLabel, + getBrokerOperationTypeLabel, + getBrokerPositionGroup, +} from './brokerDisplay'; + +function position(input: Partial): BrokerPosition { + return { + figi: null, + instrumentUid: null, + positionUid: null, + ticker: null, + classCode: null, + instrumentType: null, + name: null, + quantity: null, + blockedLots: null, + currentPrice: null, + currentValue: null, + averagePositionPrice: null, + expectedYieldPercent: null, + dailyYield: null, + ...input, + }; +} + +function operation(input: Partial): BrokerOperation { + return { + cursor: null, + accountId: 'acc-1', + id: null, + parentOperationId: null, + date: null, + type: 'OPERATION_TYPE_UNSPECIFIED', + category: 'other', + description: null, + state: null, + instrumentUid: null, + figi: null, + ticker: null, + classCode: null, + instrumentType: null, + payment: null, + price: null, + commission: null, + yield: null, + accruedInt: null, + quantity: null, + quantityDone: null, + ...input, + }; +} + +describe('broker display helpers', () => { + it('groups positions by instrument type', () => { + expect(getBrokerPositionGroup(position({ instrumentType: 'share' }))).toBe('shares'); + expect(getBrokerPositionGroup(position({ instrumentType: 'bond' }))).toBe('bonds'); + expect(getBrokerPositionGroup(position({ instrumentType: 'etf' }))).toBe('other'); + expect(getBrokerPositionGroup(position({ instrumentType: null }))).toBe('other'); + }); + + it('builds stock and bond routes from instrument metadata', () => { + expect( + getBrokerInstrumentPath({ ticker: 'sber', instrumentType: 'share', classCode: 'TQBR' }), + ).toBe('/stocks/SBER'); + expect( + getBrokerInstrumentPath({ + ticker: 'SU26238RMFS5', + instrumentType: 'bond', + classCode: 'TQOB', + }), + ).toBe('/bonds/SU26238RMFS5'); + expect( + getBrokerInstrumentPath({ ticker: null, instrumentType: 'share', classCode: 'TQBR' }), + ).toBeNull(); + expect( + getBrokerInstrumentPath({ ticker: 'TMOS', instrumentType: 'etf', classCode: 'TQTF' }), + ).toBeNull(); + }); + + it('uses class code fallback when instrument type is missing', () => { + expect( + getBrokerInstrumentPath({ ticker: 'SBER', instrumentType: null, classCode: 'TQBR' }), + ).toBe('/stocks/SBER'); + expect( + getBrokerInstrumentPath({ ticker: 'RU000A0JX0J2', instrumentType: null, classCode: 'TQOB' }), + ).toBe('/bonds/RU000A0JX0J2'); + }); + + it('does not let class code override a known unsupported or conflicting instrument type', () => { + expect( + getBrokerInstrumentPath({ ticker: 'TMOS', instrumentType: 'etf', classCode: 'TQBR' }), + ).toBeNull(); + expect( + getBrokerInstrumentPath({ + ticker: 'SU26238RMFS5', + instrumentType: 'bond', + classCode: 'TQBR', + }), + ).toBe('/bonds/SU26238RMFS5'); + }); + + it('maps operation enum values to Russian labels', () => { + expect(getBrokerOperationTypeLabel(operation({ type: 'OPERATION_TYPE_COUPON' }))).toBe( + 'Выплата купона', + ); + expect(getBrokerOperationTypeLabel(operation({ type: 'OPERATION_TYPE_TAX' }))).toBe('Налог'); + expect(getBrokerOperationTypeLabel(operation({ type: 'OPERATION_TYPE_BUY' }))).toBe('Покупка'); + expect( + getBrokerOperationTypeLabel( + operation({ type: 'OPERATION_TYPE_UNKNOWN_VALUE', description: 'Custom' }), + ), + ).toBe('Custom'); + }); + + it('classifies operations by portfolio impact', () => { + expect( + getBrokerOperationImpact( + operation({ + type: 'OPERATION_TYPE_COUPON', + category: 'income', + payment: { currency: 'RUB', units: '120', nano: 0, value: 120 }, + }), + ), + ).toBe('adds'); + expect( + getBrokerOperationImpact( + operation({ + type: 'OPERATION_TYPE_TAX', + category: 'tax', + payment: { currency: 'RUB', units: '-13', nano: 0, value: -13 }, + }), + ), + ).toBe('reduces'); + expect( + getBrokerOperationImpact( + operation({ + type: 'OPERATION_TYPE_SELL', + category: 'trade', + payment: { currency: 'RUB', units: '1000', nano: 0, value: 1000 }, + }), + ), + ).toBe('neutral'); + expect(getBrokerOperationImpact(operation({ type: 'OPERATION_TYPE_UNSPECIFIED' }))).toBe( + 'unknown', + ); + }); + + it('keeps unknown operation types unclear even when they have non-zero payments', () => { + expect( + getBrokerOperationImpact( + operation({ + type: 'OPERATION_TYPE_UNRECOGNIZED_NEW_VALUE', + category: 'other', + payment: { currency: 'RUB', units: '100', nano: 0, value: 100 }, + }), + ), + ).toBe('unknown'); + expect( + getBrokerOperationImpact( + operation({ + type: 'OPERATION_TYPE_UNRECOGNIZED_NEW_VALUE', + category: 'other', + payment: { currency: 'RUB', units: '-100', nano: 0, value: -100 }, + }), + ), + ).toBe('unknown'); + }); + + it('classifies known income operation types as additions even with weak metadata', () => { + expect( + getBrokerOperationImpact( + operation({ + type: 'OPERATION_TYPE_COUPON', + category: 'other', + payment: null, + }), + ), + ).toBe('adds'); + expect( + getBrokerOperationImpact( + operation({ + type: 'OPERATION_TYPE_DIVIDEND', + category: 'other', + payment: null, + }), + ), + ).toBe('adds'); + }); + + it('provides Russian impact labels', () => { + expect(getBrokerOperationImpactLabel('adds')).toBe('Пополняет'); + expect(getBrokerOperationImpactLabel('reduces')).toBe('Списывает'); + expect(getBrokerOperationImpactLabel('neutral')).toBe('Перекладка'); + expect(getBrokerOperationImpactLabel('unknown')).toBe('Неясно'); + }); +}); diff --git a/apps/frontend/src/pages/broker/brokerDisplay.ts b/apps/frontend/src/pages/broker/brokerDisplay.ts new file mode 100644 index 0000000..716fc3d --- /dev/null +++ b/apps/frontend/src/pages/broker/brokerDisplay.ts @@ -0,0 +1,176 @@ +import type { BrokerOperation, BrokerPosition } from '../../api/responses'; + +export type BrokerPositionGroup = 'shares' | 'bonds' | 'other'; +export type BrokerOperationImpact = 'adds' | 'reduces' | 'neutral' | 'unknown'; + +type BrokerInstrumentLinkInput = { + ticker: string | null; + instrumentType: string | null; + classCode: string | null; +}; + +const STOCK_CLASS_CODES = new Set(['TQBR']); +const BOND_CLASS_CODES = new Set(['TQOB', 'TQCB', 'TQIR']); + +const TRADE_TYPES = new Set([ + 'OPERATION_TYPE_BUY', + 'OPERATION_TYPE_BUY_CARD', + 'OPERATION_TYPE_SELL', + 'OPERATION_TYPE_SELL_CARD', + 'OPERATION_TYPE_BUY_MARGIN', + 'OPERATION_TYPE_SELL_MARGIN', + 'OPERATION_TYPE_DELIVERY_BUY', + 'OPERATION_TYPE_DELIVERY_SELL', +]); + +const BOND_REPAYMENT_TYPES = new Set([ + 'OPERATION_TYPE_BOND_REPAYMENT', + 'OPERATION_TYPE_BOND_REPAYMENT_FULL', +]); + +const INCOME_TYPES = new Set(['OPERATION_TYPE_COUPON', 'OPERATION_TYPE_DIVIDEND']); + +const TAX_TYPES = new Set([ + 'OPERATION_TYPE_TAX', + 'OPERATION_TYPE_BOND_TAX', + 'OPERATION_TYPE_DIVIDEND_TAX', + 'OPERATION_TYPE_TAX_CORRECTION', + 'OPERATION_TYPE_TAX_CORRECTION_COUPON', +]); + +const FEE_TYPES = new Set([ + 'OPERATION_TYPE_BROKER_FEE', + 'OPERATION_TYPE_SERVICE_FEE', + 'OPERATION_TYPE_MARGIN_FEE', + 'OPERATION_TYPE_SUCCESS_FEE', +]); + +const TRANSFER_INPUT_TYPES = new Set([ + 'OPERATION_TYPE_INPUT', + 'OPERATION_TYPE_INPUT_SWIFT', + 'OPERATION_TYPE_INPUT_ACQUIRING', + 'OPERATION_TYPE_INP_MULTI', +]); + +const TRANSFER_OUTPUT_TYPES = new Set([ + 'OPERATION_TYPE_OUTPUT', + 'OPERATION_TYPE_OUTPUT_SWIFT', + 'OPERATION_TYPE_OUTPUT_ACQUIRING', + 'OPERATION_TYPE_OUT_MULTI', +]); + +const SECURITY_TRANSFER_TYPES = new Set([ + 'OPERATION_TYPE_INPUT_SECURITIES', + 'OPERATION_TYPE_OUTPUT_SECURITIES', + 'OPERATION_TYPE_TRANS_IIS_BS', + 'OPERATION_TYPE_TRANS_BS_BS', +]); + +const OPERATION_TYPE_LABELS: Record = { + OPERATION_TYPE_BUY: 'Покупка', + OPERATION_TYPE_BUY_CARD: 'Покупка', + OPERATION_TYPE_SELL: 'Продажа', + OPERATION_TYPE_SELL_CARD: 'Продажа', + OPERATION_TYPE_BUY_MARGIN: 'Покупка с маржой', + OPERATION_TYPE_SELL_MARGIN: 'Продажа с маржой', + OPERATION_TYPE_DELIVERY_BUY: 'Поставка покупки', + OPERATION_TYPE_DELIVERY_SELL: 'Поставка продажи', + OPERATION_TYPE_COUPON: 'Выплата купона', + OPERATION_TYPE_DIVIDEND: 'Дивиденды', + OPERATION_TYPE_BOND_REPAYMENT: 'Погашение облигации', + OPERATION_TYPE_BOND_REPAYMENT_FULL: 'Полное погашение облигации', + OPERATION_TYPE_TAX: 'Налог', + OPERATION_TYPE_BOND_TAX: 'Налог по облигациям', + OPERATION_TYPE_DIVIDEND_TAX: 'Налог на дивиденды', + OPERATION_TYPE_TAX_CORRECTION: 'Корректировка налога', + OPERATION_TYPE_TAX_CORRECTION_COUPON: 'Корректировка налога по купону', + OPERATION_TYPE_BROKER_FEE: 'Комиссия брокера', + OPERATION_TYPE_SERVICE_FEE: 'Комиссия за обслуживание', + OPERATION_TYPE_MARGIN_FEE: 'Комиссия за маржу', + OPERATION_TYPE_SUCCESS_FEE: 'Комиссия за результат', + OPERATION_TYPE_INPUT: 'Пополнение', + OPERATION_TYPE_OUTPUT: 'Вывод средств', + OPERATION_TYPE_INPUT_SECURITIES: 'Зачисление бумаг', + OPERATION_TYPE_OUTPUT_SECURITIES: 'Списание бумаг', +}; + +export function getBrokerPositionGroup( + position: Pick, +): BrokerPositionGroup { + const instrumentType = position.instrumentType?.toLowerCase(); + + if (instrumentType === 'share') return 'shares'; + if (instrumentType === 'bond') return 'bonds'; + + return 'other'; +} + +export function getBrokerInstrumentPath(input: BrokerInstrumentLinkInput): string | null { + const ticker = input.ticker?.trim().toUpperCase(); + if (!ticker) return null; + + const instrumentType = input.instrumentType?.toLowerCase(); + const classCode = input.classCode?.toUpperCase() ?? null; + + if (instrumentType === 'share') { + return `/stocks/${encodeURIComponent(ticker)}`; + } + + if (instrumentType === 'bond') { + return `/bonds/${encodeURIComponent(ticker)}`; + } + + if (instrumentType) return null; + + if (classCode && STOCK_CLASS_CODES.has(classCode)) return `/stocks/${encodeURIComponent(ticker)}`; + if (classCode && BOND_CLASS_CODES.has(classCode)) return `/bonds/${encodeURIComponent(ticker)}`; + + return null; +} + +export function getBrokerOperationTypeLabel( + operation: Pick, +): string { + const knownLabel = OPERATION_TYPE_LABELS[operation.type]; + if (knownLabel) return knownLabel; + if (operation.description) return operation.description; + + return operation.type + .replace(/^OPERATION_TYPE_/, '') + .replace(/_/g, ' ') + .toLowerCase(); +} + +export function getBrokerOperationImpact( + operation: Pick, +): BrokerOperationImpact { + if ( + TRADE_TYPES.has(operation.type) || + BOND_REPAYMENT_TYPES.has(operation.type) || + SECURITY_TRANSFER_TYPES.has(operation.type) + ) { + return 'neutral'; + } + + if (INCOME_TYPES.has(operation.type)) return 'adds'; + if (TAX_TYPES.has(operation.type) || FEE_TYPES.has(operation.type)) return 'reduces'; + if (TRANSFER_INPUT_TYPES.has(operation.type)) return 'adds'; + if (TRANSFER_OUTPUT_TYPES.has(operation.type)) return 'reduces'; + if (operation.category === 'tax' || operation.category === 'fee') return 'reduces'; + if (operation.category === 'income' && (operation.payment?.value ?? 0) > 0) return 'adds'; + + return 'unknown'; +} + +export function getBrokerOperationImpactLabel(impact: BrokerOperationImpact): string { + switch (impact) { + case 'adds': + return 'Пополняет'; + case 'reduces': + return 'Списывает'; + case 'neutral': + return 'Перекладка'; + case 'unknown': + return 'Неясно'; + } +} diff --git a/docs/superpowers/plans/2026-06-16-tbank-broker-portfolios.md b/docs/superpowers/plans/2026-06-16-tbank-broker-portfolios.md new file mode 100644 index 0000000..5036565 --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-tbank-broker-portfolios.md @@ -0,0 +1,3552 @@ +# T-Bank Broker Portfolios Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a read-only T-Bank Invest broker portfolios area that lists brokerage/IIS accounts, current positions, cash balances, and operation history. + +**Architecture:** Add a dedicated backend `TBankModule` that owns gRPC transport, auth metadata, rate limiting, cache use, and DTO mapping. Expose MoexVibe REST endpoints under `/api/v1/broker/*`, then add frontend API/hooks/pages for a broker accounts list and account detail tabs. Persist operation history only after the direct-read MVP is working, using Prisma models that do not touch the existing manual `Portfolio` domain. + +**Tech Stack:** NestJS 10, `@grpc/grpc-js`, `@grpc/proto-loader`, `p-queue`, `@nestjs/cache-manager`, Prisma SQLite, React 18, TanStack Query v5, Vitest, Docusaurus. + +--- + +## File Structure + +### Backend + +- Create `apps/backend/src/modules/tbank/tbank.module.ts`: Nest feature module. +- Create `apps/backend/src/modules/tbank/tbank.controller.ts`: REST endpoints under `broker`. +- Create `apps/backend/src/modules/tbank/tbank.config.ts`: constants and config helpers for service names and TTL keys. +- Create `apps/backend/src/modules/tbank/types/tbank-proto.types.ts`: narrow TypeScript interfaces for the proto payloads used by MoexVibe. +- Create `apps/backend/src/modules/tbank/types/broker.types.ts`: normalized domain types returned by services. +- Create `apps/backend/src/modules/tbank/services/tbank-client.service.ts`: gRPC channel/client factory, metadata, limiter, unary wrapper. +- Create `apps/backend/src/modules/tbank/services/broker-accounts.service.ts`: `GetAccounts` + account filtering. +- Create `apps/backend/src/modules/tbank/services/broker-instruments.service.ts`: `GetInstrumentBy` cache wrapper. +- Create `apps/backend/src/modules/tbank/services/broker-portfolio.service.ts`: `GetPortfolio` + `GetPositions` aggregation. +- Create `apps/backend/src/modules/tbank/services/broker-operations.service.ts`: `GetOperationsByCursor` query and categorization. +- Create `apps/backend/src/modules/tbank/mappers/money.mapper.ts`: `MoneyValue` and `Quotation` conversion. +- Create `apps/backend/src/modules/tbank/mappers/account.mapper.ts`: account normalization and filter predicates. +- Create `apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts`: portfolio/positions normalization. +- Create `apps/backend/src/modules/tbank/mappers/operation.mapper.ts`: operation normalization and categories. +- Create DTO files under `apps/backend/src/modules/tbank/dto/`: Swagger and validation classes. +- Create tests next to each service/mapper: `*.spec.ts`. +- Create vendored proto files under `apps/backend/src/modules/tbank/proto/contracts/`. +- Modify `apps/backend/src/app.module.ts`: import `TBankModule`. +- Modify `apps/backend/src/config/configuration.ts`: add `tbank` and T-Bank cache TTL config. +- Modify `apps/backend/package.json` and root lockfile through `npm install`. + +### Durable Sync + +- Modify `apps/backend/prisma/schema.prisma`: add `BrokerOperation` and `BrokerOperationSyncState`. +- Create `apps/backend/src/modules/tbank/services/broker-operation-sync.service.ts`: local upsert/backfill logic. +- Create tests for sync windowing and upsert mapping. + +### Frontend + +- Create `apps/frontend/src/api/broker.ts`: broker API client functions. +- Modify `apps/frontend/src/api/responses.ts`: add broker response interfaces. +- Create `apps/frontend/src/hooks/useBrokerAccounts.ts`. +- Create `apps/frontend/src/hooks/useBrokerPortfolio.ts`. +- Create `apps/frontend/src/hooks/useBrokerOperations.ts`. +- Create `apps/frontend/src/pages/broker/BrokerAccountsPage.tsx`. +- Create `apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx`. +- Create `apps/frontend/src/pages/broker/BrokerPages.test.tsx`. +- Modify `apps/frontend/src/routes.tsx`: add protected broker routes. +- Modify `apps/frontend/src/components/Layout.tsx`: add navigation link. +- Modify `apps/frontend/src/styles.css`: no changes are expected for the first UI pass; keep this + file untouched unless tests or browser verification reveal text overlap. + +### Published Docs + +- Create `apps/docs/docs/backend/tbank-invest.md`. +- Modify `apps/docs/docs/backend/modules.md`. +- Modify `apps/docs/docs/backend/configuration.md`. +- Modify `apps/docs/docs/backend/caching.md`. +- Modify `apps/docs/docs/backend/portfolio.md`. +- Create `apps/docs/docs/adr/ADR-011-tbank-invest-grpc.md`. +- Modify `apps/docs/docs/adr/index.md`. +- Modify `apps/docs/sidebars.ts`. + +--- + +## Task 1: Add gRPC Dependencies And Official Proto Contracts + +**Files:** +- Modify: `apps/backend/package.json` +- Modify: `package-lock.json` +- Create: `apps/backend/src/modules/tbank/proto/contracts/common.proto` +- Create: `apps/backend/src/modules/tbank/proto/contracts/users.proto` +- Create: `apps/backend/src/modules/tbank/proto/contracts/operations.proto` +- Create: `apps/backend/src/modules/tbank/proto/contracts/instruments.proto` +- Create: `apps/backend/src/modules/tbank/proto/contracts/google/api/field_behavior.proto` + +- [ ] **Step 1: Install backend gRPC runtime dependencies** + +Run: + +```bash +npm install @grpc/grpc-js @grpc/proto-loader protobufjs long -w apps/backend +``` + +Expected: `apps/backend/package.json` gains the four dependencies and `package-lock.json` updates. + +- [ ] **Step 2: Vendor official T-Bank proto contracts** + +Run: + +```bash +mkdir -p apps/backend/src/modules/tbank/proto/contracts/google/api +curl -L -s https://opensource.tbank.ru/invest/invest-contracts/-/raw/master/src/docs/contracts/common.proto -o apps/backend/src/modules/tbank/proto/contracts/common.proto +curl -L -s https://opensource.tbank.ru/invest/invest-contracts/-/raw/master/src/docs/contracts/users.proto -o apps/backend/src/modules/tbank/proto/contracts/users.proto +curl -L -s https://opensource.tbank.ru/invest/invest-contracts/-/raw/master/src/docs/contracts/operations.proto -o apps/backend/src/modules/tbank/proto/contracts/operations.proto +curl -L -s https://opensource.tbank.ru/invest/invest-contracts/-/raw/master/src/docs/contracts/instruments.proto -o apps/backend/src/modules/tbank/proto/contracts/instruments.proto +curl -L -s https://raw.githubusercontent.com/googleapis/googleapis/master/google/api/field_behavior.proto -o apps/backend/src/modules/tbank/proto/contracts/google/api/field_behavior.proto +``` + +Expected: each file exists and begins with `syntax = "proto3";`. + +- [ ] **Step 3: Verify proto service methods are present** + +Run: + +```bash +rg -n "rpc GetAccounts|rpc GetPortfolio|rpc GetPositions|rpc GetOperationsByCursor|rpc GetInstrumentBy" apps/backend/src/modules/tbank/proto/contracts +``` + +Expected: output includes all five method declarations. + +- [ ] **Step 4: Commit dependencies and contracts** + +```bash +git add apps/backend/package.json package-lock.json apps/backend/src/modules/tbank/proto/contracts +git commit -m "feat: add tbank invest proto contracts" +``` + +--- + +## Task 2: Add Configuration And Core T-Bank Types + +**Files:** +- Modify: `apps/backend/src/config/configuration.ts` +- Create: `apps/backend/src/modules/tbank/tbank.config.ts` +- Create: `apps/backend/src/modules/tbank/types/tbank-proto.types.ts` +- Create: `apps/backend/src/modules/tbank/types/broker.types.ts` +- Test: `apps/backend/src/modules/tbank/tbank.config.spec.ts` + +- [ ] **Step 1: Write failing config test** + +Create `apps/backend/src/modules/tbank/tbank.config.spec.ts`: + +```typescript +import configuration from '../../config/configuration'; + +describe('T-Bank configuration', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it('uses conservative defaults for T-Bank integration', () => { + delete process.env.T_BANK_BASE_URL; + delete process.env.T_BANK_RATE_LIMIT_PER_SECOND; + delete process.env.CACHE_TBANK_PORTFOLIO_TTL; + + const config = configuration(); + + expect(config.tbank.baseUrl).toBe('invest-public-api.tbank.ru:443'); + expect(config.tbank.rateLimitPerSecond).toBe(5); + expect(config.cache.tbankPortfolioTtl).toBe(60); + }); + + it('reads T-Bank token and TTL overrides from environment', () => { + process.env.T_BANK_TOKEN = 'secret-token'; + process.env.T_BANK_BASE_URL = 'sandbox-invest-public-api.tbank.ru:443'; + process.env.T_BANK_RATE_LIMIT_PER_SECOND = '2'; + process.env.CACHE_TBANK_ACCOUNTS_TTL = '120'; + + const config = configuration(); + + expect(config.tbank.token).toBe('secret-token'); + expect(config.tbank.baseUrl).toBe('sandbox-invest-public-api.tbank.ru:443'); + expect(config.tbank.rateLimitPerSecond).toBe(2); + expect(config.cache.tbankAccountsTtl).toBe(120); + }); +}); +``` + +- [ ] **Step 2: Run failing config test** + +Run: + +```bash +npx vitest run src/modules/tbank/tbank.config.spec.ts -w apps/backend +``` + +Expected: FAIL because `config.tbank` and T-Bank cache keys do not exist yet. + +- [ ] **Step 3: Extend backend configuration** + +Modify `apps/backend/src/config/configuration.ts` so the returned object includes: + +```typescript +tbank: { + token: process.env.T_BANK_TOKEN || '', + baseUrl: process.env.T_BANK_BASE_URL || 'invest-public-api.tbank.ru:443', + appName: process.env.T_BANK_APP_NAME || 'ksv741.moex-vibe', + rateLimitPerSecond: parseInt(process.env.T_BANK_RATE_LIMIT_PER_SECOND || '5', 10), + requestTimeoutMs: parseInt(process.env.T_BANK_REQUEST_TIMEOUT_MS || '10000', 10), +}, +cache: { + marketDataTtl: parseInt(process.env.CACHE_MARKET_DATA_TTL || '900', 10), + historyTtl: parseInt(process.env.CACHE_HISTORY_TTL || '3600', 10), + candlesTtl: parseInt(process.env.CACHE_CANDLES_TTL || '3600', 10), + securityTtl: parseInt(process.env.CACHE_SECURITY_TTL || '86400', 10), + searchTtl: parseInt(process.env.CACHE_SEARCH_TTL || '3600', 10), + dividendsTtl: parseInt(process.env.CACHE_DIVIDENDS_TTL || '86400', 10), + tbankAccountsTtl: parseInt(process.env.CACHE_TBANK_ACCOUNTS_TTL || '3600', 10), + tbankPortfolioTtl: parseInt(process.env.CACHE_TBANK_PORTFOLIO_TTL || '60', 10), + tbankOperationsTtl: parseInt(process.env.CACHE_TBANK_OPERATIONS_TTL || '300', 10), + tbankInstrumentTtl: parseInt(process.env.CACHE_TBANK_INSTRUMENT_TTL || '86400', 10), +}, +``` + +Keep the existing `port`, `database`, `moex`, and `auth` sections unchanged. + +- [ ] **Step 4: Add T-Bank constants** + +Create `apps/backend/src/modules/tbank/tbank.config.ts`: + +```typescript +export const TBANK_PROTO_PACKAGE = 'tinkoff.public.invest.api.contract.v1'; + +export const TBANK_PROTO_FILES = { + users: 'users.proto', + operations: 'operations.proto', + instruments: 'instruments.proto', +} as const; + +export const TBANK_ACCOUNT_TYPES = { + brokerage: 'ACCOUNT_TYPE_TINKOFF', + iis: 'ACCOUNT_TYPE_TINKOFF_IIS', +} as const; + +export const TBANK_OPEN_ACCOUNT_STATUS = 'ACCOUNT_STATUS_OPEN'; + +export const TBANK_CACHE_KEYS = { + accounts: 'tbank:accounts', + portfolio: 'tbank:portfolio', + positions: 'tbank:positions', + operations: 'tbank:operations', + instrument: 'tbank:instrument', +} as const; +``` + +- [ ] **Step 5: Add narrow proto interfaces** + +Create `apps/backend/src/modules/tbank/types/tbank-proto.types.ts`: + +```typescript +export type TBankTimestamp = { + seconds?: number | string; + nanos?: number; +}; + +export type TBankMoneyValue = { + currency?: string; + units?: number | string; + nano?: number; +}; + +export type TBankQuotation = { + units?: number | string; + nano?: number; +}; + +export type TBankAccount = { + id: string; + type: string; + name?: string; + status: string; + openedDate?: TBankTimestamp; + closedDate?: TBankTimestamp; + accessLevel?: string; +}; + +export type TBankAccountsResponse = { + accounts?: TBankAccount[]; +}; + +export type TBankPortfolioPosition = { + figi?: string; + instrumentType?: string; + quantity?: TBankQuotation; + averagePositionPrice?: TBankMoneyValue; + expectedYield?: TBankQuotation; + currentNkd?: TBankMoneyValue; + currentPrice?: TBankMoneyValue; + averagePositionPriceFifo?: TBankMoneyValue; + blocked?: boolean; + blockedLots?: TBankQuotation; + positionUid?: string; + instrumentUid?: string; + expectedYieldFifo?: TBankQuotation; + dailyYield?: TBankMoneyValue; + ticker?: string; + classCode?: string; +}; + +export type TBankPortfolioResponse = { + accountId?: string; + totalAmountShares?: TBankMoneyValue; + totalAmountBonds?: TBankMoneyValue; + totalAmountEtf?: TBankMoneyValue; + totalAmountCurrencies?: TBankMoneyValue; + totalAmountFutures?: TBankMoneyValue; + expectedYield?: TBankQuotation; + positions?: TBankPortfolioPosition[]; + totalAmountOptions?: TBankMoneyValue; + totalAmountSp?: TBankMoneyValue; + totalAmountPortfolio?: TBankMoneyValue; + dailyYield?: TBankMoneyValue; + dailyYieldRelative?: TBankQuotation; + totalAmountDfa?: TBankMoneyValue; +}; + +export type TBankPositionsSecurity = { + figi?: string; + blocked?: string | number; + balance?: string | number; + positionUid?: string; + instrumentUid?: string; + ticker?: string; + classCode?: string; + exchangeBlocked?: boolean; + instrumentType?: string; +}; + +export type TBankPositionsResponse = { + accountId?: string; + money?: TBankMoneyValue[]; + blocked?: TBankMoneyValue[]; + securities?: TBankPositionsSecurity[]; +}; + +export type TBankOperationTrade = { + num?: string; + date?: TBankTimestamp; + quantity?: string | number; + price?: TBankMoneyValue; + yield?: TBankMoneyValue; + yieldRelative?: TBankQuotation; +}; + +export type TBankOperationItem = { + cursor?: string; + brokerAccountId?: string; + id?: string; + parentOperationId?: string; + name?: string; + date?: TBankTimestamp; + type?: string; + description?: string; + state?: string; + instrumentUid?: string; + figi?: string; + instrumentType?: string; + instrumentKind?: string; + positionUid?: string; + ticker?: string; + classCode?: string; + payment?: TBankMoneyValue; + price?: TBankMoneyValue; + commission?: TBankMoneyValue; + yield?: TBankMoneyValue; + yieldRelative?: TBankQuotation; + accruedInt?: TBankMoneyValue; + quantity?: string | number; + quantityRest?: string | number; + quantityDone?: string | number; + tradesInfo?: { trades?: TBankOperationTrade[] }; +}; + +export type TBankOperationsByCursorResponse = { + hasNext?: boolean; + nextCursor?: string; + items?: TBankOperationItem[]; +}; + +export type TBankInstrument = { + figi?: string; + ticker?: string; + classCode?: string; + isin?: string; + lot?: number; + currency?: string; + name?: string; + exchange?: string; + instrumentType?: string; + uid?: string; + positionUid?: string; + assetUid?: string; + instrumentKind?: string; +}; + +export type TBankInstrumentResponse = { + instrument?: TBankInstrument; +}; +``` + +- [ ] **Step 6: Add normalized broker domain types** + +Create `apps/backend/src/modules/tbank/types/broker.types.ts`: + +```typescript +export type BrokerMoney = { + currency: string; + units: string; + nano: number; + value: number; +}; + +export type BrokerAccount = { + id: string; + type: 'brokerage' | 'iis'; + name: string; + status: string; + openedAt: string | null; + accessLevel: string | null; +}; + +export type BrokerPosition = { + figi: string | null; + instrumentUid: string | null; + positionUid: string | null; + ticker: string | null; + classCode: string | null; + instrumentType: string | null; + name: string | null; + quantity: number | null; + blockedLots: number | null; + currentPrice: BrokerMoney | null; + currentValue: BrokerMoney | null; + averagePositionPrice: BrokerMoney | null; + expectedYieldPercent: number | null; + dailyYield: BrokerMoney | null; +}; + +export type BrokerPortfolio = { + account: BrokerAccount; + totals: { + shares: BrokerMoney | null; + bonds: BrokerMoney | null; + etf: BrokerMoney | null; + currencies: BrokerMoney | null; + futures: BrokerMoney | null; + options: BrokerMoney | null; + structuredProducts: BrokerMoney | null; + dfa: BrokerMoney | null; + portfolio: BrokerMoney | null; + }; + yields: { + expectedPercent: number | null; + daily: BrokerMoney | null; + dailyPercent: number | null; + }; + cash: BrokerMoney[]; + blockedCash: BrokerMoney[]; + positions: BrokerPosition[]; + asOf: string; +}; + +export type BrokerOperationCategory = 'trade' | 'income' | 'tax' | 'fee' | 'transfer' | 'other'; + +export type BrokerOperation = { + cursor: string | null; + accountId: string; + id: string | null; + parentOperationId: string | null; + date: string | null; + type: string; + category: BrokerOperationCategory; + description: string | null; + state: string | null; + instrumentUid: string | null; + figi: string | null; + ticker: string | null; + classCode: string | null; + instrumentType: string | null; + payment: BrokerMoney | null; + price: BrokerMoney | null; + commission: BrokerMoney | null; + yield: BrokerMoney | null; + accruedInt: BrokerMoney | null; + quantity: number | null; + quantityDone: number | null; +}; + +export type BrokerOperationsPage = { + accountId: string; + items: BrokerOperation[]; + nextCursor: string | null; + hasNext: boolean; + asOf: string; +}; +``` + +- [ ] **Step 7: Run config test and commit** + +Run: + +```bash +npx vitest run src/modules/tbank/tbank.config.spec.ts -w apps/backend +``` + +Expected: PASS. + +Commit: + +```bash +git add apps/backend/src/config/configuration.ts apps/backend/src/modules/tbank/tbank.config.ts apps/backend/src/modules/tbank/types apps/backend/src/modules/tbank/tbank.config.spec.ts +git commit -m "feat: configure tbank integration" +``` + +--- + +## Task 3: Implement Money, Account, And Operation Mappers + +**Files:** +- Create: `apps/backend/src/modules/tbank/mappers/money.mapper.ts` +- Create: `apps/backend/src/modules/tbank/mappers/money.mapper.spec.ts` +- Create: `apps/backend/src/modules/tbank/mappers/account.mapper.ts` +- Create: `apps/backend/src/modules/tbank/mappers/account.mapper.spec.ts` +- Create: `apps/backend/src/modules/tbank/mappers/operation.mapper.ts` +- Create: `apps/backend/src/modules/tbank/mappers/operation.mapper.spec.ts` + +- [ ] **Step 1: Write money mapper tests** + +Create `apps/backend/src/modules/tbank/mappers/money.mapper.spec.ts`: + +```typescript +import { mapMoneyValue, mapQuotationToNumber, mapTimestampToIso } from './money.mapper'; + +describe('money.mapper', () => { + it('maps positive MoneyValue with nano precision', () => { + expect(mapMoneyValue({ currency: 'rub', units: '123', nano: 450000000 })).toEqual({ + currency: 'RUB', + units: '123', + nano: 450000000, + value: 123.45, + }); + }); + + it('maps negative MoneyValue with negative nano', () => { + expect(mapMoneyValue({ currency: 'rub', units: '-5', nano: -250000000 })).toEqual({ + currency: 'RUB', + units: '-5', + nano: -250000000, + value: -5.25, + }); + }); + + it('returns null for absent MoneyValue', () => { + expect(mapMoneyValue(undefined)).toBeNull(); + }); + + it('maps quotation to number', () => { + expect(mapQuotationToNumber({ units: '12', nano: 345000000 })).toBe(12.345); + }); + + it('maps unix timestamp seconds to ISO string', () => { + expect(mapTimestampToIso({ seconds: '1781577000', nanos: 0 })).toBe('2026-06-16T02:30:00.000Z'); + }); +}); +``` + +- [ ] **Step 2: Run failing money mapper test** + +Run: + +```bash +npx vitest run src/modules/tbank/mappers/money.mapper.spec.ts -w apps/backend +``` + +Expected: FAIL because `money.mapper.ts` does not exist. + +- [ ] **Step 3: Implement money mapper** + +Create `apps/backend/src/modules/tbank/mappers/money.mapper.ts`: + +```typescript +import type { BrokerMoney } from '../types/broker.types'; +import type { TBankMoneyValue, TBankQuotation, TBankTimestamp } from '../types/tbank-proto.types'; + +const NANO_FACTOR = 1_000_000_000; + +export function mapMoneyValue(value: TBankMoneyValue | null | undefined): BrokerMoney | null { + if (!value) return null; + const units = String(value.units ?? '0'); + const nano = value.nano ?? 0; + const numericUnits = Number(units); + const decimal = numericUnits + nano / NANO_FACTOR; + + return { + currency: (value.currency || '').toUpperCase(), + units, + nano, + value: Number(decimal.toFixed(9)), + }; +} + +export function mapQuotationToNumber(value: TBankQuotation | null | undefined): number | null { + if (!value) return null; + const units = Number(value.units ?? 0); + const nano = value.nano ?? 0; + return Number((units + nano / NANO_FACTOR).toFixed(9)); +} + +export function mapTimestampToIso(value: TBankTimestamp | null | undefined): string | null { + if (!value?.seconds) return null; + const millis = Number(value.seconds) * 1000 + Math.floor((value.nanos ?? 0) / 1_000_000); + return new Date(millis).toISOString(); +} + +export function mapInteger(value: string | number | null | undefined): number | null { + if (value === null || value === undefined || value === '') return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} +``` + +- [ ] **Step 4: Write account mapper tests** + +Create `apps/backend/src/modules/tbank/mappers/account.mapper.spec.ts`: + +```typescript +import { isSupportedBrokerAccount, mapAccount } from './account.mapper'; +import type { TBankAccount } from '../types/tbank-proto.types'; + +describe('account.mapper', () => { + const baseAccount: TBankAccount = { + id: '2000000001', + type: 'ACCOUNT_TYPE_TINKOFF', + name: 'Broker', + status: 'ACCOUNT_STATUS_OPEN', + openedDate: { seconds: '1781577000' }, + accessLevel: 'ACCOUNT_ACCESS_LEVEL_FULL_ACCESS', + }; + + it('accepts open brokerage and IIS accounts', () => { + expect(isSupportedBrokerAccount(baseAccount)).toBe(true); + expect(isSupportedBrokerAccount({ ...baseAccount, type: 'ACCOUNT_TYPE_TINKOFF_IIS' })).toBe( + true, + ); + }); + + it('rejects invest box, closed, and unspecified accounts', () => { + expect(isSupportedBrokerAccount({ ...baseAccount, type: 'ACCOUNT_TYPE_INVEST_BOX' })).toBe( + false, + ); + expect(isSupportedBrokerAccount({ ...baseAccount, status: 'ACCOUNT_STATUS_CLOSED' })).toBe( + false, + ); + expect(isSupportedBrokerAccount({ ...baseAccount, type: 'ACCOUNT_TYPE_UNSPECIFIED' })).toBe( + false, + ); + }); + + it('maps T-Bank account to broker account DTO', () => { + expect(mapAccount(baseAccount)).toEqual({ + id: '2000000001', + type: 'brokerage', + name: 'Broker', + status: 'ACCOUNT_STATUS_OPEN', + openedAt: '2026-06-16T02:30:00.000Z', + accessLevel: 'ACCOUNT_ACCESS_LEVEL_FULL_ACCESS', + }); + }); +}); +``` + +- [ ] **Step 5: Implement account mapper** + +Create `apps/backend/src/modules/tbank/mappers/account.mapper.ts`: + +```typescript +import { + TBANK_ACCOUNT_TYPES, + TBANK_OPEN_ACCOUNT_STATUS, +} from '../tbank.config'; +import type { BrokerAccount } from '../types/broker.types'; +import type { TBankAccount } from '../types/tbank-proto.types'; +import { mapTimestampToIso } from './money.mapper'; + +export function isSupportedBrokerAccount(account: TBankAccount): boolean { + return ( + account.status === TBANK_OPEN_ACCOUNT_STATUS && + (account.type === TBANK_ACCOUNT_TYPES.brokerage || account.type === TBANK_ACCOUNT_TYPES.iis) + ); +} + +export function mapAccount(account: TBankAccount): BrokerAccount { + return { + id: account.id, + type: account.type === TBANK_ACCOUNT_TYPES.iis ? 'iis' : 'brokerage', + name: account.name || account.id, + status: account.status, + openedAt: mapTimestampToIso(account.openedDate), + accessLevel: account.accessLevel ?? null, + }; +} +``` + +- [ ] **Step 6: Write operation mapper tests** + +Create `apps/backend/src/modules/tbank/mappers/operation.mapper.spec.ts`: + +```typescript +import { categorizeOperationType, mapOperation, mapOperationsPage } from './operation.mapper'; + +describe('operation.mapper', () => { + it.each([ + ['OPERATION_TYPE_BUY', 'trade'], + ['OPERATION_TYPE_SELL', 'trade'], + ['OPERATION_TYPE_DIVIDEND', 'income'], + ['OPERATION_TYPE_COUPON', 'income'], + ['OPERATION_TYPE_TAX', 'tax'], + ['OPERATION_TYPE_DIVIDEND_TAX', 'tax'], + ['OPERATION_TYPE_BROKER_FEE', 'fee'], + ['OPERATION_TYPE_SERVICE_FEE', 'fee'], + ['OPERATION_TYPE_INPUT', 'transfer'], + ['OPERATION_TYPE_OUTPUT', 'transfer'], + ['OPERATION_TYPE_UNRECOGNIZED_NEW_VALUE', 'other'], + ])('maps %s to %s', (type, category) => { + expect(categorizeOperationType(type)).toBe(category); + }); + + it('maps operation item with money and quantities', () => { + const result = mapOperation( + { + cursor: 'cursor-1', + brokerAccountId: 'acc-1', + id: 'op-1', + date: { seconds: '1781577000' }, + type: 'OPERATION_TYPE_COUPON', + description: 'Coupon', + state: 'OPERATION_STATE_EXECUTED', + ticker: 'SU26238RMFS5', + classCode: 'TQOB', + payment: { currency: 'rub', units: '100', nano: 0 }, + commission: { currency: 'rub', units: '0', nano: 0 }, + quantity: '5', + quantityDone: '5', + }, + 'acc-1', + ); + + expect(result).toMatchObject({ + cursor: 'cursor-1', + accountId: 'acc-1', + id: 'op-1', + category: 'income', + ticker: 'SU26238RMFS5', + quantity: 5, + quantityDone: 5, + payment: { currency: 'RUB', value: 100 }, + }); + }); + + it('maps operations page cursor metadata', () => { + const page = mapOperationsPage('acc-1', { + hasNext: true, + nextCursor: 'next', + items: [{ cursor: 'cursor-1', type: 'OPERATION_TYPE_BUY' }], + }); + + expect(page.accountId).toBe('acc-1'); + expect(page.hasNext).toBe(true); + expect(page.nextCursor).toBe('next'); + expect(page.items).toHaveLength(1); + }); +}); +``` + +- [ ] **Step 7: Implement operation mapper** + +Create `apps/backend/src/modules/tbank/mappers/operation.mapper.ts`: + +```typescript +import type { + BrokerOperation, + BrokerOperationCategory, + BrokerOperationsPage, +} from '../types/broker.types'; +import type { + TBankOperationItem, + TBankOperationsByCursorResponse, +} from '../types/tbank-proto.types'; +import { mapInteger, mapMoneyValue, mapTimestampToIso } from './money.mapper'; + +const TRADE_TYPES = new Set([ + 'OPERATION_TYPE_BUY', + 'OPERATION_TYPE_BUY_CARD', + 'OPERATION_TYPE_SELL', + 'OPERATION_TYPE_SELL_CARD', + 'OPERATION_TYPE_BUY_MARGIN', + 'OPERATION_TYPE_SELL_MARGIN', + 'OPERATION_TYPE_DELIVERY_BUY', + 'OPERATION_TYPE_DELIVERY_SELL', +]); + +const INCOME_TYPES = new Set([ + 'OPERATION_TYPE_DIVIDEND', + 'OPERATION_TYPE_COUPON', + 'OPERATION_TYPE_BOND_REPAYMENT', + 'OPERATION_TYPE_BOND_REPAYMENT_FULL', + 'OPERATION_TYPE_OVERNIGHT', + 'OPERATION_TYPE_OVER_INCOME', + 'OPERATION_TYPE_ACCRUING_VARMARGIN', + 'OPERATION_TYPE_TAX_REPO_REFUND', + 'OPERATION_TYPE_TAX_REPO_REFUND_PROGRESSIVE', + 'OPERATION_TYPE_DIV_EXT', + 'OPERATION_TYPE_DFA_REDEMPTION', +]); + +const TAX_TYPES = new Set([ + 'OPERATION_TYPE_TAX', + 'OPERATION_TYPE_BOND_TAX', + 'OPERATION_TYPE_DIVIDEND_TAX', + 'OPERATION_TYPE_TAX_CORRECTION', + 'OPERATION_TYPE_BENEFIT_TAX', + 'OPERATION_TYPE_TAX_PROGRESSIVE', + 'OPERATION_TYPE_BOND_TAX_PROGRESSIVE', + 'OPERATION_TYPE_DIVIDEND_TAX_PROGRESSIVE', + 'OPERATION_TYPE_BENEFIT_TAX_PROGRESSIVE', + 'OPERATION_TYPE_TAX_CORRECTION_PROGRESSIVE', + 'OPERATION_TYPE_TAX_REPO', + 'OPERATION_TYPE_TAX_REPO_PROGRESSIVE', + 'OPERATION_TYPE_TAX_REPO_HOLD', + 'OPERATION_TYPE_TAX_REPO_HOLD_PROGRESSIVE', + 'OPERATION_TYPE_TAX_CORRECTION_COUPON', +]); + +const FEE_TYPES = new Set([ + 'OPERATION_TYPE_SERVICE_FEE', + 'OPERATION_TYPE_MARGIN_FEE', + 'OPERATION_TYPE_BROKER_FEE', + 'OPERATION_TYPE_SUCCESS_FEE', + 'OPERATION_TYPE_TRACK_MFEE', + 'OPERATION_TYPE_TRACK_PFEE', + 'OPERATION_TYPE_CASH_FEE', + 'OPERATION_TYPE_OUT_FEE', + 'OPERATION_TYPE_OUT_STAMP_DUTY', + 'OPERATION_TYPE_OUTPUT_PENALTY', + 'OPERATION_TYPE_ADVICE_FEE', + 'OPERATION_TYPE_OVER_COM', + 'OPERATION_TYPE_OTHER_FEE', + 'OPERATION_TYPE_FUNDING', +]); + +const TRANSFER_TYPES = new Set([ + 'OPERATION_TYPE_INPUT', + 'OPERATION_TYPE_OUTPUT', + 'OPERATION_TYPE_INPUT_SECURITIES', + 'OPERATION_TYPE_OUTPUT_SECURITIES', + 'OPERATION_TYPE_OUTPUT_SWIFT', + 'OPERATION_TYPE_INPUT_SWIFT', + 'OPERATION_TYPE_OUTPUT_ACQUIRING', + 'OPERATION_TYPE_INPUT_ACQUIRING', + 'OPERATION_TYPE_TRANS_IIS_BS', + 'OPERATION_TYPE_TRANS_BS_BS', + 'OPERATION_TYPE_OUT_MULTI', + 'OPERATION_TYPE_INP_MULTI', + 'OPERATION_TYPE_OVER_PLACEMENT', +]); + +export function categorizeOperationType(type: string | null | undefined): BrokerOperationCategory { + if (!type) return 'other'; + if (TRADE_TYPES.has(type)) return 'trade'; + if (INCOME_TYPES.has(type)) return 'income'; + if (TAX_TYPES.has(type)) return 'tax'; + if (FEE_TYPES.has(type)) return 'fee'; + if (TRANSFER_TYPES.has(type)) return 'transfer'; + return 'other'; +} + +export function mapOperation(item: TBankOperationItem, accountId: string): BrokerOperation { + const type = item.type || 'OPERATION_TYPE_UNSPECIFIED'; + + return { + cursor: item.cursor ?? null, + accountId: item.brokerAccountId || accountId, + id: item.id ?? null, + parentOperationId: item.parentOperationId ?? null, + date: mapTimestampToIso(item.date), + type, + category: categorizeOperationType(type), + description: item.description || item.name || null, + state: item.state ?? null, + instrumentUid: item.instrumentUid ?? null, + figi: item.figi ?? null, + ticker: item.ticker ?? null, + classCode: item.classCode ?? null, + instrumentType: item.instrumentType ?? null, + payment: mapMoneyValue(item.payment), + price: mapMoneyValue(item.price), + commission: mapMoneyValue(item.commission), + yield: mapMoneyValue(item.yield), + accruedInt: mapMoneyValue(item.accruedInt), + quantity: mapInteger(item.quantity), + quantityDone: mapInteger(item.quantityDone), + }; +} + +export function mapOperationsPage( + accountId: string, + response: TBankOperationsByCursorResponse, +): BrokerOperationsPage { + return { + accountId, + items: (response.items ?? []).map((item) => mapOperation(item, accountId)), + nextCursor: response.nextCursor || null, + hasNext: response.hasNext ?? false, + asOf: new Date().toISOString(), + }; +} +``` + +- [ ] **Step 8: Run mapper tests and commit** + +Run: + +```bash +npx vitest run "src/modules/tbank/mappers/*.spec.ts" -w apps/backend +``` + +Expected: PASS. + +Commit: + +```bash +git add apps/backend/src/modules/tbank/mappers +git commit -m "feat: add tbank domain mappers" +``` + +--- + +## Task 4: Implement TBankClientService gRPC Transport + +**Files:** +- Create: `apps/backend/src/modules/tbank/services/tbank-client.service.ts` +- Create: `apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts` +- Create: `apps/backend/src/modules/tbank/tbank.module.ts` + +- [ ] **Step 1: Write client service tests** + +Create `apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts`: + +```typescript +import { ServiceUnavailableException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Metadata, status } from '@grpc/grpc-js'; +import { TBankClientService } from './tbank-client.service'; + +describe('TBankClientService', () => { + const config = { + get: vi.fn((key: string, fallback?: unknown) => { + const values: Record = { + 'app.tbank.token': 'token-1', + 'app.tbank.appName': 'ksv741.moex-vibe', + 'app.tbank.rateLimitPerSecond': 5, + 'app.tbank.requestTimeoutMs': 10000, + }; + return values[key] ?? fallback; + }), + } as unknown as ConfigService; + + it('builds redacted authorization metadata', () => { + const service = new TBankClientService(config); + const metadata = service.createMetadata(); + + expect(metadata.get('Authorization')).toEqual(['Bearer token-1']); + expect(metadata.get('x-app-name')).toEqual(['ksv741.moex-vibe']); + expect(service.redactMetadata(metadata)).toEqual({ + Authorization: '', + 'x-app-name': 'ksv741.moex-vibe', + }); + }); + + it('throws integration unavailable when token is missing', async () => { + const missingConfig = { + get: vi.fn((key: string, fallback?: unknown) => + key === 'app.tbank.token' ? '' : (fallback as unknown), + ), + } as unknown as ConfigService; + const service = new TBankClientService(missingConfig); + + await expect( + service.callUnary('UsersService/GetAccounts', (_request, _metadata, _options, callback) => { + callback(null, {}); + }, {}), + ).rejects.toThrow(ServiceUnavailableException); + }); + + it('wraps grpc errors with status code and tracking id', async () => { + const service = new TBankClientService(config); + const error = Object.assign(new Error('Too many requests'), { + code: status.RESOURCE_EXHAUSTED, + metadata: new Metadata(), + }); + error.metadata.set('x-tracking-id', 'tracking-1'); + + await expect( + service.callUnary('OperationsService/GetPortfolio', (_request, _metadata, _options, cb) => { + cb(error, null); + }, {}), + ).rejects.toMatchObject({ + response: expect.objectContaining({ + message: expect.stringContaining('T-Bank upstream error'), + }), + }); + }); +}); +``` + +- [ ] **Step 2: Run failing client service test** + +Run: + +```bash +npx vitest run src/modules/tbank/services/tbank-client.service.spec.ts -w apps/backend +``` + +Expected: FAIL because `TBankClientService` does not exist. + +- [ ] **Step 3: Implement TBankClientService** + +Create `apps/backend/src/modules/tbank/services/tbank-client.service.ts`: + +```typescript +import { Injectable, ServiceUnavailableException, BadGatewayException, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + CallOptions, + ChannelCredentials, + Client, + ClientUnaryCall, + loadPackageDefinition, + Metadata, + ServiceError, + status, +} from '@grpc/grpc-js'; +import { loadSync } from '@grpc/proto-loader'; +import PQueue from 'p-queue'; +import { join } from 'path'; +import { + TBANK_PROTO_FILES, + TBANK_PROTO_PACKAGE, +} from '../tbank.config'; + +type GrpcUnary = ( + request: TRequest, + metadata: Metadata, + options: CallOptions, + callback: (error: ServiceError | null, response: TResponse | null) => void, +) => ClientUnaryCall; + +@Injectable() +export class TBankClientService { + private readonly logger = new Logger(TBankClientService.name); + private readonly queue: PQueue; + private readonly requestTimeoutMs: number; + private readonly packageDefinition: ReturnType; + private readonly clientCache = new Map(); + + constructor(private readonly configService: ConfigService) { + this.requestTimeoutMs = this.configService.get('app.tbank.requestTimeoutMs', 10000); + this.queue = new PQueue({ + interval: 1000, + intervalCap: this.configService.get('app.tbank.rateLimitPerSecond', 5), + }); + + const protoRoot = join(__dirname, '..', 'proto', 'contracts'); + const definition = loadSync(Object.values(TBANK_PROTO_FILES), { + includeDirs: [protoRoot], + keepCase: false, + longs: String, + enums: String, + defaults: true, + oneofs: true, + }); + this.packageDefinition = loadPackageDefinition(definition); + } + + createMetadata(): Metadata { + const token = this.configService.get('app.tbank.token', ''); + if (!token) { + throw new ServiceUnavailableException('T-Bank integration is not configured'); + } + + const metadata = new Metadata(); + metadata.set('Authorization', `Bearer ${token}`); + const appName = this.configService.get('app.tbank.appName', ''); + if (appName) metadata.set('x-app-name', appName); + return metadata; + } + + redactMetadata(metadata: Metadata): Record { + const result: Record = {}; + for (const key of Object.keys(metadata.getMap())) { + result[key] = key.toLowerCase() === 'authorization' ? '' : String(metadata.get(key)[0]); + } + return result; + } + + getServiceClient(serviceName: 'UsersService' | 'OperationsService' | 'InstrumentsService'): Client { + const cached = this.clientCache.get(serviceName); + if (cached) return cached; + + const pkg = this.packageDefinition as Record; + const namespace = TBANK_PROTO_PACKAGE.split('.').reduce>( + (current, part) => current[part] as Record, + pkg, + ); + const ServiceCtor = namespace[serviceName] as new (address: string, creds: ChannelCredentials) => Client; + const client = new ServiceCtor( + this.configService.get('app.tbank.baseUrl', 'invest-public-api.tbank.ru:443'), + ChannelCredentials.createSsl(), + ); + this.clientCache.set(serviceName, client); + return client; + } + + async callUnary( + label: string, + method: GrpcUnary, + request: TRequest, + ): Promise { + const metadata = this.createMetadata(); + const deadline = new Date(Date.now() + this.requestTimeoutMs); + + return this.queue.add( + () => + new Promise((resolve, reject) => { + method(request, metadata, { deadline }, (error, response) => { + if (error) { + reject(this.mapGrpcError(label, error)); + return; + } + resolve(response as TResponse); + }); + }), + ) as Promise; + } + + private mapGrpcError(label: string, error: ServiceError): Error { + const trackingId = error.metadata?.get('x-tracking-id')?.[0]; + const retryAfter = error.metadata?.get('x-ratelimit-reset')?.[0]; + const publicMessage = + error.code === status.RESOURCE_EXHAUSTED + ? 'T-Bank rate limit exceeded' + : `T-Bank upstream error while calling ${label}`; + + this.logger.warn( + JSON.stringify({ + label, + code: error.code, + trackingId, + retryAfter, + message: error.message, + }), + ); + + return new BadGatewayException({ + message: publicMessage, + trackingId: trackingId ? String(trackingId) : null, + retryAfter: retryAfter ? String(retryAfter) : null, + }); + } +} +``` + +- [ ] **Step 4: Create TBankModule** + +Create `apps/backend/src/modules/tbank/tbank.module.ts`: + +```typescript +import { Module } from '@nestjs/common'; +import { TBankClientService } from './services/tbank-client.service'; + +@Module({ + providers: [TBankClientService], + exports: [TBankClientService], +}) +export class TBankModule {} +``` + +- [ ] **Step 5: Run client service tests and commit** + +Run: + +```bash +npx vitest run src/modules/tbank/services/tbank-client.service.spec.ts -w apps/backend +``` + +Expected: PASS. + +Commit: + +```bash +git add apps/backend/src/modules/tbank/services/tbank-client.service.ts apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts apps/backend/src/modules/tbank/tbank.module.ts +git commit -m "feat: add tbank grpc client service" +``` + +--- + +## Task 5: Implement Broker Accounts Endpoint + +**Files:** +- Create: `apps/backend/src/modules/tbank/services/broker-accounts.service.ts` +- Create: `apps/backend/src/modules/tbank/services/broker-accounts.service.spec.ts` +- Create: `apps/backend/src/modules/tbank/dto/broker-account-response.dto.ts` +- Create: `apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts` +- Modify: `apps/backend/src/modules/tbank/tbank.controller.ts` +- Modify: `apps/backend/src/modules/tbank/tbank.module.ts` +- Modify: `apps/backend/src/app.module.ts` + +- [ ] **Step 1: Write accounts service test** + +Create `apps/backend/src/modules/tbank/services/broker-accounts.service.spec.ts`: + +```typescript +import { BrokerAccountsService } from './broker-accounts.service'; +import { TBankClientService } from './tbank-client.service'; +import { CacheService } from '../../cache/cache.service'; + +describe('BrokerAccountsService', () => { + const client = { + getServiceClient: vi.fn(), + callUnary: vi.fn(), + } as unknown as TBankClientService; + const cache = { + getOrFetch: vi.fn(), + } as unknown as CacheService; + + beforeEach(() => vi.clearAllMocks()); + + it('returns only open brokerage and IIS accounts from cache wrapper', async () => { + vi.mocked(cache.getOrFetch).mockImplementation( + async (_prefix: string, _parts: string[], fetchFn: () => Promise) => ({ + data: await fetchFn(), + fromCache: false, + cachedAt: '2026-06-16T02:30:00.000Z', + }), + ); + vi.mocked(client.getServiceClient).mockReturnValue({ getAccounts: vi.fn() } as any); + vi.mocked(client.callUnary).mockResolvedValue({ + accounts: [ + { id: '1', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Broker', status: 'ACCOUNT_STATUS_OPEN' }, + { id: '2', type: 'ACCOUNT_TYPE_TINKOFF_IIS', name: 'IIS', status: 'ACCOUNT_STATUS_OPEN' }, + { id: '3', type: 'ACCOUNT_TYPE_INVEST_BOX', name: 'Box', status: 'ACCOUNT_STATUS_OPEN' }, + { id: '4', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Closed', status: 'ACCOUNT_STATUS_CLOSED' }, + ], + }); + + const service = new BrokerAccountsService(client, cache); + const result = await service.findAll(); + + expect(result.data).toHaveLength(2); + expect(result.data.map((account) => account.type)).toEqual(['brokerage', 'iis']); + expect(result.meta.fromCache).toBe(false); + expect(cache.getOrFetch).toHaveBeenCalledWith( + 'tbank:accounts', + ['open-brokerage-iis'], + expect.any(Function), + 'tbankAccountsTtl', + ); + }); +}); +``` + +- [ ] **Step 2: Run failing accounts service test** + +Run: + +```bash +npx vitest run src/modules/tbank/services/broker-accounts.service.spec.ts -w apps/backend +``` + +Expected: FAIL because `BrokerAccountsService` does not exist. + +- [ ] **Step 3: Implement accounts service** + +Create `apps/backend/src/modules/tbank/services/broker-accounts.service.ts`: + +```typescript +import { Injectable } from '@nestjs/common'; +import { CacheService } from '../../cache/cache.service'; +import { TBANK_CACHE_KEYS } from '../tbank.config'; +import { isSupportedBrokerAccount, mapAccount } from '../mappers/account.mapper'; +import type { BrokerAccount } from '../types/broker.types'; +import type { TBankAccountsResponse } from '../types/tbank-proto.types'; +import { TBankClientService } from './tbank-client.service'; + +@Injectable() +export class BrokerAccountsService { + constructor( + private readonly tbankClient: TBankClientService, + private readonly cacheService: CacheService, + ) {} + + async findAll(): Promise<{ + data: BrokerAccount[]; + meta: { fromCache: boolean; cachedAt: string | null }; + }> { + const result = await this.cacheService.getOrFetch( + TBANK_CACHE_KEYS.accounts, + ['open-brokerage-iis'], + () => this.fetchAccounts(), + 'tbankAccountsTtl', + ); + + return { data: result.data, meta: { fromCache: result.fromCache, cachedAt: result.cachedAt } }; + } + + async findById(accountId: string): Promise { + const accounts = await this.findAll(); + return accounts.data.find((account) => account.id === accountId) ?? null; + } + + private async fetchAccounts(): Promise { + const usersClient = this.tbankClient.getServiceClient('UsersService') as any; + const response = await this.tbankClient.callUnary, TBankAccountsResponse>( + 'UsersService/GetAccounts', + usersClient.getAccounts.bind(usersClient), + { status: 'ACCOUNT_STATUS_OPEN' }, + ); + + return (response.accounts ?? []).filter(isSupportedBrokerAccount).map(mapAccount); + } +} +``` + +- [ ] **Step 4: Add account DTOs and envelope DTO** + +Create `apps/backend/src/modules/tbank/dto/broker-account-response.dto.ts`: + +```typescript +import { ApiProperty } from '@nestjs/swagger'; + +export class BrokerAccountResponseDto { + @ApiProperty() + id!: string; + + @ApiProperty({ enum: ['brokerage', 'iis'] }) + type!: 'brokerage' | 'iis'; + + @ApiProperty() + name!: string; + + @ApiProperty() + status!: string; + + @ApiProperty({ nullable: true }) + openedAt!: string | null; + + @ApiProperty({ nullable: true }) + accessLevel!: string | null; +} +``` + +Create `apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts`: + +```typescript +import { ApiProperty } from '@nestjs/swagger'; +import { BrokerAccountResponseDto } from './broker-account-response.dto'; + +export class BrokerResponseMetaDto { + @ApiProperty({ nullable: true }) + cachedAt!: string | null; + + @ApiProperty() + fromCache!: boolean; +} + +export class BrokerAccountsEnvelopeDto { + @ApiProperty({ type: [BrokerAccountResponseDto] }) + data!: BrokerAccountResponseDto[]; + + @ApiProperty({ type: BrokerResponseMetaDto }) + meta!: BrokerResponseMetaDto; +} +``` + +- [ ] **Step 5: Add controller and module wiring** + +Create `apps/backend/src/modules/tbank/tbank.controller.ts`: + +```typescript +import { Controller, Get } from '@nestjs/common'; +import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { BrokerAccountsService } from './services/broker-accounts.service'; +import { BrokerAccountsEnvelopeDto } from './dto/broker-envelope.dto'; + +@ApiTags('Broker') +@ApiBearerAuth() +@Controller('broker') +export class TBankController { + constructor(private readonly brokerAccountsService: BrokerAccountsService) {} + + @Get('accounts') + @ApiOperation({ summary: 'Get open T-Bank brokerage and IIS accounts' }) + @ApiOkResponse({ type: BrokerAccountsEnvelopeDto }) + async getAccounts() { + return this.brokerAccountsService.findAll(); + } +} +``` + +Modify `apps/backend/src/modules/tbank/tbank.module.ts`: + +```typescript +import { Module } from '@nestjs/common'; +import { TBankController } from './tbank.controller'; +import { BrokerAccountsService } from './services/broker-accounts.service'; +import { TBankClientService } from './services/tbank-client.service'; + +@Module({ + controllers: [TBankController], + providers: [TBankClientService, BrokerAccountsService], + exports: [TBankClientService, BrokerAccountsService], +}) +export class TBankModule {} +``` + +Modify `apps/backend/src/app.module.ts`: + +```typescript +import { TBankModule } from './modules/tbank/tbank.module'; +``` + +and add `TBankModule` after `PortfolioModule` in the `imports` array. + +- [ ] **Step 6: Run accounts tests and backend build** + +Run: + +```bash +npx vitest run src/modules/tbank/services/broker-accounts.service.spec.ts src/modules/tbank/mappers/account.mapper.spec.ts -w apps/backend +npm run build:backend +``` + +Expected: tests PASS and backend build exits 0. + +- [ ] **Step 7: Commit accounts endpoint** + +```bash +git add apps/backend/src/app.module.ts apps/backend/src/modules/tbank +git commit -m "feat: expose tbank broker accounts" +``` + +--- + +## Task 6: Implement Broker Portfolio Endpoint + +**Files:** +- Create: `apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts` +- Create: `apps/backend/src/modules/tbank/mappers/portfolio.mapper.spec.ts` +- Create: `apps/backend/src/modules/tbank/services/broker-instruments.service.ts` +- Create: `apps/backend/src/modules/tbank/services/broker-portfolio.service.ts` +- Create: `apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts` +- Create: `apps/backend/src/modules/tbank/dto/broker-money.dto.ts` +- Create: `apps/backend/src/modules/tbank/dto/broker-portfolio-response.dto.ts` +- Modify: `apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts` +- Modify: `apps/backend/src/modules/tbank/tbank.controller.ts` +- Modify: `apps/backend/src/modules/tbank/tbank.module.ts` + +- [ ] **Step 1: Write portfolio mapper tests** + +Create `apps/backend/src/modules/tbank/mappers/portfolio.mapper.spec.ts`: + +```typescript +import { mapBrokerPortfolio } from './portfolio.mapper'; +import type { BrokerAccount } from '../types/broker.types'; + +describe('portfolio.mapper', () => { + const account: BrokerAccount = { + id: 'acc-1', + type: 'brokerage', + name: 'Broker', + status: 'ACCOUNT_STATUS_OPEN', + openedAt: null, + accessLevel: 'ACCOUNT_ACCESS_LEVEL_FULL_ACCESS', + }; + + it('combines portfolio totals, cash, and enriched positions', () => { + const result = mapBrokerPortfolio({ + account, + portfolio: { + accountId: 'acc-1', + totalAmountShares: { currency: 'rub', units: '1000', nano: 0 }, + totalAmountPortfolio: { currency: 'rub', units: '1500', nano: 0 }, + expectedYield: { units: '10', nano: 500000000 }, + positions: [ + { + figi: 'BBG004730N88', + instrumentUid: 'uid-1', + ticker: 'SBER', + classCode: 'TQBR', + instrumentType: 'share', + quantity: { units: '10', nano: 0 }, + currentPrice: { currency: 'rub', units: '250', nano: 0 }, + averagePositionPrice: { currency: 'rub', units: '200', nano: 0 }, + }, + ], + }, + positions: { + money: [{ currency: 'rub', units: '500', nano: 0 }], + blocked: [{ currency: 'rub', units: '10', nano: 0 }], + securities: [], + }, + instruments: new Map([['uid-1', { name: 'Sberbank', ticker: 'SBER' }]]), + }); + + expect(result.account.id).toBe('acc-1'); + expect(result.totals.shares?.value).toBe(1000); + expect(result.cash[0].value).toBe(500); + expect(result.blockedCash[0].value).toBe(10); + expect(result.positions[0]).toMatchObject({ + ticker: 'SBER', + name: 'Sberbank', + quantity: 10, + currentValue: { value: 2500 }, + }); + }); +}); +``` + +- [ ] **Step 2: Implement portfolio mapper** + +Create `apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts`: + +```typescript +import type { BrokerAccount, BrokerPortfolio, BrokerPosition } from '../types/broker.types'; +import type { + TBankInstrument, + TBankPortfolioResponse, + TBankPositionsResponse, +} from '../types/tbank-proto.types'; +import { mapMoneyValue, mapQuotationToNumber } from './money.mapper'; + +type MapBrokerPortfolioInput = { + account: BrokerAccount; + portfolio: TBankPortfolioResponse; + positions: TBankPositionsResponse; + instruments: Map>; +}; + +export function mapBrokerPortfolio(input: MapBrokerPortfolioInput): BrokerPortfolio { + const mappedPositions = (input.portfolio.positions ?? []).map((position) => { + const quantity = mapQuotationToNumber(position.quantity); + const currentPrice = mapMoneyValue(position.currentPrice); + const currentValue = + currentPrice && quantity !== null + ? { + ...currentPrice, + units: String(Math.trunc(currentPrice.value * quantity)), + nano: 0, + value: Number((currentPrice.value * quantity).toFixed(9)), + } + : null; + const instrument = + (position.instrumentUid && input.instruments.get(position.instrumentUid)) || + (position.positionUid && input.instruments.get(position.positionUid)) || + undefined; + + return { + figi: position.figi ?? null, + instrumentUid: position.instrumentUid ?? null, + positionUid: position.positionUid ?? null, + ticker: position.ticker || instrument?.ticker || null, + classCode: position.classCode || instrument?.classCode || null, + instrumentType: position.instrumentType || instrument?.instrumentType || null, + name: instrument?.name ?? null, + quantity, + blockedLots: mapQuotationToNumber(position.blockedLots), + currentPrice, + currentValue, + averagePositionPrice: mapMoneyValue(position.averagePositionPrice), + expectedYieldPercent: mapQuotationToNumber(position.expectedYield), + dailyYield: mapMoneyValue(position.dailyYield), + }; + }); + + return { + account: input.account, + totals: { + shares: mapMoneyValue(input.portfolio.totalAmountShares), + bonds: mapMoneyValue(input.portfolio.totalAmountBonds), + etf: mapMoneyValue(input.portfolio.totalAmountEtf), + currencies: mapMoneyValue(input.portfolio.totalAmountCurrencies), + futures: mapMoneyValue(input.portfolio.totalAmountFutures), + options: mapMoneyValue(input.portfolio.totalAmountOptions), + structuredProducts: mapMoneyValue(input.portfolio.totalAmountSp), + dfa: mapMoneyValue(input.portfolio.totalAmountDfa), + portfolio: mapMoneyValue(input.portfolio.totalAmountPortfolio), + }, + yields: { + expectedPercent: mapQuotationToNumber(input.portfolio.expectedYield), + daily: mapMoneyValue(input.portfolio.dailyYield), + dailyPercent: mapQuotationToNumber(input.portfolio.dailyYieldRelative), + }, + cash: (input.positions.money ?? []).map(mapMoneyValue).filter((value) => value !== null), + blockedCash: (input.positions.blocked ?? []).map(mapMoneyValue).filter((value) => value !== null), + positions: mappedPositions, + asOf: new Date().toISOString(), + }; +} +``` + +- [ ] **Step 3: Write portfolio service test** + +Create `apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts`: + +```typescript +import { NotFoundException } from '@nestjs/common'; +import { BrokerPortfolioService } from './broker-portfolio.service'; +import { BrokerAccountsService } from './broker-accounts.service'; +import { BrokerInstrumentsService } from './broker-instruments.service'; +import { TBankClientService } from './tbank-client.service'; +import { CacheService } from '../../cache/cache.service'; + +describe('BrokerPortfolioService', () => { + const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService; + const instruments = { findByInstrumentUid: vi.fn() } as unknown as BrokerInstrumentsService; + const client = { getServiceClient: vi.fn(), callUnary: vi.fn() } as unknown as TBankClientService; + const cache = { getOrFetch: vi.fn() } as unknown as CacheService; + + beforeEach(() => vi.clearAllMocks()); + + it('throws 404 for excluded or missing account', async () => { + vi.mocked(accounts.findById).mockResolvedValue(null); + const service = new BrokerPortfolioService(accounts, instruments, client, cache); + + await expect(service.getPortfolio('missing')).rejects.toThrow(NotFoundException); + }); + + it('fetches portfolio and positions through cache', async () => { + vi.mocked(accounts.findById).mockResolvedValue({ + id: 'acc-1', + type: 'brokerage', + name: 'Broker', + status: 'ACCOUNT_STATUS_OPEN', + openedAt: null, + accessLevel: null, + }); + vi.mocked(cache.getOrFetch).mockImplementation( + async (_prefix: string, _parts: string[], fetchFn: () => Promise) => ({ + data: await fetchFn(), + fromCache: false, + cachedAt: null, + }), + ); + vi.mocked(client.getServiceClient).mockReturnValue({ + getPortfolio: vi.fn(), + getPositions: vi.fn(), + } as any); + vi.mocked(client.callUnary) + .mockResolvedValueOnce({ + accountId: 'acc-1', + totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 }, + positions: [], + }) + .mockResolvedValueOnce({ + accountId: 'acc-1', + money: [{ currency: 'rub', units: '1000', nano: 0 }], + blocked: [], + securities: [], + }); + + const service = new BrokerPortfolioService(accounts, instruments, client, cache); + const result = await service.getPortfolio('acc-1'); + + expect(result.data.account.id).toBe('acc-1'); + expect(result.data.cash[0].value).toBe(1000); + expect(cache.getOrFetch).toHaveBeenCalledWith( + 'tbank:portfolio', + ['acc-1'], + expect.any(Function), + 'tbankPortfolioTtl', + ); + }); +}); +``` + +- [ ] **Step 4: Implement instrument and portfolio services** + +Create `apps/backend/src/modules/tbank/services/broker-instruments.service.ts`: + +```typescript +import { Injectable } from '@nestjs/common'; +import { CacheService } from '../../cache/cache.service'; +import { TBANK_CACHE_KEYS } from '../tbank.config'; +import type { TBankInstrument, TBankInstrumentResponse } from '../types/tbank-proto.types'; +import { TBankClientService } from './tbank-client.service'; + +@Injectable() +export class BrokerInstrumentsService { + constructor( + private readonly tbankClient: TBankClientService, + private readonly cacheService: CacheService, + ) {} + + async findByInstrumentUid(instrumentUid: string): Promise { + const result = await this.cacheService.getOrFetch( + TBANK_CACHE_KEYS.instrument, + [instrumentUid], + () => this.fetchByUid(instrumentUid), + 'tbankInstrumentTtl', + ); + return result.data; + } + + private async fetchByUid(instrumentUid: string): Promise { + const instrumentsClient = this.tbankClient.getServiceClient('InstrumentsService') as any; + const response = await this.tbankClient.callUnary< + { idType: string; id: string }, + TBankInstrumentResponse + >( + 'InstrumentsService/GetInstrumentBy', + instrumentsClient.getInstrumentBy.bind(instrumentsClient), + { idType: 'INSTRUMENT_ID_TYPE_UID', id: instrumentUid }, + ); + return response.instrument ?? null; + } +} +``` + +Create `apps/backend/src/modules/tbank/services/broker-portfolio.service.ts`: + +```typescript +import { Injectable, NotFoundException } from '@nestjs/common'; +import { CacheService } from '../../cache/cache.service'; +import { TBANK_CACHE_KEYS } from '../tbank.config'; +import { mapBrokerPortfolio } from '../mappers/portfolio.mapper'; +import type { BrokerPortfolio } from '../types/broker.types'; +import type { + TBankInstrument, + TBankPortfolioResponse, + TBankPositionsResponse, +} from '../types/tbank-proto.types'; +import { BrokerAccountsService } from './broker-accounts.service'; +import { BrokerInstrumentsService } from './broker-instruments.service'; +import { TBankClientService } from './tbank-client.service'; + +@Injectable() +export class BrokerPortfolioService { + constructor( + private readonly accountsService: BrokerAccountsService, + private readonly instrumentsService: BrokerInstrumentsService, + private readonly tbankClient: TBankClientService, + private readonly cacheService: CacheService, + ) {} + + async getPortfolio(accountId: string): Promise<{ + data: BrokerPortfolio; + meta: { fromCache: boolean; cachedAt: string | null }; + }> { + const account = await this.accountsService.findById(accountId); + if (!account) throw new NotFoundException('Broker account not found'); + + const result = await this.cacheService.getOrFetch( + TBANK_CACHE_KEYS.portfolio, + [accountId], + async () => { + const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any; + const [portfolio, positions] = await Promise.all([ + this.tbankClient.callUnary<{ accountId: string; currency: string }, TBankPortfolioResponse>( + 'OperationsService/GetPortfolio', + operationsClient.getPortfolio.bind(operationsClient), + { accountId, currency: 'RUB' }, + ), + this.tbankClient.callUnary<{ accountId: string }, TBankPositionsResponse>( + 'OperationsService/GetPositions', + operationsClient.getPositions.bind(operationsClient), + { accountId }, + ), + ]); + + const instrumentMap = await this.buildInstrumentMap(portfolio); + return mapBrokerPortfolio({ account, portfolio, positions, instruments: instrumentMap }); + }, + 'tbankPortfolioTtl', + ); + + return { data: result.data, meta: { fromCache: result.fromCache, cachedAt: result.cachedAt } }; + } + + private async buildInstrumentMap( + portfolio: TBankPortfolioResponse, + ): Promise>> { + const ids = Array.from( + new Set((portfolio.positions ?? []).map((position) => position.instrumentUid).filter(Boolean)), + ) as string[]; + const entries = await Promise.all( + ids.map(async (id) => [id, await this.instrumentsService.findByInstrumentUid(id)] as const), + ); + return new Map(entries.filter((entry): entry is readonly [string, TBankInstrument] => entry[1] !== null)); + } +} +``` + +- [ ] **Step 5: Add portfolio DTOs and controller route** + +Create `apps/backend/src/modules/tbank/dto/broker-money.dto.ts`: + +```typescript +import { ApiProperty } from '@nestjs/swagger'; + +export class BrokerMoneyDto { + @ApiProperty() + currency!: string; + + @ApiProperty() + units!: string; + + @ApiProperty() + nano!: number; + + @ApiProperty() + value!: number; +} +``` + +Create `apps/backend/src/modules/tbank/dto/broker-portfolio-response.dto.ts`: + +```typescript +import { ApiProperty } from '@nestjs/swagger'; +import { BrokerAccountResponseDto } from './broker-account-response.dto'; +import { BrokerMoneyDto } from './broker-money.dto'; + +export class BrokerPositionResponseDto { + @ApiProperty({ nullable: true }) + figi!: string | null; + + @ApiProperty({ nullable: true }) + instrumentUid!: string | null; + + @ApiProperty({ nullable: true }) + positionUid!: string | null; + + @ApiProperty({ nullable: true }) + ticker!: string | null; + + @ApiProperty({ nullable: true }) + classCode!: string | null; + + @ApiProperty({ nullable: true }) + instrumentType!: string | null; + + @ApiProperty({ nullable: true }) + name!: string | null; + + @ApiProperty({ nullable: true }) + quantity!: number | null; + + @ApiProperty({ nullable: true }) + blockedLots!: number | null; + + @ApiProperty({ type: BrokerMoneyDto, nullable: true }) + currentPrice!: BrokerMoneyDto | null; + + @ApiProperty({ type: BrokerMoneyDto, nullable: true }) + currentValue!: BrokerMoneyDto | null; + + @ApiProperty({ type: BrokerMoneyDto, nullable: true }) + averagePositionPrice!: BrokerMoneyDto | null; + + @ApiProperty({ nullable: true }) + expectedYieldPercent!: number | null; + + @ApiProperty({ type: BrokerMoneyDto, nullable: true }) + dailyYield!: BrokerMoneyDto | null; +} + +export class BrokerPortfolioTotalsDto { + @ApiProperty({ type: BrokerMoneyDto, nullable: true }) + shares!: BrokerMoneyDto | null; + + @ApiProperty({ type: BrokerMoneyDto, nullable: true }) + bonds!: BrokerMoneyDto | null; + + @ApiProperty({ type: BrokerMoneyDto, nullable: true }) + etf!: BrokerMoneyDto | null; + + @ApiProperty({ type: BrokerMoneyDto, nullable: true }) + currencies!: BrokerMoneyDto | null; + + @ApiProperty({ type: BrokerMoneyDto, nullable: true }) + futures!: BrokerMoneyDto | null; + + @ApiProperty({ type: BrokerMoneyDto, nullable: true }) + options!: BrokerMoneyDto | null; + + @ApiProperty({ type: BrokerMoneyDto, nullable: true }) + structuredProducts!: BrokerMoneyDto | null; + + @ApiProperty({ type: BrokerMoneyDto, nullable: true }) + dfa!: BrokerMoneyDto | null; + + @ApiProperty({ type: BrokerMoneyDto, nullable: true }) + portfolio!: BrokerMoneyDto | null; +} + +export class BrokerPortfolioYieldsDto { + @ApiProperty({ nullable: true }) + expectedPercent!: number | null; + + @ApiProperty({ type: BrokerMoneyDto, nullable: true }) + daily!: BrokerMoneyDto | null; + + @ApiProperty({ nullable: true }) + dailyPercent!: number | null; +} + +export class BrokerPortfolioResponseDto { + @ApiProperty({ type: BrokerAccountResponseDto }) + account!: BrokerAccountResponseDto; + + @ApiProperty({ type: BrokerPortfolioTotalsDto }) + totals!: BrokerPortfolioTotalsDto; + + @ApiProperty({ type: BrokerPortfolioYieldsDto }) + yields!: BrokerPortfolioYieldsDto; + + @ApiProperty({ type: [BrokerMoneyDto] }) + cash!: BrokerMoneyDto[]; + + @ApiProperty({ type: [BrokerMoneyDto] }) + blockedCash!: BrokerMoneyDto[]; + + @ApiProperty({ type: [BrokerPositionResponseDto] }) + positions!: BrokerPositionResponseDto[]; + + @ApiProperty() + asOf!: string; +} +``` + +Modify `apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts` to add: + +```typescript +import { BrokerPortfolioResponseDto } from './broker-portfolio-response.dto'; + +export class BrokerPortfolioEnvelopeDto { + @ApiProperty({ type: BrokerPortfolioResponseDto }) + data!: BrokerPortfolioResponseDto; + + @ApiProperty({ type: BrokerResponseMetaDto }) + meta!: BrokerResponseMetaDto; +} +``` + +Modify `apps/backend/src/modules/tbank/tbank.controller.ts`: + +```typescript +import { Param } from '@nestjs/common'; +import { BrokerPortfolioService } from './services/broker-portfolio.service'; +import { BrokerPortfolioEnvelopeDto } from './dto/broker-envelope.dto'; +``` + +Inject `BrokerPortfolioService` in the constructor and add: + +```typescript +@Get('accounts/:accountId/portfolio') +@ApiOperation({ summary: 'Get T-Bank broker account portfolio with cash and positions' }) +@ApiOkResponse({ type: BrokerPortfolioEnvelopeDto }) +async getPortfolio(@Param('accountId') accountId: string) { + return this.brokerPortfolioService.getPortfolio(accountId); +} +``` + +Update `TBankModule` providers and exports to include `BrokerInstrumentsService` and +`BrokerPortfolioService`. + +- [ ] **Step 6: Run portfolio tests and commit** + +Run: + +```bash +npx vitest run src/modules/tbank/mappers/portfolio.mapper.spec.ts src/modules/tbank/services/broker-portfolio.service.spec.ts -w apps/backend +npm run build:backend +``` + +Expected: tests PASS and backend build exits 0. + +Commit: + +```bash +git add apps/backend/src/modules/tbank +git commit -m "feat: expose tbank broker portfolio" +``` + +--- + +## Task 7: Implement Broker Operations Endpoint + +**Files:** +- Create: `apps/backend/src/modules/tbank/dto/broker-operation-query.dto.ts` +- Create: `apps/backend/src/modules/tbank/dto/broker-operation-response.dto.ts` +- Create: `apps/backend/src/modules/tbank/services/broker-operations.service.ts` +- Create: `apps/backend/src/modules/tbank/services/broker-operations.service.spec.ts` +- Modify: `apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts` +- Modify: `apps/backend/src/modules/tbank/tbank.controller.ts` +- Modify: `apps/backend/src/modules/tbank/tbank.module.ts` + +- [ ] **Step 1: Write operations service tests** + +Create `apps/backend/src/modules/tbank/services/broker-operations.service.spec.ts`: + +```typescript +import { NotFoundException } from '@nestjs/common'; +import { BrokerOperationsService } from './broker-operations.service'; +import { BrokerAccountsService } from './broker-accounts.service'; +import { TBankClientService } from './tbank-client.service'; +import { CacheService } from '../../cache/cache.service'; + +describe('BrokerOperationsService', () => { + const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService; + const client = { getServiceClient: vi.fn(), callUnary: vi.fn() } as unknown as TBankClientService; + const cache = { getOrFetch: vi.fn() } as unknown as CacheService; + + beforeEach(() => vi.clearAllMocks()); + + it('throws 404 for excluded or missing account', async () => { + vi.mocked(accounts.findById).mockResolvedValue(null); + const service = new BrokerOperationsService(accounts, client, cache); + + await expect(service.getOperations('missing', {})).rejects.toThrow(NotFoundException); + }); + + it('builds cursor request and maps operation page', async () => { + vi.mocked(accounts.findById).mockResolvedValue({ + id: 'acc-1', + type: 'brokerage', + name: 'Broker', + status: 'ACCOUNT_STATUS_OPEN', + openedAt: null, + accessLevel: null, + }); + vi.mocked(cache.getOrFetch).mockImplementation( + async (_prefix: string, _parts: string[], fetchFn: () => Promise) => ({ + data: await fetchFn(), + fromCache: false, + cachedAt: null, + }), + ); + vi.mocked(client.getServiceClient).mockReturnValue({ getOperationsByCursor: vi.fn() } as any); + vi.mocked(client.callUnary).mockResolvedValue({ + hasNext: false, + items: [{ cursor: 'c1', brokerAccountId: 'acc-1', type: 'OPERATION_TYPE_BUY' }], + }); + + const service = new BrokerOperationsService(accounts, client, cache); + const result = await service.getOperations('acc-1', { + from: '2026-01-01T00:00:00.000Z', + to: '2026-06-16T00:00:00.000Z', + limit: 1000, + state: 'OPERATION_STATE_EXECUTED', + }); + + expect(result.data.items[0].category).toBe('trade'); + expect(client.callUnary).toHaveBeenCalledWith( + 'OperationsService/GetOperationsByCursor', + expect.any(Function), + expect.objectContaining({ + accountId: 'acc-1', + limit: 1000, + state: 'OPERATION_STATE_EXECUTED', + }), + ); + }); +}); +``` + +- [ ] **Step 2: Add query DTO** + +Create `apps/backend/src/modules/tbank/dto/broker-operation-query.dto.ts`: + +```typescript +import { Transform } from 'class-transformer'; +import { IsDateString, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class BrokerOperationQueryDto { + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + from?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + to?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + cursor?: string; + + @ApiPropertyOptional({ minimum: 1, maximum: 1000, default: 100 }) + @IsOptional() + @Transform(({ value }) => (value === undefined ? undefined : Number(value))) + @IsInt() + @Min(1) + @Max(1000) + limit?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + instrumentId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + operationTypes?: string; + + @ApiPropertyOptional({ default: 'OPERATION_STATE_EXECUTED' }) + @IsOptional() + @IsString() + state?: string; +} +``` + +- [ ] **Step 3: Implement operations service** + +Create `apps/backend/src/modules/tbank/services/broker-operations.service.ts`: + +```typescript +import { Injectable, NotFoundException } from '@nestjs/common'; +import { CacheService } from '../../cache/cache.service'; +import { TBANK_CACHE_KEYS } from '../tbank.config'; +import { mapOperationsPage } from '../mappers/operation.mapper'; +import type { BrokerOperationsPage } from '../types/broker.types'; +import type { TBankOperationsByCursorResponse } from '../types/tbank-proto.types'; +import type { BrokerOperationQueryDto } from '../dto/broker-operation-query.dto'; +import { BrokerAccountsService } from './broker-accounts.service'; +import { TBankClientService } from './tbank-client.service'; + +@Injectable() +export class BrokerOperationsService { + constructor( + private readonly accountsService: BrokerAccountsService, + private readonly tbankClient: TBankClientService, + private readonly cacheService: CacheService, + ) {} + + async getOperations( + accountId: string, + query: BrokerOperationQueryDto, + ): Promise<{ data: BrokerOperationsPage; meta: { fromCache: boolean; cachedAt: string | null } }> { + const account = await this.accountsService.findById(accountId); + if (!account) throw new NotFoundException('Broker account not found'); + + const request = this.buildRequest(accountId, query); + const cacheParts = [accountId, JSON.stringify(request)]; + const result = await this.cacheService.getOrFetch( + TBANK_CACHE_KEYS.operations, + cacheParts, + () => this.fetchOperations(accountId, request), + 'tbankOperationsTtl', + ); + + return { data: result.data, meta: { fromCache: result.fromCache, cachedAt: result.cachedAt } }; + } + + private buildRequest(accountId: string, query: BrokerOperationQueryDto): Record { + const now = new Date(); + const startOfYear = new Date(Date.UTC(now.getUTCFullYear(), 0, 1)); + const operationTypes = query.operationTypes + ? query.operationTypes.split(',').map((value) => value.trim()).filter(Boolean) + : undefined; + + return { + accountId, + instrumentId: query.instrumentId, + from: { seconds: Math.floor(new Date(query.from ?? startOfYear.toISOString()).getTime() / 1000) }, + to: { seconds: Math.floor(new Date(query.to ?? now.toISOString()).getTime() / 1000) }, + cursor: query.cursor, + limit: query.limit ?? 100, + operationTypes, + state: query.state ?? 'OPERATION_STATE_EXECUTED', + withoutCommissions: false, + withoutTrades: false, + withoutOvernights: false, + }; + } + + private async fetchOperations( + accountId: string, + request: Record, + ): Promise { + const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any; + const response = await this.tbankClient.callUnary< + Record, + TBankOperationsByCursorResponse + >( + 'OperationsService/GetOperationsByCursor', + operationsClient.getOperationsByCursor.bind(operationsClient), + request, + ); + return mapOperationsPage(accountId, response); + } +} +``` + +- [ ] **Step 4: Add operation DTOs and controller route** + +Create `apps/backend/src/modules/tbank/dto/broker-operation-response.dto.ts`: + +```typescript +import { ApiProperty } from '@nestjs/swagger'; +import { BrokerMoneyDto } from './broker-money.dto'; + +const operationCategories = ['trade', 'income', 'tax', 'fee', 'transfer', 'other'] as const; + +export class BrokerOperationResponseDto { + @ApiProperty({ nullable: true }) + cursor!: string | null; + + @ApiProperty() + accountId!: string; + + @ApiProperty({ nullable: true }) + id!: string | null; + + @ApiProperty({ nullable: true }) + parentOperationId!: string | null; + + @ApiProperty({ nullable: true }) + date!: string | null; + + @ApiProperty() + type!: string; + + @ApiProperty({ enum: operationCategories }) + category!: (typeof operationCategories)[number]; + + @ApiProperty({ nullable: true }) + description!: string | null; + + @ApiProperty({ nullable: true }) + state!: string | null; + + @ApiProperty({ nullable: true }) + instrumentUid!: string | null; + + @ApiProperty({ nullable: true }) + figi!: string | null; + + @ApiProperty({ nullable: true }) + ticker!: string | null; + + @ApiProperty({ nullable: true }) + classCode!: string | null; + + @ApiProperty({ nullable: true }) + instrumentType!: string | null; + + @ApiProperty({ type: BrokerMoneyDto, nullable: true }) + payment!: BrokerMoneyDto | null; + + @ApiProperty({ type: BrokerMoneyDto, nullable: true }) + price!: BrokerMoneyDto | null; + + @ApiProperty({ type: BrokerMoneyDto, nullable: true }) + commission!: BrokerMoneyDto | null; + + @ApiProperty({ type: BrokerMoneyDto, nullable: true }) + yield!: BrokerMoneyDto | null; + + @ApiProperty({ type: BrokerMoneyDto, nullable: true }) + accruedInt!: BrokerMoneyDto | null; + + @ApiProperty({ nullable: true }) + quantity!: number | null; + + @ApiProperty({ nullable: true }) + quantityDone!: number | null; +} + +export class BrokerOperationsPageResponseDto { + @ApiProperty() + accountId!: string; + + @ApiProperty({ type: [BrokerOperationResponseDto] }) + items!: BrokerOperationResponseDto[]; + + @ApiProperty({ nullable: true }) + nextCursor!: string | null; + + @ApiProperty() + hasNext!: boolean; + + @ApiProperty() + asOf!: string; +} +``` + +Modify `apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts`: + +```typescript +import { BrokerOperationsPageResponseDto } from './broker-operation-response.dto'; + +export class BrokerOperationsEnvelopeDto { + @ApiProperty({ type: BrokerOperationsPageResponseDto }) + data!: BrokerOperationsPageResponseDto; + + @ApiProperty({ type: BrokerResponseMetaDto }) + meta!: BrokerResponseMetaDto; +} +``` + +Modify `apps/backend/src/modules/tbank/tbank.controller.ts`: + +```typescript +import { Query } from '@nestjs/common'; +import { BrokerOperationQueryDto } from './dto/broker-operation-query.dto'; +import { BrokerOperationsService } from './services/broker-operations.service'; +import { BrokerOperationsEnvelopeDto } from './dto/broker-envelope.dto'; +``` + +Inject `BrokerOperationsService` and add: + +```typescript +@Get('accounts/:accountId/operations') +@ApiOperation({ summary: 'Get paginated T-Bank broker account operations' }) +@ApiOkResponse({ type: BrokerOperationsEnvelopeDto }) +async getOperations( + @Param('accountId') accountId: string, + @Query() query: BrokerOperationQueryDto, +) { + return this.brokerOperationsService.getOperations(accountId, query); +} +``` + +Update `TBankModule` providers and exports to include `BrokerOperationsService`. + +- [ ] **Step 5: Run operations tests and commit** + +Run: + +```bash +npx vitest run src/modules/tbank/services/broker-operations.service.spec.ts src/modules/tbank/mappers/operation.mapper.spec.ts -w apps/backend +npm run build:backend +``` + +Expected: tests PASS and backend build exits 0. + +Commit: + +```bash +git add apps/backend/src/modules/tbank +git commit -m "feat: expose tbank broker operations" +``` + +--- + +## Task 8: Generate Frontend Types And Add Broker API Hooks + +**Files:** +- Modify: `apps/frontend/src/api/responses.ts` +- Create: `apps/frontend/src/api/broker.ts` +- Create: `apps/frontend/src/api/broker.test.ts` +- Create: `apps/frontend/src/hooks/useBrokerAccounts.ts` +- Create: `apps/frontend/src/hooks/useBrokerPortfolio.ts` +- Create: `apps/frontend/src/hooks/useBrokerOperations.ts` +- Create: `apps/frontend/src/hooks/useBrokerAccounts.test.tsx` + +- [ ] **Step 1: Regenerate OpenAPI types after backend endpoints exist** + +Start backend in a separate shell when it is not already running: + +```bash +PORT=3001 npm run dev:backend +``` + +Then run: + +```bash +npm run codegen -w apps/frontend +``` + +Expected: `apps/frontend/src/api/types.ts` includes `/api/v1/broker/accounts`, +`/api/v1/broker/accounts/{accountId}/portfolio`, and +`/api/v1/broker/accounts/{accountId}/operations`. + +- [ ] **Step 2: Add frontend response interfaces** + +Append to `apps/frontend/src/api/responses.ts`: + +```typescript +export interface BrokerMoney { + currency: string; + units: string; + nano: number; + value: number; +} + +export interface BrokerAccount { + id: string; + type: 'brokerage' | 'iis'; + name: string; + status: string; + openedAt: string | null; + accessLevel: string | null; +} + +export interface BrokerPosition { + figi: string | null; + instrumentUid: string | null; + positionUid: string | null; + ticker: string | null; + classCode: string | null; + instrumentType: string | null; + name: string | null; + quantity: number | null; + blockedLots: number | null; + currentPrice: BrokerMoney | null; + currentValue: BrokerMoney | null; + averagePositionPrice: BrokerMoney | null; + expectedYieldPercent: number | null; + dailyYield: BrokerMoney | null; +} + +export interface BrokerPortfolio { + account: BrokerAccount; + totals: { + shares: BrokerMoney | null; + bonds: BrokerMoney | null; + etf: BrokerMoney | null; + currencies: BrokerMoney | null; + futures: BrokerMoney | null; + options: BrokerMoney | null; + structuredProducts: BrokerMoney | null; + dfa: BrokerMoney | null; + portfolio: BrokerMoney | null; + }; + yields: { + expectedPercent: number | null; + daily: BrokerMoney | null; + dailyPercent: number | null; + }; + cash: BrokerMoney[]; + blockedCash: BrokerMoney[]; + positions: BrokerPosition[]; + asOf: string; +} + +export type BrokerOperationCategory = 'trade' | 'income' | 'tax' | 'fee' | 'transfer' | 'other'; + +export interface BrokerOperation { + cursor: string | null; + accountId: string; + id: string | null; + parentOperationId: string | null; + date: string | null; + type: string; + category: BrokerOperationCategory; + description: string | null; + state: string | null; + instrumentUid: string | null; + figi: string | null; + ticker: string | null; + classCode: string | null; + instrumentType: string | null; + payment: BrokerMoney | null; + price: BrokerMoney | null; + commission: BrokerMoney | null; + yield: BrokerMoney | null; + accruedInt: BrokerMoney | null; + quantity: number | null; + quantityDone: number | null; +} + +export interface BrokerOperationsPage { + accountId: string; + items: BrokerOperation[]; + nextCursor: string | null; + hasNext: boolean; + asOf: string; +} +``` + +- [ ] **Step 3: Add broker API client** + +Create `apps/frontend/src/api/broker.ts`: + +```typescript +import { request } from './client'; +import type { + ApiResponseMeta, + BrokerAccount, + BrokerOperation, + BrokerOperationsPage, + BrokerPortfolio, +} from './responses'; + +export type BrokerOperationQuery = { + from?: string; + to?: string; + cursor?: string; + limit?: number; + instrumentId?: string; + operationTypes?: string; + state?: string; +}; + +export function getBrokerAccounts(): Promise<{ + data: BrokerAccount[]; + meta: ApiResponseMeta; +}> { + return request('/api/v1/broker/accounts'); +} + +export function getBrokerPortfolio(accountId: string): Promise<{ + data: BrokerPortfolio; + meta: ApiResponseMeta; +}> { + return request(`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/portfolio`); +} + +export function getBrokerOperations( + accountId: string, + query: BrokerOperationQuery = {}, +): Promise<{ data: BrokerOperationsPage; meta: ApiResponseMeta }> { + return request( + `/api/v1/broker/accounts/${encodeURIComponent(accountId)}/operations`, + { + from: query.from, + to: query.to, + cursor: query.cursor, + limit: query.limit ? String(query.limit) : undefined, + instrumentId: query.instrumentId, + operationTypes: query.operationTypes, + state: query.state, + }, + ); +} +``` + +- [ ] **Step 4: Add API client test** + +Create `apps/frontend/src/api/broker.test.ts`: + +```typescript +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getBrokerOperations } from './broker'; + +describe('broker api', () => { + afterEach(() => vi.restoreAllMocks()); + + it('serializes operations query parameters', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ + data: { + data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: 'now' }, + meta: { fromCache: false, cachedAt: null }, + }, + }), + } as Response); + + await getBrokerOperations('acc-1', { cursor: 'c1', limit: 50 }); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/v1/broker/accounts/acc-1/operations?cursor=c1&limit=50'), + expect.any(Object), + ); + }); +}); +``` + +- [ ] **Step 5: Add broker hooks** + +Create `apps/frontend/src/hooks/useBrokerAccounts.ts`: + +```typescript +import { useQuery } from '@tanstack/react-query'; +import { getBrokerAccounts } from '../api/broker'; +import type { BrokerAccount } from '../api/responses'; + +export function useBrokerAccounts() { + return useQuery({ + queryKey: ['broker', 'accounts'], + queryFn: async () => (await getBrokerAccounts()).data, + staleTime: 3_600_000, + retry: 2, + refetchOnWindowFocus: false, + }); +} +``` + +Create `apps/frontend/src/hooks/useBrokerPortfolio.ts`: + +```typescript +import { useQuery } from '@tanstack/react-query'; +import { getBrokerPortfolio } from '../api/broker'; +import type { BrokerPortfolio } from '../api/responses'; + +export function useBrokerPortfolio(accountId: string | undefined) { + return useQuery({ + queryKey: ['broker', 'portfolio', accountId], + enabled: Boolean(accountId), + queryFn: async () => (await getBrokerPortfolio(accountId!)).data, + staleTime: 60_000, + retry: 2, + refetchOnWindowFocus: false, + }); +} +``` + +Create `apps/frontend/src/hooks/useBrokerOperations.ts`: + +```typescript +import { useQuery } from '@tanstack/react-query'; +import { getBrokerOperations, type BrokerOperationQuery } from '../api/broker'; +import type { BrokerOperationsPage } from '../api/responses'; + +export function useBrokerOperations(accountId: string | undefined, query: BrokerOperationQuery = {}) { + return useQuery({ + queryKey: ['broker', 'operations', accountId, query], + enabled: Boolean(accountId), + queryFn: async () => (await getBrokerOperations(accountId!, query)).data, + staleTime: 300_000, + retry: 2, + refetchOnWindowFocus: false, + }); +} +``` + +- [ ] **Step 6: Add hook smoke test** + +Create `apps/frontend/src/hooks/useBrokerAccounts.test.tsx`: + +```tsx +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { getBrokerAccounts } from '../api/broker'; +import { useBrokerAccounts } from './useBrokerAccounts'; + +vi.mock('../api/broker', () => ({ + getBrokerAccounts: vi.fn(), +})); + +function wrapper({ children }: { children: React.ReactNode }) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return {children}; +} + +describe('useBrokerAccounts', () => { + it('returns broker accounts from API', async () => { + vi.mocked(getBrokerAccounts).mockResolvedValue({ + data: [ + { + id: 'acc-1', + type: 'brokerage', + name: 'Broker', + status: 'ACCOUNT_STATUS_OPEN', + openedAt: null, + accessLevel: null, + }, + ], + meta: { fromCache: false, cachedAt: null }, + }); + + const { result } = renderHook(() => useBrokerAccounts(), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.[0].name).toBe('Broker'); + }); +}); +``` + +- [ ] **Step 7: Run frontend API/hook tests and commit** + +Run: + +```bash +npx vitest run src/api/broker.test.ts src/hooks/useBrokerAccounts.test.tsx -w apps/frontend +npm run build:frontend +``` + +Expected: tests PASS and frontend build exits 0. + +Commit: + +```bash +git add apps/frontend/src/api apps/frontend/src/hooks +git commit -m "feat: add broker frontend api hooks" +``` + +--- + +## Task 9: Build Broker Portfolio UI + +**Files:** +- Create: `apps/frontend/src/pages/broker/BrokerAccountsPage.tsx` +- Create: `apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx` +- Create: `apps/frontend/src/pages/broker/BrokerPages.test.tsx` +- Modify: `apps/frontend/src/routes.tsx` +- Modify: `apps/frontend/src/components/Layout.tsx` +- Modify: `apps/frontend/src/styles.css`: expected to remain unchanged unless browser verification + shows a concrete layout defect. + +- [ ] **Step 1: Add UI tests for broker pages** + +Create `apps/frontend/src/pages/broker/BrokerPages.test.tsx`: + +```typescript +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { describe, expect, it, vi } from 'vitest'; +import * as accountHook from '../../hooks/useBrokerAccounts'; +import * as portfolioHook from '../../hooks/useBrokerPortfolio'; +import * as operationsHook from '../../hooks/useBrokerOperations'; +import { BrokerAccountsPage } from './BrokerAccountsPage'; +import { BrokerAccountDetailPage } from './BrokerAccountDetailPage'; + +function renderWithClient(ui: React.ReactElement, initialEntries = ['/broker']) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + {ui} + , + ); +} + +describe('Broker pages', () => { + it('renders broker and IIS accounts', () => { + vi.spyOn(accountHook, 'useBrokerAccounts').mockReturnValue({ + data: [ + { id: 'acc-1', type: 'brokerage', name: 'Broker', status: 'ACCOUNT_STATUS_OPEN', openedAt: null, accessLevel: null }, + { id: 'acc-2', type: 'iis', name: 'IIS', status: 'ACCOUNT_STATUS_OPEN', openedAt: null, accessLevel: null }, + ], + isLoading: false, + error: null, + } as any); + + renderWithClient(); + + expect(screen.getByText('Broker')).toBeInTheDocument(); + expect(screen.getByText('IIS')).toBeInTheDocument(); + }); + + it('renders positions and operations for account detail', () => { + vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ + data: { + account: { id: 'acc-1', type: 'brokerage', name: 'Broker', status: 'ACCOUNT_STATUS_OPEN', openedAt: null, accessLevel: null }, + totals: { portfolio: { currency: 'RUB', units: '1000', nano: 0, value: 1000 } }, + yields: { expectedPercent: 5, daily: null, dailyPercent: null }, + cash: [{ currency: 'RUB', units: '100', nano: 0, value: 100 }], + blockedCash: [], + positions: [{ ticker: 'SBER', name: 'Sberbank', quantity: 10, currentValue: { currency: 'RUB', units: '1000', nano: 0, value: 1000 } }], + asOf: '2026-06-16T00:00:00.000Z', + }, + isLoading: false, + error: null, + } as any); + vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({ + data: { + accountId: 'acc-1', + items: [{ id: 'op-1', date: '2026-06-16T00:00:00.000Z', category: 'trade', type: 'OPERATION_TYPE_BUY', description: 'Buy', ticker: 'SBER', payment: { currency: 'RUB', units: '-1000', nano: 0, value: -1000 } }], + nextCursor: null, + hasNext: false, + asOf: '2026-06-16T00:00:00.000Z', + }, + isLoading: false, + error: null, + } as any); + + renderWithClient( + + } /> + , + ['/broker/acc-1'], + ); + + expect(screen.getByText('SBER')).toBeInTheDocument(); + expect(screen.getByText('OPERATION_TYPE_BUY')).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Implement broker accounts page** + +Create `apps/frontend/src/pages/broker/BrokerAccountsPage.tsx`: + +```tsx +import { Link } from 'react-router-dom'; +import { useBrokerAccounts } from '../../hooks/useBrokerAccounts'; + +export function BrokerAccountsPage() { + const { data: accounts, isLoading, error } = useBrokerAccounts(); + + if (isLoading) return

Загрузка брокерских счетов...

; + if (error) return

Не удалось загрузить счета

; + + return ( +
+

Брокерские счета

+

+ Реальные брокерские счета и ИИС из T-Bank Invest. +

+
+ {(accounts ?? []).map((account) => ( + +
{account.name}
+
+ {account.type === 'iis' ? 'ИИС' : 'Брокерский счет'} +
+ + ))} +
+
+ ); +} +``` + +- [ ] **Step 3: Implement broker account detail page** + +Create `apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx`: + +```tsx +import { useParams } from 'react-router-dom'; +import { useBrokerPortfolio } from '../../hooks/useBrokerPortfolio'; +import { useBrokerOperations } from '../../hooks/useBrokerOperations'; +import type { BrokerMoney } from '../../api/responses'; + +function formatMoney(value: BrokerMoney | null | undefined) { + if (!value) return '—'; + return new Intl.NumberFormat('ru-RU', { + style: 'currency', + currency: value.currency || 'RUB', + maximumFractionDigits: 2, + }).format(value.value); +} + +export function BrokerAccountDetailPage() { + const { accountId } = useParams(); + const portfolio = useBrokerPortfolio(accountId); + const operations = useBrokerOperations(accountId, { limit: 100 }); + + if (portfolio.isLoading) return

Загрузка портфеля...

; + if (portfolio.error || !portfolio.data) { + return

Не удалось загрузить портфель

; + } + + return ( +
+

{portfolio.data.account.name}

+
+ {formatMoney(portfolio.data.totals.portfolio)} + Дневная доходность: {formatMoney(portfolio.data.yields.daily)} + Ожидаемая доходность: {portfolio.data.yields.expectedPercent ?? '—'}% +
+ +
+

Деньги

+
+ {portfolio.data.cash.map((money) => ( + {formatMoney(money)} + ))} +
+
+ +
+

Позиции

+
+ + + + + + + + + + + {portfolio.data.positions.map((position) => ( + + + + + + + ))} + +
ИнструментКоличествоСтоимостьДоходность
{position.ticker || position.name || position.figi}{position.quantity ?? '—'}{formatMoney(position.currentValue)}{position.expectedYieldPercent ?? '—'}%
+
+
+ +
+

Операции

+ {operations.isLoading ? ( +

Загрузка операций...

+ ) : ( +
+ + + + + + + + + + + {(operations.data?.items ?? []).map((operation) => ( + + + + + + + ))} + +
ДатаТипИнструментСумма
{operation.date ? new Date(operation.date).toLocaleString('ru-RU') : '—'}{operation.type}{operation.ticker || operation.description || '—'}{formatMoney(operation.payment)}
+
+ )} +
+
+ ); +} +``` + +- [ ] **Step 4: Wire routes and navigation** + +Modify `apps/frontend/src/routes.tsx` imports: + +```typescript +import { BrokerAccountsPage } from './pages/broker/BrokerAccountsPage'; +import { BrokerAccountDetailPage } from './pages/broker/BrokerAccountDetailPage'; +``` + +Add protected routes: + +```tsx + + + + } +/> + + + + } +/> +``` + +Modify `apps/frontend/src/components/Layout.tsx` to add a link after `Портфели`: + +```tsx + + Брокер + +``` + +- [ ] **Step 5: Run frontend tests, build, and browser verification** + +Run: + +```bash +npx vitest run src/pages/broker/BrokerPages.test.tsx -w apps/frontend +npm run build:frontend +``` + +Expected: tests PASS and frontend build exits 0. + +Then start dev servers: + +```bash +PORT=3001 npm run dev:backend +npm run dev:frontend +``` + +Open `http://localhost:5173/broker` with Browser. Verify: + +- page renders without a blank screen; +- protected route redirects unauthenticated users consistently with existing portfolio pages; +- layout does not overlap at desktop and mobile widths. + +- [ ] **Step 6: Commit frontend broker UI** + +```bash +git add apps/frontend/src/api apps/frontend/src/hooks apps/frontend/src/pages/broker apps/frontend/src/routes.tsx apps/frontend/src/components/Layout.tsx apps/frontend/src/styles.css +git commit -m "feat: add broker portfolio UI" +``` + +--- + +## Task 10: Add Durable Operation Sync Models And Service + +**Files:** +- Modify: `apps/backend/prisma/schema.prisma` +- Create: migration SQL generated by Prisma under `apps/backend/prisma/migrations/` when running + `npx prisma migrate dev --name add_broker_operations -w apps/backend` +- Create: `apps/backend/src/modules/tbank/services/broker-operation-sync.service.ts` +- Create: `apps/backend/src/modules/tbank/services/broker-operation-sync.service.spec.ts` +- Modify: `apps/backend/src/modules/tbank/tbank.module.ts` + +- [ ] **Step 1: Add Prisma models** + +Append to `apps/backend/prisma/schema.prisma`: + +```prisma +model BrokerOperation { + id Int @id @default(autoincrement()) + accountId String + cursor String? + operationId String? + parentOperationId String? + date DateTime? + type String + category String + state String? + instrumentUid String? + figi String? + ticker String? + classCode String? + payment String? + price String? + commission String? + yield String? + accruedInt String? + quantity Int? + quantityDone Int? + raw String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([accountId, cursor]) + @@index([accountId, date]) + @@index([accountId, type]) +} + +model BrokerOperationSyncState { + id Int @id @default(autoincrement()) + accountId String @unique + lastCursor String? + lastSyncedFrom DateTime? + lastSyncedTo DateTime? + syncedAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} +``` + +- [ ] **Step 2: Generate migration and Prisma client** + +Run: + +```bash +npx prisma migrate dev --name add_broker_operations -w apps/backend +``` + +Expected: migration SQL file created and Prisma client generated. + +- [ ] **Step 3: Write sync service test** + +Create `apps/backend/src/modules/tbank/services/broker-operation-sync.service.spec.ts`: + +```typescript +import { BrokerOperationSyncService } from './broker-operation-sync.service'; +import { BrokerOperationsService } from './broker-operations.service'; +import { PrismaService } from '../../prisma/prisma.service'; + +describe('BrokerOperationSyncService', () => { + const operations = { getOperations: vi.fn() } as unknown as BrokerOperationsService; + const prisma = { + brokerOperation: { upsert: vi.fn() }, + brokerOperationSyncState: { upsert: vi.fn() }, + } as unknown as PrismaService; + + beforeEach(() => vi.clearAllMocks()); + + it('syncs operation pages and stores raw payload', async () => { + vi.mocked(operations.getOperations) + .mockResolvedValueOnce({ + data: { + accountId: 'acc-1', + hasNext: true, + nextCursor: 'next', + asOf: '2026-06-16T00:00:00.000Z', + items: [ + { + cursor: 'c1', + accountId: 'acc-1', + id: 'op-1', + parentOperationId: null, + date: '2026-06-16T00:00:00.000Z', + type: 'OPERATION_TYPE_BUY', + category: 'trade', + description: null, + state: 'OPERATION_STATE_EXECUTED', + instrumentUid: 'uid-1', + figi: null, + ticker: 'SBER', + classCode: 'TQBR', + instrumentType: 'share', + payment: { currency: 'RUB', units: '-1000', nano: 0, value: -1000 }, + price: null, + commission: null, + yield: null, + accruedInt: null, + quantity: 10, + quantityDone: 10, + }, + ], + }, + meta: { fromCache: false, cachedAt: null }, + }) + .mockResolvedValueOnce({ + data: { accountId: 'acc-1', hasNext: false, nextCursor: null, asOf: 'now', items: [] }, + meta: { fromCache: false, cachedAt: null }, + }); + + const service = new BrokerOperationSyncService(operations, prisma); + const result = await service.syncAccount('acc-1', { + from: '2026-06-01T00:00:00.000Z', + to: '2026-06-16T00:00:00.000Z', + }); + + expect(result.upserted).toBe(1); + expect(prisma.brokerOperation.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { accountId_cursor: { accountId: 'acc-1', cursor: 'c1' } }, + }), + ); + expect(prisma.brokerOperationSyncState.upsert).toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 4: Implement sync service** + +Create `apps/backend/src/modules/tbank/services/broker-operation-sync.service.ts`: + +```typescript +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import type { BrokerOperation } from '../types/broker.types'; +import { BrokerOperationsService } from './broker-operations.service'; + +type SyncRange = { + from: string; + to: string; +}; + +@Injectable() +export class BrokerOperationSyncService { + constructor( + private readonly operationsService: BrokerOperationsService, + private readonly prisma: PrismaService, + ) {} + + async syncAccount(accountId: string, range: SyncRange): Promise<{ upserted: number }> { + let cursor: string | undefined; + let upserted = 0; + + do { + const page = await this.operationsService.getOperations(accountId, { + from: range.from, + to: range.to, + cursor, + limit: 1000, + state: 'OPERATION_STATE_EXECUTED', + }); + + for (const operation of page.data.items) { + await this.upsertOperation(operation); + upserted++; + } + + cursor = page.data.nextCursor ?? undefined; + if (!page.data.hasNext) break; + } while (cursor); + + await this.prisma.brokerOperationSyncState.upsert({ + where: { accountId }, + create: { + accountId, + lastCursor: cursor ?? null, + lastSyncedFrom: new Date(range.from), + lastSyncedTo: new Date(range.to), + }, + update: { + lastCursor: cursor ?? null, + lastSyncedFrom: new Date(range.from), + lastSyncedTo: new Date(range.to), + syncedAt: new Date(), + }, + }); + + return { upserted }; + } + + private async upsertOperation(operation: BrokerOperation): Promise { + const cursor = operation.cursor || `${operation.id || 'operation'}:${operation.date || 'no-date'}`; + const data = { + accountId: operation.accountId, + cursor, + operationId: operation.id, + parentOperationId: operation.parentOperationId, + date: operation.date ? new Date(operation.date) : null, + type: operation.type, + category: operation.category, + state: operation.state, + instrumentUid: operation.instrumentUid, + figi: operation.figi, + ticker: operation.ticker, + classCode: operation.classCode, + payment: operation.payment ? JSON.stringify(operation.payment) : null, + price: operation.price ? JSON.stringify(operation.price) : null, + commission: operation.commission ? JSON.stringify(operation.commission) : null, + yield: operation.yield ? JSON.stringify(operation.yield) : null, + accruedInt: operation.accruedInt ? JSON.stringify(operation.accruedInt) : null, + quantity: operation.quantity, + quantityDone: operation.quantityDone, + raw: JSON.stringify(operation), + }; + + await this.prisma.brokerOperation.upsert({ + where: { accountId_cursor: { accountId: operation.accountId, cursor } }, + create: data, + update: data, + }); + } +} +``` + +Add `BrokerOperationSyncService` to `TBankModule` providers and exports. + +- [ ] **Step 5: Run sync tests and commit** + +Run: + +```bash +npx vitest run src/modules/tbank/services/broker-operation-sync.service.spec.ts -w apps/backend +npm run build:backend +``` + +Expected: tests PASS and backend build exits 0. + +Commit: + +```bash +git add apps/backend/prisma apps/backend/src/modules/tbank/services/broker-operation-sync.service.ts apps/backend/src/modules/tbank/services/broker-operation-sync.service.spec.ts apps/backend/src/modules/tbank/tbank.module.ts +git commit -m "feat: persist tbank broker operations" +``` + +--- + +## Task 11: Publish T-Bank Integration Documentation + +**Files:** +- Create: `apps/docs/docs/backend/tbank-invest.md` +- Modify: `apps/docs/docs/backend/modules.md` +- Modify: `apps/docs/docs/backend/configuration.md` +- Modify: `apps/docs/docs/backend/caching.md` +- Modify: `apps/docs/docs/backend/portfolio.md` +- Create: `apps/docs/docs/adr/ADR-011-tbank-invest-grpc.md` +- Modify: `apps/docs/docs/adr/index.md` +- Modify: `apps/docs/sidebars.ts` + +- [ ] **Step 1: Add backend T-Bank integration page** + +Create `apps/docs/docs/backend/tbank-invest.md`: + +```markdown +# T-Bank Invest Integration + +`TBankModule` is a read-only backend integration with T-Bank Invest API. The backend is the only +client that talks to T-Bank; the frontend calls MoexVibe endpoints under `/api/v1/broker`. + +## Scope + +The first version supports only open brokerage accounts and IIS accounts: + +- `ACCOUNT_TYPE_TINKOFF` +- `ACCOUNT_TYPE_TINKOFF_IIS` + +Invest Box, DFA smart accounts, debit accounts, savings accounts, and money market fund accounts +are ignored. + +## Protocol + +MoexVibe uses gRPC against `invest-public-api.tbank.ru:443`. REST is treated as a debugging proxy, +not as the application integration protocol. + +The backend sends: + +```text +Authorization: Bearer +x-app-name: ksv741.moex-vibe +``` + +The token is read from backend environment variables and is never returned to the frontend. + +## Backend Endpoints + +| Endpoint | Description | +| --- | --- | +| `GET /api/v1/broker/accounts` | Open brokerage and IIS accounts. | +| `GET /api/v1/broker/accounts/:accountId/portfolio` | Portfolio totals, positions, cash, and blocked cash. | +| `GET /api/v1/broker/accounts/:accountId/operations` | Cursor-paginated operation history. | + +## T-Bank Methods + +| Need | T-Bank method | +| --- | --- | +| Accounts | `UsersService/GetAccounts` | +| Portfolio totals | `OperationsService/GetPortfolio` | +| Cash and settled positions | `OperationsService/GetPositions` | +| Operation history | `OperationsService/GetOperationsByCursor` | +| Instrument metadata | `InstrumentsService/GetInstrumentBy` | + +## Security + +This version is single-user/admin-oriented because it uses one server-side `T_BANK_TOKEN`. Before +opening MoexVibe to multiple users, replace this with encrypted per-user token storage and bind each +broker account to its owner. +``` + +- [ ] **Step 2: Update backend module docs** + +Modify `apps/docs/docs/backend/modules.md`: + +- Add `TBankModule` to feature modules in the Mermaid diagram. +- Add row `TBankModule | Нет | modules/tbank/ | Read-only T-Bank Invest broker portfolios`. +- Add a section describing `TBankModule`, its gRPC client, cache usage, and read-only scope. + +- [ ] **Step 3: Update configuration docs** + +Modify `apps/docs/docs/backend/configuration.md` and add rows: + +```markdown +| `T_BANK_TOKEN` | empty | Server-side T-Bank Invest token | +| `T_BANK_BASE_URL` | `invest-public-api.tbank.ru:443` | T-Bank gRPC endpoint | +| `T_BANK_APP_NAME` | `ksv741.moex-vibe` | Optional T-Bank app metadata | +| `T_BANK_RATE_LIMIT_PER_SECOND` | `5` | Local limiter for T-Bank calls | +| `T_BANK_REQUEST_TIMEOUT_MS` | `10000` | gRPC request deadline | +| `CACHE_TBANK_ACCOUNTS_TTL` | `3600` | Broker accounts cache TTL | +| `CACHE_TBANK_PORTFOLIO_TTL` | `60` | Broker portfolio cache TTL | +| `CACHE_TBANK_OPERATIONS_TTL` | `300` | Broker operations page cache TTL | +| `CACHE_TBANK_INSTRUMENT_TTL` | `86400` | T-Bank instrument metadata TTL | +``` + +- [ ] **Step 4: Update caching and portfolio docs** + +Modify `apps/docs/docs/backend/caching.md` to add T-Bank cache rows matching the spec. + +Modify `apps/docs/docs/backend/portfolio.md` to add a short section: + +```markdown +## Manual portfolios vs broker portfolios + +`PortfolioModule` remains the manual virtual portfolio domain. T-Bank broker accounts are exposed by +`TBankModule` under `/api/v1/broker/*` and are not stored as `Portfolio` records. +``` + +- [ ] **Step 5: Add ADR-011** + +Create `apps/docs/docs/adr/ADR-011-tbank-invest-grpc.md`: + +```markdown +# ADR-011: T-Bank Invest integration uses gRPC + +**Статус:** Accepted + +**Дата:** 2026-06-16 + +## Контекст + +MoexVibe needs a read-only integration with T-Bank Invest for brokerage and IIS accounts, current +positions, cash balances, and operation history. T-Bank provides gRPC, REST proxy, WebSocket, and an +official JS SDK. + +## Решение + +Use a thin backend gRPC integration based on official proto contracts. Keep REST as a manual +debugging tool and do not depend directly on the JS SDK in the first implementation. + +## Обоснование + +- gRPC is the primary T-Bank Invest protocol. +- Unary methods cover accounts, portfolio, positions, operations, and instruments. +- Stream methods can be added later without changing the public MoexVibe API. +- Owning the transport layer lets MoexVibe control rate limiting, metadata redaction, tracking IDs, + test doubles, and the future transition from one server token to per-user tokens. + +## Последствия + +- The backend vendors official proto contracts. +- The backend owns T-Bank-specific rate limits and cache TTLs. +- Integration remains read-only until a separate trading/order ADR is accepted. +``` + +Update `apps/docs/docs/adr/index.md` and `apps/docs/sidebars.ts` to include ADR-011 and +`backend/tbank-invest`. + +- [ ] **Step 6: Build docs and commit** + +Run: + +```bash +npm run build:docs +``` + +Expected: Docusaurus build exits 0. + +Commit: + +```bash +git add apps/docs +git commit -m "docs: document tbank invest integration" +``` + +--- + +## Task 12: Final Verification And OpenAPI Contract Check + +**Files:** +- Verify all changed files. + +- [ ] **Step 1: Run backend tests** + +Run: + +```bash +npm run test:backend +``` + +Expected: all backend Vitest tests PASS. + +- [ ] **Step 2: Run frontend tests** + +Run: + +```bash +npm run test:frontend +``` + +Expected: all frontend Vitest tests PASS. + +- [ ] **Step 3: Run lint** + +Run: + +```bash +npm run lint +``` + +Expected: ESLint exits 0 for backend and frontend. + +- [ ] **Step 4: Run builds** + +Run: + +```bash +npm run build:backend +npm run build:frontend +npm run build:docs +``` + +Expected: all builds exit 0. + +- [ ] **Step 5: Verify Swagger exposes broker endpoints** + +Start backend: + +```bash +PORT=3001 npm run dev:backend +``` + +Run: + +```bash +node -e "fetch('http://localhost:3001/api/docs-json').then(r => r.json()).then(j => { const required = ['/api/v1/broker/accounts','/api/v1/broker/accounts/{accountId}/portfolio','/api/v1/broker/accounts/{accountId}/operations']; const missing = required.filter(p => !j.paths || !j.paths[p]); console.log(JSON.stringify({ missing }, null, 2)); if (missing.length) process.exit(1); })" +``` + +Expected: + +```json +{ + "missing": [] +} +``` + +- [ ] **Step 6: Browser-check frontend** + +With backend and frontend dev servers running, open: + +```text +http://localhost:5173/broker +``` + +Verify: + +- unauthenticated users are redirected by `ProtectedRoute`; +- authenticated view renders account loading/error states; +- account detail page renders positions and operations without overlapping text at desktop and + mobile widths; +- no token value appears in the browser UI or console logs. + +- [ ] **Step 7: Inspect git diff** + +Run: + +```bash +git status --short +git diff --check +git log --oneline --max-count=8 +``` + +Expected: + +- `git diff --check` exits 0; +- status contains only intentional uncommitted files, or is clean after the final commit; +- recent commits correspond to the tasks above. diff --git a/docs/superpowers/plans/2026-06-17-broker-portfolio-display.md b/docs/superpowers/plans/2026-06-17-broker-portfolio-display.md new file mode 100644 index 0000000..c71509d --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-broker-portfolio-display.md @@ -0,0 +1,1339 @@ +# Broker Portfolio Display Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Улучшить страницу брокерского счета: разделить позиции акций и облигаций, добавить текущую цену, сделать инструменты кликабельными и оформить операции с русскими типами, бейджами эффекта и cursor-пагинацией по 10 строк. + +**Architecture:** Изолировать правила отображения в `brokerDisplay.ts`, чтобы маршруты инструментов, русские labels и impact-классификация тестировались отдельно от React. Вынести таблицы позиций и операций в небольшие компоненты рядом со страницей брокера, а `BrokerAccountDetailPage.tsx` оставить контейнером данных и состояния пагинации. + +**Tech Stack:** React 18, react-router-dom v6 `Link`, TanStack Query через существующие hooks, Vitest, Testing Library, TypeScript. + +--- + +## File Structure + +- Create: `apps/frontend/src/pages/broker/brokerDisplay.ts` + - Pure helper functions for grouping positions, building instrument links, mapping operation labels and classifying operation impact. +- Create: `apps/frontend/src/pages/broker/brokerDisplay.test.ts` + - Unit tests for all display helpers. +- Create: `apps/frontend/src/pages/broker/BrokerPositionsSection.tsx` + - Presentational component for `Акции`, `Облигации`, and `Другие инструменты` tables. +- Create: `apps/frontend/src/pages/broker/BrokerOperationsTable.tsx` + - Presentational component for operations table, impact badges, linked instruments and pagination controls. +- Modify: `apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx` + - Replace inline tables with the two components and add cursor stack state for operations. +- Modify: `apps/frontend/src/pages/broker/BrokerPages.test.tsx` + - Page-level tests for positions, operations links, Russian labels and pagination behavior. + +## Task 1: Display Helpers + +**Files:** + +- Create: `apps/frontend/src/pages/broker/brokerDisplay.ts` +- Create: `apps/frontend/src/pages/broker/brokerDisplay.test.ts` + +- [ ] **Step 1: Write the failing helper tests** + +Create `apps/frontend/src/pages/broker/brokerDisplay.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import type { BrokerOperation, BrokerPosition } from '../../api/responses'; +import { + getBrokerInstrumentPath, + getBrokerOperationImpact, + getBrokerOperationImpactLabel, + getBrokerOperationTypeLabel, + getBrokerPositionGroup, +} from './brokerDisplay'; + +function position(input: Partial): BrokerPosition { + return { + figi: null, + instrumentUid: null, + positionUid: null, + ticker: null, + classCode: null, + instrumentType: null, + name: null, + quantity: null, + blockedLots: null, + currentPrice: null, + currentValue: null, + averagePositionPrice: null, + expectedYieldPercent: null, + dailyYield: null, + ...input, + }; +} + +function operation(input: Partial): BrokerOperation { + return { + cursor: null, + accountId: 'acc-1', + id: null, + parentOperationId: null, + date: null, + type: 'OPERATION_TYPE_UNSPECIFIED', + category: 'other', + description: null, + state: null, + instrumentUid: null, + figi: null, + ticker: null, + classCode: null, + instrumentType: null, + payment: null, + price: null, + commission: null, + yield: null, + accruedInt: null, + quantity: null, + quantityDone: null, + ...input, + }; +} + +describe('broker display helpers', () => { + it('groups positions by instrument type', () => { + expect(getBrokerPositionGroup(position({ instrumentType: 'share' }))).toBe('shares'); + expect(getBrokerPositionGroup(position({ instrumentType: 'bond' }))).toBe('bonds'); + expect(getBrokerPositionGroup(position({ instrumentType: 'etf' }))).toBe('other'); + expect(getBrokerPositionGroup(position({ instrumentType: null }))).toBe('other'); + }); + + it('builds stock and bond routes from instrument metadata', () => { + expect( + getBrokerInstrumentPath({ ticker: 'sber', instrumentType: 'share', classCode: 'TQBR' }), + ).toBe('/stocks/SBER'); + expect( + getBrokerInstrumentPath({ + ticker: 'SU26238RMFS5', + instrumentType: 'bond', + classCode: 'TQOB', + }), + ).toBe('/bonds/SU26238RMFS5'); + expect( + getBrokerInstrumentPath({ ticker: null, instrumentType: 'share', classCode: 'TQBR' }), + ).toBeNull(); + expect( + getBrokerInstrumentPath({ ticker: 'TMOS', instrumentType: 'etf', classCode: 'TQTF' }), + ).toBeNull(); + }); + + it('uses class code fallback when instrument type is missing', () => { + expect( + getBrokerInstrumentPath({ ticker: 'SBER', instrumentType: null, classCode: 'TQBR' }), + ).toBe('/stocks/SBER'); + expect( + getBrokerInstrumentPath({ ticker: 'RU000A0JX0J2', instrumentType: null, classCode: 'TQOB' }), + ).toBe('/bonds/RU000A0JX0J2'); + }); + + it('maps operation enum values to Russian labels', () => { + expect(getBrokerOperationTypeLabel(operation({ type: 'OPERATION_TYPE_COUPON' }))).toBe( + 'Выплата купона', + ); + expect(getBrokerOperationTypeLabel(operation({ type: 'OPERATION_TYPE_TAX' }))).toBe('Налог'); + expect(getBrokerOperationTypeLabel(operation({ type: 'OPERATION_TYPE_BUY' }))).toBe('Покупка'); + expect( + getBrokerOperationTypeLabel( + operation({ type: 'OPERATION_TYPE_UNKNOWN_VALUE', description: 'Custom' }), + ), + ).toBe('Custom'); + }); + + it('classifies operations by portfolio impact', () => { + expect( + getBrokerOperationImpact( + operation({ + type: 'OPERATION_TYPE_COUPON', + category: 'income', + payment: { currency: 'RUB', units: '120', nano: 0, value: 120 }, + }), + ), + ).toBe('adds'); + expect( + getBrokerOperationImpact( + operation({ + type: 'OPERATION_TYPE_TAX', + category: 'tax', + payment: { currency: 'RUB', units: '-13', nano: 0, value: -13 }, + }), + ), + ).toBe('reduces'); + expect( + getBrokerOperationImpact( + operation({ + type: 'OPERATION_TYPE_SELL', + category: 'trade', + payment: { currency: 'RUB', units: '1000', nano: 0, value: 1000 }, + }), + ), + ).toBe('neutral'); + expect(getBrokerOperationImpact(operation({ type: 'OPERATION_TYPE_UNSPECIFIED' }))).toBe( + 'unknown', + ); + }); + + it('provides Russian impact labels', () => { + expect(getBrokerOperationImpactLabel('adds')).toBe('Пополняет'); + expect(getBrokerOperationImpactLabel('reduces')).toBe('Списывает'); + expect(getBrokerOperationImpactLabel('neutral')).toBe('Перекладка'); + expect(getBrokerOperationImpactLabel('unknown')).toBe('Неясно'); + }); +}); +``` + +- [ ] **Step 2: Run helper tests and verify failure** + +Run: + +```bash +npx vitest run src/pages/broker/brokerDisplay.test.ts -w apps/frontend +``` + +Expected: FAIL because `./brokerDisplay` does not exist. + +- [ ] **Step 3: Implement the display helpers** + +Create `apps/frontend/src/pages/broker/brokerDisplay.ts`: + +```ts +import type { BrokerOperation, BrokerPosition } from '../../api/responses'; + +export type BrokerPositionGroup = 'shares' | 'bonds' | 'other'; +export type BrokerOperationImpact = 'adds' | 'reduces' | 'neutral' | 'unknown'; + +type BrokerInstrumentLinkInput = { + ticker: string | null; + instrumentType: string | null; + classCode: string | null; +}; + +const STOCK_CLASS_CODES = new Set(['TQBR']); +const BOND_CLASS_CODES = new Set(['TQOB', 'TQCB', 'TQIR']); + +const TRADE_TYPES = new Set([ + 'OPERATION_TYPE_BUY', + 'OPERATION_TYPE_BUY_CARD', + 'OPERATION_TYPE_SELL', + 'OPERATION_TYPE_SELL_CARD', + 'OPERATION_TYPE_BUY_MARGIN', + 'OPERATION_TYPE_SELL_MARGIN', + 'OPERATION_TYPE_DELIVERY_BUY', + 'OPERATION_TYPE_DELIVERY_SELL', +]); + +const BOND_REPAYMENT_TYPES = new Set([ + 'OPERATION_TYPE_BOND_REPAYMENT', + 'OPERATION_TYPE_BOND_REPAYMENT_FULL', +]); + +const TRANSFER_INPUT_TYPES = new Set([ + 'OPERATION_TYPE_INPUT', + 'OPERATION_TYPE_INPUT_SWIFT', + 'OPERATION_TYPE_INPUT_ACQUIRING', + 'OPERATION_TYPE_INP_MULTI', +]); + +const TRANSFER_OUTPUT_TYPES = new Set([ + 'OPERATION_TYPE_OUTPUT', + 'OPERATION_TYPE_OUTPUT_SWIFT', + 'OPERATION_TYPE_OUTPUT_ACQUIRING', + 'OPERATION_TYPE_OUT_MULTI', +]); + +const SECURITY_TRANSFER_TYPES = new Set([ + 'OPERATION_TYPE_INPUT_SECURITIES', + 'OPERATION_TYPE_OUTPUT_SECURITIES', + 'OPERATION_TYPE_TRANS_IIS_BS', + 'OPERATION_TYPE_TRANS_BS_BS', +]); + +const OPERATION_TYPE_LABELS: Record = { + OPERATION_TYPE_BUY: 'Покупка', + OPERATION_TYPE_BUY_CARD: 'Покупка', + OPERATION_TYPE_SELL: 'Продажа', + OPERATION_TYPE_SELL_CARD: 'Продажа', + OPERATION_TYPE_BUY_MARGIN: 'Покупка с маржой', + OPERATION_TYPE_SELL_MARGIN: 'Продажа с маржой', + OPERATION_TYPE_DELIVERY_BUY: 'Поставка покупки', + OPERATION_TYPE_DELIVERY_SELL: 'Поставка продажи', + OPERATION_TYPE_COUPON: 'Выплата купона', + OPERATION_TYPE_DIVIDEND: 'Дивиденды', + OPERATION_TYPE_BOND_REPAYMENT: 'Погашение облигации', + OPERATION_TYPE_BOND_REPAYMENT_FULL: 'Полное погашение облигации', + OPERATION_TYPE_TAX: 'Налог', + OPERATION_TYPE_BOND_TAX: 'Налог по облигациям', + OPERATION_TYPE_DIVIDEND_TAX: 'Налог на дивиденды', + OPERATION_TYPE_TAX_CORRECTION: 'Корректировка налога', + OPERATION_TYPE_TAX_CORRECTION_COUPON: 'Корректировка налога по купону', + OPERATION_TYPE_BROKER_FEE: 'Комиссия брокера', + OPERATION_TYPE_SERVICE_FEE: 'Комиссия за обслуживание', + OPERATION_TYPE_MARGIN_FEE: 'Комиссия за маржу', + OPERATION_TYPE_SUCCESS_FEE: 'Комиссия за результат', + OPERATION_TYPE_INPUT: 'Пополнение', + OPERATION_TYPE_OUTPUT: 'Вывод средств', + OPERATION_TYPE_INPUT_SECURITIES: 'Зачисление бумаг', + OPERATION_TYPE_OUTPUT_SECURITIES: 'Списание бумаг', +}; + +export function getBrokerPositionGroup( + position: Pick, +): BrokerPositionGroup { + const instrumentType = position.instrumentType?.toLowerCase(); + + if (instrumentType === 'share') return 'shares'; + if (instrumentType === 'bond') return 'bonds'; + + return 'other'; +} + +export function getBrokerInstrumentPath(input: BrokerInstrumentLinkInput): string | null { + const ticker = input.ticker?.trim().toUpperCase(); + if (!ticker) return null; + + const instrumentType = input.instrumentType?.toLowerCase(); + const classCode = input.classCode?.toUpperCase() ?? null; + + if (instrumentType === 'share' || (classCode && STOCK_CLASS_CODES.has(classCode))) { + return `/stocks/${encodeURIComponent(ticker)}`; + } + + if (instrumentType === 'bond' || (classCode && BOND_CLASS_CODES.has(classCode))) { + return `/bonds/${encodeURIComponent(ticker)}`; + } + + return null; +} + +export function getBrokerOperationTypeLabel( + operation: Pick, +): string { + const knownLabel = OPERATION_TYPE_LABELS[operation.type]; + if (knownLabel) return knownLabel; + if (operation.description) return operation.description; + + return operation.type + .replace(/^OPERATION_TYPE_/, '') + .replaceAll('_', ' ') + .toLowerCase(); +} + +export function getBrokerOperationImpact( + operation: Pick, +): BrokerOperationImpact { + if ( + TRADE_TYPES.has(operation.type) || + BOND_REPAYMENT_TYPES.has(operation.type) || + SECURITY_TRANSFER_TYPES.has(operation.type) + ) { + return 'neutral'; + } + + if (TRANSFER_INPUT_TYPES.has(operation.type)) return 'adds'; + if (TRANSFER_OUTPUT_TYPES.has(operation.type)) return 'reduces'; + if (operation.category === 'tax' || operation.category === 'fee') return 'reduces'; + if (operation.category === 'income' && (operation.payment?.value ?? 0) > 0) return 'adds'; + + const paymentValue = operation.payment?.value ?? 0; + if (paymentValue > 0) return 'adds'; + if (paymentValue < 0) return 'reduces'; + + return 'unknown'; +} + +export function getBrokerOperationImpactLabel(impact: BrokerOperationImpact): string { + switch (impact) { + case 'adds': + return 'Пополняет'; + case 'reduces': + return 'Списывает'; + case 'neutral': + return 'Перекладка'; + case 'unknown': + return 'Неясно'; + } +} +``` + +- [ ] **Step 4: Run helper tests and verify success** + +Run: + +```bash +npx vitest run src/pages/broker/brokerDisplay.test.ts -w apps/frontend +``` + +Expected: PASS for all `broker display helpers` tests. + +- [ ] **Step 5: Commit helper changes** + +Run: + +```bash +git add apps/frontend/src/pages/broker/brokerDisplay.ts apps/frontend/src/pages/broker/brokerDisplay.test.ts +git commit -m "feat: add broker display helpers" +``` + +Expected: commit succeeds. + +## Task 2: Broker Position Tables + +**Files:** + +- Create: `apps/frontend/src/pages/broker/BrokerPositionsSection.tsx` +- Modify: `apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx` +- Modify: `apps/frontend/src/pages/broker/BrokerPages.test.tsx` + +- [ ] **Step 1: Add failing page test for split position tables** + +Append this test to `apps/frontend/src/pages/broker/BrokerPages.test.tsx` inside `describe('Broker pages', () => { ... })`: + +```tsx +it('renders broker positions as separate linked stock and bond tables with current price', () => { + vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ + data: { + account: { + id: 'acc-1', + type: 'brokerage', + name: 'Broker', + status: 'ACCOUNT_STATUS_OPEN', + openedAt: null, + accessLevel: null, + }, + totals: { portfolio: { currency: 'RUB', units: '10000', nano: 0, value: 10000 } }, + yields: { expectedPercent: 5, daily: null, dailyPercent: null }, + cash: [], + blockedCash: [], + positions: [ + { + figi: null, + instrumentUid: 'share-uid', + positionUid: null, + ticker: 'SBER', + classCode: 'TQBR', + instrumentType: 'share', + name: 'Sberbank', + quantity: 10, + blockedLots: null, + currentPrice: { currency: 'RUB', units: '250', nano: 0, value: 250 }, + currentValue: { currency: 'RUB', units: '2500', nano: 0, value: 2500 }, + averagePositionPrice: null, + expectedYieldPercent: 20, + dailyYield: null, + }, + { + figi: null, + instrumentUid: 'bond-uid', + positionUid: null, + ticker: 'SU26238RMFS5', + classCode: 'TQOB', + instrumentType: 'bond', + name: 'ОФЗ 26238', + quantity: 2, + blockedLots: null, + currentPrice: { currency: 'RUB', units: '900', nano: 0, value: 900 }, + currentValue: { currency: 'RUB', units: '1800', nano: 0, value: 1800 }, + averagePositionPrice: null, + expectedYieldPercent: 10, + dailyYield: null, + }, + ], + asOf: '2026-06-17T00:00:00.000Z', + }, + isLoading: false, + error: null, + } as any); + vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({ + data: { + accountId: 'acc-1', + items: [], + nextCursor: null, + hasNext: false, + asOf: '2026-06-17T00:00:00.000Z', + }, + isLoading: false, + error: null, + } as any); + + renderWithClient( + + } /> + , + ['/broker/acc-1'], + ); + + expect(screen.getByRole('heading', { name: 'Акции' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Облигации' })).toBeInTheDocument(); + expect(screen.queryByRole('columnheader', { name: 'Доходность' })).not.toBeInTheDocument(); + expect(screen.getAllByRole('columnheader', { name: 'Цена' })).toHaveLength(2); + expect(screen.getByRole('link', { name: 'SBER' })).toHaveAttribute('href', '/stocks/SBER'); + expect(screen.getByRole('link', { name: 'SU26238RMFS5' })).toHaveAttribute( + 'href', + '/bonds/SU26238RMFS5', + ); + expect(screen.getByText(/250,00/)).toBeInTheDocument(); + expect(screen.getByText(/900,00/)).toBeInTheDocument(); +}); +``` + +- [ ] **Step 2: Run page test and verify failure** + +Run: + +```bash +npx vitest run src/pages/broker/BrokerPages.test.tsx -w apps/frontend +``` + +Expected: FAIL because the current page renders one `Позиции` table, has `Доходность`, and does not link tickers. + +- [ ] **Step 3: Create the positions component** + +Create `apps/frontend/src/pages/broker/BrokerPositionsSection.tsx`: + +```tsx +import { Link } from 'react-router-dom'; +import type { BrokerMoney, BrokerPosition } from '../../api/responses'; +import { getBrokerInstrumentPath, getBrokerPositionGroup } from './brokerDisplay'; + +type BrokerPositionGroupConfig = { + key: 'shares' | 'bonds' | 'other'; + title: string; +}; + +const GROUPS: BrokerPositionGroupConfig[] = [ + { key: 'shares', title: 'Акции' }, + { key: 'bonds', title: 'Облигации' }, + { key: 'other', title: 'Другие инструменты' }, +]; + +const tableStyle = { + width: '100%', + borderCollapse: 'collapse', + fontSize: 14, +} satisfies React.CSSProperties; + +const thStyle = { + borderBottom: '1px solid #e0e0e0', + color: 'var(--color-text-secondary)', + fontWeight: 600, + padding: '10px 8px', +} satisfies React.CSSProperties; + +const tdStyle = { + borderBottom: '1px solid #eeeeee', + padding: '10px 8px', + verticalAlign: 'top', +} satisfies React.CSSProperties; + +function formatMoney(value: BrokerMoney | null | undefined) { + if (!value) return '-'; + + return new Intl.NumberFormat('ru-RU', { + style: 'currency', + currency: value.currency || 'RUB', + maximumFractionDigits: 2, + }).format(value.value); +} + +function formatQuantity(value: number | null | undefined) { + return value == null ? '-' : value.toLocaleString('ru-RU'); +} + +function PositionTicker({ position }: { position: BrokerPosition }) { + const label = position.ticker || position.figi || '-'; + const path = getBrokerInstrumentPath({ + ticker: position.ticker, + instrumentType: position.instrumentType, + classCode: position.classCode, + }); + + if (!path || label === '-') { + return {label}; + } + + return ( + + {label} + + ); +} + +function PositionTable({ title, positions }: { title: string; positions: BrokerPosition[] }) { + return ( +
+

{title}

+
+ + + + + + + + + + + + {positions.map((position) => ( + + + + + + + + ))} + +
+ Тикер + + Название + + Количество + + Цена + + Стоимость +
+ + + + {position.name || '-'} + + + {formatQuantity(position.quantity)} + + {formatMoney(position.currentPrice)} + + {formatMoney(position.currentValue)} +
+
+
+ ); +} + +export function BrokerPositionsSection({ positions }: { positions: BrokerPosition[] }) { + const grouped = GROUPS.map((group) => ({ + ...group, + positions: positions.filter((position) => getBrokerPositionGroup(position) === group.key), + })).filter((group) => group.positions.length > 0); + + if (grouped.length === 0) { + return ( +
+

Позиции

+

В портфеле нет позиций

+
+ ); + } + + return ( +
+

Позиции

+
+ {grouped.map((group) => ( + + ))} +
+
+ ); +} +``` + +- [ ] **Step 4: Replace inline positions table in the page** + +Modify `apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx`: + +```tsx +import { useParams } from 'react-router-dom'; +import type { BrokerMoney } from '../../api/responses'; +import { useBrokerOperations } from '../../hooks/useBrokerOperations'; +import { useBrokerPortfolio } from '../../hooks/useBrokerPortfolio'; +import { BrokerPositionsSection } from './BrokerPositionsSection'; +``` + +Replace the entire current `
` with heading `Позиции` and its inline table with: + +```tsx + +``` + +Remove `tableStyle`, `thStyle` and `tdStyle` from `BrokerAccountDetailPage.tsx` after the inline +positions table is deleted. These constants move into the table components and must not remain unused. + +- [ ] **Step 5: Run page test and verify success** + +Run: + +```bash +npx vitest run src/pages/broker/BrokerPages.test.tsx -w apps/frontend +``` + +Expected: PASS for the new split positions test and existing broker page tests. + +- [ ] **Step 6: Commit position table changes** + +Run: + +```bash +git add apps/frontend/src/pages/broker/BrokerPositionsSection.tsx apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx apps/frontend/src/pages/broker/BrokerPages.test.tsx +git commit -m "feat: split broker position tables" +``` + +Expected: commit succeeds. + +## Task 3: Broker Operations Table and Cursor Pagination + +**Files:** + +- Create: `apps/frontend/src/pages/broker/BrokerOperationsTable.tsx` +- Modify: `apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx` +- Modify: `apps/frontend/src/pages/broker/BrokerPages.test.tsx` + +- [ ] **Step 1: Add failing test for operation labels, links and impact badges** + +Append this test to `apps/frontend/src/pages/broker/BrokerPages.test.tsx`: + +```tsx +it('renders broker operations with Russian labels, linked instruments and impact badges', () => { + vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ + data: { + account: { + id: 'acc-1', + type: 'brokerage', + name: 'Broker', + status: 'ACCOUNT_STATUS_OPEN', + openedAt: null, + accessLevel: null, + }, + totals: { portfolio: { currency: 'RUB', units: '10000', nano: 0, value: 10000 } }, + yields: { expectedPercent: null, daily: null, dailyPercent: null }, + cash: [], + blockedCash: [], + positions: [], + asOf: '2026-06-17T00:00:00.000Z', + }, + isLoading: false, + error: null, + } as any); + vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({ + data: { + accountId: 'acc-1', + items: [ + { + cursor: 'op-1', + accountId: 'acc-1', + id: 'op-1', + parentOperationId: null, + date: '2026-06-17T10:00:00.000Z', + category: 'income', + type: 'OPERATION_TYPE_COUPON', + description: 'Coupon', + state: 'OPERATION_STATE_EXECUTED', + instrumentUid: 'bond-uid', + figi: null, + ticker: 'SU26238RMFS5', + classCode: 'TQOB', + instrumentType: 'bond', + payment: { currency: 'RUB', units: '120', nano: 0, value: 120 }, + price: null, + commission: null, + yield: null, + accruedInt: null, + quantity: 2, + quantityDone: 2, + }, + { + cursor: 'op-2', + accountId: 'acc-1', + id: 'op-2', + parentOperationId: null, + date: '2026-06-17T11:00:00.000Z', + category: 'tax', + type: 'OPERATION_TYPE_TAX', + description: 'Tax', + state: 'OPERATION_STATE_EXECUTED', + instrumentUid: null, + figi: null, + ticker: null, + classCode: null, + instrumentType: null, + payment: { currency: 'RUB', units: '-13', nano: 0, value: -13 }, + price: null, + commission: null, + yield: null, + accruedInt: null, + quantity: null, + quantityDone: null, + }, + ], + nextCursor: null, + hasNext: false, + asOf: '2026-06-17T00:00:00.000Z', + }, + isLoading: false, + error: null, + } as any); + + renderWithClient( + + } /> + , + ['/broker/acc-1'], + ); + + expect(screen.getByText('Выплата купона')).toBeInTheDocument(); + expect(screen.getByText('Налог')).toBeInTheDocument(); + expect(screen.getByText('Пополняет')).toBeInTheDocument(); + expect(screen.getByText('Списывает')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'SU26238RMFS5' })).toHaveAttribute( + 'href', + '/bonds/SU26238RMFS5', + ); +}); +``` + +- [ ] **Step 2: Add failing test for cursor pagination** + +Add import at the top of `BrokerPages.test.tsx`: + +```tsx +import userEvent from '@testing-library/user-event'; +``` + +Append this test to `apps/frontend/src/pages/broker/BrokerPages.test.tsx`: + +```tsx +it('requests broker operations by cursor with a page size of 10', async () => { + const user = userEvent.setup(); + vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ + data: { + account: { + id: 'acc-1', + type: 'brokerage', + name: 'Broker', + status: 'ACCOUNT_STATUS_OPEN', + openedAt: null, + accessLevel: null, + }, + totals: { portfolio: { currency: 'RUB', units: '10000', nano: 0, value: 10000 } }, + yields: { expectedPercent: null, daily: null, dailyPercent: null }, + cash: [], + blockedCash: [], + positions: [], + asOf: '2026-06-17T00:00:00.000Z', + }, + isLoading: false, + error: null, + } as any); + const operationsSpy = vi.spyOn(operationsHook, 'useBrokerOperations').mockImplementation( + (_accountId, query) => + ({ + data: + query.cursor === 'cursor-page-2' + ? { + accountId: 'acc-1', + items: [ + { + cursor: 'op-page-2', + accountId: 'acc-1', + id: 'op-page-2', + parentOperationId: null, + date: '2026-06-17T12:00:00.000Z', + category: 'trade', + type: 'OPERATION_TYPE_SELL', + description: 'Sell', + state: 'OPERATION_STATE_EXECUTED', + instrumentUid: 'share-uid', + figi: null, + ticker: 'SBER', + classCode: 'TQBR', + instrumentType: 'share', + payment: { currency: 'RUB', units: '1000', nano: 0, value: 1000 }, + price: null, + commission: null, + yield: null, + accruedInt: null, + quantity: 1, + quantityDone: 1, + }, + ], + nextCursor: null, + hasNext: false, + asOf: '2026-06-17T00:00:00.000Z', + } + : { + accountId: 'acc-1', + items: [ + { + cursor: 'op-page-1', + accountId: 'acc-1', + id: 'op-page-1', + parentOperationId: null, + date: '2026-06-17T10:00:00.000Z', + category: 'trade', + type: 'OPERATION_TYPE_BUY', + description: 'Buy', + state: 'OPERATION_STATE_EXECUTED', + instrumentUid: 'share-uid', + figi: null, + ticker: 'SBER', + classCode: 'TQBR', + instrumentType: 'share', + payment: { currency: 'RUB', units: '-1000', nano: 0, value: -1000 }, + price: null, + commission: null, + yield: null, + accruedInt: null, + quantity: 1, + quantityDone: 1, + }, + ], + nextCursor: 'cursor-page-2', + hasNext: true, + asOf: '2026-06-17T00:00:00.000Z', + }, + isLoading: false, + error: null, + }) as any, + ); + + renderWithClient( + + } /> + , + ['/broker/acc-1'], + ); + + expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined }); + expect(screen.getByText('Страница 1')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Вперед' })); + + expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { + limit: 10, + cursor: 'cursor-page-2', + }); + expect(screen.getByText('Страница 2')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Назад' })); + + expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined }); + expect(screen.getByText('Страница 1')).toBeInTheDocument(); +}); +``` + +- [ ] **Step 3: Run page tests and verify failure** + +Run: + +```bash +npx vitest run src/pages/broker/BrokerPages.test.tsx -w apps/frontend +``` + +Expected: FAIL because operations still render raw enum values and `BrokerAccountDetailPage` calls `useBrokerOperations(accountId, { limit: 100 })`. + +- [ ] **Step 4: Create the operations table component** + +Create `apps/frontend/src/pages/broker/BrokerOperationsTable.tsx`: + +```tsx +import { Link } from 'react-router-dom'; +import type { BrokerMoney, BrokerOperation, BrokerOperationsPage } from '../../api/responses'; +import { + getBrokerInstrumentPath, + getBrokerOperationImpact, + getBrokerOperationImpactLabel, + getBrokerOperationTypeLabel, + type BrokerOperationImpact, +} from './brokerDisplay'; + +const tableStyle = { + width: '100%', + borderCollapse: 'collapse', + fontSize: 14, +} satisfies React.CSSProperties; + +const thStyle = { + borderBottom: '1px solid #e0e0e0', + color: 'var(--color-text-secondary)', + fontWeight: 600, + padding: '10px 8px', +} satisfies React.CSSProperties; + +const tdStyle = { + borderBottom: '1px solid #eeeeee', + padding: '10px 8px', + verticalAlign: 'top', +} satisfies React.CSSProperties; + +const impactStyles: Record = { + adds: { + background: 'rgba(46, 125, 50, 0.1)', + color: 'var(--color-positive)', + }, + reduces: { + background: 'rgba(198, 40, 40, 0.1)', + color: 'var(--color-negative)', + }, + neutral: { + background: 'rgba(25, 118, 210, 0.1)', + color: 'var(--color-primary)', + }, + unknown: { + background: 'rgba(102, 102, 102, 0.12)', + color: 'var(--color-text-secondary)', + }, +}; + +function formatMoney(value: BrokerMoney | null | undefined) { + if (!value) return '-'; + + return new Intl.NumberFormat('ru-RU', { + style: 'currency', + currency: value.currency || 'RUB', + maximumFractionDigits: 2, + }).format(value.value); +} + +function formatDate(value: string | null) { + if (!value) return '-'; + + return new Date(value).toLocaleString('ru-RU'); +} + +function moneyColor(impact: BrokerOperationImpact): string { + if (impact === 'adds') return 'var(--color-positive)'; + if (impact === 'reduces') return 'var(--color-negative)'; + return 'var(--color-text)'; +} + +function OperationInstrument({ operation }: { operation: BrokerOperation }) { + const label = operation.ticker || operation.description || '-'; + const path = getBrokerInstrumentPath({ + ticker: operation.ticker, + instrumentType: operation.instrumentType, + classCode: operation.classCode, + }); + + if (!path || label === '-') { + return {label}; + } + + return {label}; +} + +function OperationType({ operation }: { operation: BrokerOperation }) { + const impact = getBrokerOperationImpact(operation); + + return ( +
+ {getBrokerOperationTypeLabel(operation)} + + {getBrokerOperationImpactLabel(impact)} + +
+ ); +} + +export function BrokerOperationsTable({ + isLoading, + page, + pageNumber, + canGoBack, + canGoForward, + onPrevious, + onNext, +}: { + isLoading: boolean; + page: BrokerOperationsPage | undefined; + pageNumber: number; + canGoBack: boolean; + canGoForward: boolean; + onPrevious: () => void; + onNext: () => void; +}) { + const operations = page?.items ?? []; + + return ( +
+
+

Операции

+
+ + + Страница {pageNumber} + + +
+
+ + {isLoading ? ( +

Загрузка операций...

+ ) : operations.length === 0 ? ( +

Операций за выбранный период нет

+ ) : ( +
+ + + + + + + + + + + {operations.map((operation) => { + const impact = getBrokerOperationImpact(operation); + + return ( + + + + + + + ); + })} + +
+ Дата + + Тип + + Инструмент + + Сумма +
{formatDate(operation.date)} + + + + + {formatMoney(operation.payment)} +
+
+ )} +
+ ); +} +``` + +- [ ] **Step 5: Add cursor pagination state to the page** + +Modify `apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx`. + +Update imports: + +```tsx +import { useState } from 'react'; +import { useParams } from 'react-router-dom'; +import type { BrokerMoney } from '../../api/responses'; +import { useBrokerOperations } from '../../hooks/useBrokerOperations'; +import { useBrokerPortfolio } from '../../hooks/useBrokerPortfolio'; +import { BrokerOperationsTable } from './BrokerOperationsTable'; +import { BrokerPositionsSection } from './BrokerPositionsSection'; +``` + +Inside `BrokerAccountDetailPage`, before calling `useBrokerOperations`, add: + +```tsx +const [operationCursor, setOperationCursor] = useState(undefined); +const [operationCursorStack, setOperationCursorStack] = useState>([]); +``` + +Replace the existing operations hook call: + +```tsx +const operations = useBrokerOperations(accountId, { + limit: 10, + cursor: operationCursor, +}); +``` + +Add handlers after error/loading guards or before `return`: + +```tsx +function handleNextOperationsPage() { + const nextCursor = operations.data?.nextCursor; + if (!nextCursor || !operations.data?.hasNext) return; + + setOperationCursorStack((previous) => [...previous, operationCursor]); + setOperationCursor(nextCursor); +} + +function handlePreviousOperationsPage() { + if (operationCursorStack.length === 0) return; + + const nextStack = operationCursorStack.slice(0, -1); + const previousCursor = operationCursorStack[operationCursorStack.length - 1]; + setOperationCursorStack(nextStack); + setOperationCursor(previousCursor); +} +``` + +Remove `formatDate` from `BrokerAccountDetailPage.tsx` after the inline operations table is deleted. +Date formatting now belongs to `BrokerOperationsTable.tsx`. + +Replace the current inline operations `
` with: + +```tsx + 0} + canGoForward={Boolean(operations.data?.hasNext && operations.data.nextCursor)} + onPrevious={handlePreviousOperationsPage} + onNext={handleNextOperationsPage} +/> +``` + +- [ ] **Step 6: Run page tests and verify success** + +Run: + +```bash +npx vitest run src/pages/broker/BrokerPages.test.tsx -w apps/frontend +``` + +Expected: PASS for operation labels, links, impact badges and pagination tests. + +- [ ] **Step 7: Commit operations table changes** + +Run: + +```bash +git add apps/frontend/src/pages/broker/BrokerOperationsTable.tsx apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx apps/frontend/src/pages/broker/BrokerPages.test.tsx +git commit -m "feat: improve broker operations table" +``` + +Expected: commit succeeds. + +## Task 4: Regression Verification + +**Files:** + +- No source file changes in this task. + +- [ ] **Step 1: Run focused frontend tests** + +Run: + +```bash +npx vitest run src/pages/broker/brokerDisplay.test.ts src/pages/broker/BrokerPages.test.tsx -w apps/frontend +``` + +Expected: PASS for helper and broker page tests. + +- [ ] **Step 2: Run the full frontend test suite** + +Run: + +```bash +npm run test:frontend +``` + +Expected: PASS for the frontend Vitest suite. + +- [ ] **Step 3: Run frontend build** + +Run: + +```bash +npm run build:frontend +``` + +Expected: TypeScript build and Vite build complete successfully. + +- [ ] **Step 4: Run lint** + +Run: + +```bash +npm run lint +``` + +Expected: ESLint completes without errors for backend and frontend workspaces. + +- [ ] **Step 5: Commit verification-only adjustments if tests required small fixes** + +If verification revealed small issues and the fixes are already applied, run: + +```bash +git add apps/frontend/src/pages/broker +git commit -m "test: cover broker portfolio display" +``` + +Expected: commit succeeds when there are verification fixes. If there are no additional changes, this step is skipped. + +## Task 5: Review Handoff + +**Files:** + +- No source file changes in this task. + +- [ ] **Step 1: Summarize final diff** + +Run: + +```bash +git status --short +git log --oneline -3 +``` + +Expected: working tree contains only intentional changes for this feature, and recent commits correspond to helpers, positions and operations. + +- [ ] **Step 2: Request code review** + +Use `superpowers:requesting-code-review` before merging or opening a PR. Ask the reviewer to focus on: + +- cursor stack behavior when navigating back and forward; +- operation impact classification for trades, taxes, fees, coupons and transfers; +- accessibility of impact badges when color is not visible; +- whether helper tests cover unknown operation types and missing tickers. + +- [ ] **Step 3: Choose execution mode for this plan** + +Recommended execution mode: Subagent-Driven. + +Subagent split: + +- Subagent 1: Task 1 helpers and unit tests. +- Subagent 2: Task 2 positions component and page tests. +- Subagent 3: Task 3 operations component and pagination tests. +- Main agent: Task 4 verification and Task 5 review handoff. + +Inline execution is also acceptable if branch state or local context makes subagent handoff less efficient. diff --git a/docs/superpowers/specs/2026-06-17-broker-portfolio-display.md b/docs/superpowers/specs/2026-06-17-broker-portfolio-display.md new file mode 100644 index 0000000..8407660 --- /dev/null +++ b/docs/superpowers/specs/2026-06-17-broker-portfolio-display.md @@ -0,0 +1,165 @@ +# Улучшение отображения брокерского портфеля + +Дата: 2026-06-17 +Статус: согласовано к планированию + +## Контекст + +Страница брокерского счета находится в `apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx`. +Сейчас она показывает все позиции одной таблицей, выводит колонку `Доходность`, не показывает отдельную +колонку текущей цены, не делает тикеры кликабельными и загружает операции одним запросом с `limit: 100`. + +Данные для страницы уже есть в текущем frontend-контракте: + +- `BrokerPosition.instrumentType`, `ticker`, `name`, `quantity`, `currentPrice`, `currentValue`; +- `BrokerOperation.type`, `category`, `description`, `ticker`, `instrumentType`, `payment`, `price`; +- `BrokerOperationsPage.nextCursor` и `hasNext` для cursor-пагинации. + +Бэкенд и публичный API для этой задачи менять не нужно. + +## Цель + +Сделать страницу брокерского портфеля легче для чтения: разделить классы инструментов, убрать +лишнюю доходность из таблицы позиций, добавить текущую цену, сделать переходы к карточкам инструментов +и явно показать финансовый смысл операций. + +## Область изменений + +В рамках задачи меняется только frontend брокерской страницы: + +- `apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx`; +- новые helper-функции и компоненты внутри `apps/frontend/src/pages/broker/`; +- тесты в `apps/frontend/src/pages/broker/` и существующий `BrokerPages.test.tsx`. + +`apps/docs`, backend DTO, OpenAPI и codegen не меняются. + +## Позиции + +Позиции брокерского портфеля показываются отдельными секциями: + +- `Акции` для `instrumentType === 'share'`; +- `Облигации` для `instrumentType === 'bond'`; +- `Другие инструменты`, если в портфеле есть позиции с другим или неизвестным типом. + +Пустые секции не отображаются. + +Таблица каждой секции использует компактные колонки: + +| Колонка | Источник | Поведение | +| ---------- | -------------- | ---------------------------------------------------------- | +| Тикер | `ticker` | Если известен маршрут инструмента, тикер является ссылкой. | +| Название | `name` | Если имени нет, показывается `-`. | +| Количество | `quantity` | Если количества нет, показывается `-`. | +| Цена | `currentPrice` | Деньги форматируются как `ru-RU` currency. | +| Стоимость | `currentValue` | Деньги форматируются как `ru-RU` currency. | + +Колонка `Доходность` удаляется из таблиц позиций брокерского портфеля. Доходность остается доступной +в summary над таблицами, где она не смешивается с построчным составом портфеля. + +### Ссылки на инструменты + +Маршрутизация повторяет ручной портфель: + +- акция: `/stocks/:ticker`; +- облигация: `/bonds/:ticker`. + +Если `ticker` отсутствует или тип инструмента не поддержан, значение показывается текстом без ссылки. +Для неизвестного `instrumentType` допускается fallback по `classCode`, если он однозначно указывает +на акцию или облигацию. + +## Операции + +Операции показываются по 10 штук на странице. Страница использует существующий cursor API: + +- первый запрос: `{ limit: 10 }`; +- переход вперед: `{ limit: 10, cursor: nextCursor }`; +- переход назад: восстановление предыдущего cursor из локального stack в UI. + +UI показывает номер текущей страницы, кнопку `Назад` и кнопку `Вперед`. `Назад` выключена на первой +странице. `Вперед` выключена, когда `hasNext === false` или нет `nextCursor`. + +Таблица операций сохраняет основные колонки: + +| Колонка | Источник | Поведение | +| ---------- | --------------------------------- | ------------------------------------------------------------- | +| Дата | `date` | `toLocaleString('ru-RU')`, при отсутствии `-`. | +| Тип | `type`, `description`, `category` | Русскоязычное название и бейдж эффекта. | +| Инструмент | `ticker`, `description` | Если известен маршрут инструмента, значение является ссылкой. | +| Сумма | `payment` | Форматируется как деньги и окрашивается по эффекту операции. | + +### Русские названия типов + +Для известных T-Bank enum-значений frontend показывает русские названия. Минимальный набор: + +| T-Bank type | Название | +| ------------------------------------ | -------------------------- | +| `OPERATION_TYPE_BUY` | Покупка | +| `OPERATION_TYPE_BUY_CARD` | Покупка | +| `OPERATION_TYPE_SELL` | Продажа | +| `OPERATION_TYPE_SELL_CARD` | Продажа | +| `OPERATION_TYPE_COUPON` | Выплата купона | +| `OPERATION_TYPE_DIVIDEND` | Дивиденды | +| `OPERATION_TYPE_BOND_REPAYMENT` | Погашение облигации | +| `OPERATION_TYPE_BOND_REPAYMENT_FULL` | Полное погашение облигации | +| `OPERATION_TYPE_TAX` | Налог | +| `OPERATION_TYPE_BOND_TAX` | Налог по облигациям | +| `OPERATION_TYPE_DIVIDEND_TAX` | Налог на дивиденды | +| `OPERATION_TYPE_BROKER_FEE` | Комиссия брокера | +| `OPERATION_TYPE_SERVICE_FEE` | Комиссия за обслуживание | +| `OPERATION_TYPE_INPUT` | Пополнение | +| `OPERATION_TYPE_OUTPUT` | Вывод средств | +| `OPERATION_TYPE_INPUT_SECURITIES` | Зачисление бумаг | +| `OPERATION_TYPE_OUTPUT_SECURITIES` | Списание бумаг | + +Если тип неизвестен, но есть `description`, показывается `description`. Если нет и описания, +показывается очищенный enum без префикса `OPERATION_TYPE_`. + +### Эффект операции + +Выбранный дизайн: бейдж эффекта в колонке `Тип` плюс цвет суммы. + +Эффект определяется не только знаком суммы, потому что сделки не являются доходом сами по себе: + +- `Пополнение` и доходы вроде купонов или дивидендов получают эффект `Пополняет`; +- налоги, комиссии и вывод средств получают эффект `Списывает`; +- покупки, продажи, ввод/вывод бумаг и погашение тела облигации получают эффект `Перекладка`; +- неизвестные операции получают эффект `Неясно`. + +Цвета используются как вспомогательный признак, а текст бейджа остается основным признаком для +доступности: + +- `Пополняет`: зеленый акцент; +- `Списывает`: красный акцент; +- `Перекладка`: нейтральный или синий акцент; +- `Неясно`: приглушенный серый акцент. + +## Ошибки и пустые состояния + +- Если портфель не загрузился, остается текущее сообщение об ошибке портфеля. +- Если операции загружаются, показывается `Загрузка операций...`. +- Если операций нет, показывается пустое состояние `Операций за выбранный период нет`. +- Если у позиции или операции нет тикера, вместо ссылки показывается текстовое значение или `-`. + +## TDD-стратегия + +Сначала пишутся failing tests: + +1. Helper-тесты для группировки позиций, маршрутов инструментов, русских названий операций и эффекта операций. +2. Component/page-тесты для раздельных таблиц акций и облигаций, отсутствия колонки `Доходность` и наличия `Цена`. +3. Component/page-тесты для кликабельных инструментов в позициях и операциях. +4. Component/page-тесты для cursor-пагинации операций по 10 элементов. + +Затем реализуются helper-функции, компоненты таблиц и интеграция в `BrokerAccountDetailPage`. + +## Acceptance Criteria + +- Позиции акций и облигаций отображаются в отдельных таблицах. +- Позиции с другим типом отображаются в `Другие инструменты`, если такие позиции есть. +- В таблицах позиций нет колонки `Доходность`. +- В таблицах позиций есть колонка `Цена`. +- Тикеры позиций ведут на `/stocks/:ticker` или `/bonds/:ticker`. +- Операции запрашиваются с `limit: 10`. +- Пользователь может переходить вперед и назад по cursor-страницам операций. +- В операциях колонка `Инструмент` ведет на страницу акции или облигации, если известен тип инструмента. +- В операциях колонка `Тип` показывает русскоязычные названия. +- В операциях визуально понятно, пополняет операция портфель, списывает средства, является перекладкой или не классифицирована.