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 { Module } from '@nestjs/common';
|
||||||
import { BrokerageAccountsController } from './brokerage-accounts.controller';
|
import { BrokerageAccountsController } from './brokerage-accounts.controller';
|
||||||
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
||||||
|
import { PortfolioController } from './portfolio.controller';
|
||||||
|
import { PortfolioService } from './portfolio.service';
|
||||||
import { TbankClient } from './tbank.client';
|
import { TbankClient } from './tbank.client';
|
||||||
import { TbankConnectionController } from './tbank-connection.controller';
|
import { TbankConnectionController } from './tbank-connection.controller';
|
||||||
import { TbankConnectionService } from './tbank-connection.service';
|
import { TbankConnectionService } from './tbank-connection.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [TbankConnectionController, BrokerageAccountsController],
|
controllers: [
|
||||||
providers: [TbankClient, TbankConnectionService, BrokerageAccountsService],
|
TbankConnectionController,
|
||||||
|
BrokerageAccountsController,
|
||||||
|
PortfolioController,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
TbankClient,
|
||||||
|
TbankConnectionService,
|
||||||
|
BrokerageAccountsService,
|
||||||
|
PortfolioService,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class TbankConnectionModule {}
|
export class TbankConnectionModule {}
|
||||||
|
|||||||
@@ -18,10 +18,12 @@ describe('TbankConnectionService', () => {
|
|||||||
getOrThrow: jest.fn(() => 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY='),
|
getOrThrow: jest.fn(() => 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY='),
|
||||||
};
|
};
|
||||||
const tbank = { validateToken: jest.fn() };
|
const tbank = { validateToken: jest.fn() };
|
||||||
|
const portfolio = { invalidate: jest.fn() };
|
||||||
const service = new TbankConnectionService(
|
const service = new TbankConnectionService(
|
||||||
prisma as never,
|
prisma as never,
|
||||||
config as never,
|
config as never,
|
||||||
tbank,
|
tbank,
|
||||||
|
portfolio as never,
|
||||||
);
|
);
|
||||||
|
|
||||||
beforeEach(() => jest.clearAllMocks());
|
beforeEach(() => jest.clearAllMocks());
|
||||||
@@ -41,5 +43,6 @@ describe('TbankConnectionService', () => {
|
|||||||
expect(prisma.tbankConnection.deleteMany).toHaveBeenCalledWith({
|
expect(prisma.tbankConnection.deleteMany).toHaveBeenCalledWith({
|
||||||
where: { userId: 'user-1' },
|
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 { PrismaService } from '../prisma/prisma.service';
|
||||||
import { TbankClient } from './tbank.client';
|
import { TbankClient } from './tbank.client';
|
||||||
import { TbankCrypto } from './tbank.crypto';
|
import { TbankCrypto } from './tbank.crypto';
|
||||||
|
import { PortfolioService } from './portfolio.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TbankConnectionService {
|
export class TbankConnectionService {
|
||||||
@@ -13,6 +14,7 @@ export class TbankConnectionService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
config: ConfigService<Environment, true>,
|
config: ConfigService<Environment, true>,
|
||||||
private readonly tbank: TbankClient,
|
private readonly tbank: TbankClient,
|
||||||
|
private readonly portfolio: PortfolioService,
|
||||||
) {
|
) {
|
||||||
this.crypto = TbankCrypto.fromBase64(
|
this.crypto = TbankCrypto.fromBase64(
|
||||||
config.getOrThrow('TOKEN_ENCRYPTION_KEY'),
|
config.getOrThrow('TOKEN_ENCRYPTION_KEY'),
|
||||||
@@ -52,6 +54,7 @@ export class TbankConnectionService {
|
|||||||
connectedAt: new Date(),
|
connectedAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
this.portfolio.invalidate(userId);
|
||||||
return {
|
return {
|
||||||
connected: true,
|
connected: true,
|
||||||
connectedAt: connection.connectedAt.toISOString(),
|
connectedAt: connection.connectedAt.toISOString(),
|
||||||
@@ -60,5 +63,6 @@ export class TbankConnectionService {
|
|||||||
|
|
||||||
async delete(userId: string): Promise<void> {
|
async delete(userId: string): Promise<void> {
|
||||||
await this.prisma.tbankConnection.deleteMany({ where: { userId } });
|
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 { join } from 'node:path';
|
||||||
import { Environment } from '../config/env.validation';
|
import { Environment } from '../config/env.validation';
|
||||||
|
|
||||||
type GetAccounts = (
|
const requestDeadlineMs = 15_000;
|
||||||
request: { status: string },
|
|
||||||
|
type Unary<Request, Response> = (
|
||||||
|
request: Request,
|
||||||
metadata: Metadata,
|
metadata: Metadata,
|
||||||
options: { deadline: Date },
|
options: { deadline: Date },
|
||||||
callback: (
|
callback: (error: ServiceError | null, response: Response) => void,
|
||||||
error: ServiceError | null,
|
|
||||||
response: { accounts?: TbankAccount[] },
|
|
||||||
) => void,
|
|
||||||
) => ClientUnaryCall;
|
) => 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 = {
|
export type TbankAccount = {
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
name: string;
|
name: string;
|
||||||
openedDate?: { seconds?: string | number };
|
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 = {
|
type TbankNamespace = {
|
||||||
tinkoff: {
|
tinkoff: {
|
||||||
public: {
|
public: { invest: { api: { contract: { v1: Record<string, unknown> } } } };
|
||||||
invest: {
|
|
||||||
api: {
|
|
||||||
contract: { v1: { UsersService: unknown } };
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -92,44 +99,52 @@ class RateLimiter {
|
|||||||
export class TbankClient {
|
export class TbankClient {
|
||||||
private readonly logger = new Logger(TbankClient.name);
|
private readonly logger = new Logger(TbankClient.name);
|
||||||
private readonly queue: RateLimiter;
|
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>) {
|
constructor(config: ConfigService<Environment, true>) {
|
||||||
const sourceProtoRoot = join(process.cwd(), 'src/tbank/proto/contracts');
|
const sourceProtoRoot = join(process.cwd(), 'src/tbank/proto/contracts');
|
||||||
const builtProtoRoot = join(process.cwd(), 'dist/tbank/proto/contracts');
|
|
||||||
const protoRoot = existsSync(sourceProtoRoot)
|
const protoRoot = existsSync(sourceProtoRoot)
|
||||||
? sourceProtoRoot
|
? sourceProtoRoot
|
||||||
: builtProtoRoot;
|
: join(process.cwd(), 'dist/tbank/proto/contracts');
|
||||||
const definition = loadSync(join(protoRoot, 'users.proto'), {
|
const definition = loadSync(
|
||||||
includeDirs: [protoRoot],
|
['users.proto', 'operations.proto', 'instruments.proto'].map((file) =>
|
||||||
keepCase: false,
|
join(protoRoot, file),
|
||||||
longs: String,
|
),
|
||||||
enums: String,
|
{
|
||||||
defaults: true,
|
includeDirs: [protoRoot],
|
||||||
oneofs: true,
|
keepCase: false,
|
||||||
});
|
longs: String,
|
||||||
const packageDefinition = loadPackageDefinition(
|
enums: String,
|
||||||
definition,
|
defaults: true,
|
||||||
) as unknown as TbankNamespace;
|
oneofs: true,
|
||||||
const Service = packageDefinition.tinkoff.public.invest.api.contract.v1
|
},
|
||||||
.UsersService as new (
|
);
|
||||||
address: string,
|
const services = (
|
||||||
credentials: ChannelCredentials,
|
loadPackageDefinition(definition) as unknown as TbankNamespace
|
||||||
) => UsersClient;
|
).tinkoff.public.invest.api.contract.v1;
|
||||||
const caPath = config.get<string | undefined>('TBANK_CA_CERT_PATH');
|
const caPath = config.get<string | undefined>('TBANK_CA_CERT_PATH');
|
||||||
const credentials = caPath
|
const credentials = caPath
|
||||||
? ChannelCredentials.createSsl(readFileSync(caPath))
|
? ChannelCredentials.createSsl(readFileSync(caPath))
|
||||||
: ChannelCredentials.createSsl();
|
: ChannelCredentials.createSsl();
|
||||||
this.client = new Service(
|
const endpoint = config.getOrThrow<string>('TBANK_GRPC_ENDPOINT');
|
||||||
config.getOrThrow<string>('TBANK_GRPC_ENDPOINT'),
|
const create = (name: string) =>
|
||||||
credentials,
|
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(
|
this.queue = new RateLimiter(
|
||||||
config.getOrThrow<number>('TBANK_RATE_LIMIT_PER_SECOND'),
|
config.getOrThrow<number>('TBANK_RATE_LIMIT_PER_SECOND'),
|
||||||
);
|
);
|
||||||
this.logger.log({
|
this.logger.log({
|
||||||
event: 'client_configured',
|
event: 'client_configured',
|
||||||
endpoint: config.getOrThrow<string>('TBANK_GRPC_ENDPOINT'),
|
endpoint,
|
||||||
rateLimitPerSecond: config.getOrThrow<number>(
|
rateLimitPerSecond: config.getOrThrow<number>(
|
||||||
'TBANK_RATE_LIMIT_PER_SECOND',
|
'TBANK_RATE_LIMIT_PER_SECOND',
|
||||||
),
|
),
|
||||||
@@ -141,64 +156,104 @@ export class TbankClient {
|
|||||||
await this.getOpenAccounts(token);
|
await this.getOpenAccounts(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getOpenAccounts(token: string): Promise<TbankAccount[]> {
|
getOpenAccounts(token: string): Promise<TbankAccount[]> {
|
||||||
return this.call(token, true);
|
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[]> {
|
getPortfolio(token: string, accountId: string): Promise<TbankPortfolio> {
|
||||||
if (this.queue.size >= 50) {
|
return this.call(token, this.operations, 'getPortfolio', { accountId });
|
||||||
this.logger.warn({ event: 'queue_full', waiting: this.queue.size });
|
}
|
||||||
|
|
||||||
|
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' });
|
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
this.logger.debug({ event: 'get_accounts_started', retry: !retry });
|
return await this.queue.run(() =>
|
||||||
const accounts = await this.queue.run(() => this.request(token));
|
this.request<Response>(token, client, method, request),
|
||||||
this.logger.debug({ event: 'get_accounts_succeeded', retry: !retry });
|
);
|
||||||
return accounts;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const code = (error as ServiceError).code;
|
const code = (error as ServiceError).code;
|
||||||
|
const retryable = [
|
||||||
|
status.UNAVAILABLE,
|
||||||
|
status.DEADLINE_EXCEEDED,
|
||||||
|
status.RESOURCE_EXHAUSTED,
|
||||||
|
].includes(code);
|
||||||
this.logger.warn({
|
this.logger.warn({
|
||||||
event: 'get_accounts_failed',
|
event: 'tbank_rpc_failed',
|
||||||
|
method,
|
||||||
grpcCode: code,
|
grpcCode: code,
|
||||||
retry: !retry,
|
retryable,
|
||||||
retryable: [
|
...(method === 'getInstrumentBy'
|
||||||
status.UNAVAILABLE,
|
? {
|
||||||
status.DEADLINE_EXCEEDED,
|
instrumentIdType: (request as { idType?: string }).idType,
|
||||||
status.RESOURCE_EXHAUSTED,
|
instrumentId: (request as { id?: string }).id,
|
||||||
].includes(code),
|
}
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
if (
|
if (retry && retryable) {
|
||||||
retry &&
|
|
||||||
[
|
|
||||||
status.UNAVAILABLE,
|
|
||||||
status.DEADLINE_EXCEEDED,
|
|
||||||
status.RESOURCE_EXHAUSTED,
|
|
||||||
].includes(code)
|
|
||||||
) {
|
|
||||||
this.logger.log({
|
|
||||||
event: 'get_accounts_retry_scheduled',
|
|
||||||
delayMs: 1000,
|
|
||||||
});
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
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 Error('INVALID_TBANK_TOKEN');
|
||||||
}
|
|
||||||
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
|
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) => {
|
return new Promise((resolve, reject) => {
|
||||||
const metadata = new Metadata();
|
const metadata = new Metadata();
|
||||||
metadata.set('Authorization', `Bearer ${token}`);
|
metadata.set('Authorization', `Bearer ${token}`);
|
||||||
this.client.getAccounts(
|
client[method](
|
||||||
{ status: 'ACCOUNT_STATUS_OPEN' },
|
request as never,
|
||||||
metadata,
|
metadata,
|
||||||
{ deadline: new Date(Date.now() + 10_000) },
|
{ deadline: new Date(Date.now() + requestDeadlineMs) },
|
||||||
(error, response) =>
|
(error, response) => (error ? reject(error) : resolve(response)),
|
||||||
error ? reject(error) : resolve(response.accounts ?? []),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,4 +121,23 @@ describe('Authentication (e2e)', () => {
|
|||||||
expect((body as Problem).code).toBe('TBANK_NOT_CONNECTED'),
|
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