From c0a4a9388074bdce20138dd5649805ed24a9e9fa Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Thu, 13 Aug 2026 20:57:01 +0300 Subject: [PATCH] feat: add brocker account list --- src/tbank/brokerage-account.dto.ts | 23 ++++ src/tbank/brokerage-accounts.controller.ts | 21 ++++ src/tbank/brokerage-accounts.service.spec.ts | 105 +++++++++++++++++++ src/tbank/brokerage-accounts.service.ts | 79 ++++++++++++++ src/tbank/tbank-connection.module.ts | 6 +- src/tbank/tbank.client.ts | 27 +++-- test/app.e2e-spec.ts | 19 ++++ 7 files changed, 272 insertions(+), 8 deletions(-) create mode 100644 src/tbank/brokerage-account.dto.ts create mode 100644 src/tbank/brokerage-accounts.controller.ts create mode 100644 src/tbank/brokerage-accounts.service.spec.ts create mode 100644 src/tbank/brokerage-accounts.service.ts diff --git a/src/tbank/brokerage-account.dto.ts b/src/tbank/brokerage-account.dto.ts new file mode 100644 index 0000000..cc85245 --- /dev/null +++ b/src/tbank/brokerage-account.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class BrokerageAccountDto { + @ApiProperty() + id: string; + + @ApiProperty() + name: string; + + @ApiProperty({ + enum: [ + 'BROKERAGE', + 'INDIVIDUAL_INVESTMENT', + 'INVEST_BOX', + 'MONEY_MARKET_FUND', + ], + }) + type: + 'BROKERAGE' | 'INDIVIDUAL_INVESTMENT' | 'INVEST_BOX' | 'MONEY_MARKET_FUND'; + + @ApiProperty({ type: String, nullable: true, format: 'date' }) + openedOn: string | null; +} diff --git a/src/tbank/brokerage-accounts.controller.ts b/src/tbank/brokerage-accounts.controller.ts new file mode 100644 index 0000000..75fc99e --- /dev/null +++ b/src/tbank/brokerage-accounts.controller.ts @@ -0,0 +1,21 @@ +import { Controller, Get, Req, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import type { Request } from 'express'; +import { AccessPayload } from '../auth/auth.types'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { BrokerageAccountDto } from './brokerage-account.dto'; +import { BrokerageAccountsService } from './brokerage-accounts.service'; + +@ApiTags('brokerage-accounts') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('brokerage-accounts') +export class BrokerageAccountsController { + constructor(private readonly accounts: BrokerageAccountsService) {} + + @Get() + @ApiOkResponse({ type: BrokerageAccountDto, isArray: true }) + list(@Req() request: Request & { user: AccessPayload }) { + return this.accounts.list(request.user.sub); + } +} diff --git a/src/tbank/brokerage-accounts.service.spec.ts b/src/tbank/brokerage-accounts.service.spec.ts new file mode 100644 index 0000000..7a28630 --- /dev/null +++ b/src/tbank/brokerage-accounts.service.spec.ts @@ -0,0 +1,105 @@ +import { BadRequestException, ConflictException } from '@nestjs/common'; +import { ServiceUnavailableException } from '@nestjs/common'; + +jest.mock('../prisma/prisma.service', () => ({ + PrismaService: class PrismaService {}, +})); + +import { BrokerageAccountsService } from './brokerage-accounts.service'; +import { TbankCrypto } from './tbank.crypto'; + +describe('BrokerageAccountsService', () => { + const encryptionKey = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY='; + const prisma = { tbankConnection: { findUnique: jest.fn() } }; + const config = { getOrThrow: jest.fn(() => encryptionKey) }; + const tbank = { getOpenAccounts: jest.fn() }; + const service = new BrokerageAccountsService( + prisma as never, + config as never, + tbank as never, + ); + const crypto = TbankCrypto.fromBase64(encryptionKey); + + beforeEach(() => jest.clearAllMocks()); + + it('does not call T-Bank without a connection', async () => { + prisma.tbankConnection.findUnique.mockResolvedValue(null); + + await expect(service.list('user-1')).rejects.toEqual( + expect.any(ConflictException), + ); + expect(tbank.getOpenAccounts).not.toHaveBeenCalled(); + }); + + it('normalizes supported accounts and ignores unsupported ones', async () => { + prisma.tbankConnection.findUnique.mockResolvedValue({ + encryptedToken: crypto.encrypt('token'), + }); + tbank.getOpenAccounts.mockResolvedValue([ + { + id: 'brokerage', + type: 'ACCOUNT_TYPE_TINKOFF', + name: ' Основной ', + openedDate: { seconds: '1723507200' }, + }, + { + id: 'debit', + type: 'ACCOUNT_TYPE_DEBIT', + name: 'Дебетовый', + }, + { + id: 'iis', + type: 'ACCOUNT_TYPE_TINKOFF_IIS', + name: ' ', + }, + ]); + + await expect(service.list('user-1')).resolves.toEqual([ + { + id: 'brokerage', + type: 'BROKERAGE', + name: 'Основной', + openedOn: '2024-08-13', + }, + { + id: 'iis', + type: 'INDIVIDUAL_INVESTMENT', + name: 'ИИС', + openedOn: null, + }, + ]); + }); + + it('retains the connection when T-Bank rejects its token', async () => { + prisma.tbankConnection.findUnique.mockResolvedValue({ + encryptedToken: crypto.encrypt('token'), + }); + tbank.getOpenAccounts.mockRejectedValue(new Error('INVALID_TBANK_TOKEN')); + + await expect(service.list('user-1')).rejects.toEqual( + expect.any(BadRequestException), + ); + }); + + it('returns an empty list from T-Bank unchanged', async () => { + prisma.tbankConnection.findUnique.mockResolvedValue({ + encryptedToken: crypto.encrypt('token'), + }); + tbank.getOpenAccounts.mockResolvedValue([]); + + await expect(service.list('user-1')).resolves.toEqual([]); + }); + + it('propagates an unavailable T-Bank response', async () => { + prisma.tbankConnection.findUnique.mockResolvedValue({ + encryptedToken: crypto.encrypt('token'), + }); + tbank.getOpenAccounts.mockRejectedValue( + new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }), + ); + + await expect(service.list('user-1')).rejects.toEqual( + expect.any(ServiceUnavailableException), + ); + }); +}); diff --git a/src/tbank/brokerage-accounts.service.ts b/src/tbank/brokerage-accounts.service.ts new file mode 100644 index 0000000..69b876a --- /dev/null +++ b/src/tbank/brokerage-accounts.service.ts @@ -0,0 +1,79 @@ +import { + BadRequestException, + ConflictException, + Injectable, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Environment } from '../config/env.validation'; +import { PrismaService } from '../prisma/prisma.service'; +import { BrokerageAccountDto } from './brokerage-account.dto'; +import { TbankClient } from './tbank.client'; +import { TbankCrypto } from './tbank.crypto'; + +const accountTypes = { + ACCOUNT_TYPE_TINKOFF: { type: 'BROKERAGE', name: 'Брокерский счет' }, + ACCOUNT_TYPE_TINKOFF_IIS: { + type: 'INDIVIDUAL_INVESTMENT', + name: 'ИИС', + }, + ACCOUNT_TYPE_INVEST_BOX: { type: 'INVEST_BOX', name: 'Инвесткопилка' }, + ACCOUNT_TYPE_INVEST_FUND: { + type: 'MONEY_MARKET_FUND', + name: 'Фонд денежного рынка', + }, +} as const; + +type AccountType = keyof typeof accountTypes; + +@Injectable() +export class BrokerageAccountsService { + private readonly crypto: TbankCrypto; + + constructor( + private readonly prisma: PrismaService, + config: ConfigService, + private readonly tbank: TbankClient, + ) { + this.crypto = TbankCrypto.fromBase64( + config.getOrThrow('TOKEN_ENCRYPTION_KEY'), + ); + } + + async list(userId: string): Promise { + const connection = await this.prisma.tbankConnection.findUnique({ + where: { userId }, + }); + if (!connection) { + throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' }); + } + try { + const accounts = await this.tbank.getOpenAccounts( + this.crypto.decrypt(connection.encryptedToken), + ); + return accounts.flatMap((account) => { + const details = accountTypes[account.type as AccountType]; + if (!details) return []; + return [ + { + id: account.id, + name: account.name.trim() || details.name, + type: details.type, + openedOn: this.openedOn(account.openedDate?.seconds), + }, + ]; + }); + } catch (error) { + if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') { + throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }); + } + throw error; + } + } + + private openedOn(seconds: string | number | undefined): string | null { + if (seconds === undefined) return null; + const date = new Date(Number(seconds) * 1000); + if (Number.isNaN(date.valueOf())) return null; + return date.toISOString().slice(0, 10); + } +} diff --git a/src/tbank/tbank-connection.module.ts b/src/tbank/tbank-connection.module.ts index 2ff9b74..7478f3f 100644 --- a/src/tbank/tbank-connection.module.ts +++ b/src/tbank/tbank-connection.module.ts @@ -1,10 +1,12 @@ import { Module } from '@nestjs/common'; +import { BrokerageAccountsController } from './brokerage-accounts.controller'; +import { BrokerageAccountsService } from './brokerage-accounts.service'; import { TbankClient } from './tbank.client'; import { TbankConnectionController } from './tbank-connection.controller'; import { TbankConnectionService } from './tbank-connection.service'; @Module({ - controllers: [TbankConnectionController], - providers: [TbankClient, TbankConnectionService], + controllers: [TbankConnectionController, BrokerageAccountsController], + providers: [TbankClient, TbankConnectionService, BrokerageAccountsService], }) export class TbankConnectionModule {} diff --git a/src/tbank/tbank.client.ts b/src/tbank/tbank.client.ts index b9078c6..d1df7cc 100644 --- a/src/tbank/tbank.client.ts +++ b/src/tbank/tbank.client.ts @@ -22,9 +22,18 @@ type GetAccounts = ( request: { status: string }, metadata: Metadata, options: { deadline: Date }, - callback: (error: ServiceError | null) => void, + callback: ( + error: ServiceError | null, + response: { accounts?: TbankAccount[] }, + ) => void, ) => ClientUnaryCall; type UsersClient = Client & { getAccounts: GetAccounts }; +export type TbankAccount = { + id: string; + type: string; + name: string; + openedDate?: { seconds?: string | number }; +}; type TbankNamespace = { tinkoff: { public: { @@ -129,18 +138,23 @@ export class TbankClient { } async validateToken(token: string): Promise { - await this.call(token, true); + await this.getOpenAccounts(token); } - private async call(token: string, retry: boolean): Promise { + async getOpenAccounts(token: string): Promise { + return this.call(token, true); + } + + private async call(token: string, retry: boolean): Promise { if (this.queue.size >= 50) { this.logger.warn({ event: 'queue_full', waiting: this.queue.size }); throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }); } try { this.logger.debug({ event: 'get_accounts_started', retry: !retry }); - await this.queue.run(() => this.request(token)); + const accounts = await this.queue.run(() => this.request(token)); this.logger.debug({ event: 'get_accounts_succeeded', retry: !retry }); + return accounts; } catch (error) { const code = (error as ServiceError).code; this.logger.warn({ @@ -175,7 +189,7 @@ export class TbankClient { } } - private request(token: string): Promise { + private request(token: string): Promise { return new Promise((resolve, reject) => { const metadata = new Metadata(); metadata.set('Authorization', `Bearer ${token}`); @@ -183,7 +197,8 @@ export class TbankClient { { status: 'ACCOUNT_STATUS_OPEN' }, metadata, { deadline: new Date(Date.now() + 10_000) }, - (error) => (error ? reject(error) : resolve()), + (error, response) => + error ? reject(error) : resolve(response.accounts ?? []), ); }); } diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 0684d2a..b6fa4d1 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -102,4 +102,23 @@ describe('Authentication (e2e)', () => { expect(body).toEqual({ connected: false, connectedAt: null }), ); }); + + it('guards brokerage accounts and reports a missing connection', async () => { + const registration = await request(app.getHttpServer()) + .post('/api/v1/auth/register') + .send({ email: 'accounts@example.com', password: 'a secure password' }) + .expect(201); + const accessToken = (registration.body as Authentication).accessToken; + + await request(app.getHttpServer()) + .get('/api/v1/brokerage-accounts') + .expect(401); + await request(app.getHttpServer()) + .get('/api/v1/brokerage-accounts') + .set('Authorization', `Bearer ${accessToken}`) + .expect(409) + .expect(({ body }: { body: unknown }) => + expect((body as Problem).code).toBe('TBANK_NOT_CONNECTED'), + ); + }); });