diff --git a/apps/frontend/src/api/broker.test.ts b/apps/frontend/src/api/broker.test.ts new file mode 100644 index 0000000..68eca40 --- /dev/null +++ b/apps/frontend/src/api/broker.test.ts @@ -0,0 +1,27 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getBrokerOperations } from './broker'; + +describe('broker api', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('serializes operations query parameters', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ + data: { + data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: 'now' }, + meta: { fromCache: false, cachedAt: null }, + }, + }), + } as Response); + + await getBrokerOperations('acc-1', { cursor: 'c1', limit: 50 }); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/v1/broker/accounts/acc-1/operations?cursor=c1&limit=50'), + expect.any(Object), + ); + }); +}); diff --git a/apps/frontend/src/api/broker.ts b/apps/frontend/src/api/broker.ts new file mode 100644 index 0000000..4fe91f3 --- /dev/null +++ b/apps/frontend/src/api/broker.ts @@ -0,0 +1,51 @@ +import { request } from './client'; +import type { + ApiResponseMeta, + BrokerAccount, + BrokerOperationsPage, + BrokerPortfolio, +} from './responses'; + +export type BrokerOperationQuery = { + from?: string; + to?: string; + cursor?: string; + limit?: number; + instrumentId?: string; + operationTypes?: string; + state?: string; +}; + +export function getBrokerAccounts(): Promise<{ + data: BrokerAccount[]; + meta: ApiResponseMeta; +}> { + return request('/api/v1/broker/accounts'); +} + +export function getBrokerPortfolio(accountId: string): Promise<{ + data: BrokerPortfolio; + meta: ApiResponseMeta; +}> { + return request( + `/api/v1/broker/accounts/${encodeURIComponent(accountId)}/portfolio`, + ); +} + +export function getBrokerOperations( + accountId: string, + query: BrokerOperationQuery = {}, +): Promise<{ data: BrokerOperationsPage; meta: ApiResponseMeta }> { + return request( + `/api/v1/broker/accounts/${encodeURIComponent(accountId)}/operations`, + { + from: query.from, + to: query.to, + cursor: query.cursor, + limit: query.limit ? String(query.limit) : undefined, + instrumentId: query.instrumentId, + operationTypes: query.operationTypes, + state: query.state, + }, + ); +} diff --git a/apps/frontend/src/api/responses.ts b/apps/frontend/src/api/responses.ts index 868e78e..3227eec 100644 --- a/apps/frontend/src/api/responses.ts +++ b/apps/frontend/src/api/responses.ts @@ -245,3 +245,94 @@ export interface ScreenerResult { pageSize: number; totalPages: number; } + +export interface BrokerMoney { + currency: string; + units: string; + nano: number; + value: number; +} + +export interface BrokerAccount { + id: string; + type: 'brokerage' | 'iis'; + name: string; + status: string; + openedAt: string | null; + accessLevel: string | null; +} + +export interface BrokerPosition { + figi: string | null; + instrumentUid: string | null; + positionUid: string | null; + ticker: string | null; + classCode: string | null; + instrumentType: string | null; + name: string | null; + quantity: number | null; + blockedLots: number | null; + currentPrice: BrokerMoney | null; + currentValue: BrokerMoney | null; + averagePositionPrice: BrokerMoney | null; + expectedYieldPercent: number | null; + dailyYield: BrokerMoney | null; +} + +export interface BrokerPortfolio { + account: BrokerAccount; + totals: { + shares: BrokerMoney | null; + bonds: BrokerMoney | null; + etf: BrokerMoney | null; + currencies: BrokerMoney | null; + futures: BrokerMoney | null; + options: BrokerMoney | null; + structuredProducts: BrokerMoney | null; + dfa: BrokerMoney | null; + portfolio: BrokerMoney | null; + }; + yields: { + expectedPercent: number | null; + daily: BrokerMoney | null; + dailyPercent: number | null; + }; + cash: BrokerMoney[]; + blockedCash: BrokerMoney[]; + positions: BrokerPosition[]; + asOf: string; +} + +export type BrokerOperationCategory = 'trade' | 'income' | 'tax' | 'fee' | 'transfer' | 'other'; + +export interface BrokerOperation { + cursor: string | null; + accountId: string; + id: string | null; + parentOperationId: string | null; + date: string | null; + type: string; + category: BrokerOperationCategory; + description: string | null; + state: string | null; + instrumentUid: string | null; + figi: string | null; + ticker: string | null; + classCode: string | null; + instrumentType: string | null; + payment: BrokerMoney | null; + price: BrokerMoney | null; + commission: BrokerMoney | null; + yield: BrokerMoney | null; + accruedInt: BrokerMoney | null; + quantity: number | null; + quantityDone: number | null; +} + +export interface BrokerOperationsPage { + accountId: string; + items: BrokerOperation[]; + nextCursor: string | null; + hasNext: boolean; + asOf: string; +} diff --git a/apps/frontend/src/api/types.ts b/apps/frontend/src/api/types.ts index 0046607..7e5dc8a 100644 --- a/apps/frontend/src/api/types.ts +++ b/apps/frontend/src/api/types.ts @@ -383,6 +383,57 @@ export interface paths { patch?: never; trace?: never; }; + '/api/v1/broker/accounts': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get open T-Bank brokerage and IIS accounts */ + get: operations['TBankController_getAccounts']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/broker/accounts/{accountId}/portfolio': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get T-Bank broker account portfolio with cash and positions */ + get: operations['TBankController_getPortfolio']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/broker/accounts/{accountId}/operations': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get paginated T-Bank broker account operations */ + get: operations['TBankController_getOperations']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -689,6 +740,109 @@ export interface components { data: components['schemas']['AnalyticsResponseDto']; meta: components['schemas']['PortfolioResponseMetaDto']; }; + BrokerAccountResponseDto: { + id: string; + /** @enum {string} */ + type: 'brokerage' | 'iis'; + name: string; + status: string; + openedAt: Record | null; + accessLevel: Record | null; + }; + BrokerResponseMetaDto: { + cachedAt: Record | null; + fromCache: boolean; + }; + BrokerAccountsEnvelopeDto: { + data: components['schemas']['BrokerAccountResponseDto'][]; + meta: components['schemas']['BrokerResponseMetaDto']; + }; + BrokerMoneyDto: { + currency: string; + units: string; + nano: number; + value: number; + }; + BrokerPortfolioTotalsDto: { + shares: components['schemas']['BrokerMoneyDto'] | null; + bonds: components['schemas']['BrokerMoneyDto'] | null; + etf: components['schemas']['BrokerMoneyDto'] | null; + currencies: components['schemas']['BrokerMoneyDto'] | null; + futures: components['schemas']['BrokerMoneyDto'] | null; + options: components['schemas']['BrokerMoneyDto'] | null; + structuredProducts: components['schemas']['BrokerMoneyDto'] | null; + dfa: components['schemas']['BrokerMoneyDto'] | null; + portfolio: components['schemas']['BrokerMoneyDto'] | null; + }; + BrokerPortfolioYieldsDto: { + expectedPercent: Record | null; + daily: components['schemas']['BrokerMoneyDto'] | null; + dailyPercent: Record | null; + }; + BrokerPositionResponseDto: { + figi: Record | null; + instrumentUid: Record | null; + positionUid: Record | null; + ticker: Record | null; + classCode: Record | null; + instrumentType: Record | null; + name: Record | null; + quantity: Record | null; + blockedLots: Record | null; + currentPrice: components['schemas']['BrokerMoneyDto'] | null; + currentValue: components['schemas']['BrokerMoneyDto'] | null; + averagePositionPrice: components['schemas']['BrokerMoneyDto'] | null; + expectedYieldPercent: Record | null; + dailyYield: components['schemas']['BrokerMoneyDto'] | null; + }; + BrokerPortfolioResponseDto: { + account: components['schemas']['BrokerAccountResponseDto']; + totals: components['schemas']['BrokerPortfolioTotalsDto']; + yields: components['schemas']['BrokerPortfolioYieldsDto']; + cash: components['schemas']['BrokerMoneyDto'][]; + blockedCash: components['schemas']['BrokerMoneyDto'][]; + positions: components['schemas']['BrokerPositionResponseDto'][]; + asOf: string; + }; + BrokerPortfolioEnvelopeDto: { + data: components['schemas']['BrokerPortfolioResponseDto']; + meta: components['schemas']['BrokerResponseMetaDto']; + }; + BrokerOperationResponseDto: { + cursor: Record | null; + accountId: string; + id: Record | null; + parentOperationId: Record | null; + date: Record | null; + type: string; + /** @enum {string} */ + category: 'trade' | 'income' | 'tax' | 'fee' | 'transfer' | 'other'; + description: Record | null; + state: Record | null; + instrumentUid: Record | null; + figi: Record | null; + ticker: Record | null; + classCode: Record | null; + instrumentType: Record | null; + payment: components['schemas']['BrokerMoneyDto'] | null; + price: components['schemas']['BrokerMoneyDto'] | null; + commission: components['schemas']['BrokerMoneyDto'] | null; + yield: components['schemas']['BrokerMoneyDto'] | null; + accruedInt: components['schemas']['BrokerMoneyDto'] | null; + quantity: Record | null; + quantityDone: Record | null; + }; + BrokerOperationsPageResponseDto: { + accountId: string; + items: components['schemas']['BrokerOperationResponseDto'][]; + nextCursor: Record | null; + hasNext: boolean; + asOf: string; + }; + BrokerOperationsEnvelopeDto: { + data: components['schemas']['BrokerOperationsPageResponseDto']; + meta: components['schemas']['BrokerResponseMetaDto']; + }; }; responses: never; parameters: never; @@ -1300,4 +1454,73 @@ export interface operations { }; }; }; + TBankController_getAccounts: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['BrokerAccountsEnvelopeDto']; + }; + }; + }; + }; + TBankController_getPortfolio: { + parameters: { + query?: never; + header?: never; + path: { + accountId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['BrokerPortfolioEnvelopeDto']; + }; + }; + }; + }; + TBankController_getOperations: { + parameters: { + query?: { + from?: string; + to?: string; + cursor?: string; + limit?: number; + instrumentId?: string; + operationTypes?: string; + state?: string; + }; + header?: never; + path: { + accountId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['BrokerOperationsEnvelopeDto']; + }; + }; + }; + }; } diff --git a/apps/frontend/src/hooks/useBrokerAccounts.test.tsx b/apps/frontend/src/hooks/useBrokerAccounts.test.tsx new file mode 100644 index 0000000..e3fe99c --- /dev/null +++ b/apps/frontend/src/hooks/useBrokerAccounts.test.tsx @@ -0,0 +1,41 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import { type ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { getBrokerAccounts } from '../api/broker'; +import { useBrokerAccounts } from './useBrokerAccounts'; + +vi.mock('../api/broker', () => ({ + getBrokerAccounts: vi.fn(), +})); + +function createWrapper() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe('useBrokerAccounts', () => { + it('returns broker accounts from API', async () => { + vi.mocked(getBrokerAccounts).mockResolvedValue({ + data: [ + { + id: 'acc-1', + type: 'brokerage', + name: 'Broker', + status: 'ACCOUNT_STATUS_OPEN', + openedAt: null, + accessLevel: null, + }, + ], + meta: { fromCache: false, cachedAt: null }, + }); + + const { result } = renderHook(() => useBrokerAccounts(), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.[0].name).toBe('Broker'); + }); +}); diff --git a/apps/frontend/src/hooks/useBrokerAccounts.ts b/apps/frontend/src/hooks/useBrokerAccounts.ts new file mode 100644 index 0000000..70f880a --- /dev/null +++ b/apps/frontend/src/hooks/useBrokerAccounts.ts @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query'; +import { getBrokerAccounts } from '../api/broker'; +import type { BrokerAccount } from '../api/responses'; + +export function useBrokerAccounts() { + return useQuery({ + queryKey: ['broker', 'accounts'], + queryFn: async () => (await getBrokerAccounts()).data, + staleTime: 3_600_000, + retry: 2, + refetchOnWindowFocus: false, + }); +} diff --git a/apps/frontend/src/hooks/useBrokerOperations.ts b/apps/frontend/src/hooks/useBrokerOperations.ts new file mode 100644 index 0000000..40945f4 --- /dev/null +++ b/apps/frontend/src/hooks/useBrokerOperations.ts @@ -0,0 +1,17 @@ +import { useQuery } from '@tanstack/react-query'; +import { getBrokerOperations, type BrokerOperationQuery } from '../api/broker'; +import type { BrokerOperationsPage } from '../api/responses'; + +export function useBrokerOperations( + accountId: string | undefined, + query: BrokerOperationQuery = {}, +) { + return useQuery({ + queryKey: ['broker', 'operations', accountId, query], + enabled: Boolean(accountId), + queryFn: async () => (await getBrokerOperations(accountId!, query)).data, + staleTime: 300_000, + retry: 2, + refetchOnWindowFocus: false, + }); +} diff --git a/apps/frontend/src/hooks/useBrokerPortfolio.ts b/apps/frontend/src/hooks/useBrokerPortfolio.ts new file mode 100644 index 0000000..4b7db02 --- /dev/null +++ b/apps/frontend/src/hooks/useBrokerPortfolio.ts @@ -0,0 +1,14 @@ +import { useQuery } from '@tanstack/react-query'; +import { getBrokerPortfolio } from '../api/broker'; +import type { BrokerPortfolio } from '../api/responses'; + +export function useBrokerPortfolio(accountId: string | undefined) { + return useQuery({ + queryKey: ['broker', 'portfolio', accountId], + enabled: Boolean(accountId), + queryFn: async () => (await getBrokerPortfolio(accountId!)).data, + staleTime: 60_000, + retry: 2, + refetchOnWindowFocus: false, + }); +}