feat(income): add account income analytics API
This commit is contained in:
@@ -254,6 +254,50 @@ describe('BrokerageOperationsService', () => {
|
|||||||
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(1);
|
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('collects every cursor page and rejects a malformed continuation cursor', async () => {
|
||||||
|
tbank.getOperationsByCursor
|
||||||
|
.mockResolvedValueOnce({ hasNext: true, nextCursor: 'next', items: [] })
|
||||||
|
.mockResolvedValueOnce({ hasNext: false, items: [] });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.collectRanges('user', 'account', [
|
||||||
|
{ from: '2026-01-01', to: '2026-01-31' },
|
||||||
|
]),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
pages: [[{ hasNext: true }, { hasNext: false }]],
|
||||||
|
});
|
||||||
|
|
||||||
|
tbank.getOperationsByCursor.mockReset();
|
||||||
|
tbank.getOperationsByCursor.mockResolvedValue({
|
||||||
|
hasNext: true,
|
||||||
|
nextCursor: 'same',
|
||||||
|
items: [],
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
service.collectRanges('user', 'account', [
|
||||||
|
{ from: '2026-02-01', to: '2026-02-28' },
|
||||||
|
]),
|
||||||
|
).rejects.toMatchObject({ response: { code: 'TBANK_UNAVAILABLE' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collects an in-progress Moscow day only through the current instant', async () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
jest.setSystemTime(new Date('2026-08-27T21:44:18.510Z'));
|
||||||
|
|
||||||
|
await service.collectRanges('user', 'account', [
|
||||||
|
{ from: '2026-08-01', to: '2026-08-28' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(tbank.getOperationsByCursor).toHaveBeenCalledWith(
|
||||||
|
'token',
|
||||||
|
expect.objectContaining({
|
||||||
|
from: { seconds: '1785531600' },
|
||||||
|
to: { seconds: '1787867058' },
|
||||||
|
limit: 1000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('evicts the least recently used ledger page after one hundred entries', async () => {
|
it('evicts the least recently used ledger page after one hundred entries', async () => {
|
||||||
for (let index = 0; index < 100; index += 1)
|
for (let index = 0; index < 100; index += 1)
|
||||||
await service.get('user', 'account', { cursor: String(index) });
|
await service.get('user', 'account', { cursor: String(index) });
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||||
import { LRUCache } from 'lru-cache';
|
import { LRUCache } from 'lru-cache';
|
||||||
import { RequestContextService } from '../common/logging/request-context.service';
|
import { RequestContextService } from '../common/logging/request-context.service';
|
||||||
import {
|
import {
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { InstrumentMetadataService } from './instrument-metadata.service';
|
import { InstrumentMetadataService } from './instrument-metadata.service';
|
||||||
import { TbankAccessService } from './tbank-access.service';
|
import { TbankAccessService } from './tbank-access.service';
|
||||||
import { TbankClient } from './tbank.client';
|
import { TbankClient } from './tbank.client';
|
||||||
|
import { DateOnlyRange, dateOnlyBounds } from './income-period';
|
||||||
|
|
||||||
const freshForMs = 30_000;
|
const freshForMs = 30_000;
|
||||||
const maxCachedPages = 100;
|
const maxCachedPages = 100;
|
||||||
@@ -39,51 +40,123 @@ export class BrokerageOperationsService {
|
|||||||
this.requestContext.set({ userId, brokerageAccountId: accountId });
|
this.requestContext.set({ userId, brokerageAccountId: accountId });
|
||||||
const range = this.range(query);
|
const range = this.range(query);
|
||||||
|
|
||||||
return this.access.withAccount(userId, accountId, async ({ token }) => {
|
return this.access.withAccount(userId, accountId, ({ token }) =>
|
||||||
const key = cacheKey(userId, accountId, query, range);
|
this.page(token, userId, accountId, query, range),
|
||||||
const cached = this.pages.get(key);
|
);
|
||||||
if (cached) return cached;
|
}
|
||||||
|
|
||||||
if (range.from > range.to) {
|
async collectRanges(
|
||||||
const page = emptyPage(accountId);
|
userId: string,
|
||||||
this.pages.set(key, page);
|
accountId: string,
|
||||||
return page;
|
ranges:
|
||||||
}
|
| DateOnlyRange[]
|
||||||
|
| ((accountOpenedAt: Date | undefined) => DateOnlyRange[]),
|
||||||
const response = await this.tbank.getOperationsByCursor(token, {
|
): Promise<{
|
||||||
accountId,
|
accountOpenedAt: Date | undefined;
|
||||||
from: seconds(range.from),
|
pages: BrokerageOperationsPageDto[][];
|
||||||
to: seconds(range.to, /^\d{4}-\d{2}-\d{2}$/.test(query.to ?? '')),
|
}> {
|
||||||
cursor: query.cursor,
|
this.requestContext.set({ userId, brokerageAccountId: accountId });
|
||||||
limit: query.limit ?? 100,
|
return this.access.withAccount(
|
||||||
state: 'OPERATION_STATE_EXECUTED',
|
userId,
|
||||||
});
|
accountId,
|
||||||
const items = (response.items ?? []).filter(
|
async ({ token, account }) => {
|
||||||
(item) =>
|
const accountOpenedAt = account.openedDate?.seconds
|
||||||
!query.categories?.length ||
|
? new Date(Number(account.openedDate.seconds) * 1000)
|
||||||
query.categories.includes(operationCategory(item.type)),
|
: undefined;
|
||||||
);
|
const selected =
|
||||||
const logoNames = new Map<string, Promise<string | null>>();
|
typeof ranges === 'function' ? ranges(accountOpenedAt) : ranges;
|
||||||
const page = {
|
return {
|
||||||
accountId,
|
accountOpenedAt,
|
||||||
items: await Promise.all(
|
pages: await Promise.all(
|
||||||
items.map(async (item) => ({
|
selected.map((range) =>
|
||||||
...mapBrokerageOperation(item),
|
this.collectRange(token, userId, accountId, range),
|
||||||
logoName: await this.logoName(
|
|
||||||
token,
|
|
||||||
item.instrumentUid,
|
|
||||||
item.figi,
|
|
||||||
logoNames,
|
|
||||||
),
|
),
|
||||||
})),
|
),
|
||||||
),
|
};
|
||||||
nextCursor: response.nextCursor || null,
|
},
|
||||||
hasNext: Boolean(response.hasNext),
|
);
|
||||||
observedAt: new Date().toISOString(),
|
}
|
||||||
};
|
|
||||||
|
private async collectRange(
|
||||||
|
token: string,
|
||||||
|
userId: string,
|
||||||
|
accountId: string,
|
||||||
|
range: DateOnlyRange,
|
||||||
|
): Promise<BrokerageOperationsPageDto[]> {
|
||||||
|
const pages: BrokerageOperationsPageDto[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
let cursor: string | undefined;
|
||||||
|
do {
|
||||||
|
const page = await this.page(
|
||||||
|
token,
|
||||||
|
userId,
|
||||||
|
accountId,
|
||||||
|
{
|
||||||
|
from: range.from,
|
||||||
|
cursor,
|
||||||
|
limit: 1000,
|
||||||
|
categories: ['income'],
|
||||||
|
},
|
||||||
|
dateOnlyBounds(range),
|
||||||
|
);
|
||||||
|
pages.push(page);
|
||||||
|
if (!page.hasNext) return pages;
|
||||||
|
if (!page.nextCursor || seen.has(page.nextCursor))
|
||||||
|
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
|
||||||
|
seen.add(page.nextCursor);
|
||||||
|
cursor = page.nextCursor;
|
||||||
|
} while (cursor);
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async page(
|
||||||
|
token: string,
|
||||||
|
userId: string,
|
||||||
|
accountId: string,
|
||||||
|
query: BrokerageOperationsQueryDto,
|
||||||
|
range: { from: Date; to: Date },
|
||||||
|
): Promise<BrokerageOperationsPageDto> {
|
||||||
|
const key = cacheKey(userId, accountId, query, range);
|
||||||
|
const cached = this.pages.get(key);
|
||||||
|
if (cached) return cached;
|
||||||
|
if (range.from > range.to) {
|
||||||
|
const page = emptyPage(accountId);
|
||||||
this.pages.set(key, page);
|
this.pages.set(key, page);
|
||||||
return page;
|
return page;
|
||||||
|
}
|
||||||
|
const response = await this.tbank.getOperationsByCursor(token, {
|
||||||
|
accountId,
|
||||||
|
from: seconds(range.from),
|
||||||
|
to: seconds(range.to, /^\d{4}-\d{2}-\d{2}$/.test(query.to ?? '')),
|
||||||
|
cursor: query.cursor,
|
||||||
|
limit: query.limit ?? 100,
|
||||||
|
state: 'OPERATION_STATE_EXECUTED',
|
||||||
});
|
});
|
||||||
|
const items = (response.items ?? []).filter(
|
||||||
|
(item) =>
|
||||||
|
!query.categories?.length ||
|
||||||
|
query.categories.includes(operationCategory(item.type)),
|
||||||
|
);
|
||||||
|
const logoNames = new Map<string, Promise<string | null>>();
|
||||||
|
const page = {
|
||||||
|
accountId,
|
||||||
|
items: await Promise.all(
|
||||||
|
items.map(async (item) => ({
|
||||||
|
...mapBrokerageOperation(item),
|
||||||
|
logoName: await this.logoName(
|
||||||
|
token,
|
||||||
|
item.instrumentUid,
|
||||||
|
item.figi,
|
||||||
|
logoNames,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
nextCursor: response.nextCursor || null,
|
||||||
|
hasNext: Boolean(response.hasNext),
|
||||||
|
observedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
this.pages.set(key, page);
|
||||||
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
private range(query: BrokerageOperationsQueryDto) {
|
private range(query: BrokerageOperationsQueryDto) {
|
||||||
|
|||||||
@@ -3,6 +3,17 @@ const NANO = 1_000_000_000n;
|
|||||||
export type ProtoDecimal = { units?: string | number; nano?: string | number };
|
export type ProtoDecimal = { units?: string | number; nano?: string | number };
|
||||||
export type Decimal = { value: bigint; scale: number };
|
export type Decimal = { value: bigint; scale: number };
|
||||||
|
|
||||||
|
export function decimalFromString(value: string): Decimal {
|
||||||
|
if (!/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(value))
|
||||||
|
throw new Error('Invalid decimal string');
|
||||||
|
const negative = value.startsWith('-');
|
||||||
|
const [whole, fraction = ''] = (negative ? value.slice(1) : value).split('.');
|
||||||
|
return {
|
||||||
|
value: (negative ? -1n : 1n) * BigInt(`${whole}${fraction}`),
|
||||||
|
scale: fraction.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function decimal(value: ProtoDecimal): Decimal {
|
export function decimal(value: ProtoDecimal): Decimal {
|
||||||
return {
|
return {
|
||||||
value: BigInt(value.units ?? 0) * NANO + BigInt(value.nano ?? 0),
|
value: BigInt(value.units ?? 0) * NANO + BigInt(value.nano ?? 0),
|
||||||
@@ -32,9 +43,15 @@ export function isZero(value: Decimal): boolean {
|
|||||||
return value.value === 0n;
|
return value.value === 0n;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function compare(left: Decimal, right: Decimal): number {
|
||||||
|
const difference = subtract(left, right).value;
|
||||||
|
return difference === 0n ? 0 : difference > 0n ? 1 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
export function formatDecimal({ value, scale }: Decimal): string {
|
export function formatDecimal({ value, scale }: Decimal): string {
|
||||||
if (value === 0n) return '0';
|
if (value === 0n) return '0';
|
||||||
const sign = value < 0n ? '-' : '';
|
const sign = value < 0n ? '-' : '';
|
||||||
|
if (scale === 0) return `${sign}${value < 0n ? -value : value}`;
|
||||||
const digits = (value < 0n ? -value : value)
|
const digits = (value < 0n ? -value : value)
|
||||||
.toString()
|
.toString()
|
||||||
.padStart(scale + 1, '0');
|
.padStart(scale + 1, '0');
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
import type { Request } from 'express';
|
||||||
|
import { AccessPayload } from '../auth/auth.types';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { ApiProblemResponses } from '../common/decorators/api-safe-error-response.decorator';
|
||||||
|
import {
|
||||||
|
IncomeAnalyticsDto,
|
||||||
|
IncomeAnalyticsQueryDto,
|
||||||
|
IncomeSummaryDto,
|
||||||
|
} from './income-analytics.dto';
|
||||||
|
import { IncomeAnalyticsService } from './income-analytics.service';
|
||||||
|
|
||||||
|
@ApiTags('brokerage-accounts')
|
||||||
|
@ApiProblemResponses()
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Controller('brokerage-accounts')
|
||||||
|
export class IncomeAnalyticsController {
|
||||||
|
constructor(private readonly income: IncomeAnalyticsService) {}
|
||||||
|
|
||||||
|
@Get(':accountId/income-summary')
|
||||||
|
@ApiOkResponse({ type: IncomeSummaryDto })
|
||||||
|
summary(
|
||||||
|
@Req() request: Request & { user: AccessPayload },
|
||||||
|
@Param('accountId') accountId: string,
|
||||||
|
) {
|
||||||
|
return this.income.summary(request.user.sub, accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':accountId/income-analytics')
|
||||||
|
@ApiOkResponse({ type: IncomeAnalyticsDto })
|
||||||
|
analytics(
|
||||||
|
@Req() request: Request & { user: AccessPayload },
|
||||||
|
@Param('accountId') accountId: string,
|
||||||
|
@Query() query: IncomeAnalyticsQueryDto,
|
||||||
|
) {
|
||||||
|
return this.income.analytics(
|
||||||
|
request.user.sub,
|
||||||
|
accountId,
|
||||||
|
query.from,
|
||||||
|
query.to,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsOptional, Matches } from 'class-validator';
|
||||||
|
import { BrokerageOperationDto } from './brokerage-operation.dto';
|
||||||
|
import { MoneyDto } from './portfolio.dto';
|
||||||
|
|
||||||
|
const dateOnly = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
|
export class IncomeAnalyticsQueryDto {
|
||||||
|
@ApiPropertyOptional({ format: 'date' })
|
||||||
|
@IsOptional()
|
||||||
|
@Matches(dateOnly)
|
||||||
|
from?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'date' })
|
||||||
|
@IsOptional()
|
||||||
|
@Matches(dateOnly)
|
||||||
|
to?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IncomePeriodDto {
|
||||||
|
@ApiProperty({ format: 'date' })
|
||||||
|
from: string;
|
||||||
|
|
||||||
|
@ApiProperty({ format: 'date' })
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IncomeCurrencySummaryDto {
|
||||||
|
@ApiProperty()
|
||||||
|
currency: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: MoneyDto })
|
||||||
|
coupon: MoneyDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: MoneyDto })
|
||||||
|
dividend: MoneyDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: MoneyDto })
|
||||||
|
total: MoneyDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IncomeSummaryDto {
|
||||||
|
@ApiProperty()
|
||||||
|
accountId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: IncomePeriodDto })
|
||||||
|
period: IncomePeriodDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: IncomeCurrencySummaryDto, isArray: true })
|
||||||
|
totalsByCurrency: IncomeCurrencySummaryDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ format: 'date-time' })
|
||||||
|
calculatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IncomeComparisonCurrencyDto {
|
||||||
|
@ApiProperty()
|
||||||
|
currency: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: IncomeCurrencySummaryDto })
|
||||||
|
selected: IncomeCurrencySummaryDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: IncomeCurrencySummaryDto })
|
||||||
|
compared: IncomeCurrencySummaryDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: IncomeCurrencySummaryDto })
|
||||||
|
difference: IncomeCurrencySummaryDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IncomeComparisonDto {
|
||||||
|
@ApiProperty({ type: IncomePeriodDto })
|
||||||
|
period: IncomePeriodDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: IncomeComparisonCurrencyDto, isArray: true })
|
||||||
|
totalsByCurrency: IncomeComparisonCurrencyDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IncomeBucketDto {
|
||||||
|
@ApiProperty({ type: IncomePeriodDto })
|
||||||
|
period: IncomePeriodDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: IncomeCurrencySummaryDto, isArray: true })
|
||||||
|
totalsByCurrency: IncomeCurrencySummaryDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IncomeInstrumentDto {
|
||||||
|
@ApiProperty()
|
||||||
|
key: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, nullable: true })
|
||||||
|
figi: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, nullable: true })
|
||||||
|
instrumentUid: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, nullable: true })
|
||||||
|
ticker: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, nullable: true })
|
||||||
|
instrumentType: string | null;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
displayName: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: MoneyDto })
|
||||||
|
coupon: MoneyDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: MoneyDto })
|
||||||
|
dividend: MoneyDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: MoneyDto })
|
||||||
|
total: MoneyDto;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
receiptCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IncomeAnalyticsDto extends IncomeSummaryDto {
|
||||||
|
@ApiProperty({ type: IncomeComparisonDto })
|
||||||
|
previous: IncomeComparisonDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: IncomeComparisonDto, nullable: true })
|
||||||
|
yearComparison: IncomeComparisonDto | null;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ['day', 'week', 'month'] })
|
||||||
|
bucketGranularity: 'day' | 'week' | 'month';
|
||||||
|
|
||||||
|
@ApiProperty({ type: IncomeBucketDto, isArray: true })
|
||||||
|
buckets: IncomeBucketDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: IncomeInstrumentDto, isArray: true })
|
||||||
|
instruments: IncomeInstrumentDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: BrokerageOperationDto, isArray: true })
|
||||||
|
entries: BrokerageOperationDto[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
jest.mock('./brokerage-operations.service', () => ({
|
||||||
|
BrokerageOperationsService: class BrokerageOperationsService {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { IncomeAnalyticsService } from './income-analytics.service';
|
||||||
|
|
||||||
|
describe('IncomeAnalyticsService', () => {
|
||||||
|
const operations = { collectRanges: jest.fn() };
|
||||||
|
const service = new IncomeAnalyticsService(operations as never);
|
||||||
|
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
it('aggregates only exact coupon and dividend payments by source currency', async () => {
|
||||||
|
operations.collectRanges.mockImplementation(
|
||||||
|
(
|
||||||
|
_user: string,
|
||||||
|
_account: string,
|
||||||
|
build: (opened?: Date) => unknown[],
|
||||||
|
) => {
|
||||||
|
build(undefined);
|
||||||
|
return {
|
||||||
|
pages: [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
items: [
|
||||||
|
entry('coupon', 'OPERATION_TYPE_COUPON', '10.25', 'RUB'),
|
||||||
|
entry('dividend', 'OPERATION_TYPE_DIVIDEND', '-2.5', 'RUB'),
|
||||||
|
entry(
|
||||||
|
'transfer',
|
||||||
|
'OPERATION_TYPE_DIVIDEND_TRANSFER',
|
||||||
|
'99',
|
||||||
|
'RUB',
|
||||||
|
),
|
||||||
|
entry('usd', 'OPERATION_TYPE_COUPON', '1', 'USD'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[{ items: [] }],
|
||||||
|
[{ items: [] }],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.analytics(
|
||||||
|
'user',
|
||||||
|
'account',
|
||||||
|
'2026-01-01',
|
||||||
|
'2026-01-31',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.totalsByCurrency).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
currency: 'RUB',
|
||||||
|
coupon: { currency: 'RUB', value: '10.25' },
|
||||||
|
dividend: { currency: 'RUB', value: '-2.5' },
|
||||||
|
total: { currency: 'RUB', value: '7.75' },
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
currency: 'USD',
|
||||||
|
total: { currency: 'USD', value: '1' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(result.entries.map((entry) => entry.id)).toEqual([
|
||||||
|
'coupon',
|
||||||
|
'dividend',
|
||||||
|
'usd',
|
||||||
|
]);
|
||||||
|
expect(result.buckets).toHaveLength(31);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function entry(id: string, type: string, value: string, currency: string) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
occurredAt: '2026-01-15T12:00:00.000Z',
|
||||||
|
type,
|
||||||
|
category: 'income',
|
||||||
|
description: id,
|
||||||
|
figi: null,
|
||||||
|
instrumentUid: null,
|
||||||
|
ticker: id,
|
||||||
|
instrumentType: null,
|
||||||
|
logoName: null,
|
||||||
|
payment: { currency, value },
|
||||||
|
quantity: null,
|
||||||
|
price: null,
|
||||||
|
accruedInterest: null,
|
||||||
|
commission: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
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<typeof decimalFromString>;
|
||||||
|
dividend: ReturnType<typeof decimalFromString>;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class IncomeAnalyticsService {
|
||||||
|
constructor(private readonly operations: BrokerageOperationsService) {}
|
||||||
|
|
||||||
|
async summary(userId: string, accountId: string): Promise<IncomeSummaryDto> {
|
||||||
|
let plan: ReturnType<typeof planIncomePeriod> | 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<IncomeAnalyticsDto> {
|
||||||
|
let plan: ReturnType<typeof planIncomePeriod> | 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<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: BrokerageOperationDto[]): IncomeInstrumentDto[] {
|
||||||
|
const groups = new Map<string, BrokerageOperationDto[]>();
|
||||||
|
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) },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
dateOnlyBounds,
|
||||||
|
planIncomePeriod,
|
||||||
|
rangeBuckets,
|
||||||
|
} from './income-period';
|
||||||
|
|
||||||
|
describe('income period planning', () => {
|
||||||
|
afterEach(() => jest.useRealTimers());
|
||||||
|
|
||||||
|
it('uses the Moscow current month and validates strict inclusive ranges', () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
jest.setSystemTime(new Date('2026-01-31T22:30:00.000Z'));
|
||||||
|
|
||||||
|
expect(planIncomePeriod(undefined, undefined)).toMatchObject({
|
||||||
|
selected: { from: '2026-02-01', to: '2026-02-01' },
|
||||||
|
});
|
||||||
|
for (const [from, to] of [
|
||||||
|
['2026-02-30', '2026-03-01'],
|
||||||
|
['2026-03-02', '2026-03-01'],
|
||||||
|
['2026-01-01', '2027-01-02'],
|
||||||
|
['2026-01-01', '2026-04-01'],
|
||||||
|
])
|
||||||
|
expect(() => planIncomePeriod(from, to)).toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a full previous month, skips impossible year-ago dates, and keeps empty buckets', () => {
|
||||||
|
const fullMonth = planIncomePeriod('2026-03-01', '2026-03-31');
|
||||||
|
expect(fullMonth.previous).toEqual({
|
||||||
|
from: '2026-02-01',
|
||||||
|
to: '2026-02-28',
|
||||||
|
});
|
||||||
|
expect(planIncomePeriod('2024-02-29', '2024-02-29').yearAgo).toBeNull();
|
||||||
|
expect(
|
||||||
|
rangeBuckets({ from: '2026-01-01', to: '2026-02-15' }, 'week'),
|
||||||
|
).toHaveLength(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not send the unfinished Moscow day as a future instant', () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
jest.setSystemTime(new Date('2026-08-27T21:44:18.510Z'));
|
||||||
|
|
||||||
|
expect(dateOnlyBounds({ from: '2026-08-01', to: '2026-08-28' })).toEqual({
|
||||||
|
from: new Date('2026-07-31T21:00:00.000Z'),
|
||||||
|
to: new Date('2026-08-27T21:44:18.510Z'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
|
export type DateOnlyRange = { from: string; to: string };
|
||||||
|
export type IncomeBucketGranularity = 'day' | 'week' | 'month';
|
||||||
|
|
||||||
|
const moscow = 'Europe/Moscow';
|
||||||
|
|
||||||
|
export function planIncomePeriod(
|
||||||
|
from: string | undefined,
|
||||||
|
to: string | undefined,
|
||||||
|
openedDate?: Date,
|
||||||
|
): {
|
||||||
|
selected: DateOnlyRange;
|
||||||
|
previous: DateOnlyRange;
|
||||||
|
yearAgo: DateOnlyRange | null;
|
||||||
|
granularity: IncomeBucketGranularity;
|
||||||
|
} {
|
||||||
|
if (Boolean(from) !== Boolean(to)) throw invalidPeriod();
|
||||||
|
const today = moscowDate(new Date());
|
||||||
|
const selected =
|
||||||
|
from && to ? { from, to } : { from: firstOfMonth(today), to: today };
|
||||||
|
assertRange(selected, today);
|
||||||
|
const previous = isFullMonth(selected)
|
||||||
|
? previousMonth(selected.from)
|
||||||
|
: shiftDays(selected, -rangeDays(selected));
|
||||||
|
const candidate = yearAgo(selected);
|
||||||
|
const yearAgoRange =
|
||||||
|
candidate && (!openedDate || candidate.from >= moscowDate(openedDate))
|
||||||
|
? candidate
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
selected,
|
||||||
|
previous,
|
||||||
|
yearAgo: yearAgoRange,
|
||||||
|
granularity:
|
||||||
|
rangeDays(selected) <= 45
|
||||||
|
? 'day'
|
||||||
|
: rangeDays(selected) <= 180
|
||||||
|
? 'week'
|
||||||
|
: 'month',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moscowDate(value: Date): string {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: moscow,
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
}).formatToParts(value);
|
||||||
|
const get = (type: string) => parts.find((part) => part.type === type)?.value;
|
||||||
|
return `${get('year')}-${get('month')}-${get('day')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dateOnlyBounds(range: DateOnlyRange): { from: Date; to: Date } {
|
||||||
|
const from = dateParts(range.from);
|
||||||
|
const end = dateParts(range.to);
|
||||||
|
const to = new Date(Date.UTC(end[0], end[1] - 1, end[2], 20, 59, 59, 999));
|
||||||
|
return {
|
||||||
|
from: new Date(Date.UTC(from[0], from[1] - 1, from[2], -3)),
|
||||||
|
to: to > new Date() ? new Date() : to,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rangeBuckets(
|
||||||
|
range: DateOnlyRange,
|
||||||
|
granularity: IncomeBucketGranularity,
|
||||||
|
): DateOnlyRange[] {
|
||||||
|
if (granularity === 'day')
|
||||||
|
return dates(range).map((date) => ({ from: date, to: date }));
|
||||||
|
const buckets: DateOnlyRange[] = [];
|
||||||
|
let cursor =
|
||||||
|
granularity === 'week'
|
||||||
|
? addDays(range.from, -((utcDate(range.from).getUTCDay() + 6) % 7))
|
||||||
|
: `${range.from.slice(0, 7)}-01`;
|
||||||
|
while (cursor <= range.to) {
|
||||||
|
const end =
|
||||||
|
granularity === 'week' ? addDays(cursor, 6) : endOfMonth(cursor);
|
||||||
|
buckets.push({
|
||||||
|
from: cursor < range.from ? range.from : cursor,
|
||||||
|
to: end > range.to ? range.to : end,
|
||||||
|
});
|
||||||
|
cursor = addDays(end, 1);
|
||||||
|
}
|
||||||
|
return buckets;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rangeDays(range: DateOnlyRange): number {
|
||||||
|
return (
|
||||||
|
Math.round(
|
||||||
|
(utcDate(range.to).valueOf() - utcDate(range.from).valueOf()) /
|
||||||
|
86_400_000,
|
||||||
|
) + 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertRange(range: DateOnlyRange, today: string): void {
|
||||||
|
try {
|
||||||
|
dateParts(range.from);
|
||||||
|
dateParts(range.to);
|
||||||
|
} catch {
|
||||||
|
throw invalidPeriod();
|
||||||
|
}
|
||||||
|
if (range.from > range.to || range.to > today || rangeDays(range) > 366)
|
||||||
|
throw invalidPeriod();
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateParts(value: string): [number, number, number] {
|
||||||
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
||||||
|
if (!match) throw new Error('Invalid date');
|
||||||
|
const parts = [Number(match[1]), Number(match[2]), Number(match[3])] as [
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
];
|
||||||
|
if (utcDate(value).toISOString().slice(0, 10) !== value)
|
||||||
|
throw new Error('Invalid date');
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function utcDate(value: string): Date {
|
||||||
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
||||||
|
if (!match) return new Date(NaN);
|
||||||
|
return new Date(
|
||||||
|
Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(value: string, days: number): string {
|
||||||
|
const date = utcDate(value);
|
||||||
|
date.setUTCDate(date.getUTCDate() + days);
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dates(range: DateOnlyRange): string[] {
|
||||||
|
const result: string[] = [];
|
||||||
|
for (let date = range.from; date <= range.to; date = addDays(date, 1))
|
||||||
|
result.push(date);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstOfMonth(value: string): string {
|
||||||
|
return `${value.slice(0, 7)}-01`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function endOfMonth(value: string): string {
|
||||||
|
const [year, month] = dateParts(firstOfMonth(value));
|
||||||
|
return new Date(Date.UTC(year, month, 0)).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFullMonth(range: DateOnlyRange): boolean {
|
||||||
|
return (
|
||||||
|
range.from === firstOfMonth(range.from) && range.to === endOfMonth(range.to)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function previousMonth(from: string): DateOnlyRange {
|
||||||
|
const last = addDays(firstOfMonth(from), -1);
|
||||||
|
return { from: firstOfMonth(last), to: last };
|
||||||
|
}
|
||||||
|
|
||||||
|
function shiftDays(range: DateOnlyRange, days: number): DateOnlyRange {
|
||||||
|
return { from: addDays(range.from, days), to: addDays(range.to, days) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function yearAgo(range: DateOnlyRange): DateOnlyRange | null {
|
||||||
|
const shift = (value: string) => {
|
||||||
|
const [year, month, day] = dateParts(value);
|
||||||
|
const result = `${String(year - 1).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||||
|
return utcDate(result).toISOString().slice(0, 10) === result
|
||||||
|
? result
|
||||||
|
: null;
|
||||||
|
};
|
||||||
|
const from = shift(range.from);
|
||||||
|
const to = shift(range.to);
|
||||||
|
return from && to ? { from, to } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidPeriod(): BadRequestException {
|
||||||
|
return new BadRequestException({ code: 'INVALID_INCOME_PERIOD' });
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ import { TbankConnectionService } from './tbank-connection.service';
|
|||||||
import { TbankTokenService } from './tbank-token.service';
|
import { TbankTokenService } from './tbank-token.service';
|
||||||
import { UpcomingEventsController } from './upcoming-events.controller';
|
import { UpcomingEventsController } from './upcoming-events.controller';
|
||||||
import { UpcomingEventsService } from './upcoming-events.service';
|
import { UpcomingEventsService } from './upcoming-events.service';
|
||||||
|
import { IncomeAnalyticsController } from './income-analytics.controller';
|
||||||
|
import { IncomeAnalyticsService } from './income-analytics.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [
|
controllers: [
|
||||||
@@ -22,6 +24,7 @@ import { UpcomingEventsService } from './upcoming-events.service';
|
|||||||
BrokerageOperationsController,
|
BrokerageOperationsController,
|
||||||
PortfolioController,
|
PortfolioController,
|
||||||
UpcomingEventsController,
|
UpcomingEventsController,
|
||||||
|
IncomeAnalyticsController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
TbankClient,
|
TbankClient,
|
||||||
@@ -34,6 +37,7 @@ import { UpcomingEventsService } from './upcoming-events.service';
|
|||||||
UpcomingEventsService,
|
UpcomingEventsService,
|
||||||
TbankTokenService,
|
TbankTokenService,
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
|
IncomeAnalyticsService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class TbankConnectionModule {}
|
export class TbankConnectionModule {}
|
||||||
|
|||||||
@@ -36,4 +36,7 @@ export class UpcomingEventsDto {
|
|||||||
|
|
||||||
@ApiProperty({ type: UpcomingEventDto, isArray: true })
|
@ApiProperty({ type: UpcomingEventDto, isArray: true })
|
||||||
events: UpcomingEventDto[];
|
events: UpcomingEventDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: MoneyDto, isArray: true })
|
||||||
|
incomeTotalsByCurrency: MoneyDto[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -216,6 +216,36 @@ describe('UpcomingEventsService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('adds exact source-currency income totals without changing the events list', async () => {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
tbank.getPortfolio.mockResolvedValue({
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
figi: 'B',
|
||||||
|
ticker: 'BOND',
|
||||||
|
instrumentType: 'bond',
|
||||||
|
quantity: { units: 1 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
moex.bondEvents.mockResolvedValue([
|
||||||
|
{ type: 'coupon', date: today, value: '1.25', currency: 'RUB' },
|
||||||
|
{ type: 'maturity', date: today, value: '100', currency: 'RUB' },
|
||||||
|
{ type: 'offer', date: today },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await service.get('user', 'account');
|
||||||
|
expect(result.incomeTotalsByCurrency).toEqual([
|
||||||
|
{ currency: 'RUB', value: '1.25' },
|
||||||
|
]);
|
||||||
|
expect(result.events).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ type: 'maturity' }),
|
||||||
|
expect.objectContaining({ type: 'offer' }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('reuses upstream results within the cache TTL across requests', async () => {
|
it('reuses upstream results within the cache TTL across requests', async () => {
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
tbank.getPortfolio.mockResolvedValue({
|
tbank.getPortfolio.mockResolvedValue({
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { RequestContextService } from '../common/logging/request-context.service';
|
import { RequestContextService } from '../common/logging/request-context.service';
|
||||||
import { StructuredLogger } from '../common/logging/structured-logger.service';
|
import { StructuredLogger } from '../common/logging/structured-logger.service';
|
||||||
import { decimal, formatDecimal, isZero, multiply } from './decimal';
|
import {
|
||||||
|
add,
|
||||||
|
decimal,
|
||||||
|
decimalFromString,
|
||||||
|
formatDecimal,
|
||||||
|
isZero,
|
||||||
|
multiply,
|
||||||
|
} from './decimal';
|
||||||
import { MoexBondEvent, MoexClient } from './moex.client';
|
import { MoexBondEvent, MoexClient } from './moex.client';
|
||||||
import { MoneyDto } from './portfolio.dto';
|
import { MoneyDto } from './portfolio.dto';
|
||||||
import { TbankAccessService } from './tbank-access.service';
|
import { TbankAccessService } from './tbank-access.service';
|
||||||
@@ -78,6 +85,7 @@ export class UpcomingEventsService {
|
|||||||
left.type.localeCompare(right.type) ||
|
left.type.localeCompare(right.type) ||
|
||||||
left.ticker.localeCompare(right.ticker),
|
left.ticker.localeCompare(right.ticker),
|
||||||
),
|
),
|
||||||
|
incomeTotalsByCurrency: incomeTotals(projected),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -245,11 +253,21 @@ function projectedMoney(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function decimalFromString(value: string) {
|
function incomeTotals(events: UpcomingEventDto[]): MoneyDto[] {
|
||||||
const negative = value.startsWith('-');
|
const totals = new Map<string, ReturnType<typeof decimal>>();
|
||||||
const [whole, fraction = ''] = (negative ? value.slice(1) : value).split('.');
|
for (const event of events) {
|
||||||
return {
|
if (!['coupon', 'dividend'].includes(event.type) || !event.projectedAmount)
|
||||||
value: (negative ? -1n : 1n) * BigInt(`${whole}${fraction}`),
|
continue;
|
||||||
scale: fraction.length,
|
const current = totals.get(event.projectedAmount.currency) ?? {
|
||||||
};
|
value: 0n,
|
||||||
|
scale: 0,
|
||||||
|
};
|
||||||
|
totals.set(
|
||||||
|
event.projectedAmount.currency,
|
||||||
|
add(current, decimalFromString(event.projectedAmount.value)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return [...totals]
|
||||||
|
.sort(([left], [right]) => left.localeCompare(right))
|
||||||
|
.map(([currency, value]) => ({ currency, value: formatDecimal(value) }));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user