feat(income): add account income analytics API

This commit is contained in:
2026-08-28 01:25:52 +03:00
parent 924f60f653
commit 7ba4da0d1b
13 changed files with 997 additions and 49 deletions
@@ -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) });
+114 -41
View File
@@ -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) {
+17
View File
@@ -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');
+45
View File
@@ -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,
);
}
}
+136
View File
@@ -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,
};
}
+258
View File
@@ -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) },
};
}
+48
View File
@@ -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'),
});
});
});
+181
View File
@@ -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' });
}
+4
View File
@@ -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 {}
+3
View File
@@ -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[];
} }
+30
View File
@@ -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({
+26 -8
View File
@@ -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) }));
} }