feat(income): add account income analytics API
This commit is contained in:
@@ -254,6 +254,50 @@ describe('BrokerageOperationsService', () => {
|
||||
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 () => {
|
||||
for (let index = 0; index < 100; index += 1)
|
||||
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 { RequestContextService } from '../common/logging/request-context.service';
|
||||
import {
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { InstrumentMetadataService } from './instrument-metadata.service';
|
||||
import { TbankAccessService } from './tbank-access.service';
|
||||
import { TbankClient } from './tbank.client';
|
||||
import { DateOnlyRange, dateOnlyBounds } from './income-period';
|
||||
|
||||
const freshForMs = 30_000;
|
||||
const maxCachedPages = 100;
|
||||
@@ -39,51 +40,123 @@ export class BrokerageOperationsService {
|
||||
this.requestContext.set({ userId, brokerageAccountId: accountId });
|
||||
const range = this.range(query);
|
||||
|
||||
return this.access.withAccount(userId, accountId, async ({ token }) => {
|
||||
const key = cacheKey(userId, accountId, query, range);
|
||||
const cached = this.pages.get(key);
|
||||
if (cached) return cached;
|
||||
return this.access.withAccount(userId, accountId, ({ token }) =>
|
||||
this.page(token, userId, accountId, query, range),
|
||||
);
|
||||
}
|
||||
|
||||
if (range.from > range.to) {
|
||||
const page = emptyPage(accountId);
|
||||
this.pages.set(key, 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,
|
||||
async collectRanges(
|
||||
userId: string,
|
||||
accountId: string,
|
||||
ranges:
|
||||
| DateOnlyRange[]
|
||||
| ((accountOpenedAt: Date | undefined) => DateOnlyRange[]),
|
||||
): Promise<{
|
||||
accountOpenedAt: Date | undefined;
|
||||
pages: BrokerageOperationsPageDto[][];
|
||||
}> {
|
||||
this.requestContext.set({ userId, brokerageAccountId: accountId });
|
||||
return this.access.withAccount(
|
||||
userId,
|
||||
accountId,
|
||||
async ({ token, account }) => {
|
||||
const accountOpenedAt = account.openedDate?.seconds
|
||||
? new Date(Number(account.openedDate.seconds) * 1000)
|
||||
: undefined;
|
||||
const selected =
|
||||
typeof ranges === 'function' ? ranges(accountOpenedAt) : ranges;
|
||||
return {
|
||||
accountOpenedAt,
|
||||
pages: await Promise.all(
|
||||
selected.map((range) =>
|
||||
this.collectRange(token, userId, accountId, range),
|
||||
),
|
||||
})),
|
||||
),
|
||||
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);
|
||||
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) {
|
||||
|
||||
@@ -3,6 +3,17 @@ const NANO = 1_000_000_000n;
|
||||
export type ProtoDecimal = { units?: string | number; nano?: string | 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 {
|
||||
return {
|
||||
value: BigInt(value.units ?? 0) * NANO + BigInt(value.nano ?? 0),
|
||||
@@ -32,9 +43,15 @@ export function isZero(value: Decimal): boolean {
|
||||
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 {
|
||||
if (value === 0n) return '0';
|
||||
const sign = value < 0n ? '-' : '';
|
||||
if (scale === 0) return `${sign}${value < 0n ? -value : value}`;
|
||||
const digits = (value < 0n ? -value : value)
|
||||
.toString()
|
||||
.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 { UpcomingEventsController } from './upcoming-events.controller';
|
||||
import { UpcomingEventsService } from './upcoming-events.service';
|
||||
import { IncomeAnalyticsController } from './income-analytics.controller';
|
||||
import { IncomeAnalyticsService } from './income-analytics.service';
|
||||
|
||||
@Module({
|
||||
controllers: [
|
||||
@@ -22,6 +24,7 @@ import { UpcomingEventsService } from './upcoming-events.service';
|
||||
BrokerageOperationsController,
|
||||
PortfolioController,
|
||||
UpcomingEventsController,
|
||||
IncomeAnalyticsController,
|
||||
],
|
||||
providers: [
|
||||
TbankClient,
|
||||
@@ -34,6 +37,7 @@ import { UpcomingEventsService } from './upcoming-events.service';
|
||||
UpcomingEventsService,
|
||||
TbankTokenService,
|
||||
TbankAccessService,
|
||||
IncomeAnalyticsService,
|
||||
],
|
||||
})
|
||||
export class TbankConnectionModule {}
|
||||
|
||||
@@ -36,4 +36,7 @@ export class UpcomingEventsDto {
|
||||
|
||||
@ApiProperty({ type: UpcomingEventDto, isArray: true })
|
||||
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 () => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
tbank.getPortfolio.mockResolvedValue({
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { RequestContextService } from '../common/logging/request-context.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 { MoneyDto } from './portfolio.dto';
|
||||
import { TbankAccessService } from './tbank-access.service';
|
||||
@@ -78,6 +85,7 @@ export class UpcomingEventsService {
|
||||
left.type.localeCompare(right.type) ||
|
||||
left.ticker.localeCompare(right.ticker),
|
||||
),
|
||||
incomeTotalsByCurrency: incomeTotals(projected),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -245,11 +253,21 @@ function projectedMoney(
|
||||
};
|
||||
}
|
||||
|
||||
function decimalFromString(value: 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,
|
||||
};
|
||||
function incomeTotals(events: UpcomingEventDto[]): MoneyDto[] {
|
||||
const totals = new Map<string, ReturnType<typeof decimal>>();
|
||||
for (const event of events) {
|
||||
if (!['coupon', 'dividend'].includes(event.type) || !event.projectedAmount)
|
||||
continue;
|
||||
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