feat(events): support date range filtering

This commit is contained in:
2026-09-12 11:57:41 +03:00
parent 235c179ff8
commit a79f59a468
6 changed files with 283 additions and 25 deletions
+8 -2
View File
@@ -8,7 +8,13 @@ describe('UpcomingEventsController', () => {
it('passes the authenticated user and account to the service', async () => { it('passes the authenticated user and account to the service', async () => {
const events = { get: jest.fn() }; const events = { get: jest.fn() };
const controller = new UpcomingEventsController(events as never); const controller = new UpcomingEventsController(events as never);
await controller.get({ user: { sub: 'user' } } as never, 'account'); await controller.get({ user: { sub: 'user' } } as never, 'account', {
expect(events.get).toHaveBeenCalledWith('user', 'account'); from: '2026-09-01',
to: '2026-09-30',
});
expect(events.get).toHaveBeenCalledWith('user', 'account', {
from: '2026-09-01',
to: '2026-09-30',
});
}); });
}); });
+12 -5
View File
@@ -1,10 +1,13 @@
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common'; import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express'; import type { Request } from 'express';
import { AccessPayload } from '../auth/auth.types'; import { AccessPayload } from '../auth/auth.types';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { ApiProblemResponses } from '../common/decorators/api-safe-error-response.decorator'; import { ApiProblemResponses } from '../common/decorators/api-safe-error-response.decorator';
import { UpcomingEventsDto } from './upcoming-events.dto'; import {
UpcomingEventsDto,
UpcomingEventsQueryDto,
} from './upcoming-events.dto';
import { UpcomingEventsService } from './upcoming-events.service'; import { UpcomingEventsService } from './upcoming-events.service';
@ApiTags('brokerage-accounts') @ApiTags('brokerage-accounts')
@@ -17,8 +20,11 @@ export class UpcomingEventsController {
@Get('all/events') @Get('all/events')
@ApiOkResponse({ type: UpcomingEventsDto }) @ApiOkResponse({ type: UpcomingEventsDto })
all(@Req() request: Request & { user: AccessPayload }) { all(
return this.events.getAll(request.user.sub); @Req() request: Request & { user: AccessPayload },
@Query() query: UpcomingEventsQueryDto,
) {
return this.events.getAll(request.user.sub, query);
} }
@Get(':accountId/events') @Get(':accountId/events')
@@ -26,7 +32,8 @@ export class UpcomingEventsController {
get( get(
@Req() request: Request & { user: AccessPayload }, @Req() request: Request & { user: AccessPayload },
@Param('accountId') accountId: string, @Param('accountId') accountId: string,
@Query() query: UpcomingEventsQueryDto,
) { ) {
return this.events.get(request.user.sub, accountId); return this.events.get(request.user.sub, accountId, query);
} }
} }
+22
View File
@@ -1,7 +1,29 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsOptional, Matches } from 'class-validator';
import { AccountReferenceDto } from './account-reference.dto'; import { AccountReferenceDto } from './account-reference.dto';
import { MoneyDto } from './portfolio.dto'; import { MoneyDto } from './portfolio.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 { export class UpcomingEventDto {
@ApiPropertyOptional({ type: AccountReferenceDto }) @ApiPropertyOptional({ type: AccountReferenceDto })
account?: AccountReferenceDto; account?: AccountReferenceDto;
+96
View File
@@ -50,6 +50,102 @@ describe('UpcomingEventsService', () => {
tbank.getDividends.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 () => { it('projects exact amounts and preserves registry dates and offers', async () => {
const now = new Date(); const now = new Date();
const day = (offset: number) => const day = (offset: number) =>
+33 -18
View File
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { RequestContextService } from '../common/logging/request-context.service'; 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 { import {
@@ -18,7 +18,11 @@ import {
TbankInstrument, TbankInstrument,
TbankPortfolio, TbankPortfolio,
} from './tbank.client'; } from './tbank.client';
import { UpcomingEventDto, UpcomingEventsDto } from './upcoming-events.dto'; import {
UpcomingEventDto,
UpcomingEventsDto,
UpcomingEventsQueryDto,
} from './upcoming-events.dto';
type Position = NonNullable<TbankPortfolio['positions']>[number]; type Position = NonNullable<TbankPortfolio['positions']>[number];
type CachedEntry<T> = { expiresAt: number; promise: Promise<T> }; type CachedEntry<T> = { expiresAt: number; promise: Promise<T> };
@@ -46,20 +50,29 @@ export class UpcomingEventsService {
private readonly logger: StructuredLogger, private readonly logger: StructuredLogger,
) {} ) {}
async get(userId: string, accountId: string): Promise<UpcomingEventsDto> { async get(
userId: string,
accountId: string,
query: UpcomingEventsQueryDto = {},
): Promise<UpcomingEventsDto> {
const window = dateWindow(query);
this.requestContext.set({ userId, brokerageAccountId: accountId }); this.requestContext.set({ userId, brokerageAccountId: accountId });
return this.access.withAccount(userId, accountId, ({ token }) => return this.access.withAccount(userId, accountId, ({ token }) =>
this.forAccount(token, accountId), this.forAccount(token, accountId, window),
); );
} }
async getAll(userId: string): Promise<UpcomingEventsDto> { async getAll(
userId: string,
query: UpcomingEventsQueryDto = {},
): Promise<UpcomingEventsDto> {
const window = dateWindow(query);
return this.access.withSupportedAccounts( return this.access.withSupportedAccounts(
userId, userId,
async ({ token, accounts }) => { async ({ token, accounts }) => {
const events: UpcomingEventDto[] = []; const events: UpcomingEventDto[] = [];
for (const account of accounts) { for (const account of accounts) {
const result = await this.forAccount(token, account.id); const result = await this.forAccount(token, account.id, window);
events.push( events.push(
...result.events.map((event) => ({ ...result.events.map((event) => ({
...event, ...event,
@@ -85,9 +98,9 @@ export class UpcomingEventsService {
private async forAccount( private async forAccount(
token: string, token: string,
accountId: string, accountId: string,
window: { from: string; to: string },
): Promise<UpcomingEventsDto> { ): Promise<UpcomingEventsDto> {
const portfolio = await this.tbank.getPortfolio(token, accountId); const portfolio = await this.tbank.getPortfolio(token, accountId);
const window = dateWindow();
const supportedPositions = (portfolio.positions ?? []).filter(supported); const supportedPositions = (portfolio.positions ?? []).filter(supported);
const events: UpcomingEventDto[][] = []; const events: UpcomingEventDto[][] = [];
for ( for (
@@ -101,7 +114,7 @@ export class UpcomingEventsService {
); );
events.push( events.push(
...(await Promise.all( ...(await Promise.all(
batch.map((position) => this.positionEvents(token, position)), batch.map((position) => this.positionEvents(token, position, window)),
)), )),
); );
} }
@@ -127,6 +140,7 @@ export class UpcomingEventsService {
private async positionEvents( private async positionEvents(
token: string, token: string,
position: Position, position: Position,
window: { from: string; to: string },
): Promise<UpcomingEventDto[]> { ): Promise<UpcomingEventDto[]> {
const ticker = position.ticker?.trim(); const ticker = position.ticker?.trim();
const instrument = ticker const instrument = ticker
@@ -147,7 +161,7 @@ export class UpcomingEventsService {
}; };
const quantity = decimal(position.quantity ?? {}); const quantity = decimal(position.quantity ?? {});
if (position.instrumentType?.toLowerCase() === 'share') { if (position.instrumentType?.toLowerCase() === 'share') {
const dividends = await this.dividends(token, position); const dividends = await this.dividends(token, position, window);
this.logger.event('debug', 'upcoming-events.source-result', { this.logger.event('debug', 'upcoming-events.source-result', {
count: dividends.length, count: dividends.length,
}); });
@@ -180,13 +194,13 @@ export class UpcomingEventsService {
private async dividends( private async dividends(
token: string, token: string,
position: Position, position: Position,
window: { from: string; to: string },
): Promise<TbankDividend[]> { ): Promise<TbankDividend[]> {
const instrumentId = position.instrumentUid ?? position.figi; const instrumentId = position.instrumentUid ?? position.figi;
if (!instrumentId) return []; if (!instrumentId) return [];
const window = dateWindow();
const dividends = await this.cached( const dividends = await this.cached(
this.dividendCache, this.dividendCache,
instrumentId, JSON.stringify([instrumentId, window.from, window.to]),
dividendCacheTtlMs, dividendCacheTtlMs,
() => () =>
this.tbank.getDividends(token, instrumentId, window.from, window.to), this.tbank.getDividends(token, instrumentId, window.from, window.to),
@@ -267,13 +281,14 @@ function supported(position: Position): boolean {
); );
} }
function dateWindow() { function dateWindow(query: UpcomingEventsQueryDto) {
const now = new Date(); const from = query.from ?? new Date().toISOString().slice(0, 10);
const from = now.toISOString().slice(0, 10); const end = new Date(`${from}T00:00:00.000Z`);
const end = new Date( end.setUTCDate(end.getUTCDate() + 30);
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 30), const to = query.to ?? end.toISOString().slice(0, 10);
); if (from > to)
return { from, to: end.toISOString().slice(0, 10) }; throw new BadRequestException({ code: 'INVALID_EVENTS_PERIOD' });
return { from, to };
} }
function projectedMoney( function projectedMoney(
+112
View File
@@ -0,0 +1,112 @@
import { INestApplication } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Test } from '@nestjs/testing';
import type { Server } from 'node:http';
import request from 'supertest';
import { AppModule } from '../src/app.module';
import { configureApp } from '../src/main';
import { TbankAccessService } from '../src/tbank/tbank-access.service';
import { TbankClient } from '../src/tbank/tbank.client';
import { MoexClient } from '../src/tbank/moex.client';
describe('Upcoming events date filter HTTP boundary', () => {
let app: INestApplication<Server>;
const getPortfolio = jest.fn();
const token = new JwtService().sign(
{ sub: 'account-owner', type: 'access' },
{ secret: process.env.JWT_SECRET },
);
beforeAll(async () => {
const module = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(TbankAccessService)
.useValue({
withAccount: (
_user: string,
_id: string,
handler: (ctx: unknown) => unknown,
) => handler({ token: 'unused' }),
withSupportedAccounts: (
_user: string,
handler: (ctx: unknown) => unknown,
) =>
handler({
token: 'unused',
accounts: [
{ id: 'account', name: 'Main' },
{ id: 'second', name: 'Second' },
],
}),
})
.overrideProvider(TbankClient)
.useValue({
getPortfolio,
getInstrumentByFigi: () => Promise.resolve(null),
})
.overrideProvider(MoexClient)
.useValue({
bondEvents: () =>
Promise.resolve([
{ type: 'coupon', date: '2099-02-01', value: '2', currency: 'RUB' },
{ type: 'offer', date: '2099-02-28' },
{ type: 'offer', date: '2099-03-01' },
]),
})
.compile();
app = module.createNestApplication();
configureApp(app);
await app.init();
});
beforeEach(() => {
getPortfolio.mockReset().mockResolvedValue({
positions: [
{
figi: 'B',
ticker: 'BOND',
instrumentType: 'bond',
quantity: { units: 3 },
},
],
});
});
afterAll(() => app.close());
describe.each(['account', 'all'])('%s account scope', (scope) => {
it('returns only the requested inclusive period and matching totals', async () => {
const response = await request(app.getHttpServer())
.get(
`/api/v1/brokerage-accounts/${scope}/events?from=2099-02-01&to=2099-02-28`,
)
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(response.body).toMatchObject({
accountId: scope,
events:
scope === 'all'
? [
{ date: '2099-02-01', account: { id: 'account' } },
{ date: '2099-02-01', account: { id: 'second' } },
{ date: '2099-02-28', account: { id: 'account' } },
{ date: '2099-02-28', account: { id: 'second' } },
]
: [{ date: '2099-02-01' }, { date: '2099-02-28' }],
incomeTotalsByCurrency: [
{ currency: 'RUB', value: scope === 'all' ? '12' : '6' },
],
});
});
it.each([
'from=not-a-date',
'from=2099-02-30',
'to=2099-13-01',
'from=2099-02-01T00:00:00Z',
'from=2099-02-28&to=2099-02-01',
])('rejects invalid dates before upstream access: %s', async (query) => {
await request(app.getHttpServer())
.get(`/api/v1/brokerage-accounts/${scope}/events?${query}`)
.set('Authorization', `Bearer ${token}`)
.expect(400);
expect(getPortfolio).not.toHaveBeenCalled();
});
});
});