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 { 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 tbankClient = { getOperationsClient: vi.fn(), callUnary: vi.fn(), } as unknown as TBankClientService; const cache = { getOrFetch: vi.fn() } as unknown as CacheService; const acc1 = { id: 'acc-1', type: 'brokerage' as const, name: 'Test Broker', status: 'ACCOUNT_STATUS_OPEN', openedAt: null, accessLevel: null, }; const mockClient = { getPortfolio: vi.fn(), getOperationsByCursor: vi.fn() }; function mockCachePassthrough() { vi.mocked(cache.getOrFetch).mockImplementation( async (_prefix: string, _parts: string[], fetchFn: () => Promise) => ({ data: await fetchFn(), fromCache: false, cachedAt: null, }), ); } 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(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(); setupMocks([]); const service = new BrokerAnalyticsService(accounts, tbankClient, cache); const result = await service.getAnalytics('acc-1'); expect(result.data).toEqual({ totalDeposits: 0, totalWithdrawn: 0, netInvested: 0, totalDividends: 0, totalCoupons: 0, totalReceived: 0, totalReturnPercent: null, totalFees: 0, totalTaxesPaid: 0, currency: 'RUB', }); }); it('aggregates deposit types correctly', async () => { vi.mocked(accounts.findById).mockResolvedValue(acc1); mockCachePassthrough(); 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(accounts, tbankClient, cache); const result = await service.getAnalytics('acc-1'); expect(result.data.totalDeposits).toBe(2300); expect(result.data.totalWithdrawn).toBe(0); expect(result.data.netInvested).toBe(2300); }); it('aggregates withdrawal types with absolute value', async () => { vi.mocked(accounts.findById).mockResolvedValue(acc1); mockCachePassthrough(); 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(accounts, tbankClient, cache); const result = await service.getAnalytics('acc-1'); expect(result.data.totalDeposits).toBe(1000); expect(result.data.totalWithdrawn).toBe(850); expect(result.data.netInvested).toBe(150); }); it('aggregates dividend and coupon types', async () => { vi.mocked(accounts.findById).mockResolvedValue(acc1); mockCachePassthrough(); 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(accounts, tbankClient, cache); const result = await service.getAnalytics('acc-1'); expect(result.data.totalDividends).toBe(450); expect(result.data.totalCoupons).toBe(100); expect(result.data.totalReceived).toBe(550); }); it('uses expectedYield from portfolio for totalReturnPercent', async () => { vi.mocked(accounts.findById).mockResolvedValue(acc1); mockCachePassthrough(); 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(accounts, tbankClient, cache); const result = await service.getAnalytics('acc-1'); expect(result.data.totalReturnPercent).toBe(7); }); it('returns null totalReturnPercent when portfolio has no expectedYield', async () => { vi.mocked(accounts.findById).mockResolvedValue(acc1); mockCachePassthrough(); setupMocks([makeItem('OPERATION_TYPE_OUTPUT', -500)]); const service = new BrokerAnalyticsService(accounts, tbankClient, cache); const result = await service.getAnalytics('acc-1'); expect(result.data.totalReturnPercent).toBeNull(); }); it('aggregates fee and tax categories from operations', async () => { vi.mocked(accounts.findById).mockResolvedValue(acc1); mockCachePassthrough(); 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(accounts, tbankClient, 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('rounds all monetary values to 2 decimal places', async () => { vi.mocked(accounts.findById).mockResolvedValue(acc1); mockCachePassthrough(); setupMocks([ makeItem('OPERATION_TYPE_INPUT', 100.336), makeItem('OPERATION_TYPE_DIVIDEND', 50.789), ]); const service = new BrokerAnalyticsService(accounts, tbankClient, cache); const result = await service.getAnalytics('acc-1'); expect(result.data.totalDeposits).toBe(100.34); expect(result.data.totalDividends).toBe(50.79); expect(result.data.totalReceived).toBe(50.79); expect(result.data.netInvested).toBe(100.34); }); it('wraps result in ApiResponse envelope through cache', async () => { vi.mocked(accounts.findById).mockResolvedValue(acc1); vi.mocked(cache.getOrFetch).mockResolvedValue({ data: { totalDeposits: 1000, totalWithdrawn: 0, netInvested: 1000, totalDividends: 0, 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(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); }); });