feat: add broker events calendar and payout projections for T-Bank accounts
Some checks failed
CI / ci (push) Failing after 3m1s

This commit is contained in:
Sergey Krylov 2026-06-22 06:39:10 +03:00
parent 7b1d649853
commit 595d059151
28 changed files with 1821 additions and 41 deletions

View File

@ -1,5 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import { BrokerAccountResponseDto } from './broker-account-response.dto';
import { BrokerEventsDataDto } from './broker-events-response.dto';
import { BrokerOperationSyncResponseDto } from './broker-operation-sync-query.dto';
import { BrokerOperationsPageResponseDto } from './broker-operation-response.dto';
import { BrokerPositionsPageResponseDto } from './broker-positions-page-response.dto';
@ -52,3 +53,11 @@ export class BrokerOperationSyncEnvelopeDto {
@ApiProperty({ type: BrokerResponseMetaDto })
meta!: BrokerResponseMetaDto;
}
export class BrokerEventsEnvelopeDto {
@ApiProperty({ type: BrokerEventsDataDto })
data!: BrokerEventsDataDto;
@ApiProperty({ type: BrokerResponseMetaDto })
meta!: BrokerResponseMetaDto;
}

View File

@ -0,0 +1,12 @@
import { ApiProperty } from '@nestjs/swagger';
import { Matches } from 'class-validator';
export class BrokerEventsQueryDto {
@ApiProperty({ example: '2026-06-22', description: 'Start date inclusive (YYYY-MM-DD)' })
@Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'from must be YYYY-MM-DD' })
from!: string;
@ApiProperty({ example: '2026-07-29', description: 'End date inclusive (YYYY-MM-DD)' })
@Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'to must be YYYY-MM-DD' })
to!: string;
}

View File

@ -0,0 +1,80 @@
import { ApiProperty } from '@nestjs/swagger';
const eventTypes = ['dividend', 'coupon', 'maturity', 'offer'] as const;
const eventCategories = ['cashflow', 'corporate'] as const;
const instrumentTypes = ['share', 'bond', 'other'] as const;
export class BrokerEventItemDto {
@ApiProperty()
id!: string;
@ApiProperty({ enum: eventTypes })
type!: string;
@ApiProperty({ enum: eventCategories })
category!: string;
@ApiProperty()
eventDate!: string;
@ApiProperty({ nullable: true })
paymentDate!: string | null;
@ApiProperty({ nullable: true })
ticker!: string | null;
@ApiProperty({ nullable: true })
name!: string | null;
@ApiProperty({ nullable: true })
instrumentUid!: string | null;
@ApiProperty({ enum: instrumentTypes })
instrumentType!: string;
@ApiProperty({ nullable: true })
quantitySnapshot!: number | null;
@ApiProperty({ nullable: true })
payoutPerUnit!: number | null;
@ApiProperty({ nullable: true })
estimatedAmount!: number | null;
@ApiProperty({ nullable: true })
currency!: string | null;
@ApiProperty()
estimateMode!: 'current_position';
}
export class BrokerEventsSummaryDto {
@ApiProperty({ minimum: 0 })
eventCount!: number;
@ApiProperty({ nullable: true })
nearestEventDate!: string | null;
@ApiProperty()
totalEstimatedCashflow!: number;
@ApiProperty()
dividendsTotal!: number;
@ApiProperty()
couponsTotal!: number;
@ApiProperty()
principalRepaymentTotal!: number;
}
export class BrokerEventsDataDto {
@ApiProperty({ type: [BrokerEventItemDto] })
items!: BrokerEventItemDto[];
@ApiProperty({ type: BrokerEventsSummaryDto })
summary!: BrokerEventsSummaryDto;
@ApiProperty()
asOf!: string;
}

View File

