import { Injectable, ServiceUnavailableException } from '@nestjs/common'; import { LRUCache } from 'lru-cache'; import { RequestContextService } from '../common/logging/request-context.service'; import { BrokerageOperationsPageDto, BrokerageOperationsQueryDto, } from './brokerage-operation.dto'; import { mapBrokerageOperation, operationCategory, } from './brokerage-operation.mapper'; import { InstrumentMetadataService } from './instrument-metadata.service'; import { TbankAccessService } from './tbank-access.service'; import { TbankClient } from './tbank.client'; import { DateOnlyRange, dateOnlyBounds } from './income-period'; const freshForMs = 30_000; const maxCachedPages = 100; @Injectable() export class BrokerageOperationsService { private readonly pages = new LRUCache({ max: maxCachedPages, ttl: freshForMs, ttlAutopurge: true, }); constructor( private readonly access: TbankAccessService, private readonly tbank: TbankClient, private readonly requestContext: RequestContextService, private readonly metadata: InstrumentMetadataService, ) {} async get( userId: string, accountId: string, query: BrokerageOperationsQueryDto, ): Promise { this.requestContext.set({ userId, brokerageAccountId: accountId }); const range = this.range(query); return this.access.withAccount(userId, accountId, ({ token, generation }) => this.page(token, generation, userId, accountId, query, range), ); } async collectRanges( userId: string, accountId: string, ranges: | DateOnlyRange[] | ((accountOpenedAt: Date | undefined) => DateOnlyRange[]), ): Promise<{ accountOpenedAt: Date | undefined; pages: BrokerageOperationsPageDto[][]; }> { this.requestContext.set({ userId, brokerageAccountId: accountId }); return this.access.withAccount( userId, accountId, async ({ token, generation, account }) => { const accountOpenedAt = account.openedDate?.seconds ? new Date(Number(account.openedDate.seconds) * 1000) : undefined; const selected = typeof ranges === 'function' ? ranges(accountOpenedAt) : ranges; return { accountOpenedAt, pages: await Promise.all( selected.map((range) => this.collectRange(token, generation, userId, accountId, range), ), ), }; }, ); } private async collectRange( token: string, generation: string, userId: string, accountId: string, range: DateOnlyRange, ): Promise { const pages: BrokerageOperationsPageDto[] = []; const seen = new Set(); let cursor: string | undefined; do { const page = await this.page( token, generation, userId, accountId, { from: range.from, cursor, limit: 1000, categories: ['income'], }, dateOnlyBounds(range), ); pages.push(page); if (!page.hasNext) return pages; if (!page.nextCursor || seen.has(page.nextCursor)) throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }); seen.add(page.nextCursor); cursor = page.nextCursor; } while (cursor); return pages; } private async page( token: string, generation: string, userId: string, accountId: string, query: BrokerageOperationsQueryDto, range: { from: Date; to: Date }, ): Promise { const key = cacheKey(generation, 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 items = (response.items ?? []).filter( (item) => !query.categories?.length || query.categories.includes(operationCategory(item.type)), ); const logoNames = new Map>(); const page = { accountId, items: await Promise.all( items.map(async (item) => ({ ...mapBrokerageOperation(item), logoName: await this.logoName( token, item.instrumentUid, item.figi, logoNames, ), })), ), nextCursor: response.nextCursor || null, hasNext: Boolean(response.hasNext), observedAt: new Date().toISOString(), }; this.pages.set(key, page); return page; } 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, }; } private async logoName( token: string, instrumentUid: string | undefined, figi: string | undefined, logoNames: Map>, ): Promise { const uid = instrumentUid?.trim() || null; const instrumentFigi = figi?.trim() || null; const key = uid || instrumentFigi ? JSON.stringify({ uid, figi: instrumentFigi }) : null; if (!key) return Promise.resolve(null); const existing = logoNames.get(key); if (existing) return existing; const logoName = this.metadata .resolve(token, uid, instrumentFigi) .then((instrument) => instrument?.brand?.logoName?.trim() || null); logoNames.set(key, logoName); return logoName; } } function cacheKey( generation: string, 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({ generation, 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(), }; }