From 84fabd264fa539dfefb1172e27a038870a30d845 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 26 Aug 2026 15:03:48 +0300 Subject: [PATCH] feat: enrich ledger events with logo metadata --- src/tbank/brokerage-operation.dto.ts | 3 + src/tbank/brokerage-operation.mapper.ts | 1 + .../brokerage-operations.service.spec.ts | 91 +++++++++++++++++++ src/tbank/brokerage-operations.service.ts | 48 ++++++++-- src/tbank/instrument-metadata.service.spec.ts | 61 +++++++++++++ src/tbank/instrument-metadata.service.ts | 86 ++++++++++++++++++ src/tbank/portfolio.service.spec.ts | 7 +- src/tbank/portfolio.service.ts | 64 +------------ src/tbank/tbank-connection.module.ts | 2 + 9 files changed, 294 insertions(+), 69 deletions(-) create mode 100644 src/tbank/instrument-metadata.service.spec.ts create mode 100644 src/tbank/instrument-metadata.service.ts diff --git a/src/tbank/brokerage-operation.dto.ts b/src/tbank/brokerage-operation.dto.ts index bbc2c54..418136b 100644 --- a/src/tbank/brokerage-operation.dto.ts +++ b/src/tbank/brokerage-operation.dto.ts @@ -86,6 +86,9 @@ export class BrokerageOperationDto { @ApiProperty({ type: String, nullable: true }) instrumentType: string | null; + @ApiProperty({ type: String, nullable: true }) + logoName: string | null; + @ApiProperty({ type: MoneyDto, nullable: true }) payment: MoneyDto | null; diff --git a/src/tbank/brokerage-operation.mapper.ts b/src/tbank/brokerage-operation.mapper.ts index 4af0e31..9d899f1 100644 --- a/src/tbank/brokerage-operation.mapper.ts +++ b/src/tbank/brokerage-operation.mapper.ts @@ -45,6 +45,7 @@ export function mapBrokerageOperation( instrumentUid: text(operation.instrumentUid), ticker: text(operation.ticker), instrumentType: text(operation.instrumentType), + logoName: null, payment: money(operation.payment), quantity: operation.quantity === undefined ? null : String(operation.quantity), diff --git a/src/tbank/brokerage-operations.service.spec.ts b/src/tbank/brokerage-operations.service.spec.ts index f8f4c89..3495f8a 100644 --- a/src/tbank/brokerage-operations.service.spec.ts +++ b/src/tbank/brokerage-operations.service.spec.ts @@ -17,10 +17,12 @@ describe('BrokerageOperationsService', () => { getOperationsByCursor: jest.fn(), }; const requestContext = { set: jest.fn() }; + const metadata = { resolve: jest.fn() }; const service = new BrokerageOperationsService( access as never, tbank as never, requestContext as never, + metadata as never, ); let operationsRequest: unknown; @@ -28,6 +30,7 @@ describe('BrokerageOperationsService', () => { jest.clearAllMocks(); access.withAccount.mockReset(); tbank.getOperationsByCursor.mockReset(); + metadata.resolve.mockReset(); access.withAccount.mockImplementation( ( _userId: string, @@ -103,6 +106,94 @@ describe('BrokerageOperationsService', () => { ); }); + it('includes resolved instrument logo metadata in a ledger entry', async () => { + tbank.getOperationsByCursor.mockResolvedValue({ + items: [ + { + id: 'coupon', + date: { seconds: '1' }, + type: 'OPERATION_TYPE_COUPON', + instrumentUid: 'instrument-uid', + }, + ], + }); + metadata.resolve.mockResolvedValue({ + brand: { logoName: 'instrument-logo.png' }, + }); + + await expect(service.get('user', 'account', {})).resolves.toMatchObject({ + items: [{ id: 'coupon', logoName: 'instrument-logo.png' }], + }); + }); + + it('resolves repeated instrument metadata only once per ledger page', async () => { + tbank.getOperationsByCursor.mockResolvedValue({ + items: [ + { + id: 'first', + date: { seconds: '1' }, + type: 'OPERATION_TYPE_COUPON', + instrumentUid: 'instrument-uid', + }, + { + id: 'second', + date: { seconds: '2' }, + type: 'OPERATION_TYPE_COUPON', + instrumentUid: 'instrument-uid', + }, + ], + }); + metadata.resolve.mockResolvedValue({ + brand: { logoName: 'instrument-logo.png' }, + }); + + await service.get('user', 'account', {}); + + expect(metadata.resolve).toHaveBeenCalledTimes(1); + }); + + it('does not let a UID-only lookup suppress a later FIGI fallback on the same page', async () => { + tbank.getOperationsByCursor.mockResolvedValue({ + items: [ + { + id: 'first', + date: { seconds: '1' }, + type: 'OPERATION_TYPE_COUPON', + instrumentUid: 'instrument-uid', + }, + { + id: 'second', + date: { seconds: '2' }, + type: 'OPERATION_TYPE_COUPON', + instrumentUid: 'instrument-uid', + figi: 'BBG000000001', + }, + ], + }); + metadata.resolve.mockResolvedValueOnce(null).mockResolvedValueOnce({ + brand: { logoName: 'fallback-logo.png' }, + }); + + await expect(service.get('user', 'account', {})).resolves.toMatchObject({ + items: [ + { id: 'first', logoName: null }, + { id: 'second', logoName: 'fallback-logo.png' }, + ], + }); + expect(metadata.resolve).toHaveBeenNthCalledWith( + 1, + 'token', + 'instrument-uid', + null, + ); + expect(metadata.resolve).toHaveBeenNthCalledWith( + 2, + 'token', + 'instrument-uid', + 'BBG000000001', + ); + }); + it('returns an empty page without calling T-Bank when the period starts in the future', async () => { await expect( service.get('user', 'account', { from: '2999-01-01T00:00:00.000Z' }), diff --git a/src/tbank/brokerage-operations.service.ts b/src/tbank/brokerage-operations.service.ts index c4abf24..ff8011b 100644 --- a/src/tbank/brokerage-operations.service.ts +++ b/src/tbank/brokerage-operations.service.ts @@ -9,6 +9,7 @@ import { mapBrokerageOperation, operationCategory, } from './brokerage-operation.mapper'; +import { InstrumentMetadataService } from './instrument-metadata.service'; import { TbankAccessService } from './tbank-access.service'; import { TbankClient } from './tbank.client'; @@ -27,6 +28,7 @@ export class BrokerageOperationsService { private readonly access: TbankAccessService, private readonly tbank: TbankClient, private readonly requestContext: RequestContextService, + private readonly metadata: InstrumentMetadataService, ) {} async get( @@ -56,15 +58,25 @@ export class BrokerageOperationsService { limit: query.limit ?? 100, state: 'OPERATION_STATE_EXECUTED', }); + const items = (response.items ?? []).filter( + (item) => + !query.categories?.length || + query.categories.includes(operationCategory(item.type)), + ); + const logoNames = new Map>(); const page = { accountId, - items: (response.items ?? []) - .filter( - (item) => - !query.categories?.length || - query.categories.includes(operationCategory(item.type)), - ) - .map(mapBrokerageOperation), + items: await Promise.all( + items.map(async (item) => ({ + ...mapBrokerageOperation(item), + logoName: await this.logoName( + token, + item.instrumentUid, + item.figi, + logoNames, + ), + })), + ), nextCursor: response.nextCursor || null, hasNext: Boolean(response.hasNext), observedAt: new Date().toISOString(), @@ -83,6 +95,28 @@ export class BrokerageOperationsService { to: dateBoundary(query.to, true) ?? now, }; } + + private async logoName( + token: string, + instrumentUid: string | undefined, + figi: string | undefined, + logoNames: Map>, + ): Promise { + const uid = instrumentUid?.trim() || null; + const instrumentFigi = figi?.trim() || null; + const key = + uid || instrumentFigi + ? JSON.stringify({ uid, figi: instrumentFigi }) + : null; + if (!key) return Promise.resolve(null); + const existing = logoNames.get(key); + if (existing) return existing; + const logoName = this.metadata + .resolve(token, uid, instrumentFigi) + .then((instrument) => instrument?.brand?.logoName?.trim() || null); + logoNames.set(key, logoName); + return logoName; + } } function cacheKey( diff --git a/src/tbank/instrument-metadata.service.spec.ts b/src/tbank/instrument-metadata.service.spec.ts new file mode 100644 index 0000000..4cc5848 --- /dev/null +++ b/src/tbank/instrument-metadata.service.spec.ts @@ -0,0 +1,61 @@ +import { InstrumentMetadataService } from './instrument-metadata.service'; + +describe('InstrumentMetadataService', () => { + const tbank = { + getInstrumentByFigi: jest.fn(), + getInstrumentByUid: jest.fn(), + }; + const logger = { event: jest.fn() }; + let service: InstrumentMetadataService; + + beforeEach(() => { + jest.clearAllMocks(); + tbank.getInstrumentByFigi.mockReset(); + tbank.getInstrumentByUid.mockReset(); + service = new InstrumentMetadataService(tbank as never, logger as never); + }); + + it('prefers the instrument UID when metadata is available', async () => { + tbank.getInstrumentByUid.mockResolvedValue({ + name: 'Инструмент', + brand: { logoName: 'instrument.png' }, + }); + + await expect( + service.resolve('token', 'uid-1', 'figi-1'), + ).resolves.toMatchObject({ + brand: { logoName: 'instrument.png' }, + }); + expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled(); + }); + + it('falls back to FIGI when the UID has no matching instrument', async () => { + tbank.getInstrumentByUid.mockResolvedValue(null); + tbank.getInstrumentByFigi.mockResolvedValue({ + ticker: 'SBER', + brand: { logoName: 'sber.png' }, + }); + + await expect( + service.resolve('token', 'uid-1', 'figi-1'), + ).resolves.toMatchObject({ + ticker: 'SBER', + }); + expect(tbank.getInstrumentByFigi).toHaveBeenCalledWith('token', 'figi-1'); + }); + + it('returns a nullable fallback when instrument metadata is unavailable', async () => { + tbank.getInstrumentByUid.mockRejectedValue(new Error('unavailable')); + + await expect(service.resolve('token', 'uid-1', null)).resolves.toBeNull(); + }); + + it('caches negative lookups and avoids repeated upstream requests', async () => { + tbank.getInstrumentByUid.mockResolvedValue(null); + + await expect(service.resolve('token', 'uid-1', null)).resolves.toBeNull(); + await expect(service.resolve('token', 'uid-1', null)).resolves.toBeNull(); + + expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/tbank/instrument-metadata.service.ts b/src/tbank/instrument-metadata.service.ts new file mode 100644 index 0000000..21eb0af --- /dev/null +++ b/src/tbank/instrument-metadata.service.ts @@ -0,0 +1,86 @@ +import { Injectable } from '@nestjs/common'; +import { LRUCache } from 'lru-cache'; +import { StructuredLogger } from '../common/logging/structured-logger.service'; +import { TbankClient, type TbankInstrument } from './tbank.client'; + +const metadataForMs = 86_400_000; +const maxCachedInstruments = 500; +const missingInstrument = Symbol('missingInstrument'); + +@Injectable() +export class InstrumentMetadataService { + private readonly metadata = new LRUCache< + string, + TbankInstrument | typeof missingInstrument + >({ + max: maxCachedInstruments, + ttl: metadataForMs, + }); + private readonly pending = new Map>(); + + constructor( + private readonly tbank: TbankClient, + private readonly logger: StructuredLogger, + ) {} + + async resolve( + token: string, + instrumentUid: string | null | undefined, + figi: string | null | undefined, + ): Promise { + const uid = instrumentUid?.trim(); + if (uid) { + const byUid = await this.lookup(`uid:${uid}`, () => + this.tbank.getInstrumentByUid(token, uid), + ); + if (byUid) return byUid; + } + const instrumentFigi = figi?.trim(); + return instrumentFigi + ? this.lookup(`figi:${instrumentFigi}`, () => + this.tbank.getInstrumentByFigi(token, instrumentFigi), + ) + : null; + } + + private async lookup( + key: string, + fetch: () => Promise, + ): Promise { + const cached = this.metadata.get(key); + if (cached !== undefined) + return cached === missingInstrument ? null : cached; + const pending = this.pending.get(key); + if (pending) return pending; + const request = this.fetch(key, fetch); + this.pending.set(key, request); + try { + return await request; + } finally { + this.pending.delete(key); + } + } + + private async fetch( + key: string, + request: () => Promise, + ): Promise { + try { + const value = await request(); + this.metadata.set(key, value ?? missingInstrument); + this.logger.event('debug', 'instrument-metadata.resolved', { + found: Boolean(value?.name?.trim() || value?.ticker?.trim()), + }); + return value; + } catch (error) { + this.logger.event('warn', 'instrument-metadata.failed', { + code: + error instanceof Error && 'response' in error + ? ((error as { response?: { code?: string } }).response?.code ?? + 'UNKNOWN') + : 'UNKNOWN', + }); + return null; + } + } +} diff --git a/src/tbank/portfolio.service.spec.ts b/src/tbank/portfolio.service.spec.ts index ea4411e..bc1abfb 100644 --- a/src/tbank/portfolio.service.spec.ts +++ b/src/tbank/portfolio.service.spec.ts @@ -8,6 +8,7 @@ jest.mock('./tbank-access.service', () => ({ })); import { PortfolioService } from './portfolio.service'; +import { InstrumentMetadataService } from './instrument-metadata.service'; import type { TbankAccountContext } from './tbank-access.service'; describe('PortfolioService', () => { @@ -20,11 +21,15 @@ describe('PortfolioService', () => { }; const requestContext = { set: jest.fn() }; const logger = { event: jest.fn() }; + const metadata = new InstrumentMetadataService( + tbank as never, + logger as never, + ); const service = new PortfolioService( access as never, tbank as never, requestContext as never, - logger as never, + metadata, ); beforeEach(() => { diff --git a/src/tbank/portfolio.service.ts b/src/tbank/portfolio.service.ts index 38787b3..08be092 100644 --- a/src/tbank/portfolio.service.ts +++ b/src/tbank/portfolio.service.ts @@ -1,6 +1,5 @@ import { Injectable, ServiceUnavailableException } from '@nestjs/common'; import { RequestContextService } from '../common/logging/request-context.service'; -import { StructuredLogger } from '../common/logging/structured-logger.service'; import { add, decimal, @@ -11,11 +10,11 @@ import { } from './decimal'; import { MoneyDto, PortfolioDto, PortfolioPositionDto } from './portfolio.dto'; import { TbankAccessService } from './tbank-access.service'; +import { InstrumentMetadataService } from './instrument-metadata.service'; import { TbankClient, TbankMoney } from './tbank.client'; const freshForMs = 30_000; const fallbackForMs = 300_000; -const metadataForMs = 86_400_000; const types: Record = { share: 'STOCK', bond: 'BOND', @@ -28,26 +27,16 @@ const types: Record = { }; type CachedSnapshot = { snapshot: PortfolioDto; storedAt: number }; -type CachedMetadata = { - value: { - ticker?: string; - name?: string; - isin?: string; - brand?: { logoName?: string | null }; - } | null; - storedAt: number; -}; @Injectable() export class PortfolioService { private readonly snapshots = new Map(); - private readonly metadata = new Map(); constructor( private readonly access: TbankAccessService, private readonly tbank: TbankClient, private readonly requestContext: RequestContextService, - private readonly logger: StructuredLogger, + private readonly metadata: InstrumentMetadataService, ) {} async get(userId: string, accountId: string): Promise { @@ -168,7 +157,7 @@ export class PortfolioService { ), } : null; - const metadata = await this.instrument(token, instrumentUid, figi); + const metadata = await this.metadata.resolve(token, instrumentUid, figi); return { figi, displayName: @@ -202,53 +191,6 @@ export class PortfolioService { ? { 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; - isin?: string; - brand?: { logoName?: string | null }; - } | 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.event('debug', 'portfolio.instrument-metadata.resolved', { - found: Boolean(value?.name?.trim() || value?.ticker?.trim()), - }); - return value; - } catch (error) { - this.logger.event('warn', 'portfolio.instrument-metadata.failed', { - 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) { diff --git a/src/tbank/tbank-connection.module.ts b/src/tbank/tbank-connection.module.ts index 56930a3..4757a82 100644 --- a/src/tbank/tbank-connection.module.ts +++ b/src/tbank/tbank-connection.module.ts @@ -3,6 +3,7 @@ import { BrokerageAccountsController } from './brokerage-accounts.controller'; import { BrokerageAccountsService } from './brokerage-accounts.service'; import { BrokerageOperationsController } from './brokerage-operations.controller'; import { BrokerageOperationsService } from './brokerage-operations.service'; +import { InstrumentMetadataService } from './instrument-metadata.service'; import { MoexClient } from './moex.client'; import { PortfolioController } from './portfolio.controller'; import { PortfolioService } from './portfolio.service'; @@ -27,6 +28,7 @@ import { UpcomingEventsService } from './upcoming-events.service'; TbankConnectionService, BrokerageAccountsService, BrokerageOperationsService, + InstrumentMetadataService, PortfolioService, MoexClient, UpcomingEventsService,