@ -0,0 +1,309 @@
import { NotFoundException } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service';
import { MoexClientService } from '../../moex-client/moex-client.service';
import { BrokerAccountsService } from './broker-accounts.service';
import { BrokerEventsService } from './broker-events.service';
import { BrokerPortfolioService } from './broker-portfolio.service';
describe('BrokerEventsService', () => {
const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
const portfolio = { getPositionsWithInstruments: vi.fn() } as unknown as BrokerPortfolioService;
const moex = {
getDividends: vi.fn(),
getBondPositionDataBatch: vi.fn(),
} as unknown as MoexClientService;
const cache = { getOrFetch: vi.fn() } as unknown as CacheService;
const acc1 = {
id: 'acc-1',
type: 'brokerage' as const,
name: 'Test Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
};
beforeEach(() => {
vi.clearAllMocks();
});
function mockCachePassthrough() {
vi.mocked(cache.getOrFetch).mockImplementation(
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: null,
}),
);
}
it('throws 404 for missing account', async () => {
vi.mocked(accounts.findById).mockResolvedValue(null);
const service = new BrokerEventsService(accounts, portfolio, moex, cache);
await expect(service.getEvents('missing', '2026-06-01', '2026-07-01')).rejects.toThrow(
NotFoundException,
);
});
it('returns empty events for account with no positions', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({
positions: [],
instruments: new Map(),
});
const service = new BrokerEventsService(accounts, portfolio, moex, cache);
const result = await service.getEvents('acc-1', '2026-06-01', '2026-07-01');
expect(result.data.items).toEqual([]);
expect(result.data.summary.eventCount).toBe(0);
expect(result.data.summary.nearestEventDate).toBeNull();
expect(result.data.summary.totalEstimatedCashflow).toBe(0);
});
it('builds dividend events from share positions in date range', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({
positions: [
{
ticker: 'SBER',
instrumentUid: 'uid-sber',
instrumentType: 'share',
quantity: { units: '10', nano: 0 },
},
],
instruments: new Map([['uid-sber', { name: 'Sberbank', currency: 'RUB' }]]),
});
vi.mocked(moex.getDividends).mockResolvedValue([
{
secid: 'SBER',
isin: 'RU000A0JS',
registryCloseDate: '2026-06-15',
value: 33.5,
currencyId: 'RUB',
},
{
secid: 'SBER',
isin: 'RU000A0JS',
registryCloseDate: '2026-06-25',
value: 33.5,
currencyId: 'RUB',
},
{
secid: 'SBER',
isin: 'RU000A0JS',
registryCloseDate: '2026-08-01',
value: 33.5,
currencyId: 'RUB',
},
]);
const service = new BrokerEventsService(accounts, portfolio, moex, cache);
const result = await service.getEvents('acc-1', '2026-06-20', '2026-07-10');
expect(result.data.items).toHaveLength(1);
expect(result.data.items[0].type).toBe('dividend');
expect(result.data.items[0].eventDate).toBe('2026-06-25');
expect(result.data.items[0].estimatedAmount).toBe(335);
expect(result.data.items[0].currency).toBe('RUB');
expect(result.data.items[0].name).toBe('Sberbank');
});
it('builds coupon, maturity, and offer events for bonds', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({
positions: [
{
ticker: 'SU26248RMFS4',
instrumentUid: 'uid-bond-1',
instrumentType: 'bond',
quantity: { units: '5', nano: 0 },
},
],
instruments: new Map([['uid-bond-1', { name: 'OFZ 26248', currency: 'RUB' }]]),
});
vi.mocked(moex.getBondPositionDataBatch).mockResolvedValue([
{
secid: 'SU26248RMFS4',
couponValue: 35.4,
nextCouponDate: '2026-06-25',
matDate: '2026-06-27',
offerDate: '2026-06-28',
faceValue: 1000,
boardid: 'TQCB',
shortName: '',
price: null,
yieldToMaturity: null,
duration: null,
couponPercent: null,
accruedInt: null,
bid: null,
offer: null,
couponPeriod: null,
bondType: null,
},
]);
const service = new BrokerEventsService(accounts, portfolio, moex, cache);
const result = await service.getEvents('acc-1', '2026-06-20', '2026-07-10');
expect(result.data.items).toHaveLength(3);
const coupon = result.data.items.find((e) => e.type === 'coupon')!;
expect(coupon.estimatedAmount).toBe(177);
const offer = result.data.items.find((e) => e.type === 'offer')!;
expect(offer.category).toBe('corporate');
const maturity = result.data.items.find((e) => e.type === 'maturity')!;
expect(maturity.estimatedAmount).toBe(5000);
});
it('handles partial failures gracefully', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({
positions: [
{
ticker: 'OK_SPLIT',
instrumentUid: 'uid-1',
instrumentType: 'share',
quantity: { units: '10', nano: 0 },
},
{
ticker: 'GOOD',
instrumentUid: 'uid-2',
instrumentType: 'share',
quantity: { units: '5', nano: 0 },
},
],
instruments: new Map([
['uid-1', { name: 'Failing' }],
['uid-2', { name: 'Working' }],
]),
});
vi.mocked(moex.getDividends).mockRejectedValueOnce(new Error('MOEX error'));
vi.mocked(moex.getDividends).mockResolvedValueOnce([
{ secid: 'GOOD', isin: 'RU', registryCloseDate: '2026-06-25', value: 20, currencyId: 'RUB' },
]);
const service = new BrokerEventsService(accounts, portfolio, moex, cache);
const result = await service.getEvents('acc-1', '2026-06-20', '2026-07-10');
expect(result.data.items).toHaveLength(1);
expect(result.data.items[0].ticker).toBe('GOOD');
});
it('includes events without amount when payout is unknown', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({
positions: [
{
ticker: 'NO_AMT',
instrumentUid: 'uid-1',
instrumentType: 'share',
quantity: { units: '10', nano: 0 },
},
],
instruments: new Map([['uid-1', { name: 'No Amount' }]]),
});
vi.mocked(moex.getDividends).mockResolvedValue([
{ secid: 'NO_AMT', isin: 'RU', registryCloseDate: '2026-06-25', value: 0, currencyId: 'RUB' },
]);
const service = new BrokerEventsService(accounts, portfolio, moex, cache);
const result = await service.getEvents('acc-1', '2026-06-20', '2026-07-10');
expect(result.data.items).toHaveLength(1);
expect(result.data.items[0].estimatedAmount).toBe(0);
});
it('calculates summary totals correctly', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({
positions: [
{
ticker: 'SBER',
instrumentUid: 'uid-1',
instrumentType: 'share',
quantity: { units: '10', nano: 0 },
},
{
ticker: 'BOND1',
instrumentUid: 'uid-2',
instrumentType: 'bond',
quantity: { units: '2', nano: 0 },
},
],
instruments: new Map([
['uid-1', { name: 'Sber' }],
['uid-2', { name: 'OFZ' }],
]),
});
vi.mocked(moex.getDividends).mockResolvedValue([
{ secid: 'SBER', isin: 'RU1', registryCloseDate: '2026-06-25', value: 30, currencyId: 'RUB' },
]);
vi.mocked(moex.getBondPositionDataBatch).mockResolvedValue([
{
secid: 'BOND1',
couponValue: 50,
nextCouponDate: '2026-06-26',
matDate: '2026-06-27',
offerDate: null,
faceValue: 1000,
boardid: 'TQCB',
shortName: '',
price: null,
yieldToMaturity: null,
duration: null,
couponPercent: null,
accruedInt: null,
bid: null,
offer: null,
couponPeriod: null,
bondType: null,
},
]);
const service = new BrokerEventsService(accounts, portfolio, moex, cache);
const result = await service.getEvents('acc-1', '2026-06-20', '2026-07-10');
expect(result.data.summary.eventCount).toBe(3);
expect(result.data.summary.nearestEventDate).toBe('2026-06-25');
expect(result.data.summary.dividendsTotal).toBe(300);
expect(result.data.summary.couponsTotal).toBe(100);
expect(result.data.summary.principalRepaymentTotal).toBe(2000);
expect(result.data.summary.totalEstimatedCashflow).toBe(2400);
});
it('filters events by inclusive date range', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({
positions: [
{
ticker: 'SBER',
instrumentUid: 'uid-1',
instrumentType: 'share',
quantity: { units: '1', nano: 0 },
},
],
instruments: new Map([['uid-1', { name: 'Sber' }]]),
});
vi.mocked(moex.getDividends).mockResolvedValue([
{ secid: 'SBER', isin: 'RU', registryCloseDate: '2026-06-20', value: 10, currencyId: 'RUB' },
{ secid: 'SBER', isin: 'RU', registryCloseDate: '2026-07-29', value: 10, currencyId: 'RUB' },
{ secid: 'SBER', isin: 'RU', registryCloseDate: '2026-07-30', value: 10, currencyId: 'RUB' },
]);
const service = new BrokerEventsService(accounts, portfolio, moex, cache);
const result = await service.getEvents('acc-1', '2026-06-20', '2026-07-29');
expect(result.data.items).toHaveLength(2);
expect(result.data.items[0].eventDate).toBe('2026-06-20');
expect(result.data.items[1].eventDate).toBe('2026-07-29');
});
});

View File

