Files
backend/src/modules/investments/income/income-analytics.service.ts
T
ksv741 516e43203e
Backend security / security (push) Successful in 9s
Backend verify / verify (push) Successful in 10m23s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Successful in 10m39s
Conventional commits / commits (pull_request) Successful in 29s
fix(investments): align roles and date contracts
Identify income dependencies by role and document screen-specific dates.
Convert inclusive calendar days into half-open source ranges while
preserving operation timestamps and existing stored coverage.
2026-09-27 11:38:13 +03:00

319 lines
9.4 KiB
TypeScript

import assert from 'node:assert/strict';
import { Injectable } from '@nestjs/common';
import {
add,
compare,
decimalFromString,
formatDecimal,
subtract,
} from '../../../common/decimal';
import { investmentResourceMeta } from '../investment-resource.mapper';
import {
IncomeAnalyticsResourceDto,
IncomeDependencyDto,
IncomeSummaryResourceDto,
} from './income-analytics.dto';
import { DateOnlyRange, moscowDate } from '../investment-calendar';
import {
BrokerageOperationDto,
BrokerageOperationsService,
} from '../operations/operations.api';
import {
IncomeAnalyticsEntryDto,
IncomeComparisonDto,
IncomeCurrencySummaryDto,
IncomeInstrumentDto,
IncomeSummaryDto,
} from './income-analytics.dto';
import {
IncomeBucketGranularity,
planIncomePeriod,
rangeBuckets,
} from './income-period';
type Total = {
coupon: ReturnType<typeof decimalFromString>;
dividend: ReturnType<typeof decimalFromString>;
};
@Injectable()
export class IncomeAnalyticsService {
constructor(private readonly operations: BrokerageOperationsService) {}
async summary(
userId: string,
accountId: string,
observe = false,
): Promise<IncomeSummaryResourceDto> {
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<IncomeAnalyticsResourceDto> {
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 : 0,
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) &&
['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<string, Total>();
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: IncomeAnalyticsEntryDto[],
): IncomeInstrumentDto[] {
const groups = new Map<string, IncomeAnalyticsEntryDto[]>();
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) },
};
}