feat: add brocker account list
This commit is contained in:
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 ?? []),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user