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 19223f8..d0d9d39 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 { BrokerOperationsPageResponseDto } from './broker-operation-response.dto'; import { BrokerPortfolioResponseDto } from './broker-portfolio-response.dto'; export class BrokerResponseMetaDto { @@ -25,3 +26,11 @@ export class BrokerPortfolioEnvelopeDto { @ApiProperty({ type: BrokerResponseMetaDto }) meta!: BrokerResponseMetaDto; } + +export class BrokerOperationsEnvelopeDto { + @ApiProperty({ type: BrokerOperationsPageResponseDto }) + data!: BrokerOperationsPageResponseDto; + + @ApiProperty({ type: BrokerResponseMetaDto }) + meta!: BrokerResponseMetaDto; +} diff --git a/apps/backend/src/modules/tbank/dto/broker-operation-query.dto.ts b/apps/backend/src/modules/tbank/dto/broker-operation-query.dto.ts new file mode 100644 index 0000000..a56cc8f --- /dev/null +++ b/apps/backend/src/modules/tbank/dto/broker-operation-query.dto.ts @@ -0,0 +1,43 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsDateString, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class BrokerOperationQueryDto { + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + from?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + to?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + cursor?: string; + + @ApiPropertyOptional({ minimum: 1, maximum: 1000, default: 100 }) + @IsOptional() + @Transform(({ value }) => (value === undefined ? undefined : Number(value))) + @IsInt() + @Min(1) + @Max(1000) + limit?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + instrumentId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + operationTypes?: string; + + @ApiPropertyOptional({ default: 'OPERATION_STATE_EXECUTED' }) + @IsOptional() + @IsString() + state?: string; +} diff --git a/apps/backend/src/modules/tbank/dto/broker-operation-response.dto.ts b/apps/backend/src/modules/tbank/dto/broker-operation-response.dto.ts new file mode 100644 index 0000000..d6b365d --- /dev/null +++ b/apps/backend/src/modules/tbank/dto/broker-operation-response.dto.ts @@ -0,0 +1,86 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { BrokerMoneyDto } from './broker-money.dto'; + +const operationCategories = ['trade', 'income', 'tax', 'fee', 'transfer', 'other'] as const; + +export class BrokerOperationResponseDto { + @ApiProperty({ nullable: true }) + cursor!: string | null; + + @ApiProperty() + accountId!: string; + + @ApiProperty({ nullable: true }) + id!: string | null; + + @ApiProperty({ nullable: true }) + parentOperationId!: string | null; + + @ApiProperty({ nullable: true }) + date!: string | null; + + @ApiProperty() + type!: string; + + @ApiProperty({ enum: operationCategories }) + category!: (typeof operationCategories)[number]; + + @ApiProperty({ nullable: true }) + description!: string | null; + + @ApiProperty({ nullable: true }) + state!: string | null; + + @ApiProperty({ nullable: true }) + instrumentUid!: string | null; + + @ApiProperty({ nullable: true }) + figi!: string | null; + + @ApiProperty({ nullable: true }) + ticker!: string | null; + + @ApiProperty({ nullable: true }) + classCode!: string | null; + + @ApiProperty({ nullable: true }) + instrumentType!: string | null; + + @ApiProperty({ type: BrokerMoneyDto, nullable: true }) + payment!: BrokerMoneyDto | null; + + @ApiProperty({ type: BrokerMoneyDto, nullable: true }) + price!: BrokerMoneyDto | null; + + @ApiProperty({ type: BrokerMoneyDto, nullable: true }) + commission!: BrokerMoneyDto | null; + + @ApiProperty({ type: BrokerMoneyDto, nullable: true }) + yield!: BrokerMoneyDto | null; + + @ApiProperty({ type: BrokerMoneyDto, nullable: true }) + accruedInt!: BrokerMoneyDto | null; + + @ApiProperty({ nullable: true }) + quantity!: number | null; + + @ApiProperty({ nullable: true }) + quantityDone!: number | null; +} + +export class BrokerOperationsPageResponseDto { + @ApiProperty() + accountId!: string; + + @ApiProperty({ type: [BrokerOperationResponseDto] }) + items!: BrokerOperationResponseDto[]; + + @ApiProperty({ nullable: true }) + nextCursor!: string | null; + + @ApiProperty() + hasNext!: boolean; + + @ApiProperty() + asOf!: string; +} diff --git a/apps/backend/src/modules/tbank/services/broker-operations.service.spec.ts b/apps/backend/src/modules/tbank/services/broker-operations.service.spec.ts new file mode 100644 index 0000000..384e810 --- /dev/null +++ b/apps/backend/src/modules/tbank/services/broker-operations.service.spec.ts @@ -0,0 +1,64 @@ +import { NotFoundException } from '@nestjs/common'; +import { CacheService } from '../../cache/cache.service'; +import { BrokerAccountsService } from './broker-accounts.service'; +import { BrokerOperationsService } from './broker-operations.service'; +import { TBankClientService } from './tbank-client.service'; + +describe('BrokerOperationsService', () => { + const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService; + const client = { getServiceClient: vi.fn(), callUnary: vi.fn() } as unknown as TBankClientService; + const cache = { getOrFetch: vi.fn() } as unknown as CacheService; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('throws 404 for excluded or missing account', async () => { + vi.mocked(accounts.findById).mockResolvedValue(null); + const service = new BrokerOperationsService(accounts, client, cache); + + await expect(service.getOperations('missing', {})).rejects.toThrow(NotFoundException); + }); + + it('builds cursor request and maps operation page', async () => { + vi.mocked(accounts.findById).mockResolvedValue({ + id: 'acc-1', + type: 'brokerage', + name: 'Broker', + status: 'ACCOUNT_STATUS_OPEN', + openedAt: null, + accessLevel: null, + }); + vi.mocked(cache.getOrFetch).mockImplementation( + async (_prefix: string, _parts: string[], fetchFn: () => Promise) => ({ + data: await fetchFn(), + fromCache: false, + cachedAt: null, + }), + ); + vi.mocked(client.getServiceClient).mockReturnValue({ getOperationsByCursor: vi.fn() } as any); + vi.mocked(client.callUnary).mockResolvedValue({ + hasNext: false, + items: [{ cursor: 'c1', brokerAccountId: 'acc-1', type: 'OPERATION_TYPE_BUY' }], + }); + + const service = new BrokerOperationsService(accounts, client, cache); + const result = await service.getOperations('acc-1', { + from: '2026-01-01T00:00:00.000Z', + to: '2026-06-16T00:00:00.000Z', + limit: 1000, + state: 'OPERATION_STATE_EXECUTED', + }); + + expect(result.data.items[0].category).toBe('trade'); + expect(client.callUnary).toHaveBeenCalledWith( + 'OperationsService/GetOperationsByCursor', + expect.any(Function), + expect.objectContaining({ + accountId: 'acc-1', + limit: 1000, + state: 'OPERATION_STATE_EXECUTED', + }), + ); + }); +}); diff --git a/apps/backend/src/modules/tbank/services/broker-operations.service.ts b/apps/backend/src/modules/tbank/services/broker-operations.service.ts new file mode 100644 index 0000000..eee1792 --- /dev/null +++ b/apps/backend/src/modules/tbank/services/broker-operations.service.ts @@ -0,0 +1,88 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { CacheService } from '../../cache/cache.service'; +import type { BrokerOperationQueryDto } from '../dto/broker-operation-query.dto'; +import { mapOperationsPage } from '../mappers/operation.mapper'; +import { TBANK_CACHE_KEYS } from '../tbank.config'; +import type { BrokerOperationsPage } from '../types/broker.types'; +import type { TBankOperationsByCursorResponse } from '../types/tbank-proto.types'; +import { BrokerAccountsService } from './broker-accounts.service'; +import { TBankClientService } from './tbank-client.service'; + +@Injectable() +export class BrokerOperationsService { + constructor( + private readonly accountsService: BrokerAccountsService, + private readonly tbankClient: TBankClientService, + private readonly cacheService: CacheService, + ) {} + + async getOperations( + accountId: string, + query: BrokerOperationQueryDto, + ): Promise<{ + data: BrokerOperationsPage; + meta: { fromCache: boolean; cachedAt: string | null }; + }> { + const account = await this.accountsService.findById(accountId); + if (!account) throw new NotFoundException('Broker account not found'); + + const request = this.buildRequest(accountId, query); + const result = await this.cacheService.getOrFetch( + TBANK_CACHE_KEYS.operations, + [accountId, JSON.stringify(request)], + () => this.fetchOperations(accountId, request), + 'tbankOperationsTtl', + ); + + return { + data: result.data, + meta: { fromCache: result.fromCache, cachedAt: result.cachedAt }, + }; + } + + private buildRequest(accountId: string, query: BrokerOperationQueryDto): Record { + const now = new Date(); + const startOfYear = new Date(Date.UTC(now.getUTCFullYear(), 0, 1)); + const operationTypes = query.operationTypes + ? query.operationTypes + .split(',') + .map((value) => value.trim()) + .filter(Boolean) + : undefined; + + return { + accountId, + instrumentId: query.instrumentId, + from: { + seconds: Math.floor(new Date(query.from ?? startOfYear.toISOString()).getTime() / 1000), + }, + to: { + seconds: Math.floor(new Date(query.to ?? now.toISOString()).getTime() / 1000), + }, + cursor: query.cursor, + limit: query.limit ?? 100, + operationTypes, + state: query.state ?? 'OPERATION_STATE_EXECUTED', + withoutCommissions: false, + withoutTrades: false, + withoutOvernights: false, + }; + } + + private async fetchOperations( + accountId: string, + request: Record, + ): Promise { + const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any; + const response = await this.tbankClient.callUnary< + Record, + TBankOperationsByCursorResponse + >( + 'OperationsService/GetOperationsByCursor', + operationsClient.getOperationsByCursor.bind(operationsClient), + request, + ); + + return mapOperationsPage(accountId, response); + } +} diff --git a/apps/backend/src/modules/tbank/tbank.controller.ts b/apps/backend/src/modules/tbank/tbank.controller.ts index d3f8e48..142c082 100644 --- a/apps/backend/src/modules/tbank/tbank.controller.ts +++ b/apps/backend/src/modules/tbank/tbank.controller.ts @@ -1,7 +1,13 @@ -import { Controller, Get, Param } from '@nestjs/common'; +import { Controller, Get, Param, Query } from '@nestjs/common'; import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { BrokerAccountsEnvelopeDto, BrokerPortfolioEnvelopeDto } from './dto/broker-envelope.dto'; +import { + BrokerAccountsEnvelopeDto, + BrokerOperationsEnvelopeDto, + BrokerPortfolioEnvelopeDto, +} from './dto/broker-envelope.dto'; +import { BrokerOperationQueryDto } from './dto/broker-operation-query.dto'; import { BrokerAccountsService } from './services/broker-accounts.service'; +import { BrokerOperationsService } from './services/broker-operations.service'; import { BrokerPortfolioService } from './services/broker-portfolio.service'; @ApiTags('Broker') @@ -11,6 +17,7 @@ export class TBankController { constructor( private readonly brokerAccountsService: BrokerAccountsService, private readonly brokerPortfolioService: BrokerPortfolioService, + private readonly brokerOperationsService: BrokerOperationsService, ) {} @Get('accounts') @@ -26,4 +33,14 @@ export class TBankController { async getPortfolio(@Param('accountId') accountId: string) { return this.brokerPortfolioService.getPortfolio(accountId); } + + @Get('accounts/:accountId/operations') + @ApiOperation({ summary: 'Get paginated T-Bank broker account operations' }) + @ApiOkResponse({ type: BrokerOperationsEnvelopeDto }) + async getOperations( + @Param('accountId') accountId: string, + @Query() query: BrokerOperationQueryDto, + ) { + return this.brokerOperationsService.getOperations(accountId, query); + } } diff --git a/apps/backend/src/modules/tbank/tbank.module.ts b/apps/backend/src/modules/tbank/tbank.module.ts index 9abf288..81d3663 100644 --- a/apps/backend/src/modules/tbank/tbank.module.ts +++ b/apps/backend/src/modules/tbank/tbank.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { TBankController } from './tbank.controller'; import { BrokerAccountsService } from './services/broker-accounts.service'; import { BrokerInstrumentsService } from './services/broker-instruments.service'; +import { BrokerOperationsService } from './services/broker-operations.service'; import { BrokerPortfolioService } from './services/broker-portfolio.service'; import { TBankClientService } from './services/tbank-client.service'; @@ -12,12 +13,14 @@ import { TBankClientService } from './services/tbank-client.service'; BrokerAccountsService, BrokerInstrumentsService, BrokerPortfolioService, + BrokerOperationsService, ], exports: [ TBankClientService, BrokerAccountsService, BrokerInstrumentsService, BrokerPortfolioService, + BrokerOperationsService, ], }) export class TBankModule {}