diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 9329973..06db044 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -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 {} diff --git a/apps/backend/src/modules/tbank/dto/broker-account-response.dto.ts b/apps/backend/src/modules/tbank/dto/broker-account-response.dto.ts new file mode 100644 index 0000000..f5ce7dc --- /dev/null +++ b/apps/backend/src/modules/tbank/dto/broker-account-response.dto.ts @@ -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; +} diff --git a/apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts b/apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts new file mode 100644 index 0000000..113db63 --- /dev/null +++ b/apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts @@ -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; +} diff --git a/apps/backend/src/modules/tbank/services/broker-accounts.service.spec.ts b/apps/backend/src/modules/tbank/services/broker-accounts.service.spec.ts new file mode 100644 index 0000000..e37584e --- /dev/null +++ b/apps/backend/src/modules/tbank/services/broker-accounts.service.spec.ts @@ -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) => ({ + 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', + ); + }); +}); diff --git a/apps/backend/src/modules/tbank/services/broker-accounts.service.ts b/apps/backend/src/modules/tbank/services/broker-accounts.service.ts new file mode 100644 index 0000000..5d51948 --- /dev/null +++ b/apps/backend/src/modules/tbank/services/broker-accounts.service.ts @@ -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 { + const accounts = await this.findAll(); + + return accounts.data.find((account) => account.id === accountId) ?? null; + } + + private async fetchAccounts(): Promise { + const usersClient = this.tbankClient.getServiceClient('UsersService') as any; + const response = await this.tbankClient.callUnary< + Record, + TBankAccountsResponse + >('UsersService/GetAccounts', usersClient.getAccounts.bind(usersClient), { + status: 'ACCOUNT_STATUS_OPEN', + }); + + return (response.accounts ?? []).filter(isSupportedBrokerAccount).map(mapAccount); + } +} diff --git a/apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts b/apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts index 83b19f3..e74067b 100644 --- a/apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts +++ b/apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts @@ -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 = { @@ -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; }, {}, ), diff --git a/apps/backend/src/modules/tbank/tbank.controller.ts b/apps/backend/src/modules/tbank/tbank.controller.ts new file mode 100644 index 0000000..f611f63 --- /dev/null +++ b/apps/backend/src/modules/tbank/tbank.controller.ts @@ -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(); + } +} diff --git a/apps/backend/src/modules/tbank/tbank.module.ts b/apps/backend/src/modules/tbank/tbank.module.ts index d43b382..abab066 100644 --- a/apps/backend/src/modules/tbank/tbank.module.ts +++ b/apps/backend/src/modules/tbank/tbank.module.ts @@ -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 {}