feat: add operations list
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsArray,
|
||||
IsDateString,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { MoneyDto } from './portfolio.dto';
|
||||
|
||||
export const operationCategories = [
|
||||
'trade',
|
||||
'income',
|
||||
'tax',
|
||||
'fee',
|
||||
'transfer',
|
||||
'other',
|
||||
] as const;
|
||||
export type OperationCategory = (typeof operationCategories)[number];
|
||||
|
||||
export class BrokerageOperationsQueryDto {
|
||||
@ApiPropertyOptional({ format: 'date-time' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
from?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'date-time' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
to?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: operationCategories, isArray: true })
|
||||
@IsOptional()
|
||||
@Transform(({ value }: { value: unknown }) =>
|
||||
typeof value === 'string' ? value.split(',').filter(Boolean) : value,
|
||||
)
|
||||
@IsArray()
|
||||
@IsEnum(operationCategories, { each: true })
|
||||
categories?: OperationCategory[];
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(4096)
|
||||
cursor?: string;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 1, maximum: 1000, default: 100 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(1000)
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export class BrokerageOperationDto {
|
||||
@ApiProperty()
|
||||
id: string;
|
||||
|
||||
@ApiProperty({ format: 'date-time' })
|
||||
occurredAt: string;
|
||||
|
||||
@ApiProperty()
|
||||
type: string;
|
||||
|
||||
@ApiProperty({ enum: operationCategories })
|
||||
category: OperationCategory;
|
||||
|
||||
@ApiProperty()
|
||||
description: string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
figi: string | null;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
instrumentUid: string | null;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
ticker: string | null;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
instrumentType: string | null;
|
||||
|
||||
@ApiProperty({ type: MoneyDto, nullable: true })
|
||||
payment: MoneyDto | null;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
quantity: string | null;
|
||||
|
||||
@ApiProperty({ type: MoneyDto, nullable: true })
|
||||
price: MoneyDto | null;
|
||||
|
||||
@ApiProperty({ type: MoneyDto, nullable: true })
|
||||
accruedInterest: MoneyDto | null;
|
||||
|
||||
@ApiProperty({ type: MoneyDto, nullable: true })
|
||||
commission: MoneyDto | null;
|
||||
}
|
||||
|
||||
export class BrokerageOperationsPageDto {
|
||||
@ApiProperty()
|
||||
accountId: string;
|
||||
|
||||
@ApiProperty({ type: BrokerageOperationDto, isArray: true })
|
||||
items: BrokerageOperationDto[];
|
||||
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
nextCursor: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
hasNext: boolean;
|
||||
|
||||
@ApiProperty({ format: 'date-time' })
|
||||
observedAt: string;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
mapBrokerageOperation,
|
||||
operationCategory,
|
||||
} from './brokerage-operation.mapper';
|
||||
|
||||
describe('mapBrokerageOperation', () => {
|
||||
it.each([
|
||||
['OPERATION_TYPE_COUPON', 'income'],
|
||||
['OPERATION_TYPE_DIVIDEND', 'income'],
|
||||
['OPERATION_TYPE_TAX', 'tax'],
|
||||
['OPERATION_TYPE_BROKER_FEE', 'fee'],
|
||||
['NEW_TYPE', 'other'],
|
||||
])('maps %s to %s', (type, category) => {
|
||||
expect(operationCategory(type)).toBe(category);
|
||||
});
|
||||
|
||||
it('keeps a trade and its commission in one exact-money entry', () => {
|
||||
expect(
|
||||
mapBrokerageOperation({
|
||||
id: 'trade',
|
||||
date: { seconds: '1' },
|
||||
type: 'OPERATION_TYPE_BUY',
|
||||
payment: { currency: 'rub', units: '-1', nano: '-500000000' },
|
||||
commission: { currency: 'RUB', nano: '-5000000' },
|
||||
}),
|
||||
).toMatchObject({
|
||||
id: 'trade',
|
||||
category: 'trade',
|
||||
payment: { currency: 'RUB', value: '-1.5' },
|
||||
commission: { currency: 'RUB', value: '-0.005' },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not invent payment for an instrument-only movement', () => {
|
||||
expect(
|
||||
mapBrokerageOperation({
|
||||
id: 'transfer',
|
||||
date: { seconds: '1' },
|
||||
type: 'OPERATION_TYPE_INPUT_SECURITIES',
|
||||
quantity: '3',
|
||||
}),
|
||||
).toMatchObject({
|
||||
category: 'transfer',
|
||||
quantity: '3',
|
||||
payment: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { decimal, formatDecimal } from './decimal';
|
||||
import {
|
||||
BrokerageOperationDto,
|
||||
OperationCategory,
|
||||
} from './brokerage-operation.dto';
|
||||
import { TbankMoney, TbankOperationItem } from './tbank.client';
|
||||
|
||||
const types: Record<string, OperationCategory> = {
|
||||
OPERATION_TYPE_BUY: 'trade',
|
||||
OPERATION_TYPE_BUY_CARD: 'trade',
|
||||
OPERATION_TYPE_SELL: 'trade',
|
||||
OPERATION_TYPE_SELL_CARD: 'trade',
|
||||
OPERATION_TYPE_BUY_MARGIN: 'trade',
|
||||
OPERATION_TYPE_SELL_MARGIN: 'trade',
|
||||
OPERATION_TYPE_COUPON: 'income',
|
||||
OPERATION_TYPE_DIVIDEND: 'income',
|
||||
OPERATION_TYPE_DIVIDEND_TRANSFER: 'income',
|
||||
OPERATION_TYPE_BOND_TAX: 'tax',
|
||||
OPERATION_TYPE_TAX: 'tax',
|
||||
OPERATION_TYPE_DIVIDEND_TAX: 'tax',
|
||||
OPERATION_TYPE_SERVICE_FEE: 'fee',
|
||||
OPERATION_TYPE_MARGIN_FEE: 'fee',
|
||||
OPERATION_TYPE_BROKER_FEE: 'fee',
|
||||
OPERATION_TYPE_SUCCESS_FEE: 'fee',
|
||||
OPERATION_TYPE_INPUT: 'transfer',
|
||||
OPERATION_TYPE_OUTPUT: 'transfer',
|
||||
OPERATION_TYPE_INPUT_SECURITIES: 'transfer',
|
||||
OPERATION_TYPE_OUTPUT_SECURITIES: 'transfer',
|
||||
};
|
||||
|
||||
export function operationCategory(type: string | undefined): OperationCategory {
|
||||
return types[type ?? ''] ?? 'other';
|
||||
}
|
||||
|
||||
export function mapBrokerageOperation(
|
||||
operation: TbankOperationItem,
|
||||
): BrokerageOperationDto {
|
||||
return {
|
||||
id: operation.id ?? operation.cursor ?? '',
|
||||
occurredAt: timestamp(operation.date),
|
||||
type: operation.type ?? 'OPERATION_TYPE_UNSPECIFIED',
|
||||
category: operationCategory(operation.type),
|
||||
description: operation.description?.trim() || operation.name?.trim() || '',
|
||||
figi: text(operation.figi),
|
||||
instrumentUid: text(operation.instrumentUid),
|
||||
ticker: text(operation.ticker),
|
||||
instrumentType: text(operation.instrumentType),
|
||||
payment: money(operation.payment),
|
||||
quantity:
|
||||
operation.quantity === undefined ? null : String(operation.quantity),
|
||||
price: money(operation.price),
|
||||
accruedInterest: money(operation.accruedInt),
|
||||
commission: money(operation.commission),
|
||||
};
|
||||
}
|
||||
|
||||
function text(value: string | undefined): string | null {
|
||||
return value?.trim() || null;
|
||||
}
|
||||
|
||||
function money(value: TbankMoney | undefined) {
|
||||
const currency = value?.currency?.trim().toUpperCase();
|
||||
return currency && value
|
||||
? { currency, value: formatDecimal(decimal(value)) }
|
||||
: null;
|
||||
}
|
||||
|
||||
function timestamp(
|
||||
value: { seconds?: string | number; nanos?: number } | undefined,
|
||||
) {
|
||||
return new Date(Number(value?.seconds ?? 0) * 1000).toISOString();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
jest.mock('../prisma/prisma.service', () => ({
|
||||
PrismaService: class PrismaService {},
|
||||
}));
|
||||
|
||||
import { BrokerageOperationsController } from './brokerage-operations.controller';
|
||||
|
||||
describe('BrokerageOperationsController', () => {
|
||||
it('passes the authenticated user, account, and validated query to the service', async () => {
|
||||
const operations = { get: jest.fn() };
|
||||
const controller = new BrokerageOperationsController(operations as never);
|
||||
const query = { limit: 10 };
|
||||
const request = { user: { sub: 'user' } };
|
||||
|
||||
await controller.get(request as never, 'account', query);
|
||||
|
||||
expect(operations.get).toHaveBeenCalledWith('user', 'account', query);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Controller, Get, Param, Query, 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 { ApiProblemResponses } from '../common/decorators/api-safe-error-response.decorator';
|
||||
import {
|
||||
BrokerageOperationsPageDto,
|
||||
BrokerageOperationsQueryDto,
|
||||
} from './brokerage-operation.dto';
|
||||
import { BrokerageOperationsService } from './brokerage-operations.service';
|
||||
|
||||
@ApiTags('brokerage-accounts')
|
||||
@ApiProblemResponses()
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('brokerage-accounts')
|
||||
export class BrokerageOperationsController {
|
||||
constructor(private readonly operations: BrokerageOperationsService) {}
|
||||
|
||||
@Get(':accountId/operations')
|
||||
@ApiOkResponse({ type: BrokerageOperationsPageDto })
|
||||
get(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Param('accountId') accountId: string,
|
||||
@Query() query: BrokerageOperationsQueryDto,
|
||||
) {
|
||||
return this.operations.get(request.user.sub, accountId, query);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
|
||||
jest.mock('../prisma/prisma.service', () => ({
|
||||
PrismaService: class PrismaService {},
|
||||
}));
|
||||
|
||||
import { BrokerageOperationsService } from './brokerage-operations.service';
|
||||
import { TbankCrypto } from './tbank.crypto';
|
||||
|
||||
describe('BrokerageOperationsService', () => {
|
||||
const key = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=';
|
||||
const prisma = { tbankConnection: { findUnique: jest.fn() } };
|
||||
const config = { getOrThrow: jest.fn(() => key) };
|
||||
const tbank = {
|
||||
getOpenAccounts: jest.fn(),
|
||||
getOperationsByCursor: jest.fn(),
|
||||
};
|
||||
const service = new BrokerageOperationsService(
|
||||
prisma as never,
|
||||
config as never,
|
||||
tbank as never,
|
||||
);
|
||||
const crypto = TbankCrypto.fromBase64(key);
|
||||
let operationsRequest: unknown;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
prisma.tbankConnection.findUnique.mockResolvedValue({
|
||||
encryptedToken: crypto.encrypt('token'),
|
||||
});
|
||||
tbank.getOpenAccounts.mockResolvedValue([
|
||||
{ id: 'account', type: 'ACCOUNT_TYPE_TINKOFF' },
|
||||
]);
|
||||
tbank.getOperationsByCursor.mockImplementation(
|
||||
(_token: string, request: unknown) => {
|
||||
operationsRequest = request;
|
||||
return Promise.resolve({ items: [] });
|
||||
},
|
||||
);
|
||||
(service as unknown as { pages: Map<string, unknown> }).pages.clear();
|
||||
});
|
||||
|
||||
it('uses the current UTC year by default and asks only for executed operations', async () => {
|
||||
await service.get('user', 'account', {});
|
||||
|
||||
const request = operationsRequest as {
|
||||
state: string;
|
||||
from: { seconds: string };
|
||||
};
|
||||
expect(request.state).toBe('OPERATION_STATE_EXECUTED');
|
||||
expect(request.from.seconds).toBe(
|
||||
String(Math.floor(Date.UTC(new Date().getUTCFullYear(), 0, 1) / 1000)),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses inclusive date boundaries, filters categories, and preserves cursors', async () => {
|
||||
tbank.getOperationsByCursor.mockResolvedValue({
|
||||
hasNext: true,
|
||||
nextCursor: 'next',
|
||||
items: [
|
||||
{ id: 'income', date: { seconds: '1' }, type: 'OPERATION_TYPE_COUPON' },
|
||||
{
|
||||
id: 'fee',
|
||||
date: { seconds: '2' },
|
||||
type: 'OPERATION_TYPE_BROKER_FEE',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.get('user', 'account', {
|
||||
from: '2025-01-01',
|
||||
to: '2025-01-31',
|
||||
categories: ['income'],
|
||||
cursor: 'previous',
|
||||
limit: 10,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
items: [{ id: 'income' }],
|
||||
nextCursor: 'next',
|
||||
hasNext: true,
|
||||
});
|
||||
expect(tbank.getOperationsByCursor).toHaveBeenCalledWith(
|
||||
'token',
|
||||
expect.objectContaining({
|
||||
from: { seconds: '1735689600' },
|
||||
to: { seconds: '1738367999', nanos: 999_999_999 },
|
||||
cursor: 'previous',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns an empty page without calling T-Bank when the period starts in the future', async () => {
|
||||
await expect(
|
||||
service.get('user', 'account', { from: '2999-01-01T00:00:00.000Z' }),
|
||||
).resolves.toMatchObject({
|
||||
accountId: 'account',
|
||||
items: [],
|
||||
nextCursor: null,
|
||||
hasNext: false,
|
||||
});
|
||||
expect(tbank.getOperationsByCursor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects inaccessible accounts before reading operations', async () => {
|
||||
tbank.getOpenAccounts.mockResolvedValue([]);
|
||||
|
||||
await expect(service.get('user', 'account', {})).rejects.toEqual(
|
||||
expect.any(NotFoundException),
|
||||
);
|
||||
expect(tbank.getOperationsByCursor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retains existing T-Bank error semantics', async () => {
|
||||
tbank.getOpenAccounts.mockRejectedValueOnce(
|
||||
new Error('INVALID_TBANK_TOKEN'),
|
||||
);
|
||||
await expect(service.get('user', 'account', {})).rejects.toEqual(
|
||||
expect.any(BadRequestException),
|
||||
);
|
||||
tbank.getOpenAccounts.mockResolvedValueOnce([
|
||||
{ id: 'account', type: 'ACCOUNT_TYPE_TINKOFF' },
|
||||
]);
|
||||
tbank.getOperationsByCursor.mockRejectedValueOnce(
|
||||
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
||||
);
|
||||
await expect(service.get('user', 'account', {})).rejects.toEqual(
|
||||
expect.any(ServiceUnavailableException),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Environment } from '../config/env.validation';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
BrokerageOperationsPageDto,
|
||||
BrokerageOperationsQueryDto,
|
||||
} from './brokerage-operation.dto';
|
||||
import {
|
||||
mapBrokerageOperation,
|
||||
operationCategory,
|
||||
} from './brokerage-operation.mapper';
|
||||
import { TbankClient } from './tbank.client';
|
||||
import { TbankCrypto } from './tbank.crypto';
|
||||
|
||||
const freshForMs = 30_000;
|
||||
const supportedAccounts = new Set([
|
||||
'ACCOUNT_TYPE_TINKOFF',
|
||||
'ACCOUNT_TYPE_TINKOFF_IIS',
|
||||
'ACCOUNT_TYPE_INVEST_BOX',
|
||||
'ACCOUNT_TYPE_INVEST_FUND',
|
||||
]);
|
||||
|
||||
@Injectable()
|
||||
export class BrokerageOperationsService {
|
||||
private readonly crypto: TbankCrypto;
|
||||
private readonly pages = new Map<
|
||||
string,
|
||||
{ page: BrokerageOperationsPageDto; storedAt: number }
|
||||
>();
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
config: ConfigService<Environment, true>,
|
||||
private readonly tbank: TbankClient,
|
||||
) {
|
||||
this.crypto = TbankCrypto.fromBase64(
|
||||
config.getOrThrow('TOKEN_ENCRYPTION_KEY'),
|
||||
);
|
||||
}
|
||||
|
||||
async get(
|
||||
userId: string,
|
||||
accountId: string,
|
||||
query: BrokerageOperationsQueryDto,
|
||||
): Promise<BrokerageOperationsPageDto> {
|
||||
const range = this.range(query);
|
||||
const key = JSON.stringify({ userId, accountId, ...query, ...range });
|
||||
const cached = this.pages.get(key);
|
||||
if (cached && Date.now() - cached.storedAt <= freshForMs)
|
||||
return cached.page;
|
||||
|
||||
try {
|
||||
const connection = await this.prisma.tbankConnection.findUnique({
|
||||
where: { userId },
|
||||
});
|
||||
if (!connection)
|
||||
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
|
||||
const token = this.crypto.decrypt(connection.encryptedToken);
|
||||
const accounts = await this.tbank.getOpenAccounts(token);
|
||||
if (
|
||||
!accounts.some(
|
||||
(account) =>
|
||||
account.id === accountId && supportedAccounts.has(account.type),
|
||||
)
|
||||
)
|
||||
throw new NotFoundException();
|
||||
|
||||
if (range.from > range.to) {
|
||||
const page = emptyPage(accountId);
|
||||
this.pages.set(key, { page, storedAt: Date.now() });
|
||||
return page;
|
||||
}
|
||||
|
||||
const response = await this.tbank.getOperationsByCursor(token, {
|
||||
accountId,
|
||||
from: seconds(range.from),
|
||||
to: seconds(range.to, /^\d{4}-\d{2}-\d{2}$/.test(query.to ?? '')),
|
||||
cursor: query.cursor,
|
||||
limit: query.limit ?? 100,
|
||||
state: 'OPERATION_STATE_EXECUTED',
|
||||
});
|
||||
const page = {
|
||||
accountId,
|
||||
items: (response.items ?? [])
|
||||
.filter(
|
||||
(item) =>
|
||||
!query.categories?.length ||
|
||||
query.categories.includes(operationCategory(item.type)),
|
||||
)
|
||||
.map(mapBrokerageOperation),
|
||||
nextCursor: response.nextCursor || null,
|
||||
hasNext: Boolean(response.hasNext),
|
||||
observedAt: new Date().toISOString(),
|
||||
};
|
||||
this.pages.set(key, { page, storedAt: Date.now() });
|
||||
return page;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
|
||||
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private range(query: BrokerageOperationsQueryDto) {
|
||||
const now = new Date();
|
||||
return {
|
||||
from:
|
||||
dateBoundary(query.from, false) ??
|
||||
new Date(Date.UTC(now.getUTCFullYear(), 0, 1)),
|
||||
to: dateBoundary(query.to, true) ?? now,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function dateBoundary(value: string | undefined, end: boolean): Date | null {
|
||||
if (!value) return null;
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(value))
|
||||
return new Date(`${value}T${end ? '23:59:59.999' : '00:00:00.000'}Z`);
|
||||
return new Date(value);
|
||||
}
|
||||
|
||||
function seconds(value: Date, endOfSecond = false) {
|
||||
return {
|
||||
seconds: Math.floor(value.valueOf() / 1000).toString(),
|
||||
...(endOfSecond ? { nanos: 999_999_999 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function emptyPage(accountId: string): BrokerageOperationsPageDto {
|
||||
return {
|
||||
accountId,
|
||||
items: [],
|
||||
nextCursor: null,
|
||||
hasNext: false,
|
||||
observedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BrokerageAccountsController } from './brokerage-accounts.controller';
|
||||
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
||||
import { BrokerageOperationsController } from './brokerage-operations.controller';
|
||||
import { BrokerageOperationsService } from './brokerage-operations.service';
|
||||
import { PortfolioController } from './portfolio.controller';
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
import { TbankClient } from './tbank.client';
|
||||
@@ -11,12 +13,14 @@ import { TbankConnectionService } from './tbank-connection.service';
|
||||
controllers: [
|
||||
TbankConnectionController,
|
||||
BrokerageAccountsController,
|
||||
BrokerageOperationsController,
|
||||
PortfolioController,
|
||||
],
|
||||
providers: [
|
||||
TbankClient,
|
||||
TbankConnectionService,
|
||||
BrokerageAccountsService,
|
||||
BrokerageOperationsService,
|
||||
PortfolioService,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ServiceUnavailableException } from '@nestjs/common';
|
||||
import { TbankClient } from './tbank.client';
|
||||
|
||||
describe('TbankClient operations', () => {
|
||||
const request = {
|
||||
accountId: 'account',
|
||||
from: { seconds: '1' },
|
||||
to: { seconds: '2' },
|
||||
cursor: 'cursor',
|
||||
limit: 10,
|
||||
state: 'OPERATION_STATE_EXECUTED' as const,
|
||||
};
|
||||
|
||||
it('passes a cursor operations request to OperationsService', async () => {
|
||||
const client = Object.create(TbankClient.prototype) as TbankClient;
|
||||
const call = jest.fn().mockResolvedValue({ items: [] });
|
||||
(client as unknown as { call: jest.Mock }).call = call;
|
||||
|
||||
await expect(
|
||||
client.getOperationsByCursor('token', request),
|
||||
).resolves.toEqual({
|
||||
items: [],
|
||||
});
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
'token',
|
||||
undefined,
|
||||
'getOperationsByCursor',
|
||||
request,
|
||||
);
|
||||
});
|
||||
|
||||
it('propagates the normalized upstream error', async () => {
|
||||
const client = Object.create(TbankClient.prototype) as TbankClient;
|
||||
const error = new ServiceUnavailableException({
|
||||
code: 'TBANK_UNAVAILABLE',
|
||||
});
|
||||
(client as unknown as { call: jest.Mock }).call = jest
|
||||
.fn()
|
||||
.mockRejectedValue(error);
|
||||
|
||||
await expect(client.getOperationsByCursor('token', request)).rejects.toBe(
|
||||
error,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -47,6 +47,37 @@ export type TbankPortfolio = {
|
||||
};
|
||||
export type TbankPositions = { money?: TbankMoney[] };
|
||||
export type TbankInstrument = { ticker?: string; name?: string };
|
||||
export type TbankTimestamp = { seconds?: string | number; nanos?: number };
|
||||
export type TbankOperationItem = {
|
||||
cursor?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
date?: TbankTimestamp;
|
||||
type?: string;
|
||||
description?: string;
|
||||
figi?: string;
|
||||
instrumentUid?: string;
|
||||
instrumentType?: string;
|
||||
ticker?: string;
|
||||
payment?: TbankMoney;
|
||||
price?: TbankMoney;
|
||||
commission?: TbankMoney;
|
||||
accruedInt?: TbankMoney;
|
||||
quantity?: string | number;
|
||||
};
|
||||
export type TbankOperationsByCursorRequest = {
|
||||
accountId: string;
|
||||
from?: TbankTimestamp;
|
||||
to?: TbankTimestamp;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
state: 'OPERATION_STATE_EXECUTED';
|
||||
};
|
||||
export type TbankOperationsByCursorResponse = {
|
||||
hasNext?: boolean;
|
||||
nextCursor?: string;
|
||||
items?: TbankOperationItem[];
|
||||
};
|
||||
type TbankNamespace = {
|
||||
tinkoff: {
|
||||
public: { invest: { api: { contract: { v1: Record<string, unknown> } } } };
|
||||
@@ -172,6 +203,13 @@ export class TbankClient {
|
||||
return this.call(token, this.operations, 'getPositions', { accountId });
|
||||
}
|
||||
|
||||
getOperationsByCursor(
|
||||
token: string,
|
||||
request: TbankOperationsByCursorRequest,
|
||||
): Promise<TbankOperationsByCursorResponse> {
|
||||
return this.call(token, this.operations, 'getOperationsByCursor', request);
|
||||
}
|
||||
|
||||
getInstrumentByFigi(
|
||||
token: string,
|
||||
figi: string,
|
||||
|
||||
Reference in New Issue
Block a user