codex/tbank-broker-portfolios-design #15

Merged
ksv741 merged 17 commits from codex/tbank-broker-portfolios-design into main 2026-06-17 07:46:51 +03:00
9 changed files with 210 additions and 11 deletions
Showing only changes of commit ea916dfec9 - Show all commits

View File

@ -1,5 +1,6 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { BrokerAccountResponseDto } from './broker-account-response.dto'; import { BrokerAccountResponseDto } from './broker-account-response.dto';
import { BrokerOperationSyncResponseDto } from './broker-operation-sync-query.dto';
import { BrokerOperationsPageResponseDto } from './broker-operation-response.dto'; import { BrokerOperationsPageResponseDto } from './broker-operation-response.dto';
import { BrokerPortfolioResponseDto } from './broker-portfolio-response.dto'; import { BrokerPortfolioResponseDto } from './broker-portfolio-response.dto';
@ -34,3 +35,11 @@ export class BrokerOperationsEnvelopeDto {
@ApiProperty({ type: BrokerResponseMetaDto }) @ApiProperty({ type: BrokerResponseMetaDto })
meta!: BrokerResponseMetaDto; meta!: BrokerResponseMetaDto;
} }
export class BrokerOperationSyncEnvelopeDto {
@ApiProperty({ type: BrokerOperationSyncResponseDto })
data!: BrokerOperationSyncResponseDto;
@ApiProperty({ type: BrokerResponseMetaDto })
meta!: BrokerResponseMetaDto;
}

View File

@ -0,0 +1,17 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsISO8601 } from 'class-validator';
export class BrokerOperationSyncQueryDto {
@ApiProperty({ example: '2026-06-01T00:00:00.000Z' })
@IsISO8601()
from!: string;
@ApiProperty({ example: '2026-06-17T00:00:00.000Z' })
@IsISO8601()
to!: string;
}
export class BrokerOperationSyncResponseDto {
@ApiProperty({ example: 42 })
upserted!: number;
}

View File

