From dfca11c81f7e6dadce3ca6f961d265c5e3f8ff8e Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Thu, 13 Aug 2026 22:44:53 +0300 Subject: [PATCH] feat: add portfolio --- src/tbank/decimal.spec.ts | 22 ++ src/tbank/decimal.ts | 44 ++++ src/tbank/portfolio.controller.ts | 24 ++ src/tbank/portfolio.dto.ts | 61 +++++ src/tbank/portfolio.service.spec.ts | 167 ++++++++++++ src/tbank/portfolio.service.ts | 283 +++++++++++++++++++++ src/tbank/tbank-connection.module.ts | 15 +- src/tbank/tbank-connection.service.spec.ts | 3 + src/tbank/tbank-connection.service.ts | 4 + src/tbank/tbank.client.ts | 207 +++++++++------ test/app.e2e-spec.ts | 19 ++ 11 files changed, 771 insertions(+), 78 deletions(-) create mode 100644 src/tbank/decimal.spec.ts create mode 100644 src/tbank/decimal.ts create mode 100644 src/tbank/portfolio.controller.ts create mode 100644 src/tbank/portfolio.dto.ts create mode 100644 src/tbank/portfolio.service.spec.ts create mode 100644 src/tbank/portfolio.service.ts diff --git a/src/tbank/decimal.spec.ts b/src/tbank/decimal.spec.ts new file mode 100644 index 0000000..54b39d8 --- /dev/null +++ b/src/tbank/decimal.spec.ts @@ -0,0 +1,22 @@ +import { add, decimal, formatDecimal, multiply, subtract } from './decimal'; + +describe('decimal', () => { + it('keeps signed nanos exact without floats', () => { + expect(formatDecimal(decimal({ units: 0, nano: -500000000 }))).toBe('-0.5'); + expect( + formatDecimal( + add(decimal({ units: '9007199254740993' }), decimal({ nano: 1 })), + ), + ).toBe('9007199254740993.000000001'); + expect( + formatDecimal( + subtract(decimal({ units: 1 }), decimal({ nano: 500000000 })), + ), + ).toBe('0.5'); + expect( + formatDecimal( + multiply(decimal({ units: 2 }), decimal({ nano: 500000000 })), + ), + ).toBe('1'); + }); +}); diff --git a/src/tbank/decimal.ts b/src/tbank/decimal.ts new file mode 100644 index 0000000..f2c952c --- /dev/null +++ b/src/tbank/decimal.ts @@ -0,0 +1,44 @@ +const NANO = 1_000_000_000n; + +export type ProtoDecimal = { units?: string | number; nano?: string | number }; +export type Decimal = { value: bigint; scale: number }; + +export function decimal(value: ProtoDecimal): Decimal { + return { + value: BigInt(value.units ?? 0) * NANO + BigInt(value.nano ?? 0), + scale: 9, + }; +} + +export function add(left: Decimal, right: Decimal): Decimal { + const scale = Math.max(left.scale, right.scale); + return { + value: + left.value * 10n ** BigInt(scale - left.scale) + + right.value * 10n ** BigInt(scale - right.scale), + scale, + }; +} + +export function subtract(left: Decimal, right: Decimal): Decimal { + return add(left, { value: -right.value, scale: right.scale }); +} + +export function multiply(left: Decimal, right: Decimal): Decimal { + return { value: left.value * right.value, scale: left.scale + right.scale }; +} + +export function isZero(value: Decimal): boolean { + return value.value === 0n; +} + +export function formatDecimal({ value, scale }: Decimal): string { + if (value === 0n) return '0'; + const sign = value < 0n ? '-' : ''; + const digits = (value < 0n ? -value : value) + .toString() + .padStart(scale + 1, '0'); + const whole = digits.slice(0, -scale) || '0'; + const fraction = digits.slice(-scale).replace(/0+$/, ''); + return `${sign}${whole}${fraction ? `.${fraction}` : ''}`; +} diff --git a/src/tbank/portfolio.controller.ts b/src/tbank/portfolio.controller.ts new file mode 100644 index 0000000..c951ee0 --- /dev/null +++ b/src/tbank/portfolio.controller.ts @@ -0,0 +1,24 @@ +import { Controller, Get, Param, 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 { PortfolioDto } from './portfolio.dto'; +import { PortfolioService } from './portfolio.service'; + +@ApiTags('brokerage-accounts') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('brokerage-accounts') +export class PortfolioController { + constructor(private readonly portfolio: PortfolioService) {} + + @Get(':accountId/portfolio') + @ApiOkResponse({ type: PortfolioDto }) + get( + @Req() request: Request & { user: AccessPayload }, + @Param('accountId') accountId: string, + ) { + return this.portfolio.get(request.user.sub, accountId); + } +} diff --git a/src/tbank/portfolio.dto.ts b/src/tbank/portfolio.dto.ts new file mode 100644 index 0000000..5e2b295 --- /dev/null +++ b/src/tbank/portfolio.dto.ts @@ -0,0 +1,61 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class MoneyDto { + @ApiProperty() + currency: string; + + @ApiProperty() + value: string; +} + +export class PortfolioPositionDto { + @ApiProperty({ type: String, nullable: true }) + figi: string | null; + + @ApiProperty() + displayName: string; + + @ApiProperty({ + enum: [ + 'STOCK', + 'BOND', + 'FUND', + 'CURRENCY', + 'FUTURE', + 'OPTION', + 'STRUCTURED_NOTE', + 'DIGITAL_FINANCIAL_ASSET', + 'OTHER', + ], + }) + type: string; + + @ApiProperty() + quantity: string; + + @ApiProperty({ type: MoneyDto, nullable: true }) + currentValue: MoneyDto | null; + + @ApiProperty({ type: MoneyDto, nullable: true }) + averagePurchasePrice: MoneyDto | null; + + @ApiProperty({ type: MoneyDto, nullable: true }) + absoluteReturn: MoneyDto | null; +} + +export class PortfolioDto { + @ApiProperty({ format: 'date-time' }) + lastSuccessfulAt: string; + + @ApiProperty() + isStale: boolean; + + @ApiProperty({ type: MoneyDto, isArray: true }) + totalsByCurrency: MoneyDto[]; + + @ApiProperty({ type: PortfolioPositionDto, isArray: true }) + positions: PortfolioPositionDto[]; + + @ApiProperty({ type: MoneyDto, isArray: true }) + cashBalances: MoneyDto[]; +} diff --git a/src/tbank/portfolio.service.spec.ts b/src/tbank/portfolio.service.spec.ts new file mode 100644 index 0000000..7b7912e --- /dev/null +++ b/src/tbank/portfolio.service.spec.ts @@ -0,0 +1,167 @@ +import { + BadRequestException, + ConflictException, + NotFoundException, + ServiceUnavailableException, +} from '@nestjs/common'; + +jest.mock('../prisma/prisma.service', () => ({ + PrismaService: class PrismaService {}, +})); + +import { PortfolioService } from './portfolio.service'; +import { TbankCrypto } from './tbank.crypto'; + +describe('PortfolioService', () => { + const key = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY='; + const prisma = { tbankConnection: { findUnique: jest.fn() } }; + const config = { getOrThrow: jest.fn(() => key) }; + const tbank = { + getOpenAccounts: jest.fn(), + getPortfolio: jest.fn(), + getPositions: jest.fn(), + getInstrumentByFigi: jest.fn(), + getInstrumentByUid: jest.fn(), + }; + const service = new PortfolioService( + prisma as never, + config as never, + tbank as never, + ); + const crypto = TbankCrypto.fromBase64(key); + + beforeEach(() => { + jest.clearAllMocks(); + service.invalidate('user'); + prisma.tbankConnection.findUnique.mockResolvedValue({ + encryptedToken: crypto.encrypt('token'), + }); + tbank.getOpenAccounts.mockResolvedValue([ + { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF' }, + ]); + tbank.getPortfolio.mockResolvedValue({ positions: [] }); + tbank.getPositions.mockResolvedValue({ money: [] }); + }); + + it('requires a connection and an accessible brokerage account', async () => { + prisma.tbankConnection.findUnique.mockResolvedValue(null); + await expect(service.get('user', 'account')).rejects.toEqual( + expect.any(ConflictException), + ); + prisma.tbankConnection.findUnique.mockResolvedValue({ + encryptedToken: crypto.encrypt('token'), + }); + tbank.getOpenAccounts.mockResolvedValue([]); + await expect(service.get('user', 'account')).rejects.toEqual( + expect.any(NotFoundException), + ); + }); + + it('normalizes upstream order, exact totals, metadata fallback, and cash', async () => { + tbank.getPortfolio.mockResolvedValue({ + positions: [ + { + figi: 'ONE', + instrumentType: 'share', + quantity: { units: 2 }, + currentPrice: { currency: 'rub', units: 3 }, + averagePositionPrice: { currency: 'RUB', units: 2 }, + }, + { + figi: 'TWO', + instrumentType: 'new-kind', + quantity: { nano: -500000000 }, + currentPrice: { currency: 'USD', units: 4 }, + }, + { figi: 'ZERO', quantity: {} }, + ], + }); + tbank.getPositions.mockResolvedValue({ + money: [ + { currency: 'RUB', units: 1 }, + { currency: 'rub', nano: 500000000 }, + { currency: 'USD' }, + ], + }); + tbank.getInstrumentByFigi + .mockResolvedValueOnce({ ticker: 'ONE', name: 'Первая позиция' }) + .mockRejectedValueOnce(new Error('unavailable')); + + await expect(service.get('user', 'account')).resolves.toMatchObject({ + positions: [ + { + figi: 'ONE', + displayName: 'Первая позиция', + type: 'STOCK', + quantity: '2', + currentValue: { currency: 'RUB', value: '6' }, + absoluteReturn: { currency: 'RUB', value: '2' }, + }, + { + figi: 'TWO', + displayName: 'TWO', + type: 'OTHER', + quantity: '-0.5', + currentValue: { currency: 'USD', value: '-2' }, + }, + ], + cashBalances: [{ currency: 'RUB', value: '1.5' }], + totalsByCurrency: [ + { currency: 'RUB', value: '7.5' }, + { currency: 'USD', value: '-2' }, + ], + }); + }); + + it('uses instrument UID before FIGI when resolving a name', async () => { + tbank.getPortfolio.mockResolvedValue({ + positions: [ + { + figi: 'FIGI', + instrumentUid: 'UID', + instrumentType: 'share', + quantity: { units: 1 }, + }, + ], + }); + tbank.getInstrumentByUid.mockResolvedValue({ name: 'Имя по UID' }); + + await expect(service.get('user', 'account')).resolves.toMatchObject({ + positions: [{ displayName: 'Имя по UID' }], + }); + expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled(); + }); + + it('uses fresh snapshots, falls back only for temporary errors, and clears them', async () => { + await service.get('user', 'account'); + await service.get('user', 'account'); + expect(tbank.getOpenAccounts).toHaveBeenCalledTimes(1); + service.invalidate('user'); + await service.get('user', 'account'); + expect(tbank.getOpenAccounts).toHaveBeenCalledTimes(2); + const snapshots = ( + service as never as { snapshots: Map } + ).snapshots; + snapshots.get('user:account')!.storedAt = Date.now() - 30_001; + tbank.getOpenAccounts.mockRejectedValueOnce( + new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }), + ); + await expect(service.get('user', 'account')).resolves.toMatchObject({ + isStale: true, + }); + }); + + it('does not mask an invalid token with a snapshot', async () => { + await service.get('user', 'account'); + const snapshots = ( + service as never as { snapshots: Map } + ).snapshots; + snapshots.get('user:account')!.storedAt = Date.now() - 30_001; + tbank.getOpenAccounts.mockRejectedValueOnce( + new Error('INVALID_TBANK_TOKEN'), + ); + await expect(service.get('user', 'account')).rejects.toEqual( + expect.any(BadRequestException), + ); + }); +}); diff --git a/src/tbank/portfolio.service.ts b/src/tbank/portfolio.service.ts new file mode 100644 index 0000000..609aa95 --- /dev/null +++ b/src/tbank/portfolio.service.ts @@ -0,0 +1,283 @@ +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, + ServiceUnavailableException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Environment } from '../config/env.validation'; +import { PrismaService } from '../prisma/prisma.service'; +import { + add, + decimal, + formatDecimal, + isZero, + multiply, + subtract, +} from './decimal'; +import { MoneyDto, PortfolioDto, PortfolioPositionDto } from './portfolio.dto'; +import { TbankClient, TbankMoney } from './tbank.client'; +import { TbankCrypto } from './tbank.crypto'; + +const freshForMs = 30_000; +const fallbackForMs = 300_000; +const metadataForMs = 86_400_000; +const supportedAccounts = new Set([ + 'ACCOUNT_TYPE_TINKOFF', + 'ACCOUNT_TYPE_TINKOFF_IIS', + 'ACCOUNT_TYPE_INVEST_BOX', + 'ACCOUNT_TYPE_INVEST_FUND', +]); +const types: Record = { + share: 'STOCK', + bond: 'BOND', + etf: 'FUND', + currency: 'CURRENCY', + futures: 'FUTURE', + option: 'OPTION', + sp: 'STRUCTURED_NOTE', + dfa: 'DIGITAL_FINANCIAL_ASSET', +}; + +type CachedSnapshot = { snapshot: PortfolioDto; storedAt: number }; +type CachedMetadata = { + value: { ticker?: string; name?: string } | null; + storedAt: number; +}; + +@Injectable() +export class PortfolioService { + private readonly logger = new Logger(PortfolioService.name); + private readonly crypto: TbankCrypto; + private readonly snapshots = new Map(); + private readonly metadata = new Map(); + + 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): Promise { + const key = `${userId}:${accountId}`; + const cached = this.snapshots.get(key); + if (cached && Date.now() - cached.storedAt <= freshForMs) + return cached.snapshot; + 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(); + } + const [portfolio, positions] = await Promise.all([ + this.tbank.getPortfolio(token, accountId), + this.tbank.getPositions(token, accountId), + ]); + const snapshot = await this.normalize( + token, + portfolio.positions ?? [], + positions.money ?? [], + ); + this.snapshots.set(key, { snapshot, storedAt: Date.now() }); + return snapshot; + } catch (error) { + if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') { + throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }); + } + if ( + error instanceof ServiceUnavailableException && + cached && + Date.now() - cached.storedAt <= fallbackForMs + ) { + return { ...cached.snapshot, isStale: true }; + } + throw error; + } + } + + invalidate(userId: string): void { + for (const key of this.snapshots.keys()) + if (key.startsWith(`${userId}:`)) this.snapshots.delete(key); + } + + private async normalize( + token: string, + upstream: { + figi?: string; + instrumentUid?: string; + instrumentType?: string; + quantity?: { units?: string | number; nano?: string | number }; + currentPrice?: TbankMoney; + averagePositionPrice?: TbankMoney; + }[], + money: TbankMoney[], + ): Promise { + const positions = await Promise.all( + upstream.map((position) => this.position(token, position)), + ); + const cash = new Map>(); + for (const value of money) { + const currency = value.currency?.trim().toUpperCase(); + const amount = decimal(value); + if (!currency || isZero(amount)) continue; + cash.set(currency, add(cash.get(currency) ?? decimal({}), amount)); + } + const cashBalances = [...cash].map(([currency, value]) => ({ + currency, + value: formatDecimal(value), + })); + const totals = new Map(cash); + for (const position of positions) { + if (!position?.currentValue) continue; + const amount = decimalFromString(position.currentValue.value); + totals.set( + position.currentValue.currency, + add(totals.get(position.currentValue.currency) ?? decimal({}), amount), + ); + } + return { + lastSuccessfulAt: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'), + isStale: false, + totalsByCurrency: [...totals].map(([currency, value]) => ({ + currency, + value: formatDecimal(value), + })), + positions: positions.filter( + (position): position is PortfolioPositionDto => position !== null, + ), + cashBalances, + }; + } + + private async position( + token: string, + position: Parameters[1][number], + ): Promise { + const quantity = decimal(position.quantity ?? {}); + if (isZero(quantity)) return null; + const figi = position.figi?.trim() || null; + const instrumentUid = position.instrumentUid?.trim() || null; + const currentPrice = this.money(position.currentPrice); + const averagePurchasePrice = this.money(position.averagePositionPrice); + const currentValue = currentPrice && this.value(quantity, currentPrice); + const absoluteReturn = + currentPrice && + averagePurchasePrice && + currentPrice.currency === averagePurchasePrice.currency + ? { + currency: currentPrice.currency, + value: formatDecimal( + multiply( + subtract( + decimal(position.currentPrice!), + decimal(position.averagePositionPrice!), + ), + quantity, + ), + ), + } + : null; + const metadata = await this.instrument(token, instrumentUid, figi); + return { + figi, + displayName: + metadata?.name?.trim() || + metadata?.ticker?.trim() || + figi || + 'Unknown instrument', + type: types[position.instrumentType?.toLowerCase() ?? ''] ?? 'OTHER', + quantity: formatDecimal(quantity), + currentValue, + averagePurchasePrice, + absoluteReturn, + }; + } + + private value( + quantity: ReturnType, + price: MoneyDto, + ): MoneyDto { + return { + currency: price.currency, + value: formatDecimal(multiply(quantity, decimalFromString(price.value))), + }; + } + + private money(value: TbankMoney | undefined): MoneyDto | null { + const currency = value?.currency?.trim().toUpperCase(); + return currency && value + ? { currency, value: formatDecimal(decimal(value)) } + : null; + } + + private async instrument( + token: string, + instrumentUid: string | null, + figi: string | null, + ) { + const lookup = async ( + key: string, + fetch: () => Promise<{ ticker?: string; name?: string } | null>, + ) => { + const cached = this.metadata.get(key); + if (cached && Date.now() - cached.storedAt <= metadataForMs) + return cached.value; + try { + const value = await fetch(); + this.metadata.set(key, { value, storedAt: Date.now() }); + this.logger.debug({ + event: 'instrument_metadata_resolved', + cacheKey: key, + found: Boolean(value?.name?.trim() || value?.ticker?.trim()), + }); + return value; + } catch (error) { + this.logger.warn({ + event: 'instrument_metadata_failed', + cacheKey: key, + code: + error instanceof Error && 'response' in error + ? ((error as { response?: { code?: string } }).response?.code ?? + 'UNKNOWN') + : 'UNKNOWN', + }); + return undefined; + } + }; + if (instrumentUid) { + const byUid = await lookup(`uid:${instrumentUid}`, () => + this.tbank.getInstrumentByUid(token, instrumentUid), + ); + if (byUid) return byUid; + } + if (!figi) return null; + return lookup(`figi:${figi}`, () => + this.tbank.getInstrumentByFigi(token, figi), + ); + } +} + +function decimalFromString(value: string) { + const negative = value.startsWith('-'); + const [whole, fraction = ''] = (negative ? value.slice(1) : value).split('.'); + return { + value: (negative ? -1n : 1n) * BigInt(`${whole}${fraction}`), + scale: fraction.length, + }; +} diff --git a/src/tbank/tbank-connection.module.ts b/src/tbank/tbank-connection.module.ts index 7478f3f..0ab0746 100644 --- a/src/tbank/tbank-connection.module.ts +++ b/src/tbank/tbank-connection.module.ts @@ -1,12 +1,23 @@ import { Module } from '@nestjs/common'; import { BrokerageAccountsController } from './brokerage-accounts.controller'; import { BrokerageAccountsService } from './brokerage-accounts.service'; +import { PortfolioController } from './portfolio.controller'; +import { PortfolioService } from './portfolio.service'; import { TbankClient } from './tbank.client'; import { TbankConnectionController } from './tbank-connection.controller'; import { TbankConnectionService } from './tbank-connection.service'; @Module({ - controllers: [TbankConnectionController, BrokerageAccountsController], - providers: [TbankClient, TbankConnectionService, BrokerageAccountsService], + controllers: [ + TbankConnectionController, + BrokerageAccountsController, + PortfolioController, + ], + providers: [ + TbankClient, + TbankConnectionService, + BrokerageAccountsService, + PortfolioService, + ], }) export class TbankConnectionModule {} diff --git a/src/tbank/tbank-connection.service.spec.ts b/src/tbank/tbank-connection.service.spec.ts index 231388d..3f518ff 100644 --- a/src/tbank/tbank-connection.service.spec.ts +++ b/src/tbank/tbank-connection.service.spec.ts @@ -18,10 +18,12 @@ describe('TbankConnectionService', () => { getOrThrow: jest.fn(() => 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY='), }; const tbank = { validateToken: jest.fn() }; + const portfolio = { invalidate: jest.fn() }; const service = new TbankConnectionService( prisma as never, config as never, tbank, + portfolio as never, ); beforeEach(() => jest.clearAllMocks()); @@ -41,5 +43,6 @@ describe('TbankConnectionService', () => { expect(prisma.tbankConnection.deleteMany).toHaveBeenCalledWith({ where: { userId: 'user-1' }, }); + expect(portfolio.invalidate).toHaveBeenCalledWith('user-1'); }); }); diff --git a/src/tbank/tbank-connection.service.ts b/src/tbank/tbank-connection.service.ts index 68f354f..baca1fe 100644 --- a/src/tbank/tbank-connection.service.ts +++ b/src/tbank/tbank-connection.service.ts @@ -4,6 +4,7 @@ import { Environment } from '../config/env.validation'; import { PrismaService } from '../prisma/prisma.service'; import { TbankClient } from './tbank.client'; import { TbankCrypto } from './tbank.crypto'; +import { PortfolioService } from './portfolio.service'; @Injectable() export class TbankConnectionService { @@ -13,6 +14,7 @@ export class TbankConnectionService { private readonly prisma: PrismaService, config: ConfigService, private readonly tbank: TbankClient, + private readonly portfolio: PortfolioService, ) { this.crypto = TbankCrypto.fromBase64( config.getOrThrow('TOKEN_ENCRYPTION_KEY'), @@ -52,6 +54,7 @@ export class TbankConnectionService { connectedAt: new Date(), }, }); + this.portfolio.invalidate(userId); return { connected: true, connectedAt: connection.connectedAt.toISOString(), @@ -60,5 +63,6 @@ export class TbankConnectionService { async delete(userId: string): Promise { await this.prisma.tbankConnection.deleteMany({ where: { userId } }); + this.portfolio.invalidate(userId); } } diff --git a/src/tbank/tbank.client.ts b/src/tbank/tbank.client.ts index d1df7cc..cf24e52 100644 --- a/src/tbank/tbank.client.ts +++ b/src/tbank/tbank.client.ts @@ -18,31 +18,38 @@ import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { Environment } from '../config/env.validation'; -type GetAccounts = ( - request: { status: string }, +const requestDeadlineMs = 15_000; + +type Unary = ( + request: Request, metadata: Metadata, options: { deadline: Date }, - callback: ( - error: ServiceError | null, - response: { accounts?: TbankAccount[] }, - ) => void, + callback: (error: ServiceError | null, response: Response) => void, ) => ClientUnaryCall; -type UsersClient = Client & { getAccounts: GetAccounts }; +type TbankClientService = Client & Record>; +export type TbankDecimal = { units?: string | number; nano?: string | number }; +export type TbankMoney = TbankDecimal & { currency?: string }; export type TbankAccount = { id: string; type: string; name: string; openedDate?: { seconds?: string | number }; }; +export type TbankPortfolio = { + positions?: { + figi?: string; + instrumentUid?: string; + instrumentType?: string; + quantity?: TbankDecimal; + currentPrice?: TbankMoney; + averagePositionPrice?: TbankMoney; + }[]; +}; +export type TbankPositions = { money?: TbankMoney[] }; +export type TbankInstrument = { ticker?: string; name?: string }; type TbankNamespace = { tinkoff: { - public: { - invest: { - api: { - contract: { v1: { UsersService: unknown } }; - }; - }; - }; + public: { invest: { api: { contract: { v1: Record } } } }; }; }; @@ -92,44 +99,52 @@ class RateLimiter { export class TbankClient { private readonly logger = new Logger(TbankClient.name); private readonly queue: RateLimiter; - private readonly client: UsersClient; + private readonly users: TbankClientService; + private readonly operations: TbankClientService; + private readonly instruments: TbankClientService; constructor(config: ConfigService) { const sourceProtoRoot = join(process.cwd(), 'src/tbank/proto/contracts'); - const builtProtoRoot = join(process.cwd(), 'dist/tbank/proto/contracts'); const protoRoot = existsSync(sourceProtoRoot) ? sourceProtoRoot - : builtProtoRoot; - const definition = loadSync(join(protoRoot, 'users.proto'), { - includeDirs: [protoRoot], - keepCase: false, - longs: String, - enums: String, - defaults: true, - oneofs: true, - }); - const packageDefinition = loadPackageDefinition( - definition, - ) as unknown as TbankNamespace; - const Service = packageDefinition.tinkoff.public.invest.api.contract.v1 - .UsersService as new ( - address: string, - credentials: ChannelCredentials, - ) => UsersClient; + : join(process.cwd(), 'dist/tbank/proto/contracts'); + const definition = loadSync( + ['users.proto', 'operations.proto', 'instruments.proto'].map((file) => + join(protoRoot, file), + ), + { + includeDirs: [protoRoot], + keepCase: false, + longs: String, + enums: String, + defaults: true, + oneofs: true, + }, + ); + const services = ( + loadPackageDefinition(definition) as unknown as TbankNamespace + ).tinkoff.public.invest.api.contract.v1; const caPath = config.get('TBANK_CA_CERT_PATH'); const credentials = caPath ? ChannelCredentials.createSsl(readFileSync(caPath)) : ChannelCredentials.createSsl(); - this.client = new Service( - config.getOrThrow('TBANK_GRPC_ENDPOINT'), - credentials, - ); + const endpoint = config.getOrThrow('TBANK_GRPC_ENDPOINT'); + const create = (name: string) => + new ( + services[name] as new ( + address: string, + credentials: ChannelCredentials, + ) => TbankClientService + )(endpoint, credentials); + this.users = create('UsersService'); + this.operations = create('OperationsService'); + this.instruments = create('InstrumentsService'); this.queue = new RateLimiter( config.getOrThrow('TBANK_RATE_LIMIT_PER_SECOND'), ); this.logger.log({ event: 'client_configured', - endpoint: config.getOrThrow('TBANK_GRPC_ENDPOINT'), + endpoint, rateLimitPerSecond: config.getOrThrow( 'TBANK_RATE_LIMIT_PER_SECOND', ), @@ -141,64 +156,104 @@ export class TbankClient { await this.getOpenAccounts(token); } - async getOpenAccounts(token: string): Promise { - return this.call(token, true); + getOpenAccounts(token: string): Promise { + return this.call(token, this.users, 'getAccounts', { + status: 'ACCOUNT_STATUS_OPEN', + }).then( + (response: { accounts?: TbankAccount[] }) => response.accounts ?? [], + ); } - private async call(token: string, retry: boolean): Promise { - if (this.queue.size >= 50) { - this.logger.warn({ event: 'queue_full', waiting: this.queue.size }); + getPortfolio(token: string, accountId: string): Promise { + return this.call(token, this.operations, 'getPortfolio', { accountId }); + } + + getPositions(token: string, accountId: string): Promise { + return this.call(token, this.operations, 'getPositions', { accountId }); + } + + getInstrumentByFigi( + token: string, + figi: string, + ): Promise { + return this.call(token, this.instruments, 'getInstrumentBy', { + idType: 'INSTRUMENT_ID_TYPE_FIGI', + id: figi, + }).then( + (response: { instrument?: TbankInstrument }) => + response.instrument ?? null, + ); + } + + getInstrumentByUid( + token: string, + instrumentUid: string, + ): Promise { + return this.call(token, this.instruments, 'getInstrumentBy', { + idType: 'INSTRUMENT_ID_TYPE_UID', + id: instrumentUid, + }).then( + (response: { instrument?: TbankInstrument }) => + response.instrument ?? null, + ); + } + + private async call( + token: string, + client: TbankClientService, + method: string, + request: object, + retry = true, + ): Promise { + if (this.queue.size >= 50) throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }); - } try { - this.logger.debug({ event: 'get_accounts_started', retry: !retry }); - const accounts = await this.queue.run(() => this.request(token)); - this.logger.debug({ event: 'get_accounts_succeeded', retry: !retry }); - return accounts; + return await this.queue.run(() => + this.request(token, client, method, request), + ); } catch (error) { const code = (error as ServiceError).code; + const retryable = [ + status.UNAVAILABLE, + status.DEADLINE_EXCEEDED, + status.RESOURCE_EXHAUSTED, + ].includes(code); this.logger.warn({ - event: 'get_accounts_failed', + event: 'tbank_rpc_failed', + method, grpcCode: code, - retry: !retry, - retryable: [ - status.UNAVAILABLE, - status.DEADLINE_EXCEEDED, - status.RESOURCE_EXHAUSTED, - ].includes(code), + retryable, + ...(method === 'getInstrumentBy' + ? { + instrumentIdType: (request as { idType?: string }).idType, + instrumentId: (request as { id?: string }).id, + } + : {}), }); - if ( - retry && - [ - status.UNAVAILABLE, - status.DEADLINE_EXCEEDED, - status.RESOURCE_EXHAUSTED, - ].includes(code) - ) { - this.logger.log({ - event: 'get_accounts_retry_scheduled', - delayMs: 1000, - }); + if (retry && retryable) { await new Promise((resolve) => setTimeout(resolve, 1000)); - return this.call(token, false); + return this.call(token, client, method, request, false); } - if ([status.UNAUTHENTICATED, status.PERMISSION_DENIED].includes(code)) { + if ([status.UNAUTHENTICATED, status.PERMISSION_DENIED].includes(code)) throw new Error('INVALID_TBANK_TOKEN'); - } throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }); } } - private request(token: string): Promise { + private request( + token: string, + client: TbankClientService, + method: string, + request: object, + ): Promise { return new Promise((resolve, reject) => { const metadata = new Metadata(); metadata.set('Authorization', `Bearer ${token}`); - this.client.getAccounts( - { status: 'ACCOUNT_STATUS_OPEN' }, + client[method]( + request as never, metadata, - { deadline: new Date(Date.now() + 10_000) }, - (error, response) => - error ? reject(error) : resolve(response.accounts ?? []), + { deadline: new Date(Date.now() + requestDeadlineMs) }, + (error, response) => (error ? reject(error) : resolve(response)), ); }); } diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index b6fa4d1..bd1306e 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -121,4 +121,23 @@ describe('Authentication (e2e)', () => { expect((body as Problem).code).toBe('TBANK_NOT_CONNECTED'), ); }); + + it('guards portfolio and reports a missing connection', async () => { + const registration = await request(app.getHttpServer()) + .post('/api/v1/auth/register') + .send({ email: 'portfolio@example.com', password: 'a secure password' }) + .expect(201); + const accessToken = (registration.body as Authentication).accessToken; + + await request(app.getHttpServer()) + .get('/api/v1/brokerage-accounts/account/portfolio') + .expect(401); + await request(app.getHttpServer()) + .get('/api/v1/brokerage-accounts/account/portfolio') + .set('Authorization', `Bearer ${accessToken}`) + .expect(409) + .expect(({ body }: { body: unknown }) => + expect((body as Problem).code).toBe('TBANK_NOT_CONNECTED'), + ); + }); });