feat: add brocker account list

This commit is contained in:
2026-08-13 20:57:01 +03:00
parent 18d7cfcf9f
commit c0a4a93880
7 changed files with 272 additions and 8 deletions
+23
View File
@@ -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;
}
@@ -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);
}
}
@@ -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),
);
});
});
+79
View File
@@ -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<Environment, true>,
private readonly tbank: TbankClient,
) {
this.crypto = TbankCrypto.fromBase64(
config.getOrThrow('TOKEN_ENCRYPTION_KEY'),
);
}
async list(userId: string): Promise<BrokerageAccountDto[]> {
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);
}
}
+4 -2
View File
@@ -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 {}
+21 -6
View File
@@ -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<void> {
await this.call(token, true);
await this.getOpenAccounts(token);
}
private async call(token: string, retry: boolean): Promise<void> {
async getOpenAccounts(token: string): Promise<TbankAccount[]> {
return this.call(token, true);
}
private async call(token: string, retry: boolean): Promise<TbankAccount[]> {
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<void> {
private request(token: string): Promise<TbankAccount[]> {
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 ?? []),
);
});
}