feat: add actual payouts and filter-as-draft UX to broker events calendar #37
@ -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;
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -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), {
|
||||
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();
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 }}>
|
||||
~
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 отдельно показывает фактические поступления и прогноз выплат.
|
||||
- Все денежные суммы явно обозначены как оценочные.
|
||||
- Пустой диапазон отображается как отдельное пустое состояние.
|
||||
- Ошибка по одному инструменту не ломает весь ответ.
|
||||
|
||||
@ -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 пакетов.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user