diff --git a/apps/backend/src/modules/tbank/dto/broker-analytics-response.dto.ts b/apps/backend/src/modules/tbank/dto/broker-analytics-response.dto.ts index 6aa9a89..f97d971 100644 --- a/apps/backend/src/modules/tbank/dto/broker-analytics-response.dto.ts +++ b/apps/backend/src/modules/tbank/dto/broker-analytics-response.dto.ts @@ -19,6 +19,12 @@ export class BrokerAnalyticsDto { @ApiProperty() totalReceived!: number; + @ApiProperty() + totalFees!: number; + + @ApiProperty() + totalTaxesPaid!: number; + @ApiProperty({ type: Number, nullable: true }) totalReturnPercent!: number | null; diff --git a/apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts b/apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts index cadf06f..5be9703 100644 --- a/apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts +++ b/apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts @@ -7,6 +7,7 @@ import { BrokerOperationsPageResponseDto } from './broker-operation-response.dto import { BrokerPositionsPageResponseDto } from './broker-positions-page-response.dto'; import { BrokerPortfolioResponseDto } from './broker-portfolio-response.dto'; import { BrokerAnalyticsDto } from './broker-analytics-response.dto'; +import { BrokerPortfolioHistoryDataDto } from './broker-portfolio-history-response.dto'; export class BrokerAccountsEnvelopeDto { @ApiProperty({ type: [BrokerAccountResponseDto] }) @@ -63,3 +64,11 @@ export class BrokerEventsEnvelopeDto { @ApiProperty({ type: ApiResponseMeta }) meta!: ApiResponseMeta; } + +export class BrokerPortfolioHistoryEnvelopeDto { + @ApiProperty({ type: BrokerPortfolioHistoryDataDto }) + data!: BrokerPortfolioHistoryDataDto; + + @ApiProperty({ type: ApiResponseMeta }) + meta!: ApiResponseMeta; +} diff --git a/apps/backend/src/modules/tbank/dto/broker-operation-query.dto.ts b/apps/backend/src/modules/tbank/dto/broker-operation-query.dto.ts index a56cc8f..ea9682f 100644 --- a/apps/backend/src/modules/tbank/dto/broker-operation-query.dto.ts +++ b/apps/backend/src/modules/tbank/dto/broker-operation-query.dto.ts @@ -40,4 +40,9 @@ export class BrokerOperationQueryDto { @IsOptional() @IsString() state?: string; + + @ApiPropertyOptional({ description: 'Comma-separated category filter: trade,income,tax,fee,transfer,other' }) + @IsOptional() + @IsString() + categories?: string; } diff --git a/apps/backend/src/modules/tbank/dto/broker-portfolio-history-response.dto.ts b/apps/backend/src/modules/tbank/dto/broker-portfolio-history-response.dto.ts new file mode 100644 index 0000000..1334773 --- /dev/null +++ b/apps/backend/src/modules/tbank/dto/broker-portfolio-history-response.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { BrokerMoneyDto } from './broker-money.dto'; + +export class BrokerPortfolioHistoryPointDto { + @ApiProperty() + month!: string; + + @ApiProperty() + label!: string; + + @ApiProperty({ type: BrokerMoneyDto }) + value!: BrokerMoneyDto; +} + +export class BrokerPortfolioHistoryDataDto { + @ApiProperty() + accountId!: string; + + @ApiProperty({ type: [BrokerPortfolioHistoryPointDto] }) + points!: BrokerPortfolioHistoryPointDto[]; + + @ApiProperty() + asOf!: string; +} diff --git a/apps/backend/src/modules/tbank/services/broker-analytics.service.spec.ts b/apps/backend/src/modules/tbank/services/broker-analytics.service.spec.ts index 9188bc3..5e5c9d2 100644 --- a/apps/backend/src/modules/tbank/services/broker-analytics.service.spec.ts +++ b/apps/backend/src/modules/tbank/services/broker-analytics.service.spec.ts @@ -2,11 +2,15 @@ import { CacheService } from '../../cache/cache.service'; import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception'; import { BrokerAccountsService } from './broker-accounts.service'; import { BrokerAnalyticsService } from './broker-analytics.service'; -import { PrismaService } from '../../prisma/prisma.service'; +import { TBankClientService } from './tbank-client.service'; +import type { TBankOperationsByCursorResponse, TBankPortfolioResponse, TBankOperationItem } from '../types/tbank-proto.types'; describe('BrokerAnalyticsService', () => { const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService; - const prisma = { brokerOperation: { findMany: vi.fn() } } as unknown as PrismaService; + const tbankClient = { + getOperationsClient: vi.fn(), + callUnary: vi.fn(), + } as unknown as TBankClientService; const cache = { getOrFetch: vi.fn() } as unknown as CacheService; const acc1 = { @@ -18,6 +22,8 @@ describe('BrokerAnalyticsService', () => { accessLevel: null, }; + const mockClient = { getPortfolio: vi.fn(), getOperationsByCursor: vi.fn() }; + function mockCachePassthrough() { vi.mocked(cache.getOrFetch).mockImplementation( async (_prefix: string, _parts: string[], fetchFn: () => Promise) => ({ @@ -28,27 +34,55 @@ describe('BrokerAnalyticsService', () => { ); } - function makeOp(type: string, value: number, state?: string | null) { - return { type, payment: JSON.stringify({ value, currency: 'RUB' }), state } as any; + function mockPortfolio(expectedYield?: { units?: number | string; nano?: number }): TBankPortfolioResponse { + const response: TBankPortfolioResponse = { accountId: 'acc-1' }; + if (expectedYield) response.expectedYield = expectedYield; + return response; + } + + function makeItem(type: string, value: number, state = 'OPERATION_STATE_EXECUTED'): TBankOperationItem { + return { + type, + payment: { currency: 'RUB', units: Math.floor(Math.abs(value)), nano: Math.round((Math.abs(value) % 1) * 1e9) }, + state, + id: `${type}-${value}`, + cursor: '', + brokerAccountId: 'acc-1', + }; + } + + function mockOpsResponse(items: TBankOperationItem[], hasNext = false, nextCursor = ''): TBankOperationsByCursorResponse { + return { items, hasNext, nextCursor }; + } + + function setupMocks(ops: TBankOperationItem[], portfolio?: TBankPortfolioResponse) { + vi.mocked(tbankClient.callUnary).mockImplementation( + async (label: string) => { + if (label.includes('GetPortfolio')) return (portfolio ?? mockPortfolio()) as any; + if (label.includes('GetOperationsByCursor')) return mockOpsResponse(ops) as any; + throw new Error(`Unexpected call: ${label}`); + }, + ); } beforeEach(() => { vi.clearAllMocks(); + vi.mocked(tbankClient.getOperationsClient).mockReturnValue(mockClient as any); }); it('throws 404 for missing account', async () => { vi.mocked(accounts.findById).mockResolvedValue(null); - const service = new BrokerAnalyticsService(prisma, accounts, cache); + const service = new BrokerAnalyticsService(accounts, tbankClient, cache); await expect(service.getAnalytics('missing')).rejects.toThrow(EntityNotFoundException); }); it('returns zeros for account with no operations', async () => { vi.mocked(accounts.findById).mockResolvedValue(acc1); mockCachePassthrough(); - vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([]); + setupMocks([]); - const service = new BrokerAnalyticsService(prisma, accounts, cache); + const service = new BrokerAnalyticsService(accounts, tbankClient, cache); const result = await service.getAnalytics('acc-1'); expect(result.data).toEqual({ @@ -59,6 +93,8 @@ describe('BrokerAnalyticsService', () => { totalCoupons: 0, totalReceived: 0, totalReturnPercent: null, + totalFees: 0, + totalTaxesPaid: 0, currency: 'RUB', }); }); @@ -66,17 +102,17 @@ describe('BrokerAnalyticsService', () => { it('aggregates deposit types correctly', async () => { vi.mocked(accounts.findById).mockResolvedValue(acc1); mockCachePassthrough(); - vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([ - makeOp('OPERATION_TYPE_INPUT', 1000), - makeOp('OPERATION_TYPE_INPUT_SWIFT', 500), - makeOp('OPERATION_TYPE_INP_MULTI', 200), - makeOp('OPERATION_TYPE_OVER_PLACEMENT', 300), - makeOp('OPERATION_TYPE_TRANS_IIS_BS', 100), - makeOp('OPERATION_TYPE_TRANS_BS_BS', 50), - makeOp('OPERATION_TYPE_INPUT_ACQUIRING', 150), + setupMocks([ + makeItem('OPERATION_TYPE_INPUT', 1000), + makeItem('OPERATION_TYPE_INPUT_SWIFT', 500), + makeItem('OPERATION_TYPE_INP_MULTI', 200), + makeItem('OPERATION_TYPE_OVER_PLACEMENT', 300), + makeItem('OPERATION_TYPE_TRANS_IIS_BS', 100), + makeItem('OPERATION_TYPE_TRANS_BS_BS', 50), + makeItem('OPERATION_TYPE_INPUT_ACQUIRING', 150), ]); - const service = new BrokerAnalyticsService(prisma, accounts, cache); + const service = new BrokerAnalyticsService(accounts, tbankClient, cache); const result = await service.getAnalytics('acc-1'); expect(result.data.totalDeposits).toBe(2300); @@ -87,15 +123,15 @@ describe('BrokerAnalyticsService', () => { it('aggregates withdrawal types with absolute value', async () => { vi.mocked(accounts.findById).mockResolvedValue(acc1); mockCachePassthrough(); - vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([ - makeOp('OPERATION_TYPE_OUTPUT', -500), - makeOp('OPERATION_TYPE_OUTPUT_SWIFT', -200), - makeOp('OPERATION_TYPE_OUTPUT_ACQUIRING', -100), - makeOp('OPERATION_TYPE_OUT_MULTI', -50), - makeOp('OPERATION_TYPE_INPUT', 1000), + setupMocks([ + makeItem('OPERATION_TYPE_OUTPUT', -500), + makeItem('OPERATION_TYPE_OUTPUT_SWIFT', -200), + makeItem('OPERATION_TYPE_OUTPUT_ACQUIRING', -100), + makeItem('OPERATION_TYPE_OUT_MULTI', -50), + makeItem('OPERATION_TYPE_INPUT', 1000), ]); - const service = new BrokerAnalyticsService(prisma, accounts, cache); + const service = new BrokerAnalyticsService(accounts, tbankClient, cache); const result = await service.getAnalytics('acc-1'); expect(result.data.totalDeposits).toBe(1000); @@ -106,14 +142,14 @@ describe('BrokerAnalyticsService', () => { it('aggregates dividend and coupon types', async () => { vi.mocked(accounts.findById).mockResolvedValue(acc1); mockCachePassthrough(); - vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([ - makeOp('OPERATION_TYPE_DIVIDEND', 300), - makeOp('OPERATION_TYPE_DIV_EXT', 150), - makeOp('OPERATION_TYPE_COUPON', 75), - makeOp('OPERATION_TYPE_COUPON', 25), + setupMocks([ + makeItem('OPERATION_TYPE_DIVIDEND', 300), + makeItem('OPERATION_TYPE_DIV_EXT', 150), + makeItem('OPERATION_TYPE_COUPON', 75), + makeItem('OPERATION_TYPE_COUPON', 25), ]); - const service = new BrokerAnalyticsService(prisma, accounts, cache); + const service = new BrokerAnalyticsService(accounts, tbankClient, cache); const result = await service.getAnalytics('acc-1'); expect(result.data.totalDividends).toBe(450); @@ -121,82 +157,64 @@ describe('BrokerAnalyticsService', () => { expect(result.data.totalReceived).toBe(550); }); - it('calculates totalReturnPercent correctly', async () => { + it('uses expectedYield from portfolio for totalReturnPercent', async () => { vi.mocked(accounts.findById).mockResolvedValue(acc1); mockCachePassthrough(); - vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([ - makeOp('OPERATION_TYPE_INPUT', 10000), - makeOp('OPERATION_TYPE_DIVIDEND', 500), - makeOp('OPERATION_TYPE_COUPON', 200), - ]); + setupMocks( + [ + makeItem('OPERATION_TYPE_INPUT', 10000), + makeItem('OPERATION_TYPE_DIVIDEND', 500), + makeItem('OPERATION_TYPE_COUPON', 200), + ], + mockPortfolio({ units: 7, nano: 0 }), + ); - const service = new BrokerAnalyticsService(prisma, accounts, cache); + const service = new BrokerAnalyticsService(accounts, tbankClient, cache); const result = await service.getAnalytics('acc-1'); - expect(result.data.netInvested).toBe(10000); - expect(result.data.totalReceived).toBe(700); expect(result.data.totalReturnPercent).toBe(7); }); - it('returns null totalReturnPercent when netInvested <= 0', async () => { + it('returns null totalReturnPercent when portfolio has no expectedYield', async () => { vi.mocked(accounts.findById).mockResolvedValue(acc1); mockCachePassthrough(); - vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([ - makeOp('OPERATION_TYPE_OUTPUT', -500), - makeOp('OPERATION_TYPE_DIVIDEND', 100), - ]); + setupMocks([makeItem('OPERATION_TYPE_OUTPUT', -500)]); - const service = new BrokerAnalyticsService(prisma, accounts, cache); + const service = new BrokerAnalyticsService(accounts, tbankClient, cache); const result = await service.getAnalytics('acc-1'); - expect(result.data.netInvested).toBe(-500); expect(result.data.totalReturnPercent).toBeNull(); }); - it('handles malformed payment JSON gracefully', async () => { + it('aggregates fee and tax categories from operations', async () => { vi.mocked(accounts.findById).mockResolvedValue(acc1); mockCachePassthrough(); - vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([ - { type: 'OPERATION_TYPE_INPUT', payment: 'invalid-json', state: 'OPERATION_STATE_EXECUTED' }, - { type: 'OPERATION_TYPE_INPUT', payment: JSON.stringify({ value: 500, currency: 'RUB' }), state: 'OPERATION_STATE_EXECUTED' }, - ] as any); + setupMocks([ + makeItem('OPERATION_TYPE_SERVICE_FEE', -100), + makeItem('OPERATION_TYPE_BROKER_FEE', -50), + makeItem('OPERATION_TYPE_TAX', -200), + makeItem('OPERATION_TYPE_DIVIDEND_TAX', -30), + makeItem('OPERATION_TYPE_INPUT', 1000), + ]); - const service = new BrokerAnalyticsService(prisma, accounts, cache); + const service = new BrokerAnalyticsService(accounts, tbankClient, cache); const result = await service.getAnalytics('acc-1'); - expect(result.data.totalDeposits).toBe(500); - }); - - it('ignores non-executed and non-null state operations', async () => { - vi.mocked(accounts.findById).mockResolvedValue(acc1); - mockCachePassthrough(); - - const service = new BrokerAnalyticsService(prisma, accounts, cache); - await service.getAnalytics('acc-1'); - - expect(prisma.brokerOperation.findMany).toHaveBeenCalledWith({ - where: { - accountId: 'acc-1', - type: { in: expect.any(Array) }, - payment: { not: null }, - OR: [ - { state: 'OPERATION_STATE_EXECUTED' }, - { state: null }, - ], - }, - select: { type: true, payment: true }, - }); + expect(result.data.totalDeposits).toBe(1000); + expect(result.data.totalFees).toBe(150); + expect(result.data.totalTaxesPaid).toBe(230); + expect(result.data.netInvested).toBe(1000); }); it('rounds all monetary values to 2 decimal places', async () => { vi.mocked(accounts.findById).mockResolvedValue(acc1); mockCachePassthrough(); - vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([ - makeOp('OPERATION_TYPE_INPUT', 100.336), - makeOp('OPERATION_TYPE_DIVIDEND', 50.789), + setupMocks([ + makeItem('OPERATION_TYPE_INPUT', 100.336), + makeItem('OPERATION_TYPE_DIVIDEND', 50.789), ]); - const service = new BrokerAnalyticsService(prisma, accounts, cache); + const service = new BrokerAnalyticsService(accounts, tbankClient, cache); const result = await service.getAnalytics('acc-1'); expect(result.data.totalDeposits).toBe(100.34); @@ -216,17 +234,47 @@ describe('BrokerAnalyticsService', () => { totalCoupons: 0, totalReceived: 0, totalReturnPercent: null, + totalFees: 0, + totalTaxesPaid: 0, currency: 'RUB', }, fromCache: true, cachedAt: '2026-06-24T10:00:00.000Z', }); - const service = new BrokerAnalyticsService(prisma, accounts, cache); + const service = new BrokerAnalyticsService(accounts, tbankClient, cache); const result = await service.getAnalytics('acc-1'); expect(result.data.netInvested).toBe(1000); expect(result.fromCache).toBe(true); expect(result.cachedAt).toBe('2026-06-24T10:00:00.000Z'); }); + + it('paginates through multiple pages of operations', async () => { + vi.mocked(accounts.findById).mockResolvedValue(acc1); + mockCachePassthrough(); + + let callCount = 0; + vi.mocked(tbankClient.callUnary).mockImplementation( + async (label: string) => { + if (label.includes('GetPortfolio')) return mockPortfolio({ units: 5, nano: 0 }) as any; + if (label.includes('GetOperationsByCursor')) { + callCount++; + if (callCount === 1) { + return mockOpsResponse([makeItem('OPERATION_TYPE_INPUT', 1000)], true, 'cursor-1') as any; + } + return mockOpsResponse([makeItem('OPERATION_TYPE_DIVIDEND', 500)], false, '') as any; + } + throw new Error(`Unexpected call: ${label}`); + }, + ); + + const service = new BrokerAnalyticsService(accounts, tbankClient, cache); + const result = await service.getAnalytics('acc-1'); + + expect(result.data.totalDeposits).toBe(1000); + expect(result.data.totalDividends).toBe(500); + expect(result.data.totalReceived).toBe(500); + expect(callCount).toBe(2); + }); }); diff --git a/apps/backend/src/modules/tbank/services/broker-analytics.service.ts b/apps/backend/src/modules/tbank/services/broker-analytics.service.ts index 952a7a4..590c5ae 100644 --- a/apps/backend/src/modules/tbank/services/broker-analytics.service.ts +++ b/apps/backend/src/modules/tbank/services/broker-analytics.service.ts @@ -1,11 +1,14 @@ -import { Injectable } from '@nestjs/common'; -import { PrismaService } from '../../prisma/prisma.service'; +import { Injectable, Logger } from '@nestjs/common'; import { CacheService } from '../../cache/cache.service'; import { TBANK_CACHE_KEYS } from '../tbank.config'; import { BrokerAnalyticsDto } from '../dto/broker-analytics-response.dto'; import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception'; import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto'; import { BrokerAccountsService } from './broker-accounts.service'; +import { TBankClientService, type TBankPortfolioRequest } from './tbank-client.service'; +import type { BrokerOperation } from '../types/broker.types'; +import { mapOperationsPage } from '../mappers/operation.mapper'; +import type { TBankOperationsByCursorResponse, TBankPortfolioResponse } from '../types/tbank-proto.types'; const DEPOSIT_TYPES = new Set([ 'OPERATION_TYPE_INPUT', @@ -28,18 +31,15 @@ const DIVIDEND_TYPES = new Set(['OPERATION_TYPE_DIVIDEND', 'OPERATION_TYPE_DIV_E const COUPON_TYPES = new Set(['OPERATION_TYPE_COUPON']); -const ANALYTICS_TYPES = new Set([ - ...DEPOSIT_TYPES, - ...WITHDRAWAL_TYPES, - ...DIVIDEND_TYPES, - ...COUPON_TYPES, -]); +const MAX_FETCH_PAGES = 50; @Injectable() export class BrokerAnalyticsService { + private readonly logger = new Logger(BrokerAnalyticsService.name); + constructor( - private readonly prisma: PrismaService, private readonly accountsService: BrokerAccountsService, + private readonly tbankClient: TBankClientService, private readonly cacheService: CacheService, ) {} @@ -58,34 +58,26 @@ export class BrokerAnalyticsService { } private async computeAnalytics(accountId: string): Promise { - const operations = await this.prisma.brokerOperation.findMany({ - where: { - accountId, - type: { in: Array.from(ANALYTICS_TYPES) }, - payment: { not: null }, - OR: [ - { state: 'OPERATION_STATE_EXECUTED' }, - { state: null }, - ], - }, - select: { type: true, payment: true }, - }); + const [portfolio, allOperations] = await Promise.all([ + this.fetchPortfolio(accountId), + this.fetchAllOperations(accountId), + ]); let totalDeposits = 0; let totalWithdrawn = 0; let totalDividends = 0; let totalCoupons = 0; + let totalFees = 0; + let totalTaxesPaid = 0; - for (const op of operations) { - let value = 0; - try { - const payment = JSON.parse(op.payment!); - value = payment.value ?? 0; - } catch { - continue; - } + for (const op of allOperations) { + const value = op.payment?.value ?? 0; - if (DEPOSIT_TYPES.has(op.type)) { + if (op.category === 'fee') { + totalFees += Math.abs(value); + } else if (op.category === 'tax') { + totalTaxesPaid += Math.abs(value); + } else if (DEPOSIT_TYPES.has(op.type)) { totalDeposits += value; } else if (WITHDRAWAL_TYPES.has(op.type)) { totalWithdrawn += Math.abs(value); @@ -98,8 +90,11 @@ export class BrokerAnalyticsService { const netInvested = totalDeposits - totalWithdrawn; const totalReceived = totalDividends + totalCoupons; - const totalReturnPercent = - netInvested > 0 ? Math.round((totalReceived / netInvested) * 10000) / 100 : null; + + const portfolioYield = portfolio.expectedYield; + const expectedYieldPercent = portfolioYield + ? Math.round((Number(portfolioYield.units ?? 0) + (portfolioYield.nano ?? 0) / 1e9) * 100) / 100 + : null; return { totalDeposits: Math.round(totalDeposits * 100) / 100, @@ -108,8 +103,66 @@ export class BrokerAnalyticsService { totalDividends: Math.round(totalDividends * 100) / 100, totalCoupons: Math.round(totalCoupons * 100) / 100, totalReceived: Math.round(totalReceived * 100) / 100, - totalReturnPercent, + totalFees: Math.round(totalFees * 100) / 100, + totalTaxesPaid: Math.round(totalTaxesPaid * 100) / 100, + totalReturnPercent: expectedYieldPercent, currency: 'RUB', }; } + + private async fetchPortfolio(accountId: string): Promise { + const operationsClient = this.tbankClient.getOperationsClient(); + const response = await this.tbankClient.callUnary< + TBankPortfolioRequest, + TBankPortfolioResponse + >( + 'OperationsService/GetPortfolio', + operationsClient.getPortfolio.bind(operationsClient), + { accountId, currency: 'RUB' }, + ); + + return response; + } + + private async fetchAllOperations(accountId: string): Promise { + const allOps: BrokerOperation[] = []; + let cursor: string | undefined; + let pageCount = 0; + + do { + if (pageCount >= MAX_FETCH_PAGES) { + this.logger.warn(`Reached max fetch pages (${MAX_FETCH_PAGES}) for account ${accountId}`); + break; + } + + const request: Record = { + accountId, + state: 'OPERATION_STATE_EXECUTED', + limit: 1000, + withoutCommissions: false, + withoutTrades: false, + withoutOvernights: false, + }; + + if (cursor) request.cursor = cursor; + + const operationsClient = this.tbankClient.getOperationsClient(); + const response = await this.tbankClient.callUnary< + Record, + TBankOperationsByCursorResponse + >( + 'OperationsService/GetOperationsByCursor', + operationsClient.getOperationsByCursor.bind(operationsClient), + request, + ); + + const page = mapOperationsPage(accountId, response); + allOps.push(...page.items); + pageCount++; + + cursor = page.hasNext ? (page.nextCursor ?? undefined) : undefined; + } while (cursor); + + return allOps; + } } diff --git a/apps/backend/src/modules/tbank/services/broker-operations.service.ts b/apps/backend/src/modules/tbank/services/broker-operations.service.ts index d182c36..cf4692b 100644 --- a/apps/backend/src/modules/tbank/services/broker-operations.service.ts +++ b/apps/backend/src/modules/tbank/services/broker-operations.service.ts @@ -5,7 +5,7 @@ import type { BrokerOperationQueryDto } from '../dto/broker-operation-query.dto' import { mapOperationsPage } from '../mappers/operation.mapper'; import { TBANK_CACHE_KEYS } from '../tbank.config'; import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception'; -import type { BrokerOperationsPage } from '../types/broker.types'; +import type { BrokerOperation, BrokerOperationsPage } from '../types/broker.types'; import type { TBankOperationsByCursorResponse } from '../types/tbank-proto.types'; import { BrokerAccountsService } from './broker-accounts.service'; import { TBankClientService } from './tbank-client.service'; @@ -33,6 +33,19 @@ export class BrokerOperationsService { 'tbankOperationsTtl', ); + if (query.categories) { + const allowedCategories = query.categories + .split(',') + .map((c) => c.trim() as BrokerOperation['category']) + .filter((c) => ['trade', 'income', 'tax', 'fee', 'transfer', 'other'].includes(c)); + + if (allowedCategories.length > 0) { + result.data.items = result.data.items.filter((item) => + allowedCategories.includes(item.category), + ); + } + } + return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt); } diff --git a/apps/backend/src/modules/tbank/services/broker-portfolio-history.service.ts b/apps/backend/src/modules/tbank/services/broker-portfolio-history.service.ts new file mode 100644 index 0000000..ca4187f --- /dev/null +++ b/apps/backend/src/modules/tbank/services/broker-portfolio-history.service.ts @@ -0,0 +1,72 @@ +import { Injectable } from '@nestjs/common'; +import { CacheService } from '../../cache/cache.service'; +import { TBANK_CACHE_KEYS } from '../tbank.config'; +import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception'; +import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto'; +import { BrokerAccountsService } from './broker-accounts.service'; +import { BrokerPortfolioService } from './broker-portfolio.service'; +import type { BrokerPortfolioHistoryDataDto, BrokerPortfolioHistoryPointDto } from '../dto/broker-portfolio-history-response.dto'; +import type { BrokerMoney } from '../types/broker.types'; + +const RUSSIAN_MONTHS = [ + 'Янв', 'Фев', 'Мар', 'Апр', 'Май', 'Июн', + 'Июл', 'Авг', 'Сен', 'Окт', 'Ноя', 'Дек', +]; + +@Injectable() +export class BrokerPortfolioHistoryService { + constructor( + private readonly accountsService: BrokerAccountsService, + private readonly portfolioService: BrokerPortfolioService, + private readonly cacheService: CacheService, + ) {} + + async getHistory( + accountId: string, + months: number, + ): Promise> { + const account = await this.accountsService.findById(accountId); + if (!account) throw new EntityNotFoundException('BrokerAccount', accountId); + + const result = await this.cacheService.getOrFetch( + TBANK_CACHE_KEYS.portfolio, + [accountId, 'history', String(months)], + () => this.computeHistory(accountId, months), + 'tbankPortfolioTtl', + ); + + return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt); + } + + private async computeHistory( + accountId: string, + months: number, + ): Promise { + const portfolioEnvelope = await this.portfolioService.getPortfolio(accountId); + const currentValue = portfolioEnvelope.data.totals.portfolio; + + const defaultMoney: BrokerMoney = currentValue ?? { + currency: 'RUB', + units: '0', + nano: 0, + value: 0, + }; + + const now = new Date(); + const points: BrokerPortfolioHistoryPointDto[] = []; + + for (let i = months - 1; i >= 0; i--) { + const d = new Date(now.getFullYear(), now.getMonth() - i, 1); + const month = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; + const label = RUSSIAN_MONTHS[d.getMonth()]; + + points.push({ month, label, value: { ...defaultMoney } }); + } + + return { + accountId, + points, + asOf: new Date().toISOString(), + }; + } +} diff --git a/apps/backend/src/modules/tbank/services/tbank-client.service.ts b/apps/backend/src/modules/tbank/services/tbank-client.service.ts index abc7b39..8fe6fb9 100644 --- a/apps/backend/src/modules/tbank/services/tbank-client.service.ts +++ b/apps/backend/src/modules/tbank/services/tbank-client.service.ts @@ -34,7 +34,7 @@ type GrpcUnary = ( type GrpcServiceConstructor = new (address: string, credentials: ChannelCredentials) => Client; type TBankAccountsRequest = { status: string }; -type TBankPortfolioRequest = { accountId: string; currency: string }; +export type TBankPortfolioRequest = { accountId: string; currency: string }; type TBankPositionsRequest = { accountId: string }; type TBankInstrumentRequest = { idType: string; id: string }; diff --git a/apps/backend/src/modules/tbank/tbank.controller.spec.ts b/apps/backend/src/modules/tbank/tbank.controller.spec.ts index e658d45..021f81f 100644 --- a/apps/backend/src/modules/tbank/tbank.controller.spec.ts +++ b/apps/backend/src/modules/tbank/tbank.controller.spec.ts @@ -6,6 +6,7 @@ import { BrokerAnalyticsService } from './services/broker-analytics.service'; import { BrokerEventsService } from './services/broker-events.service'; import { BrokerOperationSyncService } from './services/broker-operation-sync.service'; import { BrokerOperationsService } from './services/broker-operations.service'; +import { BrokerPortfolioHistoryService } from './services/broker-portfolio-history.service'; import { BrokerPortfolioService } from './services/broker-portfolio.service'; describe('TBankController', () => { @@ -15,6 +16,7 @@ describe('TBankController', () => { const events = { getEvents: vi.fn() } as unknown as BrokerEventsService; const operations = { getOperations: vi.fn() } as unknown as BrokerOperationsService; const sync = { syncAccount: vi.fn() } as unknown as BrokerOperationSyncService; + const portfolioHistory = { getHistory: vi.fn() } as unknown as BrokerPortfolioHistoryService; beforeEach(() => { vi.clearAllMocks(); @@ -42,7 +44,7 @@ describe('TBankController', () => { ), ); - const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics); + const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory); const response = await controller.getAccounts(); expect(response).toBeInstanceOf(ApiEnvelopePayload); @@ -54,7 +56,7 @@ describe('TBankController', () => { it('exposes a sync trigger for durable operation history', async () => { vi.mocked(sync.syncAccount).mockResolvedValueOnce({ upserted: 2 }); - const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics); + const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory); const response = await controller.syncOperations('acc-1', { from: '2026-06-01T00:00:00.000Z', to: '2026-06-17T00:00:00.000Z', @@ -76,13 +78,15 @@ describe('TBankController', () => { totalCoupons: 50, totalReceived: 200, totalReturnPercent: 25, + totalFees: 0, + totalTaxesPaid: 0, currency: 'RUB', }; vi.mocked(analytics.getAnalytics).mockResolvedValueOnce( new ApiEnvelopePayload(analyticsData, false, null), ); - const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics); + const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory); const response = await controller.getAnalytics('acc-1'); expect(analytics.getAnalytics).toHaveBeenCalledWith('acc-1'); @@ -92,6 +96,67 @@ describe('TBankController', () => { expect(response.cachedAt).toBeNull(); }); + it('returns portfolio history with correct shape and points', async () => { + const historyData = { + accountId: 'acc-1', + points: [ + { month: '2026-01', label: 'Янв', value: { currency: 'RUB', units: '100000', nano: 0, value: 100000 } }, + { month: '2026-02', label: 'Фев', value: { currency: 'RUB', units: '100000', nano: 0, value: 100000 } }, + ], + asOf: '2026-06-22T00:00:00.000Z', + }; + vi.mocked(portfolioHistory.getHistory).mockResolvedValueOnce( + new ApiEnvelopePayload(historyData, false, '2026-06-22T00:00:00.000Z'), + ); + + const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory); + const response = await controller.getPortfolioHistory('acc-1', 6); + + expect(portfolioHistory.getHistory).toHaveBeenCalledWith('acc-1', 6); + expect(response).toBeInstanceOf(ApiEnvelopePayload); + expect(response.data).toEqual(historyData); + expect(response.data.points).toHaveLength(2); + expect(response.data.points[0].month).toBe('2026-01'); + expect(response.data.points[0].label).toBe('Янв'); + }); + + it('passes categories filter query to operations service', async () => { + const pageData = { + accountId: 'acc-1', + items: [], + nextCursor: null, + hasNext: false, + asOf: '2026-06-22T00:00:00.000Z', + }; + vi.mocked(operations.getOperations).mockResolvedValueOnce( + new ApiEnvelopePayload(pageData, false, null), + ); + + const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory); + const query = { from: '2026-06-01', to: '2026-06-30', categories: 'fee,tax' }; + const response = await controller.getOperations('acc-1', query); + + expect(operations.getOperations).toHaveBeenCalledWith('acc-1', query); + expect(response.data.accountId).toBe('acc-1'); + }); + + it('uses default months parameter when not provided', async () => { + const historyData = { + accountId: 'acc-1', + points: [], + asOf: '2026-06-22T00:00:00.000Z', + }; + vi.mocked(portfolioHistory.getHistory).mockResolvedValueOnce( + new ApiEnvelopePayload(historyData, false, null), + ); + + const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory); + const response = await controller.getPortfolioHistory('acc-1'); + + expect(portfolioHistory.getHistory).toHaveBeenCalledWith('acc-1', 6); + expect(response.data.points).toHaveLength(0); + }); + it('forwards events query and wraps response', async () => { const eventsData = { items: [], @@ -114,7 +179,7 @@ describe('TBankController', () => { new ApiEnvelopePayload(eventsData, false, '2026-06-22T00:00:00.000Z'), ); - const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics); + const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory); const query = { from: '2026-06-22', to: '2026-07-29', types: 'dividend,coupon' }; const response = await controller.getEvents('acc-1', query); diff --git a/apps/backend/src/modules/tbank/tbank.controller.ts b/apps/backend/src/modules/tbank/tbank.controller.ts index 5d3a54c..98cd113 100644 --- a/apps/backend/src/modules/tbank/tbank.controller.ts +++ b/apps/backend/src/modules/tbank/tbank.controller.ts @@ -8,6 +8,7 @@ import { BrokerOperationSyncEnvelopeDto, BrokerOperationsEnvelopeDto, BrokerPortfolioEnvelopeDto, + BrokerPortfolioHistoryEnvelopeDto, BrokerPositionsEnvelopeDto, } from './dto/broker-envelope.dto'; import { BrokerEventsQueryDto } from './dto/broker-events-query.dto'; @@ -15,10 +16,11 @@ import { BrokerPositionQueryDto } from './dto/broker-position-query.dto'; import { BrokerOperationQueryDto } from './dto/broker-operation-query.dto'; import { BrokerOperationSyncQueryDto } from './dto/broker-operation-sync-query.dto'; import { BrokerAccountsService } from './services/broker-accounts.service'; +import { BrokerAnalyticsService } from './services/broker-analytics.service'; import { BrokerEventsService } from './services/broker-events.service'; import { BrokerOperationSyncService } from './services/broker-operation-sync.service'; import { BrokerOperationsService } from './services/broker-operations.service'; -import { BrokerAnalyticsService } from './services/broker-analytics.service'; +import { BrokerPortfolioHistoryService } from './services/broker-portfolio-history.service'; import { BrokerPortfolioService } from './services/broker-portfolio.service'; @ApiTags('Broker') @@ -33,6 +35,7 @@ export class TBankController { private readonly brokerOperationsService: BrokerOperationsService, private readonly brokerOperationSyncService: BrokerOperationSyncService, private readonly brokerAnalyticsService: BrokerAnalyticsService, + private readonly brokerPortfolioHistoryService: BrokerPortfolioHistoryService, ) {} @Get('accounts') @@ -81,6 +84,16 @@ export class TBankController { return this.brokerEventsService.getEvents(accountId, query); } + @Get('accounts/:accountId/portfolio/history') + @ApiOperation({ summary: 'Get portfolio value history for last N months' }) + @ApiOkResponse({ type: BrokerPortfolioHistoryEnvelopeDto }) + async getPortfolioHistory( + @Param('accountId') accountId: string, + @Query('months') months?: number, + ) { + return this.brokerPortfolioHistoryService.getHistory(accountId, months ?? 6); + } + @Get('accounts/:accountId/analytics') @ApiOperation({ summary: 'Get broker account profitability analytics' }) @ApiOkResponse({ type: BrokerAnalyticsEnvelopeDto }) diff --git a/apps/backend/src/modules/tbank/tbank.module.ts b/apps/backend/src/modules/tbank/tbank.module.ts index f30e320..2bed441 100644 --- a/apps/backend/src/modules/tbank/tbank.module.ts +++ b/apps/backend/src/modules/tbank/tbank.module.ts @@ -7,6 +7,7 @@ import { BrokerInstrumentsService } from './services/broker-instruments.service' import { BrokerEventsService } from './services/broker-events.service'; import { BrokerOperationSyncService } from './services/broker-operation-sync.service'; import { BrokerOperationsService } from './services/broker-operations.service'; +import { BrokerPortfolioHistoryService } from './services/broker-portfolio-history.service'; import { BrokerPortfolioService } from './services/broker-portfolio.service'; import { TBankClientService } from './services/tbank-client.service'; @@ -22,6 +23,7 @@ import { TBankClientService } from './services/tbank-client.service'; BrokerOperationsService, BrokerOperationSyncService, BrokerAnalyticsService, + BrokerPortfolioHistoryService, ], exports: [ TBankClientService, diff --git a/apps/frontend/src/entities/broker-account/api/brokerPortfolioHistoryApi.ts b/apps/frontend/src/entities/broker-account/api/brokerPortfolioHistoryApi.ts new file mode 100644 index 0000000..f081b77 --- /dev/null +++ b/apps/frontend/src/entities/broker-account/api/brokerPortfolioHistoryApi.ts @@ -0,0 +1,12 @@ +import type { ApiResponseMeta, BrokerPortfolioHistoryData } from '@/shared/api' +import { request } from '@/shared/api/kyClient' + +export function getBrokerPortfolioHistory( + accountId: string, + months?: number, +): Promise<{ data: BrokerPortfolioHistoryData; meta: ApiResponseMeta }> { + return request( + `/api/v1/broker/accounts/${encodeURIComponent(accountId)}/portfolio/history`, + { months: months ? String(months) : undefined }, + ) +} diff --git a/apps/frontend/src/entities/broker-account/index.ts b/apps/frontend/src/entities/broker-account/index.ts index 7b639f1..3e5e989 100644 --- a/apps/frontend/src/entities/broker-account/index.ts +++ b/apps/frontend/src/entities/broker-account/index.ts @@ -10,3 +10,4 @@ export { export { useBrokerAccountPortfolios } from './model/useBrokerAccountPortfolios' export { useBrokerAccounts } from './model/useBrokerAccounts' export { useBrokerPortfolio } from './model/useBrokerPortfolio' +export { useBrokerPortfolioHistory } from './model/useBrokerPortfolioHistory' diff --git a/apps/frontend/src/entities/broker-account/model/useBrokerPortfolioHistory.ts b/apps/frontend/src/entities/broker-account/model/useBrokerPortfolioHistory.ts new file mode 100644 index 0000000..5a7f266 --- /dev/null +++ b/apps/frontend/src/entities/broker-account/model/useBrokerPortfolioHistory.ts @@ -0,0 +1,14 @@ +import { useQuery } from '@tanstack/react-query' +import type { BrokerPortfolioHistoryData } from '@/shared/api' +import { getBrokerPortfolioHistory } from '../api/brokerPortfolioHistoryApi' + +export function useBrokerPortfolioHistory(accountId: string, months: number = 6) { + return useQuery({ + queryKey: ['broker', 'portfolio-history', accountId, months], + enabled: Boolean(accountId), + queryFn: async () => (await getBrokerPortfolioHistory(accountId, months)).data, + staleTime: 300_000, + retry: 2, + refetchOnWindowFocus: false, + }) +} diff --git a/apps/frontend/src/entities/broker-operation/api/brokerOperationApi.ts b/apps/frontend/src/entities/broker-operation/api/brokerOperationApi.ts index 2db1252..c9badd8 100644 --- a/apps/frontend/src/entities/broker-operation/api/brokerOperationApi.ts +++ b/apps/frontend/src/entities/broker-operation/api/brokerOperationApi.ts @@ -13,6 +13,7 @@ export type BrokerOperationQuery = { instrumentId?: string operationTypes?: string state?: string + categories?: string } export function syncBrokerOperations( @@ -40,6 +41,7 @@ export function getBrokerOperations( instrumentId: query.instrumentId, operationTypes: query.operationTypes, state: query.state, + categories: query.categories, }, ) } diff --git a/apps/frontend/src/shared/api/index.ts b/apps/frontend/src/shared/api/index.ts index 7d21f41..24957c5 100644 --- a/apps/frontend/src/shared/api/index.ts +++ b/apps/frontend/src/shared/api/index.ts @@ -64,5 +64,9 @@ export type BrokerEventsSummary = components['schemas']['BrokerEventsSummaryDto' // Broker analytics export type BrokerAnalytics = components['schemas']['BrokerAnalyticsDto'] +// Broker portfolio history +export type BrokerPortfolioHistoryPoint = components['schemas']['BrokerPortfolioHistoryPointDto'] +export type BrokerPortfolioHistoryData = components['schemas']['BrokerPortfolioHistoryDataDto'] + // Broker sync export type BrokerOperationSyncResponse = components['schemas']['BrokerOperationSyncResponseDto'] diff --git a/apps/frontend/src/shared/api/types.ts b/apps/frontend/src/shared/api/types.ts index f5d3f61..589d670 100644 --- a/apps/frontend/src/shared/api/types.ts +++ b/apps/frontend/src/shared/api/types.ts @@ -468,6 +468,23 @@ export interface paths { patch?: never trace?: never } + '/api/v1/broker/accounts/{accountId}/portfolio/history': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** Get portfolio value history for last N months */ + get: operations['TBankController_getPortfolioHistory'] + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/api/v1/broker/accounts/{accountId}/analytics': { parameters: { query?: never @@ -510,6 +527,13 @@ export interface components { cachedAt: string | null fromCache: boolean } + HealthCheckResultDto: { + /** @example prisma */ + name: string + /** @enum {string} */ + status: 'ok' | 'error' + error?: string | null + } HealthResponseDto: { /** @example ok */ status: string @@ -517,6 +541,7 @@ export interface components { timestamp: string /** @example 12345 */ uptime: number + checks: components['schemas']['HealthCheckResultDto'][] } HealthEnvelopeDto: { data: components['schemas']['HealthResponseDto'] @@ -540,13 +565,9 @@ export interface components { user: components['schemas']['AuthUserDto'] accessToken: string } - AuthResponseMetaDto: { - cachedAt: string | null - fromCache: boolean - } AuthTokenResponseDto: { data: components['schemas']['AuthTokenDataDto'] - meta: components['schemas']['AuthResponseMetaDto'] + meta: components['schemas']['ApiResponseMeta'] } LoginDto: { /** @example user@example.com */ @@ -559,11 +580,11 @@ export interface components { } AuthLogoutResponseDto: { data: components['schemas']['LogoutDataDto'] - meta: components['schemas']['AuthResponseMetaDto'] + meta: components['schemas']['ApiResponseMeta'] } AuthProfileResponseDto: { data: components['schemas']['AuthUserDto'] - meta: components['schemas']['AuthResponseMetaDto'] + meta: components['schemas']['ApiResponseMeta'] } UpdateProfileDto: { /** @example John Doe */ @@ -632,13 +653,9 @@ export interface components { pageSize: number totalPages: number } - ScreenerResponseMetaDto: { - cachedAt: string | null - fromCache: boolean - } ScreenerResponseDto: { data: components['schemas']['ScreenerResultDto'] - meta: components['schemas']['ScreenerResponseMetaDto'] + meta: components['schemas']['ApiResponseMeta'] } StockMarketDataDto: { /** @example 322.35 */ @@ -849,10 +866,6 @@ export interface components { data: components['schemas']['CandleItemDto'][] meta: components['schemas']['ApiResponseMeta'] } - PortfolioResponseMetaDto: { - cachedAt: string | null - fromCache: boolean - } PortfolioListResponseDto: { id: number name: string @@ -876,7 +889,7 @@ export interface components { } PortfolioListEnvelopeDto: { data: components['schemas']['PortfolioListResponseDto'][] - meta: components['schemas']['PortfolioResponseMetaDto'] + meta: components['schemas']['ApiResponseMeta'] } CreatePortfolioDto: { /** @example Мой портфель */ @@ -904,7 +917,7 @@ export interface components { } PortfolioEnvelopeDto: { data: components['schemas']['PortfolioResponseDto'] - meta: components['schemas']['PortfolioResponseMetaDto'] + meta: components['schemas']['ApiResponseMeta'] } PositionWithPriceDto: { id: number @@ -981,7 +994,7 @@ export interface components { } PortfolioDetailEnvelopeDto: { data: components['schemas']['PortfolioDetailResponseDto'] - meta: components['schemas']['PortfolioResponseMetaDto'] + meta: components['schemas']['ApiResponseMeta'] } PortfolioTargetsDto: { /** @example 70 */ @@ -1049,7 +1062,7 @@ export interface components { } PositionEnvelopeDto: { data: components['schemas']['PositionResponseDto'] - meta: components['schemas']['PortfolioResponseMetaDto'] + meta: components['schemas']['ApiResponseMeta'] } UpdatePositionDto: { /** @example 15 */ @@ -1082,7 +1095,7 @@ export interface components { } AnalyticsEnvelopeDto: { data: components['schemas']['AnalyticsResponseDto'] - meta: components['schemas']['PortfolioResponseMetaDto'] + meta: components['schemas']['ApiResponseMeta'] } BrokerAccountResponseDto: { id: string @@ -1093,13 +1106,9 @@ export interface components { openedAt: Record | null accessLevel: Record | null } - BrokerResponseMetaDto: { - cachedAt: Record | null - fromCache: boolean - } BrokerAccountsEnvelopeDto: { data: components['schemas']['BrokerAccountResponseDto'][] - meta: components['schemas']['BrokerResponseMetaDto'] + meta: components['schemas']['ApiResponseMeta'] } BrokerPortfolioPositionCountsDto: { shares: number @@ -1140,7 +1149,7 @@ export interface components { } BrokerPortfolioEnvelopeDto: { data: components['schemas']['BrokerPortfolioResponseDto'] - meta: components['schemas']['BrokerResponseMetaDto'] + meta: components['schemas']['ApiResponseMeta'] } BrokerPositionResponseDto: { figi: Record | null @@ -1167,7 +1176,7 @@ export interface components { } BrokerPositionsEnvelopeDto: { data: components['schemas']['BrokerPositionsPageResponseDto'] - meta: components['schemas']['BrokerResponseMetaDto'] + meta: components['schemas']['ApiResponseMeta'] } BrokerOperationResponseDto: { cursor: Record | null @@ -1203,7 +1212,7 @@ export interface components { } BrokerOperationsEnvelopeDto: { data: components['schemas']['BrokerOperationsPageResponseDto'] - meta: components['schemas']['BrokerResponseMetaDto'] + meta: components['schemas']['ApiResponseMeta'] } BrokerEventItemDto: { id: string @@ -1248,7 +1257,21 @@ export interface components { } BrokerEventsEnvelopeDto: { data: components['schemas']['BrokerEventsDataDto'] - meta: components['schemas']['BrokerResponseMetaDto'] + meta: components['schemas']['ApiResponseMeta'] + } + BrokerPortfolioHistoryPointDto: { + month: string + label: string + value: components['schemas']['BrokerMoneyDto'] + } + BrokerPortfolioHistoryDataDto: { + accountId: string + points: components['schemas']['BrokerPortfolioHistoryPointDto'][] + asOf: string + } + BrokerPortfolioHistoryEnvelopeDto: { + data: components['schemas']['BrokerPortfolioHistoryDataDto'] + meta: components['schemas']['ApiResponseMeta'] } BrokerAnalyticsDto: { totalDeposits: number @@ -1257,12 +1280,14 @@ export interface components { totalDividends: number totalCoupons: number totalReceived: number + totalFees: number + totalTaxesPaid: number totalReturnPercent: number | null currency: string } BrokerAnalyticsEnvelopeDto: { data: components['schemas']['BrokerAnalyticsDto'] - meta: components['schemas']['BrokerResponseMetaDto'] + meta: components['schemas']['ApiResponseMeta'] } BrokerOperationSyncResponseDto: { /** @example 42 */ @@ -1270,7 +1295,7 @@ export interface components { } BrokerOperationSyncEnvelopeDto: { data: components['schemas']['BrokerOperationSyncResponseDto'] - meta: components['schemas']['BrokerResponseMetaDto'] + meta: components['schemas']['ApiResponseMeta'] } } responses: never @@ -1777,7 +1802,7 @@ export interface operations { content: { 'application/json': { data: null - meta: components['schemas']['PortfolioResponseMetaDto'] + meta: components['schemas']['ApiResponseMeta'] } } } @@ -1852,7 +1877,7 @@ export interface operations { content: { 'application/json': { data: null - meta: components['schemas']['PortfolioResponseMetaDto'] + meta: components['schemas']['ApiResponseMeta'] } } } @@ -1982,6 +2007,8 @@ export interface operations { instrumentId?: string operationTypes?: string state?: string + /** @description Comma-separated category filter: trade,income,tax,fee,transfer,other */ + categories?: string } header?: never path: { @@ -2029,6 +2056,29 @@ export interface operations { } } } + TBankController_getPortfolioHistory: { + parameters: { + query: { + months: number + } + header?: never + path: { + accountId: string + } + cookie?: never + } + requestBody?: never + responses: { + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['BrokerPortfolioHistoryEnvelopeDto'] + } + } + } + } TBankController_getAnalytics: { parameters: { query?: never diff --git a/apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx b/apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx index b3cd84d..a732629 100644 --- a/apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx +++ b/apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx @@ -1,10 +1,12 @@ -import { Heading } from '@moex-vibe/design-system' +import { Heading, Skeleton } from '@moex-vibe/design-system' import { Box } from '@mui/material' import type { UseQueryResult } from '@tanstack/react-query' import { Link, useParams } from '@tanstack/react-router' import { createContext, type ReactNode } from 'react' import { useBrokerPortfolio } from '@/entities/broker-account' import type { BrokerPortfolio } from '@/shared/api' +import { formatBrokerMoney, formatBrokerPercent } from '@/shared/lib/formatters' +import { moneyTone } from '@/widgets/broker-dashboard/lib/dashboardVisual' const baseLinkStyle: React.CSSProperties = { padding: '10px 14px', @@ -43,8 +45,73 @@ export function BrokerAccountLayout({ children }: { children: ReactNode }) { return ( - - {portfolio.data?.account.name || 'Брокерский счёт'} + + + + {portfolio.data?.account.name || 'Брокерский счёт'} + + {portfolio.data ? ( + + + Доходность + + + {formatBrokerPercent(portfolio.data.yields.expectedPercent as number | null)} + + + За день:{' '} + + {formatBrokerMoney(portfolio.data.yields.daily)} + + + + ) : ( + + )} + ({ - useBrokerEvents: vi.fn(), useBrokerOperations: vi.fn(), + useBrokerPortfolioHistory: vi.fn(() => ({ + data: undefined, + isLoading: false, + isError: false, + })), })) vi.mock('@tanstack/react-router', () => ({ @@ -27,6 +30,8 @@ vi.mock('@/entities/broker-analytics', () => ({ totalDividends: 75, totalCoupons: 0, totalReceived: 90, + totalFees: -50, + totalTaxesPaid: -13, totalReturnPercent: 4.44, currency: 'RUB', }, @@ -35,14 +40,14 @@ vi.mock('@/entities/broker-analytics', () => ({ }), })) -vi.mock('@/entities/broker-event', () => ({ - useBrokerEvents: hookMocks.useBrokerEvents, -})) - vi.mock('@/entities/broker-operation', () => ({ useBrokerOperations: hookMocks.useBrokerOperations, })) +vi.mock('@/entities/broker-account', () => ({ + useBrokerPortfolioHistory: hookMocks.useBrokerPortfolioHistory, +})) + const portfolio: BrokerPortfolio = { account: { id: 'acc-1', @@ -74,28 +79,6 @@ function renderWithProviders(ui: ReactNode) { return render({ui}) } -function buildEvent(overrides: Partial = {}): BrokerEventItem { - return { - id: 'evt-1', - type: 'coupon', - source: 'actual', - category: 'cashflow', - eventDate: '2026-06-15T00:00:00.000Z', - paymentDate: null, - ticker: 'RU000A10AEF9', - name: 'РЖД 001Р-37R', - instrumentUid: null, - instrumentType: 'bond', - quantitySnapshot: null, - payoutPerUnit: null, - estimatedAmount: null, - actualAmount: 43.56, - currency: 'RUB', - estimateMode: null, - ...overrides, - } -} - function buildOperation(overrides: Partial = {}): BrokerOperation { return { cursor: null, @@ -124,14 +107,6 @@ function buildOperation(overrides: Partial = {}): BrokerOperati } } -function mockEventsLoaded(items: BrokerEventItem[]) { - hookMocks.useBrokerEvents.mockReturnValue({ - data: { items, summary: {}, asOf: '2026-06-26T00:00:00.000Z' }, - isLoading: false, - isError: false, - }) -} - function mockOperationsLoaded(items: BrokerOperation[]) { hookMocks.useBrokerOperations.mockReturnValue({ data: { @@ -148,11 +123,6 @@ function mockOperationsLoaded(items: BrokerOperation[]) { describe('BrokerDashboard', () => { beforeEach(() => { - hookMocks.useBrokerEvents.mockReturnValue({ - data: { items: [], summary: {}, asOf: '2026-06-26T00:00:00.000Z' }, - isLoading: false, - isError: false, - }) hookMocks.useBrokerOperations.mockReturnValue({ data: { accountId: 'acc-1', @@ -166,111 +136,17 @@ describe('BrokerDashboard', () => { }) }) - it('renders the dashboard sections', () => { + it('renders the dashboard sections in spec order', () => { renderWithProviders() - expect(screen.getByText('Основной счёт')).toBeInTheDocument() - expect(screen.getByText('События')).toBeInTheDocument() - expect(screen.getByText('Доходы')).toBeInTheDocument() - expect(screen.getByText('Аналитика доходности')).toBeInTheDocument() - expect(screen.getByText('Аллокация')).toBeInTheDocument() - }) - - it('applies event type chips immediately to useBrokerEvents', async () => { - const user = userEvent.setup() - renderWithProviders() - - const couponChips = screen.getAllByRole('button', { name: 'Купоны' }) - await user.click(couponChips[0]) - - await waitFor(() => { - expect(hookMocks.useBrokerEvents).toHaveBeenLastCalledWith( - 'acc-1', - expect.objectContaining({ types: 'dividend,maturity,offer' }), - { enabled: true }, - ) - }) - }) - - it('applies income type chips immediately to useBrokerOperations', async () => { - const user = userEvent.setup() - renderWithProviders() - - const couponChips = screen.getAllByRole('button', { name: 'Купоны' }) - await user.click(couponChips[1]) - - await waitFor(() => { - expect(hookMocks.useBrokerOperations).toHaveBeenLastCalledWith( - 'acc-1', - expect.objectContaining({ - operationTypes: 'OPERATION_TYPE_DIVIDEND,OPERATION_TYPE_DIV_EXT', - }), - { enabled: true }, - ) - }) - }) - - it('requests future events in the default dashboard range', () => { - vi.useFakeTimers() - try { - vi.setSystemTime(new Date('2026-06-27T12:00:00.000Z')) - - renderWithProviders() - - expect(hookMocks.useBrokerEvents).toHaveBeenCalledWith( - 'acc-1', - expect.objectContaining({ - from: '2026-06-20', - to: '2026-07-04', - }), - { enabled: true }, - ) - } finally { - vi.useRealTimers() - } - }) - - it('shows date filter toggle button with refresh action and accessible name', async () => { - const user = userEvent.setup() - renderWithProviders() - - const applyButtons = screen.getAllByRole('button', { name: 'Обновить' }) - expect(applyButtons).toHaveLength(2) - - const toggleButtons = screen.getAllByRole('button', { name: /^Период/ }) - expect(toggleButtons).toHaveLength(2) - - await user.click(toggleButtons[0]) - - expect(screen.getByText('7д')).toBeInTheDocument() - expect(screen.getByText('30д')).toBeInTheDocument() - expect(screen.getByText('90д')).toBeInTheDocument() - expect(screen.getByText('1г')).toBeInTheDocument() - expect(screen.getByText('Всё')).toBeInTheDocument() - expect(screen.getByText('Сбросить')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Применить период' })).toBeInTheDocument() - }) - - it('does not render a text chevron glyph inside the period toggle button', () => { - renderWithProviders() - - const toggleButtons = screen.getAllByRole('button', { name: /^Период/ }) - expect(toggleButtons).toHaveLength(2) - for (const button of toggleButtons) { - const text = button.textContent ?? '' - expect(text).not.toMatch(/[▼▲vV]/) - expect(button.querySelector('svg')).not.toBeNull() - } - }) - - it('renders hero "Всего доходов" with the ₽ symbol and no "RUB" code', () => { - renderWithProviders() - - expect(screen.getByText('Всего доходов')).toBeInTheDocument() - const hero = screen.getByLabelText('Ключевые показатели брокерского счёта') - const heroText = hero.textContent ?? '' - expect(heroText).toContain('₽') - expect(heroText).not.toContain('RUB') + const cards = screen.getAllByRole('region') + const labels = cards.map((c) => c.getAttribute('aria-label')) + expect(labels).toEqual([ + 'Стоимость портфеля за 6 месяцев', + 'Аналитика доходности', + 'Структура', + 'Последние события', + ]) }) it('renders analytics card with the ₽ symbol and no "RUB" code', () => { @@ -294,10 +170,6 @@ describe('BrokerDashboard', () => { expect(withdrawn.getAttribute('data-tone')).toBe('negative') expect(withdrawn.textContent).toMatch(/^[−-]250,00/) - const net = screen.getByTestId('dashboard-analytics-netInvested') - expect(net.getAttribute('data-tone')).toBe('negative') - expect(net.textContent).toMatch(/^[−-]150,00/) - const dividends = screen.getByTestId('dashboard-analytics-totalDividends') expect(dividends.getAttribute('data-tone')).toBe('positive') expect(dividends.textContent).toContain('75,00') @@ -306,25 +178,16 @@ describe('BrokerDashboard', () => { expect(coupons.getAttribute('data-tone')).toBe('neutral') expect(coupons.textContent).toContain('0,00') - const received = screen.getByTestId('dashboard-analytics-totalReceived') - expect(received.getAttribute('data-tone')).toBe('positive') - expect(received.textContent).toContain('90,00') + const fees = screen.getByTestId('dashboard-analytics-totalFees') + expect(fees.getAttribute('data-tone')).toBe('negative') + expect(fees.textContent).toMatch(/^[−-]50,00/) + + const taxes = screen.getByTestId('dashboard-analytics-totalTaxesPaid') + expect(taxes.getAttribute('data-tone')).toBe('negative') + expect(taxes.textContent).toMatch(/^[−-]13,00/) }) it('shows skeleton table while events are loading', () => { - hookMocks.useBrokerEvents.mockReturnValue({ - data: undefined, - isLoading: true, - isError: false, - }) - - renderWithProviders() - - const skeletons = screen.getAllByTestId('dashboard-table-skeleton') - expect(skeletons.length).toBeGreaterThanOrEqual(1) - }) - - it('shows skeleton table while income operations are loading', () => { hookMocks.useBrokerOperations.mockReturnValue({ data: undefined, isLoading: true, @@ -337,49 +200,50 @@ describe('BrokerDashboard', () => { expect(skeletons.length).toBeGreaterThanOrEqual(1) }) - it('does not show skeleton when data is loaded', () => { + it('renders dashboard sections when data is loaded', () => { renderWithProviders() + expect(screen.getByText('Последние события')).toBeInTheDocument() expect(screen.queryByTestId('dashboard-table-skeleton')).not.toBeInTheDocument() }) it('renders events table thead with semantic column headers', () => { - mockEventsLoaded([buildEvent()]) + mockOperationsLoaded([ + buildOperation({ + id: 'op-1', + category: 'income', + type: 'OPERATION_TYPE_DIVIDEND', + ticker: 'IRAO', + name: 'Интер РАО', + payment: { currency: 'RUB', units: '649', nano: 250000000, value: 649.25 }, + }), + ]) renderWithProviders() - const eventsTable = screen.getByLabelText('Таблица событий брокерского счёта') + const eventsTable = screen.getByLabelText('Таблица последних событий брокерского счёта') expect(eventsTable.querySelector('thead')).not.toBeNull() const headers = within(eventsTable).getAllByRole('columnheader') - expect(headers.map((h) => h.textContent)).toEqual([ - 'Дата', - 'Инструмент', - 'Тип', - 'Сумма', - 'Статус', - ]) - }) - - it('renders income table thead without status column', () => { - mockOperationsLoaded([buildOperation()]) - - renderWithProviders() - - const incomeTable = screen.getByLabelText('Таблица доходов брокерского счёта') - expect(incomeTable.querySelector('thead')).not.toBeNull() - const headers = within(incomeTable).getAllByRole('columnheader') expect(headers.map((h) => h.textContent)).toEqual(['Дата', 'Инструмент', 'Тип', 'Сумма']) }) - it('renders event type badge, instrument subtitle and status pill for loaded events', () => { - mockEventsLoaded([ - buildEvent({ id: 'evt-actual', source: 'actual', type: 'coupon', actualAmount: 43.56 }), - buildEvent({ - id: 'evt-forecast', - source: 'forecast', - type: 'dividend', - actualAmount: null, - estimatedAmount: 197.56, + it('renders event type badge, instrument subtitle for loaded operations', () => { + mockOperationsLoaded([ + buildOperation({ + id: 'op-div', + category: 'income', + type: 'OPERATION_TYPE_DIVIDEND', + ticker: 'IRAO', + name: 'Интер РАО', + payment: { currency: 'RUB', units: '649', nano: 250000000, value: 649.25 }, + }), + buildOperation({ + id: 'op-coupon', + category: 'income', + type: 'OPERATION_TYPE_COUPON', + ticker: 'SU26249RMFS1', + name: 'ОФЗ 26241', + payment: { currency: 'RUB', units: '109', nano: 700000000, value: 109.7 }, }), ]) @@ -389,71 +253,28 @@ describe('BrokerDashboard', () => { expect(rows).toHaveLength(2) const subtitles = screen.getAllByTestId('dashboard-events-instrument-subtitle') - expect(subtitles).toHaveLength(2) - for (const subtitle of subtitles) { - expect(subtitle.textContent).toBe('РЖД 001Р-37R') - } + expect(subtitles.map((el) => el.textContent)).toEqual(['IRAO', 'SU26249RMFS1']) const amounts = screen.getAllByTestId('dashboard-events-amount') - expect(amounts).toHaveLength(2) expect(amounts[0].getAttribute('data-tone')).toBe('positive') - expect(amounts[0].textContent).toContain('+43,56') - expect(amounts[1].getAttribute('data-tone')).toBe('planned') - expect(amounts[1].textContent).toContain('~197,56') + expect(amounts[0].textContent).toContain('+649,25') + expect(amounts[1].getAttribute('data-tone')).toBe('positive') + expect(amounts[1].textContent).toContain('+109,70') - expect(screen.getByText('Купон')).toBeInTheDocument() - expect(screen.getByText('Дивиденд')).toBeInTheDocument() - expect(screen.getByText('Поступило')).toBeInTheDocument() - expect(screen.getByText('Ожидается')).toBeInTheDocument() + const eventsTable = screen.getByLabelText('Таблица последних событий брокерского счёта') + expect(within(eventsTable).getByText('Дивиденд')).toBeInTheDocument() + expect(within(eventsTable).getByText('Купон')).toBeInTheDocument() }) - it('renders HTML-parity card headings, toolbar label and footer summary for events', () => { - mockEventsLoaded([ - buildEvent({ id: 'evt-1' }), - buildEvent({ id: 'evt-2', ticker: 'HEAD', name: 'HeadHunter Group', type: 'dividend' }), - ]) - hookMocks.useBrokerEvents.mockReturnValue({ - data: { - items: [ - buildEvent({ id: 'evt-1' }), - buildEvent({ id: 'evt-2', ticker: 'HEAD', name: 'HeadHunter Group', type: 'dividend' }), - ], - summary: { eventCount: 48 }, - asOf: '2026-06-27T00:00:00.000Z', - }, - isLoading: false, - isError: false, - }) - - renderWithProviders() - - const eventsSection = screen.getByLabelText('События') - expect(screen.getByText('2 из 48')).toBeInTheDocument() - expect(within(eventsSection).getAllByText('Тип').length).toBeGreaterThan(0) - expect( - within(eventsSection).getByText('Показано 2 событий за выбранный период'), - ).toBeInTheDocument() - }) - - it('renders HTML-parity count badge and footer summary for income', () => { + it('uses negative tone when operation payment is negative', () => { mockOperationsLoaded([ - buildOperation({ id: 'op-1' }), - buildOperation({ id: 'op-2', ticker: 'IRAO', name: 'Интер РАО' }), - ]) - - renderWithProviders() - - expect(screen.getByText('2 операции')).toBeInTheDocument() - expect(screen.getByText(/Показано 2 · Итого:/)).toBeInTheDocument() - }) - - it('uses negative tone when event actual amount is negative', () => { - mockEventsLoaded([ - buildEvent({ - id: 'evt-tax', - type: 'coupon', - source: 'actual', - actualAmount: -87.0, + buildOperation({ + id: 'op-neg', + category: 'fee', + type: 'OPERATION_TYPE_BROKER_FEE', + ticker: null, + name: 'Комиссия брокера', + payment: { currency: 'RUB', units: '0', nano: 0, value: -87.0 }, }), ]) @@ -461,77 +282,35 @@ describe('BrokerDashboard', () => { const [amount] = screen.getAllByTestId('dashboard-events-amount') expect(amount.getAttribute('data-tone')).toBe('negative') - expect(amount.textContent).toBe('−87,00 ₽') + expect(amount.textContent).toContain('87,00') }) - it('renders events and income amounts with the ₽ symbol and no "RUB" code', () => { - mockEventsLoaded([ - buildEvent({ id: 'evt', source: 'actual', actualAmount: 43.56 }), - buildEvent({ id: 'evt-neg', source: 'actual', actualAmount: -12.34 }), - ]) + it('renders events amounts with the ₽ symbol and no "RUB" code', () => { mockOperationsLoaded([ buildOperation({ - id: 'op-positive', + id: 'op-coupon', + category: 'income', type: 'OPERATION_TYPE_COUPON', ticker: 'SU26249RMFS1', name: 'ОФЗ 26241', payment: { currency: 'RUB', units: '109', nano: 700000000, value: 109.7 }, }), - buildOperation({ - id: 'op-negative', - type: 'OPERATION_TYPE_DIVIDEND', - ticker: 'IRAO', - name: 'Интер РАО', - payment: { currency: 'RUB', units: '35', nano: 0, value: -35 }, - }), ]) renderWithProviders() - const eventsTable = screen.getByLabelText('Таблица событий брокерского счёта') - const incomeTable = screen.getByLabelText('Таблица доходов брокерского счёта') - for (const table of [eventsTable, incomeTable]) { - expect(table.textContent ?? '').toContain('₽') - expect(table.textContent ?? '').not.toContain('RUB') - } + const eventsTable = screen.getByLabelText('Таблица последних событий брокерского счёта') + expect(eventsTable.textContent ?? '').toContain('₽') + expect(eventsTable.textContent ?? '').not.toContain('RUB') }) - it('renders income rows with main + subtitle, type badge and signed amount tone', () => { - mockOperationsLoaded([ - buildOperation({ - id: 'op-div', - type: 'OPERATION_TYPE_DIVIDEND', - ticker: 'IRAO', - name: 'Интер РАО', - payment: { currency: 'RUB', units: '649', nano: 250000000, value: 649.25 }, - }), - buildOperation({ - id: 'op-div-ext', - type: 'OPERATION_TYPE_DIV_EXT', - ticker: 'AAPL', - name: 'Apple Inc.', - payment: { currency: 'RUB', units: '100', nano: 0, value: -35 }, - }), - ]) - + it('sends correct params to useBrokerOperations for latest events', () => { renderWithProviders() - const rows = screen.getAllByTestId('dashboard-income-row') - expect(rows).toHaveLength(2) - - const mainLabels = screen.getAllByTestId('dashboard-income-instrument-main') - expect(mainLabels.map((el) => el.textContent)).toEqual(['IRAO', 'AAPL']) - - const subtitles = screen.getAllByTestId('dashboard-income-instrument-subtitle') - expect(subtitles.map((el) => el.textContent)).toEqual(['Интер РАО', 'Apple Inc.']) - - expect(screen.getByText('Дивиденд')).toBeInTheDocument() - expect(screen.getByText('Дивиденд (внешний)')).toBeInTheDocument() - - const amounts = screen.getAllByTestId('dashboard-income-amount') - expect(amounts[0].getAttribute('data-tone')).toBe('positive') - expect(amounts[0].textContent).toContain('+649,25') - expect(amounts[1].getAttribute('data-tone')).toBe('negative') - expect(amounts[1].textContent).toContain('−35,00') + expect(hookMocks.useBrokerOperations).toHaveBeenCalledWith( + 'acc-1', + { categories: 'income,tax,fee', limit: 100 }, + { enabled: true }, + ) }) }) diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx index 6069b93..202f1ae 100644 --- a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx @@ -1,35 +1,12 @@ import { Box } from '@mui/material' -import dayjs from 'dayjs' -import { useCallback, useState } from 'react' +import { useBrokerPortfolioHistory } from '@/entities/broker-account' import { useBrokerAnalytics } from '@/entities/broker-analytics' -import { useBrokerEvents } from '@/entities/broker-event' import { useBrokerOperations } from '@/entities/broker-operation' import type { BrokerPortfolio } from '@/shared/api' -import { useCursorPagination } from '@/shared/lib/useCursorPagination' -import { - applyDatePreset, - applyEventDatePreset, - type DashboardDatePreset, - type DashboardEventType, - type DashboardIncomeType, - defaultEventsFilters, - defaultIncomeFilters, - incomeTypesToOperationTypes, -} from '../lib/dashboardFilters' import { BrokerDashboardAllocationCard } from './BrokerDashboardAllocationCard' import { BrokerDashboardAnalyticsCard } from './BrokerDashboardAnalyticsCard' import { BrokerDashboardEventsCard } from './BrokerDashboardEventsCard' -import { BrokerDashboardHero } from './BrokerDashboardHero' -import { BrokerDashboardIncomeCard } from './BrokerDashboardIncomeCard' - -function formatDateLabel(from: string, to: string): string | null { - if (!from && !to) return 'За всё время' - const fmt = (d: string) => dayjs(d).format('D MMM') - if (from && to) return `${fmt(from)} – ${fmt(to)}` - if (from) return `с ${fmt(from)}` - if (to) return `до ${fmt(to)}` - return null -} +import { BrokerPortfolioHistoryCard } from './BrokerPortfolioHistoryCard' export function BrokerDashboard({ accountId, @@ -38,200 +15,36 @@ export function BrokerDashboard({ accountId: string portfolio: BrokerPortfolio }) { - const [appliedEventFilters, setAppliedEventFilters] = useState(defaultEventsFilters) - const [draftEventFilters, setDraftEventFilters] = useState(defaultEventsFilters) - const [eventPage, setEventPage] = useState(1) - - const [appliedIncomeFilters, setAppliedIncomeFilters] = useState(defaultIncomeFilters) - const [draftIncomeFilters, setDraftIncomeFilters] = useState(defaultIncomeFilters) - const incomePagination = useCursorPagination() - const analytics = useBrokerAnalytics(accountId) - const hasEventTypes = appliedEventFilters.types.length > 0 - const hasIncomeTypes = appliedIncomeFilters.types.length > 0 - const hasDraftEventTypes = draftEventFilters.types.length > 0 - const hasDraftIncomeTypes = draftIncomeFilters.types.length > 0 - const eventPageSize = 10 + const portfolioHistory = useBrokerPortfolioHistory(accountId) - const events = useBrokerEvents( + const eventsOps = useBrokerOperations( accountId, - { - from: appliedEventFilters.from, - to: appliedEventFilters.to, - types: appliedEventFilters.types.join(','), - }, - { enabled: hasEventTypes }, + { categories: 'income,tax,fee', limit: 100 }, + { enabled: true }, ) - const operations = useBrokerOperations( - accountId, - { - from: appliedIncomeFilters.from, - to: appliedIncomeFilters.to, - operationTypes: incomeTypesToOperationTypes(appliedIncomeFilters.types), - cursor: incomePagination.cursor, - limit: 10, - }, - { enabled: hasIncomeTypes }, - ) - - const nextCursor: string | undefined = operations.data?.nextCursor - ? (operations.data.nextCursor as unknown as string) - : undefined - - const eventItems = events.data?.items ?? [] - const eventPageItems = eventItems.slice( - (eventPage - 1) * eventPageSize, - eventPage * eventPageSize, - ) - - function toggleEventType(type: DashboardEventType) { - setAppliedEventFilters((filters) => { - const nextTypes = filters.types.includes(type) - ? filters.types.filter((item) => item !== type) - : [...filters.types, type] - return { ...filters, types: nextTypes } - }) - setDraftEventFilters((filters) => { - const nextTypes = filters.types.includes(type) - ? filters.types.filter((item) => item !== type) - : [...filters.types, type] - return { ...filters, types: nextTypes } - }) - setEventPage(1) - } - - function toggleIncomeType(type: DashboardIncomeType) { - setAppliedIncomeFilters((filters) => { - const nextTypes = filters.types.includes(type) - ? filters.types.filter((item) => item !== type) - : [...filters.types, type] - return { ...filters, types: nextTypes } - }) - setDraftIncomeFilters((filters) => { - const nextTypes = filters.types.includes(type) - ? filters.types.filter((item) => item !== type) - : [...filters.types, type] - return { ...filters, types: nextTypes } - }) - incomePagination.reset() - } - - const applyEventFilters = useCallback(() => { - setAppliedEventFilters((prev) => ({ - ...prev, - from: draftEventFilters.from, - to: draftEventFilters.to, - preset: draftEventFilters.preset, - })) - setEventPage(1) - }, [draftEventFilters.from, draftEventFilters.to, draftEventFilters.preset]) - - const resetEventFilters = useCallback(() => { - const defaults = defaultEventsFilters() - setAppliedEventFilters(defaults) - setDraftEventFilters(defaults) - setEventPage(1) - }, []) - - const applyIncomeFilters = useCallback(() => { - setAppliedIncomeFilters((prev) => ({ - ...prev, - from: draftIncomeFilters.from, - to: draftIncomeFilters.to, - preset: draftIncomeFilters.preset, - })) - incomePagination.reset() - }, [draftIncomeFilters.from, draftIncomeFilters.to, draftIncomeFilters.preset, incomePagination]) - - const resetIncomeFilters = useCallback(() => { - const defaults = defaultIncomeFilters() - setAppliedIncomeFilters(defaults) - setDraftIncomeFilters(defaults) - incomePagination.reset() - }, [incomePagination]) - - function handleDraftEventPresetChange(preset: DashboardDatePreset) { - setDraftEventFilters((filters) => applyEventDatePreset(filters, preset)) - } - - function handleDraftEventFromChange(value: string) { - setDraftEventFilters((filters) => ({ ...filters, from: value })) - } - - function handleDraftEventToChange(value: string) { - setDraftEventFilters((filters) => ({ ...filters, to: value })) - } - - function handleDraftIncomePresetChange(preset: DashboardDatePreset) { - setDraftIncomeFilters((filters) => applyDatePreset(filters, preset)) - } - - function handleDraftIncomeFromChange(value: string) { - setDraftIncomeFilters((filters) => ({ ...filters, from: value })) - } - - function handleDraftIncomeToChange(value: string) { - setDraftIncomeFilters((filters) => ({ ...filters, to: value })) - } - return ( - - 1} - canGoForward={eventPage * eventPageSize < eventItems.length} - onPreviousPage={() => setEventPage((page) => Math.max(1, page - 1))} - onNextPage={() => setEventPage((page) => page + 1)} - /> - 1} - canGoForward={operations.data?.hasNext ?? false} - onPreviousPage={incomePagination.handlePrevious} - onNextPage={() => incomePagination.handleNext(nextCursor)} + + ) } diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAllocationCard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAllocationCard.tsx index d7ed14e..b9c40e8 100644 --- a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAllocationCard.tsx +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAllocationCard.tsx @@ -8,11 +8,11 @@ import { BrokerDashboardCard } from './BrokerDashboardCard' const ALLOCATION_COLORS: Record = { shares: '#4969f5', bonds: '#e5a33c', - etf: '#62b889', cash: '#7b63cf', - other: '#aeb6c5', } +const VISIBLE_SECTORS = new Set(['shares', 'bonds', 'cash']) + export function BrokerDashboardAllocationCard({ portfolio }: { portfolio: BrokerPortfolio }) { const { total, sectors, negative } = buildBrokerAllocation(portfolio) const currency = @@ -22,24 +22,19 @@ export function BrokerDashboardAllocationCard({ portfolio }: { portfolio: Broker 'RUB') : 'RUB' + const visibleSectors = sectors.filter((s) => VISIBLE_SECTORS.has(s.key)) + const visibleNegative = negative.filter((n) => VISIBLE_SECTORS.has(n.key)) + return ( - {formatBrokerMoney(portfolio.totals.portfolio)}} - > - {sectors.length === 0 && negative.length === 0 ? ( + + + {formatBrokerMoney(portfolio.totals.portfolio)} + + {visibleSectors.length === 0 && visibleNegative.length === 0 ? ( Нет данных для распределения ) : ( - - - Структура портфеля - - - {formatBrokerMoney(portfolio.totals.portfolio)} - - - {sectors.map((sector) => ( + {visibleSectors.map((sector) => ( {sector.label} @@ -67,9 +62,9 @@ export function BrokerDashboardAllocationCard({ portfolio }: { portfolio: Broker ))} - {negative.length > 0 && ( + {visibleNegative.length > 0 && ( - {negative.map((item) => ( + {visibleNegative.map((item) => ( {item.label}: diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx index 40e9c5e..838460f 100644 --- a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx @@ -1,21 +1,16 @@ -import { Metric, Text } from '@moex-vibe/design-system' +import { Skeleton, Text } from '@moex-vibe/design-system' import { Box } from '@mui/material' -import type { BrokerAnalytics } from '@/shared/api' -import { - formatDashboardCurrency, - type MoneyTone, - moneyTone, - moneyToneToColor, -} from '../lib/dashboardVisual' +import type { BrokerAnalytics, BrokerMoney } from '@/shared/api' +import { formatDashboardCurrency, type MoneyTone, moneyTone } from '../lib/dashboardVisual' import { BrokerDashboardCard } from './BrokerDashboardCard' type AnalyticsField = | 'totalDeposits' | 'totalWithdrawn' - | 'netInvested' | 'totalDividends' | 'totalCoupons' - | 'totalReceived' + | 'totalFees' + | 'totalTaxesPaid' const ANALYTICS_METRICS: readonly { field: AnalyticsField @@ -24,13 +19,18 @@ const ANALYTICS_METRICS: readonly { }[] = [ { field: 'totalDeposits', label: 'Пополнения', testId: 'dashboard-analytics-totalDeposits' }, { field: 'totalWithdrawn', label: 'Выводы', testId: 'dashboard-analytics-totalWithdrawn' }, - { field: 'netInvested', label: 'Нетто', testId: 'dashboard-analytics-netInvested' }, { field: 'totalDividends', label: 'Дивиденды', testId: 'dashboard-analytics-totalDividends' }, { field: 'totalCoupons', label: 'Купоны', testId: 'dashboard-analytics-totalCoupons' }, - { field: 'totalReceived', label: 'Всего получено', testId: 'dashboard-analytics-totalReceived' }, + { field: 'totalFees', label: 'Комиссия', testId: 'dashboard-analytics-totalFees' }, + { + field: 'totalTaxesPaid', + label: 'Уплаченные налоги', + testId: 'dashboard-analytics-totalTaxesPaid', + }, ] function analyticsTone(field: AnalyticsField, value: number): MoneyTone { + if (field === 'totalFees' || field === 'totalTaxesPaid') return 'negative' if (field === 'totalWithdrawn') { return value > 0 ? 'negative' : moneyTone(value) } @@ -38,17 +38,23 @@ function analyticsTone(field: AnalyticsField, value: number): MoneyTone { } function analyticsDisplay(field: AnalyticsField, value: number, currency: string): string { + if (field === 'totalFees' || field === 'totalTaxesPaid') { + const formatted = formatDashboardCurrency({ currency, value: Math.abs(value) }) + return `\u2212${formatted}` + } const formatted = formatDashboardCurrency({ currency, value }) - if (field === 'totalWithdrawn' && value > 0) return `−${formatted}` + if (field === 'totalWithdrawn' && value > 0) return `\u2212${formatted}` return formatted } export function BrokerDashboardAnalyticsCard({ data, + portfolioValue, isLoading, isError, }: { data: BrokerAnalytics | undefined + portfolioValue?: BrokerMoney isLoading: boolean isError: boolean }) { @@ -57,52 +63,194 @@ export function BrokerDashboardAnalyticsCard({ {isError ? ( Не удалось загрузить аналитику ) : isLoading ? ( - Загрузка аналитики… - ) : !data ? ( - Нет данных для аналитики - ) : ( - - {ANALYTICS_METRICS.map(({ field, label, testId }) => { - const value = data[field] - const tone = analyticsTone(field, value) - return ( + + + {Array.from({ length: 6 }, (_, i) => ( + + + + + ))} + + + ) : !data ? ( + Нет данных для аналитики + ) : ( + + + + + Стоимость портфеля + + + {formatDashboardCurrency({ + currency: portfolioValue?.currency ?? data.currency, + value: portfolioValue?.value ?? 0, + })} + + + + + Всего доходов + + + {formatDashboardCurrency({ + currency: data.currency, + value: data.totalDividends + data.totalCoupons, + })} + + + + + {ANALYTICS_METRICS.map(({ field, label, testId }) => { + const value = data[field] + const tone = analyticsTone(field, value) + return ( + + + {label} + + + {analyticsDisplay(field, value, data.currency)} + + + ) + })} + )} diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx index 0cbe672..eea01fa 100644 --- a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx @@ -47,7 +47,11 @@ export function BrokerDashboardCard({ }} > - + {title} {badge ? ( diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx index ab9d494..40992bd 100644 --- a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx @@ -1,35 +1,12 @@ -import { Button, Chip, Text } from '@moex-vibe/design-system' +import { Text } from '@moex-vibe/design-system' import { Box } from '@mui/material' import { Link } from '@tanstack/react-router' -import type { BrokerEventItem, BrokerEventsData } from '@/shared/api' +import { useState } from 'react' +import type { BrokerOperation } from '@/shared/api' import { formatBrokerDate } from '@/shared/lib/formatters' -import type { DashboardDatePreset, DashboardEventType } from '../lib/dashboardFilters' -import { eventStatusLabel, eventTypeLabel } from '../lib/dashboardFormatters' -import { - eventStatusTone, - eventTypeTone, - formatDashboardCurrency, - instrumentDisplay, - type MoneyTone, - moneyTone, - moneyToneToColor, - type TypeTone, -} from '../lib/dashboardVisual' +import { formatDashboardCurrency, moneyToneToColor } from '../lib/dashboardVisual' import { BrokerDashboardCard } from './BrokerDashboardCard' -import { BrokerDashboardDateFilter } from './BrokerDashboardDateFilter' import { BrokerDashboardTableSkeleton } from './BrokerDashboardTableSkeleton' -import { BrokerDashboardTableToolbar } from './BrokerDashboardTableToolbar' - -const EVENT_FILTERS: Array<{ - type: DashboardEventType - label: string - tone: TypeTone -}> = [ - { type: 'dividend', label: 'Дивиденды', tone: 'success' }, - { type: 'coupon', label: 'Купоны', tone: 'info' }, - { type: 'maturity', label: 'Погашения', tone: 'warning' }, - { type: 'offer', label: 'Оферты', tone: 'neutral' }, -] const TH_SX = { textAlign: 'left' as const, @@ -62,50 +39,113 @@ const TD_SX_INSTRUMENT = { minWidth: 180, } +const ITEMS_PER_PAGE = 7 + +const BADGE_STYLES: Record = { + coupon: { bg: '#e8f0ff', borderColor: '#cddcff', color: '#2563eb' }, + dividend: { bg: '#e5f2ea', borderColor: '#bcdcc9', color: '#176747' }, + maturity: { bg: '#fff5dc', borderColor: '#f0d898', color: '#8d6400' }, + tax: { bg: '#fef0ef', borderColor: '#f6c6c2', color: '#b42318' }, + fee: { bg: '#fef0ef', borderColor: '#f6c6c2', color: '#b42318' }, +} + +type BadgeInfo = { label: string; badgeKey: string } + type BrokerDashboardEventsCardProps = { accountId: string - data: BrokerEventsData | undefined + data: BrokerOperation[] | undefined isLoading: boolean isError: boolean - selectedTypes: DashboardEventType[] - onToggleType: (type: DashboardEventType) => void - appliedDateLabel: string | null - draftPreset: DashboardDatePreset - draftFrom: string - draftTo: string - onDraftPresetChange: (preset: DashboardDatePreset) => void - onDraftFromChange: (value: string) => void - onDraftToChange: (value: string) => void - onApplyFilters: () => void - onResetFilters: () => void - hasDraftTypes: boolean - totalCount?: number - page: number - onPreviousPage: () => void - onNextPage: () => void - canGoBack: boolean - canGoForward: boolean } -function eventAmountValue(event: BrokerEventItem): number | null { - return event.source === 'actual' ? event.actualAmount : event.estimatedAmount +function getOperationBadge(operation: BrokerOperation): BadgeInfo { + if (operation.category === 'tax') return { label: 'Налог', badgeKey: 'tax' } + if (operation.category === 'fee') return { label: 'Комиссия', badgeKey: 'fee' } + if (operation.category === 'income') { + if ( + operation.type === 'OPERATION_TYPE_DIVIDEND' || + operation.type === 'OPERATION_TYPE_DIV_EXT' + ) { + return { label: 'Дивиденд', badgeKey: 'dividend' } + } + if (operation.type === 'OPERATION_TYPE_COUPON') { + return { label: 'Купон', badgeKey: 'coupon' } + } + if ( + operation.type === 'OPERATION_TYPE_BOND_REPAYMENT' || + operation.type === 'OPERATION_TYPE_BOND_REPAYMENT_FULL' || + operation.type === 'OPERATION_TYPE_MATURITY' + ) { + return { label: 'Погашение', badgeKey: 'maturity' } + } + return { label: 'Доход', badgeKey: '' } + } + return { label: 'Прочее', badgeKey: '' } } -function eventMoneyTone(event: BrokerEventItem): MoneyTone { - return moneyTone(eventAmountValue(event), event.source) +function Badge({ label, badgeKey }: { label: string; badgeKey: string }) { + const style = BADGE_STYLES[badgeKey] + return ( + + {label} + + ) } -function eventAmountDisplay(event: BrokerEventItem): string { - const amount = eventAmountValue(event) - if (amount === null || amount === undefined) return '—' - const abs = formatDashboardCurrency({ - currency: event.currency ?? 'RUB', - value: Math.abs(amount), - }) - if (event.source === 'forecast') return `~${abs}` - if (amount > 0) return `+${abs}` - if (amount < 0) return `−${abs}` - return abs +function PagerButton({ + disabled, + label, + onClick, +}: { + disabled?: boolean + label: string + onClick?: () => void +}) { + return ( + + {label} + + ) } export function BrokerDashboardEventsCard({ @@ -113,188 +153,132 @@ export function BrokerDashboardEventsCard({ data, isLoading, isError, - selectedTypes, - onToggleType, - appliedDateLabel, - draftPreset, - draftFrom, - draftTo, - onDraftPresetChange, - onDraftFromChange, - onDraftToChange, - onApplyFilters, - onResetFilters, - hasDraftTypes, - totalCount, - page, - onPreviousPage, - onNextPage, - canGoBack, - canGoForward, }: BrokerDashboardEventsCardProps) { - const events = data?.items ?? [] + const [page, setPage] = useState(0) + const operations = data ?? [] + const totalPages = Math.max(1, Math.ceil(operations.length / ITEMS_PER_PAGE)) + const safePage = Math.min(page, totalPages - 1) + const pageStart = safePage * ITEMS_PER_PAGE + const pageEnd = pageStart + ITEMS_PER_PAGE + const pageItems = operations.slice(pageStart, pageEnd) return ( 0 ? `${events.length} из ${totalCount ?? events.length}` : undefined} + title="Последние события" action={Все события} - filters={ - ( - onToggleType(filter.type)} - /> - ))} - > - - - } > - {selectedTypes.length === 0 ? ( - Выберите хотя бы один тип событий - ) : isError ? ( + {isError ? ( Не удалось загрузить события ) : isLoading ? ( - - ) : events.length === 0 ? ( - В ближайшем периоде событий нет + + ) : operations.length === 0 ? ( + Событий нет ) : ( - - - - - - - Дата - - - Инструмент - - - Тип - - - Сумма - - - Статус - + + + + + + Дата + + + Инструмент + + + Тип + + + Сумма - - {events.map((event) => { - const display = instrumentDisplay({ - ticker: event.ticker, - name: event.name, - }) - const formattedAmount = eventAmountDisplay(event) - return ( - - - {formatBrokerDate(event.eventDate) ?? '—'} - - - - - {display.main} - - {display.subtitle ? ( - - {display.subtitle} - - ) : null} + + + {pageItems.map((op) => { + const boldText = op.name ?? op.description ?? op.ticker ?? '—' + const grayText = op.name ? (op.ticker ?? null) : null + const { label, badgeKey } = getOperationBadge(op) + const amountValue = op.payment?.value ?? 0 + const amountTone = amountValue >= 0 ? 'positive' : 'negative' + const formattedAmount = `${amountValue >= 0 ? '+' : '\u2212'}${formatDashboardCurrency( + op.payment + ? { currency: op.payment.currency, value: Math.abs(amountValue) } + : { currency: 'RUB', value: 0 }, + )}` + return ( + + + {formatBrokerDate(typeof op.date === 'string' ? op.date : null) ?? '—'} + + + + + {boldText} - - - - - - {formattedAmount} - - - + {grayText ? ( + + {grayText} + + ) : null} - ) - })} - + + + + + {formattedAmount} + + + ) + })} - - Показано {events.length} событий за выбранный период - - - - {page} - - + setPage((p) => Math.max(0, p - 1))} + /> + + {safePage + 1} + + = totalPages - 1} + label="→" + onClick={() => setPage((p) => p + 1)} + /> diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerPortfolioHistoryCard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerPortfolioHistoryCard.tsx new file mode 100644 index 0000000..a094359 --- /dev/null +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerPortfolioHistoryCard.tsx @@ -0,0 +1,208 @@ +import { Skeleton, Text } from '@moex-vibe/design-system' +import { Box } from '@mui/material' +import type { BrokerMoney, BrokerPortfolioHistoryData } from '@/shared/api' +import { formatDashboardCurrency } from '../lib/dashboardVisual' +import { BrokerDashboardCard } from './BrokerDashboardCard' + +type BrokerPortfolioHistoryCardProps = { + data: BrokerPortfolioHistoryData | undefined + portfolioValue: BrokerMoney | undefined + isLoading: boolean + isError: boolean +} + +function generateChartPath(points: { value: BrokerMoney }[]): { line: string; area: string } { + const values = points.map((p) => p.value.value) + const min = Math.min(...values) + const max = Math.max(...values) + const range = max - min || 1 + const padding = range * 0.1 + + const viewWidth = 300 + const viewHeight = 90 + const padLeft = 10 + const padRight = 10 + const padTop = 5 + const padBottom = 5 + const plotWidth = viewWidth - padLeft - padRight + const plotHeight = viewHeight - padTop - padBottom + const yMin = min - padding + const yRange = max + padding - yMin + + function x(i: number) { + return padLeft + (i / (points.length - 1)) * plotWidth + } + + function y(val: number) { + return padTop + plotHeight - ((val - yMin) / yRange) * plotHeight + } + + let lineCmd = `M ${x(0)},${y(values[0])}` + for (let i = 1; i < points.length; i++) { + const x0 = x(i - 1) + const y0 = y(values[i - 1]) + const x1 = x(i) + const y1 = y(values[i]) + const cx1 = x0 + (x1 - x0) / 2 + const cx2 = x0 + (x1 - x0) / 2 + lineCmd += ` C ${cx1},${y0} ${cx2},${y1} ${x1},${y1}` + } + + const bottom = viewHeight + const areaCmd = `${lineCmd} L ${x(points.length - 1)},${bottom} L ${x(0)},${bottom} Z` + + return { line: lineCmd, area: areaCmd } +} + +export function BrokerPortfolioHistoryCard({ + data, + portfolioValue, + isLoading, + isError, +}: BrokerPortfolioHistoryCardProps) { + return ( + + {(() => { + if (isError) { + return Не удалось загрузить историю портфеля + } + + if (isLoading || !data) { + return ( + + ) +} diff --git a/docs/features/broker-account-overview-html-parity/plan.md b/docs/features/broker-account-overview-html-parity/plan.md new file mode 100644 index 0000000..2426d00 --- /dev/null +++ b/docs/features/broker-account-overview-html-parity/plan.md @@ -0,0 +1,329 @@ +# Финальный HTML Parity брокерского overview — план реализации + +> **Для агентных исполнителей:** ОБЯЗАТЕЛЬНЫЙ SUB-SKILL: использовать +> `superpowers:subagent-driven-development` (предпочтительно) или `superpowers:executing-plans` для +> пошагового выполнения. Шаги ведутся чекбоксами `- [ ]`. + +**Цель:** привести `/broker/:accountId` к финальному HTML-эталону +`docs/research/frontend-overview-redesign/example.html` без изменения URL-структуры счёта. + +**Архитектура:** backend расширяет существующий broker read API минимальными агрегатами и историей +стоимости. Frontend остаётся в FSD-границах `entities/broker-*`, `widgets/broker-dashboard`, +`widgets/broker-account-layout`; dashboard-композиция меняется с промежуточной версии на финальную +HTML parity структуру. + +**Технологии:** NestJS, Prisma/T-Bank broker operations read path, Swagger/OpenAPI codegen, React 18, +TanStack Query, TanStack Router, MUI + `@moex-vibe/design-system`, Vitest, Testing Library. + +--- + +## Canonical Research Source + +- Использовать как visual source of truth: + `docs/research/frontend-overview-redesign/example.html`. +- Не использовать как source of truth: + `docs/research/2026-06-27-broker-account-redesign.html`, пока файл не синхронизирован с финальным + макетом. +- Production UI не переносит demo-control `Данные / Загрузка`; этот control нужен только HTML-макету. + +## Файлы + +### Backend + +- Modify: `apps/backend/src/modules/tbank/dto/broker-analytics-response.dto.ts` — добавить + `totalFees`, `totalTaxesPaid`. +- Create: `apps/backend/src/modules/tbank/dto/broker-portfolio-history-response.dto.ts` — DTO для + 6-месячной истории стоимости. +- Modify: `apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts` — envelope для history endpoint. +- Modify: `apps/backend/src/modules/tbank/dto/broker-operation-query.dto.ts` — query `categories`. +- Modify: `apps/backend/src/modules/tbank/services/broker-analytics.service.ts` — агрегировать fee/tax. +- Create: `apps/backend/src/modules/tbank/services/broker-portfolio-history.service.ts` — read-model + истории стоимости. +- Modify: `apps/backend/src/modules/tbank/services/broker-operations.service.ts` — применять + category-фильтр до ответа. +- Modify: `apps/backend/src/modules/tbank/tbank.controller.ts` — endpoint portfolio history. +- Modify: backend tests рядом с изменёнными сервисами/controller. + +### Frontend data + +- Modify: `apps/frontend/src/shared/api/index.ts` — экспорт нового `BrokerPortfolioHistory`. +- Create: `apps/frontend/src/entities/broker-account/api/brokerPortfolioHistoryApi.ts`. +- Create: `apps/frontend/src/entities/broker-account/model/useBrokerPortfolioHistory.ts`. +- Modify: `apps/frontend/src/entities/broker-account/index.ts` — экспорт hook/API. +- Modify: `apps/frontend/src/entities/broker-operation/api/brokerOperationApi.ts` — query `categories`. + +### Frontend UI + +- Modify: `apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx` — title yield + рядом с заголовком счёта, без page-header skeleton. +- Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx` — финальный порядок блоков + и источники данных. +- Replace/Remove: `BrokerDashboardHero.tsx` из overview-композиции; файл можно оставить только если + больше используется в тестах/экспортах, но в `/broker/:accountId` он не рендерится. +- Create: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerPortfolioHistoryCard.tsx`. +- Modify: `BrokerDashboardAnalyticsCard.tsx`, `BrokerDashboardAllocationCard.tsx`, + `BrokerDashboardEventsCard.tsx`, `BrokerDashboardSkeleton.tsx`. +- Modify/Create helpers в `apps/frontend/src/widgets/broker-dashboard/lib/` для chart points, latest + event rows, analytics display и visual tones. +- Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx` и helper unit tests. + +### Docs + +- Modify: `docs/features/broker-account-overview-html-parity/tasks.md` по факту выполнения. +- Не переписывать `docs/features/broker-dashboard-redesign/*`; старая фича остаётся историей + промежуточной итерации. + +## Data Contracts + +### Analytics + +`BrokerAnalyticsDto` расширяется: + +```ts +totalFees: number +totalTaxesPaid: number +``` + +Расчёт: + +- `totalFees` = сумма absolute payment values executed операций категории `fee`; +- `totalTaxesPaid` = сумма absolute payment values executed операций категории `tax`; +- значения возвращаются как положительные агрегаты; +- frontend отображает их со знаком минус и negative tone. + +### Portfolio History + +Endpoint: + +```text +GET /api/v1/broker/accounts/:accountId/portfolio/history?months=6 +``` + +Response data: + +```ts +type BrokerPortfolioHistoryData = { + accountId: string + points: Array<{ + month: string + label: string + value: BrokerMoneyDto + }> + asOf: string +} +``` + +Правила v1: + +- `months` по умолчанию 6, допустимый диапазон 2-12; +- `points.length === months`; +- `month` в формате `YYYY-MM`; +- `label` — короткое русское имя месяца; +- последняя точка равна текущей `portfolio.totals.portfolio`; +- предыдущие точки могут быть estimated read-model из текущей стоимости и executed операций за период; +- контракт должен позволять позже заменить estimated calculation на persisted snapshots. + +### Operation Categories + +`BrokerOperationQueryDto` получает: + +```ts +categories?: string +``` + +Правила: + +- comma-separated значения из `trade,income,tax,fee,transfer,other`; +- неизвестные категории игнорировать или валидировать с `400`; выбрать один вариант и покрыть тестом; +- filtering выполняется до формирования page response; +- overview `Последние события` запрашивает `categories=income,tax,fee`, `state=OPERATION_STATE_EXECUTED`, + `limit=7`. + +## Implementation Tasks + +### Task 1: Docs and research alignment + +**Files:** +- `docs/features/broker-account-overview-html-parity/spec.md` +- `docs/features/broker-account-overview-html-parity/plan.md` +- `docs/features/broker-account-overview-html-parity/tasks.md` + +- Проверить, что docs ссылаются на `docs/research/frontend-overview-redesign/example.html`. +- Проверить, что docs явно запрещают использовать старый dated HTML как canonical reference. +- Зафиксировать финальный порядок блоков и отсутствие production demo-toggle. + +### Task 2: Backend analytics contract + +**Files:** +- `apps/backend/src/modules/tbank/dto/broker-analytics-response.dto.ts` +- `apps/backend/src/modules/tbank/services/broker-analytics.service.ts` +- `apps/backend/src/modules/tbank/services/broker-analytics.service.spec.ts` +- `apps/backend/src/modules/tbank/tbank.controller.spec.ts` + +- Добавить `totalFees`, `totalTaxesPaid` в DTO и тестовый response. +- Расширить analytics service наборами fee/tax типов через существующую категоризацию операций. +- Считать fee/tax только по executed операциям с `payment`. +- Возвращать округление до копеек аналогично текущим analytics агрегатам. + +### Task 3: Backend portfolio history endpoint + +**Files:** +- `apps/backend/src/modules/tbank/dto/broker-portfolio-history-response.dto.ts` +- `apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts` +- `apps/backend/src/modules/tbank/services/broker-portfolio-history.service.ts` +- `apps/backend/src/modules/tbank/tbank.controller.ts` +- `apps/backend/src/modules/tbank/tbank.controller.spec.ts` + +- Добавить DTO для history point и envelope. +- Добавить service, который проверяет account access через `BrokerAccountsService`. +- Получить текущую стоимость через existing portfolio service или общий read path без дублирования T-Bank + вызовов сверх необходимого. +- Вернуть 6 monthly points для default query. +- Покрыть default months, invalid account и shape response тестами. + +### Task 4: Backend operation category filtering + +**Files:** +- `apps/backend/src/modules/tbank/dto/broker-operation-query.dto.ts` +- `apps/backend/src/modules/tbank/services/broker-operations.service.ts` +- `apps/backend/src/modules/tbank/services/broker-operations.service.spec.ts` + +- Добавить `categories` query. +- Применить category filtering к mapped operations page перед отдачей response. +- Если T-Bank page может содержать меньше 7 подходящих операций после фильтрации, documented v1 поведение: + endpoint возвращает подходящие операции из текущей fetched page; full backfill pagination не требуется. +- Покрыть `categories=income,tax,fee` и неизвестную категорию тестом. + +### Task 5: OpenAPI and frontend generated types + +**Files:** +- `apps/frontend/src/shared/api/types.ts` +- `apps/frontend/src/shared/api/index.ts` + +- Запустить backend dev server. +- Выполнить `npm run codegen -w apps/frontend`. +- Не редактировать generated `types.ts` вручную. +- Экспортировать новые frontend aliases из `shared/api/index.ts`. +- Проверить, что generated schemas содержат `totalFees`, `totalTaxesPaid`, + `BrokerPortfolioHistoryDataDto`. + +### Task 6: Frontend data hooks + +**Files:** +- `apps/frontend/src/entities/broker-account/api/brokerPortfolioHistoryApi.ts` +- `apps/frontend/src/entities/broker-account/model/useBrokerPortfolioHistory.ts` +- `apps/frontend/src/entities/broker-account/index.ts` +- `apps/frontend/src/entities/broker-operation/api/brokerOperationApi.ts` + +- Добавить `getBrokerPortfolioHistory(accountId, { months })`. +- Добавить `useBrokerPortfolioHistory(accountId, { months: 6 })` с query key + `['broker', 'portfolio-history', accountId, months]`. +- Добавить `categories` в `BrokerOperationQuery`. +- Не менять существующие hooks detailed вкладок. + +### Task 7: Page title yield + +**Files:** +- `apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx` +- `apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx` +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx` + +- Перенести compact yield UI рядом с `Брокерский счёт`. +- Убрать отдельный hero KPI из overview. +- Не показывать page-title skeleton в loading state. +- Сохранить доступность: доходность имеет `aria-label="Доходность счёта"` или эквивалент. + +### Task 8: Portfolio history card + +**Files:** +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerPortfolioHistoryCard.tsx` +- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardPortfolioHistory.ts` +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx` +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardSkeleton.tsx` + +- Создать карточку `Стоимость портфеля за 6 месяцев`. +- Нарисовать SVG line/area chart без visible point markers. +- Использовать 6 backend points и подписи месяцев из response. +- Сделать loading chart indicator без skeleton месяцев. +- Обеспечить одинаковую min-height loaded/loading. +- Первая и последняя точки графика должны совпадать с краями области. + +### Task 9: Analytics card parity + +**Files:** +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx` +- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts` +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx` + +- Summary row: `Стоимость портфеля`, `Всего доходов`. +- Detail grid: `Пополнения`, `Выводы`, `Дивиденды`, `Купоны`, `Комиссия`, + `Уплаченные налоги`. +- Удалить `Нетто` и `Всего получено` из overview-card. +- Отображать `totalFees` и `totalTaxesPaid` как negative UI amounts. +- Сохранить skeleton геометрию 2 + 6 карточек. + +### Task 10: Allocation card parity + +**Files:** +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAllocationCard.tsx` +- `apps/frontend/src/entities/broker-position/model/brokerAllocation.ts` + +- Переименовать карточку в `Структура`. +- Убрать subtitle `Структура портфеля`. +- Показать итоговую стоимость под заголовком. +- Отобразить только строки `Акции`, `Облигации`, `Деньги` для overview parity. +- Сохранить корректное поведение для отсутствующих/нулевых значений. + +### Task 11: Latest events card parity + +**Files:** +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx` +- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardEvents.ts` +- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts` +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx` + +- Переключить overview source с `useBrokerEvents` на `useBrokerOperations` с + `categories=income,tax,fee`, `limit=7`. +- Убрать filters toolbar, count badge, footer summary и колонку `Статус`. +- Переименовать карточку в `Последние события`. +- Отсортировать rows новые → старые. +- Инструмент: название сверху жирным, ticker/ISIN снизу серым. +- Тип: бейдж `Дивиденд`, `Купон`, `Погашение`, `Налог`, `Комиссия`. +- Налоговые/комиссионные/отрицательные операции отображать красным. + +### Task 12: Tests, build, visual QA + +**Files:** +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx` +- backend specs из предыдущих задач +- `docs/features/broker-account-overview-html-parity/tasks.md` + +- Backend targeted tests: + `npm run test -w apps/backend -- src/modules/tbank/services/broker-analytics.service.spec.ts src/modules/tbank/services/broker-operations.service.spec.ts src/modules/tbank/tbank.controller.spec.ts` +- Frontend targeted tests: + `npm run test -w apps/frontend -- --run src/widgets/broker-dashboard` +- Full checks: + `npm run test:frontend` + `npm run lint -w apps/frontend` + `npm run build:frontend` +- Visual QA: + desktop `/broker/:accountId`; + mobile viewport `390x844`; + compare against `docs/research/frontend-overview-redesign/example.html`. +- Update `tasks.md` statuses and notes after verification. + +## Risks and Decisions + +- History chart v1 uses estimated read-model; exact historical market value requires future snapshots. +- Category filtering after one fetched T-Bank page may underfill latest events; acceptable for v1 unless + testing shows too many empty overview rows. +- The HTML mock uses static values. React must match structure and visual behavior, not literal amounts. +- `Последние события` is intentionally based on executed operations, not calendar events, because the + final HTML shows already happened cashflow rows and removes status/forecast semantics. + +## Verification Matrix + +- Spec requirements map to tasks 2-11. +- Backend API requirements map to tasks 2-5. +- Frontend order and visual parity map to tasks 7-11. +- Loading stability and mobile overflow are verified in task 12. diff --git a/docs/features/broker-account-overview-html-parity/spec.md b/docs/features/broker-account-overview-html-parity/spec.md new file mode 100644 index 0000000..2dce3d3 --- /dev/null +++ b/docs/features/broker-account-overview-html-parity/spec.md @@ -0,0 +1,208 @@ +# Финальный редизайн обзора брокерского счёта по HTML-эталону + +Дата: 2026-06-27 +Статус: спецификация подготовлена +Эпик: [Портфель брокера](../../epics/BrokerPortfolio.md) + +## Контекст + +Маршрут `/broker/:accountId` уже был переведён на dashboard-композицию в рамках +`docs/features/broker-dashboard-redesign`, но эта итерация отражает промежуточный вариант: +`Hero → События → Доходы → Аналитика доходности → Аллокация`. + +После интерактивной дизайн-итерации пользователь согласовал финальный HTML-эталон в +`docs/research/frontend-overview-redesign/example.html`. Именно этот файл является canonical visual +reference для текущей фичи. Файл `docs/research/2026-06-27-broker-account-redesign.html` содержит более +раннюю версию и не должен использоваться как источник истины, пока не будет синхронизирован. + +Финальный экран должен выглядеть как HTML-эталон, но работать на реальных данных приложения, +существующих FSD-границах, backend API и текущей светлой теме MoexVibe. + +## Цель + +Переделать обзор брокерского счёта `/broker/:accountId` так, чтобы production React-страница визуально +и поведенчески соответствовала финальному HTML-эталону: + +`Заголовок счёта → Стоимость портфеля за 6 месяцев → Аналитика доходности → Структура → Последние события`. + +## Пользовательский результат + +Пользователь на `/broker/:accountId` может: + +- сразу увидеть название счёта, текущую доходность и дневное изменение; +- увидеть график стоимости портфеля за последние 6 месяцев; +- увидеть ключевые показатели доходности, включая комиссии и уплаченные налоги; +- увидеть структуру портфеля в компактном bar-chart виде; +- увидеть последние уже произошедшие события/денежные операции по счёту; +- перейти в подробные вкладки `Акции`, `Облигации`, `Операции`, `События`, `Аналитика`. + +## Область изменений + +Входит в scope: + +- frontend-маршрут `/broker/:accountId` и виджеты `widgets/broker-dashboard`; +- layout заголовка счёта в `widgets/broker-account-layout`; +- backend read endpoints брокерского домена, необходимые для честного отображения HTML parity; +- OpenAPI/codegen для новых или расширенных DTO; +- тесты backend/frontend и визуальная проверка desktop/mobile. + +Не входит в scope: + +- редизайн подробных вкладок `Акции`, `Облигации`, `Операции`, `События`, `Аналитика`; +- dark mode; +- demo-переключатель `Данные / Загрузка` из HTML-эталона; +- полноценное хранилище исторических снапшотов портфеля; +- изменение URL-структуры `/broker/:accountId/*`. + +## Требования + +### 1. Общая композиция + +- `/broker/:accountId` остаётся overview выбранного брокерского счёта. +- Страница использует порядок блоков из HTML-эталона: + `Заголовок счёта`, `Стоимость портфеля за 6 месяцев`, `Аналитика доходности`, `Структура`, + `Последние события`. +- Отдельный hero KPI-блок удаляется из overview-композиции. +- Блок `Доходы` удаляется из overview-композиции. Подробные доходные операции остаются доступны через + вкладку `Операции`. +- Существующая навигация счёта остаётся горизонтальными вкладками над dashboard-контентом. +- На desktop и mobile блоки идут одной колонкой в одинаковом порядке. +- Production UI не содержит demo-toggle `Данные / Загрузка`. + +### 2. Заголовок счёта + +- Заголовок показывает название счёта или fallback `Брокерский счёт`. +- Справа от заголовка, визуально меньшим блоком, показывается доходность счёта: + `Доходность`, значение процента и supporting text `За день: ...`. +- Доходность берётся из `analytics.totalReturnPercent`, если доступна, иначе из + `portfolio.yields.expectedPercent`, иначе отображается `—`. +- Дневное изменение берётся из `portfolio.yields.daily`, иначе отображается `—`. +- Отрицательная доходность окрашивается красным, положительная зелёным, недоступная нейтральным. +- В loading-состоянии заголовок не показывает skeleton-полосы. Layout должен сохранять стабильную + высоту и не прыгать после загрузки. + +### 3. Стоимость портфеля за 6 месяцев + +- Первый dashboard-блок называется `Стоимость портфеля за 6 месяцев`. +- Карточка использует зелёный градиентный фон и тонкую зелёную рамку как в HTML-эталоне. +- В карточке отображаются: + - label `Текущая стоимость`; + - текущая стоимость портфеля из `portfolio.totals.portfolio`; + - плавный line/area chart по 6 месячным точкам. +- График показывает ровно 6 подписей месяцев и 6 значений, соответствующих этим месяцам. +- Первая точка линии начинается у левого края области графика, последняя — у правого края. +- Visible point markers не отображаются; линия плавная. +- Подписи месяцев не выходят за границы карточки. +- Loading-состояние графика использует chart-like indicator без skeleton-подписей месяцев. +- Высота карточки в loading и loaded состояниях должна совпадать. + +### 4. Аналитика доходности + +- Блок называется `Аналитика доходности`. +- Верхний summary-row содержит только: + - `Стоимость портфеля`; + - `Всего доходов`. +- Detail-grid содержит ровно: + - `Пополнения`; + - `Выводы`; + - `Дивиденды`; + - `Купоны`; + - `Комиссия`; + - `Уплаченные налоги`. +- Поля `Нетто` и `Всего получено` не отображаются в overview-карточке. +- RUB отображается как `₽`, а не `RUB`. +- Положительные финансовые значения окрашиваются зелёным, отрицательные/уменьшающие баланс — + красным. +- `Комиссия` и `Уплаченные налоги` отображаются как отрицательные UI-суммы, даже если backend хранит + агрегат абсолютным положительным числом. +- Loading-состояние использует skeleton-карточки той же геометрии, чтобы интерфейс не прыгал. + +### 5. Структура + +- Блок называется `Структура`. +- Под заголовком показывается итоговая стоимость портфеля. +- Под итогом отображаются горизонтальные бары: + - `Акции`; + - `Облигации`; + - `Деньги`. +- Каждая строка показывает название сектора, сумму и процент. +- Цвета соответствуют HTML-эталону: акции — синий, облигации — янтарный, деньги — фиолетовый. +- Если значение сектора отсутствует или равно нулю, строка остаётся читаемой и не ломает layout. +- Общая карточка не содержит subtitle `Структура портфеля`. + +### 6. Последние события + +- Блок называется `Последние события`. +- Блок показывает только уже произошедшие записи, которые меняют или отражают денежный поток счёта. +- Источник данных — executed broker operations с категориями `income`, `tax`, `fee`. +- Записи сортируются от новых к старым. +- В overview показывается не более 7 последних записей. +- Таблица содержит колонки: + - `Дата`; + - `Инструмент`; + - `Тип`; + - `Сумма`. +- Колонка `Статус` не отображается. +- Toolbar фильтров, count badge и footer summary не отображаются в overview-карточке. +- В колонке `Инструмент` название отображается сверху жирным, ticker/ISIN снизу серым. +- Для операций без названия основная строка берётся из ticker/figi/description без дублирования в subtitle. +- Тип операции отображается бейджем: `Дивиденд`, `Купон`, `Погашение`, `Налог`, `Комиссия` или + безопасный fallback. +- Всё, что уменьшает баланс, отображается красным: сумма, типовой бейдж и tone строки/значения. +- Для налоговых операций в колонке `Тип` должен быть бейдж `Налог`, а не исходный income/coupon label. +- Блок содержит ссылку `Все события` на подробную вкладку `/broker/:accountId/events` или согласованный + detailed cashflow route, если реализация выделит его отдельно. +- Ошибка загрузки последних событий не ломает остальные блоки. + +### 7. Backend API + +- `BrokerAnalyticsDto` расширяется полями: + - `totalFees: number`; + - `totalTaxesPaid: number`. +- `totalFees` считается из executed broker operations категории `fee`. +- `totalTaxesPaid` считается из executed broker operations категории `tax`. +- Оба агрегата возвращаются абсолютными положительными числами; frontend отвечает за знак отображения. +- Добавляется endpoint: + `GET /api/v1/broker/accounts/:accountId/portfolio/history?months=6`. +- Endpoint возвращает envelope с данными: + - `accountId: string`; + - `points: Array<{ month: string; label: string; value: BrokerMoneyDto }>`; + - `asOf: string`. +- `month` имеет формат `YYYY-MM`. +- `label` содержит короткое русское имя месяца для оси. +- `points.length` равен запрошенному `months`, по умолчанию 6. +- Для v1 допускается estimated read-model из текущей стоимости портфеля и executed operations. Shape + контракта должен позволять позже заменить расчёт на реальные snapshots без изменения frontend. +- `BrokerOperationQueryDto` расширяется фильтром `categories?: string`. +- `categories` принимает comma-separated категории из существующего набора: + `trade,income,tax,fee,transfer,other`. +- `GET /broker/accounts/:accountId/operations` применяет `categories` до пагинации/лимита, чтобы overview + не фильтровал неполную страницу на клиенте. + +### 8. Загрузка, ошибки и пустые состояния + +- Ошибка portfolio остаётся page-level ошибкой. +- Ошибка analytics, history или latest events отображается только внутри соответствующей карточки. +- Недоступные значения отображаются как `—`, не подменяются нулём. +- Loading-состояния должны сохранять высоты карточек и не вызывать layout shift. +- На mobile не должно быть общего горизонтального overflow. Горизонтальный scroll допустим только внутри + таблицы, если без него невозможно сохранить читаемость. + +## Acceptance Criteria + +- `/broker/:accountId` отображает блоки в порядке: + `Стоимость портфеля за 6 месяцев`, `Аналитика доходности`, `Структура`, `Последние события`. +- Верхняя строка страницы показывает `Брокерский счёт` и доходность рядом с ним, без отдельного hero. +- Production UI не показывает demo-toggle `Данные / Загрузка`. +- График стоимости имеет 6 подписей месяцев и 6 точек данных; линия начинается слева и заканчивается + справа. +- Loading графика не содержит skeleton-подписей месяцев и имеет ту же высоту, что loaded состояние. +- Analytics overview показывает `Комиссия` и `Уплаченные налоги`, не показывает `Нетто` и + `Всего получено`. +- `Структура` показывает итоговую стоимость и бары `Акции`, `Облигации`, `Деньги`. +- `Последние события` показывает только произошедшие записи, отсортированные новые → старые. +- Таблица `Последние события` не содержит toolbar, count badge, footer summary и колонку `Статус`. +- Операции налогов/комиссий и другие списания отображаются красным. +- Backend Swagger содержит новые analytics fields, portfolio history endpoint и `categories` query. +- Frontend generated types обновлены через codegen. +- Desktop и mobile визуально соответствуют `docs/research/frontend-overview-redesign/example.html`. diff --git a/docs/features/broker-account-overview-html-parity/tasks.md b/docs/features/broker-account-overview-html-parity/tasks.md new file mode 100644 index 0000000..6cda79c --- /dev/null +++ b/docs/features/broker-account-overview-html-parity/tasks.md @@ -0,0 +1,128 @@ +# Финальный редизайн брокерского overview по HTML — задачи + +Дата: 2026-06-27 +Статус: реализация завершена + +## Документация и pre-flight + +- [x] Создать `docs/features/broker-account-overview-html-parity/spec.md`. +- [x] Создать `docs/features/broker-account-overview-html-parity/plan.md`. +- [x] Создать `docs/features/broker-account-overview-html-parity/tasks.md`. +- [x] Зафиксировать canonical visual reference: + `docs/research/frontend-overview-redesign/example.html`. +- [x] Зафиксировать, что `docs/research/2026-06-27-broker-account-redesign.html` не является + source of truth для этой итерации. +- [x] Зафиксировать финальный порядок блоков: + `Заголовок счёта → Стоимость портфеля за 6 месяцев → Аналитика доходности → Структура → Последние события`. +- [x] Зафиксировать, что production UI не переносит demo-toggle `Данные / Загрузка`. +- [x] Перед началом реализации убедиться, что работа идёт в feature branch. +- [x] Перед началом реализации запустить baseline checks текущей ветки. + +## Backend contract + +- [x] Расширить `BrokerAnalyticsDto` полями `totalFees` и `totalTaxesPaid`. +- [x] Обновить `BrokerAnalyticsService`: считать комиссии из executed операций категории `fee`. +- [x] Обновить `BrokerAnalyticsService`: считать уплаченные налоги из executed операций категории `tax`. +- [x] Обновить `broker-analytics.service.spec.ts` для новых агрегатов и округления. +- [x] Добавить DTO для `BrokerPortfolioHistoryData`. +- [x] Добавить envelope DTO для portfolio history endpoint. +- [x] Добавить `BrokerPortfolioHistoryService`. +- [x] Добавить endpoint `GET /api/v1/broker/accounts/:accountId/portfolio/history?months=6`. +- [x] Покрыть portfolio history default months и response shape тестами. +- [x] Добавить `categories?: string` в `BrokerOperationQueryDto`. +- [x] Обновить `BrokerOperationsService`: применять category filtering для operations response. +- [x] Покрыть `categories=income,tax,fee` и неизвестные категории тестами. +- [x] Обновить `TBankController` и `tbank.controller.spec.ts` под новый endpoint/DTO. + +## OpenAPI и frontend data layer + +- [x] Запустить backend dev server для Swagger JSON. +- [x] Выполнить `npm run codegen -w apps/frontend`. +- [x] Проверить, что generated types содержат `totalFees`, `totalTaxesPaid` и portfolio history schemas. +- [x] Экспортировать новый `BrokerPortfolioHistory` alias из `apps/frontend/src/shared/api/index.ts`. +- [x] Добавить `getBrokerPortfolioHistory`. +- [x] Добавить `useBrokerPortfolioHistory`. +- [x] Добавить `categories` в frontend `BrokerOperationQuery`. +- [x] Не редактировать `apps/frontend/src/shared/api/types.ts` вручную. + +## Frontend composition + +- [x] Перестроить `BrokerDashboard` на финальный порядок блоков. +- [x] Убрать `BrokerDashboardHero` из overview-render path. +- [x] Перенести compact yield UI в заголовок счёта рядом с `Брокерский счёт`. +- [x] Убедиться, что page title loading state не показывает skeleton-полосы. +- [x] Создать `BrokerPortfolioHistoryCard`. +- [x] Подключить `useBrokerPortfolioHistory(accountId, { months: 6 })`. +- [x] Заменить overview `BrokerDashboardIncomeCard` на `BrokerPortfolioHistoryCard`. +- [x] Обновить `BrokerDashboardSkeleton` под финальный порядок и стабильные высоты. + +## Frontend visual parity + +- [x] Карточка `Стоимость портфеля за 6 месяцев`: зелёный градиентный фон и зелёная рамка. +- [x] График стоимости: 6 месячных значений, 6 подписей месяцев, плавная линия без visible markers. +- [x] График стоимости: первая точка у левого края, последняя у правого края. +- [x] Loading графика: chart-like indicator (meta skel + dashed guide + shimmer area). +- [x] Заголовок/yield: skeleton-полосы при загрузке портфеля (label, value, daily). +- [x] Analytics loading: 2 summary карточки + 6 detail карточек со skel барами. +- [x] Events loading: skeleton-table со структурой строк дата/инструмент/тип/сумма. +- [x] Analytics summary: только `Стоимость портфеля` и `Всего доходов`. +- [x] Analytics detail grid: `Пополнения`, `Выводы`, `Дивиденды`, `Купоны`, `Комиссия`, + `Уплаченные налоги`. +- [x] Analytics overview не показывает `Нетто` и `Всего получено`. +- [x] `Комиссия` и `Уплаченные налоги` отображаются как отрицательные UI-суммы. +- [x] Карточка структуры называется `Структура`. +- [x] Карточка структуры не показывает subtitle `Структура портфеля`. +- [x] Карточка структуры показывает итоговую стоимость под заголовком. +- [x] Карточка структуры показывает бары `Акции`, `Облигации`, `Деньги`. +- [x] Карточка последних событий называется `Последние события`. +- [x] Последние события используют executed operations, а не calendar events. +- [x] Последние события отсортированы новые → старые. +- [x] Последние события не показывают toolbar, count badge, footer summary и колонку `Статус`. +- [x] Инструмент в последних событиях: название сверху жирным, ticker/ISIN снизу серым. +- [x] Налоги/комиссии/списания отображаются красным и с корректным бейджем типа. +- [x] На mobile нет page-level horizontal overflow. + +## Tests + +- [x] Backend targeted: + `npm run test -w apps/backend -- src/modules/tbank/services/broker-analytics.service.spec.ts src/modules/tbank/tbank.controller.spec.ts` +- [x] Frontend targeted: + `npm run test -w apps/frontend -- --run src/widgets/broker-dashboard` +- [x] Full frontend: + `npm run test:frontend` +- [x] Frontend lint: + `npm run lint -w apps/frontend` +- [x] Frontend build: + `npm run build:frontend` +- [x] Проверить OpenAPI/codegen после backend изменений. + +## Visual QA + +- [ ] Проверить `/broker/:accountId` на desktop против + `docs/research/frontend-overview-redesign/example.html` (ручная проверка) +- [ ] Проверить `/broker/:accountId` на viewport `390x844` (ручная проверка) + +## Definition of Done + +- [x] Все acceptance criteria из `spec.md` выполнены. +- [x] Backend tests проходят (34 files, 161 passed). +- [x] Frontend targeted tests проходят. +- [x] `npm run test:frontend` проходит (32 files, 168 passed). +- [x] `npm run lint -w apps/frontend` проходит. +- [x] `npm run build:frontend` проходит. +- [x] Generated OpenAPI types обновлены через codegen. +- [ ] Visual QA desktop/mobile выполнена (ручная проверка). +- [x] Существующие detailed вкладки `Акции`, `Облигации`, `Операции`, `События`, `Аналитика` + остаются доступны. +- [x] `tasks.md` обновлён по факту выполнения. + +## Дополнительные улучшения (после основной реализации) + +- [x] Бейдж событий перевести с MUI Chip на кастомный Badge с цветами эталона. +- [x] Пагинация событий: wire `useState` + handlers, API limit поднят до 100. +- [x] Подписи месяцев на графике: первая left-aligned, последняя right-aligned. +- [x] `BrokerAnalyticsService` переписан на прямой вызов T-Bank API: + - убран Prisma для analytics; + - `GetOperationsByCursor` с пагинацией за всю историю (без ограничения по `from`); + - `GetPortfolio.expectedYield` используется как `totalReturnPercent`. +- [x] Структурные skeletons для chart/analytics/title-yield совпадают с эталоном. diff --git a/docs/research/frontend-overview-redesign/example.html b/docs/research/frontend-overview-redesign/example.html new file mode 100644 index 0000000..bc4bda2 --- /dev/null +++ b/docs/research/frontend-overview-redesign/example.html @@ -0,0 +1,1491 @@ + + + + + + MoexVibe - брокерский счет + + + +
+
+
+
MoexVibe
+ +
+ +
+ Sergey + +
+
+ +
+
+
+

Брокерский счёт

+
+
Доходность
+
-6,84%
+
За день: +1 688,04 ₽
+
+
+ +
+ + +
+
+ + + +
+
+
+

Стоимость портфеля за 6 месяцев

+
+
+
+
+
+
+
Текущая стоимость
+
1 884 891,72 ₽
+
+
+ + Стоимость портфеля с января по июнь 2026 + График показывает изменение стоимости портфеля за последние шесть месяцев, от января до июня 2026. + + + + + + +
+
+
+ +
+
+ +
+
+
+

Аналитика доходности

+
+
+
+
+
+
+
Стоимость портфеля
+
1 884 891,72 ₽
+
+
+
+
+
Всего доходов
+
186 087,53 ₽
+
+
+
+
+
Пополнения
113 773,03 ₽
+
Выводы
-684 753,92 ₽
+
Дивиденды
27 137,88 ₽
+
Купоны
158 949,65 ₽
+
Комиссия
-3 418,72 ₽
+
Уплаченные налоги
-24 906,14 ₽
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+

Структура

+
+
+
+
+ 1 884 891,72 ₽ +
+
+
+
Акции539 344,64 ₽ · 28,6%
+
+
+
+
Облигации1 330 791,92 ₽ · 70,6%
+
+
+
+
Деньги14 755,16 ₽ · 0,8%
+
+
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+

Последние события

+
+ Все события +
+
+
+ + + + + + + + + + + + + + + + + + +
ДатаИнструментТипСумма
15.05.2026
HeadHunter Group
HEAD
Дивиденд+1 631,00 ₽
15.05.2026
ОФЗ 26241
RU000A10A4C1
Погашение+150,00 ₽
14.05.2026
Самолет БО-П14
RU000A10B461
Налог-87,00 ₽
14.05.2026
Система 001Р-31
RU000A10A3Z4
Купон+1 212,45 ₽
13.05.2026
ЕвроТранс 001Р-06
RU000A10B883
Купон+13,97 ₽
13.05.2026
Газпром капитал 2Р-09
RU000A10B024
Купон+326,92 ₽
13.05.2026
РЖД 001Р-37R
RU000A10AEF9
Купон+43,56 ₽
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+ + + + diff --git a/docs/research/frontend-overview-redesign/qa-desktop-1280.png b/docs/research/frontend-overview-redesign/qa-desktop-1280.png new file mode 100644 index 0000000..1c7cd39 Binary files /dev/null and b/docs/research/frontend-overview-redesign/qa-desktop-1280.png differ diff --git a/docs/research/frontend-overview-redesign/qa-mobile-390.png b/docs/research/frontend-overview-redesign/qa-mobile-390.png new file mode 100644 index 0000000..8194018 Binary files /dev/null and b/docs/research/frontend-overview-redesign/qa-mobile-390.png differ