diff --git a/src/tbank/income-analytics.dto.ts b/src/tbank/income-analytics.dto.ts index 60a8c8e..59f9c44 100644 --- a/src/tbank/income-analytics.dto.ts +++ b/src/tbank/income-analytics.dto.ts @@ -115,6 +115,11 @@ export class IncomeInstrumentDto { receiptCount: number; } +export class IncomeAnalyticsEntryDto extends BrokerageOperationDto { + @ApiProperty() + instrumentKey: string; +} + export class IncomeAnalyticsDto extends IncomeSummaryDto { @ApiProperty({ type: IncomeComparisonDto }) previous: IncomeComparisonDto; @@ -131,6 +136,6 @@ export class IncomeAnalyticsDto extends IncomeSummaryDto { @ApiProperty({ type: IncomeInstrumentDto, isArray: true }) instruments: IncomeInstrumentDto[]; - @ApiProperty({ type: BrokerageOperationDto, isArray: true }) - entries: BrokerageOperationDto[]; + @ApiProperty({ type: IncomeAnalyticsEntryDto, isArray: true }) + entries: IncomeAnalyticsEntryDto[]; } diff --git a/src/tbank/income-analytics.service.spec.ts b/src/tbank/income-analytics.service.spec.ts index fdd0861..d5c4f4b 100644 --- a/src/tbank/income-analytics.service.spec.ts +++ b/src/tbank/income-analytics.service.spec.ts @@ -2,6 +2,7 @@ jest.mock('./brokerage-operations.service', () => ({ BrokerageOperationsService: class BrokerageOperationsService {}, })); +import { BrokerageOperationDto } from './brokerage-operation.dto'; import { IncomeAnalyticsService } from './income-analytics.service'; describe('IncomeAnalyticsService', () => { @@ -10,6 +11,133 @@ describe('IncomeAnalyticsService', () => { beforeEach(() => jest.clearAllMocks()); + function seed(items: BrokerageOperationDto[]) { + operations.collectRanges.mockImplementation( + ( + _user: string, + _account: string, + build: (opened?: Date) => unknown[], + ) => { + build(undefined); + return { pages: [[{ items }], [{ items: [] }], [{ items: [] }]] }; + }, + ); + } + + it.each([ + { + identity: { + instrumentUid: 'UID', + figi: 'FIGI', + ticker: 'T', + instrumentType: 'bond', + }, + key: 'uid:UID:RUB', + }, + { + identity: { figi: 'FIGI', ticker: 'T', instrumentType: 'bond' }, + key: 'figi:FIGI:RUB', + }, + { + identity: { ticker: 'T', instrumentType: 'bond' }, + key: 'ticker:T:bond:RUB', + }, + { identity: { ticker: 'T' }, key: 'ticker:T::RUB' }, + { + identity: { ticker: null, instrumentType: 'bond' }, + key: 'ticker::bond:RUB', + }, + { identity: { ticker: null }, key: 'operation:receipt:RUB' }, + ])( + 'uses one membership for entry and aggregate: $key', + async ({ identity, key }) => { + const payment = { + ...entry('receipt', 'OPERATION_TYPE_COUPON', '10.25', 'RUB'), + ...identity, + }; + seed([payment]); + + const result = await service.analytics( + 'user', + 'account', + '2026-01-01', + '2026-01-31', + ); + + expect(result.entries).toEqual([{ ...payment, instrumentKey: key }]); + expect(result.instruments).toEqual([ + expect.objectContaining({ + key, + receiptCount: 1, + total: { currency: 'RUB', value: '10.25' }, + }), + ]); + }, + ); + + it('preserves entry order, repeated IDs, currency groups and signed exact totals', async () => { + const selected = [ + { + ...entry('same-id', 'OPERATION_TYPE_COUPON', '10.25', 'RUB'), + instrumentUid: 'UID', + }, + { + ...entry('same-id', 'OPERATION_TYPE_DIVIDEND', '-0.25', 'RUB'), + instrumentUid: 'UID', + }, + { + ...entry('usd', 'OPERATION_TYPE_COUPON', '20', 'USD'), + instrumentUid: 'UID', + }, + ]; + seed(selected); + + const result = await service.analytics( + 'user', + 'account', + '2026-01-01', + '2026-01-31', + ); + + expect(result.entries).toEqual( + selected.map((item) => ({ + ...item, + instrumentKey: `uid:UID:${item.payment?.currency}`, + })), + ); + expect( + result.instruments.map( + ({ key, receiptCount, coupon, dividend, total }) => ({ + key, + receiptCount, + coupon, + dividend, + total, + }), + ), + ).toEqual([ + { + key: 'uid:UID:USD', + receiptCount: 1, + coupon: { currency: 'USD', value: '20' }, + dividend: { currency: 'USD', value: '0' }, + total: { currency: 'USD', value: '20' }, + }, + { + key: 'uid:UID:RUB', + receiptCount: 2, + coupon: { currency: 'RUB', value: '10.25' }, + dividend: { currency: 'RUB', value: '-0.25' }, + total: { currency: 'RUB', value: '10' }, + }, + ]); + expect( + result.entries + .filter((item) => item.instrumentKey === 'uid:UID:RUB') + .map((item) => item.payment?.value), + ).toEqual(['10.25', '-0.25']); + }); + it('aggregates only exact coupon and dividend payments by source currency', async () => { operations.collectRanges.mockImplementation( ( @@ -70,7 +198,12 @@ describe('IncomeAnalyticsService', () => { }); }); -function entry(id: string, type: string, value: string, currency: string) { +function entry( + id: string, + type: string, + value: string, + currency: string, +): BrokerageOperationDto { return { id, occurredAt: '2026-01-15T12:00:00.000Z', diff --git a/src/tbank/income-analytics.service.ts b/src/tbank/income-analytics.service.ts index 7d697a5..2713afe 100644 --- a/src/tbank/income-analytics.service.ts +++ b/src/tbank/income-analytics.service.ts @@ -17,6 +17,7 @@ import { } from './income-period'; import { IncomeAnalyticsDto, + IncomeAnalyticsEntryDto, IncomeComparisonDto, IncomeCurrencySummaryDto, IncomeInstrumentDto, @@ -75,7 +76,10 @@ export class IncomeAnalyticsService { if (!plan) throw new Error('Income period was not planned'); const selected = qualifying( collected.pages[0].flatMap((page) => page.items), - ); + ).map((entry) => ({ + ...entry, + instrumentKey: `${instrumentKey(entry)}:${entry.payment?.currency ?? ''}`, + })); const previous = qualifying( collected.pages[1].flatMap((page) => page.items), ); @@ -184,11 +188,14 @@ function buckets( })); } -function instruments(entries: BrokerageOperationDto[]): IncomeInstrumentDto[] { - const groups = new Map(); +function instruments( + entries: IncomeAnalyticsEntryDto[], +): IncomeInstrumentDto[] { + const groups = new Map(); for (const entry of entries) { - const key = `${instrumentKey(entry)}:${entry.payment?.currency ?? ''}`; - groups.set(key, [...(groups.get(key) ?? []), entry]); + const group = groups.get(entry.instrumentKey); + if (group) group.push(entry); + else groups.set(entry.instrumentKey, [entry]); } return [...groups] .map(([key, group]) => {