import { Injectable } from '@nestjs/common'; import assert from 'node:assert/strict'; import { add, compare, decimalFromString, formatDecimal, } from '#src/common/decimal'; import { isTbankProtocolValue, TbankOperationType } from '#src/integrations/tbank/tbank-protocol'; import { DateOnlyRange, moscowDate } from '../investment-calendar'; import { investmentResourceMeta } from '../investment-resource.mapper'; import { BrokerageOperationDto, BrokerageOperationsService, } from '../operations/operations.api'; import { IncomeAnalyticsEntryDto, IncomeAnalyticsResourceDto, IncomeDependencyDto, IncomeInstrumentDto, IncomeSummaryDto, IncomeSummaryResourceDto, IncomeTotalDto, } from './income-analytics.dto'; import { IncomeBucketGranularity, planIncomePeriod, rangeBuckets, } from './income-period'; const STALE_FRESHNESS_MS = 0; type Total = { coupon: ReturnType; dividend: ReturnType; }; @Injectable() export class IncomeAnalyticsService { constructor(private readonly operations: BrokerageOperationsService) {} async summary( userId: string, accountId: string, ): Promise { const plan = planIncomePeriod(undefined, undefined); const [selected] = await this.operations.collectRanges( userId, accountId, () => [plan.selected], ); if (selected.items === null) return { data: null, meta: selected.meta }; assert(selected.meta.lastSuccessfulAt); return { data: summaryResponse( accountId, plan.selected, qualifying(selected.items), selected.meta.lastSuccessfulAt, ), meta: selected.meta, }; } async analytics( userId: string, accountId: string, from: string | undefined, to: string | undefined, ): Promise { const plan = planIncomePeriod(from, to); const [current] = await this.operations.collectRanges( userId, accountId, () => [plan.selected], ); const dependencies: IncomeDependencyDto[] = [ { ...current.meta, role: 'selected' }, ]; const work = current.meta.sync; const meta = { ...investmentResourceMeta({ resourceKey: `income/${accountId}/${plan.selected.from}/${plan.selected.to}`, dataRevision: current.meta.dataRevision, availability: current.items === null ? 'temporarily-unavailable' : 'available', fetchedAt: current.meta.lastSuccessfulAt ? new Date(current.meta.lastSuccessfulAt) : null, freshForMs: current.meta.freshness === 'fresh' ? Infinity : STALE_FRESHNESS_MS, work: work.id ? { id: work.id, state: work.state, errorCode: work.errorCode } : null, lastAttempt: current.meta.lastAttempt, coverage: current.meta.coverage, dependencies, }), dependencies, }; if (current.items === null) return { data: null, meta }; assert(current.meta.lastSuccessfulAt); const selected = qualifying(current.items).map((entry) => ({ ...entry, instrumentKey: instrumentKey(entry), })); return { data: { ...summaryResponse( accountId, plan.selected, selected, current.meta.lastSuccessfulAt, ), bucketGranularity: plan.granularity, buckets: buckets(plan.selected, plan.granularity, selected), instruments: instruments(selected), entries: selected, }, meta, }; } } function qualifying(entries: BrokerageOperationDto[]): BrokerageOperationDto[] { return entries.filter( (entry) => Boolean(entry.payment) && [TbankOperationType.Coupon, TbankOperationType.Dividend].some((type) => isTbankProtocolValue(entry.type, type)), ); } function summaryResponse( accountId: string, period: DateOnlyRange, entries: BrokerageOperationDto[], calculatedAt: string, ): IncomeSummaryDto { return { accountId, period, total: totals(entries), calculatedAt }; } function totals(entries: BrokerageOperationDto[]): IncomeTotalDto { const total = zeroTotal(); for (const entry of entries) { if (!entry.payment) continue; const value = decimalFromString(entry.payment.value); if (isTbankProtocolValue(entry.type, TbankOperationType.Coupon)) { total.coupon = add(total.coupon, value); } else total.dividend = add(total.dividend, value); } return presentTotal(total); } function buckets( range: DateOnlyRange, granularity: IncomeBucketGranularity, entries: BrokerageOperationDto[], ) { const datedEntries = entries.map((entry) => ({ entry, date: moscowDate(new Date(entry.occurredAt)), })); return rangeBuckets(range, granularity).map((period) => ({ period, total: totals( datedEntries .filter(({ date }) => date >= period.from && date <= period.to) .map(({ entry }) => entry), ), })); } function instruments( entries: IncomeAnalyticsEntryDto[], ): IncomeInstrumentDto[] { const groups = new Map(); for (const entry of entries) { const group = groups.get(entry.instrumentKey); if (group) group.push(entry); else groups.set(entry.instrumentKey, [entry]); } return [...groups] .map(([key, group]) => { const first = group[0]; const total = totals(group); return { key, figi: first.figi, instrumentUid: first.instrumentUid, ticker: first.ticker, instrumentType: first.instrumentType, displayName: first.ticker ?? (first.description || first.type), coupon: total.coupon, dividend: total.dividend, total: total.total, receiptCount: group.length, }; }) .sort( (left, right) => compare( decimalFromString(right.total.value), decimalFromString(left.total.value), ) || left.key.localeCompare(right.key), ); } function instrumentKey(entry: BrokerageOperationDto): string { if (entry.instrumentUid) return `uid:${entry.instrumentUid}`; if (entry.figi) return `figi:${entry.figi}`; if (entry.ticker || entry.instrumentType) { return `ticker:${entry.ticker ?? ''}:${entry.instrumentType ?? ''}`; } return `operation:${entry.account ? `${entry.account.id}:` : ''}${entry.id}`; } function zeroTotal(): Total { return { coupon: { value: 0n, scale: 0 }, dividend: { value: 0n, scale: 0 } }; } function presentTotal(value: Total): IncomeTotalDto { return { coupon: { currency: 'RUB', value: formatDecimal(value.coupon) }, dividend: { currency: 'RUB', value: formatDecimal(value.dividend) }, total: { currency: 'RUB', value: formatDecimal(add(value.coupon, value.dividend)) }, }; }