From 326fb67def9b3878d5447ef838ae96d50bf493d6 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Fri, 14 Aug 2026 13:43:02 +0300 Subject: [PATCH] feat: add operations list --- src/tbank/brokerage-operation.dto.ts | 120 +++++++++++++++ src/tbank/brokerage-operation.mapper.spec.ts | 48 ++++++ src/tbank/brokerage-operation.mapper.ts | 72 +++++++++ .../brokerage-operations.controller.spec.ts | 18 +++ src/tbank/brokerage-operations.controller.ts | 30 ++++ .../brokerage-operations.service.spec.ts | 135 +++++++++++++++++ src/tbank/brokerage-operations.service.ts | 143 ++++++++++++++++++ src/tbank/tbank-connection.module.ts | 4 + src/tbank/tbank.client.spec.ts | 45 ++++++ src/tbank/tbank.client.ts | 38 +++++ 10 files changed, 653 insertions(+) create mode 100644 src/tbank/brokerage-operation.dto.ts create mode 100644 src/tbank/brokerage-operation.mapper.spec.ts create mode 100644 src/tbank/brokerage-operation.mapper.ts create mode 100644 src/tbank/brokerage-operations.controller.spec.ts create mode 100644 src/tbank/brokerage-operations.controller.ts create mode 100644 src/tbank/brokerage-operations.service.spec.ts create mode 100644 src/tbank/brokerage-operations.service.ts create mode 100644 src/tbank/tbank.client.spec.ts diff --git a/src/tbank/brokerage-operation.dto.ts b/src/tbank/brokerage-operation.dto.ts new file mode 100644 index 0000000..bbc2c54 --- /dev/null +++ b/src/tbank/brokerage-operation.dto.ts @@ -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; +} diff --git a/src/tbank/brokerage-operation.mapper.spec.ts b/src/tbank/brokerage-operation.mapper.spec.ts new file mode 100644 index 0000000..c6d7f96 --- /dev/null +++ b/src/tbank/brokerage-operation.mapper.spec.ts @@ -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, + }); + }); +}); diff --git a/src/tbank/brokerage-operation.mapper.ts b/src/tbank/brokerage-operation.mapper.ts new file mode 100644 index 0000000..4af0e31 --- /dev/null +++ b/src/tbank/brokerage-operation.mapper.ts @@ -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 = { + 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(); +} diff --git a/src/tbank/brokerage-operations.controller.spec.ts b/src/tbank/brokerage-operations.controller.spec.ts new file mode 100644 index 0000000..813153d --- /dev/null +++ b/src/tbank/brokerage-operations.controller.spec.ts @@ -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); + }); +}); diff --git a/src/tbank/brokerage-operations.controller.ts b/src/tbank/brokerage-operations.controller.ts new file mode 100644 index 0000000..85f97f2 --- /dev/null +++ b/src/tbank/brokerage-operations.controller.ts @@ -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); + } +} diff --git a/src/tbank/brokerage-operations.service.spec.ts b/src/tbank/brokerage-operations.service.spec.ts new file mode 100644 index 0000000..3370db4 --- /dev/null +++ b/src/tbank/brokerage-operations.service.spec.ts @@ -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 }).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), + ); + }); +}); diff --git a/src/tbank/brokerage-operations.service.ts b/src/tbank/brokerage-operations.service.ts new file mode 100644 index 0000000..6f27a8b --- /dev/null +++ b/src/tbank/brokerage-operations.service.ts @@ -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, + private readonly tbank: TbankClient, + ) { + this.crypto = TbankCrypto.fromBase64( + config.getOrThrow('TOKEN_ENCRYPTION_KEY'), + ); + } + + async get( + userId: string, + accountId: string, + query: BrokerageOperationsQueryDto, + ): Promise { + 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(), + }; +} diff --git a/src/tbank/tbank-connection.module.ts b/src/tbank/tbank-connection.module.ts index 0ab0746..3ed4382 100644 --- a/src/tbank/tbank-connection.module.ts +++ b/src/tbank/tbank-connection.module.ts @@ -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, ], }) diff --git a/src/tbank/tbank.client.spec.ts b/src/tbank/tbank.client.spec.ts new file mode 100644 index 0000000..ba06ff5 --- /dev/null +++ b/src/tbank/tbank.client.spec.ts @@ -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, + ); + }); +}); diff --git a/src/tbank/tbank.client.ts b/src/tbank/tbank.client.ts index cf24e52..cb3bd38 100644 --- a/src/tbank/tbank.client.ts +++ b/src/tbank/tbank.client.ts @@ -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 } } } }; @@ -172,6 +203,13 @@ export class TbankClient { return this.call(token, this.operations, 'getPositions', { accountId }); } + getOperationsByCursor( + token: string, + request: TbankOperationsByCursorRequest, + ): Promise { + return this.call(token, this.operations, 'getOperationsByCursor', request); + } + getInstrumentByFigi( token: string, figi: string,