diff --git a/apps/backend/src/modules/tbank/services/broker-analytics.service.spec.ts b/apps/backend/src/modules/tbank/services/broker-analytics.service.spec.ts new file mode 100644 index 0000000..4e3b9d7 --- /dev/null +++ b/apps/backend/src/modules/tbank/services/broker-analytics.service.spec.ts @@ -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) => ({ + 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'); + }); +}); diff --git a/apps/backend/src/modules/tbank/services/broker-analytics.service.ts b/apps/backend/src/modules/tbank/services/broker-analytics.service.ts index 54c8ff5..4380b81 100644 --- a/apps/backend/src/modules/tbank/services/broker-analytics.service.ts +++ b/apps/backend/src/modules/tbank/services/broker-analytics.service.ts @@ -67,6 +67,10 @@ export class BrokerAnalyticsService { accountId, type: { in: Array.from(ANALYTICS_TYPES) }, payment: { not: null }, + OR: [ + { state: 'OPERATION_STATE_EXECUTED' }, + { state: null }, + ], }, select: { type: true, payment: true }, }); diff --git a/apps/backend/src/modules/tbank/tbank.controller.spec.ts b/apps/backend/src/modules/tbank/tbank.controller.spec.ts index 8520b10..8bebdc9 100644 --- a/apps/backend/src/modules/tbank/tbank.controller.spec.ts +++ b/apps/backend/src/modules/tbank/tbank.controller.spec.ts @@ -63,6 +63,31 @@ describe('TBankController', () => { 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 () => { const eventsData = { items: [], diff --git a/apps/frontend/src/entities/broker-operation/api/brokerOperationApi.ts b/apps/frontend/src/entities/broker-operation/api/brokerOperationApi.ts index e5d5b2f..2db1252 100644 --- a/apps/frontend/src/entities/broker-operation/api/brokerOperationApi.ts +++ b/apps/frontend/src/entities/broker-operation/api/brokerOperationApi.ts @@ -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' export type BrokerOperationQuery = { @@ -11,6 +15,17 @@ export type BrokerOperationQuery = { state?: string } +export function syncBrokerOperations( + accountId: string, + query: { from: string; to: string }, +): Promise<{ data: BrokerOperationSyncResponse; meta: ApiResponseMeta }> { + return request( + `/api/v1/broker/accounts/${encodeURIComponent(accountId)}/operations/sync`, + { from: query.from, to: query.to }, + { method: 'POST' }, + ) +} + export function getBrokerOperations( accountId: string, query: BrokerOperationQuery = {}, diff --git a/apps/frontend/src/entities/broker-operation/index.ts b/apps/frontend/src/entities/broker-operation/index.ts index 90c1f15..ad585c4 100644 --- a/apps/frontend/src/entities/broker-operation/index.ts +++ b/apps/frontend/src/entities/broker-operation/index.ts @@ -7,3 +7,4 @@ export { isBrokerOperationType, } from './model/operationFilters' export { useBrokerOperations } from './model/useBrokerOperations' +export { useSyncBrokerOperations } from './model/useSyncBrokerOperations' diff --git a/apps/frontend/src/entities/broker-operation/model/useSyncBrokerOperations.ts b/apps/frontend/src/entities/broker-operation/model/useSyncBrokerOperations.ts new file mode 100644 index 0000000..de04e64 --- /dev/null +++ b/apps/frontend/src/entities/broker-operation/model/useSyncBrokerOperations.ts @@ -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] }) + }, + }) +} diff --git a/apps/frontend/src/pages/broker-operations/ui/BrokerOperationsPage.tsx b/apps/frontend/src/pages/broker-operations/ui/BrokerOperationsPage.tsx index 0d6e038..52162b6 100644 --- a/apps/frontend/src/pages/broker-operations/ui/BrokerOperationsPage.tsx +++ b/apps/frontend/src/pages/broker-operations/ui/BrokerOperationsPage.tsx @@ -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 { useEffect } from 'react' import { BROKER_OPERATION_TYPE_OPTIONS, isBrokerOperationType, useBrokerOperations, + useSyncBrokerOperations, } from '@/entities/broker-operation' import { useSearchParamsCompat } from '@/shared/lib/router/useSearchParams' import { useCursorPagination } from '@/shared/lib/useCursorPagination' import { useBrokerAccountContext } from '@/widgets/broker-account-layout' 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() { const { accountId } = useBrokerAccountContext() + const sync = useSyncBrokerOperations(accountId) const [searchParams, setSearchParams] = useSearchParamsCompat() const urlType = searchParams.get('type') const selectedType = isBrokerOperationType(urlType) ? urlType : '' @@ -56,14 +67,24 @@ export function BrokerOperationsPage() { /> ) + function handleSync() { + const range = getDefaultSyncRange() + sync.mutate(range) + } + return ( - - Операции - + + + Операции + + + Тип операции + {sync.isSuccess && ( + + Синхронизировано: {sync.data?.upserted} операций + + )} + {sync.isError && ( + + Ошибка синхронизации + + )} {history} ) diff --git a/apps/frontend/src/shared/api/index.ts b/apps/frontend/src/shared/api/index.ts index f7a43d2..7d21f41 100644 --- a/apps/frontend/src/shared/api/index.ts +++ b/apps/frontend/src/shared/api/index.ts @@ -63,3 +63,6 @@ export type BrokerEventsSummary = components['schemas']['BrokerEventsSummaryDto' // Broker analytics export type BrokerAnalytics = components['schemas']['BrokerAnalyticsDto'] + +// Broker sync +export type BrokerOperationSyncResponse = components['schemas']['BrokerOperationSyncResponseDto'] diff --git a/apps/frontend/src/shared/api/types.ts b/apps/frontend/src/shared/api/types.ts index 0f22223..76b48a8 100644 --- a/apps/frontend/src/shared/api/types.ts +++ b/apps/frontend/src/shared/api/types.ts @@ -468,6 +468,23 @@ export interface paths { patch?: 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': { parameters: { query?: never @@ -1193,6 +1210,15 @@ export interface components { actualCouponsTotal: number actualPrincipalRepaymentTotal: number } + BrokerEventsDataDto: { + items: components['schemas']['BrokerEventItemDto'][] + summary: components['schemas']['BrokerEventsSummaryDto'] + asOf: string + } + BrokerEventsEnvelopeDto: { + data: components['schemas']['BrokerEventsDataDto'] + meta: components['schemas']['BrokerResponseMetaDto'] + } BrokerAnalyticsDto: { totalDeposits: number totalWithdrawn: number @@ -1203,13 +1229,8 @@ export interface components { totalReturnPercent: number | null currency: string } - BrokerEventsDataDto: { - items: components['schemas']['BrokerEventItemDto'][] - summary: components['schemas']['BrokerEventsSummaryDto'] - asOf: string - } - BrokerEventsEnvelopeDto: { - data: components['schemas']['BrokerEventsDataDto'] + BrokerAnalyticsEnvelopeDto: { + data: components['schemas']['BrokerAnalyticsDto'] meta: components['schemas']['BrokerResponseMetaDto'] } 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: { parameters: { query: { diff --git a/docs/features/broker-account-analytics/tasks.md b/docs/features/broker-account-analytics/tasks.md index 864f391..8361d07 100644 --- a/docs/features/broker-account-analytics/tasks.md +++ b/docs/features/broker-account-analytics/tasks.md @@ -2,15 +2,15 @@ ## Backend -- [ ] **T1** DTO `BrokerAnalyticsDto` + сервис `BrokerAnalyticsService` с агрегацией и кешем -- [ ] **T2** Controller endpoint, module registration, cache key, config TTL +- [x] **T1** DTO `BrokerAnalyticsDto` + сервис `BrokerAnalyticsService` с агрегацией и кешем +- [x] **T2** Controller endpoint, module registration, cache key, config TTL ## Frontend -- [ ] **T3** Shared типы, entity API, хук `useBrokerAnalytics` -- [ ] **T4** Страница `BrokerAnalyticsPage` с вёрсткой трёх блоков -- [ ] **T5** Роут `/analytics` + таб в навигации +- [x] **T3** Shared типы, entity API, хук `useBrokerAnalytics` +- [x] **T4** Страница `BrokerAnalyticsPage` с вёрсткой трёх блоков +- [x] **T5** Роут `/analytics` + таб в навигации ## Проверка -- [ ] **T6** Линт, сборка, тесты +- [x] **T6** Линт, сборка, тесты