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 3fb49a0..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({ @@ -68,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); @@ -89,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); @@ -108,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); @@ -123,92 +157,47 @@ 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 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', - 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, 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 service = new BrokerAnalyticsService(accounts, tbankClient, cache); const result = await service.getAnalytics('acc-1'); expect(result.data.totalDeposits).toBe(1000); @@ -217,33 +206,15 @@ describe('BrokerAnalyticsService', () => { 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(); - 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); @@ -271,11 +242,39 @@ describe('BrokerAnalyticsService', () => { 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 f36ed42..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,25 +58,10 @@ export class BrokerAnalyticsService { } private async computeAnalytics(accountId: string): Promise { - const operations = await this.prisma.brokerOperation.findMany({ - where: { - accountId, - 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, category: true }, - }); + const [portfolio, allOperations] = await Promise.all([ + this.fetchPortfolio(accountId), + this.fetchAllOperations(accountId), + ]); let totalDeposits = 0; let totalWithdrawn = 0; @@ -85,14 +70,8 @@ export class BrokerAnalyticsService { 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 (op.category === 'fee') { totalFees += Math.abs(value); @@ -111,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, @@ -123,8 +105,64 @@ export class BrokerAnalyticsService { totalReceived: Math.round(totalReceived * 100) / 100, totalFees: Math.round(totalFees * 100) / 100, totalTaxesPaid: Math.round(totalTaxesPaid * 100) / 100, - totalReturnPercent, + 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/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 };