import { BadRequestException, ConflictException, Injectable, NotFoundException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { LRUCache } from 'lru-cache'; import { RequestContextService } from '../common/logging/request-context.service'; 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 maxCachedPages = 100; 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 LRUCache({ max: maxCachedPages, ttl: freshForMs, ttlAutopurge: true, }); constructor( private readonly prisma: PrismaService, config: ConfigService, private readonly tbank: TbankClient, private readonly requestContext: RequestContextService, ) { this.crypto = TbankCrypto.fromBase64( config.getOrThrow('TOKEN_ENCRYPTION_KEY'), ); } async get( userId: string, accountId: string, query: BrokerageOperationsQueryDto, ): Promise { this.requestContext.set({ userId, brokerageAccountId: accountId }); const range = this.range(query); 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 key = cacheKey(userId, accountId, query, range); const cached = this.pages.get(key); if (cached) return cached; if (range.from > range.to) { const page = emptyPage(accountId); this.pages.set(key, page); 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); 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 cacheKey( userId: string, accountId: string, query: BrokerageOperationsQueryDto, range: { from: Date; to: Date }, ): string { const categories = [...new Set(query.categories ?? [])].sort(); const end = query.to ? range.to.toISOString() : Math.floor(range.to.valueOf() / freshForMs); return JSON.stringify({ userId, accountId, from: range.from.toISOString(), to: end, categories, cursor: query.cursor ?? null, limit: query.limit ?? 100, }); } 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(), }; }