feat: expose tbank broker accounts

This commit is contained in:
Sergey Krylov 2026-06-16 22:34:12 +03:00
parent 4ef2f8d05e
commit ddeab01b9e
8 changed files with 174 additions and 4 deletions

View File

@ -10,6 +10,7 @@ import { CandlesModule } from './modules/candles/candles.module';
import { PortfolioModule } from './modules/portfolio/portfolio.module';
import { PrismaModule } from './modules/prisma/prisma.module';
import { AuthModule } from './modules/auth/auth.module';
import { TBankModule } from './modules/tbank/tbank.module';
import configuration from './config/configuration';
@Module({
@ -25,6 +26,7 @@ import configuration from './config/configuration';
BondsModule,
CandlesModule,
PortfolioModule,
TBankModule,
],
})
export class AppModule {}

View File

@ -0,0 +1,21 @@
import { ApiProperty } from '@nestjs/swagger';
export class BrokerAccountResponseDto {
@ApiProperty()
id!: string;
@ApiProperty({ enum: ['brokerage', 'iis'] })
type!: 'brokerage' | 'iis';
@ApiProperty()
name!: string;
@ApiProperty()
status!: string;
@ApiProperty({ nullable: true })
openedAt!: string | null;
@ApiProperty({ nullable: true })
accessLevel!: string | null;
}

View File

@ -0,0 +1,18 @@
import { ApiProperty } from '@nestjs/swagger';
import { BrokerAccountResponseDto } from './broker-account-response.dto';
export class BrokerResponseMetaDto {
@ApiProperty({ nullable: true })
cachedAt!: string | null;
@ApiProperty()
fromCache!: boolean;
}
export class BrokerAccountsEnvelopeDto {
@ApiProperty({ type: [BrokerAccountResponseDto] })
data!: BrokerAccountResponseDto[];
@ApiProperty({ type: BrokerResponseMetaDto })
meta!: BrokerResponseMetaDto;
}

View File

@ -0,0 +1,49 @@
import { BrokerAccountsService } from './broker-accounts.service';
import { TBankClientService } from './tbank-client.service';
import { CacheService } from '../../cache/cache.service';
describe('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('returns only open brokerage and IIS accounts from cache wrapper', async () => {
vi.mocked(cache.getOrFetch).mockImplementation(
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: '2026-06-16T02:30:00.000Z',
}),
);
vi.mocked(client.getServiceClient).mockReturnValue({ getAccounts: vi.fn() } as any);
vi.mocked(client.callUnary).mockResolvedValue({
accounts: [
{ id: '1', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Broker', status: 'ACCOUNT_STATUS_OPEN' },
{ id: '2', type: 'ACCOUNT_TYPE_TINKOFF_IIS', name: 'IIS', status: 'ACCOUNT_STATUS_OPEN' },
{ id: '3', type: 'ACCOUNT_TYPE_INVEST_BOX', name: 'Box', status: 'ACCOUNT_STATUS_OPEN' },
{ id: '4', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Closed', status: 'ACCOUNT_STATUS_CLOSED' },
],
});
const service = new BrokerAccountsService(client, cache);
const result = await service.findAll();
expect(result.data).toHaveLength(2);
expect(result.data.map((account) => account.type)).toEqual(['brokerage', 'iis']);
expect(result.meta.fromCache).toBe(false);
expect(cache.getOrFetch).toHaveBeenCalledWith(
'tbank:accounts',
['open-brokerage-iis'],
expect.any(Function),
'tbankAccountsTtl',
);
});
});

View File

@ -0,0 +1,50 @@
import { Injectable } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service';
import { isSupportedBrokerAccount, mapAccount } from '../mappers/account.mapper';
import { TBANK_CACHE_KEYS } from '../tbank.config';
import type { BrokerAccount } from '../types/broker.types';
import type { TBankAccountsResponse } from '../types/tbank-proto.types';
import { TBankClientService } from './tbank-client.service';
@Injectable()
export class BrokerAccountsService {
constructor(
private readonly tbankClient: TBankClientService,
private readonly cacheService: CacheService,
) {}
async findAll(): Promise<{
data: BrokerAccount[];
meta: { fromCache: boolean; cachedAt: string | null };
}> {
const result = await this.cacheService.getOrFetch(
TBANK_CACHE_KEYS.accounts,
['open-brokerage-iis'],
() => this.fetchAccounts(),
'tbankAccountsTtl',
);
return {
data: result.data,
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
};
}
async findById(accountId: string): Promise<BrokerAccount | null> {
const accounts = await this.findAll();
return accounts.data.find((account) => account.id === accountId) ?? null;
}
private async fetchAccounts(): Promise<BrokerAccount[]> {
const usersClient = this.tbankClient.getServiceClient('UsersService') as any;
const response = await this.tbankClient.callUnary<
Record<string, string>,
TBankAccountsResponse
>('UsersService/GetAccounts', usersClient.getAccounts.bind(usersClient), {
status: 'ACCOUNT_STATUS_OPEN',
});
return (response.accounts ?? []).filter(isSupportedBrokerAccount).map(mapAccount);
}
}

View File

@ -1,9 +1,15 @@
import { ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Metadata, status } from '@grpc/grpc-js';
import { ClientUnaryCall, Metadata, ServiceError, status } from '@grpc/grpc-js';
import { TBankClientService } from './tbank-client.service';
describe('TBankClientService', () => {
const unaryCall = {
cancel: vi.fn(),
getPeer: vi.fn(),
getAuthContext: vi.fn(),
} as unknown as ClientUnaryCall;
const config = {
get: vi.fn((key: string, fallback?: unknown) => {
const values: Record<string, unknown> = {
@ -42,6 +48,7 @@ describe('TBankClientService', () => {
'UsersService/GetAccounts',
(_request, _metadata, _options, callback) => {
callback(null, {});
return unaryCall;
},
{},
),
@ -52,8 +59,9 @@ describe('TBankClientService', () => {
const service = new TBankClientService(config);
const error = Object.assign(new Error('Too many requests'), {
code: status.RESOURCE_EXHAUSTED,
details: 'Too many requests',
metadata: new Metadata(),
});
}) as ServiceError;
error.metadata.set('x-tracking-id', 'tracking-1');
await expect(
@ -61,6 +69,7 @@ describe('TBankClientService', () => {
'OperationsService/GetPortfolio',
(_request, _metadata, _options, callback) => {
callback(error, null);
return unaryCall;
},
{},
),

View File

@ -0,0 +1,18 @@
import { Controller, Get } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BrokerAccountsEnvelopeDto } from './dto/broker-envelope.dto';
import { BrokerAccountsService } from './services/broker-accounts.service';
@ApiTags('Broker')
@ApiBearerAuth()
@Controller('broker')
export class TBankController {
constructor(private readonly brokerAccountsService: BrokerAccountsService) {}
@Get('accounts')
@ApiOperation({ summary: 'Get open T-Bank brokerage and IIS accounts' })
@ApiOkResponse({ type: BrokerAccountsEnvelopeDto })
async getAccounts() {
return this.brokerAccountsService.findAll();
}
}

View File

@ -1,8 +1,11 @@
import { Module } from '@nestjs/common';
import { TBankController } from './tbank.controller';
import { BrokerAccountsService } from './services/broker-accounts.service';
import { TBankClientService } from './services/tbank-client.service';
@Module({
providers: [TBankClientService],
exports: [TBankClientService],
controllers: [TBankController],
providers: [TBankClientService, BrokerAccountsService],
exports: [TBankClientService, BrokerAccountsService],
})
export class TBankModule {}