codex/frontend-overview-redesign #50
@ -2,11 +2,15 @@ import { CacheService } from '../../cache/cache.service';
|
|||||||
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
|
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
|
||||||
import { BrokerAccountsService } from './broker-accounts.service';
|
import { BrokerAccountsService } from './broker-accounts.service';
|
||||||
import { BrokerAnalyticsService } from './broker-analytics.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', () => {
|
describe('BrokerAnalyticsService', () => {
|
||||||
const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
|
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 cache = { getOrFetch: vi.fn() } as unknown as CacheService;
|
||||||
|
|
||||||
const acc1 = {
|
const acc1 = {
|
||||||
@ -18,6 +22,8 @@ describe('BrokerAnalyticsService', () => {
|
|||||||
accessLevel: null,
|
accessLevel: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockClient = { getPortfolio: vi.fn(), getOperationsByCursor: vi.fn() };
|
||||||
|
|
||||||
function mockCachePassthrough() {
|
function mockCachePassthrough() {
|
||||||
vi.mocked(cache.getOrFetch).mockImplementation(
|
vi.mocked(cache.getOrFetch).mockImplementation(
|
||||||
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
|
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
|
||||||
@ -28,27 +34,55 @@ describe('BrokerAnalyticsService', () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeOp(type: string, value: number, state?: string | null) {
|
function mockPortfolio(expectedYield?: { units?: number | string; nano?: number }): TBankPortfolioResponse {
|
||||||
return { type, payment: JSON.stringify({ value, currency: 'RUB' }), state } as any;
|
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(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(tbankClient.getOperationsClient).mockReturnValue(mockClient as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws 404 for missing account', async () => {
|
it('throws 404 for missing account', async () => {
|
||||||
vi.mocked(accounts.findById).mockResolvedValue(null);
|
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);
|
await expect(service.getAnalytics('missing')).rejects.toThrow(EntityNotFoundException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns zeros for account with no operations', async () => {
|
it('returns zeros for account with no operations', async () => {
|
||||||
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
||||||
mockCachePassthrough();
|
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');
|
const result = await service.getAnalytics('acc-1');
|
||||||
|
|
||||||
expect(result.data).toEqual({
|
expect(result.data).toEqual({
|
||||||
@ -68,17 +102,17 @@ describe('BrokerAnalyticsService', () => {
|
|||||||
it('aggregates deposit types correctly', async () => {
|
it('aggregates deposit types correctly', async () => {
|
||||||
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
||||||
mockCachePassthrough();
|
mockCachePassthrough();
|
||||||
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
|
setupMocks([
|
||||||
makeOp('OPERATION_TYPE_INPUT', 1000),
|
makeItem('OPERATION_TYPE_INPUT', 1000),
|
||||||
makeOp('OPERATION_TYPE_INPUT_SWIFT', 500),
|
makeItem('OPERATION_TYPE_INPUT_SWIFT', 500),
|
||||||
makeOp('OPERATION_TYPE_INP_MULTI', 200),
|
makeItem('OPERATION_TYPE_INP_MULTI', 200),
|
||||||
makeOp('OPERATION_TYPE_OVER_PLACEMENT', 300),
|
makeItem('OPERATION_TYPE_OVER_PLACEMENT', 300),
|
||||||
makeOp('OPERATION_TYPE_TRANS_IIS_BS', 100),
|
makeItem('OPERATION_TYPE_TRANS_IIS_BS', 100),
|
||||||
makeOp('OPERATION_TYPE_TRANS_BS_BS', 50),
|
makeItem('OPERATION_TYPE_TRANS_BS_BS', 50),
|
||||||
makeOp('OPERATION_TYPE_INPUT_ACQUIRING', 150),
|
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');
|
const result = await service.getAnalytics('acc-1');
|
||||||
|
|
||||||
expect(result.data.totalDeposits).toBe(2300);
|
expect(result.data.totalDeposits).toBe(2300);
|
||||||
@ -89,15 +123,15 @@ describe('BrokerAnalyticsService', () => {
|
|||||||
it('aggregates withdrawal types with absolute value', async () => {
|
it('aggregates withdrawal types with absolute value', async () => {
|
||||||
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
||||||
mockCachePassthrough();
|
mockCachePassthrough();
|
||||||
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
|
setupMocks([
|
||||||
makeOp('OPERATION_TYPE_OUTPUT', -500),
|
makeItem('OPERATION_TYPE_OUTPUT', -500),
|
||||||
makeOp('OPERATION_TYPE_OUTPUT_SWIFT', -200),
|
makeItem('OPERATION_TYPE_OUTPUT_SWIFT', -200),
|
||||||
makeOp('OPERATION_TYPE_OUTPUT_ACQUIRING', -100),
|
makeItem('OPERATION_TYPE_OUTPUT_ACQUIRING', -100),
|
||||||
makeOp('OPERATION_TYPE_OUT_MULTI', -50),
|
makeItem('OPERATION_TYPE_OUT_MULTI', -50),
|
||||||
makeOp('OPERATION_TYPE_INPUT', 1000),
|
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');
|
const result = await service.getAnalytics('acc-1');
|
||||||
|
|
||||||
expect(result.data.totalDeposits).toBe(1000);
|
expect(result.data.totalDeposits).toBe(1000);
|
||||||
@ -108,14 +142,14 @@ describe('BrokerAnalyticsService', () => {
|
|||||||
it('aggregates dividend and coupon types', async () => {
|
it('aggregates dividend and coupon types', async () => {
|
||||||
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
||||||
mockCachePassthrough();
|
mockCachePassthrough();
|
||||||
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
|
setupMocks([
|
||||||
makeOp('OPERATION_TYPE_DIVIDEND', 300),
|
makeItem('OPERATION_TYPE_DIVIDEND', 300),
|
||||||
makeOp('OPERATION_TYPE_DIV_EXT', 150),
|
makeItem('OPERATION_TYPE_DIV_EXT', 150),
|
||||||
makeOp('OPERATION_TYPE_COUPON', 75),
|
makeItem('OPERATION_TYPE_COUPON', 75),
|
||||||
makeOp('OPERATION_TYPE_COUPON', 25),
|
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');
|
const result = await service.getAnalytics('acc-1');
|
||||||
|
|
||||||
expect(result.data.totalDividends).toBe(450);
|
expect(result.data.totalDividends).toBe(450);
|
||||||
@ -123,92 +157,47 @@ describe('BrokerAnalyticsService', () => {
|
|||||||
expect(result.data.totalReceived).toBe(550);
|
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);
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
||||||
mockCachePassthrough();
|
mockCachePassthrough();
|
||||||
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
|
setupMocks(
|
||||||
makeOp('OPERATION_TYPE_INPUT', 10000),
|
[
|
||||||
makeOp('OPERATION_TYPE_DIVIDEND', 500),
|
makeItem('OPERATION_TYPE_INPUT', 10000),
|
||||||
makeOp('OPERATION_TYPE_COUPON', 200),
|
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');
|
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);
|
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);
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
||||||
mockCachePassthrough();
|
mockCachePassthrough();
|
||||||
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
|
setupMocks([makeItem('OPERATION_TYPE_OUTPUT', -500)]);
|
||||||
makeOp('OPERATION_TYPE_OUTPUT', -500),
|
|
||||||
makeOp('OPERATION_TYPE_DIVIDEND', 100),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
|
||||||
const result = await service.getAnalytics('acc-1');
|
const result = await service.getAnalytics('acc-1');
|
||||||
|
|
||||||
expect(result.data.netInvested).toBe(-500);
|
|
||||||
expect(result.data.totalReturnPercent).toBeNull();
|
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);
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
||||||
mockCachePassthrough();
|
mockCachePassthrough();
|
||||||
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
|
setupMocks([
|
||||||
{ type: 'OPERATION_TYPE_INPUT', payment: 'invalid-json', state: 'OPERATION_STATE_EXECUTED' },
|
makeItem('OPERATION_TYPE_SERVICE_FEE', -100),
|
||||||
{ type: 'OPERATION_TYPE_INPUT', payment: JSON.stringify({ value: 500, currency: 'RUB' }), state: 'OPERATION_STATE_EXECUTED' },
|
makeItem('OPERATION_TYPE_BROKER_FEE', -50),
|
||||||
] as any);
|
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',
|
|
||||||
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');
|
const result = await service.getAnalytics('acc-1');
|
||||||
|
|
||||||
expect(result.data.totalDeposits).toBe(1000);
|
expect(result.data.totalDeposits).toBe(1000);
|
||||||
@ -217,33 +206,15 @@ describe('BrokerAnalyticsService', () => {
|
|||||||
expect(result.data.netInvested).toBe(1000);
|
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 () => {
|
it('rounds all monetary values to 2 decimal places', async () => {
|
||||||
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
||||||
mockCachePassthrough();
|
mockCachePassthrough();
|
||||||
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
|
setupMocks([
|
||||||
makeOp('OPERATION_TYPE_INPUT', 100.336),
|
makeItem('OPERATION_TYPE_INPUT', 100.336),
|
||||||
makeOp('OPERATION_TYPE_DIVIDEND', 50.789),
|
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');
|
const result = await service.getAnalytics('acc-1');
|
||||||
|
|
||||||
expect(result.data.totalDeposits).toBe(100.34);
|
expect(result.data.totalDeposits).toBe(100.34);
|
||||||
@ -271,11 +242,39 @@ describe('BrokerAnalyticsService', () => {
|
|||||||
cachedAt: '2026-06-24T10:00:00.000Z',
|
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');
|
const result = await service.getAnalytics('acc-1');
|
||||||
|
|
||||||
expect(result.data.netInvested).toBe(1000);
|
expect(result.data.netInvested).toBe(1000);
|
||||||
expect(result.fromCache).toBe(true);
|
expect(result.fromCache).toBe(true);
|
||||||
expect(result.cachedAt).toBe('2026-06-24T10:00:00.000Z');
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,11 +1,14 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
|
||||||
import { CacheService } from '../../cache/cache.service';
|
import { CacheService } from '../../cache/cache.service';
|
||||||
import { TBANK_CACHE_KEYS } from '../tbank.config';
|
import { TBANK_CACHE_KEYS } from '../tbank.config';
|
||||||
import { BrokerAnalyticsDto } from '../dto/broker-analytics-response.dto';
|
import { BrokerAnalyticsDto } from '../dto/broker-analytics-response.dto';
|
||||||
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
|
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
|
||||||
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
|
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
|
||||||
import { BrokerAccountsService } from './broker-accounts.service';
|
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([
|
const DEPOSIT_TYPES = new Set([
|
||||||
'OPERATION_TYPE_INPUT',
|
'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 COUPON_TYPES = new Set(['OPERATION_TYPE_COUPON']);
|
||||||
|
|
||||||
const ANALYTICS_TYPES = new Set([
|
const MAX_FETCH_PAGES = 50;
|
||||||
...DEPOSIT_TYPES,
|
|
||||||
...WITHDRAWAL_TYPES,
|
|
||||||
...DIVIDEND_TYPES,
|
|
||||||
...COUPON_TYPES,
|
|
||||||
]);
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BrokerAnalyticsService {
|
export class BrokerAnalyticsService {
|
||||||
|
private readonly logger = new Logger(BrokerAnalyticsService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly accountsService: BrokerAccountsService,
|
private readonly accountsService: BrokerAccountsService,
|
||||||
|
private readonly tbankClient: TBankClientService,
|
||||||
private readonly cacheService: CacheService,
|
private readonly cacheService: CacheService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -58,25 +58,10 @@ export class BrokerAnalyticsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async computeAnalytics(accountId: string): Promise<BrokerAnalyticsDto> {
|
private async computeAnalytics(accountId: string): Promise<BrokerAnalyticsDto> {
|
||||||
const operations = await this.prisma.brokerOperation.findMany({
|
const [portfolio, allOperations] = await Promise.all([
|
||||||
where: {
|
this.fetchPortfolio(accountId),
|
||||||
accountId,
|
this.fetchAllOperations(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 },
|
|
||||||
});
|
|
||||||
|
|
||||||
let totalDeposits = 0;
|
let totalDeposits = 0;
|
||||||
let totalWithdrawn = 0;
|
let totalWithdrawn = 0;
|
||||||
@ -85,14 +70,8 @@ export class BrokerAnalyticsService {
|
|||||||
let totalFees = 0;
|
let totalFees = 0;
|
||||||
let totalTaxesPaid = 0;
|
let totalTaxesPaid = 0;
|
||||||
|
|
||||||
for (const op of operations) {
|
for (const op of allOperations) {
|
||||||
let value = 0;
|
const value = op.payment?.value ?? 0;
|
||||||
try {
|
|
||||||
const payment = JSON.parse(op.payment!);
|
|
||||||
value = payment.value ?? 0;
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (op.category === 'fee') {
|
if (op.category === 'fee') {
|
||||||
totalFees += Math.abs(value);
|
totalFees += Math.abs(value);
|
||||||
@ -111,8 +90,11 @@ export class BrokerAnalyticsService {
|
|||||||
|
|
||||||
const netInvested = totalDeposits - totalWithdrawn;
|
const netInvested = totalDeposits - totalWithdrawn;
|
||||||
const totalReceived = totalDividends + totalCoupons;
|
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 {
|
return {
|
||||||
totalDeposits: Math.round(totalDeposits * 100) / 100,
|
totalDeposits: Math.round(totalDeposits * 100) / 100,
|
||||||
@ -123,8 +105,64 @@ export class BrokerAnalyticsService {
|
|||||||
totalReceived: Math.round(totalReceived * 100) / 100,
|
totalReceived: Math.round(totalReceived * 100) / 100,
|
||||||
totalFees: Math.round(totalFees * 100) / 100,
|
totalFees: Math.round(totalFees * 100) / 100,
|
||||||
totalTaxesPaid: Math.round(totalTaxesPaid * 100) / 100,
|
totalTaxesPaid: Math.round(totalTaxesPaid * 100) / 100,
|
||||||
totalReturnPercent,
|
totalReturnPercent: expectedYieldPercent,
|
||||||
currency: 'RUB',
|
currency: 'RUB',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async fetchPortfolio(accountId: string): Promise<TBankPortfolioResponse> {
|
||||||
|
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<BrokerOperation[]> {
|
||||||
|
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<string, unknown> = {
|
||||||
|
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<string, unknown>,
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,7 +34,7 @@ type GrpcUnary<TRequest, TResponse> = (
|
|||||||
type GrpcServiceConstructor = new (address: string, credentials: ChannelCredentials) => Client;
|
type GrpcServiceConstructor = new (address: string, credentials: ChannelCredentials) => Client;
|
||||||
|
|
||||||
type TBankAccountsRequest = { status: string };
|
type TBankAccountsRequest = { status: string };
|
||||||
type TBankPortfolioRequest = { accountId: string; currency: string };
|
export type TBankPortfolioRequest = { accountId: string; currency: string };
|
||||||
type TBankPositionsRequest = { accountId: string };
|
type TBankPositionsRequest = { accountId: string };
|
||||||
type TBankInstrumentRequest = { idType: string; id: string };
|
type TBankInstrumentRequest = { idType: string; id: string };
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user