feat: add operations list
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
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(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user