feat: add actual payouts and filter-as-draft UX to broker events calendar
Some checks failed
CI / ci (pull_request) Failing after 3m9s
CI / ci (push) Failing after 3m9s

- 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
This commit is contained in:
Sergey Krylov 2026-06-22 21:48:03 +03:00
parent c71ba090ad
commit f7dc338719
16 changed files with 887 additions and 103 deletions

View File

@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Matches } from 'class-validator'; import { IsOptional, Matches } from 'class-validator';
export class BrokerEventsQueryDto { export class BrokerEventsQueryDto {
@ApiProperty({ example: '2026-06-22', description: 'Start date inclusive (YYYY-MM-DD)' }) @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)' }) @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' }) @Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'to must be YYYY-MM-DD' })
to!: string; 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;
} }

View File

@ -1,6 +1,7 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
const eventTypes = ['dividend', 'coupon', 'maturity', 'offer'] as const; const eventTypes = ['dividend', 'coupon', 'maturity', 'offer'] as const;
const eventSources = ['forecast', 'actual'] as const;
const eventCategories = ['cashflow', 'corporate'] as const; const eventCategories = ['cashflow', 'corporate'] as const;
const instrumentTypes = ['share', 'bond', 'other'] as const; const instrumentTypes = ['share', 'bond', 'other'] as const;
@ -11,6 +12,9 @@ export class BrokerEventItemDto {
@ApiProperty({ enum: eventTypes }) @ApiProperty({ enum: eventTypes })
type!: string; type!: string;
@ApiProperty({ enum: eventSources })
source!: string;
@ApiProperty({ enum: eventCategories }) @ApiProperty({ enum: eventCategories })
category!: string; category!: string;
@ -41,11 +45,14 @@ export class BrokerEventItemDto {
@ApiProperty({ nullable: true }) @ApiProperty({ nullable: true })
estimatedAmount!: number | null; estimatedAmount!: number | null;
@ApiProperty({ nullable: true })
actualAmount!: number | null;
@ApiProperty({ nullable: true }) @ApiProperty({ nullable: true })
currency!: string | null; currency!: string | null;
@ApiProperty() @ApiProperty({ nullable: true })
estimateMode!: 'current_position'; estimateMode!: 'current_position' | null;
} }
export class BrokerEventsSummaryDto { export class BrokerEventsSummaryDto {
@ -58,6 +65,12 @@ export class BrokerEventsSummaryDto {
@ApiProperty() @ApiProperty()
totalEstimatedCashflow!: number; totalEstimatedCashflow!: number;
@ApiProperty()
actualCashflow!: number;
@ApiProperty()
forecastEstimatedCashflow!: number;
@ApiProperty() @ApiProperty()
dividendsTotal!: number; dividendsTotal!: number;
@ -66,6 +79,15 @@ export class BrokerEventsSummaryDto {
@ApiProperty() @ApiProperty()
principalRepaymentTotal!: number; principalRepaymentTotal!: number;
@ApiProperty()
actualDividendsTotal!: number;
@ApiProperty()
actualCouponsTotal!: number;
@ApiProperty()
actualPrincipalRepaymentTotal!: number;
} }
export class BrokerEventsDataDto { export class BrokerEventsDataDto {

View File

@ -3,6 +3,7 @@ import { CacheService } from '../../cache/cache.service';
import { MoexClientService } from '../../moex-client/moex-client.service'; import { MoexClientService } from '../../moex-client/moex-client.service';
import { BrokerAccountsService } from './broker-accounts.service'; import { BrokerAccountsService } from './broker-accounts.service';
import { BrokerEventsService } from './broker-events.service'; import { BrokerEventsService } from './broker-events.service';
import { BrokerOperationsService } from './broker-operations.service';
import { BrokerPortfolioService } from './broker-portfolio.service'; import { BrokerPortfolioService } from './broker-portfolio.service';
describe('BrokerEventsService', () => { describe('BrokerEventsService', () => {
@ -12,6 +13,7 @@ describe('BrokerEventsService', () => {
getDividends: vi.fn(), getDividends: vi.fn(),
getBondPositionDataBatch: vi.fn(), getBondPositionDataBatch: vi.fn(),
} as unknown as MoexClientService; } as unknown as MoexClientService;
const operations = { getOperations: vi.fn() } as unknown as BrokerOperationsService;
const cache = { getOrFetch: vi.fn() } as unknown as CacheService; const cache = { getOrFetch: vi.fn() } as unknown as CacheService;
const acc1 = { const acc1 = {
@ -40,10 +42,10 @@ describe('BrokerEventsService', () => {
it('throws 404 for missing account', async () => { it('throws 404 for missing account', async () => {
vi.mocked(accounts.findById).mockResolvedValue(null); vi.mocked(accounts.findById).mockResolvedValue(null);
const service = new BrokerEventsService(accounts, portfolio, moex, cache); const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
await expect(service.getEvents('missing', '2026-06-01', '2026-07-01')).rejects.toThrow( await expect(
NotFoundException, service.getEvents('missing', { from: '2026-06-01', to: '2026-07-01' }),
); ).rejects.toThrow(NotFoundException);
}); });
it('returns empty events for account with no positions', async () => { it('returns empty events for account with no positions', async () => {
@ -54,13 +56,20 @@ describe('BrokerEventsService', () => {
instruments: new Map(), instruments: new Map(),
}); });
const service = new BrokerEventsService(accounts, portfolio, moex, cache); vi.mocked(operations.getOperations).mockResolvedValue({
const result = await service.getEvents('acc-1', '2026-06-01', '2026-07-01'); 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.items).toEqual([]);
expect(result.data.summary.eventCount).toBe(0); expect(result.data.summary.eventCount).toBe(0);
expect(result.data.summary.nearestEventDate).toBeNull(); expect(result.data.summary.nearestEventDate).toBeNull();
expect(result.data.summary.totalEstimatedCashflow).toBe(0); 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 () => { 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); vi.mocked(operations.getOperations).mockResolvedValue({
const result = await service.getEvents('acc-1', '2026-06-20', '2026-07-10'); 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).toHaveLength(1);
expect(result.data.items[0].type).toBe('dividend'); expect(result.data.items[0].type).toBe('dividend');
expect(result.data.items[0].eventDate).toBe('2026-06-25'); expect(result.data.items[0].eventDate).toBe('2026-06-25');
expect(result.data.items[0].estimatedAmount).toBe(335); 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].currency).toBe('RUB');
expect(result.data.items[0].name).toBe('Sberbank'); expect(result.data.items[0].name).toBe('Sberbank');
}); });
@ -148,8 +164,13 @@ describe('BrokerEventsService', () => {
}, },
]); ]);
const service = new BrokerEventsService(accounts, portfolio, moex, cache); vi.mocked(operations.getOperations).mockResolvedValue({
const result = await service.getEvents('acc-1', '2026-06-20', '2026-07-10'); 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); expect(result.data.items).toHaveLength(3);
const coupon = result.data.items.find((e) => e.type === 'coupon')!; 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' }, { secid: 'GOOD', isin: 'RU', registryCloseDate: '2026-06-25', value: 20, currencyId: 'RUB' },
]); ]);
const service = new BrokerEventsService(accounts, portfolio, moex, cache); vi.mocked(operations.getOperations).mockResolvedValue({
const result = await service.getEvents('acc-1', '2026-06-20', '2026-07-10'); 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).toHaveLength(1);
expect(result.data.items[0].ticker).toBe('GOOD'); 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' }, { secid: 'NO_AMT', isin: 'RU', registryCloseDate: '2026-06-25', value: 0, currencyId: 'RUB' },
]); ]);
const service = new BrokerEventsService(accounts, portfolio, moex, cache); vi.mocked(operations.getOperations).mockResolvedValue({
const result = await service.getEvents('acc-1', '2026-06-20', '2026-07-10'); 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).toHaveLength(1);
expect(result.data.items[0].estimatedAmount).toBe(0); expect(result.data.items[0].estimatedAmount).toBe(0);
@ -268,8 +299,13 @@ describe('BrokerEventsService', () => {
}, },
]); ]);
const service = new BrokerEventsService(accounts, portfolio, moex, cache); vi.mocked(operations.getOperations).mockResolvedValue({
const result = await service.getEvents('acc-1', '2026-06-20', '2026-07-10'); 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.eventCount).toBe(3);
expect(result.data.summary.nearestEventDate).toBe('2026-06-25'); 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.couponsTotal).toBe(100);
expect(result.data.summary.principalRepaymentTotal).toBe(2000); expect(result.data.summary.principalRepaymentTotal).toBe(2000);
expect(result.data.summary.totalEstimatedCashflow).toBe(2400); 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 () => { 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' }, { secid: 'SBER', isin: 'RU', registryCloseDate: '2026-07-30', value: 10, currencyId: 'RUB' },
]); ]);
const service = new BrokerEventsService(accounts, portfolio, moex, cache); vi.mocked(operations.getOperations).mockResolvedValue({
const result = await service.getEvents('acc-1', '2026-06-20', '2026-07-29'); 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).toHaveLength(2);
expect(result.data.items[0].eventDate).toBe('2026-06-20'); expect(result.data.items[0].eventDate).toBe('2026-06-20');
expect(result.data.items[1].eventDate).toBe('2026-07-29'); 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);
});
}); });

