feat: add portfolio
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import { add, decimal, formatDecimal, multiply, subtract } from './decimal';
|
||||
|
||||
describe('decimal', () => {
|
||||
it('keeps signed nanos exact without floats', () => {
|
||||
expect(formatDecimal(decimal({ units: 0, nano: -500000000 }))).toBe('-0.5');
|
||||
expect(
|
||||
formatDecimal(
|
||||
add(decimal({ units: '9007199254740993' }), decimal({ nano: 1 })),
|
||||
),
|
||||
).toBe('9007199254740993.000000001');
|
||||
expect(
|
||||
formatDecimal(
|
||||
subtract(decimal({ units: 1 }), decimal({ nano: 500000000 })),
|
||||
),
|
||||
).toBe('0.5');
|
||||
expect(
|
||||
formatDecimal(
|
||||
multiply(decimal({ units: 2 }), decimal({ nano: 500000000 })),
|
||||
),
|
||||
).toBe('1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
const NANO = 1_000_000_000n;
|
||||
|
||||
export type ProtoDecimal = { units?: string | number; nano?: string | number };
|
||||
export type Decimal = { value: bigint; scale: number };
|
||||
|
||||
export function decimal(value: ProtoDecimal): Decimal {
|
||||
return {
|
||||
value: BigInt(value.units ?? 0) * NANO + BigInt(value.nano ?? 0),
|
||||
scale: 9,
|
||||
};
|
||||
}
|
||||
|
||||
export function add(left: Decimal, right: Decimal): Decimal {
|
||||
const scale = Math.max(left.scale, right.scale);
|
||||
return {
|
||||
value:
|
||||
left.value * 10n ** BigInt(scale - left.scale) +
|
||||
right.value * 10n ** BigInt(scale - right.scale),
|
||||
scale,
|
||||
};
|
||||
}
|
||||
|
||||
export function subtract(left: Decimal, right: Decimal): Decimal {
|
||||
return add(left, { value: -right.value, scale: right.scale });
|
||||
}
|
||||
|
||||
export function multiply(left: Decimal, right: Decimal): Decimal {
|
||||
return { value: left.value * right.value, scale: left.scale + right.scale };
|
||||
}
|
||||
|
||||
export function isZero(value: Decimal): boolean {
|
||||
return value.value === 0n;
|
||||
}
|
||||
|
||||
export function formatDecimal({ value, scale }: Decimal): string {
|
||||
if (value === 0n) return '0';
|
||||
const sign = value < 0n ? '-' : '';
|
||||
const digits = (value < 0n ? -value : value)
|
||||
.toString()
|
||||
.padStart(scale + 1, '0');
|
||||
const whole = digits.slice(0, -scale) || '0';
|
||||
const fraction = digits.slice(-scale).replace(/0+$/, '');
|
||||
return `${sign}${whole}${fraction ? `.${fraction}` : ''}`;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
import type { Request } from 'express';
|
||||
import { AccessPayload } from '../auth/auth.types';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { PortfolioDto } from './portfolio.dto';
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
|
||||
@ApiTags('brokerage-accounts')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('brokerage-accounts')
|
||||
export class PortfolioController {
|
||||
constructor(private readonly portfolio: PortfolioService) {}
|
||||
|
||||
@Get(':accountId/portfolio')
|
||||
@ApiOkResponse({ type: PortfolioDto })
|
||||
get(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Param('accountId') accountId: string,
|
||||
) {
|
||||
return this.portfolio.get(request.user.sub, accountId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class MoneyDto {
|
||||
@ApiProperty()
|
||||
currency: string;
|
||||
|
||||
@ApiProperty()
|
||||
value: string;
|
||||
}
|
||||
|
||||
export class PortfolioPositionDto {
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
figi: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
displayName: string;
|
||||
|
||||
@ApiProperty({
|
||||
enum: [
|
||||
'STOCK',
|
||||
'BOND',
|
||||
'FUND',
|
||||
'CURRENCY',
|
||||
'FUTURE',
|
||||
'OPTION',
|
||||
'STRUCTURED_NOTE',
|
||||
'DIGITAL_FINANCIAL_ASSET',
|
||||
'OTHER',
|
||||
],
|
||||
})
|
||||
type: string;
|
||||
|
||||
@ApiProperty()
|
||||
quantity: string;
|
||||
|
||||
@ApiProperty({ type: MoneyDto, nullable: true })
|
||||
currentValue: MoneyDto | null;
|
||||
|
||||
@ApiProperty({ type: MoneyDto, nullable: true })
|
||||
averagePurchasePrice: MoneyDto | null;
|
||||
|
||||
@ApiProperty({ type: MoneyDto, nullable: true })
|
||||
absoluteReturn: MoneyDto | null;
|
||||
}
|
||||
|
||||
export class PortfolioDto {
|
||||
@ApiProperty({ format: 'date-time' })
|
||||
lastSuccessfulAt: string;
|
||||
|
||||
@ApiProperty()
|
||||
isStale: boolean;
|
||||
|
||||
@ApiProperty({ type: MoneyDto, isArray: true })
|
||||
totalsByCurrency: MoneyDto[];
|
||||
|
||||
@ApiProperty({ type: PortfolioPositionDto, isArray: true })
|
||||
positions: PortfolioPositionDto[];
|
||||
|
||||
@ApiProperty({ type: MoneyDto, isArray: true })
|
||||
cashBalances: MoneyDto[];
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
|
||||
jest.mock('../prisma/prisma.service', () => ({
|
||||
PrismaService: class PrismaService {},
|
||||
}));
|
||||
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
import { TbankCrypto } from './tbank.crypto';
|
||||
|
||||
describe('PortfolioService', () => {
|
||||
const key = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=';
|
||||
const prisma = { tbankConnection: { findUnique: jest.fn() } };
|
||||
const config = { getOrThrow: jest.fn(() => key) };
|
||||
const tbank = {
|
||||
getOpenAccounts: jest.fn(),
|
||||
getPortfolio: jest.fn(),
|
||||
getPositions: jest.fn(),
|
||||
getInstrumentByFigi: jest.fn(),
|
||||
getInstrumentByUid: jest.fn(),
|
||||
};
|
||||
const service = new PortfolioService(
|
||||
prisma as never,
|
||||
config as never,
|
||||
tbank as never,
|
||||
);
|
||||
const crypto = TbankCrypto.fromBase64(key);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
service.invalidate('user');
|
||||
prisma.tbankConnection.findUnique.mockResolvedValue({
|
||||
encryptedToken: crypto.encrypt('token'),
|
||||
});
|
||||
tbank.getOpenAccounts.mockResolvedValue([
|
||||
{ id: 'account', type: 'ACCOUNT_TYPE_TINKOFF' },
|
||||
]);
|
||||
tbank.getPortfolio.mockResolvedValue({ positions: [] });
|
||||
tbank.getPositions.mockResolvedValue({ money: [] });
|
||||
});
|
||||
|
||||
it('requires a connection and an accessible brokerage account', async () => {
|
||||
prisma.tbankConnection.findUnique.mockResolvedValue(null);
|
||||
await expect(service.get('user', 'account')).rejects.toEqual(
|
||||
expect.any(ConflictException),
|
||||
);
|
||||
prisma.tbankConnection.findUnique.mockResolvedValue({
|
||||
encryptedToken: crypto.encrypt('token'),
|
||||
});
|
||||
tbank.getOpenAccounts.mockResolvedValue([]);
|
||||
await expect(service.get('user', 'account')).rejects.toEqual(
|
||||
expect.any(NotFoundException),
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes upstream order, exact totals, metadata fallback, and cash', async () => {
|
||||
tbank.getPortfolio.mockResolvedValue({
|
||||
positions: [
|
||||
{
|
||||
figi: 'ONE',
|
||||
instrumentType: 'share',
|
||||
quantity: { units: 2 },
|
||||
currentPrice: { currency: 'rub', units: 3 },
|
||||
averagePositionPrice: { currency: 'RUB', units: 2 },
|
||||
},
|
||||
{
|
||||
figi: 'TWO',
|
||||
instrumentType: 'new-kind',
|
||||
quantity: { nano: -500000000 },
|
||||
currentPrice: { currency: 'USD', units: 4 },
|
||||
},
|
||||
{ figi: 'ZERO', quantity: {} },
|
||||
],
|
||||
});
|
||||
tbank.getPositions.mockResolvedValue({
|
||||
money: [
|
||||
{ currency: 'RUB', units: 1 },
|
||||
{ currency: 'rub', nano: 500000000 },
|
||||
{ currency: 'USD' },
|
||||
],
|
||||
});
|
||||
tbank.getInstrumentByFigi
|
||||
.mockResolvedValueOnce({ ticker: 'ONE', name: 'Первая позиция' })
|
||||
.mockRejectedValueOnce(new Error('unavailable'));
|
||||
|
||||
await expect(service.get('user', 'account')).resolves.toMatchObject({
|
||||
positions: [
|
||||
{
|
||||
figi: 'ONE',
|
||||
displayName: 'Первая позиция',
|
||||
type: 'STOCK',
|
||||
quantity: '2',
|
||||
currentValue: { currency: 'RUB', value: '6' },
|
||||
absoluteReturn: { currency: 'RUB', value: '2' },
|
||||
},
|
||||
{
|
||||
figi: 'TWO',
|
||||
displayName: 'TWO',
|
||||
type: 'OTHER',
|
||||
quantity: '-0.5',
|
||||
currentValue: { currency: 'USD', value: '-2' },
|
||||
},
|
||||
],
|
||||
cashBalances: [{ currency: 'RUB', value: '1.5' }],
|
||||
totalsByCurrency: [
|
||||
{ currency: 'RUB', value: '7.5' },
|
||||
{ currency: 'USD', value: '-2' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('uses instrument UID before FIGI when resolving a name', async () => {
|
||||
tbank.getPortfolio.mockResolvedValue({
|
||||
positions: [
|
||||
{
|
||||
figi: 'FIGI',
|
||||
instrumentUid: 'UID',
|
||||
instrumentType: 'share',
|
||||
quantity: { units: 1 },
|
||||
},
|
||||
],
|
||||
});
|
||||
tbank.getInstrumentByUid.mockResolvedValue({ name: 'Имя по UID' });
|
||||
|
||||
await expect(service.get('user', 'account')).resolves.toMatchObject({
|
||||
positions: [{ displayName: 'Имя по UID' }],
|
||||
});
|
||||
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses fresh snapshots, falls back only for temporary errors, and clears them', async () => {
|
||||
await service.get('user', 'account');
|
||||
await service.get('user', 'account');
|
||||
expect(tbank.getOpenAccounts).toHaveBeenCalledTimes(1);
|
||||
service.invalidate('user');
|
||||
await service.get('user', 'account');
|
||||
expect(tbank.getOpenAccounts).toHaveBeenCalledTimes(2);
|
||||
const snapshots = (
|
||||
service as never as { snapshots: Map<string, { storedAt: number }> }
|
||||
).snapshots;
|
||||
snapshots.get('user:account')!.storedAt = Date.now() - 30_001;
|
||||
tbank.getOpenAccounts.mockRejectedValueOnce(
|
||||
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
||||
);
|
||||
await expect(service.get('user', 'account')).resolves.toMatchObject({
|
||||
isStale: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not mask an invalid token with a snapshot', async () => {
|
||||
await service.get('user', 'account');
|
||||
const snapshots = (
|
||||
service as never as { snapshots: Map<string, { storedAt: number }> }
|
||||
).snapshots;
|
||||
snapshots.get('user:account')!.storedAt = Date.now() - 30_001;
|
||||
tbank.getOpenAccounts.mockRejectedValueOnce(
|
||||
new Error('INVALID_TBANK_TOKEN'),
|
||||
);
|
||||
await expect(service.get('user', 'account')).rejects.toEqual(
|
||||
expect.any(BadRequestException),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Environment } from '../config/env.validation';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
add,
|
||||
decimal,
|
||||
formatDecimal,
|
||||
isZero,
|
||||
multiply,
|
||||
subtract,
|
||||
} from './decimal';
|
||||
import { MoneyDto, PortfolioDto, PortfolioPositionDto } from './portfolio.dto';
|
||||
import { TbankClient, TbankMoney } from './tbank.client';
|
||||
import { TbankCrypto } from './tbank.crypto';
|
||||
|
||||
const freshForMs = 30_000;
|
||||
const fallbackForMs = 300_000;
|
||||
const metadataForMs = 86_400_000;
|
||||
const supportedAccounts = new Set([
|
||||
'ACCOUNT_TYPE_TINKOFF',
|
||||
'ACCOUNT_TYPE_TINKOFF_IIS',
|
||||
'ACCOUNT_TYPE_INVEST_BOX',
|
||||
'ACCOUNT_TYPE_INVEST_FUND',
|
||||
]);
|
||||
const types: Record<string, string> = {
|
||||
share: 'STOCK',
|
||||
bond: 'BOND',
|
||||
etf: 'FUND',
|
||||
currency: 'CURRENCY',
|
||||
futures: 'FUTURE',
|
||||
option: 'OPTION',
|
||||
sp: 'STRUCTURED_NOTE',
|
||||
dfa: 'DIGITAL_FINANCIAL_ASSET',
|
||||
};
|
||||
|
||||
type CachedSnapshot = { snapshot: PortfolioDto; storedAt: number };
|
||||
type CachedMetadata = {
|
||||
value: { ticker?: string; name?: string } | null;
|
||||
storedAt: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PortfolioService {
|
||||
private readonly logger = new Logger(PortfolioService.name);
|
||||
private readonly crypto: TbankCrypto;
|
||||
private readonly snapshots = new Map<string, CachedSnapshot>();
|
||||
private readonly metadata = new Map<string, CachedMetadata>();
|
||||
|
||||
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): Promise<PortfolioDto> {
|
||||
const key = `${userId}:${accountId}`;
|
||||
const cached = this.snapshots.get(key);
|
||||
if (cached && Date.now() - cached.storedAt <= freshForMs)
|
||||
return cached.snapshot;
|
||||
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();
|
||||
}
|
||||
const [portfolio, positions] = await Promise.all([
|
||||
this.tbank.getPortfolio(token, accountId),
|
||||
this.tbank.getPositions(token, accountId),
|
||||
]);
|
||||
const snapshot = await this.normalize(
|
||||
token,
|
||||
portfolio.positions ?? [],
|
||||
positions.money ?? [],
|
||||
);
|
||||
this.snapshots.set(key, { snapshot, storedAt: Date.now() });
|
||||
return snapshot;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
|
||||
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
|
||||
}
|
||||
if (
|
||||
error instanceof ServiceUnavailableException &&
|
||||
cached &&
|
||||
Date.now() - cached.storedAt <= fallbackForMs
|
||||
) {
|
||||
return { ...cached.snapshot, isStale: true };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
invalidate(userId: string): void {
|
||||
for (const key of this.snapshots.keys())
|
||||
if (key.startsWith(`${userId}:`)) this.snapshots.delete(key);
|
||||
}
|
||||
|
||||
private async normalize(
|
||||
token: string,
|
||||
upstream: {
|
||||
figi?: string;
|
||||
instrumentUid?: string;
|
||||
instrumentType?: string;
|
||||
quantity?: { units?: string | number; nano?: string | number };
|
||||
currentPrice?: TbankMoney;
|
||||
averagePositionPrice?: TbankMoney;
|
||||
}[],
|
||||
money: TbankMoney[],
|
||||
): Promise<PortfolioDto> {
|
||||
const positions = await Promise.all(
|
||||
upstream.map((position) => this.position(token, position)),
|
||||
);
|
||||
const cash = new Map<string, ReturnType<typeof decimal>>();
|
||||
for (const value of money) {
|
||||
const currency = value.currency?.trim().toUpperCase();
|
||||
const amount = decimal(value);
|
||||
if (!currency || isZero(amount)) continue;
|
||||
cash.set(currency, add(cash.get(currency) ?? decimal({}), amount));
|
||||
}
|
||||
const cashBalances = [...cash].map(([currency, value]) => ({
|
||||
currency,
|
||||
value: formatDecimal(value),
|
||||
}));
|
||||
const totals = new Map(cash);
|
||||
for (const position of positions) {
|
||||
if (!position?.currentValue) continue;
|
||||
const amount = decimalFromString(position.currentValue.value);
|
||||
totals.set(
|
||||
position.currentValue.currency,
|
||||
add(totals.get(position.currentValue.currency) ?? decimal({}), amount),
|
||||
);
|
||||
}
|
||||
return {
|
||||
lastSuccessfulAt: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
||||
isStale: false,
|
||||
totalsByCurrency: [...totals].map(([currency, value]) => ({
|
||||
currency,
|
||||
value: formatDecimal(value),
|
||||
})),
|
||||
positions: positions.filter(
|
||||
(position): position is PortfolioPositionDto => position !== null,
|
||||
),
|
||||
cashBalances,
|
||||
};
|
||||
}
|
||||
|
||||
private async position(
|
||||
token: string,
|
||||
position: Parameters<PortfolioService['normalize']>[1][number],
|
||||
): Promise<PortfolioPositionDto | null> {
|
||||
const quantity = decimal(position.quantity ?? {});
|
||||
if (isZero(quantity)) return null;
|
||||
const figi = position.figi?.trim() || null;
|
||||
const instrumentUid = position.instrumentUid?.trim() || null;
|
||||
const currentPrice = this.money(position.currentPrice);
|
||||
const averagePurchasePrice = this.money(position.averagePositionPrice);
|
||||
const currentValue = currentPrice && this.value(quantity, currentPrice);
|
||||
const absoluteReturn =
|
||||
currentPrice &&
|
||||
averagePurchasePrice &&
|
||||
currentPrice.currency === averagePurchasePrice.currency
|
||||
? {
|
||||
currency: currentPrice.currency,
|
||||
value: formatDecimal(
|
||||
multiply(
|
||||
subtract(
|
||||
decimal(position.currentPrice!),
|
||||
decimal(position.averagePositionPrice!),
|
||||
),
|
||||
quantity,
|
||||
),
|
||||
),
|
||||
}
|
||||
: null;
|
||||
const metadata = await this.instrument(token, instrumentUid, figi);
|
||||
return {
|
||||
figi,
|
||||
displayName:
|
||||
metadata?.name?.trim() ||
|
||||
metadata?.ticker?.trim() ||
|
||||
figi ||
|
||||
'Unknown instrument',
|
||||
type: types[position.instrumentType?.toLowerCase() ?? ''] ?? 'OTHER',
|
||||
quantity: formatDecimal(quantity),
|
||||
currentValue,
|
||||
averagePurchasePrice,
|
||||
absoluteReturn,
|
||||
};
|
||||
}
|
||||
|
||||
private value(
|
||||
quantity: ReturnType<typeof decimal>,
|
||||
price: MoneyDto,
|
||||
): MoneyDto {
|
||||
return {
|
||||
currency: price.currency,
|
||||
value: formatDecimal(multiply(quantity, decimalFromString(price.value))),
|
||||
};
|
||||
}
|
||||
|
||||
private money(value: TbankMoney | undefined): MoneyDto | null {
|
||||
const currency = value?.currency?.trim().toUpperCase();
|
||||
return currency && value
|
||||
? { currency, value: formatDecimal(decimal(value)) }
|
||||
: null;
|
||||
}
|
||||
|
||||
private async instrument(
|
||||
token: string,
|
||||
instrumentUid: string | null,
|
||||
figi: string | null,
|
||||
) {
|
||||
const lookup = async (
|
||||
key: string,
|
||||
fetch: () => Promise<{ ticker?: string; name?: string } | null>,
|
||||
) => {
|
||||
const cached = this.metadata.get(key);
|
||||
if (cached && Date.now() - cached.storedAt <= metadataForMs)
|
||||
return cached.value;
|
||||
try {
|
||||
const value = await fetch();
|
||||
this.metadata.set(key, { value, storedAt: Date.now() });
|
||||
this.logger.debug({
|
||||
event: 'instrument_metadata_resolved',
|
||||
cacheKey: key,
|
||||
found: Boolean(value?.name?.trim() || value?.ticker?.trim()),
|
||||
});
|
||||
return value;
|
||||
} catch (error) {
|
||||
this.logger.warn({
|
||||
event: 'instrument_metadata_failed',
|
||||
cacheKey: key,
|
||||
code:
|
||||
error instanceof Error && 'response' in error
|
||||
? ((error as { response?: { code?: string } }).response?.code ??
|
||||
'UNKNOWN')
|
||||
: 'UNKNOWN',
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
if (instrumentUid) {
|
||||
const byUid = await lookup(`uid:${instrumentUid}`, () =>
|
||||
this.tbank.getInstrumentByUid(token, instrumentUid),
|
||||
);
|
||||
if (byUid) return byUid;
|
||||
}
|
||||
if (!figi) return null;
|
||||
return lookup(`figi:${figi}`, () =>
|
||||
this.tbank.getInstrumentByFigi(token, figi),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function decimalFromString(value: string) {
|
||||
const negative = value.startsWith('-');
|
||||
const [whole, fraction = ''] = (negative ? value.slice(1) : value).split('.');
|
||||
return {
|
||||
value: (negative ? -1n : 1n) * BigInt(`${whole}${fraction}`),
|
||||
scale: fraction.length,
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BrokerageAccountsController } from './brokerage-accounts.controller';
|
||||
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
||||
import { PortfolioController } from './portfolio.controller';
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
import { TbankClient } from './tbank.client';
|
||||
import { TbankConnectionController } from './tbank-connection.controller';
|
||||
import { TbankConnectionService } from './tbank-connection.service';
|
||||
|
||||
@Module({
|
||||
controllers: [TbankConnectionController, BrokerageAccountsController],
|
||||
providers: [TbankClient, TbankConnectionService, BrokerageAccountsService],
|
||||
controllers: [
|
||||
TbankConnectionController,
|
||||
BrokerageAccountsController,
|
||||
PortfolioController,
|
||||
],
|
||||
providers: [
|
||||
TbankClient,
|
||||
TbankConnectionService,
|
||||
BrokerageAccountsService,
|
||||
PortfolioService,
|
||||
],
|
||||
})
|
||||
export class TbankConnectionModule {}
|
||||
|
||||
@@ -18,10 +18,12 @@ describe('TbankConnectionService', () => {
|
||||
getOrThrow: jest.fn(() => 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY='),
|
||||
};
|
||||
const tbank = { validateToken: jest.fn() };
|
||||
const portfolio = { invalidate: jest.fn() };
|
||||
const service = new TbankConnectionService(
|
||||
prisma as never,
|
||||
config as never,
|
||||
tbank,
|
||||
portfolio as never,
|
||||
);
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
@@ -41,5 +43,6 @@ describe('TbankConnectionService', () => {
|
||||
expect(prisma.tbankConnection.deleteMany).toHaveBeenCalledWith({
|
||||
where: { userId: 'user-1' },
|
||||
});
|
||||
expect(portfolio.invalidate).toHaveBeenCalledWith('user-1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Environment } from '../config/env.validation';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { TbankClient } from './tbank.client';
|
||||
import { TbankCrypto } from './tbank.crypto';
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
|
||||
@Injectable()
|
||||
export class TbankConnectionService {
|
||||
@@ -13,6 +14,7 @@ export class TbankConnectionService {
|
||||
private readonly prisma: PrismaService,
|
||||
config: ConfigService<Environment, true>,
|
||||
private readonly tbank: TbankClient,
|
||||
private readonly portfolio: PortfolioService,
|
||||
) {
|
||||
this.crypto = TbankCrypto.fromBase64(
|
||||
config.getOrThrow('TOKEN_ENCRYPTION_KEY'),
|
||||
@@ -52,6 +54,7 @@ export class TbankConnectionService {
|
||||
connectedAt: new Date(),
|
||||
},
|
||||
});
|
||||
this.portfolio.invalidate(userId);
|
||||
return {
|
||||
connected: true,
|
||||
connectedAt: connection.connectedAt.toISOString(),
|
||||
@@ -60,5 +63,6 @@ export class TbankConnectionService {
|
||||
|
||||
async delete(userId: string): Promise<void> {
|
||||
await this.prisma.tbankConnection.deleteMany({ where: { userId } });
|
||||
this.portfolio.invalidate(userId);
|
||||
}
|
||||
}
|
||||
|
||||
+131
-76
@@ -18,31 +18,38 @@ import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { Environment } from '../config/env.validation';
|
||||
|
||||
type GetAccounts = (
|
||||
request: { status: string },
|
||||
const requestDeadlineMs = 15_000;
|
||||
|
||||
type Unary<Request, Response> = (
|
||||
request: Request,
|
||||
metadata: Metadata,
|
||||
options: { deadline: Date },
|
||||
callback: (
|
||||
error: ServiceError | null,
|
||||
response: { accounts?: TbankAccount[] },
|
||||
) => void,
|
||||
callback: (error: ServiceError | null, response: Response) => void,
|
||||
) => ClientUnaryCall;
|
||||
type UsersClient = Client & { getAccounts: GetAccounts };
|
||||
type TbankClientService = Client & Record<string, Unary<never, never>>;
|
||||
export type TbankDecimal = { units?: string | number; nano?: string | number };
|
||||
export type TbankMoney = TbankDecimal & { currency?: string };
|
||||
export type TbankAccount = {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
openedDate?: { seconds?: string | number };
|
||||
};
|
||||
export type TbankPortfolio = {
|
||||
positions?: {
|
||||
figi?: string;
|
||||
instrumentUid?: string;
|
||||
instrumentType?: string;
|
||||
quantity?: TbankDecimal;
|
||||
currentPrice?: TbankMoney;
|
||||
averagePositionPrice?: TbankMoney;
|
||||
}[];
|
||||
};
|
||||
export type TbankPositions = { money?: TbankMoney[] };
|
||||
export type TbankInstrument = { ticker?: string; name?: string };
|
||||
type TbankNamespace = {
|
||||
tinkoff: {
|
||||
public: {
|
||||
invest: {
|
||||
api: {
|
||||
contract: { v1: { UsersService: unknown } };
|
||||
};
|
||||
};
|
||||
};
|
||||
public: { invest: { api: { contract: { v1: Record<string, unknown> } } } };
|
||||
};
|
||||
};
|
||||
|
||||
@@ -92,44 +99,52 @@ class RateLimiter {
|
||||
export class TbankClient {
|
||||
private readonly logger = new Logger(TbankClient.name);
|
||||
private readonly queue: RateLimiter;
|
||||
private readonly client: UsersClient;
|
||||
private readonly users: TbankClientService;
|
||||
private readonly operations: TbankClientService;
|
||||
private readonly instruments: TbankClientService;
|
||||
|
||||
constructor(config: ConfigService<Environment, true>) {
|
||||
const sourceProtoRoot = join(process.cwd(), 'src/tbank/proto/contracts');
|
||||
const builtProtoRoot = join(process.cwd(), 'dist/tbank/proto/contracts');
|
||||
const protoRoot = existsSync(sourceProtoRoot)
|
||||
? sourceProtoRoot
|
||||
: builtProtoRoot;
|
||||
const definition = loadSync(join(protoRoot, 'users.proto'), {
|
||||
includeDirs: [protoRoot],
|
||||
keepCase: false,
|
||||
longs: String,
|
||||
enums: String,
|
||||
defaults: true,
|
||||
oneofs: true,
|
||||
});
|
||||
const packageDefinition = loadPackageDefinition(
|
||||
definition,
|
||||
) as unknown as TbankNamespace;
|
||||
const Service = packageDefinition.tinkoff.public.invest.api.contract.v1
|
||||
.UsersService as new (
|
||||
address: string,
|
||||
credentials: ChannelCredentials,
|
||||
) => UsersClient;
|
||||
: join(process.cwd(), 'dist/tbank/proto/contracts');
|
||||
const definition = loadSync(
|
||||
['users.proto', 'operations.proto', 'instruments.proto'].map((file) =>
|
||||
join(protoRoot, file),
|
||||
),
|
||||
{
|
||||
includeDirs: [protoRoot],
|
||||
keepCase: false,
|
||||
longs: String,
|
||||
enums: String,
|
||||
defaults: true,
|
||||
oneofs: true,
|
||||
},
|
||||
);
|
||||
const services = (
|
||||
loadPackageDefinition(definition) as unknown as TbankNamespace
|
||||
).tinkoff.public.invest.api.contract.v1;
|
||||
const caPath = config.get<string | undefined>('TBANK_CA_CERT_PATH');
|
||||
const credentials = caPath
|
||||
? ChannelCredentials.createSsl(readFileSync(caPath))
|
||||
: ChannelCredentials.createSsl();
|
||||
this.client = new Service(
|
||||
config.getOrThrow<string>('TBANK_GRPC_ENDPOINT'),
|
||||
credentials,
|
||||
);
|
||||
const endpoint = config.getOrThrow<string>('TBANK_GRPC_ENDPOINT');
|
||||
const create = (name: string) =>
|
||||
new (
|
||||
services[name] as new (
|
||||
address: string,
|
||||
credentials: ChannelCredentials,
|
||||
) => TbankClientService
|
||||
)(endpoint, credentials);
|
||||
this.users = create('UsersService');
|
||||
this.operations = create('OperationsService');
|
||||
this.instruments = create('InstrumentsService');
|
||||
this.queue = new RateLimiter(
|
||||
config.getOrThrow<number>('TBANK_RATE_LIMIT_PER_SECOND'),
|
||||
);
|
||||
this.logger.log({
|
||||
event: 'client_configured',
|
||||
endpoint: config.getOrThrow<string>('TBANK_GRPC_ENDPOINT'),
|
||||
endpoint,
|
||||
rateLimitPerSecond: config.getOrThrow<number>(
|
||||
'TBANK_RATE_LIMIT_PER_SECOND',
|
||||
),
|
||||
@@ -141,64 +156,104 @@ export class TbankClient {
|
||||
await this.getOpenAccounts(token);
|
||||
}
|
||||
|
||||
async getOpenAccounts(token: string): Promise<TbankAccount[]> {
|
||||
return this.call(token, true);
|
||||
getOpenAccounts(token: string): Promise<TbankAccount[]> {
|
||||
return this.call(token, this.users, 'getAccounts', {
|
||||
status: 'ACCOUNT_STATUS_OPEN',
|
||||
}).then(
|
||||
(response: { accounts?: TbankAccount[] }) => response.accounts ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
private async call(token: string, retry: boolean): Promise<TbankAccount[]> {
|
||||
if (this.queue.size >= 50) {
|
||||
this.logger.warn({ event: 'queue_full', waiting: this.queue.size });
|
||||
getPortfolio(token: string, accountId: string): Promise<TbankPortfolio> {
|
||||
return this.call(token, this.operations, 'getPortfolio', { accountId });
|
||||
}
|
||||
|
||||
getPositions(token: string, accountId: string): Promise<TbankPositions> {
|
||||
return this.call(token, this.operations, 'getPositions', { accountId });
|
||||
}
|
||||
|
||||
getInstrumentByFigi(
|
||||
token: string,
|
||||
figi: string,
|
||||
): Promise<TbankInstrument | null> {
|
||||
return this.call(token, this.instruments, 'getInstrumentBy', {
|
||||
idType: 'INSTRUMENT_ID_TYPE_FIGI',
|
||||
id: figi,
|
||||
}).then(
|
||||
(response: { instrument?: TbankInstrument }) =>
|
||||
response.instrument ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
getInstrumentByUid(
|
||||
token: string,
|
||||
instrumentUid: string,
|
||||
): Promise<TbankInstrument | null> {
|
||||
return this.call(token, this.instruments, 'getInstrumentBy', {
|
||||
idType: 'INSTRUMENT_ID_TYPE_UID',
|
||||
id: instrumentUid,
|
||||
}).then(
|
||||
(response: { instrument?: TbankInstrument }) =>
|
||||
response.instrument ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
private async call<Response>(
|
||||
token: string,
|
||||
client: TbankClientService,
|
||||
method: string,
|
||||
request: object,
|
||||
retry = true,
|
||||
): Promise<Response> {
|
||||
if (this.queue.size >= 50)
|
||||
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
|
||||
}
|
||||
try {
|
||||
this.logger.debug({ event: 'get_accounts_started', retry: !retry });
|
||||
const accounts = await this.queue.run(() => this.request(token));
|
||||
this.logger.debug({ event: 'get_accounts_succeeded', retry: !retry });
|
||||
return accounts;
|
||||
return await this.queue.run(() =>
|
||||
this.request<Response>(token, client, method, request),
|
||||
);
|
||||
} catch (error) {
|
||||
const code = (error as ServiceError).code;
|
||||
const retryable = [
|
||||
status.UNAVAILABLE,
|
||||
status.DEADLINE_EXCEEDED,
|
||||
status.RESOURCE_EXHAUSTED,
|
||||
].includes(code);
|
||||
this.logger.warn({
|
||||
event: 'get_accounts_failed',
|
||||
event: 'tbank_rpc_failed',
|
||||
method,
|
||||
grpcCode: code,
|
||||
retry: !retry,
|
||||
retryable: [
|
||||
status.UNAVAILABLE,
|
||||
status.DEADLINE_EXCEEDED,
|
||||
status.RESOURCE_EXHAUSTED,
|
||||
].includes(code),
|
||||
retryable,
|
||||
...(method === 'getInstrumentBy'
|
||||
? {
|
||||
instrumentIdType: (request as { idType?: string }).idType,
|
||||
instrumentId: (request as { id?: string }).id,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
if (
|
||||
retry &&
|
||||
[
|
||||
status.UNAVAILABLE,
|
||||
status.DEADLINE_EXCEEDED,
|
||||
status.RESOURCE_EXHAUSTED,
|
||||
].includes(code)
|
||||
) {
|
||||
this.logger.log({
|
||||
event: 'get_accounts_retry_scheduled',
|
||||
delayMs: 1000,
|
||||
});
|
||||
if (retry && retryable) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return this.call(token, false);
|
||||
return this.call(token, client, method, request, false);
|
||||
}
|
||||
if ([status.UNAUTHENTICATED, status.PERMISSION_DENIED].includes(code)) {
|
||||
if ([status.UNAUTHENTICATED, status.PERMISSION_DENIED].includes(code))
|
||||
throw new Error('INVALID_TBANK_TOKEN');
|
||||
}
|
||||
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
|
||||
}
|
||||
}
|
||||
|
||||
private request(token: string): Promise<TbankAccount[]> {
|
||||
private request<Response>(
|
||||
token: string,
|
||||
client: TbankClientService,
|
||||
method: string,
|
||||
request: object,
|
||||
): Promise<Response> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const metadata = new Metadata();
|
||||
metadata.set('Authorization', `Bearer ${token}`);
|
||||
this.client.getAccounts(
|
||||
{ status: 'ACCOUNT_STATUS_OPEN' },
|
||||
client[method](
|
||||
request as never,
|
||||
metadata,
|
||||
{ deadline: new Date(Date.now() + 10_000) },
|
||||
(error, response) =>
|
||||
error ? reject(error) : resolve(response.accounts ?? []),
|
||||
{ deadline: new Date(Date.now() + requestDeadlineMs) },
|
||||
(error, response) => (error ? reject(error) : resolve(response)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -121,4 +121,23 @@ describe('Authentication (e2e)', () => {
|
||||
expect((body as Problem).code).toBe('TBANK_NOT_CONNECTED'),
|
||||
);
|
||||
});
|
||||
|
||||
it('guards portfolio and reports a missing connection', async () => {
|
||||
const registration = await request(app.getHttpServer())
|
||||
.post('/api/v1/auth/register')
|
||||
.send({ email: 'portfolio@example.com', password: 'a secure password' })
|
||||
.expect(201);
|
||||
const accessToken = (registration.body as Authentication).accessToken;
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/v1/brokerage-accounts/account/portfolio')
|
||||
.expect(401);
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/v1/brokerage-accounts/account/portfolio')
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.expect(409)
|
||||
.expect(({ body }: { body: unknown }) =>
|
||||
expect((body as Problem).code).toBe('TBANK_NOT_CONNECTED'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user