feat: add broker events calendar and payout projections for T-Bank accounts
Some checks failed
CI / ci (push) Failing after 3m1s
Some checks failed
CI / ci (push) Failing after 3m1s
This commit is contained in:
parent
7b1d649853
commit
595d059151
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
263
apps/backend/src/modules/tbank/services/broker-events.service.ts
Normal file
263
apps/backend/src/modules/tbank/services/broker-events.service.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -19,4 +19,5 @@ export const TBANK_CACHE_KEYS = {
|
||||
positions: 'tbank:positions',
|
||||
operations: 'tbank:operations',
|
||||
instrument: 'tbank:instrument',
|
||||
events: 'tbank:events',
|
||||
} as const;
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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,
|
||||
],
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
2
apps/frontend/src/entities/broker-event/index.ts
Normal file
2
apps/frontend/src/entities/broker-event/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { getBrokerEvents, type BrokerEventsQuery } from './api/brokerEventApi';
|
||||
export { useBrokerEvents } from './model/useBrokerEvents';
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
@ -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">
|
||||
Не удалось загрузить последние операции
|
||||
|
||||
1
apps/frontend/src/pages/broker-events/index.ts
Normal file
1
apps/frontend/src/pages/broker-events/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { BrokerEventsPage } from './ui/BrokerEventsPage';
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
223
apps/frontend/src/pages/broker-events/ui/BrokerEventsPage.tsx
Normal file
223
apps/frontend/src/pages/broker-events/ui/BrokerEventsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@ const links = [
|
||||
{ to: '/shares', label: 'Акции' },
|
||||
{ to: '/bonds', label: 'Облигации' },
|
||||
{ to: '/operations', label: 'Операции' },
|
||||
{ to: '/events', label: 'События' },
|
||||
];
|
||||
|
||||
export function BrokerAccountLayout() {
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export { BrokerEventsOverview } from './ui/BrokerEventsOverview';
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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-статусы.
|
||||
|
||||
@ -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
|
||||
счёта.
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user