feat: expose tbank broker operations
This commit is contained in:
parent
bd6b2589c0
commit
d01d2b9f7f
@ -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 { BrokerOperationsPageResponseDto } from './broker-operation-response.dto';
|
||||||
import { BrokerPortfolioResponseDto } from './broker-portfolio-response.dto';
|
import { BrokerPortfolioResponseDto } from './broker-portfolio-response.dto';
|
||||||
|
|
||||||
export class BrokerResponseMetaDto {
|
export class BrokerResponseMetaDto {
|
||||||
@ -25,3 +26,11 @@ export class BrokerPortfolioEnvelopeDto {
|
|||||||
@ApiProperty({ type: BrokerResponseMetaDto })
|
@ApiProperty({ type: BrokerResponseMetaDto })
|
||||||
meta!: BrokerResponseMetaDto;
|
meta!: BrokerResponseMetaDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class BrokerOperationsEnvelopeDto {
|
||||||
|
@ApiProperty({ type: BrokerOperationsPageResponseDto })
|
||||||
|
data!: BrokerOperationsPageResponseDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: BrokerResponseMetaDto })
|
||||||
|
meta!: BrokerResponseMetaDto;
|
||||||
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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<unknown>) => ({
|
||||||
|
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',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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<string, unknown> {
|
||||||
|
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<string, unknown>,
|
||||||
|
): Promise<BrokerOperationsPage> {
|
||||||
|
const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any;
|
||||||
|
const response = await this.tbankClient.callUnary<
|
||||||
|
Record<string, unknown>,
|
||||||
|
TBankOperationsByCursorResponse
|
||||||
|
>(
|
||||||
|
'OperationsService/GetOperationsByCursor',
|
||||||
|
operationsClient.getOperationsByCursor.bind(operationsClient),
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
|
||||||
|
return mapOperationsPage(accountId, response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 { 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 { BrokerAccountsService } from './services/broker-accounts.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')
|
||||||
@ -11,6 +17,7 @@ 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,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('accounts')
|
@Get('accounts')
|
||||||
@ -26,4 +33,14 @@ export class TBankController {
|
|||||||
async getPortfolio(@Param('accountId') accountId: string) {
|
async getPortfolio(@Param('accountId') accountId: string) {
|
||||||
return this.brokerPortfolioService.getPortfolio(accountId);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
|||||||
import { TBankController } from './tbank.controller';
|
import { TBankController } from './tbank.controller';
|
||||||
import { BrokerAccountsService } from './services/broker-accounts.service';
|
import { BrokerAccountsService } from './services/broker-accounts.service';
|
||||||
import { BrokerInstrumentsService } from './services/broker-instruments.service';
|
import { BrokerInstrumentsService } from './services/broker-instruments.service';
|
||||||
|
import { BrokerOperationsService } from './services/broker-operations.service';
|
||||||
import { BrokerPortfolioService } from './services/broker-portfolio.service';
|
import { BrokerPortfolioService } from './services/broker-portfolio.service';
|
||||||
import { TBankClientService } from './services/tbank-client.service';
|
import { TBankClientService } from './services/tbank-client.service';
|
||||||
|
|
||||||
@ -12,12 +13,14 @@ import { TBankClientService } from './services/tbank-client.service';
|
|||||||
BrokerAccountsService,
|
BrokerAccountsService,
|
||||||
BrokerInstrumentsService,
|
BrokerInstrumentsService,
|
||||||
BrokerPortfolioService,
|
BrokerPortfolioService,
|
||||||
|
BrokerOperationsService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
TBankClientService,
|
TBankClientService,
|
||||||
BrokerAccountsService,
|
BrokerAccountsService,
|
||||||
BrokerInstrumentsService,
|
BrokerInstrumentsService,
|
||||||
BrokerPortfolioService,
|
BrokerPortfolioService,
|
||||||
|
BrokerOperationsService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class TBankModule {}
|
export class TBankModule {}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user