feat: add portfolio

This commit is contained in:
2026-08-13 22:44:53 +03:00
parent c0a4a93880
commit dfca11c81f
11 changed files with 771 additions and 78 deletions
+22
View File
@@ -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');
});
});
+44
View File
@@ -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}` : ''}`;
}
+24
View File
@@ -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);
}
}
+61
View File
@@ -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[];
}
+167
View File
@@ -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),
);
});
});
+283
View File
@@ -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,
};
}
+13 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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)),
);
});
}
+19
View File
@@ -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'),
);
});
});