Files
backend/src/tbank/brokerage-operations.service.ts
T
2026-08-14 13:43:02 +03:00

144 lines
4.1 KiB
TypeScript

import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
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 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 Map<
string,
{ page: BrokerageOperationsPageDto; storedAt: number }
>();
constructor(
private readonly prisma: PrismaService,
config: ConfigService<Environment, true>,
private readonly tbank: TbankClient,
) {
this.crypto = TbankCrypto.fromBase64(
config.getOrThrow('TOKEN_ENCRYPTION_KEY'),
);
}
async get(
userId: string,
accountId: string,
query: BrokerageOperationsQueryDto,
): Promise<BrokerageOperationsPageDto> {
const range = this.range(query);
const key = JSON.stringify({ userId, accountId, ...query, ...range });
const cached = this.pages.get(key);
if (cached && Date.now() - cached.storedAt <= freshForMs)
return cached.page;
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();
if (range.from > range.to) {
const page = emptyPage(accountId);
this.pages.set(key, { page, storedAt: Date.now() });
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, storedAt: Date.now() });
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 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(),
};
}