From e489d45f889656183e88ad68d964f721b715cbdb Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 26 Aug 2026 20:57:55 +0300 Subject: [PATCH] feat(tbank): add brokerage account summaries --- src/tbank/brokerage-account.dto.ts | 9 ++ src/tbank/brokerage-accounts.service.spec.ts | 100 ++++++++++++++++++- src/tbank/brokerage-accounts.service.ts | 58 ++++++++++- src/tbank/portfolio-totals.ts | 37 +++++++ src/tbank/portfolio.service.spec.ts | 23 +++++ src/tbank/portfolio.service.ts | 15 +-- src/tbank/tbank-access.service.ts | 17 +++- 7 files changed, 240 insertions(+), 19 deletions(-) create mode 100644 src/tbank/portfolio-totals.ts diff --git a/src/tbank/brokerage-account.dto.ts b/src/tbank/brokerage-account.dto.ts index cc85245..8f1ebb7 100644 --- a/src/tbank/brokerage-account.dto.ts +++ b/src/tbank/brokerage-account.dto.ts @@ -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; } diff --git a/src/tbank/brokerage-accounts.service.spec.ts b/src/tbank/brokerage-accounts.service.spec.ts index 651a2ad..1d3b30e 100644 --- a/src/tbank/brokerage-accounts.service.spec.ts +++ b/src/tbank/brokerage-accounts.service.spec.ts @@ -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'), diff --git a/src/tbank/brokerage-accounts.service.ts b/src/tbank/brokerage-accounts.service.ts index 2bb1519..5c70572 100644 --- a/src/tbank/brokerage-accounts.service.ts +++ b/src/tbank/brokerage-accounts.service.ts @@ -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 { - 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' + ); +} diff --git a/src/tbank/portfolio-totals.ts b/src/tbank/portfolio-totals.ts new file mode 100644 index 0000000..4bdfe86 --- /dev/null +++ b/src/tbank/portfolio-totals.ts @@ -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, + money: NonNullable, +): MoneyDto[] { + const totals = new Map>(); + const addTotal = ( + currency: string | undefined, + amount: ReturnType, + ) => { + 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 }; diff --git a/src/tbank/portfolio.service.spec.ts b/src/tbank/portfolio.service.spec.ts index bc1abfb..2000294 100644 --- a/src/tbank/portfolio.service.spec.ts +++ b/src/tbank/portfolio.service.spec.ts @@ -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: [ diff --git a/src/tbank/portfolio.service.ts b/src/tbank/portfolio.service.ts index 08be092..36aca1f 100644 --- a/src/tbank/portfolio.service.ts +++ b/src/tbank/portfolio.service.ts @@ -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, ), diff --git a/src/tbank/tbank-access.service.ts b/src/tbank/tbank-access.service.ts index a849f9f..a432380 100644 --- a/src/tbank/tbank-access.service.ts +++ b/src/tbank/tbank-access.service.ts @@ -26,9 +26,24 @@ export class TbankAccessService { ) {} async listSupportedAccounts(userId: string): Promise { + return this.withSupportedAccounts(userId, ({ accounts }) => accounts); + } + + async withSupportedAccounts( + userId: string, + handler: (context: { + token: string; + accounts: BrokerageAccountDto[]; + }) => Promise | T, + ): Promise { 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), + ), + }), ); }