import { Injectable } 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 { TbankAccessService } from './tbank-access.service'; import { TbankClient } from './tbank.client'; 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, ) {} 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, async ({ token }) => { 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; }); } 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(), }; }