refactor: organize backend by business responsibility
This commit is contained in:
@@ -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) }));
|
||||
}
|
||||
Reference in New Issue
Block a user