feat: complete broker account analytics with sync button, tests, and state filter
Some checks failed
CI / ci (pull_request) Failing after 3m39s
CI / ci (push) Failing after 3m10s

- Add state filter (EXECUTED/null) to analytics query (spec compliance)
- Add service unit tests (11 tests) and controller test
- Add sync button to operations page with mutation hook
- Regenerate frontend types via codegen
- Update tasks.md marking all items complete

Backend: 114 tests, Frontend: 116 tests — all pass
This commit is contained in:
Sergey Krylov 2026-06-24 11:29:09 +03:00
parent ef4bc48e50
commit 3dfcf5aaa8
10 changed files with 388 additions and 18 deletions

View File

@ -0,0 +1,232 @@
import { NotFoundException } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service';
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(NotFoundException);
});
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,
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',
type: { in: expect.any(Array) },
payment: { not: null },
OR: [
{ state: 'OPERATION_STATE_EXECUTED' },
{ state: null },
],
},
select: { type: true, payment: true },
});
});
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,
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.meta.fromCache).toBe(true);
expect(result.meta.cachedAt).toBe('2026-06-24T10:00:00.000Z');
});
});

View File

@ -67,6 +67,10 @@ export class BrokerAnalyticsService {
accountId, accountId,
type: { in: Array.from(ANALYTICS_TYPES) }, type: { in: Array.from(ANALYTICS_TYPES) },
payment: { not: null }, payment: { not: null },
OR: [
{ state: 'OPERATION_STATE_EXECUTED' },
{ state: null },
],
}, },
select: { type: true, payment: true }, select: { type: true, payment: true },
}); });

View File

