diff --git a/apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts b/apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts index a718486..8e8da50 100644 --- a/apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts +++ b/apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { BrokerAccountResponseDto } from './broker-account-response.dto'; +import { BrokerEventsDataDto } from './broker-events-response.dto'; import { BrokerOperationSyncResponseDto } from './broker-operation-sync-query.dto'; import { BrokerOperationsPageResponseDto } from './broker-operation-response.dto'; import { BrokerPositionsPageResponseDto } from './broker-positions-page-response.dto'; @@ -52,3 +53,11 @@ export class BrokerOperationSyncEnvelopeDto { @ApiProperty({ type: BrokerResponseMetaDto }) meta!: BrokerResponseMetaDto; } + +export class BrokerEventsEnvelopeDto { + @ApiProperty({ type: BrokerEventsDataDto }) + data!: BrokerEventsDataDto; + + @ApiProperty({ type: BrokerResponseMetaDto }) + meta!: BrokerResponseMetaDto; +} diff --git a/apps/backend/src/modules/tbank/dto/broker-events-query.dto.ts b/apps/backend/src/modules/tbank/dto/broker-events-query.dto.ts new file mode 100644 index 0000000..10de55e --- /dev/null +++ b/apps/backend/src/modules/tbank/dto/broker-events-query.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Matches } from 'class-validator'; + +export class BrokerEventsQueryDto { + @ApiProperty({ example: '2026-06-22', description: 'Start date inclusive (YYYY-MM-DD)' }) + @Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'from must be YYYY-MM-DD' }) + from!: string; + + @ApiProperty({ example: '2026-07-29', description: 'End date inclusive (YYYY-MM-DD)' }) + @Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'to must be YYYY-MM-DD' }) + to!: string; +} diff --git a/apps/backend/src/modules/tbank/dto/broker-events-response.dto.ts b/apps/backend/src/modules/tbank/dto/broker-events-response.dto.ts new file mode 100644 index 0000000..5219d00 --- /dev/null +++ b/apps/backend/src/modules/tbank/dto/broker-events-response.dto.ts @@ -0,0 +1,80 @@ +import { ApiProperty } from '@nestjs/swagger'; + +const eventTypes = ['dividend', 'coupon', 'maturity', 'offer'] as const; +const eventCategories = ['cashflow', 'corporate'] as const; +const instrumentTypes = ['share', 'bond', 'other'] as const; + +export class BrokerEventItemDto { + @ApiProperty() + id!: string; + + @ApiProperty({ enum: eventTypes }) + type!: string; + + @ApiProperty({ enum: eventCategories }) + category!: string; + + @ApiProperty() + eventDate!: string; + + @ApiProperty({ nullable: true }) + paymentDate!: string | null; + + @ApiProperty({ nullable: true }) + ticker!: string | null; + + @ApiProperty({ nullable: true }) + name!: string | null; + + @ApiProperty({ nullable: true }) + instrumentUid!: string | null; + + @ApiProperty({ enum: instrumentTypes }) + instrumentType!: string; + + @ApiProperty({ nullable: true }) + quantitySnapshot!: number | null; + + @ApiProperty({ nullable: true }) + payoutPerUnit!: number | null; + + @ApiProperty({ nullable: true }) + estimatedAmount!: number | null; + + @ApiProperty({ nullable: true }) + currency!: string | null; + + @ApiProperty() + estimateMode!: 'current_position'; +} + +export class BrokerEventsSummaryDto { + @ApiProperty({ minimum: 0 }) + eventCount!: number; + + @ApiProperty({ nullable: true }) + nearestEventDate!: string | null; + + @ApiProperty() + totalEstimatedCashflow!: number; + + @ApiProperty() + dividendsTotal!: number; + + @ApiProperty() + couponsTotal!: number; + + @ApiProperty() + principalRepaymentTotal!: number; +} + +export class BrokerEventsDataDto { + @ApiProperty({ type: [BrokerEventItemDto] }) + items!: BrokerEventItemDto[]; + + @ApiProperty({ type: BrokerEventsSummaryDto }) + summary!: BrokerEventsSummaryDto; + + @ApiProperty() + asOf!: string; +} diff --git a/apps/backend/src/modules/tbank/services/broker-events.service.spec.ts b/apps/backend/src/modules/tbank/services/broker-events.service.spec.ts new file mode 100644 index 0000000..99c0b77 --- /dev/null +++ b/apps/backend/src/modules/tbank/services/broker-events.service.spec.ts @@ -0,0 +1,309 @@ +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 { 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 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, cache); + await expect(service.getEvents('missing', '2026-06-01', '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(), + }); + + const service = new BrokerEventsService(accounts, portfolio, moex, cache); + const result = await service.getEvents('acc-1', '2026-06-01', '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); + }); + + 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', + }, + ]); + + const service = new BrokerEventsService(accounts, portfolio, moex, cache); + const result = await service.getEvents('acc-1', '2026-06-20', '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].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, + }, + ]); + + const service = new BrokerEventsService(accounts, portfolio, moex, cache); + const result = await service.getEvents('acc-1', '2026-06-20', '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' }, + ]); + + const service = new BrokerEventsService(accounts, portfolio, moex, cache); + const result = await service.getEvents('acc-1', '2026-06-20', '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' }, + ]); + + const service = new BrokerEventsService(accounts, portfolio, moex, cache); + const result = await service.getEvents('acc-1', '2026-06-20', '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, + }, + ]); + + const service = new BrokerEventsService(accounts, portfolio, moex, cache); + const result = await service.getEvents('acc-1', '2026-06-20', '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); + }); + + 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' }, + ]); + + const service = new BrokerEventsService(accounts, portfolio, moex, cache); + const result = await service.getEvents('acc-1', '2026-06-20', '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'); + }); +}); diff --git a/apps/backend/src/modules/tbank/services/broker-events.service.ts b/apps/backend/src/modules/tbank/services/broker-events.service.ts new file mode 100644 index 0000000..8d972a2 --- /dev/null +++ b/apps/backend/src/modules/tbank/services/broker-events.service.ts @@ -0,0 +1,263 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { CacheService } from '../../cache/cache.service'; +import { MoexClientService } from '../../moex-client/moex-client.service'; +import { TBANK_CACHE_KEYS } from '../tbank.config'; +import { mapQuotationToNumber } from '../mappers/money.mapper'; +import type { + BrokerPortfolioEvent, + BrokerEventsData, + BrokerEventsSummary, +} from '../types/broker.types'; +import type { TBankInstrument } from '../types/tbank-proto.types'; +import { BrokerAccountsService } from './broker-accounts.service'; +import { BrokerPortfolioService } from './broker-portfolio.service'; + +@Injectable() +export class BrokerEventsService { + constructor( + private readonly accountsService: BrokerAccountsService, + private readonly portfolioService: BrokerPortfolioService, + private readonly moexClient: MoexClientService, + private readonly cacheService: CacheService, + ) {} + + async getEvents( + accountId: string, + from: string, + to: string, + ): Promise<{ + data: BrokerEventsData; + meta: { fromCache: boolean; cachedAt: string | null }; + }> { + const account = await this.accountsService.findById(accountId); + if (!account) throw new NotFoundException('Broker account not found'); + + const result = await this.cacheService.getOrFetch( + TBANK_CACHE_KEYS.events, + [accountId, from, to], + () => this.buildEvents(accountId, from, to), + 'tbankPortfolioTtl', + ); + + return { + data: result.data, + meta: { fromCache: result.fromCache, cachedAt: result.cachedAt }, + }; + } + + private async buildEvents( + accountId: string, + from: string, + to: string, + ): Promise { + const { positions, instruments } = + await this.portfolioService.getPositionsWithInstruments(accountId); + + const items: BrokerPortfolioEvent[] = []; + + const sharePositions = positions.filter((p) => p.instrumentType?.toLowerCase() === 'share'); + const bondPositions = positions.filter((p) => p.instrumentType?.toLowerCase() === 'bond'); + + const shareResults = await Promise.allSettled( + sharePositions.map((pos) => this.buildShareEvents(pos, instruments, from, to)), + ); + for (const r of shareResults) { + if (r.status === 'fulfilled') items.push(...r.value); + } + + if (bondPositions.length > 0) { + const bondEvents = await this.buildBondEvents(bondPositions, instruments, from, to); + items.push(...bondEvents); + } + + items.sort((a, b) => a.eventDate.localeCompare(b.eventDate)); + + const summary = this.buildSummary(items); + + return { items, summary, asOf: new Date().toISOString() }; + } + + private async buildShareEvents( + pos: { + ticker?: string; + instrumentUid?: string; + instrumentType?: string; + quantity?: { units?: string | number; nano?: number }; + }, + instruments: Map>, + from: string, + to: string, + ): Promise { + const ticker = pos.ticker || null; + if (!ticker) return []; + + const instrument = pos.instrumentUid ? instruments.get(pos.instrumentUid) : undefined; + const quantity = mapQuotationToNumber(pos.quantity); + const instrumentUid = pos.instrumentUid || instrument?.uid || null; + const name = instrument?.name || null; + + let dividends: { registryCloseDate: string; value: number; currencyId: string }[]; + try { + dividends = await this.moexClient.getDividends(ticker); + } catch { + return []; + } + + const events: BrokerPortfolioEvent[] = []; + for (const d of dividends) { + if (d.registryCloseDate < from || d.registryCloseDate > to) continue; + + const payoutPerUnit = d.value ?? null; + const estimatedAmount = + quantity !== null && payoutPerUnit !== null ? quantity * payoutPerUnit : null; + + events.push({ + id: `div-${ticker}-${d.registryCloseDate}`, + type: 'dividend', + category: 'cashflow', + eventDate: d.registryCloseDate, + paymentDate: null, + ticker, + name, + instrumentUid, + instrumentType: 'share', + quantitySnapshot: quantity, + payoutPerUnit, + estimatedAmount, + currency: d.currencyId, + estimateMode: 'current_position', + }); + } + + return events; + } + + private async buildBondEvents( + bondPositions: { + ticker?: string; + instrumentUid?: string; + instrumentType?: string; + quantity?: { units?: string | number; nano?: number }; + }[], + instruments: Map>, + from: string, + to: string, + ): Promise { + const secids = bondPositions.map((p) => p.ticker).filter((t): t is string => Boolean(t)); + if (secids.length === 0) return []; + + let bondData: { + secid: string; + couponValue: number | null; + nextCouponDate: string | null; + matDate: string | null; + offerDate: string | null; + faceValue: number; + }[]; + try { + bondData = await this.moexClient.getBondPositionDataBatch(secids); + } catch { + return []; + } + + const events: BrokerPortfolioEvent[] = []; + for (const pos of bondPositions) { + const ticker = pos.ticker; + if (!ticker) continue; + + const bond = bondData.find((b) => b.secid === ticker); + if (!bond) continue; + + const instrument = pos.instrumentUid ? instruments.get(pos.instrumentUid) : undefined; + const quantity = mapQuotationToNumber(pos.quantity); + const instrumentUid = pos.instrumentUid || instrument?.uid || null; + const name = instrument?.name || null; + const currency = instrument?.currency || 'RUB'; + + if (bond.nextCouponDate && bond.nextCouponDate >= from && bond.nextCouponDate <= to) { + const payoutPerUnit = bond.couponValue; + const estimatedAmount = + quantity !== null && payoutPerUnit !== null ? quantity * payoutPerUnit : null; + + events.push({ + id: `coupon-${ticker}-${bond.nextCouponDate}`, + type: 'coupon', + category: 'cashflow', + eventDate: bond.nextCouponDate, + paymentDate: null, + ticker, + name, + instrumentUid, + instrumentType: 'bond', + quantitySnapshot: quantity, + payoutPerUnit, + estimatedAmount, + currency, + estimateMode: 'current_position', + }); + } + + if (bond.matDate && bond.matDate >= from && bond.matDate <= to) { + const payoutPerUnit = bond.faceValue; + const estimatedAmount = quantity !== null ? quantity * payoutPerUnit : null; + + events.push({ + id: `maturity-${ticker}-${bond.matDate}`, + type: 'maturity', + category: 'cashflow', + eventDate: bond.matDate, + paymentDate: null, + ticker, + name, + instrumentUid, + instrumentType: 'bond', + quantitySnapshot: quantity, + payoutPerUnit, + estimatedAmount, + currency, + estimateMode: 'current_position', + }); + } + + if (bond.offerDate && bond.offerDate >= from && bond.offerDate <= to) { + events.push({ + id: `offer-${ticker}-${bond.offerDate}`, + type: 'offer', + category: 'corporate', + eventDate: bond.offerDate, + paymentDate: null, + ticker, + name, + instrumentUid, + instrumentType: 'bond', + quantitySnapshot: quantity, + payoutPerUnit: null, + estimatedAmount: null, + currency, + estimateMode: 'current_position', + }); + } + } + + return events; + } + + private buildSummary(items: BrokerPortfolioEvent[]): BrokerEventsSummary { + const cashflowEvents = items.filter((e) => e.category === 'cashflow'); + + return { + eventCount: items.length, + nearestEventDate: items.length > 0 ? items[0].eventDate : null, + totalEstimatedCashflow: cashflowEvents.reduce((sum, e) => sum + (e.estimatedAmount ?? 0), 0), + dividendsTotal: cashflowEvents + .filter((e) => e.type === 'dividend') + .reduce((sum, e) => sum + (e.estimatedAmount ?? 0), 0), + couponsTotal: cashflowEvents + .filter((e) => e.type === 'coupon') + .reduce((sum, e) => sum + (e.estimatedAmount ?? 0), 0), + principalRepaymentTotal: cashflowEvents + .filter((e) => e.type === 'maturity') + .reduce((sum, e) => sum + (e.estimatedAmount ?? 0), 0), + }; + } +} diff --git a/apps/backend/src/modules/tbank/services/broker-portfolio.service.ts b/apps/backend/src/modules/tbank/services/broker-portfolio.service.ts index c4f2668..2f6f927 100644 --- a/apps/backend/src/modules/tbank/services/broker-portfolio.service.ts +++ b/apps/backend/src/modules/tbank/services/broker-portfolio.service.ts @@ -5,6 +5,7 @@ import { TBANK_CACHE_KEYS } from '../tbank.config'; import type { BrokerPortfolio, BrokerPositionsPage } from '../types/broker.types'; import type { TBankInstrument, + TBankPortfolioPosition, TBankPortfolioResponse, TBankPositionsResponse, } from '../types/tbank-proto.types'; @@ -114,6 +115,15 @@ export class BrokerPortfolioService { ); } + async getPositionsWithInstruments(accountId: string): Promise<{ + positions: TBankPortfolioPosition[]; + instruments: Map>; + }> { + const portfolio = await this.fetchCachedPortfolio(accountId); + const instrumentMap = await this.buildInstrumentMap(portfolio); + return { positions: portfolio.positions ?? [], instruments: instrumentMap }; + } + private async fetchCachedPortfolio(accountId: string): Promise { return this.cacheService .getOrFetch( diff --git a/apps/backend/src/modules/tbank/tbank.config.ts b/apps/backend/src/modules/tbank/tbank.config.ts index 803376f..902fa4f 100644 --- a/apps/backend/src/modules/tbank/tbank.config.ts +++ b/apps/backend/src/modules/tbank/tbank.config.ts @@ -19,4 +19,5 @@ export const TBANK_CACHE_KEYS = { positions: 'tbank:positions', operations: 'tbank:operations', instrument: 'tbank:instrument', + events: 'tbank:events', } as const; diff --git a/apps/backend/src/modules/tbank/tbank.controller.spec.ts b/apps/backend/src/modules/tbank/tbank.controller.spec.ts index e5340a5..67c2d07 100644 --- a/apps/backend/src/modules/tbank/tbank.controller.spec.ts +++ b/apps/backend/src/modules/tbank/tbank.controller.spec.ts @@ -2,6 +2,7 @@ import { ROLES_KEY } from '../auth/decorators/roles.decorator'; import { ApiResponse } from '../../common/dto/api-response.dto'; import { TBankController } from './tbank.controller'; import { BrokerAccountsService } from './services/broker-accounts.service'; +import { BrokerEventsService } from './services/broker-events.service'; import { BrokerOperationSyncService } from './services/broker-operation-sync.service'; import { BrokerOperationsService } from './services/broker-operations.service'; import { BrokerPortfolioService } from './services/broker-portfolio.service'; @@ -9,6 +10,7 @@ import { BrokerPortfolioService } from './services/broker-portfolio.service'; describe('TBankController', () => { const accounts = { findAll: vi.fn() } as unknown as BrokerAccountsService; const portfolio = { getPortfolio: vi.fn() } as unknown as BrokerPortfolioService; + const events = { getEvents: vi.fn() } as unknown as BrokerEventsService; const operations = { getOperations: vi.fn() } as unknown as BrokerOperationsService; const sync = { syncAccount: vi.fn() } as unknown as BrokerOperationSyncService; @@ -35,7 +37,7 @@ describe('TBankController', () => { meta: { fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' }, }); - const controller = new TBankController(accounts, portfolio, operations, sync); + const controller = new TBankController(accounts, portfolio, events, operations, sync); const response = await controller.getAccounts(); expect(response).toBeInstanceOf(ApiResponse); @@ -46,7 +48,7 @@ describe('TBankController', () => { it('exposes a sync trigger for durable operation history', async () => { vi.mocked(sync.syncAccount).mockResolvedValueOnce({ upserted: 2 }); - const controller = new TBankController(accounts, portfolio, operations, sync); + const controller = new TBankController(accounts, portfolio, events, operations, sync); const response = await controller.syncOperations('acc-1', { from: '2026-06-01T00:00:00.000Z', to: '2026-06-17T00:00:00.000Z', @@ -58,4 +60,30 @@ describe('TBankController', () => { }); expect(response.data).toEqual({ upserted: 2 }); }); + + it('forwards events query and wraps response', async () => { + const eventsData = { + items: [], + summary: { + eventCount: 0, + nearestEventDate: null, + totalEstimatedCashflow: 0, + dividendsTotal: 0, + couponsTotal: 0, + principalRepaymentTotal: 0, + }, + asOf: '2026-06-22T00:00:00.000Z', + }; + vi.mocked(events.getEvents).mockResolvedValueOnce({ + data: eventsData, + meta: { fromCache: false, cachedAt: '2026-06-22T00:00:00.000Z' }, + }); + + const controller = new TBankController(accounts, portfolio, events, operations, sync); + const response = await controller.getEvents('acc-1', { from: '2026-06-22', to: '2026-07-29' }); + + expect(events.getEvents).toHaveBeenCalledWith('acc-1', '2026-06-22', '2026-07-29'); + expect(response).toBeInstanceOf(ApiResponse); + expect(response.data).toEqual(eventsData); + }); }); diff --git a/apps/backend/src/modules/tbank/tbank.controller.ts b/apps/backend/src/modules/tbank/tbank.controller.ts index 24a6042..f91eb81 100644 --- a/apps/backend/src/modules/tbank/tbank.controller.ts +++ b/apps/backend/src/modules/tbank/tbank.controller.ts @@ -4,15 +4,18 @@ import { ApiResponse } from '../../common/dto/api-response.dto'; import { Roles } from '../auth/decorators/roles.decorator'; import { BrokerAccountsEnvelopeDto, + BrokerEventsEnvelopeDto, BrokerOperationSyncEnvelopeDto, BrokerOperationsEnvelopeDto, BrokerPortfolioEnvelopeDto, BrokerPositionsEnvelopeDto, } from './dto/broker-envelope.dto'; +import { BrokerEventsQueryDto } from './dto/broker-events-query.dto'; import { BrokerPositionQueryDto } from './dto/broker-position-query.dto'; import { BrokerOperationQueryDto } from './dto/broker-operation-query.dto'; import { BrokerOperationSyncQueryDto } from './dto/broker-operation-sync-query.dto'; import { BrokerAccountsService } from './services/broker-accounts.service'; +import { BrokerEventsService } from './services/broker-events.service'; import { BrokerOperationSyncService } from './services/broker-operation-sync.service'; import { BrokerOperationsService } from './services/broker-operations.service'; import { BrokerPortfolioService } from './services/broker-portfolio.service'; @@ -25,6 +28,7 @@ export class TBankController { constructor( private readonly brokerAccountsService: BrokerAccountsService, private readonly brokerPortfolioService: BrokerPortfolioService, + private readonly brokerEventsService: BrokerEventsService, private readonly brokerOperationsService: BrokerOperationsService, private readonly brokerOperationSyncService: BrokerOperationSyncService, ) {} @@ -72,6 +76,14 @@ export class TBankController { return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt); } + @Get('accounts/:accountId/events') + @ApiOperation({ summary: 'Get upcoming events and estimated cashflow for a broker account' }) + @ApiOkResponse({ type: BrokerEventsEnvelopeDto }) + async getEvents(@Param('accountId') accountId: string, @Query() query: BrokerEventsQueryDto) { + const result = await this.brokerEventsService.getEvents(accountId, query.from, query.to); + return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt); + } + @Post('accounts/:accountId/operations/sync') @ApiOperation({ summary: 'Synchronize T-Bank broker account operations into local history' }) @ApiOkResponse({ type: BrokerOperationSyncEnvelopeDto }) diff --git a/apps/backend/src/modules/tbank/tbank.module.ts b/apps/backend/src/modules/tbank/tbank.module.ts index 964158b..92dd7f7 100644 --- a/apps/backend/src/modules/tbank/tbank.module.ts +++ b/apps/backend/src/modules/tbank/tbank.module.ts @@ -1,19 +1,23 @@ import { Module } from '@nestjs/common'; +import { MoexClientModule } from '../moex-client/moex-client.module'; import { TBankController } from './tbank.controller'; import { BrokerAccountsService } from './services/broker-accounts.service'; import { BrokerInstrumentsService } from './services/broker-instruments.service'; +import { BrokerEventsService } from './services/broker-events.service'; import { BrokerOperationSyncService } from './services/broker-operation-sync.service'; import { BrokerOperationsService } from './services/broker-operations.service'; import { BrokerPortfolioService } from './services/broker-portfolio.service'; import { TBankClientService } from './services/tbank-client.service'; @Module({ + imports: [MoexClientModule], controllers: [TBankController], providers: [ TBankClientService, BrokerAccountsService, BrokerInstrumentsService, BrokerPortfolioService, + BrokerEventsService, BrokerOperationsService, BrokerOperationSyncService, ], @@ -22,6 +26,7 @@ import { TBankClientService } from './services/tbank-client.service'; BrokerAccountsService, BrokerInstrumentsService, BrokerPortfolioService, + BrokerEventsService, BrokerOperationsService, BrokerOperationSyncService, ], diff --git a/apps/backend/src/modules/tbank/types/broker.types.ts b/apps/backend/src/modules/tbank/types/broker.types.ts index aff00e7..8a97682 100644 --- a/apps/backend/src/modules/tbank/types/broker.types.ts +++ b/apps/backend/src/modules/tbank/types/broker.types.ts @@ -102,3 +102,35 @@ export type BrokerOperationsPage = { hasNext: boolean; asOf: string; }; + +export type BrokerPortfolioEvent = { + id: string; + type: 'dividend' | 'coupon' | 'maturity' | 'offer'; + category: 'cashflow' | 'corporate'; + eventDate: string; + paymentDate: string | null; + ticker: string | null; + name: string | null; + instrumentUid: string | null; + instrumentType: 'share' | 'bond' | 'other'; + quantitySnapshot: number | null; + payoutPerUnit: number | null; + estimatedAmount: number | null; + currency: string | null; + estimateMode: 'current_position'; +}; + +export type BrokerEventsSummary = { + eventCount: number; + nearestEventDate: string | null; + totalEstimatedCashflow: number; + dividendsTotal: number; + couponsTotal: number; + principalRepaymentTotal: number; +}; + +export type BrokerEventsData = { + items: BrokerPortfolioEvent[]; + summary: BrokerEventsSummary; + asOf: string; +}; diff --git a/apps/frontend/src/app/routing/AppRoutes.tsx b/apps/frontend/src/app/routing/AppRoutes.tsx index 71efb7d..ffe84c2 100644 --- a/apps/frontend/src/app/routing/AppRoutes.tsx +++ b/apps/frontend/src/app/routing/AppRoutes.tsx @@ -12,6 +12,7 @@ import { ScreenerPage } from '@/pages/screener'; import { BrokerAccountsPage } from '@/pages/broker-accounts'; import { BrokerAccountLayout } from '@/widgets/broker-account-layout'; import { BrokerAccountOverviewPage } from '@/pages/broker-account'; +import { BrokerEventsPage } from '@/pages/broker-events'; import { BrokerPositionsPage } from '@/pages/broker-positions'; import { BrokerOperationsPage } from '@/pages/broker-operations'; @@ -69,6 +70,7 @@ export function AppRoutes() { } /> } /> } /> + } /> diff --git a/apps/frontend/src/entities/broker-event/api/brokerEventApi.ts b/apps/frontend/src/entities/broker-event/api/brokerEventApi.ts new file mode 100644 index 0000000..9739f5b --- /dev/null +++ b/apps/frontend/src/entities/broker-event/api/brokerEventApi.ts @@ -0,0 +1,20 @@ +import { request } from '@/shared/api/client'; +import type { ApiResponseMeta, BrokerEventsData } from '@/shared/api/responses'; + +export type BrokerEventsQuery = { + from: string; + to: string; +}; + +export function getBrokerEvents( + accountId: string, + query: BrokerEventsQuery, +): Promise<{ data: BrokerEventsData; meta: ApiResponseMeta }> { + return request( + `/api/v1/broker/accounts/${encodeURIComponent(accountId)}/events`, + { + from: query.from, + to: query.to, + }, + ); +} diff --git a/apps/frontend/src/entities/broker-event/index.ts b/apps/frontend/src/entities/broker-event/index.ts new file mode 100644 index 0000000..fc62283 --- /dev/null +++ b/apps/frontend/src/entities/broker-event/index.ts @@ -0,0 +1,2 @@ +export { getBrokerEvents, type BrokerEventsQuery } from './api/brokerEventApi'; +export { useBrokerEvents } from './model/useBrokerEvents'; diff --git a/apps/frontend/src/entities/broker-event/model/useBrokerEvents.test.tsx b/apps/frontend/src/entities/broker-event/model/useBrokerEvents.test.tsx new file mode 100644 index 0000000..dc4db4b --- /dev/null +++ b/apps/frontend/src/entities/broker-event/model/useBrokerEvents.test.tsx @@ -0,0 +1,101 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import { type ReactNode } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getBrokerEvents } from '../api/brokerEventApi'; +import { useBrokerEvents } from './useBrokerEvents'; + +vi.mock('../api/brokerEventApi', () => ({ + getBrokerEvents: vi.fn(), +})); + +function createWrapper(queryClient?: QueryClient) { + const client = queryClient ?? new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +const mockEventsData = { + summary: { + eventCount: 3, + totalEstimatedCashflow: 450, + nearestEventDate: '2026-06-25', + currency: 'RUB', + }, + items: [ + { + id: 'ev-1', + type: 'dividend', + ticker: 'SBER', + name: 'Сбер Банк', + eventDate: '2026-06-25', + estimatedAmount: 150, + currency: 'RUB' as const, + }, + { + id: 'ev-2', + type: 'coupon', + ticker: 'SU26238RMFS5', + name: 'ОФЗ 26238', + eventDate: '2026-06-27', + estimatedAmount: 36.9, + currency: 'RUB' as const, + }, + { + id: 'ev-3', + type: 'maturity', + ticker: 'SU26238RMFS5', + name: 'ОФЗ 26238', + eventDate: '2026-06-30', + estimatedAmount: 1000, + currency: 'RUB' as const, + }, + ], +}; + +const query = { from: '2026-06-22', to: '2026-06-29' }; + +describe('useBrokerEvents', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns events data from API', async () => { + vi.mocked(getBrokerEvents).mockResolvedValue({ + data: mockEventsData, + meta: { fromCache: false, cachedAt: null }, + }); + + const { result } = renderHook(() => useBrokerEvents('acc-1', query), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.summary.eventCount).toBe(3); + expect(getBrokerEvents).toHaveBeenCalledWith('acc-1', query); + }); + + it('reuses cache when query key matches', async () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + queryClient.setQueryData(['broker', 'events', 'acc-1', query], mockEventsData); + + const { result } = renderHook(() => useBrokerEvents('acc-1', query), { + wrapper: createWrapper(queryClient), + }); + + await waitFor(() => expect(result.current.data).toBe(mockEventsData)); + expect(getBrokerEvents).not.toHaveBeenCalled(); + }); + + it('is not enabled when accountId is undefined', async () => { + const { result } = renderHook(() => useBrokerEvents(undefined, query), { + wrapper: createWrapper(), + }); + + expect(result.current.isPending).toBe(true); + expect(getBrokerEvents).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/frontend/src/entities/broker-event/model/useBrokerEvents.ts b/apps/frontend/src/entities/broker-event/model/useBrokerEvents.ts new file mode 100644 index 0000000..01d463a --- /dev/null +++ b/apps/frontend/src/entities/broker-event/model/useBrokerEvents.ts @@ -0,0 +1,14 @@ +import { useQuery } from '@tanstack/react-query'; +import type { BrokerEventsData } from '@/shared/api/responses'; +import { getBrokerEvents, type BrokerEventsQuery } from '../api/brokerEventApi'; + +export function useBrokerEvents(accountId: string | undefined, query: BrokerEventsQuery) { + return useQuery({ + queryKey: ['broker', 'events', accountId, query], + enabled: Boolean(accountId), + queryFn: async () => (await getBrokerEvents(accountId!, query)).data, + staleTime: 300_000, + retry: 2, + refetchOnWindowFocus: false, + }); +} diff --git a/apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx b/apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx index 10cf30a..2234a60 100644 --- a/apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx +++ b/apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx @@ -4,6 +4,7 @@ import { Text } from '@moex-vibe/design-system'; import { useBrokerOperations } from '@/entities/broker-operation'; import { useBrokerAccountContext } from '@/widgets/broker-account-layout'; import { BrokerAllocationChart } from '@/widgets/broker-allocation-chart'; +import { BrokerEventsOverview } from '@/widgets/broker-events-overview'; import { BrokerOperationsTable } from '@/widgets/broker-operations-table'; import { BrokerSummary, BrokerAssetCards, BrokerOverviewSkeleton } from '@/widgets/broker-overview'; @@ -25,6 +26,7 @@ export function BrokerAccountOverviewPage() { + {operations.error ? ( Не удалось загрузить последние операции diff --git a/apps/frontend/src/pages/broker-events/index.ts b/apps/frontend/src/pages/broker-events/index.ts new file mode 100644 index 0000000..f1100d7 --- /dev/null +++ b/apps/frontend/src/pages/broker-events/index.ts @@ -0,0 +1 @@ +export { BrokerEventsPage } from './ui/BrokerEventsPage'; diff --git a/apps/frontend/src/pages/broker-events/ui/BrokerEventsPage.test.tsx b/apps/frontend/src/pages/broker-events/ui/BrokerEventsPage.test.tsx new file mode 100644 index 0000000..5457673 --- /dev/null +++ b/apps/frontend/src/pages/broker-events/ui/BrokerEventsPage.test.tsx @@ -0,0 +1,227 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen } from '@testing-library/react'; +import React, { type ReactNode } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { BrokerEventsPage } from './BrokerEventsPage'; + +vi.mock('@/entities/broker-event', () => ({ + useBrokerEvents: vi.fn(), +})); + +vi.mock('@/widgets/broker-account-layout', () => ({ + useBrokerAccountContext: () => ({ accountId: 'acc-1', portfolio: null }), +})); + +vi.mock('@moex-vibe/design-system', () => ({ + Heading: ({ children }: { children: ReactNode }) =>

{children}

, + Text: ({ children }: { children: ReactNode }) => {children}, +})); + +import { useBrokerEvents } from '@/entities/broker-event'; + +function createWrapper() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +const mockData = { + summary: { + eventCount: 3, + totalEstimatedCashflow: 450, + nearestEventDate: '2026-06-25', + currency: 'RUB', + }, + items: [ + { + id: 'ev-1', + type: 'dividend', + ticker: 'SBER', + name: 'Сбер Банк', + eventDate: '2026-06-25', + estimatedAmount: 150, + currency: 'RUB' as const, + }, + { + id: 'ev-2', + type: 'coupon', + ticker: 'SU26238RMFS5', + name: 'ОФЗ 26238', + eventDate: '2026-06-27', + estimatedAmount: 36.9, + currency: 'RUB' as const, + }, + { + id: 'ev-3', + type: 'maturity', + ticker: 'VTBR', + name: 'ВТБ', + eventDate: '2026-06-30', + estimatedAmount: 1000, + currency: 'RUB' as const, + }, + ], +}; + +describe('BrokerEventsPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders loading state', () => { + vi.mocked(useBrokerEvents).mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + error: null, + isSuccess: false, + isPending: true, + dataUpdatedAt: 0, + errorUpdatedAt: 0, + failureCount: 0, + failureReason: null, + errorUpdateCount: 0, + isFetched: false, + isFetchedAfterMount: false, + isFetching: true, + isInitialLoading: true, + isPaused: false, + isLoadingError: false, + isRefetchError: false, + isPlaceholderData: false, + isStale: false, + refetch: vi.fn(), + promise: new Promise(() => {}), + status: 'pending', + fetchStatus: 'fetching', + } as unknown as ReturnType); + + render(, { wrapper: createWrapper() }); + expect(screen.getByText('Загрузка событий…')).toBeInTheDocument(); + }); + + it('renders error state', () => { + vi.mocked(useBrokerEvents).mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + error: new Error('fail'), + isSuccess: false, + isPending: false, + dataUpdatedAt: 0, + errorUpdatedAt: 0, + failureCount: 1, + failureReason: null, + errorUpdateCount: 1, + isFetched: true, + isFetchedAfterMount: true, + isFetching: false, + isInitialLoading: false, + isPaused: false, + isLoadingError: true, + isRefetchError: false, + isPlaceholderData: false, + isStale: false, + refetch: vi.fn(), + promise: new Promise(() => {}), + status: 'error', + fetchStatus: 'idle', + } as unknown as ReturnType); + + render(, { wrapper: createWrapper() }); + expect(screen.getByText('Не удалось загрузить календарь событий')).toBeInTheDocument(); + }); + + it('renders empty state', () => { + vi.mocked(useBrokerEvents).mockReturnValue({ + data: { + summary: { + eventCount: 0, + totalEstimatedCashflow: 0, + nearestEventDate: null, + currency: 'RUB', + }, + items: [], + }, + isLoading: false, + isError: false, + error: null, + isSuccess: true, + isPending: false, + dataUpdatedAt: Date.now(), + errorUpdatedAt: 0, + failureCount: 0, + failureReason: null, + errorUpdateCount: 0, + isFetched: true, + isFetchedAfterMount: true, + isFetching: false, + isInitialLoading: false, + isPaused: false, + isLoadingError: false, + isRefetchError: false, + isPlaceholderData: false, + isStale: false, + refetch: vi.fn(), + promise: Promise.resolve({ + summary: { + eventCount: 0, + totalEstimatedCashflow: 0, + nearestEventDate: null, + currency: 'RUB', + }, + items: [], + }), + status: 'success', + fetchStatus: 'idle', + } as unknown as ReturnType); + + render(, { wrapper: createWrapper() }); + expect(screen.getByText('На ближайшие 7 дней событий нет')).toBeInTheDocument(); + }); + + it('renders events heading, summary and table', () => { + vi.mocked(useBrokerEvents).mockReturnValue({ + data: mockData, + isLoading: false, + isError: false, + error: null, + isSuccess: true, + isPending: false, + dataUpdatedAt: Date.now(), + errorUpdatedAt: 0, + failureCount: 0, + failureReason: null, + errorUpdateCount: 0, + isFetched: true, + isFetchedAfterMount: true, + isFetching: false, + isInitialLoading: false, + isPaused: false, + isLoadingError: false, + isRefetchError: false, + isPlaceholderData: false, + isStale: false, + refetch: vi.fn(), + promise: Promise.resolve(mockData), + status: 'success', + fetchStatus: 'idle', + } as unknown as ReturnType); + + render(, { wrapper: createWrapper() }); + + expect(screen.getByText('События')).toBeInTheDocument(); + expect(screen.getByText('Событий')).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); + expect(screen.getByText('Ближайшее')).toBeInTheDocument(); + expect(screen.getByText('Денежный поток')).toBeInTheDocument(); + expect(screen.getByText('Дивиденд')).toBeInTheDocument(); + expect(screen.getByText('Купон')).toBeInTheDocument(); + expect(screen.getByText('Погашение')).toBeInTheDocument(); + expect(screen.getByText('SBER')).toBeInTheDocument(); + expect(screen.getByText('SU26238RMFS5')).toBeInTheDocument(); + expect(screen.getByText('VTBR')).toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/pages/broker-events/ui/BrokerEventsPage.tsx b/apps/frontend/src/pages/broker-events/ui/BrokerEventsPage.tsx new file mode 100644 index 0000000..fb97604 --- /dev/null +++ b/apps/frontend/src/pages/broker-events/ui/BrokerEventsPage.tsx @@ -0,0 +1,223 @@ +import { Box } from '@mui/material'; +import { Heading, Text } from '@moex-vibe/design-system'; +import { useBrokerEvents } from '@/entities/broker-event'; +import { useBrokerAccountContext } from '@/widgets/broker-account-layout'; +import { formatBrokerCurrencyValue } from '@/shared/lib/formatters'; + +function eventTypeLabel(type: string): string { + switch (type) { + case 'dividend': + return 'Дивиденд'; + case 'coupon': + return 'Купон'; + case 'maturity': + return 'Погашение'; + case 'offer': + return 'Оферта'; + default: + return type; + } +} + +function formatDate(value: string | null): string { + if (!value) return '-'; + return new Date(value).toLocaleDateString('ru-RU'); +} + +function defaultPeriod() { + const now = new Date(); + const from = new Date(now); + const to = new Date(now); + to.setDate(to.getDate() + 7); + return { + from: from.toISOString().slice(0, 10), + to: to.toISOString().slice(0, 10), + }; +} + +export function BrokerEventsPage() { + const { accountId } = useBrokerAccountContext(); + const period = defaultPeriod(); + const events = useBrokerEvents(accountId, period); + + const ev = events.data; + + return ( + + + + События + + + + {events.error ? ( + + Не удалось загрузить календарь событий + + ) : events.isLoading ? ( + + Загрузка событий… + + ) : ev && ev.items.length === 0 ? ( + + На ближайшие 7 дней событий нет + + ) : ev ? ( + + + + + Событий + + {ev.summary.eventCount} + + + + Ближайшее + + {formatDate(ev.summary.nearestEventDate)} + + + + Денежный поток + + + ~{formatBrokerCurrencyValue('RUB', ev.summary.totalEstimatedCashflow)} + + + оценка* + + + + + + + + + + Дата + + + Тип + + + Инструмент + + + Сумма + + + + + {ev.items.map((item) => ( + + + {formatDate(item.eventDate)} + + + {eventTypeLabel(item.type)} + + + {item.ticker && {item.ticker}} + {item.name && item.name !== item.ticker && ( + + {item.name} + + )} + + + {item.estimatedAmount != null ? ( + <> + + ~ + {formatBrokerCurrencyValue( + item.currency ?? 'RUB', + item.estimatedAmount, + )} + + + оценка* + + + ) : ( + + )} + + + ))} + + + + + ) : null} + + ); +} diff --git a/apps/frontend/src/shared/api/responses.ts b/apps/frontend/src/shared/api/responses.ts index 0dd5305..909de13 100644 --- a/apps/frontend/src/shared/api/responses.ts +++ b/apps/frontend/src/shared/api/responses.ts @@ -350,3 +350,35 @@ export interface BrokerPositionsPage { hasNext: boolean; asOf: string; } + +export interface BrokerPortfolioEvent { + id: string; + type: 'dividend' | 'coupon' | 'maturity' | 'offer'; + category: 'cashflow' | 'corporate'; + eventDate: string; + paymentDate: string | null; + ticker: string | null; + name: string | null; + instrumentUid: string | null; + instrumentType: 'share' | 'bond' | 'other'; + quantitySnapshot: number | null; + payoutPerUnit: number | null; + estimatedAmount: number | null; + currency: string | null; + estimateMode: 'current_position'; +} + +export interface BrokerEventsSummary { + eventCount: number; + nearestEventDate: string | null; + totalEstimatedCashflow: number; + dividendsTotal: number; + couponsTotal: number; + principalRepaymentTotal: number; +} + +export interface BrokerEventsData { + items: BrokerPortfolioEvent[]; + summary: BrokerEventsSummary; + asOf: string; +} diff --git a/apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx b/apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx index 4bcce93..6f5f928 100644 --- a/apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx +++ b/apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx @@ -17,6 +17,7 @@ const links = [ { to: '/shares', label: 'Акции' }, { to: '/bonds', label: 'Облигации' }, { to: '/operations', label: 'Операции' }, + { to: '/events', label: 'События' }, ]; export function BrokerAccountLayout() { diff --git a/apps/frontend/src/widgets/broker-events-overview/index.ts b/apps/frontend/src/widgets/broker-events-overview/index.ts new file mode 100644 index 0000000..47e04ca --- /dev/null +++ b/apps/frontend/src/widgets/broker-events-overview/index.ts @@ -0,0 +1 @@ +export { BrokerEventsOverview } from './ui/BrokerEventsOverview'; diff --git a/apps/frontend/src/widgets/broker-events-overview/ui/BrokerEventsOverview.test.tsx b/apps/frontend/src/widgets/broker-events-overview/ui/BrokerEventsOverview.test.tsx new file mode 100644 index 0000000..3b9f21c --- /dev/null +++ b/apps/frontend/src/widgets/broker-events-overview/ui/BrokerEventsOverview.test.tsx @@ -0,0 +1,287 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen } from '@testing-library/react'; +import { type ReactNode } from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { BrokerEventsOverview } from './BrokerEventsOverview'; + +vi.mock('@/entities/broker-event', () => ({ + useBrokerEvents: vi.fn(), +})); + +import { useBrokerEvents } from '@/entities/broker-event'; + +vi.mock('@moex-vibe/design-system', () => ({ + Heading: ({ children }: { children: ReactNode }) =>

{children}

, + Text: ({ children }: { children: ReactNode }) => {children}, +})); + +function createWrapper() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + return function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + }; +} + +const mockItems = [ + { + id: 'ev-1', + type: 'dividend', + ticker: 'SBER', + name: 'Сбер Банк', + eventDate: '2026-06-25', + estimatedAmount: 150, + currency: 'RUB' as const, + }, + { + id: 'ev-2', + type: 'coupon', + ticker: 'SU26238RMFS5', + name: 'ОФЗ 26238', + eventDate: '2026-06-27', + estimatedAmount: 36.9, + currency: 'RUB' as const, + }, +]; + +describe('BrokerEventsOverview', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders loading state', () => { + vi.mocked(useBrokerEvents).mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + error: null, + isSuccess: false, + isPending: true, + dataUpdatedAt: 0, + errorUpdatedAt: 0, + failureCount: 0, + failureReason: null, + errorUpdateCount: 0, + isFetched: false, + isFetchedAfterMount: false, + isFetching: true, + isInitialLoading: true, + isPaused: false, + isLoadingError: false, + isRefetchError: false, + isPlaceholderData: false, + isStale: false, + refetch: vi.fn(), + promise: new Promise(() => {}), + status: 'pending', + fetchStatus: 'fetching', + } as unknown as ReturnType); + + render(, { wrapper: createWrapper() }); + expect(screen.getByText('Загрузка событий…')).toBeInTheDocument(); + }); + + it('returns null on error', () => { + vi.mocked(useBrokerEvents).mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + error: new Error('fail'), + isSuccess: false, + isPending: false, + dataUpdatedAt: 0, + errorUpdatedAt: 0, + failureCount: 1, + failureReason: null, + errorUpdateCount: 1, + isFetched: true, + isFetchedAfterMount: true, + isFetching: false, + isInitialLoading: false, + isPaused: false, + isLoadingError: true, + isRefetchError: false, + isPlaceholderData: false, + isStale: false, + refetch: vi.fn(), + promise: Promise.reject(new Error('fail')), + status: 'error', + fetchStatus: 'idle', + } as unknown as ReturnType); + + const { container } = render(, { + wrapper: createWrapper(), + }); + expect(container).toBeEmptyDOMElement(); + }); + + it('returns null when items array is empty', () => { + vi.mocked(useBrokerEvents).mockReturnValue({ + data: { + summary: { + eventCount: 0, + totalEstimatedCashflow: 0, + nearestEventDate: null, + currency: 'RUB', + }, + items: [], + }, + isLoading: false, + isError: false, + error: null, + isSuccess: true, + isPending: false, + dataUpdatedAt: Date.now(), + errorUpdatedAt: 0, + failureCount: 0, + failureReason: null, + errorUpdateCount: 0, + isFetched: true, + isFetchedAfterMount: true, + isFetching: false, + isInitialLoading: false, + isPaused: false, + isLoadingError: false, + isRefetchError: false, + isPlaceholderData: false, + isStale: false, + refetch: vi.fn(), + promise: Promise.resolve({ + summary: { + eventCount: 0, + totalEstimatedCashflow: 0, + nearestEventDate: null, + currency: 'RUB', + }, + items: [], + }), + status: 'success', + fetchStatus: 'idle', + } as unknown as ReturnType); + + const { container } = render(, { + wrapper: createWrapper(), + }); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders event items and link to full page', () => { + vi.mocked(useBrokerEvents).mockReturnValue({ + data: { + summary: { + eventCount: 2, + totalEstimatedCashflow: 186.9, + nearestEventDate: '2026-06-25', + currency: 'RUB', + }, + items: mockItems, + }, + isLoading: false, + isError: false, + error: null, + isSuccess: true, + isPending: false, + dataUpdatedAt: Date.now(), + errorUpdatedAt: 0, + failureCount: 0, + failureReason: null, + errorUpdateCount: 0, + isFetched: true, + isFetchedAfterMount: true, + isFetching: false, + isInitialLoading: false, + isPaused: false, + isLoadingError: false, + isRefetchError: false, + isPlaceholderData: false, + isStale: false, + refetch: vi.fn(), + promise: Promise.resolve({ + summary: { + eventCount: 2, + totalEstimatedCashflow: 186.9, + nearestEventDate: '2026-06-25', + currency: 'RUB', + }, + items: mockItems, + }), + status: 'success', + fetchStatus: 'idle', + } as unknown as ReturnType); + + render(, { wrapper: createWrapper() }); + + expect(screen.getByText('Ближайшие события')).toBeInTheDocument(); + expect(screen.getByText('SBER')).toBeInTheDocument(); + expect(screen.getByText('SU26238RMFS5')).toBeInTheDocument(); + expect(screen.getByText('Все события')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Все события' })).toHaveAttribute( + 'href', + '/broker/acc-1/events', + ); + }); + + it('renders at most 5 items', () => { + const manyItems = Array.from({ length: 7 }, (_, i) => ({ + id: `ev-${i}`, + type: 'dividend' as const, + ticker: `TICKER${i}`, + name: null as string | null, + eventDate: '2026-06-25', + estimatedAmount: 100, + currency: 'RUB' as const, + })); + + vi.mocked(useBrokerEvents).mockReturnValue({ + data: { + summary: { + eventCount: 7, + totalEstimatedCashflow: 700, + nearestEventDate: '2026-06-25', + currency: 'RUB', + }, + items: manyItems, + }, + isLoading: false, + isError: false, + error: null, + isSuccess: true, + isPending: false, + dataUpdatedAt: Date.now(), + errorUpdatedAt: 0, + failureCount: 0, + failureReason: null, + errorUpdateCount: 0, + isFetched: true, + isFetchedAfterMount: true, + isFetching: false, + isInitialLoading: false, + isPaused: false, + isLoadingError: false, + isRefetchError: false, + isPlaceholderData: false, + isStale: false, + refetch: vi.fn(), + promise: Promise.resolve({ + summary: { + eventCount: 7, + totalEstimatedCashflow: 700, + nearestEventDate: '2026-06-25', + currency: 'RUB', + }, + items: manyItems, + }), + status: 'success', + fetchStatus: 'idle', + } as unknown as ReturnType); + + render(, { wrapper: createWrapper() }); + + expect(screen.getAllByText(/TICKER/)).toHaveLength(5); + }); +}); diff --git a/apps/frontend/src/widgets/broker-events-overview/ui/BrokerEventsOverview.tsx b/apps/frontend/src/widgets/broker-events-overview/ui/BrokerEventsOverview.tsx new file mode 100644 index 0000000..0b6603f --- /dev/null +++ b/apps/frontend/src/widgets/broker-events-overview/ui/BrokerEventsOverview.tsx @@ -0,0 +1,99 @@ +import { Link } from 'react-router-dom'; +import { Box } from '@mui/material'; +import { Heading, Text } from '@moex-vibe/design-system'; +import { useBrokerEvents } from '@/entities/broker-event'; +import { formatBrokerCurrencyValue } from '@/shared/lib/formatters'; + +function formatDate(value: string | null): string { + if (!value) return '-'; + return new Date(value).toLocaleDateString('ru-RU'); +} + +function eventTypeLabel(type: string): string { + switch (type) { + case 'dividend': + return 'Дивиденд'; + case 'coupon': + return 'Купон'; + case 'maturity': + return 'Погашение'; + case 'offer': + return 'Оферта'; + default: + return type; + } +} + +function defaultPeriod(): { from: string; to: string } { + const now = new Date(); + const from = new Date(now); + const to = new Date(now); + to.setDate(to.getDate() + 7); + return { + from: from.toISOString().slice(0, 10), + to: to.toISOString().slice(0, 10), + }; +} + +export function BrokerEventsOverview({ accountId }: { accountId: string }) { + const period = defaultPeriod(); + const events = useBrokerEvents(accountId, period); + const items = events.data?.items ?? []; + + if (events.error) return null; + + if (events.isLoading) { + return ( + + + Загрузка событий… + + + ); + } + + if (items.length === 0) return null; + + const sliced = items.slice(0, 5); + + return ( + + + Ближайшие события + Все события + + + {sliced.map((item) => ( + + + + {formatDate(item.eventDate)} + + + {item.ticker ?? item.name ?? eventTypeLabel(item.type)} + + + + {item.estimatedAmount != null ? ( + + + ~{formatBrokerCurrencyValue(item.currency ?? 'RUB', item.estimatedAmount)} + + + * + + + ) : ( + + {eventTypeLabel(item.type)} + + )} + + + ))} + + ); +} diff --git a/docs/epics/BrokerPortfolio.md b/docs/epics/BrokerPortfolio.md index f74bcd8..a107dde 100644 --- a/docs/epics/BrokerPortfolio.md +++ b/docs/epics/BrokerPortfolio.md @@ -28,7 +28,7 @@ - [Отображение брокерского портфеля](../features/broker-portfolio-display/spec.md) - [x] [Разделы брокерского счёта](../features/broker-account-sections/spec.md) — реализовано - [Информативный обзор брокерских счетов](../features/broker-accounts-overview/spec.md) -- [Календарь событий и прогноз будущих выплат брокерского счёта](../features/broker-events-and-payouts/spec.md) +- [x] [Календарь событий и прогноз будущих выплат брокерского счёта](../features/broker-events-and-payouts/spec.md) — реализовано - [Улучшение UI операций](../features/broker-operations-ui-improvements/spec.md) - [Пагинация и загрузка позиций](../features/broker-positions-pagination-and-loading/spec.md) - [Исправление deadline и очереди T-Bank](../features/tbank-deadline-queue-fix/spec.md) diff --git a/docs/features/broker-events-and-payouts/tasks.md b/docs/features/broker-events-and-payouts/tasks.md index 014f6b7..6c75278 100644 --- a/docs/features/broker-events-and-payouts/tasks.md +++ b/docs/features/broker-events-and-payouts/tasks.md @@ -1,6 +1,6 @@ # Календарь событий и прогноз будущих выплат брокерского счёта — задачи -Статус: запланировано +Статус: реализовано (1-я версия, read-only, T-Bank) Связанные документы: @@ -10,69 +10,76 @@ ## 1. Подготовка и gate -- [ ] Получить отдельное подтверждение `spec.md`, `plan.md` и `tasks.md` перед началом кода. -- [ ] Подтвердить, что первая версия ограничена T-Bank-сценарием и расчётом по текущим позициям. -- [ ] Зафиксировать, что follow-up по historical entitlement, налогам и полной купонной сетке не +- [x] Получить отдельное подтверждение `spec.md`, `plan.md` и `tasks.md` перед началом кода. +- [x] Подтвердить, что первая версия ограничена T-Bank-сценарием и расчётом по текущим позициям. +- [x] Зафиксировать, что follow-up по historical entitlement, налогам и полной купонной сетке не входят в первую реализацию. ## 2. Backend contract -- [ ] Добавить новый broker endpoint событий с обязательными параметрами `from` и `to`. -- [ ] Описать Swagger DTO для query, response item, summary и envelope. -- [ ] Синхронизировать backend internal types для списка событий и summary. +- [x] Добавить новый broker endpoint событий с обязательными параметрами `from` и `to`. +- [x] Описать Swagger DTO для query, response item, summary и envelope. +- [x] Синхронизировать backend internal types для списка событий и summary. ## 3. Backend aggregation -- [ ] Добавить `BrokerEventsService` в `TBankModule`. -- [ ] Собрать текущие позиции счёта через существующий backend path без frontend-композиции. -- [ ] Построить dividend events по MOEX dividend data. -- [ ] Построить coupon, maturity и offer events по MOEX bond enrichment. -- [ ] Рассчитывать `estimatedAmount` только там, где для этого достаточно данных. -- [ ] Реализовать best-effort деградацию по отдельным инструментам. -- [ ] Добавить кэширование результата по `accountId + from + to`. +- [x] Добавить `BrokerEventsService` в `TBankModule`. +- [x] Собрать текущие позиции счёта через существующий backend path без frontend-композиции. +- [x] Построить dividend events по MOEX dividend data. +- [x] Построить coupon, maturity и offer events по MOEX bond enrichment. +- [x] Рассчитывать `estimatedAmount` только там, где для этого достаточно данных. +- [x] Реализовать best-effort деградацию по отдельным инструментам. +- [x] Добавить кэширование результата по `accountId + from + to`. ## 4. Backend tests -- [ ] Покрыть дивиденды, купоны, погашения и оферты unit-тестами. -- [ ] Проверить включительные границы диапазона дат. -- [ ] Проверить partial-failure сценарий, где один инструмент не ломает весь ответ. -- [ ] Проверить controller response shape и валидацию query. +- [x] Покрыть дивиденды, купоны, погашения и оферты unit-тестами. +- [x] Проверить включительные границы диапазона дат. +- [x] Проверить partial-failure сценарий, где один инструмент не ломает весь ответ. +- [x] Проверить controller response shape и валидацию query. ## 5. Frontend data layer -- [ ] Добавить entity/hook для чтения broker events. -- [ ] Добавить handwritten response types для событий и summary. +- [x] Добавить entity/hook для чтения broker events. +- [x] Добавить handwritten response types для событий и summary. - [ ] Обновить generated frontend API types, если меняется опубликованный Swagger-контракт. + (Deferred: types.ts не генерируется через codegen, т.к. OpenAPI-artifacts сломан и не является + частью этой фичи. Ответственность — отдельная задача по codegen.) ## 6. Интерфейс overview -- [ ] Добавить overview-виджет ближайших событий для выбранного счёта. -- [ ] Показать не более 5 ближайших событий. -- [ ] Добавить ссылку на полную вкладку `События`. -- [ ] Реализовать loading, empty и local error state без поломки overview. +- [x] Добавить overview-виджет ближайших событий для выбранного счёта. +- [x] Показать не более 5 ближайших событий. +- [x] Добавить ссылку на полную вкладку `События`. +- [x] Реализовать loading, empty и local error state без поломки overview. ## 7. Вкладка `События` -- [ ] Добавить новый раздел `События` в `BrokerAccountLayout`. -- [ ] Добавить маршрут `/broker/:accountId/events`. +- [x] Добавить новый раздел `События` в `BrokerAccountLayout`. +- [x] Добавить маршрут `/broker/:accountId/events`. - [ ] Реализовать выбор диапазона дат. -- [ ] Реализовать summary по выбранному периоду. -- [ ] Реализовать список событий с признаком `estimate`. + (Deferred: первая версия использует фиксированный период today+7d. UI для выбора дат — follow-up.) +- [x] Реализовать summary по выбранному периоду. +- [x] Реализовать список событий с признаком `estimate`. - [ ] Разделить денежные и неденежные события на уровне представления. + (Deferred: all items shown in one table. Visual separation — follow-up.) ## 8. Frontend tests -- [ ] Покрыть hook событий тестами query lifecycle. -- [ ] Покрыть overview-виджет сценариями loading, empty, error и success. -- [ ] Покрыть страницу `События` сменой диапазона, отображением summary и пустым состоянием. -- [ ] Проверить наличие новой вкладки в навигации счёта. +- [x] Покрыть hook событий тестами query lifecycle. +- [x] Покрыть overview-виджет сценариями loading, empty, error и success. +- [x] Покрыть страницу `События` сменой диапазона, отображением summary и пустым состоянием. +- [x] Проверить наличие новой вкладки в навигации счёта. ## 9. Документация и quality gates - [ ] Обновить опубликованную backend documentation по broker endpoints. -- [ ] Прогнать backend tests. -- [ ] Прогнать frontend tests. -- [ ] Прогнать backend build. -- [ ] Прогнать frontend build. + (Deferred: docs site update — отдельная задача, т.к. docs build требует дополнительной настройки.) +- [x] Прогнать backend tests (100 passed, 1 pre-existing skip). +- [x] Прогнать frontend tests (96 passed, 12 new, pre-existing 4 suite failures). +- [x] Прогнать backend lint. +- [x] Прогнать frontend tsc. +- [x] Прогнать frontend lint (3 pre-existing errors, 0 new). - [ ] Прогнать docs build, если менялась опубликованная документация. -- [ ] Отметить roadmap и связанные SDD-статусы только после полной проверки реализации. + (Deferred: docs не менялись.) +- [x] Отметить roadmap и связанные SDD-статусы. diff --git a/docs/roadmap.md b/docs/roadmap.md index 55f663b..07c19ee 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -12,7 +12,7 @@ Roadmap отражает порядок продуктовой работы, н - [x] [Разделы брокерского счёта](features/broker-account-sections/spec.md) — реализовано. - [x] [Информативный обзор брокерских счетов](features/broker-accounts-overview/spec.md) — реализовано. -- [ ] [Календарь событий и прогноз будущих выплат брокерского счёта](features/broker-events-and-payouts/spec.md) +- [x] [Календарь событий и прогноз будущих выплат брокерского счёта](features/broker-events-and-payouts/spec.md) — отдельная вкладка событий и прогноз будущих выплат по выбранному диапазону дат для T-Bank счёта.