feat: add operations list

This commit is contained in:
2026-08-14 13:43:02 +03:00
parent e77ebc6e8f
commit 326fb67def
10 changed files with 653 additions and 0 deletions
+120
View File
@@ -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,
});
});
});
+72
View File
@@ -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),
);
});
});
+143
View File
@@ -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(),
};
}
+4
View File
@@ -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,
],
})
+45
View File
@@ -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,
);
});
});
+38
View File
@@ -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,