From f7dc338719efcf004eb35b0aae64c481f03ff81d Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Mon, 22 Jun 2026 21:48:03 +0300 Subject: [PATCH] feat: add actual payouts and filter-as-draft UX to broker events calendar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: actual events from T-Bank operations, types filter, split forecast/actual summary - Frontend: draft/applied filters with multi-select types, status column (Факт/Прогноз), green actual amounts - Docs: update spec, plan, tasks --- .../tbank/dto/broker-events-query.dto.ts | 13 +- .../tbank/dto/broker-events-response.dto.ts | 26 ++- .../services/broker-events.service.spec.ts | 218 ++++++++++++++++-- .../tbank/services/broker-events.service.ts | 200 ++++++++++++++-- .../modules/tbank/tbank.controller.spec.ts | 10 +- .../src/modules/tbank/tbank.controller.ts | 4 +- .../src/modules/tbank/types/broker.types.ts | 9 +- .../broker-event/api/brokerEventApi.ts | 2 + .../model/useBrokerEvents.test.tsx | 11 +- .../broker-event/model/useBrokerEvents.ts | 6 +- .../ui/BrokerEventsPage.test.tsx | 128 +++++++++- .../broker-events/ui/BrokerEventsPage.tsx | 189 +++++++++++++-- apps/frontend/src/shared/api/responses.ts | 9 +- .../broker-events-and-payouts/plan.md | 85 +++++-- .../broker-events-and-payouts/spec.md | 58 ++++- .../broker-events-and-payouts/tasks.md | 22 +- 16 files changed, 887 insertions(+), 103 deletions(-) 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 index 10de55e..5182853 100644 --- a/apps/backend/src/modules/tbank/dto/broker-events-query.dto.ts +++ b/apps/backend/src/modules/tbank/dto/broker-events-query.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { Matches } from 'class-validator'; +import { IsOptional, Matches } from 'class-validator'; export class BrokerEventsQueryDto { @ApiProperty({ example: '2026-06-22', description: 'Start date inclusive (YYYY-MM-DD)' }) @@ -9,4 +9,15 @@ export class BrokerEventsQueryDto { @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; + + @ApiProperty({ + required: false, + example: 'dividend,coupon,maturity,offer', + description: 'Comma-separated event types to include', + }) + @IsOptional() + @Matches(/^(dividend|coupon|maturity|offer)(,(dividend|coupon|maturity|offer))*$/, { + message: 'types must be a comma-separated list of known event types', + }) + types?: 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 index 5219d00..fb92719 100644 --- a/apps/backend/src/modules/tbank/dto/broker-events-response.dto.ts +++ b/apps/backend/src/modules/tbank/dto/broker-events-response.dto.ts @@ -1,6 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; const eventTypes = ['dividend', 'coupon', 'maturity', 'offer'] as const; +const eventSources = ['forecast', 'actual'] as const; const eventCategories = ['cashflow', 'corporate'] as const; const instrumentTypes = ['share', 'bond', 'other'] as const; @@ -11,6 +12,9 @@ export class BrokerEventItemDto { @ApiProperty({ enum: eventTypes }) type!: string; + @ApiProperty({ enum: eventSources }) + source!: string; + @ApiProperty({ enum: eventCategories }) category!: string; @@ -41,11 +45,14 @@ export class BrokerEventItemDto { @ApiProperty({ nullable: true }) estimatedAmount!: number | null; + @ApiProperty({ nullable: true }) + actualAmount!: number | null; + @ApiProperty({ nullable: true }) currency!: string | null; - @ApiProperty() - estimateMode!: 'current_position'; + @ApiProperty({ nullable: true }) + estimateMode!: 'current_position' | null; } export class BrokerEventsSummaryDto { @@ -58,6 +65,12 @@ export class BrokerEventsSummaryDto { @ApiProperty() totalEstimatedCashflow!: number; + @ApiProperty() + actualCashflow!: number; + + @ApiProperty() + forecastEstimatedCashflow!: number; + @ApiProperty() dividendsTotal!: number; @@ -66,6 +79,15 @@ export class BrokerEventsSummaryDto { @ApiProperty() principalRepaymentTotal!: number; + + @ApiProperty() + actualDividendsTotal!: number; + + @ApiProperty() + actualCouponsTotal!: number; + + @ApiProperty() + actualPrincipalRepaymentTotal!: number; } export class BrokerEventsDataDto { 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 index 99c0b77..b32d9a0 100644 --- a/apps/backend/src/modules/tbank/services/broker-events.service.spec.ts +++ b/apps/backend/src/modules/tbank/services/broker-events.service.spec.ts @@ -3,6 +3,7 @@ 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', () => { @@ -12,6 +13,7 @@ describe('BrokerEventsService', () => { 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 = { @@ -40,10 +42,10 @@ describe('BrokerEventsService', () => { 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, - ); + 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 () => { @@ -54,13 +56,20 @@ describe('BrokerEventsService', () => { instruments: new Map(), }); - const service = new BrokerEventsService(accounts, portfolio, moex, cache); - const result = await service.getEvents('acc-1', '2026-06-01', '2026-07-01'); + 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 () => { @@ -101,13 +110,20 @@ describe('BrokerEventsService', () => { }, ]); - const service = new BrokerEventsService(accounts, portfolio, moex, cache); - const result = await service.getEvents('acc-1', '2026-06-20', '2026-07-10'); + 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'); }); @@ -148,8 +164,13 @@ describe('BrokerEventsService', () => { }, ]); - const service = new BrokerEventsService(accounts, portfolio, moex, cache); - const result = await service.getEvents('acc-1', '2026-06-20', '2026-07-10'); + 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')!; @@ -188,8 +209,13 @@ describe('BrokerEventsService', () => { { 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'); + 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'); @@ -213,8 +239,13 @@ describe('BrokerEventsService', () => { { 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'); + 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); @@ -268,8 +299,13 @@ describe('BrokerEventsService', () => { }, ]); - const service = new BrokerEventsService(accounts, portfolio, moex, cache); - const result = await service.getEvents('acc-1', '2026-06-20', '2026-07-10'); + 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'); @@ -277,6 +313,8 @@ describe('BrokerEventsService', () => { 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 () => { @@ -299,11 +337,155 @@ describe('BrokerEventsService', () => { { 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'); + 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); + }); }); diff --git a/apps/backend/src/modules/tbank/services/broker-events.service.ts b/apps/backend/src/modules/tbank/services/broker-events.service.ts index 8d972a2..ef60b52 100644 --- a/apps/backend/src/modules/tbank/services/broker-events.service.ts +++ b/apps/backend/src/modules/tbank/services/broker-events.service.ts @@ -4,27 +4,54 @@ import { MoexClientService } from '../../moex-client/moex-client.service'; import { TBANK_CACHE_KEYS } from '../tbank.config'; import { mapQuotationToNumber } from '../mappers/money.mapper'; import type { + BrokerOperation, BrokerPortfolioEvent, BrokerEventsData, BrokerEventsSummary, } from '../types/broker.types'; import type { TBankInstrument } from '../types/tbank-proto.types'; import { BrokerAccountsService } from './broker-accounts.service'; +import { BrokerOperationsService } from './broker-operations.service'; import { BrokerPortfolioService } from './broker-portfolio.service'; +type BrokerEventType = BrokerPortfolioEvent['type']; + +type BrokerEventsQuery = { + from: string; + to: string; + types?: string; +}; + +const ALL_EVENT_TYPES: BrokerEventType[] = ['dividend', 'coupon', 'maturity', 'offer']; + +const ACTUAL_OPERATION_TYPES: Record, string[]> = { + dividend: ['OPERATION_TYPE_DIVIDEND', 'OPERATION_TYPE_DIV_EXT'], + coupon: ['OPERATION_TYPE_COUPON'], + maturity: ['OPERATION_TYPE_BOND_REPAYMENT', 'OPERATION_TYPE_BOND_REPAYMENT_FULL'], +}; + +const OPERATION_EVENT_TYPES = new Map>( + Object.entries(ACTUAL_OPERATION_TYPES).flatMap(([eventType, operationTypes]) => + operationTypes.map((operationType) => [ + operationType, + eventType as Exclude, + ]), + ), +); + @Injectable() export class BrokerEventsService { constructor( private readonly accountsService: BrokerAccountsService, private readonly portfolioService: BrokerPortfolioService, private readonly moexClient: MoexClientService, + private readonly operationsService: BrokerOperationsService, private readonly cacheService: CacheService, ) {} async getEvents( accountId: string, - from: string, - to: string, + query: BrokerEventsQuery, ): Promise<{ data: BrokerEventsData; meta: { fromCache: boolean; cachedAt: string | null }; @@ -32,10 +59,12 @@ export class BrokerEventsService { const account = await this.accountsService.findById(accountId); if (!account) throw new NotFoundException('Broker account not found'); + const eventTypes = this.parseEventTypes(query.types); + const eventTypeKey = Array.from(eventTypes).join(','); const result = await this.cacheService.getOrFetch( TBANK_CACHE_KEYS.events, - [accountId, from, to], - () => this.buildEvents(accountId, from, to), + [accountId, query.from, query.to, eventTypeKey], + () => this.buildEvents(accountId, query.from, query.to, eventTypes), 'tbankPortfolioTtl', ); @@ -49,6 +78,7 @@ export class BrokerEventsService { accountId: string, from: string, to: string, + eventTypes: Set, ): Promise { const { positions, instruments } = await this.portfolioService.getPositionsWithInstruments(accountId); @@ -59,22 +89,37 @@ export class BrokerEventsService { const bondPositions = positions.filter((p) => p.instrumentType?.toLowerCase() === 'bond'); const shareResults = await Promise.allSettled( - sharePositions.map((pos) => this.buildShareEvents(pos, instruments, from, to)), + eventTypes.has('dividend') + ? 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); + const bondEvents = await this.buildBondEvents( + bondPositions, + instruments, + from, + to, + eventTypes, + ); items.push(...bondEvents); } - items.sort((a, b) => a.eventDate.localeCompare(b.eventDate)); + const actualEvents = await this.buildActualEvents(accountId, from, to, eventTypes); + const actualKeys = new Set(actualEvents.map((event) => this.eventDedupKey(event))); + const deduplicatedForecasts = items.filter( + (event) => !actualKeys.has(this.eventDedupKey(event)), + ); - const summary = this.buildSummary(items); + const allItems = [...actualEvents, ...deduplicatedForecasts]; + allItems.sort((a, b) => a.eventDate.localeCompare(b.eventDate)); - return { items, summary, asOf: new Date().toISOString() }; + const summary = this.buildSummary(allItems); + + return { items: allItems, summary, asOf: new Date().toISOString() }; } private async buildShareEvents( @@ -114,6 +159,7 @@ export class BrokerEventsService { events.push({ id: `div-${ticker}-${d.registryCloseDate}`, type: 'dividend', + source: 'forecast', category: 'cashflow', eventDate: d.registryCloseDate, paymentDate: null, @@ -124,6 +170,7 @@ export class BrokerEventsService { quantitySnapshot: quantity, payoutPerUnit, estimatedAmount, + actualAmount: null, currency: d.currencyId, estimateMode: 'current_position', }); @@ -142,6 +189,7 @@ export class BrokerEventsService { instruments: Map>, from: string, to: string, + eventTypes: Set, ): Promise { const secids = bondPositions.map((p) => p.ticker).filter((t): t is string => Boolean(t)); if (secids.length === 0) return []; @@ -174,7 +222,12 @@ export class BrokerEventsService { const name = instrument?.name || null; const currency = instrument?.currency || 'RUB'; - if (bond.nextCouponDate && bond.nextCouponDate >= from && bond.nextCouponDate <= to) { + if ( + eventTypes.has('coupon') && + bond.nextCouponDate && + bond.nextCouponDate >= from && + bond.nextCouponDate <= to + ) { const payoutPerUnit = bond.couponValue; const estimatedAmount = quantity !== null && payoutPerUnit !== null ? quantity * payoutPerUnit : null; @@ -182,6 +235,7 @@ export class BrokerEventsService { events.push({ id: `coupon-${ticker}-${bond.nextCouponDate}`, type: 'coupon', + source: 'forecast', category: 'cashflow', eventDate: bond.nextCouponDate, paymentDate: null, @@ -192,18 +246,25 @@ export class BrokerEventsService { quantitySnapshot: quantity, payoutPerUnit, estimatedAmount, + actualAmount: null, currency, estimateMode: 'current_position', }); } - if (bond.matDate && bond.matDate >= from && bond.matDate <= to) { + if ( + eventTypes.has('maturity') && + 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', + source: 'forecast', category: 'cashflow', eventDate: bond.matDate, paymentDate: null, @@ -214,15 +275,22 @@ export class BrokerEventsService { quantitySnapshot: quantity, payoutPerUnit, estimatedAmount, + actualAmount: null, currency, estimateMode: 'current_position', }); } - if (bond.offerDate && bond.offerDate >= from && bond.offerDate <= to) { + if ( + eventTypes.has('offer') && + bond.offerDate && + bond.offerDate >= from && + bond.offerDate <= to + ) { events.push({ id: `offer-${ticker}-${bond.offerDate}`, type: 'offer', + source: 'forecast', category: 'corporate', eventDate: bond.offerDate, paymentDate: null, @@ -233,6 +301,7 @@ export class BrokerEventsService { quantitySnapshot: quantity, payoutPerUnit: null, estimatedAmount: null, + actualAmount: null, currency, estimateMode: 'current_position', }); @@ -244,20 +313,119 @@ export class BrokerEventsService { private buildSummary(items: BrokerPortfolioEvent[]): BrokerEventsSummary { const cashflowEvents = items.filter((e) => e.category === 'cashflow'); + const forecastEvents = cashflowEvents.filter((e) => e.source === 'forecast'); + const actualEvents = cashflowEvents.filter((e) => e.source === 'actual'); return { eventCount: items.length, nearestEventDate: items.length > 0 ? items[0].eventDate : null, - totalEstimatedCashflow: cashflowEvents.reduce((sum, e) => sum + (e.estimatedAmount ?? 0), 0), - dividendsTotal: cashflowEvents + totalEstimatedCashflow: forecastEvents.reduce((sum, e) => sum + (e.estimatedAmount ?? 0), 0), + forecastEstimatedCashflow: forecastEvents.reduce( + (sum, e) => sum + (e.estimatedAmount ?? 0), + 0, + ), + actualCashflow: actualEvents.reduce((sum, e) => sum + (e.actualAmount ?? 0), 0), + dividendsTotal: forecastEvents .filter((e) => e.type === 'dividend') .reduce((sum, e) => sum + (e.estimatedAmount ?? 0), 0), - couponsTotal: cashflowEvents + couponsTotal: forecastEvents .filter((e) => e.type === 'coupon') .reduce((sum, e) => sum + (e.estimatedAmount ?? 0), 0), - principalRepaymentTotal: cashflowEvents + principalRepaymentTotal: forecastEvents .filter((e) => e.type === 'maturity') .reduce((sum, e) => sum + (e.estimatedAmount ?? 0), 0), + actualDividendsTotal: actualEvents + .filter((e) => e.type === 'dividend') + .reduce((sum, e) => sum + (e.actualAmount ?? 0), 0), + actualCouponsTotal: actualEvents + .filter((e) => e.type === 'coupon') + .reduce((sum, e) => sum + (e.actualAmount ?? 0), 0), + actualPrincipalRepaymentTotal: actualEvents + .filter((e) => e.type === 'maturity') + .reduce((sum, e) => sum + (e.actualAmount ?? 0), 0), }; } + + private async buildActualEvents( + accountId: string, + from: string, + to: string, + eventTypes: Set, + ): Promise { + const operationTypes = this.actualOperationTypes(eventTypes); + if (operationTypes.length === 0) return []; + + const page = await this.operationsService.getOperations(accountId, { + from: `${from}T00:00:00.000Z`, + to: `${to}T23:59:59.999Z`, + operationTypes: operationTypes.join(','), + limit: 100, + state: 'OPERATION_STATE_EXECUTED', + }); + + return page.data.items.flatMap((operation) => { + const eventType = OPERATION_EVENT_TYPES.get(operation.type); + if (!eventType || !eventTypes.has(eventType) || !operation.date) return []; + + const eventDate = operation.date.slice(0, 10); + if (eventDate < from || eventDate > to) return []; + + return [this.mapActualOperation(operation, eventType, eventDate)]; + }); + } + + private mapActualOperation( + operation: BrokerOperation, + type: Exclude, + eventDate: string, + ): BrokerPortfolioEvent { + return { + id: `actual-${operation.id || operation.cursor || `${type}-${eventDate}`}`, + type, + source: 'actual', + category: 'cashflow', + eventDate, + paymentDate: eventDate, + ticker: operation.ticker, + name: operation.name || operation.description, + instrumentUid: operation.instrumentUid, + instrumentType: this.mapInstrumentType(operation.instrumentType), + quantitySnapshot: operation.quantityDone ?? operation.quantity, + payoutPerUnit: null, + estimatedAmount: null, + actualAmount: operation.payment?.value ?? null, + currency: operation.payment?.currency ?? null, + estimateMode: null, + }; + } + + private parseEventTypes(types: string | undefined): Set { + if (!types) return new Set(ALL_EVENT_TYPES); + + const parsed = types + .split(',') + .map((value) => value.trim()) + .filter((value): value is BrokerEventType => + ALL_EVENT_TYPES.includes(value as BrokerEventType), + ); + + return new Set(parsed.length > 0 ? parsed : ALL_EVENT_TYPES); + } + + private actualOperationTypes(eventTypes: Set): string[] { + return Array.from(eventTypes).flatMap((type) => + type === 'offer' ? [] : ACTUAL_OPERATION_TYPES[type], + ); + } + + private mapInstrumentType(instrumentType: string | null): 'share' | 'bond' | 'other' { + const normalized = instrumentType?.toLowerCase(); + if (normalized === 'share') return 'share'; + if (normalized === 'bond') return 'bond'; + return 'other'; + } + + private eventDedupKey(event: BrokerPortfolioEvent): string { + return `${event.type}:${event.ticker ?? event.instrumentUid ?? event.name ?? ''}:${event.eventDate}`; + } } diff --git a/apps/backend/src/modules/tbank/tbank.controller.spec.ts b/apps/backend/src/modules/tbank/tbank.controller.spec.ts index 67c2d07..c9cc156 100644 --- a/apps/backend/src/modules/tbank/tbank.controller.spec.ts +++ b/apps/backend/src/modules/tbank/tbank.controller.spec.ts @@ -68,9 +68,14 @@ describe('TBankController', () => { eventCount: 0, nearestEventDate: null, totalEstimatedCashflow: 0, + actualCashflow: 0, + forecastEstimatedCashflow: 0, dividendsTotal: 0, couponsTotal: 0, principalRepaymentTotal: 0, + actualDividendsTotal: 0, + actualCouponsTotal: 0, + actualPrincipalRepaymentTotal: 0, }, asOf: '2026-06-22T00:00:00.000Z', }; @@ -80,9 +85,10 @@ describe('TBankController', () => { }); const controller = new TBankController(accounts, portfolio, events, operations, sync); - const response = await controller.getEvents('acc-1', { from: '2026-06-22', to: '2026-07-29' }); + const query = { from: '2026-06-22', to: '2026-07-29', types: 'dividend,coupon' }; + const response = await controller.getEvents('acc-1', query); - expect(events.getEvents).toHaveBeenCalledWith('acc-1', '2026-06-22', '2026-07-29'); + expect(events.getEvents).toHaveBeenCalledWith('acc-1', query); 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 f91eb81..6d52de7 100644 --- a/apps/backend/src/modules/tbank/tbank.controller.ts +++ b/apps/backend/src/modules/tbank/tbank.controller.ts @@ -77,10 +77,10 @@ export class TBankController { } @Get('accounts/:accountId/events') - @ApiOperation({ summary: 'Get upcoming events and estimated cashflow for a broker account' }) + @ApiOperation({ summary: 'Get broker account calendar events and cashflow' }) @ApiOkResponse({ type: BrokerEventsEnvelopeDto }) async getEvents(@Param('accountId') accountId: string, @Query() query: BrokerEventsQueryDto) { - const result = await this.brokerEventsService.getEvents(accountId, query.from, query.to); + const result = await this.brokerEventsService.getEvents(accountId, query); return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt); } diff --git a/apps/backend/src/modules/tbank/types/broker.types.ts b/apps/backend/src/modules/tbank/types/broker.types.ts index 8a97682..81479b6 100644 --- a/apps/backend/src/modules/tbank/types/broker.types.ts +++ b/apps/backend/src/modules/tbank/types/broker.types.ts @@ -106,6 +106,7 @@ export type BrokerOperationsPage = { export type BrokerPortfolioEvent = { id: string; type: 'dividend' | 'coupon' | 'maturity' | 'offer'; + source: 'forecast' | 'actual'; category: 'cashflow' | 'corporate'; eventDate: string; paymentDate: string | null; @@ -116,17 +117,23 @@ export type BrokerPortfolioEvent = { quantitySnapshot: number | null; payoutPerUnit: number | null; estimatedAmount: number | null; + actualAmount: number | null; currency: string | null; - estimateMode: 'current_position'; + estimateMode: 'current_position' | null; }; export type BrokerEventsSummary = { eventCount: number; nearestEventDate: string | null; totalEstimatedCashflow: number; + actualCashflow: number; + forecastEstimatedCashflow: number; dividendsTotal: number; couponsTotal: number; principalRepaymentTotal: number; + actualDividendsTotal: number; + actualCouponsTotal: number; + actualPrincipalRepaymentTotal: number; }; export type BrokerEventsData = { diff --git a/apps/frontend/src/entities/broker-event/api/brokerEventApi.ts b/apps/frontend/src/entities/broker-event/api/brokerEventApi.ts index 9739f5b..2d7fa0b 100644 --- a/apps/frontend/src/entities/broker-event/api/brokerEventApi.ts +++ b/apps/frontend/src/entities/broker-event/api/brokerEventApi.ts @@ -4,6 +4,7 @@ import type { ApiResponseMeta, BrokerEventsData } from '@/shared/api/responses'; export type BrokerEventsQuery = { from: string; to: string; + types?: string; }; export function getBrokerEvents( @@ -15,6 +16,7 @@ export function getBrokerEvents( { from: query.from, to: query.to, + types: query.types, }, ); } diff --git a/apps/frontend/src/entities/broker-event/model/useBrokerEvents.test.tsx b/apps/frontend/src/entities/broker-event/model/useBrokerEvents.test.tsx index 46f2881..41b7135 100644 --- a/apps/frontend/src/entities/broker-event/model/useBrokerEvents.test.tsx +++ b/apps/frontend/src/entities/broker-event/model/useBrokerEvents.test.tsx @@ -81,13 +81,16 @@ describe('useBrokerEvents', () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); queryClient.setQueryData( - ['broker', 'events', 'acc-1', '2026-06-22', '2026-06-29'], + ['broker', 'events', 'acc-1', '2026-06-22', '2026-06-29', 'dividend,coupon'], mockEventsData, ); - const { result } = renderHook(() => useBrokerEvents('acc-1', query), { - wrapper: createWrapper(queryClient), - }); + const { result } = renderHook( + () => useBrokerEvents('acc-1', { ...query, types: 'dividend,coupon' }), + { + wrapper: createWrapper(queryClient), + }, + ); await waitFor(() => expect(result.current.data).toBe(mockEventsData)); 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 index ca4f06a..1ab29f5 100644 --- a/apps/frontend/src/entities/broker-event/model/useBrokerEvents.ts +++ b/apps/frontend/src/entities/broker-event/model/useBrokerEvents.ts @@ -3,11 +3,11 @@ import type { BrokerEventsData } from '@/shared/api/responses'; import { getBrokerEvents, type BrokerEventsQuery } from '../api/brokerEventApi'; export function useBrokerEvents(accountId: string | undefined, query: BrokerEventsQuery) { - const { from, to } = query; + const { from, to, types } = query; return useQuery({ - queryKey: ['broker', 'events', accountId, from, to], + queryKey: ['broker', 'events', accountId, from, to, types], enabled: Boolean(accountId), - queryFn: async () => (await getBrokerEvents(accountId!, { from, to })).data, + queryFn: async () => (await getBrokerEvents(accountId!, { from, to, types })).data, staleTime: 300_000, retry: 2, refetchOnWindowFocus: false, diff --git a/apps/frontend/src/pages/broker-events/ui/BrokerEventsPage.test.tsx b/apps/frontend/src/pages/broker-events/ui/BrokerEventsPage.test.tsx index 3618303..1e0a7ab 100644 --- a/apps/frontend/src/pages/broker-events/ui/BrokerEventsPage.test.tsx +++ b/apps/frontend/src/pages/broker-events/ui/BrokerEventsPage.test.tsx @@ -1,5 +1,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import React, { type ReactNode } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { BrokerEventsPage } from './BrokerEventsPage'; @@ -13,11 +14,24 @@ vi.mock('@/widgets/broker-account-layout', () => ({ })); const mockSetSearchParams = vi.fn(); +let currentSearchParams = new URLSearchParams(); vi.mock('react-router-dom', () => ({ - useSearchParams: () => [new URLSearchParams(), mockSetSearchParams], + useSearchParams: () => [currentSearchParams, mockSetSearchParams], })); vi.mock('@moex-vibe/design-system', () => ({ + Button: ({ children, onClick, disabled }: any) => ( + + ), + Checkbox: ({ label, checked, onChange }: any) => ( + + ), + Chip: ({ label }: any) => {label}, Heading: ({ children }: { children: ReactNode }) =>

{children}

, Text: ({ children }: { children: ReactNode }) => {children}, TextField: ({ label, value, onChange, ...props }: any) => ( @@ -44,36 +58,70 @@ const mockData = { summary: { eventCount: 3, totalEstimatedCashflow: 450, + actualCashflow: 0, + forecastEstimatedCashflow: 450, nearestEventDate: '2026-06-25', - currency: 'RUB', + dividendsTotal: 150, + couponsTotal: 36.9, + principalRepaymentTotal: 1000, + actualDividendsTotal: 0, + actualCouponsTotal: 0, + actualPrincipalRepaymentTotal: 0, }, items: [ { id: 'ev-1', type: 'dividend', + source: 'forecast', + category: 'cashflow', ticker: 'SBER', name: 'Сбер Банк', eventDate: '2026-06-25', + paymentDate: null, + instrumentUid: 'uid-sber', + instrumentType: 'share', + quantitySnapshot: 10, + payoutPerUnit: 15, estimatedAmount: 150, + actualAmount: null, currency: 'RUB' as const, + estimateMode: 'current_position', }, { id: 'ev-2', type: 'coupon', + source: 'forecast', + category: 'cashflow', ticker: 'SU26238RMFS5', name: 'ОФЗ 26238', eventDate: '2026-06-27', + paymentDate: null, + instrumentUid: 'uid-bond', + instrumentType: 'bond', + quantitySnapshot: 1, + payoutPerUnit: 36.9, estimatedAmount: 36.9, + actualAmount: null, currency: 'RUB' as const, + estimateMode: 'current_position', }, { id: 'ev-3', type: 'maturity', + source: 'actual', + category: 'cashflow', ticker: 'VTBR', name: 'ВТБ', eventDate: '2026-06-30', - estimatedAmount: 1000, + paymentDate: '2026-06-30', + instrumentUid: 'uid-vtbr', + instrumentType: 'bond', + quantitySnapshot: null, + payoutPerUnit: null, + estimatedAmount: null, + actualAmount: 1000, currency: 'RUB' as const, + estimateMode: null, }, ], }; @@ -81,6 +129,7 @@ const mockData = { describe('BrokerEventsPage', () => { beforeEach(() => { vi.clearAllMocks(); + currentSearchParams = new URLSearchParams(); }); it('renders loading state', () => { @@ -153,8 +202,15 @@ describe('BrokerEventsPage', () => { summary: { eventCount: 0, totalEstimatedCashflow: 0, + actualCashflow: 0, + forecastEstimatedCashflow: 0, nearestEventDate: null, - currency: 'RUB', + dividendsTotal: 0, + couponsTotal: 0, + principalRepaymentTotal: 0, + actualDividendsTotal: 0, + actualCouponsTotal: 0, + actualPrincipalRepaymentTotal: 0, }, items: [], }, @@ -182,8 +238,15 @@ describe('BrokerEventsPage', () => { summary: { eventCount: 0, totalEstimatedCashflow: 0, + actualCashflow: 0, + forecastEstimatedCashflow: 0, nearestEventDate: null, - currency: 'RUB', + dividendsTotal: 0, + couponsTotal: 0, + principalRepaymentTotal: 0, + actualDividendsTotal: 0, + actualCouponsTotal: 0, + actualPrincipalRepaymentTotal: 0, }, items: [], }), @@ -262,12 +325,65 @@ describe('BrokerEventsPage', () => { 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('Погашение')).toBeInTheDocument(); expect(screen.getByText('SBER')).toBeInTheDocument(); expect(screen.getByText('SU26238RMFS5')).toBeInTheDocument(); expect(screen.getByText('VTBR')).toBeInTheDocument(); + expect(screen.getByText('Прогноз выплат')).toBeInTheDocument(); + expect(screen.getAllByText('Поступило').length).toBeGreaterThan(0); + expect(screen.getByText('Факт')).toBeInTheDocument(); + expect(screen.getAllByText('Прогноз').length).toBeGreaterThan(0); + }); + + it('keeps date and type changes as draft until applying filters', async () => { + currentSearchParams = new URLSearchParams({ + from: '2026-06-15', + to: '2026-06-29', + types: 'dividend,coupon', + }); + 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() }); + + await userEvent.clear(screen.getByLabelText('С')); + await userEvent.type(screen.getByLabelText('С'), '2026-06-10'); + await userEvent.click(screen.getByLabelText('Купоны')); + + expect(mockSetSearchParams).not.toHaveBeenCalled(); + + await userEvent.click(screen.getByRole('button', { name: 'Показать' })); + + const applied = mockSetSearchParams.mock.calls[0][0] as URLSearchParams; + expect(applied.get('from')).toBe('2026-06-10'); + expect(applied.get('to')).toBe('2026-06-29'); + expect(applied.get('types')).toBe('dividend'); }); }); diff --git a/apps/frontend/src/pages/broker-events/ui/BrokerEventsPage.tsx b/apps/frontend/src/pages/broker-events/ui/BrokerEventsPage.tsx index aed5ec4..112d724 100644 --- a/apps/frontend/src/pages/broker-events/ui/BrokerEventsPage.tsx +++ b/apps/frontend/src/pages/broker-events/ui/BrokerEventsPage.tsx @@ -1,12 +1,29 @@ import { useState, useEffect } from 'react'; import { useSearchParams } from 'react-router-dom'; import { Box } from '@mui/material'; -import { Heading, Text, TextField } from '@moex-vibe/design-system'; +import { Button, Checkbox, Chip, Heading, Text, TextField } from '@moex-vibe/design-system'; import dayjs from 'dayjs'; import { useBrokerEvents } from '@/entities/broker-event'; import { useBrokerAccountContext } from '@/widgets/broker-account-layout'; import { formatBrokerCurrencyValue, formatBrokerDate } from '@/shared/lib/formatters'; +const EVENT_TYPES = ['dividend', 'coupon', 'maturity', 'offer'] as const; + +type EventType = (typeof EVENT_TYPES)[number]; + +type Filters = { + from: string; + to: string; + types: EventType[]; +}; + +const EVENT_TYPE_OPTIONS: { value: EventType; label: string }[] = [ + { value: 'dividend', label: 'Дивиденды' }, + { value: 'coupon', label: 'Купоны' }, + { value: 'maturity', label: 'Погашения' }, + { value: 'offer', label: 'Оферты' }, +]; + function eventTypeLabel(type: string): string { switch (type) { case 'dividend': @@ -25,29 +42,64 @@ function eventTypeLabel(type: string): string { function defaultPeriod(): { from: string; to: string } { const now = dayjs(); return { - from: now.format('YYYY-MM-DD'), + from: now.subtract(7, 'day').format('YYYY-MM-DD'), to: now.add(7, 'day').format('YYYY-MM-DD'), }; } +function parseTypes(value: string | null): EventType[] { + if (!value) return [...EVENT_TYPES]; + + const parsed = value + .split(',') + .map((type) => type.trim()) + .filter((type): type is EventType => EVENT_TYPES.includes(type as EventType)); + + return parsed.length > 0 ? parsed : [...EVENT_TYPES]; +} + +function filtersFromSearchParams(searchParams: URLSearchParams): Filters { + const def = defaultPeriod(); + const from = searchParams.get('from'); + const to = searchParams.get('to'); + + return { + from: from && dayjs(from).isValid() ? from : def.from, + to: to && dayjs(to).isValid() ? to : def.to, + types: parseTypes(searchParams.get('types')), + }; +} + +function filtersToSearchParams(filters: Filters): URLSearchParams { + const next = new URLSearchParams(); + next.set('from', filters.from); + next.set('to', filters.to); + next.set('types', filters.types.join(',')); + return next; +} + +function sourceLabel(source: string): string { + return source === 'actual' ? 'Факт' : 'Прогноз'; +} + export function BrokerEventsPage() { const { accountId } = useBrokerAccountContext(); const [searchParams, setSearchParams] = useSearchParams(); - const [initialized, setInitialized] = useState(false); + const [appliedFilters, setAppliedFilters] = useState(() => + filtersFromSearchParams(searchParams), + ); + const [draftFilters, setDraftFilters] = useState(() => + filtersFromSearchParams(searchParams), + ); useEffect(() => { - const from = searchParams.get('from'); - const to = searchParams.get('to'); - if (!from || !to || !dayjs(from).isValid() || !dayjs(to).isValid()) { - const def = defaultPeriod(); - setSearchParams({ from: def.from, to: def.to }, { replace: true }); - } - setInitialized(true); + const next = filtersFromSearchParams(searchParams); + setAppliedFilters(next); + setDraftFilters(next); }, []); // eslint-disable-line react-hooks/exhaustive-deps - const from = searchParams.get('from') ?? ''; - const to = searchParams.get('to') ?? ''; - const period = initialized ? { from, to } : defaultPeriod(); + const from = draftFilters.from; + const to = draftFilters.to; const validFrom = dayjs(from); const validTo = dayjs(to); @@ -55,11 +107,32 @@ export function BrokerEventsPage() { from && to && validFrom.isValid() && validTo.isValid() && validTo.isBefore(validFrom) ? '"По" не может быть раньше "С"' : ''; + const typeError = draftFilters.types.length === 0 ? 'Выберите хотя бы один тип события' : ''; + const filterError = dateError || typeError; - const events = useBrokerEvents(dateError ? undefined : accountId, period); + const events = useBrokerEvents(filterError ? undefined : accountId, { + from: appliedFilters.from, + to: appliedFilters.to, + types: appliedFilters.types.join(','), + }); const ev = events.data; + function toggleType(type: EventType, checked: boolean) { + setDraftFilters((current) => ({ + ...current, + types: checked + ? [...new Set([...current.types, type])] + : current.types.filter((t) => t !== type), + })); + } + + function applyFilters() { + if (filterError) return; + setAppliedFilters(draftFilters); + setSearchParams(filtersToSearchParams(draftFilters), { replace: true }); + } + return ( События - + { - const newParams = new URLSearchParams(searchParams); - newParams.set('from', e.target.value); - setSearchParams(newParams, { replace: true }); + setDraftFilters((current) => ({ ...current, from: e.target.value })); }} InputLabelProps={{ shrink: true }} /> @@ -85,14 +156,45 @@ export function BrokerEventsPage() { type="date" value={to} onChange={(e) => { - const newParams = new URLSearchParams(searchParams); - newParams.set('to', e.target.value); - setSearchParams(newParams, { replace: true }); + setDraftFilters((current) => ({ ...current, to: e.target.value })); }} InputLabelProps={{ shrink: true }} error={!!dateError} helperText={dateError} /> + + + Типы событий + + + {EVENT_TYPE_OPTIONS.map((option) => ( + toggleType(option.value, checked)} + error={!!typeError} + /> + ))} + + {typeError && ( + + {typeError} + + )} + + @@ -136,15 +238,23 @@ export function BrokerEventsPage() { - Денежный поток + Прогноз выплат - ~{formatBrokerCurrencyValue('RUB', ev.summary.totalEstimatedCashflow)} + ~{formatBrokerCurrencyValue('RUB', ev.summary.forecastEstimatedCashflow)} оценка* + + + Поступило + + + +{formatBrokerCurrencyValue('RUB', ev.summary.actualCashflow)} + + @@ -177,6 +287,19 @@ export function BrokerEventsPage() { > Тип + + Статус + {eventTypeLabel(item.type)} + + + - {item.estimatedAmount != null ? ( + {item.source === 'actual' && item.actualAmount != null ? ( + <> + + +{formatBrokerCurrencyValue(item.currency ?? 'RUB', item.actualAmount)} + + + Поступило + + + ) : item.estimatedAmount != null ? ( <> ~ diff --git a/apps/frontend/src/shared/api/responses.ts b/apps/frontend/src/shared/api/responses.ts index 909de13..539214a 100644 --- a/apps/frontend/src/shared/api/responses.ts +++ b/apps/frontend/src/shared/api/responses.ts @@ -354,6 +354,7 @@ export interface BrokerPositionsPage { export interface BrokerPortfolioEvent { id: string; type: 'dividend' | 'coupon' | 'maturity' | 'offer'; + source: 'forecast' | 'actual'; category: 'cashflow' | 'corporate'; eventDate: string; paymentDate: string | null; @@ -364,17 +365,23 @@ export interface BrokerPortfolioEvent { quantitySnapshot: number | null; payoutPerUnit: number | null; estimatedAmount: number | null; + actualAmount: number | null; currency: string | null; - estimateMode: 'current_position'; + estimateMode: 'current_position' | null; } export interface BrokerEventsSummary { eventCount: number; nearestEventDate: string | null; totalEstimatedCashflow: number; + actualCashflow: number; + forecastEstimatedCashflow: number; dividendsTotal: number; couponsTotal: number; principalRepaymentTotal: number; + actualDividendsTotal: number; + actualCouponsTotal: number; + actualPrincipalRepaymentTotal: number; } export interface BrokerEventsData { diff --git a/docs/features/broker-events-and-payouts/plan.md b/docs/features/broker-events-and-payouts/plan.md index 447e1b3..ab764d0 100644 --- a/docs/features/broker-events-and-payouts/plan.md +++ b/docs/features/broker-events-and-payouts/plan.md @@ -4,13 +4,14 @@ > (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use > checkbox (`- [ ]`) syntax for tracking. -**Goal:** Добавить в брокерский счёт T-Bank раздел предстоящих событий и ориентировочный прогноз -будущих выплат по выбранному периоду. +**Goal:** Добавить в брокерский счёт T-Bank раздел предстоящих и прошедших событий с прогнозом +будущих выплат, фактическими прошедшими поступлениями и управляемыми фильтрами. -**Architecture:** Backend добавляет отдельный read-only endpoint событий поверх уже существующих -данных T-Bank и MOEX. Слой агрегации строит best-effort список событий по текущим позициям счёта и -summary по будущим денежным потокам. Frontend расширяет shell брокерского счёта новой вкладкой -`События`, отдельной страницей и компактным overview-виджетом ближайших событий. +**Architecture:** Backend предоставляет единый read-only endpoint событий поверх существующих данных +T-Bank и MOEX. Слой агрегации объединяет прогнозные события по текущим позициям и фактические +прошедшие выплаты из операций счёта, затем строит раздельный summary факта и прогноза. Frontend +держит фильтры в черновике, применяет их только по кнопке `Показать` и визуально разделяет +`Факт`/`Прогноз`. **Tech Stack:** NestJS 10, Prisma, CacheService, MOEX client, T-Bank module, React 18, React Router 6, TanStack Query 5, TypeScript, Vitest, Testing Library. @@ -39,13 +40,13 @@ summary по будущим денежным потокам. Frontend расши Новый endpoint: ```text -GET /api/v1/broker/accounts/:accountId/events?from=YYYY-MM-DD&to=YYYY-MM-DD +GET /api/v1/broker/accounts/:accountId/events?from=YYYY-MM-DD&to=YYYY-MM-DD&types=dividend,coupon,maturity,offer ``` Endpoint возвращает: - `items` — плоский список событий по текущим позициям счёта; -- `summary` — агрегаты по денежным событиям за период; +- `summary` — агрегаты по фактическим и прогнозным денежным событиям за период; - `asOf` — момент построения read model. ### Backend read model @@ -56,7 +57,8 @@ Endpoint возвращает: - `BrokerPortfolioService` или внутренний shared-path для получения текущих позиций; - `BrokerInstrumentsService` для сопоставления T-Bank instrument metadata; - `MoexClientService` для дивидендов и bond enrichment; -- `CacheService` для кэширования результата по `accountId + from + to`. +- `BrokerOperationsService` для получения фактических прошедших выплат; +- `CacheService` для кэширования результата по `accountId + from + to + types`. Сервис не записывает события в Prisma и не вводит отдельные таблицы в первой версии. @@ -78,14 +80,26 @@ Endpoint возвращает: ### Семантика summary -`summary` агрегирует только денежные события: +`summary` агрегирует денежные события раздельно по источнику: - дивиденды; - купоны; - погашения. -Оферты остаются в общем списке событий, но не обязаны входить в сумму денежных потоков первой -версии. +Поля прогноза считаются только по `source: 'forecast'`, поля факта — только по `source: 'actual'`. +Оферты остаются в общем списке событий, но не входят в денежные итоги. + +### Фактические прошедшие события + +Для прошедшей части диапазона backend читает исполненные операции T-Bank: + +- `OPERATION_TYPE_DIVIDEND` и `OPERATION_TYPE_DIV_EXT` → `dividend`; +- `OPERATION_TYPE_COUPON` → `coupon`; +- `OPERATION_TYPE_BOND_REPAYMENT` и `OPERATION_TYPE_BOND_REPAYMENT_FULL` → `maturity`. + +Фактическая строка получает `source: 'actual'`, `actualAmount` из `operation.payment`, дату операции +как `eventDate`, `estimateMode: null`. Прогнозная строка получает `source: 'forecast'`, +`estimatedAmount`, `estimateMode: 'current_position'`. ### Частичная деградация @@ -105,10 +119,11 @@ Endpoint возвращает: type BrokerEventsQuery = { from: string; to: string; + types?: string; }; ``` -Обе даты обязательны в первой версии, чтобы не вводить неочевидные дефолты по периоду. +Обе даты обязательны. `types` опционален: если параметр отсутствует, backend использует все типы. ### Response contract @@ -116,6 +131,7 @@ type BrokerEventsQuery = { type BrokerPortfolioEvent = { id: string; type: 'dividend' | 'coupon' | 'maturity' | 'offer'; + source: 'forecast' | 'actual'; category: 'cashflow' | 'corporate'; eventDate: string; paymentDate: string | null; @@ -126,17 +142,23 @@ type BrokerPortfolioEvent = { quantitySnapshot: number | null; payoutPerUnit: number | null; estimatedAmount: number | null; + actualAmount: number | null; currency: string | null; - estimateMode: 'current_position'; + estimateMode: 'current_position' | null; }; type BrokerEventsSummary = { eventCount: number; nearestEventDate: string | null; totalEstimatedCashflow: number; + actualCashflow: number; + forecastEstimatedCashflow: number; dividendsTotal: number; couponsTotal: number; principalRepaymentTotal: number; + actualDividendsTotal: number; + actualCouponsTotal: number; + actualPrincipalRepaymentTotal: number; }; ``` @@ -210,12 +232,41 @@ Overview получает компактный блок ближайших со ### Frontend date range UI -Даты хранятся в URL-параметрах `from` / `to` для возможности поделиться ссылкой. На странице — два `TextField[type=date]` из дизайн-системы. dayjs для форматирования, валидации и дефолтов. +Даты и типы хранятся в URL-параметрах `from` / `to` / `types` для возможности поделиться ссылкой. +На странице — два date input из дизайн-системы и multi-select типов событий. dayjs используется для +форматирования, валидации и дефолтов. Поток данных: ``` -useSearchParams → from/to → useBrokerEvents(query) → TanStack Query (автоrefetch) +useSearchParams → applied filters → useBrokerEvents(query) → TanStack Query +local draft state → button "Показать" → setSearchParams(applied filters) ``` -Дефолт при первом визите без параметров: today – today+7d. Валидация: to >= from. При невалидных датах — показ ошибки под полем, запрос не выполняется. +Дефолт при первом визите без параметров: today-7d – today+7d и все типы событий. Валидация: to >= +from, выбран хотя бы один тип. При невалидных фильтрах кнопка `Показать` disabled или показывает +ошибку, запрос не выполняется. Изменение полей не запускает запрос до применения. + +## Follow-up implementation tasks + +### Backend + +1. Расширить `BrokerEventsQueryDto` параметром `types` с валидацией comma-separated значений. +2. Расширить `BrokerPortfolioEvent` и Swagger DTO полями `source`, `actualAmount`, nullable + `estimateMode`. +3. Расширить `BrokerEventsSummary` раздельными полями факта и прогноза. +4. Добавить в `BrokerEventsService` фильтрацию типов и cache key с `types`. +5. Инжектировать `BrokerOperationsService` в `BrokerEventsService` и строить actual events по + исполненным операциям для прошедшей части диапазона. +6. Исключить дубли forecast/actual для прошедших cashflow-событий по ключу `type + ticker + date`. +7. Обновить backend unit/controller tests на query `types`, actual events и summary. + +### Frontend + +1. Расширить handwritten response types и `BrokerEventsQuery` параметром `types`. +2. Обновить `useBrokerEvents` query key с учётом `types`. +3. Переделать `BrokerEventsPage` на applied filters + draft filters + кнопку `Показать`. +4. Добавить multi-select типов событий по дизайн-системе/текущим UI-паттернам проекта. +5. Отобразить `Факт`/`Прогноз`, `Поступило`, зелёное выделение actual-сумм и раздельный summary. +6. Обновить frontend tests на отсутствие запроса при черновом изменении фильтров, применение по + кнопке, multi-select типов и actual event styling. diff --git a/docs/features/broker-events-and-payouts/spec.md b/docs/features/broker-events-and-payouts/spec.md index 8fa7256..9cff4bf 100644 --- a/docs/features/broker-events-and-payouts/spec.md +++ b/docs/features/broker-events-and-payouts/spec.md @@ -1,13 +1,14 @@ # Календарь событий и прогноз будущих выплат брокерского счёта Дата: 2026-06-21 -Статус: согласовано к планированию +Статус: реализовано; доработка UX и смешанного календаря согласована к реализации Эпик: [Портфель брокера](../../epics/BrokerPortfolio.md) ## Цель Дать пользователю брокерского счёта T-Bank отдельный раздел, где можно увидеть будущие события по -бумагам счёта и ориентировочный денежный поток по выбранному диапазону дат. +бумагам счёта, фактически прошедшие выплаты и ориентировочный денежный поток по выбранному +диапазону дат. ## Пользовательский результат @@ -18,6 +19,8 @@ - выбрать период, например с `2026-06-22` по `2026-07-29`; - получить список дивидендов, купонов, погашений и оферт, попадающих в этот период; - увидеть ориентировочную сумму будущих выплат по выбранному диапазону; +- увидеть фактически поступившие прошедшие выплаты из операций брокерского счёта; +- выбрать несколько типов событий, которые нужно показать; - понимать, какие значения являются оценкой по текущим позициям, а не подтверждённым правом на выплату. @@ -28,7 +31,9 @@ - новый блок ближайших событий на overview счёта; - новую вкладку `События` в навигации брокерского счёта; - фильтр диапазона дат; +- фильтр нескольких типов событий; - список событий по текущим позициям счёта; +- список прошедших фактических выплат по операциям счёта; - агрегированный summary по будущим выплатам за выбранный период. ## Требования @@ -76,6 +81,19 @@ - Границы диапазона включительные. - Если `eventDate` не попадает в диапазон, событие не показывается. - В первой версии отдельный режим фильтрации по `paymentDate` отсутствует. +- При первом открытии вкладки без параметров период по умолчанию равен `сегодня - 7 дней` / `сегодня + + 7 дней`. +- Изменение дат в интерфейсе не запускает запрос автоматически: пользователь редактирует черновик + фильтров и применяет его кнопкой `Показать`. + +### 4.1. Фильтр типов событий + +- Пользователь может выбрать несколько типов событий через multi-select: `dividend`, `coupon`, + `maturity`, `offer`. +- При первом открытии включены все типы событий. +- После применения фильтра выбранные типы сохраняются в URL в параметре `types`. +- URL отражает только применённые фильтры, а не черновые значения в полях. +- Если пользователь снимает все типы, запрос не выполняется, а UI показывает валидационное сообщение. ### 5. Источники данных @@ -119,6 +137,22 @@ - В первой версии оферта считается информационным событием. - Для оферты не требуется обязательная денежная оценка. +### 6.1. Фактические прошедшие события + +- Для прошедшей части выбранного диапазона календарь добавляет фактические события из операций + T-Bank. +- Фактические события строятся только по исполненным операциям счёта. +- В фактические события входят дивиденды, купоны и погашения облигаций. +- Фактические события имеют источник `actual`, не являются оценкой и используют фактическую сумму + операции. +- Фактические поступления визуально выделяются зелёным как уже поступившие деньги. +- Будущие события имеют источник `forecast`, строятся по текущим позициям и сохраняют признак оценки + `current_position`. +- Оферты остаются прогнозными событиями, если доступны по данным облигаций; фактическая оферта из + операций в этой доработке не строится. +- Если одно и то же событие доступно как факт и как прогноз за прошедшую дату, UI должен отдавать + приоритет факту, чтобы не показывать пользователю дубль одного поступления. + ### 7. Overview счёта - Overview показывает ближайшие 3-5 событий выбранного счёта. @@ -128,17 +162,23 @@ ### 8. Вкладка `События` -- Вкладка содержит фильтр периода, summary и список событий. +- Вкладка содержит фильтр периода, multi-select типов событий, summary и список событий. +- Фильтры используют компоненты и визуальные паттерны дизайн-системы. +- Изменение фильтров не запускает запрос до нажатия кнопки `Показать`. +- Кнопка `Показать` применяет фильтры, обновляет URL и запускает загрузку данных. - Список событий показывает: - дату события; - тип события; + - источник события (`Факт` или `Прогноз`); - инструмент; - тип инструмента; - количество бумаг, использованное для расчёта; - выплату на единицу при наличии; - итоговую ориентировочную сумму при наличии; + - фактическую сумму поступления при наличии; - валюту при наличии. - Для денежных оценок UI показывает признак `estimate`. +- Для фактических поступлений UI показывает признак `Поступило` и зелёное выделение суммы или статуса. ### 9. Summary по периоду @@ -146,12 +186,13 @@ Summary по выбранному периоду показывает: - количество событий; - ближайшую дату события; -- общий ориентировочный денежный поток; +- общий ориентировочный денежный поток по прогнозам; +- общий фактический денежный поток по прошедшим поступлениям; - сумму дивидендов; - сумму купонов; - сумму погашений. -Оферты не обязаны входить в денежный итог первой версии. +Оферты не обязаны входить в денежные итоги. ### 10. Ошибки, пустые состояния и частичная деградация @@ -174,9 +215,16 @@ Summary по выбранному периоду показывает: - На overview брокерского счёта отображается блок ближайших событий. - В навигации брокерского счёта есть вкладка `События`. - Пользователь может задать диапазон дат. +- По умолчанию вкладка открывает период `сегодня - 7 дней` / `сегодня + 7 дней`. +- Изменение дат или типов событий не запускает запрос до нажатия `Показать`. +- Пользователь может выбрать несколько типов событий через multi-select. +- Выбранные применённые фильтры восстанавливаются из URL. - Вкладка показывает только события, дата которых попадает в выбранный диапазон. - Пользователь видит дивиденды, купоны, погашения и оферты, если они доступны по текущим позициям. +- Пользователь видит фактические прошедшие дивиденды, купоны и погашения из операций счёта. +- Фактические прошедшие поступления помечены как `Поступило` и визуально выделены зелёным. - Summary показывает агрегированный прогноз будущих выплат по диапазону. +- Summary отдельно показывает фактические поступления и прогноз выплат. - Все денежные суммы явно обозначены как оценочные. - Пустой диапазон отображается как отдельное пустое состояние. - Ошибка по одному инструменту не ломает весь ответ. diff --git a/docs/features/broker-events-and-payouts/tasks.md b/docs/features/broker-events-and-payouts/tasks.md index 8bb904c..99cec84 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) +Статус: реализовано (1-я версия, read-only, T-Bank); доработка UX и смешанного календаря в работе Связанные документы: @@ -86,3 +86,23 @@ - [ ] Прогнать docs build, если менялась опубликованная документация. (Deferred: docs не менялись.) - [x] Отметить roadmap и связанные SDD-статусы. + +## 10. Follow-up: UX фильтров и прошедшие события + +- [x] Согласовать UX-дизайн доработки с пользователем. +- [x] Создать feature branch `codex/broker-events-calendar-ux`. +- [x] Обновить `spec.md` под смешанный календарь факта и прогноза. +- [x] Обновить `plan.md` под backend/frontend реализацию доработки. +- [x] Прогнать baseline tests перед реализацией. +- [x] Расширить backend query параметром `types`. +- [x] Расширить backend response полями `source`, `actualAmount`, nullable `estimateMode` и раздельным + summary. +- [x] Добавить фактические прошедшие события из операций T-Bank. +- [x] Добавить фильтрацию нескольких типов событий. +- [x] Покрыть backend tests для `types`, actual events, summary и controller forwarding. +- [x] Обновить frontend response/query types. +- [x] Перевести страницу `События` на applied filters + draft filters + кнопку `Показать`. +- [x] Добавить multi-select типов событий. +- [x] Отобразить `Факт`/`Прогноз`, `Поступило` и зелёное выделение фактических выплат. +- [x] Покрыть frontend tests для нового UX и actual events. +- [x] Прогнать финальные проверки затронутых backend/frontend пакетов.