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
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:
@@ -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),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user