feat(tbank): add brokerage account summaries
This commit is contained in:
@@ -1,4 +1,10 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { MoneyDto } from './portfolio.dto';
|
||||||
|
|
||||||
|
export class PortfolioSummaryDto {
|
||||||
|
@ApiProperty({ type: MoneyDto, isArray: true })
|
||||||
|
totalsByCurrency: MoneyDto[];
|
||||||
|
}
|
||||||
|
|
||||||
export class BrokerageAccountDto {
|
export class BrokerageAccountDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@@ -20,4 +26,7 @@ export class BrokerageAccountDto {
|
|||||||
|
|
||||||
@ApiProperty({ type: String, nullable: true, format: 'date' })
|
@ApiProperty({ type: String, nullable: true, format: 'date' })
|
||||||
openedOn: string | null;
|
openedOn: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ type: PortfolioSummaryDto, nullable: true })
|
||||||
|
portfolioSummary?: PortfolioSummaryDto | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,16 +16,24 @@ describe('BrokerageAccountsService', () => {
|
|||||||
const encryptionKey = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=';
|
const encryptionKey = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=';
|
||||||
const prisma = { tbankConnection: { findUnique: jest.fn() } };
|
const prisma = { tbankConnection: { findUnique: jest.fn() } };
|
||||||
const config = { getOrThrow: jest.fn(() => encryptionKey) };
|
const config = { getOrThrow: jest.fn(() => encryptionKey) };
|
||||||
const tbank = { getOpenAccounts: jest.fn() };
|
const tbank = {
|
||||||
|
getOpenAccounts: jest.fn(),
|
||||||
|
getPortfolio: jest.fn(),
|
||||||
|
getPositions: jest.fn(),
|
||||||
|
};
|
||||||
const tokens = new TbankTokenService(config as never);
|
const tokens = new TbankTokenService(config as never);
|
||||||
const access = new TbankAccessService(
|
const access = new TbankAccessService(
|
||||||
prisma as never,
|
prisma as never,
|
||||||
tokens,
|
tokens,
|
||||||
tbank as never,
|
tbank as never,
|
||||||
);
|
);
|
||||||
const service = new BrokerageAccountsService(access);
|
const service = new BrokerageAccountsService(access, tbank as never);
|
||||||
|
|
||||||
beforeEach(() => jest.clearAllMocks());
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
tbank.getPortfolio.mockResolvedValue({ positions: [] });
|
||||||
|
tbank.getPositions.mockResolvedValue({ money: [] });
|
||||||
|
});
|
||||||
|
|
||||||
it('does not call T-Bank without a connection', async () => {
|
it('does not call T-Bank without a connection', async () => {
|
||||||
prisma.tbankConnection.findUnique.mockResolvedValue(null);
|
prisma.tbankConnection.findUnique.mockResolvedValue(null);
|
||||||
@@ -65,16 +73,102 @@ describe('BrokerageAccountsService', () => {
|
|||||||
type: 'BROKERAGE',
|
type: 'BROKERAGE',
|
||||||
name: 'Основной',
|
name: 'Основной',
|
||||||
openedOn: '2024-08-13',
|
openedOn: '2024-08-13',
|
||||||
|
portfolioSummary: { totalsByCurrency: [] },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'iis',
|
id: 'iis',
|
||||||
type: 'INDIVIDUAL_INVESTMENT',
|
type: 'INDIVIDUAL_INVESTMENT',
|
||||||
name: 'ИИС',
|
name: 'ИИС',
|
||||||
openedOn: null,
|
openedOn: null,
|
||||||
|
portfolioSummary: { totalsByCurrency: [] },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps upstream order and adds exact summaries without loading metadata', async () => {
|
||||||
|
prisma.tbankConnection.findUnique.mockResolvedValue({
|
||||||
|
encryptedToken: tokens.encrypt('token'),
|
||||||
|
});
|
||||||
|
tbank.getOpenAccounts.mockResolvedValue([
|
||||||
|
{ id: 'first', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Первый' },
|
||||||
|
{ id: 'second', type: 'ACCOUNT_TYPE_TINKOFF_IIS', name: 'Второй' },
|
||||||
|
]);
|
||||||
|
tbank.getPortfolio
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
quantity: { units: 2 },
|
||||||
|
currentPrice: { currency: 'rub', units: 3 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({ positions: [] });
|
||||||
|
tbank.getPositions
|
||||||
|
.mockResolvedValueOnce({ money: [{ currency: 'RUB', nano: 500000000 }] })
|
||||||
|
.mockResolvedValueOnce({ money: [{ currency: 'USD', units: 4 }] });
|
||||||
|
|
||||||
|
await expect(service.list('user-1')).resolves.toEqual([
|
||||||
|
{
|
||||||
|
id: 'first',
|
||||||
|
type: 'BROKERAGE',
|
||||||
|
name: 'Первый',
|
||||||
|
openedOn: null,
|
||||||
|
portfolioSummary: {
|
||||||
|
totalsByCurrency: [{ currency: 'RUB', value: '6.5' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'second',
|
||||||
|
type: 'INDIVIDUAL_INVESTMENT',
|
||||||
|
name: 'Второй',
|
||||||
|
openedOn: null,
|
||||||
|
portfolioSummary: {
|
||||||
|
totalsByCurrency: [{ currency: 'USD', value: '4' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(tbank.getPortfolio).toHaveBeenCalledWith('token', 'first');
|
||||||
|
expect(tbank.getPositions).toHaveBeenCalledWith('token', 'second');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('localizes an unavailable summary without suppressing another account', async () => {
|
||||||
|
prisma.tbankConnection.findUnique.mockResolvedValue({
|
||||||
|
encryptedToken: tokens.encrypt('token'),
|
||||||
|
});
|
||||||
|
tbank.getOpenAccounts.mockResolvedValue([
|
||||||
|
{ id: 'unavailable', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Первый' },
|
||||||
|
{ id: 'available', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Второй' },
|
||||||
|
]);
|
||||||
|
tbank.getPortfolio
|
||||||
|
.mockRejectedValueOnce(
|
||||||
|
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce({ positions: [] });
|
||||||
|
tbank.getPositions.mockResolvedValue({ money: [] });
|
||||||
|
|
||||||
|
await expect(service.list('user-1')).resolves.toMatchObject([
|
||||||
|
{ id: 'unavailable', portfolioSummary: null },
|
||||||
|
{ id: 'available', portfolioSummary: { totalsByCurrency: [] } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mask an invalid token when the other summary input is unavailable', async () => {
|
||||||
|
prisma.tbankConnection.findUnique.mockResolvedValue({
|
||||||
|
encryptedToken: tokens.encrypt('token'),
|
||||||
|
});
|
||||||
|
tbank.getOpenAccounts.mockResolvedValue([
|
||||||
|
{ id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Основной' },
|
||||||
|
]);
|
||||||
|
tbank.getPortfolio.mockRejectedValue(
|
||||||
|
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
||||||
|
);
|
||||||
|
tbank.getPositions.mockRejectedValue(new Error('INVALID_TBANK_TOKEN'));
|
||||||
|
|
||||||
|
await expect(service.list('user-1')).rejects.toEqual(
|
||||||
|
expect.any(BadRequestException),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('retains the connection when T-Bank rejects its token', async () => {
|
it('retains the connection when T-Bank rejects its token', async () => {
|
||||||
prisma.tbankConnection.findUnique.mockResolvedValue({
|
prisma.tbankConnection.findUnique.mockResolvedValue({
|
||||||
encryptedToken: tokens.encrypt('token'),
|
encryptedToken: tokens.encrypt('token'),
|
||||||
|
|||||||
@@ -1,12 +1,66 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ServiceUnavailableException } from '@nestjs/common';
|
||||||
import { BrokerageAccountDto } from './brokerage-account.dto';
|
import { BrokerageAccountDto } from './brokerage-account.dto';
|
||||||
|
import { totalsByCurrency } from './portfolio-totals';
|
||||||
import { TbankAccessService } from './tbank-access.service';
|
import { TbankAccessService } from './tbank-access.service';
|
||||||
|
import { TbankClient } from './tbank.client';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BrokerageAccountsService {
|
export class BrokerageAccountsService {
|
||||||
constructor(private readonly access: TbankAccessService) {}
|
constructor(
|
||||||
|
private readonly access: TbankAccessService,
|
||||||
|
private readonly tbank: TbankClient,
|
||||||
|
) {}
|
||||||
|
|
||||||
async list(userId: string): Promise<BrokerageAccountDto[]> {
|
async list(userId: string): Promise<BrokerageAccountDto[]> {
|
||||||
return this.access.listSupportedAccounts(userId);
|
return this.access.withSupportedAccounts(
|
||||||
|
userId,
|
||||||
|
async ({ token, accounts }) =>
|
||||||
|
Promise.all(
|
||||||
|
accounts.map(async (account) => {
|
||||||
|
const [portfolioResult, positionsResult] = await Promise.allSettled(
|
||||||
|
[
|
||||||
|
this.tbank.getPortfolio(token, account.id),
|
||||||
|
this.tbank.getPositions(token, account.id),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
portfolioResult.status === 'rejected' &&
|
||||||
|
!isTbankUnavailable(portfolioResult.reason)
|
||||||
|
)
|
||||||
|
throw portfolioResult.reason;
|
||||||
|
if (
|
||||||
|
positionsResult.status === 'rejected' &&
|
||||||
|
!isTbankUnavailable(positionsResult.reason)
|
||||||
|
)
|
||||||
|
throw positionsResult.reason;
|
||||||
|
if (
|
||||||
|
portfolioResult.status === 'rejected' ||
|
||||||
|
positionsResult.status === 'rejected'
|
||||||
|
)
|
||||||
|
return { ...account, portfolioSummary: null };
|
||||||
|
return {
|
||||||
|
...account,
|
||||||
|
portfolioSummary: {
|
||||||
|
totalsByCurrency: totalsByCurrency(
|
||||||
|
portfolioResult.value.positions ?? [],
|
||||||
|
positionsResult.value.money ?? [],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTbankUnavailable(error: unknown): boolean {
|
||||||
|
if (!(error instanceof ServiceUnavailableException)) return false;
|
||||||
|
const response = error.getResponse();
|
||||||
|
return (
|
||||||
|
typeof response === 'object' &&
|
||||||
|
response !== null &&
|
||||||
|
'code' in response &&
|
||||||
|
response.code === 'TBANK_UNAVAILABLE'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { add, decimal, formatDecimal, isZero, multiply } from './decimal';
|
||||||
|
import { MoneyDto } from './portfolio.dto';
|
||||||
|
import { TbankPortfolio, TbankPositions } from './tbank.client';
|
||||||
|
|
||||||
|
function totalsByCurrency(
|
||||||
|
positions: NonNullable<TbankPortfolio['positions']>,
|
||||||
|
money: NonNullable<TbankPositions['money']>,
|
||||||
|
): MoneyDto[] {
|
||||||
|
const totals = new Map<string, ReturnType<typeof decimal>>();
|
||||||
|
const addTotal = (
|
||||||
|
currency: string | undefined,
|
||||||
|
amount: ReturnType<typeof decimal>,
|
||||||
|
) => {
|
||||||
|
const normalizedCurrency = currency?.trim().toUpperCase();
|
||||||
|
if (!normalizedCurrency || isZero(amount)) return;
|
||||||
|
totals.set(
|
||||||
|
normalizedCurrency,
|
||||||
|
add(totals.get(normalizedCurrency) ?? decimal({}), amount),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const value of money) addTotal(value.currency, decimal(value));
|
||||||
|
for (const position of positions) {
|
||||||
|
const price = position.currentPrice;
|
||||||
|
if (!price) continue;
|
||||||
|
addTotal(
|
||||||
|
price.currency,
|
||||||
|
multiply(decimal(position.quantity ?? {}), decimal(price)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...totals]
|
||||||
|
.filter(([, value]) => !isZero(value))
|
||||||
|
.map(([currency, value]) => ({ currency, value: formatDecimal(value) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export { totalsByCurrency };
|
||||||
@@ -127,6 +127,29 @@ describe('PortfolioService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('excludes zero and currency-less values while keeping an empty portfolio available', async () => {
|
||||||
|
tbank.getPortfolio.mockResolvedValue({
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
quantity: { units: 1 },
|
||||||
|
currentPrice: { currency: 'RUB' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
quantity: { units: 1 },
|
||||||
|
currentPrice: { units: 10 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
tbank.getPositions.mockResolvedValue({
|
||||||
|
money: [{ currency: 'USD' }, { units: 2 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.get('user', 'account')).resolves.toMatchObject({
|
||||||
|
totalsByCurrency: [],
|
||||||
|
cashBalances: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('uses instrument UID before FIGI when resolving a name', async () => {
|
it('uses instrument UID before FIGI when resolving a name', async () => {
|
||||||
tbank.getPortfolio.mockResolvedValue({
|
tbank.getPortfolio.mockResolvedValue({
|
||||||
positions: [
|
positions: [
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
multiply,
|
multiply,
|
||||||
subtract,
|
subtract,
|
||||||
} from './decimal';
|
} from './decimal';
|
||||||
|
import { totalsByCurrency } from './portfolio-totals';
|
||||||
import { MoneyDto, PortfolioDto, PortfolioPositionDto } from './portfolio.dto';
|
import { MoneyDto, PortfolioDto, PortfolioPositionDto } from './portfolio.dto';
|
||||||
import { TbankAccessService } from './tbank-access.service';
|
import { TbankAccessService } from './tbank-access.service';
|
||||||
import { InstrumentMetadataService } from './instrument-metadata.service';
|
import { InstrumentMetadataService } from './instrument-metadata.service';
|
||||||
@@ -106,22 +107,10 @@ export class PortfolioService {
|
|||||||
currency,
|
currency,
|
||||||
value: formatDecimal(value),
|
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 {
|
return {
|
||||||
lastSuccessfulAt: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
lastSuccessfulAt: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
||||||
isStale: false,
|
isStale: false,
|
||||||
totalsByCurrency: [...totals].map(([currency, value]) => ({
|
totalsByCurrency: totalsByCurrency(upstream, money),
|
||||||
currency,
|
|
||||||
value: formatDecimal(value),
|
|
||||||
})),
|
|
||||||
positions: positions.filter(
|
positions: positions.filter(
|
||||||
(position): position is PortfolioPositionDto => position !== null,
|
(position): position is PortfolioPositionDto => position !== null,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -26,9 +26,24 @@ export class TbankAccessService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async listSupportedAccounts(userId: string): Promise<BrokerageAccountDto[]> {
|
async listSupportedAccounts(userId: string): Promise<BrokerageAccountDto[]> {
|
||||||
|
return this.withSupportedAccounts(userId, ({ accounts }) => accounts);
|
||||||
|
}
|
||||||
|
|
||||||
|
async withSupportedAccounts<T>(
|
||||||
|
userId: string,
|
||||||
|
handler: (context: {
|
||||||
|
token: string;
|
||||||
|
accounts: BrokerageAccountDto[];
|
||||||
|
}) => Promise<T> | T,
|
||||||
|
): Promise<T> {
|
||||||
const { token } = await this.connection(userId);
|
const { token } = await this.connection(userId);
|
||||||
return this.tokens.translateInvalidToken(async () =>
|
return this.tokens.translateInvalidToken(async () =>
|
||||||
supportedTbankAccounts(await this.tbank.getOpenAccounts(token)),
|
handler({
|
||||||
|
token,
|
||||||
|
accounts: supportedTbankAccounts(
|
||||||
|
await this.tbank.getOpenAccounts(token),
|
||||||
|
),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user