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 { 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;
}

View File

@ -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 {

View File

@ -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);
});
});

View File

@ -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<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()
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<BrokerEventType>,
): Promise<BrokerEventsData> {
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<string, Partial<TBankInstrument>>,
from: string,
to: string,
eventTypes: Set<BrokerEventType>,
): Promise<BrokerPortfolioEvent[]> {
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<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,
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);
});

View File

@ -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);
}

View File

@ -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 = {

View File

@ -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,
},
);
}

View File

@ -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();

View File

@ -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<BrokerEventsData>({
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,

View File

@ -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) => (
<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>,
Text: ({ children }: { children: ReactNode }) => <span>{children}</span>,
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<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 { 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<Filters>(() =>
filtersFromSearchParams(searchParams),
);
const [draftFilters, setDraftFilters] = useState<Filters>(() =>
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 (
<Box component="section" aria-labelledby="broker-events-heading">
<Box
@ -68,15 +141,13 @@ export function BrokerEventsPage() {
<Heading level={2} id="broker-events-heading">
События
</Heading>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, alignItems: 'flex-end' }}>
<TextField
label="С"
type="date"
value={from}
onChange={(e) => {
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}
/>
<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>
@ -136,15 +238,23 @@ export function BrokerEventsPage() {
</Box>
<Box>
<Text variant="caption" tone="secondary">
Денежный поток
Прогноз выплат
</Text>
<Box sx={{ fontWeight: 700 }}>
~{formatBrokerCurrencyValue('RUB', ev.summary.totalEstimatedCashflow)}
~{formatBrokerCurrencyValue('RUB', ev.summary.forecastEstimatedCashflow)}
</Box>
<Text variant="caption" tone="muted">
оценка*
</Text>
</Box>
<Box>
<Text variant="caption" tone="secondary">
Поступило
</Text>
<Box sx={{ fontWeight: 700, color: 'success.main' }}>
+{formatBrokerCurrencyValue('RUB', ev.summary.actualCashflow)}
</Box>
</Box>
</Box>
<Box sx={{ p: 2, bgcolor: 'surface.default', borderRadius: 2 }}>
@ -177,6 +287,19 @@ export function BrokerEventsPage() {
>
Тип
</Box>
<Box
component="th"
sx={{
textAlign: 'left',
borderBottom: '1px solid',
borderColor: 'divider',
p: 1,
color: 'text.secondary',
fontWeight: 600,
}}
>
Статус
</Box>
<Box
component="th"
sx={{
@ -220,6 +343,15 @@ export function BrokerEventsPage() {
>
{eventTypeLabel(item.type)}
</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
component="td"
sx={{ p: 1, borderBottom: '1px solid', borderColor: 'divider' }}
@ -240,7 +372,16 @@ export function BrokerEventsPage() {
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 }}>
~

View File

@ -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 {

View File

@ -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.

View File

@ -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 отдельно показывает фактические поступления и прогноз выплат.
- Все денежные суммы явно обозначены как оценочные.
- Пустой диапазон отображается как отдельное пустое состояние.
- Ошибка по одному инструменту не ломает весь ответ.

View File

@ -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 пакетов.