feat(tbank): add brokerage account summaries

This commit is contained in:
2026-08-26 20:57:55 +03:00
parent 84fabd264f
commit e489d45f88
7 changed files with 240 additions and 19 deletions
+9
View File
@@ -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;
}
+97 -3
View File
@@ -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'),
+56 -2
View File
@@ -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'
);
}
+37
View File
@@ -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 };
+23
View File
@@ -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: [
+2 -13
View File
@@ -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,
),
+16 -1
View File
@@ -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),
),
}),
);
}