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..3fb49a0 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 @@ -59,6 +59,8 @@ describe('BrokerAnalyticsService', () => { totalCoupons: 0, totalReceived: 0, totalReturnPercent: null, + totalFees: 0, + totalTaxesPaid: 0, currency: 'RUB', }); }); @@ -177,17 +179,62 @@ describe('BrokerAnalyticsService', () => { expect(prisma.brokerOperation.findMany).toHaveBeenCalledWith({ where: { accountId: 'acc-1', - type: { in: expect.any(Array) }, payment: { not: null }, OR: [ { state: 'OPERATION_STATE_EXECUTED' }, { state: null }, ], + AND: [ + { + OR: [ + { type: { in: expect.any(Array) } }, + { category: { in: ['fee', 'tax'] } }, + ], + }, + ], }, - select: { type: true, payment: true }, + select: { type: true, payment: true, category: true }, }); }); + it('aggregates fee and tax categories from executed operations', async () => { + vi.mocked(accounts.findById).mockResolvedValue(acc1); + mockCachePassthrough(); + vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([ + { type: 'OPERATION_TYPE_SERVICE_FEE', payment: JSON.stringify({ value: -100, currency: 'RUB' }), state: 'OPERATION_STATE_EXECUTED', category: 'fee' }, + { type: 'OPERATION_TYPE_BROKER_FEE', payment: JSON.stringify({ value: -50, currency: 'RUB' }), state: 'OPERATION_STATE_EXECUTED', category: 'fee' }, + { type: 'OPERATION_TYPE_TAX', payment: JSON.stringify({ value: -200, currency: 'RUB' }), state: 'OPERATION_STATE_EXECUTED', category: 'tax' }, + { type: 'OPERATION_TYPE_DIVIDEND_TAX', payment: JSON.stringify({ value: -30, currency: 'RUB' }), state: 'OPERATION_STATE_EXECUTED', category: 'tax' }, + makeOp('OPERATION_TYPE_INPUT', 1000), + ] as any); + + const service = new BrokerAnalyticsService(prisma, accounts, cache); + const result = await service.getAnalytics('acc-1'); + + 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('includes fee/tax category operations in the query alongside ANALYTICS_TYPES', async () => { + vi.mocked(accounts.findById).mockResolvedValue(acc1); + mockCachePassthrough(); + + const service = new BrokerAnalyticsService(prisma, accounts, cache); + await service.getAnalytics('acc-1'); + + const whereArg = vi.mocked(prisma.brokerOperation.findMany).mock.calls[0][0]!.where as any; + expect(whereArg.accountId).toBe('acc-1'); + expect(whereArg.payment).toEqual({ not: null }); + const orConditions = whereArg.AND[0].OR; + expect(orConditions).toEqual( + expect.arrayContaining([ + { category: { in: ['fee', 'tax'] } }, + ]), + ); + }); + it('rounds all monetary values to 2 decimal places', async () => { vi.mocked(accounts.findById).mockResolvedValue(acc1); mockCachePassthrough(); @@ -216,6 +263,8 @@ describe('BrokerAnalyticsService', () => { totalCoupons: 0, totalReceived: 0, totalReturnPercent: null, + totalFees: 0, + totalTaxesPaid: 0, currency: 'RUB', }, fromCache: true, 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..f36ed42 100644 --- a/apps/backend/src/modules/tbank/services/broker-analytics.service.ts +++ b/apps/backend/src/modules/tbank/services/broker-analytics.service.ts @@ -61,20 +61,29 @@ export class BrokerAnalyticsService { 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 }, ], + AND: [ + { + OR: [ + { type: { in: Array.from(ANALYTICS_TYPES) } }, + { category: { in: ['fee', 'tax'] } }, + ], + }, + ], }, - select: { type: true, payment: true }, + select: { type: true, payment: true, category: true }, }); 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; @@ -85,7 +94,11 @@ export class BrokerAnalyticsService { continue; } - 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); @@ -108,6 +121,8 @@ export class BrokerAnalyticsService { totalDividends: Math.round(totalDividends * 100) / 100, totalCoupons: Math.round(totalCoupons * 100) / 100, totalReceived: Math.round(totalReceived * 100) / 100, + totalFees: Math.round(totalFees * 100) / 100, + totalTaxesPaid: Math.round(totalTaxesPaid * 100) / 100, totalReturnPercent, currency: 'RUB', }; 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/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,