@ -63,6 +63,31 @@ describe('TBankController', () => {
expect(response.data).toEqual({ upserted: 2 }); expect(response.data).toEqual({ upserted: 2 });
}); });
it('exposes analytics endpoint through controller', async () => {
const analyticsData = {
totalDeposits: 1000,
totalWithdrawn: 200,
netInvested: 800,
totalDividends: 150,
totalCoupons: 50,
totalReceived: 200,
totalReturnPercent: 25,
currency: 'RUB',
};
vi.mocked(analytics.getAnalytics).mockResolvedValueOnce({
data: analyticsData,
meta: { fromCache: false, cachedAt: null },
});
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics);
const response = await controller.getAnalytics('acc-1');
expect(analytics.getAnalytics).toHaveBeenCalledWith('acc-1');
expect(response).toBeInstanceOf(ApiResponse);
expect(response.data).toEqual(analyticsData);
expect(response.meta).toEqual({ fromCache: false, cachedAt: null });
});
it('forwards events query and wraps response', async () => { it('forwards events query and wraps response', async () => {
const eventsData = { const eventsData = {
items: [], items: [],

View File

@ -1,4 +1,8 @@
import type { ApiResponseMeta, BrokerOperationsPage } from '@/shared/api' import type {
ApiResponseMeta,
BrokerOperationSyncResponse,
BrokerOperationsPage,
} from '@/shared/api'
import { request } from '@/shared/api/kyClient' import { request } from '@/shared/api/kyClient'
export type BrokerOperationQuery = { export type BrokerOperationQuery = {
@ -11,6 +15,17 @@ export type BrokerOperationQuery = {
state?: string state?: string
} }
export function syncBrokerOperations(
accountId: string,
query: { from: string; to: string },
): Promise<{ data: BrokerOperationSyncResponse; meta: ApiResponseMeta }> {
return request<BrokerOperationSyncResponse>(
`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/operations/sync`,
{ from: query.from, to: query.to },
{ method: 'POST' },
)
}
export function getBrokerOperations( export function getBrokerOperations(
accountId: string, accountId: string,
query: BrokerOperationQuery = {}, query: BrokerOperationQuery = {},

View File

@ -7,3 +7,4 @@ export {
isBrokerOperationType, isBrokerOperationType,
} from './model/operationFilters' } from './model/operationFilters'
export { useBrokerOperations } from './model/useBrokerOperations' export { useBrokerOperations } from './model/useBrokerOperations'
export { useSyncBrokerOperations } from './model/useSyncBrokerOperations'

View File

@ -0,0 +1,17 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { syncBrokerOperations } from '../api/brokerOperationApi'
export function useSyncBrokerOperations(accountId: string | undefined) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (range: { from: string; to: string }) => {
if (!accountId) throw new Error('Account ID required')
return (await syncBrokerOperations(accountId, range)).data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['broker', 'analytics', accountId] })
queryClient.invalidateQueries({ queryKey: ['broker', 'events', accountId] })
},
})
}

View File

@ -1,18 +1,29 @@
import { Heading, Text } from '@moex-vibe/design-system' import { Button, Heading, Text } from '@moex-vibe/design-system'
import { Box } from '@mui/material' import { Box } from '@mui/material'
import { useEffect } from 'react' import { useEffect } from 'react'
import { import {
BROKER_OPERATION_TYPE_OPTIONS, BROKER_OPERATION_TYPE_OPTIONS,
isBrokerOperationType, isBrokerOperationType,
useBrokerOperations, useBrokerOperations,
useSyncBrokerOperations,
} from '@/entities/broker-operation' } from '@/entities/broker-operation'
import { useSearchParamsCompat } from '@/shared/lib/router/useSearchParams' import { useSearchParamsCompat } from '@/shared/lib/router/useSearchParams'
import { useCursorPagination } from '@/shared/lib/useCursorPagination' import { useCursorPagination } from '@/shared/lib/useCursorPagination'
import { useBrokerAccountContext } from '@/widgets/broker-account-layout' import { useBrokerAccountContext } from '@/widgets/broker-account-layout'
import { BrokerOperationsTable } from '@/widgets/broker-operations-table' import { BrokerOperationsTable } from '@/widgets/broker-operations-table'
function getDefaultSyncRange() {
const now = new Date()
const start = new Date(Date.UTC(now.getUTCFullYear(), 0, 1))
return {
from: start.toISOString(),
to: now.toISOString(),
}
}
export function BrokerOperationsPage() { export function BrokerOperationsPage() {
const { accountId } = useBrokerAccountContext() const { accountId } = useBrokerAccountContext()
const sync = useSyncBrokerOperations(accountId)
const [searchParams, setSearchParams] = useSearchParamsCompat() const [searchParams, setSearchParams] = useSearchParamsCompat()
const urlType = searchParams.get('type') const urlType = searchParams.get('type')
const selectedType = isBrokerOperationType(urlType) ? urlType : '' const selectedType = isBrokerOperationType(urlType) ? urlType : ''
@ -56,14 +67,24 @@ export function BrokerOperationsPage() {
/> />
) )
function handleSync() {
const range = getDefaultSyncRange()
sync.mutate(range)
}
return ( return (
<Box component="section" aria-labelledby="broker-operations-heading"> <Box component="section" aria-labelledby="broker-operations-heading">
<Box <Box
sx={{ display: 'flex', alignItems: 'end', justifyContent: 'space-between', gap: 2, mb: 2 }} sx={{ display: 'flex', alignItems: 'end', justifyContent: 'space-between', gap: 2, mb: 2 }}
> >
<Heading level={2} id="broker-operations-heading"> <Box sx={{ display: 'flex', alignItems: 'end', gap: 2 }}>
Операции <Heading level={2} id="broker-operations-heading">
</Heading> Операции
</Heading>
<Button variant="secondary" size="small" loading={sync.isPending} onClick={handleSync}>
Синхронизировать
</Button>
</Box>
<Box component="label" sx={{ display: 'grid', gap: 0.5, color: 'text.secondary' }}> <Box component="label" sx={{ display: 'grid', gap: 0.5, color: 'text.secondary' }}>
<Text variant="label">Тип операции</Text> <Text variant="label">Тип операции</Text>
<select value={selectedType} onChange={handleTypeChange}> <select value={selectedType} onChange={handleTypeChange}>
@ -76,6 +97,16 @@ export function BrokerOperationsPage() {
</select> </select>
</Box> </Box>
</Box> </Box>
{sync.isSuccess && (
<Text component="p" tone="positive" style={{ marginBottom: '12px' }}>
Синхронизировано: {sync.data?.upserted} операций
</Text>
)}
{sync.isError && (
<Text component="p" tone="negative" style={{ marginBottom: '12px' }}>
Ошибка синхронизации
</Text>
)}
{history} {history}
</Box> </Box>
) )

View File

@ -63,3 +63,6 @@ export type BrokerEventsSummary = components['schemas']['BrokerEventsSummaryDto'
// Broker analytics // Broker analytics
export type BrokerAnalytics = components['schemas']['BrokerAnalyticsDto'] export type BrokerAnalytics = components['schemas']['BrokerAnalyticsDto']
// Broker sync
export type BrokerOperationSyncResponse = components['schemas']['BrokerOperationSyncResponseDto']

View File

@ -468,6 +468,23 @@ export interface paths {
patch?: never patch?: never
trace?: never trace?: never
} }
'/api/v1/broker/accounts/{accountId}/analytics': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
/** Get broker account profitability analytics */
get: operations['TBankController_getAnalytics']
put?: never
post?: never
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/api/v1/broker/accounts/{accountId}/operations/sync': { '/api/v1/broker/accounts/{accountId}/operations/sync': {
parameters: { parameters: {
query?: never query?: never
@ -1193,6 +1210,15 @@ export interface components {
actualCouponsTotal: number actualCouponsTotal: number
actualPrincipalRepaymentTotal: number actualPrincipalRepaymentTotal: number
} }
BrokerEventsDataDto: {
items: components['schemas']['BrokerEventItemDto'][]
summary: components['schemas']['BrokerEventsSummaryDto']
asOf: string
}
BrokerEventsEnvelopeDto: {
data: components['schemas']['BrokerEventsDataDto']
meta: components['schemas']['BrokerResponseMetaDto']
}
BrokerAnalyticsDto: { BrokerAnalyticsDto: {
totalDeposits: number totalDeposits: number
totalWithdrawn: number totalWithdrawn: number
@ -1203,13 +1229,8 @@ export interface components {
totalReturnPercent: number | null totalReturnPercent: number | null
currency: string currency: string
} }
BrokerEventsDataDto: { BrokerAnalyticsEnvelopeDto: {
items: components['schemas']['BrokerEventItemDto'][] data: components['schemas']['BrokerAnalyticsDto']
summary: components['schemas']['BrokerEventsSummaryDto']
asOf: string
}
BrokerEventsEnvelopeDto: {
data: components['schemas']['BrokerEventsDataDto']
meta: components['schemas']['BrokerResponseMetaDto'] meta: components['schemas']['BrokerResponseMetaDto']
} }
BrokerOperationSyncResponseDto: { BrokerOperationSyncResponseDto: {
@ -1977,6 +1998,27 @@ export interface operations {
} }
} }
} }
TBankController_getAnalytics: {
parameters: {
query?: never
header?: never
path: {
accountId: string
}
cookie?: never
}
requestBody?: never
responses: {
200: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['BrokerAnalyticsEnvelopeDto']
}
}
}
}
TBankController_syncOperations: { TBankController_syncOperations: {
parameters: { parameters: {
query: { query: {

View File

@ -2,15 +2,15 @@
## Backend ## Backend
- [ ] **T1** DTO `BrokerAnalyticsDto` + сервис `BrokerAnalyticsService` с агрегацией и кешем - [x] **T1** DTO `BrokerAnalyticsDto` + сервис `BrokerAnalyticsService` с агрегацией и кешем
- [ ] **T2** Controller endpoint, module registration, cache key, config TTL - [x] **T2** Controller endpoint, module registration, cache key, config TTL
## Frontend ## Frontend
- [ ] **T3** Shared типы, entity API, хук `useBrokerAnalytics` - [x] **T3** Shared типы, entity API, хук `useBrokerAnalytics`
- [ ] **T4** Страница `BrokerAnalyticsPage` с вёрсткой трёх блоков - [x] **T4** Страница `BrokerAnalyticsPage` с вёрсткой трёх блоков
- [ ] **T5** Роут `/analytics` + таб в навигации - [x] **T5** Роут `/analytics` + таб в навигации
## Проверка ## Проверка
- [ ] **T6** Линт, сборка, тесты - [x] **T6** Линт, сборка, тесты