@ -0,0 +1,263 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service';
import { MoexClientService } from '../../moex-client/moex-client.service';
import { TBANK_CACHE_KEYS } from '../tbank.config';
import { mapQuotationToNumber } from '../mappers/money.mapper';
import type {
BrokerPortfolioEvent,
BrokerEventsData,
BrokerEventsSummary,
} from '../types/broker.types';
import type { TBankInstrument } from '../types/tbank-proto.types';
import { BrokerAccountsService } from './broker-accounts.service';
import { BrokerPortfolioService } from './broker-portfolio.service';
@Injectable()
export class BrokerEventsService {
constructor(
private readonly accountsService: BrokerAccountsService,
private readonly portfolioService: BrokerPortfolioService,
private readonly moexClient: MoexClientService,
private readonly cacheService: CacheService,
) {}
async getEvents(
accountId: string,
from: string,
to: string,
): Promise<{
data: BrokerEventsData;
meta: { fromCache: boolean; cachedAt: string | null };
}> {
const account = await this.accountsService.findById(accountId);
if (!account) throw new NotFoundException('Broker account not found');
const result = await this.cacheService.getOrFetch(
TBANK_CACHE_KEYS.events,
[accountId, from, to],
() => this.buildEvents(accountId, from, to),
'tbankPortfolioTtl',
);
return {
data: result.data,
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
};
}
private async buildEvents(
accountId: string,
from: string,
to: string,
): Promise<BrokerEventsData> {
const { positions, instruments } =
await this.portfolioService.getPositionsWithInstruments(accountId);
const items: BrokerPortfolioEvent[] = [];
const sharePositions = positions.filter((p) => p.instrumentType?.toLowerCase() === 'share');
const bondPositions = positions.filter((p) => p.instrumentType?.toLowerCase() === 'bond');
const shareResults = await Promise.allSettled(
sharePositions.map((pos) => this.buildShareEvents(pos, instruments, from, to)),
);
for (const r of shareResults) {
if (r.status === 'fulfilled') items.push(...r.value);
}
if (bondPositions.length > 0) {
const bondEvents = await this.buildBondEvents(bondPositions, instruments, from, to);
items.push(...bondEvents);
}
items.sort((a, b) => a.eventDate.localeCompare(b.eventDate));
const summary = this.buildSummary(items);
return { items, summary, asOf: new Date().toISOString() };
}
private async buildShareEvents(
pos: {
ticker?: string;
instrumentUid?: string;
instrumentType?: string;
quantity?: { units?: string | number; nano?: number };
},
instruments: Map<string, Partial<TBankInstrument>>,
from: string,
to: string,
): Promise<BrokerPortfolioEvent[]> {
const ticker = pos.ticker || null;
if (!ticker) return [];
const instrument = pos.instrumentUid ? instruments.get(pos.instrumentUid) : undefined;
const quantity = mapQuotationToNumber(pos.quantity);
const instrumentUid = pos.instrumentUid || instrument?.uid || null;
const name = instrument?.name || null;
let dividends: { registryCloseDate: string; value: number; currencyId: string }[];
try {
dividends = await this.moexClient.getDividends(ticker);
} catch {
return [];
}
const events: BrokerPortfolioEvent[] = [];
for (const d of dividends) {
if (d.registryCloseDate < from || d.registryCloseDate > to) continue;
const payoutPerUnit = d.value ?? null;
const estimatedAmount =
quantity !== null && payoutPerUnit !== null ? quantity * payoutPerUnit : null;
events.push({
id: `div-${ticker}-${d.registryCloseDate}`,
type: 'dividend',
category: 'cashflow',
eventDate: d.registryCloseDate,
paymentDate: null,
ticker,
name,
instrumentUid,
instrumentType: 'share',
quantitySnapshot: quantity,
payoutPerUnit,
estimatedAmount,
currency: d.currencyId,
estimateMode: 'current_position',
});
}
return events;
}
private async buildBondEvents(
bondPositions: {
ticker?: string;
instrumentUid?: string;
instrumentType?: string;
quantity?: { units?: string | number; nano?: number };
}[],
instruments: Map<string, Partial<TBankInstrument>>,
from: string,
to: string,
): Promise<BrokerPortfolioEvent[]> {
const secids = bondPositions.map((p) => p.ticker).filter((t): t is string => Boolean(t));
if (secids.length === 0) return [];
let bondData: {
secid: string;
couponValue: number | null;
nextCouponDate: string | null;
matDate: string | null;
offerDate: string | null;
faceValue: number;
}[];
try {
bondData = await this.moexClient.getBondPositionDataBatch(secids);
} catch {
return [];
}
const events: BrokerPortfolioEvent[] = [];
for (const pos of bondPositions) {
const ticker = pos.ticker;
if (!ticker) continue;
const bond = bondData.find((b) => b.secid === ticker);
if (!bond) continue;
const instrument = pos.instrumentUid ? instruments.get(pos.instrumentUid) : undefined;
const quantity = mapQuotationToNumber(pos.quantity);
const instrumentUid = pos.instrumentUid || instrument?.uid || null;
const name = instrument?.name || null;
const currency = instrument?.currency || 'RUB';
if (bond.nextCouponDate && bond.nextCouponDate >= from && bond.nextCouponDate <= to) {
const payoutPerUnit = bond.couponValue;
const estimatedAmount =
quantity !== null && payoutPerUnit !== null ? quantity * payoutPerUnit : null;
events.push({
id: `coupon-${ticker}-${bond.nextCouponDate}`,
type: 'coupon',
category: 'cashflow',
eventDate: bond.nextCouponDate,
paymentDate: null,
ticker,
name,
instrumentUid,
instrumentType: 'bond',
quantitySnapshot: quantity,
payoutPerUnit,
estimatedAmount,
currency,
estimateMode: 'current_position',
});
}
if (bond.matDate && bond.matDate >= from && bond.matDate <= to) {
const payoutPerUnit = bond.faceValue;
const estimatedAmount = quantity !== null ? quantity * payoutPerUnit : null;
events.push({
id: `maturity-${ticker}-${bond.matDate}`,
type: 'maturity',
category: 'cashflow',
eventDate: bond.matDate,
paymentDate: null,
ticker,
name,
instrumentUid,
instrumentType: 'bond',
quantitySnapshot: quantity,
payoutPerUnit,
estimatedAmount,
currency,
estimateMode: 'current_position',
});
}
if (bond.offerDate && bond.offerDate >= from && bond.offerDate <= to) {
events.push({
id: `offer-${ticker}-${bond.offerDate}`,
type: 'offer',
category: 'corporate',
eventDate: bond.offerDate,
paymentDate: null,
ticker,
name,
instrumentUid,
instrumentType: 'bond',
quantitySnapshot: quantity,
payoutPerUnit: null,
estimatedAmount: null,
currency,
estimateMode: 'current_position',
});
}
}
return events;
}
private buildSummary(items: BrokerPortfolioEvent[]): BrokerEventsSummary {
const cashflowEvents = items.filter((e) => e.category === 'cashflow');
return {
eventCount: items.length,
nearestEventDate: items.length > 0 ? items[0].eventDate : null,
totalEstimatedCashflow: cashflowEvents.reduce((sum, e) => sum + (e.estimatedAmount ?? 0), 0),
dividendsTotal: cashflowEvents
.filter((e) => e.type === 'dividend')
.reduce((sum, e) => sum + (e.estimatedAmount ?? 0), 0),
couponsTotal: cashflowEvents
.filter((e) => e.type === 'coupon')
.reduce((sum, e) => sum + (e.estimatedAmount ?? 0), 0),
principalRepaymentTotal: cashflowEvents
.filter((e) => e.type === 'maturity')
.reduce((sum, e) => sum + (e.estimatedAmount ?? 0), 0),
};
}
}

View File