@ -0,0 +1,61 @@
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
import { ApiResponse } from '../../common/dto/api-response.dto';
import { TBankController } from './tbank.controller';
import { BrokerAccountsService } from './services/broker-accounts.service';
import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
import { BrokerOperationsService } from './services/broker-operations.service';
import { BrokerPortfolioService } from './services/broker-portfolio.service';
describe('TBankController', () => {
const accounts = { findAll: vi.fn() } as unknown as BrokerAccountsService;
const portfolio = { getPortfolio: vi.fn() } as unknown as BrokerPortfolioService;
const operations = { getOperations: vi.fn() } as unknown as BrokerOperationsService;
const sync = { syncAccount: vi.fn() } as unknown as BrokerOperationSyncService;
beforeEach(() => {
vi.clearAllMocks();
});
it('limits broker endpoints to admins because they use a server-side T-Bank token', () => {
expect(Reflect.getMetadata(ROLES_KEY, TBankController)).toEqual(['admin']);
});
it('returns accounts in a single API envelope', async () => {
vi.mocked(accounts.findAll).mockResolvedValueOnce({
data: [
{
id: 'acc-1',
type: 'brokerage',
name: 'Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
},
],
meta: { fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' },
});
const controller = new TBankController(accounts, portfolio, operations, sync);
const response = await controller.getAccounts();
expect(response).toBeInstanceOf(ApiResponse);
expect(response.data).toHaveLength(1);
expect(response.meta).toEqual({ fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' });
});
it('exposes an admin sync trigger for durable operation history', async () => {
vi.mocked(sync.syncAccount).mockResolvedValueOnce({ upserted: 2 });
const controller = new TBankController(accounts, portfolio, operations, sync);
const response = await controller.syncOperations('acc-1', {
from: '2026-06-01T00:00:00.000Z',
to: '2026-06-17T00:00:00.000Z',
});
expect(sync.syncAccount).toHaveBeenCalledWith('acc-1', {
from: '2026-06-01T00:00:00.000Z',
to: '2026-06-17T00:00:00.000Z',
});
expect(response.data).toEqual({ upserted: 2 });
});
});

View File

@ -1,37 +1,46 @@
import { Controller, Get, Param, Query } from '@nestjs/common'; import { Controller, Get, Param, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ApiResponse } from '../../common/dto/api-response.dto';
import { Roles } from '../auth/decorators/roles.decorator';
import { import {
BrokerAccountsEnvelopeDto, BrokerAccountsEnvelopeDto,
BrokerOperationSyncEnvelopeDto,
BrokerOperationsEnvelopeDto, BrokerOperationsEnvelopeDto,
BrokerPortfolioEnvelopeDto, BrokerPortfolioEnvelopeDto,
} from './dto/broker-envelope.dto'; } from './dto/broker-envelope.dto';
import { BrokerOperationQueryDto } from './dto/broker-operation-query.dto'; import { BrokerOperationQueryDto } from './dto/broker-operation-query.dto';
import { BrokerOperationSyncQueryDto } from './dto/broker-operation-sync-query.dto';
import { BrokerAccountsService } from './services/broker-accounts.service'; import { BrokerAccountsService } from './services/broker-accounts.service';
import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
import { BrokerOperationsService } from './services/broker-operations.service'; import { BrokerOperationsService } from './services/broker-operations.service';
import { BrokerPortfolioService } from './services/broker-portfolio.service'; import { BrokerPortfolioService } from './services/broker-portfolio.service';
@ApiTags('Broker') @ApiTags('Broker')
@ApiBearerAuth() @ApiBearerAuth()
@Roles('admin')
@Controller('broker') @Controller('broker')
export class TBankController { export class TBankController {
constructor( constructor(
private readonly brokerAccountsService: BrokerAccountsService, private readonly brokerAccountsService: BrokerAccountsService,
private readonly brokerPortfolioService: BrokerPortfolioService, private readonly brokerPortfolioService: BrokerPortfolioService,
private readonly brokerOperationsService: BrokerOperationsService, private readonly brokerOperationsService: BrokerOperationsService,
private readonly brokerOperationSyncService: BrokerOperationSyncService,
) {} ) {}
@Get('accounts') @Get('accounts')
@ApiOperation({ summary: 'Get open T-Bank brokerage and IIS accounts' }) @ApiOperation({ summary: 'Get open T-Bank brokerage and IIS accounts' })
@ApiOkResponse({ type: BrokerAccountsEnvelopeDto }) @ApiOkResponse({ type: BrokerAccountsEnvelopeDto })
async getAccounts() { async getAccounts() {
return this.brokerAccountsService.findAll(); const result = await this.brokerAccountsService.findAll();
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
} }
@Get('accounts/:accountId/portfolio') @Get('accounts/:accountId/portfolio')
@ApiOperation({ summary: 'Get T-Bank broker account portfolio with cash and positions' }) @ApiOperation({ summary: 'Get T-Bank broker account portfolio with cash and positions' })
@ApiOkResponse({ type: BrokerPortfolioEnvelopeDto }) @ApiOkResponse({ type: BrokerPortfolioEnvelopeDto })
async getPortfolio(@Param('accountId') accountId: string) { async getPortfolio(@Param('accountId') accountId: string) {
return this.brokerPortfolioService.getPortfolio(accountId); const result = await this.brokerPortfolioService.getPortfolio(accountId);
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
} }
@Get('accounts/:accountId/operations') @Get('accounts/:accountId/operations')
@ -41,6 +50,18 @@ export class TBankController {
@Param('accountId') accountId: string, @Param('accountId') accountId: string,
@Query() query: BrokerOperationQueryDto, @Query() query: BrokerOperationQueryDto,
) { ) {
return this.brokerOperationsService.getOperations(accountId, query); const result = await this.brokerOperationsService.getOperations(accountId, query);
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
}
@Post('accounts/:accountId/operations/sync')
@ApiOperation({ summary: 'Synchronize T-Bank broker account operations into local history' })
@ApiOkResponse({ type: BrokerOperationSyncEnvelopeDto })
async syncOperations(
@Param('accountId') accountId: string,
@Query() query: BrokerOperationSyncQueryDto,
) {
const result = await this.brokerOperationSyncService.syncAccount(accountId, query);
return new ApiResponse(result);
} }
} }

View File

@ -149,5 +149,7 @@ Read-only интеграция с T-Bank Invest для брокерских сч
- `BrokerPortfolioService` объединяет портфель, позиции, cash и метаданные инструментов - `BrokerPortfolioService` объединяет портфель, позиции, cash и метаданные инструментов
- `BrokerOperationsService` отдаёт cursor-paginated историю операций - `BrokerOperationsService` отдаёт cursor-paginated историю операций
- `BrokerOperationSyncService` сохраняет историю операций в отдельные Prisma-таблицы - `BrokerOperationSyncService` сохраняет историю операций в отдельные Prisma-таблицы
- Broker endpoints требуют роль `admin`, потому что текущая версия использует один server-side
`T_BANK_TOKEN`
- Direct-read endpoints используют `CacheService` с T-Bank TTL и не записывают данные в ручной - Direct-read endpoints используют `CacheService` с T-Bank TTL и не записывают данные в ручной
`PortfolioModule` `PortfolioModule`

View File

@ -29,13 +29,14 @@ x-app-name: ksv741.moex-vibe
## Backend endpoints ## Backend endpoints
Все endpoints защищены JWT и возвращают стандартную оболочку `{ data, meta }`. Все endpoints защищены JWT, требуют роль `admin` и возвращают стандартную оболочку `{ data, meta }`.
| Endpoint | Описание | | Endpoint | Описание |
|---|---| |---|---|
| `GET /api/v1/broker/accounts` | Открытые брокерские счета и ИИС | | `GET /api/v1/broker/accounts` | Открытые брокерские счета и ИИС |
| `GET /api/v1/broker/accounts/:accountId/portfolio` | Итоги портфеля, позиции, деньги и заблокированные деньги | | `GET /api/v1/broker/accounts/:accountId/portfolio` | Итоги портфеля, позиции, деньги и заблокированные деньги |
| `GET /api/v1/broker/accounts/:accountId/operations` | История операций с cursor pagination | | `GET /api/v1/broker/accounts/:accountId/operations` | История операций с cursor pagination |
| `POST /api/v1/broker/accounts/:accountId/operations/sync` | Синхронизация истории операций в локальные Prisma-таблицы |
## Методы T-Bank ## Методы T-Bank
@ -62,10 +63,14 @@ Direct-read endpoints используют короткий in-memory cache, ч
- `BrokerOperation` — нормализованная операция и сырой JSON payload; - `BrokerOperation` — нормализованная операция и сырой JSON payload;
- `BrokerOperationSyncState` — состояние последней синхронизации по брокерскому счёту. - `BrokerOperationSyncState` — состояние последней синхронизации по брокерскому счёту.
Синхронизация запускается явно через admin-only endpoint `POST .../operations/sync` с query
параметрами `from` и `to` в ISO-8601 формате.
Эти таблицы не связаны с ручными портфелями `Portfolio` и `Position`. Эти таблицы не связаны с ручными портфелями `Portfolio` и `Position`.
## Безопасность ## Безопасность
Текущая версия рассчитана на single-user/admin сценарий: используется один server-side Текущая версия рассчитана на single-user/admin сценарий: используется один server-side
`T_BANK_TOKEN`. Перед multi-user режимом нужно добавить зашифрованное хранение пользовательских `T_BANK_TOKEN`, поэтому broker endpoints доступны только пользователям с ролью `admin`. Перед
T-Bank токенов и привязку каждого брокерского счёта к владельцу. multi-user режимом нужно добавить зашифрованное хранение пользовательских T-Bank токенов и привязку
каждого брокерского счёта к владельцу.

View File

@ -17,6 +17,24 @@ describe('request', () => {
expect(result.data.status).toBe('ok'); 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 () => { it('includes Authorization header when token is set', async () => {
setAccessToken('test-token'); setAccessToken('test-token');
let capturedAuth: string | null = null; let capturedAuth: string | null = null;

View File

@ -40,14 +40,31 @@ async function refreshTokens(): Promise<boolean> {
credentials: 'include', credentials: 'include',
}); });
if (!res.ok) return false; if (!res.ok) return false;
const json: ApiEnvelope<{ data: AuthResponse; meta: ApiResponseMeta }> = await res.json(); const json = await res.json();
accessToken = json.data.data.accessToken; accessToken = normalizeEnvelope<AuthResponse>(json).data.accessToken;
return true; return true;
} catch { } catch {
return false; 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> { async function handleUnauthorized(): Promise<boolean> {
if (isRefreshing && refreshPromise) { if (isRefreshing && refreshPromise) {
return refreshPromise; return refreshPromise;
@ -114,8 +131,8 @@ export async function request<T>(
throw new Error(`Ошибка API: ${res.status} ${res.statusText}${text ? ` - ${text}` : ''}`); throw new Error(`Ошибка API: ${res.status} ${res.statusText}${text ? ` - ${text}` : ''}`);
} }
const json: ApiEnvelope<{ data: T; meta: ApiResponseMeta }> = await res.json(); const json = await res.json();
return json.data; return normalizeEnvelope<T>(json);
} }
export function getHealth(): Promise<{ data: HealthResponse; meta: ApiResponseMeta }> { export function getHealth(): Promise<{ data: HealthResponse; meta: ApiResponseMeta }> {

View File

@ -434,6 +434,23 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
'/api/v1/broker/accounts/{accountId}/operations/sync': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Synchronize T-Bank broker account operations into local history */
post: operations['TBankController_syncOperations'];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
} }
export type webhooks = Record<string, never>; export type webhooks = Record<string, never>;
export interface components { export interface components {
@ -843,6 +860,14 @@ export interface components {
data: components['schemas']['BrokerOperationsPageResponseDto']; data: components['schemas']['BrokerOperationsPageResponseDto'];
meta: components['schemas']['BrokerResponseMetaDto']; meta: components['schemas']['BrokerResponseMetaDto'];
}; };
BrokerOperationSyncResponseDto: {
/** @example 42 */
upserted: number;
};
BrokerOperationSyncEnvelopeDto: {
data: components['schemas']['BrokerOperationSyncResponseDto'];
meta: components['schemas']['BrokerResponseMetaDto'];
};
}; };
responses: never; responses: never;
parameters: never; parameters: never;
@ -1523,4 +1548,28 @@ export interface operations {
}; };
}; };
}; };
TBankController_syncOperations: {
parameters: {
query: {
from: string;
to: string;
};
header?: never;
path: {
accountId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['BrokerOperationSyncEnvelopeDto'];
};
};
};
};
} }