moex-vibe/apps/backend/src/modules/tbank/services/broker-analytics.service.spec.ts
Sergey Krylov ff0e11b303 feat(tbank): add totalFees/totalTaxesPaid, portfolio history endpoint, and category filtering
- Add totalFees and totalTaxesPaid to BrokerAnalyticsDto and service
- Add GET /accounts/:accountId/portfolio/history endpoint with estimated v1 read-model
- Add categories query param to operations endpoint for filtering by category
- Update tests and module registration
2026-06-27 19:30:32 +03:00

282 lines
10 KiB
TypeScript

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<unknown>) => ({
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');
});
});