import { Injectable } from '@nestjs/common'; import assert from 'node:assert/strict'; import { add, compare, decimalFromString, formatDecimal, subtract, } from '../../../common/decimal'; import { isTbankProtocolValue, TbankOperationType } from '../../../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, IncomeComparisonDto, IncomeCurrencySummaryDto, IncomeDependencyDto, IncomeInstrumentDto, IncomeSummaryDto, IncomeSummaryResourceDto, } 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, observe = false, ): Promise { let plan = planIncomePeriod(undefined, undefined); const [selected] = await this.operations.collectRanges( userId, accountId, (openedAt) => { plan = planIncomePeriod(undefined, undefined, openedAt); return [plan.selected]; }, observe, ); 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, observe = false, ): Promise { let plan = planIncomePeriod(from, to); const periods = await this.operations.collectRanges( userId, accountId, (openedAt) => { plan = planIncomePeriod(from, to, openedAt); return [ plan.selected, plan.previous, ...(plan.yearAgo ? [plan.yearAgo] : []), ]; }, observe, ); const [current, prior, year] = periods; // Preserve the legacy order while consumers migrate to explicit roles. const dependencies: IncomeDependencyDto[] = [ { ...current.meta, role: 'selected' }, { ...prior.meta, role: 'previous' }, { ...(plan.yearAgo && year ? year.meta : investmentResourceMeta({ resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier dataRevision: 'unavailable', availability: 'missing', fetchedAt: null, freshForMs: 0, work: null, unavailableReason: 'CALENDAR_OR_ACCOUNT_HISTORY', })), role: 'year', }, ]; const states = periods.map((period) => period.meta.sync); const work = states.find((state) => state.state === 'running') ?? states.find((state) => state.state === 'failed'); const meta = { ...investmentResourceMeta({ resourceKey: `income/${accountId}/${plan.selected.from}/${plan.selected.to}`, dataRevision: current.meta.dataRevision, availability: current.meta.availability, fetchedAt: current.meta.lastSuccessfulAt ? new Date(current.meta.lastSuccessfulAt) : null, freshForMs: current.meta.freshness === 'fresh' ? Infinity : STALE_FRESHNESS_MS, work: work?.id ? { ...work, id: work.id } : null, 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)}:${entry.payment?.currency ?? ''}`, })); return { data: { ...summaryResponse( accountId, plan.selected, selected, current.meta.lastSuccessfulAt, ), previous: prior.items === null ? null : comparison(selected, plan.previous, qualifying(prior.items)), yearComparison: plan.yearAgo && year?.items !== null && year ? comparison(selected, plan.yearAgo, qualifying(year.items)) : null, 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, totalsByCurrency: totals(entries), calculatedAt }; } function totals(entries: BrokerageOperationDto[]): IncomeCurrencySummaryDto[] { const values = new Map(); for (const entry of entries) { if (!entry.payment) continue; const total = values.get(entry.payment.currency) ?? zeroTotal(); 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); values.set(entry.payment.currency, total); } return [...values] .sort(([left], [right]) => left.localeCompare(right)) .map(([currency, total]) => presentTotal(currency, total)); } function comparison( selectedEntries: BrokerageOperationDto[], comparedPeriod: DateOnlyRange, comparedEntries: BrokerageOperationDto[], ): IncomeComparisonDto { const selected = new Map( totals(selectedEntries).map((total) => [total.currency, total]), ); const compared = new Map( totals(comparedEntries).map((total) => [total.currency, total]), ); const currencies = [ ...new Set([...selected.keys(), ...compared.keys()]), // eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings. ].sort(); return { period: comparedPeriod, totalsByCurrency: currencies.map((currency) => { const current = selected.get(currency) ?? presentTotal(currency, zeroTotal()); const prior = compared.get(currency) ?? presentTotal(currency, zeroTotal()); return { currency, selected: current, compared: prior, difference: difference(current, prior), }; }), }; } 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, totalsByCurrency: 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 difference( left: IncomeCurrencySummaryDto, right: IncomeCurrencySummaryDto, ): IncomeCurrencySummaryDto { return presentTotal(left.currency, { coupon: subtract( decimalFromString(left.coupon.value), decimalFromString(right.coupon.value), ), dividend: subtract( decimalFromString(left.dividend.value), decimalFromString(right.dividend.value), ), }); } function zeroTotal(): Total { return { coupon: { value: 0n, scale: 0 }, dividend: { value: 0n, scale: 0 } }; } function presentTotal( currency: string, value: Total, ): IncomeCurrencySummaryDto { const total = add(value.coupon, value.dividend); return { currency, coupon: { currency, value: formatDecimal(value.coupon) }, dividend: { currency, value: formatDecimal(value.dividend) }, total: { currency, value: formatDecimal(total) }, }; }