244 lines
7.0 KiB
TypeScript
244 lines
7.0 KiB
TypeScript
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<string, BrokerageOperationsPageDto>({
|
|
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<BrokerageOperationsPageDto> {
|
|
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<BrokerageOperationsPageDto[]> {
|
|
const pages: BrokerageOperationsPageDto[] = [];
|
|
const seen = new Set<string>();
|
|
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<BrokerageOperationsPageDto> {
|
|
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<string, Promise<string | null>>();
|
|
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<string, Promise<string | null>>,
|
|
): Promise<string | null> {
|
|
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(),
|
|
};
|
|
}
|