feat: use money for upcoming event projections

This commit is contained in:
2026-08-25 22:30:08 +03:00
parent 130b303ec1
commit f1df104462
3 changed files with 72 additions and 17 deletions
+3 -5
View File
@@ -1,4 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { MoneyDto } from './portfolio.dto';
export class UpcomingEventDto { export class UpcomingEventDto {
@ApiProperty({ enum: ['coupon', 'dividend', 'maturity', 'offer'] }) @ApiProperty({ enum: ['coupon', 'dividend', 'maturity', 'offer'] })
@@ -22,11 +23,8 @@ export class UpcomingEventDto {
@ApiProperty() @ApiProperty()
displayName: string; displayName: string;
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ type: MoneyDto, nullable: true })
currency: string | null; projectedAmount: MoneyDto | null;
@ApiProperty({ type: String, nullable: true })
amount: string | null;
} }
export class UpcomingEventsDto { export class UpcomingEventsDto {
+47 -5
View File
@@ -94,9 +94,17 @@ describe('UpcomingEventsService', () => {
await expect(service.get('user', 'account')).resolves.toMatchObject({ await expect(service.get('user', 'account')).resolves.toMatchObject({
events: [ events: [
{ type: 'coupon', amount: '7.5', dateKind: 'event' }, {
{ type: 'dividend', amount: '2.5', dateKind: 'registry' }, type: 'coupon',
{ type: 'offer', amount: null }, projectedAmount: { currency: 'RUB', value: '7.5' },
dateKind: 'event',
},
{
type: 'dividend',
projectedAmount: { currency: 'RUB', value: '2.5' },
dateKind: 'registry',
},
{ type: 'offer', projectedAmount: null },
], ],
}); });
}); });
@@ -135,7 +143,9 @@ describe('UpcomingEventsService', () => {
]); ]);
await expect(service.get('user', 'account')).resolves.toMatchObject({ await expect(service.get('user', 'account')).resolves.toMatchObject({
events: [{ ticker: 'SBER', amount: '1' }], events: [
{ ticker: 'SBER', projectedAmount: { currency: 'RUB', value: '1' } },
],
}); });
expect(tbank.getInstrumentByFigi).toHaveBeenCalledWith('token', 'S'); expect(tbank.getInstrumentByFigi).toHaveBeenCalledWith('token', 'S');
}); });
@@ -170,7 +180,39 @@ describe('UpcomingEventsService', () => {
]); ]);
await expect(service.get('user', 'account')).resolves.toMatchObject({ await expect(service.get('user', 'account')).resolves.toMatchObject({
events: [{ type: 'dividend', amount: '2' }], 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 }],
}); });
}); });
+22 -7
View File
@@ -3,6 +3,7 @@ import { RequestContextService } from '../common/logging/request-context.service
import { StructuredLogger } from '../common/logging/structured-logger.service'; import { StructuredLogger } from '../common/logging/structured-logger.service';
import { decimal, formatDecimal, isZero, multiply } from './decimal'; import { decimal, formatDecimal, isZero, multiply } from './decimal';
import { MoexBondEvent, MoexClient } from './moex.client'; import { MoexBondEvent, MoexClient } from './moex.client';
import { MoneyDto } from './portfolio.dto';
import { TbankAccessService } from './tbank-access.service'; import { TbankAccessService } from './tbank-access.service';
import { import {
TbankClient, TbankClient,
@@ -184,9 +185,12 @@ export class UpcomingEventsService {
.toISOString() .toISOString()
.slice(0, 10), .slice(0, 10),
dateKind: 'registry', dateKind: 'registry',
currency: event.dividendNet?.currency ?? null, projectedAmount: event.dividendNet
amount: event.dividendNet ? projectedMoney(
? formatDecimal(multiply(quantity, decimal(event.dividendNet))) quantity,
formatDecimal(decimal(event.dividendNet)),
event.dividendNet.currency,
)
: null, : null,
}; };
} }
@@ -204,8 +208,10 @@ export class UpcomingEventsService {
type: event.type, type: event.type,
date: event.date, date: event.date,
dateKind: 'event', dateKind: 'event',
currency: 'currency' in event ? event.currency : null, projectedAmount:
amount: 'value' in event ? amount(quantity, event.value) : null, 'value' in event
? projectedMoney(quantity, event.value, event.currency)
: null,
}; };
} }
} }
@@ -226,8 +232,17 @@ function dateWindow() {
return { from, to: end.toISOString().slice(0, 10) }; return { from, to: end.toISOString().slice(0, 10) };
} }
function amount(quantity: ReturnType<typeof decimal>, value: string): string { function projectedMoney(
return formatDecimal(multiply(quantity, decimalFromString(value))); 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 decimalFromString(value: string) { function decimalFromString(value: string) {