import { Injectable } from '@nestjs/common'; import { BrokerageOperationDto } from './brokerage-operation.dto'; import { BrokerageOperationsService } from './brokerage-operations.service'; import { add, compare, decimalFromString, formatDecimal, subtract, } from './decimal'; import { DateOnlyRange, IncomeBucketGranularity, moscowDate, planIncomePeriod, rangeBuckets, } from './income-period'; import { IncomeAnalyticsDto, IncomeComparisonDto, IncomeCurrencySummaryDto, IncomeInstrumentDto, IncomeSummaryDto, } from './income-analytics.dto'; type Total = { coupon: ReturnType; dividend: ReturnType; }; @Injectable() export class IncomeAnalyticsService { constructor(private readonly operations: BrokerageOperationsService) {} async summary(userId: string, accountId: string): Promise { let plan: ReturnType | undefined; const collected = await this.operations.collectRanges( userId, accountId, (openedAt) => { const planned = planIncomePeriod(undefined, undefined, openedAt); plan = planned; return [planned.selected]; }, ); if (!plan) throw new Error('Income period was not planned'); return summaryResponse( accountId, plan.selected, qualifying(collected.pages[0].flatMap((page) => page.items)), new Date().toISOString(), ); } async analytics( userId: string, accountId: string, from: string | undefined, to: string | undefined, ): Promise { let plan: ReturnType | undefined; const collected = await this.operations.collectRanges( userId, accountId, (openedAt) => { const planned = planIncomePeriod(from, to, openedAt); plan = planned; return [ planned.selected, planned.previous, ...(planned.yearAgo ? [planned.yearAgo] : []), ]; }, ); if (!plan) throw new Error('Income period was not planned'); const selected = qualifying( collected.pages[0].flatMap((page) => page.items), ); const previous = qualifying( collected.pages[1].flatMap((page) => page.items), ); const yearAgo = plan.yearAgo ? qualifying(collected.pages[2].flatMap((page) => page.items)) : null; const calculatedAt = new Date().toISOString(); const base = summaryResponse( accountId, plan.selected, selected, calculatedAt, ); return { ...base, previous: comparison(selected, plan.previous, previous), yearComparison: plan.yearAgo && yearAgo ? comparison(selected, plan.yearAgo, yearAgo) : null, bucketGranularity: plan.granularity, buckets: buckets(plan.selected, plan.granularity, selected), instruments: instruments(selected), entries: selected, }; } } function qualifying(entries: BrokerageOperationDto[]): BrokerageOperationDto[] { return entries.filter( (entry) => Boolean(entry.payment) && ['OPERATION_TYPE_COUPON', 'OPERATION_TYPE_DIVIDEND'].includes(entry.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 (entry.type === 'OPERATION_TYPE_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()]), ].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[], ) { return rangeBuckets(range, granularity).map((period) => ({ period, totalsByCurrency: totals( entries.filter((entry) => { const date = moscowDate(new Date(entry.occurredAt)); return date >= period.from && date <= period.to; }), ), })); } function instruments(entries: BrokerageOperationDto[]): IncomeInstrumentDto[] { const groups = new Map(); for (const entry of entries) { const key = `${instrumentKey(entry)}:${entry.payment?.currency ?? ''}`; groups.set(key, [...(groups.get(key) ?? []), 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.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) }, }; }