From ea916dfec96fcfa52dada97a0ab9afb50dc40385 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 17 Jun 2026 06:57:49 +0300 Subject: [PATCH] fix: restrict and expose tbank broker sync --- .../modules/tbank/dto/broker-envelope.dto.ts | 9 +++ .../dto/broker-operation-sync-query.dto.ts | 17 ++++++ .../modules/tbank/tbank.controller.spec.ts | 61 +++++++++++++++++++ .../src/modules/tbank/tbank.controller.ts | 29 +++++++-- apps/docs/docs/backend/modules.md | 2 + apps/docs/docs/backend/tbank-invest.md | 11 +++- apps/frontend/src/api/client.test.ts | 18 ++++++ apps/frontend/src/api/client.ts | 25 ++++++-- apps/frontend/src/api/types.ts | 49 +++++++++++++++ 9 files changed, 210 insertions(+), 11 deletions(-) create mode 100644 apps/backend/src/modules/tbank/dto/broker-operation-sync-query.dto.ts create mode 100644 apps/backend/src/modules/tbank/tbank.controller.spec.ts diff --git a/apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts b/apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts index d0d9d39..d98eb0a 100644 --- a/apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts +++ b/apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { BrokerAccountResponseDto } from './broker-account-response.dto'; +import { BrokerOperationSyncResponseDto } from './broker-operation-sync-query.dto'; import { BrokerOperationsPageResponseDto } from './broker-operation-response.dto'; import { BrokerPortfolioResponseDto } from './broker-portfolio-response.dto'; @@ -34,3 +35,11 @@ export class BrokerOperationsEnvelopeDto { @ApiProperty({ type: BrokerResponseMetaDto }) meta!: BrokerResponseMetaDto; } + +export class BrokerOperationSyncEnvelopeDto { + @ApiProperty({ type: BrokerOperationSyncResponseDto }) + data!: BrokerOperationSyncResponseDto; + + @ApiProperty({ type: BrokerResponseMetaDto }) + meta!: BrokerResponseMetaDto; +} diff --git a/apps/backend/src/modules/tbank/dto/broker-operation-sync-query.dto.ts b/apps/backend/src/modules/tbank/dto/broker-operation-sync-query.dto.ts new file mode 100644 index 0000000..a99b85d --- /dev/null +++ b/apps/backend/src/modules/tbank/dto/broker-operation-sync-query.dto.ts @@ -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; +} diff --git a/apps/backend/src/modules/tbank/tbank.controller.spec.ts b/apps/backend/src/modules/tbank/tbank.controller.spec.ts new file mode 100644 index 0000000..9e792d1 --- /dev/null +++ b/apps/backend/src/modules/tbank/tbank.controller.spec.ts @@ -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 }); + }); +}); diff --git a/apps/backend/src/modules/tbank/tbank.controller.ts b/apps/backend/src/modules/tbank/tbank.controller.ts index 142c082..b676120 100644 --- a/apps/backend/src/modules/tbank/tbank.controller.ts +++ b/apps/backend/src/modules/tbank/tbank.controller.ts @@ -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 { ApiResponse } from '../../common/dto/api-response.dto'; +import { Roles } from '../auth/decorators/roles.decorator'; import { BrokerAccountsEnvelopeDto, + BrokerOperationSyncEnvelopeDto, BrokerOperationsEnvelopeDto, BrokerPortfolioEnvelopeDto, } from './dto/broker-envelope.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 { BrokerOperationSyncService } from './services/broker-operation-sync.service'; import { BrokerOperationsService } from './services/broker-operations.service'; import { BrokerPortfolioService } from './services/broker-portfolio.service'; @ApiTags('Broker') @ApiBearerAuth() +@Roles('admin') @Controller('broker') export class TBankController { constructor( private readonly brokerAccountsService: BrokerAccountsService, private readonly brokerPortfolioService: BrokerPortfolioService, private readonly brokerOperationsService: BrokerOperationsService, + private readonly brokerOperationSyncService: BrokerOperationSyncService, ) {} @Get('accounts') @ApiOperation({ summary: 'Get open T-Bank brokerage and IIS accounts' }) @ApiOkResponse({ type: BrokerAccountsEnvelopeDto }) 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') @ApiOperation({ summary: 'Get T-Bank broker account portfolio with cash and positions' }) @ApiOkResponse({ type: BrokerPortfolioEnvelopeDto }) 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') @@ -41,6 +50,18 @@ export class TBankController { @Param('accountId') accountId: string, @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); } } diff --git a/apps/docs/docs/backend/modules.md b/apps/docs/docs/backend/modules.md index 457153c..f4cb9a4 100644 --- a/apps/docs/docs/backend/modules.md +++ b/apps/docs/docs/backend/modules.md @@ -149,5 +149,7 @@ Read-only интеграция с T-Bank Invest для брокерских сч - `BrokerPortfolioService` объединяет портфель, позиции, cash и метаданные инструментов - `BrokerOperationsService` отдаёт cursor-paginated историю операций - `BrokerOperationSyncService` сохраняет историю операций в отдельные Prisma-таблицы +- Broker endpoints требуют роль `admin`, потому что текущая версия использует один server-side + `T_BANK_TOKEN` - Direct-read endpoints используют `CacheService` с T-Bank TTL и не записывают данные в ручной `PortfolioModule` diff --git a/apps/docs/docs/backend/tbank-invest.md b/apps/docs/docs/backend/tbank-invest.md index a332568..b2e2158 100644 --- a/apps/docs/docs/backend/tbank-invest.md +++ b/apps/docs/docs/backend/tbank-invest.md @@ -29,13 +29,14 @@ x-app-name: ksv741.moex-vibe ## Backend endpoints -Все endpoints защищены JWT и возвращают стандартную оболочку `{ data, meta }`. +Все endpoints защищены JWT, требуют роль `admin` и возвращают стандартную оболочку `{ data, meta }`. | Endpoint | Описание | |---|---| | `GET /api/v1/broker/accounts` | Открытые брокерские счета и ИИС | | `GET /api/v1/broker/accounts/:accountId/portfolio` | Итоги портфеля, позиции, деньги и заблокированные деньги | | `GET /api/v1/broker/accounts/:accountId/operations` | История операций с cursor pagination | +| `POST /api/v1/broker/accounts/:accountId/operations/sync` | Синхронизация истории операций в локальные Prisma-таблицы | ## Методы T-Bank @@ -62,10 +63,14 @@ Direct-read endpoints используют короткий in-memory cache, ч - `BrokerOperation` — нормализованная операция и сырой JSON payload; - `BrokerOperationSyncState` — состояние последней синхронизации по брокерскому счёту. +Синхронизация запускается явно через admin-only endpoint `POST .../operations/sync` с query +параметрами `from` и `to` в ISO-8601 формате. + Эти таблицы не связаны с ручными портфелями `Portfolio` и `Position`. ## Безопасность Текущая версия рассчитана на single-user/admin сценарий: используется один server-side -`T_BANK_TOKEN`. Перед multi-user режимом нужно добавить зашифрованное хранение пользовательских -T-Bank токенов и привязку каждого брокерского счёта к владельцу. +`T_BANK_TOKEN`, поэтому broker endpoints доступны только пользователям с ролью `admin`. Перед +multi-user режимом нужно добавить зашифрованное хранение пользовательских T-Bank токенов и привязку +каждого брокерского счёта к владельцу. diff --git a/apps/frontend/src/api/client.test.ts b/apps/frontend/src/api/client.test.ts index 095f62f..cd4dd9d 100644 --- a/apps/frontend/src/api/client.test.ts +++ b/apps/frontend/src/api/client.test.ts @@ -17,6 +17,24 @@ describe('request', () => { 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; diff --git a/apps/frontend/src/api/client.ts b/apps/frontend/src/api/client.ts index 320a94d..c6f0873 100644 --- a/apps/frontend/src/api/client.ts +++ b/apps/frontend/src/api/client.ts @@ -40,14 +40,31 @@ async function refreshTokens(): Promise { credentials: 'include', }); if (!res.ok) return false; - const json: ApiEnvelope<{ data: AuthResponse; meta: ApiResponseMeta }> = await res.json(); - accessToken = json.data.data.accessToken; + const json = await res.json(); + accessToken = normalizeEnvelope(json).data.accessToken; return true; } catch { return false; } } +function normalizeEnvelope(json: unknown): { data: T; meta: ApiResponseMeta } { + const envelope = json as ApiEnvelope; + 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 { if (isRefreshing && refreshPromise) { return refreshPromise; @@ -114,8 +131,8 @@ export async function request( throw new Error(`Ошибка API: ${res.status} ${res.statusText}${text ? ` - ${text}` : ''}`); } - const json: ApiEnvelope<{ data: T; meta: ApiResponseMeta }> = await res.json(); - return json.data; + const json = await res.json(); + return normalizeEnvelope(json); } export function getHealth(): Promise<{ data: HealthResponse; meta: ApiResponseMeta }> { diff --git a/apps/frontend/src/api/types.ts b/apps/frontend/src/api/types.ts index 7e5dc8a..b7bbec6 100644 --- a/apps/frontend/src/api/types.ts +++ b/apps/frontend/src/api/types.ts @@ -434,6 +434,23 @@ export interface paths { patch?: 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; export interface components { @@ -843,6 +860,14 @@ export interface components { data: components['schemas']['BrokerOperationsPageResponseDto']; meta: components['schemas']['BrokerResponseMetaDto']; }; + BrokerOperationSyncResponseDto: { + /** @example 42 */ + upserted: number; + }; + BrokerOperationSyncEnvelopeDto: { + data: components['schemas']['BrokerOperationSyncResponseDto']; + meta: components['schemas']['BrokerResponseMetaDto']; + }; }; responses: 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']; + }; + }; + }; + }; }