jest.mock('../../../prisma/prisma.service', () => ({ PrismaService: class {}, })); jest.mock('../operations/application/brokerage-operations.service', () => ({ BrokerageOperationsService: class BrokerageOperationsService {}, })); import assert from 'node:assert/strict'; import { investmentResourceMeta } from '../investment-resource.mapper'; import { BrokerageOperationDto } from '../operations/presentation/brokerage-operation.dto'; import { IncomeAnalyticsService } from './income-analytics.service'; describe('IncomeAnalyticsService', () => { const operations = { collectRanges: jest.fn() }; const service = new IncomeAnalyticsService(operations as never); beforeEach(() => jest.clearAllMocks()); it('labels each comparison dependency independently of its resource key', async () => { seed([]); const response = await service.analytics( 'user', 'account', '2026-01-01', '2026-01-31', ); expect(response.meta.dependencies).toEqual([ expect.objectContaining({ role: 'selected', resourceKey: 'period/0' }), expect.objectContaining({ role: 'previous', resourceKey: 'period/1' }), expect.objectContaining({ role: 'year', resourceKey: 'period/2' }), ]); }); it('labels an unavailable leap-day comparison as year', async () => { operations.collectRanges.mockImplementation( (_user: string, _account: string, build: () => unknown[]) => build().map((_, index) => ({ items: [], meta: completeMeta(index) })), ); const response = await service.analytics( 'user', 'account', '2024-02-29', '2024-02-29', ); expect(response.meta.dependencies).toEqual([ expect.objectContaining({ role: 'selected' }), expect.objectContaining({ role: 'previous' }), expect.objectContaining({ role: 'year', availability: 'temporarily-unavailable', unavailableReason: 'CALENDAR_OR_ACCOUNT_HISTORY', }), ]); expect(response.data?.yearComparison).toBeNull(); }); function seed(items: BrokerageOperationDto[]) { operations.collectRanges.mockImplementation( ( _user: string, _account: string, build: (opened?: Date) => unknown[], ) => { build(undefined); return [items, [], []].map((items, index) => ({ items, meta: completeMeta(index), })); }, ); } 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 response = await service.analytics( 'user', 'account', '2026-01-01', '2026-01-31', ); const result = response.data; assert(result); 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 response = await service.analytics( 'user', 'account', '2026-01-01', '2026-01-31', ); const result = response.data; assert(result); 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('assigns signed payments to Moscow days and retains empty buckets', async () => { seed([ { ...entry('before', 'OPERATION_TYPE_COUPON', '0.1', 'RUB'), occurredAt: '2026-01-14T20:59:59.999Z', }, { ...entry('after', 'OPERATION_TYPE_COUPON', '0.2', 'RUB'), occurredAt: '2026-01-14T21:00:00.000Z', }, { ...entry('reversal', 'OPERATION_TYPE_DIVIDEND', '-0.1', 'RUB'), occurredAt: '2026-01-15T20:59:59.999Z', }, ]); const response = await service.analytics( 'user', 'account', '2026-01-14', '2026-01-16', ); expect(response.data?.buckets).toEqual([ { period: { from: '2026-01-14', to: '2026-01-14' }, totalsByCurrency: [expect.objectContaining({ total: { currency: 'RUB', value: '0.1' }, })], }, { period: { from: '2026-01-15', to: '2026-01-15' }, totalsByCurrency: [expect.objectContaining({ coupon: { currency: 'RUB', value: '0.2' }, dividend: { currency: 'RUB', value: '-0.1' }, total: { currency: 'RUB', value: '0.1' }, })], }, { period: { from: '2026-01-16', to: '2026-01-16' }, totalsByCurrency: [], }, ]); }); 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 [ [ 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'), ], [], [], ].map((items, index) => ({ items, meta: completeMeta(index) })); }, ); const response = await service.analytics( 'user', 'account', '2026-01-01', '2026-01-31', ); const result = response.data; assert(result); 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, ): BrokerageOperationDto { 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, }; } function completeMeta(index: number) { return investmentResourceMeta({ resourceKey: `period/${index}`, dataRevision: '1', availability: 'complete', fetchedAt: new Date('2026-02-01Z'), freshForMs: Infinity, work: null, }); }