View File

@ -4,27 +4,54 @@ import { MoexClientService } from '../../moex-client/moex-client.service';
import { TBANK_CACHE_KEYS } from '../tbank.config'; import { TBANK_CACHE_KEYS } from '../tbank.config';
import { mapQuotationToNumber } from '../mappers/money.mapper'; import { mapQuotationToNumber } from '../mappers/money.mapper';
import type { import type {
BrokerOperation,
BrokerPortfolioEvent, BrokerPortfolioEvent,
BrokerEventsData, BrokerEventsData,
BrokerEventsSummary, BrokerEventsSummary,
} from '../types/broker.types'; } from '../types/broker.types';
import type { TBankInstrument } from '../types/tbank-proto.types'; import type { TBankInstrument } from '../types/tbank-proto.types';
import { BrokerAccountsService } from './broker-accounts.service'; import { BrokerAccountsService } from './broker-accounts.service';
import { BrokerOperationsService } from './broker-operations.service';
import { BrokerPortfolioService } from './broker-portfolio.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<Exclude<BrokerEventType, 'offer'>, 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<string, Exclude<BrokerEventType, 'offer'>>(
Object.entries(ACTUAL_OPERATION_TYPES).flatMap(([eventType, operationTypes]) =>
operationTypes.map((operationType) => [
operationType,
eventType as Exclude<BrokerEventType, 'offer'>,
]),
),
);
@Injectable() @Injectable()
export class BrokerEventsService { export class BrokerEventsService {
constructor( constructor(
private readonly accountsService: BrokerAccountsService, private readonly accountsService: BrokerAccountsService,
private readonly portfolioService: BrokerPortfolioService, private readonly portfolioService: BrokerPortfolioService,
private readonly moexClient: MoexClientService, private readonly moexClient: MoexClientService,
private readonly operationsService: BrokerOperationsService,
private readonly cacheService: CacheService, private readonly cacheService: CacheService,
) {} ) {}
async getEvents( async getEvents(
accountId: string, accountId: string,
from: string, query: BrokerEventsQuery,
to: string,
): Promise<{ ): Promise<{
data: BrokerEventsData; data: BrokerEventsData;
meta: { fromCache: boolean; cachedAt: string | null }; meta: { fromCache: boolean; cachedAt: string | null };
@ -32,10 +59,12 @@ export class BrokerEventsService {
const account = await this.accountsService.findById(accountId); const account = await this.accountsService.findById(accountId);
if (!account) throw new NotFoundException('Broker account not found'); 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( const result = await this.cacheService.getOrFetch(
TBANK_CACHE_KEYS.events, TBANK_CACHE_KEYS.events,
[accountId, from, to], [accountId, query.from, query.to, eventTypeKey],
() => this.buildEvents(accountId, from, to), () => this.buildEvents(accountId, query.from, query.to, eventTypes),
'tbankPortfolioTtl', 'tbankPortfolioTtl',
); );
@ -49,6 +78,7 @@ export class BrokerEventsService {
accountId: string, accountId: string,
from: string, from: string,
to: string, to: string,
eventTypes: Set<BrokerEventType>,
): Promise<BrokerEventsData> { ): Promise<BrokerEventsData> {
const { positions, instruments } = const { positions, instruments } =
await this.portfolioService.getPositionsWithInstruments(accountId); await this.portfolioService.getPositionsWithInstruments(accountId);
@ -59,22 +89,37 @@ export class BrokerEventsService {
const bondPositions = positions.filter((p) => p.instrumentType?.toLowerCase() === 'bond'); const bondPositions = positions.filter((p) => p.instrumentType?.toLowerCase() === 'bond');
const shareResults = await Promise.allSettled( 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) { for (const r of shareResults) {
if (r.status === 'fulfilled') items.push(...r.value); if (r.status === 'fulfilled') items.push(...r.value);
} }
if (bondPositions.length > 0) { 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.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( private async buildShareEvents(
@ -114,6 +159,7 @@ export class BrokerEventsService {
events.push({ events.push({
id: `div-${ticker}-${d.registryCloseDate}`, id: `div-${ticker}-${d.registryCloseDate}`,
type: 'dividend', type: 'dividend',
source: 'forecast',
category: 'cashflow', category: 'cashflow',
eventDate: d.registryCloseDate, eventDate: d.registryCloseDate,
paymentDate: null, paymentDate: null,
@ -124,6 +170,7 @@ export class BrokerEventsService {
quantitySnapshot: quantity, quantitySnapshot: quantity,
payoutPerUnit, payoutPerUnit,
estimatedAmount, estimatedAmount,
actualAmount: null,
currency: d.currencyId, currency: d.currencyId,
estimateMode: 'current_position', estimateMode: 'current_position',
}); });
@ -142,6 +189,7 @@ export class BrokerEventsService {
instruments: Map<string, Partial<TBankInstrument>>, instruments: Map<string, Partial<TBankInstrument>>,
from: string, from: string,
to: string, to: string,
eventTypes: Set<BrokerEventType>,
): Promise<BrokerPortfolioEvent[]> { ): Promise<BrokerPortfolioEvent[]> {
const secids = bondPositions.map((p) => p.ticker).filter((t): t is string => Boolean(t)); const secids = bondPositions.map((p) => p.ticker).filter((t): t is string => Boolean(t));
if (secids.length === 0) return []; if (secids.length === 0) return [];
@ -174,7 +222,12 @@ export class BrokerEventsService {
const name = instrument?.name || null; const name = instrument?.name || null;
const currency = instrument?.currency || 'RUB'; 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 payoutPerUnit = bond.couponValue;
const estimatedAmount = const estimatedAmount =
quantity !== null && payoutPerUnit !== null ? quantity * payoutPerUnit : null; quantity !== null && payoutPerUnit !== null ? quantity * payoutPerUnit : null;
@ -182,6 +235,7 @@ export class BrokerEventsService {
events.push({ events.push({
id: `coupon-${ticker}-${bond.nextCouponDate}`, id: `coupon-${ticker}-${bond.nextCouponDate}`,
type: 'coupon', type: 'coupon',
source: 'forecast',
category: 'cashflow', category: 'cashflow',
eventDate: bond.nextCouponDate, eventDate: bond.nextCouponDate,
paymentDate: null, paymentDate: null,
@ -192,18 +246,25 @@ export class BrokerEventsService {
quantitySnapshot: quantity, quantitySnapshot: quantity,
payoutPerUnit, payoutPerUnit,
estimatedAmount, estimatedAmount,
actualAmount: null,
currency, currency,
estimateMode: 'current_position', 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 payoutPerUnit = bond.faceValue;
const estimatedAmount = quantity !== null ? quantity * payoutPerUnit : null; const estimatedAmount = quantity !== null ? quantity * payoutPerUnit : null;
events.push({ events.push({
id: `maturity-${ticker}-${bond.matDate}`, id: `maturity-${ticker}-${bond.matDate}`,
type: 'maturity', type: 'maturity',
source: 'forecast',
category: 'cashflow', category: 'cashflow',
eventDate: bond.matDate, eventDate: bond.matDate,
paymentDate: null, paymentDate: null,
@ -214,15 +275,22 @@ export class BrokerEventsService {
quantitySnapshot: quantity, quantitySnapshot: quantity,
payoutPerUnit, payoutPerUnit,
estimatedAmount, estimatedAmount,
actualAmount: null,
currency, currency,
estimateMode: 'current_position', 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({ events.push({
id: `offer-${ticker}-${bond.offerDate}`, id: `offer-${ticker}-${bond.offerDate}`,
type: 'offer', type: 'offer',
source: 'forecast',
category: 'corporate', category: 'corporate',
eventDate: bond.offerDate, eventDate: bond.offerDate,
paymentDate: null, paymentDate: null,
@ -233,6 +301,7 @@ export class BrokerEventsService {
quantitySnapshot: quantity, quantitySnapshot: quantity,
payoutPerUnit: null, payoutPerUnit: null,
estimatedAmount: null, estimatedAmount: null,
actualAmount: null,
currency, currency,
estimateMode: 'current_position', estimateMode: 'current_position',
}); });
@ -244,20 +313,119 @@ export class BrokerEventsService {
private buildSummary(items: BrokerPortfolioEvent[]): BrokerEventsSummary { private buildSummary(items: BrokerPortfolioEvent[]): BrokerEventsSummary {
const cashflowEvents = items.filter((e) => e.category === 'cashflow'); 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 { return {
eventCount: items.length, eventCount: items.length,
nearestEventDate: items.length > 0 ? items[0].eventDate : null, nearestEventDate: items.length > 0 ? items[0].eventDate : null,
totalEstimatedCashflow: cashflowEvents.reduce((sum, e) => sum + (e.estimatedAmount ?? 0), 0), totalEstimatedCashflow: forecastEvents.reduce((sum, e) => sum + (e.estimatedAmount ?? 0), 0),
dividendsTotal: cashflowEvents 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') .filter((e) => e.type === 'dividend')
.reduce((sum, e) => sum + (e.estimatedAmount ?? 0), 0), .reduce((sum, e) => sum + (e.estimatedAmount ?? 0), 0),
couponsTotal: cashflowEvents couponsTotal: forecastEvents
.filter((e) => e.type === 'coupon') .filter((e) => e.type === 'coupon')
.reduce((sum, e) => sum + (e.estimatedAmount ?? 0), 0), .reduce((sum, e) => sum + (e.estimatedAmount ?? 0), 0),
principalRepaymentTotal: cashflowEvents principalRepaymentTotal: forecastEvents
.filter((e) => e.type === 'maturity') .filter((e) => e.type === 'maturity')
.reduce((sum, e) => sum + (e.estimatedAmount ?? 0), 0), .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<BrokerEventType>,
): Promise<BrokerPortfolioEvent[]> {
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<BrokerEventType, 'offer'>,
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<BrokerEventType> {
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<BrokerEventType>): 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}`;
}
} }

View File

@ -68,9 +68,14 @@ describe('TBankController', () => {
eventCount: 0, eventCount: 0,
nearestEventDate: null, nearestEventDate: null,
totalEstimatedCashflow: 0, totalEstimatedCashflow: 0,
actualCashflow: 0,
forecastEstimatedCashflow: 0,
dividendsTotal: 0, dividendsTotal: 0,
couponsTotal: 0, couponsTotal: 0,
principalRepaymentTotal: 0, principalRepaymentTotal: 0,
actualDividendsTotal: 0,
actualCouponsTotal: 0,
actualPrincipalRepaymentTotal: 0,
}, },
asOf: '2026-06-22T00:00:00.000Z', asOf: '2026-06-22T00:00:00.000Z',
}; };
@ -80,9 +85,10 @@ describe('TBankController', () => {
}); });
const controller = new TBankController(accounts, portfolio, events, operations, sync); 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).toBeInstanceOf(ApiResponse);
expect(response.data).toEqual(eventsData); expect(response.data).toEqual(eventsData);
}); });

View File

@ -77,10 +77,10 @@ export class TBankController {
} }
@Get('accounts/:accountId/events') @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 }) @ApiOkResponse({ type: BrokerEventsEnvelopeDto })
async getEvents(@Param('accountId') accountId: string, @Query() query: BrokerEventsQueryDto) { 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); return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
} }

View File

@ -106,6 +106,7 @@ export type BrokerOperationsPage = {
export type BrokerPortfolioEvent = { export type BrokerPortfolioEvent = {
id: string; id: string;
type: 'dividend' | 'coupon' | 'maturity' | 'offer'; type: 'dividend' | 'coupon' | 'maturity' | 'offer';
source: 'forecast' | 'actual';
category: 'cashflow' | 'corporate'; category: 'cashflow' | 'corporate';
eventDate: string; eventDate: string;
paymentDate: string | null; paymentDate: string | null;
@ -116,17 +117,23 @@ export type BrokerPortfolioEvent = {
quantitySnapshot: number | null; quantitySnapshot: number | null;
payoutPerUnit: number | null; payoutPerUnit: number | null;
estimatedAmount: number | null; estimatedAmount: number | null;
actualAmount: number | null;
currency: string | null; currency: string | null;
estimateMode: 'current_position'; estimateMode: 'current_position' | null;
}; };
export type BrokerEventsSummary = { export type BrokerEventsSummary = {
eventCount: number; eventCount: number;
nearestEventDate: string | null; nearestEventDate: string | null;
totalEstimatedCashflow: number; totalEstimatedCashflow: number;
actualCashflow: number;
forecastEstimatedCashflow: number;
dividendsTotal: number; dividendsTotal: number;
couponsTotal: number; couponsTotal: number;
principalRepaymentTotal: number; principalRepaymentTotal: number;
actualDividendsTotal: number;
actualCouponsTotal: number;
actualPrincipalRepaymentTotal: number;
}; };
export type BrokerEventsData = { export type BrokerEventsData = {

View File

@ -4,6 +4,7 @@ import type { ApiResponseMeta, BrokerEventsData } from '@/shared/api/responses';
export type BrokerEventsQuery = { export type BrokerEventsQuery = {
from: string; from: string;
to: string; to: string;
types?: string;
}; };
export function getBrokerEvents( export function getBrokerEvents(
@ -15,6 +16,7 @@ export function getBrokerEvents(
{ {
from: query.from, from: query.from,
to: query.to, to: query.to,
types: query.types,
}, },
); );
} }

View File

@ -81,13 +81,16 @@ describe('useBrokerEvents', () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
queryClient.setQueryData( 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, mockEventsData,
); );
const { result } = renderHook(() => useBrokerEvents('acc-1', query), { const { result } = renderHook(
wrapper: createWrapper(queryClient), () => useBrokerEvents('acc-1', { ...query, types: 'dividend,coupon' }),
}); {
wrapper: createWrapper(queryClient),
},
);
await waitFor(() => expect(result.current.data).toBe(mockEventsData)); await waitFor(() => expect(result.current.data).toBe(mockEventsData));
expect(getBrokerEvents).not.toHaveBeenCalled(); expect(getBrokerEvents).not.toHaveBeenCalled();

View File

@ -3,11 +3,11 @@ import type { BrokerEventsData } from '@/shared/api/responses';
import { getBrokerEvents, type BrokerEventsQuery } from '../api/brokerEventApi'; import { getBrokerEvents, type BrokerEventsQuery } from '../api/brokerEventApi';
export function useBrokerEvents(accountId: string | undefined, query: BrokerEventsQuery) { export function useBrokerEvents(accountId: string | undefined, query: BrokerEventsQuery) {
const { from, to } = query; const { from, to, types } = query;
return useQuery<BrokerEventsData>({ return useQuery<BrokerEventsData>({
queryKey: ['broker', 'events', accountId, from, to], queryKey: ['broker', 'events', accountId, from, to, types],
enabled: Boolean(accountId), enabled: Boolean(accountId),
queryFn: async () => (await getBrokerEvents(accountId!, { from, to })).data, queryFn: async () => (await getBrokerEvents(accountId!, { from, to, types })).data,
staleTime: 300_000, staleTime: 300_000,
retry: 2, retry: 2,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,

View File

@ -1,5 +1,6 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React, { type ReactNode } from 'react'; import React, { type ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { BrokerEventsPage } from './BrokerEventsPage'; import { BrokerEventsPage } from './BrokerEventsPage';
@ -13,11 +14,24 @@ vi.mock('@/widgets/broker-account-layout', () => ({
})); }));
const mockSetSearchParams = vi.fn(); const mockSetSearchParams = vi.fn();
let currentSearchParams = new URLSearchParams();
vi.mock('react-router-dom', () => ({ vi.mock('react-router-dom', () => ({
useSearchParams: () => [new URLSearchParams(), mockSetSearchParams], useSearchParams: () => [currentSearchParams, mockSetSearchParams],
})); }));
vi.mock('@moex-vibe/design-system', () => ({ vi.mock('@moex-vibe/design-system', () => ({
Button: ({ children, onClick, disabled }: any) => (
<button type="button" onClick={onClick} disabled={disabled}>
{children}
</button>
),
Checkbox: ({ label, checked, onChange }: any) => (
<label>
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
{label}
</label>
),
Chip: ({ label }: any) => <span>{label}</span>,
Heading: ({ children }: { children: ReactNode }) => <h2>{children}</h2>, Heading: ({ children }: { children: ReactNode }) => <h2>{children}</h2>,
Text: ({ children }: { children: ReactNode }) => <span>{children}</span>, Text: ({ children }: { children: ReactNode }) => <span>{children}</span>,
TextField: ({ label, value, onChange, ...props }: any) => ( TextField: ({ label, value, onChange, ...props }: any) => (
@ -44,36 +58,70 @@ const mockData = {
summary: { summary: {
eventCount: 3, eventCount: 3,
totalEstimatedCashflow: 450, totalEstimatedCashflow: 450,
actualCashflow: 0,
forecastEstimatedCashflow: 450,
nearestEventDate: '2026-06-25', nearestEventDate: '2026-06-25',
currency: 'RUB', dividendsTotal: 150,
couponsTotal: 36.9,
principalRepaymentTotal: 1000,
actualDividendsTotal: 0,
actualCouponsTotal: 0,
actualPrincipalRepaymentTotal: 0,
}, },
items: [ items: [
{ {
id: 'ev-1', id: 'ev-1',
type: 'dividend', type: 'dividend',
source: 'forecast',
category: 'cashflow',
ticker: 'SBER', ticker: 'SBER',
name: 'Сбер Банк', name: 'Сбер Банк',
eventDate: '2026-06-25', eventDate: '2026-06-25',
paymentDate: null,
instrumentUid: 'uid-sber',
instrumentType: 'share',
quantitySnapshot: 10,
payoutPerUnit: 15,
estimatedAmount: 150, estimatedAmount: 150,
actualAmount: null,
currency: 'RUB' as const, currency: 'RUB' as const,
estimateMode: 'current_position',
}, },
{ {
id: 'ev-2', id: 'ev-2',
type: 'coupon', type: 'coupon',
source: 'forecast',
category: 'cashflow',
ticker: 'SU26238RMFS5', ticker: 'SU26238RMFS5',
name: 'ОФЗ 26238', name: 'ОФЗ 26238',
eventDate: '2026-06-27', eventDate: '2026-06-27',
paymentDate: null,
instrumentUid: 'uid-bond',
instrumentType: 'bond',
quantitySnapshot: 1,
payoutPerUnit: 36.9,
estimatedAmount: 36.9, estimatedAmount: 36.9,
actualAmount: null,
currency: 'RUB' as const, currency: 'RUB' as const,
estimateMode: 'current_position',
}, },
{ {
id: 'ev-3', id: 'ev-3',
type: 'maturity', type: 'maturity',
source: 'actual',
category: 'cashflow',
ticker: 'VTBR', ticker: 'VTBR',
name: 'ВТБ', name: 'ВТБ',
eventDate: '2026-06-30', 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, currency: 'RUB' as const,
estimateMode: null,
}, },
], ],
}; };
@ -81,6 +129,7 @@ const mockData = {
describe('BrokerEventsPage', () => { describe('BrokerEventsPage', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
currentSearchParams = new URLSearchParams();
}); });
it('renders loading state', () => { it('renders loading state', () => {
@ -153,8 +202,15 @@ describe('BrokerEventsPage', () => {
summary: { summary: {
eventCount: 0, eventCount: 0,
totalEstimatedCashflow: 0, totalEstimatedCashflow: 0,
actualCashflow: 0,
forecastEstimatedCashflow: 0,
nearestEventDate: null, nearestEventDate: null,
currency: 'RUB', dividendsTotal: 0,
couponsTotal: 0,
principalRepaymentTotal: 0,
actualDividendsTotal: 0,
actualCouponsTotal: 0,
actualPrincipalRepaymentTotal: 0,
}, },
items: [], items: [],
}, },
@ -182,8 +238,15 @@ describe('BrokerEventsPage', () => {
summary: { summary: {
eventCount: 0, eventCount: 0,
totalEstimatedCashflow: 0, totalEstimatedCashflow: 0,
actualCashflow: 0,
forecastEstimatedCashflow: 0,
nearestEventDate: null, nearestEventDate: null,
currency: 'RUB', dividendsTotal: 0,
couponsTotal: 0,
principalRepaymentTotal: 0,
actualDividendsTotal: 0,
actualCouponsTotal: 0,
actualPrincipalRepaymentTotal: 0,
}, },
items: [], items: [],
}), }),
@ -262,12 +325,65 @@ describe('BrokerEventsPage', () => {
expect(screen.getByText('Событий')).toBeInTheDocument(); expect(screen.getByText('Событий')).toBeInTheDocument();
expect(screen.getByText('3')).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('Купон')).toBeInTheDocument(); expect(screen.getByText('Купон')).toBeInTheDocument();
expect(screen.getByText('Погашение')).toBeInTheDocument(); expect(screen.getByText('Погашение')).toBeInTheDocument();
expect(screen.getByText('SBER')).toBeInTheDocument(); expect(screen.getByText('SBER')).toBeInTheDocument();
expect(screen.getByText('SU26238RMFS5')).toBeInTheDocument(); expect(screen.getByText('SU26238RMFS5')).toBeInTheDocument();
expect(screen.getByText('VTBR')).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<typeof useBrokerEvents>);
render(<BrokerEventsPage />, { 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');
}); });
}); });

View File

@ -1,12 +1,29 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { Box } from '@mui/material'; 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 dayjs from 'dayjs';
import { useBrokerEvents } from '@/entities/broker-event'; import { useBrokerEvents } from '@/entities/broker-event';
import { useBrokerAccountContext } from '@/widgets/broker-account-layout'; import { useBrokerAccountContext } from '@/widgets/broker-account-layout';
import { formatBrokerCurrencyValue, formatBrokerDate } from '@/shared/lib/formatters'; 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 { function eventTypeLabel(type: string): string {
switch (type) { switch (type) {
case 'dividend': case 'dividend':
@ -25,29 +42,64 @@ function eventTypeLabel(type: string): string {
function defaultPeriod(): { from: string; to: string } { function defaultPeriod(): { from: string; to: string } {
const now = dayjs(); const now = dayjs();
return { 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'), 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() { export function BrokerEventsPage() {
const { accountId } = useBrokerAccountContext(); const { accountId } = useBrokerAccountContext();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [initialized, setInitialized] = useState(false); const [appliedFilters, setAppliedFilters] = useState<Filters>(() =>
filtersFromSearchParams(searchParams),
);
const [draftFilters, setDraftFilters] = useState<Filters>(() =>
filtersFromSearchParams(searchParams),
);
useEffect(() => { useEffect(() => {
const from = searchParams.get('from'); const next = filtersFromSearchParams(searchParams);
const to = searchParams.get('to'); setAppliedFilters(next);
if (!from || !to || !dayjs(from).isValid() || !dayjs(to).isValid()) { setDraftFilters(next);
const def = defaultPeriod();
setSearchParams({ from: def.from, to: def.to }, { replace: true });
}
setInitialized(true);
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []); // eslint-disable-line react-hooks/exhaustive-deps
const from = searchParams.get('from') ?? ''; const from = draftFilters.from;
const to = searchParams.get('to') ?? ''; const to = draftFilters.to;
const period = initialized ? { from, to } : defaultPeriod();
const validFrom = dayjs(from); const validFrom = dayjs(from);
const validTo = dayjs(to); const validTo = dayjs(to);
@ -55,11 +107,32 @@ export function BrokerEventsPage() {
from && to && validFrom.isValid() && validTo.isValid() && validTo.isBefore(validFrom) 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; 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 ( return (
<Box component="section" aria-labelledby="broker-events-heading"> <Box component="section" aria-labelledby="broker-events-heading">
<Box <Box
@ -68,15 +141,13 @@ export function BrokerEventsPage() {
<Heading level={2} id="broker-events-heading"> <Heading level={2} id="broker-events-heading">
События События
</Heading> </Heading>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}> <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, alignItems: 'flex-end' }}>
<TextField <TextField
label="С" label="С"
type="date" type="date"
value={from} value={from}
onChange={(e) => { onChange={(e) => {
const newParams = new URLSearchParams(searchParams); setDraftFilters((current) => ({ ...current, from: e.target.value }));
newParams.set('from', e.target.value);
setSearchParams(newParams, { replace: true });
}} }}
InputLabelProps={{ shrink: true }} InputLabelProps={{ shrink: true }}
/> />
@ -85,14 +156,45 @@ export function BrokerEventsPage() {
type="date" type="date"
value={to} value={to}
onChange={(e) => { onChange={(e) => {
const newParams = new URLSearchParams(searchParams); setDraftFilters((current) => ({ ...current, to: e.target.value }));
newParams.set('to', e.target.value);
setSearchParams(newParams, { replace: true });
}} }}
InputLabelProps={{ shrink: true }} InputLabelProps={{ shrink: true }}
error={!!dateError} error={!!dateError}
helperText={dateError} helperText={dateError}
/> />
<Box
sx={{
border: '1px solid',
borderColor: typeError ? 'error.main' : 'divider',
borderRadius: 2,
px: 1.5,
py: 1,
minWidth: 280,
}}
>
<Text variant="caption" tone={typeError ? 'negative' : 'secondary'}>
Типы событий
</Text>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{EVENT_TYPE_OPTIONS.map((option) => (
<Checkbox
key={option.value}
label={option.label}
checked={draftFilters.types.includes(option.value)}
onChange={(checked) => toggleType(option.value, checked)}
error={!!typeError}
/>
))}
</Box>
{typeError && (
<Text variant="caption" tone="negative">
{typeError}
</Text>
)}
</Box>
<Button onClick={applyFilters} disabled={!!filterError}>
Показать
</Button>
</Box> </Box>
</Box> </Box>
@ -136,15 +238,23 @@ export function BrokerEventsPage() {
</Box> </Box>
<Box> <Box>
<Text variant="caption" tone="secondary"> <Text variant="caption" tone="secondary">
Денежный поток Прогноз выплат
</Text> </Text>
<Box sx={{ fontWeight: 700 }}> <Box sx={{ fontWeight: 700 }}>
~{formatBrokerCurrencyValue('RUB', ev.summary.totalEstimatedCashflow)} ~{formatBrokerCurrencyValue('RUB', ev.summary.forecastEstimatedCashflow)}
</Box> </Box>
<Text variant="caption" tone="muted"> <Text variant="caption" tone="muted">
оценка* оценка*
</Text> </Text>
</Box> </Box>
<Box>
<Text variant="caption" tone="secondary">
Поступило
</Text>
<Box sx={{ fontWeight: 700, color: 'success.main' }}>
+{formatBrokerCurrencyValue('RUB', ev.summary.actualCashflow)}
</Box>
</Box>
</Box> </Box>
<Box sx={{ p: 2, bgcolor: 'surface.default', borderRadius: 2 }}> <Box sx={{ p: 2, bgcolor: 'surface.default', borderRadius: 2 }}>
@ -177,6 +287,19 @@ export function BrokerEventsPage() {
> >
Тип Тип
</Box> </Box>
<Box
component="th"
sx={{
textAlign: 'left',
borderBottom: '1px solid',
borderColor: 'divider',
p: 1,
color: 'text.secondary',
fontWeight: 600,
}}
>
Статус
</Box>
<Box <Box
component="th" component="th"
sx={{ sx={{
@ -220,6 +343,15 @@ export function BrokerEventsPage() {
> >
{eventTypeLabel(item.type)} {eventTypeLabel(item.type)}
</Box> </Box>
<Box
component="td"
sx={{ p: 1, borderBottom: '1px solid', borderColor: 'divider' }}
>
<Chip
label={sourceLabel(item.source)}
tone={item.source === 'actual' ? 'success' : 'info'}
/>
</Box>
<Box <Box
component="td" component="td"
sx={{ p: 1, borderBottom: '1px solid', borderColor: 'divider' }} sx={{ p: 1, borderBottom: '1px solid', borderColor: 'divider' }}
@ -240,7 +372,16 @@ export function BrokerEventsPage() {
borderColor: 'divider', borderColor: 'divider',
}} }}
> >
{item.estimatedAmount != null ? ( {item.source === 'actual' && item.actualAmount != null ? (
<>
<Box sx={{ fontWeight: 700, color: 'success.main' }}>
+{formatBrokerCurrencyValue(item.currency ?? 'RUB', item.actualAmount)}
</Box>
<Text variant="caption" tone="secondary">
Поступило
</Text>
</>
) : item.estimatedAmount != null ? (
<> <>
<Box sx={{ fontWeight: 700 }}> <Box sx={{ fontWeight: 700 }}>
~ ~

View File

@ -354,6 +354,7 @@ export interface BrokerPositionsPage {
export interface BrokerPortfolioEvent { export interface BrokerPortfolioEvent {
id: string; id: string;
type: 'dividend' | 'coupon' | 'maturity' | 'offer'; type: 'dividend' | 'coupon' | 'maturity' | 'offer';
source: 'forecast' | 'actual';
category: 'cashflow' | 'corporate'; category: 'cashflow' | 'corporate';
eventDate: string; eventDate: string;
paymentDate: string | null; paymentDate: string | null;
@ -364,17 +365,23 @@ export interface BrokerPortfolioEvent {
quantitySnapshot: number | null; quantitySnapshot: number | null;
payoutPerUnit: number | null; payoutPerUnit: number | null;
estimatedAmount: number | null; estimatedAmount: number | null;
actualAmount: number | null;
currency: string | null; currency: string | null;
estimateMode: 'current_position'; estimateMode: 'current_position' | null;
} }
export interface BrokerEventsSummary { export interface BrokerEventsSummary {
eventCount: number; eventCount: number;
nearestEventDate: string | null; nearestEventDate: string | null;
totalEstimatedCashflow: number; totalEstimatedCashflow: number;
actualCashflow: number;
forecastEstimatedCashflow: number;
dividendsTotal: number; dividendsTotal: number;
couponsTotal: number; couponsTotal: number;
principalRepaymentTotal: number; principalRepaymentTotal: number;
actualDividendsTotal: number;
actualCouponsTotal: number;
actualPrincipalRepaymentTotal: number;
} }
export interface BrokerEventsData { export interface BrokerEventsData {

View File

@ -4,13 +4,14 @@
> (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use > (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use
> checkbox (`- [ ]`) syntax for tracking. > checkbox (`- [ ]`) syntax for tracking.
**Goal:** Добавить в брокерский счёт T-Bank раздел предстоящих событий и ориентировочный прогноз **Goal:** Добавить в брокерский счёт T-Bank раздел предстоящих и прошедших событий с прогнозом
будущих выплат по выбранному периоду. будущих выплат, фактическими прошедшими поступлениями и управляемыми фильтрами.
**Architecture:** Backend добавляет отдельный read-only endpoint событий поверх уже существующих **Architecture:** Backend предоставляет единый read-only endpoint событий поверх существующих данных
данных T-Bank и MOEX. Слой агрегации строит best-effort список событий по текущим позициям счёта и T-Bank и MOEX. Слой агрегации объединяет прогнозные события по текущим позициям и фактические
summary по будущим денежным потокам. Frontend расширяет shell брокерского счёта новой вкладкой прошедшие выплаты из операций счёта, затем строит раздельный summary факта и прогноза. Frontend
`События`, отдельной страницей и компактным overview-виджетом ближайших событий. держит фильтры в черновике, применяет их только по кнопке `Показать` и визуально разделяет
`Факт`/`Прогноз`.
**Tech Stack:** NestJS 10, Prisma, CacheService, MOEX client, T-Bank module, React 18, React Router **Tech Stack:** NestJS 10, Prisma, CacheService, MOEX client, T-Bank module, React 18, React Router
6, TanStack Query 5, TypeScript, Vitest, Testing Library. 6, TanStack Query 5, TypeScript, Vitest, Testing Library.
@ -39,13 +40,13 @@ summary по будущим денежным потокам. Frontend расши
Новый endpoint: Новый endpoint:
```text ```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 возвращает: Endpoint возвращает:
- `items` — плоский список событий по текущим позициям счёта; - `items` — плоский список событий по текущим позициям счёта;
- `summary` — агрегаты по денежным событиям за период; - `summary` — агрегаты по фактическим и прогнозным денежным событиям за период;
- `asOf` — момент построения read model. - `asOf` — момент построения read model.
### Backend read model ### Backend read model
@ -56,7 +57,8 @@ Endpoint возвращает:
- `BrokerPortfolioService` или внутренний shared-path для получения текущих позиций; - `BrokerPortfolioService` или внутренний shared-path для получения текущих позиций;
- `BrokerInstrumentsService` для сопоставления T-Bank instrument metadata; - `BrokerInstrumentsService` для сопоставления T-Bank instrument metadata;
- `MoexClientService` для дивидендов и bond enrichment; - `MoexClientService` для дивидендов и bond enrichment;
- `CacheService` для кэширования результата по `accountId + from + to`. - `BrokerOperationsService` для получения фактических прошедших выплат;
- `CacheService` для кэширования результата по `accountId + from + to + types`.
Сервис не записывает события в Prisma и не вводит отдельные таблицы в первой версии. Сервис не записывает события в Prisma и не вводит отдельные таблицы в первой версии.
@ -78,14 +80,26 @@ Endpoint возвращает:
### Семантика summary ### Семантика 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 = { type BrokerEventsQuery = {
from: string; from: string;
to: string; to: string;
types?: string;
}; };
``` ```
Обе даты обязательны в первой версии, чтобы не вводить неочевидные дефолты по периоду. Обе даты обязательны. `types` опционален: если параметр отсутствует, backend использует все типы.
### Response contract ### Response contract
@ -116,6 +131,7 @@ type BrokerEventsQuery = {
type BrokerPortfolioEvent = { type BrokerPortfolioEvent = {
id: string; id: string;
type: 'dividend' | 'coupon' | 'maturity' | 'offer'; type: 'dividend' | 'coupon' | 'maturity' | 'offer';
source: 'forecast' | 'actual';
category: 'cashflow' | 'corporate'; category: 'cashflow' | 'corporate';
eventDate: string; eventDate: string;
paymentDate: string | null; paymentDate: string | null;
@ -126,17 +142,23 @@ type BrokerPortfolioEvent = {
quantitySnapshot: number | null; quantitySnapshot: number | null;
payoutPerUnit: number | null; payoutPerUnit: number | null;
estimatedAmount: number | null; estimatedAmount: number | null;
actualAmount: number | null;
currency: string | null; currency: string | null;
estimateMode: 'current_position'; estimateMode: 'current_position' | null;
}; };
type BrokerEventsSummary = { type BrokerEventsSummary = {
eventCount: number; eventCount: number;
nearestEventDate: string | null; nearestEventDate: string | null;
totalEstimatedCashflow: number; totalEstimatedCashflow: number;
actualCashflow: number;
forecastEstimatedCashflow: number;
dividendsTotal: number; dividendsTotal: number;
couponsTotal: number; couponsTotal: number;
principalRepaymentTotal: number; principalRepaymentTotal: number;
actualDividendsTotal: number;
actualCouponsTotal: number;
actualPrincipalRepaymentTotal: number;
}; };
``` ```
@ -210,12 +232,41 @@ Overview получает компактный блок ближайших со
### Frontend date range UI ### 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.

View File

@ -1,13 +1,14 @@
# Календарь событий и прогноз будущих выплат брокерского счёта # Календарь событий и прогноз будущих выплат брокерского счёта
Дата: 2026-06-21 Дата: 2026-06-21
Статус: согласовано к планированию Статус: реализовано; доработка UX и смешанного календаря согласована к реализации
Эпик: [Портфель брокера](../../epics/BrokerPortfolio.md) Эпик: [Портфель брокера](../../epics/BrokerPortfolio.md)
## Цель ## Цель
Дать пользователю брокерского счёта T-Bank отдельный раздел, где можно увидеть будущие события по Дать пользователю брокерского счёта T-Bank отдельный раздел, где можно увидеть будущие события по
бумагам счёта и ориентировочный денежный поток по выбранному диапазону дат. бумагам счёта, фактически прошедшие выплаты и ориентировочный денежный поток по выбранному
диапазону дат.
## Пользовательский результат ## Пользовательский результат
@ -18,6 +19,8 @@
- выбрать период, например с `2026-06-22` по `2026-07-29`; - выбрать период, например с `2026-06-22` по `2026-07-29`;
- получить список дивидендов, купонов, погашений и оферт, попадающих в этот период; - получить список дивидендов, купонов, погашений и оферт, попадающих в этот период;
- увидеть ориентировочную сумму будущих выплат по выбранному диапазону; - увидеть ориентировочную сумму будущих выплат по выбранному диапазону;
- увидеть фактически поступившие прошедшие выплаты из операций брокерского счёта;
- выбрать несколько типов событий, которые нужно показать;
- понимать, какие значения являются оценкой по текущим позициям, а не подтверждённым правом на - понимать, какие значения являются оценкой по текущим позициям, а не подтверждённым правом на
выплату. выплату.
@ -28,7 +31,9 @@
- новый блок ближайших событий на overview счёта; - новый блок ближайших событий на overview счёта;
- новую вкладку `События` в навигации брокерского счёта; - новую вкладку `События` в навигации брокерского счёта;
- фильтр диапазона дат; - фильтр диапазона дат;
- фильтр нескольких типов событий;
- список событий по текущим позициям счёта; - список событий по текущим позициям счёта;
- список прошедших фактических выплат по операциям счёта;
- агрегированный summary по будущим выплатам за выбранный период. - агрегированный summary по будущим выплатам за выбранный период.
## Требования ## Требования
@ -76,6 +81,19 @@
- Границы диапазона включительные. - Границы диапазона включительные.
- Если `eventDate` не попадает в диапазон, событие не показывается. - Если `eventDate` не попадает в диапазон, событие не показывается.
- В первой версии отдельный режим фильтрации по `paymentDate` отсутствует. - В первой версии отдельный режим фильтрации по `paymentDate` отсутствует.
- При первом открытии вкладки без параметров период по умолчанию равен `сегодня - 7 дней` / `сегодня
+ 7 дней`.
- Изменение дат в интерфейсе не запускает запрос автоматически: пользователь редактирует черновик
фильтров и применяет его кнопкой `Показать`.
### 4.1. Фильтр типов событий
- Пользователь может выбрать несколько типов событий через multi-select: `dividend`, `coupon`,
`maturity`, `offer`.
- При первом открытии включены все типы событий.
- После применения фильтра выбранные типы сохраняются в URL в параметре `types`.
- URL отражает только применённые фильтры, а не черновые значения в полях.
- Если пользователь снимает все типы, запрос не выполняется, а UI показывает валидационное сообщение.
### 5. Источники данных ### 5. Источники данных
@ -119,6 +137,22 @@
- В первой версии оферта считается информационным событием. - В первой версии оферта считается информационным событием.
- Для оферты не требуется обязательная денежная оценка. - Для оферты не требуется обязательная денежная оценка.
### 6.1. Фактические прошедшие события
- Для прошедшей части выбранного диапазона календарь добавляет фактические события из операций
T-Bank.
- Фактические события строятся только по исполненным операциям счёта.
- В фактические события входят дивиденды, купоны и погашения облигаций.
- Фактические события имеют источник `actual`, не являются оценкой и используют фактическую сумму
операции.
- Фактические поступления визуально выделяются зелёным как уже поступившие деньги.
- Будущие события имеют источник `forecast`, строятся по текущим позициям и сохраняют признак оценки
`current_position`.
- Оферты остаются прогнозными событиями, если доступны по данным облигаций; фактическая оферта из
операций в этой доработке не строится.
- Если одно и то же событие доступно как факт и как прогноз за прошедшую дату, UI должен отдавать
приоритет факту, чтобы не показывать пользователю дубль одного поступления.
### 7. Overview счёта ### 7. Overview счёта
- Overview показывает ближайшие 3-5 событий выбранного счёта. - Overview показывает ближайшие 3-5 событий выбранного счёта.
@ -128,17 +162,23 @@
### 8. Вкладка `События` ### 8. Вкладка `События`
- Вкладка содержит фильтр периода, summary и список событий. - Вкладка содержит фильтр периода, multi-select типов событий, summary и список событий.
- Фильтры используют компоненты и визуальные паттерны дизайн-системы.
- Изменение фильтров не запускает запрос до нажатия кнопки `Показать`.
- Кнопка `Показать` применяет фильтры, обновляет URL и запускает загрузку данных.
- Список событий показывает: - Список событий показывает:
- дату события; - дату события;
- тип события; - тип события;
- источник события (`Факт` или `Прогноз`);
- инструмент; - инструмент;
- тип инструмента; - тип инструмента;
- количество бумаг, использованное для расчёта; - количество бумаг, использованное для расчёта;
- выплату на единицу при наличии; - выплату на единицу при наличии;
- итоговую ориентировочную сумму при наличии; - итоговую ориентировочную сумму при наличии;
- фактическую сумму поступления при наличии;
- валюту при наличии. - валюту при наличии.
- Для денежных оценок UI показывает признак `estimate`. - Для денежных оценок UI показывает признак `estimate`.
- Для фактических поступлений UI показывает признак `Поступило` и зелёное выделение суммы или статуса.
### 9. Summary по периоду ### 9. Summary по периоду
@ -146,12 +186,13 @@ Summary по выбранному периоду показывает:
- количество событий; - количество событий;
- ближайшую дату события; - ближайшую дату события;
- общий ориентировочный денежный поток; - общий ориентировочный денежный поток по прогнозам;
- общий фактический денежный поток по прошедшим поступлениям;
- сумму дивидендов; - сумму дивидендов;
- сумму купонов; - сумму купонов;
- сумму погашений. - сумму погашений.
Оферты не обязаны входить в денежный итог первой версии. Оферты не обязаны входить в денежные итоги.
### 10. Ошибки, пустые состояния и частичная деградация ### 10. Ошибки, пустые состояния и частичная деградация
@ -174,9 +215,16 @@ Summary по выбранному периоду показывает:
- На overview брокерского счёта отображается блок ближайших событий. - На overview брокерского счёта отображается блок ближайших событий.
- В навигации брокерского счёта есть вкладка `События`. - В навигации брокерского счёта есть вкладка `События`.
- Пользователь может задать диапазон дат. - Пользователь может задать диапазон дат.
- По умолчанию вкладка открывает период `сегодня - 7 дней` / `сегодня + 7 дней`.
- Изменение дат или типов событий не запускает запрос до нажатия `Показать`.
- Пользователь может выбрать несколько типов событий через multi-select.
- Выбранные применённые фильтры восстанавливаются из URL.
- Вкладка показывает только события, дата которых попадает в выбранный диапазон. - Вкладка показывает только события, дата которых попадает в выбранный диапазон.
- Пользователь видит дивиденды, купоны, погашения и оферты, если они доступны по текущим позициям. - Пользователь видит дивиденды, купоны, погашения и оферты, если они доступны по текущим позициям.
- Пользователь видит фактические прошедшие дивиденды, купоны и погашения из операций счёта.
- Фактические прошедшие поступления помечены как `Поступило` и визуально выделены зелёным.
- Summary показывает агрегированный прогноз будущих выплат по диапазону. - Summary показывает агрегированный прогноз будущих выплат по диапазону.
- Summary отдельно показывает фактические поступления и прогноз выплат.
- Все денежные суммы явно обозначены как оценочные. - Все денежные суммы явно обозначены как оценочные.
- Пустой диапазон отображается как отдельное пустое состояние. - Пустой диапазон отображается как отдельное пустое состояние.
- Ошибка по одному инструменту не ломает весь ответ. - Ошибка по одному инструменту не ломает весь ответ.

View File

@ -1,6 +1,6 @@
# Календарь событий и прогноз будущих выплат брокерского счёта — задачи # Календарь событий и прогноз будущих выплат брокерского счёта — задачи
Статус: реализовано (1-я версия, read-only, T-Bank) Статус: реализовано (1-я версия, read-only, T-Bank); доработка UX и смешанного календаря в работе
Связанные документы: Связанные документы:
@ -86,3 +86,23 @@
- [ ] Прогнать docs build, если менялась опубликованная документация. - [ ] Прогнать docs build, если менялась опубликованная документация.
(Deferred: docs не менялись.) (Deferred: docs не менялись.)
- [x] Отметить roadmap и связанные SDD-статусы. - [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 пакетов.