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 { Module } from '@nestjs/common';
|
||||||
|
import { BrokerageAccountsController } from './brokerage-accounts.controller';
|
||||||
|
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
||||||
import { TbankClient } from './tbank.client';
|
import { TbankClient } from './tbank.client';
|
||||||
import { TbankConnectionController } from './tbank-connection.controller';
|
import { TbankConnectionController } from './tbank-connection.controller';
|
||||||
import { TbankConnectionService } from './tbank-connection.service';
|
import { TbankConnectionService } from './tbank-connection.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [TbankConnectionController],
|
controllers: [TbankConnectionController, BrokerageAccountsController],
|
||||||
providers: [TbankClient, TbankConnectionService],
|
providers: [TbankClient, TbankConnectionService, BrokerageAccountsService],
|
||||||
})
|
})
|
||||||
export class TbankConnectionModule {}
|
export class TbankConnectionModule {}
|
||||||
|
|||||||
@@ -22,9 +22,18 @@ type GetAccounts = (
|
|||||||
request: { status: string },
|
request: { status: string },
|
||||||
metadata: Metadata,
|
metadata: Metadata,
|
||||||
options: { deadline: Date },
|
options: { deadline: Date },
|
||||||
callback: (error: ServiceError | null) => void,
|
callback: (
|
||||||
|
error: ServiceError | null,
|
||||||
|
response: { accounts?: TbankAccount[] },
|
||||||
|
) => void,
|
||||||
) => ClientUnaryCall;
|
) => ClientUnaryCall;
|
||||||
type UsersClient = Client & { getAccounts: GetAccounts };
|
type UsersClient = Client & { getAccounts: GetAccounts };
|
||||||
|
export type TbankAccount = {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
openedDate?: { seconds?: string | number };
|
||||||
|
};
|
||||||
type TbankNamespace = {
|
type TbankNamespace = {
|
||||||
tinkoff: {
|
tinkoff: {
|
||||||
public: {
|
public: {
|
||||||
@@ -129,18 +138,23 @@ export class TbankClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async validateToken(token: string): Promise<void> {
|
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) {
|
if (this.queue.size >= 50) {
|
||||||
this.logger.warn({ event: 'queue_full', waiting: this.queue.size });
|
this.logger.warn({ event: 'queue_full', waiting: this.queue.size });
|
||||||
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
|
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
this.logger.debug({ event: 'get_accounts_started', retry: !retry });
|
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 });
|
this.logger.debug({ event: 'get_accounts_succeeded', retry: !retry });
|
||||||
|
return accounts;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const code = (error as ServiceError).code;
|
const code = (error as ServiceError).code;
|
||||||
this.logger.warn({
|
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) => {
|
return new Promise((resolve, reject) => {
|
||||||
const metadata = new Metadata();
|
const metadata = new Metadata();
|
||||||
metadata.set('Authorization', `Bearer ${token}`);
|
metadata.set('Authorization', `Bearer ${token}`);
|
||||||
@@ -183,7 +197,8 @@ export class TbankClient {
|
|||||||
{ status: 'ACCOUNT_STATUS_OPEN' },
|
{ status: 'ACCOUNT_STATUS_OPEN' },
|
||||||
metadata,
|
metadata,
|
||||||
{ deadline: new Date(Date.now() + 10_000) },
|
{ 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 }),
|
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