refactor: organize backend by business responsibility

This commit is contained in:
2026-09-13 13:52:57 +03:00
parent 7fde3ece6e
commit 8ba005346d
145 changed files with 3027 additions and 2346 deletions
@@ -0,0 +1,20 @@
jest.mock('../../../prisma/prisma.service', () => ({
PrismaService: class PrismaService {},
}));
import { UpcomingEventsController } from './upcoming-events.controller';
describe('UpcomingEventsController', () => {
it('passes the authenticated user and account to the service', async () => {
const events = { get: jest.fn() };
const controller = new UpcomingEventsController(events as never);
await controller.get({ user: { sub: 'user' } } as never, 'account', {
from: '2026-09-01',
to: '2026-09-30',
});
expect(events.get).toHaveBeenCalledWith('user', 'account', {
from: '2026-09-01',
to: '2026-09-30',
});
});
});
@@ -0,0 +1,38 @@
import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express';
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
import {
UpcomingEventsDto,
UpcomingEventsQueryDto,
} from './upcoming-events.dto';
import { UpcomingEventsService } from './upcoming-events.service';
@ApiTags('brokerage-accounts')
@ApiProblemResponses()
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('brokerage-accounts')
export class UpcomingEventsController {
constructor(private readonly events: UpcomingEventsService) {}
@Get('all/events')
@ApiOkResponse({ type: UpcomingEventsDto })
all(
@Req() request: Request & { user: AccessPayload },
@Query() query: UpcomingEventsQueryDto,
) {
return this.events.getAll(request.user.sub, query);
}
@Get(':accountId/events')
@ApiOkResponse({ type: UpcomingEventsDto })
get(
@Req() request: Request & { user: AccessPayload },
@Param('accountId') accountId: string,
@Query() query: UpcomingEventsQueryDto,
) {
return this.events.get(request.user.sub, accountId, query);
}
}
@@ -0,0 +1,74 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsOptional, Matches } from 'class-validator';
import { MoneyDto } from '../../../common/money.dto';
import { AccountReferenceDto } from '../account-reference.dto';
export class UpcomingEventsQueryDto {
@ApiPropertyOptional({
format: 'date',
description: 'Inclusive start date (YYYY-MM-DD). Defaults to today in UTC.',
})
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsDateString({ strict: true })
from?: string;
@ApiPropertyOptional({
format: 'date',
description:
'Inclusive end date (YYYY-MM-DD). Defaults to 30 days after from.',
})
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsDateString({ strict: true })
to?: string;
}
export class UpcomingEventDto {
@ApiPropertyOptional({ type: AccountReferenceDto })
account?: AccountReferenceDto;
@ApiProperty({ enum: ['coupon', 'dividend', 'maturity', 'offer'] })
declare type: 'coupon' | 'dividend' | 'maturity' | 'offer';
@ApiProperty({ format: 'date' })
declare date: string;
@ApiProperty({ enum: ['event', 'registry'] })
declare dateKind: 'event' | 'registry';
@ApiProperty({ type: String, nullable: true })
declare figi: string | null;
@ApiProperty({ type: String, nullable: true })
declare instrumentUid: string | null;
@ApiProperty()
declare ticker: string;
@ApiProperty()
declare displayName: string;
@ApiProperty({ type: String, nullable: true })
declare isin: string | null;
@ApiProperty({ type: String, nullable: true })
declare logoName: string | null;
@ApiProperty({ type: MoneyDto, nullable: true })
declare projectedAmount: MoneyDto | null;
}
export class UpcomingEventsDto {
@ApiProperty()
declare accountId: string;
@ApiProperty({ format: 'date-time' })
declare calculatedAt: string;
@ApiProperty({ type: UpcomingEventDto, isArray: true })
declare events: UpcomingEventDto[];
@ApiProperty({ type: MoneyDto, isArray: true })
declare incomeTotalsByCurrency: MoneyDto[];
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { MoexModule } from '../../../integrations/moex/moex.module';
import { TbankModule } from '../../../integrations/tbank/tbank.module';
import { AuthModule } from '../../auth/auth.module';
import { UpcomingEventsController } from './upcoming-events.controller';
import { UpcomingEventsService } from './upcoming-events.service';
@Module({
imports: [AuthModule, TbankModule, MoexModule],
controllers: [UpcomingEventsController],
providers: [UpcomingEventsService],
})
export class UpcomingEventsModule {}
@@ -0,0 +1,434 @@
import {
BadRequestException,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
jest.mock('../../../integrations/tbank/tbank-access.service', () => ({
TbankAccessService: class TbankAccessService {},
}));
import type { TbankAccountContext } from '../../../integrations/tbank/tbank-access.service';
import { UpcomingEventsService } from './upcoming-events.service';
describe('UpcomingEventsService', () => {
const access = { withAccount: jest.fn() };
const tbank = {
getPortfolio: jest.fn(),
getInstrumentByUid: jest.fn(),
getInstrumentByFigi: jest.fn(),
getDividends: jest.fn(),
};
const moex = { bondEvents: jest.fn() };
const requestContext = { set: jest.fn() };
const logger = { info: jest.fn(), event: jest.fn() };
let service: UpcomingEventsService;
beforeEach(() => {
service = new UpcomingEventsService(
access as never,
tbank as never,
moex as never,
requestContext as never,
logger as never,
);
jest.clearAllMocks();
access.withAccount.mockImplementation(
(
_userId: string,
_accountId: string,
handler: (context: TbankAccountContext) => Promise<unknown>,
) =>
handler({
token: 'token',
generation: 'generation-1',
account: { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: '' },
}),
);
tbank.getInstrumentByFigi.mockResolvedValue({ name: 'Бумага' });
moex.bondEvents.mockResolvedValue([]);
tbank.getDividends.mockResolvedValue([]);
});
it('includes both selected boundaries beyond the default window and isolates dividend caches by period', async () => {
tbank.getPortfolio.mockResolvedValue({
positions: [
{
figi: 'S',
ticker: 'SHARE',
instrumentType: 'share',
quantity: { units: 2 },
},
{
figi: 'B',
ticker: 'BOND',
instrumentType: 'bond',
quantity: { units: 3 },
},
],
});
moex.bondEvents.mockResolvedValue([
{ type: 'coupon', date: '2099-02-01', value: '2', currency: 'RUB' },
{ type: 'offer', date: '2099-02-28' },
{ type: 'maturity', date: '2099-03-01', value: '1000', currency: 'RUB' },
{ type: 'offer', date: '2099-01-31' },
]);
tbank.getDividends.mockImplementation(
(_token: string, _id: string, from: string) =>
Promise.resolve([
{
dividendNet: { units: 5, currency: 'RUB' },
recordDate: {
seconds: String(Date.parse(`${from}T00:00:00Z`) / 1000),
},
},
]),
);
const february = { from: '2099-02-01', to: '2099-02-28' };
const first = await service.get('user', 'account', february);
expect(first.events.map(({ type, date }) => ({ type, date }))).toEqual([
{ type: 'coupon', date: february.from },
{ type: 'dividend', date: february.from },
{ type: 'offer', date: february.to },
]);
expect(first.incomeTotalsByCurrency).toEqual([
{ currency: 'RUB', value: '16' },
]);
const march = await service.get('user', 'account', {
from: '2099-03-01',
to: '2099-03-01',
});
expect(march.events.map((event) => event.type)).toEqual([
'dividend',
'maturity',
]);
expect(march.incomeTotalsByCurrency).toEqual([
{ currency: 'RUB', value: '10' },
]);
await service.get('user', 'account', february);
expect(tbank.getDividends).toHaveBeenCalledTimes(2);
expect(tbank.getDividends).toHaveBeenNthCalledWith(
1,
'token',
'S',
february.from,
february.to,
);
expect(tbank.getDividends).toHaveBeenNthCalledWith(
2,
'token',
'S',
'2099-03-01',
'2099-03-01',
);
});
it('defaults the end to 30 days after the selected start', async () => {
tbank.getPortfolio.mockResolvedValue({
positions: [
{
figi: 'B',
ticker: 'BOND',
instrumentType: 'bond',
quantity: { units: 1 },
},
],
});
moex.bondEvents.mockResolvedValue([
{ type: 'offer', date: '2099-01-01' },
{ type: 'offer', date: '2099-01-31' },
{ type: 'offer', date: '2099-02-01' },
]);
const result = await service.get('user', 'account', { from: '2099-01-01' });
expect(result.events.map((event) => event.date)).toEqual([
'2099-01-01',
'2099-01-31',
]);
});
it('projects exact amounts and preserves registry dates and offers', async () => {
const now = new Date();
const day = (offset: number) =>
new Date(
Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate() + offset,
),
)
.toISOString()
.slice(0, 10);
tbank.getInstrumentByFigi.mockResolvedValue({
name: 'Бумага',
isin: ' RU000A0JX0J2 ',
brand: { logoName: ' instrument-logo.png ' },
});
tbank.getPortfolio.mockResolvedValue({
positions: [
{
figi: 'S',
ticker: 'SBER',
instrumentType: 'share',
quantity: { units: 2 },
},
{
figi: 'B',
ticker: 'BOND',
instrumentType: 'bond',
quantity: { units: 3 },
},
],
});
tbank.getDividends.mockResolvedValue([
{
dividendNet: { units: 1, nano: 250000000, currency: 'RUB' },
recordDate: {
seconds: String(Math.floor(Date.parse(`${day(1)}T00:00:00Z`) / 1000)),
},
dividendType: 'Regular Cash',
},
]);
moex.bondEvents.mockResolvedValue([
{ type: 'offer', date: day(2) },
{ type: 'coupon', date: day(1), value: '2.5', currency: 'RUB' },
{ type: 'maturity', date: day(31), value: '1000', currency: 'RUB' },
]);
await expect(service.get('user', 'account')).resolves.toMatchObject({
events: [
{
type: 'coupon',
projectedAmount: { currency: 'RUB', value: '7.5' },
dateKind: 'event',
isin: 'RU000A0JX0J2',
logoName: 'instrument-logo.png',
},
{
type: 'dividend',
projectedAmount: { currency: 'RUB', value: '2.5' },
dateKind: 'registry',
isin: 'RU000A0JX0J2',
logoName: 'instrument-logo.png',
},
{
type: 'offer',
projectedAmount: null,
isin: 'RU000A0JX0J2',
logoName: 'instrument-logo.png',
},
],
});
});
it.each([undefined, ' '])(
'keeps events without optional metadata when it is %s',
async (metadata) => {
const today = new Date().toISOString().slice(0, 10);
tbank.getPortfolio.mockResolvedValue({
positions: [
{
instrumentUid: 'bond-uid',
ticker: 'BOND',
instrumentType: 'bond',
quantity: { units: 1 },
},
],
});
tbank.getInstrumentByUid.mockResolvedValue({
name: 'Bond',
isin: metadata,
brand: { logoName: metadata },
});
moex.bondEvents.mockResolvedValue([{ type: 'offer', date: today }]);
await expect(service.get('user', 'account')).resolves.toMatchObject({
events: [{ type: 'offer', isin: null, logoName: null }],
});
},
);
it('does not read upstream data for inaccessible accounts', async () => {
access.withAccount.mockRejectedValueOnce(new NotFoundException());
await expect(service.get('user', 'account')).rejects.toEqual(
expect.any(NotFoundException),
);
expect(tbank.getPortfolio).not.toHaveBeenCalled();
});
it('uses the portfolio ticker without an unnecessary instrument lookup', async () => {
const today = new Date().toISOString().slice(0, 10);
tbank.getPortfolio.mockResolvedValue({
positions: [
{
figi: 'S',
ticker: 'SBER',
instrumentType: 'share',
quantity: { units: 1 },
},
],
});
tbank.getInstrumentByFigi.mockRejectedValue(
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
);
tbank.getDividends.mockResolvedValue([
{
dividendNet: { units: 1, nano: 0, currency: 'RUB' },
recordDate: {
seconds: String(Math.floor(Date.parse(`${today}T00:00:00Z`) / 1000)),
},
dividendType: 'Regular Cash',
},
]);
await expect(service.get('user', 'account')).resolves.toMatchObject({
events: [
{
ticker: 'SBER',
projectedAmount: { currency: 'RUB', value: '1' },
isin: null,
logoName: null,
},
],
});
expect(tbank.getInstrumentByFigi).toHaveBeenCalledWith('token', 'S');
});
it('skips cancelled dividends', async () => {
const today = new Date().toISOString().slice(0, 10);
tbank.getPortfolio.mockResolvedValue({
positions: [
{
figi: 'S',
ticker: 'SBER',
instrumentType: 'share',
quantity: { units: 1 },
},
],
});
tbank.getDividends.mockResolvedValue([
{
dividendNet: { units: 1, nano: 0, currency: 'RUB' },
recordDate: {
seconds: String(Math.floor(Date.parse(`${today}T00:00:00Z`) / 1000)),
},
dividendType: 'Cancelled',
},
{
dividendNet: { units: 2, nano: 0, currency: 'RUB' },
recordDate: {
seconds: String(Math.floor(Date.parse(`${today}T00:00:00Z`) / 1000)),
},
dividendType: 'Regular Cash',
},
]);
await expect(service.get('user', 'account')).resolves.toMatchObject({
events: [
{
type: 'dividend',
projectedAmount: { currency: 'RUB', value: '2' },
},
],
});
});
it('does not invent projected money when the source omits currency', async () => {
const today = new Date().toISOString().slice(0, 10);
tbank.getPortfolio.mockResolvedValue({
positions: [
{
figi: 'S',
ticker: 'SBER',
instrumentType: 'share',
quantity: { units: 1 },
},
],
});
tbank.getDividends.mockResolvedValue([
{
dividendNet: { units: 1, nano: 0 },
recordDate: {
seconds: String(Math.floor(Date.parse(`${today}T00:00:00Z`) / 1000)),
},
dividendType: 'Regular Cash',
},
]);
await expect(service.get('user', 'account')).resolves.toMatchObject({
events: [{ type: 'dividend', projectedAmount: null }],
});
});
it('adds exact source-currency income totals without changing the events list', async () => {
const today = new Date().toISOString().slice(0, 10);
tbank.getPortfolio.mockResolvedValue({
positions: [
{
figi: 'B',
ticker: 'BOND',
instrumentType: 'bond',
quantity: { units: 1 },
},
],
});
moex.bondEvents.mockResolvedValue([
{ type: 'coupon', date: today, value: '1.25', currency: 'RUB' },
{ type: 'maturity', date: today, value: '100', currency: 'RUB' },
{ type: 'offer', date: today },
]);
const result = await service.get('user', 'account');
expect(result.incomeTotalsByCurrency).toEqual([
{ currency: 'RUB', value: '1.25' },
]);
expect(result.events).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: 'maturity' }),
expect.objectContaining({ type: 'offer' }),
]),
);
});
it('reuses upstream results within the cache TTL across requests', async () => {
const today = new Date().toISOString().slice(0, 10);
tbank.getPortfolio.mockResolvedValue({
positions: [
{
figi: 'S',
ticker: 'SBER',
instrumentType: 'share',
quantity: { units: 1 },
},
],
});
tbank.getDividends.mockResolvedValue([
{
dividendNet: { units: 1, nano: 0, currency: 'RUB' },
recordDate: {
seconds: String(Math.floor(Date.parse(`${today}T00:00:00Z`) / 1000)),
},
dividendType: 'Regular Cash',
},
]);
await service.get('user', 'account');
await service.get('user', 'account');
expect(tbank.getInstrumentByFigi).toHaveBeenCalledTimes(1);
expect(tbank.getDividends).toHaveBeenCalledTimes(1);
});
it('preserves invalid token and source failures without a partial result', async () => {
access.withAccount.mockRejectedValueOnce(
new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }),
);
await expect(service.get('user', 'account')).rejects.toEqual(
expect.any(BadRequestException),
);
tbank.getPortfolio.mockRejectedValueOnce(
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
);
await expect(service.get('user', 'account')).rejects.toEqual(
expect.any(ServiceUnavailableException),
);
});
});
@@ -0,0 +1,327 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import {
add,
decimalFromString,
formatDecimal,
isZero,
multiply,
} from '../../../common/decimal';
import { RequestContextService } from '../../../common/logging/request-context.service';
import { StructuredLogger } from '../../../common/logging/structured-logger.service';
import { MoneyDto } from '../../../common/money.dto';
import { MoexBondEvent, MoexClient } from '../../../integrations/moex/moex.api';
import { decimal } from '../../../integrations/tbank/decimal';
import {
TbankAccessService,
TbankClient,
TbankDividend,
TbankInstrument,
TbankPortfolio,
} from '../../../integrations/tbank/tbank.api';
import {
UpcomingEventDto,
UpcomingEventsDto,
UpcomingEventsQueryDto,
} from './upcoming-events.dto';
type Position = NonNullable<TbankPortfolio['positions']>[number];
type CachedEntry<T> = { expiresAt: number; promise: Promise<T> };
const positionsBatchSize = 5;
const dividendCacheTtlMs = 15 * 60_000;
const instrumentCacheTtlMs = 24 * 60 * 60_000;
@Injectable()
export class UpcomingEventsService {
private readonly dividendCache = new Map<
string,
CachedEntry<TbankDividend[]>
>();
private readonly instrumentCache = new Map<
string,
CachedEntry<TbankInstrument | null>
>();
constructor(
private readonly access: TbankAccessService,
private readonly tbank: TbankClient,
private readonly moex: MoexClient,
private readonly requestContext: RequestContextService,
private readonly logger: StructuredLogger,
) {}
async get(
userId: string,
accountId: string,
query: UpcomingEventsQueryDto = {},
): Promise<UpcomingEventsDto> {
const window = dateWindow(query);
this.requestContext.set({ userId, brokerageAccountId: accountId });
return this.access.withAccount(userId, accountId, ({ token }) =>
this.forAccount(token, accountId, window),
);
}
async getAll(
userId: string,
query: UpcomingEventsQueryDto = {},
): Promise<UpcomingEventsDto> {
const window = dateWindow(query);
return this.access.withSupportedAccounts(
userId,
async ({ token, accounts }) => {
const events: (UpcomingEventDto & {
account: { id: string; name: string };
})[] = [];
for (const account of accounts) {
const result = await this.forAccount(token, account.id, window);
events.push(
...result.events.map((event) => ({
...event,
account: { id: account.id, name: account.name },
})),
);
}
events.sort(
(left, right) =>
left.date.localeCompare(right.date) ||
left.account.id.localeCompare(right.account.id),
);
return {
accountId: 'all',
calculatedAt: new Date().toISOString(),
events,
incomeTotalsByCurrency: incomeTotals(events),
};
},
);
}
private async forAccount(
token: string,
accountId: string,
window: { from: string; to: string },
): Promise<UpcomingEventsDto> {
const portfolio = await this.tbank.getPortfolio(token, accountId);
const supportedPositions = (portfolio.positions ?? []).filter(supported);
const events: UpcomingEventDto[][] = [];
for (
let offset = 0;
offset < supportedPositions.length;
offset += positionsBatchSize
) {
const batch = supportedPositions.slice(
offset,
offset + positionsBatchSize,
);
events.push(
...(await Promise.all(
batch.map((position) => this.positionEvents(token, position, window)),
)),
);
}
const projected = events
.flat()
.filter((event) => event.date >= window.from && event.date <= window.to);
this.logger.info('upcoming-events.calculated', {
count: projected.length,
});
return {
accountId,
calculatedAt: new Date().toISOString(),
events: projected.sort(
(left, right) =>
left.date.localeCompare(right.date) ||
left.type.localeCompare(right.type) ||
left.ticker.localeCompare(right.ticker),
),
incomeTotalsByCurrency: incomeTotals(projected),
};
}
private async positionEvents(
token: string,
position: Position,
window: { from: string; to: string },
): Promise<UpcomingEventDto[]> {
const ticker = position.ticker?.trim();
const instrument = ticker
? await this.instrument(token, position).catch(() => {
this.logger.event('warn', 'upcoming-events.metadata-unavailable');
return null;
})
: await this.instrument(token, position);
const resolvedTicker = ticker || instrument?.ticker?.trim();
if (!resolvedTicker) return [];
const metadata = {
figi: position.figi?.trim() || null,
instrumentUid: position.instrumentUid?.trim() || null,
ticker: resolvedTicker,
displayName: instrument?.name?.trim() || resolvedTicker,
isin: instrument?.isin?.trim() || null,
logoName: instrument?.brand?.logoName?.trim() || null,
};
const quantity = decimal(position.quantity ?? {});
if (position.instrumentType?.toLowerCase() === 'share') {
const dividends = await this.dividends(token, position, window);
this.logger.event('debug', 'upcoming-events.source-result', {
count: dividends.length,
});
return dividends.map((event) => this.dividend(metadata, quantity, event));
}
const events = await this.moex.bondEvents(resolvedTicker);
this.logger.event('debug', 'upcoming-events.source-result', {
count: events.length,
});
return events.map((event) => this.bond(metadata, quantity, event));
}
private async instrument(token: string, position: Position) {
const instrumentUid = position.instrumentUid?.trim();
if (instrumentUid)
return this.cached(
this.instrumentCache,
instrumentUid,
instrumentCacheTtlMs,
() => this.tbank.getInstrumentByUid(token, instrumentUid),
);
const figi = position.figi?.trim();
if (figi)
return this.cached(this.instrumentCache, figi, instrumentCacheTtlMs, () =>
this.tbank.getInstrumentByFigi(token, figi),
);
return null;
}
private async dividends(
token: string,
position: Position,
window: { from: string; to: string },
): Promise<TbankDividend[]> {
const instrumentId = position.instrumentUid ?? position.figi;
if (!instrumentId) return [];
const dividends = await this.cached(
this.dividendCache,
JSON.stringify([instrumentId, window.from, window.to]),
dividendCacheTtlMs,
() =>
this.tbank.getDividends(token, instrumentId, window.from, window.to),
);
return dividends.filter(
(event) =>
event.dividendType !== 'Cancelled' && Boolean(event.recordDate),
);
}
private async cached<T>(
cache: Map<string, CachedEntry<T>>,
key: string,
ttlMs: number,
load: () => Promise<T>,
): Promise<T> {
const now = Date.now();
const hit = cache.get(key);
if (hit && hit.expiresAt > now) return hit.promise;
const promise = load().catch((error: unknown) => {
cache.delete(key);
throw error;
});
cache.set(key, { expiresAt: now + ttlMs, promise });
return promise;
}
private dividend(
metadata: Pick<
UpcomingEventDto,
'figi' | 'instrumentUid' | 'ticker' | 'displayName' | 'isin' | 'logoName'
>,
quantity: ReturnType<typeof decimal>,
event: TbankDividend,
): UpcomingEventDto {
return {
...metadata,
type: 'dividend',
date: new Date(Number(event.recordDate?.seconds) * 1000)
.toISOString()
.slice(0, 10),
dateKind: 'registry',
projectedAmount: event.dividendNet
? projectedMoney(
quantity,
formatDecimal(decimal(event.dividendNet)),
event.dividendNet.currency,
)
: null,
};
}
private bond(
metadata: Pick<
UpcomingEventDto,
'figi' | 'instrumentUid' | 'ticker' | 'displayName' | 'isin' | 'logoName'
>,
quantity: ReturnType<typeof decimal>,
event: MoexBondEvent,
): UpcomingEventDto {
return {
...metadata,
type: event.type,
date: event.date,
dateKind: 'event',
projectedAmount:
'value' in event
? projectedMoney(quantity, event.value, event.currency)
: null,
};
}
}
function supported(position: Position): boolean {
return (
!isZero(decimal(position.quantity ?? {})) &&
['share', 'bond'].includes(position.instrumentType?.toLowerCase() ?? '')
);
}
function dateWindow(query: UpcomingEventsQueryDto) {
const from = query.from ?? new Date().toISOString().slice(0, 10);
const end = new Date(`${from}T00:00:00.000Z`);
end.setUTCDate(end.getUTCDate() + 30);
const to = query.to ?? end.toISOString().slice(0, 10);
if (from > to)
throw new BadRequestException({ code: 'INVALID_EVENTS_PERIOD' });
return { from, to };
}
function projectedMoney(
quantity: ReturnType<typeof decimal>,
value: string,
currency: string | undefined,
): MoneyDto | null {
const normalizedCurrency = currency?.trim().toUpperCase();
if (!normalizedCurrency) return null;
return {
currency: normalizedCurrency,
value: formatDecimal(multiply(quantity, decimalFromString(value))),
};
}
function incomeTotals(events: UpcomingEventDto[]): MoneyDto[] {
const totals = new Map<string, ReturnType<typeof decimal>>();
for (const event of events) {
if (!['coupon', 'dividend'].includes(event.type) || !event.projectedAmount)
continue;
const current = totals.get(event.projectedAmount.currency) ?? {
value: 0n,
scale: 0,
};
totals.set(
event.projectedAmount.currency,
add(current, decimalFromString(event.projectedAmount.value)),
);
}
return [...totals]
.sort(([left], [right]) => left.localeCompare(right))
.map(([currency, value]) => ({ currency, value: formatDecimal(value) }));
}