chore: strengthen backend eslint profile
This commit is contained in:
@@ -1,14 +1,16 @@
|
||||
import type { Request } from 'express';
|
||||
|
||||
import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
import type { Request } from 'express';
|
||||
|
||||
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
||||
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
|
||||
import { InvestmentReadQueryDto } from '../investment-resource.dto';
|
||||
import {
|
||||
IncomeAnalyticsResourceDto,
|
||||
IncomeAnalyticsQueryDto,
|
||||
IncomeAnalyticsResourceDto,
|
||||
IncomeSummaryResourceDto,
|
||||
} from './income-analytics.dto';
|
||||
import { InvestmentReadQueryDto } from '../investment-resource.dto';
|
||||
import { IncomeAnalyticsService } from './income-analytics.service';
|
||||
|
||||
@ApiTags('brokerage-accounts')
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, Matches } from 'class-validator';
|
||||
import { MoneyDto } from '../../../common/money.dto';
|
||||
import { BrokerageOperationDto } from '../operations/operations.api';
|
||||
|
||||
import { MoneyDto } from '../../../common/money.dto';
|
||||
import {
|
||||
InvestmentDependencyDto,
|
||||
InvestmentReadQueryDto,
|
||||
InvestmentResourceMetaDto,
|
||||
} from '../investment-resource.dto';
|
||||
import { BrokerageOperationDto } from '../operations/operations.api';
|
||||
|
||||
const dateOnly = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
@@ -102,16 +102,16 @@ export class IncomeInstrumentDto {
|
||||
declare key: string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
declare figi: string | null;
|
||||
declare figi: null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
declare instrumentUid: string | null;
|
||||
declare instrumentUid: null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
declare ticker: string | null;
|
||||
declare ticker: null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
declare instrumentType: string | null;
|
||||
declare instrumentType: null | string;
|
||||
|
||||
@ApiProperty()
|
||||
declare displayName: string;
|
||||
@@ -142,7 +142,7 @@ export class IncomeAnalyticsDto extends IncomeSummaryDto {
|
||||
declare yearComparison: IncomeComparisonDto | null;
|
||||
|
||||
@ApiProperty({ enum: ['day', 'week', 'month'] })
|
||||
declare bucketGranularity: 'day' | 'week' | 'month';
|
||||
declare bucketGranularity: 'day' | 'month' | 'week';
|
||||
|
||||
@ApiProperty({ type: IncomeBucketDto, isArray: true })
|
||||
declare buckets: IncomeBucketDto[];
|
||||
@@ -157,6 +157,7 @@ export class IncomeAnalyticsDto extends IncomeSummaryDto {
|
||||
export class IncomeSummaryResourceDto {
|
||||
@ApiProperty({ type: IncomeSummaryDto, nullable: true })
|
||||
declare data: IncomeSummaryDto | null;
|
||||
|
||||
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||
declare meta: InvestmentResourceMetaDto;
|
||||
}
|
||||
@@ -169,6 +170,7 @@ export class IncomeDependencyDto extends InvestmentDependencyDto {
|
||||
}
|
||||
|
||||
export class IncomeAnalyticsMetaDto extends InvestmentResourceMetaDto {
|
||||
// eslint-disable-next-line @darraghor/nestjs-typed/api-property-matches-property-optionality -- Overrides optional dependencies inherited from InvestmentResourceMetaDto.
|
||||
@ApiProperty({
|
||||
type: IncomeDependencyDto,
|
||||
isArray: true,
|
||||
@@ -182,6 +184,7 @@ export class IncomeAnalyticsMetaDto extends InvestmentResourceMetaDto {
|
||||
export class IncomeAnalyticsResourceDto {
|
||||
@ApiProperty({ type: IncomeAnalyticsDto, nullable: true })
|
||||
declare data: IncomeAnalyticsDto | null;
|
||||
|
||||
@ApiProperty({ type: IncomeAnalyticsMetaDto })
|
||||
declare meta: IncomeAnalyticsMetaDto;
|
||||
}
|
||||
|
||||
@@ -6,8 +6,9 @@ jest.mock('../operations/application/brokerage-operations.service', () => ({
|
||||
}));
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { BrokerageOperationDto } from '../operations/presentation/brokerage-operation.dto';
|
||||
|
||||
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||
import { BrokerageOperationDto } from '../operations/presentation/brokerage-operation.dto';
|
||||
import { IncomeAnalyticsService } from './income-analytics.service';
|
||||
|
||||
describe('IncomeAnalyticsService', () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
add,
|
||||
compare,
|
||||
@@ -7,24 +8,21 @@ import {
|
||||
formatDecimal,
|
||||
subtract,
|
||||
} from '../../../common/decimal';
|
||||
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||
import {
|
||||
IncomeAnalyticsResourceDto,
|
||||
IncomeDependencyDto,
|
||||
IncomeSummaryResourceDto,
|
||||
} from './income-analytics.dto';
|
||||
import { DateOnlyRange, moscowDate } from '../investment-calendar';
|
||||
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||
import {
|
||||
BrokerageOperationDto,
|
||||
BrokerageOperationsService,
|
||||
} from '../operations/operations.api';
|
||||
|
||||
import {
|
||||
IncomeAnalyticsEntryDto,
|
||||
IncomeAnalyticsResourceDto,
|
||||
IncomeComparisonDto,
|
||||
IncomeCurrencySummaryDto,
|
||||
IncomeDependencyDto,
|
||||
IncomeInstrumentDto,
|
||||
IncomeSummaryDto,
|
||||
IncomeSummaryResourceDto,
|
||||
} from './income-analytics.dto';
|
||||
import {
|
||||
IncomeBucketGranularity,
|
||||
@@ -96,9 +94,9 @@ export class IncomeAnalyticsService {
|
||||
{ ...current.meta, role: 'selected' },
|
||||
{ ...prior.meta, role: 'previous' },
|
||||
{
|
||||
...(plan.yearAgo && year
|
||||
? year.meta
|
||||
: investmentResourceMeta({
|
||||
...(plan.yearAgo && year ?
|
||||
year.meta :
|
||||
investmentResourceMeta({
|
||||
resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier
|
||||
dataRevision: 'unavailable',
|
||||
availability: 'missing',
|
||||
@@ -119,9 +117,9 @@ export class IncomeAnalyticsService {
|
||||
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,
|
||||
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,
|
||||
@@ -144,13 +142,13 @@ export class IncomeAnalyticsService {
|
||||
current.meta.lastSuccessfulAt,
|
||||
),
|
||||
previous:
|
||||
prior.items === null
|
||||
? null
|
||||
: comparison(selected, plan.previous, qualifying(prior.items)),
|
||||
prior.items === null ?
|
||||
null :
|
||||
comparison(selected, plan.previous, qualifying(prior.items)),
|
||||
yearComparison:
|
||||
plan.yearAgo && year?.items !== null && year
|
||||
? comparison(selected, plan.yearAgo, qualifying(year.items))
|
||||
: null,
|
||||
plan.yearAgo && year?.items !== null && year ?
|
||||
comparison(selected, plan.yearAgo, qualifying(year.items)) :
|
||||
null,
|
||||
bucketGranularity: plan.granularity,
|
||||
buckets: buckets(plan.selected, plan.granularity, selected),
|
||||
instruments: instruments(selected),
|
||||
@@ -184,9 +182,9 @@ function totals(entries: BrokerageOperationDto[]): IncomeCurrencySummaryDto[] {
|
||||
if (!entry.payment) continue;
|
||||
const total = values.get(entry.payment.currency) ?? zeroTotal();
|
||||
const value = decimalFromString(entry.payment.value);
|
||||
if (entry.type === 'OPERATION_TYPE_COUPON')
|
||||
if (entry.type === 'OPERATION_TYPE_COUPON') {
|
||||
total.coupon = add(total.coupon, value);
|
||||
else total.dividend = add(total.dividend, value);
|
||||
} else total.dividend = add(total.dividend, value);
|
||||
values.set(entry.payment.currency, total);
|
||||
}
|
||||
return [...values]
|
||||
@@ -207,6 +205,7 @@ function comparison(
|
||||
);
|
||||
const currencies = [
|
||||
...new Set([...selected.keys(), ...compared.keys()]),
|
||||
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
|
||||
].sort();
|
||||
return {
|
||||
period: comparedPeriod,
|
||||
@@ -279,8 +278,9 @@ function instruments(
|
||||
function instrumentKey(entry: BrokerageOperationDto): string {
|
||||
if (entry.instrumentUid) return `uid:${entry.instrumentUid}`;
|
||||
if (entry.figi) return `figi:${entry.figi}`;
|
||||
if (entry.ticker || entry.instrumentType)
|
||||
if (entry.ticker || entry.instrumentType) {
|
||||
return `ticker:${entry.ticker ?? ''}:${entry.instrumentType ?? ''}`;
|
||||
}
|
||||
return `operation:${entry.account ? `${entry.account.id}:` : ''}${entry.id}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { dateOnlyBounds } from '../investment-calendar';
|
||||
import { planIncomePeriod, rangeBuckets } from './income-period';
|
||||
|
||||
@@ -17,8 +18,9 @@ describe('income period planning', () => {
|
||||
['2026-03-02', '2026-03-01'],
|
||||
['2026-01-01', '2027-01-02'],
|
||||
['2026-01-01', '2026-04-01'],
|
||||
])
|
||||
]) {
|
||||
expect(() => planIncomePeriod(from, to)).toThrow(BadRequestException);
|
||||
}
|
||||
});
|
||||
|
||||
it('uses a full previous month, skips impossible year-ago dates, and keeps empty buckets', () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { MILLISECONDS_PER_DAY } from '../../../common/time';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { MILLISECONDS_PER_DAY } from '../../../common/time';
|
||||
import {
|
||||
DateOnlyRange,
|
||||
dateParts,
|
||||
@@ -13,41 +14,41 @@ const DAYS_PER_WEEK = 7;
|
||||
const ISO_MONTH_LENGTH = 7;
|
||||
const ISO_YEAR_LENGTH = 4;
|
||||
|
||||
export type IncomeBucketGranularity = 'day' | 'week' | 'month';
|
||||
export type IncomeBucketGranularity = 'day' | 'month' | 'week';
|
||||
|
||||
export function planIncomePeriod(
|
||||
from: string | undefined,
|
||||
to: string | undefined,
|
||||
openedDate?: Date,
|
||||
): {
|
||||
selected: DateOnlyRange;
|
||||
previous: DateOnlyRange;
|
||||
yearAgo: DateOnlyRange | null;
|
||||
granularity: IncomeBucketGranularity;
|
||||
previous: DateOnlyRange;
|
||||
selected: DateOnlyRange;
|
||||
yearAgo: DateOnlyRange | null;
|
||||
} {
|
||||
if (Boolean(from) !== Boolean(to)) throw invalidPeriod();
|
||||
const today = moscowDate(new Date());
|
||||
const selected =
|
||||
from && to ? { from, to } : { from: firstOfMonth(today), to: today };
|
||||
assertRange(selected, today);
|
||||
const previous = isFullMonth(selected)
|
||||
? previousMonth(selected.from)
|
||||
: shiftDays(selected, -rangeDays(selected));
|
||||
const previous = isFullMonth(selected) ?
|
||||
previousMonth(selected.from) :
|
||||
shiftDays(selected, -rangeDays(selected));
|
||||
const candidate = yearAgo(selected);
|
||||
const yearAgoRange =
|
||||
candidate && (!openedDate || candidate.from >= moscowDate(openedDate))
|
||||
? candidate
|
||||
: null;
|
||||
candidate && (!openedDate || candidate.from >= moscowDate(openedDate)) ?
|
||||
candidate :
|
||||
null;
|
||||
return {
|
||||
selected,
|
||||
previous,
|
||||
yearAgo: yearAgoRange,
|
||||
granularity:
|
||||
rangeDays(selected) <= DAILY_BUCKET_MAX_DAYS
|
||||
? 'day'
|
||||
: rangeDays(selected) <= WEEKLY_BUCKET_MAX_DAYS
|
||||
? 'week'
|
||||
: 'month',
|
||||
rangeDays(selected) <= DAILY_BUCKET_MAX_DAYS ?
|
||||
'day' :
|
||||
rangeDays(selected) <= WEEKLY_BUCKET_MAX_DAYS ?
|
||||
'week' :
|
||||
'month',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -55,24 +56,25 @@ export function rangeBuckets(
|
||||
range: DateOnlyRange,
|
||||
granularity: IncomeBucketGranularity,
|
||||
): DateOnlyRange[] {
|
||||
if (granularity === 'day')
|
||||
if (granularity === 'day') {
|
||||
return dates(range).map((date) => ({ from: date, to: date }));
|
||||
}
|
||||
const buckets: DateOnlyRange[] = [];
|
||||
let cursor =
|
||||
granularity === 'week'
|
||||
? addDays(
|
||||
granularity === 'week' ?
|
||||
addDays(
|
||||
range.from,
|
||||
-(
|
||||
(utcDate(range.from).getUTCDay() + DAYS_PER_WEEK - 1) %
|
||||
DAYS_PER_WEEK
|
||||
),
|
||||
)
|
||||
: `${range.from.slice(0, ISO_MONTH_LENGTH)}-01`;
|
||||
) :
|
||||
`${range.from.slice(0, ISO_MONTH_LENGTH)}-01`;
|
||||
while (cursor <= range.to) {
|
||||
const end =
|
||||
granularity === 'week'
|
||||
? addDays(cursor, DAYS_PER_WEEK - 1)
|
||||
: endOfMonth(cursor);
|
||||
granularity === 'week' ?
|
||||
addDays(cursor, DAYS_PER_WEEK - 1) :
|
||||
endOfMonth(cursor);
|
||||
buckets.push({
|
||||
from: cursor < range.from ? range.from : cursor,
|
||||
to: end > range.to ? range.to : end,
|
||||
@@ -86,7 +88,7 @@ export function rangeDays(range: DateOnlyRange): number {
|
||||
return (
|
||||
Math.round(
|
||||
(utcDate(range.to).valueOf() - utcDate(range.from).valueOf()) /
|
||||
MILLISECONDS_PER_DAY,
|
||||
MILLISECONDS_PER_DAY,
|
||||
) + 1
|
||||
);
|
||||
}
|
||||
@@ -109,8 +111,9 @@ function addDays(value: string, days: number): string {
|
||||
|
||||
function dates(range: DateOnlyRange): string[] {
|
||||
const result: string[] = [];
|
||||
for (let date = range.from; date <= range.to; date = addDays(date, 1))
|
||||
for (let date = range.from; date <= range.to; date = addDays(date, 1)) {
|
||||
result.push(date);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -142,9 +145,9 @@ function yearAgo(range: DateOnlyRange): DateOnlyRange | null {
|
||||
const shift = (value: string) => {
|
||||
const [year, month, day] = dateParts(value);
|
||||
const result = `${String(year - 1).padStart(ISO_YEAR_LENGTH, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
return utcDate(result).toISOString().slice(0, 10) === result
|
||||
? result
|
||||
: null;
|
||||
return utcDate(result).toISOString().slice(0, 10) === result ?
|
||||
result :
|
||||
null;
|
||||
};
|
||||
const from = shift(range.from);
|
||||
const to = shift(range.to);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { OperationsModule } from '../operations/operations.module';
|
||||
import { IncomeAnalyticsController } from './income-analytics.controller';
|
||||
|
||||
Reference in New Issue
Block a user