diff --git a/src/tbank/upcoming-events.controller.spec.ts b/src/tbank/upcoming-events.controller.spec.ts index ba0498a..572368c 100644 --- a/src/tbank/upcoming-events.controller.spec.ts +++ b/src/tbank/upcoming-events.controller.spec.ts @@ -8,7 +8,13 @@ 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'); - expect(events.get).toHaveBeenCalledWith('user', 'account'); + 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', + }); }); }); diff --git a/src/tbank/upcoming-events.controller.ts b/src/tbank/upcoming-events.controller.ts index 3237d71..d0b8210 100644 --- a/src/tbank/upcoming-events.controller.ts +++ b/src/tbank/upcoming-events.controller.ts @@ -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 type { Request } from 'express'; import { AccessPayload } from '../auth/auth.types'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; 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'; @ApiTags('brokerage-accounts') @@ -17,8 +20,11 @@ export class UpcomingEventsController { @Get('all/events') @ApiOkResponse({ type: UpcomingEventsDto }) - all(@Req() request: Request & { user: AccessPayload }) { - return this.events.getAll(request.user.sub); + all( + @Req() request: Request & { user: AccessPayload }, + @Query() query: UpcomingEventsQueryDto, + ) { + return this.events.getAll(request.user.sub, query); } @Get(':accountId/events') @@ -26,7 +32,8 @@ export class UpcomingEventsController { get( @Req() request: Request & { user: AccessPayload }, @Param('accountId') accountId: string, + @Query() query: UpcomingEventsQueryDto, ) { - return this.events.get(request.user.sub, accountId); + return this.events.get(request.user.sub, accountId, query); } } diff --git a/src/tbank/upcoming-events.dto.ts b/src/tbank/upcoming-events.dto.ts index 7c9faef..f70d91f 100644 --- a/src/tbank/upcoming-events.dto.ts +++ b/src/tbank/upcoming-events.dto.ts @@ -1,7 +1,29 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsDateString, IsOptional, Matches } from 'class-validator'; import { AccountReferenceDto } from './account-reference.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 { @ApiPropertyOptional({ type: AccountReferenceDto }) account?: AccountReferenceDto; diff --git a/src/tbank/upcoming-events.service.spec.ts b/src/tbank/upcoming-events.service.spec.ts index 773b384..bcc9fa9 100644 --- a/src/tbank/upcoming-events.service.spec.ts +++ b/src/tbank/upcoming-events.service.spec.ts @@ -50,6 +50,102 @@ describe('UpcomingEventsService', () => { 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) => diff --git a/src/tbank/upcoming-events.service.ts b/src/tbank/upcoming-events.service.ts index a347425..c07fd39 100644 --- a/src/tbank/upcoming-events.service.ts +++ b/src/tbank/upcoming-events.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { RequestContextService } from '../common/logging/request-context.service'; import { StructuredLogger } from '../common/logging/structured-logger.service'; import { @@ -18,7 +18,11 @@ import { TbankInstrument, TbankPortfolio, } from './tbank.client'; -import { UpcomingEventDto, UpcomingEventsDto } from './upcoming-events.dto'; +import { + UpcomingEventDto, + UpcomingEventsDto, + UpcomingEventsQueryDto, +} from './upcoming-events.dto'; type Position = NonNullable[number]; type CachedEntry = { expiresAt: number; promise: Promise }; @@ -46,20 +50,29 @@ export class UpcomingEventsService { private readonly logger: StructuredLogger, ) {} - async get(userId: string, accountId: string): Promise { + async get( + userId: string, + accountId: string, + query: UpcomingEventsQueryDto = {}, + ): Promise { + const window = dateWindow(query); this.requestContext.set({ userId, brokerageAccountId: accountId }); return this.access.withAccount(userId, accountId, ({ token }) => - this.forAccount(token, accountId), + this.forAccount(token, accountId, window), ); } - async getAll(userId: string): Promise { + async getAll( + userId: string, + query: UpcomingEventsQueryDto = {}, + ): Promise { + const window = dateWindow(query); return this.access.withSupportedAccounts( userId, async ({ token, accounts }) => { const events: UpcomingEventDto[] = []; for (const account of accounts) { - const result = await this.forAccount(token, account.id); + const result = await this.forAccount(token, account.id, window); events.push( ...result.events.map((event) => ({ ...event, @@ -85,9 +98,9 @@ export class UpcomingEventsService { private async forAccount( token: string, accountId: string, + window: { from: string; to: string }, ): Promise { const portfolio = await this.tbank.getPortfolio(token, accountId); - const window = dateWindow(); const supportedPositions = (portfolio.positions ?? []).filter(supported); const events: UpcomingEventDto[][] = []; for ( @@ -101,7 +114,7 @@ export class UpcomingEventsService { ); events.push( ...(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( token: string, position: Position, + window: { from: string; to: string }, ): Promise { const ticker = position.ticker?.trim(); const instrument = ticker @@ -147,7 +161,7 @@ export class UpcomingEventsService { }; const quantity = decimal(position.quantity ?? {}); 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', { count: dividends.length, }); @@ -180,13 +194,13 @@ export class UpcomingEventsService { private async dividends( token: string, position: Position, + window: { from: string; to: string }, ): Promise { const instrumentId = position.instrumentUid ?? position.figi; if (!instrumentId) return []; - const window = dateWindow(); const dividends = await this.cached( this.dividendCache, - instrumentId, + JSON.stringify([instrumentId, window.from, window.to]), dividendCacheTtlMs, () => this.tbank.getDividends(token, instrumentId, window.from, window.to), @@ -267,13 +281,14 @@ function supported(position: Position): boolean { ); } -function dateWindow() { - const now = new Date(); - const from = now.toISOString().slice(0, 10); - const end = new Date( - Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 30), - ); - return { from, to: end.toISOString().slice(0, 10) }; +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( diff --git a/test/upcoming-events.e2e-spec.ts b/test/upcoming-events.e2e-spec.ts new file mode 100644 index 0000000..9c745dd --- /dev/null +++ b/test/upcoming-events.e2e-spec.ts @@ -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; + 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(); + }); + }); +});