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'; describe('BrokerAnalyticsService', () => { const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService; const prisma = { brokerOperation: { findMany: vi.fn() } } as unknown as PrismaService; 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, }; function mockCachePassthrough() { vi.mocked(cache.getOrFetch).mockImplementation( async (_prefix: string, _parts: string[], fetchFn: () => Promise) => ({ data: await fetchFn(), fromCache: false, cachedAt: null, }), ); } function makeOp(type: string, value: number, state?: string | null) { return { type, payment: JSON.stringify({ value, currency: 'RUB' }), state } as any; } beforeEach(() => { vi.clearAllMocks(); }); it('throws 404 for missing account', async () => { vi.mocked(accounts.findById).mockResolvedValue(null); const service = new BrokerAnalyticsService(prisma, accounts, 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([]); const service = new BrokerAnalyticsService(prisma, accounts, 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(); 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), ]); const service = new BrokerAnalyticsService(prisma, accounts, 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(); 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), ]); const service = new BrokerAnalyticsService(prisma, accounts, 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(); 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), ]); const service = new BrokerAnalyticsService(prisma, accounts, 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('calculates totalReturnPercent correctly', 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), ]); const service = new BrokerAnalyticsService(prisma, accounts, 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 () => { vi.mocked(accounts.findById).mockResolvedValue(acc1); mockCachePassthrough(); vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([ makeOp('OPERATION_TYPE_OUTPUT', -500), makeOp('OPERATION_TYPE_DIVIDEND', 100), ]); const service = new BrokerAnalyticsService(prisma, accounts, 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 () => { 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); 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 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(); vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([ makeOp('OPERATION_TYPE_INPUT', 100.336), makeOp('OPERATION_TYPE_DIVIDEND', 50.789), ]); const service = new BrokerAnalyticsService(prisma, accounts, 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(prisma, accounts, 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'); }); });