feat(tbank): add brokerage account summaries
This commit is contained in:
@@ -1,4 +1,10 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { MoneyDto } from './portfolio.dto';
|
||||
|
||||
export class PortfolioSummaryDto {
|
||||
@ApiProperty({ type: MoneyDto, isArray: true })
|
||||
totalsByCurrency: MoneyDto[];
|
||||
}
|
||||
|
||||
export class BrokerageAccountDto {
|
||||
@ApiProperty()
|
||||
@@ -20,4 +26,7 @@ export class BrokerageAccountDto {
|
||||
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date' })
|
||||
openedOn: string | null;
|
||||
|
||||
@ApiProperty({ type: PortfolioSummaryDto, nullable: true })
|
||||
portfolioSummary?: PortfolioSummaryDto | null;
|
||||
}
|
||||
|
||||
@@ -16,16 +16,24 @@ describe('BrokerageAccountsService', () => {
|
||||
const encryptionKey = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=';
|
||||
const prisma = { tbankConnection: { findUnique: jest.fn() } };
|
||||
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 access = new TbankAccessService(
|
||||
prisma as never,
|
||||
tokens,
|
||||
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 () => {
|
||||
prisma.tbankConnection.findUnique.mockResolvedValue(null);
|
||||
@@ -65,16 +73,102 @@ describe('BrokerageAccountsService', () => {
|
||||
type: 'BROKERAGE',
|
||||
name: 'Основной',
|
||||
openedOn: '2024-08-13',
|
||||
portfolioSummary: { totalsByCurrency: [] },
|
||||
},
|
||||
{
|
||||
id: 'iis',
|
||||
type: 'INDIVIDUAL_INVESTMENT',
|
||||
name: 'ИИС',
|
||||
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 () => {
|
||||
prisma.tbankConnection.findUnique.mockResolvedValue({
|
||||
encryptedToken: tokens.encrypt('token'),
|
||||
|
||||
@@ -1,12 +1,66 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ServiceUnavailableException } from '@nestjs/common';
|
||||
import { BrokerageAccountDto } from './brokerage-account.dto';
|
||||
import { totalsByCurrency } from './portfolio-totals';
|
||||
import { TbankAccessService } from './tbank-access.service';
|
||||
import { TbankClient } from './tbank.client';
|
||||
|
||||
@Injectable()
|
||||
export class BrokerageAccountsService {
|
||||
constructor(private readonly access: TbankAccessService) {}
|
||||
constructor(
|
||||
private readonly access: TbankAccessService,
|
||||
private readonly tbank: TbankClient,
|
||||
) {}
|
||||
|
||||
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 () => {
|
||||
tbank.getPortfolio.mockResolvedValue({
|
||||
positions: [
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
multiply,
|
||||
subtract,
|
||||
} from './decimal';
|
||||
import { totalsByCurrency } from './portfolio-totals';
|
||||
import { MoneyDto, PortfolioDto, PortfolioPositionDto } from './portfolio.dto';
|
||||
import { TbankAccessService } from './tbank-access.service';
|
||||
import { InstrumentMetadataService } from './instrument-metadata.service';
|
||||
@@ -106,22 +107,10 @@ export class PortfolioService {
|
||||
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),
|
||||
})),
|
||||
totalsByCurrency: totalsByCurrency(upstream, money),
|
||||
positions: positions.filter(
|
||||
(position): position is PortfolioPositionDto => position !== null,
|
||||
),
|
||||
|
||||
@@ -26,9 +26,24 @@ export class TbankAccessService {
|
||||
) {}
|
||||
|
||||
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);
|
||||
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