feat: enrich ledger events with logo metadata

This commit is contained in:
2026-08-26 15:03:48 +03:00
parent f1df104462
commit 84fabd264f
9 changed files with 294 additions and 69 deletions
+3 -61
View File
@@ -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<string, string> = {
share: 'STOCK',
bond: 'BOND',
@@ -28,26 +27,16 @@ const types: Record<string, string> = {
};
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<string, CachedSnapshot>();
private readonly metadata = new Map<string, CachedMetadata>();
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<PortfolioDto> {
@@ -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) {