From 516e43203eb3fee8e6a0c7018be2fee11f1b900c Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sun, 27 Sep 2026 11:38:13 +0300 Subject: [PATCH] fix(investments): align roles and date contracts Identify income dependencies by role and document screen-specific dates. Convert inclusive calendar days into half-open source ranges while preserving operation timestamps and existing stored coverage. --- src/integrations/tbank/tbank.client.spec.ts | 17 ++- src/integrations/tbank/tbank.client.ts | 11 +- .../income/income-analytics.dto.ts | 35 +++++- .../income/income-analytics.service.spec.ts | 38 ++++++ .../income/income-analytics.service.ts | 58 +++++---- .../investments/income/income-period.spec.ts | 14 +++ .../investments/investment-calendar.ts | 24 ++-- .../investments/investment-sync-plan.ts | 7 +- .../brokerage-operations.repository.ts | 14 ++- .../tbank-sync/tbank-screen.spec.ts | 9 ++ .../investments/tbank-sync/tbank-screen.ts | 9 +- .../tbank-sync/tbank-sync.controller.ts | 12 +- .../investments/tbank-sync/tbank-sync.dto.ts | 115 +++++++++++++++++- .../upcoming-events/upcoming-events.dto.ts | 5 +- test/openapi-contract.e2e-spec.ts | 104 ++++++++++++++++ test/tbank-income-local.e2e-spec.ts | 17 ++- test/tbank-screen-sync.e2e-spec.ts | 51 ++++++++ test/upcoming-events.e2e-spec.ts | 34 ++++++ 18 files changed, 500 insertions(+), 74 deletions(-) diff --git a/src/integrations/tbank/tbank.client.spec.ts b/src/integrations/tbank/tbank.client.spec.ts index 7ba571f..22456bc 100644 --- a/src/integrations/tbank/tbank.client.spec.ts +++ b/src/integrations/tbank/tbank.client.spec.ts @@ -46,11 +46,26 @@ describe('TbankClient operations', () => { seconds: String(Math.floor(Date.parse('2026-08-19T00:00:00Z') / 1000)), }, to: { - seconds: String(Math.floor(Date.parse('2026-09-18T00:00:00Z') / 1000)), + seconds: String(Math.floor(Date.parse('2026-09-19T00:00:00Z') / 1000)), }, }); }); + it.each(['getDividends', 'getBondCoupons'] as const)( + '%s converts a leap-day calendar window to a full half-open UTC day', + async (method) => { + const client = Object.create(TbankClient.prototype) as TbankClient; + const call = jest.fn().mockResolvedValue({}); + (client as unknown as { call: jest.Mock }).call = call; + await client[method]('token', 'uid', '2024-02-29', '2024-02-29'); + expect(call).toHaveBeenCalledWith('token', undefined, method, { + instrumentId: 'uid', + from: { seconds: String(Date.parse('2024-02-29T00:00:00Z') / 1000) }, + to: { seconds: String(Date.parse('2024-03-01T00:00:00Z') / 1000) }, + }); + }, + ); + it('propagates the normalized upstream error', async () => { const client = Object.create(TbankClient.prototype) as TbankClient; const error = new ServiceUnavailableException({ diff --git a/src/integrations/tbank/tbank.client.ts b/src/integrations/tbank/tbank.client.ts index 893776c..19376aa 100644 --- a/src/integrations/tbank/tbank.client.ts +++ b/src/integrations/tbank/tbank.client.ts @@ -292,7 +292,7 @@ export class TbankClient { { instrumentId, from: { seconds: String(utcSeconds(from)) }, - to: { seconds: String(utcSeconds(to)) }, + to: { seconds: String(utcSeconds(to, true)) }, }, ).then((response) => response.dividends ?? []); } @@ -344,7 +344,7 @@ export class TbankClient { { instrumentId: uid, from: { seconds: String(utcSeconds(from)) }, - to: { seconds: String(utcSeconds(to)) }, + to: { seconds: String(utcSeconds(to, true)) }, }, ).then((response) => response.events ?? []); } @@ -552,6 +552,9 @@ export class TbankClient { } } -function utcSeconds(date: string): number { - return Math.floor(Date.parse(`${date}T00:00:00Z`) / MILLISECONDS_PER_SECOND); +// Calendar owners supply inclusive UTC days; RPC timestamps use [from, next(to)). +function utcSeconds(date: string, nextDay = false): number { + const instant = new Date(`${date}T00:00:00Z`); + if (nextDay) instant.setUTCDate(instant.getUTCDate() + 1); + return Math.floor(instant.valueOf() / MILLISECONDS_PER_SECOND); } diff --git a/src/modules/investments/income/income-analytics.dto.ts b/src/modules/investments/income/income-analytics.dto.ts index 7e0ab99..d133732 100644 --- a/src/modules/investments/income/income-analytics.dto.ts +++ b/src/modules/investments/income/income-analytics.dto.ts @@ -4,6 +4,7 @@ import { MoneyDto } from '../../../common/money.dto'; import { BrokerageOperationDto } from '../operations/operations.api'; import { + InvestmentDependencyDto, InvestmentReadQueryDto, InvestmentResourceMetaDto, } from '../investment-resource.dto'; @@ -11,12 +12,20 @@ import { const dateOnly = /^\d{4}-\d{2}-\d{2}$/; export class IncomeAnalyticsQueryDto extends InvestmentReadQueryDto { - @ApiPropertyOptional({ format: 'date' }) + @ApiPropertyOptional({ + format: 'date', + description: + 'Inclusive calendar date in Europe/Moscow. Supply both from/to or neither.', + }) @IsOptional() @Matches(dateOnly) from?: string; - @ApiPropertyOptional({ format: 'date' }) + @ApiPropertyOptional({ + format: 'date', + description: + 'Inclusive calendar date in Europe/Moscow, no later than today. Supply both from/to or neither.', + }) @IsOptional() @Matches(dateOnly) to?: string; @@ -152,9 +161,27 @@ export class IncomeSummaryResourceDto { declare meta: InvestmentResourceMetaDto; } +const incomeDependencyRoles = ['selected', 'previous', 'year'] as const; + +export class IncomeDependencyDto extends InvestmentDependencyDto { + @ApiProperty({ enum: incomeDependencyRoles }) + declare role: (typeof incomeDependencyRoles)[number]; +} + +export class IncomeAnalyticsMetaDto extends InvestmentResourceMetaDto { + @ApiProperty({ + type: IncomeDependencyDto, + isArray: true, + required: true, + description: + 'Dependencies identified by role, not array order or resourceKey. A missing role is unavailable.', + }) + declare dependencies: IncomeDependencyDto[]; +} + export class IncomeAnalyticsResourceDto { @ApiProperty({ type: IncomeAnalyticsDto, nullable: true }) declare data: IncomeAnalyticsDto | null; - @ApiProperty({ type: InvestmentResourceMetaDto }) - declare meta: InvestmentResourceMetaDto; + @ApiProperty({ type: IncomeAnalyticsMetaDto }) + declare meta: IncomeAnalyticsMetaDto; } diff --git a/src/modules/investments/income/income-analytics.service.spec.ts b/src/modules/investments/income/income-analytics.service.spec.ts index 56f11fc..fa7c3d9 100644 --- a/src/modules/investments/income/income-analytics.service.spec.ts +++ b/src/modules/investments/income/income-analytics.service.spec.ts @@ -16,6 +16,44 @@ describe('IncomeAnalyticsService', () => { beforeEach(() => jest.clearAllMocks()); + it('labels each comparison dependency independently of its resource key', async () => { + seed([]); + const response = await service.analytics( + 'user', + 'account', + '2026-01-01', + '2026-01-31', + ); + expect(response.meta.dependencies).toEqual([ + expect.objectContaining({ role: 'selected', resourceKey: 'period/0' }), + expect.objectContaining({ role: 'previous', resourceKey: 'period/1' }), + expect.objectContaining({ role: 'year', resourceKey: 'period/2' }), + ]); + }); + + it('labels an unavailable leap-day comparison as year', async () => { + operations.collectRanges.mockImplementation( + (_user: string, _account: string, build: () => unknown[]) => + build().map((_, index) => ({ items: [], meta: completeMeta(index) })), + ); + const response = await service.analytics( + 'user', + 'account', + '2024-02-29', + '2024-02-29', + ); + expect(response.meta.dependencies).toEqual([ + expect.objectContaining({ role: 'selected' }), + expect.objectContaining({ role: 'previous' }), + expect.objectContaining({ + role: 'year', + availability: 'missing', + unavailableReason: 'CALENDAR_OR_ACCOUNT_HISTORY', + }), + ]); + expect(response.data?.yearComparison).toBeNull(); + }); + function seed(items: BrokerageOperationDto[]) { operations.collectRanges.mockImplementation( ( diff --git a/src/modules/investments/income/income-analytics.service.ts b/src/modules/investments/income/income-analytics.service.ts index da650b8..824f1eb 100644 --- a/src/modules/investments/income/income-analytics.service.ts +++ b/src/modules/investments/income/income-analytics.service.ts @@ -10,6 +10,7 @@ import { import { investmentResourceMeta } from '../investment-resource.mapper'; import { IncomeAnalyticsResourceDto, + IncomeDependencyDto, IncomeSummaryResourceDto, } from './income-analytics.dto'; import { DateOnlyRange, moscowDate } from '../investment-calendar'; @@ -90,35 +91,44 @@ export class IncomeAnalyticsService { observe, ); const [current, prior, year] = periods; - const dependencies = periods.map((period) => period.meta); - if (!plan.yearAgo) - dependencies.push({ - ...investmentResourceMeta({ - resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier - dataRevision: 'unavailable', - availability: 'missing', - fetchedAt: null, - freshForMs: 0, - work: null, - unavailableReason: 'CALENDAR_OR_ACCOUNT_HISTORY', - }), - }); + // Preserve the legacy order while consumers migrate to explicit roles. + const dependencies: IncomeDependencyDto[] = [ + { ...current.meta, role: 'selected' }, + { ...prior.meta, role: 'previous' }, + { + ...(plan.yearAgo && year + ? year.meta + : investmentResourceMeta({ + resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier + dataRevision: 'unavailable', + availability: 'missing', + fetchedAt: null, + freshForMs: 0, + work: null, + unavailableReason: 'CALENDAR_OR_ACCOUNT_HISTORY', + })), + role: 'year', + }, + ]; const states = periods.map((period) => period.meta.sync); const work = states.find((state) => state.state === 'running') ?? states.find((state) => state.state === 'failed'); - const meta = investmentResourceMeta({ - resourceKey: `income/${accountId}/${plan.selected.from}/${plan.selected.to}`, - dataRevision: current.meta.dataRevision, - availability: current.meta.availability, - fetchedAt: current.meta.lastSuccessfulAt - ? new Date(current.meta.lastSuccessfulAt) - : null, - freshForMs: current.meta.freshness === 'fresh' ? Infinity : 0, - work: work?.id ? { ...work, id: work.id } : null, - coverage: current.meta.coverage, + const meta = { + ...investmentResourceMeta({ + resourceKey: `income/${accountId}/${plan.selected.from}/${plan.selected.to}`, + dataRevision: current.meta.dataRevision, + availability: current.meta.availability, + fetchedAt: current.meta.lastSuccessfulAt + ? new Date(current.meta.lastSuccessfulAt) + : null, + freshForMs: current.meta.freshness === 'fresh' ? Infinity : 0, + work: work?.id ? { ...work, id: work.id } : null, + coverage: current.meta.coverage, + dependencies, + }), dependencies, - }); + }; if (current.items === null) return { data: null, meta }; assert(current.meta.lastSuccessfulAt); const selected = qualifying(current.items).map((entry) => ({ diff --git a/src/modules/investments/income/income-period.spec.ts b/src/modules/investments/income/income-period.spec.ts index 61fd090..36a17bd 100644 --- a/src/modules/investments/income/income-period.spec.ts +++ b/src/modules/investments/income/income-period.spec.ts @@ -53,4 +53,18 @@ describe('income period planning', () => { to: new Date('2026-08-27T21:44:18.510Z'), }); }); + it.each([ + ['2024-02-29', '2024-02-28T21:00:00.000Z', '2024-02-29T21:00:00.000Z'], + ['2025-12-31', '2025-12-30T21:00:00.000Z', '2025-12-31T21:00:00.000Z'], + ])('maps inclusive %s to one full half-open Moscow day', (date, from, to) => { + expect( + dateOnlyBounds( + { from: date, to: date }, + new Date('2026-01-01T12:00:00Z'), + ), + ).toEqual({ + from: new Date(from), + to: new Date(to), + }); + }); }); diff --git a/src/modules/investments/investment-calendar.ts b/src/modules/investments/investment-calendar.ts index abb6902..4b7eb98 100644 --- a/src/modules/investments/investment-calendar.ts +++ b/src/modules/investments/investment-calendar.ts @@ -1,10 +1,4 @@ const MOSCOW_UTC_OFFSET_HOURS = 3; -const MOSCOW_DAY_END_UTC = { - hours: 20, - minutes: 59, - seconds: 59, - milliseconds: 999, -}; export type DateOnlyRange = { from: string; to: string }; const moscow = 'Europe/Moscow'; const moscowFormatter = new Intl.DateTimeFormat('en-CA', { @@ -19,25 +13,21 @@ export function moscowDate(value: Date): string { return `${get('year')}-${get('month')}-${get('day')}`; } -export function dateOnlyBounds(range: DateOnlyRange): { from: Date; to: Date } { +// Inclusive Moscow calendar dates become a half-open instant range, capped at now. +export function dateOnlyBounds( + range: DateOnlyRange, + now = new Date(), +): { from: Date; to: Date } { const from = dateParts(range.from); const end = dateParts(range.to); const to = new Date( - Date.UTC( - end[0], - end[1] - 1, - end[2], - MOSCOW_DAY_END_UTC.hours, - MOSCOW_DAY_END_UTC.minutes, - MOSCOW_DAY_END_UTC.seconds, - MOSCOW_DAY_END_UTC.milliseconds, - ), + Date.UTC(end[0], end[1] - 1, end[2] + 1, -MOSCOW_UTC_OFFSET_HOURS), ); return { from: new Date( Date.UTC(from[0], from[1] - 1, from[2], -MOSCOW_UTC_OFFSET_HOURS), ), - to: to > new Date() ? new Date() : to, + to: to > now ? now : to, }; } diff --git a/src/modules/investments/investment-sync-plan.ts b/src/modules/investments/investment-sync-plan.ts index 638c176..d54de6b 100644 --- a/src/modules/investments/investment-sync-plan.ts +++ b/src/modules/investments/investment-sync-plan.ts @@ -141,11 +141,8 @@ export function planInvestmentScreen( ? [period.previous, ...(period.yearAgo ? [period.yearAgo] : [])] : []), ]) { - const bounds = dateOnlyBounds(range); - operations( - bounds.from, - new Date(Math.min(bounds.to.valueOf() + 1, now.valueOf())), - ); + const bounds = dateOnlyBounds(range, now); + operations(bounds.from, bounds.to); } if (screen.screen === 'income' && !period.yearAgo) unavailable.push({ diff --git a/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts b/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts index e613cf2..d4b8abc 100644 --- a/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts +++ b/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts @@ -12,7 +12,11 @@ import type { TbankOperationItem, TbankOperationsByCursorResponse, } from '../../../../integrations/tbank/tbank.api'; -import { DateOnlyRange, dateOnlyBounds } from '../../investment-calendar'; +import { + DateOnlyRange, + dateOnlyBounds, + moscowDate, +} from '../../investment-calendar'; import { operationCategory } from './brokerage-operation.mapper'; import { type OperationInterval, @@ -96,14 +100,12 @@ export class BrokerageOperationsRepository { ? new Date(`${oldest}T00:00:00+03:00`) : undefined; const periods = input.periods?.(openedAt).map((period) => { - const bounds = dateOnlyBounds(period); - // Date-only end is inclusive; persisted coverage is half-open. - const end = new Date(`${period.to}T21:00:00Z`); + const now = new Date(); + const bounds = dateOnlyBounds(period, now); return { ...bounds, key: `${period.from}/${period.to}`, - movingEnd: end.valueOf() > Date.now(), - to: new Date(Math.min(end.valueOf(), Date.now())), + movingEnd: period.to >= moscowDate(now), }; }); const binding = createHash('sha256') diff --git a/src/modules/investments/tbank-sync/tbank-screen.spec.ts b/src/modules/investments/tbank-sync/tbank-screen.spec.ts index bf0cc8d..c138b23 100644 --- a/src/modules/investments/tbank-sync/tbank-screen.spec.ts +++ b/src/modules/investments/tbank-sync/tbank-screen.spec.ts @@ -62,4 +62,13 @@ describe('manual sync screen boundary', () => { }), ).toThrow(); }); + it('keeps independently supplied event dates and rejects an end before the default UTC start', () => { + for (const range of [{ from: '2096-02-29' }, { to: '2096-02-29' }]) { + const scope = { screen: 'events' as const, accountId: 'all', ...range }; + expect(normalizeScreen(scope)).toEqual(scope); + } + expect(() => + normalizeScreen({ screen: 'events', accountId: 'all', to: '2000-01-01' }), + ).toThrow(); + }); }); diff --git a/src/modules/investments/tbank-sync/tbank-screen.ts b/src/modules/investments/tbank-sync/tbank-screen.ts index 166d083..eb5a363 100644 --- a/src/modules/investments/tbank-sync/tbank-screen.ts +++ b/src/modules/investments/tbank-sync/tbank-screen.ts @@ -63,12 +63,19 @@ export function normalizeScreen( if (['stock', 'bond'].includes(input.screen) && !input.instrumentUid) fail(); if (input.accountId !== undefined && !accountId) fail(); if (['income', 'events'].includes(input.screen)) { - if (Boolean(input.from) !== Boolean(input.to)) fail(); + if (input.screen === 'income' && Boolean(input.from) !== Boolean(input.to)) + fail(); for (const value of [input.from, input.to]) if (value && !/^\d{4}-\d{2}-\d{2}$/.test(value)) fail(); } if (input.from && input.to && new Date(input.from) > new Date(input.to)) fail(); + if ( + input.screen === 'events' && + input.to && + (input.from ?? new Date().toISOString().slice(0, 10)) > input.to + ) + fail(); if ( input.candleRange && new Date(input.candleRange.from) >= new Date(input.candleRange.to) diff --git a/src/modules/investments/tbank-sync/tbank-sync.controller.ts b/src/modules/investments/tbank-sync/tbank-sync.controller.ts index faaabe3..ee8bf85 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.controller.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.controller.ts @@ -16,6 +16,7 @@ import { ApiAcceptedResponse, ApiBearerAuth, ApiBody, + ApiExtraModels, ApiOkResponse, ApiTags, } from '@nestjs/swagger'; @@ -24,12 +25,21 @@ import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-r import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api'; import { InvestmentScreenDto, + IncomeInvestmentScreenDto, + EventsInvestmentScreenDto, + TimestampInvestmentScreenDto, + investmentScreenSchema, TbankSyncAcceptedDto, TbankSyncStatusDto, } from './tbank-sync.dto'; import { TbankSyncService } from './tbank-sync.service'; @ApiTags('tbank-sync') +@ApiExtraModels( + IncomeInvestmentScreenDto, + EventsInvestmentScreenDto, + TimestampInvestmentScreenDto, +) @ApiProblemResponses() @ApiBearerAuth() @UseGuards(JwtAuthGuard) @@ -38,7 +48,7 @@ export class TbankSyncController { constructor(private readonly sync: TbankSyncService) {} @Post() @HttpCode(HttpStatus.ACCEPTED) - @ApiBody({ type: InvestmentScreenDto }) + @ApiBody({ schema: investmentScreenSchema }) @ApiAcceptedResponse({ type: TbankSyncAcceptedDto }) request( @Req() request: Request & { user: AccessPayload }, diff --git a/src/modules/investments/tbank-sync/tbank-sync.dto.ts b/src/modules/investments/tbank-sync/tbank-sync.dto.ts index 4e2e8b5..011c9e7 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.dto.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.dto.ts @@ -1,4 +1,10 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + ApiProperty, + ApiPropertyOptional, + getSchemaPath, + OmitType, + PickType, +} from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsArray, @@ -46,11 +52,21 @@ export class InvestmentScreenDto { @MinLength(1) @MaxLength(maxAccountIdLength) accountId?: string; - @ApiPropertyOptional({ format: 'date-time' }) + @ApiPropertyOptional({ + oneOf: [ + { type: 'string', format: 'date' }, + { type: 'string', format: 'date-time' }, + ], + }) @IsOptional() @IsDateString({ strict: true }) from?: string; - @ApiPropertyOptional({ format: 'date-time' }) + @ApiPropertyOptional({ + oneOf: [ + { type: 'string', format: 'date' }, + { type: 'string', format: 'date-time' }, + ], + }) @IsOptional() @IsDateString({ strict: true }) to?: string; @@ -85,9 +101,98 @@ export class InvestmentScreenDto { @Max(maxPageSize) limit?: number; } + +export class IncomeInvestmentScreenDto extends PickType(InvestmentScreenDto, [ + 'accountId', +]) { + @ApiProperty({ enum: ['income'] }) declare screen: 'income'; + @ApiProperty({ required: true }) declare accountId: string; + @ApiPropertyOptional({ + format: 'date', + description: + 'Inclusive start calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither for the current Moscow month.', + }) + from?: string; + @ApiPropertyOptional({ + format: 'date', + description: + 'Inclusive end calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither. Timestamp queries use the start of the following Moscow day as the exclusive end, capped at now.', + }) + to?: string; +} + +export class EventsInvestmentScreenDto extends PickType(InvestmentScreenDto, [ + 'accountId', +]) { + @ApiProperty({ enum: ['events'] }) declare screen: 'events'; + @ApiProperty({ required: true }) declare accountId: string; + @ApiPropertyOptional({ + format: 'date', + description: + 'Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC.', + }) + from?: string; + @ApiPropertyOptional({ + format: 'date', + description: + 'Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end.', + }) + to?: string; +} + +const timestampScreens = [ + 'accounts', + 'account', + 'operations', + 'stocks', + 'bonds', + 'stock', + 'bond', +] as const; + +export class TimestampInvestmentScreenDto extends OmitType( + InvestmentScreenDto, + ['screen', 'from', 'to'], +) { + // The runtime validator retains legacy date-only inputs; the public contract uses instants. + @ApiProperty({ enum: timestampScreens }) + declare screen: (typeof timestampScreens)[number]; + @ApiPropertyOptional({ + format: 'date-time', + description: 'Start instant for operations.', + }) + from?: string; + @ApiPropertyOptional({ + format: 'date-time', + description: 'End instant for operations.', + }) + to?: string; +} + +export const investmentScreenSchema = { + oneOf: [ + IncomeInvestmentScreenDto, + EventsInvestmentScreenDto, + TimestampInvestmentScreenDto, + ].map((model) => ({ $ref: getSchemaPath(model) })), + discriminator: { + propertyName: 'screen', + mapping: { + income: getSchemaPath(IncomeInvestmentScreenDto), + events: getSchemaPath(EventsInvestmentScreenDto), + ...Object.fromEntries( + timestampScreens.map((screen) => [ + screen, + getSchemaPath(TimestampInvestmentScreenDto), + ]), + ), + }, + }, +}; + export class TbankSyncAcceptedDto { @ApiProperty({ format: 'uuid' }) declare id: string; - @ApiProperty({ type: InvestmentScreenDto }) + @ApiProperty({ type: Object, ...investmentScreenSchema }) declare scope: InvestmentScreenDto; @ApiProperty({ format: 'date-time' }) declare now: string; } @@ -98,7 +203,7 @@ export class TbankSyncProgressDto { } export class TbankSyncStatusDto { @ApiProperty({ format: 'uuid' }) declare id: string; - @ApiProperty({ type: InvestmentScreenDto, nullable: true }) + @ApiProperty({ type: Object, ...investmentScreenSchema, nullable: true }) declare scope: InvestmentScreenDto | null; @ApiProperty({ format: 'date-time' }) declare now: string; @ApiProperty({ enum: ['running', 'succeeded', 'failed', 'cancelled'] }) diff --git a/src/modules/investments/upcoming-events/upcoming-events.dto.ts b/src/modules/investments/upcoming-events/upcoming-events.dto.ts index 402a37a..55a7d8b 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.dto.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.dto.ts @@ -10,7 +10,8 @@ import { AccountReferenceDto } from '../account-reference.dto'; export class UpcomingEventsQueryDto extends InvestmentReadQueryDto { @ApiPropertyOptional({ format: 'date', - description: 'Inclusive start date (YYYY-MM-DD). Defaults to today in UTC.', + description: + 'Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC.', }) @IsOptional() @Matches(/^\d{4}-\d{2}-\d{2}$/) @@ -20,7 +21,7 @@ export class UpcomingEventsQueryDto extends InvestmentReadQueryDto { @ApiPropertyOptional({ format: 'date', description: - 'Inclusive end date (YYYY-MM-DD). Defaults to 30 days after from.', + 'Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end.', }) @IsOptional() @Matches(/^\d{4}-\d{2}-\d{2}$/) diff --git a/test/openapi-contract.e2e-spec.ts b/test/openapi-contract.e2e-spec.ts index a201c2c..e8dbac7 100644 --- a/test/openapi-contract.e2e-spec.ts +++ b/test/openapi-contract.e2e-spec.ts @@ -1,5 +1,6 @@ import { INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; +import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; @@ -22,6 +23,109 @@ describe('OpenAPI contract (integration)', () => { afterAll(() => app.close()); + it('distinguishes calendar screen dates from operation instants in requests and responses', () => { + const document = createOpenApiDocument(app); + const schemas = document.components?.schemas; + const screenSchema = { + oneOf: [ + { $ref: '#/components/schemas/IncomeInvestmentScreenDto' }, + { $ref: '#/components/schemas/EventsInvestmentScreenDto' }, + { $ref: '#/components/schemas/TimestampInvestmentScreenDto' }, + ], + discriminator: { + propertyName: 'screen', + mapping: { + income: '#/components/schemas/IncomeInvestmentScreenDto', + events: '#/components/schemas/EventsInvestmentScreenDto', + operations: '#/components/schemas/TimestampInvestmentScreenDto', + }, + }, + }; + expect( + document.paths['/api/v1/tbank-sync'].post?.requestBody, + ).toMatchObject({ + content: { 'application/json': { schema: screenSchema } }, + }); + expect(schemas?.TbankSyncAcceptedDto).toMatchObject({ + properties: { scope: screenSchema }, + }); + expect(schemas?.TbankSyncStatusDto).toMatchObject({ + properties: { scope: { ...screenSchema, nullable: true } }, + }); + for (const [model, screen] of [ + ['IncomeInvestmentScreenDto', 'income'], + ['EventsInvestmentScreenDto', 'events'], + ]) { + const schema = schemas?.[model]; + assert(schema && !('$ref' in schema)); + expect(schema.required).toContain('screen'); + expect(schema.required).toContain('accountId'); + expect(schema).toMatchObject({ + properties: { + screen: { enum: [screen] }, + from: { type: 'string', format: 'date' }, + to: { type: 'string', format: 'date' }, + }, + }); + } + expect(schemas?.TimestampInvestmentScreenDto).toMatchObject({ + properties: { + from: { type: 'string', format: 'date-time' }, + to: { type: 'string', format: 'date-time' }, + }, + }); + expect(schemas).not.toHaveProperty('InvestmentScreenDto'); + for (const scope of ['all', '{accountId}']) { + for (const [resource, format] of [ + ['income-analytics', 'date'], + ['events', 'date'], + ['operations', 'date-time'], + ]) { + const parameters = + document.paths[`/api/v1/brokerage-accounts/${scope}/${resource}`].get + ?.parameters; + for (const name of ['from', 'to']) + expect( + parameters?.find( + (parameter) => 'name' in parameter && parameter.name === name, + ), + ).toMatchObject({ + name, + in: 'query', + schema: { format }, + }); + } + } + }); + + it('exposes required semantic income dependency roles', () => { + const schemas = createOpenApiDocument(app).components?.schemas; + expect(schemas?.IncomeAnalyticsResourceDto).toMatchObject({ + properties: { + meta: { $ref: '#/components/schemas/IncomeAnalyticsMetaDto' }, + }, + }); + const meta = schemas?.IncomeAnalyticsMetaDto; + assert(meta && !('$ref' in meta)); + expect(meta.required).toContain('dependencies'); + expect(meta).toMatchObject({ + properties: { + dependencies: { + type: 'array', + items: { $ref: '#/components/schemas/IncomeDependencyDto' }, + }, + }, + }); + const dependency = schemas?.IncomeDependencyDto; + assert(dependency && !('$ref' in dependency)); + expect(dependency.required).toContain('role'); + expect(dependency).toMatchObject({ + properties: { + role: { type: 'string', enum: ['selected', 'previous', 'year'] }, + }, + }); + }); + it('generates the runtime contract without server startup or deployment configuration', async () => { const directory = await mkdtemp(resolve(tmpdir(), 'wealthledger-openapi-')); try { diff --git a/test/tbank-income-local.e2e-spec.ts b/test/tbank-income-local.e2e-spec.ts index 617b83b..8a2f71a 100644 --- a/test/tbank-income-local.e2e-spec.ts +++ b/test/tbank-income-local.e2e-spec.ts @@ -138,7 +138,15 @@ describe('local income completeness and exact money (SQLite)', () => { ).toEqual(['a', 'b']); expect(result.data.previous).toBeNull(); expect(result.data.yearComparison).toBeNull(); - expect(result.meta.dependencies?.[1].availability).toBe('missing'); + expect(result.meta.dependencies.map(({ role }) => role)).toEqual([ + 'selected', + 'previous', + 'year', + ]); + expect( + result.meta.dependencies.find(({ role }) => role === 'previous') + ?.availability, + ).toBe('missing'); expect(result.data.calculatedAt).toBe('2025-02-01T00:00:00.000Z'); await prisma.$disconnect(); } @@ -264,9 +272,10 @@ describe('local income completeness and exact money (SQLite)', () => { }); it('distinguishes unavailable calendar comparison without scheduling it', async () => { const result = await read('a', 'from=2024-02-29&to=2024-02-29'); - expect(result.meta.dependencies?.at(-1)?.unavailableReason).toBe( - 'CALENDAR_OR_ACCOUNT_HISTORY', - ); + expect( + result.meta.dependencies.find(({ role }) => role === 'year') + ?.unavailableReason, + ).toBe('CALENDAR_OR_ACCOUNT_HISTORY'); expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); }); }); diff --git a/test/tbank-screen-sync.e2e-spec.ts b/test/tbank-screen-sync.e2e-spec.ts index 5df8d13..e8b2d5a 100644 --- a/test/tbank-screen-sync.e2e-spec.ts +++ b/test/tbank-screen-sync.e2e-spec.ts @@ -122,6 +122,25 @@ describe('screen sync HTTP and SQLite', () => { from: '2026-02-31', to: '2026-03-01', }, + ...(['income', 'events'] as const).flatMap((screen) => [ + { screen, accountId: 'all', from: '2025-02-29', to: '2025-03-01' }, + { screen, accountId: 'all', from: '2024-03-01', to: '2024-02-29' }, + { + screen, + accountId: 'all', + from: '2024-02-29T00:00:00Z', + to: '2024-02-29', + }, + { + screen, + accountId: 'all', + from: '2024-02-29', + to: '2024-02-29T23:59:59Z', + }, + ]), + { screen: 'income', accountId: 'all', from: '2024-02-29' }, + { screen: 'income', accountId: 'all', to: '2024-02-29' }, + { screen: 'events', accountId: 'all', to: '2000-01-01' }, { screen: 'stock', instrumentUid: '00000000-0000-4000-8000-000000000001', @@ -145,6 +164,38 @@ describe('screen sync HTTP and SQLite', () => { expect(await prisma.tbankSyncWork.count()).toBe(0); expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); }); + it.each([ + { + screen: 'income', + accountId: 'all', + from: '2024-02-29', + to: '2024-02-29', + }, + { + screen: 'events', + accountId: 'all', + from: '2096-02-29', + to: '2096-02-29', + }, + { screen: 'events', accountId: 'all', from: '2096-02-29' }, + { screen: 'events', accountId: 'all', to: '2096-02-29' }, + { + screen: 'operations', + accountId: 'all', + from: '2024-02-29T00:00:00Z', + to: '2024-03-01T00:00:00Z', + }, + ])( + 'preserves valid calendar/instant scope in accepted and status responses: %j', + async (scope) => { + const response = await post(scope).expect(202); + const accepted = response.body as { id: string; scope: object }; + expect(accepted.scope).toEqual(scope); + const result = await settled(accepted.id); + expect(result.scope).toEqual(scope); + expect(result.state).toBe('succeeded'); + }, + ); it('keeps failed work terminal, cancels abandoned work without restart, and hides old connection handles', async () => { upstream.getOpenAccounts.mockRejectedValue( new Error('upstream details never exposed'), diff --git a/test/upcoming-events.e2e-spec.ts b/test/upcoming-events.e2e-spec.ts index c173dcf..92d1131 100644 --- a/test/upcoming-events.e2e-spec.ts +++ b/test/upcoming-events.e2e-spec.ts @@ -497,4 +497,38 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { await get(`brokerage-accounts/account/events?${query}`).expect(400); expect(upstream.getDividends).not.toHaveBeenCalled(); }); + + it('uses UTC today for an end-only range and includes the entire leap day', async () => { + const today = new Date().toISOString().slice(0, 10); + await get('brokerage-accounts/account/events?to=2096-02-29').expect(200); + await settled(); + expect(upstream.getDividends).toHaveBeenCalledWith( + 'synthetic-owner', + shareUid, + today, + '2096-02-29', + ); + upstream.getDividends.mockResolvedValue([ + { + recordDate: { + seconds: String(Date.parse('2096-02-29T23:59:59Z') / 1000), + }, + dividendNet: { units: '7', currency: 'rub' }, + }, + { + recordDate: time('2096-03-01'), + dividendNet: { units: '99', currency: 'rub' }, + }, + ]); + const range = { from: '2096-02-29', to: '2096-02-29' }; + await events(false, 'account', 'owner', range).expect(200); + await settled(); + const result = (await events(true, 'account', 'owner', range).expect(200)) + .body as UpcomingEventsResourceDto; + expect(result.data?.events).toHaveLength(1); + expect(result.data?.events[0]).toMatchObject({ + date: '2096-02-29', + projectedAmount: { currency: 'RUB', value: '14' }, + }); + }); });