codex/tbank-broker-portfolios-design #15
@ -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 {}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
18
apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts
Normal file
18
apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts
Normal 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;
|
||||
}
|
||||
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
},
|
||||
{},
|
||||
),
|
||||
|
||||
18
apps/backend/src/modules/tbank/tbank.controller.ts
Normal file
18
apps/backend/src/modules/tbank/tbank.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user