refactor(frontend): create shared/api layer with re-export shims
This commit is contained in:
parent
df4b778d95
commit
a9132109da
@ -1,232 +1,17 @@
|
||||
import type {
|
||||
ApiEnvelope,
|
||||
ApiResponseMeta,
|
||||
AuthResponse,
|
||||
ShareResponse,
|
||||
StockMarketData,
|
||||
DividendItem,
|
||||
ShareHistoryItem,
|
||||
BondResponse,
|
||||
BondMarketData,
|
||||
BondHistoryItem,
|
||||
CandleItem,
|
||||
SearchResultItem,
|
||||
HealthResponse,
|
||||
} from './responses';
|
||||
|
||||
const BASE = '';
|
||||
|
||||
let accessToken: string | null = null;
|
||||
let onUnauthorized: (() => void) | null = null;
|
||||
let isRefreshing = false;
|
||||
let refreshPromise: Promise<boolean> | null = null;
|
||||
|
||||
export function setAccessToken(token: string | null) {
|
||||
accessToken = token;
|
||||
}
|
||||
|
||||
export function getAccessToken(): string | null {
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
export function setOnUnauthorized(cb: () => void) {
|
||||
onUnauthorized = cb;
|
||||
}
|
||||
|
||||
async function refreshTokens(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${BASE}/api/v1/auth/refresh`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const json = await res.json();
|
||||
accessToken = normalizeEnvelope<AuthResponse>(json).data.accessToken;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeEnvelope<T>(json: unknown): { data: T; meta: ApiResponseMeta } {
|
||||
const envelope = json as ApiEnvelope<T | { data: T; meta: ApiResponseMeta }>;
|
||||
if (
|
||||
envelope.data &&
|
||||
typeof envelope.data === 'object' &&
|
||||
'data' in envelope.data &&
|
||||
'meta' in envelope.data
|
||||
) {
|
||||
return envelope.data as { data: T; meta: ApiResponseMeta };
|
||||
}
|
||||
|
||||
return {
|
||||
data: envelope.data as T,
|
||||
meta: envelope.meta,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleUnauthorized(): Promise<boolean> {
|
||||
if (isRefreshing && refreshPromise) {
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
isRefreshing = true;
|
||||
refreshPromise = refreshTokens().then((success) => {
|
||||
isRefreshing = false;
|
||||
refreshPromise = null;
|
||||
return success;
|
||||
});
|
||||
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
export async function request<T>(
|
||||
path: string,
|
||||
params?: Record<string, string | undefined>,
|
||||
options?: { method?: string; body?: unknown; skipAuth?: boolean },
|
||||
): Promise<{ data: T; meta: ApiResponseMeta }> {
|
||||
const url = new URL(`${BASE}${path}`, window.location.origin);
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== undefined) url.searchParams.set(k, v);
|
||||
}
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (!options?.skipAuth && accessToken) {
|
||||
headers['Authorization'] = `Bearer ${accessToken}`;
|
||||
}
|
||||
if (options?.body && !(options.body instanceof FormData)) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
headers,
|
||||
credentials: 'include' as RequestCredentials,
|
||||
};
|
||||
if (options?.method) {
|
||||
fetchOptions.method = options.method;
|
||||
}
|
||||
if (options?.body !== undefined) {
|
||||
fetchOptions.body =
|
||||
options.body instanceof FormData ? options.body : JSON.stringify(options.body);
|
||||
}
|
||||
|
||||
let res = await fetch(url.toString(), fetchOptions);
|
||||
|
||||
if (res.status === 401 && !options?.skipAuth) {
|
||||
const refreshed = await handleUnauthorized();
|
||||
if (refreshed) {
|
||||
headers['Authorization'] = `Bearer ${accessToken}`;
|
||||
res = await fetch(url.toString(), { ...fetchOptions, headers });
|
||||
} else {
|
||||
accessToken = null;
|
||||
onUnauthorized?.();
|
||||
throw new Error('Сессия истекла');
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`Ошибка API: ${res.status} ${res.statusText}${text ? ` - ${text}` : ''}`);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
return normalizeEnvelope<T>(json);
|
||||
}
|
||||
|
||||
export function getHealth(): Promise<{ data: HealthResponse; meta: ApiResponseMeta }> {
|
||||
return request<HealthResponse>('/api/v1/health');
|
||||
}
|
||||
|
||||
export function searchSecurities(
|
||||
q: string,
|
||||
type: 'all' | 'share' | 'bond' = 'all',
|
||||
limit = 20,
|
||||
): Promise<{ data: SearchResultItem[]; meta: ApiResponseMeta }> {
|
||||
return request<SearchResultItem[]>('/api/v1/securities/search', {
|
||||
q,
|
||||
type,
|
||||
limit: String(limit),
|
||||
});
|
||||
}
|
||||
|
||||
export function getShare(secid: string): Promise<{ data: ShareResponse; meta: ApiResponseMeta }> {
|
||||
return request<ShareResponse>(`/api/v1/securities/shares/${encodeURIComponent(secid)}`);
|
||||
}
|
||||
|
||||
export function getShareMarketData(
|
||||
secid: string,
|
||||
): Promise<{ data: StockMarketData; meta: ApiResponseMeta }> {
|
||||
return request<StockMarketData>(
|
||||
`/api/v1/securities/shares/${encodeURIComponent(secid)}/marketdata`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getShareDividends(
|
||||
secid: string,
|
||||
): Promise<{ data: DividendItem[]; meta: ApiResponseMeta }> {
|
||||
return request<DividendItem[]>(
|
||||
`/api/v1/securities/shares/${encodeURIComponent(secid)}/dividends`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getShareHistory(
|
||||
secid: string,
|
||||
from: string,
|
||||
till: string,
|
||||
): Promise<{ data: ShareHistoryItem[]; meta: ApiResponseMeta }> {
|
||||
return request<ShareHistoryItem[]>(
|
||||
`/api/v1/securities/shares/${encodeURIComponent(secid)}/history`,
|
||||
{ from, till },
|
||||
);
|
||||
}
|
||||
|
||||
export function getBond(secid: string): Promise<{ data: BondResponse; meta: ApiResponseMeta }> {
|
||||
return request<BondResponse>(`/api/v1/securities/bonds/${encodeURIComponent(secid)}`);
|
||||
}
|
||||
|
||||
export function getBondMarketData(
|
||||
secid: string,
|
||||
): Promise<{ data: BondMarketData; meta: ApiResponseMeta }> {
|
||||
return request<BondMarketData>(
|
||||
`/api/v1/securities/bonds/${encodeURIComponent(secid)}/marketdata`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getBondHistory(
|
||||
secid: string,
|
||||
from: string,
|
||||
till: string,
|
||||
): Promise<{ data: BondHistoryItem[]; meta: ApiResponseMeta }> {
|
||||
return request<BondHistoryItem[]>(
|
||||
`/api/v1/securities/bonds/${encodeURIComponent(secid)}/history`,
|
||||
{ from, till },
|
||||
);
|
||||
}
|
||||
|
||||
export function getShareCandles(
|
||||
secid: string,
|
||||
interval: '1h' | '24h',
|
||||
from: string,
|
||||
till: string,
|
||||
): Promise<{ data: CandleItem[]; meta: ApiResponseMeta }> {
|
||||
return request<CandleItem[]>(`/api/v1/securities/shares/${encodeURIComponent(secid)}/candles`, {
|
||||
interval,
|
||||
from,
|
||||
till,
|
||||
});
|
||||
}
|
||||
|
||||
export function getBondCandles(
|
||||
secid: string,
|
||||
interval: '1h' | '24h',
|
||||
from: string,
|
||||
till: string,
|
||||
): Promise<{ data: CandleItem[]; meta: ApiResponseMeta }> {
|
||||
return request<CandleItem[]>(`/api/v1/securities/bonds/${encodeURIComponent(secid)}/candles`, {
|
||||
interval,
|
||||
from,
|
||||
till,
|
||||
});
|
||||
}
|
||||
export {
|
||||
request,
|
||||
setAccessToken,
|
||||
getAccessToken,
|
||||
setOnUnauthorized,
|
||||
getHealth,
|
||||
searchSecurities,
|
||||
getShare,
|
||||
getShareMarketData,
|
||||
getShareDividends,
|
||||
getShareHistory,
|
||||
getBond,
|
||||
getBondMarketData,
|
||||
getBondHistory,
|
||||
getShareCandles,
|
||||
getBondCandles,
|
||||
} from '../shared/api/client';
|
||||
|
||||
@ -1,352 +1,32 @@
|
||||
export interface ApiResponseMeta {
|
||||
cachedAt: string | null;
|
||||
fromCache: boolean;
|
||||
}
|
||||
|
||||
export interface ApiEnvelope<T> {
|
||||
data: T;
|
||||
meta: ApiResponseMeta;
|
||||
}
|
||||
|
||||
export interface StockMarketData {
|
||||
price: number | null;
|
||||
change: number | null;
|
||||
changePercent: number | null;
|
||||
open: number | null;
|
||||
high: number | null;
|
||||
low: number | null;
|
||||
volume: number;
|
||||
value: number;
|
||||
issueCapitalization: number | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ShareResponse {
|
||||
secid: string;
|
||||
isin: string;
|
||||
name: string;
|
||||
shortName: string;
|
||||
latName: string | null;
|
||||
listLevel: number;
|
||||
issueSize: number;
|
||||
faceValue: number;
|
||||
faceUnit: string;
|
||||
type: string;
|
||||
marketData: StockMarketData;
|
||||
}
|
||||
|
||||
export interface DividendItem {
|
||||
registryCloseDate: string;
|
||||
value: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface ShareHistoryItem {
|
||||
date: string;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface BondMarketData {
|
||||
price: number | null;
|
||||
yieldToMaturity: number | null;
|
||||
duration: number | null;
|
||||
accruedInt: number | null;
|
||||
couponValue: number | null;
|
||||
couponPercent: number | null;
|
||||
nextCouponDate: string | null;
|
||||
open: number;
|
||||
high: number | null;
|
||||
low: number | null;
|
||||
volume: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface BondResponse {
|
||||
secid: string;
|
||||
isin: string;
|
||||
name: string;
|
||||
shortName: string;
|
||||
latName: string | null;
|
||||
listLevel: number;
|
||||
issueSize: number;
|
||||
faceValue: number;
|
||||
faceUnit: string;
|
||||
matDate: string;
|
||||
couponValue: number;
|
||||
couponPercent: number | null;
|
||||
couponPeriod: number;
|
||||
nextCoupon: string | null;
|
||||
accruedInt: number;
|
||||
bondType: string;
|
||||
bondSubType: string;
|
||||
offerDate: string | null;
|
||||
buybackDate: string | null;
|
||||
marketData: BondMarketData;
|
||||
}
|
||||
|
||||
export interface BondHistoryItem {
|
||||
date: string;
|
||||
closePrice: number;
|
||||
yieldClose: number | null;
|
||||
duration: number | null;
|
||||
}
|
||||
|
||||
export interface CandleItem {
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
value: number;
|
||||
begin: string;
|
||||
end: string;
|
||||
}
|
||||
|
||||
export interface SearchResultItem {
|
||||
secid: string;
|
||||
isin: string;
|
||||
shortName: string;
|
||||
type: 'share' | 'bond';
|
||||
listLevel: number;
|
||||
currency: string | null;
|
||||
price: number | null;
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
status: string;
|
||||
timestamp: string;
|
||||
uptime: number;
|
||||
}
|
||||
|
||||
export interface UserResponse {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string | null;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
user: UserResponse;
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
export interface Portfolio {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
currency: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
totalValue: number;
|
||||
positionCount: number;
|
||||
shareCount: number;
|
||||
bondCount: number;
|
||||
}
|
||||
|
||||
export interface PositionWithPrice {
|
||||
id: number;
|
||||
portfolioId: number;
|
||||
secid: string;
|
||||
shortName: string | null;
|
||||
type: 'share' | 'bond';
|
||||
quantity: number;
|
||||
notes: string | null;
|
||||
tags: string[] | null;
|
||||
currentPrice: number | null;
|
||||
buyPrice: number | null;
|
||||
buyDate: string | null;
|
||||
totalCost: number | null;
|
||||
currentValue: number | null;
|
||||
pnl: number | null;
|
||||
pnlPercent: number | null;
|
||||
dividendIncome: number | null;
|
||||
totalReturn: number | null;
|
||||
totalReturnPercent: number | null;
|
||||
weightPercent: number;
|
||||
change?: number | null;
|
||||
changePercent?: number | null;
|
||||
yieldToMaturity?: number | null;
|
||||
duration?: number | null;
|
||||
couponValue?: number | null;
|
||||
couponPercent?: number | null;
|
||||
nextCouponDate?: string | null;
|
||||
matDate?: string | null;
|
||||
accruedInt?: number | null;
|
||||
bid?: number | null;
|
||||
offer?: number | null;
|
||||
couponPeriod?: number | null;
|
||||
bondType?: string | null;
|
||||
offerDate?: string | null;
|
||||
}
|
||||
|
||||
export interface PortfolioDetail extends Portfolio {
|
||||
positions: PositionWithPrice[];
|
||||
totalValue: number;
|
||||
analytics: PortfolioSummary;
|
||||
}
|
||||
|
||||
export interface Position {
|
||||
id: number;
|
||||
secid: string;
|
||||
quantity: number;
|
||||
notes: string | null;
|
||||
tags: string[] | null;
|
||||
portfolioId: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PortfolioSummary {
|
||||
totalInvested: number;
|
||||
totalValue: number;
|
||||
totalPnl: number;
|
||||
totalPnlPercent: number | null;
|
||||
totalDividends: number;
|
||||
totalReturn: number;
|
||||
totalReturnPercent: number | null;
|
||||
positionCount: number;
|
||||
weightedYield: number | null;
|
||||
}
|
||||
|
||||
export interface AnalyticsResponse {
|
||||
positions: PositionWithPrice[];
|
||||
summary: PortfolioSummary;
|
||||
}
|
||||
|
||||
export interface ScreenerItem {
|
||||
secid: string;
|
||||
shortName: string;
|
||||
isin: string;
|
||||
type: 'share' | 'bond';
|
||||
price: number | null;
|
||||
change: number | null;
|
||||
changePercent: number | null;
|
||||
volume: number;
|
||||
listLevel: number;
|
||||
capitalization: number | null;
|
||||
yieldToMaturity: number | null;
|
||||
duration: number | null;
|
||||
couponValue: number | null;
|
||||
couponPercent: number | null;
|
||||
accruedInt: number | null;
|
||||
matDate: string | null;
|
||||
bondType: string | null;
|
||||
}
|
||||
|
||||
export interface ScreenerResult {
|
||||
items: ScreenerItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
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;
|
||||
positionCounts: {
|
||||
shares: number;
|
||||
bonds: number;
|
||||
etf: number;
|
||||
other: number;
|
||||
};
|
||||
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[];
|
||||
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;
|
||||
name: 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;
|
||||
}
|
||||
|
||||
export interface BrokerPositionsPage {
|
||||
accountId: string;
|
||||
items: BrokerPosition[];
|
||||
nextCursor: string | null;
|
||||
hasNext: boolean;
|
||||
asOf: string;
|
||||
}
|
||||
export type {
|
||||
ApiResponseMeta,
|
||||
ApiEnvelope,
|
||||
StockMarketData,
|
||||
ShareResponse,
|
||||
DividendItem,
|
||||
ShareHistoryItem,
|
||||
BondMarketData,
|
||||
BondResponse,
|
||||
BondHistoryItem,
|
||||
CandleItem,
|
||||
SearchResultItem,
|
||||
HealthResponse,
|
||||
UserResponse,
|
||||
AuthResponse,
|
||||
Portfolio,
|
||||
PositionWithPrice,
|
||||
PortfolioDetail,
|
||||
Position,
|
||||
PortfolioSummary,
|
||||
AnalyticsResponse,
|
||||
ScreenerItem,
|
||||
ScreenerResult,
|
||||
BrokerMoney,
|
||||
BrokerAccount,
|
||||
BrokerPosition,
|
||||
BrokerPortfolio,
|
||||
BrokerOperationCategory,
|
||||
BrokerOperation,
|
||||
BrokerOperationsPage,
|
||||
BrokerPositionsPage,
|
||||
} from '../shared/api/responses';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
153
apps/frontend/src/shared/api/client.test.ts
Normal file
153
apps/frontend/src/shared/api/client.test.ts
Normal file
@ -0,0 +1,153 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../test/server';
|
||||
import { request, setAccessToken, getAccessToken, setOnUnauthorized } from './client';
|
||||
|
||||
const API = '/api/v1';
|
||||
|
||||
beforeEach(() => {
|
||||
setAccessToken(null);
|
||||
});
|
||||
|
||||
describe('request', () => {
|
||||
it('makes GET request and returns data', async () => {
|
||||
const result = await request<{ status: string; timestamp: string; uptime: number }>(
|
||||
'/api/v1/health',
|
||||
);
|
||||
expect(result.data.status).toBe('ok');
|
||||
});
|
||||
|
||||
it('supports the single API envelope shape documented by Swagger', async () => {
|
||||
server.use(
|
||||
http.get(`${API}/test-single-envelope`, () =>
|
||||
HttpResponse.json({
|
||||
data: { ok: true },
|
||||
meta: { fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await request<{ ok: boolean }>('/api/v1/test-single-envelope');
|
||||
|
||||
expect(result).toEqual({
|
||||
data: { ok: true },
|
||||
meta: { fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' },
|
||||
});
|
||||
});
|
||||
|
||||
it('includes Authorization header when token is set', async () => {
|
||||
setAccessToken('test-token');
|
||||
let capturedAuth: string | null = null;
|
||||
server.use(
|
||||
http.get(`${API}/test-auth`, ({ request }) => {
|
||||
capturedAuth = request.headers.get('Authorization');
|
||||
return HttpResponse.json({
|
||||
data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } },
|
||||
});
|
||||
}),
|
||||
);
|
||||
await request('/api/v1/test-auth');
|
||||
expect(capturedAuth).toBe('Bearer test-token');
|
||||
});
|
||||
|
||||
it('retries on 401 and succeeds after refresh', async () => {
|
||||
setAccessToken('expired-token');
|
||||
let attempts = 0;
|
||||
server.use(
|
||||
http.get(`${API}/test-retry`, ({ request }) => {
|
||||
attempts++;
|
||||
const auth = request.headers.get('Authorization');
|
||||
if (auth === 'Bearer expired-token') {
|
||||
return new HttpResponse(null, { status: 401 });
|
||||
}
|
||||
return HttpResponse.json({
|
||||
data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } },
|
||||
});
|
||||
}),
|
||||
http.post(`${API}/auth/refresh`, () =>
|
||||
HttpResponse.json({
|
||||
data: {
|
||||
data: {
|
||||
user: { id: 1, email: 'user@test.com', name: null, role: 'user' },
|
||||
accessToken: 'new-token',
|
||||
},
|
||||
meta: { fromCache: false, cachedAt: null },
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
const result = await request<{ ok: boolean }>('/api/v1/test-retry');
|
||||
expect(attempts).toBe(2);
|
||||
expect(result.data).toEqual({ ok: true });
|
||||
expect(getAccessToken()).toBe('new-token');
|
||||
});
|
||||
|
||||
it('throws on persistent 401 and clears token', async () => {
|
||||
setAccessToken('expired-token');
|
||||
let unauthorizedCalled = false;
|
||||
setOnUnauthorized(() => {
|
||||
unauthorizedCalled = true;
|
||||
});
|
||||
server.use(
|
||||
http.get(`${API}/test-fail`, () => new HttpResponse(null, { status: 401 })),
|
||||
http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })),
|
||||
);
|
||||
await expect(request('/api/v1/test-fail')).rejects.toThrow('Сессия истекла');
|
||||
expect(getAccessToken()).toBeNull();
|
||||
expect(unauthorizedCalled).toBe(true);
|
||||
});
|
||||
|
||||
it('throws on non-ok response with status text', async () => {
|
||||
server.use(
|
||||
http.get(
|
||||
`${API}/test-error`,
|
||||
() => new HttpResponse('Not found', { status: 404, statusText: 'Not Found' }),
|
||||
),
|
||||
);
|
||||
await expect(request('/api/v1/test-error')).rejects.toThrow('Ошибка API: 404');
|
||||
});
|
||||
|
||||
it('sends JSON body for POST requests', async () => {
|
||||
let capturedBody: string | null = null;
|
||||
server.use(
|
||||
http.post(`${API}/test-post`, async ({ request }) => {
|
||||
capturedBody = await request.text();
|
||||
return HttpResponse.json({
|
||||
data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } },
|
||||
});
|
||||
}),
|
||||
);
|
||||
await request('/api/v1/test-post', undefined, { method: 'POST', body: { foo: 'bar' } });
|
||||
expect(capturedBody).toBe(JSON.stringify({ foo: 'bar' }));
|
||||
});
|
||||
|
||||
it('does not send auth header when skipAuth is true', async () => {
|
||||
setAccessToken('test-token');
|
||||
let capturedAuth: string | null = null;
|
||||
server.use(
|
||||
http.get(`${API}/test-skip`, ({ request }) => {
|
||||
capturedAuth = request.headers.get('Authorization');
|
||||
return HttpResponse.json({
|
||||
data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } },
|
||||
});
|
||||
}),
|
||||
);
|
||||
await request('/api/v1/test-skip', undefined, { skipAuth: true });
|
||||
expect(capturedAuth).toBeNull();
|
||||
});
|
||||
|
||||
it('sets query params correctly', async () => {
|
||||
let capturedUrl = '';
|
||||
server.use(
|
||||
http.get(`${API}/test-params`, ({ request }) => {
|
||||
capturedUrl = request.url;
|
||||
return HttpResponse.json({
|
||||
data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } },
|
||||
});
|
||||
}),
|
||||
);
|
||||
await request('/api/v1/test-params', { q: 'sber', type: 'share' });
|
||||
expect(capturedUrl).toContain('q=sber');
|
||||
expect(capturedUrl).toContain('type=share');
|
||||
});
|
||||
});
|
||||
232
apps/frontend/src/shared/api/client.ts
Normal file
232
apps/frontend/src/shared/api/client.ts
Normal file
@ -0,0 +1,232 @@
|
||||
import type {
|
||||
ApiEnvelope,
|
||||
ApiResponseMeta,
|
||||
AuthResponse,
|
||||
ShareResponse,
|
||||
StockMarketData,
|
||||
DividendItem,
|
||||
ShareHistoryItem,
|
||||
BondResponse,
|
||||
BondMarketData,
|
||||
BondHistoryItem,
|
||||
CandleItem,
|
||||
SearchResultItem,
|
||||
HealthResponse,
|
||||
} from './responses';
|
||||
|
||||
const BASE = '';
|
||||
|
||||
let accessToken: string | null = null;
|
||||
let onUnauthorized: (() => void) | null = null;
|
||||
let isRefreshing = false;
|
||||
let refreshPromise: Promise<boolean> | null = null;
|
||||
|
||||
export function setAccessToken(token: string | null) {
|
||||
accessToken = token;
|
||||
}
|
||||
|
||||
export function getAccessToken(): string | null {
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
export function setOnUnauthorized(cb: () => void) {
|
||||
onUnauthorized = cb;
|
||||
}
|
||||
|
||||
async function refreshTokens(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${BASE}/api/v1/auth/refresh`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const json = await res.json();
|
||||
accessToken = normalizeEnvelope<AuthResponse>(json).data.accessToken;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeEnvelope<T>(json: unknown): { data: T; meta: ApiResponseMeta } {
|
||||
const envelope = json as ApiEnvelope<T | { data: T; meta: ApiResponseMeta }>;
|
||||
if (
|
||||
envelope.data &&
|
||||
typeof envelope.data === 'object' &&
|
||||
'data' in envelope.data &&
|
||||
'meta' in envelope.data
|
||||
) {
|
||||
return envelope.data as { data: T; meta: ApiResponseMeta };
|
||||
}
|
||||
|
||||
return {
|
||||
data: envelope.data as T,
|
||||
meta: envelope.meta,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleUnauthorized(): Promise<boolean> {
|
||||
if (isRefreshing && refreshPromise) {
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
isRefreshing = true;
|
||||
refreshPromise = refreshTokens().then((success) => {
|
||||
isRefreshing = false;
|
||||
refreshPromise = null;
|
||||
return success;
|
||||
});
|
||||
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
export async function request<T>(
|
||||
path: string,
|
||||
params?: Record<string, string | undefined>,
|
||||
options?: { method?: string; body?: unknown; skipAuth?: boolean },
|
||||
): Promise<{ data: T; meta: ApiResponseMeta }> {
|
||||
const url = new URL(`${BASE}${path}`, window.location.origin);
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== undefined) url.searchParams.set(k, v);
|
||||
}
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (!options?.skipAuth && accessToken) {
|
||||
headers['Authorization'] = `Bearer ${accessToken}`;
|
||||
}
|
||||
if (options?.body && !(options.body instanceof FormData)) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
headers,
|
||||
credentials: 'include' as RequestCredentials,
|
||||
};
|
||||
if (options?.method) {
|
||||
fetchOptions.method = options.method;
|
||||
}
|
||||
if (options?.body !== undefined) {
|
||||
fetchOptions.body =
|
||||
options.body instanceof FormData ? options.body : JSON.stringify(options.body);
|
||||
}
|
||||
|
||||
let res = await fetch(url.toString(), fetchOptions);
|
||||
|
||||
if (res.status === 401 && !options?.skipAuth) {
|
||||
const refreshed = await handleUnauthorized();
|
||||
if (refreshed) {
|
||||
headers['Authorization'] = `Bearer ${accessToken}`;
|
||||
res = await fetch(url.toString(), { ...fetchOptions, headers });
|
||||
} else {
|
||||
accessToken = null;
|
||||
onUnauthorized?.();
|
||||
throw new Error('Сессия истекла');
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`Ошибка API: ${res.status} ${res.statusText}${text ? ` - ${text}` : ''}`);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
return normalizeEnvelope<T>(json);
|
||||
}
|
||||
|
||||
export function getHealth(): Promise<{ data: HealthResponse; meta: ApiResponseMeta }> {
|
||||
return request<HealthResponse>('/api/v1/health');
|
||||
}
|
||||
|
||||
export function searchSecurities(
|
||||
q: string,
|
||||
type: 'all' | 'share' | 'bond' = 'all',
|
||||
limit = 20,
|
||||
): Promise<{ data: SearchResultItem[]; meta: ApiResponseMeta }> {
|
||||
return request<SearchResultItem[]>('/api/v1/securities/search', {
|
||||
q,
|
||||
type,
|
||||
limit: String(limit),
|
||||
});
|
||||
}
|
||||
|
||||
export function getShare(secid: string): Promise<{ data: ShareResponse; meta: ApiResponseMeta }> {
|
||||
return request<ShareResponse>(`/api/v1/securities/shares/${encodeURIComponent(secid)}`);
|
||||
}
|
||||
|
||||
export function getShareMarketData(
|
||||
secid: string,
|
||||
): Promise<{ data: StockMarketData; meta: ApiResponseMeta }> {
|
||||
return request<StockMarketData>(
|
||||
`/api/v1/securities/shares/${encodeURIComponent(secid)}/marketdata`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getShareDividends(
|
||||
secid: string,
|
||||
): Promise<{ data: DividendItem[]; meta: ApiResponseMeta }> {
|
||||
return request<DividendItem[]>(
|
||||
`/api/v1/securities/shares/${encodeURIComponent(secid)}/dividends`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getShareHistory(
|
||||
secid: string,
|
||||
from: string,
|
||||
till: string,
|
||||
): Promise<{ data: ShareHistoryItem[]; meta: ApiResponseMeta }> {
|
||||
return request<ShareHistoryItem[]>(
|
||||
`/api/v1/securities/shares/${encodeURIComponent(secid)}/history`,
|
||||
{ from, till },
|
||||
);
|
||||
}
|
||||
|
||||
export function getBond(secid: string): Promise<{ data: BondResponse; meta: ApiResponseMeta }> {
|
||||
return request<BondResponse>(`/api/v1/securities/bonds/${encodeURIComponent(secid)}`);
|
||||
}
|
||||
|
||||
export function getBondMarketData(
|
||||
secid: string,
|
||||
): Promise<{ data: BondMarketData; meta: ApiResponseMeta }> {
|
||||
return request<BondMarketData>(
|
||||
`/api/v1/securities/bonds/${encodeURIComponent(secid)}/marketdata`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getBondHistory(
|
||||
secid: string,
|
||||
from: string,
|
||||
till: string,
|
||||
): Promise<{ data: BondHistoryItem[]; meta: ApiResponseMeta }> {
|
||||
return request<BondHistoryItem[]>(
|
||||
`/api/v1/securities/bonds/${encodeURIComponent(secid)}/history`,
|
||||
{ from, till },
|
||||
);
|
||||
}
|
||||
|
||||
export function getShareCandles(
|
||||
secid: string,
|
||||
interval: '1h' | '24h',
|
||||
from: string,
|
||||
till: string,
|
||||
): Promise<{ data: CandleItem[]; meta: ApiResponseMeta }> {
|
||||
return request<CandleItem[]>(`/api/v1/securities/shares/${encodeURIComponent(secid)}/candles`, {
|
||||
interval,
|
||||
from,
|
||||
till,
|
||||
});
|
||||
}
|
||||
|
||||
export function getBondCandles(
|
||||
secid: string,
|
||||
interval: '1h' | '24h',
|
||||
from: string,
|
||||
till: string,
|
||||
): Promise<{ data: CandleItem[]; meta: ApiResponseMeta }> {
|
||||
return request<CandleItem[]>(`/api/v1/securities/bonds/${encodeURIComponent(secid)}/candles`, {
|
||||
interval,
|
||||
from,
|
||||
till,
|
||||
});
|
||||
}
|
||||
49
apps/frontend/src/shared/api/index.ts
Normal file
49
apps/frontend/src/shared/api/index.ts
Normal file
@ -0,0 +1,49 @@
|
||||
export {
|
||||
request,
|
||||
setAccessToken,
|
||||
getAccessToken,
|
||||
setOnUnauthorized,
|
||||
getHealth,
|
||||
searchSecurities,
|
||||
getShare,
|
||||
getShareMarketData,
|
||||
getShareDividends,
|
||||
getShareHistory,
|
||||
getBond,
|
||||
getBondMarketData,
|
||||
getBondHistory,
|
||||
getShareCandles,
|
||||
getBondCandles,
|
||||
} from './client';
|
||||
export type {
|
||||
ApiResponseMeta,
|
||||
ApiEnvelope,
|
||||
StockMarketData,
|
||||
ShareResponse,
|
||||
DividendItem,
|
||||
ShareHistoryItem,
|
||||
BondMarketData,
|
||||
BondResponse,
|
||||
BondHistoryItem,
|
||||
CandleItem,
|
||||
SearchResultItem,
|
||||
HealthResponse,
|
||||
UserResponse,
|
||||
AuthResponse,
|
||||
Portfolio,
|
||||
PositionWithPrice,
|
||||
PortfolioDetail,
|
||||
Position,
|
||||
PortfolioSummary,
|
||||
AnalyticsResponse,
|
||||
ScreenerItem,
|
||||
ScreenerResult,
|
||||
BrokerMoney,
|
||||
BrokerAccount,
|
||||
BrokerPosition,
|
||||
BrokerPortfolio,
|
||||
BrokerOperationCategory,
|
||||
BrokerOperation,
|
||||
BrokerOperationsPage,
|
||||
BrokerPositionsPage,
|
||||
} from './responses';
|
||||
352
apps/frontend/src/shared/api/responses.ts
Normal file
352
apps/frontend/src/shared/api/responses.ts
Normal file
@ -0,0 +1,352 @@
|
||||
export interface ApiResponseMeta {
|
||||
cachedAt: string | null;
|
||||
fromCache: boolean;
|
||||
}
|
||||
|
||||
export interface ApiEnvelope<T> {
|
||||
data: T;
|
||||
meta: ApiResponseMeta;
|
||||
}
|
||||
|
||||
export interface StockMarketData {
|
||||
price: number | null;
|
||||
change: number | null;
|
||||
changePercent: number | null;
|
||||
open: number | null;
|
||||
high: number | null;
|
||||
low: number | null;
|
||||
volume: number;
|
||||
value: number;
|
||||
issueCapitalization: number | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ShareResponse {
|
||||
secid: string;
|
||||
isin: string;
|
||||
name: string;
|
||||
shortName: string;
|
||||
latName: string | null;
|
||||
listLevel: number;
|
||||
issueSize: number;
|
||||
faceValue: number;
|
||||
faceUnit: string;
|
||||
type: string;
|
||||
marketData: StockMarketData;
|
||||
}
|
||||
|
||||
export interface DividendItem {
|
||||
registryCloseDate: string;
|
||||
value: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface ShareHistoryItem {
|
||||
date: string;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface BondMarketData {
|
||||
price: number | null;
|
||||
yieldToMaturity: number | null;
|
||||
duration: number | null;
|
||||
accruedInt: number | null;
|
||||
couponValue: number | null;
|
||||
couponPercent: number | null;
|
||||
nextCouponDate: string | null;
|
||||
open: number;
|
||||
high: number | null;
|
||||
low: number | null;
|
||||
volume: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface BondResponse {
|
||||
secid: string;
|
||||
isin: string;
|
||||
name: string;
|
||||
shortName: string;
|
||||
latName: string | null;
|
||||
listLevel: number;
|
||||
issueSize: number;
|
||||
faceValue: number;
|
||||
faceUnit: string;
|
||||
matDate: string;
|
||||
couponValue: number;
|
||||
couponPercent: number | null;
|
||||
couponPeriod: number;
|
||||
nextCoupon: string | null;
|
||||
accruedInt: number;
|
||||
bondType: string;
|
||||
bondSubType: string;
|
||||
offerDate: string | null;
|
||||
buybackDate: string | null;
|
||||
marketData: BondMarketData;
|
||||
}
|
||||
|
||||
export interface BondHistoryItem {
|
||||
date: string;
|
||||
closePrice: number;
|
||||
yieldClose: number | null;
|
||||
duration: number | null;
|
||||
}
|
||||
|
||||
export interface CandleItem {
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
value: number;
|
||||
begin: string;
|
||||
end: string;
|
||||
}
|
||||
|
||||
export interface SearchResultItem {
|
||||
secid: string;
|
||||
isin: string;
|
||||
shortName: string;
|
||||
type: 'share' | 'bond';
|
||||
listLevel: number;
|
||||
currency: string | null;
|
||||
price: number | null;
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
status: string;
|
||||
timestamp: string;
|
||||
uptime: number;
|
||||
}
|
||||
|
||||
export interface UserResponse {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string | null;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
user: UserResponse;
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
export interface Portfolio {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
currency: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
totalValue: number;
|
||||
positionCount: number;
|
||||
shareCount: number;
|
||||
bondCount: number;
|
||||
}
|
||||
|
||||
export interface PositionWithPrice {
|
||||
id: number;
|
||||
portfolioId: number;
|
||||
secid: string;
|
||||
shortName: string | null;
|
||||
type: 'share' | 'bond';
|
||||
quantity: number;
|
||||
notes: string | null;
|
||||
tags: string[] | null;
|
||||
currentPrice: number | null;
|
||||
buyPrice: number | null;
|
||||
buyDate: string | null;
|
||||
totalCost: number | null;
|
||||
currentValue: number | null;
|
||||
pnl: number | null;
|
||||
pnlPercent: number | null;
|
||||
dividendIncome: number | null;
|
||||
totalReturn: number | null;
|
||||
totalReturnPercent: number | null;
|
||||
weightPercent: number;
|
||||
change?: number | null;
|
||||
changePercent?: number | null;
|
||||
yieldToMaturity?: number | null;
|
||||
duration?: number | null;
|
||||
couponValue?: number | null;
|
||||
couponPercent?: number | null;
|
||||
nextCouponDate?: string | null;
|
||||
matDate?: string | null;
|
||||
accruedInt?: number | null;
|
||||
bid?: number | null;
|
||||
offer?: number | null;
|
||||
couponPeriod?: number | null;
|
||||
bondType?: string | null;
|
||||
offerDate?: string | null;
|
||||
}
|
||||
|
||||
export interface PortfolioDetail extends Portfolio {
|
||||
positions: PositionWithPrice[];
|
||||
totalValue: number;
|
||||
analytics: PortfolioSummary;
|
||||
}
|
||||
|
||||
export interface Position {
|
||||
id: number;
|
||||
secid: string;
|
||||
quantity: number;
|
||||
notes: string | null;
|
||||
tags: string[] | null;
|
||||
portfolioId: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PortfolioSummary {
|
||||
totalInvested: number;
|
||||
totalValue: number;
|
||||
totalPnl: number;
|
||||
totalPnlPercent: number | null;
|
||||
totalDividends: number;
|
||||
totalReturn: number;
|
||||
totalReturnPercent: number | null;
|
||||
positionCount: number;
|
||||
weightedYield: number | null;
|
||||
}
|
||||
|
||||
export interface AnalyticsResponse {
|
||||
positions: PositionWithPrice[];
|
||||
summary: PortfolioSummary;
|
||||
}
|
||||
|
||||
export interface ScreenerItem {
|
||||
secid: string;
|
||||
shortName: string;
|
||||
isin: string;
|
||||
type: 'share' | 'bond';
|
||||
price: number | null;
|
||||
change: number | null;
|
||||
changePercent: number | null;
|
||||
volume: number;
|
||||
listLevel: number;
|
||||
capitalization: number | null;
|
||||
yieldToMaturity: number | null;
|
||||
duration: number | null;
|
||||
couponValue: number | null;
|
||||
couponPercent: number | null;
|
||||
accruedInt: number | null;
|
||||
matDate: string | null;
|
||||
bondType: string | null;
|
||||
}
|
||||
|
||||
export interface ScreenerResult {
|
||||
items: ScreenerItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
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;
|
||||
positionCounts: {
|
||||
shares: number;
|
||||
bonds: number;
|
||||
etf: number;
|
||||
other: number;
|
||||
};
|
||||
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[];
|
||||
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;
|
||||
name: 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;
|
||||
}
|
||||
|
||||
export interface BrokerPositionsPage {
|
||||
accountId: string;
|
||||
items: BrokerPosition[];
|
||||
nextCursor: string | null;
|
||||
hasNext: boolean;
|
||||
asOf: string;
|
||||
}
|
||||
1637
apps/frontend/src/shared/api/types.ts
Normal file
1637
apps/frontend/src/shared/api/types.ts
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user