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 { Module } from '@nestjs/common';
|
||||||
import { BrokerageAccountsController } from './brokerage-accounts.controller';
|
import { BrokerageAccountsController } from './brokerage-accounts.controller';
|
||||||
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
||||||
|
import { BrokerageOperationsController } from './brokerage-operations.controller';
|
||||||
|
import { BrokerageOperationsService } from './brokerage-operations.service';
|
||||||
import { PortfolioController } from './portfolio.controller';
|
import { PortfolioController } from './portfolio.controller';
|
||||||
import { PortfolioService } from './portfolio.service';
|
import { PortfolioService } from './portfolio.service';
|
||||||
import { TbankClient } from './tbank.client';
|
import { TbankClient } from './tbank.client';
|
||||||
@@ -11,12 +13,14 @@ import { TbankConnectionService } from './tbank-connection.service';
|
|||||||
controllers: [
|
controllers: [
|
||||||
TbankConnectionController,
|
TbankConnectionController,
|
||||||
BrokerageAccountsController,
|
BrokerageAccountsController,
|
||||||
|
BrokerageOperationsController,
|
||||||
PortfolioController,
|
PortfolioController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
TbankClient,
|
TbankClient,
|
||||||
TbankConnectionService,
|
TbankConnectionService,
|
||||||
BrokerageAccountsService,
|
BrokerageAccountsService,
|
||||||
|
BrokerageOperationsService,
|
||||||
PortfolioService,
|
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 TbankPositions = { money?: TbankMoney[] };
|
||||||
export type TbankInstrument = { ticker?: string; name?: string };
|
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 = {
|
type TbankNamespace = {
|
||||||
tinkoff: {
|
tinkoff: {
|
||||||
public: { invest: { api: { contract: { v1: Record<string, unknown> } } } };
|
public: { invest: { api: { contract: { v1: Record<string, unknown> } } } };
|
||||||
@@ -172,6 +203,13 @@ export class TbankClient {
|
|||||||
return this.call(token, this.operations, 'getPositions', { accountId });
|
return this.call(token, this.operations, 'getPositions', { accountId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getOperationsByCursor(
|
||||||
|
token: string,
|
||||||
|
request: TbankOperationsByCursorRequest,
|
||||||
|
): Promise<TbankOperationsByCursorResponse> {
|
||||||
|
return this.call(token, this.operations, 'getOperationsByCursor', request);
|
||||||
|
}
|
||||||
|
|
||||||
getInstrumentByFigi(
|
getInstrumentByFigi(
|
||||||
token: string,
|
token: string,
|
||||||
figi: string,
|
figi: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user