diff --git a/src/tbank/brokerage-operations.service.spec.ts b/src/tbank/brokerage-operations.service.spec.ts index 3495f8a..b22ff5a 100644 --- a/src/tbank/brokerage-operations.service.spec.ts +++ b/src/tbank/brokerage-operations.service.spec.ts @@ -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) }); diff --git a/src/tbank/brokerage-operations.service.ts b/src/tbank/brokerage-operations.service.ts index ff8011b..e132044 100644 --- a/src/tbank/brokerage-operations.service.ts +++ b/src/tbank/brokerage-operations.service.ts @@ -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>(); - 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 { + const pages: BrokerageOperationsPageDto[] = []; + const seen = new Set(); + 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 { + 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>(); + 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) { diff --git a/src/tbank/decimal.ts b/src/tbank/decimal.ts index f2c952c..dd6236a 100644 --- a/src/tbank/decimal.ts +++ b/src/tbank/decimal.ts @@ -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'); diff --git a/src/tbank/income-analytics.controller.ts b/src/tbank/income-analytics.controller.ts new file mode 100644 index 0000000..e8744a5 --- /dev/null +++ b/src/tbank/income-analytics.controller.ts @@ -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, + ); + } +} diff --git a/src/tbank/income-analytics.dto.ts b/src/tbank/income-analytics.dto.ts new file mode 100644 index 0000000..60a8c8e --- /dev/null +++ b/src/tbank/income-analytics.dto.ts @@ -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[]; +} diff --git a/src/tbank/income-analytics.service.spec.ts b/src/tbank/income-analytics.service.spec.ts new file mode 100644 index 0000000..fdd0861 --- /dev/null +++ b/src/tbank/income-analytics.service.spec.ts @@ -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, + }; +} diff --git a/src/tbank/income-analytics.service.ts b/src/tbank/income-analytics.service.ts new file mode 100644 index 0000000..7d697a5 --- /dev/null +++ b/src/tbank/income-analytics.service.ts @@ -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; + dividend: ReturnType; +}; + +@Injectable() +export class IncomeAnalyticsService { + constructor(private readonly operations: BrokerageOperationsService) {} + + async summary(userId: string, accountId: string): Promise { + let plan: ReturnType | undefined; + const collected = await this.operations.collectRanges( + userId, + accountId, + (openedAt) => { + const planned = planIncomePeriod(undefined, undefined, openedAt); + plan = planned; + return [planned.selected]; + }, + ); + if (!plan) throw new Error('Income period was not planned'); + return summaryResponse( + accountId, + plan.selected, + qualifying(collected.pages[0].flatMap((page) => page.items)), + new Date().toISOString(), + ); + } + + async analytics( + userId: string, + accountId: string, + from: string | undefined, + to: string | undefined, + ): Promise { + let plan: ReturnType | undefined; + const collected = await this.operations.collectRanges( + userId, + accountId, + (openedAt) => { + const planned = planIncomePeriod(from, to, openedAt); + plan = planned; + return [ + planned.selected, + planned.previous, + ...(planned.yearAgo ? [planned.yearAgo] : []), + ]; + }, + ); + if (!plan) throw new Error('Income period was not planned'); + const selected = qualifying( + collected.pages[0].flatMap((page) => page.items), + ); + const previous = qualifying( + collected.pages[1].flatMap((page) => page.items), + ); + const yearAgo = plan.yearAgo + ? qualifying(collected.pages[2].flatMap((page) => page.items)) + : null; + const calculatedAt = new Date().toISOString(); + const base = summaryResponse( + accountId, + plan.selected, + selected, + calculatedAt, + ); + return { + ...base, + previous: comparison(selected, plan.previous, previous), + yearComparison: + plan.yearAgo && yearAgo + ? comparison(selected, plan.yearAgo, yearAgo) + : null, + bucketGranularity: plan.granularity, + buckets: buckets(plan.selected, plan.granularity, selected), + instruments: instruments(selected), + entries: selected, + }; + } +} + +function qualifying(entries: BrokerageOperationDto[]): BrokerageOperationDto[] { + return entries.filter( + (entry) => + Boolean(entry.payment) && + ['OPERATION_TYPE_COUPON', 'OPERATION_TYPE_DIVIDEND'].includes(entry.type), + ); +} + +function summaryResponse( + accountId: string, + period: DateOnlyRange, + entries: BrokerageOperationDto[], + calculatedAt: string, +): IncomeSummaryDto { + return { accountId, period, totalsByCurrency: totals(entries), calculatedAt }; +} + +function totals(entries: BrokerageOperationDto[]): IncomeCurrencySummaryDto[] { + const values = new Map(); + for (const entry of entries) { + if (!entry.payment) continue; + const total = values.get(entry.payment.currency) ?? zeroTotal(); + const value = decimalFromString(entry.payment.value); + if (entry.type === 'OPERATION_TYPE_COUPON') + total.coupon = add(total.coupon, value); + else total.dividend = add(total.dividend, value); + values.set(entry.payment.currency, total); + } + return [...values] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([currency, total]) => presentTotal(currency, total)); +} + +function comparison( + selectedEntries: BrokerageOperationDto[], + comparedPeriod: DateOnlyRange, + comparedEntries: BrokerageOperationDto[], +): IncomeComparisonDto { + const selected = new Map( + totals(selectedEntries).map((total) => [total.currency, total]), + ); + const compared = new Map( + totals(comparedEntries).map((total) => [total.currency, total]), + ); + const currencies = [ + ...new Set([...selected.keys(), ...compared.keys()]), + ].sort(); + return { + period: comparedPeriod, + totalsByCurrency: currencies.map((currency) => { + const current = + selected.get(currency) ?? presentTotal(currency, zeroTotal()); + const prior = + compared.get(currency) ?? presentTotal(currency, zeroTotal()); + return { + currency, + selected: current, + compared: prior, + difference: difference(current, prior), + }; + }), + }; +} + +function buckets( + range: DateOnlyRange, + granularity: IncomeBucketGranularity, + entries: BrokerageOperationDto[], +) { + return rangeBuckets(range, granularity).map((period) => ({ + period, + totalsByCurrency: totals( + entries.filter((entry) => { + const date = moscowDate(new Date(entry.occurredAt)); + return date >= period.from && date <= period.to; + }), + ), + })); +} + +function instruments(entries: BrokerageOperationDto[]): IncomeInstrumentDto[] { + const groups = new Map(); + for (const entry of entries) { + const key = `${instrumentKey(entry)}:${entry.payment?.currency ?? ''}`; + groups.set(key, [...(groups.get(key) ?? []), entry]); + } + return [...groups] + .map(([key, group]) => { + const first = group[0]; + const [total] = totals(group); + return { + key, + figi: first.figi, + instrumentUid: first.instrumentUid, + ticker: first.ticker, + instrumentType: first.instrumentType, + displayName: first.ticker ?? (first.description || first.type), + coupon: total.coupon, + dividend: total.dividend, + total: total.total, + receiptCount: group.length, + }; + }) + .sort( + (left, right) => + compare( + decimalFromString(right.total.value), + decimalFromString(left.total.value), + ) || left.key.localeCompare(right.key), + ); +} + +function instrumentKey(entry: BrokerageOperationDto): string { + if (entry.instrumentUid) return `uid:${entry.instrumentUid}`; + if (entry.figi) return `figi:${entry.figi}`; + if (entry.ticker || entry.instrumentType) + return `ticker:${entry.ticker ?? ''}:${entry.instrumentType ?? ''}`; + return `operation:${entry.id}`; +} + +function difference( + left: IncomeCurrencySummaryDto, + right: IncomeCurrencySummaryDto, +): IncomeCurrencySummaryDto { + return presentTotal(left.currency, { + coupon: subtract( + decimalFromString(left.coupon.value), + decimalFromString(right.coupon.value), + ), + dividend: subtract( + decimalFromString(left.dividend.value), + decimalFromString(right.dividend.value), + ), + }); +} + +function zeroTotal(): Total { + return { coupon: { value: 0n, scale: 0 }, dividend: { value: 0n, scale: 0 } }; +} + +function presentTotal( + currency: string, + value: Total, +): IncomeCurrencySummaryDto { + const total = add(value.coupon, value.dividend); + return { + currency, + coupon: { currency, value: formatDecimal(value.coupon) }, + dividend: { currency, value: formatDecimal(value.dividend) }, + total: { currency, value: formatDecimal(total) }, + }; +} diff --git a/src/tbank/income-period.spec.ts b/src/tbank/income-period.spec.ts new file mode 100644 index 0000000..bc15d38 --- /dev/null +++ b/src/tbank/income-period.spec.ts @@ -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'), + }); + }); +}); diff --git a/src/tbank/income-period.ts b/src/tbank/income-period.ts new file mode 100644 index 0000000..59a06f0 --- /dev/null +++ b/src/tbank/income-period.ts @@ -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' }); +} diff --git a/src/tbank/tbank-connection.module.ts b/src/tbank/tbank-connection.module.ts index 4757a82..68bb029 100644 --- a/src/tbank/tbank-connection.module.ts +++ b/src/tbank/tbank-connection.module.ts @@ -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 {} diff --git a/src/tbank/upcoming-events.dto.ts b/src/tbank/upcoming-events.dto.ts index 0f04e13..5360561 100644 --- a/src/tbank/upcoming-events.dto.ts +++ b/src/tbank/upcoming-events.dto.ts @@ -36,4 +36,7 @@ export class UpcomingEventsDto { @ApiProperty({ type: UpcomingEventDto, isArray: true }) events: UpcomingEventDto[]; + + @ApiProperty({ type: MoneyDto, isArray: true }) + incomeTotalsByCurrency: MoneyDto[]; } diff --git a/src/tbank/upcoming-events.service.spec.ts b/src/tbank/upcoming-events.service.spec.ts index 97be051..3a8e49b 100644 --- a/src/tbank/upcoming-events.service.spec.ts +++ b/src/tbank/upcoming-events.service.spec.ts @@ -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({ diff --git a/src/tbank/upcoming-events.service.ts b/src/tbank/upcoming-events.service.ts index b21f40f..229a112 100644 --- a/src/tbank/upcoming-events.service.ts +++ b/src/tbank/upcoming-events.service.ts @@ -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>(); + 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) })); }