import { Injectable, NotFoundException } from '@nestjs/common'; import { CacheService } from '../../cache/cache.service'; import { MoexClientService } from '../../moex-client/moex-client.service'; import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto'; import { TBANK_CACHE_KEYS } from '../tbank.config'; import { mapQuotationToNumber } from '../mappers/money.mapper'; import type { BrokerOperation, BrokerPortfolioEvent, BrokerEventsData, BrokerEventsSummary, } from '../types/broker.types'; import type { TBankInstrument } from '../types/tbank-proto.types'; import { BrokerAccountsService } from './broker-accounts.service'; import { BrokerOperationsService } from './broker-operations.service'; import { BrokerPortfolioService } from './broker-portfolio.service'; type BrokerEventType = BrokerPortfolioEvent['type']; type BrokerEventsQuery = { from: string; to: string; types?: string; }; const ALL_EVENT_TYPES: BrokerEventType[] = ['dividend', 'coupon', 'maturity', 'offer']; const ACTUAL_OPERATION_TYPES: Record, string[]> = { dividend: ['OPERATION_TYPE_DIVIDEND', 'OPERATION_TYPE_DIV_EXT'], coupon: ['OPERATION_TYPE_COUPON'], maturity: ['OPERATION_TYPE_BOND_REPAYMENT', 'OPERATION_TYPE_BOND_REPAYMENT_FULL'], }; const OPERATION_EVENT_TYPES = new Map>( Object.entries(ACTUAL_OPERATION_TYPES).flatMap(([eventType, operationTypes]) => operationTypes.map((operationType) => [ operationType, eventType as Exclude, ]), ), ); @Injectable() export class BrokerEventsService { constructor( private readonly accountsService: BrokerAccountsService, private readonly portfolioService: BrokerPortfolioService, private readonly moexClient: MoexClientService, private readonly operationsService: BrokerOperationsService, private readonly cacheService: CacheService, ) {} async getEvents( accountId: string, query: BrokerEventsQuery, ): Promise> { const account = await this.accountsService.findById(accountId); if (!account) throw new NotFoundException('Broker account not found'); const eventTypes = this.parseEventTypes(query.types); const eventTypeKey = Array.from(eventTypes).join(','); const result = await this.cacheService.getOrFetch( TBANK_CACHE_KEYS.events, [accountId, query.from, query.to, eventTypeKey], () => this.buildEvents(accountId, query.from, query.to, eventTypes), 'tbankPortfolioTtl', ); return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt); } private async buildEvents( accountId: string, from: string, to: string, eventTypes: Set, ): Promise { const { positions, instruments } = await this.portfolioService.getPositionsWithInstruments(accountId); const items: BrokerPortfolioEvent[] = []; const sharePositions = positions.filter((p) => p.instrumentType?.toLowerCase() === 'share'); const bondPositions = positions.filter((p) => p.instrumentType?.toLowerCase() === 'bond'); const shareResults = await Promise.allSettled( eventTypes.has('dividend') ? sharePositions.map((pos) => this.buildShareEvents(pos, instruments, from, to)) : [], ); for (const r of shareResults) { if (r.status === 'fulfilled') items.push(...r.value); } if (bondPositions.length > 0) { const bondEvents = await this.buildBondEvents( bondPositions, instruments, from, to, eventTypes, ); items.push(...bondEvents); } const actualEvents = await this.buildActualEvents(accountId, from, to, eventTypes); const actualKeys = new Set(actualEvents.map((event) => this.eventDedupKey(event))); const deduplicatedForecasts = items.filter( (event) => !actualKeys.has(this.eventDedupKey(event)), ); const allItems = [...actualEvents, ...deduplicatedForecasts]; allItems.sort((a, b) => a.eventDate.localeCompare(b.eventDate)); const summary = this.buildSummary(allItems); return { items: allItems, summary, asOf: new Date().toISOString() }; } private async buildShareEvents( pos: { ticker?: string; instrumentUid?: string; instrumentType?: string; quantity?: { units?: string | number; nano?: number }; }, instruments: Map>, from: string, to: string, ): Promise { const ticker = pos.ticker || null; if (!ticker) return []; const instrument = pos.instrumentUid ? instruments.get(pos.instrumentUid) : undefined; const quantity = mapQuotationToNumber(pos.quantity); const instrumentUid = pos.instrumentUid || instrument?.uid || null; const name = instrument?.name || null; let dividends: { registryCloseDate: string; value: number; currencyId: string }[]; try { dividends = await this.moexClient.getDividends(ticker); } catch { return []; } const events: BrokerPortfolioEvent[] = []; for (const d of dividends) { if (d.registryCloseDate < from || d.registryCloseDate > to) continue; const payoutPerUnit = d.value ?? null; const estimatedAmount = quantity !== null && payoutPerUnit !== null ? quantity * payoutPerUnit : null; events.push({ id: `div-${ticker}-${d.registryCloseDate}`, type: 'dividend', source: 'forecast', category: 'cashflow', eventDate: d.registryCloseDate, paymentDate: null, ticker, name, instrumentUid, instrumentType: 'share', quantitySnapshot: quantity, payoutPerUnit, estimatedAmount, actualAmount: null, currency: d.currencyId, estimateMode: 'current_position', }); } return events; } private async buildBondEvents( bondPositions: { ticker?: string; instrumentUid?: string; instrumentType?: string; quantity?: { units?: string | number; nano?: number }; }[], instruments: Map>, from: string, to: string, eventTypes: Set, ): Promise { const secids = bondPositions.map((p) => p.ticker).filter((t): t is string => Boolean(t)); if (secids.length === 0) return []; let bondData: { secid: string; couponValue: number | null; nextCouponDate: string | null; matDate: string | null; offerDate: string | null; faceValue: number; }[]; try { bondData = await this.moexClient.getBondPositionDataBatch(secids); } catch { return []; } const events: BrokerPortfolioEvent[] = []; for (const pos of bondPositions) { const ticker = pos.ticker; if (!ticker) continue; const bond = bondData.find((b) => b.secid === ticker); if (!bond) continue; const instrument = pos.instrumentUid ? instruments.get(pos.instrumentUid) : undefined; const quantity = mapQuotationToNumber(pos.quantity); const instrumentUid = pos.instrumentUid || instrument?.uid || null; const name = instrument?.name || null; const currency = instrument?.currency || 'RUB'; if ( eventTypes.has('coupon') && bond.nextCouponDate && bond.nextCouponDate >= from && bond.nextCouponDate <= to ) { const payoutPerUnit = bond.couponValue; const estimatedAmount = quantity !== null && payoutPerUnit !== null ? quantity * payoutPerUnit : null; events.push({ id: `coupon-${ticker}-${bond.nextCouponDate}`, type: 'coupon', source: 'forecast', category: 'cashflow', eventDate: bond.nextCouponDate, paymentDate: null, ticker, name, instrumentUid, instrumentType: 'bond', quantitySnapshot: quantity, payoutPerUnit, estimatedAmount, actualAmount: null, currency, estimateMode: 'current_position', }); } if ( eventTypes.has('maturity') && bond.matDate && bond.matDate >= from && bond.matDate <= to ) { const payoutPerUnit = bond.faceValue; const estimatedAmount = quantity !== null ? quantity * payoutPerUnit : null; events.push({ id: `maturity-${ticker}-${bond.matDate}`, type: 'maturity', source: 'forecast', category: 'cashflow', eventDate: bond.matDate, paymentDate: null, ticker, name, instrumentUid, instrumentType: 'bond', quantitySnapshot: quantity, payoutPerUnit, estimatedAmount, actualAmount: null, currency, estimateMode: 'current_position', }); } if ( eventTypes.has('offer') && bond.offerDate && bond.offerDate >= from && bond.offerDate <= to ) { events.push({ id: `offer-${ticker}-${bond.offerDate}`, type: 'offer', source: 'forecast', category: 'corporate', eventDate: bond.offerDate, paymentDate: null, ticker, name, instrumentUid, instrumentType: 'bond', quantitySnapshot: quantity, payoutPerUnit: null, estimatedAmount: null, actualAmount: null, currency, estimateMode: 'current_position', }); } } return events; } private buildSummary(items: BrokerPortfolioEvent[]): BrokerEventsSummary { const cashflowEvents = items.filter((e) => e.category === 'cashflow'); const forecastEvents = cashflowEvents.filter((e) => e.source === 'forecast'); const actualEvents = cashflowEvents.filter((e) => e.source === 'actual'); return { eventCount: items.length, nearestEventDate: items.length > 0 ? items[0].eventDate : null, totalEstimatedCashflow: forecastEvents.reduce((sum, e) => sum + (e.estimatedAmount ?? 0), 0), forecastEstimatedCashflow: forecastEvents.reduce( (sum, e) => sum + (e.estimatedAmount ?? 0), 0, ), actualCashflow: actualEvents.reduce((sum, e) => sum + (e.actualAmount ?? 0), 0), dividendsTotal: forecastEvents .filter((e) => e.type === 'dividend') .reduce((sum, e) => sum + (e.estimatedAmount ?? 0), 0), couponsTotal: forecastEvents .filter((e) => e.type === 'coupon') .reduce((sum, e) => sum + (e.estimatedAmount ?? 0), 0), principalRepaymentTotal: forecastEvents .filter((e) => e.type === 'maturity') .reduce((sum, e) => sum + (e.estimatedAmount ?? 0), 0), actualDividendsTotal: actualEvents .filter((e) => e.type === 'dividend') .reduce((sum, e) => sum + (e.actualAmount ?? 0), 0), actualCouponsTotal: actualEvents .filter((e) => e.type === 'coupon') .reduce((sum, e) => sum + (e.actualAmount ?? 0), 0), actualPrincipalRepaymentTotal: actualEvents .filter((e) => e.type === 'maturity') .reduce((sum, e) => sum + (e.actualAmount ?? 0), 0), }; } private async buildActualEvents( accountId: string, from: string, to: string, eventTypes: Set, ): Promise { const operationTypes = this.actualOperationTypes(eventTypes); if (operationTypes.length === 0) return []; const page = await this.operationsService.getOperations(accountId, { from: `${from}T00:00:00.000Z`, to: `${to}T23:59:59.999Z`, operationTypes: operationTypes.join(','), limit: 100, state: 'OPERATION_STATE_EXECUTED', }); return page.data.items.flatMap((operation) => { const eventType = OPERATION_EVENT_TYPES.get(operation.type); if (!eventType || !eventTypes.has(eventType) || !operation.date) return []; const eventDate = operation.date.slice(0, 10); if (eventDate < from || eventDate > to) return []; return [this.mapActualOperation(operation, eventType, eventDate)]; }); } private mapActualOperation( operation: BrokerOperation, type: Exclude, eventDate: string, ): BrokerPortfolioEvent { return { id: `actual-${operation.id || operation.cursor || `${type}-${eventDate}`}`, type, source: 'actual', category: 'cashflow', eventDate, paymentDate: eventDate, ticker: operation.ticker, name: operation.name || operation.description, instrumentUid: operation.instrumentUid, instrumentType: this.mapInstrumentType(operation.instrumentType), quantitySnapshot: operation.quantityDone ?? operation.quantity, payoutPerUnit: null, estimatedAmount: null, actualAmount: operation.payment?.value ?? null, currency: operation.payment?.currency ?? null, estimateMode: null, }; } private parseEventTypes(types: string | undefined): Set { if (!types) return new Set(ALL_EVENT_TYPES); const parsed = types .split(',') .map((value) => value.trim()) .filter((value): value is BrokerEventType => ALL_EVENT_TYPES.includes(value as BrokerEventType), ); return new Set(parsed.length > 0 ? parsed : ALL_EVENT_TYPES); } private actualOperationTypes(eventTypes: Set): string[] { return Array.from(eventTypes).flatMap((type) => type === 'offer' ? [] : ACTUAL_OPERATION_TYPES[type], ); } private mapInstrumentType(instrumentType: string | null): 'share' | 'bond' | 'other' { const normalized = instrumentType?.toLowerCase(); if (normalized === 'share') return 'share'; if (normalized === 'bond') return 'bond'; return 'other'; } private eventDedupKey(event: BrokerPortfolioEvent): string { return `${event.type}:${event.ticker ?? event.instrumentUid ?? event.name ?? ''}:${event.eventDate}`; } }