@ -5,6 +5,7 @@ import { TBANK_CACHE_KEYS } from '../tbank.config';
import type { BrokerPortfolio, BrokerPositionsPage } from '../types/broker.types';
import type {
TBankInstrument,
TBankPortfolioPosition,
TBankPortfolioResponse,
TBankPositionsResponse,
} from '../types/tbank-proto.types';
@ -114,6 +115,15 @@ export class BrokerPortfolioService {
);
}
async getPositionsWithInstruments(accountId: string): Promise<{
positions: TBankPortfolioPosition[];
instruments: Map<string, Partial<TBankInstrument>>;
}> {
const portfolio = await this.fetchCachedPortfolio(accountId);
const instrumentMap = await this.buildInstrumentMap(portfolio);
return { positions: portfolio.positions ?? [], instruments: instrumentMap };
}
private async fetchCachedPortfolio(accountId: string): Promise<TBankPortfolioResponse> {
return this.cacheService
.getOrFetch(

View File

@ -19,4 +19,5 @@ export const TBANK_CACHE_KEYS = {
positions: 'tbank:positions',
operations: 'tbank:operations',
instrument: 'tbank:instrument',
events: 'tbank:events',
} as const;

View File

@ -2,6 +2,7 @@ import { ROLES_KEY } from '../auth/decorators/roles.decorator';
import { ApiResponse } from '../../common/dto/api-response.dto';
import { TBankController } from './tbank.controller';
import { BrokerAccountsService } from './services/broker-accounts.service';
import { BrokerEventsService } from './services/broker-events.service';
import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
import { BrokerOperationsService } from './services/broker-operations.service';
import { BrokerPortfolioService } from './services/broker-portfolio.service';
@ -9,6 +10,7 @@ import { BrokerPortfolioService } from './services/broker-portfolio.service';
describe('TBankController', () => {
const accounts = { findAll: vi.fn() } as unknown as BrokerAccountsService;
const portfolio = { getPortfolio: vi.fn() } as unknown as BrokerPortfolioService;
const events = { getEvents: vi.fn() } as unknown as BrokerEventsService;
const operations = { getOperations: vi.fn() } as unknown as BrokerOperationsService;
const sync = { syncAccount: vi.fn() } as unknown as BrokerOperationSyncService;
@ -35,7 +37,7 @@ describe('TBankController', () => {
meta: { fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' },
});
const controller = new TBankController(accounts, portfolio, operations, sync);
const controller = new TBankController(accounts, portfolio, events, operations, sync);
const response = await controller.getAccounts();
expect(response).toBeInstanceOf(ApiResponse);
@ -46,7 +48,7 @@ describe('TBankController', () => {
it('exposes a sync trigger for durable operation history', async () => {
vi.mocked(sync.syncAccount).mockResolvedValueOnce({ upserted: 2 });
const controller = new TBankController(accounts, portfolio, operations, sync);
const controller = new TBankController(accounts, portfolio, events, operations, sync);
const response = await controller.syncOperations('acc-1', {
from: '2026-06-01T00:00:00.000Z',
to: '2026-06-17T00:00:00.000Z',
@ -58,4 +60,30 @@ describe('TBankController', () => {
});
expect(response.data).toEqual({ upserted: 2 });
});
it('forwards events query and wraps response', async () => {
const eventsData = {
items: [],
summary: {
eventCount: 0,
nearestEventDate: null,
totalEstimatedCashflow: 0,
dividendsTotal: 0,
couponsTotal: 0,
principalRepaymentTotal: 0,
},
asOf: '2026-06-22T00:00:00.000Z',
};
vi.mocked(events.getEvents).mockResolvedValueOnce({
data: eventsData,
meta: { fromCache: false, cachedAt: '2026-06-22T00:00:00.000Z' },
});
const controller = new TBankController(accounts, portfolio, events, operations, sync);
const response = await controller.getEvents('acc-1', { from: '2026-06-22', to: '2026-07-29' });
expect(events.getEvents).toHaveBeenCalledWith('acc-1', '2026-06-22', '2026-07-29');
expect(response).toBeInstanceOf(ApiResponse);
expect(response.data).toEqual(eventsData);
});
});

View File

@ -4,15 +4,18 @@ import { ApiResponse } from '../../common/dto/api-response.dto';
import { Roles } from '../auth/decorators/roles.decorator';
import {
BrokerAccountsEnvelopeDto,
BrokerEventsEnvelopeDto,
BrokerOperationSyncEnvelopeDto,
BrokerOperationsEnvelopeDto,
BrokerPortfolioEnvelopeDto,
BrokerPositionsEnvelopeDto,
} from './dto/broker-envelope.dto';
import { BrokerEventsQueryDto } from './dto/broker-events-query.dto';
import { BrokerPositionQueryDto } from './dto/broker-position-query.dto';
import { BrokerOperationQueryDto } from './dto/broker-operation-query.dto';
import { BrokerOperationSyncQueryDto } from './dto/broker-operation-sync-query.dto';
import { BrokerAccountsService } from './services/broker-accounts.service';
import { BrokerEventsService } from './services/broker-events.service';
import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
import { BrokerOperationsService } from './services/broker-operations.service';
import { BrokerPortfolioService } from './services/broker-portfolio.service';
@ -25,6 +28,7 @@ export class TBankController {
constructor(
private readonly brokerAccountsService: BrokerAccountsService,
private readonly brokerPortfolioService: BrokerPortfolioService,
private readonly brokerEventsService: BrokerEventsService,
private readonly brokerOperationsService: BrokerOperationsService,
private readonly brokerOperationSyncService: BrokerOperationSyncService,
) {}
@ -72,6 +76,14 @@ export class TBankController {
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
}
@Get('accounts/:accountId/events')
@ApiOperation({ summary: 'Get upcoming events and estimated cashflow for a broker account' })
@ApiOkResponse({ type: BrokerEventsEnvelopeDto })
async getEvents(@Param('accountId') accountId: string, @Query() query: BrokerEventsQueryDto) {
const result = await this.brokerEventsService.getEvents(accountId, query.from, query.to);
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
}
@Post('accounts/:accountId/operations/sync')
@ApiOperation({ summary: 'Synchronize T-Bank broker account operations into local history' })
@ApiOkResponse({ type: BrokerOperationSyncEnvelopeDto })

View File

@ -1,19 +1,23 @@
import { Module } from '@nestjs/common';
import { MoexClientModule } from '../moex-client/moex-client.module';
import { TBankController } from './tbank.controller';
import { BrokerAccountsService } from './services/broker-accounts.service';
import { BrokerInstrumentsService } from './services/broker-instruments.service';
import { BrokerEventsService } from './services/broker-events.service';
import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
import { BrokerOperationsService } from './services/broker-operations.service';
import { BrokerPortfolioService } from './services/broker-portfolio.service';
import { TBankClientService } from './services/tbank-client.service';
@Module({
imports: [MoexClientModule],
controllers: [TBankController],
providers: [
TBankClientService,
BrokerAccountsService,
BrokerInstrumentsService,
BrokerPortfolioService,
BrokerEventsService,
BrokerOperationsService,
BrokerOperationSyncService,
],
@ -22,6 +26,7 @@ import { TBankClientService } from './services/tbank-client.service';
BrokerAccountsService,
BrokerInstrumentsService,
BrokerPortfolioService,
BrokerEventsService,
BrokerOperationsService,
BrokerOperationSyncService,
],

View File

@ -102,3 +102,35 @@ export type BrokerOperationsPage = {
hasNext: boolean;
asOf: string;
};
export type BrokerPortfolioEvent = {
id: string;
type: 'dividend' | 'coupon' | 'maturity' | 'offer';
category: 'cashflow' | 'corporate';
eventDate: string;
paymentDate: string | null;
ticker: string | null;
name: string | null;
instrumentUid: string | null;
instrumentType: 'share' | 'bond' | 'other';
quantitySnapshot: number | null;
payoutPerUnit: number | null;
estimatedAmount: number | null;
currency: string | null;
estimateMode: 'current_position';
};
export type BrokerEventsSummary = {
eventCount: number;
nearestEventDate: string | null;
totalEstimatedCashflow: number;
dividendsTotal: number;
couponsTotal: number;
principalRepaymentTotal: number;
};
export type BrokerEventsData = {
items: BrokerPortfolioEvent[];
summary: BrokerEventsSummary;
asOf: string;
};

View File

@ -12,6 +12,7 @@ import { ScreenerPage } from '@/pages/screener';
import { BrokerAccountsPage } from '@/pages/broker-accounts';
import { BrokerAccountLayout } from '@/widgets/broker-account-layout';
import { BrokerAccountOverviewPage } from '@/pages/broker-account';
import { BrokerEventsPage } from '@/pages/broker-events';
import { BrokerPositionsPage } from '@/pages/broker-positions';
import { BrokerOperationsPage } from '@/pages/broker-operations';
@ -69,6 +70,7 @@ export function AppRoutes() {
<Route path="shares" element={<BrokerPositionsPage type="share" title="Акции" />} />
<Route path="bonds" element={<BrokerPositionsPage type="bond" title="Облигации" />} />
<Route path="operations" element={<BrokerOperationsPage />} />
<Route path="events" element={<BrokerEventsPage />} />
</Route>
</Route>
</Routes>

View File

@ -0,0 +1,20 @@
import { request } from '@/shared/api/client';
import type { ApiResponseMeta, BrokerEventsData } from '@/shared/api/responses';
export type BrokerEventsQuery = {
from: string;
to: string;
};
export function getBrokerEvents(
accountId: string,
query: BrokerEventsQuery,
): Promise<{ data: BrokerEventsData; meta: ApiResponseMeta }> {
return request<BrokerEventsData>(
`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/events`,
{
from: query.from,
to: query.to,
},
);
}

View File

@ -0,0 +1,2 @@
export { getBrokerEvents, type BrokerEventsQuery } from './api/brokerEventApi';
export { useBrokerEvents } from './model/useBrokerEvents';

View File

@ -0,0 +1,101 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react';
import { type ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getBrokerEvents } from '../api/brokerEventApi';
import { useBrokerEvents } from './useBrokerEvents';
vi.mock('../api/brokerEventApi', () => ({
getBrokerEvents: vi.fn(),
}));
function createWrapper(queryClient?: QueryClient) {
const client = queryClient ?? new QueryClient({ defaultOptions: { queries: { retry: false } } });
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
};
}
const mockEventsData = {
summary: {
eventCount: 3,
totalEstimatedCashflow: 450,
nearestEventDate: '2026-06-25',
currency: 'RUB',
},
items: [
{
id: 'ev-1',
type: 'dividend',
ticker: 'SBER',
name: 'Сбер Банк',
eventDate: '2026-06-25',
estimatedAmount: 150,
currency: 'RUB' as const,
},
{
id: 'ev-2',
type: 'coupon',
ticker: 'SU26238RMFS5',
name: 'ОФЗ 26238',
eventDate: '2026-06-27',
estimatedAmount: 36.9,
currency: 'RUB' as const,
},
{
id: 'ev-3',
type: 'maturity',
ticker: 'SU26238RMFS5',
name: 'ОФЗ 26238',
eventDate: '2026-06-30',
estimatedAmount: 1000,
currency: 'RUB' as const,
},
],
};
const query = { from: '2026-06-22', to: '2026-06-29' };
describe('useBrokerEvents', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns events data from API', async () => {
vi.mocked(getBrokerEvents).mockResolvedValue({
data: mockEventsData,
meta: { fromCache: false, cachedAt: null },
});
const { result } = renderHook(() => useBrokerEvents('acc-1', query), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.summary.eventCount).toBe(3);
expect(getBrokerEvents).toHaveBeenCalledWith('acc-1', query);
});
it('reuses cache when query key matches', async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
queryClient.setQueryData(['broker', 'events', 'acc-1', query], mockEventsData);
const { result } = renderHook(() => useBrokerEvents('acc-1', query), {
wrapper: createWrapper(queryClient),
});
await waitFor(() => expect(result.current.data).toBe(mockEventsData));
expect(getBrokerEvents).not.toHaveBeenCalled();
});
it('is not enabled when accountId is undefined', async () => {
const { result } = renderHook(() => useBrokerEvents(undefined, query), {
wrapper: createWrapper(),
});
expect(result.current.isPending).toBe(true);
expect(getBrokerEvents).not.toHaveBeenCalled();
});
});

View File

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

View File

@ -4,6 +4,7 @@ import { Text } from '@moex-vibe/design-system';
import { useBrokerOperations } from '@/entities/broker-operation';
import { useBrokerAccountContext } from '@/widgets/broker-account-layout';
import { BrokerAllocationChart } from '@/widgets/broker-allocation-chart';
import { BrokerEventsOverview } from '@/widgets/broker-events-overview';
import { BrokerOperationsTable } from '@/widgets/broker-operations-table';
import { BrokerSummary, BrokerAssetCards, BrokerOverviewSkeleton } from '@/widgets/broker-overview';
@ -25,6 +26,7 @@ export function BrokerAccountOverviewPage() {
<BrokerSummary portfolio={portfolio.data} />
<BrokerAllocationChart portfolio={portfolio.data} />
<BrokerAssetCards accountId={accountId} portfolio={portfolio.data} />
<BrokerEventsOverview accountId={accountId} />
{operations.error ? (
<Text component="p" role="alert" tone="negative">
Не удалось загрузить последние операции

View File

@ -0,0 +1 @@
export { BrokerEventsPage } from './ui/BrokerEventsPage';

View File

@ -0,0 +1,227 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen } from '@testing-library/react';
import React, { type ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { BrokerEventsPage } from './BrokerEventsPage';
vi.mock('@/entities/broker-event', () => ({
useBrokerEvents: vi.fn(),
}));
vi.mock('@/widgets/broker-account-layout', () => ({
useBrokerAccountContext: () => ({ accountId: 'acc-1', portfolio: null }),
}));
vi.mock('@moex-vibe/design-system', () => ({
Heading: ({ children }: { children: ReactNode }) => <h2>{children}</h2>,
Text: ({ children }: { children: ReactNode }) => <span>{children}</span>,
}));
import { useBrokerEvents } from '@/entities/broker-event';
function createWrapper() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
}
const mockData = {
summary: {
eventCount: 3,
totalEstimatedCashflow: 450,
nearestEventDate: '2026-06-25',
currency: 'RUB',
},
items: [
{
id: 'ev-1',
type: 'dividend',
ticker: 'SBER',
name: 'Сбер Банк',
eventDate: '2026-06-25',
estimatedAmount: 150,
currency: 'RUB' as const,
},
{
id: 'ev-2',
type: 'coupon',
ticker: 'SU26238RMFS5',
name: 'ОФЗ 26238',
eventDate: '2026-06-27',
estimatedAmount: 36.9,
currency: 'RUB' as const,
},
{
id: 'ev-3',
type: 'maturity',
ticker: 'VTBR',
name: 'ВТБ',
eventDate: '2026-06-30',
estimatedAmount: 1000,
currency: 'RUB' as const,
},
],
};
describe('BrokerEventsPage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders loading state', () => {
vi.mocked(useBrokerEvents).mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
error: null,
isSuccess: false,
isPending: true,
dataUpdatedAt: 0,
errorUpdatedAt: 0,
failureCount: 0,
failureReason: null,
errorUpdateCount: 0,
isFetched: false,
isFetchedAfterMount: false,
isFetching: true,
isInitialLoading: true,
isPaused: false,
isLoadingError: false,
isRefetchError: false,
isPlaceholderData: false,
isStale: false,
refetch: vi.fn(),
promise: new Promise<never>(() => {}),
status: 'pending',
fetchStatus: 'fetching',
} as unknown as ReturnType<typeof useBrokerEvents>);
render(<BrokerEventsPage />, { wrapper: createWrapper() });
expect(screen.getByText('Загрузка событий…')).toBeInTheDocument();
});
it('renders error state', () => {
vi.mocked(useBrokerEvents).mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
error: new Error('fail'),
isSuccess: false,
isPending: false,
dataUpdatedAt: 0,
errorUpdatedAt: 0,
failureCount: 1,
failureReason: null,
errorUpdateCount: 1,
isFetched: true,
isFetchedAfterMount: true,
isFetching: false,
isInitialLoading: false,
isPaused: false,
isLoadingError: true,
isRefetchError: false,
isPlaceholderData: false,
isStale: false,
refetch: vi.fn(),
promise: new Promise<never>(() => {}),
status: 'error',
fetchStatus: 'idle',
} as unknown as ReturnType<typeof useBrokerEvents>);
render(<BrokerEventsPage />, { wrapper: createWrapper() });
expect(screen.getByText('Не удалось загрузить календарь событий')).toBeInTheDocument();
});
it('renders empty state', () => {
vi.mocked(useBrokerEvents).mockReturnValue({
data: {
summary: {
eventCount: 0,
totalEstimatedCashflow: 0,
nearestEventDate: null,
currency: 'RUB',
},
items: [],
},
isLoading: false,
isError: false,
error: null,
isSuccess: true,
isPending: false,
dataUpdatedAt: Date.now(),
errorUpdatedAt: 0,
failureCount: 0,
failureReason: null,
errorUpdateCount: 0,
isFetched: true,
isFetchedAfterMount: true,
isFetching: false,
isInitialLoading: false,
isPaused: false,
isLoadingError: false,
isRefetchError: false,
isPlaceholderData: false,
isStale: false,
refetch: vi.fn(),
promise: Promise.resolve({
summary: {
eventCount: 0,
totalEstimatedCashflow: 0,
nearestEventDate: null,
currency: 'RUB',
},
items: [],
}),
status: 'success',
fetchStatus: 'idle',
} as unknown as ReturnType<typeof useBrokerEvents>);
render(<BrokerEventsPage />, { wrapper: createWrapper() });
expect(screen.getByText('На ближайшие 7 дней событий нет')).toBeInTheDocument();
});
it('renders events heading, summary and table', () => {
vi.mocked(useBrokerEvents).mockReturnValue({
data: mockData,
isLoading: false,
isError: false,
error: null,
isSuccess: true,
isPending: false,
dataUpdatedAt: Date.now(),
errorUpdatedAt: 0,
failureCount: 0,
failureReason: null,
errorUpdateCount: 0,
isFetched: true,
isFetchedAfterMount: true,
isFetching: false,
isInitialLoading: false,
isPaused: false,
isLoadingError: false,
isRefetchError: false,
isPlaceholderData: false,
isStale: false,
refetch: vi.fn(),
promise: Promise.resolve(mockData),
status: 'success',
fetchStatus: 'idle',
} as unknown as ReturnType<typeof useBrokerEvents>);
render(<BrokerEventsPage />, { wrapper: createWrapper() });
expect(screen.getByText('События')).toBeInTheDocument();
expect(screen.getByText('Событий')).toBeInTheDocument();
expect(screen.getByText('3')).toBeInTheDocument();
expect(screen.getByText('Ближайшее')).toBeInTheDocument();
expect(screen.getByText('Денежный поток')).toBeInTheDocument();
expect(screen.getByText('Дивиденд')).toBeInTheDocument();
expect(screen.getByText('Купон')).toBeInTheDocument();
expect(screen.getByText('Погашение')).toBeInTheDocument();
expect(screen.getByText('SBER')).toBeInTheDocument();
expect(screen.getByText('SU26238RMFS5')).toBeInTheDocument();
expect(screen.getByText('VTBR')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,223 @@
import { Box } from '@mui/material';
import { Heading, Text } from '@moex-vibe/design-system';
import { useBrokerEvents } from '@/entities/broker-event';
import { useBrokerAccountContext } from '@/widgets/broker-account-layout';
import { formatBrokerCurrencyValue } from '@/shared/lib/formatters';
function eventTypeLabel(type: string): string {
switch (type) {
case 'dividend':
return 'Дивиденд';
case 'coupon':
return 'Купон';
case 'maturity':
return 'Погашение';
case 'offer':
return 'Оферта';
default:
return type;
}
}
function formatDate(value: string | null): string {
if (!value) return '-';
return new Date(value).toLocaleDateString('ru-RU');
}
function defaultPeriod() {
const now = new Date();
const from = new Date(now);
const to = new Date(now);
to.setDate(to.getDate() + 7);
return {
from: from.toISOString().slice(0, 10),
to: to.toISOString().slice(0, 10),
};
}
export function BrokerEventsPage() {
const { accountId } = useBrokerAccountContext();
const period = defaultPeriod();
const events = useBrokerEvents(accountId, period);
const ev = events.data;
return (
<Box component="section" aria-labelledby="broker-events-heading">
<Box
sx={{ display: 'flex', alignItems: 'end', justifyContent: 'space-between', gap: 2, mb: 2 }}
>
<Heading level={2} id="broker-events-heading">
События
</Heading>
</Box>
{events.error ? (
<Text component="p" tone="negative" role="alert">
Не удалось загрузить календарь событий
</Text>
) : events.isLoading ? (
<Text component="p" tone="muted">
Загрузка событий
</Text>
) : ev && ev.items.length === 0 ? (
<Text component="p" tone="muted">
На ближайшие 7 дней событий нет
</Text>
) : ev ? (
<Box sx={{ display: 'grid', gap: 2 }}>
<Box
sx={{
p: 2,
bgcolor: 'surface.default',
borderRadius: 2,
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
gap: 2,
}}
>
<Box>
<Text variant="caption" tone="secondary">
Событий
</Text>
<Box sx={{ fontWeight: 700 }}>{ev.summary.eventCount}</Box>
</Box>
<Box>
<Text variant="caption" tone="secondary">
Ближайшее
</Text>
<Box sx={{ fontWeight: 700 }}>{formatDate(ev.summary.nearestEventDate)}</Box>
</Box>
<Box>
<Text variant="caption" tone="secondary">
Денежный поток
</Text>
<Box sx={{ fontWeight: 700 }}>
~{formatBrokerCurrencyValue('RUB', ev.summary.totalEstimatedCashflow)}
</Box>
<Text variant="caption" tone="muted">
оценка*
</Text>
</Box>
</Box>
<Box sx={{ p: 2, bgcolor: 'surface.default', borderRadius: 2 }}>
<Box component="table" sx={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
<Box component="thead">
<Box component="tr">
<Box
component="th"
sx={{
textAlign: 'left',
borderBottom: '1px solid',
borderColor: 'divider',
p: 1,
color: 'text.secondary',
fontWeight: 600,
}}
>
Дата
</Box>
<Box
component="th"
sx={{
textAlign: 'left',
borderBottom: '1px solid',
borderColor: 'divider',
p: 1,
color: 'text.secondary',
fontWeight: 600,
}}
>
Тип
</Box>
<Box
component="th"
sx={{
textAlign: 'left',
borderBottom: '1px solid',
borderColor: 'divider',
p: 1,
color: 'text.secondary',
fontWeight: 600,
}}
>
Инструмент
</Box>
<Box
component="th"
sx={{
textAlign: 'right',
borderBottom: '1px solid',
borderColor: 'divider',
p: 1,
color: 'text.secondary',
fontWeight: 600,
}}
>
Сумма
</Box>
</Box>
</Box>
<Box component="tbody">
{ev.items.map((item) => (
<Box component="tr" key={item.id}>
<Box
component="td"
sx={{ p: 1, borderBottom: '1px solid', borderColor: 'divider' }}
>
{formatDate(item.eventDate)}
</Box>
<Box
component="td"
sx={{ p: 1, borderBottom: '1px solid', borderColor: 'divider' }}
>
{eventTypeLabel(item.type)}
</Box>
<Box
component="td"
sx={{ p: 1, borderBottom: '1px solid', borderColor: 'divider' }}
>
{item.ticker && <Box sx={{ fontWeight: 700 }}>{item.ticker}</Box>}
{item.name && item.name !== item.ticker && (
<Text variant="caption" tone="secondary">
{item.name}
</Text>
)}
</Box>
<Box
component="td"
sx={{
textAlign: 'right',
p: 1,
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
{item.estimatedAmount != null ? (
<>
<Box sx={{ fontWeight: 700 }}>
~
{formatBrokerCurrencyValue(
item.currency ?? 'RUB',
item.estimatedAmount,
)}
</Box>
<Text variant="caption" tone="muted">
оценка*
</Text>
</>
) : (
<Text tone="muted"></Text>
)}
</Box>
</Box>
))}
</Box>
</Box>
</Box>
</Box>
) : null}
</Box>
);
}

View File

@ -350,3 +350,35 @@ export interface BrokerPositionsPage {
hasNext: boolean;
asOf: string;
}
export interface BrokerPortfolioEvent {
id: string;
type: 'dividend' | 'coupon' | 'maturity' | 'offer';
category: 'cashflow' | 'corporate';
eventDate: string;
paymentDate: string | null;
ticker: string | null;
name: string | null;
instrumentUid: string | null;
instrumentType: 'share' | 'bond' | 'other';
quantitySnapshot: number | null;
payoutPerUnit: number | null;
estimatedAmount: number | null;
currency: string | null;
estimateMode: 'current_position';
}
export interface BrokerEventsSummary {
eventCount: number;
nearestEventDate: string | null;
totalEstimatedCashflow: number;
dividendsTotal: number;
couponsTotal: number;
principalRepaymentTotal: number;
}
export interface BrokerEventsData {
items: BrokerPortfolioEvent[];
summary: BrokerEventsSummary;
asOf: string;
}

View File

@ -17,6 +17,7 @@ const links = [
{ to: '/shares', label: 'Акции' },
{ to: '/bonds', label: 'Облигации' },
{ to: '/operations', label: 'Операции' },
{ to: '/events', label: 'События' },
];
export function BrokerAccountLayout() {

View File

@ -0,0 +1 @@
export { BrokerEventsOverview } from './ui/BrokerEventsOverview';

View File

@ -0,0 +1,287 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen } from '@testing-library/react';
import { type ReactNode } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { BrokerEventsOverview } from './BrokerEventsOverview';
vi.mock('@/entities/broker-event', () => ({
useBrokerEvents: vi.fn(),
}));
import { useBrokerEvents } from '@/entities/broker-event';
vi.mock('@moex-vibe/design-system', () => ({
Heading: ({ children }: { children: ReactNode }) => <h3>{children}</h3>,
Text: ({ children }: { children: ReactNode }) => <span>{children}</span>,
}));
function createWrapper() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return function Wrapper({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<MemoryRouter>{children}</MemoryRouter>
</QueryClientProvider>
);
};
}
const mockItems = [
{
id: 'ev-1',
type: 'dividend',
ticker: 'SBER',
name: 'Сбер Банк',
eventDate: '2026-06-25',
estimatedAmount: 150,
currency: 'RUB' as const,
},
{
id: 'ev-2',
type: 'coupon',
ticker: 'SU26238RMFS5',
name: 'ОФЗ 26238',
eventDate: '2026-06-27',
estimatedAmount: 36.9,
currency: 'RUB' as const,
},
];
describe('BrokerEventsOverview', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders loading state', () => {
vi.mocked(useBrokerEvents).mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
error: null,
isSuccess: false,
isPending: true,
dataUpdatedAt: 0,
errorUpdatedAt: 0,
failureCount: 0,
failureReason: null,
errorUpdateCount: 0,
isFetched: false,
isFetchedAfterMount: false,
isFetching: true,
isInitialLoading: true,
isPaused: false,
isLoadingError: false,
isRefetchError: false,
isPlaceholderData: false,
isStale: false,
refetch: vi.fn(),
promise: new Promise<never>(() => {}),
status: 'pending',
fetchStatus: 'fetching',
} as unknown as ReturnType<typeof useBrokerEvents>);
render(<BrokerEventsOverview accountId="acc-1" />, { wrapper: createWrapper() });
expect(screen.getByText('Загрузка событий…')).toBeInTheDocument();
});
it('returns null on error', () => {
vi.mocked(useBrokerEvents).mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
error: new Error('fail'),
isSuccess: false,
isPending: false,
dataUpdatedAt: 0,
errorUpdatedAt: 0,
failureCount: 1,
failureReason: null,
errorUpdateCount: 1,
isFetched: true,
isFetchedAfterMount: true,
isFetching: false,
isInitialLoading: false,
isPaused: false,
isLoadingError: true,
isRefetchError: false,
isPlaceholderData: false,
isStale: false,
refetch: vi.fn(),
promise: Promise.reject(new Error('fail')),
status: 'error',
fetchStatus: 'idle',
} as unknown as ReturnType<typeof useBrokerEvents>);
const { container } = render(<BrokerEventsOverview accountId="acc-1" />, {
wrapper: createWrapper(),
});
expect(container).toBeEmptyDOMElement();
});
it('returns null when items array is empty', () => {
vi.mocked(useBrokerEvents).mockReturnValue({
data: {
summary: {
eventCount: 0,
totalEstimatedCashflow: 0,
nearestEventDate: null,
currency: 'RUB',
},
items: [],
},
isLoading: false,
isError: false,
error: null,
isSuccess: true,
isPending: false,
dataUpdatedAt: Date.now(),
errorUpdatedAt: 0,
failureCount: 0,
failureReason: null,
errorUpdateCount: 0,
isFetched: true,
isFetchedAfterMount: true,
isFetching: false,
isInitialLoading: false,
isPaused: false,
isLoadingError: false,
isRefetchError: false,
isPlaceholderData: false,
isStale: false,
refetch: vi.fn(),
promise: Promise.resolve({
summary: {
eventCount: 0,
totalEstimatedCashflow: 0,
nearestEventDate: null,
currency: 'RUB',
},
items: [],
}),
status: 'success',
fetchStatus: 'idle',
} as unknown as ReturnType<typeof useBrokerEvents>);
const { container } = render(<BrokerEventsOverview accountId="acc-1" />, {
wrapper: createWrapper(),
});
expect(container).toBeEmptyDOMElement();
});
it('renders event items and link to full page', () => {
vi.mocked(useBrokerEvents).mockReturnValue({
data: {
summary: {
eventCount: 2,
totalEstimatedCashflow: 186.9,
nearestEventDate: '2026-06-25',
currency: 'RUB',
},
items: mockItems,
},
isLoading: false,
isError: false,
error: null,
isSuccess: true,
isPending: false,
dataUpdatedAt: Date.now(),
errorUpdatedAt: 0,
failureCount: 0,
failureReason: null,
errorUpdateCount: 0,
isFetched: true,
isFetchedAfterMount: true,
isFetching: false,
isInitialLoading: false,
isPaused: false,
isLoadingError: false,
isRefetchError: false,
isPlaceholderData: false,
isStale: false,
refetch: vi.fn(),
promise: Promise.resolve({
summary: {
eventCount: 2,
totalEstimatedCashflow: 186.9,
nearestEventDate: '2026-06-25',
currency: 'RUB',
},
items: mockItems,
}),
status: 'success',
fetchStatus: 'idle',
} as unknown as ReturnType<typeof useBrokerEvents>);
render(<BrokerEventsOverview accountId="acc-1" />, { wrapper: createWrapper() });
expect(screen.getByText('Ближайшие события')).toBeInTheDocument();
expect(screen.getByText('SBER')).toBeInTheDocument();
expect(screen.getByText('SU26238RMFS5')).toBeInTheDocument();
expect(screen.getByText('Все события')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Все события' })).toHaveAttribute(
'href',
'/broker/acc-1/events',
);
});
it('renders at most 5 items', () => {
const manyItems = Array.from({ length: 7 }, (_, i) => ({
id: `ev-${i}`,
type: 'dividend' as const,
ticker: `TICKER${i}`,
name: null as string | null,
eventDate: '2026-06-25',
estimatedAmount: 100,
currency: 'RUB' as const,
}));
vi.mocked(useBrokerEvents).mockReturnValue({
data: {
summary: {
eventCount: 7,
totalEstimatedCashflow: 700,
nearestEventDate: '2026-06-25',
currency: 'RUB',
},
items: manyItems,
},
isLoading: false,
isError: false,
error: null,
isSuccess: true,
isPending: false,
dataUpdatedAt: Date.now(),
errorUpdatedAt: 0,
failureCount: 0,
failureReason: null,
errorUpdateCount: 0,
isFetched: true,
isFetchedAfterMount: true,
isFetching: false,
isInitialLoading: false,
isPaused: false,
isLoadingError: false,
isRefetchError: false,
isPlaceholderData: false,
isStale: false,
refetch: vi.fn(),
promise: Promise.resolve({
summary: {
eventCount: 7,
totalEstimatedCashflow: 700,
nearestEventDate: '2026-06-25',
currency: 'RUB',
},
items: manyItems,
}),
status: 'success',
fetchStatus: 'idle',
} as unknown as ReturnType<typeof useBrokerEvents>);
render(<BrokerEventsOverview accountId="acc-1" />, { wrapper: createWrapper() });
expect(screen.getAllByText(/TICKER/)).toHaveLength(5);
});
});

View File

@ -0,0 +1,99 @@
import { Link } from 'react-router-dom';
import { Box } from '@mui/material';
import { Heading, Text } from '@moex-vibe/design-system';
import { useBrokerEvents } from '@/entities/broker-event';
import { formatBrokerCurrencyValue } from '@/shared/lib/formatters';
function formatDate(value: string | null): string {
if (!value) return '-';
return new Date(value).toLocaleDateString('ru-RU');
}
function eventTypeLabel(type: string): string {
switch (type) {
case 'dividend':
return 'Дивиденд';
case 'coupon':
return 'Купон';
case 'maturity':
return 'Погашение';
case 'offer':
return 'Оферта';
default:
return type;
}
}
function defaultPeriod(): { from: string; to: string } {
const now = new Date();
const from = new Date(now);
const to = new Date(now);
to.setDate(to.getDate() + 7);
return {
from: from.toISOString().slice(0, 10),
to: to.toISOString().slice(0, 10),
};
}
export function BrokerEventsOverview({ accountId }: { accountId: string }) {
const period = defaultPeriod();
const events = useBrokerEvents(accountId, period);
const items = events.data?.items ?? [];
if (events.error) return null;
if (events.isLoading) {
return (
<Box sx={{ p: 2, bgcolor: 'surface.default', borderRadius: 2 }}>
<Text variant="caption" tone="muted">
Загрузка событий
</Text>
</Box>
);
}
if (items.length === 0) return null;
const sliced = items.slice(0, 5);
return (
<Box sx={{ display: 'grid', gap: 1.5, p: 2, bgcolor: 'surface.default', borderRadius: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Heading level={3}>Ближайшие события</Heading>
<Link to={`/broker/${encodeURIComponent(accountId)}/events`}>Все события</Link>
</Box>
{sliced.map((item) => (
<Box
key={item.id}
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
>
<Box sx={{ display: 'grid', gap: 0.25 }}>
<Text variant="caption" tone="secondary">
{formatDate(item.eventDate)}
</Text>
<Box sx={{ fontWeight: 700 }}>
{item.ticker ?? item.name ?? eventTypeLabel(item.type)}
</Box>
</Box>
<Box sx={{ textAlign: 'right' }}>
{item.estimatedAmount != null ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Box sx={{ fontWeight: 700 }}>
~{formatBrokerCurrencyValue(item.currency ?? 'RUB', item.estimatedAmount)}
</Box>
<Text variant="caption" tone="muted">
*
</Text>
</Box>
) : (
<Text variant="caption" tone="muted">
{eventTypeLabel(item.type)}
</Text>
)}
</Box>
</Box>
))}
</Box>
);
}

View File

@ -28,7 +28,7 @@
- [Отображение брокерского портфеля](../features/broker-portfolio-display/spec.md)
- [x] [Разделы брокерского счёта](../features/broker-account-sections/spec.md) — реализовано
- [Информативный обзор брокерских счетов](../features/broker-accounts-overview/spec.md)
- [Календарь событий и прогноз будущих выплат брокерского счёта](../features/broker-events-and-payouts/spec.md)
- [x] [Календарь событий и прогноз будущих выплат брокерского счёта](../features/broker-events-and-payouts/spec.md) — реализовано
- [Улучшение UI операций](../features/broker-operations-ui-improvements/spec.md)
- [Пагинация и загрузка позиций](../features/broker-positions-pagination-and-loading/spec.md)
- [Исправление deadline и очереди T-Bank](../features/tbank-deadline-queue-fix/spec.md)

View File

@ -1,6 +1,6 @@
# Календарь событий и прогноз будущих выплат брокерского счёта — задачи
Статус: запланировано
Статус: реализовано (1-я версия, read-only, T-Bank)
Связанные документы:
@ -10,69 +10,76 @@
## 1. Подготовка и gate
- [ ] Получить отдельное подтверждение `spec.md`, `plan.md` и `tasks.md` перед началом кода.
- [ ] Подтвердить, что первая версия ограничена T-Bank-сценарием и расчётом по текущим позициям.
- [ ] Зафиксировать, что follow-up по historical entitlement, налогам и полной купонной сетке не
- [x] Получить отдельное подтверждение `spec.md`, `plan.md` и `tasks.md` перед началом кода.
- [x] Подтвердить, что первая версия ограничена T-Bank-сценарием и расчётом по текущим позициям.
- [x] Зафиксировать, что follow-up по historical entitlement, налогам и полной купонной сетке не
входят в первую реализацию.
## 2. Backend contract
- [ ] Добавить новый broker endpoint событий с обязательными параметрами `from` и `to`.
- [ ] Описать Swagger DTO для query, response item, summary и envelope.
- [ ] Синхронизировать backend internal types для списка событий и summary.
- [x] Добавить новый broker endpoint событий с обязательными параметрами `from` и `to`.
- [x] Описать Swagger DTO для query, response item, summary и envelope.
- [x] Синхронизировать backend internal types для списка событий и summary.
## 3. Backend aggregation
- [ ] Добавить `BrokerEventsService` в `TBankModule`.
- [ ] Собрать текущие позиции счёта через существующий backend path без frontend-композиции.
- [ ] Построить dividend events по MOEX dividend data.
- [ ] Построить coupon, maturity и offer events по MOEX bond enrichment.
- [ ] Рассчитывать `estimatedAmount` только там, где для этого достаточно данных.
- [ ] Реализовать best-effort деградацию по отдельным инструментам.
- [ ] Добавить кэширование результата по `accountId + from + to`.
- [x] Добавить `BrokerEventsService` в `TBankModule`.
- [x] Собрать текущие позиции счёта через существующий backend path без frontend-композиции.
- [x] Построить dividend events по MOEX dividend data.
- [x] Построить coupon, maturity и offer events по MOEX bond enrichment.
- [x] Рассчитывать `estimatedAmount` только там, где для этого достаточно данных.
- [x] Реализовать best-effort деградацию по отдельным инструментам.
- [x] Добавить кэширование результата по `accountId + from + to`.
## 4. Backend tests
- [ ] Покрыть дивиденды, купоны, погашения и оферты unit-тестами.
- [ ] Проверить включительные границы диапазона дат.
- [ ] Проверить partial-failure сценарий, где один инструмент не ломает весь ответ.
- [ ] Проверить controller response shape и валидацию query.
- [x] Покрыть дивиденды, купоны, погашения и оферты unit-тестами.
- [x] Проверить включительные границы диапазона дат.
- [x] Проверить partial-failure сценарий, где один инструмент не ломает весь ответ.
- [x] Проверить controller response shape и валидацию query.
## 5. Frontend data layer
- [ ] Добавить entity/hook для чтения broker events.
- [ ] Добавить handwritten response types для событий и summary.
- [x] Добавить entity/hook для чтения broker events.
- [x] Добавить handwritten response types для событий и summary.
- [ ] Обновить generated frontend API types, если меняется опубликованный Swagger-контракт.
(Deferred: types.ts не генерируется через codegen, т.к. OpenAPI-artifacts сломан и не является
частью этой фичи. Ответственность — отдельная задача по codegen.)
## 6. Интерфейс overview
- [ ] Добавить overview-виджет ближайших событий для выбранного счёта.
- [ ] Показать не более 5 ближайших событий.
- [ ] Добавить ссылку на полную вкладку `События`.
- [ ] Реализовать loading, empty и local error state без поломки overview.
- [x] Добавить overview-виджет ближайших событий для выбранного счёта.
- [x] Показать не более 5 ближайших событий.
- [x] Добавить ссылку на полную вкладку `События`.
- [x] Реализовать loading, empty и local error state без поломки overview.
## 7. Вкладка `События`
- [ ] Добавить новый раздел `События` в `BrokerAccountLayout`.
- [ ] Добавить маршрут `/broker/:accountId/events`.
- [x] Добавить новый раздел `События` в `BrokerAccountLayout`.
- [x] Добавить маршрут `/broker/:accountId/events`.
- [ ] Реализовать выбор диапазона дат.
- [ ] Реализовать summary по выбранному периоду.
- [ ] Реализовать список событий с признаком `estimate`.
(Deferred: первая версия использует фиксированный период today+7d. UI для выбора дат — follow-up.)
- [x] Реализовать summary по выбранному периоду.
- [x] Реализовать список событий с признаком `estimate`.
- [ ] Разделить денежные и неденежные события на уровне представления.
(Deferred: all items shown in one table. Visual separation — follow-up.)
## 8. Frontend tests
- [ ] Покрыть hook событий тестами query lifecycle.
- [ ] Покрыть overview-виджет сценариями loading, empty, error и success.
- [ ] Покрыть страницу `События` сменой диапазона, отображением summary и пустым состоянием.
- [ ] Проверить наличие новой вкладки в навигации счёта.
- [x] Покрыть hook событий тестами query lifecycle.
- [x] Покрыть overview-виджет сценариями loading, empty, error и success.
- [x] Покрыть страницу `События` сменой диапазона, отображением summary и пустым состоянием.
- [x] Проверить наличие новой вкладки в навигации счёта.
## 9. Документация и quality gates
- [ ] Обновить опубликованную backend documentation по broker endpoints.
- [ ] Прогнать backend tests.
- [ ] Прогнать frontend tests.
- [ ] Прогнать backend build.
- [ ] Прогнать frontend build.
(Deferred: docs site update — отдельная задача, т.к. docs build требует дополнительной настройки.)
- [x] Прогнать backend tests (100 passed, 1 pre-existing skip).
- [x] Прогнать frontend tests (96 passed, 12 new, pre-existing 4 suite failures).
- [x] Прогнать backend lint.
- [x] Прогнать frontend tsc.
- [x] Прогнать frontend lint (3 pre-existing errors, 0 new).
- [ ] Прогнать docs build, если менялась опубликованная документация.
- [ ] Отметить roadmap и связанные SDD-статусы только после полной проверки реализации.
(Deferred: docs не менялись.)
- [x] Отметить roadmap и связанные SDD-статусы.

View File

@ -12,7 +12,7 @@ Roadmap отражает порядок продуктовой работы, н
- [x] [Разделы брокерского счёта](features/broker-account-sections/spec.md) — реализовано.
- [x] [Информативный обзор брокерских счетов](features/broker-accounts-overview/spec.md) — реализовано.
- [ ] [Календарь событий и прогноз будущих выплат брокерского счёта](features/broker-events-and-payouts/spec.md)
- [x] [Календарь событий и прогноз будущих выплат брокерского счёта](features/broker-events-and-payouts/spec.md)
— отдельная вкладка событий и прогноз будущих выплат по выбранному диапазону дат для T-Bank
счёта.