fix(investments): align roles and date contracts
Backend security / security (push) Successful in 9s
Backend verify / verify (push) Successful in 10m23s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Successful in 10m39s
Conventional commits / commits (pull_request) Successful in 29s

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.
This commit is contained in:
2026-09-27 11:38:13 +03:00
parent e4232138d0
commit 516e43203e
18 changed files with 500 additions and 74 deletions
@@ -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;
}
@@ -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(
(
@@ -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) => ({
@@ -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),
});
});
});
+7 -17
View File
@@ -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,
};
}
@@ -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({
@@ -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')
@@ -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();
});
});
@@ -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)
@@ -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 },
@@ -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'] })
@@ -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}$/)