import { BadRequestException, ConflictException, Injectable, NotFoundException, ServiceUnavailableException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Environment } from '../config/env.validation'; import { RequestContextService } from '../common/logging/request-context.service'; import { StructuredLogger } from '../common/logging/structured-logger.service'; 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; isin?: string } | null; storedAt: number; }; @Injectable() export class PortfolioService { 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, private readonly requestContext: RequestContextService, private readonly logger: StructuredLogger, ) { this.crypto = TbankCrypto.fromBase64( config.getOrThrow('TOKEN_ENCRYPTION_KEY'), ); } async get(userId: string, accountId: string): Promise { this.requestContext.set({ userId, brokerageAccountId: accountId }); 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, isin: metadata?.isin?.trim() || null, }; } 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; isin?: 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.event('debug', 'portfolio.instrument-metadata.resolved', { found: Boolean(value?.name?.trim() || value?.ticker?.trim()), }); return value; } catch (error) { this.logger.event('warn', 'portfolio.instrument-metadata.failed', { 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, }; }