import { NotFoundException } from '@nestjs/common'; import { CacheService } from '../../cache/cache.service'; import { MoexClientService } from '../../moex-client/moex-client.service'; import { BrokerAccountsService } from './broker-accounts.service'; import { BrokerEventsService } from './broker-events.service'; import { BrokerOperationsService } from './broker-operations.service'; import { BrokerPortfolioService } from './broker-portfolio.service'; describe('BrokerEventsService', () => { const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService; const portfolio = { getPositionsWithInstruments: vi.fn() } as unknown as BrokerPortfolioService; const moex = { getDividends: vi.fn(), getBondPositionDataBatch: vi.fn(), } as unknown as MoexClientService; const operations = { getOperations: vi.fn() } as unknown as BrokerOperationsService; const cache = { getOrFetch: vi.fn() } as unknown as CacheService; const acc1 = { id: 'acc-1', type: 'brokerage' as const, name: 'Test Broker', status: 'ACCOUNT_STATUS_OPEN', openedAt: null, accessLevel: null, }; beforeEach(() => { vi.clearAllMocks(); }); function mockCachePassthrough() { vi.mocked(cache.getOrFetch).mockImplementation( async (_prefix: string, _parts: string[], fetchFn: () => Promise) => ({ data: await fetchFn(), fromCache: false, cachedAt: null, }), ); } it('throws 404 for missing account', async () => { vi.mocked(accounts.findById).mockResolvedValue(null); const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache); await expect( service.getEvents('missing', { from: '2026-06-01', to: '2026-07-01' }), ).rejects.toThrow(NotFoundException); }); it('returns empty events for account with no positions', async () => { vi.mocked(accounts.findById).mockResolvedValue(acc1); mockCachePassthrough(); vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({ positions: [], instruments: new Map(), }); vi.mocked(operations.getOperations).mockResolvedValue({ data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-01' }, meta: { fromCache: false, cachedAt: null }, }); const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache); const result = await service.getEvents('acc-1', { from: '2026-06-01', to: '2026-07-01' }); expect(result.data.items).toEqual([]); expect(result.data.summary.eventCount).toBe(0); expect(result.data.summary.nearestEventDate).toBeNull(); expect(result.data.summary.totalEstimatedCashflow).toBe(0); expect(result.data.summary.actualCashflow).toBe(0); expect(result.data.summary.forecastEstimatedCashflow).toBe(0); }); it('builds dividend events from share positions in date range', async () => { vi.mocked(accounts.findById).mockResolvedValue(acc1); mockCachePassthrough(); vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({ positions: [ { ticker: 'SBER', instrumentUid: 'uid-sber', instrumentType: 'share', quantity: { units: '10', nano: 0 }, }, ], instruments: new Map([['uid-sber', { name: 'Sberbank', currency: 'RUB' }]]), }); vi.mocked(moex.getDividends).mockResolvedValue([ { secid: 'SBER', isin: 'RU000A0JS', registryCloseDate: '2026-06-15', value: 33.5, currencyId: 'RUB', }, { secid: 'SBER', isin: 'RU000A0JS', registryCloseDate: '2026-06-25', value: 33.5, currencyId: 'RUB', }, { secid: 'SBER', isin: 'RU000A0JS', registryCloseDate: '2026-08-01', value: 33.5, currencyId: 'RUB', }, ]); vi.mocked(operations.getOperations).mockResolvedValue({ data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' }, meta: { fromCache: false, cachedAt: null }, }); const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache); const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' }); expect(result.data.items).toHaveLength(1); expect(result.data.items[0].type).toBe('dividend'); expect(result.data.items[0].eventDate).toBe('2026-06-25'); expect(result.data.items[0].estimatedAmount).toBe(335); expect(result.data.items[0].actualAmount).toBeNull(); expect(result.data.items[0].source).toBe('forecast'); expect(result.data.items[0].currency).toBe('RUB'); expect(result.data.items[0].name).toBe('Sberbank'); }); it('builds coupon, maturity, and offer events for bonds', async () => { vi.mocked(accounts.findById).mockResolvedValue(acc1); mockCachePassthrough(); vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({ positions: [ { ticker: 'SU26248RMFS4', instrumentUid: 'uid-bond-1', instrumentType: 'bond', quantity: { units: '5', nano: 0 }, }, ], instruments: new Map([['uid-bond-1', { name: 'OFZ 26248', currency: 'RUB' }]]), }); vi.mocked(moex.getBondPositionDataBatch).mockResolvedValue([ { secid: 'SU26248RMFS4', couponValue: 35.4, nextCouponDate: '2026-06-25', matDate: '2026-06-27', offerDate: '2026-06-28', faceValue: 1000, boardid: 'TQCB', shortName: '', price: null, yieldToMaturity: null, duration: null, couponPercent: null, accruedInt: null, bid: null, offer: null, couponPeriod: null, bondType: null, }, ]); vi.mocked(operations.getOperations).mockResolvedValue({ data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' }, meta: { fromCache: false, cachedAt: null }, }); const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache); const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' }); expect(result.data.items).toHaveLength(3); const coupon = result.data.items.find((e) => e.type === 'coupon')!; expect(coupon.estimatedAmount).toBe(177); const offer = result.data.items.find((e) => e.type === 'offer')!; expect(offer.category).toBe('corporate'); const maturity = result.data.items.find((e) => e.type === 'maturity')!; expect(maturity.estimatedAmount).toBe(5000); }); it('handles partial failures gracefully', async () => { vi.mocked(accounts.findById).mockResolvedValue(acc1); mockCachePassthrough(); vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({ positions: [ { ticker: 'OK_SPLIT', instrumentUid: 'uid-1', instrumentType: 'share', quantity: { units: '10', nano: 0 }, }, { ticker: 'GOOD', instrumentUid: 'uid-2', instrumentType: 'share', quantity: { units: '5', nano: 0 }, }, ], instruments: new Map([ ['uid-1', { name: 'Failing' }], ['uid-2', { name: 'Working' }], ]), }); vi.mocked(moex.getDividends).mockRejectedValueOnce(new Error('MOEX error')); vi.mocked(moex.getDividends).mockResolvedValueOnce([ { secid: 'GOOD', isin: 'RU', registryCloseDate: '2026-06-25', value: 20, currencyId: 'RUB' }, ]); vi.mocked(operations.getOperations).mockResolvedValue({ data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' }, meta: { fromCache: false, cachedAt: null }, }); const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache); const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' }); expect(result.data.items).toHaveLength(1); expect(result.data.items[0].ticker).toBe('GOOD'); }); it('includes events without amount when payout is unknown', async () => { vi.mocked(accounts.findById).mockResolvedValue(acc1); mockCachePassthrough(); vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({ positions: [ { ticker: 'NO_AMT', instrumentUid: 'uid-1', instrumentType: 'share', quantity: { units: '10', nano: 0 }, }, ], instruments: new Map([['uid-1', { name: 'No Amount' }]]), }); vi.mocked(moex.getDividends).mockResolvedValue([ { secid: 'NO_AMT', isin: 'RU', registryCloseDate: '2026-06-25', value: 0, currencyId: 'RUB' }, ]); vi.mocked(operations.getOperations).mockResolvedValue({ data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' }, meta: { fromCache: false, cachedAt: null }, }); const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache); const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' }); expect(result.data.items).toHaveLength(1); expect(result.data.items[0].estimatedAmount).toBe(0); }); it('calculates summary totals correctly', async () => { vi.mocked(accounts.findById).mockResolvedValue(acc1); mockCachePassthrough(); vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({ positions: [ { ticker: 'SBER', instrumentUid: 'uid-1', instrumentType: 'share', quantity: { units: '10', nano: 0 }, }, { ticker: 'BOND1', instrumentUid: 'uid-2', instrumentType: 'bond', quantity: { units: '2', nano: 0 }, }, ], instruments: new Map([ ['uid-1', { name: 'Sber' }], ['uid-2', { name: 'OFZ' }], ]), }); vi.mocked(moex.getDividends).mockResolvedValue([ { secid: 'SBER', isin: 'RU1', registryCloseDate: '2026-06-25', value: 30, currencyId: 'RUB' }, ]); vi.mocked(moex.getBondPositionDataBatch).mockResolvedValue([ { secid: 'BOND1', couponValue: 50, nextCouponDate: '2026-06-26', matDate: '2026-06-27', offerDate: null, faceValue: 1000, boardid: 'TQCB', shortName: '', price: null, yieldToMaturity: null, duration: null, couponPercent: null, accruedInt: null, bid: null, offer: null, couponPeriod: null, bondType: null, }, ]); vi.mocked(operations.getOperations).mockResolvedValue({ data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' }, meta: { fromCache: false, cachedAt: null }, }); const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache); const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' }); expect(result.data.summary.eventCount).toBe(3); expect(result.data.summary.nearestEventDate).toBe('2026-06-25'); expect(result.data.summary.dividendsTotal).toBe(300); expect(result.data.summary.couponsTotal).toBe(100); expect(result.data.summary.principalRepaymentTotal).toBe(2000); expect(result.data.summary.totalEstimatedCashflow).toBe(2400); expect(result.data.summary.forecastEstimatedCashflow).toBe(2400); expect(result.data.summary.actualCashflow).toBe(0); }); it('filters events by inclusive date range', async () => { vi.mocked(accounts.findById).mockResolvedValue(acc1); mockCachePassthrough(); vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({ positions: [ { ticker: 'SBER', instrumentUid: 'uid-1', instrumentType: 'share', quantity: { units: '1', nano: 0 }, }, ], instruments: new Map([['uid-1', { name: 'Sber' }]]), }); vi.mocked(moex.getDividends).mockResolvedValue([ { secid: 'SBER', isin: 'RU', registryCloseDate: '2026-06-20', value: 10, currencyId: 'RUB' }, { secid: 'SBER', isin: 'RU', registryCloseDate: '2026-07-29', value: 10, currencyId: 'RUB' }, { secid: 'SBER', isin: 'RU', registryCloseDate: '2026-07-30', value: 10, currencyId: 'RUB' }, ]); vi.mocked(operations.getOperations).mockResolvedValue({ data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' }, meta: { fromCache: false, cachedAt: null }, }); const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache); const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-29' }); expect(result.data.items).toHaveLength(2); expect(result.data.items[0].eventDate).toBe('2026-06-20'); expect(result.data.items[1].eventDate).toBe('2026-07-29'); }); it('filters forecast events by selected event types', async () => { vi.mocked(accounts.findById).mockResolvedValue(acc1); mockCachePassthrough(); vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({ positions: [ { ticker: 'SBER', instrumentUid: 'uid-share', instrumentType: 'share', quantity: { units: '10', nano: 0 }, }, { ticker: 'BOND1', instrumentUid: 'uid-bond', instrumentType: 'bond', quantity: { units: '2', nano: 0 }, }, ], instruments: new Map(), }); vi.mocked(moex.getDividends).mockResolvedValue([ { secid: 'SBER', isin: 'RU', registryCloseDate: '2026-06-25', value: 30, currencyId: 'RUB' }, ]); vi.mocked(moex.getBondPositionDataBatch).mockResolvedValue([ { secid: 'BOND1', couponValue: 50, nextCouponDate: '2026-06-26', matDate: '2026-06-27', offerDate: '2026-06-28', faceValue: 1000, boardid: 'TQCB', shortName: '', price: null, yieldToMaturity: null, duration: null, couponPercent: null, accruedInt: null, bid: null, offer: null, couponPeriod: null, bondType: null, }, ]); vi.mocked(operations.getOperations).mockResolvedValue({ data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' }, meta: { fromCache: false, cachedAt: null }, }); const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache); const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10', types: 'coupon,maturity', }); expect(result.data.items.map((event) => event.type)).toEqual(['coupon', 'maturity']); expect(cache.getOrFetch).toHaveBeenCalledWith( expect.any(String), ['acc-1', '2026-06-20', '2026-07-10', 'coupon,maturity'], expect.any(Function), 'tbankPortfolioTtl', ); }); it('adds actual past income events from broker operations', async () => { vi.mocked(accounts.findById).mockResolvedValue(acc1); mockCachePassthrough(); vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({ positions: [], instruments: new Map(), }); vi.mocked(operations.getOperations).mockResolvedValue({ data: { accountId: 'acc-1', items: [ { cursor: 'cur-1', accountId: 'acc-1', id: 'op-1', parentOperationId: null, date: '2026-06-18T10:00:00.000Z', type: 'OPERATION_TYPE_DIVIDEND', category: 'income', description: 'Dividend payment', name: 'Sberbank', state: 'OPERATION_STATE_EXECUTED', instrumentUid: 'uid-sber', figi: null, ticker: 'SBER', classCode: 'TQBR', instrumentType: 'share', payment: { currency: 'RUB', units: '123', nano: 450000000, value: 123.45 }, price: null, commission: null, yield: null, accruedInt: null, quantity: null, quantityDone: null, }, ], nextCursor: null, hasNext: false, asOf: '2026-06-19T00:00:00.000Z', }, meta: { fromCache: false, cachedAt: null }, }); const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache); const result = await service.getEvents('acc-1', { from: '2026-06-15', to: '2026-06-20', types: 'dividend', }); expect(operations.getOperations).toHaveBeenCalledWith('acc-1', { from: '2026-06-15T00:00:00.000Z', to: '2026-06-20T23:59:59.999Z', operationTypes: 'OPERATION_TYPE_DIVIDEND,OPERATION_TYPE_DIV_EXT', limit: 100, state: 'OPERATION_STATE_EXECUTED', }); expect(result.data.items).toEqual([ expect.objectContaining({ id: 'actual-op-1', type: 'dividend', source: 'actual', eventDate: '2026-06-18', actualAmount: 123.45, estimatedAmount: null, estimateMode: null, currency: 'RUB', }), ]); expect(result.data.summary.actualCashflow).toBe(123.45); expect(result.data.summary.actualDividendsTotal).toBe(123.45); expect(result.data.summary.forecastEstimatedCashflow).toBe(0); }); });