From 516e43203eb3fee8e6a0c7018be2fee11f1b900c Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sun, 27 Sep 2026 11:38:13 +0300 Subject: [PATCH 01/22] 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' }, + }); + }); }); From 4796d9d3f0160d96004a982f6c2526f199de4b9f Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sun, 27 Sep 2026 14:09:05 +0300 Subject: [PATCH 02/22] fix: harden auth and investment synchronization Resolve A01-A10 and C01-C07 from the backend audit. Preserve auth failures, forced candle reconciliation and forecast freshness. Validate MOEX responses and nested HTTP errors; remove unused legacy paths. Verified: 209 unit tests, 262 SQLite e2e tests on Linux, OpenAPI and durability. Record the remaining macOS native SQLite abort in the audit. --- .ai/architecture.md | 6 +- .ai/conventions/error-handling.md | 4 +- .ai/conventions/http-api.md | 6 +- BACKEND_AUDIT.md | 673 ++++++++++++++++++ .../api-safe-error-response.decorator.ts | 6 + .../filters/http-exception.filter.spec.ts | 8 +- src/common/filters/http-exception.filter.ts | 26 +- .../logging/request-logging.middleware.ts | 3 - .../logging/structured-logger.service.ts | 1 - src/integrations/moex/moex.client.spec.ts | 185 ++++- src/integrations/moex/moex.client.ts | 21 +- .../tbank/tbank-access.service.spec.ts | 162 ----- .../tbank/tbank-access.service.ts | 97 +-- .../tbank/tbank-account.policy.spec.ts | 27 +- .../tbank/tbank-account.policy.ts | 11 - src/integrations/tbank/tbank.api.ts | 3 +- src/main.ts | 15 +- src/modules/auth/application/auth.service.ts | 44 +- .../auth/presentation/auth.controller.spec.ts | 21 - .../auth/presentation/auth.controller.ts | 2 +- .../domain/bank-card.calculations.spec.ts | 15 +- .../domain/bank-card.calculations.ts | 10 +- .../application/candle.service.spec.ts | 113 +++ .../instruments/application/candle.service.ts | 105 ++- .../application/catalog-discovery.service.ts | 2 +- .../application/catalog-enrichment.service.ts | 14 +- .../application/instrument-market.ts | 14 - .../instrument-position.spec.ts} | 37 +- .../infrastructure/candle.repository.ts | 35 +- .../catalog-discovery.repository.ts | 2 +- .../stocks/application/stock-position.ts | 5 - .../investments/investment-sync-plan.spec.ts | 2 - .../investments/investment-sync-plan.ts | 2 +- .../brokerage-operations.service.ts | 10 +- .../brokerage-operation.mapper.spec.ts | 11 + .../brokerage-operation.mapper.ts | 12 +- .../brokerage-operations.repository.ts | 26 +- .../investments/operations/operations.api.ts | 5 +- .../portfolio/application/forecast.service.ts | 23 +- .../application/portfolio.service.ts | 10 +- .../investments/portfolio/portfolio.api.ts | 1 - .../tbank-sync/tbank-sync.controller.ts | 15 +- .../tbank-sync/tbank-sync.repository.ts | 2 +- .../tbank-sync/tbank-sync.service.ts | 21 +- test/app.e2e-spec.ts | 136 +++- test/bank-cards.e2e-spec.ts | 34 + test/fixtures/tbank-sync-crash.cjs | 2 +- test/openapi-contract.e2e-spec.ts | 17 + test/tbank-candles-persistence.e2e-spec.ts | 150 +++- test/tbank-catalog-discovery.e2e-spec.ts | 2 +- test/tbank-ledger-local.e2e-spec.ts | 93 +++ test/tbank-metadata-persistence.e2e-spec.ts | 2 +- test/tbank-operations-sync.e2e-spec.ts | 2 +- test/tbank-screen-sync.e2e-spec.ts | 114 ++- test/upcoming-events.e2e-spec.ts | 158 +++- 55 files changed, 1954 insertions(+), 569 deletions(-) create mode 100644 BACKEND_AUDIT.md delete mode 100644 src/integrations/tbank/tbank-access.service.spec.ts delete mode 100644 src/modules/auth/presentation/auth.controller.spec.ts create mode 100644 src/modules/investments/instruments/application/candle.service.spec.ts rename src/modules/investments/instruments/{stocks/application/stock-position.spec.ts => application/instrument-position.spec.ts} (72%) delete mode 100644 src/modules/investments/instruments/stocks/application/stock-position.ts diff --git a/.ai/architecture.md b/.ai/architecture.md index b89cd57..770f008 100644 --- a/.ai/architecture.md +++ b/.ai/architecture.md @@ -160,8 +160,10 @@ HTTP/e2e naming и расположение — в [Testing](conventions/testing `*.module.ts` задаёт DI boundary; `*.api.ts` перечисляет используемые другими модулями providers, функции и типы. Внешний production import идёт через этот -API или файл Nest module. Чистый `integrations/tbank/decimal.ts` — отдельный -публичный вход для protobuf conversion: импорт арифметики не загружает DI/Prisma. +API или файл Nest module. Чистые `integrations/tbank/decimal.ts` и +`modules/investments/operations/domain/operations-sync-plan.ts` — отдельные +публичные входы для protobuf conversion и interval rules: импорт расчётов +не загружает DI/Prisma. Внутри модуля используй прямые локальные импорты. Tests могут импортировать внутренний owner для проверки поведения и mocks. Новый export требует текущего потребителя; controller и persistence остаются diff --git a/.ai/conventions/error-handling.md b/.ai/conventions/error-handling.md index 7996c42..dfc072e 100644 --- a/.ai/conventions/error-handling.md +++ b/.ai/conventions/error-handling.md @@ -8,8 +8,8 @@ application/domain outcome → application mapping → HttpExceptionFilter ``` Сохраняй принятый профиль RFC 9457: `status`, стабильный публичный `code`, -для validation — `errors: [{ pointer, detail }]`. Точный контракт и известное -расхождение validation описаны в [HTTP API](http-api.md#источник-контракта). +для validation — `errors: [{ pointer, detail }]`. Точный контракт validation +описан в [HTTP API](http-api.md#источник-контракта). Не добавляй `type/title/instance` как обязательные поля без contract change. Чистый domain не импортирует Nest HTTP exceptions. Существующий application diff --git a/.ai/conventions/http-api.md b/.ai/conventions/http-api.md index 7e24e75..988a756 100644 --- a/.ai/conventions/http-api.md +++ b/.ai/conventions/http-api.md @@ -83,6 +83,6 @@ artifact отдельно; backend не читает их репозитории HTTP prefix — `/api/v1`; Swagger доступен согласно `src/config/swagger.config.ts`. Общий error response — `application/problem+json` со `status` и публичным `code`. Validation требует `400 / VALIDATION_ERROR` и `errors: [{ pointer, detail }]`. -Известное ограничение: ValidationPipe сейчас формирует строковые сообщения, -а filter ожидает структурированные field errors; schema parity не доказывает -правильность `errors`. Изменение требует проверки реального HTTP pipeline. +ValidationPipe сохраняет структурированные field errors без `target/value`, +а filter формирует JSON pointers, включая вложенные поля. Schema parity не +доказывает правильность `errors`; изменения проверяются через реальный HTTP pipeline. diff --git a/BACKEND_AUDIT.md b/BACKEND_AUDIT.md new file mode 100644 index 0000000..27c27cb --- /dev/null +++ b/BACKEND_AUDIT.md @@ -0,0 +1,673 @@ +# Аудит backend WealthLedger и план исправлений + +Дата аудита: 2026-09-27. Репозиторий: `/Users/ksv741/Project/vibe/WealthLedger/backend`. +База: `516e43203eb3fee8e6a0c7018be2fee11f1b900c`, ветка `codex/investment-contracts`. +При аудите рабочее дерево было чистым; production-код не изменялся. +Этот документ сохраняет результаты аудита и задаёт проверяемый план исправлений. +Само сохранение документа не означает выполнения исправлений. + +Номера строк относятся к указанному commit. Перед изменением повторно найти +symbol и проверить актуальный flow: после предыдущих исправлений строки сдвинутся. +Все относительные пути и ссылки относятся только к backend. + +## Общая оценка + +Backend имеет понятных владельцев ответственности: контроллеры тонкие, +денежные расчёты преимущественно точные, транзакции и очереди сосредоточены +у своих владельцев. Массовое дробление файлов не требуется. +Наиболее проблемные области — обработка ошибок auth, проверка ответов MOEX, +согласование состояния фоновой синхронизации и временных признаков кешированных данных. + +Изучены локальные [AGENTS.md](AGENTS.md), [порядок работы](.ai/conventions/engineering.md), +[архитектура](.ai/architecture.md) и активированные темы HTTP, errors, data, +testing, security, integrations и observability. Соседние репозитории не использовались. + +Инвентаризированы 135 production-файлов TypeScript, 16 004 строки, 17 Nest module-файлов. +Generated Prisma, vendor protocols, миграции и тесты отделены от production-рейтинга. + +Приоритеты: **высокий** — существенный риск безопасности или искажения данных; +**средний** — ограниченный дефект поведения, контракта или доказанная стоимость связности; +**низкий** — локальное упрощение. Уверенность всех перечисленных находок высокая +для описанного механизма; ограничения конкретного воспроизведения указаны отдельно. + +## План выполнения и границы + +1. Прочитать действующий backend AGENTS и активированные conventions; зафиксировать + текущие HEAD/status. Сохранить существующие staged/unstaged/untracked изменения. +2. Работать по находкам A01–A10 и cleanup C01–C07 ниже, в порядке пакетов из таблицы. + Перед каждым исправлением перечитать владельца целиком, callers, связанные tests + и 1–3 релевантных примера. Если факт больше не воспроизводится, отметить это + с доказательством вместо механического применения рекомендации. +3. Для дефекта сначала получить падающую проверку наблюдаемого результата на самой + дешёвой надёжной boundary. Для concurrency/atomicity использовать реальную + изолированную SQLite; mock race не доказывает DB-инвариант. +4. Исправлять у существующего владельца минимальным связанным diff. Сохранять + precision, ownership, public error codes, transaction/fencing, cache isolation, + limiter и выполняющиеся promises. Предложения ниже задают результат, а не + обязательную форму реализации. +5. После каждого пакета выполнить targeted checks; в конце — актуальные обязательные + gates по backend conventions. Перед каждой Node-командой выполнить `nvm use --silent`. +6. Сохранить исходный снимок аудита; результаты реализации записывать в таблицу + «Статус исправлений». Для каждого ID указать diff/решение, команды и результат, + остаточный риск либо точную причину невыполнения. + +| Пакет | Пункты | Условие завершения | +| --- | --- | --- | +| 1. Auth и безопасные ошибки | A01, A02 | Отказы хранилища не маскируются; секреты не попадают в completion log | +| 2. MOEX trust boundary | A03 | Malformed response не публикуется как полный результат; pagination завершается | +| 3. Статус синхронизации | A04 | Screen учитывает состояние successor через владельца operations | +| 4. Свечи | A05 | Force не теряется при coalescing; forced publication остаётся атомарной | +| 5. Свежесть и время | A06, A07 | Stale-флаг актуален; wire timestamp совпадает с используемым при фильтрации | +| 6. HTTP validation | A08 | Реальный pipeline возвращает структурированные field errors | +| 7. Связность и удаление legacy | A09, A10, C01–C07 | Живые boundaries сохранены; лишние реализации и exports удалены | + +Пакеты независимы по цели, но могут пересекаться файлами и общими fixtures. +Параллелить только области с непересекающимся владением файлами, затем выполнить +общую проверку. A02 и A08 изменяют один HTTP filter и должны быть согласованы. + +В scope входят исправления подтверждённых находок и их regression tests. +Гипотезы в конце документа не являются задачами на изменение кода. +Новые зависимости, generic repositories, scheduler framework, интерфейсы ради +паттерна, schema migrations и size-only splits для этих исправлений не требуются. +Отдельную необходимость такого расширения следует доказать до реализации. +Коммиты, push и PR выполняются только по отдельному запросу пользователя. + +## Подтверждённые находки + +### A01. Auth lifecycle смешивает invalid credentials и operational failure + +**Приоритет:** высокий. **Уверенность:** высокая. + +**Места и потребители:** [AuthService.logout, строки 104–126](src/modules/auth/application/auth.service.ts), +[AuthController.logout, 95–104; refresh, 85–91](src/modules/auth/presentation/auth.controller.ts). + +Общий catch в logout подавляет JWT verification errors и отказ Prisma при записи +`revokedAt`. Controller затем очищает cookie и возвращает 204, хотя серверная +сессия осталась действительной. Сохранённая копия refresh token продолжает работать. +Смежное проявление: refresh controller удаляет cookie при любой ошибке, включая +временный отказ БД, без доказательства недействительности токена. + +**Доказательство:** inline fault injection на настоящих AuthService, JwtService +и AuthController с синтетической сессией подтвердил: + +```text +logoutResolved=true +cookieCleared=true +sessionNotRevoked=true +sameRefreshTokenStillUsable=true +unexpectedRefreshErrorPropagated=true +validCookieRemoved=true +``` + +Это доказательство обработки injected boundary failure, а не реального отказа БД. +[Auth HTTP suite](test/app.e2e-spec.ts) проверяет обычный logout, но не этот отказ. +Нарушен [контракт отзыва сессии](.ai/conventions/security-auth.md): идемпотентность +не превращает несостоявшуюся запись в успешную. + +**Минимальное действие:** ограничить подавление ошибок logout недействительным JWT; +ошибки хранилища пробрасывать. При refresh очищать cookie только при установленном +auth failure. Существующий AuthService.refresh уже отделяет verification от DB reads; +BankCardsRepository.write также пробрасывает неизвестные ошибки. + +**Приёмка:** + +- Отсутствующий/недействительный/уже отозванный refresh сохраняет идемпотентный logout. +- Ошибка записи revocation не возвращает фиктивный 204. +- Успешный logout делает последующий refresh невозможным. +- Временный отказ refresh не удаляет действующую cookie, повтор после восстановления возможен. +- Проверены status/code/cookie через configured HTTP; успешный отзыв — с SQLite. + +**Риск:** сохранить существующую семантику недействительных токенов. Изменение +access-JWT lifetime или введение replay history не входят в этот пункт. + +### A02. Многострочное сообщение исключения попадает в completion log + +**Приоритет:** высокий. **Уверенность:** высокая. + +**Места:** [HttpExceptionFilter, 30–37](src/common/filters/http-exception.filter.ts). +**Потребители:** [RequestLoggingMiddleware, 43–57](src/common/logging/request-logging.middleware.ts), +[StructuredLogger, 47–63](src/common/logging/structured-logger.service.ts). + +Фильтр удаляет только первую строку `exception.stack`. Остальные строки +многострочного Error.message сохраняются. Поле stack разрешено logger allowlist; +redaction HTTP body к нему не применяется. + +**Воспроизведение:** configured HTTP app; AuthService.login выбрасывает синтетический +`Error('operation failed\npassword: SYNTHETIC_AUDIT_SECRET')`. Ответ остаётся +`500 / INTERNAL_SERVER_ERROR`, но completion log содержит вторую строку сообщения. +Реальные секреты не использовались. Существующий filter spec проверяет однострочное сообщение. +Нарушены [observability](.ai/conventions/observability.md) и security rules. + +**Минимальное действие:** убрать передачу сырого stack через общую HTTP boundary, +сохранив code/request context. Если stack необходим, нужна проверенная форма кадров +без произвольного текста исключения; удаление только первой строки недостаточно. + +**Приёмка:** multiline synthetic secrets отсутствуют в response и в конечном +payload logger, а status/code/correlation сохраняются. Проверить настоящий +filter → middleware → logger, не только mock вызова filter. + +**Риск:** диагностическая подробность может уменьшиться; raw message возвращать нельзя. + +### A03. MOEX malformed response превращается в полный пустой прогноз + +**Приоритет:** высокий. **Уверенность:** высокая. + +**Место:** [MoexClient, 122–142 и 163–165](src/integrations/moex/moex.client.ts). +Отсутствующий запрошенный ISS block преобразуется в `[]`. Неполный cursor может +завершить чтение с частичными данными; cursor с нулевым pageSize может зациклить его. + +**Реальная цепочка:** [CatalogEnrichmentService, 444 и 509–548](src/modules/investments/instruments/application/catalog-enrichment.service.ts) +публикует `bondEvents: []`; readMany считает существующий row complete; +[UpcomingEventsService, 137–197](src/modules/investments/upcoming-events/upcoming-events.service.ts) +строит пустые events/totals; [ForecastRepository, 124–150](src/modules/investments/portfolio/infrastructure/forecast.repository.ts) +может заменить прежний непустой forecast. Владелец protocol validation — integration client. + +**Воспроизведение:** настоящий MoexClient с mock fetch HTTP 200 без coupons/offers +вернул `[]`. Cursor `INDEX=0,TOTAL=1,PAGESIZE=0` дал пять запросов `start=0`; +проба остановлена искусственной ошибкой. Timeout каждого fetch не ограничивает весь цикл. + +**Расхождение тестов:** [moex.client.spec, 97–107](src/integrations/moex/moex.client.spec.ts) +считает отсутствующий block пустым результатом; +[upcoming-events.e2e, 439–451](test/upcoming-events.e2e-spec.ts) обещает обратное, +но подменяет весь клиент готовым rejection и не проверяет parser. + +**Минимальное действие:** различать отсутствующий/повреждённый block и корректный +`data: []`; проверять структуру строк и cursor, положительный pageSize и строгое +продвижение start. Нарушение протокола → `MOEX_UNAVAILABLE`, без частичного success. + +**Приёмка:** + +- Malformed block/cursor не меняет сохранённый полный календарь/forecast. +- Корректный пустой block остаётся успешным пустым результатом. +- Непродвигающаяся pagination завершается ошибкой, без бесконечных fetch. +- Семантика HTTP 404 проверена отдельно и не изменена случайно. +- Downstream regression использует настоящий MoexClient с mock fetch, а не mock rejection клиента. + +**Риск:** перепутать законное отсутствие данных с ошибкой protocol shape. + +### A04. Screen sync сообщает succeeded до завершения successor + +**Приоритет:** средний. **Уверенность:** высокая. + +**Места:** [TbankSyncService.status, 330–345](src/modules/investments/tbank-sync/tbank-sync.service.ts), +[TbankSyncRepository.read, 70–80](src/modules/investments/tbank-sync/tbank-sync.repository.ts). +[Operations repository, 441–445](src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts) +помечает объединяемую группу `superseded` и сохраняет successorId. Владелец операций +умеет разрешать цепочку в observe, строки 564–590, но screen читает прямого child. + +**Сценарий:** две активные screen-группы на части января; третья объединяет интервалы. +Screen вытесненной группы сообщает succeeded, хотя successor ещё running; его +последующая ошибка также теряется. + +**Воспроизведение настоящего status с mocked persistence:** + +```text +screenState=succeeded +resource.availability=missing +resource.sync.state=running +actualOperationState=running +``` + +SQLite bridge покрыт [operations-sync e2e, 455–474](test/tbank-operations-sync.e2e-spec.ts), +но существующая проверка наблюдает operations.observe, а не screen status. + +**Минимальное действие:** разрешать operations successors через существующего +владельца перед агрегацией screen state/progress. Не копировать второй алгоритм +разрешения цепочки в screen service. + +**Приёмка:** configured HTTP + SQLite bridge; задержанный successor → running, +затем успех → succeeded или отказ → failed; progress согласован, чужая revision +не видна. **Риск:** fencing, цепочки successors и корректный подсчёт progress. + +### A05. Force обновления свечей теряется при coalescing + +**Приоритет:** средний. **Уверенность:** высокая. + +**Места:** [CandleService.request/load, 326–365](src/modules/investments/instruments/application/candle.service.ts), +[CandleRepository.reserve, 76–82](src/modules/investments/instruments/infrastructure/candle.repository.ts). +Публичный caller: [TbankSyncService, 288–306](src/modules/investments/tbank-sync/tbank-sync.service.ts) +вызывает candles.request с force=true; candleRange необязателен, ключ может совпасть +с ordinary GET/prefetch. + +При launch=false service возвращает существующий id без сохранения новых force/priority. +При присоединении cross-user observer требования также не повышаются. + +**Воспроизведение настоящего класса с mocked repository/upstream:** январь покрыт, +ordinary period=all заблокирован перед загрузкой detail, затем приходит force all. +После release единственный RPC идёт от `2026-02-01T00:00:00.000Z` до +`2026-02-02T12:00:00.000Z`; январь не перечитан. Исправленные исторические значения +остаются прежними. Existing candle suite отдельно проверяет force и coalescing. + +**Минимальное действие:** сохранить forced требование и выполнить полную reconciliation. +Если gap-план уже начат, безопасно перепланировать или выполнить следующую forced +попытку. Простого `force ||= true` после публикации первых окон недостаточно. +Аналог повышения требований есть в CatalogDiscoveryService, но его алгоритм нельзя +механически переносить на staged свечи. + +**Приёмка:** ordinary load + force того же scope действительно обновляют уже +закрытую свечу. Возвращённый work ID завершается только после выполнения forced +требования с актуальным диапазоном запроса. Проверить join до запуска и после +публикации первого обычного окна, включая observer другого пользователя; +экранный запрос получает экранный приоритет. Ошибка следующего forced окна сохраняет +прежнюю историю и timestamps; user/revision fencing сохраняется. +Proof атомарности — существующая SQLite boundary. + +**Риск:** высокий риск неверного локального исправления из-за staged/atomic publication. + +### A06. Payout snapshot замораживает positionIsStale + +**Приоритет:** средний. **Уверенность:** высокая. + +**Место:** [ForecastRepository.resolve, 132–137](src/modules/investments/portfolio/infrastructure/forecast.repository.ts). +**Потребители:** [StockIncomeService, 67–87 и 161–163](src/modules/investments/instruments/stocks/application/stock-income.service.ts), +[BondIncomeService, 180–182](src/modules/investments/instruments/bonds/application/bond-income.service.ts). + +Истечение 60-секундного portfolio TTL меняет isStale, но не source revision/timestamps. +Repository возвращает прежний payload вместо нового candidate. Возможны одновременно +`meta.freshness: stale` и `data.positionIsStale: false`. + +**Воспроизведение:** настоящий repository с mocked transaction; candidate false, +затем true при тех же версиях → повторный результат false, всего одна запись. +Pure payout tests принимают stale input, но не проверяют aging persisted forecast. + +**Минимальное действие:** вычислять возраст сохранённой позиции при выдаче по её +positionAt и действующей freshness policy. Учитывать именно позицию сохранённого +fallback, а не случайно подставлять свежесть более нового несовместимого портфеля. +Не сравнивать весь candidate без разбора: текущий calculatedAt у events вызовет +запись на каждом чтении. Не менять financial revision только из-за хода времени. + +**Приёмка:** controllable clock, TTL истёк без изменения источников, sync=observe: +stale=true, суммы и source timestamps прежние, новых RPC нет; fallback остаётся +согласованным. **Риск:** смешение возраста сохранённой и текущей позиции. + +### A07. Timestamp операций расходится между persistence и wire + +**Приоритет:** средний. **Уверенность:** высокая. + +**Место:** [brokerage-operation.mapper, 85–90](src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts) +игнорирует nanos; [repository, 653–657 и publish](src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts) +учитывает миллисекунды. Consumers mapper: BrokerageOperationsService, обычный ledger +и income reads (строки 359 и 619 на базе аудита). + +**Воспроизведение:** seconds=1768000000, nanos=600000000 → persistence +`2026-01-09T23:06:40.600Z`, wire `2026-01-09T23:06:40.000Z`. +Запрос from=`…40.500Z` возвращает строку с видимым временем раньше from. +Existing mapper specs проверяют money/quantity, но не это соответствие. + +**Минимальное действие:** согласовать конвертацию protobuf timestamp; при возможности +использовать уже сохранённый occurredAt для wire. Предотвратить дальнейшее расхождение +реализаций без создания универсальной библиотеки дат. + +**Приёмка:** subsecond mapper case и HTTP boundary внутри секунды; порядок/cursor, +обычный ledger и income используют согласованное время. **Риск:** исправляется +публичное значение timestamp; формат и точность Money сохраняются. + +### A08. Configured validation response теряет ошибки полей + +**Приоритет:** средний. **Уверенность:** высокая. + +**Места:** [configureApp, 29](src/main.ts), +[HttpExceptionFilter, 44–48 и 70–78](src/common/filters/http-exception.filter.ts). +ValidationPipe выдаёт message:string[], filter приводит его к ValidationError[] +и ищет constraints. children вложенных ошибок также не обходятся. + +**Configured HTTP reproduction:** invalid email и отсутствующий password: + +```json +{"status":400,"code":"VALIDATION_ERROR","errors":[]} +``` + +Ожидаемый [HTTP contract](.ai/conventions/http-api.md) требует pointer/detail. +Conventions уже фиксируют это известное расхождение. OpenAPI parity проверяет +schema, но не наблюдаемый response pipeline. + +**Минимальное действие:** согласовать exceptionFactory и filter, сформировать +структурированные ошибки, включая вложенные поля, без target/value исходного DTO. + +**Приёмка:** реальные requests credentials и nested Money дают непустые корректные +pointer/detail; status/code/content-type сохраняются; неизвестные исключения +по-прежнему безопасны. Проверить согласование с A02 и публичной metadata. +**Риск:** public error shape и корректность вложенных путей. + +### A09. Planner зависит от service barrel и образует runtime import cycle + +**Приоритет:** средний. **Уверенность:** высокая. + +**Места:** [investment-sync-plan, 3–6](src/modules/investments/investment-sync-plan.ts) +→ [operations.api](src/modules/investments/operations/operations.api.ts) +→ operations services → investment-sync-plan. + +Алгоритму нужны interval rules, но barrel загружает services, TbankClient и Prisma. +[Planner spec, строка 1](src/modules/investments/investment-sync-plan.spec.ts) уже +вынужден mock Prisma. Это доказанная стоимость связности; production crash из-за +цикла не обнаружен. Type-only цикл T-Bank records — отдельный безвредный случай. + +**Минимальное действие:** предоставить interval rules чистый публичный вход, +аналогичный разрешённому integrations/tbank/decimal.ts; service API оставить отдельно. +Не заменять cycle на forwardRef и не вводить новый DI слой. + +**Приёмка:** planner импортируется/тестируется без Prisma mock; cycle устранён, +interval semantics и DI initialization сохранены. **Риск:** низкий, import/export surface. + +### A10. Неиспользуемый legacy T-Bank access flow + +**Приоритет:** низкий. **Уверенность:** высокая. + +**Место:** [TbankAccessService, 255–330](src/integrations/tbank/tbank-access.service.ts). +listSupportedAccounts/withSupportedAccounts/withConnection/withAccount/ +withAccountForConnection/private connection вызываются только внутри собственной +цепочки и tests. Проверены production consumers, DI и decorators. + +Legacy flow поддерживает generation=hash(ciphertext), тогда как живой persisted +flow использует DB revision, local и tokenForAttempt. Поддерживаются две модели +без production-потребности; около 76 строк, context types и TbankClient dependency. + +**Минимальное действие:** повторно проверить callers на актуальном HEAD, удалить +legacy-цепочку, неиспользуемые context exports и зависимость; отдельно проверить +оставшихся consumers accessibleTbankAccount до его удаления. + +**Приёмка:** нет мёртвой цепочки, живые access/invalid-token/revision/fencing cases +сохранены. Перенести полезные security assertions, если они были только в удаляемых +specs. **Риск:** удалить общее правило или полезную проверку вместе с legacy. + +## Локальные упрощения + +Все пункты: низкий приоритет, высокая уверенность. Перед удалением повторить поиск +callers; отсутствие production callers в базе аудита не гарантирует его на новом HEAD. + +| ID | Место и доказательство | Минимальное действие и приёмка | +| --- | --- | --- | +| C01 | [portfolio.service, 515–522](src/modules/investments/portfolio/application/portfolio.service.ts): локальный decimalFromString повторяет common parser без validation; callers position/value | Импортировать существующий common owner; удалить дубликат. Exact-money tests и typecheck. Текущая ошибка сумм не доказана; проверить допустимые входы. | +| C02 | [tbank.api](src/integrations/tbank/tbank.api.ts): TbankSyncAttempt импортируют напрямую семь внешних файлов sync/instruments; enrichment также обходит API для market types | Экспортировать используемые типы, перенаправить imports через API. TbankCoupon уже экспортируется. Typecheck и поиск обходов; runtime-риск минимален. | +| C03 | [auth.service, 190–192](src/modules/auth/application/auth.service.ts): normalizeEmail возвращает аргумент; register/login вызывают его, реальная нормализация в CredentialsDto | Удалить no-op, сохранить DTO transform. Existing HTTP case/whitespace tests; дополнительную нормализацию без необходимости не вводить. | +| C04 | [bank-card.calculations, 56–76](src/modules/banking/bank-cards/domain/bank-card.calculations.ts): string period используется только tests; production summary передаёт from/to | Сузить API до рабочего диапазона; перевести полезные unit cases. Проверить границы месяца/range. | +| C05 | [instrument-market, 39–51](src/modules/investments/instruments/application/instrument-market.ts): optional без consumers; [stock-position wrapper](src/modules/investments/instruments/stocks/application/stock-position.ts) нужен только своему spec | Удалить optional, перенаправить полезные wrapper tests на instrumentPosition. Сохранить fractional/mixed/offset holdings cases. | +| C06 | [portfolio.api, 2](src/modules/investments/portfolio/portfolio.api.ts): totalsByCurrency не имеет внешнего production consumer; внутренний использует mapper напрямую | Удалить только лишний export, mapper сохранить; typecheck. | +| C07 | [auth.controller.spec, 9–20](src/modules/auth/presentation/auth.controller.spec.ts): проверяет HTTP_CODE_METADATA через prototype; те же 200 проверяются configured HTTP suite | Удалить дублирующий wiring spec только при сохранении реальных HTTP status/cookie assertions. | + +## Карта ответственности и проверенные сценарии + +| Область / владелец | Интерфейс и consumers | Состояние, зависимости, инварианты и эффекты | +| --- | --- | --- | +| App/config/common/Prisma | Composition root, configureApp, Money/decimal, logging | Env validation, HTTP pipeline, DB lifecycle, request context; общая арифметика без Nest/Prisma | +| Auth | Auth HTTP; guard/AccessPayload API для protected controllers | User/Session, bcrypt/JWT, refresh hash/CAS; cookies у controller | +| Bank cards/operations/categories | BankCardsController → профильные services | Ownership, archive/version/replay/tombstones; refund budget и перевод в общей transaction boundary | +| Bank summary | Controller → use case → repository → pure calculation | Согласованное чтение и BigInt-сводка по периоду | +| Brokerage accounts | HTTP/read API для screen → service → PortfolioService | Собственной persistence нет; missing membership отличается от confirmed empty | +| Portfolio/forecast | HTTP и API для instruments/events/accounts | Private user/revision/account snapshots; mapping/decimal; aggregate completeness; atomic dependency version checks | +| Operations | HTTP/API для income/screen | Local journal, signed cursor binding; durable work/staging, intervals, recovery, atomic reconciliation | +| Income analytics | HTTP/read API → Operations API | Period roles/completeness, grouping и точные signed totals по валютам; своей DB нет | +| Upcoming events | HTTP/read API → portfolio/calendars → forecast | Events по positions; missing calendar не равен пустому; redemption исключён из income totals | +| Catalog discovery/enrichment | Stock/bond services и public source APIs | Shared identity/aliases/source rows; private work, TTL, batching, coalescing и generation-fenced publication | +| Candles | Market services/screen → CandleService/Repository | Shared candles/coverage; mutable tail, gaps, windows, очередь и staged force replacement | +| Stock/bond position/income | Controllers → application services | UID/type matching, exact weighted cost, mixed currencies, payout estimates; Portfolio/Catalog/Forecast APIs | +| T-Bank connection | Guarded GET/PUT/DELETE | Проверка токена до replacement, AES-GCM, новая revision/cascade; failed validation сохраняет connection | +| T-Bank client/access/membership/metadata | Integration API для business modules | gRPC/TLS, единые limiter/retry/deadline; credentials, account access, membership и publication fencing | +| Screen sync | POST descriptor / GET status | Orchestration через APIs; durable children/attempts; passive observation | +| MOEX/NCR | Integration APIs → enrichment | ISS parser/pagination; NCR strict registry parser/exact ISIN/shared cache | + +Прослежены auth/session flows; создание/изменение карт, операций, переводов, +возвратов и summary; ledger→sync→staging→publication; portfolio→mapping→snapshot; +payouts/events→forecast; catalog/candles и screen orchestration. + +Повторной регистрации владельцев caches/queues и циклов Nest modules не обнаружено. +InstrumentSourcesModule позволяет portfolio пользоваться источниками без DI-цикла +с InstrumentsModule. Type-only цикл T-Bank records не является finding. + +## Размеры production-файлов + +Количество строк включает comments/blank lines. Размер — основание для чтения, +а не самостоятельный дефект. Обязательные splits не обоснованы. + +| Файл | Строк | Решение и причина | +| --- | ---: | --- | +| [brokerage-operations.repository](src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts) | 924 | Оставить: overlap groups, staging, recovery и publication держат общий transactional invariant | +| [catalog-enrichment.service](src/modules/investments/instruments/application/catalog-enrichment.service.ts) | 665 | Упростить локально: protocol boundary/imports; сохранить producers, batching, completions вместе | +| [brokerage-operations.service](src/modules/investments/operations/application/brokerage-operations.service.ts) | 636 | Оставить: ledger/income имеют различную completeness; generic builder добавит скрытые режимы | +| [tbank-sync.service](src/modules/investments/tbank-sync/tbank-sync.service.ts) | 595 | Упростить: разрешение successors вернуть operations owner, generic job framework не нужен | +| [tbank.client](src/integrations/tbank/tbank.client.ts) | 560 | Оставить: records/RPC methods и единый limiter/retry owner | +| [portfolio.service](src/modules/investments/portfolio/application/portfolio.service.ts) | 522 | Упростить: удалить duplicate parser, scheduling state не разрывать | +| [candle.service](src/modules/investments/instruments/application/candle.service.ts) | 481 | Оставить: исправить force join, сохранить queue/publication invariant | +| [catalog-discovery.repository](src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts) | 480 | Оставить: identity/aliases/source publication и CAS связны | +| [catalog-discovery.service](src/modules/investments/instruments/application/catalog-discovery.service.ts) | 390 | Оставить: search/detail и producer lifecycle имеют одного owner | +| [bank-operations.service](src/modules/banking/bank-cards/application/bank-operations.service.ts) | 343 | Оставить: разрыв validation/refund/transfer writes повышает риск | +| [tbank-access.service](src/integrations/tbank/tbank-access.service.ts) | 331 | Упростить удалением legacy flow | +| [investment-sync-plan](src/modules/investments/investment-sync-plan.ts) | 327 | Упростить зависимость: убрать service barrel из алгоритма | +| [instrument-metadata.repository](src/integrations/tbank/instrument-metadata.repository.ts) | 320 | Оставить: identity, batching и publication согласованы | +| [income-analytics.service](src/modules/investments/income/income-analytics.service.ts) | 318 | Оставить: связные projections, выгода дополнительных моделей не доказана | +| [candle.repository](src/modules/investments/instruments/infrastructure/candle.repository.ts) | 306 | Оставить: reconciliation, coverage, fencing и stage cleanup | +| [upcoming-events.service](src/modules/investments/upcoming-events/upcoming-events.service.ts) | 297 | Оставить: один сценарий, основной дефект у MOEX boundary | +| [brokerage-operations-sync.service](src/modules/investments/operations/application/brokerage-operations-sync.service.ts) | 294 | Оставить: связный владелец durable work execution | + +Отдельные категории: 37 unit specs / 3319 строк; 21 HTTP/SQLite specs / 7878 строк; +10 test support/fixtures / 1993 строки; 6 vendor proto / 3588 строк; +5 SQL migrations / 345 строк. Generated Prisma и build artifacts не ранжировались. + +## Удачные решения, которые требуется сохранить + +- BigInt/decimal и string Money; разные валюты не складываются неявно. +- Refresh CAS, ownership queries, версии операций, replay fingerprints/tombstones. +- Network calls отделены от DB transactions. +- Revision/attempt fencing, late-result discard, staging перед force reconciliation. +- Missing/partial/confirmed empty/saved complete различаются явно. +- Concrete providers, компактные плоские модули и разрешённый прямой Prisma + в связных CRUD/transaction services не являются автоматическими дефектами. +- SQLite behavior tests проверяют реальные денежные границы, races и rollback. + +## Выполненные проверки и пределы доказательства + +Перед успешными Node-командами загружалась `~/.nvm/nvm.sh`, затем `nvm use --silent`. +Первый вызов nvm без загрузки shell function завершился command not found; +после загрузки проверок это ограничение не осталось. + +| Команда / проверка | Результат аудита | +| --- | --- | +| git status, git rev-parse HEAD, git diff --check | Чистое дерево, неизменный commit, diff errors отсутствуют | +| npm run lint | Успешно | +| npm run typecheck:app; npm run typecheck:test | Успешно | +| npm test -- --runInBand | 37 suites / 200 tests passed | +| Build в npm run test:e2e | Prisma generation и Nest build успешны | +| Полный e2e в sandbox | listen EPERM на localhost; не засчитан | +| LOG_LEVEL=error node test/sqlite.cjs --all, с разрешением localhost | all-accounts: 18 passed; auth-suite затем завершилась native SIGABRT | +| node test/sqlite.cjs tbank-screen-sync.e2e-spec.ts tbank-operations-persistence.e2e-spec.ts tbank-candles-persistence.e2e-spec.ts tbank-metadata-persistence.e2e-spec.ts tbank-local-reads.e2e-spec.ts openapi-contract.e2e-spec.ts | Первые три: 27 + 5 + 9 passed; metadata native abort; последующие не достигнуты | +| node test/sqlite.cjs openapi-contract.e2e-spec.ts, отдельно | 3 tests passed | +| Inline reproductions | Подтверждены описанные случаи; validation/logging через configureApp, остальные с указанными mocked boundaries | + +Native abort произошёл в `node::RemoveEnvironmentCleanupHook` / `better_sqlite3.node` +на Node 24.21.0. Причина не установлена; связывать его с конкретной source-находкой +нельзя. Полный e2e gate не подтверждён. При реализации сначала повторить в штатном +окружении; если проблема остаётся, диагностировать отдельно и явно сообщить, +не отключая падающие suites и не выдавая неполную проверку за зелёную. + +Логи текущей сессии (временные файлы, могут исчезнуть): +`/tmp/wealthledger-audit-e2e.log`, `/tmp/wealthledger-audit-targeted-e2e.log`, +`/tmp/wealthledger-audit-openapi.log`. Документ не зависит от сохранности этих файлов. + +**Проверено:** все production-модули, composition/API, основные consumers, +значимые flows, связанные tests кандидатов. Все production auth/banking, +integrations/sync/operations и остальные investment slices прочитаны. + +**Частично проверено:** весь набор HTTP fixtures и integration suites не проходил +одинаково глубокий построчный аудит; реальное выполнение e2e ограничено указанным +native abort. Schema прочитана; история миграций не проходила полный compatibility audit. + +**Не проверено:** clean npm ci, полный verify с coverage как единая команда, +durability/restart/backup, container/deployment, production performance, +реальные внешние API и полная сверка vendor proto с upstream. + +### Финальная проверка реализации + +Использовать актуальные scripts/conventions backend. На базе аудита для code changes +требуются npm ci, npm run verify, relevant SQLite/HTTP checks; для этих HTTP изменений +также OpenAPI generation/parity. Полный npm run test:e2e нужен для итоговой проверки +совокупных изменений. Перед Node — nvm use --silent. + +Автоисправления lint/format не являются доказательством. Не запускать миграции, +reset/drop или tests на пользовательской БД. Штатный SQLite runner создаёт временные +базы и защищает от использования внешнего DATABASE_URL. + +По завершении повторить routing по staged/unstaged/new files, выполнить Boy Scout +и semantic review: callers, owners, money/access/atomicity/error contracts, +generation/cache/async lifecycle. Указать точные команды, outcomes и ограничения. + +## Неподтверждённые гипотезы — вне scope исправлений + +- Logout/refresh interleaving и browser cookie overwrite требуют явных ordering + semantics и отдельного HTTP/browser proof. +- N+1 в карточных views виден, но измеренная latency/timeout проблема не установлена. +- JSON casts внутренних сохранённых payload сами по себе не доказывают небезопасную boundary. +- Cash/accrued-interest valuation: локального контракта недостаточно для обвинения в ошибке. +- Native SQLite abort требует отдельной диагностики runtime/dependency lifecycle. + +## Статус исправлений + +Начало исправлений: HEAD `516e43203eb3fee8e6a0c7018be2fee11f1b900c`, +ветка `codex/investment-contracts`; `git status --short`: только +`?? BACKEND_AUDIT.md`, staged/unstaged tracked changes отсутствовали. +Исходные разделы аудита сохранены. `npm ci` успешно: 1065 packages, 44s; +manifest/lockfile не менялись. Перед Node-командами загружается `~/.nvm/nvm.sh` +и выполняется `nvm use --silent`. + +Все A01–A10 и C01–C07 повторно подтверждены и реализованы в порядке пакетов. +Статусы ниже разделяют готовность исправления и полноту проверки: native abort +не засчитывается как успешный behavioral test. Неподтвердившихся и уже исправленных +на исходном HEAD пунктов не обнаружено. + +| ID | Статус | Изменение / доказательство | Проверки и ограничения | +| --- | --- | --- | --- | +| A01 | Исправлено и проверено | Logout подавляет только JWT verification failure; refresh удаляет cookie только при UnauthorizedException. Configured HTTP red: logout 204 вместо 500, refresh удалил cookie при отказе БД. | `node test/sqlite.cjs app.e2e-spec.ts`: regression воспроизведена на временной SQLite; macOS green повтор и первый полный e2e столкнулись с native SIGABRT. Итоговый Linux e2e: auth 12/12 passed, включая revocation failure и сохранение cookie для retry. | +| A02 | Исправлено и проверено | Удалены raw stack и его неиспользуемый transport/allowlist; regression проверяет multiline synthetic secret в конечном Pino payload и correlation. | Targeted filter/logging: 3 suites / 4 tests passed, unit red → green; macOS configured HTTP runner завершился native SIGABRT; итоговый Linux auth 12/12 passed, включая настоящий filter → middleware → Pino payload. | +| A03 | Исправлено и проверено | MoexClient отклоняет отсутствующий/повреждённый block, неверную ширину row, malformed/nonprogressing cursor. Пустые блоки и 404 сохранены. | Unit red 12 failures → 21 passed; `node test/sqlite.cjs upcoming-events.e2e-spec.ts`: downstream red 2 failures → 18 passed, настоящий client + mock fetch сохраняют calendar/forecast payload/revisions/timestamps. Typecheck и scoped lint passed. | +| A04 | Исправлено и проверено | Screen status разрешает superseded child через operations.observe перед агрегацией state/progress; attempt/revision fencing сохранён. | `node test/sqlite.cjs tbank-screen-sync.e2e-spec.ts`: HTTP/SQLite red succeeded вместо running → 29 passed; successor success/failure, progress, чужой user и удалённая revision. | +| A05 | Исправлено и проверено | Каждый join повышает force/priority при более сильном требовании; идентичный join не меняет updatedAt. Load фиксирует force/now на проход, при новой forced потребности выполняет полную reconciliation. Финальная публикация и observers сериализованы с reservations; screen priority=0. | Service unit red → green (полный исторический диапазон и более поздний now); `node test/sqlite.cjs tbank-candles-persistence.e2e-spec.ts`: 14 passed на финальном коде, same/cross-user join до загрузки и после обычного окна, rollback второго forced окна, timestamps и fencing. Native abort в первом финальном запуске; единственный повтор без изменений прошёл. | +| A06 | Исправлено и проверено | ForecastService вычисляет возвращаемый positionIsStale из сохранённого positionAt через существующую portfolio TTL policy; не меняет финансовый snapshot. | `node test/sqlite.cjs upcoming-events.e2e-spec.ts`: red stock/bond false на TTL → 20 passed; TTL−1/TTL, observe без RPC, прежние суммы/revisions/timestamps, fallback при несовместимом свежем candidate. Review дополнительно воспроизвёл meta.freshness=fresh для старого fallback (2 red); freshForMs теперь учитывает сохранённый stale flag, repeat 20 passed, stateVersion рассчитывается штатно. | +| A07 | Исправлено и проверено | Одна operationTimestamp conversion у mapper для stage/publish; ledger/income wire используют сохранённый occurredAt. Missing seconds по-прежнему отклоняется. | Unit red .600→.000 → 9 passed; `node test/sqlite.cjs tbank-ledger-local.e2e-spec.ts`: HTTP red → 11 passed (from=.500, cursor .800/.600, income timestamp/exact total). Оба typecheck/lint passed. На macOS operations-persistence завершилась native SIGABRT и не засчитана. Итоговый Linux полный e2e: ledger 11/11 и operations-persistence 5/5 passed. | +| A08 | Исправлено и проверено | Global/screen ValidationPipe сохраняют structured errors без target/value; filter обходит children и экранирует JSON pointers. Удалён descriptorErrors; OpenAPI errors.items согласован с object shape. | Nested Money HTTP red errors:[] → bank22 / screen29 passed; escaped pointer и отсутствие дублей/secret проверены. Unit7 и scoped lint passed. Metadata red items:string → финальный OpenAPI parity 3 passed. На macOS auth suite native SIGABRT; итоговый Linux auth 12/12 passed, включая credential field errors без input leak. | +| A09 | Исправлено и проверено | Planner импортирует существующий чистый operations/domain/operations-sync-plan как явно документированный публичный вход; service barrel больше не экспортирует runtime interval rules. | Planner и CandleService tests выполняются без Prisma mock; targeted пакет 7 suites / 49 tests passed. DI подтверждается configured HTTP и итоговым build. | +| A10 | Исправлено и проверено | Удалены шесть legacy methods, context types, TbankClient constructor dependency и accessibleTbankAccount без живых callers; legacy-only spec удалён. | Поиск src/test не находит legacy symbols; 5 unit suites / 11 tests, оба typecheck, scoped lint/build; connection HTTP/SQLite4 passed: missing/invalid/unsupported/cross-user, replacement/deletion, revision/fencing. Token error/crypto assertions сохранены. MacOS metadata-persistence15 passed, operations-sync/catalog-discovery native SIGABRT. Итоговый Linux e2e: metadata15, operations-sync17 и catalog-discovery10 passed. | +| C01 | Исправлено и проверено | PortfolioService использует common decimalFromString; локальный parser удалён. Входы из money/formatDecimal уже canonical; текущая ошибка сумм не заявляется. | Exact-money portfolio tests в targeted49 passed; итоговые types/verify ниже. | +| C02 | Исправлено и проверено | TbankSyncAttempt/TbankLastPrice экспортируются type-only через tbank.api; семь production consumers используют API, TbankCoupon переиспользован. | Поиск прямых imports tbank-access.service/tbank-market.types в src/modules пуст; оба typecheck и scoped lint passed. | +| C03 | Исправлено и проверено | Удалён no-op normalizeEmail; CredentialsDto transform trim/lowercase сохранён. | Auth unit passed; реальные case/whitespace HTTP assertions сохранены. После macOS native SIGABRT полный Linux auth gate прошёл: 12/12. | +| C04 | Исправлено и проверено | bankSummary принимает только рабочий диапазон from/to; строковый month overload удалён, полезные unit cases переведены на диапазоны. | Targeted49 passed: month start/end, outside range, signed64/BigInt суммы и refunds. | +| C05 | Исправлено и проверено | Удалены optional без callers и stockPosition wrapper; spec перенесён к instrumentPosition, проверяет напрямую владельца. | Targeted49 passed: fractional/large quantity, weighted costs, mixed/unknown currencies, no position и offset long/short. | +| C06 | Исправлено и проверено | Удалён неиспользуемый внешний totalsByCurrency export; живой mapper и внутренний caller сохранены. | Поиск imports подтверждает единственный внутренний caller; exact-money targeted tests passed, итоговый typecheck ниже. | +| C07 | Исправлено и проверено | Удалён HTTP_CODE_METADATA wiring spec; реальные register/login/refresh status200 и logout/cookie assertions сохранены в app.e2e. | Проверены callers/оставшиеся HTTP assertions; полный Linux auth gate прошёл: 12/12. Предыдущий macOS native SIGABRT остаётся отдельным ограничением среды. | + +### Gates первого прогона (macOS) + +Все команды выполнены из backend на Node `v24.21.0` после `nvm use --silent`. +Проверки данных использовали штатный runner, fresh temporary SQLite и штатные +migrations; рабочая БД не использовалась. Для localhost/Prisma cache запуски +получили разрешение вне sandbox; native abort повторялся и там. + +| Команда | Итог | +| --- | --- | +| `npm ci` | PASS; manifest и lockfile без изменений. | +| `npm run verify` | PASS: lint, app/test typecheck, 36 suites / 209 unit tests с coverage, build. | +| `npm run openapi:generate` | PASS; актуальный игнорируемый `openapi.json` сформирован. | +| `npm run test:openapi-contract` | PASS: 1 suite / 3 tests, включая object shape validation errors. | +| `npx prisma validate` | PASS. Schema и migrations не менялись. | +| `npm run test:durability -- /tmp/wealthledger-fix-baseline-current-runtime-fjftdF` | PASS. Baseline `0862d56`, до последней T-Bank migration, отдельно установлен и собран на текущем Node; результат `/var/folders/j_/h2ft23gd3qq35ps3v5bcftfm0000gn/T/wealthledger-bank-durability-SIeCq0`. | +| `LOG_LEVEL=error npm run test:e2e` | INCOMPLETE, exit 1: all-accounts 18/18 passed, затем app.e2e native SIGABRT до Jest summary. Остальные suites общий gate не выполнил. | +| `node test/sqlite.cjs tbank-candles-persistence.e2e-spec.ts` | PASS на финальном коде: 14/14 после одного native abort и повтора без edits. | +| `node test/sqlite.cjs tbank-metadata-persistence.e2e-spec.ts` | PASS на финальном коде: 15/15. | +| `node test/sqlite.cjs tbank-operations-sync.e2e-spec.ts` | INCOMPLETE: native SIGABRT до summary. | +| `node test/sqlite.cjs tbank-operations-persistence.e2e-spec.ts` | INCOMPLETE: native SIGABRT до summary. | +| `node test/sqlite.cjs tbank-catalog-discovery.e2e-spec.ts` | INCOMPLETE: native SIGABRT до summary. | +| `git diff --check` | PASS. | + +Логи итоговых gates: `/tmp/wealthledger-fix-verify.log`, +`/tmp/wealthledger-fix-openapi-generate.log`, `/tmp/wealthledger-fix-openapi-contract.log`, +`/tmp/wealthledger-fix-prisma-validate.log`, `/tmp/wealthledger-fix-durability.log`, +`/tmp/wealthledger-fix-final-e2e.log`. Дополнительные SQLite логи: +`/tmp/wealthledger-fix-a05-latest-sqlite-retry.log`, +`/tmp/wealthledger-fix-final-metadata-persistence.log`, +`/tmp/wealthledger-final-operations-sync.log`, +`/tmp/wealthledger-final-operations-persistence.log`, +`/tmp/wealthledger-fix-final-catalog-discovery.log`. + +Native failure: assertion `(env) != nullptr` в `node::RemoveEnvironmentCleanupHook`, +stack содержит destructor из `better_sqlite3.node`. Это конкретное ограничение +текущего native runtime, уже наблюдавшееся при исходном аудите; его первопричина +в рамках исправлений не установлена. Тесты не отключались, runner/dependencies +не изменялись. Эти macOS запуски остаются INCOMPLETE. Полный Linux прогон ниже +закрыл e2e gate и auth/operations/catalog acceptance; локальный native abort +на macOS при этом не исправлен. + +### Повторная полная проверка (Linux, 2026-09-27) + +**PASS: 21 e2e suites / 262 tests, exit 0**, ни одного skipped/disabled test. +Выполнена вся штатная `npm run test:e2e`: build и `node test/sqlite.cjs --all`. +Каждая suite получила отдельную temporary SQLite со всеми пятью migrations. +Закрыты прежде неполные auth12, operations-persistence5, operations-sync17, +catalog-discovery10; также прошли candles14, forecast/upcoming20, screen29, +OpenAPI3 и большой read-baseline с 300 000 операций / 1 000 000 свечей. + +Перед повтором зафиксирован тот же HEAD и hashes всех tracked/untracked files; +до завершения тестов ни один исходник, тест, config или audit не менялся. +Поэтому предыдущие `verify` (209 unit tests), OpenAPI и durability сохраняют +актуальность. В этом повторе изменён только статус данного документа. + +MacOS `LOG_LEVEL=error node test/sqlite.cjs app.e2e-spec.ts` снова завершился +native SIGABRT. Для Linux использован существующий Dockerfile target +`development`, Node `v24.21.0` (проверен против `.nvmrc`) и неизменённый lockfile: +SHA-256 `f986979b7922d684fead3a4d32532d11f1e469cec0e35669509343be41cbb2cd`. +Image `wealthledger-backend:audit-verification-20260927`, +ID `sha256:69264cf3dba23566d99fded450104bbe56f92755604e88232beb97825155ae73`. +Сборка выполнила `npm ci`; зависимости и runner не подменялись. Test/scripts +подключались read-only, внешняя сеть отключена, host ports не публиковались; +одноразовый контейнер удалён после завершения, рабочая БД не подключалась. + +Команды воспроизведения из backend (на host предварительно `source ~/.nvm/nvm.sh` +и `nvm use --silent`; внутри image закреплён тот же Node): + +```bash +docker build --target development --tag wealthledger-backend:audit-verification-20260927 . +docker run --rm --network none \ + --mount type=bind,source=/Users/ksv741/Project/vibe/WealthLedger/backend/test,target=/app/test,readonly \ + --mount type=bind,source=/Users/ksv741/Project/vibe/WealthLedger/backend/scripts,target=/app/scripts,readonly \ + -e LOG_LEVEL=error wealthledger-backend:audit-verification-20260927 npm run test:e2e +``` + +Логи: `/tmp/wealthledger-recheck-auth.log` (macOS abort), +`/tmp/wealthledger-recheck-linux-build.log` (Linux image build), +`/tmp/wealthledger-recheck-linux-e2e.log` (полный PASS). + +Read-only диагностика локализовала macOS crash в GC finalizer Statement/Database +вложенного `better-sqlite3 12.11.1`, используемого Prisma adapter 7.10.0. +Node 24.21 ObjectWrap destructor вызывает RemoveEnvironmentCleanupHook, который +падает на `env != nullptr`. Связь с Jest VM context и происхождением native +binary остаётся гипотезой, а не доказанной первопричиной. Ошибка application +lifecycle не обнаружена: close/disconnect ожидаются. Linux PASS доказывает +поведение backend в этом окружении, но не устранение native crash на macOS. + +### Semantic / Boy Scout review и состояние дерева + +Повторный routing по staged/unstaged/new files активировал Architecture, +HTTP/errors, Data, Testing, Security, Integrations и Observability. Проверены +owners/callers и связанная документация; независимый review охватил auth/logging, +candle coalescing/publication, forecast fallback и cleanup. После устранения +найденной при review stale metadata у старого forecast fallback оставшихся +semantic замечаний нет. Проверены точная денежная арифметика, ownership, +transactions, generation fencing, сохранение cache/pending promises и отсутствие +новых дублирующих helpers. + +На момент завершения проверки, до запроса публикации, HEAD оставался +`516e43203eb3fee8e6a0c7018be2fee11f1b900c`, ветка +`codex/investment-contracts`; staged files отсутствуют. Все изменения локальные, +без commit/push/PR. Новые untracked files: исходный `BACKEND_AUDIT.md`, +`src/modules/investments/instruments/application/candle.service.spec.ts` и +`src/modules/investments/instruments/application/instrument-position.spec.ts`. +Остальные изменения — tracked source/tests и три связанных convention-файла. +Новые зависимости, scripts, migrations и разделение production-файлов не добавлены. +Исходный текст до раздела «Статус исправлений» побайтово совпадает со снимком +`/tmp/wealthledger-backend-audit-before-fixes.md`. + +### Подготовка публикации + +После успешной полной проверки пользователь разрешил commit, PR и merge. +Ветка публикации — `codex/backend-audit-fixes` от актуального `origin/develop` +`5d3835e`; его tree совпадает с исходным HEAD проверки, поэтому исходники и +результаты gates не изменились. Pre-commit review не обнаружил посторонних +файлов, секретов или незакрытых semantic findings. PR направляется в `develop`; +merge выполняется после обязательных `verify`, `security`, `commits`. diff --git a/src/common/decorators/api-safe-error-response.decorator.ts b/src/common/decorators/api-safe-error-response.decorator.ts index f62ab49..d9f8947 100644 --- a/src/common/decorators/api-safe-error-response.decorator.ts +++ b/src/common/decorators/api-safe-error-response.decorator.ts @@ -15,6 +15,12 @@ export class ProblemDetailsDto { @ApiProperty({ required: false, + type: 'array', + items: { + type: 'object', + required: ['pointer', 'detail'], + properties: { pointer: { type: 'string' }, detail: { type: 'string' } }, + }, example: [{ pointer: '#/email', detail: 'must be a valid email address' }], }) errors?: { pointer: string; detail: string }[]; diff --git a/src/common/filters/http-exception.filter.spec.ts b/src/common/filters/http-exception.filter.spec.ts index f916897..0a10828 100644 --- a/src/common/filters/http-exception.filter.spec.ts +++ b/src/common/filters/http-exception.filter.spec.ts @@ -14,7 +14,10 @@ describe('HttpExceptionFilter', () => { }), } as unknown as ArgumentsHost; - filter.catch(new Error('database password: secret'), host); + filter.catch( + new Error('operation failed\npassword: SYNTHETIC_AUDIT_SECRET'), + host, + ); expect(status).toHaveBeenCalledWith(500); expect(type).toHaveBeenCalledWith('application/problem+json'); @@ -23,7 +26,6 @@ describe('HttpExceptionFilter', () => { code: 'INTERNAL_SERVER_ERROR', }); expect(locals.publicErrorCode).toBe('INTERNAL_SERVER_ERROR'); - expect(typeof locals.errorStack).toBe('string'); - expect(locals.errorStack).not.toContain('database password: secret'); + expect(locals.errorStack).toBeUndefined(); }); }); diff --git a/src/common/filters/http-exception.filter.ts b/src/common/filters/http-exception.filter.ts index 5962da6..0703a83 100644 --- a/src/common/filters/http-exception.filter.ts +++ b/src/common/filters/http-exception.filter.ts @@ -27,14 +27,6 @@ export class HttpExceptionFilter implements ExceptionFilter { ? 'VALIDATION_ERROR' : this.codeFor(status, payload); (response.locals ??= {}).publicErrorCode = code; - if ( - status >= Number(HttpStatus.INTERNAL_SERVER_ERROR) && - exception instanceof Error - ) - response.locals.errorStack = exception.stack - ?.split('\n') - .slice(1) - .join('\n'); response .status(status) .type('application/problem+json') @@ -69,12 +61,18 @@ export class HttpExceptionFilter implements ExceptionFilter { private validationErrors( errors: ValidationError[], + prefix = '#', ): { pointer: string; detail: string }[] { - return errors.flatMap((error) => - Object.values(error.constraints ?? {}).map((detail) => ({ - pointer: `#/${error.property}`, - detail, - })), - ); + return errors.flatMap((error) => { + if (!error || typeof error.property !== 'string') return []; + const pointer = `${prefix}/${error.property.replaceAll('~', '~0').replaceAll('/', '~1')}`; + return [ + ...Object.values(error.constraints ?? {}).map((detail) => ({ + pointer, + detail, + })), + ...this.validationErrors(error.children ?? [], pointer), + ]; + }); } } diff --git a/src/common/logging/request-logging.middleware.ts b/src/common/logging/request-logging.middleware.ts index fdfaf04..cb6952c 100644 --- a/src/common/logging/request-logging.middleware.ts +++ b/src/common/logging/request-logging.middleware.ts @@ -42,9 +42,6 @@ export class RequestLoggingMiddleware implements NestMiddleware { ...(response.locals.publicErrorCode ? { code: response.locals.publicErrorCode as string } : {}), - ...(response.locals.errorStack - ? { stack: response.locals.errorStack as string } - : {}), }; const level = response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR diff --git a/src/common/logging/structured-logger.service.ts b/src/common/logging/structured-logger.service.ts index 7968da5..6ec49cf 100644 --- a/src/common/logging/structured-logger.service.ts +++ b/src/common/logging/structured-logger.service.ts @@ -17,7 +17,6 @@ const allowedFields = new Set([ 'status', 'durationMs', 'code', - 'stack', 'operation', 'grpcCode', 'attempts', diff --git a/src/integrations/moex/moex.client.spec.ts b/src/integrations/moex/moex.client.spec.ts index bc01d6a..46c2e58 100644 --- a/src/integrations/moex/moex.client.spec.ts +++ b/src/integrations/moex/moex.client.spec.ts @@ -1,5 +1,11 @@ import { MoexClient } from './moex.client'; +const emptyEvents = { + coupons: { columns: ['coupondate', 'value', 'faceunit'], data: [] }, + offers: { columns: ['offerdate'], data: [] }, + description: { columns: ['name', 'value'], data: [] }, +}; + describe('MoexClient', () => { afterEach(() => jest.restoreAllMocks()); it('uses effective yield only for the exact ISIN and board and preserves its own price and date', async () => { @@ -92,17 +98,174 @@ describe('MoexClient', () => { expect(result?.value ?? null).toBe(value === null ? null : '0'); }, ); - it('treats a successful response without the requested block as no events', async () => { - const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - description: { columns: ['name', 'value'], data: [] }, - boards: { columns: ['secid'], data: [] }, - }), - } as Response); - + it('accepts confirmed empty blocks', async () => { + jest + .spyOn(global, 'fetch') + .mockImplementation(() => Promise.resolve(Response.json(emptyEvents))); await expect(new MoexClient().bondEvents('BOND')).resolves.toEqual([]); - fetchMock.mockRestore(); }); + + it('preserves not-found semantics independently of malformed HTTP 200', async () => { + jest + .spyOn(global, 'fetch') + .mockImplementation(() => + Promise.resolve(new Response(null, { status: 404 })), + ); + const client = new MoexClient(); + await expect(client.bondEvents('MISSING')).resolves.toEqual([]); + await expect( + client.bondYield('MISSING', 'ISIN', 'TQOB'), + ).resolves.toBeNull(); + }); + + it.each([ + ['missing block', { coupons: undefined }], + ['missing columns', { coupons: { data: [] } }], + ['invalid column', { coupons: { columns: [null], data: [] } }], + ['invalid row', { coupons: { columns: ['coupondate'], data: [null] } }], + [ + 'short row', + { coupons: { columns: ['coupondate', 'value'], data: [['2099-02-01']] } }, + ], + [ + 'long row', + { coupons: { columns: ['coupondate'], data: [['2099-02-01', 2]] } }, + ], + [ + 'empty cursor', + { + 'coupons.cursor': { columns: ['INDEX', 'TOTAL', 'PAGESIZE'], data: [] }, + }, + ], + [ + 'missing cursor field', + { 'coupons.cursor': { columns: ['INDEX', 'TOTAL'], data: [[0, 1]] } }, + ], + [ + 'null cursor index', + { + 'coupons.cursor': { + columns: ['INDEX', 'TOTAL', 'PAGESIZE'], + data: [[null, 0, 1]], + }, + }, + ], + [ + 'boolean cursor total', + { + 'coupons.cursor': { + columns: ['INDEX', 'TOTAL', 'PAGESIZE'], + data: [[0, false, 1]], + }, + }, + ], + [ + 'empty cursor total', + { + 'coupons.cursor': { + columns: ['INDEX', 'TOTAL', 'PAGESIZE'], + data: [[0, '', 1]], + }, + }, + ], + [ + 'short page', + { + 'coupons.cursor': { + columns: ['INDEX', 'TOTAL', 'PAGESIZE'], + data: [[0, 1, 1]], + }, + }, + ], + ])( + 'rejects %s without returning a partial calendar', + async (_name, invalid) => { + jest + .spyOn(global, 'fetch') + .mockImplementation(() => + Promise.resolve(Response.json({ ...emptyEvents, ...invalid })), + ); + await expect(new MoexClient().bondEvents('BOND')).rejects.toMatchObject({ + response: { code: 'MOEX_UNAVAILABLE' }, + }); + }, + ); + + it.each([0, -1])( + 'rejects nonpositive page size %s before repeating a fetch', + async (pageSize) => { + const starts: string[] = []; + jest.spyOn(global, 'fetch').mockImplementation((input) => { + const url = new URL(input instanceof Request ? input.url : input); + if (url.searchParams.get('iss.only')?.startsWith('coupons,')) { + starts.push(url.searchParams.get('start') ?? ''); + if (starts.length > 1) + return Promise.reject(new Error('Repeated page')); + } + return Promise.resolve( + Response.json({ + ...emptyEvents, + 'coupons.cursor': { + columns: ['INDEX', 'TOTAL', 'PAGESIZE'], + data: [[0, 1, pageSize]], + }, + }), + ); + }); + await expect(new MoexClient().bondEvents('BOND')).rejects.toMatchObject({ + response: { code: 'MOEX_UNAVAILABLE' }, + }); + expect(starts).toEqual(['0']); + }, + ); + + it.each([false, true])( + 'reads all cursor pages and rejects a repeated index: %s', + async (repeatIndex) => { + const starts: string[] = []; + jest.spyOn(global, 'fetch').mockImplementation((input) => { + const url = new URL(input instanceof Request ? input.url : input); + const start = Number(url.searchParams.get('start')); + if (url.searchParams.get('iss.only')?.startsWith('coupons,')) { + starts.push(String(start)); + if (starts.length > 2) + return Promise.reject(new Error('Repeated page')); + } + return Promise.resolve( + Response.json({ + ...emptyEvents, + coupons: { + columns: ['coupondate', 'value', 'faceunit'], + data: [[`2099-02-0${start + 1}`, '2.000000001', 'SUR']], + }, + 'coupons.cursor': { + columns: ['INDEX', 'TOTAL', 'PAGESIZE'], + data: [[repeatIndex ? 0 : start, 2, 1]], + }, + }), + ); + }); + const result = new MoexClient().bondEvents('BOND'); + if (repeatIndex) + await expect(result).rejects.toMatchObject({ + response: { code: 'MOEX_UNAVAILABLE' }, + }); + else + await expect(result).resolves.toEqual([ + { + type: 'coupon', + date: '2099-02-01', + value: '2.000000001', + currency: 'RUB', + }, + { + type: 'coupon', + date: '2099-02-02', + value: '2.000000001', + currency: 'RUB', + }, + ]); + expect(starts).toEqual(['0', '1']); + }, + ); }); diff --git a/src/integrations/moex/moex.client.ts b/src/integrations/moex/moex.client.ts index 85351e6..d00fbbf 100644 --- a/src/integrations/moex/moex.client.ts +++ b/src/integrations/moex/moex.client.ts @@ -123,22 +123,26 @@ export class MoexClient { rows.push(...current); if (!paginated) return rows; const cursorBlock = response[`${block}.cursor`]; - if (!cursorBlock) { + if (cursorBlock === undefined) { if (current.length < FALLBACK_PAGE_SIZE) return rows; start += FALLBACK_PAGE_SIZE; continue; } - const cursor = parseRows(cursorBlock)[0]; + const cursors = parseRows(cursorBlock); + const cursor = cursors[0]; const index = integer(cursor?.index); const total = integer(cursor?.total); const pageSize = integer(cursor?.pagesize); if ( - index === null || + cursors.length !== 1 || + index !== start || total === null || pageSize === null || - index + pageSize >= total + pageSize === 0 || + current.length !== Math.min(pageSize, total - start) ) - return rows; + throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); + if (index + pageSize >= total) return rows; start = index + pageSize; } } @@ -161,14 +165,13 @@ export class MoexClient { } function parseRows(block: IssBlock | undefined): Record[] { - if (!block) return []; - if (!Array.isArray(block.columns) || !Array.isArray(block.data)) + if (!Array.isArray(block?.columns) || !Array.isArray(block?.data)) throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); const columns = block.columns; if (!columns.every((column) => typeof column === 'string')) throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); return block.data.map((values) => { - if (!Array.isArray(values)) + if (!Array.isArray(values) || values.length !== columns.length) throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); return Object.fromEntries( columns.map((column, index) => [column.toLowerCase(), values[index]]), @@ -191,6 +194,6 @@ function currencyText(value: unknown): string | null { } function integer(value: unknown): number | null { - const result = Number(value); + const result = Number(numberText(value) ?? NaN); return Number.isSafeInteger(result) && result >= 0 ? result : null; } diff --git a/src/integrations/tbank/tbank-access.service.spec.ts b/src/integrations/tbank/tbank-access.service.spec.ts deleted file mode 100644 index f0b94e0..0000000 --- a/src/integrations/tbank/tbank-access.service.spec.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { - BadRequestException, - ConflictException, - NotFoundException, - ServiceUnavailableException, -} from '@nestjs/common'; - -jest.mock('../../prisma/prisma.service', () => ({ - PrismaService: class PrismaService {}, -})); - -import type { TbankAccountContext } from './tbank-access.service'; -import { TbankAccessService } from './tbank-access.service'; -import { TbankTokenService } from './tbank-token.service'; - -describe('TbankAccessService', () => { - const prisma = { tbankConnection: { findUnique: jest.fn() } }; - const token = { - decrypt: jest.fn(), - translateInvalidToken: jest.fn((operation: () => Promise) => - new TbankTokenService({ - getOrThrow: () => 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=', - } as never).translateInvalidToken(operation), - ), - }; - const tbank = { getOpenAccounts: jest.fn() }; - const service = new TbankAccessService( - prisma as never, - token as never, - tbank as never, - ); - - beforeEach(() => { - jest.clearAllMocks(); - prisma.tbankConnection.findUnique.mockResolvedValue({ - encryptedToken: 'encrypted', - }); - token.decrypt.mockReturnValue('plain-token'); - tbank.getOpenAccounts.mockResolvedValue([ - { - id: 'account', - type: 'ACCOUNT_TYPE_TINKOFF', - name: ' Основной ', - openedDate: { seconds: '1723507200' }, - }, - { id: 'debit', type: 'ACCOUNT_TYPE_DEBIT', name: 'Debit' }, - ]); - }); - - it('lists supported accounts through the saved decrypted token', async () => { - await expect(service.listSupportedAccounts('user')).resolves.toEqual([ - { - id: 'account', - type: 'BROKERAGE', - name: 'Основной', - openedOn: '2024-08-13', - }, - ]); - - expect(prisma.tbankConnection.findUnique).toHaveBeenCalledWith({ - where: { userId: 'user' }, - }); - expect(token.decrypt).toHaveBeenCalledWith('encrypted'); - expect(tbank.getOpenAccounts).toHaveBeenCalledWith('plain-token'); - }); - - it('rejects missing connections before calling T-Bank', async () => { - prisma.tbankConnection.findUnique.mockResolvedValue(null); - - await expect(service.listSupportedAccounts('user')).rejects.toEqual( - expect.any(ConflictException), - ); - expect(tbank.getOpenAccounts).not.toHaveBeenCalled(); - }); - - it('resolves the persisted connection generation without an upstream account call', async () => { - const context = await service.withConnection('user', (value) => value); - - expect(context).toEqual({ - token: 'plain-token', - generation: expect.any(String) as string, - }); - expect(tbank.getOpenAccounts).not.toHaveBeenCalled(); - }); - - it('translates invalid tokens and preserves unavailable upstream errors', async () => { - tbank.getOpenAccounts.mockRejectedValueOnce( - new Error('INVALID_TBANK_TOKEN'), - ); - await expect(service.listSupportedAccounts('user')).rejects.toEqual( - expect.any(BadRequestException), - ); - - const unavailable = new ServiceUnavailableException({ - code: 'TBANK_UNAVAILABLE', - }); - tbank.getOpenAccounts.mockRejectedValueOnce(unavailable); - await expect(service.listSupportedAccounts('user')).rejects.toBe( - unavailable, - ); - }); - - it('wraps account-scoped handlers with resolved account access', async () => { - const handler = jest.fn((context: TbankAccountContext) => - Promise.resolve({ - resolvedToken: context.token, - generation: context.generation, - accountId: context.account.id, - }), - ); - - await expect( - service.withAccount('user', 'account', handler), - ).resolves.toEqual({ - resolvedToken: 'plain-token', - generation: expect.any(String) as string, - accountId: 'account', - }); - }); - - it('derives a stable opaque generation from each persisted encrypted payload', async () => { - const generation = () => - service.withAccount('user', 'account', ({ generation: value }) => - Promise.resolve(value), - ); - prisma.tbankConnection.findUnique - .mockResolvedValueOnce({ encryptedToken: 'encrypted:first-nonce' }) - .mockResolvedValueOnce({ encryptedToken: 'encrypted:first-nonce' }) - .mockResolvedValueOnce({ encryptedToken: 'encrypted:second-nonce' }); - - const first = await generation(); - const sameRecord = await generation(); - const replaced = await generation(); - - expect(sameRecord).toBe(first); - expect(replaced).not.toBe(first); - expect(first).not.toContain('plain-token'); - expect(first).not.toContain('encrypted:first-nonce'); - }); - - it('rejects inaccessible accounts before running the handler', async () => { - const handler = jest.fn(); - - await expect( - service.withAccount('user', 'missing', handler), - ).rejects.toEqual(expect.any(NotFoundException)); - expect(handler).not.toHaveBeenCalled(); - }); - - it('translates invalid-token handler failures and preserves unrelated handler failures', async () => { - await expect( - service.withAccount('user', 'account', () => - Promise.reject(new Error('INVALID_TBANK_TOKEN')), - ), - ).rejects.toEqual(expect.any(BadRequestException)); - - const failure = new Error('source failed'); - await expect( - service.withAccount('user', 'account', () => Promise.reject(failure)), - ).rejects.toBe(failure); - }); -}); diff --git a/src/integrations/tbank/tbank-access.service.ts b/src/integrations/tbank/tbank-access.service.ts index 5d3a6c6..48be690 100644 --- a/src/integrations/tbank/tbank-access.service.ts +++ b/src/integrations/tbank/tbank-access.service.ts @@ -5,24 +5,11 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { createHash, randomUUID } from 'node:crypto'; +import { randomUUID } from 'node:crypto'; import { PrismaService } from '../../prisma/prisma.service'; -import { - SupportedTbankAccount, - accessibleTbankAccount, - supportedTbankAccounts, -} from './tbank-account.policy'; +import { supportedTbankAccounts } from './tbank-account.policy'; import { TbankTokenService } from './tbank-token.service'; -import { TbankAccount, TbankClient } from './tbank.client'; - -export type TbankConnectionContext = { - token: string; - generation: string; -}; - -export type TbankAccountContext = TbankConnectionContext & { - account: TbankAccount; -}; +import type { TbankAccount } from './tbank.client'; export type TbankSyncAttempt = { userId: string; @@ -36,7 +23,6 @@ export class TbankAccessService { constructor( private readonly prisma: PrismaService, private readonly tokens: TbankTokenService, - private readonly tbank: TbankClient, ) {} async reserveMembership( @@ -251,81 +237,4 @@ export class TbankAccessService { throw new NotFoundException(); return connection; } - - async listSupportedAccounts( - userId: string, - ): Promise { - return this.withSupportedAccounts(userId, ({ accounts }) => accounts); - } - - async withSupportedAccounts( - userId: string, - handler: (context: { - token: string; - generation: string; - accounts: SupportedTbankAccount[]; - }) => Promise | T, - ): Promise { - return this.withConnection(userId, async ({ token, generation }) => - handler({ - token, - generation, - accounts: supportedTbankAccounts( - await this.tbank.getOpenAccounts(token), - ), - }), - ); - } - - async withConnection( - userId: string, - handler: (context: TbankConnectionContext) => Promise | T, - ): Promise { - const connection = await this.connection(userId); - return this.tokens.translateInvalidToken(async () => handler(connection)); - } - - async withAccount( - userId: string, - accountId: string, - handler: (context: TbankAccountContext) => Promise, - ): Promise { - return this.withConnection(userId, (connection) => - this.withAccountForConnection(connection, accountId, handler), - ); - } - - async withAccountForConnection( - connection: TbankConnectionContext, - accountId: string, - handler: (context: TbankAccountContext) => Promise, - ): Promise { - const { token, generation } = connection; - return this.tokens.translateInvalidToken(async () => { - const account = accessibleTbankAccount( - await this.tbank.getOpenAccounts(token), - accountId, - ); - if (!account) throw new NotFoundException(); - return handler({ token, generation, account }); - }); - } - - private async connection( - userId: string, - ): Promise<{ token: string; generation: string }> { - const connection = await this.prisma.tbankConnection.findUnique({ - where: { userId }, - }); - if (!connection) - throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' }); - if (connection.invalidToken) - throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }); - return { - token: this.tokens.decrypt(connection.encryptedToken), - generation: createHash('sha256') - .update(connection.encryptedToken) - .digest('base64url'), - }; - } } diff --git a/src/integrations/tbank/tbank-account.policy.spec.ts b/src/integrations/tbank/tbank-account.policy.spec.ts index 7b582e8..8cb0915 100644 --- a/src/integrations/tbank/tbank-account.policy.spec.ts +++ b/src/integrations/tbank/tbank-account.policy.spec.ts @@ -1,7 +1,4 @@ -import { - accessibleTbankAccount, - supportedTbankAccounts, -} from './tbank-account.policy'; +import { supportedTbankAccounts } from './tbank-account.policy'; describe('tbank-account-policy', () => { it('normalizes supported account types and fallback names', () => { @@ -45,26 +42,4 @@ describe('tbank-account-policy', () => { }, ]); }); - - it('matches account-scoped access only for supported account types', () => { - expect( - accessibleTbankAccount( - [ - { id: 'account', type: 'ACCOUNT_TYPE_DEBIT', name: 'Debit' }, - { id: 'other', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Other' }, - ], - 'account', - ), - ).toBeNull(); - expect( - accessibleTbankAccount( - [{ id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Brokerage' }], - 'account', - ), - ).toEqual({ - id: 'account', - type: 'ACCOUNT_TYPE_TINKOFF', - name: 'Brokerage', - }); - }); }); diff --git a/src/integrations/tbank/tbank-account.policy.ts b/src/integrations/tbank/tbank-account.policy.ts index 22c3aec..0589da5 100644 --- a/src/integrations/tbank/tbank-account.policy.ts +++ b/src/integrations/tbank/tbank-account.policy.ts @@ -39,17 +39,6 @@ export function supportedTbankAccounts( }); } -export function accessibleTbankAccount( - accounts: TbankAccount[], - accountId: string, -): TbankAccount | null { - return ( - accounts.find( - (account) => account.id === accountId && account.type in accountTypes, - ) ?? null - ); -} - function openedOn(seconds: string | number | undefined): string | null { if (seconds === undefined) return null; const date = new Date(Number(seconds) * MILLISECONDS_PER_SECOND); diff --git a/src/integrations/tbank/tbank.api.ts b/src/integrations/tbank/tbank.api.ts index 6d67a1d..d260155 100644 --- a/src/integrations/tbank/tbank.api.ts +++ b/src/integrations/tbank/tbank.api.ts @@ -2,13 +2,14 @@ export { InstrumentMetadataService } from './instrument-metadata.service'; export { TbankAccessService, - type TbankConnectionContext, + type TbankSyncAttempt, } from './tbank-access.service'; export { type TbankBond, type TbankCandle, type TbankCoupon, type TbankFundamentals, + type TbankLastPrice, type TbankShare, } from './tbank-market.types'; export { TbankTokenService } from './tbank-token.service'; diff --git a/src/main.ts b/src/main.ts index e80447a..61c4511 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,8 @@ -import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { + BadRequestException, + INestApplication, + ValidationPipe, +} from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import cookieParser from 'cookie-parser'; import { ConfigService } from '@nestjs/config'; @@ -26,7 +30,14 @@ export function configureApp(app: INestApplication): void { app.use(cookieParser()); const requestLogging = app.get(RequestLoggingMiddleware); app.use(requestLogging.use.bind(requestLogging)); - app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true })); + app.useGlobalPipes( + new ValidationPipe({ + transform: true, + whitelist: true, + validationError: { target: false, value: false }, + exceptionFactory: (message) => new BadRequestException({ message }), + }), + ); app.useGlobalFilters(new HttpExceptionFilter()); app.enableShutdownHooks(); } diff --git a/src/modules/auth/application/auth.service.ts b/src/modules/auth/application/auth.service.ts index 2e611c2..61babc2 100644 --- a/src/modules/auth/application/auth.service.ts +++ b/src/modules/auth/application/auth.service.ts @@ -29,9 +29,8 @@ export class AuthService { email: string, password: string, ): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { - const normalizedEmail = this.normalizeEmail(email); const existing = await this.prisma.user.findUnique({ - where: { email: normalizedEmail }, + where: { email }, }); if (existing) throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' }); @@ -41,7 +40,7 @@ export class AuthService { user = await this.prisma.user.create({ data: { id: randomUUID(), - email: normalizedEmail, + email, passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS), }, }); @@ -58,7 +57,7 @@ export class AuthService { password: string, ): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { const user = await this.prisma.user.findUnique({ - where: { email: this.normalizeEmail(email) }, + where: { email }, }); const valid = await bcrypt.compare( password, @@ -103,26 +102,27 @@ export class AuthService { async logout(token?: string): Promise { if (!token) return; + let payload: RefreshPayload; try { - const payload = await this.jwt.verifyAsync(token, { + payload = await this.jwt.verifyAsync(token, { algorithms: ['HS256'], }); - if (payload.type !== 'refresh') return; - const session = await this.prisma.session.findUnique({ - where: { id: payload.sid }, - }); - if ( - session && - !session.revokedAt && - session.refreshTokenHash === this.hash(token) - ) { - await this.prisma.session.update({ - where: { id: session.id }, - data: { revokedAt: new Date() }, - }); - } } catch { - // Logout is intentionally idempotent. + return; + } + if (payload.type !== 'refresh') return; + const session = await this.prisma.session.findUnique({ + where: { id: payload.sid }, + }); + if ( + session && + !session.revokedAt && + session.refreshTokenHash === this.hash(token) + ) { + await this.prisma.session.update({ + where: { id: session.id }, + data: { revokedAt: new Date() }, + }); } } @@ -187,10 +187,6 @@ export class AuthService { }; } - private normalizeEmail(email: string): string { - return email; - } - private hash(token: string): string { return createHash('sha256').update(token).digest('hex'); } diff --git a/src/modules/auth/presentation/auth.controller.spec.ts b/src/modules/auth/presentation/auth.controller.spec.ts deleted file mode 100644 index 5c8dc1e..0000000 --- a/src/modules/auth/presentation/auth.controller.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { HTTP_CODE_METADATA } from '@nestjs/common/constants'; - -jest.mock('../application/auth.service', () => ({ - AuthService: class AuthService {}, -})); - -import { AuthController } from './auth.controller'; - -describe('AuthController response statuses', () => { - it.each(['register', 'login', 'refresh'] as const)( - '%s explicitly returns HTTP 200', - (handler) => { - // Nest stores @HttpCode metadata on the unbound handler function. - const handlerTarget = Object.getOwnPropertyDescriptor( - AuthController.prototype, - handler, - )?.value as object; - expect(Reflect.getMetadata(HTTP_CODE_METADATA, handlerTarget)).toBe(200); - }, - ); -}); diff --git a/src/modules/auth/presentation/auth.controller.ts b/src/modules/auth/presentation/auth.controller.ts index 3b44ca6..45a35dc 100644 --- a/src/modules/auth/presentation/auth.controller.ts +++ b/src/modules/auth/presentation/auth.controller.ts @@ -87,7 +87,7 @@ export class AuthController { if (!token) throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); return this.respond(response, await this.auth.refresh(token)); } catch (error) { - this.clearCookie(response); + if (error instanceof UnauthorizedException) this.clearCookie(response); throw error; } } diff --git a/src/modules/banking/bank-cards/domain/bank-card.calculations.spec.ts b/src/modules/banking/bank-cards/domain/bank-card.calculations.spec.ts index 9585110..6e4aa88 100644 --- a/src/modules/banking/bank-cards/domain/bank-card.calculations.spec.ts +++ b/src/modules/banking/bank-cards/domain/bank-card.calculations.spec.ts @@ -116,7 +116,9 @@ describe('bank card calculations', () => { amountKopecks: 5000n, }, ]; - expect(bankSummary('2026-09', operations)).toEqual({ + expect( + bankSummary({ from: '2026-09-01', to: '2026-09-30' }, operations), + ).toEqual({ expenses: 120n, refunds: 500n, net: -380n, @@ -125,7 +127,9 @@ describe('bank card calculations', () => { { categoryId: 'travel', expenses: 20n, refunds: 0n, net: 20n }, ], }); - expect(bankSummary('2026-07', operations)).toEqual({ + expect( + bankSummary({ from: '2026-07-01', to: '2026-07-31' }, operations), + ).toEqual({ expenses: 0n, refunds: 0n, net: 0n, @@ -141,7 +145,12 @@ describe('bank card calculations', () => { amountKopecks: 2n ** 63n - 1n, }; const total = 2n * (2n ** 63n - 1n); - expect(bankSummary('2026-09', [operation, operation])).toEqual({ + expect( + bankSummary({ from: '2026-09-01', to: '2026-09-30' }, [ + operation, + operation, + ]), + ).toEqual({ expenses: total, refunds: 0n, net: total, diff --git a/src/modules/banking/bank-cards/domain/bank-card.calculations.ts b/src/modules/banking/bank-cards/domain/bank-card.calculations.ts index 4ee045a..e3a5ae6 100644 --- a/src/modules/banking/bank-cards/domain/bank-card.calculations.ts +++ b/src/modules/banking/bank-cards/domain/bank-card.calculations.ts @@ -1,6 +1,5 @@ import { BankOperationKind } from './bank-operation-kind'; -const ISO_MONTH_LENGTH = 7; export function moscowToday(now = new Date()): string { return new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Moscow', @@ -54,7 +53,7 @@ export function bankBalance( } export function bankSummary( - period: string | { from: string; to: string }, + period: { from: string; to: string }, operations: Array<{ kind: string; date: string; @@ -70,12 +69,7 @@ export function bankSummary( >(); // ponytail: O(N) history fold; optimize only after measuring growing histories. for (const operation of operations) { - if ( - typeof period === 'string' - ? operation.date.slice(0, ISO_MONTH_LENGTH) !== period - : operation.date < period.from || operation.date > period.to - ) - continue; + if (operation.date < period.from || operation.date > period.to) continue; if ( operation.kind !== BankOperationKind.Expense && operation.kind !== BankOperationKind.Refund diff --git a/src/modules/investments/instruments/application/candle.service.spec.ts b/src/modules/investments/instruments/application/candle.service.spec.ts new file mode 100644 index 0000000..0660470 --- /dev/null +++ b/src/modules/investments/instruments/application/candle.service.spec.ts @@ -0,0 +1,113 @@ +jest.mock('../infrastructure/candle.repository', () => ({ + CandleRepository: class {}, +})); +jest.mock('./catalog-enrichment.service', () => ({ + CatalogEnrichmentService: class {}, +})); +jest.mock('../../../../integrations/tbank/tbank.api', () => ({ + TbankAccessService: class {}, + TbankClient: class {}, +})); + +import { CandleService } from './candle.service'; + +describe('CandleService forced demand', () => { + it.each([false, true])( + 'reconciles the full range through the latest forced timestamp while force=%s is loading', + async (initialForce) => { + let entered = () => {}; + let release = () => {}; + let complete = () => {}; + const started = new Promise((resolve) => (entered = resolve)); + const blocked = new Promise((resolve) => (release = resolve)); + const finished = new Promise((resolve) => (complete = resolve)); + let completed = 0; + const work = { id: 'work', attemptId: 'attempt' }; + const repository = { + reserve: jest + .fn() + .mockResolvedValueOnce({ work, launch: true }) + .mockResolvedValueOnce({ + work: { id: 'other', attemptId: 'other-attempt' }, + launch: true, + }) + .mockResolvedValue({ work, launch: false }), + read: jest.fn().mockResolvedValue({ + coverage: [ + { + from: new Date('2026-01-01Z'), + to: new Date('2026-02-01Z'), + isComplete: true, + fetchedAt: new Date('2026-02-01Z'), + }, + ], + }), + stage: jest.fn().mockResolvedValue(true), + publish: jest.fn((_attempt, _intent, done = true) => { + if (done && ++completed === 2) complete(); + return Promise.resolve(true); + }), + fail: jest.fn(), + }; + const sources = { + ensureDetail: jest.fn().mockResolvedValue({ + first_1dayCandleDate: { + seconds: String(Date.parse('2026-01-01Z') / 1000), + }, + }), + }; + const upstream = { getCandles: jest.fn().mockResolvedValue([]) }; + upstream.getCandles.mockImplementationOnce(async () => { + entered(); + await blocked; + return []; + }); + const service = new CandleService( + repository as never, + sources as never, + { tokenForAttempt: jest.fn().mockResolvedValue('token') } as never, + upstream as never, + { event: jest.fn() } as never, + ); + const input = { + uid: 'uid', + instrumentType: 'share' as const, + period: 'all' as const, + }; + await service.request( + 'owner', + 'revision', + input, + new Date('2026-02-02T12:00:00Z'), + initialForce, + 2, + ); + await started; + const latest = new Date('2026-02-02T13:00:00Z'); + await service.request( + 'owner', + 'revision', + { ...input, uid: 'other' }, + latest, + false, + 1, + ); + expect( + await service.request('owner', 'revision', input, latest, true, 0), + ).toBe('work'); + release(); + await finished; + + expect(upstream.getCandles).toHaveBeenCalledWith( + 'token', + 'uid', + new Date('2026-01-01Z'), + latest, + ); + expect(repository.fail).not.toHaveBeenCalled(); + expect( + upstream.getCandles.mock.calls.map((call: unknown[]) => call[1]), + ).toEqual(['uid', 'uid', 'other']); + }, + ); +}); diff --git a/src/modules/investments/instruments/application/candle.service.ts b/src/modules/investments/instruments/application/candle.service.ts index f6830e6..aacc939 100644 --- a/src/modules/investments/instruments/application/candle.service.ts +++ b/src/modules/investments/instruments/application/candle.service.ts @@ -13,8 +13,8 @@ import { TbankAccessService, TbankClient, type TbankShare, + type TbankSyncAttempt, } from '../../../../integrations/tbank/tbank.api'; -import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service'; import { investmentResourceMeta } from '../../investment-resource.mapper'; import { CatalogEnrichmentService } from './catalog-enrichment.service'; import { CandleRepository } from '../infrastructure/candle.repository'; @@ -39,8 +39,12 @@ type Producer = { force: boolean; priority: number; observers: TbankSyncAttempt[]; - ranges?: CandleRange[]; - nextWindow?: number; + pass?: { + now: Date; + force: boolean; + ranges: CandleRange[]; + nextWindow: number; + }; }; const months = { month: 1, halfYear: 6, year: 12 }; const tailFreshForMs = 60_000; @@ -323,9 +327,14 @@ export class CandleService { id: reserved.work.id, attemptId: reserved.work.attemptId, }; + const producer = this.producers.get(scope(input)); + if (producer) { + producer.force ||= force; + producer.priority = Math.min(producer.priority, priority); + if (force && now > producer.now) producer.now = now; + } if (reserved.launch) { this.active.add(attempt.attemptId); - const producer = this.producers.get(scope(input)); if (producer) producer.observers.push(attempt); else { const next = { input, attempt, now, force, priority, observers: [] }; @@ -346,7 +355,7 @@ export class CandleService { } private async load(producer: Producer) { - const { input, attempt, now } = producer; + const { input, attempt } = producer; const instrument = await this.sources.ensureDetail( attempt.userId, attempt.revision, @@ -354,20 +363,24 @@ export class CandleService { input.uid, ); if (!instrument) throw new NotFoundException(); - const range = rangeFor(input, instrument, now); - if (!producer.ranges) { + let pass = producer.pass; + if (!pass || (producer.force && (!pass.force || producer.now > pass.now))) { + const { now, force } = producer; + const range = rangeFor(input, instrument, now); const saved = await this.repository.read(input.uid, range); - producer.ranges = ( - producer.force ? [range] : candleGaps(range, saved.coverage, now) - ) - .flatMap(candleWindows) - .reverse(); - producer.nextWindow = 0; + pass = producer.pass = { + now, + force, + ranges: (force ? [range] : candleGaps(range, saved.coverage, now)) + .flatMap(candleWindows) + .reverse(), + nextWindow: 0, + }; } - const ranges = producer.ranges; - const index = producer.nextWindow ?? 0; + const { ranges, now, force } = pass; + const index = pass.nextWindow; let window = ranges[index]; - if (window && !producer.force) { + if (window && !force) { const latest = await this.repository.read(input.uid, window); const gaps = candleGaps(window, latest.coverage, now) .flatMap(candleWindows) @@ -398,7 +411,7 @@ export class CandleService { if (!(await this.repository.stage(attempt, window, rows, now))) return false; if ( - !producer.force && + !force && !(await this.repository.publish( attempt, { uid: input.uid, ranges: [window] }, @@ -406,13 +419,10 @@ export class CandleService { )) ) return false; - producer.nextWindow = index + 1; - if (producer.nextWindow < ranges.length) return null; + pass.nextWindow = index + 1; + if (pass.nextWindow < ranges.length) return null; } - return this.repository.publish(attempt, { - uid: input.uid, - ranges: producer.force ? ranges : [], - }); + return true; } private async drain() { @@ -443,20 +453,43 @@ export class CandleService { code = response.code; } } - if (!success) await this.repository.fail(producer.attempt, code); - this.active.delete(producer.attempt.attemptId); - for (const observer of producer.observers) { - if ( - !success || - !(await this.repository.completeObserver( - observer, - producer.attempt, - )) - ) - await this.repository.fail(observer, code); - this.active.delete(observer.attemptId); + // Seal publication with reservations so a late forced join cannot be + // acknowledged after the final demand check but before work completion. + const finished = this.reservations.then(async () => { + const pass = producer.pass; + if (success && pass) { + if (producer.force && (!pass.force || producer.now > pass.now)) + return false; + try { + success = await this.repository.publish(producer.attempt, { + uid: producer.input.uid, + ranges: pass.force ? pass.ranges : [], + }); + } catch { + success = false; + } + } + if (!success) await this.repository.fail(producer.attempt, code); + this.active.delete(producer.attempt.attemptId); + for (const observer of producer.observers) { + if ( + !success || + !(await this.repository.completeObserver( + observer, + producer.attempt, + )) + ) + await this.repository.fail(observer, code); + this.active.delete(observer.attemptId); + } + this.producers.delete(scope(producer.input)); + return true; + }); + this.reservations = finished.catch(() => undefined); + if (!(await finished)) { + this.queue.push(producer); + continue; } - this.producers.delete(scope(producer.input)); if (success && !producer.force && producer.input.period !== 'all') await this.request( producer.attempt.userId, diff --git a/src/modules/investments/instruments/application/catalog-discovery.service.ts b/src/modules/investments/instruments/application/catalog-discovery.service.ts index 1905550..ae06df5 100644 --- a/src/modules/investments/instruments/application/catalog-discovery.service.ts +++ b/src/modules/investments/instruments/application/catalog-discovery.service.ts @@ -11,8 +11,8 @@ import { TbankClient, tbankSourceNeedsRefresh, tbankSourceTtlMs, + type TbankSyncAttempt, } from '../../../../integrations/tbank/tbank.api'; -import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service'; import { investmentResourceMeta } from '../../investment-resource.mapper'; import { CatalogDiscoveryRepository, diff --git a/src/modules/investments/instruments/application/catalog-enrichment.service.ts b/src/modules/investments/instruments/application/catalog-enrichment.service.ts index 785347c..2d29778 100644 --- a/src/modules/investments/instruments/application/catalog-enrichment.service.ts +++ b/src/modules/investments/instruments/application/catalog-enrichment.service.ts @@ -1,4 +1,3 @@ -import type { TbankDividend } from '../../../../integrations/tbank/tbank.api'; import type { MoexBondEvent } from '../../../../integrations/moex/moex.api'; import { CandleRepository } from '../infrastructure/candle.repository'; import { candleGaps, utcDay } from '../domain/candle-coverage'; @@ -14,11 +13,6 @@ import { } from '../../../../common/time'; import { MoexClient } from '../../../../integrations/moex/moex.api'; import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api'; -import type { - TbankCandle, - TbankFundamentals, -} from '../../../../integrations/tbank/tbank.api'; -import type { TbankCoupon } from '../../../../integrations/tbank/tbank-market.types'; import { previousClose, timestamp } from './instrument-market'; import { Injectable, NotFoundException } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; @@ -29,10 +23,14 @@ import { tbankSourceNeedsRefresh, tbankSourceTtlMs, type TbankBond, + type TbankCandle, + type TbankCoupon, + type TbankDividend, + type TbankFundamentals, + type TbankLastPrice, type TbankShare, + type TbankSyncAttempt, } from '../../../../integrations/tbank/tbank.api'; -import type { TbankLastPrice } from '../../../../integrations/tbank/tbank-market.types'; -import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service'; import { investmentResourceMeta } from '../../investment-resource.mapper'; import { CatalogDiscoveryRepository, diff --git a/src/modules/investments/instruments/application/instrument-market.ts b/src/modules/investments/instruments/application/instrument-market.ts index a74e55e..b09f461 100644 --- a/src/modules/investments/instruments/application/instrument-market.ts +++ b/src/modules/investments/instruments/application/instrument-market.ts @@ -1,5 +1,4 @@ import { MILLISECONDS_PER_SECOND } from '../../../../common/time'; -import { ServiceUnavailableException } from '@nestjs/common'; import type { TbankCandle, TbankTimestamp, @@ -36,16 +35,3 @@ export function previousClose( null ); } -export async function optional( - operation: () => Promise, - name: string, - unavailable: string[], -): Promise { - try { - return await operation(); - } catch (error) { - if (!(error instanceof ServiceUnavailableException)) throw error; - unavailable.push(name); - return null; - } -} diff --git a/src/modules/investments/instruments/stocks/application/stock-position.spec.ts b/src/modules/investments/instruments/application/instrument-position.spec.ts similarity index 72% rename from src/modules/investments/instruments/stocks/application/stock-position.spec.ts rename to src/modules/investments/instruments/application/instrument-position.spec.ts index 4ecb8a5..fdf43ce 100644 --- a/src/modules/investments/instruments/stocks/application/stock-position.spec.ts +++ b/src/modules/investments/instruments/application/instrument-position.spec.ts @@ -1,8 +1,8 @@ import { PortfolioDto, PortfolioPositionDto, -} from '../../../portfolio/presentation/portfolio.dto'; -import { stockPosition } from './stock-position'; +} from '../../portfolio/presentation/portfolio.dto'; +import { instrumentPosition } from './instrument-position'; const money = (value: string, currency = 'RUB') => ({ value, currency }); const row = ( @@ -34,13 +34,14 @@ const portfolio = (positions: PortfolioPositionDto[]): PortfolioDto => ({ describe('stock position', () => { it('weights costs exactly and keeps preferred shares separate', () => { - const result = stockPosition( + const result = instrumentPosition( portfolio([ row(), row('3', '20', '70'), row('100', '1', '100', 'preferred'), ]), 'common', + 'STOCK', ); expect(result.quantity).toBe('5'); expect(result.averagePurchasePrice).toEqual(money('16')); @@ -49,14 +50,18 @@ describe('stock position', () => { expect(result.returnPercent).toBe('25'); expect(result.accounts[0].returnPercent).toBe('50'); }); - it('does not round large amounts through floating point', () => { - const result = stockPosition( - portfolio([row('9007199254740993', '0.1', '900719925474099.3')]), - 'common', - ); - expect(result.quantity).toBe('9007199254740993'); - expect(result.averagePurchasePrice?.value).toBe('0.1'); - }); + it.each(['9007199254740993', '0.25'])( + 'preserves exact quantity %s', + (quantity) => { + const result = instrumentPosition( + portfolio([row(quantity, '0.1', '900719925474099.3')]), + 'common', + 'STOCK', + ); + expect(result.quantity).toBe(quantity); + expect(result.averagePurchasePrice?.value).toBe('0.1'); + }, + ); it('does not sum currencies or treat unknown values as zero', () => { const second = { ...row(), @@ -64,7 +69,11 @@ describe('stock position', () => { averagePurchasePrice: null, absoluteReturn: null, }; - const result = stockPosition(portfolio([row(), second]), 'common'); + const result = instrumentPosition( + portfolio([row(), second]), + 'common', + 'STOCK', + ); expect(result.quantity).toBe('4'); expect(result.currentValue).toBeNull(); expect(result.averagePurchasePrice).toBeNull(); @@ -72,14 +81,14 @@ describe('stock position', () => { expect(result.returnPercent).toBeNull(); }); it('keeps no-position and offsetting long/short holdings honest', () => { - expect(stockPosition(portfolio([]), 'common')).toMatchObject({ + expect(instrumentPosition(portfolio([]), 'common', 'STOCK')).toMatchObject({ quantity: '0', accounts: [], currentValue: null, returnPercent: null, }); expect( - stockPosition(portfolio([row(), row('-2')]), 'common') + instrumentPosition(portfolio([row(), row('-2')]), 'common', 'STOCK') .averagePurchasePrice, ).toBeNull(); }); diff --git a/src/modules/investments/instruments/infrastructure/candle.repository.ts b/src/modules/investments/instruments/infrastructure/candle.repository.ts index 5b205bb..d3c4273 100644 --- a/src/modules/investments/instruments/infrastructure/candle.repository.ts +++ b/src/modules/investments/instruments/infrastructure/candle.repository.ts @@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto'; import { createHash } from 'node:crypto'; import type { Prisma } from '../../../../../generated/prisma/client'; import { PrismaService } from '../../../../prisma/prisma.service'; -import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service'; +import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank.api'; import { confirmedCandleCoverage, type CandleRange, @@ -78,8 +78,37 @@ export class CandleRepository { previous?.state === 'running' && previous.processRunId === runId && active.has(previous.attemptId) - ) - return { work: previous, launch: false }; + ) { + const previousIntent = previous.intent as Prisma.JsonObject; + const previousNow = + typeof previousIntent.now === 'string' ? previousIntent.now : ''; + const nextPriority = Math.min(previous.priority, priority); + if ( + nextPriority === previous.priority && + (!intent.force || + (previousIntent.force === true && + (!intent.now || intent.now <= previousNow))) + ) + return { work: previous, launch: false }; + const work = await tx.tbankSyncWork.update({ + where: { id: previous.id }, + data: { + priority: nextPriority, + intent: { + ...previousIntent, + ...(intent.force + ? { + force: true, + ...(intent.now && intent.now > previousNow + ? { now: intent.now } + : {}), + } + : {}), + }, + }, + }); + return { work, launch: false }; + } if (previous) await tx.tbankSyncStage.deleteMany({ where: { workId: previous.id } }); const data = { diff --git a/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts b/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts index b7bc444..2c2d183 100644 --- a/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts +++ b/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts @@ -3,10 +3,10 @@ import { randomUUID } from 'node:crypto'; import { isDeepStrictEqual } from 'node:util'; import type { Prisma } from '../../../../../generated/prisma/client'; import { PrismaService } from '../../../../prisma/prisma.service'; -import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service'; import type { TbankBond, TbankShare, + TbankSyncAttempt, } from '../../../../integrations/tbank/tbank.api'; export type CatalogQuery = { diff --git a/src/modules/investments/instruments/stocks/application/stock-position.ts b/src/modules/investments/instruments/stocks/application/stock-position.ts deleted file mode 100644 index 37402d4..0000000 --- a/src/modules/investments/instruments/stocks/application/stock-position.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { PortfolioDto } from '../../../portfolio/portfolio.api'; -import { instrumentPosition } from '../../application/instrument-position'; -export function stockPosition(portfolio: PortfolioDto, uid: string) { - return instrumentPosition(portfolio, uid, 'STOCK'); -} diff --git a/src/modules/investments/investment-sync-plan.spec.ts b/src/modules/investments/investment-sync-plan.spec.ts index 82b2b00..7dd5b03 100644 --- a/src/modules/investments/investment-sync-plan.spec.ts +++ b/src/modules/investments/investment-sync-plan.spec.ts @@ -1,5 +1,3 @@ -jest.mock('../../prisma/prisma.service', () => ({ PrismaService: class {} })); - import { planInvestmentScreen, ledgerRange, diff --git a/src/modules/investments/investment-sync-plan.ts b/src/modules/investments/investment-sync-plan.ts index d54de6b..42a638b 100644 --- a/src/modules/investments/investment-sync-plan.ts +++ b/src/modules/investments/investment-sync-plan.ts @@ -3,7 +3,7 @@ import { dateOnlyBounds, moscowDate } from './investment-calendar'; import { operationIntervals, type OperationInterval, -} from './operations/operations.api'; +} from './operations/domain/operations-sync-plan'; export type InvestmentScreen = { screen: diff --git a/src/modules/investments/operations/application/brokerage-operations.service.ts b/src/modules/investments/operations/application/brokerage-operations.service.ts index 5db1783..b29231e 100644 --- a/src/modules/investments/operations/application/brokerage-operations.service.ts +++ b/src/modules/investments/operations/application/brokerage-operations.service.ts @@ -356,7 +356,10 @@ export class BrokerageOperationsService { : { accountId, items: rows.map((row) => ({ - ...mapBrokerageOperation(row.payload as TbankOperationItem), + ...mapBrokerageOperation( + row.payload as TbankOperationItem, + row.occurredAt, + ), ...(accountId === 'all' ? { account: { @@ -616,7 +619,10 @@ export class BrokerageOperationsService { ), ) .map((row) => ({ - ...mapBrokerageOperation(row.payload as TbankOperationItem), + ...mapBrokerageOperation( + row.payload as TbankOperationItem, + row.occurredAt, + ), ...(accountId === 'all' ? { account: { diff --git a/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.spec.ts b/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.spec.ts index cf569ca..0c20319 100644 --- a/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.spec.ts +++ b/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.spec.ts @@ -4,6 +4,17 @@ import { } from './brokerage-operation.mapper'; describe('mapBrokerageOperation', () => { + it('keeps subsecond timestamps at persisted millisecond precision and the standalone epoch default', () => { + expect( + mapBrokerageOperation({ + date: { seconds: '1768000000', nanos: 600999999 }, + }).occurredAt, + ).toBe('2026-01-09T23:06:40.600Z'); + expect(mapBrokerageOperation({}).occurredAt).toBe( + '1970-01-01T00:00:00.000Z', + ); + }); + it.each([ ['OPERATION_TYPE_COUPON', 'income'], ['OPERATION_TYPE_DIVIDEND', 'income'], diff --git a/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts b/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts index 35fc796..3fe3966 100644 --- a/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts +++ b/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts @@ -10,6 +10,8 @@ import { OperationCategory, } from '../presentation/brokerage-operation.dto'; +const NANOSECONDS_PER_MILLISECOND = 1_000_000; + const types: Record = { OPERATION_TYPE_BUY: 'trade', OPERATION_TYPE_BUY_CARD: 'trade', @@ -42,10 +44,11 @@ export function operationCategory(type: string | undefined): OperationCategory { export function mapBrokerageOperation( operation: TbankOperationItem, + occurredAt = operationTimestamp(operation.date), ): BrokerageOperationDto { return { id: operation.id ?? operation.cursor ?? '', - occurredAt: timestamp(operation.date), + occurredAt: occurredAt.toISOString(), type: operation.type ?? 'OPERATION_TYPE_UNSPECIFIED', category: operationCategory(operation.type), description: operation.description?.trim() || operation.name?.trim() || '', @@ -82,10 +85,11 @@ function money(value: TbankMoney | undefined) { : null; } -function timestamp( +export function operationTimestamp( value: { seconds?: string | number; nanos?: number } | undefined, ) { return new Date( - Number(value?.seconds ?? 0) * MILLISECONDS_PER_SECOND, - ).toISOString(); + Number(value?.seconds ?? 0) * MILLISECONDS_PER_SECOND + + Math.floor((value?.nanos ?? 0) / NANOSECONDS_PER_MILLISECOND), + ); } diff --git a/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts b/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts index d4b8abc..bd86956 100644 --- a/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts +++ b/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts @@ -5,7 +5,6 @@ import { } from '@nestjs/common'; import { createHash, randomUUID } from 'node:crypto'; import { isDeepStrictEqual } from 'node:util'; -import { MILLISECONDS_PER_SECOND } from '../../../../common/time'; import { PrismaService } from '../../../../prisma/prisma.service'; import type { Prisma } from '../../../../../generated/prisma/client'; import type { @@ -17,7 +16,10 @@ import { dateOnlyBounds, moscowDate, } from '../../investment-calendar'; -import { operationCategory } from './brokerage-operation.mapper'; +import { + operationCategory, + operationTimestamp, +} from './brokerage-operation.mapper'; import { type OperationInterval, operationIntervals, @@ -45,7 +47,6 @@ type PageProgress = { pages: number; }; const progressItem = ''; -const NANOSECONDS_PER_MILLISECOND = 1_000_000; @Injectable() export class BrokerageOperationsRepository { @@ -650,13 +651,11 @@ export class BrokerageOperationsRepository { for (const operation of response.items ?? []) { if (!operation.id?.trim()) throw new Error('Missing broker operation identity'); - const occurredAt = new Date( - Number(operation.date?.seconds) * MILLISECONDS_PER_SECOND + - Math.floor( - (operation.date?.nanos ?? 0) / NANOSECONDS_PER_MILLISECOND, - ), - ); - if (!Number.isFinite(occurredAt.valueOf())) + const occurredAt = operationTimestamp(operation.date); + if ( + operation.date?.seconds === undefined || + !Number.isFinite(occurredAt.valueOf()) + ) throw new Error('Invalid operation timestamp'); if ( occurredAt < interval.from || @@ -793,12 +792,7 @@ export class BrokerageOperationsRepository { if (previous && isDeepStrictEqual(previous.payload, operation)) continue; const data = { - occurredAt: new Date( - Number(operation.date?.seconds) * MILLISECONDS_PER_SECOND + - Math.floor( - (operation.date?.nanos ?? 0) / NANOSECONDS_PER_MILLISECOND, - ), - ), + occurredAt: operationTimestamp(operation.date), instrumentUid: operation.instrumentUid ?? null, category: operationCategory(operation.type), payload: row.payload as Prisma.InputJsonObject, diff --git a/src/modules/investments/operations/operations.api.ts b/src/modules/investments/operations/operations.api.ts index 6706b81..69bf304 100644 --- a/src/modules/investments/operations/operations.api.ts +++ b/src/modules/investments/operations/operations.api.ts @@ -2,10 +2,7 @@ export { BrokerageOperationDto } from './presentation/brokerage-operation.dto'; export { BrokerageOperationsService } from './application/brokerage-operations.service'; -export { - operationIntervals, - type OperationInterval, -} from './domain/operations-sync-plan'; +export type { OperationInterval } from './domain/operations-sync-plan'; export { operationCategories } from './presentation/brokerage-operation.dto'; export { BrokerageOperationsSyncService } from './application/brokerage-operations-sync.service'; diff --git a/src/modules/investments/portfolio/application/forecast.service.ts b/src/modules/investments/portfolio/application/forecast.service.ts index 07b1686..2f379d9 100644 --- a/src/modules/investments/portfolio/application/forecast.service.ts +++ b/src/modules/investments/portfolio/application/forecast.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@nestjs/common'; +import { tbankSourceNeedsRefresh } from '../../../../integrations/tbank/tbank.api'; import { investmentResourceMeta } from '../../investment-resource.mapper'; import type { InvestmentResourceMetaDto } from '../../investment-resource.dto'; import { @@ -67,8 +68,23 @@ export class ForecastService { dependencies .map((dependency) => dependency.sync) .find((sync) => sync.state === 'failed'); + const payload = stored ? (stored.payload as T) : null; + const data = + payload && + 'positionIsStale' in payload && + 'positionAt' in payload && + typeof payload.positionAt === 'string' + ? { + ...payload, + positionIsStale: tbankSourceNeedsRefresh( + 'portfolio', + new Date(payload.positionAt), + new Date(Date.now()), + ), + } + : payload; return { - data: stored ? (stored.payload as T) : null, + data, meta: investmentResourceMeta({ resourceKey: input.scopeKey, dataRevision: String(stored?.dataRevision ?? 0), @@ -80,7 +96,10 @@ export class ForecastService { ? 'partial' : 'missing', fetchedAt: first ? new Date(first) : null, - freshForMs: Number.POSITIVE_INFINITY, + freshForMs: + data && 'positionIsStale' in data && data.positionIsStale === true + ? 0 + : Number.POSITIVE_INFINITY, work: work?.id ? { ...work, id: work.id } : null, dependencies, }), diff --git a/src/modules/investments/portfolio/application/portfolio.service.ts b/src/modules/investments/portfolio/application/portfolio.service.ts index 42798ca..a0aeb79 100644 --- a/src/modules/investments/portfolio/application/portfolio.service.ts +++ b/src/modules/investments/portfolio/application/portfolio.service.ts @@ -14,6 +14,7 @@ import { } from '@nestjs/common'; import { add, + decimalFromString, formatDecimal, isZero, multiply, @@ -511,12 +512,3 @@ export class PortfolioService { : null; } } - -function decimalFromString(value: string) { - const negative = value.startsWith('-'); - const [whole, fraction = ''] = (negative ? value.slice(1) : value).split('.'); - return { - value: (negative ? -1n : 1n) * BigInt(`${whole}${fraction}`), - scale: fraction.length, - }; -} diff --git a/src/modules/investments/portfolio/portfolio.api.ts b/src/modules/investments/portfolio/portfolio.api.ts index 15fb504..eb62409 100644 --- a/src/modules/investments/portfolio/portfolio.api.ts +++ b/src/modules/investments/portfolio/portfolio.api.ts @@ -1,5 +1,4 @@ // Public cross-module surface. Nest providers are exported by the matching module. -export { totalsByCurrency } from './infrastructure/tbank-portfolio.mapper'; export { PortfolioDto, PortfolioPositionDto, diff --git a/src/modules/investments/tbank-sync/tbank-sync.controller.ts b/src/modules/investments/tbank-sync/tbank-sync.controller.ts index ee8bf85..c4985c0 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.controller.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.controller.ts @@ -10,7 +10,6 @@ import { Req, UseGuards, ValidationPipe, - type ValidationError, } from '@nestjs/common'; import { ApiAcceptedResponse, @@ -58,8 +57,8 @@ export class TbankSyncController { transform: true, whitelist: true, forbidNonWhitelisted: true, - exceptionFactory: (message) => - new BadRequestException({ message: descriptorErrors(message) }), + validationError: { target: false, value: false }, + exceptionFactory: (message) => new BadRequestException({ message }), }), ) input: unknown, @@ -75,13 +74,3 @@ export class TbankSyncController { return this.sync.status(request.user.sub, id); } } - -function descriptorErrors( - errors: ValidationError[], - prefix = '', -): ValidationError[] { - return errors.flatMap((error) => [ - { ...error, property: `${prefix}${error.property}` }, - ...descriptorErrors(error.children ?? [], `${prefix}${error.property}/`), - ]); -} diff --git a/src/modules/investments/tbank-sync/tbank-sync.repository.ts b/src/modules/investments/tbank-sync/tbank-sync.repository.ts index d5b53bc..5add3a8 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.repository.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.repository.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { createHash, randomUUID } from 'node:crypto'; import type { Prisma } from '../../../../generated/prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; -import type { TbankSyncAttempt } from '../../../integrations/tbank/tbank-access.service'; +import type { TbankSyncAttempt } from '../../../integrations/tbank/tbank.api'; import type { InvestmentScreenDto } from './tbank-sync.dto'; export type ScreenIntent = { scope: InvestmentScreenDto; now: string }; export type ScreenProgress = { diff --git a/src/modules/investments/tbank-sync/tbank-sync.service.ts b/src/modules/investments/tbank-sync/tbank-sync.service.ts index 99788d1..826830c 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.service.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.service.ts @@ -9,6 +9,7 @@ import { TbankAccessService, TbankMembershipService, InstrumentMetadataService, + type TbankSyncAttempt, } from '../../../integrations/tbank/tbank.api'; import { PortfolioService } from '../portfolio/portfolio.api'; import { @@ -30,7 +31,6 @@ import { IncomeAnalyticsService } from '../income/income-read.api'; import { UpcomingEventsService } from '../upcoming-events/upcoming-events-read.api'; import { planInvestmentScreen } from '../investment-sync-plan'; import type { InvestmentResourceMetaDto } from '../investment-resource.dto'; -import type { TbankSyncAttempt } from '../../../integrations/tbank/tbank-access.service'; import { TbankSyncRepository, type ScreenIntent, @@ -303,6 +303,7 @@ export class TbankSyncService { }, now, true, + 0, ), ]); } @@ -327,10 +328,17 @@ export class TbankSyncService { return this.sourceStatus(userId, saved.work); const intent = saved.work.intent as unknown as ScreenIntent; await this.access.local(userId, intent.scope.accountId); - const running = saved.children.some((child) => child.state === 'running'); + const children = await Promise.all( + saved.children.map((child) => + child.state === 'superseded' + ? this.operations.observe(userId, child.id) + : Promise.resolve(child), + ), + ); + const running = children.some((child) => child.state === 'running'); const failed = saved.work.errorCode ?? - saved.children.find((child) => child.errorCode)?.errorCode ?? + children.find((child) => child.errorCode)?.errorCode ?? null; const state = saved.work.processRunId !== this.runId && @@ -340,7 +348,7 @@ export class TbankSyncService { ? 'running' : failed ? 'failed' - : saved.children.some((child) => child.state === 'cancelled') + : children.some((child) => child.state === 'cancelled') ? 'cancelled' : 'succeeded'; const resources = await this.observe(userId, intent.scope); @@ -355,9 +363,8 @@ export class TbankSyncService { state, errorCode: failed, progress: { - completed: saved.children.filter((child) => child.state !== 'running') - .length, - total: saved.children.length, + completed: children.filter((child) => child.state !== 'running').length, + total: children.length, discovering: !saved.progress.dispatched, }, resources, diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index cb756c2..d1ed34f 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -1,6 +1,7 @@ import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test, TestingModule } from '@nestjs/testing'; +import { PinoLogger } from 'nestjs-pino'; import request from 'supertest'; import { App } from 'supertest/types'; import { AppModule } from '../src/app.module'; @@ -28,7 +29,10 @@ describe('Authentication (e2e)', () => { await app.get(PrismaService).user.deleteMany(); }); - afterEach(() => app.close()); + afterEach(async () => { + await app.close(); + jest.restoreAllMocks(); + }); it('registers, guards profile, rotates refresh and logs out', async () => { const agent = request.agent(app.getHttpServer()); @@ -50,14 +54,94 @@ describe('Authentication (e2e)', () => { ); const firstCookie = registration.headers['set-cookie'][0]; - await agent.post('/api/v1/auth/refresh').expect(200); + const refreshed = await agent.post('/api/v1/auth/refresh').expect(200); await request(app.getHttpServer()) .post('/api/v1/auth/refresh') .set('Cookie', firstCookie) .expect(401) .expect('Content-Type', /application\/problem\+json/); + const logout = await agent.post('/api/v1/auth/logout').expect(204); + expect(logout.headers['set-cookie'][0]).toContain('refresh_token=;'); + await request(app.getHttpServer()) + .post('/api/v1/auth/refresh') + .set('Cookie', refreshed.headers['set-cookie'][0]) + .expect(401) + .expect(({ body }: { body: unknown }) => + expect(body).toEqual({ status: 401, code: 'UNAUTHORIZED' }), + ); + await request(app.getHttpServer()) + .post('/api/v1/auth/logout') + .set('Cookie', refreshed.headers['set-cookie'][0]) + .expect(204); await agent.post('/api/v1/auth/logout').expect(204); - await agent.post('/api/v1/auth/logout').expect(204); + }); + + it('keeps logout idempotent for an invalid refresh token', async () => { + const logout = await request(app.getHttpServer()) + .post('/api/v1/auth/logout') + .set('Cookie', 'refresh_token=invalid') + .expect(204); + + expect(logout.headers['set-cookie'][0]).toContain('refresh_token=;'); + }); + + it('reports a failed revocation and preserves the refresh cookie for retry', async () => { + const registration = await request(app.getHttpServer()) + .post('/api/v1/auth/register') + .send({ email: 'logout@example.test', password: 'a secure password' }) + .expect(200); + const cookie = registration.headers['set-cookie'][0]; + const revoke = jest + .spyOn(app.get(PrismaService).session, 'update') + .mockRejectedValueOnce(new Error('Storage unavailable')); + + const failed = await request(app.getHttpServer()) + .post('/api/v1/auth/logout') + .set('Cookie', cookie) + .expect(500) + .expect('Content-Type', /application\/problem\+json/); + revoke.mockRestore(); + + expect(failed.body).toEqual({ + status: 500, + code: 'INTERNAL_SERVER_ERROR', + }); + expect(failed.headers['set-cookie']).toBeUndefined(); + expect( + await app.get(PrismaService).session.findFirstOrThrow(), + ).toMatchObject({ revokedAt: null }); + await request(app.getHttpServer()) + .post('/api/v1/auth/refresh') + .set('Cookie', cookie) + .expect(200); + }); + + it('preserves a valid refresh cookie during a storage failure', async () => { + const registration = await request(app.getHttpServer()) + .post('/api/v1/auth/register') + .send({ email: 'refresh@example.test', password: 'a secure password' }) + .expect(200); + const cookie = registration.headers['set-cookie'][0]; + const read = jest + .spyOn(app.get(PrismaService).session, 'findUnique') + .mockRejectedValueOnce(new Error('Storage unavailable')); + + const failed = await request(app.getHttpServer()) + .post('/api/v1/auth/refresh') + .set('Cookie', cookie) + .expect(500) + .expect('Content-Type', /application\/problem\+json/); + read.mockRestore(); + + expect(failed.body).toEqual({ + status: 500, + code: 'INTERNAL_SERVER_ERROR', + }); + expect(failed.headers['set-cookie']).toBeUndefined(); + await request(app.getHttpServer()) + .post('/api/v1/auth/refresh') + .set('Cookie', cookie) + .expect(200); }); it('rotates one concurrent refresh on SQLite and keeps the winner usable', async () => { @@ -141,6 +225,26 @@ describe('Authentication (e2e)', () => { ); }); + it('returns field errors for invalid credentials without exposing input', async () => { + const response = await request(app.getHttpServer()) + .post('/api/v1/auth/register') + .send({ email: 'SYNTHETIC_VALIDATION_SECRET' }) + .expect(400) + .expect('Content-Type', /application\/problem\+json/); + expect(response.body).toEqual({ + status: 400, + code: 'VALIDATION_ERROR', + errors: expect.arrayContaining([ + { pointer: '#/email', detail: 'must be a valid email address' }, + { pointer: '#/password', detail: 'password must be a string' }, + ]) as unknown, + }); + expect(JSON.stringify(response.body).toLowerCase()).not.toContain( + 'synthetic_validation_secret', + ); + expect(await app.get(PrismaService).user.count()).toBe(0); + }); + it('guards T-Bank connection and never returns a token', async () => { const registration = await request(app.getHttpServer()) .post('/api/v1/auth/register') @@ -218,27 +322,37 @@ describe('Authentication (e2e)', () => { ); }); - it('records the public code and stack for an unhandled error', async () => { - const logger = app.get(StructuredLogger); - const event = jest.spyOn(logger, 'event'); + it('logs an unhandled error with safe public context and correlation', async () => { + const error = jest.spyOn(PinoLogger.prototype, 'error'); + const clientRequestId = 'a2dc6fbb-5a48-4c3c-9e3e-4fbf4d638a43'; jest .spyOn(app.get(AuthService), 'login') - .mockRejectedValue(new Error('password: secret')); + .mockRejectedValue( + new Error('operation failed\npassword: SYNTHETIC_AUDIT_SECRET'), + ); - await request(app.getHttpServer()) + const response = await request(app.getHttpServer()) .post('/api/v1/auth/login') + .set('X-Request-ID', clientRequestId) .send({ email: 'user@example.com', password: 'a secure password' }) .expect(500) + .expect('Content-Type', /application\/problem\+json/) .expect(({ body }: { body: unknown }) => expect(body).toEqual({ status: 500, code: 'INTERNAL_SERVER_ERROR' }), ); - expect(event).toHaveBeenCalledWith( - 'error', - 'http.request.completed', + expect(error).toHaveBeenCalledTimes(1); + expect(error).toHaveBeenCalledWith( expect.objectContaining({ + requestId: response.headers['x-request-id'], + clientRequestId, + status: 500, code: 'INTERNAL_SERVER_ERROR', }), + 'http.request.completed', + ); + expect(JSON.stringify(error.mock.calls)).not.toContain( + 'SYNTHETIC_AUDIT_SECRET', ); }); }); diff --git a/test/bank-cards.e2e-spec.ts b/test/bank-cards.e2e-spec.ts index 57cae6c..1585a47 100644 --- a/test/bank-cards.e2e-spec.ts +++ b/test/bank-cards.e2e-spec.ts @@ -275,6 +275,40 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { }) .expect(400); }); + it('returns nested money field errors without exposing input or writing a card', async () => { + const before = await db.bankCard.count(); + const response = await auth('post', '') + .send({ + id: randomUUID(), + name: 'Card', + openingDate: '2026-01-01', + openingBalance: { + currency: 'USD', + value: 'SYNTHETIC_VALIDATION_SECRET', + }, + }) + .expect(400) + .expect('Content-Type', /application\/problem\+json/); + expect(response.body).toEqual({ + status: 400, + code: 'VALIDATION_ERROR', + errors: [ + { + pointer: '#/openingBalance/currency', + detail: expect.stringContaining('RUB') as unknown, + }, + { + pointer: '#/openingBalance/value', + detail: expect.any(String) as unknown, + }, + ], + }); + expect(JSON.stringify(response.body)).not.toContain( + 'SYNTHETIC_VALIDATION_SECRET', + ); + expect(await db.bankCard.count()).toBe(before); + }); + it('scopes every card/category/expense link and edit to owner', async () => { const own = await createCard(), foreign = await createCard('0', stranger); diff --git a/test/fixtures/tbank-sync-crash.cjs b/test/fixtures/tbank-sync-crash.cjs index 3f1d6a4..8e13110 100644 --- a/test/fixtures/tbank-sync-crash.cjs +++ b/test/fixtures/tbank-sync-crash.cjs @@ -57,7 +57,7 @@ repository.publish = async (...args) => { }; const service = new BrokerageOperationsSyncService( repository, - new TbankAccessService(prisma, tokens, upstream), + new TbankAccessService(prisma, tokens), upstream, { event() { diff --git a/test/openapi-contract.e2e-spec.ts b/test/openapi-contract.e2e-spec.ts index e8dbac7..2df4936 100644 --- a/test/openapi-contract.e2e-spec.ts +++ b/test/openapi-contract.e2e-spec.ts @@ -127,6 +127,23 @@ describe('OpenAPI contract (integration)', () => { }); it('generates the runtime contract without server startup or deployment configuration', async () => { + expect( + createOpenApiDocument(app).components?.schemas?.ProblemDetailsDto, + ).toMatchObject({ + properties: { + errors: { + type: 'array', + items: { + type: 'object', + required: ['pointer', 'detail'], + properties: { + pointer: { type: 'string' }, + detail: { type: 'string' }, + }, + }, + }, + }, + }); const directory = await mkdtemp(resolve(tmpdir(), 'wealthledger-openapi-')); try { const networkGuard = resolve(directory, 'no-network.cjs'); diff --git a/test/tbank-candles-persistence.e2e-spec.ts b/test/tbank-candles-persistence.e2e-spec.ts index 73e4b0d..6b5740a 100644 --- a/test/tbank-candles-persistence.e2e-spec.ts +++ b/test/tbank-candles-persistence.e2e-spec.ts @@ -22,7 +22,7 @@ describe('atomic shared candle reconciliation (SQLite)', () => { }) as never, ); const upstream = { getCandles: jest.fn() }; - const access = new TbankAccessService(prisma, tokens, upstream as never); + const access = new TbankAccessService(prisma, tokens); const instrument = { uid: 'uid', currency: 'rub', @@ -46,10 +46,10 @@ describe('atomic shared candle reconciliation (SQLite)', () => { }, ]), }; - const createService = () => + const createService = (selectedSources = sources) => new CandleService( repository, - sources as never, + selectedSources as never, access, upstream as never, { event: jest.fn() } as never, @@ -112,6 +112,7 @@ describe('atomic shared candle reconciliation (SQLite)', () => { }); }); afterAll(async () => prisma.$disconnect()); + afterEach(() => jest.restoreAllMocks()); it('deduplicates seams, excludes outside rows, persists exact decimals and closed empty coverage after reopen', async () => { const attempt = await reserve(); await repository.stage( @@ -302,6 +303,149 @@ describe('atomic shared candle reconciliation (SQLite)', () => { expect(read.meta.sync.state).toBe('failed'); expect(read.data?.candles).toEqual([]); }); + it.each([ + ['owner', 'before loading', false], + ['observer', 'before loading', false], + ['owner', 'after ordinary publication', false], + ['observer', 'after ordinary publication', false], + ['observer', 'after ordinary publication', true], + ] as const)( + 'honors force from %s joining %s (later failure=%s)', + async (userId, phase, laterFailure) => { + if (userId !== owner.userId) { + await prisma.user.upsert({ + where: { id: userId }, + create: { + id: userId, + email: `${userId}@example.invalid`, + passwordHash: 'synthetic', + }, + update: {}, + }); + await prisma.tbankConnection.create({ + data: { + userId, + revision: 'revision', + encryptedToken: tokens.encrypt('synthetic'), + connectedAt: new Date(), + }, + }); + } + const history = { + ...instrument, + first_1dayCandleDate: { + seconds: String(Date.parse('2024-01-01Z') / 1000), + }, + }; + const covered = { + from: new Date('2024-01-01Z'), + to: new Date('2025-01-01Z'), + }; + const old = { ...candle, time: '2024-01-02T00:00:00.000Z' }; + const initial = await reserve([covered]); + await repository.stage(initial, covered, [old], new Date('2025-01-02Z')); + await repository.publish(initial, { uid: 'uid', ranges: [covered] }); + let entered = () => {}; + let release = () => {}; + const started = new Promise((resolve) => (entered = resolve)); + const blocked = new Promise((resolve) => (release = resolve)); + const selectedSources = { + ...sources, + ensureDetail: jest.fn().mockResolvedValue(history), + }; + if (phase === 'before loading') + selectedSources.ensureDetail.mockImplementationOnce(async () => { + entered(); + await blocked; + return history; + }); + else { + const publish = repository.publish.bind(repository); + jest + .spyOn(repository, 'publish') + .mockImplementationOnce(async (...args) => { + const result = await publish(...args); + entered(); + await blocked; + return result; + }); + } + const forcedAt = new Date('2026-02-03T13:00:00Z'); + const forcedStates: string[] = []; + const fullRange = { from: covered.from, to: new Date('2026-02-04Z') }; + let beforeForced: + Awaited> | undefined; + upstream.getCandles.mockImplementation( + async (_token, _uid, _from, to: Date) => { + if (laterFailure && forcedStates.length) + throw new Error('Forced reconciliation second window unavailable'); + if (to.valueOf() === forcedAt.valueOf()) { + beforeForced = await repository.read('uid', fullRange); + const work = await repository.work( + userId, + 'revision', + 'candles:uid:all', + ); + forcedStates.push(work?.state ?? 'missing'); + } + return [ + { + time: { seconds: String(Date.parse(old.time) / 1000) }, + close: { units: '200', nano: 1 }, + isComplete: true, + }, + ]; + }, + ); + const service = createService(selectedSources); + const input = { + uid: 'uid', + instrumentType: 'share' as const, + period: 'all' as const, + }; + const ordinaryId = await service.request( + 'owner', + 'revision', + input, + new Date('2026-02-02T12:00:00Z'), + false, + 2, + ); + await started; + const forcedId = await service.request( + userId, + 'revision', + input, + forcedAt, + true, + 0, + ); + release(); + await settled(); + + if (laterFailure) { + expect(beforeForced).toBeDefined(); + expect(await repository.read('uid', fullRange)).toEqual(beforeForced); + } else + expect((await repository.read('uid', covered)).data).toEqual([ + { ...old, close: '200.000000001' }, + ]); + expect(forcedStates).toEqual(['running']); + expect( + await repository.work(userId, 'revision', 'candles:uid:all'), + ).toMatchObject({ + id: forcedId, + state: laterFailure ? 'failed' : 'complete', + priority: 0, + intent: { + force: true, + now: forcedAt.toISOString(), + }, + }); + if (userId === owner.userId) expect(forcedId).toBe(ordinaryId); + else expect(forcedId).not.toBe(ordinaryId); + }, + ); it('preserves source incompleteness and bond percentage precision without nominal conversion', async () => { const now = new Date(); now.setUTCHours(0, 0, 0, 0); diff --git a/test/tbank-catalog-discovery.e2e-spec.ts b/test/tbank-catalog-discovery.e2e-spec.ts index 23f66ff..0868b8e 100644 --- a/test/tbank-catalog-discovery.e2e-spec.ts +++ b/test/tbank-catalog-discovery.e2e-spec.ts @@ -22,7 +22,7 @@ describe('shared catalog discovery (SQLite)', () => { }) as never, ); const upstream = { findShares: jest.fn(), findBonds: jest.fn() }; - const access = new TbankAccessService(prisma, tokens, upstream as never); + const access = new TbankAccessService(prisma, tokens); const repository = new CatalogDiscoveryRepository(prisma); const createService = () => new CatalogDiscoveryService( diff --git a/test/tbank-ledger-local.e2e-spec.ts b/test/tbank-ledger-local.e2e-spec.ts index 5f46ea6..e2fb2dd 100644 --- a/test/tbank-ledger-local.e2e-spec.ts +++ b/test/tbank-ledger-local.e2e-spec.ts @@ -11,6 +11,8 @@ import { PrismaService } from '../src/prisma/prisma.service'; import { InstrumentMetadataRepository } from '../src/integrations/tbank/instrument-metadata.repository'; import { TbankClient } from '../src/integrations/tbank/tbank.client'; import { BrokerageOperationsResourceDto } from '../src/modules/investments/operations/presentation/brokerage-operation.dto'; +import { BrokerageOperationsRepository } from '../src/modules/investments/operations/infrastructure/brokerage-operations.repository'; +import type { IncomeAnalyticsResourceDto } from '../src/modules/investments/income/income-analytics.dto'; const uid = '11111111-1111-4111-8111-111111111111'; const period = 'from=2025-01-01&to=2025-01-31'; @@ -127,6 +129,97 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); }); + it('uses the persisted subsecond timestamp for filtering, cursor order and income entries', async () => { + await prisma.tbankOperation.deleteMany(); + const { revision } = await prisma.tbankConnection.findUniqueOrThrow({ + where: { userId: 'owner' }, + }); + const repository = app.get(BrokerageOperationsRepository); + const interval = { + accountId: 'a', + from: new Date('2025-01-02T00:00:00.500Z'), + to: new Date('2025-01-02T00:00:01.000Z'), + }; + const reserved = await repository.request( + 'owner', + revision, + [interval], + 'subsecond-test', + true, + new Set(), + 1, + ); + assert(reserved); + const attempt = { + userId: 'owner', + revision, + id: reserved.work.id, + attemptId: reserved.work.attemptId, + }; + await expect( + repository.stagePage(attempt, interval, undefined, { + hasNext: false, + items: [{ id: 'invalid', date: {} }], + }), + ).rejects.toThrow('Invalid operation timestamp'); + await repository.stagePage(attempt, interval, undefined, { + hasNext: false, + items: [400, 600, 800].map((milliseconds) => ({ + id: String(milliseconds), + date: { seconds: '1735776000', nanos: milliseconds * 1_000_000 }, + type: 'OPERATION_TYPE_COUPON', + payment: { currency: 'rub', units: '1', nano: 1 }, + })), + }); + await repository.publish(attempt, [interval], new Date(), true); + const saved = await prisma.tbankOperation.findMany({ + orderBy: { occurredAt: 'desc' }, + }); + const expected = [ + { id: '800', occurredAt: '2025-01-02T00:00:00.800Z' }, + { id: '600', occurredAt: '2025-01-02T00:00:00.600Z' }, + ]; + expect( + saved.map((row) => ({ + id: row.brokerOperationId, + occurredAt: row.occurredAt.toISOString(), + })), + ).toEqual(expected); + const items: { id: string; occurredAt: string }[] = []; + let cursor: string | null = null; + do { + const response = await request(app.getHttpServer()) + .get( + `/api/v1/brokerage-accounts/a/operations?from=${interval.from.toISOString()}&to=2025-01-02T00:00:00.900Z&limit=1&sync=observe${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`, + ) + .set('Authorization', auth()) + .expect(200); + const result = response.body as BrokerageOperationsResourceDto; + assert(result.data); + expect(result.data.items).toHaveLength(1); + items.push( + ...result.data.items.map(({ id, occurredAt }) => ({ id, occurredAt })), + ); + cursor = result.data.nextCursor; + } while (cursor); + const income = ( + await request(app.getHttpServer()) + .get( + `/api/v1/brokerage-accounts/a/income-analytics?${period}&sync=observe`, + ) + .set('Authorization', auth()) + .expect(200) + ).body as IncomeAnalyticsResourceDto; + expect(items).toEqual(expected); + expect( + income.data?.entries.map(({ id, occurredAt }) => ({ id, occurredAt })), + ).toEqual(expected); + expect(income.data?.totalsByCurrency[0].total).toEqual({ + currency: 'RUB', + value: '2.000000002', + }); + expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); + }); it.each(['default', 'today', 'today timestamp'])( 'keeps observed current coverage complete through %s period pagination without RPC', async (periodKind) => { diff --git a/test/tbank-metadata-persistence.e2e-spec.ts b/test/tbank-metadata-persistence.e2e-spec.ts index 9bb117a..fba3938 100644 --- a/test/tbank-metadata-persistence.e2e-spec.ts +++ b/test/tbank-metadata-persistence.e2e-spec.ts @@ -25,7 +25,7 @@ describe('durable shared instrument metadata (SQLite)', () => { getInstrumentByUid: jest.fn(), getInstrumentByFigi: jest.fn(), }; - const access = new TbankAccessService(prisma, tokens, upstream as never); + const access = new TbankAccessService(prisma, tokens); const repository = new InstrumentMetadataRepository(prisma); const logger = { event: jest.fn() }; const createService = () => diff --git a/test/tbank-operations-sync.e2e-spec.ts b/test/tbank-operations-sync.e2e-spec.ts index 8d6b813..ac70f75 100644 --- a/test/tbank-operations-sync.e2e-spec.ts +++ b/test/tbank-operations-sync.e2e-spec.ts @@ -42,7 +42,7 @@ describe('Request-driven operations synchronization (SQLite)', () => { TOKEN_ENCRYPTION_KEY: process.env.TOKEN_ENCRYPTION_KEY, }), ); - const access = new TbankAccessService(prisma, tokens, upstream as never); + const access = new TbankAccessService(prisma, tokens); const logger = { event: jest.fn() }; const createService = () => new BrokerageOperationsSyncService( diff --git a/test/tbank-screen-sync.e2e-spec.ts b/test/tbank-screen-sync.e2e-spec.ts index e8b2d5a..6c75d5b 100644 --- a/test/tbank-screen-sync.e2e-spec.ts +++ b/test/tbank-screen-sync.e2e-spec.ts @@ -144,7 +144,12 @@ describe('screen sync HTTP and SQLite', () => { { screen: 'stock', instrumentUid: '00000000-0000-4000-8000-000000000001', - candleRange: { from: '2026-02-01', to: '2026-01-01', jobs: [] }, + candleRange: { + from: '2026-02-01', + to: '2026-01-01', + jobs: [], + '~bad/path': 'SYNTHETIC_VALIDATION_SECRET', + }, }, ])('rejects malformed scope without reserving work: %j', async (scope) => { const response = await post(scope).expect(400); @@ -161,6 +166,20 @@ describe('screen sync HTTP and SQLite', () => { expect(typeof error.pointer).toBe('string'); expect(typeof error.detail).toBe('string'); } + if ('candleRange' in scope) { + expect( + problem.errors.filter( + (error) => error.pointer === '#/candleRange/jobs', + ), + ).toHaveLength(1); + expect(problem.errors).toContainEqual({ + pointer: '#/candleRange/~0bad~1path', + detail: expect.any(String) as unknown, + }); + expect(JSON.stringify(response.body)).not.toContain( + 'SYNTHETIC_VALIDATION_SECRET', + ); + } expect(await prisma.tbankSyncWork.count()).toBe(0); expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); }); @@ -287,6 +306,99 @@ describe('screen sync HTTP and SQLite', () => { await status(last.meta.sync.id).expect(200); expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1); }); + it.each(['succeeded', 'failed'] as const)( + 'observes the operations successor until it is %s', + async (outcome) => { + const accounts = [ + { + id: 'a', + name: 'Synthetic', + type: 'ACCOUNT_TYPE_TINKOFF', + openedDate: { seconds: '1577836800' }, + }, + ]; + let releaseMembership: (value: typeof accounts) => void = () => {}; + upstream.getOpenAccounts.mockReturnValue( + new Promise((resolve) => { + releaseMembership = resolve; + }), + ); + const connection = await prisma.tbankConnection.findUniqueOrThrow({ + where: { userId: 'owner' }, + }); + await prisma.tbankAccount.create({ + data: { + userId: 'owner', + revision: connection.revision, + accountId: 'a', + name: 'Synthetic', + type: 'ACCOUNT_TYPE_TINKOFF', + openedOn: '2020-01-01', + upstreamOrder: 0, + }, + }); + let release: (value: { + hasNext: boolean; + items: never[]; + }) => void = () => {}; + let reject: (error: Error) => void = () => {}; + upstream.getOperationsByCursor + .mockReturnValueOnce( + new Promise((resolve, rejectResult) => { + release = resolve; + reject = rejectResult; + }), + ) + .mockImplementation(() => + outcome === 'failed' + ? Promise.reject(new Error('Synthetic outage')) + : Promise.resolve({ hasNext: false, items: [] }), + ); + const accept = async (from: string, to: string) => { + const response = await post({ + screen: 'operations', + accountId: 'a', + from, + to, + }).expect(202); + return (response.body as { id: string }).id; + }; + const dispatched = async (id: string) => { + for (let i = 0; i < 300; i++) { + const work = await prisma.tbankSyncWork.findUniqueOrThrow({ + where: { id }, + }); + if ((work.progress as { dispatched: boolean }).dispatched) return id; + await delay(5); + } + throw new Error('Screen work did not dispatch'); + }; + const first = await accept('2026-01-01', '2026-01-10'); + const second = await accept('2026-01-20', '2026-01-31'); + const merged = await accept('2026-01-01', '2026-01-31'); + releaseMembership(accounts); + await Promise.all([first, second, merged].map(dispatched)); + try { + expect((await status(second).expect(200)).body).toMatchObject({ + state: 'running', + progress: { completed: 1, total: 2, discovering: false }, + }); + await status(second, 'other').expect(404); + } finally { + if (outcome === 'failed') reject(new Error('Synthetic outage')); + else release({ hasNext: false, items: [] }); + await settled(merged); + await settled(first); + } + expect(await settled(second)).toMatchObject({ + state: outcome, + errorCode: outcome === 'failed' ? 'TBANK_UNAVAILABLE' : null, + progress: { completed: 2, total: 2, discovering: false }, + }); + await prisma.tbankConnection.delete({ where: { userId: 'owner' } }); + await status(second).expect(404); + }, + ); it('rejects known foreign accounts locally and requires authentication', async () => { await post({ screen: 'operations', accountId: 'foreign' }).expect(404); await request(app.getHttpServer()) diff --git a/test/upcoming-events.e2e-spec.ts b/test/upcoming-events.e2e-spec.ts index 92d1131..33c0f02 100644 --- a/test/upcoming-events.e2e-spec.ts +++ b/test/upcoming-events.e2e-spec.ts @@ -10,6 +10,7 @@ import { PrismaService } from '../src/prisma/prisma.service'; import { TbankClient, TbankTokenService, + tbankSourceTtlMs, } from '../src/integrations/tbank/tbank.api'; import { MoexClient } from '../src/integrations/moex/moex.api'; import { CatalogEnrichmentService } from '../src/modules/investments/instruments/application/catalog-enrichment.service'; @@ -423,6 +424,95 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { expect(moex.bondEvents).not.toHaveBeenCalled(); }); + it.each([ + ['stocks', shareUid, 'dividends', 'STOCK'], + ['bonds', bondUid, 'coupons', 'BOND'], + ])( + 'ages the saved %s payout position without RPC or a financial revision, including incompatible fresh candidates', + async (kind, uid, payout, type) => { + const date = new Date(Date.now() + 7 * 86400000) + .toISOString() + .slice(0, 10); + upstream.getDividends.mockResolvedValue([ + { + recordDate: time(date), + dividendNet: { units: '10', nano: 1, currency: 'rub' }, + }, + ]); + upstream.getBondCoupons.mockResolvedValue([ + { + couponDate: time(date), + fixDate: time(date), + payOneBond: { units: '2', currency: 'rub' }, + }, + ]); + await portfolio([position(uid, '3', type)]); + const path = `${kind}/${uid}/${payout}`; + const read = async () => + (await get(`${path}?sync=observe`).expect(200)).body as + StockDividendsResourceDto | BondCouponsResourceDto; + await get(path).expect(200); + await settled(); + const before = await read(); + if (!before.data?.positionAt) + throw new Error('Expected a saved position'); + expect(before.data.positionIsStale).toBe(false); + const saved = await prisma.tbankForecast.findFirstOrThrow(); + const expiresAt = + Date.parse(before.data.positionAt) + tbankSourceTtlMs.portfolio; + const clock = jest.spyOn(Date, 'now').mockReturnValue(expiresAt - 1); + jest.clearAllMocks(); + try { + expect((await read()).data).toEqual(before.data); + clock.mockReturnValue(expiresAt); + const aged = await read(); + expect(aged.data).toEqual({ ...before.data, positionIsStale: true }); + expect(aged.meta.freshness).toBe('stale'); + expect(aged.meta.dataRevision).toBe(before.meta.dataRevision); + expect(aged.meta.lastSuccessfulAt).toBe(before.meta.lastSuccessfulAt); + expect(await prisma.tbankForecast.findFirstOrThrow()).toEqual(saved); + + await portfolio([position(uid, '9', type)]); + await prisma.tbankPortfolio.updateMany({ + where: { userId: 'owner' }, + data: { fetchedAt: new Date(expiresAt) }, + }); + const repository = app.get(ForecastRepository); + const resolve = repository.resolve.bind(repository); + const publish = jest + .spyOn(repository, 'resolve') + .mockImplementationOnce(async (input) => { + expect(input.candidate).toMatchObject({ + positionAt: new Date(expiresAt).toISOString(), + positionIsStale: false, + }); + await prisma.tbankPortfolio.updateMany({ + where: { userId: 'owner' }, + data: { dataRevision: { increment: 1 } }, + }); + return resolve(input); + }); + try { + const fallback = await read(); + expect(fallback.data).toEqual(aged.data); + expect(fallback.meta.freshness).toBe('stale'); + expect(fallback.meta.dataRevision).toBe(before.meta.dataRevision); + expect(fallback.meta.lastSuccessfulAt).toBe( + before.meta.lastSuccessfulAt, + ); + expect(await prisma.tbankForecast.findFirstOrThrow()).toEqual(saved); + } finally { + publish.mockRestore(); + } + for (const rpc of Object.values(upstream)) + expect(rpc).not.toHaveBeenCalled(); + expect(moex.bondEvents).not.toHaveBeenCalled(); + } finally { + clock.mockRestore(); + } + }, + ); + it('keeps optional metadata failure separate from calendar completeness and uses portfolio ticker', async () => { upstream.getBond.mockRejectedValue(new Error('metadata unavailable')); await portfolio([position(bondUid, '3', 'BOND')]); @@ -436,14 +526,72 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { expect(moex.bondEvents).toHaveBeenCalledWith('BOND'); }); - it('does not turn a missing MOEX block into a complete empty forecast and defaults to a 30-day window', async () => { + it.each([ + ['missing block', { coupons: undefined }], + [ + 'invalid cursor', + { 'coupons.cursor': { columns: ['INDEX', 'TOTAL'], data: [[0, 1]] } }, + ], + ])( + 'preserves the saved calendar and forecast when real MOEX parsing rejects %s', + async (_name, invalid) => { + await portfolio([position(bondUid, '3', 'BOND')]); + const response = { + coupons: { + columns: ['coupondate', 'value', 'faceunit'], + data: [['2099-02-01', '2', 'RUB']], + }, + offers: { columns: ['offerdate'], data: [] }, + description: { columns: ['name', 'value'], data: [] }, + }; + const fetchMock = jest + .spyOn(global, 'fetch') + .mockImplementation(() => Promise.resolve(Response.json(response))); + moex.bondEvents.mockImplementation((ticker: string) => + new MoexClient().bondEvents(ticker), + ); + try { + await events(false).expect(200); + await settled(); + const before = (await events().expect(200)) + .body as UpcomingEventsResourceDto; + expect(before.data?.incomeTotalsByCurrency).toEqual([ + { currency: 'RUB', value: '6' }, + ]); + const calendar = await prisma.tbankInstrumentSource.findFirstOrThrow({ + where: { kind: 'bond-events' }, + }); + const forecast = await prisma.tbankForecast.findFirstOrThrow(); + fetchMock.mockImplementation(() => + Promise.resolve(Response.json({ ...response, ...invalid })), + ); + await app + .get(CatalogEnrichmentService) + .requestMany('owner', 'owner-revision', 'bond', [bondUid], true, [ + 'bond-events', + ]); + await settled(); + const after = (await events().expect(200)) + .body as UpcomingEventsResourceDto; + expect(after.data).toEqual(before.data); + expect(after.meta.lastSuccessfulAt).toBe(before.meta.lastSuccessfulAt); + expect(after.meta.sync.state).toBe('failed'); + expect( + await prisma.tbankInstrumentSource.findFirstOrThrow({ + where: { kind: 'bond-events' }, + }), + ).toEqual(calendar); + expect(await prisma.tbankForecast.findFirstOrThrow()).toEqual(forecast); + } finally { + fetchMock.mockRestore(); + } + }, + ); + + it('defaults to a 30-day window', async () => { await portfolio([position(bondUid, '3', 'BOND')]); - moex.bondEvents.mockRejectedValueOnce(new Error('missing MOEX block')); await events(false).expect(200); await settled(); - expect( - ((await events().expect(200)).body as UpcomingEventsResourceDto).data, - ).toBeNull(); await get('brokerage-accounts/account/events?from=2099-02-01').expect(200); await settled(); const result = ( From 9b3558f96c88ff625deae83c07d2087c2ceabfe9 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Mon, 28 Sep 2026 11:04:18 +0300 Subject: [PATCH 03/22] chore: strengthen backend eslint profile --- .ai/architecture.md | 4 +- .prettierrc | 4 - README.md | 28 + commitlint.config.cjs | 4 +- eslint-suppressions.json | 206 ++++ eslint.config.mjs | 110 +- package-lock.json | 969 ++++++++++++++---- package.json | 30 +- scripts/check-release.cjs | 20 +- scripts/generate-openapi.cjs | 1 + src/app.module.ts | 3 +- src/common/decimal.ts | 16 +- .../api-safe-error-response.decorator.ts | 11 +- src/common/dto-validation.spec.ts | 4 +- .../filters/http-exception.filter.spec.ts | 1 + src/common/filters/http-exception.filter.ts | 29 +- src/common/logging/logging.module.ts | 1 + src/common/logging/request-context.service.ts | 6 +- .../request-logging.middleware.spec.ts | 5 +- .../logging/request-logging.middleware.ts | 32 +- .../logging/structured-logger.service.ts | 32 +- src/common/money.dto.ts | 1 + src/config/env.validation.ts | 14 +- src/integrations/moex/moex.client.spec.ts | 15 +- src/integrations/moex/moex.client.ts | 52 +- src/integrations/moex/moex.module.ts | 1 + .../ncr/ncr-ratings.client.spec.ts | 13 + src/integrations/ncr/ncr-ratings.client.ts | 28 +- src/integrations/ncr/ncr.module.ts | 1 + src/integrations/tbank/decimal.spec.ts | 15 + src/integrations/tbank/decimal.ts | 2 +- .../tbank/instrument-metadata.repository.ts | 64 +- .../tbank/instrument-metadata.service.ts | 53 +- .../tbank/tbank-access.service.ts | 40 +- .../tbank/tbank-account.policy.ts | 4 +- src/integrations/tbank/tbank-market.types.ts | 46 +- .../tbank/tbank-membership.service.ts | 1 + .../tbank/tbank-source.policy.spec.ts | 1 + src/integrations/tbank/tbank-source.policy.ts | 1 + src/integrations/tbank/tbank-token.service.ts | 4 +- src/integrations/tbank/tbank.api.ts | 22 +- src/integrations/tbank/tbank.client.spec.ts | 34 +- src/integrations/tbank/tbank.client.ts | 133 +-- src/integrations/tbank/tbank.crypto.ts | 3 +- src/integrations/tbank/tbank.module.ts | 3 +- src/main.ts | 8 +- .../auth/application/auth.service.spec.ts | 2 +- src/modules/auth/application/auth.service.ts | 33 +- src/modules/auth/application/auth.types.ts | 10 +- src/modules/auth/auth.module.ts | 5 +- .../auth/infrastructure/jwt.strategy.ts | 4 +- .../auth/presentation/auth.controller.ts | 9 +- src/modules/auth/presentation/auth.dto.ts | 4 +- .../auth/presentation/jwt-auth.guard.ts | 4 +- .../bank-cards/application/bank-card-input.ts | 25 +- .../application/bank-cards.service.ts | 55 +- .../application/bank-operations.service.ts | 124 ++- .../application/expense-categories.service.ts | 12 +- .../application/get-bank-summary.use-case.ts | 6 +- .../banking/bank-cards/bank-cards.module.ts | 5 +- .../domain/bank-card.calculations.ts | 39 +- .../bank-cards/domain/rub-money.spec.ts | 3 +- .../banking/bank-cards/domain/rub-money.ts | 15 +- .../infrastructure/bank-cards.repository.ts | 20 +- .../bank-cards/presentation/bank-card.dto.ts | 70 +- .../presentation/bank-cards.controller.ts | 26 +- .../brokerage-account.dto.ts | 10 +- .../brokerage-accounts.controller.ts | 8 +- .../brokerage-accounts.module.ts | 3 +- .../brokerage-accounts.service.spec.ts | 2 +- .../brokerage-accounts.service.ts | 39 +- .../income/income-analytics.controller.ts | 8 +- .../income/income-analytics.dto.ts | 17 +- .../income/income-analytics.service.spec.ts | 3 +- .../income/income-analytics.service.ts | 46 +- .../investments/income/income-period.spec.ts | 4 +- .../investments/income/income-period.ts | 61 +- .../investments/income/income.module.ts | 1 + .../instruments/application/candle.service.ts | 144 +-- .../application/catalog-discovery.service.ts | 138 +-- .../application/catalog-enrichment.service.ts | 285 +++--- .../application/catalog-selection.ts | 20 +- .../application/instrument-market.ts | 15 +- .../application/instrument-position.ts | 33 +- .../bonds/application/bond-catalog.service.ts | 32 +- .../bonds/application/bond-income.service.ts | 84 +- .../bonds/application/bond-market.service.ts | 47 +- .../bonds/application/bond.spec.ts | 11 +- .../bonds/infrastructure/bond-money.mapper.ts | 8 +- .../bonds/presentation/bond.controller.ts | 20 +- .../bonds/presentation/bond.dto.ts | 130 ++- .../domain/candle-coverage.spec.ts | 2 +- .../instruments/domain/candle-coverage.ts | 20 +- .../infrastructure/candle.repository.ts | 74 +- .../catalog-discovery.repository.ts | 117 ++- .../instruments/instrument-income.api.ts | 2 +- .../instruments/instrument-sources.module.ts | 5 +- .../instruments/instruments.api.ts | 6 +- .../instruments/instruments.module.ts | 7 +- .../application/get-stock-company.use-case.ts | 37 +- .../application/stock-catalog.service.ts | 34 +- .../application/stock-income.service.ts | 63 +- .../application/stock-market.service.ts | 53 +- .../stocks/application/stock.spec.ts | 2 +- .../stocks/presentation/stock.controller.ts | 20 +- .../stocks/presentation/stock.dto.ts | 135 ++- .../investments/investment-calendar.ts | 3 +- .../investments/investment-resource.dto.ts | 27 +- .../investments/investment-resource.mapper.ts | 54 +- .../investments/investment-sync-plan.spec.ts | 4 +- .../investments/investment-sync-plan.ts | 195 ++-- .../brokerage-operations-sync.service.ts | 63 +- .../brokerage-operations.service.ts | 274 +++-- .../operations/domain/operations-sync-plan.ts | 55 +- .../brokerage-operation.mapper.ts | 32 +- .../brokerage-operations.repository.ts | 256 ++--- .../investments/operations/operations.api.ts | 6 +- .../operations/operations.module.ts | 5 +- .../presentation/brokerage-operation.dto.ts | 25 +- .../brokerage-operations.controller.ts | 12 +- .../portfolio/application/forecast.service.ts | 74 +- .../application/portfolio.service.spec.ts | 7 +- .../application/portfolio.service.ts | 174 ++-- .../domain/portfolio-structure.spec.ts | 4 +- .../portfolio/domain/portfolio-structure.ts | 26 +- .../portfolio/domain/portfolio-totals.ts | 3 +- .../infrastructure/forecast.repository.ts | 38 +- .../infrastructure/portfolio.repository.ts | 22 +- .../infrastructure/tbank-portfolio.mapper.ts | 4 +- .../investments/portfolio/portfolio.api.ts | 6 +- .../investments/portfolio/portfolio.module.ts | 11 +- .../presentation/portfolio.controller.ts | 10 +- .../portfolio/presentation/portfolio.dto.ts | 24 +- .../tbank-connection.controller.ts | 4 +- .../tbank-connection/tbank-connection.dto.ts | 2 +- .../tbank-connection.module.ts | 1 + .../tbank-connection.service.ts | 3 +- .../tbank-sync/tbank-screen.spec.ts | 3 +- .../investments/tbank-sync/tbank-screen.ts | 69 +- .../tbank-sync/tbank-sync.controller.ts | 14 +- .../investments/tbank-sync/tbank-sync.dto.ts | 27 +- .../tbank-sync/tbank-sync.module.ts | 13 +- .../tbank-sync/tbank-sync.repository.ts | 21 +- .../tbank-sync/tbank-sync.service.ts | 255 ++--- .../upcoming-events.controller.ts | 6 +- .../upcoming-events/upcoming-events.dto.ts | 20 +- .../upcoming-events/upcoming-events.module.ts | 5 +- .../upcoming-events.service.spec.ts | 2 +- .../upcoming-events.service.ts | 74 +- src/prisma/prisma.module.ts | 1 + src/prisma/prisma.service.ts | 4 +- test/all-accounts.e2e-spec.ts | 21 +- test/app.e2e-spec.ts | 5 +- test/bank-cards-durability.cjs | 276 +++-- test/bank-cards.e2e-spec.ts | 52 +- test/bonds.e2e-spec.ts | 6 +- test/container-smoke.cjs | 12 +- test/e2e-env.cjs | 2 +- test/eslint-config.cjs | 47 + test/fixtures/tbank-sync-crash.cjs | 13 +- test/openapi-contract.e2e-spec.ts | 7 +- test/portfolio-structure.e2e-spec.ts | 13 +- test/sqlite-regression.cjs | 22 +- test/sqlite.cjs | 17 +- test/stocks.e2e-spec.ts | 6 +- test/tbank-candles-persistence.e2e-spec.ts | 35 +- test/tbank-catalog-discovery.e2e-spec.ts | 14 +- test/tbank-catalog-local.e2e-spec.ts | 77 +- test/tbank-connection.e2e-spec.ts | 18 +- test/tbank-income-local.e2e-spec.ts | 15 +- test/tbank-ledger-local.e2e-spec.ts | 35 +- test/tbank-local-reads.e2e-spec.ts | 26 +- test/tbank-metadata-persistence.e2e-spec.ts | 29 +- test/tbank-migration.e2e-spec.ts | 15 +- test/tbank-operations-persistence.e2e-spec.ts | 6 +- test/tbank-operations-sync.e2e-spec.ts | 36 +- test/tbank-read-baseline.e2e-spec.ts | 87 +- test/tbank-screen-sync.e2e-spec.ts | 34 +- test/upcoming-events.e2e-spec.ts | 45 +- 179 files changed, 4485 insertions(+), 2716 deletions(-) delete mode 100644 .prettierrc create mode 100644 eslint-suppressions.json create mode 100644 test/eslint-config.cjs diff --git a/.ai/architecture.md b/.ai/architecture.md index 770f008..892117b 100644 --- a/.ai/architecture.md +++ b/.ai/architecture.md @@ -235,8 +235,8 @@ protobuf units/nano conversion — T-Bank integration. Domain используе Общий выбор symbolic values и числовых policies — [Domain values](conventions/engineering.md#domain-values). Сохраняй strict TS и explicit boundary types. Для недоверенного input выбирай -`unknown` + runtime narrowing; отключённый lint `no-explicit-any` не делает -`any` нормой domain/application model. `declare` допустим для заполняемых извне +`unknown` + runtime narrowing; ESLint запрещает explicit `any` +в domain/application model, тестах и конфигурации TypeScript. `declare` допустим для заполняемых извне DTO fields, не для сокрытия неинициализированного owned state. OpenAPI enum metadata и runtime validation должны читать тот же vocabulary, diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index a20502b..0000000 --- a/.prettierrc +++ /dev/null @@ -1,4 +0,0 @@ -{ - "singleQuote": true, - "trailingComma": "all" -} diff --git a/README.md b/README.md index fa6844f..4592bb4 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,34 @@ Required checks настраиваются отдельно в Gitea: налич runtime-валидацию. В обычном коде нужны инициализация или сужение типа после проверки значения. +## ESLint + +`npm run lint` проверяет весь authored TypeScript и JavaScript, включая scripts +и configs. `npm run lint:fix` (также `npm run format`) применяет безопасные +автоисправления. Форматирование принадлежит ESLint Stylistic: два пробела, +одинарные кавычки и точки с запятой; Prettier не используется. + +Профиль: ESLint recommended, typescript-eslint recommendedTypeChecked, +Node, NestJS typed, Jest, import-x с TypeScript resolver, SonarJS, RegExp, +Security, Perfectionist recommended-natural и Stylistic customize. +NestJS проверяется в production TS, Jest — в specs/e2e; generated Prisma, +dist и coverage исключены. DI imports сохраняют runtime metadata. + +Исключения конфигурации: private backend не публикуется в npm; +TS imports разрешает import-x вместо Node resolver; IsIn проверяет domain +unions. Автосортировка объектов, классов, модулей и декораторов отключена, +поскольку порядок может влиять на runtime. Остальные точечные исключения +объясняют false positives непосредственно в коде. + +`eslint-suppressions.json` — штатный ESLint baseline только для существующих +SonarJS cognitive-complexity, no-nested-conditional и no-nested-template-literals. +Это отложенный refactor, а не исправленные нарушения. Baseline ограничен +файлами и количеством сообщений; новые файлы и превышение количества блокируют +lint. Он не защищает от усложнения уже учтённой функции при прежнем количестве +сообщений — это остаётся предметом review. При сокращении долга обновляйте +baseline командой `npx eslint . --prune-suppressions`; не пересоздавайте его +для обхода новых ошибок. Security, TypeScript и Jest в baseline не включены. + ## SonarQube Проект `wealthledger-backend` на `https://sonarqube.personal.ksv741.ru` diff --git a/commitlint.config.cjs b/commitlint.config.cjs index 4aa31e2..fe88ec0 100644 --- a/commitlint.config.cjs +++ b/commitlint.config.cjs @@ -1,4 +1,4 @@ module.exports = { - extends: ["@commitlint/config-conventional"], - rules: { "type-enum": [2, "always", ["feat", "fix", "perf", "docs", "spec", "refactor", "build", "ci", "test", "style", "chore", "revert"]] }, + extends: ['@commitlint/config-conventional'], + rules: { 'type-enum': [2, 'always', ['feat', 'fix', 'perf', 'docs', 'spec', 'refactor', 'build', 'ci', 'test', 'style', 'chore', 'revert']] }, }; diff --git a/eslint-suppressions.json b/eslint-suppressions.json new file mode 100644 index 0000000..19e68f3 --- /dev/null +++ b/eslint-suppressions.json @@ -0,0 +1,206 @@ +{ + "src/common/decimal.ts": { + "sonarjs/no-nested-conditional": { + "count": 1 + } + }, + "src/common/logging/request-logging.middleware.ts": { + "sonarjs/no-nested-conditional": { + "count": 1 + } + }, + "src/integrations/tbank/instrument-metadata.repository.ts": { + "sonarjs/cognitive-complexity": { + "count": 1 + }, + "sonarjs/no-nested-conditional": { + "count": 1 + } + }, + "src/integrations/tbank/instrument-metadata.service.ts": { + "sonarjs/cognitive-complexity": { + "count": 1 + } + }, + "src/integrations/tbank/tbank.client.ts": { + "sonarjs/cognitive-complexity": { + "count": 1 + } + }, + "src/modules/banking/bank-cards/application/bank-operations.service.ts": { + "sonarjs/cognitive-complexity": { + "count": 1 + } + }, + "src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts": { + "sonarjs/cognitive-complexity": { + "count": 1 + } + }, + "src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts": { + "sonarjs/no-nested-conditional": { + "count": 1 + } + }, + "src/modules/investments/income/income-analytics.service.ts": { + "sonarjs/no-nested-template-literals": { + "count": 1 + } + }, + "src/modules/investments/income/income-period.ts": { + "sonarjs/no-nested-conditional": { + "count": 1 + } + }, + "src/modules/investments/instruments/application/candle.service.ts": { + "sonarjs/cognitive-complexity": { + "count": 4 + }, + "sonarjs/no-nested-conditional": { + "count": 4 + }, + "sonarjs/no-nested-template-literals": { + "count": 1 + } + }, + "src/modules/investments/instruments/application/catalog-discovery.service.ts": { + "sonarjs/cognitive-complexity": { + "count": 1 + }, + "sonarjs/no-nested-conditional": { + "count": 1 + } + }, + "src/modules/investments/instruments/application/catalog-enrichment.service.ts": { + "sonarjs/cognitive-complexity": { + "count": 2 + }, + "sonarjs/no-nested-conditional": { + "count": 1 + } + }, + "src/modules/investments/instruments/bonds/application/bond-income.service.ts": { + "sonarjs/no-nested-conditional": { + "count": 2 + } + }, + "src/modules/investments/instruments/bonds/application/bond.spec.ts": { + "sonarjs/no-nested-conditional": { + "count": 1 + } + }, + "src/modules/investments/instruments/infrastructure/candle.repository.ts": { + "sonarjs/cognitive-complexity": { + "count": 2 + } + }, + "src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts": { + "sonarjs/cognitive-complexity": { + "count": 1 + }, + "sonarjs/no-nested-conditional": { + "count": 2 + } + }, + "src/modules/investments/instruments/stocks/application/stock-income.service.ts": { + "sonarjs/no-nested-conditional": { + "count": 1 + } + }, + "src/modules/investments/investment-resource.mapper.ts": { + "sonarjs/no-nested-conditional": { + "count": 3 + } + }, + "src/modules/investments/investment-sync-plan.ts": { + "sonarjs/cognitive-complexity": { + "count": 1 + }, + "sonarjs/no-nested-conditional": { + "count": 4 + } + }, + "src/modules/investments/operations/application/brokerage-operations.service.ts": { + "sonarjs/cognitive-complexity": { + "count": 2 + }, + "sonarjs/no-nested-conditional": { + "count": 9 + } + }, + "src/modules/investments/operations/domain/operations-sync-plan.ts": { + "sonarjs/cognitive-complexity": { + "count": 1 + }, + "sonarjs/no-nested-conditional": { + "count": 1 + } + }, + "src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts": { + "sonarjs/no-nested-conditional": { + "count": 2 + } + }, + "src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts": { + "sonarjs/cognitive-complexity": { + "count": 3 + }, + "sonarjs/no-nested-conditional": { + "count": 1 + } + }, + "src/modules/investments/portfolio/application/forecast.service.ts": { + "sonarjs/no-nested-conditional": { + "count": 1 + } + }, + "src/modules/investments/portfolio/application/portfolio.service.ts": { + "sonarjs/cognitive-complexity": { + "count": 1 + }, + "sonarjs/no-nested-conditional": { + "count": 1 + } + }, + "src/modules/investments/tbank-sync/tbank-screen.ts": { + "sonarjs/cognitive-complexity": { + "count": 1 + } + }, + "src/modules/investments/tbank-sync/tbank-sync.service.ts": { + "sonarjs/cognitive-complexity": { + "count": 3 + }, + "sonarjs/no-nested-conditional": { + "count": 7 + } + }, + "src/modules/investments/upcoming-events/upcoming-events.service.ts": { + "sonarjs/cognitive-complexity": { + "count": 1 + } + }, + "test/bank-cards-durability.cjs": { + "sonarjs/no-nested-template-literals": { + "count": 1 + } + }, + "test/sqlite-regression.cjs": { + "sonarjs/no-nested-conditional": { + "count": 2 + } + }, + "test/sqlite.cjs": { + "sonarjs/no-nested-conditional": { + "count": 2 + } + }, + "test/tbank-ledger-local.e2e-spec.ts": { + "sonarjs/no-nested-conditional": { + "count": 1 + }, + "sonarjs/no-nested-template-literals": { + "count": 3 + } + } +} \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs index 537b840..81c0330 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,31 +1,71 @@ // @ts-check +import nest from '@darraghor/eslint-plugin-nestjs-typed'; import eslint from '@eslint/js'; -import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; +import stylistic from '@stylistic/eslint-plugin'; +import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript'; +import { importX } from 'eslint-plugin-import-x'; +import jest from 'eslint-plugin-jest'; +import node from 'eslint-plugin-n'; +import perfectionist from 'eslint-plugin-perfectionist'; +import regexp from 'eslint-plugin-regexp'; +import security from 'eslint-plugin-security'; +import sonarjs from 'eslint-plugin-sonarjs'; +import { defineConfig, globalIgnores } from 'eslint/config'; import globals from 'globals'; import tseslint from 'typescript-eslint'; -export default tseslint.config( +const tests = ['**/*.spec.ts', 'test/**/*.ts']; + +export default defineConfig( + globalIgnores(['dist/**', 'dist-dev/**', 'build/**', 'generated/**', 'coverage/**', 'node_modules/**']), { - ignores: ['eslint.config.mjs', 'dist/', 'generated/'], - }, - eslint.configs.recommended, - ...tseslint.configs.recommendedTypeChecked, - eslintPluginPrettierRecommended, - { - languageOptions: { - globals: { - ...globals.node, - ...globals.jest, - }, - sourceType: 'commonjs', - parserOptions: { - projectService: true, - tsconfigRootDir: import.meta.dirname, - }, + files: ['**/*.{ts,js,cjs,mjs}'], + extends: [ + eslint.configs.recommended, + node.configs['flat/recommended'], + importX.flatConfigs.recommended, + sonarjs.configs.recommended, + regexp.configs['flat/recommended'], + security.configs.recommended, + perfectionist.configs['recommended-natural'], + stylistic.configs.customize({ indent: 2, quotes: 'single', semi: true, jsx: false }), + ], + languageOptions: { globals: globals.node }, + linterOptions: { reportUnusedDisableDirectives: 'error' }, + settings: { + 'import-x/resolver-next': [createTypeScriptImportResolver()], + 'node': { tryExtensions: ['.ts', '.js', '.json', '.node'], version: '24.21.0' }, + }, + rules: { + 'curly': ['error', 'multi-line'], + // This private application is deployed as a whole, never published to npm. + 'n/no-unpublished-import': 'off', + 'n/no-unpublished-require': 'off', + // Decorator execution and object insertion order can be observable at runtime. + 'perfectionist/sort-decorators': 'off', + 'perfectionist/sort-objects': 'off', + 'perfectionist/sort-modules': 'off', + 'perfectionist/sort-classes': 'off', + '@stylistic/brace-style': ['error', '1tbs', { allowSingleLine: false }], + '@stylistic/arrow-parens': ['error', 'always'], + '@stylistic/operator-linebreak': ['error', 'after'], + 'import-x/no-duplicates': ['error', { 'prefer-inline': true }], }, }, { + files: ['**/*.mjs'], + extends: [node.configs['flat/recommended-module']], + }, + { + files: ['**/*.ts'], + extends: [tseslint.configs.recommendedTypeChecked, importX.flatConfigs.typescript], + languageOptions: { + sourceType: 'module', + parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname }, + }, rules: { + // TypeScript resolves source imports; import-x checks them with the TS resolver. + 'n/no-missing-import': 'off', 'no-magic-numbers': 'off', '@typescript-eslint/no-magic-numbers': [ 'error', @@ -63,24 +103,28 @@ export default tseslint.config( enforceConst: true, }, ], - '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-non-null-assertion': 'error', - 'no-restricted-syntax': [ - 'error', - { - selector: - 'PropertyDefinition[definite=true], VariableDeclarator[definite=true]', - message: - 'Definite assignment assertions are forbidden. Initialize owned values; use declare only for externally populated DTO fields.', - }, - ], - '@typescript-eslint/no-floating-promises': 'warn', - '@typescript-eslint/no-unsafe-argument': 'warn', - 'prettier/prettier': ['error', { endOfLine: 'auto' }], + 'no-restricted-syntax': ['error', { + selector: 'PropertyDefinition[definite=true], VariableDeclarator[definite=true]', + message: 'Definite assignment assertions are forbidden. Initialize owned values; use declare only for externally populated DTO fields.', + }], }, }, { - files: ['**/*.spec.ts', 'test/**/*.ts'], - rules: { '@typescript-eslint/no-magic-numbers': 'off' }, + files: ['src/**/*.ts'], + ignores: tests, + extends: [nest.configs.flatRecommended], + rules: { + // Domain string unions are validated with IsIn rather than runtime TS enums. + '@darraghor/nestjs-typed/validated-non-primitive-property-needs-type-decorator': ['error', { additionalTypeDecorators: ['IsIn'] }], + }, + }, + { + files: tests, + extends: [jest.configs['flat/recommended']], + rules: { + '@typescript-eslint/no-magic-numbers': 'off', + 'jest/expect-expect': ['error', { assertFunctionNames: ['expect', '**.expect'] }], + }, }, ); diff --git a/package-lock.json b/package-lock.json index 225e498..697eecf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "class-transformer": "0.5.1", "class-validator": "0.15.1", "cookie-parser": "1.4.7", + "dotenv": "17.4.2", "lru-cache": "11.5.2", "nestjs-pino": "5.1.0", "passport": "0.7.0", @@ -38,12 +39,13 @@ "devDependencies": { "@commitlint/cli": "21.2.2", "@commitlint/config-conventional": "21.2.2", - "@eslint/eslintrc": "3.3.7", + "@darraghor/eslint-plugin-nestjs-typed": "7.5.6", "@eslint/js": "10.0.1", "@nestjs/cli": "12.0.0", "@nestjs/schematics": "12.0.1", "@nestjs/testing": "12.0.1", "@release-it/conventional-changelog": "12.0.0", + "@stylistic/eslint-plugin": "5.10.0", "@types/bcrypt": "6.0.0", "@types/cookie-parser": "1.4.10", "@types/express": "5.0.6", @@ -52,12 +54,17 @@ "@types/passport-jwt": "4.0.1", "@types/supertest": "7.2.1", "conventional-changelog-conventionalcommits": "10.4.0", - "eslint": "10.10.0", - "eslint-config-prettier": "10.1.8", - "eslint-plugin-prettier": "5.5.6", + "eslint": "10.11.0", + "eslint-import-resolver-typescript": "4.4.5", + "eslint-plugin-import-x": "4.17.1", + "eslint-plugin-jest": "29.16.6", + "eslint-plugin-n": "18.4.0", + "eslint-plugin-perfectionist": "5.12.1", + "eslint-plugin-regexp": "3.3.1", + "eslint-plugin-security": "4.1.0", + "eslint-plugin-sonarjs": "4.2.1", "globals": "17.12.0", "jest": "30.5.1", - "prettier": "3.9.6", "release-it": "21.0.2", "source-map-support": "0.5.21", "supertest": "7.2.2", @@ -66,7 +73,7 @@ "ts-node": "10.9.2", "tsconfig-paths": "4.2.0", "typescript": "6.0.3", - "typescript-eslint": "8.70.0" + "typescript-eslint": "8.70.1" }, "engines": { "node": "v24.21.0" @@ -1276,6 +1283,30 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@darraghor/eslint-plugin-nestjs-typed": { + "version": "7.5.6", + "resolved": "https://registry.npmjs.org/@darraghor/eslint-plugin-nestjs-typed/-/eslint-plugin-nestjs-typed-7.5.6.tgz", + "integrity": "sha512-G8QbQX0Xd2gj9ZrRXE+8RCeXivEVNRt9DggvxIfHA9aBOMPrp+RHdvtxWViNqaJMezhSxD+3FFuLE3vZ6el0GQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@typescript-eslint/scope-manager": "^8.70.0", + "@typescript-eslint/type-utils": "^8.70.0", + "@typescript-eslint/utils": "^8.70.0", + "eslint-module-utils": "2.14.0", + "glob": "13.0.6", + "reflect-metadata": "0.2.2", + "ts-api-utils": "2.5.0" + }, + "engines": { + "node": ">=24.0.0" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^7.0.0 || ^8.0.0", + "class-validator": "*", + "eslint": ">=9.18.0" + } + }, "node_modules/@electric-sql/pglite": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.3.tgz", @@ -1459,43 +1490,6 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz", - "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.3.2", - "minimatch": "^3.1.5", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@eslint/js": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", @@ -4374,12 +4368,40 @@ "@sinonjs/commons": "^3.0.1" } }, + "node_modules/@sonarsource/analyzer-commons-configurations": { + "version": "2.31.0-5284", + "resolved": "https://registry.npmjs.org/@sonarsource/analyzer-commons-configurations/-/analyzer-commons-configurations-2.31.0-5284.tgz", + "integrity": "sha512-ruk3blMs9BE9RNSp9LPpWZAIjxJmq4Cp00E3cELIHz7Kym3WxdWGRlKOMYLRQ5tJg/de7jN5AtEElY3bGIfTcw==", + "dev": true, + "license": "LGPL-3.0-only" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "license": "MIT" }, + "node_modules/@stylistic/eslint-plugin": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.10.0.tgz", + "integrity": "sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/types": "^8.56.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "estraverse": "^5.3.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": "^9.0.0 || ^10.0.0" + } + }, "node_modules/@tokenizer/inflate": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", @@ -4886,17 +4908,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz", - "integrity": "sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.1.tgz", + "integrity": "sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.70.0", - "@typescript-eslint/type-utils": "8.70.0", - "@typescript-eslint/utils": "8.70.0", - "@typescript-eslint/visitor-keys": "8.70.0", + "@typescript-eslint/scope-manager": "8.70.1", + "@typescript-eslint/type-utils": "8.70.1", + "@typescript-eslint/utils": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -4909,7 +4931,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.70.0", + "@typescript-eslint/parser": "^8.70.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -4925,16 +4947,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.0.tgz", - "integrity": "sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.1.tgz", + "integrity": "sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.70.0", - "@typescript-eslint/types": "8.70.0", - "@typescript-eslint/typescript-estree": "8.70.0", - "@typescript-eslint/visitor-keys": "8.70.0", + "@typescript-eslint/scope-manager": "8.70.1", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1", "debug": "^4.4.3" }, "engines": { @@ -4950,14 +4972,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.0.tgz", - "integrity": "sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.1.tgz", + "integrity": "sha512-62xOgboPfwc3/IgPSX/W6oQR3ZbF04194FPGUGH8HL8iLFHbt/456/8Ph1wLNUgVF+s94FlHoipBsz+v7+LMnA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.70.0", - "@typescript-eslint/types": "^8.70.0", + "@typescript-eslint/tsconfig-utils": "^8.70.1", + "@typescript-eslint/types": "^8.70.1", "debug": "^4.4.3" }, "engines": { @@ -4972,14 +4994,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.0.tgz", - "integrity": "sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.1.tgz", + "integrity": "sha512-Pa0EeSeAusQc1WbjQMac+YfenewYTBu0KjgYvkUKwhXaHUKbFog23Dm/rp0DX/6tyYOQ3Xl1a+3EcFNZynGHCw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.70.0", - "@typescript-eslint/visitor-keys": "8.70.0" + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4990,9 +5012,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.0.tgz", - "integrity": "sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.1.tgz", + "integrity": "sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg==", "dev": true, "license": "MIT", "engines": { @@ -5007,15 +5029,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.0.tgz", - "integrity": "sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.1.tgz", + "integrity": "sha512-7zKTnyvaVWqzLZHPFQtX1hVHqgkMC+WebPWakNCSyrQVbIP1AM0L0TlBZtACldIRb6PptI8Odk+jyZ5kP3B1VA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.70.0", - "@typescript-eslint/typescript-estree": "8.70.0", - "@typescript-eslint/utils": "8.70.0", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1", + "@typescript-eslint/utils": "8.70.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -5032,9 +5054,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.0.tgz", - "integrity": "sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.1.tgz", + "integrity": "sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q==", "dev": true, "license": "MIT", "engines": { @@ -5046,16 +5068,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.0.tgz", - "integrity": "sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.1.tgz", + "integrity": "sha512-TU8PwyGN0PQJUcE96mw8eCQ44SmxGdQlJmlWakHaHQ15eIuuvye5yNtmh/i6oS88jzXVQB71xdNkbkB/fMwL0g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.70.0", - "@typescript-eslint/tsconfig-utils": "8.70.0", - "@typescript-eslint/types": "8.70.0", - "@typescript-eslint/visitor-keys": "8.70.0", + "@typescript-eslint/project-service": "8.70.1", + "@typescript-eslint/tsconfig-utils": "8.70.1", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -5084,9 +5106,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5113,16 +5135,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.0.tgz", - "integrity": "sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.1.tgz", + "integrity": "sha512-Esgul8MsnKnRLdYU2Eb2cRV9bS5HJYtKj1ByJnOzzG2M58DGdSUQ1jUuILxipqcpB2h9WLrbD5GijIWUjX/Tqw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.70.0", - "@typescript-eslint/types": "8.70.0", - "@typescript-eslint/typescript-estree": "8.70.0" + "@typescript-eslint/scope-manager": "8.70.1", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5137,13 +5159,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.0.tgz", - "integrity": "sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.1.tgz", + "integrity": "sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/types": "8.70.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -6248,13 +6270,6 @@ "@babel/core": "^7.11.0 || ^8.0.0-beta.1 || ^8.0.0" } }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -6400,17 +6415,6 @@ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "license": "ISC" }, - "node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/browserslist": { "version": "4.28.8", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", @@ -6504,6 +6508,19 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, + "node_modules/builtin-modules": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", + "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -6978,6 +6995,16 @@ "node": ">= 6" } }, + "node_modules/comment-parser": { + "version": "1.4.9", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.9.tgz", + "integrity": "sha512-+2AvZKjJaNq9GQljm3tr0utwrig5BL+jgJGvz5rDpGKNIp+ojcRXWUwBbh/sGIi1QCprR6PsKFakub+w1v+4hQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/component-emitter": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", @@ -6988,13 +7015,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/concat-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", @@ -7948,7 +7968,6 @@ "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" @@ -8118,9 +8137,9 @@ } }, "node_modules/eslint": { - "version": "10.10.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.10.0.tgz", - "integrity": "sha512-NPXn6r5zl4uET1DAVPaOwzX3rut4c0wcmw3dWJAfOsTM5+TogXo0DDjz8pwm/hL8cyVNpHqeK4JpN0NjnyFFNw==", + "version": "10.11.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.11.0.tgz", + "integrity": "sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==", "dev": true, "license": "MIT", "workspaces": [ @@ -8176,53 +8195,437 @@ } } }, - "node_modules/eslint-config-prettier": { - "version": "10.1.8", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", - "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", - "dev": true, - "license": "MIT", - "bin": { - "eslint-config-prettier": "bin/cli.js" - }, - "funding": { - "url": "https://opencollective.com/eslint-config-prettier" - }, - "peerDependencies": { - "eslint": ">=7.0.0" - } - }, - "node_modules/eslint-plugin-prettier": { - "version": "5.5.6", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.6.tgz", - "integrity": "sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==", + "node_modules/eslint-compat-utils": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/eslint-compat-utils/-/eslint-compat-utils-0.5.1.tgz", + "integrity": "sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==", "dev": true, "license": "MIT", "dependencies": { - "prettier-linter-helpers": "^1.0.1", - "synckit": "^0.11.13" + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "eslint": ">=6.0.0" + } + }, + "node_modules/eslint-import-context": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/eslint-import-context/-/eslint-import-context-0.1.9.tgz", + "integrity": "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-tsconfig": "^4.10.1", + "stable-hash-x": "^0.2.0" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-context" + }, + "peerDependencies": { + "unrs-resolver": "^1.0.0" + }, + "peerDependenciesMeta": { + "unrs-resolver": { + "optional": true + } + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-4.4.5.tgz", + "integrity": "sha512-nbE5XLph6TLtGYcu/U6e6ZVXyKBhbDWK5cLGk76eJ7NdZpwf1P9EFkpt1Z01mNZNrrilsAYWKH6zUkL4reoXbw==", + "dev": true, + "license": "ISC", + "dependencies": { + "debug": "^4.4.1", + "eslint-import-context": "^0.1.8", + "get-tsconfig": "^4.10.1", + "is-bun-module": "^2.0.0", + "stable-hash-x": "^0.2.0", + "tinyglobby": "^0.2.14", + "unrs-resolver": "^1.7.11" + }, + "engines": { + "node": "^16.17.0 || >=18.6.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.14.0.tgz", + "integrity": "sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-es-x": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-es-x/-/eslint-plugin-es-x-7.8.0.tgz", + "integrity": "sha512-7Ds8+wAAoV3T+LAKeu39Y5BzXCrGKrcISfgKEqTS4BDN8SFEDQd0S43jiQ8vIa3wUKD07qitZdfzlenSi8/0qQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/ota-meshi", + "https://opencollective.com/eslint" + ], + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.1.2", + "@eslint-community/regexpp": "^4.11.0", + "eslint-compat-utils": "^0.5.1" }, "engines": { "node": "^14.18.0 || >=16.0.0" }, + "peerDependencies": { + "eslint": ">=8" + } + }, + "node_modules/eslint-plugin-import-x": { + "version": "4.17.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-import-x/-/eslint-plugin-import-x-4.17.1.tgz", + "integrity": "sha512-4cdstYkKCyjumM2Q9NSI03K8D2a9F4Ssz33K2lv2hQa4KmR9jPLwk3uWGtNvclfqBrPGfGuMBwsGMbe6dMRbfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "^8.56.0", + "comment-parser": "^1.4.1", + "debug": "^4.4.1", + "eslint-import-context": "^0.1.9", + "is-glob": "^4.0.3", + "minimatch": "^9.0.3 || ^10.1.2", + "semver": "^7.7.2", + "stable-hash-x": "^0.2.0", + "unrs-resolver": "^1.9.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, "funding": { - "url": "https://opencollective.com/eslint-plugin-prettier" + "url": "https://opencollective.com/eslint-plugin-import-x" }, "peerDependencies": { - "@types/eslint": ">=8.0.0", - "eslint": ">=8.0.0", - "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", - "prettier": ">=3.0.0" + "@typescript-eslint/utils": "^8.56.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "eslint-import-resolver-node": "*" }, "peerDependenciesMeta": { - "@types/eslint": { + "@typescript-eslint/utils": { "optional": true }, - "eslint-config-prettier": { + "eslint-import-resolver-node": { "optional": true } } }, + "node_modules/eslint-plugin-import-x/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint-plugin-import-x/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/eslint-plugin-import-x/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/eslint-plugin-jest": { + "version": "29.16.6", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-29.16.6.tgz", + "integrity": "sha512-q8TVr0rlNvUD0XnafGWkwtPeX+tTl2llP86EDl3sJtwWrQA/JT9SIu2hLLZbhhX6fT5SDE4O0gIKyZUujKnkYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.0.0" + }, + "engines": { + "node": "^20.12.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^8.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "jest": "*", + "typescript": ">=4.8.4 <8.0.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + }, + "jest": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-n": { + "version": "18.4.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-18.4.0.tgz", + "integrity": "sha512-TEm6Nqn9+l7EWfJXOL6pmrEdpiM2ZTgo4fNMRDvBTM70jeQ7x3X3Akcx+sRCIRfpcSVwMypH3ZAA33PLyNKmYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.5.0", + "enhanced-resolve": "^5.17.1", + "eslint-plugin-es-x": "^7.8.0", + "get-tsconfig": "^4.8.1", + "globals": "^15.11.0", + "globrex": "^0.1.2", + "ignore": "^5.3.2", + "js-yaml": "^5.4.1", + "semver": "^7.6.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": ">=8.57.1", + "ts-declaration-location": "^1.0.6", + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "ts-declaration-location": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-n/node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-plugin-n/node_modules/js-yaml": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.2.tgz", + "integrity": "sha512-m+aqu+LwO1O6sIopafj8HUVl5aawITwZQe/yHpMCKjaWBaA/d07B/QdMb3529REftiU+RMMHL3Vlsw3hON7vWg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.mjs" + } + }, + "node_modules/eslint-plugin-perfectionist": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-perfectionist/-/eslint-plugin-perfectionist-5.12.1.tgz", + "integrity": "sha512-krdSE7Dz+TCtcVr2+rfNhlOIFFBJcUdoRvjaN7JQnsaVnxJw+BZh9wMkY3MSLCJat+yW+UPTQLKS82Epc7MGZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "^8.70.0", + "@typescript-eslint/utils": "^8.70.0", + "natural-orderby": "^5.0.0" + }, + "engines": { + "node": "^20.0.0 || >=22.0.0" + }, + "peerDependencies": { + "eslint": "^8.45.0 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-regexp": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-regexp/-/eslint-plugin-regexp-3.3.1.tgz", + "integrity": "sha512-58bGUci/AaWAo1ETj6Lsnmy6QXwnSyVONRA805828nnqp7vrTwdaQMc02vtowmYB3A5OB3SLr3r5HgmfxX4dLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.11.0", + "comment-parser": "^1.4.0", + "jsdoc-type-pratt-parser": "^9.0.0", + "refa": "^0.12.1", + "regexp-ast-analysis": "^0.7.1", + "scslre": "^0.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "peerDependencies": { + "eslint": ">=9.38.0" + } + }, + "node_modules/eslint-plugin-security": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-security/-/eslint-plugin-security-4.1.0.tgz", + "integrity": "sha512-cB4H72o/w8C0cm4uHJOnXMJz7MYTTuOwhn2X7PMuzZwfwqUoHK/ASHT6WKP+95lFk5Hkf3xfpiXB8a1+cef4rg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-regex": "^2.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-sonarjs": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-sonarjs/-/eslint-plugin-sonarjs-4.2.1.tgz", + "integrity": "sha512-WGsnVkUxmHyBOytShcIeE+vhQaS8e+gYgbHNNi4y2e5Zj+DpE2DLTvarqDUhNwjceTZHGLgevSgfBSEVkt4EaA==", + "dev": true, + "license": "LGPL-3.0-only", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@sonarsource/analyzer-commons-configurations": "^2.31.0-5284", + "builtin-modules": "^3.3.0", + "bytes": "^3.1.2", + "functional-red-black-tree": "^1.0.1", + "globals": "^17.12.0", + "jsx-ast-utils-x": "^0.1.0", + "lodash.merge": "^4.6.2", + "minimatch": "^10.2.6", + "scslre": "^0.3.0", + "semver": "^7.8.5", + "ts-api-utils": "^2.5.0", + "typescript": ">=5 <6.1.0", + "yaml": "^2.9.0" + }, + "peerDependencies": { + "eslint": "^8.0.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-sonarjs/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint-plugin-sonarjs/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/eslint-plugin-sonarjs/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/eslint-scope": { "version": "9.1.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", @@ -8603,13 +9006,6 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, - "node_modules/fast-diff": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", - "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -8932,6 +9328,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", + "dev": true, + "license": "MIT" + }, "node_modules/generate-function": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", @@ -9039,6 +9442,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-tsconfig": { + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.3.tgz", + "integrity": "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/get-uri": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-8.0.1.tgz", @@ -9199,6 +9615,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globrex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", + "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", + "dev": true, + "license": "MIT" + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -9569,6 +9992,16 @@ "dev": true, "license": "MIT" }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -10468,6 +10901,37 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdoc-type-pratt-parser": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-9.2.2.tgz", + "integrity": "sha512-eFY7Rlscvv/BpY6UsVsjKwMhu1tJO/F4U/vC9g3yeRy/JqszuO1UP6Vdq+85G+3FP6fEw/sWR8a5ftPFY86csw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.9", + "@types/node": "^26.4.0" + }, + "engines": { + "node": "^22.22.2 || >=24.15.0" + } + }, + "node_modules/jsdoc-type-pratt-parser/node_modules/@types/node": { + "version": "26.6.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.3.tgz", + "integrity": "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/jsdoc-type-pratt-parser/node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -10551,6 +11015,16 @@ "npm": ">=6" } }, + "node_modules/jsx-ast-utils-x": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/jsx-ast-utils-x/-/jsx-ast-utils-x-0.1.0.tgz", + "integrity": "sha512-eQQBjBnsVtGacsG9uJNB8qOr3yA8rga4wAaGG1qRcBzSIvfhERLrWxMAM1hp5fcS6Abo8M4+bUBTekYR0qTPQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", @@ -10960,19 +11434,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -11313,6 +11774,16 @@ "dev": true, "license": "MIT" }, + "node_modules/natural-orderby": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/natural-orderby/-/natural-orderby-5.0.0.tgz", + "integrity": "sha512-kKHJhxwpR/Okycz4HhQKKlhWe4ASEfPgkSWNmKFHd7+ezuQlxkA5cM3+XkBPvm1gmHen3w53qsYAv+8GwRrBlg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -12232,35 +12703,6 @@ "node": ">= 0.8.0" } }, - "node_modules/prettier": { - "version": "3.9.6", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", - "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/prettier-linter-helpers": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", - "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-diff": "^1.1.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/pretty-format": { "version": "30.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.1.tgz", @@ -12651,12 +13093,49 @@ "node": ">= 12.13.0" } }, + "node_modules/refa": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/refa/-/refa-0.12.1.tgz", + "integrity": "sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.8.0" + }, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", "license": "Apache-2.0" }, + "node_modules/regexp-ast-analysis": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/regexp-ast-analysis/-/regexp-ast-analysis-0.7.1.tgz", + "integrity": "sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.8.0", + "refa": "^0.12.1" + }, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/regexp-tree": { + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", + "integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==", + "dev": true, + "license": "MIT", + "bin": { + "regexp-tree": "bin/regexp-tree" + } + }, "node_modules/release-it": { "version": "21.0.2", "resolved": "https://registry.npmjs.org/release-it/-/release-it-21.0.2.tgz", @@ -12804,6 +13283,16 @@ "node": ">=4" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -12919,6 +13408,16 @@ ], "license": "MIT" }, + "node_modules/safe-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-2.1.1.tgz", + "integrity": "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "regexp-tree": "~0.1.1" + } + }, "node_modules/safe-regex2": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", @@ -12963,6 +13462,21 @@ "license": "MIT", "peer": true }, + "node_modules/scslre": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/scslre/-/scslre-0.3.0.tgz", + "integrity": "sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.8.0", + "refa": "^0.12.0", + "regexp-ast-analysis": "^0.7.0" + }, + "engines": { + "node": "^14.0.0 || >=16.0.0" + } + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -13311,6 +13825,16 @@ "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" } }, + "node_modules/stable-hash-x": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz", + "integrity": "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -13578,7 +14102,6 @@ "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" }, @@ -14127,16 +14650,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.0.tgz", - "integrity": "sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.1.tgz", + "integrity": "sha512-AcWG7KDjZ2THNXsgwttMaGmzVi0VFRlFYfqFHYQRbDpF3owuYbuiL8c7UUrd2k8s3PoSfIQrWfrGXfcElrWLYA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.70.0", - "@typescript-eslint/parser": "8.70.0", - "@typescript-eslint/typescript-estree": "8.70.0", - "@typescript-eslint/utils": "8.70.0" + "@typescript-eslint/eslint-plugin": "8.70.1", + "@typescript-eslint/parser": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1", + "@typescript-eslint/utils": "8.70.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -14832,6 +15355,22 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "17.7.3", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", diff --git a/package.json b/package.json index d849ebf..9ef6d3c 100644 --- a/package.json +++ b/package.json @@ -11,14 +11,14 @@ "scripts": { "build": "prisma generate && nest build", "openapi:generate": "npm run build && node scripts/generate-openapi.cjs", - "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "format": "npm run lint:fix", "start": "nest start", "start:dev": "NODE_ENV=development nest start --watch --path tsconfig.dev.json", "start:debug": "nest start --debug --watch --path tsconfig.dev.json", "start:prod": "node dist/src/main.js", "lint": "npm run lint:check", - "lint:check": "eslint \"{src,apps,libs,test}/**/*.ts\" --max-warnings=0", - "lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "lint:check": "eslint . --max-warnings=0", + "lint:fix": "eslint . --fix --max-warnings=0", "typecheck:app": "tsc --project tsconfig.typecheck.app.json", "typecheck:test": "tsc --project tsconfig.typecheck.test.json", "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", @@ -30,10 +30,11 @@ "test:container": "node test/container-smoke.cjs", "test:durability": "node test/bank-cards-durability.cjs", "test:openapi-contract": "npm run build && node test/sqlite.cjs openapi-contract.e2e-spec.ts", - "verify": "npm run lint && npm run typecheck:app && npm run typecheck:test && npm run test:cov -- --runInBand && npm run build", + "verify": "npm run lint && npm run test:lint && npm run typecheck:app && npm run typecheck:test && npm run test:cov -- --runInBand && npm run build", "release:preview": "release-it --dry-run --ci", "release:prepare": "release-it --ci", - "commitlint": "commitlint" + "commitlint": "commitlint", + "test:lint": "node --test test/eslint-config.cjs" }, "dependencies": { "@grpc/grpc-js": "1.14.4", @@ -53,6 +54,7 @@ "class-transformer": "0.5.1", "class-validator": "0.15.1", "cookie-parser": "1.4.7", + "dotenv": "17.4.2", "lru-cache": "11.5.2", "nestjs-pino": "5.1.0", "passport": "0.7.0", @@ -65,12 +67,13 @@ "devDependencies": { "@commitlint/cli": "21.2.2", "@commitlint/config-conventional": "21.2.2", - "@eslint/eslintrc": "3.3.7", + "@darraghor/eslint-plugin-nestjs-typed": "7.5.6", "@eslint/js": "10.0.1", "@nestjs/cli": "12.0.0", "@nestjs/schematics": "12.0.1", "@nestjs/testing": "12.0.1", "@release-it/conventional-changelog": "12.0.0", + "@stylistic/eslint-plugin": "5.10.0", "@types/bcrypt": "6.0.0", "@types/cookie-parser": "1.4.10", "@types/express": "5.0.6", @@ -79,12 +82,17 @@ "@types/passport-jwt": "4.0.1", "@types/supertest": "7.2.1", "conventional-changelog-conventionalcommits": "10.4.0", - "eslint": "10.10.0", - "eslint-config-prettier": "10.1.8", - "eslint-plugin-prettier": "5.5.6", + "eslint": "10.11.0", + "eslint-import-resolver-typescript": "4.4.5", + "eslint-plugin-import-x": "4.17.1", + "eslint-plugin-jest": "29.16.6", + "eslint-plugin-n": "18.4.0", + "eslint-plugin-perfectionist": "5.12.1", + "eslint-plugin-regexp": "3.3.1", + "eslint-plugin-security": "4.1.0", + "eslint-plugin-sonarjs": "4.2.1", "globals": "17.12.0", "jest": "30.5.1", - "prettier": "3.9.6", "release-it": "21.0.2", "source-map-support": "0.5.21", "supertest": "7.2.2", @@ -93,7 +101,7 @@ "ts-node": "10.9.2", "tsconfig-paths": "4.2.0", "typescript": "6.0.3", - "typescript-eslint": "8.70.0" + "typescript-eslint": "8.70.1" }, "overrides": { "@nestjs/platform-express": { diff --git a/scripts/check-release.cjs b/scripts/check-release.cjs index a2c957b..2a6c2b3 100644 --- a/scripts/check-release.cjs +++ b/scripts/check-release.cjs @@ -1,10 +1,12 @@ -const assert = require("node:assert/strict"); -const { execFileSync } = require("node:child_process"); -const { version } = require("../package.json"); -const git = (...args) => execFileSync("git", args, { encoding: "utf8" }).trim(); +const assert = require('node:assert/strict'); +const { execFileSync } = require('node:child_process'); -assert.equal(git("status", "--porcelain"), "", "Commit all changes before preparing a release"); -const tags = git("tag", "--merged", "HEAD", "--list", "v[0-9]*", "--sort=-version:refname") - .split("\n").filter(tag => /^v\d+\.\d+\.\d+$/.test(tag)); -assert.equal(version, tags[0]?.slice(1) ?? "0.0.0", - "Version is already prepared or release tags are missing. Fetch tags; finish the existing release before preparing another."); +const { version } = require('../package.json'); +// eslint-disable-next-line sonarjs/no-os-command-from-path -- Developer-only command uses the operator PATH and fixed executable/arguments; no request input. +const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim(); + +assert.equal(git('status', '--porcelain'), '', 'Commit all changes before preparing a release'); +const tags = git('tag', '--merged', 'HEAD', '--list', 'v[0-9]*', '--sort=-version:refname') + .split('\n').filter((tag) => /^v\d+\.\d+\.\d+$/.test(tag)); +assert.equal(version, tags[0]?.slice(1) ?? '0.0.0', + 'Version is already prepared or release tags are missing. Fetch tags; finish the existing release before preparing another.'); diff --git a/scripts/generate-openapi.cjs b/scripts/generate-openapi.cjs index 3845d46..ad8b646 100644 --- a/scripts/generate-openapi.cjs +++ b/scripts/generate-openapi.cjs @@ -15,6 +15,7 @@ Object.assign(process.env, { }); const { NestFactory } = require('@nestjs/core'); + const { AppModule } = require('../dist/src/app.module.js'); const { API_PREFIX, createOpenApiDocument } = require('../dist/src/openapi.js'); diff --git a/src/app.module.ts b/src/app.module.ts index a5e7443..02a572d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,8 +1,8 @@ -import { TbankSyncModule } from './modules/investments/tbank-sync/tbank-sync.module'; import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { LoggerModule } from 'nestjs-pino'; import { randomUUID } from 'node:crypto'; + import { LoggingModule } from './common/logging/logging.module'; import { Environment, validateEnv } from './config/env.validation'; import { AuthModule } from './modules/auth/auth.module'; @@ -13,6 +13,7 @@ import { InstrumentsModule } from './modules/investments/instruments/instruments import { OperationsModule } from './modules/investments/operations/operations.module'; import { PortfolioModule } from './modules/investments/portfolio/portfolio.module'; import { TbankConnectionModule } from './modules/investments/tbank-connection/tbank-connection.module'; +import { TbankSyncModule } from './modules/investments/tbank-sync/tbank-sync.module'; import { UpcomingEventsModule } from './modules/investments/upcoming-events/upcoming-events.module'; import { PrismaModule } from './prisma/prisma.module'; diff --git a/src/common/decimal.ts b/src/common/decimal.ts index 998e7a5..9f969b6 100644 --- a/src/common/decimal.ts +++ b/src/common/decimal.ts @@ -1,8 +1,10 @@ -export type Decimal = { value: bigint; scale: number }; +export type Decimal = { scale: number; value: bigint }; export function decimalFromString(value: string): Decimal { - if (!/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(value)) + // eslint-disable-next-line security/detect-unsafe-regex -- Anchored decimal grammar: the literal decimal point separates disjoint digit runs; backtracking is linear. + if (!/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(value)) { throw new Error('Invalid decimal string'); + } const negative = value.startsWith('-'); const [whole, fraction = ''] = (negative ? value.slice(1) : value).split('.'); return { @@ -46,8 +48,12 @@ export function formatDecimal({ value, scale }: Decimal): string { .toString() .padStart(scale + 1, '0'); const whole = digits.slice(0, -scale) || '0'; - const fraction = digits.slice(-scale).replace(/0+$/, ''); - return `${sign}${whole}${fraction ? `.${fraction}` : ''}`; + const rawFraction = digits.slice(-scale); + let fractionEnd = rawFraction.length; + while (rawFraction.charAt(fractionEnd - 1) === '0') fractionEnd -= 1; + const fraction = rawFraction.slice(0, fractionEnd); + const fractionalPart = fraction ? '.' + fraction : ''; + return `${sign}${whole}${fractionalPart}`; } // Round half away from zero; callers choose presentation precision explicitly. @@ -55,7 +61,7 @@ export function divide(left: Decimal, right: Decimal, scale: number): Decimal { if (right.value === 0n) throw new Error('Division by zero'); const numerator = left.value * 10n ** BigInt(right.scale + scale); const denominator = right.value * 10n ** BigInt(left.scale); - const sign = numerator < 0n !== denominator < 0n ? -1n : 1n; + const sign = (numerator < 0n) !== (denominator < 0n) ? -1n : 1n; const n = numerator < 0n ? -numerator : numerator; const d = denominator < 0n ? -denominator : denominator; return { value: sign * ((n + d / 2n) / d), scale }; diff --git a/src/common/decorators/api-safe-error-response.decorator.ts b/src/common/decorators/api-safe-error-response.decorator.ts index d9f8947..81300b4 100644 --- a/src/common/decorators/api-safe-error-response.decorator.ts +++ b/src/common/decorators/api-safe-error-response.decorator.ts @@ -1,7 +1,8 @@ -import { HttpStatus, applyDecorators } from '@nestjs/common'; +import { applyDecorators, HttpStatus } from '@nestjs/common'; import { ApiExtraModels, ApiProperty, + ApiPropertyOptional, ApiResponse, getSchemaPath, } from '@nestjs/swagger'; @@ -13,8 +14,8 @@ export class ProblemDetailsDto { @ApiProperty({ example: 'UNAUTHORIZED' }) declare code: string; - @ApiProperty({ - required: false, + // eslint-disable-next-line @darraghor/nestjs-typed/api-property-returning-array-should-set-array -- Raw OpenAPI items schema already declares type: array. + @ApiPropertyOptional({ type: 'array', items: { type: 'object', @@ -23,7 +24,7 @@ export class ProblemDetailsDto { }, example: [{ pointer: '#/email', detail: 'must be a valid email address' }], }) - errors?: { pointer: string; detail: string }[]; + errors?: { detail: string; pointer: string }[]; } function problemResponse(status: number, description: string) { @@ -38,7 +39,7 @@ function problemResponse(status: number, description: string) { }); } -export function ApiProblemResponses(): MethodDecorator & ClassDecorator { +export function ApiProblemResponses(): ClassDecorator & MethodDecorator { return applyDecorators( ApiExtraModels(ProblemDetailsDto), problemResponse(HttpStatus.BAD_REQUEST, 'Invalid request'), diff --git a/src/common/dto-validation.spec.ts b/src/common/dto-validation.spec.ts index 93016de..942fd3d 100644 --- a/src/common/dto-validation.spec.ts +++ b/src/common/dto-validation.spec.ts @@ -1,4 +1,5 @@ import { BadRequestException, ValidationPipe } from '@nestjs/common'; + import { CredentialsDto } from '../modules/auth/presentation/auth.dto'; import { CreateBankCardDto, @@ -6,7 +7,8 @@ import { } from '../modules/banking/bank-cards/presentation/bank-card.dto'; describe('DTO validation', () => { - const pipe = new ValidationPipe({ transform: true, whitelist: true }); + const pipe = new ValidationPipe({ + forbidUnknownValues: true, transform: true, whitelist: true }); it('requires credentials and normalizes a valid email', async () => { const metadata = { type: 'body' as const, metatype: CredentialsDto }; diff --git a/src/common/filters/http-exception.filter.spec.ts b/src/common/filters/http-exception.filter.spec.ts index 0a10828..231cddb 100644 --- a/src/common/filters/http-exception.filter.spec.ts +++ b/src/common/filters/http-exception.filter.spec.ts @@ -1,4 +1,5 @@ import { ArgumentsHost } from '@nestjs/common'; + import { HttpExceptionFilter } from './http-exception.filter'; describe('HttpExceptionFilter', () => { diff --git a/src/common/filters/http-exception.filter.ts b/src/common/filters/http-exception.filter.ts index 0703a83..1e715f7 100644 --- a/src/common/filters/http-exception.filter.ts +++ b/src/common/filters/http-exception.filter.ts @@ -1,3 +1,5 @@ +import type { Response } from 'express'; + import { ArgumentsHost, Catch, @@ -6,7 +8,6 @@ import { HttpStatus, ValidationError, } from '@nestjs/common'; -import type { Response } from 'express'; @Catch() export class HttpExceptionFilter implements ExceptionFilter { @@ -14,32 +15,33 @@ export class HttpExceptionFilter implements ExceptionFilter { const context = host.switchToHttp(); const response = context.getResponse(); const status = - exception instanceof HttpException - ? exception.getStatus() - : HttpStatus.INTERNAL_SERVER_ERROR; + exception instanceof HttpException ? + exception.getStatus() : + HttpStatus.INTERNAL_SERVER_ERROR; const payload = exception instanceof HttpException ? exception.getResponse() : undefined; const isValidation = Array.isArray( (payload as { message?: unknown })?.message, ); - const code = isValidation - ? 'VALIDATION_ERROR' - : this.codeFor(status, payload); - (response.locals ??= {}).publicErrorCode = code; + const code = isValidation ? + 'VALIDATION_ERROR' : + this.codeFor(status, payload); + response.locals ??= {}; + response.locals.publicErrorCode = code; response .status(status) .type('application/problem+json') .json({ status, code, - ...(isValidation - ? { + ...(isValidation ? + { errors: this.validationErrors( (payload as { message: ValidationError[] }).message, ), - } - : {}), + } : + {}), }); } @@ -49,6 +51,7 @@ export class HttpExceptionFilter implements ExceptionFilter { if (typeof code === 'string') return code; } return ( + // eslint-disable-next-line security/detect-object-injection -- The lookup key is a numeric HTTP status, never a property name from the request. { 400: 'BAD_REQUEST', 401: 'UNAUTHORIZED', @@ -62,7 +65,7 @@ export class HttpExceptionFilter implements ExceptionFilter { private validationErrors( errors: ValidationError[], prefix = '#', - ): { pointer: string; detail: string }[] { + ): { detail: string; pointer: string }[] { return errors.flatMap((error) => { if (!error || typeof error.property !== 'string') return []; const pointer = `${prefix}/${error.property.replaceAll('~', '~0').replaceAll('/', '~1')}`; diff --git a/src/common/logging/logging.module.ts b/src/common/logging/logging.module.ts index 096d005..510d88e 100644 --- a/src/common/logging/logging.module.ts +++ b/src/common/logging/logging.module.ts @@ -1,4 +1,5 @@ import { Global, Module } from '@nestjs/common'; + import { RequestContextService } from './request-context.service'; import { RequestLoggingMiddleware } from './request-logging.middleware'; import { StructuredLogger } from './structured-logger.service'; diff --git a/src/common/logging/request-context.service.ts b/src/common/logging/request-context.service.ts index 2b25e10..828adc9 100644 --- a/src/common/logging/request-context.service.ts +++ b/src/common/logging/request-context.service.ts @@ -2,10 +2,10 @@ import { Injectable } from '@nestjs/common'; import { AsyncLocalStorage } from 'node:async_hooks'; export type RequestLogContext = { - requestId: string; - clientRequestId?: string; - userId?: string; brokerageAccountId?: string; + clientRequestId?: string; + requestId: string; + userId?: string; }; @Injectable() diff --git a/src/common/logging/request-logging.middleware.spec.ts b/src/common/logging/request-logging.middleware.spec.ts index 2c14cfa..780ab34 100644 --- a/src/common/logging/request-logging.middleware.spec.ts +++ b/src/common/logging/request-logging.middleware.spec.ts @@ -1,4 +1,5 @@ import { EventEmitter } from 'node:events'; + import { RequestLoggingMiddleware } from './request-logging.middleware'; describe('RequestLoggingMiddleware', () => { @@ -19,11 +20,11 @@ describe('RequestLoggingMiddleware', () => { const next = jest.fn(); const request: { - method: string; baseUrl: string; - route: { path: string }; header: jest.Mock; id?: string; + method: string; + route: { path: string }; } = { method: 'GET', baseUrl: '/api/v1', diff --git a/src/common/logging/request-logging.middleware.ts b/src/common/logging/request-logging.middleware.ts index cb6952c..10fcd9b 100644 --- a/src/common/logging/request-logging.middleware.ts +++ b/src/common/logging/request-logging.middleware.ts @@ -1,7 +1,9 @@ -import { constants as httpStatus } from 'node:http2'; -import { Injectable, NestMiddleware } from '@nestjs/common'; import type { NextFunction, Request, Response } from 'express'; + +import { Injectable, NestMiddleware } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; +import { constants as httpStatus } from 'node:http2'; + import { RequestContextService } from './request-context.service'; import { StructuredLogger } from './structured-logger.service'; @@ -31,26 +33,26 @@ export class RequestLoggingMiddleware implements NestMiddleware { const routePath = (routeRequest as { route?: { path?: unknown } }).route ?.path; const route = - typeof routePath === 'string' - ? `${request.baseUrl}${routePath}` - : ''; + typeof routePath === 'string' ? + `${request.baseUrl}${routePath}` : + ''; const context = { method: request.method, route, status: response.statusCode, durationMs: Math.round(performance.now() - startedAt), - ...(response.locals.publicErrorCode - ? { code: response.locals.publicErrorCode as string } - : {}), + ...(response.locals.publicErrorCode ? + { code: response.locals.publicErrorCode as string } : + {}), }; const level = - response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR - ? 'error' - : response.statusCode === httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS || - response.statusCode === httpStatus.HTTP_STATUS_UNAUTHORIZED || - response.statusCode === httpStatus.HTTP_STATUS_FORBIDDEN - ? 'warn' - : 'info'; + response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR ? + 'error' : + response.statusCode === httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS || + response.statusCode === httpStatus.HTTP_STATUS_UNAUTHORIZED || + response.statusCode === httpStatus.HTTP_STATUS_FORBIDDEN ? + 'warn' : + 'info'; this.logger.event(level, 'http.request.completed', context); }); diff --git a/src/common/logging/structured-logger.service.ts b/src/common/logging/structured-logger.service.ts index 6ec49cf..8e2fb71 100644 --- a/src/common/logging/structured-logger.service.ts +++ b/src/common/logging/structured-logger.service.ts @@ -1,27 +1,28 @@ import { Injectable, OnApplicationShutdown } from '@nestjs/common'; import { PinoLogger } from 'nestjs-pino'; + import { RequestContextService } from './request-context.service'; -type LogLevel = 'debug' | 'info' | 'warn' | 'error'; -type LogContext = Record; +type LogLevel = 'debug' | 'error' | 'info' | 'warn'; +type LogContext = Record; const allowedFields = new Set([ - 'service', - 'environment', - 'requestId', - 'clientRequestId', - 'userId', - 'brokerageAccountId', - 'method', - 'route', - 'status', - 'durationMs', - 'code', - 'operation', - 'grpcCode', 'attempts', + 'brokerageAccountId', + 'clientRequestId', + 'code', 'count', + 'durationMs', + 'environment', 'found', + 'grpcCode', + 'method', + 'operation', + 'requestId', + 'route', + 'service', + 'status', + 'userId', ]); @Injectable() @@ -48,6 +49,7 @@ export class StructuredLogger implements OnApplicationShutdown { message: string, context: LogContext = {}, ): void { + // eslint-disable-next-line security/detect-object-injection -- The log level is restricted to the internal LogLevel union. this.pino[level]( this.safeContext({ ...this.requestContext.get(), ...context }), message, diff --git a/src/common/money.dto.ts b/src/common/money.dto.ts index 87feaad..fbd29df 100644 --- a/src/common/money.dto.ts +++ b/src/common/money.dto.ts @@ -1,4 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; + import type { Money } from './money.types'; export class MoneyDto implements Money { diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index a15f0c5..d9c929b 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -7,12 +7,12 @@ export interface Environment { DATABASE_URL: string; FRONTEND_ORIGIN: string; JWT_SECRET: string; + LOG_LEVEL: 'debug' | 'error' | 'info' | 'warn'; PORT: number; - TOKEN_ENCRYPTION_KEY: string; TBANK_CA_CERT_PATH?: string; TBANK_GRPC_ENDPOINT: string; TBANK_RATE_LIMIT_PER_SECOND: number; - LOG_LEVEL: 'debug' | 'info' | 'warn' | 'error'; + TOKEN_ENCRYPTION_KEY: string; } export function validateEnv(config: Record): Environment { @@ -71,7 +71,7 @@ export function validateEnv(config: Record): Environment { if ( typeof tokenEncryptionKey !== 'string' || - !/^[A-Za-z0-9+/]+={0,2}$/.test(tokenEncryptionKey) || + !/^[A-Z0-9+/]+={0,2}$/i.test(tokenEncryptionKey) || Buffer.from(tokenEncryptionKey, 'base64').length !== TOKEN_KEY_BYTES ) { throw new Error( @@ -92,7 +92,7 @@ export function validateEnv(config: Record): Environment { throw new Error('TBANK_CA_CERT_PATH must be a file path'); } - if (!['debug', 'info', 'warn', 'error'].includes(logLevel as string)) { + if (!['debug', 'error', 'info', 'warn'].includes(logLevel as string)) { throw new Error('LOG_LEVEL must be debug, info, warn, or error'); } @@ -102,9 +102,9 @@ export function validateEnv(config: Record): Environment { JWT_SECRET: jwtSecret, PORT: parsedPort, TOKEN_ENCRYPTION_KEY: tokenEncryptionKey, - ...(typeof tbankCaCertPath === 'string' && tbankCaCertPath - ? { TBANK_CA_CERT_PATH: tbankCaCertPath } - : {}), + ...(typeof tbankCaCertPath === 'string' && tbankCaCertPath ? + { TBANK_CA_CERT_PATH: tbankCaCertPath } : + {}), TBANK_GRPC_ENDPOINT: tbankEndpoint, TBANK_RATE_LIMIT_PER_SECOND: parsedTbankRateLimit, LOG_LEVEL: logLevel as Environment['LOG_LEVEL'], diff --git a/src/integrations/moex/moex.client.spec.ts b/src/integrations/moex/moex.client.spec.ts index 46c2e58..6da226b 100644 --- a/src/integrations/moex/moex.client.spec.ts +++ b/src/integrations/moex/moex.client.spec.ts @@ -199,8 +199,9 @@ describe('MoexClient', () => { const url = new URL(input instanceof Request ? input.url : input); if (url.searchParams.get('iss.only')?.startsWith('coupons,')) { starts.push(url.searchParams.get('start') ?? ''); - if (starts.length > 1) + if (starts.length > 1) { return Promise.reject(new Error('Repeated page')); + } } return Promise.resolve( Response.json({ @@ -228,13 +229,14 @@ describe('MoexClient', () => { const start = Number(url.searchParams.get('start')); if (url.searchParams.get('iss.only')?.startsWith('coupons,')) { starts.push(String(start)); - if (starts.length > 2) + if (starts.length > 2) { return Promise.reject(new Error('Repeated page')); + } } return Promise.resolve( Response.json({ ...emptyEvents, - coupons: { + 'coupons': { columns: ['coupondate', 'value', 'faceunit'], data: [[`2099-02-0${start + 1}`, '2.000000001', 'SUR']], }, @@ -246,11 +248,13 @@ describe('MoexClient', () => { ); }); const result = new MoexClient().bondEvents('BOND'); - if (repeatIndex) + if (repeatIndex) { + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. await expect(result).rejects.toMatchObject({ response: { code: 'MOEX_UNAVAILABLE' }, }); - else + } else { + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. await expect(result).resolves.toEqual([ { type: 'coupon', @@ -265,6 +269,7 @@ describe('MoexClient', () => { currency: 'RUB', }, ]); + } expect(starts).toEqual(['0', '1']); }, ); diff --git a/src/integrations/moex/moex.client.ts b/src/integrations/moex/moex.client.ts index d00fbbf..eb6fc31 100644 --- a/src/integrations/moex/moex.client.ts +++ b/src/integrations/moex/moex.client.ts @@ -1,5 +1,5 @@ -import { constants as httpStatus } from 'node:http2'; import { Injectable, ServiceUnavailableException } from '@nestjs/common'; +import { constants as httpStatus } from 'node:http2'; const FALLBACK_PAGE_SIZE = 100; @@ -7,18 +7,18 @@ const baseUrl = 'https://iss.moex.com/iss'; const timeoutMs = 10_000; export type MoexBondEvent = - | { type: 'coupon'; date: string; value: string; currency: string } - | { type: 'maturity'; date: string; value: string; currency: string } - | { type: 'offer'; date: string }; + | { currency: string; date: string; type: 'coupon'; value: string } | + { currency: string; date: string; type: 'maturity'; value: string } | + { date: string; type: 'offer' }; type IssBlock = { columns?: unknown; data?: unknown }; type IssResponse = Record; export type MoexBondYield = { - value: string; - pricePercent: string; date: string; - dateType: string | null; + dateType: null | string; + pricePercent: string; tradeAt: string; + value: string; }; @Injectable() @@ -58,8 +58,9 @@ export class MoexClient { !Number.isFinite(Date.parse(date)) || !tradeAt || !/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(tradeAt) - ) + ) { return null; + } const tradeIso = `${tradeAt.replace(' ', 'T')}+03:00`; if (!Number.isFinite(Date.parse(tradeIso))) return null; return { @@ -70,6 +71,7 @@ export class MoexClient { tradeAt: new Date(tradeIso).toISOString(), }; } + async bondEvents(secid: string): Promise { const path = `securities/${encodeURIComponent(secid)}/bondization.json`; const [coupons, offers, description] = await Promise.all([ @@ -86,8 +88,9 @@ export class MoexClient { const date = text(row.coupondate); const value = numberText(row.value); const currency = currencyText(row.faceunit); - if (date && value && currency) + if (date && value && currency) { events.push({ type: 'coupon', date, value, currency }); + } } for (const row of offers) { const date = text(row.offerdate); @@ -99,8 +102,9 @@ export class MoexClient { const date = values.get('MATDATE'); const value = numberText(values.get('FACEVALUE')); const currency = currencyText(values.get('FACEUNIT')); - if (date && value && currency) + if (date && value && currency) { events.push({ type: 'maturity', date, value, currency }); + } return events; } @@ -115,10 +119,11 @@ export class MoexClient { const query = new URLSearchParams({ 'iss.meta': 'off', 'iss.only': `${block},${block}.cursor`, - start: String(start), + 'start': String(start), }); const response = await this.request(`${baseUrl}/${path}?${query}`); if (!response) return rows; + // eslint-disable-next-line security/detect-object-injection -- Keys are fixed MOEX block names; row indexes come from the validated columns array. const current = parseRows(response[block]); rows.push(...current); if (!paginated) return rows; @@ -140,8 +145,9 @@ export class MoexClient { pageSize === null || pageSize === 0 || current.length !== Math.min(pageSize, total - start) - ) + ) { throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); + } if (index + pageSize >= total) return rows; start = index + pageSize; } @@ -155,8 +161,9 @@ export class MoexClient { if (response.status === httpStatus.HTTP_STATUS_NOT_FOUND) return null; if (!response.ok) throw new Error(`MOEX HTTP ${response.status}`); const body: unknown = await response.json(); - if (!body || typeof body !== 'object') + if (!body || typeof body !== 'object') { throw new Error('MOEX malformed response'); + } return body as IssResponse; } catch { throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); @@ -165,35 +172,40 @@ export class MoexClient { } function parseRows(block: IssBlock | undefined): Record[] { - if (!Array.isArray(block?.columns) || !Array.isArray(block?.data)) + if (!Array.isArray(block?.columns) || !Array.isArray(block?.data)) { throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); + } const columns = block.columns; - if (!columns.every((column) => typeof column === 'string')) + if (!columns.every((column) => typeof column === 'string')) { throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); + } return block.data.map((values) => { - if (!Array.isArray(values) || values.length !== columns.length) + if (!Array.isArray(values) || values.length !== columns.length) { throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); + } return Object.fromEntries( + // eslint-disable-next-line security/detect-object-injection -- Keys are fixed MOEX block names; row indexes come from the validated columns array. columns.map((column, index) => [column.toLowerCase(), values[index]]), ); }); } -function text(value: unknown): string | null { +function text(value: unknown): null | string { return typeof value === 'string' && value.trim() ? value.trim() : null; } -function numberText(value: unknown): string | null { +function numberText(value: unknown): null | string { const result = typeof value === 'number' ? String(value) : text(value); + // eslint-disable-next-line security/detect-unsafe-regex -- Anchored decimal grammar: the literal decimal point separates disjoint digit runs; backtracking is linear. return result && /^-?\d+(?:\.\d+)?$/.test(result) ? result : null; } -function currencyText(value: unknown): string | null { +function currencyText(value: unknown): null | string { const result = text(value)?.toUpperCase(); return result === 'SUR' ? 'RUB' : (result ?? null); } -function integer(value: unknown): number | null { +function integer(value: unknown): null | number { const result = Number(numberText(value) ?? NaN); return Number.isSafeInteger(result) && result >= 0 ? result : null; } diff --git a/src/integrations/moex/moex.module.ts b/src/integrations/moex/moex.module.ts index 9b20ab1..74e6327 100644 --- a/src/integrations/moex/moex.module.ts +++ b/src/integrations/moex/moex.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; + import { MoexClient } from './moex.client'; @Module({ diff --git a/src/integrations/ncr/ncr-ratings.client.spec.ts b/src/integrations/ncr/ncr-ratings.client.spec.ts index 2ed3cbf..870da53 100644 --- a/src/integrations/ncr/ncr-ratings.client.spec.ts +++ b/src/integrations/ncr/ncr-ratings.client.spec.ts @@ -1,4 +1,5 @@ import { ServiceUnavailableException } from '@nestjs/common'; + import { NcrRatingsClient } from './ncr-ratings.client'; const isin = 'RU000A10EW44'; const row = (value = 'AAA.ru', code = isin, href = '/ratings/issues/123/') => @@ -65,6 +66,18 @@ describe('public NCR issue ratings', () => { ); }, ); + it.each(['AA+.ru', 'B-.ru', 'BBB+.ru', 'CCC-.ru', 'CC.ru', 'D.ru'])( + 'accepts the public rating grammar: %s', async (value) => { + jest.spyOn(global, 'fetch').mockResolvedValue(response(table(row(value)))); + expect(await new NcrRatingsClient().rating(isin)).toMatchObject({ value }); + }, + ); + it.each(['AAA+.ru', 'CC-.ru', 'BBBB.ru', 'BBB++.ru'])( + 'rejects invalid rating grades: %s', async (value) => { + jest.spyOn(global, 'fetch').mockResolvedValue(response(table(row(value)))); + await expect(new NcrRatingsClient().rating(isin)).rejects.toBeInstanceOf(ServiceUnavailableException); + }, + ); it('bounds outage retries then recovers without retaining a false missing rating', async () => { const fetchMock = jest .spyOn(global, 'fetch') diff --git a/src/integrations/ncr/ncr-ratings.client.ts b/src/integrations/ncr/ncr-ratings.client.ts index ff3e47d..16eea7a 100644 --- a/src/integrations/ncr/ncr-ratings.client.ts +++ b/src/integrations/ncr/ncr-ratings.client.ts @@ -11,11 +11,11 @@ const DATE_COLUMN = 5; const registryUrl = 'https://ratings.ru/ratings/issues/'; export type NcrRating = { agency: 'NCR'; - value: string | null; - status: 'active' | 'withdrawn'; date: string; - sourceUrl: string; observedAt: string; + sourceUrl: string; + status: 'active' | 'withdrawn'; + value: null | string; }; @Injectable() @@ -33,8 +33,9 @@ export class NcrRatingsClient { if (!pending) { pending = this.registry().catch(() => { // Bound outage traffic too; never present a failed download as no rating. - if (this.cache.get('registry') === pending) + if (this.cache.get('registry') === pending) { this.cache.set('registry', pending, { ttl: 60_000 }); + } throw new ServiceUnavailableException({ code: 'NCR_UNAVAILABLE' }); }); this.cache.set('registry', pending); @@ -57,10 +58,13 @@ export class NcrRatingsClient { table.length !== 1 || headings.length !== COLUMN_COUNT || headings[2] !== 'Рейтинг' || + // eslint-disable-next-line security/detect-object-injection -- Column indexes are local numeric constants. headings[ISIN_COLUMN] !== 'ISIN' || + // eslint-disable-next-line security/detect-object-injection -- Column indexes are local numeric constants. headings[DATE_COLUMN] !== 'Дата' - ) + ) { throw new Error('NCR registry changed'); + } const observedAt = new Date().toISOString(); const result = new Map(); table.find('tbody tr').each((_, row) => { @@ -73,23 +77,26 @@ export class NcrRatingsClient { const withdrawn = raw.toLowerCase() === 'рейтинг отозван'; if ( !withdrawn && - !/^(AAA|AA[+-]?|A[+-]?|BBB[+-]?|BB[+-]?|B[+-]?|CCC[+-]?|CC|C|D)\.ru$/.test( + !/^(?:AAA|A{1,2}[+-]?|B{1,3}[+-]?|CCC[+-]?|C{1,2}|D)\.ru$/.test( raw, ) - ) + ) { throw new Error('NCR rating changed'); + } const dateParts = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec( cells.eq(DATE_COLUMN).text().trim(), ); const href = cells.eq(ISIN_COLUMN).find('a').attr('href'); - if (!dateParts || !href || !/^\/ratings\/issues\/\d+\/$/.test(href)) + if (!dateParts || !href || !/^\/ratings\/issues\/\d+\/$/.test(href)) { throw new Error('NCR identity missing'); + } const date = `${dateParts[3]}-${dateParts[2]}-${dateParts[1]}`; if ( !Number.isFinite(Date.parse(date)) || new Date(date).toISOString().slice(0, 10) !== date - ) + ) { throw new Error('NCR date invalid'); + } const rating: NcrRating = { agency: 'NCR', value: withdrawn ? null : raw, @@ -103,8 +110,9 @@ export class NcrRatingsClient { previous && previous.date === date && (previous.value !== rating.value || previous.status !== rating.status) - ) + ) { throw new Error('NCR rating ambiguous'); + } if (!previous || previous.date < date) result.set(isin, rating); }); if (!result.size) throw new Error('NCR registry empty'); diff --git a/src/integrations/ncr/ncr.module.ts b/src/integrations/ncr/ncr.module.ts index ae77a97..ee8bd4a 100644 --- a/src/integrations/ncr/ncr.module.ts +++ b/src/integrations/ncr/ncr.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; + import { NcrRatingsClient } from './ncr-ratings.client'; @Module({ diff --git a/src/integrations/tbank/decimal.spec.ts b/src/integrations/tbank/decimal.spec.ts index 3cb5ea6..af463e1 100644 --- a/src/integrations/tbank/decimal.spec.ts +++ b/src/integrations/tbank/decimal.spec.ts @@ -21,3 +21,18 @@ describe('decimal', () => { ).toBe('1'); }); }); + +describe('decimal formatting', () => { + it.each([ + [120000n, 4, '12'], + [-120030n, 4, '-12.003'], + [1n, 4, '0.0001'], + [0n, 4, '0'], + ])('preserves value %s at scale %s while trimming only trailing zeros', (value, scale, expected) => { + expect(formatDecimal({ value, scale })).toBe(expected); + }); + it('preserves a long fraction with a trailing zero run', () => { + const significant = '1'.repeat(10_000); + expect(formatDecimal({ value: BigInt(significant + '0'.repeat(10_000)), scale: 20_000 })).toBe('0.' + significant); + }); +}); diff --git a/src/integrations/tbank/decimal.ts b/src/integrations/tbank/decimal.ts index 06ec46e..f91faa0 100644 --- a/src/integrations/tbank/decimal.ts +++ b/src/integrations/tbank/decimal.ts @@ -2,7 +2,7 @@ import { Decimal } from '../../common/decimal'; const NANO = 1_000_000_000n; -export type ProtoDecimal = { units?: string | number; nano?: string | number }; +export type ProtoDecimal = { nano?: number | string; units?: number | string }; export function decimal(value: ProtoDecimal): Decimal { return { diff --git a/src/integrations/tbank/instrument-metadata.repository.ts b/src/integrations/tbank/instrument-metadata.repository.ts index 9e66b73..ef67ad6 100644 --- a/src/integrations/tbank/instrument-metadata.repository.ts +++ b/src/integrations/tbank/instrument-metadata.repository.ts @@ -1,24 +1,26 @@ import { Injectable } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; import { isDeepStrictEqual } from 'node:util'; + import type { - Prisma, TbankInstrumentLookup as MetadataLookupRow, TbankInstrument as MetadataRow, + Prisma, } from '../../../generated/prisma/client'; -import { PrismaService } from '../../prisma/prisma.service'; import type { TbankSyncAttempt } from './tbank-access.service'; import type { TbankInstrument } from './tbank.client'; + +import { PrismaService } from '../../prisma/prisma.service'; import { tbankSourceTtlMs } from './tbank-source.policy'; export type MetadataIdentity = { - instrumentUid?: string | null; - figi?: string | null; + figi?: null | string; + instrumentUid?: null | string; }; export type MetadataLookup = { - type: 'uid' | 'figi'; + data: null | TbankInstrument; id: string; - data: TbankInstrument | null; + type: 'figi' | 'uid'; }; const provider = 'tbank'; // Keep IN lists below the SQLite/Prisma parameter limit, including fixed predicates. @@ -36,15 +38,16 @@ export class InstrumentMetadataRepository { async readMany(identities: MetadataIdentity[]) { const identifiers = identities.flatMap((identity) => [ - ...(identity.instrumentUid?.trim() - ? [{ identifierType: 'uid', identifier: identity.instrumentUid.trim() }] - : []), - ...(identity.figi?.trim() - ? [{ identifierType: 'figi', identifier: identity.figi.trim() }] - : []), + ...(identity.instrumentUid?.trim() ? + [{ identifierType: 'uid', identifier: identity.instrumentUid.trim() }] : + []), + ...(identity.figi?.trim() ? + [{ identifierType: 'figi', identifier: identity.figi.trim() }] : + []), ]); - if (!identifiers.length) + if (!identifiers.length) { return new Map>(); + } return this.prisma.$transaction(async (tx) => { const lookups: MetadataLookupRow[] = []; for (const identifierType of ['uid', 'figi']) { @@ -59,7 +62,7 @@ export class InstrumentMetadataRepository { let offset = 0; offset < values.length; offset += metadataReadBatchSize - ) + ) { lookups.push( ...(await tx.tbankInstrumentLookup.findMany({ where: { @@ -71,6 +74,7 @@ export class InstrumentMetadataRepository { }, })), ); + } } const uids = [ ...new Set(lookups.flatMap((row) => row.matchedUids as string[])), @@ -80,7 +84,7 @@ export class InstrumentMetadataRepository { let offset = 0; offset < uids.length; offset += metadataReadBatchSize - ) + ) { instruments.push( ...(await tx.tbankInstrument.findMany({ where: { @@ -89,6 +93,7 @@ export class InstrumentMetadataRepository { }, })), ); + } const byIdentifier = new Map( lookups.map((row) => [`${row.identifierType}:${row.identifier}`, row]), ); @@ -103,7 +108,7 @@ export class InstrumentMetadataRepository { } async reserve( - attemptOwner: { userId: string; revision: string }, + attemptOwner: { revision: string; userId: string }, identity: MetadataIdentity, runId: string, active: ReadonlySet, @@ -125,8 +130,9 @@ export class InstrumentMetadataRepository { previous?.state === 'running' && previous.processRunId === runId && active.has(previous.attemptId) - ) + ) { return { work: previous, launch: false }; + } const data = { attemptId: randomUUID(), processRunId: runId, @@ -183,12 +189,13 @@ export class InstrumentMetadataRepository { ['uid', uid], ['figi', figi], ] as const) { - if (id && uid && !aliases.has(`${type}:${id}`)) + if (id && uid && !aliases.has(`${type}:${id}`)) { aliases.set(`${type}:${id}`, { type, id, data: { ...result.data, uid }, }); + } } } for (const result of aliases.values()) { @@ -196,8 +203,9 @@ export class InstrumentMetadataRepository { const uid = instrument?.uid?.trim() || (result.type === 'uid' ? result.id : null); // FIGI is an alias, never a fabricated provider UID. - if (instrument && !uid) + if (instrument && !uid) { throw new Error('Metadata response has no instrument UID'); + } if (instrument && uid) { const key = { provider, uid }; const previous = await tx.tbankInstrument.findUnique({ @@ -220,9 +228,9 @@ export class InstrumentMetadataRepository { update: { ...data, dataRevision: { - increment: isDeepStrictEqual(previous?.metadata, payload) - ? 0 - : 1, + increment: isDeepStrictEqual(previous?.metadata, payload) ? + 0 : + 1, }, }, }); @@ -243,9 +251,9 @@ export class InstrumentMetadataRepository { matchedUids, fetchedAt: now, dataRevision: { - increment: isDeepStrictEqual(previous?.matchedUids, matchedUids) - ? 0 - : 1, + increment: isDeepStrictEqual(previous?.matchedUids, matchedUids) ? + 0 : + 1, }, }, }); @@ -266,7 +274,7 @@ export class InstrumentMetadataRepository { errorCode: success ? null : 'TBANK_UNAVAILABLE', }, }); - if (success) + if (success) { await this.prisma.tbankSyncWork.updateMany({ where: { ...attempt, @@ -275,6 +283,7 @@ export class InstrumentMetadataRepository { }, data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' }, }); + } } } @@ -283,7 +292,7 @@ function selectMetadata( lookups: ReadonlyMap, instruments: ReadonlyMap, ) { - const resolve = (type: string, id: string | null | undefined) => { + const resolve = (type: string, id: null | string | undefined) => { const lookup = lookups.get(`${type}:${id?.trim()}`); const uid = (lookup?.matchedUids as string[] | undefined)?.[0]; const instrument = uid ? instruments.get(uid) : undefined; @@ -307,6 +316,7 @@ function selectMetadata( complete, fresh: complete && + // eslint-disable-next-line sonarjs/different-types-comparison -- The conditional aggregation is nullable at runtime; keep the freshness guard. fetchedAt !== null && Date.now() - fetchedAt.valueOf() < tbankSourceTtlMs.metadata, fetchedAt, diff --git a/src/integrations/tbank/instrument-metadata.service.ts b/src/integrations/tbank/instrument-metadata.service.ts index 42fdf9c..ee3c4a9 100644 --- a/src/integrations/tbank/instrument-metadata.service.ts +++ b/src/integrations/tbank/instrument-metadata.service.ts @@ -1,23 +1,24 @@ import { Injectable } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; + import { StructuredLogger } from '../../common/logging/structured-logger.service'; +import { + InstrumentMetadataRepository, + type MetadataIdentity, + metadataKey, + type MetadataLookup, +} from './instrument-metadata.repository'; import { TbankAccessService, type TbankSyncAttempt, } from './tbank-access.service'; import { TbankClient, type TbankInstrument } from './tbank.client'; -import { - InstrumentMetadataRepository, - metadataKey, - type MetadataIdentity, - type MetadataLookup, -} from './instrument-metadata.repository'; export { metadataKey } from './instrument-metadata.repository'; export type { MetadataIdentity } from './instrument-metadata.repository'; type Producer = { - identity: MetadataIdentity; attempt: TbankSyncAttempt; + identity: MetadataIdentity; observers: TbankSyncAttempt[]; }; @Injectable() @@ -49,8 +50,9 @@ export class InstrumentMetadataService { const connection = await this.access.local(userId); if (revision && connection.revision !== revision) return null; const data = (await this.savedMany([identity])).get(metadataKey(identity)); - if (!observe && !data?.fresh) + if (!observe && !data?.fresh) { await this.request(userId, connection.revision, identity); + } return { ...data, work: await this.repository.work(userId, connection.revision, identity), @@ -119,7 +121,7 @@ export class InstrumentMetadataService { let useful = false; let uidFailed = false; if (identity.instrumentUid) { - let data: TbankInstrument | null | undefined; + let data: null | TbankInstrument | undefined; try { data = await this.tbank.getInstrumentByUid( token, @@ -130,29 +132,37 @@ export class InstrumentMetadataService { !identity.figi || (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') - ) + ) { throw error; + } uidFailed = true; } - if (data?.uid && data.uid !== identity.instrumentUid) + if (data?.uid && data.uid !== identity.instrumentUid) { throw new Error('Metadata UID mismatch'); - if (data !== undefined) + } + if (data !== undefined) { results.push({ type: 'uid', id: identity.instrumentUid, data }); + } useful = Boolean(data?.name?.trim() || data?.ticker?.trim()); - if (!useful && data && !identity.figi) + if (!useful && data && !identity.figi) { throw new Error('Incomplete metadata'); + } } if (!useful && identity.figi) { const data = await this.tbank.getInstrumentByFigi( token, identity.figi, ); - if (data?.figi && data.figi !== identity.figi) + if (data?.figi && data.figi !== identity.figi) { throw new Error('Metadata FIGI mismatch'); - if (data && !data.name?.trim() && !data.ticker?.trim()) + } + if (data && !data.name?.trim() && !data.ticker?.trim()) { throw new Error('Incomplete metadata'); - if (data === undefined || (uidFailed && !data)) + } + // eslint-disable-next-line sonarjs/different-types-comparison -- Defensive validation at an untrusted upstream boundary includes missing payloads. + if (data === undefined || (uidFailed && !data)) { throw new Error('Metadata source unavailable'); + } results.push({ type: 'figi', id: identity.figi, data }); // A successful FIGI response identifies its canonical UID too; // a failed UID RPC is never persisted as a negative lookup. @@ -160,8 +170,9 @@ export class InstrumentMetadataService { uidFailed && data?.uid === identity.instrumentUid && identity.instrumentUid - ) + ) { results.push({ type: 'uid', id: identity.instrumentUid, data }); + } } if ( !results.length || @@ -170,8 +181,9 @@ export class InstrumentMetadataService { (result) => result.data?.name?.trim() || result.data?.ticker?.trim(), )) - ) + ) { throw new Error('Incomplete metadata'); + } success = await this.repository.publish( attempt, results, @@ -179,12 +191,13 @@ export class InstrumentMetadataService { ); } } catch (error) { - if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') + if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') { await this.access.rejectMembershipToken(attempt); - else + } else { this.logger.event('warn', 'instrument-metadata.failed', { code: 'TBANK_UNAVAILABLE', }); + } } // Remove the producer before awaiting finalization: a new request cannot // attach to a producer whose observer list has already been drained. diff --git a/src/integrations/tbank/tbank-access.service.ts b/src/integrations/tbank/tbank-access.service.ts index 48be690..613fa1e 100644 --- a/src/integrations/tbank/tbank-access.service.ts +++ b/src/integrations/tbank/tbank-access.service.ts @@ -1,4 +1,3 @@ -import { tbankSourceNeedsRefresh } from './tbank-source.policy'; import { BadRequestException, ConflictException, @@ -6,16 +5,19 @@ import { NotFoundException, } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; -import { PrismaService } from '../../prisma/prisma.service'; -import { supportedTbankAccounts } from './tbank-account.policy'; -import { TbankTokenService } from './tbank-token.service'; + import type { TbankAccount } from './tbank.client'; +import { PrismaService } from '../../prisma/prisma.service'; +import { supportedTbankAccounts } from './tbank-account.policy'; +import { tbankSourceNeedsRefresh } from './tbank-source.policy'; +import { TbankTokenService } from './tbank-token.service'; + export type TbankSyncAttempt = { - userId: string; - revision: string; - id: string; attemptId: string; + id: string; + revision: string; + userId: string; }; @Injectable() @@ -48,8 +50,9 @@ export class TbankAccessService { connection.membershipAt, new Date(), ) - ) + ) { return null; + } const scope = { userId, revision, scopeKey: 'membership' }; const previous = await tx.tbankSyncWork.findUnique({ where: { userId_revision_scopeKey: scope }, @@ -58,8 +61,9 @@ export class TbankAccessService { previous?.state === 'running' && previous.processRunId === processRunId && active.has(previous.attemptId) - ) + ) { return { work: previous, launch: false }; + } const data = { attemptId: randomUUID(), processRunId, @@ -100,7 +104,7 @@ export class TbankAccessService { return result.count === 1; } - async tokenForAttempt(attempt: TbankSyncAttempt): Promise { + async tokenForAttempt(attempt: TbankSyncAttempt): Promise { const work = await this.prisma.tbankSyncWork.findFirst({ where: { ...attempt, @@ -119,8 +123,9 @@ export class TbankAccessService { completeWork = false, ): Promise { const supported = supportedTbankAccounts(accounts); - if (new Set(supported.map(({ id }) => id)).size !== supported.length) + if (new Set(supported.map(({ id }) => id)).size !== supported.length) { throw new Error('Duplicate T-Bank account identity'); + } return this.prisma.$transaction(async (tx) => { // Acquire the write fence before reading membership. Replacement and // attempt supersession cannot interleave with the publication below. @@ -146,6 +151,7 @@ export class TbankAccessService { const changed = previous.length !== next.length || previous.some((account, index) => { + // eslint-disable-next-line security/detect-object-injection -- The index comes from the paired account array iteration. const incoming = next[index]; return ( account.accountId !== incoming.accountId || @@ -181,11 +187,12 @@ export class TbankAccessService { membershipRevision: { increment: changed ? 1 : 0 }, }, }); - if (completeWork) + if (completeWork) { await tx.tbankSyncWork.update({ where: { id: attempt.id }, data: { state: 'complete', progress: { accounts: supported.length } }, }); + } return true; }); } @@ -224,17 +231,20 @@ export class TbankAccessService { accounts: { orderBy: { upstreamOrder: 'asc' } }, }, }); - if (!connection) + if (!connection) { throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' }); - if (connection.invalidToken) + } + if (connection.invalidToken) { throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }); + } if ( connection.membershipAt && accountId && accountId !== 'all' && !connection.accounts.some((account) => account.accountId === accountId) - ) + ) { throw new NotFoundException(); + } return connection; } } diff --git a/src/integrations/tbank/tbank-account.policy.ts b/src/integrations/tbank/tbank-account.policy.ts index 0589da5..8ce7264 100644 --- a/src/integrations/tbank/tbank-account.policy.ts +++ b/src/integrations/tbank/tbank-account.policy.ts @@ -18,8 +18,8 @@ type AccountType = keyof typeof accountTypes; export type SupportedTbankAccount = { id: string; name: string; + openedOn: null | string; type: (typeof accountTypes)[AccountType]['type']; - openedOn: string | null; }; export function supportedTbankAccounts( @@ -39,7 +39,7 @@ export function supportedTbankAccounts( }); } -function openedOn(seconds: string | number | undefined): string | null { +function openedOn(seconds: number | string | undefined): null | string { if (seconds === undefined) return null; const date = new Date(Number(seconds) * MILLISECONDS_PER_SECOND); if (Number.isNaN(date.valueOf())) return null; diff --git a/src/integrations/tbank/tbank-market.types.ts b/src/integrations/tbank/tbank-market.types.ts index 825953a..e931ccb 100644 --- a/src/integrations/tbank/tbank-market.types.ts +++ b/src/integrations/tbank/tbank-market.types.ts @@ -6,23 +6,23 @@ import type { } from './tbank.client'; export type TbankShare = TbankInstrument & { - uid?: string; - figi?: string; + apiTradeAvailableFlag?: boolean; assetUid?: string; classCode?: string; - realExchange?: string; - apiTradeAvailableFlag?: boolean; currency?: string; - shareType?: string; - instrumentKind?: string; - sector?: string; - lot?: number; + figi?: string; first_1dayCandleDate?: TbankTimestamp; + instrumentKind?: string; + lot?: number; + realExchange?: string; + sector?: string; + shareType?: string; + uid?: string; }; export type TbankCandle = { - time?: TbankTimestamp; close?: TbankDecimal; isComplete?: boolean; + time?: TbankTimestamp; }; export type TbankLastPrice = { instrumentUid?: string; @@ -32,38 +32,38 @@ export type TbankLastPrice = { export type TbankFundamentals = { assetUid?: string; currency?: string; + ebitdaTtm?: number; marketCapitalization?: number; + netDebtToEbitda?: number; + netIncomeTtm?: number; peRatioTtm?: number; priceToBookTtm?: number; revenueTtm?: number; - netIncomeTtm?: number; - ebitdaTtm?: number; roe?: number; totalDebtMrq?: number; - netDebtToEbitda?: number; }; export type TbankBond = Omit & { - nominal?: TbankMoney; - initialNominal?: TbankMoney; aciValue?: TbankMoney; - maturityDate?: TbankTimestamp; + amortizationFlag?: boolean; + bondType?: string; callDate?: TbankTimestamp; couponQuantityPerYear?: number; floatingCouponFlag?: boolean; + initialNominal?: TbankMoney; + maturityDate?: TbankTimestamp; + nominal?: TbankMoney; perpetualFlag?: boolean; - amortizationFlag?: boolean; - subordinatedFlag?: boolean; - bondType?: string; riskLevel?: string; + subordinatedFlag?: boolean; }; export type TbankCoupon = { - couponNumber?: string; couponDate?: TbankTimestamp; + couponEndDate?: TbankTimestamp; + couponNumber?: string; + couponPeriod?: number; + couponStartDate?: TbankTimestamp; + couponType?: string; fixDate?: TbankTimestamp; payOneBond?: TbankMoney; - couponType?: string; - couponStartDate?: TbankTimestamp; - couponEndDate?: TbankTimestamp; - couponPeriod?: number; }; diff --git a/src/integrations/tbank/tbank-membership.service.ts b/src/integrations/tbank/tbank-membership.service.ts index 0522c2b..784c3cc 100644 --- a/src/integrations/tbank/tbank-membership.service.ts +++ b/src/integrations/tbank/tbank-membership.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; + import { StructuredLogger } from '../../common/logging/structured-logger.service'; import { TbankAccessService, diff --git a/src/integrations/tbank/tbank-source.policy.spec.ts b/src/integrations/tbank/tbank-source.policy.spec.ts index 8952a4f..03307b9 100644 --- a/src/integrations/tbank/tbank-source.policy.spec.ts +++ b/src/integrations/tbank/tbank-source.policy.spec.ts @@ -8,6 +8,7 @@ describe('request-driven source freshness', () => { '%s expires at its boundary without changing source time', (source) => { const fetchedAt = new Date('2026-09-01Z'); + // eslint-disable-next-line security/detect-object-injection -- The key comes from the local source-policy test table. const ttl = tbankSourceTtlMs[source]; expect( tbankSourceNeedsRefresh( diff --git a/src/integrations/tbank/tbank-source.policy.ts b/src/integrations/tbank/tbank-source.policy.ts index 39e3f54..ca2d39a 100644 --- a/src/integrations/tbank/tbank-source.policy.ts +++ b/src/integrations/tbank/tbank-source.policy.ts @@ -21,6 +21,7 @@ export function tbankSourceNeedsRefresh( return ( force || !fetchedAt || + // eslint-disable-next-line security/detect-object-injection -- The key is a member of the closed TbankSource union. now.valueOf() - fetchedAt.valueOf() >= tbankSourceTtlMs[source] ); } diff --git a/src/integrations/tbank/tbank-token.service.ts b/src/integrations/tbank/tbank-token.service.ts index 2e1a9ed..e029e07 100644 --- a/src/integrations/tbank/tbank-token.service.ts +++ b/src/integrations/tbank/tbank-token.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; + import { Environment } from '../../config/env.validation'; import { TbankCrypto } from './tbank.crypto'; @@ -25,8 +26,9 @@ export class TbankTokenService { try { return await operation(); } catch (error) { - if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') + if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') { throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }); + } throw error; } } diff --git a/src/integrations/tbank/tbank.api.ts b/src/integrations/tbank/tbank.api.ts index d260155..1f297d5 100644 --- a/src/integrations/tbank/tbank.api.ts +++ b/src/integrations/tbank/tbank.api.ts @@ -1,9 +1,12 @@ // Public cross-module surface. Nest providers are exported by the matching module. export { InstrumentMetadataService } from './instrument-metadata.service'; +export { metadataKey } from './instrument-metadata.service'; +export type { MetadataIdentity } from './instrument-metadata.service'; export { TbankAccessService, type TbankSyncAttempt, } from './tbank-access.service'; +export { supportedTbankAccounts } from './tbank-account.policy'; export { type TbankBond, type TbankCandle, @@ -12,8 +15,15 @@ export { type TbankLastPrice, type TbankShare, } from './tbank-market.types'; + +export { TbankMembershipService } from './tbank-membership.service'; + +export { + tbankSourceNeedsRefresh, + tbankSourceTtlMs, +} from './tbank-source.policy'; + export { TbankTokenService } from './tbank-token.service'; -export { supportedTbankAccounts } from './tbank-account.policy'; export { TbankClient, type TbankDividend, @@ -25,13 +35,3 @@ export { type TbankPositions, type TbankTimestamp, } from './tbank.client'; - -export { TbankMembershipService } from './tbank-membership.service'; - -export { - tbankSourceTtlMs, - tbankSourceNeedsRefresh, -} from './tbank-source.policy'; - -export { metadataKey } from './instrument-metadata.service'; -export type { MetadataIdentity } from './instrument-metadata.service'; diff --git a/src/integrations/tbank/tbank.client.spec.ts b/src/integrations/tbank/tbank.client.spec.ts index 22456bc..525ab85 100644 --- a/src/integrations/tbank/tbank.client.spec.ts +++ b/src/integrations/tbank/tbank.client.spec.ts @@ -1,5 +1,6 @@ import { status } from '@grpc/grpc-js'; import { NotFoundException, ServiceUnavailableException } from '@nestjs/common'; + import { TbankClient } from './tbank.client'; describe('TbankClient operations', () => { @@ -57,6 +58,7 @@ describe('TbankClient operations', () => { const client = Object.create(TbankClient.prototype) as TbankClient; const call = jest.fn().mockResolvedValue({}); (client as unknown as { call: jest.Mock }).call = call; + // eslint-disable-next-line security/detect-object-injection -- RPC method names come from the literal test table. await client[method]('token', 'uid', '2024-02-29', '2024-02-29'); expect(call).toHaveBeenCalledWith('token', undefined, method, { instrumentId: 'uid', @@ -111,11 +113,10 @@ describe('TbankClient operations', () => { ok: true, }); const failed = invoke('token', {}, 'getPortfolio', {}); - const rejected = expect(failed).rejects.toEqual( - expect.any(ServiceUnavailableException), - ); - await jest.advanceTimersByTimeAsync(1000); - await rejected; + await Promise.all([ + expect(failed).rejects.toEqual(expect.any(ServiceUnavailableException)), + jest.advanceTimersByTimeAsync(1000), + ]); expect(logger.event).toHaveBeenCalledWith( 'debug', @@ -140,7 +141,7 @@ describe('TbankClient metadata retries', () => { const instrument = { name: 'Облигация', ticker: 'BOND' }; let client: TbankClient; let requestRpc: jest.Mock; - let queue: { size: number; run: jest.Mock }; + let queue: { run: jest.Mock; size: number }; let logger: { event: jest.Mock]>; }; @@ -234,12 +235,10 @@ describe('TbankClient metadata retries', () => { it('stops retrying a saturated queue after three admission attempts', async () => { queue.size = 50; - const result = expect( - client.getInstrumentByUid('token', 'uid'), - ).rejects.toBeInstanceOf(ServiceUnavailableException); - - await jest.advanceTimersByTimeAsync(6000); - await result; + await Promise.all([ + expect(client.getInstrumentByUid('token', 'uid')).rejects.toBeInstanceOf(ServiceUnavailableException), + jest.advanceTimersByTimeAsync(6000), + ]); expect(queue.run).not.toHaveBeenCalled(); expect(logger.event).toHaveBeenCalledTimes(3); @@ -253,12 +252,10 @@ describe('TbankClient metadata retries', () => { it('stops retrying an unavailable upstream after three RPCs', async () => { requestRpc.mockRejectedValue({ code: status.UNAVAILABLE }); - const result = expect( - client.getInstrumentByUid('token', 'uid'), - ).rejects.toBeInstanceOf(ServiceUnavailableException); - - await jest.advanceTimersByTimeAsync(6000); - await result; + await Promise.all([ + expect(client.getInstrumentByUid('token', 'uid')).rejects.toBeInstanceOf(ServiceUnavailableException), + jest.advanceTimersByTimeAsync(6000), + ]); expect(queue.run).toHaveBeenCalledTimes(3); expect(jest.getTimerCount()).toBe(0); @@ -282,6 +279,7 @@ describe('TbankClient metadata retries', () => { async (method) => { requestRpc.mockRejectedValue({ code: status.NOT_FOUND }); + // eslint-disable-next-line security/detect-object-injection -- RPC method names come from the literal test table. await expect(client[method]('token', 'instrument')).resolves.toBeNull(); expect(requestRpc).toHaveBeenCalledTimes(1); expect(jest.getTimerCount()).toBe(0); diff --git a/src/integrations/tbank/tbank.client.ts b/src/integrations/tbank/tbank.client.ts index 19376aa..902733f 100644 --- a/src/integrations/tbank/tbank.client.ts +++ b/src/integrations/tbank/tbank.client.ts @@ -1,4 +1,3 @@ -import { MILLISECONDS_PER_SECOND } from '../../common/time'; import { ChannelCredentials, Client, @@ -17,8 +16,6 @@ import { import { ConfigService } from '@nestjs/config'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { StructuredLogger } from '../../common/logging/structured-logger.service'; -import { Environment } from '../../config/env.validation'; import type { TbankBond, @@ -29,6 +26,10 @@ import type { TbankShare, } from './tbank-market.types'; +import { StructuredLogger } from '../../common/logging/structured-logger.service'; +import { MILLISECONDS_PER_SECOND } from '../../common/time'; +import { Environment } from '../../config/env.validation'; + const RATE_WINDOW_MS = 1_000; const METADATA_MAX_ATTEMPTS = 3; const RPC_MAX_ATTEMPTS = 2; @@ -42,78 +43,78 @@ type Unary = ( request: Request, metadata: Metadata, options: { deadline: Date }, - callback: (error: ServiceError | null, response: Response) => void, + callback: (error: null | ServiceError, response: Response) => void, ) => ClientUnaryCall; type TbankClientService = Client & Record>; -export type TbankDecimal = { units?: string | number; nano?: string | number }; +export type TbankDecimal = { nano?: number | string; units?: number | string }; export type TbankMoney = TbankDecimal & { currency?: string }; export type TbankAccount = { id: string; - type: string; name: string; - openedDate?: { seconds?: string | number }; + openedDate?: { seconds?: number | string }; + type: string; }; export type TbankPortfolio = { positions?: { - figi?: string; - instrumentUid?: string; - instrumentType?: string; - ticker?: string; - quantity?: TbankDecimal; - currentPrice?: TbankMoney; averagePositionPrice?: TbankMoney; + currentPrice?: TbankMoney; + figi?: string; + instrumentType?: string; + instrumentUid?: string; + quantity?: TbankDecimal; + ticker?: string; }[]; }; export type TbankPositions = { money?: TbankMoney[] }; export type TbankInstrument = { - uid?: string; + brand?: { logoName?: null | string }; figi?: string; instrumentType?: string; - ticker?: string; - name?: string; isin?: string; - brand?: { logoName?: string | null }; + name?: string; + ticker?: string; + uid?: string; }; export type TbankDividend = { dividendNet?: TbankMoney; - recordDate?: TbankTimestamp; - paymentDate?: TbankTimestamp; - lastBuyDate?: TbankTimestamp; dividendType?: string; + lastBuyDate?: TbankTimestamp; + paymentDate?: TbankTimestamp; + recordDate?: TbankTimestamp; }; -export type TbankTimestamp = { seconds?: string | number; nanos?: number }; +export type TbankTimestamp = { nanos?: number; seconds?: number | string }; export type TbankOperationItem = { - state?: string; + accruedInt?: TbankMoney; + commission?: TbankMoney; cursor?: string; - id?: string; - name?: string; date?: TbankTimestamp; - type?: string; description?: string; figi?: string; - instrumentUid?: string; + id?: string; instrumentType?: string; - ticker?: string; + instrumentUid?: string; + name?: string; payment?: TbankMoney; price?: TbankMoney; - commission?: TbankMoney; - accruedInt?: TbankMoney; - quantity?: string | number; - quantityDone?: string | number; + quantity?: number | string; + quantityDone?: number | string; + state?: string; + ticker?: string; + type?: string; }; export type TbankOperationsByCursorRequest = { accountId: string; - instrumentId?: string; - from?: TbankTimestamp; - to?: TbankTimestamp; cursor?: string; + from?: TbankTimestamp; + instrumentId?: string; limit?: number; state: 'OPERATION_STATE_EXECUTED'; + to?: TbankTimestamp; }; export type TbankOperationsByCursorResponse = { hasNext?: boolean; - nextCursor?: string; items?: TbankOperationItem[]; + nextCursor?: string; }; type TbankNamespace = { tinkoff: { @@ -197,12 +198,14 @@ export class TbankClient { loadPackageDefinition(definition) as unknown as TbankNamespace ).tinkoff.public.invest.api.contract.v1; const caPath = config.get('TBANK_CA_CERT_PATH'); - const credentials = caPath - ? ChannelCredentials.createSsl(readFileSync(caPath)) - : ChannelCredentials.createSsl(); + const credentials = caPath ? + // eslint-disable-next-line security/detect-non-literal-fs-filename -- The CA path is trusted operator configuration, never supplied by an HTTP request. + ChannelCredentials.createSsl(readFileSync(caPath)) : + ChannelCredentials.createSsl(); const endpoint = config.getOrThrow('TBANK_GRPC_ENDPOINT'); const create = (name: string) => new ( + // eslint-disable-next-line security/detect-object-injection -- Service and method names are internal RPC selections, not request input. services[name] as new ( address: string, credentials: ChannelCredentials, @@ -268,14 +271,14 @@ export class TbankClient { getInstrumentByFigi( token: string, figi: string, - ): Promise { + ): Promise { return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_FIGI', figi); } getInstrumentByUid( token: string, instrumentUid: string, - ): Promise { + ): Promise { return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_UID', instrumentUid); } @@ -320,7 +323,8 @@ export class TbankClient { }, ).then((response) => response.instruments ?? []); } - getBond(token: string, uid: string): Promise { + + getBond(token: string, uid: string): Promise { return this.call<{ instrument?: TbankBond }>( token, this.sparseInstruments, @@ -331,6 +335,7 @@ export class TbankClient { }, ).then((response) => response.instrument ?? null); } + getBondCoupons( token: string, uid: string, @@ -349,7 +354,7 @@ export class TbankClient { ).then((response) => response.events ?? []); } - getShare(token: string, uid: string): Promise { + getShare(token: string, uid: string): Promise { return this.call<{ instrument?: TbankShare }>( token, this.instruments, @@ -361,7 +366,7 @@ export class TbankClient { ).then((response) => response.instrument ?? null); } - getAssetDescription(token: string, assetUid: string): Promise { + getAssetDescription(token: string, assetUid: string): Promise { return this.call<{ asset?: { description?: string } }>( token, this.instruments, @@ -375,7 +380,7 @@ export class TbankClient { getFundamentals( token: string, assetUid: string, - ): Promise { + ): Promise { return this.call<{ fundamentals?: TbankFundamentals[] }>( token, this.sparseInstruments, @@ -399,13 +404,13 @@ export class TbankClient { ).then((response) => response.lastPrices ?? []); } - getLastPrice(token: string, uid: string): Promise { + getLastPrice(token: string, uid: string): Promise { return this.getLastPrices(token, [uid]).then( (prices) => prices.find((item) => item.instrumentUid === uid) ?? null, ); } - getTradingStatus(token: string, uid: string): Promise { + getTradingStatus(token: string, uid: string): Promise { return this.call<{ tradingStatus?: string }>( token, this.marketData, @@ -442,9 +447,9 @@ export class TbankClient { private async getInstrument( token: string, - idType: 'INSTRUMENT_ID_TYPE_UID' | 'INSTRUMENT_ID_TYPE_FIGI', + idType: 'INSTRUMENT_ID_TYPE_FIGI' | 'INSTRUMENT_ID_TYPE_UID', id: string, - ): Promise { + ): Promise { try { const response = await this.call<{ instrument?: TbankInstrument }>( token, @@ -471,8 +476,9 @@ export class TbankClient { const startedAt = performance.now(); const queueFull = this.queue.size >= MAX_QUEUE_SIZE; try { - if (queueFull) + if (queueFull) { throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }); + } const response = await this.queue.run(() => this.request(token, client, method, request), ); @@ -484,15 +490,15 @@ export class TbankClient { return response; } catch (error) { const grpcCode = (error as ServiceError).code; - const failureContext = queueFull - ? { code: 'TBANK_UNAVAILABLE' } - : { grpcCode }; - const retryable = queueFull - ? metadataCall - : [ - status.UNAVAILABLE, + const failureContext = queueFull ? + { code: 'TBANK_UNAVAILABLE' } : + { grpcCode }; + const retryable = queueFull ? + metadataCall : + [ status.DEADLINE_EXCEEDED, status.RESOURCE_EXHAUSTED, + status.UNAVAILABLE, ].includes(grpcCode); if (retryable && attempts < maxAttempts) { this.logger.event('warn', 'tbank.rpc.retry', { @@ -504,9 +510,9 @@ export class TbankClient { await new Promise((resolve) => setTimeout( resolve, - metadataCall - ? attempts * METADATA_RETRY_DELAY_MS - : RPC_RETRY_DELAY_MS, + metadataCall ? + attempts * METADATA_RETRY_DELAY_MS : + RPC_RETRY_DELAY_MS, ), ); continue; @@ -518,15 +524,17 @@ export class TbankClient { durationMs: Math.round(performance.now() - startedAt), }); if ( - [status.UNAUTHENTICATED, status.PERMISSION_DENIED].includes(grpcCode) - ) + [status.PERMISSION_DENIED, status.UNAUTHENTICATED].includes(grpcCode) + ) { // eslint-disable-next-line preserve-caught-error -- Discard raw upstream authentication errors at the integration boundary. throw new Error('INVALID_TBANK_TOKEN'); + } if ( grpcCode === status.NOT_FOUND && (method === 'shareBy' || metadataCall) - ) + ) { throw new NotFoundException(); + } throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }); } } @@ -542,6 +550,7 @@ export class TbankClient { return new Promise((resolve, reject) => { const metadata = new Metadata(); metadata.set('Authorization', `Bearer ${token}`); + // eslint-disable-next-line security/detect-object-injection -- Service and method names are internal RPC selections, not request input. client[method]( request as never, metadata, diff --git a/src/integrations/tbank/tbank.crypto.ts b/src/integrations/tbank/tbank.crypto.ts index 6eebe25..a40d081 100644 --- a/src/integrations/tbank/tbank.crypto.ts +++ b/src/integrations/tbank/tbank.crypto.ts @@ -11,8 +11,9 @@ export class TbankCrypto { static fromBase64(key: string): TbankCrypto { const decoded = Buffer.from(key, 'base64'); - if (decoded.length !== KEY_SIZE_BYTES) + if (decoded.length !== KEY_SIZE_BYTES) { throw new Error('Invalid token encryption key'); + } return new TbankCrypto(decoded); } diff --git a/src/integrations/tbank/tbank.module.ts b/src/integrations/tbank/tbank.module.ts index fa7eed8..3fff10d 100644 --- a/src/integrations/tbank/tbank.module.ts +++ b/src/integrations/tbank/tbank.module.ts @@ -1,8 +1,9 @@ import { Module } from '@nestjs/common'; -import { TbankMembershipService } from './tbank-membership.service'; + import { InstrumentMetadataRepository } from './instrument-metadata.repository'; import { InstrumentMetadataService } from './instrument-metadata.service'; import { TbankAccessService } from './tbank-access.service'; +import { TbankMembershipService } from './tbank-membership.service'; import { TbankTokenService } from './tbank-token.service'; import { TbankClient } from './tbank.client'; diff --git a/src/main.ts b/src/main.ts index 61c4511..1ea1611 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,18 +3,19 @@ import { INestApplication, ValidationPipe, } from '@nestjs/common'; -import { NestFactory } from '@nestjs/core'; -import cookieParser from 'cookie-parser'; import { ConfigService } from '@nestjs/config'; +import { NestFactory } from '@nestjs/core'; import { SwaggerModule } from '@nestjs/swagger'; -import { API_PREFIX, createOpenApiDocument } from './openapi'; +import cookieParser from 'cookie-parser'; import { Logger } from 'nestjs-pino'; + import { AppModule } from './app.module'; import { HttpExceptionFilter } from './common/filters/http-exception.filter'; import { RequestLoggingMiddleware } from './common/logging/request-logging.middleware'; import { StructuredLogger } from './common/logging/structured-logger.service'; import { Environment } from './config/env.validation'; import { shouldExposeSwagger } from './config/swagger.config'; +import { API_PREFIX, createOpenApiDocument } from './openapi'; export function configureApp(app: INestApplication): void { const config = app.get>(ConfigService); @@ -32,6 +33,7 @@ export function configureApp(app: INestApplication): void { app.use(requestLogging.use.bind(requestLogging)); app.useGlobalPipes( new ValidationPipe({ + forbidUnknownValues: true, transform: true, whitelist: true, validationError: { target: false, value: false }, diff --git a/src/modules/auth/application/auth.service.spec.ts b/src/modules/auth/application/auth.service.spec.ts index df2fe1f..3e04a6d 100644 --- a/src/modules/auth/application/auth.service.spec.ts +++ b/src/modules/auth/application/auth.service.spec.ts @@ -114,8 +114,8 @@ describe('AuthService', () => { where, data, }: { - where: { id?: string; refreshTokenHash?: string }; data: { refreshTokenHash: string }; + where: { id?: string; refreshTokenHash?: string }; }) => { if (where.id === session.id && where.refreshTokenHash === currentHash) { currentHash = data.refreshTokenHash; diff --git a/src/modules/auth/application/auth.service.ts b/src/modules/auth/application/auth.service.ts index 61babc2..b2528c8 100644 --- a/src/modules/auth/application/auth.service.ts +++ b/src/modules/auth/application/auth.service.ts @@ -6,6 +6,7 @@ import { import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; import { createHash, randomUUID } from 'crypto'; + import { Session, User } from '../../../../generated/prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; import { AuthUser, RefreshPayload } from './auth.types'; @@ -16,6 +17,7 @@ const REFRESH_TTL_MS = 604_800_000; const ACCESS_TTL = '15m'; const REFRESH_TTL = '7d'; const DUMMY_PASSWORD_HASH = + // eslint-disable-next-line sonarjs/no-hardcoded-passwords -- Public dummy bcrypt hash preserves login timing for nonexistent users; it is not a credential. '$2b$12$Z49BwJsyiUqY0ZozFzt9IeYvjhV4kbeLw9KdxOguT5bfhA1RvPCx6'; @Injectable() @@ -28,12 +30,13 @@ export class AuthService { async register( email: string, password: string, - ): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { + ): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> { const existing = await this.prisma.user.findUnique({ where: { email }, }); - if (existing) + if (existing) { throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' }); + } let user: User; try { @@ -45,8 +48,9 @@ export class AuthService { }, }); } catch (error) { - if (isUniqueConstraintError(error)) + if (isUniqueConstraintError(error)) { throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' }); + } throw error; } return this.createSession(user); @@ -55,7 +59,7 @@ export class AuthService { async login( email: string, password: string, - ): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { + ): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> { const user = await this.prisma.user.findUnique({ where: { email }, }); @@ -63,14 +67,15 @@ export class AuthService { password, user?.passwordHash ?? DUMMY_PASSWORD_HASH, ); - if (!user || !valid) + if (!user || !valid) { throw new UnauthorizedException({ code: 'INVALID_CREDENTIALS' }); + } return this.createSession(user); } async refresh( token: string, - ): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { + ): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> { let payload: RefreshPayload; try { payload = await this.jwt.verifyAsync(token, { @@ -79,8 +84,9 @@ export class AuthService { } catch { throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); } - if (payload.type !== 'refresh') + if (payload.type !== 'refresh') { throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); + } const session = await this.prisma.session.findUnique({ where: { id: payload.sid }, include: { user: true }, @@ -91,12 +97,14 @@ export class AuthService { session.revokedAt || session.expiresAt <= new Date() ) { - if (session && !session.revokedAt) + if (session && !session.revokedAt) { await this.prisma.session.delete({ where: { id: session.id } }); + } throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); } - if (session.refreshTokenHash !== this.hash(token)) + if (session.refreshTokenHash !== this.hash(token)) { throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); + } return this.rotateSession(session, session.refreshTokenHash); } @@ -138,7 +146,7 @@ export class AuthService { private async createSession( user: User, - ): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { + ): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> { const session = await this.prisma.session.create({ data: { id: randomUUID(), @@ -153,7 +161,7 @@ export class AuthService { private async rotateSession( session: Session & { user: User }, expectedRefreshTokenHash?: string, - ): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { + ): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> { const refreshToken = await this.jwt.signAsync( { sub: session.userId, @@ -172,8 +180,9 @@ export class AuthService { where: { id: session.id, refreshTokenHash: expectedRefreshTokenHash }, data, }); - if (updated.count !== 1) + if (updated.count !== 1) { throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); + } } else { await this.prisma.session.update({ where: { id: session.id }, data }); } diff --git a/src/modules/auth/application/auth.types.ts b/src/modules/auth/application/auth.types.ts index cbeba59..6e5348e 100644 --- a/src/modules/auth/application/auth.types.ts +++ b/src/modules/auth/application/auth.types.ts @@ -1,17 +1,17 @@ export interface AuthUser { - id: string; email: string; + id: string; } export interface AccessPayload { - sub: string; sid: string; + sub: string; type: 'access'; } export interface RefreshPayload { - sub: string; - sid: string; - type: 'refresh'; jti: string; + sid: string; + sub: string; + type: 'refresh'; } diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 9fa5687..1d93f41 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -2,11 +2,12 @@ import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; + import { Environment } from '../../config/env.validation'; -import { AuthController } from './presentation/auth.controller'; import { AuthService } from './application/auth.service'; -import { JwtAuthGuard } from './presentation/jwt-auth.guard'; import { JwtStrategy } from './infrastructure/jwt.strategy'; +import { AuthController } from './presentation/auth.controller'; +import { JwtAuthGuard } from './presentation/jwt-auth.guard'; @Module({ imports: [ diff --git a/src/modules/auth/infrastructure/jwt.strategy.ts b/src/modules/auth/infrastructure/jwt.strategy.ts index 1f97b0e..96110de 100644 --- a/src/modules/auth/infrastructure/jwt.strategy.ts +++ b/src/modules/auth/infrastructure/jwt.strategy.ts @@ -2,6 +2,7 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; + import { Environment } from '../../../config/env.validation'; import { AccessPayload } from '../application/auth.types'; @@ -17,8 +18,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) { } validate(payload: AccessPayload): AccessPayload { - if (payload.type !== 'access') + if (payload.type !== 'access') { throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); + } return payload; } } diff --git a/src/modules/auth/presentation/auth.controller.ts b/src/modules/auth/presentation/auth.controller.ts index 45a35dc..c169f0e 100644 --- a/src/modules/auth/presentation/auth.controller.ts +++ b/src/modules/auth/presentation/auth.controller.ts @@ -1,3 +1,5 @@ +import type { Request, Response } from 'express'; + import { Body, Controller, @@ -17,11 +19,11 @@ import { ApiProperty, ApiTags, } from '@nestjs/swagger'; -import type { Request, Response } from 'express'; + import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; -import { CredentialsDto } from './auth.dto'; import { AuthService } from '../application/auth.service'; import { AccessPayload } from '../application/auth.types'; +import { CredentialsDto } from './auth.dto'; import { JwtAuthGuard } from './jwt-auth.guard'; const REFRESH_COOKIE = 'refresh_token'; @@ -115,9 +117,9 @@ export class AuthController { private respond( response: Response, result: { - user: { id: string; email: string }; accessToken: string; refreshToken: string; + user: { email: string; id: string }; }, ) { response.cookie(REFRESH_COOKIE, result.refreshToken, this.cookieOptions()); @@ -129,6 +131,7 @@ export class AuthController { } private refreshToken(request: Request): string | undefined { + // eslint-disable-next-line security/detect-object-injection -- The cookie name is a fixed application constant; its value is narrowed below. const token: unknown = request.cookies?.[REFRESH_COOKIE]; return typeof token === 'string' ? token : undefined; } diff --git a/src/modules/auth/presentation/auth.dto.ts b/src/modules/auth/presentation/auth.dto.ts index b2e25ec..62fe648 100644 --- a/src/modules/auth/presentation/auth.dto.ts +++ b/src/modules/auth/presentation/auth.dto.ts @@ -28,9 +28,10 @@ class EmailConstraint { character.charCodeAt(0) < ASCII_SPACE || character.charCodeAt(0) === ASCII_DELETE, ) && - value.split('@').every(Boolean) + value.split('@').every(Boolean) ); } + defaultMessage(): string { return 'must be a valid email address'; } @@ -44,6 +45,7 @@ class PasswordBytesConstraint { Buffer.byteLength(value, 'utf8') <= MAX_PASSWORD_BYTES ); } + defaultMessage(): string { return 'must not exceed 72 UTF-8 bytes'; } diff --git a/src/modules/auth/presentation/jwt-auth.guard.ts b/src/modules/auth/presentation/jwt-auth.guard.ts index 8f0f625..7056b18 100644 --- a/src/modules/auth/presentation/jwt-auth.guard.ts +++ b/src/modules/auth/presentation/jwt-auth.guard.ts @@ -1,5 +1,6 @@ import { ExecutionContext, Injectable } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; + import { RequestContextService } from '../../../common/logging/request-context.service'; import { AccessPayload } from '../application/auth.types'; @@ -14,8 +15,9 @@ export class JwtAuthGuard extends AuthGuard('jwt') { const request = context .switchToHttp() .getRequest<{ user?: AccessPayload }>(); - if (request.user?.sub) + if (request.user?.sub) { this.requestContext.set({ userId: request.user.sub }); + } return Boolean(allowed); } } diff --git a/src/modules/banking/bank-cards/application/bank-card-input.ts b/src/modules/banking/bank-cards/application/bank-card-input.ts index e62f380..e0f3440 100644 --- a/src/modules/banking/bank-cards/application/bank-card-input.ts +++ b/src/modules/banking/bank-cards/application/bank-card-input.ts @@ -1,21 +1,22 @@ -import { - MAX_CARD_NAME_LENGTH, - MAX_OPERATION_DESCRIPTION_LENGTH, -} from '../domain/bank-card-limits'; import { BadRequestException, ConflictException, NotFoundException, } from '@nestjs/common'; import { createHash } from 'node:crypto'; + +import { + MAX_CARD_NAME_LENGTH, + MAX_OPERATION_DESCRIPTION_LENGTH, +} from '../domain/bank-card-limits'; +import { isCalendarDate, moscowToday } from '../domain/bank-card.calculations'; +import { parseRubMoney } from '../domain/rub-money'; import { bankPaymentSystems, BankPeriodQueryDto, CreateBankCardDto, CreateBankOperationDto, } from '../presentation/bank-card.dto'; -import { isCalendarDate, moscowToday } from '../domain/bank-card.calculations'; -import { parseRubMoney } from '../domain/rub-money'; export const BankErrorCode = { InvalidInput: 'BANK_INVALID_INPUT', @@ -54,8 +55,9 @@ export function periodBounds(query: BankPeriodQueryDto) { !isCalendarDate(query.from) || !isCalendarDate(query.to) || query.from > query.to - ) + ) { return fail(); + } return { from: query.from, to: query.to }; } export function conflict( @@ -93,8 +95,9 @@ export function currentDate(value: string) { if (value > moscowToday()) fail(BankErrorCode.InvalidDate); } export function name(value: string, max: number) { - if (typeof value !== 'string' || !value.trim() || value.trim().length > max) + if (typeof value !== 'string' || !value.trim() || value.trim().length > max) { fail(); + } return value.trim(); } export function cardInput(dto: CreateBankCardDto) { @@ -104,8 +107,9 @@ export function cardInput(dto: CreateBankCardDto) { if ( dto.paymentSystem != null && !bankPaymentSystems.some((system) => system === dto.paymentSystem) - ) + ) { fail(); + } return { ...(dto.paymentSystem == null ? {} : { paymentSystem: dto.paymentSystem }), name: name(dto.name, MAX_CARD_NAME_LENGTH), @@ -120,8 +124,9 @@ export function operationInput(dto: CreateBankOperationDto) { dto.description != null && (typeof dto.description !== 'string' || dto.description.length > MAX_OPERATION_DESCRIPTION_LENGTH) - ) + ) { fail(); + } return { kind: dto.kind, cardId: dto.cardId, diff --git a/src/modules/banking/bank-cards/application/bank-cards.service.ts b/src/modules/banking/bank-cards/application/bank-cards.service.ts index 356c5a0..1f5de0c 100644 --- a/src/modules/banking/bank-cards/application/bank-cards.service.ts +++ b/src/modules/banking/bank-cards/application/bank-cards.service.ts @@ -1,24 +1,24 @@ import { Injectable } from '@nestjs/common'; + import { BankCard, Prisma } from '../../../../../generated/prisma/client'; import { PrismaService } from '../../../../prisma/prisma.service'; +import { bankBalance } from '../domain/bank-card.calculations'; +import { rubMoney } from '../domain/rub-money'; +import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; import { BankCardDto, CreateBankCardDto, UpdateBankCardDto, } from '../presentation/bank-card.dto'; -import { bankBalance } from '../domain/bank-card.calculations'; -import { rubMoney } from '../domain/rub-money'; - import { - cardInput, BankErrorCode, + cardInput, conflict, currentDate, fail, fingerprint, missing, } from './bank-card-input'; -import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; type Tx = Prisma.TransactionClient; @Injectable() export class BankCardsService { @@ -26,6 +26,7 @@ export class BankCardsService { private readonly prisma: PrismaService, private readonly repository: BankCardsRepository, ) {} + private async cardView(tx: Tx, card: BankCard): Promise { const rows = await tx.bankCardOperation.findMany({ where: { @@ -47,6 +48,7 @@ export class BankCardsService { archivedAt: card.archivedAt?.toISOString() ?? null, }; } + list(userId: string, includeArchived = false) { return this.prisma.$transaction(async (tx) => Promise.all( @@ -59,11 +61,13 @@ export class BankCardsService { ), ); } + get(userId: string, id: string) { return this.prisma.$transaction(async (tx) => this.cardView(tx, await this.repository.card(tx, userId, id)), ); } + createCard(userId: string, dto: CreateBankCardDto) { const input = cardInput(dto), hash = fingerprint(input); @@ -71,8 +75,10 @@ export class BankCardsService { const existing = await tx.bankCard.findUnique({ where: { id: dto.id } }); if (existing) { if (existing.userId !== userId) missing(); - if (existing.createFingerprint !== hash) + // eslint-disable-next-line security/detect-possible-timing-attacks -- This compares an idempotency fingerprint after ownership validation, not a secret. + if (existing.createFingerprint !== hash) { conflict(BankErrorCode.IntentConflict); + } return this.cardView(tx, existing); } currentDate(input.openingDate); @@ -85,6 +91,7 @@ export class BankCardsService { ); }); } + updateCard(userId: string, id: string, dto: UpdateBankCardDto) { return this.repository.write(async (tx) => { const previous = await this.repository.card(tx, userId, id); @@ -93,17 +100,17 @@ export class BankCardsService { id, name: dto.name ?? previous.name, bankName: - dto.bankName === undefined - ? (previous.bankName ?? undefined) - : (dto.bankName ?? undefined), + dto.bankName === undefined ? + (previous.bankName ?? undefined) : + (dto.bankName ?? undefined), lastFour: - dto.lastFour === undefined - ? (previous.lastFour ?? undefined) - : (dto.lastFour ?? undefined), + dto.lastFour === undefined ? + (previous.lastFour ?? undefined) : + (dto.lastFour ?? undefined), paymentSystem: - dto.paymentSystem === undefined - ? previous.paymentSystem - : dto.paymentSystem, + dto.paymentSystem === undefined ? + previous.paymentSystem : + dto.paymentSystem, openingBalance: dto.openingBalance ?? rubMoney(previous.openingBalanceKopecks), openingDate: dto.openingDate ?? previous.openingDate, @@ -111,8 +118,9 @@ export class BankCardsService { const financial = input.openingBalanceKopecks !== previous.openingBalanceKopecks || input.openingDate !== previous.openingDate; - if (financial && previous.archivedAt) + if (financial && previous.archivedAt) { conflict(BankErrorCode.CardArchived); + } currentDate(input.openingDate); if ( await tx.bankCardOperation.findFirst({ @@ -123,20 +131,21 @@ export class BankCardsService { OR: [{ cardId: id }, { targetCardId: id }], }, }) - ) + ) { fail(BankErrorCode.OpeningDateConflict); + } const changed = await tx.bankCard.updateMany({ where: { id, userId, version: dto.version }, data: { ...input, paymentSystem: input.paymentSystem ?? null, version: { increment: 1 }, - ...(dto.archived === undefined - ? {} - : { - archivedAt: dto.archived - ? (previous.archivedAt ?? new Date()) - : null, + ...(dto.archived === undefined ? + {} : + { + archivedAt: dto.archived ? + (previous.archivedAt ?? new Date()) : + null, }), }, }); diff --git a/src/modules/banking/bank-cards/application/bank-operations.service.ts b/src/modules/banking/bank-cards/application/bank-operations.service.ts index 9b6cabd..d9dac92 100644 --- a/src/modules/banking/bank-cards/application/bank-operations.service.ts +++ b/src/modules/banking/bank-cards/application/bank-operations.service.ts @@ -1,20 +1,21 @@ -import { BankOperationKind } from '../domain/bank-operation-kind'; import { GoneException, Injectable } from '@nestjs/common'; + import { BankCardOperation, Prisma, } from '../../../../../generated/prisma/client'; import { PrismaService } from '../../../../prisma/prisma.service'; +import { BankOperationKind } from '../domain/bank-operation-kind'; +import { rubMoney } from '../domain/rub-money'; +import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; import { BankOperationDto, - DEFAULT_BANK_PAGE_SIZE, BankOperationsQueryDto, BankPageQueryDto, CreateBankOperationDto, + DEFAULT_BANK_PAGE_SIZE, UpdateBankOperationDto, } from '../presentation/bank-card.dto'; -import { rubMoney } from '../domain/rub-money'; - import { BankErrorCode, conflict, @@ -25,7 +26,6 @@ import { operationInput, periodBounds, } from './bank-card-input'; -import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; type Tx = Prisma.TransactionClient; @Injectable() export class BankOperationsService { @@ -33,34 +33,35 @@ export class BankOperationsService { private readonly prisma: PrismaService, private readonly repository: BankCardsRepository, ) {} + private async operationView( tx: Tx, row: BankCardOperation, ): Promise { const card = await this.repository.card(tx, row.userId, row.cardId); - const target = row.targetCardId - ? await this.repository.card(tx, row.userId, row.targetCardId) - : null; - const source = row.originalExpenseId - ? await this.repository.operation(tx, row.userId, row.originalExpenseId) - : null; + const target = row.targetCardId ? + await this.repository.card(tx, row.userId, row.targetCardId) : + null; + const source = row.originalExpenseId ? + await this.repository.operation(tx, row.userId, row.originalExpenseId) : + null; const categoryId = source?.categoryId ?? row.categoryId; - const category = categoryId - ? await tx.expenseCategory.findFirst({ + const category = categoryId ? + await tx.expenseCategory.findFirst({ where: { id: categoryId, userId: row.userId }, - }) - : null; + }) : + null; const refunds = - row.kind === BankOperationKind.Expense || source - ? await tx.bankCardOperation.findMany({ + row.kind === BankOperationKind.Expense || source ? + await tx.bankCardOperation.findMany({ where: { userId: row.userId, originalExpenseId: source?.id ?? row.id, deletedAt: null, ...(source ? { id: { not: row.id } } : {}), }, - }) - : []; + }) : + []; return { id: row.id, kind: row.kind as BankOperationDto['kind'], @@ -73,18 +74,19 @@ export class BankOperationsService { originalExpenseId: row.originalExpenseId, amount: rubMoney(row.amountKopecks), availableRefund: - row.kind === BankOperationKind.Expense || source - ? rubMoney( + row.kind === BankOperationKind.Expense || source ? + rubMoney( (source?.amountKopecks ?? row.amountKopecks) - - refunds.reduce((sum, r) => sum + r.amountKopecks, 0n), - ) - : null, + refunds.reduce((sum, r) => sum + r.amountKopecks, 0n), + ) : + null, date: row.date, description: row.description, version: row.version, createdAt: row.createdAt.toISOString(), }; } + private async validateOperation( tx: Tx, userId: string, @@ -102,23 +104,26 @@ export class BankOperationsService { if ( (id === input.cardId || id === input.targetCardId) && input.date < card.openingDate - ) + ) { fail(BankErrorCode.InvalidDate); + } } if ( old && (old.kind !== input.kind || old.originalExpenseId !== input.originalExpenseId) - ) + ) { fail(BankErrorCode.ImmutableOperation); + } if (input.kind === BankOperationKind.Transfer) { if ( !input.targetCardId || input.targetCardId === input.cardId || input.categoryId || input.originalExpenseId - ) + ) { fail(); + } } else if (input.targetCardId) fail(); if (input.kind === BankOperationKind.Expense) { if (input.originalExpenseId) fail(); @@ -133,11 +138,13 @@ export class BankOperationsService { !(await tx.expenseCategory.findFirst({ where: { userId, id: input.categoryId }, })) - ) + ) { missing(); + } } else if (input.categoryId) fail(); - if (input.kind !== BankOperationKind.Refund && input.originalExpenseId) + if (input.kind !== BankOperationKind.Refund && input.originalExpenseId) { fail(); + } if (input.kind === BankOperationKind.Refund) { if (!input.originalExpenseId) fail(); const source = await this.repository.operation( @@ -149,8 +156,9 @@ export class BankOperationsService { source.deletedAt || source.kind !== BankOperationKind.Expense || source.cardId !== input.cardId - ) + ) { fail(BankErrorCode.InvalidRefund); + } if ( ( await tx.bankCardOperation.updateMany({ @@ -163,8 +171,9 @@ export class BankOperationsService { data: { version: { increment: 1 } }, }) ).count !== 1 - ) + ) { conflict(); + } const refunds = await tx.bankCardOperation.findMany({ where: { userId, @@ -176,10 +185,11 @@ export class BankOperationsService { if (input.date < source.date) fail(BankErrorCode.InvalidDate); if ( refunds.reduce((sum, r) => sum + r.amountKopecks, 0n) + - input.amountKopecks > + input.amountKopecks > source.amountKopecks - ) + ) { conflict(BankErrorCode.RefundLimit); + } } if (old?.kind === BankOperationKind.Expense) { const refunds = await tx.bankCardOperation.findMany({ @@ -190,11 +200,13 @@ export class BankOperationsService { (input.cardId !== old.cardId || refunds.some((row) => row.date < input.date) || refunds.reduce((sum, row) => sum + row.amountKopecks, 0n) > - input.amountKopecks) - ) + input.amountKopecks) + ) { conflict(BankErrorCode.DependentRefunds); + } } } + createOperation(userId: string, dto: CreateBankOperationDto) { const normalized = operationInput(dto), hash = fingerprint(normalized); @@ -204,10 +216,13 @@ export class BankOperationsService { }); if (existing) { if (existing.userId !== userId) missing(); - if (existing.createFingerprint !== hash) + // eslint-disable-next-line security/detect-possible-timing-attacks -- This compares an idempotency fingerprint after ownership validation, not a secret. + if (existing.createFingerprint !== hash) { conflict(BankErrorCode.IntentConflict); - if (existing.deletedAt) + } + if (existing.deletedAt) { throw new GoneException({ code: BankErrorCode.OperationDeleted }); + } return this.operationView(tx, existing); } const input = { ...normalized }; @@ -220,6 +235,7 @@ export class BankOperationsService { ); }); } + getOperation(userId: string, id: string) { return this.prisma.$transaction(async (tx) => { const row = await this.repository.operation(tx, userId, id); @@ -227,6 +243,7 @@ export class BankOperationsService { return this.operationView(tx, row); }); } + updateOperation(userId: string, id: string, dto: UpdateBankOperationDto) { return this.repository.write(async (tx) => { const old = await this.repository.operation(tx, userId, id); @@ -252,29 +269,33 @@ export class BankOperationsService { data: { ...input, version: { increment: 1 } }, }) ).count !== 1 - ) + ) { conflict(); + } return this.operationView( tx, await this.repository.operation(tx, userId, id), ); }); } + deleteOperation(userId: string, id: string, version: number) { return this.repository.write(async (tx) => { const row = await this.repository.operation(tx, userId, id); if (row.deletedAt) return; if (row.version !== version) conflict(); await this.repository.card(tx, userId, row.cardId, true); - if (row.targetCardId) + if (row.targetCardId) { await this.repository.card(tx, userId, row.targetCardId, true); + } if ( row.kind === BankOperationKind.Expense && (await tx.bankCardOperation.findFirst({ where: { userId, originalExpenseId: id, deletedAt: null }, })) - ) + ) { conflict(BankErrorCode.DependentRefunds); + } if (row.originalExpenseId) { const source = await this.repository.operation( tx, @@ -288,8 +309,9 @@ export class BankOperationsService { data: { version: { increment: 1 } }, }) ).count !== 1 - ) + ) { conflict(); + } } if ( ( @@ -298,30 +320,32 @@ export class BankOperationsService { data: { deletedAt: new Date(), version: { increment: 1 } }, }) ).count !== 1 - ) + ) { conflict(); + } }); } + history( userId: string, id: string, query: BankOperationsQueryDto | BankPageQueryDto, expenses = false, ) { - const period = expenses - ? undefined - : periodBounds(query as BankOperationsQueryDto); + const period = expenses ? + undefined : + periodBounds(query as BankOperationsQueryDto); return this.prisma.$transaction(async (tx) => { await this.repository.card(tx, userId, id); - const page = query.page ?? 1, - limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE; + const limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE, + page = query.page ?? 1; const where: Prisma.BankCardOperationWhereInput = { userId, deletedAt: null, OR: [{ cardId: id }, { targetCardId: id }], - ...(period - ? { date: { gte: period.from, lte: period.to } } - : { kind: BankOperationKind.Expense }), + ...(period ? + { date: { gte: period.from, lte: period.to } } : + { kind: BankOperationKind.Expense }), }; const total = await tx.bankCardOperation.count({ where }); const rows = await tx.bankCardOperation.findMany({ diff --git a/src/modules/banking/bank-cards/application/expense-categories.service.ts b/src/modules/banking/bank-cards/application/expense-categories.service.ts index f110154..80f2d77 100644 --- a/src/modules/banking/bank-cards/application/expense-categories.service.ts +++ b/src/modules/banking/bank-cards/application/expense-categories.service.ts @@ -1,16 +1,17 @@ -import { MAX_CATEGORY_NAME_LENGTH } from '../domain/bank-card-limits'; import { Injectable } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; -import { PrismaService } from '../../../../prisma/prisma.service'; -import { conflict, missing, name } from './bank-card-input'; +import { PrismaService } from '../../../../prisma/prisma.service'; +import { MAX_CATEGORY_NAME_LENGTH } from '../domain/bank-card-limits'; import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; +import { conflict, missing, name } from './bank-card-input'; @Injectable() export class ExpenseCategoriesService { constructor( private readonly prisma: PrismaService, private readonly repository: BankCardsRepository, ) {} + async categories(userId: string) { return ( await this.prisma.expenseCategory.findMany({ @@ -24,6 +25,7 @@ export class ExpenseCategoriesService { version, })); } + createCategory(userId: string, value: string) { const label = name(value, MAX_CATEGORY_NAME_LENGTH); return this.repository.write(async (tx) => { @@ -44,6 +46,7 @@ export class ExpenseCategoriesService { }; }); } + updateCategory(userId: string, id: string, value: string, version: number) { const label = name(value, MAX_CATEGORY_NAME_LENGTH); return this.repository.write(async (tx) => { @@ -60,8 +63,9 @@ export class ExpenseCategoriesService { }, }) ).count !== 1 - ) + ) { conflict(); + } return { id, name: label, diff --git a/src/modules/banking/bank-cards/application/get-bank-summary.use-case.ts b/src/modules/banking/bank-cards/application/get-bank-summary.use-case.ts index 5b224fb..33fa467 100644 --- a/src/modules/banking/bank-cards/application/get-bank-summary.use-case.ts +++ b/src/modules/banking/bank-cards/application/get-bank-summary.use-case.ts @@ -1,10 +1,10 @@ import { Injectable } from '@nestjs/common'; -import { BankSummaryQueryDto } from '../presentation/bank-card.dto'; + import { bankSummary } from '../domain/bank-card.calculations'; import { rubMoney } from '../domain/rub-money'; - -import { periodBounds } from './bank-card-input'; import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; +import { BankSummaryQueryDto } from '../presentation/bank-card.dto'; +import { periodBounds } from './bank-card-input'; @Injectable() export class GetBankSummaryUseCase { constructor(private readonly repository: BankCardsRepository) {} diff --git a/src/modules/banking/bank-cards/bank-cards.module.ts b/src/modules/banking/bank-cards/bank-cards.module.ts index e34a089..2b9e006 100644 --- a/src/modules/banking/bank-cards/bank-cards.module.ts +++ b/src/modules/banking/bank-cards/bank-cards.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; + import { AuthModule } from '../../auth/auth.module'; -import { BankCardsController } from './presentation/bank-cards.controller'; -import { BankCardsRepository } from './infrastructure/bank-cards.repository'; import { BankCardsService } from './application/bank-cards.service'; import { BankOperationsService } from './application/bank-operations.service'; import { ExpenseCategoriesService } from './application/expense-categories.service'; import { GetBankSummaryUseCase } from './application/get-bank-summary.use-case'; +import { BankCardsRepository } from './infrastructure/bank-cards.repository'; +import { BankCardsController } from './presentation/bank-cards.controller'; @Module({ imports: [AuthModule], controllers: [BankCardsController], diff --git a/src/modules/banking/bank-cards/domain/bank-card.calculations.ts b/src/modules/banking/bank-cards/domain/bank-card.calculations.ts index e3a5ae6..14ce726 100644 --- a/src/modules/banking/bank-cards/domain/bank-card.calculations.ts +++ b/src/modules/banking/bank-cards/domain/bank-card.calculations.ts @@ -22,10 +22,10 @@ export function bankBalance( cardId: string, opening: bigint, operations: Array<{ - kind: string; - cardId: string; - targetCardId: string | null; amountKopecks: bigint; + cardId: string; + kind: string; + targetCardId: null | string; }>, ): bigint { // ponytail: O(N) history fold; optimize only after measuring growing histories. @@ -35,19 +35,21 @@ export function bankBalance( if ( operation.kind === BankOperationKind.Expense || operation.kind === BankOperationKind.Transfer - ) + ) { balance -= operation.amountKopecks; - else if ( + } else if ( operation.kind === BankOperationKind.Topup || operation.kind === BankOperationKind.Refund - ) + ) { balance += operation.amountKopecks; + } } if ( operation.kind === BankOperationKind.Transfer && operation.targetCardId === cardId - ) + ) { balance += operation.amountKopecks; + } } return balance; } @@ -55,17 +57,17 @@ export function bankBalance( export function bankSummary( period: { from: string; to: string }, operations: Array<{ - kind: string; - date: string; amountKopecks: bigint; - categoryId: string | null; + categoryId: null | string; + date: string; + kind: string; }>, ) { let expenses = 0n; let refunds = 0n; const categories = new Map< string, - { categoryId: string; expenses: bigint; refunds: bigint; net: bigint } + { categoryId: string; expenses: bigint; net: bigint; refunds: bigint } >(); // ponytail: O(N) history fold; optimize only after measuring growing histories. for (const operation of operations) { @@ -73,16 +75,17 @@ export function bankSummary( if ( operation.kind !== BankOperationKind.Expense && operation.kind !== BankOperationKind.Refund - ) + ) { continue; + } const expense = - operation.kind === BankOperationKind.Expense - ? operation.amountKopecks - : 0n; + operation.kind === BankOperationKind.Expense ? + operation.amountKopecks : + 0n; const refund = - operation.kind === BankOperationKind.Refund - ? operation.amountKopecks - : 0n; + operation.kind === BankOperationKind.Refund ? + operation.amountKopecks : + 0n; expenses += expense; refunds += refund; if (operation.categoryId !== null) { diff --git a/src/modules/banking/bank-cards/domain/rub-money.spec.ts b/src/modules/banking/bank-cards/domain/rub-money.spec.ts index 383c230..7c34f36 100644 --- a/src/modules/banking/bank-cards/domain/rub-money.spec.ts +++ b/src/modules/banking/bank-cards/domain/rub-money.spec.ts @@ -39,8 +39,9 @@ describe('RUB money', () => { }); it('requires positive operation amounts, including at the maximum', () => { - for (const value of ['0', '-0.00', '-0.01']) + for (const value of ['0', '-0.00', '-0.01']) { expect(() => parseRubMoney({ currency: 'RUB', value }, true)).toThrow(); + } expect(parseRubMoney({ currency: 'RUB', value: '0.01' }, true)).toBe(1n); expect( parseRubMoney({ currency: 'RUB', value: '92233720368547758.07' }, true), diff --git a/src/modules/banking/bank-cards/domain/rub-money.ts b/src/modules/banking/bank-cards/domain/rub-money.ts index 2bb1b67..3fc9ccc 100644 --- a/src/modules/banking/bank-cards/domain/rub-money.ts +++ b/src/modules/banking/bank-cards/domain/rub-money.ts @@ -1,6 +1,7 @@ -import { decimalFromString, formatDecimal } from '../../../../common/decimal'; import type { Money } from '../../../../common/money.types'; +import { decimalFromString, formatDecimal } from '../../../../common/decimal'; + const MIN_KOPECKS = -9_223_372_036_854_775_808n; const MAX_KOPECKS = 9_223_372_036_854_775_807n; @@ -9,16 +10,20 @@ export function parseRubMoney(money: Money, positive = false): bigint { money?.currency !== 'RUB' || typeof money.value !== 'string' || money.value !== money.value.trim() - ) + ) { throw new TypeError('Expected RUB money with a decimal string'); + } const amount = decimalFromString(money.value); - if (amount.scale > 2) + if (amount.scale > 2) { throw new RangeError('RUB money supports at most two decimal places'); + } const kopecks = amount.value * 10n ** BigInt(2 - amount.scale); - if (kopecks < MIN_KOPECKS || kopecks > MAX_KOPECKS) + if (kopecks < MIN_KOPECKS || kopecks > MAX_KOPECKS) { throw new RangeError('RUB money is outside the signed 64-bit range'); - if (positive && kopecks <= 0n) + } + if (positive && kopecks <= 0n) { throw new RangeError('Operation amount must be positive'); + } return kopecks; } diff --git a/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts b/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts index 48fab86..3d675ad 100644 --- a/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts +++ b/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts @@ -1,14 +1,14 @@ -import { BankOperationKind } from '../domain/bank-operation-kind'; import { Injectable } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; + import { Prisma } from '../../../../../generated/prisma/client'; import { PrismaService } from '../../../../prisma/prisma.service'; - import { BankErrorCode, conflict, missing, } from '../application/bank-card-input'; +import { BankOperationKind } from '../domain/bank-operation-kind'; type Tx = Prisma.TransactionClient; const TRANSACTION_MAX_WAIT_MS = 10_000; const TRANSACTION_TIMEOUT_MS = 15_000; @@ -36,33 +36,37 @@ export class BankCardsRepository { }); } catch (error) { const code = - error instanceof Prisma.PrismaClientKnownRequestError - ? error.code - : ''; + error instanceof Prisma.PrismaClientKnownRequestError ? + error.code : + ''; const busy = code === 'P2034' || code === 'P2028' || (error instanceof Error && /SQLITE_BUSY|database is locked/.test(error.message)); - if ((busy || code === 'P2002') && attempt < MAX_WRITE_RETRY_COUNT) + if ((busy || code === 'P2002') && attempt < MAX_WRITE_RETRY_COUNT) { continue; + } if (busy) conflict(BankErrorCode.WriteConflict); if (code === 'P2002') conflict(BankErrorCode.DuplicateCategory); throw error; } } } + async card(tx: Tx, userId: string, id: string, active = false) { const card = await tx.bankCard.findFirst({ where: { id, userId } }); if (!card) return missing(); if (active && card.archivedAt) conflict(BankErrorCode.CardArchived); return card; } + async operation(tx: Tx, userId: string, id: string) { const row = await tx.bankCardOperation.findFirst({ where: { id, userId } }); if (!row) return missing(); return row; } + async seed(tx: Tx, userId: string) { for (const [index, label] of seeds.entries()) { const seedKey = index === seeds.length - 1 ? 'other' : String(index); @@ -70,7 +74,7 @@ export class BankCardsRepository { !(await tx.expenseCategory.findUnique({ where: { userId_seedKey: { userId, seedKey } }, })) - ) + ) { await tx.expenseCategory.create({ data: { id: randomUUID(), @@ -80,8 +84,10 @@ export class BankCardsRepository { nameKey: label.toLocaleLowerCase('ru-RU'), }, }); + } } } + summaryData( userId: string, period: { from: string; to: string }, diff --git a/src/modules/banking/bank-cards/presentation/bank-card.dto.ts b/src/modules/banking/bank-cards/presentation/bank-card.dto.ts index bb23f56..dfc9ac7 100644 --- a/src/modules/banking/bank-cards/presentation/bank-card.dto.ts +++ b/src/modules/banking/bank-cards/presentation/bank-card.dto.ts @@ -1,9 +1,3 @@ -import { - MAX_CARD_NAME_LENGTH, - MAX_CATEGORY_NAME_LENGTH, - MAX_OPERATION_DESCRIPTION_LENGTH, -} from '../domain/bank-card-limits'; -import { BankOperationKind } from '../domain/bank-operation-kind'; import { ApiProperty, ApiPropertyOptional, @@ -28,7 +22,14 @@ import { ValidateIf, ValidateNested, } from 'class-validator'; + import { MoneyDto } from '../../../../common/money.dto'; +import { + MAX_CARD_NAME_LENGTH, + MAX_CATEGORY_NAME_LENGTH, + MAX_OPERATION_DESCRIPTION_LENGTH, +} from '../domain/bank-card-limits'; +import { BankOperationKind } from '../domain/bank-operation-kind'; export const DEFAULT_BANK_PAGE_SIZE = 50; const MAX_BANK_PAGE_SIZE = 100; @@ -41,6 +42,7 @@ export class RubMoneyInputDto { @ApiProperty({ enum: ['RUB'] }) @IsIn(['RUB']) declare currency: string; @ApiProperty({ pattern: '^-?[0-9]+(\\.[0-9]{1,2})?$' }) @IsString() + // eslint-disable-next-line security/detect-unsafe-regex -- Anchored decimal grammar: the literal decimal point separates disjoint digit runs; backtracking is linear. @Matches(/^-?\d+(\.\d{1,2})?$/) declare value: string; } @@ -52,6 +54,7 @@ export class CreateBankCardDto { @MinLength(1) @MaxLength(MAX_CARD_NAME_LENGTH) declare name: string; + @ApiPropertyOptional({ type: String, nullable: true, @@ -61,20 +64,24 @@ export class CreateBankCardDto { @Transform(trim) @IsString() @MaxLength(MAX_CARD_NAME_LENGTH) - bankName?: string | null; + bankName?: null | string; + @ApiPropertyOptional({ type: String, nullable: true, pattern: '^[0-9]{4}$' }) @IsOptional() - @Matches(/^[0-9]{4}$/) - lastFour?: string | null; + @Matches(/^\d{4}$/) + lastFour?: null | string; + @ApiPropertyOptional({ enum: bankPaymentSystems, nullable: true }) @IsOptional() @IsIn(bankPaymentSystems) - paymentSystem?: string | null; + paymentSystem?: null | string; + @ApiProperty({ type: RubMoneyInputDto }) @IsDefined() @ValidateNested() @Type(() => RubMoneyInputDto) declare openingBalance: MoneyDto; + @ApiProperty({ format: 'date' }) @IsString() @Matches(/^\d{4}-\d{2}-\d{2}$/) @@ -106,28 +113,34 @@ export class CreateBankOperationDto { @ApiProperty({ enum: bankOperationKinds }) @IsIn(bankOperationKinds) declare kind: BankOperationKind; + @ApiProperty({ format: 'uuid' }) @IsUUID() declare cardId: string; @ApiPropertyOptional({ format: 'uuid' }) @ValidateIf((_object, value: unknown) => value !== undefined) @IsUUID() targetCardId?: string; + @ApiPropertyOptional({ format: 'uuid' }) @ValidateIf((_object, value: unknown) => value !== undefined) @IsUUID() categoryId?: string; + @ApiPropertyOptional({ format: 'uuid' }) @ValidateIf((_object, value: unknown) => value !== undefined) @IsUUID() originalExpenseId?: string; + @ApiProperty({ type: RubMoneyInputDto }) @IsDefined() @ValidateNested() @Type(() => RubMoneyInputDto) declare amount: MoneyDto; + @ApiProperty({ format: 'date' }) @IsString() @Matches(/^\d{4}-\d{2}-\d{2}$/) declare date: string; + @ApiPropertyOptional({ maxLength: MAX_OPERATION_DESCRIPTION_LENGTH }) @ValidateIf((_object, value: unknown) => value !== undefined) @IsString() @@ -153,6 +166,7 @@ export class BankPageQueryDto { @IsInt() @Min(1) page?: number; + @ApiPropertyOptional({ minimum: 1, maximum: MAX_BANK_PAGE_SIZE, @@ -173,6 +187,7 @@ export class BankPeriodQueryDto { @IsOptional() @Matches(/^\d{4}-(0[1-9]|1[0-2])$/) month?: string; + @ApiPropertyOptional({ format: 'date', description: 'Inclusive start date; requires to.', @@ -180,6 +195,7 @@ export class BankPeriodQueryDto { @IsOptional() @Matches(/^\d{4}-\d{2}-\d{2}$/) from?: string; + @ApiPropertyOptional({ format: 'date', description: 'Inclusive end date; requires from.', @@ -215,16 +231,20 @@ export class BankCardDto { @ApiProperty({ type: String }) declare id: string; @ApiProperty({ type: String }) declare name: string; @ApiProperty({ type: String, nullable: true }) declare bankName: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare lastFour: - string | null; + null | string; + @ApiProperty({ enum: bankPaymentSystems, nullable: true }) - declare paymentSystem: string | null; + declare paymentSystem: null | string; + @ApiProperty({ type: MoneyDto }) declare openingBalance: MoneyDto; @ApiProperty({ type: String }) declare openingDate: string; @ApiProperty({ type: MoneyDto }) declare balance: MoneyDto; @ApiProperty({ type: String, nullable: true }) declare archivedAt: - string | null; + null | string; + @ApiProperty({ type: Number }) declare version: number; } export class BankOperationDto { @@ -233,27 +253,35 @@ export class BankOperationDto { @ApiProperty({ type: String }) declare cardId: string; @ApiProperty({ type: String }) declare cardName: string; @ApiProperty({ type: String, nullable: true }) declare targetCardId: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare targetCardName: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare categoryId: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare categoryName: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare originalExpenseId: - string | null; + null | string; + @ApiProperty({ type: MoneyDto }) declare amount: MoneyDto; @ApiProperty({ type: MoneyDto, nullable: true }) declare availableRefund: MoneyDto | null; + @ApiProperty({ type: String }) declare date: string; @ApiProperty({ type: String, nullable: true }) declare description: - string | null; + null | string; + @ApiProperty({ type: Number }) declare version: number; @ApiProperty({ type: String }) declare createdAt: string; } export class BankOperationsPageDto { @ApiProperty({ type: BankOperationDto, isArray: true }) declare items: BankOperationDto[]; + @ApiProperty({ type: Number }) declare page: number; @ApiProperty({ type: Number }) declare total: number; @ApiProperty({ type: Boolean }) declare hasNext: boolean; @@ -269,7 +297,7 @@ export class BankSummaryDto { @ApiPropertyOptional({ type: String }) month?: string; @ApiPropertyOptional({ format: 'date' }) from?: string; @ApiPropertyOptional({ format: 'date' }) to?: string; - @ApiProperty({ type: String, nullable: true }) declare cardId: string | null; + @ApiProperty({ type: String, nullable: true }) declare cardId: null | string; @ApiProperty({ type: MoneyDto }) declare expenses: MoneyDto; @ApiProperty({ type: MoneyDto }) declare refunds: MoneyDto; @ApiProperty({ type: MoneyDto }) declare net: MoneyDto; diff --git a/src/modules/banking/bank-cards/presentation/bank-cards.controller.ts b/src/modules/banking/bank-cards/presentation/bank-cards.controller.ts index e484e23..0a3e19a 100644 --- a/src/modules/banking/bank-cards/presentation/bank-cards.controller.ts +++ b/src/modules/banking/bank-cards/presentation/bank-cards.controller.ts @@ -1,3 +1,5 @@ +import type { Request } from 'express'; + import { Body, Controller, @@ -21,12 +23,16 @@ import { ApiTags, getSchemaPath, } from '@nestjs/swagger'; -import type { Request } from 'express'; + import { ApiProblemResponses, ProblemDetailsDto, } from '../../../../common/decorators/api-safe-error-response.decorator'; import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api'; +import { BankCardsService } from '../application/bank-cards.service'; +import { BankOperationsService } from '../application/bank-operations.service'; +import { ExpenseCategoriesService } from '../application/expense-categories.service'; +import { GetBankSummaryUseCase } from '../application/get-bank-summary.use-case'; import { BankCardDto, BankCardsQueryDto, @@ -45,10 +51,6 @@ import { UpdateBankOperationDto, UpdateExpenseCategoryDto, } from './bank-card.dto'; -import { BankCardsService } from '../application/bank-cards.service'; -import { BankOperationsService } from '../application/bank-operations.service'; -import { ExpenseCategoriesService } from '../application/expense-categories.service'; -import { GetBankSummaryUseCase } from '../application/get-bank-summary.use-case'; type AuthRequest = Request & { user: AccessPayload }; @ApiResponse({ status: 409, @@ -82,26 +84,31 @@ export class BankCardsController { private readonly expenseCategories: ExpenseCategoriesService, private readonly bankSummary: GetBankSummaryUseCase, ) {} + @Get() @ApiOkResponse({ type: BankCardDto, isArray: true }) list(@Req() req: AuthRequest, @Query() query: BankCardsQueryDto) { return this.cards.list(req.user.sub, query.includeArchived === 'true'); } + @Post() @ApiCreatedResponse({ type: BankCardDto }) create(@Req() req: AuthRequest, @Body() body: CreateBankCardDto) { return this.cards.createCard(req.user.sub, body); } + @Get('categories') @ApiOkResponse({ type: ExpenseCategoryDto, isArray: true }) categories(@Req() req: AuthRequest) { return this.expenseCategories.categories(req.user.sub); } + @Post('categories') @ApiCreatedResponse({ type: ExpenseCategoryDto }) createCategory(@Req() req: AuthRequest, @Body() body: CategoryNameDto) { return this.expenseCategories.createCategory(req.user.sub, body.name); } + @Patch('categories/:id') @ApiOkResponse({ type: ExpenseCategoryDto }) updateCategory( @@ -116,11 +123,13 @@ export class BankCardsController { body.version, ); } + @Get('summary') @ApiOkResponse({ type: BankSummaryDto }) summary(@Req() req: AuthRequest, @Query() query: BankSummaryQueryDto) { return this.bankSummary.execute(req.user.sub, query); } + @Post('operations') @ApiCreatedResponse({ type: BankOperationDto }) createOperation( @@ -129,11 +138,13 @@ export class BankCardsController { ) { return this.operations.createOperation(req.user.sub, body); } + @Get('operations/:id') @ApiOkResponse({ type: BankOperationDto }) operation(@Req() req: AuthRequest, @Param('id') id: string) { return this.operations.getOperation(req.user.sub, id); } + @Patch('operations/:id') @ApiOkResponse({ type: BankOperationDto }) updateOperation( @@ -143,6 +154,7 @@ export class BankCardsController { ) { return this.operations.updateOperation(req.user.sub, id, body); } + @Delete('operations/:id') @HttpCode(HttpStatus.NO_CONTENT) @ApiNoContentResponse() @@ -153,6 +165,7 @@ export class BankCardsController { ) { return this.operations.deleteOperation(req.user.sub, id, query.version); } + @Get(':id/operations') @ApiOkResponse({ type: BankOperationsPageDto }) history( @@ -162,6 +175,7 @@ export class BankCardsController { ) { return this.operations.history(req.user.sub, id, query); } + @Get(':id/expenses') @ApiOkResponse({ type: BankOperationsPageDto }) expenses( @@ -171,11 +185,13 @@ export class BankCardsController { ) { return this.operations.history(req.user.sub, id, query, true); } + @Get(':id') @ApiOkResponse({ type: BankCardDto }) get(@Req() req: AuthRequest, @Param('id') id: string) { return this.cards.get(req.user.sub, id); } + @Patch(':id') @ApiOkResponse({ type: BankCardDto }) update( diff --git a/src/modules/investments/brokerage-accounts/brokerage-account.dto.ts b/src/modules/investments/brokerage-accounts/brokerage-account.dto.ts index 22c8506..214573d 100644 --- a/src/modules/investments/brokerage-accounts/brokerage-account.dto.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-account.dto.ts @@ -1,6 +1,7 @@ -import { InvestmentResourceMetaDto } from '../investment-resource.dto'; import { ApiProperty } from '@nestjs/swagger'; + import { MoneyDto } from '../../../common/money.dto'; +import { InvestmentResourceMetaDto } from '../investment-resource.dto'; export class PortfolioSummaryDto { @ApiProperty({ type: MoneyDto, isArray: true }) @@ -40,15 +41,18 @@ export class BrokerageAccountDto { 'BROKERAGE' | 'INDIVIDUAL_INVESTMENT' | 'INVEST_BOX' | 'MONEY_MARKET_FUND'; @ApiProperty({ type: String, nullable: true, format: 'date' }) - declare openedOn: string | null; + declare openedOn: null | string; + // eslint-disable-next-line @darraghor/nestjs-typed/api-property-matches-property-optionality -- Preserve the published required nullable schema; partial internal models may omit the field. @ApiProperty({ type: PortfolioSummaryDto, nullable: true }) - portfolioSummary?: PortfolioSummaryDto | null; + portfolioSummary?: null | PortfolioSummaryDto; } export class BrokerageAccountsResourceDto { + // eslint-disable-next-line @darraghor/nestjs-typed/api-property-returning-array-should-set-array -- Nullable array union requires isArray in the explicit wire schema. @ApiProperty({ type: BrokerageAccountDto, isArray: true, nullable: true }) declare data: BrokerageAccountDto[] | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } diff --git a/src/modules/investments/brokerage-accounts/brokerage-accounts.controller.ts b/src/modules/investments/brokerage-accounts/brokerage-accounts.controller.ts index 564029c..85e2bce 100644 --- a/src/modules/investments/brokerage-accounts/brokerage-accounts.controller.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-accounts.controller.ts @@ -1,9 +1,11 @@ -import { InvestmentReadQueryDto } from '../investment-resource.dto'; -import { Controller, Get, Req, Query, UseGuards } from '@nestjs/common'; -import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import type { Request } from 'express'; + +import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; + import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api'; +import { InvestmentReadQueryDto } from '../investment-resource.dto'; import { BrokerageAccountsResourceDto } from './brokerage-account.dto'; import { BrokerageAccountsService } from './brokerage-accounts.service'; diff --git a/src/modules/investments/brokerage-accounts/brokerage-accounts.module.ts b/src/modules/investments/brokerage-accounts/brokerage-accounts.module.ts index 267ea01..35f4e9f 100644 --- a/src/modules/investments/brokerage-accounts/brokerage-accounts.module.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-accounts.module.ts @@ -1,7 +1,8 @@ -import { PortfolioModule } from '../portfolio/portfolio.module'; import { Module } from '@nestjs/common'; + import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { AuthModule } from '../../auth/auth.module'; +import { PortfolioModule } from '../portfolio/portfolio.module'; import { BrokerageAccountsController } from './brokerage-accounts.controller'; import { BrokerageAccountsService } from './brokerage-accounts.service'; diff --git a/src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts index 2d20622..a9473d3 100644 --- a/src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts @@ -1,8 +1,8 @@ jest.mock('../../../prisma/prisma.service', () => ({ PrismaService: class {}, })); -import { BrokerageAccountsService } from './brokerage-accounts.service'; import { investmentResourceMeta } from '../investment-resource.mapper'; +import { BrokerageAccountsService } from './brokerage-accounts.service'; jest.mock('../portfolio/application/portfolio.service', () => ({ PortfolioService: class PortfolioService {}, diff --git a/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts index b8a0111..d2c1234 100644 --- a/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts @@ -1,7 +1,8 @@ import { Injectable } from '@nestjs/common'; import { createHash } from 'node:crypto'; -import { PortfolioService } from '../portfolio/portfolio.api'; + import { investmentResourceMeta } from '../investment-resource.mapper'; +import { PortfolioService } from '../portfolio/portfolio.api'; import { BrokerageAccountDto } from './brokerage-account.dto'; @Injectable() @@ -11,15 +12,15 @@ export class BrokerageAccountsService { async list(userId: string, observe = false) { const saved = await this.portfolio.saved(userId, 'all', observe); const data: BrokerageAccountDto[] | null = - saved.membership.availability === 'missing' - ? null - : saved.accounts.map((account) => ({ + saved.membership.availability === 'missing' ? + null : + saved.accounts.map((account) => ({ id: account.id, name: account.name, type: account.type as BrokerageAccountDto['type'], openedOn: account.openedOn, - portfolioSummary: account.snapshot - ? { + portfolioSummary: account.snapshot ? + { totalsByCurrency: account.snapshot.totalsByCurrency, stockPositionCount: account.snapshot.positions.filter( (position) => position.type === 'STOCK', @@ -27,8 +28,8 @@ export class BrokerageAccountsService { bondPositionCount: account.snapshot.positions.filter( (position) => position.type === 'BOND', ).length, - } - : null, + } : + null, })); return { data, @@ -46,18 +47,18 @@ export class BrokerageAccountsService { ) .digest('base64url'), availability: - data === null - ? 'missing' - : saved.accounts.some((account) => !account.snapshot) - ? 'partial' - : 'complete', - fetchedAt: saved.membership.lastSuccessfulAt - ? new Date(saved.membership.lastSuccessfulAt) - : null, + data === null ? + 'missing' : + saved.accounts.some((account) => !account.snapshot) ? + 'partial' : + 'complete', + fetchedAt: saved.membership.lastSuccessfulAt ? + new Date(saved.membership.lastSuccessfulAt) : + null, freshForMs: 300_000, - work: saved.membership.sync.id - ? { ...saved.membership.sync, id: saved.membership.sync.id } - : null, + work: saved.membership.sync.id ? + { ...saved.membership.sync, id: saved.membership.sync.id } : + null, dependencies: saved.accounts.map((account) => account.meta), }), }; diff --git a/src/modules/investments/income/income-analytics.controller.ts b/src/modules/investments/income/income-analytics.controller.ts index 5d08d42..d72f624 100644 --- a/src/modules/investments/income/income-analytics.controller.ts +++ b/src/modules/investments/income/income-analytics.controller.ts @@ -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') diff --git a/src/modules/investments/income/income-analytics.dto.ts b/src/modules/investments/income/income-analytics.dto.ts index d133732..a398d59 100644 --- a/src/modules/investments/income/income-analytics.dto.ts +++ b/src/modules/investments/income/income-analytics.dto.ts @@ -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; } diff --git a/src/modules/investments/income/income-analytics.service.spec.ts b/src/modules/investments/income/income-analytics.service.spec.ts index fa7c3d9..0aef960 100644 --- a/src/modules/investments/income/income-analytics.service.spec.ts +++ b/src/modules/investments/income/income-analytics.service.spec.ts @@ -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', () => { diff --git a/src/modules/investments/income/income-analytics.service.ts b/src/modules/investments/income/income-analytics.service.ts index 824f1eb..76fd624 100644 --- a/src/modules/investments/income/income-analytics.service.ts +++ b/src/modules/investments/income/income-analytics.service.ts @@ -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}`; } diff --git a/src/modules/investments/income/income-period.spec.ts b/src/modules/investments/income/income-period.spec.ts index 36a17bd..31afc16 100644 --- a/src/modules/investments/income/income-period.spec.ts +++ b/src/modules/investments/income/income-period.spec.ts @@ -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', () => { diff --git a/src/modules/investments/income/income-period.ts b/src/modules/investments/income/income-period.ts index e6e242e..c7c280d 100644 --- a/src/modules/investments/income/income-period.ts +++ b/src/modules/investments/income/income-period.ts @@ -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); diff --git a/src/modules/investments/income/income.module.ts b/src/modules/investments/income/income.module.ts index 3185170..addc0b5 100644 --- a/src/modules/investments/income/income.module.ts +++ b/src/modules/investments/income/income.module.ts @@ -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'; diff --git a/src/modules/investments/instruments/application/candle.service.ts b/src/modules/investments/instruments/application/candle.service.ts index aacc939..86d7ba5 100644 --- a/src/modules/investments/instruments/application/candle.service.ts +++ b/src/modules/investments/instruments/application/candle.service.ts @@ -1,13 +1,13 @@ -import { planInvestmentScreen } from '../../investment-sync-plan'; -import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; import { Injectable, NotFoundException, ServiceUnavailableException, } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; -import { MILLISECONDS_PER_DAY } from '../../../../common/time'; + import { formatDecimal } from '../../../../common/decimal'; +import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; +import { MILLISECONDS_PER_DAY } from '../../../../common/time'; import { decimal } from '../../../../integrations/tbank/decimal'; import { TbankAccessService, @@ -16,35 +16,36 @@ import { type TbankSyncAttempt, } from '../../../../integrations/tbank/tbank.api'; import { investmentResourceMeta } from '../../investment-resource.mapper'; -import { CatalogEnrichmentService } from './catalog-enrichment.service'; -import { CandleRepository } from '../infrastructure/candle.repository'; +import { planInvestmentScreen } from '../../investment-sync-plan'; import { candleGaps, + type CandleRange, candleWindows, utcDay, - type CandleRange, } from '../domain/candle-coverage'; +import { CandleRepository } from '../infrastructure/candle.repository'; +import { CatalogEnrichmentService } from './catalog-enrichment.service'; import { timestamp } from './instrument-market'; type CandleInput = { - uid: string; - instrumentType: 'share' | 'bond'; - period: 'month' | 'halfYear' | 'year' | 'all'; + instrumentType: 'bond' | 'share'; + period: 'all' | 'halfYear' | 'month' | 'year'; range?: CandleRange; + uid: string; }; type Producer = { - input: CandleInput; attempt: TbankSyncAttempt; - now: Date; force: boolean; - priority: number; + input: CandleInput; + now: Date; observers: TbankSyncAttempt[]; pass?: { - now: Date; force: boolean; - ranges: CandleRange[]; nextWindow: number; + now: Date; + ranges: CandleRange[]; }; + priority: number; }; const months = { month: 1, halfYear: 6, year: 12 }; const tailFreshForMs = 60_000; @@ -61,18 +62,20 @@ function rangeFor( const from = utcDay(now); const history = timestamp(instrument.first_1dayCandleDate); if (input.period === 'all') { - if (!history) + if (!history) { throw new ServiceUnavailableException({ code: - input.instrumentType === 'share' - ? 'STOCK_HISTORY_START_UNAVAILABLE' - : 'BOND_HISTORY_START_UNAVAILABLE', + input.instrumentType === 'share' ? + 'STOCK_HISTORY_START_UNAVAILABLE' : + 'BOND_HISTORY_START_UNAVAILABLE', }); + } from.setTime(utcDay(new Date(history)).valueOf()); } else { from.setUTCMonth(from.getUTCMonth() - months[input.period]); - if (history && Date.parse(history) > from.valueOf()) + if (history && Date.parse(history) > from.valueOf()) { from.setTime(utcDay(new Date(history)).valueOf()); + } } return { from, to: new Date(utcDay(now).valueOf() + MILLISECONDS_PER_DAY) }; } @@ -102,16 +105,18 @@ export class CandleService { true, ['detail'], ); - if (detail.meta.availability === 'complete' && !detail.instrument) + if (detail.meta.availability === 'complete' && !detail.instrument) { throw new NotFoundException(); + } const now = new Date(); - const range = detail.instrument - ? rangeFor(input, detail.instrument, now) - : null; + const range = detail.instrument ? + rangeFor(input, detail.instrument, now) : + null; const saved = range ? await this.repository.read(input.uid, range) : null; const gaps = range && saved ? candleGaps(range, saved.coverage, now) : []; - if (!observe && (!saved || gaps.length)) + if (!observe && (!saved || gaps.length)) { await this.request(userId, revision, input, now); + } const work = await this.repository.work(userId, revision, scope(input)); const fetchedAt = saved?.coverage @@ -129,14 +134,14 @@ export class CandleService { }); return { data: - !saved || !covered - ? null - : { + !saved || !covered ? + null : + { candles: saved.data, currency: - input.instrumentType === 'share' - ? detail.instrument?.currency?.toUpperCase() || null - : null, + input.instrumentType === 'share' ? + detail.instrument?.currency?.toUpperCase() || null : + null, historyFrom: timestamp(detail?.instrument?.first_1dayCandleDate), observedAt: fetchedAt?.toISOString() ?? now.toISOString(), }, @@ -149,8 +154,8 @@ export class CandleService { work, coverage: { requested: range ? [publicRange(range)] : [], - completeRanges: range - ? completeCoverage.flatMap((row) => { + completeRanges: range ? + completeCoverage.flatMap((row) => { const from = new Date( Math.max(row.from.valueOf(), range.from.valueOf()), ); @@ -158,22 +163,22 @@ export class CandleService { Math.min(row.to.valueOf(), range.to.valueOf()), ); return from < to ? [publicRange({ from, to })] : []; - }) - : [], + }) : + [], missingRanges: missing.map(publicRange), asOf: fetchedAt?.toISOString() ?? null, - historyBoundary: timestamp(detail?.instrument?.first_1dayCandleDate) - ? 'known' - : 'unknown', - historyState: complete - ? 'complete' - : work?.state === 'failed' - ? 'failed' - : work?.state === 'running' - ? 'loading' - : covered - ? 'partial' - : 'missing', + historyBoundary: timestamp(detail?.instrument?.first_1dayCandleDate) ? + 'known' : + 'unknown', + historyState: complete ? + 'complete' : + work?.state === 'failed' ? + 'failed' : + work?.state === 'running' ? + 'loading' : + covered ? + 'partial' : + 'missing', }, dependencies: [ detail.meta, @@ -183,9 +188,9 @@ export class CandleService { dataRevision: String(row.dataRevision), availability: 'complete', fetchedAt: row.fetchedAt, - freshForMs: row.isComplete - ? Number.POSITIVE_INFINITY - : tailFreshForMs, + freshForMs: row.isComplete ? + Number.POSITIVE_INFINITY : + tailFreshForMs, work: null, }), ), @@ -197,13 +202,14 @@ export class CandleService { prefetch( userId: string, revision: string, - positions: { instrumentUid?: string | null; type: string }[], + positions: { instrumentUid?: null | string; type: string }[], ) { const key = JSON.stringify([ userId, revision, positions .map((position) => [position.instrumentUid, position.type]) + // 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(), ]); const existing = this.prefetches.get(key); @@ -218,14 +224,14 @@ export class CandleService { private async prefetchPositions( userId: string, revision: string, - positions: { instrumentUid?: string | null; type: string }[], + positions: { instrumentUid?: null | string; type: string }[], ) { const now = new Date(); const unique = new Map( positions .filter( (position) => - position.instrumentUid && ['STOCK', 'BOND'].includes(position.type), + position.instrumentUid && ['BOND', 'STOCK'].includes(position.type), ) .map((position) => [position.instrumentUid ?? '', position]), ); @@ -267,7 +273,7 @@ export class CandleService { instruments: [ { uid, - type: position.type as 'STOCK' | 'BOND', + type: position.type as 'BOND' | 'STOCK', historyFrom: history ? new Date(history) : null, }, ], @@ -280,9 +286,9 @@ export class CandleService { ) { const input = { uid, instrumentType, period: 'all' as const }; const range = - detail?.instrument && history - ? rangeFor(input, detail.instrument, now) - : null; + detail?.instrument && history ? + rangeFor(input, detail.instrument, now) : + null; if ( !range || candleGaps( @@ -290,8 +296,9 @@ export class CandleService { coverage.filter((row) => row.instrumentUid === uid), now, ).length - ) + ) { await this.request(userId, revision, input, now, false, 2); + } } } } @@ -379,6 +386,7 @@ export class CandleService { } const { ranges, now, force } = pass; const index = pass.nextWindow; + // eslint-disable-next-line security/detect-object-injection -- The index is the internal numeric window cursor. let window = ranges[index]; if (window && !force) { const latest = await this.repository.read(input.uid, window); @@ -386,6 +394,7 @@ export class CandleService { .flatMap(candleWindows) .reverse(); ranges.splice(index, 1, ...gaps); + // eslint-disable-next-line security/detect-object-injection -- The index is the internal numeric window cursor. window = ranges[index]; } if (window) { @@ -408,8 +417,9 @@ export class CandleService { }, ]; }); - if (!(await this.repository.stage(attempt, window, rows, now))) + if (!(await this.repository.stage(attempt, window, rows, now))) { return false; + } if ( !force && !(await this.repository.publish( @@ -417,8 +427,9 @@ export class CandleService { { uid: input.uid, ranges: [window] }, false, )) - ) + ) { return false; + } pass.nextWindow = index + 1; if (pass.nextWindow < ranges.length) return null; } @@ -441,16 +452,18 @@ export class CandleService { } success = result; } catch (error) { - if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') + if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') { await this.access.rejectMembershipToken(producer.attempt); + } if (error instanceof ServiceUnavailableException) { const response = error.getResponse(); if ( typeof response === 'object' && 'code' in response && typeof response.code === 'string' - ) + ) { code = response.code; + } } } // Seal publication with reservations so a late forced join cannot be @@ -458,8 +471,9 @@ export class CandleService { const finished = this.reservations.then(async () => { const pass = producer.pass; if (success && pass) { - if (producer.force && (!pass.force || producer.now > pass.now)) + if (producer.force && (!pass.force || producer.now > pass.now)) { return false; + } try { success = await this.repository.publish(producer.attempt, { uid: producer.input.uid, @@ -478,8 +492,9 @@ export class CandleService { observer, producer.attempt, )) - ) + ) { await this.repository.fail(observer, code); + } this.active.delete(observer.attemptId); } this.producers.delete(scope(producer.input)); @@ -490,7 +505,7 @@ export class CandleService { this.queue.push(producer); continue; } - if (success && !producer.force && producer.input.period !== 'all') + if (success && !producer.force && producer.input.period !== 'all') { await this.request( producer.attempt.userId, producer.attempt.revision, @@ -499,6 +514,7 @@ export class CandleService { false, 2, ); + } } } catch { this.logger.event('error', 'candles.persistence-failed', { diff --git a/src/modules/investments/instruments/application/catalog-discovery.service.ts b/src/modules/investments/instruments/application/catalog-discovery.service.ts index ae06df5..0c2b417 100644 --- a/src/modules/investments/instruments/application/catalog-discovery.service.ts +++ b/src/modules/investments/instruments/application/catalog-discovery.service.ts @@ -1,10 +1,10 @@ -import { CatalogEnrichmentService } from './catalog-enrichment.service'; import { + ConflictException, Injectable, NotFoundException, - ConflictException, } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; + import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; import { TbankAccessService, @@ -17,9 +17,10 @@ import { investmentResourceMeta } from '../../investment-resource.mapper'; import { CatalogDiscoveryRepository, catalogQuery, - catalogScope, type CatalogQuery, + catalogScope, } from '../infrastructure/catalog-discovery.repository'; +import { CatalogEnrichmentService } from './catalog-enrichment.service'; import { catalogSearchLimit, selectCatalogInstruments, @@ -28,10 +29,10 @@ import { const tickerAmbiguityLimit = 2; type Producer = { - input: CatalogQuery; attempt: TbankSyncAttempt; - observers: TbankSyncAttempt[]; force: boolean; + input: CatalogQuery; + observers: TbankSyncAttempt[]; }; @Injectable() export class CatalogDiscoveryService { @@ -43,8 +44,9 @@ export class CatalogDiscoveryService { private running = false; private readonly completions = new Map< string, - { workId: string; done: Promise; resolve: () => void } + { done: Promise; resolve: () => void; workId: string } >(); + constructor( private readonly repository: CatalogDiscoveryRepository, private readonly access: TbankAccessService, @@ -73,14 +75,14 @@ export class CatalogDiscoveryService { async search(userId: string, input: CatalogQuery, observe = false) { const discovery = await this.read(userId, input, observe); - const sources = discovery.data - ? await this.enrichment.readMany( + const sources = discovery.data ? + await this.enrichment.readMany( userId, input.instrumentType, discovery.data.map((item) => item.uid), observe, - ) - : []; + ) : + []; const dependencies = [ discovery.meta, ...sources.map((source) => source.meta), @@ -108,22 +110,22 @@ export class CatalogDiscoveryService { .map((dependency) => dependency.dataRevision) .join('/'), availability: - discovery.data === null - ? 'missing' - : sources.every((source) => source.meta.availability === 'complete') - ? 'complete' - : 'partial', - fetchedAt: discovery.meta.lastSuccessfulAt - ? new Date(discovery.meta.lastSuccessfulAt) - : null, + discovery.data === null ? + 'missing' : + sources.every((source) => source.meta.availability === 'complete') ? + 'complete' : + 'partial', + fetchedAt: discovery.meta.lastSuccessfulAt ? + new Date(discovery.meta.lastSuccessfulAt) : + null, freshForMs: tbankSourceTtlMs.catalog, - work: discovery.meta.sync.id - ? { + work: discovery.meta.sync.id ? + { id: discovery.meta.sync.id, state: discovery.meta.sync.state, errorCode: discovery.meta.sync.errorCode, - } - : null, + } : + null, dependencies, }), }; @@ -131,7 +133,7 @@ export class CatalogDiscoveryService { async detail( userId: string, - instrumentType: 'share' | 'bond', + instrumentType: 'bond' | 'share', uid: string, observe = false, ) { @@ -143,22 +145,24 @@ export class CatalogDiscoveryService { ['detail'], ); const source = sources[0]; - if (source.meta.availability === 'complete' && !source.instrument) + if (source.meta.availability === 'complete' && !source.instrument) { throw new NotFoundException(); + } const instrument = source.instrument; let slug = uid; const dependencies = [source.meta]; if ( instrument?.ticker && - /^[A-Za-z0-9][A-Za-z0-9._@+-]{0,79}$/.test(instrument.ticker) + /^[A-Z0-9][\w.@+-]{0,79}$/i.test(instrument.ticker) ) { const alias = await this.read( userId, { instrumentType, query: instrument.ticker, exactTicker: true }, observe, ); - if (alias.data?.length === 1 && alias.data[0].uid === uid) + if (alias.data?.length === 1 && alias.data[0].uid === uid) { slug = instrument.ticker.toUpperCase(); + } dependencies.push(alias.meta); } return { @@ -169,17 +173,17 @@ export class CatalogDiscoveryService { .map((dependency) => dependency.dataRevision) .join('/'), availability: source.meta.availability, - fetchedAt: source.meta.lastSuccessfulAt - ? new Date(source.meta.lastSuccessfulAt) - : null, + fetchedAt: source.meta.lastSuccessfulAt ? + new Date(source.meta.lastSuccessfulAt) : + null, freshForMs: tbankSourceTtlMs.metadata, - work: source.meta.sync.id - ? { + work: source.meta.sync.id ? + { id: source.meta.sync.id, state: source.meta.sync.state, errorCode: source.meta.sync.errorCode, - } - : null, + } : + null, dependencies, }), }; @@ -187,7 +191,7 @@ export class CatalogDiscoveryService { async byTicker( userId: string, - instrumentType: 'share' | 'bond', + instrumentType: 'bond' | 'share', ticker: string, observe = false, ) { @@ -197,38 +201,39 @@ export class CatalogDiscoveryService { observe, ); if (lookup.data?.length === 0) throw new NotFoundException(); - if (lookup.data && lookup.data.length > 1) + if (lookup.data && lookup.data.length > 1) { throw new ConflictException({ code: - instrumentType === 'share' - ? 'STOCK_TICKER_AMBIGUOUS' - : 'BOND_TICKER_AMBIGUOUS', + instrumentType === 'share' ? + 'STOCK_TICKER_AMBIGUOUS' : + 'BOND_TICKER_AMBIGUOUS', }); - const result = lookup.data - ? await this.detail(userId, instrumentType, lookup.data[0].uid, observe) - : null; + } + const result = lookup.data ? + await this.detail(userId, instrumentType, lookup.data[0].uid, observe) : + null; const dependencies = [lookup.meta, ...(result ? [result.meta] : [])]; return { - data: result?.data - ? { ...result.data, slug: ticker.trim().toUpperCase() } - : null, + data: result?.data ? + { ...result.data, slug: ticker.trim().toUpperCase() } : + null, meta: investmentResourceMeta({ resourceKey: `${instrumentType}/ticker/${ticker.trim().toUpperCase()}`, dataRevision: dependencies .map((dependency) => dependency.dataRevision) .join('/'), availability: result?.meta.availability ?? 'missing', - fetchedAt: lookup.meta.lastSuccessfulAt - ? new Date(lookup.meta.lastSuccessfulAt) - : null, + fetchedAt: lookup.meta.lastSuccessfulAt ? + new Date(lookup.meta.lastSuccessfulAt) : + null, freshForMs: tbankSourceTtlMs.metadata, - work: lookup.meta.sync.id - ? { + work: lookup.meta.sync.id ? + { id: lookup.meta.sync.id, state: lookup.meta.sync.state, errorCode: lookup.meta.sync.errorCode, - } - : null, + } : + null, dependencies, }), }; @@ -245,8 +250,9 @@ export class CatalogDiscoveryService { const saved = await this.repository.read(input); if ( !tbankSourceNeedsRefresh('catalog', saved.fetchedAt, new Date(), force) - ) + ) { return null; + } const reservation = await this.repository.reserve( { userId, revision }, input, @@ -291,12 +297,13 @@ export class CatalogDiscoveryService { async refresh(userId: string, revision: string, query: CatalogQuery) { const id = await this.request(userId, revision, query, true); - if (id) + if (id) { await Promise.all( [...this.completions.values()] .filter((entry) => entry.workId === id) .map((entry) => entry.done), ); + } const saved = await this.repository.read(catalogQuery(query)); const sources = await this.enrichment.readMany( userId, @@ -323,19 +330,19 @@ export class CatalogDiscoveryService { const token = await this.access.tokenForAttempt(attempt); if (token) { const found = - input.instrumentType === 'share' - ? await this.tbank.findShares(token, input.query) - : await this.tbank.findBonds(token, input.query); + input.instrumentType === 'share' ? + await this.tbank.findShares(token, input.query) : + await this.tbank.findBonds(token, input.query); success = await this.repository.publish( attempt, input, selectCatalogInstruments( - input.exactTicker - ? found.filter( + input.exactTicker ? + found.filter( (item) => item.ticker?.trim().toUpperCase() === input.query, - ) - : found, + ) : + found, input.query, input.instrumentType, ).slice( @@ -346,17 +353,18 @@ export class CatalogDiscoveryService { ); } } catch (error) { - if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') + if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') { await this.access.rejectMembershipToken(attempt); - else + } else { this.logger.event('warn', 'catalog-discovery.failed', { code: 'TBANK_UNAVAILABLE', }); + } } if (success) { const saved = await this.repository.read(input); - for (const observer of [attempt, ...producer.observers]) - if (!input.exactTicker || saved.data?.length === 1) + for (const observer of [attempt, ...producer.observers]) { + if (!input.exactTicker || saved.data?.length === 1) { await this.enrichment.requestMany( observer.userId, observer.revision, @@ -365,6 +373,8 @@ export class CatalogDiscoveryService { producer.force, input.exactTicker ? ['detail'] : ['last-price', 'detail'], ); + } + } } this.producers.delete(catalogScope(input)); for (const observer of [attempt, ...producer.observers]) { diff --git a/src/modules/investments/instruments/application/catalog-enrichment.service.ts b/src/modules/investments/instruments/application/catalog-enrichment.service.ts index 2d29778..712c1db 100644 --- a/src/modules/investments/instruments/application/catalog-enrichment.service.ts +++ b/src/modules/investments/instruments/application/catalog-enrichment.service.ts @@ -1,47 +1,47 @@ -import type { MoexBondEvent } from '../../../../integrations/moex/moex.api'; -import { CandleRepository } from '../infrastructure/candle.repository'; -import { candleGaps, utcDay } from '../domain/candle-coverage'; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; + import { decimalFromString, divide, formatDecimal, } from '../../../../common/decimal'; -import { decimal } from '../../../../integrations/tbank/decimal'; +import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; import { MILLISECONDS_PER_DAY, MILLISECONDS_PER_SECOND, } from '../../../../common/time'; -import { MoexClient } from '../../../../integrations/moex/moex.api'; +import { type MoexBondEvent, MoexClient } from '../../../../integrations/moex/moex.api'; import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api'; -import { previousClose, timestamp } from './instrument-market'; -import { Injectable, NotFoundException } from '@nestjs/common'; -import { randomUUID } from 'node:crypto'; -import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; +import { decimal } from '../../../../integrations/tbank/decimal'; import { TbankAccessService, - TbankClient, - tbankSourceNeedsRefresh, - tbankSourceTtlMs, type TbankBond, type TbankCandle, + TbankClient, type TbankCoupon, type TbankDividend, type TbankFundamentals, type TbankLastPrice, type TbankShare, + tbankSourceNeedsRefresh, + tbankSourceTtlMs, type TbankSyncAttempt, } from '../../../../integrations/tbank/tbank.api'; import { investmentResourceMeta } from '../../investment-resource.mapper'; +import { candleGaps, utcDay } from '../domain/candle-coverage'; +import { CandleRepository } from '../infrastructure/candle.repository'; import { CatalogDiscoveryRepository, catalogScope, type CatalogSource, } from '../infrastructure/catalog-discovery.repository'; import { catalogSearchLimit } from './catalog-selection'; +import { previousClose, timestamp } from './instrument-market'; type Producer = { - input: CatalogSource; attempt: TbankSyncAttempt; + input: CatalogSource; observers: { attempt: TbankSyncAttempt; input: CatalogSource }[]; }; @Injectable() @@ -54,13 +54,13 @@ export class CatalogEnrichmentService { private running = false; private readonly completions = new Map< string, - { workId: string; done: Promise; resolve: () => void } + { done: Promise; resolve: () => void; workId: string } >(); async ensureDetail( userId: string, revision: string, - instrumentType: 'share' | 'bond', + instrumentType: 'bond' | 'share', uid: string, ) { const ids = await this.requestMany( @@ -82,6 +82,7 @@ export class CatalogEnrichmentService { if (!rows.length) throw new Error('Instrument dependency missing'); return (rows[0].payload as CatalogPayload).instrument ?? null; } + constructor( private readonly repository: CatalogDiscoveryRepository, private readonly access: TbankAccessService, @@ -94,21 +95,21 @@ export class CatalogEnrichmentService { async readMany( userId: string, - instrumentType: 'share' | 'bond', + instrumentType: 'bond' | 'share', uids: string[], observe: boolean, kinds: CatalogSource['kind'][] = ['last-price', 'detail'], range?: { from: string; - to: string; ticker?: string; tickers?: Record; + to: string; }, ) { const { revision } = await this.access.local(userId); const inputs = sourceInputs(instrumentType, uids, kinds, range); const rows = inputs.length ? await this.repository.readSources(inputs) : []; - if (!observe) + if (!observe) { await this.requestMany( userId, revision, @@ -118,9 +119,10 @@ export class CatalogEnrichmentService { kinds, range, ); - const work = inputs.length - ? await this.repository.workMany(userId, revision, inputs) - : []; + } + const work = inputs.length ? + await this.repository.workMany(userId, revision, inputs) : + []; const byScope = new Map(work.map((row) => [row.scopeKey, row])); return inputs.map((input) => { const row = rows.find( @@ -130,16 +132,16 @@ export class CatalogEnrichmentService { const payload = row?.payload as CatalogPayload | undefined; return { input, - version: row - ? { + version: row ? + { instrumentUid: row.instrumentUid, source: row.source, kind: row.kind, rangeKey: row.rangeKey, dataRevision: row.dataRevision, fetchedAt: row.fetchedAt, - } - : null, + } : + null, payload, instrument: payload?.instrument ?? null, price: payload?.price ?? null, @@ -158,22 +160,22 @@ export class CatalogEnrichmentService { requestMany( userId: string, revision: string, - instrumentType: 'share' | 'bond', + instrumentType: 'bond' | 'share', uids: string[], force = false, kinds: CatalogSource['kind'][] = ['last-price', 'detail'], range?: { from: string; - to: string; ticker?: string; tickers?: Record; + to: string; }, ) { const inputs = sourceInputs(instrumentType, uids, kinds, range); const reservation = this.reservations.then(async () => { - const rows = inputs.length - ? await this.repository.readSources(inputs) - : []; + const rows = inputs.length ? + await this.repository.readSources(inputs) : + []; const ids: string[] = []; for (const input of inputs) { const row = rows.find( @@ -187,8 +189,9 @@ export class CatalogEnrichmentService { new Date(), force, ) - ) + ) { continue; + } const reserved = await this.repository.reserve( { userId, revision }, input, @@ -229,24 +232,24 @@ export class CatalogEnrichmentService { async section( userId: string, - instrumentType: 'share' | 'bond', + instrumentType: 'bond' | 'share', uid: string, - section: 'quote' | 'company' | 'metrics', + section: 'company' | 'metrics' | 'quote', observe = false, ) { const kinds: CatalogSource['kind'][] = - section === 'company' - ? ['detail', 'description', 'fundamentals'] - : [ - ...(instrumentType === 'bond' - ? ['bond-current' as const] - : ['detail' as const]), + section === 'company' ? + ['detail', 'description', 'fundamentals'] : + [ + ...(instrumentType === 'bond' ? + ['bond-current' as const] : + ['detail' as const]), 'last-price', 'trading-status', 'comparison', - ...(section === 'metrics' - ? ['coupons' as const, 'yield' as const, 'rating' as const] - : []), + ...(section === 'metrics' ? + ['coupons' as const, 'yield' as const, 'rating' as const] : + []), ]; const sources = await this.readMany( userId, @@ -256,8 +259,9 @@ export class CatalogEnrichmentService { kinds, ); const first = sources[0]; - if (first.meta.availability === 'complete' && !first.instrument) + if (first.meta.availability === 'complete' && !first.instrument) { throw new NotFoundException(); + } const values = sources.reduce( (result, source) => ({ ...result, ...source.payload }), {}, @@ -271,18 +275,18 @@ export class CatalogEnrichmentService { dataRevision: dependencies .map((dependency) => dependency.dataRevision) .join('/'), - availability: !first.instrument - ? 'missing' - : complete - ? 'complete' - : 'partial', - fetchedAt: first.meta.lastSuccessfulAt - ? new Date(first.meta.lastSuccessfulAt) - : null, + availability: !first.instrument ? + 'missing' : + complete ? + 'complete' : + 'partial', + fetchedAt: first.meta.lastSuccessfulAt ? + new Date(first.meta.lastSuccessfulAt) : + null, freshForMs: - section === 'company' - ? tbankSourceTtlMs.company - : tbankSourceTtlMs.quote, + section === 'company' ? + tbankSourceTtlMs.company : + tbankSourceTtlMs.quote, work: null, dependencies, }); @@ -307,18 +311,21 @@ export class CatalogEnrichmentService { ): Promise { if (input.kind === 'detail' || input.kind === 'bond-current') { const instrument = - input.instrumentType === 'share' - ? await this.tbank.getShare(token, input.uid) - : await this.tbank.getBond(token, input.uid); - if (instrument && instrument.uid !== input.uid) + input.instrumentType === 'share' ? + await this.tbank.getShare(token, input.uid) : + await this.tbank.getBond(token, input.uid); + if (instrument && instrument.uid !== input.uid) { throw new Error('Catalog UID mismatch'); + } + // eslint-disable-next-line sonarjs/different-types-comparison -- Defensive validation rejects missing upstream payloads instead of caching them as absence. if (instrument === undefined) throw new Error('Missing catalog response'); return { instrument: instrument ? publicInstrument(instrument) : null }; } - if (input.kind === 'trading-status') + if (input.kind === 'trading-status') { return { tradingStatus: await this.tbank.getTradingStatus(token, input.uid), }; + } if (input.kind === 'comparison') { const prices = await this.repository.readSources([ { ...input, kind: 'last-price', rangeKey: undefined }, @@ -343,16 +350,18 @@ export class CatalogEnrichmentService { ); const rows = response.map((candle) => { const time = timestamp(candle.time); - if (!time || !candle.close) + if (!time || !candle.close) { throw new Error('Invalid candle response'); + } return { time, close: formatDecimal(decimal(candle.close)), isComplete: candle.isComplete === true, }; }); - if (!(await this.candles.stage(attempt, gap, rows, now))) + if (!(await this.candles.stage(attempt, gap, rows, now))) { throw new Error('Cancelled candle comparison'); + } } if ( gaps.length && @@ -361,8 +370,9 @@ export class CatalogEnrichmentService { { uid: input.uid, ranges: gaps }, false, )) - ) + ) { throw new Error('Cancelled candle comparison'); + } const values = (await this.candles.read(input.uid, range)).data.map( (row) => { const value = divide( @@ -386,48 +396,51 @@ export class CatalogEnrichmentService { } const details = await this.repository.readSources([ { ...input, kind: 'detail', rangeKey: undefined }, - ...(input.instrumentType === 'bond' - ? [{ ...input, kind: 'bond-current' as const, rangeKey: undefined }] - : []), + ...(input.instrumentType === 'bond' ? + [{ ...input, kind: 'bond-current' as const, rangeKey: undefined }] : + []), ]); const instrument = details .map((row) => (row.payload as CatalogPayload).instrument) .find(Boolean); if ( !instrument && - !['dividends', 'coupons', 'bond-events'].includes(input.kind) - ) + !['bond-events', 'coupons', 'dividends'].includes(input.kind) + ) { throw new Error('Instrument dependency missing'); - if (input.kind === 'description') + } + if (input.kind === 'description') { return { - description: instrument?.assetUid - ? await this.tbank.getAssetDescription(token, instrument?.assetUid) - : null, + description: instrument?.assetUid ? + await this.tbank.getAssetDescription(token, instrument?.assetUid) : + null, }; - if (input.kind === 'fundamentals') + } + if (input.kind === 'fundamentals') { return { - fundamentals: instrument?.assetUid - ? await this.tbank.getFundamentals(token, instrument?.assetUid) - : null, + fundamentals: instrument?.assetUid ? + await this.tbank.getFundamentals(token, instrument?.assetUid) : + null, }; + } if (input.kind === 'coupons' || input.kind === 'dividends') { const year = Number(input.rangeKey); - const [from, to] = input.rangeKey?.startsWith('[') - ? (JSON.parse(input.rangeKey) as [string, string]) - : [ + const [from, to] = input.rangeKey?.startsWith('[') ? + (JSON.parse(input.rangeKey) as [string, string]) : + [ `${year - payoutHistoryYears}-01-01`, `${year + payoutFutureYears}-01-01`, ]; - return input.kind === 'coupons' - ? { + return input.kind === 'coupons' ? + { coupons: await this.tbank.getBondCoupons( token, input.uid, from, to, ), - } - : { + } : + { dividends: await this.tbank.getDividends( token, input.uid, @@ -441,23 +454,25 @@ export class CatalogEnrichmentService { if (!ticker) throw new Error('Bond ticker missing'); return { bondEvents: await this.moex.bondEvents(ticker) }; } - if (input.kind === 'yield') + if (input.kind === 'yield') { return { effectiveYield: - instrument?.ticker && instrument.isin && instrument.classCode - ? await this.moex.bondYield( + instrument?.ticker && instrument.isin && instrument.classCode ? + await this.moex.bondYield( instrument.ticker, instrument.isin, instrument.classCode, - ) - : null, + ) : + null, }; - if (input.kind === 'rating') + } + if (input.kind === 'rating') { return { - creditRating: instrument?.isin - ? await this.ratings.rating(instrument.isin) - : null, + creditRating: instrument?.isin ? + await this.ratings.rating(instrument.isin) : + null, }; + } throw new Error('Unsupported catalog source'); } @@ -473,6 +488,7 @@ export class CatalogEnrichmentService { let i = 0; i < this.queue.length && batch.length < catalogSearchLimit; ) { + // eslint-disable-next-line security/detect-object-injection -- Numeric queue indexes and validated instrument UIDs select internal data only. const next = this.queue[i]; if ( next.input.kind === 'last-price' && @@ -490,38 +506,40 @@ export class CatalogEnrichmentService { try { const token = await this.access.tokenForAttempt(producer.attempt); if (!token) failed = true; - else if (producer.input.kind === 'last-price') + else if (producer.input.kind === 'last-price') { prices = await this.tbank.getLastPrices( token, batch.map((item) => item.input.uid), ); - else + } else { payload = await this.load(producer.input, token, producer.attempt); + } } catch (error) { failed = true; - if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') + if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') { await this.access.rejectMembershipToken(producer.attempt); - else + } else { this.logger.event('warn', 'catalog-enrichment.failed', { code: 'TBANK_UNAVAILABLE', }); + } } for (const item of batch) { const price = prices?.find((entry) => entry.instrumentUid === item.input.uid) ?? null; const value = - item.input.kind === 'last-price' - ? { - price: price - ? { + item.input.kind === 'last-price' ? + { + price: price ? + { instrumentUid: item.input.uid, price: price.price, time: price.time, - } - : null, - } - : payload; + } : + null, + } : + payload; // Keep accepting observers while publication awaits SQLite. item.observers.unshift({ attempt: item.attempt, input: item.input }); for (const observer of item.observers) { @@ -558,14 +576,14 @@ export class CatalogEnrichmentService { } } function sourceInputs( - instrumentType: 'share' | 'bond', + instrumentType: 'bond' | 'share', uids: string[], kinds: CatalogSource['kind'][], range?: { from: string; - to: string; ticker?: string; tickers?: Record; + to: string; }, ): CatalogSource[] { return kinds.flatMap((kind) => @@ -573,20 +591,22 @@ function sourceInputs( instrumentType, uid, kind, - ...(kind === 'bond-events' && (range?.tickers?.[uid] || range?.ticker) - ? { ticker: range?.tickers?.[uid] || range?.ticker } - : {}), - ...(['coupons', 'dividends'].includes(kind) - ? { - rangeKey: range - ? JSON.stringify([range.from, range.to]) - : String(new Date().getUTCFullYear()), - } - : {}), + // eslint-disable-next-line security/detect-object-injection -- Numeric queue indexes and validated instrument UIDs select internal data only. + ...(kind === 'bond-events' && (range?.tickers?.[uid] || range?.ticker) ? + // eslint-disable-next-line security/detect-object-injection -- Numeric queue indexes and validated instrument UIDs select internal data only. + { ticker: range?.tickers?.[uid] || range?.ticker } : + {}), + ...(['coupons', 'dividends'].includes(kind) ? + { + rangeKey: range ? + JSON.stringify([range.from, range.to]) : + String(new Date().getUTCFullYear()), + } : + {}), })), ); } -function publicInstrument(value: TbankShare | TbankBond) { +function publicInstrument(value: TbankBond | TbankShare) { const bond = value as TbankBond; return { uid: value.uid, @@ -625,38 +645,39 @@ const previousCloseLookbackMs = const payoutHistoryYears = 5; const payoutFutureYears = 2; export type CatalogPayload = { - instrument?: TbankShare | TbankBond | null; - price?: TbankLastPrice | null; - candle?: TbankCandle | null; - tradingStatus?: string | null; - description?: string | null; - fundamentals?: TbankFundamentals | null; - coupons?: TbankCoupon[]; - dividends?: TbankDividend[]; bondEvents?: MoexBondEvent[]; - effectiveYield?: Awaited>; + candle?: null | TbankCandle; + coupons?: TbankCoupon[]; creditRating?: Awaited>; + description?: null | string; + dividends?: TbankDividend[]; + effectiveYield?: Awaited>; + fundamentals?: null | TbankFundamentals; + instrument?: null | TbankBond | TbankShare; + price?: null | TbankLastPrice; + tradingStatus?: null | string; }; function sourcePolicy(kind: CatalogSource['kind']) { if ( [ - 'last-price', 'bond-current', - 'trading-status', 'comparison', + 'last-price', + 'trading-status', 'yield', ].includes(kind) - ) + ) { return 'quote'; + } if (kind === 'description' || kind === 'fundamentals') return 'company'; - if (['coupons', 'dividends', 'bond-events'].includes(kind)) return 'calendar'; + if (['bond-events', 'coupons', 'dividends'].includes(kind)) return 'calendar'; return 'metadata'; } function producerKey(input: CatalogSource) { return input.instrumentType === 'bond' && - (input.kind === 'detail' || input.kind === 'bond-current') - ? `bond-metadata:${input.uid}` - : catalogScope(input); + (input.kind === 'detail' || input.kind === 'bond-current') ? + `bond-metadata:${input.uid}` : + catalogScope(input); } const quotationScale = 9; diff --git a/src/modules/investments/instruments/application/catalog-selection.ts b/src/modules/investments/instruments/application/catalog-selection.ts index 0f39396..c91f26a 100644 --- a/src/modules/investments/instruments/application/catalog-selection.ts +++ b/src/modules/investments/instruments/application/catalog-selection.ts @@ -5,13 +5,13 @@ export const catalogSearchLimit = 30; export function selectCatalogInstruments( found: T[], query: string, - instrumentType: 'share' | 'bond', + instrumentType: 'bond' | 'share', ): (T & { uid: string })[] { const seen = new Set(); const kind = - instrumentType === 'share' - ? 'INSTRUMENT_TYPE_SHARE' - : 'INSTRUMENT_TYPE_BOND'; + instrumentType === 'share' ? + 'INSTRUMENT_TYPE_SHARE' : + 'INSTRUMENT_TYPE_BOND'; const preferredClass = instrumentType === 'share' ? 'TQBR' : 'TQOB'; return found .filter( @@ -21,17 +21,17 @@ export function selectCatalogInstruments( .sort( (a, b) => Number(b.apiTradeAvailableFlag === true) - - Number(a.apiTradeAvailableFlag === true) || + Number(a.apiTradeAvailableFlag === true) || Number(b.ticker?.toUpperCase() === query.toUpperCase()) - - Number(a.ticker?.toUpperCase() === query.toUpperCase()) || + Number(a.ticker?.toUpperCase() === query.toUpperCase()) || Number(b.classCode === preferredClass) - - Number(a.classCode === preferredClass) || + Number(a.classCode === preferredClass) || a.uid.localeCompare(b.uid), ) .filter((instrument) => { - const key = instrument.isin?.trim() - ? `isin:${instrument.isin.trim().toUpperCase()}` - : `uid:${instrument.uid}`; + const key = instrument.isin?.trim() ? + `isin:${instrument.isin.trim().toUpperCase()}` : + `uid:${instrument.uid}`; if (seen.has(key)) return false; seen.add(key); return true; diff --git a/src/modules/investments/instruments/application/instrument-market.ts b/src/modules/investments/instruments/application/instrument-market.ts index b09f461..a43b3c3 100644 --- a/src/modules/investments/instruments/application/instrument-market.ts +++ b/src/modules/investments/instruments/application/instrument-market.ts @@ -1,15 +1,16 @@ -import { MILLISECONDS_PER_SECOND } from '../../../../common/time'; import type { TbankCandle, TbankTimestamp, } from '../../../../integrations/tbank/tbank.api'; +import { MILLISECONDS_PER_SECOND } from '../../../../common/time'; + const MOSCOW_OFFSET_MS = 10_800_000; -export function timestamp(value?: TbankTimestamp): string | null { +export function timestamp(value?: TbankTimestamp): null | string { const seconds = Number(value?.seconds); - return Number.isFinite(seconds) && seconds > 0 - ? new Date(seconds * MILLISECONDS_PER_SECOND).toISOString() - : null; + return Number.isFinite(seconds) && seconds > 0 ? + new Date(seconds * MILLISECONDS_PER_SECOND).toISOString() : + null; } export function moscowDay(iso: string): string { return new Date(Date.parse(iso) + MOSCOW_OFFSET_MS) @@ -19,7 +20,7 @@ export function moscowDay(iso: string): string { export function previousClose( candles: TbankCandle[], priceAt: string, -): TbankCandle | null { +): null | TbankCandle { return ( candles .filter((candle) => { @@ -32,6 +33,6 @@ export function previousClose( ); }) .sort((a, b) => Number(b.time?.seconds) - Number(a.time?.seconds))[0] ?? - null + null ); } diff --git a/src/modules/investments/instruments/application/instrument-position.ts b/src/modules/investments/instruments/application/instrument-position.ts index 2c84ff4..e808f8c 100644 --- a/src/modules/investments/instruments/application/instrument-position.ts +++ b/src/modules/investments/instruments/application/instrument-position.ts @@ -5,7 +5,6 @@ import { formatDecimal, multiply, } from '../../../../common/decimal'; - import { MoneyDto } from '../../../../common/money.dto'; import { PortfolioDto, @@ -19,7 +18,7 @@ const RETURN_PERCENT_DECIMAL_PLACES = 6; export function instrumentPosition( portfolio: PortfolioDto, uid: string, - type: 'STOCK' | 'BOND', + type: 'BOND' | 'STOCK', ): StockPositionDto { const positions = portfolio.positions.filter( (p) => p.type === type && p.instrumentUid === uid, @@ -40,8 +39,8 @@ export function instrumentPosition( currentValue, absoluteReturn, averagePurchasePrice: - positive && totalCost - ? { + positive && totalCost ? + { currency: totalCost.currency, value: formatDecimal( divide( @@ -50,15 +49,15 @@ export function instrumentPosition( PRICE_DECIMAL_PLACES, ), ), - } - : null, + } : + null, returnPercent: positive ? percent(absoluteReturn, totalCost) : null, accounts: positions.map((p) => ({ ...p, returnPercent: - decimalFromString(p.quantity).value > 0n - ? percent(p.absoluteReturn, cost(p)) - : null, + decimalFromString(p.quantity).value > 0n ? + percent(p.absoluteReturn, cost(p)) : + null, })), isStale: portfolio.isStale, observedAt: portfolio.lastSuccessfulAt, @@ -66,8 +65,8 @@ export function instrumentPosition( } function cost(position: PortfolioPositionDto): MoneyDto | null { const price = position.averagePurchasePrice; - return price - ? { + return price ? + { currency: price.currency, value: formatDecimal( multiply( @@ -75,16 +74,17 @@ function cost(position: PortfolioPositionDto): MoneyDto | null { decimalFromString(position.quantity), ), ), - } - : null; + } : + null; } function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null { const currency = values[0]?.currency; if ( !currency || !values.every((v): v is MoneyDto => v !== null && v.currency === currency) - ) + ) { return null; + } return { currency, value: formatDecimal( @@ -98,14 +98,15 @@ function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null { function percent( result: MoneyDto | null, base: MoneyDto | null, -): string | null { +): null | string { if ( !base || !result || base.currency !== result.currency || decimalFromString(base.value).value <= 0n - ) + ) { return null; + } return formatDecimal( divide( multiply(decimalFromString(result.value), decimalFromString('100')), diff --git a/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts b/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts index b8cd052..0ee3d2b 100644 --- a/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts +++ b/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts @@ -1,8 +1,10 @@ -import { CatalogDiscoveryService } from '../../application/catalog-discovery.service'; import { Injectable } from '@nestjs/common'; -import { formatDecimal } from '../../../../../common/decimal'; + import type { TbankBond } from '../../../../../integrations/tbank/tbank.api'; + +import { formatDecimal } from '../../../../../common/decimal'; import { decimal } from '../../../../../integrations/tbank/decimal'; +import { CatalogDiscoveryService } from '../../application/catalog-discovery.service'; import { timestamp } from '../../application/instrument-market'; import { BondDto, @@ -32,23 +34,24 @@ export class BondCatalogService { data: result.data?.map((item) => { const short = toBond(item); - const detail = item.detail - ? toBond({ ...item.detail, uid: item.uid }) - : short; + const detail = item.detail ? + toBond({ ...item.detail, uid: item.uid }) : + short; const last = item.price; return { ...detail, classCode: detail.classCode ?? short.classCode, isin: detail.isin ?? short.isin, apiTradeAvailable: short.apiTradeAvailable, - pricePercent: last?.price - ? formatDecimal(decimal(last.price)) - : null, + pricePercent: last?.price ? + formatDecimal(decimal(last.price)) : + null, priceAt: timestamp(last?.time), }; }) ?? null, }; } + async get( userId: string, uid: string, @@ -57,11 +60,12 @@ export class BondCatalogService { const result = await this.discovery.detail(userId, 'bond', uid, observe); return { ...result, - data: result.data - ? { ...toBond(result.data), slug: result.data.slug } - : null, + data: result.data ? + { ...toBond(result.data), slug: result.data.slug } : + null, }; } + async getByTicker( userId: string, ticker: string, @@ -75,9 +79,9 @@ export class BondCatalogService { ); return { ...result, - data: result.data - ? { ...toBond(result.data), slug: result.data.slug } - : null, + data: result.data ? + { ...toBond(result.data), slug: result.data.slug } : + null, }; } } diff --git a/src/modules/investments/instruments/bonds/application/bond-income.service.ts b/src/modules/investments/instruments/bonds/application/bond-income.service.ts index 7e82d38..a9f4869 100644 --- a/src/modules/investments/instruments/bonds/application/bond-income.service.ts +++ b/src/modules/investments/instruments/bonds/application/bond-income.service.ts @@ -1,18 +1,19 @@ import { Injectable, NotFoundException } from '@nestjs/common'; + import { decimalFromString, + divide, formatDecimal, multiply, - divide, } from '../../../../../common/decimal'; import { TbankAccessService, - type TbankCoupon, type TbankBond, + type TbankCoupon, } from '../../../../../integrations/tbank/tbank.api'; import { - PortfolioService, ForecastService, + PortfolioService, } from '../../../portfolio/portfolio.api'; import { CatalogEnrichmentService, @@ -20,14 +21,14 @@ import { } from '../../application/catalog-enrichment.service'; import { moscowDay, timestamp } from '../../application/instrument-market'; import { instrumentPosition } from '../../application/instrument-position'; +import { money } from '../infrastructure/bond-money.mapper'; import { BondCouponsDto, BondCouponsResourceDto, - BondPositionDto, BondMetricsDto, BondMetricsResourceDto, + BondPositionDto, } from '../presentation/bond.dto'; -import { money } from '../infrastructure/bond-money.mapper'; import { bondQuote } from './bond-market.service'; const ISO_YEAR_LENGTH = 4; const INCOME_HISTORY_YEARS = 5; @@ -40,6 +41,7 @@ export class BondIncomeService { private readonly access: TbankAccessService, private readonly forecasts: ForecastService, ) {} + async metrics( userId: string, uid: string, @@ -53,9 +55,9 @@ export class BondIncomeService { observe, ); return { - data: result.values.instrument - ? bondMetrics(result.values, result.unavailable, result.observedAt) - : null, + data: result.values.instrument ? + bondMetrics(result.values, result.unavailable, result.observedAt) : + null, meta: result.meta, }; } @@ -67,6 +69,7 @@ export class BondIncomeService { meta: { ...saved.meta, resourceKey: `bond/position/${uid}` }, }; } + async coupons( userId: string, uid: string, @@ -85,21 +88,22 @@ export class BondIncomeService { ['detail', 'coupons'], { from, to }, ); - if (sources[0].meta.availability === 'complete' && !sources[0].instrument) + if (sources[0].meta.availability === 'complete' && !sources[0].instrument) { throw new NotFoundException(); + } const events = sources[1].payload?.coupons; - const position = portfolio.data - ? instrumentPosition(portfolio.data, uid, 'BOND') - : null; - const projected = events - ? bondCoupons( + const position = portfolio.data ? + instrumentPosition(portfolio.data, uid, 'BOND') : + null; + const projected = events ? + bondCoupons( events, position, { from, to, today }, sources[1].meta.lastSuccessfulAt ?? new Date().toISOString(), !portfolio.data, - ) - : undefined; + ) : + undefined; const result = await this.forecasts.result({ userId, revision: connection.revision, @@ -110,9 +114,9 @@ export class BondIncomeService { candidate: portfolio.data && sources[0].instrument ? projected : undefined, }); - return result.data || !projected - ? result - : { + return result.data || !projected ? + result : + { data: bondCoupons( events ?? [], null, @@ -135,19 +139,19 @@ export function bondCoupons( const { from, to, today } = window; const quantity = position?.accounts.length && - position.accounts.every((p) => decimalFromString(p.quantity).value > 0n) - ? decimalFromString(position.quantity) - : null; + position.accounts.every((p) => decimalFromString(p.quantity).value > 0n) ? + decimalFromString(position.quantity) : + null; const coupons = events .map((event) => { const paymentDate = timestamp(event.couponDate)?.slice(0, 10) ?? null; const recordDate = timestamp(event.fixDate)?.slice(0, 10) ?? null; const perBond = money(event.payOneBond); - const timing = !paymentDate - ? ('undated' as const) - : paymentDate >= today - ? ('upcoming' as const) - : ('past' as const); + const timing = !paymentDate ? + ('undated' as const) : + paymentDate >= today ? + ('upcoming' as const) : + ('past' as const); return { number: event.couponNumber ?? null, paymentDate, @@ -161,14 +165,14 @@ export function bondCoupons( perBond && timing === 'upcoming' && recordDate && - recordDate >= today - ? { + recordDate >= today ? + { currency: perBond.currency, value: formatDecimal( multiply(quantity, decimalFromString(perBond.value)), ), - } - : null, + } : + null, }; }) .sort((a, b) => (a.paymentDate ?? '').localeCompare(b.paymentDate ?? '')); @@ -214,8 +218,8 @@ export function bondMetrics( clean && coupon.currency === clean.currency && decimalFromString(coupon.value).value >= 0n && - decimalFromString(clean.value).value > 0n - ? formatDecimal( + decimalFromString(clean.value).value > 0n ? + formatDecimal( divide( multiply( decimalFromString(coupon.value), @@ -227,14 +231,14 @@ export function bondMetrics( ), COUPON_YIELD_DECIMAL_PLACES, ), - ) - : null; + ) : + null; const couponType = - next?.couponType && next.couponType !== 'COUPON_TYPE_UNSPECIFIED' - ? next.couponType - : instrument.floatingCouponFlag === true - ? 'COUPON_TYPE_FLOATING' - : null; + next?.couponType && next.couponType !== 'COUPON_TYPE_UNSPECIFIED' ? + next.couponType : + instrument.floatingCouponFlag === true ? + 'COUPON_TYPE_FLOATING' : + null; return { creditRating, coupon, diff --git a/src/modules/investments/instruments/bonds/application/bond-market.service.ts b/src/modules/investments/instruments/bonds/application/bond-market.service.ts index 6365538..c12b5b8 100644 --- a/src/modules/investments/instruments/bonds/application/bond-market.service.ts +++ b/src/modules/investments/instruments/bonds/application/bond-market.service.ts @@ -1,10 +1,7 @@ -import { CandleService } from '../../application/candle.service'; -import type { TbankBond } from '../../../../../integrations/tbank/tbank.api'; -import { - CatalogEnrichmentService, - type CatalogPayload, -} from '../../application/catalog-enrichment.service'; import { Injectable } from '@nestjs/common'; + +import type { TbankBond } from '../../../../../integrations/tbank/tbank.api'; + import { add, decimalFromString, @@ -14,15 +11,19 @@ import { subtract, } from '../../../../../common/decimal'; import { decimal } from '../../../../../integrations/tbank/decimal'; +import { CandleService } from '../../application/candle.service'; +import { + CatalogEnrichmentService, + type CatalogPayload, +} from '../../application/catalog-enrichment.service'; import { timestamp } from '../../application/instrument-market'; +import { money } from '../infrastructure/bond-money.mapper'; import { BondCandlesQueryDto, BondQuoteDto, BondQuoteResourceDto, } from '../presentation/bond.dto'; -import { money } from '../infrastructure/bond-money.mapper'; - const PRICE_DECIMAL_PLACES = 9; @Injectable() export class BondMarketService { @@ -30,6 +31,7 @@ export class BondMarketService { private readonly history: CandleService, private readonly sources: CatalogEnrichmentService, ) {} + async quote( userId: string, uid: string, @@ -43,12 +45,13 @@ export class BondMarketService { observe, ); return { - data: result.values.instrument - ? bondQuote(result.values, result.unavailable, result.observedAt) - : null, + data: result.values.instrument ? + bondQuote(result.values, result.unavailable, result.observedAt) : + null, meta: result.meta, }; } + candles( userId: string, uid: string, @@ -77,8 +80,8 @@ export function bondQuote( const cleanPrice = pricePercent !== null && nominal && - decimalFromString(nominal.value).value > 0n - ? { + decimalFromString(nominal.value).value > 0n ? + { currency: nominal.currency, value: formatDecimal( divide( @@ -90,11 +93,11 @@ export function bondQuote( PRICE_DECIMAL_PLACES, ), ), - } - : null; + } : + null; const dirtyPrice = - cleanPrice && aci && aci.currency === cleanPrice.currency - ? { + cleanPrice && aci && aci.currency === cleanPrice.currency ? + { currency: cleanPrice.currency, value: formatDecimal( add( @@ -102,8 +105,8 @@ export function bondQuote( decimalFromString(aci.value), ), ), - } - : null; + } : + null; return { pricePercent, cleanPrice, @@ -112,9 +115,9 @@ export function bondQuote( priceAt, tradingStatus, changePercentagePoints: - last?.price && previous?.close - ? formatDecimal(subtract(decimal(last.price), decimal(previous.close))) - : null, + last?.price && previous?.close ? + formatDecimal(subtract(decimal(last.price), decimal(previous.close))) : + null, previousCloseDate: timestamp(previous?.time)?.slice(0, 10) ?? null, unavailable, observedAt, diff --git a/src/modules/investments/instruments/bonds/application/bond.spec.ts b/src/modules/investments/instruments/bonds/application/bond.spec.ts index 6f8f234..5f9098b 100644 --- a/src/modules/investments/instruments/bonds/application/bond.spec.ts +++ b/src/modules/investments/instruments/bonds/application/bond.spec.ts @@ -1,7 +1,8 @@ +import type { PortfolioDto } from '../../../portfolio/portfolio.api'; + +import { instrumentPosition } from '../../application/instrument-position'; import { bondCoupons, bondMetrics } from './bond-income.service'; import { bondQuote } from './bond-market.service'; -import { instrumentPosition } from '../../application/instrument-position'; -import type { PortfolioDto } from '../../../portfolio/portfolio.api'; jest.mock('../../../../../prisma/prisma.service', () => ({ PrismaService: class {}, })); @@ -122,9 +123,9 @@ describe('bond financial projections', () => { { couponDate: time('2026-10-10'), payOneBond: - scenario === 'unknown' - ? undefined - : money('30', scenario === 'currency' ? 'usd' : 'rub'), + scenario === 'unknown' ? + undefined : + money('30', scenario === 'currency' ? 'usd' : 'rub'), couponPeriod: scenario === 'period' ? 0 : 90, }, ], diff --git a/src/modules/investments/instruments/bonds/infrastructure/bond-money.mapper.ts b/src/modules/investments/instruments/bonds/infrastructure/bond-money.mapper.ts index a57dead..861a30b 100644 --- a/src/modules/investments/instruments/bonds/infrastructure/bond-money.mapper.ts +++ b/src/modules/investments/instruments/bonds/infrastructure/bond-money.mapper.ts @@ -1,10 +1,10 @@ import { formatDecimal } from '../../../../../common/decimal'; -import { type TbankMoney } from '../../../../../integrations/tbank/tbank.api'; import { decimal } from '../../../../../integrations/tbank/decimal'; +import { type TbankMoney } from '../../../../../integrations/tbank/tbank.api'; export function money(value?: TbankMoney) { const currency = value?.currency?.toUpperCase(); - return value && currency - ? { currency, value: formatDecimal(decimal(value)) } - : null; + return value && currency ? + { currency, value: formatDecimal(decimal(value)) } : + null; } diff --git a/src/modules/investments/instruments/bonds/presentation/bond.controller.ts b/src/modules/investments/instruments/bonds/presentation/bond.controller.ts index da9bdf7..1675bbb 100644 --- a/src/modules/investments/instruments/bonds/presentation/bond.controller.ts +++ b/src/modules/investments/instruments/bonds/presentation/bond.controller.ts @@ -1,4 +1,5 @@ -import { InvestmentReadQueryDto } from '../../../investment-resource.dto'; +import type { Request } from 'express'; + import { Controller, Get, @@ -9,24 +10,25 @@ import { 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 { BondCatalogService } from '../application/bond-catalog.service'; import { BondIncomeService } from '../application/bond-income.service'; import { BondMarketService } from '../application/bond-market.service'; import { - BondCandlesResourceDto, BondCandlesQueryDto, + BondCandlesResourceDto, BondCouponsResourceDto, - BondResourceDto, BondMetricsResourceDto, BondPositionResourceDto, BondQuoteResourceDto, + BondResourceDto, BondSearchQueryDto, BondSearchResourceDto, BondTickerParamsDto, } from './bond.dto'; -import { BondCatalogService } from '../application/bond-catalog.service'; @ApiTags('bonds') @ApiProblemResponses() @@ -39,6 +41,7 @@ export class BondController { private readonly market: BondMarketService, private readonly income: BondIncomeService, ) {} + @Get(':uid/metrics') @ApiOkResponse({ type: BondMetricsResourceDto }) metrics( @@ -48,6 +51,7 @@ export class BondController { ) { return this.income.metrics(request.user.sub, uid, query.sync === 'observe'); } + @Get() @ApiOkResponse({ type: BondSearchResourceDto }) search( @@ -60,6 +64,7 @@ export class BondController { query.sync === 'observe', ); } + @Get('by-ticker/:ticker') @ApiOkResponse({ type: BondResourceDto }) getByTicker( @@ -73,6 +78,7 @@ export class BondController { query.sync === 'observe', ); } + @Get(':uid') @ApiOkResponse({ type: BondResourceDto }) get( @@ -82,6 +88,7 @@ export class BondController { ) { return this.bonds.get(request.user.sub, uid, query.sync === 'observe'); } + @Get(':uid/quote') @ApiOkResponse({ type: BondQuoteResourceDto }) quote( @@ -91,6 +98,7 @@ export class BondController { ) { return this.market.quote(request.user.sub, uid, query.sync === 'observe'); } + @Get(':uid/candles') @ApiOkResponse({ type: BondCandlesResourceDto }) candles( @@ -105,6 +113,7 @@ export class BondController { query.sync === 'observe', ); } + @Get(':uid/position') @ApiOkResponse({ type: BondPositionResourceDto }) position( @@ -118,6 +127,7 @@ export class BondController { query.sync === 'observe', ); } + @Get(':uid/coupons') @ApiOkResponse({ type: BondCouponsResourceDto }) coupons( diff --git a/src/modules/investments/instruments/bonds/presentation/bond.dto.ts b/src/modules/investments/instruments/bonds/presentation/bond.dto.ts index 009beb8..a7624c1 100644 --- a/src/modules/investments/instruments/bonds/presentation/bond.dto.ts +++ b/src/modules/investments/instruments/bonds/presentation/bond.dto.ts @@ -1,7 +1,7 @@ -import { InvestmentResourceMetaDto } from '../../../investment-resource.dto'; import { ApiProperty } from '@nestjs/swagger'; -import { MoneyDto } from '../../../../../common/money.dto'; +import { MoneyDto } from '../../../../../common/money.dto'; +import { InvestmentResourceMetaDto } from '../../../investment-resource.dto'; import { StockCandlesDto, StockCandlesQueryDto, @@ -20,44 +20,61 @@ export class BondDto { description: 'Unambiguous ticker URL segment, or the exact instrument UID.', }) declare slug: string; + @ApiProperty() declare name: string; - @ApiProperty({ type: String, nullable: true }) declare isin: string | null; + @ApiProperty({ type: String, nullable: true }) declare isin: null | string; @ApiProperty({ type: String, nullable: true }) declare classCode: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare realExchange: - string | null; + null | string; + @ApiProperty({ type: Boolean, nullable: true }) declare apiTradeAvailable: boolean | null; - @ApiProperty({ type: String, nullable: true }) declare ticker: string | null; + + @ApiProperty({ type: String, nullable: true }) declare ticker: null | string; @ApiProperty({ type: String, nullable: true }) declare logoName: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare currency: - string | null; - @ApiProperty({ type: String, nullable: true }) declare sector: string | null; - @ApiProperty({ type: Number, nullable: true }) declare lot: number | null; + null | string; + + @ApiProperty({ type: String, nullable: true }) declare sector: null | string; + @ApiProperty({ type: Number, nullable: true }) declare lot: null | number; @ApiProperty({ type: String, nullable: true, format: 'date-time' }) - declare historyFrom: string | null; + declare historyFrom: null | string; + @ApiProperty({ type: MoneyDto, nullable: true }) declare nominal: MoneyDto | null; + @ApiProperty({ type: MoneyDto, nullable: true }) declare initialNominal: MoneyDto | null; + @ApiProperty({ type: MoneyDto, nullable: true }) declare aci: MoneyDto | null; @ApiProperty({ type: String, nullable: true }) declare maturityDate: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare callDate: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare bondType: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare riskLevel: - string | null; + null | string; + @ApiProperty({ type: Number, nullable: true }) declare couponQuantityPerYear: - number | null; + null | number; + @ApiProperty({ type: Boolean, nullable: true }) declare floatingCoupon: boolean | null; + @ApiProperty({ type: Boolean, nullable: true }) declare perpetual: boolean | null; + @ApiProperty({ type: Boolean, nullable: true }) declare amortization: boolean | null; + @ApiProperty({ type: Boolean, nullable: true }) declare subordinated: boolean | null; } @@ -68,13 +85,15 @@ export class BondSearchResultDto extends BondDto { nullable: true, description: 'Clean price in percent of nominal, not money.', }) - declare pricePercent: string | null; + declare pricePercent: null | string; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) - declare priceAt: string | null; + declare priceAt: null | string; } export class BondQuoteDto { @ApiProperty({ type: String, nullable: true }) declare pricePercent: - string | null; + null | string; + @ApiProperty({ type: MoneyDto, nullable: true, @@ -82,6 +101,7 @@ export class BondQuoteDto { 'Indicative clean value at the current nominal; no FX conversion.', }) declare cleanPrice: MoneyDto | null; + @ApiProperty({ type: MoneyDto, nullable: true }) declare aci: MoneyDto | null; @ApiProperty({ type: MoneyDto, @@ -90,41 +110,53 @@ export class BondQuoteDto { 'Indicative clean value plus current accrued interest, excluding fees.', }) declare dirtyPrice: MoneyDto | null; + @ApiProperty({ type: String, nullable: true }) - declare changePercentagePoints: string | null; + declare changePercentagePoints: null | string; + @ApiProperty({ type: String, nullable: true }) declare previousCloseDate: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare tradingStatus: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) - declare priceAt: string | null; - @ApiProperty({ type: [String] }) declare unavailable: string[]; + declare priceAt: null | string; + + @ApiProperty({ type: String, isArray: true }) declare unavailable: string[]; @ApiProperty({ format: 'date-time' }) declare observedAt: string; } export class BondCouponDto { - @ApiProperty({ type: String, nullable: true }) declare number: string | null; + @ApiProperty({ type: String, nullable: true }) declare number: null | string; @ApiProperty({ type: String, nullable: true }) declare paymentDate: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare recordDate: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare couponType: - string | null; + null | string; + @ApiProperty({ type: Number, nullable: true }) declare periodDays: - number | null; + null | number; + @ApiProperty({ type: MoneyDto, nullable: true }) declare perBond: MoneyDto | null; + @ApiProperty({ type: MoneyDto, nullable: true }) declare estimatedAmount: MoneyDto | null; + @ApiProperty({ enum: ['upcoming', 'past', 'undated'] }) declare timing: - 'upcoming' | 'past' | 'undated'; + 'past' | 'undated' | 'upcoming'; } export class BondCouponsDto { - @ApiProperty({ type: [BondCouponDto] }) declare coupons: BondCouponDto[]; + @ApiProperty({ type: BondCouponDto, isArray: true }) declare coupons: BondCouponDto[]; @ApiProperty() declare from: string; @ApiProperty() declare to: string; @ApiProperty() declare observedAt: string; @ApiProperty({ type: String, nullable: true }) declare positionAt: - string | null; + null | string; + @ApiProperty() declare estimateUnavailable: boolean; @ApiProperty() declare positionIsStale: boolean; } @@ -132,6 +164,7 @@ export class BondCouponsDto { export class BondYieldDto { @ApiProperty({ description: 'MOEX effective annual yield in percent.' }) declare value: string; + @ApiProperty() declare pricePercent: string; @ApiProperty({ format: 'date' }) declare date: string; @ApiProperty({ @@ -139,14 +172,16 @@ export class BondYieldDto { nullable: true, description: 'MOEX yield target, e.g. MATDATE or OFFERDATE.', }) - declare dateType: string | null; + declare dateType: null | string; + @ApiProperty({ format: 'date-time' }) declare tradeAt: string; } export class BondCreditRatingDto { @ApiProperty({ enum: ['NCR'] }) declare agency: 'NCR'; - @ApiProperty({ type: String, nullable: true }) declare value: string | null; + @ApiProperty({ type: String, nullable: true }) declare value: null | string; @ApiProperty({ enum: ['active', 'withdrawn'] }) declare status: 'active' | 'withdrawn'; + @ApiProperty({ format: 'date' }) declare date: string; @ApiProperty({ format: 'uri' }) declare sourceUrl: string; @ApiProperty({ format: 'date-time' }) declare observedAt: string; @@ -154,39 +189,50 @@ export class BondCreditRatingDto { export class BondMetricsDto { @ApiProperty({ type: BondCreditRatingDto, nullable: true }) declare creditRating: BondCreditRatingDto | null; + @ApiProperty({ type: MoneyDto, nullable: true }) declare coupon: MoneyDto | null; + @ApiProperty({ type: String, nullable: true, format: 'date' }) - declare couponDate: string | null; + declare couponDate: null | string; + @ApiProperty({ type: String, nullable: true }) declare couponType: - string | null; + null | string; + @ApiProperty({ type: Number, nullable: true }) declare couponPeriodDays: - number | null; + null | number; + @ApiProperty({ type: String, nullable: true, description: 'Next coupon × 365 / period days / indicative clean price × 100. Not yield to maturity.', }) - declare couponYieldPercent: string | null; + declare couponYieldPercent: null | string; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) - declare couponYieldPriceAt: string | null; + declare couponYieldPriceAt: null | string; + @ApiProperty({ type: BondYieldDto, nullable: true }) declare effectiveYield: BondYieldDto | null; - @ApiProperty({ type: [String] }) declare unavailable: string[]; + + @ApiProperty({ type: String, isArray: true }) declare unavailable: string[]; @ApiProperty({ format: 'date-time' }) declare observedAt: string; } export class BondPositionResourceDto { @ApiProperty({ type: BondPositionDto, nullable: true }) declare data: BondPositionDto | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } export class BondSearchResourceDto { + // eslint-disable-next-line @darraghor/nestjs-typed/api-property-returning-array-should-set-array -- Nullable array union requires isArray in the explicit wire schema. @ApiProperty({ type: BondSearchResultDto, isArray: true, nullable: true }) declare data: BondSearchResultDto[] | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } @@ -200,6 +246,7 @@ export class BondResourceDto { export class BondQuoteResourceDto { @ApiProperty({ type: BondQuoteDto, nullable: true }) declare data: BondQuoteDto | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } @@ -207,6 +254,7 @@ export class BondQuoteResourceDto { export class BondMetricsResourceDto { @ApiProperty({ type: BondMetricsDto, nullable: true }) declare data: BondMetricsDto | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } @@ -214,6 +262,7 @@ export class BondMetricsResourceDto { export class BondCandlesResourceDto { @ApiProperty({ type: BondCandlesDto, nullable: true }) declare data: BondCandlesDto | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } @@ -221,6 +270,7 @@ export class BondCandlesResourceDto { export class BondCouponsResourceDto { @ApiProperty({ type: BondCouponsDto, nullable: true }) declare data: BondCouponsDto | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } diff --git a/src/modules/investments/instruments/domain/candle-coverage.spec.ts b/src/modules/investments/instruments/domain/candle-coverage.spec.ts index 549051a..b2915df 100644 --- a/src/modules/investments/instruments/domain/candle-coverage.spec.ts +++ b/src/modules/investments/instruments/domain/candle-coverage.spec.ts @@ -67,7 +67,7 @@ describe('source-confirmed daily candle coverage', () => { expect(window.to.valueOf() - window.from.valueOf()).toBeLessThanOrEqual( 365 * 86400000, ); - if (index) expect(window.from).toEqual(windows[index - 1].to); + expect(window.from).toEqual(index ? windows[index - 1].to : range.from); }); }); }); diff --git a/src/modules/investments/instruments/domain/candle-coverage.ts b/src/modules/investments/instruments/domain/candle-coverage.ts index d4302c9..91e9c5d 100644 --- a/src/modules/investments/instruments/domain/candle-coverage.ts +++ b/src/modules/investments/instruments/domain/candle-coverage.ts @@ -2,13 +2,13 @@ import { MILLISECONDS_PER_DAY } from '../../../../common/time'; export type CandleRange = { from: Date; to: Date }; export type CandleCoverage = CandleRange & { - isComplete: boolean; fetchedAt: Date; + isComplete: boolean; }; export type SavedCandle = { - time: string; close: string; isComplete: boolean; + time: string; }; const windowDays = 365; const tailFreshForMs = 60_000; @@ -30,14 +30,15 @@ export function candleGaps( !covered.isComplete && (now.valueOf() - covered.fetchedAt.valueOf() >= tailFreshForMs || utcDay(now).valueOf() !== utcDay(covered.fetchedAt).valueOf()) - ) + ) { continue; + } gaps = gaps.flatMap((gap) => { if (covered.to <= gap.from || covered.from >= gap.to) return [gap]; return [ - ...(covered.from > gap.from - ? [{ from: gap.from, to: covered.from }] - : []), + ...(covered.from > gap.from ? + [{ from: gap.from, to: covered.from }] : + []), ...(covered.to < gap.to ? [{ from: covered.to, to: gap.to }] : []), ]; }); @@ -73,11 +74,14 @@ export function confirmedCandleCoverage( for (const boundary of [ midnight, ...[...incompleteDays].flatMap((day) => [day, day + MILLISECONDS_PER_DAY]), - ]) - if (boundary > range.from.valueOf() && boundary < range.to.valueOf()) + ]) { + if (boundary > range.from.valueOf() && boundary < range.to.valueOf()) { boundaries.add(boundary); + } + } const sorted = [...boundaries].sort((a, b) => a - b); return sorted.slice(1).map((to, index) => { + // eslint-disable-next-line security/detect-object-injection -- The index comes from the sorted numeric boundary array. const from = sorted[index]; return { from: new Date(from), diff --git a/src/modules/investments/instruments/infrastructure/candle.repository.ts b/src/modules/investments/instruments/infrastructure/candle.repository.ts index d3c4273..8363bde 100644 --- a/src/modules/investments/instruments/infrastructure/candle.repository.ts +++ b/src/modules/investments/instruments/infrastructure/candle.repository.ts @@ -1,24 +1,25 @@ import { Injectable } from '@nestjs/common'; -import { randomUUID } from 'node:crypto'; -import { createHash } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; + import type { Prisma } from '../../../../../generated/prisma/client'; -import { PrismaService } from '../../../../prisma/prisma.service'; import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank.api'; + +import { PrismaService } from '../../../../prisma/prisma.service'; import { - confirmedCandleCoverage, type CandleRange, + confirmedCandleCoverage, type SavedCandle, } from '../domain/candle-coverage'; const provider = 'tbank'; const interval = 'day'; type CandleIntent = { - instrumentType?: 'share' | 'bond'; - uid: string; - ranges: CandleRange[]; - period?: string; - now?: string; force?: boolean; + instrumentType?: 'bond' | 'share'; + now?: string; + period?: string; + ranges: CandleRange[]; + uid: string; }; @Injectable() export class CandleRepository { @@ -57,7 +58,7 @@ export class CandleRepository { } reserve( - owner: { userId: string; revision: string }, + owner: { revision: string; userId: string }, scopeKey: string, intent: CandleIntent, runId: string, @@ -88,29 +89,31 @@ export class CandleRepository { (!intent.force || (previousIntent.force === true && (!intent.now || intent.now <= previousNow))) - ) + ) { return { work: previous, launch: false }; + } const work = await tx.tbankSyncWork.update({ where: { id: previous.id }, data: { priority: nextPriority, intent: { ...previousIntent, - ...(intent.force - ? { + ...(intent.force ? + { force: true, - ...(intent.now && intent.now > previousNow - ? { now: intent.now } - : {}), - } - : {}), + ...(intent.now && intent.now > previousNow ? + { now: intent.now } : + {}), + } : + {}), }, }, }); return { work, launch: false }; } - if (previous) + if (previous) { await tx.tbankSyncStage.deleteMany({ where: { workId: previous.id } }); + } const data = { attemptId: randomUUID(), processRunId: runId, @@ -142,12 +145,15 @@ export class CandleRepository { fetchedAt: Date, ) { // Reject malformed financial input instead of publishing a false empty range. - for (const candle of candles) + for (const candle of candles) { if ( !Number.isFinite(Date.parse(candle.time)) || + // eslint-disable-next-line security/detect-unsafe-regex -- Anchored decimal grammar: the literal decimal point separates disjoint digit runs; backtracking is linear. !/^-?\d+(?:\.\d+)?$/.test(candle.close) - ) + ) { throw new Error('Invalid candle'); + } + } const key = `${range.from.toISOString()}/${range.to.toISOString()}`; const rows = [ ...new Map( @@ -155,7 +161,7 @@ export class CandleRepository { .filter( (candle) => new Date(candle.time) >= range.from && - new Date(candle.time) < range.to, + new Date(candle.time) < range.to, ) .map((candle) => [candle.time, candle]), ).values(), @@ -222,8 +228,9 @@ export class CandleRepository { `${range.from.toISOString()}/${range.to.toISOString()}`, ), ) - ) + ) { throw new Error('Incomplete candle reconciliation'); + } const identity = { provider, instrumentUid: intent.uid, interval }; for (const range of intent.ranges) { const row = staged.find( @@ -254,21 +261,22 @@ export class CandleRepository { // Preserve coverage outside the requested reconciliation boundaries. for (const previous of old) { for (const part of [ - ...(previous.from < range.from - ? [{ from: previous.from, to: range.from }] - : []), - ...(previous.to > range.to - ? [{ from: range.to, to: previous.to }] - : []), - ]) + ...(previous.from < range.from ? + [{ from: previous.from, to: range.from }] : + []), + ...(previous.to > range.to ? + [{ from: range.to, to: previous.to }] : + []), + ]) { await tx.tbankCandleCoverage.create({ data: { ...previous, ...part }, }); + } } await tx.tbankCandle.deleteMany({ where: { ...identity, time: { gte: range.from, lt: range.to } }, }); - for (const candle of payload.candles) + for (const candle of payload.candles) { await tx.tbankCandle.create({ data: { ...identity, @@ -279,14 +287,16 @@ export class CandleRepository { fetchedAt, }, }); + } for (const covered of confirmedCandleCoverage( range, payload.candles, fetchedAt, - )) + )) { await tx.tbankCandleCoverage.create({ data: { ...identity, ...covered }, }); + } } await tx.tbankSyncStage.deleteMany({ where: { workId: attempt.id, attemptId: attempt.attemptId }, diff --git a/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts b/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts index 2c2d183..ae844f1 100644 --- a/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts +++ b/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts @@ -1,40 +1,42 @@ import { Injectable } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; import { isDeepStrictEqual } from 'node:util'; + import type { Prisma } from '../../../../../generated/prisma/client'; -import { PrismaService } from '../../../../prisma/prisma.service'; import type { TbankBond, TbankShare, TbankSyncAttempt, } from '../../../../integrations/tbank/tbank.api'; +import { PrismaService } from '../../../../prisma/prisma.service'; + export type CatalogQuery = { - instrumentType: 'share' | 'bond'; - query: string; exactTicker?: boolean; + instrumentType: 'bond' | 'share'; + query: string; }; export type CatalogSource = { - instrumentType: 'share' | 'bond'; - uid: string; + instrumentType: 'bond' | 'share'; kind: - | 'detail' - | 'last-price' - | 'bond-current' - | 'description' - | 'fundamentals' - | 'trading-status' - | 'comparison' - | 'coupons' - | 'yield' - | 'rating' - | 'dividends' - | 'bond-events'; + | 'bond-current' | + 'bond-events' | + 'comparison' | + 'coupons' | + 'description' | + 'detail' | + 'dividends' | + 'fundamentals' | + 'last-price' | + 'rating' | + 'trading-status' | + 'yield'; rangeKey?: string; ticker?: string; + uid: string; }; export type CatalogWork = CatalogQuery | CatalogSource; -export type CatalogInstrument = (TbankShare | TbankBond) & { uid: string }; +export type CatalogInstrument = (TbankBond | TbankShare) & { uid: string }; const provider = 'tbank'; export function catalogQuery(input: CatalogQuery): CatalogQuery { return { @@ -44,13 +46,14 @@ export function catalogQuery(input: CatalogQuery): CatalogQuery { }; } export function catalogScope(input: CatalogWork) { - if ('uid' in input) + if ('uid' in input) { return `catalog-source:${JSON.stringify([ input.kind, input.kind === 'detail' ? input.instrumentType : null, input.rangeKey ?? '', input.uid, ])}`; + } return `${input.exactTicker ? 'ticker' : 'catalog'}:${JSON.stringify([input.instrumentType, input.query])}`; } @@ -60,8 +63,8 @@ export class CatalogDiscoveryRepository { read(input: CatalogQuery) { return this.prisma.$transaction(async (tx) => { - const alias = input.exactTicker - ? await tx.tbankInstrumentLookup.findUnique({ + const alias = input.exactTicker ? + await tx.tbankInstrumentLookup.findUnique({ where: { provider_identifierType_identifier: { provider, @@ -69,17 +72,17 @@ export class CatalogDiscoveryRepository { identifier: input.query, }, }, - }) - : null; - const discovery = input.exactTicker - ? alias - ? { + }) : + null; + const discovery = input.exactTicker ? + alias ? + { instrumentUids: alias.matchedUids, fetchedAt: alias.fetchedAt, dataRevision: alias.dataRevision, - } - : null - : await tx.tbankCatalogDiscovery.findUnique({ + } : + null : + await tx.tbankCatalogDiscovery.findUnique({ where: { provider_instrumentType_query: { provider, @@ -101,12 +104,13 @@ export class CatalogDiscoveryRepository { }); const byUid = new Map(rows.map((row) => [row.instrumentUid, row])); // A damaged/missing dependency is unknown, never a confirmed empty search. - if (uids.some((uid) => !byUid.has(uid))) + if (uids.some((uid) => !byUid.has(uid))) { return { data: null, fetchedAt: null, dataRevision: String(discovery.dataRevision), }; + } return { data: uids.map((uid) => byUid.get(uid)?.payload as CatalogInstrument), fetchedAt: discovery.fetchedAt, @@ -127,9 +131,9 @@ export class CatalogDiscoveryRepository { kind: input.kind, source: sourceProvider(input), rangeKey: - input.kind === 'detail' - ? input.instrumentType - : (input.rangeKey ?? ''), + input.kind === 'detail' ? + input.instrumentType : + (input.rangeKey ?? ''), })), }, }); @@ -176,7 +180,7 @@ export class CatalogDiscoveryRepository { }, }); const instrument = ( - value as { instrument?: TbankShare | TbankBond | null } + value as { instrument?: null | TbankBond | TbankShare } ).instrument; if ( (input.kind === 'detail' || input.kind === 'bond-current') && @@ -225,9 +229,9 @@ export class CatalogDiscoveryRepository { increment: isDeepStrictEqual( previousAlias?.matchedUids, matchedUids, - ) - ? 0 - : 1, + ) ? + 0 : + 1, }, }, }); @@ -239,9 +243,9 @@ export class CatalogDiscoveryRepository { source: sourceProvider(input), kind: input.kind, rangeKey: - input.kind === 'detail' - ? input.instrumentType - : (input.rangeKey ?? ''), + input.kind === 'detail' ? + input.instrumentType : + (input.rangeKey ?? ''), }; const previous = await tx.tbankInstrumentSource.findUnique({ where: { provider_instrumentUid_source_kind_rangeKey: key }, @@ -262,7 +266,7 @@ export class CatalogDiscoveryRepository { } reserve( - owner: { userId: string; revision: string }, + owner: { revision: string; userId: string }, input: CatalogWork, runId: string, active: ReadonlySet, @@ -281,8 +285,9 @@ export class CatalogDiscoveryRepository { previous?.state === 'running' && previous.processRunId === runId && active.has(previous.attemptId) - ) + ) { return { work: previous, launch: false }; + } const data = { attemptId: randomUUID(), processRunId: runId, @@ -351,9 +356,9 @@ export class CatalogDiscoveryRepository { instrumentKind: instrument.instrumentKind, apiTradeAvailableFlag: instrument.apiTradeAvailableFlag, currency: instrument.currency, - brand: instrument.brand - ? { logoName: instrument.brand.logoName } - : undefined, + brand: instrument.brand ? + { logoName: instrument.brand.logoName } : + undefined, }), ) as Prisma.InputJsonObject; await tx.tbankInstrument.upsert({ @@ -410,9 +415,9 @@ export class CatalogDiscoveryRepository { increment: isDeepStrictEqual( previous?.matchedUids, instrumentUids, - ) - ? 0 - : 1, + ) ? + 0 : + 1, }, }, }); @@ -436,9 +441,9 @@ export class CatalogDiscoveryRepository { increment: isDeepStrictEqual( previous?.instrumentUids, instrumentUids, - ) - ? 0 - : 1, + ) ? + 0 : + 1, }, }, }); @@ -472,9 +477,9 @@ export class CatalogDiscoveryRepository { } function sourceProvider(input: CatalogSource) { - return input.kind === 'yield' || input.kind === 'bond-events' - ? 'moex' - : input.kind === 'rating' - ? 'ncr' - : 'tbank'; + return input.kind === 'yield' || input.kind === 'bond-events' ? + 'moex' : + input.kind === 'rating' ? + 'ncr' : + 'tbank'; } diff --git a/src/modules/investments/instruments/instrument-income.api.ts b/src/modules/investments/instruments/instrument-income.api.ts index b717fd0..8b01151 100644 --- a/src/modules/investments/instruments/instrument-income.api.ts +++ b/src/modules/investments/instruments/instrument-income.api.ts @@ -1,2 +1,2 @@ -export { StockIncomeService } from './stocks/application/stock-income.service'; export { BondIncomeService } from './bonds/application/bond-income.service'; +export { StockIncomeService } from './stocks/application/stock-income.service'; diff --git a/src/modules/investments/instruments/instrument-sources.module.ts b/src/modules/investments/instruments/instrument-sources.module.ts index 24a85ef..23e20cf 100644 --- a/src/modules/investments/instruments/instrument-sources.module.ts +++ b/src/modules/investments/instruments/instrument-sources.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; + import { MoexModule } from '../../../integrations/moex/moex.module'; import { NcrModule } from '../../../integrations/ncr/ncr.module'; import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { CandleService } from './application/candle.service'; -import { CandleRepository } from './infrastructure/candle.repository'; -import { CatalogEnrichmentService } from './application/catalog-enrichment.service'; import { CatalogDiscoveryService } from './application/catalog-discovery.service'; +import { CatalogEnrichmentService } from './application/catalog-enrichment.service'; +import { CandleRepository } from './infrastructure/candle.repository'; import { CatalogDiscoveryRepository } from './infrastructure/catalog-discovery.repository'; @Module({ diff --git a/src/modules/investments/instruments/instruments.api.ts b/src/modules/investments/instruments/instruments.api.ts index bc88c67..014d610 100644 --- a/src/modules/investments/instruments/instruments.api.ts +++ b/src/modules/investments/instruments/instruments.api.ts @@ -1,5 +1,5 @@ export { CandleService } from './application/candle.service'; -export { CatalogEnrichmentService } from './application/catalog-enrichment.service'; -export type { CatalogPayload } from './application/catalog-enrichment.service'; - export { CatalogDiscoveryService } from './application/catalog-discovery.service'; +export { CatalogEnrichmentService } from './application/catalog-enrichment.service'; + +export type { CatalogPayload } from './application/catalog-enrichment.service'; diff --git a/src/modules/investments/instruments/instruments.module.ts b/src/modules/investments/instruments/instruments.module.ts index 8eb5280..9ba931e 100644 --- a/src/modules/investments/instruments/instruments.module.ts +++ b/src/modules/investments/instruments/instruments.module.ts @@ -1,17 +1,18 @@ -import { InstrumentSourcesModule } from './instrument-sources.module'; import { Module } from '@nestjs/common'; + import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { AuthModule } from '../../auth/auth.module'; import { PortfolioModule } from '../portfolio/portfolio.module'; +import { BondCatalogService } from './bonds/application/bond-catalog.service'; import { BondIncomeService } from './bonds/application/bond-income.service'; import { BondMarketService } from './bonds/application/bond-market.service'; import { BondController } from './bonds/presentation/bond.controller'; -import { BondCatalogService } from './bonds/application/bond-catalog.service'; +import { InstrumentSourcesModule } from './instrument-sources.module'; import { GetStockCompanyUseCase } from './stocks/application/get-stock-company.use-case'; +import { StockCatalogService } from './stocks/application/stock-catalog.service'; import { StockIncomeService } from './stocks/application/stock-income.service'; import { StockMarketService } from './stocks/application/stock-market.service'; import { StockController } from './stocks/presentation/stock.controller'; -import { StockCatalogService } from './stocks/application/stock-catalog.service'; @Module({ imports: [AuthModule, TbankModule, InstrumentSourcesModule, PortfolioModule], diff --git a/src/modules/investments/instruments/stocks/application/get-stock-company.use-case.ts b/src/modules/investments/instruments/stocks/application/get-stock-company.use-case.ts index 8c05cdb..ea4df48 100644 --- a/src/modules/investments/instruments/stocks/application/get-stock-company.use-case.ts +++ b/src/modules/investments/instruments/stocks/application/get-stock-company.use-case.ts @@ -1,5 +1,7 @@ import { Injectable } from '@nestjs/common'; + import type { TbankFundamentals } from '../../../../../integrations/tbank/tbank.api'; + import { CatalogEnrichmentService } from '../../application/catalog-enrichment.service'; import { StockCompanyResourceDto, @@ -21,8 +23,8 @@ export class GetStockCompanyUseCase { observe, ); return { - data: result.values.instrument - ? { + data: result.values.instrument ? + { description: result.values.description ?? null, metrics: metrics( result.values.fundamentals ?? null, @@ -30,14 +32,14 @@ export class GetStockCompanyUseCase { ), unavailable: result.unavailable, observedAt: result.observedAt, - } - : null, + } : + null, meta: result.meta, }; } } export function metrics( - values: TbankFundamentals | null, + values: null | TbankFundamentals, sector?: string, ): StockMetricDto[] { if (!values) return []; @@ -49,30 +51,31 @@ export function metrics( ][] = [ ['marketCapitalization', 'money', null], ['peRatioTtm', 'ratio', 'TTM'], - ...(bank - ? ([ + ...(bank ? + ([ ['priceToBookTtm', 'ratio', 'TTM'], ['roe', 'percent', null], - ] as typeof selection) - : ([ + ] as typeof selection) : + ([ ['revenueTtm', 'money', 'TTM'], ['ebitdaTtm', 'money', 'TTM'], ] as typeof selection)), ['netIncomeTtm', 'money', 'TTM'], - ...(!bank - ? ([ + ...(!bank ? + ([ ['totalDebtMrq', 'money', 'MRQ'], ['netDebtToEbitda', 'ratio', null], - ] as typeof selection) - : []), + ] as typeof selection) : + []), ]; return selection.flatMap(([key, unit, period]) => { + // eslint-disable-next-line security/detect-object-injection -- Keys come from the fixed metric selection declared above. const value = values[key]; const currency = values.currency?.toUpperCase() || null; return typeof value === 'number' && Number.isFinite(value) && - (unit !== 'money' || currency) - ? [ + (unit !== 'money' || currency) ? + [ { key, value, @@ -80,7 +83,7 @@ export function metrics( period, currency: unit === 'money' ? currency : null, }, - ] - : []; + ] : + []; }); } diff --git a/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts b/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts index 867182c..914d09a 100644 --- a/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts +++ b/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts @@ -1,8 +1,10 @@ -import { CatalogDiscoveryService } from '../../application/catalog-discovery.service'; import { Injectable } from '@nestjs/common'; -import { formatDecimal } from '../../../../../common/decimal'; + import type { TbankShare } from '../../../../../integrations/tbank/tbank.api'; + +import { formatDecimal } from '../../../../../common/decimal'; import { decimal } from '../../../../../integrations/tbank/decimal'; +import { CatalogDiscoveryService } from '../../application/catalog-discovery.service'; import { timestamp } from '../../application/instrument-market'; import { StockDto, @@ -30,9 +32,9 @@ export class StockCatalogService { data: result.data?.map((item) => { const short = stock(item); - const detail = item.detail - ? stock({ ...item.detail, uid: item.uid }) - : short; + const detail = item.detail ? + stock({ ...item.detail, uid: item.uid }) : + short; const last = item.price; return { ...detail, @@ -40,17 +42,18 @@ export class StockCatalogService { isin: detail.isin ?? short.isin, apiTradeAvailable: short.apiTradeAvailable, price: - last?.price && detail.currency - ? { + last?.price && detail.currency ? + { currency: detail.currency, value: formatDecimal(decimal(last.price)), - } - : null, + } : + null, priceAt: timestamp(last?.time), }; }) ?? null, }; } + async get( userId: string, uid: string, @@ -59,11 +62,12 @@ export class StockCatalogService { const result = await this.discovery.detail(userId, 'share', uid, observe); return { ...result, - data: result.data - ? { ...stock(result.data), slug: result.data.slug } - : null, + data: result.data ? + { ...stock(result.data), slug: result.data.slug } : + null, }; } + async getByTicker( userId: string, ticker: string, @@ -77,9 +81,9 @@ export class StockCatalogService { ); return { ...result, - data: result.data - ? { ...stock(result.data), slug: result.data.slug } - : null, + data: result.data ? + { ...stock(result.data), slug: result.data.slug } : + null, }; } } diff --git a/src/modules/investments/instruments/stocks/application/stock-income.service.ts b/src/modules/investments/instruments/stocks/application/stock-income.service.ts index a0754a7..9dd4eb2 100644 --- a/src/modules/investments/instruments/stocks/application/stock-income.service.ts +++ b/src/modules/investments/instruments/stocks/application/stock-income.service.ts @@ -1,27 +1,28 @@ import { Injectable, NotFoundException } from '@nestjs/common'; + import { decimalFromString, formatDecimal, multiply, } from '../../../../../common/decimal'; +import { decimal } from '../../../../../integrations/tbank/decimal'; import { TbankAccessService, type TbankDividend, } from '../../../../../integrations/tbank/tbank.api'; import { - PortfolioService, ForecastService, + PortfolioService, } from '../../../portfolio/portfolio.api'; import { CatalogEnrichmentService } from '../../application/catalog-enrichment.service'; import { moscowDay, timestamp } from '../../application/instrument-market'; import { instrumentPosition } from '../../application/instrument-position'; import { + StockDividendDto, StockDividendsDto, StockDividendsResourceDto, StockPositionDto, - StockDividendDto, } from '../presentation/stock.dto'; -import { decimal } from '../../../../../integrations/tbank/decimal'; const ISO_YEAR_LENGTH = 4; const INCOME_HISTORY_YEARS = 5; @Injectable() @@ -40,6 +41,7 @@ export class StockIncomeService { meta: { ...saved.meta, resourceKey: `stock/position/${uid}` }, }; } + async dividends( userId: string, uid: string, @@ -58,21 +60,22 @@ export class StockIncomeService { ['detail', 'dividends'], { from, to }, ); - if (sources[0].meta.availability === 'complete' && !sources[0].instrument) + if (sources[0].meta.availability === 'complete' && !sources[0].instrument) { throw new NotFoundException(); + } const events = sources[1].payload?.dividends; - const position = portfolio.data - ? instrumentPosition(portfolio.data, uid, 'STOCK') - : null; - const projected = events - ? stockDividends( + const position = portfolio.data ? + instrumentPosition(portfolio.data, uid, 'STOCK') : + null; + const projected = events ? + stockDividends( events, position, { from, to, today }, sources[1].meta.lastSuccessfulAt ?? new Date().toISOString(), !portfolio.data, - ) - : undefined; + ) : + undefined; const result = await this.forecasts.result({ userId, revision: connection.revision, @@ -83,9 +86,9 @@ export class StockIncomeService { candidate: portfolio.data && sources[0].instrument ? projected : undefined, }); - return result.data || !projected - ? result - : { + return result.data || !projected ? + result : + { data: stockDividends( events ?? [], null, @@ -100,7 +103,7 @@ export class StockIncomeService { export function stockDividends( events: TbankDividend[], - position: StockPositionDto | null, + position: null | StockPositionDto, window: { from: string; to: string; today: string }, observedAt: string, positionUnavailable: boolean, @@ -109,9 +112,9 @@ export function stockDividends( const quantity = position && position.accounts.length && - position.accounts.every((p) => decimalFromString(p.quantity).value > 0n) - ? decimalFromString(position.quantity) - : null; + position.accounts.every((p) => decimalFromString(p.quantity).value > 0n) ? + decimalFromString(position.quantity) : + null; const dividends: StockDividendDto[] = events .filter((event) => event.dividendType?.toLowerCase() !== 'cancelled') .map((event) => { @@ -120,29 +123,29 @@ export function stockDividends( const lastBuyDate = timestamp(event.lastBuyDate)?.slice(0, 10) ?? null; const currency = event.dividendNet?.currency?.toUpperCase(); const perShare = - currency && event.dividendNet - ? { currency, value: formatDecimal(decimal(event.dividendNet)) } - : null; + currency && event.dividendNet ? + { currency, value: formatDecimal(decimal(event.dividendNet)) } : + null; // A future payment can have an already-fixed register: keep the event, but do not estimate entitlement from today's holding. const eventDate = paymentDate ?? recordDate; - const timing: StockDividendDto['timing'] = eventDate - ? eventDate >= today - ? 'upcoming' - : 'past' - : 'undated'; + const timing: StockDividendDto['timing'] = eventDate ? + eventDate >= today ? + 'upcoming' : + 'past' : + 'undated'; const estimatedAmount = quantity && perShare && recordDate && recordDate >= today && - timing === 'upcoming' - ? { + timing === 'upcoming' ? + { currency: perShare.currency, value: formatDecimal( multiply(quantity, decimalFromString(perShare.value)), ), - } - : null; + } : + null; return { perShare, recordDate, diff --git a/src/modules/investments/instruments/stocks/application/stock-market.service.ts b/src/modules/investments/instruments/stocks/application/stock-market.service.ts index efc6b7c..82dac1d 100644 --- a/src/modules/investments/instruments/stocks/application/stock-market.service.ts +++ b/src/modules/investments/instruments/stocks/application/stock-market.service.ts @@ -1,9 +1,5 @@ -import { CandleService } from '../../application/candle.service'; -import { - CatalogEnrichmentService, - type CatalogPayload, -} from '../../application/catalog-enrichment.service'; import { Injectable } from '@nestjs/common'; + import { decimalFromString, divide, @@ -12,6 +8,11 @@ import { subtract, } from '../../../../../common/decimal'; import { decimal } from '../../../../../integrations/tbank/decimal'; +import { CandleService } from '../../application/candle.service'; +import { + CatalogEnrichmentService, + type CatalogPayload, +} from '../../application/catalog-enrichment.service'; import { timestamp } from '../../application/instrument-market'; import { StockCandlesQueryDto, @@ -26,6 +27,7 @@ export class StockMarketService { private readonly history: CandleService, private readonly sources: CatalogEnrichmentService, ) {} + async quote( userId: string, uid: string, @@ -39,12 +41,13 @@ export class StockMarketService { observe, ); return { - data: result.values.instrument - ? stockQuote(result.values, result.unavailable, result.observedAt) - : null, + data: result.values.instrument ? + stockQuote(result.values, result.unavailable, result.observedAt) : + null, meta: result.meta, }; } + candles( userId: string, uid: string, @@ -69,20 +72,20 @@ export function stockQuote( const priceAt = timestamp(last?.time); const currency = share?.currency?.toUpperCase(); const price = - last?.price && currency - ? { currency, value: formatDecimal(decimal(last.price)) } - : null; + last?.price && currency ? + { currency, value: formatDecimal(decimal(last.price)) } : + null; const previousPrice = - previous?.close && currency - ? { currency, value: formatDecimal(decimal(previous.close)) } - : null; + previous?.close && currency ? + { currency, value: formatDecimal(decimal(previous.close)) } : + null; const difference = - price && previousPrice - ? subtract( + price && previousPrice ? + subtract( decimalFromString(price.value), decimalFromString(previousPrice.value), - ) - : null; + ) : + null; return { price, priceAt, @@ -90,21 +93,21 @@ export function stockQuote( previousCloseDate: timestamp(previous?.time)?.slice(0, 10) ?? null, previousClose: previousPrice, change: - difference && currency - ? { currency, value: formatDecimal(difference) } - : null, + difference && currency ? + { currency, value: formatDecimal(difference) } : + null, changePercent: difference && previousPrice && - decimalFromString(previousPrice.value).value > 0n - ? formatDecimal( + decimalFromString(previousPrice.value).value > 0n ? + formatDecimal( divide( multiply(difference, decimalFromString('100')), decimalFromString(previousPrice.value), CHANGE_PERCENT_DECIMAL_PLACES, ), - ) - : null, + ) : + null, unavailable, observedAt, }; diff --git a/src/modules/investments/instruments/stocks/application/stock.spec.ts b/src/modules/investments/instruments/stocks/application/stock.spec.ts index 2ae894b..8aa115c 100644 --- a/src/modules/investments/instruments/stocks/application/stock.spec.ts +++ b/src/modules/investments/instruments/stocks/application/stock.spec.ts @@ -1,7 +1,7 @@ +import { previousClose } from '../../application/instrument-market'; import { metrics } from './get-stock-company.use-case'; import { stockDividends } from './stock-income.service'; import { stockQuote } from './stock-market.service'; -import { previousClose } from '../../application/instrument-market'; jest.mock('../../../../../prisma/prisma.service', () => ({ PrismaService: class {}, })); diff --git a/src/modules/investments/instruments/stocks/presentation/stock.controller.ts b/src/modules/investments/instruments/stocks/presentation/stock.controller.ts index aa8dfe7..04c74e4 100644 --- a/src/modules/investments/instruments/stocks/presentation/stock.controller.ts +++ b/src/modules/investments/instruments/stocks/presentation/stock.controller.ts @@ -1,4 +1,5 @@ -import { InvestmentReadQueryDto } from '../../../investment-resource.dto'; +import type { Request } from 'express'; + import { Controller, Get, @@ -9,25 +10,26 @@ import { 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 { GetStockCompanyUseCase } from '../application/get-stock-company.use-case'; +import { StockCatalogService } from '../application/stock-catalog.service'; import { StockIncomeService } from '../application/stock-income.service'; import { StockMarketService } from '../application/stock-market.service'; import { - StockCandlesResourceDto, StockCandlesQueryDto, + StockCandlesResourceDto, StockCompanyResourceDto, StockDividendsResourceDto, - StockResourceDto, StockPositionResourceDto, StockQuoteResourceDto, + StockResourceDto, StockSearchQueryDto, StockSearchResourceDto, StockTickerParamsDto, } from './stock.dto'; -import { StockCatalogService } from '../application/stock-catalog.service'; @ApiTags('stocks') @ApiProblemResponses() @@ -41,6 +43,7 @@ export class StockController { private readonly income: StockIncomeService, private readonly companyInfo: GetStockCompanyUseCase, ) {} + @Get() @ApiOkResponse({ type: StockSearchResourceDto }) search( @@ -53,6 +56,7 @@ export class StockController { query.sync === 'observe', ); } + @Get('by-ticker/:ticker') @ApiOkResponse({ type: StockResourceDto }) getByTicker( @@ -66,6 +70,7 @@ export class StockController { query.sync === 'observe', ); } + @Get(':uid') @ApiOkResponse({ type: StockResourceDto }) get( @@ -75,6 +80,7 @@ export class StockController { ) { return this.stocks.get(request.user.sub, uid, query.sync === 'observe'); } + @Get(':uid/quote') @ApiOkResponse({ type: StockQuoteResourceDto }) quote( @@ -84,6 +90,7 @@ export class StockController { ) { return this.market.quote(request.user.sub, uid, query.sync === 'observe'); } + @Get(':uid/candles') @ApiOkResponse({ type: StockCandlesResourceDto }) candles( @@ -98,6 +105,7 @@ export class StockController { query.sync === 'observe', ); } + @Get(':uid/position') @ApiOkResponse({ type: StockPositionResourceDto }) position( @@ -111,6 +119,7 @@ export class StockController { query.sync === 'observe', ); } + @Get(':uid/company') @ApiOkResponse({ type: StockCompanyResourceDto }) company( @@ -124,6 +133,7 @@ export class StockController { query.sync === 'observe', ); } + @Get(':uid/dividends') @ApiOkResponse({ type: StockDividendsResourceDto }) dividends( diff --git a/src/modules/investments/instruments/stocks/presentation/stock.dto.ts b/src/modules/investments/instruments/stocks/presentation/stock.dto.ts index c722395..4fa0620 100644 --- a/src/modules/investments/instruments/stocks/presentation/stock.dto.ts +++ b/src/modules/investments/instruments/stocks/presentation/stock.dto.ts @@ -1,7 +1,3 @@ -import { - InvestmentReadQueryDto, - InvestmentResourceMetaDto, -} from '../../../investment-resource.dto'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { @@ -12,7 +8,12 @@ import { MaxLength, MinLength, } from 'class-validator'; + import { MoneyDto } from '../../../../../common/money.dto'; +import { + InvestmentReadQueryDto, + InvestmentResourceMetaDto, +} from '../../../investment-resource.dto'; import { PortfolioPositionDto } from '../../../portfolio/portfolio.api'; const MAX_INSTRUMENT_QUERY_LENGTH = 80; @@ -36,7 +37,7 @@ export class StockTickerParamsDto { @IsString() @MinLength(1) @MaxLength(MAX_INSTRUMENT_QUERY_LENGTH) - @Matches(/^[A-Za-z0-9][A-Za-z0-9._@+-]*$/) + @Matches(/^[A-Z0-9][\w.@+-]*$/i) declare ticker: string; } export class StockCandlesQueryDto extends InvestmentReadQueryDto { @@ -46,7 +47,7 @@ export class StockCandlesQueryDto extends InvestmentReadQueryDto { }) @IsOptional() @IsIn(['month', 'halfYear', 'year', 'all']) - period?: 'month' | 'halfYear' | 'year' | 'all'; + period?: 'all' | 'halfYear' | 'month' | 'year'; } export class StockDto { @ApiProperty({ format: 'uuid' }) declare instrumentUid: string; @@ -54,48 +55,63 @@ export class StockDto { description: 'Unambiguous ticker URL segment, or the exact instrument UID.', }) declare slug: string; + @ApiProperty() declare name: string; - @ApiProperty({ type: String, nullable: true }) declare isin: string | null; + @ApiProperty({ type: String, nullable: true }) declare isin: null | string; @ApiProperty({ type: String, nullable: true }) declare classCode: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare realExchange: - string | null; + null | string; + @ApiProperty({ type: Boolean, nullable: true }) declare apiTradeAvailable: boolean | null; - @ApiProperty({ type: String, nullable: true }) declare ticker: string | null; + + @ApiProperty({ type: String, nullable: true }) declare ticker: null | string; @ApiProperty({ type: String, nullable: true }) declare logoName: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare currency: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare shareType: - string | null; - @ApiProperty({ type: String, nullable: true }) declare sector: string | null; - @ApiProperty({ type: Number, nullable: true }) declare lot: number | null; + null | string; + + @ApiProperty({ type: String, nullable: true }) declare sector: null | string; + @ApiProperty({ type: Number, nullable: true }) declare lot: null | number; @ApiProperty({ type: String, nullable: true, format: 'date-time' }) - declare historyFrom: string | null; + declare historyFrom: null | string; } export class StockSearchResultDto extends StockDto { @ApiProperty({ type: MoneyDto, nullable: true }) declare price: MoneyDto | null; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) - declare priceAt: string | null; + declare priceAt: null | string; } export class StockQuoteDto { @ApiProperty({ type: MoneyDto, nullable: true }) declare price: MoneyDto | null; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) - declare priceAt: string | null; + declare priceAt: null | string; + @ApiProperty({ type: String, nullable: true }) declare tradingStatus: - string | null; + null | string; + @ApiProperty({ type: MoneyDto, nullable: true }) declare change: MoneyDto | null; + @ApiProperty({ type: String, nullable: true }) declare changePercent: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true, format: 'date' }) - declare previousCloseDate: string | null; + declare previousCloseDate: null | string; + @ApiProperty({ type: MoneyDto, nullable: true }) declare previousClose: MoneyDto | null; - @ApiProperty({ type: [String] }) declare unavailable: string[]; + + @ApiProperty({ type: String, isArray: true }) declare unavailable: string[]; @ApiProperty({ format: 'date-time' }) declare observedAt: string; } export class StockCandleDto { @@ -104,29 +120,36 @@ export class StockCandleDto { @ApiProperty() declare isComplete: boolean; } export class StockCandlesDto { - @ApiProperty({ type: [StockCandleDto] }) declare candles: StockCandleDto[]; + @ApiProperty({ type: StockCandleDto, isArray: true }) declare candles: StockCandleDto[]; @ApiProperty({ type: String, nullable: true }) declare currency: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) - declare historyFrom: string | null; + declare historyFrom: null | string; + @ApiProperty({ format: 'date-time' }) declare observedAt: string; } export class StockPositionRowDto extends PortfolioPositionDto { @ApiProperty({ type: String, nullable: true }) declare returnPercent: - string | null; + null | string; } export class StockPositionDto { @ApiProperty() declare quantity: string; @ApiProperty({ type: MoneyDto, nullable: true }) declare currentValue: MoneyDto | null; + @ApiProperty({ type: MoneyDto, nullable: true }) declare averagePurchasePrice: MoneyDto | null; + @ApiProperty({ type: MoneyDto, nullable: true }) declare absoluteReturn: MoneyDto | null; + @ApiProperty({ type: String, nullable: true }) declare returnPercent: - string | null; - @ApiProperty({ type: [StockPositionRowDto] }) + null | string; + + @ApiProperty({ type: StockPositionRowDto, isArray: true }) declare accounts: StockPositionRowDto[]; + @ApiProperty() declare isStale: boolean; @ApiProperty({ format: 'date-time' }) declare observedAt: string; } @@ -134,90 +157,108 @@ export class StockMetricDto { @ApiProperty() declare key: string; @ApiProperty() declare value: number; @ApiProperty({ enum: ['money', 'ratio', 'percent'] }) declare unit: - 'money' | 'ratio' | 'percent'; + 'money' | 'percent' | 'ratio'; + @ApiProperty({ type: String, nullable: true }) declare currency: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true, enum: ['TTM', 'MRQ'] }) - declare period: 'TTM' | 'MRQ' | null; + declare period: 'MRQ' | 'TTM' | null; } export class StockCompanyDto { @ApiProperty({ type: String, nullable: true }) declare description: - string | null; - @ApiProperty({ type: [StockMetricDto] }) declare metrics: StockMetricDto[]; - @ApiProperty({ type: [String] }) declare unavailable: string[]; + null | string; + + @ApiProperty({ type: StockMetricDto, isArray: true }) declare metrics: StockMetricDto[]; + @ApiProperty({ type: String, isArray: true }) declare unavailable: string[]; @ApiProperty({ format: 'date-time' }) declare observedAt: string; } export class StockDividendDto { @ApiProperty({ type: MoneyDto, nullable: true }) declare perShare: MoneyDto | null; + @ApiProperty({ type: String, nullable: true, format: 'date' }) - declare recordDate: string | null; + declare recordDate: null | string; + @ApiProperty({ type: String, nullable: true, format: 'date' }) - declare lastBuyDate: string | null; + declare lastBuyDate: null | string; + @ApiProperty({ type: String, nullable: true, format: 'date' }) - declare paymentDate: string | null; + declare paymentDate: null | string; + @ApiProperty({ enum: ['upcoming', 'past', 'undated'] }) declare timing: - 'upcoming' | 'past' | 'undated'; + 'past' | 'undated' | 'upcoming'; + @ApiProperty({ type: MoneyDto, nullable: true }) declare estimatedAmount: MoneyDto | null; } export class StockDividendsDto { - @ApiProperty({ type: [StockDividendDto] }) + @ApiProperty({ type: StockDividendDto, isArray: true }) declare dividends: StockDividendDto[]; + @ApiProperty({ format: 'date' }) declare from: string; @ApiProperty({ format: 'date' }) declare to: string; @ApiProperty({ format: 'date-time' }) declare observedAt: string; @ApiProperty({ type: String, nullable: true, format: 'date-time' }) - declare positionAt: string | null; + declare positionAt: null | string; + @ApiProperty() declare estimateUnavailable: boolean; @ApiProperty() declare positionIsStale: boolean; } export class StockPositionResourceDto { @ApiProperty({ type: StockPositionDto, nullable: true }) - declare data: StockPositionDto | null; + declare data: null | StockPositionDto; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } export class StockSearchResourceDto { + // eslint-disable-next-line @darraghor/nestjs-typed/api-property-returning-array-should-set-array -- Nullable array union requires isArray in the explicit wire schema. @ApiProperty({ type: StockSearchResultDto, isArray: true, nullable: true }) - declare data: StockSearchResultDto[] | null; + declare data: null | StockSearchResultDto[]; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } export class StockResourceDto { @ApiProperty({ type: StockDto, nullable: true }) - declare data: StockDto | null; + declare data: null | StockDto; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } export class StockQuoteResourceDto { @ApiProperty({ type: StockQuoteDto, nullable: true }) - declare data: StockQuoteDto | null; + declare data: null | StockQuoteDto; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } export class StockCompanyResourceDto { @ApiProperty({ type: StockCompanyDto, nullable: true }) - declare data: StockCompanyDto | null; + declare data: null | StockCompanyDto; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } export class StockCandlesResourceDto { @ApiProperty({ type: StockCandlesDto, nullable: true }) - declare data: StockCandlesDto | null; + declare data: null | StockCandlesDto; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } export class StockDividendsResourceDto { @ApiProperty({ type: StockDividendsDto, nullable: true }) - declare data: StockDividendsDto | null; + declare data: null | StockDividendsDto; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } diff --git a/src/modules/investments/investment-calendar.ts b/src/modules/investments/investment-calendar.ts index 4b7eb98..c2d76e0 100644 --- a/src/modules/investments/investment-calendar.ts +++ b/src/modules/investments/investment-calendar.ts @@ -39,8 +39,9 @@ export function dateParts(value: string): [number, number, number] { number, number, ]; - if (utcDate(value).toISOString().slice(0, 10) !== value) + if (utcDate(value).toISOString().slice(0, 10) !== value) { throw new Error('Invalid date'); + } return parts; } diff --git a/src/modules/investments/investment-resource.dto.ts b/src/modules/investments/investment-resource.dto.ts index bf0496a..32ab6e8 100644 --- a/src/modules/investments/investment-resource.dto.ts +++ b/src/modules/investments/investment-resource.dto.ts @@ -14,18 +14,22 @@ export class InvestmentReadQueryDto { export class InvestmentSyncDto { @ApiProperty({ type: String, nullable: true }) - declare id: string | null; + declare id: null | string; + @ApiProperty({ enum: syncStateValues }) declare state: (typeof syncStateValues)[number]; + @ApiProperty({ type: String, nullable: true }) - declare errorCode: string | null; + declare errorCode: null | string; } export class InvestmentRangeDto { @ApiPropertyOptional() accountId?: string; + @ApiProperty({ format: 'date-time' }) declare from: string; + @ApiProperty({ format: 'date-time' }) declare to: string; } @@ -33,38 +37,51 @@ export class InvestmentRangeDto { export class InvestmentCoverageDto { @ApiProperty({ type: InvestmentRangeDto, isArray: true }) declare requested: InvestmentRangeDto[]; + @ApiProperty({ type: InvestmentRangeDto, isArray: true }) declare completeRanges: InvestmentRangeDto[]; + @ApiProperty({ type: InvestmentRangeDto, isArray: true }) declare missingRanges: InvestmentRangeDto[]; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) - declare asOf: string | null; + declare asOf: null | string; + @ApiProperty({ enum: ['known', 'unknown'] }) declare historyBoundary: 'known' | 'unknown'; + @ApiProperty({ enum: ['missing', 'loading', 'partial', 'complete', 'failed'], }) declare historyState: - 'missing' | 'loading' | 'partial' | 'complete' | 'failed'; + 'complete' | 'failed' | 'loading' | 'missing' | 'partial'; } export class InvestmentDependencyDto { @ApiProperty() declare dataRevision: string; + @ApiProperty() declare stateVersion: string; + @ApiPropertyOptional({ type: String }) unavailableReason?: string; + @ApiPropertyOptional({ type: () => InvestmentCoverageDto }) coverage?: InvestmentCoverageDto; + @ApiProperty() declare resourceKey: string; + @ApiProperty({ enum: availabilityValues }) declare availability: (typeof availabilityValues)[number]; + @ApiProperty({ enum: freshnessValues }) declare freshness: (typeof freshnessValues)[number]; + @ApiProperty({ type: String, format: 'date-time', nullable: true }) - declare lastSuccessfulAt: string | null; + declare lastSuccessfulAt: null | string; + @ApiProperty({ type: InvestmentSyncDto }) declare sync: InvestmentSyncDto; } diff --git a/src/modules/investments/investment-resource.mapper.ts b/src/modules/investments/investment-resource.mapper.ts index 0e47aea..dc7da49 100644 --- a/src/modules/investments/investment-resource.mapper.ts +++ b/src/modules/investments/investment-resource.mapper.ts @@ -1,25 +1,26 @@ import { createHash } from 'node:crypto'; + import { InvestmentResourceMetaDto } from './investment-resource.dto'; export function investmentResourceMeta(input: { - resourceKey: string; - dataRevision: string; availability: InvestmentResourceMetaDto['availability']; + coverage?: NonNullable; + dataRevision: string; + dependencies?: InvestmentResourceMetaDto[]; fetchedAt: Date | null; freshForMs: number; - work: { id: string; state: string; errorCode: string | null } | null; - dependencies?: InvestmentResourceMetaDto[]; - coverage?: InvestmentResourceMetaDto['coverage']; + resourceKey: string; unavailableReason?: string; + work: null | { errorCode: null | string; id: string; state: string }; }): InvestmentResourceMetaDto { const ownSync = { id: input.work?.id ?? null, state: - input.work?.state === 'running' - ? ('running' as const) - : input.work?.state === 'failed' - ? ('failed' as const) - : ('idle' as const), + input.work?.state === 'running' ? + ('running' as const) : + input.work?.state === 'failed' ? + ('failed' as const) : + ('idle' as const), errorCode: input.work?.errorCode ?? null, }; const states = [ @@ -33,11 +34,11 @@ export function investmentResourceMeta(input: { states.find((state) => state.state === 'queued') ?? states.find((state) => state.state === 'failed') ?? ownSync; - const sourceFreshness = input.fetchedAt - ? Date.now() - input.fetchedAt.valueOf() < input.freshForMs - ? ('fresh' as const) - : ('stale' as const) - : ('unknown' as const); + const sourceFreshness = input.fetchedAt ? + Date.now() - input.fetchedAt.valueOf() < input.freshForMs ? + ('fresh' as const) : + ('stale' as const) : + ('unknown' as const); const freshness = [ sourceFreshness, ...(input.dependencies ?? []) @@ -51,19 +52,20 @@ export function investmentResourceMeta(input: { ), ] .filter((timestamp): timestamp is string => Boolean(timestamp)) + // 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(); const meta = { resourceKey: input.resourceKey, - ...(input.unavailableReason - ? { unavailableReason: input.unavailableReason } - : {}), + ...(input.unavailableReason ? + { unavailableReason: input.unavailableReason } : + {}), dataRevision: input.dataRevision, availability: input.availability, - freshness: freshness.includes('unknown') - ? ('unknown' as const) - : freshness.includes('stale') - ? ('stale' as const) - : ('fresh' as const), + freshness: freshness.includes('unknown') ? + ('unknown' as const) : + freshness.includes('stale') ? + ('stale' as const) : + ('fresh' as const), lastSuccessfulAt: timestamps[0] ?? null, sync, ...(input.coverage ? { coverage: input.coverage } : {}), @@ -77,9 +79,9 @@ export function investmentResourceMeta(input: { ...meta, // The requested window is identified by resourceKey. Its moving "now" // boundary is not a change to saved coverage between status and GET. - coverage: meta.coverage - ? { ...meta.coverage, requested: undefined } - : undefined, + coverage: meta.coverage ? + { ...meta.coverage, requested: undefined } : + undefined, }), ) .digest('base64url'), diff --git a/src/modules/investments/investment-sync-plan.spec.ts b/src/modules/investments/investment-sync-plan.spec.ts index 7dd5b03..58d0915 100644 --- a/src/modules/investments/investment-sync-plan.spec.ts +++ b/src/modules/investments/investment-sync-plan.spec.ts @@ -1,7 +1,7 @@ import { - planInvestmentScreen, - ledgerRange, accountHistory, + ledgerRange, + planInvestmentScreen, } from './investment-sync-plan'; describe('screen dependency and history planning', () => { diff --git a/src/modules/investments/investment-sync-plan.ts b/src/modules/investments/investment-sync-plan.ts index 42a638b..e5b01b7 100644 --- a/src/modules/investments/investment-sync-plan.ts +++ b/src/modules/investments/investment-sync-plan.ts @@ -1,58 +1,58 @@ import { planIncomePeriod } from './income/income.api'; import { dateOnlyBounds, moscowDate } from './investment-calendar'; import { - operationIntervals, type OperationInterval, + operationIntervals, } from './operations/domain/operations-sync-plan'; export type InvestmentScreen = { - screen: - | 'accounts' - | 'account' - | 'operations' - | 'income' - | 'events' - | 'stocks' - | 'bonds' - | 'stock' - | 'bond'; accountId?: string; + candlePeriod?: 'all' | 'halfYear' | 'month' | 'year'; + candleRange?: { from: Date; to: Date }; from?: string; - to?: string; instrumentUid?: string; query?: string; - candlePeriod?: 'month' | 'halfYear' | 'year' | 'all'; - candleRange?: { from: Date; to: Date }; + screen: + | 'account' | + 'accounts' | + 'bond' | + 'bonds' | + 'events' | + 'income' | + 'operations' | + 'stock' | + 'stocks'; + to?: string; }; const defaultEventDays = 30; const incomeHistoryYears = 5; const candleMonths = { month: 1, halfYear: 6, year: 12 }; -type Account = { accountId: string; openedOn: string | null }; +type Account = { accountId: string; openedOn: null | string }; type Instrument = { - uid: string; - type: 'STOCK' | 'BOND'; historyFrom: Date | null; + type: 'BOND' | 'STOCK'; + uid: string; }; type Source = { - kind: - | 'membership' - | 'portfolio' - | 'operations' - | 'metadata' - | 'catalog' - | 'quote' - | 'company' - | 'metrics' - | 'calendar' - | 'candles'; accountId?: string; - instrumentUid?: string; - instrumentType?: 'STOCK' | 'BOND'; from?: Date; - to?: Date; + instrumentType?: 'BOND' | 'STOCK'; + instrumentUid?: string; + kind: + | 'calendar' | + 'candles' | + 'catalog' | + 'company' | + 'membership' | + 'metadata' | + 'metrics' | + 'operations' | + 'portfolio' | + 'quote'; + priority: 'discovery' | 'history' | 'screen'; query?: string; - priority: 'discovery' | 'screen' | 'history'; + to?: Date; }; // Expands screen scope, never credentials or arbitrary caller-supplied jobs. @@ -74,19 +74,19 @@ export function planInvestmentScreen( screen.accountId === 'all' || account.accountId === screen.accountId, ); - const unavailable: { resourceKey: string; reason: string }[] = []; + const unavailable: { reason: string; resourceKey: string }[] = []; const operations = (from: Date, to: Date, instrumentUid?: string) => { const targets = accounts.flatMap((account) => - from < to - ? [ + from < to ? + [ { accountId: account.accountId, from, to, ...(instrumentUid ? { instrumentUid } : {}), }, - ] - : [], + ] : + [], ); sources.push( ...operationIntervals(targets).map((range) => ({ @@ -105,16 +105,18 @@ export function planInvestmentScreen( })), ); const knownOpening = - accounts.length && accounts.every((account) => account.openedOn) - ? new Date( + accounts.length && accounts.every((account) => account.openedOn) ? + new Date( + // 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. `${accounts.map((account) => account.openedOn ?? '').sort()[0]}T00:00:00+03:00`, - ) - : undefined; + ) : + undefined; if (!context.membershipKnown) return { sources, unavailable, now }; if ( - ['accounts', 'account', 'events', 'stock', 'bond'].includes(screen.screen) - ) + ['account', 'accounts', 'bond', 'events', 'stock'].includes(screen.screen) + ) { portfolio(); + } if ( screen.screen === 'operations' || screen.screen === 'stock' || @@ -137,18 +139,19 @@ export function planInvestmentScreen( ); for (const range of [ period.selected, - ...(screen.screen === 'income' - ? [period.previous, ...(period.yearAgo ? [period.yearAgo] : [])] - : []), + ...(screen.screen === 'income' ? + [period.previous, ...(period.yearAgo ? [period.yearAgo] : [])] : + []), ]) { const bounds = dateOnlyBounds(range, now); operations(bounds.from, bounds.to); } - if (screen.screen === 'income' && !period.yearAgo) + if (screen.screen === 'income' && !period.yearAgo) { unavailable.push({ resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier reason: 'calendar_or_account_boundary', }); + } if (screen.screen === 'account') { const range = ledgerRange( screen.from, @@ -160,13 +163,14 @@ export function planInvestmentScreen( operations(range.from, range.to); } } - if (screen.screen === 'stocks' || screen.screen === 'bonds') + if (screen.screen === 'stocks' || screen.screen === 'bonds') { sources.push({ kind: 'catalog', instrumentType: screen.screen === 'stocks' ? 'STOCK' : 'BOND', query: screen.query?.trim().toUpperCase() ?? '', priority: 'screen', }); + } const instruments = context.instruments.filter( (instrument) => !screen.instrumentUid || instrument.uid === screen.instrumentUid, @@ -175,20 +179,22 @@ export function planInvestmentScreen( screen.instrumentUid && !instruments.length && (screen.screen === 'stock' || screen.screen === 'bond') - ) + ) { instruments.push({ uid: screen.instrumentUid, type: screen.screen === 'stock' ? 'STOCK' : 'BOND', historyFrom: null, }); + } for (const instrument of instruments) { const identity = { instrumentUid: instrument.uid, instrumentType: instrument.type, }; sources.push({ ...identity, kind: 'metadata', priority: 'screen' }); - if (['stocks', 'bonds', 'stock', 'bond'].includes(screen.screen)) + if (['bond', 'bonds', 'stock', 'stocks'].includes(screen.screen)) { sources.push({ ...identity, kind: 'quote', priority: 'screen' }); + } if (screen.screen === 'stock' || screen.screen === 'bond') { sources.push({ ...identity, @@ -197,47 +203,51 @@ export function planInvestmentScreen( }); const from = new Date(now); const period = screen.candlePeriod ?? 'month'; - if (period !== 'all') + if (period !== 'all') { + // eslint-disable-next-line security/detect-object-injection -- The period belongs to the validated candle-period union. from.setUTCMonth(from.getUTCMonth() - candleMonths[period]); + } const range = screen.candleRange ?? - (period === 'all' - ? instrument.historyFrom - ? { from: instrument.historyFrom, to: now } - : null - : { from, to: now }); - if (range) + (period === 'all' ? + instrument.historyFrom ? + { from: instrument.historyFrom, to: now } : + null : + { from, to: now }); + if (range) { sources.push({ ...identity, kind: 'candles', ...range, priority: 'screen', }); - else + } else { unavailable.push({ resourceKey: `candles/${instrument.uid}/all`, reason: 'history_start_unavailable', }); + } } - if (['account', 'events', 'stock', 'bond'].includes(screen.screen)) { + if (['account', 'bond', 'events', 'stock'].includes(screen.screen)) { const day = - screen.screen === 'stock' || screen.screen === 'bond' - ? moscowDate(now) - : now.toISOString().slice(0, 10); + screen.screen === 'stock' || screen.screen === 'bond' ? + moscowDate(now) : + now.toISOString().slice(0, 10); const from = - screen.screen === 'events' && screen.from - ? new Date(`${screen.from}T00:00:00Z`) - : new Date(`${day}T00:00:00Z`); + screen.screen === 'events' && screen.from ? + new Date(`${screen.from}T00:00:00Z`) : + new Date(`${day}T00:00:00Z`); const to = - screen.screen === 'events' && screen.to - ? new Date(`${screen.to}T00:00:00Z`) - : new Date(from); + screen.screen === 'events' && screen.to ? + new Date(`${screen.to}T00:00:00Z`) : + new Date(from); if ( !(screen.screen === 'events' && screen.to) && screen.screen !== 'stock' && screen.screen !== 'bond' - ) + ) { to.setUTCDate(to.getUTCDate() + defaultEventDays); + } if (screen.screen === 'stock' || screen.screen === 'bond') { from.setUTCFullYear(from.getUTCFullYear() - incomeHistoryYears); to.setUTCFullYear(to.getUTCFullYear() + 1); @@ -252,9 +262,9 @@ export function planInvestmentScreen( } if ( !force && - ['account', 'operations', 'income', 'events'].includes(screen.screen) + ['account', 'events', 'income', 'operations'].includes(screen.screen) ) { - if (instrument.historyFrom) + if (instrument.historyFrom) { sources.push({ ...identity, kind: 'candles', @@ -262,14 +272,15 @@ export function planInvestmentScreen( to: now, priority: 'history', }); - else + } else { unavailable.push({ resourceKey: `candles/${instrument.uid}/all`, reason: 'history_start_unavailable', }); + } } } - if (!force && !['stocks', 'bonds'].includes(screen.screen)) + if (!force && !['bonds', 'stocks'].includes(screen.screen)) { sources.push( ...accountHistory(accounts, now).map((range) => ({ ...range, @@ -277,6 +288,7 @@ export function planInvestmentScreen( priority: 'history' as const, })), ); + } const priorities = { discovery: 0, screen: 1, history: 2 }; sources.sort( (left, right) => priorities[left.priority] - priorities[right.priority], @@ -290,9 +302,9 @@ export function accountHistory( ): OperationInterval[] { return accounts.flatMap((account) => { const from = new Date( - account.openedOn - ? `${account.openedOn}T00:00:00Z` - : '1970-01-01T00:00:00Z', + account.openedOn ? + `${account.openedOn}T00:00:00Z` : + '1970-01-01T00:00:00Z', ); return from < now ? [{ accountId: account.accountId, from, to: now }] : []; }); @@ -305,21 +317,22 @@ export function ledgerRange( accounts: Account[], now: Date, ) { - const opening = accounts.every((account) => account.openedOn) - ? accounts.map((account) => account.openedOn ?? '').sort()[0] - : undefined; - const from = fromValue - ? new Date(fromValue) - : instrumentUid - ? new Date(opening ? `${opening}T00:00:00Z` : '1970-01-01T00:00:00Z') - : new Date(Date.UTC(now.getUTCFullYear(), 0, 1)); - const end = toValue - ? new Date( - /^\d{4}-\d{2}-\d{2}$/.test(toValue) - ? `${toValue}T23:59:59.999Z` - : toValue, - ) - : now; + const opening = accounts.every((account) => account.openedOn) ? + // 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. + accounts.map((account) => account.openedOn ?? '').sort()[0] : + undefined; + const from = fromValue ? + new Date(fromValue) : + instrumentUid ? + new Date(opening ? `${opening}T00:00:00Z` : '1970-01-01T00:00:00Z') : + new Date(Date.UTC(now.getUTCFullYear(), 0, 1)); + const end = toValue ? + new Date( + /^\d{4}-\d{2}-\d{2}$/.test(toValue) ? + `${toValue}T23:59:59.999Z` : + toValue, + ) : + now; return { from, to: new Date(Math.min(end.valueOf() + (toValue ? 1 : 0), now.valueOf())), diff --git a/src/modules/investments/operations/application/brokerage-operations-sync.service.ts b/src/modules/investments/operations/application/brokerage-operations-sync.service.ts index cb37443..8e635eb 100644 --- a/src/modules/investments/operations/application/brokerage-operations-sync.service.ts +++ b/src/modules/investments/operations/application/brokerage-operations-sync.service.ts @@ -1,22 +1,23 @@ -import { accountHistory } from '../../investment-sync-plan'; import { Injectable, NotFoundException } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; -import { MILLISECONDS_PER_SECOND } from '../../../../common/time'; + import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; +import { MILLISECONDS_PER_SECOND } from '../../../../common/time'; import { TbankAccessService, TbankClient, } from '../../../../integrations/tbank/tbank.api'; +import { accountHistory } from '../../investment-sync-plan'; +import { + type OperationInterval, + operationIntervals, + planOperationRead, + uncoveredOperationIntervals, +} from '../domain/operations-sync-plan'; import { BrokerageOperationsRepository, type OperationAttempt, } from '../infrastructure/brokerage-operations.repository'; -import { - operationIntervals, - planOperationRead, - type OperationInterval, - uncoveredOperationIntervals, -} from '../domain/operations-sync-plan'; const operationsPageSize = 1000; const priorities = { screen: 1, history: 2 } as const; @@ -29,6 +30,7 @@ export class BrokerageOperationsSyncService { string, { attempt: OperationAttempt; priority: number } >(); + private draining = false; // Only short DB reservations/publications are serialized, never RPCs. private reservations: Promise = Promise.resolve(); @@ -44,10 +46,10 @@ export class BrokerageOperationsSyncService { userId: string, intervals: OperationInterval[], options: { - movingEnd?: boolean; force?: boolean; - observe?: boolean; history?: boolean; + movingEnd?: boolean; + observe?: boolean; revision?: string; } = {}, ) { @@ -55,8 +57,9 @@ export class BrokerageOperationsSyncService { if ( !connection.membershipAt || (options.revision && options.revision !== connection.revision) - ) + ) { return { plan: null, handle: null }; + } if ( intervals.some( (interval) => @@ -64,8 +67,9 @@ export class BrokerageOperationsSyncService { (account) => account.accountId === interval.accountId, ), ) - ) + ) { throw new NotFoundException(); + } const coverage = await this.repository.coverage( userId, connection.revision, @@ -76,24 +80,24 @@ export class BrokerageOperationsSyncService { ...intervals.map((interval) => interval.to.valueOf()), ); const targets = intervals.map((interval) => - options.movingEnd && interval.to.valueOf() === movingTo - ? { ...interval, to: now } - : interval, + options.movingEnd && interval.to.valueOf() === movingTo ? + { ...interval, to: now } : + interval, ); const plan = planOperationRead(targets, coverage, now, options); const complete = plan.intervals.every( (interval) => interval.availability === 'complete', ); const handle = - !options.observe && plan.load.length - ? await this.request( + !options.observe && plan.load.length ? + await this.request( userId, plan.load, complete || Boolean(options.force), 'screen', connection.revision, - ) - : null; + ) : + null; if (!options.observe && !options.force && options.history) { const accountIds = new Set( intervals.map((interval) => interval.accountId), @@ -108,7 +112,7 @@ export class BrokerageOperationsSyncService { movingEnd: true, }).load; const remaining = uncoveredOperationIntervals(missing, targets); - if (remaining.length) + if (remaining.length) { await this.request( userId, remaining, @@ -116,6 +120,7 @@ export class BrokerageOperationsSyncService { 'history', connection.revision, ); + } } return { plan, handle }; } @@ -126,7 +131,7 @@ export class BrokerageOperationsSyncService { atomic: boolean, priority: keyof typeof priorities = 'screen', revision?: string, - ): Promise { + ): Promise { const connection = await this.access.local(userId); if ( connection.membershipAt && @@ -136,8 +141,9 @@ export class BrokerageOperationsSyncService { (account) => account.accountId === interval.accountId, ), ) - ) + ) { throw new NotFoundException(); + } if (revision && revision !== connection.revision) return null; const cutoff = new Date(); const bounded = operationIntervals(intervals) @@ -155,6 +161,7 @@ export class BrokerageOperationsSyncService { this.processRunId, atomic, this.runningAttempts, + // eslint-disable-next-line security/detect-object-injection -- The priority belongs to the internal priority union. priorities[priority], ); if (!requested) return null; @@ -258,17 +265,18 @@ export class BrokerageOperationsSyncService { Math.ceil(interval.to.valueOf() / MILLISECONDS_PER_SECOND) + 1, ), }, - ...(interval.instrumentUid - ? { instrumentId: interval.instrumentUid } - : {}), + ...(interval.instrumentUid ? + { instrumentId: interval.instrumentUid } : + {}), cursor, limit: operationsPageSize, state: 'OPERATION_STATE_EXECUTED', }); if ( !(await this.repository.stagePage(attempt, interval, cursor, response)) - ) + ) { return false; + } if (!plan.atomic && !response.hasNext) { if ( !(await this.repository.publish( @@ -277,8 +285,9 @@ export class BrokerageOperationsSyncService { new Date(), true, )) - ) + ) { return false; + } } // One page per turn: newly requested screen work can overtake backfill. // TbankClient alone owns the bounded RPC queue and retries. diff --git a/src/modules/investments/operations/application/brokerage-operations.service.ts b/src/modules/investments/operations/application/brokerage-operations.service.ts index b29231e..d9bac28 100644 --- a/src/modules/investments/operations/application/brokerage-operations.service.ts +++ b/src/modules/investments/operations/application/brokerage-operations.service.ts @@ -1,41 +1,42 @@ -import { - ledgerRange, - accountHistory, - planInvestmentScreen, -} from '../../investment-sync-plan'; -import { ConfigService } from '@nestjs/config'; -import { investmentResourceMeta } from '../../investment-resource.mapper'; -import { BrokerageOperationsRepository } from '../infrastructure/brokerage-operations.repository'; -import { BrokerageOperationsSyncService } from './brokerage-operations-sync.service'; -import { - planOperationRead, - uncoveredOperationIntervals, - operationIntervalsOverlap, -} from '../domain/operations-sync-plan'; import { BadRequestException, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { createHash, createHmac, timingSafeEqual } from 'node:crypto'; + import { InstrumentMetadataService, metadataKey, TbankAccessService, TbankMembershipService, - tbankSourceTtlMs, type TbankOperationItem, + tbankSourceTtlMs, } from '../../../../integrations/tbank/tbank.api'; -import { DateOnlyRange, dateOnlyBounds } from '../../investment-calendar'; +import { dateOnlyBounds, DateOnlyRange } from '../../investment-calendar'; +import { investmentResourceMeta } from '../../investment-resource.mapper'; import { - DEFAULT_OPERATIONS_PAGE_SIZE, - BrokerageOperationsQueryDto, -} from '../presentation/brokerage-operation.dto'; + accountHistory, + ledgerRange, + planInvestmentScreen, +} from '../../investment-sync-plan'; +import { + operationIntervalsOverlap, + planOperationRead, + uncoveredOperationIntervals, +} from '../domain/operations-sync-plan'; import { mapBrokerageOperation } from '../infrastructure/brokerage-operation.mapper'; +import { BrokerageOperationsRepository } from '../infrastructure/brokerage-operations.repository'; +import { + BrokerageOperationsQueryDto, + DEFAULT_OPERATIONS_PAGE_SIZE, +} from '../presentation/brokerage-operation.dto'; +import { BrokerageOperationsSyncService } from './brokerage-operations-sync.service'; type LocalOperationsCursor = { - movingEnd?: boolean; - binding: string; - to: string; - at: string; accountId: string; + at: string; + binding: string; id: string; + movingEnd?: boolean; + to: string; }; @Injectable() @@ -70,18 +71,19 @@ export class BrokerageOperationsService { const from = range.from; const to = cursor ? new Date(cursor.to) : range.to; const movingEnd = cursor?.movingEnd ?? range.to.valueOf() === now.valueOf(); + // 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. const categories = [...new Set(query.categories ?? [])].sort(); const limit = query.limit ?? DEFAULT_OPERATIONS_PAGE_SIZE; const filterKey = JSON.stringify([ accountId, from.toISOString(), - query.to - ? new Date( - /^\d{4}-\d{2}-\d{2}$/.test(query.to) - ? `${query.to}T23:59:59.999Z` - : query.to, - ).toISOString() - : 'now', + query.to ? + new Date( + /^\d{4}-\d{2}-\d{2}$/.test(query.to) ? + `${query.to}T23:59:59.999Z` : + query.to, + ).toISOString() : + 'now', query.instrumentUid ?? null, categories, limit, @@ -118,9 +120,9 @@ export class BrokerageOperationsService { source.priority === 'screen' && source.accountId && source.from && - source.to - ? [{ accountId: source.accountId, from: source.from, to: source.to }] - : [], + source.to ? + [{ accountId: source.accountId, from: source.from, to: source.to }] : + [], ); if (query.sync !== 'observe') { const discovery = await this.membership.request( @@ -148,9 +150,9 @@ export class BrokerageOperationsService { accountId === 'all' || account.accountId === accountId, ) .flatMap((account) => - currentRange.from < currentRange.to - ? [{ accountId: account.accountId, ...currentRange }] - : [], + currentRange.from < currentRange.to ? + [{ accountId: account.accountId, ...currentRange }] : + [], ); await this.sync.readPlan(userId, targets, { movingEnd, @@ -170,16 +172,16 @@ export class BrokerageOperationsService { const saved = await this.repository.readPage(readInput); const accounts = saved?.connection.accounts ?? []; const targets = accounts.flatMap((account) => - from < to - ? [ + from < to ? + [ { accountId: account.accountId, from, to, instrumentUid: query.instrumentUid, }, - ] - : [], + ] : + [], ); const plan = planOperationRead(targets, saved?.coverage ?? [], now, { movingEnd, @@ -191,8 +193,8 @@ export class BrokerageOperationsService { row.accountId === target.accountId && (!row.instrumentUid || row.instrumentUid === target.instrumentUid) && - row.from < target.to && - target.from < row.to, + row.from < target.to && + target.from < row.to, ) .map((row) => ({ accountId: target.accountId, @@ -230,13 +232,8 @@ export class BrokerageOperationsService { sources .map((row) => row.fetchedAt) .sort((a, b) => a.valueOf() - b.valueOf())[0] ?? - (complete ? saved?.connection.membershipAt : null) ?? - null; - const serialize = (range: { accountId: string; from: Date; to: Date }) => ({ - accountId: range.accountId, - from: range.from.toISOString(), - to: range.to.toISOString(), - }); + (complete ? saved?.connection.membershipAt : null) ?? + null; const historyTargets = accountHistory(accounts, now); const history = planOperationRead( historyTargets, @@ -273,9 +270,11 @@ export class BrokerageOperationsService { ] as const; }), ); - for (const identity of identities.values()) - if (!instruments.get(metadataKey(identity))?.fresh) + for (const identity of identities.values()) { + if (!instruments.get(metadataKey(identity))?.fresh) { await this.metadata.request(userId, connection.revision, identity); + } + } } const membershipMeta = investmentResourceMeta({ resourceKey: 'accounts/membership', @@ -288,17 +287,17 @@ export class BrokerageOperationsService { const meta = investmentResourceMeta({ resourceKey: `operations/${createHash('sha256').update(filterKey).digest('base64url')}`, dataRevision: saved?.binding ?? 'missing', - availability: !available - ? 'missing' - : complete - ? 'complete' - : confirmed.length - ? 'partial' - : 'missing', + availability: !available ? + 'missing' : + complete ? + 'complete' : + confirmed.length ? + 'partial' : + 'missing', fetchedAt: lastSuccessfulAt, - freshForMs: plan.intervals.some((interval) => interval.stale) - ? 0 - : Number.POSITIVE_INFINITY, + freshForMs: plan.intervals.some((interval) => interval.stale) ? + 0 : + Number.POSITIVE_INFINITY, work: work ?? saved?.membershipWork ?? null, dependencies: [membershipMeta, ...metadataDependencies], coverage: { @@ -312,8 +311,8 @@ export class BrokerageOperationsService { (interval) => interval.accountId === account.accountId && interval.asOf, ), - ) - ? new Date( + ) ? + new Date( Math.min( ...accounts.map((account) => Math.max( @@ -326,42 +325,42 @@ export class BrokerageOperationsService { ), ), ), - ).toISOString() - : null, + ).toISOString() : + null, historyBoundary: knownBoundary ? 'known' : 'unknown', historyState: saved?.work.some( (work) => work.state === 'running' && work.priority > 1, - ) - ? 'loading' - : saved?.work.some( - (work) => work.state === 'failed' && work.priority > 1, - ) - ? 'failed' - : knownBoundary && - history.intervals.every( - (interval) => interval.availability === 'complete', - ) - ? 'complete' - : saved?.coverage.length - ? 'partial' - : 'missing', + ) ? + 'loading' : + saved?.work.some( + (work) => work.state === 'failed' && work.priority > 1, + ) ? + 'failed' : + knownBoundary && + history.intervals.every( + (interval) => interval.availability === 'complete', + ) ? + 'complete' : + saved?.coverage.length ? + 'partial' : + 'missing', }, }); const last = rows.at(-1); const hasNext = Boolean(saved && saved.rows.length > limit); return { data: - meta.availability === 'missing' - ? null - : { + meta.availability === 'missing' ? + null : + { accountId, items: rows.map((row) => ({ ...mapBrokerageOperation( row.payload as TbankOperationItem, row.occurredAt, ), - ...(accountId === 'all' - ? { + ...(accountId === 'all' ? + { account: { id: row.accountId, name: @@ -369,8 +368,8 @@ export class BrokerageOperationsService { (account) => account.accountId === row.accountId, )?.name ?? row.accountId, }, - } - : {}), + } : + {}), logoName: instruments .get(metadataKey(row.payload as TbankOperationItem)) @@ -378,16 +377,16 @@ export class BrokerageOperationsService { })), hasNext, nextCursor: - hasNext && last && saved - ? this.encodeCursor({ + hasNext && last && saved ? + this.encodeCursor({ binding: saved.binding, movingEnd, to: to.toISOString(), at: last.occurredAt.toISOString(), accountId: last.accountId, id: last.brokerOperationId, - }) - : null, + }) : + null, observedAt: now.toISOString(), }, meta, @@ -413,23 +412,25 @@ export class BrokerageOperationsService { extra || actual.length !== expected.length || !timingSafeEqual(actual, expected) - ) + ) { throw new Error(); + } const cursor = JSON.parse( Buffer.from(body, 'base64url').toString(), ) as Partial; if ( (cursor.movingEnd !== undefined && typeof cursor.movingEnd !== 'boolean') || - !cursor.binding || - !cursor.accountId || - !cursor.id || - !cursor.at || - !cursor.to || - !Number.isFinite(Date.parse(cursor.at)) || - !Number.isFinite(Date.parse(cursor.to)) - ) + !cursor.binding || + !cursor.accountId || + !cursor.id || + !cursor.at || + !cursor.to || + !Number.isFinite(Date.parse(cursor.at)) || + !Number.isFinite(Date.parse(cursor.to)) + ) { throw new Error(); + } return cursor as LocalOperationsCursor; } catch { throw new BadRequestException({ code: 'INVALID_OPERATIONS_CURSOR' }); @@ -465,12 +466,13 @@ export class BrokerageOperationsService { ...period, })), ); - if (targets.length) + if (targets.length) { await this.sync.readPlan(userId, targets, { movingEnd: snapshot.periods?.some((period) => period.movingEnd), history: true, revision: connection.revision, }); + } }; if (!observe) { const discovery = await this.membership.request( @@ -530,17 +532,8 @@ export class BrokerageOperationsService { sources .map((row) => row.fetchedAt) .sort((a, b) => a.valueOf() - b.valueOf())[0] ?? - (complete ? saved?.connection.membershipAt : null) ?? - null; - const serialize = (range: { - accountId: string; - from: Date; - to: Date; - }) => ({ - accountId: range.accountId, - from: range.from.toISOString(), - to: range.to.toISOString(), - }); + (complete ? saved?.connection.membershipAt : null) ?? + null; const confirmed = plan.intervals.flatMap((target) => sources .filter( @@ -555,15 +548,16 @@ export class BrokerageOperationsService { })), ); const meta = investmentResourceMeta({ + // eslint-disable-next-line security/detect-object-injection -- The numeric index enumerates the three comparison periods. resourceKey: `income/${accountId}/${['selected', 'previous', 'yearAgo'][index]}/${period.key}`, dataRevision: saved?.binding ?? 'missing', - availability: !known - ? 'missing' - : complete - ? 'complete' - : sources.length - ? 'partial' - : 'missing', + availability: !known ? + 'missing' : + complete ? + 'complete' : + sources.length ? + 'partial' : + 'missing', fetchedAt, freshForMs: plan.intervals.some((range) => range.stale) ? 0 : Infinity, work, @@ -576,8 +570,8 @@ export class BrokerageOperationsService { confirmed, ).map(serialize), asOf: - complete && targets.length - ? new Date( + complete && targets.length ? + new Date( Math.min( ...targets.map((target) => Math.max( @@ -592,24 +586,24 @@ export class BrokerageOperationsService { ), ), ), - ).toISOString() - : null, + ).toISOString() : + null, historyBoundary: (saved?.connection.accounts ?? []).every( (account) => account.openedOn, - ) - ? 'known' - : 'unknown', - historyState: complete - ? 'complete' - : sources.length - ? 'partial' - : 'missing', + ) ? + 'known' : + 'unknown', + historyState: complete ? + 'complete' : + sources.length ? + 'partial' : + 'missing', }, }); return { meta, - items: complete - ? (saved?.rows ?? []) + items: complete ? + (saved?.rows ?? []) .filter((row) => plan.intervals.some( (interval) => @@ -623,8 +617,8 @@ export class BrokerageOperationsService { row.payload as TbankOperationItem, row.occurredAt, ), - ...(accountId === 'all' - ? { + ...(accountId === 'all' ? + { account: { id: row.accountId, name: @@ -632,11 +626,15 @@ export class BrokerageOperationsService { (account) => account.accountId === row.accountId, )?.name ?? row.accountId, }, - } - : {}), - })) - : null, + } : + {}), + })) : + null, }; }); } } + +function serialize(range: { accountId: string; from: Date; to: Date }) { + return { accountId: range.accountId, from: range.from.toISOString(), to: range.to.toISOString() }; +} diff --git a/src/modules/investments/operations/domain/operations-sync-plan.ts b/src/modules/investments/operations/domain/operations-sync-plan.ts index 555a43c..b0e73ca 100644 --- a/src/modules/investments/operations/domain/operations-sync-plan.ts +++ b/src/modules/investments/operations/domain/operations-sync-plan.ts @@ -1,8 +1,8 @@ export type OperationInterval = { accountId: string; from: Date; - to: Date; instrumentUid?: string; + to: Date; }; // Normalize duplicate/overlapping targets before creating a durable identity. @@ -12,8 +12,10 @@ export function operationIntervals( ): OperationInterval[] { const groups = new Map(); for (const interval of requested) { - if (!(interval.from < interval.to)) + // eslint-disable-next-line sonarjs/no-inverted-boolean-check -- Negation also rejects Invalid Date; >= would silently accept NaN. + if (!(interval.from < interval.to)) { throw new Error('Invalid operations interval'); + } const key = JSON.stringify([ interval.accountId, interval.instrumentUid ?? '', @@ -23,17 +25,18 @@ export function operationIntervals( groups.set(key, group); } const result: OperationInterval[] = []; + // 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. for (const key of [...groups.keys()].sort()) { const merged: OperationInterval[] = []; for (const interval of (groups.get(key) ?? []).sort( (left, right) => left.from.valueOf() - right.from.valueOf(), )) { const previous = merged.at(-1); - if (previous && interval.from <= previous.to) + if (previous && interval.from <= previous.to) { previous.to = new Date( Math.max(previous.to.valueOf(), interval.to.valueOf()), ); - else merged.push({ ...interval }); + } else merged.push({ ...interval }); } for (const interval of merged) { let from = interval.from; @@ -61,8 +64,8 @@ export function operationIntervalsOverlap( (!left.instrumentUid || !right.instrumentUid || left.instrumentUid === right.instrumentUid) && - left.from < right.to && - right.from < left.to + left.from < right.to && + right.from < left.to ); } @@ -78,15 +81,16 @@ export function uncoveredOperationIntervals( !operationIntervalsOverlap(fragment, covered) || (covered.instrumentUid && covered.instrumentUid !== fragment.instrumentUid) - ) + ) { return [fragment]; + } return [ - ...(fragment.from < covered.from - ? [{ ...fragment, to: covered.from }] - : []), - ...(covered.to < fragment.to - ? [{ ...fragment, from: covered.to }] - : []), + ...(fragment.from < covered.from ? + [{ ...fragment, to: covered.from }] : + []), + ...(covered.to < fragment.to ? + [{ ...fragment, from: covered.to }] : + []), ]; }); } @@ -95,8 +99,8 @@ export function uncoveredOperationIntervals( } export type OperationCoverage = OperationInterval & { - fetchedAt: Date; asOf: Date; + fetchedAt: Date; }; const operationsFreshForMs = 300_000; @@ -104,7 +108,7 @@ export function planOperationRead( requested: OperationInterval[], coverage: OperationCoverage[], now: Date, - options: { movingEnd?: boolean; force?: boolean } = {}, + options: { force?: boolean; movingEnd?: boolean } = {}, ) { const monthStart = new Date( Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1), @@ -137,8 +141,9 @@ export function planOperationRead( row.asOf.valueOf() === row.to.valueOf() && row.to > target.from, ) .sort((left, right) => right.to.valueOf() - left.to.valueOf())[0]; - if (latest && latest.to < target.to) + if (latest && latest.to < target.to) { effective = { ...target, to: latest.to }; + } } const missing = uncoveredOperationIntervals([effective], ranges); const complete = missing.length === 0; @@ -163,20 +168,20 @@ export function planOperationRead( // Completeness is bounded by the observed end, never by the new wall clock. return { ...effective, - availability: complete - ? ('complete' as const) - : sources.length - ? ('partial' as const) - : ('missing' as const), + availability: complete ? + ('complete' as const) : + sources.length ? + ('partial' as const) : + ('missing' as const), stale, - asOf: sources.length - ? new Date( + asOf: sources.length ? + new Date( Math.min( effective.to.valueOf(), Math.max(...sources.map((row) => row.asOf.valueOf())), ), - ) - : null, + ) : + null, }; }); return { intervals, load: operationIntervals(load) }; diff --git a/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts b/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts index 3fe3966..586080e 100644 --- a/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts +++ b/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts @@ -1,10 +1,10 @@ -import { MILLISECONDS_PER_SECOND } from '../../../../common/time'; import { formatDecimal } from '../../../../common/decimal'; +import { MILLISECONDS_PER_SECOND } from '../../../../common/time'; +import { decimal } from '../../../../integrations/tbank/decimal'; import { TbankMoney, TbankOperationItem, } from '../../../../integrations/tbank/tbank.api'; -import { decimal } from '../../../../integrations/tbank/decimal'; import { BrokerageOperationDto, OperationCategory, @@ -59,14 +59,14 @@ export function mapBrokerageOperation( logoName: null, payment: money(operation.payment), quantity: - (operationCategory(operation.type) === 'trade' - ? operation.quantityDone - : operation.quantity) === undefined - ? null - : String( - operationCategory(operation.type) === 'trade' - ? operation.quantityDone - : operation.quantity, + (operationCategory(operation.type) === 'trade' ? + operation.quantityDone : + operation.quantity) === undefined ? + null : + String( + operationCategory(operation.type) === 'trade' ? + operation.quantityDone : + operation.quantity, ), price: money(operation.price), accruedInterest: money(operation.accruedInt), @@ -74,22 +74,22 @@ export function mapBrokerageOperation( }; } -function text(value: string | undefined): string | null { +function text(value: string | undefined): null | string { return value?.trim() || null; } function money(value: TbankMoney | undefined) { const currency = value?.currency?.trim().toUpperCase(); - return currency && value - ? { currency, value: formatDecimal(decimal(value)) } - : null; + return currency && value ? + { currency, value: formatDecimal(decimal(value)) } : + null; } export function operationTimestamp( - value: { seconds?: string | number; nanos?: number } | undefined, + value: undefined | { nanos?: number; seconds?: number | string }, ) { return new Date( Number(value?.seconds ?? 0) * MILLISECONDS_PER_SECOND + - Math.floor((value?.nanos ?? 0) / NANOSECONDS_PER_MILLISECOND), + Math.floor((value?.nanos ?? 0) / NANOSECONDS_PER_MILLISECOND), ); } diff --git a/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts b/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts index bd86956..375df64 100644 --- a/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts +++ b/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts @@ -5,46 +5,48 @@ import { } from '@nestjs/common'; import { createHash, randomUUID } from 'node:crypto'; import { isDeepStrictEqual } from 'node:util'; -import { PrismaService } from '../../../../prisma/prisma.service'; + import type { Prisma } from '../../../../../generated/prisma/client'; import type { TbankOperationItem, TbankOperationsByCursorResponse, } from '../../../../integrations/tbank/tbank.api'; + +import { PrismaService } from '../../../../prisma/prisma.service'; import { - DateOnlyRange, dateOnlyBounds, + DateOnlyRange, moscowDate, } from '../../investment-calendar'; -import { - operationCategory, - operationTimestamp, -} from './brokerage-operation.mapper'; import { type OperationInterval, operationIntervals, operationIntervalsOverlap, uncoveredOperationIntervals, } from '../domain/operations-sync-plan'; +import { + operationCategory, + operationTimestamp, +} from './brokerage-operation.mapper'; export type { OperationInterval } from '../domain/operations-sync-plan'; export type OperationAttempt = { - id: string; attemptId: string; - userId: string; + id: string; revision: string; + userId: string; }; type OperationsIntent = { - intervals: string[]; atomic: boolean; atomicIntervals?: string[]; + intervals: string[]; screenIntervals?: string[]; }; type PageProgress = { complete: boolean; cursor: string; - seen: string[]; pages: number; + seen: string[]; }; const progressItem = ''; @@ -53,17 +55,17 @@ export class BrokerageOperationsRepository { constructor(private readonly prisma: PrismaService) {} async readPage(input: { - userId: string; - revision: string; accountId: string; - from: Date; - to: Date; - instrumentUid?: string; categories: string[]; + cursor?: { accountId: string; at: string; binding: string; id: string }; + filterKey: string; + from: Date; + instrumentUid?: string; limit?: number; periods?: (openedAt: Date | undefined) => DateOnlyRange[]; - filterKey: string; - cursor?: { binding: string; at: string; accountId: string; id: string }; + revision: string; + to: Date; + userId: string; }) { return this.prisma.$transaction(async (tx) => { const connection = await tx.tbankConnection.findUnique({ @@ -92,14 +94,16 @@ export class BrokerageOperationsRepository { connection.membershipAt && input.accountId !== 'all' && !connection.accounts.length - ) + ) { throw new NotFoundException(); + } const accounts = connection.accounts; + // 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. const oldest = accounts.map((account) => account.openedOn).sort()[0]; const openedAt = - oldest && accounts.every((account) => account.openedOn) - ? new Date(`${oldest}T00:00:00+03:00`) - : undefined; + oldest && accounts.every((account) => account.openedOn) ? + new Date(`${oldest}T00:00:00+03:00`) : + undefined; const periods = input.periods?.(openedAt).map((period) => { const now = new Date(); const bounds = dateOnlyBounds(period, now); @@ -123,8 +127,9 @@ export class BrokerageOperationsRepository { ]), ) .digest('base64url'); - if (input.cursor && input.cursor.binding !== binding) + if (input.cursor && input.cursor.binding !== binding) { throw new BadRequestException({ code: 'INVALID_OPERATIONS_CURSOR' }); + } const scope = { userId: input.userId, revision: input.revision, @@ -162,21 +167,21 @@ export class BrokerageOperationsRepository { const rows = await tx.tbankOperation.findMany({ where: { ...scope, - ...(periods - ? { + ...(periods ? + { OR: periods.map((period) => ({ occurredAt: { gte: period.from, lt: period.to }, })), - } - : { occurredAt: { gte: input.from, lt: input.to } }), - ...(input.instrumentUid - ? { instrumentUid: input.instrumentUid } - : {}), - ...(input.categories.length - ? { category: { in: input.categories } } - : {}), - ...(after - ? { + } : + { occurredAt: { gte: input.from, lt: input.to } }), + ...(input.instrumentUid ? + { instrumentUid: input.instrumentUid } : + {}), + ...(input.categories.length ? + { category: { in: input.categories } } : + {}), + ...(after ? + { OR: [ { occurredAt: { lt: new Date(after.at) } }, { @@ -189,8 +194,8 @@ export class BrokerageOperationsRepository { brokerOperationId: { gt: after.id }, }, ], - } - : {}), + } : + {}), }, orderBy: [ { occurredAt: 'desc' }, @@ -247,6 +252,7 @@ export class BrokerageOperationsRepository { const scopeKey = 'operations:' + createHash('sha256') + // 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. .update(JSON.stringify([...new Set(intervals.map(intervalKey))].sort())) .digest('hex'); return this.prisma.$transaction(async (tx) => { @@ -271,14 +277,15 @@ export class BrokerageOperationsRepository { (account) => account.accountId === interval.accountId, ), ) - ) + ) { throw new NotFoundException(); + } if (priority > 1) { for (const account of accounts.filter((account) => intervals.some( (interval) => interval.accountId === account.accountId, ), - )) + )) { await tx.tbankAccount.update({ where: { userId_revision_accountId: { @@ -289,13 +296,14 @@ export class BrokerageOperationsRepository { }, data: { historyFrom: new Date( - account.openedOn - ? `${account.openedOn}T00:00:00Z` - : '1970-01-01T00:00:00Z', + account.openedOn ? + `${account.openedOn}T00:00:00Z` : + '1970-01-01T00:00:00Z', ), historyBoundary: account.openedOn ? 'known' : 'unknown', }, }); + } } const coverage = await tx.tbankOperationCoverage.findMany({ where: { @@ -366,42 +374,42 @@ export class BrokerageOperationsRepository { ) { const pending = previousIntent?.intervals.map(decodeInterval) ?? []; const additions = - previous.scopeKey === scopeKey - ? [] - : uncoveredOperationIntervals(intervals, pending); + previous.scopeKey === scopeKey ? + [] : + uncoveredOperationIntervals(intervals, pending); const work = await tx.tbankSyncWork.update({ where: { id: previous.id }, data: { intent: { atomic: previousIntent?.atomic ?? publicationAtomic, - atomicIntervals: previousIntent?.atomic - ? [ + atomicIntervals: previousIntent?.atomic ? + [ ...new Set([ ...(previousIntent.atomicIntervals ?? previousIntent.intervals), ...additions.map(intervalKey), ]), - ] - : [], + ] : + [], screenIntervals: [ ...new Set([ ...(previousIntent?.screenIntervals ?? - (previous.priority === 1 - ? (previousIntent?.intervals ?? []) - : [])), - ...(priority === 1 - ? [...pending, ...additions] + (previous.priority === 1 ? + (previousIntent?.intervals ?? []) : + [])), + ...(priority === 1 ? + [...pending, ...additions] .filter((item) => intervals.some((target) => operationIntervalsOverlap(item, target), ), ) - .map(intervalKey) - : []), + .map(intervalKey) : + []), ]), ], - intervals: (priority < previous.priority - ? [ + intervals: (priority < previous.priority ? + [ ...pending.filter((item) => intervals.some((target) => operationIntervalsOverlap(item, target), @@ -414,8 +422,8 @@ export class BrokerageOperationsRepository { operationIntervalsOverlap(item, target), ), ), - ] - : [...pending, ...additions] + ] : + [...pending, ...additions] ).map(intervalKey), }, priority: Math.min(priority, previous.priority), @@ -427,9 +435,9 @@ export class BrokerageOperationsRepository { previous && previous.state !== 'complete' && previous.state !== 'superseded'; - const retained = recovering - ? (previousIntent?.intervals.map(decodeInterval) ?? []) - : []; + const retained = recovering ? + (previousIntent?.intervals.map(decodeInterval) ?? []) : + []; // A bridge between two active groups must fence both producers before // either can publish a subset of the newly combined result. for (const joined of touching) { @@ -448,26 +456,27 @@ export class BrokerageOperationsRepository { }); await tx.tbankSyncStage.deleteMany({ where: { workId: joined.id } }); } - if (previous) + if (previous) { await tx.tbankSyncStage.deleteMany({ where: { workId: previous.id } }); + } const targets = - recovering && !upgradeGroup && touching.length <= 1 - ? previous?.scopeKey === scopeKey - ? retained - : [...retained, ...uncoveredOperationIntervals(intervals, retained)] - : operationIntervals([...retained, ...intervals]); + recovering && !upgradeGroup && touching.length <= 1 ? + previous?.scopeKey === scopeKey ? + retained : + [...retained, ...uncoveredOperationIntervals(intervals, retained)] : + operationIntervals([...retained, ...intervals]); const atomicTargets = [ ...(publicationAtomic ? intervals : []), - ...(recovering && previousIntent?.atomic - ? (previousIntent.atomicIntervals ?? previousIntent.intervals).map( + ...(recovering && previousIntent?.atomic ? + (previousIntent.atomicIntervals ?? previousIntent.intervals).map( decodeInterval, - ) - : []), + ) : + []), ...touching.flatMap((work) => { const joined = work.intent as OperationsIntent; - return joined.atomic - ? (joined.atomicIntervals ?? joined.intervals).map(decodeInterval) - : []; + return joined.atomic ? + (joined.atomicIntervals ?? joined.intervals).map(decodeInterval) : + []; }), ]; const intent = { @@ -486,14 +495,14 @@ export class BrokerageOperationsRepository { intervals.some((incoming) => operationIntervalsOverlap(target, incoming), )) || - ( - previousIntent?.screenIntervals ?? - (previous?.priority === 1 - ? (previousIntent?.intervals ?? []) - : []) - ).some((key) => - operationIntervalsOverlap(target, decodeInterval(key)), - ), + ( + previousIntent?.screenIntervals ?? + (previous?.priority === 1 ? + (previousIntent?.intervals ?? []) : + []) + ).some((key) => + operationIntervalsOverlap(target, decodeInterval(key)), + ), ) .map(intervalKey), atomic: atomicTargets.length > 0, @@ -545,20 +554,20 @@ export class BrokerageOperationsRepository { marker.payload as PageProgress, ]), ); - const group = intent.atomic - ? (intent.atomicIntervals ?? intent.intervals) - : intent.intervals; + const group = intent.atomic ? + (intent.atomicIntervals ?? intent.intervals) : + intent.intervals; const nextKey = group.find((key) => !progress.get(key)?.complete); return { priority: work.priority, atomic: intent.atomic, intervals: group.map(decodeInterval), - next: nextKey - ? { + next: nextKey ? + { interval: decodeInterval(nextKey), cursor: progress.get(nextKey)?.cursor || undefined, - } - : null, + } : + null, }; } @@ -582,8 +591,9 @@ export class BrokerageOperationsRepository { updatedAt: true, }, }); - if (!work || work.state !== 'superseded') + if (!work || work.state !== 'superseded') { return work ? { ...work, id } : null; + } target = (work.progress as { successorId: string }).successorId; } throw new Error('Cyclic operations work successor'); @@ -599,7 +609,7 @@ export class BrokerageOperationsRepository { errorCode: invalidToken ? 'INVALID_TBANK_TOKEN' : 'TBANK_UNAVAILABLE', }, }); - if (invalidToken) + if (invalidToken) { await tx.tbankConnection.update({ where: { userId_revision: { @@ -609,6 +619,7 @@ export class BrokerageOperationsRepository { }, data: { invalidToken: true }, }); + } return true; }); } @@ -636,34 +647,40 @@ export class BrokerageOperationsRepository { }, }); const progress = marker?.payload as PageProgress | undefined; - if (progress?.complete || (progress?.cursor ?? '') !== (cursor ?? '')) + if (progress?.complete || (progress?.cursor ?? '') !== (cursor ?? '')) { throw new Error('Unexpected operations page'); - if (response.hasNext === undefined) + } + if (response.hasNext === undefined) { throw new Error('Missing operations pagination state'); + } const nextCursor = response.nextCursor ?? ''; if ( response.hasNext && (!nextCursor || nextCursor === cursor || progress?.seen.includes(nextCursor)) - ) + ) { throw new Error('Invalid operations continuation'); + } for (const operation of response.items ?? []) { - if (!operation.id?.trim()) + if (!operation.id?.trim()) { throw new Error('Missing broker operation identity'); + } const occurredAt = operationTimestamp(operation.date); if ( operation.date?.seconds === undefined || !Number.isFinite(occurredAt.valueOf()) - ) + ) { throw new Error('Invalid operation timestamp'); + } if ( occurredAt < interval.from || occurredAt >= interval.to || (interval.instrumentUid && operation.instrumentUid !== interval.instrumentUid) - ) + ) { continue; + } const payload = JSON.parse( JSON.stringify({ ...operation, cursor: undefined }), ) as Prisma.InputJsonObject; @@ -672,10 +689,12 @@ export class BrokerageOperationsRepository { const previous = await tx.tbankSyncStage.findUnique({ where: { workId_attemptId_sourceKey_itemKey: identity }, }); - if (previous && !isDeepStrictEqual(previous.payload, payload)) + if (previous && !isDeepStrictEqual(previous.payload, payload)) { throw new Error('Conflicting broker operation identity'); - if (!previous) + } + if (!previous) { await tx.tbankSyncStage.create({ data: { ...identity, payload } }); + } } const payload: PageProgress = { complete: !response.hasNext, @@ -712,8 +731,9 @@ export class BrokerageOperationsRepository { fetchedAt: Date, advanceWork = false, ): Promise { - if (!intervals.length) + if (!intervals.length) { throw new Error('Empty operations publication group'); + } return this.prisma.$transaction( async (tx) => { if (!(await claim(tx, attempt))) return false; @@ -734,8 +754,9 @@ export class BrokerageOperationsRepository { }, }, }); - if (!(marker?.payload as PageProgress | undefined)?.complete) + if (!(marker?.payload as PageProgress | undefined)?.complete) { throw new Error('Incomplete operations publication group'); + } const staged = await tx.tbankSyncStage.findMany({ where: { ...stage, itemKey: { not: progressItem } }, }); @@ -745,10 +766,11 @@ export class BrokerageOperationsRepository { if ( previous !== undefined && !isDeepStrictEqual(previous, row.payload) - ) + ) { throw new Error( 'Conflicting broker operation identity across intervals', ); + } groupRows.set(identity, row.payload); } const rows = staged.filter((row) => { @@ -764,16 +786,16 @@ export class BrokerageOperationsRepository { where: { ...scope, occurredAt: { gte: interval.from, lt: interval.to }, - ...(interval.instrumentUid - ? { instrumentUid: interval.instrumentUid } - : {}), + ...(interval.instrumentUid ? + { instrumentUid: interval.instrumentUid } : + {}), }, }); const incoming = new Set(rows.map((row) => row.itemKey)); const removed = existing.filter( (row) => !incoming.has(row.brokerOperationId), ); - for (const row of removed) + for (const row of removed) { await tx.tbankOperation.delete({ where: { userId_revision_accountId_brokerOperationId: { @@ -782,6 +804,7 @@ export class BrokerageOperationsRepository { }, }, }); + } if (removed.length) changedAccounts.add(interval.accountId); for (const row of rows) { const operation = row.payload as TbankOperationItem; @@ -789,8 +812,9 @@ export class BrokerageOperationsRepository { const previous = await tx.tbankOperation.findUnique({ where: { userId_revision_accountId_brokerOperationId: identity }, }); - if (previous && isDeepStrictEqual(previous.payload, operation)) + if (previous && isDeepStrictEqual(previous.payload, operation)) { continue; + } const data = { occurredAt: operationTimestamp(operation.date), instrumentUid: operation.instrumentUid ?? null, @@ -818,7 +842,7 @@ export class BrokerageOperationsRepository { update: { fetchedAt, asOf: interval.to }, }); } - for (const accountId of changedAccounts) + for (const accountId of changedAccounts) { await tx.tbankAccount.update({ where: { userId_revision_accountId: { @@ -829,7 +853,8 @@ export class BrokerageOperationsRepository { }, data: { operationsRevision: { increment: 1 } }, }); - for (const interval of intervals) + } + for (const interval of intervals) { await tx.tbankSyncStage.deleteMany({ where: { workId: attempt.id, @@ -837,6 +862,7 @@ export class BrokerageOperationsRepository { sourceKey: intervalKey(interval), }, }); + } if (advanceWork) { const work = await tx.tbankSyncWork.findUniqueOrThrow({ where: { id: attempt.id }, @@ -867,9 +893,9 @@ export class BrokerageOperationsRepository { intent.screenIntervals ?? (work.priority === 1 ? intent.intervals : []) ).includes(key), - ) - ? 1 - : 2, + ) ? + 1 : + 2, progress: { remainingIntervals: remaining.length }, }, }); @@ -882,8 +908,10 @@ export class BrokerageOperationsRepository { } function intervalKey(interval: OperationInterval): string { - if (!(interval.from < interval.to)) + // eslint-disable-next-line sonarjs/no-inverted-boolean-check -- Negation also rejects Invalid Date; >= would silently accept NaN. + if (!(interval.from < interval.to)) { throw new Error('Invalid operations interval'); + } return JSON.stringify([ interval.accountId, interval.from.toISOString(), diff --git a/src/modules/investments/operations/operations.api.ts b/src/modules/investments/operations/operations.api.ts index 69bf304..e197d9d 100644 --- a/src/modules/investments/operations/operations.api.ts +++ b/src/modules/investments/operations/operations.api.ts @@ -1,8 +1,8 @@ -// Public cross-module surface. Nest providers are exported by the matching module. -export { BrokerageOperationDto } from './presentation/brokerage-operation.dto'; +export { BrokerageOperationsSyncService } from './application/brokerage-operations-sync.service'; export { BrokerageOperationsService } from './application/brokerage-operations.service'; export type { OperationInterval } from './domain/operations-sync-plan'; +// Public cross-module surface. Nest providers are exported by the matching module. +export { BrokerageOperationDto } from './presentation/brokerage-operation.dto'; export { operationCategories } from './presentation/brokerage-operation.dto'; -export { BrokerageOperationsSyncService } from './application/brokerage-operations-sync.service'; diff --git a/src/modules/investments/operations/operations.module.ts b/src/modules/investments/operations/operations.module.ts index 16f37dc..a91bce8 100644 --- a/src/modules/investments/operations/operations.module.ts +++ b/src/modules/investments/operations/operations.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; + import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { AuthModule } from '../../auth/auth.module'; -import { BrokerageOperationsController } from './presentation/brokerage-operations.controller'; +import { BrokerageOperationsSyncService } from './application/brokerage-operations-sync.service'; import { BrokerageOperationsService } from './application/brokerage-operations.service'; import { BrokerageOperationsRepository } from './infrastructure/brokerage-operations.repository'; -import { BrokerageOperationsSyncService } from './application/brokerage-operations-sync.service'; +import { BrokerageOperationsController } from './presentation/brokerage-operations.controller'; @Module({ imports: [AuthModule, TbankModule], diff --git a/src/modules/investments/operations/presentation/brokerage-operation.dto.ts b/src/modules/investments/operations/presentation/brokerage-operation.dto.ts index 6846422..224bad9 100644 --- a/src/modules/investments/operations/presentation/brokerage-operation.dto.ts +++ b/src/modules/investments/operations/presentation/brokerage-operation.dto.ts @@ -1,7 +1,3 @@ -import { - InvestmentReadQueryDto, - InvestmentResourceMetaDto, -} from '../../investment-resource.dto'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform, Type } from 'class-transformer'; import { @@ -16,9 +12,13 @@ import { MaxLength, Min, } from 'class-validator'; -import { MoneyDto } from '../../../../common/money.dto'; +import { MoneyDto } from '../../../../common/money.dto'; import { AccountReferenceDto } from '../../account-reference.dto'; +import { + InvestmentReadQueryDto, + InvestmentResourceMetaDto, +} from '../../investment-resource.dto'; const MAX_CURSOR_LENGTH = 4096; const MAX_OPERATIONS_PAGE_SIZE = 1000; @@ -102,25 +102,25 @@ export class BrokerageOperationDto { declare description: 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({ type: String, nullable: true }) - declare logoName: string | null; + declare logoName: null | string; @ApiProperty({ type: MoneyDto, nullable: true }) declare payment: MoneyDto | null; @ApiProperty({ type: String, nullable: true }) - declare quantity: string | null; + declare quantity: null | string; @ApiProperty({ type: MoneyDto, nullable: true }) declare price: MoneyDto | null; @@ -140,7 +140,7 @@ export class BrokerageOperationsPageDto { declare items: BrokerageOperationDto[]; @ApiProperty({ type: String, nullable: true }) - declare nextCursor: string | null; + declare nextCursor: null | string; @ApiProperty() declare hasNext: boolean; @@ -152,6 +152,7 @@ export class BrokerageOperationsPageDto { export class BrokerageOperationsResourceDto { @ApiProperty({ type: BrokerageOperationsPageDto, nullable: true }) declare data: BrokerageOperationsPageDto | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } diff --git a/src/modules/investments/operations/presentation/brokerage-operations.controller.ts b/src/modules/investments/operations/presentation/brokerage-operations.controller.ts index 317e4ec..66d1a38 100644 --- a/src/modules/investments/operations/presentation/brokerage-operations.controller.ts +++ b/src/modules/investments/operations/presentation/brokerage-operations.controller.ts @@ -1,13 +1,15 @@ +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 { - BrokerageOperationsResourceDto, - BrokerageOperationsQueryDto, -} from './brokerage-operation.dto'; import { BrokerageOperationsService } from '../application/brokerage-operations.service'; +import { + BrokerageOperationsQueryDto, + BrokerageOperationsResourceDto, +} from './brokerage-operation.dto'; @ApiTags('brokerage-accounts') @ApiProblemResponses() diff --git a/src/modules/investments/portfolio/application/forecast.service.ts b/src/modules/investments/portfolio/application/forecast.service.ts index 2f379d9..389e627 100644 --- a/src/modules/investments/portfolio/application/forecast.service.ts +++ b/src/modules/investments/portfolio/application/forecast.service.ts @@ -1,7 +1,9 @@ import { Injectable } from '@nestjs/common'; + +import type { InvestmentResourceMetaDto } from '../../investment-resource.dto'; + import { tbankSourceNeedsRefresh } from '../../../../integrations/tbank/tbank.api'; import { investmentResourceMeta } from '../../investment-resource.mapper'; -import type { InvestmentResourceMetaDto } from '../../investment-resource.dto'; import { ForecastRepository, type ForecastSourceVersion, @@ -12,16 +14,16 @@ export class ForecastService { constructor(private readonly repository: ForecastRepository) {} async result(input: { - userId: string; - revision: string; - membershipRevision: number; - scopeKey: string; - portfolio: InvestmentResourceMetaDto; - sources: { - version: ForecastSourceVersion | null; - meta: InvestmentResourceMetaDto; - }[]; candidate?: T; + membershipRevision: number; + portfolio: InvestmentResourceMetaDto; + revision: string; + scopeKey: string; + sources: { + meta: InvestmentResourceMetaDto; + version: ForecastSourceVersion | null; + }[]; + userId: string; }) { const dependencies = [ input.portfolio, @@ -31,8 +33,8 @@ export class ForecastService { ...input, portfolioProjection: input.portfolio.dataRevision, portfolios: (input.portfolio.dependencies ?? []).flatMap((dependency) => - dependency.resourceKey.startsWith('portfolio/') - ? [ + dependency.resourceKey.startsWith('portfolio/') ? + [ { accountId: decodeURIComponent( dependency.resourceKey.slice('portfolio/'.length), @@ -41,65 +43,65 @@ export class ForecastService { (dependency as InvestmentResourceMetaDto).dataRevision, ), }, - ] - : [], + ] : + [], ), sources: input.sources.flatMap((source) => source.version ? [source.version] : [], ), timestamps: dependencies.flatMap((dependency) => - dependency.lastSuccessfulAt - ? [ + dependency.lastSuccessfulAt ? + [ { resourceKey: dependency.resourceKey, at: dependency.lastSuccessfulAt, }, - ] - : [], + ] : + [], ), }); const timestamps = stored?.sourceTimestamps as - { resourceKey: string; at: string }[] | undefined; + undefined | { at: string; resourceKey: string }[]; const first = timestamps?.map((entry) => entry.at).sort()[0]; const work = dependencies .map((dependency) => dependency.sync) .find((sync) => sync.state === 'running') ?? - dependencies - .map((dependency) => dependency.sync) - .find((sync) => sync.state === 'failed'); + dependencies + .map((dependency) => dependency.sync) + .find((sync) => sync.state === 'failed'); const payload = stored ? (stored.payload as T) : null; const data = payload && 'positionIsStale' in payload && 'positionAt' in payload && - typeof payload.positionAt === 'string' - ? { + typeof payload.positionAt === 'string' ? + { ...payload, positionIsStale: tbankSourceNeedsRefresh( 'portfolio', new Date(payload.positionAt), new Date(Date.now()), ), - } - : payload; + } : + payload; return { data, meta: investmentResourceMeta({ resourceKey: input.scopeKey, dataRevision: String(stored?.dataRevision ?? 0), - availability: stored - ? 'complete' - : dependencies.some( - (dependency) => dependency.availability !== 'missing', - ) - ? 'partial' - : 'missing', + availability: stored ? + 'complete' : + dependencies.some( + (dependency) => dependency.availability !== 'missing', + ) ? + 'partial' : + 'missing', fetchedAt: first ? new Date(first) : null, freshForMs: - data && 'positionIsStale' in data && data.positionIsStale === true - ? 0 - : Number.POSITIVE_INFINITY, + data && 'positionIsStale' in data && data.positionIsStale === true ? + 0 : + Number.POSITIVE_INFINITY, work: work?.id ? { ...work, id: work.id } : null, dependencies, }), diff --git a/src/modules/investments/portfolio/application/portfolio.service.spec.ts b/src/modules/investments/portfolio/application/portfolio.service.spec.ts index 5c3603b..5df4ec9 100644 --- a/src/modules/investments/portfolio/application/portfolio.service.spec.ts +++ b/src/modules/investments/portfolio/application/portfolio.service.spec.ts @@ -1,10 +1,10 @@ -import { setImmediate as immediate } from 'node:timers/promises'; import { ServiceUnavailableException } from '@nestjs/common'; +import { setImmediate as immediate } from 'node:timers/promises'; jest.mock('../../../../prisma/prisma.service', () => ({ PrismaService: class {}, })); -import { PortfolioService } from './portfolio.service'; import { PortfolioDto } from '../presentation/portfolio.dto'; +import { PortfolioService } from './portfolio.service'; describe('persistent portfolio projection', () => { const access = { local: jest.fn(), tokenForAttempt: jest.fn() }; @@ -103,8 +103,9 @@ describe('persistent portfolio projection', () => { let index = 0; index < 10 && !repository.publish.mock.calls.length; index += 1 - ) + ) { await immediate(); + } const snapshot = ( repository.publish.mock.calls as [unknown, string, PortfolioDto][] )[0][2]; diff --git a/src/modules/investments/portfolio/application/portfolio.service.ts b/src/modules/investments/portfolio/application/portfolio.service.ts index a0aeb79..faad7a6 100644 --- a/src/modules/investments/portfolio/application/portfolio.service.ts +++ b/src/modules/investments/portfolio/application/portfolio.service.ts @@ -1,17 +1,10 @@ -import { CandleService } from '../../instruments/instruments.api'; -import { randomUUID } from 'node:crypto'; -import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; -import { investmentResourceMeta } from '../../investment-resource.mapper'; -import { - PortfolioRepository, - portfolioFreshForMs, - portfolioWorkKey, -} from '../infrastructure/portfolio.repository'; import { Injectable, - ServiceUnavailableException, NotFoundException, + ServiceUnavailableException, } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; + import { add, decimalFromString, @@ -21,22 +14,30 @@ import { subtract, } from '../../../../common/decimal'; import { RequestContextService } from '../../../../common/logging/request-context.service'; +import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; import { MoneyDto } from '../../../../common/money.dto'; +import { decimal } from '../../../../integrations/tbank/decimal'; import { InstrumentMetadataService, - metadataKey, - type TbankInstrument, type MetadataIdentity, + metadataKey, TbankAccessService, TbankClient, - TbankMoney, + type TbankInstrument, TbankMembershipService, + TbankMoney, tbankSourceTtlMs, } from '../../../../integrations/tbank/tbank.api'; -import { decimal } from '../../../../integrations/tbank/decimal'; +import { CandleService } from '../../instruments/instruments.api'; +import { investmentResourceMeta } from '../../investment-resource.mapper'; import { portfolioStructure } from '../domain/portfolio-structure'; -import { totalsByCurrency } from '../infrastructure/tbank-portfolio.mapper'; import { sumMoneyByCurrency } from '../domain/portfolio-totals'; +import { + portfolioFreshForMs, + PortfolioRepository, + portfolioWorkKey, +} from '../infrastructure/portfolio.repository'; +import { totalsByCurrency } from '../infrastructure/tbank-portfolio.mapper'; import { PortfolioDto, PortfolioPositionDto, @@ -80,7 +81,7 @@ export class PortfolioService { userId, connection.revision, ); - if (discovery) + if (discovery) { void discovery.done .then(async () => { const current = await this.access.local(userId, accountId); @@ -88,12 +89,13 @@ export class PortfolioService { for (const account of current.accounts.filter( (account) => accountId === 'all' || account.accountId === accountId, - )) + )) { await this.queueSnapshot( userId, current.revision, account.accountId, ); + } }) .catch(() => this.logger.event( @@ -102,22 +104,25 @@ export class PortfolioService { { userId }, ), ); + } for (const account of (discovery ? [] : connection.accounts).filter( (account) => accountId === 'all' || account.accountId === accountId, - )) + )) { await this.queueSnapshot( userId, connection.revision, account.accountId, ); + } } const saved = await this.repository.read(userId, connection.revision); if ( saved?.membershipAt && accountId !== 'all' && !saved.accounts.some((account) => account.accountId === accountId) - ) + ) { throw new NotFoundException(); + } if (!observe) { const identities = new Map(); for (const account of (saved?.accounts ?? []).filter( @@ -125,17 +130,20 @@ export class PortfolioService { )) { const snapshot = account.portfolio?.payload as unknown as PortfolioDto | undefined; - for (const position of snapshot?.positions ?? []) + for (const position of snapshot?.positions ?? []) { identities.set(metadataKey(position), position); + } } const savedMetadata = await this.metadata.savedMany([ ...identities.values(), ]); - for (const identity of identities.values()) - if (!savedMetadata.get(metadataKey(identity))?.fresh) + for (const identity of identities.values()) { + if (!savedMetadata.get(metadataKey(identity))?.fresh) { await this.metadata.request(userId, connection.revision, identity); + } + } } - if (!observe) + if (!observe) { void this.candles .prefetch( userId, @@ -156,6 +164,7 @@ export class PortfolioService { .catch(() => this.logger.event('warn', 'portfolio.candle-prefetch-unavailable'), ); + } const membershipMeta = investmentResourceMeta({ resourceKey: 'accounts/membership', dataRevision: String(saved?.membershipRevision ?? 0), @@ -175,15 +184,15 @@ export class PortfolioService { name: account.name, type: account.type, openedOn: account.openedOn, - snapshot: account.portfolio - ? { + snapshot: account.portfolio ? + { ...(account.portfolio.payload as unknown as PortfolioDto), lastSuccessfulAt: account.portfolio.fetchedAt.toISOString(), isStale: Date.now() - account.portfolio.fetchedAt.valueOf() >= portfolioFreshForMs, - } - : null, + } : + null, meta: investmentResourceMeta({ resourceKey: `portfolio/${encodeURIComponent(account.accountId)}`, dataRevision: String(account.portfolio?.dataRevision ?? 0), @@ -209,8 +218,8 @@ export class PortfolioService { ); const metadataDependencies = [...metadata.entries()].flatMap( ([key, source]) => - source.complete && source.data - ? [ + source.complete && source.data ? + [ investmentResourceMeta({ resourceKey: `instrument/metadata/${key}`, dataRevision: source.dataRevision, @@ -219,20 +228,20 @@ export class PortfolioService { freshForMs: tbankSourceTtlMs.metadata, work: null, }), - ] - : [], + ] : + [], ); const portfolios = saved.accounts.flatMap((account) => - account.snapshot - ? [ + account.snapshot ? + [ { ...account.snapshot, positions: account.snapshot.positions.map((position) => { const shared = metadata.get(metadataKey(position))?.data; return { ...position, - ...(shared - ? { + ...(shared ? + { displayName: shared.name?.trim() || shared.ticker?.trim() || @@ -241,28 +250,29 @@ export class PortfolioService { isin: shared.isin?.trim() || position.isin, logoName: shared.brand?.logoName?.trim() || position.logoName, - } - : {}), - ...(accountId === 'all' - ? { account: { id: account.id, name: account.name } } - : {}), + } : + {}), + ...(accountId === 'all' ? + { account: { id: account.id, name: account.name } } : + {}), }; }), }, - ] - : [], + ] : + [], ); const lastSuccessfulAt = + // 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. portfolios.map((portfolio) => portfolio.lastSuccessfulAt).sort()[0] ?? saved.membership.lastSuccessfulAt; const data = - missing || !lastSuccessfulAt - ? null - : { + missing || !lastSuccessfulAt ? + null : + { lastSuccessfulAt, - isStale: portfolios.length - ? portfolios.some((portfolio) => portfolio.isStale) - : saved.membership.freshness !== 'fresh', + isStale: portfolios.length ? + portfolios.some((portfolio) => portfolio.isStale) : + saved.membership.freshness !== 'fresh', totalsByCurrency: sumMoneyByCurrency( portfolios.flatMap((portfolio) => portfolio.totalsByCurrency), ), @@ -279,10 +289,10 @@ export class PortfolioService { saved.accounts .map((account) => account.meta.sync) .find((work) => work.state === 'running') ?? - saved.accounts - .map((account) => account.meta.sync) - .find((work) => work.state === 'failed') ?? - saved.membership.sync; + saved.accounts + .map((account) => account.meta.sync) + .find((work) => work.state === 'failed') ?? + saved.membership.sync; return { data, meta: investmentResourceMeta({ @@ -294,15 +304,15 @@ export class PortfolioService { ]), metadataDependencies.map((dependency) => dependency.dataRevision), ]), - availability: missing - ? portfolios.length - ? 'partial' - : 'missing' - : 'complete', + availability: missing ? + portfolios.length ? + 'partial' : + 'missing' : + 'complete', fetchedAt: lastSuccessfulAt ? new Date(lastSuccessfulAt) : null, - freshForMs: portfolios.length - ? portfolioFreshForMs - : membershipFreshForMs, + freshForMs: portfolios.length ? + portfolioFreshForMs : + membershipFreshForMs, work: work.id ? { ...work, id: work.id } : null, dependencies: [ saved.membership, @@ -333,11 +343,12 @@ export class PortfolioService { force, ); if (!requested) return null; - if (!requested.launch) + if (!requested.launch) { return { id: requested.work.id, done: this.pending.get(requested.work.attemptId) ?? Promise.resolve(), }; + } const { id, attemptId } = requested.work; this.active.add(attemptId); const done = new Promise((resolve) => setImmediate(resolve)) @@ -360,22 +371,24 @@ export class PortfolioService { snapshot, ); if (published) { - for (const position of snapshot.positions) + for (const position of snapshot.positions) { await this.metadata.request(userId, revision, position); - if (!force) + } + if (!force) { await this.candles.prefetch( userId, revision, snapshot.positions, ); + } } } catch (error) { if ( error instanceof Error && error.message === 'INVALID_TBANK_TOKEN' - ) + ) { await this.access.rejectMembershipToken(attempt); - else await this.repository.fail(attempt); + } else await this.repository.fail(attempt); } }) .catch(() => @@ -396,8 +409,9 @@ export class PortfolioService { async get(userId: string, accountId: string): Promise { const result = await this.read(userId, accountId); - if (!result.data) + if (!result.data) { throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }); + } return result.data; } @@ -407,12 +421,12 @@ export class PortfolioService { private async normalize( upstream: { - figi?: string; - instrumentUid?: string; - instrumentType?: string; - quantity?: { units?: string | number; nano?: string | number }; - currentPrice?: TbankMoney; averagePositionPrice?: TbankMoney; + currentPrice?: TbankMoney; + figi?: string; + instrumentType?: string; + instrumentUid?: string; + quantity?: { nano?: number | string; units?: number | string }; }[], money: TbankMoney[], ): Promise { @@ -450,8 +464,8 @@ export class PortfolioService { private position( position: Parameters[0][number], - metadata: TbankInstrument | null, - ): PortfolioPositionDto | null { + metadata: null | TbankInstrument, + ): null | PortfolioPositionDto { const quantity = decimal(position.quantity ?? {}); if (isZero(quantity)) return null; const figi = position.figi?.trim() || null; @@ -462,8 +476,8 @@ export class PortfolioService { const absoluteReturn = currentPrice && averagePurchasePrice && - currentPrice.currency === averagePurchasePrice.currency - ? { + currentPrice.currency === averagePurchasePrice.currency ? + { currency: currentPrice.currency, value: formatDecimal( multiply( @@ -474,8 +488,8 @@ export class PortfolioService { quantity, ), ), - } - : null; + } : + null; return { figi, instrumentUid, @@ -507,8 +521,8 @@ export class PortfolioService { private money(value: TbankMoney | undefined): MoneyDto | null { const currency = value?.currency?.trim().toUpperCase(); - return currency && value - ? { currency, value: formatDecimal(decimal(value)) } - : null; + return currency && value ? + { currency, value: formatDecimal(decimal(value)) } : + null; } } diff --git a/src/modules/investments/portfolio/domain/portfolio-structure.spec.ts b/src/modules/investments/portfolio/domain/portfolio-structure.spec.ts index 83cbe50..4264872 100644 --- a/src/modules/investments/portfolio/domain/portfolio-structure.spec.ts +++ b/src/modules/investments/portfolio/domain/portfolio-structure.spec.ts @@ -1,9 +1,9 @@ -import { portfolioStructure } from './portfolio-structure'; import { PortfolioPositionDto } from '../presentation/portfolio.dto'; +import { portfolioStructure } from './portfolio-structure'; const position = ( type: string, - value: string | null, + value: null | string, currency = 'RUB', ): PortfolioPositionDto => ({ type, diff --git a/src/modules/investments/portfolio/domain/portfolio-structure.ts b/src/modules/investments/portfolio/domain/portfolio-structure.ts index 514f167..c6e749d 100644 --- a/src/modules/investments/portfolio/domain/portfolio-structure.ts +++ b/src/modules/investments/portfolio/domain/portfolio-structure.ts @@ -1,11 +1,11 @@ +import type { Money } from '../../../../common/money.types'; + import { add, decimalFromString, formatDecimal, } from '../../../../common/decimal'; -import type { Money } from '../../../../common/money.types'; - const SHARE_PERCENT_DECIMAL_PLACES = 6; const PERCENT_SCALED_FACTOR = 100_000_000n; @@ -13,14 +13,14 @@ const categoryTypes = ['STOCK', 'BOND', 'OTHER', 'CASH'] as const; // Build from positions and cash, never from totals (which already include cash). export function portfolioStructure( - positions: { type: string; currentValue: Money | null }[], + positions: { currentValue: Money | null; type: string }[], cash: Money[], ) { const currencies = new Map< string, { - values: ReturnType[]; hasNegativeValues: boolean; + values: ReturnType[]; } >(); const unvalued = new Map(); @@ -31,14 +31,15 @@ export function portfolioStructure( }; const index = categoryTypes.findIndex((category) => category === type); const value = decimalFromString(money.value); + // eslint-disable-next-line security/detect-object-injection -- The numeric index belongs to the fixed portfolio category array. group.values[index] = add(group.values[index], value); group.hasNegativeValues ||= value.value < 0n; currencies.set(money.currency, group); }; for (const position of positions) { - const type = ['STOCK', 'BOND'].includes(position.type) - ? position.type - : 'OTHER'; + const type = ['BOND', 'STOCK'].includes(position.type) ? + position.type : + 'OTHER'; if (position.currentValue) include(type, position.currentValue); else unvalued.set(type, (unvalued.get(type) ?? 0) + 1); } @@ -53,21 +54,22 @@ export function portfolioStructure( const canCalculateShares = !unvalued.size && !group.hasNegativeValues && total.value > 0n; const categories = group.values.map((value, index) => ({ + // eslint-disable-next-line security/detect-object-injection -- The numeric index belongs to the fixed portfolio category array. type: categoryTypes[index], value: formatDecimal(value), // Rounded to six decimal places using integer arithmetic. Only the // resulting dimensionless percentage is passed to chart geometry. - sharePercent: canCalculateShares - ? formatDecimal({ + sharePercent: canCalculateShares ? + formatDecimal({ value: (value.value * 10n ** BigInt(total.scale - value.scale) * PERCENT_SCALED_FACTOR + total.value / 2n) / - total.value, + total.value, scale: SHARE_PERCENT_DECIMAL_PLACES, - }) - : null, + }) : + null, })); return { currency, diff --git a/src/modules/investments/portfolio/domain/portfolio-totals.ts b/src/modules/investments/portfolio/domain/portfolio-totals.ts index e38548c..da81680 100644 --- a/src/modules/investments/portfolio/domain/portfolio-totals.ts +++ b/src/modules/investments/portfolio/domain/portfolio-totals.ts @@ -1,9 +1,10 @@ +import type { Money } from '../../../../common/money.types'; + import { add, decimalFromString, formatDecimal, } from '../../../../common/decimal'; -import type { Money } from '../../../../common/money.types'; export function sumMoneyByCurrency(amounts: Money[]): Money[] { const totals = new Map>(); diff --git a/src/modules/investments/portfolio/infrastructure/forecast.repository.ts b/src/modules/investments/portfolio/infrastructure/forecast.repository.ts index ae95b21..714192b 100644 --- a/src/modules/investments/portfolio/infrastructure/forecast.repository.ts +++ b/src/modules/investments/portfolio/infrastructure/forecast.repository.ts @@ -1,15 +1,17 @@ import { Injectable } from '@nestjs/common'; import { isDeepStrictEqual } from 'node:util'; + import type { Prisma } from '../../../../../generated/prisma/client'; + import { PrismaService } from '../../../../prisma/prisma.service'; export type ForecastSourceVersion = { - instrumentUid: string; - source: string; - kind: string; - rangeKey: string; dataRevision: number; fetchedAt: Date; + instrumentUid: string; + kind: string; + rangeKey: string; + source: string; }; const forecastSourceReadBatchSize = 100; @@ -18,15 +20,15 @@ export class ForecastRepository { constructor(private readonly prisma: PrismaService) {} resolve(input: { - userId: string; - revision: string; - membershipRevision: number; - scopeKey: string; candidate?: object; + membershipRevision: number; portfolioProjection: string; portfolios: { accountId: string; dataRevision: number }[]; + revision: string; + scopeKey: string; sources: ForecastSourceVersion[]; - timestamps: { resourceKey: string; at: string }[]; + timestamps: { at: string; resourceKey: string }[]; + userId: string; }) { return this.prisma.$transaction(async (tx) => { const owner = { userId: input.userId, revision: input.revision }; @@ -54,9 +56,9 @@ export class ForecastRepository { where: { userId_revision_scopeKey: key }, }); const fallback = - previous?.membershipRevision === input.membershipRevision - ? previous - : null; + previous?.membershipRevision === input.membershipRevision ? + previous : + null; if (!input.candidate) return fallback; const portfolios = await tx.tbankPortfolio.findMany({ where: { @@ -74,15 +76,16 @@ export class ForecastRepository { row.dataRevision === expected.dataRevision, ), ) - ) + ) { return fallback; + } // Four identity parameters per source: stay below SQLite's parameter limit. const sources: ForecastSourceVersion[] = []; for ( let offset = 0; offset < input.sources.length; offset += forecastSourceReadBatchSize - ) + ) { sources.push( ...(await tx.tbankInstrumentSource.findMany({ where: { @@ -106,6 +109,7 @@ export class ForecastRepository { }, })), ); + } if ( input.sources.some( (expected) => @@ -119,8 +123,9 @@ export class ForecastRepository { row.fetchedAt.valueOf() === expected.fetchedAt.valueOf(), ), ) - ) + ) { return fallback; + } const revisions = JSON.parse( JSON.stringify({ portfolioProjection: input.portfolioProjection, @@ -133,8 +138,9 @@ export class ForecastRepository { fallback && isDeepStrictEqual(fallback.dependencyRevisions, revisions) && isDeepStrictEqual(fallback.sourceTimestamps, timestamps) - ) + ) { return fallback; + } const data = { membershipRevision: input.membershipRevision, payload: JSON.parse( diff --git a/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts b/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts index b7ffe04..8144fd0 100644 --- a/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts +++ b/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts @@ -1,18 +1,20 @@ -import { - tbankSourceTtlMs, - tbankSourceNeedsRefresh, -} from '../../../../integrations/tbank/tbank.api'; import { Injectable } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; -import { PrismaService } from '../../../../prisma/prisma.service'; + import type { Prisma } from '../../../../../generated/prisma/client'; import type { PortfolioDto } from '../presentation/portfolio.dto'; +import { + tbankSourceNeedsRefresh, + tbankSourceTtlMs, +} from '../../../../integrations/tbank/tbank.api'; +import { PrismaService } from '../../../../prisma/prisma.service'; + type PortfolioAttempt = { - id: string; attemptId: string; - userId: string; + id: string; revision: string; + userId: string; }; export const portfolioFreshForMs = tbankSourceTtlMs.portfolio; @@ -71,8 +73,9 @@ export class PortfolioRepository { !force && snapshot && !tbankSourceNeedsRefresh('portfolio', snapshot.fetchedAt, new Date()) - ) + ) { return null; + } const scope = { userId, revision, scopeKey: portfolioWorkKey(accountId) }; const previous = await tx.tbankSyncWork.findUnique({ where: { userId_revision_scopeKey: scope }, @@ -81,8 +84,9 @@ export class PortfolioRepository { previous?.state === 'running' && previous.processRunId === processRunId && active.has(previous.attemptId) - ) + ) { return { work: previous, launch: false }; + } const data = { state: 'running', attemptId: randomUUID(), diff --git a/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts b/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts index fd01dc4..f4ae5b6 100644 --- a/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts +++ b/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts @@ -1,12 +1,12 @@ +import type { Money } from '../../../../common/money.types'; + import { add, formatDecimal, isZero, multiply, } from '../../../../common/decimal'; -import type { Money } from '../../../../common/money.types'; import { decimal } from '../../../../integrations/tbank/decimal'; - import { TbankPortfolio, TbankPositions, diff --git a/src/modules/investments/portfolio/portfolio.api.ts b/src/modules/investments/portfolio/portfolio.api.ts index eb62409..fc567da 100644 --- a/src/modules/investments/portfolio/portfolio.api.ts +++ b/src/modules/investments/portfolio/portfolio.api.ts @@ -1,8 +1,8 @@ +export { ForecastService } from './application/forecast.service'; +export { PortfolioService } from './application/portfolio.service'; + // Public cross-module surface. Nest providers are exported by the matching module. export { PortfolioDto, PortfolioPositionDto, } from './presentation/portfolio.dto'; -export { PortfolioService } from './application/portfolio.service'; - -export { ForecastService } from './application/forecast.service'; diff --git a/src/modules/investments/portfolio/portfolio.module.ts b/src/modules/investments/portfolio/portfolio.module.ts index 5f77166..6b462b2 100644 --- a/src/modules/investments/portfolio/portfolio.module.ts +++ b/src/modules/investments/portfolio/portfolio.module.ts @@ -1,12 +1,13 @@ -import { ForecastService } from './application/forecast.service'; -import { ForecastRepository } from './infrastructure/forecast.repository'; -import { InstrumentSourcesModule } from '../instruments/instrument-sources.module'; -import { PortfolioRepository } from './infrastructure/portfolio.repository'; import { Module } from '@nestjs/common'; + import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { AuthModule } from '../../auth/auth.module'; -import { PortfolioController } from './presentation/portfolio.controller'; +import { InstrumentSourcesModule } from '../instruments/instrument-sources.module'; +import { ForecastService } from './application/forecast.service'; import { PortfolioService } from './application/portfolio.service'; +import { ForecastRepository } from './infrastructure/forecast.repository'; +import { PortfolioRepository } from './infrastructure/portfolio.repository'; +import { PortfolioController } from './presentation/portfolio.controller'; @Module({ imports: [AuthModule, TbankModule, InstrumentSourcesModule], diff --git a/src/modules/investments/portfolio/presentation/portfolio.controller.ts b/src/modules/investments/portfolio/presentation/portfolio.controller.ts index 942dd6a..573b696 100644 --- a/src/modules/investments/portfolio/presentation/portfolio.controller.ts +++ b/src/modules/investments/portfolio/presentation/portfolio.controller.ts @@ -1,11 +1,13 @@ -import { InvestmentReadQueryDto } from '../../investment-resource.dto'; -import { Controller, Get, Param, Req, Query, UseGuards } from '@nestjs/common'; -import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import type { Request } from 'express'; + +import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; + import { ApiProblemResponses } from '../../../../common/decorators/api-safe-error-response.decorator'; import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api'; -import { PortfolioResourceDto } from './portfolio.dto'; +import { InvestmentReadQueryDto } from '../../investment-resource.dto'; import { PortfolioService } from '../application/portfolio.service'; +import { PortfolioResourceDto } from './portfolio.dto'; @ApiTags('brokerage-accounts') @ApiProblemResponses() diff --git a/src/modules/investments/portfolio/presentation/portfolio.dto.ts b/src/modules/investments/portfolio/presentation/portfolio.dto.ts index a7a8224..a2bcde6 100644 --- a/src/modules/investments/portfolio/presentation/portfolio.dto.ts +++ b/src/modules/investments/portfolio/presentation/portfolio.dto.ts @@ -1,17 +1,18 @@ -import { InvestmentResourceMetaDto } from '../../investment-resource.dto'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + import { MoneyDto } from '../../../../common/money.dto'; import { AccountReferenceDto } from '../../account-reference.dto'; +import { InvestmentResourceMetaDto } from '../../investment-resource.dto'; export class PortfolioPositionDto { @ApiPropertyOptional({ type: String, nullable: true }) - instrumentUid?: string | null; + instrumentUid?: null | string; @ApiPropertyOptional({ type: AccountReferenceDto }) account?: AccountReferenceDto; @ApiProperty({ type: String, nullable: true }) - declare figi: string | null; + declare figi: null | string; @ApiProperty() declare displayName: string; @@ -44,13 +45,13 @@ export class PortfolioPositionDto { declare absoluteReturn: MoneyDto | null; @ApiProperty({ type: String, nullable: true }) - declare isin: string | null; + declare isin: null | string; @ApiProperty({ type: String, nullable: true }) - declare ticker: string | null; + declare ticker: null | string; @ApiProperty({ type: String, nullable: true }) - declare logoName: string | null; + declare logoName: null | string; } export class PortfolioStructureCategoryDto { @@ -66,7 +67,7 @@ export class PortfolioStructureCategoryDto { description: 'Percentage including cash, rounded to six decimal places. Null for incomplete, negative or zero-total compositions.', }) - declare sharePercent: string | null; + declare sharePercent: null | string; } export class PortfolioStructureCurrencyDto { @@ -81,7 +82,7 @@ export class PortfolioStructureCurrencyDto { }) declare hasNegativeValues: boolean; - @ApiProperty({ type: [PortfolioStructureCategoryDto] }) + @ApiProperty({ type: PortfolioStructureCategoryDto, isArray: true }) declare categories: PortfolioStructureCategoryDto[]; } @@ -94,11 +95,11 @@ export class PortfolioUnvaluedCategoryDto { } export class PortfolioStructureDto { - @ApiProperty({ type: [PortfolioStructureCurrencyDto] }) + @ApiProperty({ type: PortfolioStructureCurrencyDto, isArray: true }) declare currencies: PortfolioStructureCurrencyDto[]; @ApiProperty({ - type: [PortfolioUnvaluedCategoryDto], + type: PortfolioUnvaluedCategoryDto, isArray: true, description: 'Positions without current value and known valuation currency. Shares are unavailable across all currencies when nonempty.', }) @@ -127,7 +128,8 @@ export class PortfolioDto { export class PortfolioResourceDto { @ApiProperty({ type: PortfolioDto, nullable: true }) - declare data: PortfolioDto | null; + declare data: null | PortfolioDto; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } diff --git a/src/modules/investments/tbank-connection/tbank-connection.controller.ts b/src/modules/investments/tbank-connection/tbank-connection.controller.ts index 4e7943b..6d94617 100644 --- a/src/modules/investments/tbank-connection/tbank-connection.controller.ts +++ b/src/modules/investments/tbank-connection/tbank-connection.controller.ts @@ -1,3 +1,5 @@ +import type { Request } from 'express'; + import { Body, Controller, @@ -15,7 +17,7 @@ import { 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 { diff --git a/src/modules/investments/tbank-connection/tbank-connection.dto.ts b/src/modules/investments/tbank-connection/tbank-connection.dto.ts index fd0dcb8..e51b955 100644 --- a/src/modules/investments/tbank-connection/tbank-connection.dto.ts +++ b/src/modules/investments/tbank-connection/tbank-connection.dto.ts @@ -16,5 +16,5 @@ export class TbankConnectionStatusDto { declare connected: boolean; @ApiProperty({ type: String, nullable: true, format: 'date-time' }) - declare connectedAt: string | null; + declare connectedAt: null | string; } diff --git a/src/modules/investments/tbank-connection/tbank-connection.module.ts b/src/modules/investments/tbank-connection/tbank-connection.module.ts index 2f6de7f..b393571 100644 --- a/src/modules/investments/tbank-connection/tbank-connection.module.ts +++ b/src/modules/investments/tbank-connection/tbank-connection.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; + import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { AuthModule } from '../../auth/auth.module'; import { TbankConnectionController } from './tbank-connection.controller'; diff --git a/src/modules/investments/tbank-connection/tbank-connection.service.ts b/src/modules/investments/tbank-connection/tbank-connection.service.ts index a048821..9bd15ad 100644 --- a/src/modules/investments/tbank-connection/tbank-connection.service.ts +++ b/src/modules/investments/tbank-connection/tbank-connection.service.ts @@ -1,8 +1,9 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; + import { + supportedTbankAccounts, TbankClient, TbankTokenService, - supportedTbankAccounts, } from '../../../integrations/tbank/tbank.api'; import { PrismaService } from '../../../prisma/prisma.service'; diff --git a/src/modules/investments/tbank-sync/tbank-screen.spec.ts b/src/modules/investments/tbank-sync/tbank-screen.spec.ts index c138b23..40bfccf 100644 --- a/src/modules/investments/tbank-sync/tbank-screen.spec.ts +++ b/src/modules/investments/tbank-sync/tbank-screen.spec.ts @@ -40,8 +40,9 @@ describe('manual sync screen boundary', () => { from: '2026-02-01', to: '2026-01-01', }, - ] as Parameters[0][]) + ] as Parameters[0][]) { expect(() => normalizeScreen(scope)).toThrow(); + } }); it('retains inclusive calendar days, rejecting instant values for date-only owners', () => { const scope = { diff --git a/src/modules/investments/tbank-sync/tbank-screen.ts b/src/modules/investments/tbank-sync/tbank-screen.ts index eb5a363..e26d79f 100644 --- a/src/modules/investments/tbank-sync/tbank-screen.ts +++ b/src/modules/investments/tbank-sync/tbank-screen.ts @@ -1,4 +1,5 @@ import { BadRequestException } from '@nestjs/common'; + import type { InvestmentScreenDto } from './tbank-sync.dto'; // Reject fields from other screens before reserving any source work. @@ -41,68 +42,78 @@ export function normalizeScreen( }); }; if (!allowed[input.screen]) fail(); - for (const key of Object.keys(input)) + for (const key of Object.keys(input)) { if ( key !== 'screen' && input[key as keyof InvestmentScreenDto] !== undefined && !allowed[input.screen].includes(key) - ) + ) { fail(); + } + } const accountId = input.accountId?.trim(); const query = input.query?.trim().toUpperCase(); if ( - ['account', 'operations', 'income', 'events'].includes(input.screen) && + ['account', 'events', 'income', 'operations'].includes(input.screen) && !accountId - ) + ) { fail(); - if ( - ['stocks', 'bonds'].includes(input.screen) && - (!query || query.length < 2) - ) - fail(); - if (['stock', 'bond'].includes(input.screen) && !input.instrumentUid) fail(); - if (input.accountId !== undefined && !accountId) fail(); - if (['income', 'events'].includes(input.screen)) { - 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)) + if ( + ['bonds', 'stocks'].includes(input.screen) && + (!query || query.length < 2) + ) { fail(); + } + if (['bond', 'stock'].includes(input.screen) && !input.instrumentUid) fail(); + if (input.accountId !== undefined && !accountId) fail(); + if (['events', 'income'].includes(input.screen)) { + 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) - ) + ) { fail(); + } // Fixed field order is part of coalescing, irrespective of JSON input order. return { screen: input.screen, ...(accountId ? { accountId } : {}), ...(input.from ? { from: input.from } : {}), ...(input.to ? { to: input.to } : {}), - ...(input.instrumentUid - ? { instrumentUid: input.instrumentUid.toLowerCase() } - : {}), + ...(input.instrumentUid ? + { instrumentUid: input.instrumentUid.toLowerCase() } : + {}), ...(query ? { query } : {}), ...(input.candlePeriod ? { candlePeriod: input.candlePeriod } : {}), - ...(input.candleRange - ? { + ...(input.candleRange ? + { candleRange: { from: new Date(input.candleRange.from).toISOString(), to: new Date(input.candleRange.to).toISOString(), }, - } - : {}), - ...(input.categories - ? { categories: [...new Set(input.categories)].sort() } - : {}), + } : + {}), + ...(input.categories ? + // 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. + { categories: [...new Set(input.categories)].sort() } : + {}), ...(input.limit !== undefined ? { limit: input.limit } : {}), }; } diff --git a/src/modules/investments/tbank-sync/tbank-sync.controller.ts b/src/modules/investments/tbank-sync/tbank-sync.controller.ts index c4985c0..4d9bfcf 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.controller.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.controller.ts @@ -1,6 +1,8 @@ +import type { Request } from 'express'; + import { - Body, BadRequestException, + Body, Controller, Get, HttpCode, @@ -19,17 +21,17 @@ import { 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 { - InvestmentScreenDto, - IncomeInvestmentScreenDto, EventsInvestmentScreenDto, - TimestampInvestmentScreenDto, + IncomeInvestmentScreenDto, + InvestmentScreenDto, investmentScreenSchema, TbankSyncAcceptedDto, TbankSyncStatusDto, + TimestampInvestmentScreenDto, } from './tbank-sync.dto'; import { TbankSyncService } from './tbank-sync.service'; @@ -53,6 +55,7 @@ export class TbankSyncController { @Req() request: Request & { user: AccessPayload }, @Body( new ValidationPipe({ + forbidUnknownValues: true, expectedType: InvestmentScreenDto, transform: true, whitelist: true, @@ -65,6 +68,7 @@ export class TbankSyncController { ) { return this.sync.request(request.user.sub, input as InvestmentScreenDto); } + @Get(':id') @ApiOkResponse({ type: TbankSyncStatusDto }) status( diff --git a/src/modules/investments/tbank-sync/tbank-sync.dto.ts b/src/modules/investments/tbank-sync/tbank-sync.dto.ts index 011c9e7..5f5c467 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.dto.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.dto.ts @@ -20,6 +20,7 @@ import { MinLength, ValidateNested, } from 'class-validator'; + import { InvestmentResourceMetaDto } from '../investment-resource.dto'; import { operationCategories } from '../operations/operations.api'; @@ -46,12 +47,14 @@ export class InvestmentScreenDto { @ApiProperty({ enum: investmentScreens }) @IsIn(investmentScreens) declare screen: (typeof investmentScreens)[number]; + @ApiPropertyOptional() @IsOptional() @IsString() @MinLength(1) @MaxLength(maxAccountIdLength) accountId?: string; + @ApiPropertyOptional({ oneOf: [ { type: 'string', format: 'date' }, @@ -61,6 +64,7 @@ export class InvestmentScreenDto { @IsOptional() @IsDateString({ strict: true }) from?: string; + @ApiPropertyOptional({ oneOf: [ { type: 'string', format: 'date' }, @@ -70,30 +74,36 @@ export class InvestmentScreenDto { @IsOptional() @IsDateString({ strict: true }) to?: string; + @ApiPropertyOptional({ format: 'uuid' }) @IsOptional() @IsUUID() instrumentUid?: string; + @ApiPropertyOptional() @IsOptional() @IsString() @MinLength(2) @MaxLength(maxQueryLength) query?: string; + @ApiPropertyOptional({ enum: ['month', 'halfYear', 'year', 'all'] }) @IsOptional() @IsIn(['month', 'halfYear', 'year', 'all']) - candlePeriod?: 'month' | 'halfYear' | 'year' | 'all'; + candlePeriod?: 'all' | 'halfYear' | 'month' | 'year'; + @ApiPropertyOptional({ type: SyncCandleRangeDto }) @IsOptional() @ValidateNested() @Type(() => SyncCandleRangeDto) candleRange?: SyncCandleRangeDto; + @ApiPropertyOptional({ enum: operationCategories, isArray: true }) @IsOptional() @IsArray() @IsIn(operationCategories, { each: true }) categories?: (typeof operationCategories)[number][]; + @ApiPropertyOptional({ minimum: 1, maximum: maxPageSize }) @IsOptional() @IsInt() @@ -106,6 +116,7 @@ export class IncomeInvestmentScreenDto extends PickType(InvestmentScreenDto, [ 'accountId', ]) { @ApiProperty({ enum: ['income'] }) declare screen: 'income'; + // eslint-disable-next-line @darraghor/nestjs-typed/api-property-matches-property-optionality -- Overrides optional metadata inherited through PickType. @ApiProperty({ required: true }) declare accountId: string; @ApiPropertyOptional({ format: 'date', @@ -113,6 +124,7 @@ export class IncomeInvestmentScreenDto extends PickType(InvestmentScreenDto, [ '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: @@ -125,6 +137,7 @@ export class EventsInvestmentScreenDto extends PickType(InvestmentScreenDto, [ 'accountId', ]) { @ApiProperty({ enum: ['events'] }) declare screen: 'events'; + // eslint-disable-next-line @darraghor/nestjs-typed/api-property-matches-property-optionality -- Overrides optional metadata inherited through PickType. @ApiProperty({ required: true }) declare accountId: string; @ApiPropertyOptional({ format: 'date', @@ -132,6 +145,7 @@ export class EventsInvestmentScreenDto extends PickType(InvestmentScreenDto, [ 'Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC.', }) from?: string; + @ApiPropertyOptional({ format: 'date', description: @@ -157,11 +171,13 @@ export class TimestampInvestmentScreenDto extends OmitType( // 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.', @@ -194,6 +210,7 @@ export class TbankSyncAcceptedDto { @ApiProperty({ format: 'uuid' }) declare id: string; @ApiProperty({ type: Object, ...investmentScreenSchema }) declare scope: InvestmentScreenDto; + @ApiProperty({ format: 'date-time' }) declare now: string; } export class TbankSyncProgressDto { @@ -205,13 +222,17 @@ export class TbankSyncStatusDto { @ApiProperty({ format: 'uuid' }) declare id: string; @ApiProperty({ type: Object, ...investmentScreenSchema, nullable: true }) declare scope: InvestmentScreenDto | null; + @ApiProperty({ format: 'date-time' }) declare now: string; @ApiProperty({ enum: ['running', 'succeeded', 'failed', 'cancelled'] }) - declare state: 'running' | 'succeeded' | 'failed' | 'cancelled'; + declare state: 'cancelled' | 'failed' | 'running' | 'succeeded'; + @ApiProperty({ type: String, nullable: true }) declare errorCode: - string | null; + null | string; + @ApiProperty({ type: TbankSyncProgressDto }) declare progress: TbankSyncProgressDto; + @ApiProperty({ type: InvestmentResourceMetaDto, isArray: true }) declare resources: InvestmentResourceMetaDto[]; } diff --git a/src/modules/investments/tbank-sync/tbank-sync.module.ts b/src/modules/investments/tbank-sync/tbank-sync.module.ts index b963c81..32e5d3d 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.module.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.module.ts @@ -1,16 +1,17 @@ import { Module } from '@nestjs/common'; -import { AuthModule } from '../../auth/auth.module'; + import { TbankModule } from '../../../integrations/tbank/tbank.module'; -import { PortfolioModule } from '../portfolio/portfolio.module'; -import { OperationsModule } from '../operations/operations.module'; -import { InstrumentSourcesModule } from '../instruments/instrument-sources.module'; -import { InstrumentsModule } from '../instruments/instruments.module'; +import { AuthModule } from '../../auth/auth.module'; import { BrokerageAccountsModule } from '../brokerage-accounts/brokerage-accounts.module'; import { IncomeModule } from '../income/income.module'; +import { InstrumentSourcesModule } from '../instruments/instrument-sources.module'; +import { InstrumentsModule } from '../instruments/instruments.module'; +import { OperationsModule } from '../operations/operations.module'; +import { PortfolioModule } from '../portfolio/portfolio.module'; import { UpcomingEventsModule } from '../upcoming-events/upcoming-events.module'; import { TbankSyncController } from './tbank-sync.controller'; -import { TbankSyncService } from './tbank-sync.service'; import { TbankSyncRepository } from './tbank-sync.repository'; +import { TbankSyncService } from './tbank-sync.service'; @Module({ imports: [ AuthModule, diff --git a/src/modules/investments/tbank-sync/tbank-sync.repository.ts b/src/modules/investments/tbank-sync/tbank-sync.repository.ts index 5add3a8..8189dd0 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.repository.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.repository.ts @@ -1,13 +1,15 @@ import { Injectable } from '@nestjs/common'; import { createHash, randomUUID } from 'node:crypto'; + import type { Prisma } from '../../../../generated/prisma/client'; -import { PrismaService } from '../../../prisma/prisma.service'; import type { TbankSyncAttempt } from '../../../integrations/tbank/tbank.api'; import type { InvestmentScreenDto } from './tbank-sync.dto'; -export type ScreenIntent = { scope: InvestmentScreenDto; now: string }; + +import { PrismaService } from '../../../prisma/prisma.service'; +export type ScreenIntent = { now: string; scope: InvestmentScreenDto }; export type ScreenProgress = { - children: string[]; attempts?: Record; + children: string[]; dispatched: boolean; }; @Injectable() @@ -38,8 +40,9 @@ export class TbankSyncRepository { (await tx.tbankSyncWork.count({ where: { userId, revision, id: { in: children }, state: 'running' }, })) - ) + ) { return { work: previous, launch: false }; + } } const data = { attemptId: randomUUID(), @@ -60,6 +63,7 @@ export class TbankSyncRepository { return { work, launch: true }; }); } + read(userId: string, revision: string, id: string) { return this.prisma.$transaction(async (tx) => { const work = await tx.tbankSyncWork.findFirst({ @@ -75,19 +79,20 @@ export class TbankSyncRepository { work, children: children.map((child) => progress.attempts?.[child.id] && - progress.attempts[child.id] !== child.attemptId - ? { ...child, state: 'cancelled', errorCode: null } - : child, + progress.attempts[child.id] !== child.attemptId ? + { ...child, state: 'cancelled', errorCode: null } : + child, ), progress, }; }); } + async progress( attempt: TbankSyncAttempt, children: string[], dispatched: boolean, - errorCode: string | null = null, + errorCode: null | string = null, ) { return this.prisma.$transaction(async (tx) => { const previous = await tx.tbankSyncWork.findFirst({ where: attempt }); diff --git a/src/modules/investments/tbank-sync/tbank-sync.service.ts b/src/modules/investments/tbank-sync/tbank-sync.service.ts index 826830c..52f4668 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.service.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.service.ts @@ -1,43 +1,45 @@ import { - Injectable, ConflictException, + Injectable, NotFoundException, ServiceUnavailableException, } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; + +import type { InvestmentResourceMetaDto } from '../investment-resource.dto'; + import { + InstrumentMetadataService, TbankAccessService, TbankMembershipService, - InstrumentMetadataService, type TbankSyncAttempt, } from '../../../integrations/tbank/tbank.api'; -import { PortfolioService } from '../portfolio/portfolio.api'; +import { BrokerageAccountsService } from '../brokerage-accounts/brokerage-accounts-read.api'; +import { IncomeAnalyticsService } from '../income/income-read.api'; import { - BrokerageOperationsService, - BrokerageOperationsSyncService, - type OperationInterval, -} from '../operations/operations.api'; + BondIncomeService, + StockIncomeService, +} from '../instruments/instrument-income.api'; import { CandleService, CatalogDiscoveryService, CatalogEnrichmentService, } from '../instruments/instruments.api'; -import { - StockIncomeService, - BondIncomeService, -} from '../instruments/instrument-income.api'; -import { BrokerageAccountsService } from '../brokerage-accounts/brokerage-accounts-read.api'; -import { IncomeAnalyticsService } from '../income/income-read.api'; -import { UpcomingEventsService } from '../upcoming-events/upcoming-events-read.api'; -import { planInvestmentScreen } from '../investment-sync-plan'; -import type { InvestmentResourceMetaDto } from '../investment-resource.dto'; -import { - TbankSyncRepository, - type ScreenIntent, -} from './tbank-sync.repository'; -import { InvestmentScreenDto, TbankSyncStatusDto } from './tbank-sync.dto'; -import { normalizeScreen } from './tbank-screen'; import { investmentResourceMeta } from '../investment-resource.mapper'; +import { planInvestmentScreen } from '../investment-sync-plan'; +import { + BrokerageOperationsService, + BrokerageOperationsSyncService, + type OperationInterval, +} from '../operations/operations.api'; +import { PortfolioService } from '../portfolio/portfolio.api'; +import { UpcomingEventsService } from '../upcoming-events/upcoming-events-read.api'; +import { normalizeScreen } from './tbank-screen'; +import { InvestmentScreenDto, TbankSyncStatusDto } from './tbank-sync.dto'; +import { + type ScreenIntent, + TbankSyncRepository, +} from './tbank-sync.repository'; @Injectable() export class TbankSyncService { @@ -88,7 +90,7 @@ export class TbankSyncService { private async dispatch(attempt: TbankSyncAttempt, intent: ScreenIntent) { const children: string[] = []; - const add = async (ids: (string | null | undefined)[]) => { + const add = async (ids: (null | string | undefined)[]) => { children.push(...ids.filter((id): id is string => Boolean(id))); await this.repository.progress(attempt, children, false); }; @@ -107,14 +109,14 @@ export class TbankSyncService { accountScope === 'all' || account.accountId === accountScope, ); if ( - ['accounts', 'account', 'events', 'stock', 'bond'].includes( + ['account', 'accounts', 'bond', 'events', 'stock'].includes( scope.screen, ) ) { for (const account of scope.screen === 'stock' || - scope.screen === 'bond' - ? current.accounts - : selectedAccounts) { + scope.screen === 'bond' ? + current.accounts : + selectedAccounts) { const work = await this.portfolio.request( userId, revision, @@ -127,9 +129,9 @@ export class TbankSyncService { } const saved = await this.portfolio.read( userId, - scope.screen === 'stock' || scope.screen === 'bond' - ? 'all' - : accountScope, + scope.screen === 'stock' || scope.screen === 'bond' ? + 'all' : + accountScope, true, ); const positions = saved.data?.positions ?? []; @@ -137,8 +139,8 @@ export class TbankSyncService { ...new Map( positions.flatMap((position) => position.instrumentUid && - (position.type === 'STOCK' || position.type === 'BOND') - ? [ + (position.type === 'STOCK' || position.type === 'BOND') ? + [ [ position.instrumentUid, { @@ -147,27 +149,27 @@ export class TbankSyncService { historyFrom: null, }, ] as const, - ] - : [], + ] : + [], ), ).values(), ]; const plan = planInvestmentScreen( { ...scope, - candleRange: scope.candleRange - ? { + candleRange: scope.candleRange ? + { from: new Date(scope.candleRange.from), to: new Date(scope.candleRange.to), - } - : undefined, + } : + undefined, }, { accounts: selectedAccounts, instruments: - scope.screen === 'stocks' || scope.screen === 'bonds' - ? [] - : instruments, + scope.screen === 'stocks' || scope.screen === 'bonds' ? + [] : + instruments, membershipKnown: Boolean(current.membershipAt), }, now, @@ -177,20 +179,20 @@ export class TbankSyncService { source.kind === 'operations' && source.accountId && source.from && - source.to - ? [ + source.to ? + [ { accountId: source.accountId, from: source.from, to: source.to, - ...(source.instrumentUid - ? { instrumentUid: source.instrumentUid } - : {}), + ...(source.instrumentUid ? + { instrumentUid: source.instrumentUid } : + {}), }, - ] - : [], + ] : + [], ); - if (intervals.length) + if (intervals.length) { await add([ await this.operations.request( userId, @@ -200,24 +202,27 @@ export class TbankSyncService { revision, ), ]); + } for (const source of plan.sources) { const uid = source.instrumentUid; const type = source.instrumentType === 'BOND' ? 'bond' : 'share'; - if (source.kind === 'catalog') + if (source.kind === 'catalog') { await add( await this.discovery.refresh(userId, revision, { instrumentType: type, query: source.query ?? '', }), ); + } if (!uid) continue; - if (source.kind === 'metadata') + if (source.kind === 'metadata') { await add([ await this.metadata.request(userId, revision, { instrumentUid: uid, }), ]); - if (source.kind === 'quote') + } + if (source.kind === 'quote') { await add( await this.sources.requestMany( userId, @@ -233,7 +238,8 @@ export class TbankSyncService { ], ), ); - if (source.kind === 'company') + } + if (source.kind === 'company') { await add( await this.sources.requestMany( userId, @@ -244,7 +250,8 @@ export class TbankSyncService { ['description', 'fundamentals'], ), ); - if (source.kind === 'metrics') + } + if (source.kind === 'metrics') { await add( await this.sources.requestMany( userId, @@ -255,7 +262,8 @@ export class TbankSyncService { ['coupons', 'yield', 'rating'], ), ); - if (source.kind === 'calendar' && source.from && source.to) + } + if (source.kind === 'calendar' && source.from && source.to) { await add( await this.sources.requestMany( userId, @@ -264,11 +272,11 @@ export class TbankSyncService { [uid], true, [ - type === 'share' - ? 'dividends' - : scope.screen === 'bond' - ? 'coupons' - : 'bond-events', + type === 'share' ? + 'dividends' : + scope.screen === 'bond' ? + 'coupons' : + 'bond-events', ], { from: source.from.toISOString().slice(0, 10), @@ -279,6 +287,7 @@ export class TbankSyncService { }, ), ); + } } if ( (scope.screen === 'stock' || scope.screen === 'bond') && @@ -292,14 +301,14 @@ export class TbankSyncService { uid: scope.instrumentUid, instrumentType: scope.screen === 'bond' ? 'bond' : 'share', period: scope.candlePeriod ?? 'month', - ...(scope.candleRange - ? { + ...(scope.candleRange ? + { range: { from: new Date(scope.candleRange.from), to: new Date(scope.candleRange.to), }, - } - : {}), + } : + {}), }, now, true, @@ -324,15 +333,16 @@ export class TbankSyncService { }); const saved = await this.repository.read(userId, connection.revision, id); if (!saved) throw new NotFoundException(); - if (!saved.work.scopeKey.startsWith('screen:')) + if (!saved.work.scopeKey.startsWith('screen:')) { return this.sourceStatus(userId, saved.work); + } const intent = saved.work.intent as unknown as ScreenIntent; await this.access.local(userId, intent.scope.accountId); const children = await Promise.all( saved.children.map((child) => - child.state === 'superseded' - ? this.operations.observe(userId, child.id) - : Promise.resolve(child), + child.state === 'superseded' ? + this.operations.observe(userId, child.id) : + Promise.resolve(child), ), ); const running = children.some((child) => child.state === 'running'); @@ -342,21 +352,22 @@ export class TbankSyncService { null; const state = saved.work.processRunId !== this.runId && - (!saved.progress.dispatched || running) - ? 'cancelled' - : !saved.progress.dispatched || running - ? 'running' - : failed - ? 'failed' - : children.some((child) => child.state === 'cancelled') - ? 'cancelled' - : 'succeeded'; + (!saved.progress.dispatched || running) ? + 'cancelled' : + !saved.progress.dispatched || running ? + 'running' : + failed ? + 'failed' : + children.some((child) => child.state === 'cancelled') ? + 'cancelled' : + 'succeeded'; const resources = await this.observe(userId, intent.scope); if ( (await this.access.local(userId, intent.scope.accountId)).revision !== connection.revision - ) + ) { throw new NotFoundException(); + } return { id, ...intent, @@ -380,16 +391,16 @@ export class TbankSyncService { const add = async (read: Promise<{ meta: InvestmentResourceMetaDto }>) => { resources.push((await read).meta); }; - if (work.scopeKey === 'membership') + if (work.scopeKey === 'membership') { await add(this.accounts.list(userId, true)); - else if ( + } else if ( input.source === 'portfolio' && typeof input.accountId === 'string' - ) + ) { await add(this.portfolio.read(userId, input.accountId, true)); - else if (input.source === 'catalog') { + } else if (input.source === 'catalog') { const type = input.instrumentType === 'bond' ? 'bond' : 'share'; - if (typeof input.query === 'string') + if (typeof input.query === 'string') { await add( this.discovery.search( userId, @@ -401,14 +412,14 @@ export class TbankSyncService { true, ), ); - else if ( + } else if ( typeof input.uid === 'string' && typeof input.kind === 'string' ) { const range = - typeof input.rangeKey === 'string' && input.rangeKey.startsWith('[') - ? (JSON.parse(input.rangeKey) as string[]) - : null; + typeof input.rangeKey === 'string' && input.rangeKey.startsWith('[') ? + (JSON.parse(input.rangeKey) as string[]) : + null; const sources = await this.sources.readMany( userId, type, @@ -417,9 +428,9 @@ export class TbankSyncService { [ input.kind as Parameters< CatalogEnrichmentService['readMany'] - >[4] extends (infer K)[] | undefined - ? K - : never, + >[4] extends (infer K)[] | undefined ? + K : + never, ], range ? { from: range[0], to: range[1] } : undefined, ); @@ -452,7 +463,7 @@ export class TbankSyncService { { uid: input.uid, instrumentType: input.instrumentType === 'bond' ? 'bond' : 'share', - period: input.period as 'month' | 'halfYear' | 'year' | 'all', + period: input.period as 'all' | 'halfYear' | 'month' | 'year', }, true, ), @@ -475,21 +486,22 @@ export class TbankSyncService { ); } } - const observed = work.scopeKey.startsWith('operations:') - ? await this.operations.observe(userId, work.id) - : work; + const observed = work.scopeKey.startsWith('operations:') ? + await this.operations.observe(userId, work.id) : + work; const state = - observed.state === 'running' - ? observed.updatedAt < this.startedAt - ? 'cancelled' - : 'running' - : observed.state === 'failed' - ? 'failed' - : observed.state === 'cancelled' - ? 'cancelled' - : 'succeeded'; - if ((await this.access.local(userId)).revision !== work.revision) + observed.state === 'running' ? + observed.updatedAt < this.startedAt ? + 'cancelled' : + 'running' : + observed.state === 'failed' ? + 'failed' : + observed.state === 'cancelled' ? + 'cancelled' : + 'succeeded'; + if ((await this.access.local(userId)).revision !== work.revision) { throw new NotFoundException(); + } return { id: work.id, scope: null, @@ -511,14 +523,15 @@ export class TbankSyncService { resources.push((await read).meta); }; const account = scope.accountId ?? 'all'; - if (scope.screen === 'accounts') + if (scope.screen === 'accounts') { await add(this.accounts.list(userId, true)); + } if (scope.screen === 'account') { await add(this.portfolio.read(userId, account, true)); await add(this.income.summary(userId, account, true)); await add(this.events.get(userId, account, { sync: 'observe' })); } - if (['account', 'operations', 'stock', 'bond'].includes(scope.screen)) + if (['account', 'bond', 'operations', 'stock'].includes(scope.screen)) { await add( this.ledger.read(userId, account, { from: scope.from, @@ -529,11 +542,13 @@ export class TbankSyncService { sync: 'observe', }), ); - if (scope.screen === 'income') + } + if (scope.screen === 'income') { await add( this.income.analytics(userId, account, scope.from, scope.to, true), ); - if (scope.screen === 'events') + } + if (scope.screen === 'events') { await add( this.events.get(userId, account, { from: scope.from, @@ -541,9 +556,10 @@ export class TbankSyncService { sync: 'observe', }), ); + } const type = scope.screen === 'bond' || scope.screen === 'bonds' ? 'bond' : 'share'; - if (scope.screen === 'stocks' || scope.screen === 'bonds') + if (scope.screen === 'stocks' || scope.screen === 'bonds') { await add( this.discovery.search( userId, @@ -551,6 +567,7 @@ export class TbankSyncService { true, ), ); + } if ( (scope.screen === 'stock' || scope.screen === 'bond') && scope.instrumentUid @@ -574,27 +591,27 @@ export class TbankSyncService { uid, instrumentType: type, period: scope.candlePeriod ?? 'month', - ...(scope.candleRange - ? { + ...(scope.candleRange ? + { range: { from: new Date(scope.candleRange.from), to: new Date(scope.candleRange.to), }, - } - : {}), + } : + {}), }, true, ), ); await add( - type === 'share' - ? this.stockIncome.readPosition(userId, uid, true) - : this.bondIncome.readPosition(userId, uid, true), + type === 'share' ? + this.stockIncome.readPosition(userId, uid, true) : + this.bondIncome.readPosition(userId, uid, true), ); await add( - type === 'share' - ? this.stockIncome.dividends(userId, uid, true) - : this.bondIncome.coupons(userId, uid, true), + type === 'share' ? + this.stockIncome.dividends(userId, uid, true) : + this.bondIncome.coupons(userId, uid, true), ); } return resources; diff --git a/src/modules/investments/upcoming-events/upcoming-events.controller.ts b/src/modules/investments/upcoming-events/upcoming-events.controller.ts index 2a5d4e4..17bc59a 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.controller.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.controller.ts @@ -1,11 +1,13 @@ +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 { - UpcomingEventsResourceDto, UpcomingEventsQueryDto, + UpcomingEventsResourceDto, } from './upcoming-events.dto'; import { UpcomingEventsService } from './upcoming-events.service'; diff --git a/src/modules/investments/upcoming-events/upcoming-events.dto.ts b/src/modules/investments/upcoming-events/upcoming-events.dto.ts index 55a7d8b..8d8353b 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.dto.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.dto.ts @@ -1,11 +1,12 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsDateString, IsOptional, Matches } from 'class-validator'; + +import { MoneyDto } from '../../../common/money.dto'; +import { AccountReferenceDto } from '../account-reference.dto'; import { InvestmentReadQueryDto, InvestmentResourceMetaDto, } from '../investment-resource.dto'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsDateString, IsOptional, Matches } from 'class-validator'; -import { MoneyDto } from '../../../common/money.dto'; -import { AccountReferenceDto } from '../account-reference.dto'; export class UpcomingEventsQueryDto extends InvestmentReadQueryDto { @ApiPropertyOptional({ @@ -43,10 +44,10 @@ export class UpcomingEventDto { declare dateKind: 'event' | 'registry'; @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() declare ticker: string; @@ -55,10 +56,10 @@ export class UpcomingEventDto { declare displayName: string; @ApiProperty({ type: String, nullable: true }) - declare isin: string | null; + declare isin: null | string; @ApiProperty({ type: String, nullable: true }) - declare logoName: string | null; + declare logoName: null | string; @ApiProperty({ type: MoneyDto, nullable: true }) declare projectedAmount: MoneyDto | null; @@ -80,7 +81,8 @@ export class UpcomingEventsDto { export class UpcomingEventsResourceDto { @ApiProperty({ type: UpcomingEventsDto, nullable: true }) - declare data: UpcomingEventsDto | null; + declare data: null | UpcomingEventsDto; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } diff --git a/src/modules/investments/upcoming-events/upcoming-events.module.ts b/src/modules/investments/upcoming-events/upcoming-events.module.ts index fbd2ad2..ed66431 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.module.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.module.ts @@ -1,8 +1,9 @@ -import { InstrumentSourcesModule } from '../instruments/instrument-sources.module'; -import { PortfolioModule } from '../portfolio/portfolio.module'; import { Module } from '@nestjs/common'; + import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { AuthModule } from '../../auth/auth.module'; +import { InstrumentSourcesModule } from '../instruments/instrument-sources.module'; +import { PortfolioModule } from '../portfolio/portfolio.module'; import { UpcomingEventsController } from './upcoming-events.controller'; import { UpcomingEventsService } from './upcoming-events.service'; diff --git a/src/modules/investments/upcoming-events/upcoming-events.service.spec.ts b/src/modules/investments/upcoming-events/upcoming-events.service.spec.ts index 762017f..207e233 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.service.spec.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.service.spec.ts @@ -1,5 +1,5 @@ -import { dividendEvent, bondEvent } from './upcoming-events.service'; import { decimalFromString } from '../../../common/decimal'; +import { bondEvent, dividendEvent } from './upcoming-events.service'; jest.mock('../../../prisma/prisma.service', () => ({ PrismaService: class {}, })); diff --git a/src/modules/investments/upcoming-events/upcoming-events.service.ts b/src/modules/investments/upcoming-events/upcoming-events.service.ts index b1f2c36..2f6534c 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.service.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.service.ts @@ -1,5 +1,7 @@ import { BadRequestException, Injectable } from '@nestjs/common'; -import { MILLISECONDS_PER_SECOND } from '../../../common/time'; + +import type { MoexBondEvent } from '../../../integrations/moex/moex.api'; + import { add, decimalFromString, @@ -8,21 +10,21 @@ import { multiply, } from '../../../common/decimal'; import { MoneyDto } from '../../../common/money.dto'; -import type { MoexBondEvent } from '../../../integrations/moex/moex.api'; +import { MILLISECONDS_PER_SECOND } from '../../../common/time'; import { decimal } from '../../../integrations/tbank/decimal'; import { - TbankAccessService, InstrumentMetadataService, metadataKey, + TbankAccessService, type TbankDividend, } from '../../../integrations/tbank/tbank.api'; -import { - PortfolioService, - ForecastService, - type PortfolioPositionDto, -} from '../portfolio/portfolio.api'; import { CatalogEnrichmentService } from '../instruments/instruments.api'; import { investmentResourceMeta } from '../investment-resource.mapper'; +import { + ForecastService, + type PortfolioPositionDto, + PortfolioService, +} from '../portfolio/portfolio.api'; import { UpcomingEventDto, UpcomingEventsDto, @@ -74,7 +76,7 @@ export class UpcomingEventsService { metadata.get(metadataKey(position))?.data?.ticker || '', })) - .filter((entry): entry is { uid: string; ticker: string } => + .filter((entry): entry is { ticker: string; uid: string } => Boolean(entry.uid), ); const uids = [...new Set(identities.map((entry) => entry.uid))]; @@ -82,7 +84,7 @@ export class UpcomingEventsService { let offset = 0; offset < uids.length; offset += calendarReadBatchSize - ) + ) { sources.push( ...(await this.sources.readMany( userId, @@ -98,6 +100,7 @@ export class UpcomingEventsService { }, )), ); + } } for (const position of positions) { const generic = metadata.get(metadataKey(position))?.data; @@ -126,7 +129,7 @@ export class UpcomingEventsService { (source) => source.input.uid === uid && source.input.kind === - (type === 'share' ? 'dividends' : 'bond-events'), + (type === 'share' ? 'dividends' : 'bond-events'), ); const instrument = sources.find( @@ -148,15 +151,15 @@ export class UpcomingEventsService { }; const quantity = decimalFromString(position.quantity); const projected = - type === 'share' - ? (calendar.payload?.dividends ?? []) + type === 'share' ? + (calendar.payload?.dividends ?? []) .filter( (event) => event.dividendType?.toLowerCase() !== 'cancelled' && Boolean(event.recordDate), ) - .map((event) => dividendEvent(identity, quantity, event)) - : (calendar.payload?.bondEvents ?? []).map((event) => + .map((event) => dividendEvent(identity, quantity, event)) : + (calendar.payload?.bondEvents ?? []).map((event) => bondEvent(identity, quantity, event), ); events.push( @@ -166,9 +169,9 @@ export class UpcomingEventsService { ) .map((event) => ({ ...event, - ...(accountId === 'all' && position.account - ? { account: position.account } - : {}), + ...(accountId === 'all' && position.account ? + { account: position.account } : + {}), })), ); } @@ -179,14 +182,14 @@ export class UpcomingEventsService { a.type.localeCompare(b.type) || a.ticker.localeCompare(b.ticker), ); - const candidate: UpcomingEventsDto | undefined = ready - ? { + const candidate: undefined | UpcomingEventsDto = ready ? + { accountId, calculatedAt: new Date().toISOString(), events, incomeTotalsByCurrency: incomeTotals(events), - } - : undefined; + } : + undefined; return this.forecasts.result({ userId, revision: connection.revision, @@ -197,6 +200,7 @@ export class UpcomingEventsService { candidate, }); } + getAll(userId: string, query: UpcomingEventsQueryDto = {}) { return this.get(userId, 'all', query); } @@ -205,7 +209,7 @@ export class UpcomingEventsService { export function dividendEvent( metadata: Pick< UpcomingEventDto, - 'figi' | 'instrumentUid' | 'ticker' | 'displayName' | 'isin' | 'logoName' + 'displayName' | 'figi' | 'instrumentUid' | 'isin' | 'logoName' | 'ticker' >, quantity: ReturnType, event: TbankDividend, @@ -217,20 +221,20 @@ export function dividendEvent( .toISOString() .slice(0, 10), dateKind: 'registry', - projectedAmount: event.dividendNet - ? projectedMoney( + projectedAmount: event.dividendNet ? + projectedMoney( quantity, formatDecimal(decimal(event.dividendNet)), event.dividendNet.currency, - ) - : null, + ) : + null, }; } export function bondEvent( metadata: Pick< UpcomingEventDto, - 'figi' | 'instrumentUid' | 'ticker' | 'displayName' | 'isin' | 'logoName' + 'displayName' | 'figi' | 'instrumentUid' | 'isin' | 'logoName' | 'ticker' >, quantity: ReturnType, event: MoexBondEvent, @@ -241,16 +245,16 @@ export function bondEvent( date: event.date, dateKind: 'event', projectedAmount: - 'value' in event - ? projectedMoney(quantity, event.value, event.currency) - : null, + 'value' in event ? + projectedMoney(quantity, event.value, event.currency) : + null, }; } function supported(position: Position): boolean { return ( !isZero(decimalFromString(position.quantity)) && - ['STOCK', 'BOND'].includes(position.type) + ['BOND', 'STOCK'].includes(position.type) ); } @@ -259,8 +263,9 @@ function dateWindow(query: UpcomingEventsQueryDto) { const end = new Date(`${from}T00:00:00.000Z`); end.setUTCDate(end.getUTCDate() + DEFAULT_EVENT_WINDOW_DAYS); const to = query.to ?? end.toISOString().slice(0, 10); - if (from > to) + if (from > to) { throw new BadRequestException({ code: 'INVALID_EVENTS_PERIOD' }); + } return { from, to }; } @@ -280,8 +285,9 @@ function projectedMoney( function incomeTotals(events: UpcomingEventDto[]): MoneyDto[] { const totals = new Map>(); for (const event of events) { - if (!['coupon', 'dividend'].includes(event.type) || !event.projectedAmount) + if (!['coupon', 'dividend'].includes(event.type) || !event.projectedAmount) { continue; + } const current = totals.get(event.projectedAmount.currency) ?? { value: 0n, scale: 0, diff --git a/src/prisma/prisma.module.ts b/src/prisma/prisma.module.ts index 7207426..d30ce68 100644 --- a/src/prisma/prisma.module.ts +++ b/src/prisma/prisma.module.ts @@ -1,4 +1,5 @@ import { Global, Module } from '@nestjs/common'; + import { PrismaService } from './prisma.service'; @Global() diff --git a/src/prisma/prisma.service.ts b/src/prisma/prisma.service.ts index c65572a..6af476c 100644 --- a/src/prisma/prisma.service.ts +++ b/src/prisma/prisma.service.ts @@ -1,6 +1,7 @@ import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'; + import { PrismaClient } from '../../generated/prisma/client'; import { Environment } from '../config/env.validation'; @@ -11,8 +12,7 @@ function getDatabaseUrl(config: ConfigService): string { @Injectable() export class PrismaService extends PrismaClient - implements OnModuleInit, OnModuleDestroy -{ + implements OnModuleDestroy, OnModuleInit { constructor(config: ConfigService) { super({ adapter: new PrismaBetterSqlite3({ diff --git a/test/all-accounts.e2e-spec.ts b/test/all-accounts.e2e-spec.ts index f6cef5b..9bb7cd0 100644 --- a/test/all-accounts.e2e-spec.ts +++ b/test/all-accounts.e2e-spec.ts @@ -1,13 +1,15 @@ +import type { Server } from 'node:http'; + 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 { PrismaService } from '../src/prisma/prisma.service'; -import { TbankClient } from '../src/integrations/tbank/tbank.client'; import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; +import { TbankClient } from '../src/integrations/tbank/tbank.client'; import { configureApp } from '../src/main'; +import { PrismaService } from '../src/prisma/prisma.service'; describe('All accounts HTTP boundary', () => { let app: INestApplication; @@ -78,15 +80,10 @@ describe('All accounts HTTP boundary', () => { .expect(200); expect(response.body).not.toHaveProperty('token'); expect(response.body).not.toHaveProperty('generation'); - if (path === 'portfolio') - expect(response.body).toMatchObject({ - data: { positions: [] }, - meta: { availability: 'complete' }, - }); - else - expect(response.body).toMatchObject({ - meta: { availability: 'complete' }, - }); + expect(response.body).toMatchObject({ + meta: { availability: 'complete' }, + ...(path === 'portfolio' ? { data: { positions: [] } } : {}), + }); }, ); diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index d1ed34f..f73ba25 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -4,13 +4,14 @@ import { Test, TestingModule } from '@nestjs/testing'; import { PinoLogger } from 'nestjs-pino'; import request from 'supertest'; import { App } from 'supertest/types'; + import { AppModule } from '../src/app.module'; import { StructuredLogger } from '../src/common/logging/structured-logger.service'; import { configureApp } from '../src/main'; import { AuthService } from '../src/modules/auth/application/auth.service'; import { PrismaService } from '../src/prisma/prisma.service'; -type Authentication = { user: { email: string }; accessToken: string }; +type Authentication = { accessToken: string; user: { email: string } }; type Problem = { code: string }; describe('Authentication (e2e)', () => { @@ -181,7 +182,7 @@ describe('Authentication (e2e)', () => { ); barrier.mockRestore(); expect(arrived).toBe(2); - expect(responses.map((response) => response.status).sort()).toEqual([ + expect(responses.map((response) => response.status).sort((a, b) => a - b)).toEqual([ 200, 401, ]); const winner = responses.find((response) => response.status === 200); diff --git a/test/bank-cards-durability.cjs b/test/bank-cards-durability.cjs index 23e801a..7fb7f0b 100644 --- a/test/bank-cards-durability.cjs +++ b/test/bank-cards-durability.cjs @@ -1,22 +1,25 @@ const assert = require('node:assert/strict'); const { spawn, execFileSync } = require('node:child_process'); +const { randomUUID } = require('node:crypto'); +const { once } = require('node:events'); const { mkdtempSync, openSync, closeSync, writeFileSync, existsSync, readdirSync, readFileSync } = require('node:fs'); +const { createServer } = require('node:net'); const { tmpdir } = require('node:os'); const { join, resolve } = require('node:path'); -const { once } = require('node:events'); -const { createServer } = require('node:net'); -const { randomUUID } = require('node:crypto'); const repo = resolve(__dirname, '..'); assert.ok(process.argv[2], 'Pass a separately built baseline repository directory'); const baseline = resolve(process.argv[2]); assert.notEqual(baseline, repo, 'Baseline must be a separate historical build'); for (const root of [baseline, repo]) { - assert.ok(existsSync(join(root, 'dist/src/main.js')), `Build first: ${root}`); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds. + assert.ok(existsSync(join(root, 'dist/src/main.js')), `Build first: ${root}`); } -const migrations = root => readdirSync(join(root, 'prisma/migrations')).filter(name => existsSync(join(root, 'prisma/migrations', name, 'migration.sql'))).sort(); -const previous = migrations(baseline), current = migrations(repo); +// eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds. +const migrations = (root) => readdirSync(join(root, 'prisma/migrations')).filter((name) => existsSync(join(root, 'prisma/migrations', name, 'migration.sql'))).sort(); +const current = migrations(repo), previous = migrations(baseline); assert.ok(previous.length > 0 && previous.length < current.length, 'Baseline must precede the tested migrations'); assert.deepEqual(previous, current.slice(0, previous.length), 'Baseline migrations must be an unchanged prefix'); +// eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds. for (const name of previous) assert.equal(readFileSync(join(baseline, 'prisma/migrations', name, 'migration.sql'), 'utf8'), readFileSync(join(repo, 'prisma/migrations', name, 'migration.sql'), 'utf8')); const Database = require(join(repo, 'node_modules/better-sqlite3')); const dir = mkdtempSync(join(tmpdir(), 'wealthledger-bank-durability-')); @@ -26,97 +29,184 @@ const env = { ...process.env, DATABASE_URL: `file:${file}`, NODE_ENV: 'developme delete env.RUST_LOG; let child, origin, run = 0; const credentials = { email: 'durability@example.test', password: 'fictional password long enough' }; -const rub = value => ({ currency: 'RUB', value }); +const rub = (value) => ({ currency: 'RUB', value }); async function start(root) { - const server = createServer(); server.listen(0, '127.0.0.1'); await once(server,'listening'); const port=server.address().port; await new Promise(r=>server.close(r)); - origin=`http://127.0.0.1:${port}`; - const log = openSync(join(dir,`app-${++run}.log`),'w'); - child=spawn(process.execPath,[join(root,'dist/src/main.js')],{cwd:root,env:{...env,DATABASE_URL:`file:${file}`,PORT:String(port)},stdio:['ignore',log,log]}); closeSync(log); - for(let i=0;i<150;i++){if(child.exitCode!==null)throw new Error(`app exited ${child.exitCode}`);try{const r=await fetch(`${origin}/api/v1/auth/me`);if(r.status===401)return;}catch{} await new Promise(r=>setTimeout(r,100));} - throw new Error('app startup timeout'); + const server = createServer(); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const port = server.address().port; + await new Promise((r) => server.close(r)); + origin = `http://127.0.0.1:${port}`; + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds. + const log = openSync(join(dir, `app-${++run}.log`), 'w'); + child = spawn(process.execPath, [join(root, 'dist/src/main.js')], { cwd: root, env: { ...env, DATABASE_URL: `file:${file}`, PORT: String(port) }, stdio: ['ignore', log, log] }); + closeSync(log); + for (let i = 0; i < 150; i++) { + if (child.exitCode !== null) throw new Error(`app exited ${child.exitCode}`); + try { + const r = await fetch(`${origin}/api/v1/auth/me`); + if (r.status === 401) return; + } catch { /* Startup connection failures are retried until the deadline. */ } + await new Promise((r) => setTimeout(r, 100)); + } + throw new Error('app startup timeout'); +} +async function stop() { + if (!child) return; + const c = child; + child = null; + if (c.exitCode !== null) return; + const ended = once(c, 'exit'); + c.kill('SIGTERM'); + await ended; +} +async function http(path, method = 'GET', body, token, status = 200) { + const r = await fetch(`${origin}/api/v1/${path}`, { method, headers: { ...(body ? { 'content-type': 'application/json' } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}) }); + const text = await r.text(); + assert.equal(r.status, status, `${method} ${path}: ${text}`); + return text ? JSON.parse(text) : null; +} + +function migrate(root) { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths are under an exclusive mkdtemp directory or the local migrations directory. + execFileSync(process.execPath, [join(root, 'node_modules/prisma/build/index.js'), 'migrate', 'deploy'], { cwd: root, env: { ...env, DATABASE_URL: `file:${file}` }, stdio: ['ignore', openSync(join(dir, `migration-${run}.log`), 'w'), 'pipe'] }); +} +function rows(table) { + const db = new Database(file, { readonly: true }); + db.defaultSafeIntegers(true); + try { + return db.prepare(`SELECT * FROM "${table}" ORDER BY id`).all(); + } finally { + db.close(); + } } -async function stop(){ if(!child)return;const c=child; child=null; if(c.exitCode!==null)return; const ended=once(c,'exit');c.kill('SIGTERM');await ended;} -async function http(path,method='GET',body,token,status=200){const r=await fetch(`${origin}/api/v1/${path}`,{method,headers:{...(body?{'content-type':'application/json'}:{}),...(token?{Authorization:`Bearer ${token}`}:{})},...(body?{body:JSON.stringify(body)}:{})});const text=await r.text();assert.equal(r.status,status,`${method} ${path}: ${text}`);return text?JSON.parse(text):null;} -function migrate(root){execFileSync(process.execPath,[join(root,'node_modules/prisma/build/index.js'),'migrate','deploy'],{cwd:root,env:{...env,DATABASE_URL:`file:${file}`},stdio:['ignore',openSync(join(dir,`migration-${run}.log`),'w'),'pipe']});} -function rows(table){const db=new Database(file,{readonly:true});db.defaultSafeIntegers(true);try{return db.prepare(`SELECT * FROM "${table}" ORDER BY id`).all();}finally{db.close();}} // The rollback build must leave every additive T-Bank dataset and work intent intact. function seedTbank(owner) { - const db = new Database(file); - const revision = db.prepare('SELECT revision FROM TbankConnection WHERE userId=?').get(owner).revision; - const now = Date.now(); - const scope = {userId:owner,revision,accountId:'durable-account'}; - const exact = {currency:'RUB',value:'-9007199254740993.123456789'}; - const insert = (table, row) => db.prepare(`INSERT INTO "${table}" (${Object.keys(row).map(key=>`"${key}"`).join(',')}) VALUES (${Object.keys(row).map(()=>'?').join(',')})`).run(...Object.values(row)); - db.transaction(() => { - db.prepare('UPDATE TbankConnection SET membershipAt=?,membershipRevision=1 WHERE userId=?').run(now,owner); - insert('TbankAccount',{...scope,type:'BROKERAGE',name:'Synthetic persisted account',upstreamOrder:0}); - insert('TbankPortfolio',{...scope,payload:JSON.stringify({positions:[],cashBalances:[],totalsByCurrency:[],structure:{currencies:[],unvaluedCategories:[]}}),fetchedAt:now}); - insert('TbankOperation',{...scope,brokerOperationId:'durable-income',occurredAt:now,category:'income',payload:JSON.stringify({id:'durable-income',payment:exact})}); - insert('TbankOperationCoverage',{...scope,from:now-1000,to:now+1000,fetchedAt:now,asOf:now}); - insert('TbankForecast',{userId:owner,revision,scopeKey:'durable-forecast',membershipRevision:1,payload:JSON.stringify({incomeTotalsByCurrency:[exact]}),dependencyRevisions:JSON.stringify({portfolio:1}),sourceTimestamps:JSON.stringify({portfolio:new Date(now).toISOString()}),calculatedAt:now}); - insert('TbankInstrument',{provider:'tbank',uid:'durable-instrument',instrumentType:'bond',metadata:JSON.stringify({name:'Synthetic'}),fetchedAt:now}); - insert('TbankCatalogDiscovery',{provider:'tbank',instrumentType:'bond',query:'SYN',instrumentUids:JSON.stringify(['durable-instrument']),fetchedAt:now}); - insert('TbankInstrumentLookup',{provider:'tbank',identifierType:'ticker',identifier:'SYN',matchedUids:JSON.stringify(['durable-instrument']),fetchedAt:now}); - insert('TbankInstrumentSource',{provider:'tbank',instrumentUid:'durable-instrument',source:'tbank',kind:'coupons',rangeKey:'durable',payload:JSON.stringify({events:[]}),fetchedAt:now}); - insert('TbankCandle',{provider:'tbank',instrumentUid:'durable-instrument',interval:'day',time:now,close:exact.value,payload:JSON.stringify({close:exact.value}),isComplete:1,fetchedAt:now}); - insert('TbankCandleCoverage',{provider:'tbank',instrumentUid:'durable-instrument',interval:'day',from:now-1000,to:now+1000,isComplete:1,fetchedAt:now}); - insert('TbankSyncWork',{id:'durable-work',userId:owner,revision,scopeKey:'durable-intent',intent:JSON.stringify({from:now-1000,to:now+1000}),state:'running',attemptId:'stopped-attempt',processRunId:'stopped-process',priority:0,progress:JSON.stringify({completed:1}),createdAt:now,updatedAt:now}); - insert('TbankSyncStage',{workId:'durable-work',attemptId:'stopped-attempt',sourceKey:'durable',itemKey:'next-row',payload:JSON.stringify({payment:exact})}); - })(); - db.close(); + const db = new Database(file); + const revision = db.prepare('SELECT revision FROM TbankConnection WHERE userId=?').get(owner).revision; + const now = Date.now(); + const scope = { userId: owner, revision, accountId: 'durable-account' }; + const exact = { currency: 'RUB', value: '-9007199254740993.123456789' }; + const insert = (table, row) => db.prepare(`INSERT INTO "${table}" (${Object.keys(row).map((key) => `"${key}"`).join(',')}) VALUES (${Object.keys(row).map(() => '?').join(',')})`).run(...Object.values(row)); + db.transaction(() => { + db.prepare('UPDATE TbankConnection SET membershipAt=?,membershipRevision=1 WHERE userId=?').run(now, owner); + insert('TbankAccount', { ...scope, type: 'BROKERAGE', name: 'Synthetic persisted account', upstreamOrder: 0 }); + insert('TbankPortfolio', { ...scope, payload: JSON.stringify({ positions: [], cashBalances: [], totalsByCurrency: [], structure: { currencies: [], unvaluedCategories: [] } }), fetchedAt: now }); + insert('TbankOperation', { ...scope, brokerOperationId: 'durable-income', occurredAt: now, category: 'income', payload: JSON.stringify({ id: 'durable-income', payment: exact }) }); + insert('TbankOperationCoverage', { ...scope, from: now - 1000, to: now + 1000, fetchedAt: now, asOf: now }); + insert('TbankForecast', { userId: owner, revision, scopeKey: 'durable-forecast', membershipRevision: 1, payload: JSON.stringify({ incomeTotalsByCurrency: [exact] }), dependencyRevisions: JSON.stringify({ portfolio: 1 }), sourceTimestamps: JSON.stringify({ portfolio: new Date(now).toISOString() }), calculatedAt: now }); + insert('TbankInstrument', { provider: 'tbank', uid: 'durable-instrument', instrumentType: 'bond', metadata: JSON.stringify({ name: 'Synthetic' }), fetchedAt: now }); + insert('TbankCatalogDiscovery', { provider: 'tbank', instrumentType: 'bond', query: 'SYN', instrumentUids: JSON.stringify(['durable-instrument']), fetchedAt: now }); + insert('TbankInstrumentLookup', { provider: 'tbank', identifierType: 'ticker', identifier: 'SYN', matchedUids: JSON.stringify(['durable-instrument']), fetchedAt: now }); + insert('TbankInstrumentSource', { provider: 'tbank', instrumentUid: 'durable-instrument', source: 'tbank', kind: 'coupons', rangeKey: 'durable', payload: JSON.stringify({ events: [] }), fetchedAt: now }); + insert('TbankCandle', { provider: 'tbank', instrumentUid: 'durable-instrument', interval: 'day', time: now, close: exact.value, payload: JSON.stringify({ close: exact.value }), isComplete: 1, fetchedAt: now }); + insert('TbankCandleCoverage', { provider: 'tbank', instrumentUid: 'durable-instrument', interval: 'day', from: now - 1000, to: now + 1000, isComplete: 1, fetchedAt: now }); + insert('TbankSyncWork', { id: 'durable-work', userId: owner, revision, scopeKey: 'durable-intent', intent: JSON.stringify({ from: now - 1000, to: now + 1000 }), state: 'running', attemptId: 'stopped-attempt', processRunId: 'stopped-process', priority: 0, progress: JSON.stringify({ completed: 1 }), createdAt: now, updatedAt: now }); + insert('TbankSyncStage', { workId: 'durable-work', attemptId: 'stopped-attempt', sourceKey: 'durable', itemKey: 'next-row', payload: JSON.stringify({ payment: exact }) }); + })(); + db.close(); } function tbankSnapshot() { - const db = new Database(file,{readonly:true}); - db.defaultSafeIntegers(true); - try { - assert.equal(db.pragma('integrity_check',{simple:true}),'ok'); - assert.deepEqual(db.pragma('foreign_key_check'),[]); - return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND (name LIKE 'Tbank%' OR name='_prisma_migrations') ORDER BY name").all().map(({name})=>[name,db.prepare(`SELECT * FROM "${name}" ORDER BY rowid`).all()])); - } finally {db.close();} + const db = new Database(file, { readonly: true }); + db.defaultSafeIntegers(true); + try { + assert.equal(db.pragma('integrity_check', { simple: true }), 'ok'); + assert.deepEqual(db.pragma('foreign_key_check'), []); + return Object.fromEntries(db.prepare('SELECT name FROM sqlite_master WHERE type=\'table\' AND (name LIKE \'Tbank%\' OR name=\'_prisma_migrations\') ORDER BY name').all().map(({ name }) => [name, db.prepare(`SELECT * FROM "${name}" ORDER BY rowid`).all()])); + } finally { + db.close(); + } } -(async()=>{ - try{ - migrate(baseline); await start(baseline); - const auth=await http('auth/register','POST',credentials); const owner=auth.user.id; - await stop(); - const db=new Database(file);db.prepare('INSERT INTO TbankConnection (userId,encryptedToken,connectedAt) VALUES (?,?,?)').run(owner,'fictional-encrypted-token-never-used',new Date().toISOString());await db.backup(join(dir,'before-migration.db'));db.close(); - const beforeUsers=rows('User'), beforeSessions=rows('Session'); - migrate(repo); - assert.deepEqual(rows('User'),beforeUsers); assert.deepEqual(rows('Session'),beforeSessions); const migratedDb=new Database(file,{readonly:true});assert.equal(migratedDb.prepare('SELECT encryptedToken FROM TbankConnection WHERE userId=?').get(owner).encryptedToken,'fictional-encrypted-token-never-used');migratedDb.close(); - await start(repo); let token=(await http('auth/login','POST',credentials)).accessToken; - const intent={id:randomUUID(),name:'Долговременная',openingDate:'2026-01-01',openingBalance:rub('1000')}; - let card=await http('bank-cards','POST',intent,token,201); - const category=(await http('bank-cards/categories','GET',null,token)).find(x=>x.isDefault); - await http(`bank-cards/categories/${category.id}`,'PATCH',{name:'Сохранённая категория',version:category.version},token); - const expenseIntent={id:randomUUID(),kind:'expense',cardId:card.id,amount:rub('100'),date:'2026-08-01'}; - let expense=await http('bank-cards/operations','POST',expenseIntent,token,201); - const refundIntent={id:randomUUID(),kind:'refund',cardId:card.id,originalExpenseId:expense.id,amount:rub('100'),date:'2026-09-01'}; - const refund=await http('bank-cards/operations','POST',refundIntent,token,201); - const deletedIntent={id:randomUUID(),kind:'topup',cardId:card.id,amount:rub('12.34'),date:'2026-09-01'}; - const deleted=await http('bank-cards/operations','POST',deletedIntent,token,201); - await http(`bank-cards/operations/${deleted.id}?version=${deleted.version}`,'DELETE',null,token,204); - card=await http(`bank-cards/${card.id}`,'GET',null,token); - card=await http(`bank-cards/${card.id}`,'PATCH',{version:card.version,archived:true},token); - seedTbank(owner); const tbankBefore=tbankSnapshot(); - const snapshots={cards:rows('BankCard'),operations:rows('BankCardOperation'),categories:rows('ExpenseCategory')}; - await stop(); await start(repo);token=(await http('auth/login','POST',credentials)).accessToken; - assert.deepEqual(await http('bank-cards','POST',intent,token,201),card); - assert.deepEqual(await http('bank-cards/operations','POST',refundIntent,token,201),refund); - await http('bank-cards/operations','POST',deletedIntent,token,410); - assert.deepEqual(await http(`bank-cards/${card.id}`,'GET',null,token),card); - await stop(); - assert.deepEqual(tbankSnapshot(),tbankBefore);assert.deepEqual(rows('BankCard'),snapshots.cards);assert.deepEqual(rows('BankCardOperation'),snapshots.operations);assert.deepEqual(rows('ExpenseCategory'),snapshots.categories); - const latest=new Database(file);await latest.backup(join(dir,'after-history.db'));latest.close(); - await start(baseline);token=(await http('auth/login','POST',credentials)).accessToken;assert.equal((await http('auth/me','GET',null,token)).id,owner);await stop(); - assert.deepEqual(tbankSnapshot(),tbankBefore);assert.deepEqual(rows('BankCard'),snapshots.cards);assert.deepEqual(rows('BankCardOperation'),snapshots.operations);assert.deepEqual(rows('ExpenseCategory'),snapshots.categories); - await start(repo);token=(await http('auth/login','POST',credentials)).accessToken;assert.deepEqual(await http(`bank-cards/${card.id}`,'GET',null,token),card);await stop(); - const restored=join(dir,'restored.db');const backup=new Database(join(dir,'after-history.db'),{readonly:true});await backup.backup(restored);backup.close();file=restored; - assert.deepEqual(tbankSnapshot(),tbankBefore);assert.deepEqual(rows('BankCard'),snapshots.cards);assert.deepEqual(rows('BankCardOperation'),snapshots.operations);assert.deepEqual(rows('ExpenseCategory'),snapshots.categories); - assert.deepEqual(rows('User'),beforeUsers);assert.ok(rows('Session').some(s=>s.id===beforeSessions[0].id)); - assert.deepEqual(tbankSnapshot(),tbankBefore); - const restoredDb=new Database(file,{readonly:true});assert.equal(restoredDb.prepare('SELECT encryptedToken FROM TbankConnection WHERE userId=?').get(owner).encryptedToken,'fictional-encrypted-token-never-used');assert.equal(restoredDb.pragma('integrity_check',{simple:true}),'ok');restoredDb.close(); - await start(repo);token=(await http('auth/login','POST',credentials)).accessToken;assert.deepEqual(await http(`bank-cards/${card.id}`,'GET',null,token),card);await stop(); - writeFileSync(join(dir,'result.json'),JSON.stringify({result:'PASS',baseline,dir,checks:['all populated T-Bank datasets, staging, progress and migration state survive restart, rollback and isolated restore','foreign key and integrity checks after each boundary','baseline migration and auth','additive migration preserves User Session TbankConnection','restart preserves archive categories refund links tombstones fingerprints','same-intent replay after restart','previous application auth with additive schema','new application returns preserved history after rollback','SQLite backup and restored application reads history']},null,2)); - console.log(`PASS durability ${dir}`); - }finally{await stop();} -})().catch(error=>{console.error(error);process.exitCode=1;}); +(async () => { + try { + migrate(baseline); + await start(baseline); + const auth = await http('auth/register', 'POST', credentials); + const owner = auth.user.id; + await stop(); + const db = new Database(file); + db.prepare('INSERT INTO TbankConnection (userId,encryptedToken,connectedAt) VALUES (?,?,?)').run(owner, 'fictional-encrypted-token-never-used', new Date().toISOString()); + await db.backup(join(dir, 'before-migration.db')); + db.close(); + const beforeSessions = rows('Session'), beforeUsers = rows('User'); + migrate(repo); + assert.deepEqual(rows('User'), beforeUsers); + assert.deepEqual(rows('Session'), beforeSessions); + const migratedDb = new Database(file, { readonly: true }); + assert.equal(migratedDb.prepare('SELECT encryptedToken FROM TbankConnection WHERE userId=?').get(owner).encryptedToken, 'fictional-encrypted-token-never-used'); + migratedDb.close(); + await start(repo); + let token = (await http('auth/login', 'POST', credentials)).accessToken; + const intent = { id: randomUUID(), name: 'Долговременная', openingDate: '2026-01-01', openingBalance: rub('1000') }; + let card = await http('bank-cards', 'POST', intent, token, 201); + const category = (await http('bank-cards/categories', 'GET', null, token)).find((x) => x.isDefault); + await http(`bank-cards/categories/${category.id}`, 'PATCH', { name: 'Сохранённая категория', version: category.version }, token); + const expenseIntent = { id: randomUUID(), kind: 'expense', cardId: card.id, amount: rub('100'), date: '2026-08-01' }; + let expense = await http('bank-cards/operations', 'POST', expenseIntent, token, 201); + const refundIntent = { id: randomUUID(), kind: 'refund', cardId: card.id, originalExpenseId: expense.id, amount: rub('100'), date: '2026-09-01' }; + const refund = await http('bank-cards/operations', 'POST', refundIntent, token, 201); + const deletedIntent = { id: randomUUID(), kind: 'topup', cardId: card.id, amount: rub('12.34'), date: '2026-09-01' }; + const deleted = await http('bank-cards/operations', 'POST', deletedIntent, token, 201); + await http(`bank-cards/operations/${deleted.id}?version=${deleted.version}`, 'DELETE', null, token, 204); + card = await http(`bank-cards/${card.id}`, 'GET', null, token); + card = await http(`bank-cards/${card.id}`, 'PATCH', { version: card.version, archived: true }, token); + seedTbank(owner); + const tbankBefore = tbankSnapshot(); + const snapshots = { cards: rows('BankCard'), operations: rows('BankCardOperation'), categories: rows('ExpenseCategory') }; + await stop(); + await start(repo); + token = (await http('auth/login', 'POST', credentials)).accessToken; + assert.deepEqual(await http('bank-cards', 'POST', intent, token, 201), card); + assert.deepEqual(await http('bank-cards/operations', 'POST', refundIntent, token, 201), refund); + await http('bank-cards/operations', 'POST', deletedIntent, token, 410); + assert.deepEqual(await http(`bank-cards/${card.id}`, 'GET', null, token), card); + await stop(); + assert.deepEqual(tbankSnapshot(), tbankBefore); + assert.deepEqual(rows('BankCard'), snapshots.cards); + assert.deepEqual(rows('BankCardOperation'), snapshots.operations); + assert.deepEqual(rows('ExpenseCategory'), snapshots.categories); + const latest = new Database(file); + await latest.backup(join(dir, 'after-history.db')); + latest.close(); + await start(baseline); + token = (await http('auth/login', 'POST', credentials)).accessToken; + assert.equal((await http('auth/me', 'GET', null, token)).id, owner); + await stop(); + assert.deepEqual(tbankSnapshot(), tbankBefore); + assert.deepEqual(rows('BankCard'), snapshots.cards); + assert.deepEqual(rows('BankCardOperation'), snapshots.operations); + assert.deepEqual(rows('ExpenseCategory'), snapshots.categories); + await start(repo); + token = (await http('auth/login', 'POST', credentials)).accessToken; + assert.deepEqual(await http(`bank-cards/${card.id}`, 'GET', null, token), card); + await stop(); + const restored = join(dir, 'restored.db'); + const backup = new Database(join(dir, 'after-history.db'), { readonly: true }); + await backup.backup(restored); + backup.close(); + file = restored; + assert.deepEqual(tbankSnapshot(), tbankBefore); + assert.deepEqual(rows('BankCard'), snapshots.cards); + assert.deepEqual(rows('BankCardOperation'), snapshots.operations); + assert.deepEqual(rows('ExpenseCategory'), snapshots.categories); + assert.deepEqual(rows('User'), beforeUsers); + assert.ok(rows('Session').some((s) => s.id === beforeSessions[0].id)); + assert.deepEqual(tbankSnapshot(), tbankBefore); + const restoredDb = new Database(file, { readonly: true }); + assert.equal(restoredDb.prepare('SELECT encryptedToken FROM TbankConnection WHERE userId=?').get(owner).encryptedToken, 'fictional-encrypted-token-never-used'); + assert.equal(restoredDb.pragma('integrity_check', { simple: true }), 'ok'); + restoredDb.close(); + await start(repo); + token = (await http('auth/login', 'POST', credentials)).accessToken; + assert.deepEqual(await http(`bank-cards/${card.id}`, 'GET', null, token), card); + await stop(); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds. + writeFileSync(join(dir, 'result.json'), JSON.stringify({ result: 'PASS', baseline, dir, checks: ['all populated T-Bank datasets, staging, progress and migration state survive restart, rollback and isolated restore', 'foreign key and integrity checks after each boundary', 'baseline migration and auth', 'additive migration preserves User Session TbankConnection', 'restart preserves archive categories refund links tombstones fingerprints', 'same-intent replay after restart', 'previous application auth with additive schema', 'new application returns preserved history after rollback', 'SQLite backup and restored application reads history'] }, null, 2)); + console.log(`PASS durability ${dir}`); + } finally { + await stop(); + } +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/test/bank-cards.e2e-spec.ts b/test/bank-cards.e2e-spec.ts index 1585a47..cfa6de5 100644 --- a/test/bank-cards.e2e-spec.ts +++ b/test/bank-cards.e2e-spec.ts @@ -1,10 +1,12 @@ +import type { Server } from 'node:http'; + import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; import assert from 'node:assert/strict'; import { randomUUID } from 'node:crypto'; -import type { Server } from 'node:http'; import request from 'supertest'; + import { AppModule } from '../src/app.module'; import { configureApp } from '../src/main'; import { @@ -28,12 +30,12 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { ); const money = (value: string) => ({ currency: 'RUB', value }); const auth = ( - method: 'get' | 'post' | 'patch' | 'delete', + method: 'delete' | 'get' | 'patch' | 'post', path: string, user = owner, ) => - request(app.getHttpServer()) - [method]('/api/v1/bank-cards' + path) + // eslint-disable-next-line security/detect-object-injection -- Keys come from local test fixtures or numeric iteration, not external input. + request(app.getHttpServer())[method]('/api/v1/bank-cards' + path) .set('Authorization', `Bearer ${token(user)}`); const createCard = async (value = '1000', user = owner, extra = {}) => ( @@ -102,10 +104,11 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { if (app) await app.close(); }); it('requires auth and reads never seed data or need T-Bank', async () => { - for (const path of ['', '/categories', '/summary?month=2026-08']) + for (const path of ['', '/categories', '/summary?month=2026-08']) { await request(app.getHttpServer()) .get('/api/v1/bank-cards' + path) .expect(401); + } expect((await auth('get', '').expect(200)).body).toEqual([]); expect(await categories()).toEqual([]); expect(await db.expenseCategory.count({ where: { userId: owner } })).toBe( @@ -241,10 +244,11 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { money('92233720368547758.08'), { currency: 'USD', value: '1' }, money('NaN'), - ]) + ]) { await auth('post', '/operations') .send({ ...op(card.id), amount }) .expect(400); + } for (const extra of [ { date: '2026-02-30' }, { date: '2099-01-01' }, @@ -252,10 +256,11 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { { description: 'x'.repeat(501) }, { kind: 'topup', categoryId: (await categories())[0].id }, { kind: 'transfer', targetCardId: card.id }, - ]) + ]) { await auth('post', '/operations') .send(op(card.id, 'expense', '1', extra)) .expect(400); + } expect(await db.bankCardOperation.count()).toBe(before); for (const extra of [ { name: ' ' }, @@ -264,7 +269,7 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { { lastFour: '123' }, { openingDate: '2026-02-30' }, { openingBalance: money('-92233720368547758.09') }, - ]) + ]) { await auth('post', '') .send({ id: randomUUID(), @@ -274,6 +279,7 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { ...extra, }) .expect(400); + } }); it('returns nested money field errors without exposing input or writing a card', async () => { const before = await db.bankCard.count(); @@ -310,8 +316,8 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { }); it('scopes every card/category/expense link and edit to owner', async () => { - const own = await createCard(), - foreign = await createCard('0', stranger); + const foreign = await createCard('0', stranger), + own = await createCard(); const foreignCategories = ( await auth('get', '/categories', stranger).expect(200) ).body as ExpenseCategoryDto[]; @@ -326,15 +332,17 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { '/' + foreign.id + '/expenses', '/operations/' + foreignOp.id, '/summary?month=2026-08&cardId=' + foreign.id, - ]) + ]) { await auth('get', path).expect(404); + } for (const body of [ op(foreign.id), op(own.id, 'expense', '1', { categoryId: foreignCategories[0].id }), op(own.id, 'transfer', '1', { targetCardId: foreign.id }), op(own.id, 'refund', '1', { originalExpenseId: foreignOp.id }), - ]) + ]) { await auth('post', '/operations').send(body).expect(404); + } await auth('patch', '/' + foreign.id) .send({ version: 1, archived: true }) .expect(404); @@ -457,7 +465,7 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { }), ), ]); - expect(responses.map((r) => r.status).sort()).toEqual([201, 409]); + expect(responses.map((r) => r.status).sort((a, b) => a - b)).toEqual([201, 409]); const body = op(card.id, 'refund', '400', { date: '2026-09-01', originalExpenseId: expense.id, @@ -484,10 +492,11 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { { amount: money('999') }, { date: '2026-09-02' }, { cardId: (await createCard()).id }, - ]) + ]) { await auth('patch', '/operations/' + expense.id) .send({ version: current.version, ...patch }) .expect(409); + } await auth( 'delete', `/operations/${expense.id}?version=${current.version}`, @@ -501,6 +510,7 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { category.id, ); }); + // eslint-disable-next-line sonarjs/assertions-in-tests -- Every rejection is asserted through awaited Supertest .expect(400). it('rejects invalid refund references, dates, immutable kind and purchase link', async () => { const card = await createCard(), other = await createCard(); @@ -513,8 +523,9 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { originalExpenseId: purchase.id, date: '2026-07-01', }), - ]) + ]) { await auth('post', '/operations').send(body).expect(400); + } const refund = await createOp( op(card.id, 'refund', '10', { originalExpenseId: purchase.id }), ); @@ -532,7 +543,7 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { await createOp(op(a.id, 'expense', '20')); await createOp(op(a.id, 'expense', '30')); const pages: BankOperationsPageDto[] = []; - for (let page = 1; page <= 3; page++) + for (let page = 1; page <= 3; page++) { pages.push( ( await auth( @@ -541,6 +552,7 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { ).expect(200) ).body as BankOperationsPageDto, ); + } expect(new Set(pages.flatMap((p) => p.items.map((i) => i.id))).size).toBe( 3, ); @@ -564,8 +576,9 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { for (const body of [ op(card.id), op(card.id, 'transfer', '1', { targetCardId: (await createCard()).id }), - ]) + ]) { await auth('post', '/operations').send(body).expect(409); + } await auth('patch', '/operations/' + expense.id) .send({ version: 1, description: 'no' }) .expect(409); @@ -625,7 +638,7 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { }); expect( refunds.reduce((sum, row) => sum + row.amountKopecks, 0n) <= - source.amountKopecks, + source.amountKopecks, ).toBe(true); }); it('serializes opening-date changes and validates archived previous transfer sides', async () => { @@ -755,8 +768,9 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { it('filters journal and summary inclusively across months and rejects invalid periods', async () => { const card = await createCard(); const dates = ['2026-07-30', '2026-07-31', '2026-08-01', '2026-08-02']; - for (const date of dates) + for (const date of dates) { await createOp(op(card.id, 'expense', '10', { date })); + } const range = 'from=2026-07-31&to=2026-08-01'; const history = ( await auth('get', `/${card.id}/operations?${range}&limit=1`).expect(200) diff --git a/test/bonds.e2e-spec.ts b/test/bonds.e2e-spec.ts index c8b5d0d..8e53c35 100644 --- a/test/bonds.e2e-spec.ts +++ b/test/bonds.e2e-spec.ts @@ -1,3 +1,5 @@ +import type { Server } from 'node:http'; + import { ConflictException, INestApplication, @@ -6,13 +8,13 @@ import { } 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 { BondCatalogService } from '../src/modules/investments/instruments/bonds/application/bond-catalog.service'; import { BondIncomeService } from '../src/modules/investments/instruments/bonds/application/bond-income.service'; import { BondMarketService } from '../src/modules/investments/instruments/bonds/application/bond-market.service'; -import { BondCatalogService } from '../src/modules/investments/instruments/bonds/application/bond-catalog.service'; const uid = 'e6123145-9665-43e0-8413-cd61b8aa9b13'; describe('Bond HTTP contract', () => { diff --git a/test/container-smoke.cjs b/test/container-smoke.cjs index 6d0cb11..93efa14 100644 --- a/test/container-smoke.cjs +++ b/test/container-smoke.cjs @@ -6,6 +6,7 @@ const { randomUUID } = require('node:crypto'); const image = process.argv[2]; assert.ok(image, 'Pass a built backend image reference'); const name = `wealthledger-smoke-${randomUUID()}`; +// eslint-disable-next-line sonarjs/no-os-command-from-path -- Developer-only command uses the operator PATH and fixed executable/arguments; no request input. const docker = (...args) => execFileSync('docker', args, { encoding: 'utf8' }).trim(); const environment = [ '-e', 'DATABASE_URL=file:/data/wealthledger.db', @@ -64,6 +65,13 @@ async function ready() { `); console.log('PASS image: fresh/existing migration, non-root read-only runtime, auth, persistence, proto assets'); } finally { - try { docker('rm', '-f', name); } finally { docker('volume', 'rm', name); } + try { + docker('rm', '-f', name); + } finally { + docker('volume', 'rm', name); + } } -})().catch((error) => { console.error(error); process.exitCode = 1; }); +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/test/e2e-env.cjs b/test/e2e-env.cjs index 1c76d9d..d55c3dc 100644 --- a/test/e2e-env.cjs +++ b/test/e2e-env.cjs @@ -1,5 +1,5 @@ -const { join, dirname, basename } = require('node:path'); const { tmpdir } = require('node:os'); +const { join, dirname, basename } = require('node:path'); const dir = process.env.WL_E2E_DIRECTORY; if ( !dir || diff --git a/test/eslint-config.cjs b/test/eslint-config.cjs new file mode 100644 index 0000000..48446da --- /dev/null +++ b/test/eslint-config.cjs @@ -0,0 +1,47 @@ +const { ESLint } = require('eslint'); +const assert = require('node:assert/strict'); +const { test } = require('node:test'); + +const eslint = new ESLint(); + +// Protect the quality gate's file scopes when configs or plugin versions change. +test('typed application code enforces async and unsafe-type checks', async () => { + const [result] = await eslint.lintText( + 'export const unsafe: any = 1; Promise.resolve(unsafe);', + { filePath: 'src/main.ts' }, + ); + const rules = new Set(result.messages.map(({ ruleId }) => ruleId)); + assert.ok(rules.has('@typescript-eslint/no-explicit-any')); + assert.ok(rules.has('@typescript-eslint/no-floating-promises')); +}); + +test('framework and runtime rules reach their intended files', async () => { + const app = await eslint.calculateConfigForFile('src/main.ts'); + const spec = await eslint.calculateConfigForFile('test/app.e2e-spec.ts'); + const script = await eslint.calculateConfigForFile('scripts/generate-openapi.cjs'); + const config = await eslint.calculateConfigForFile('eslint.config.mjs'); + assert.equal(app.rules['@darraghor/nestjs-typed/validation-pipe-should-use-forbid-unknown'][0], 2); + assert.equal(spec.rules['jest/no-focused-tests'][0], 2); + assert.equal(app.rules['jest/no-focused-tests'], undefined); + assert.equal(app.languageOptions.globals.expect, undefined); + assert.equal(config.languageOptions.globals.require, 'off'); + for (const profile of [app, spec, script, config]) { + assert.equal(profile.rules['n/no-deprecated-api'][0], 2); + assert.equal(profile.rules['security/detect-eval-with-expression'][0], 1); + assert.equal(profile.rules['regexp/no-super-linear-backtracking'][0], 2); + assert.equal(profile.rules['sonarjs/cognitive-complexity'][0], 2); + assert.equal(profile.rules['import-x/no-unresolved'][0], 2); + assert.equal(profile.rules['perfectionist/sort-imports'][0], 2); + assert.equal(profile.rules['@stylistic/semi'][0], 2); + } +}); + +test('focused tests are rejected and generated code is excluded', async () => { + const [result] = await eslint.lintText( + 'it.only(\'example\', () => { expect(1).toBe(1); });', + { filePath: 'test/app.e2e-spec.ts' }, + ); + assert.ok(result.messages.some(({ ruleId }) => ruleId === 'jest/no-focused-tests')); + assert.ok(await eslint.isPathIgnored('generated/prisma/client.ts')); + assert.ok(await eslint.isPathIgnored('dist/src/main.js')); +}); diff --git a/test/fixtures/tbank-sync-crash.cjs b/test/fixtures/tbank-sync-crash.cjs index 8e13110..463615f 100644 --- a/test/fixtures/tbank-sync-crash.cjs +++ b/test/fixtures/tbank-sync-crash.cjs @@ -1,19 +1,20 @@ // This process is deliberately killed by the SQLite suite at durable boundaries. require('../e2e-env.cjs'); const { ConfigService } = require('@nestjs/config'); -const { PrismaService } = require('../../dist/src/prisma/prisma.service'); -const { - TbankTokenService, -} = require('../../dist/src/integrations/tbank/tbank-token.service'); + const { TbankAccessService, } = require('../../dist/src/integrations/tbank/tbank-access.service'); const { - BrokerageOperationsRepository, -} = require('../../dist/src/modules/investments/operations/infrastructure/brokerage-operations.repository'); + TbankTokenService, +} = require('../../dist/src/integrations/tbank/tbank-token.service'); const { BrokerageOperationsSyncService, } = require('../../dist/src/modules/investments/operations/application/brokerage-operations-sync.service'); +const { + BrokerageOperationsRepository, +} = require('../../dist/src/modules/investments/operations/infrastructure/brokerage-operations.repository'); +const { PrismaService } = require('../../dist/src/prisma/prisma.service'); const mode = process.argv[2]; const prisma = new PrismaService(new ConfigService(process.env)); const repository = new BrokerageOperationsRepository(prisma); diff --git a/test/openapi-contract.e2e-spec.ts b/test/openapi-contract.e2e-spec.ts index 2df4936..6034b08 100644 --- a/test/openapi-contract.e2e-spec.ts +++ b/test/openapi-contract.e2e-spec.ts @@ -5,6 +5,7 @@ import { execFileSync } from 'node:child_process'; import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { resolve } from 'node:path'; + import { AppModule } from '../src/app.module'; import { configureApp } from '../src/main'; import { createOpenApiDocument } from '../src/openapi'; @@ -56,6 +57,7 @@ describe('OpenAPI contract (integration)', () => { ['IncomeInvestmentScreenDto', 'income'], ['EventsInvestmentScreenDto', 'events'], ]) { + // eslint-disable-next-line security/detect-object-injection -- Keys come from local test fixtures or numeric iteration, not external input. const schema = schemas?.[model]; assert(schema && !('$ref' in schema)); expect(schema.required).toContain('screen'); @@ -84,7 +86,7 @@ describe('OpenAPI contract (integration)', () => { const parameters = document.paths[`/api/v1/brokerage-accounts/${scope}/${resource}`].get ?.parameters; - for (const name of ['from', 'to']) + for (const name of ['from', 'to']) { expect( parameters?.find( (parameter) => 'name' in parameter && parameter.name === name, @@ -94,6 +96,7 @@ describe('OpenAPI contract (integration)', () => { in: 'query', schema: { format }, }); + } } } }); @@ -147,6 +150,7 @@ describe('OpenAPI contract (integration)', () => { const directory = await mkdtemp(resolve(tmpdir(), 'wealthledger-openapi-')); try { const networkGuard = resolve(directory, 'no-network.cjs'); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds. await writeFile( networkGuard, ` @@ -170,6 +174,7 @@ describe('OpenAPI contract (integration)', () => { }, ); const generated = JSON.parse( + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds. await readFile(resolve(directory, 'openapi.json'), 'utf8'), ) as unknown; expect(generated).toEqual(createOpenApiDocument(app)); diff --git a/test/portfolio-structure.e2e-spec.ts b/test/portfolio-structure.e2e-spec.ts index c95bd8f..68a1c16 100644 --- a/test/portfolio-structure.e2e-spec.ts +++ b/test/portfolio-structure.e2e-spec.ts @@ -1,13 +1,15 @@ +import type { Server } from 'node:http'; + 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 { PrismaService } from '../src/prisma/prisma.service'; import { setTimeout as delay } from 'node:timers/promises'; +import request from 'supertest'; + +import { AppModule } from '../src/app.module'; import { TbankClient } from '../src/integrations/tbank/tbank.client'; import { configureApp } from '../src/main'; +import { PrismaService } from '../src/prisma/prisma.service'; describe('Portfolio structure HTTP contract', () => { let app: INestApplication; @@ -72,8 +74,9 @@ describe('Portfolio structure HTTP contract', () => { !(await app .get(PrismaService) .tbankSyncWork.count({ where: { state: 'running' } })) - ) + ) { return; + } await delay(5); } throw new Error('Portfolio did not settle'); diff --git a/test/sqlite-regression.cjs b/test/sqlite-regression.cjs index fb7f43c..e6e0c96 100644 --- a/test/sqlite-regression.cjs +++ b/test/sqlite-regression.cjs @@ -12,7 +12,9 @@ const { join, resolve } = require('node:path'); const cwd = resolve(__dirname, '..'); const evidence = mkdtempSync(join(tmpdir(), 'wealthledger-sqlite-regression-')); const sentinel = join(evidence, 'user.db'); +// eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds. writeFileSync(sentinel, 'fictional user database: must never be opened'); +// eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds. const before = readFileSync(sentinel); const databases = new Set(); async function check(name, overrides = {}, signal) { @@ -43,6 +45,7 @@ async function check(name, overrides = {}, signal) { child.once('error', reject); child.once('close', resolve); }); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds. writeFileSync(join(evidence, `${name}.log`), log); const dirs = [...log.matchAll(/^SQLite .+: (.+)$/gm)].map( (match) => match[1], @@ -51,21 +54,23 @@ async function check(name, overrides = {}, signal) { for (const dir of dirs) { assert.ok(!databases.has(dir), 'runners must never share databases'); databases.add(dir); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds. assert.ok(!existsSync(dir), `runner must clean up ${dir}`); } assert.deepEqual( + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds. readFileSync(sentinel), before, 'user database must be untouched', ); const expected = - signal === 'SIGINT' - ? 130 - : signal === 'SIGTERM' - ? 143 - : overrides.PRISMA_SCHEMA_ENGINE_BINARY - ? 1 - : 0; + signal === 'SIGINT' ? + 130 : + signal === 'SIGTERM' ? + 143 : + overrides.PRISMA_SCHEMA_ENGINE_BINARY ? + 1 : + 0; assert.equal(code, expected, `See ${name}.log`); console.log(`PASS ${name}: ${dirs.length} isolated databases`); } @@ -76,8 +81,9 @@ async function check(name, overrides = {}, signal) { check('parallel-1'), check('parallel-2', { RUST_LOG: 'error' }), ]); - for (const result of parallel) + for (const result of parallel) { if (result.status === 'rejected') throw result.reason; + } await check('migration-failure', { PRISMA_SCHEMA_ENGINE_BINARY: join(evidence, 'missing-engine'), }); diff --git a/test/sqlite.cjs b/test/sqlite.cjs index 74357e6..4bcedfc 100644 --- a/test/sqlite.cjs +++ b/test/sqlite.cjs @@ -9,13 +9,14 @@ const available = readdirSync(__dirname) .sort(); const requested = process.argv.slice(2); const suites = - requested.length === 0 - ? ['app.e2e-spec.ts', 'bank-cards.e2e-spec.ts'] - : requested.length === 1 && requested[0] === '--all' - ? available - : requested; -if (suites.some((suite) => !available.includes(suite))) + requested.length === 0 ? + ['app.e2e-spec.ts', 'bank-cards.e2e-spec.ts'] : + requested.length === 1 && requested[0] === '--all' ? + available : + requested; +if (suites.some((suite) => !available.includes(suite))) { throw new Error('Unknown e2e suite'); +} let child; let interrupted; let killTimer; @@ -46,9 +47,9 @@ function run(args, env) { child.once('close', (code, signal) => { clearTimeout(killTimer); child = undefined; - if (code !== 0 || signal) + if (code !== 0 || signal) { reject(new Error(`Child failed: ${signal ?? code}`)); - else resolve(); + } else resolve(); }); }); } diff --git a/test/stocks.e2e-spec.ts b/test/stocks.e2e-spec.ts index 6e04520..6bd6e54 100644 --- a/test/stocks.e2e-spec.ts +++ b/test/stocks.e2e-spec.ts @@ -1,3 +1,5 @@ +import type { Server } from 'node:http'; + import { ConflictException, INestApplication, @@ -6,13 +8,13 @@ import { } 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 { StockCatalogService } from '../src/modules/investments/instruments/stocks/application/stock-catalog.service'; import { StockIncomeService } from '../src/modules/investments/instruments/stocks/application/stock-income.service'; import { StockMarketService } from '../src/modules/investments/instruments/stocks/application/stock-market.service'; -import { StockCatalogService } from '../src/modules/investments/instruments/stocks/application/stock-catalog.service'; const uid = 'e6123145-9665-43e0-8413-cd61b8aa9b13'; describe('Stock HTTP contract', () => { diff --git a/test/tbank-candles-persistence.e2e-spec.ts b/test/tbank-candles-persistence.e2e-spec.ts index 6b5740a..cbdfd51 100644 --- a/test/tbank-candles-persistence.e2e-spec.ts +++ b/test/tbank-candles-persistence.e2e-spec.ts @@ -1,14 +1,15 @@ +import { ConfigService } from '@nestjs/config'; import { setTimeout as delay } from 'node:timers/promises'; -import { CandleService } from '../src/modules/investments/instruments/application/candle.service'; + import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service'; import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; -import { ConfigService } from '@nestjs/config'; -import { PrismaService } from '../src/prisma/prisma.service'; -import { CandleRepository } from '../src/modules/investments/instruments/infrastructure/candle.repository'; +import { CandleService } from '../src/modules/investments/instruments/application/candle.service'; import { candleGaps, type CandleRange, } from '../src/modules/investments/instruments/domain/candle-coverage'; +import { CandleRepository } from '../src/modules/investments/instruments/infrastructure/candle.repository'; +import { PrismaService } from '../src/prisma/prisma.service'; describe('atomic shared candle reconciliation (SQLite)', () => { const prisma = new PrismaService( @@ -56,8 +57,9 @@ describe('atomic shared candle reconciliation (SQLite)', () => { ); async function settled() { for (let i = 0; i < 500; i++) { - if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))) + if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))) { return; + } await delay(5); } throw new Error('Candle work did not settle'); @@ -225,10 +227,11 @@ describe('atomic shared candle reconciliation (SQLite)', () => { string, Date, Date, - ][]) + ][]) { expect(call[3].valueOf() - call[2].valueOf()).toBeLessThanOrEqual( 365 * 86400000, ); + } await prisma.$disconnect(); const reopened = createService(); expect((await reopened.read('owner', input)).data?.candles).toEqual([]); @@ -353,13 +356,13 @@ describe('atomic shared candle reconciliation (SQLite)', () => { ...sources, ensureDetail: jest.fn().mockResolvedValue(history), }; - if (phase === 'before loading') + if (phase === 'before loading') { selectedSources.ensureDetail.mockImplementationOnce(async () => { entered(); await blocked; return history; }); - else { + } else { const publish = repository.publish.bind(repository); jest .spyOn(repository, 'publish') @@ -377,8 +380,9 @@ describe('atomic shared candle reconciliation (SQLite)', () => { Awaited> | undefined; upstream.getCandles.mockImplementation( async (_token, _uid, _from, to: Date) => { - if (laterFailure && forcedStates.length) + if (laterFailure && forcedStates.length) { throw new Error('Forced reconciliation second window unavailable'); + } if (to.valueOf() === forcedAt.valueOf()) { beforeForced = await repository.read('uid', fullRange); const work = await repository.work( @@ -424,12 +428,16 @@ describe('atomic shared candle reconciliation (SQLite)', () => { await settled(); if (laterFailure) { + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. expect(beforeForced).toBeDefined(); + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. expect(await repository.read('uid', fullRange)).toEqual(beforeForced); - } else + } else { + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. expect((await repository.read('uid', covered)).data).toEqual([ { ...old, close: '200.000000001' }, ]); + } expect(forcedStates).toEqual(['running']); expect( await repository.work(userId, 'revision', 'candles:uid:all'), @@ -442,7 +450,9 @@ describe('atomic shared candle reconciliation (SQLite)', () => { now: forcedAt.toISOString(), }, }); + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. if (userId === owner.userId) expect(forcedId).toBe(ordinaryId); + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. else expect(forcedId).not.toBe(ordinaryId); }, ); @@ -495,8 +505,9 @@ describe('atomic shared candle reconciliation (SQLite)', () => { { instrumentUid: 'not-supported', type: 'FUND' }, { instrumentUid: null, type: 'BOND' }, ]); - for (let i = 0; i < 100 && !upstream.getCandles.mock.calls.length; i++) + for (let i = 0; i < 100 && !upstream.getCandles.mock.calls.length; i++) { await delay(5); + } expect((upstream.getCandles.mock.calls as unknown[][])[0]?.[1]).toBe('uid'); await service.read('owner', { uid: 'opened', @@ -510,7 +521,7 @@ describe('atomic shared candle reconciliation (SQLite)', () => { ); expect( new Set(upstream.getCandles.mock.calls.map((call: unknown[]) => call[1])), - ).toEqual(new Set(['uid', 'opened'])); + ).toEqual(new Set(['opened', 'uid'])); const count = upstream.getCandles.mock.calls.length; await service.prefetch('owner', 'revision', [ { instrumentUid: 'uid', type: 'STOCK' }, diff --git a/test/tbank-catalog-discovery.e2e-spec.ts b/test/tbank-catalog-discovery.e2e-spec.ts index 0868b8e..d263717 100644 --- a/test/tbank-catalog-discovery.e2e-spec.ts +++ b/test/tbank-catalog-discovery.e2e-spec.ts @@ -1,7 +1,7 @@ -import assert from 'node:assert/strict'; import { ConfigService } from '@nestjs/config'; +import assert from 'node:assert/strict'; import { setTimeout as delay } from 'node:timers/promises'; -import { PrismaService } from '../src/prisma/prisma.service'; + import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service'; import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; import { CatalogDiscoveryService } from '../src/modules/investments/instruments/application/catalog-discovery.service'; @@ -10,6 +10,7 @@ import { type CatalogInstrument, type CatalogQuery, } from '../src/modules/investments/instruments/infrastructure/catalog-discovery.repository'; +import { PrismaService } from '../src/prisma/prisma.service'; // Real transactions prove query coverage, private work ownership and late-publication fences. describe('shared catalog discovery (SQLite)', () => { @@ -52,18 +53,20 @@ describe('shared catalog discovery (SQLite)', () => { }); async function settled() { for (let i = 0; i < 300; i++) { - if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))) + if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))) { return; + } await delay(5); } throw new Error('Catalog work did not settle'); } beforeAll(async () => { await prisma.$connect(); - for (const id of ['owner', 'other']) + for (const id of ['owner', 'other']) { await prisma.user.create({ data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' }, }); + } }); afterAll(async () => prisma.$disconnect()); beforeEach(async () => { @@ -199,7 +202,7 @@ describe('shared catalog discovery (SQLite)', () => { await service.read('other', query); await delay(10); await prisma.tbankConnection.delete({ where: { userId: 'owner' } }); - if (action === 'replace') + if (action === 'replace') { await prisma.tbankConnection.create({ data: { userId: 'owner', @@ -208,6 +211,7 @@ describe('shared catalog discovery (SQLite)', () => { connectedAt: new Date(), }, }); + } blocked.resolve([instrument]); await settled(); expect(await prisma.tbankCatalogDiscovery.count()).toBe(0); diff --git a/test/tbank-catalog-local.e2e-spec.ts b/test/tbank-catalog-local.e2e-spec.ts index 551ab71..611b42a 100644 --- a/test/tbank-catalog-local.e2e-spec.ts +++ b/test/tbank-catalog-local.e2e-spec.ts @@ -1,34 +1,33 @@ -import { MoexClient } from '../src/integrations/moex/moex.api'; -import { NcrRatingsClient } from '../src/integrations/ncr/ncr.api'; -import type { - StockCandlesResourceDto, - StockCompanyResourceDto, - StockQuoteResourceDto, -} from '../src/modules/investments/instruments/stocks/presentation/stock.dto'; -import type { - BondMetricsResourceDto, - BondQuoteResourceDto, -} from '../src/modules/investments/instruments/bonds/presentation/bond.dto'; -import { InstrumentMetadataService } from '../src/integrations/tbank/instrument-metadata.service'; -import assert from 'node:assert/strict'; +import type { Server } from 'node:http'; + import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; +import assert from 'node:assert/strict'; import { setTimeout as delay } from 'node:timers/promises'; -import type { Server } from 'node:http'; import request from 'supertest'; + import { AppModule } from '../src/app.module'; -import { configureApp } from '../src/main'; -import { PrismaService } from '../src/prisma/prisma.service'; -import { TbankClient } from '../src/integrations/tbank/tbank.client'; +import { MoexClient } from '../src/integrations/moex/moex.api'; +import { NcrRatingsClient } from '../src/integrations/ncr/ncr.api'; +import { InstrumentMetadataService } from '../src/integrations/tbank/instrument-metadata.service'; import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; +import { TbankClient } from '../src/integrations/tbank/tbank.client'; +import { configureApp } from '../src/main'; import { CatalogEnrichmentService } from '../src/modules/investments/instruments/application/catalog-enrichment.service'; +import { + type BondMetricsResourceDto, + type BondQuoteResourceDto, type BondSearchResourceDto, +} from '../src/modules/investments/instruments/bonds/presentation/bond.dto'; import { CatalogDiscoveryRepository } from '../src/modules/investments/instruments/infrastructure/catalog-discovery.repository'; -import type { - StockResourceDto, - StockSearchResourceDto, +import { + type StockCandlesResourceDto, + type StockCompanyResourceDto, + type StockQuoteResourceDto, + type StockResourceDto, + type StockSearchResourceDto, } from '../src/modules/investments/instruments/stocks/presentation/stock.dto'; -import type { BondSearchResourceDto } from '../src/modules/investments/instruments/bonds/presentation/bond.dto'; +import { PrismaService } from '../src/prisma/prisma.service'; // HTTP + disposable SQLite: missing/partial results, exact prices, shared source reuse and revision races. describe('local stock/bond search (SQLite HTTP)', () => { @@ -89,8 +88,9 @@ describe('local stock/bond search (SQLite HTTP)', () => { await delay(10); if ( !(await prisma.tbankSyncWork.count({ where: { state: 'running' } })) - ) + ) { return; + } } } throw new Error('Catalog sources did not settle'); @@ -109,10 +109,11 @@ describe('local stock/bond search (SQLite HTTP)', () => { await app.init(); await app.listen(0, '127.0.0.1'); prisma = app.get(PrismaService); - for (const id of ['owner', 'other']) + for (const id of ['owner', 'other']) { await prisma.user.create({ data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' }, }); + } }); beforeEach(async () => { await prisma.tbankConnection.deleteMany(); @@ -162,7 +163,7 @@ describe('local stock/bond search (SQLite HTTP)', () => { })), ), ); - for (const userId of ['owner', 'other']) + for (const userId of ['owner', 'other']) { await prisma.tbankConnection.create({ data: { userId, @@ -173,6 +174,7 @@ describe('local stock/bond search (SQLite HTTP)', () => { connectedAt: new Date(), }, }); + } }); afterAll(async () => app?.close()); @@ -187,8 +189,9 @@ describe('local stock/bond search (SQLite HTTP)', () => { expect(cold.data).toBeNull(); expect(cold.meta.availability).toBe('missing'); found.resolve([share]); - for (let i = 0; i < 100 && !upstream.getShare.mock.calls.length; i++) + for (let i = 0; i < 100 && !upstream.getShare.mock.calls.length; i++) { await delay(5); + } const partial = await read(); expect(partial.data?.[0].instrumentUid).toBe('share'); expect(partial.meta.availability).toBe('partial'); @@ -329,16 +332,18 @@ describe('local stock/bond search (SQLite HTTP)', () => { const detail = deferred(); upstream.getShare.mockReturnValueOnce(detail.promise); await read(false); - for (let i = 0; i < 100 && !upstream.getShare.mock.calls.length; i++) + for (let i = 0; i < 100 && !upstream.getShare.mock.calls.length; i++) { await delay(5); + } await get('stocks?query=OTHER', 'other').expect(200); for ( let i = 0; i < 100 && (await prisma.tbankSyncWork.count({ where: { userId: 'other' } })) < 3; i++ - ) + ) { await delay(5); + } detail.resolve({ ...share, currency: 'rub', @@ -364,10 +369,11 @@ describe('local stock/bond search (SQLite HTTP)', () => { const detail = deferred(); upstream.getShare.mockReturnValueOnce(detail.promise); await read(false); - for (let i = 0; i < 100 && !upstream.getShare.mock.calls.length; i++) + for (let i = 0; i < 100 && !upstream.getShare.mock.calls.length; i++) { await delay(5); + } await prisma.tbankConnection.delete({ where: { userId: 'owner' } }); - if (action === 'replace') + if (action === 'replace') { await prisma.tbankConnection.create({ data: { userId: 'owner', @@ -376,6 +382,7 @@ describe('local stock/bond search (SQLite HTTP)', () => { connectedAt: new Date(), }, }); + } detail.resolve(share); await settled(); expect( @@ -451,8 +458,9 @@ describe('local stock/bond search (SQLite HTTP)', () => { ]); upstream.getLastPrices.mockReturnValueOnce(prices.promise); await read(false); - for (let i = 0; i < 100 && !upstream.getLastPrices.mock.calls.length; i++) + for (let i = 0; i < 100 && !upstream.getLastPrices.mock.calls.length; i++) { await delay(5); + } const initiating = await prisma.tbankSyncWork.findFirstOrThrow({ where: { userId: 'owner', scopeKey: { contains: 'last-price' } }, orderBy: { createdAt: 'asc' }, @@ -575,8 +583,9 @@ describe('local stock/bond search (SQLite HTTP)', () => { let i = 0; i < 100 && !upstream.getAssetDescription.mock.calls.length; i++ - ) + ) { await delay(5); + } const partial = ( await get(`stocks/${uid}/company?sync=observe`).expect(200) ).body as StockCompanyResourceDto; @@ -628,8 +637,9 @@ describe('local stock/bond search (SQLite HTTP)', () => { const cold = (await get(`stocks/${uid}/quote`).expect(200)) .body as StockQuoteResourceDto; expect(cold.meta.availability).toBe('missing'); - for (let i = 0; i < 100 && !upstream.getLastPrices.mock.calls.length; i++) + for (let i = 0; i < 100 && !upstream.getLastPrices.mock.calls.length; i++) { await delay(5); + } const pending = (await get(`stocks/${uid}/quote?sync=observe`).expect(200)) .body as StockQuoteResourceDto; expect(pending.data?.price).toBeNull(); @@ -858,8 +868,9 @@ describe('local stock/bond search (SQLite HTTP)', () => { let i = 0; i < 100 && !upstream.getAssetDescription.mock.calls.length; i++ - ) + ) { await delay(5); + } await prisma.tbankConnection.delete({ where: { userId: 'owner' } }); company.resolve('Late company'); await settled(); diff --git a/test/tbank-connection.e2e-spec.ts b/test/tbank-connection.e2e-spec.ts index c23e12f..f85298d 100644 --- a/test/tbank-connection.e2e-spec.ts +++ b/test/tbank-connection.e2e-spec.ts @@ -1,14 +1,16 @@ +import type { Server } from 'node:http'; + import { INestApplication, NotFoundException } 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 { TbankAccessService } from '../src/integrations/tbank/tbank-access.service'; +import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; +import { TbankClient } from '../src/integrations/tbank/tbank.client'; import { configureApp } from '../src/main'; import { PrismaService } from '../src/prisma/prisma.service'; -import { TbankClient } from '../src/integrations/tbank/tbank.client'; -import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; -import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service'; describe('Persistent T-Bank connection boundary (SQLite/HTTP)', () => { let app: INestApplication; @@ -35,10 +37,11 @@ describe('Persistent T-Bank connection boundary (SQLite/HTTP)', () => { await app.init(); await app.listen(0, '127.0.0.1'); prisma = app.get(PrismaService); - for (const id of ['owner', 'other']) + for (const id of ['owner', 'other']) { await prisma.user.create({ data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' }, }); + } }); beforeEach(async () => { await prisma.tbankConnection.deleteMany(); @@ -57,7 +60,7 @@ describe('Persistent T-Bank connection boundary (SQLite/HTTP)', () => { .expect(401); expect(upstream.validateToken).not.toHaveBeenCalled(); const response = await put(' synthetic-token ').expect(200); - expect(Object.keys(response.body as object).sort()).toEqual([ + expect(Object.keys(response.body as object).sort((a, b) => a.localeCompare(b))).toEqual([ 'connected', 'connectedAt', ]); @@ -145,11 +148,12 @@ describe('Persistent T-Bank connection boundary (SQLite/HTTP)', () => { await expect( prisma.tbankOperation.create({ data: operation }), ).rejects.toMatchObject({ code: 'P2003' }); - for (let index = 0; index < 2; index += 1) + for (let index = 0; index < 2; index += 1) { await request(app.getHttpServer()) .delete('/api/v1/tbank-connection') .set('Authorization', `Bearer ${jwt('owner')}`) .expect(204); + } await expect( prisma.tbankOperation.create({ data: { ...operation, revision: replacement.revision }, diff --git a/test/tbank-income-local.e2e-spec.ts b/test/tbank-income-local.e2e-spec.ts index 8a2f71a..9c08e50 100644 --- a/test/tbank-income-local.e2e-spec.ts +++ b/test/tbank-income-local.e2e-spec.ts @@ -1,19 +1,21 @@ -import assert from 'node:assert/strict'; +import type { Server } from 'node:http'; + import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; -import type { Server } from 'node:http'; +import assert from 'node:assert/strict'; import request from 'supertest'; + import { AppModule } from '../src/app.module'; -import { configureApp } from '../src/main'; -import { PrismaService } from '../src/prisma/prisma.service'; import { TbankClient } from '../src/integrations/tbank/tbank.client'; -import { moscowDate } from '../src/modules/investments/investment-calendar'; +import { configureApp } from '../src/main'; import { IncomeAnalyticsResourceDto, IncomeSummaryResourceDto, } from '../src/modules/investments/income/income-analytics.dto'; +import { moscowDate } from '../src/modules/investments/investment-calendar'; import { BrokerageOperationsRepository } from '../src/modules/investments/operations/infrastructure/brokerage-operations.repository'; +import { PrismaService } from '../src/prisma/prisma.service'; // Financial proof uses real persisted rows/coverage and the configured HTTP boundary. describe('local income completeness and exact money (SQLite)', () => { @@ -102,7 +104,7 @@ describe('local income completeness and exact money (SQLite)', () => { ['same', '10', 1, 'rub', 'OPERATION_TYPE_COUPON'], ['negative', '-2', -500000000, 'rub', 'OPERATION_TYPE_DIVIDEND'], ['usd', '3', 0, 'usd', 'OPERATION_TYPE_COUPON'], - ] as const) + ] as const) { await prisma.tbankOperation.create({ data: { userId: 'income-owner', @@ -119,6 +121,7 @@ describe('local income completeness and exact money (SQLite)', () => { }, }, }); + } } }); it('returns exact selected sums without a false zero comparison; reopen and observe need no RPC', async () => { diff --git a/test/tbank-ledger-local.e2e-spec.ts b/test/tbank-ledger-local.e2e-spec.ts index e2fb2dd..00e0036 100644 --- a/test/tbank-ledger-local.e2e-spec.ts +++ b/test/tbank-ledger-local.e2e-spec.ts @@ -1,18 +1,21 @@ -import assert from 'node:assert/strict'; +import type { Server } from 'node:http'; + import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; +import assert from 'node:assert/strict'; import { setTimeout as delay } from 'node:timers/promises'; -import type { Server } from 'node:http'; import request from 'supertest'; + +import type { IncomeAnalyticsResourceDto } from '../src/modules/investments/income/income-analytics.dto'; + import { AppModule } from '../src/app.module'; -import { configureApp } from '../src/main'; -import { PrismaService } from '../src/prisma/prisma.service'; import { InstrumentMetadataRepository } from '../src/integrations/tbank/instrument-metadata.repository'; import { TbankClient } from '../src/integrations/tbank/tbank.client'; -import { BrokerageOperationsResourceDto } from '../src/modules/investments/operations/presentation/brokerage-operation.dto'; +import { configureApp } from '../src/main'; import { BrokerageOperationsRepository } from '../src/modules/investments/operations/infrastructure/brokerage-operations.repository'; -import type { IncomeAnalyticsResourceDto } from '../src/modules/investments/income/income-analytics.dto'; +import { BrokerageOperationsResourceDto } from '../src/modules/investments/operations/presentation/brokerage-operation.dto'; +import { PrismaService } from '../src/prisma/prisma.service'; const uid = '11111111-1111-4111-8111-111111111111'; const period = 'from=2025-01-01&to=2025-01-31'; @@ -48,10 +51,11 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { await app.init(); await app.listen(0, '127.0.0.1'); prisma = app.get(PrismaService); - for (const id of ['owner', 'other']) + for (const id of ['owner', 'other']) { await prisma.user.create({ data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' }, }); + } }); afterAll(async () => app?.close()); beforeEach(async () => { @@ -84,7 +88,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { fetchedAt: new Date(), }, }); - for (const id of ['1', '2', '3']) + for (const id of ['1', '2', '3']) { await prisma.tbankOperation.create({ data: { userId: 'owner', @@ -103,10 +107,11 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { }, }, }); + } } }); it('reads ordered pages with identical IDs/timestamps across accounts and no upstream RPC, including after reopen', async () => { - let cursor: string | null = null; + let cursor: null | string = null; const ids: string[] = []; do { const response = await get( @@ -186,7 +191,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { })), ).toEqual(expected); const items: { id: string; occurredAt: string }[] = []; - let cursor: string | null = null; + let cursor: null | string = null; do { const response = await request(app.getHttpServer()) .get( @@ -235,9 +240,9 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { data: { occurredAt: new Date(observedAt.valueOf() - 1000) }, }); const query = - periodKind === 'default' - ? '' - : `&to=${observedAt.toISOString().slice(0, 10)}${periodKind === 'today timestamp' ? 'T23:59:59.999Z' : ''}`; + periodKind === 'default' ? + '' : + `&to=${observedAt.toISOString().slice(0, 10)}${periodKind === 'today timestamp' ? 'T23:59:59.999Z' : ''}`; const page = (cursor?: string, observe = false) => request(app.getHttpServer()) .get( @@ -418,10 +423,12 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { expect(cold.meta.availability).toBe('missing'); await get('sync=observe', 'a').expect(200); expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1); + // eslint-disable-next-line sonarjs/no-extra-arguments -- The typed callback is assigned the promise resolver before invocation. unblock({ hasNext: false, items: [] }); for (let index = 0; index < 300; index += 1) { - if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))) + if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))) { break; + } await delay(5); } const count = upstream.getOperationsByCursor.mock.calls.length; diff --git a/test/tbank-local-reads.e2e-spec.ts b/test/tbank-local-reads.e2e-spec.ts index be20a87..3536b66 100644 --- a/test/tbank-local-reads.e2e-spec.ts +++ b/test/tbank-local-reads.e2e-spec.ts @@ -1,20 +1,22 @@ +import type { Server } from 'node:http'; + import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; import { setTimeout as delay } from 'node:timers/promises'; -import type { Server } from 'node:http'; import request from 'supertest'; + import { AppModule } from '../src/app.module'; -import { configureApp } from '../src/main'; -import { PrismaService } from '../src/prisma/prisma.service'; +import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; import { - TbankClient, type TbankAccount, + TbankClient, type TbankPortfolio, } from '../src/integrations/tbank/tbank.client'; -import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; -import { PortfolioResourceDto } from '../src/modules/investments/portfolio/presentation/portfolio.dto'; +import { configureApp } from '../src/main'; import { BrokerageAccountsResourceDto } from '../src/modules/investments/brokerage-accounts/brokerage-account.dto'; +import { PortfolioResourceDto } from '../src/modules/investments/portfolio/presentation/portfolio.dto'; +import { PrismaService } from '../src/prisma/prisma.service'; function deferred() { let resolve: (value: T) => void = () => { @@ -72,10 +74,11 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { await app.init(); await app.listen(0, '127.0.0.1'); prisma = app.get(PrismaService); - for (const id of ['owner', 'other']) + for (const id of ['owner', 'other']) { await prisma.user.create({ data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' }, }); + } }); beforeEach(async () => { await prisma.tbankConnection.deleteMany(); @@ -336,9 +339,9 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { ]); await put().expect(200); upstream.getPortfolio.mockImplementation((_token, id) => - id === 'second' - ? Promise.reject(new Error('synthetic unavailable')) - : Promise.resolve({ + id === 'second' ? + Promise.reject(new Error('synthetic unavailable')) : + Promise.resolve({ positions: [ { instrumentUid: uid, @@ -351,8 +354,9 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { ); await get('brokerage-accounts').expect(200); for (let index = 0; index < 200; index += 1) { - if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))) + if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))) { break; + } await delay(5); } const summaries = (await get('brokerage-accounts?sync=observe').expect(200)) diff --git a/test/tbank-metadata-persistence.e2e-spec.ts b/test/tbank-metadata-persistence.e2e-spec.ts index fba3938..ef509bf 100644 --- a/test/tbank-metadata-persistence.e2e-spec.ts +++ b/test/tbank-metadata-persistence.e2e-spec.ts @@ -1,15 +1,17 @@ -import assert from 'node:assert/strict'; import { ConfigService } from '@nestjs/config'; +import assert from 'node:assert/strict'; import { setTimeout as delay } from 'node:timers/promises'; -import { PrismaService } from '../src/prisma/prisma.service'; -import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service'; -import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; -import { InstrumentMetadataService } from '../src/integrations/tbank/instrument-metadata.service'; + +import type { TbankInstrument } from '../src/integrations/tbank/tbank.client'; + import { InstrumentMetadataRepository, metadataKey, } from '../src/integrations/tbank/instrument-metadata.repository'; -import type { TbankInstrument } from '../src/integrations/tbank/tbank.client'; +import { InstrumentMetadataService } from '../src/integrations/tbank/instrument-metadata.service'; +import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service'; +import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; +import { PrismaService } from '../src/prisma/prisma.service'; // Shared publication is proven against real FK/transaction/connection boundaries. describe('durable shared instrument metadata (SQLite)', () => { @@ -55,18 +57,20 @@ describe('durable shared instrument metadata (SQLite)', () => { }); async function settled() { for (let index = 0; index < 300; index++) { - if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))) + if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))) { return; + } await delay(5); } throw new Error('Metadata work did not settle'); } beforeAll(async () => { await prisma.$connect(); - for (const id of ['owner', 'other']) + for (const id of ['owner', 'other']) { await prisma.user.create({ data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' }, }); + } }); afterAll(async () => prisma.$disconnect()); beforeEach(async () => { @@ -152,7 +156,7 @@ describe('durable shared instrument metadata (SQLite)', () => { await delay(10); await service.read('other', identity); await prisma.tbankConnection.delete({ where: { userId: 'owner' } }); - if (action === 'replace') + if (action === 'replace') { await prisma.tbankConnection.create({ data: { userId: 'owner', @@ -161,6 +165,7 @@ describe('durable shared instrument metadata (SQLite)', () => { connectedAt: new Date(), }, }); + } blocked.resolve(known); await settled(); expect(await prisma.tbankInstrument.count()).toBe(0); @@ -346,9 +351,9 @@ describe('durable shared instrument metadata (SQLite)', () => { await service.read('owner', identity); await settled(); const identities = Array.from({ length: 1000 }, (_, index) => - index - ? { instrumentUid: `uid-${index}`, figi: `FIGI-${index}` } - : identity, + index ? + { instrumentUid: `uid-${index}`, figi: `FIGI-${index}` } : + identity, ); const saved = await service.savedMany(identities); expect(saved.size).toBe(1000); diff --git a/test/tbank-migration.e2e-spec.ts b/test/tbank-migration.e2e-spec.ts index db674e9..5f081dd 100644 --- a/test/tbank-migration.e2e-spec.ts +++ b/test/tbank-migration.e2e-spec.ts @@ -1,7 +1,9 @@ -import { readFileSync, readdirSync } from 'node:fs'; -import { join } from 'node:path'; -import { DatabaseSync } from 'node:sqlite'; import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +// eslint-disable-next-line n/no-unsupported-features/node-builtins -- The migration test intentionally uses node:sqlite from the pinned Node 24 runtime. +import { DatabaseSync } from 'node:sqlite'; + import { PrismaClient } from '../generated/prisma/client'; const migration = '20260924170000_persist_tbank_data'; @@ -25,8 +27,10 @@ describe('T-Bank additive migration on real SQLite', () => { db.exec('PRAGMA foreign_keys=ON'); for (const name of readdirSync(migrations) .filter((name) => name < migration && /^\d/.test(name)) - .sort()) + .sort((a, b) => a.localeCompare(b))) { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths are under an exclusive mkdtemp directory or the local migrations directory. db.exec(readFileSync(join(migrations, name, 'migration.sql'), 'utf8')); + } if (populated) { db.exec(` INSERT INTO User (id,email,passwordHash,createdAt,updatedAt) VALUES @@ -90,7 +94,7 @@ describe('T-Bank additive migration on real SQLite', () => { adapter: new PrismaBetterSqlite3({ url: `file:${file}` }), }); try { - if (!populated) + if (!populated) { await prisma.user.create({ data: { id: 'owner', @@ -104,6 +108,7 @@ describe('T-Bank additive migration on real SQLite', () => { }, }, }); + } const connection = await prisma.tbankConnection.findUniqueOrThrow({ where: { userId: 'owner' }, }); diff --git a/test/tbank-operations-persistence.e2e-spec.ts b/test/tbank-operations-persistence.e2e-spec.ts index 507b3b5..4beff9f 100644 --- a/test/tbank-operations-persistence.e2e-spec.ts +++ b/test/tbank-operations-persistence.e2e-spec.ts @@ -1,6 +1,7 @@ import { ConfigService } from '@nestjs/config'; -import { PrismaService } from '../src/prisma/prisma.service'; + import { BrokerageOperationsRepository } from '../src/modules/investments/operations/infrastructure/brokerage-operations.repository'; +import { PrismaService } from '../src/prisma/prisma.service'; // Real transactions are the boundary: staged pages never become financial // results, and a failed multi-account publication must preserve every old row. @@ -106,11 +107,12 @@ describe('Operations staging and reconciliation (isolated SQLite)', () => { } })(), ]); - for (const snapshot of snapshots) + for (const snapshot of snapshots) { expect([ ['10', '10'], ['20', '20'], ]).toContainEqual(snapshot); + } await reader.$disconnect(); await reader.$connect(); expect( diff --git a/test/tbank-operations-sync.e2e-spec.ts b/test/tbank-operations-sync.e2e-spec.ts index ac70f75..0937213 100644 --- a/test/tbank-operations-sync.e2e-spec.ts +++ b/test/tbank-operations-sync.e2e-spec.ts @@ -3,15 +3,17 @@ import { fork } from 'node:child_process'; import { once } from 'node:events'; import { join } from 'node:path'; import { setTimeout as delay } from 'node:timers/promises'; -import { PrismaService } from '../src/prisma/prisma.service'; -import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service'; + import type { TbankOperationsByCursorRequest, TbankOperationsByCursorResponse, } from '../src/integrations/tbank/tbank.client'; + +import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service'; import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; -import { BrokerageOperationsRepository } from '../src/modules/investments/operations/infrastructure/brokerage-operations.repository'; import { BrokerageOperationsSyncService } from '../src/modules/investments/operations/application/brokerage-operations-sync.service'; +import { BrokerageOperationsRepository } from '../src/modules/investments/operations/infrastructure/brokerage-operations.repository'; +import { PrismaService } from '../src/prisma/prisma.service'; function deferred() { let resolve: (value: T) => void = () => { @@ -154,11 +156,12 @@ describe('Request-driven operations synchronization (SQLite)', () => { upstream.getOperationsByCursor .mockReturnValueOnce(gate.promise) .mockImplementation((_token, request) => { - if (Number(request.from?.seconds) + 1 === march.from.valueOf() / 1000) + if (Number(request.from?.seconds) + 1 === march.from.valueOf() / 1000) { return Promise.resolve({ hasNext: false, items: [operation('20', '1773000000')], }); + } return Promise.reject(new Error('unrelated history unavailable')); }); const history = requiredHandle( @@ -332,8 +335,9 @@ describe('Request-driven operations synchronization (SQLite)', () => { if ( failFebruary && request.from?.seconds === String(february.from.valueOf() / 1000 - 1) - ) + ) { return Promise.reject(new Error('Second month unavailable')); + } return Promise.resolve({ hasNext: false, items: [ @@ -431,22 +435,29 @@ describe('Request-driven operations synchronization (SQLite)', () => { await delay(10); expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); if (checkpoint === 'after-publish') { + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. expect(await prisma.tbankOperation.count()).toBe(1); + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. expect(await prisma.tbankSyncStage.count()).toBe(0); } else { + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. expect(await prisma.tbankOperation.count()).toBe(0); + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. expect(await prisma.tbankSyncStage.count()).toBeGreaterThan(0); upstream.getOperationsByCursor.mockResolvedValueOnce(complete); await service.request('owner', [january], true); await state(handle, 'complete'); + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. expect( upstream.getOperationsByCursor.mock.calls[0][1].cursor, ).toBeUndefined(); + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. expect(await prisma.tbankOperation.count()).toBe(1); } } finally { - if (child.exitCode === null && child.signalCode === null) + if (child.exitCode === null && child.signalCode === null) { child.kill('SIGKILL'); + } await exited; } }, @@ -492,8 +503,9 @@ describe('Request-driven operations synchronization (SQLite)', () => { let index = 0; index < 500 && upstream.getOperationsByCursor.mock.calls.length < 2; index += 1 - ) + ) { await delay(2); + } expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(2); expect(upstream.getOperationsByCursor.mock.calls[1][1].from).toEqual({ seconds: String(early.to.valueOf() / 1000 - 1), @@ -507,8 +519,8 @@ describe('Request-driven operations synchronization (SQLite)', () => { it('lets new screen work overtake history between provider pages', async () => { const first = deferred<{ hasNext: boolean; - nextCursor: string; items: never[]; + nextCursor: string; }>(); upstream.getOperationsByCursor .mockReturnValueOnce(first.promise) @@ -542,8 +554,9 @@ describe('Request-driven operations synchronization (SQLite)', () => { let index = 0; index < 500 && upstream.getOperationsByCursor.mock.calls.length < 2; index += 1 - ) + ) { await delay(2); + } expect(await prisma.tbankOperation.count()).toBe(1); const before = await prisma.tbankOperationCoverage.findMany(); expect(before).toHaveLength(1); @@ -675,8 +688,9 @@ describe('Request-driven operations synchronization (SQLite)', () => { let index = 0; index < 500 && upstream.getOperationsByCursor.mock.calls.length < 2; index += 1 - ) + ) { await delay(2); + } expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(2); const before = await prisma.tbankSyncWork.findUniqueOrThrow({ where: { id: handle }, @@ -708,7 +722,7 @@ describe('Request-driven operations synchronization (SQLite)', () => { }); }); -function requiredHandle(value: string | null): string { +function requiredHandle(value: null | string): string { if (!value) throw new Error('Expected sync handle'); return value; } diff --git a/test/tbank-read-baseline.e2e-spec.ts b/test/tbank-read-baseline.e2e-spec.ts index 56cf046..5a16f08 100644 --- a/test/tbank-read-baseline.e2e-spec.ts +++ b/test/tbank-read-baseline.e2e-spec.ts @@ -1,23 +1,29 @@ +import type { NextFunction, Request, Response } from 'express'; +import type { Server } from 'node:http'; + import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; -import type { Server } from 'node:http'; -import { cpus, totalmem } from 'node:os'; -import { writeFileSync, statSync, readFileSync } from 'node:fs'; -import { setTimeout as delay } from 'node:timers/promises'; -import type { Request, Response, NextFunction } from 'express'; +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { cpus, tmpdir, totalmem } from 'node:os'; +import { join } from 'node:path'; import { performance } from 'node:perf_hooks'; +import { setTimeout as delay } from 'node:timers/promises'; import request from 'supertest'; + import { AppModule } from '../src/app.module'; +import { MoexClient } from '../src/integrations/moex/moex.api'; +import { NcrRatingsClient } from '../src/integrations/ncr/ncr.api'; +import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; import { TbankClient, TbankOperationsByCursorRequest, } from '../src/integrations/tbank/tbank.client'; -import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; import { configureApp } from '../src/main'; import { PrismaService } from '../src/prisma/prisma.service'; -import { MoexClient } from '../src/integrations/moex/moex.api'; -import { NcrRatingsClient } from '../src/integrations/ncr/ncr.api'; + +const evidenceDirectory = mkdtempSync(join(tmpdir(), 'wealthledger-read-evidence-')); // Same navigation and synthetic sources as the saved baseline; isolated SQLite only. describe('T-Bank persisted read budget', () => { @@ -52,9 +58,9 @@ describe('T-Bank persisted read budget', () => { const operations = Array.from({ length: 100_000 }, (_, index) => { // 1,000 measured-month rows, 200 income receipts, remaining rows over 10 years. const time = - index < 1000 - ? Date.UTC(2026, 7, 1) + index * 2_500_000 - : Date.UTC(2016, 8, 1) + (index - 1000) * 3_160_000; + index < 1000 ? + Date.UTC(2026, 7, 1) + index * 2_500_000 : + Date.UTC(2016, 8, 1) + (index - 1000) * 3_160_000; const instrument = instruments[index % instruments.length]; return { id: `operation-${index}`, @@ -69,15 +75,17 @@ describe('T-Bank persisted read budget', () => { }; }).sort((a, b) => Number(b.date.seconds) - Number(a.date.seconds)); const rpc: Record = {}; - let upstreamMode: 'ready' | 'delayed' | 'failed' = 'ready'; + let upstreamMode: 'delayed' | 'failed' | 'ready' = 'ready'; let sourceGate: Promise | undefined; let releaseSource = () => {}; const serverTimes: number[] = []; async function counted(name: string, value: T): Promise { + // eslint-disable-next-line security/detect-object-injection -- Keys come from local test fixtures or numeric iteration, not external input. rpc[name] = (rpc[name] ?? 0) + 1; if (upstreamMode === 'delayed') await sourceGate; - if (upstreamMode === 'failed') + if (upstreamMode === 'failed') { return Promise.reject(new Error('Synthetic outage')); + } return Promise.resolve(value); } const upstream = { @@ -261,7 +269,7 @@ describe('T-Bank persisted read budget', () => { historyBoundary: 'openedOn', }, }); - for (let offset = 0; offset < operations.length; offset += 1000) + for (let offset = 0; offset < operations.length; offset += 1000) { await prisma.tbankOperation.createMany({ data: operations.slice(offset, offset + 1000).map((row) => ({ ...scope, @@ -272,6 +280,7 @@ describe('T-Bank persisted read budget', () => { payload: row, })), }); + } await prisma.tbankOperationCoverage.create({ data: { ...scope, @@ -289,9 +298,9 @@ describe('T-Bank persisted read budget', () => { uid: instrument.uid, ticker: instrument.ticker, instrumentType: - instrument.instrumentKind === 'INSTRUMENT_TYPE_SHARE' - ? 'share' - : 'bond', + instrument.instrumentKind === 'INSTRUMENT_TYPE_SHARE' ? + 'share' : + 'bond', metadata: instrument, fetchedAt: now, }, @@ -329,8 +338,8 @@ describe('T-Bank persisted read budget', () => { ], }); } - expect(await prisma.tbankOperation.count()).toBe(300_000); - expect(await prisma.tbankCandle.count()).toBe(1_000_000); + assert.equal(await prisma.tbankOperation.count(), 300_000); + assert.equal(await prisma.tbankCandle.count(), 1_000_000); }, 300_000); afterAll(async () => app?.close()); @@ -368,8 +377,9 @@ describe('T-Bank persisted read budget', () => { !(await app .get(PrismaService) .tbankSyncWork.count({ where: { state: 'running' } })) - ) + ) { return; + } } throw new Error('Import did not settle'); } @@ -397,6 +407,7 @@ describe('T-Bank persisted read budget', () => { await fresh(); } const warmupRpc = { ...rpc }; + // eslint-disable-next-line security/detect-object-injection -- Keys come from local test fixtures or numeric iteration, not external input. for (const key of Object.keys(rpc)) rpc[key] = 0; await app.close(); await start(); @@ -411,21 +422,26 @@ describe('T-Bank persisted read budget', () => { .set('Authorization', `Bearer ${token}`) .expect(200); const body = response.body as { - meta: { availability: string }; data: unknown; + meta: { availability: string }; }; - if (route.includes('candles?period=all')) + if (route.includes('candles?period=all')) { + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. expect((body.data as { candles: unknown[] }).candles).toHaveLength( 5000, ); + } expect({ route, availability: body.meta.availability }).toEqual({ route, availability: 'complete', }); + // eslint-disable-next-line security/detect-object-injection -- Keys come from local test fixtures or numeric iteration, not external input. if (!responses[route]) { + // eslint-disable-next-line security/detect-object-injection -- Keys come from local test fixtures or numeric iteration, not external input. responses[route] = body; + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths are under an exclusive mkdtemp directory or the local migrations directory. writeFileSync( - '/tmp/wealthledger-browser-responses.json', + join(evidenceDirectory, 'browser-responses.json'), JSON.stringify(responses), ); } @@ -439,8 +455,9 @@ describe('T-Bank persisted read budget', () => { serverTimes.length = 0; const before = { ...rpc }; const samples = []; - for (let batch = 0; batch < 10; batch += 1) + for (let batch = 0; batch < 10; batch += 1) { samples.push(...(await Promise.all(Array.from({ length: 10 }, read)))); + } const clientTimes = samples .map((sample) => sample.ms) .sort((a, b) => a - b); @@ -459,6 +476,7 @@ describe('T-Bank persisted read budget', () => { rpc: Object.fromEntries( Object.entries(rpc).map(([key, count]) => [ key, + // eslint-disable-next-line security/detect-object-injection -- Keys come from local test fixtures or numeric iteration, not external input. count - (before[key] ?? 0), ]), ), @@ -469,13 +487,13 @@ describe('T-Bank persisted read budget', () => { runtime: process.version, hardware: { cpuQuota: - process.platform === 'linux' - ? readFileSync('/sys/fs/cgroup/cpu.max', 'utf8').trim() - : null, + process.platform === 'linux' ? + readFileSync('/sys/fs/cgroup/cpu.max', 'utf8').trim() : + null, memoryLimit: - process.platform === 'linux' - ? readFileSync('/sys/fs/cgroup/memory.max', 'utf8').trim() - : null, + process.platform === 'linux' ? + readFileSync('/sys/fs/cgroup/memory.max', 'utf8').trim() : + null, cpu: cpus()[0]?.model, cpus: cpus().length, memoryBytes: totalmem(), @@ -495,6 +513,7 @@ describe('T-Bank persisted read budget', () => { evidence, totalRpc: { ...rpc }, warmupRpc, + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds. databaseBytes: statSync( String(process.env.DATABASE_URL).replace('file:', ''), ).size, @@ -513,11 +532,12 @@ describe('T-Bank persisted read budget', () => { newRpc, reduction: 1 - newRpc / oldRpc, }; + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths are under an exclusive mkdtemp directory or the local migrations directory. writeFileSync( - '/tmp/wealthledger-persistent-budget.json', + join(evidenceDirectory, 'persistent-budget.json'), JSON.stringify(output, null, 2), ); - console.log(JSON.stringify(output)); + console.log(JSON.stringify({ ...output, evidenceDirectory })); // Hold the source until HTTP reads finish: proves independence without a // wall-clock race against the runner's throughput. The p95 gate stays below. await fresh(); @@ -572,8 +592,9 @@ describe('T-Bank persisted read budget', () => { staleLogicalPass: { portfolio: 1, positions: 1, retries: 0 }, coldImportMs, }); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths are under an exclusive mkdtemp directory or the local migrations directory. writeFileSync( - '/tmp/wealthledger-persistent-budget.json', + join(evidenceDirectory, 'persistent-budget.json'), JSON.stringify(output, null, 2), ); expect(newRpc).toBe(0); diff --git a/test/tbank-screen-sync.e2e-spec.ts b/test/tbank-screen-sync.e2e-spec.ts index 6c75d5b..f319d78 100644 --- a/test/tbank-screen-sync.e2e-spec.ts +++ b/test/tbank-screen-sync.e2e-spec.ts @@ -1,15 +1,17 @@ +import type { Server } from 'node:http'; + import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; import { setTimeout as delay } from 'node:timers/promises'; -import type { Server } from 'node:http'; import request from 'supertest'; + import { AppModule } from '../src/app.module'; -import { configureApp } from '../src/main'; -import { PrismaService } from '../src/prisma/prisma.service'; -import { TbankClient } from '../src/integrations/tbank/tbank.client'; import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; +import { TbankClient } from '../src/integrations/tbank/tbank.client'; +import { configureApp } from '../src/main'; import { TbankSyncStatusDto } from '../src/modules/investments/tbank-sync/tbank-sync.dto'; +import { PrismaService } from '../src/prisma/prisma.service'; describe('screen sync HTTP and SQLite', () => { let app: INestApplication; @@ -51,10 +53,11 @@ describe('screen sync HTTP and SQLite', () => { await app.init(); await app.listen(0, '127.0.0.1'); prisma = app.get(PrismaService); - for (const id of ['owner', 'other']) + for (const id of ['owner', 'other']) { await prisma.user.create({ data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' }, }); + } }); beforeEach(async () => { await prisma.tbankConnection.deleteMany(); @@ -62,7 +65,7 @@ describe('screen sync HTTP and SQLite', () => { upstream.getOpenAccounts.mockResolvedValue([]); upstream.getPortfolio.mockResolvedValue({ positions: [] }); upstream.getPositions.mockResolvedValue({ money: [] }); - for (const userId of ['owner', 'other']) + for (const userId of ['owner', 'other']) { await prisma.tbankConnection.create({ data: { userId, @@ -72,6 +75,7 @@ describe('screen sync HTTP and SQLite', () => { membershipRevision: 1, }, }); + } }); afterAll(async () => app.close()); it('accepts before blocked RPC finishes, joins identical requests, and observes without starting work', async () => { @@ -145,9 +149,9 @@ describe('screen sync HTTP and SQLite', () => { screen: 'stock', instrumentUid: '00000000-0000-4000-8000-000000000001', candleRange: { - from: '2026-02-01', - to: '2026-01-01', - jobs: [], + 'from': '2026-02-01', + 'to': '2026-01-01', + 'jobs': [], '~bad/path': 'SYNTHETIC_VALIDATION_SECRET', }, }, @@ -158,7 +162,7 @@ describe('screen sync HTTP and SQLite', () => { ); const problem = response.body as { code: string; - errors: { pointer: string; detail: string }[]; + errors: { detail: string; pointer: string }[]; }; expect(problem.code).toBe('VALIDATION_ERROR'); expect(problem.errors.length).toBeGreaterThan(0); @@ -167,15 +171,19 @@ describe('screen sync HTTP and SQLite', () => { expect(typeof error.detail).toBe('string'); } if ('candleRange' in scope) { + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. expect( problem.errors.filter( (error) => error.pointer === '#/candleRange/jobs', ), ).toHaveLength(1); + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. expect(problem.errors).toContainEqual({ pointer: '#/candleRange/~0bad~1path', + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. detail: expect.any(String) as unknown, }); + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. expect(JSON.stringify(response.body)).not.toContain( 'SYNTHETIC_VALIDATION_SECRET', ); @@ -350,9 +358,9 @@ describe('screen sync HTTP and SQLite', () => { }), ) .mockImplementation(() => - outcome === 'failed' - ? Promise.reject(new Error('Synthetic outage')) - : Promise.resolve({ hasNext: false, items: [] }), + outcome === 'failed' ? + Promise.reject(new Error('Synthetic outage')) : + Promise.resolve({ hasNext: false, items: [] }), ); const accept = async (from: string, to: string) => { const response = await post({ diff --git a/test/upcoming-events.e2e-spec.ts b/test/upcoming-events.e2e-spec.ts index 33c0f02..d19ee82 100644 --- a/test/upcoming-events.e2e-spec.ts +++ b/test/upcoming-events.e2e-spec.ts @@ -1,23 +1,26 @@ +import type { Server } from 'node:http'; + import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; -import type { Server } from 'node:http'; import { setTimeout as delay } from 'node:timers/promises'; import request from 'supertest'; + +import type { BondCouponsResourceDto } from '../src/modules/investments/instruments/bonds/presentation/bond.dto'; +import type { StockDividendsResourceDto } from '../src/modules/investments/instruments/stocks/presentation/stock.dto'; +import type { UpcomingEventsResourceDto } from '../src/modules/investments/upcoming-events/upcoming-events.dto'; + import { AppModule } from '../src/app.module'; -import { configureApp } from '../src/main'; -import { PrismaService } from '../src/prisma/prisma.service'; +import { MoexClient } from '../src/integrations/moex/moex.api'; import { TbankClient, - TbankTokenService, tbankSourceTtlMs, + TbankTokenService, } from '../src/integrations/tbank/tbank.api'; -import { MoexClient } from '../src/integrations/moex/moex.api'; +import { configureApp } from '../src/main'; import { CatalogEnrichmentService } from '../src/modules/investments/instruments/application/catalog-enrichment.service'; import { ForecastRepository } from '../src/modules/investments/portfolio/infrastructure/forecast.repository'; -import type { UpcomingEventsResourceDto } from '../src/modules/investments/upcoming-events/upcoming-events.dto'; -import type { StockDividendsResourceDto } from '../src/modules/investments/instruments/stocks/presentation/stock.dto'; -import type { BondCouponsResourceDto } from '../src/modules/investments/instruments/bonds/presentation/bond.dto'; +import { PrismaService } from '../src/prisma/prisma.service'; const shareUid = '00000000-0000-4000-8000-000000000001'; const bondUid = '00000000-0000-4000-8000-000000000002'; @@ -86,8 +89,9 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { await delay(10); if ( !(await prisma.tbankSyncWork.count({ where: { state: 'running' } })) - ) + ) { return; + } } } throw new Error('Forecast dependencies did not settle'); @@ -139,10 +143,11 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { await app.init(); await app.listen(0, '127.0.0.1'); prisma = app.get(PrismaService); - for (const id of ['owner', 'other']) + for (const id of ['owner', 'other']) { await prisma.user.create({ data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' }, }); + } }); beforeEach(async () => { await settled(); @@ -236,8 +241,9 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { const cold = (await events(false).expect(200)) .body as UpcomingEventsResourceDto; expect(cold.data).toBeNull(); - for (let i = 0; i < 100 && !upstream.getDividends.mock.calls.length; i++) + for (let i = 0; i < 100 && !upstream.getDividends.mock.calls.length; i++) { await delay(5); + } await events().expect(200); release([]); await settled(); @@ -290,9 +296,9 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { .body as UpcomingEventsResourceDto; await portfolio([position(shareUid, '7'), position(newUid, '5')]); upstream.getDividends.mockImplementation((_token: string, uid: string) => - uid === newUid - ? Promise.reject(new Error('new calendar unavailable')) - : Promise.resolve([]), + uid === newUid ? + Promise.reject(new Error('new calendar unavailable')) : + Promise.resolve([]), ); const pending = (await events(false).expect(200)) .body as UpcomingEventsResourceDto; @@ -450,12 +456,13 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { const path = `${kind}/${uid}/${payout}`; const read = async () => (await get(`${path}?sync=observe`).expect(200)).body as - StockDividendsResourceDto | BondCouponsResourceDto; + BondCouponsResourceDto | StockDividendsResourceDto; await get(path).expect(200); await settled(); const before = await read(); - if (!before.data?.positionAt) + if (!before.data?.positionAt) { throw new Error('Expected a saved position'); + } expect(before.data.positionIsStale).toBe(false); const saved = await prisma.tbankForecast.findFirstOrThrow(); const expiresAt = @@ -504,8 +511,9 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { } finally { publish.mockRestore(); } - for (const rpc of Object.values(upstream)) + for (const rpc of Object.values(upstream)) { expect(rpc).not.toHaveBeenCalled(); + } expect(moex.bondEvents).not.toHaveBeenCalled(); } finally { clock.mockRestore(); @@ -615,8 +623,9 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { }), ); await events(false).expect(200); - for (let i = 0; i < 100 && !upstream.getDividends.mock.calls.length; i++) + for (let i = 0; i < 100 && !upstream.getDividends.mock.calls.length; i++) { await delay(5); + } await prisma.tbankConnection.delete({ where: { userId: 'owner' } }); release([ { From 9cc9fbdcff91f1a58c78ab83eea5fc0bc80106c7 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Mon, 28 Sep 2026 11:24:59 +0300 Subject: [PATCH 04/22] refactor: replace ignored numeric literals with constants --- .ai/architecture.md | 9 +-- eslint.config.mjs | 24 -------- src/common/decimal.ts | 58 ++++++++++++------- src/common/time.ts | 2 + src/config/env.validation.ts | 7 ++- src/integrations/moex/moex.client.ts | 16 +++-- src/integrations/ncr/ncr-ratings.client.ts | 11 +++- src/integrations/tbank/decimal.ts | 4 +- .../tbank/instrument-metadata.repository.ts | 19 +++--- .../tbank/tbank-access.service.ts | 8 ++- .../tbank/tbank-account.policy.ts | 4 +- src/integrations/tbank/tbank.client.ts | 21 ++++--- src/integrations/tbank/tbank.crypto.ts | 8 ++- src/modules/auth/application/auth.service.ts | 4 +- src/modules/auth/presentation/auth.dto.ts | 9 ++- .../bank-cards/application/bank-card-input.ts | 8 ++- .../application/bank-cards.service.ts | 5 +- .../application/bank-operations.service.ts | 24 ++++---- .../application/expense-categories.service.ts | 8 ++- .../bank-cards/domain/bank-card-limits.ts | 7 +++ .../domain/bank-card.calculations.ts | 20 ++++--- .../banking/bank-cards/domain/rub-money.ts | 14 +++-- .../infrastructure/bank-cards.repository.ts | 8 ++- .../bank-cards/presentation/bank-card.dto.ts | 32 +++++----- .../income/income-analytics.service.ts | 4 +- .../investments/income/income-period.ts | 34 ++++++----- src/modules/investments/instrument-query.ts | 2 + .../instruments/application/candle.service.ts | 15 +++-- .../application/catalog-discovery.service.ts | 14 +++-- .../application/catalog-enrichment.service.ts | 10 +++- .../application/instrument-market.ts | 10 ++-- .../application/instrument-position.ts | 12 ++-- .../bonds/application/bond-catalog.service.ts | 9 ++- .../bonds/application/bond-income.service.ts | 29 ++++++---- .../bonds/application/bond-market.service.ts | 7 ++- .../instruments/domain/candle-coverage.ts | 4 +- .../infrastructure/candle.repository.ts | 4 +- .../catalog-discovery.repository.ts | 21 ++++--- .../application/stock-catalog.service.ts | 4 +- .../application/stock-income.service.ts | 17 ++++-- .../application/stock-market.service.ts | 7 ++- .../stocks/presentation/stock.dto.ts | 14 +++-- .../investments/investment-calendar.ts | 13 +++-- .../investments/investment-sync-plan.ts | 15 +++-- .../brokerage-operations-sync.service.ts | 12 ++-- .../brokerage-operations.service.ts | 31 ++++++---- .../operations/domain/operations-sync-plan.ts | 20 +++++-- .../brokerage-operation.mapper.ts | 6 +- .../brokerage-operations.repository.ts | 39 ++++++++----- .../presentation/brokerage-operation.dto.ts | 6 +- .../portfolio/application/forecast.service.ts | 7 ++- .../application/portfolio.service.ts | 6 +- .../portfolio/domain/portfolio-structure.ts | 16 +++-- .../infrastructure/forecast.repository.ts | 4 +- .../infrastructure/portfolio.repository.ts | 4 +- .../investments/tbank-sync/tbank-screen.ts | 7 ++- .../investments/tbank-sync/tbank-sync.dto.ts | 15 +++-- .../tbank-sync/tbank-sync.service.ts | 16 +++-- .../upcoming-events.service.ts | 13 +++-- test/eslint-config.cjs | 16 +++++ 60 files changed, 510 insertions(+), 283 deletions(-) create mode 100644 src/modules/investments/instrument-query.ts diff --git a/.ai/architecture.md b/.ai/architecture.md index 892117b..6adccfb 100644 --- a/.ai/architecture.md +++ b/.ai/architecture.md @@ -246,10 +246,11 @@ OpenAPI enum metadata и runtime validation должны читать тот ж сохраняй; generated Prisma/protocol code вручную не меняй. `@typescript-eslint/no-magic-numbers` включён как error; базовое ESLint rule -отключено в пользу TS-версии. Исключения для очевидных чисел и их bigint форм, -индексов, default values, enum/type declarations и readonly properties заданы -в ESLint config. Test fixtures/expected values остаются inline, остальные lint -правила продолжают действовать. Numeric policy получает предметное имя и единицы; +отключено в пользу TS-версии. Общего списка `ignore` для чисел и bigint нет: +значения, проверяемые правилом, получают предметные имена у владельца. +Исключения для индексов, default values, enum/type declarations и readonly +properties заданы в ESLint config. Test fixtures/expected values остаются inline, +остальные lint правила продолжают действовать. Numeric policy получает предметное имя и единицы; semantic magic strings проверяются conventions/review без нового инструмента. ## Logging diff --git a/eslint.config.mjs b/eslint.config.mjs index 81c0330..02a5136 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -70,30 +70,6 @@ export default defineConfig( '@typescript-eslint/no-magic-numbers': [ 'error', { - ignore: [ - -60, - -24, - -10, - -2, - -1, - 0, - 1, - 2, - 10, - 24, - 60, - '-60n', - '-24n', - '-10n', - '-2n', - '-1n', - '0n', - '1n', - '2n', - '10n', - '24n', - '60n', - ], ignoreArrayIndexes: true, ignoreDefaultValues: true, ignoreEnums: true, diff --git a/src/common/decimal.ts b/src/common/decimal.ts index 9f969b6..b19a258 100644 --- a/src/common/decimal.ts +++ b/src/common/decimal.ts @@ -1,3 +1,19 @@ +export const DECIMAL_RADIX = 10n; + +const SIGN_LENGTH = 1; +const NEGATIVE_SIGN = -1n; +const POSITIVE_SIGN = 1n; +const ZERO_UNITS = 0n; +const EQUAL = 0; +const GREATER_THAN = 1; +const LESS_THAN = -1; +const INTEGER_SCALE = 0; +const MIN_WHOLE_DIGITS = 1; +const DIGITS_START = 0; +const PREVIOUS_DIGIT_OFFSET = 1; +const DIGIT_STEP = 1; +const ROUNDING_DIVISOR = 2n; + export type Decimal = { scale: number; value: bigint }; export function decimalFromString(value: string): Decimal { @@ -6,9 +22,9 @@ export function decimalFromString(value: string): Decimal { throw new Error('Invalid decimal string'); } const negative = value.startsWith('-'); - const [whole, fraction = ''] = (negative ? value.slice(1) : value).split('.'); + const [whole, fraction = ''] = (negative ? value.slice(SIGN_LENGTH) : value).split('.'); return { - value: (negative ? -1n : 1n) * BigInt(`${whole}${fraction}`), + value: (negative ? NEGATIVE_SIGN : POSITIVE_SIGN) * BigInt(`${whole}${fraction}`), scale: fraction.length, }; } @@ -17,8 +33,8 @@ export function add(left: Decimal, right: Decimal): Decimal { const scale = Math.max(left.scale, right.scale); return { value: - left.value * 10n ** BigInt(scale - left.scale) + - right.value * 10n ** BigInt(scale - right.scale), + left.value * DECIMAL_RADIX ** BigInt(scale - left.scale) + + right.value * DECIMAL_RADIX ** BigInt(scale - right.scale), scale, }; } @@ -32,37 +48,37 @@ export function multiply(left: Decimal, right: Decimal): Decimal { } export function isZero(value: Decimal): boolean { - return value.value === 0n; + return value.value === ZERO_UNITS; } export function compare(left: Decimal, right: Decimal): number { const difference = subtract(left, right).value; - return difference === 0n ? 0 : difference > 0n ? 1 : -1; + return difference === ZERO_UNITS ? EQUAL : difference > ZERO_UNITS ? GREATER_THAN : LESS_THAN; } export function formatDecimal({ value, scale }: Decimal): string { - if (value === 0n) return '0'; - const sign = value < 0n ? '-' : ''; - if (scale === 0) return `${sign}${value < 0n ? -value : value}`; - const digits = (value < 0n ? -value : value) + if (value === ZERO_UNITS) return '0'; + const sign = value < ZERO_UNITS ? '-' : ''; + if (scale === INTEGER_SCALE) return `${sign}${value < ZERO_UNITS ? -value : value}`; + const digits = (value < ZERO_UNITS ? -value : value) .toString() - .padStart(scale + 1, '0'); - const whole = digits.slice(0, -scale) || '0'; + .padStart(scale + MIN_WHOLE_DIGITS, '0'); + const whole = digits.slice(DIGITS_START, -scale) || '0'; const rawFraction = digits.slice(-scale); let fractionEnd = rawFraction.length; - while (rawFraction.charAt(fractionEnd - 1) === '0') fractionEnd -= 1; - const fraction = rawFraction.slice(0, fractionEnd); + while (rawFraction.charAt(fractionEnd - PREVIOUS_DIGIT_OFFSET) === '0') fractionEnd -= DIGIT_STEP; + const fraction = rawFraction.slice(DIGITS_START, fractionEnd); const fractionalPart = fraction ? '.' + fraction : ''; return `${sign}${whole}${fractionalPart}`; } // Round half away from zero; callers choose presentation precision explicitly. export function divide(left: Decimal, right: Decimal, scale: number): Decimal { - if (right.value === 0n) throw new Error('Division by zero'); - const numerator = left.value * 10n ** BigInt(right.scale + scale); - const denominator = right.value * 10n ** BigInt(left.scale); - const sign = (numerator < 0n) !== (denominator < 0n) ? -1n : 1n; - const n = numerator < 0n ? -numerator : numerator; - const d = denominator < 0n ? -denominator : denominator; - return { value: sign * ((n + d / 2n) / d), scale }; + if (right.value === ZERO_UNITS) throw new Error('Division by zero'); + const numerator = left.value * DECIMAL_RADIX ** BigInt(right.scale + scale); + const denominator = right.value * DECIMAL_RADIX ** BigInt(left.scale); + const sign = (numerator < ZERO_UNITS) !== (denominator < ZERO_UNITS) ? NEGATIVE_SIGN : POSITIVE_SIGN; + const n = numerator < ZERO_UNITS ? -numerator : numerator; + const d = denominator < ZERO_UNITS ? -denominator : denominator; + return { value: sign * ((n + d / ROUNDING_DIVISOR) / d), scale }; } diff --git a/src/common/time.ts b/src/common/time.ts index 48deb5b..ca5a945 100644 --- a/src/common/time.ts +++ b/src/common/time.ts @@ -1,2 +1,4 @@ +export const ISO_DATE_LENGTH = 10; +export const ISO_DATE_START = 0; export const MILLISECONDS_PER_SECOND = 1_000; export const MILLISECONDS_PER_DAY = 86_400_000; diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index d9c929b..ac378db 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -1,3 +1,6 @@ +const MIN_PORT = 1; +const MIN_REQUESTS_PER_SECOND = 1; + const DEFAULT_PORT = 3000; const DEFAULT_TBANK_RATE_LIMIT_PER_SECOND = 5; const MAX_PORT = 65_535; @@ -56,7 +59,7 @@ export function validateEnv(config: Record): Environment { const parsedPort = Number(port); if ( !Number.isInteger(parsedPort) || - parsedPort < 1 || + parsedPort < MIN_PORT || parsedPort > MAX_PORT ) { throw new Error('PORT must be a valid port number'); @@ -84,7 +87,7 @@ export function validateEnv(config: Record): Environment { } const parsedTbankRateLimit = Number(tbankRateLimit); - if (!Number.isInteger(parsedTbankRateLimit) || parsedTbankRateLimit < 1) { + if (!Number.isInteger(parsedTbankRateLimit) || parsedTbankRateLimit < MIN_REQUESTS_PER_SECOND) { throw new Error('TBANK_RATE_LIMIT_PER_SECOND must be a positive integer'); } diff --git a/src/integrations/moex/moex.client.ts b/src/integrations/moex/moex.client.ts index eb6fc31..5b5d561 100644 --- a/src/integrations/moex/moex.client.ts +++ b/src/integrations/moex/moex.client.ts @@ -1,6 +1,12 @@ import { Injectable, ServiceUnavailableException } from '@nestjs/common'; import { constants as httpStatus } from 'node:http2'; +const MIN_PRICE_PERCENT_EXCLUSIVE = 0; +const FIRST_PAGE_OFFSET = 0; +const EXPECTED_CURSOR_ROWS = 1; +const EMPTY_PAGE_SIZE = 0; +const MIN_CURSOR_VALUE = 0; + const FALLBACK_PAGE_SIZE = 100; const baseUrl = 'https://iss.moex.com/iss'; @@ -52,7 +58,7 @@ export class MoexClient { if ( value === null || pricePercent === null || - Number(pricePercent) <= 0 || + Number(pricePercent) <= MIN_PRICE_PERCENT_EXCLUSIVE || !date || !/^\d{4}-\d{2}-\d{2}$/.test(date) || !Number.isFinite(Date.parse(date)) || @@ -114,7 +120,7 @@ export class MoexClient { paginated = true, ): Promise[]> { const rows: Record[] = []; - let start = 0; + let start = FIRST_PAGE_OFFSET; for (;;) { const query = new URLSearchParams({ 'iss.meta': 'off', @@ -139,11 +145,11 @@ export class MoexClient { const total = integer(cursor?.total); const pageSize = integer(cursor?.pagesize); if ( - cursors.length !== 1 || + cursors.length !== EXPECTED_CURSOR_ROWS || index !== start || total === null || pageSize === null || - pageSize === 0 || + pageSize === EMPTY_PAGE_SIZE || current.length !== Math.min(pageSize, total - start) ) { throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); @@ -207,5 +213,5 @@ function currencyText(value: unknown): null | string { function integer(value: unknown): null | number { const result = Number(numberText(value) ?? NaN); - return Number.isSafeInteger(result) && result >= 0 ? result : null; + return Number.isSafeInteger(result) && result >= MIN_CURSOR_VALUE ? result : null; } diff --git a/src/integrations/ncr/ncr-ratings.client.ts b/src/integrations/ncr/ncr-ratings.client.ts index 16eea7a..257b58d 100644 --- a/src/integrations/ncr/ncr-ratings.client.ts +++ b/src/integrations/ncr/ncr-ratings.client.ts @@ -2,6 +2,11 @@ import { Injectable, ServiceUnavailableException } from '@nestjs/common'; import { load } from 'cheerio'; import { LRUCache } from 'lru-cache'; +import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time'; + +const EXPECTED_REGISTRY_TABLES = 1; +const RATING_COLUMN = 2; + const REGISTRY_CACHE_TTL_MS = 21_600_000; const REQUEST_TIMEOUT_MS = 10_000; const COLUMN_COUNT = 6; @@ -55,7 +60,7 @@ export class NcrRatingsClient { .map((_, cell) => $(cell).text().trim()) .get(); if ( - table.length !== 1 || + table.length !== EXPECTED_REGISTRY_TABLES || headings.length !== COLUMN_COUNT || headings[2] !== 'Рейтинг' || // eslint-disable-next-line security/detect-object-injection -- Column indexes are local numeric constants. @@ -73,7 +78,7 @@ export class NcrRatingsClient { const isin = cells.eq(ISIN_COLUMN).text().trim().toUpperCase(); // Unassigned issues cannot safely be matched to a held instrument. if (!/^[A-Z]{2}[A-Z0-9]{9}\d$/.test(isin)) return; - const raw = cells.eq(2).text().replace(/\s+/g, ' ').trim(); + const raw = cells.eq(RATING_COLUMN).text().replace(/\s+/g, ' ').trim(); const withdrawn = raw.toLowerCase() === 'рейтинг отозван'; if ( !withdrawn && @@ -93,7 +98,7 @@ export class NcrRatingsClient { const date = `${dateParts[3]}-${dateParts[2]}-${dateParts[1]}`; if ( !Number.isFinite(Date.parse(date)) || - new Date(date).toISOString().slice(0, 10) !== date + new Date(date).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) !== date ) { throw new Error('NCR date invalid'); } diff --git a/src/integrations/tbank/decimal.ts b/src/integrations/tbank/decimal.ts index f91faa0..fc2faef 100644 --- a/src/integrations/tbank/decimal.ts +++ b/src/integrations/tbank/decimal.ts @@ -1,12 +1,14 @@ import { Decimal } from '../../common/decimal'; +const MISSING_PROTO_COMPONENT = 0; + const NANO = 1_000_000_000n; export type ProtoDecimal = { nano?: number | string; units?: number | string }; export function decimal(value: ProtoDecimal): Decimal { return { - value: BigInt(value.units ?? 0) * NANO + BigInt(value.nano ?? 0), + value: BigInt(value.units ?? MISSING_PROTO_COMPONENT) * NANO + BigInt(value.nano ?? MISSING_PROTO_COMPONENT), scale: 9, }; } diff --git a/src/integrations/tbank/instrument-metadata.repository.ts b/src/integrations/tbank/instrument-metadata.repository.ts index ef67ad6..277dd99 100644 --- a/src/integrations/tbank/instrument-metadata.repository.ts +++ b/src/integrations/tbank/instrument-metadata.repository.ts @@ -13,6 +13,11 @@ import type { TbankInstrument } from './tbank.client'; import { PrismaService } from '../../prisma/prisma.service'; import { tbankSourceTtlMs } from './tbank-source.policy'; +const FIRST_BATCH_OFFSET = 0; +const UNCHANGED_REVISION_INCREMENT = 0; +const REVISION_INCREMENT = 1; +const MISSING_DATA_REVISION = 0; + export type MetadataIdentity = { figi?: null | string; instrumentUid?: null | string; @@ -59,7 +64,7 @@ export class InstrumentMetadataRepository { ), ]; for ( - let offset = 0; + let offset = FIRST_BATCH_OFFSET; offset < values.length; offset += metadataReadBatchSize ) { @@ -81,7 +86,7 @@ export class InstrumentMetadataRepository { ]; const instruments: MetadataRow[] = []; for ( - let offset = 0; + let offset = FIRST_BATCH_OFFSET; offset < uids.length; offset += metadataReadBatchSize ) { @@ -229,8 +234,8 @@ export class InstrumentMetadataRepository { ...data, dataRevision: { increment: isDeepStrictEqual(previous?.metadata, payload) ? - 0 : - 1, + UNCHANGED_REVISION_INCREMENT : + REVISION_INCREMENT, }, }, }); @@ -252,8 +257,8 @@ export class InstrumentMetadataRepository { fetchedAt: now, dataRevision: { increment: isDeepStrictEqual(previous?.matchedUids, matchedUids) ? - 0 : - 1, + UNCHANGED_REVISION_INCREMENT : + REVISION_INCREMENT, }, }, }); @@ -323,7 +328,7 @@ function selectMetadata( dataRevision: used .map( (row) => - `${row.lookup?.dataRevision ?? 0}:${row.instrument?.dataRevision ?? 0}`, + `${row.lookup?.dataRevision ?? MISSING_DATA_REVISION}:${row.instrument?.dataRevision ?? MISSING_DATA_REVISION}`, ) .join('/'), }; diff --git a/src/integrations/tbank/tbank-access.service.ts b/src/integrations/tbank/tbank-access.service.ts index 613fa1e..588c9cb 100644 --- a/src/integrations/tbank/tbank-access.service.ts +++ b/src/integrations/tbank/tbank-access.service.ts @@ -13,6 +13,10 @@ import { supportedTbankAccounts } from './tbank-account.policy'; import { tbankSourceNeedsRefresh } from './tbank-source.policy'; import { TbankTokenService } from './tbank-token.service'; +const EXPECTED_UPDATED_CONNECTIONS = 1; +const MEMBERSHIP_REVISION_INCREMENT = 1; +const UNCHANGED_MEMBERSHIP_REVISION_INCREMENT = 0; + export type TbankSyncAttempt = { attemptId: string; id: string; @@ -101,7 +105,7 @@ export class TbankAccessService { }, data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' }, }); - return result.count === 1; + return result.count === EXPECTED_UPDATED_CONNECTIONS; } async tokenForAttempt(attempt: TbankSyncAttempt): Promise { @@ -184,7 +188,7 @@ export class TbankAccessService { where: { userId_revision: { userId, revision } }, data: { membershipAt: fetchedAt, - membershipRevision: { increment: changed ? 1 : 0 }, + membershipRevision: { increment: changed ? MEMBERSHIP_REVISION_INCREMENT : UNCHANGED_MEMBERSHIP_REVISION_INCREMENT }, }, }); if (completeWork) { diff --git a/src/integrations/tbank/tbank-account.policy.ts b/src/integrations/tbank/tbank-account.policy.ts index 8ce7264..eb456e2 100644 --- a/src/integrations/tbank/tbank-account.policy.ts +++ b/src/integrations/tbank/tbank-account.policy.ts @@ -1,4 +1,4 @@ -import { MILLISECONDS_PER_SECOND } from '../../common/time'; +import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../common/time'; import { TbankAccount } from './tbank.client'; const accountTypes = { @@ -43,5 +43,5 @@ function openedOn(seconds: number | string | undefined): null | string { if (seconds === undefined) return null; const date = new Date(Number(seconds) * MILLISECONDS_PER_SECOND); if (Number.isNaN(date.valueOf())) return null; - return date.toISOString().slice(0, 10); + return date.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH); } diff --git a/src/integrations/tbank/tbank.client.ts b/src/integrations/tbank/tbank.client.ts index 902733f..62101ab 100644 --- a/src/integrations/tbank/tbank.client.ts +++ b/src/integrations/tbank/tbank.client.ts @@ -30,6 +30,13 @@ import { StructuredLogger } from '../../common/logging/structured-logger.service import { MILLISECONDS_PER_SECOND } from '../../common/time'; import { Environment } from '../../config/env.validation'; +const INITIAL_REQUEST_COUNT = 0; +const MIN_RATE_DELAY_MS = 1; +const FIRST_ATTEMPT = 1; +const ATTEMPT_INCREMENT = 1; +const NO_RPC_ATTEMPTS = 0; +const NEXT_DAY_OFFSET = 1; + const RATE_WINDOW_MS = 1_000; const METADATA_MAX_ATTEMPTS = 3; const RPC_MAX_ATTEMPTS = 2; @@ -124,7 +131,7 @@ type TbankNamespace = { class RateLimiter { private readonly waiting: (() => void)[] = []; - private starts = 0; + private starts = INITIAL_REQUEST_COUNT; private windowStartedAt = Date.now(); constructor(private readonly perSecond: number) {} @@ -149,16 +156,16 @@ class RateLimiter { const now = Date.now(); if (now - this.windowStartedAt >= RATE_WINDOW_MS) { this.windowStartedAt = now; - this.starts = 0; + this.starts = INITIAL_REQUEST_COUNT; } while (this.starts < this.perSecond && this.waiting.length) { - this.starts += 1; + this.starts++; this.waiting.shift()?.(); } if (this.waiting.length && this.starts === this.perSecond) { setTimeout( () => this.drain(), - Math.max(1, RATE_WINDOW_MS - (now - this.windowStartedAt)), + Math.max(MIN_RATE_DELAY_MS, RATE_WINDOW_MS - (now - this.windowStartedAt)), ); } } @@ -472,7 +479,7 @@ export class TbankClient { ): Promise { const metadataCall = method === 'getInstrumentBy'; const maxAttempts = metadataCall ? METADATA_MAX_ATTEMPTS : RPC_MAX_ATTEMPTS; - for (let attempts = 1; attempts <= maxAttempts; attempts += 1) { + for (let attempts = FIRST_ATTEMPT; attempts <= maxAttempts; attempts += ATTEMPT_INCREMENT) { const startedAt = performance.now(); const queueFull = this.queue.size >= MAX_QUEUE_SIZE; try { @@ -520,7 +527,7 @@ export class TbankClient { this.logger.event('error', 'tbank.rpc.failed', { operation: method, ...failureContext, - attempts: queueFull && !metadataCall ? 0 : attempts, + attempts: queueFull && !metadataCall ? NO_RPC_ATTEMPTS : attempts, durationMs: Math.round(performance.now() - startedAt), }); if ( @@ -564,6 +571,6 @@ export class TbankClient { // 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); + if (nextDay) instant.setUTCDate(instant.getUTCDate() + NEXT_DAY_OFFSET); return Math.floor(instant.valueOf() / MILLISECONDS_PER_SECOND); } diff --git a/src/integrations/tbank/tbank.crypto.ts b/src/integrations/tbank/tbank.crypto.ts index a40d081..40dc533 100644 --- a/src/integrations/tbank/tbank.crypto.ts +++ b/src/integrations/tbank/tbank.crypto.ts @@ -1,5 +1,7 @@ import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; +const VERSION_SIZE_BYTES = 1; + const KEY_SIZE_BYTES = 32; const VERSION = 1; @@ -34,15 +36,15 @@ export class TbankCrypto { decrypt(payload: string): string { const bytes = Buffer.from(payload, 'base64'); - if (bytes[0] !== VERSION || bytes.length < 1 + NONCE_SIZE + TAG_SIZE) { + if (bytes[0] !== VERSION || bytes.length < VERSION_SIZE_BYTES + NONCE_SIZE + TAG_SIZE) { throw new Error('Invalid encrypted token'); } - const nonce = bytes.subarray(1, 1 + NONCE_SIZE); + const nonce = bytes.subarray(VERSION_SIZE_BYTES, VERSION_SIZE_BYTES + NONCE_SIZE); const tag = bytes.subarray(-TAG_SIZE); const decipher = createDecipheriv('aes-256-gcm', this.key, nonce); decipher.setAuthTag(tag); return Buffer.concat([ - decipher.update(bytes.subarray(1 + NONCE_SIZE, -TAG_SIZE)), + decipher.update(bytes.subarray(VERSION_SIZE_BYTES + NONCE_SIZE, -TAG_SIZE)), decipher.final(), ]).toString('utf8'); } diff --git a/src/modules/auth/application/auth.service.ts b/src/modules/auth/application/auth.service.ts index b2528c8..19baedb 100644 --- a/src/modules/auth/application/auth.service.ts +++ b/src/modules/auth/application/auth.service.ts @@ -11,6 +11,8 @@ import { Session, User } from '../../../../generated/prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; import { AuthUser, RefreshPayload } from './auth.types'; +const EXPECTED_ROTATED_SESSIONS = 1; + const PASSWORD_HASH_ROUNDS = 12; const REFRESH_TTL_MS = 604_800_000; @@ -180,7 +182,7 @@ export class AuthService { where: { id: session.id, refreshTokenHash: expectedRefreshTokenHash }, data, }); - if (updated.count !== 1) { + if (updated.count !== EXPECTED_ROTATED_SESSIONS) { throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); } } else { diff --git a/src/modules/auth/presentation/auth.dto.ts b/src/modules/auth/presentation/auth.dto.ts index 62fe648..c04bee2 100644 --- a/src/modules/auth/presentation/auth.dto.ts +++ b/src/modules/auth/presentation/auth.dto.ts @@ -7,6 +7,9 @@ import { ValidatorConstraint, } from 'class-validator'; +const EMAIL_PART_COUNT = 2; +const FIRST_CHARACTER_INDEX = 0; + const MAX_EMAIL_LENGTH = 254; const ASCII_SPACE = 32; const ASCII_DELETE = 127; @@ -19,14 +22,14 @@ class EmailConstraint { return ( typeof value === 'string' && value.length <= MAX_EMAIL_LENGTH && - value.split('@').length === 2 && + value.split('@').length === EMAIL_PART_COUNT && !value .split('') .some( (character) => /\s/.test(character) || - character.charCodeAt(0) < ASCII_SPACE || - character.charCodeAt(0) === ASCII_DELETE, + character.charCodeAt(FIRST_CHARACTER_INDEX) < ASCII_SPACE || + character.charCodeAt(FIRST_CHARACTER_INDEX) === ASCII_DELETE, ) && value.split('@').every(Boolean) ); diff --git a/src/modules/banking/bank-cards/application/bank-card-input.ts b/src/modules/banking/bank-cards/application/bank-card-input.ts index e0f3440..8bb0dd6 100644 --- a/src/modules/banking/bank-cards/application/bank-card-input.ts +++ b/src/modules/banking/bank-cards/application/bank-card-input.ts @@ -5,6 +5,7 @@ import { } from '@nestjs/common'; import { createHash } from 'node:crypto'; +import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../common/time'; import { MAX_CARD_NAME_LENGTH, MAX_OPERATION_DESCRIPTION_LENGTH, @@ -18,6 +19,9 @@ import { CreateBankOperationDto, } from '../presentation/bank-card.dto'; +const NEXT_MONTH_OFFSET = 1; +const PREVIOUS_MONTH_LAST_DAY = 0; + export const BankErrorCode = { InvalidInput: 'BANK_INVALID_INPUT', VersionConflict: 'BANK_VERSION_CONFLICT', @@ -45,8 +49,8 @@ export function periodBounds(query: BankPeriodQueryDto) { const from = `${query.month}-01`; if (!isCalendarDate(from)) return fail(); const end = new Date(`${from}T00:00:00Z`); - end.setUTCMonth(end.getUTCMonth() + 1, 0); - return { from, to: end.toISOString().slice(0, 10) }; + end.setUTCMonth(end.getUTCMonth() + NEXT_MONTH_OFFSET, PREVIOUS_MONTH_LAST_DAY); + return { from, to: end.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) }; } if ( query.month || diff --git a/src/modules/banking/bank-cards/application/bank-cards.service.ts b/src/modules/banking/bank-cards/application/bank-cards.service.ts index 1f5de0c..2c5d92b 100644 --- a/src/modules/banking/bank-cards/application/bank-cards.service.ts +++ b/src/modules/banking/bank-cards/application/bank-cards.service.ts @@ -19,6 +19,9 @@ import { fingerprint, missing, } from './bank-card-input'; + +const EXPECTED_UPDATED_CARDS = 1; + type Tx = Prisma.TransactionClient; @Injectable() export class BankCardsService { @@ -149,7 +152,7 @@ export class BankCardsService { }), }, }); - if (changed.count !== 1) conflict(); + if (changed.count !== EXPECTED_UPDATED_CARDS) conflict(); return this.cardView(tx, await this.repository.card(tx, userId, id)); }); } diff --git a/src/modules/banking/bank-cards/application/bank-operations.service.ts b/src/modules/banking/bank-cards/application/bank-operations.service.ts index d9dac92..eef0336 100644 --- a/src/modules/banking/bank-cards/application/bank-operations.service.ts +++ b/src/modules/banking/bank-cards/application/bank-operations.service.ts @@ -5,8 +5,9 @@ import { Prisma, } from '../../../../../generated/prisma/client'; import { PrismaService } from '../../../../prisma/prisma.service'; +import { FIRST_PAGE } from '../domain/bank-card-limits'; import { BankOperationKind } from '../domain/bank-operation-kind'; -import { rubMoney } from '../domain/rub-money'; +import { rubMoney, ZERO_KOPECKS } from '../domain/rub-money'; import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; import { BankOperationDto, @@ -26,6 +27,9 @@ import { operationInput, periodBounds, } from './bank-card-input'; + +const EXPECTED_UPDATED_ROWS = 1; + type Tx = Prisma.TransactionClient; @Injectable() export class BankOperationsService { @@ -77,7 +81,7 @@ export class BankOperationsService { row.kind === BankOperationKind.Expense || source ? rubMoney( (source?.amountKopecks ?? row.amountKopecks) - - refunds.reduce((sum, r) => sum + r.amountKopecks, 0n), + refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS), ) : null, date: row.date, @@ -170,7 +174,7 @@ export class BankOperationsService { }, data: { version: { increment: 1 } }, }) - ).count !== 1 + ).count !== EXPECTED_UPDATED_ROWS ) { conflict(); } @@ -184,7 +188,7 @@ export class BankOperationsService { }); if (input.date < source.date) fail(BankErrorCode.InvalidDate); if ( - refunds.reduce((sum, r) => sum + r.amountKopecks, 0n) + + refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS) + input.amountKopecks > source.amountKopecks ) { @@ -199,7 +203,7 @@ export class BankOperationsService { refunds.length && (input.cardId !== old.cardId || refunds.some((row) => row.date < input.date) || - refunds.reduce((sum, row) => sum + row.amountKopecks, 0n) > + refunds.reduce((sum, row) => sum + row.amountKopecks, ZERO_KOPECKS) > input.amountKopecks) ) { conflict(BankErrorCode.DependentRefunds); @@ -268,7 +272,7 @@ export class BankOperationsService { where: { id, userId, version: dto.version, deletedAt: null }, data: { ...input, version: { increment: 1 } }, }) - ).count !== 1 + ).count !== EXPECTED_UPDATED_ROWS ) { conflict(); } @@ -308,7 +312,7 @@ export class BankOperationsService { where: { id: source.id, userId, version: source.version }, data: { version: { increment: 1 } }, }) - ).count !== 1 + ).count !== EXPECTED_UPDATED_ROWS ) { conflict(); } @@ -319,7 +323,7 @@ export class BankOperationsService { where: { id, userId, version, deletedAt: null }, data: { deletedAt: new Date(), version: { increment: 1 } }, }) - ).count !== 1 + ).count !== EXPECTED_UPDATED_ROWS ) { conflict(); } @@ -338,7 +342,7 @@ export class BankOperationsService { return this.prisma.$transaction(async (tx) => { await this.repository.card(tx, userId, id); const limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE, - page = query.page ?? 1; + page = query.page ?? FIRST_PAGE; const where: Prisma.BankCardOperationWhereInput = { userId, deletedAt: null, @@ -351,7 +355,7 @@ export class BankOperationsService { const rows = await tx.bankCardOperation.findMany({ where, orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }], - skip: (page - 1) * limit, + skip: (page - FIRST_PAGE) * limit, take: limit, }); return { diff --git a/src/modules/banking/bank-cards/application/expense-categories.service.ts b/src/modules/banking/bank-cards/application/expense-categories.service.ts index 80f2d77..4dbad85 100644 --- a/src/modules/banking/bank-cards/application/expense-categories.service.ts +++ b/src/modules/banking/bank-cards/application/expense-categories.service.ts @@ -5,6 +5,10 @@ import { PrismaService } from '../../../../prisma/prisma.service'; import { MAX_CATEGORY_NAME_LENGTH } from '../domain/bank-card-limits'; import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; import { conflict, missing, name } from './bank-card-input'; + +const EXPECTED_UPDATED_CATEGORIES = 1; +const VERSION_INCREMENT = 1; + @Injectable() export class ExpenseCategoriesService { constructor( @@ -62,7 +66,7 @@ export class ExpenseCategoriesService { version: { increment: 1 }, }, }) - ).count !== 1 + ).count !== EXPECTED_UPDATED_CATEGORIES ) { conflict(); } @@ -70,7 +74,7 @@ export class ExpenseCategoriesService { id, name: label, isDefault: row.seedKey === 'other', - version: version + 1, + version: version + VERSION_INCREMENT, }; }); } diff --git a/src/modules/banking/bank-cards/domain/bank-card-limits.ts b/src/modules/banking/bank-cards/domain/bank-card-limits.ts index 9483b9d..6b403d9 100644 --- a/src/modules/banking/bank-cards/domain/bank-card-limits.ts +++ b/src/modules/banking/bank-cards/domain/bank-card-limits.ts @@ -1,3 +1,10 @@ +export const FIRST_PAGE = 1; + +export const MIN_ENTITY_VERSION = 1; + +export const MIN_CATEGORY_NAME_LENGTH = 1; + +export const MIN_CARD_NAME_LENGTH = 1; export const MAX_CARD_NAME_LENGTH = 100; export const MAX_CATEGORY_NAME_LENGTH = 80; export const MAX_OPERATION_DESCRIPTION_LENGTH = 500; diff --git a/src/modules/banking/bank-cards/domain/bank-card.calculations.ts b/src/modules/banking/bank-cards/domain/bank-card.calculations.ts index 14ce726..cf1a0a3 100644 --- a/src/modules/banking/bank-cards/domain/bank-card.calculations.ts +++ b/src/modules/banking/bank-cards/domain/bank-card.calculations.ts @@ -1,4 +1,6 @@ +import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../common/time'; import { BankOperationKind } from './bank-operation-kind'; +import { ZERO_KOPECKS } from './rub-money'; export function moscowToday(now = new Date()): string { return new Intl.DateTimeFormat('en-CA', { @@ -10,11 +12,11 @@ export function moscowToday(now = new Date()): string { } export function isCalendarDate(value: string): boolean { - if (value.length !== 10 || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; + if (value.length !== ISO_DATE_LENGTH || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; if (value.startsWith('0000')) return false; const date = new Date(`${value}T00:00:00.000Z`); return ( - !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value + !Number.isNaN(date.getTime()) && date.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) === value ); } @@ -63,8 +65,8 @@ export function bankSummary( kind: string; }>, ) { - let expenses = 0n; - let refunds = 0n; + let expenses = ZERO_KOPECKS; + let refunds = ZERO_KOPECKS; const categories = new Map< string, { categoryId: string; expenses: bigint; net: bigint; refunds: bigint } @@ -81,19 +83,19 @@ export function bankSummary( const expense = operation.kind === BankOperationKind.Expense ? operation.amountKopecks : - 0n; + ZERO_KOPECKS; const refund = operation.kind === BankOperationKind.Refund ? operation.amountKopecks : - 0n; + ZERO_KOPECKS; expenses += expense; refunds += refund; if (operation.categoryId !== null) { const category = categories.get(operation.categoryId) ?? { categoryId: operation.categoryId, - expenses: 0n, - refunds: 0n, - net: 0n, + expenses: ZERO_KOPECKS, + refunds: ZERO_KOPECKS, + net: ZERO_KOPECKS, }; category.expenses += expense; category.refunds += refund; diff --git a/src/modules/banking/bank-cards/domain/rub-money.ts b/src/modules/banking/bank-cards/domain/rub-money.ts index 3fc9ccc..f435958 100644 --- a/src/modules/banking/bank-cards/domain/rub-money.ts +++ b/src/modules/banking/bank-cards/domain/rub-money.ts @@ -1,6 +1,10 @@ import type { Money } from '../../../../common/money.types'; -import { decimalFromString, formatDecimal } from '../../../../common/decimal'; +import { DECIMAL_RADIX, decimalFromString, formatDecimal } from '../../../../common/decimal'; + +export const ZERO_KOPECKS = 0n; + +const RUB_DECIMAL_PLACES = 2; const MIN_KOPECKS = -9_223_372_036_854_775_808n; const MAX_KOPECKS = 9_223_372_036_854_775_807n; @@ -14,14 +18,14 @@ export function parseRubMoney(money: Money, positive = false): bigint { throw new TypeError('Expected RUB money with a decimal string'); } const amount = decimalFromString(money.value); - if (amount.scale > 2) { + if (amount.scale > RUB_DECIMAL_PLACES) { throw new RangeError('RUB money supports at most two decimal places'); } - const kopecks = amount.value * 10n ** BigInt(2 - amount.scale); + const kopecks = amount.value * DECIMAL_RADIX ** BigInt(RUB_DECIMAL_PLACES - amount.scale); if (kopecks < MIN_KOPECKS || kopecks > MAX_KOPECKS) { throw new RangeError('RUB money is outside the signed 64-bit range'); } - if (positive && kopecks <= 0n) { + if (positive && kopecks <= ZERO_KOPECKS) { throw new RangeError('Operation amount must be positive'); } return kopecks; @@ -30,6 +34,6 @@ export function parseRubMoney(money: Money, positive = false): bigint { export function rubMoney(kopecks: bigint): Money { return { currency: 'RUB', - value: formatDecimal({ value: kopecks, scale: 2 }), + value: formatDecimal({ value: kopecks, scale: RUB_DECIMAL_PLACES }), }; } diff --git a/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts b/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts index 3d675ad..2c5e9aa 100644 --- a/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts +++ b/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts @@ -9,6 +9,10 @@ import { missing, } from '../application/bank-card-input'; import { BankOperationKind } from '../domain/bank-operation-kind'; + +const INITIAL_RETRY_COUNT = 0; +const LAST_SEED_OFFSET = 1; + type Tx = Prisma.TransactionClient; const TRANSACTION_MAX_WAIT_MS = 10_000; const TRANSACTION_TIMEOUT_MS = 15_000; @@ -28,7 +32,7 @@ const seeds = [ export class BankCardsRepository { constructor(private readonly prisma: PrismaService) {} async write(run: (tx: Tx) => Promise): Promise { - for (let attempt = 0; ; attempt++) { + for (let attempt = INITIAL_RETRY_COUNT; ; attempt++) { try { return await this.prisma.$transaction(run, { maxWait: TRANSACTION_MAX_WAIT_MS, @@ -69,7 +73,7 @@ export class BankCardsRepository { async seed(tx: Tx, userId: string) { for (const [index, label] of seeds.entries()) { - const seedKey = index === seeds.length - 1 ? 'other' : String(index); + const seedKey = index === seeds.length - LAST_SEED_OFFSET ? 'other' : String(index); if ( !(await tx.expenseCategory.findUnique({ where: { userId_seedKey: { userId, seedKey } }, diff --git a/src/modules/banking/bank-cards/presentation/bank-card.dto.ts b/src/modules/banking/bank-cards/presentation/bank-card.dto.ts index dfc9ac7..cb44a0f 100644 --- a/src/modules/banking/bank-cards/presentation/bank-card.dto.ts +++ b/src/modules/banking/bank-cards/presentation/bank-card.dto.ts @@ -25,12 +25,18 @@ import { import { MoneyDto } from '../../../../common/money.dto'; import { + FIRST_PAGE, MAX_CARD_NAME_LENGTH, MAX_CATEGORY_NAME_LENGTH, MAX_OPERATION_DESCRIPTION_LENGTH, + MIN_CARD_NAME_LENGTH, + MIN_CATEGORY_NAME_LENGTH, + MIN_ENTITY_VERSION, } from '../domain/bank-card-limits'; import { BankOperationKind } from '../domain/bank-operation-kind'; +const MIN_PAGE_SIZE = 1; + export const DEFAULT_BANK_PAGE_SIZE = 50; const MAX_BANK_PAGE_SIZE = 100; @@ -48,10 +54,10 @@ export class RubMoneyInputDto { } export class CreateBankCardDto { @ApiProperty({ format: 'uuid' }) @IsUUID() declare id: string; - @ApiProperty({ minLength: 1, maxLength: MAX_CARD_NAME_LENGTH }) + @ApiProperty({ minLength: MIN_CARD_NAME_LENGTH, maxLength: MAX_CARD_NAME_LENGTH }) @Transform(trim) @IsString() - @MinLength(1) + @MinLength(MIN_CARD_NAME_LENGTH) @MaxLength(MAX_CARD_NAME_LENGTH) declare name: string; @@ -91,22 +97,22 @@ export class UpdateBankCardDto extends PartialType( OmitType(CreateBankCardDto, ['id'] as const), { skipNullProperties: false }, ) { - @ApiProperty({ minimum: 1 }) @IsInt() @Min(1) declare version: number; + @ApiProperty({ minimum: MIN_ENTITY_VERSION }) @IsInt() @Min(MIN_ENTITY_VERSION) declare version: number; @ApiPropertyOptional() @ValidateIf((_object, value: unknown) => value !== undefined) @IsBoolean() archived?: boolean; } export class CategoryNameDto { - @ApiProperty({ minLength: 1, maxLength: MAX_CATEGORY_NAME_LENGTH }) + @ApiProperty({ minLength: MIN_CATEGORY_NAME_LENGTH, maxLength: MAX_CATEGORY_NAME_LENGTH }) @Transform(trim) @IsString() - @MinLength(1) + @MinLength(MIN_CATEGORY_NAME_LENGTH) @MaxLength(MAX_CATEGORY_NAME_LENGTH) declare name: string; } export class UpdateExpenseCategoryDto extends CategoryNameDto { - @ApiProperty({ minimum: 1 }) @IsInt() @Min(1) declare version: number; + @ApiProperty({ minimum: MIN_ENTITY_VERSION }) @IsInt() @Min(MIN_ENTITY_VERSION) declare version: number; } export class CreateBankOperationDto { @ApiProperty({ format: 'uuid' }) @IsUUID() declare id: string; @@ -151,7 +157,7 @@ export class UpdateBankOperationDto extends PartialType( OmitType(CreateBankOperationDto, ['id'] as const), { skipNullProperties: false }, ) { - @ApiProperty({ minimum: 1 }) @IsInt() @Min(1) declare version: number; + @ApiProperty({ minimum: MIN_ENTITY_VERSION }) @IsInt() @Min(MIN_ENTITY_VERSION) declare version: number; } export class BankCardsQueryDto { @ApiPropertyOptional({ enum: ['true', 'false'] }) @@ -160,22 +166,22 @@ export class BankCardsQueryDto { includeArchived?: string; } export class BankPageQueryDto { - @ApiPropertyOptional({ minimum: 1, default: 1 }) + @ApiPropertyOptional({ minimum: FIRST_PAGE, default: FIRST_PAGE }) @IsOptional() @Type(() => Number) @IsInt() - @Min(1) + @Min(FIRST_PAGE) page?: number; @ApiPropertyOptional({ - minimum: 1, + minimum: MIN_PAGE_SIZE, maximum: MAX_BANK_PAGE_SIZE, default: DEFAULT_BANK_PAGE_SIZE, }) @IsOptional() @Type(() => Number) @IsInt() - @Min(1) + @Min(MIN_PAGE_SIZE) @Max(MAX_BANK_PAGE_SIZE) limit?: number; } @@ -215,10 +221,10 @@ export class BankSummaryQueryDto extends BankPeriodQueryDto { cardId?: string; } export class BankVersionQueryDto { - @ApiProperty({ minimum: 1 }) + @ApiProperty({ minimum: MIN_ENTITY_VERSION }) @Type(() => Number) @IsInt() - @Min(1) + @Min(MIN_ENTITY_VERSION) declare version: number; } export class ExpenseCategoryDto { diff --git a/src/modules/investments/income/income-analytics.service.ts b/src/modules/investments/income/income-analytics.service.ts index 76fd624..5072d42 100644 --- a/src/modules/investments/income/income-analytics.service.ts +++ b/src/modules/investments/income/income-analytics.service.ts @@ -30,6 +30,8 @@ import { rangeBuckets, } from './income-period'; +const STALE_FRESHNESS_MS = 0; + type Total = { coupon: ReturnType; dividend: ReturnType; @@ -120,7 +122,7 @@ export class IncomeAnalyticsService { fetchedAt: current.meta.lastSuccessfulAt ? new Date(current.meta.lastSuccessfulAt) : null, - freshForMs: current.meta.freshness === 'fresh' ? Infinity : 0, + freshForMs: current.meta.freshness === 'fresh' ? Infinity : STALE_FRESHNESS_MS, work: work?.id ? { ...work, id: work.id } : null, coverage: current.meta.coverage, dependencies, diff --git a/src/modules/investments/income/income-period.ts b/src/modules/investments/income/income-period.ts index c7c280d..087d39e 100644 --- a/src/modules/investments/income/income-period.ts +++ b/src/modules/investments/income/income-period.ts @@ -1,6 +1,6 @@ import { BadRequestException } from '@nestjs/common'; -import { MILLISECONDS_PER_DAY } from '../../../common/time'; +import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_DAY } from '../../../common/time'; import { DateOnlyRange, dateParts, @@ -8,6 +8,14 @@ import { utcDate, } from '../investment-calendar'; +const MONDAY_WEEKDAY_INDEX = 1; +const INCLUSIVE_DAY_OFFSET = 1; +const NEXT_DAY_OFFSET = 1; +const PREVIOUS_MONTH_LAST_DAY = 0; +const PREVIOUS_DAY_OFFSET = -1; +const PREVIOUS_YEAR_OFFSET = 1; +const DATE_COMPONENT_DIGITS = 2; + const DAILY_BUCKET_MAX_DAYS = 45; const WEEKLY_BUCKET_MAX_DAYS = 180; const DAYS_PER_WEEK = 7; @@ -65,21 +73,21 @@ export function rangeBuckets( addDays( range.from, -( - (utcDate(range.from).getUTCDay() + DAYS_PER_WEEK - 1) % + (utcDate(range.from).getUTCDay() + DAYS_PER_WEEK - MONDAY_WEEKDAY_INDEX) % DAYS_PER_WEEK ), ) : - `${range.from.slice(0, ISO_MONTH_LENGTH)}-01`; + `${range.from.slice(ISO_DATE_START, ISO_MONTH_LENGTH)}-01`; while (cursor <= range.to) { const end = granularity === 'week' ? - addDays(cursor, DAYS_PER_WEEK - 1) : + addDays(cursor, DAYS_PER_WEEK - INCLUSIVE_DAY_OFFSET) : endOfMonth(cursor); buckets.push({ from: cursor < range.from ? range.from : cursor, to: end > range.to ? range.to : end, }); - cursor = addDays(end, 1); + cursor = addDays(end, NEXT_DAY_OFFSET); } return buckets; } @@ -89,7 +97,7 @@ export function rangeDays(range: DateOnlyRange): number { Math.round( (utcDate(range.to).valueOf() - utcDate(range.from).valueOf()) / MILLISECONDS_PER_DAY, - ) + 1 + ) + INCLUSIVE_DAY_OFFSET ); } @@ -106,24 +114,24 @@ function assertRange(range: DateOnlyRange, today: string): void { function addDays(value: string, days: number): string { const date = utcDate(value); date.setUTCDate(date.getUTCDate() + days); - return date.toISOString().slice(0, 10); + return date.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH); } 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, NEXT_DAY_OFFSET)) { result.push(date); } return result; } function firstOfMonth(value: string): string { - return `${value.slice(0, ISO_MONTH_LENGTH)}-01`; + return `${value.slice(ISO_DATE_START, ISO_MONTH_LENGTH)}-01`; } function endOfMonth(value: string): string { const [year, month] = dateParts(firstOfMonth(value)); - return new Date(Date.UTC(year, month, 0)).toISOString().slice(0, 10); + return new Date(Date.UTC(year, month, PREVIOUS_MONTH_LAST_DAY)).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH); } function isFullMonth(range: DateOnlyRange): boolean { @@ -133,7 +141,7 @@ function isFullMonth(range: DateOnlyRange): boolean { } function previousMonth(from: string): DateOnlyRange { - const last = addDays(firstOfMonth(from), -1); + const last = addDays(firstOfMonth(from), PREVIOUS_DAY_OFFSET); return { from: firstOfMonth(last), to: last }; } @@ -144,8 +152,8 @@ function shiftDays(range: DateOnlyRange, days: number): DateOnlyRange { 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 ? + const result = `${String(year - PREVIOUS_YEAR_OFFSET).padStart(ISO_YEAR_LENGTH, '0')}-${String(month).padStart(DATE_COMPONENT_DIGITS, '0')}-${String(day).padStart(DATE_COMPONENT_DIGITS, '0')}`; + return utcDate(result).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) === result ? result : null; }; diff --git a/src/modules/investments/instrument-query.ts b/src/modules/investments/instrument-query.ts new file mode 100644 index 0000000..df8d3eb --- /dev/null +++ b/src/modules/investments/instrument-query.ts @@ -0,0 +1,2 @@ +export const MIN_INSTRUMENT_QUERY_LENGTH = 2; +export const MAX_INSTRUMENT_QUERY_LENGTH = 80; diff --git a/src/modules/investments/instruments/application/candle.service.ts b/src/modules/investments/instruments/application/candle.service.ts index 86d7ba5..5e17cdc 100644 --- a/src/modules/investments/instruments/application/candle.service.ts +++ b/src/modules/investments/instruments/application/candle.service.ts @@ -27,6 +27,11 @@ import { CandleRepository } from '../infrastructure/candle.repository'; import { CatalogEnrichmentService } from './catalog-enrichment.service'; import { timestamp } from './instrument-market'; +const FIRST_BATCH_OFFSET = 0; +const BACKGROUND_SYNC_PRIORITY = 2; +const REPLACED_WINDOW_COUNT = 1; +const NEXT_WINDOW_OFFSET = 1; + type CandleInput = { instrumentType: 'bond' | 'share'; period: 'all' | 'halfYear' | 'month' | 'year'; @@ -246,7 +251,7 @@ export class CandleService { ) .map(([uid]) => uid); for ( - let offset = 0; + let offset = FIRST_BATCH_OFFSET; offset < uids.length; offset += prefetchReadBatchSize ) { @@ -297,7 +302,7 @@ export class CandleService { now, ).length ) { - await this.request(userId, revision, input, now, false, 2); + await this.request(userId, revision, input, now, false, BACKGROUND_SYNC_PRIORITY); } } } @@ -393,7 +398,7 @@ export class CandleService { const gaps = candleGaps(window, latest.coverage, now) .flatMap(candleWindows) .reverse(); - ranges.splice(index, 1, ...gaps); + ranges.splice(index, REPLACED_WINDOW_COUNT, ...gaps); // eslint-disable-next-line security/detect-object-injection -- The index is the internal numeric window cursor. window = ranges[index]; } @@ -430,7 +435,7 @@ export class CandleService { ) { return false; } - pass.nextWindow = index + 1; + pass.nextWindow = index + NEXT_WINDOW_OFFSET; if (pass.nextWindow < ranges.length) return null; } return true; @@ -512,7 +517,7 @@ export class CandleService { { ...producer.input, period: 'all' }, producer.now, false, - 2, + BACKGROUND_SYNC_PRIORITY, ); } } diff --git a/src/modules/investments/instruments/application/catalog-discovery.service.ts b/src/modules/investments/instruments/application/catalog-discovery.service.ts index 0c2b417..54dc369 100644 --- a/src/modules/investments/instruments/application/catalog-discovery.service.ts +++ b/src/modules/investments/instruments/application/catalog-discovery.service.ts @@ -26,6 +26,10 @@ import { selectCatalogInstruments, } from './catalog-selection'; +const UNAMBIGUOUS_MATCH_COUNT = 1; +const NO_MATCHES = 0; +const FIRST_RESULT_OFFSET = 0; + const tickerAmbiguityLimit = 2; type Producer = { @@ -160,7 +164,7 @@ export class CatalogDiscoveryService { { instrumentType, query: instrument.ticker, exactTicker: true }, observe, ); - if (alias.data?.length === 1 && alias.data[0].uid === uid) { + if (alias.data?.length === UNAMBIGUOUS_MATCH_COUNT && alias.data[0].uid === uid) { slug = instrument.ticker.toUpperCase(); } dependencies.push(alias.meta); @@ -200,8 +204,8 @@ export class CatalogDiscoveryService { { instrumentType, query: ticker, exactTicker: true }, observe, ); - if (lookup.data?.length === 0) throw new NotFoundException(); - if (lookup.data && lookup.data.length > 1) { + if (lookup.data?.length === NO_MATCHES) throw new NotFoundException(); + if (lookup.data && lookup.data.length > UNAMBIGUOUS_MATCH_COUNT) { throw new ConflictException({ code: instrumentType === 'share' ? @@ -346,7 +350,7 @@ export class CatalogDiscoveryService { input.query, input.instrumentType, ).slice( - 0, + FIRST_RESULT_OFFSET, input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit, ), new Date(), @@ -364,7 +368,7 @@ export class CatalogDiscoveryService { if (success) { const saved = await this.repository.read(input); for (const observer of [attempt, ...producer.observers]) { - if (!input.exactTicker || saved.data?.length === 1) { + if (!input.exactTicker || saved.data?.length === UNAMBIGUOUS_MATCH_COUNT) { await this.enrichment.requestMany( observer.userId, observer.revision, diff --git a/src/modules/investments/instruments/application/catalog-enrichment.service.ts b/src/modules/investments/instruments/application/catalog-enrichment.service.ts index 712c1db..edbed0c 100644 --- a/src/modules/investments/instruments/application/catalog-enrichment.service.ts +++ b/src/modules/investments/instruments/application/catalog-enrichment.service.ts @@ -39,6 +39,10 @@ import { import { catalogSearchLimit } from './catalog-selection'; import { previousClose, timestamp } from './instrument-market'; +const MISSING_DATA_REVISION = 0; +const FIRST_QUEUE_INDEX = 0; +const DEQUEUED_ITEM_COUNT = 1; + type Producer = { attempt: TbankSyncAttempt; input: CatalogSource; @@ -147,7 +151,7 @@ export class CatalogEnrichmentService { price: payload?.price ?? null, meta: investmentResourceMeta({ resourceKey: catalogScope(input), - dataRevision: String(row?.dataRevision ?? 0), + dataRevision: String(row?.dataRevision ?? MISSING_DATA_REVISION), availability: row ? 'complete' : 'missing', fetchedAt: row?.fetchedAt ?? null, freshForMs: tbankSourceTtlMs[sourcePolicy(input.kind)], @@ -485,7 +489,7 @@ export class CatalogEnrichmentService { // One batch RPC for queued prices sharing the same authorized producer. if (producer.input.kind === 'last-price') { for ( - let i = 0; + let i = FIRST_QUEUE_INDEX; i < this.queue.length && batch.length < catalogSearchLimit; ) { // eslint-disable-next-line security/detect-object-injection -- Numeric queue indexes and validated instrument UIDs select internal data only. @@ -496,7 +500,7 @@ export class CatalogEnrichmentService { next.attempt.revision === producer.attempt.revision ) { batch.push(next); - this.queue.splice(i, 1); + this.queue.splice(i, DEQUEUED_ITEM_COUNT); } else i++; } } diff --git a/src/modules/investments/instruments/application/instrument-market.ts b/src/modules/investments/instruments/application/instrument-market.ts index a43b3c3..4620119 100644 --- a/src/modules/investments/instruments/application/instrument-market.ts +++ b/src/modules/investments/instruments/application/instrument-market.ts @@ -3,19 +3,21 @@ import type { TbankTimestamp, } from '../../../../integrations/tbank/tbank.api'; -import { MILLISECONDS_PER_SECOND } from '../../../../common/time'; +import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../../../common/time'; + +const MIN_TIMESTAMP_SECONDS_EXCLUSIVE = 0; const MOSCOW_OFFSET_MS = 10_800_000; export function timestamp(value?: TbankTimestamp): null | string { const seconds = Number(value?.seconds); - return Number.isFinite(seconds) && seconds > 0 ? + return Number.isFinite(seconds) && seconds > MIN_TIMESTAMP_SECONDS_EXCLUSIVE ? new Date(seconds * MILLISECONDS_PER_SECOND).toISOString() : null; } export function moscowDay(iso: string): string { return new Date(Date.parse(iso) + MOSCOW_OFFSET_MS) .toISOString() - .slice(0, 10); + .slice(ISO_DATE_START, ISO_DATE_LENGTH); } export function previousClose( candles: TbankCandle[], @@ -29,7 +31,7 @@ export function previousClose( candle.isComplete && candle.close && time && - time.slice(0, 10) < moscowDay(priceAt) + time.slice(ISO_DATE_START, ISO_DATE_LENGTH) < moscowDay(priceAt) ); }) .sort((a, b) => Number(b.time?.seconds) - Number(a.time?.seconds))[0] ?? diff --git a/src/modules/investments/instruments/application/instrument-position.ts b/src/modules/investments/instruments/application/instrument-position.ts index e808f8c..9fdb930 100644 --- a/src/modules/investments/instruments/application/instrument-position.ts +++ b/src/modules/investments/instruments/application/instrument-position.ts @@ -12,6 +12,10 @@ import { } from '../../portfolio/portfolio.api'; import { StockPositionDto } from '../stocks/presentation/stock.dto'; +const EMPTY_POSITIONS_COUNT = 0; +const ZERO_QUANTITY = 0n; +const ZERO_AMOUNT = 0n; + const PRICE_DECIMAL_PLACES = 9; const RETURN_PERCENT_DECIMAL_PLACES = 6; @@ -32,8 +36,8 @@ export function instrumentPosition( const currentValue = sumComplete(positions.map((p) => p.currentValue)); const absoluteReturn = sumComplete(positions.map((p) => p.absoluteReturn)); const positive = - positions.length > 0 && - positions.every((p) => decimalFromString(p.quantity).value > 0n); + positions.length > EMPTY_POSITIONS_COUNT && + positions.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY); return { quantity: formatDecimal(quantity), currentValue, @@ -55,7 +59,7 @@ export function instrumentPosition( accounts: positions.map((p) => ({ ...p, returnPercent: - decimalFromString(p.quantity).value > 0n ? + decimalFromString(p.quantity).value > ZERO_QUANTITY ? percent(p.absoluteReturn, cost(p)) : null, })), @@ -103,7 +107,7 @@ function percent( !base || !result || base.currency !== result.currency || - decimalFromString(base.value).value <= 0n + decimalFromString(base.value).value <= ZERO_AMOUNT ) { return null; } diff --git a/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts b/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts index 0ee3d2b..df5972b 100644 --- a/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts +++ b/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts @@ -14,8 +14,11 @@ import { type IdentifiedBond = TbankBond & { uid: string }; +import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time'; import { money } from '../infrastructure/bond-money.mapper'; +const MIN_LOT_EXCLUSIVE = 0; + @Injectable() export class BondCatalogService { constructor(private readonly discovery: CatalogDiscoveryService) {} @@ -100,13 +103,13 @@ function toBond(bond: IdentifiedBond): BondDto { currency: bond.currency?.toUpperCase() || null, bondType: bond.bondType || null, sector: bond.sector || null, - lot: bond.lot && bond.lot > 0 ? bond.lot : null, + lot: bond.lot && bond.lot > MIN_LOT_EXCLUSIVE ? bond.lot : null, historyFrom: timestamp(bond.first_1dayCandleDate), nominal: money(bond.nominal), initialNominal: money(bond.initialNominal), aci: money(bond.aciValue), - maturityDate: timestamp(bond.maturityDate)?.slice(0, 10) ?? null, - callDate: timestamp(bond.callDate)?.slice(0, 10) ?? null, + maturityDate: timestamp(bond.maturityDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null, + callDate: timestamp(bond.callDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null, couponQuantityPerYear: bond.couponQuantityPerYear ?? null, floatingCoupon: bond.floatingCouponFlag ?? null, perpetual: bond.perpetualFlag ?? null, diff --git a/src/modules/investments/instruments/bonds/application/bond-income.service.ts b/src/modules/investments/instruments/bonds/application/bond-income.service.ts index a9f4869..6096373 100644 --- a/src/modules/investments/instruments/bonds/application/bond-income.service.ts +++ b/src/modules/investments/instruments/bonds/application/bond-income.service.ts @@ -6,6 +6,7 @@ import { formatDecimal, multiply, } from '../../../../../common/decimal'; +import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time'; import { TbankAccessService, type TbankBond, @@ -30,6 +31,12 @@ import { BondPositionDto, } from '../presentation/bond.dto'; import { bondQuote } from './bond-market.service'; + +const INCOME_FORECAST_YEARS = 1; +const ZERO_QUANTITY = 0n; +const MIN_COUPON_PERIOD_EXCLUSIVE = 0; +const ZERO_AMOUNT = 0n; + const ISO_YEAR_LENGTH = 4; const INCOME_HISTORY_YEARS = 5; const COUPON_YIELD_DECIMAL_PLACES = 4; @@ -77,8 +84,8 @@ export class BondIncomeService { ): Promise { const connection = await this.access.local(userId); const today = moscowDay(new Date().toISOString()); - const from = `${Number(today.slice(0, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`; - const to = `${Number(today.slice(0, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`; + const from = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`; + const to = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) + INCOME_FORECAST_YEARS}${today.slice(ISO_YEAR_LENGTH)}`; const portfolio = await this.portfolios.read(userId, 'all', observe); const sources = await this.sources.readMany( userId, @@ -139,13 +146,13 @@ export function bondCoupons( const { from, to, today } = window; const quantity = position?.accounts.length && - position.accounts.every((p) => decimalFromString(p.quantity).value > 0n) ? + position.accounts.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY) ? decimalFromString(position.quantity) : null; const coupons = events .map((event) => { - const paymentDate = timestamp(event.couponDate)?.slice(0, 10) ?? null; - const recordDate = timestamp(event.fixDate)?.slice(0, 10) ?? null; + const paymentDate = timestamp(event.couponDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null; + const recordDate = timestamp(event.fixDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null; const perBond = money(event.payOneBond); const timing = !paymentDate ? ('undated' as const) : @@ -198,10 +205,10 @@ export function bondMetrics( const effectiveYield = values.effectiveYield ?? null; const creditRating = values.creditRating ?? null; const today = moscowDay(new Date().toISOString()); - const to = `${Number(today.slice(0, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`; + const to = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) + INCOME_FORECAST_YEARS}${today.slice(ISO_YEAR_LENGTH)}`; const next = events ?.filter((event) => { - const date = timestamp(event.couponDate)?.slice(0, 10); + const date = timestamp(event.couponDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH); return date && date >= today && date <= to; }) .sort( @@ -210,15 +217,15 @@ export function bondMetrics( const coupon = money(next?.payOneBond); const period = next?.couponPeriod; const couponPeriodDays = - period && Number.isInteger(period) && period > 0 ? period : null; + period && Number.isInteger(period) && period > MIN_COUPON_PERIOD_EXCLUSIVE ? period : null; const clean = quote?.cleanPrice; const couponYieldPercent = coupon && couponPeriodDays && clean && coupon.currency === clean.currency && - decimalFromString(coupon.value).value >= 0n && - decimalFromString(clean.value).value > 0n ? + decimalFromString(coupon.value).value >= ZERO_AMOUNT && + decimalFromString(clean.value).value > ZERO_AMOUNT ? formatDecimal( divide( multiply( @@ -242,7 +249,7 @@ export function bondMetrics( return { creditRating, coupon, - couponDate: timestamp(next?.couponDate)?.slice(0, 10) ?? null, + couponDate: timestamp(next?.couponDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null, couponType, couponPeriodDays, couponYieldPercent, diff --git a/src/modules/investments/instruments/bonds/application/bond-market.service.ts b/src/modules/investments/instruments/bonds/application/bond-market.service.ts index c12b5b8..fbb4fcf 100644 --- a/src/modules/investments/instruments/bonds/application/bond-market.service.ts +++ b/src/modules/investments/instruments/bonds/application/bond-market.service.ts @@ -10,6 +10,7 @@ import { multiply, subtract, } from '../../../../../common/decimal'; +import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time'; import { decimal } from '../../../../../integrations/tbank/decimal'; import { CandleService } from '../../application/candle.service'; import { @@ -24,6 +25,8 @@ import { BondQuoteResourceDto, } from '../presentation/bond.dto'; +const ZERO_AMOUNT = 0n; + const PRICE_DECIMAL_PLACES = 9; @Injectable() export class BondMarketService { @@ -80,7 +83,7 @@ export function bondQuote( const cleanPrice = pricePercent !== null && nominal && - decimalFromString(nominal.value).value > 0n ? + decimalFromString(nominal.value).value > ZERO_AMOUNT ? { currency: nominal.currency, value: formatDecimal( @@ -118,7 +121,7 @@ export function bondQuote( last?.price && previous?.close ? formatDecimal(subtract(decimal(last.price), decimal(previous.close))) : null, - previousCloseDate: timestamp(previous?.time)?.slice(0, 10) ?? null, + previousCloseDate: timestamp(previous?.time)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null, unavailable, observedAt, }; diff --git a/src/modules/investments/instruments/domain/candle-coverage.ts b/src/modules/investments/instruments/domain/candle-coverage.ts index 91e9c5d..3db1224 100644 --- a/src/modules/investments/instruments/domain/candle-coverage.ts +++ b/src/modules/investments/instruments/domain/candle-coverage.ts @@ -1,5 +1,7 @@ import { MILLISECONDS_PER_DAY } from '../../../../common/time'; +const FIRST_RANGE_END_INDEX = 1; + export type CandleRange = { from: Date; to: Date }; export type CandleCoverage = CandleRange & { fetchedAt: Date; @@ -80,7 +82,7 @@ export function confirmedCandleCoverage( } } const sorted = [...boundaries].sort((a, b) => a - b); - return sorted.slice(1).map((to, index) => { + return sorted.slice(FIRST_RANGE_END_INDEX).map((to, index) => { // eslint-disable-next-line security/detect-object-injection -- The index comes from the sorted numeric boundary array. const from = sorted[index]; return { diff --git a/src/modules/investments/instruments/infrastructure/candle.repository.ts b/src/modules/investments/instruments/infrastructure/candle.repository.ts index 8363bde..c9750cf 100644 --- a/src/modules/investments/instruments/infrastructure/candle.repository.ts +++ b/src/modules/investments/instruments/infrastructure/candle.repository.ts @@ -11,6 +11,8 @@ import { type SavedCandle, } from '../domain/candle-coverage'; +const NO_UPDATED_ROWS = 0; + const provider = 'tbank'; const interval = 'day'; type CandleIntent = { @@ -323,7 +325,7 @@ export class CandleRepository { }, data: { state: 'complete', errorCode: null }, }); - return result.count > 0; + return result.count > NO_UPDATED_ROWS; }); } diff --git a/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts b/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts index ae844f1..e0c916a 100644 --- a/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts +++ b/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts @@ -11,6 +11,9 @@ import type { import { PrismaService } from '../../../../prisma/prisma.service'; +const UNCHANGED_REVISION_INCREMENT = 0; +const REVISION_INCREMENT = 1; + export type CatalogQuery = { exactTicker?: boolean; instrumentType: 'bond' | 'share'; @@ -205,7 +208,7 @@ export class CatalogDiscoveryRepository { ticker: instrument.ticker ?? null, fetchedAt: now, dataRevision: { - increment: isDeepStrictEqual(previous.metadata, metadata) ? 0 : 1, + increment: isDeepStrictEqual(previous.metadata, metadata) ? UNCHANGED_REVISION_INCREMENT : REVISION_INCREMENT, }, }, }); @@ -230,8 +233,8 @@ export class CatalogDiscoveryRepository { previousAlias?.matchedUids, matchedUids, ) ? - 0 : - 1, + UNCHANGED_REVISION_INCREMENT : + REVISION_INCREMENT, }, }, }); @@ -257,7 +260,7 @@ export class CatalogDiscoveryRepository { payload, fetchedAt: now, dataRevision: { - increment: isDeepStrictEqual(previous?.payload, payload) ? 0 : 1, + increment: isDeepStrictEqual(previous?.payload, payload) ? UNCHANGED_REVISION_INCREMENT : REVISION_INCREMENT, }, }, }); @@ -390,7 +393,7 @@ export class CatalogDiscoveryRepository { payload, fetchedAt: now, dataRevision: { - increment: isDeepStrictEqual(previous?.payload, payload) ? 0 : 1, + increment: isDeepStrictEqual(previous?.payload, payload) ? UNCHANGED_REVISION_INCREMENT : REVISION_INCREMENT, }, }, }); @@ -416,8 +419,8 @@ export class CatalogDiscoveryRepository { previous?.matchedUids, instrumentUids, ) ? - 0 : - 1, + UNCHANGED_REVISION_INCREMENT : + REVISION_INCREMENT, }, }, }); @@ -442,8 +445,8 @@ export class CatalogDiscoveryRepository { previous?.instrumentUids, instrumentUids, ) ? - 0 : - 1, + UNCHANGED_REVISION_INCREMENT : + REVISION_INCREMENT, }, }, }); diff --git a/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts b/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts index 914d09a..30fc5c9 100644 --- a/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts +++ b/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts @@ -12,6 +12,8 @@ import { StockSearchResourceDto, } from '../presentation/stock.dto'; +const MIN_LOT_EXCLUSIVE = 0; + type IdentifiedShare = TbankShare & { uid: string }; @Injectable() @@ -102,7 +104,7 @@ function stock(share: IdentifiedShare): StockDto { currency: share.currency?.toUpperCase() || null, shareType: share.shareType || null, sector: share.sector || null, - lot: share.lot && share.lot > 0 ? share.lot : null, + lot: share.lot && share.lot > MIN_LOT_EXCLUSIVE ? share.lot : null, historyFrom: timestamp(share.first_1dayCandleDate), }; } diff --git a/src/modules/investments/instruments/stocks/application/stock-income.service.ts b/src/modules/investments/instruments/stocks/application/stock-income.service.ts index 9dd4eb2..dceabb4 100644 --- a/src/modules/investments/instruments/stocks/application/stock-income.service.ts +++ b/src/modules/investments/instruments/stocks/application/stock-income.service.ts @@ -5,6 +5,7 @@ import { formatDecimal, multiply, } from '../../../../../common/decimal'; +import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time'; import { decimal } from '../../../../../integrations/tbank/decimal'; import { TbankAccessService, @@ -23,6 +24,10 @@ import { StockDividendsResourceDto, StockPositionDto, } from '../presentation/stock.dto'; + +const INCOME_FORECAST_YEARS = 1; +const ZERO_QUANTITY = 0n; + const ISO_YEAR_LENGTH = 4; const INCOME_HISTORY_YEARS = 5; @Injectable() @@ -49,8 +54,8 @@ export class StockIncomeService { ): Promise { const connection = await this.access.local(userId); const today = moscowDay(new Date().toISOString()); - const from = `${Number(today.slice(0, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`; - const to = `${Number(today.slice(0, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`; + const from = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`; + const to = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) + INCOME_FORECAST_YEARS}${today.slice(ISO_YEAR_LENGTH)}`; const portfolio = await this.portfolios.read(userId, 'all', observe); const sources = await this.sources.readMany( userId, @@ -112,15 +117,15 @@ export function stockDividends( const quantity = position && position.accounts.length && - position.accounts.every((p) => decimalFromString(p.quantity).value > 0n) ? + position.accounts.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY) ? decimalFromString(position.quantity) : null; const dividends: StockDividendDto[] = events .filter((event) => event.dividendType?.toLowerCase() !== 'cancelled') .map((event) => { - const recordDate = timestamp(event.recordDate)?.slice(0, 10) ?? null; - const paymentDate = timestamp(event.paymentDate)?.slice(0, 10) ?? null; - const lastBuyDate = timestamp(event.lastBuyDate)?.slice(0, 10) ?? null; + const recordDate = timestamp(event.recordDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null; + const paymentDate = timestamp(event.paymentDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null; + const lastBuyDate = timestamp(event.lastBuyDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null; const currency = event.dividendNet?.currency?.toUpperCase(); const perShare = currency && event.dividendNet ? diff --git a/src/modules/investments/instruments/stocks/application/stock-market.service.ts b/src/modules/investments/instruments/stocks/application/stock-market.service.ts index 82dac1d..c4b3912 100644 --- a/src/modules/investments/instruments/stocks/application/stock-market.service.ts +++ b/src/modules/investments/instruments/stocks/application/stock-market.service.ts @@ -7,6 +7,7 @@ import { multiply, subtract, } from '../../../../../common/decimal'; +import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time'; import { decimal } from '../../../../../integrations/tbank/decimal'; import { CandleService } from '../../application/candle.service'; import { @@ -20,6 +21,8 @@ import { StockQuoteResourceDto, } from '../presentation/stock.dto'; +const ZERO_AMOUNT = 0n; + const CHANGE_PERCENT_DECIMAL_PLACES = 6; @Injectable() export class StockMarketService { @@ -90,7 +93,7 @@ export function stockQuote( price, priceAt, tradingStatus, - previousCloseDate: timestamp(previous?.time)?.slice(0, 10) ?? null, + previousCloseDate: timestamp(previous?.time)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null, previousClose: previousPrice, change: difference && currency ? @@ -99,7 +102,7 @@ export function stockQuote( changePercent: difference && previousPrice && - decimalFromString(previousPrice.value).value > 0n ? + decimalFromString(previousPrice.value).value > ZERO_AMOUNT ? formatDecimal( divide( multiply(difference, decimalFromString('100')), diff --git a/src/modules/investments/instruments/stocks/presentation/stock.dto.ts b/src/modules/investments/instruments/stocks/presentation/stock.dto.ts index 4fa0620..ef8ee5f 100644 --- a/src/modules/investments/instruments/stocks/presentation/stock.dto.ts +++ b/src/modules/investments/instruments/stocks/presentation/stock.dto.ts @@ -10,32 +10,36 @@ import { } from 'class-validator'; import { MoneyDto } from '../../../../../common/money.dto'; +import { + MAX_INSTRUMENT_QUERY_LENGTH, + MIN_INSTRUMENT_QUERY_LENGTH, +} from '../../../instrument-query'; import { InvestmentReadQueryDto, InvestmentResourceMetaDto, } from '../../../investment-resource.dto'; import { PortfolioPositionDto } from '../../../portfolio/portfolio.api'; -const MAX_INSTRUMENT_QUERY_LENGTH = 80; +const MIN_TICKER_LENGTH = 1; export class StockSearchQueryDto extends InvestmentReadQueryDto { - @ApiProperty({ minLength: 2, maxLength: MAX_INSTRUMENT_QUERY_LENGTH }) + @ApiProperty({ minLength: MIN_INSTRUMENT_QUERY_LENGTH, maxLength: MAX_INSTRUMENT_QUERY_LENGTH }) @Transform(({ value }: { value: unknown }) => typeof value === 'string' ? value.trim() : value, ) @IsString() - @MinLength(2) + @MinLength(MIN_INSTRUMENT_QUERY_LENGTH) @MaxLength(MAX_INSTRUMENT_QUERY_LENGTH) declare query: string; } export class StockTickerParamsDto { @ApiProperty({ - minLength: 1, + minLength: MIN_TICKER_LENGTH, maxLength: MAX_INSTRUMENT_QUERY_LENGTH, pattern: '^[A-Za-z0-9][A-Za-z0-9._@+-]*$', }) @IsString() - @MinLength(1) + @MinLength(MIN_TICKER_LENGTH) @MaxLength(MAX_INSTRUMENT_QUERY_LENGTH) @Matches(/^[A-Z0-9][\w.@+-]*$/i) declare ticker: string; diff --git a/src/modules/investments/investment-calendar.ts b/src/modules/investments/investment-calendar.ts index c2d76e0..3244748 100644 --- a/src/modules/investments/investment-calendar.ts +++ b/src/modules/investments/investment-calendar.ts @@ -1,3 +1,8 @@ +import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time'; + +const UTC_MONTH_INDEX_OFFSET = 1; +const NEXT_DAY_OFFSET = 1; + const MOSCOW_UTC_OFFSET_HOURS = 3; export type DateOnlyRange = { from: string; to: string }; const moscow = 'Europe/Moscow'; @@ -21,11 +26,11 @@ export function dateOnlyBounds( const from = dateParts(range.from); const end = dateParts(range.to); const to = new Date( - Date.UTC(end[0], end[1] - 1, end[2] + 1, -MOSCOW_UTC_OFFSET_HOURS), + Date.UTC(end[0], end[1] - UTC_MONTH_INDEX_OFFSET, end[2] + NEXT_DAY_OFFSET, -MOSCOW_UTC_OFFSET_HOURS), ); return { from: new Date( - Date.UTC(from[0], from[1] - 1, from[2], -MOSCOW_UTC_OFFSET_HOURS), + Date.UTC(from[0], from[1] - UTC_MONTH_INDEX_OFFSET, from[2], -MOSCOW_UTC_OFFSET_HOURS), ), to: to > now ? now : to, }; @@ -39,7 +44,7 @@ export function dateParts(value: string): [number, number, number] { number, number, ]; - if (utcDate(value).toISOString().slice(0, 10) !== value) { + if (utcDate(value).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) !== value) { throw new Error('Invalid date'); } return parts; @@ -49,6 +54,6 @@ export function utcDate(value: string): Date { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); if (!match) return new Date(NaN); return new Date( - Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])), + Date.UTC(Number(match[1]), Number(match[2]) - UTC_MONTH_INDEX_OFFSET, Number(match[3])), ); } diff --git a/src/modules/investments/investment-sync-plan.ts b/src/modules/investments/investment-sync-plan.ts index e5b01b7..50de3d0 100644 --- a/src/modules/investments/investment-sync-plan.ts +++ b/src/modules/investments/investment-sync-plan.ts @@ -1,3 +1,4 @@ +import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time'; import { planIncomePeriod } from './income/income.api'; import { dateOnlyBounds, moscowDate } from './investment-calendar'; import { @@ -5,6 +6,12 @@ import { operationIntervals, } from './operations/domain/operations-sync-plan'; +const FORECAST_YEARS = 1; +const JANUARY_INDEX = 0; +const FIRST_DAY_OF_MONTH = 1; +const EXCLUSIVE_END_OFFSET_MS = 1; +const NO_END_OFFSET_MS = 0; + export type InvestmentScreen = { accountId?: string; candlePeriod?: 'all' | 'halfYear' | 'month' | 'year'; @@ -232,7 +239,7 @@ export function planInvestmentScreen( const day = screen.screen === 'stock' || screen.screen === 'bond' ? moscowDate(now) : - now.toISOString().slice(0, 10); + now.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH); const from = screen.screen === 'events' && screen.from ? new Date(`${screen.from}T00:00:00Z`) : @@ -250,7 +257,7 @@ export function planInvestmentScreen( } if (screen.screen === 'stock' || screen.screen === 'bond') { from.setUTCFullYear(from.getUTCFullYear() - incomeHistoryYears); - to.setUTCFullYear(to.getUTCFullYear() + 1); + to.setUTCFullYear(to.getUTCFullYear() + FORECAST_YEARS); } sources.push({ ...identity, @@ -325,7 +332,7 @@ export function ledgerRange( new Date(fromValue) : instrumentUid ? new Date(opening ? `${opening}T00:00:00Z` : '1970-01-01T00:00:00Z') : - new Date(Date.UTC(now.getUTCFullYear(), 0, 1)); + new Date(Date.UTC(now.getUTCFullYear(), JANUARY_INDEX, FIRST_DAY_OF_MONTH)); const end = toValue ? new Date( /^\d{4}-\d{2}-\d{2}$/.test(toValue) ? @@ -335,6 +342,6 @@ export function ledgerRange( now; return { from, - to: new Date(Math.min(end.valueOf() + (toValue ? 1 : 0), now.valueOf())), + to: new Date(Math.min(end.valueOf() + (toValue ? EXCLUSIVE_END_OFFSET_MS : NO_END_OFFSET_MS), now.valueOf())), }; } diff --git a/src/modules/investments/operations/application/brokerage-operations-sync.service.ts b/src/modules/investments/operations/application/brokerage-operations-sync.service.ts index 8e635eb..0bf9456 100644 --- a/src/modules/investments/operations/application/brokerage-operations-sync.service.ts +++ b/src/modules/investments/operations/application/brokerage-operations-sync.service.ts @@ -11,6 +11,7 @@ import { accountHistory } from '../../investment-sync-plan'; import { type OperationInterval, operationIntervals, + operationsSyncPriority, planOperationRead, uncoveredOperationIntervals, } from '../domain/operations-sync-plan'; @@ -19,8 +20,9 @@ import { type OperationAttempt, } from '../infrastructure/brokerage-operations.repository'; +const UPSTREAM_RANGE_MARGIN_SECONDS = 1; + const operationsPageSize = 1000; -const priorities = { screen: 1, history: 2 } as const; @Injectable() export class BrokerageOperationsSyncService { @@ -129,7 +131,7 @@ export class BrokerageOperationsSyncService { userId: string, intervals: OperationInterval[], atomic: boolean, - priority: keyof typeof priorities = 'screen', + priority: keyof typeof operationsSyncPriority = 'screen', revision?: string, ): Promise { const connection = await this.access.local(userId); @@ -162,7 +164,7 @@ export class BrokerageOperationsSyncService { atomic, this.runningAttempts, // eslint-disable-next-line security/detect-object-injection -- The priority belongs to the internal priority union. - priorities[priority], + operationsSyncPriority[priority], ); if (!requested) return null; const { id, attemptId, revision } = requested.work; @@ -257,12 +259,12 @@ export class BrokerageOperationsSyncService { // Upstream inclusivity is not assumed; storage selects exact [from,to). from: { seconds: String( - Math.floor(interval.from.valueOf() / MILLISECONDS_PER_SECOND) - 1, + Math.floor(interval.from.valueOf() / MILLISECONDS_PER_SECOND) - UPSTREAM_RANGE_MARGIN_SECONDS, ), }, to: { seconds: String( - Math.ceil(interval.to.valueOf() / MILLISECONDS_PER_SECOND) + 1, + Math.ceil(interval.to.valueOf() / MILLISECONDS_PER_SECOND) + UPSTREAM_RANGE_MARGIN_SECONDS, ), }, ...(interval.instrumentUid ? diff --git a/src/modules/investments/operations/application/brokerage-operations.service.ts b/src/modules/investments/operations/application/brokerage-operations.service.ts index d9bac28..eb6b802 100644 --- a/src/modules/investments/operations/application/brokerage-operations.service.ts +++ b/src/modules/investments/operations/application/brokerage-operations.service.ts @@ -19,6 +19,7 @@ import { } from '../../investment-sync-plan'; import { operationIntervalsOverlap, + operationsSyncPriority, planOperationRead, uncoveredOperationIntervals, } from '../domain/operations-sync-plan'; @@ -30,6 +31,14 @@ import { } from '../presentation/brokerage-operation.dto'; import { BrokerageOperationsSyncService } from './brokerage-operations-sync.service'; +const EXCLUSIVE_END_OFFSET_MS = 1; +const FIRST_RESULT_OFFSET = 0; +const MISSING_MEMBERSHIP_REVISION = 0; +const STALE_FRESHNESS_MS = 0; +const UNKNOWN_AS_OF_MS = 0; +const LAST_ROW_INDEX = -1; +const UNIX_EPOCH_MS = 0; + type LocalOperationsCursor = { accountId: string; at: string; @@ -106,7 +115,7 @@ export class BrokerageOperationsService { screen: 'operations', accountId, from: from.toISOString(), - to: new Date(to.valueOf() - 1).toISOString(), + to: new Date(to.valueOf() - EXCLUSIVE_END_OFFSET_MS).toISOString(), }, { accounts: selected, @@ -244,7 +253,7 @@ export class BrokerageOperationsService { const knownBoundary = accounts.every( (account) => account.openedOn !== null, ); - const rows = saved?.rows.slice(0, limit) ?? []; + const rows = saved?.rows.slice(FIRST_RESULT_OFFSET, limit) ?? []; const instruments = await this.metadata.savedMany( rows.map((row) => row.payload as TbankOperationItem), ); @@ -278,7 +287,7 @@ export class BrokerageOperationsService { } const membershipMeta = investmentResourceMeta({ resourceKey: 'accounts/membership', - dataRevision: String(saved?.connection.membershipRevision ?? 0), + dataRevision: String(saved?.connection.membershipRevision ?? MISSING_MEMBERSHIP_REVISION), availability: available ? 'complete' : 'missing', fetchedAt: saved?.connection.membershipAt ?? null, freshForMs: tbankSourceTtlMs.membership, @@ -296,7 +305,7 @@ export class BrokerageOperationsService { 'missing', fetchedAt: lastSuccessfulAt, freshForMs: plan.intervals.some((interval) => interval.stale) ? - 0 : + STALE_FRESHNESS_MS : Number.POSITIVE_INFINITY, work: work ?? saved?.membershipWork ?? null, dependencies: [membershipMeta, ...metadataDependencies], @@ -321,7 +330,7 @@ export class BrokerageOperationsService { (interval) => interval.accountId === account.accountId, ) - .map((interval) => interval.asOf?.valueOf() ?? 0), + .map((interval) => interval.asOf?.valueOf() ?? UNKNOWN_AS_OF_MS), ), ), ), @@ -329,11 +338,11 @@ export class BrokerageOperationsService { null, historyBoundary: knownBoundary ? 'known' : 'unknown', historyState: saved?.work.some( - (work) => work.state === 'running' && work.priority > 1, + (work) => work.state === 'running' && work.priority > operationsSyncPriority.screen, ) ? 'loading' : saved?.work.some( - (work) => work.state === 'failed' && work.priority > 1, + (work) => work.state === 'failed' && work.priority > operationsSyncPriority.screen, ) ? 'failed' : knownBoundary && @@ -346,7 +355,7 @@ export class BrokerageOperationsService { 'missing', }, }); - const last = rows.at(-1); + const last = rows.at(LAST_ROW_INDEX); const hasNext = Boolean(saved && saved.rows.length > limit); return { data: @@ -448,7 +457,7 @@ export class BrokerageOperationsService { userId, revision: connection.revision, accountId, - from: new Date(0), + from: new Date(UNIX_EPOCH_MS), to: new Date(), categories: ['income'], filterKey: `income/${accountId}`, @@ -486,7 +495,7 @@ export class BrokerageOperationsService { const now = new Date(); const membership = investmentResourceMeta({ resourceKey: 'accounts/membership', - dataRevision: String(saved?.connection.membershipRevision ?? 0), + dataRevision: String(saved?.connection.membershipRevision ?? MISSING_MEMBERSHIP_REVISION), availability: saved?.connection.membershipAt ? 'complete' : 'missing', fetchedAt: saved?.connection.membershipAt ?? null, freshForMs: tbankSourceTtlMs.membership, @@ -559,7 +568,7 @@ export class BrokerageOperationsService { 'partial' : 'missing', fetchedAt, - freshForMs: plan.intervals.some((range) => range.stale) ? 0 : Infinity, + freshForMs: plan.intervals.some((range) => range.stale) ? STALE_FRESHNESS_MS : Infinity, work, dependencies: [membership], coverage: { diff --git a/src/modules/investments/operations/domain/operations-sync-plan.ts b/src/modules/investments/operations/domain/operations-sync-plan.ts index b0e73ca..3105e64 100644 --- a/src/modules/investments/operations/domain/operations-sync-plan.ts +++ b/src/modules/investments/operations/domain/operations-sync-plan.ts @@ -1,3 +1,11 @@ +export const operationsSyncPriority = { screen: 1, history: 2 } as const; + +const LAST_INTERVAL_INDEX = -1; +const NEXT_MONTH_OFFSET = 1; +const FIRST_DAY_OF_MONTH = 1; +const EMPTY_INTERVAL_COUNT = 0; +const EMPTY_SOURCE_COUNT = 0; + export type OperationInterval = { accountId: string; from: Date; @@ -31,7 +39,7 @@ export function operationIntervals( for (const interval of (groups.get(key) ?? []).sort( (left, right) => left.from.valueOf() - right.from.valueOf(), )) { - const previous = merged.at(-1); + const previous = merged.at(LAST_INTERVAL_INDEX); if (previous && interval.from <= previous.to) { previous.to = new Date( Math.max(previous.to.valueOf(), interval.to.valueOf()), @@ -42,7 +50,7 @@ export function operationIntervals( let from = interval.from; while (from < interval.to) { const nextMonth = new Date( - Date.UTC(from.getUTCFullYear(), from.getUTCMonth() + 1, 1), + Date.UTC(from.getUTCFullYear(), from.getUTCMonth() + NEXT_MONTH_OFFSET, FIRST_DAY_OF_MONTH), ); const to = new Date( Math.min(nextMonth.valueOf(), interval.to.valueOf()), @@ -111,7 +119,7 @@ export function planOperationRead( options: { force?: boolean; movingEnd?: boolean } = {}, ) { const monthStart = new Date( - Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1), + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), FIRST_DAY_OF_MONTH), ); const observedEnd = Math.max( ...requested.map((interval) => interval.to.valueOf()), @@ -146,10 +154,10 @@ export function planOperationRead( } } const missing = uncoveredOperationIntervals([effective], ranges); - const complete = missing.length === 0; + const complete = missing.length === EMPTY_INTERVAL_COUNT; const stale = current && - sources.length > 0 && + sources.length > EMPTY_SOURCE_COUNT && uncoveredOperationIntervals( ranges .map((row) => ({ @@ -161,7 +169,7 @@ export function planOperationRead( })) .filter((row) => row.from < row.to), fresh.map((row) => ({ ...row, instrumentUid: target.instrumentUid })), - ).length > 0; + ).length > EMPTY_INTERVAL_COUNT; if (options.force) load.push(target); else if (stale) load.push({ ...target, from: monthStart, to: now }); else load.push(...missing); diff --git a/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts b/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts index 586080e..735c04b 100644 --- a/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts +++ b/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts @@ -10,6 +10,8 @@ import { OperationCategory, } from '../presentation/brokerage-operation.dto'; +const MISSING_TIMESTAMP_COMPONENT = 0; + const NANOSECONDS_PER_MILLISECOND = 1_000_000; const types: Record = { @@ -89,7 +91,7 @@ export function operationTimestamp( value: undefined | { nanos?: number; seconds?: number | string }, ) { return new Date( - Number(value?.seconds ?? 0) * MILLISECONDS_PER_SECOND + - Math.floor((value?.nanos ?? 0) / NANOSECONDS_PER_MILLISECOND), + Number(value?.seconds ?? MISSING_TIMESTAMP_COMPONENT) * MILLISECONDS_PER_SECOND + + Math.floor((value?.nanos ?? MISSING_TIMESTAMP_COMPONENT) / NANOSECONDS_PER_MILLISECOND), ); } diff --git a/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts b/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts index 375df64..2d3adc9 100644 --- a/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts +++ b/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts @@ -22,12 +22,21 @@ import { type OperationInterval, operationIntervals, operationIntervalsOverlap, + operationsSyncPriority, uncoveredOperationIntervals, } from '../domain/operations-sync-plan'; import { operationCategory, operationTimestamp, } from './brokerage-operation.mapper'; + +const CURSOR_LOOKAHEAD_ROWS = 1; +const EMPTY_INTERVAL_COUNT = 0; +const SINGLE_WORK_GROUP = 1; +const INITIAL_PAGE_COUNT = 0; +const PAGE_INCREMENT = 1; +const EXPECTED_UPDATED_WORK_ITEMS = 1; + export type { OperationInterval } from '../domain/operations-sync-plan'; export type OperationAttempt = { @@ -202,7 +211,7 @@ export class BrokerageOperationsRepository { { accountId: 'asc' }, { brokerOperationId: 'asc' }, ], - ...(input.limit === undefined ? {} : { take: input.limit + 1 }), + ...(input.limit === undefined ? {} : { take: input.limit + CURSOR_LOOKAHEAD_ROWS }), }); return { connection, @@ -280,7 +289,7 @@ export class BrokerageOperationsRepository { ) { throw new NotFoundException(); } - if (priority > 1) { + if (priority > operationsSyncPriority.screen) { for (const account of accounts.filter((account) => intervals.some( (interval) => interval.accountId === account.accountId, @@ -331,7 +340,7 @@ export class BrokerageOperationsRepository { to: row.to, instrumentUid: interval.instrumentUid, })), - ).length === 0, + ).length === EMPTY_INTERVAL_COUNT, ); const active = await tx.tbankSyncWork.findMany({ where: { @@ -366,7 +375,7 @@ export class BrokerageOperationsRepository { const upgradeGroup = publicationAtomic && previousIntent && !previousIntent.atomic; if ( - touching.length <= 1 && + touching.length <= SINGLE_WORK_GROUP && previous?.state === 'running' && previous.processRunId === processRunId && runningAttempts.has(previous.attemptId) && @@ -394,10 +403,10 @@ export class BrokerageOperationsRepository { screenIntervals: [ ...new Set([ ...(previousIntent?.screenIntervals ?? - (previous.priority === 1 ? + (previous.priority === operationsSyncPriority.screen ? (previousIntent?.intervals ?? []) : [])), - ...(priority === 1 ? + ...(priority === operationsSyncPriority.screen ? [...pending, ...additions] .filter((item) => intervals.some((target) => @@ -460,7 +469,7 @@ export class BrokerageOperationsRepository { await tx.tbankSyncStage.deleteMany({ where: { workId: previous.id } }); } const targets = - recovering && !upgradeGroup && touching.length <= 1 ? + recovering && !upgradeGroup && touching.length <= SINGLE_WORK_GROUP ? previous?.scopeKey === scopeKey ? retained : [...retained, ...uncoveredOperationIntervals(intervals, retained)] : @@ -491,13 +500,13 @@ export class BrokerageOperationsRepository { screenIntervals: targets .filter( (target) => - (priority === 1 && + (priority === operationsSyncPriority.screen && intervals.some((incoming) => operationIntervalsOverlap(target, incoming), )) || ( previousIntent?.screenIntervals ?? - (previous?.priority === 1 ? + (previous?.priority === operationsSyncPriority.screen ? (previousIntent?.intervals ?? []) : []) ).some((key) => @@ -505,7 +514,7 @@ export class BrokerageOperationsRepository { ), ) .map(intervalKey), - atomic: atomicTargets.length > 0, + atomic: atomicTargets.length > EMPTY_INTERVAL_COUNT, }; const data = { intent, @@ -703,7 +712,7 @@ export class BrokerageOperationsRepository { ...(progress?.seen ?? []), ...(response.hasNext ? [nextCursor] : []), ], - pages: (progress?.pages ?? 0) + 1, + pages: (progress?.pages ?? INITIAL_PAGE_COUNT) + PAGE_INCREMENT, }; await tx.tbankSyncStage.upsert({ where: { @@ -891,11 +900,11 @@ export class BrokerageOperationsRepository { priority: remaining.some((key) => ( intent.screenIntervals ?? - (work.priority === 1 ? intent.intervals : []) + (work.priority === operationsSyncPriority.screen ? intent.intervals : []) ).includes(key), ) ? - 1 : - 2, + operationsSyncPriority.screen : + operationsSyncPriority.history, progress: { remainingIntervals: remaining.length }, }, }); @@ -932,7 +941,7 @@ async function claim( }, data: { updatedAt: new Date() }, }); - return result.count === 1; + return result.count === EXPECTED_UPDATED_WORK_ITEMS; } function decodeInterval(key: string): OperationInterval { diff --git a/src/modules/investments/operations/presentation/brokerage-operation.dto.ts b/src/modules/investments/operations/presentation/brokerage-operation.dto.ts index 224bad9..d9919ac 100644 --- a/src/modules/investments/operations/presentation/brokerage-operation.dto.ts +++ b/src/modules/investments/operations/presentation/brokerage-operation.dto.ts @@ -20,6 +20,8 @@ import { InvestmentResourceMetaDto, } from '../../investment-resource.dto'; +const MIN_OPERATIONS_PAGE_SIZE = 1; + const MAX_CURSOR_LENGTH = 4096; const MAX_OPERATIONS_PAGE_SIZE = 1000; export const DEFAULT_OPERATIONS_PAGE_SIZE = 100; @@ -70,14 +72,14 @@ export class BrokerageOperationsQueryDto extends InvestmentReadQueryDto { cursor?: string; @ApiPropertyOptional({ - minimum: 1, + minimum: MIN_OPERATIONS_PAGE_SIZE, maximum: MAX_OPERATIONS_PAGE_SIZE, default: DEFAULT_OPERATIONS_PAGE_SIZE, }) @IsOptional() @Type(() => Number) @IsInt() - @Min(1) + @Min(MIN_OPERATIONS_PAGE_SIZE) @Max(MAX_OPERATIONS_PAGE_SIZE) limit?: number; } diff --git a/src/modules/investments/portfolio/application/forecast.service.ts b/src/modules/investments/portfolio/application/forecast.service.ts index 389e627..13370d2 100644 --- a/src/modules/investments/portfolio/application/forecast.service.ts +++ b/src/modules/investments/portfolio/application/forecast.service.ts @@ -9,6 +9,9 @@ import { type ForecastSourceVersion, } from '../infrastructure/forecast.repository'; +const MISSING_DATA_REVISION = 0; +const STALE_FRESHNESS_MS = 0; + @Injectable() export class ForecastService { constructor(private readonly repository: ForecastRepository) {} @@ -89,7 +92,7 @@ export class ForecastService { data, meta: investmentResourceMeta({ resourceKey: input.scopeKey, - dataRevision: String(stored?.dataRevision ?? 0), + dataRevision: String(stored?.dataRevision ?? MISSING_DATA_REVISION), availability: stored ? 'complete' : dependencies.some( @@ -100,7 +103,7 @@ export class ForecastService { fetchedAt: first ? new Date(first) : null, freshForMs: data && 'positionIsStale' in data && data.positionIsStale === true ? - 0 : + STALE_FRESHNESS_MS : Number.POSITIVE_INFINITY, work: work?.id ? { ...work, id: work.id } : null, dependencies, diff --git a/src/modules/investments/portfolio/application/portfolio.service.ts b/src/modules/investments/portfolio/application/portfolio.service.ts index faad7a6..dfd9390 100644 --- a/src/modules/investments/portfolio/application/portfolio.service.ts +++ b/src/modules/investments/portfolio/application/portfolio.service.ts @@ -43,6 +43,8 @@ import { PortfolioPositionDto, } from '../presentation/portfolio.dto'; +const MISSING_DATA_REVISION = 0; + const membershipFreshForMs = tbankSourceTtlMs.membership; const types: Record = { share: 'STOCK', @@ -167,7 +169,7 @@ export class PortfolioService { } const membershipMeta = investmentResourceMeta({ resourceKey: 'accounts/membership', - dataRevision: String(saved?.membershipRevision ?? 0), + dataRevision: String(saved?.membershipRevision ?? MISSING_DATA_REVISION), availability: saved?.membershipAt ? 'complete' : 'missing', fetchedAt: saved?.membershipAt ?? null, freshForMs: membershipFreshForMs, @@ -195,7 +197,7 @@ export class PortfolioService { null, meta: investmentResourceMeta({ resourceKey: `portfolio/${encodeURIComponent(account.accountId)}`, - dataRevision: String(account.portfolio?.dataRevision ?? 0), + dataRevision: String(account.portfolio?.dataRevision ?? MISSING_DATA_REVISION), availability: account.portfolio ? 'complete' : 'missing', fetchedAt: account.portfolio?.fetchedAt ?? null, freshForMs: portfolioFreshForMs, diff --git a/src/modules/investments/portfolio/domain/portfolio-structure.ts b/src/modules/investments/portfolio/domain/portfolio-structure.ts index c6e749d..cdc656f 100644 --- a/src/modules/investments/portfolio/domain/portfolio-structure.ts +++ b/src/modules/investments/portfolio/domain/portfolio-structure.ts @@ -2,10 +2,16 @@ import type { Money } from '../../../../common/money.types'; import { add, + DECIMAL_RADIX, decimalFromString, formatDecimal, } from '../../../../common/decimal'; +const ZERO_AMOUNT = 0n; +const INITIAL_UNVALUED_COUNT = 0; +const POSITION_INCREMENT = 1; +const ROUNDING_DIVISOR = 2n; + const SHARE_PERCENT_DECIMAL_PLACES = 6; const PERCENT_SCALED_FACTOR = 100_000_000n; @@ -33,7 +39,7 @@ export function portfolioStructure( const value = decimalFromString(money.value); // eslint-disable-next-line security/detect-object-injection -- The numeric index belongs to the fixed portfolio category array. group.values[index] = add(group.values[index], value); - group.hasNegativeValues ||= value.value < 0n; + group.hasNegativeValues ||= value.value < ZERO_AMOUNT; currencies.set(money.currency, group); }; for (const position of positions) { @@ -41,7 +47,7 @@ export function portfolioStructure( position.type : 'OTHER'; if (position.currentValue) include(type, position.currentValue); - else unvalued.set(type, (unvalued.get(type) ?? 0) + 1); + else unvalued.set(type, (unvalued.get(type) ?? INITIAL_UNVALUED_COUNT) + POSITION_INCREMENT); } for (const amount of cash) include('CASH', amount); @@ -52,7 +58,7 @@ export function portfolioStructure( .map(([currency, group]) => { const total = group.values.reduce(add, decimalFromString('0')); const canCalculateShares = - !unvalued.size && !group.hasNegativeValues && total.value > 0n; + !unvalued.size && !group.hasNegativeValues && total.value > ZERO_AMOUNT; const categories = group.values.map((value, index) => ({ // eslint-disable-next-line security/detect-object-injection -- The numeric index belongs to the fixed portfolio category array. type: categoryTypes[index], @@ -63,9 +69,9 @@ export function portfolioStructure( formatDecimal({ value: (value.value * - 10n ** BigInt(total.scale - value.scale) * + DECIMAL_RADIX ** BigInt(total.scale - value.scale) * PERCENT_SCALED_FACTOR + - total.value / 2n) / + total.value / ROUNDING_DIVISOR) / total.value, scale: SHARE_PERCENT_DECIMAL_PLACES, }) : diff --git a/src/modules/investments/portfolio/infrastructure/forecast.repository.ts b/src/modules/investments/portfolio/infrastructure/forecast.repository.ts index 714192b..8f77124 100644 --- a/src/modules/investments/portfolio/infrastructure/forecast.repository.ts +++ b/src/modules/investments/portfolio/infrastructure/forecast.repository.ts @@ -5,6 +5,8 @@ import type { Prisma } from '../../../../../generated/prisma/client'; import { PrismaService } from '../../../../prisma/prisma.service'; +const FIRST_BATCH_OFFSET = 0; + export type ForecastSourceVersion = { dataRevision: number; fetchedAt: Date; @@ -82,7 +84,7 @@ export class ForecastRepository { // Four identity parameters per source: stay below SQLite's parameter limit. const sources: ForecastSourceVersion[] = []; for ( - let offset = 0; + let offset = FIRST_BATCH_OFFSET; offset < input.sources.length; offset += forecastSourceReadBatchSize ) { diff --git a/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts b/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts index 8144fd0..22f3b5a 100644 --- a/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts +++ b/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts @@ -10,6 +10,8 @@ import { } from '../../../../integrations/tbank/tbank.api'; import { PrismaService } from '../../../../prisma/prisma.service'; +const EXPECTED_UPDATED_ACCOUNTS = 1; + type PortfolioAttempt = { attemptId: string; id: string; @@ -152,7 +154,7 @@ export class PortfolioRepository { }, data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' }, }); - return result.count === 1; + return result.count === EXPECTED_UPDATED_ACCOUNTS; } } diff --git a/src/modules/investments/tbank-sync/tbank-screen.ts b/src/modules/investments/tbank-sync/tbank-screen.ts index e26d79f..99d47f4 100644 --- a/src/modules/investments/tbank-sync/tbank-screen.ts +++ b/src/modules/investments/tbank-sync/tbank-screen.ts @@ -2,6 +2,9 @@ import { BadRequestException } from '@nestjs/common'; import type { InvestmentScreenDto } from './tbank-sync.dto'; +import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../common/time'; +import { MIN_INSTRUMENT_QUERY_LENGTH } from '../instrument-query'; + // Reject fields from other screens before reserving any source work. export function normalizeScreen( input: InvestmentScreenDto, @@ -61,7 +64,7 @@ export function normalizeScreen( } if ( ['bonds', 'stocks'].includes(input.screen) && - (!query || query.length < 2) + (!query || query.length < MIN_INSTRUMENT_QUERY_LENGTH) ) { fail(); } @@ -81,7 +84,7 @@ export function normalizeScreen( if ( input.screen === 'events' && input.to && - (input.from ?? new Date().toISOString().slice(0, 10)) > input.to + (input.from ?? new Date().toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH)) > input.to ) { fail(); } diff --git a/src/modules/investments/tbank-sync/tbank-sync.dto.ts b/src/modules/investments/tbank-sync/tbank-sync.dto.ts index 5f5c467..f7341ac 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.dto.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.dto.ts @@ -21,11 +21,14 @@ import { ValidateNested, } from 'class-validator'; +import { MAX_INSTRUMENT_QUERY_LENGTH, MIN_INSTRUMENT_QUERY_LENGTH } from '../instrument-query'; import { InvestmentResourceMetaDto } from '../investment-resource.dto'; import { operationCategories } from '../operations/operations.api'; +const MIN_ACCOUNT_ID_LENGTH = 1; +const MIN_SYNC_PAGE_SIZE = 1; + const maxAccountIdLength = 256; -const maxQueryLength = 80; const maxPageSize = 1000; export const investmentScreens = [ @@ -51,7 +54,7 @@ export class InvestmentScreenDto { @ApiPropertyOptional() @IsOptional() @IsString() - @MinLength(1) + @MinLength(MIN_ACCOUNT_ID_LENGTH) @MaxLength(maxAccountIdLength) accountId?: string; @@ -83,8 +86,8 @@ export class InvestmentScreenDto { @ApiPropertyOptional() @IsOptional() @IsString() - @MinLength(2) - @MaxLength(maxQueryLength) + @MinLength(MIN_INSTRUMENT_QUERY_LENGTH) + @MaxLength(MAX_INSTRUMENT_QUERY_LENGTH) query?: string; @ApiPropertyOptional({ enum: ['month', 'halfYear', 'year', 'all'] }) @@ -104,10 +107,10 @@ export class InvestmentScreenDto { @IsIn(operationCategories, { each: true }) categories?: (typeof operationCategories)[number][]; - @ApiPropertyOptional({ minimum: 1, maximum: maxPageSize }) + @ApiPropertyOptional({ minimum: MIN_SYNC_PAGE_SIZE, maximum: maxPageSize }) @IsOptional() @IsInt() - @Min(1) + @Min(MIN_SYNC_PAGE_SIZE) @Max(maxPageSize) limit?: number; } diff --git a/src/modules/investments/tbank-sync/tbank-sync.service.ts b/src/modules/investments/tbank-sync/tbank-sync.service.ts index 52f4668..0bb01da 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.service.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.service.ts @@ -8,6 +8,7 @@ import { randomUUID } from 'node:crypto'; import type { InvestmentResourceMetaDto } from '../investment-resource.dto'; +import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../common/time'; import { InstrumentMetadataService, TbankAccessService, @@ -41,6 +42,11 @@ import { TbankSyncRepository, } from './tbank-sync.repository'; +const FOREGROUND_CANDLE_PRIORITY = 0; +const EXCLUSIVE_END_OFFSET_MS = 1; +const PENDING_CHILD_COUNT = 0; +const COMPLETED_CHILD_COUNT = 1; + @Injectable() export class TbankSyncService { private readonly runId = randomUUID(); @@ -279,8 +285,8 @@ export class TbankSyncService { 'bond-events', ], { - from: source.from.toISOString().slice(0, 10), - to: source.to.toISOString().slice(0, 10), + from: source.from.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH), + to: source.to.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH), ticker: positions.find((position) => position.instrumentUid === uid) ?.ticker ?? undefined, @@ -312,7 +318,7 @@ export class TbankSyncService { }, now, true, - 0, + FOREGROUND_CANDLE_PRIORITY, ), ]); } @@ -479,7 +485,7 @@ export class TbankSyncService { await add( this.ledger.read(userId, accountId, { from, - to: new Date(Date.parse(end) - 1).toISOString(), + to: new Date(Date.parse(end) - EXCLUSIVE_END_OFFSET_MS).toISOString(), ...(instrumentUid ? { instrumentUid } : {}), sync: 'observe', }), @@ -509,7 +515,7 @@ export class TbankSyncService { state, errorCode: observed.errorCode, progress: { - completed: state === 'running' ? 0 : 1, + completed: state === 'running' ? PENDING_CHILD_COUNT : COMPLETED_CHILD_COUNT, total: 1, discovering: false, }, diff --git a/src/modules/investments/upcoming-events/upcoming-events.service.ts b/src/modules/investments/upcoming-events/upcoming-events.service.ts index 2f6534c..5c208f3 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.service.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.service.ts @@ -10,7 +10,7 @@ import { multiply, } from '../../../common/decimal'; import { MoneyDto } from '../../../common/money.dto'; -import { MILLISECONDS_PER_SECOND } from '../../../common/time'; +import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../../common/time'; import { decimal } from '../../../integrations/tbank/decimal'; import { InstrumentMetadataService, @@ -30,6 +30,9 @@ import { UpcomingEventsDto, UpcomingEventsQueryDto, } from './upcoming-events.dto'; + +const FIRST_BATCH_OFFSET = 0; + const DEFAULT_EVENT_WINDOW_DAYS = 30; const calendarReadBatchSize = 100; type Position = PortfolioPositionDto; @@ -81,7 +84,7 @@ export class UpcomingEventsService { ); const uids = [...new Set(identities.map((entry) => entry.uid))]; for ( - let offset = 0; + let offset = FIRST_BATCH_OFFSET; offset < uids.length; offset += calendarReadBatchSize ) { @@ -219,7 +222,7 @@ export function dividendEvent( type: 'dividend', date: new Date(Number(event.recordDate?.seconds) * MILLISECONDS_PER_SECOND) .toISOString() - .slice(0, 10), + .slice(ISO_DATE_START, ISO_DATE_LENGTH), dateKind: 'registry', projectedAmount: event.dividendNet ? projectedMoney( @@ -259,10 +262,10 @@ function supported(position: Position): boolean { } function dateWindow(query: UpcomingEventsQueryDto) { - const from = query.from ?? new Date().toISOString().slice(0, 10); + const from = query.from ?? new Date().toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH); const end = new Date(`${from}T00:00:00.000Z`); end.setUTCDate(end.getUTCDate() + DEFAULT_EVENT_WINDOW_DAYS); - const to = query.to ?? end.toISOString().slice(0, 10); + const to = query.to ?? end.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH); if (from > to) { throw new BadRequestException({ code: 'INVALID_EVENTS_PERIOD' }); } diff --git a/test/eslint-config.cjs b/test/eslint-config.cjs index 48446da..c186465 100644 --- a/test/eslint-config.cjs +++ b/test/eslint-config.cjs @@ -45,3 +45,19 @@ test('focused tests are rejected and generated code is excluded', async () => { assert.ok(await eslint.isPathIgnored('generated/prisma/client.ts')); assert.ok(await eslint.isPathIgnored('dist/src/main.js')); }); + +test('numeric literals require names, including previously ignored numbers and bigints', async () => { + const literals = [-60, -24, -10, -2, -1, 0, 1, 2, 10, 24, 60].map(String); + for (const literal of [...literals, ...literals.map((value) => `${value}n`)]) { + const [inline] = await eslint.lintText( + `export function value() { return ${literal}; }`, + { filePath: 'src/main.ts' }, + ); + assert.ok(inline.messages.some(({ ruleId }) => ruleId === '@typescript-eslint/no-magic-numbers'), literal); + const [named] = await eslint.lintText( + `const boundary = ${literal}; export function value() { return boundary; }`, + { filePath: 'src/main.ts' }, + ); + assert.ok(!named.messages.some(({ ruleId }) => ruleId === '@typescript-eslint/no-magic-numbers'), literal); + } +}); From 0303e60b64c507b03fba91f9987f024cf28c95cc Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Mon, 28 Sep 2026 12:33:56 +0300 Subject: [PATCH 05/22] style: align multiline ternary operators --- eslint.config.mjs | 6 +- src/common/filters/http-exception.filter.ts | 20 +-- .../logging/request-logging.middleware.ts | 26 +-- src/config/env.validation.ts | 6 +- .../tbank/instrument-metadata.repository.ts | 24 +-- src/integrations/tbank/tbank.client.ts | 24 +-- .../application/bank-cards.service.ts | 30 ++-- .../application/bank-operations.service.ts | 48 ++--- .../domain/bank-card.calculations.ts | 12 +- .../infrastructure/bank-cards.repository.ts | 6 +- .../brokerage-accounts.service.ts | 36 ++-- .../income/income-analytics.service.ts | 24 +-- .../investments/income/income-period.ts | 42 ++--- .../instruments/application/candle.service.ts | 68 ++++---- .../application/catalog-discovery.service.ts | 92 +++++----- .../application/catalog-enrichment.service.ts | 154 ++++++++-------- .../application/catalog-selection.ts | 12 +- .../application/instrument-market.ts | 6 +- .../application/instrument-position.ts | 22 +-- .../bonds/application/bond-catalog.service.ts | 24 +-- .../bonds/application/bond-income.service.ts | 68 ++++---- .../bonds/application/bond-market.service.ts | 28 +-- .../bonds/application/bond.spec.ts | 6 +- .../bonds/infrastructure/bond-money.mapper.ts | 6 +- .../instruments/domain/candle-coverage.ts | 6 +- .../infrastructure/candle.repository.ts | 26 +-- .../catalog-discovery.repository.ts | 66 +++---- .../application/get-stock-company.use-case.ts | 32 ++-- .../application/stock-catalog.service.ts | 26 +-- .../application/stock-income.service.ts | 50 +++--- .../application/stock-market.service.ts | 40 ++--- .../investments/investment-resource.mapper.ts | 42 ++--- .../investments/investment-sync-plan.ts | 86 ++++----- .../brokerage-operations-sync.service.ts | 20 +-- .../brokerage-operations.service.ts | 164 +++++++++--------- .../operations/domain/operations-sync-plan.ts | 30 ++-- .../brokerage-operation.mapper.ts | 22 +-- .../brokerage-operations.repository.ts | 138 +++++++-------- .../portfolio/application/forecast.service.ts | 42 ++--- .../application/portfolio.service.ts | 80 ++++----- .../portfolio/domain/portfolio-structure.ts | 14 +- .../infrastructure/forecast.repository.ts | 6 +- .../investments/tbank-sync/tbank-screen.ts | 22 +-- .../tbank-sync/tbank-sync.repository.ts | 6 +- .../tbank-sync/tbank-sync.service.ts | 146 ++++++++-------- .../upcoming-events.service.ts | 36 ++-- test/sqlite-regression.cjs | 14 +- test/sqlite.cjs | 10 +- test/tbank-ledger-local.e2e-spec.ts | 6 +- test/tbank-local-reads.e2e-spec.ts | 6 +- test/tbank-metadata-persistence.e2e-spec.ts | 6 +- test/tbank-read-baseline.e2e-spec.ts | 24 +-- test/tbank-screen-sync.e2e-spec.ts | 6 +- test/upcoming-events.e2e-spec.ts | 6 +- 54 files changed, 986 insertions(+), 982 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 02a5136..d5955f1 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -48,7 +48,11 @@ export default defineConfig( 'perfectionist/sort-classes': 'off', '@stylistic/brace-style': ['error', '1tbs', { allowSingleLine: false }], '@stylistic/arrow-parens': ['error', 'always'], - '@stylistic/operator-linebreak': ['error', 'after'], + '@stylistic/operator-linebreak': [ + 'error', + 'after', + { overrides: { '?': 'before', ':': 'before' } }, + ], 'import-x/no-duplicates': ['error', { 'prefer-inline': true }], }, }, diff --git a/src/common/filters/http-exception.filter.ts b/src/common/filters/http-exception.filter.ts index 1e715f7..e228b30 100644 --- a/src/common/filters/http-exception.filter.ts +++ b/src/common/filters/http-exception.filter.ts @@ -15,18 +15,18 @@ export class HttpExceptionFilter implements ExceptionFilter { const context = host.switchToHttp(); const response = context.getResponse(); const status = - exception instanceof HttpException ? - exception.getStatus() : - HttpStatus.INTERNAL_SERVER_ERROR; + exception instanceof HttpException + ? exception.getStatus() + : HttpStatus.INTERNAL_SERVER_ERROR; const payload = exception instanceof HttpException ? exception.getResponse() : undefined; const isValidation = Array.isArray( (payload as { message?: unknown })?.message, ); - const code = isValidation ? - 'VALIDATION_ERROR' : - this.codeFor(status, payload); + const code = isValidation + ? 'VALIDATION_ERROR' + : this.codeFor(status, payload); response.locals ??= {}; response.locals.publicErrorCode = code; response @@ -35,13 +35,13 @@ export class HttpExceptionFilter implements ExceptionFilter { .json({ status, code, - ...(isValidation ? - { + ...(isValidation + ? { errors: this.validationErrors( (payload as { message: ValidationError[] }).message, ), - } : - {}), + } + : {}), }); } diff --git a/src/common/logging/request-logging.middleware.ts b/src/common/logging/request-logging.middleware.ts index 10fcd9b..a942167 100644 --- a/src/common/logging/request-logging.middleware.ts +++ b/src/common/logging/request-logging.middleware.ts @@ -33,26 +33,26 @@ export class RequestLoggingMiddleware implements NestMiddleware { const routePath = (routeRequest as { route?: { path?: unknown } }).route ?.path; const route = - typeof routePath === 'string' ? - `${request.baseUrl}${routePath}` : - ''; + typeof routePath === 'string' + ? `${request.baseUrl}${routePath}` + : ''; const context = { method: request.method, route, status: response.statusCode, durationMs: Math.round(performance.now() - startedAt), - ...(response.locals.publicErrorCode ? - { code: response.locals.publicErrorCode as string } : - {}), + ...(response.locals.publicErrorCode + ? { code: response.locals.publicErrorCode as string } + : {}), }; const level = - response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR ? - 'error' : - response.statusCode === httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS || - response.statusCode === httpStatus.HTTP_STATUS_UNAUTHORIZED || - response.statusCode === httpStatus.HTTP_STATUS_FORBIDDEN ? - 'warn' : - 'info'; + response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR + ? 'error' + : response.statusCode === httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS || + response.statusCode === httpStatus.HTTP_STATUS_UNAUTHORIZED || + response.statusCode === httpStatus.HTTP_STATUS_FORBIDDEN + ? 'warn' + : 'info'; this.logger.event(level, 'http.request.completed', context); }); diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index ac378db..e22b3f8 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -105,9 +105,9 @@ export function validateEnv(config: Record): Environment { JWT_SECRET: jwtSecret, PORT: parsedPort, TOKEN_ENCRYPTION_KEY: tokenEncryptionKey, - ...(typeof tbankCaCertPath === 'string' && tbankCaCertPath ? - { TBANK_CA_CERT_PATH: tbankCaCertPath } : - {}), + ...(typeof tbankCaCertPath === 'string' && tbankCaCertPath + ? { TBANK_CA_CERT_PATH: tbankCaCertPath } + : {}), TBANK_GRPC_ENDPOINT: tbankEndpoint, TBANK_RATE_LIMIT_PER_SECOND: parsedTbankRateLimit, LOG_LEVEL: logLevel as Environment['LOG_LEVEL'], diff --git a/src/integrations/tbank/instrument-metadata.repository.ts b/src/integrations/tbank/instrument-metadata.repository.ts index 277dd99..7fc6819 100644 --- a/src/integrations/tbank/instrument-metadata.repository.ts +++ b/src/integrations/tbank/instrument-metadata.repository.ts @@ -43,12 +43,12 @@ export class InstrumentMetadataRepository { async readMany(identities: MetadataIdentity[]) { const identifiers = identities.flatMap((identity) => [ - ...(identity.instrumentUid?.trim() ? - [{ identifierType: 'uid', identifier: identity.instrumentUid.trim() }] : - []), - ...(identity.figi?.trim() ? - [{ identifierType: 'figi', identifier: identity.figi.trim() }] : - []), + ...(identity.instrumentUid?.trim() + ? [{ identifierType: 'uid', identifier: identity.instrumentUid.trim() }] + : []), + ...(identity.figi?.trim() + ? [{ identifierType: 'figi', identifier: identity.figi.trim() }] + : []), ]); if (!identifiers.length) { return new Map>(); @@ -233,9 +233,9 @@ export class InstrumentMetadataRepository { update: { ...data, dataRevision: { - increment: isDeepStrictEqual(previous?.metadata, payload) ? - UNCHANGED_REVISION_INCREMENT : - REVISION_INCREMENT, + increment: isDeepStrictEqual(previous?.metadata, payload) + ? UNCHANGED_REVISION_INCREMENT + : REVISION_INCREMENT, }, }, }); @@ -256,9 +256,9 @@ export class InstrumentMetadataRepository { matchedUids, fetchedAt: now, dataRevision: { - increment: isDeepStrictEqual(previous?.matchedUids, matchedUids) ? - UNCHANGED_REVISION_INCREMENT : - REVISION_INCREMENT, + increment: isDeepStrictEqual(previous?.matchedUids, matchedUids) + ? UNCHANGED_REVISION_INCREMENT + : REVISION_INCREMENT, }, }, }); diff --git a/src/integrations/tbank/tbank.client.ts b/src/integrations/tbank/tbank.client.ts index 62101ab..3cdcfb8 100644 --- a/src/integrations/tbank/tbank.client.ts +++ b/src/integrations/tbank/tbank.client.ts @@ -205,10 +205,10 @@ export class TbankClient { loadPackageDefinition(definition) as unknown as TbankNamespace ).tinkoff.public.invest.api.contract.v1; const caPath = config.get('TBANK_CA_CERT_PATH'); - const credentials = caPath ? + const credentials = caPath // eslint-disable-next-line security/detect-non-literal-fs-filename -- The CA path is trusted operator configuration, never supplied by an HTTP request. - ChannelCredentials.createSsl(readFileSync(caPath)) : - ChannelCredentials.createSsl(); + ? ChannelCredentials.createSsl(readFileSync(caPath)) + : ChannelCredentials.createSsl(); const endpoint = config.getOrThrow('TBANK_GRPC_ENDPOINT'); const create = (name: string) => new ( @@ -497,12 +497,12 @@ export class TbankClient { return response; } catch (error) { const grpcCode = (error as ServiceError).code; - const failureContext = queueFull ? - { code: 'TBANK_UNAVAILABLE' } : - { grpcCode }; - const retryable = queueFull ? - metadataCall : - [ + const failureContext = queueFull + ? { code: 'TBANK_UNAVAILABLE' } + : { grpcCode }; + const retryable = queueFull + ? metadataCall + : [ status.DEADLINE_EXCEEDED, status.RESOURCE_EXHAUSTED, status.UNAVAILABLE, @@ -517,9 +517,9 @@ export class TbankClient { await new Promise((resolve) => setTimeout( resolve, - metadataCall ? - attempts * METADATA_RETRY_DELAY_MS : - RPC_RETRY_DELAY_MS, + metadataCall + ? attempts * METADATA_RETRY_DELAY_MS + : RPC_RETRY_DELAY_MS, ), ); continue; diff --git a/src/modules/banking/bank-cards/application/bank-cards.service.ts b/src/modules/banking/bank-cards/application/bank-cards.service.ts index 2c5d92b..7a777f9 100644 --- a/src/modules/banking/bank-cards/application/bank-cards.service.ts +++ b/src/modules/banking/bank-cards/application/bank-cards.service.ts @@ -103,17 +103,17 @@ export class BankCardsService { id, name: dto.name ?? previous.name, bankName: - dto.bankName === undefined ? - (previous.bankName ?? undefined) : - (dto.bankName ?? undefined), + dto.bankName === undefined + ? (previous.bankName ?? undefined) + : (dto.bankName ?? undefined), lastFour: - dto.lastFour === undefined ? - (previous.lastFour ?? undefined) : - (dto.lastFour ?? undefined), + dto.lastFour === undefined + ? (previous.lastFour ?? undefined) + : (dto.lastFour ?? undefined), paymentSystem: - dto.paymentSystem === undefined ? - previous.paymentSystem : - dto.paymentSystem, + dto.paymentSystem === undefined + ? previous.paymentSystem + : dto.paymentSystem, openingBalance: dto.openingBalance ?? rubMoney(previous.openingBalanceKopecks), openingDate: dto.openingDate ?? previous.openingDate, @@ -143,12 +143,12 @@ export class BankCardsService { ...input, paymentSystem: input.paymentSystem ?? null, version: { increment: 1 }, - ...(dto.archived === undefined ? - {} : - { - archivedAt: dto.archived ? - (previous.archivedAt ?? new Date()) : - null, + ...(dto.archived === undefined + ? {} + : { + archivedAt: dto.archived + ? (previous.archivedAt ?? new Date()) + : null, }), }, }); diff --git a/src/modules/banking/bank-cards/application/bank-operations.service.ts b/src/modules/banking/bank-cards/application/bank-operations.service.ts index eef0336..870748b 100644 --- a/src/modules/banking/bank-cards/application/bank-operations.service.ts +++ b/src/modules/banking/bank-cards/application/bank-operations.service.ts @@ -43,29 +43,29 @@ export class BankOperationsService { row: BankCardOperation, ): Promise { const card = await this.repository.card(tx, row.userId, row.cardId); - const target = row.targetCardId ? - await this.repository.card(tx, row.userId, row.targetCardId) : - null; - const source = row.originalExpenseId ? - await this.repository.operation(tx, row.userId, row.originalExpenseId) : - null; + const target = row.targetCardId + ? await this.repository.card(tx, row.userId, row.targetCardId) + : null; + const source = row.originalExpenseId + ? await this.repository.operation(tx, row.userId, row.originalExpenseId) + : null; const categoryId = source?.categoryId ?? row.categoryId; - const category = categoryId ? - await tx.expenseCategory.findFirst({ + const category = categoryId + ? await tx.expenseCategory.findFirst({ where: { id: categoryId, userId: row.userId }, - }) : - null; + }) + : null; const refunds = - row.kind === BankOperationKind.Expense || source ? - await tx.bankCardOperation.findMany({ + row.kind === BankOperationKind.Expense || source + ? await tx.bankCardOperation.findMany({ where: { userId: row.userId, originalExpenseId: source?.id ?? row.id, deletedAt: null, ...(source ? { id: { not: row.id } } : {}), }, - }) : - []; + }) + : []; return { id: row.id, kind: row.kind as BankOperationDto['kind'], @@ -78,12 +78,12 @@ export class BankOperationsService { originalExpenseId: row.originalExpenseId, amount: rubMoney(row.amountKopecks), availableRefund: - row.kind === BankOperationKind.Expense || source ? - rubMoney( + row.kind === BankOperationKind.Expense || source + ? rubMoney( (source?.amountKopecks ?? row.amountKopecks) - refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS), - ) : - null, + ) + : null, date: row.date, description: row.description, version: row.version, @@ -336,9 +336,9 @@ export class BankOperationsService { query: BankOperationsQueryDto | BankPageQueryDto, expenses = false, ) { - const period = expenses ? - undefined : - periodBounds(query as BankOperationsQueryDto); + const period = expenses + ? undefined + : periodBounds(query as BankOperationsQueryDto); return this.prisma.$transaction(async (tx) => { await this.repository.card(tx, userId, id); const limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE, @@ -347,9 +347,9 @@ export class BankOperationsService { userId, deletedAt: null, OR: [{ cardId: id }, { targetCardId: id }], - ...(period ? - { date: { gte: period.from, lte: period.to } } : - { kind: BankOperationKind.Expense }), + ...(period + ? { date: { gte: period.from, lte: period.to } } + : { kind: BankOperationKind.Expense }), }; const total = await tx.bankCardOperation.count({ where }); const rows = await tx.bankCardOperation.findMany({ diff --git a/src/modules/banking/bank-cards/domain/bank-card.calculations.ts b/src/modules/banking/bank-cards/domain/bank-card.calculations.ts index cf1a0a3..cba24a7 100644 --- a/src/modules/banking/bank-cards/domain/bank-card.calculations.ts +++ b/src/modules/banking/bank-cards/domain/bank-card.calculations.ts @@ -81,13 +81,13 @@ export function bankSummary( continue; } const expense = - operation.kind === BankOperationKind.Expense ? - operation.amountKopecks : - ZERO_KOPECKS; + operation.kind === BankOperationKind.Expense + ? operation.amountKopecks + : ZERO_KOPECKS; const refund = - operation.kind === BankOperationKind.Refund ? - operation.amountKopecks : - ZERO_KOPECKS; + operation.kind === BankOperationKind.Refund + ? operation.amountKopecks + : ZERO_KOPECKS; expenses += expense; refunds += refund; if (operation.categoryId !== null) { diff --git a/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts b/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts index 2c5e9aa..52a54e4 100644 --- a/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts +++ b/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts @@ -40,9 +40,9 @@ export class BankCardsRepository { }); } catch (error) { const code = - error instanceof Prisma.PrismaClientKnownRequestError ? - error.code : - ''; + error instanceof Prisma.PrismaClientKnownRequestError + ? error.code + : ''; const busy = code === 'P2034' || code === 'P2028' || diff --git a/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts index d2c1234..8cce797 100644 --- a/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts @@ -12,15 +12,15 @@ export class BrokerageAccountsService { async list(userId: string, observe = false) { const saved = await this.portfolio.saved(userId, 'all', observe); const data: BrokerageAccountDto[] | null = - saved.membership.availability === 'missing' ? - null : - saved.accounts.map((account) => ({ + saved.membership.availability === 'missing' + ? null + : saved.accounts.map((account) => ({ id: account.id, name: account.name, type: account.type as BrokerageAccountDto['type'], openedOn: account.openedOn, - portfolioSummary: account.snapshot ? - { + portfolioSummary: account.snapshot + ? { totalsByCurrency: account.snapshot.totalsByCurrency, stockPositionCount: account.snapshot.positions.filter( (position) => position.type === 'STOCK', @@ -28,8 +28,8 @@ export class BrokerageAccountsService { bondPositionCount: account.snapshot.positions.filter( (position) => position.type === 'BOND', ).length, - } : - null, + } + : null, })); return { data, @@ -47,18 +47,18 @@ export class BrokerageAccountsService { ) .digest('base64url'), availability: - data === null ? - 'missing' : - saved.accounts.some((account) => !account.snapshot) ? - 'partial' : - 'complete', - fetchedAt: saved.membership.lastSuccessfulAt ? - new Date(saved.membership.lastSuccessfulAt) : - null, + data === null + ? 'missing' + : saved.accounts.some((account) => !account.snapshot) + ? 'partial' + : 'complete', + fetchedAt: saved.membership.lastSuccessfulAt + ? new Date(saved.membership.lastSuccessfulAt) + : null, freshForMs: 300_000, - work: saved.membership.sync.id ? - { ...saved.membership.sync, id: saved.membership.sync.id } : - null, + work: saved.membership.sync.id + ? { ...saved.membership.sync, id: saved.membership.sync.id } + : null, dependencies: saved.accounts.map((account) => account.meta), }), }; diff --git a/src/modules/investments/income/income-analytics.service.ts b/src/modules/investments/income/income-analytics.service.ts index 5072d42..0c8e551 100644 --- a/src/modules/investments/income/income-analytics.service.ts +++ b/src/modules/investments/income/income-analytics.service.ts @@ -96,9 +96,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 +119,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 : STALE_FRESHNESS_MS, work: work?.id ? { ...work, id: work.id } : null, coverage: current.meta.coverage, @@ -144,13 +144,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), diff --git a/src/modules/investments/income/income-period.ts b/src/modules/investments/income/income-period.ts index 087d39e..32f91699 100644 --- a/src/modules/investments/income/income-period.ts +++ b/src/modules/investments/income/income-period.ts @@ -39,24 +39,24 @@ export function planIncomePeriod( 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', }; } @@ -69,20 +69,20 @@ export function rangeBuckets( } const buckets: DateOnlyRange[] = []; let cursor = - granularity === 'week' ? - addDays( + granularity === 'week' + ? addDays( range.from, -( (utcDate(range.from).getUTCDay() + DAYS_PER_WEEK - MONDAY_WEEKDAY_INDEX) % DAYS_PER_WEEK ), - ) : - `${range.from.slice(ISO_DATE_START, ISO_MONTH_LENGTH)}-01`; + ) + : `${range.from.slice(ISO_DATE_START, ISO_MONTH_LENGTH)}-01`; while (cursor <= range.to) { const end = - granularity === 'week' ? - addDays(cursor, DAYS_PER_WEEK - INCLUSIVE_DAY_OFFSET) : - endOfMonth(cursor); + granularity === 'week' + ? addDays(cursor, DAYS_PER_WEEK - INCLUSIVE_DAY_OFFSET) + : endOfMonth(cursor); buckets.push({ from: cursor < range.from ? range.from : cursor, to: end > range.to ? range.to : end, @@ -153,9 +153,9 @@ function yearAgo(range: DateOnlyRange): DateOnlyRange | null { const shift = (value: string) => { const [year, month, day] = dateParts(value); const result = `${String(year - PREVIOUS_YEAR_OFFSET).padStart(ISO_YEAR_LENGTH, '0')}-${String(month).padStart(DATE_COMPONENT_DIGITS, '0')}-${String(day).padStart(DATE_COMPONENT_DIGITS, '0')}`; - return utcDate(result).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) === result ? - result : - null; + return utcDate(result).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) === result + ? result + : null; }; const from = shift(range.from); const to = shift(range.to); diff --git a/src/modules/investments/instruments/application/candle.service.ts b/src/modules/investments/instruments/application/candle.service.ts index 5e17cdc..9566d4e 100644 --- a/src/modules/investments/instruments/application/candle.service.ts +++ b/src/modules/investments/instruments/application/candle.service.ts @@ -70,9 +70,9 @@ function rangeFor( if (!history) { throw new ServiceUnavailableException({ code: - input.instrumentType === 'share' ? - 'STOCK_HISTORY_START_UNAVAILABLE' : - 'BOND_HISTORY_START_UNAVAILABLE', + input.instrumentType === 'share' + ? 'STOCK_HISTORY_START_UNAVAILABLE' + : 'BOND_HISTORY_START_UNAVAILABLE', }); } from.setTime(utcDay(new Date(history)).valueOf()); @@ -114,9 +114,9 @@ export class CandleService { throw new NotFoundException(); } const now = new Date(); - const range = detail.instrument ? - rangeFor(input, detail.instrument, now) : - null; + const range = detail.instrument + ? rangeFor(input, detail.instrument, now) + : null; const saved = range ? await this.repository.read(input.uid, range) : null; const gaps = range && saved ? candleGaps(range, saved.coverage, now) : []; if (!observe && (!saved || gaps.length)) { @@ -139,14 +139,14 @@ export class CandleService { }); return { data: - !saved || !covered ? - null : - { + !saved || !covered + ? null + : { candles: saved.data, currency: - input.instrumentType === 'share' ? - detail.instrument?.currency?.toUpperCase() || null : - null, + input.instrumentType === 'share' + ? detail.instrument?.currency?.toUpperCase() || null + : null, historyFrom: timestamp(detail?.instrument?.first_1dayCandleDate), observedAt: fetchedAt?.toISOString() ?? now.toISOString(), }, @@ -159,8 +159,8 @@ export class CandleService { work, coverage: { requested: range ? [publicRange(range)] : [], - completeRanges: range ? - completeCoverage.flatMap((row) => { + completeRanges: range + ? completeCoverage.flatMap((row) => { const from = new Date( Math.max(row.from.valueOf(), range.from.valueOf()), ); @@ -168,22 +168,22 @@ export class CandleService { Math.min(row.to.valueOf(), range.to.valueOf()), ); return from < to ? [publicRange({ from, to })] : []; - }) : - [], + }) + : [], missingRanges: missing.map(publicRange), asOf: fetchedAt?.toISOString() ?? null, - historyBoundary: timestamp(detail?.instrument?.first_1dayCandleDate) ? - 'known' : - 'unknown', - historyState: complete ? - 'complete' : - work?.state === 'failed' ? - 'failed' : - work?.state === 'running' ? - 'loading' : - covered ? - 'partial' : - 'missing', + historyBoundary: timestamp(detail?.instrument?.first_1dayCandleDate) + ? 'known' + : 'unknown', + historyState: complete + ? 'complete' + : work?.state === 'failed' + ? 'failed' + : work?.state === 'running' + ? 'loading' + : covered + ? 'partial' + : 'missing', }, dependencies: [ detail.meta, @@ -193,9 +193,9 @@ export class CandleService { dataRevision: String(row.dataRevision), availability: 'complete', fetchedAt: row.fetchedAt, - freshForMs: row.isComplete ? - Number.POSITIVE_INFINITY : - tailFreshForMs, + freshForMs: row.isComplete + ? Number.POSITIVE_INFINITY + : tailFreshForMs, work: null, }), ), @@ -291,9 +291,9 @@ export class CandleService { ) { const input = { uid, instrumentType, period: 'all' as const }; const range = - detail?.instrument && history ? - rangeFor(input, detail.instrument, now) : - null; + detail?.instrument && history + ? rangeFor(input, detail.instrument, now) + : null; if ( !range || candleGaps( diff --git a/src/modules/investments/instruments/application/catalog-discovery.service.ts b/src/modules/investments/instruments/application/catalog-discovery.service.ts index 54dc369..6105b3a 100644 --- a/src/modules/investments/instruments/application/catalog-discovery.service.ts +++ b/src/modules/investments/instruments/application/catalog-discovery.service.ts @@ -79,14 +79,14 @@ export class CatalogDiscoveryService { async search(userId: string, input: CatalogQuery, observe = false) { const discovery = await this.read(userId, input, observe); - const sources = discovery.data ? - await this.enrichment.readMany( + const sources = discovery.data + ? await this.enrichment.readMany( userId, input.instrumentType, discovery.data.map((item) => item.uid), observe, - ) : - []; + ) + : []; const dependencies = [ discovery.meta, ...sources.map((source) => source.meta), @@ -114,22 +114,22 @@ export class CatalogDiscoveryService { .map((dependency) => dependency.dataRevision) .join('/'), availability: - discovery.data === null ? - 'missing' : - sources.every((source) => source.meta.availability === 'complete') ? - 'complete' : - 'partial', - fetchedAt: discovery.meta.lastSuccessfulAt ? - new Date(discovery.meta.lastSuccessfulAt) : - null, + discovery.data === null + ? 'missing' + : sources.every((source) => source.meta.availability === 'complete') + ? 'complete' + : 'partial', + fetchedAt: discovery.meta.lastSuccessfulAt + ? new Date(discovery.meta.lastSuccessfulAt) + : null, freshForMs: tbankSourceTtlMs.catalog, - work: discovery.meta.sync.id ? - { + work: discovery.meta.sync.id + ? { id: discovery.meta.sync.id, state: discovery.meta.sync.state, errorCode: discovery.meta.sync.errorCode, - } : - null, + } + : null, dependencies, }), }; @@ -177,17 +177,17 @@ export class CatalogDiscoveryService { .map((dependency) => dependency.dataRevision) .join('/'), availability: source.meta.availability, - fetchedAt: source.meta.lastSuccessfulAt ? - new Date(source.meta.lastSuccessfulAt) : - null, + fetchedAt: source.meta.lastSuccessfulAt + ? new Date(source.meta.lastSuccessfulAt) + : null, freshForMs: tbankSourceTtlMs.metadata, - work: source.meta.sync.id ? - { + work: source.meta.sync.id + ? { id: source.meta.sync.id, state: source.meta.sync.state, errorCode: source.meta.sync.errorCode, - } : - null, + } + : null, dependencies, }), }; @@ -208,36 +208,36 @@ export class CatalogDiscoveryService { if (lookup.data && lookup.data.length > UNAMBIGUOUS_MATCH_COUNT) { throw new ConflictException({ code: - instrumentType === 'share' ? - 'STOCK_TICKER_AMBIGUOUS' : - 'BOND_TICKER_AMBIGUOUS', + instrumentType === 'share' + ? 'STOCK_TICKER_AMBIGUOUS' + : 'BOND_TICKER_AMBIGUOUS', }); } - const result = lookup.data ? - await this.detail(userId, instrumentType, lookup.data[0].uid, observe) : - null; + const result = lookup.data + ? await this.detail(userId, instrumentType, lookup.data[0].uid, observe) + : null; const dependencies = [lookup.meta, ...(result ? [result.meta] : [])]; return { - data: result?.data ? - { ...result.data, slug: ticker.trim().toUpperCase() } : - null, + data: result?.data + ? { ...result.data, slug: ticker.trim().toUpperCase() } + : null, meta: investmentResourceMeta({ resourceKey: `${instrumentType}/ticker/${ticker.trim().toUpperCase()}`, dataRevision: dependencies .map((dependency) => dependency.dataRevision) .join('/'), availability: result?.meta.availability ?? 'missing', - fetchedAt: lookup.meta.lastSuccessfulAt ? - new Date(lookup.meta.lastSuccessfulAt) : - null, + fetchedAt: lookup.meta.lastSuccessfulAt + ? new Date(lookup.meta.lastSuccessfulAt) + : null, freshForMs: tbankSourceTtlMs.metadata, - work: lookup.meta.sync.id ? - { + work: lookup.meta.sync.id + ? { id: lookup.meta.sync.id, state: lookup.meta.sync.state, errorCode: lookup.meta.sync.errorCode, - } : - null, + } + : null, dependencies, }), }; @@ -334,19 +334,19 @@ export class CatalogDiscoveryService { const token = await this.access.tokenForAttempt(attempt); if (token) { const found = - input.instrumentType === 'share' ? - await this.tbank.findShares(token, input.query) : - await this.tbank.findBonds(token, input.query); + input.instrumentType === 'share' + ? await this.tbank.findShares(token, input.query) + : await this.tbank.findBonds(token, input.query); success = await this.repository.publish( attempt, input, selectCatalogInstruments( - input.exactTicker ? - found.filter( + input.exactTicker + ? found.filter( (item) => item.ticker?.trim().toUpperCase() === input.query, - ) : - found, + ) + : found, input.query, input.instrumentType, ).slice( diff --git a/src/modules/investments/instruments/application/catalog-enrichment.service.ts b/src/modules/investments/instruments/application/catalog-enrichment.service.ts index edbed0c..1b8e892 100644 --- a/src/modules/investments/instruments/application/catalog-enrichment.service.ts +++ b/src/modules/investments/instruments/application/catalog-enrichment.service.ts @@ -124,9 +124,9 @@ export class CatalogEnrichmentService { range, ); } - const work = inputs.length ? - await this.repository.workMany(userId, revision, inputs) : - []; + const work = inputs.length + ? await this.repository.workMany(userId, revision, inputs) + : []; const byScope = new Map(work.map((row) => [row.scopeKey, row])); return inputs.map((input) => { const row = rows.find( @@ -136,16 +136,16 @@ export class CatalogEnrichmentService { const payload = row?.payload as CatalogPayload | undefined; return { input, - version: row ? - { + version: row + ? { instrumentUid: row.instrumentUid, source: row.source, kind: row.kind, rangeKey: row.rangeKey, dataRevision: row.dataRevision, fetchedAt: row.fetchedAt, - } : - null, + } + : null, payload, instrument: payload?.instrument ?? null, price: payload?.price ?? null, @@ -177,9 +177,9 @@ export class CatalogEnrichmentService { ) { const inputs = sourceInputs(instrumentType, uids, kinds, range); const reservation = this.reservations.then(async () => { - const rows = inputs.length ? - await this.repository.readSources(inputs) : - []; + const rows = inputs.length + ? await this.repository.readSources(inputs) + : []; const ids: string[] = []; for (const input of inputs) { const row = rows.find( @@ -242,18 +242,18 @@ export class CatalogEnrichmentService { observe = false, ) { const kinds: CatalogSource['kind'][] = - section === 'company' ? - ['detail', 'description', 'fundamentals'] : - [ - ...(instrumentType === 'bond' ? - ['bond-current' as const] : - ['detail' as const]), + section === 'company' + ? ['detail', 'description', 'fundamentals'] + : [ + ...(instrumentType === 'bond' + ? ['bond-current' as const] + : ['detail' as const]), 'last-price', 'trading-status', 'comparison', - ...(section === 'metrics' ? - ['coupons' as const, 'yield' as const, 'rating' as const] : - []), + ...(section === 'metrics' + ? ['coupons' as const, 'yield' as const, 'rating' as const] + : []), ]; const sources = await this.readMany( userId, @@ -279,18 +279,18 @@ export class CatalogEnrichmentService { dataRevision: dependencies .map((dependency) => dependency.dataRevision) .join('/'), - availability: !first.instrument ? - 'missing' : - complete ? - 'complete' : - 'partial', - fetchedAt: first.meta.lastSuccessfulAt ? - new Date(first.meta.lastSuccessfulAt) : - null, + availability: !first.instrument + ? 'missing' + : complete + ? 'complete' + : 'partial', + fetchedAt: first.meta.lastSuccessfulAt + ? new Date(first.meta.lastSuccessfulAt) + : null, freshForMs: - section === 'company' ? - tbankSourceTtlMs.company : - tbankSourceTtlMs.quote, + section === 'company' + ? tbankSourceTtlMs.company + : tbankSourceTtlMs.quote, work: null, dependencies, }); @@ -315,9 +315,9 @@ export class CatalogEnrichmentService { ): Promise { if (input.kind === 'detail' || input.kind === 'bond-current') { const instrument = - input.instrumentType === 'share' ? - await this.tbank.getShare(token, input.uid) : - await this.tbank.getBond(token, input.uid); + input.instrumentType === 'share' + ? await this.tbank.getShare(token, input.uid) + : await this.tbank.getBond(token, input.uid); if (instrument && instrument.uid !== input.uid) { throw new Error('Catalog UID mismatch'); } @@ -400,9 +400,9 @@ export class CatalogEnrichmentService { } const details = await this.repository.readSources([ { ...input, kind: 'detail', rangeKey: undefined }, - ...(input.instrumentType === 'bond' ? - [{ ...input, kind: 'bond-current' as const, rangeKey: undefined }] : - []), + ...(input.instrumentType === 'bond' + ? [{ ...input, kind: 'bond-current' as const, rangeKey: undefined }] + : []), ]); const instrument = details .map((row) => (row.payload as CatalogPayload).instrument) @@ -415,36 +415,36 @@ export class CatalogEnrichmentService { } if (input.kind === 'description') { return { - description: instrument?.assetUid ? - await this.tbank.getAssetDescription(token, instrument?.assetUid) : - null, + description: instrument?.assetUid + ? await this.tbank.getAssetDescription(token, instrument?.assetUid) + : null, }; } if (input.kind === 'fundamentals') { return { - fundamentals: instrument?.assetUid ? - await this.tbank.getFundamentals(token, instrument?.assetUid) : - null, + fundamentals: instrument?.assetUid + ? await this.tbank.getFundamentals(token, instrument?.assetUid) + : null, }; } if (input.kind === 'coupons' || input.kind === 'dividends') { const year = Number(input.rangeKey); - const [from, to] = input.rangeKey?.startsWith('[') ? - (JSON.parse(input.rangeKey) as [string, string]) : - [ + const [from, to] = input.rangeKey?.startsWith('[') + ? (JSON.parse(input.rangeKey) as [string, string]) + : [ `${year - payoutHistoryYears}-01-01`, `${year + payoutFutureYears}-01-01`, ]; - return input.kind === 'coupons' ? - { + return input.kind === 'coupons' + ? { coupons: await this.tbank.getBondCoupons( token, input.uid, from, to, ), - } : - { + } + : { dividends: await this.tbank.getDividends( token, input.uid, @@ -461,20 +461,20 @@ export class CatalogEnrichmentService { if (input.kind === 'yield') { return { effectiveYield: - instrument?.ticker && instrument.isin && instrument.classCode ? - await this.moex.bondYield( + instrument?.ticker && instrument.isin && instrument.classCode + ? await this.moex.bondYield( instrument.ticker, instrument.isin, instrument.classCode, - ) : - null, + ) + : null, }; } if (input.kind === 'rating') { return { - creditRating: instrument?.isin ? - await this.ratings.rating(instrument.isin) : - null, + creditRating: instrument?.isin + ? await this.ratings.rating(instrument.isin) + : null, }; } throw new Error('Unsupported catalog source'); @@ -533,17 +533,17 @@ export class CatalogEnrichmentService { prices?.find((entry) => entry.instrumentUid === item.input.uid) ?? null; const value = - item.input.kind === 'last-price' ? - { - price: price ? - { + item.input.kind === 'last-price' + ? { + price: price + ? { instrumentUid: item.input.uid, price: price.price, time: price.time, - } : - null, - } : - payload; + } + : null, + } + : payload; // Keep accepting observers while publication awaits SQLite. item.observers.unshift({ attempt: item.attempt, input: item.input }); for (const observer of item.observers) { @@ -596,17 +596,17 @@ function sourceInputs( uid, kind, // eslint-disable-next-line security/detect-object-injection -- Numeric queue indexes and validated instrument UIDs select internal data only. - ...(kind === 'bond-events' && (range?.tickers?.[uid] || range?.ticker) ? + ...(kind === 'bond-events' && (range?.tickers?.[uid] || range?.ticker) // eslint-disable-next-line security/detect-object-injection -- Numeric queue indexes and validated instrument UIDs select internal data only. - { ticker: range?.tickers?.[uid] || range?.ticker } : - {}), - ...(['coupons', 'dividends'].includes(kind) ? - { - rangeKey: range ? - JSON.stringify([range.from, range.to]) : - String(new Date().getUTCFullYear()), - } : - {}), + ? { ticker: range?.tickers?.[uid] || range?.ticker } + : {}), + ...(['coupons', 'dividends'].includes(kind) + ? { + rangeKey: range + ? JSON.stringify([range.from, range.to]) + : String(new Date().getUTCFullYear()), + } + : {}), })), ); } @@ -679,9 +679,9 @@ function sourcePolicy(kind: CatalogSource['kind']) { } function producerKey(input: CatalogSource) { return input.instrumentType === 'bond' && - (input.kind === 'detail' || input.kind === 'bond-current') ? - `bond-metadata:${input.uid}` : - catalogScope(input); + (input.kind === 'detail' || input.kind === 'bond-current') + ? `bond-metadata:${input.uid}` + : catalogScope(input); } const quotationScale = 9; diff --git a/src/modules/investments/instruments/application/catalog-selection.ts b/src/modules/investments/instruments/application/catalog-selection.ts index c91f26a..2928a9e 100644 --- a/src/modules/investments/instruments/application/catalog-selection.ts +++ b/src/modules/investments/instruments/application/catalog-selection.ts @@ -9,9 +9,9 @@ export function selectCatalogInstruments( ): (T & { uid: string })[] { const seen = new Set(); const kind = - instrumentType === 'share' ? - 'INSTRUMENT_TYPE_SHARE' : - 'INSTRUMENT_TYPE_BOND'; + instrumentType === 'share' + ? 'INSTRUMENT_TYPE_SHARE' + : 'INSTRUMENT_TYPE_BOND'; const preferredClass = instrumentType === 'share' ? 'TQBR' : 'TQOB'; return found .filter( @@ -29,9 +29,9 @@ export function selectCatalogInstruments( a.uid.localeCompare(b.uid), ) .filter((instrument) => { - const key = instrument.isin?.trim() ? - `isin:${instrument.isin.trim().toUpperCase()}` : - `uid:${instrument.uid}`; + const key = instrument.isin?.trim() + ? `isin:${instrument.isin.trim().toUpperCase()}` + : `uid:${instrument.uid}`; if (seen.has(key)) return false; seen.add(key); return true; diff --git a/src/modules/investments/instruments/application/instrument-market.ts b/src/modules/investments/instruments/application/instrument-market.ts index 4620119..53f9c52 100644 --- a/src/modules/investments/instruments/application/instrument-market.ts +++ b/src/modules/investments/instruments/application/instrument-market.ts @@ -10,9 +10,9 @@ const MIN_TIMESTAMP_SECONDS_EXCLUSIVE = 0; const MOSCOW_OFFSET_MS = 10_800_000; export function timestamp(value?: TbankTimestamp): null | string { const seconds = Number(value?.seconds); - return Number.isFinite(seconds) && seconds > MIN_TIMESTAMP_SECONDS_EXCLUSIVE ? - new Date(seconds * MILLISECONDS_PER_SECOND).toISOString() : - null; + return Number.isFinite(seconds) && seconds > MIN_TIMESTAMP_SECONDS_EXCLUSIVE + ? new Date(seconds * MILLISECONDS_PER_SECOND).toISOString() + : null; } export function moscowDay(iso: string): string { return new Date(Date.parse(iso) + MOSCOW_OFFSET_MS) diff --git a/src/modules/investments/instruments/application/instrument-position.ts b/src/modules/investments/instruments/application/instrument-position.ts index 9fdb930..6bbc2cb 100644 --- a/src/modules/investments/instruments/application/instrument-position.ts +++ b/src/modules/investments/instruments/application/instrument-position.ts @@ -43,8 +43,8 @@ export function instrumentPosition( currentValue, absoluteReturn, averagePurchasePrice: - positive && totalCost ? - { + positive && totalCost + ? { currency: totalCost.currency, value: formatDecimal( divide( @@ -53,15 +53,15 @@ export function instrumentPosition( PRICE_DECIMAL_PLACES, ), ), - } : - null, + } + : null, returnPercent: positive ? percent(absoluteReturn, totalCost) : null, accounts: positions.map((p) => ({ ...p, returnPercent: - decimalFromString(p.quantity).value > ZERO_QUANTITY ? - percent(p.absoluteReturn, cost(p)) : - null, + decimalFromString(p.quantity).value > ZERO_QUANTITY + ? percent(p.absoluteReturn, cost(p)) + : null, })), isStale: portfolio.isStale, observedAt: portfolio.lastSuccessfulAt, @@ -69,8 +69,8 @@ export function instrumentPosition( } function cost(position: PortfolioPositionDto): MoneyDto | null { const price = position.averagePurchasePrice; - return price ? - { + return price + ? { currency: price.currency, value: formatDecimal( multiply( @@ -78,8 +78,8 @@ function cost(position: PortfolioPositionDto): MoneyDto | null { decimalFromString(position.quantity), ), ), - } : - null; + } + : null; } function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null { const currency = values[0]?.currency; diff --git a/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts b/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts index df5972b..dbfcee5 100644 --- a/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts +++ b/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts @@ -37,18 +37,18 @@ export class BondCatalogService { data: result.data?.map((item) => { const short = toBond(item); - const detail = item.detail ? - toBond({ ...item.detail, uid: item.uid }) : - short; + const detail = item.detail + ? toBond({ ...item.detail, uid: item.uid }) + : short; const last = item.price; return { ...detail, classCode: detail.classCode ?? short.classCode, isin: detail.isin ?? short.isin, apiTradeAvailable: short.apiTradeAvailable, - pricePercent: last?.price ? - formatDecimal(decimal(last.price)) : - null, + pricePercent: last?.price + ? formatDecimal(decimal(last.price)) + : null, priceAt: timestamp(last?.time), }; }) ?? null, @@ -63,9 +63,9 @@ export class BondCatalogService { const result = await this.discovery.detail(userId, 'bond', uid, observe); return { ...result, - data: result.data ? - { ...toBond(result.data), slug: result.data.slug } : - null, + data: result.data + ? { ...toBond(result.data), slug: result.data.slug } + : null, }; } @@ -82,9 +82,9 @@ export class BondCatalogService { ); return { ...result, - data: result.data ? - { ...toBond(result.data), slug: result.data.slug } : - null, + data: result.data + ? { ...toBond(result.data), slug: result.data.slug } + : null, }; } } diff --git a/src/modules/investments/instruments/bonds/application/bond-income.service.ts b/src/modules/investments/instruments/bonds/application/bond-income.service.ts index 6096373..ab09102 100644 --- a/src/modules/investments/instruments/bonds/application/bond-income.service.ts +++ b/src/modules/investments/instruments/bonds/application/bond-income.service.ts @@ -62,9 +62,9 @@ export class BondIncomeService { observe, ); return { - data: result.values.instrument ? - bondMetrics(result.values, result.unavailable, result.observedAt) : - null, + data: result.values.instrument + ? bondMetrics(result.values, result.unavailable, result.observedAt) + : null, meta: result.meta, }; } @@ -99,18 +99,18 @@ export class BondIncomeService { throw new NotFoundException(); } const events = sources[1].payload?.coupons; - const position = portfolio.data ? - instrumentPosition(portfolio.data, uid, 'BOND') : - null; - const projected = events ? - bondCoupons( + const position = portfolio.data + ? instrumentPosition(portfolio.data, uid, 'BOND') + : null; + const projected = events + ? bondCoupons( events, position, { from, to, today }, sources[1].meta.lastSuccessfulAt ?? new Date().toISOString(), !portfolio.data, - ) : - undefined; + ) + : undefined; const result = await this.forecasts.result({ userId, revision: connection.revision, @@ -121,9 +121,9 @@ export class BondIncomeService { candidate: portfolio.data && sources[0].instrument ? projected : undefined, }); - return result.data || !projected ? - result : - { + return result.data || !projected + ? result + : { data: bondCoupons( events ?? [], null, @@ -146,19 +146,19 @@ export function bondCoupons( const { from, to, today } = window; const quantity = position?.accounts.length && - position.accounts.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY) ? - decimalFromString(position.quantity) : - null; + position.accounts.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY) + ? decimalFromString(position.quantity) + : null; const coupons = events .map((event) => { const paymentDate = timestamp(event.couponDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null; const recordDate = timestamp(event.fixDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null; const perBond = money(event.payOneBond); - const timing = !paymentDate ? - ('undated' as const) : - paymentDate >= today ? - ('upcoming' as const) : - ('past' as const); + const timing = !paymentDate + ? ('undated' as const) + : paymentDate >= today + ? ('upcoming' as const) + : ('past' as const); return { number: event.couponNumber ?? null, paymentDate, @@ -172,14 +172,14 @@ export function bondCoupons( perBond && timing === 'upcoming' && recordDate && - recordDate >= today ? - { + recordDate >= today + ? { currency: perBond.currency, value: formatDecimal( multiply(quantity, decimalFromString(perBond.value)), ), - } : - null, + } + : null, }; }) .sort((a, b) => (a.paymentDate ?? '').localeCompare(b.paymentDate ?? '')); @@ -225,8 +225,8 @@ export function bondMetrics( clean && coupon.currency === clean.currency && decimalFromString(coupon.value).value >= ZERO_AMOUNT && - decimalFromString(clean.value).value > ZERO_AMOUNT ? - formatDecimal( + decimalFromString(clean.value).value > ZERO_AMOUNT + ? formatDecimal( divide( multiply( decimalFromString(coupon.value), @@ -238,14 +238,14 @@ export function bondMetrics( ), COUPON_YIELD_DECIMAL_PLACES, ), - ) : - null; + ) + : null; const couponType = - next?.couponType && next.couponType !== 'COUPON_TYPE_UNSPECIFIED' ? - next.couponType : - instrument.floatingCouponFlag === true ? - 'COUPON_TYPE_FLOATING' : - null; + next?.couponType && next.couponType !== 'COUPON_TYPE_UNSPECIFIED' + ? next.couponType + : instrument.floatingCouponFlag === true + ? 'COUPON_TYPE_FLOATING' + : null; return { creditRating, coupon, diff --git a/src/modules/investments/instruments/bonds/application/bond-market.service.ts b/src/modules/investments/instruments/bonds/application/bond-market.service.ts index fbb4fcf..5902ea8 100644 --- a/src/modules/investments/instruments/bonds/application/bond-market.service.ts +++ b/src/modules/investments/instruments/bonds/application/bond-market.service.ts @@ -48,9 +48,9 @@ export class BondMarketService { observe, ); return { - data: result.values.instrument ? - bondQuote(result.values, result.unavailable, result.observedAt) : - null, + data: result.values.instrument + ? bondQuote(result.values, result.unavailable, result.observedAt) + : null, meta: result.meta, }; } @@ -83,8 +83,8 @@ export function bondQuote( const cleanPrice = pricePercent !== null && nominal && - decimalFromString(nominal.value).value > ZERO_AMOUNT ? - { + decimalFromString(nominal.value).value > ZERO_AMOUNT + ? { currency: nominal.currency, value: formatDecimal( divide( @@ -96,11 +96,11 @@ export function bondQuote( PRICE_DECIMAL_PLACES, ), ), - } : - null; + } + : null; const dirtyPrice = - cleanPrice && aci && aci.currency === cleanPrice.currency ? - { + cleanPrice && aci && aci.currency === cleanPrice.currency + ? { currency: cleanPrice.currency, value: formatDecimal( add( @@ -108,8 +108,8 @@ export function bondQuote( decimalFromString(aci.value), ), ), - } : - null; + } + : null; return { pricePercent, cleanPrice, @@ -118,9 +118,9 @@ export function bondQuote( priceAt, tradingStatus, changePercentagePoints: - last?.price && previous?.close ? - formatDecimal(subtract(decimal(last.price), decimal(previous.close))) : - null, + last?.price && previous?.close + ? formatDecimal(subtract(decimal(last.price), decimal(previous.close))) + : null, previousCloseDate: timestamp(previous?.time)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null, unavailable, observedAt, diff --git a/src/modules/investments/instruments/bonds/application/bond.spec.ts b/src/modules/investments/instruments/bonds/application/bond.spec.ts index 5f9098b..fed812e 100644 --- a/src/modules/investments/instruments/bonds/application/bond.spec.ts +++ b/src/modules/investments/instruments/bonds/application/bond.spec.ts @@ -123,9 +123,9 @@ describe('bond financial projections', () => { { couponDate: time('2026-10-10'), payOneBond: - scenario === 'unknown' ? - undefined : - money('30', scenario === 'currency' ? 'usd' : 'rub'), + scenario === 'unknown' + ? undefined + : money('30', scenario === 'currency' ? 'usd' : 'rub'), couponPeriod: scenario === 'period' ? 0 : 90, }, ], diff --git a/src/modules/investments/instruments/bonds/infrastructure/bond-money.mapper.ts b/src/modules/investments/instruments/bonds/infrastructure/bond-money.mapper.ts index 861a30b..fd115e7 100644 --- a/src/modules/investments/instruments/bonds/infrastructure/bond-money.mapper.ts +++ b/src/modules/investments/instruments/bonds/infrastructure/bond-money.mapper.ts @@ -4,7 +4,7 @@ import { type TbankMoney } from '../../../../../integrations/tbank/tbank.api'; export function money(value?: TbankMoney) { const currency = value?.currency?.toUpperCase(); - return value && currency ? - { currency, value: formatDecimal(decimal(value)) } : - null; + return value && currency + ? { currency, value: formatDecimal(decimal(value)) } + : null; } diff --git a/src/modules/investments/instruments/domain/candle-coverage.ts b/src/modules/investments/instruments/domain/candle-coverage.ts index 3db1224..ef2dad1 100644 --- a/src/modules/investments/instruments/domain/candle-coverage.ts +++ b/src/modules/investments/instruments/domain/candle-coverage.ts @@ -38,9 +38,9 @@ export function candleGaps( gaps = gaps.flatMap((gap) => { if (covered.to <= gap.from || covered.from >= gap.to) return [gap]; return [ - ...(covered.from > gap.from ? - [{ from: gap.from, to: covered.from }] : - []), + ...(covered.from > gap.from + ? [{ from: gap.from, to: covered.from }] + : []), ...(covered.to < gap.to ? [{ from: covered.to, to: gap.to }] : []), ]; }); diff --git a/src/modules/investments/instruments/infrastructure/candle.repository.ts b/src/modules/investments/instruments/infrastructure/candle.repository.ts index c9750cf..844916f 100644 --- a/src/modules/investments/instruments/infrastructure/candle.repository.ts +++ b/src/modules/investments/instruments/infrastructure/candle.repository.ts @@ -100,14 +100,14 @@ export class CandleRepository { priority: nextPriority, intent: { ...previousIntent, - ...(intent.force ? - { + ...(intent.force + ? { force: true, - ...(intent.now && intent.now > previousNow ? - { now: intent.now } : - {}), - } : - {}), + ...(intent.now && intent.now > previousNow + ? { now: intent.now } + : {}), + } + : {}), }, }, }); @@ -263,12 +263,12 @@ export class CandleRepository { // Preserve coverage outside the requested reconciliation boundaries. for (const previous of old) { for (const part of [ - ...(previous.from < range.from ? - [{ from: previous.from, to: range.from }] : - []), - ...(previous.to > range.to ? - [{ from: range.to, to: previous.to }] : - []), + ...(previous.from < range.from + ? [{ from: previous.from, to: range.from }] + : []), + ...(previous.to > range.to + ? [{ from: range.to, to: previous.to }] + : []), ]) { await tx.tbankCandleCoverage.create({ data: { ...previous, ...part }, diff --git a/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts b/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts index e0c916a..46fe512 100644 --- a/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts +++ b/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts @@ -66,8 +66,8 @@ export class CatalogDiscoveryRepository { read(input: CatalogQuery) { return this.prisma.$transaction(async (tx) => { - const alias = input.exactTicker ? - await tx.tbankInstrumentLookup.findUnique({ + const alias = input.exactTicker + ? await tx.tbankInstrumentLookup.findUnique({ where: { provider_identifierType_identifier: { provider, @@ -75,17 +75,17 @@ export class CatalogDiscoveryRepository { identifier: input.query, }, }, - }) : - null; - const discovery = input.exactTicker ? - alias ? - { + }) + : null; + const discovery = input.exactTicker + ? alias + ? { instrumentUids: alias.matchedUids, fetchedAt: alias.fetchedAt, dataRevision: alias.dataRevision, - } : - null : - await tx.tbankCatalogDiscovery.findUnique({ + } + : null + : await tx.tbankCatalogDiscovery.findUnique({ where: { provider_instrumentType_query: { provider, @@ -134,9 +134,9 @@ export class CatalogDiscoveryRepository { kind: input.kind, source: sourceProvider(input), rangeKey: - input.kind === 'detail' ? - input.instrumentType : - (input.rangeKey ?? ''), + input.kind === 'detail' + ? input.instrumentType + : (input.rangeKey ?? ''), })), }, }); @@ -232,9 +232,9 @@ export class CatalogDiscoveryRepository { increment: isDeepStrictEqual( previousAlias?.matchedUids, matchedUids, - ) ? - UNCHANGED_REVISION_INCREMENT : - REVISION_INCREMENT, + ) + ? UNCHANGED_REVISION_INCREMENT + : REVISION_INCREMENT, }, }, }); @@ -246,9 +246,9 @@ export class CatalogDiscoveryRepository { source: sourceProvider(input), kind: input.kind, rangeKey: - input.kind === 'detail' ? - input.instrumentType : - (input.rangeKey ?? ''), + input.kind === 'detail' + ? input.instrumentType + : (input.rangeKey ?? ''), }; const previous = await tx.tbankInstrumentSource.findUnique({ where: { provider_instrumentUid_source_kind_rangeKey: key }, @@ -359,9 +359,9 @@ export class CatalogDiscoveryRepository { instrumentKind: instrument.instrumentKind, apiTradeAvailableFlag: instrument.apiTradeAvailableFlag, currency: instrument.currency, - brand: instrument.brand ? - { logoName: instrument.brand.logoName } : - undefined, + brand: instrument.brand + ? { logoName: instrument.brand.logoName } + : undefined, }), ) as Prisma.InputJsonObject; await tx.tbankInstrument.upsert({ @@ -418,9 +418,9 @@ export class CatalogDiscoveryRepository { increment: isDeepStrictEqual( previous?.matchedUids, instrumentUids, - ) ? - UNCHANGED_REVISION_INCREMENT : - REVISION_INCREMENT, + ) + ? UNCHANGED_REVISION_INCREMENT + : REVISION_INCREMENT, }, }, }); @@ -444,9 +444,9 @@ export class CatalogDiscoveryRepository { increment: isDeepStrictEqual( previous?.instrumentUids, instrumentUids, - ) ? - UNCHANGED_REVISION_INCREMENT : - REVISION_INCREMENT, + ) + ? UNCHANGED_REVISION_INCREMENT + : REVISION_INCREMENT, }, }, }); @@ -480,9 +480,9 @@ export class CatalogDiscoveryRepository { } function sourceProvider(input: CatalogSource) { - return input.kind === 'yield' || input.kind === 'bond-events' ? - 'moex' : - input.kind === 'rating' ? - 'ncr' : - 'tbank'; + return input.kind === 'yield' || input.kind === 'bond-events' + ? 'moex' + : input.kind === 'rating' + ? 'ncr' + : 'tbank'; } diff --git a/src/modules/investments/instruments/stocks/application/get-stock-company.use-case.ts b/src/modules/investments/instruments/stocks/application/get-stock-company.use-case.ts index ea4df48..fcc3560 100644 --- a/src/modules/investments/instruments/stocks/application/get-stock-company.use-case.ts +++ b/src/modules/investments/instruments/stocks/application/get-stock-company.use-case.ts @@ -23,8 +23,8 @@ export class GetStockCompanyUseCase { observe, ); return { - data: result.values.instrument ? - { + data: result.values.instrument + ? { description: result.values.description ?? null, metrics: metrics( result.values.fundamentals ?? null, @@ -32,8 +32,8 @@ export class GetStockCompanyUseCase { ), unavailable: result.unavailable, observedAt: result.observedAt, - } : - null, + } + : null, meta: result.meta, }; } @@ -51,22 +51,22 @@ export function metrics( ][] = [ ['marketCapitalization', 'money', null], ['peRatioTtm', 'ratio', 'TTM'], - ...(bank ? - ([ + ...(bank + ? ([ ['priceToBookTtm', 'ratio', 'TTM'], ['roe', 'percent', null], - ] as typeof selection) : - ([ + ] as typeof selection) + : ([ ['revenueTtm', 'money', 'TTM'], ['ebitdaTtm', 'money', 'TTM'], ] as typeof selection)), ['netIncomeTtm', 'money', 'TTM'], - ...(!bank ? - ([ + ...(!bank + ? ([ ['totalDebtMrq', 'money', 'MRQ'], ['netDebtToEbitda', 'ratio', null], - ] as typeof selection) : - []), + ] as typeof selection) + : []), ]; return selection.flatMap(([key, unit, period]) => { // eslint-disable-next-line security/detect-object-injection -- Keys come from the fixed metric selection declared above. @@ -74,8 +74,8 @@ export function metrics( const currency = values.currency?.toUpperCase() || null; return typeof value === 'number' && Number.isFinite(value) && - (unit !== 'money' || currency) ? - [ + (unit !== 'money' || currency) + ? [ { key, value, @@ -83,7 +83,7 @@ export function metrics( period, currency: unit === 'money' ? currency : null, }, - ] : - []; + ] + : []; }); } diff --git a/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts b/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts index 30fc5c9..bb3762a 100644 --- a/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts +++ b/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts @@ -34,9 +34,9 @@ export class StockCatalogService { data: result.data?.map((item) => { const short = stock(item); - const detail = item.detail ? - stock({ ...item.detail, uid: item.uid }) : - short; + const detail = item.detail + ? stock({ ...item.detail, uid: item.uid }) + : short; const last = item.price; return { ...detail, @@ -44,12 +44,12 @@ export class StockCatalogService { isin: detail.isin ?? short.isin, apiTradeAvailable: short.apiTradeAvailable, price: - last?.price && detail.currency ? - { + last?.price && detail.currency + ? { currency: detail.currency, value: formatDecimal(decimal(last.price)), - } : - null, + } + : null, priceAt: timestamp(last?.time), }; }) ?? null, @@ -64,9 +64,9 @@ export class StockCatalogService { const result = await this.discovery.detail(userId, 'share', uid, observe); return { ...result, - data: result.data ? - { ...stock(result.data), slug: result.data.slug } : - null, + data: result.data + ? { ...stock(result.data), slug: result.data.slug } + : null, }; } @@ -83,9 +83,9 @@ export class StockCatalogService { ); return { ...result, - data: result.data ? - { ...stock(result.data), slug: result.data.slug } : - null, + data: result.data + ? { ...stock(result.data), slug: result.data.slug } + : null, }; } } diff --git a/src/modules/investments/instruments/stocks/application/stock-income.service.ts b/src/modules/investments/instruments/stocks/application/stock-income.service.ts index dceabb4..cdb47c4 100644 --- a/src/modules/investments/instruments/stocks/application/stock-income.service.ts +++ b/src/modules/investments/instruments/stocks/application/stock-income.service.ts @@ -69,18 +69,18 @@ export class StockIncomeService { throw new NotFoundException(); } const events = sources[1].payload?.dividends; - const position = portfolio.data ? - instrumentPosition(portfolio.data, uid, 'STOCK') : - null; - const projected = events ? - stockDividends( + const position = portfolio.data + ? instrumentPosition(portfolio.data, uid, 'STOCK') + : null; + const projected = events + ? stockDividends( events, position, { from, to, today }, sources[1].meta.lastSuccessfulAt ?? new Date().toISOString(), !portfolio.data, - ) : - undefined; + ) + : undefined; const result = await this.forecasts.result({ userId, revision: connection.revision, @@ -91,9 +91,9 @@ export class StockIncomeService { candidate: portfolio.data && sources[0].instrument ? projected : undefined, }); - return result.data || !projected ? - result : - { + return result.data || !projected + ? result + : { data: stockDividends( events ?? [], null, @@ -117,9 +117,9 @@ export function stockDividends( const quantity = position && position.accounts.length && - position.accounts.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY) ? - decimalFromString(position.quantity) : - null; + position.accounts.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY) + ? decimalFromString(position.quantity) + : null; const dividends: StockDividendDto[] = events .filter((event) => event.dividendType?.toLowerCase() !== 'cancelled') .map((event) => { @@ -128,29 +128,29 @@ export function stockDividends( const lastBuyDate = timestamp(event.lastBuyDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null; const currency = event.dividendNet?.currency?.toUpperCase(); const perShare = - currency && event.dividendNet ? - { currency, value: formatDecimal(decimal(event.dividendNet)) } : - null; + currency && event.dividendNet + ? { currency, value: formatDecimal(decimal(event.dividendNet)) } + : null; // A future payment can have an already-fixed register: keep the event, but do not estimate entitlement from today's holding. const eventDate = paymentDate ?? recordDate; - const timing: StockDividendDto['timing'] = eventDate ? - eventDate >= today ? - 'upcoming' : - 'past' : - 'undated'; + const timing: StockDividendDto['timing'] = eventDate + ? eventDate >= today + ? 'upcoming' + : 'past' + : 'undated'; const estimatedAmount = quantity && perShare && recordDate && recordDate >= today && - timing === 'upcoming' ? - { + timing === 'upcoming' + ? { currency: perShare.currency, value: formatDecimal( multiply(quantity, decimalFromString(perShare.value)), ), - } : - null; + } + : null; return { perShare, recordDate, diff --git a/src/modules/investments/instruments/stocks/application/stock-market.service.ts b/src/modules/investments/instruments/stocks/application/stock-market.service.ts index c4b3912..e03ad7a 100644 --- a/src/modules/investments/instruments/stocks/application/stock-market.service.ts +++ b/src/modules/investments/instruments/stocks/application/stock-market.service.ts @@ -44,9 +44,9 @@ export class StockMarketService { observe, ); return { - data: result.values.instrument ? - stockQuote(result.values, result.unavailable, result.observedAt) : - null, + data: result.values.instrument + ? stockQuote(result.values, result.unavailable, result.observedAt) + : null, meta: result.meta, }; } @@ -75,20 +75,20 @@ export function stockQuote( const priceAt = timestamp(last?.time); const currency = share?.currency?.toUpperCase(); const price = - last?.price && currency ? - { currency, value: formatDecimal(decimal(last.price)) } : - null; + last?.price && currency + ? { currency, value: formatDecimal(decimal(last.price)) } + : null; const previousPrice = - previous?.close && currency ? - { currency, value: formatDecimal(decimal(previous.close)) } : - null; + previous?.close && currency + ? { currency, value: formatDecimal(decimal(previous.close)) } + : null; const difference = - price && previousPrice ? - subtract( + price && previousPrice + ? subtract( decimalFromString(price.value), decimalFromString(previousPrice.value), - ) : - null; + ) + : null; return { price, priceAt, @@ -96,21 +96,21 @@ export function stockQuote( previousCloseDate: timestamp(previous?.time)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null, previousClose: previousPrice, change: - difference && currency ? - { currency, value: formatDecimal(difference) } : - null, + difference && currency + ? { currency, value: formatDecimal(difference) } + : null, changePercent: difference && previousPrice && - decimalFromString(previousPrice.value).value > ZERO_AMOUNT ? - formatDecimal( + decimalFromString(previousPrice.value).value > ZERO_AMOUNT + ? formatDecimal( divide( multiply(difference, decimalFromString('100')), decimalFromString(previousPrice.value), CHANGE_PERCENT_DECIMAL_PLACES, ), - ) : - null, + ) + : null, unavailable, observedAt, }; diff --git a/src/modules/investments/investment-resource.mapper.ts b/src/modules/investments/investment-resource.mapper.ts index dc7da49..e072acf 100644 --- a/src/modules/investments/investment-resource.mapper.ts +++ b/src/modules/investments/investment-resource.mapper.ts @@ -16,11 +16,11 @@ export function investmentResourceMeta(input: { const ownSync = { id: input.work?.id ?? null, state: - input.work?.state === 'running' ? - ('running' as const) : - input.work?.state === 'failed' ? - ('failed' as const) : - ('idle' as const), + input.work?.state === 'running' + ? ('running' as const) + : input.work?.state === 'failed' + ? ('failed' as const) + : ('idle' as const), errorCode: input.work?.errorCode ?? null, }; const states = [ @@ -34,11 +34,11 @@ export function investmentResourceMeta(input: { states.find((state) => state.state === 'queued') ?? states.find((state) => state.state === 'failed') ?? ownSync; - const sourceFreshness = input.fetchedAt ? - Date.now() - input.fetchedAt.valueOf() < input.freshForMs ? - ('fresh' as const) : - ('stale' as const) : - ('unknown' as const); + const sourceFreshness = input.fetchedAt + ? Date.now() - input.fetchedAt.valueOf() < input.freshForMs + ? ('fresh' as const) + : ('stale' as const) + : ('unknown' as const); const freshness = [ sourceFreshness, ...(input.dependencies ?? []) @@ -56,16 +56,16 @@ export function investmentResourceMeta(input: { .sort(); const meta = { resourceKey: input.resourceKey, - ...(input.unavailableReason ? - { unavailableReason: input.unavailableReason } : - {}), + ...(input.unavailableReason + ? { unavailableReason: input.unavailableReason } + : {}), dataRevision: input.dataRevision, availability: input.availability, - freshness: freshness.includes('unknown') ? - ('unknown' as const) : - freshness.includes('stale') ? - ('stale' as const) : - ('fresh' as const), + freshness: freshness.includes('unknown') + ? ('unknown' as const) + : freshness.includes('stale') + ? ('stale' as const) + : ('fresh' as const), lastSuccessfulAt: timestamps[0] ?? null, sync, ...(input.coverage ? { coverage: input.coverage } : {}), @@ -79,9 +79,9 @@ export function investmentResourceMeta(input: { ...meta, // The requested window is identified by resourceKey. Its moving "now" // boundary is not a change to saved coverage between status and GET. - coverage: meta.coverage ? - { ...meta.coverage, requested: undefined } : - undefined, + coverage: meta.coverage + ? { ...meta.coverage, requested: undefined } + : undefined, }), ) .digest('base64url'), diff --git a/src/modules/investments/investment-sync-plan.ts b/src/modules/investments/investment-sync-plan.ts index 50de3d0..c907c1c 100644 --- a/src/modules/investments/investment-sync-plan.ts +++ b/src/modules/investments/investment-sync-plan.ts @@ -84,16 +84,16 @@ export function planInvestmentScreen( const unavailable: { reason: string; resourceKey: string }[] = []; const operations = (from: Date, to: Date, instrumentUid?: string) => { const targets = accounts.flatMap((account) => - from < to ? - [ + from < to + ? [ { accountId: account.accountId, from, to, ...(instrumentUid ? { instrumentUid } : {}), }, - ] : - [], + ] + : [], ); sources.push( ...operationIntervals(targets).map((range) => ({ @@ -112,12 +112,12 @@ export function planInvestmentScreen( })), ); const knownOpening = - accounts.length && accounts.every((account) => account.openedOn) ? - new Date( + accounts.length && accounts.every((account) => account.openedOn) + ? new Date( // 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. `${accounts.map((account) => account.openedOn ?? '').sort()[0]}T00:00:00+03:00`, - ) : - undefined; + ) + : undefined; if (!context.membershipKnown) return { sources, unavailable, now }; if ( ['account', 'accounts', 'bond', 'events', 'stock'].includes(screen.screen) @@ -146,9 +146,9 @@ export function planInvestmentScreen( ); for (const range of [ period.selected, - ...(screen.screen === 'income' ? - [period.previous, ...(period.yearAgo ? [period.yearAgo] : [])] : - []), + ...(screen.screen === 'income' + ? [period.previous, ...(period.yearAgo ? [period.yearAgo] : [])] + : []), ]) { const bounds = dateOnlyBounds(range, now); operations(bounds.from, bounds.to); @@ -216,11 +216,11 @@ export function planInvestmentScreen( } const range = screen.candleRange ?? - (period === 'all' ? - instrument.historyFrom ? - { from: instrument.historyFrom, to: now } : - null : - { from, to: now }); + (period === 'all' + ? instrument.historyFrom + ? { from: instrument.historyFrom, to: now } + : null + : { from, to: now }); if (range) { sources.push({ ...identity, @@ -237,17 +237,17 @@ export function planInvestmentScreen( } if (['account', 'bond', 'events', 'stock'].includes(screen.screen)) { const day = - screen.screen === 'stock' || screen.screen === 'bond' ? - moscowDate(now) : - now.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH); + screen.screen === 'stock' || screen.screen === 'bond' + ? moscowDate(now) + : now.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH); const from = - screen.screen === 'events' && screen.from ? - new Date(`${screen.from}T00:00:00Z`) : - new Date(`${day}T00:00:00Z`); + screen.screen === 'events' && screen.from + ? new Date(`${screen.from}T00:00:00Z`) + : new Date(`${day}T00:00:00Z`); const to = - screen.screen === 'events' && screen.to ? - new Date(`${screen.to}T00:00:00Z`) : - new Date(from); + screen.screen === 'events' && screen.to + ? new Date(`${screen.to}T00:00:00Z`) + : new Date(from); if ( !(screen.screen === 'events' && screen.to) && screen.screen !== 'stock' && @@ -309,9 +309,9 @@ export function accountHistory( ): OperationInterval[] { return accounts.flatMap((account) => { const from = new Date( - account.openedOn ? - `${account.openedOn}T00:00:00Z` : - '1970-01-01T00:00:00Z', + account.openedOn + ? `${account.openedOn}T00:00:00Z` + : '1970-01-01T00:00:00Z', ); return from < now ? [{ accountId: account.accountId, from, to: now }] : []; }); @@ -324,22 +324,22 @@ export function ledgerRange( accounts: Account[], now: Date, ) { - const opening = accounts.every((account) => account.openedOn) ? + const opening = accounts.every((account) => account.openedOn) // 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. - accounts.map((account) => account.openedOn ?? '').sort()[0] : - undefined; - const from = fromValue ? - new Date(fromValue) : - instrumentUid ? - new Date(opening ? `${opening}T00:00:00Z` : '1970-01-01T00:00:00Z') : - new Date(Date.UTC(now.getUTCFullYear(), JANUARY_INDEX, FIRST_DAY_OF_MONTH)); - const end = toValue ? - new Date( - /^\d{4}-\d{2}-\d{2}$/.test(toValue) ? - `${toValue}T23:59:59.999Z` : - toValue, - ) : - now; + ? accounts.map((account) => account.openedOn ?? '').sort()[0] + : undefined; + const from = fromValue + ? new Date(fromValue) + : instrumentUid + ? new Date(opening ? `${opening}T00:00:00Z` : '1970-01-01T00:00:00Z') + : new Date(Date.UTC(now.getUTCFullYear(), JANUARY_INDEX, FIRST_DAY_OF_MONTH)); + const end = toValue + ? new Date( + /^\d{4}-\d{2}-\d{2}$/.test(toValue) + ? `${toValue}T23:59:59.999Z` + : toValue, + ) + : now; return { from, to: new Date(Math.min(end.valueOf() + (toValue ? EXCLUSIVE_END_OFFSET_MS : NO_END_OFFSET_MS), now.valueOf())), diff --git a/src/modules/investments/operations/application/brokerage-operations-sync.service.ts b/src/modules/investments/operations/application/brokerage-operations-sync.service.ts index 0bf9456..fdaad98 100644 --- a/src/modules/investments/operations/application/brokerage-operations-sync.service.ts +++ b/src/modules/investments/operations/application/brokerage-operations-sync.service.ts @@ -82,24 +82,24 @@ export class BrokerageOperationsSyncService { ...intervals.map((interval) => interval.to.valueOf()), ); const targets = intervals.map((interval) => - options.movingEnd && interval.to.valueOf() === movingTo ? - { ...interval, to: now } : - interval, + options.movingEnd && interval.to.valueOf() === movingTo + ? { ...interval, to: now } + : interval, ); const plan = planOperationRead(targets, coverage, now, options); const complete = plan.intervals.every( (interval) => interval.availability === 'complete', ); const handle = - !options.observe && plan.load.length ? - await this.request( + !options.observe && plan.load.length + ? await this.request( userId, plan.load, complete || Boolean(options.force), 'screen', connection.revision, - ) : - null; + ) + : null; if (!options.observe && !options.force && options.history) { const accountIds = new Set( intervals.map((interval) => interval.accountId), @@ -267,9 +267,9 @@ export class BrokerageOperationsSyncService { Math.ceil(interval.to.valueOf() / MILLISECONDS_PER_SECOND) + UPSTREAM_RANGE_MARGIN_SECONDS, ), }, - ...(interval.instrumentUid ? - { instrumentId: interval.instrumentUid } : - {}), + ...(interval.instrumentUid + ? { instrumentId: interval.instrumentUid } + : {}), cursor, limit: operationsPageSize, state: 'OPERATION_STATE_EXECUTED', diff --git a/src/modules/investments/operations/application/brokerage-operations.service.ts b/src/modules/investments/operations/application/brokerage-operations.service.ts index eb6b802..3a43f43 100644 --- a/src/modules/investments/operations/application/brokerage-operations.service.ts +++ b/src/modules/investments/operations/application/brokerage-operations.service.ts @@ -86,13 +86,13 @@ export class BrokerageOperationsService { const filterKey = JSON.stringify([ accountId, from.toISOString(), - query.to ? - new Date( - /^\d{4}-\d{2}-\d{2}$/.test(query.to) ? - `${query.to}T23:59:59.999Z` : - query.to, - ).toISOString() : - 'now', + query.to + ? new Date( + /^\d{4}-\d{2}-\d{2}$/.test(query.to) + ? `${query.to}T23:59:59.999Z` + : query.to, + ).toISOString() + : 'now', query.instrumentUid ?? null, categories, limit, @@ -129,9 +129,9 @@ export class BrokerageOperationsService { source.priority === 'screen' && source.accountId && source.from && - source.to ? - [{ accountId: source.accountId, from: source.from, to: source.to }] : - [], + source.to + ? [{ accountId: source.accountId, from: source.from, to: source.to }] + : [], ); if (query.sync !== 'observe') { const discovery = await this.membership.request( @@ -159,9 +159,9 @@ export class BrokerageOperationsService { accountId === 'all' || account.accountId === accountId, ) .flatMap((account) => - currentRange.from < currentRange.to ? - [{ accountId: account.accountId, ...currentRange }] : - [], + currentRange.from < currentRange.to + ? [{ accountId: account.accountId, ...currentRange }] + : [], ); await this.sync.readPlan(userId, targets, { movingEnd, @@ -181,16 +181,16 @@ export class BrokerageOperationsService { const saved = await this.repository.readPage(readInput); const accounts = saved?.connection.accounts ?? []; const targets = accounts.flatMap((account) => - from < to ? - [ + from < to + ? [ { accountId: account.accountId, from, to, instrumentUid: query.instrumentUid, }, - ] : - [], + ] + : [], ); const plan = planOperationRead(targets, saved?.coverage ?? [], now, { movingEnd, @@ -296,17 +296,17 @@ export class BrokerageOperationsService { const meta = investmentResourceMeta({ resourceKey: `operations/${createHash('sha256').update(filterKey).digest('base64url')}`, dataRevision: saved?.binding ?? 'missing', - availability: !available ? - 'missing' : - complete ? - 'complete' : - confirmed.length ? - 'partial' : - 'missing', + availability: !available + ? 'missing' + : complete + ? 'complete' + : confirmed.length + ? 'partial' + : 'missing', fetchedAt: lastSuccessfulAt, - freshForMs: plan.intervals.some((interval) => interval.stale) ? - STALE_FRESHNESS_MS : - Number.POSITIVE_INFINITY, + freshForMs: plan.intervals.some((interval) => interval.stale) + ? STALE_FRESHNESS_MS + : Number.POSITIVE_INFINITY, work: work ?? saved?.membershipWork ?? null, dependencies: [membershipMeta, ...metadataDependencies], coverage: { @@ -320,8 +320,8 @@ export class BrokerageOperationsService { (interval) => interval.accountId === account.accountId && interval.asOf, ), - ) ? - new Date( + ) + ? new Date( Math.min( ...accounts.map((account) => Math.max( @@ -334,42 +334,42 @@ export class BrokerageOperationsService { ), ), ), - ).toISOString() : - null, + ).toISOString() + : null, historyBoundary: knownBoundary ? 'known' : 'unknown', historyState: saved?.work.some( (work) => work.state === 'running' && work.priority > operationsSyncPriority.screen, - ) ? - 'loading' : - saved?.work.some( + ) + ? 'loading' + : saved?.work.some( (work) => work.state === 'failed' && work.priority > operationsSyncPriority.screen, - ) ? - 'failed' : - knownBoundary && - history.intervals.every( - (interval) => interval.availability === 'complete', - ) ? - 'complete' : - saved?.coverage.length ? - 'partial' : - 'missing', + ) + ? 'failed' + : knownBoundary && + history.intervals.every( + (interval) => interval.availability === 'complete', + ) + ? 'complete' + : saved?.coverage.length + ? 'partial' + : 'missing', }, }); const last = rows.at(LAST_ROW_INDEX); const hasNext = Boolean(saved && saved.rows.length > limit); return { data: - meta.availability === 'missing' ? - null : - { + meta.availability === 'missing' + ? null + : { accountId, items: rows.map((row) => ({ ...mapBrokerageOperation( row.payload as TbankOperationItem, row.occurredAt, ), - ...(accountId === 'all' ? - { + ...(accountId === 'all' + ? { account: { id: row.accountId, name: @@ -377,8 +377,8 @@ export class BrokerageOperationsService { (account) => account.accountId === row.accountId, )?.name ?? row.accountId, }, - } : - {}), + } + : {}), logoName: instruments .get(metadataKey(row.payload as TbankOperationItem)) @@ -386,16 +386,16 @@ export class BrokerageOperationsService { })), hasNext, nextCursor: - hasNext && last && saved ? - this.encodeCursor({ + hasNext && last && saved + ? this.encodeCursor({ binding: saved.binding, movingEnd, to: to.toISOString(), at: last.occurredAt.toISOString(), accountId: last.accountId, id: last.brokerOperationId, - }) : - null, + }) + : null, observedAt: now.toISOString(), }, meta, @@ -560,13 +560,13 @@ export class BrokerageOperationsService { // eslint-disable-next-line security/detect-object-injection -- The numeric index enumerates the three comparison periods. resourceKey: `income/${accountId}/${['selected', 'previous', 'yearAgo'][index]}/${period.key}`, dataRevision: saved?.binding ?? 'missing', - availability: !known ? - 'missing' : - complete ? - 'complete' : - sources.length ? - 'partial' : - 'missing', + availability: !known + ? 'missing' + : complete + ? 'complete' + : sources.length + ? 'partial' + : 'missing', fetchedAt, freshForMs: plan.intervals.some((range) => range.stale) ? STALE_FRESHNESS_MS : Infinity, work, @@ -579,8 +579,8 @@ export class BrokerageOperationsService { confirmed, ).map(serialize), asOf: - complete && targets.length ? - new Date( + complete && targets.length + ? new Date( Math.min( ...targets.map((target) => Math.max( @@ -595,24 +595,24 @@ export class BrokerageOperationsService { ), ), ), - ).toISOString() : - null, + ).toISOString() + : null, historyBoundary: (saved?.connection.accounts ?? []).every( (account) => account.openedOn, - ) ? - 'known' : - 'unknown', - historyState: complete ? - 'complete' : - sources.length ? - 'partial' : - 'missing', + ) + ? 'known' + : 'unknown', + historyState: complete + ? 'complete' + : sources.length + ? 'partial' + : 'missing', }, }); return { meta, - items: complete ? - (saved?.rows ?? []) + items: complete + ? (saved?.rows ?? []) .filter((row) => plan.intervals.some( (interval) => @@ -626,8 +626,8 @@ export class BrokerageOperationsService { row.payload as TbankOperationItem, row.occurredAt, ), - ...(accountId === 'all' ? - { + ...(accountId === 'all' + ? { account: { id: row.accountId, name: @@ -635,10 +635,10 @@ export class BrokerageOperationsService { (account) => account.accountId === row.accountId, )?.name ?? row.accountId, }, - } : - {}), - })) : - null, + } + : {}), + })) + : null, }; }); } diff --git a/src/modules/investments/operations/domain/operations-sync-plan.ts b/src/modules/investments/operations/domain/operations-sync-plan.ts index 3105e64..6477330 100644 --- a/src/modules/investments/operations/domain/operations-sync-plan.ts +++ b/src/modules/investments/operations/domain/operations-sync-plan.ts @@ -93,12 +93,12 @@ export function uncoveredOperationIntervals( return [fragment]; } return [ - ...(fragment.from < covered.from ? - [{ ...fragment, to: covered.from }] : - []), - ...(covered.to < fragment.to ? - [{ ...fragment, from: covered.to }] : - []), + ...(fragment.from < covered.from + ? [{ ...fragment, to: covered.from }] + : []), + ...(covered.to < fragment.to + ? [{ ...fragment, from: covered.to }] + : []), ]; }); } @@ -176,20 +176,20 @@ export function planOperationRead( // Completeness is bounded by the observed end, never by the new wall clock. return { ...effective, - availability: complete ? - ('complete' as const) : - sources.length ? - ('partial' as const) : - ('missing' as const), + availability: complete + ? ('complete' as const) + : sources.length + ? ('partial' as const) + : ('missing' as const), stale, - asOf: sources.length ? - new Date( + asOf: sources.length + ? new Date( Math.min( effective.to.valueOf(), Math.max(...sources.map((row) => row.asOf.valueOf())), ), - ) : - null, + ) + : null, }; }); return { intervals, load: operationIntervals(load) }; diff --git a/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts b/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts index 735c04b..17782cb 100644 --- a/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts +++ b/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts @@ -61,14 +61,14 @@ export function mapBrokerageOperation( logoName: null, payment: money(operation.payment), quantity: - (operationCategory(operation.type) === 'trade' ? - operation.quantityDone : - operation.quantity) === undefined ? - null : - String( - operationCategory(operation.type) === 'trade' ? - operation.quantityDone : - operation.quantity, + (operationCategory(operation.type) === 'trade' + ? operation.quantityDone + : operation.quantity) === undefined + ? null + : String( + operationCategory(operation.type) === 'trade' + ? operation.quantityDone + : operation.quantity, ), price: money(operation.price), accruedInterest: money(operation.accruedInt), @@ -82,9 +82,9 @@ function text(value: string | undefined): null | string { function money(value: TbankMoney | undefined) { const currency = value?.currency?.trim().toUpperCase(); - return currency && value ? - { currency, value: formatDecimal(decimal(value)) } : - null; + return currency && value + ? { currency, value: formatDecimal(decimal(value)) } + : null; } export function operationTimestamp( diff --git a/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts b/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts index 2d3adc9..5c08d06 100644 --- a/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts +++ b/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts @@ -110,9 +110,9 @@ export class BrokerageOperationsRepository { // 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. const oldest = accounts.map((account) => account.openedOn).sort()[0]; const openedAt = - oldest && accounts.every((account) => account.openedOn) ? - new Date(`${oldest}T00:00:00+03:00`) : - undefined; + oldest && accounts.every((account) => account.openedOn) + ? new Date(`${oldest}T00:00:00+03:00`) + : undefined; const periods = input.periods?.(openedAt).map((period) => { const now = new Date(); const bounds = dateOnlyBounds(period, now); @@ -176,21 +176,21 @@ export class BrokerageOperationsRepository { const rows = await tx.tbankOperation.findMany({ where: { ...scope, - ...(periods ? - { + ...(periods + ? { OR: periods.map((period) => ({ occurredAt: { gte: period.from, lt: period.to }, })), - } : - { occurredAt: { gte: input.from, lt: input.to } }), - ...(input.instrumentUid ? - { instrumentUid: input.instrumentUid } : - {}), - ...(input.categories.length ? - { category: { in: input.categories } } : - {}), - ...(after ? - { + } + : { occurredAt: { gte: input.from, lt: input.to } }), + ...(input.instrumentUid + ? { instrumentUid: input.instrumentUid } + : {}), + ...(input.categories.length + ? { category: { in: input.categories } } + : {}), + ...(after + ? { OR: [ { occurredAt: { lt: new Date(after.at) } }, { @@ -203,8 +203,8 @@ export class BrokerageOperationsRepository { brokerOperationId: { gt: after.id }, }, ], - } : - {}), + } + : {}), }, orderBy: [ { occurredAt: 'desc' }, @@ -305,9 +305,9 @@ export class BrokerageOperationsRepository { }, data: { historyFrom: new Date( - account.openedOn ? - `${account.openedOn}T00:00:00Z` : - '1970-01-01T00:00:00Z', + account.openedOn + ? `${account.openedOn}T00:00:00Z` + : '1970-01-01T00:00:00Z', ), historyBoundary: account.openedOn ? 'known' : 'unknown', }, @@ -383,42 +383,42 @@ export class BrokerageOperationsRepository { ) { const pending = previousIntent?.intervals.map(decodeInterval) ?? []; const additions = - previous.scopeKey === scopeKey ? - [] : - uncoveredOperationIntervals(intervals, pending); + previous.scopeKey === scopeKey + ? [] + : uncoveredOperationIntervals(intervals, pending); const work = await tx.tbankSyncWork.update({ where: { id: previous.id }, data: { intent: { atomic: previousIntent?.atomic ?? publicationAtomic, - atomicIntervals: previousIntent?.atomic ? - [ + atomicIntervals: previousIntent?.atomic + ? [ ...new Set([ ...(previousIntent.atomicIntervals ?? previousIntent.intervals), ...additions.map(intervalKey), ]), - ] : - [], + ] + : [], screenIntervals: [ ...new Set([ ...(previousIntent?.screenIntervals ?? - (previous.priority === operationsSyncPriority.screen ? - (previousIntent?.intervals ?? []) : - [])), - ...(priority === operationsSyncPriority.screen ? - [...pending, ...additions] + (previous.priority === operationsSyncPriority.screen + ? (previousIntent?.intervals ?? []) + : [])), + ...(priority === operationsSyncPriority.screen + ? [...pending, ...additions] .filter((item) => intervals.some((target) => operationIntervalsOverlap(item, target), ), ) - .map(intervalKey) : - []), + .map(intervalKey) + : []), ]), ], - intervals: (priority < previous.priority ? - [ + intervals: (priority < previous.priority + ? [ ...pending.filter((item) => intervals.some((target) => operationIntervalsOverlap(item, target), @@ -431,8 +431,8 @@ export class BrokerageOperationsRepository { operationIntervalsOverlap(item, target), ), ), - ] : - [...pending, ...additions] + ] + : [...pending, ...additions] ).map(intervalKey), }, priority: Math.min(priority, previous.priority), @@ -444,9 +444,9 @@ export class BrokerageOperationsRepository { previous && previous.state !== 'complete' && previous.state !== 'superseded'; - const retained = recovering ? - (previousIntent?.intervals.map(decodeInterval) ?? []) : - []; + const retained = recovering + ? (previousIntent?.intervals.map(decodeInterval) ?? []) + : []; // A bridge between two active groups must fence both producers before // either can publish a subset of the newly combined result. for (const joined of touching) { @@ -469,23 +469,23 @@ export class BrokerageOperationsRepository { await tx.tbankSyncStage.deleteMany({ where: { workId: previous.id } }); } const targets = - recovering && !upgradeGroup && touching.length <= SINGLE_WORK_GROUP ? - previous?.scopeKey === scopeKey ? - retained : - [...retained, ...uncoveredOperationIntervals(intervals, retained)] : - operationIntervals([...retained, ...intervals]); + recovering && !upgradeGroup && touching.length <= SINGLE_WORK_GROUP + ? previous?.scopeKey === scopeKey + ? retained + : [...retained, ...uncoveredOperationIntervals(intervals, retained)] + : operationIntervals([...retained, ...intervals]); const atomicTargets = [ ...(publicationAtomic ? intervals : []), - ...(recovering && previousIntent?.atomic ? - (previousIntent.atomicIntervals ?? previousIntent.intervals).map( + ...(recovering && previousIntent?.atomic + ? (previousIntent.atomicIntervals ?? previousIntent.intervals).map( decodeInterval, - ) : - []), + ) + : []), ...touching.flatMap((work) => { const joined = work.intent as OperationsIntent; - return joined.atomic ? - (joined.atomicIntervals ?? joined.intervals).map(decodeInterval) : - []; + return joined.atomic + ? (joined.atomicIntervals ?? joined.intervals).map(decodeInterval) + : []; }), ]; const intent = { @@ -506,9 +506,9 @@ export class BrokerageOperationsRepository { )) || ( previousIntent?.screenIntervals ?? - (previous?.priority === operationsSyncPriority.screen ? - (previousIntent?.intervals ?? []) : - []) + (previous?.priority === operationsSyncPriority.screen + ? (previousIntent?.intervals ?? []) + : []) ).some((key) => operationIntervalsOverlap(target, decodeInterval(key)), ), @@ -563,20 +563,20 @@ export class BrokerageOperationsRepository { marker.payload as PageProgress, ]), ); - const group = intent.atomic ? - (intent.atomicIntervals ?? intent.intervals) : - intent.intervals; + const group = intent.atomic + ? (intent.atomicIntervals ?? intent.intervals) + : intent.intervals; const nextKey = group.find((key) => !progress.get(key)?.complete); return { priority: work.priority, atomic: intent.atomic, intervals: group.map(decodeInterval), - next: nextKey ? - { + next: nextKey + ? { interval: decodeInterval(nextKey), cursor: progress.get(nextKey)?.cursor || undefined, - } : - null, + } + : null, }; } @@ -795,9 +795,9 @@ export class BrokerageOperationsRepository { where: { ...scope, occurredAt: { gte: interval.from, lt: interval.to }, - ...(interval.instrumentUid ? - { instrumentUid: interval.instrumentUid } : - {}), + ...(interval.instrumentUid + ? { instrumentUid: interval.instrumentUid } + : {}), }, }); const incoming = new Set(rows.map((row) => row.itemKey)); @@ -902,9 +902,9 @@ export class BrokerageOperationsRepository { intent.screenIntervals ?? (work.priority === operationsSyncPriority.screen ? intent.intervals : []) ).includes(key), - ) ? - operationsSyncPriority.screen : - operationsSyncPriority.history, + ) + ? operationsSyncPriority.screen + : operationsSyncPriority.history, progress: { remainingIntervals: remaining.length }, }, }); diff --git a/src/modules/investments/portfolio/application/forecast.service.ts b/src/modules/investments/portfolio/application/forecast.service.ts index 13370d2..f896501 100644 --- a/src/modules/investments/portfolio/application/forecast.service.ts +++ b/src/modules/investments/portfolio/application/forecast.service.ts @@ -36,8 +36,8 @@ export class ForecastService { ...input, portfolioProjection: input.portfolio.dataRevision, portfolios: (input.portfolio.dependencies ?? []).flatMap((dependency) => - dependency.resourceKey.startsWith('portfolio/') ? - [ + dependency.resourceKey.startsWith('portfolio/') + ? [ { accountId: decodeURIComponent( dependency.resourceKey.slice('portfolio/'.length), @@ -46,21 +46,21 @@ export class ForecastService { (dependency as InvestmentResourceMetaDto).dataRevision, ), }, - ] : - [], + ] + : [], ), sources: input.sources.flatMap((source) => source.version ? [source.version] : [], ), timestamps: dependencies.flatMap((dependency) => - dependency.lastSuccessfulAt ? - [ + dependency.lastSuccessfulAt + ? [ { resourceKey: dependency.resourceKey, at: dependency.lastSuccessfulAt, }, - ] : - [], + ] + : [], ), }); const timestamps = stored?.sourceTimestamps as @@ -78,33 +78,33 @@ export class ForecastService { payload && 'positionIsStale' in payload && 'positionAt' in payload && - typeof payload.positionAt === 'string' ? - { + typeof payload.positionAt === 'string' + ? { ...payload, positionIsStale: tbankSourceNeedsRefresh( 'portfolio', new Date(payload.positionAt), new Date(Date.now()), ), - } : - payload; + } + : payload; return { data, meta: investmentResourceMeta({ resourceKey: input.scopeKey, dataRevision: String(stored?.dataRevision ?? MISSING_DATA_REVISION), - availability: stored ? - 'complete' : - dependencies.some( + availability: stored + ? 'complete' + : dependencies.some( (dependency) => dependency.availability !== 'missing', - ) ? - 'partial' : - 'missing', + ) + ? 'partial' + : 'missing', fetchedAt: first ? new Date(first) : null, freshForMs: - data && 'positionIsStale' in data && data.positionIsStale === true ? - STALE_FRESHNESS_MS : - Number.POSITIVE_INFINITY, + data && 'positionIsStale' in data && data.positionIsStale === true + ? STALE_FRESHNESS_MS + : Number.POSITIVE_INFINITY, work: work?.id ? { ...work, id: work.id } : null, dependencies, }), diff --git a/src/modules/investments/portfolio/application/portfolio.service.ts b/src/modules/investments/portfolio/application/portfolio.service.ts index dfd9390..e6930dc 100644 --- a/src/modules/investments/portfolio/application/portfolio.service.ts +++ b/src/modules/investments/portfolio/application/portfolio.service.ts @@ -186,15 +186,15 @@ export class PortfolioService { name: account.name, type: account.type, openedOn: account.openedOn, - snapshot: account.portfolio ? - { + snapshot: account.portfolio + ? { ...(account.portfolio.payload as unknown as PortfolioDto), lastSuccessfulAt: account.portfolio.fetchedAt.toISOString(), isStale: Date.now() - account.portfolio.fetchedAt.valueOf() >= portfolioFreshForMs, - } : - null, + } + : null, meta: investmentResourceMeta({ resourceKey: `portfolio/${encodeURIComponent(account.accountId)}`, dataRevision: String(account.portfolio?.dataRevision ?? MISSING_DATA_REVISION), @@ -220,8 +220,8 @@ export class PortfolioService { ); const metadataDependencies = [...metadata.entries()].flatMap( ([key, source]) => - source.complete && source.data ? - [ + source.complete && source.data + ? [ investmentResourceMeta({ resourceKey: `instrument/metadata/${key}`, dataRevision: source.dataRevision, @@ -230,20 +230,20 @@ export class PortfolioService { freshForMs: tbankSourceTtlMs.metadata, work: null, }), - ] : - [], + ] + : [], ); const portfolios = saved.accounts.flatMap((account) => - account.snapshot ? - [ + account.snapshot + ? [ { ...account.snapshot, positions: account.snapshot.positions.map((position) => { const shared = metadata.get(metadataKey(position))?.data; return { ...position, - ...(shared ? - { + ...(shared + ? { displayName: shared.name?.trim() || shared.ticker?.trim() || @@ -252,29 +252,29 @@ export class PortfolioService { isin: shared.isin?.trim() || position.isin, logoName: shared.brand?.logoName?.trim() || position.logoName, - } : - {}), - ...(accountId === 'all' ? - { account: { id: account.id, name: account.name } } : - {}), + } + : {}), + ...(accountId === 'all' + ? { account: { id: account.id, name: account.name } } + : {}), }; }), }, - ] : - [], + ] + : [], ); const lastSuccessfulAt = // 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. portfolios.map((portfolio) => portfolio.lastSuccessfulAt).sort()[0] ?? saved.membership.lastSuccessfulAt; const data = - missing || !lastSuccessfulAt ? - null : - { + missing || !lastSuccessfulAt + ? null + : { lastSuccessfulAt, - isStale: portfolios.length ? - portfolios.some((portfolio) => portfolio.isStale) : - saved.membership.freshness !== 'fresh', + isStale: portfolios.length + ? portfolios.some((portfolio) => portfolio.isStale) + : saved.membership.freshness !== 'fresh', totalsByCurrency: sumMoneyByCurrency( portfolios.flatMap((portfolio) => portfolio.totalsByCurrency), ), @@ -306,15 +306,15 @@ export class PortfolioService { ]), metadataDependencies.map((dependency) => dependency.dataRevision), ]), - availability: missing ? - portfolios.length ? - 'partial' : - 'missing' : - 'complete', + availability: missing + ? portfolios.length + ? 'partial' + : 'missing' + : 'complete', fetchedAt: lastSuccessfulAt ? new Date(lastSuccessfulAt) : null, - freshForMs: portfolios.length ? - portfolioFreshForMs : - membershipFreshForMs, + freshForMs: portfolios.length + ? portfolioFreshForMs + : membershipFreshForMs, work: work.id ? { ...work, id: work.id } : null, dependencies: [ saved.membership, @@ -478,8 +478,8 @@ export class PortfolioService { const absoluteReturn = currentPrice && averagePurchasePrice && - currentPrice.currency === averagePurchasePrice.currency ? - { + currentPrice.currency === averagePurchasePrice.currency + ? { currency: currentPrice.currency, value: formatDecimal( multiply( @@ -490,8 +490,8 @@ export class PortfolioService { quantity, ), ), - } : - null; + } + : null; return { figi, instrumentUid, @@ -523,8 +523,8 @@ export class PortfolioService { private money(value: TbankMoney | undefined): MoneyDto | null { const currency = value?.currency?.trim().toUpperCase(); - return currency && value ? - { currency, value: formatDecimal(decimal(value)) } : - null; + return currency && value + ? { currency, value: formatDecimal(decimal(value)) } + : null; } } diff --git a/src/modules/investments/portfolio/domain/portfolio-structure.ts b/src/modules/investments/portfolio/domain/portfolio-structure.ts index cdc656f..6b01a9a 100644 --- a/src/modules/investments/portfolio/domain/portfolio-structure.ts +++ b/src/modules/investments/portfolio/domain/portfolio-structure.ts @@ -43,9 +43,9 @@ export function portfolioStructure( currencies.set(money.currency, group); }; for (const position of positions) { - const type = ['BOND', 'STOCK'].includes(position.type) ? - position.type : - 'OTHER'; + const type = ['BOND', 'STOCK'].includes(position.type) + ? position.type + : 'OTHER'; if (position.currentValue) include(type, position.currentValue); else unvalued.set(type, (unvalued.get(type) ?? INITIAL_UNVALUED_COUNT) + POSITION_INCREMENT); } @@ -65,8 +65,8 @@ export function portfolioStructure( value: formatDecimal(value), // Rounded to six decimal places using integer arithmetic. Only the // resulting dimensionless percentage is passed to chart geometry. - sharePercent: canCalculateShares ? - formatDecimal({ + sharePercent: canCalculateShares + ? formatDecimal({ value: (value.value * DECIMAL_RADIX ** BigInt(total.scale - value.scale) * @@ -74,8 +74,8 @@ export function portfolioStructure( total.value / ROUNDING_DIVISOR) / total.value, scale: SHARE_PERCENT_DECIMAL_PLACES, - }) : - null, + }) + : null, })); return { currency, diff --git a/src/modules/investments/portfolio/infrastructure/forecast.repository.ts b/src/modules/investments/portfolio/infrastructure/forecast.repository.ts index 8f77124..d7987db 100644 --- a/src/modules/investments/portfolio/infrastructure/forecast.repository.ts +++ b/src/modules/investments/portfolio/infrastructure/forecast.repository.ts @@ -58,9 +58,9 @@ export class ForecastRepository { where: { userId_revision_scopeKey: key }, }); const fallback = - previous?.membershipRevision === input.membershipRevision ? - previous : - null; + previous?.membershipRevision === input.membershipRevision + ? previous + : null; if (!input.candidate) return fallback; const portfolios = await tx.tbankPortfolio.findMany({ where: { diff --git a/src/modules/investments/tbank-sync/tbank-screen.ts b/src/modules/investments/tbank-sync/tbank-screen.ts index 99d47f4..ca228f0 100644 --- a/src/modules/investments/tbank-sync/tbank-screen.ts +++ b/src/modules/investments/tbank-sync/tbank-screen.ts @@ -100,23 +100,23 @@ export function normalizeScreen( ...(accountId ? { accountId } : {}), ...(input.from ? { from: input.from } : {}), ...(input.to ? { to: input.to } : {}), - ...(input.instrumentUid ? - { instrumentUid: input.instrumentUid.toLowerCase() } : - {}), + ...(input.instrumentUid + ? { instrumentUid: input.instrumentUid.toLowerCase() } + : {}), ...(query ? { query } : {}), ...(input.candlePeriod ? { candlePeriod: input.candlePeriod } : {}), - ...(input.candleRange ? - { + ...(input.candleRange + ? { candleRange: { from: new Date(input.candleRange.from).toISOString(), to: new Date(input.candleRange.to).toISOString(), }, - } : - {}), - ...(input.categories ? - // 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. - { categories: [...new Set(input.categories)].sort() } : - {}), + } + : {}), + ...(input.categories + // 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. + ? { categories: [...new Set(input.categories)].sort() } + : {}), ...(input.limit !== undefined ? { limit: input.limit } : {}), }; } diff --git a/src/modules/investments/tbank-sync/tbank-sync.repository.ts b/src/modules/investments/tbank-sync/tbank-sync.repository.ts index 8189dd0..4fe800a 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.repository.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.repository.ts @@ -79,9 +79,9 @@ export class TbankSyncRepository { work, children: children.map((child) => progress.attempts?.[child.id] && - progress.attempts[child.id] !== child.attemptId ? - { ...child, state: 'cancelled', errorCode: null } : - child, + progress.attempts[child.id] !== child.attemptId + ? { ...child, state: 'cancelled', errorCode: null } + : child, ), progress, }; diff --git a/src/modules/investments/tbank-sync/tbank-sync.service.ts b/src/modules/investments/tbank-sync/tbank-sync.service.ts index 0bb01da..dcd94e7 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.service.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.service.ts @@ -120,9 +120,9 @@ export class TbankSyncService { ) ) { for (const account of scope.screen === 'stock' || - scope.screen === 'bond' ? - current.accounts : - selectedAccounts) { + scope.screen === 'bond' + ? current.accounts + : selectedAccounts) { const work = await this.portfolio.request( userId, revision, @@ -135,9 +135,9 @@ export class TbankSyncService { } const saved = await this.portfolio.read( userId, - scope.screen === 'stock' || scope.screen === 'bond' ? - 'all' : - accountScope, + scope.screen === 'stock' || scope.screen === 'bond' + ? 'all' + : accountScope, true, ); const positions = saved.data?.positions ?? []; @@ -145,8 +145,8 @@ export class TbankSyncService { ...new Map( positions.flatMap((position) => position.instrumentUid && - (position.type === 'STOCK' || position.type === 'BOND') ? - [ + (position.type === 'STOCK' || position.type === 'BOND') + ? [ [ position.instrumentUid, { @@ -155,27 +155,27 @@ export class TbankSyncService { historyFrom: null, }, ] as const, - ] : - [], + ] + : [], ), ).values(), ]; const plan = planInvestmentScreen( { ...scope, - candleRange: scope.candleRange ? - { + candleRange: scope.candleRange + ? { from: new Date(scope.candleRange.from), to: new Date(scope.candleRange.to), - } : - undefined, + } + : undefined, }, { accounts: selectedAccounts, instruments: - scope.screen === 'stocks' || scope.screen === 'bonds' ? - [] : - instruments, + scope.screen === 'stocks' || scope.screen === 'bonds' + ? [] + : instruments, membershipKnown: Boolean(current.membershipAt), }, now, @@ -185,18 +185,18 @@ export class TbankSyncService { source.kind === 'operations' && source.accountId && source.from && - source.to ? - [ + source.to + ? [ { accountId: source.accountId, from: source.from, to: source.to, - ...(source.instrumentUid ? - { instrumentUid: source.instrumentUid } : - {}), + ...(source.instrumentUid + ? { instrumentUid: source.instrumentUid } + : {}), }, - ] : - [], + ] + : [], ); if (intervals.length) { await add([ @@ -278,11 +278,11 @@ export class TbankSyncService { [uid], true, [ - type === 'share' ? - 'dividends' : - scope.screen === 'bond' ? - 'coupons' : - 'bond-events', + type === 'share' + ? 'dividends' + : scope.screen === 'bond' + ? 'coupons' + : 'bond-events', ], { from: source.from.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH), @@ -307,14 +307,14 @@ export class TbankSyncService { uid: scope.instrumentUid, instrumentType: scope.screen === 'bond' ? 'bond' : 'share', period: scope.candlePeriod ?? 'month', - ...(scope.candleRange ? - { + ...(scope.candleRange + ? { range: { from: new Date(scope.candleRange.from), to: new Date(scope.candleRange.to), }, - } : - {}), + } + : {}), }, now, true, @@ -346,9 +346,9 @@ export class TbankSyncService { await this.access.local(userId, intent.scope.accountId); const children = await Promise.all( saved.children.map((child) => - child.state === 'superseded' ? - this.operations.observe(userId, child.id) : - Promise.resolve(child), + child.state === 'superseded' + ? this.operations.observe(userId, child.id) + : Promise.resolve(child), ), ); const running = children.some((child) => child.state === 'running'); @@ -358,15 +358,15 @@ export class TbankSyncService { null; const state = saved.work.processRunId !== this.runId && - (!saved.progress.dispatched || running) ? - 'cancelled' : - !saved.progress.dispatched || running ? - 'running' : - failed ? - 'failed' : - children.some((child) => child.state === 'cancelled') ? - 'cancelled' : - 'succeeded'; + (!saved.progress.dispatched || running) + ? 'cancelled' + : !saved.progress.dispatched || running + ? 'running' + : failed + ? 'failed' + : children.some((child) => child.state === 'cancelled') + ? 'cancelled' + : 'succeeded'; const resources = await this.observe(userId, intent.scope); if ( (await this.access.local(userId, intent.scope.accountId)).revision !== @@ -423,9 +423,9 @@ export class TbankSyncService { typeof input.kind === 'string' ) { const range = - typeof input.rangeKey === 'string' && input.rangeKey.startsWith('[') ? - (JSON.parse(input.rangeKey) as string[]) : - null; + typeof input.rangeKey === 'string' && input.rangeKey.startsWith('[') + ? (JSON.parse(input.rangeKey) as string[]) + : null; const sources = await this.sources.readMany( userId, type, @@ -434,9 +434,9 @@ export class TbankSyncService { [ input.kind as Parameters< CatalogEnrichmentService['readMany'] - >[4] extends (infer K)[] | undefined ? - K : - never, + >[4] extends (infer K)[] | undefined + ? K + : never, ], range ? { from: range[0], to: range[1] } : undefined, ); @@ -492,19 +492,19 @@ export class TbankSyncService { ); } } - const observed = work.scopeKey.startsWith('operations:') ? - await this.operations.observe(userId, work.id) : - work; + const observed = work.scopeKey.startsWith('operations:') + ? await this.operations.observe(userId, work.id) + : work; const state = - observed.state === 'running' ? - observed.updatedAt < this.startedAt ? - 'cancelled' : - 'running' : - observed.state === 'failed' ? - 'failed' : - observed.state === 'cancelled' ? - 'cancelled' : - 'succeeded'; + observed.state === 'running' + ? observed.updatedAt < this.startedAt + ? 'cancelled' + : 'running' + : observed.state === 'failed' + ? 'failed' + : observed.state === 'cancelled' + ? 'cancelled' + : 'succeeded'; if ((await this.access.local(userId)).revision !== work.revision) { throw new NotFoundException(); } @@ -597,27 +597,27 @@ export class TbankSyncService { uid, instrumentType: type, period: scope.candlePeriod ?? 'month', - ...(scope.candleRange ? - { + ...(scope.candleRange + ? { range: { from: new Date(scope.candleRange.from), to: new Date(scope.candleRange.to), }, - } : - {}), + } + : {}), }, true, ), ); await add( - type === 'share' ? - this.stockIncome.readPosition(userId, uid, true) : - this.bondIncome.readPosition(userId, uid, true), + type === 'share' + ? this.stockIncome.readPosition(userId, uid, true) + : this.bondIncome.readPosition(userId, uid, true), ); await add( - type === 'share' ? - this.stockIncome.dividends(userId, uid, true) : - this.bondIncome.coupons(userId, uid, true), + type === 'share' + ? this.stockIncome.dividends(userId, uid, true) + : this.bondIncome.coupons(userId, uid, true), ); } return resources; diff --git a/src/modules/investments/upcoming-events/upcoming-events.service.ts b/src/modules/investments/upcoming-events/upcoming-events.service.ts index 5c208f3..dc30d92 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.service.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.service.ts @@ -154,15 +154,15 @@ export class UpcomingEventsService { }; const quantity = decimalFromString(position.quantity); const projected = - type === 'share' ? - (calendar.payload?.dividends ?? []) + type === 'share' + ? (calendar.payload?.dividends ?? []) .filter( (event) => event.dividendType?.toLowerCase() !== 'cancelled' && Boolean(event.recordDate), ) - .map((event) => dividendEvent(identity, quantity, event)) : - (calendar.payload?.bondEvents ?? []).map((event) => + .map((event) => dividendEvent(identity, quantity, event)) + : (calendar.payload?.bondEvents ?? []).map((event) => bondEvent(identity, quantity, event), ); events.push( @@ -172,9 +172,9 @@ export class UpcomingEventsService { ) .map((event) => ({ ...event, - ...(accountId === 'all' && position.account ? - { account: position.account } : - {}), + ...(accountId === 'all' && position.account + ? { account: position.account } + : {}), })), ); } @@ -185,14 +185,14 @@ export class UpcomingEventsService { a.type.localeCompare(b.type) || a.ticker.localeCompare(b.ticker), ); - const candidate: undefined | UpcomingEventsDto = ready ? - { + const candidate: undefined | UpcomingEventsDto = ready + ? { accountId, calculatedAt: new Date().toISOString(), events, incomeTotalsByCurrency: incomeTotals(events), - } : - undefined; + } + : undefined; return this.forecasts.result({ userId, revision: connection.revision, @@ -224,13 +224,13 @@ export function dividendEvent( .toISOString() .slice(ISO_DATE_START, ISO_DATE_LENGTH), dateKind: 'registry', - projectedAmount: event.dividendNet ? - projectedMoney( + projectedAmount: event.dividendNet + ? projectedMoney( quantity, formatDecimal(decimal(event.dividendNet)), event.dividendNet.currency, - ) : - null, + ) + : null, }; } @@ -248,9 +248,9 @@ export function bondEvent( date: event.date, dateKind: 'event', projectedAmount: - 'value' in event ? - projectedMoney(quantity, event.value, event.currency) : - null, + 'value' in event + ? projectedMoney(quantity, event.value, event.currency) + : null, }; } diff --git a/test/sqlite-regression.cjs b/test/sqlite-regression.cjs index e6e0c96..13e1298 100644 --- a/test/sqlite-regression.cjs +++ b/test/sqlite-regression.cjs @@ -64,13 +64,13 @@ async function check(name, overrides = {}, signal) { 'user database must be untouched', ); const expected = - signal === 'SIGINT' ? - 130 : - signal === 'SIGTERM' ? - 143 : - overrides.PRISMA_SCHEMA_ENGINE_BINARY ? - 1 : - 0; + signal === 'SIGINT' + ? 130 + : signal === 'SIGTERM' + ? 143 + : overrides.PRISMA_SCHEMA_ENGINE_BINARY + ? 1 + : 0; assert.equal(code, expected, `See ${name}.log`); console.log(`PASS ${name}: ${dirs.length} isolated databases`); } diff --git a/test/sqlite.cjs b/test/sqlite.cjs index 4bcedfc..6d13fe0 100644 --- a/test/sqlite.cjs +++ b/test/sqlite.cjs @@ -9,11 +9,11 @@ const available = readdirSync(__dirname) .sort(); const requested = process.argv.slice(2); const suites = - requested.length === 0 ? - ['app.e2e-spec.ts', 'bank-cards.e2e-spec.ts'] : - requested.length === 1 && requested[0] === '--all' ? - available : - requested; + requested.length === 0 + ? ['app.e2e-spec.ts', 'bank-cards.e2e-spec.ts'] + : requested.length === 1 && requested[0] === '--all' + ? available + : requested; if (suites.some((suite) => !available.includes(suite))) { throw new Error('Unknown e2e suite'); } diff --git a/test/tbank-ledger-local.e2e-spec.ts b/test/tbank-ledger-local.e2e-spec.ts index 00e0036..9f9d699 100644 --- a/test/tbank-ledger-local.e2e-spec.ts +++ b/test/tbank-ledger-local.e2e-spec.ts @@ -240,9 +240,9 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { data: { occurredAt: new Date(observedAt.valueOf() - 1000) }, }); const query = - periodKind === 'default' ? - '' : - `&to=${observedAt.toISOString().slice(0, 10)}${periodKind === 'today timestamp' ? 'T23:59:59.999Z' : ''}`; + periodKind === 'default' + ? '' + : `&to=${observedAt.toISOString().slice(0, 10)}${periodKind === 'today timestamp' ? 'T23:59:59.999Z' : ''}`; const page = (cursor?: string, observe = false) => request(app.getHttpServer()) .get( diff --git a/test/tbank-local-reads.e2e-spec.ts b/test/tbank-local-reads.e2e-spec.ts index 3536b66..8f4d307 100644 --- a/test/tbank-local-reads.e2e-spec.ts +++ b/test/tbank-local-reads.e2e-spec.ts @@ -339,9 +339,9 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { ]); await put().expect(200); upstream.getPortfolio.mockImplementation((_token, id) => - id === 'second' ? - Promise.reject(new Error('synthetic unavailable')) : - Promise.resolve({ + id === 'second' + ? Promise.reject(new Error('synthetic unavailable')) + : Promise.resolve({ positions: [ { instrumentUid: uid, diff --git a/test/tbank-metadata-persistence.e2e-spec.ts b/test/tbank-metadata-persistence.e2e-spec.ts index ef509bf..5e0dbea 100644 --- a/test/tbank-metadata-persistence.e2e-spec.ts +++ b/test/tbank-metadata-persistence.e2e-spec.ts @@ -351,9 +351,9 @@ describe('durable shared instrument metadata (SQLite)', () => { await service.read('owner', identity); await settled(); const identities = Array.from({ length: 1000 }, (_, index) => - index ? - { instrumentUid: `uid-${index}`, figi: `FIGI-${index}` } : - identity, + index + ? { instrumentUid: `uid-${index}`, figi: `FIGI-${index}` } + : identity, ); const saved = await service.savedMany(identities); expect(saved.size).toBe(1000); diff --git a/test/tbank-read-baseline.e2e-spec.ts b/test/tbank-read-baseline.e2e-spec.ts index 5a16f08..b3388b4 100644 --- a/test/tbank-read-baseline.e2e-spec.ts +++ b/test/tbank-read-baseline.e2e-spec.ts @@ -58,9 +58,9 @@ describe('T-Bank persisted read budget', () => { const operations = Array.from({ length: 100_000 }, (_, index) => { // 1,000 measured-month rows, 200 income receipts, remaining rows over 10 years. const time = - index < 1000 ? - Date.UTC(2026, 7, 1) + index * 2_500_000 : - Date.UTC(2016, 8, 1) + (index - 1000) * 3_160_000; + index < 1000 + ? Date.UTC(2026, 7, 1) + index * 2_500_000 + : Date.UTC(2016, 8, 1) + (index - 1000) * 3_160_000; const instrument = instruments[index % instruments.length]; return { id: `operation-${index}`, @@ -298,9 +298,9 @@ describe('T-Bank persisted read budget', () => { uid: instrument.uid, ticker: instrument.ticker, instrumentType: - instrument.instrumentKind === 'INSTRUMENT_TYPE_SHARE' ? - 'share' : - 'bond', + instrument.instrumentKind === 'INSTRUMENT_TYPE_SHARE' + ? 'share' + : 'bond', metadata: instrument, fetchedAt: now, }, @@ -487,13 +487,13 @@ describe('T-Bank persisted read budget', () => { runtime: process.version, hardware: { cpuQuota: - process.platform === 'linux' ? - readFileSync('/sys/fs/cgroup/cpu.max', 'utf8').trim() : - null, + process.platform === 'linux' + ? readFileSync('/sys/fs/cgroup/cpu.max', 'utf8').trim() + : null, memoryLimit: - process.platform === 'linux' ? - readFileSync('/sys/fs/cgroup/memory.max', 'utf8').trim() : - null, + process.platform === 'linux' + ? readFileSync('/sys/fs/cgroup/memory.max', 'utf8').trim() + : null, cpu: cpus()[0]?.model, cpus: cpus().length, memoryBytes: totalmem(), diff --git a/test/tbank-screen-sync.e2e-spec.ts b/test/tbank-screen-sync.e2e-spec.ts index f319d78..bf3c590 100644 --- a/test/tbank-screen-sync.e2e-spec.ts +++ b/test/tbank-screen-sync.e2e-spec.ts @@ -358,9 +358,9 @@ describe('screen sync HTTP and SQLite', () => { }), ) .mockImplementation(() => - outcome === 'failed' ? - Promise.reject(new Error('Synthetic outage')) : - Promise.resolve({ hasNext: false, items: [] }), + outcome === 'failed' + ? Promise.reject(new Error('Synthetic outage')) + : Promise.resolve({ hasNext: false, items: [] }), ); const accept = async (from: string, to: string) => { const response = await post({ diff --git a/test/upcoming-events.e2e-spec.ts b/test/upcoming-events.e2e-spec.ts index d19ee82..4a23c8d 100644 --- a/test/upcoming-events.e2e-spec.ts +++ b/test/upcoming-events.e2e-spec.ts @@ -296,9 +296,9 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { .body as UpcomingEventsResourceDto; await portfolio([position(shareUid, '7'), position(newUid, '5')]); upstream.getDividends.mockImplementation((_token: string, uid: string) => - uid === newUid ? - Promise.reject(new Error('new calendar unavailable')) : - Promise.resolve([]), + uid === newUid + ? Promise.reject(new Error('new calendar unavailable')) + : Promise.resolve([]), ); const pending = (await events(false).expect(200)) .body as UpcomingEventsResourceDto; From 2d14081d27935dc85da466e5d9713d2f703ca673 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Mon, 28 Sep 2026 13:18:06 +0300 Subject: [PATCH 06/22] refactor: enforce enum machine codes Use owner enums for error and T-Bank protocol values with ESLint regression checks. Preserve open wire fallbacks and keep protocol imports independent of DI and Prisma. Verified npm ci, verify, 226 unit tests, 262 isolated e2e tests and unchanged OpenAPI. --- .ai/architecture.md | 14 ++-- .ai/conventions/engineering.md | 3 +- .ai/conventions/integrations.md | 4 +- eslint.config.mjs | 43 ++++++++++++ .../api-safe-error-response.decorator.ts | 4 +- src/common/error-code.ts | 8 +++ src/common/filters/http-exception.filter.ts | 16 +++-- src/integrations/moex/moex.client.ts | 12 ++-- src/integrations/ncr/ncr-ratings.client.ts | 4 +- .../tbank/instrument-metadata.repository.ts | 5 +- .../tbank/instrument-metadata.service.ts | 9 +-- .../tbank/tbank-access.service.ts | 9 +-- .../tbank/tbank-account.policy.ts | 9 +-- src/integrations/tbank/tbank-error-code.ts | 10 +++ .../tbank/tbank-membership.service.ts | 5 +- src/integrations/tbank/tbank-protocol.spec.ts | 42 +++++++++++ src/integrations/tbank/tbank-protocol.ts | 70 +++++++++++++++++++ src/integrations/tbank/tbank-token.service.ts | 5 +- src/integrations/tbank/tbank.client.spec.ts | 3 +- src/integrations/tbank/tbank.client.ts | 38 ++++++---- .../auth/application/auth-error-code.ts | 4 ++ src/modules/auth/application/auth.service.ts | 23 +++--- .../auth/infrastructure/jwt.strategy.ts | 3 +- .../auth/presentation/auth.controller.ts | 3 +- .../bank-cards/application/bank-card-input.ts | 35 +++++----- .../infrastructure/bank-cards.repository.ts | 9 +-- .../income/income-analytics.service.ts | 5 +- .../investments/income/income-period.ts | 4 +- .../instruments/application/candle.service.ts | 12 ++-- .../application/catalog-discovery.service.ts | 12 ++-- .../application/catalog-enrichment.service.ts | 7 +- .../application/catalog-selection.ts | 6 +- .../bonds/application/bond-income.service.ts | 5 +- .../domain/instrument-error-code.ts | 6 ++ .../catalog-discovery.repository.ts | 11 ++- .../brokerage-operations-sync.service.ts | 13 ++-- .../brokerage-operations.service.ts | 3 +- .../operations/domain/operation-error-code.ts | 3 + .../brokerage-operation.mapper.ts | 54 +++++++------- .../brokerage-operations.repository.ts | 9 ++- .../application/portfolio.service.ts | 5 +- .../infrastructure/portfolio.repository.ts | 8 +-- .../tbank-connection.service.ts | 3 +- .../tbank-sync/tbank-sync.service.ts | 3 +- .../upcoming-events.service.ts | 4 +- src/prisma/prisma-error-code.ts | 9 +++ test/eslint-config.cjs | 24 +++++++ 47 files changed, 432 insertions(+), 164 deletions(-) create mode 100644 src/common/error-code.ts create mode 100644 src/integrations/tbank/tbank-error-code.ts create mode 100644 src/integrations/tbank/tbank-protocol.spec.ts create mode 100644 src/integrations/tbank/tbank-protocol.ts create mode 100644 src/modules/auth/application/auth-error-code.ts create mode 100644 src/modules/investments/instruments/domain/instrument-error-code.ts create mode 100644 src/modules/investments/operations/domain/operation-error-code.ts create mode 100644 src/prisma/prisma-error-code.ts diff --git a/.ai/architecture.md b/.ai/architecture.md index 6adccfb..2dc1b5f 100644 --- a/.ai/architecture.md +++ b/.ai/architecture.md @@ -160,10 +160,11 @@ HTTP/e2e naming и расположение — в [Testing](conventions/testing `*.module.ts` задаёт DI boundary; `*.api.ts` перечисляет используемые другими модулями providers, функции и типы. Внешний production import идёт через этот -API или файл Nest module. Чистые `integrations/tbank/decimal.ts` и +API или файл Nest module. Чистые `integrations/tbank/decimal.ts`, +`integrations/tbank/tbank-protocol.ts`, `integrations/tbank/tbank-error-code.ts` и `modules/investments/operations/domain/operations-sync-plan.ts` — отдельные -публичные входы для protobuf conversion и interval rules: импорт расчётов -не загружает DI/Prisma. +публичные входы для protobuf conversion, protocol/error vocabulary и interval +rules: импорт этих значений и расчётов не загружает DI/Prisma. Внутри модуля используй прямые локальные импорты. Tests могут импортировать внутренний owner для проверки поведения и mocks. Новый export требует текущего потребителя; controller и persistence остаются @@ -251,7 +252,12 @@ OpenAPI enum metadata и runtime validation должны читать тот ж Исключения для индексов, default values, enum/type declarations и readonly properties заданы в ESLint config. Test fixtures/expected values остаются inline, остальные lint правила продолжают действовать. Numeric policy получает предметное имя и единицы; -semantic magic strings проверяются conventions/review без нового инструмента. +Machine codes и protocol values задаются строковыми enum у владельца. +`no-restricted-syntax` запрещает inline-коды и значения известных protocol families +в production TS; `no-unsafe-enum-comparison` проверяет сравнения enum. +Внешние открытые строки проверяются на boundary без cast и без потери unknown fallback. +Tests сохраняют независимые literal wire assertions; обычный текст и env keys +не считаются кодами. Новые словари требуют проверки владельца и расширения selectors. ## Logging diff --git a/.ai/conventions/engineering.md b/.ai/conventions/engineering.md index 98f5a1b..b100e4f 100644 --- a/.ai/conventions/engineering.md +++ b/.ai/conventions/engineering.md @@ -29,7 +29,8 @@ owner и 1–3 примера. Код показывает механизм; с ## Domain values Finite business vocabulary принадлежит одному domain/module/entity owner. -Переиспользуй его symbols; для нового словаря без аналога — `as const` + union. +Переиспользуй его symbols; для machine codes и protocol values используй +строковые `enum`, для структуры с разными payload — discriminated union. Сохраняй wire values; generated/external enums не переписывай вручную. Numeric policy называй по смыслу и единицам. Индексы, `Math.max(value, 0)` и обычные labels не выноси в constants без semantic benefit. diff --git a/.ai/conventions/integrations.md b/.ai/conventions/integrations.md index d742ab0..9b5f4d5 100644 --- a/.ai/conventions/integrations.md +++ b/.ai/conventions/integrations.md @@ -22,7 +22,9 @@ RPC, credentials/access и resilience. Эти владельцы не объед Преобразование provider records происходит на boundary: например, `tbank-portfolio.mapper.ts` и `bond-money.mapper.ts`; protobuf decimal helper -остаётся отдельным чистым входом integration. Domain получает внутренние +остаётся отдельным чистым входом integration. `tbank-protocol.ts` и +`tbank-error-code.ts` также импортируются напрямую: их enums и guards +не должны загружать DI/Prisma через service exports. Domain получает внутренние значения и не импортирует gRPC/SDK types. Не добавляй новый adapter на каждый client или mapper для простого копирования полей. Даты/units/nano разбираются на границе клиента/его mapping, а не становятся моделью чистых расчётов. diff --git a/eslint.config.mjs b/eslint.config.mjs index d5955f1..1e01eab 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -107,4 +107,47 @@ export default defineConfig( 'jest/expect-expect': ['error', { assertFunctionNames: ['expect', '**.expect'] }], }, }, + { + files: [ + 'src/**/*.ts', + ], + ignores: [ + '**/*.spec.ts', + 'test/**/*.ts', + ], + rules: { + 'no-restricted-syntax': [ + 'error', + { + selector: 'Literal[value=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember > Literal)', + message: 'Use the owning enum member for machine codes and protocol values.', + }, + { + selector: 'TemplateElement[value.cooked=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember TemplateElement)', + message: 'Use the owning enum member instead of a template machine code.', + }, + { + selector: 'Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > Literal.value[value=type(string)]', + message: 'Use an enum member instead of an inline code or protocol value.', + }, + { + selector: 'Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > TemplateLiteral.value', + message: 'Use an enum member instead of constructing a code or protocol value.', + }, + { + selector: 'Property[computed=false][key.name=/^(OPERATION_TYPE|ACCOUNT_TYPE)_/]', + message: 'Use a computed enum member as the protocol mapping key.', + }, + { + selector: 'BinaryExpression:has(> :matches(MemberExpression[property.name=/^(code|errorCode)$/], Identifier[name=/^(code|errorCode)$/])) > Literal[value=type(string)]', + message: 'Compare codes with their enum members, not string literals.', + }, + { + selector: 'PropertyDefinition[definite=true], VariableDeclarator[definite=true]', + message: 'Definite assignment assertions are forbidden. Initialize owned values; use declare only for externally populated DTO fields.', + }, + ], + '@typescript-eslint/no-unsafe-enum-comparison': 'error', + }, + }, ); diff --git a/src/common/decorators/api-safe-error-response.decorator.ts b/src/common/decorators/api-safe-error-response.decorator.ts index 81300b4..1c2ec64 100644 --- a/src/common/decorators/api-safe-error-response.decorator.ts +++ b/src/common/decorators/api-safe-error-response.decorator.ts @@ -7,11 +7,13 @@ import { getSchemaPath, } from '@nestjs/swagger'; +import { HttpErrorCode } from '../error-code'; + export class ProblemDetailsDto { @ApiProperty({ example: 401 }) declare status: number; - @ApiProperty({ example: 'UNAUTHORIZED' }) + @ApiProperty({ example: HttpErrorCode.Unauthorized }) declare code: string; // eslint-disable-next-line @darraghor/nestjs-typed/api-property-returning-array-should-set-array -- Raw OpenAPI items schema already declares type: array. diff --git a/src/common/error-code.ts b/src/common/error-code.ts new file mode 100644 index 0000000..db48c8a --- /dev/null +++ b/src/common/error-code.ts @@ -0,0 +1,8 @@ +export enum HttpErrorCode { + BadRequest = 'BAD_REQUEST', + InternalServerError = 'INTERNAL_SERVER_ERROR', + NotFound = 'NOT_FOUND', + Unauthorized = 'UNAUTHORIZED', + UnsupportedMediaType = 'UNSUPPORTED_MEDIA_TYPE', + ValidationError = 'VALIDATION_ERROR', +} diff --git a/src/common/filters/http-exception.filter.ts b/src/common/filters/http-exception.filter.ts index e228b30..2130736 100644 --- a/src/common/filters/http-exception.filter.ts +++ b/src/common/filters/http-exception.filter.ts @@ -9,6 +9,8 @@ import { ValidationError, } from '@nestjs/common'; +import { HttpErrorCode } from '../error-code'; + @Catch() export class HttpExceptionFilter implements ExceptionFilter { catch(exception: unknown, host: ArgumentsHost): void { @@ -25,7 +27,7 @@ export class HttpExceptionFilter implements ExceptionFilter { (payload as { message?: unknown })?.message, ); const code = isValidation - ? 'VALIDATION_ERROR' + ? HttpErrorCode.ValidationError : this.codeFor(status, payload); response.locals ??= {}; response.locals.publicErrorCode = code; @@ -53,12 +55,12 @@ export class HttpExceptionFilter implements ExceptionFilter { return ( // eslint-disable-next-line security/detect-object-injection -- The lookup key is a numeric HTTP status, never a property name from the request. { - 400: 'BAD_REQUEST', - 401: 'UNAUTHORIZED', - 404: 'NOT_FOUND', - 415: 'UNSUPPORTED_MEDIA_TYPE', - 500: 'INTERNAL_SERVER_ERROR', - }[status] ?? 'INTERNAL_SERVER_ERROR' + 400: HttpErrorCode.BadRequest, + 401: HttpErrorCode.Unauthorized, + 404: HttpErrorCode.NotFound, + 415: HttpErrorCode.UnsupportedMediaType, + 500: HttpErrorCode.InternalServerError, + }[status] ?? HttpErrorCode.InternalServerError ); } diff --git a/src/integrations/moex/moex.client.ts b/src/integrations/moex/moex.client.ts index 5b5d561..61f1f0e 100644 --- a/src/integrations/moex/moex.client.ts +++ b/src/integrations/moex/moex.client.ts @@ -1,6 +1,8 @@ import { Injectable, ServiceUnavailableException } from '@nestjs/common'; import { constants as httpStatus } from 'node:http2'; +enum MoexErrorCode { Unavailable = 'MOEX_UNAVAILABLE' } + const MIN_PRICE_PERCENT_EXCLUSIVE = 0; const FIRST_PAGE_OFFSET = 0; const EXPECTED_CURSOR_ROWS = 1; @@ -152,7 +154,7 @@ export class MoexClient { pageSize === EMPTY_PAGE_SIZE || current.length !== Math.min(pageSize, total - start) ) { - throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); + throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable }); } if (index + pageSize >= total) return rows; start = index + pageSize; @@ -172,22 +174,22 @@ export class MoexClient { } return body as IssResponse; } catch { - throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); + throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable }); } } } function parseRows(block: IssBlock | undefined): Record[] { if (!Array.isArray(block?.columns) || !Array.isArray(block?.data)) { - throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); + throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable }); } const columns = block.columns; if (!columns.every((column) => typeof column === 'string')) { - throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); + throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable }); } return block.data.map((values) => { if (!Array.isArray(values) || values.length !== columns.length) { - throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); + throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable }); } return Object.fromEntries( // eslint-disable-next-line security/detect-object-injection -- Keys are fixed MOEX block names; row indexes come from the validated columns array. diff --git a/src/integrations/ncr/ncr-ratings.client.ts b/src/integrations/ncr/ncr-ratings.client.ts index 257b58d..a5f110e 100644 --- a/src/integrations/ncr/ncr-ratings.client.ts +++ b/src/integrations/ncr/ncr-ratings.client.ts @@ -4,6 +4,8 @@ import { LRUCache } from 'lru-cache'; import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time'; +enum NcrErrorCode { Unavailable = 'NCR_UNAVAILABLE' } + const EXPECTED_REGISTRY_TABLES = 1; const RATING_COLUMN = 2; @@ -41,7 +43,7 @@ export class NcrRatingsClient { if (this.cache.get('registry') === pending) { this.cache.set('registry', pending, { ttl: 60_000 }); } - throw new ServiceUnavailableException({ code: 'NCR_UNAVAILABLE' }); + throw new ServiceUnavailableException({ code: NcrErrorCode.Unavailable }); }); this.cache.set('registry', pending); } diff --git a/src/integrations/tbank/instrument-metadata.repository.ts b/src/integrations/tbank/instrument-metadata.repository.ts index 7fc6819..4e8933f 100644 --- a/src/integrations/tbank/instrument-metadata.repository.ts +++ b/src/integrations/tbank/instrument-metadata.repository.ts @@ -11,6 +11,7 @@ import type { TbankSyncAttempt } from './tbank-access.service'; import type { TbankInstrument } from './tbank.client'; import { PrismaService } from '../../prisma/prisma.service'; +import { TbankErrorCode } from './tbank-error-code'; import { tbankSourceTtlMs } from './tbank-source.policy'; const FIRST_BATCH_OFFSET = 0; @@ -276,7 +277,7 @@ export class InstrumentMetadataRepository { }, data: { state: success ? 'complete' : 'failed', - errorCode: success ? null : 'TBANK_UNAVAILABLE', + errorCode: success ? null : TbankErrorCode.Unavailable, }, }); if (success) { @@ -286,7 +287,7 @@ export class InstrumentMetadataRepository { state: 'running', connection: { invalidToken: true }, }, - data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' }, + data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken }, }); } } diff --git a/src/integrations/tbank/instrument-metadata.service.ts b/src/integrations/tbank/instrument-metadata.service.ts index ee3c4a9..9dfb08a 100644 --- a/src/integrations/tbank/instrument-metadata.service.ts +++ b/src/integrations/tbank/instrument-metadata.service.ts @@ -12,6 +12,7 @@ import { TbankAccessService, type TbankSyncAttempt, } from './tbank-access.service'; +import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code'; import { TbankClient, type TbankInstrument } from './tbank.client'; export { metadataKey } from './instrument-metadata.repository'; export type { MetadataIdentity } from './instrument-metadata.repository'; @@ -131,7 +132,7 @@ export class InstrumentMetadataService { if ( !identity.figi || (error instanceof Error && - error.message === 'INVALID_TBANK_TOKEN') + isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) ) { throw error; } @@ -191,11 +192,11 @@ export class InstrumentMetadataService { ); } } catch (error) { - if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') { + if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) { await this.access.rejectMembershipToken(attempt); } else { this.logger.event('warn', 'instrument-metadata.failed', { - code: 'TBANK_UNAVAILABLE', + code: TbankErrorCode.Unavailable, }); } } @@ -209,7 +210,7 @@ export class InstrumentMetadataService { } } catch { this.logger.event('error', 'instrument-metadata.persistence-failed', { - code: 'TBANK_UNAVAILABLE', + code: TbankErrorCode.Unavailable, }); this.active.clear(); this.producers.clear(); diff --git a/src/integrations/tbank/tbank-access.service.ts b/src/integrations/tbank/tbank-access.service.ts index 588c9cb..eed498a 100644 --- a/src/integrations/tbank/tbank-access.service.ts +++ b/src/integrations/tbank/tbank-access.service.ts @@ -10,6 +10,7 @@ import type { TbankAccount } from './tbank.client'; import { PrismaService } from '../../prisma/prisma.service'; import { supportedTbankAccounts } from './tbank-account.policy'; +import { TbankErrorCode } from './tbank-error-code'; import { tbankSourceNeedsRefresh } from './tbank-source.policy'; import { TbankTokenService } from './tbank-token.service'; @@ -103,7 +104,7 @@ export class TbankAccessService { state: 'running', connection: { invalidToken: false }, }, - data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' }, + data: { state: 'failed', errorCode: TbankErrorCode.Unavailable }, }); return result.count === EXPECTED_UPDATED_CONNECTIONS; } @@ -205,7 +206,7 @@ export class TbankAccessService { return this.prisma.$transaction(async (tx) => { const claimed = await tx.tbankSyncWork.updateMany({ where: { ...attempt, state: 'running' }, - data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' }, + data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken }, }); if (!claimed.count) return false; await tx.tbankConnection.update({ @@ -236,10 +237,10 @@ export class TbankAccessService { }, }); if (!connection) { - throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' }); + throw new ConflictException({ code: TbankErrorCode.NotConnected }); } if (connection.invalidToken) { - throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }); + throw new BadRequestException({ code: TbankErrorCode.InvalidToken }); } if ( connection.membershipAt && diff --git a/src/integrations/tbank/tbank-account.policy.ts b/src/integrations/tbank/tbank-account.policy.ts index eb456e2..816ae98 100644 --- a/src/integrations/tbank/tbank-account.policy.ts +++ b/src/integrations/tbank/tbank-account.policy.ts @@ -1,14 +1,15 @@ import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../common/time'; +import { TbankAccountType } from './tbank-protocol'; import { TbankAccount } from './tbank.client'; const accountTypes = { - ACCOUNT_TYPE_TINKOFF: { type: 'BROKERAGE', name: 'Брокерский счет' }, - ACCOUNT_TYPE_TINKOFF_IIS: { + [TbankAccountType.Tinkoff]: { type: 'BROKERAGE', name: 'Брокерский счет' }, + [TbankAccountType.TinkoffIis]: { type: 'INDIVIDUAL_INVESTMENT', name: 'ИИС', }, - ACCOUNT_TYPE_INVEST_BOX: { type: 'INVEST_BOX', name: 'Инвесткопилка' }, - ACCOUNT_TYPE_INVEST_FUND: { + [TbankAccountType.InvestBox]: { type: 'INVEST_BOX', name: 'Инвесткопилка' }, + [TbankAccountType.InvestFund]: { type: 'MONEY_MARKET_FUND', name: 'Фонд денежного рынка', }, diff --git a/src/integrations/tbank/tbank-error-code.ts b/src/integrations/tbank/tbank-error-code.ts new file mode 100644 index 0000000..f74ecb0 --- /dev/null +++ b/src/integrations/tbank/tbank-error-code.ts @@ -0,0 +1,10 @@ +export enum TbankErrorCode { + InvalidToken = 'INVALID_TBANK_TOKEN', + NotConnected = 'TBANK_NOT_CONNECTED', + Unavailable = 'TBANK_UNAVAILABLE', +} + +// Error messages and persisted codes cross an open string boundary. +export function isTbankErrorCode(value: unknown, expected: TbankErrorCode): boolean { + return value === expected; +} diff --git a/src/integrations/tbank/tbank-membership.service.ts b/src/integrations/tbank/tbank-membership.service.ts index 784c3cc..625372d 100644 --- a/src/integrations/tbank/tbank-membership.service.ts +++ b/src/integrations/tbank/tbank-membership.service.ts @@ -6,6 +6,7 @@ import { TbankAccessService, type TbankSyncAttempt, } from './tbank-access.service'; +import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code'; import { TbankClient } from './tbank.client'; @Injectable() @@ -37,7 +38,7 @@ export class TbankMembershipService { .catch(() => this.logger.event('error', 'tbank.membership.persistence_failed', { userId, - code: 'TBANK_UNAVAILABLE', + code: TbankErrorCode.Unavailable, }), ) .finally(() => this.active.delete(attemptId)); @@ -62,7 +63,7 @@ export class TbankMembershipService { } catch (error) { await this.access.failMembership( attempt, - error instanceof Error && error.message === 'INVALID_TBANK_TOKEN', + error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken), ); } } diff --git a/src/integrations/tbank/tbank-protocol.spec.ts b/src/integrations/tbank/tbank-protocol.spec.ts new file mode 100644 index 0000000..e34be02 --- /dev/null +++ b/src/integrations/tbank/tbank-protocol.spec.ts @@ -0,0 +1,42 @@ +import { loadSync } from '@grpc/proto-loader'; +import { join } from 'node:path'; + +import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code'; +import { + InstrumentIdType, + isTbankProtocolValue, + TbankAccountStatus, + TbankAccountType, + TbankCandleInterval, + TbankCouponType, + TbankInstrumentType, + TbankOperationState, + TbankOperationType, +} from './tbank-protocol'; + +it('uses the string enum values declared by the vendored protocol', () => { + const directory = join(__dirname, 'proto/contracts'); + const definitions = loadSync(['users.proto', 'instruments.proto', 'operations.proto', 'marketdata.proto'].map((file) => join(directory, file)), { includeDirs: [directory], enums: String }); + for (const [name, values] of Object.entries({ + InstrumentIdType, + InstrumentType: TbankInstrumentType, + AccountStatus: TbankAccountStatus, + AccountType: TbankAccountType, + CandleInterval: TbankCandleInterval, + CouponType: TbankCouponType, + OperationState: TbankOperationState, + OperationType: TbankOperationType, + })) { + const definition = definitions[`tinkoff.public.invest.api.contract.v1.${name}`]; + expect(definition).toHaveProperty('format', 'Protocol Buffer 3 EnumDescriptorProto'); + const descriptor = definition as { type: { value: { name: string }[] } }; + expect(descriptor.type.value.map((member) => member.name)).toEqual(expect.arrayContaining(Object.values(values))); + } +}); + +it('recognizes supported wire values without accepting unknown ones', () => { + expect(isTbankProtocolValue('OPERATION_STATE_EXECUTED', TbankOperationState.Executed)).toBe(true); + expect(isTbankProtocolValue('NEW_UPSTREAM_STATE', TbankOperationState.Executed)).toBe(false); + expect(isTbankErrorCode('INVALID_TBANK_TOKEN', TbankErrorCode.InvalidToken)).toBe(true); + expect(isTbankErrorCode('NEW_UPSTREAM_ERROR', TbankErrorCode.InvalidToken)).toBe(false); +}); diff --git a/src/integrations/tbank/tbank-protocol.ts b/src/integrations/tbank/tbank-protocol.ts new file mode 100644 index 0000000..ebfbaf8 --- /dev/null +++ b/src/integrations/tbank/tbank-protocol.ts @@ -0,0 +1,70 @@ +// String wire values for the protocol subset used by this integration. +// proto-loader decodes enums as strings; vendor contracts remain the source. +export enum InstrumentIdType { + Figi = 'INSTRUMENT_ID_TYPE_FIGI', + Uid = 'INSTRUMENT_ID_TYPE_UID', +} + +export enum TbankInstrumentType { + Bond = 'INSTRUMENT_TYPE_BOND', + Share = 'INSTRUMENT_TYPE_SHARE', +} + +export enum TbankAccountStatus { + Open = 'ACCOUNT_STATUS_OPEN', +} + +export enum TbankAccountType { + InvestBox = 'ACCOUNT_TYPE_INVEST_BOX', + InvestFund = 'ACCOUNT_TYPE_INVEST_FUND', + Tinkoff = 'ACCOUNT_TYPE_TINKOFF', + TinkoffIis = 'ACCOUNT_TYPE_TINKOFF_IIS', +} + +export enum TbankCandleInterval { + Day = 'CANDLE_INTERVAL_DAY', +} + +export enum TbankOperationState { + Executed = 'OPERATION_STATE_EXECUTED', +} + +export enum TbankCouponType { + Floating = 'COUPON_TYPE_FLOATING', + Unspecified = 'COUPON_TYPE_UNSPECIFIED', +} + +export enum TbankOperationType { + BondTax = 'OPERATION_TYPE_BOND_TAX', + BrokerFee = 'OPERATION_TYPE_BROKER_FEE', + Buy = 'OPERATION_TYPE_BUY', + BuyCard = 'OPERATION_TYPE_BUY_CARD', + BuyMargin = 'OPERATION_TYPE_BUY_MARGIN', + Coupon = 'OPERATION_TYPE_COUPON', + DivExt = 'OPERATION_TYPE_DIV_EXT', + Dividend = 'OPERATION_TYPE_DIVIDEND', + DividendTax = 'OPERATION_TYPE_DIVIDEND_TAX', + DividendTransfer = 'OPERATION_TYPE_DIVIDEND_TRANSFER', + Input = 'OPERATION_TYPE_INPUT', + InputSecurities = 'OPERATION_TYPE_INPUT_SECURITIES', + MarginFee = 'OPERATION_TYPE_MARGIN_FEE', + Output = 'OPERATION_TYPE_OUTPUT', + OutputSecurities = 'OPERATION_TYPE_OUTPUT_SECURITIES', + Sell = 'OPERATION_TYPE_SELL', + SellCard = 'OPERATION_TYPE_SELL_CARD', + SellMargin = 'OPERATION_TYPE_SELL_MARGIN', + ServiceFee = 'OPERATION_TYPE_SERVICE_FEE', + SuccessFee = 'OPERATION_TYPE_SUCCESS_FEE', + Tax = 'OPERATION_TYPE_TAX', + TransBsBs = 'OPERATION_TYPE_TRANS_BS_BS', + TransIisBs = 'OPERATION_TYPE_TRANS_IIS_BS', + Unspecified = 'OPERATION_TYPE_UNSPECIFIED', +} + +// Provider responses may contain values newer than our supported protocol subset. +export function isTbankProtocolValue( + value: unknown, + expected: TbankCouponType | TbankInstrumentType | TbankOperationState | TbankOperationType, +): boolean { + return value === expected; +} diff --git a/src/integrations/tbank/tbank-token.service.ts b/src/integrations/tbank/tbank-token.service.ts index e029e07..92e80b5 100644 --- a/src/integrations/tbank/tbank-token.service.ts +++ b/src/integrations/tbank/tbank-token.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Environment } from '../../config/env.validation'; +import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code'; import { TbankCrypto } from './tbank.crypto'; @Injectable() @@ -26,8 +27,8 @@ export class TbankTokenService { try { return await operation(); } catch (error) { - if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') { - throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }); + if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) { + throw new BadRequestException({ code: TbankErrorCode.InvalidToken }); } throw error; } diff --git a/src/integrations/tbank/tbank.client.spec.ts b/src/integrations/tbank/tbank.client.spec.ts index 525ab85..56b6fb6 100644 --- a/src/integrations/tbank/tbank.client.spec.ts +++ b/src/integrations/tbank/tbank.client.spec.ts @@ -1,6 +1,7 @@ import { status } from '@grpc/grpc-js'; import { NotFoundException, ServiceUnavailableException } from '@nestjs/common'; +import { TbankOperationState } from './tbank-protocol'; import { TbankClient } from './tbank.client'; describe('TbankClient operations', () => { @@ -10,7 +11,7 @@ describe('TbankClient operations', () => { to: { seconds: '2' }, cursor: 'cursor', limit: 10, - state: 'OPERATION_STATE_EXECUTED' as const, + state: TbankOperationState.Executed, }; it('passes a cursor operations request to OperationsService', async () => { diff --git a/src/integrations/tbank/tbank.client.ts b/src/integrations/tbank/tbank.client.ts index 3cdcfb8..a3ff196 100644 --- a/src/integrations/tbank/tbank.client.ts +++ b/src/integrations/tbank/tbank.client.ts @@ -29,6 +29,14 @@ import type { import { StructuredLogger } from '../../common/logging/structured-logger.service'; import { MILLISECONDS_PER_SECOND } from '../../common/time'; import { Environment } from '../../config/env.validation'; +import { TbankErrorCode } from './tbank-error-code'; +import { + InstrumentIdType, + TbankAccountStatus, + TbankCandleInterval, + TbankInstrumentType, + TbankOperationState, +} from './tbank-protocol'; const INITIAL_REQUEST_COUNT = 0; const MIN_RATE_DELAY_MS = 1; @@ -115,7 +123,7 @@ export type TbankOperationsByCursorRequest = { from?: TbankTimestamp; instrumentId?: string; limit?: number; - state: 'OPERATION_STATE_EXECUTED'; + state: TbankOperationState.Executed; to?: TbankTimestamp; }; export type TbankOperationsByCursorResponse = { @@ -256,7 +264,7 @@ export class TbankClient { token, this.users, 'getAccounts', - { status: 'ACCOUNT_STATUS_OPEN' }, + { status: TbankAccountStatus.Open }, ).then((response) => response.accounts ?? []); } @@ -279,14 +287,14 @@ export class TbankClient { token: string, figi: string, ): Promise { - return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_FIGI', figi); + return this.getInstrument(token, InstrumentIdType.Figi, figi); } getInstrumentByUid( token: string, instrumentUid: string, ): Promise { - return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_UID', instrumentUid); + return this.getInstrument(token, InstrumentIdType.Uid, instrumentUid); } getDividends( @@ -314,7 +322,7 @@ export class TbankClient { 'findInstrument', { query, - instrumentKind: 'INSTRUMENT_TYPE_SHARE', + instrumentKind: TbankInstrumentType.Share, }, ).then((response) => response.instruments ?? []); } @@ -326,7 +334,7 @@ export class TbankClient { 'findInstrument', { query, - instrumentKind: 'INSTRUMENT_TYPE_BOND', + instrumentKind: TbankInstrumentType.Bond, }, ).then((response) => response.instruments ?? []); } @@ -337,7 +345,7 @@ export class TbankClient { this.sparseInstruments, 'bondBy', { - idType: 'INSTRUMENT_ID_TYPE_UID', + idType: InstrumentIdType.Uid, id: uid, }, ).then((response) => response.instrument ?? null); @@ -367,7 +375,7 @@ export class TbankClient { this.instruments, 'shareBy', { - idType: 'INSTRUMENT_ID_TYPE_UID', + idType: InstrumentIdType.Uid, id: uid, }, ).then((response) => response.instrument ?? null); @@ -446,7 +454,7 @@ export class TbankClient { to: { seconds: String(Math.floor(to.valueOf() / MILLISECONDS_PER_SECOND)), }, - interval: 'CANDLE_INTERVAL_DAY', + interval: TbankCandleInterval.Day, limit: 2400, }, ).then((response) => response.candles ?? []); @@ -454,7 +462,7 @@ export class TbankClient { private async getInstrument( token: string, - idType: 'INSTRUMENT_ID_TYPE_FIGI' | 'INSTRUMENT_ID_TYPE_UID', + idType: InstrumentIdType, id: string, ): Promise { try { @@ -484,7 +492,7 @@ export class TbankClient { const queueFull = this.queue.size >= MAX_QUEUE_SIZE; try { if (queueFull) { - throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }); + throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable }); } const response = await this.queue.run(() => this.request(token, client, method, request), @@ -498,7 +506,7 @@ export class TbankClient { } catch (error) { const grpcCode = (error as ServiceError).code; const failureContext = queueFull - ? { code: 'TBANK_UNAVAILABLE' } + ? { code: TbankErrorCode.Unavailable } : { grpcCode }; const retryable = queueFull ? metadataCall @@ -534,7 +542,7 @@ export class TbankClient { [status.PERMISSION_DENIED, status.UNAUTHENTICATED].includes(grpcCode) ) { // eslint-disable-next-line preserve-caught-error -- Discard raw upstream authentication errors at the integration boundary. - throw new Error('INVALID_TBANK_TOKEN'); + throw new Error(TbankErrorCode.InvalidToken); } if ( grpcCode === status.NOT_FOUND && @@ -542,10 +550,10 @@ export class TbankClient { ) { throw new NotFoundException(); } - throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }); + throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable }); } } - throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }); + throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable }); } private request( diff --git a/src/modules/auth/application/auth-error-code.ts b/src/modules/auth/application/auth-error-code.ts new file mode 100644 index 0000000..0b3bcc7 --- /dev/null +++ b/src/modules/auth/application/auth-error-code.ts @@ -0,0 +1,4 @@ +export enum AuthErrorCode { + EmailAlreadyRegistered = 'EMAIL_ALREADY_REGISTERED', + InvalidCredentials = 'INVALID_CREDENTIALS', +} diff --git a/src/modules/auth/application/auth.service.ts b/src/modules/auth/application/auth.service.ts index 19baedb..8410ab6 100644 --- a/src/modules/auth/application/auth.service.ts +++ b/src/modules/auth/application/auth.service.ts @@ -8,7 +8,10 @@ import * as bcrypt from 'bcrypt'; import { createHash, randomUUID } from 'crypto'; import { Session, User } from '../../../../generated/prisma/client'; +import { HttpErrorCode } from '../../../common/error-code'; +import { isPrismaErrorCode, PrismaErrorCode } from '../../../prisma/prisma-error-code'; import { PrismaService } from '../../../prisma/prisma.service'; +import { AuthErrorCode } from './auth-error-code'; import { AuthUser, RefreshPayload } from './auth.types'; const EXPECTED_ROTATED_SESSIONS = 1; @@ -37,7 +40,7 @@ export class AuthService { where: { email }, }); if (existing) { - throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' }); + throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered }); } let user: User; @@ -51,7 +54,7 @@ export class AuthService { }); } catch (error) { if (isUniqueConstraintError(error)) { - throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' }); + throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered }); } throw error; } @@ -70,7 +73,7 @@ export class AuthService { user?.passwordHash ?? DUMMY_PASSWORD_HASH, ); if (!user || !valid) { - throw new UnauthorizedException({ code: 'INVALID_CREDENTIALS' }); + throw new UnauthorizedException({ code: AuthErrorCode.InvalidCredentials }); } return this.createSession(user); } @@ -84,10 +87,10 @@ export class AuthService { algorithms: ['HS256'], }); } catch { - throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); + throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); } if (payload.type !== 'refresh') { - throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); + throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); } const session = await this.prisma.session.findUnique({ where: { id: payload.sid }, @@ -102,10 +105,10 @@ export class AuthService { if (session && !session.revokedAt) { await this.prisma.session.delete({ where: { id: session.id } }); } - throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); + throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); } if (session.refreshTokenHash !== this.hash(token)) { - throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); + throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); } return this.rotateSession(session, session.refreshTokenHash); } @@ -142,7 +145,7 @@ export class AuthService { async userById(id: string): Promise { const user = await this.prisma.user.findUnique({ where: { id } }); - if (!user) throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); + if (!user) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); return user; } @@ -183,7 +186,7 @@ export class AuthService { data, }); if (updated.count !== EXPECTED_ROTATED_SESSIONS) { - throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); + throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); } } else { await this.prisma.session.update({ where: { id: session.id }, data }); @@ -208,6 +211,6 @@ function isUniqueConstraintError(error: unknown): boolean { typeof error === 'object' && error !== null && 'code' in error && - (error as { code?: unknown }).code === 'P2002' + isPrismaErrorCode(error.code, PrismaErrorCode.UniqueConstraint) ); } diff --git a/src/modules/auth/infrastructure/jwt.strategy.ts b/src/modules/auth/infrastructure/jwt.strategy.ts index 96110de..ce4cfd9 100644 --- a/src/modules/auth/infrastructure/jwt.strategy.ts +++ b/src/modules/auth/infrastructure/jwt.strategy.ts @@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; +import { HttpErrorCode } from '../../../common/error-code'; import { Environment } from '../../../config/env.validation'; import { AccessPayload } from '../application/auth.types'; @@ -19,7 +20,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { validate(payload: AccessPayload): AccessPayload { if (payload.type !== 'access') { - throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); + throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); } return payload; } diff --git a/src/modules/auth/presentation/auth.controller.ts b/src/modules/auth/presentation/auth.controller.ts index c169f0e..8f78326 100644 --- a/src/modules/auth/presentation/auth.controller.ts +++ b/src/modules/auth/presentation/auth.controller.ts @@ -21,6 +21,7 @@ import { } from '@nestjs/swagger'; import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; +import { HttpErrorCode } from '../../../common/error-code'; import { AuthService } from '../application/auth.service'; import { AccessPayload } from '../application/auth.types'; import { CredentialsDto } from './auth.dto'; @@ -86,7 +87,7 @@ export class AuthController { ) { try { const token = this.refreshToken(request); - if (!token) throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); + if (!token) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); return this.respond(response, await this.auth.refresh(token)); } catch (error) { if (error instanceof UnauthorizedException) this.clearCookie(response); diff --git a/src/modules/banking/bank-cards/application/bank-card-input.ts b/src/modules/banking/bank-cards/application/bank-card-input.ts index 8bb0dd6..0fe28ac 100644 --- a/src/modules/banking/bank-cards/application/bank-card-input.ts +++ b/src/modules/banking/bank-cards/application/bank-card-input.ts @@ -22,24 +22,23 @@ import { const NEXT_MONTH_OFFSET = 1; const PREVIOUS_MONTH_LAST_DAY = 0; -export const BankErrorCode = { - InvalidInput: 'BANK_INVALID_INPUT', - VersionConflict: 'BANK_VERSION_CONFLICT', - NotFound: 'BANK_NOT_FOUND', - InvalidMoney: 'BANK_INVALID_MONEY', - InvalidDate: 'BANK_INVALID_DATE', - ImmutableOperation: 'BANK_IMMUTABLE_OPERATION', - InvalidRefund: 'BANK_INVALID_REFUND', - RefundLimit: 'BANK_REFUND_LIMIT', - DependentRefunds: 'BANK_DEPENDENT_REFUNDS', - IntentConflict: 'BANK_INTENT_CONFLICT', - OperationDeleted: 'BANK_OPERATION_DELETED', - CardArchived: 'BANK_CARD_ARCHIVED', - OpeningDateConflict: 'BANK_OPENING_DATE_CONFLICT', - WriteConflict: 'BANK_WRITE_CONFLICT', - DuplicateCategory: 'BANK_DUPLICATE_CATEGORY', -} as const; -export type BankErrorCode = (typeof BankErrorCode)[keyof typeof BankErrorCode]; +export enum BankErrorCode { + CardArchived = 'BANK_CARD_ARCHIVED', + DependentRefunds = 'BANK_DEPENDENT_REFUNDS', + DuplicateCategory = 'BANK_DUPLICATE_CATEGORY', + ImmutableOperation = 'BANK_IMMUTABLE_OPERATION', + IntentConflict = 'BANK_INTENT_CONFLICT', + InvalidDate = 'BANK_INVALID_DATE', + InvalidInput = 'BANK_INVALID_INPUT', + InvalidMoney = 'BANK_INVALID_MONEY', + InvalidRefund = 'BANK_INVALID_REFUND', + NotFound = 'BANK_NOT_FOUND', + OpeningDateConflict = 'BANK_OPENING_DATE_CONFLICT', + OperationDeleted = 'BANK_OPERATION_DELETED', + RefundLimit = 'BANK_REFUND_LIMIT', + VersionConflict = 'BANK_VERSION_CONFLICT', + WriteConflict = 'BANK_WRITE_CONFLICT', +} export function fail(code: BankErrorCode = BankErrorCode.InvalidInput): never { throw new BadRequestException({ code }); diff --git a/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts b/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts index 52a54e4..95a29ad 100644 --- a/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts +++ b/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; import { Prisma } from '../../../../../generated/prisma/client'; +import { isPrismaErrorCode, PrismaErrorCode } from '../../../../prisma/prisma-error-code'; import { PrismaService } from '../../../../prisma/prisma.service'; import { BankErrorCode, @@ -44,15 +45,15 @@ export class BankCardsRepository { ? error.code : ''; const busy = - code === 'P2034' || - code === 'P2028' || + isPrismaErrorCode(code, PrismaErrorCode.WriteConflict) || + isPrismaErrorCode(code, PrismaErrorCode.TransactionFailure) || (error instanceof Error && /SQLITE_BUSY|database is locked/.test(error.message)); - if ((busy || code === 'P2002') && attempt < MAX_WRITE_RETRY_COUNT) { + if ((busy || isPrismaErrorCode(code, PrismaErrorCode.UniqueConstraint)) && attempt < MAX_WRITE_RETRY_COUNT) { continue; } if (busy) conflict(BankErrorCode.WriteConflict); - if (code === 'P2002') conflict(BankErrorCode.DuplicateCategory); + if (isPrismaErrorCode(code, PrismaErrorCode.UniqueConstraint)) conflict(BankErrorCode.DuplicateCategory); throw error; } } diff --git a/src/modules/investments/income/income-analytics.service.ts b/src/modules/investments/income/income-analytics.service.ts index 0c8e551..480e37c 100644 --- a/src/modules/investments/income/income-analytics.service.ts +++ b/src/modules/investments/income/income-analytics.service.ts @@ -8,6 +8,7 @@ import { formatDecimal, subtract, } from '../../../common/decimal'; +import { isTbankProtocolValue, TbankOperationType } from '../../../integrations/tbank/tbank-protocol'; import { DateOnlyRange, moscowDate } from '../investment-calendar'; import { investmentResourceMeta } from '../investment-resource.mapper'; import { @@ -165,7 +166,7 @@ function qualifying(entries: BrokerageOperationDto[]): BrokerageOperationDto[] { return entries.filter( (entry) => Boolean(entry.payment) && - ['OPERATION_TYPE_COUPON', 'OPERATION_TYPE_DIVIDEND'].includes(entry.type), + [TbankOperationType.Coupon, TbankOperationType.Dividend].some((type) => isTbankProtocolValue(entry.type, type)), ); } @@ -184,7 +185,7 @@ 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 (isTbankProtocolValue(entry.type, TbankOperationType.Coupon)) { total.coupon = add(total.coupon, value); } else total.dividend = add(total.dividend, value); values.set(entry.payment.currency, total); diff --git a/src/modules/investments/income/income-period.ts b/src/modules/investments/income/income-period.ts index 32f91699..b4522f6 100644 --- a/src/modules/investments/income/income-period.ts +++ b/src/modules/investments/income/income-period.ts @@ -8,6 +8,8 @@ import { utcDate, } from '../investment-calendar'; +enum IncomeErrorCode { InvalidPeriod = 'INVALID_INCOME_PERIOD' } + const MONDAY_WEEKDAY_INDEX = 1; const INCLUSIVE_DAY_OFFSET = 1; const NEXT_DAY_OFFSET = 1; @@ -163,5 +165,5 @@ function yearAgo(range: DateOnlyRange): DateOnlyRange | null { } function invalidPeriod(): BadRequestException { - return new BadRequestException({ code: 'INVALID_INCOME_PERIOD' }); + return new BadRequestException({ code: IncomeErrorCode.InvalidPeriod }); } diff --git a/src/modules/investments/instruments/application/candle.service.ts b/src/modules/investments/instruments/application/candle.service.ts index 9566d4e..1c8f778 100644 --- a/src/modules/investments/instruments/application/candle.service.ts +++ b/src/modules/investments/instruments/application/candle.service.ts @@ -9,6 +9,7 @@ import { formatDecimal } from '../../../../common/decimal'; import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; import { MILLISECONDS_PER_DAY } from '../../../../common/time'; import { decimal } from '../../../../integrations/tbank/decimal'; +import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; import { TbankAccessService, TbankClient, @@ -23,6 +24,7 @@ import { candleWindows, utcDay, } from '../domain/candle-coverage'; +import { InstrumentErrorCode } from '../domain/instrument-error-code'; import { CandleRepository } from '../infrastructure/candle.repository'; import { CatalogEnrichmentService } from './catalog-enrichment.service'; import { timestamp } from './instrument-market'; @@ -71,8 +73,8 @@ function rangeFor( throw new ServiceUnavailableException({ code: input.instrumentType === 'share' - ? 'STOCK_HISTORY_START_UNAVAILABLE' - : 'BOND_HISTORY_START_UNAVAILABLE', + ? InstrumentErrorCode.StockHistoryStartUnavailable + : InstrumentErrorCode.BondHistoryStartUnavailable, }); } from.setTime(utcDay(new Date(history)).valueOf()); @@ -448,7 +450,7 @@ export class CandleService { const producer = this.queue.shift(); if (!producer) break; let success = false; - let code = 'TBANK_UNAVAILABLE'; + let code: string = TbankErrorCode.Unavailable; try { const result = await this.load(producer); if (result === null) { @@ -457,7 +459,7 @@ export class CandleService { } success = result; } catch (error) { - if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') { + if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) { await this.access.rejectMembershipToken(producer.attempt); } if (error instanceof ServiceUnavailableException) { @@ -523,7 +525,7 @@ export class CandleService { } } catch { this.logger.event('error', 'candles.persistence-failed', { - code: 'TBANK_UNAVAILABLE', + code: TbankErrorCode.Unavailable, }); } finally { this.active.clear(); diff --git a/src/modules/investments/instruments/application/catalog-discovery.service.ts b/src/modules/investments/instruments/application/catalog-discovery.service.ts index 6105b3a..9286681 100644 --- a/src/modules/investments/instruments/application/catalog-discovery.service.ts +++ b/src/modules/investments/instruments/application/catalog-discovery.service.ts @@ -6,6 +6,7 @@ import { import { randomUUID } from 'node:crypto'; import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; +import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; import { TbankAccessService, TbankClient, @@ -14,6 +15,7 @@ import { type TbankSyncAttempt, } from '../../../../integrations/tbank/tbank.api'; import { investmentResourceMeta } from '../../investment-resource.mapper'; +import { InstrumentErrorCode } from '../domain/instrument-error-code'; import { CatalogDiscoveryRepository, catalogQuery, @@ -209,8 +211,8 @@ export class CatalogDiscoveryService { throw new ConflictException({ code: instrumentType === 'share' - ? 'STOCK_TICKER_AMBIGUOUS' - : 'BOND_TICKER_AMBIGUOUS', + ? InstrumentErrorCode.StockTickerAmbiguous + : InstrumentErrorCode.BondTickerAmbiguous, }); } const result = lookup.data @@ -357,11 +359,11 @@ export class CatalogDiscoveryService { ); } } catch (error) { - if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') { + if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) { await this.access.rejectMembershipToken(attempt); } else { this.logger.event('warn', 'catalog-discovery.failed', { - code: 'TBANK_UNAVAILABLE', + code: TbankErrorCode.Unavailable, }); } } @@ -395,7 +397,7 @@ export class CatalogDiscoveryService { this.producers.clear(); this.queue.length = 0; this.logger.event('error', 'catalog-discovery.persistence-failed', { - code: 'TBANK_UNAVAILABLE', + code: TbankErrorCode.Unavailable, }); } finally { this.running = false; diff --git a/src/modules/investments/instruments/application/catalog-enrichment.service.ts b/src/modules/investments/instruments/application/catalog-enrichment.service.ts index 1b8e892..cfc78a0 100644 --- a/src/modules/investments/instruments/application/catalog-enrichment.service.ts +++ b/src/modules/investments/instruments/application/catalog-enrichment.service.ts @@ -14,6 +14,7 @@ import { import { type MoexBondEvent, MoexClient } from '../../../../integrations/moex/moex.api'; import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api'; import { decimal } from '../../../../integrations/tbank/decimal'; +import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; import { TbankAccessService, type TbankBond, @@ -520,11 +521,11 @@ export class CatalogEnrichmentService { } } catch (error) { failed = true; - if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') { + if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) { await this.access.rejectMembershipToken(producer.attempt); } else { this.logger.event('warn', 'catalog-enrichment.failed', { - code: 'TBANK_UNAVAILABLE', + code: TbankErrorCode.Unavailable, }); } } @@ -572,7 +573,7 @@ export class CatalogEnrichmentService { this.producers.clear(); this.queue.length = 0; this.logger.event('error', 'catalog-enrichment.persistence-failed', { - code: 'TBANK_UNAVAILABLE', + code: TbankErrorCode.Unavailable, }); } finally { this.running = false; diff --git a/src/modules/investments/instruments/application/catalog-selection.ts b/src/modules/investments/instruments/application/catalog-selection.ts index 2928a9e..471dbe4 100644 --- a/src/modules/investments/instruments/application/catalog-selection.ts +++ b/src/modules/investments/instruments/application/catalog-selection.ts @@ -1,5 +1,7 @@ import type { TbankShare } from '../../../../integrations/tbank/tbank.api'; +import { TbankInstrumentType } from '../../../../integrations/tbank/tbank-protocol'; + export const catalogSearchLimit = 30; export function selectCatalogInstruments( @@ -10,8 +12,8 @@ export function selectCatalogInstruments( const seen = new Set(); const kind = instrumentType === 'share' - ? 'INSTRUMENT_TYPE_SHARE' - : 'INSTRUMENT_TYPE_BOND'; + ? TbankInstrumentType.Share + : TbankInstrumentType.Bond; const preferredClass = instrumentType === 'share' ? 'TQBR' : 'TQOB'; return found .filter( diff --git a/src/modules/investments/instruments/bonds/application/bond-income.service.ts b/src/modules/investments/instruments/bonds/application/bond-income.service.ts index ab09102..b96b7d9 100644 --- a/src/modules/investments/instruments/bonds/application/bond-income.service.ts +++ b/src/modules/investments/instruments/bonds/application/bond-income.service.ts @@ -7,6 +7,7 @@ import { multiply, } from '../../../../../common/decimal'; import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time'; +import { isTbankProtocolValue, TbankCouponType } from '../../../../../integrations/tbank/tbank-protocol'; import { TbankAccessService, type TbankBond, @@ -241,10 +242,10 @@ export function bondMetrics( ) : null; const couponType = - next?.couponType && next.couponType !== 'COUPON_TYPE_UNSPECIFIED' + next?.couponType && !isTbankProtocolValue(next.couponType, TbankCouponType.Unspecified) ? next.couponType : instrument.floatingCouponFlag === true - ? 'COUPON_TYPE_FLOATING' + ? TbankCouponType.Floating : null; return { creditRating, diff --git a/src/modules/investments/instruments/domain/instrument-error-code.ts b/src/modules/investments/instruments/domain/instrument-error-code.ts new file mode 100644 index 0000000..3f5f103 --- /dev/null +++ b/src/modules/investments/instruments/domain/instrument-error-code.ts @@ -0,0 +1,6 @@ +export enum InstrumentErrorCode { + BondHistoryStartUnavailable = 'BOND_HISTORY_START_UNAVAILABLE', + BondTickerAmbiguous = 'BOND_TICKER_AMBIGUOUS', + StockHistoryStartUnavailable = 'STOCK_HISTORY_START_UNAVAILABLE', + StockTickerAmbiguous = 'STOCK_TICKER_AMBIGUOUS', +} diff --git a/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts b/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts index 46fe512..43547bf 100644 --- a/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts +++ b/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts @@ -3,12 +3,9 @@ import { randomUUID } from 'node:crypto'; import { isDeepStrictEqual } from 'node:util'; import type { Prisma } from '../../../../../generated/prisma/client'; -import type { - TbankBond, - TbankShare, - TbankSyncAttempt, -} from '../../../../integrations/tbank/tbank.api'; +import type { TbankBond, TbankShare, TbankSyncAttempt } from '../../../../integrations/tbank/tbank.api'; +import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; import { PrismaService } from '../../../../prisma/prisma.service'; const UNCHANGED_REVISION_INCREMENT = 0; @@ -462,7 +459,7 @@ export class CatalogDiscoveryRepository { state: 'running', connection: { invalidToken: true }, }, - data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' }, + data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken }, }); await tx.tbankSyncWork.updateMany({ where: { @@ -472,7 +469,7 @@ export class CatalogDiscoveryRepository { }, data: { state: success ? 'complete' : 'failed', - errorCode: success ? null : 'TBANK_UNAVAILABLE', + errorCode: success ? null : TbankErrorCode.Unavailable, }, }); }); diff --git a/src/modules/investments/operations/application/brokerage-operations-sync.service.ts b/src/modules/investments/operations/application/brokerage-operations-sync.service.ts index fdaad98..4207384 100644 --- a/src/modules/investments/operations/application/brokerage-operations-sync.service.ts +++ b/src/modules/investments/operations/application/brokerage-operations-sync.service.ts @@ -3,10 +3,9 @@ import { randomUUID } from 'node:crypto'; import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; import { MILLISECONDS_PER_SECOND } from '../../../../common/time'; -import { - TbankAccessService, - TbankClient, -} from '../../../../integrations/tbank/tbank.api'; +import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; +import { TbankOperationState } from '../../../../integrations/tbank/tbank-protocol'; +import { TbankAccessService, TbankClient } from '../../../../integrations/tbank/tbank.api'; import { accountHistory } from '../../investment-sync-plan'; import { type OperationInterval, @@ -208,7 +207,7 @@ export class BrokerageOperationsSyncService { this.logger.event( 'error', 'tbank.operations.sync.persistence_failed', - { userId: attempt.userId, code: 'TBANK_UNAVAILABLE' }, + { userId: attempt.userId, code: TbankErrorCode.Unavailable }, ); } const next = pending ? await this.repository.pending(attempt) : null; @@ -272,7 +271,7 @@ export class BrokerageOperationsSyncService { : {}), cursor, limit: operationsPageSize, - state: 'OPERATION_STATE_EXECUTED', + state: TbankOperationState.Executed, }); if ( !(await this.repository.stagePage(attempt, interval, cursor, response)) @@ -297,7 +296,7 @@ export class BrokerageOperationsSyncService { } catch (error) { await this.repository.fail( attempt, - error instanceof Error && error.message === 'INVALID_TBANK_TOKEN', + error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken), ); return false; } diff --git a/src/modules/investments/operations/application/brokerage-operations.service.ts b/src/modules/investments/operations/application/brokerage-operations.service.ts index 3a43f43..800dc29 100644 --- a/src/modules/investments/operations/application/brokerage-operations.service.ts +++ b/src/modules/investments/operations/application/brokerage-operations.service.ts @@ -17,6 +17,7 @@ import { ledgerRange, planInvestmentScreen, } from '../../investment-sync-plan'; +import { OperationErrorCode } from '../domain/operation-error-code'; import { operationIntervalsOverlap, operationsSyncPriority, @@ -442,7 +443,7 @@ export class BrokerageOperationsService { } return cursor as LocalOperationsCursor; } catch { - throw new BadRequestException({ code: 'INVALID_OPERATIONS_CURSOR' }); + throw new BadRequestException({ code: OperationErrorCode.InvalidOperationsCursor }); } } diff --git a/src/modules/investments/operations/domain/operation-error-code.ts b/src/modules/investments/operations/domain/operation-error-code.ts new file mode 100644 index 0000000..b3c10d4 --- /dev/null +++ b/src/modules/investments/operations/domain/operation-error-code.ts @@ -0,0 +1,3 @@ +export enum OperationErrorCode { + InvalidOperationsCursor = 'INVALID_OPERATIONS_CURSOR', +} diff --git a/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts b/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts index 17782cb..5d2a752 100644 --- a/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts +++ b/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts @@ -1,10 +1,8 @@ import { formatDecimal } from '../../../../common/decimal'; import { MILLISECONDS_PER_SECOND } from '../../../../common/time'; import { decimal } from '../../../../integrations/tbank/decimal'; -import { - TbankMoney, - TbankOperationItem, -} from '../../../../integrations/tbank/tbank.api'; +import { TbankOperationType } from '../../../../integrations/tbank/tbank-protocol'; +import { TbankMoney, TbankOperationItem } from '../../../../integrations/tbank/tbank.api'; import { BrokerageOperationDto, OperationCategory, @@ -15,29 +13,29 @@ const MISSING_TIMESTAMP_COMPONENT = 0; const NANOSECONDS_PER_MILLISECOND = 1_000_000; const types: Record = { - OPERATION_TYPE_BUY: 'trade', - OPERATION_TYPE_BUY_CARD: 'trade', - OPERATION_TYPE_SELL: 'trade', - OPERATION_TYPE_SELL_CARD: 'trade', - OPERATION_TYPE_BUY_MARGIN: 'trade', - OPERATION_TYPE_SELL_MARGIN: 'trade', - OPERATION_TYPE_COUPON: 'income', - OPERATION_TYPE_DIVIDEND: 'income', - OPERATION_TYPE_DIVIDEND_TRANSFER: 'income', - OPERATION_TYPE_DIV_EXT: 'income', - OPERATION_TYPE_TRANS_IIS_BS: 'transfer', - OPERATION_TYPE_TRANS_BS_BS: 'transfer', - OPERATION_TYPE_BOND_TAX: 'tax', - OPERATION_TYPE_TAX: 'tax', - OPERATION_TYPE_DIVIDEND_TAX: 'tax', - OPERATION_TYPE_SERVICE_FEE: 'fee', - OPERATION_TYPE_MARGIN_FEE: 'fee', - OPERATION_TYPE_BROKER_FEE: 'fee', - OPERATION_TYPE_SUCCESS_FEE: 'fee', - OPERATION_TYPE_INPUT: 'transfer', - OPERATION_TYPE_OUTPUT: 'transfer', - OPERATION_TYPE_INPUT_SECURITIES: 'transfer', - OPERATION_TYPE_OUTPUT_SECURITIES: 'transfer', + [TbankOperationType.Buy]: 'trade', + [TbankOperationType.BuyCard]: 'trade', + [TbankOperationType.Sell]: 'trade', + [TbankOperationType.SellCard]: 'trade', + [TbankOperationType.BuyMargin]: 'trade', + [TbankOperationType.SellMargin]: 'trade', + [TbankOperationType.Coupon]: 'income', + [TbankOperationType.Dividend]: 'income', + [TbankOperationType.DividendTransfer]: 'income', + [TbankOperationType.DivExt]: 'income', + [TbankOperationType.TransIisBs]: 'transfer', + [TbankOperationType.TransBsBs]: 'transfer', + [TbankOperationType.BondTax]: 'tax', + [TbankOperationType.Tax]: 'tax', + [TbankOperationType.DividendTax]: 'tax', + [TbankOperationType.ServiceFee]: 'fee', + [TbankOperationType.MarginFee]: 'fee', + [TbankOperationType.BrokerFee]: 'fee', + [TbankOperationType.SuccessFee]: 'fee', + [TbankOperationType.Input]: 'transfer', + [TbankOperationType.Output]: 'transfer', + [TbankOperationType.InputSecurities]: 'transfer', + [TbankOperationType.OutputSecurities]: 'transfer', }; export function operationCategory(type: string | undefined): OperationCategory { @@ -51,7 +49,7 @@ export function mapBrokerageOperation( return { id: operation.id ?? operation.cursor ?? '', occurredAt: occurredAt.toISOString(), - type: operation.type ?? 'OPERATION_TYPE_UNSPECIFIED', + type: operation.type ?? TbankOperationType.Unspecified, category: operationCategory(operation.type), description: operation.description?.trim() || operation.name?.trim() || '', figi: text(operation.figi), diff --git a/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts b/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts index 5c08d06..7181714 100644 --- a/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts +++ b/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts @@ -12,12 +12,15 @@ import type { TbankOperationsByCursorResponse, } from '../../../../integrations/tbank/tbank.api'; +import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; +import { isTbankProtocolValue, TbankOperationState } from '../../../../integrations/tbank/tbank-protocol'; import { PrismaService } from '../../../../prisma/prisma.service'; import { dateOnlyBounds, DateOnlyRange, moscowDate, } from '../../investment-calendar'; +import { OperationErrorCode } from '../domain/operation-error-code'; import { type OperationInterval, operationIntervals, @@ -137,7 +140,7 @@ export class BrokerageOperationsRepository { ) .digest('base64url'); if (input.cursor && input.cursor.binding !== binding) { - throw new BadRequestException({ code: 'INVALID_OPERATIONS_CURSOR' }); + throw new BadRequestException({ code: OperationErrorCode.InvalidOperationsCursor }); } const scope = { userId: input.userId, @@ -615,7 +618,7 @@ export class BrokerageOperationsRepository { where: { id: attempt.id }, data: { state: 'failed', - errorCode: invalidToken ? 'INVALID_TBANK_TOKEN' : 'TBANK_UNAVAILABLE', + errorCode: invalidToken ? TbankErrorCode.InvalidToken : TbankErrorCode.Unavailable, }, }); if (invalidToken) { @@ -784,7 +787,7 @@ export class BrokerageOperationsRepository { } const rows = staged.filter((row) => { const state = (row.payload as TbankOperationItem).state; - return !state || state === 'OPERATION_STATE_EXECUTED'; + return !state || isTbankProtocolValue(state, TbankOperationState.Executed); }); const scope = { userId: attempt.userId, diff --git a/src/modules/investments/portfolio/application/portfolio.service.ts b/src/modules/investments/portfolio/application/portfolio.service.ts index e6930dc..8da8363 100644 --- a/src/modules/investments/portfolio/application/portfolio.service.ts +++ b/src/modules/investments/portfolio/application/portfolio.service.ts @@ -17,6 +17,7 @@ import { RequestContextService } from '../../../../common/logging/request-contex import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; import { MoneyDto } from '../../../../common/money.dto'; import { decimal } from '../../../../integrations/tbank/decimal'; +import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; import { InstrumentMetadataService, type MetadataIdentity, @@ -387,7 +388,7 @@ export class PortfolioService { } catch (error) { if ( error instanceof Error && - error.message === 'INVALID_TBANK_TOKEN' + isTbankErrorCode(error.message, TbankErrorCode.InvalidToken) ) { await this.access.rejectMembershipToken(attempt); } else await this.repository.fail(attempt); @@ -412,7 +413,7 @@ export class PortfolioService { async get(userId: string, accountId: string): Promise { const result = await this.read(userId, accountId); if (!result.data) { - throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }); + throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable }); } return result.data; } diff --git a/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts b/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts index 22f3b5a..682e9de 100644 --- a/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts +++ b/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts @@ -4,10 +4,8 @@ import { randomUUID } from 'node:crypto'; import type { Prisma } from '../../../../../generated/prisma/client'; import type { PortfolioDto } from '../presentation/portfolio.dto'; -import { - tbankSourceNeedsRefresh, - tbankSourceTtlMs, -} from '../../../../integrations/tbank/tbank.api'; +import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; +import { tbankSourceNeedsRefresh, tbankSourceTtlMs } from '../../../../integrations/tbank/tbank.api'; import { PrismaService } from '../../../../prisma/prisma.service'; const EXPECTED_UPDATED_ACCOUNTS = 1; @@ -152,7 +150,7 @@ export class PortfolioRepository { state: 'running', connection: { invalidToken: false }, }, - data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' }, + data: { state: 'failed', errorCode: TbankErrorCode.Unavailable }, }); return result.count === EXPECTED_UPDATED_ACCOUNTS; } diff --git a/src/modules/investments/tbank-connection/tbank-connection.service.ts b/src/modules/investments/tbank-connection/tbank-connection.service.ts index 9bd15ad..cbde0b4 100644 --- a/src/modules/investments/tbank-connection/tbank-connection.service.ts +++ b/src/modules/investments/tbank-connection/tbank-connection.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { HttpErrorCode } from '../../../common/error-code'; import { supportedTbankAccounts, TbankClient, @@ -29,7 +30,7 @@ export class TbankConnectionService { async put(userId: string, value: string) { const token = value.trim(); - if (!token) throw new BadRequestException({ code: 'VALIDATION_ERROR' }); + if (!token) throw new BadRequestException({ code: HttpErrorCode.ValidationError }); const accounts = await this.tokens.translateInvalidToken(() => this.tbank.validateToken(token), ); diff --git a/src/modules/investments/tbank-sync/tbank-sync.service.ts b/src/modules/investments/tbank-sync/tbank-sync.service.ts index dcd94e7..96f0682 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.service.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.service.ts @@ -9,6 +9,7 @@ import { randomUUID } from 'node:crypto'; import type { InvestmentResourceMetaDto } from '../investment-resource.dto'; import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../common/time'; +import { TbankErrorCode } from '../../../integrations/tbank/tbank-error-code'; import { InstrumentMetadataService, TbankAccessService, @@ -325,7 +326,7 @@ export class TbankSyncService { await this.repository.progress(attempt, children, true); } catch { await this.repository - .progress(attempt, children, true, 'TBANK_UNAVAILABLE') + .progress(attempt, children, true, TbankErrorCode.Unavailable) .catch(() => undefined); } } diff --git a/src/modules/investments/upcoming-events/upcoming-events.service.ts b/src/modules/investments/upcoming-events/upcoming-events.service.ts index dc30d92..1f61e3f 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.service.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.service.ts @@ -31,6 +31,8 @@ import { UpcomingEventsQueryDto, } from './upcoming-events.dto'; +enum EventsErrorCode { InvalidPeriod = 'INVALID_EVENTS_PERIOD' } + const FIRST_BATCH_OFFSET = 0; const DEFAULT_EVENT_WINDOW_DAYS = 30; @@ -267,7 +269,7 @@ function dateWindow(query: UpcomingEventsQueryDto) { end.setUTCDate(end.getUTCDate() + DEFAULT_EVENT_WINDOW_DAYS); const to = query.to ?? end.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH); if (from > to) { - throw new BadRequestException({ code: 'INVALID_EVENTS_PERIOD' }); + throw new BadRequestException({ code: EventsErrorCode.InvalidPeriod }); } return { from, to }; } diff --git a/src/prisma/prisma-error-code.ts b/src/prisma/prisma-error-code.ts new file mode 100644 index 0000000..20843e3 --- /dev/null +++ b/src/prisma/prisma-error-code.ts @@ -0,0 +1,9 @@ +export enum PrismaErrorCode { + TransactionFailure = 'P2028', + UniqueConstraint = 'P2002', + WriteConflict = 'P2034', +} + +export function isPrismaErrorCode(value: unknown, expected: PrismaErrorCode): boolean { + return value === expected; +} diff --git a/test/eslint-config.cjs b/test/eslint-config.cjs index c186465..c574386 100644 --- a/test/eslint-config.cjs +++ b/test/eslint-config.cjs @@ -61,3 +61,27 @@ test('numeric literals require names, including previously ignored numbers and b assert.ok(!named.messages.some(({ ruleId }) => ruleId === '@typescript-eslint/no-magic-numbers'), literal); } }); + +// Single-use codes must be caught too; fixture literals remain independent wire assertions. +test('machine codes require enum members without banning ordinary text or Prisma selects', async () => { + const eslint = new ESLint(); + const filePath = 'src/main.ts'; + for (const code of ['export function check(code: string) { return code === "one-off-code"; }', 'export const result = `MOEX_UNAVAILABLE`;', 'export const result = { code: "one-off-code" };', 'export const result = { "errorCode": "one-off-code" };', 'export const result = { code: `dynamic-${Date.now()}` };', 'export const result = "MOEX_UNAVAILABLE";', 'export const result = ["INSTRUMENT_ID_TYPE_FIGI"];', 'export const result = { OPERATION_TYPE_COUPON: "income" };']) { + const [result] = await eslint.lintText(code, { filePath }); + assert.equal(result.fatalErrorCount, 0, code); + assert.ok(result.messages.some(({ ruleId }) => ruleId === 'no-restricted-syntax'), code); + } + for (const code of [ + 'enum Code { Unavailable = "MOEX_UNAVAILABLE" } export const result = { code: Code.Unavailable };', + 'export const select = { code: true, errorCode: true };', + 'export const label = "Please try again"; export const key = "JWT_SECRET";', + ]) { + const [result] = await eslint.lintText(code, { filePath }); + assert.equal(result.fatalErrorCount, 0, code); + assert.ok(!result.messages.some(({ ruleId }) => ruleId === 'no-restricted-syntax'), code); + } + const [fixture] = await eslint.lintText('export const result = { code: "MOEX_UNAVAILABLE" };', { filePath: 'src/integrations/moex/moex.client.spec.ts' }); + assert.ok(!fixture.messages.some(({ ruleId }) => ruleId === 'no-restricted-syntax')); + const [comparison] = await eslint.lintText('enum Code { Ready = "ready" } export function check(code: Code) { return code === "ready"; }', { filePath }); + assert.ok(comparison.messages.some(({ ruleId }) => ruleId === '@typescript-eslint/no-unsafe-enum-comparison')); +}); From 53b27949631cadda10f988ff1920ac813d175ccc Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Mon, 28 Sep 2026 14:12:38 +0300 Subject: [PATCH 07/22] ci: reduce duplicate actions and serialize checks --- .gitea/workflows/commits.yml | 26 --------------- .gitea/workflows/security.yml | 31 ----------------- .gitea/workflows/verify.yml | 63 ++++++++++++++++++++++++++++++++++- README.md | 27 ++++++++++++--- 4 files changed, 84 insertions(+), 63 deletions(-) delete mode 100644 .gitea/workflows/commits.yml delete mode 100644 .gitea/workflows/security.yml diff --git a/.gitea/workflows/commits.yml b/.gitea/workflows/commits.yml deleted file mode 100644 index 2372d2c..0000000 --- a/.gitea/workflows/commits.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Conventional commits - -on: - pull_request: - types: [opened, synchronize, reopened, edited] - -jobs: - commits: - runs-on: ubuntu-latest - steps: - - uses: https://github.com/actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha }} - fetch-depth: 0 - - uses: https://github.com/actions/setup-node@v4 - with: - node-version-file: .nvmrc - - run: npm ci --ignore-scripts - - name: Check PR title and commits - env: - PR_TITLE: ${{ github.event.pull_request.title }} - BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: | - printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict - npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA" diff --git a/.gitea/workflows/security.yml b/.gitea/workflows/security.yml deleted file mode 100644 index 7c779f1..0000000 --- a/.gitea/workflows/security.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Backend security - -on: - push: - branches: - - '**' - pull_request: - schedule: - - cron: '17 5 * * *' - -jobs: - security: - runs-on: ubuntu-latest - steps: - - uses: https://github.com/actions/checkout@v4 - with: - fetch-depth: 0 - - name: Record checked revision - run: git rev-parse HEAD - - name: Scan history with Gitleaks 8.30.1 - run: | - curl --fail --location --silent --show-error https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz -o /tmp/gitleaks.tar.gz - echo '551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb /tmp/gitleaks.tar.gz' | sha256sum --check - mkdir -p /tmp/gitleaks-bin - tar -xzf /tmp/gitleaks.tar.gz -C /tmp/gitleaks-bin gitleaks - /tmp/gitleaks-bin/gitleaks git --log-opts=--all --redact=100 --no-banner - - uses: https://github.com/actions/setup-node@v4 - with: - node-version-file: .nvmrc - - name: Audit runtime and development dependencies - run: npm audit --include=dev --audit-level=low diff --git a/.gitea/workflows/verify.yml b/.gitea/workflows/verify.yml index 204159a..7087f11 100644 --- a/.gitea/workflows/verify.yml +++ b/.gitea/workflows/verify.yml @@ -2,11 +2,72 @@ name: Backend verify on: push: - branches: ['**'] + branches: [develop, main] pull_request: + types: [opened, synchronize, reopened, edited] + schedule: + - cron: '47 5 * * *' + +# Gitea >= 1.26. A new revision supersedes only the same PR/branch/event. +concurrency: + group: backend-verify-${{ gitea.event_name }}-${{ gitea.event.pull_request.number || gitea.ref }} + cancel-in-progress: true jobs: + commits: + if: gitea.event_name == 'pull_request' + timeout-minutes: 15 + runs-on: ubuntu-latest + steps: + - uses: https://github.com/actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + - uses: https://github.com/actions/setup-node@v4 + with: + node-version-file: .nvmrc + - run: npm ci --ignore-scripts + - name: Check PR title and commits + env: + PR_TITLE: ${{ github.event.pull_request.title }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict + npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA" + + security: + needs: commits + if: >- + !cancelled() && + (needs.commits.result == 'success' || needs.commits.result == 'skipped') + timeout-minutes: 15 + runs-on: ubuntu-latest + steps: + - uses: https://github.com/actions/checkout@v4 + with: + fetch-depth: 0 + - name: Record checked revision + run: git rev-parse HEAD + - name: Scan history with Gitleaks 8.30.1 + run: | + curl --fail --location --silent --show-error https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz -o /tmp/gitleaks.tar.gz + echo '551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb /tmp/gitleaks.tar.gz' | sha256sum --check + mkdir -p /tmp/gitleaks-bin + tar -xzf /tmp/gitleaks.tar.gz -C /tmp/gitleaks-bin gitleaks + /tmp/gitleaks-bin/gitleaks git --log-opts=--all --redact=100 --no-banner + - uses: https://github.com/actions/setup-node@v4 + with: + node-version-file: .nvmrc + - name: Audit runtime and development dependencies + run: npm audit --include=dev --audit-level=low + verify: + needs: [commits, security] + if: >- + !cancelled() && needs.security.result == 'success' && + gitea.event_name != 'schedule' + timeout-minutes: 45 runs-on: ubuntu-latest steps: - uses: https://github.com/actions/checkout@v4 diff --git a/README.md b/README.md index fa6844f..c2ec7f1 100644 --- a/README.md +++ b/README.md @@ -74,17 +74,34 @@ TypeScript 7 требует поддержки со стороны `ts-jest` и ### CI и обновления зависимостей -`.gitea/workflows/verify.yml` запускает `npm ci`, генерацию Prisma client, -`npm run verify` и `npm run test:e2e` на push и PR. Используется только checkout +Единый `.gitea/workflows/verify.yml` запускает `commits → security → verify` +последовательно на PR; на push в `develop`/`main` — `security → verify`. +Рабочие ветки проверяются после открытия PR, без дублирующего push-запуска. +Изменение заголовка PR тоже повторяет проверки Conventional Commits. +Новый запуск отменяет устаревший для того же PR/ветки и типа события +(требуется Gitea 1.26+). Ежедневно в 05:47 UTC выполняется только `security`. +Job `verify` запускает `npm ci`, генерацию Prisma client, +`npm run verify` и `npm run test:e2e`. Используется только checkout backend и его зависимости; OpenAPI генерируется во время проверки, SHA записывается в лог. Успешный unit gate не заменяет SQLite/e2e; ошибка любой suite завершает job с FAIL. -`security.yml` сканирует всю Git-историю Gitleaks 8.30.1 с полностью скрытыми -значениями и выполняет `npm audit --include=dev --audit-level=low` при push, -PR и ежедневно. `.gitleaksignore` перечисляет только проверенные исторические +Job `security` сканирует всю Git-историю Gitleaks 8.30.1 с полностью скрытыми +значениями и выполняет `npm audit --include=dev --audit-level=low`. +`.gitleaksignore` перечисляет только проверенные исторические совпадения: детерминированные fixtures, placeholder и badge из Nest starter. Новые коммиты этими исключениями не покрываются. +Имена jobs `commits`, `security`, `verify` сохранены, но Gitea включает имя +workflow в статус. При внедрении администратор должен заменить required checks +для защищённых веток на `Backend verify / commits (pull_request)`, +`Backend verify / security (pull_request)` и +`Backend verify / verify (pull_request)`, удалив старые имена и push-статусы, +если они требовались. Все три PR-проверки должны оставаться обязательными. +Публикация образов по тегам остаётся в `image.yml`. +Последовательность jobs ограничивает один запуск; для ограничения нагрузки всего сервера настройте +`runner.capacity: 1` у общего runner и учтите остальные зарегистрированные +runners. Конфигурация runner управляется вне этого репозитория. + Renovate группирует minor/patch updates, а major требует согласования в Dependency Dashboard. Автоматический merge не включён. Прямые зависимости фиксируются точными версиями: `.npmrc` задаёт `save-exact=true` для npm install, From 7d2262fb439298e73d2651aabae7c6dc6e2107d5 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Mon, 28 Sep 2026 15:48:44 +0300 Subject: [PATCH 08/22] fix: resolve build imports before linting Resolve dist imports in runtime scripts against their TypeScript sources. Keep missing-module checks enabled and cover absent build output in the lint gate. Verified npm ci, prisma generate and full verify with dist absent at startup. --- eslint.config.mjs | 12 ++++++++++++ test/eslint-config.cjs | 29 +++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/eslint.config.mjs b/eslint.config.mjs index 1e01eab..998a57b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -60,6 +60,18 @@ export default defineConfig( files: ['**/*.mjs'], extends: [node.configs['flat/recommended-module']], }, + { + files: ['scripts/generate-openapi.cjs', 'test/fixtures/tbank-sync-crash.cjs', 'test/runtime-check.cjs'], + rules: { + // These scripts run after build; lint validates their source paths before dist exists. + 'n/no-missing-require': ['error', { + resolverConfig: { + alias: { '../dist': import.meta.dirname, '../../dist': import.meta.dirname }, + extensionAlias: { '.js': ['.ts', '.js'] }, + }, + }], + }, + }, { files: ['**/*.ts'], extends: [tseslint.configs.recommendedTypeChecked, importX.flatConfigs.typescript], diff --git a/test/eslint-config.cjs b/test/eslint-config.cjs index c574386..592f8c0 100644 --- a/test/eslint-config.cjs +++ b/test/eslint-config.cjs @@ -1,9 +1,38 @@ const { ESLint } = require('eslint'); const assert = require('node:assert/strict'); +const { mkdtempSync, rmSync, writeFileSync } = require('node:fs'); +const { basename, join } = require('node:path'); const { test } = require('node:test'); const eslint = new ESLint(); +test('runtime scripts resolve build imports against sources before dist exists', async () => { + const directory = mkdtempSync(join(process.cwd(), '.eslint-require-')); + try { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Test-owned temporary source with no compiled counterpart. + writeFileSync(join(directory, 'entry.ts'), 'export const value = true;'); + for (const [filePath, prefix] of [ + ['scripts/generate-openapi.cjs', '../'], + ['test/runtime-check.cjs', '../'], + ['test/fixtures/tbank-sync-crash.cjs', '../../'], + ]) { + const source = `${prefix}dist/${basename(directory)}/entry`; + const [result] = await eslint.lintText( + `require('${source}.js'); require('${source}'); require('node:fs'); require('eslint'); require('${source}-missing.js'); require('${prefix}missing-local.cjs'); require('wealthledger-missing-package');`, + { filePath }, + ); + assert.equal(result.fatalErrorCount, 0, filePath); + const missing = result.messages.filter(({ ruleId }) => ruleId === 'n/no-missing-require'); + assert.equal(missing.length, 3, filePath); + for (const target of [`${source}-missing.js`, `${prefix}missing-local.cjs`, 'wealthledger-missing-package']) { + assert.ok(missing.some(({ message }) => message.includes(`'${target}'`)), target); + } + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + // Protect the quality gate's file scopes when configs or plugin versions change. test('typed application code enforces async and unsafe-type checks', async () => { const [result] = await eslint.lintText( From e76eb545d7d2bbd763e696c947da9e0cd34e1ecd Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Mon, 28 Sep 2026 16:23:58 +0300 Subject: [PATCH 09/22] perf: group catalog source reads by identity --- .../catalog-discovery.repository.ts | 16 ++++++- test/tbank-catalog-local.e2e-spec.ts | 44 +++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts b/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts index 43547bf..23cbc0e 100644 --- a/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts +++ b/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts @@ -123,11 +123,23 @@ export class CatalogDiscoveryRepository { } readSources(inputs: CatalogSource[]) { + // Share identity predicates across UIDs instead of preparing one OR per row. + const groups = new Map(); + for (const input of inputs) { + const key = JSON.stringify([ + input.kind, + sourceProvider(input), + input.kind === 'detail' ? input.instrumentType : (input.rangeKey ?? ''), + ]); + const group = groups.get(key); + if (group) group.push(input); + else groups.set(key, [input]); + } return this.prisma.tbankInstrumentSource.findMany({ where: { provider, - OR: inputs.map((input) => ({ - instrumentUid: input.uid, + OR: [...groups.values()].map(([input, ...rest]) => ({ + instrumentUid: { in: [input, ...rest].map((entry) => entry.uid) }, kind: input.kind, source: sourceProvider(input), rangeKey: diff --git a/test/tbank-catalog-local.e2e-spec.ts b/test/tbank-catalog-local.e2e-spec.ts index 611b42a..2b532c0 100644 --- a/test/tbank-catalog-local.e2e-spec.ts +++ b/test/tbank-catalog-local.e2e-spec.ts @@ -178,6 +178,50 @@ describe('local stock/bond search (SQLite HTTP)', () => { }); afterAll(async () => app?.close()); + it('reads only requested source identities when batching UIDs, types and periods', async () => { + const repository = app.get(CatalogDiscoveryRepository); + const fetchedAt = new Date(); + for (const uid of ['a', 'b']) { + await prisma.tbankInstrument.create({ + data: { provider: 'tbank', uid, instrumentType: 'share', metadata: {}, fetchedAt }, + }); + } + const expected = [ + { instrumentUid: 'a', source: 'tbank', kind: 'detail', rangeKey: 'share' }, + { instrumentUid: 'b', source: 'tbank', kind: 'detail', rangeKey: 'share' }, + { instrumentUid: 'a', source: 'tbank', kind: 'dividends', rangeKey: '2026' }, + { instrumentUid: 'b', source: 'tbank', kind: 'dividends', rangeKey: '2027' }, + { instrumentUid: 'b', source: 'moex', kind: 'bond-events', rangeKey: '' }, + { instrumentUid: 'a', source: 'ncr', kind: 'rating', rangeKey: '' }, + ]; + const distractors = [ + { instrumentUid: 'a', source: 'tbank', kind: 'detail', rangeKey: 'bond' }, + { instrumentUid: 'b', source: 'tbank', kind: 'dividends', rangeKey: '2026' }, + { instrumentUid: 'a', source: 'tbank', kind: 'dividends', rangeKey: '2027' }, + { instrumentUid: 'a', source: 'moex', kind: 'bond-events', rangeKey: '' }, + { instrumentUid: 'b', source: 'tbank', kind: 'bond-events', rangeKey: '' }, + { instrumentUid: 'a', source: 'tbank', kind: 'rating', rangeKey: '' }, + ]; + await prisma.tbankInstrumentSource.createMany({ + data: [...expected, ...distractors].map((identity) => ({ + ...identity, provider: 'tbank', fetchedAt, payload: identity, + })), + }); + const rows = await repository.readSources([ + { uid: 'a', instrumentType: 'share', kind: 'detail' }, + { uid: 'b', instrumentType: 'share', kind: 'detail' }, + { uid: 'a', instrumentType: 'share', kind: 'detail' }, + { uid: 'a', instrumentType: 'share', kind: 'dividends', rangeKey: '2026' }, + { uid: 'b', instrumentType: 'share', kind: 'dividends', rangeKey: '2027' }, + { uid: 'b', instrumentType: 'bond', kind: 'bond-events' }, + { uid: 'a', instrumentType: 'bond', kind: 'rating' }, + { uid: 'missing', instrumentType: 'share', kind: 'detail' }, + ]); + expect(rows.map((row) => row.payload)).toEqual(expect.arrayContaining(expected)); + expect(rows).toHaveLength(expected.length); + expect(await repository.readSources([])).toEqual([]); + }); + it('returns missing without waiting for discovery, then partial while enrichment is blocked; observe never launches work', async () => { const found = deferred<(typeof share)[]>(); const detail = deferred(); From a970eaaf7a5549d1c51844ee6fc06c1aaf5afa56 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Mon, 28 Sep 2026 11:04:18 +0300 Subject: [PATCH 10/22] chore: strengthen backend eslint profile --- .ai/architecture.md | 4 +- .prettierrc | 4 - README.md | 28 + commitlint.config.cjs | 4 +- eslint-suppressions.json | 206 ++++ eslint.config.mjs | 110 +- package-lock.json | 969 ++++++++++++++---- package.json | 30 +- scripts/check-release.cjs | 20 +- scripts/generate-openapi.cjs | 1 + src/app.module.ts | 3 +- src/common/decimal.ts | 16 +- .../api-safe-error-response.decorator.ts | 11 +- src/common/dto-validation.spec.ts | 4 +- .../filters/http-exception.filter.spec.ts | 1 + src/common/filters/http-exception.filter.ts | 29 +- src/common/logging/logging.module.ts | 1 + src/common/logging/request-context.service.ts | 6 +- .../request-logging.middleware.spec.ts | 5 +- .../logging/request-logging.middleware.ts | 32 +- .../logging/structured-logger.service.ts | 32 +- src/common/money.dto.ts | 1 + src/config/env.validation.ts | 14 +- src/integrations/moex/moex.client.spec.ts | 15 +- src/integrations/moex/moex.client.ts | 52 +- src/integrations/moex/moex.module.ts | 1 + .../ncr/ncr-ratings.client.spec.ts | 13 + src/integrations/ncr/ncr-ratings.client.ts | 28 +- src/integrations/ncr/ncr.module.ts | 1 + src/integrations/tbank/decimal.spec.ts | 15 + src/integrations/tbank/decimal.ts | 2 +- .../tbank/instrument-metadata.repository.ts | 64 +- .../tbank/instrument-metadata.service.ts | 53 +- .../tbank/tbank-access.service.ts | 40 +- .../tbank/tbank-account.policy.ts | 4 +- src/integrations/tbank/tbank-market.types.ts | 46 +- .../tbank/tbank-membership.service.ts | 1 + .../tbank/tbank-source.policy.spec.ts | 1 + src/integrations/tbank/tbank-source.policy.ts | 1 + src/integrations/tbank/tbank-token.service.ts | 4 +- src/integrations/tbank/tbank.api.ts | 22 +- src/integrations/tbank/tbank.client.spec.ts | 34 +- src/integrations/tbank/tbank.client.ts | 133 +-- src/integrations/tbank/tbank.crypto.ts | 3 +- src/integrations/tbank/tbank.module.ts | 3 +- src/main.ts | 8 +- .../auth/application/auth.service.spec.ts | 2 +- src/modules/auth/application/auth.service.ts | 33 +- src/modules/auth/application/auth.types.ts | 10 +- src/modules/auth/auth.module.ts | 5 +- .../auth/infrastructure/jwt.strategy.ts | 4 +- .../auth/presentation/auth.controller.ts | 9 +- src/modules/auth/presentation/auth.dto.ts | 4 +- .../auth/presentation/jwt-auth.guard.ts | 4 +- .../bank-cards/application/bank-card-input.ts | 25 +- .../application/bank-cards.service.ts | 55 +- .../application/bank-operations.service.ts | 124 ++- .../application/expense-categories.service.ts | 12 +- .../application/get-bank-summary.use-case.ts | 6 +- .../banking/bank-cards/bank-cards.module.ts | 5 +- .../domain/bank-card.calculations.ts | 39 +- .../bank-cards/domain/rub-money.spec.ts | 3 +- .../banking/bank-cards/domain/rub-money.ts | 15 +- .../infrastructure/bank-cards.repository.ts | 20 +- .../bank-cards/presentation/bank-card.dto.ts | 70 +- .../presentation/bank-cards.controller.ts | 26 +- .../brokerage-account.dto.ts | 10 +- .../brokerage-accounts.controller.ts | 8 +- .../brokerage-accounts.module.ts | 3 +- .../brokerage-accounts.service.spec.ts | 2 +- .../brokerage-accounts.service.ts | 39 +- .../income/income-analytics.controller.ts | 8 +- .../income/income-analytics.dto.ts | 17 +- .../income/income-analytics.service.spec.ts | 3 +- .../income/income-analytics.service.ts | 46 +- .../investments/income/income-period.spec.ts | 4 +- .../investments/income/income-period.ts | 61 +- .../investments/income/income.module.ts | 1 + .../instruments/application/candle.service.ts | 144 +-- .../application/catalog-discovery.service.ts | 138 +-- .../application/catalog-enrichment.service.ts | 285 +++--- .../application/catalog-selection.ts | 20 +- .../application/instrument-market.ts | 15 +- .../application/instrument-position.ts | 33 +- .../bonds/application/bond-catalog.service.ts | 32 +- .../bonds/application/bond-income.service.ts | 84 +- .../bonds/application/bond-market.service.ts | 47 +- .../bonds/application/bond.spec.ts | 11 +- .../bonds/infrastructure/bond-money.mapper.ts | 8 +- .../bonds/presentation/bond.controller.ts | 20 +- .../bonds/presentation/bond.dto.ts | 130 ++- .../domain/candle-coverage.spec.ts | 2 +- .../instruments/domain/candle-coverage.ts | 20 +- .../infrastructure/candle.repository.ts | 74 +- .../catalog-discovery.repository.ts | 117 ++- .../instruments/instrument-income.api.ts | 2 +- .../instruments/instrument-sources.module.ts | 5 +- .../instruments/instruments.api.ts | 6 +- .../instruments/instruments.module.ts | 7 +- .../application/get-stock-company.use-case.ts | 37 +- .../application/stock-catalog.service.ts | 34 +- .../application/stock-income.service.ts | 63 +- .../application/stock-market.service.ts | 53 +- .../stocks/application/stock.spec.ts | 2 +- .../stocks/presentation/stock.controller.ts | 20 +- .../stocks/presentation/stock.dto.ts | 135 ++- .../investments/investment-calendar.ts | 3 +- .../investments/investment-resource.dto.ts | 27 +- .../investments/investment-resource.mapper.ts | 54 +- .../investments/investment-sync-plan.spec.ts | 4 +- .../investments/investment-sync-plan.ts | 195 ++-- .../brokerage-operations-sync.service.ts | 63 +- .../brokerage-operations.service.ts | 274 +++-- .../operations/domain/operations-sync-plan.ts | 55 +- .../brokerage-operation.mapper.ts | 32 +- .../brokerage-operations.repository.ts | 256 ++--- .../investments/operations/operations.api.ts | 6 +- .../operations/operations.module.ts | 5 +- .../presentation/brokerage-operation.dto.ts | 25 +- .../brokerage-operations.controller.ts | 12 +- .../portfolio/application/forecast.service.ts | 74 +- .../application/portfolio.service.spec.ts | 7 +- .../application/portfolio.service.ts | 174 ++-- .../domain/portfolio-structure.spec.ts | 4 +- .../portfolio/domain/portfolio-structure.ts | 26 +- .../portfolio/domain/portfolio-totals.ts | 3 +- .../infrastructure/forecast.repository.ts | 38 +- .../infrastructure/portfolio.repository.ts | 22 +- .../infrastructure/tbank-portfolio.mapper.ts | 4 +- .../investments/portfolio/portfolio.api.ts | 6 +- .../investments/portfolio/portfolio.module.ts | 11 +- .../presentation/portfolio.controller.ts | 10 +- .../portfolio/presentation/portfolio.dto.ts | 24 +- .../tbank-connection.controller.ts | 4 +- .../tbank-connection/tbank-connection.dto.ts | 2 +- .../tbank-connection.module.ts | 1 + .../tbank-connection.service.ts | 3 +- .../tbank-sync/tbank-screen.spec.ts | 3 +- .../investments/tbank-sync/tbank-screen.ts | 69 +- .../tbank-sync/tbank-sync.controller.ts | 14 +- .../investments/tbank-sync/tbank-sync.dto.ts | 27 +- .../tbank-sync/tbank-sync.module.ts | 13 +- .../tbank-sync/tbank-sync.repository.ts | 21 +- .../tbank-sync/tbank-sync.service.ts | 255 ++--- .../upcoming-events.controller.ts | 6 +- .../upcoming-events/upcoming-events.dto.ts | 20 +- .../upcoming-events/upcoming-events.module.ts | 5 +- .../upcoming-events.service.spec.ts | 2 +- .../upcoming-events.service.ts | 74 +- src/prisma/prisma.module.ts | 1 + src/prisma/prisma.service.ts | 4 +- test/all-accounts.e2e-spec.ts | 21 +- test/app.e2e-spec.ts | 5 +- test/bank-cards-durability.cjs | 276 +++-- test/bank-cards.e2e-spec.ts | 52 +- test/bonds.e2e-spec.ts | 6 +- test/container-smoke.cjs | 12 +- test/e2e-env.cjs | 2 +- test/eslint-config.cjs | 47 + test/fixtures/tbank-sync-crash.cjs | 13 +- test/openapi-contract.e2e-spec.ts | 7 +- test/portfolio-structure.e2e-spec.ts | 13 +- test/sqlite-regression.cjs | 22 +- test/sqlite.cjs | 17 +- test/stocks.e2e-spec.ts | 6 +- test/tbank-candles-persistence.e2e-spec.ts | 35 +- test/tbank-catalog-discovery.e2e-spec.ts | 14 +- test/tbank-catalog-local.e2e-spec.ts | 77 +- test/tbank-connection.e2e-spec.ts | 18 +- test/tbank-income-local.e2e-spec.ts | 15 +- test/tbank-ledger-local.e2e-spec.ts | 35 +- test/tbank-local-reads.e2e-spec.ts | 26 +- test/tbank-metadata-persistence.e2e-spec.ts | 29 +- test/tbank-migration.e2e-spec.ts | 15 +- test/tbank-operations-persistence.e2e-spec.ts | 6 +- test/tbank-operations-sync.e2e-spec.ts | 36 +- test/tbank-read-baseline.e2e-spec.ts | 87 +- test/tbank-screen-sync.e2e-spec.ts | 34 +- test/upcoming-events.e2e-spec.ts | 45 +- 179 files changed, 4485 insertions(+), 2716 deletions(-) delete mode 100644 .prettierrc create mode 100644 eslint-suppressions.json create mode 100644 test/eslint-config.cjs diff --git a/.ai/architecture.md b/.ai/architecture.md index 770f008..892117b 100644 --- a/.ai/architecture.md +++ b/.ai/architecture.md @@ -235,8 +235,8 @@ protobuf units/nano conversion — T-Bank integration. Domain используе Общий выбор symbolic values и числовых policies — [Domain values](conventions/engineering.md#domain-values). Сохраняй strict TS и explicit boundary types. Для недоверенного input выбирай -`unknown` + runtime narrowing; отключённый lint `no-explicit-any` не делает -`any` нормой domain/application model. `declare` допустим для заполняемых извне +`unknown` + runtime narrowing; ESLint запрещает explicit `any` +в domain/application model, тестах и конфигурации TypeScript. `declare` допустим для заполняемых извне DTO fields, не для сокрытия неинициализированного owned state. OpenAPI enum metadata и runtime validation должны читать тот же vocabulary, diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index a20502b..0000000 --- a/.prettierrc +++ /dev/null @@ -1,4 +0,0 @@ -{ - "singleQuote": true, - "trailingComma": "all" -} diff --git a/README.md b/README.md index c2ec7f1..4ab2683 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,34 @@ Required checks настраиваются отдельно в Gitea: налич runtime-валидацию. В обычном коде нужны инициализация или сужение типа после проверки значения. +## ESLint + +`npm run lint` проверяет весь authored TypeScript и JavaScript, включая scripts +и configs. `npm run lint:fix` (также `npm run format`) применяет безопасные +автоисправления. Форматирование принадлежит ESLint Stylistic: два пробела, +одинарные кавычки и точки с запятой; Prettier не используется. + +Профиль: ESLint recommended, typescript-eslint recommendedTypeChecked, +Node, NestJS typed, Jest, import-x с TypeScript resolver, SonarJS, RegExp, +Security, Perfectionist recommended-natural и Stylistic customize. +NestJS проверяется в production TS, Jest — в specs/e2e; generated Prisma, +dist и coverage исключены. DI imports сохраняют runtime metadata. + +Исключения конфигурации: private backend не публикуется в npm; +TS imports разрешает import-x вместо Node resolver; IsIn проверяет domain +unions. Автосортировка объектов, классов, модулей и декораторов отключена, +поскольку порядок может влиять на runtime. Остальные точечные исключения +объясняют false positives непосредственно в коде. + +`eslint-suppressions.json` — штатный ESLint baseline только для существующих +SonarJS cognitive-complexity, no-nested-conditional и no-nested-template-literals. +Это отложенный refactor, а не исправленные нарушения. Baseline ограничен +файлами и количеством сообщений; новые файлы и превышение количества блокируют +lint. Он не защищает от усложнения уже учтённой функции при прежнем количестве +сообщений — это остаётся предметом review. При сокращении долга обновляйте +baseline командой `npx eslint . --prune-suppressions`; не пересоздавайте его +для обхода новых ошибок. Security, TypeScript и Jest в baseline не включены. + ## SonarQube Проект `wealthledger-backend` на `https://sonarqube.personal.ksv741.ru` diff --git a/commitlint.config.cjs b/commitlint.config.cjs index 4aa31e2..fe88ec0 100644 --- a/commitlint.config.cjs +++ b/commitlint.config.cjs @@ -1,4 +1,4 @@ module.exports = { - extends: ["@commitlint/config-conventional"], - rules: { "type-enum": [2, "always", ["feat", "fix", "perf", "docs", "spec", "refactor", "build", "ci", "test", "style", "chore", "revert"]] }, + extends: ['@commitlint/config-conventional'], + rules: { 'type-enum': [2, 'always', ['feat', 'fix', 'perf', 'docs', 'spec', 'refactor', 'build', 'ci', 'test', 'style', 'chore', 'revert']] }, }; diff --git a/eslint-suppressions.json b/eslint-suppressions.json new file mode 100644 index 0000000..19e68f3 --- /dev/null +++ b/eslint-suppressions.json @@ -0,0 +1,206 @@ +{ + "src/common/decimal.ts": { + "sonarjs/no-nested-conditional": { + "count": 1 + } + }, + "src/common/logging/request-logging.middleware.ts": { + "sonarjs/no-nested-conditional": { + "count": 1 + } + }, + "src/integrations/tbank/instrument-metadata.repository.ts": { + "sonarjs/cognitive-complexity": { + "count": 1 + }, + "sonarjs/no-nested-conditional": { + "count": 1 + } + }, + "src/integrations/tbank/instrument-metadata.service.ts": { + "sonarjs/cognitive-complexity": { + "count": 1 + } + }, + "src/integrations/tbank/tbank.client.ts": { + "sonarjs/cognitive-complexity": { + "count": 1 + } + }, + "src/modules/banking/bank-cards/application/bank-operations.service.ts": { + "sonarjs/cognitive-complexity": { + "count": 1 + } + }, + "src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts": { + "sonarjs/cognitive-complexity": { + "count": 1 + } + }, + "src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts": { + "sonarjs/no-nested-conditional": { + "count": 1 + } + }, + "src/modules/investments/income/income-analytics.service.ts": { + "sonarjs/no-nested-template-literals": { + "count": 1 + } + }, + "src/modules/investments/income/income-period.ts": { + "sonarjs/no-nested-conditional": { + "count": 1 + } + }, + "src/modules/investments/instruments/application/candle.service.ts": { + "sonarjs/cognitive-complexity": { + "count": 4 + }, + "sonarjs/no-nested-conditional": { + "count": 4 + }, + "sonarjs/no-nested-template-literals": { + "count": 1 + } + }, + "src/modules/investments/instruments/application/catalog-discovery.service.ts": { + "sonarjs/cognitive-complexity": { + "count": 1 + }, + "sonarjs/no-nested-conditional": { + "count": 1 + } + }, + "src/modules/investments/instruments/application/catalog-enrichment.service.ts": { + "sonarjs/cognitive-complexity": { + "count": 2 + }, + "sonarjs/no-nested-conditional": { + "count": 1 + } + }, + "src/modules/investments/instruments/bonds/application/bond-income.service.ts": { + "sonarjs/no-nested-conditional": { + "count": 2 + } + }, + "src/modules/investments/instruments/bonds/application/bond.spec.ts": { + "sonarjs/no-nested-conditional": { + "count": 1 + } + }, + "src/modules/investments/instruments/infrastructure/candle.repository.ts": { + "sonarjs/cognitive-complexity": { + "count": 2 + } + }, + "src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts": { + "sonarjs/cognitive-complexity": { + "count": 1 + }, + "sonarjs/no-nested-conditional": { + "count": 2 + } + }, + "src/modules/investments/instruments/stocks/application/stock-income.service.ts": { + "sonarjs/no-nested-conditional": { + "count": 1 + } + }, + "src/modules/investments/investment-resource.mapper.ts": { + "sonarjs/no-nested-conditional": { + "count": 3 + } + }, + "src/modules/investments/investment-sync-plan.ts": { + "sonarjs/cognitive-complexity": { + "count": 1 + }, + "sonarjs/no-nested-conditional": { + "count": 4 + } + }, + "src/modules/investments/operations/application/brokerage-operations.service.ts": { + "sonarjs/cognitive-complexity": { + "count": 2 + }, + "sonarjs/no-nested-conditional": { + "count": 9 + } + }, + "src/modules/investments/operations/domain/operations-sync-plan.ts": { + "sonarjs/cognitive-complexity": { + "count": 1 + }, + "sonarjs/no-nested-conditional": { + "count": 1 + } + }, + "src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts": { + "sonarjs/no-nested-conditional": { + "count": 2 + } + }, + "src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts": { + "sonarjs/cognitive-complexity": { + "count": 3 + }, + "sonarjs/no-nested-conditional": { + "count": 1 + } + }, + "src/modules/investments/portfolio/application/forecast.service.ts": { + "sonarjs/no-nested-conditional": { + "count": 1 + } + }, + "src/modules/investments/portfolio/application/portfolio.service.ts": { + "sonarjs/cognitive-complexity": { + "count": 1 + }, + "sonarjs/no-nested-conditional": { + "count": 1 + } + }, + "src/modules/investments/tbank-sync/tbank-screen.ts": { + "sonarjs/cognitive-complexity": { + "count": 1 + } + }, + "src/modules/investments/tbank-sync/tbank-sync.service.ts": { + "sonarjs/cognitive-complexity": { + "count": 3 + }, + "sonarjs/no-nested-conditional": { + "count": 7 + } + }, + "src/modules/investments/upcoming-events/upcoming-events.service.ts": { + "sonarjs/cognitive-complexity": { + "count": 1 + } + }, + "test/bank-cards-durability.cjs": { + "sonarjs/no-nested-template-literals": { + "count": 1 + } + }, + "test/sqlite-regression.cjs": { + "sonarjs/no-nested-conditional": { + "count": 2 + } + }, + "test/sqlite.cjs": { + "sonarjs/no-nested-conditional": { + "count": 2 + } + }, + "test/tbank-ledger-local.e2e-spec.ts": { + "sonarjs/no-nested-conditional": { + "count": 1 + }, + "sonarjs/no-nested-template-literals": { + "count": 3 + } + } +} \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs index 537b840..81c0330 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,31 +1,71 @@ // @ts-check +import nest from '@darraghor/eslint-plugin-nestjs-typed'; import eslint from '@eslint/js'; -import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; +import stylistic from '@stylistic/eslint-plugin'; +import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript'; +import { importX } from 'eslint-plugin-import-x'; +import jest from 'eslint-plugin-jest'; +import node from 'eslint-plugin-n'; +import perfectionist from 'eslint-plugin-perfectionist'; +import regexp from 'eslint-plugin-regexp'; +import security from 'eslint-plugin-security'; +import sonarjs from 'eslint-plugin-sonarjs'; +import { defineConfig, globalIgnores } from 'eslint/config'; import globals from 'globals'; import tseslint from 'typescript-eslint'; -export default tseslint.config( +const tests = ['**/*.spec.ts', 'test/**/*.ts']; + +export default defineConfig( + globalIgnores(['dist/**', 'dist-dev/**', 'build/**', 'generated/**', 'coverage/**', 'node_modules/**']), { - ignores: ['eslint.config.mjs', 'dist/', 'generated/'], - }, - eslint.configs.recommended, - ...tseslint.configs.recommendedTypeChecked, - eslintPluginPrettierRecommended, - { - languageOptions: { - globals: { - ...globals.node, - ...globals.jest, - }, - sourceType: 'commonjs', - parserOptions: { - projectService: true, - tsconfigRootDir: import.meta.dirname, - }, + files: ['**/*.{ts,js,cjs,mjs}'], + extends: [ + eslint.configs.recommended, + node.configs['flat/recommended'], + importX.flatConfigs.recommended, + sonarjs.configs.recommended, + regexp.configs['flat/recommended'], + security.configs.recommended, + perfectionist.configs['recommended-natural'], + stylistic.configs.customize({ indent: 2, quotes: 'single', semi: true, jsx: false }), + ], + languageOptions: { globals: globals.node }, + linterOptions: { reportUnusedDisableDirectives: 'error' }, + settings: { + 'import-x/resolver-next': [createTypeScriptImportResolver()], + 'node': { tryExtensions: ['.ts', '.js', '.json', '.node'], version: '24.21.0' }, + }, + rules: { + 'curly': ['error', 'multi-line'], + // This private application is deployed as a whole, never published to npm. + 'n/no-unpublished-import': 'off', + 'n/no-unpublished-require': 'off', + // Decorator execution and object insertion order can be observable at runtime. + 'perfectionist/sort-decorators': 'off', + 'perfectionist/sort-objects': 'off', + 'perfectionist/sort-modules': 'off', + 'perfectionist/sort-classes': 'off', + '@stylistic/brace-style': ['error', '1tbs', { allowSingleLine: false }], + '@stylistic/arrow-parens': ['error', 'always'], + '@stylistic/operator-linebreak': ['error', 'after'], + 'import-x/no-duplicates': ['error', { 'prefer-inline': true }], }, }, { + files: ['**/*.mjs'], + extends: [node.configs['flat/recommended-module']], + }, + { + files: ['**/*.ts'], + extends: [tseslint.configs.recommendedTypeChecked, importX.flatConfigs.typescript], + languageOptions: { + sourceType: 'module', + parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname }, + }, rules: { + // TypeScript resolves source imports; import-x checks them with the TS resolver. + 'n/no-missing-import': 'off', 'no-magic-numbers': 'off', '@typescript-eslint/no-magic-numbers': [ 'error', @@ -63,24 +103,28 @@ export default tseslint.config( enforceConst: true, }, ], - '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-non-null-assertion': 'error', - 'no-restricted-syntax': [ - 'error', - { - selector: - 'PropertyDefinition[definite=true], VariableDeclarator[definite=true]', - message: - 'Definite assignment assertions are forbidden. Initialize owned values; use declare only for externally populated DTO fields.', - }, - ], - '@typescript-eslint/no-floating-promises': 'warn', - '@typescript-eslint/no-unsafe-argument': 'warn', - 'prettier/prettier': ['error', { endOfLine: 'auto' }], + 'no-restricted-syntax': ['error', { + selector: 'PropertyDefinition[definite=true], VariableDeclarator[definite=true]', + message: 'Definite assignment assertions are forbidden. Initialize owned values; use declare only for externally populated DTO fields.', + }], }, }, { - files: ['**/*.spec.ts', 'test/**/*.ts'], - rules: { '@typescript-eslint/no-magic-numbers': 'off' }, + files: ['src/**/*.ts'], + ignores: tests, + extends: [nest.configs.flatRecommended], + rules: { + // Domain string unions are validated with IsIn rather than runtime TS enums. + '@darraghor/nestjs-typed/validated-non-primitive-property-needs-type-decorator': ['error', { additionalTypeDecorators: ['IsIn'] }], + }, + }, + { + files: tests, + extends: [jest.configs['flat/recommended']], + rules: { + '@typescript-eslint/no-magic-numbers': 'off', + 'jest/expect-expect': ['error', { assertFunctionNames: ['expect', '**.expect'] }], + }, }, ); diff --git a/package-lock.json b/package-lock.json index 225e498..697eecf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "class-transformer": "0.5.1", "class-validator": "0.15.1", "cookie-parser": "1.4.7", + "dotenv": "17.4.2", "lru-cache": "11.5.2", "nestjs-pino": "5.1.0", "passport": "0.7.0", @@ -38,12 +39,13 @@ "devDependencies": { "@commitlint/cli": "21.2.2", "@commitlint/config-conventional": "21.2.2", - "@eslint/eslintrc": "3.3.7", + "@darraghor/eslint-plugin-nestjs-typed": "7.5.6", "@eslint/js": "10.0.1", "@nestjs/cli": "12.0.0", "@nestjs/schematics": "12.0.1", "@nestjs/testing": "12.0.1", "@release-it/conventional-changelog": "12.0.0", + "@stylistic/eslint-plugin": "5.10.0", "@types/bcrypt": "6.0.0", "@types/cookie-parser": "1.4.10", "@types/express": "5.0.6", @@ -52,12 +54,17 @@ "@types/passport-jwt": "4.0.1", "@types/supertest": "7.2.1", "conventional-changelog-conventionalcommits": "10.4.0", - "eslint": "10.10.0", - "eslint-config-prettier": "10.1.8", - "eslint-plugin-prettier": "5.5.6", + "eslint": "10.11.0", + "eslint-import-resolver-typescript": "4.4.5", + "eslint-plugin-import-x": "4.17.1", + "eslint-plugin-jest": "29.16.6", + "eslint-plugin-n": "18.4.0", + "eslint-plugin-perfectionist": "5.12.1", + "eslint-plugin-regexp": "3.3.1", + "eslint-plugin-security": "4.1.0", + "eslint-plugin-sonarjs": "4.2.1", "globals": "17.12.0", "jest": "30.5.1", - "prettier": "3.9.6", "release-it": "21.0.2", "source-map-support": "0.5.21", "supertest": "7.2.2", @@ -66,7 +73,7 @@ "ts-node": "10.9.2", "tsconfig-paths": "4.2.0", "typescript": "6.0.3", - "typescript-eslint": "8.70.0" + "typescript-eslint": "8.70.1" }, "engines": { "node": "v24.21.0" @@ -1276,6 +1283,30 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@darraghor/eslint-plugin-nestjs-typed": { + "version": "7.5.6", + "resolved": "https://registry.npmjs.org/@darraghor/eslint-plugin-nestjs-typed/-/eslint-plugin-nestjs-typed-7.5.6.tgz", + "integrity": "sha512-G8QbQX0Xd2gj9ZrRXE+8RCeXivEVNRt9DggvxIfHA9aBOMPrp+RHdvtxWViNqaJMezhSxD+3FFuLE3vZ6el0GQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@typescript-eslint/scope-manager": "^8.70.0", + "@typescript-eslint/type-utils": "^8.70.0", + "@typescript-eslint/utils": "^8.70.0", + "eslint-module-utils": "2.14.0", + "glob": "13.0.6", + "reflect-metadata": "0.2.2", + "ts-api-utils": "2.5.0" + }, + "engines": { + "node": ">=24.0.0" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^7.0.0 || ^8.0.0", + "class-validator": "*", + "eslint": ">=9.18.0" + } + }, "node_modules/@electric-sql/pglite": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.3.tgz", @@ -1459,43 +1490,6 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz", - "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.3.2", - "minimatch": "^3.1.5", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@eslint/js": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", @@ -4374,12 +4368,40 @@ "@sinonjs/commons": "^3.0.1" } }, + "node_modules/@sonarsource/analyzer-commons-configurations": { + "version": "2.31.0-5284", + "resolved": "https://registry.npmjs.org/@sonarsource/analyzer-commons-configurations/-/analyzer-commons-configurations-2.31.0-5284.tgz", + "integrity": "sha512-ruk3blMs9BE9RNSp9LPpWZAIjxJmq4Cp00E3cELIHz7Kym3WxdWGRlKOMYLRQ5tJg/de7jN5AtEElY3bGIfTcw==", + "dev": true, + "license": "LGPL-3.0-only" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "license": "MIT" }, + "node_modules/@stylistic/eslint-plugin": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.10.0.tgz", + "integrity": "sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/types": "^8.56.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "estraverse": "^5.3.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": "^9.0.0 || ^10.0.0" + } + }, "node_modules/@tokenizer/inflate": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", @@ -4886,17 +4908,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz", - "integrity": "sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.1.tgz", + "integrity": "sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.70.0", - "@typescript-eslint/type-utils": "8.70.0", - "@typescript-eslint/utils": "8.70.0", - "@typescript-eslint/visitor-keys": "8.70.0", + "@typescript-eslint/scope-manager": "8.70.1", + "@typescript-eslint/type-utils": "8.70.1", + "@typescript-eslint/utils": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -4909,7 +4931,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.70.0", + "@typescript-eslint/parser": "^8.70.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -4925,16 +4947,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.0.tgz", - "integrity": "sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.1.tgz", + "integrity": "sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.70.0", - "@typescript-eslint/types": "8.70.0", - "@typescript-eslint/typescript-estree": "8.70.0", - "@typescript-eslint/visitor-keys": "8.70.0", + "@typescript-eslint/scope-manager": "8.70.1", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1", "debug": "^4.4.3" }, "engines": { @@ -4950,14 +4972,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.0.tgz", - "integrity": "sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.1.tgz", + "integrity": "sha512-62xOgboPfwc3/IgPSX/W6oQR3ZbF04194FPGUGH8HL8iLFHbt/456/8Ph1wLNUgVF+s94FlHoipBsz+v7+LMnA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.70.0", - "@typescript-eslint/types": "^8.70.0", + "@typescript-eslint/tsconfig-utils": "^8.70.1", + "@typescript-eslint/types": "^8.70.1", "debug": "^4.4.3" }, "engines": { @@ -4972,14 +4994,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.0.tgz", - "integrity": "sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.1.tgz", + "integrity": "sha512-Pa0EeSeAusQc1WbjQMac+YfenewYTBu0KjgYvkUKwhXaHUKbFog23Dm/rp0DX/6tyYOQ3Xl1a+3EcFNZynGHCw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.70.0", - "@typescript-eslint/visitor-keys": "8.70.0" + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4990,9 +5012,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.0.tgz", - "integrity": "sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.1.tgz", + "integrity": "sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg==", "dev": true, "license": "MIT", "engines": { @@ -5007,15 +5029,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.0.tgz", - "integrity": "sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.1.tgz", + "integrity": "sha512-7zKTnyvaVWqzLZHPFQtX1hVHqgkMC+WebPWakNCSyrQVbIP1AM0L0TlBZtACldIRb6PptI8Odk+jyZ5kP3B1VA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.70.0", - "@typescript-eslint/typescript-estree": "8.70.0", - "@typescript-eslint/utils": "8.70.0", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1", + "@typescript-eslint/utils": "8.70.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -5032,9 +5054,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.0.tgz", - "integrity": "sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.1.tgz", + "integrity": "sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q==", "dev": true, "license": "MIT", "engines": { @@ -5046,16 +5068,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.0.tgz", - "integrity": "sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.1.tgz", + "integrity": "sha512-TU8PwyGN0PQJUcE96mw8eCQ44SmxGdQlJmlWakHaHQ15eIuuvye5yNtmh/i6oS88jzXVQB71xdNkbkB/fMwL0g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.70.0", - "@typescript-eslint/tsconfig-utils": "8.70.0", - "@typescript-eslint/types": "8.70.0", - "@typescript-eslint/visitor-keys": "8.70.0", + "@typescript-eslint/project-service": "8.70.1", + "@typescript-eslint/tsconfig-utils": "8.70.1", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -5084,9 +5106,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5113,16 +5135,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.0.tgz", - "integrity": "sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.1.tgz", + "integrity": "sha512-Esgul8MsnKnRLdYU2Eb2cRV9bS5HJYtKj1ByJnOzzG2M58DGdSUQ1jUuILxipqcpB2h9WLrbD5GijIWUjX/Tqw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.70.0", - "@typescript-eslint/types": "8.70.0", - "@typescript-eslint/typescript-estree": "8.70.0" + "@typescript-eslint/scope-manager": "8.70.1", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5137,13 +5159,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.0.tgz", - "integrity": "sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.1.tgz", + "integrity": "sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/types": "8.70.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -6248,13 +6270,6 @@ "@babel/core": "^7.11.0 || ^8.0.0-beta.1 || ^8.0.0" } }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -6400,17 +6415,6 @@ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "license": "ISC" }, - "node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/browserslist": { "version": "4.28.8", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", @@ -6504,6 +6508,19 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, + "node_modules/builtin-modules": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", + "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -6978,6 +6995,16 @@ "node": ">= 6" } }, + "node_modules/comment-parser": { + "version": "1.4.9", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.9.tgz", + "integrity": "sha512-+2AvZKjJaNq9GQljm3tr0utwrig5BL+jgJGvz5rDpGKNIp+ojcRXWUwBbh/sGIi1QCprR6PsKFakub+w1v+4hQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/component-emitter": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", @@ -6988,13 +7015,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/concat-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", @@ -7948,7 +7968,6 @@ "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" @@ -8118,9 +8137,9 @@ } }, "node_modules/eslint": { - "version": "10.10.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.10.0.tgz", - "integrity": "sha512-NPXn6r5zl4uET1DAVPaOwzX3rut4c0wcmw3dWJAfOsTM5+TogXo0DDjz8pwm/hL8cyVNpHqeK4JpN0NjnyFFNw==", + "version": "10.11.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.11.0.tgz", + "integrity": "sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==", "dev": true, "license": "MIT", "workspaces": [ @@ -8176,53 +8195,437 @@ } } }, - "node_modules/eslint-config-prettier": { - "version": "10.1.8", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", - "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", - "dev": true, - "license": "MIT", - "bin": { - "eslint-config-prettier": "bin/cli.js" - }, - "funding": { - "url": "https://opencollective.com/eslint-config-prettier" - }, - "peerDependencies": { - "eslint": ">=7.0.0" - } - }, - "node_modules/eslint-plugin-prettier": { - "version": "5.5.6", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.6.tgz", - "integrity": "sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==", + "node_modules/eslint-compat-utils": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/eslint-compat-utils/-/eslint-compat-utils-0.5.1.tgz", + "integrity": "sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==", "dev": true, "license": "MIT", "dependencies": { - "prettier-linter-helpers": "^1.0.1", - "synckit": "^0.11.13" + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "eslint": ">=6.0.0" + } + }, + "node_modules/eslint-import-context": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/eslint-import-context/-/eslint-import-context-0.1.9.tgz", + "integrity": "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-tsconfig": "^4.10.1", + "stable-hash-x": "^0.2.0" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-context" + }, + "peerDependencies": { + "unrs-resolver": "^1.0.0" + }, + "peerDependenciesMeta": { + "unrs-resolver": { + "optional": true + } + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-4.4.5.tgz", + "integrity": "sha512-nbE5XLph6TLtGYcu/U6e6ZVXyKBhbDWK5cLGk76eJ7NdZpwf1P9EFkpt1Z01mNZNrrilsAYWKH6zUkL4reoXbw==", + "dev": true, + "license": "ISC", + "dependencies": { + "debug": "^4.4.1", + "eslint-import-context": "^0.1.8", + "get-tsconfig": "^4.10.1", + "is-bun-module": "^2.0.0", + "stable-hash-x": "^0.2.0", + "tinyglobby": "^0.2.14", + "unrs-resolver": "^1.7.11" + }, + "engines": { + "node": "^16.17.0 || >=18.6.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.14.0.tgz", + "integrity": "sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-es-x": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-es-x/-/eslint-plugin-es-x-7.8.0.tgz", + "integrity": "sha512-7Ds8+wAAoV3T+LAKeu39Y5BzXCrGKrcISfgKEqTS4BDN8SFEDQd0S43jiQ8vIa3wUKD07qitZdfzlenSi8/0qQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/ota-meshi", + "https://opencollective.com/eslint" + ], + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.1.2", + "@eslint-community/regexpp": "^4.11.0", + "eslint-compat-utils": "^0.5.1" }, "engines": { "node": "^14.18.0 || >=16.0.0" }, + "peerDependencies": { + "eslint": ">=8" + } + }, + "node_modules/eslint-plugin-import-x": { + "version": "4.17.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-import-x/-/eslint-plugin-import-x-4.17.1.tgz", + "integrity": "sha512-4cdstYkKCyjumM2Q9NSI03K8D2a9F4Ssz33K2lv2hQa4KmR9jPLwk3uWGtNvclfqBrPGfGuMBwsGMbe6dMRbfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "^8.56.0", + "comment-parser": "^1.4.1", + "debug": "^4.4.1", + "eslint-import-context": "^0.1.9", + "is-glob": "^4.0.3", + "minimatch": "^9.0.3 || ^10.1.2", + "semver": "^7.7.2", + "stable-hash-x": "^0.2.0", + "unrs-resolver": "^1.9.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, "funding": { - "url": "https://opencollective.com/eslint-plugin-prettier" + "url": "https://opencollective.com/eslint-plugin-import-x" }, "peerDependencies": { - "@types/eslint": ">=8.0.0", - "eslint": ">=8.0.0", - "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", - "prettier": ">=3.0.0" + "@typescript-eslint/utils": "^8.56.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "eslint-import-resolver-node": "*" }, "peerDependenciesMeta": { - "@types/eslint": { + "@typescript-eslint/utils": { "optional": true }, - "eslint-config-prettier": { + "eslint-import-resolver-node": { "optional": true } } }, + "node_modules/eslint-plugin-import-x/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint-plugin-import-x/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/eslint-plugin-import-x/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/eslint-plugin-jest": { + "version": "29.16.6", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-29.16.6.tgz", + "integrity": "sha512-q8TVr0rlNvUD0XnafGWkwtPeX+tTl2llP86EDl3sJtwWrQA/JT9SIu2hLLZbhhX6fT5SDE4O0gIKyZUujKnkYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.0.0" + }, + "engines": { + "node": "^20.12.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^8.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "jest": "*", + "typescript": ">=4.8.4 <8.0.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + }, + "jest": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-n": { + "version": "18.4.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-18.4.0.tgz", + "integrity": "sha512-TEm6Nqn9+l7EWfJXOL6pmrEdpiM2ZTgo4fNMRDvBTM70jeQ7x3X3Akcx+sRCIRfpcSVwMypH3ZAA33PLyNKmYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.5.0", + "enhanced-resolve": "^5.17.1", + "eslint-plugin-es-x": "^7.8.0", + "get-tsconfig": "^4.8.1", + "globals": "^15.11.0", + "globrex": "^0.1.2", + "ignore": "^5.3.2", + "js-yaml": "^5.4.1", + "semver": "^7.6.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": ">=8.57.1", + "ts-declaration-location": "^1.0.6", + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "ts-declaration-location": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-n/node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-plugin-n/node_modules/js-yaml": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.2.tgz", + "integrity": "sha512-m+aqu+LwO1O6sIopafj8HUVl5aawITwZQe/yHpMCKjaWBaA/d07B/QdMb3529REftiU+RMMHL3Vlsw3hON7vWg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.mjs" + } + }, + "node_modules/eslint-plugin-perfectionist": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-perfectionist/-/eslint-plugin-perfectionist-5.12.1.tgz", + "integrity": "sha512-krdSE7Dz+TCtcVr2+rfNhlOIFFBJcUdoRvjaN7JQnsaVnxJw+BZh9wMkY3MSLCJat+yW+UPTQLKS82Epc7MGZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "^8.70.0", + "@typescript-eslint/utils": "^8.70.0", + "natural-orderby": "^5.0.0" + }, + "engines": { + "node": "^20.0.0 || >=22.0.0" + }, + "peerDependencies": { + "eslint": "^8.45.0 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-regexp": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-regexp/-/eslint-plugin-regexp-3.3.1.tgz", + "integrity": "sha512-58bGUci/AaWAo1ETj6Lsnmy6QXwnSyVONRA805828nnqp7vrTwdaQMc02vtowmYB3A5OB3SLr3r5HgmfxX4dLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.11.0", + "comment-parser": "^1.4.0", + "jsdoc-type-pratt-parser": "^9.0.0", + "refa": "^0.12.1", + "regexp-ast-analysis": "^0.7.1", + "scslre": "^0.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "peerDependencies": { + "eslint": ">=9.38.0" + } + }, + "node_modules/eslint-plugin-security": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-security/-/eslint-plugin-security-4.1.0.tgz", + "integrity": "sha512-cB4H72o/w8C0cm4uHJOnXMJz7MYTTuOwhn2X7PMuzZwfwqUoHK/ASHT6WKP+95lFk5Hkf3xfpiXB8a1+cef4rg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-regex": "^2.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-sonarjs": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-sonarjs/-/eslint-plugin-sonarjs-4.2.1.tgz", + "integrity": "sha512-WGsnVkUxmHyBOytShcIeE+vhQaS8e+gYgbHNNi4y2e5Zj+DpE2DLTvarqDUhNwjceTZHGLgevSgfBSEVkt4EaA==", + "dev": true, + "license": "LGPL-3.0-only", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@sonarsource/analyzer-commons-configurations": "^2.31.0-5284", + "builtin-modules": "^3.3.0", + "bytes": "^3.1.2", + "functional-red-black-tree": "^1.0.1", + "globals": "^17.12.0", + "jsx-ast-utils-x": "^0.1.0", + "lodash.merge": "^4.6.2", + "minimatch": "^10.2.6", + "scslre": "^0.3.0", + "semver": "^7.8.5", + "ts-api-utils": "^2.5.0", + "typescript": ">=5 <6.1.0", + "yaml": "^2.9.0" + }, + "peerDependencies": { + "eslint": "^8.0.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-sonarjs/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint-plugin-sonarjs/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/eslint-plugin-sonarjs/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/eslint-scope": { "version": "9.1.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", @@ -8603,13 +9006,6 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, - "node_modules/fast-diff": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", - "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -8932,6 +9328,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", + "dev": true, + "license": "MIT" + }, "node_modules/generate-function": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", @@ -9039,6 +9442,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-tsconfig": { + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.3.tgz", + "integrity": "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/get-uri": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-8.0.1.tgz", @@ -9199,6 +9615,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globrex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", + "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", + "dev": true, + "license": "MIT" + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -9569,6 +9992,16 @@ "dev": true, "license": "MIT" }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -10468,6 +10901,37 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdoc-type-pratt-parser": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-9.2.2.tgz", + "integrity": "sha512-eFY7Rlscvv/BpY6UsVsjKwMhu1tJO/F4U/vC9g3yeRy/JqszuO1UP6Vdq+85G+3FP6fEw/sWR8a5ftPFY86csw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.9", + "@types/node": "^26.4.0" + }, + "engines": { + "node": "^22.22.2 || >=24.15.0" + } + }, + "node_modules/jsdoc-type-pratt-parser/node_modules/@types/node": { + "version": "26.6.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.3.tgz", + "integrity": "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/jsdoc-type-pratt-parser/node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -10551,6 +11015,16 @@ "npm": ">=6" } }, + "node_modules/jsx-ast-utils-x": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/jsx-ast-utils-x/-/jsx-ast-utils-x-0.1.0.tgz", + "integrity": "sha512-eQQBjBnsVtGacsG9uJNB8qOr3yA8rga4wAaGG1qRcBzSIvfhERLrWxMAM1hp5fcS6Abo8M4+bUBTekYR0qTPQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", @@ -10960,19 +11434,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -11313,6 +11774,16 @@ "dev": true, "license": "MIT" }, + "node_modules/natural-orderby": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/natural-orderby/-/natural-orderby-5.0.0.tgz", + "integrity": "sha512-kKHJhxwpR/Okycz4HhQKKlhWe4ASEfPgkSWNmKFHd7+ezuQlxkA5cM3+XkBPvm1gmHen3w53qsYAv+8GwRrBlg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -12232,35 +12703,6 @@ "node": ">= 0.8.0" } }, - "node_modules/prettier": { - "version": "3.9.6", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", - "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/prettier-linter-helpers": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", - "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-diff": "^1.1.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/pretty-format": { "version": "30.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.1.tgz", @@ -12651,12 +13093,49 @@ "node": ">= 12.13.0" } }, + "node_modules/refa": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/refa/-/refa-0.12.1.tgz", + "integrity": "sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.8.0" + }, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", "license": "Apache-2.0" }, + "node_modules/regexp-ast-analysis": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/regexp-ast-analysis/-/regexp-ast-analysis-0.7.1.tgz", + "integrity": "sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.8.0", + "refa": "^0.12.1" + }, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/regexp-tree": { + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", + "integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==", + "dev": true, + "license": "MIT", + "bin": { + "regexp-tree": "bin/regexp-tree" + } + }, "node_modules/release-it": { "version": "21.0.2", "resolved": "https://registry.npmjs.org/release-it/-/release-it-21.0.2.tgz", @@ -12804,6 +13283,16 @@ "node": ">=4" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -12919,6 +13408,16 @@ ], "license": "MIT" }, + "node_modules/safe-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-2.1.1.tgz", + "integrity": "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "regexp-tree": "~0.1.1" + } + }, "node_modules/safe-regex2": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", @@ -12963,6 +13462,21 @@ "license": "MIT", "peer": true }, + "node_modules/scslre": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/scslre/-/scslre-0.3.0.tgz", + "integrity": "sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.8.0", + "refa": "^0.12.0", + "regexp-ast-analysis": "^0.7.0" + }, + "engines": { + "node": "^14.0.0 || >=16.0.0" + } + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -13311,6 +13825,16 @@ "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" } }, + "node_modules/stable-hash-x": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz", + "integrity": "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -13578,7 +14102,6 @@ "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" }, @@ -14127,16 +14650,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.0.tgz", - "integrity": "sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.1.tgz", + "integrity": "sha512-AcWG7KDjZ2THNXsgwttMaGmzVi0VFRlFYfqFHYQRbDpF3owuYbuiL8c7UUrd2k8s3PoSfIQrWfrGXfcElrWLYA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.70.0", - "@typescript-eslint/parser": "8.70.0", - "@typescript-eslint/typescript-estree": "8.70.0", - "@typescript-eslint/utils": "8.70.0" + "@typescript-eslint/eslint-plugin": "8.70.1", + "@typescript-eslint/parser": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1", + "@typescript-eslint/utils": "8.70.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -14832,6 +15355,22 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "17.7.3", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", diff --git a/package.json b/package.json index d849ebf..9ef6d3c 100644 --- a/package.json +++ b/package.json @@ -11,14 +11,14 @@ "scripts": { "build": "prisma generate && nest build", "openapi:generate": "npm run build && node scripts/generate-openapi.cjs", - "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "format": "npm run lint:fix", "start": "nest start", "start:dev": "NODE_ENV=development nest start --watch --path tsconfig.dev.json", "start:debug": "nest start --debug --watch --path tsconfig.dev.json", "start:prod": "node dist/src/main.js", "lint": "npm run lint:check", - "lint:check": "eslint \"{src,apps,libs,test}/**/*.ts\" --max-warnings=0", - "lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "lint:check": "eslint . --max-warnings=0", + "lint:fix": "eslint . --fix --max-warnings=0", "typecheck:app": "tsc --project tsconfig.typecheck.app.json", "typecheck:test": "tsc --project tsconfig.typecheck.test.json", "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", @@ -30,10 +30,11 @@ "test:container": "node test/container-smoke.cjs", "test:durability": "node test/bank-cards-durability.cjs", "test:openapi-contract": "npm run build && node test/sqlite.cjs openapi-contract.e2e-spec.ts", - "verify": "npm run lint && npm run typecheck:app && npm run typecheck:test && npm run test:cov -- --runInBand && npm run build", + "verify": "npm run lint && npm run test:lint && npm run typecheck:app && npm run typecheck:test && npm run test:cov -- --runInBand && npm run build", "release:preview": "release-it --dry-run --ci", "release:prepare": "release-it --ci", - "commitlint": "commitlint" + "commitlint": "commitlint", + "test:lint": "node --test test/eslint-config.cjs" }, "dependencies": { "@grpc/grpc-js": "1.14.4", @@ -53,6 +54,7 @@ "class-transformer": "0.5.1", "class-validator": "0.15.1", "cookie-parser": "1.4.7", + "dotenv": "17.4.2", "lru-cache": "11.5.2", "nestjs-pino": "5.1.0", "passport": "0.7.0", @@ -65,12 +67,13 @@ "devDependencies": { "@commitlint/cli": "21.2.2", "@commitlint/config-conventional": "21.2.2", - "@eslint/eslintrc": "3.3.7", + "@darraghor/eslint-plugin-nestjs-typed": "7.5.6", "@eslint/js": "10.0.1", "@nestjs/cli": "12.0.0", "@nestjs/schematics": "12.0.1", "@nestjs/testing": "12.0.1", "@release-it/conventional-changelog": "12.0.0", + "@stylistic/eslint-plugin": "5.10.0", "@types/bcrypt": "6.0.0", "@types/cookie-parser": "1.4.10", "@types/express": "5.0.6", @@ -79,12 +82,17 @@ "@types/passport-jwt": "4.0.1", "@types/supertest": "7.2.1", "conventional-changelog-conventionalcommits": "10.4.0", - "eslint": "10.10.0", - "eslint-config-prettier": "10.1.8", - "eslint-plugin-prettier": "5.5.6", + "eslint": "10.11.0", + "eslint-import-resolver-typescript": "4.4.5", + "eslint-plugin-import-x": "4.17.1", + "eslint-plugin-jest": "29.16.6", + "eslint-plugin-n": "18.4.0", + "eslint-plugin-perfectionist": "5.12.1", + "eslint-plugin-regexp": "3.3.1", + "eslint-plugin-security": "4.1.0", + "eslint-plugin-sonarjs": "4.2.1", "globals": "17.12.0", "jest": "30.5.1", - "prettier": "3.9.6", "release-it": "21.0.2", "source-map-support": "0.5.21", "supertest": "7.2.2", @@ -93,7 +101,7 @@ "ts-node": "10.9.2", "tsconfig-paths": "4.2.0", "typescript": "6.0.3", - "typescript-eslint": "8.70.0" + "typescript-eslint": "8.70.1" }, "overrides": { "@nestjs/platform-express": { diff --git a/scripts/check-release.cjs b/scripts/check-release.cjs index a2c957b..2a6c2b3 100644 --- a/scripts/check-release.cjs +++ b/scripts/check-release.cjs @@ -1,10 +1,12 @@ -const assert = require("node:assert/strict"); -const { execFileSync } = require("node:child_process"); -const { version } = require("../package.json"); -const git = (...args) => execFileSync("git", args, { encoding: "utf8" }).trim(); +const assert = require('node:assert/strict'); +const { execFileSync } = require('node:child_process'); -assert.equal(git("status", "--porcelain"), "", "Commit all changes before preparing a release"); -const tags = git("tag", "--merged", "HEAD", "--list", "v[0-9]*", "--sort=-version:refname") - .split("\n").filter(tag => /^v\d+\.\d+\.\d+$/.test(tag)); -assert.equal(version, tags[0]?.slice(1) ?? "0.0.0", - "Version is already prepared or release tags are missing. Fetch tags; finish the existing release before preparing another."); +const { version } = require('../package.json'); +// eslint-disable-next-line sonarjs/no-os-command-from-path -- Developer-only command uses the operator PATH and fixed executable/arguments; no request input. +const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim(); + +assert.equal(git('status', '--porcelain'), '', 'Commit all changes before preparing a release'); +const tags = git('tag', '--merged', 'HEAD', '--list', 'v[0-9]*', '--sort=-version:refname') + .split('\n').filter((tag) => /^v\d+\.\d+\.\d+$/.test(tag)); +assert.equal(version, tags[0]?.slice(1) ?? '0.0.0', + 'Version is already prepared or release tags are missing. Fetch tags; finish the existing release before preparing another.'); diff --git a/scripts/generate-openapi.cjs b/scripts/generate-openapi.cjs index 3845d46..ad8b646 100644 --- a/scripts/generate-openapi.cjs +++ b/scripts/generate-openapi.cjs @@ -15,6 +15,7 @@ Object.assign(process.env, { }); const { NestFactory } = require('@nestjs/core'); + const { AppModule } = require('../dist/src/app.module.js'); const { API_PREFIX, createOpenApiDocument } = require('../dist/src/openapi.js'); diff --git a/src/app.module.ts b/src/app.module.ts index a5e7443..02a572d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,8 +1,8 @@ -import { TbankSyncModule } from './modules/investments/tbank-sync/tbank-sync.module'; import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { LoggerModule } from 'nestjs-pino'; import { randomUUID } from 'node:crypto'; + import { LoggingModule } from './common/logging/logging.module'; import { Environment, validateEnv } from './config/env.validation'; import { AuthModule } from './modules/auth/auth.module'; @@ -13,6 +13,7 @@ import { InstrumentsModule } from './modules/investments/instruments/instruments import { OperationsModule } from './modules/investments/operations/operations.module'; import { PortfolioModule } from './modules/investments/portfolio/portfolio.module'; import { TbankConnectionModule } from './modules/investments/tbank-connection/tbank-connection.module'; +import { TbankSyncModule } from './modules/investments/tbank-sync/tbank-sync.module'; import { UpcomingEventsModule } from './modules/investments/upcoming-events/upcoming-events.module'; import { PrismaModule } from './prisma/prisma.module'; diff --git a/src/common/decimal.ts b/src/common/decimal.ts index 998e7a5..9f969b6 100644 --- a/src/common/decimal.ts +++ b/src/common/decimal.ts @@ -1,8 +1,10 @@ -export type Decimal = { value: bigint; scale: number }; +export type Decimal = { scale: number; value: bigint }; export function decimalFromString(value: string): Decimal { - if (!/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(value)) + // eslint-disable-next-line security/detect-unsafe-regex -- Anchored decimal grammar: the literal decimal point separates disjoint digit runs; backtracking is linear. + if (!/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(value)) { throw new Error('Invalid decimal string'); + } const negative = value.startsWith('-'); const [whole, fraction = ''] = (negative ? value.slice(1) : value).split('.'); return { @@ -46,8 +48,12 @@ export function formatDecimal({ value, scale }: Decimal): string { .toString() .padStart(scale + 1, '0'); const whole = digits.slice(0, -scale) || '0'; - const fraction = digits.slice(-scale).replace(/0+$/, ''); - return `${sign}${whole}${fraction ? `.${fraction}` : ''}`; + const rawFraction = digits.slice(-scale); + let fractionEnd = rawFraction.length; + while (rawFraction.charAt(fractionEnd - 1) === '0') fractionEnd -= 1; + const fraction = rawFraction.slice(0, fractionEnd); + const fractionalPart = fraction ? '.' + fraction : ''; + return `${sign}${whole}${fractionalPart}`; } // Round half away from zero; callers choose presentation precision explicitly. @@ -55,7 +61,7 @@ export function divide(left: Decimal, right: Decimal, scale: number): Decimal { if (right.value === 0n) throw new Error('Division by zero'); const numerator = left.value * 10n ** BigInt(right.scale + scale); const denominator = right.value * 10n ** BigInt(left.scale); - const sign = numerator < 0n !== denominator < 0n ? -1n : 1n; + const sign = (numerator < 0n) !== (denominator < 0n) ? -1n : 1n; const n = numerator < 0n ? -numerator : numerator; const d = denominator < 0n ? -denominator : denominator; return { value: sign * ((n + d / 2n) / d), scale }; diff --git a/src/common/decorators/api-safe-error-response.decorator.ts b/src/common/decorators/api-safe-error-response.decorator.ts index d9f8947..81300b4 100644 --- a/src/common/decorators/api-safe-error-response.decorator.ts +++ b/src/common/decorators/api-safe-error-response.decorator.ts @@ -1,7 +1,8 @@ -import { HttpStatus, applyDecorators } from '@nestjs/common'; +import { applyDecorators, HttpStatus } from '@nestjs/common'; import { ApiExtraModels, ApiProperty, + ApiPropertyOptional, ApiResponse, getSchemaPath, } from '@nestjs/swagger'; @@ -13,8 +14,8 @@ export class ProblemDetailsDto { @ApiProperty({ example: 'UNAUTHORIZED' }) declare code: string; - @ApiProperty({ - required: false, + // eslint-disable-next-line @darraghor/nestjs-typed/api-property-returning-array-should-set-array -- Raw OpenAPI items schema already declares type: array. + @ApiPropertyOptional({ type: 'array', items: { type: 'object', @@ -23,7 +24,7 @@ export class ProblemDetailsDto { }, example: [{ pointer: '#/email', detail: 'must be a valid email address' }], }) - errors?: { pointer: string; detail: string }[]; + errors?: { detail: string; pointer: string }[]; } function problemResponse(status: number, description: string) { @@ -38,7 +39,7 @@ function problemResponse(status: number, description: string) { }); } -export function ApiProblemResponses(): MethodDecorator & ClassDecorator { +export function ApiProblemResponses(): ClassDecorator & MethodDecorator { return applyDecorators( ApiExtraModels(ProblemDetailsDto), problemResponse(HttpStatus.BAD_REQUEST, 'Invalid request'), diff --git a/src/common/dto-validation.spec.ts b/src/common/dto-validation.spec.ts index 93016de..942fd3d 100644 --- a/src/common/dto-validation.spec.ts +++ b/src/common/dto-validation.spec.ts @@ -1,4 +1,5 @@ import { BadRequestException, ValidationPipe } from '@nestjs/common'; + import { CredentialsDto } from '../modules/auth/presentation/auth.dto'; import { CreateBankCardDto, @@ -6,7 +7,8 @@ import { } from '../modules/banking/bank-cards/presentation/bank-card.dto'; describe('DTO validation', () => { - const pipe = new ValidationPipe({ transform: true, whitelist: true }); + const pipe = new ValidationPipe({ + forbidUnknownValues: true, transform: true, whitelist: true }); it('requires credentials and normalizes a valid email', async () => { const metadata = { type: 'body' as const, metatype: CredentialsDto }; diff --git a/src/common/filters/http-exception.filter.spec.ts b/src/common/filters/http-exception.filter.spec.ts index 0a10828..231cddb 100644 --- a/src/common/filters/http-exception.filter.spec.ts +++ b/src/common/filters/http-exception.filter.spec.ts @@ -1,4 +1,5 @@ import { ArgumentsHost } from '@nestjs/common'; + import { HttpExceptionFilter } from './http-exception.filter'; describe('HttpExceptionFilter', () => { diff --git a/src/common/filters/http-exception.filter.ts b/src/common/filters/http-exception.filter.ts index 0703a83..1e715f7 100644 --- a/src/common/filters/http-exception.filter.ts +++ b/src/common/filters/http-exception.filter.ts @@ -1,3 +1,5 @@ +import type { Response } from 'express'; + import { ArgumentsHost, Catch, @@ -6,7 +8,6 @@ import { HttpStatus, ValidationError, } from '@nestjs/common'; -import type { Response } from 'express'; @Catch() export class HttpExceptionFilter implements ExceptionFilter { @@ -14,32 +15,33 @@ export class HttpExceptionFilter implements ExceptionFilter { const context = host.switchToHttp(); const response = context.getResponse(); const status = - exception instanceof HttpException - ? exception.getStatus() - : HttpStatus.INTERNAL_SERVER_ERROR; + exception instanceof HttpException ? + exception.getStatus() : + HttpStatus.INTERNAL_SERVER_ERROR; const payload = exception instanceof HttpException ? exception.getResponse() : undefined; const isValidation = Array.isArray( (payload as { message?: unknown })?.message, ); - const code = isValidation - ? 'VALIDATION_ERROR' - : this.codeFor(status, payload); - (response.locals ??= {}).publicErrorCode = code; + const code = isValidation ? + 'VALIDATION_ERROR' : + this.codeFor(status, payload); + response.locals ??= {}; + response.locals.publicErrorCode = code; response .status(status) .type('application/problem+json') .json({ status, code, - ...(isValidation - ? { + ...(isValidation ? + { errors: this.validationErrors( (payload as { message: ValidationError[] }).message, ), - } - : {}), + } : + {}), }); } @@ -49,6 +51,7 @@ export class HttpExceptionFilter implements ExceptionFilter { if (typeof code === 'string') return code; } return ( + // eslint-disable-next-line security/detect-object-injection -- The lookup key is a numeric HTTP status, never a property name from the request. { 400: 'BAD_REQUEST', 401: 'UNAUTHORIZED', @@ -62,7 +65,7 @@ export class HttpExceptionFilter implements ExceptionFilter { private validationErrors( errors: ValidationError[], prefix = '#', - ): { pointer: string; detail: string }[] { + ): { detail: string; pointer: string }[] { return errors.flatMap((error) => { if (!error || typeof error.property !== 'string') return []; const pointer = `${prefix}/${error.property.replaceAll('~', '~0').replaceAll('/', '~1')}`; diff --git a/src/common/logging/logging.module.ts b/src/common/logging/logging.module.ts index 096d005..510d88e 100644 --- a/src/common/logging/logging.module.ts +++ b/src/common/logging/logging.module.ts @@ -1,4 +1,5 @@ import { Global, Module } from '@nestjs/common'; + import { RequestContextService } from './request-context.service'; import { RequestLoggingMiddleware } from './request-logging.middleware'; import { StructuredLogger } from './structured-logger.service'; diff --git a/src/common/logging/request-context.service.ts b/src/common/logging/request-context.service.ts index 2b25e10..828adc9 100644 --- a/src/common/logging/request-context.service.ts +++ b/src/common/logging/request-context.service.ts @@ -2,10 +2,10 @@ import { Injectable } from '@nestjs/common'; import { AsyncLocalStorage } from 'node:async_hooks'; export type RequestLogContext = { - requestId: string; - clientRequestId?: string; - userId?: string; brokerageAccountId?: string; + clientRequestId?: string; + requestId: string; + userId?: string; }; @Injectable() diff --git a/src/common/logging/request-logging.middleware.spec.ts b/src/common/logging/request-logging.middleware.spec.ts index 2c14cfa..780ab34 100644 --- a/src/common/logging/request-logging.middleware.spec.ts +++ b/src/common/logging/request-logging.middleware.spec.ts @@ -1,4 +1,5 @@ import { EventEmitter } from 'node:events'; + import { RequestLoggingMiddleware } from './request-logging.middleware'; describe('RequestLoggingMiddleware', () => { @@ -19,11 +20,11 @@ describe('RequestLoggingMiddleware', () => { const next = jest.fn(); const request: { - method: string; baseUrl: string; - route: { path: string }; header: jest.Mock; id?: string; + method: string; + route: { path: string }; } = { method: 'GET', baseUrl: '/api/v1', diff --git a/src/common/logging/request-logging.middleware.ts b/src/common/logging/request-logging.middleware.ts index cb6952c..10fcd9b 100644 --- a/src/common/logging/request-logging.middleware.ts +++ b/src/common/logging/request-logging.middleware.ts @@ -1,7 +1,9 @@ -import { constants as httpStatus } from 'node:http2'; -import { Injectable, NestMiddleware } from '@nestjs/common'; import type { NextFunction, Request, Response } from 'express'; + +import { Injectable, NestMiddleware } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; +import { constants as httpStatus } from 'node:http2'; + import { RequestContextService } from './request-context.service'; import { StructuredLogger } from './structured-logger.service'; @@ -31,26 +33,26 @@ export class RequestLoggingMiddleware implements NestMiddleware { const routePath = (routeRequest as { route?: { path?: unknown } }).route ?.path; const route = - typeof routePath === 'string' - ? `${request.baseUrl}${routePath}` - : ''; + typeof routePath === 'string' ? + `${request.baseUrl}${routePath}` : + ''; const context = { method: request.method, route, status: response.statusCode, durationMs: Math.round(performance.now() - startedAt), - ...(response.locals.publicErrorCode - ? { code: response.locals.publicErrorCode as string } - : {}), + ...(response.locals.publicErrorCode ? + { code: response.locals.publicErrorCode as string } : + {}), }; const level = - response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR - ? 'error' - : response.statusCode === httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS || - response.statusCode === httpStatus.HTTP_STATUS_UNAUTHORIZED || - response.statusCode === httpStatus.HTTP_STATUS_FORBIDDEN - ? 'warn' - : 'info'; + response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR ? + 'error' : + response.statusCode === httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS || + response.statusCode === httpStatus.HTTP_STATUS_UNAUTHORIZED || + response.statusCode === httpStatus.HTTP_STATUS_FORBIDDEN ? + 'warn' : + 'info'; this.logger.event(level, 'http.request.completed', context); }); diff --git a/src/common/logging/structured-logger.service.ts b/src/common/logging/structured-logger.service.ts index 6ec49cf..8e2fb71 100644 --- a/src/common/logging/structured-logger.service.ts +++ b/src/common/logging/structured-logger.service.ts @@ -1,27 +1,28 @@ import { Injectable, OnApplicationShutdown } from '@nestjs/common'; import { PinoLogger } from 'nestjs-pino'; + import { RequestContextService } from './request-context.service'; -type LogLevel = 'debug' | 'info' | 'warn' | 'error'; -type LogContext = Record; +type LogLevel = 'debug' | 'error' | 'info' | 'warn'; +type LogContext = Record; const allowedFields = new Set([ - 'service', - 'environment', - 'requestId', - 'clientRequestId', - 'userId', - 'brokerageAccountId', - 'method', - 'route', - 'status', - 'durationMs', - 'code', - 'operation', - 'grpcCode', 'attempts', + 'brokerageAccountId', + 'clientRequestId', + 'code', 'count', + 'durationMs', + 'environment', 'found', + 'grpcCode', + 'method', + 'operation', + 'requestId', + 'route', + 'service', + 'status', + 'userId', ]); @Injectable() @@ -48,6 +49,7 @@ export class StructuredLogger implements OnApplicationShutdown { message: string, context: LogContext = {}, ): void { + // eslint-disable-next-line security/detect-object-injection -- The log level is restricted to the internal LogLevel union. this.pino[level]( this.safeContext({ ...this.requestContext.get(), ...context }), message, diff --git a/src/common/money.dto.ts b/src/common/money.dto.ts index 87feaad..fbd29df 100644 --- a/src/common/money.dto.ts +++ b/src/common/money.dto.ts @@ -1,4 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; + import type { Money } from './money.types'; export class MoneyDto implements Money { diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index a15f0c5..d9c929b 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -7,12 +7,12 @@ export interface Environment { DATABASE_URL: string; FRONTEND_ORIGIN: string; JWT_SECRET: string; + LOG_LEVEL: 'debug' | 'error' | 'info' | 'warn'; PORT: number; - TOKEN_ENCRYPTION_KEY: string; TBANK_CA_CERT_PATH?: string; TBANK_GRPC_ENDPOINT: string; TBANK_RATE_LIMIT_PER_SECOND: number; - LOG_LEVEL: 'debug' | 'info' | 'warn' | 'error'; + TOKEN_ENCRYPTION_KEY: string; } export function validateEnv(config: Record): Environment { @@ -71,7 +71,7 @@ export function validateEnv(config: Record): Environment { if ( typeof tokenEncryptionKey !== 'string' || - !/^[A-Za-z0-9+/]+={0,2}$/.test(tokenEncryptionKey) || + !/^[A-Z0-9+/]+={0,2}$/i.test(tokenEncryptionKey) || Buffer.from(tokenEncryptionKey, 'base64').length !== TOKEN_KEY_BYTES ) { throw new Error( @@ -92,7 +92,7 @@ export function validateEnv(config: Record): Environment { throw new Error('TBANK_CA_CERT_PATH must be a file path'); } - if (!['debug', 'info', 'warn', 'error'].includes(logLevel as string)) { + if (!['debug', 'error', 'info', 'warn'].includes(logLevel as string)) { throw new Error('LOG_LEVEL must be debug, info, warn, or error'); } @@ -102,9 +102,9 @@ export function validateEnv(config: Record): Environment { JWT_SECRET: jwtSecret, PORT: parsedPort, TOKEN_ENCRYPTION_KEY: tokenEncryptionKey, - ...(typeof tbankCaCertPath === 'string' && tbankCaCertPath - ? { TBANK_CA_CERT_PATH: tbankCaCertPath } - : {}), + ...(typeof tbankCaCertPath === 'string' && tbankCaCertPath ? + { TBANK_CA_CERT_PATH: tbankCaCertPath } : + {}), TBANK_GRPC_ENDPOINT: tbankEndpoint, TBANK_RATE_LIMIT_PER_SECOND: parsedTbankRateLimit, LOG_LEVEL: logLevel as Environment['LOG_LEVEL'], diff --git a/src/integrations/moex/moex.client.spec.ts b/src/integrations/moex/moex.client.spec.ts index 46c2e58..6da226b 100644 --- a/src/integrations/moex/moex.client.spec.ts +++ b/src/integrations/moex/moex.client.spec.ts @@ -199,8 +199,9 @@ describe('MoexClient', () => { const url = new URL(input instanceof Request ? input.url : input); if (url.searchParams.get('iss.only')?.startsWith('coupons,')) { starts.push(url.searchParams.get('start') ?? ''); - if (starts.length > 1) + if (starts.length > 1) { return Promise.reject(new Error('Repeated page')); + } } return Promise.resolve( Response.json({ @@ -228,13 +229,14 @@ describe('MoexClient', () => { const start = Number(url.searchParams.get('start')); if (url.searchParams.get('iss.only')?.startsWith('coupons,')) { starts.push(String(start)); - if (starts.length > 2) + if (starts.length > 2) { return Promise.reject(new Error('Repeated page')); + } } return Promise.resolve( Response.json({ ...emptyEvents, - coupons: { + 'coupons': { columns: ['coupondate', 'value', 'faceunit'], data: [[`2099-02-0${start + 1}`, '2.000000001', 'SUR']], }, @@ -246,11 +248,13 @@ describe('MoexClient', () => { ); }); const result = new MoexClient().bondEvents('BOND'); - if (repeatIndex) + if (repeatIndex) { + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. await expect(result).rejects.toMatchObject({ response: { code: 'MOEX_UNAVAILABLE' }, }); - else + } else { + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. await expect(result).resolves.toEqual([ { type: 'coupon', @@ -265,6 +269,7 @@ describe('MoexClient', () => { currency: 'RUB', }, ]); + } expect(starts).toEqual(['0', '1']); }, ); diff --git a/src/integrations/moex/moex.client.ts b/src/integrations/moex/moex.client.ts index d00fbbf..eb6fc31 100644 --- a/src/integrations/moex/moex.client.ts +++ b/src/integrations/moex/moex.client.ts @@ -1,5 +1,5 @@ -import { constants as httpStatus } from 'node:http2'; import { Injectable, ServiceUnavailableException } from '@nestjs/common'; +import { constants as httpStatus } from 'node:http2'; const FALLBACK_PAGE_SIZE = 100; @@ -7,18 +7,18 @@ const baseUrl = 'https://iss.moex.com/iss'; const timeoutMs = 10_000; export type MoexBondEvent = - | { type: 'coupon'; date: string; value: string; currency: string } - | { type: 'maturity'; date: string; value: string; currency: string } - | { type: 'offer'; date: string }; + | { currency: string; date: string; type: 'coupon'; value: string } | + { currency: string; date: string; type: 'maturity'; value: string } | + { date: string; type: 'offer' }; type IssBlock = { columns?: unknown; data?: unknown }; type IssResponse = Record; export type MoexBondYield = { - value: string; - pricePercent: string; date: string; - dateType: string | null; + dateType: null | string; + pricePercent: string; tradeAt: string; + value: string; }; @Injectable() @@ -58,8 +58,9 @@ export class MoexClient { !Number.isFinite(Date.parse(date)) || !tradeAt || !/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(tradeAt) - ) + ) { return null; + } const tradeIso = `${tradeAt.replace(' ', 'T')}+03:00`; if (!Number.isFinite(Date.parse(tradeIso))) return null; return { @@ -70,6 +71,7 @@ export class MoexClient { tradeAt: new Date(tradeIso).toISOString(), }; } + async bondEvents(secid: string): Promise { const path = `securities/${encodeURIComponent(secid)}/bondization.json`; const [coupons, offers, description] = await Promise.all([ @@ -86,8 +88,9 @@ export class MoexClient { const date = text(row.coupondate); const value = numberText(row.value); const currency = currencyText(row.faceunit); - if (date && value && currency) + if (date && value && currency) { events.push({ type: 'coupon', date, value, currency }); + } } for (const row of offers) { const date = text(row.offerdate); @@ -99,8 +102,9 @@ export class MoexClient { const date = values.get('MATDATE'); const value = numberText(values.get('FACEVALUE')); const currency = currencyText(values.get('FACEUNIT')); - if (date && value && currency) + if (date && value && currency) { events.push({ type: 'maturity', date, value, currency }); + } return events; } @@ -115,10 +119,11 @@ export class MoexClient { const query = new URLSearchParams({ 'iss.meta': 'off', 'iss.only': `${block},${block}.cursor`, - start: String(start), + 'start': String(start), }); const response = await this.request(`${baseUrl}/${path}?${query}`); if (!response) return rows; + // eslint-disable-next-line security/detect-object-injection -- Keys are fixed MOEX block names; row indexes come from the validated columns array. const current = parseRows(response[block]); rows.push(...current); if (!paginated) return rows; @@ -140,8 +145,9 @@ export class MoexClient { pageSize === null || pageSize === 0 || current.length !== Math.min(pageSize, total - start) - ) + ) { throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); + } if (index + pageSize >= total) return rows; start = index + pageSize; } @@ -155,8 +161,9 @@ export class MoexClient { if (response.status === httpStatus.HTTP_STATUS_NOT_FOUND) return null; if (!response.ok) throw new Error(`MOEX HTTP ${response.status}`); const body: unknown = await response.json(); - if (!body || typeof body !== 'object') + if (!body || typeof body !== 'object') { throw new Error('MOEX malformed response'); + } return body as IssResponse; } catch { throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); @@ -165,35 +172,40 @@ export class MoexClient { } function parseRows(block: IssBlock | undefined): Record[] { - if (!Array.isArray(block?.columns) || !Array.isArray(block?.data)) + if (!Array.isArray(block?.columns) || !Array.isArray(block?.data)) { throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); + } const columns = block.columns; - if (!columns.every((column) => typeof column === 'string')) + if (!columns.every((column) => typeof column === 'string')) { throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); + } return block.data.map((values) => { - if (!Array.isArray(values) || values.length !== columns.length) + if (!Array.isArray(values) || values.length !== columns.length) { throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); + } return Object.fromEntries( + // eslint-disable-next-line security/detect-object-injection -- Keys are fixed MOEX block names; row indexes come from the validated columns array. columns.map((column, index) => [column.toLowerCase(), values[index]]), ); }); } -function text(value: unknown): string | null { +function text(value: unknown): null | string { return typeof value === 'string' && value.trim() ? value.trim() : null; } -function numberText(value: unknown): string | null { +function numberText(value: unknown): null | string { const result = typeof value === 'number' ? String(value) : text(value); + // eslint-disable-next-line security/detect-unsafe-regex -- Anchored decimal grammar: the literal decimal point separates disjoint digit runs; backtracking is linear. return result && /^-?\d+(?:\.\d+)?$/.test(result) ? result : null; } -function currencyText(value: unknown): string | null { +function currencyText(value: unknown): null | string { const result = text(value)?.toUpperCase(); return result === 'SUR' ? 'RUB' : (result ?? null); } -function integer(value: unknown): number | null { +function integer(value: unknown): null | number { const result = Number(numberText(value) ?? NaN); return Number.isSafeInteger(result) && result >= 0 ? result : null; } diff --git a/src/integrations/moex/moex.module.ts b/src/integrations/moex/moex.module.ts index 9b20ab1..74e6327 100644 --- a/src/integrations/moex/moex.module.ts +++ b/src/integrations/moex/moex.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; + import { MoexClient } from './moex.client'; @Module({ diff --git a/src/integrations/ncr/ncr-ratings.client.spec.ts b/src/integrations/ncr/ncr-ratings.client.spec.ts index 2ed3cbf..870da53 100644 --- a/src/integrations/ncr/ncr-ratings.client.spec.ts +++ b/src/integrations/ncr/ncr-ratings.client.spec.ts @@ -1,4 +1,5 @@ import { ServiceUnavailableException } from '@nestjs/common'; + import { NcrRatingsClient } from './ncr-ratings.client'; const isin = 'RU000A10EW44'; const row = (value = 'AAA.ru', code = isin, href = '/ratings/issues/123/') => @@ -65,6 +66,18 @@ describe('public NCR issue ratings', () => { ); }, ); + it.each(['AA+.ru', 'B-.ru', 'BBB+.ru', 'CCC-.ru', 'CC.ru', 'D.ru'])( + 'accepts the public rating grammar: %s', async (value) => { + jest.spyOn(global, 'fetch').mockResolvedValue(response(table(row(value)))); + expect(await new NcrRatingsClient().rating(isin)).toMatchObject({ value }); + }, + ); + it.each(['AAA+.ru', 'CC-.ru', 'BBBB.ru', 'BBB++.ru'])( + 'rejects invalid rating grades: %s', async (value) => { + jest.spyOn(global, 'fetch').mockResolvedValue(response(table(row(value)))); + await expect(new NcrRatingsClient().rating(isin)).rejects.toBeInstanceOf(ServiceUnavailableException); + }, + ); it('bounds outage retries then recovers without retaining a false missing rating', async () => { const fetchMock = jest .spyOn(global, 'fetch') diff --git a/src/integrations/ncr/ncr-ratings.client.ts b/src/integrations/ncr/ncr-ratings.client.ts index ff3e47d..16eea7a 100644 --- a/src/integrations/ncr/ncr-ratings.client.ts +++ b/src/integrations/ncr/ncr-ratings.client.ts @@ -11,11 +11,11 @@ const DATE_COLUMN = 5; const registryUrl = 'https://ratings.ru/ratings/issues/'; export type NcrRating = { agency: 'NCR'; - value: string | null; - status: 'active' | 'withdrawn'; date: string; - sourceUrl: string; observedAt: string; + sourceUrl: string; + status: 'active' | 'withdrawn'; + value: null | string; }; @Injectable() @@ -33,8 +33,9 @@ export class NcrRatingsClient { if (!pending) { pending = this.registry().catch(() => { // Bound outage traffic too; never present a failed download as no rating. - if (this.cache.get('registry') === pending) + if (this.cache.get('registry') === pending) { this.cache.set('registry', pending, { ttl: 60_000 }); + } throw new ServiceUnavailableException({ code: 'NCR_UNAVAILABLE' }); }); this.cache.set('registry', pending); @@ -57,10 +58,13 @@ export class NcrRatingsClient { table.length !== 1 || headings.length !== COLUMN_COUNT || headings[2] !== 'Рейтинг' || + // eslint-disable-next-line security/detect-object-injection -- Column indexes are local numeric constants. headings[ISIN_COLUMN] !== 'ISIN' || + // eslint-disable-next-line security/detect-object-injection -- Column indexes are local numeric constants. headings[DATE_COLUMN] !== 'Дата' - ) + ) { throw new Error('NCR registry changed'); + } const observedAt = new Date().toISOString(); const result = new Map(); table.find('tbody tr').each((_, row) => { @@ -73,23 +77,26 @@ export class NcrRatingsClient { const withdrawn = raw.toLowerCase() === 'рейтинг отозван'; if ( !withdrawn && - !/^(AAA|AA[+-]?|A[+-]?|BBB[+-]?|BB[+-]?|B[+-]?|CCC[+-]?|CC|C|D)\.ru$/.test( + !/^(?:AAA|A{1,2}[+-]?|B{1,3}[+-]?|CCC[+-]?|C{1,2}|D)\.ru$/.test( raw, ) - ) + ) { throw new Error('NCR rating changed'); + } const dateParts = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec( cells.eq(DATE_COLUMN).text().trim(), ); const href = cells.eq(ISIN_COLUMN).find('a').attr('href'); - if (!dateParts || !href || !/^\/ratings\/issues\/\d+\/$/.test(href)) + if (!dateParts || !href || !/^\/ratings\/issues\/\d+\/$/.test(href)) { throw new Error('NCR identity missing'); + } const date = `${dateParts[3]}-${dateParts[2]}-${dateParts[1]}`; if ( !Number.isFinite(Date.parse(date)) || new Date(date).toISOString().slice(0, 10) !== date - ) + ) { throw new Error('NCR date invalid'); + } const rating: NcrRating = { agency: 'NCR', value: withdrawn ? null : raw, @@ -103,8 +110,9 @@ export class NcrRatingsClient { previous && previous.date === date && (previous.value !== rating.value || previous.status !== rating.status) - ) + ) { throw new Error('NCR rating ambiguous'); + } if (!previous || previous.date < date) result.set(isin, rating); }); if (!result.size) throw new Error('NCR registry empty'); diff --git a/src/integrations/ncr/ncr.module.ts b/src/integrations/ncr/ncr.module.ts index ae77a97..ee8bd4a 100644 --- a/src/integrations/ncr/ncr.module.ts +++ b/src/integrations/ncr/ncr.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; + import { NcrRatingsClient } from './ncr-ratings.client'; @Module({ diff --git a/src/integrations/tbank/decimal.spec.ts b/src/integrations/tbank/decimal.spec.ts index 3cb5ea6..af463e1 100644 --- a/src/integrations/tbank/decimal.spec.ts +++ b/src/integrations/tbank/decimal.spec.ts @@ -21,3 +21,18 @@ describe('decimal', () => { ).toBe('1'); }); }); + +describe('decimal formatting', () => { + it.each([ + [120000n, 4, '12'], + [-120030n, 4, '-12.003'], + [1n, 4, '0.0001'], + [0n, 4, '0'], + ])('preserves value %s at scale %s while trimming only trailing zeros', (value, scale, expected) => { + expect(formatDecimal({ value, scale })).toBe(expected); + }); + it('preserves a long fraction with a trailing zero run', () => { + const significant = '1'.repeat(10_000); + expect(formatDecimal({ value: BigInt(significant + '0'.repeat(10_000)), scale: 20_000 })).toBe('0.' + significant); + }); +}); diff --git a/src/integrations/tbank/decimal.ts b/src/integrations/tbank/decimal.ts index 06ec46e..f91faa0 100644 --- a/src/integrations/tbank/decimal.ts +++ b/src/integrations/tbank/decimal.ts @@ -2,7 +2,7 @@ import { Decimal } from '../../common/decimal'; const NANO = 1_000_000_000n; -export type ProtoDecimal = { units?: string | number; nano?: string | number }; +export type ProtoDecimal = { nano?: number | string; units?: number | string }; export function decimal(value: ProtoDecimal): Decimal { return { diff --git a/src/integrations/tbank/instrument-metadata.repository.ts b/src/integrations/tbank/instrument-metadata.repository.ts index 9e66b73..ef67ad6 100644 --- a/src/integrations/tbank/instrument-metadata.repository.ts +++ b/src/integrations/tbank/instrument-metadata.repository.ts @@ -1,24 +1,26 @@ import { Injectable } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; import { isDeepStrictEqual } from 'node:util'; + import type { - Prisma, TbankInstrumentLookup as MetadataLookupRow, TbankInstrument as MetadataRow, + Prisma, } from '../../../generated/prisma/client'; -import { PrismaService } from '../../prisma/prisma.service'; import type { TbankSyncAttempt } from './tbank-access.service'; import type { TbankInstrument } from './tbank.client'; + +import { PrismaService } from '../../prisma/prisma.service'; import { tbankSourceTtlMs } from './tbank-source.policy'; export type MetadataIdentity = { - instrumentUid?: string | null; - figi?: string | null; + figi?: null | string; + instrumentUid?: null | string; }; export type MetadataLookup = { - type: 'uid' | 'figi'; + data: null | TbankInstrument; id: string; - data: TbankInstrument | null; + type: 'figi' | 'uid'; }; const provider = 'tbank'; // Keep IN lists below the SQLite/Prisma parameter limit, including fixed predicates. @@ -36,15 +38,16 @@ export class InstrumentMetadataRepository { async readMany(identities: MetadataIdentity[]) { const identifiers = identities.flatMap((identity) => [ - ...(identity.instrumentUid?.trim() - ? [{ identifierType: 'uid', identifier: identity.instrumentUid.trim() }] - : []), - ...(identity.figi?.trim() - ? [{ identifierType: 'figi', identifier: identity.figi.trim() }] - : []), + ...(identity.instrumentUid?.trim() ? + [{ identifierType: 'uid', identifier: identity.instrumentUid.trim() }] : + []), + ...(identity.figi?.trim() ? + [{ identifierType: 'figi', identifier: identity.figi.trim() }] : + []), ]); - if (!identifiers.length) + if (!identifiers.length) { return new Map>(); + } return this.prisma.$transaction(async (tx) => { const lookups: MetadataLookupRow[] = []; for (const identifierType of ['uid', 'figi']) { @@ -59,7 +62,7 @@ export class InstrumentMetadataRepository { let offset = 0; offset < values.length; offset += metadataReadBatchSize - ) + ) { lookups.push( ...(await tx.tbankInstrumentLookup.findMany({ where: { @@ -71,6 +74,7 @@ export class InstrumentMetadataRepository { }, })), ); + } } const uids = [ ...new Set(lookups.flatMap((row) => row.matchedUids as string[])), @@ -80,7 +84,7 @@ export class InstrumentMetadataRepository { let offset = 0; offset < uids.length; offset += metadataReadBatchSize - ) + ) { instruments.push( ...(await tx.tbankInstrument.findMany({ where: { @@ -89,6 +93,7 @@ export class InstrumentMetadataRepository { }, })), ); + } const byIdentifier = new Map( lookups.map((row) => [`${row.identifierType}:${row.identifier}`, row]), ); @@ -103,7 +108,7 @@ export class InstrumentMetadataRepository { } async reserve( - attemptOwner: { userId: string; revision: string }, + attemptOwner: { revision: string; userId: string }, identity: MetadataIdentity, runId: string, active: ReadonlySet, @@ -125,8 +130,9 @@ export class InstrumentMetadataRepository { previous?.state === 'running' && previous.processRunId === runId && active.has(previous.attemptId) - ) + ) { return { work: previous, launch: false }; + } const data = { attemptId: randomUUID(), processRunId: runId, @@ -183,12 +189,13 @@ export class InstrumentMetadataRepository { ['uid', uid], ['figi', figi], ] as const) { - if (id && uid && !aliases.has(`${type}:${id}`)) + if (id && uid && !aliases.has(`${type}:${id}`)) { aliases.set(`${type}:${id}`, { type, id, data: { ...result.data, uid }, }); + } } } for (const result of aliases.values()) { @@ -196,8 +203,9 @@ export class InstrumentMetadataRepository { const uid = instrument?.uid?.trim() || (result.type === 'uid' ? result.id : null); // FIGI is an alias, never a fabricated provider UID. - if (instrument && !uid) + if (instrument && !uid) { throw new Error('Metadata response has no instrument UID'); + } if (instrument && uid) { const key = { provider, uid }; const previous = await tx.tbankInstrument.findUnique({ @@ -220,9 +228,9 @@ export class InstrumentMetadataRepository { update: { ...data, dataRevision: { - increment: isDeepStrictEqual(previous?.metadata, payload) - ? 0 - : 1, + increment: isDeepStrictEqual(previous?.metadata, payload) ? + 0 : + 1, }, }, }); @@ -243,9 +251,9 @@ export class InstrumentMetadataRepository { matchedUids, fetchedAt: now, dataRevision: { - increment: isDeepStrictEqual(previous?.matchedUids, matchedUids) - ? 0 - : 1, + increment: isDeepStrictEqual(previous?.matchedUids, matchedUids) ? + 0 : + 1, }, }, }); @@ -266,7 +274,7 @@ export class InstrumentMetadataRepository { errorCode: success ? null : 'TBANK_UNAVAILABLE', }, }); - if (success) + if (success) { await this.prisma.tbankSyncWork.updateMany({ where: { ...attempt, @@ -275,6 +283,7 @@ export class InstrumentMetadataRepository { }, data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' }, }); + } } } @@ -283,7 +292,7 @@ function selectMetadata( lookups: ReadonlyMap, instruments: ReadonlyMap, ) { - const resolve = (type: string, id: string | null | undefined) => { + const resolve = (type: string, id: null | string | undefined) => { const lookup = lookups.get(`${type}:${id?.trim()}`); const uid = (lookup?.matchedUids as string[] | undefined)?.[0]; const instrument = uid ? instruments.get(uid) : undefined; @@ -307,6 +316,7 @@ function selectMetadata( complete, fresh: complete && + // eslint-disable-next-line sonarjs/different-types-comparison -- The conditional aggregation is nullable at runtime; keep the freshness guard. fetchedAt !== null && Date.now() - fetchedAt.valueOf() < tbankSourceTtlMs.metadata, fetchedAt, diff --git a/src/integrations/tbank/instrument-metadata.service.ts b/src/integrations/tbank/instrument-metadata.service.ts index 42fdf9c..ee3c4a9 100644 --- a/src/integrations/tbank/instrument-metadata.service.ts +++ b/src/integrations/tbank/instrument-metadata.service.ts @@ -1,23 +1,24 @@ import { Injectable } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; + import { StructuredLogger } from '../../common/logging/structured-logger.service'; +import { + InstrumentMetadataRepository, + type MetadataIdentity, + metadataKey, + type MetadataLookup, +} from './instrument-metadata.repository'; import { TbankAccessService, type TbankSyncAttempt, } from './tbank-access.service'; import { TbankClient, type TbankInstrument } from './tbank.client'; -import { - InstrumentMetadataRepository, - metadataKey, - type MetadataIdentity, - type MetadataLookup, -} from './instrument-metadata.repository'; export { metadataKey } from './instrument-metadata.repository'; export type { MetadataIdentity } from './instrument-metadata.repository'; type Producer = { - identity: MetadataIdentity; attempt: TbankSyncAttempt; + identity: MetadataIdentity; observers: TbankSyncAttempt[]; }; @Injectable() @@ -49,8 +50,9 @@ export class InstrumentMetadataService { const connection = await this.access.local(userId); if (revision && connection.revision !== revision) return null; const data = (await this.savedMany([identity])).get(metadataKey(identity)); - if (!observe && !data?.fresh) + if (!observe && !data?.fresh) { await this.request(userId, connection.revision, identity); + } return { ...data, work: await this.repository.work(userId, connection.revision, identity), @@ -119,7 +121,7 @@ export class InstrumentMetadataService { let useful = false; let uidFailed = false; if (identity.instrumentUid) { - let data: TbankInstrument | null | undefined; + let data: null | TbankInstrument | undefined; try { data = await this.tbank.getInstrumentByUid( token, @@ -130,29 +132,37 @@ export class InstrumentMetadataService { !identity.figi || (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') - ) + ) { throw error; + } uidFailed = true; } - if (data?.uid && data.uid !== identity.instrumentUid) + if (data?.uid && data.uid !== identity.instrumentUid) { throw new Error('Metadata UID mismatch'); - if (data !== undefined) + } + if (data !== undefined) { results.push({ type: 'uid', id: identity.instrumentUid, data }); + } useful = Boolean(data?.name?.trim() || data?.ticker?.trim()); - if (!useful && data && !identity.figi) + if (!useful && data && !identity.figi) { throw new Error('Incomplete metadata'); + } } if (!useful && identity.figi) { const data = await this.tbank.getInstrumentByFigi( token, identity.figi, ); - if (data?.figi && data.figi !== identity.figi) + if (data?.figi && data.figi !== identity.figi) { throw new Error('Metadata FIGI mismatch'); - if (data && !data.name?.trim() && !data.ticker?.trim()) + } + if (data && !data.name?.trim() && !data.ticker?.trim()) { throw new Error('Incomplete metadata'); - if (data === undefined || (uidFailed && !data)) + } + // eslint-disable-next-line sonarjs/different-types-comparison -- Defensive validation at an untrusted upstream boundary includes missing payloads. + if (data === undefined || (uidFailed && !data)) { throw new Error('Metadata source unavailable'); + } results.push({ type: 'figi', id: identity.figi, data }); // A successful FIGI response identifies its canonical UID too; // a failed UID RPC is never persisted as a negative lookup. @@ -160,8 +170,9 @@ export class InstrumentMetadataService { uidFailed && data?.uid === identity.instrumentUid && identity.instrumentUid - ) + ) { results.push({ type: 'uid', id: identity.instrumentUid, data }); + } } if ( !results.length || @@ -170,8 +181,9 @@ export class InstrumentMetadataService { (result) => result.data?.name?.trim() || result.data?.ticker?.trim(), )) - ) + ) { throw new Error('Incomplete metadata'); + } success = await this.repository.publish( attempt, results, @@ -179,12 +191,13 @@ export class InstrumentMetadataService { ); } } catch (error) { - if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') + if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') { await this.access.rejectMembershipToken(attempt); - else + } else { this.logger.event('warn', 'instrument-metadata.failed', { code: 'TBANK_UNAVAILABLE', }); + } } // Remove the producer before awaiting finalization: a new request cannot // attach to a producer whose observer list has already been drained. diff --git a/src/integrations/tbank/tbank-access.service.ts b/src/integrations/tbank/tbank-access.service.ts index 48be690..613fa1e 100644 --- a/src/integrations/tbank/tbank-access.service.ts +++ b/src/integrations/tbank/tbank-access.service.ts @@ -1,4 +1,3 @@ -import { tbankSourceNeedsRefresh } from './tbank-source.policy'; import { BadRequestException, ConflictException, @@ -6,16 +5,19 @@ import { NotFoundException, } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; -import { PrismaService } from '../../prisma/prisma.service'; -import { supportedTbankAccounts } from './tbank-account.policy'; -import { TbankTokenService } from './tbank-token.service'; + import type { TbankAccount } from './tbank.client'; +import { PrismaService } from '../../prisma/prisma.service'; +import { supportedTbankAccounts } from './tbank-account.policy'; +import { tbankSourceNeedsRefresh } from './tbank-source.policy'; +import { TbankTokenService } from './tbank-token.service'; + export type TbankSyncAttempt = { - userId: string; - revision: string; - id: string; attemptId: string; + id: string; + revision: string; + userId: string; }; @Injectable() @@ -48,8 +50,9 @@ export class TbankAccessService { connection.membershipAt, new Date(), ) - ) + ) { return null; + } const scope = { userId, revision, scopeKey: 'membership' }; const previous = await tx.tbankSyncWork.findUnique({ where: { userId_revision_scopeKey: scope }, @@ -58,8 +61,9 @@ export class TbankAccessService { previous?.state === 'running' && previous.processRunId === processRunId && active.has(previous.attemptId) - ) + ) { return { work: previous, launch: false }; + } const data = { attemptId: randomUUID(), processRunId, @@ -100,7 +104,7 @@ export class TbankAccessService { return result.count === 1; } - async tokenForAttempt(attempt: TbankSyncAttempt): Promise { + async tokenForAttempt(attempt: TbankSyncAttempt): Promise { const work = await this.prisma.tbankSyncWork.findFirst({ where: { ...attempt, @@ -119,8 +123,9 @@ export class TbankAccessService { completeWork = false, ): Promise { const supported = supportedTbankAccounts(accounts); - if (new Set(supported.map(({ id }) => id)).size !== supported.length) + if (new Set(supported.map(({ id }) => id)).size !== supported.length) { throw new Error('Duplicate T-Bank account identity'); + } return this.prisma.$transaction(async (tx) => { // Acquire the write fence before reading membership. Replacement and // attempt supersession cannot interleave with the publication below. @@ -146,6 +151,7 @@ export class TbankAccessService { const changed = previous.length !== next.length || previous.some((account, index) => { + // eslint-disable-next-line security/detect-object-injection -- The index comes from the paired account array iteration. const incoming = next[index]; return ( account.accountId !== incoming.accountId || @@ -181,11 +187,12 @@ export class TbankAccessService { membershipRevision: { increment: changed ? 1 : 0 }, }, }); - if (completeWork) + if (completeWork) { await tx.tbankSyncWork.update({ where: { id: attempt.id }, data: { state: 'complete', progress: { accounts: supported.length } }, }); + } return true; }); } @@ -224,17 +231,20 @@ export class TbankAccessService { accounts: { orderBy: { upstreamOrder: 'asc' } }, }, }); - if (!connection) + if (!connection) { throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' }); - if (connection.invalidToken) + } + if (connection.invalidToken) { throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }); + } if ( connection.membershipAt && accountId && accountId !== 'all' && !connection.accounts.some((account) => account.accountId === accountId) - ) + ) { throw new NotFoundException(); + } return connection; } } diff --git a/src/integrations/tbank/tbank-account.policy.ts b/src/integrations/tbank/tbank-account.policy.ts index 0589da5..8ce7264 100644 --- a/src/integrations/tbank/tbank-account.policy.ts +++ b/src/integrations/tbank/tbank-account.policy.ts @@ -18,8 +18,8 @@ type AccountType = keyof typeof accountTypes; export type SupportedTbankAccount = { id: string; name: string; + openedOn: null | string; type: (typeof accountTypes)[AccountType]['type']; - openedOn: string | null; }; export function supportedTbankAccounts( @@ -39,7 +39,7 @@ export function supportedTbankAccounts( }); } -function openedOn(seconds: string | number | undefined): string | null { +function openedOn(seconds: number | string | undefined): null | string { if (seconds === undefined) return null; const date = new Date(Number(seconds) * MILLISECONDS_PER_SECOND); if (Number.isNaN(date.valueOf())) return null; diff --git a/src/integrations/tbank/tbank-market.types.ts b/src/integrations/tbank/tbank-market.types.ts index 825953a..e931ccb 100644 --- a/src/integrations/tbank/tbank-market.types.ts +++ b/src/integrations/tbank/tbank-market.types.ts @@ -6,23 +6,23 @@ import type { } from './tbank.client'; export type TbankShare = TbankInstrument & { - uid?: string; - figi?: string; + apiTradeAvailableFlag?: boolean; assetUid?: string; classCode?: string; - realExchange?: string; - apiTradeAvailableFlag?: boolean; currency?: string; - shareType?: string; - instrumentKind?: string; - sector?: string; - lot?: number; + figi?: string; first_1dayCandleDate?: TbankTimestamp; + instrumentKind?: string; + lot?: number; + realExchange?: string; + sector?: string; + shareType?: string; + uid?: string; }; export type TbankCandle = { - time?: TbankTimestamp; close?: TbankDecimal; isComplete?: boolean; + time?: TbankTimestamp; }; export type TbankLastPrice = { instrumentUid?: string; @@ -32,38 +32,38 @@ export type TbankLastPrice = { export type TbankFundamentals = { assetUid?: string; currency?: string; + ebitdaTtm?: number; marketCapitalization?: number; + netDebtToEbitda?: number; + netIncomeTtm?: number; peRatioTtm?: number; priceToBookTtm?: number; revenueTtm?: number; - netIncomeTtm?: number; - ebitdaTtm?: number; roe?: number; totalDebtMrq?: number; - netDebtToEbitda?: number; }; export type TbankBond = Omit & { - nominal?: TbankMoney; - initialNominal?: TbankMoney; aciValue?: TbankMoney; - maturityDate?: TbankTimestamp; + amortizationFlag?: boolean; + bondType?: string; callDate?: TbankTimestamp; couponQuantityPerYear?: number; floatingCouponFlag?: boolean; + initialNominal?: TbankMoney; + maturityDate?: TbankTimestamp; + nominal?: TbankMoney; perpetualFlag?: boolean; - amortizationFlag?: boolean; - subordinatedFlag?: boolean; - bondType?: string; riskLevel?: string; + subordinatedFlag?: boolean; }; export type TbankCoupon = { - couponNumber?: string; couponDate?: TbankTimestamp; + couponEndDate?: TbankTimestamp; + couponNumber?: string; + couponPeriod?: number; + couponStartDate?: TbankTimestamp; + couponType?: string; fixDate?: TbankTimestamp; payOneBond?: TbankMoney; - couponType?: string; - couponStartDate?: TbankTimestamp; - couponEndDate?: TbankTimestamp; - couponPeriod?: number; }; diff --git a/src/integrations/tbank/tbank-membership.service.ts b/src/integrations/tbank/tbank-membership.service.ts index 0522c2b..784c3cc 100644 --- a/src/integrations/tbank/tbank-membership.service.ts +++ b/src/integrations/tbank/tbank-membership.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; + import { StructuredLogger } from '../../common/logging/structured-logger.service'; import { TbankAccessService, diff --git a/src/integrations/tbank/tbank-source.policy.spec.ts b/src/integrations/tbank/tbank-source.policy.spec.ts index 8952a4f..03307b9 100644 --- a/src/integrations/tbank/tbank-source.policy.spec.ts +++ b/src/integrations/tbank/tbank-source.policy.spec.ts @@ -8,6 +8,7 @@ describe('request-driven source freshness', () => { '%s expires at its boundary without changing source time', (source) => { const fetchedAt = new Date('2026-09-01Z'); + // eslint-disable-next-line security/detect-object-injection -- The key comes from the local source-policy test table. const ttl = tbankSourceTtlMs[source]; expect( tbankSourceNeedsRefresh( diff --git a/src/integrations/tbank/tbank-source.policy.ts b/src/integrations/tbank/tbank-source.policy.ts index 39e3f54..ca2d39a 100644 --- a/src/integrations/tbank/tbank-source.policy.ts +++ b/src/integrations/tbank/tbank-source.policy.ts @@ -21,6 +21,7 @@ export function tbankSourceNeedsRefresh( return ( force || !fetchedAt || + // eslint-disable-next-line security/detect-object-injection -- The key is a member of the closed TbankSource union. now.valueOf() - fetchedAt.valueOf() >= tbankSourceTtlMs[source] ); } diff --git a/src/integrations/tbank/tbank-token.service.ts b/src/integrations/tbank/tbank-token.service.ts index 2e1a9ed..e029e07 100644 --- a/src/integrations/tbank/tbank-token.service.ts +++ b/src/integrations/tbank/tbank-token.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; + import { Environment } from '../../config/env.validation'; import { TbankCrypto } from './tbank.crypto'; @@ -25,8 +26,9 @@ export class TbankTokenService { try { return await operation(); } catch (error) { - if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') + if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') { throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }); + } throw error; } } diff --git a/src/integrations/tbank/tbank.api.ts b/src/integrations/tbank/tbank.api.ts index d260155..1f297d5 100644 --- a/src/integrations/tbank/tbank.api.ts +++ b/src/integrations/tbank/tbank.api.ts @@ -1,9 +1,12 @@ // Public cross-module surface. Nest providers are exported by the matching module. export { InstrumentMetadataService } from './instrument-metadata.service'; +export { metadataKey } from './instrument-metadata.service'; +export type { MetadataIdentity } from './instrument-metadata.service'; export { TbankAccessService, type TbankSyncAttempt, } from './tbank-access.service'; +export { supportedTbankAccounts } from './tbank-account.policy'; export { type TbankBond, type TbankCandle, @@ -12,8 +15,15 @@ export { type TbankLastPrice, type TbankShare, } from './tbank-market.types'; + +export { TbankMembershipService } from './tbank-membership.service'; + +export { + tbankSourceNeedsRefresh, + tbankSourceTtlMs, +} from './tbank-source.policy'; + export { TbankTokenService } from './tbank-token.service'; -export { supportedTbankAccounts } from './tbank-account.policy'; export { TbankClient, type TbankDividend, @@ -25,13 +35,3 @@ export { type TbankPositions, type TbankTimestamp, } from './tbank.client'; - -export { TbankMembershipService } from './tbank-membership.service'; - -export { - tbankSourceTtlMs, - tbankSourceNeedsRefresh, -} from './tbank-source.policy'; - -export { metadataKey } from './instrument-metadata.service'; -export type { MetadataIdentity } from './instrument-metadata.service'; diff --git a/src/integrations/tbank/tbank.client.spec.ts b/src/integrations/tbank/tbank.client.spec.ts index 22456bc..525ab85 100644 --- a/src/integrations/tbank/tbank.client.spec.ts +++ b/src/integrations/tbank/tbank.client.spec.ts @@ -1,5 +1,6 @@ import { status } from '@grpc/grpc-js'; import { NotFoundException, ServiceUnavailableException } from '@nestjs/common'; + import { TbankClient } from './tbank.client'; describe('TbankClient operations', () => { @@ -57,6 +58,7 @@ describe('TbankClient operations', () => { const client = Object.create(TbankClient.prototype) as TbankClient; const call = jest.fn().mockResolvedValue({}); (client as unknown as { call: jest.Mock }).call = call; + // eslint-disable-next-line security/detect-object-injection -- RPC method names come from the literal test table. await client[method]('token', 'uid', '2024-02-29', '2024-02-29'); expect(call).toHaveBeenCalledWith('token', undefined, method, { instrumentId: 'uid', @@ -111,11 +113,10 @@ describe('TbankClient operations', () => { ok: true, }); const failed = invoke('token', {}, 'getPortfolio', {}); - const rejected = expect(failed).rejects.toEqual( - expect.any(ServiceUnavailableException), - ); - await jest.advanceTimersByTimeAsync(1000); - await rejected; + await Promise.all([ + expect(failed).rejects.toEqual(expect.any(ServiceUnavailableException)), + jest.advanceTimersByTimeAsync(1000), + ]); expect(logger.event).toHaveBeenCalledWith( 'debug', @@ -140,7 +141,7 @@ describe('TbankClient metadata retries', () => { const instrument = { name: 'Облигация', ticker: 'BOND' }; let client: TbankClient; let requestRpc: jest.Mock; - let queue: { size: number; run: jest.Mock }; + let queue: { run: jest.Mock; size: number }; let logger: { event: jest.Mock]>; }; @@ -234,12 +235,10 @@ describe('TbankClient metadata retries', () => { it('stops retrying a saturated queue after three admission attempts', async () => { queue.size = 50; - const result = expect( - client.getInstrumentByUid('token', 'uid'), - ).rejects.toBeInstanceOf(ServiceUnavailableException); - - await jest.advanceTimersByTimeAsync(6000); - await result; + await Promise.all([ + expect(client.getInstrumentByUid('token', 'uid')).rejects.toBeInstanceOf(ServiceUnavailableException), + jest.advanceTimersByTimeAsync(6000), + ]); expect(queue.run).not.toHaveBeenCalled(); expect(logger.event).toHaveBeenCalledTimes(3); @@ -253,12 +252,10 @@ describe('TbankClient metadata retries', () => { it('stops retrying an unavailable upstream after three RPCs', async () => { requestRpc.mockRejectedValue({ code: status.UNAVAILABLE }); - const result = expect( - client.getInstrumentByUid('token', 'uid'), - ).rejects.toBeInstanceOf(ServiceUnavailableException); - - await jest.advanceTimersByTimeAsync(6000); - await result; + await Promise.all([ + expect(client.getInstrumentByUid('token', 'uid')).rejects.toBeInstanceOf(ServiceUnavailableException), + jest.advanceTimersByTimeAsync(6000), + ]); expect(queue.run).toHaveBeenCalledTimes(3); expect(jest.getTimerCount()).toBe(0); @@ -282,6 +279,7 @@ describe('TbankClient metadata retries', () => { async (method) => { requestRpc.mockRejectedValue({ code: status.NOT_FOUND }); + // eslint-disable-next-line security/detect-object-injection -- RPC method names come from the literal test table. await expect(client[method]('token', 'instrument')).resolves.toBeNull(); expect(requestRpc).toHaveBeenCalledTimes(1); expect(jest.getTimerCount()).toBe(0); diff --git a/src/integrations/tbank/tbank.client.ts b/src/integrations/tbank/tbank.client.ts index 19376aa..902733f 100644 --- a/src/integrations/tbank/tbank.client.ts +++ b/src/integrations/tbank/tbank.client.ts @@ -1,4 +1,3 @@ -import { MILLISECONDS_PER_SECOND } from '../../common/time'; import { ChannelCredentials, Client, @@ -17,8 +16,6 @@ import { import { ConfigService } from '@nestjs/config'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { StructuredLogger } from '../../common/logging/structured-logger.service'; -import { Environment } from '../../config/env.validation'; import type { TbankBond, @@ -29,6 +26,10 @@ import type { TbankShare, } from './tbank-market.types'; +import { StructuredLogger } from '../../common/logging/structured-logger.service'; +import { MILLISECONDS_PER_SECOND } from '../../common/time'; +import { Environment } from '../../config/env.validation'; + const RATE_WINDOW_MS = 1_000; const METADATA_MAX_ATTEMPTS = 3; const RPC_MAX_ATTEMPTS = 2; @@ -42,78 +43,78 @@ type Unary = ( request: Request, metadata: Metadata, options: { deadline: Date }, - callback: (error: ServiceError | null, response: Response) => void, + callback: (error: null | ServiceError, response: Response) => void, ) => ClientUnaryCall; type TbankClientService = Client & Record>; -export type TbankDecimal = { units?: string | number; nano?: string | number }; +export type TbankDecimal = { nano?: number | string; units?: number | string }; export type TbankMoney = TbankDecimal & { currency?: string }; export type TbankAccount = { id: string; - type: string; name: string; - openedDate?: { seconds?: string | number }; + openedDate?: { seconds?: number | string }; + type: string; }; export type TbankPortfolio = { positions?: { - figi?: string; - instrumentUid?: string; - instrumentType?: string; - ticker?: string; - quantity?: TbankDecimal; - currentPrice?: TbankMoney; averagePositionPrice?: TbankMoney; + currentPrice?: TbankMoney; + figi?: string; + instrumentType?: string; + instrumentUid?: string; + quantity?: TbankDecimal; + ticker?: string; }[]; }; export type TbankPositions = { money?: TbankMoney[] }; export type TbankInstrument = { - uid?: string; + brand?: { logoName?: null | string }; figi?: string; instrumentType?: string; - ticker?: string; - name?: string; isin?: string; - brand?: { logoName?: string | null }; + name?: string; + ticker?: string; + uid?: string; }; export type TbankDividend = { dividendNet?: TbankMoney; - recordDate?: TbankTimestamp; - paymentDate?: TbankTimestamp; - lastBuyDate?: TbankTimestamp; dividendType?: string; + lastBuyDate?: TbankTimestamp; + paymentDate?: TbankTimestamp; + recordDate?: TbankTimestamp; }; -export type TbankTimestamp = { seconds?: string | number; nanos?: number }; +export type TbankTimestamp = { nanos?: number; seconds?: number | string }; export type TbankOperationItem = { - state?: string; + accruedInt?: TbankMoney; + commission?: TbankMoney; cursor?: string; - id?: string; - name?: string; date?: TbankTimestamp; - type?: string; description?: string; figi?: string; - instrumentUid?: string; + id?: string; instrumentType?: string; - ticker?: string; + instrumentUid?: string; + name?: string; payment?: TbankMoney; price?: TbankMoney; - commission?: TbankMoney; - accruedInt?: TbankMoney; - quantity?: string | number; - quantityDone?: string | number; + quantity?: number | string; + quantityDone?: number | string; + state?: string; + ticker?: string; + type?: string; }; export type TbankOperationsByCursorRequest = { accountId: string; - instrumentId?: string; - from?: TbankTimestamp; - to?: TbankTimestamp; cursor?: string; + from?: TbankTimestamp; + instrumentId?: string; limit?: number; state: 'OPERATION_STATE_EXECUTED'; + to?: TbankTimestamp; }; export type TbankOperationsByCursorResponse = { hasNext?: boolean; - nextCursor?: string; items?: TbankOperationItem[]; + nextCursor?: string; }; type TbankNamespace = { tinkoff: { @@ -197,12 +198,14 @@ export class TbankClient { loadPackageDefinition(definition) as unknown as TbankNamespace ).tinkoff.public.invest.api.contract.v1; const caPath = config.get('TBANK_CA_CERT_PATH'); - const credentials = caPath - ? ChannelCredentials.createSsl(readFileSync(caPath)) - : ChannelCredentials.createSsl(); + const credentials = caPath ? + // eslint-disable-next-line security/detect-non-literal-fs-filename -- The CA path is trusted operator configuration, never supplied by an HTTP request. + ChannelCredentials.createSsl(readFileSync(caPath)) : + ChannelCredentials.createSsl(); const endpoint = config.getOrThrow('TBANK_GRPC_ENDPOINT'); const create = (name: string) => new ( + // eslint-disable-next-line security/detect-object-injection -- Service and method names are internal RPC selections, not request input. services[name] as new ( address: string, credentials: ChannelCredentials, @@ -268,14 +271,14 @@ export class TbankClient { getInstrumentByFigi( token: string, figi: string, - ): Promise { + ): Promise { return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_FIGI', figi); } getInstrumentByUid( token: string, instrumentUid: string, - ): Promise { + ): Promise { return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_UID', instrumentUid); } @@ -320,7 +323,8 @@ export class TbankClient { }, ).then((response) => response.instruments ?? []); } - getBond(token: string, uid: string): Promise { + + getBond(token: string, uid: string): Promise { return this.call<{ instrument?: TbankBond }>( token, this.sparseInstruments, @@ -331,6 +335,7 @@ export class TbankClient { }, ).then((response) => response.instrument ?? null); } + getBondCoupons( token: string, uid: string, @@ -349,7 +354,7 @@ export class TbankClient { ).then((response) => response.events ?? []); } - getShare(token: string, uid: string): Promise { + getShare(token: string, uid: string): Promise { return this.call<{ instrument?: TbankShare }>( token, this.instruments, @@ -361,7 +366,7 @@ export class TbankClient { ).then((response) => response.instrument ?? null); } - getAssetDescription(token: string, assetUid: string): Promise { + getAssetDescription(token: string, assetUid: string): Promise { return this.call<{ asset?: { description?: string } }>( token, this.instruments, @@ -375,7 +380,7 @@ export class TbankClient { getFundamentals( token: string, assetUid: string, - ): Promise { + ): Promise { return this.call<{ fundamentals?: TbankFundamentals[] }>( token, this.sparseInstruments, @@ -399,13 +404,13 @@ export class TbankClient { ).then((response) => response.lastPrices ?? []); } - getLastPrice(token: string, uid: string): Promise { + getLastPrice(token: string, uid: string): Promise { return this.getLastPrices(token, [uid]).then( (prices) => prices.find((item) => item.instrumentUid === uid) ?? null, ); } - getTradingStatus(token: string, uid: string): Promise { + getTradingStatus(token: string, uid: string): Promise { return this.call<{ tradingStatus?: string }>( token, this.marketData, @@ -442,9 +447,9 @@ export class TbankClient { private async getInstrument( token: string, - idType: 'INSTRUMENT_ID_TYPE_UID' | 'INSTRUMENT_ID_TYPE_FIGI', + idType: 'INSTRUMENT_ID_TYPE_FIGI' | 'INSTRUMENT_ID_TYPE_UID', id: string, - ): Promise { + ): Promise { try { const response = await this.call<{ instrument?: TbankInstrument }>( token, @@ -471,8 +476,9 @@ export class TbankClient { const startedAt = performance.now(); const queueFull = this.queue.size >= MAX_QUEUE_SIZE; try { - if (queueFull) + if (queueFull) { throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }); + } const response = await this.queue.run(() => this.request(token, client, method, request), ); @@ -484,15 +490,15 @@ export class TbankClient { return response; } catch (error) { const grpcCode = (error as ServiceError).code; - const failureContext = queueFull - ? { code: 'TBANK_UNAVAILABLE' } - : { grpcCode }; - const retryable = queueFull - ? metadataCall - : [ - status.UNAVAILABLE, + const failureContext = queueFull ? + { code: 'TBANK_UNAVAILABLE' } : + { grpcCode }; + const retryable = queueFull ? + metadataCall : + [ status.DEADLINE_EXCEEDED, status.RESOURCE_EXHAUSTED, + status.UNAVAILABLE, ].includes(grpcCode); if (retryable && attempts < maxAttempts) { this.logger.event('warn', 'tbank.rpc.retry', { @@ -504,9 +510,9 @@ export class TbankClient { await new Promise((resolve) => setTimeout( resolve, - metadataCall - ? attempts * METADATA_RETRY_DELAY_MS - : RPC_RETRY_DELAY_MS, + metadataCall ? + attempts * METADATA_RETRY_DELAY_MS : + RPC_RETRY_DELAY_MS, ), ); continue; @@ -518,15 +524,17 @@ export class TbankClient { durationMs: Math.round(performance.now() - startedAt), }); if ( - [status.UNAUTHENTICATED, status.PERMISSION_DENIED].includes(grpcCode) - ) + [status.PERMISSION_DENIED, status.UNAUTHENTICATED].includes(grpcCode) + ) { // eslint-disable-next-line preserve-caught-error -- Discard raw upstream authentication errors at the integration boundary. throw new Error('INVALID_TBANK_TOKEN'); + } if ( grpcCode === status.NOT_FOUND && (method === 'shareBy' || metadataCall) - ) + ) { throw new NotFoundException(); + } throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }); } } @@ -542,6 +550,7 @@ export class TbankClient { return new Promise((resolve, reject) => { const metadata = new Metadata(); metadata.set('Authorization', `Bearer ${token}`); + // eslint-disable-next-line security/detect-object-injection -- Service and method names are internal RPC selections, not request input. client[method]( request as never, metadata, diff --git a/src/integrations/tbank/tbank.crypto.ts b/src/integrations/tbank/tbank.crypto.ts index 6eebe25..a40d081 100644 --- a/src/integrations/tbank/tbank.crypto.ts +++ b/src/integrations/tbank/tbank.crypto.ts @@ -11,8 +11,9 @@ export class TbankCrypto { static fromBase64(key: string): TbankCrypto { const decoded = Buffer.from(key, 'base64'); - if (decoded.length !== KEY_SIZE_BYTES) + if (decoded.length !== KEY_SIZE_BYTES) { throw new Error('Invalid token encryption key'); + } return new TbankCrypto(decoded); } diff --git a/src/integrations/tbank/tbank.module.ts b/src/integrations/tbank/tbank.module.ts index fa7eed8..3fff10d 100644 --- a/src/integrations/tbank/tbank.module.ts +++ b/src/integrations/tbank/tbank.module.ts @@ -1,8 +1,9 @@ import { Module } from '@nestjs/common'; -import { TbankMembershipService } from './tbank-membership.service'; + import { InstrumentMetadataRepository } from './instrument-metadata.repository'; import { InstrumentMetadataService } from './instrument-metadata.service'; import { TbankAccessService } from './tbank-access.service'; +import { TbankMembershipService } from './tbank-membership.service'; import { TbankTokenService } from './tbank-token.service'; import { TbankClient } from './tbank.client'; diff --git a/src/main.ts b/src/main.ts index 61c4511..1ea1611 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,18 +3,19 @@ import { INestApplication, ValidationPipe, } from '@nestjs/common'; -import { NestFactory } from '@nestjs/core'; -import cookieParser from 'cookie-parser'; import { ConfigService } from '@nestjs/config'; +import { NestFactory } from '@nestjs/core'; import { SwaggerModule } from '@nestjs/swagger'; -import { API_PREFIX, createOpenApiDocument } from './openapi'; +import cookieParser from 'cookie-parser'; import { Logger } from 'nestjs-pino'; + import { AppModule } from './app.module'; import { HttpExceptionFilter } from './common/filters/http-exception.filter'; import { RequestLoggingMiddleware } from './common/logging/request-logging.middleware'; import { StructuredLogger } from './common/logging/structured-logger.service'; import { Environment } from './config/env.validation'; import { shouldExposeSwagger } from './config/swagger.config'; +import { API_PREFIX, createOpenApiDocument } from './openapi'; export function configureApp(app: INestApplication): void { const config = app.get>(ConfigService); @@ -32,6 +33,7 @@ export function configureApp(app: INestApplication): void { app.use(requestLogging.use.bind(requestLogging)); app.useGlobalPipes( new ValidationPipe({ + forbidUnknownValues: true, transform: true, whitelist: true, validationError: { target: false, value: false }, diff --git a/src/modules/auth/application/auth.service.spec.ts b/src/modules/auth/application/auth.service.spec.ts index df2fe1f..3e04a6d 100644 --- a/src/modules/auth/application/auth.service.spec.ts +++ b/src/modules/auth/application/auth.service.spec.ts @@ -114,8 +114,8 @@ describe('AuthService', () => { where, data, }: { - where: { id?: string; refreshTokenHash?: string }; data: { refreshTokenHash: string }; + where: { id?: string; refreshTokenHash?: string }; }) => { if (where.id === session.id && where.refreshTokenHash === currentHash) { currentHash = data.refreshTokenHash; diff --git a/src/modules/auth/application/auth.service.ts b/src/modules/auth/application/auth.service.ts index 61babc2..b2528c8 100644 --- a/src/modules/auth/application/auth.service.ts +++ b/src/modules/auth/application/auth.service.ts @@ -6,6 +6,7 @@ import { import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; import { createHash, randomUUID } from 'crypto'; + import { Session, User } from '../../../../generated/prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; import { AuthUser, RefreshPayload } from './auth.types'; @@ -16,6 +17,7 @@ const REFRESH_TTL_MS = 604_800_000; const ACCESS_TTL = '15m'; const REFRESH_TTL = '7d'; const DUMMY_PASSWORD_HASH = + // eslint-disable-next-line sonarjs/no-hardcoded-passwords -- Public dummy bcrypt hash preserves login timing for nonexistent users; it is not a credential. '$2b$12$Z49BwJsyiUqY0ZozFzt9IeYvjhV4kbeLw9KdxOguT5bfhA1RvPCx6'; @Injectable() @@ -28,12 +30,13 @@ export class AuthService { async register( email: string, password: string, - ): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { + ): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> { const existing = await this.prisma.user.findUnique({ where: { email }, }); - if (existing) + if (existing) { throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' }); + } let user: User; try { @@ -45,8 +48,9 @@ export class AuthService { }, }); } catch (error) { - if (isUniqueConstraintError(error)) + if (isUniqueConstraintError(error)) { throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' }); + } throw error; } return this.createSession(user); @@ -55,7 +59,7 @@ export class AuthService { async login( email: string, password: string, - ): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { + ): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> { const user = await this.prisma.user.findUnique({ where: { email }, }); @@ -63,14 +67,15 @@ export class AuthService { password, user?.passwordHash ?? DUMMY_PASSWORD_HASH, ); - if (!user || !valid) + if (!user || !valid) { throw new UnauthorizedException({ code: 'INVALID_CREDENTIALS' }); + } return this.createSession(user); } async refresh( token: string, - ): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { + ): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> { let payload: RefreshPayload; try { payload = await this.jwt.verifyAsync(token, { @@ -79,8 +84,9 @@ export class AuthService { } catch { throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); } - if (payload.type !== 'refresh') + if (payload.type !== 'refresh') { throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); + } const session = await this.prisma.session.findUnique({ where: { id: payload.sid }, include: { user: true }, @@ -91,12 +97,14 @@ export class AuthService { session.revokedAt || session.expiresAt <= new Date() ) { - if (session && !session.revokedAt) + if (session && !session.revokedAt) { await this.prisma.session.delete({ where: { id: session.id } }); + } throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); } - if (session.refreshTokenHash !== this.hash(token)) + if (session.refreshTokenHash !== this.hash(token)) { throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); + } return this.rotateSession(session, session.refreshTokenHash); } @@ -138,7 +146,7 @@ export class AuthService { private async createSession( user: User, - ): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { + ): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> { const session = await this.prisma.session.create({ data: { id: randomUUID(), @@ -153,7 +161,7 @@ export class AuthService { private async rotateSession( session: Session & { user: User }, expectedRefreshTokenHash?: string, - ): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { + ): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> { const refreshToken = await this.jwt.signAsync( { sub: session.userId, @@ -172,8 +180,9 @@ export class AuthService { where: { id: session.id, refreshTokenHash: expectedRefreshTokenHash }, data, }); - if (updated.count !== 1) + if (updated.count !== 1) { throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); + } } else { await this.prisma.session.update({ where: { id: session.id }, data }); } diff --git a/src/modules/auth/application/auth.types.ts b/src/modules/auth/application/auth.types.ts index cbeba59..6e5348e 100644 --- a/src/modules/auth/application/auth.types.ts +++ b/src/modules/auth/application/auth.types.ts @@ -1,17 +1,17 @@ export interface AuthUser { - id: string; email: string; + id: string; } export interface AccessPayload { - sub: string; sid: string; + sub: string; type: 'access'; } export interface RefreshPayload { - sub: string; - sid: string; - type: 'refresh'; jti: string; + sid: string; + sub: string; + type: 'refresh'; } diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 9fa5687..1d93f41 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -2,11 +2,12 @@ import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; + import { Environment } from '../../config/env.validation'; -import { AuthController } from './presentation/auth.controller'; import { AuthService } from './application/auth.service'; -import { JwtAuthGuard } from './presentation/jwt-auth.guard'; import { JwtStrategy } from './infrastructure/jwt.strategy'; +import { AuthController } from './presentation/auth.controller'; +import { JwtAuthGuard } from './presentation/jwt-auth.guard'; @Module({ imports: [ diff --git a/src/modules/auth/infrastructure/jwt.strategy.ts b/src/modules/auth/infrastructure/jwt.strategy.ts index 1f97b0e..96110de 100644 --- a/src/modules/auth/infrastructure/jwt.strategy.ts +++ b/src/modules/auth/infrastructure/jwt.strategy.ts @@ -2,6 +2,7 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; + import { Environment } from '../../../config/env.validation'; import { AccessPayload } from '../application/auth.types'; @@ -17,8 +18,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) { } validate(payload: AccessPayload): AccessPayload { - if (payload.type !== 'access') + if (payload.type !== 'access') { throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); + } return payload; } } diff --git a/src/modules/auth/presentation/auth.controller.ts b/src/modules/auth/presentation/auth.controller.ts index 45a35dc..c169f0e 100644 --- a/src/modules/auth/presentation/auth.controller.ts +++ b/src/modules/auth/presentation/auth.controller.ts @@ -1,3 +1,5 @@ +import type { Request, Response } from 'express'; + import { Body, Controller, @@ -17,11 +19,11 @@ import { ApiProperty, ApiTags, } from '@nestjs/swagger'; -import type { Request, Response } from 'express'; + import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; -import { CredentialsDto } from './auth.dto'; import { AuthService } from '../application/auth.service'; import { AccessPayload } from '../application/auth.types'; +import { CredentialsDto } from './auth.dto'; import { JwtAuthGuard } from './jwt-auth.guard'; const REFRESH_COOKIE = 'refresh_token'; @@ -115,9 +117,9 @@ export class AuthController { private respond( response: Response, result: { - user: { id: string; email: string }; accessToken: string; refreshToken: string; + user: { email: string; id: string }; }, ) { response.cookie(REFRESH_COOKIE, result.refreshToken, this.cookieOptions()); @@ -129,6 +131,7 @@ export class AuthController { } private refreshToken(request: Request): string | undefined { + // eslint-disable-next-line security/detect-object-injection -- The cookie name is a fixed application constant; its value is narrowed below. const token: unknown = request.cookies?.[REFRESH_COOKIE]; return typeof token === 'string' ? token : undefined; } diff --git a/src/modules/auth/presentation/auth.dto.ts b/src/modules/auth/presentation/auth.dto.ts index b2e25ec..62fe648 100644 --- a/src/modules/auth/presentation/auth.dto.ts +++ b/src/modules/auth/presentation/auth.dto.ts @@ -28,9 +28,10 @@ class EmailConstraint { character.charCodeAt(0) < ASCII_SPACE || character.charCodeAt(0) === ASCII_DELETE, ) && - value.split('@').every(Boolean) + value.split('@').every(Boolean) ); } + defaultMessage(): string { return 'must be a valid email address'; } @@ -44,6 +45,7 @@ class PasswordBytesConstraint { Buffer.byteLength(value, 'utf8') <= MAX_PASSWORD_BYTES ); } + defaultMessage(): string { return 'must not exceed 72 UTF-8 bytes'; } diff --git a/src/modules/auth/presentation/jwt-auth.guard.ts b/src/modules/auth/presentation/jwt-auth.guard.ts index 8f0f625..7056b18 100644 --- a/src/modules/auth/presentation/jwt-auth.guard.ts +++ b/src/modules/auth/presentation/jwt-auth.guard.ts @@ -1,5 +1,6 @@ import { ExecutionContext, Injectable } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; + import { RequestContextService } from '../../../common/logging/request-context.service'; import { AccessPayload } from '../application/auth.types'; @@ -14,8 +15,9 @@ export class JwtAuthGuard extends AuthGuard('jwt') { const request = context .switchToHttp() .getRequest<{ user?: AccessPayload }>(); - if (request.user?.sub) + if (request.user?.sub) { this.requestContext.set({ userId: request.user.sub }); + } return Boolean(allowed); } } diff --git a/src/modules/banking/bank-cards/application/bank-card-input.ts b/src/modules/banking/bank-cards/application/bank-card-input.ts index e62f380..e0f3440 100644 --- a/src/modules/banking/bank-cards/application/bank-card-input.ts +++ b/src/modules/banking/bank-cards/application/bank-card-input.ts @@ -1,21 +1,22 @@ -import { - MAX_CARD_NAME_LENGTH, - MAX_OPERATION_DESCRIPTION_LENGTH, -} from '../domain/bank-card-limits'; import { BadRequestException, ConflictException, NotFoundException, } from '@nestjs/common'; import { createHash } from 'node:crypto'; + +import { + MAX_CARD_NAME_LENGTH, + MAX_OPERATION_DESCRIPTION_LENGTH, +} from '../domain/bank-card-limits'; +import { isCalendarDate, moscowToday } from '../domain/bank-card.calculations'; +import { parseRubMoney } from '../domain/rub-money'; import { bankPaymentSystems, BankPeriodQueryDto, CreateBankCardDto, CreateBankOperationDto, } from '../presentation/bank-card.dto'; -import { isCalendarDate, moscowToday } from '../domain/bank-card.calculations'; -import { parseRubMoney } from '../domain/rub-money'; export const BankErrorCode = { InvalidInput: 'BANK_INVALID_INPUT', @@ -54,8 +55,9 @@ export function periodBounds(query: BankPeriodQueryDto) { !isCalendarDate(query.from) || !isCalendarDate(query.to) || query.from > query.to - ) + ) { return fail(); + } return { from: query.from, to: query.to }; } export function conflict( @@ -93,8 +95,9 @@ export function currentDate(value: string) { if (value > moscowToday()) fail(BankErrorCode.InvalidDate); } export function name(value: string, max: number) { - if (typeof value !== 'string' || !value.trim() || value.trim().length > max) + if (typeof value !== 'string' || !value.trim() || value.trim().length > max) { fail(); + } return value.trim(); } export function cardInput(dto: CreateBankCardDto) { @@ -104,8 +107,9 @@ export function cardInput(dto: CreateBankCardDto) { if ( dto.paymentSystem != null && !bankPaymentSystems.some((system) => system === dto.paymentSystem) - ) + ) { fail(); + } return { ...(dto.paymentSystem == null ? {} : { paymentSystem: dto.paymentSystem }), name: name(dto.name, MAX_CARD_NAME_LENGTH), @@ -120,8 +124,9 @@ export function operationInput(dto: CreateBankOperationDto) { dto.description != null && (typeof dto.description !== 'string' || dto.description.length > MAX_OPERATION_DESCRIPTION_LENGTH) - ) + ) { fail(); + } return { kind: dto.kind, cardId: dto.cardId, diff --git a/src/modules/banking/bank-cards/application/bank-cards.service.ts b/src/modules/banking/bank-cards/application/bank-cards.service.ts index 356c5a0..1f5de0c 100644 --- a/src/modules/banking/bank-cards/application/bank-cards.service.ts +++ b/src/modules/banking/bank-cards/application/bank-cards.service.ts @@ -1,24 +1,24 @@ import { Injectable } from '@nestjs/common'; + import { BankCard, Prisma } from '../../../../../generated/prisma/client'; import { PrismaService } from '../../../../prisma/prisma.service'; +import { bankBalance } from '../domain/bank-card.calculations'; +import { rubMoney } from '../domain/rub-money'; +import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; import { BankCardDto, CreateBankCardDto, UpdateBankCardDto, } from '../presentation/bank-card.dto'; -import { bankBalance } from '../domain/bank-card.calculations'; -import { rubMoney } from '../domain/rub-money'; - import { - cardInput, BankErrorCode, + cardInput, conflict, currentDate, fail, fingerprint, missing, } from './bank-card-input'; -import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; type Tx = Prisma.TransactionClient; @Injectable() export class BankCardsService { @@ -26,6 +26,7 @@ export class BankCardsService { private readonly prisma: PrismaService, private readonly repository: BankCardsRepository, ) {} + private async cardView(tx: Tx, card: BankCard): Promise { const rows = await tx.bankCardOperation.findMany({ where: { @@ -47,6 +48,7 @@ export class BankCardsService { archivedAt: card.archivedAt?.toISOString() ?? null, }; } + list(userId: string, includeArchived = false) { return this.prisma.$transaction(async (tx) => Promise.all( @@ -59,11 +61,13 @@ export class BankCardsService { ), ); } + get(userId: string, id: string) { return this.prisma.$transaction(async (tx) => this.cardView(tx, await this.repository.card(tx, userId, id)), ); } + createCard(userId: string, dto: CreateBankCardDto) { const input = cardInput(dto), hash = fingerprint(input); @@ -71,8 +75,10 @@ export class BankCardsService { const existing = await tx.bankCard.findUnique({ where: { id: dto.id } }); if (existing) { if (existing.userId !== userId) missing(); - if (existing.createFingerprint !== hash) + // eslint-disable-next-line security/detect-possible-timing-attacks -- This compares an idempotency fingerprint after ownership validation, not a secret. + if (existing.createFingerprint !== hash) { conflict(BankErrorCode.IntentConflict); + } return this.cardView(tx, existing); } currentDate(input.openingDate); @@ -85,6 +91,7 @@ export class BankCardsService { ); }); } + updateCard(userId: string, id: string, dto: UpdateBankCardDto) { return this.repository.write(async (tx) => { const previous = await this.repository.card(tx, userId, id); @@ -93,17 +100,17 @@ export class BankCardsService { id, name: dto.name ?? previous.name, bankName: - dto.bankName === undefined - ? (previous.bankName ?? undefined) - : (dto.bankName ?? undefined), + dto.bankName === undefined ? + (previous.bankName ?? undefined) : + (dto.bankName ?? undefined), lastFour: - dto.lastFour === undefined - ? (previous.lastFour ?? undefined) - : (dto.lastFour ?? undefined), + dto.lastFour === undefined ? + (previous.lastFour ?? undefined) : + (dto.lastFour ?? undefined), paymentSystem: - dto.paymentSystem === undefined - ? previous.paymentSystem - : dto.paymentSystem, + dto.paymentSystem === undefined ? + previous.paymentSystem : + dto.paymentSystem, openingBalance: dto.openingBalance ?? rubMoney(previous.openingBalanceKopecks), openingDate: dto.openingDate ?? previous.openingDate, @@ -111,8 +118,9 @@ export class BankCardsService { const financial = input.openingBalanceKopecks !== previous.openingBalanceKopecks || input.openingDate !== previous.openingDate; - if (financial && previous.archivedAt) + if (financial && previous.archivedAt) { conflict(BankErrorCode.CardArchived); + } currentDate(input.openingDate); if ( await tx.bankCardOperation.findFirst({ @@ -123,20 +131,21 @@ export class BankCardsService { OR: [{ cardId: id }, { targetCardId: id }], }, }) - ) + ) { fail(BankErrorCode.OpeningDateConflict); + } const changed = await tx.bankCard.updateMany({ where: { id, userId, version: dto.version }, data: { ...input, paymentSystem: input.paymentSystem ?? null, version: { increment: 1 }, - ...(dto.archived === undefined - ? {} - : { - archivedAt: dto.archived - ? (previous.archivedAt ?? new Date()) - : null, + ...(dto.archived === undefined ? + {} : + { + archivedAt: dto.archived ? + (previous.archivedAt ?? new Date()) : + null, }), }, }); diff --git a/src/modules/banking/bank-cards/application/bank-operations.service.ts b/src/modules/banking/bank-cards/application/bank-operations.service.ts index 9b6cabd..d9dac92 100644 --- a/src/modules/banking/bank-cards/application/bank-operations.service.ts +++ b/src/modules/banking/bank-cards/application/bank-operations.service.ts @@ -1,20 +1,21 @@ -import { BankOperationKind } from '../domain/bank-operation-kind'; import { GoneException, Injectable } from '@nestjs/common'; + import { BankCardOperation, Prisma, } from '../../../../../generated/prisma/client'; import { PrismaService } from '../../../../prisma/prisma.service'; +import { BankOperationKind } from '../domain/bank-operation-kind'; +import { rubMoney } from '../domain/rub-money'; +import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; import { BankOperationDto, - DEFAULT_BANK_PAGE_SIZE, BankOperationsQueryDto, BankPageQueryDto, CreateBankOperationDto, + DEFAULT_BANK_PAGE_SIZE, UpdateBankOperationDto, } from '../presentation/bank-card.dto'; -import { rubMoney } from '../domain/rub-money'; - import { BankErrorCode, conflict, @@ -25,7 +26,6 @@ import { operationInput, periodBounds, } from './bank-card-input'; -import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; type Tx = Prisma.TransactionClient; @Injectable() export class BankOperationsService { @@ -33,34 +33,35 @@ export class BankOperationsService { private readonly prisma: PrismaService, private readonly repository: BankCardsRepository, ) {} + private async operationView( tx: Tx, row: BankCardOperation, ): Promise { const card = await this.repository.card(tx, row.userId, row.cardId); - const target = row.targetCardId - ? await this.repository.card(tx, row.userId, row.targetCardId) - : null; - const source = row.originalExpenseId - ? await this.repository.operation(tx, row.userId, row.originalExpenseId) - : null; + const target = row.targetCardId ? + await this.repository.card(tx, row.userId, row.targetCardId) : + null; + const source = row.originalExpenseId ? + await this.repository.operation(tx, row.userId, row.originalExpenseId) : + null; const categoryId = source?.categoryId ?? row.categoryId; - const category = categoryId - ? await tx.expenseCategory.findFirst({ + const category = categoryId ? + await tx.expenseCategory.findFirst({ where: { id: categoryId, userId: row.userId }, - }) - : null; + }) : + null; const refunds = - row.kind === BankOperationKind.Expense || source - ? await tx.bankCardOperation.findMany({ + row.kind === BankOperationKind.Expense || source ? + await tx.bankCardOperation.findMany({ where: { userId: row.userId, originalExpenseId: source?.id ?? row.id, deletedAt: null, ...(source ? { id: { not: row.id } } : {}), }, - }) - : []; + }) : + []; return { id: row.id, kind: row.kind as BankOperationDto['kind'], @@ -73,18 +74,19 @@ export class BankOperationsService { originalExpenseId: row.originalExpenseId, amount: rubMoney(row.amountKopecks), availableRefund: - row.kind === BankOperationKind.Expense || source - ? rubMoney( + row.kind === BankOperationKind.Expense || source ? + rubMoney( (source?.amountKopecks ?? row.amountKopecks) - - refunds.reduce((sum, r) => sum + r.amountKopecks, 0n), - ) - : null, + refunds.reduce((sum, r) => sum + r.amountKopecks, 0n), + ) : + null, date: row.date, description: row.description, version: row.version, createdAt: row.createdAt.toISOString(), }; } + private async validateOperation( tx: Tx, userId: string, @@ -102,23 +104,26 @@ export class BankOperationsService { if ( (id === input.cardId || id === input.targetCardId) && input.date < card.openingDate - ) + ) { fail(BankErrorCode.InvalidDate); + } } if ( old && (old.kind !== input.kind || old.originalExpenseId !== input.originalExpenseId) - ) + ) { fail(BankErrorCode.ImmutableOperation); + } if (input.kind === BankOperationKind.Transfer) { if ( !input.targetCardId || input.targetCardId === input.cardId || input.categoryId || input.originalExpenseId - ) + ) { fail(); + } } else if (input.targetCardId) fail(); if (input.kind === BankOperationKind.Expense) { if (input.originalExpenseId) fail(); @@ -133,11 +138,13 @@ export class BankOperationsService { !(await tx.expenseCategory.findFirst({ where: { userId, id: input.categoryId }, })) - ) + ) { missing(); + } } else if (input.categoryId) fail(); - if (input.kind !== BankOperationKind.Refund && input.originalExpenseId) + if (input.kind !== BankOperationKind.Refund && input.originalExpenseId) { fail(); + } if (input.kind === BankOperationKind.Refund) { if (!input.originalExpenseId) fail(); const source = await this.repository.operation( @@ -149,8 +156,9 @@ export class BankOperationsService { source.deletedAt || source.kind !== BankOperationKind.Expense || source.cardId !== input.cardId - ) + ) { fail(BankErrorCode.InvalidRefund); + } if ( ( await tx.bankCardOperation.updateMany({ @@ -163,8 +171,9 @@ export class BankOperationsService { data: { version: { increment: 1 } }, }) ).count !== 1 - ) + ) { conflict(); + } const refunds = await tx.bankCardOperation.findMany({ where: { userId, @@ -176,10 +185,11 @@ export class BankOperationsService { if (input.date < source.date) fail(BankErrorCode.InvalidDate); if ( refunds.reduce((sum, r) => sum + r.amountKopecks, 0n) + - input.amountKopecks > + input.amountKopecks > source.amountKopecks - ) + ) { conflict(BankErrorCode.RefundLimit); + } } if (old?.kind === BankOperationKind.Expense) { const refunds = await tx.bankCardOperation.findMany({ @@ -190,11 +200,13 @@ export class BankOperationsService { (input.cardId !== old.cardId || refunds.some((row) => row.date < input.date) || refunds.reduce((sum, row) => sum + row.amountKopecks, 0n) > - input.amountKopecks) - ) + input.amountKopecks) + ) { conflict(BankErrorCode.DependentRefunds); + } } } + createOperation(userId: string, dto: CreateBankOperationDto) { const normalized = operationInput(dto), hash = fingerprint(normalized); @@ -204,10 +216,13 @@ export class BankOperationsService { }); if (existing) { if (existing.userId !== userId) missing(); - if (existing.createFingerprint !== hash) + // eslint-disable-next-line security/detect-possible-timing-attacks -- This compares an idempotency fingerprint after ownership validation, not a secret. + if (existing.createFingerprint !== hash) { conflict(BankErrorCode.IntentConflict); - if (existing.deletedAt) + } + if (existing.deletedAt) { throw new GoneException({ code: BankErrorCode.OperationDeleted }); + } return this.operationView(tx, existing); } const input = { ...normalized }; @@ -220,6 +235,7 @@ export class BankOperationsService { ); }); } + getOperation(userId: string, id: string) { return this.prisma.$transaction(async (tx) => { const row = await this.repository.operation(tx, userId, id); @@ -227,6 +243,7 @@ export class BankOperationsService { return this.operationView(tx, row); }); } + updateOperation(userId: string, id: string, dto: UpdateBankOperationDto) { return this.repository.write(async (tx) => { const old = await this.repository.operation(tx, userId, id); @@ -252,29 +269,33 @@ export class BankOperationsService { data: { ...input, version: { increment: 1 } }, }) ).count !== 1 - ) + ) { conflict(); + } return this.operationView( tx, await this.repository.operation(tx, userId, id), ); }); } + deleteOperation(userId: string, id: string, version: number) { return this.repository.write(async (tx) => { const row = await this.repository.operation(tx, userId, id); if (row.deletedAt) return; if (row.version !== version) conflict(); await this.repository.card(tx, userId, row.cardId, true); - if (row.targetCardId) + if (row.targetCardId) { await this.repository.card(tx, userId, row.targetCardId, true); + } if ( row.kind === BankOperationKind.Expense && (await tx.bankCardOperation.findFirst({ where: { userId, originalExpenseId: id, deletedAt: null }, })) - ) + ) { conflict(BankErrorCode.DependentRefunds); + } if (row.originalExpenseId) { const source = await this.repository.operation( tx, @@ -288,8 +309,9 @@ export class BankOperationsService { data: { version: { increment: 1 } }, }) ).count !== 1 - ) + ) { conflict(); + } } if ( ( @@ -298,30 +320,32 @@ export class BankOperationsService { data: { deletedAt: new Date(), version: { increment: 1 } }, }) ).count !== 1 - ) + ) { conflict(); + } }); } + history( userId: string, id: string, query: BankOperationsQueryDto | BankPageQueryDto, expenses = false, ) { - const period = expenses - ? undefined - : periodBounds(query as BankOperationsQueryDto); + const period = expenses ? + undefined : + periodBounds(query as BankOperationsQueryDto); return this.prisma.$transaction(async (tx) => { await this.repository.card(tx, userId, id); - const page = query.page ?? 1, - limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE; + const limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE, + page = query.page ?? 1; const where: Prisma.BankCardOperationWhereInput = { userId, deletedAt: null, OR: [{ cardId: id }, { targetCardId: id }], - ...(period - ? { date: { gte: period.from, lte: period.to } } - : { kind: BankOperationKind.Expense }), + ...(period ? + { date: { gte: period.from, lte: period.to } } : + { kind: BankOperationKind.Expense }), }; const total = await tx.bankCardOperation.count({ where }); const rows = await tx.bankCardOperation.findMany({ diff --git a/src/modules/banking/bank-cards/application/expense-categories.service.ts b/src/modules/banking/bank-cards/application/expense-categories.service.ts index f110154..80f2d77 100644 --- a/src/modules/banking/bank-cards/application/expense-categories.service.ts +++ b/src/modules/banking/bank-cards/application/expense-categories.service.ts @@ -1,16 +1,17 @@ -import { MAX_CATEGORY_NAME_LENGTH } from '../domain/bank-card-limits'; import { Injectable } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; -import { PrismaService } from '../../../../prisma/prisma.service'; -import { conflict, missing, name } from './bank-card-input'; +import { PrismaService } from '../../../../prisma/prisma.service'; +import { MAX_CATEGORY_NAME_LENGTH } from '../domain/bank-card-limits'; import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; +import { conflict, missing, name } from './bank-card-input'; @Injectable() export class ExpenseCategoriesService { constructor( private readonly prisma: PrismaService, private readonly repository: BankCardsRepository, ) {} + async categories(userId: string) { return ( await this.prisma.expenseCategory.findMany({ @@ -24,6 +25,7 @@ export class ExpenseCategoriesService { version, })); } + createCategory(userId: string, value: string) { const label = name(value, MAX_CATEGORY_NAME_LENGTH); return this.repository.write(async (tx) => { @@ -44,6 +46,7 @@ export class ExpenseCategoriesService { }; }); } + updateCategory(userId: string, id: string, value: string, version: number) { const label = name(value, MAX_CATEGORY_NAME_LENGTH); return this.repository.write(async (tx) => { @@ -60,8 +63,9 @@ export class ExpenseCategoriesService { }, }) ).count !== 1 - ) + ) { conflict(); + } return { id, name: label, diff --git a/src/modules/banking/bank-cards/application/get-bank-summary.use-case.ts b/src/modules/banking/bank-cards/application/get-bank-summary.use-case.ts index 5b224fb..33fa467 100644 --- a/src/modules/banking/bank-cards/application/get-bank-summary.use-case.ts +++ b/src/modules/banking/bank-cards/application/get-bank-summary.use-case.ts @@ -1,10 +1,10 @@ import { Injectable } from '@nestjs/common'; -import { BankSummaryQueryDto } from '../presentation/bank-card.dto'; + import { bankSummary } from '../domain/bank-card.calculations'; import { rubMoney } from '../domain/rub-money'; - -import { periodBounds } from './bank-card-input'; import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; +import { BankSummaryQueryDto } from '../presentation/bank-card.dto'; +import { periodBounds } from './bank-card-input'; @Injectable() export class GetBankSummaryUseCase { constructor(private readonly repository: BankCardsRepository) {} diff --git a/src/modules/banking/bank-cards/bank-cards.module.ts b/src/modules/banking/bank-cards/bank-cards.module.ts index e34a089..2b9e006 100644 --- a/src/modules/banking/bank-cards/bank-cards.module.ts +++ b/src/modules/banking/bank-cards/bank-cards.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; + import { AuthModule } from '../../auth/auth.module'; -import { BankCardsController } from './presentation/bank-cards.controller'; -import { BankCardsRepository } from './infrastructure/bank-cards.repository'; import { BankCardsService } from './application/bank-cards.service'; import { BankOperationsService } from './application/bank-operations.service'; import { ExpenseCategoriesService } from './application/expense-categories.service'; import { GetBankSummaryUseCase } from './application/get-bank-summary.use-case'; +import { BankCardsRepository } from './infrastructure/bank-cards.repository'; +import { BankCardsController } from './presentation/bank-cards.controller'; @Module({ imports: [AuthModule], controllers: [BankCardsController], diff --git a/src/modules/banking/bank-cards/domain/bank-card.calculations.ts b/src/modules/banking/bank-cards/domain/bank-card.calculations.ts index e3a5ae6..14ce726 100644 --- a/src/modules/banking/bank-cards/domain/bank-card.calculations.ts +++ b/src/modules/banking/bank-cards/domain/bank-card.calculations.ts @@ -22,10 +22,10 @@ export function bankBalance( cardId: string, opening: bigint, operations: Array<{ - kind: string; - cardId: string; - targetCardId: string | null; amountKopecks: bigint; + cardId: string; + kind: string; + targetCardId: null | string; }>, ): bigint { // ponytail: O(N) history fold; optimize only after measuring growing histories. @@ -35,19 +35,21 @@ export function bankBalance( if ( operation.kind === BankOperationKind.Expense || operation.kind === BankOperationKind.Transfer - ) + ) { balance -= operation.amountKopecks; - else if ( + } else if ( operation.kind === BankOperationKind.Topup || operation.kind === BankOperationKind.Refund - ) + ) { balance += operation.amountKopecks; + } } if ( operation.kind === BankOperationKind.Transfer && operation.targetCardId === cardId - ) + ) { balance += operation.amountKopecks; + } } return balance; } @@ -55,17 +57,17 @@ export function bankBalance( export function bankSummary( period: { from: string; to: string }, operations: Array<{ - kind: string; - date: string; amountKopecks: bigint; - categoryId: string | null; + categoryId: null | string; + date: string; + kind: string; }>, ) { let expenses = 0n; let refunds = 0n; const categories = new Map< string, - { categoryId: string; expenses: bigint; refunds: bigint; net: bigint } + { categoryId: string; expenses: bigint; net: bigint; refunds: bigint } >(); // ponytail: O(N) history fold; optimize only after measuring growing histories. for (const operation of operations) { @@ -73,16 +75,17 @@ export function bankSummary( if ( operation.kind !== BankOperationKind.Expense && operation.kind !== BankOperationKind.Refund - ) + ) { continue; + } const expense = - operation.kind === BankOperationKind.Expense - ? operation.amountKopecks - : 0n; + operation.kind === BankOperationKind.Expense ? + operation.amountKopecks : + 0n; const refund = - operation.kind === BankOperationKind.Refund - ? operation.amountKopecks - : 0n; + operation.kind === BankOperationKind.Refund ? + operation.amountKopecks : + 0n; expenses += expense; refunds += refund; if (operation.categoryId !== null) { diff --git a/src/modules/banking/bank-cards/domain/rub-money.spec.ts b/src/modules/banking/bank-cards/domain/rub-money.spec.ts index 383c230..7c34f36 100644 --- a/src/modules/banking/bank-cards/domain/rub-money.spec.ts +++ b/src/modules/banking/bank-cards/domain/rub-money.spec.ts @@ -39,8 +39,9 @@ describe('RUB money', () => { }); it('requires positive operation amounts, including at the maximum', () => { - for (const value of ['0', '-0.00', '-0.01']) + for (const value of ['0', '-0.00', '-0.01']) { expect(() => parseRubMoney({ currency: 'RUB', value }, true)).toThrow(); + } expect(parseRubMoney({ currency: 'RUB', value: '0.01' }, true)).toBe(1n); expect( parseRubMoney({ currency: 'RUB', value: '92233720368547758.07' }, true), diff --git a/src/modules/banking/bank-cards/domain/rub-money.ts b/src/modules/banking/bank-cards/domain/rub-money.ts index 2bb1b67..3fc9ccc 100644 --- a/src/modules/banking/bank-cards/domain/rub-money.ts +++ b/src/modules/banking/bank-cards/domain/rub-money.ts @@ -1,6 +1,7 @@ -import { decimalFromString, formatDecimal } from '../../../../common/decimal'; import type { Money } from '../../../../common/money.types'; +import { decimalFromString, formatDecimal } from '../../../../common/decimal'; + const MIN_KOPECKS = -9_223_372_036_854_775_808n; const MAX_KOPECKS = 9_223_372_036_854_775_807n; @@ -9,16 +10,20 @@ export function parseRubMoney(money: Money, positive = false): bigint { money?.currency !== 'RUB' || typeof money.value !== 'string' || money.value !== money.value.trim() - ) + ) { throw new TypeError('Expected RUB money with a decimal string'); + } const amount = decimalFromString(money.value); - if (amount.scale > 2) + if (amount.scale > 2) { throw new RangeError('RUB money supports at most two decimal places'); + } const kopecks = amount.value * 10n ** BigInt(2 - amount.scale); - if (kopecks < MIN_KOPECKS || kopecks > MAX_KOPECKS) + if (kopecks < MIN_KOPECKS || kopecks > MAX_KOPECKS) { throw new RangeError('RUB money is outside the signed 64-bit range'); - if (positive && kopecks <= 0n) + } + if (positive && kopecks <= 0n) { throw new RangeError('Operation amount must be positive'); + } return kopecks; } diff --git a/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts b/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts index 48fab86..3d675ad 100644 --- a/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts +++ b/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts @@ -1,14 +1,14 @@ -import { BankOperationKind } from '../domain/bank-operation-kind'; import { Injectable } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; + import { Prisma } from '../../../../../generated/prisma/client'; import { PrismaService } from '../../../../prisma/prisma.service'; - import { BankErrorCode, conflict, missing, } from '../application/bank-card-input'; +import { BankOperationKind } from '../domain/bank-operation-kind'; type Tx = Prisma.TransactionClient; const TRANSACTION_MAX_WAIT_MS = 10_000; const TRANSACTION_TIMEOUT_MS = 15_000; @@ -36,33 +36,37 @@ export class BankCardsRepository { }); } catch (error) { const code = - error instanceof Prisma.PrismaClientKnownRequestError - ? error.code - : ''; + error instanceof Prisma.PrismaClientKnownRequestError ? + error.code : + ''; const busy = code === 'P2034' || code === 'P2028' || (error instanceof Error && /SQLITE_BUSY|database is locked/.test(error.message)); - if ((busy || code === 'P2002') && attempt < MAX_WRITE_RETRY_COUNT) + if ((busy || code === 'P2002') && attempt < MAX_WRITE_RETRY_COUNT) { continue; + } if (busy) conflict(BankErrorCode.WriteConflict); if (code === 'P2002') conflict(BankErrorCode.DuplicateCategory); throw error; } } } + async card(tx: Tx, userId: string, id: string, active = false) { const card = await tx.bankCard.findFirst({ where: { id, userId } }); if (!card) return missing(); if (active && card.archivedAt) conflict(BankErrorCode.CardArchived); return card; } + async operation(tx: Tx, userId: string, id: string) { const row = await tx.bankCardOperation.findFirst({ where: { id, userId } }); if (!row) return missing(); return row; } + async seed(tx: Tx, userId: string) { for (const [index, label] of seeds.entries()) { const seedKey = index === seeds.length - 1 ? 'other' : String(index); @@ -70,7 +74,7 @@ export class BankCardsRepository { !(await tx.expenseCategory.findUnique({ where: { userId_seedKey: { userId, seedKey } }, })) - ) + ) { await tx.expenseCategory.create({ data: { id: randomUUID(), @@ -80,8 +84,10 @@ export class BankCardsRepository { nameKey: label.toLocaleLowerCase('ru-RU'), }, }); + } } } + summaryData( userId: string, period: { from: string; to: string }, diff --git a/src/modules/banking/bank-cards/presentation/bank-card.dto.ts b/src/modules/banking/bank-cards/presentation/bank-card.dto.ts index bb23f56..dfc9ac7 100644 --- a/src/modules/banking/bank-cards/presentation/bank-card.dto.ts +++ b/src/modules/banking/bank-cards/presentation/bank-card.dto.ts @@ -1,9 +1,3 @@ -import { - MAX_CARD_NAME_LENGTH, - MAX_CATEGORY_NAME_LENGTH, - MAX_OPERATION_DESCRIPTION_LENGTH, -} from '../domain/bank-card-limits'; -import { BankOperationKind } from '../domain/bank-operation-kind'; import { ApiProperty, ApiPropertyOptional, @@ -28,7 +22,14 @@ import { ValidateIf, ValidateNested, } from 'class-validator'; + import { MoneyDto } from '../../../../common/money.dto'; +import { + MAX_CARD_NAME_LENGTH, + MAX_CATEGORY_NAME_LENGTH, + MAX_OPERATION_DESCRIPTION_LENGTH, +} from '../domain/bank-card-limits'; +import { BankOperationKind } from '../domain/bank-operation-kind'; export const DEFAULT_BANK_PAGE_SIZE = 50; const MAX_BANK_PAGE_SIZE = 100; @@ -41,6 +42,7 @@ export class RubMoneyInputDto { @ApiProperty({ enum: ['RUB'] }) @IsIn(['RUB']) declare currency: string; @ApiProperty({ pattern: '^-?[0-9]+(\\.[0-9]{1,2})?$' }) @IsString() + // eslint-disable-next-line security/detect-unsafe-regex -- Anchored decimal grammar: the literal decimal point separates disjoint digit runs; backtracking is linear. @Matches(/^-?\d+(\.\d{1,2})?$/) declare value: string; } @@ -52,6 +54,7 @@ export class CreateBankCardDto { @MinLength(1) @MaxLength(MAX_CARD_NAME_LENGTH) declare name: string; + @ApiPropertyOptional({ type: String, nullable: true, @@ -61,20 +64,24 @@ export class CreateBankCardDto { @Transform(trim) @IsString() @MaxLength(MAX_CARD_NAME_LENGTH) - bankName?: string | null; + bankName?: null | string; + @ApiPropertyOptional({ type: String, nullable: true, pattern: '^[0-9]{4}$' }) @IsOptional() - @Matches(/^[0-9]{4}$/) - lastFour?: string | null; + @Matches(/^\d{4}$/) + lastFour?: null | string; + @ApiPropertyOptional({ enum: bankPaymentSystems, nullable: true }) @IsOptional() @IsIn(bankPaymentSystems) - paymentSystem?: string | null; + paymentSystem?: null | string; + @ApiProperty({ type: RubMoneyInputDto }) @IsDefined() @ValidateNested() @Type(() => RubMoneyInputDto) declare openingBalance: MoneyDto; + @ApiProperty({ format: 'date' }) @IsString() @Matches(/^\d{4}-\d{2}-\d{2}$/) @@ -106,28 +113,34 @@ export class CreateBankOperationDto { @ApiProperty({ enum: bankOperationKinds }) @IsIn(bankOperationKinds) declare kind: BankOperationKind; + @ApiProperty({ format: 'uuid' }) @IsUUID() declare cardId: string; @ApiPropertyOptional({ format: 'uuid' }) @ValidateIf((_object, value: unknown) => value !== undefined) @IsUUID() targetCardId?: string; + @ApiPropertyOptional({ format: 'uuid' }) @ValidateIf((_object, value: unknown) => value !== undefined) @IsUUID() categoryId?: string; + @ApiPropertyOptional({ format: 'uuid' }) @ValidateIf((_object, value: unknown) => value !== undefined) @IsUUID() originalExpenseId?: string; + @ApiProperty({ type: RubMoneyInputDto }) @IsDefined() @ValidateNested() @Type(() => RubMoneyInputDto) declare amount: MoneyDto; + @ApiProperty({ format: 'date' }) @IsString() @Matches(/^\d{4}-\d{2}-\d{2}$/) declare date: string; + @ApiPropertyOptional({ maxLength: MAX_OPERATION_DESCRIPTION_LENGTH }) @ValidateIf((_object, value: unknown) => value !== undefined) @IsString() @@ -153,6 +166,7 @@ export class BankPageQueryDto { @IsInt() @Min(1) page?: number; + @ApiPropertyOptional({ minimum: 1, maximum: MAX_BANK_PAGE_SIZE, @@ -173,6 +187,7 @@ export class BankPeriodQueryDto { @IsOptional() @Matches(/^\d{4}-(0[1-9]|1[0-2])$/) month?: string; + @ApiPropertyOptional({ format: 'date', description: 'Inclusive start date; requires to.', @@ -180,6 +195,7 @@ export class BankPeriodQueryDto { @IsOptional() @Matches(/^\d{4}-\d{2}-\d{2}$/) from?: string; + @ApiPropertyOptional({ format: 'date', description: 'Inclusive end date; requires from.', @@ -215,16 +231,20 @@ export class BankCardDto { @ApiProperty({ type: String }) declare id: string; @ApiProperty({ type: String }) declare name: string; @ApiProperty({ type: String, nullable: true }) declare bankName: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare lastFour: - string | null; + null | string; + @ApiProperty({ enum: bankPaymentSystems, nullable: true }) - declare paymentSystem: string | null; + declare paymentSystem: null | string; + @ApiProperty({ type: MoneyDto }) declare openingBalance: MoneyDto; @ApiProperty({ type: String }) declare openingDate: string; @ApiProperty({ type: MoneyDto }) declare balance: MoneyDto; @ApiProperty({ type: String, nullable: true }) declare archivedAt: - string | null; + null | string; + @ApiProperty({ type: Number }) declare version: number; } export class BankOperationDto { @@ -233,27 +253,35 @@ export class BankOperationDto { @ApiProperty({ type: String }) declare cardId: string; @ApiProperty({ type: String }) declare cardName: string; @ApiProperty({ type: String, nullable: true }) declare targetCardId: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare targetCardName: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare categoryId: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare categoryName: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare originalExpenseId: - string | null; + null | string; + @ApiProperty({ type: MoneyDto }) declare amount: MoneyDto; @ApiProperty({ type: MoneyDto, nullable: true }) declare availableRefund: MoneyDto | null; + @ApiProperty({ type: String }) declare date: string; @ApiProperty({ type: String, nullable: true }) declare description: - string | null; + null | string; + @ApiProperty({ type: Number }) declare version: number; @ApiProperty({ type: String }) declare createdAt: string; } export class BankOperationsPageDto { @ApiProperty({ type: BankOperationDto, isArray: true }) declare items: BankOperationDto[]; + @ApiProperty({ type: Number }) declare page: number; @ApiProperty({ type: Number }) declare total: number; @ApiProperty({ type: Boolean }) declare hasNext: boolean; @@ -269,7 +297,7 @@ export class BankSummaryDto { @ApiPropertyOptional({ type: String }) month?: string; @ApiPropertyOptional({ format: 'date' }) from?: string; @ApiPropertyOptional({ format: 'date' }) to?: string; - @ApiProperty({ type: String, nullable: true }) declare cardId: string | null; + @ApiProperty({ type: String, nullable: true }) declare cardId: null | string; @ApiProperty({ type: MoneyDto }) declare expenses: MoneyDto; @ApiProperty({ type: MoneyDto }) declare refunds: MoneyDto; @ApiProperty({ type: MoneyDto }) declare net: MoneyDto; diff --git a/src/modules/banking/bank-cards/presentation/bank-cards.controller.ts b/src/modules/banking/bank-cards/presentation/bank-cards.controller.ts index e484e23..0a3e19a 100644 --- a/src/modules/banking/bank-cards/presentation/bank-cards.controller.ts +++ b/src/modules/banking/bank-cards/presentation/bank-cards.controller.ts @@ -1,3 +1,5 @@ +import type { Request } from 'express'; + import { Body, Controller, @@ -21,12 +23,16 @@ import { ApiTags, getSchemaPath, } from '@nestjs/swagger'; -import type { Request } from 'express'; + import { ApiProblemResponses, ProblemDetailsDto, } from '../../../../common/decorators/api-safe-error-response.decorator'; import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api'; +import { BankCardsService } from '../application/bank-cards.service'; +import { BankOperationsService } from '../application/bank-operations.service'; +import { ExpenseCategoriesService } from '../application/expense-categories.service'; +import { GetBankSummaryUseCase } from '../application/get-bank-summary.use-case'; import { BankCardDto, BankCardsQueryDto, @@ -45,10 +51,6 @@ import { UpdateBankOperationDto, UpdateExpenseCategoryDto, } from './bank-card.dto'; -import { BankCardsService } from '../application/bank-cards.service'; -import { BankOperationsService } from '../application/bank-operations.service'; -import { ExpenseCategoriesService } from '../application/expense-categories.service'; -import { GetBankSummaryUseCase } from '../application/get-bank-summary.use-case'; type AuthRequest = Request & { user: AccessPayload }; @ApiResponse({ status: 409, @@ -82,26 +84,31 @@ export class BankCardsController { private readonly expenseCategories: ExpenseCategoriesService, private readonly bankSummary: GetBankSummaryUseCase, ) {} + @Get() @ApiOkResponse({ type: BankCardDto, isArray: true }) list(@Req() req: AuthRequest, @Query() query: BankCardsQueryDto) { return this.cards.list(req.user.sub, query.includeArchived === 'true'); } + @Post() @ApiCreatedResponse({ type: BankCardDto }) create(@Req() req: AuthRequest, @Body() body: CreateBankCardDto) { return this.cards.createCard(req.user.sub, body); } + @Get('categories') @ApiOkResponse({ type: ExpenseCategoryDto, isArray: true }) categories(@Req() req: AuthRequest) { return this.expenseCategories.categories(req.user.sub); } + @Post('categories') @ApiCreatedResponse({ type: ExpenseCategoryDto }) createCategory(@Req() req: AuthRequest, @Body() body: CategoryNameDto) { return this.expenseCategories.createCategory(req.user.sub, body.name); } + @Patch('categories/:id') @ApiOkResponse({ type: ExpenseCategoryDto }) updateCategory( @@ -116,11 +123,13 @@ export class BankCardsController { body.version, ); } + @Get('summary') @ApiOkResponse({ type: BankSummaryDto }) summary(@Req() req: AuthRequest, @Query() query: BankSummaryQueryDto) { return this.bankSummary.execute(req.user.sub, query); } + @Post('operations') @ApiCreatedResponse({ type: BankOperationDto }) createOperation( @@ -129,11 +138,13 @@ export class BankCardsController { ) { return this.operations.createOperation(req.user.sub, body); } + @Get('operations/:id') @ApiOkResponse({ type: BankOperationDto }) operation(@Req() req: AuthRequest, @Param('id') id: string) { return this.operations.getOperation(req.user.sub, id); } + @Patch('operations/:id') @ApiOkResponse({ type: BankOperationDto }) updateOperation( @@ -143,6 +154,7 @@ export class BankCardsController { ) { return this.operations.updateOperation(req.user.sub, id, body); } + @Delete('operations/:id') @HttpCode(HttpStatus.NO_CONTENT) @ApiNoContentResponse() @@ -153,6 +165,7 @@ export class BankCardsController { ) { return this.operations.deleteOperation(req.user.sub, id, query.version); } + @Get(':id/operations') @ApiOkResponse({ type: BankOperationsPageDto }) history( @@ -162,6 +175,7 @@ export class BankCardsController { ) { return this.operations.history(req.user.sub, id, query); } + @Get(':id/expenses') @ApiOkResponse({ type: BankOperationsPageDto }) expenses( @@ -171,11 +185,13 @@ export class BankCardsController { ) { return this.operations.history(req.user.sub, id, query, true); } + @Get(':id') @ApiOkResponse({ type: BankCardDto }) get(@Req() req: AuthRequest, @Param('id') id: string) { return this.cards.get(req.user.sub, id); } + @Patch(':id') @ApiOkResponse({ type: BankCardDto }) update( diff --git a/src/modules/investments/brokerage-accounts/brokerage-account.dto.ts b/src/modules/investments/brokerage-accounts/brokerage-account.dto.ts index 22c8506..214573d 100644 --- a/src/modules/investments/brokerage-accounts/brokerage-account.dto.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-account.dto.ts @@ -1,6 +1,7 @@ -import { InvestmentResourceMetaDto } from '../investment-resource.dto'; import { ApiProperty } from '@nestjs/swagger'; + import { MoneyDto } from '../../../common/money.dto'; +import { InvestmentResourceMetaDto } from '../investment-resource.dto'; export class PortfolioSummaryDto { @ApiProperty({ type: MoneyDto, isArray: true }) @@ -40,15 +41,18 @@ export class BrokerageAccountDto { 'BROKERAGE' | 'INDIVIDUAL_INVESTMENT' | 'INVEST_BOX' | 'MONEY_MARKET_FUND'; @ApiProperty({ type: String, nullable: true, format: 'date' }) - declare openedOn: string | null; + declare openedOn: null | string; + // eslint-disable-next-line @darraghor/nestjs-typed/api-property-matches-property-optionality -- Preserve the published required nullable schema; partial internal models may omit the field. @ApiProperty({ type: PortfolioSummaryDto, nullable: true }) - portfolioSummary?: PortfolioSummaryDto | null; + portfolioSummary?: null | PortfolioSummaryDto; } export class BrokerageAccountsResourceDto { + // eslint-disable-next-line @darraghor/nestjs-typed/api-property-returning-array-should-set-array -- Nullable array union requires isArray in the explicit wire schema. @ApiProperty({ type: BrokerageAccountDto, isArray: true, nullable: true }) declare data: BrokerageAccountDto[] | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } diff --git a/src/modules/investments/brokerage-accounts/brokerage-accounts.controller.ts b/src/modules/investments/brokerage-accounts/brokerage-accounts.controller.ts index 564029c..85e2bce 100644 --- a/src/modules/investments/brokerage-accounts/brokerage-accounts.controller.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-accounts.controller.ts @@ -1,9 +1,11 @@ -import { InvestmentReadQueryDto } from '../investment-resource.dto'; -import { Controller, Get, Req, Query, UseGuards } from '@nestjs/common'; -import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import type { Request } from 'express'; + +import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; + import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api'; +import { InvestmentReadQueryDto } from '../investment-resource.dto'; import { BrokerageAccountsResourceDto } from './brokerage-account.dto'; import { BrokerageAccountsService } from './brokerage-accounts.service'; diff --git a/src/modules/investments/brokerage-accounts/brokerage-accounts.module.ts b/src/modules/investments/brokerage-accounts/brokerage-accounts.module.ts index 267ea01..35f4e9f 100644 --- a/src/modules/investments/brokerage-accounts/brokerage-accounts.module.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-accounts.module.ts @@ -1,7 +1,8 @@ -import { PortfolioModule } from '../portfolio/portfolio.module'; import { Module } from '@nestjs/common'; + import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { AuthModule } from '../../auth/auth.module'; +import { PortfolioModule } from '../portfolio/portfolio.module'; import { BrokerageAccountsController } from './brokerage-accounts.controller'; import { BrokerageAccountsService } from './brokerage-accounts.service'; diff --git a/src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts index 2d20622..a9473d3 100644 --- a/src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts @@ -1,8 +1,8 @@ jest.mock('../../../prisma/prisma.service', () => ({ PrismaService: class {}, })); -import { BrokerageAccountsService } from './brokerage-accounts.service'; import { investmentResourceMeta } from '../investment-resource.mapper'; +import { BrokerageAccountsService } from './brokerage-accounts.service'; jest.mock('../portfolio/application/portfolio.service', () => ({ PortfolioService: class PortfolioService {}, diff --git a/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts index b8a0111..d2c1234 100644 --- a/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts @@ -1,7 +1,8 @@ import { Injectable } from '@nestjs/common'; import { createHash } from 'node:crypto'; -import { PortfolioService } from '../portfolio/portfolio.api'; + import { investmentResourceMeta } from '../investment-resource.mapper'; +import { PortfolioService } from '../portfolio/portfolio.api'; import { BrokerageAccountDto } from './brokerage-account.dto'; @Injectable() @@ -11,15 +12,15 @@ export class BrokerageAccountsService { async list(userId: string, observe = false) { const saved = await this.portfolio.saved(userId, 'all', observe); const data: BrokerageAccountDto[] | null = - saved.membership.availability === 'missing' - ? null - : saved.accounts.map((account) => ({ + saved.membership.availability === 'missing' ? + null : + saved.accounts.map((account) => ({ id: account.id, name: account.name, type: account.type as BrokerageAccountDto['type'], openedOn: account.openedOn, - portfolioSummary: account.snapshot - ? { + portfolioSummary: account.snapshot ? + { totalsByCurrency: account.snapshot.totalsByCurrency, stockPositionCount: account.snapshot.positions.filter( (position) => position.type === 'STOCK', @@ -27,8 +28,8 @@ export class BrokerageAccountsService { bondPositionCount: account.snapshot.positions.filter( (position) => position.type === 'BOND', ).length, - } - : null, + } : + null, })); return { data, @@ -46,18 +47,18 @@ export class BrokerageAccountsService { ) .digest('base64url'), availability: - data === null - ? 'missing' - : saved.accounts.some((account) => !account.snapshot) - ? 'partial' - : 'complete', - fetchedAt: saved.membership.lastSuccessfulAt - ? new Date(saved.membership.lastSuccessfulAt) - : null, + data === null ? + 'missing' : + saved.accounts.some((account) => !account.snapshot) ? + 'partial' : + 'complete', + fetchedAt: saved.membership.lastSuccessfulAt ? + new Date(saved.membership.lastSuccessfulAt) : + null, freshForMs: 300_000, - work: saved.membership.sync.id - ? { ...saved.membership.sync, id: saved.membership.sync.id } - : null, + work: saved.membership.sync.id ? + { ...saved.membership.sync, id: saved.membership.sync.id } : + null, dependencies: saved.accounts.map((account) => account.meta), }), }; diff --git a/src/modules/investments/income/income-analytics.controller.ts b/src/modules/investments/income/income-analytics.controller.ts index 5d08d42..d72f624 100644 --- a/src/modules/investments/income/income-analytics.controller.ts +++ b/src/modules/investments/income/income-analytics.controller.ts @@ -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') diff --git a/src/modules/investments/income/income-analytics.dto.ts b/src/modules/investments/income/income-analytics.dto.ts index d133732..a398d59 100644 --- a/src/modules/investments/income/income-analytics.dto.ts +++ b/src/modules/investments/income/income-analytics.dto.ts @@ -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; } diff --git a/src/modules/investments/income/income-analytics.service.spec.ts b/src/modules/investments/income/income-analytics.service.spec.ts index fa7c3d9..0aef960 100644 --- a/src/modules/investments/income/income-analytics.service.spec.ts +++ b/src/modules/investments/income/income-analytics.service.spec.ts @@ -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', () => { diff --git a/src/modules/investments/income/income-analytics.service.ts b/src/modules/investments/income/income-analytics.service.ts index 824f1eb..76fd624 100644 --- a/src/modules/investments/income/income-analytics.service.ts +++ b/src/modules/investments/income/income-analytics.service.ts @@ -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}`; } diff --git a/src/modules/investments/income/income-period.spec.ts b/src/modules/investments/income/income-period.spec.ts index 36a17bd..31afc16 100644 --- a/src/modules/investments/income/income-period.spec.ts +++ b/src/modules/investments/income/income-period.spec.ts @@ -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', () => { diff --git a/src/modules/investments/income/income-period.ts b/src/modules/investments/income/income-period.ts index e6e242e..c7c280d 100644 --- a/src/modules/investments/income/income-period.ts +++ b/src/modules/investments/income/income-period.ts @@ -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); diff --git a/src/modules/investments/income/income.module.ts b/src/modules/investments/income/income.module.ts index 3185170..addc0b5 100644 --- a/src/modules/investments/income/income.module.ts +++ b/src/modules/investments/income/income.module.ts @@ -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'; diff --git a/src/modules/investments/instruments/application/candle.service.ts b/src/modules/investments/instruments/application/candle.service.ts index aacc939..86d7ba5 100644 --- a/src/modules/investments/instruments/application/candle.service.ts +++ b/src/modules/investments/instruments/application/candle.service.ts @@ -1,13 +1,13 @@ -import { planInvestmentScreen } from '../../investment-sync-plan'; -import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; import { Injectable, NotFoundException, ServiceUnavailableException, } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; -import { MILLISECONDS_PER_DAY } from '../../../../common/time'; + import { formatDecimal } from '../../../../common/decimal'; +import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; +import { MILLISECONDS_PER_DAY } from '../../../../common/time'; import { decimal } from '../../../../integrations/tbank/decimal'; import { TbankAccessService, @@ -16,35 +16,36 @@ import { type TbankSyncAttempt, } from '../../../../integrations/tbank/tbank.api'; import { investmentResourceMeta } from '../../investment-resource.mapper'; -import { CatalogEnrichmentService } from './catalog-enrichment.service'; -import { CandleRepository } from '../infrastructure/candle.repository'; +import { planInvestmentScreen } from '../../investment-sync-plan'; import { candleGaps, + type CandleRange, candleWindows, utcDay, - type CandleRange, } from '../domain/candle-coverage'; +import { CandleRepository } from '../infrastructure/candle.repository'; +import { CatalogEnrichmentService } from './catalog-enrichment.service'; import { timestamp } from './instrument-market'; type CandleInput = { - uid: string; - instrumentType: 'share' | 'bond'; - period: 'month' | 'halfYear' | 'year' | 'all'; + instrumentType: 'bond' | 'share'; + period: 'all' | 'halfYear' | 'month' | 'year'; range?: CandleRange; + uid: string; }; type Producer = { - input: CandleInput; attempt: TbankSyncAttempt; - now: Date; force: boolean; - priority: number; + input: CandleInput; + now: Date; observers: TbankSyncAttempt[]; pass?: { - now: Date; force: boolean; - ranges: CandleRange[]; nextWindow: number; + now: Date; + ranges: CandleRange[]; }; + priority: number; }; const months = { month: 1, halfYear: 6, year: 12 }; const tailFreshForMs = 60_000; @@ -61,18 +62,20 @@ function rangeFor( const from = utcDay(now); const history = timestamp(instrument.first_1dayCandleDate); if (input.period === 'all') { - if (!history) + if (!history) { throw new ServiceUnavailableException({ code: - input.instrumentType === 'share' - ? 'STOCK_HISTORY_START_UNAVAILABLE' - : 'BOND_HISTORY_START_UNAVAILABLE', + input.instrumentType === 'share' ? + 'STOCK_HISTORY_START_UNAVAILABLE' : + 'BOND_HISTORY_START_UNAVAILABLE', }); + } from.setTime(utcDay(new Date(history)).valueOf()); } else { from.setUTCMonth(from.getUTCMonth() - months[input.period]); - if (history && Date.parse(history) > from.valueOf()) + if (history && Date.parse(history) > from.valueOf()) { from.setTime(utcDay(new Date(history)).valueOf()); + } } return { from, to: new Date(utcDay(now).valueOf() + MILLISECONDS_PER_DAY) }; } @@ -102,16 +105,18 @@ export class CandleService { true, ['detail'], ); - if (detail.meta.availability === 'complete' && !detail.instrument) + if (detail.meta.availability === 'complete' && !detail.instrument) { throw new NotFoundException(); + } const now = new Date(); - const range = detail.instrument - ? rangeFor(input, detail.instrument, now) - : null; + const range = detail.instrument ? + rangeFor(input, detail.instrument, now) : + null; const saved = range ? await this.repository.read(input.uid, range) : null; const gaps = range && saved ? candleGaps(range, saved.coverage, now) : []; - if (!observe && (!saved || gaps.length)) + if (!observe && (!saved || gaps.length)) { await this.request(userId, revision, input, now); + } const work = await this.repository.work(userId, revision, scope(input)); const fetchedAt = saved?.coverage @@ -129,14 +134,14 @@ export class CandleService { }); return { data: - !saved || !covered - ? null - : { + !saved || !covered ? + null : + { candles: saved.data, currency: - input.instrumentType === 'share' - ? detail.instrument?.currency?.toUpperCase() || null - : null, + input.instrumentType === 'share' ? + detail.instrument?.currency?.toUpperCase() || null : + null, historyFrom: timestamp(detail?.instrument?.first_1dayCandleDate), observedAt: fetchedAt?.toISOString() ?? now.toISOString(), }, @@ -149,8 +154,8 @@ export class CandleService { work, coverage: { requested: range ? [publicRange(range)] : [], - completeRanges: range - ? completeCoverage.flatMap((row) => { + completeRanges: range ? + completeCoverage.flatMap((row) => { const from = new Date( Math.max(row.from.valueOf(), range.from.valueOf()), ); @@ -158,22 +163,22 @@ export class CandleService { Math.min(row.to.valueOf(), range.to.valueOf()), ); return from < to ? [publicRange({ from, to })] : []; - }) - : [], + }) : + [], missingRanges: missing.map(publicRange), asOf: fetchedAt?.toISOString() ?? null, - historyBoundary: timestamp(detail?.instrument?.first_1dayCandleDate) - ? 'known' - : 'unknown', - historyState: complete - ? 'complete' - : work?.state === 'failed' - ? 'failed' - : work?.state === 'running' - ? 'loading' - : covered - ? 'partial' - : 'missing', + historyBoundary: timestamp(detail?.instrument?.first_1dayCandleDate) ? + 'known' : + 'unknown', + historyState: complete ? + 'complete' : + work?.state === 'failed' ? + 'failed' : + work?.state === 'running' ? + 'loading' : + covered ? + 'partial' : + 'missing', }, dependencies: [ detail.meta, @@ -183,9 +188,9 @@ export class CandleService { dataRevision: String(row.dataRevision), availability: 'complete', fetchedAt: row.fetchedAt, - freshForMs: row.isComplete - ? Number.POSITIVE_INFINITY - : tailFreshForMs, + freshForMs: row.isComplete ? + Number.POSITIVE_INFINITY : + tailFreshForMs, work: null, }), ), @@ -197,13 +202,14 @@ export class CandleService { prefetch( userId: string, revision: string, - positions: { instrumentUid?: string | null; type: string }[], + positions: { instrumentUid?: null | string; type: string }[], ) { const key = JSON.stringify([ userId, revision, positions .map((position) => [position.instrumentUid, position.type]) + // 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(), ]); const existing = this.prefetches.get(key); @@ -218,14 +224,14 @@ export class CandleService { private async prefetchPositions( userId: string, revision: string, - positions: { instrumentUid?: string | null; type: string }[], + positions: { instrumentUid?: null | string; type: string }[], ) { const now = new Date(); const unique = new Map( positions .filter( (position) => - position.instrumentUid && ['STOCK', 'BOND'].includes(position.type), + position.instrumentUid && ['BOND', 'STOCK'].includes(position.type), ) .map((position) => [position.instrumentUid ?? '', position]), ); @@ -267,7 +273,7 @@ export class CandleService { instruments: [ { uid, - type: position.type as 'STOCK' | 'BOND', + type: position.type as 'BOND' | 'STOCK', historyFrom: history ? new Date(history) : null, }, ], @@ -280,9 +286,9 @@ export class CandleService { ) { const input = { uid, instrumentType, period: 'all' as const }; const range = - detail?.instrument && history - ? rangeFor(input, detail.instrument, now) - : null; + detail?.instrument && history ? + rangeFor(input, detail.instrument, now) : + null; if ( !range || candleGaps( @@ -290,8 +296,9 @@ export class CandleService { coverage.filter((row) => row.instrumentUid === uid), now, ).length - ) + ) { await this.request(userId, revision, input, now, false, 2); + } } } } @@ -379,6 +386,7 @@ export class CandleService { } const { ranges, now, force } = pass; const index = pass.nextWindow; + // eslint-disable-next-line security/detect-object-injection -- The index is the internal numeric window cursor. let window = ranges[index]; if (window && !force) { const latest = await this.repository.read(input.uid, window); @@ -386,6 +394,7 @@ export class CandleService { .flatMap(candleWindows) .reverse(); ranges.splice(index, 1, ...gaps); + // eslint-disable-next-line security/detect-object-injection -- The index is the internal numeric window cursor. window = ranges[index]; } if (window) { @@ -408,8 +417,9 @@ export class CandleService { }, ]; }); - if (!(await this.repository.stage(attempt, window, rows, now))) + if (!(await this.repository.stage(attempt, window, rows, now))) { return false; + } if ( !force && !(await this.repository.publish( @@ -417,8 +427,9 @@ export class CandleService { { uid: input.uid, ranges: [window] }, false, )) - ) + ) { return false; + } pass.nextWindow = index + 1; if (pass.nextWindow < ranges.length) return null; } @@ -441,16 +452,18 @@ export class CandleService { } success = result; } catch (error) { - if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') + if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') { await this.access.rejectMembershipToken(producer.attempt); + } if (error instanceof ServiceUnavailableException) { const response = error.getResponse(); if ( typeof response === 'object' && 'code' in response && typeof response.code === 'string' - ) + ) { code = response.code; + } } } // Seal publication with reservations so a late forced join cannot be @@ -458,8 +471,9 @@ export class CandleService { const finished = this.reservations.then(async () => { const pass = producer.pass; if (success && pass) { - if (producer.force && (!pass.force || producer.now > pass.now)) + if (producer.force && (!pass.force || producer.now > pass.now)) { return false; + } try { success = await this.repository.publish(producer.attempt, { uid: producer.input.uid, @@ -478,8 +492,9 @@ export class CandleService { observer, producer.attempt, )) - ) + ) { await this.repository.fail(observer, code); + } this.active.delete(observer.attemptId); } this.producers.delete(scope(producer.input)); @@ -490,7 +505,7 @@ export class CandleService { this.queue.push(producer); continue; } - if (success && !producer.force && producer.input.period !== 'all') + if (success && !producer.force && producer.input.period !== 'all') { await this.request( producer.attempt.userId, producer.attempt.revision, @@ -499,6 +514,7 @@ export class CandleService { false, 2, ); + } } } catch { this.logger.event('error', 'candles.persistence-failed', { diff --git a/src/modules/investments/instruments/application/catalog-discovery.service.ts b/src/modules/investments/instruments/application/catalog-discovery.service.ts index ae06df5..0c2b417 100644 --- a/src/modules/investments/instruments/application/catalog-discovery.service.ts +++ b/src/modules/investments/instruments/application/catalog-discovery.service.ts @@ -1,10 +1,10 @@ -import { CatalogEnrichmentService } from './catalog-enrichment.service'; import { + ConflictException, Injectable, NotFoundException, - ConflictException, } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; + import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; import { TbankAccessService, @@ -17,9 +17,10 @@ import { investmentResourceMeta } from '../../investment-resource.mapper'; import { CatalogDiscoveryRepository, catalogQuery, - catalogScope, type CatalogQuery, + catalogScope, } from '../infrastructure/catalog-discovery.repository'; +import { CatalogEnrichmentService } from './catalog-enrichment.service'; import { catalogSearchLimit, selectCatalogInstruments, @@ -28,10 +29,10 @@ import { const tickerAmbiguityLimit = 2; type Producer = { - input: CatalogQuery; attempt: TbankSyncAttempt; - observers: TbankSyncAttempt[]; force: boolean; + input: CatalogQuery; + observers: TbankSyncAttempt[]; }; @Injectable() export class CatalogDiscoveryService { @@ -43,8 +44,9 @@ export class CatalogDiscoveryService { private running = false; private readonly completions = new Map< string, - { workId: string; done: Promise; resolve: () => void } + { done: Promise; resolve: () => void; workId: string } >(); + constructor( private readonly repository: CatalogDiscoveryRepository, private readonly access: TbankAccessService, @@ -73,14 +75,14 @@ export class CatalogDiscoveryService { async search(userId: string, input: CatalogQuery, observe = false) { const discovery = await this.read(userId, input, observe); - const sources = discovery.data - ? await this.enrichment.readMany( + const sources = discovery.data ? + await this.enrichment.readMany( userId, input.instrumentType, discovery.data.map((item) => item.uid), observe, - ) - : []; + ) : + []; const dependencies = [ discovery.meta, ...sources.map((source) => source.meta), @@ -108,22 +110,22 @@ export class CatalogDiscoveryService { .map((dependency) => dependency.dataRevision) .join('/'), availability: - discovery.data === null - ? 'missing' - : sources.every((source) => source.meta.availability === 'complete') - ? 'complete' - : 'partial', - fetchedAt: discovery.meta.lastSuccessfulAt - ? new Date(discovery.meta.lastSuccessfulAt) - : null, + discovery.data === null ? + 'missing' : + sources.every((source) => source.meta.availability === 'complete') ? + 'complete' : + 'partial', + fetchedAt: discovery.meta.lastSuccessfulAt ? + new Date(discovery.meta.lastSuccessfulAt) : + null, freshForMs: tbankSourceTtlMs.catalog, - work: discovery.meta.sync.id - ? { + work: discovery.meta.sync.id ? + { id: discovery.meta.sync.id, state: discovery.meta.sync.state, errorCode: discovery.meta.sync.errorCode, - } - : null, + } : + null, dependencies, }), }; @@ -131,7 +133,7 @@ export class CatalogDiscoveryService { async detail( userId: string, - instrumentType: 'share' | 'bond', + instrumentType: 'bond' | 'share', uid: string, observe = false, ) { @@ -143,22 +145,24 @@ export class CatalogDiscoveryService { ['detail'], ); const source = sources[0]; - if (source.meta.availability === 'complete' && !source.instrument) + if (source.meta.availability === 'complete' && !source.instrument) { throw new NotFoundException(); + } const instrument = source.instrument; let slug = uid; const dependencies = [source.meta]; if ( instrument?.ticker && - /^[A-Za-z0-9][A-Za-z0-9._@+-]{0,79}$/.test(instrument.ticker) + /^[A-Z0-9][\w.@+-]{0,79}$/i.test(instrument.ticker) ) { const alias = await this.read( userId, { instrumentType, query: instrument.ticker, exactTicker: true }, observe, ); - if (alias.data?.length === 1 && alias.data[0].uid === uid) + if (alias.data?.length === 1 && alias.data[0].uid === uid) { slug = instrument.ticker.toUpperCase(); + } dependencies.push(alias.meta); } return { @@ -169,17 +173,17 @@ export class CatalogDiscoveryService { .map((dependency) => dependency.dataRevision) .join('/'), availability: source.meta.availability, - fetchedAt: source.meta.lastSuccessfulAt - ? new Date(source.meta.lastSuccessfulAt) - : null, + fetchedAt: source.meta.lastSuccessfulAt ? + new Date(source.meta.lastSuccessfulAt) : + null, freshForMs: tbankSourceTtlMs.metadata, - work: source.meta.sync.id - ? { + work: source.meta.sync.id ? + { id: source.meta.sync.id, state: source.meta.sync.state, errorCode: source.meta.sync.errorCode, - } - : null, + } : + null, dependencies, }), }; @@ -187,7 +191,7 @@ export class CatalogDiscoveryService { async byTicker( userId: string, - instrumentType: 'share' | 'bond', + instrumentType: 'bond' | 'share', ticker: string, observe = false, ) { @@ -197,38 +201,39 @@ export class CatalogDiscoveryService { observe, ); if (lookup.data?.length === 0) throw new NotFoundException(); - if (lookup.data && lookup.data.length > 1) + if (lookup.data && lookup.data.length > 1) { throw new ConflictException({ code: - instrumentType === 'share' - ? 'STOCK_TICKER_AMBIGUOUS' - : 'BOND_TICKER_AMBIGUOUS', + instrumentType === 'share' ? + 'STOCK_TICKER_AMBIGUOUS' : + 'BOND_TICKER_AMBIGUOUS', }); - const result = lookup.data - ? await this.detail(userId, instrumentType, lookup.data[0].uid, observe) - : null; + } + const result = lookup.data ? + await this.detail(userId, instrumentType, lookup.data[0].uid, observe) : + null; const dependencies = [lookup.meta, ...(result ? [result.meta] : [])]; return { - data: result?.data - ? { ...result.data, slug: ticker.trim().toUpperCase() } - : null, + data: result?.data ? + { ...result.data, slug: ticker.trim().toUpperCase() } : + null, meta: investmentResourceMeta({ resourceKey: `${instrumentType}/ticker/${ticker.trim().toUpperCase()}`, dataRevision: dependencies .map((dependency) => dependency.dataRevision) .join('/'), availability: result?.meta.availability ?? 'missing', - fetchedAt: lookup.meta.lastSuccessfulAt - ? new Date(lookup.meta.lastSuccessfulAt) - : null, + fetchedAt: lookup.meta.lastSuccessfulAt ? + new Date(lookup.meta.lastSuccessfulAt) : + null, freshForMs: tbankSourceTtlMs.metadata, - work: lookup.meta.sync.id - ? { + work: lookup.meta.sync.id ? + { id: lookup.meta.sync.id, state: lookup.meta.sync.state, errorCode: lookup.meta.sync.errorCode, - } - : null, + } : + null, dependencies, }), }; @@ -245,8 +250,9 @@ export class CatalogDiscoveryService { const saved = await this.repository.read(input); if ( !tbankSourceNeedsRefresh('catalog', saved.fetchedAt, new Date(), force) - ) + ) { return null; + } const reservation = await this.repository.reserve( { userId, revision }, input, @@ -291,12 +297,13 @@ export class CatalogDiscoveryService { async refresh(userId: string, revision: string, query: CatalogQuery) { const id = await this.request(userId, revision, query, true); - if (id) + if (id) { await Promise.all( [...this.completions.values()] .filter((entry) => entry.workId === id) .map((entry) => entry.done), ); + } const saved = await this.repository.read(catalogQuery(query)); const sources = await this.enrichment.readMany( userId, @@ -323,19 +330,19 @@ export class CatalogDiscoveryService { const token = await this.access.tokenForAttempt(attempt); if (token) { const found = - input.instrumentType === 'share' - ? await this.tbank.findShares(token, input.query) - : await this.tbank.findBonds(token, input.query); + input.instrumentType === 'share' ? + await this.tbank.findShares(token, input.query) : + await this.tbank.findBonds(token, input.query); success = await this.repository.publish( attempt, input, selectCatalogInstruments( - input.exactTicker - ? found.filter( + input.exactTicker ? + found.filter( (item) => item.ticker?.trim().toUpperCase() === input.query, - ) - : found, + ) : + found, input.query, input.instrumentType, ).slice( @@ -346,17 +353,18 @@ export class CatalogDiscoveryService { ); } } catch (error) { - if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') + if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') { await this.access.rejectMembershipToken(attempt); - else + } else { this.logger.event('warn', 'catalog-discovery.failed', { code: 'TBANK_UNAVAILABLE', }); + } } if (success) { const saved = await this.repository.read(input); - for (const observer of [attempt, ...producer.observers]) - if (!input.exactTicker || saved.data?.length === 1) + for (const observer of [attempt, ...producer.observers]) { + if (!input.exactTicker || saved.data?.length === 1) { await this.enrichment.requestMany( observer.userId, observer.revision, @@ -365,6 +373,8 @@ export class CatalogDiscoveryService { producer.force, input.exactTicker ? ['detail'] : ['last-price', 'detail'], ); + } + } } this.producers.delete(catalogScope(input)); for (const observer of [attempt, ...producer.observers]) { diff --git a/src/modules/investments/instruments/application/catalog-enrichment.service.ts b/src/modules/investments/instruments/application/catalog-enrichment.service.ts index 2d29778..712c1db 100644 --- a/src/modules/investments/instruments/application/catalog-enrichment.service.ts +++ b/src/modules/investments/instruments/application/catalog-enrichment.service.ts @@ -1,47 +1,47 @@ -import type { MoexBondEvent } from '../../../../integrations/moex/moex.api'; -import { CandleRepository } from '../infrastructure/candle.repository'; -import { candleGaps, utcDay } from '../domain/candle-coverage'; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; + import { decimalFromString, divide, formatDecimal, } from '../../../../common/decimal'; -import { decimal } from '../../../../integrations/tbank/decimal'; +import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; import { MILLISECONDS_PER_DAY, MILLISECONDS_PER_SECOND, } from '../../../../common/time'; -import { MoexClient } from '../../../../integrations/moex/moex.api'; +import { type MoexBondEvent, MoexClient } from '../../../../integrations/moex/moex.api'; import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api'; -import { previousClose, timestamp } from './instrument-market'; -import { Injectable, NotFoundException } from '@nestjs/common'; -import { randomUUID } from 'node:crypto'; -import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; +import { decimal } from '../../../../integrations/tbank/decimal'; import { TbankAccessService, - TbankClient, - tbankSourceNeedsRefresh, - tbankSourceTtlMs, type TbankBond, type TbankCandle, + TbankClient, type TbankCoupon, type TbankDividend, type TbankFundamentals, type TbankLastPrice, type TbankShare, + tbankSourceNeedsRefresh, + tbankSourceTtlMs, type TbankSyncAttempt, } from '../../../../integrations/tbank/tbank.api'; import { investmentResourceMeta } from '../../investment-resource.mapper'; +import { candleGaps, utcDay } from '../domain/candle-coverage'; +import { CandleRepository } from '../infrastructure/candle.repository'; import { CatalogDiscoveryRepository, catalogScope, type CatalogSource, } from '../infrastructure/catalog-discovery.repository'; import { catalogSearchLimit } from './catalog-selection'; +import { previousClose, timestamp } from './instrument-market'; type Producer = { - input: CatalogSource; attempt: TbankSyncAttempt; + input: CatalogSource; observers: { attempt: TbankSyncAttempt; input: CatalogSource }[]; }; @Injectable() @@ -54,13 +54,13 @@ export class CatalogEnrichmentService { private running = false; private readonly completions = new Map< string, - { workId: string; done: Promise; resolve: () => void } + { done: Promise; resolve: () => void; workId: string } >(); async ensureDetail( userId: string, revision: string, - instrumentType: 'share' | 'bond', + instrumentType: 'bond' | 'share', uid: string, ) { const ids = await this.requestMany( @@ -82,6 +82,7 @@ export class CatalogEnrichmentService { if (!rows.length) throw new Error('Instrument dependency missing'); return (rows[0].payload as CatalogPayload).instrument ?? null; } + constructor( private readonly repository: CatalogDiscoveryRepository, private readonly access: TbankAccessService, @@ -94,21 +95,21 @@ export class CatalogEnrichmentService { async readMany( userId: string, - instrumentType: 'share' | 'bond', + instrumentType: 'bond' | 'share', uids: string[], observe: boolean, kinds: CatalogSource['kind'][] = ['last-price', 'detail'], range?: { from: string; - to: string; ticker?: string; tickers?: Record; + to: string; }, ) { const { revision } = await this.access.local(userId); const inputs = sourceInputs(instrumentType, uids, kinds, range); const rows = inputs.length ? await this.repository.readSources(inputs) : []; - if (!observe) + if (!observe) { await this.requestMany( userId, revision, @@ -118,9 +119,10 @@ export class CatalogEnrichmentService { kinds, range, ); - const work = inputs.length - ? await this.repository.workMany(userId, revision, inputs) - : []; + } + const work = inputs.length ? + await this.repository.workMany(userId, revision, inputs) : + []; const byScope = new Map(work.map((row) => [row.scopeKey, row])); return inputs.map((input) => { const row = rows.find( @@ -130,16 +132,16 @@ export class CatalogEnrichmentService { const payload = row?.payload as CatalogPayload | undefined; return { input, - version: row - ? { + version: row ? + { instrumentUid: row.instrumentUid, source: row.source, kind: row.kind, rangeKey: row.rangeKey, dataRevision: row.dataRevision, fetchedAt: row.fetchedAt, - } - : null, + } : + null, payload, instrument: payload?.instrument ?? null, price: payload?.price ?? null, @@ -158,22 +160,22 @@ export class CatalogEnrichmentService { requestMany( userId: string, revision: string, - instrumentType: 'share' | 'bond', + instrumentType: 'bond' | 'share', uids: string[], force = false, kinds: CatalogSource['kind'][] = ['last-price', 'detail'], range?: { from: string; - to: string; ticker?: string; tickers?: Record; + to: string; }, ) { const inputs = sourceInputs(instrumentType, uids, kinds, range); const reservation = this.reservations.then(async () => { - const rows = inputs.length - ? await this.repository.readSources(inputs) - : []; + const rows = inputs.length ? + await this.repository.readSources(inputs) : + []; const ids: string[] = []; for (const input of inputs) { const row = rows.find( @@ -187,8 +189,9 @@ export class CatalogEnrichmentService { new Date(), force, ) - ) + ) { continue; + } const reserved = await this.repository.reserve( { userId, revision }, input, @@ -229,24 +232,24 @@ export class CatalogEnrichmentService { async section( userId: string, - instrumentType: 'share' | 'bond', + instrumentType: 'bond' | 'share', uid: string, - section: 'quote' | 'company' | 'metrics', + section: 'company' | 'metrics' | 'quote', observe = false, ) { const kinds: CatalogSource['kind'][] = - section === 'company' - ? ['detail', 'description', 'fundamentals'] - : [ - ...(instrumentType === 'bond' - ? ['bond-current' as const] - : ['detail' as const]), + section === 'company' ? + ['detail', 'description', 'fundamentals'] : + [ + ...(instrumentType === 'bond' ? + ['bond-current' as const] : + ['detail' as const]), 'last-price', 'trading-status', 'comparison', - ...(section === 'metrics' - ? ['coupons' as const, 'yield' as const, 'rating' as const] - : []), + ...(section === 'metrics' ? + ['coupons' as const, 'yield' as const, 'rating' as const] : + []), ]; const sources = await this.readMany( userId, @@ -256,8 +259,9 @@ export class CatalogEnrichmentService { kinds, ); const first = sources[0]; - if (first.meta.availability === 'complete' && !first.instrument) + if (first.meta.availability === 'complete' && !first.instrument) { throw new NotFoundException(); + } const values = sources.reduce( (result, source) => ({ ...result, ...source.payload }), {}, @@ -271,18 +275,18 @@ export class CatalogEnrichmentService { dataRevision: dependencies .map((dependency) => dependency.dataRevision) .join('/'), - availability: !first.instrument - ? 'missing' - : complete - ? 'complete' - : 'partial', - fetchedAt: first.meta.lastSuccessfulAt - ? new Date(first.meta.lastSuccessfulAt) - : null, + availability: !first.instrument ? + 'missing' : + complete ? + 'complete' : + 'partial', + fetchedAt: first.meta.lastSuccessfulAt ? + new Date(first.meta.lastSuccessfulAt) : + null, freshForMs: - section === 'company' - ? tbankSourceTtlMs.company - : tbankSourceTtlMs.quote, + section === 'company' ? + tbankSourceTtlMs.company : + tbankSourceTtlMs.quote, work: null, dependencies, }); @@ -307,18 +311,21 @@ export class CatalogEnrichmentService { ): Promise { if (input.kind === 'detail' || input.kind === 'bond-current') { const instrument = - input.instrumentType === 'share' - ? await this.tbank.getShare(token, input.uid) - : await this.tbank.getBond(token, input.uid); - if (instrument && instrument.uid !== input.uid) + input.instrumentType === 'share' ? + await this.tbank.getShare(token, input.uid) : + await this.tbank.getBond(token, input.uid); + if (instrument && instrument.uid !== input.uid) { throw new Error('Catalog UID mismatch'); + } + // eslint-disable-next-line sonarjs/different-types-comparison -- Defensive validation rejects missing upstream payloads instead of caching them as absence. if (instrument === undefined) throw new Error('Missing catalog response'); return { instrument: instrument ? publicInstrument(instrument) : null }; } - if (input.kind === 'trading-status') + if (input.kind === 'trading-status') { return { tradingStatus: await this.tbank.getTradingStatus(token, input.uid), }; + } if (input.kind === 'comparison') { const prices = await this.repository.readSources([ { ...input, kind: 'last-price', rangeKey: undefined }, @@ -343,16 +350,18 @@ export class CatalogEnrichmentService { ); const rows = response.map((candle) => { const time = timestamp(candle.time); - if (!time || !candle.close) + if (!time || !candle.close) { throw new Error('Invalid candle response'); + } return { time, close: formatDecimal(decimal(candle.close)), isComplete: candle.isComplete === true, }; }); - if (!(await this.candles.stage(attempt, gap, rows, now))) + if (!(await this.candles.stage(attempt, gap, rows, now))) { throw new Error('Cancelled candle comparison'); + } } if ( gaps.length && @@ -361,8 +370,9 @@ export class CatalogEnrichmentService { { uid: input.uid, ranges: gaps }, false, )) - ) + ) { throw new Error('Cancelled candle comparison'); + } const values = (await this.candles.read(input.uid, range)).data.map( (row) => { const value = divide( @@ -386,48 +396,51 @@ export class CatalogEnrichmentService { } const details = await this.repository.readSources([ { ...input, kind: 'detail', rangeKey: undefined }, - ...(input.instrumentType === 'bond' - ? [{ ...input, kind: 'bond-current' as const, rangeKey: undefined }] - : []), + ...(input.instrumentType === 'bond' ? + [{ ...input, kind: 'bond-current' as const, rangeKey: undefined }] : + []), ]); const instrument = details .map((row) => (row.payload as CatalogPayload).instrument) .find(Boolean); if ( !instrument && - !['dividends', 'coupons', 'bond-events'].includes(input.kind) - ) + !['bond-events', 'coupons', 'dividends'].includes(input.kind) + ) { throw new Error('Instrument dependency missing'); - if (input.kind === 'description') + } + if (input.kind === 'description') { return { - description: instrument?.assetUid - ? await this.tbank.getAssetDescription(token, instrument?.assetUid) - : null, + description: instrument?.assetUid ? + await this.tbank.getAssetDescription(token, instrument?.assetUid) : + null, }; - if (input.kind === 'fundamentals') + } + if (input.kind === 'fundamentals') { return { - fundamentals: instrument?.assetUid - ? await this.tbank.getFundamentals(token, instrument?.assetUid) - : null, + fundamentals: instrument?.assetUid ? + await this.tbank.getFundamentals(token, instrument?.assetUid) : + null, }; + } if (input.kind === 'coupons' || input.kind === 'dividends') { const year = Number(input.rangeKey); - const [from, to] = input.rangeKey?.startsWith('[') - ? (JSON.parse(input.rangeKey) as [string, string]) - : [ + const [from, to] = input.rangeKey?.startsWith('[') ? + (JSON.parse(input.rangeKey) as [string, string]) : + [ `${year - payoutHistoryYears}-01-01`, `${year + payoutFutureYears}-01-01`, ]; - return input.kind === 'coupons' - ? { + return input.kind === 'coupons' ? + { coupons: await this.tbank.getBondCoupons( token, input.uid, from, to, ), - } - : { + } : + { dividends: await this.tbank.getDividends( token, input.uid, @@ -441,23 +454,25 @@ export class CatalogEnrichmentService { if (!ticker) throw new Error('Bond ticker missing'); return { bondEvents: await this.moex.bondEvents(ticker) }; } - if (input.kind === 'yield') + if (input.kind === 'yield') { return { effectiveYield: - instrument?.ticker && instrument.isin && instrument.classCode - ? await this.moex.bondYield( + instrument?.ticker && instrument.isin && instrument.classCode ? + await this.moex.bondYield( instrument.ticker, instrument.isin, instrument.classCode, - ) - : null, + ) : + null, }; - if (input.kind === 'rating') + } + if (input.kind === 'rating') { return { - creditRating: instrument?.isin - ? await this.ratings.rating(instrument.isin) - : null, + creditRating: instrument?.isin ? + await this.ratings.rating(instrument.isin) : + null, }; + } throw new Error('Unsupported catalog source'); } @@ -473,6 +488,7 @@ export class CatalogEnrichmentService { let i = 0; i < this.queue.length && batch.length < catalogSearchLimit; ) { + // eslint-disable-next-line security/detect-object-injection -- Numeric queue indexes and validated instrument UIDs select internal data only. const next = this.queue[i]; if ( next.input.kind === 'last-price' && @@ -490,38 +506,40 @@ export class CatalogEnrichmentService { try { const token = await this.access.tokenForAttempt(producer.attempt); if (!token) failed = true; - else if (producer.input.kind === 'last-price') + else if (producer.input.kind === 'last-price') { prices = await this.tbank.getLastPrices( token, batch.map((item) => item.input.uid), ); - else + } else { payload = await this.load(producer.input, token, producer.attempt); + } } catch (error) { failed = true; - if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') + if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') { await this.access.rejectMembershipToken(producer.attempt); - else + } else { this.logger.event('warn', 'catalog-enrichment.failed', { code: 'TBANK_UNAVAILABLE', }); + } } for (const item of batch) { const price = prices?.find((entry) => entry.instrumentUid === item.input.uid) ?? null; const value = - item.input.kind === 'last-price' - ? { - price: price - ? { + item.input.kind === 'last-price' ? + { + price: price ? + { instrumentUid: item.input.uid, price: price.price, time: price.time, - } - : null, - } - : payload; + } : + null, + } : + payload; // Keep accepting observers while publication awaits SQLite. item.observers.unshift({ attempt: item.attempt, input: item.input }); for (const observer of item.observers) { @@ -558,14 +576,14 @@ export class CatalogEnrichmentService { } } function sourceInputs( - instrumentType: 'share' | 'bond', + instrumentType: 'bond' | 'share', uids: string[], kinds: CatalogSource['kind'][], range?: { from: string; - to: string; ticker?: string; tickers?: Record; + to: string; }, ): CatalogSource[] { return kinds.flatMap((kind) => @@ -573,20 +591,22 @@ function sourceInputs( instrumentType, uid, kind, - ...(kind === 'bond-events' && (range?.tickers?.[uid] || range?.ticker) - ? { ticker: range?.tickers?.[uid] || range?.ticker } - : {}), - ...(['coupons', 'dividends'].includes(kind) - ? { - rangeKey: range - ? JSON.stringify([range.from, range.to]) - : String(new Date().getUTCFullYear()), - } - : {}), + // eslint-disable-next-line security/detect-object-injection -- Numeric queue indexes and validated instrument UIDs select internal data only. + ...(kind === 'bond-events' && (range?.tickers?.[uid] || range?.ticker) ? + // eslint-disable-next-line security/detect-object-injection -- Numeric queue indexes and validated instrument UIDs select internal data only. + { ticker: range?.tickers?.[uid] || range?.ticker } : + {}), + ...(['coupons', 'dividends'].includes(kind) ? + { + rangeKey: range ? + JSON.stringify([range.from, range.to]) : + String(new Date().getUTCFullYear()), + } : + {}), })), ); } -function publicInstrument(value: TbankShare | TbankBond) { +function publicInstrument(value: TbankBond | TbankShare) { const bond = value as TbankBond; return { uid: value.uid, @@ -625,38 +645,39 @@ const previousCloseLookbackMs = const payoutHistoryYears = 5; const payoutFutureYears = 2; export type CatalogPayload = { - instrument?: TbankShare | TbankBond | null; - price?: TbankLastPrice | null; - candle?: TbankCandle | null; - tradingStatus?: string | null; - description?: string | null; - fundamentals?: TbankFundamentals | null; - coupons?: TbankCoupon[]; - dividends?: TbankDividend[]; bondEvents?: MoexBondEvent[]; - effectiveYield?: Awaited>; + candle?: null | TbankCandle; + coupons?: TbankCoupon[]; creditRating?: Awaited>; + description?: null | string; + dividends?: TbankDividend[]; + effectiveYield?: Awaited>; + fundamentals?: null | TbankFundamentals; + instrument?: null | TbankBond | TbankShare; + price?: null | TbankLastPrice; + tradingStatus?: null | string; }; function sourcePolicy(kind: CatalogSource['kind']) { if ( [ - 'last-price', 'bond-current', - 'trading-status', 'comparison', + 'last-price', + 'trading-status', 'yield', ].includes(kind) - ) + ) { return 'quote'; + } if (kind === 'description' || kind === 'fundamentals') return 'company'; - if (['coupons', 'dividends', 'bond-events'].includes(kind)) return 'calendar'; + if (['bond-events', 'coupons', 'dividends'].includes(kind)) return 'calendar'; return 'metadata'; } function producerKey(input: CatalogSource) { return input.instrumentType === 'bond' && - (input.kind === 'detail' || input.kind === 'bond-current') - ? `bond-metadata:${input.uid}` - : catalogScope(input); + (input.kind === 'detail' || input.kind === 'bond-current') ? + `bond-metadata:${input.uid}` : + catalogScope(input); } const quotationScale = 9; diff --git a/src/modules/investments/instruments/application/catalog-selection.ts b/src/modules/investments/instruments/application/catalog-selection.ts index 0f39396..c91f26a 100644 --- a/src/modules/investments/instruments/application/catalog-selection.ts +++ b/src/modules/investments/instruments/application/catalog-selection.ts @@ -5,13 +5,13 @@ export const catalogSearchLimit = 30; export function selectCatalogInstruments( found: T[], query: string, - instrumentType: 'share' | 'bond', + instrumentType: 'bond' | 'share', ): (T & { uid: string })[] { const seen = new Set(); const kind = - instrumentType === 'share' - ? 'INSTRUMENT_TYPE_SHARE' - : 'INSTRUMENT_TYPE_BOND'; + instrumentType === 'share' ? + 'INSTRUMENT_TYPE_SHARE' : + 'INSTRUMENT_TYPE_BOND'; const preferredClass = instrumentType === 'share' ? 'TQBR' : 'TQOB'; return found .filter( @@ -21,17 +21,17 @@ export function selectCatalogInstruments( .sort( (a, b) => Number(b.apiTradeAvailableFlag === true) - - Number(a.apiTradeAvailableFlag === true) || + Number(a.apiTradeAvailableFlag === true) || Number(b.ticker?.toUpperCase() === query.toUpperCase()) - - Number(a.ticker?.toUpperCase() === query.toUpperCase()) || + Number(a.ticker?.toUpperCase() === query.toUpperCase()) || Number(b.classCode === preferredClass) - - Number(a.classCode === preferredClass) || + Number(a.classCode === preferredClass) || a.uid.localeCompare(b.uid), ) .filter((instrument) => { - const key = instrument.isin?.trim() - ? `isin:${instrument.isin.trim().toUpperCase()}` - : `uid:${instrument.uid}`; + const key = instrument.isin?.trim() ? + `isin:${instrument.isin.trim().toUpperCase()}` : + `uid:${instrument.uid}`; if (seen.has(key)) return false; seen.add(key); return true; diff --git a/src/modules/investments/instruments/application/instrument-market.ts b/src/modules/investments/instruments/application/instrument-market.ts index b09f461..a43b3c3 100644 --- a/src/modules/investments/instruments/application/instrument-market.ts +++ b/src/modules/investments/instruments/application/instrument-market.ts @@ -1,15 +1,16 @@ -import { MILLISECONDS_PER_SECOND } from '../../../../common/time'; import type { TbankCandle, TbankTimestamp, } from '../../../../integrations/tbank/tbank.api'; +import { MILLISECONDS_PER_SECOND } from '../../../../common/time'; + const MOSCOW_OFFSET_MS = 10_800_000; -export function timestamp(value?: TbankTimestamp): string | null { +export function timestamp(value?: TbankTimestamp): null | string { const seconds = Number(value?.seconds); - return Number.isFinite(seconds) && seconds > 0 - ? new Date(seconds * MILLISECONDS_PER_SECOND).toISOString() - : null; + return Number.isFinite(seconds) && seconds > 0 ? + new Date(seconds * MILLISECONDS_PER_SECOND).toISOString() : + null; } export function moscowDay(iso: string): string { return new Date(Date.parse(iso) + MOSCOW_OFFSET_MS) @@ -19,7 +20,7 @@ export function moscowDay(iso: string): string { export function previousClose( candles: TbankCandle[], priceAt: string, -): TbankCandle | null { +): null | TbankCandle { return ( candles .filter((candle) => { @@ -32,6 +33,6 @@ export function previousClose( ); }) .sort((a, b) => Number(b.time?.seconds) - Number(a.time?.seconds))[0] ?? - null + null ); } diff --git a/src/modules/investments/instruments/application/instrument-position.ts b/src/modules/investments/instruments/application/instrument-position.ts index 2c84ff4..e808f8c 100644 --- a/src/modules/investments/instruments/application/instrument-position.ts +++ b/src/modules/investments/instruments/application/instrument-position.ts @@ -5,7 +5,6 @@ import { formatDecimal, multiply, } from '../../../../common/decimal'; - import { MoneyDto } from '../../../../common/money.dto'; import { PortfolioDto, @@ -19,7 +18,7 @@ const RETURN_PERCENT_DECIMAL_PLACES = 6; export function instrumentPosition( portfolio: PortfolioDto, uid: string, - type: 'STOCK' | 'BOND', + type: 'BOND' | 'STOCK', ): StockPositionDto { const positions = portfolio.positions.filter( (p) => p.type === type && p.instrumentUid === uid, @@ -40,8 +39,8 @@ export function instrumentPosition( currentValue, absoluteReturn, averagePurchasePrice: - positive && totalCost - ? { + positive && totalCost ? + { currency: totalCost.currency, value: formatDecimal( divide( @@ -50,15 +49,15 @@ export function instrumentPosition( PRICE_DECIMAL_PLACES, ), ), - } - : null, + } : + null, returnPercent: positive ? percent(absoluteReturn, totalCost) : null, accounts: positions.map((p) => ({ ...p, returnPercent: - decimalFromString(p.quantity).value > 0n - ? percent(p.absoluteReturn, cost(p)) - : null, + decimalFromString(p.quantity).value > 0n ? + percent(p.absoluteReturn, cost(p)) : + null, })), isStale: portfolio.isStale, observedAt: portfolio.lastSuccessfulAt, @@ -66,8 +65,8 @@ export function instrumentPosition( } function cost(position: PortfolioPositionDto): MoneyDto | null { const price = position.averagePurchasePrice; - return price - ? { + return price ? + { currency: price.currency, value: formatDecimal( multiply( @@ -75,16 +74,17 @@ function cost(position: PortfolioPositionDto): MoneyDto | null { decimalFromString(position.quantity), ), ), - } - : null; + } : + null; } function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null { const currency = values[0]?.currency; if ( !currency || !values.every((v): v is MoneyDto => v !== null && v.currency === currency) - ) + ) { return null; + } return { currency, value: formatDecimal( @@ -98,14 +98,15 @@ function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null { function percent( result: MoneyDto | null, base: MoneyDto | null, -): string | null { +): null | string { if ( !base || !result || base.currency !== result.currency || decimalFromString(base.value).value <= 0n - ) + ) { return null; + } return formatDecimal( divide( multiply(decimalFromString(result.value), decimalFromString('100')), diff --git a/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts b/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts index b8cd052..0ee3d2b 100644 --- a/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts +++ b/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts @@ -1,8 +1,10 @@ -import { CatalogDiscoveryService } from '../../application/catalog-discovery.service'; import { Injectable } from '@nestjs/common'; -import { formatDecimal } from '../../../../../common/decimal'; + import type { TbankBond } from '../../../../../integrations/tbank/tbank.api'; + +import { formatDecimal } from '../../../../../common/decimal'; import { decimal } from '../../../../../integrations/tbank/decimal'; +import { CatalogDiscoveryService } from '../../application/catalog-discovery.service'; import { timestamp } from '../../application/instrument-market'; import { BondDto, @@ -32,23 +34,24 @@ export class BondCatalogService { data: result.data?.map((item) => { const short = toBond(item); - const detail = item.detail - ? toBond({ ...item.detail, uid: item.uid }) - : short; + const detail = item.detail ? + toBond({ ...item.detail, uid: item.uid }) : + short; const last = item.price; return { ...detail, classCode: detail.classCode ?? short.classCode, isin: detail.isin ?? short.isin, apiTradeAvailable: short.apiTradeAvailable, - pricePercent: last?.price - ? formatDecimal(decimal(last.price)) - : null, + pricePercent: last?.price ? + formatDecimal(decimal(last.price)) : + null, priceAt: timestamp(last?.time), }; }) ?? null, }; } + async get( userId: string, uid: string, @@ -57,11 +60,12 @@ export class BondCatalogService { const result = await this.discovery.detail(userId, 'bond', uid, observe); return { ...result, - data: result.data - ? { ...toBond(result.data), slug: result.data.slug } - : null, + data: result.data ? + { ...toBond(result.data), slug: result.data.slug } : + null, }; } + async getByTicker( userId: string, ticker: string, @@ -75,9 +79,9 @@ export class BondCatalogService { ); return { ...result, - data: result.data - ? { ...toBond(result.data), slug: result.data.slug } - : null, + data: result.data ? + { ...toBond(result.data), slug: result.data.slug } : + null, }; } } diff --git a/src/modules/investments/instruments/bonds/application/bond-income.service.ts b/src/modules/investments/instruments/bonds/application/bond-income.service.ts index 7e82d38..a9f4869 100644 --- a/src/modules/investments/instruments/bonds/application/bond-income.service.ts +++ b/src/modules/investments/instruments/bonds/application/bond-income.service.ts @@ -1,18 +1,19 @@ import { Injectable, NotFoundException } from '@nestjs/common'; + import { decimalFromString, + divide, formatDecimal, multiply, - divide, } from '../../../../../common/decimal'; import { TbankAccessService, - type TbankCoupon, type TbankBond, + type TbankCoupon, } from '../../../../../integrations/tbank/tbank.api'; import { - PortfolioService, ForecastService, + PortfolioService, } from '../../../portfolio/portfolio.api'; import { CatalogEnrichmentService, @@ -20,14 +21,14 @@ import { } from '../../application/catalog-enrichment.service'; import { moscowDay, timestamp } from '../../application/instrument-market'; import { instrumentPosition } from '../../application/instrument-position'; +import { money } from '../infrastructure/bond-money.mapper'; import { BondCouponsDto, BondCouponsResourceDto, - BondPositionDto, BondMetricsDto, BondMetricsResourceDto, + BondPositionDto, } from '../presentation/bond.dto'; -import { money } from '../infrastructure/bond-money.mapper'; import { bondQuote } from './bond-market.service'; const ISO_YEAR_LENGTH = 4; const INCOME_HISTORY_YEARS = 5; @@ -40,6 +41,7 @@ export class BondIncomeService { private readonly access: TbankAccessService, private readonly forecasts: ForecastService, ) {} + async metrics( userId: string, uid: string, @@ -53,9 +55,9 @@ export class BondIncomeService { observe, ); return { - data: result.values.instrument - ? bondMetrics(result.values, result.unavailable, result.observedAt) - : null, + data: result.values.instrument ? + bondMetrics(result.values, result.unavailable, result.observedAt) : + null, meta: result.meta, }; } @@ -67,6 +69,7 @@ export class BondIncomeService { meta: { ...saved.meta, resourceKey: `bond/position/${uid}` }, }; } + async coupons( userId: string, uid: string, @@ -85,21 +88,22 @@ export class BondIncomeService { ['detail', 'coupons'], { from, to }, ); - if (sources[0].meta.availability === 'complete' && !sources[0].instrument) + if (sources[0].meta.availability === 'complete' && !sources[0].instrument) { throw new NotFoundException(); + } const events = sources[1].payload?.coupons; - const position = portfolio.data - ? instrumentPosition(portfolio.data, uid, 'BOND') - : null; - const projected = events - ? bondCoupons( + const position = portfolio.data ? + instrumentPosition(portfolio.data, uid, 'BOND') : + null; + const projected = events ? + bondCoupons( events, position, { from, to, today }, sources[1].meta.lastSuccessfulAt ?? new Date().toISOString(), !portfolio.data, - ) - : undefined; + ) : + undefined; const result = await this.forecasts.result({ userId, revision: connection.revision, @@ -110,9 +114,9 @@ export class BondIncomeService { candidate: portfolio.data && sources[0].instrument ? projected : undefined, }); - return result.data || !projected - ? result - : { + return result.data || !projected ? + result : + { data: bondCoupons( events ?? [], null, @@ -135,19 +139,19 @@ export function bondCoupons( const { from, to, today } = window; const quantity = position?.accounts.length && - position.accounts.every((p) => decimalFromString(p.quantity).value > 0n) - ? decimalFromString(position.quantity) - : null; + position.accounts.every((p) => decimalFromString(p.quantity).value > 0n) ? + decimalFromString(position.quantity) : + null; const coupons = events .map((event) => { const paymentDate = timestamp(event.couponDate)?.slice(0, 10) ?? null; const recordDate = timestamp(event.fixDate)?.slice(0, 10) ?? null; const perBond = money(event.payOneBond); - const timing = !paymentDate - ? ('undated' as const) - : paymentDate >= today - ? ('upcoming' as const) - : ('past' as const); + const timing = !paymentDate ? + ('undated' as const) : + paymentDate >= today ? + ('upcoming' as const) : + ('past' as const); return { number: event.couponNumber ?? null, paymentDate, @@ -161,14 +165,14 @@ export function bondCoupons( perBond && timing === 'upcoming' && recordDate && - recordDate >= today - ? { + recordDate >= today ? + { currency: perBond.currency, value: formatDecimal( multiply(quantity, decimalFromString(perBond.value)), ), - } - : null, + } : + null, }; }) .sort((a, b) => (a.paymentDate ?? '').localeCompare(b.paymentDate ?? '')); @@ -214,8 +218,8 @@ export function bondMetrics( clean && coupon.currency === clean.currency && decimalFromString(coupon.value).value >= 0n && - decimalFromString(clean.value).value > 0n - ? formatDecimal( + decimalFromString(clean.value).value > 0n ? + formatDecimal( divide( multiply( decimalFromString(coupon.value), @@ -227,14 +231,14 @@ export function bondMetrics( ), COUPON_YIELD_DECIMAL_PLACES, ), - ) - : null; + ) : + null; const couponType = - next?.couponType && next.couponType !== 'COUPON_TYPE_UNSPECIFIED' - ? next.couponType - : instrument.floatingCouponFlag === true - ? 'COUPON_TYPE_FLOATING' - : null; + next?.couponType && next.couponType !== 'COUPON_TYPE_UNSPECIFIED' ? + next.couponType : + instrument.floatingCouponFlag === true ? + 'COUPON_TYPE_FLOATING' : + null; return { creditRating, coupon, diff --git a/src/modules/investments/instruments/bonds/application/bond-market.service.ts b/src/modules/investments/instruments/bonds/application/bond-market.service.ts index 6365538..c12b5b8 100644 --- a/src/modules/investments/instruments/bonds/application/bond-market.service.ts +++ b/src/modules/investments/instruments/bonds/application/bond-market.service.ts @@ -1,10 +1,7 @@ -import { CandleService } from '../../application/candle.service'; -import type { TbankBond } from '../../../../../integrations/tbank/tbank.api'; -import { - CatalogEnrichmentService, - type CatalogPayload, -} from '../../application/catalog-enrichment.service'; import { Injectable } from '@nestjs/common'; + +import type { TbankBond } from '../../../../../integrations/tbank/tbank.api'; + import { add, decimalFromString, @@ -14,15 +11,19 @@ import { subtract, } from '../../../../../common/decimal'; import { decimal } from '../../../../../integrations/tbank/decimal'; +import { CandleService } from '../../application/candle.service'; +import { + CatalogEnrichmentService, + type CatalogPayload, +} from '../../application/catalog-enrichment.service'; import { timestamp } from '../../application/instrument-market'; +import { money } from '../infrastructure/bond-money.mapper'; import { BondCandlesQueryDto, BondQuoteDto, BondQuoteResourceDto, } from '../presentation/bond.dto'; -import { money } from '../infrastructure/bond-money.mapper'; - const PRICE_DECIMAL_PLACES = 9; @Injectable() export class BondMarketService { @@ -30,6 +31,7 @@ export class BondMarketService { private readonly history: CandleService, private readonly sources: CatalogEnrichmentService, ) {} + async quote( userId: string, uid: string, @@ -43,12 +45,13 @@ export class BondMarketService { observe, ); return { - data: result.values.instrument - ? bondQuote(result.values, result.unavailable, result.observedAt) - : null, + data: result.values.instrument ? + bondQuote(result.values, result.unavailable, result.observedAt) : + null, meta: result.meta, }; } + candles( userId: string, uid: string, @@ -77,8 +80,8 @@ export function bondQuote( const cleanPrice = pricePercent !== null && nominal && - decimalFromString(nominal.value).value > 0n - ? { + decimalFromString(nominal.value).value > 0n ? + { currency: nominal.currency, value: formatDecimal( divide( @@ -90,11 +93,11 @@ export function bondQuote( PRICE_DECIMAL_PLACES, ), ), - } - : null; + } : + null; const dirtyPrice = - cleanPrice && aci && aci.currency === cleanPrice.currency - ? { + cleanPrice && aci && aci.currency === cleanPrice.currency ? + { currency: cleanPrice.currency, value: formatDecimal( add( @@ -102,8 +105,8 @@ export function bondQuote( decimalFromString(aci.value), ), ), - } - : null; + } : + null; return { pricePercent, cleanPrice, @@ -112,9 +115,9 @@ export function bondQuote( priceAt, tradingStatus, changePercentagePoints: - last?.price && previous?.close - ? formatDecimal(subtract(decimal(last.price), decimal(previous.close))) - : null, + last?.price && previous?.close ? + formatDecimal(subtract(decimal(last.price), decimal(previous.close))) : + null, previousCloseDate: timestamp(previous?.time)?.slice(0, 10) ?? null, unavailable, observedAt, diff --git a/src/modules/investments/instruments/bonds/application/bond.spec.ts b/src/modules/investments/instruments/bonds/application/bond.spec.ts index 6f8f234..5f9098b 100644 --- a/src/modules/investments/instruments/bonds/application/bond.spec.ts +++ b/src/modules/investments/instruments/bonds/application/bond.spec.ts @@ -1,7 +1,8 @@ +import type { PortfolioDto } from '../../../portfolio/portfolio.api'; + +import { instrumentPosition } from '../../application/instrument-position'; import { bondCoupons, bondMetrics } from './bond-income.service'; import { bondQuote } from './bond-market.service'; -import { instrumentPosition } from '../../application/instrument-position'; -import type { PortfolioDto } from '../../../portfolio/portfolio.api'; jest.mock('../../../../../prisma/prisma.service', () => ({ PrismaService: class {}, })); @@ -122,9 +123,9 @@ describe('bond financial projections', () => { { couponDate: time('2026-10-10'), payOneBond: - scenario === 'unknown' - ? undefined - : money('30', scenario === 'currency' ? 'usd' : 'rub'), + scenario === 'unknown' ? + undefined : + money('30', scenario === 'currency' ? 'usd' : 'rub'), couponPeriod: scenario === 'period' ? 0 : 90, }, ], diff --git a/src/modules/investments/instruments/bonds/infrastructure/bond-money.mapper.ts b/src/modules/investments/instruments/bonds/infrastructure/bond-money.mapper.ts index a57dead..861a30b 100644 --- a/src/modules/investments/instruments/bonds/infrastructure/bond-money.mapper.ts +++ b/src/modules/investments/instruments/bonds/infrastructure/bond-money.mapper.ts @@ -1,10 +1,10 @@ import { formatDecimal } from '../../../../../common/decimal'; -import { type TbankMoney } from '../../../../../integrations/tbank/tbank.api'; import { decimal } from '../../../../../integrations/tbank/decimal'; +import { type TbankMoney } from '../../../../../integrations/tbank/tbank.api'; export function money(value?: TbankMoney) { const currency = value?.currency?.toUpperCase(); - return value && currency - ? { currency, value: formatDecimal(decimal(value)) } - : null; + return value && currency ? + { currency, value: formatDecimal(decimal(value)) } : + null; } diff --git a/src/modules/investments/instruments/bonds/presentation/bond.controller.ts b/src/modules/investments/instruments/bonds/presentation/bond.controller.ts index da9bdf7..1675bbb 100644 --- a/src/modules/investments/instruments/bonds/presentation/bond.controller.ts +++ b/src/modules/investments/instruments/bonds/presentation/bond.controller.ts @@ -1,4 +1,5 @@ -import { InvestmentReadQueryDto } from '../../../investment-resource.dto'; +import type { Request } from 'express'; + import { Controller, Get, @@ -9,24 +10,25 @@ import { 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 { BondCatalogService } from '../application/bond-catalog.service'; import { BondIncomeService } from '../application/bond-income.service'; import { BondMarketService } from '../application/bond-market.service'; import { - BondCandlesResourceDto, BondCandlesQueryDto, + BondCandlesResourceDto, BondCouponsResourceDto, - BondResourceDto, BondMetricsResourceDto, BondPositionResourceDto, BondQuoteResourceDto, + BondResourceDto, BondSearchQueryDto, BondSearchResourceDto, BondTickerParamsDto, } from './bond.dto'; -import { BondCatalogService } from '../application/bond-catalog.service'; @ApiTags('bonds') @ApiProblemResponses() @@ -39,6 +41,7 @@ export class BondController { private readonly market: BondMarketService, private readonly income: BondIncomeService, ) {} + @Get(':uid/metrics') @ApiOkResponse({ type: BondMetricsResourceDto }) metrics( @@ -48,6 +51,7 @@ export class BondController { ) { return this.income.metrics(request.user.sub, uid, query.sync === 'observe'); } + @Get() @ApiOkResponse({ type: BondSearchResourceDto }) search( @@ -60,6 +64,7 @@ export class BondController { query.sync === 'observe', ); } + @Get('by-ticker/:ticker') @ApiOkResponse({ type: BondResourceDto }) getByTicker( @@ -73,6 +78,7 @@ export class BondController { query.sync === 'observe', ); } + @Get(':uid') @ApiOkResponse({ type: BondResourceDto }) get( @@ -82,6 +88,7 @@ export class BondController { ) { return this.bonds.get(request.user.sub, uid, query.sync === 'observe'); } + @Get(':uid/quote') @ApiOkResponse({ type: BondQuoteResourceDto }) quote( @@ -91,6 +98,7 @@ export class BondController { ) { return this.market.quote(request.user.sub, uid, query.sync === 'observe'); } + @Get(':uid/candles') @ApiOkResponse({ type: BondCandlesResourceDto }) candles( @@ -105,6 +113,7 @@ export class BondController { query.sync === 'observe', ); } + @Get(':uid/position') @ApiOkResponse({ type: BondPositionResourceDto }) position( @@ -118,6 +127,7 @@ export class BondController { query.sync === 'observe', ); } + @Get(':uid/coupons') @ApiOkResponse({ type: BondCouponsResourceDto }) coupons( diff --git a/src/modules/investments/instruments/bonds/presentation/bond.dto.ts b/src/modules/investments/instruments/bonds/presentation/bond.dto.ts index 009beb8..a7624c1 100644 --- a/src/modules/investments/instruments/bonds/presentation/bond.dto.ts +++ b/src/modules/investments/instruments/bonds/presentation/bond.dto.ts @@ -1,7 +1,7 @@ -import { InvestmentResourceMetaDto } from '../../../investment-resource.dto'; import { ApiProperty } from '@nestjs/swagger'; -import { MoneyDto } from '../../../../../common/money.dto'; +import { MoneyDto } from '../../../../../common/money.dto'; +import { InvestmentResourceMetaDto } from '../../../investment-resource.dto'; import { StockCandlesDto, StockCandlesQueryDto, @@ -20,44 +20,61 @@ export class BondDto { description: 'Unambiguous ticker URL segment, or the exact instrument UID.', }) declare slug: string; + @ApiProperty() declare name: string; - @ApiProperty({ type: String, nullable: true }) declare isin: string | null; + @ApiProperty({ type: String, nullable: true }) declare isin: null | string; @ApiProperty({ type: String, nullable: true }) declare classCode: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare realExchange: - string | null; + null | string; + @ApiProperty({ type: Boolean, nullable: true }) declare apiTradeAvailable: boolean | null; - @ApiProperty({ type: String, nullable: true }) declare ticker: string | null; + + @ApiProperty({ type: String, nullable: true }) declare ticker: null | string; @ApiProperty({ type: String, nullable: true }) declare logoName: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare currency: - string | null; - @ApiProperty({ type: String, nullable: true }) declare sector: string | null; - @ApiProperty({ type: Number, nullable: true }) declare lot: number | null; + null | string; + + @ApiProperty({ type: String, nullable: true }) declare sector: null | string; + @ApiProperty({ type: Number, nullable: true }) declare lot: null | number; @ApiProperty({ type: String, nullable: true, format: 'date-time' }) - declare historyFrom: string | null; + declare historyFrom: null | string; + @ApiProperty({ type: MoneyDto, nullable: true }) declare nominal: MoneyDto | null; + @ApiProperty({ type: MoneyDto, nullable: true }) declare initialNominal: MoneyDto | null; + @ApiProperty({ type: MoneyDto, nullable: true }) declare aci: MoneyDto | null; @ApiProperty({ type: String, nullable: true }) declare maturityDate: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare callDate: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare bondType: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare riskLevel: - string | null; + null | string; + @ApiProperty({ type: Number, nullable: true }) declare couponQuantityPerYear: - number | null; + null | number; + @ApiProperty({ type: Boolean, nullable: true }) declare floatingCoupon: boolean | null; + @ApiProperty({ type: Boolean, nullable: true }) declare perpetual: boolean | null; + @ApiProperty({ type: Boolean, nullable: true }) declare amortization: boolean | null; + @ApiProperty({ type: Boolean, nullable: true }) declare subordinated: boolean | null; } @@ -68,13 +85,15 @@ export class BondSearchResultDto extends BondDto { nullable: true, description: 'Clean price in percent of nominal, not money.', }) - declare pricePercent: string | null; + declare pricePercent: null | string; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) - declare priceAt: string | null; + declare priceAt: null | string; } export class BondQuoteDto { @ApiProperty({ type: String, nullable: true }) declare pricePercent: - string | null; + null | string; + @ApiProperty({ type: MoneyDto, nullable: true, @@ -82,6 +101,7 @@ export class BondQuoteDto { 'Indicative clean value at the current nominal; no FX conversion.', }) declare cleanPrice: MoneyDto | null; + @ApiProperty({ type: MoneyDto, nullable: true }) declare aci: MoneyDto | null; @ApiProperty({ type: MoneyDto, @@ -90,41 +110,53 @@ export class BondQuoteDto { 'Indicative clean value plus current accrued interest, excluding fees.', }) declare dirtyPrice: MoneyDto | null; + @ApiProperty({ type: String, nullable: true }) - declare changePercentagePoints: string | null; + declare changePercentagePoints: null | string; + @ApiProperty({ type: String, nullable: true }) declare previousCloseDate: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare tradingStatus: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) - declare priceAt: string | null; - @ApiProperty({ type: [String] }) declare unavailable: string[]; + declare priceAt: null | string; + + @ApiProperty({ type: String, isArray: true }) declare unavailable: string[]; @ApiProperty({ format: 'date-time' }) declare observedAt: string; } export class BondCouponDto { - @ApiProperty({ type: String, nullable: true }) declare number: string | null; + @ApiProperty({ type: String, nullable: true }) declare number: null | string; @ApiProperty({ type: String, nullable: true }) declare paymentDate: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare recordDate: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare couponType: - string | null; + null | string; + @ApiProperty({ type: Number, nullable: true }) declare periodDays: - number | null; + null | number; + @ApiProperty({ type: MoneyDto, nullable: true }) declare perBond: MoneyDto | null; + @ApiProperty({ type: MoneyDto, nullable: true }) declare estimatedAmount: MoneyDto | null; + @ApiProperty({ enum: ['upcoming', 'past', 'undated'] }) declare timing: - 'upcoming' | 'past' | 'undated'; + 'past' | 'undated' | 'upcoming'; } export class BondCouponsDto { - @ApiProperty({ type: [BondCouponDto] }) declare coupons: BondCouponDto[]; + @ApiProperty({ type: BondCouponDto, isArray: true }) declare coupons: BondCouponDto[]; @ApiProperty() declare from: string; @ApiProperty() declare to: string; @ApiProperty() declare observedAt: string; @ApiProperty({ type: String, nullable: true }) declare positionAt: - string | null; + null | string; + @ApiProperty() declare estimateUnavailable: boolean; @ApiProperty() declare positionIsStale: boolean; } @@ -132,6 +164,7 @@ export class BondCouponsDto { export class BondYieldDto { @ApiProperty({ description: 'MOEX effective annual yield in percent.' }) declare value: string; + @ApiProperty() declare pricePercent: string; @ApiProperty({ format: 'date' }) declare date: string; @ApiProperty({ @@ -139,14 +172,16 @@ export class BondYieldDto { nullable: true, description: 'MOEX yield target, e.g. MATDATE or OFFERDATE.', }) - declare dateType: string | null; + declare dateType: null | string; + @ApiProperty({ format: 'date-time' }) declare tradeAt: string; } export class BondCreditRatingDto { @ApiProperty({ enum: ['NCR'] }) declare agency: 'NCR'; - @ApiProperty({ type: String, nullable: true }) declare value: string | null; + @ApiProperty({ type: String, nullable: true }) declare value: null | string; @ApiProperty({ enum: ['active', 'withdrawn'] }) declare status: 'active' | 'withdrawn'; + @ApiProperty({ format: 'date' }) declare date: string; @ApiProperty({ format: 'uri' }) declare sourceUrl: string; @ApiProperty({ format: 'date-time' }) declare observedAt: string; @@ -154,39 +189,50 @@ export class BondCreditRatingDto { export class BondMetricsDto { @ApiProperty({ type: BondCreditRatingDto, nullable: true }) declare creditRating: BondCreditRatingDto | null; + @ApiProperty({ type: MoneyDto, nullable: true }) declare coupon: MoneyDto | null; + @ApiProperty({ type: String, nullable: true, format: 'date' }) - declare couponDate: string | null; + declare couponDate: null | string; + @ApiProperty({ type: String, nullable: true }) declare couponType: - string | null; + null | string; + @ApiProperty({ type: Number, nullable: true }) declare couponPeriodDays: - number | null; + null | number; + @ApiProperty({ type: String, nullable: true, description: 'Next coupon × 365 / period days / indicative clean price × 100. Not yield to maturity.', }) - declare couponYieldPercent: string | null; + declare couponYieldPercent: null | string; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) - declare couponYieldPriceAt: string | null; + declare couponYieldPriceAt: null | string; + @ApiProperty({ type: BondYieldDto, nullable: true }) declare effectiveYield: BondYieldDto | null; - @ApiProperty({ type: [String] }) declare unavailable: string[]; + + @ApiProperty({ type: String, isArray: true }) declare unavailable: string[]; @ApiProperty({ format: 'date-time' }) declare observedAt: string; } export class BondPositionResourceDto { @ApiProperty({ type: BondPositionDto, nullable: true }) declare data: BondPositionDto | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } export class BondSearchResourceDto { + // eslint-disable-next-line @darraghor/nestjs-typed/api-property-returning-array-should-set-array -- Nullable array union requires isArray in the explicit wire schema. @ApiProperty({ type: BondSearchResultDto, isArray: true, nullable: true }) declare data: BondSearchResultDto[] | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } @@ -200,6 +246,7 @@ export class BondResourceDto { export class BondQuoteResourceDto { @ApiProperty({ type: BondQuoteDto, nullable: true }) declare data: BondQuoteDto | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } @@ -207,6 +254,7 @@ export class BondQuoteResourceDto { export class BondMetricsResourceDto { @ApiProperty({ type: BondMetricsDto, nullable: true }) declare data: BondMetricsDto | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } @@ -214,6 +262,7 @@ export class BondMetricsResourceDto { export class BondCandlesResourceDto { @ApiProperty({ type: BondCandlesDto, nullable: true }) declare data: BondCandlesDto | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } @@ -221,6 +270,7 @@ export class BondCandlesResourceDto { export class BondCouponsResourceDto { @ApiProperty({ type: BondCouponsDto, nullable: true }) declare data: BondCouponsDto | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } diff --git a/src/modules/investments/instruments/domain/candle-coverage.spec.ts b/src/modules/investments/instruments/domain/candle-coverage.spec.ts index 549051a..b2915df 100644 --- a/src/modules/investments/instruments/domain/candle-coverage.spec.ts +++ b/src/modules/investments/instruments/domain/candle-coverage.spec.ts @@ -67,7 +67,7 @@ describe('source-confirmed daily candle coverage', () => { expect(window.to.valueOf() - window.from.valueOf()).toBeLessThanOrEqual( 365 * 86400000, ); - if (index) expect(window.from).toEqual(windows[index - 1].to); + expect(window.from).toEqual(index ? windows[index - 1].to : range.from); }); }); }); diff --git a/src/modules/investments/instruments/domain/candle-coverage.ts b/src/modules/investments/instruments/domain/candle-coverage.ts index d4302c9..91e9c5d 100644 --- a/src/modules/investments/instruments/domain/candle-coverage.ts +++ b/src/modules/investments/instruments/domain/candle-coverage.ts @@ -2,13 +2,13 @@ import { MILLISECONDS_PER_DAY } from '../../../../common/time'; export type CandleRange = { from: Date; to: Date }; export type CandleCoverage = CandleRange & { - isComplete: boolean; fetchedAt: Date; + isComplete: boolean; }; export type SavedCandle = { - time: string; close: string; isComplete: boolean; + time: string; }; const windowDays = 365; const tailFreshForMs = 60_000; @@ -30,14 +30,15 @@ export function candleGaps( !covered.isComplete && (now.valueOf() - covered.fetchedAt.valueOf() >= tailFreshForMs || utcDay(now).valueOf() !== utcDay(covered.fetchedAt).valueOf()) - ) + ) { continue; + } gaps = gaps.flatMap((gap) => { if (covered.to <= gap.from || covered.from >= gap.to) return [gap]; return [ - ...(covered.from > gap.from - ? [{ from: gap.from, to: covered.from }] - : []), + ...(covered.from > gap.from ? + [{ from: gap.from, to: covered.from }] : + []), ...(covered.to < gap.to ? [{ from: covered.to, to: gap.to }] : []), ]; }); @@ -73,11 +74,14 @@ export function confirmedCandleCoverage( for (const boundary of [ midnight, ...[...incompleteDays].flatMap((day) => [day, day + MILLISECONDS_PER_DAY]), - ]) - if (boundary > range.from.valueOf() && boundary < range.to.valueOf()) + ]) { + if (boundary > range.from.valueOf() && boundary < range.to.valueOf()) { boundaries.add(boundary); + } + } const sorted = [...boundaries].sort((a, b) => a - b); return sorted.slice(1).map((to, index) => { + // eslint-disable-next-line security/detect-object-injection -- The index comes from the sorted numeric boundary array. const from = sorted[index]; return { from: new Date(from), diff --git a/src/modules/investments/instruments/infrastructure/candle.repository.ts b/src/modules/investments/instruments/infrastructure/candle.repository.ts index d3c4273..8363bde 100644 --- a/src/modules/investments/instruments/infrastructure/candle.repository.ts +++ b/src/modules/investments/instruments/infrastructure/candle.repository.ts @@ -1,24 +1,25 @@ import { Injectable } from '@nestjs/common'; -import { randomUUID } from 'node:crypto'; -import { createHash } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; + import type { Prisma } from '../../../../../generated/prisma/client'; -import { PrismaService } from '../../../../prisma/prisma.service'; import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank.api'; + +import { PrismaService } from '../../../../prisma/prisma.service'; import { - confirmedCandleCoverage, type CandleRange, + confirmedCandleCoverage, type SavedCandle, } from '../domain/candle-coverage'; const provider = 'tbank'; const interval = 'day'; type CandleIntent = { - instrumentType?: 'share' | 'bond'; - uid: string; - ranges: CandleRange[]; - period?: string; - now?: string; force?: boolean; + instrumentType?: 'bond' | 'share'; + now?: string; + period?: string; + ranges: CandleRange[]; + uid: string; }; @Injectable() export class CandleRepository { @@ -57,7 +58,7 @@ export class CandleRepository { } reserve( - owner: { userId: string; revision: string }, + owner: { revision: string; userId: string }, scopeKey: string, intent: CandleIntent, runId: string, @@ -88,29 +89,31 @@ export class CandleRepository { (!intent.force || (previousIntent.force === true && (!intent.now || intent.now <= previousNow))) - ) + ) { return { work: previous, launch: false }; + } const work = await tx.tbankSyncWork.update({ where: { id: previous.id }, data: { priority: nextPriority, intent: { ...previousIntent, - ...(intent.force - ? { + ...(intent.force ? + { force: true, - ...(intent.now && intent.now > previousNow - ? { now: intent.now } - : {}), - } - : {}), + ...(intent.now && intent.now > previousNow ? + { now: intent.now } : + {}), + } : + {}), }, }, }); return { work, launch: false }; } - if (previous) + if (previous) { await tx.tbankSyncStage.deleteMany({ where: { workId: previous.id } }); + } const data = { attemptId: randomUUID(), processRunId: runId, @@ -142,12 +145,15 @@ export class CandleRepository { fetchedAt: Date, ) { // Reject malformed financial input instead of publishing a false empty range. - for (const candle of candles) + for (const candle of candles) { if ( !Number.isFinite(Date.parse(candle.time)) || + // eslint-disable-next-line security/detect-unsafe-regex -- Anchored decimal grammar: the literal decimal point separates disjoint digit runs; backtracking is linear. !/^-?\d+(?:\.\d+)?$/.test(candle.close) - ) + ) { throw new Error('Invalid candle'); + } + } const key = `${range.from.toISOString()}/${range.to.toISOString()}`; const rows = [ ...new Map( @@ -155,7 +161,7 @@ export class CandleRepository { .filter( (candle) => new Date(candle.time) >= range.from && - new Date(candle.time) < range.to, + new Date(candle.time) < range.to, ) .map((candle) => [candle.time, candle]), ).values(), @@ -222,8 +228,9 @@ export class CandleRepository { `${range.from.toISOString()}/${range.to.toISOString()}`, ), ) - ) + ) { throw new Error('Incomplete candle reconciliation'); + } const identity = { provider, instrumentUid: intent.uid, interval }; for (const range of intent.ranges) { const row = staged.find( @@ -254,21 +261,22 @@ export class CandleRepository { // Preserve coverage outside the requested reconciliation boundaries. for (const previous of old) { for (const part of [ - ...(previous.from < range.from - ? [{ from: previous.from, to: range.from }] - : []), - ...(previous.to > range.to - ? [{ from: range.to, to: previous.to }] - : []), - ]) + ...(previous.from < range.from ? + [{ from: previous.from, to: range.from }] : + []), + ...(previous.to > range.to ? + [{ from: range.to, to: previous.to }] : + []), + ]) { await tx.tbankCandleCoverage.create({ data: { ...previous, ...part }, }); + } } await tx.tbankCandle.deleteMany({ where: { ...identity, time: { gte: range.from, lt: range.to } }, }); - for (const candle of payload.candles) + for (const candle of payload.candles) { await tx.tbankCandle.create({ data: { ...identity, @@ -279,14 +287,16 @@ export class CandleRepository { fetchedAt, }, }); + } for (const covered of confirmedCandleCoverage( range, payload.candles, fetchedAt, - )) + )) { await tx.tbankCandleCoverage.create({ data: { ...identity, ...covered }, }); + } } await tx.tbankSyncStage.deleteMany({ where: { workId: attempt.id, attemptId: attempt.attemptId }, diff --git a/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts b/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts index 2c2d183..ae844f1 100644 --- a/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts +++ b/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts @@ -1,40 +1,42 @@ import { Injectable } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; import { isDeepStrictEqual } from 'node:util'; + import type { Prisma } from '../../../../../generated/prisma/client'; -import { PrismaService } from '../../../../prisma/prisma.service'; import type { TbankBond, TbankShare, TbankSyncAttempt, } from '../../../../integrations/tbank/tbank.api'; +import { PrismaService } from '../../../../prisma/prisma.service'; + export type CatalogQuery = { - instrumentType: 'share' | 'bond'; - query: string; exactTicker?: boolean; + instrumentType: 'bond' | 'share'; + query: string; }; export type CatalogSource = { - instrumentType: 'share' | 'bond'; - uid: string; + instrumentType: 'bond' | 'share'; kind: - | 'detail' - | 'last-price' - | 'bond-current' - | 'description' - | 'fundamentals' - | 'trading-status' - | 'comparison' - | 'coupons' - | 'yield' - | 'rating' - | 'dividends' - | 'bond-events'; + | 'bond-current' | + 'bond-events' | + 'comparison' | + 'coupons' | + 'description' | + 'detail' | + 'dividends' | + 'fundamentals' | + 'last-price' | + 'rating' | + 'trading-status' | + 'yield'; rangeKey?: string; ticker?: string; + uid: string; }; export type CatalogWork = CatalogQuery | CatalogSource; -export type CatalogInstrument = (TbankShare | TbankBond) & { uid: string }; +export type CatalogInstrument = (TbankBond | TbankShare) & { uid: string }; const provider = 'tbank'; export function catalogQuery(input: CatalogQuery): CatalogQuery { return { @@ -44,13 +46,14 @@ export function catalogQuery(input: CatalogQuery): CatalogQuery { }; } export function catalogScope(input: CatalogWork) { - if ('uid' in input) + if ('uid' in input) { return `catalog-source:${JSON.stringify([ input.kind, input.kind === 'detail' ? input.instrumentType : null, input.rangeKey ?? '', input.uid, ])}`; + } return `${input.exactTicker ? 'ticker' : 'catalog'}:${JSON.stringify([input.instrumentType, input.query])}`; } @@ -60,8 +63,8 @@ export class CatalogDiscoveryRepository { read(input: CatalogQuery) { return this.prisma.$transaction(async (tx) => { - const alias = input.exactTicker - ? await tx.tbankInstrumentLookup.findUnique({ + const alias = input.exactTicker ? + await tx.tbankInstrumentLookup.findUnique({ where: { provider_identifierType_identifier: { provider, @@ -69,17 +72,17 @@ export class CatalogDiscoveryRepository { identifier: input.query, }, }, - }) - : null; - const discovery = input.exactTicker - ? alias - ? { + }) : + null; + const discovery = input.exactTicker ? + alias ? + { instrumentUids: alias.matchedUids, fetchedAt: alias.fetchedAt, dataRevision: alias.dataRevision, - } - : null - : await tx.tbankCatalogDiscovery.findUnique({ + } : + null : + await tx.tbankCatalogDiscovery.findUnique({ where: { provider_instrumentType_query: { provider, @@ -101,12 +104,13 @@ export class CatalogDiscoveryRepository { }); const byUid = new Map(rows.map((row) => [row.instrumentUid, row])); // A damaged/missing dependency is unknown, never a confirmed empty search. - if (uids.some((uid) => !byUid.has(uid))) + if (uids.some((uid) => !byUid.has(uid))) { return { data: null, fetchedAt: null, dataRevision: String(discovery.dataRevision), }; + } return { data: uids.map((uid) => byUid.get(uid)?.payload as CatalogInstrument), fetchedAt: discovery.fetchedAt, @@ -127,9 +131,9 @@ export class CatalogDiscoveryRepository { kind: input.kind, source: sourceProvider(input), rangeKey: - input.kind === 'detail' - ? input.instrumentType - : (input.rangeKey ?? ''), + input.kind === 'detail' ? + input.instrumentType : + (input.rangeKey ?? ''), })), }, }); @@ -176,7 +180,7 @@ export class CatalogDiscoveryRepository { }, }); const instrument = ( - value as { instrument?: TbankShare | TbankBond | null } + value as { instrument?: null | TbankBond | TbankShare } ).instrument; if ( (input.kind === 'detail' || input.kind === 'bond-current') && @@ -225,9 +229,9 @@ export class CatalogDiscoveryRepository { increment: isDeepStrictEqual( previousAlias?.matchedUids, matchedUids, - ) - ? 0 - : 1, + ) ? + 0 : + 1, }, }, }); @@ -239,9 +243,9 @@ export class CatalogDiscoveryRepository { source: sourceProvider(input), kind: input.kind, rangeKey: - input.kind === 'detail' - ? input.instrumentType - : (input.rangeKey ?? ''), + input.kind === 'detail' ? + input.instrumentType : + (input.rangeKey ?? ''), }; const previous = await tx.tbankInstrumentSource.findUnique({ where: { provider_instrumentUid_source_kind_rangeKey: key }, @@ -262,7 +266,7 @@ export class CatalogDiscoveryRepository { } reserve( - owner: { userId: string; revision: string }, + owner: { revision: string; userId: string }, input: CatalogWork, runId: string, active: ReadonlySet, @@ -281,8 +285,9 @@ export class CatalogDiscoveryRepository { previous?.state === 'running' && previous.processRunId === runId && active.has(previous.attemptId) - ) + ) { return { work: previous, launch: false }; + } const data = { attemptId: randomUUID(), processRunId: runId, @@ -351,9 +356,9 @@ export class CatalogDiscoveryRepository { instrumentKind: instrument.instrumentKind, apiTradeAvailableFlag: instrument.apiTradeAvailableFlag, currency: instrument.currency, - brand: instrument.brand - ? { logoName: instrument.brand.logoName } - : undefined, + brand: instrument.brand ? + { logoName: instrument.brand.logoName } : + undefined, }), ) as Prisma.InputJsonObject; await tx.tbankInstrument.upsert({ @@ -410,9 +415,9 @@ export class CatalogDiscoveryRepository { increment: isDeepStrictEqual( previous?.matchedUids, instrumentUids, - ) - ? 0 - : 1, + ) ? + 0 : + 1, }, }, }); @@ -436,9 +441,9 @@ export class CatalogDiscoveryRepository { increment: isDeepStrictEqual( previous?.instrumentUids, instrumentUids, - ) - ? 0 - : 1, + ) ? + 0 : + 1, }, }, }); @@ -472,9 +477,9 @@ export class CatalogDiscoveryRepository { } function sourceProvider(input: CatalogSource) { - return input.kind === 'yield' || input.kind === 'bond-events' - ? 'moex' - : input.kind === 'rating' - ? 'ncr' - : 'tbank'; + return input.kind === 'yield' || input.kind === 'bond-events' ? + 'moex' : + input.kind === 'rating' ? + 'ncr' : + 'tbank'; } diff --git a/src/modules/investments/instruments/instrument-income.api.ts b/src/modules/investments/instruments/instrument-income.api.ts index b717fd0..8b01151 100644 --- a/src/modules/investments/instruments/instrument-income.api.ts +++ b/src/modules/investments/instruments/instrument-income.api.ts @@ -1,2 +1,2 @@ -export { StockIncomeService } from './stocks/application/stock-income.service'; export { BondIncomeService } from './bonds/application/bond-income.service'; +export { StockIncomeService } from './stocks/application/stock-income.service'; diff --git a/src/modules/investments/instruments/instrument-sources.module.ts b/src/modules/investments/instruments/instrument-sources.module.ts index 24a85ef..23e20cf 100644 --- a/src/modules/investments/instruments/instrument-sources.module.ts +++ b/src/modules/investments/instruments/instrument-sources.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; + import { MoexModule } from '../../../integrations/moex/moex.module'; import { NcrModule } from '../../../integrations/ncr/ncr.module'; import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { CandleService } from './application/candle.service'; -import { CandleRepository } from './infrastructure/candle.repository'; -import { CatalogEnrichmentService } from './application/catalog-enrichment.service'; import { CatalogDiscoveryService } from './application/catalog-discovery.service'; +import { CatalogEnrichmentService } from './application/catalog-enrichment.service'; +import { CandleRepository } from './infrastructure/candle.repository'; import { CatalogDiscoveryRepository } from './infrastructure/catalog-discovery.repository'; @Module({ diff --git a/src/modules/investments/instruments/instruments.api.ts b/src/modules/investments/instruments/instruments.api.ts index bc88c67..014d610 100644 --- a/src/modules/investments/instruments/instruments.api.ts +++ b/src/modules/investments/instruments/instruments.api.ts @@ -1,5 +1,5 @@ export { CandleService } from './application/candle.service'; -export { CatalogEnrichmentService } from './application/catalog-enrichment.service'; -export type { CatalogPayload } from './application/catalog-enrichment.service'; - export { CatalogDiscoveryService } from './application/catalog-discovery.service'; +export { CatalogEnrichmentService } from './application/catalog-enrichment.service'; + +export type { CatalogPayload } from './application/catalog-enrichment.service'; diff --git a/src/modules/investments/instruments/instruments.module.ts b/src/modules/investments/instruments/instruments.module.ts index 8eb5280..9ba931e 100644 --- a/src/modules/investments/instruments/instruments.module.ts +++ b/src/modules/investments/instruments/instruments.module.ts @@ -1,17 +1,18 @@ -import { InstrumentSourcesModule } from './instrument-sources.module'; import { Module } from '@nestjs/common'; + import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { AuthModule } from '../../auth/auth.module'; import { PortfolioModule } from '../portfolio/portfolio.module'; +import { BondCatalogService } from './bonds/application/bond-catalog.service'; import { BondIncomeService } from './bonds/application/bond-income.service'; import { BondMarketService } from './bonds/application/bond-market.service'; import { BondController } from './bonds/presentation/bond.controller'; -import { BondCatalogService } from './bonds/application/bond-catalog.service'; +import { InstrumentSourcesModule } from './instrument-sources.module'; import { GetStockCompanyUseCase } from './stocks/application/get-stock-company.use-case'; +import { StockCatalogService } from './stocks/application/stock-catalog.service'; import { StockIncomeService } from './stocks/application/stock-income.service'; import { StockMarketService } from './stocks/application/stock-market.service'; import { StockController } from './stocks/presentation/stock.controller'; -import { StockCatalogService } from './stocks/application/stock-catalog.service'; @Module({ imports: [AuthModule, TbankModule, InstrumentSourcesModule, PortfolioModule], diff --git a/src/modules/investments/instruments/stocks/application/get-stock-company.use-case.ts b/src/modules/investments/instruments/stocks/application/get-stock-company.use-case.ts index 8c05cdb..ea4df48 100644 --- a/src/modules/investments/instruments/stocks/application/get-stock-company.use-case.ts +++ b/src/modules/investments/instruments/stocks/application/get-stock-company.use-case.ts @@ -1,5 +1,7 @@ import { Injectable } from '@nestjs/common'; + import type { TbankFundamentals } from '../../../../../integrations/tbank/tbank.api'; + import { CatalogEnrichmentService } from '../../application/catalog-enrichment.service'; import { StockCompanyResourceDto, @@ -21,8 +23,8 @@ export class GetStockCompanyUseCase { observe, ); return { - data: result.values.instrument - ? { + data: result.values.instrument ? + { description: result.values.description ?? null, metrics: metrics( result.values.fundamentals ?? null, @@ -30,14 +32,14 @@ export class GetStockCompanyUseCase { ), unavailable: result.unavailable, observedAt: result.observedAt, - } - : null, + } : + null, meta: result.meta, }; } } export function metrics( - values: TbankFundamentals | null, + values: null | TbankFundamentals, sector?: string, ): StockMetricDto[] { if (!values) return []; @@ -49,30 +51,31 @@ export function metrics( ][] = [ ['marketCapitalization', 'money', null], ['peRatioTtm', 'ratio', 'TTM'], - ...(bank - ? ([ + ...(bank ? + ([ ['priceToBookTtm', 'ratio', 'TTM'], ['roe', 'percent', null], - ] as typeof selection) - : ([ + ] as typeof selection) : + ([ ['revenueTtm', 'money', 'TTM'], ['ebitdaTtm', 'money', 'TTM'], ] as typeof selection)), ['netIncomeTtm', 'money', 'TTM'], - ...(!bank - ? ([ + ...(!bank ? + ([ ['totalDebtMrq', 'money', 'MRQ'], ['netDebtToEbitda', 'ratio', null], - ] as typeof selection) - : []), + ] as typeof selection) : + []), ]; return selection.flatMap(([key, unit, period]) => { + // eslint-disable-next-line security/detect-object-injection -- Keys come from the fixed metric selection declared above. const value = values[key]; const currency = values.currency?.toUpperCase() || null; return typeof value === 'number' && Number.isFinite(value) && - (unit !== 'money' || currency) - ? [ + (unit !== 'money' || currency) ? + [ { key, value, @@ -80,7 +83,7 @@ export function metrics( period, currency: unit === 'money' ? currency : null, }, - ] - : []; + ] : + []; }); } diff --git a/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts b/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts index 867182c..914d09a 100644 --- a/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts +++ b/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts @@ -1,8 +1,10 @@ -import { CatalogDiscoveryService } from '../../application/catalog-discovery.service'; import { Injectable } from '@nestjs/common'; -import { formatDecimal } from '../../../../../common/decimal'; + import type { TbankShare } from '../../../../../integrations/tbank/tbank.api'; + +import { formatDecimal } from '../../../../../common/decimal'; import { decimal } from '../../../../../integrations/tbank/decimal'; +import { CatalogDiscoveryService } from '../../application/catalog-discovery.service'; import { timestamp } from '../../application/instrument-market'; import { StockDto, @@ -30,9 +32,9 @@ export class StockCatalogService { data: result.data?.map((item) => { const short = stock(item); - const detail = item.detail - ? stock({ ...item.detail, uid: item.uid }) - : short; + const detail = item.detail ? + stock({ ...item.detail, uid: item.uid }) : + short; const last = item.price; return { ...detail, @@ -40,17 +42,18 @@ export class StockCatalogService { isin: detail.isin ?? short.isin, apiTradeAvailable: short.apiTradeAvailable, price: - last?.price && detail.currency - ? { + last?.price && detail.currency ? + { currency: detail.currency, value: formatDecimal(decimal(last.price)), - } - : null, + } : + null, priceAt: timestamp(last?.time), }; }) ?? null, }; } + async get( userId: string, uid: string, @@ -59,11 +62,12 @@ export class StockCatalogService { const result = await this.discovery.detail(userId, 'share', uid, observe); return { ...result, - data: result.data - ? { ...stock(result.data), slug: result.data.slug } - : null, + data: result.data ? + { ...stock(result.data), slug: result.data.slug } : + null, }; } + async getByTicker( userId: string, ticker: string, @@ -77,9 +81,9 @@ export class StockCatalogService { ); return { ...result, - data: result.data - ? { ...stock(result.data), slug: result.data.slug } - : null, + data: result.data ? + { ...stock(result.data), slug: result.data.slug } : + null, }; } } diff --git a/src/modules/investments/instruments/stocks/application/stock-income.service.ts b/src/modules/investments/instruments/stocks/application/stock-income.service.ts index a0754a7..9dd4eb2 100644 --- a/src/modules/investments/instruments/stocks/application/stock-income.service.ts +++ b/src/modules/investments/instruments/stocks/application/stock-income.service.ts @@ -1,27 +1,28 @@ import { Injectable, NotFoundException } from '@nestjs/common'; + import { decimalFromString, formatDecimal, multiply, } from '../../../../../common/decimal'; +import { decimal } from '../../../../../integrations/tbank/decimal'; import { TbankAccessService, type TbankDividend, } from '../../../../../integrations/tbank/tbank.api'; import { - PortfolioService, ForecastService, + PortfolioService, } from '../../../portfolio/portfolio.api'; import { CatalogEnrichmentService } from '../../application/catalog-enrichment.service'; import { moscowDay, timestamp } from '../../application/instrument-market'; import { instrumentPosition } from '../../application/instrument-position'; import { + StockDividendDto, StockDividendsDto, StockDividendsResourceDto, StockPositionDto, - StockDividendDto, } from '../presentation/stock.dto'; -import { decimal } from '../../../../../integrations/tbank/decimal'; const ISO_YEAR_LENGTH = 4; const INCOME_HISTORY_YEARS = 5; @Injectable() @@ -40,6 +41,7 @@ export class StockIncomeService { meta: { ...saved.meta, resourceKey: `stock/position/${uid}` }, }; } + async dividends( userId: string, uid: string, @@ -58,21 +60,22 @@ export class StockIncomeService { ['detail', 'dividends'], { from, to }, ); - if (sources[0].meta.availability === 'complete' && !sources[0].instrument) + if (sources[0].meta.availability === 'complete' && !sources[0].instrument) { throw new NotFoundException(); + } const events = sources[1].payload?.dividends; - const position = portfolio.data - ? instrumentPosition(portfolio.data, uid, 'STOCK') - : null; - const projected = events - ? stockDividends( + const position = portfolio.data ? + instrumentPosition(portfolio.data, uid, 'STOCK') : + null; + const projected = events ? + stockDividends( events, position, { from, to, today }, sources[1].meta.lastSuccessfulAt ?? new Date().toISOString(), !portfolio.data, - ) - : undefined; + ) : + undefined; const result = await this.forecasts.result({ userId, revision: connection.revision, @@ -83,9 +86,9 @@ export class StockIncomeService { candidate: portfolio.data && sources[0].instrument ? projected : undefined, }); - return result.data || !projected - ? result - : { + return result.data || !projected ? + result : + { data: stockDividends( events ?? [], null, @@ -100,7 +103,7 @@ export class StockIncomeService { export function stockDividends( events: TbankDividend[], - position: StockPositionDto | null, + position: null | StockPositionDto, window: { from: string; to: string; today: string }, observedAt: string, positionUnavailable: boolean, @@ -109,9 +112,9 @@ export function stockDividends( const quantity = position && position.accounts.length && - position.accounts.every((p) => decimalFromString(p.quantity).value > 0n) - ? decimalFromString(position.quantity) - : null; + position.accounts.every((p) => decimalFromString(p.quantity).value > 0n) ? + decimalFromString(position.quantity) : + null; const dividends: StockDividendDto[] = events .filter((event) => event.dividendType?.toLowerCase() !== 'cancelled') .map((event) => { @@ -120,29 +123,29 @@ export function stockDividends( const lastBuyDate = timestamp(event.lastBuyDate)?.slice(0, 10) ?? null; const currency = event.dividendNet?.currency?.toUpperCase(); const perShare = - currency && event.dividendNet - ? { currency, value: formatDecimal(decimal(event.dividendNet)) } - : null; + currency && event.dividendNet ? + { currency, value: formatDecimal(decimal(event.dividendNet)) } : + null; // A future payment can have an already-fixed register: keep the event, but do not estimate entitlement from today's holding. const eventDate = paymentDate ?? recordDate; - const timing: StockDividendDto['timing'] = eventDate - ? eventDate >= today - ? 'upcoming' - : 'past' - : 'undated'; + const timing: StockDividendDto['timing'] = eventDate ? + eventDate >= today ? + 'upcoming' : + 'past' : + 'undated'; const estimatedAmount = quantity && perShare && recordDate && recordDate >= today && - timing === 'upcoming' - ? { + timing === 'upcoming' ? + { currency: perShare.currency, value: formatDecimal( multiply(quantity, decimalFromString(perShare.value)), ), - } - : null; + } : + null; return { perShare, recordDate, diff --git a/src/modules/investments/instruments/stocks/application/stock-market.service.ts b/src/modules/investments/instruments/stocks/application/stock-market.service.ts index efc6b7c..82dac1d 100644 --- a/src/modules/investments/instruments/stocks/application/stock-market.service.ts +++ b/src/modules/investments/instruments/stocks/application/stock-market.service.ts @@ -1,9 +1,5 @@ -import { CandleService } from '../../application/candle.service'; -import { - CatalogEnrichmentService, - type CatalogPayload, -} from '../../application/catalog-enrichment.service'; import { Injectable } from '@nestjs/common'; + import { decimalFromString, divide, @@ -12,6 +8,11 @@ import { subtract, } from '../../../../../common/decimal'; import { decimal } from '../../../../../integrations/tbank/decimal'; +import { CandleService } from '../../application/candle.service'; +import { + CatalogEnrichmentService, + type CatalogPayload, +} from '../../application/catalog-enrichment.service'; import { timestamp } from '../../application/instrument-market'; import { StockCandlesQueryDto, @@ -26,6 +27,7 @@ export class StockMarketService { private readonly history: CandleService, private readonly sources: CatalogEnrichmentService, ) {} + async quote( userId: string, uid: string, @@ -39,12 +41,13 @@ export class StockMarketService { observe, ); return { - data: result.values.instrument - ? stockQuote(result.values, result.unavailable, result.observedAt) - : null, + data: result.values.instrument ? + stockQuote(result.values, result.unavailable, result.observedAt) : + null, meta: result.meta, }; } + candles( userId: string, uid: string, @@ -69,20 +72,20 @@ export function stockQuote( const priceAt = timestamp(last?.time); const currency = share?.currency?.toUpperCase(); const price = - last?.price && currency - ? { currency, value: formatDecimal(decimal(last.price)) } - : null; + last?.price && currency ? + { currency, value: formatDecimal(decimal(last.price)) } : + null; const previousPrice = - previous?.close && currency - ? { currency, value: formatDecimal(decimal(previous.close)) } - : null; + previous?.close && currency ? + { currency, value: formatDecimal(decimal(previous.close)) } : + null; const difference = - price && previousPrice - ? subtract( + price && previousPrice ? + subtract( decimalFromString(price.value), decimalFromString(previousPrice.value), - ) - : null; + ) : + null; return { price, priceAt, @@ -90,21 +93,21 @@ export function stockQuote( previousCloseDate: timestamp(previous?.time)?.slice(0, 10) ?? null, previousClose: previousPrice, change: - difference && currency - ? { currency, value: formatDecimal(difference) } - : null, + difference && currency ? + { currency, value: formatDecimal(difference) } : + null, changePercent: difference && previousPrice && - decimalFromString(previousPrice.value).value > 0n - ? formatDecimal( + decimalFromString(previousPrice.value).value > 0n ? + formatDecimal( divide( multiply(difference, decimalFromString('100')), decimalFromString(previousPrice.value), CHANGE_PERCENT_DECIMAL_PLACES, ), - ) - : null, + ) : + null, unavailable, observedAt, }; diff --git a/src/modules/investments/instruments/stocks/application/stock.spec.ts b/src/modules/investments/instruments/stocks/application/stock.spec.ts index 2ae894b..8aa115c 100644 --- a/src/modules/investments/instruments/stocks/application/stock.spec.ts +++ b/src/modules/investments/instruments/stocks/application/stock.spec.ts @@ -1,7 +1,7 @@ +import { previousClose } from '../../application/instrument-market'; import { metrics } from './get-stock-company.use-case'; import { stockDividends } from './stock-income.service'; import { stockQuote } from './stock-market.service'; -import { previousClose } from '../../application/instrument-market'; jest.mock('../../../../../prisma/prisma.service', () => ({ PrismaService: class {}, })); diff --git a/src/modules/investments/instruments/stocks/presentation/stock.controller.ts b/src/modules/investments/instruments/stocks/presentation/stock.controller.ts index aa8dfe7..04c74e4 100644 --- a/src/modules/investments/instruments/stocks/presentation/stock.controller.ts +++ b/src/modules/investments/instruments/stocks/presentation/stock.controller.ts @@ -1,4 +1,5 @@ -import { InvestmentReadQueryDto } from '../../../investment-resource.dto'; +import type { Request } from 'express'; + import { Controller, Get, @@ -9,25 +10,26 @@ import { 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 { GetStockCompanyUseCase } from '../application/get-stock-company.use-case'; +import { StockCatalogService } from '../application/stock-catalog.service'; import { StockIncomeService } from '../application/stock-income.service'; import { StockMarketService } from '../application/stock-market.service'; import { - StockCandlesResourceDto, StockCandlesQueryDto, + StockCandlesResourceDto, StockCompanyResourceDto, StockDividendsResourceDto, - StockResourceDto, StockPositionResourceDto, StockQuoteResourceDto, + StockResourceDto, StockSearchQueryDto, StockSearchResourceDto, StockTickerParamsDto, } from './stock.dto'; -import { StockCatalogService } from '../application/stock-catalog.service'; @ApiTags('stocks') @ApiProblemResponses() @@ -41,6 +43,7 @@ export class StockController { private readonly income: StockIncomeService, private readonly companyInfo: GetStockCompanyUseCase, ) {} + @Get() @ApiOkResponse({ type: StockSearchResourceDto }) search( @@ -53,6 +56,7 @@ export class StockController { query.sync === 'observe', ); } + @Get('by-ticker/:ticker') @ApiOkResponse({ type: StockResourceDto }) getByTicker( @@ -66,6 +70,7 @@ export class StockController { query.sync === 'observe', ); } + @Get(':uid') @ApiOkResponse({ type: StockResourceDto }) get( @@ -75,6 +80,7 @@ export class StockController { ) { return this.stocks.get(request.user.sub, uid, query.sync === 'observe'); } + @Get(':uid/quote') @ApiOkResponse({ type: StockQuoteResourceDto }) quote( @@ -84,6 +90,7 @@ export class StockController { ) { return this.market.quote(request.user.sub, uid, query.sync === 'observe'); } + @Get(':uid/candles') @ApiOkResponse({ type: StockCandlesResourceDto }) candles( @@ -98,6 +105,7 @@ export class StockController { query.sync === 'observe', ); } + @Get(':uid/position') @ApiOkResponse({ type: StockPositionResourceDto }) position( @@ -111,6 +119,7 @@ export class StockController { query.sync === 'observe', ); } + @Get(':uid/company') @ApiOkResponse({ type: StockCompanyResourceDto }) company( @@ -124,6 +133,7 @@ export class StockController { query.sync === 'observe', ); } + @Get(':uid/dividends') @ApiOkResponse({ type: StockDividendsResourceDto }) dividends( diff --git a/src/modules/investments/instruments/stocks/presentation/stock.dto.ts b/src/modules/investments/instruments/stocks/presentation/stock.dto.ts index c722395..4fa0620 100644 --- a/src/modules/investments/instruments/stocks/presentation/stock.dto.ts +++ b/src/modules/investments/instruments/stocks/presentation/stock.dto.ts @@ -1,7 +1,3 @@ -import { - InvestmentReadQueryDto, - InvestmentResourceMetaDto, -} from '../../../investment-resource.dto'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { @@ -12,7 +8,12 @@ import { MaxLength, MinLength, } from 'class-validator'; + import { MoneyDto } from '../../../../../common/money.dto'; +import { + InvestmentReadQueryDto, + InvestmentResourceMetaDto, +} from '../../../investment-resource.dto'; import { PortfolioPositionDto } from '../../../portfolio/portfolio.api'; const MAX_INSTRUMENT_QUERY_LENGTH = 80; @@ -36,7 +37,7 @@ export class StockTickerParamsDto { @IsString() @MinLength(1) @MaxLength(MAX_INSTRUMENT_QUERY_LENGTH) - @Matches(/^[A-Za-z0-9][A-Za-z0-9._@+-]*$/) + @Matches(/^[A-Z0-9][\w.@+-]*$/i) declare ticker: string; } export class StockCandlesQueryDto extends InvestmentReadQueryDto { @@ -46,7 +47,7 @@ export class StockCandlesQueryDto extends InvestmentReadQueryDto { }) @IsOptional() @IsIn(['month', 'halfYear', 'year', 'all']) - period?: 'month' | 'halfYear' | 'year' | 'all'; + period?: 'all' | 'halfYear' | 'month' | 'year'; } export class StockDto { @ApiProperty({ format: 'uuid' }) declare instrumentUid: string; @@ -54,48 +55,63 @@ export class StockDto { description: 'Unambiguous ticker URL segment, or the exact instrument UID.', }) declare slug: string; + @ApiProperty() declare name: string; - @ApiProperty({ type: String, nullable: true }) declare isin: string | null; + @ApiProperty({ type: String, nullable: true }) declare isin: null | string; @ApiProperty({ type: String, nullable: true }) declare classCode: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare realExchange: - string | null; + null | string; + @ApiProperty({ type: Boolean, nullable: true }) declare apiTradeAvailable: boolean | null; - @ApiProperty({ type: String, nullable: true }) declare ticker: string | null; + + @ApiProperty({ type: String, nullable: true }) declare ticker: null | string; @ApiProperty({ type: String, nullable: true }) declare logoName: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare currency: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare shareType: - string | null; - @ApiProperty({ type: String, nullable: true }) declare sector: string | null; - @ApiProperty({ type: Number, nullable: true }) declare lot: number | null; + null | string; + + @ApiProperty({ type: String, nullable: true }) declare sector: null | string; + @ApiProperty({ type: Number, nullable: true }) declare lot: null | number; @ApiProperty({ type: String, nullable: true, format: 'date-time' }) - declare historyFrom: string | null; + declare historyFrom: null | string; } export class StockSearchResultDto extends StockDto { @ApiProperty({ type: MoneyDto, nullable: true }) declare price: MoneyDto | null; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) - declare priceAt: string | null; + declare priceAt: null | string; } export class StockQuoteDto { @ApiProperty({ type: MoneyDto, nullable: true }) declare price: MoneyDto | null; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) - declare priceAt: string | null; + declare priceAt: null | string; + @ApiProperty({ type: String, nullable: true }) declare tradingStatus: - string | null; + null | string; + @ApiProperty({ type: MoneyDto, nullable: true }) declare change: MoneyDto | null; + @ApiProperty({ type: String, nullable: true }) declare changePercent: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true, format: 'date' }) - declare previousCloseDate: string | null; + declare previousCloseDate: null | string; + @ApiProperty({ type: MoneyDto, nullable: true }) declare previousClose: MoneyDto | null; - @ApiProperty({ type: [String] }) declare unavailable: string[]; + + @ApiProperty({ type: String, isArray: true }) declare unavailable: string[]; @ApiProperty({ format: 'date-time' }) declare observedAt: string; } export class StockCandleDto { @@ -104,29 +120,36 @@ export class StockCandleDto { @ApiProperty() declare isComplete: boolean; } export class StockCandlesDto { - @ApiProperty({ type: [StockCandleDto] }) declare candles: StockCandleDto[]; + @ApiProperty({ type: StockCandleDto, isArray: true }) declare candles: StockCandleDto[]; @ApiProperty({ type: String, nullable: true }) declare currency: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) - declare historyFrom: string | null; + declare historyFrom: null | string; + @ApiProperty({ format: 'date-time' }) declare observedAt: string; } export class StockPositionRowDto extends PortfolioPositionDto { @ApiProperty({ type: String, nullable: true }) declare returnPercent: - string | null; + null | string; } export class StockPositionDto { @ApiProperty() declare quantity: string; @ApiProperty({ type: MoneyDto, nullable: true }) declare currentValue: MoneyDto | null; + @ApiProperty({ type: MoneyDto, nullable: true }) declare averagePurchasePrice: MoneyDto | null; + @ApiProperty({ type: MoneyDto, nullable: true }) declare absoluteReturn: MoneyDto | null; + @ApiProperty({ type: String, nullable: true }) declare returnPercent: - string | null; - @ApiProperty({ type: [StockPositionRowDto] }) + null | string; + + @ApiProperty({ type: StockPositionRowDto, isArray: true }) declare accounts: StockPositionRowDto[]; + @ApiProperty() declare isStale: boolean; @ApiProperty({ format: 'date-time' }) declare observedAt: string; } @@ -134,90 +157,108 @@ export class StockMetricDto { @ApiProperty() declare key: string; @ApiProperty() declare value: number; @ApiProperty({ enum: ['money', 'ratio', 'percent'] }) declare unit: - 'money' | 'ratio' | 'percent'; + 'money' | 'percent' | 'ratio'; + @ApiProperty({ type: String, nullable: true }) declare currency: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true, enum: ['TTM', 'MRQ'] }) - declare period: 'TTM' | 'MRQ' | null; + declare period: 'MRQ' | 'TTM' | null; } export class StockCompanyDto { @ApiProperty({ type: String, nullable: true }) declare description: - string | null; - @ApiProperty({ type: [StockMetricDto] }) declare metrics: StockMetricDto[]; - @ApiProperty({ type: [String] }) declare unavailable: string[]; + null | string; + + @ApiProperty({ type: StockMetricDto, isArray: true }) declare metrics: StockMetricDto[]; + @ApiProperty({ type: String, isArray: true }) declare unavailable: string[]; @ApiProperty({ format: 'date-time' }) declare observedAt: string; } export class StockDividendDto { @ApiProperty({ type: MoneyDto, nullable: true }) declare perShare: MoneyDto | null; + @ApiProperty({ type: String, nullable: true, format: 'date' }) - declare recordDate: string | null; + declare recordDate: null | string; + @ApiProperty({ type: String, nullable: true, format: 'date' }) - declare lastBuyDate: string | null; + declare lastBuyDate: null | string; + @ApiProperty({ type: String, nullable: true, format: 'date' }) - declare paymentDate: string | null; + declare paymentDate: null | string; + @ApiProperty({ enum: ['upcoming', 'past', 'undated'] }) declare timing: - 'upcoming' | 'past' | 'undated'; + 'past' | 'undated' | 'upcoming'; + @ApiProperty({ type: MoneyDto, nullable: true }) declare estimatedAmount: MoneyDto | null; } export class StockDividendsDto { - @ApiProperty({ type: [StockDividendDto] }) + @ApiProperty({ type: StockDividendDto, isArray: true }) declare dividends: StockDividendDto[]; + @ApiProperty({ format: 'date' }) declare from: string; @ApiProperty({ format: 'date' }) declare to: string; @ApiProperty({ format: 'date-time' }) declare observedAt: string; @ApiProperty({ type: String, nullable: true, format: 'date-time' }) - declare positionAt: string | null; + declare positionAt: null | string; + @ApiProperty() declare estimateUnavailable: boolean; @ApiProperty() declare positionIsStale: boolean; } export class StockPositionResourceDto { @ApiProperty({ type: StockPositionDto, nullable: true }) - declare data: StockPositionDto | null; + declare data: null | StockPositionDto; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } export class StockSearchResourceDto { + // eslint-disable-next-line @darraghor/nestjs-typed/api-property-returning-array-should-set-array -- Nullable array union requires isArray in the explicit wire schema. @ApiProperty({ type: StockSearchResultDto, isArray: true, nullable: true }) - declare data: StockSearchResultDto[] | null; + declare data: null | StockSearchResultDto[]; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } export class StockResourceDto { @ApiProperty({ type: StockDto, nullable: true }) - declare data: StockDto | null; + declare data: null | StockDto; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } export class StockQuoteResourceDto { @ApiProperty({ type: StockQuoteDto, nullable: true }) - declare data: StockQuoteDto | null; + declare data: null | StockQuoteDto; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } export class StockCompanyResourceDto { @ApiProperty({ type: StockCompanyDto, nullable: true }) - declare data: StockCompanyDto | null; + declare data: null | StockCompanyDto; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } export class StockCandlesResourceDto { @ApiProperty({ type: StockCandlesDto, nullable: true }) - declare data: StockCandlesDto | null; + declare data: null | StockCandlesDto; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } export class StockDividendsResourceDto { @ApiProperty({ type: StockDividendsDto, nullable: true }) - declare data: StockDividendsDto | null; + declare data: null | StockDividendsDto; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } diff --git a/src/modules/investments/investment-calendar.ts b/src/modules/investments/investment-calendar.ts index 4b7eb98..c2d76e0 100644 --- a/src/modules/investments/investment-calendar.ts +++ b/src/modules/investments/investment-calendar.ts @@ -39,8 +39,9 @@ export function dateParts(value: string): [number, number, number] { number, number, ]; - if (utcDate(value).toISOString().slice(0, 10) !== value) + if (utcDate(value).toISOString().slice(0, 10) !== value) { throw new Error('Invalid date'); + } return parts; } diff --git a/src/modules/investments/investment-resource.dto.ts b/src/modules/investments/investment-resource.dto.ts index bf0496a..32ab6e8 100644 --- a/src/modules/investments/investment-resource.dto.ts +++ b/src/modules/investments/investment-resource.dto.ts @@ -14,18 +14,22 @@ export class InvestmentReadQueryDto { export class InvestmentSyncDto { @ApiProperty({ type: String, nullable: true }) - declare id: string | null; + declare id: null | string; + @ApiProperty({ enum: syncStateValues }) declare state: (typeof syncStateValues)[number]; + @ApiProperty({ type: String, nullable: true }) - declare errorCode: string | null; + declare errorCode: null | string; } export class InvestmentRangeDto { @ApiPropertyOptional() accountId?: string; + @ApiProperty({ format: 'date-time' }) declare from: string; + @ApiProperty({ format: 'date-time' }) declare to: string; } @@ -33,38 +37,51 @@ export class InvestmentRangeDto { export class InvestmentCoverageDto { @ApiProperty({ type: InvestmentRangeDto, isArray: true }) declare requested: InvestmentRangeDto[]; + @ApiProperty({ type: InvestmentRangeDto, isArray: true }) declare completeRanges: InvestmentRangeDto[]; + @ApiProperty({ type: InvestmentRangeDto, isArray: true }) declare missingRanges: InvestmentRangeDto[]; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) - declare asOf: string | null; + declare asOf: null | string; + @ApiProperty({ enum: ['known', 'unknown'] }) declare historyBoundary: 'known' | 'unknown'; + @ApiProperty({ enum: ['missing', 'loading', 'partial', 'complete', 'failed'], }) declare historyState: - 'missing' | 'loading' | 'partial' | 'complete' | 'failed'; + 'complete' | 'failed' | 'loading' | 'missing' | 'partial'; } export class InvestmentDependencyDto { @ApiProperty() declare dataRevision: string; + @ApiProperty() declare stateVersion: string; + @ApiPropertyOptional({ type: String }) unavailableReason?: string; + @ApiPropertyOptional({ type: () => InvestmentCoverageDto }) coverage?: InvestmentCoverageDto; + @ApiProperty() declare resourceKey: string; + @ApiProperty({ enum: availabilityValues }) declare availability: (typeof availabilityValues)[number]; + @ApiProperty({ enum: freshnessValues }) declare freshness: (typeof freshnessValues)[number]; + @ApiProperty({ type: String, format: 'date-time', nullable: true }) - declare lastSuccessfulAt: string | null; + declare lastSuccessfulAt: null | string; + @ApiProperty({ type: InvestmentSyncDto }) declare sync: InvestmentSyncDto; } diff --git a/src/modules/investments/investment-resource.mapper.ts b/src/modules/investments/investment-resource.mapper.ts index 0e47aea..dc7da49 100644 --- a/src/modules/investments/investment-resource.mapper.ts +++ b/src/modules/investments/investment-resource.mapper.ts @@ -1,25 +1,26 @@ import { createHash } from 'node:crypto'; + import { InvestmentResourceMetaDto } from './investment-resource.dto'; export function investmentResourceMeta(input: { - resourceKey: string; - dataRevision: string; availability: InvestmentResourceMetaDto['availability']; + coverage?: NonNullable; + dataRevision: string; + dependencies?: InvestmentResourceMetaDto[]; fetchedAt: Date | null; freshForMs: number; - work: { id: string; state: string; errorCode: string | null } | null; - dependencies?: InvestmentResourceMetaDto[]; - coverage?: InvestmentResourceMetaDto['coverage']; + resourceKey: string; unavailableReason?: string; + work: null | { errorCode: null | string; id: string; state: string }; }): InvestmentResourceMetaDto { const ownSync = { id: input.work?.id ?? null, state: - input.work?.state === 'running' - ? ('running' as const) - : input.work?.state === 'failed' - ? ('failed' as const) - : ('idle' as const), + input.work?.state === 'running' ? + ('running' as const) : + input.work?.state === 'failed' ? + ('failed' as const) : + ('idle' as const), errorCode: input.work?.errorCode ?? null, }; const states = [ @@ -33,11 +34,11 @@ export function investmentResourceMeta(input: { states.find((state) => state.state === 'queued') ?? states.find((state) => state.state === 'failed') ?? ownSync; - const sourceFreshness = input.fetchedAt - ? Date.now() - input.fetchedAt.valueOf() < input.freshForMs - ? ('fresh' as const) - : ('stale' as const) - : ('unknown' as const); + const sourceFreshness = input.fetchedAt ? + Date.now() - input.fetchedAt.valueOf() < input.freshForMs ? + ('fresh' as const) : + ('stale' as const) : + ('unknown' as const); const freshness = [ sourceFreshness, ...(input.dependencies ?? []) @@ -51,19 +52,20 @@ export function investmentResourceMeta(input: { ), ] .filter((timestamp): timestamp is string => Boolean(timestamp)) + // 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(); const meta = { resourceKey: input.resourceKey, - ...(input.unavailableReason - ? { unavailableReason: input.unavailableReason } - : {}), + ...(input.unavailableReason ? + { unavailableReason: input.unavailableReason } : + {}), dataRevision: input.dataRevision, availability: input.availability, - freshness: freshness.includes('unknown') - ? ('unknown' as const) - : freshness.includes('stale') - ? ('stale' as const) - : ('fresh' as const), + freshness: freshness.includes('unknown') ? + ('unknown' as const) : + freshness.includes('stale') ? + ('stale' as const) : + ('fresh' as const), lastSuccessfulAt: timestamps[0] ?? null, sync, ...(input.coverage ? { coverage: input.coverage } : {}), @@ -77,9 +79,9 @@ export function investmentResourceMeta(input: { ...meta, // The requested window is identified by resourceKey. Its moving "now" // boundary is not a change to saved coverage between status and GET. - coverage: meta.coverage - ? { ...meta.coverage, requested: undefined } - : undefined, + coverage: meta.coverage ? + { ...meta.coverage, requested: undefined } : + undefined, }), ) .digest('base64url'), diff --git a/src/modules/investments/investment-sync-plan.spec.ts b/src/modules/investments/investment-sync-plan.spec.ts index 7dd5b03..58d0915 100644 --- a/src/modules/investments/investment-sync-plan.spec.ts +++ b/src/modules/investments/investment-sync-plan.spec.ts @@ -1,7 +1,7 @@ import { - planInvestmentScreen, - ledgerRange, accountHistory, + ledgerRange, + planInvestmentScreen, } from './investment-sync-plan'; describe('screen dependency and history planning', () => { diff --git a/src/modules/investments/investment-sync-plan.ts b/src/modules/investments/investment-sync-plan.ts index 42a638b..e5b01b7 100644 --- a/src/modules/investments/investment-sync-plan.ts +++ b/src/modules/investments/investment-sync-plan.ts @@ -1,58 +1,58 @@ import { planIncomePeriod } from './income/income.api'; import { dateOnlyBounds, moscowDate } from './investment-calendar'; import { - operationIntervals, type OperationInterval, + operationIntervals, } from './operations/domain/operations-sync-plan'; export type InvestmentScreen = { - screen: - | 'accounts' - | 'account' - | 'operations' - | 'income' - | 'events' - | 'stocks' - | 'bonds' - | 'stock' - | 'bond'; accountId?: string; + candlePeriod?: 'all' | 'halfYear' | 'month' | 'year'; + candleRange?: { from: Date; to: Date }; from?: string; - to?: string; instrumentUid?: string; query?: string; - candlePeriod?: 'month' | 'halfYear' | 'year' | 'all'; - candleRange?: { from: Date; to: Date }; + screen: + | 'account' | + 'accounts' | + 'bond' | + 'bonds' | + 'events' | + 'income' | + 'operations' | + 'stock' | + 'stocks'; + to?: string; }; const defaultEventDays = 30; const incomeHistoryYears = 5; const candleMonths = { month: 1, halfYear: 6, year: 12 }; -type Account = { accountId: string; openedOn: string | null }; +type Account = { accountId: string; openedOn: null | string }; type Instrument = { - uid: string; - type: 'STOCK' | 'BOND'; historyFrom: Date | null; + type: 'BOND' | 'STOCK'; + uid: string; }; type Source = { - kind: - | 'membership' - | 'portfolio' - | 'operations' - | 'metadata' - | 'catalog' - | 'quote' - | 'company' - | 'metrics' - | 'calendar' - | 'candles'; accountId?: string; - instrumentUid?: string; - instrumentType?: 'STOCK' | 'BOND'; from?: Date; - to?: Date; + instrumentType?: 'BOND' | 'STOCK'; + instrumentUid?: string; + kind: + | 'calendar' | + 'candles' | + 'catalog' | + 'company' | + 'membership' | + 'metadata' | + 'metrics' | + 'operations' | + 'portfolio' | + 'quote'; + priority: 'discovery' | 'history' | 'screen'; query?: string; - priority: 'discovery' | 'screen' | 'history'; + to?: Date; }; // Expands screen scope, never credentials or arbitrary caller-supplied jobs. @@ -74,19 +74,19 @@ export function planInvestmentScreen( screen.accountId === 'all' || account.accountId === screen.accountId, ); - const unavailable: { resourceKey: string; reason: string }[] = []; + const unavailable: { reason: string; resourceKey: string }[] = []; const operations = (from: Date, to: Date, instrumentUid?: string) => { const targets = accounts.flatMap((account) => - from < to - ? [ + from < to ? + [ { accountId: account.accountId, from, to, ...(instrumentUid ? { instrumentUid } : {}), }, - ] - : [], + ] : + [], ); sources.push( ...operationIntervals(targets).map((range) => ({ @@ -105,16 +105,18 @@ export function planInvestmentScreen( })), ); const knownOpening = - accounts.length && accounts.every((account) => account.openedOn) - ? new Date( + accounts.length && accounts.every((account) => account.openedOn) ? + new Date( + // 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. `${accounts.map((account) => account.openedOn ?? '').sort()[0]}T00:00:00+03:00`, - ) - : undefined; + ) : + undefined; if (!context.membershipKnown) return { sources, unavailable, now }; if ( - ['accounts', 'account', 'events', 'stock', 'bond'].includes(screen.screen) - ) + ['account', 'accounts', 'bond', 'events', 'stock'].includes(screen.screen) + ) { portfolio(); + } if ( screen.screen === 'operations' || screen.screen === 'stock' || @@ -137,18 +139,19 @@ export function planInvestmentScreen( ); for (const range of [ period.selected, - ...(screen.screen === 'income' - ? [period.previous, ...(period.yearAgo ? [period.yearAgo] : [])] - : []), + ...(screen.screen === 'income' ? + [period.previous, ...(period.yearAgo ? [period.yearAgo] : [])] : + []), ]) { const bounds = dateOnlyBounds(range, now); operations(bounds.from, bounds.to); } - if (screen.screen === 'income' && !period.yearAgo) + if (screen.screen === 'income' && !period.yearAgo) { unavailable.push({ resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier reason: 'calendar_or_account_boundary', }); + } if (screen.screen === 'account') { const range = ledgerRange( screen.from, @@ -160,13 +163,14 @@ export function planInvestmentScreen( operations(range.from, range.to); } } - if (screen.screen === 'stocks' || screen.screen === 'bonds') + if (screen.screen === 'stocks' || screen.screen === 'bonds') { sources.push({ kind: 'catalog', instrumentType: screen.screen === 'stocks' ? 'STOCK' : 'BOND', query: screen.query?.trim().toUpperCase() ?? '', priority: 'screen', }); + } const instruments = context.instruments.filter( (instrument) => !screen.instrumentUid || instrument.uid === screen.instrumentUid, @@ -175,20 +179,22 @@ export function planInvestmentScreen( screen.instrumentUid && !instruments.length && (screen.screen === 'stock' || screen.screen === 'bond') - ) + ) { instruments.push({ uid: screen.instrumentUid, type: screen.screen === 'stock' ? 'STOCK' : 'BOND', historyFrom: null, }); + } for (const instrument of instruments) { const identity = { instrumentUid: instrument.uid, instrumentType: instrument.type, }; sources.push({ ...identity, kind: 'metadata', priority: 'screen' }); - if (['stocks', 'bonds', 'stock', 'bond'].includes(screen.screen)) + if (['bond', 'bonds', 'stock', 'stocks'].includes(screen.screen)) { sources.push({ ...identity, kind: 'quote', priority: 'screen' }); + } if (screen.screen === 'stock' || screen.screen === 'bond') { sources.push({ ...identity, @@ -197,47 +203,51 @@ export function planInvestmentScreen( }); const from = new Date(now); const period = screen.candlePeriod ?? 'month'; - if (period !== 'all') + if (period !== 'all') { + // eslint-disable-next-line security/detect-object-injection -- The period belongs to the validated candle-period union. from.setUTCMonth(from.getUTCMonth() - candleMonths[period]); + } const range = screen.candleRange ?? - (period === 'all' - ? instrument.historyFrom - ? { from: instrument.historyFrom, to: now } - : null - : { from, to: now }); - if (range) + (period === 'all' ? + instrument.historyFrom ? + { from: instrument.historyFrom, to: now } : + null : + { from, to: now }); + if (range) { sources.push({ ...identity, kind: 'candles', ...range, priority: 'screen', }); - else + } else { unavailable.push({ resourceKey: `candles/${instrument.uid}/all`, reason: 'history_start_unavailable', }); + } } - if (['account', 'events', 'stock', 'bond'].includes(screen.screen)) { + if (['account', 'bond', 'events', 'stock'].includes(screen.screen)) { const day = - screen.screen === 'stock' || screen.screen === 'bond' - ? moscowDate(now) - : now.toISOString().slice(0, 10); + screen.screen === 'stock' || screen.screen === 'bond' ? + moscowDate(now) : + now.toISOString().slice(0, 10); const from = - screen.screen === 'events' && screen.from - ? new Date(`${screen.from}T00:00:00Z`) - : new Date(`${day}T00:00:00Z`); + screen.screen === 'events' && screen.from ? + new Date(`${screen.from}T00:00:00Z`) : + new Date(`${day}T00:00:00Z`); const to = - screen.screen === 'events' && screen.to - ? new Date(`${screen.to}T00:00:00Z`) - : new Date(from); + screen.screen === 'events' && screen.to ? + new Date(`${screen.to}T00:00:00Z`) : + new Date(from); if ( !(screen.screen === 'events' && screen.to) && screen.screen !== 'stock' && screen.screen !== 'bond' - ) + ) { to.setUTCDate(to.getUTCDate() + defaultEventDays); + } if (screen.screen === 'stock' || screen.screen === 'bond') { from.setUTCFullYear(from.getUTCFullYear() - incomeHistoryYears); to.setUTCFullYear(to.getUTCFullYear() + 1); @@ -252,9 +262,9 @@ export function planInvestmentScreen( } if ( !force && - ['account', 'operations', 'income', 'events'].includes(screen.screen) + ['account', 'events', 'income', 'operations'].includes(screen.screen) ) { - if (instrument.historyFrom) + if (instrument.historyFrom) { sources.push({ ...identity, kind: 'candles', @@ -262,14 +272,15 @@ export function planInvestmentScreen( to: now, priority: 'history', }); - else + } else { unavailable.push({ resourceKey: `candles/${instrument.uid}/all`, reason: 'history_start_unavailable', }); + } } } - if (!force && !['stocks', 'bonds'].includes(screen.screen)) + if (!force && !['bonds', 'stocks'].includes(screen.screen)) { sources.push( ...accountHistory(accounts, now).map((range) => ({ ...range, @@ -277,6 +288,7 @@ export function planInvestmentScreen( priority: 'history' as const, })), ); + } const priorities = { discovery: 0, screen: 1, history: 2 }; sources.sort( (left, right) => priorities[left.priority] - priorities[right.priority], @@ -290,9 +302,9 @@ export function accountHistory( ): OperationInterval[] { return accounts.flatMap((account) => { const from = new Date( - account.openedOn - ? `${account.openedOn}T00:00:00Z` - : '1970-01-01T00:00:00Z', + account.openedOn ? + `${account.openedOn}T00:00:00Z` : + '1970-01-01T00:00:00Z', ); return from < now ? [{ accountId: account.accountId, from, to: now }] : []; }); @@ -305,21 +317,22 @@ export function ledgerRange( accounts: Account[], now: Date, ) { - const opening = accounts.every((account) => account.openedOn) - ? accounts.map((account) => account.openedOn ?? '').sort()[0] - : undefined; - const from = fromValue - ? new Date(fromValue) - : instrumentUid - ? new Date(opening ? `${opening}T00:00:00Z` : '1970-01-01T00:00:00Z') - : new Date(Date.UTC(now.getUTCFullYear(), 0, 1)); - const end = toValue - ? new Date( - /^\d{4}-\d{2}-\d{2}$/.test(toValue) - ? `${toValue}T23:59:59.999Z` - : toValue, - ) - : now; + const opening = accounts.every((account) => account.openedOn) ? + // 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. + accounts.map((account) => account.openedOn ?? '').sort()[0] : + undefined; + const from = fromValue ? + new Date(fromValue) : + instrumentUid ? + new Date(opening ? `${opening}T00:00:00Z` : '1970-01-01T00:00:00Z') : + new Date(Date.UTC(now.getUTCFullYear(), 0, 1)); + const end = toValue ? + new Date( + /^\d{4}-\d{2}-\d{2}$/.test(toValue) ? + `${toValue}T23:59:59.999Z` : + toValue, + ) : + now; return { from, to: new Date(Math.min(end.valueOf() + (toValue ? 1 : 0), now.valueOf())), diff --git a/src/modules/investments/operations/application/brokerage-operations-sync.service.ts b/src/modules/investments/operations/application/brokerage-operations-sync.service.ts index cb37443..8e635eb 100644 --- a/src/modules/investments/operations/application/brokerage-operations-sync.service.ts +++ b/src/modules/investments/operations/application/brokerage-operations-sync.service.ts @@ -1,22 +1,23 @@ -import { accountHistory } from '../../investment-sync-plan'; import { Injectable, NotFoundException } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; -import { MILLISECONDS_PER_SECOND } from '../../../../common/time'; + import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; +import { MILLISECONDS_PER_SECOND } from '../../../../common/time'; import { TbankAccessService, TbankClient, } from '../../../../integrations/tbank/tbank.api'; +import { accountHistory } from '../../investment-sync-plan'; +import { + type OperationInterval, + operationIntervals, + planOperationRead, + uncoveredOperationIntervals, +} from '../domain/operations-sync-plan'; import { BrokerageOperationsRepository, type OperationAttempt, } from '../infrastructure/brokerage-operations.repository'; -import { - operationIntervals, - planOperationRead, - type OperationInterval, - uncoveredOperationIntervals, -} from '../domain/operations-sync-plan'; const operationsPageSize = 1000; const priorities = { screen: 1, history: 2 } as const; @@ -29,6 +30,7 @@ export class BrokerageOperationsSyncService { string, { attempt: OperationAttempt; priority: number } >(); + private draining = false; // Only short DB reservations/publications are serialized, never RPCs. private reservations: Promise = Promise.resolve(); @@ -44,10 +46,10 @@ export class BrokerageOperationsSyncService { userId: string, intervals: OperationInterval[], options: { - movingEnd?: boolean; force?: boolean; - observe?: boolean; history?: boolean; + movingEnd?: boolean; + observe?: boolean; revision?: string; } = {}, ) { @@ -55,8 +57,9 @@ export class BrokerageOperationsSyncService { if ( !connection.membershipAt || (options.revision && options.revision !== connection.revision) - ) + ) { return { plan: null, handle: null }; + } if ( intervals.some( (interval) => @@ -64,8 +67,9 @@ export class BrokerageOperationsSyncService { (account) => account.accountId === interval.accountId, ), ) - ) + ) { throw new NotFoundException(); + } const coverage = await this.repository.coverage( userId, connection.revision, @@ -76,24 +80,24 @@ export class BrokerageOperationsSyncService { ...intervals.map((interval) => interval.to.valueOf()), ); const targets = intervals.map((interval) => - options.movingEnd && interval.to.valueOf() === movingTo - ? { ...interval, to: now } - : interval, + options.movingEnd && interval.to.valueOf() === movingTo ? + { ...interval, to: now } : + interval, ); const plan = planOperationRead(targets, coverage, now, options); const complete = plan.intervals.every( (interval) => interval.availability === 'complete', ); const handle = - !options.observe && plan.load.length - ? await this.request( + !options.observe && plan.load.length ? + await this.request( userId, plan.load, complete || Boolean(options.force), 'screen', connection.revision, - ) - : null; + ) : + null; if (!options.observe && !options.force && options.history) { const accountIds = new Set( intervals.map((interval) => interval.accountId), @@ -108,7 +112,7 @@ export class BrokerageOperationsSyncService { movingEnd: true, }).load; const remaining = uncoveredOperationIntervals(missing, targets); - if (remaining.length) + if (remaining.length) { await this.request( userId, remaining, @@ -116,6 +120,7 @@ export class BrokerageOperationsSyncService { 'history', connection.revision, ); + } } return { plan, handle }; } @@ -126,7 +131,7 @@ export class BrokerageOperationsSyncService { atomic: boolean, priority: keyof typeof priorities = 'screen', revision?: string, - ): Promise { + ): Promise { const connection = await this.access.local(userId); if ( connection.membershipAt && @@ -136,8 +141,9 @@ export class BrokerageOperationsSyncService { (account) => account.accountId === interval.accountId, ), ) - ) + ) { throw new NotFoundException(); + } if (revision && revision !== connection.revision) return null; const cutoff = new Date(); const bounded = operationIntervals(intervals) @@ -155,6 +161,7 @@ export class BrokerageOperationsSyncService { this.processRunId, atomic, this.runningAttempts, + // eslint-disable-next-line security/detect-object-injection -- The priority belongs to the internal priority union. priorities[priority], ); if (!requested) return null; @@ -258,17 +265,18 @@ export class BrokerageOperationsSyncService { Math.ceil(interval.to.valueOf() / MILLISECONDS_PER_SECOND) + 1, ), }, - ...(interval.instrumentUid - ? { instrumentId: interval.instrumentUid } - : {}), + ...(interval.instrumentUid ? + { instrumentId: interval.instrumentUid } : + {}), cursor, limit: operationsPageSize, state: 'OPERATION_STATE_EXECUTED', }); if ( !(await this.repository.stagePage(attempt, interval, cursor, response)) - ) + ) { return false; + } if (!plan.atomic && !response.hasNext) { if ( !(await this.repository.publish( @@ -277,8 +285,9 @@ export class BrokerageOperationsSyncService { new Date(), true, )) - ) + ) { return false; + } } // One page per turn: newly requested screen work can overtake backfill. // TbankClient alone owns the bounded RPC queue and retries. diff --git a/src/modules/investments/operations/application/brokerage-operations.service.ts b/src/modules/investments/operations/application/brokerage-operations.service.ts index b29231e..d9bac28 100644 --- a/src/modules/investments/operations/application/brokerage-operations.service.ts +++ b/src/modules/investments/operations/application/brokerage-operations.service.ts @@ -1,41 +1,42 @@ -import { - ledgerRange, - accountHistory, - planInvestmentScreen, -} from '../../investment-sync-plan'; -import { ConfigService } from '@nestjs/config'; -import { investmentResourceMeta } from '../../investment-resource.mapper'; -import { BrokerageOperationsRepository } from '../infrastructure/brokerage-operations.repository'; -import { BrokerageOperationsSyncService } from './brokerage-operations-sync.service'; -import { - planOperationRead, - uncoveredOperationIntervals, - operationIntervalsOverlap, -} from '../domain/operations-sync-plan'; import { BadRequestException, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { createHash, createHmac, timingSafeEqual } from 'node:crypto'; + import { InstrumentMetadataService, metadataKey, TbankAccessService, TbankMembershipService, - tbankSourceTtlMs, type TbankOperationItem, + tbankSourceTtlMs, } from '../../../../integrations/tbank/tbank.api'; -import { DateOnlyRange, dateOnlyBounds } from '../../investment-calendar'; +import { dateOnlyBounds, DateOnlyRange } from '../../investment-calendar'; +import { investmentResourceMeta } from '../../investment-resource.mapper'; import { - DEFAULT_OPERATIONS_PAGE_SIZE, - BrokerageOperationsQueryDto, -} from '../presentation/brokerage-operation.dto'; + accountHistory, + ledgerRange, + planInvestmentScreen, +} from '../../investment-sync-plan'; +import { + operationIntervalsOverlap, + planOperationRead, + uncoveredOperationIntervals, +} from '../domain/operations-sync-plan'; import { mapBrokerageOperation } from '../infrastructure/brokerage-operation.mapper'; +import { BrokerageOperationsRepository } from '../infrastructure/brokerage-operations.repository'; +import { + BrokerageOperationsQueryDto, + DEFAULT_OPERATIONS_PAGE_SIZE, +} from '../presentation/brokerage-operation.dto'; +import { BrokerageOperationsSyncService } from './brokerage-operations-sync.service'; type LocalOperationsCursor = { - movingEnd?: boolean; - binding: string; - to: string; - at: string; accountId: string; + at: string; + binding: string; id: string; + movingEnd?: boolean; + to: string; }; @Injectable() @@ -70,18 +71,19 @@ export class BrokerageOperationsService { const from = range.from; const to = cursor ? new Date(cursor.to) : range.to; const movingEnd = cursor?.movingEnd ?? range.to.valueOf() === now.valueOf(); + // 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. const categories = [...new Set(query.categories ?? [])].sort(); const limit = query.limit ?? DEFAULT_OPERATIONS_PAGE_SIZE; const filterKey = JSON.stringify([ accountId, from.toISOString(), - query.to - ? new Date( - /^\d{4}-\d{2}-\d{2}$/.test(query.to) - ? `${query.to}T23:59:59.999Z` - : query.to, - ).toISOString() - : 'now', + query.to ? + new Date( + /^\d{4}-\d{2}-\d{2}$/.test(query.to) ? + `${query.to}T23:59:59.999Z` : + query.to, + ).toISOString() : + 'now', query.instrumentUid ?? null, categories, limit, @@ -118,9 +120,9 @@ export class BrokerageOperationsService { source.priority === 'screen' && source.accountId && source.from && - source.to - ? [{ accountId: source.accountId, from: source.from, to: source.to }] - : [], + source.to ? + [{ accountId: source.accountId, from: source.from, to: source.to }] : + [], ); if (query.sync !== 'observe') { const discovery = await this.membership.request( @@ -148,9 +150,9 @@ export class BrokerageOperationsService { accountId === 'all' || account.accountId === accountId, ) .flatMap((account) => - currentRange.from < currentRange.to - ? [{ accountId: account.accountId, ...currentRange }] - : [], + currentRange.from < currentRange.to ? + [{ accountId: account.accountId, ...currentRange }] : + [], ); await this.sync.readPlan(userId, targets, { movingEnd, @@ -170,16 +172,16 @@ export class BrokerageOperationsService { const saved = await this.repository.readPage(readInput); const accounts = saved?.connection.accounts ?? []; const targets = accounts.flatMap((account) => - from < to - ? [ + from < to ? + [ { accountId: account.accountId, from, to, instrumentUid: query.instrumentUid, }, - ] - : [], + ] : + [], ); const plan = planOperationRead(targets, saved?.coverage ?? [], now, { movingEnd, @@ -191,8 +193,8 @@ export class BrokerageOperationsService { row.accountId === target.accountId && (!row.instrumentUid || row.instrumentUid === target.instrumentUid) && - row.from < target.to && - target.from < row.to, + row.from < target.to && + target.from < row.to, ) .map((row) => ({ accountId: target.accountId, @@ -230,13 +232,8 @@ export class BrokerageOperationsService { sources .map((row) => row.fetchedAt) .sort((a, b) => a.valueOf() - b.valueOf())[0] ?? - (complete ? saved?.connection.membershipAt : null) ?? - null; - const serialize = (range: { accountId: string; from: Date; to: Date }) => ({ - accountId: range.accountId, - from: range.from.toISOString(), - to: range.to.toISOString(), - }); + (complete ? saved?.connection.membershipAt : null) ?? + null; const historyTargets = accountHistory(accounts, now); const history = planOperationRead( historyTargets, @@ -273,9 +270,11 @@ export class BrokerageOperationsService { ] as const; }), ); - for (const identity of identities.values()) - if (!instruments.get(metadataKey(identity))?.fresh) + for (const identity of identities.values()) { + if (!instruments.get(metadataKey(identity))?.fresh) { await this.metadata.request(userId, connection.revision, identity); + } + } } const membershipMeta = investmentResourceMeta({ resourceKey: 'accounts/membership', @@ -288,17 +287,17 @@ export class BrokerageOperationsService { const meta = investmentResourceMeta({ resourceKey: `operations/${createHash('sha256').update(filterKey).digest('base64url')}`, dataRevision: saved?.binding ?? 'missing', - availability: !available - ? 'missing' - : complete - ? 'complete' - : confirmed.length - ? 'partial' - : 'missing', + availability: !available ? + 'missing' : + complete ? + 'complete' : + confirmed.length ? + 'partial' : + 'missing', fetchedAt: lastSuccessfulAt, - freshForMs: plan.intervals.some((interval) => interval.stale) - ? 0 - : Number.POSITIVE_INFINITY, + freshForMs: plan.intervals.some((interval) => interval.stale) ? + 0 : + Number.POSITIVE_INFINITY, work: work ?? saved?.membershipWork ?? null, dependencies: [membershipMeta, ...metadataDependencies], coverage: { @@ -312,8 +311,8 @@ export class BrokerageOperationsService { (interval) => interval.accountId === account.accountId && interval.asOf, ), - ) - ? new Date( + ) ? + new Date( Math.min( ...accounts.map((account) => Math.max( @@ -326,42 +325,42 @@ export class BrokerageOperationsService { ), ), ), - ).toISOString() - : null, + ).toISOString() : + null, historyBoundary: knownBoundary ? 'known' : 'unknown', historyState: saved?.work.some( (work) => work.state === 'running' && work.priority > 1, - ) - ? 'loading' - : saved?.work.some( - (work) => work.state === 'failed' && work.priority > 1, - ) - ? 'failed' - : knownBoundary && - history.intervals.every( - (interval) => interval.availability === 'complete', - ) - ? 'complete' - : saved?.coverage.length - ? 'partial' - : 'missing', + ) ? + 'loading' : + saved?.work.some( + (work) => work.state === 'failed' && work.priority > 1, + ) ? + 'failed' : + knownBoundary && + history.intervals.every( + (interval) => interval.availability === 'complete', + ) ? + 'complete' : + saved?.coverage.length ? + 'partial' : + 'missing', }, }); const last = rows.at(-1); const hasNext = Boolean(saved && saved.rows.length > limit); return { data: - meta.availability === 'missing' - ? null - : { + meta.availability === 'missing' ? + null : + { accountId, items: rows.map((row) => ({ ...mapBrokerageOperation( row.payload as TbankOperationItem, row.occurredAt, ), - ...(accountId === 'all' - ? { + ...(accountId === 'all' ? + { account: { id: row.accountId, name: @@ -369,8 +368,8 @@ export class BrokerageOperationsService { (account) => account.accountId === row.accountId, )?.name ?? row.accountId, }, - } - : {}), + } : + {}), logoName: instruments .get(metadataKey(row.payload as TbankOperationItem)) @@ -378,16 +377,16 @@ export class BrokerageOperationsService { })), hasNext, nextCursor: - hasNext && last && saved - ? this.encodeCursor({ + hasNext && last && saved ? + this.encodeCursor({ binding: saved.binding, movingEnd, to: to.toISOString(), at: last.occurredAt.toISOString(), accountId: last.accountId, id: last.brokerOperationId, - }) - : null, + }) : + null, observedAt: now.toISOString(), }, meta, @@ -413,23 +412,25 @@ export class BrokerageOperationsService { extra || actual.length !== expected.length || !timingSafeEqual(actual, expected) - ) + ) { throw new Error(); + } const cursor = JSON.parse( Buffer.from(body, 'base64url').toString(), ) as Partial; if ( (cursor.movingEnd !== undefined && typeof cursor.movingEnd !== 'boolean') || - !cursor.binding || - !cursor.accountId || - !cursor.id || - !cursor.at || - !cursor.to || - !Number.isFinite(Date.parse(cursor.at)) || - !Number.isFinite(Date.parse(cursor.to)) - ) + !cursor.binding || + !cursor.accountId || + !cursor.id || + !cursor.at || + !cursor.to || + !Number.isFinite(Date.parse(cursor.at)) || + !Number.isFinite(Date.parse(cursor.to)) + ) { throw new Error(); + } return cursor as LocalOperationsCursor; } catch { throw new BadRequestException({ code: 'INVALID_OPERATIONS_CURSOR' }); @@ -465,12 +466,13 @@ export class BrokerageOperationsService { ...period, })), ); - if (targets.length) + if (targets.length) { await this.sync.readPlan(userId, targets, { movingEnd: snapshot.periods?.some((period) => period.movingEnd), history: true, revision: connection.revision, }); + } }; if (!observe) { const discovery = await this.membership.request( @@ -530,17 +532,8 @@ export class BrokerageOperationsService { sources .map((row) => row.fetchedAt) .sort((a, b) => a.valueOf() - b.valueOf())[0] ?? - (complete ? saved?.connection.membershipAt : null) ?? - null; - const serialize = (range: { - accountId: string; - from: Date; - to: Date; - }) => ({ - accountId: range.accountId, - from: range.from.toISOString(), - to: range.to.toISOString(), - }); + (complete ? saved?.connection.membershipAt : null) ?? + null; const confirmed = plan.intervals.flatMap((target) => sources .filter( @@ -555,15 +548,16 @@ export class BrokerageOperationsService { })), ); const meta = investmentResourceMeta({ + // eslint-disable-next-line security/detect-object-injection -- The numeric index enumerates the three comparison periods. resourceKey: `income/${accountId}/${['selected', 'previous', 'yearAgo'][index]}/${period.key}`, dataRevision: saved?.binding ?? 'missing', - availability: !known - ? 'missing' - : complete - ? 'complete' - : sources.length - ? 'partial' - : 'missing', + availability: !known ? + 'missing' : + complete ? + 'complete' : + sources.length ? + 'partial' : + 'missing', fetchedAt, freshForMs: plan.intervals.some((range) => range.stale) ? 0 : Infinity, work, @@ -576,8 +570,8 @@ export class BrokerageOperationsService { confirmed, ).map(serialize), asOf: - complete && targets.length - ? new Date( + complete && targets.length ? + new Date( Math.min( ...targets.map((target) => Math.max( @@ -592,24 +586,24 @@ export class BrokerageOperationsService { ), ), ), - ).toISOString() - : null, + ).toISOString() : + null, historyBoundary: (saved?.connection.accounts ?? []).every( (account) => account.openedOn, - ) - ? 'known' - : 'unknown', - historyState: complete - ? 'complete' - : sources.length - ? 'partial' - : 'missing', + ) ? + 'known' : + 'unknown', + historyState: complete ? + 'complete' : + sources.length ? + 'partial' : + 'missing', }, }); return { meta, - items: complete - ? (saved?.rows ?? []) + items: complete ? + (saved?.rows ?? []) .filter((row) => plan.intervals.some( (interval) => @@ -623,8 +617,8 @@ export class BrokerageOperationsService { row.payload as TbankOperationItem, row.occurredAt, ), - ...(accountId === 'all' - ? { + ...(accountId === 'all' ? + { account: { id: row.accountId, name: @@ -632,11 +626,15 @@ export class BrokerageOperationsService { (account) => account.accountId === row.accountId, )?.name ?? row.accountId, }, - } - : {}), - })) - : null, + } : + {}), + })) : + null, }; }); } } + +function serialize(range: { accountId: string; from: Date; to: Date }) { + return { accountId: range.accountId, from: range.from.toISOString(), to: range.to.toISOString() }; +} diff --git a/src/modules/investments/operations/domain/operations-sync-plan.ts b/src/modules/investments/operations/domain/operations-sync-plan.ts index 555a43c..b0e73ca 100644 --- a/src/modules/investments/operations/domain/operations-sync-plan.ts +++ b/src/modules/investments/operations/domain/operations-sync-plan.ts @@ -1,8 +1,8 @@ export type OperationInterval = { accountId: string; from: Date; - to: Date; instrumentUid?: string; + to: Date; }; // Normalize duplicate/overlapping targets before creating a durable identity. @@ -12,8 +12,10 @@ export function operationIntervals( ): OperationInterval[] { const groups = new Map(); for (const interval of requested) { - if (!(interval.from < interval.to)) + // eslint-disable-next-line sonarjs/no-inverted-boolean-check -- Negation also rejects Invalid Date; >= would silently accept NaN. + if (!(interval.from < interval.to)) { throw new Error('Invalid operations interval'); + } const key = JSON.stringify([ interval.accountId, interval.instrumentUid ?? '', @@ -23,17 +25,18 @@ export function operationIntervals( groups.set(key, group); } const result: OperationInterval[] = []; + // 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. for (const key of [...groups.keys()].sort()) { const merged: OperationInterval[] = []; for (const interval of (groups.get(key) ?? []).sort( (left, right) => left.from.valueOf() - right.from.valueOf(), )) { const previous = merged.at(-1); - if (previous && interval.from <= previous.to) + if (previous && interval.from <= previous.to) { previous.to = new Date( Math.max(previous.to.valueOf(), interval.to.valueOf()), ); - else merged.push({ ...interval }); + } else merged.push({ ...interval }); } for (const interval of merged) { let from = interval.from; @@ -61,8 +64,8 @@ export function operationIntervalsOverlap( (!left.instrumentUid || !right.instrumentUid || left.instrumentUid === right.instrumentUid) && - left.from < right.to && - right.from < left.to + left.from < right.to && + right.from < left.to ); } @@ -78,15 +81,16 @@ export function uncoveredOperationIntervals( !operationIntervalsOverlap(fragment, covered) || (covered.instrumentUid && covered.instrumentUid !== fragment.instrumentUid) - ) + ) { return [fragment]; + } return [ - ...(fragment.from < covered.from - ? [{ ...fragment, to: covered.from }] - : []), - ...(covered.to < fragment.to - ? [{ ...fragment, from: covered.to }] - : []), + ...(fragment.from < covered.from ? + [{ ...fragment, to: covered.from }] : + []), + ...(covered.to < fragment.to ? + [{ ...fragment, from: covered.to }] : + []), ]; }); } @@ -95,8 +99,8 @@ export function uncoveredOperationIntervals( } export type OperationCoverage = OperationInterval & { - fetchedAt: Date; asOf: Date; + fetchedAt: Date; }; const operationsFreshForMs = 300_000; @@ -104,7 +108,7 @@ export function planOperationRead( requested: OperationInterval[], coverage: OperationCoverage[], now: Date, - options: { movingEnd?: boolean; force?: boolean } = {}, + options: { force?: boolean; movingEnd?: boolean } = {}, ) { const monthStart = new Date( Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1), @@ -137,8 +141,9 @@ export function planOperationRead( row.asOf.valueOf() === row.to.valueOf() && row.to > target.from, ) .sort((left, right) => right.to.valueOf() - left.to.valueOf())[0]; - if (latest && latest.to < target.to) + if (latest && latest.to < target.to) { effective = { ...target, to: latest.to }; + } } const missing = uncoveredOperationIntervals([effective], ranges); const complete = missing.length === 0; @@ -163,20 +168,20 @@ export function planOperationRead( // Completeness is bounded by the observed end, never by the new wall clock. return { ...effective, - availability: complete - ? ('complete' as const) - : sources.length - ? ('partial' as const) - : ('missing' as const), + availability: complete ? + ('complete' as const) : + sources.length ? + ('partial' as const) : + ('missing' as const), stale, - asOf: sources.length - ? new Date( + asOf: sources.length ? + new Date( Math.min( effective.to.valueOf(), Math.max(...sources.map((row) => row.asOf.valueOf())), ), - ) - : null, + ) : + null, }; }); return { intervals, load: operationIntervals(load) }; diff --git a/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts b/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts index 3fe3966..586080e 100644 --- a/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts +++ b/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts @@ -1,10 +1,10 @@ -import { MILLISECONDS_PER_SECOND } from '../../../../common/time'; import { formatDecimal } from '../../../../common/decimal'; +import { MILLISECONDS_PER_SECOND } from '../../../../common/time'; +import { decimal } from '../../../../integrations/tbank/decimal'; import { TbankMoney, TbankOperationItem, } from '../../../../integrations/tbank/tbank.api'; -import { decimal } from '../../../../integrations/tbank/decimal'; import { BrokerageOperationDto, OperationCategory, @@ -59,14 +59,14 @@ export function mapBrokerageOperation( logoName: null, payment: money(operation.payment), quantity: - (operationCategory(operation.type) === 'trade' - ? operation.quantityDone - : operation.quantity) === undefined - ? null - : String( - operationCategory(operation.type) === 'trade' - ? operation.quantityDone - : operation.quantity, + (operationCategory(operation.type) === 'trade' ? + operation.quantityDone : + operation.quantity) === undefined ? + null : + String( + operationCategory(operation.type) === 'trade' ? + operation.quantityDone : + operation.quantity, ), price: money(operation.price), accruedInterest: money(operation.accruedInt), @@ -74,22 +74,22 @@ export function mapBrokerageOperation( }; } -function text(value: string | undefined): string | null { +function text(value: string | undefined): null | string { return value?.trim() || null; } function money(value: TbankMoney | undefined) { const currency = value?.currency?.trim().toUpperCase(); - return currency && value - ? { currency, value: formatDecimal(decimal(value)) } - : null; + return currency && value ? + { currency, value: formatDecimal(decimal(value)) } : + null; } export function operationTimestamp( - value: { seconds?: string | number; nanos?: number } | undefined, + value: undefined | { nanos?: number; seconds?: number | string }, ) { return new Date( Number(value?.seconds ?? 0) * MILLISECONDS_PER_SECOND + - Math.floor((value?.nanos ?? 0) / NANOSECONDS_PER_MILLISECOND), + Math.floor((value?.nanos ?? 0) / NANOSECONDS_PER_MILLISECOND), ); } diff --git a/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts b/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts index bd86956..375df64 100644 --- a/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts +++ b/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts @@ -5,46 +5,48 @@ import { } from '@nestjs/common'; import { createHash, randomUUID } from 'node:crypto'; import { isDeepStrictEqual } from 'node:util'; -import { PrismaService } from '../../../../prisma/prisma.service'; + import type { Prisma } from '../../../../../generated/prisma/client'; import type { TbankOperationItem, TbankOperationsByCursorResponse, } from '../../../../integrations/tbank/tbank.api'; + +import { PrismaService } from '../../../../prisma/prisma.service'; import { - DateOnlyRange, dateOnlyBounds, + DateOnlyRange, moscowDate, } from '../../investment-calendar'; -import { - operationCategory, - operationTimestamp, -} from './brokerage-operation.mapper'; import { type OperationInterval, operationIntervals, operationIntervalsOverlap, uncoveredOperationIntervals, } from '../domain/operations-sync-plan'; +import { + operationCategory, + operationTimestamp, +} from './brokerage-operation.mapper'; export type { OperationInterval } from '../domain/operations-sync-plan'; export type OperationAttempt = { - id: string; attemptId: string; - userId: string; + id: string; revision: string; + userId: string; }; type OperationsIntent = { - intervals: string[]; atomic: boolean; atomicIntervals?: string[]; + intervals: string[]; screenIntervals?: string[]; }; type PageProgress = { complete: boolean; cursor: string; - seen: string[]; pages: number; + seen: string[]; }; const progressItem = ''; @@ -53,17 +55,17 @@ export class BrokerageOperationsRepository { constructor(private readonly prisma: PrismaService) {} async readPage(input: { - userId: string; - revision: string; accountId: string; - from: Date; - to: Date; - instrumentUid?: string; categories: string[]; + cursor?: { accountId: string; at: string; binding: string; id: string }; + filterKey: string; + from: Date; + instrumentUid?: string; limit?: number; periods?: (openedAt: Date | undefined) => DateOnlyRange[]; - filterKey: string; - cursor?: { binding: string; at: string; accountId: string; id: string }; + revision: string; + to: Date; + userId: string; }) { return this.prisma.$transaction(async (tx) => { const connection = await tx.tbankConnection.findUnique({ @@ -92,14 +94,16 @@ export class BrokerageOperationsRepository { connection.membershipAt && input.accountId !== 'all' && !connection.accounts.length - ) + ) { throw new NotFoundException(); + } const accounts = connection.accounts; + // 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. const oldest = accounts.map((account) => account.openedOn).sort()[0]; const openedAt = - oldest && accounts.every((account) => account.openedOn) - ? new Date(`${oldest}T00:00:00+03:00`) - : undefined; + oldest && accounts.every((account) => account.openedOn) ? + new Date(`${oldest}T00:00:00+03:00`) : + undefined; const periods = input.periods?.(openedAt).map((period) => { const now = new Date(); const bounds = dateOnlyBounds(period, now); @@ -123,8 +127,9 @@ export class BrokerageOperationsRepository { ]), ) .digest('base64url'); - if (input.cursor && input.cursor.binding !== binding) + if (input.cursor && input.cursor.binding !== binding) { throw new BadRequestException({ code: 'INVALID_OPERATIONS_CURSOR' }); + } const scope = { userId: input.userId, revision: input.revision, @@ -162,21 +167,21 @@ export class BrokerageOperationsRepository { const rows = await tx.tbankOperation.findMany({ where: { ...scope, - ...(periods - ? { + ...(periods ? + { OR: periods.map((period) => ({ occurredAt: { gte: period.from, lt: period.to }, })), - } - : { occurredAt: { gte: input.from, lt: input.to } }), - ...(input.instrumentUid - ? { instrumentUid: input.instrumentUid } - : {}), - ...(input.categories.length - ? { category: { in: input.categories } } - : {}), - ...(after - ? { + } : + { occurredAt: { gte: input.from, lt: input.to } }), + ...(input.instrumentUid ? + { instrumentUid: input.instrumentUid } : + {}), + ...(input.categories.length ? + { category: { in: input.categories } } : + {}), + ...(after ? + { OR: [ { occurredAt: { lt: new Date(after.at) } }, { @@ -189,8 +194,8 @@ export class BrokerageOperationsRepository { brokerOperationId: { gt: after.id }, }, ], - } - : {}), + } : + {}), }, orderBy: [ { occurredAt: 'desc' }, @@ -247,6 +252,7 @@ export class BrokerageOperationsRepository { const scopeKey = 'operations:' + createHash('sha256') + // 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. .update(JSON.stringify([...new Set(intervals.map(intervalKey))].sort())) .digest('hex'); return this.prisma.$transaction(async (tx) => { @@ -271,14 +277,15 @@ export class BrokerageOperationsRepository { (account) => account.accountId === interval.accountId, ), ) - ) + ) { throw new NotFoundException(); + } if (priority > 1) { for (const account of accounts.filter((account) => intervals.some( (interval) => interval.accountId === account.accountId, ), - )) + )) { await tx.tbankAccount.update({ where: { userId_revision_accountId: { @@ -289,13 +296,14 @@ export class BrokerageOperationsRepository { }, data: { historyFrom: new Date( - account.openedOn - ? `${account.openedOn}T00:00:00Z` - : '1970-01-01T00:00:00Z', + account.openedOn ? + `${account.openedOn}T00:00:00Z` : + '1970-01-01T00:00:00Z', ), historyBoundary: account.openedOn ? 'known' : 'unknown', }, }); + } } const coverage = await tx.tbankOperationCoverage.findMany({ where: { @@ -366,42 +374,42 @@ export class BrokerageOperationsRepository { ) { const pending = previousIntent?.intervals.map(decodeInterval) ?? []; const additions = - previous.scopeKey === scopeKey - ? [] - : uncoveredOperationIntervals(intervals, pending); + previous.scopeKey === scopeKey ? + [] : + uncoveredOperationIntervals(intervals, pending); const work = await tx.tbankSyncWork.update({ where: { id: previous.id }, data: { intent: { atomic: previousIntent?.atomic ?? publicationAtomic, - atomicIntervals: previousIntent?.atomic - ? [ + atomicIntervals: previousIntent?.atomic ? + [ ...new Set([ ...(previousIntent.atomicIntervals ?? previousIntent.intervals), ...additions.map(intervalKey), ]), - ] - : [], + ] : + [], screenIntervals: [ ...new Set([ ...(previousIntent?.screenIntervals ?? - (previous.priority === 1 - ? (previousIntent?.intervals ?? []) - : [])), - ...(priority === 1 - ? [...pending, ...additions] + (previous.priority === 1 ? + (previousIntent?.intervals ?? []) : + [])), + ...(priority === 1 ? + [...pending, ...additions] .filter((item) => intervals.some((target) => operationIntervalsOverlap(item, target), ), ) - .map(intervalKey) - : []), + .map(intervalKey) : + []), ]), ], - intervals: (priority < previous.priority - ? [ + intervals: (priority < previous.priority ? + [ ...pending.filter((item) => intervals.some((target) => operationIntervalsOverlap(item, target), @@ -414,8 +422,8 @@ export class BrokerageOperationsRepository { operationIntervalsOverlap(item, target), ), ), - ] - : [...pending, ...additions] + ] : + [...pending, ...additions] ).map(intervalKey), }, priority: Math.min(priority, previous.priority), @@ -427,9 +435,9 @@ export class BrokerageOperationsRepository { previous && previous.state !== 'complete' && previous.state !== 'superseded'; - const retained = recovering - ? (previousIntent?.intervals.map(decodeInterval) ?? []) - : []; + const retained = recovering ? + (previousIntent?.intervals.map(decodeInterval) ?? []) : + []; // A bridge between two active groups must fence both producers before // either can publish a subset of the newly combined result. for (const joined of touching) { @@ -448,26 +456,27 @@ export class BrokerageOperationsRepository { }); await tx.tbankSyncStage.deleteMany({ where: { workId: joined.id } }); } - if (previous) + if (previous) { await tx.tbankSyncStage.deleteMany({ where: { workId: previous.id } }); + } const targets = - recovering && !upgradeGroup && touching.length <= 1 - ? previous?.scopeKey === scopeKey - ? retained - : [...retained, ...uncoveredOperationIntervals(intervals, retained)] - : operationIntervals([...retained, ...intervals]); + recovering && !upgradeGroup && touching.length <= 1 ? + previous?.scopeKey === scopeKey ? + retained : + [...retained, ...uncoveredOperationIntervals(intervals, retained)] : + operationIntervals([...retained, ...intervals]); const atomicTargets = [ ...(publicationAtomic ? intervals : []), - ...(recovering && previousIntent?.atomic - ? (previousIntent.atomicIntervals ?? previousIntent.intervals).map( + ...(recovering && previousIntent?.atomic ? + (previousIntent.atomicIntervals ?? previousIntent.intervals).map( decodeInterval, - ) - : []), + ) : + []), ...touching.flatMap((work) => { const joined = work.intent as OperationsIntent; - return joined.atomic - ? (joined.atomicIntervals ?? joined.intervals).map(decodeInterval) - : []; + return joined.atomic ? + (joined.atomicIntervals ?? joined.intervals).map(decodeInterval) : + []; }), ]; const intent = { @@ -486,14 +495,14 @@ export class BrokerageOperationsRepository { intervals.some((incoming) => operationIntervalsOverlap(target, incoming), )) || - ( - previousIntent?.screenIntervals ?? - (previous?.priority === 1 - ? (previousIntent?.intervals ?? []) - : []) - ).some((key) => - operationIntervalsOverlap(target, decodeInterval(key)), - ), + ( + previousIntent?.screenIntervals ?? + (previous?.priority === 1 ? + (previousIntent?.intervals ?? []) : + []) + ).some((key) => + operationIntervalsOverlap(target, decodeInterval(key)), + ), ) .map(intervalKey), atomic: atomicTargets.length > 0, @@ -545,20 +554,20 @@ export class BrokerageOperationsRepository { marker.payload as PageProgress, ]), ); - const group = intent.atomic - ? (intent.atomicIntervals ?? intent.intervals) - : intent.intervals; + const group = intent.atomic ? + (intent.atomicIntervals ?? intent.intervals) : + intent.intervals; const nextKey = group.find((key) => !progress.get(key)?.complete); return { priority: work.priority, atomic: intent.atomic, intervals: group.map(decodeInterval), - next: nextKey - ? { + next: nextKey ? + { interval: decodeInterval(nextKey), cursor: progress.get(nextKey)?.cursor || undefined, - } - : null, + } : + null, }; } @@ -582,8 +591,9 @@ export class BrokerageOperationsRepository { updatedAt: true, }, }); - if (!work || work.state !== 'superseded') + if (!work || work.state !== 'superseded') { return work ? { ...work, id } : null; + } target = (work.progress as { successorId: string }).successorId; } throw new Error('Cyclic operations work successor'); @@ -599,7 +609,7 @@ export class BrokerageOperationsRepository { errorCode: invalidToken ? 'INVALID_TBANK_TOKEN' : 'TBANK_UNAVAILABLE', }, }); - if (invalidToken) + if (invalidToken) { await tx.tbankConnection.update({ where: { userId_revision: { @@ -609,6 +619,7 @@ export class BrokerageOperationsRepository { }, data: { invalidToken: true }, }); + } return true; }); } @@ -636,34 +647,40 @@ export class BrokerageOperationsRepository { }, }); const progress = marker?.payload as PageProgress | undefined; - if (progress?.complete || (progress?.cursor ?? '') !== (cursor ?? '')) + if (progress?.complete || (progress?.cursor ?? '') !== (cursor ?? '')) { throw new Error('Unexpected operations page'); - if (response.hasNext === undefined) + } + if (response.hasNext === undefined) { throw new Error('Missing operations pagination state'); + } const nextCursor = response.nextCursor ?? ''; if ( response.hasNext && (!nextCursor || nextCursor === cursor || progress?.seen.includes(nextCursor)) - ) + ) { throw new Error('Invalid operations continuation'); + } for (const operation of response.items ?? []) { - if (!operation.id?.trim()) + if (!operation.id?.trim()) { throw new Error('Missing broker operation identity'); + } const occurredAt = operationTimestamp(operation.date); if ( operation.date?.seconds === undefined || !Number.isFinite(occurredAt.valueOf()) - ) + ) { throw new Error('Invalid operation timestamp'); + } if ( occurredAt < interval.from || occurredAt >= interval.to || (interval.instrumentUid && operation.instrumentUid !== interval.instrumentUid) - ) + ) { continue; + } const payload = JSON.parse( JSON.stringify({ ...operation, cursor: undefined }), ) as Prisma.InputJsonObject; @@ -672,10 +689,12 @@ export class BrokerageOperationsRepository { const previous = await tx.tbankSyncStage.findUnique({ where: { workId_attemptId_sourceKey_itemKey: identity }, }); - if (previous && !isDeepStrictEqual(previous.payload, payload)) + if (previous && !isDeepStrictEqual(previous.payload, payload)) { throw new Error('Conflicting broker operation identity'); - if (!previous) + } + if (!previous) { await tx.tbankSyncStage.create({ data: { ...identity, payload } }); + } } const payload: PageProgress = { complete: !response.hasNext, @@ -712,8 +731,9 @@ export class BrokerageOperationsRepository { fetchedAt: Date, advanceWork = false, ): Promise { - if (!intervals.length) + if (!intervals.length) { throw new Error('Empty operations publication group'); + } return this.prisma.$transaction( async (tx) => { if (!(await claim(tx, attempt))) return false; @@ -734,8 +754,9 @@ export class BrokerageOperationsRepository { }, }, }); - if (!(marker?.payload as PageProgress | undefined)?.complete) + if (!(marker?.payload as PageProgress | undefined)?.complete) { throw new Error('Incomplete operations publication group'); + } const staged = await tx.tbankSyncStage.findMany({ where: { ...stage, itemKey: { not: progressItem } }, }); @@ -745,10 +766,11 @@ export class BrokerageOperationsRepository { if ( previous !== undefined && !isDeepStrictEqual(previous, row.payload) - ) + ) { throw new Error( 'Conflicting broker operation identity across intervals', ); + } groupRows.set(identity, row.payload); } const rows = staged.filter((row) => { @@ -764,16 +786,16 @@ export class BrokerageOperationsRepository { where: { ...scope, occurredAt: { gte: interval.from, lt: interval.to }, - ...(interval.instrumentUid - ? { instrumentUid: interval.instrumentUid } - : {}), + ...(interval.instrumentUid ? + { instrumentUid: interval.instrumentUid } : + {}), }, }); const incoming = new Set(rows.map((row) => row.itemKey)); const removed = existing.filter( (row) => !incoming.has(row.brokerOperationId), ); - for (const row of removed) + for (const row of removed) { await tx.tbankOperation.delete({ where: { userId_revision_accountId_brokerOperationId: { @@ -782,6 +804,7 @@ export class BrokerageOperationsRepository { }, }, }); + } if (removed.length) changedAccounts.add(interval.accountId); for (const row of rows) { const operation = row.payload as TbankOperationItem; @@ -789,8 +812,9 @@ export class BrokerageOperationsRepository { const previous = await tx.tbankOperation.findUnique({ where: { userId_revision_accountId_brokerOperationId: identity }, }); - if (previous && isDeepStrictEqual(previous.payload, operation)) + if (previous && isDeepStrictEqual(previous.payload, operation)) { continue; + } const data = { occurredAt: operationTimestamp(operation.date), instrumentUid: operation.instrumentUid ?? null, @@ -818,7 +842,7 @@ export class BrokerageOperationsRepository { update: { fetchedAt, asOf: interval.to }, }); } - for (const accountId of changedAccounts) + for (const accountId of changedAccounts) { await tx.tbankAccount.update({ where: { userId_revision_accountId: { @@ -829,7 +853,8 @@ export class BrokerageOperationsRepository { }, data: { operationsRevision: { increment: 1 } }, }); - for (const interval of intervals) + } + for (const interval of intervals) { await tx.tbankSyncStage.deleteMany({ where: { workId: attempt.id, @@ -837,6 +862,7 @@ export class BrokerageOperationsRepository { sourceKey: intervalKey(interval), }, }); + } if (advanceWork) { const work = await tx.tbankSyncWork.findUniqueOrThrow({ where: { id: attempt.id }, @@ -867,9 +893,9 @@ export class BrokerageOperationsRepository { intent.screenIntervals ?? (work.priority === 1 ? intent.intervals : []) ).includes(key), - ) - ? 1 - : 2, + ) ? + 1 : + 2, progress: { remainingIntervals: remaining.length }, }, }); @@ -882,8 +908,10 @@ export class BrokerageOperationsRepository { } function intervalKey(interval: OperationInterval): string { - if (!(interval.from < interval.to)) + // eslint-disable-next-line sonarjs/no-inverted-boolean-check -- Negation also rejects Invalid Date; >= would silently accept NaN. + if (!(interval.from < interval.to)) { throw new Error('Invalid operations interval'); + } return JSON.stringify([ interval.accountId, interval.from.toISOString(), diff --git a/src/modules/investments/operations/operations.api.ts b/src/modules/investments/operations/operations.api.ts index 69bf304..e197d9d 100644 --- a/src/modules/investments/operations/operations.api.ts +++ b/src/modules/investments/operations/operations.api.ts @@ -1,8 +1,8 @@ -// Public cross-module surface. Nest providers are exported by the matching module. -export { BrokerageOperationDto } from './presentation/brokerage-operation.dto'; +export { BrokerageOperationsSyncService } from './application/brokerage-operations-sync.service'; export { BrokerageOperationsService } from './application/brokerage-operations.service'; export type { OperationInterval } from './domain/operations-sync-plan'; +// Public cross-module surface. Nest providers are exported by the matching module. +export { BrokerageOperationDto } from './presentation/brokerage-operation.dto'; export { operationCategories } from './presentation/brokerage-operation.dto'; -export { BrokerageOperationsSyncService } from './application/brokerage-operations-sync.service'; diff --git a/src/modules/investments/operations/operations.module.ts b/src/modules/investments/operations/operations.module.ts index 16f37dc..a91bce8 100644 --- a/src/modules/investments/operations/operations.module.ts +++ b/src/modules/investments/operations/operations.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; + import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { AuthModule } from '../../auth/auth.module'; -import { BrokerageOperationsController } from './presentation/brokerage-operations.controller'; +import { BrokerageOperationsSyncService } from './application/brokerage-operations-sync.service'; import { BrokerageOperationsService } from './application/brokerage-operations.service'; import { BrokerageOperationsRepository } from './infrastructure/brokerage-operations.repository'; -import { BrokerageOperationsSyncService } from './application/brokerage-operations-sync.service'; +import { BrokerageOperationsController } from './presentation/brokerage-operations.controller'; @Module({ imports: [AuthModule, TbankModule], diff --git a/src/modules/investments/operations/presentation/brokerage-operation.dto.ts b/src/modules/investments/operations/presentation/brokerage-operation.dto.ts index 6846422..224bad9 100644 --- a/src/modules/investments/operations/presentation/brokerage-operation.dto.ts +++ b/src/modules/investments/operations/presentation/brokerage-operation.dto.ts @@ -1,7 +1,3 @@ -import { - InvestmentReadQueryDto, - InvestmentResourceMetaDto, -} from '../../investment-resource.dto'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform, Type } from 'class-transformer'; import { @@ -16,9 +12,13 @@ import { MaxLength, Min, } from 'class-validator'; -import { MoneyDto } from '../../../../common/money.dto'; +import { MoneyDto } from '../../../../common/money.dto'; import { AccountReferenceDto } from '../../account-reference.dto'; +import { + InvestmentReadQueryDto, + InvestmentResourceMetaDto, +} from '../../investment-resource.dto'; const MAX_CURSOR_LENGTH = 4096; const MAX_OPERATIONS_PAGE_SIZE = 1000; @@ -102,25 +102,25 @@ export class BrokerageOperationDto { declare description: 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({ type: String, nullable: true }) - declare logoName: string | null; + declare logoName: null | string; @ApiProperty({ type: MoneyDto, nullable: true }) declare payment: MoneyDto | null; @ApiProperty({ type: String, nullable: true }) - declare quantity: string | null; + declare quantity: null | string; @ApiProperty({ type: MoneyDto, nullable: true }) declare price: MoneyDto | null; @@ -140,7 +140,7 @@ export class BrokerageOperationsPageDto { declare items: BrokerageOperationDto[]; @ApiProperty({ type: String, nullable: true }) - declare nextCursor: string | null; + declare nextCursor: null | string; @ApiProperty() declare hasNext: boolean; @@ -152,6 +152,7 @@ export class BrokerageOperationsPageDto { export class BrokerageOperationsResourceDto { @ApiProperty({ type: BrokerageOperationsPageDto, nullable: true }) declare data: BrokerageOperationsPageDto | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } diff --git a/src/modules/investments/operations/presentation/brokerage-operations.controller.ts b/src/modules/investments/operations/presentation/brokerage-operations.controller.ts index 317e4ec..66d1a38 100644 --- a/src/modules/investments/operations/presentation/brokerage-operations.controller.ts +++ b/src/modules/investments/operations/presentation/brokerage-operations.controller.ts @@ -1,13 +1,15 @@ +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 { - BrokerageOperationsResourceDto, - BrokerageOperationsQueryDto, -} from './brokerage-operation.dto'; import { BrokerageOperationsService } from '../application/brokerage-operations.service'; +import { + BrokerageOperationsQueryDto, + BrokerageOperationsResourceDto, +} from './brokerage-operation.dto'; @ApiTags('brokerage-accounts') @ApiProblemResponses() diff --git a/src/modules/investments/portfolio/application/forecast.service.ts b/src/modules/investments/portfolio/application/forecast.service.ts index 2f379d9..389e627 100644 --- a/src/modules/investments/portfolio/application/forecast.service.ts +++ b/src/modules/investments/portfolio/application/forecast.service.ts @@ -1,7 +1,9 @@ import { Injectable } from '@nestjs/common'; + +import type { InvestmentResourceMetaDto } from '../../investment-resource.dto'; + import { tbankSourceNeedsRefresh } from '../../../../integrations/tbank/tbank.api'; import { investmentResourceMeta } from '../../investment-resource.mapper'; -import type { InvestmentResourceMetaDto } from '../../investment-resource.dto'; import { ForecastRepository, type ForecastSourceVersion, @@ -12,16 +14,16 @@ export class ForecastService { constructor(private readonly repository: ForecastRepository) {} async result(input: { - userId: string; - revision: string; - membershipRevision: number; - scopeKey: string; - portfolio: InvestmentResourceMetaDto; - sources: { - version: ForecastSourceVersion | null; - meta: InvestmentResourceMetaDto; - }[]; candidate?: T; + membershipRevision: number; + portfolio: InvestmentResourceMetaDto; + revision: string; + scopeKey: string; + sources: { + meta: InvestmentResourceMetaDto; + version: ForecastSourceVersion | null; + }[]; + userId: string; }) { const dependencies = [ input.portfolio, @@ -31,8 +33,8 @@ export class ForecastService { ...input, portfolioProjection: input.portfolio.dataRevision, portfolios: (input.portfolio.dependencies ?? []).flatMap((dependency) => - dependency.resourceKey.startsWith('portfolio/') - ? [ + dependency.resourceKey.startsWith('portfolio/') ? + [ { accountId: decodeURIComponent( dependency.resourceKey.slice('portfolio/'.length), @@ -41,65 +43,65 @@ export class ForecastService { (dependency as InvestmentResourceMetaDto).dataRevision, ), }, - ] - : [], + ] : + [], ), sources: input.sources.flatMap((source) => source.version ? [source.version] : [], ), timestamps: dependencies.flatMap((dependency) => - dependency.lastSuccessfulAt - ? [ + dependency.lastSuccessfulAt ? + [ { resourceKey: dependency.resourceKey, at: dependency.lastSuccessfulAt, }, - ] - : [], + ] : + [], ), }); const timestamps = stored?.sourceTimestamps as - { resourceKey: string; at: string }[] | undefined; + undefined | { at: string; resourceKey: string }[]; const first = timestamps?.map((entry) => entry.at).sort()[0]; const work = dependencies .map((dependency) => dependency.sync) .find((sync) => sync.state === 'running') ?? - dependencies - .map((dependency) => dependency.sync) - .find((sync) => sync.state === 'failed'); + dependencies + .map((dependency) => dependency.sync) + .find((sync) => sync.state === 'failed'); const payload = stored ? (stored.payload as T) : null; const data = payload && 'positionIsStale' in payload && 'positionAt' in payload && - typeof payload.positionAt === 'string' - ? { + typeof payload.positionAt === 'string' ? + { ...payload, positionIsStale: tbankSourceNeedsRefresh( 'portfolio', new Date(payload.positionAt), new Date(Date.now()), ), - } - : payload; + } : + payload; return { data, meta: investmentResourceMeta({ resourceKey: input.scopeKey, dataRevision: String(stored?.dataRevision ?? 0), - availability: stored - ? 'complete' - : dependencies.some( - (dependency) => dependency.availability !== 'missing', - ) - ? 'partial' - : 'missing', + availability: stored ? + 'complete' : + dependencies.some( + (dependency) => dependency.availability !== 'missing', + ) ? + 'partial' : + 'missing', fetchedAt: first ? new Date(first) : null, freshForMs: - data && 'positionIsStale' in data && data.positionIsStale === true - ? 0 - : Number.POSITIVE_INFINITY, + data && 'positionIsStale' in data && data.positionIsStale === true ? + 0 : + Number.POSITIVE_INFINITY, work: work?.id ? { ...work, id: work.id } : null, dependencies, }), diff --git a/src/modules/investments/portfolio/application/portfolio.service.spec.ts b/src/modules/investments/portfolio/application/portfolio.service.spec.ts index 5c3603b..5df4ec9 100644 --- a/src/modules/investments/portfolio/application/portfolio.service.spec.ts +++ b/src/modules/investments/portfolio/application/portfolio.service.spec.ts @@ -1,10 +1,10 @@ -import { setImmediate as immediate } from 'node:timers/promises'; import { ServiceUnavailableException } from '@nestjs/common'; +import { setImmediate as immediate } from 'node:timers/promises'; jest.mock('../../../../prisma/prisma.service', () => ({ PrismaService: class {}, })); -import { PortfolioService } from './portfolio.service'; import { PortfolioDto } from '../presentation/portfolio.dto'; +import { PortfolioService } from './portfolio.service'; describe('persistent portfolio projection', () => { const access = { local: jest.fn(), tokenForAttempt: jest.fn() }; @@ -103,8 +103,9 @@ describe('persistent portfolio projection', () => { let index = 0; index < 10 && !repository.publish.mock.calls.length; index += 1 - ) + ) { await immediate(); + } const snapshot = ( repository.publish.mock.calls as [unknown, string, PortfolioDto][] )[0][2]; diff --git a/src/modules/investments/portfolio/application/portfolio.service.ts b/src/modules/investments/portfolio/application/portfolio.service.ts index a0aeb79..faad7a6 100644 --- a/src/modules/investments/portfolio/application/portfolio.service.ts +++ b/src/modules/investments/portfolio/application/portfolio.service.ts @@ -1,17 +1,10 @@ -import { CandleService } from '../../instruments/instruments.api'; -import { randomUUID } from 'node:crypto'; -import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; -import { investmentResourceMeta } from '../../investment-resource.mapper'; -import { - PortfolioRepository, - portfolioFreshForMs, - portfolioWorkKey, -} from '../infrastructure/portfolio.repository'; import { Injectable, - ServiceUnavailableException, NotFoundException, + ServiceUnavailableException, } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; + import { add, decimalFromString, @@ -21,22 +14,30 @@ import { subtract, } from '../../../../common/decimal'; import { RequestContextService } from '../../../../common/logging/request-context.service'; +import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; import { MoneyDto } from '../../../../common/money.dto'; +import { decimal } from '../../../../integrations/tbank/decimal'; import { InstrumentMetadataService, - metadataKey, - type TbankInstrument, type MetadataIdentity, + metadataKey, TbankAccessService, TbankClient, - TbankMoney, + type TbankInstrument, TbankMembershipService, + TbankMoney, tbankSourceTtlMs, } from '../../../../integrations/tbank/tbank.api'; -import { decimal } from '../../../../integrations/tbank/decimal'; +import { CandleService } from '../../instruments/instruments.api'; +import { investmentResourceMeta } from '../../investment-resource.mapper'; import { portfolioStructure } from '../domain/portfolio-structure'; -import { totalsByCurrency } from '../infrastructure/tbank-portfolio.mapper'; import { sumMoneyByCurrency } from '../domain/portfolio-totals'; +import { + portfolioFreshForMs, + PortfolioRepository, + portfolioWorkKey, +} from '../infrastructure/portfolio.repository'; +import { totalsByCurrency } from '../infrastructure/tbank-portfolio.mapper'; import { PortfolioDto, PortfolioPositionDto, @@ -80,7 +81,7 @@ export class PortfolioService { userId, connection.revision, ); - if (discovery) + if (discovery) { void discovery.done .then(async () => { const current = await this.access.local(userId, accountId); @@ -88,12 +89,13 @@ export class PortfolioService { for (const account of current.accounts.filter( (account) => accountId === 'all' || account.accountId === accountId, - )) + )) { await this.queueSnapshot( userId, current.revision, account.accountId, ); + } }) .catch(() => this.logger.event( @@ -102,22 +104,25 @@ export class PortfolioService { { userId }, ), ); + } for (const account of (discovery ? [] : connection.accounts).filter( (account) => accountId === 'all' || account.accountId === accountId, - )) + )) { await this.queueSnapshot( userId, connection.revision, account.accountId, ); + } } const saved = await this.repository.read(userId, connection.revision); if ( saved?.membershipAt && accountId !== 'all' && !saved.accounts.some((account) => account.accountId === accountId) - ) + ) { throw new NotFoundException(); + } if (!observe) { const identities = new Map(); for (const account of (saved?.accounts ?? []).filter( @@ -125,17 +130,20 @@ export class PortfolioService { )) { const snapshot = account.portfolio?.payload as unknown as PortfolioDto | undefined; - for (const position of snapshot?.positions ?? []) + for (const position of snapshot?.positions ?? []) { identities.set(metadataKey(position), position); + } } const savedMetadata = await this.metadata.savedMany([ ...identities.values(), ]); - for (const identity of identities.values()) - if (!savedMetadata.get(metadataKey(identity))?.fresh) + for (const identity of identities.values()) { + if (!savedMetadata.get(metadataKey(identity))?.fresh) { await this.metadata.request(userId, connection.revision, identity); + } + } } - if (!observe) + if (!observe) { void this.candles .prefetch( userId, @@ -156,6 +164,7 @@ export class PortfolioService { .catch(() => this.logger.event('warn', 'portfolio.candle-prefetch-unavailable'), ); + } const membershipMeta = investmentResourceMeta({ resourceKey: 'accounts/membership', dataRevision: String(saved?.membershipRevision ?? 0), @@ -175,15 +184,15 @@ export class PortfolioService { name: account.name, type: account.type, openedOn: account.openedOn, - snapshot: account.portfolio - ? { + snapshot: account.portfolio ? + { ...(account.portfolio.payload as unknown as PortfolioDto), lastSuccessfulAt: account.portfolio.fetchedAt.toISOString(), isStale: Date.now() - account.portfolio.fetchedAt.valueOf() >= portfolioFreshForMs, - } - : null, + } : + null, meta: investmentResourceMeta({ resourceKey: `portfolio/${encodeURIComponent(account.accountId)}`, dataRevision: String(account.portfolio?.dataRevision ?? 0), @@ -209,8 +218,8 @@ export class PortfolioService { ); const metadataDependencies = [...metadata.entries()].flatMap( ([key, source]) => - source.complete && source.data - ? [ + source.complete && source.data ? + [ investmentResourceMeta({ resourceKey: `instrument/metadata/${key}`, dataRevision: source.dataRevision, @@ -219,20 +228,20 @@ export class PortfolioService { freshForMs: tbankSourceTtlMs.metadata, work: null, }), - ] - : [], + ] : + [], ); const portfolios = saved.accounts.flatMap((account) => - account.snapshot - ? [ + account.snapshot ? + [ { ...account.snapshot, positions: account.snapshot.positions.map((position) => { const shared = metadata.get(metadataKey(position))?.data; return { ...position, - ...(shared - ? { + ...(shared ? + { displayName: shared.name?.trim() || shared.ticker?.trim() || @@ -241,28 +250,29 @@ export class PortfolioService { isin: shared.isin?.trim() || position.isin, logoName: shared.brand?.logoName?.trim() || position.logoName, - } - : {}), - ...(accountId === 'all' - ? { account: { id: account.id, name: account.name } } - : {}), + } : + {}), + ...(accountId === 'all' ? + { account: { id: account.id, name: account.name } } : + {}), }; }), }, - ] - : [], + ] : + [], ); const lastSuccessfulAt = + // 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. portfolios.map((portfolio) => portfolio.lastSuccessfulAt).sort()[0] ?? saved.membership.lastSuccessfulAt; const data = - missing || !lastSuccessfulAt - ? null - : { + missing || !lastSuccessfulAt ? + null : + { lastSuccessfulAt, - isStale: portfolios.length - ? portfolios.some((portfolio) => portfolio.isStale) - : saved.membership.freshness !== 'fresh', + isStale: portfolios.length ? + portfolios.some((portfolio) => portfolio.isStale) : + saved.membership.freshness !== 'fresh', totalsByCurrency: sumMoneyByCurrency( portfolios.flatMap((portfolio) => portfolio.totalsByCurrency), ), @@ -279,10 +289,10 @@ export class PortfolioService { saved.accounts .map((account) => account.meta.sync) .find((work) => work.state === 'running') ?? - saved.accounts - .map((account) => account.meta.sync) - .find((work) => work.state === 'failed') ?? - saved.membership.sync; + saved.accounts + .map((account) => account.meta.sync) + .find((work) => work.state === 'failed') ?? + saved.membership.sync; return { data, meta: investmentResourceMeta({ @@ -294,15 +304,15 @@ export class PortfolioService { ]), metadataDependencies.map((dependency) => dependency.dataRevision), ]), - availability: missing - ? portfolios.length - ? 'partial' - : 'missing' - : 'complete', + availability: missing ? + portfolios.length ? + 'partial' : + 'missing' : + 'complete', fetchedAt: lastSuccessfulAt ? new Date(lastSuccessfulAt) : null, - freshForMs: portfolios.length - ? portfolioFreshForMs - : membershipFreshForMs, + freshForMs: portfolios.length ? + portfolioFreshForMs : + membershipFreshForMs, work: work.id ? { ...work, id: work.id } : null, dependencies: [ saved.membership, @@ -333,11 +343,12 @@ export class PortfolioService { force, ); if (!requested) return null; - if (!requested.launch) + if (!requested.launch) { return { id: requested.work.id, done: this.pending.get(requested.work.attemptId) ?? Promise.resolve(), }; + } const { id, attemptId } = requested.work; this.active.add(attemptId); const done = new Promise((resolve) => setImmediate(resolve)) @@ -360,22 +371,24 @@ export class PortfolioService { snapshot, ); if (published) { - for (const position of snapshot.positions) + for (const position of snapshot.positions) { await this.metadata.request(userId, revision, position); - if (!force) + } + if (!force) { await this.candles.prefetch( userId, revision, snapshot.positions, ); + } } } catch (error) { if ( error instanceof Error && error.message === 'INVALID_TBANK_TOKEN' - ) + ) { await this.access.rejectMembershipToken(attempt); - else await this.repository.fail(attempt); + } else await this.repository.fail(attempt); } }) .catch(() => @@ -396,8 +409,9 @@ export class PortfolioService { async get(userId: string, accountId: string): Promise { const result = await this.read(userId, accountId); - if (!result.data) + if (!result.data) { throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }); + } return result.data; } @@ -407,12 +421,12 @@ export class PortfolioService { private async normalize( upstream: { - figi?: string; - instrumentUid?: string; - instrumentType?: string; - quantity?: { units?: string | number; nano?: string | number }; - currentPrice?: TbankMoney; averagePositionPrice?: TbankMoney; + currentPrice?: TbankMoney; + figi?: string; + instrumentType?: string; + instrumentUid?: string; + quantity?: { nano?: number | string; units?: number | string }; }[], money: TbankMoney[], ): Promise { @@ -450,8 +464,8 @@ export class PortfolioService { private position( position: Parameters[0][number], - metadata: TbankInstrument | null, - ): PortfolioPositionDto | null { + metadata: null | TbankInstrument, + ): null | PortfolioPositionDto { const quantity = decimal(position.quantity ?? {}); if (isZero(quantity)) return null; const figi = position.figi?.trim() || null; @@ -462,8 +476,8 @@ export class PortfolioService { const absoluteReturn = currentPrice && averagePurchasePrice && - currentPrice.currency === averagePurchasePrice.currency - ? { + currentPrice.currency === averagePurchasePrice.currency ? + { currency: currentPrice.currency, value: formatDecimal( multiply( @@ -474,8 +488,8 @@ export class PortfolioService { quantity, ), ), - } - : null; + } : + null; return { figi, instrumentUid, @@ -507,8 +521,8 @@ export class PortfolioService { private money(value: TbankMoney | undefined): MoneyDto | null { const currency = value?.currency?.trim().toUpperCase(); - return currency && value - ? { currency, value: formatDecimal(decimal(value)) } - : null; + return currency && value ? + { currency, value: formatDecimal(decimal(value)) } : + null; } } diff --git a/src/modules/investments/portfolio/domain/portfolio-structure.spec.ts b/src/modules/investments/portfolio/domain/portfolio-structure.spec.ts index 83cbe50..4264872 100644 --- a/src/modules/investments/portfolio/domain/portfolio-structure.spec.ts +++ b/src/modules/investments/portfolio/domain/portfolio-structure.spec.ts @@ -1,9 +1,9 @@ -import { portfolioStructure } from './portfolio-structure'; import { PortfolioPositionDto } from '../presentation/portfolio.dto'; +import { portfolioStructure } from './portfolio-structure'; const position = ( type: string, - value: string | null, + value: null | string, currency = 'RUB', ): PortfolioPositionDto => ({ type, diff --git a/src/modules/investments/portfolio/domain/portfolio-structure.ts b/src/modules/investments/portfolio/domain/portfolio-structure.ts index 514f167..c6e749d 100644 --- a/src/modules/investments/portfolio/domain/portfolio-structure.ts +++ b/src/modules/investments/portfolio/domain/portfolio-structure.ts @@ -1,11 +1,11 @@ +import type { Money } from '../../../../common/money.types'; + import { add, decimalFromString, formatDecimal, } from '../../../../common/decimal'; -import type { Money } from '../../../../common/money.types'; - const SHARE_PERCENT_DECIMAL_PLACES = 6; const PERCENT_SCALED_FACTOR = 100_000_000n; @@ -13,14 +13,14 @@ const categoryTypes = ['STOCK', 'BOND', 'OTHER', 'CASH'] as const; // Build from positions and cash, never from totals (which already include cash). export function portfolioStructure( - positions: { type: string; currentValue: Money | null }[], + positions: { currentValue: Money | null; type: string }[], cash: Money[], ) { const currencies = new Map< string, { - values: ReturnType[]; hasNegativeValues: boolean; + values: ReturnType[]; } >(); const unvalued = new Map(); @@ -31,14 +31,15 @@ export function portfolioStructure( }; const index = categoryTypes.findIndex((category) => category === type); const value = decimalFromString(money.value); + // eslint-disable-next-line security/detect-object-injection -- The numeric index belongs to the fixed portfolio category array. group.values[index] = add(group.values[index], value); group.hasNegativeValues ||= value.value < 0n; currencies.set(money.currency, group); }; for (const position of positions) { - const type = ['STOCK', 'BOND'].includes(position.type) - ? position.type - : 'OTHER'; + const type = ['BOND', 'STOCK'].includes(position.type) ? + position.type : + 'OTHER'; if (position.currentValue) include(type, position.currentValue); else unvalued.set(type, (unvalued.get(type) ?? 0) + 1); } @@ -53,21 +54,22 @@ export function portfolioStructure( const canCalculateShares = !unvalued.size && !group.hasNegativeValues && total.value > 0n; const categories = group.values.map((value, index) => ({ + // eslint-disable-next-line security/detect-object-injection -- The numeric index belongs to the fixed portfolio category array. type: categoryTypes[index], value: formatDecimal(value), // Rounded to six decimal places using integer arithmetic. Only the // resulting dimensionless percentage is passed to chart geometry. - sharePercent: canCalculateShares - ? formatDecimal({ + sharePercent: canCalculateShares ? + formatDecimal({ value: (value.value * 10n ** BigInt(total.scale - value.scale) * PERCENT_SCALED_FACTOR + total.value / 2n) / - total.value, + total.value, scale: SHARE_PERCENT_DECIMAL_PLACES, - }) - : null, + }) : + null, })); return { currency, diff --git a/src/modules/investments/portfolio/domain/portfolio-totals.ts b/src/modules/investments/portfolio/domain/portfolio-totals.ts index e38548c..da81680 100644 --- a/src/modules/investments/portfolio/domain/portfolio-totals.ts +++ b/src/modules/investments/portfolio/domain/portfolio-totals.ts @@ -1,9 +1,10 @@ +import type { Money } from '../../../../common/money.types'; + import { add, decimalFromString, formatDecimal, } from '../../../../common/decimal'; -import type { Money } from '../../../../common/money.types'; export function sumMoneyByCurrency(amounts: Money[]): Money[] { const totals = new Map>(); diff --git a/src/modules/investments/portfolio/infrastructure/forecast.repository.ts b/src/modules/investments/portfolio/infrastructure/forecast.repository.ts index ae95b21..714192b 100644 --- a/src/modules/investments/portfolio/infrastructure/forecast.repository.ts +++ b/src/modules/investments/portfolio/infrastructure/forecast.repository.ts @@ -1,15 +1,17 @@ import { Injectable } from '@nestjs/common'; import { isDeepStrictEqual } from 'node:util'; + import type { Prisma } from '../../../../../generated/prisma/client'; + import { PrismaService } from '../../../../prisma/prisma.service'; export type ForecastSourceVersion = { - instrumentUid: string; - source: string; - kind: string; - rangeKey: string; dataRevision: number; fetchedAt: Date; + instrumentUid: string; + kind: string; + rangeKey: string; + source: string; }; const forecastSourceReadBatchSize = 100; @@ -18,15 +20,15 @@ export class ForecastRepository { constructor(private readonly prisma: PrismaService) {} resolve(input: { - userId: string; - revision: string; - membershipRevision: number; - scopeKey: string; candidate?: object; + membershipRevision: number; portfolioProjection: string; portfolios: { accountId: string; dataRevision: number }[]; + revision: string; + scopeKey: string; sources: ForecastSourceVersion[]; - timestamps: { resourceKey: string; at: string }[]; + timestamps: { at: string; resourceKey: string }[]; + userId: string; }) { return this.prisma.$transaction(async (tx) => { const owner = { userId: input.userId, revision: input.revision }; @@ -54,9 +56,9 @@ export class ForecastRepository { where: { userId_revision_scopeKey: key }, }); const fallback = - previous?.membershipRevision === input.membershipRevision - ? previous - : null; + previous?.membershipRevision === input.membershipRevision ? + previous : + null; if (!input.candidate) return fallback; const portfolios = await tx.tbankPortfolio.findMany({ where: { @@ -74,15 +76,16 @@ export class ForecastRepository { row.dataRevision === expected.dataRevision, ), ) - ) + ) { return fallback; + } // Four identity parameters per source: stay below SQLite's parameter limit. const sources: ForecastSourceVersion[] = []; for ( let offset = 0; offset < input.sources.length; offset += forecastSourceReadBatchSize - ) + ) { sources.push( ...(await tx.tbankInstrumentSource.findMany({ where: { @@ -106,6 +109,7 @@ export class ForecastRepository { }, })), ); + } if ( input.sources.some( (expected) => @@ -119,8 +123,9 @@ export class ForecastRepository { row.fetchedAt.valueOf() === expected.fetchedAt.valueOf(), ), ) - ) + ) { return fallback; + } const revisions = JSON.parse( JSON.stringify({ portfolioProjection: input.portfolioProjection, @@ -133,8 +138,9 @@ export class ForecastRepository { fallback && isDeepStrictEqual(fallback.dependencyRevisions, revisions) && isDeepStrictEqual(fallback.sourceTimestamps, timestamps) - ) + ) { return fallback; + } const data = { membershipRevision: input.membershipRevision, payload: JSON.parse( diff --git a/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts b/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts index b7ffe04..8144fd0 100644 --- a/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts +++ b/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts @@ -1,18 +1,20 @@ -import { - tbankSourceTtlMs, - tbankSourceNeedsRefresh, -} from '../../../../integrations/tbank/tbank.api'; import { Injectable } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; -import { PrismaService } from '../../../../prisma/prisma.service'; + import type { Prisma } from '../../../../../generated/prisma/client'; import type { PortfolioDto } from '../presentation/portfolio.dto'; +import { + tbankSourceNeedsRefresh, + tbankSourceTtlMs, +} from '../../../../integrations/tbank/tbank.api'; +import { PrismaService } from '../../../../prisma/prisma.service'; + type PortfolioAttempt = { - id: string; attemptId: string; - userId: string; + id: string; revision: string; + userId: string; }; export const portfolioFreshForMs = tbankSourceTtlMs.portfolio; @@ -71,8 +73,9 @@ export class PortfolioRepository { !force && snapshot && !tbankSourceNeedsRefresh('portfolio', snapshot.fetchedAt, new Date()) - ) + ) { return null; + } const scope = { userId, revision, scopeKey: portfolioWorkKey(accountId) }; const previous = await tx.tbankSyncWork.findUnique({ where: { userId_revision_scopeKey: scope }, @@ -81,8 +84,9 @@ export class PortfolioRepository { previous?.state === 'running' && previous.processRunId === processRunId && active.has(previous.attemptId) - ) + ) { return { work: previous, launch: false }; + } const data = { state: 'running', attemptId: randomUUID(), diff --git a/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts b/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts index fd01dc4..f4ae5b6 100644 --- a/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts +++ b/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts @@ -1,12 +1,12 @@ +import type { Money } from '../../../../common/money.types'; + import { add, formatDecimal, isZero, multiply, } from '../../../../common/decimal'; -import type { Money } from '../../../../common/money.types'; import { decimal } from '../../../../integrations/tbank/decimal'; - import { TbankPortfolio, TbankPositions, diff --git a/src/modules/investments/portfolio/portfolio.api.ts b/src/modules/investments/portfolio/portfolio.api.ts index eb62409..fc567da 100644 --- a/src/modules/investments/portfolio/portfolio.api.ts +++ b/src/modules/investments/portfolio/portfolio.api.ts @@ -1,8 +1,8 @@ +export { ForecastService } from './application/forecast.service'; +export { PortfolioService } from './application/portfolio.service'; + // Public cross-module surface. Nest providers are exported by the matching module. export { PortfolioDto, PortfolioPositionDto, } from './presentation/portfolio.dto'; -export { PortfolioService } from './application/portfolio.service'; - -export { ForecastService } from './application/forecast.service'; diff --git a/src/modules/investments/portfolio/portfolio.module.ts b/src/modules/investments/portfolio/portfolio.module.ts index 5f77166..6b462b2 100644 --- a/src/modules/investments/portfolio/portfolio.module.ts +++ b/src/modules/investments/portfolio/portfolio.module.ts @@ -1,12 +1,13 @@ -import { ForecastService } from './application/forecast.service'; -import { ForecastRepository } from './infrastructure/forecast.repository'; -import { InstrumentSourcesModule } from '../instruments/instrument-sources.module'; -import { PortfolioRepository } from './infrastructure/portfolio.repository'; import { Module } from '@nestjs/common'; + import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { AuthModule } from '../../auth/auth.module'; -import { PortfolioController } from './presentation/portfolio.controller'; +import { InstrumentSourcesModule } from '../instruments/instrument-sources.module'; +import { ForecastService } from './application/forecast.service'; import { PortfolioService } from './application/portfolio.service'; +import { ForecastRepository } from './infrastructure/forecast.repository'; +import { PortfolioRepository } from './infrastructure/portfolio.repository'; +import { PortfolioController } from './presentation/portfolio.controller'; @Module({ imports: [AuthModule, TbankModule, InstrumentSourcesModule], diff --git a/src/modules/investments/portfolio/presentation/portfolio.controller.ts b/src/modules/investments/portfolio/presentation/portfolio.controller.ts index 942dd6a..573b696 100644 --- a/src/modules/investments/portfolio/presentation/portfolio.controller.ts +++ b/src/modules/investments/portfolio/presentation/portfolio.controller.ts @@ -1,11 +1,13 @@ -import { InvestmentReadQueryDto } from '../../investment-resource.dto'; -import { Controller, Get, Param, Req, Query, UseGuards } from '@nestjs/common'; -import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import type { Request } from 'express'; + +import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; + import { ApiProblemResponses } from '../../../../common/decorators/api-safe-error-response.decorator'; import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api'; -import { PortfolioResourceDto } from './portfolio.dto'; +import { InvestmentReadQueryDto } from '../../investment-resource.dto'; import { PortfolioService } from '../application/portfolio.service'; +import { PortfolioResourceDto } from './portfolio.dto'; @ApiTags('brokerage-accounts') @ApiProblemResponses() diff --git a/src/modules/investments/portfolio/presentation/portfolio.dto.ts b/src/modules/investments/portfolio/presentation/portfolio.dto.ts index a7a8224..a2bcde6 100644 --- a/src/modules/investments/portfolio/presentation/portfolio.dto.ts +++ b/src/modules/investments/portfolio/presentation/portfolio.dto.ts @@ -1,17 +1,18 @@ -import { InvestmentResourceMetaDto } from '../../investment-resource.dto'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + import { MoneyDto } from '../../../../common/money.dto'; import { AccountReferenceDto } from '../../account-reference.dto'; +import { InvestmentResourceMetaDto } from '../../investment-resource.dto'; export class PortfolioPositionDto { @ApiPropertyOptional({ type: String, nullable: true }) - instrumentUid?: string | null; + instrumentUid?: null | string; @ApiPropertyOptional({ type: AccountReferenceDto }) account?: AccountReferenceDto; @ApiProperty({ type: String, nullable: true }) - declare figi: string | null; + declare figi: null | string; @ApiProperty() declare displayName: string; @@ -44,13 +45,13 @@ export class PortfolioPositionDto { declare absoluteReturn: MoneyDto | null; @ApiProperty({ type: String, nullable: true }) - declare isin: string | null; + declare isin: null | string; @ApiProperty({ type: String, nullable: true }) - declare ticker: string | null; + declare ticker: null | string; @ApiProperty({ type: String, nullable: true }) - declare logoName: string | null; + declare logoName: null | string; } export class PortfolioStructureCategoryDto { @@ -66,7 +67,7 @@ export class PortfolioStructureCategoryDto { description: 'Percentage including cash, rounded to six decimal places. Null for incomplete, negative or zero-total compositions.', }) - declare sharePercent: string | null; + declare sharePercent: null | string; } export class PortfolioStructureCurrencyDto { @@ -81,7 +82,7 @@ export class PortfolioStructureCurrencyDto { }) declare hasNegativeValues: boolean; - @ApiProperty({ type: [PortfolioStructureCategoryDto] }) + @ApiProperty({ type: PortfolioStructureCategoryDto, isArray: true }) declare categories: PortfolioStructureCategoryDto[]; } @@ -94,11 +95,11 @@ export class PortfolioUnvaluedCategoryDto { } export class PortfolioStructureDto { - @ApiProperty({ type: [PortfolioStructureCurrencyDto] }) + @ApiProperty({ type: PortfolioStructureCurrencyDto, isArray: true }) declare currencies: PortfolioStructureCurrencyDto[]; @ApiProperty({ - type: [PortfolioUnvaluedCategoryDto], + type: PortfolioUnvaluedCategoryDto, isArray: true, description: 'Positions without current value and known valuation currency. Shares are unavailable across all currencies when nonempty.', }) @@ -127,7 +128,8 @@ export class PortfolioDto { export class PortfolioResourceDto { @ApiProperty({ type: PortfolioDto, nullable: true }) - declare data: PortfolioDto | null; + declare data: null | PortfolioDto; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } diff --git a/src/modules/investments/tbank-connection/tbank-connection.controller.ts b/src/modules/investments/tbank-connection/tbank-connection.controller.ts index 4e7943b..6d94617 100644 --- a/src/modules/investments/tbank-connection/tbank-connection.controller.ts +++ b/src/modules/investments/tbank-connection/tbank-connection.controller.ts @@ -1,3 +1,5 @@ +import type { Request } from 'express'; + import { Body, Controller, @@ -15,7 +17,7 @@ import { 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 { diff --git a/src/modules/investments/tbank-connection/tbank-connection.dto.ts b/src/modules/investments/tbank-connection/tbank-connection.dto.ts index fd0dcb8..e51b955 100644 --- a/src/modules/investments/tbank-connection/tbank-connection.dto.ts +++ b/src/modules/investments/tbank-connection/tbank-connection.dto.ts @@ -16,5 +16,5 @@ export class TbankConnectionStatusDto { declare connected: boolean; @ApiProperty({ type: String, nullable: true, format: 'date-time' }) - declare connectedAt: string | null; + declare connectedAt: null | string; } diff --git a/src/modules/investments/tbank-connection/tbank-connection.module.ts b/src/modules/investments/tbank-connection/tbank-connection.module.ts index 2f6de7f..b393571 100644 --- a/src/modules/investments/tbank-connection/tbank-connection.module.ts +++ b/src/modules/investments/tbank-connection/tbank-connection.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; + import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { AuthModule } from '../../auth/auth.module'; import { TbankConnectionController } from './tbank-connection.controller'; diff --git a/src/modules/investments/tbank-connection/tbank-connection.service.ts b/src/modules/investments/tbank-connection/tbank-connection.service.ts index a048821..9bd15ad 100644 --- a/src/modules/investments/tbank-connection/tbank-connection.service.ts +++ b/src/modules/investments/tbank-connection/tbank-connection.service.ts @@ -1,8 +1,9 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; + import { + supportedTbankAccounts, TbankClient, TbankTokenService, - supportedTbankAccounts, } from '../../../integrations/tbank/tbank.api'; import { PrismaService } from '../../../prisma/prisma.service'; diff --git a/src/modules/investments/tbank-sync/tbank-screen.spec.ts b/src/modules/investments/tbank-sync/tbank-screen.spec.ts index c138b23..40bfccf 100644 --- a/src/modules/investments/tbank-sync/tbank-screen.spec.ts +++ b/src/modules/investments/tbank-sync/tbank-screen.spec.ts @@ -40,8 +40,9 @@ describe('manual sync screen boundary', () => { from: '2026-02-01', to: '2026-01-01', }, - ] as Parameters[0][]) + ] as Parameters[0][]) { expect(() => normalizeScreen(scope)).toThrow(); + } }); it('retains inclusive calendar days, rejecting instant values for date-only owners', () => { const scope = { diff --git a/src/modules/investments/tbank-sync/tbank-screen.ts b/src/modules/investments/tbank-sync/tbank-screen.ts index eb5a363..e26d79f 100644 --- a/src/modules/investments/tbank-sync/tbank-screen.ts +++ b/src/modules/investments/tbank-sync/tbank-screen.ts @@ -1,4 +1,5 @@ import { BadRequestException } from '@nestjs/common'; + import type { InvestmentScreenDto } from './tbank-sync.dto'; // Reject fields from other screens before reserving any source work. @@ -41,68 +42,78 @@ export function normalizeScreen( }); }; if (!allowed[input.screen]) fail(); - for (const key of Object.keys(input)) + for (const key of Object.keys(input)) { if ( key !== 'screen' && input[key as keyof InvestmentScreenDto] !== undefined && !allowed[input.screen].includes(key) - ) + ) { fail(); + } + } const accountId = input.accountId?.trim(); const query = input.query?.trim().toUpperCase(); if ( - ['account', 'operations', 'income', 'events'].includes(input.screen) && + ['account', 'events', 'income', 'operations'].includes(input.screen) && !accountId - ) + ) { fail(); - if ( - ['stocks', 'bonds'].includes(input.screen) && - (!query || query.length < 2) - ) - fail(); - if (['stock', 'bond'].includes(input.screen) && !input.instrumentUid) fail(); - if (input.accountId !== undefined && !accountId) fail(); - if (['income', 'events'].includes(input.screen)) { - 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)) + if ( + ['bonds', 'stocks'].includes(input.screen) && + (!query || query.length < 2) + ) { fail(); + } + if (['bond', 'stock'].includes(input.screen) && !input.instrumentUid) fail(); + if (input.accountId !== undefined && !accountId) fail(); + if (['events', 'income'].includes(input.screen)) { + 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) - ) + ) { fail(); + } // Fixed field order is part of coalescing, irrespective of JSON input order. return { screen: input.screen, ...(accountId ? { accountId } : {}), ...(input.from ? { from: input.from } : {}), ...(input.to ? { to: input.to } : {}), - ...(input.instrumentUid - ? { instrumentUid: input.instrumentUid.toLowerCase() } - : {}), + ...(input.instrumentUid ? + { instrumentUid: input.instrumentUid.toLowerCase() } : + {}), ...(query ? { query } : {}), ...(input.candlePeriod ? { candlePeriod: input.candlePeriod } : {}), - ...(input.candleRange - ? { + ...(input.candleRange ? + { candleRange: { from: new Date(input.candleRange.from).toISOString(), to: new Date(input.candleRange.to).toISOString(), }, - } - : {}), - ...(input.categories - ? { categories: [...new Set(input.categories)].sort() } - : {}), + } : + {}), + ...(input.categories ? + // 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. + { categories: [...new Set(input.categories)].sort() } : + {}), ...(input.limit !== undefined ? { limit: input.limit } : {}), }; } diff --git a/src/modules/investments/tbank-sync/tbank-sync.controller.ts b/src/modules/investments/tbank-sync/tbank-sync.controller.ts index c4985c0..4d9bfcf 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.controller.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.controller.ts @@ -1,6 +1,8 @@ +import type { Request } from 'express'; + import { - Body, BadRequestException, + Body, Controller, Get, HttpCode, @@ -19,17 +21,17 @@ import { 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 { - InvestmentScreenDto, - IncomeInvestmentScreenDto, EventsInvestmentScreenDto, - TimestampInvestmentScreenDto, + IncomeInvestmentScreenDto, + InvestmentScreenDto, investmentScreenSchema, TbankSyncAcceptedDto, TbankSyncStatusDto, + TimestampInvestmentScreenDto, } from './tbank-sync.dto'; import { TbankSyncService } from './tbank-sync.service'; @@ -53,6 +55,7 @@ export class TbankSyncController { @Req() request: Request & { user: AccessPayload }, @Body( new ValidationPipe({ + forbidUnknownValues: true, expectedType: InvestmentScreenDto, transform: true, whitelist: true, @@ -65,6 +68,7 @@ export class TbankSyncController { ) { return this.sync.request(request.user.sub, input as InvestmentScreenDto); } + @Get(':id') @ApiOkResponse({ type: TbankSyncStatusDto }) status( diff --git a/src/modules/investments/tbank-sync/tbank-sync.dto.ts b/src/modules/investments/tbank-sync/tbank-sync.dto.ts index 011c9e7..5f5c467 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.dto.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.dto.ts @@ -20,6 +20,7 @@ import { MinLength, ValidateNested, } from 'class-validator'; + import { InvestmentResourceMetaDto } from '../investment-resource.dto'; import { operationCategories } from '../operations/operations.api'; @@ -46,12 +47,14 @@ export class InvestmentScreenDto { @ApiProperty({ enum: investmentScreens }) @IsIn(investmentScreens) declare screen: (typeof investmentScreens)[number]; + @ApiPropertyOptional() @IsOptional() @IsString() @MinLength(1) @MaxLength(maxAccountIdLength) accountId?: string; + @ApiPropertyOptional({ oneOf: [ { type: 'string', format: 'date' }, @@ -61,6 +64,7 @@ export class InvestmentScreenDto { @IsOptional() @IsDateString({ strict: true }) from?: string; + @ApiPropertyOptional({ oneOf: [ { type: 'string', format: 'date' }, @@ -70,30 +74,36 @@ export class InvestmentScreenDto { @IsOptional() @IsDateString({ strict: true }) to?: string; + @ApiPropertyOptional({ format: 'uuid' }) @IsOptional() @IsUUID() instrumentUid?: string; + @ApiPropertyOptional() @IsOptional() @IsString() @MinLength(2) @MaxLength(maxQueryLength) query?: string; + @ApiPropertyOptional({ enum: ['month', 'halfYear', 'year', 'all'] }) @IsOptional() @IsIn(['month', 'halfYear', 'year', 'all']) - candlePeriod?: 'month' | 'halfYear' | 'year' | 'all'; + candlePeriod?: 'all' | 'halfYear' | 'month' | 'year'; + @ApiPropertyOptional({ type: SyncCandleRangeDto }) @IsOptional() @ValidateNested() @Type(() => SyncCandleRangeDto) candleRange?: SyncCandleRangeDto; + @ApiPropertyOptional({ enum: operationCategories, isArray: true }) @IsOptional() @IsArray() @IsIn(operationCategories, { each: true }) categories?: (typeof operationCategories)[number][]; + @ApiPropertyOptional({ minimum: 1, maximum: maxPageSize }) @IsOptional() @IsInt() @@ -106,6 +116,7 @@ export class IncomeInvestmentScreenDto extends PickType(InvestmentScreenDto, [ 'accountId', ]) { @ApiProperty({ enum: ['income'] }) declare screen: 'income'; + // eslint-disable-next-line @darraghor/nestjs-typed/api-property-matches-property-optionality -- Overrides optional metadata inherited through PickType. @ApiProperty({ required: true }) declare accountId: string; @ApiPropertyOptional({ format: 'date', @@ -113,6 +124,7 @@ export class IncomeInvestmentScreenDto extends PickType(InvestmentScreenDto, [ '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: @@ -125,6 +137,7 @@ export class EventsInvestmentScreenDto extends PickType(InvestmentScreenDto, [ 'accountId', ]) { @ApiProperty({ enum: ['events'] }) declare screen: 'events'; + // eslint-disable-next-line @darraghor/nestjs-typed/api-property-matches-property-optionality -- Overrides optional metadata inherited through PickType. @ApiProperty({ required: true }) declare accountId: string; @ApiPropertyOptional({ format: 'date', @@ -132,6 +145,7 @@ export class EventsInvestmentScreenDto extends PickType(InvestmentScreenDto, [ 'Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC.', }) from?: string; + @ApiPropertyOptional({ format: 'date', description: @@ -157,11 +171,13 @@ export class TimestampInvestmentScreenDto extends OmitType( // 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.', @@ -194,6 +210,7 @@ export class TbankSyncAcceptedDto { @ApiProperty({ format: 'uuid' }) declare id: string; @ApiProperty({ type: Object, ...investmentScreenSchema }) declare scope: InvestmentScreenDto; + @ApiProperty({ format: 'date-time' }) declare now: string; } export class TbankSyncProgressDto { @@ -205,13 +222,17 @@ export class TbankSyncStatusDto { @ApiProperty({ format: 'uuid' }) declare id: string; @ApiProperty({ type: Object, ...investmentScreenSchema, nullable: true }) declare scope: InvestmentScreenDto | null; + @ApiProperty({ format: 'date-time' }) declare now: string; @ApiProperty({ enum: ['running', 'succeeded', 'failed', 'cancelled'] }) - declare state: 'running' | 'succeeded' | 'failed' | 'cancelled'; + declare state: 'cancelled' | 'failed' | 'running' | 'succeeded'; + @ApiProperty({ type: String, nullable: true }) declare errorCode: - string | null; + null | string; + @ApiProperty({ type: TbankSyncProgressDto }) declare progress: TbankSyncProgressDto; + @ApiProperty({ type: InvestmentResourceMetaDto, isArray: true }) declare resources: InvestmentResourceMetaDto[]; } diff --git a/src/modules/investments/tbank-sync/tbank-sync.module.ts b/src/modules/investments/tbank-sync/tbank-sync.module.ts index b963c81..32e5d3d 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.module.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.module.ts @@ -1,16 +1,17 @@ import { Module } from '@nestjs/common'; -import { AuthModule } from '../../auth/auth.module'; + import { TbankModule } from '../../../integrations/tbank/tbank.module'; -import { PortfolioModule } from '../portfolio/portfolio.module'; -import { OperationsModule } from '../operations/operations.module'; -import { InstrumentSourcesModule } from '../instruments/instrument-sources.module'; -import { InstrumentsModule } from '../instruments/instruments.module'; +import { AuthModule } from '../../auth/auth.module'; import { BrokerageAccountsModule } from '../brokerage-accounts/brokerage-accounts.module'; import { IncomeModule } from '../income/income.module'; +import { InstrumentSourcesModule } from '../instruments/instrument-sources.module'; +import { InstrumentsModule } from '../instruments/instruments.module'; +import { OperationsModule } from '../operations/operations.module'; +import { PortfolioModule } from '../portfolio/portfolio.module'; import { UpcomingEventsModule } from '../upcoming-events/upcoming-events.module'; import { TbankSyncController } from './tbank-sync.controller'; -import { TbankSyncService } from './tbank-sync.service'; import { TbankSyncRepository } from './tbank-sync.repository'; +import { TbankSyncService } from './tbank-sync.service'; @Module({ imports: [ AuthModule, diff --git a/src/modules/investments/tbank-sync/tbank-sync.repository.ts b/src/modules/investments/tbank-sync/tbank-sync.repository.ts index 5add3a8..8189dd0 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.repository.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.repository.ts @@ -1,13 +1,15 @@ import { Injectable } from '@nestjs/common'; import { createHash, randomUUID } from 'node:crypto'; + import type { Prisma } from '../../../../generated/prisma/client'; -import { PrismaService } from '../../../prisma/prisma.service'; import type { TbankSyncAttempt } from '../../../integrations/tbank/tbank.api'; import type { InvestmentScreenDto } from './tbank-sync.dto'; -export type ScreenIntent = { scope: InvestmentScreenDto; now: string }; + +import { PrismaService } from '../../../prisma/prisma.service'; +export type ScreenIntent = { now: string; scope: InvestmentScreenDto }; export type ScreenProgress = { - children: string[]; attempts?: Record; + children: string[]; dispatched: boolean; }; @Injectable() @@ -38,8 +40,9 @@ export class TbankSyncRepository { (await tx.tbankSyncWork.count({ where: { userId, revision, id: { in: children }, state: 'running' }, })) - ) + ) { return { work: previous, launch: false }; + } } const data = { attemptId: randomUUID(), @@ -60,6 +63,7 @@ export class TbankSyncRepository { return { work, launch: true }; }); } + read(userId: string, revision: string, id: string) { return this.prisma.$transaction(async (tx) => { const work = await tx.tbankSyncWork.findFirst({ @@ -75,19 +79,20 @@ export class TbankSyncRepository { work, children: children.map((child) => progress.attempts?.[child.id] && - progress.attempts[child.id] !== child.attemptId - ? { ...child, state: 'cancelled', errorCode: null } - : child, + progress.attempts[child.id] !== child.attemptId ? + { ...child, state: 'cancelled', errorCode: null } : + child, ), progress, }; }); } + async progress( attempt: TbankSyncAttempt, children: string[], dispatched: boolean, - errorCode: string | null = null, + errorCode: null | string = null, ) { return this.prisma.$transaction(async (tx) => { const previous = await tx.tbankSyncWork.findFirst({ where: attempt }); diff --git a/src/modules/investments/tbank-sync/tbank-sync.service.ts b/src/modules/investments/tbank-sync/tbank-sync.service.ts index 826830c..52f4668 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.service.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.service.ts @@ -1,43 +1,45 @@ import { - Injectable, ConflictException, + Injectable, NotFoundException, ServiceUnavailableException, } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; + +import type { InvestmentResourceMetaDto } from '../investment-resource.dto'; + import { + InstrumentMetadataService, TbankAccessService, TbankMembershipService, - InstrumentMetadataService, type TbankSyncAttempt, } from '../../../integrations/tbank/tbank.api'; -import { PortfolioService } from '../portfolio/portfolio.api'; +import { BrokerageAccountsService } from '../brokerage-accounts/brokerage-accounts-read.api'; +import { IncomeAnalyticsService } from '../income/income-read.api'; import { - BrokerageOperationsService, - BrokerageOperationsSyncService, - type OperationInterval, -} from '../operations/operations.api'; + BondIncomeService, + StockIncomeService, +} from '../instruments/instrument-income.api'; import { CandleService, CatalogDiscoveryService, CatalogEnrichmentService, } from '../instruments/instruments.api'; -import { - StockIncomeService, - BondIncomeService, -} from '../instruments/instrument-income.api'; -import { BrokerageAccountsService } from '../brokerage-accounts/brokerage-accounts-read.api'; -import { IncomeAnalyticsService } from '../income/income-read.api'; -import { UpcomingEventsService } from '../upcoming-events/upcoming-events-read.api'; -import { planInvestmentScreen } from '../investment-sync-plan'; -import type { InvestmentResourceMetaDto } from '../investment-resource.dto'; -import { - TbankSyncRepository, - type ScreenIntent, -} from './tbank-sync.repository'; -import { InvestmentScreenDto, TbankSyncStatusDto } from './tbank-sync.dto'; -import { normalizeScreen } from './tbank-screen'; import { investmentResourceMeta } from '../investment-resource.mapper'; +import { planInvestmentScreen } from '../investment-sync-plan'; +import { + BrokerageOperationsService, + BrokerageOperationsSyncService, + type OperationInterval, +} from '../operations/operations.api'; +import { PortfolioService } from '../portfolio/portfolio.api'; +import { UpcomingEventsService } from '../upcoming-events/upcoming-events-read.api'; +import { normalizeScreen } from './tbank-screen'; +import { InvestmentScreenDto, TbankSyncStatusDto } from './tbank-sync.dto'; +import { + type ScreenIntent, + TbankSyncRepository, +} from './tbank-sync.repository'; @Injectable() export class TbankSyncService { @@ -88,7 +90,7 @@ export class TbankSyncService { private async dispatch(attempt: TbankSyncAttempt, intent: ScreenIntent) { const children: string[] = []; - const add = async (ids: (string | null | undefined)[]) => { + const add = async (ids: (null | string | undefined)[]) => { children.push(...ids.filter((id): id is string => Boolean(id))); await this.repository.progress(attempt, children, false); }; @@ -107,14 +109,14 @@ export class TbankSyncService { accountScope === 'all' || account.accountId === accountScope, ); if ( - ['accounts', 'account', 'events', 'stock', 'bond'].includes( + ['account', 'accounts', 'bond', 'events', 'stock'].includes( scope.screen, ) ) { for (const account of scope.screen === 'stock' || - scope.screen === 'bond' - ? current.accounts - : selectedAccounts) { + scope.screen === 'bond' ? + current.accounts : + selectedAccounts) { const work = await this.portfolio.request( userId, revision, @@ -127,9 +129,9 @@ export class TbankSyncService { } const saved = await this.portfolio.read( userId, - scope.screen === 'stock' || scope.screen === 'bond' - ? 'all' - : accountScope, + scope.screen === 'stock' || scope.screen === 'bond' ? + 'all' : + accountScope, true, ); const positions = saved.data?.positions ?? []; @@ -137,8 +139,8 @@ export class TbankSyncService { ...new Map( positions.flatMap((position) => position.instrumentUid && - (position.type === 'STOCK' || position.type === 'BOND') - ? [ + (position.type === 'STOCK' || position.type === 'BOND') ? + [ [ position.instrumentUid, { @@ -147,27 +149,27 @@ export class TbankSyncService { historyFrom: null, }, ] as const, - ] - : [], + ] : + [], ), ).values(), ]; const plan = planInvestmentScreen( { ...scope, - candleRange: scope.candleRange - ? { + candleRange: scope.candleRange ? + { from: new Date(scope.candleRange.from), to: new Date(scope.candleRange.to), - } - : undefined, + } : + undefined, }, { accounts: selectedAccounts, instruments: - scope.screen === 'stocks' || scope.screen === 'bonds' - ? [] - : instruments, + scope.screen === 'stocks' || scope.screen === 'bonds' ? + [] : + instruments, membershipKnown: Boolean(current.membershipAt), }, now, @@ -177,20 +179,20 @@ export class TbankSyncService { source.kind === 'operations' && source.accountId && source.from && - source.to - ? [ + source.to ? + [ { accountId: source.accountId, from: source.from, to: source.to, - ...(source.instrumentUid - ? { instrumentUid: source.instrumentUid } - : {}), + ...(source.instrumentUid ? + { instrumentUid: source.instrumentUid } : + {}), }, - ] - : [], + ] : + [], ); - if (intervals.length) + if (intervals.length) { await add([ await this.operations.request( userId, @@ -200,24 +202,27 @@ export class TbankSyncService { revision, ), ]); + } for (const source of plan.sources) { const uid = source.instrumentUid; const type = source.instrumentType === 'BOND' ? 'bond' : 'share'; - if (source.kind === 'catalog') + if (source.kind === 'catalog') { await add( await this.discovery.refresh(userId, revision, { instrumentType: type, query: source.query ?? '', }), ); + } if (!uid) continue; - if (source.kind === 'metadata') + if (source.kind === 'metadata') { await add([ await this.metadata.request(userId, revision, { instrumentUid: uid, }), ]); - if (source.kind === 'quote') + } + if (source.kind === 'quote') { await add( await this.sources.requestMany( userId, @@ -233,7 +238,8 @@ export class TbankSyncService { ], ), ); - if (source.kind === 'company') + } + if (source.kind === 'company') { await add( await this.sources.requestMany( userId, @@ -244,7 +250,8 @@ export class TbankSyncService { ['description', 'fundamentals'], ), ); - if (source.kind === 'metrics') + } + if (source.kind === 'metrics') { await add( await this.sources.requestMany( userId, @@ -255,7 +262,8 @@ export class TbankSyncService { ['coupons', 'yield', 'rating'], ), ); - if (source.kind === 'calendar' && source.from && source.to) + } + if (source.kind === 'calendar' && source.from && source.to) { await add( await this.sources.requestMany( userId, @@ -264,11 +272,11 @@ export class TbankSyncService { [uid], true, [ - type === 'share' - ? 'dividends' - : scope.screen === 'bond' - ? 'coupons' - : 'bond-events', + type === 'share' ? + 'dividends' : + scope.screen === 'bond' ? + 'coupons' : + 'bond-events', ], { from: source.from.toISOString().slice(0, 10), @@ -279,6 +287,7 @@ export class TbankSyncService { }, ), ); + } } if ( (scope.screen === 'stock' || scope.screen === 'bond') && @@ -292,14 +301,14 @@ export class TbankSyncService { uid: scope.instrumentUid, instrumentType: scope.screen === 'bond' ? 'bond' : 'share', period: scope.candlePeriod ?? 'month', - ...(scope.candleRange - ? { + ...(scope.candleRange ? + { range: { from: new Date(scope.candleRange.from), to: new Date(scope.candleRange.to), }, - } - : {}), + } : + {}), }, now, true, @@ -324,15 +333,16 @@ export class TbankSyncService { }); const saved = await this.repository.read(userId, connection.revision, id); if (!saved) throw new NotFoundException(); - if (!saved.work.scopeKey.startsWith('screen:')) + if (!saved.work.scopeKey.startsWith('screen:')) { return this.sourceStatus(userId, saved.work); + } const intent = saved.work.intent as unknown as ScreenIntent; await this.access.local(userId, intent.scope.accountId); const children = await Promise.all( saved.children.map((child) => - child.state === 'superseded' - ? this.operations.observe(userId, child.id) - : Promise.resolve(child), + child.state === 'superseded' ? + this.operations.observe(userId, child.id) : + Promise.resolve(child), ), ); const running = children.some((child) => child.state === 'running'); @@ -342,21 +352,22 @@ export class TbankSyncService { null; const state = saved.work.processRunId !== this.runId && - (!saved.progress.dispatched || running) - ? 'cancelled' - : !saved.progress.dispatched || running - ? 'running' - : failed - ? 'failed' - : children.some((child) => child.state === 'cancelled') - ? 'cancelled' - : 'succeeded'; + (!saved.progress.dispatched || running) ? + 'cancelled' : + !saved.progress.dispatched || running ? + 'running' : + failed ? + 'failed' : + children.some((child) => child.state === 'cancelled') ? + 'cancelled' : + 'succeeded'; const resources = await this.observe(userId, intent.scope); if ( (await this.access.local(userId, intent.scope.accountId)).revision !== connection.revision - ) + ) { throw new NotFoundException(); + } return { id, ...intent, @@ -380,16 +391,16 @@ export class TbankSyncService { const add = async (read: Promise<{ meta: InvestmentResourceMetaDto }>) => { resources.push((await read).meta); }; - if (work.scopeKey === 'membership') + if (work.scopeKey === 'membership') { await add(this.accounts.list(userId, true)); - else if ( + } else if ( input.source === 'portfolio' && typeof input.accountId === 'string' - ) + ) { await add(this.portfolio.read(userId, input.accountId, true)); - else if (input.source === 'catalog') { + } else if (input.source === 'catalog') { const type = input.instrumentType === 'bond' ? 'bond' : 'share'; - if (typeof input.query === 'string') + if (typeof input.query === 'string') { await add( this.discovery.search( userId, @@ -401,14 +412,14 @@ export class TbankSyncService { true, ), ); - else if ( + } else if ( typeof input.uid === 'string' && typeof input.kind === 'string' ) { const range = - typeof input.rangeKey === 'string' && input.rangeKey.startsWith('[') - ? (JSON.parse(input.rangeKey) as string[]) - : null; + typeof input.rangeKey === 'string' && input.rangeKey.startsWith('[') ? + (JSON.parse(input.rangeKey) as string[]) : + null; const sources = await this.sources.readMany( userId, type, @@ -417,9 +428,9 @@ export class TbankSyncService { [ input.kind as Parameters< CatalogEnrichmentService['readMany'] - >[4] extends (infer K)[] | undefined - ? K - : never, + >[4] extends (infer K)[] | undefined ? + K : + never, ], range ? { from: range[0], to: range[1] } : undefined, ); @@ -452,7 +463,7 @@ export class TbankSyncService { { uid: input.uid, instrumentType: input.instrumentType === 'bond' ? 'bond' : 'share', - period: input.period as 'month' | 'halfYear' | 'year' | 'all', + period: input.period as 'all' | 'halfYear' | 'month' | 'year', }, true, ), @@ -475,21 +486,22 @@ export class TbankSyncService { ); } } - const observed = work.scopeKey.startsWith('operations:') - ? await this.operations.observe(userId, work.id) - : work; + const observed = work.scopeKey.startsWith('operations:') ? + await this.operations.observe(userId, work.id) : + work; const state = - observed.state === 'running' - ? observed.updatedAt < this.startedAt - ? 'cancelled' - : 'running' - : observed.state === 'failed' - ? 'failed' - : observed.state === 'cancelled' - ? 'cancelled' - : 'succeeded'; - if ((await this.access.local(userId)).revision !== work.revision) + observed.state === 'running' ? + observed.updatedAt < this.startedAt ? + 'cancelled' : + 'running' : + observed.state === 'failed' ? + 'failed' : + observed.state === 'cancelled' ? + 'cancelled' : + 'succeeded'; + if ((await this.access.local(userId)).revision !== work.revision) { throw new NotFoundException(); + } return { id: work.id, scope: null, @@ -511,14 +523,15 @@ export class TbankSyncService { resources.push((await read).meta); }; const account = scope.accountId ?? 'all'; - if (scope.screen === 'accounts') + if (scope.screen === 'accounts') { await add(this.accounts.list(userId, true)); + } if (scope.screen === 'account') { await add(this.portfolio.read(userId, account, true)); await add(this.income.summary(userId, account, true)); await add(this.events.get(userId, account, { sync: 'observe' })); } - if (['account', 'operations', 'stock', 'bond'].includes(scope.screen)) + if (['account', 'bond', 'operations', 'stock'].includes(scope.screen)) { await add( this.ledger.read(userId, account, { from: scope.from, @@ -529,11 +542,13 @@ export class TbankSyncService { sync: 'observe', }), ); - if (scope.screen === 'income') + } + if (scope.screen === 'income') { await add( this.income.analytics(userId, account, scope.from, scope.to, true), ); - if (scope.screen === 'events') + } + if (scope.screen === 'events') { await add( this.events.get(userId, account, { from: scope.from, @@ -541,9 +556,10 @@ export class TbankSyncService { sync: 'observe', }), ); + } const type = scope.screen === 'bond' || scope.screen === 'bonds' ? 'bond' : 'share'; - if (scope.screen === 'stocks' || scope.screen === 'bonds') + if (scope.screen === 'stocks' || scope.screen === 'bonds') { await add( this.discovery.search( userId, @@ -551,6 +567,7 @@ export class TbankSyncService { true, ), ); + } if ( (scope.screen === 'stock' || scope.screen === 'bond') && scope.instrumentUid @@ -574,27 +591,27 @@ export class TbankSyncService { uid, instrumentType: type, period: scope.candlePeriod ?? 'month', - ...(scope.candleRange - ? { + ...(scope.candleRange ? + { range: { from: new Date(scope.candleRange.from), to: new Date(scope.candleRange.to), }, - } - : {}), + } : + {}), }, true, ), ); await add( - type === 'share' - ? this.stockIncome.readPosition(userId, uid, true) - : this.bondIncome.readPosition(userId, uid, true), + type === 'share' ? + this.stockIncome.readPosition(userId, uid, true) : + this.bondIncome.readPosition(userId, uid, true), ); await add( - type === 'share' - ? this.stockIncome.dividends(userId, uid, true) - : this.bondIncome.coupons(userId, uid, true), + type === 'share' ? + this.stockIncome.dividends(userId, uid, true) : + this.bondIncome.coupons(userId, uid, true), ); } return resources; diff --git a/src/modules/investments/upcoming-events/upcoming-events.controller.ts b/src/modules/investments/upcoming-events/upcoming-events.controller.ts index 2a5d4e4..17bc59a 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.controller.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.controller.ts @@ -1,11 +1,13 @@ +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 { - UpcomingEventsResourceDto, UpcomingEventsQueryDto, + UpcomingEventsResourceDto, } from './upcoming-events.dto'; import { UpcomingEventsService } from './upcoming-events.service'; diff --git a/src/modules/investments/upcoming-events/upcoming-events.dto.ts b/src/modules/investments/upcoming-events/upcoming-events.dto.ts index 55a7d8b..8d8353b 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.dto.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.dto.ts @@ -1,11 +1,12 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsDateString, IsOptional, Matches } from 'class-validator'; + +import { MoneyDto } from '../../../common/money.dto'; +import { AccountReferenceDto } from '../account-reference.dto'; import { InvestmentReadQueryDto, InvestmentResourceMetaDto, } from '../investment-resource.dto'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsDateString, IsOptional, Matches } from 'class-validator'; -import { MoneyDto } from '../../../common/money.dto'; -import { AccountReferenceDto } from '../account-reference.dto'; export class UpcomingEventsQueryDto extends InvestmentReadQueryDto { @ApiPropertyOptional({ @@ -43,10 +44,10 @@ export class UpcomingEventDto { declare dateKind: 'event' | 'registry'; @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() declare ticker: string; @@ -55,10 +56,10 @@ export class UpcomingEventDto { declare displayName: string; @ApiProperty({ type: String, nullable: true }) - declare isin: string | null; + declare isin: null | string; @ApiProperty({ type: String, nullable: true }) - declare logoName: string | null; + declare logoName: null | string; @ApiProperty({ type: MoneyDto, nullable: true }) declare projectedAmount: MoneyDto | null; @@ -80,7 +81,8 @@ export class UpcomingEventsDto { export class UpcomingEventsResourceDto { @ApiProperty({ type: UpcomingEventsDto, nullable: true }) - declare data: UpcomingEventsDto | null; + declare data: null | UpcomingEventsDto; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } diff --git a/src/modules/investments/upcoming-events/upcoming-events.module.ts b/src/modules/investments/upcoming-events/upcoming-events.module.ts index fbd2ad2..ed66431 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.module.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.module.ts @@ -1,8 +1,9 @@ -import { InstrumentSourcesModule } from '../instruments/instrument-sources.module'; -import { PortfolioModule } from '../portfolio/portfolio.module'; import { Module } from '@nestjs/common'; + import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { AuthModule } from '../../auth/auth.module'; +import { InstrumentSourcesModule } from '../instruments/instrument-sources.module'; +import { PortfolioModule } from '../portfolio/portfolio.module'; import { UpcomingEventsController } from './upcoming-events.controller'; import { UpcomingEventsService } from './upcoming-events.service'; diff --git a/src/modules/investments/upcoming-events/upcoming-events.service.spec.ts b/src/modules/investments/upcoming-events/upcoming-events.service.spec.ts index 762017f..207e233 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.service.spec.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.service.spec.ts @@ -1,5 +1,5 @@ -import { dividendEvent, bondEvent } from './upcoming-events.service'; import { decimalFromString } from '../../../common/decimal'; +import { bondEvent, dividendEvent } from './upcoming-events.service'; jest.mock('../../../prisma/prisma.service', () => ({ PrismaService: class {}, })); diff --git a/src/modules/investments/upcoming-events/upcoming-events.service.ts b/src/modules/investments/upcoming-events/upcoming-events.service.ts index b1f2c36..2f6534c 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.service.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.service.ts @@ -1,5 +1,7 @@ import { BadRequestException, Injectable } from '@nestjs/common'; -import { MILLISECONDS_PER_SECOND } from '../../../common/time'; + +import type { MoexBondEvent } from '../../../integrations/moex/moex.api'; + import { add, decimalFromString, @@ -8,21 +10,21 @@ import { multiply, } from '../../../common/decimal'; import { MoneyDto } from '../../../common/money.dto'; -import type { MoexBondEvent } from '../../../integrations/moex/moex.api'; +import { MILLISECONDS_PER_SECOND } from '../../../common/time'; import { decimal } from '../../../integrations/tbank/decimal'; import { - TbankAccessService, InstrumentMetadataService, metadataKey, + TbankAccessService, type TbankDividend, } from '../../../integrations/tbank/tbank.api'; -import { - PortfolioService, - ForecastService, - type PortfolioPositionDto, -} from '../portfolio/portfolio.api'; import { CatalogEnrichmentService } from '../instruments/instruments.api'; import { investmentResourceMeta } from '../investment-resource.mapper'; +import { + ForecastService, + type PortfolioPositionDto, + PortfolioService, +} from '../portfolio/portfolio.api'; import { UpcomingEventDto, UpcomingEventsDto, @@ -74,7 +76,7 @@ export class UpcomingEventsService { metadata.get(metadataKey(position))?.data?.ticker || '', })) - .filter((entry): entry is { uid: string; ticker: string } => + .filter((entry): entry is { ticker: string; uid: string } => Boolean(entry.uid), ); const uids = [...new Set(identities.map((entry) => entry.uid))]; @@ -82,7 +84,7 @@ export class UpcomingEventsService { let offset = 0; offset < uids.length; offset += calendarReadBatchSize - ) + ) { sources.push( ...(await this.sources.readMany( userId, @@ -98,6 +100,7 @@ export class UpcomingEventsService { }, )), ); + } } for (const position of positions) { const generic = metadata.get(metadataKey(position))?.data; @@ -126,7 +129,7 @@ export class UpcomingEventsService { (source) => source.input.uid === uid && source.input.kind === - (type === 'share' ? 'dividends' : 'bond-events'), + (type === 'share' ? 'dividends' : 'bond-events'), ); const instrument = sources.find( @@ -148,15 +151,15 @@ export class UpcomingEventsService { }; const quantity = decimalFromString(position.quantity); const projected = - type === 'share' - ? (calendar.payload?.dividends ?? []) + type === 'share' ? + (calendar.payload?.dividends ?? []) .filter( (event) => event.dividendType?.toLowerCase() !== 'cancelled' && Boolean(event.recordDate), ) - .map((event) => dividendEvent(identity, quantity, event)) - : (calendar.payload?.bondEvents ?? []).map((event) => + .map((event) => dividendEvent(identity, quantity, event)) : + (calendar.payload?.bondEvents ?? []).map((event) => bondEvent(identity, quantity, event), ); events.push( @@ -166,9 +169,9 @@ export class UpcomingEventsService { ) .map((event) => ({ ...event, - ...(accountId === 'all' && position.account - ? { account: position.account } - : {}), + ...(accountId === 'all' && position.account ? + { account: position.account } : + {}), })), ); } @@ -179,14 +182,14 @@ export class UpcomingEventsService { a.type.localeCompare(b.type) || a.ticker.localeCompare(b.ticker), ); - const candidate: UpcomingEventsDto | undefined = ready - ? { + const candidate: undefined | UpcomingEventsDto = ready ? + { accountId, calculatedAt: new Date().toISOString(), events, incomeTotalsByCurrency: incomeTotals(events), - } - : undefined; + } : + undefined; return this.forecasts.result({ userId, revision: connection.revision, @@ -197,6 +200,7 @@ export class UpcomingEventsService { candidate, }); } + getAll(userId: string, query: UpcomingEventsQueryDto = {}) { return this.get(userId, 'all', query); } @@ -205,7 +209,7 @@ export class UpcomingEventsService { export function dividendEvent( metadata: Pick< UpcomingEventDto, - 'figi' | 'instrumentUid' | 'ticker' | 'displayName' | 'isin' | 'logoName' + 'displayName' | 'figi' | 'instrumentUid' | 'isin' | 'logoName' | 'ticker' >, quantity: ReturnType, event: TbankDividend, @@ -217,20 +221,20 @@ export function dividendEvent( .toISOString() .slice(0, 10), dateKind: 'registry', - projectedAmount: event.dividendNet - ? projectedMoney( + projectedAmount: event.dividendNet ? + projectedMoney( quantity, formatDecimal(decimal(event.dividendNet)), event.dividendNet.currency, - ) - : null, + ) : + null, }; } export function bondEvent( metadata: Pick< UpcomingEventDto, - 'figi' | 'instrumentUid' | 'ticker' | 'displayName' | 'isin' | 'logoName' + 'displayName' | 'figi' | 'instrumentUid' | 'isin' | 'logoName' | 'ticker' >, quantity: ReturnType, event: MoexBondEvent, @@ -241,16 +245,16 @@ export function bondEvent( date: event.date, dateKind: 'event', projectedAmount: - 'value' in event - ? projectedMoney(quantity, event.value, event.currency) - : null, + 'value' in event ? + projectedMoney(quantity, event.value, event.currency) : + null, }; } function supported(position: Position): boolean { return ( !isZero(decimalFromString(position.quantity)) && - ['STOCK', 'BOND'].includes(position.type) + ['BOND', 'STOCK'].includes(position.type) ); } @@ -259,8 +263,9 @@ function dateWindow(query: UpcomingEventsQueryDto) { const end = new Date(`${from}T00:00:00.000Z`); end.setUTCDate(end.getUTCDate() + DEFAULT_EVENT_WINDOW_DAYS); const to = query.to ?? end.toISOString().slice(0, 10); - if (from > to) + if (from > to) { throw new BadRequestException({ code: 'INVALID_EVENTS_PERIOD' }); + } return { from, to }; } @@ -280,8 +285,9 @@ function projectedMoney( function incomeTotals(events: UpcomingEventDto[]): MoneyDto[] { const totals = new Map>(); for (const event of events) { - if (!['coupon', 'dividend'].includes(event.type) || !event.projectedAmount) + if (!['coupon', 'dividend'].includes(event.type) || !event.projectedAmount) { continue; + } const current = totals.get(event.projectedAmount.currency) ?? { value: 0n, scale: 0, diff --git a/src/prisma/prisma.module.ts b/src/prisma/prisma.module.ts index 7207426..d30ce68 100644 --- a/src/prisma/prisma.module.ts +++ b/src/prisma/prisma.module.ts @@ -1,4 +1,5 @@ import { Global, Module } from '@nestjs/common'; + import { PrismaService } from './prisma.service'; @Global() diff --git a/src/prisma/prisma.service.ts b/src/prisma/prisma.service.ts index c65572a..6af476c 100644 --- a/src/prisma/prisma.service.ts +++ b/src/prisma/prisma.service.ts @@ -1,6 +1,7 @@ import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'; + import { PrismaClient } from '../../generated/prisma/client'; import { Environment } from '../config/env.validation'; @@ -11,8 +12,7 @@ function getDatabaseUrl(config: ConfigService): string { @Injectable() export class PrismaService extends PrismaClient - implements OnModuleInit, OnModuleDestroy -{ + implements OnModuleDestroy, OnModuleInit { constructor(config: ConfigService) { super({ adapter: new PrismaBetterSqlite3({ diff --git a/test/all-accounts.e2e-spec.ts b/test/all-accounts.e2e-spec.ts index f6cef5b..9bb7cd0 100644 --- a/test/all-accounts.e2e-spec.ts +++ b/test/all-accounts.e2e-spec.ts @@ -1,13 +1,15 @@ +import type { Server } from 'node:http'; + 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 { PrismaService } from '../src/prisma/prisma.service'; -import { TbankClient } from '../src/integrations/tbank/tbank.client'; import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; +import { TbankClient } from '../src/integrations/tbank/tbank.client'; import { configureApp } from '../src/main'; +import { PrismaService } from '../src/prisma/prisma.service'; describe('All accounts HTTP boundary', () => { let app: INestApplication; @@ -78,15 +80,10 @@ describe('All accounts HTTP boundary', () => { .expect(200); expect(response.body).not.toHaveProperty('token'); expect(response.body).not.toHaveProperty('generation'); - if (path === 'portfolio') - expect(response.body).toMatchObject({ - data: { positions: [] }, - meta: { availability: 'complete' }, - }); - else - expect(response.body).toMatchObject({ - meta: { availability: 'complete' }, - }); + expect(response.body).toMatchObject({ + meta: { availability: 'complete' }, + ...(path === 'portfolio' ? { data: { positions: [] } } : {}), + }); }, ); diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index d1ed34f..f73ba25 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -4,13 +4,14 @@ import { Test, TestingModule } from '@nestjs/testing'; import { PinoLogger } from 'nestjs-pino'; import request from 'supertest'; import { App } from 'supertest/types'; + import { AppModule } from '../src/app.module'; import { StructuredLogger } from '../src/common/logging/structured-logger.service'; import { configureApp } from '../src/main'; import { AuthService } from '../src/modules/auth/application/auth.service'; import { PrismaService } from '../src/prisma/prisma.service'; -type Authentication = { user: { email: string }; accessToken: string }; +type Authentication = { accessToken: string; user: { email: string } }; type Problem = { code: string }; describe('Authentication (e2e)', () => { @@ -181,7 +182,7 @@ describe('Authentication (e2e)', () => { ); barrier.mockRestore(); expect(arrived).toBe(2); - expect(responses.map((response) => response.status).sort()).toEqual([ + expect(responses.map((response) => response.status).sort((a, b) => a - b)).toEqual([ 200, 401, ]); const winner = responses.find((response) => response.status === 200); diff --git a/test/bank-cards-durability.cjs b/test/bank-cards-durability.cjs index 23e801a..7fb7f0b 100644 --- a/test/bank-cards-durability.cjs +++ b/test/bank-cards-durability.cjs @@ -1,22 +1,25 @@ const assert = require('node:assert/strict'); const { spawn, execFileSync } = require('node:child_process'); +const { randomUUID } = require('node:crypto'); +const { once } = require('node:events'); const { mkdtempSync, openSync, closeSync, writeFileSync, existsSync, readdirSync, readFileSync } = require('node:fs'); +const { createServer } = require('node:net'); const { tmpdir } = require('node:os'); const { join, resolve } = require('node:path'); -const { once } = require('node:events'); -const { createServer } = require('node:net'); -const { randomUUID } = require('node:crypto'); const repo = resolve(__dirname, '..'); assert.ok(process.argv[2], 'Pass a separately built baseline repository directory'); const baseline = resolve(process.argv[2]); assert.notEqual(baseline, repo, 'Baseline must be a separate historical build'); for (const root of [baseline, repo]) { - assert.ok(existsSync(join(root, 'dist/src/main.js')), `Build first: ${root}`); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds. + assert.ok(existsSync(join(root, 'dist/src/main.js')), `Build first: ${root}`); } -const migrations = root => readdirSync(join(root, 'prisma/migrations')).filter(name => existsSync(join(root, 'prisma/migrations', name, 'migration.sql'))).sort(); -const previous = migrations(baseline), current = migrations(repo); +// eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds. +const migrations = (root) => readdirSync(join(root, 'prisma/migrations')).filter((name) => existsSync(join(root, 'prisma/migrations', name, 'migration.sql'))).sort(); +const current = migrations(repo), previous = migrations(baseline); assert.ok(previous.length > 0 && previous.length < current.length, 'Baseline must precede the tested migrations'); assert.deepEqual(previous, current.slice(0, previous.length), 'Baseline migrations must be an unchanged prefix'); +// eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds. for (const name of previous) assert.equal(readFileSync(join(baseline, 'prisma/migrations', name, 'migration.sql'), 'utf8'), readFileSync(join(repo, 'prisma/migrations', name, 'migration.sql'), 'utf8')); const Database = require(join(repo, 'node_modules/better-sqlite3')); const dir = mkdtempSync(join(tmpdir(), 'wealthledger-bank-durability-')); @@ -26,97 +29,184 @@ const env = { ...process.env, DATABASE_URL: `file:${file}`, NODE_ENV: 'developme delete env.RUST_LOG; let child, origin, run = 0; const credentials = { email: 'durability@example.test', password: 'fictional password long enough' }; -const rub = value => ({ currency: 'RUB', value }); +const rub = (value) => ({ currency: 'RUB', value }); async function start(root) { - const server = createServer(); server.listen(0, '127.0.0.1'); await once(server,'listening'); const port=server.address().port; await new Promise(r=>server.close(r)); - origin=`http://127.0.0.1:${port}`; - const log = openSync(join(dir,`app-${++run}.log`),'w'); - child=spawn(process.execPath,[join(root,'dist/src/main.js')],{cwd:root,env:{...env,DATABASE_URL:`file:${file}`,PORT:String(port)},stdio:['ignore',log,log]}); closeSync(log); - for(let i=0;i<150;i++){if(child.exitCode!==null)throw new Error(`app exited ${child.exitCode}`);try{const r=await fetch(`${origin}/api/v1/auth/me`);if(r.status===401)return;}catch{} await new Promise(r=>setTimeout(r,100));} - throw new Error('app startup timeout'); + const server = createServer(); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const port = server.address().port; + await new Promise((r) => server.close(r)); + origin = `http://127.0.0.1:${port}`; + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds. + const log = openSync(join(dir, `app-${++run}.log`), 'w'); + child = spawn(process.execPath, [join(root, 'dist/src/main.js')], { cwd: root, env: { ...env, DATABASE_URL: `file:${file}`, PORT: String(port) }, stdio: ['ignore', log, log] }); + closeSync(log); + for (let i = 0; i < 150; i++) { + if (child.exitCode !== null) throw new Error(`app exited ${child.exitCode}`); + try { + const r = await fetch(`${origin}/api/v1/auth/me`); + if (r.status === 401) return; + } catch { /* Startup connection failures are retried until the deadline. */ } + await new Promise((r) => setTimeout(r, 100)); + } + throw new Error('app startup timeout'); +} +async function stop() { + if (!child) return; + const c = child; + child = null; + if (c.exitCode !== null) return; + const ended = once(c, 'exit'); + c.kill('SIGTERM'); + await ended; +} +async function http(path, method = 'GET', body, token, status = 200) { + const r = await fetch(`${origin}/api/v1/${path}`, { method, headers: { ...(body ? { 'content-type': 'application/json' } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}) }); + const text = await r.text(); + assert.equal(r.status, status, `${method} ${path}: ${text}`); + return text ? JSON.parse(text) : null; +} + +function migrate(root) { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths are under an exclusive mkdtemp directory or the local migrations directory. + execFileSync(process.execPath, [join(root, 'node_modules/prisma/build/index.js'), 'migrate', 'deploy'], { cwd: root, env: { ...env, DATABASE_URL: `file:${file}` }, stdio: ['ignore', openSync(join(dir, `migration-${run}.log`), 'w'), 'pipe'] }); +} +function rows(table) { + const db = new Database(file, { readonly: true }); + db.defaultSafeIntegers(true); + try { + return db.prepare(`SELECT * FROM "${table}" ORDER BY id`).all(); + } finally { + db.close(); + } } -async function stop(){ if(!child)return;const c=child; child=null; if(c.exitCode!==null)return; const ended=once(c,'exit');c.kill('SIGTERM');await ended;} -async function http(path,method='GET',body,token,status=200){const r=await fetch(`${origin}/api/v1/${path}`,{method,headers:{...(body?{'content-type':'application/json'}:{}),...(token?{Authorization:`Bearer ${token}`}:{})},...(body?{body:JSON.stringify(body)}:{})});const text=await r.text();assert.equal(r.status,status,`${method} ${path}: ${text}`);return text?JSON.parse(text):null;} -function migrate(root){execFileSync(process.execPath,[join(root,'node_modules/prisma/build/index.js'),'migrate','deploy'],{cwd:root,env:{...env,DATABASE_URL:`file:${file}`},stdio:['ignore',openSync(join(dir,`migration-${run}.log`),'w'),'pipe']});} -function rows(table){const db=new Database(file,{readonly:true});db.defaultSafeIntegers(true);try{return db.prepare(`SELECT * FROM "${table}" ORDER BY id`).all();}finally{db.close();}} // The rollback build must leave every additive T-Bank dataset and work intent intact. function seedTbank(owner) { - const db = new Database(file); - const revision = db.prepare('SELECT revision FROM TbankConnection WHERE userId=?').get(owner).revision; - const now = Date.now(); - const scope = {userId:owner,revision,accountId:'durable-account'}; - const exact = {currency:'RUB',value:'-9007199254740993.123456789'}; - const insert = (table, row) => db.prepare(`INSERT INTO "${table}" (${Object.keys(row).map(key=>`"${key}"`).join(',')}) VALUES (${Object.keys(row).map(()=>'?').join(',')})`).run(...Object.values(row)); - db.transaction(() => { - db.prepare('UPDATE TbankConnection SET membershipAt=?,membershipRevision=1 WHERE userId=?').run(now,owner); - insert('TbankAccount',{...scope,type:'BROKERAGE',name:'Synthetic persisted account',upstreamOrder:0}); - insert('TbankPortfolio',{...scope,payload:JSON.stringify({positions:[],cashBalances:[],totalsByCurrency:[],structure:{currencies:[],unvaluedCategories:[]}}),fetchedAt:now}); - insert('TbankOperation',{...scope,brokerOperationId:'durable-income',occurredAt:now,category:'income',payload:JSON.stringify({id:'durable-income',payment:exact})}); - insert('TbankOperationCoverage',{...scope,from:now-1000,to:now+1000,fetchedAt:now,asOf:now}); - insert('TbankForecast',{userId:owner,revision,scopeKey:'durable-forecast',membershipRevision:1,payload:JSON.stringify({incomeTotalsByCurrency:[exact]}),dependencyRevisions:JSON.stringify({portfolio:1}),sourceTimestamps:JSON.stringify({portfolio:new Date(now).toISOString()}),calculatedAt:now}); - insert('TbankInstrument',{provider:'tbank',uid:'durable-instrument',instrumentType:'bond',metadata:JSON.stringify({name:'Synthetic'}),fetchedAt:now}); - insert('TbankCatalogDiscovery',{provider:'tbank',instrumentType:'bond',query:'SYN',instrumentUids:JSON.stringify(['durable-instrument']),fetchedAt:now}); - insert('TbankInstrumentLookup',{provider:'tbank',identifierType:'ticker',identifier:'SYN',matchedUids:JSON.stringify(['durable-instrument']),fetchedAt:now}); - insert('TbankInstrumentSource',{provider:'tbank',instrumentUid:'durable-instrument',source:'tbank',kind:'coupons',rangeKey:'durable',payload:JSON.stringify({events:[]}),fetchedAt:now}); - insert('TbankCandle',{provider:'tbank',instrumentUid:'durable-instrument',interval:'day',time:now,close:exact.value,payload:JSON.stringify({close:exact.value}),isComplete:1,fetchedAt:now}); - insert('TbankCandleCoverage',{provider:'tbank',instrumentUid:'durable-instrument',interval:'day',from:now-1000,to:now+1000,isComplete:1,fetchedAt:now}); - insert('TbankSyncWork',{id:'durable-work',userId:owner,revision,scopeKey:'durable-intent',intent:JSON.stringify({from:now-1000,to:now+1000}),state:'running',attemptId:'stopped-attempt',processRunId:'stopped-process',priority:0,progress:JSON.stringify({completed:1}),createdAt:now,updatedAt:now}); - insert('TbankSyncStage',{workId:'durable-work',attemptId:'stopped-attempt',sourceKey:'durable',itemKey:'next-row',payload:JSON.stringify({payment:exact})}); - })(); - db.close(); + const db = new Database(file); + const revision = db.prepare('SELECT revision FROM TbankConnection WHERE userId=?').get(owner).revision; + const now = Date.now(); + const scope = { userId: owner, revision, accountId: 'durable-account' }; + const exact = { currency: 'RUB', value: '-9007199254740993.123456789' }; + const insert = (table, row) => db.prepare(`INSERT INTO "${table}" (${Object.keys(row).map((key) => `"${key}"`).join(',')}) VALUES (${Object.keys(row).map(() => '?').join(',')})`).run(...Object.values(row)); + db.transaction(() => { + db.prepare('UPDATE TbankConnection SET membershipAt=?,membershipRevision=1 WHERE userId=?').run(now, owner); + insert('TbankAccount', { ...scope, type: 'BROKERAGE', name: 'Synthetic persisted account', upstreamOrder: 0 }); + insert('TbankPortfolio', { ...scope, payload: JSON.stringify({ positions: [], cashBalances: [], totalsByCurrency: [], structure: { currencies: [], unvaluedCategories: [] } }), fetchedAt: now }); + insert('TbankOperation', { ...scope, brokerOperationId: 'durable-income', occurredAt: now, category: 'income', payload: JSON.stringify({ id: 'durable-income', payment: exact }) }); + insert('TbankOperationCoverage', { ...scope, from: now - 1000, to: now + 1000, fetchedAt: now, asOf: now }); + insert('TbankForecast', { userId: owner, revision, scopeKey: 'durable-forecast', membershipRevision: 1, payload: JSON.stringify({ incomeTotalsByCurrency: [exact] }), dependencyRevisions: JSON.stringify({ portfolio: 1 }), sourceTimestamps: JSON.stringify({ portfolio: new Date(now).toISOString() }), calculatedAt: now }); + insert('TbankInstrument', { provider: 'tbank', uid: 'durable-instrument', instrumentType: 'bond', metadata: JSON.stringify({ name: 'Synthetic' }), fetchedAt: now }); + insert('TbankCatalogDiscovery', { provider: 'tbank', instrumentType: 'bond', query: 'SYN', instrumentUids: JSON.stringify(['durable-instrument']), fetchedAt: now }); + insert('TbankInstrumentLookup', { provider: 'tbank', identifierType: 'ticker', identifier: 'SYN', matchedUids: JSON.stringify(['durable-instrument']), fetchedAt: now }); + insert('TbankInstrumentSource', { provider: 'tbank', instrumentUid: 'durable-instrument', source: 'tbank', kind: 'coupons', rangeKey: 'durable', payload: JSON.stringify({ events: [] }), fetchedAt: now }); + insert('TbankCandle', { provider: 'tbank', instrumentUid: 'durable-instrument', interval: 'day', time: now, close: exact.value, payload: JSON.stringify({ close: exact.value }), isComplete: 1, fetchedAt: now }); + insert('TbankCandleCoverage', { provider: 'tbank', instrumentUid: 'durable-instrument', interval: 'day', from: now - 1000, to: now + 1000, isComplete: 1, fetchedAt: now }); + insert('TbankSyncWork', { id: 'durable-work', userId: owner, revision, scopeKey: 'durable-intent', intent: JSON.stringify({ from: now - 1000, to: now + 1000 }), state: 'running', attemptId: 'stopped-attempt', processRunId: 'stopped-process', priority: 0, progress: JSON.stringify({ completed: 1 }), createdAt: now, updatedAt: now }); + insert('TbankSyncStage', { workId: 'durable-work', attemptId: 'stopped-attempt', sourceKey: 'durable', itemKey: 'next-row', payload: JSON.stringify({ payment: exact }) }); + })(); + db.close(); } function tbankSnapshot() { - const db = new Database(file,{readonly:true}); - db.defaultSafeIntegers(true); - try { - assert.equal(db.pragma('integrity_check',{simple:true}),'ok'); - assert.deepEqual(db.pragma('foreign_key_check'),[]); - return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND (name LIKE 'Tbank%' OR name='_prisma_migrations') ORDER BY name").all().map(({name})=>[name,db.prepare(`SELECT * FROM "${name}" ORDER BY rowid`).all()])); - } finally {db.close();} + const db = new Database(file, { readonly: true }); + db.defaultSafeIntegers(true); + try { + assert.equal(db.pragma('integrity_check', { simple: true }), 'ok'); + assert.deepEqual(db.pragma('foreign_key_check'), []); + return Object.fromEntries(db.prepare('SELECT name FROM sqlite_master WHERE type=\'table\' AND (name LIKE \'Tbank%\' OR name=\'_prisma_migrations\') ORDER BY name').all().map(({ name }) => [name, db.prepare(`SELECT * FROM "${name}" ORDER BY rowid`).all()])); + } finally { + db.close(); + } } -(async()=>{ - try{ - migrate(baseline); await start(baseline); - const auth=await http('auth/register','POST',credentials); const owner=auth.user.id; - await stop(); - const db=new Database(file);db.prepare('INSERT INTO TbankConnection (userId,encryptedToken,connectedAt) VALUES (?,?,?)').run(owner,'fictional-encrypted-token-never-used',new Date().toISOString());await db.backup(join(dir,'before-migration.db'));db.close(); - const beforeUsers=rows('User'), beforeSessions=rows('Session'); - migrate(repo); - assert.deepEqual(rows('User'),beforeUsers); assert.deepEqual(rows('Session'),beforeSessions); const migratedDb=new Database(file,{readonly:true});assert.equal(migratedDb.prepare('SELECT encryptedToken FROM TbankConnection WHERE userId=?').get(owner).encryptedToken,'fictional-encrypted-token-never-used');migratedDb.close(); - await start(repo); let token=(await http('auth/login','POST',credentials)).accessToken; - const intent={id:randomUUID(),name:'Долговременная',openingDate:'2026-01-01',openingBalance:rub('1000')}; - let card=await http('bank-cards','POST',intent,token,201); - const category=(await http('bank-cards/categories','GET',null,token)).find(x=>x.isDefault); - await http(`bank-cards/categories/${category.id}`,'PATCH',{name:'Сохранённая категория',version:category.version},token); - const expenseIntent={id:randomUUID(),kind:'expense',cardId:card.id,amount:rub('100'),date:'2026-08-01'}; - let expense=await http('bank-cards/operations','POST',expenseIntent,token,201); - const refundIntent={id:randomUUID(),kind:'refund',cardId:card.id,originalExpenseId:expense.id,amount:rub('100'),date:'2026-09-01'}; - const refund=await http('bank-cards/operations','POST',refundIntent,token,201); - const deletedIntent={id:randomUUID(),kind:'topup',cardId:card.id,amount:rub('12.34'),date:'2026-09-01'}; - const deleted=await http('bank-cards/operations','POST',deletedIntent,token,201); - await http(`bank-cards/operations/${deleted.id}?version=${deleted.version}`,'DELETE',null,token,204); - card=await http(`bank-cards/${card.id}`,'GET',null,token); - card=await http(`bank-cards/${card.id}`,'PATCH',{version:card.version,archived:true},token); - seedTbank(owner); const tbankBefore=tbankSnapshot(); - const snapshots={cards:rows('BankCard'),operations:rows('BankCardOperation'),categories:rows('ExpenseCategory')}; - await stop(); await start(repo);token=(await http('auth/login','POST',credentials)).accessToken; - assert.deepEqual(await http('bank-cards','POST',intent,token,201),card); - assert.deepEqual(await http('bank-cards/operations','POST',refundIntent,token,201),refund); - await http('bank-cards/operations','POST',deletedIntent,token,410); - assert.deepEqual(await http(`bank-cards/${card.id}`,'GET',null,token),card); - await stop(); - assert.deepEqual(tbankSnapshot(),tbankBefore);assert.deepEqual(rows('BankCard'),snapshots.cards);assert.deepEqual(rows('BankCardOperation'),snapshots.operations);assert.deepEqual(rows('ExpenseCategory'),snapshots.categories); - const latest=new Database(file);await latest.backup(join(dir,'after-history.db'));latest.close(); - await start(baseline);token=(await http('auth/login','POST',credentials)).accessToken;assert.equal((await http('auth/me','GET',null,token)).id,owner);await stop(); - assert.deepEqual(tbankSnapshot(),tbankBefore);assert.deepEqual(rows('BankCard'),snapshots.cards);assert.deepEqual(rows('BankCardOperation'),snapshots.operations);assert.deepEqual(rows('ExpenseCategory'),snapshots.categories); - await start(repo);token=(await http('auth/login','POST',credentials)).accessToken;assert.deepEqual(await http(`bank-cards/${card.id}`,'GET',null,token),card);await stop(); - const restored=join(dir,'restored.db');const backup=new Database(join(dir,'after-history.db'),{readonly:true});await backup.backup(restored);backup.close();file=restored; - assert.deepEqual(tbankSnapshot(),tbankBefore);assert.deepEqual(rows('BankCard'),snapshots.cards);assert.deepEqual(rows('BankCardOperation'),snapshots.operations);assert.deepEqual(rows('ExpenseCategory'),snapshots.categories); - assert.deepEqual(rows('User'),beforeUsers);assert.ok(rows('Session').some(s=>s.id===beforeSessions[0].id)); - assert.deepEqual(tbankSnapshot(),tbankBefore); - const restoredDb=new Database(file,{readonly:true});assert.equal(restoredDb.prepare('SELECT encryptedToken FROM TbankConnection WHERE userId=?').get(owner).encryptedToken,'fictional-encrypted-token-never-used');assert.equal(restoredDb.pragma('integrity_check',{simple:true}),'ok');restoredDb.close(); - await start(repo);token=(await http('auth/login','POST',credentials)).accessToken;assert.deepEqual(await http(`bank-cards/${card.id}`,'GET',null,token),card);await stop(); - writeFileSync(join(dir,'result.json'),JSON.stringify({result:'PASS',baseline,dir,checks:['all populated T-Bank datasets, staging, progress and migration state survive restart, rollback and isolated restore','foreign key and integrity checks after each boundary','baseline migration and auth','additive migration preserves User Session TbankConnection','restart preserves archive categories refund links tombstones fingerprints','same-intent replay after restart','previous application auth with additive schema','new application returns preserved history after rollback','SQLite backup and restored application reads history']},null,2)); - console.log(`PASS durability ${dir}`); - }finally{await stop();} -})().catch(error=>{console.error(error);process.exitCode=1;}); +(async () => { + try { + migrate(baseline); + await start(baseline); + const auth = await http('auth/register', 'POST', credentials); + const owner = auth.user.id; + await stop(); + const db = new Database(file); + db.prepare('INSERT INTO TbankConnection (userId,encryptedToken,connectedAt) VALUES (?,?,?)').run(owner, 'fictional-encrypted-token-never-used', new Date().toISOString()); + await db.backup(join(dir, 'before-migration.db')); + db.close(); + const beforeSessions = rows('Session'), beforeUsers = rows('User'); + migrate(repo); + assert.deepEqual(rows('User'), beforeUsers); + assert.deepEqual(rows('Session'), beforeSessions); + const migratedDb = new Database(file, { readonly: true }); + assert.equal(migratedDb.prepare('SELECT encryptedToken FROM TbankConnection WHERE userId=?').get(owner).encryptedToken, 'fictional-encrypted-token-never-used'); + migratedDb.close(); + await start(repo); + let token = (await http('auth/login', 'POST', credentials)).accessToken; + const intent = { id: randomUUID(), name: 'Долговременная', openingDate: '2026-01-01', openingBalance: rub('1000') }; + let card = await http('bank-cards', 'POST', intent, token, 201); + const category = (await http('bank-cards/categories', 'GET', null, token)).find((x) => x.isDefault); + await http(`bank-cards/categories/${category.id}`, 'PATCH', { name: 'Сохранённая категория', version: category.version }, token); + const expenseIntent = { id: randomUUID(), kind: 'expense', cardId: card.id, amount: rub('100'), date: '2026-08-01' }; + let expense = await http('bank-cards/operations', 'POST', expenseIntent, token, 201); + const refundIntent = { id: randomUUID(), kind: 'refund', cardId: card.id, originalExpenseId: expense.id, amount: rub('100'), date: '2026-09-01' }; + const refund = await http('bank-cards/operations', 'POST', refundIntent, token, 201); + const deletedIntent = { id: randomUUID(), kind: 'topup', cardId: card.id, amount: rub('12.34'), date: '2026-09-01' }; + const deleted = await http('bank-cards/operations', 'POST', deletedIntent, token, 201); + await http(`bank-cards/operations/${deleted.id}?version=${deleted.version}`, 'DELETE', null, token, 204); + card = await http(`bank-cards/${card.id}`, 'GET', null, token); + card = await http(`bank-cards/${card.id}`, 'PATCH', { version: card.version, archived: true }, token); + seedTbank(owner); + const tbankBefore = tbankSnapshot(); + const snapshots = { cards: rows('BankCard'), operations: rows('BankCardOperation'), categories: rows('ExpenseCategory') }; + await stop(); + await start(repo); + token = (await http('auth/login', 'POST', credentials)).accessToken; + assert.deepEqual(await http('bank-cards', 'POST', intent, token, 201), card); + assert.deepEqual(await http('bank-cards/operations', 'POST', refundIntent, token, 201), refund); + await http('bank-cards/operations', 'POST', deletedIntent, token, 410); + assert.deepEqual(await http(`bank-cards/${card.id}`, 'GET', null, token), card); + await stop(); + assert.deepEqual(tbankSnapshot(), tbankBefore); + assert.deepEqual(rows('BankCard'), snapshots.cards); + assert.deepEqual(rows('BankCardOperation'), snapshots.operations); + assert.deepEqual(rows('ExpenseCategory'), snapshots.categories); + const latest = new Database(file); + await latest.backup(join(dir, 'after-history.db')); + latest.close(); + await start(baseline); + token = (await http('auth/login', 'POST', credentials)).accessToken; + assert.equal((await http('auth/me', 'GET', null, token)).id, owner); + await stop(); + assert.deepEqual(tbankSnapshot(), tbankBefore); + assert.deepEqual(rows('BankCard'), snapshots.cards); + assert.deepEqual(rows('BankCardOperation'), snapshots.operations); + assert.deepEqual(rows('ExpenseCategory'), snapshots.categories); + await start(repo); + token = (await http('auth/login', 'POST', credentials)).accessToken; + assert.deepEqual(await http(`bank-cards/${card.id}`, 'GET', null, token), card); + await stop(); + const restored = join(dir, 'restored.db'); + const backup = new Database(join(dir, 'after-history.db'), { readonly: true }); + await backup.backup(restored); + backup.close(); + file = restored; + assert.deepEqual(tbankSnapshot(), tbankBefore); + assert.deepEqual(rows('BankCard'), snapshots.cards); + assert.deepEqual(rows('BankCardOperation'), snapshots.operations); + assert.deepEqual(rows('ExpenseCategory'), snapshots.categories); + assert.deepEqual(rows('User'), beforeUsers); + assert.ok(rows('Session').some((s) => s.id === beforeSessions[0].id)); + assert.deepEqual(tbankSnapshot(), tbankBefore); + const restoredDb = new Database(file, { readonly: true }); + assert.equal(restoredDb.prepare('SELECT encryptedToken FROM TbankConnection WHERE userId=?').get(owner).encryptedToken, 'fictional-encrypted-token-never-used'); + assert.equal(restoredDb.pragma('integrity_check', { simple: true }), 'ok'); + restoredDb.close(); + await start(repo); + token = (await http('auth/login', 'POST', credentials)).accessToken; + assert.deepEqual(await http(`bank-cards/${card.id}`, 'GET', null, token), card); + await stop(); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds. + writeFileSync(join(dir, 'result.json'), JSON.stringify({ result: 'PASS', baseline, dir, checks: ['all populated T-Bank datasets, staging, progress and migration state survive restart, rollback and isolated restore', 'foreign key and integrity checks after each boundary', 'baseline migration and auth', 'additive migration preserves User Session TbankConnection', 'restart preserves archive categories refund links tombstones fingerprints', 'same-intent replay after restart', 'previous application auth with additive schema', 'new application returns preserved history after rollback', 'SQLite backup and restored application reads history'] }, null, 2)); + console.log(`PASS durability ${dir}`); + } finally { + await stop(); + } +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/test/bank-cards.e2e-spec.ts b/test/bank-cards.e2e-spec.ts index 1585a47..cfa6de5 100644 --- a/test/bank-cards.e2e-spec.ts +++ b/test/bank-cards.e2e-spec.ts @@ -1,10 +1,12 @@ +import type { Server } from 'node:http'; + import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; import assert from 'node:assert/strict'; import { randomUUID } from 'node:crypto'; -import type { Server } from 'node:http'; import request from 'supertest'; + import { AppModule } from '../src/app.module'; import { configureApp } from '../src/main'; import { @@ -28,12 +30,12 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { ); const money = (value: string) => ({ currency: 'RUB', value }); const auth = ( - method: 'get' | 'post' | 'patch' | 'delete', + method: 'delete' | 'get' | 'patch' | 'post', path: string, user = owner, ) => - request(app.getHttpServer()) - [method]('/api/v1/bank-cards' + path) + // eslint-disable-next-line security/detect-object-injection -- Keys come from local test fixtures or numeric iteration, not external input. + request(app.getHttpServer())[method]('/api/v1/bank-cards' + path) .set('Authorization', `Bearer ${token(user)}`); const createCard = async (value = '1000', user = owner, extra = {}) => ( @@ -102,10 +104,11 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { if (app) await app.close(); }); it('requires auth and reads never seed data or need T-Bank', async () => { - for (const path of ['', '/categories', '/summary?month=2026-08']) + for (const path of ['', '/categories', '/summary?month=2026-08']) { await request(app.getHttpServer()) .get('/api/v1/bank-cards' + path) .expect(401); + } expect((await auth('get', '').expect(200)).body).toEqual([]); expect(await categories()).toEqual([]); expect(await db.expenseCategory.count({ where: { userId: owner } })).toBe( @@ -241,10 +244,11 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { money('92233720368547758.08'), { currency: 'USD', value: '1' }, money('NaN'), - ]) + ]) { await auth('post', '/operations') .send({ ...op(card.id), amount }) .expect(400); + } for (const extra of [ { date: '2026-02-30' }, { date: '2099-01-01' }, @@ -252,10 +256,11 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { { description: 'x'.repeat(501) }, { kind: 'topup', categoryId: (await categories())[0].id }, { kind: 'transfer', targetCardId: card.id }, - ]) + ]) { await auth('post', '/operations') .send(op(card.id, 'expense', '1', extra)) .expect(400); + } expect(await db.bankCardOperation.count()).toBe(before); for (const extra of [ { name: ' ' }, @@ -264,7 +269,7 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { { lastFour: '123' }, { openingDate: '2026-02-30' }, { openingBalance: money('-92233720368547758.09') }, - ]) + ]) { await auth('post', '') .send({ id: randomUUID(), @@ -274,6 +279,7 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { ...extra, }) .expect(400); + } }); it('returns nested money field errors without exposing input or writing a card', async () => { const before = await db.bankCard.count(); @@ -310,8 +316,8 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { }); it('scopes every card/category/expense link and edit to owner', async () => { - const own = await createCard(), - foreign = await createCard('0', stranger); + const foreign = await createCard('0', stranger), + own = await createCard(); const foreignCategories = ( await auth('get', '/categories', stranger).expect(200) ).body as ExpenseCategoryDto[]; @@ -326,15 +332,17 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { '/' + foreign.id + '/expenses', '/operations/' + foreignOp.id, '/summary?month=2026-08&cardId=' + foreign.id, - ]) + ]) { await auth('get', path).expect(404); + } for (const body of [ op(foreign.id), op(own.id, 'expense', '1', { categoryId: foreignCategories[0].id }), op(own.id, 'transfer', '1', { targetCardId: foreign.id }), op(own.id, 'refund', '1', { originalExpenseId: foreignOp.id }), - ]) + ]) { await auth('post', '/operations').send(body).expect(404); + } await auth('patch', '/' + foreign.id) .send({ version: 1, archived: true }) .expect(404); @@ -457,7 +465,7 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { }), ), ]); - expect(responses.map((r) => r.status).sort()).toEqual([201, 409]); + expect(responses.map((r) => r.status).sort((a, b) => a - b)).toEqual([201, 409]); const body = op(card.id, 'refund', '400', { date: '2026-09-01', originalExpenseId: expense.id, @@ -484,10 +492,11 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { { amount: money('999') }, { date: '2026-09-02' }, { cardId: (await createCard()).id }, - ]) + ]) { await auth('patch', '/operations/' + expense.id) .send({ version: current.version, ...patch }) .expect(409); + } await auth( 'delete', `/operations/${expense.id}?version=${current.version}`, @@ -501,6 +510,7 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { category.id, ); }); + // eslint-disable-next-line sonarjs/assertions-in-tests -- Every rejection is asserted through awaited Supertest .expect(400). it('rejects invalid refund references, dates, immutable kind and purchase link', async () => { const card = await createCard(), other = await createCard(); @@ -513,8 +523,9 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { originalExpenseId: purchase.id, date: '2026-07-01', }), - ]) + ]) { await auth('post', '/operations').send(body).expect(400); + } const refund = await createOp( op(card.id, 'refund', '10', { originalExpenseId: purchase.id }), ); @@ -532,7 +543,7 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { await createOp(op(a.id, 'expense', '20')); await createOp(op(a.id, 'expense', '30')); const pages: BankOperationsPageDto[] = []; - for (let page = 1; page <= 3; page++) + for (let page = 1; page <= 3; page++) { pages.push( ( await auth( @@ -541,6 +552,7 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { ).expect(200) ).body as BankOperationsPageDto, ); + } expect(new Set(pages.flatMap((p) => p.items.map((i) => i.id))).size).toBe( 3, ); @@ -564,8 +576,9 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { for (const body of [ op(card.id), op(card.id, 'transfer', '1', { targetCardId: (await createCard()).id }), - ]) + ]) { await auth('post', '/operations').send(body).expect(409); + } await auth('patch', '/operations/' + expense.id) .send({ version: 1, description: 'no' }) .expect(409); @@ -625,7 +638,7 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { }); expect( refunds.reduce((sum, row) => sum + row.amountKopecks, 0n) <= - source.amountKopecks, + source.amountKopecks, ).toBe(true); }); it('serializes opening-date changes and validates archived previous transfer sides', async () => { @@ -755,8 +768,9 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { it('filters journal and summary inclusively across months and rejects invalid periods', async () => { const card = await createCard(); const dates = ['2026-07-30', '2026-07-31', '2026-08-01', '2026-08-02']; - for (const date of dates) + for (const date of dates) { await createOp(op(card.id, 'expense', '10', { date })); + } const range = 'from=2026-07-31&to=2026-08-01'; const history = ( await auth('get', `/${card.id}/operations?${range}&limit=1`).expect(200) diff --git a/test/bonds.e2e-spec.ts b/test/bonds.e2e-spec.ts index c8b5d0d..8e53c35 100644 --- a/test/bonds.e2e-spec.ts +++ b/test/bonds.e2e-spec.ts @@ -1,3 +1,5 @@ +import type { Server } from 'node:http'; + import { ConflictException, INestApplication, @@ -6,13 +8,13 @@ import { } 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 { BondCatalogService } from '../src/modules/investments/instruments/bonds/application/bond-catalog.service'; import { BondIncomeService } from '../src/modules/investments/instruments/bonds/application/bond-income.service'; import { BondMarketService } from '../src/modules/investments/instruments/bonds/application/bond-market.service'; -import { BondCatalogService } from '../src/modules/investments/instruments/bonds/application/bond-catalog.service'; const uid = 'e6123145-9665-43e0-8413-cd61b8aa9b13'; describe('Bond HTTP contract', () => { diff --git a/test/container-smoke.cjs b/test/container-smoke.cjs index 6d0cb11..93efa14 100644 --- a/test/container-smoke.cjs +++ b/test/container-smoke.cjs @@ -6,6 +6,7 @@ const { randomUUID } = require('node:crypto'); const image = process.argv[2]; assert.ok(image, 'Pass a built backend image reference'); const name = `wealthledger-smoke-${randomUUID()}`; +// eslint-disable-next-line sonarjs/no-os-command-from-path -- Developer-only command uses the operator PATH and fixed executable/arguments; no request input. const docker = (...args) => execFileSync('docker', args, { encoding: 'utf8' }).trim(); const environment = [ '-e', 'DATABASE_URL=file:/data/wealthledger.db', @@ -64,6 +65,13 @@ async function ready() { `); console.log('PASS image: fresh/existing migration, non-root read-only runtime, auth, persistence, proto assets'); } finally { - try { docker('rm', '-f', name); } finally { docker('volume', 'rm', name); } + try { + docker('rm', '-f', name); + } finally { + docker('volume', 'rm', name); + } } -})().catch((error) => { console.error(error); process.exitCode = 1; }); +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/test/e2e-env.cjs b/test/e2e-env.cjs index 1c76d9d..d55c3dc 100644 --- a/test/e2e-env.cjs +++ b/test/e2e-env.cjs @@ -1,5 +1,5 @@ -const { join, dirname, basename } = require('node:path'); const { tmpdir } = require('node:os'); +const { join, dirname, basename } = require('node:path'); const dir = process.env.WL_E2E_DIRECTORY; if ( !dir || diff --git a/test/eslint-config.cjs b/test/eslint-config.cjs new file mode 100644 index 0000000..48446da --- /dev/null +++ b/test/eslint-config.cjs @@ -0,0 +1,47 @@ +const { ESLint } = require('eslint'); +const assert = require('node:assert/strict'); +const { test } = require('node:test'); + +const eslint = new ESLint(); + +// Protect the quality gate's file scopes when configs or plugin versions change. +test('typed application code enforces async and unsafe-type checks', async () => { + const [result] = await eslint.lintText( + 'export const unsafe: any = 1; Promise.resolve(unsafe);', + { filePath: 'src/main.ts' }, + ); + const rules = new Set(result.messages.map(({ ruleId }) => ruleId)); + assert.ok(rules.has('@typescript-eslint/no-explicit-any')); + assert.ok(rules.has('@typescript-eslint/no-floating-promises')); +}); + +test('framework and runtime rules reach their intended files', async () => { + const app = await eslint.calculateConfigForFile('src/main.ts'); + const spec = await eslint.calculateConfigForFile('test/app.e2e-spec.ts'); + const script = await eslint.calculateConfigForFile('scripts/generate-openapi.cjs'); + const config = await eslint.calculateConfigForFile('eslint.config.mjs'); + assert.equal(app.rules['@darraghor/nestjs-typed/validation-pipe-should-use-forbid-unknown'][0], 2); + assert.equal(spec.rules['jest/no-focused-tests'][0], 2); + assert.equal(app.rules['jest/no-focused-tests'], undefined); + assert.equal(app.languageOptions.globals.expect, undefined); + assert.equal(config.languageOptions.globals.require, 'off'); + for (const profile of [app, spec, script, config]) { + assert.equal(profile.rules['n/no-deprecated-api'][0], 2); + assert.equal(profile.rules['security/detect-eval-with-expression'][0], 1); + assert.equal(profile.rules['regexp/no-super-linear-backtracking'][0], 2); + assert.equal(profile.rules['sonarjs/cognitive-complexity'][0], 2); + assert.equal(profile.rules['import-x/no-unresolved'][0], 2); + assert.equal(profile.rules['perfectionist/sort-imports'][0], 2); + assert.equal(profile.rules['@stylistic/semi'][0], 2); + } +}); + +test('focused tests are rejected and generated code is excluded', async () => { + const [result] = await eslint.lintText( + 'it.only(\'example\', () => { expect(1).toBe(1); });', + { filePath: 'test/app.e2e-spec.ts' }, + ); + assert.ok(result.messages.some(({ ruleId }) => ruleId === 'jest/no-focused-tests')); + assert.ok(await eslint.isPathIgnored('generated/prisma/client.ts')); + assert.ok(await eslint.isPathIgnored('dist/src/main.js')); +}); diff --git a/test/fixtures/tbank-sync-crash.cjs b/test/fixtures/tbank-sync-crash.cjs index 8e13110..463615f 100644 --- a/test/fixtures/tbank-sync-crash.cjs +++ b/test/fixtures/tbank-sync-crash.cjs @@ -1,19 +1,20 @@ // This process is deliberately killed by the SQLite suite at durable boundaries. require('../e2e-env.cjs'); const { ConfigService } = require('@nestjs/config'); -const { PrismaService } = require('../../dist/src/prisma/prisma.service'); -const { - TbankTokenService, -} = require('../../dist/src/integrations/tbank/tbank-token.service'); + const { TbankAccessService, } = require('../../dist/src/integrations/tbank/tbank-access.service'); const { - BrokerageOperationsRepository, -} = require('../../dist/src/modules/investments/operations/infrastructure/brokerage-operations.repository'); + TbankTokenService, +} = require('../../dist/src/integrations/tbank/tbank-token.service'); const { BrokerageOperationsSyncService, } = require('../../dist/src/modules/investments/operations/application/brokerage-operations-sync.service'); +const { + BrokerageOperationsRepository, +} = require('../../dist/src/modules/investments/operations/infrastructure/brokerage-operations.repository'); +const { PrismaService } = require('../../dist/src/prisma/prisma.service'); const mode = process.argv[2]; const prisma = new PrismaService(new ConfigService(process.env)); const repository = new BrokerageOperationsRepository(prisma); diff --git a/test/openapi-contract.e2e-spec.ts b/test/openapi-contract.e2e-spec.ts index 2df4936..6034b08 100644 --- a/test/openapi-contract.e2e-spec.ts +++ b/test/openapi-contract.e2e-spec.ts @@ -5,6 +5,7 @@ import { execFileSync } from 'node:child_process'; import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { resolve } from 'node:path'; + import { AppModule } from '../src/app.module'; import { configureApp } from '../src/main'; import { createOpenApiDocument } from '../src/openapi'; @@ -56,6 +57,7 @@ describe('OpenAPI contract (integration)', () => { ['IncomeInvestmentScreenDto', 'income'], ['EventsInvestmentScreenDto', 'events'], ]) { + // eslint-disable-next-line security/detect-object-injection -- Keys come from local test fixtures or numeric iteration, not external input. const schema = schemas?.[model]; assert(schema && !('$ref' in schema)); expect(schema.required).toContain('screen'); @@ -84,7 +86,7 @@ describe('OpenAPI contract (integration)', () => { const parameters = document.paths[`/api/v1/brokerage-accounts/${scope}/${resource}`].get ?.parameters; - for (const name of ['from', 'to']) + for (const name of ['from', 'to']) { expect( parameters?.find( (parameter) => 'name' in parameter && parameter.name === name, @@ -94,6 +96,7 @@ describe('OpenAPI contract (integration)', () => { in: 'query', schema: { format }, }); + } } } }); @@ -147,6 +150,7 @@ describe('OpenAPI contract (integration)', () => { const directory = await mkdtemp(resolve(tmpdir(), 'wealthledger-openapi-')); try { const networkGuard = resolve(directory, 'no-network.cjs'); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds. await writeFile( networkGuard, ` @@ -170,6 +174,7 @@ describe('OpenAPI contract (integration)', () => { }, ); const generated = JSON.parse( + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds. await readFile(resolve(directory, 'openapi.json'), 'utf8'), ) as unknown; expect(generated).toEqual(createOpenApiDocument(app)); diff --git a/test/portfolio-structure.e2e-spec.ts b/test/portfolio-structure.e2e-spec.ts index c95bd8f..68a1c16 100644 --- a/test/portfolio-structure.e2e-spec.ts +++ b/test/portfolio-structure.e2e-spec.ts @@ -1,13 +1,15 @@ +import type { Server } from 'node:http'; + 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 { PrismaService } from '../src/prisma/prisma.service'; import { setTimeout as delay } from 'node:timers/promises'; +import request from 'supertest'; + +import { AppModule } from '../src/app.module'; import { TbankClient } from '../src/integrations/tbank/tbank.client'; import { configureApp } from '../src/main'; +import { PrismaService } from '../src/prisma/prisma.service'; describe('Portfolio structure HTTP contract', () => { let app: INestApplication; @@ -72,8 +74,9 @@ describe('Portfolio structure HTTP contract', () => { !(await app .get(PrismaService) .tbankSyncWork.count({ where: { state: 'running' } })) - ) + ) { return; + } await delay(5); } throw new Error('Portfolio did not settle'); diff --git a/test/sqlite-regression.cjs b/test/sqlite-regression.cjs index fb7f43c..e6e0c96 100644 --- a/test/sqlite-regression.cjs +++ b/test/sqlite-regression.cjs @@ -12,7 +12,9 @@ const { join, resolve } = require('node:path'); const cwd = resolve(__dirname, '..'); const evidence = mkdtempSync(join(tmpdir(), 'wealthledger-sqlite-regression-')); const sentinel = join(evidence, 'user.db'); +// eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds. writeFileSync(sentinel, 'fictional user database: must never be opened'); +// eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds. const before = readFileSync(sentinel); const databases = new Set(); async function check(name, overrides = {}, signal) { @@ -43,6 +45,7 @@ async function check(name, overrides = {}, signal) { child.once('error', reject); child.once('close', resolve); }); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds. writeFileSync(join(evidence, `${name}.log`), log); const dirs = [...log.matchAll(/^SQLite .+: (.+)$/gm)].map( (match) => match[1], @@ -51,21 +54,23 @@ async function check(name, overrides = {}, signal) { for (const dir of dirs) { assert.ok(!databases.has(dir), 'runners must never share databases'); databases.add(dir); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds. assert.ok(!existsSync(dir), `runner must clean up ${dir}`); } assert.deepEqual( + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds. readFileSync(sentinel), before, 'user database must be untouched', ); const expected = - signal === 'SIGINT' - ? 130 - : signal === 'SIGTERM' - ? 143 - : overrides.PRISMA_SCHEMA_ENGINE_BINARY - ? 1 - : 0; + signal === 'SIGINT' ? + 130 : + signal === 'SIGTERM' ? + 143 : + overrides.PRISMA_SCHEMA_ENGINE_BINARY ? + 1 : + 0; assert.equal(code, expected, `See ${name}.log`); console.log(`PASS ${name}: ${dirs.length} isolated databases`); } @@ -76,8 +81,9 @@ async function check(name, overrides = {}, signal) { check('parallel-1'), check('parallel-2', { RUST_LOG: 'error' }), ]); - for (const result of parallel) + for (const result of parallel) { if (result.status === 'rejected') throw result.reason; + } await check('migration-failure', { PRISMA_SCHEMA_ENGINE_BINARY: join(evidence, 'missing-engine'), }); diff --git a/test/sqlite.cjs b/test/sqlite.cjs index 74357e6..4bcedfc 100644 --- a/test/sqlite.cjs +++ b/test/sqlite.cjs @@ -9,13 +9,14 @@ const available = readdirSync(__dirname) .sort(); const requested = process.argv.slice(2); const suites = - requested.length === 0 - ? ['app.e2e-spec.ts', 'bank-cards.e2e-spec.ts'] - : requested.length === 1 && requested[0] === '--all' - ? available - : requested; -if (suites.some((suite) => !available.includes(suite))) + requested.length === 0 ? + ['app.e2e-spec.ts', 'bank-cards.e2e-spec.ts'] : + requested.length === 1 && requested[0] === '--all' ? + available : + requested; +if (suites.some((suite) => !available.includes(suite))) { throw new Error('Unknown e2e suite'); +} let child; let interrupted; let killTimer; @@ -46,9 +47,9 @@ function run(args, env) { child.once('close', (code, signal) => { clearTimeout(killTimer); child = undefined; - if (code !== 0 || signal) + if (code !== 0 || signal) { reject(new Error(`Child failed: ${signal ?? code}`)); - else resolve(); + } else resolve(); }); }); } diff --git a/test/stocks.e2e-spec.ts b/test/stocks.e2e-spec.ts index 6e04520..6bd6e54 100644 --- a/test/stocks.e2e-spec.ts +++ b/test/stocks.e2e-spec.ts @@ -1,3 +1,5 @@ +import type { Server } from 'node:http'; + import { ConflictException, INestApplication, @@ -6,13 +8,13 @@ import { } 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 { StockCatalogService } from '../src/modules/investments/instruments/stocks/application/stock-catalog.service'; import { StockIncomeService } from '../src/modules/investments/instruments/stocks/application/stock-income.service'; import { StockMarketService } from '../src/modules/investments/instruments/stocks/application/stock-market.service'; -import { StockCatalogService } from '../src/modules/investments/instruments/stocks/application/stock-catalog.service'; const uid = 'e6123145-9665-43e0-8413-cd61b8aa9b13'; describe('Stock HTTP contract', () => { diff --git a/test/tbank-candles-persistence.e2e-spec.ts b/test/tbank-candles-persistence.e2e-spec.ts index 6b5740a..cbdfd51 100644 --- a/test/tbank-candles-persistence.e2e-spec.ts +++ b/test/tbank-candles-persistence.e2e-spec.ts @@ -1,14 +1,15 @@ +import { ConfigService } from '@nestjs/config'; import { setTimeout as delay } from 'node:timers/promises'; -import { CandleService } from '../src/modules/investments/instruments/application/candle.service'; + import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service'; import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; -import { ConfigService } from '@nestjs/config'; -import { PrismaService } from '../src/prisma/prisma.service'; -import { CandleRepository } from '../src/modules/investments/instruments/infrastructure/candle.repository'; +import { CandleService } from '../src/modules/investments/instruments/application/candle.service'; import { candleGaps, type CandleRange, } from '../src/modules/investments/instruments/domain/candle-coverage'; +import { CandleRepository } from '../src/modules/investments/instruments/infrastructure/candle.repository'; +import { PrismaService } from '../src/prisma/prisma.service'; describe('atomic shared candle reconciliation (SQLite)', () => { const prisma = new PrismaService( @@ -56,8 +57,9 @@ describe('atomic shared candle reconciliation (SQLite)', () => { ); async function settled() { for (let i = 0; i < 500; i++) { - if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))) + if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))) { return; + } await delay(5); } throw new Error('Candle work did not settle'); @@ -225,10 +227,11 @@ describe('atomic shared candle reconciliation (SQLite)', () => { string, Date, Date, - ][]) + ][]) { expect(call[3].valueOf() - call[2].valueOf()).toBeLessThanOrEqual( 365 * 86400000, ); + } await prisma.$disconnect(); const reopened = createService(); expect((await reopened.read('owner', input)).data?.candles).toEqual([]); @@ -353,13 +356,13 @@ describe('atomic shared candle reconciliation (SQLite)', () => { ...sources, ensureDetail: jest.fn().mockResolvedValue(history), }; - if (phase === 'before loading') + if (phase === 'before loading') { selectedSources.ensureDetail.mockImplementationOnce(async () => { entered(); await blocked; return history; }); - else { + } else { const publish = repository.publish.bind(repository); jest .spyOn(repository, 'publish') @@ -377,8 +380,9 @@ describe('atomic shared candle reconciliation (SQLite)', () => { Awaited> | undefined; upstream.getCandles.mockImplementation( async (_token, _uid, _from, to: Date) => { - if (laterFailure && forcedStates.length) + if (laterFailure && forcedStates.length) { throw new Error('Forced reconciliation second window unavailable'); + } if (to.valueOf() === forcedAt.valueOf()) { beforeForced = await repository.read('uid', fullRange); const work = await repository.work( @@ -424,12 +428,16 @@ describe('atomic shared candle reconciliation (SQLite)', () => { await settled(); if (laterFailure) { + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. expect(beforeForced).toBeDefined(); + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. expect(await repository.read('uid', fullRange)).toEqual(beforeForced); - } else + } else { + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. expect((await repository.read('uid', covered)).data).toEqual([ { ...old, close: '200.000000001' }, ]); + } expect(forcedStates).toEqual(['running']); expect( await repository.work(userId, 'revision', 'candles:uid:all'), @@ -442,7 +450,9 @@ describe('atomic shared candle reconciliation (SQLite)', () => { now: forcedAt.toISOString(), }, }); + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. if (userId === owner.userId) expect(forcedId).toBe(ordinaryId); + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. else expect(forcedId).not.toBe(ordinaryId); }, ); @@ -495,8 +505,9 @@ describe('atomic shared candle reconciliation (SQLite)', () => { { instrumentUid: 'not-supported', type: 'FUND' }, { instrumentUid: null, type: 'BOND' }, ]); - for (let i = 0; i < 100 && !upstream.getCandles.mock.calls.length; i++) + for (let i = 0; i < 100 && !upstream.getCandles.mock.calls.length; i++) { await delay(5); + } expect((upstream.getCandles.mock.calls as unknown[][])[0]?.[1]).toBe('uid'); await service.read('owner', { uid: 'opened', @@ -510,7 +521,7 @@ describe('atomic shared candle reconciliation (SQLite)', () => { ); expect( new Set(upstream.getCandles.mock.calls.map((call: unknown[]) => call[1])), - ).toEqual(new Set(['uid', 'opened'])); + ).toEqual(new Set(['opened', 'uid'])); const count = upstream.getCandles.mock.calls.length; await service.prefetch('owner', 'revision', [ { instrumentUid: 'uid', type: 'STOCK' }, diff --git a/test/tbank-catalog-discovery.e2e-spec.ts b/test/tbank-catalog-discovery.e2e-spec.ts index 0868b8e..d263717 100644 --- a/test/tbank-catalog-discovery.e2e-spec.ts +++ b/test/tbank-catalog-discovery.e2e-spec.ts @@ -1,7 +1,7 @@ -import assert from 'node:assert/strict'; import { ConfigService } from '@nestjs/config'; +import assert from 'node:assert/strict'; import { setTimeout as delay } from 'node:timers/promises'; -import { PrismaService } from '../src/prisma/prisma.service'; + import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service'; import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; import { CatalogDiscoveryService } from '../src/modules/investments/instruments/application/catalog-discovery.service'; @@ -10,6 +10,7 @@ import { type CatalogInstrument, type CatalogQuery, } from '../src/modules/investments/instruments/infrastructure/catalog-discovery.repository'; +import { PrismaService } from '../src/prisma/prisma.service'; // Real transactions prove query coverage, private work ownership and late-publication fences. describe('shared catalog discovery (SQLite)', () => { @@ -52,18 +53,20 @@ describe('shared catalog discovery (SQLite)', () => { }); async function settled() { for (let i = 0; i < 300; i++) { - if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))) + if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))) { return; + } await delay(5); } throw new Error('Catalog work did not settle'); } beforeAll(async () => { await prisma.$connect(); - for (const id of ['owner', 'other']) + for (const id of ['owner', 'other']) { await prisma.user.create({ data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' }, }); + } }); afterAll(async () => prisma.$disconnect()); beforeEach(async () => { @@ -199,7 +202,7 @@ describe('shared catalog discovery (SQLite)', () => { await service.read('other', query); await delay(10); await prisma.tbankConnection.delete({ where: { userId: 'owner' } }); - if (action === 'replace') + if (action === 'replace') { await prisma.tbankConnection.create({ data: { userId: 'owner', @@ -208,6 +211,7 @@ describe('shared catalog discovery (SQLite)', () => { connectedAt: new Date(), }, }); + } blocked.resolve([instrument]); await settled(); expect(await prisma.tbankCatalogDiscovery.count()).toBe(0); diff --git a/test/tbank-catalog-local.e2e-spec.ts b/test/tbank-catalog-local.e2e-spec.ts index 551ab71..611b42a 100644 --- a/test/tbank-catalog-local.e2e-spec.ts +++ b/test/tbank-catalog-local.e2e-spec.ts @@ -1,34 +1,33 @@ -import { MoexClient } from '../src/integrations/moex/moex.api'; -import { NcrRatingsClient } from '../src/integrations/ncr/ncr.api'; -import type { - StockCandlesResourceDto, - StockCompanyResourceDto, - StockQuoteResourceDto, -} from '../src/modules/investments/instruments/stocks/presentation/stock.dto'; -import type { - BondMetricsResourceDto, - BondQuoteResourceDto, -} from '../src/modules/investments/instruments/bonds/presentation/bond.dto'; -import { InstrumentMetadataService } from '../src/integrations/tbank/instrument-metadata.service'; -import assert from 'node:assert/strict'; +import type { Server } from 'node:http'; + import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; +import assert from 'node:assert/strict'; import { setTimeout as delay } from 'node:timers/promises'; -import type { Server } from 'node:http'; import request from 'supertest'; + import { AppModule } from '../src/app.module'; -import { configureApp } from '../src/main'; -import { PrismaService } from '../src/prisma/prisma.service'; -import { TbankClient } from '../src/integrations/tbank/tbank.client'; +import { MoexClient } from '../src/integrations/moex/moex.api'; +import { NcrRatingsClient } from '../src/integrations/ncr/ncr.api'; +import { InstrumentMetadataService } from '../src/integrations/tbank/instrument-metadata.service'; import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; +import { TbankClient } from '../src/integrations/tbank/tbank.client'; +import { configureApp } from '../src/main'; import { CatalogEnrichmentService } from '../src/modules/investments/instruments/application/catalog-enrichment.service'; +import { + type BondMetricsResourceDto, + type BondQuoteResourceDto, type BondSearchResourceDto, +} from '../src/modules/investments/instruments/bonds/presentation/bond.dto'; import { CatalogDiscoveryRepository } from '../src/modules/investments/instruments/infrastructure/catalog-discovery.repository'; -import type { - StockResourceDto, - StockSearchResourceDto, +import { + type StockCandlesResourceDto, + type StockCompanyResourceDto, + type StockQuoteResourceDto, + type StockResourceDto, + type StockSearchResourceDto, } from '../src/modules/investments/instruments/stocks/presentation/stock.dto'; -import type { BondSearchResourceDto } from '../src/modules/investments/instruments/bonds/presentation/bond.dto'; +import { PrismaService } from '../src/prisma/prisma.service'; // HTTP + disposable SQLite: missing/partial results, exact prices, shared source reuse and revision races. describe('local stock/bond search (SQLite HTTP)', () => { @@ -89,8 +88,9 @@ describe('local stock/bond search (SQLite HTTP)', () => { await delay(10); if ( !(await prisma.tbankSyncWork.count({ where: { state: 'running' } })) - ) + ) { return; + } } } throw new Error('Catalog sources did not settle'); @@ -109,10 +109,11 @@ describe('local stock/bond search (SQLite HTTP)', () => { await app.init(); await app.listen(0, '127.0.0.1'); prisma = app.get(PrismaService); - for (const id of ['owner', 'other']) + for (const id of ['owner', 'other']) { await prisma.user.create({ data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' }, }); + } }); beforeEach(async () => { await prisma.tbankConnection.deleteMany(); @@ -162,7 +163,7 @@ describe('local stock/bond search (SQLite HTTP)', () => { })), ), ); - for (const userId of ['owner', 'other']) + for (const userId of ['owner', 'other']) { await prisma.tbankConnection.create({ data: { userId, @@ -173,6 +174,7 @@ describe('local stock/bond search (SQLite HTTP)', () => { connectedAt: new Date(), }, }); + } }); afterAll(async () => app?.close()); @@ -187,8 +189,9 @@ describe('local stock/bond search (SQLite HTTP)', () => { expect(cold.data).toBeNull(); expect(cold.meta.availability).toBe('missing'); found.resolve([share]); - for (let i = 0; i < 100 && !upstream.getShare.mock.calls.length; i++) + for (let i = 0; i < 100 && !upstream.getShare.mock.calls.length; i++) { await delay(5); + } const partial = await read(); expect(partial.data?.[0].instrumentUid).toBe('share'); expect(partial.meta.availability).toBe('partial'); @@ -329,16 +332,18 @@ describe('local stock/bond search (SQLite HTTP)', () => { const detail = deferred(); upstream.getShare.mockReturnValueOnce(detail.promise); await read(false); - for (let i = 0; i < 100 && !upstream.getShare.mock.calls.length; i++) + for (let i = 0; i < 100 && !upstream.getShare.mock.calls.length; i++) { await delay(5); + } await get('stocks?query=OTHER', 'other').expect(200); for ( let i = 0; i < 100 && (await prisma.tbankSyncWork.count({ where: { userId: 'other' } })) < 3; i++ - ) + ) { await delay(5); + } detail.resolve({ ...share, currency: 'rub', @@ -364,10 +369,11 @@ describe('local stock/bond search (SQLite HTTP)', () => { const detail = deferred(); upstream.getShare.mockReturnValueOnce(detail.promise); await read(false); - for (let i = 0; i < 100 && !upstream.getShare.mock.calls.length; i++) + for (let i = 0; i < 100 && !upstream.getShare.mock.calls.length; i++) { await delay(5); + } await prisma.tbankConnection.delete({ where: { userId: 'owner' } }); - if (action === 'replace') + if (action === 'replace') { await prisma.tbankConnection.create({ data: { userId: 'owner', @@ -376,6 +382,7 @@ describe('local stock/bond search (SQLite HTTP)', () => { connectedAt: new Date(), }, }); + } detail.resolve(share); await settled(); expect( @@ -451,8 +458,9 @@ describe('local stock/bond search (SQLite HTTP)', () => { ]); upstream.getLastPrices.mockReturnValueOnce(prices.promise); await read(false); - for (let i = 0; i < 100 && !upstream.getLastPrices.mock.calls.length; i++) + for (let i = 0; i < 100 && !upstream.getLastPrices.mock.calls.length; i++) { await delay(5); + } const initiating = await prisma.tbankSyncWork.findFirstOrThrow({ where: { userId: 'owner', scopeKey: { contains: 'last-price' } }, orderBy: { createdAt: 'asc' }, @@ -575,8 +583,9 @@ describe('local stock/bond search (SQLite HTTP)', () => { let i = 0; i < 100 && !upstream.getAssetDescription.mock.calls.length; i++ - ) + ) { await delay(5); + } const partial = ( await get(`stocks/${uid}/company?sync=observe`).expect(200) ).body as StockCompanyResourceDto; @@ -628,8 +637,9 @@ describe('local stock/bond search (SQLite HTTP)', () => { const cold = (await get(`stocks/${uid}/quote`).expect(200)) .body as StockQuoteResourceDto; expect(cold.meta.availability).toBe('missing'); - for (let i = 0; i < 100 && !upstream.getLastPrices.mock.calls.length; i++) + for (let i = 0; i < 100 && !upstream.getLastPrices.mock.calls.length; i++) { await delay(5); + } const pending = (await get(`stocks/${uid}/quote?sync=observe`).expect(200)) .body as StockQuoteResourceDto; expect(pending.data?.price).toBeNull(); @@ -858,8 +868,9 @@ describe('local stock/bond search (SQLite HTTP)', () => { let i = 0; i < 100 && !upstream.getAssetDescription.mock.calls.length; i++ - ) + ) { await delay(5); + } await prisma.tbankConnection.delete({ where: { userId: 'owner' } }); company.resolve('Late company'); await settled(); diff --git a/test/tbank-connection.e2e-spec.ts b/test/tbank-connection.e2e-spec.ts index c23e12f..f85298d 100644 --- a/test/tbank-connection.e2e-spec.ts +++ b/test/tbank-connection.e2e-spec.ts @@ -1,14 +1,16 @@ +import type { Server } from 'node:http'; + import { INestApplication, NotFoundException } 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 { TbankAccessService } from '../src/integrations/tbank/tbank-access.service'; +import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; +import { TbankClient } from '../src/integrations/tbank/tbank.client'; import { configureApp } from '../src/main'; import { PrismaService } from '../src/prisma/prisma.service'; -import { TbankClient } from '../src/integrations/tbank/tbank.client'; -import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; -import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service'; describe('Persistent T-Bank connection boundary (SQLite/HTTP)', () => { let app: INestApplication; @@ -35,10 +37,11 @@ describe('Persistent T-Bank connection boundary (SQLite/HTTP)', () => { await app.init(); await app.listen(0, '127.0.0.1'); prisma = app.get(PrismaService); - for (const id of ['owner', 'other']) + for (const id of ['owner', 'other']) { await prisma.user.create({ data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' }, }); + } }); beforeEach(async () => { await prisma.tbankConnection.deleteMany(); @@ -57,7 +60,7 @@ describe('Persistent T-Bank connection boundary (SQLite/HTTP)', () => { .expect(401); expect(upstream.validateToken).not.toHaveBeenCalled(); const response = await put(' synthetic-token ').expect(200); - expect(Object.keys(response.body as object).sort()).toEqual([ + expect(Object.keys(response.body as object).sort((a, b) => a.localeCompare(b))).toEqual([ 'connected', 'connectedAt', ]); @@ -145,11 +148,12 @@ describe('Persistent T-Bank connection boundary (SQLite/HTTP)', () => { await expect( prisma.tbankOperation.create({ data: operation }), ).rejects.toMatchObject({ code: 'P2003' }); - for (let index = 0; index < 2; index += 1) + for (let index = 0; index < 2; index += 1) { await request(app.getHttpServer()) .delete('/api/v1/tbank-connection') .set('Authorization', `Bearer ${jwt('owner')}`) .expect(204); + } await expect( prisma.tbankOperation.create({ data: { ...operation, revision: replacement.revision }, diff --git a/test/tbank-income-local.e2e-spec.ts b/test/tbank-income-local.e2e-spec.ts index 8a2f71a..9c08e50 100644 --- a/test/tbank-income-local.e2e-spec.ts +++ b/test/tbank-income-local.e2e-spec.ts @@ -1,19 +1,21 @@ -import assert from 'node:assert/strict'; +import type { Server } from 'node:http'; + import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; -import type { Server } from 'node:http'; +import assert from 'node:assert/strict'; import request from 'supertest'; + import { AppModule } from '../src/app.module'; -import { configureApp } from '../src/main'; -import { PrismaService } from '../src/prisma/prisma.service'; import { TbankClient } from '../src/integrations/tbank/tbank.client'; -import { moscowDate } from '../src/modules/investments/investment-calendar'; +import { configureApp } from '../src/main'; import { IncomeAnalyticsResourceDto, IncomeSummaryResourceDto, } from '../src/modules/investments/income/income-analytics.dto'; +import { moscowDate } from '../src/modules/investments/investment-calendar'; import { BrokerageOperationsRepository } from '../src/modules/investments/operations/infrastructure/brokerage-operations.repository'; +import { PrismaService } from '../src/prisma/prisma.service'; // Financial proof uses real persisted rows/coverage and the configured HTTP boundary. describe('local income completeness and exact money (SQLite)', () => { @@ -102,7 +104,7 @@ describe('local income completeness and exact money (SQLite)', () => { ['same', '10', 1, 'rub', 'OPERATION_TYPE_COUPON'], ['negative', '-2', -500000000, 'rub', 'OPERATION_TYPE_DIVIDEND'], ['usd', '3', 0, 'usd', 'OPERATION_TYPE_COUPON'], - ] as const) + ] as const) { await prisma.tbankOperation.create({ data: { userId: 'income-owner', @@ -119,6 +121,7 @@ describe('local income completeness and exact money (SQLite)', () => { }, }, }); + } } }); it('returns exact selected sums without a false zero comparison; reopen and observe need no RPC', async () => { diff --git a/test/tbank-ledger-local.e2e-spec.ts b/test/tbank-ledger-local.e2e-spec.ts index e2fb2dd..00e0036 100644 --- a/test/tbank-ledger-local.e2e-spec.ts +++ b/test/tbank-ledger-local.e2e-spec.ts @@ -1,18 +1,21 @@ -import assert from 'node:assert/strict'; +import type { Server } from 'node:http'; + import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; +import assert from 'node:assert/strict'; import { setTimeout as delay } from 'node:timers/promises'; -import type { Server } from 'node:http'; import request from 'supertest'; + +import type { IncomeAnalyticsResourceDto } from '../src/modules/investments/income/income-analytics.dto'; + import { AppModule } from '../src/app.module'; -import { configureApp } from '../src/main'; -import { PrismaService } from '../src/prisma/prisma.service'; import { InstrumentMetadataRepository } from '../src/integrations/tbank/instrument-metadata.repository'; import { TbankClient } from '../src/integrations/tbank/tbank.client'; -import { BrokerageOperationsResourceDto } from '../src/modules/investments/operations/presentation/brokerage-operation.dto'; +import { configureApp } from '../src/main'; import { BrokerageOperationsRepository } from '../src/modules/investments/operations/infrastructure/brokerage-operations.repository'; -import type { IncomeAnalyticsResourceDto } from '../src/modules/investments/income/income-analytics.dto'; +import { BrokerageOperationsResourceDto } from '../src/modules/investments/operations/presentation/brokerage-operation.dto'; +import { PrismaService } from '../src/prisma/prisma.service'; const uid = '11111111-1111-4111-8111-111111111111'; const period = 'from=2025-01-01&to=2025-01-31'; @@ -48,10 +51,11 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { await app.init(); await app.listen(0, '127.0.0.1'); prisma = app.get(PrismaService); - for (const id of ['owner', 'other']) + for (const id of ['owner', 'other']) { await prisma.user.create({ data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' }, }); + } }); afterAll(async () => app?.close()); beforeEach(async () => { @@ -84,7 +88,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { fetchedAt: new Date(), }, }); - for (const id of ['1', '2', '3']) + for (const id of ['1', '2', '3']) { await prisma.tbankOperation.create({ data: { userId: 'owner', @@ -103,10 +107,11 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { }, }, }); + } } }); it('reads ordered pages with identical IDs/timestamps across accounts and no upstream RPC, including after reopen', async () => { - let cursor: string | null = null; + let cursor: null | string = null; const ids: string[] = []; do { const response = await get( @@ -186,7 +191,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { })), ).toEqual(expected); const items: { id: string; occurredAt: string }[] = []; - let cursor: string | null = null; + let cursor: null | string = null; do { const response = await request(app.getHttpServer()) .get( @@ -235,9 +240,9 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { data: { occurredAt: new Date(observedAt.valueOf() - 1000) }, }); const query = - periodKind === 'default' - ? '' - : `&to=${observedAt.toISOString().slice(0, 10)}${periodKind === 'today timestamp' ? 'T23:59:59.999Z' : ''}`; + periodKind === 'default' ? + '' : + `&to=${observedAt.toISOString().slice(0, 10)}${periodKind === 'today timestamp' ? 'T23:59:59.999Z' : ''}`; const page = (cursor?: string, observe = false) => request(app.getHttpServer()) .get( @@ -418,10 +423,12 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { expect(cold.meta.availability).toBe('missing'); await get('sync=observe', 'a').expect(200); expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1); + // eslint-disable-next-line sonarjs/no-extra-arguments -- The typed callback is assigned the promise resolver before invocation. unblock({ hasNext: false, items: [] }); for (let index = 0; index < 300; index += 1) { - if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))) + if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))) { break; + } await delay(5); } const count = upstream.getOperationsByCursor.mock.calls.length; diff --git a/test/tbank-local-reads.e2e-spec.ts b/test/tbank-local-reads.e2e-spec.ts index be20a87..3536b66 100644 --- a/test/tbank-local-reads.e2e-spec.ts +++ b/test/tbank-local-reads.e2e-spec.ts @@ -1,20 +1,22 @@ +import type { Server } from 'node:http'; + import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; import { setTimeout as delay } from 'node:timers/promises'; -import type { Server } from 'node:http'; import request from 'supertest'; + import { AppModule } from '../src/app.module'; -import { configureApp } from '../src/main'; -import { PrismaService } from '../src/prisma/prisma.service'; +import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; import { - TbankClient, type TbankAccount, + TbankClient, type TbankPortfolio, } from '../src/integrations/tbank/tbank.client'; -import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; -import { PortfolioResourceDto } from '../src/modules/investments/portfolio/presentation/portfolio.dto'; +import { configureApp } from '../src/main'; import { BrokerageAccountsResourceDto } from '../src/modules/investments/brokerage-accounts/brokerage-account.dto'; +import { PortfolioResourceDto } from '../src/modules/investments/portfolio/presentation/portfolio.dto'; +import { PrismaService } from '../src/prisma/prisma.service'; function deferred() { let resolve: (value: T) => void = () => { @@ -72,10 +74,11 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { await app.init(); await app.listen(0, '127.0.0.1'); prisma = app.get(PrismaService); - for (const id of ['owner', 'other']) + for (const id of ['owner', 'other']) { await prisma.user.create({ data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' }, }); + } }); beforeEach(async () => { await prisma.tbankConnection.deleteMany(); @@ -336,9 +339,9 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { ]); await put().expect(200); upstream.getPortfolio.mockImplementation((_token, id) => - id === 'second' - ? Promise.reject(new Error('synthetic unavailable')) - : Promise.resolve({ + id === 'second' ? + Promise.reject(new Error('synthetic unavailable')) : + Promise.resolve({ positions: [ { instrumentUid: uid, @@ -351,8 +354,9 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { ); await get('brokerage-accounts').expect(200); for (let index = 0; index < 200; index += 1) { - if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))) + if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))) { break; + } await delay(5); } const summaries = (await get('brokerage-accounts?sync=observe').expect(200)) diff --git a/test/tbank-metadata-persistence.e2e-spec.ts b/test/tbank-metadata-persistence.e2e-spec.ts index fba3938..ef509bf 100644 --- a/test/tbank-metadata-persistence.e2e-spec.ts +++ b/test/tbank-metadata-persistence.e2e-spec.ts @@ -1,15 +1,17 @@ -import assert from 'node:assert/strict'; import { ConfigService } from '@nestjs/config'; +import assert from 'node:assert/strict'; import { setTimeout as delay } from 'node:timers/promises'; -import { PrismaService } from '../src/prisma/prisma.service'; -import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service'; -import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; -import { InstrumentMetadataService } from '../src/integrations/tbank/instrument-metadata.service'; + +import type { TbankInstrument } from '../src/integrations/tbank/tbank.client'; + import { InstrumentMetadataRepository, metadataKey, } from '../src/integrations/tbank/instrument-metadata.repository'; -import type { TbankInstrument } from '../src/integrations/tbank/tbank.client'; +import { InstrumentMetadataService } from '../src/integrations/tbank/instrument-metadata.service'; +import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service'; +import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; +import { PrismaService } from '../src/prisma/prisma.service'; // Shared publication is proven against real FK/transaction/connection boundaries. describe('durable shared instrument metadata (SQLite)', () => { @@ -55,18 +57,20 @@ describe('durable shared instrument metadata (SQLite)', () => { }); async function settled() { for (let index = 0; index < 300; index++) { - if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))) + if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))) { return; + } await delay(5); } throw new Error('Metadata work did not settle'); } beforeAll(async () => { await prisma.$connect(); - for (const id of ['owner', 'other']) + for (const id of ['owner', 'other']) { await prisma.user.create({ data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' }, }); + } }); afterAll(async () => prisma.$disconnect()); beforeEach(async () => { @@ -152,7 +156,7 @@ describe('durable shared instrument metadata (SQLite)', () => { await delay(10); await service.read('other', identity); await prisma.tbankConnection.delete({ where: { userId: 'owner' } }); - if (action === 'replace') + if (action === 'replace') { await prisma.tbankConnection.create({ data: { userId: 'owner', @@ -161,6 +165,7 @@ describe('durable shared instrument metadata (SQLite)', () => { connectedAt: new Date(), }, }); + } blocked.resolve(known); await settled(); expect(await prisma.tbankInstrument.count()).toBe(0); @@ -346,9 +351,9 @@ describe('durable shared instrument metadata (SQLite)', () => { await service.read('owner', identity); await settled(); const identities = Array.from({ length: 1000 }, (_, index) => - index - ? { instrumentUid: `uid-${index}`, figi: `FIGI-${index}` } - : identity, + index ? + { instrumentUid: `uid-${index}`, figi: `FIGI-${index}` } : + identity, ); const saved = await service.savedMany(identities); expect(saved.size).toBe(1000); diff --git a/test/tbank-migration.e2e-spec.ts b/test/tbank-migration.e2e-spec.ts index db674e9..5f081dd 100644 --- a/test/tbank-migration.e2e-spec.ts +++ b/test/tbank-migration.e2e-spec.ts @@ -1,7 +1,9 @@ -import { readFileSync, readdirSync } from 'node:fs'; -import { join } from 'node:path'; -import { DatabaseSync } from 'node:sqlite'; import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +// eslint-disable-next-line n/no-unsupported-features/node-builtins -- The migration test intentionally uses node:sqlite from the pinned Node 24 runtime. +import { DatabaseSync } from 'node:sqlite'; + import { PrismaClient } from '../generated/prisma/client'; const migration = '20260924170000_persist_tbank_data'; @@ -25,8 +27,10 @@ describe('T-Bank additive migration on real SQLite', () => { db.exec('PRAGMA foreign_keys=ON'); for (const name of readdirSync(migrations) .filter((name) => name < migration && /^\d/.test(name)) - .sort()) + .sort((a, b) => a.localeCompare(b))) { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths are under an exclusive mkdtemp directory or the local migrations directory. db.exec(readFileSync(join(migrations, name, 'migration.sql'), 'utf8')); + } if (populated) { db.exec(` INSERT INTO User (id,email,passwordHash,createdAt,updatedAt) VALUES @@ -90,7 +94,7 @@ describe('T-Bank additive migration on real SQLite', () => { adapter: new PrismaBetterSqlite3({ url: `file:${file}` }), }); try { - if (!populated) + if (!populated) { await prisma.user.create({ data: { id: 'owner', @@ -104,6 +108,7 @@ describe('T-Bank additive migration on real SQLite', () => { }, }, }); + } const connection = await prisma.tbankConnection.findUniqueOrThrow({ where: { userId: 'owner' }, }); diff --git a/test/tbank-operations-persistence.e2e-spec.ts b/test/tbank-operations-persistence.e2e-spec.ts index 507b3b5..4beff9f 100644 --- a/test/tbank-operations-persistence.e2e-spec.ts +++ b/test/tbank-operations-persistence.e2e-spec.ts @@ -1,6 +1,7 @@ import { ConfigService } from '@nestjs/config'; -import { PrismaService } from '../src/prisma/prisma.service'; + import { BrokerageOperationsRepository } from '../src/modules/investments/operations/infrastructure/brokerage-operations.repository'; +import { PrismaService } from '../src/prisma/prisma.service'; // Real transactions are the boundary: staged pages never become financial // results, and a failed multi-account publication must preserve every old row. @@ -106,11 +107,12 @@ describe('Operations staging and reconciliation (isolated SQLite)', () => { } })(), ]); - for (const snapshot of snapshots) + for (const snapshot of snapshots) { expect([ ['10', '10'], ['20', '20'], ]).toContainEqual(snapshot); + } await reader.$disconnect(); await reader.$connect(); expect( diff --git a/test/tbank-operations-sync.e2e-spec.ts b/test/tbank-operations-sync.e2e-spec.ts index ac70f75..0937213 100644 --- a/test/tbank-operations-sync.e2e-spec.ts +++ b/test/tbank-operations-sync.e2e-spec.ts @@ -3,15 +3,17 @@ import { fork } from 'node:child_process'; import { once } from 'node:events'; import { join } from 'node:path'; import { setTimeout as delay } from 'node:timers/promises'; -import { PrismaService } from '../src/prisma/prisma.service'; -import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service'; + import type { TbankOperationsByCursorRequest, TbankOperationsByCursorResponse, } from '../src/integrations/tbank/tbank.client'; + +import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service'; import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; -import { BrokerageOperationsRepository } from '../src/modules/investments/operations/infrastructure/brokerage-operations.repository'; import { BrokerageOperationsSyncService } from '../src/modules/investments/operations/application/brokerage-operations-sync.service'; +import { BrokerageOperationsRepository } from '../src/modules/investments/operations/infrastructure/brokerage-operations.repository'; +import { PrismaService } from '../src/prisma/prisma.service'; function deferred() { let resolve: (value: T) => void = () => { @@ -154,11 +156,12 @@ describe('Request-driven operations synchronization (SQLite)', () => { upstream.getOperationsByCursor .mockReturnValueOnce(gate.promise) .mockImplementation((_token, request) => { - if (Number(request.from?.seconds) + 1 === march.from.valueOf() / 1000) + if (Number(request.from?.seconds) + 1 === march.from.valueOf() / 1000) { return Promise.resolve({ hasNext: false, items: [operation('20', '1773000000')], }); + } return Promise.reject(new Error('unrelated history unavailable')); }); const history = requiredHandle( @@ -332,8 +335,9 @@ describe('Request-driven operations synchronization (SQLite)', () => { if ( failFebruary && request.from?.seconds === String(february.from.valueOf() / 1000 - 1) - ) + ) { return Promise.reject(new Error('Second month unavailable')); + } return Promise.resolve({ hasNext: false, items: [ @@ -431,22 +435,29 @@ describe('Request-driven operations synchronization (SQLite)', () => { await delay(10); expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); if (checkpoint === 'after-publish') { + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. expect(await prisma.tbankOperation.count()).toBe(1); + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. expect(await prisma.tbankSyncStage.count()).toBe(0); } else { + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. expect(await prisma.tbankOperation.count()).toBe(0); + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. expect(await prisma.tbankSyncStage.count()).toBeGreaterThan(0); upstream.getOperationsByCursor.mockResolvedValueOnce(complete); await service.request('owner', [january], true); await state(handle, 'complete'); + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. expect( upstream.getOperationsByCursor.mock.calls[0][1].cursor, ).toBeUndefined(); + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. expect(await prisma.tbankOperation.count()).toBe(1); } } finally { - if (child.exitCode === null && child.signalCode === null) + if (child.exitCode === null && child.signalCode === null) { child.kill('SIGKILL'); + } await exited; } }, @@ -492,8 +503,9 @@ describe('Request-driven operations synchronization (SQLite)', () => { let index = 0; index < 500 && upstream.getOperationsByCursor.mock.calls.length < 2; index += 1 - ) + ) { await delay(2); + } expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(2); expect(upstream.getOperationsByCursor.mock.calls[1][1].from).toEqual({ seconds: String(early.to.valueOf() / 1000 - 1), @@ -507,8 +519,8 @@ describe('Request-driven operations synchronization (SQLite)', () => { it('lets new screen work overtake history between provider pages', async () => { const first = deferred<{ hasNext: boolean; - nextCursor: string; items: never[]; + nextCursor: string; }>(); upstream.getOperationsByCursor .mockReturnValueOnce(first.promise) @@ -542,8 +554,9 @@ describe('Request-driven operations synchronization (SQLite)', () => { let index = 0; index < 500 && upstream.getOperationsByCursor.mock.calls.length < 2; index += 1 - ) + ) { await delay(2); + } expect(await prisma.tbankOperation.count()).toBe(1); const before = await prisma.tbankOperationCoverage.findMany(); expect(before).toHaveLength(1); @@ -675,8 +688,9 @@ describe('Request-driven operations synchronization (SQLite)', () => { let index = 0; index < 500 && upstream.getOperationsByCursor.mock.calls.length < 2; index += 1 - ) + ) { await delay(2); + } expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(2); const before = await prisma.tbankSyncWork.findUniqueOrThrow({ where: { id: handle }, @@ -708,7 +722,7 @@ describe('Request-driven operations synchronization (SQLite)', () => { }); }); -function requiredHandle(value: string | null): string { +function requiredHandle(value: null | string): string { if (!value) throw new Error('Expected sync handle'); return value; } diff --git a/test/tbank-read-baseline.e2e-spec.ts b/test/tbank-read-baseline.e2e-spec.ts index 56cf046..5a16f08 100644 --- a/test/tbank-read-baseline.e2e-spec.ts +++ b/test/tbank-read-baseline.e2e-spec.ts @@ -1,23 +1,29 @@ +import type { NextFunction, Request, Response } from 'express'; +import type { Server } from 'node:http'; + import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; -import type { Server } from 'node:http'; -import { cpus, totalmem } from 'node:os'; -import { writeFileSync, statSync, readFileSync } from 'node:fs'; -import { setTimeout as delay } from 'node:timers/promises'; -import type { Request, Response, NextFunction } from 'express'; +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { cpus, tmpdir, totalmem } from 'node:os'; +import { join } from 'node:path'; import { performance } from 'node:perf_hooks'; +import { setTimeout as delay } from 'node:timers/promises'; import request from 'supertest'; + import { AppModule } from '../src/app.module'; +import { MoexClient } from '../src/integrations/moex/moex.api'; +import { NcrRatingsClient } from '../src/integrations/ncr/ncr.api'; +import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; import { TbankClient, TbankOperationsByCursorRequest, } from '../src/integrations/tbank/tbank.client'; -import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; import { configureApp } from '../src/main'; import { PrismaService } from '../src/prisma/prisma.service'; -import { MoexClient } from '../src/integrations/moex/moex.api'; -import { NcrRatingsClient } from '../src/integrations/ncr/ncr.api'; + +const evidenceDirectory = mkdtempSync(join(tmpdir(), 'wealthledger-read-evidence-')); // Same navigation and synthetic sources as the saved baseline; isolated SQLite only. describe('T-Bank persisted read budget', () => { @@ -52,9 +58,9 @@ describe('T-Bank persisted read budget', () => { const operations = Array.from({ length: 100_000 }, (_, index) => { // 1,000 measured-month rows, 200 income receipts, remaining rows over 10 years. const time = - index < 1000 - ? Date.UTC(2026, 7, 1) + index * 2_500_000 - : Date.UTC(2016, 8, 1) + (index - 1000) * 3_160_000; + index < 1000 ? + Date.UTC(2026, 7, 1) + index * 2_500_000 : + Date.UTC(2016, 8, 1) + (index - 1000) * 3_160_000; const instrument = instruments[index % instruments.length]; return { id: `operation-${index}`, @@ -69,15 +75,17 @@ describe('T-Bank persisted read budget', () => { }; }).sort((a, b) => Number(b.date.seconds) - Number(a.date.seconds)); const rpc: Record = {}; - let upstreamMode: 'ready' | 'delayed' | 'failed' = 'ready'; + let upstreamMode: 'delayed' | 'failed' | 'ready' = 'ready'; let sourceGate: Promise | undefined; let releaseSource = () => {}; const serverTimes: number[] = []; async function counted(name: string, value: T): Promise { + // eslint-disable-next-line security/detect-object-injection -- Keys come from local test fixtures or numeric iteration, not external input. rpc[name] = (rpc[name] ?? 0) + 1; if (upstreamMode === 'delayed') await sourceGate; - if (upstreamMode === 'failed') + if (upstreamMode === 'failed') { return Promise.reject(new Error('Synthetic outage')); + } return Promise.resolve(value); } const upstream = { @@ -261,7 +269,7 @@ describe('T-Bank persisted read budget', () => { historyBoundary: 'openedOn', }, }); - for (let offset = 0; offset < operations.length; offset += 1000) + for (let offset = 0; offset < operations.length; offset += 1000) { await prisma.tbankOperation.createMany({ data: operations.slice(offset, offset + 1000).map((row) => ({ ...scope, @@ -272,6 +280,7 @@ describe('T-Bank persisted read budget', () => { payload: row, })), }); + } await prisma.tbankOperationCoverage.create({ data: { ...scope, @@ -289,9 +298,9 @@ describe('T-Bank persisted read budget', () => { uid: instrument.uid, ticker: instrument.ticker, instrumentType: - instrument.instrumentKind === 'INSTRUMENT_TYPE_SHARE' - ? 'share' - : 'bond', + instrument.instrumentKind === 'INSTRUMENT_TYPE_SHARE' ? + 'share' : + 'bond', metadata: instrument, fetchedAt: now, }, @@ -329,8 +338,8 @@ describe('T-Bank persisted read budget', () => { ], }); } - expect(await prisma.tbankOperation.count()).toBe(300_000); - expect(await prisma.tbankCandle.count()).toBe(1_000_000); + assert.equal(await prisma.tbankOperation.count(), 300_000); + assert.equal(await prisma.tbankCandle.count(), 1_000_000); }, 300_000); afterAll(async () => app?.close()); @@ -368,8 +377,9 @@ describe('T-Bank persisted read budget', () => { !(await app .get(PrismaService) .tbankSyncWork.count({ where: { state: 'running' } })) - ) + ) { return; + } } throw new Error('Import did not settle'); } @@ -397,6 +407,7 @@ describe('T-Bank persisted read budget', () => { await fresh(); } const warmupRpc = { ...rpc }; + // eslint-disable-next-line security/detect-object-injection -- Keys come from local test fixtures or numeric iteration, not external input. for (const key of Object.keys(rpc)) rpc[key] = 0; await app.close(); await start(); @@ -411,21 +422,26 @@ describe('T-Bank persisted read budget', () => { .set('Authorization', `Bearer ${token}`) .expect(200); const body = response.body as { - meta: { availability: string }; data: unknown; + meta: { availability: string }; }; - if (route.includes('candles?period=all')) + if (route.includes('candles?period=all')) { + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. expect((body.data as { candles: unknown[] }).candles).toHaveLength( 5000, ); + } expect({ route, availability: body.meta.availability }).toEqual({ route, availability: 'complete', }); + // eslint-disable-next-line security/detect-object-injection -- Keys come from local test fixtures or numeric iteration, not external input. if (!responses[route]) { + // eslint-disable-next-line security/detect-object-injection -- Keys come from local test fixtures or numeric iteration, not external input. responses[route] = body; + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths are under an exclusive mkdtemp directory or the local migrations directory. writeFileSync( - '/tmp/wealthledger-browser-responses.json', + join(evidenceDirectory, 'browser-responses.json'), JSON.stringify(responses), ); } @@ -439,8 +455,9 @@ describe('T-Bank persisted read budget', () => { serverTimes.length = 0; const before = { ...rpc }; const samples = []; - for (let batch = 0; batch < 10; batch += 1) + for (let batch = 0; batch < 10; batch += 1) { samples.push(...(await Promise.all(Array.from({ length: 10 }, read)))); + } const clientTimes = samples .map((sample) => sample.ms) .sort((a, b) => a - b); @@ -459,6 +476,7 @@ describe('T-Bank persisted read budget', () => { rpc: Object.fromEntries( Object.entries(rpc).map(([key, count]) => [ key, + // eslint-disable-next-line security/detect-object-injection -- Keys come from local test fixtures or numeric iteration, not external input. count - (before[key] ?? 0), ]), ), @@ -469,13 +487,13 @@ describe('T-Bank persisted read budget', () => { runtime: process.version, hardware: { cpuQuota: - process.platform === 'linux' - ? readFileSync('/sys/fs/cgroup/cpu.max', 'utf8').trim() - : null, + process.platform === 'linux' ? + readFileSync('/sys/fs/cgroup/cpu.max', 'utf8').trim() : + null, memoryLimit: - process.platform === 'linux' - ? readFileSync('/sys/fs/cgroup/memory.max', 'utf8').trim() - : null, + process.platform === 'linux' ? + readFileSync('/sys/fs/cgroup/memory.max', 'utf8').trim() : + null, cpu: cpus()[0]?.model, cpus: cpus().length, memoryBytes: totalmem(), @@ -495,6 +513,7 @@ describe('T-Bank persisted read budget', () => { evidence, totalRpc: { ...rpc }, warmupRpc, + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds. databaseBytes: statSync( String(process.env.DATABASE_URL).replace('file:', ''), ).size, @@ -513,11 +532,12 @@ describe('T-Bank persisted read budget', () => { newRpc, reduction: 1 - newRpc / oldRpc, }; + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths are under an exclusive mkdtemp directory or the local migrations directory. writeFileSync( - '/tmp/wealthledger-persistent-budget.json', + join(evidenceDirectory, 'persistent-budget.json'), JSON.stringify(output, null, 2), ); - console.log(JSON.stringify(output)); + console.log(JSON.stringify({ ...output, evidenceDirectory })); // Hold the source until HTTP reads finish: proves independence without a // wall-clock race against the runner's throughput. The p95 gate stays below. await fresh(); @@ -572,8 +592,9 @@ describe('T-Bank persisted read budget', () => { staleLogicalPass: { portfolio: 1, positions: 1, retries: 0 }, coldImportMs, }); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths are under an exclusive mkdtemp directory or the local migrations directory. writeFileSync( - '/tmp/wealthledger-persistent-budget.json', + join(evidenceDirectory, 'persistent-budget.json'), JSON.stringify(output, null, 2), ); expect(newRpc).toBe(0); diff --git a/test/tbank-screen-sync.e2e-spec.ts b/test/tbank-screen-sync.e2e-spec.ts index 6c75d5b..f319d78 100644 --- a/test/tbank-screen-sync.e2e-spec.ts +++ b/test/tbank-screen-sync.e2e-spec.ts @@ -1,15 +1,17 @@ +import type { Server } from 'node:http'; + import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; import { setTimeout as delay } from 'node:timers/promises'; -import type { Server } from 'node:http'; import request from 'supertest'; + import { AppModule } from '../src/app.module'; -import { configureApp } from '../src/main'; -import { PrismaService } from '../src/prisma/prisma.service'; -import { TbankClient } from '../src/integrations/tbank/tbank.client'; import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; +import { TbankClient } from '../src/integrations/tbank/tbank.client'; +import { configureApp } from '../src/main'; import { TbankSyncStatusDto } from '../src/modules/investments/tbank-sync/tbank-sync.dto'; +import { PrismaService } from '../src/prisma/prisma.service'; describe('screen sync HTTP and SQLite', () => { let app: INestApplication; @@ -51,10 +53,11 @@ describe('screen sync HTTP and SQLite', () => { await app.init(); await app.listen(0, '127.0.0.1'); prisma = app.get(PrismaService); - for (const id of ['owner', 'other']) + for (const id of ['owner', 'other']) { await prisma.user.create({ data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' }, }); + } }); beforeEach(async () => { await prisma.tbankConnection.deleteMany(); @@ -62,7 +65,7 @@ describe('screen sync HTTP and SQLite', () => { upstream.getOpenAccounts.mockResolvedValue([]); upstream.getPortfolio.mockResolvedValue({ positions: [] }); upstream.getPositions.mockResolvedValue({ money: [] }); - for (const userId of ['owner', 'other']) + for (const userId of ['owner', 'other']) { await prisma.tbankConnection.create({ data: { userId, @@ -72,6 +75,7 @@ describe('screen sync HTTP and SQLite', () => { membershipRevision: 1, }, }); + } }); afterAll(async () => app.close()); it('accepts before blocked RPC finishes, joins identical requests, and observes without starting work', async () => { @@ -145,9 +149,9 @@ describe('screen sync HTTP and SQLite', () => { screen: 'stock', instrumentUid: '00000000-0000-4000-8000-000000000001', candleRange: { - from: '2026-02-01', - to: '2026-01-01', - jobs: [], + 'from': '2026-02-01', + 'to': '2026-01-01', + 'jobs': [], '~bad/path': 'SYNTHETIC_VALIDATION_SECRET', }, }, @@ -158,7 +162,7 @@ describe('screen sync HTTP and SQLite', () => { ); const problem = response.body as { code: string; - errors: { pointer: string; detail: string }[]; + errors: { detail: string; pointer: string }[]; }; expect(problem.code).toBe('VALIDATION_ERROR'); expect(problem.errors.length).toBeGreaterThan(0); @@ -167,15 +171,19 @@ describe('screen sync HTTP and SQLite', () => { expect(typeof error.detail).toBe('string'); } if ('candleRange' in scope) { + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. expect( problem.errors.filter( (error) => error.pointer === '#/candleRange/jobs', ), ).toHaveLength(1); + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. expect(problem.errors).toContainEqual({ pointer: '#/candleRange/~0bad~1path', + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. detail: expect.any(String) as unknown, }); + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. expect(JSON.stringify(response.body)).not.toContain( 'SYNTHETIC_VALIDATION_SECRET', ); @@ -350,9 +358,9 @@ describe('screen sync HTTP and SQLite', () => { }), ) .mockImplementation(() => - outcome === 'failed' - ? Promise.reject(new Error('Synthetic outage')) - : Promise.resolve({ hasNext: false, items: [] }), + outcome === 'failed' ? + Promise.reject(new Error('Synthetic outage')) : + Promise.resolve({ hasNext: false, items: [] }), ); const accept = async (from: string, to: string) => { const response = await post({ diff --git a/test/upcoming-events.e2e-spec.ts b/test/upcoming-events.e2e-spec.ts index 33c0f02..d19ee82 100644 --- a/test/upcoming-events.e2e-spec.ts +++ b/test/upcoming-events.e2e-spec.ts @@ -1,23 +1,26 @@ +import type { Server } from 'node:http'; + import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; -import type { Server } from 'node:http'; import { setTimeout as delay } from 'node:timers/promises'; import request from 'supertest'; + +import type { BondCouponsResourceDto } from '../src/modules/investments/instruments/bonds/presentation/bond.dto'; +import type { StockDividendsResourceDto } from '../src/modules/investments/instruments/stocks/presentation/stock.dto'; +import type { UpcomingEventsResourceDto } from '../src/modules/investments/upcoming-events/upcoming-events.dto'; + import { AppModule } from '../src/app.module'; -import { configureApp } from '../src/main'; -import { PrismaService } from '../src/prisma/prisma.service'; +import { MoexClient } from '../src/integrations/moex/moex.api'; import { TbankClient, - TbankTokenService, tbankSourceTtlMs, + TbankTokenService, } from '../src/integrations/tbank/tbank.api'; -import { MoexClient } from '../src/integrations/moex/moex.api'; +import { configureApp } from '../src/main'; import { CatalogEnrichmentService } from '../src/modules/investments/instruments/application/catalog-enrichment.service'; import { ForecastRepository } from '../src/modules/investments/portfolio/infrastructure/forecast.repository'; -import type { UpcomingEventsResourceDto } from '../src/modules/investments/upcoming-events/upcoming-events.dto'; -import type { StockDividendsResourceDto } from '../src/modules/investments/instruments/stocks/presentation/stock.dto'; -import type { BondCouponsResourceDto } from '../src/modules/investments/instruments/bonds/presentation/bond.dto'; +import { PrismaService } from '../src/prisma/prisma.service'; const shareUid = '00000000-0000-4000-8000-000000000001'; const bondUid = '00000000-0000-4000-8000-000000000002'; @@ -86,8 +89,9 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { await delay(10); if ( !(await prisma.tbankSyncWork.count({ where: { state: 'running' } })) - ) + ) { return; + } } } throw new Error('Forecast dependencies did not settle'); @@ -139,10 +143,11 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { await app.init(); await app.listen(0, '127.0.0.1'); prisma = app.get(PrismaService); - for (const id of ['owner', 'other']) + for (const id of ['owner', 'other']) { await prisma.user.create({ data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' }, }); + } }); beforeEach(async () => { await settled(); @@ -236,8 +241,9 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { const cold = (await events(false).expect(200)) .body as UpcomingEventsResourceDto; expect(cold.data).toBeNull(); - for (let i = 0; i < 100 && !upstream.getDividends.mock.calls.length; i++) + for (let i = 0; i < 100 && !upstream.getDividends.mock.calls.length; i++) { await delay(5); + } await events().expect(200); release([]); await settled(); @@ -290,9 +296,9 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { .body as UpcomingEventsResourceDto; await portfolio([position(shareUid, '7'), position(newUid, '5')]); upstream.getDividends.mockImplementation((_token: string, uid: string) => - uid === newUid - ? Promise.reject(new Error('new calendar unavailable')) - : Promise.resolve([]), + uid === newUid ? + Promise.reject(new Error('new calendar unavailable')) : + Promise.resolve([]), ); const pending = (await events(false).expect(200)) .body as UpcomingEventsResourceDto; @@ -450,12 +456,13 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { const path = `${kind}/${uid}/${payout}`; const read = async () => (await get(`${path}?sync=observe`).expect(200)).body as - StockDividendsResourceDto | BondCouponsResourceDto; + BondCouponsResourceDto | StockDividendsResourceDto; await get(path).expect(200); await settled(); const before = await read(); - if (!before.data?.positionAt) + if (!before.data?.positionAt) { throw new Error('Expected a saved position'); + } expect(before.data.positionIsStale).toBe(false); const saved = await prisma.tbankForecast.findFirstOrThrow(); const expiresAt = @@ -504,8 +511,9 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { } finally { publish.mockRestore(); } - for (const rpc of Object.values(upstream)) + for (const rpc of Object.values(upstream)) { expect(rpc).not.toHaveBeenCalled(); + } expect(moex.bondEvents).not.toHaveBeenCalled(); } finally { clock.mockRestore(); @@ -615,8 +623,9 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { }), ); await events(false).expect(200); - for (let i = 0; i < 100 && !upstream.getDividends.mock.calls.length; i++) + for (let i = 0; i < 100 && !upstream.getDividends.mock.calls.length; i++) { await delay(5); + } await prisma.tbankConnection.delete({ where: { userId: 'owner' } }); release([ { From b78c215ff13e908621e6970cfdcdd4f483e45d2a Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Mon, 28 Sep 2026 11:24:59 +0300 Subject: [PATCH 11/22] refactor: replace ignored numeric literals with constants --- .ai/architecture.md | 9 +-- eslint.config.mjs | 24 -------- src/common/decimal.ts | 58 ++++++++++++------- src/common/time.ts | 2 + src/config/env.validation.ts | 7 ++- src/integrations/moex/moex.client.ts | 16 +++-- src/integrations/ncr/ncr-ratings.client.ts | 11 +++- src/integrations/tbank/decimal.ts | 4 +- .../tbank/instrument-metadata.repository.ts | 19 +++--- .../tbank/tbank-access.service.ts | 8 ++- .../tbank/tbank-account.policy.ts | 4 +- src/integrations/tbank/tbank.client.ts | 21 ++++--- src/integrations/tbank/tbank.crypto.ts | 8 ++- src/modules/auth/application/auth.service.ts | 4 +- src/modules/auth/presentation/auth.dto.ts | 9 ++- .../bank-cards/application/bank-card-input.ts | 8 ++- .../application/bank-cards.service.ts | 5 +- .../application/bank-operations.service.ts | 24 ++++---- .../application/expense-categories.service.ts | 8 ++- .../bank-cards/domain/bank-card-limits.ts | 7 +++ .../domain/bank-card.calculations.ts | 20 ++++--- .../banking/bank-cards/domain/rub-money.ts | 14 +++-- .../infrastructure/bank-cards.repository.ts | 8 ++- .../bank-cards/presentation/bank-card.dto.ts | 32 +++++----- .../income/income-analytics.service.ts | 4 +- .../investments/income/income-period.ts | 34 ++++++----- src/modules/investments/instrument-query.ts | 2 + .../instruments/application/candle.service.ts | 15 +++-- .../application/catalog-discovery.service.ts | 14 +++-- .../application/catalog-enrichment.service.ts | 10 +++- .../application/instrument-market.ts | 10 ++-- .../application/instrument-position.ts | 12 ++-- .../bonds/application/bond-catalog.service.ts | 9 ++- .../bonds/application/bond-income.service.ts | 29 ++++++---- .../bonds/application/bond-market.service.ts | 7 ++- .../instruments/domain/candle-coverage.ts | 4 +- .../infrastructure/candle.repository.ts | 4 +- .../catalog-discovery.repository.ts | 21 ++++--- .../application/stock-catalog.service.ts | 4 +- .../application/stock-income.service.ts | 17 ++++-- .../application/stock-market.service.ts | 7 ++- .../stocks/presentation/stock.dto.ts | 14 +++-- .../investments/investment-calendar.ts | 13 +++-- .../investments/investment-sync-plan.ts | 15 +++-- .../brokerage-operations-sync.service.ts | 12 ++-- .../brokerage-operations.service.ts | 31 ++++++---- .../operations/domain/operations-sync-plan.ts | 20 +++++-- .../brokerage-operation.mapper.ts | 6 +- .../brokerage-operations.repository.ts | 39 ++++++++----- .../presentation/brokerage-operation.dto.ts | 6 +- .../portfolio/application/forecast.service.ts | 7 ++- .../application/portfolio.service.ts | 6 +- .../portfolio/domain/portfolio-structure.ts | 16 +++-- .../infrastructure/forecast.repository.ts | 4 +- .../infrastructure/portfolio.repository.ts | 4 +- .../investments/tbank-sync/tbank-screen.ts | 7 ++- .../investments/tbank-sync/tbank-sync.dto.ts | 15 +++-- .../tbank-sync/tbank-sync.service.ts | 16 +++-- .../upcoming-events.service.ts | 13 +++-- test/eslint-config.cjs | 16 +++++ 60 files changed, 510 insertions(+), 283 deletions(-) create mode 100644 src/modules/investments/instrument-query.ts diff --git a/.ai/architecture.md b/.ai/architecture.md index 892117b..6adccfb 100644 --- a/.ai/architecture.md +++ b/.ai/architecture.md @@ -246,10 +246,11 @@ OpenAPI enum metadata и runtime validation должны читать тот ж сохраняй; generated Prisma/protocol code вручную не меняй. `@typescript-eslint/no-magic-numbers` включён как error; базовое ESLint rule -отключено в пользу TS-версии. Исключения для очевидных чисел и их bigint форм, -индексов, default values, enum/type declarations и readonly properties заданы -в ESLint config. Test fixtures/expected values остаются inline, остальные lint -правила продолжают действовать. Numeric policy получает предметное имя и единицы; +отключено в пользу TS-версии. Общего списка `ignore` для чисел и bigint нет: +значения, проверяемые правилом, получают предметные имена у владельца. +Исключения для индексов, default values, enum/type declarations и readonly +properties заданы в ESLint config. Test fixtures/expected values остаются inline, +остальные lint правила продолжают действовать. Numeric policy получает предметное имя и единицы; semantic magic strings проверяются conventions/review без нового инструмента. ## Logging diff --git a/eslint.config.mjs b/eslint.config.mjs index 81c0330..02a5136 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -70,30 +70,6 @@ export default defineConfig( '@typescript-eslint/no-magic-numbers': [ 'error', { - ignore: [ - -60, - -24, - -10, - -2, - -1, - 0, - 1, - 2, - 10, - 24, - 60, - '-60n', - '-24n', - '-10n', - '-2n', - '-1n', - '0n', - '1n', - '2n', - '10n', - '24n', - '60n', - ], ignoreArrayIndexes: true, ignoreDefaultValues: true, ignoreEnums: true, diff --git a/src/common/decimal.ts b/src/common/decimal.ts index 9f969b6..b19a258 100644 --- a/src/common/decimal.ts +++ b/src/common/decimal.ts @@ -1,3 +1,19 @@ +export const DECIMAL_RADIX = 10n; + +const SIGN_LENGTH = 1; +const NEGATIVE_SIGN = -1n; +const POSITIVE_SIGN = 1n; +const ZERO_UNITS = 0n; +const EQUAL = 0; +const GREATER_THAN = 1; +const LESS_THAN = -1; +const INTEGER_SCALE = 0; +const MIN_WHOLE_DIGITS = 1; +const DIGITS_START = 0; +const PREVIOUS_DIGIT_OFFSET = 1; +const DIGIT_STEP = 1; +const ROUNDING_DIVISOR = 2n; + export type Decimal = { scale: number; value: bigint }; export function decimalFromString(value: string): Decimal { @@ -6,9 +22,9 @@ export function decimalFromString(value: string): Decimal { throw new Error('Invalid decimal string'); } const negative = value.startsWith('-'); - const [whole, fraction = ''] = (negative ? value.slice(1) : value).split('.'); + const [whole, fraction = ''] = (negative ? value.slice(SIGN_LENGTH) : value).split('.'); return { - value: (negative ? -1n : 1n) * BigInt(`${whole}${fraction}`), + value: (negative ? NEGATIVE_SIGN : POSITIVE_SIGN) * BigInt(`${whole}${fraction}`), scale: fraction.length, }; } @@ -17,8 +33,8 @@ export function add(left: Decimal, right: Decimal): Decimal { const scale = Math.max(left.scale, right.scale); return { value: - left.value * 10n ** BigInt(scale - left.scale) + - right.value * 10n ** BigInt(scale - right.scale), + left.value * DECIMAL_RADIX ** BigInt(scale - left.scale) + + right.value * DECIMAL_RADIX ** BigInt(scale - right.scale), scale, }; } @@ -32,37 +48,37 @@ export function multiply(left: Decimal, right: Decimal): Decimal { } export function isZero(value: Decimal): boolean { - return value.value === 0n; + return value.value === ZERO_UNITS; } export function compare(left: Decimal, right: Decimal): number { const difference = subtract(left, right).value; - return difference === 0n ? 0 : difference > 0n ? 1 : -1; + return difference === ZERO_UNITS ? EQUAL : difference > ZERO_UNITS ? GREATER_THAN : LESS_THAN; } export function formatDecimal({ value, scale }: Decimal): string { - if (value === 0n) return '0'; - const sign = value < 0n ? '-' : ''; - if (scale === 0) return `${sign}${value < 0n ? -value : value}`; - const digits = (value < 0n ? -value : value) + if (value === ZERO_UNITS) return '0'; + const sign = value < ZERO_UNITS ? '-' : ''; + if (scale === INTEGER_SCALE) return `${sign}${value < ZERO_UNITS ? -value : value}`; + const digits = (value < ZERO_UNITS ? -value : value) .toString() - .padStart(scale + 1, '0'); - const whole = digits.slice(0, -scale) || '0'; + .padStart(scale + MIN_WHOLE_DIGITS, '0'); + const whole = digits.slice(DIGITS_START, -scale) || '0'; const rawFraction = digits.slice(-scale); let fractionEnd = rawFraction.length; - while (rawFraction.charAt(fractionEnd - 1) === '0') fractionEnd -= 1; - const fraction = rawFraction.slice(0, fractionEnd); + while (rawFraction.charAt(fractionEnd - PREVIOUS_DIGIT_OFFSET) === '0') fractionEnd -= DIGIT_STEP; + const fraction = rawFraction.slice(DIGITS_START, fractionEnd); const fractionalPart = fraction ? '.' + fraction : ''; return `${sign}${whole}${fractionalPart}`; } // Round half away from zero; callers choose presentation precision explicitly. export function divide(left: Decimal, right: Decimal, scale: number): Decimal { - if (right.value === 0n) throw new Error('Division by zero'); - const numerator = left.value * 10n ** BigInt(right.scale + scale); - const denominator = right.value * 10n ** BigInt(left.scale); - const sign = (numerator < 0n) !== (denominator < 0n) ? -1n : 1n; - const n = numerator < 0n ? -numerator : numerator; - const d = denominator < 0n ? -denominator : denominator; - return { value: sign * ((n + d / 2n) / d), scale }; + if (right.value === ZERO_UNITS) throw new Error('Division by zero'); + const numerator = left.value * DECIMAL_RADIX ** BigInt(right.scale + scale); + const denominator = right.value * DECIMAL_RADIX ** BigInt(left.scale); + const sign = (numerator < ZERO_UNITS) !== (denominator < ZERO_UNITS) ? NEGATIVE_SIGN : POSITIVE_SIGN; + const n = numerator < ZERO_UNITS ? -numerator : numerator; + const d = denominator < ZERO_UNITS ? -denominator : denominator; + return { value: sign * ((n + d / ROUNDING_DIVISOR) / d), scale }; } diff --git a/src/common/time.ts b/src/common/time.ts index 48deb5b..ca5a945 100644 --- a/src/common/time.ts +++ b/src/common/time.ts @@ -1,2 +1,4 @@ +export const ISO_DATE_LENGTH = 10; +export const ISO_DATE_START = 0; export const MILLISECONDS_PER_SECOND = 1_000; export const MILLISECONDS_PER_DAY = 86_400_000; diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index d9c929b..ac378db 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -1,3 +1,6 @@ +const MIN_PORT = 1; +const MIN_REQUESTS_PER_SECOND = 1; + const DEFAULT_PORT = 3000; const DEFAULT_TBANK_RATE_LIMIT_PER_SECOND = 5; const MAX_PORT = 65_535; @@ -56,7 +59,7 @@ export function validateEnv(config: Record): Environment { const parsedPort = Number(port); if ( !Number.isInteger(parsedPort) || - parsedPort < 1 || + parsedPort < MIN_PORT || parsedPort > MAX_PORT ) { throw new Error('PORT must be a valid port number'); @@ -84,7 +87,7 @@ export function validateEnv(config: Record): Environment { } const parsedTbankRateLimit = Number(tbankRateLimit); - if (!Number.isInteger(parsedTbankRateLimit) || parsedTbankRateLimit < 1) { + if (!Number.isInteger(parsedTbankRateLimit) || parsedTbankRateLimit < MIN_REQUESTS_PER_SECOND) { throw new Error('TBANK_RATE_LIMIT_PER_SECOND must be a positive integer'); } diff --git a/src/integrations/moex/moex.client.ts b/src/integrations/moex/moex.client.ts index eb6fc31..5b5d561 100644 --- a/src/integrations/moex/moex.client.ts +++ b/src/integrations/moex/moex.client.ts @@ -1,6 +1,12 @@ import { Injectable, ServiceUnavailableException } from '@nestjs/common'; import { constants as httpStatus } from 'node:http2'; +const MIN_PRICE_PERCENT_EXCLUSIVE = 0; +const FIRST_PAGE_OFFSET = 0; +const EXPECTED_CURSOR_ROWS = 1; +const EMPTY_PAGE_SIZE = 0; +const MIN_CURSOR_VALUE = 0; + const FALLBACK_PAGE_SIZE = 100; const baseUrl = 'https://iss.moex.com/iss'; @@ -52,7 +58,7 @@ export class MoexClient { if ( value === null || pricePercent === null || - Number(pricePercent) <= 0 || + Number(pricePercent) <= MIN_PRICE_PERCENT_EXCLUSIVE || !date || !/^\d{4}-\d{2}-\d{2}$/.test(date) || !Number.isFinite(Date.parse(date)) || @@ -114,7 +120,7 @@ export class MoexClient { paginated = true, ): Promise[]> { const rows: Record[] = []; - let start = 0; + let start = FIRST_PAGE_OFFSET; for (;;) { const query = new URLSearchParams({ 'iss.meta': 'off', @@ -139,11 +145,11 @@ export class MoexClient { const total = integer(cursor?.total); const pageSize = integer(cursor?.pagesize); if ( - cursors.length !== 1 || + cursors.length !== EXPECTED_CURSOR_ROWS || index !== start || total === null || pageSize === null || - pageSize === 0 || + pageSize === EMPTY_PAGE_SIZE || current.length !== Math.min(pageSize, total - start) ) { throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); @@ -207,5 +213,5 @@ function currencyText(value: unknown): null | string { function integer(value: unknown): null | number { const result = Number(numberText(value) ?? NaN); - return Number.isSafeInteger(result) && result >= 0 ? result : null; + return Number.isSafeInteger(result) && result >= MIN_CURSOR_VALUE ? result : null; } diff --git a/src/integrations/ncr/ncr-ratings.client.ts b/src/integrations/ncr/ncr-ratings.client.ts index 16eea7a..257b58d 100644 --- a/src/integrations/ncr/ncr-ratings.client.ts +++ b/src/integrations/ncr/ncr-ratings.client.ts @@ -2,6 +2,11 @@ import { Injectable, ServiceUnavailableException } from '@nestjs/common'; import { load } from 'cheerio'; import { LRUCache } from 'lru-cache'; +import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time'; + +const EXPECTED_REGISTRY_TABLES = 1; +const RATING_COLUMN = 2; + const REGISTRY_CACHE_TTL_MS = 21_600_000; const REQUEST_TIMEOUT_MS = 10_000; const COLUMN_COUNT = 6; @@ -55,7 +60,7 @@ export class NcrRatingsClient { .map((_, cell) => $(cell).text().trim()) .get(); if ( - table.length !== 1 || + table.length !== EXPECTED_REGISTRY_TABLES || headings.length !== COLUMN_COUNT || headings[2] !== 'Рейтинг' || // eslint-disable-next-line security/detect-object-injection -- Column indexes are local numeric constants. @@ -73,7 +78,7 @@ export class NcrRatingsClient { const isin = cells.eq(ISIN_COLUMN).text().trim().toUpperCase(); // Unassigned issues cannot safely be matched to a held instrument. if (!/^[A-Z]{2}[A-Z0-9]{9}\d$/.test(isin)) return; - const raw = cells.eq(2).text().replace(/\s+/g, ' ').trim(); + const raw = cells.eq(RATING_COLUMN).text().replace(/\s+/g, ' ').trim(); const withdrawn = raw.toLowerCase() === 'рейтинг отозван'; if ( !withdrawn && @@ -93,7 +98,7 @@ export class NcrRatingsClient { const date = `${dateParts[3]}-${dateParts[2]}-${dateParts[1]}`; if ( !Number.isFinite(Date.parse(date)) || - new Date(date).toISOString().slice(0, 10) !== date + new Date(date).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) !== date ) { throw new Error('NCR date invalid'); } diff --git a/src/integrations/tbank/decimal.ts b/src/integrations/tbank/decimal.ts index f91faa0..fc2faef 100644 --- a/src/integrations/tbank/decimal.ts +++ b/src/integrations/tbank/decimal.ts @@ -1,12 +1,14 @@ import { Decimal } from '../../common/decimal'; +const MISSING_PROTO_COMPONENT = 0; + const NANO = 1_000_000_000n; export type ProtoDecimal = { nano?: number | string; units?: number | string }; export function decimal(value: ProtoDecimal): Decimal { return { - value: BigInt(value.units ?? 0) * NANO + BigInt(value.nano ?? 0), + value: BigInt(value.units ?? MISSING_PROTO_COMPONENT) * NANO + BigInt(value.nano ?? MISSING_PROTO_COMPONENT), scale: 9, }; } diff --git a/src/integrations/tbank/instrument-metadata.repository.ts b/src/integrations/tbank/instrument-metadata.repository.ts index ef67ad6..277dd99 100644 --- a/src/integrations/tbank/instrument-metadata.repository.ts +++ b/src/integrations/tbank/instrument-metadata.repository.ts @@ -13,6 +13,11 @@ import type { TbankInstrument } from './tbank.client'; import { PrismaService } from '../../prisma/prisma.service'; import { tbankSourceTtlMs } from './tbank-source.policy'; +const FIRST_BATCH_OFFSET = 0; +const UNCHANGED_REVISION_INCREMENT = 0; +const REVISION_INCREMENT = 1; +const MISSING_DATA_REVISION = 0; + export type MetadataIdentity = { figi?: null | string; instrumentUid?: null | string; @@ -59,7 +64,7 @@ export class InstrumentMetadataRepository { ), ]; for ( - let offset = 0; + let offset = FIRST_BATCH_OFFSET; offset < values.length; offset += metadataReadBatchSize ) { @@ -81,7 +86,7 @@ export class InstrumentMetadataRepository { ]; const instruments: MetadataRow[] = []; for ( - let offset = 0; + let offset = FIRST_BATCH_OFFSET; offset < uids.length; offset += metadataReadBatchSize ) { @@ -229,8 +234,8 @@ export class InstrumentMetadataRepository { ...data, dataRevision: { increment: isDeepStrictEqual(previous?.metadata, payload) ? - 0 : - 1, + UNCHANGED_REVISION_INCREMENT : + REVISION_INCREMENT, }, }, }); @@ -252,8 +257,8 @@ export class InstrumentMetadataRepository { fetchedAt: now, dataRevision: { increment: isDeepStrictEqual(previous?.matchedUids, matchedUids) ? - 0 : - 1, + UNCHANGED_REVISION_INCREMENT : + REVISION_INCREMENT, }, }, }); @@ -323,7 +328,7 @@ function selectMetadata( dataRevision: used .map( (row) => - `${row.lookup?.dataRevision ?? 0}:${row.instrument?.dataRevision ?? 0}`, + `${row.lookup?.dataRevision ?? MISSING_DATA_REVISION}:${row.instrument?.dataRevision ?? MISSING_DATA_REVISION}`, ) .join('/'), }; diff --git a/src/integrations/tbank/tbank-access.service.ts b/src/integrations/tbank/tbank-access.service.ts index 613fa1e..588c9cb 100644 --- a/src/integrations/tbank/tbank-access.service.ts +++ b/src/integrations/tbank/tbank-access.service.ts @@ -13,6 +13,10 @@ import { supportedTbankAccounts } from './tbank-account.policy'; import { tbankSourceNeedsRefresh } from './tbank-source.policy'; import { TbankTokenService } from './tbank-token.service'; +const EXPECTED_UPDATED_CONNECTIONS = 1; +const MEMBERSHIP_REVISION_INCREMENT = 1; +const UNCHANGED_MEMBERSHIP_REVISION_INCREMENT = 0; + export type TbankSyncAttempt = { attemptId: string; id: string; @@ -101,7 +105,7 @@ export class TbankAccessService { }, data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' }, }); - return result.count === 1; + return result.count === EXPECTED_UPDATED_CONNECTIONS; } async tokenForAttempt(attempt: TbankSyncAttempt): Promise { @@ -184,7 +188,7 @@ export class TbankAccessService { where: { userId_revision: { userId, revision } }, data: { membershipAt: fetchedAt, - membershipRevision: { increment: changed ? 1 : 0 }, + membershipRevision: { increment: changed ? MEMBERSHIP_REVISION_INCREMENT : UNCHANGED_MEMBERSHIP_REVISION_INCREMENT }, }, }); if (completeWork) { diff --git a/src/integrations/tbank/tbank-account.policy.ts b/src/integrations/tbank/tbank-account.policy.ts index 8ce7264..eb456e2 100644 --- a/src/integrations/tbank/tbank-account.policy.ts +++ b/src/integrations/tbank/tbank-account.policy.ts @@ -1,4 +1,4 @@ -import { MILLISECONDS_PER_SECOND } from '../../common/time'; +import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../common/time'; import { TbankAccount } from './tbank.client'; const accountTypes = { @@ -43,5 +43,5 @@ function openedOn(seconds: number | string | undefined): null | string { if (seconds === undefined) return null; const date = new Date(Number(seconds) * MILLISECONDS_PER_SECOND); if (Number.isNaN(date.valueOf())) return null; - return date.toISOString().slice(0, 10); + return date.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH); } diff --git a/src/integrations/tbank/tbank.client.ts b/src/integrations/tbank/tbank.client.ts index 902733f..62101ab 100644 --- a/src/integrations/tbank/tbank.client.ts +++ b/src/integrations/tbank/tbank.client.ts @@ -30,6 +30,13 @@ import { StructuredLogger } from '../../common/logging/structured-logger.service import { MILLISECONDS_PER_SECOND } from '../../common/time'; import { Environment } from '../../config/env.validation'; +const INITIAL_REQUEST_COUNT = 0; +const MIN_RATE_DELAY_MS = 1; +const FIRST_ATTEMPT = 1; +const ATTEMPT_INCREMENT = 1; +const NO_RPC_ATTEMPTS = 0; +const NEXT_DAY_OFFSET = 1; + const RATE_WINDOW_MS = 1_000; const METADATA_MAX_ATTEMPTS = 3; const RPC_MAX_ATTEMPTS = 2; @@ -124,7 +131,7 @@ type TbankNamespace = { class RateLimiter { private readonly waiting: (() => void)[] = []; - private starts = 0; + private starts = INITIAL_REQUEST_COUNT; private windowStartedAt = Date.now(); constructor(private readonly perSecond: number) {} @@ -149,16 +156,16 @@ class RateLimiter { const now = Date.now(); if (now - this.windowStartedAt >= RATE_WINDOW_MS) { this.windowStartedAt = now; - this.starts = 0; + this.starts = INITIAL_REQUEST_COUNT; } while (this.starts < this.perSecond && this.waiting.length) { - this.starts += 1; + this.starts++; this.waiting.shift()?.(); } if (this.waiting.length && this.starts === this.perSecond) { setTimeout( () => this.drain(), - Math.max(1, RATE_WINDOW_MS - (now - this.windowStartedAt)), + Math.max(MIN_RATE_DELAY_MS, RATE_WINDOW_MS - (now - this.windowStartedAt)), ); } } @@ -472,7 +479,7 @@ export class TbankClient { ): Promise { const metadataCall = method === 'getInstrumentBy'; const maxAttempts = metadataCall ? METADATA_MAX_ATTEMPTS : RPC_MAX_ATTEMPTS; - for (let attempts = 1; attempts <= maxAttempts; attempts += 1) { + for (let attempts = FIRST_ATTEMPT; attempts <= maxAttempts; attempts += ATTEMPT_INCREMENT) { const startedAt = performance.now(); const queueFull = this.queue.size >= MAX_QUEUE_SIZE; try { @@ -520,7 +527,7 @@ export class TbankClient { this.logger.event('error', 'tbank.rpc.failed', { operation: method, ...failureContext, - attempts: queueFull && !metadataCall ? 0 : attempts, + attempts: queueFull && !metadataCall ? NO_RPC_ATTEMPTS : attempts, durationMs: Math.round(performance.now() - startedAt), }); if ( @@ -564,6 +571,6 @@ export class TbankClient { // 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); + if (nextDay) instant.setUTCDate(instant.getUTCDate() + NEXT_DAY_OFFSET); return Math.floor(instant.valueOf() / MILLISECONDS_PER_SECOND); } diff --git a/src/integrations/tbank/tbank.crypto.ts b/src/integrations/tbank/tbank.crypto.ts index a40d081..40dc533 100644 --- a/src/integrations/tbank/tbank.crypto.ts +++ b/src/integrations/tbank/tbank.crypto.ts @@ -1,5 +1,7 @@ import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; +const VERSION_SIZE_BYTES = 1; + const KEY_SIZE_BYTES = 32; const VERSION = 1; @@ -34,15 +36,15 @@ export class TbankCrypto { decrypt(payload: string): string { const bytes = Buffer.from(payload, 'base64'); - if (bytes[0] !== VERSION || bytes.length < 1 + NONCE_SIZE + TAG_SIZE) { + if (bytes[0] !== VERSION || bytes.length < VERSION_SIZE_BYTES + NONCE_SIZE + TAG_SIZE) { throw new Error('Invalid encrypted token'); } - const nonce = bytes.subarray(1, 1 + NONCE_SIZE); + const nonce = bytes.subarray(VERSION_SIZE_BYTES, VERSION_SIZE_BYTES + NONCE_SIZE); const tag = bytes.subarray(-TAG_SIZE); const decipher = createDecipheriv('aes-256-gcm', this.key, nonce); decipher.setAuthTag(tag); return Buffer.concat([ - decipher.update(bytes.subarray(1 + NONCE_SIZE, -TAG_SIZE)), + decipher.update(bytes.subarray(VERSION_SIZE_BYTES + NONCE_SIZE, -TAG_SIZE)), decipher.final(), ]).toString('utf8'); } diff --git a/src/modules/auth/application/auth.service.ts b/src/modules/auth/application/auth.service.ts index b2528c8..19baedb 100644 --- a/src/modules/auth/application/auth.service.ts +++ b/src/modules/auth/application/auth.service.ts @@ -11,6 +11,8 @@ import { Session, User } from '../../../../generated/prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; import { AuthUser, RefreshPayload } from './auth.types'; +const EXPECTED_ROTATED_SESSIONS = 1; + const PASSWORD_HASH_ROUNDS = 12; const REFRESH_TTL_MS = 604_800_000; @@ -180,7 +182,7 @@ export class AuthService { where: { id: session.id, refreshTokenHash: expectedRefreshTokenHash }, data, }); - if (updated.count !== 1) { + if (updated.count !== EXPECTED_ROTATED_SESSIONS) { throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); } } else { diff --git a/src/modules/auth/presentation/auth.dto.ts b/src/modules/auth/presentation/auth.dto.ts index 62fe648..c04bee2 100644 --- a/src/modules/auth/presentation/auth.dto.ts +++ b/src/modules/auth/presentation/auth.dto.ts @@ -7,6 +7,9 @@ import { ValidatorConstraint, } from 'class-validator'; +const EMAIL_PART_COUNT = 2; +const FIRST_CHARACTER_INDEX = 0; + const MAX_EMAIL_LENGTH = 254; const ASCII_SPACE = 32; const ASCII_DELETE = 127; @@ -19,14 +22,14 @@ class EmailConstraint { return ( typeof value === 'string' && value.length <= MAX_EMAIL_LENGTH && - value.split('@').length === 2 && + value.split('@').length === EMAIL_PART_COUNT && !value .split('') .some( (character) => /\s/.test(character) || - character.charCodeAt(0) < ASCII_SPACE || - character.charCodeAt(0) === ASCII_DELETE, + character.charCodeAt(FIRST_CHARACTER_INDEX) < ASCII_SPACE || + character.charCodeAt(FIRST_CHARACTER_INDEX) === ASCII_DELETE, ) && value.split('@').every(Boolean) ); diff --git a/src/modules/banking/bank-cards/application/bank-card-input.ts b/src/modules/banking/bank-cards/application/bank-card-input.ts index e0f3440..8bb0dd6 100644 --- a/src/modules/banking/bank-cards/application/bank-card-input.ts +++ b/src/modules/banking/bank-cards/application/bank-card-input.ts @@ -5,6 +5,7 @@ import { } from '@nestjs/common'; import { createHash } from 'node:crypto'; +import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../common/time'; import { MAX_CARD_NAME_LENGTH, MAX_OPERATION_DESCRIPTION_LENGTH, @@ -18,6 +19,9 @@ import { CreateBankOperationDto, } from '../presentation/bank-card.dto'; +const NEXT_MONTH_OFFSET = 1; +const PREVIOUS_MONTH_LAST_DAY = 0; + export const BankErrorCode = { InvalidInput: 'BANK_INVALID_INPUT', VersionConflict: 'BANK_VERSION_CONFLICT', @@ -45,8 +49,8 @@ export function periodBounds(query: BankPeriodQueryDto) { const from = `${query.month}-01`; if (!isCalendarDate(from)) return fail(); const end = new Date(`${from}T00:00:00Z`); - end.setUTCMonth(end.getUTCMonth() + 1, 0); - return { from, to: end.toISOString().slice(0, 10) }; + end.setUTCMonth(end.getUTCMonth() + NEXT_MONTH_OFFSET, PREVIOUS_MONTH_LAST_DAY); + return { from, to: end.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) }; } if ( query.month || diff --git a/src/modules/banking/bank-cards/application/bank-cards.service.ts b/src/modules/banking/bank-cards/application/bank-cards.service.ts index 1f5de0c..2c5d92b 100644 --- a/src/modules/banking/bank-cards/application/bank-cards.service.ts +++ b/src/modules/banking/bank-cards/application/bank-cards.service.ts @@ -19,6 +19,9 @@ import { fingerprint, missing, } from './bank-card-input'; + +const EXPECTED_UPDATED_CARDS = 1; + type Tx = Prisma.TransactionClient; @Injectable() export class BankCardsService { @@ -149,7 +152,7 @@ export class BankCardsService { }), }, }); - if (changed.count !== 1) conflict(); + if (changed.count !== EXPECTED_UPDATED_CARDS) conflict(); return this.cardView(tx, await this.repository.card(tx, userId, id)); }); } diff --git a/src/modules/banking/bank-cards/application/bank-operations.service.ts b/src/modules/banking/bank-cards/application/bank-operations.service.ts index d9dac92..eef0336 100644 --- a/src/modules/banking/bank-cards/application/bank-operations.service.ts +++ b/src/modules/banking/bank-cards/application/bank-operations.service.ts @@ -5,8 +5,9 @@ import { Prisma, } from '../../../../../generated/prisma/client'; import { PrismaService } from '../../../../prisma/prisma.service'; +import { FIRST_PAGE } from '../domain/bank-card-limits'; import { BankOperationKind } from '../domain/bank-operation-kind'; -import { rubMoney } from '../domain/rub-money'; +import { rubMoney, ZERO_KOPECKS } from '../domain/rub-money'; import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; import { BankOperationDto, @@ -26,6 +27,9 @@ import { operationInput, periodBounds, } from './bank-card-input'; + +const EXPECTED_UPDATED_ROWS = 1; + type Tx = Prisma.TransactionClient; @Injectable() export class BankOperationsService { @@ -77,7 +81,7 @@ export class BankOperationsService { row.kind === BankOperationKind.Expense || source ? rubMoney( (source?.amountKopecks ?? row.amountKopecks) - - refunds.reduce((sum, r) => sum + r.amountKopecks, 0n), + refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS), ) : null, date: row.date, @@ -170,7 +174,7 @@ export class BankOperationsService { }, data: { version: { increment: 1 } }, }) - ).count !== 1 + ).count !== EXPECTED_UPDATED_ROWS ) { conflict(); } @@ -184,7 +188,7 @@ export class BankOperationsService { }); if (input.date < source.date) fail(BankErrorCode.InvalidDate); if ( - refunds.reduce((sum, r) => sum + r.amountKopecks, 0n) + + refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS) + input.amountKopecks > source.amountKopecks ) { @@ -199,7 +203,7 @@ export class BankOperationsService { refunds.length && (input.cardId !== old.cardId || refunds.some((row) => row.date < input.date) || - refunds.reduce((sum, row) => sum + row.amountKopecks, 0n) > + refunds.reduce((sum, row) => sum + row.amountKopecks, ZERO_KOPECKS) > input.amountKopecks) ) { conflict(BankErrorCode.DependentRefunds); @@ -268,7 +272,7 @@ export class BankOperationsService { where: { id, userId, version: dto.version, deletedAt: null }, data: { ...input, version: { increment: 1 } }, }) - ).count !== 1 + ).count !== EXPECTED_UPDATED_ROWS ) { conflict(); } @@ -308,7 +312,7 @@ export class BankOperationsService { where: { id: source.id, userId, version: source.version }, data: { version: { increment: 1 } }, }) - ).count !== 1 + ).count !== EXPECTED_UPDATED_ROWS ) { conflict(); } @@ -319,7 +323,7 @@ export class BankOperationsService { where: { id, userId, version, deletedAt: null }, data: { deletedAt: new Date(), version: { increment: 1 } }, }) - ).count !== 1 + ).count !== EXPECTED_UPDATED_ROWS ) { conflict(); } @@ -338,7 +342,7 @@ export class BankOperationsService { return this.prisma.$transaction(async (tx) => { await this.repository.card(tx, userId, id); const limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE, - page = query.page ?? 1; + page = query.page ?? FIRST_PAGE; const where: Prisma.BankCardOperationWhereInput = { userId, deletedAt: null, @@ -351,7 +355,7 @@ export class BankOperationsService { const rows = await tx.bankCardOperation.findMany({ where, orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }], - skip: (page - 1) * limit, + skip: (page - FIRST_PAGE) * limit, take: limit, }); return { diff --git a/src/modules/banking/bank-cards/application/expense-categories.service.ts b/src/modules/banking/bank-cards/application/expense-categories.service.ts index 80f2d77..4dbad85 100644 --- a/src/modules/banking/bank-cards/application/expense-categories.service.ts +++ b/src/modules/banking/bank-cards/application/expense-categories.service.ts @@ -5,6 +5,10 @@ import { PrismaService } from '../../../../prisma/prisma.service'; import { MAX_CATEGORY_NAME_LENGTH } from '../domain/bank-card-limits'; import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; import { conflict, missing, name } from './bank-card-input'; + +const EXPECTED_UPDATED_CATEGORIES = 1; +const VERSION_INCREMENT = 1; + @Injectable() export class ExpenseCategoriesService { constructor( @@ -62,7 +66,7 @@ export class ExpenseCategoriesService { version: { increment: 1 }, }, }) - ).count !== 1 + ).count !== EXPECTED_UPDATED_CATEGORIES ) { conflict(); } @@ -70,7 +74,7 @@ export class ExpenseCategoriesService { id, name: label, isDefault: row.seedKey === 'other', - version: version + 1, + version: version + VERSION_INCREMENT, }; }); } diff --git a/src/modules/banking/bank-cards/domain/bank-card-limits.ts b/src/modules/banking/bank-cards/domain/bank-card-limits.ts index 9483b9d..6b403d9 100644 --- a/src/modules/banking/bank-cards/domain/bank-card-limits.ts +++ b/src/modules/banking/bank-cards/domain/bank-card-limits.ts @@ -1,3 +1,10 @@ +export const FIRST_PAGE = 1; + +export const MIN_ENTITY_VERSION = 1; + +export const MIN_CATEGORY_NAME_LENGTH = 1; + +export const MIN_CARD_NAME_LENGTH = 1; export const MAX_CARD_NAME_LENGTH = 100; export const MAX_CATEGORY_NAME_LENGTH = 80; export const MAX_OPERATION_DESCRIPTION_LENGTH = 500; diff --git a/src/modules/banking/bank-cards/domain/bank-card.calculations.ts b/src/modules/banking/bank-cards/domain/bank-card.calculations.ts index 14ce726..cf1a0a3 100644 --- a/src/modules/banking/bank-cards/domain/bank-card.calculations.ts +++ b/src/modules/banking/bank-cards/domain/bank-card.calculations.ts @@ -1,4 +1,6 @@ +import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../common/time'; import { BankOperationKind } from './bank-operation-kind'; +import { ZERO_KOPECKS } from './rub-money'; export function moscowToday(now = new Date()): string { return new Intl.DateTimeFormat('en-CA', { @@ -10,11 +12,11 @@ export function moscowToday(now = new Date()): string { } export function isCalendarDate(value: string): boolean { - if (value.length !== 10 || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; + if (value.length !== ISO_DATE_LENGTH || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; if (value.startsWith('0000')) return false; const date = new Date(`${value}T00:00:00.000Z`); return ( - !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value + !Number.isNaN(date.getTime()) && date.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) === value ); } @@ -63,8 +65,8 @@ export function bankSummary( kind: string; }>, ) { - let expenses = 0n; - let refunds = 0n; + let expenses = ZERO_KOPECKS; + let refunds = ZERO_KOPECKS; const categories = new Map< string, { categoryId: string; expenses: bigint; net: bigint; refunds: bigint } @@ -81,19 +83,19 @@ export function bankSummary( const expense = operation.kind === BankOperationKind.Expense ? operation.amountKopecks : - 0n; + ZERO_KOPECKS; const refund = operation.kind === BankOperationKind.Refund ? operation.amountKopecks : - 0n; + ZERO_KOPECKS; expenses += expense; refunds += refund; if (operation.categoryId !== null) { const category = categories.get(operation.categoryId) ?? { categoryId: operation.categoryId, - expenses: 0n, - refunds: 0n, - net: 0n, + expenses: ZERO_KOPECKS, + refunds: ZERO_KOPECKS, + net: ZERO_KOPECKS, }; category.expenses += expense; category.refunds += refund; diff --git a/src/modules/banking/bank-cards/domain/rub-money.ts b/src/modules/banking/bank-cards/domain/rub-money.ts index 3fc9ccc..f435958 100644 --- a/src/modules/banking/bank-cards/domain/rub-money.ts +++ b/src/modules/banking/bank-cards/domain/rub-money.ts @@ -1,6 +1,10 @@ import type { Money } from '../../../../common/money.types'; -import { decimalFromString, formatDecimal } from '../../../../common/decimal'; +import { DECIMAL_RADIX, decimalFromString, formatDecimal } from '../../../../common/decimal'; + +export const ZERO_KOPECKS = 0n; + +const RUB_DECIMAL_PLACES = 2; const MIN_KOPECKS = -9_223_372_036_854_775_808n; const MAX_KOPECKS = 9_223_372_036_854_775_807n; @@ -14,14 +18,14 @@ export function parseRubMoney(money: Money, positive = false): bigint { throw new TypeError('Expected RUB money with a decimal string'); } const amount = decimalFromString(money.value); - if (amount.scale > 2) { + if (amount.scale > RUB_DECIMAL_PLACES) { throw new RangeError('RUB money supports at most two decimal places'); } - const kopecks = amount.value * 10n ** BigInt(2 - amount.scale); + const kopecks = amount.value * DECIMAL_RADIX ** BigInt(RUB_DECIMAL_PLACES - amount.scale); if (kopecks < MIN_KOPECKS || kopecks > MAX_KOPECKS) { throw new RangeError('RUB money is outside the signed 64-bit range'); } - if (positive && kopecks <= 0n) { + if (positive && kopecks <= ZERO_KOPECKS) { throw new RangeError('Operation amount must be positive'); } return kopecks; @@ -30,6 +34,6 @@ export function parseRubMoney(money: Money, positive = false): bigint { export function rubMoney(kopecks: bigint): Money { return { currency: 'RUB', - value: formatDecimal({ value: kopecks, scale: 2 }), + value: formatDecimal({ value: kopecks, scale: RUB_DECIMAL_PLACES }), }; } diff --git a/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts b/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts index 3d675ad..2c5e9aa 100644 --- a/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts +++ b/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts @@ -9,6 +9,10 @@ import { missing, } from '../application/bank-card-input'; import { BankOperationKind } from '../domain/bank-operation-kind'; + +const INITIAL_RETRY_COUNT = 0; +const LAST_SEED_OFFSET = 1; + type Tx = Prisma.TransactionClient; const TRANSACTION_MAX_WAIT_MS = 10_000; const TRANSACTION_TIMEOUT_MS = 15_000; @@ -28,7 +32,7 @@ const seeds = [ export class BankCardsRepository { constructor(private readonly prisma: PrismaService) {} async write(run: (tx: Tx) => Promise): Promise { - for (let attempt = 0; ; attempt++) { + for (let attempt = INITIAL_RETRY_COUNT; ; attempt++) { try { return await this.prisma.$transaction(run, { maxWait: TRANSACTION_MAX_WAIT_MS, @@ -69,7 +73,7 @@ export class BankCardsRepository { async seed(tx: Tx, userId: string) { for (const [index, label] of seeds.entries()) { - const seedKey = index === seeds.length - 1 ? 'other' : String(index); + const seedKey = index === seeds.length - LAST_SEED_OFFSET ? 'other' : String(index); if ( !(await tx.expenseCategory.findUnique({ where: { userId_seedKey: { userId, seedKey } }, diff --git a/src/modules/banking/bank-cards/presentation/bank-card.dto.ts b/src/modules/banking/bank-cards/presentation/bank-card.dto.ts index dfc9ac7..cb44a0f 100644 --- a/src/modules/banking/bank-cards/presentation/bank-card.dto.ts +++ b/src/modules/banking/bank-cards/presentation/bank-card.dto.ts @@ -25,12 +25,18 @@ import { import { MoneyDto } from '../../../../common/money.dto'; import { + FIRST_PAGE, MAX_CARD_NAME_LENGTH, MAX_CATEGORY_NAME_LENGTH, MAX_OPERATION_DESCRIPTION_LENGTH, + MIN_CARD_NAME_LENGTH, + MIN_CATEGORY_NAME_LENGTH, + MIN_ENTITY_VERSION, } from '../domain/bank-card-limits'; import { BankOperationKind } from '../domain/bank-operation-kind'; +const MIN_PAGE_SIZE = 1; + export const DEFAULT_BANK_PAGE_SIZE = 50; const MAX_BANK_PAGE_SIZE = 100; @@ -48,10 +54,10 @@ export class RubMoneyInputDto { } export class CreateBankCardDto { @ApiProperty({ format: 'uuid' }) @IsUUID() declare id: string; - @ApiProperty({ minLength: 1, maxLength: MAX_CARD_NAME_LENGTH }) + @ApiProperty({ minLength: MIN_CARD_NAME_LENGTH, maxLength: MAX_CARD_NAME_LENGTH }) @Transform(trim) @IsString() - @MinLength(1) + @MinLength(MIN_CARD_NAME_LENGTH) @MaxLength(MAX_CARD_NAME_LENGTH) declare name: string; @@ -91,22 +97,22 @@ export class UpdateBankCardDto extends PartialType( OmitType(CreateBankCardDto, ['id'] as const), { skipNullProperties: false }, ) { - @ApiProperty({ minimum: 1 }) @IsInt() @Min(1) declare version: number; + @ApiProperty({ minimum: MIN_ENTITY_VERSION }) @IsInt() @Min(MIN_ENTITY_VERSION) declare version: number; @ApiPropertyOptional() @ValidateIf((_object, value: unknown) => value !== undefined) @IsBoolean() archived?: boolean; } export class CategoryNameDto { - @ApiProperty({ minLength: 1, maxLength: MAX_CATEGORY_NAME_LENGTH }) + @ApiProperty({ minLength: MIN_CATEGORY_NAME_LENGTH, maxLength: MAX_CATEGORY_NAME_LENGTH }) @Transform(trim) @IsString() - @MinLength(1) + @MinLength(MIN_CATEGORY_NAME_LENGTH) @MaxLength(MAX_CATEGORY_NAME_LENGTH) declare name: string; } export class UpdateExpenseCategoryDto extends CategoryNameDto { - @ApiProperty({ minimum: 1 }) @IsInt() @Min(1) declare version: number; + @ApiProperty({ minimum: MIN_ENTITY_VERSION }) @IsInt() @Min(MIN_ENTITY_VERSION) declare version: number; } export class CreateBankOperationDto { @ApiProperty({ format: 'uuid' }) @IsUUID() declare id: string; @@ -151,7 +157,7 @@ export class UpdateBankOperationDto extends PartialType( OmitType(CreateBankOperationDto, ['id'] as const), { skipNullProperties: false }, ) { - @ApiProperty({ minimum: 1 }) @IsInt() @Min(1) declare version: number; + @ApiProperty({ minimum: MIN_ENTITY_VERSION }) @IsInt() @Min(MIN_ENTITY_VERSION) declare version: number; } export class BankCardsQueryDto { @ApiPropertyOptional({ enum: ['true', 'false'] }) @@ -160,22 +166,22 @@ export class BankCardsQueryDto { includeArchived?: string; } export class BankPageQueryDto { - @ApiPropertyOptional({ minimum: 1, default: 1 }) + @ApiPropertyOptional({ minimum: FIRST_PAGE, default: FIRST_PAGE }) @IsOptional() @Type(() => Number) @IsInt() - @Min(1) + @Min(FIRST_PAGE) page?: number; @ApiPropertyOptional({ - minimum: 1, + minimum: MIN_PAGE_SIZE, maximum: MAX_BANK_PAGE_SIZE, default: DEFAULT_BANK_PAGE_SIZE, }) @IsOptional() @Type(() => Number) @IsInt() - @Min(1) + @Min(MIN_PAGE_SIZE) @Max(MAX_BANK_PAGE_SIZE) limit?: number; } @@ -215,10 +221,10 @@ export class BankSummaryQueryDto extends BankPeriodQueryDto { cardId?: string; } export class BankVersionQueryDto { - @ApiProperty({ minimum: 1 }) + @ApiProperty({ minimum: MIN_ENTITY_VERSION }) @Type(() => Number) @IsInt() - @Min(1) + @Min(MIN_ENTITY_VERSION) declare version: number; } export class ExpenseCategoryDto { diff --git a/src/modules/investments/income/income-analytics.service.ts b/src/modules/investments/income/income-analytics.service.ts index 76fd624..5072d42 100644 --- a/src/modules/investments/income/income-analytics.service.ts +++ b/src/modules/investments/income/income-analytics.service.ts @@ -30,6 +30,8 @@ import { rangeBuckets, } from './income-period'; +const STALE_FRESHNESS_MS = 0; + type Total = { coupon: ReturnType; dividend: ReturnType; @@ -120,7 +122,7 @@ export class IncomeAnalyticsService { fetchedAt: current.meta.lastSuccessfulAt ? new Date(current.meta.lastSuccessfulAt) : null, - freshForMs: current.meta.freshness === 'fresh' ? Infinity : 0, + freshForMs: current.meta.freshness === 'fresh' ? Infinity : STALE_FRESHNESS_MS, work: work?.id ? { ...work, id: work.id } : null, coverage: current.meta.coverage, dependencies, diff --git a/src/modules/investments/income/income-period.ts b/src/modules/investments/income/income-period.ts index c7c280d..087d39e 100644 --- a/src/modules/investments/income/income-period.ts +++ b/src/modules/investments/income/income-period.ts @@ -1,6 +1,6 @@ import { BadRequestException } from '@nestjs/common'; -import { MILLISECONDS_PER_DAY } from '../../../common/time'; +import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_DAY } from '../../../common/time'; import { DateOnlyRange, dateParts, @@ -8,6 +8,14 @@ import { utcDate, } from '../investment-calendar'; +const MONDAY_WEEKDAY_INDEX = 1; +const INCLUSIVE_DAY_OFFSET = 1; +const NEXT_DAY_OFFSET = 1; +const PREVIOUS_MONTH_LAST_DAY = 0; +const PREVIOUS_DAY_OFFSET = -1; +const PREVIOUS_YEAR_OFFSET = 1; +const DATE_COMPONENT_DIGITS = 2; + const DAILY_BUCKET_MAX_DAYS = 45; const WEEKLY_BUCKET_MAX_DAYS = 180; const DAYS_PER_WEEK = 7; @@ -65,21 +73,21 @@ export function rangeBuckets( addDays( range.from, -( - (utcDate(range.from).getUTCDay() + DAYS_PER_WEEK - 1) % + (utcDate(range.from).getUTCDay() + DAYS_PER_WEEK - MONDAY_WEEKDAY_INDEX) % DAYS_PER_WEEK ), ) : - `${range.from.slice(0, ISO_MONTH_LENGTH)}-01`; + `${range.from.slice(ISO_DATE_START, ISO_MONTH_LENGTH)}-01`; while (cursor <= range.to) { const end = granularity === 'week' ? - addDays(cursor, DAYS_PER_WEEK - 1) : + addDays(cursor, DAYS_PER_WEEK - INCLUSIVE_DAY_OFFSET) : endOfMonth(cursor); buckets.push({ from: cursor < range.from ? range.from : cursor, to: end > range.to ? range.to : end, }); - cursor = addDays(end, 1); + cursor = addDays(end, NEXT_DAY_OFFSET); } return buckets; } @@ -89,7 +97,7 @@ export function rangeDays(range: DateOnlyRange): number { Math.round( (utcDate(range.to).valueOf() - utcDate(range.from).valueOf()) / MILLISECONDS_PER_DAY, - ) + 1 + ) + INCLUSIVE_DAY_OFFSET ); } @@ -106,24 +114,24 @@ function assertRange(range: DateOnlyRange, today: string): void { function addDays(value: string, days: number): string { const date = utcDate(value); date.setUTCDate(date.getUTCDate() + days); - return date.toISOString().slice(0, 10); + return date.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH); } 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, NEXT_DAY_OFFSET)) { result.push(date); } return result; } function firstOfMonth(value: string): string { - return `${value.slice(0, ISO_MONTH_LENGTH)}-01`; + return `${value.slice(ISO_DATE_START, ISO_MONTH_LENGTH)}-01`; } function endOfMonth(value: string): string { const [year, month] = dateParts(firstOfMonth(value)); - return new Date(Date.UTC(year, month, 0)).toISOString().slice(0, 10); + return new Date(Date.UTC(year, month, PREVIOUS_MONTH_LAST_DAY)).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH); } function isFullMonth(range: DateOnlyRange): boolean { @@ -133,7 +141,7 @@ function isFullMonth(range: DateOnlyRange): boolean { } function previousMonth(from: string): DateOnlyRange { - const last = addDays(firstOfMonth(from), -1); + const last = addDays(firstOfMonth(from), PREVIOUS_DAY_OFFSET); return { from: firstOfMonth(last), to: last }; } @@ -144,8 +152,8 @@ function shiftDays(range: DateOnlyRange, days: number): DateOnlyRange { 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 ? + const result = `${String(year - PREVIOUS_YEAR_OFFSET).padStart(ISO_YEAR_LENGTH, '0')}-${String(month).padStart(DATE_COMPONENT_DIGITS, '0')}-${String(day).padStart(DATE_COMPONENT_DIGITS, '0')}`; + return utcDate(result).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) === result ? result : null; }; diff --git a/src/modules/investments/instrument-query.ts b/src/modules/investments/instrument-query.ts new file mode 100644 index 0000000..df8d3eb --- /dev/null +++ b/src/modules/investments/instrument-query.ts @@ -0,0 +1,2 @@ +export const MIN_INSTRUMENT_QUERY_LENGTH = 2; +export const MAX_INSTRUMENT_QUERY_LENGTH = 80; diff --git a/src/modules/investments/instruments/application/candle.service.ts b/src/modules/investments/instruments/application/candle.service.ts index 86d7ba5..5e17cdc 100644 --- a/src/modules/investments/instruments/application/candle.service.ts +++ b/src/modules/investments/instruments/application/candle.service.ts @@ -27,6 +27,11 @@ import { CandleRepository } from '../infrastructure/candle.repository'; import { CatalogEnrichmentService } from './catalog-enrichment.service'; import { timestamp } from './instrument-market'; +const FIRST_BATCH_OFFSET = 0; +const BACKGROUND_SYNC_PRIORITY = 2; +const REPLACED_WINDOW_COUNT = 1; +const NEXT_WINDOW_OFFSET = 1; + type CandleInput = { instrumentType: 'bond' | 'share'; period: 'all' | 'halfYear' | 'month' | 'year'; @@ -246,7 +251,7 @@ export class CandleService { ) .map(([uid]) => uid); for ( - let offset = 0; + let offset = FIRST_BATCH_OFFSET; offset < uids.length; offset += prefetchReadBatchSize ) { @@ -297,7 +302,7 @@ export class CandleService { now, ).length ) { - await this.request(userId, revision, input, now, false, 2); + await this.request(userId, revision, input, now, false, BACKGROUND_SYNC_PRIORITY); } } } @@ -393,7 +398,7 @@ export class CandleService { const gaps = candleGaps(window, latest.coverage, now) .flatMap(candleWindows) .reverse(); - ranges.splice(index, 1, ...gaps); + ranges.splice(index, REPLACED_WINDOW_COUNT, ...gaps); // eslint-disable-next-line security/detect-object-injection -- The index is the internal numeric window cursor. window = ranges[index]; } @@ -430,7 +435,7 @@ export class CandleService { ) { return false; } - pass.nextWindow = index + 1; + pass.nextWindow = index + NEXT_WINDOW_OFFSET; if (pass.nextWindow < ranges.length) return null; } return true; @@ -512,7 +517,7 @@ export class CandleService { { ...producer.input, period: 'all' }, producer.now, false, - 2, + BACKGROUND_SYNC_PRIORITY, ); } } diff --git a/src/modules/investments/instruments/application/catalog-discovery.service.ts b/src/modules/investments/instruments/application/catalog-discovery.service.ts index 0c2b417..54dc369 100644 --- a/src/modules/investments/instruments/application/catalog-discovery.service.ts +++ b/src/modules/investments/instruments/application/catalog-discovery.service.ts @@ -26,6 +26,10 @@ import { selectCatalogInstruments, } from './catalog-selection'; +const UNAMBIGUOUS_MATCH_COUNT = 1; +const NO_MATCHES = 0; +const FIRST_RESULT_OFFSET = 0; + const tickerAmbiguityLimit = 2; type Producer = { @@ -160,7 +164,7 @@ export class CatalogDiscoveryService { { instrumentType, query: instrument.ticker, exactTicker: true }, observe, ); - if (alias.data?.length === 1 && alias.data[0].uid === uid) { + if (alias.data?.length === UNAMBIGUOUS_MATCH_COUNT && alias.data[0].uid === uid) { slug = instrument.ticker.toUpperCase(); } dependencies.push(alias.meta); @@ -200,8 +204,8 @@ export class CatalogDiscoveryService { { instrumentType, query: ticker, exactTicker: true }, observe, ); - if (lookup.data?.length === 0) throw new NotFoundException(); - if (lookup.data && lookup.data.length > 1) { + if (lookup.data?.length === NO_MATCHES) throw new NotFoundException(); + if (lookup.data && lookup.data.length > UNAMBIGUOUS_MATCH_COUNT) { throw new ConflictException({ code: instrumentType === 'share' ? @@ -346,7 +350,7 @@ export class CatalogDiscoveryService { input.query, input.instrumentType, ).slice( - 0, + FIRST_RESULT_OFFSET, input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit, ), new Date(), @@ -364,7 +368,7 @@ export class CatalogDiscoveryService { if (success) { const saved = await this.repository.read(input); for (const observer of [attempt, ...producer.observers]) { - if (!input.exactTicker || saved.data?.length === 1) { + if (!input.exactTicker || saved.data?.length === UNAMBIGUOUS_MATCH_COUNT) { await this.enrichment.requestMany( observer.userId, observer.revision, diff --git a/src/modules/investments/instruments/application/catalog-enrichment.service.ts b/src/modules/investments/instruments/application/catalog-enrichment.service.ts index 712c1db..edbed0c 100644 --- a/src/modules/investments/instruments/application/catalog-enrichment.service.ts +++ b/src/modules/investments/instruments/application/catalog-enrichment.service.ts @@ -39,6 +39,10 @@ import { import { catalogSearchLimit } from './catalog-selection'; import { previousClose, timestamp } from './instrument-market'; +const MISSING_DATA_REVISION = 0; +const FIRST_QUEUE_INDEX = 0; +const DEQUEUED_ITEM_COUNT = 1; + type Producer = { attempt: TbankSyncAttempt; input: CatalogSource; @@ -147,7 +151,7 @@ export class CatalogEnrichmentService { price: payload?.price ?? null, meta: investmentResourceMeta({ resourceKey: catalogScope(input), - dataRevision: String(row?.dataRevision ?? 0), + dataRevision: String(row?.dataRevision ?? MISSING_DATA_REVISION), availability: row ? 'complete' : 'missing', fetchedAt: row?.fetchedAt ?? null, freshForMs: tbankSourceTtlMs[sourcePolicy(input.kind)], @@ -485,7 +489,7 @@ export class CatalogEnrichmentService { // One batch RPC for queued prices sharing the same authorized producer. if (producer.input.kind === 'last-price') { for ( - let i = 0; + let i = FIRST_QUEUE_INDEX; i < this.queue.length && batch.length < catalogSearchLimit; ) { // eslint-disable-next-line security/detect-object-injection -- Numeric queue indexes and validated instrument UIDs select internal data only. @@ -496,7 +500,7 @@ export class CatalogEnrichmentService { next.attempt.revision === producer.attempt.revision ) { batch.push(next); - this.queue.splice(i, 1); + this.queue.splice(i, DEQUEUED_ITEM_COUNT); } else i++; } } diff --git a/src/modules/investments/instruments/application/instrument-market.ts b/src/modules/investments/instruments/application/instrument-market.ts index a43b3c3..4620119 100644 --- a/src/modules/investments/instruments/application/instrument-market.ts +++ b/src/modules/investments/instruments/application/instrument-market.ts @@ -3,19 +3,21 @@ import type { TbankTimestamp, } from '../../../../integrations/tbank/tbank.api'; -import { MILLISECONDS_PER_SECOND } from '../../../../common/time'; +import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../../../common/time'; + +const MIN_TIMESTAMP_SECONDS_EXCLUSIVE = 0; const MOSCOW_OFFSET_MS = 10_800_000; export function timestamp(value?: TbankTimestamp): null | string { const seconds = Number(value?.seconds); - return Number.isFinite(seconds) && seconds > 0 ? + return Number.isFinite(seconds) && seconds > MIN_TIMESTAMP_SECONDS_EXCLUSIVE ? new Date(seconds * MILLISECONDS_PER_SECOND).toISOString() : null; } export function moscowDay(iso: string): string { return new Date(Date.parse(iso) + MOSCOW_OFFSET_MS) .toISOString() - .slice(0, 10); + .slice(ISO_DATE_START, ISO_DATE_LENGTH); } export function previousClose( candles: TbankCandle[], @@ -29,7 +31,7 @@ export function previousClose( candle.isComplete && candle.close && time && - time.slice(0, 10) < moscowDay(priceAt) + time.slice(ISO_DATE_START, ISO_DATE_LENGTH) < moscowDay(priceAt) ); }) .sort((a, b) => Number(b.time?.seconds) - Number(a.time?.seconds))[0] ?? diff --git a/src/modules/investments/instruments/application/instrument-position.ts b/src/modules/investments/instruments/application/instrument-position.ts index e808f8c..9fdb930 100644 --- a/src/modules/investments/instruments/application/instrument-position.ts +++ b/src/modules/investments/instruments/application/instrument-position.ts @@ -12,6 +12,10 @@ import { } from '../../portfolio/portfolio.api'; import { StockPositionDto } from '../stocks/presentation/stock.dto'; +const EMPTY_POSITIONS_COUNT = 0; +const ZERO_QUANTITY = 0n; +const ZERO_AMOUNT = 0n; + const PRICE_DECIMAL_PLACES = 9; const RETURN_PERCENT_DECIMAL_PLACES = 6; @@ -32,8 +36,8 @@ export function instrumentPosition( const currentValue = sumComplete(positions.map((p) => p.currentValue)); const absoluteReturn = sumComplete(positions.map((p) => p.absoluteReturn)); const positive = - positions.length > 0 && - positions.every((p) => decimalFromString(p.quantity).value > 0n); + positions.length > EMPTY_POSITIONS_COUNT && + positions.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY); return { quantity: formatDecimal(quantity), currentValue, @@ -55,7 +59,7 @@ export function instrumentPosition( accounts: positions.map((p) => ({ ...p, returnPercent: - decimalFromString(p.quantity).value > 0n ? + decimalFromString(p.quantity).value > ZERO_QUANTITY ? percent(p.absoluteReturn, cost(p)) : null, })), @@ -103,7 +107,7 @@ function percent( !base || !result || base.currency !== result.currency || - decimalFromString(base.value).value <= 0n + decimalFromString(base.value).value <= ZERO_AMOUNT ) { return null; } diff --git a/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts b/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts index 0ee3d2b..df5972b 100644 --- a/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts +++ b/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts @@ -14,8 +14,11 @@ import { type IdentifiedBond = TbankBond & { uid: string }; +import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time'; import { money } from '../infrastructure/bond-money.mapper'; +const MIN_LOT_EXCLUSIVE = 0; + @Injectable() export class BondCatalogService { constructor(private readonly discovery: CatalogDiscoveryService) {} @@ -100,13 +103,13 @@ function toBond(bond: IdentifiedBond): BondDto { currency: bond.currency?.toUpperCase() || null, bondType: bond.bondType || null, sector: bond.sector || null, - lot: bond.lot && bond.lot > 0 ? bond.lot : null, + lot: bond.lot && bond.lot > MIN_LOT_EXCLUSIVE ? bond.lot : null, historyFrom: timestamp(bond.first_1dayCandleDate), nominal: money(bond.nominal), initialNominal: money(bond.initialNominal), aci: money(bond.aciValue), - maturityDate: timestamp(bond.maturityDate)?.slice(0, 10) ?? null, - callDate: timestamp(bond.callDate)?.slice(0, 10) ?? null, + maturityDate: timestamp(bond.maturityDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null, + callDate: timestamp(bond.callDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null, couponQuantityPerYear: bond.couponQuantityPerYear ?? null, floatingCoupon: bond.floatingCouponFlag ?? null, perpetual: bond.perpetualFlag ?? null, diff --git a/src/modules/investments/instruments/bonds/application/bond-income.service.ts b/src/modules/investments/instruments/bonds/application/bond-income.service.ts index a9f4869..6096373 100644 --- a/src/modules/investments/instruments/bonds/application/bond-income.service.ts +++ b/src/modules/investments/instruments/bonds/application/bond-income.service.ts @@ -6,6 +6,7 @@ import { formatDecimal, multiply, } from '../../../../../common/decimal'; +import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time'; import { TbankAccessService, type TbankBond, @@ -30,6 +31,12 @@ import { BondPositionDto, } from '../presentation/bond.dto'; import { bondQuote } from './bond-market.service'; + +const INCOME_FORECAST_YEARS = 1; +const ZERO_QUANTITY = 0n; +const MIN_COUPON_PERIOD_EXCLUSIVE = 0; +const ZERO_AMOUNT = 0n; + const ISO_YEAR_LENGTH = 4; const INCOME_HISTORY_YEARS = 5; const COUPON_YIELD_DECIMAL_PLACES = 4; @@ -77,8 +84,8 @@ export class BondIncomeService { ): Promise { const connection = await this.access.local(userId); const today = moscowDay(new Date().toISOString()); - const from = `${Number(today.slice(0, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`; - const to = `${Number(today.slice(0, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`; + const from = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`; + const to = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) + INCOME_FORECAST_YEARS}${today.slice(ISO_YEAR_LENGTH)}`; const portfolio = await this.portfolios.read(userId, 'all', observe); const sources = await this.sources.readMany( userId, @@ -139,13 +146,13 @@ export function bondCoupons( const { from, to, today } = window; const quantity = position?.accounts.length && - position.accounts.every((p) => decimalFromString(p.quantity).value > 0n) ? + position.accounts.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY) ? decimalFromString(position.quantity) : null; const coupons = events .map((event) => { - const paymentDate = timestamp(event.couponDate)?.slice(0, 10) ?? null; - const recordDate = timestamp(event.fixDate)?.slice(0, 10) ?? null; + const paymentDate = timestamp(event.couponDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null; + const recordDate = timestamp(event.fixDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null; const perBond = money(event.payOneBond); const timing = !paymentDate ? ('undated' as const) : @@ -198,10 +205,10 @@ export function bondMetrics( const effectiveYield = values.effectiveYield ?? null; const creditRating = values.creditRating ?? null; const today = moscowDay(new Date().toISOString()); - const to = `${Number(today.slice(0, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`; + const to = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) + INCOME_FORECAST_YEARS}${today.slice(ISO_YEAR_LENGTH)}`; const next = events ?.filter((event) => { - const date = timestamp(event.couponDate)?.slice(0, 10); + const date = timestamp(event.couponDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH); return date && date >= today && date <= to; }) .sort( @@ -210,15 +217,15 @@ export function bondMetrics( const coupon = money(next?.payOneBond); const period = next?.couponPeriod; const couponPeriodDays = - period && Number.isInteger(period) && period > 0 ? period : null; + period && Number.isInteger(period) && period > MIN_COUPON_PERIOD_EXCLUSIVE ? period : null; const clean = quote?.cleanPrice; const couponYieldPercent = coupon && couponPeriodDays && clean && coupon.currency === clean.currency && - decimalFromString(coupon.value).value >= 0n && - decimalFromString(clean.value).value > 0n ? + decimalFromString(coupon.value).value >= ZERO_AMOUNT && + decimalFromString(clean.value).value > ZERO_AMOUNT ? formatDecimal( divide( multiply( @@ -242,7 +249,7 @@ export function bondMetrics( return { creditRating, coupon, - couponDate: timestamp(next?.couponDate)?.slice(0, 10) ?? null, + couponDate: timestamp(next?.couponDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null, couponType, couponPeriodDays, couponYieldPercent, diff --git a/src/modules/investments/instruments/bonds/application/bond-market.service.ts b/src/modules/investments/instruments/bonds/application/bond-market.service.ts index c12b5b8..fbb4fcf 100644 --- a/src/modules/investments/instruments/bonds/application/bond-market.service.ts +++ b/src/modules/investments/instruments/bonds/application/bond-market.service.ts @@ -10,6 +10,7 @@ import { multiply, subtract, } from '../../../../../common/decimal'; +import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time'; import { decimal } from '../../../../../integrations/tbank/decimal'; import { CandleService } from '../../application/candle.service'; import { @@ -24,6 +25,8 @@ import { BondQuoteResourceDto, } from '../presentation/bond.dto'; +const ZERO_AMOUNT = 0n; + const PRICE_DECIMAL_PLACES = 9; @Injectable() export class BondMarketService { @@ -80,7 +83,7 @@ export function bondQuote( const cleanPrice = pricePercent !== null && nominal && - decimalFromString(nominal.value).value > 0n ? + decimalFromString(nominal.value).value > ZERO_AMOUNT ? { currency: nominal.currency, value: formatDecimal( @@ -118,7 +121,7 @@ export function bondQuote( last?.price && previous?.close ? formatDecimal(subtract(decimal(last.price), decimal(previous.close))) : null, - previousCloseDate: timestamp(previous?.time)?.slice(0, 10) ?? null, + previousCloseDate: timestamp(previous?.time)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null, unavailable, observedAt, }; diff --git a/src/modules/investments/instruments/domain/candle-coverage.ts b/src/modules/investments/instruments/domain/candle-coverage.ts index 91e9c5d..3db1224 100644 --- a/src/modules/investments/instruments/domain/candle-coverage.ts +++ b/src/modules/investments/instruments/domain/candle-coverage.ts @@ -1,5 +1,7 @@ import { MILLISECONDS_PER_DAY } from '../../../../common/time'; +const FIRST_RANGE_END_INDEX = 1; + export type CandleRange = { from: Date; to: Date }; export type CandleCoverage = CandleRange & { fetchedAt: Date; @@ -80,7 +82,7 @@ export function confirmedCandleCoverage( } } const sorted = [...boundaries].sort((a, b) => a - b); - return sorted.slice(1).map((to, index) => { + return sorted.slice(FIRST_RANGE_END_INDEX).map((to, index) => { // eslint-disable-next-line security/detect-object-injection -- The index comes from the sorted numeric boundary array. const from = sorted[index]; return { diff --git a/src/modules/investments/instruments/infrastructure/candle.repository.ts b/src/modules/investments/instruments/infrastructure/candle.repository.ts index 8363bde..c9750cf 100644 --- a/src/modules/investments/instruments/infrastructure/candle.repository.ts +++ b/src/modules/investments/instruments/infrastructure/candle.repository.ts @@ -11,6 +11,8 @@ import { type SavedCandle, } from '../domain/candle-coverage'; +const NO_UPDATED_ROWS = 0; + const provider = 'tbank'; const interval = 'day'; type CandleIntent = { @@ -323,7 +325,7 @@ export class CandleRepository { }, data: { state: 'complete', errorCode: null }, }); - return result.count > 0; + return result.count > NO_UPDATED_ROWS; }); } diff --git a/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts b/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts index ae844f1..e0c916a 100644 --- a/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts +++ b/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts @@ -11,6 +11,9 @@ import type { import { PrismaService } from '../../../../prisma/prisma.service'; +const UNCHANGED_REVISION_INCREMENT = 0; +const REVISION_INCREMENT = 1; + export type CatalogQuery = { exactTicker?: boolean; instrumentType: 'bond' | 'share'; @@ -205,7 +208,7 @@ export class CatalogDiscoveryRepository { ticker: instrument.ticker ?? null, fetchedAt: now, dataRevision: { - increment: isDeepStrictEqual(previous.metadata, metadata) ? 0 : 1, + increment: isDeepStrictEqual(previous.metadata, metadata) ? UNCHANGED_REVISION_INCREMENT : REVISION_INCREMENT, }, }, }); @@ -230,8 +233,8 @@ export class CatalogDiscoveryRepository { previousAlias?.matchedUids, matchedUids, ) ? - 0 : - 1, + UNCHANGED_REVISION_INCREMENT : + REVISION_INCREMENT, }, }, }); @@ -257,7 +260,7 @@ export class CatalogDiscoveryRepository { payload, fetchedAt: now, dataRevision: { - increment: isDeepStrictEqual(previous?.payload, payload) ? 0 : 1, + increment: isDeepStrictEqual(previous?.payload, payload) ? UNCHANGED_REVISION_INCREMENT : REVISION_INCREMENT, }, }, }); @@ -390,7 +393,7 @@ export class CatalogDiscoveryRepository { payload, fetchedAt: now, dataRevision: { - increment: isDeepStrictEqual(previous?.payload, payload) ? 0 : 1, + increment: isDeepStrictEqual(previous?.payload, payload) ? UNCHANGED_REVISION_INCREMENT : REVISION_INCREMENT, }, }, }); @@ -416,8 +419,8 @@ export class CatalogDiscoveryRepository { previous?.matchedUids, instrumentUids, ) ? - 0 : - 1, + UNCHANGED_REVISION_INCREMENT : + REVISION_INCREMENT, }, }, }); @@ -442,8 +445,8 @@ export class CatalogDiscoveryRepository { previous?.instrumentUids, instrumentUids, ) ? - 0 : - 1, + UNCHANGED_REVISION_INCREMENT : + REVISION_INCREMENT, }, }, }); diff --git a/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts b/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts index 914d09a..30fc5c9 100644 --- a/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts +++ b/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts @@ -12,6 +12,8 @@ import { StockSearchResourceDto, } from '../presentation/stock.dto'; +const MIN_LOT_EXCLUSIVE = 0; + type IdentifiedShare = TbankShare & { uid: string }; @Injectable() @@ -102,7 +104,7 @@ function stock(share: IdentifiedShare): StockDto { currency: share.currency?.toUpperCase() || null, shareType: share.shareType || null, sector: share.sector || null, - lot: share.lot && share.lot > 0 ? share.lot : null, + lot: share.lot && share.lot > MIN_LOT_EXCLUSIVE ? share.lot : null, historyFrom: timestamp(share.first_1dayCandleDate), }; } diff --git a/src/modules/investments/instruments/stocks/application/stock-income.service.ts b/src/modules/investments/instruments/stocks/application/stock-income.service.ts index 9dd4eb2..dceabb4 100644 --- a/src/modules/investments/instruments/stocks/application/stock-income.service.ts +++ b/src/modules/investments/instruments/stocks/application/stock-income.service.ts @@ -5,6 +5,7 @@ import { formatDecimal, multiply, } from '../../../../../common/decimal'; +import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time'; import { decimal } from '../../../../../integrations/tbank/decimal'; import { TbankAccessService, @@ -23,6 +24,10 @@ import { StockDividendsResourceDto, StockPositionDto, } from '../presentation/stock.dto'; + +const INCOME_FORECAST_YEARS = 1; +const ZERO_QUANTITY = 0n; + const ISO_YEAR_LENGTH = 4; const INCOME_HISTORY_YEARS = 5; @Injectable() @@ -49,8 +54,8 @@ export class StockIncomeService { ): Promise { const connection = await this.access.local(userId); const today = moscowDay(new Date().toISOString()); - const from = `${Number(today.slice(0, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`; - const to = `${Number(today.slice(0, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`; + const from = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`; + const to = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) + INCOME_FORECAST_YEARS}${today.slice(ISO_YEAR_LENGTH)}`; const portfolio = await this.portfolios.read(userId, 'all', observe); const sources = await this.sources.readMany( userId, @@ -112,15 +117,15 @@ export function stockDividends( const quantity = position && position.accounts.length && - position.accounts.every((p) => decimalFromString(p.quantity).value > 0n) ? + position.accounts.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY) ? decimalFromString(position.quantity) : null; const dividends: StockDividendDto[] = events .filter((event) => event.dividendType?.toLowerCase() !== 'cancelled') .map((event) => { - const recordDate = timestamp(event.recordDate)?.slice(0, 10) ?? null; - const paymentDate = timestamp(event.paymentDate)?.slice(0, 10) ?? null; - const lastBuyDate = timestamp(event.lastBuyDate)?.slice(0, 10) ?? null; + const recordDate = timestamp(event.recordDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null; + const paymentDate = timestamp(event.paymentDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null; + const lastBuyDate = timestamp(event.lastBuyDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null; const currency = event.dividendNet?.currency?.toUpperCase(); const perShare = currency && event.dividendNet ? diff --git a/src/modules/investments/instruments/stocks/application/stock-market.service.ts b/src/modules/investments/instruments/stocks/application/stock-market.service.ts index 82dac1d..c4b3912 100644 --- a/src/modules/investments/instruments/stocks/application/stock-market.service.ts +++ b/src/modules/investments/instruments/stocks/application/stock-market.service.ts @@ -7,6 +7,7 @@ import { multiply, subtract, } from '../../../../../common/decimal'; +import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time'; import { decimal } from '../../../../../integrations/tbank/decimal'; import { CandleService } from '../../application/candle.service'; import { @@ -20,6 +21,8 @@ import { StockQuoteResourceDto, } from '../presentation/stock.dto'; +const ZERO_AMOUNT = 0n; + const CHANGE_PERCENT_DECIMAL_PLACES = 6; @Injectable() export class StockMarketService { @@ -90,7 +93,7 @@ export function stockQuote( price, priceAt, tradingStatus, - previousCloseDate: timestamp(previous?.time)?.slice(0, 10) ?? null, + previousCloseDate: timestamp(previous?.time)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null, previousClose: previousPrice, change: difference && currency ? @@ -99,7 +102,7 @@ export function stockQuote( changePercent: difference && previousPrice && - decimalFromString(previousPrice.value).value > 0n ? + decimalFromString(previousPrice.value).value > ZERO_AMOUNT ? formatDecimal( divide( multiply(difference, decimalFromString('100')), diff --git a/src/modules/investments/instruments/stocks/presentation/stock.dto.ts b/src/modules/investments/instruments/stocks/presentation/stock.dto.ts index 4fa0620..ef8ee5f 100644 --- a/src/modules/investments/instruments/stocks/presentation/stock.dto.ts +++ b/src/modules/investments/instruments/stocks/presentation/stock.dto.ts @@ -10,32 +10,36 @@ import { } from 'class-validator'; import { MoneyDto } from '../../../../../common/money.dto'; +import { + MAX_INSTRUMENT_QUERY_LENGTH, + MIN_INSTRUMENT_QUERY_LENGTH, +} from '../../../instrument-query'; import { InvestmentReadQueryDto, InvestmentResourceMetaDto, } from '../../../investment-resource.dto'; import { PortfolioPositionDto } from '../../../portfolio/portfolio.api'; -const MAX_INSTRUMENT_QUERY_LENGTH = 80; +const MIN_TICKER_LENGTH = 1; export class StockSearchQueryDto extends InvestmentReadQueryDto { - @ApiProperty({ minLength: 2, maxLength: MAX_INSTRUMENT_QUERY_LENGTH }) + @ApiProperty({ minLength: MIN_INSTRUMENT_QUERY_LENGTH, maxLength: MAX_INSTRUMENT_QUERY_LENGTH }) @Transform(({ value }: { value: unknown }) => typeof value === 'string' ? value.trim() : value, ) @IsString() - @MinLength(2) + @MinLength(MIN_INSTRUMENT_QUERY_LENGTH) @MaxLength(MAX_INSTRUMENT_QUERY_LENGTH) declare query: string; } export class StockTickerParamsDto { @ApiProperty({ - minLength: 1, + minLength: MIN_TICKER_LENGTH, maxLength: MAX_INSTRUMENT_QUERY_LENGTH, pattern: '^[A-Za-z0-9][A-Za-z0-9._@+-]*$', }) @IsString() - @MinLength(1) + @MinLength(MIN_TICKER_LENGTH) @MaxLength(MAX_INSTRUMENT_QUERY_LENGTH) @Matches(/^[A-Z0-9][\w.@+-]*$/i) declare ticker: string; diff --git a/src/modules/investments/investment-calendar.ts b/src/modules/investments/investment-calendar.ts index c2d76e0..3244748 100644 --- a/src/modules/investments/investment-calendar.ts +++ b/src/modules/investments/investment-calendar.ts @@ -1,3 +1,8 @@ +import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time'; + +const UTC_MONTH_INDEX_OFFSET = 1; +const NEXT_DAY_OFFSET = 1; + const MOSCOW_UTC_OFFSET_HOURS = 3; export type DateOnlyRange = { from: string; to: string }; const moscow = 'Europe/Moscow'; @@ -21,11 +26,11 @@ export function dateOnlyBounds( const from = dateParts(range.from); const end = dateParts(range.to); const to = new Date( - Date.UTC(end[0], end[1] - 1, end[2] + 1, -MOSCOW_UTC_OFFSET_HOURS), + Date.UTC(end[0], end[1] - UTC_MONTH_INDEX_OFFSET, end[2] + NEXT_DAY_OFFSET, -MOSCOW_UTC_OFFSET_HOURS), ); return { from: new Date( - Date.UTC(from[0], from[1] - 1, from[2], -MOSCOW_UTC_OFFSET_HOURS), + Date.UTC(from[0], from[1] - UTC_MONTH_INDEX_OFFSET, from[2], -MOSCOW_UTC_OFFSET_HOURS), ), to: to > now ? now : to, }; @@ -39,7 +44,7 @@ export function dateParts(value: string): [number, number, number] { number, number, ]; - if (utcDate(value).toISOString().slice(0, 10) !== value) { + if (utcDate(value).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) !== value) { throw new Error('Invalid date'); } return parts; @@ -49,6 +54,6 @@ export function utcDate(value: string): Date { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); if (!match) return new Date(NaN); return new Date( - Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])), + Date.UTC(Number(match[1]), Number(match[2]) - UTC_MONTH_INDEX_OFFSET, Number(match[3])), ); } diff --git a/src/modules/investments/investment-sync-plan.ts b/src/modules/investments/investment-sync-plan.ts index e5b01b7..50de3d0 100644 --- a/src/modules/investments/investment-sync-plan.ts +++ b/src/modules/investments/investment-sync-plan.ts @@ -1,3 +1,4 @@ +import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time'; import { planIncomePeriod } from './income/income.api'; import { dateOnlyBounds, moscowDate } from './investment-calendar'; import { @@ -5,6 +6,12 @@ import { operationIntervals, } from './operations/domain/operations-sync-plan'; +const FORECAST_YEARS = 1; +const JANUARY_INDEX = 0; +const FIRST_DAY_OF_MONTH = 1; +const EXCLUSIVE_END_OFFSET_MS = 1; +const NO_END_OFFSET_MS = 0; + export type InvestmentScreen = { accountId?: string; candlePeriod?: 'all' | 'halfYear' | 'month' | 'year'; @@ -232,7 +239,7 @@ export function planInvestmentScreen( const day = screen.screen === 'stock' || screen.screen === 'bond' ? moscowDate(now) : - now.toISOString().slice(0, 10); + now.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH); const from = screen.screen === 'events' && screen.from ? new Date(`${screen.from}T00:00:00Z`) : @@ -250,7 +257,7 @@ export function planInvestmentScreen( } if (screen.screen === 'stock' || screen.screen === 'bond') { from.setUTCFullYear(from.getUTCFullYear() - incomeHistoryYears); - to.setUTCFullYear(to.getUTCFullYear() + 1); + to.setUTCFullYear(to.getUTCFullYear() + FORECAST_YEARS); } sources.push({ ...identity, @@ -325,7 +332,7 @@ export function ledgerRange( new Date(fromValue) : instrumentUid ? new Date(opening ? `${opening}T00:00:00Z` : '1970-01-01T00:00:00Z') : - new Date(Date.UTC(now.getUTCFullYear(), 0, 1)); + new Date(Date.UTC(now.getUTCFullYear(), JANUARY_INDEX, FIRST_DAY_OF_MONTH)); const end = toValue ? new Date( /^\d{4}-\d{2}-\d{2}$/.test(toValue) ? @@ -335,6 +342,6 @@ export function ledgerRange( now; return { from, - to: new Date(Math.min(end.valueOf() + (toValue ? 1 : 0), now.valueOf())), + to: new Date(Math.min(end.valueOf() + (toValue ? EXCLUSIVE_END_OFFSET_MS : NO_END_OFFSET_MS), now.valueOf())), }; } diff --git a/src/modules/investments/operations/application/brokerage-operations-sync.service.ts b/src/modules/investments/operations/application/brokerage-operations-sync.service.ts index 8e635eb..0bf9456 100644 --- a/src/modules/investments/operations/application/brokerage-operations-sync.service.ts +++ b/src/modules/investments/operations/application/brokerage-operations-sync.service.ts @@ -11,6 +11,7 @@ import { accountHistory } from '../../investment-sync-plan'; import { type OperationInterval, operationIntervals, + operationsSyncPriority, planOperationRead, uncoveredOperationIntervals, } from '../domain/operations-sync-plan'; @@ -19,8 +20,9 @@ import { type OperationAttempt, } from '../infrastructure/brokerage-operations.repository'; +const UPSTREAM_RANGE_MARGIN_SECONDS = 1; + const operationsPageSize = 1000; -const priorities = { screen: 1, history: 2 } as const; @Injectable() export class BrokerageOperationsSyncService { @@ -129,7 +131,7 @@ export class BrokerageOperationsSyncService { userId: string, intervals: OperationInterval[], atomic: boolean, - priority: keyof typeof priorities = 'screen', + priority: keyof typeof operationsSyncPriority = 'screen', revision?: string, ): Promise { const connection = await this.access.local(userId); @@ -162,7 +164,7 @@ export class BrokerageOperationsSyncService { atomic, this.runningAttempts, // eslint-disable-next-line security/detect-object-injection -- The priority belongs to the internal priority union. - priorities[priority], + operationsSyncPriority[priority], ); if (!requested) return null; const { id, attemptId, revision } = requested.work; @@ -257,12 +259,12 @@ export class BrokerageOperationsSyncService { // Upstream inclusivity is not assumed; storage selects exact [from,to). from: { seconds: String( - Math.floor(interval.from.valueOf() / MILLISECONDS_PER_SECOND) - 1, + Math.floor(interval.from.valueOf() / MILLISECONDS_PER_SECOND) - UPSTREAM_RANGE_MARGIN_SECONDS, ), }, to: { seconds: String( - Math.ceil(interval.to.valueOf() / MILLISECONDS_PER_SECOND) + 1, + Math.ceil(interval.to.valueOf() / MILLISECONDS_PER_SECOND) + UPSTREAM_RANGE_MARGIN_SECONDS, ), }, ...(interval.instrumentUid ? diff --git a/src/modules/investments/operations/application/brokerage-operations.service.ts b/src/modules/investments/operations/application/brokerage-operations.service.ts index d9bac28..eb6b802 100644 --- a/src/modules/investments/operations/application/brokerage-operations.service.ts +++ b/src/modules/investments/operations/application/brokerage-operations.service.ts @@ -19,6 +19,7 @@ import { } from '../../investment-sync-plan'; import { operationIntervalsOverlap, + operationsSyncPriority, planOperationRead, uncoveredOperationIntervals, } from '../domain/operations-sync-plan'; @@ -30,6 +31,14 @@ import { } from '../presentation/brokerage-operation.dto'; import { BrokerageOperationsSyncService } from './brokerage-operations-sync.service'; +const EXCLUSIVE_END_OFFSET_MS = 1; +const FIRST_RESULT_OFFSET = 0; +const MISSING_MEMBERSHIP_REVISION = 0; +const STALE_FRESHNESS_MS = 0; +const UNKNOWN_AS_OF_MS = 0; +const LAST_ROW_INDEX = -1; +const UNIX_EPOCH_MS = 0; + type LocalOperationsCursor = { accountId: string; at: string; @@ -106,7 +115,7 @@ export class BrokerageOperationsService { screen: 'operations', accountId, from: from.toISOString(), - to: new Date(to.valueOf() - 1).toISOString(), + to: new Date(to.valueOf() - EXCLUSIVE_END_OFFSET_MS).toISOString(), }, { accounts: selected, @@ -244,7 +253,7 @@ export class BrokerageOperationsService { const knownBoundary = accounts.every( (account) => account.openedOn !== null, ); - const rows = saved?.rows.slice(0, limit) ?? []; + const rows = saved?.rows.slice(FIRST_RESULT_OFFSET, limit) ?? []; const instruments = await this.metadata.savedMany( rows.map((row) => row.payload as TbankOperationItem), ); @@ -278,7 +287,7 @@ export class BrokerageOperationsService { } const membershipMeta = investmentResourceMeta({ resourceKey: 'accounts/membership', - dataRevision: String(saved?.connection.membershipRevision ?? 0), + dataRevision: String(saved?.connection.membershipRevision ?? MISSING_MEMBERSHIP_REVISION), availability: available ? 'complete' : 'missing', fetchedAt: saved?.connection.membershipAt ?? null, freshForMs: tbankSourceTtlMs.membership, @@ -296,7 +305,7 @@ export class BrokerageOperationsService { 'missing', fetchedAt: lastSuccessfulAt, freshForMs: plan.intervals.some((interval) => interval.stale) ? - 0 : + STALE_FRESHNESS_MS : Number.POSITIVE_INFINITY, work: work ?? saved?.membershipWork ?? null, dependencies: [membershipMeta, ...metadataDependencies], @@ -321,7 +330,7 @@ export class BrokerageOperationsService { (interval) => interval.accountId === account.accountId, ) - .map((interval) => interval.asOf?.valueOf() ?? 0), + .map((interval) => interval.asOf?.valueOf() ?? UNKNOWN_AS_OF_MS), ), ), ), @@ -329,11 +338,11 @@ export class BrokerageOperationsService { null, historyBoundary: knownBoundary ? 'known' : 'unknown', historyState: saved?.work.some( - (work) => work.state === 'running' && work.priority > 1, + (work) => work.state === 'running' && work.priority > operationsSyncPriority.screen, ) ? 'loading' : saved?.work.some( - (work) => work.state === 'failed' && work.priority > 1, + (work) => work.state === 'failed' && work.priority > operationsSyncPriority.screen, ) ? 'failed' : knownBoundary && @@ -346,7 +355,7 @@ export class BrokerageOperationsService { 'missing', }, }); - const last = rows.at(-1); + const last = rows.at(LAST_ROW_INDEX); const hasNext = Boolean(saved && saved.rows.length > limit); return { data: @@ -448,7 +457,7 @@ export class BrokerageOperationsService { userId, revision: connection.revision, accountId, - from: new Date(0), + from: new Date(UNIX_EPOCH_MS), to: new Date(), categories: ['income'], filterKey: `income/${accountId}`, @@ -486,7 +495,7 @@ export class BrokerageOperationsService { const now = new Date(); const membership = investmentResourceMeta({ resourceKey: 'accounts/membership', - dataRevision: String(saved?.connection.membershipRevision ?? 0), + dataRevision: String(saved?.connection.membershipRevision ?? MISSING_MEMBERSHIP_REVISION), availability: saved?.connection.membershipAt ? 'complete' : 'missing', fetchedAt: saved?.connection.membershipAt ?? null, freshForMs: tbankSourceTtlMs.membership, @@ -559,7 +568,7 @@ export class BrokerageOperationsService { 'partial' : 'missing', fetchedAt, - freshForMs: plan.intervals.some((range) => range.stale) ? 0 : Infinity, + freshForMs: plan.intervals.some((range) => range.stale) ? STALE_FRESHNESS_MS : Infinity, work, dependencies: [membership], coverage: { diff --git a/src/modules/investments/operations/domain/operations-sync-plan.ts b/src/modules/investments/operations/domain/operations-sync-plan.ts index b0e73ca..3105e64 100644 --- a/src/modules/investments/operations/domain/operations-sync-plan.ts +++ b/src/modules/investments/operations/domain/operations-sync-plan.ts @@ -1,3 +1,11 @@ +export const operationsSyncPriority = { screen: 1, history: 2 } as const; + +const LAST_INTERVAL_INDEX = -1; +const NEXT_MONTH_OFFSET = 1; +const FIRST_DAY_OF_MONTH = 1; +const EMPTY_INTERVAL_COUNT = 0; +const EMPTY_SOURCE_COUNT = 0; + export type OperationInterval = { accountId: string; from: Date; @@ -31,7 +39,7 @@ export function operationIntervals( for (const interval of (groups.get(key) ?? []).sort( (left, right) => left.from.valueOf() - right.from.valueOf(), )) { - const previous = merged.at(-1); + const previous = merged.at(LAST_INTERVAL_INDEX); if (previous && interval.from <= previous.to) { previous.to = new Date( Math.max(previous.to.valueOf(), interval.to.valueOf()), @@ -42,7 +50,7 @@ export function operationIntervals( let from = interval.from; while (from < interval.to) { const nextMonth = new Date( - Date.UTC(from.getUTCFullYear(), from.getUTCMonth() + 1, 1), + Date.UTC(from.getUTCFullYear(), from.getUTCMonth() + NEXT_MONTH_OFFSET, FIRST_DAY_OF_MONTH), ); const to = new Date( Math.min(nextMonth.valueOf(), interval.to.valueOf()), @@ -111,7 +119,7 @@ export function planOperationRead( options: { force?: boolean; movingEnd?: boolean } = {}, ) { const monthStart = new Date( - Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1), + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), FIRST_DAY_OF_MONTH), ); const observedEnd = Math.max( ...requested.map((interval) => interval.to.valueOf()), @@ -146,10 +154,10 @@ export function planOperationRead( } } const missing = uncoveredOperationIntervals([effective], ranges); - const complete = missing.length === 0; + const complete = missing.length === EMPTY_INTERVAL_COUNT; const stale = current && - sources.length > 0 && + sources.length > EMPTY_SOURCE_COUNT && uncoveredOperationIntervals( ranges .map((row) => ({ @@ -161,7 +169,7 @@ export function planOperationRead( })) .filter((row) => row.from < row.to), fresh.map((row) => ({ ...row, instrumentUid: target.instrumentUid })), - ).length > 0; + ).length > EMPTY_INTERVAL_COUNT; if (options.force) load.push(target); else if (stale) load.push({ ...target, from: monthStart, to: now }); else load.push(...missing); diff --git a/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts b/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts index 586080e..735c04b 100644 --- a/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts +++ b/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts @@ -10,6 +10,8 @@ import { OperationCategory, } from '../presentation/brokerage-operation.dto'; +const MISSING_TIMESTAMP_COMPONENT = 0; + const NANOSECONDS_PER_MILLISECOND = 1_000_000; const types: Record = { @@ -89,7 +91,7 @@ export function operationTimestamp( value: undefined | { nanos?: number; seconds?: number | string }, ) { return new Date( - Number(value?.seconds ?? 0) * MILLISECONDS_PER_SECOND + - Math.floor((value?.nanos ?? 0) / NANOSECONDS_PER_MILLISECOND), + Number(value?.seconds ?? MISSING_TIMESTAMP_COMPONENT) * MILLISECONDS_PER_SECOND + + Math.floor((value?.nanos ?? MISSING_TIMESTAMP_COMPONENT) / NANOSECONDS_PER_MILLISECOND), ); } diff --git a/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts b/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts index 375df64..2d3adc9 100644 --- a/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts +++ b/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts @@ -22,12 +22,21 @@ import { type OperationInterval, operationIntervals, operationIntervalsOverlap, + operationsSyncPriority, uncoveredOperationIntervals, } from '../domain/operations-sync-plan'; import { operationCategory, operationTimestamp, } from './brokerage-operation.mapper'; + +const CURSOR_LOOKAHEAD_ROWS = 1; +const EMPTY_INTERVAL_COUNT = 0; +const SINGLE_WORK_GROUP = 1; +const INITIAL_PAGE_COUNT = 0; +const PAGE_INCREMENT = 1; +const EXPECTED_UPDATED_WORK_ITEMS = 1; + export type { OperationInterval } from '../domain/operations-sync-plan'; export type OperationAttempt = { @@ -202,7 +211,7 @@ export class BrokerageOperationsRepository { { accountId: 'asc' }, { brokerOperationId: 'asc' }, ], - ...(input.limit === undefined ? {} : { take: input.limit + 1 }), + ...(input.limit === undefined ? {} : { take: input.limit + CURSOR_LOOKAHEAD_ROWS }), }); return { connection, @@ -280,7 +289,7 @@ export class BrokerageOperationsRepository { ) { throw new NotFoundException(); } - if (priority > 1) { + if (priority > operationsSyncPriority.screen) { for (const account of accounts.filter((account) => intervals.some( (interval) => interval.accountId === account.accountId, @@ -331,7 +340,7 @@ export class BrokerageOperationsRepository { to: row.to, instrumentUid: interval.instrumentUid, })), - ).length === 0, + ).length === EMPTY_INTERVAL_COUNT, ); const active = await tx.tbankSyncWork.findMany({ where: { @@ -366,7 +375,7 @@ export class BrokerageOperationsRepository { const upgradeGroup = publicationAtomic && previousIntent && !previousIntent.atomic; if ( - touching.length <= 1 && + touching.length <= SINGLE_WORK_GROUP && previous?.state === 'running' && previous.processRunId === processRunId && runningAttempts.has(previous.attemptId) && @@ -394,10 +403,10 @@ export class BrokerageOperationsRepository { screenIntervals: [ ...new Set([ ...(previousIntent?.screenIntervals ?? - (previous.priority === 1 ? + (previous.priority === operationsSyncPriority.screen ? (previousIntent?.intervals ?? []) : [])), - ...(priority === 1 ? + ...(priority === operationsSyncPriority.screen ? [...pending, ...additions] .filter((item) => intervals.some((target) => @@ -460,7 +469,7 @@ export class BrokerageOperationsRepository { await tx.tbankSyncStage.deleteMany({ where: { workId: previous.id } }); } const targets = - recovering && !upgradeGroup && touching.length <= 1 ? + recovering && !upgradeGroup && touching.length <= SINGLE_WORK_GROUP ? previous?.scopeKey === scopeKey ? retained : [...retained, ...uncoveredOperationIntervals(intervals, retained)] : @@ -491,13 +500,13 @@ export class BrokerageOperationsRepository { screenIntervals: targets .filter( (target) => - (priority === 1 && + (priority === operationsSyncPriority.screen && intervals.some((incoming) => operationIntervalsOverlap(target, incoming), )) || ( previousIntent?.screenIntervals ?? - (previous?.priority === 1 ? + (previous?.priority === operationsSyncPriority.screen ? (previousIntent?.intervals ?? []) : []) ).some((key) => @@ -505,7 +514,7 @@ export class BrokerageOperationsRepository { ), ) .map(intervalKey), - atomic: atomicTargets.length > 0, + atomic: atomicTargets.length > EMPTY_INTERVAL_COUNT, }; const data = { intent, @@ -703,7 +712,7 @@ export class BrokerageOperationsRepository { ...(progress?.seen ?? []), ...(response.hasNext ? [nextCursor] : []), ], - pages: (progress?.pages ?? 0) + 1, + pages: (progress?.pages ?? INITIAL_PAGE_COUNT) + PAGE_INCREMENT, }; await tx.tbankSyncStage.upsert({ where: { @@ -891,11 +900,11 @@ export class BrokerageOperationsRepository { priority: remaining.some((key) => ( intent.screenIntervals ?? - (work.priority === 1 ? intent.intervals : []) + (work.priority === operationsSyncPriority.screen ? intent.intervals : []) ).includes(key), ) ? - 1 : - 2, + operationsSyncPriority.screen : + operationsSyncPriority.history, progress: { remainingIntervals: remaining.length }, }, }); @@ -932,7 +941,7 @@ async function claim( }, data: { updatedAt: new Date() }, }); - return result.count === 1; + return result.count === EXPECTED_UPDATED_WORK_ITEMS; } function decodeInterval(key: string): OperationInterval { diff --git a/src/modules/investments/operations/presentation/brokerage-operation.dto.ts b/src/modules/investments/operations/presentation/brokerage-operation.dto.ts index 224bad9..d9919ac 100644 --- a/src/modules/investments/operations/presentation/brokerage-operation.dto.ts +++ b/src/modules/investments/operations/presentation/brokerage-operation.dto.ts @@ -20,6 +20,8 @@ import { InvestmentResourceMetaDto, } from '../../investment-resource.dto'; +const MIN_OPERATIONS_PAGE_SIZE = 1; + const MAX_CURSOR_LENGTH = 4096; const MAX_OPERATIONS_PAGE_SIZE = 1000; export const DEFAULT_OPERATIONS_PAGE_SIZE = 100; @@ -70,14 +72,14 @@ export class BrokerageOperationsQueryDto extends InvestmentReadQueryDto { cursor?: string; @ApiPropertyOptional({ - minimum: 1, + minimum: MIN_OPERATIONS_PAGE_SIZE, maximum: MAX_OPERATIONS_PAGE_SIZE, default: DEFAULT_OPERATIONS_PAGE_SIZE, }) @IsOptional() @Type(() => Number) @IsInt() - @Min(1) + @Min(MIN_OPERATIONS_PAGE_SIZE) @Max(MAX_OPERATIONS_PAGE_SIZE) limit?: number; } diff --git a/src/modules/investments/portfolio/application/forecast.service.ts b/src/modules/investments/portfolio/application/forecast.service.ts index 389e627..13370d2 100644 --- a/src/modules/investments/portfolio/application/forecast.service.ts +++ b/src/modules/investments/portfolio/application/forecast.service.ts @@ -9,6 +9,9 @@ import { type ForecastSourceVersion, } from '../infrastructure/forecast.repository'; +const MISSING_DATA_REVISION = 0; +const STALE_FRESHNESS_MS = 0; + @Injectable() export class ForecastService { constructor(private readonly repository: ForecastRepository) {} @@ -89,7 +92,7 @@ export class ForecastService { data, meta: investmentResourceMeta({ resourceKey: input.scopeKey, - dataRevision: String(stored?.dataRevision ?? 0), + dataRevision: String(stored?.dataRevision ?? MISSING_DATA_REVISION), availability: stored ? 'complete' : dependencies.some( @@ -100,7 +103,7 @@ export class ForecastService { fetchedAt: first ? new Date(first) : null, freshForMs: data && 'positionIsStale' in data && data.positionIsStale === true ? - 0 : + STALE_FRESHNESS_MS : Number.POSITIVE_INFINITY, work: work?.id ? { ...work, id: work.id } : null, dependencies, diff --git a/src/modules/investments/portfolio/application/portfolio.service.ts b/src/modules/investments/portfolio/application/portfolio.service.ts index faad7a6..dfd9390 100644 --- a/src/modules/investments/portfolio/application/portfolio.service.ts +++ b/src/modules/investments/portfolio/application/portfolio.service.ts @@ -43,6 +43,8 @@ import { PortfolioPositionDto, } from '../presentation/portfolio.dto'; +const MISSING_DATA_REVISION = 0; + const membershipFreshForMs = tbankSourceTtlMs.membership; const types: Record = { share: 'STOCK', @@ -167,7 +169,7 @@ export class PortfolioService { } const membershipMeta = investmentResourceMeta({ resourceKey: 'accounts/membership', - dataRevision: String(saved?.membershipRevision ?? 0), + dataRevision: String(saved?.membershipRevision ?? MISSING_DATA_REVISION), availability: saved?.membershipAt ? 'complete' : 'missing', fetchedAt: saved?.membershipAt ?? null, freshForMs: membershipFreshForMs, @@ -195,7 +197,7 @@ export class PortfolioService { null, meta: investmentResourceMeta({ resourceKey: `portfolio/${encodeURIComponent(account.accountId)}`, - dataRevision: String(account.portfolio?.dataRevision ?? 0), + dataRevision: String(account.portfolio?.dataRevision ?? MISSING_DATA_REVISION), availability: account.portfolio ? 'complete' : 'missing', fetchedAt: account.portfolio?.fetchedAt ?? null, freshForMs: portfolioFreshForMs, diff --git a/src/modules/investments/portfolio/domain/portfolio-structure.ts b/src/modules/investments/portfolio/domain/portfolio-structure.ts index c6e749d..cdc656f 100644 --- a/src/modules/investments/portfolio/domain/portfolio-structure.ts +++ b/src/modules/investments/portfolio/domain/portfolio-structure.ts @@ -2,10 +2,16 @@ import type { Money } from '../../../../common/money.types'; import { add, + DECIMAL_RADIX, decimalFromString, formatDecimal, } from '../../../../common/decimal'; +const ZERO_AMOUNT = 0n; +const INITIAL_UNVALUED_COUNT = 0; +const POSITION_INCREMENT = 1; +const ROUNDING_DIVISOR = 2n; + const SHARE_PERCENT_DECIMAL_PLACES = 6; const PERCENT_SCALED_FACTOR = 100_000_000n; @@ -33,7 +39,7 @@ export function portfolioStructure( const value = decimalFromString(money.value); // eslint-disable-next-line security/detect-object-injection -- The numeric index belongs to the fixed portfolio category array. group.values[index] = add(group.values[index], value); - group.hasNegativeValues ||= value.value < 0n; + group.hasNegativeValues ||= value.value < ZERO_AMOUNT; currencies.set(money.currency, group); }; for (const position of positions) { @@ -41,7 +47,7 @@ export function portfolioStructure( position.type : 'OTHER'; if (position.currentValue) include(type, position.currentValue); - else unvalued.set(type, (unvalued.get(type) ?? 0) + 1); + else unvalued.set(type, (unvalued.get(type) ?? INITIAL_UNVALUED_COUNT) + POSITION_INCREMENT); } for (const amount of cash) include('CASH', amount); @@ -52,7 +58,7 @@ export function portfolioStructure( .map(([currency, group]) => { const total = group.values.reduce(add, decimalFromString('0')); const canCalculateShares = - !unvalued.size && !group.hasNegativeValues && total.value > 0n; + !unvalued.size && !group.hasNegativeValues && total.value > ZERO_AMOUNT; const categories = group.values.map((value, index) => ({ // eslint-disable-next-line security/detect-object-injection -- The numeric index belongs to the fixed portfolio category array. type: categoryTypes[index], @@ -63,9 +69,9 @@ export function portfolioStructure( formatDecimal({ value: (value.value * - 10n ** BigInt(total.scale - value.scale) * + DECIMAL_RADIX ** BigInt(total.scale - value.scale) * PERCENT_SCALED_FACTOR + - total.value / 2n) / + total.value / ROUNDING_DIVISOR) / total.value, scale: SHARE_PERCENT_DECIMAL_PLACES, }) : diff --git a/src/modules/investments/portfolio/infrastructure/forecast.repository.ts b/src/modules/investments/portfolio/infrastructure/forecast.repository.ts index 714192b..8f77124 100644 --- a/src/modules/investments/portfolio/infrastructure/forecast.repository.ts +++ b/src/modules/investments/portfolio/infrastructure/forecast.repository.ts @@ -5,6 +5,8 @@ import type { Prisma } from '../../../../../generated/prisma/client'; import { PrismaService } from '../../../../prisma/prisma.service'; +const FIRST_BATCH_OFFSET = 0; + export type ForecastSourceVersion = { dataRevision: number; fetchedAt: Date; @@ -82,7 +84,7 @@ export class ForecastRepository { // Four identity parameters per source: stay below SQLite's parameter limit. const sources: ForecastSourceVersion[] = []; for ( - let offset = 0; + let offset = FIRST_BATCH_OFFSET; offset < input.sources.length; offset += forecastSourceReadBatchSize ) { diff --git a/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts b/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts index 8144fd0..22f3b5a 100644 --- a/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts +++ b/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts @@ -10,6 +10,8 @@ import { } from '../../../../integrations/tbank/tbank.api'; import { PrismaService } from '../../../../prisma/prisma.service'; +const EXPECTED_UPDATED_ACCOUNTS = 1; + type PortfolioAttempt = { attemptId: string; id: string; @@ -152,7 +154,7 @@ export class PortfolioRepository { }, data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' }, }); - return result.count === 1; + return result.count === EXPECTED_UPDATED_ACCOUNTS; } } diff --git a/src/modules/investments/tbank-sync/tbank-screen.ts b/src/modules/investments/tbank-sync/tbank-screen.ts index e26d79f..99d47f4 100644 --- a/src/modules/investments/tbank-sync/tbank-screen.ts +++ b/src/modules/investments/tbank-sync/tbank-screen.ts @@ -2,6 +2,9 @@ import { BadRequestException } from '@nestjs/common'; import type { InvestmentScreenDto } from './tbank-sync.dto'; +import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../common/time'; +import { MIN_INSTRUMENT_QUERY_LENGTH } from '../instrument-query'; + // Reject fields from other screens before reserving any source work. export function normalizeScreen( input: InvestmentScreenDto, @@ -61,7 +64,7 @@ export function normalizeScreen( } if ( ['bonds', 'stocks'].includes(input.screen) && - (!query || query.length < 2) + (!query || query.length < MIN_INSTRUMENT_QUERY_LENGTH) ) { fail(); } @@ -81,7 +84,7 @@ export function normalizeScreen( if ( input.screen === 'events' && input.to && - (input.from ?? new Date().toISOString().slice(0, 10)) > input.to + (input.from ?? new Date().toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH)) > input.to ) { fail(); } diff --git a/src/modules/investments/tbank-sync/tbank-sync.dto.ts b/src/modules/investments/tbank-sync/tbank-sync.dto.ts index 5f5c467..f7341ac 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.dto.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.dto.ts @@ -21,11 +21,14 @@ import { ValidateNested, } from 'class-validator'; +import { MAX_INSTRUMENT_QUERY_LENGTH, MIN_INSTRUMENT_QUERY_LENGTH } from '../instrument-query'; import { InvestmentResourceMetaDto } from '../investment-resource.dto'; import { operationCategories } from '../operations/operations.api'; +const MIN_ACCOUNT_ID_LENGTH = 1; +const MIN_SYNC_PAGE_SIZE = 1; + const maxAccountIdLength = 256; -const maxQueryLength = 80; const maxPageSize = 1000; export const investmentScreens = [ @@ -51,7 +54,7 @@ export class InvestmentScreenDto { @ApiPropertyOptional() @IsOptional() @IsString() - @MinLength(1) + @MinLength(MIN_ACCOUNT_ID_LENGTH) @MaxLength(maxAccountIdLength) accountId?: string; @@ -83,8 +86,8 @@ export class InvestmentScreenDto { @ApiPropertyOptional() @IsOptional() @IsString() - @MinLength(2) - @MaxLength(maxQueryLength) + @MinLength(MIN_INSTRUMENT_QUERY_LENGTH) + @MaxLength(MAX_INSTRUMENT_QUERY_LENGTH) query?: string; @ApiPropertyOptional({ enum: ['month', 'halfYear', 'year', 'all'] }) @@ -104,10 +107,10 @@ export class InvestmentScreenDto { @IsIn(operationCategories, { each: true }) categories?: (typeof operationCategories)[number][]; - @ApiPropertyOptional({ minimum: 1, maximum: maxPageSize }) + @ApiPropertyOptional({ minimum: MIN_SYNC_PAGE_SIZE, maximum: maxPageSize }) @IsOptional() @IsInt() - @Min(1) + @Min(MIN_SYNC_PAGE_SIZE) @Max(maxPageSize) limit?: number; } diff --git a/src/modules/investments/tbank-sync/tbank-sync.service.ts b/src/modules/investments/tbank-sync/tbank-sync.service.ts index 52f4668..0bb01da 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.service.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.service.ts @@ -8,6 +8,7 @@ import { randomUUID } from 'node:crypto'; import type { InvestmentResourceMetaDto } from '../investment-resource.dto'; +import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../common/time'; import { InstrumentMetadataService, TbankAccessService, @@ -41,6 +42,11 @@ import { TbankSyncRepository, } from './tbank-sync.repository'; +const FOREGROUND_CANDLE_PRIORITY = 0; +const EXCLUSIVE_END_OFFSET_MS = 1; +const PENDING_CHILD_COUNT = 0; +const COMPLETED_CHILD_COUNT = 1; + @Injectable() export class TbankSyncService { private readonly runId = randomUUID(); @@ -279,8 +285,8 @@ export class TbankSyncService { 'bond-events', ], { - from: source.from.toISOString().slice(0, 10), - to: source.to.toISOString().slice(0, 10), + from: source.from.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH), + to: source.to.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH), ticker: positions.find((position) => position.instrumentUid === uid) ?.ticker ?? undefined, @@ -312,7 +318,7 @@ export class TbankSyncService { }, now, true, - 0, + FOREGROUND_CANDLE_PRIORITY, ), ]); } @@ -479,7 +485,7 @@ export class TbankSyncService { await add( this.ledger.read(userId, accountId, { from, - to: new Date(Date.parse(end) - 1).toISOString(), + to: new Date(Date.parse(end) - EXCLUSIVE_END_OFFSET_MS).toISOString(), ...(instrumentUid ? { instrumentUid } : {}), sync: 'observe', }), @@ -509,7 +515,7 @@ export class TbankSyncService { state, errorCode: observed.errorCode, progress: { - completed: state === 'running' ? 0 : 1, + completed: state === 'running' ? PENDING_CHILD_COUNT : COMPLETED_CHILD_COUNT, total: 1, discovering: false, }, diff --git a/src/modules/investments/upcoming-events/upcoming-events.service.ts b/src/modules/investments/upcoming-events/upcoming-events.service.ts index 2f6534c..5c208f3 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.service.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.service.ts @@ -10,7 +10,7 @@ import { multiply, } from '../../../common/decimal'; import { MoneyDto } from '../../../common/money.dto'; -import { MILLISECONDS_PER_SECOND } from '../../../common/time'; +import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../../common/time'; import { decimal } from '../../../integrations/tbank/decimal'; import { InstrumentMetadataService, @@ -30,6 +30,9 @@ import { UpcomingEventsDto, UpcomingEventsQueryDto, } from './upcoming-events.dto'; + +const FIRST_BATCH_OFFSET = 0; + const DEFAULT_EVENT_WINDOW_DAYS = 30; const calendarReadBatchSize = 100; type Position = PortfolioPositionDto; @@ -81,7 +84,7 @@ export class UpcomingEventsService { ); const uids = [...new Set(identities.map((entry) => entry.uid))]; for ( - let offset = 0; + let offset = FIRST_BATCH_OFFSET; offset < uids.length; offset += calendarReadBatchSize ) { @@ -219,7 +222,7 @@ export function dividendEvent( type: 'dividend', date: new Date(Number(event.recordDate?.seconds) * MILLISECONDS_PER_SECOND) .toISOString() - .slice(0, 10), + .slice(ISO_DATE_START, ISO_DATE_LENGTH), dateKind: 'registry', projectedAmount: event.dividendNet ? projectedMoney( @@ -259,10 +262,10 @@ function supported(position: Position): boolean { } function dateWindow(query: UpcomingEventsQueryDto) { - const from = query.from ?? new Date().toISOString().slice(0, 10); + const from = query.from ?? new Date().toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH); const end = new Date(`${from}T00:00:00.000Z`); end.setUTCDate(end.getUTCDate() + DEFAULT_EVENT_WINDOW_DAYS); - const to = query.to ?? end.toISOString().slice(0, 10); + const to = query.to ?? end.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH); if (from > to) { throw new BadRequestException({ code: 'INVALID_EVENTS_PERIOD' }); } diff --git a/test/eslint-config.cjs b/test/eslint-config.cjs index 48446da..c186465 100644 --- a/test/eslint-config.cjs +++ b/test/eslint-config.cjs @@ -45,3 +45,19 @@ test('focused tests are rejected and generated code is excluded', async () => { assert.ok(await eslint.isPathIgnored('generated/prisma/client.ts')); assert.ok(await eslint.isPathIgnored('dist/src/main.js')); }); + +test('numeric literals require names, including previously ignored numbers and bigints', async () => { + const literals = [-60, -24, -10, -2, -1, 0, 1, 2, 10, 24, 60].map(String); + for (const literal of [...literals, ...literals.map((value) => `${value}n`)]) { + const [inline] = await eslint.lintText( + `export function value() { return ${literal}; }`, + { filePath: 'src/main.ts' }, + ); + assert.ok(inline.messages.some(({ ruleId }) => ruleId === '@typescript-eslint/no-magic-numbers'), literal); + const [named] = await eslint.lintText( + `const boundary = ${literal}; export function value() { return boundary; }`, + { filePath: 'src/main.ts' }, + ); + assert.ok(!named.messages.some(({ ruleId }) => ruleId === '@typescript-eslint/no-magic-numbers'), literal); + } +}); From 183b7f1efdafb77f2eb5afb82160f79feb642887 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Mon, 28 Sep 2026 12:33:56 +0300 Subject: [PATCH 12/22] style: align multiline ternary operators --- eslint.config.mjs | 6 +- src/common/filters/http-exception.filter.ts | 20 +-- .../logging/request-logging.middleware.ts | 26 +-- src/config/env.validation.ts | 6 +- .../tbank/instrument-metadata.repository.ts | 24 +-- src/integrations/tbank/tbank.client.ts | 24 +-- .../application/bank-cards.service.ts | 30 ++-- .../application/bank-operations.service.ts | 48 ++--- .../domain/bank-card.calculations.ts | 12 +- .../infrastructure/bank-cards.repository.ts | 6 +- .../brokerage-accounts.service.ts | 36 ++-- .../income/income-analytics.service.ts | 24 +-- .../investments/income/income-period.ts | 42 ++--- .../instruments/application/candle.service.ts | 68 ++++---- .../application/catalog-discovery.service.ts | 92 +++++----- .../application/catalog-enrichment.service.ts | 154 ++++++++-------- .../application/catalog-selection.ts | 12 +- .../application/instrument-market.ts | 6 +- .../application/instrument-position.ts | 22 +-- .../bonds/application/bond-catalog.service.ts | 24 +-- .../bonds/application/bond-income.service.ts | 68 ++++---- .../bonds/application/bond-market.service.ts | 28 +-- .../bonds/application/bond.spec.ts | 6 +- .../bonds/infrastructure/bond-money.mapper.ts | 6 +- .../instruments/domain/candle-coverage.ts | 6 +- .../infrastructure/candle.repository.ts | 26 +-- .../catalog-discovery.repository.ts | 66 +++---- .../application/get-stock-company.use-case.ts | 32 ++-- .../application/stock-catalog.service.ts | 26 +-- .../application/stock-income.service.ts | 50 +++--- .../application/stock-market.service.ts | 40 ++--- .../investments/investment-resource.mapper.ts | 42 ++--- .../investments/investment-sync-plan.ts | 86 ++++----- .../brokerage-operations-sync.service.ts | 20 +-- .../brokerage-operations.service.ts | 164 +++++++++--------- .../operations/domain/operations-sync-plan.ts | 30 ++-- .../brokerage-operation.mapper.ts | 22 +-- .../brokerage-operations.repository.ts | 138 +++++++-------- .../portfolio/application/forecast.service.ts | 42 ++--- .../application/portfolio.service.ts | 80 ++++----- .../portfolio/domain/portfolio-structure.ts | 14 +- .../infrastructure/forecast.repository.ts | 6 +- .../investments/tbank-sync/tbank-screen.ts | 22 +-- .../tbank-sync/tbank-sync.repository.ts | 6 +- .../tbank-sync/tbank-sync.service.ts | 146 ++++++++-------- .../upcoming-events.service.ts | 36 ++-- test/sqlite-regression.cjs | 14 +- test/sqlite.cjs | 10 +- test/tbank-ledger-local.e2e-spec.ts | 6 +- test/tbank-local-reads.e2e-spec.ts | 6 +- test/tbank-metadata-persistence.e2e-spec.ts | 6 +- test/tbank-read-baseline.e2e-spec.ts | 24 +-- test/tbank-screen-sync.e2e-spec.ts | 6 +- test/upcoming-events.e2e-spec.ts | 6 +- 54 files changed, 986 insertions(+), 982 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 02a5136..d5955f1 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -48,7 +48,11 @@ export default defineConfig( 'perfectionist/sort-classes': 'off', '@stylistic/brace-style': ['error', '1tbs', { allowSingleLine: false }], '@stylistic/arrow-parens': ['error', 'always'], - '@stylistic/operator-linebreak': ['error', 'after'], + '@stylistic/operator-linebreak': [ + 'error', + 'after', + { overrides: { '?': 'before', ':': 'before' } }, + ], 'import-x/no-duplicates': ['error', { 'prefer-inline': true }], }, }, diff --git a/src/common/filters/http-exception.filter.ts b/src/common/filters/http-exception.filter.ts index 1e715f7..e228b30 100644 --- a/src/common/filters/http-exception.filter.ts +++ b/src/common/filters/http-exception.filter.ts @@ -15,18 +15,18 @@ export class HttpExceptionFilter implements ExceptionFilter { const context = host.switchToHttp(); const response = context.getResponse(); const status = - exception instanceof HttpException ? - exception.getStatus() : - HttpStatus.INTERNAL_SERVER_ERROR; + exception instanceof HttpException + ? exception.getStatus() + : HttpStatus.INTERNAL_SERVER_ERROR; const payload = exception instanceof HttpException ? exception.getResponse() : undefined; const isValidation = Array.isArray( (payload as { message?: unknown })?.message, ); - const code = isValidation ? - 'VALIDATION_ERROR' : - this.codeFor(status, payload); + const code = isValidation + ? 'VALIDATION_ERROR' + : this.codeFor(status, payload); response.locals ??= {}; response.locals.publicErrorCode = code; response @@ -35,13 +35,13 @@ export class HttpExceptionFilter implements ExceptionFilter { .json({ status, code, - ...(isValidation ? - { + ...(isValidation + ? { errors: this.validationErrors( (payload as { message: ValidationError[] }).message, ), - } : - {}), + } + : {}), }); } diff --git a/src/common/logging/request-logging.middleware.ts b/src/common/logging/request-logging.middleware.ts index 10fcd9b..a942167 100644 --- a/src/common/logging/request-logging.middleware.ts +++ b/src/common/logging/request-logging.middleware.ts @@ -33,26 +33,26 @@ export class RequestLoggingMiddleware implements NestMiddleware { const routePath = (routeRequest as { route?: { path?: unknown } }).route ?.path; const route = - typeof routePath === 'string' ? - `${request.baseUrl}${routePath}` : - ''; + typeof routePath === 'string' + ? `${request.baseUrl}${routePath}` + : ''; const context = { method: request.method, route, status: response.statusCode, durationMs: Math.round(performance.now() - startedAt), - ...(response.locals.publicErrorCode ? - { code: response.locals.publicErrorCode as string } : - {}), + ...(response.locals.publicErrorCode + ? { code: response.locals.publicErrorCode as string } + : {}), }; const level = - response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR ? - 'error' : - response.statusCode === httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS || - response.statusCode === httpStatus.HTTP_STATUS_UNAUTHORIZED || - response.statusCode === httpStatus.HTTP_STATUS_FORBIDDEN ? - 'warn' : - 'info'; + response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR + ? 'error' + : response.statusCode === httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS || + response.statusCode === httpStatus.HTTP_STATUS_UNAUTHORIZED || + response.statusCode === httpStatus.HTTP_STATUS_FORBIDDEN + ? 'warn' + : 'info'; this.logger.event(level, 'http.request.completed', context); }); diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index ac378db..e22b3f8 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -105,9 +105,9 @@ export function validateEnv(config: Record): Environment { JWT_SECRET: jwtSecret, PORT: parsedPort, TOKEN_ENCRYPTION_KEY: tokenEncryptionKey, - ...(typeof tbankCaCertPath === 'string' && tbankCaCertPath ? - { TBANK_CA_CERT_PATH: tbankCaCertPath } : - {}), + ...(typeof tbankCaCertPath === 'string' && tbankCaCertPath + ? { TBANK_CA_CERT_PATH: tbankCaCertPath } + : {}), TBANK_GRPC_ENDPOINT: tbankEndpoint, TBANK_RATE_LIMIT_PER_SECOND: parsedTbankRateLimit, LOG_LEVEL: logLevel as Environment['LOG_LEVEL'], diff --git a/src/integrations/tbank/instrument-metadata.repository.ts b/src/integrations/tbank/instrument-metadata.repository.ts index 277dd99..7fc6819 100644 --- a/src/integrations/tbank/instrument-metadata.repository.ts +++ b/src/integrations/tbank/instrument-metadata.repository.ts @@ -43,12 +43,12 @@ export class InstrumentMetadataRepository { async readMany(identities: MetadataIdentity[]) { const identifiers = identities.flatMap((identity) => [ - ...(identity.instrumentUid?.trim() ? - [{ identifierType: 'uid', identifier: identity.instrumentUid.trim() }] : - []), - ...(identity.figi?.trim() ? - [{ identifierType: 'figi', identifier: identity.figi.trim() }] : - []), + ...(identity.instrumentUid?.trim() + ? [{ identifierType: 'uid', identifier: identity.instrumentUid.trim() }] + : []), + ...(identity.figi?.trim() + ? [{ identifierType: 'figi', identifier: identity.figi.trim() }] + : []), ]); if (!identifiers.length) { return new Map>(); @@ -233,9 +233,9 @@ export class InstrumentMetadataRepository { update: { ...data, dataRevision: { - increment: isDeepStrictEqual(previous?.metadata, payload) ? - UNCHANGED_REVISION_INCREMENT : - REVISION_INCREMENT, + increment: isDeepStrictEqual(previous?.metadata, payload) + ? UNCHANGED_REVISION_INCREMENT + : REVISION_INCREMENT, }, }, }); @@ -256,9 +256,9 @@ export class InstrumentMetadataRepository { matchedUids, fetchedAt: now, dataRevision: { - increment: isDeepStrictEqual(previous?.matchedUids, matchedUids) ? - UNCHANGED_REVISION_INCREMENT : - REVISION_INCREMENT, + increment: isDeepStrictEqual(previous?.matchedUids, matchedUids) + ? UNCHANGED_REVISION_INCREMENT + : REVISION_INCREMENT, }, }, }); diff --git a/src/integrations/tbank/tbank.client.ts b/src/integrations/tbank/tbank.client.ts index 62101ab..3cdcfb8 100644 --- a/src/integrations/tbank/tbank.client.ts +++ b/src/integrations/tbank/tbank.client.ts @@ -205,10 +205,10 @@ export class TbankClient { loadPackageDefinition(definition) as unknown as TbankNamespace ).tinkoff.public.invest.api.contract.v1; const caPath = config.get('TBANK_CA_CERT_PATH'); - const credentials = caPath ? + const credentials = caPath // eslint-disable-next-line security/detect-non-literal-fs-filename -- The CA path is trusted operator configuration, never supplied by an HTTP request. - ChannelCredentials.createSsl(readFileSync(caPath)) : - ChannelCredentials.createSsl(); + ? ChannelCredentials.createSsl(readFileSync(caPath)) + : ChannelCredentials.createSsl(); const endpoint = config.getOrThrow('TBANK_GRPC_ENDPOINT'); const create = (name: string) => new ( @@ -497,12 +497,12 @@ export class TbankClient { return response; } catch (error) { const grpcCode = (error as ServiceError).code; - const failureContext = queueFull ? - { code: 'TBANK_UNAVAILABLE' } : - { grpcCode }; - const retryable = queueFull ? - metadataCall : - [ + const failureContext = queueFull + ? { code: 'TBANK_UNAVAILABLE' } + : { grpcCode }; + const retryable = queueFull + ? metadataCall + : [ status.DEADLINE_EXCEEDED, status.RESOURCE_EXHAUSTED, status.UNAVAILABLE, @@ -517,9 +517,9 @@ export class TbankClient { await new Promise((resolve) => setTimeout( resolve, - metadataCall ? - attempts * METADATA_RETRY_DELAY_MS : - RPC_RETRY_DELAY_MS, + metadataCall + ? attempts * METADATA_RETRY_DELAY_MS + : RPC_RETRY_DELAY_MS, ), ); continue; diff --git a/src/modules/banking/bank-cards/application/bank-cards.service.ts b/src/modules/banking/bank-cards/application/bank-cards.service.ts index 2c5d92b..7a777f9 100644 --- a/src/modules/banking/bank-cards/application/bank-cards.service.ts +++ b/src/modules/banking/bank-cards/application/bank-cards.service.ts @@ -103,17 +103,17 @@ export class BankCardsService { id, name: dto.name ?? previous.name, bankName: - dto.bankName === undefined ? - (previous.bankName ?? undefined) : - (dto.bankName ?? undefined), + dto.bankName === undefined + ? (previous.bankName ?? undefined) + : (dto.bankName ?? undefined), lastFour: - dto.lastFour === undefined ? - (previous.lastFour ?? undefined) : - (dto.lastFour ?? undefined), + dto.lastFour === undefined + ? (previous.lastFour ?? undefined) + : (dto.lastFour ?? undefined), paymentSystem: - dto.paymentSystem === undefined ? - previous.paymentSystem : - dto.paymentSystem, + dto.paymentSystem === undefined + ? previous.paymentSystem + : dto.paymentSystem, openingBalance: dto.openingBalance ?? rubMoney(previous.openingBalanceKopecks), openingDate: dto.openingDate ?? previous.openingDate, @@ -143,12 +143,12 @@ export class BankCardsService { ...input, paymentSystem: input.paymentSystem ?? null, version: { increment: 1 }, - ...(dto.archived === undefined ? - {} : - { - archivedAt: dto.archived ? - (previous.archivedAt ?? new Date()) : - null, + ...(dto.archived === undefined + ? {} + : { + archivedAt: dto.archived + ? (previous.archivedAt ?? new Date()) + : null, }), }, }); diff --git a/src/modules/banking/bank-cards/application/bank-operations.service.ts b/src/modules/banking/bank-cards/application/bank-operations.service.ts index eef0336..870748b 100644 --- a/src/modules/banking/bank-cards/application/bank-operations.service.ts +++ b/src/modules/banking/bank-cards/application/bank-operations.service.ts @@ -43,29 +43,29 @@ export class BankOperationsService { row: BankCardOperation, ): Promise { const card = await this.repository.card(tx, row.userId, row.cardId); - const target = row.targetCardId ? - await this.repository.card(tx, row.userId, row.targetCardId) : - null; - const source = row.originalExpenseId ? - await this.repository.operation(tx, row.userId, row.originalExpenseId) : - null; + const target = row.targetCardId + ? await this.repository.card(tx, row.userId, row.targetCardId) + : null; + const source = row.originalExpenseId + ? await this.repository.operation(tx, row.userId, row.originalExpenseId) + : null; const categoryId = source?.categoryId ?? row.categoryId; - const category = categoryId ? - await tx.expenseCategory.findFirst({ + const category = categoryId + ? await tx.expenseCategory.findFirst({ where: { id: categoryId, userId: row.userId }, - }) : - null; + }) + : null; const refunds = - row.kind === BankOperationKind.Expense || source ? - await tx.bankCardOperation.findMany({ + row.kind === BankOperationKind.Expense || source + ? await tx.bankCardOperation.findMany({ where: { userId: row.userId, originalExpenseId: source?.id ?? row.id, deletedAt: null, ...(source ? { id: { not: row.id } } : {}), }, - }) : - []; + }) + : []; return { id: row.id, kind: row.kind as BankOperationDto['kind'], @@ -78,12 +78,12 @@ export class BankOperationsService { originalExpenseId: row.originalExpenseId, amount: rubMoney(row.amountKopecks), availableRefund: - row.kind === BankOperationKind.Expense || source ? - rubMoney( + row.kind === BankOperationKind.Expense || source + ? rubMoney( (source?.amountKopecks ?? row.amountKopecks) - refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS), - ) : - null, + ) + : null, date: row.date, description: row.description, version: row.version, @@ -336,9 +336,9 @@ export class BankOperationsService { query: BankOperationsQueryDto | BankPageQueryDto, expenses = false, ) { - const period = expenses ? - undefined : - periodBounds(query as BankOperationsQueryDto); + const period = expenses + ? undefined + : periodBounds(query as BankOperationsQueryDto); return this.prisma.$transaction(async (tx) => { await this.repository.card(tx, userId, id); const limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE, @@ -347,9 +347,9 @@ export class BankOperationsService { userId, deletedAt: null, OR: [{ cardId: id }, { targetCardId: id }], - ...(period ? - { date: { gte: period.from, lte: period.to } } : - { kind: BankOperationKind.Expense }), + ...(period + ? { date: { gte: period.from, lte: period.to } } + : { kind: BankOperationKind.Expense }), }; const total = await tx.bankCardOperation.count({ where }); const rows = await tx.bankCardOperation.findMany({ diff --git a/src/modules/banking/bank-cards/domain/bank-card.calculations.ts b/src/modules/banking/bank-cards/domain/bank-card.calculations.ts index cf1a0a3..cba24a7 100644 --- a/src/modules/banking/bank-cards/domain/bank-card.calculations.ts +++ b/src/modules/banking/bank-cards/domain/bank-card.calculations.ts @@ -81,13 +81,13 @@ export function bankSummary( continue; } const expense = - operation.kind === BankOperationKind.Expense ? - operation.amountKopecks : - ZERO_KOPECKS; + operation.kind === BankOperationKind.Expense + ? operation.amountKopecks + : ZERO_KOPECKS; const refund = - operation.kind === BankOperationKind.Refund ? - operation.amountKopecks : - ZERO_KOPECKS; + operation.kind === BankOperationKind.Refund + ? operation.amountKopecks + : ZERO_KOPECKS; expenses += expense; refunds += refund; if (operation.categoryId !== null) { diff --git a/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts b/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts index 2c5e9aa..52a54e4 100644 --- a/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts +++ b/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts @@ -40,9 +40,9 @@ export class BankCardsRepository { }); } catch (error) { const code = - error instanceof Prisma.PrismaClientKnownRequestError ? - error.code : - ''; + error instanceof Prisma.PrismaClientKnownRequestError + ? error.code + : ''; const busy = code === 'P2034' || code === 'P2028' || diff --git a/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts index d2c1234..8cce797 100644 --- a/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts @@ -12,15 +12,15 @@ export class BrokerageAccountsService { async list(userId: string, observe = false) { const saved = await this.portfolio.saved(userId, 'all', observe); const data: BrokerageAccountDto[] | null = - saved.membership.availability === 'missing' ? - null : - saved.accounts.map((account) => ({ + saved.membership.availability === 'missing' + ? null + : saved.accounts.map((account) => ({ id: account.id, name: account.name, type: account.type as BrokerageAccountDto['type'], openedOn: account.openedOn, - portfolioSummary: account.snapshot ? - { + portfolioSummary: account.snapshot + ? { totalsByCurrency: account.snapshot.totalsByCurrency, stockPositionCount: account.snapshot.positions.filter( (position) => position.type === 'STOCK', @@ -28,8 +28,8 @@ export class BrokerageAccountsService { bondPositionCount: account.snapshot.positions.filter( (position) => position.type === 'BOND', ).length, - } : - null, + } + : null, })); return { data, @@ -47,18 +47,18 @@ export class BrokerageAccountsService { ) .digest('base64url'), availability: - data === null ? - 'missing' : - saved.accounts.some((account) => !account.snapshot) ? - 'partial' : - 'complete', - fetchedAt: saved.membership.lastSuccessfulAt ? - new Date(saved.membership.lastSuccessfulAt) : - null, + data === null + ? 'missing' + : saved.accounts.some((account) => !account.snapshot) + ? 'partial' + : 'complete', + fetchedAt: saved.membership.lastSuccessfulAt + ? new Date(saved.membership.lastSuccessfulAt) + : null, freshForMs: 300_000, - work: saved.membership.sync.id ? - { ...saved.membership.sync, id: saved.membership.sync.id } : - null, + work: saved.membership.sync.id + ? { ...saved.membership.sync, id: saved.membership.sync.id } + : null, dependencies: saved.accounts.map((account) => account.meta), }), }; diff --git a/src/modules/investments/income/income-analytics.service.ts b/src/modules/investments/income/income-analytics.service.ts index 5072d42..0c8e551 100644 --- a/src/modules/investments/income/income-analytics.service.ts +++ b/src/modules/investments/income/income-analytics.service.ts @@ -96,9 +96,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 +119,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 : STALE_FRESHNESS_MS, work: work?.id ? { ...work, id: work.id } : null, coverage: current.meta.coverage, @@ -144,13 +144,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), diff --git a/src/modules/investments/income/income-period.ts b/src/modules/investments/income/income-period.ts index 087d39e..32f91699 100644 --- a/src/modules/investments/income/income-period.ts +++ b/src/modules/investments/income/income-period.ts @@ -39,24 +39,24 @@ export function planIncomePeriod( 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', }; } @@ -69,20 +69,20 @@ export function rangeBuckets( } const buckets: DateOnlyRange[] = []; let cursor = - granularity === 'week' ? - addDays( + granularity === 'week' + ? addDays( range.from, -( (utcDate(range.from).getUTCDay() + DAYS_PER_WEEK - MONDAY_WEEKDAY_INDEX) % DAYS_PER_WEEK ), - ) : - `${range.from.slice(ISO_DATE_START, ISO_MONTH_LENGTH)}-01`; + ) + : `${range.from.slice(ISO_DATE_START, ISO_MONTH_LENGTH)}-01`; while (cursor <= range.to) { const end = - granularity === 'week' ? - addDays(cursor, DAYS_PER_WEEK - INCLUSIVE_DAY_OFFSET) : - endOfMonth(cursor); + granularity === 'week' + ? addDays(cursor, DAYS_PER_WEEK - INCLUSIVE_DAY_OFFSET) + : endOfMonth(cursor); buckets.push({ from: cursor < range.from ? range.from : cursor, to: end > range.to ? range.to : end, @@ -153,9 +153,9 @@ function yearAgo(range: DateOnlyRange): DateOnlyRange | null { const shift = (value: string) => { const [year, month, day] = dateParts(value); const result = `${String(year - PREVIOUS_YEAR_OFFSET).padStart(ISO_YEAR_LENGTH, '0')}-${String(month).padStart(DATE_COMPONENT_DIGITS, '0')}-${String(day).padStart(DATE_COMPONENT_DIGITS, '0')}`; - return utcDate(result).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) === result ? - result : - null; + return utcDate(result).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) === result + ? result + : null; }; const from = shift(range.from); const to = shift(range.to); diff --git a/src/modules/investments/instruments/application/candle.service.ts b/src/modules/investments/instruments/application/candle.service.ts index 5e17cdc..9566d4e 100644 --- a/src/modules/investments/instruments/application/candle.service.ts +++ b/src/modules/investments/instruments/application/candle.service.ts @@ -70,9 +70,9 @@ function rangeFor( if (!history) { throw new ServiceUnavailableException({ code: - input.instrumentType === 'share' ? - 'STOCK_HISTORY_START_UNAVAILABLE' : - 'BOND_HISTORY_START_UNAVAILABLE', + input.instrumentType === 'share' + ? 'STOCK_HISTORY_START_UNAVAILABLE' + : 'BOND_HISTORY_START_UNAVAILABLE', }); } from.setTime(utcDay(new Date(history)).valueOf()); @@ -114,9 +114,9 @@ export class CandleService { throw new NotFoundException(); } const now = new Date(); - const range = detail.instrument ? - rangeFor(input, detail.instrument, now) : - null; + const range = detail.instrument + ? rangeFor(input, detail.instrument, now) + : null; const saved = range ? await this.repository.read(input.uid, range) : null; const gaps = range && saved ? candleGaps(range, saved.coverage, now) : []; if (!observe && (!saved || gaps.length)) { @@ -139,14 +139,14 @@ export class CandleService { }); return { data: - !saved || !covered ? - null : - { + !saved || !covered + ? null + : { candles: saved.data, currency: - input.instrumentType === 'share' ? - detail.instrument?.currency?.toUpperCase() || null : - null, + input.instrumentType === 'share' + ? detail.instrument?.currency?.toUpperCase() || null + : null, historyFrom: timestamp(detail?.instrument?.first_1dayCandleDate), observedAt: fetchedAt?.toISOString() ?? now.toISOString(), }, @@ -159,8 +159,8 @@ export class CandleService { work, coverage: { requested: range ? [publicRange(range)] : [], - completeRanges: range ? - completeCoverage.flatMap((row) => { + completeRanges: range + ? completeCoverage.flatMap((row) => { const from = new Date( Math.max(row.from.valueOf(), range.from.valueOf()), ); @@ -168,22 +168,22 @@ export class CandleService { Math.min(row.to.valueOf(), range.to.valueOf()), ); return from < to ? [publicRange({ from, to })] : []; - }) : - [], + }) + : [], missingRanges: missing.map(publicRange), asOf: fetchedAt?.toISOString() ?? null, - historyBoundary: timestamp(detail?.instrument?.first_1dayCandleDate) ? - 'known' : - 'unknown', - historyState: complete ? - 'complete' : - work?.state === 'failed' ? - 'failed' : - work?.state === 'running' ? - 'loading' : - covered ? - 'partial' : - 'missing', + historyBoundary: timestamp(detail?.instrument?.first_1dayCandleDate) + ? 'known' + : 'unknown', + historyState: complete + ? 'complete' + : work?.state === 'failed' + ? 'failed' + : work?.state === 'running' + ? 'loading' + : covered + ? 'partial' + : 'missing', }, dependencies: [ detail.meta, @@ -193,9 +193,9 @@ export class CandleService { dataRevision: String(row.dataRevision), availability: 'complete', fetchedAt: row.fetchedAt, - freshForMs: row.isComplete ? - Number.POSITIVE_INFINITY : - tailFreshForMs, + freshForMs: row.isComplete + ? Number.POSITIVE_INFINITY + : tailFreshForMs, work: null, }), ), @@ -291,9 +291,9 @@ export class CandleService { ) { const input = { uid, instrumentType, period: 'all' as const }; const range = - detail?.instrument && history ? - rangeFor(input, detail.instrument, now) : - null; + detail?.instrument && history + ? rangeFor(input, detail.instrument, now) + : null; if ( !range || candleGaps( diff --git a/src/modules/investments/instruments/application/catalog-discovery.service.ts b/src/modules/investments/instruments/application/catalog-discovery.service.ts index 54dc369..6105b3a 100644 --- a/src/modules/investments/instruments/application/catalog-discovery.service.ts +++ b/src/modules/investments/instruments/application/catalog-discovery.service.ts @@ -79,14 +79,14 @@ export class CatalogDiscoveryService { async search(userId: string, input: CatalogQuery, observe = false) { const discovery = await this.read(userId, input, observe); - const sources = discovery.data ? - await this.enrichment.readMany( + const sources = discovery.data + ? await this.enrichment.readMany( userId, input.instrumentType, discovery.data.map((item) => item.uid), observe, - ) : - []; + ) + : []; const dependencies = [ discovery.meta, ...sources.map((source) => source.meta), @@ -114,22 +114,22 @@ export class CatalogDiscoveryService { .map((dependency) => dependency.dataRevision) .join('/'), availability: - discovery.data === null ? - 'missing' : - sources.every((source) => source.meta.availability === 'complete') ? - 'complete' : - 'partial', - fetchedAt: discovery.meta.lastSuccessfulAt ? - new Date(discovery.meta.lastSuccessfulAt) : - null, + discovery.data === null + ? 'missing' + : sources.every((source) => source.meta.availability === 'complete') + ? 'complete' + : 'partial', + fetchedAt: discovery.meta.lastSuccessfulAt + ? new Date(discovery.meta.lastSuccessfulAt) + : null, freshForMs: tbankSourceTtlMs.catalog, - work: discovery.meta.sync.id ? - { + work: discovery.meta.sync.id + ? { id: discovery.meta.sync.id, state: discovery.meta.sync.state, errorCode: discovery.meta.sync.errorCode, - } : - null, + } + : null, dependencies, }), }; @@ -177,17 +177,17 @@ export class CatalogDiscoveryService { .map((dependency) => dependency.dataRevision) .join('/'), availability: source.meta.availability, - fetchedAt: source.meta.lastSuccessfulAt ? - new Date(source.meta.lastSuccessfulAt) : - null, + fetchedAt: source.meta.lastSuccessfulAt + ? new Date(source.meta.lastSuccessfulAt) + : null, freshForMs: tbankSourceTtlMs.metadata, - work: source.meta.sync.id ? - { + work: source.meta.sync.id + ? { id: source.meta.sync.id, state: source.meta.sync.state, errorCode: source.meta.sync.errorCode, - } : - null, + } + : null, dependencies, }), }; @@ -208,36 +208,36 @@ export class CatalogDiscoveryService { if (lookup.data && lookup.data.length > UNAMBIGUOUS_MATCH_COUNT) { throw new ConflictException({ code: - instrumentType === 'share' ? - 'STOCK_TICKER_AMBIGUOUS' : - 'BOND_TICKER_AMBIGUOUS', + instrumentType === 'share' + ? 'STOCK_TICKER_AMBIGUOUS' + : 'BOND_TICKER_AMBIGUOUS', }); } - const result = lookup.data ? - await this.detail(userId, instrumentType, lookup.data[0].uid, observe) : - null; + const result = lookup.data + ? await this.detail(userId, instrumentType, lookup.data[0].uid, observe) + : null; const dependencies = [lookup.meta, ...(result ? [result.meta] : [])]; return { - data: result?.data ? - { ...result.data, slug: ticker.trim().toUpperCase() } : - null, + data: result?.data + ? { ...result.data, slug: ticker.trim().toUpperCase() } + : null, meta: investmentResourceMeta({ resourceKey: `${instrumentType}/ticker/${ticker.trim().toUpperCase()}`, dataRevision: dependencies .map((dependency) => dependency.dataRevision) .join('/'), availability: result?.meta.availability ?? 'missing', - fetchedAt: lookup.meta.lastSuccessfulAt ? - new Date(lookup.meta.lastSuccessfulAt) : - null, + fetchedAt: lookup.meta.lastSuccessfulAt + ? new Date(lookup.meta.lastSuccessfulAt) + : null, freshForMs: tbankSourceTtlMs.metadata, - work: lookup.meta.sync.id ? - { + work: lookup.meta.sync.id + ? { id: lookup.meta.sync.id, state: lookup.meta.sync.state, errorCode: lookup.meta.sync.errorCode, - } : - null, + } + : null, dependencies, }), }; @@ -334,19 +334,19 @@ export class CatalogDiscoveryService { const token = await this.access.tokenForAttempt(attempt); if (token) { const found = - input.instrumentType === 'share' ? - await this.tbank.findShares(token, input.query) : - await this.tbank.findBonds(token, input.query); + input.instrumentType === 'share' + ? await this.tbank.findShares(token, input.query) + : await this.tbank.findBonds(token, input.query); success = await this.repository.publish( attempt, input, selectCatalogInstruments( - input.exactTicker ? - found.filter( + input.exactTicker + ? found.filter( (item) => item.ticker?.trim().toUpperCase() === input.query, - ) : - found, + ) + : found, input.query, input.instrumentType, ).slice( diff --git a/src/modules/investments/instruments/application/catalog-enrichment.service.ts b/src/modules/investments/instruments/application/catalog-enrichment.service.ts index edbed0c..1b8e892 100644 --- a/src/modules/investments/instruments/application/catalog-enrichment.service.ts +++ b/src/modules/investments/instruments/application/catalog-enrichment.service.ts @@ -124,9 +124,9 @@ export class CatalogEnrichmentService { range, ); } - const work = inputs.length ? - await this.repository.workMany(userId, revision, inputs) : - []; + const work = inputs.length + ? await this.repository.workMany(userId, revision, inputs) + : []; const byScope = new Map(work.map((row) => [row.scopeKey, row])); return inputs.map((input) => { const row = rows.find( @@ -136,16 +136,16 @@ export class CatalogEnrichmentService { const payload = row?.payload as CatalogPayload | undefined; return { input, - version: row ? - { + version: row + ? { instrumentUid: row.instrumentUid, source: row.source, kind: row.kind, rangeKey: row.rangeKey, dataRevision: row.dataRevision, fetchedAt: row.fetchedAt, - } : - null, + } + : null, payload, instrument: payload?.instrument ?? null, price: payload?.price ?? null, @@ -177,9 +177,9 @@ export class CatalogEnrichmentService { ) { const inputs = sourceInputs(instrumentType, uids, kinds, range); const reservation = this.reservations.then(async () => { - const rows = inputs.length ? - await this.repository.readSources(inputs) : - []; + const rows = inputs.length + ? await this.repository.readSources(inputs) + : []; const ids: string[] = []; for (const input of inputs) { const row = rows.find( @@ -242,18 +242,18 @@ export class CatalogEnrichmentService { observe = false, ) { const kinds: CatalogSource['kind'][] = - section === 'company' ? - ['detail', 'description', 'fundamentals'] : - [ - ...(instrumentType === 'bond' ? - ['bond-current' as const] : - ['detail' as const]), + section === 'company' + ? ['detail', 'description', 'fundamentals'] + : [ + ...(instrumentType === 'bond' + ? ['bond-current' as const] + : ['detail' as const]), 'last-price', 'trading-status', 'comparison', - ...(section === 'metrics' ? - ['coupons' as const, 'yield' as const, 'rating' as const] : - []), + ...(section === 'metrics' + ? ['coupons' as const, 'yield' as const, 'rating' as const] + : []), ]; const sources = await this.readMany( userId, @@ -279,18 +279,18 @@ export class CatalogEnrichmentService { dataRevision: dependencies .map((dependency) => dependency.dataRevision) .join('/'), - availability: !first.instrument ? - 'missing' : - complete ? - 'complete' : - 'partial', - fetchedAt: first.meta.lastSuccessfulAt ? - new Date(first.meta.lastSuccessfulAt) : - null, + availability: !first.instrument + ? 'missing' + : complete + ? 'complete' + : 'partial', + fetchedAt: first.meta.lastSuccessfulAt + ? new Date(first.meta.lastSuccessfulAt) + : null, freshForMs: - section === 'company' ? - tbankSourceTtlMs.company : - tbankSourceTtlMs.quote, + section === 'company' + ? tbankSourceTtlMs.company + : tbankSourceTtlMs.quote, work: null, dependencies, }); @@ -315,9 +315,9 @@ export class CatalogEnrichmentService { ): Promise { if (input.kind === 'detail' || input.kind === 'bond-current') { const instrument = - input.instrumentType === 'share' ? - await this.tbank.getShare(token, input.uid) : - await this.tbank.getBond(token, input.uid); + input.instrumentType === 'share' + ? await this.tbank.getShare(token, input.uid) + : await this.tbank.getBond(token, input.uid); if (instrument && instrument.uid !== input.uid) { throw new Error('Catalog UID mismatch'); } @@ -400,9 +400,9 @@ export class CatalogEnrichmentService { } const details = await this.repository.readSources([ { ...input, kind: 'detail', rangeKey: undefined }, - ...(input.instrumentType === 'bond' ? - [{ ...input, kind: 'bond-current' as const, rangeKey: undefined }] : - []), + ...(input.instrumentType === 'bond' + ? [{ ...input, kind: 'bond-current' as const, rangeKey: undefined }] + : []), ]); const instrument = details .map((row) => (row.payload as CatalogPayload).instrument) @@ -415,36 +415,36 @@ export class CatalogEnrichmentService { } if (input.kind === 'description') { return { - description: instrument?.assetUid ? - await this.tbank.getAssetDescription(token, instrument?.assetUid) : - null, + description: instrument?.assetUid + ? await this.tbank.getAssetDescription(token, instrument?.assetUid) + : null, }; } if (input.kind === 'fundamentals') { return { - fundamentals: instrument?.assetUid ? - await this.tbank.getFundamentals(token, instrument?.assetUid) : - null, + fundamentals: instrument?.assetUid + ? await this.tbank.getFundamentals(token, instrument?.assetUid) + : null, }; } if (input.kind === 'coupons' || input.kind === 'dividends') { const year = Number(input.rangeKey); - const [from, to] = input.rangeKey?.startsWith('[') ? - (JSON.parse(input.rangeKey) as [string, string]) : - [ + const [from, to] = input.rangeKey?.startsWith('[') + ? (JSON.parse(input.rangeKey) as [string, string]) + : [ `${year - payoutHistoryYears}-01-01`, `${year + payoutFutureYears}-01-01`, ]; - return input.kind === 'coupons' ? - { + return input.kind === 'coupons' + ? { coupons: await this.tbank.getBondCoupons( token, input.uid, from, to, ), - } : - { + } + : { dividends: await this.tbank.getDividends( token, input.uid, @@ -461,20 +461,20 @@ export class CatalogEnrichmentService { if (input.kind === 'yield') { return { effectiveYield: - instrument?.ticker && instrument.isin && instrument.classCode ? - await this.moex.bondYield( + instrument?.ticker && instrument.isin && instrument.classCode + ? await this.moex.bondYield( instrument.ticker, instrument.isin, instrument.classCode, - ) : - null, + ) + : null, }; } if (input.kind === 'rating') { return { - creditRating: instrument?.isin ? - await this.ratings.rating(instrument.isin) : - null, + creditRating: instrument?.isin + ? await this.ratings.rating(instrument.isin) + : null, }; } throw new Error('Unsupported catalog source'); @@ -533,17 +533,17 @@ export class CatalogEnrichmentService { prices?.find((entry) => entry.instrumentUid === item.input.uid) ?? null; const value = - item.input.kind === 'last-price' ? - { - price: price ? - { + item.input.kind === 'last-price' + ? { + price: price + ? { instrumentUid: item.input.uid, price: price.price, time: price.time, - } : - null, - } : - payload; + } + : null, + } + : payload; // Keep accepting observers while publication awaits SQLite. item.observers.unshift({ attempt: item.attempt, input: item.input }); for (const observer of item.observers) { @@ -596,17 +596,17 @@ function sourceInputs( uid, kind, // eslint-disable-next-line security/detect-object-injection -- Numeric queue indexes and validated instrument UIDs select internal data only. - ...(kind === 'bond-events' && (range?.tickers?.[uid] || range?.ticker) ? + ...(kind === 'bond-events' && (range?.tickers?.[uid] || range?.ticker) // eslint-disable-next-line security/detect-object-injection -- Numeric queue indexes and validated instrument UIDs select internal data only. - { ticker: range?.tickers?.[uid] || range?.ticker } : - {}), - ...(['coupons', 'dividends'].includes(kind) ? - { - rangeKey: range ? - JSON.stringify([range.from, range.to]) : - String(new Date().getUTCFullYear()), - } : - {}), + ? { ticker: range?.tickers?.[uid] || range?.ticker } + : {}), + ...(['coupons', 'dividends'].includes(kind) + ? { + rangeKey: range + ? JSON.stringify([range.from, range.to]) + : String(new Date().getUTCFullYear()), + } + : {}), })), ); } @@ -679,9 +679,9 @@ function sourcePolicy(kind: CatalogSource['kind']) { } function producerKey(input: CatalogSource) { return input.instrumentType === 'bond' && - (input.kind === 'detail' || input.kind === 'bond-current') ? - `bond-metadata:${input.uid}` : - catalogScope(input); + (input.kind === 'detail' || input.kind === 'bond-current') + ? `bond-metadata:${input.uid}` + : catalogScope(input); } const quotationScale = 9; diff --git a/src/modules/investments/instruments/application/catalog-selection.ts b/src/modules/investments/instruments/application/catalog-selection.ts index c91f26a..2928a9e 100644 --- a/src/modules/investments/instruments/application/catalog-selection.ts +++ b/src/modules/investments/instruments/application/catalog-selection.ts @@ -9,9 +9,9 @@ export function selectCatalogInstruments( ): (T & { uid: string })[] { const seen = new Set(); const kind = - instrumentType === 'share' ? - 'INSTRUMENT_TYPE_SHARE' : - 'INSTRUMENT_TYPE_BOND'; + instrumentType === 'share' + ? 'INSTRUMENT_TYPE_SHARE' + : 'INSTRUMENT_TYPE_BOND'; const preferredClass = instrumentType === 'share' ? 'TQBR' : 'TQOB'; return found .filter( @@ -29,9 +29,9 @@ export function selectCatalogInstruments( a.uid.localeCompare(b.uid), ) .filter((instrument) => { - const key = instrument.isin?.trim() ? - `isin:${instrument.isin.trim().toUpperCase()}` : - `uid:${instrument.uid}`; + const key = instrument.isin?.trim() + ? `isin:${instrument.isin.trim().toUpperCase()}` + : `uid:${instrument.uid}`; if (seen.has(key)) return false; seen.add(key); return true; diff --git a/src/modules/investments/instruments/application/instrument-market.ts b/src/modules/investments/instruments/application/instrument-market.ts index 4620119..53f9c52 100644 --- a/src/modules/investments/instruments/application/instrument-market.ts +++ b/src/modules/investments/instruments/application/instrument-market.ts @@ -10,9 +10,9 @@ const MIN_TIMESTAMP_SECONDS_EXCLUSIVE = 0; const MOSCOW_OFFSET_MS = 10_800_000; export function timestamp(value?: TbankTimestamp): null | string { const seconds = Number(value?.seconds); - return Number.isFinite(seconds) && seconds > MIN_TIMESTAMP_SECONDS_EXCLUSIVE ? - new Date(seconds * MILLISECONDS_PER_SECOND).toISOString() : - null; + return Number.isFinite(seconds) && seconds > MIN_TIMESTAMP_SECONDS_EXCLUSIVE + ? new Date(seconds * MILLISECONDS_PER_SECOND).toISOString() + : null; } export function moscowDay(iso: string): string { return new Date(Date.parse(iso) + MOSCOW_OFFSET_MS) diff --git a/src/modules/investments/instruments/application/instrument-position.ts b/src/modules/investments/instruments/application/instrument-position.ts index 9fdb930..6bbc2cb 100644 --- a/src/modules/investments/instruments/application/instrument-position.ts +++ b/src/modules/investments/instruments/application/instrument-position.ts @@ -43,8 +43,8 @@ export function instrumentPosition( currentValue, absoluteReturn, averagePurchasePrice: - positive && totalCost ? - { + positive && totalCost + ? { currency: totalCost.currency, value: formatDecimal( divide( @@ -53,15 +53,15 @@ export function instrumentPosition( PRICE_DECIMAL_PLACES, ), ), - } : - null, + } + : null, returnPercent: positive ? percent(absoluteReturn, totalCost) : null, accounts: positions.map((p) => ({ ...p, returnPercent: - decimalFromString(p.quantity).value > ZERO_QUANTITY ? - percent(p.absoluteReturn, cost(p)) : - null, + decimalFromString(p.quantity).value > ZERO_QUANTITY + ? percent(p.absoluteReturn, cost(p)) + : null, })), isStale: portfolio.isStale, observedAt: portfolio.lastSuccessfulAt, @@ -69,8 +69,8 @@ export function instrumentPosition( } function cost(position: PortfolioPositionDto): MoneyDto | null { const price = position.averagePurchasePrice; - return price ? - { + return price + ? { currency: price.currency, value: formatDecimal( multiply( @@ -78,8 +78,8 @@ function cost(position: PortfolioPositionDto): MoneyDto | null { decimalFromString(position.quantity), ), ), - } : - null; + } + : null; } function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null { const currency = values[0]?.currency; diff --git a/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts b/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts index df5972b..dbfcee5 100644 --- a/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts +++ b/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts @@ -37,18 +37,18 @@ export class BondCatalogService { data: result.data?.map((item) => { const short = toBond(item); - const detail = item.detail ? - toBond({ ...item.detail, uid: item.uid }) : - short; + const detail = item.detail + ? toBond({ ...item.detail, uid: item.uid }) + : short; const last = item.price; return { ...detail, classCode: detail.classCode ?? short.classCode, isin: detail.isin ?? short.isin, apiTradeAvailable: short.apiTradeAvailable, - pricePercent: last?.price ? - formatDecimal(decimal(last.price)) : - null, + pricePercent: last?.price + ? formatDecimal(decimal(last.price)) + : null, priceAt: timestamp(last?.time), }; }) ?? null, @@ -63,9 +63,9 @@ export class BondCatalogService { const result = await this.discovery.detail(userId, 'bond', uid, observe); return { ...result, - data: result.data ? - { ...toBond(result.data), slug: result.data.slug } : - null, + data: result.data + ? { ...toBond(result.data), slug: result.data.slug } + : null, }; } @@ -82,9 +82,9 @@ export class BondCatalogService { ); return { ...result, - data: result.data ? - { ...toBond(result.data), slug: result.data.slug } : - null, + data: result.data + ? { ...toBond(result.data), slug: result.data.slug } + : null, }; } } diff --git a/src/modules/investments/instruments/bonds/application/bond-income.service.ts b/src/modules/investments/instruments/bonds/application/bond-income.service.ts index 6096373..ab09102 100644 --- a/src/modules/investments/instruments/bonds/application/bond-income.service.ts +++ b/src/modules/investments/instruments/bonds/application/bond-income.service.ts @@ -62,9 +62,9 @@ export class BondIncomeService { observe, ); return { - data: result.values.instrument ? - bondMetrics(result.values, result.unavailable, result.observedAt) : - null, + data: result.values.instrument + ? bondMetrics(result.values, result.unavailable, result.observedAt) + : null, meta: result.meta, }; } @@ -99,18 +99,18 @@ export class BondIncomeService { throw new NotFoundException(); } const events = sources[1].payload?.coupons; - const position = portfolio.data ? - instrumentPosition(portfolio.data, uid, 'BOND') : - null; - const projected = events ? - bondCoupons( + const position = portfolio.data + ? instrumentPosition(portfolio.data, uid, 'BOND') + : null; + const projected = events + ? bondCoupons( events, position, { from, to, today }, sources[1].meta.lastSuccessfulAt ?? new Date().toISOString(), !portfolio.data, - ) : - undefined; + ) + : undefined; const result = await this.forecasts.result({ userId, revision: connection.revision, @@ -121,9 +121,9 @@ export class BondIncomeService { candidate: portfolio.data && sources[0].instrument ? projected : undefined, }); - return result.data || !projected ? - result : - { + return result.data || !projected + ? result + : { data: bondCoupons( events ?? [], null, @@ -146,19 +146,19 @@ export function bondCoupons( const { from, to, today } = window; const quantity = position?.accounts.length && - position.accounts.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY) ? - decimalFromString(position.quantity) : - null; + position.accounts.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY) + ? decimalFromString(position.quantity) + : null; const coupons = events .map((event) => { const paymentDate = timestamp(event.couponDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null; const recordDate = timestamp(event.fixDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null; const perBond = money(event.payOneBond); - const timing = !paymentDate ? - ('undated' as const) : - paymentDate >= today ? - ('upcoming' as const) : - ('past' as const); + const timing = !paymentDate + ? ('undated' as const) + : paymentDate >= today + ? ('upcoming' as const) + : ('past' as const); return { number: event.couponNumber ?? null, paymentDate, @@ -172,14 +172,14 @@ export function bondCoupons( perBond && timing === 'upcoming' && recordDate && - recordDate >= today ? - { + recordDate >= today + ? { currency: perBond.currency, value: formatDecimal( multiply(quantity, decimalFromString(perBond.value)), ), - } : - null, + } + : null, }; }) .sort((a, b) => (a.paymentDate ?? '').localeCompare(b.paymentDate ?? '')); @@ -225,8 +225,8 @@ export function bondMetrics( clean && coupon.currency === clean.currency && decimalFromString(coupon.value).value >= ZERO_AMOUNT && - decimalFromString(clean.value).value > ZERO_AMOUNT ? - formatDecimal( + decimalFromString(clean.value).value > ZERO_AMOUNT + ? formatDecimal( divide( multiply( decimalFromString(coupon.value), @@ -238,14 +238,14 @@ export function bondMetrics( ), COUPON_YIELD_DECIMAL_PLACES, ), - ) : - null; + ) + : null; const couponType = - next?.couponType && next.couponType !== 'COUPON_TYPE_UNSPECIFIED' ? - next.couponType : - instrument.floatingCouponFlag === true ? - 'COUPON_TYPE_FLOATING' : - null; + next?.couponType && next.couponType !== 'COUPON_TYPE_UNSPECIFIED' + ? next.couponType + : instrument.floatingCouponFlag === true + ? 'COUPON_TYPE_FLOATING' + : null; return { creditRating, coupon, diff --git a/src/modules/investments/instruments/bonds/application/bond-market.service.ts b/src/modules/investments/instruments/bonds/application/bond-market.service.ts index fbb4fcf..5902ea8 100644 --- a/src/modules/investments/instruments/bonds/application/bond-market.service.ts +++ b/src/modules/investments/instruments/bonds/application/bond-market.service.ts @@ -48,9 +48,9 @@ export class BondMarketService { observe, ); return { - data: result.values.instrument ? - bondQuote(result.values, result.unavailable, result.observedAt) : - null, + data: result.values.instrument + ? bondQuote(result.values, result.unavailable, result.observedAt) + : null, meta: result.meta, }; } @@ -83,8 +83,8 @@ export function bondQuote( const cleanPrice = pricePercent !== null && nominal && - decimalFromString(nominal.value).value > ZERO_AMOUNT ? - { + decimalFromString(nominal.value).value > ZERO_AMOUNT + ? { currency: nominal.currency, value: formatDecimal( divide( @@ -96,11 +96,11 @@ export function bondQuote( PRICE_DECIMAL_PLACES, ), ), - } : - null; + } + : null; const dirtyPrice = - cleanPrice && aci && aci.currency === cleanPrice.currency ? - { + cleanPrice && aci && aci.currency === cleanPrice.currency + ? { currency: cleanPrice.currency, value: formatDecimal( add( @@ -108,8 +108,8 @@ export function bondQuote( decimalFromString(aci.value), ), ), - } : - null; + } + : null; return { pricePercent, cleanPrice, @@ -118,9 +118,9 @@ export function bondQuote( priceAt, tradingStatus, changePercentagePoints: - last?.price && previous?.close ? - formatDecimal(subtract(decimal(last.price), decimal(previous.close))) : - null, + last?.price && previous?.close + ? formatDecimal(subtract(decimal(last.price), decimal(previous.close))) + : null, previousCloseDate: timestamp(previous?.time)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null, unavailable, observedAt, diff --git a/src/modules/investments/instruments/bonds/application/bond.spec.ts b/src/modules/investments/instruments/bonds/application/bond.spec.ts index 5f9098b..fed812e 100644 --- a/src/modules/investments/instruments/bonds/application/bond.spec.ts +++ b/src/modules/investments/instruments/bonds/application/bond.spec.ts @@ -123,9 +123,9 @@ describe('bond financial projections', () => { { couponDate: time('2026-10-10'), payOneBond: - scenario === 'unknown' ? - undefined : - money('30', scenario === 'currency' ? 'usd' : 'rub'), + scenario === 'unknown' + ? undefined + : money('30', scenario === 'currency' ? 'usd' : 'rub'), couponPeriod: scenario === 'period' ? 0 : 90, }, ], diff --git a/src/modules/investments/instruments/bonds/infrastructure/bond-money.mapper.ts b/src/modules/investments/instruments/bonds/infrastructure/bond-money.mapper.ts index 861a30b..fd115e7 100644 --- a/src/modules/investments/instruments/bonds/infrastructure/bond-money.mapper.ts +++ b/src/modules/investments/instruments/bonds/infrastructure/bond-money.mapper.ts @@ -4,7 +4,7 @@ import { type TbankMoney } from '../../../../../integrations/tbank/tbank.api'; export function money(value?: TbankMoney) { const currency = value?.currency?.toUpperCase(); - return value && currency ? - { currency, value: formatDecimal(decimal(value)) } : - null; + return value && currency + ? { currency, value: formatDecimal(decimal(value)) } + : null; } diff --git a/src/modules/investments/instruments/domain/candle-coverage.ts b/src/modules/investments/instruments/domain/candle-coverage.ts index 3db1224..ef2dad1 100644 --- a/src/modules/investments/instruments/domain/candle-coverage.ts +++ b/src/modules/investments/instruments/domain/candle-coverage.ts @@ -38,9 +38,9 @@ export function candleGaps( gaps = gaps.flatMap((gap) => { if (covered.to <= gap.from || covered.from >= gap.to) return [gap]; return [ - ...(covered.from > gap.from ? - [{ from: gap.from, to: covered.from }] : - []), + ...(covered.from > gap.from + ? [{ from: gap.from, to: covered.from }] + : []), ...(covered.to < gap.to ? [{ from: covered.to, to: gap.to }] : []), ]; }); diff --git a/src/modules/investments/instruments/infrastructure/candle.repository.ts b/src/modules/investments/instruments/infrastructure/candle.repository.ts index c9750cf..844916f 100644 --- a/src/modules/investments/instruments/infrastructure/candle.repository.ts +++ b/src/modules/investments/instruments/infrastructure/candle.repository.ts @@ -100,14 +100,14 @@ export class CandleRepository { priority: nextPriority, intent: { ...previousIntent, - ...(intent.force ? - { + ...(intent.force + ? { force: true, - ...(intent.now && intent.now > previousNow ? - { now: intent.now } : - {}), - } : - {}), + ...(intent.now && intent.now > previousNow + ? { now: intent.now } + : {}), + } + : {}), }, }, }); @@ -263,12 +263,12 @@ export class CandleRepository { // Preserve coverage outside the requested reconciliation boundaries. for (const previous of old) { for (const part of [ - ...(previous.from < range.from ? - [{ from: previous.from, to: range.from }] : - []), - ...(previous.to > range.to ? - [{ from: range.to, to: previous.to }] : - []), + ...(previous.from < range.from + ? [{ from: previous.from, to: range.from }] + : []), + ...(previous.to > range.to + ? [{ from: range.to, to: previous.to }] + : []), ]) { await tx.tbankCandleCoverage.create({ data: { ...previous, ...part }, diff --git a/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts b/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts index e0c916a..46fe512 100644 --- a/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts +++ b/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts @@ -66,8 +66,8 @@ export class CatalogDiscoveryRepository { read(input: CatalogQuery) { return this.prisma.$transaction(async (tx) => { - const alias = input.exactTicker ? - await tx.tbankInstrumentLookup.findUnique({ + const alias = input.exactTicker + ? await tx.tbankInstrumentLookup.findUnique({ where: { provider_identifierType_identifier: { provider, @@ -75,17 +75,17 @@ export class CatalogDiscoveryRepository { identifier: input.query, }, }, - }) : - null; - const discovery = input.exactTicker ? - alias ? - { + }) + : null; + const discovery = input.exactTicker + ? alias + ? { instrumentUids: alias.matchedUids, fetchedAt: alias.fetchedAt, dataRevision: alias.dataRevision, - } : - null : - await tx.tbankCatalogDiscovery.findUnique({ + } + : null + : await tx.tbankCatalogDiscovery.findUnique({ where: { provider_instrumentType_query: { provider, @@ -134,9 +134,9 @@ export class CatalogDiscoveryRepository { kind: input.kind, source: sourceProvider(input), rangeKey: - input.kind === 'detail' ? - input.instrumentType : - (input.rangeKey ?? ''), + input.kind === 'detail' + ? input.instrumentType + : (input.rangeKey ?? ''), })), }, }); @@ -232,9 +232,9 @@ export class CatalogDiscoveryRepository { increment: isDeepStrictEqual( previousAlias?.matchedUids, matchedUids, - ) ? - UNCHANGED_REVISION_INCREMENT : - REVISION_INCREMENT, + ) + ? UNCHANGED_REVISION_INCREMENT + : REVISION_INCREMENT, }, }, }); @@ -246,9 +246,9 @@ export class CatalogDiscoveryRepository { source: sourceProvider(input), kind: input.kind, rangeKey: - input.kind === 'detail' ? - input.instrumentType : - (input.rangeKey ?? ''), + input.kind === 'detail' + ? input.instrumentType + : (input.rangeKey ?? ''), }; const previous = await tx.tbankInstrumentSource.findUnique({ where: { provider_instrumentUid_source_kind_rangeKey: key }, @@ -359,9 +359,9 @@ export class CatalogDiscoveryRepository { instrumentKind: instrument.instrumentKind, apiTradeAvailableFlag: instrument.apiTradeAvailableFlag, currency: instrument.currency, - brand: instrument.brand ? - { logoName: instrument.brand.logoName } : - undefined, + brand: instrument.brand + ? { logoName: instrument.brand.logoName } + : undefined, }), ) as Prisma.InputJsonObject; await tx.tbankInstrument.upsert({ @@ -418,9 +418,9 @@ export class CatalogDiscoveryRepository { increment: isDeepStrictEqual( previous?.matchedUids, instrumentUids, - ) ? - UNCHANGED_REVISION_INCREMENT : - REVISION_INCREMENT, + ) + ? UNCHANGED_REVISION_INCREMENT + : REVISION_INCREMENT, }, }, }); @@ -444,9 +444,9 @@ export class CatalogDiscoveryRepository { increment: isDeepStrictEqual( previous?.instrumentUids, instrumentUids, - ) ? - UNCHANGED_REVISION_INCREMENT : - REVISION_INCREMENT, + ) + ? UNCHANGED_REVISION_INCREMENT + : REVISION_INCREMENT, }, }, }); @@ -480,9 +480,9 @@ export class CatalogDiscoveryRepository { } function sourceProvider(input: CatalogSource) { - return input.kind === 'yield' || input.kind === 'bond-events' ? - 'moex' : - input.kind === 'rating' ? - 'ncr' : - 'tbank'; + return input.kind === 'yield' || input.kind === 'bond-events' + ? 'moex' + : input.kind === 'rating' + ? 'ncr' + : 'tbank'; } diff --git a/src/modules/investments/instruments/stocks/application/get-stock-company.use-case.ts b/src/modules/investments/instruments/stocks/application/get-stock-company.use-case.ts index ea4df48..fcc3560 100644 --- a/src/modules/investments/instruments/stocks/application/get-stock-company.use-case.ts +++ b/src/modules/investments/instruments/stocks/application/get-stock-company.use-case.ts @@ -23,8 +23,8 @@ export class GetStockCompanyUseCase { observe, ); return { - data: result.values.instrument ? - { + data: result.values.instrument + ? { description: result.values.description ?? null, metrics: metrics( result.values.fundamentals ?? null, @@ -32,8 +32,8 @@ export class GetStockCompanyUseCase { ), unavailable: result.unavailable, observedAt: result.observedAt, - } : - null, + } + : null, meta: result.meta, }; } @@ -51,22 +51,22 @@ export function metrics( ][] = [ ['marketCapitalization', 'money', null], ['peRatioTtm', 'ratio', 'TTM'], - ...(bank ? - ([ + ...(bank + ? ([ ['priceToBookTtm', 'ratio', 'TTM'], ['roe', 'percent', null], - ] as typeof selection) : - ([ + ] as typeof selection) + : ([ ['revenueTtm', 'money', 'TTM'], ['ebitdaTtm', 'money', 'TTM'], ] as typeof selection)), ['netIncomeTtm', 'money', 'TTM'], - ...(!bank ? - ([ + ...(!bank + ? ([ ['totalDebtMrq', 'money', 'MRQ'], ['netDebtToEbitda', 'ratio', null], - ] as typeof selection) : - []), + ] as typeof selection) + : []), ]; return selection.flatMap(([key, unit, period]) => { // eslint-disable-next-line security/detect-object-injection -- Keys come from the fixed metric selection declared above. @@ -74,8 +74,8 @@ export function metrics( const currency = values.currency?.toUpperCase() || null; return typeof value === 'number' && Number.isFinite(value) && - (unit !== 'money' || currency) ? - [ + (unit !== 'money' || currency) + ? [ { key, value, @@ -83,7 +83,7 @@ export function metrics( period, currency: unit === 'money' ? currency : null, }, - ] : - []; + ] + : []; }); } diff --git a/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts b/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts index 30fc5c9..bb3762a 100644 --- a/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts +++ b/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts @@ -34,9 +34,9 @@ export class StockCatalogService { data: result.data?.map((item) => { const short = stock(item); - const detail = item.detail ? - stock({ ...item.detail, uid: item.uid }) : - short; + const detail = item.detail + ? stock({ ...item.detail, uid: item.uid }) + : short; const last = item.price; return { ...detail, @@ -44,12 +44,12 @@ export class StockCatalogService { isin: detail.isin ?? short.isin, apiTradeAvailable: short.apiTradeAvailable, price: - last?.price && detail.currency ? - { + last?.price && detail.currency + ? { currency: detail.currency, value: formatDecimal(decimal(last.price)), - } : - null, + } + : null, priceAt: timestamp(last?.time), }; }) ?? null, @@ -64,9 +64,9 @@ export class StockCatalogService { const result = await this.discovery.detail(userId, 'share', uid, observe); return { ...result, - data: result.data ? - { ...stock(result.data), slug: result.data.slug } : - null, + data: result.data + ? { ...stock(result.data), slug: result.data.slug } + : null, }; } @@ -83,9 +83,9 @@ export class StockCatalogService { ); return { ...result, - data: result.data ? - { ...stock(result.data), slug: result.data.slug } : - null, + data: result.data + ? { ...stock(result.data), slug: result.data.slug } + : null, }; } } diff --git a/src/modules/investments/instruments/stocks/application/stock-income.service.ts b/src/modules/investments/instruments/stocks/application/stock-income.service.ts index dceabb4..cdb47c4 100644 --- a/src/modules/investments/instruments/stocks/application/stock-income.service.ts +++ b/src/modules/investments/instruments/stocks/application/stock-income.service.ts @@ -69,18 +69,18 @@ export class StockIncomeService { throw new NotFoundException(); } const events = sources[1].payload?.dividends; - const position = portfolio.data ? - instrumentPosition(portfolio.data, uid, 'STOCK') : - null; - const projected = events ? - stockDividends( + const position = portfolio.data + ? instrumentPosition(portfolio.data, uid, 'STOCK') + : null; + const projected = events + ? stockDividends( events, position, { from, to, today }, sources[1].meta.lastSuccessfulAt ?? new Date().toISOString(), !portfolio.data, - ) : - undefined; + ) + : undefined; const result = await this.forecasts.result({ userId, revision: connection.revision, @@ -91,9 +91,9 @@ export class StockIncomeService { candidate: portfolio.data && sources[0].instrument ? projected : undefined, }); - return result.data || !projected ? - result : - { + return result.data || !projected + ? result + : { data: stockDividends( events ?? [], null, @@ -117,9 +117,9 @@ export function stockDividends( const quantity = position && position.accounts.length && - position.accounts.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY) ? - decimalFromString(position.quantity) : - null; + position.accounts.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY) + ? decimalFromString(position.quantity) + : null; const dividends: StockDividendDto[] = events .filter((event) => event.dividendType?.toLowerCase() !== 'cancelled') .map((event) => { @@ -128,29 +128,29 @@ export function stockDividends( const lastBuyDate = timestamp(event.lastBuyDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null; const currency = event.dividendNet?.currency?.toUpperCase(); const perShare = - currency && event.dividendNet ? - { currency, value: formatDecimal(decimal(event.dividendNet)) } : - null; + currency && event.dividendNet + ? { currency, value: formatDecimal(decimal(event.dividendNet)) } + : null; // A future payment can have an already-fixed register: keep the event, but do not estimate entitlement from today's holding. const eventDate = paymentDate ?? recordDate; - const timing: StockDividendDto['timing'] = eventDate ? - eventDate >= today ? - 'upcoming' : - 'past' : - 'undated'; + const timing: StockDividendDto['timing'] = eventDate + ? eventDate >= today + ? 'upcoming' + : 'past' + : 'undated'; const estimatedAmount = quantity && perShare && recordDate && recordDate >= today && - timing === 'upcoming' ? - { + timing === 'upcoming' + ? { currency: perShare.currency, value: formatDecimal( multiply(quantity, decimalFromString(perShare.value)), ), - } : - null; + } + : null; return { perShare, recordDate, diff --git a/src/modules/investments/instruments/stocks/application/stock-market.service.ts b/src/modules/investments/instruments/stocks/application/stock-market.service.ts index c4b3912..e03ad7a 100644 --- a/src/modules/investments/instruments/stocks/application/stock-market.service.ts +++ b/src/modules/investments/instruments/stocks/application/stock-market.service.ts @@ -44,9 +44,9 @@ export class StockMarketService { observe, ); return { - data: result.values.instrument ? - stockQuote(result.values, result.unavailable, result.observedAt) : - null, + data: result.values.instrument + ? stockQuote(result.values, result.unavailable, result.observedAt) + : null, meta: result.meta, }; } @@ -75,20 +75,20 @@ export function stockQuote( const priceAt = timestamp(last?.time); const currency = share?.currency?.toUpperCase(); const price = - last?.price && currency ? - { currency, value: formatDecimal(decimal(last.price)) } : - null; + last?.price && currency + ? { currency, value: formatDecimal(decimal(last.price)) } + : null; const previousPrice = - previous?.close && currency ? - { currency, value: formatDecimal(decimal(previous.close)) } : - null; + previous?.close && currency + ? { currency, value: formatDecimal(decimal(previous.close)) } + : null; const difference = - price && previousPrice ? - subtract( + price && previousPrice + ? subtract( decimalFromString(price.value), decimalFromString(previousPrice.value), - ) : - null; + ) + : null; return { price, priceAt, @@ -96,21 +96,21 @@ export function stockQuote( previousCloseDate: timestamp(previous?.time)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null, previousClose: previousPrice, change: - difference && currency ? - { currency, value: formatDecimal(difference) } : - null, + difference && currency + ? { currency, value: formatDecimal(difference) } + : null, changePercent: difference && previousPrice && - decimalFromString(previousPrice.value).value > ZERO_AMOUNT ? - formatDecimal( + decimalFromString(previousPrice.value).value > ZERO_AMOUNT + ? formatDecimal( divide( multiply(difference, decimalFromString('100')), decimalFromString(previousPrice.value), CHANGE_PERCENT_DECIMAL_PLACES, ), - ) : - null, + ) + : null, unavailable, observedAt, }; diff --git a/src/modules/investments/investment-resource.mapper.ts b/src/modules/investments/investment-resource.mapper.ts index dc7da49..e072acf 100644 --- a/src/modules/investments/investment-resource.mapper.ts +++ b/src/modules/investments/investment-resource.mapper.ts @@ -16,11 +16,11 @@ export function investmentResourceMeta(input: { const ownSync = { id: input.work?.id ?? null, state: - input.work?.state === 'running' ? - ('running' as const) : - input.work?.state === 'failed' ? - ('failed' as const) : - ('idle' as const), + input.work?.state === 'running' + ? ('running' as const) + : input.work?.state === 'failed' + ? ('failed' as const) + : ('idle' as const), errorCode: input.work?.errorCode ?? null, }; const states = [ @@ -34,11 +34,11 @@ export function investmentResourceMeta(input: { states.find((state) => state.state === 'queued') ?? states.find((state) => state.state === 'failed') ?? ownSync; - const sourceFreshness = input.fetchedAt ? - Date.now() - input.fetchedAt.valueOf() < input.freshForMs ? - ('fresh' as const) : - ('stale' as const) : - ('unknown' as const); + const sourceFreshness = input.fetchedAt + ? Date.now() - input.fetchedAt.valueOf() < input.freshForMs + ? ('fresh' as const) + : ('stale' as const) + : ('unknown' as const); const freshness = [ sourceFreshness, ...(input.dependencies ?? []) @@ -56,16 +56,16 @@ export function investmentResourceMeta(input: { .sort(); const meta = { resourceKey: input.resourceKey, - ...(input.unavailableReason ? - { unavailableReason: input.unavailableReason } : - {}), + ...(input.unavailableReason + ? { unavailableReason: input.unavailableReason } + : {}), dataRevision: input.dataRevision, availability: input.availability, - freshness: freshness.includes('unknown') ? - ('unknown' as const) : - freshness.includes('stale') ? - ('stale' as const) : - ('fresh' as const), + freshness: freshness.includes('unknown') + ? ('unknown' as const) + : freshness.includes('stale') + ? ('stale' as const) + : ('fresh' as const), lastSuccessfulAt: timestamps[0] ?? null, sync, ...(input.coverage ? { coverage: input.coverage } : {}), @@ -79,9 +79,9 @@ export function investmentResourceMeta(input: { ...meta, // The requested window is identified by resourceKey. Its moving "now" // boundary is not a change to saved coverage between status and GET. - coverage: meta.coverage ? - { ...meta.coverage, requested: undefined } : - undefined, + coverage: meta.coverage + ? { ...meta.coverage, requested: undefined } + : undefined, }), ) .digest('base64url'), diff --git a/src/modules/investments/investment-sync-plan.ts b/src/modules/investments/investment-sync-plan.ts index 50de3d0..c907c1c 100644 --- a/src/modules/investments/investment-sync-plan.ts +++ b/src/modules/investments/investment-sync-plan.ts @@ -84,16 +84,16 @@ export function planInvestmentScreen( const unavailable: { reason: string; resourceKey: string }[] = []; const operations = (from: Date, to: Date, instrumentUid?: string) => { const targets = accounts.flatMap((account) => - from < to ? - [ + from < to + ? [ { accountId: account.accountId, from, to, ...(instrumentUid ? { instrumentUid } : {}), }, - ] : - [], + ] + : [], ); sources.push( ...operationIntervals(targets).map((range) => ({ @@ -112,12 +112,12 @@ export function planInvestmentScreen( })), ); const knownOpening = - accounts.length && accounts.every((account) => account.openedOn) ? - new Date( + accounts.length && accounts.every((account) => account.openedOn) + ? new Date( // 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. `${accounts.map((account) => account.openedOn ?? '').sort()[0]}T00:00:00+03:00`, - ) : - undefined; + ) + : undefined; if (!context.membershipKnown) return { sources, unavailable, now }; if ( ['account', 'accounts', 'bond', 'events', 'stock'].includes(screen.screen) @@ -146,9 +146,9 @@ export function planInvestmentScreen( ); for (const range of [ period.selected, - ...(screen.screen === 'income' ? - [period.previous, ...(period.yearAgo ? [period.yearAgo] : [])] : - []), + ...(screen.screen === 'income' + ? [period.previous, ...(period.yearAgo ? [period.yearAgo] : [])] + : []), ]) { const bounds = dateOnlyBounds(range, now); operations(bounds.from, bounds.to); @@ -216,11 +216,11 @@ export function planInvestmentScreen( } const range = screen.candleRange ?? - (period === 'all' ? - instrument.historyFrom ? - { from: instrument.historyFrom, to: now } : - null : - { from, to: now }); + (period === 'all' + ? instrument.historyFrom + ? { from: instrument.historyFrom, to: now } + : null + : { from, to: now }); if (range) { sources.push({ ...identity, @@ -237,17 +237,17 @@ export function planInvestmentScreen( } if (['account', 'bond', 'events', 'stock'].includes(screen.screen)) { const day = - screen.screen === 'stock' || screen.screen === 'bond' ? - moscowDate(now) : - now.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH); + screen.screen === 'stock' || screen.screen === 'bond' + ? moscowDate(now) + : now.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH); const from = - screen.screen === 'events' && screen.from ? - new Date(`${screen.from}T00:00:00Z`) : - new Date(`${day}T00:00:00Z`); + screen.screen === 'events' && screen.from + ? new Date(`${screen.from}T00:00:00Z`) + : new Date(`${day}T00:00:00Z`); const to = - screen.screen === 'events' && screen.to ? - new Date(`${screen.to}T00:00:00Z`) : - new Date(from); + screen.screen === 'events' && screen.to + ? new Date(`${screen.to}T00:00:00Z`) + : new Date(from); if ( !(screen.screen === 'events' && screen.to) && screen.screen !== 'stock' && @@ -309,9 +309,9 @@ export function accountHistory( ): OperationInterval[] { return accounts.flatMap((account) => { const from = new Date( - account.openedOn ? - `${account.openedOn}T00:00:00Z` : - '1970-01-01T00:00:00Z', + account.openedOn + ? `${account.openedOn}T00:00:00Z` + : '1970-01-01T00:00:00Z', ); return from < now ? [{ accountId: account.accountId, from, to: now }] : []; }); @@ -324,22 +324,22 @@ export function ledgerRange( accounts: Account[], now: Date, ) { - const opening = accounts.every((account) => account.openedOn) ? + const opening = accounts.every((account) => account.openedOn) // 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. - accounts.map((account) => account.openedOn ?? '').sort()[0] : - undefined; - const from = fromValue ? - new Date(fromValue) : - instrumentUid ? - new Date(opening ? `${opening}T00:00:00Z` : '1970-01-01T00:00:00Z') : - new Date(Date.UTC(now.getUTCFullYear(), JANUARY_INDEX, FIRST_DAY_OF_MONTH)); - const end = toValue ? - new Date( - /^\d{4}-\d{2}-\d{2}$/.test(toValue) ? - `${toValue}T23:59:59.999Z` : - toValue, - ) : - now; + ? accounts.map((account) => account.openedOn ?? '').sort()[0] + : undefined; + const from = fromValue + ? new Date(fromValue) + : instrumentUid + ? new Date(opening ? `${opening}T00:00:00Z` : '1970-01-01T00:00:00Z') + : new Date(Date.UTC(now.getUTCFullYear(), JANUARY_INDEX, FIRST_DAY_OF_MONTH)); + const end = toValue + ? new Date( + /^\d{4}-\d{2}-\d{2}$/.test(toValue) + ? `${toValue}T23:59:59.999Z` + : toValue, + ) + : now; return { from, to: new Date(Math.min(end.valueOf() + (toValue ? EXCLUSIVE_END_OFFSET_MS : NO_END_OFFSET_MS), now.valueOf())), diff --git a/src/modules/investments/operations/application/brokerage-operations-sync.service.ts b/src/modules/investments/operations/application/brokerage-operations-sync.service.ts index 0bf9456..fdaad98 100644 --- a/src/modules/investments/operations/application/brokerage-operations-sync.service.ts +++ b/src/modules/investments/operations/application/brokerage-operations-sync.service.ts @@ -82,24 +82,24 @@ export class BrokerageOperationsSyncService { ...intervals.map((interval) => interval.to.valueOf()), ); const targets = intervals.map((interval) => - options.movingEnd && interval.to.valueOf() === movingTo ? - { ...interval, to: now } : - interval, + options.movingEnd && interval.to.valueOf() === movingTo + ? { ...interval, to: now } + : interval, ); const plan = planOperationRead(targets, coverage, now, options); const complete = plan.intervals.every( (interval) => interval.availability === 'complete', ); const handle = - !options.observe && plan.load.length ? - await this.request( + !options.observe && plan.load.length + ? await this.request( userId, plan.load, complete || Boolean(options.force), 'screen', connection.revision, - ) : - null; + ) + : null; if (!options.observe && !options.force && options.history) { const accountIds = new Set( intervals.map((interval) => interval.accountId), @@ -267,9 +267,9 @@ export class BrokerageOperationsSyncService { Math.ceil(interval.to.valueOf() / MILLISECONDS_PER_SECOND) + UPSTREAM_RANGE_MARGIN_SECONDS, ), }, - ...(interval.instrumentUid ? - { instrumentId: interval.instrumentUid } : - {}), + ...(interval.instrumentUid + ? { instrumentId: interval.instrumentUid } + : {}), cursor, limit: operationsPageSize, state: 'OPERATION_STATE_EXECUTED', diff --git a/src/modules/investments/operations/application/brokerage-operations.service.ts b/src/modules/investments/operations/application/brokerage-operations.service.ts index eb6b802..3a43f43 100644 --- a/src/modules/investments/operations/application/brokerage-operations.service.ts +++ b/src/modules/investments/operations/application/brokerage-operations.service.ts @@ -86,13 +86,13 @@ export class BrokerageOperationsService { const filterKey = JSON.stringify([ accountId, from.toISOString(), - query.to ? - new Date( - /^\d{4}-\d{2}-\d{2}$/.test(query.to) ? - `${query.to}T23:59:59.999Z` : - query.to, - ).toISOString() : - 'now', + query.to + ? new Date( + /^\d{4}-\d{2}-\d{2}$/.test(query.to) + ? `${query.to}T23:59:59.999Z` + : query.to, + ).toISOString() + : 'now', query.instrumentUid ?? null, categories, limit, @@ -129,9 +129,9 @@ export class BrokerageOperationsService { source.priority === 'screen' && source.accountId && source.from && - source.to ? - [{ accountId: source.accountId, from: source.from, to: source.to }] : - [], + source.to + ? [{ accountId: source.accountId, from: source.from, to: source.to }] + : [], ); if (query.sync !== 'observe') { const discovery = await this.membership.request( @@ -159,9 +159,9 @@ export class BrokerageOperationsService { accountId === 'all' || account.accountId === accountId, ) .flatMap((account) => - currentRange.from < currentRange.to ? - [{ accountId: account.accountId, ...currentRange }] : - [], + currentRange.from < currentRange.to + ? [{ accountId: account.accountId, ...currentRange }] + : [], ); await this.sync.readPlan(userId, targets, { movingEnd, @@ -181,16 +181,16 @@ export class BrokerageOperationsService { const saved = await this.repository.readPage(readInput); const accounts = saved?.connection.accounts ?? []; const targets = accounts.flatMap((account) => - from < to ? - [ + from < to + ? [ { accountId: account.accountId, from, to, instrumentUid: query.instrumentUid, }, - ] : - [], + ] + : [], ); const plan = planOperationRead(targets, saved?.coverage ?? [], now, { movingEnd, @@ -296,17 +296,17 @@ export class BrokerageOperationsService { const meta = investmentResourceMeta({ resourceKey: `operations/${createHash('sha256').update(filterKey).digest('base64url')}`, dataRevision: saved?.binding ?? 'missing', - availability: !available ? - 'missing' : - complete ? - 'complete' : - confirmed.length ? - 'partial' : - 'missing', + availability: !available + ? 'missing' + : complete + ? 'complete' + : confirmed.length + ? 'partial' + : 'missing', fetchedAt: lastSuccessfulAt, - freshForMs: plan.intervals.some((interval) => interval.stale) ? - STALE_FRESHNESS_MS : - Number.POSITIVE_INFINITY, + freshForMs: plan.intervals.some((interval) => interval.stale) + ? STALE_FRESHNESS_MS + : Number.POSITIVE_INFINITY, work: work ?? saved?.membershipWork ?? null, dependencies: [membershipMeta, ...metadataDependencies], coverage: { @@ -320,8 +320,8 @@ export class BrokerageOperationsService { (interval) => interval.accountId === account.accountId && interval.asOf, ), - ) ? - new Date( + ) + ? new Date( Math.min( ...accounts.map((account) => Math.max( @@ -334,42 +334,42 @@ export class BrokerageOperationsService { ), ), ), - ).toISOString() : - null, + ).toISOString() + : null, historyBoundary: knownBoundary ? 'known' : 'unknown', historyState: saved?.work.some( (work) => work.state === 'running' && work.priority > operationsSyncPriority.screen, - ) ? - 'loading' : - saved?.work.some( + ) + ? 'loading' + : saved?.work.some( (work) => work.state === 'failed' && work.priority > operationsSyncPriority.screen, - ) ? - 'failed' : - knownBoundary && - history.intervals.every( - (interval) => interval.availability === 'complete', - ) ? - 'complete' : - saved?.coverage.length ? - 'partial' : - 'missing', + ) + ? 'failed' + : knownBoundary && + history.intervals.every( + (interval) => interval.availability === 'complete', + ) + ? 'complete' + : saved?.coverage.length + ? 'partial' + : 'missing', }, }); const last = rows.at(LAST_ROW_INDEX); const hasNext = Boolean(saved && saved.rows.length > limit); return { data: - meta.availability === 'missing' ? - null : - { + meta.availability === 'missing' + ? null + : { accountId, items: rows.map((row) => ({ ...mapBrokerageOperation( row.payload as TbankOperationItem, row.occurredAt, ), - ...(accountId === 'all' ? - { + ...(accountId === 'all' + ? { account: { id: row.accountId, name: @@ -377,8 +377,8 @@ export class BrokerageOperationsService { (account) => account.accountId === row.accountId, )?.name ?? row.accountId, }, - } : - {}), + } + : {}), logoName: instruments .get(metadataKey(row.payload as TbankOperationItem)) @@ -386,16 +386,16 @@ export class BrokerageOperationsService { })), hasNext, nextCursor: - hasNext && last && saved ? - this.encodeCursor({ + hasNext && last && saved + ? this.encodeCursor({ binding: saved.binding, movingEnd, to: to.toISOString(), at: last.occurredAt.toISOString(), accountId: last.accountId, id: last.brokerOperationId, - }) : - null, + }) + : null, observedAt: now.toISOString(), }, meta, @@ -560,13 +560,13 @@ export class BrokerageOperationsService { // eslint-disable-next-line security/detect-object-injection -- The numeric index enumerates the three comparison periods. resourceKey: `income/${accountId}/${['selected', 'previous', 'yearAgo'][index]}/${period.key}`, dataRevision: saved?.binding ?? 'missing', - availability: !known ? - 'missing' : - complete ? - 'complete' : - sources.length ? - 'partial' : - 'missing', + availability: !known + ? 'missing' + : complete + ? 'complete' + : sources.length + ? 'partial' + : 'missing', fetchedAt, freshForMs: plan.intervals.some((range) => range.stale) ? STALE_FRESHNESS_MS : Infinity, work, @@ -579,8 +579,8 @@ export class BrokerageOperationsService { confirmed, ).map(serialize), asOf: - complete && targets.length ? - new Date( + complete && targets.length + ? new Date( Math.min( ...targets.map((target) => Math.max( @@ -595,24 +595,24 @@ export class BrokerageOperationsService { ), ), ), - ).toISOString() : - null, + ).toISOString() + : null, historyBoundary: (saved?.connection.accounts ?? []).every( (account) => account.openedOn, - ) ? - 'known' : - 'unknown', - historyState: complete ? - 'complete' : - sources.length ? - 'partial' : - 'missing', + ) + ? 'known' + : 'unknown', + historyState: complete + ? 'complete' + : sources.length + ? 'partial' + : 'missing', }, }); return { meta, - items: complete ? - (saved?.rows ?? []) + items: complete + ? (saved?.rows ?? []) .filter((row) => plan.intervals.some( (interval) => @@ -626,8 +626,8 @@ export class BrokerageOperationsService { row.payload as TbankOperationItem, row.occurredAt, ), - ...(accountId === 'all' ? - { + ...(accountId === 'all' + ? { account: { id: row.accountId, name: @@ -635,10 +635,10 @@ export class BrokerageOperationsService { (account) => account.accountId === row.accountId, )?.name ?? row.accountId, }, - } : - {}), - })) : - null, + } + : {}), + })) + : null, }; }); } diff --git a/src/modules/investments/operations/domain/operations-sync-plan.ts b/src/modules/investments/operations/domain/operations-sync-plan.ts index 3105e64..6477330 100644 --- a/src/modules/investments/operations/domain/operations-sync-plan.ts +++ b/src/modules/investments/operations/domain/operations-sync-plan.ts @@ -93,12 +93,12 @@ export function uncoveredOperationIntervals( return [fragment]; } return [ - ...(fragment.from < covered.from ? - [{ ...fragment, to: covered.from }] : - []), - ...(covered.to < fragment.to ? - [{ ...fragment, from: covered.to }] : - []), + ...(fragment.from < covered.from + ? [{ ...fragment, to: covered.from }] + : []), + ...(covered.to < fragment.to + ? [{ ...fragment, from: covered.to }] + : []), ]; }); } @@ -176,20 +176,20 @@ export function planOperationRead( // Completeness is bounded by the observed end, never by the new wall clock. return { ...effective, - availability: complete ? - ('complete' as const) : - sources.length ? - ('partial' as const) : - ('missing' as const), + availability: complete + ? ('complete' as const) + : sources.length + ? ('partial' as const) + : ('missing' as const), stale, - asOf: sources.length ? - new Date( + asOf: sources.length + ? new Date( Math.min( effective.to.valueOf(), Math.max(...sources.map((row) => row.asOf.valueOf())), ), - ) : - null, + ) + : null, }; }); return { intervals, load: operationIntervals(load) }; diff --git a/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts b/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts index 735c04b..17782cb 100644 --- a/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts +++ b/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts @@ -61,14 +61,14 @@ export function mapBrokerageOperation( logoName: null, payment: money(operation.payment), quantity: - (operationCategory(operation.type) === 'trade' ? - operation.quantityDone : - operation.quantity) === undefined ? - null : - String( - operationCategory(operation.type) === 'trade' ? - operation.quantityDone : - operation.quantity, + (operationCategory(operation.type) === 'trade' + ? operation.quantityDone + : operation.quantity) === undefined + ? null + : String( + operationCategory(operation.type) === 'trade' + ? operation.quantityDone + : operation.quantity, ), price: money(operation.price), accruedInterest: money(operation.accruedInt), @@ -82,9 +82,9 @@ function text(value: string | undefined): null | string { function money(value: TbankMoney | undefined) { const currency = value?.currency?.trim().toUpperCase(); - return currency && value ? - { currency, value: formatDecimal(decimal(value)) } : - null; + return currency && value + ? { currency, value: formatDecimal(decimal(value)) } + : null; } export function operationTimestamp( diff --git a/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts b/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts index 2d3adc9..5c08d06 100644 --- a/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts +++ b/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts @@ -110,9 +110,9 @@ export class BrokerageOperationsRepository { // 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. const oldest = accounts.map((account) => account.openedOn).sort()[0]; const openedAt = - oldest && accounts.every((account) => account.openedOn) ? - new Date(`${oldest}T00:00:00+03:00`) : - undefined; + oldest && accounts.every((account) => account.openedOn) + ? new Date(`${oldest}T00:00:00+03:00`) + : undefined; const periods = input.periods?.(openedAt).map((period) => { const now = new Date(); const bounds = dateOnlyBounds(period, now); @@ -176,21 +176,21 @@ export class BrokerageOperationsRepository { const rows = await tx.tbankOperation.findMany({ where: { ...scope, - ...(periods ? - { + ...(periods + ? { OR: periods.map((period) => ({ occurredAt: { gte: period.from, lt: period.to }, })), - } : - { occurredAt: { gte: input.from, lt: input.to } }), - ...(input.instrumentUid ? - { instrumentUid: input.instrumentUid } : - {}), - ...(input.categories.length ? - { category: { in: input.categories } } : - {}), - ...(after ? - { + } + : { occurredAt: { gte: input.from, lt: input.to } }), + ...(input.instrumentUid + ? { instrumentUid: input.instrumentUid } + : {}), + ...(input.categories.length + ? { category: { in: input.categories } } + : {}), + ...(after + ? { OR: [ { occurredAt: { lt: new Date(after.at) } }, { @@ -203,8 +203,8 @@ export class BrokerageOperationsRepository { brokerOperationId: { gt: after.id }, }, ], - } : - {}), + } + : {}), }, orderBy: [ { occurredAt: 'desc' }, @@ -305,9 +305,9 @@ export class BrokerageOperationsRepository { }, data: { historyFrom: new Date( - account.openedOn ? - `${account.openedOn}T00:00:00Z` : - '1970-01-01T00:00:00Z', + account.openedOn + ? `${account.openedOn}T00:00:00Z` + : '1970-01-01T00:00:00Z', ), historyBoundary: account.openedOn ? 'known' : 'unknown', }, @@ -383,42 +383,42 @@ export class BrokerageOperationsRepository { ) { const pending = previousIntent?.intervals.map(decodeInterval) ?? []; const additions = - previous.scopeKey === scopeKey ? - [] : - uncoveredOperationIntervals(intervals, pending); + previous.scopeKey === scopeKey + ? [] + : uncoveredOperationIntervals(intervals, pending); const work = await tx.tbankSyncWork.update({ where: { id: previous.id }, data: { intent: { atomic: previousIntent?.atomic ?? publicationAtomic, - atomicIntervals: previousIntent?.atomic ? - [ + atomicIntervals: previousIntent?.atomic + ? [ ...new Set([ ...(previousIntent.atomicIntervals ?? previousIntent.intervals), ...additions.map(intervalKey), ]), - ] : - [], + ] + : [], screenIntervals: [ ...new Set([ ...(previousIntent?.screenIntervals ?? - (previous.priority === operationsSyncPriority.screen ? - (previousIntent?.intervals ?? []) : - [])), - ...(priority === operationsSyncPriority.screen ? - [...pending, ...additions] + (previous.priority === operationsSyncPriority.screen + ? (previousIntent?.intervals ?? []) + : [])), + ...(priority === operationsSyncPriority.screen + ? [...pending, ...additions] .filter((item) => intervals.some((target) => operationIntervalsOverlap(item, target), ), ) - .map(intervalKey) : - []), + .map(intervalKey) + : []), ]), ], - intervals: (priority < previous.priority ? - [ + intervals: (priority < previous.priority + ? [ ...pending.filter((item) => intervals.some((target) => operationIntervalsOverlap(item, target), @@ -431,8 +431,8 @@ export class BrokerageOperationsRepository { operationIntervalsOverlap(item, target), ), ), - ] : - [...pending, ...additions] + ] + : [...pending, ...additions] ).map(intervalKey), }, priority: Math.min(priority, previous.priority), @@ -444,9 +444,9 @@ export class BrokerageOperationsRepository { previous && previous.state !== 'complete' && previous.state !== 'superseded'; - const retained = recovering ? - (previousIntent?.intervals.map(decodeInterval) ?? []) : - []; + const retained = recovering + ? (previousIntent?.intervals.map(decodeInterval) ?? []) + : []; // A bridge between two active groups must fence both producers before // either can publish a subset of the newly combined result. for (const joined of touching) { @@ -469,23 +469,23 @@ export class BrokerageOperationsRepository { await tx.tbankSyncStage.deleteMany({ where: { workId: previous.id } }); } const targets = - recovering && !upgradeGroup && touching.length <= SINGLE_WORK_GROUP ? - previous?.scopeKey === scopeKey ? - retained : - [...retained, ...uncoveredOperationIntervals(intervals, retained)] : - operationIntervals([...retained, ...intervals]); + recovering && !upgradeGroup && touching.length <= SINGLE_WORK_GROUP + ? previous?.scopeKey === scopeKey + ? retained + : [...retained, ...uncoveredOperationIntervals(intervals, retained)] + : operationIntervals([...retained, ...intervals]); const atomicTargets = [ ...(publicationAtomic ? intervals : []), - ...(recovering && previousIntent?.atomic ? - (previousIntent.atomicIntervals ?? previousIntent.intervals).map( + ...(recovering && previousIntent?.atomic + ? (previousIntent.atomicIntervals ?? previousIntent.intervals).map( decodeInterval, - ) : - []), + ) + : []), ...touching.flatMap((work) => { const joined = work.intent as OperationsIntent; - return joined.atomic ? - (joined.atomicIntervals ?? joined.intervals).map(decodeInterval) : - []; + return joined.atomic + ? (joined.atomicIntervals ?? joined.intervals).map(decodeInterval) + : []; }), ]; const intent = { @@ -506,9 +506,9 @@ export class BrokerageOperationsRepository { )) || ( previousIntent?.screenIntervals ?? - (previous?.priority === operationsSyncPriority.screen ? - (previousIntent?.intervals ?? []) : - []) + (previous?.priority === operationsSyncPriority.screen + ? (previousIntent?.intervals ?? []) + : []) ).some((key) => operationIntervalsOverlap(target, decodeInterval(key)), ), @@ -563,20 +563,20 @@ export class BrokerageOperationsRepository { marker.payload as PageProgress, ]), ); - const group = intent.atomic ? - (intent.atomicIntervals ?? intent.intervals) : - intent.intervals; + const group = intent.atomic + ? (intent.atomicIntervals ?? intent.intervals) + : intent.intervals; const nextKey = group.find((key) => !progress.get(key)?.complete); return { priority: work.priority, atomic: intent.atomic, intervals: group.map(decodeInterval), - next: nextKey ? - { + next: nextKey + ? { interval: decodeInterval(nextKey), cursor: progress.get(nextKey)?.cursor || undefined, - } : - null, + } + : null, }; } @@ -795,9 +795,9 @@ export class BrokerageOperationsRepository { where: { ...scope, occurredAt: { gte: interval.from, lt: interval.to }, - ...(interval.instrumentUid ? - { instrumentUid: interval.instrumentUid } : - {}), + ...(interval.instrumentUid + ? { instrumentUid: interval.instrumentUid } + : {}), }, }); const incoming = new Set(rows.map((row) => row.itemKey)); @@ -902,9 +902,9 @@ export class BrokerageOperationsRepository { intent.screenIntervals ?? (work.priority === operationsSyncPriority.screen ? intent.intervals : []) ).includes(key), - ) ? - operationsSyncPriority.screen : - operationsSyncPriority.history, + ) + ? operationsSyncPriority.screen + : operationsSyncPriority.history, progress: { remainingIntervals: remaining.length }, }, }); diff --git a/src/modules/investments/portfolio/application/forecast.service.ts b/src/modules/investments/portfolio/application/forecast.service.ts index 13370d2..f896501 100644 --- a/src/modules/investments/portfolio/application/forecast.service.ts +++ b/src/modules/investments/portfolio/application/forecast.service.ts @@ -36,8 +36,8 @@ export class ForecastService { ...input, portfolioProjection: input.portfolio.dataRevision, portfolios: (input.portfolio.dependencies ?? []).flatMap((dependency) => - dependency.resourceKey.startsWith('portfolio/') ? - [ + dependency.resourceKey.startsWith('portfolio/') + ? [ { accountId: decodeURIComponent( dependency.resourceKey.slice('portfolio/'.length), @@ -46,21 +46,21 @@ export class ForecastService { (dependency as InvestmentResourceMetaDto).dataRevision, ), }, - ] : - [], + ] + : [], ), sources: input.sources.flatMap((source) => source.version ? [source.version] : [], ), timestamps: dependencies.flatMap((dependency) => - dependency.lastSuccessfulAt ? - [ + dependency.lastSuccessfulAt + ? [ { resourceKey: dependency.resourceKey, at: dependency.lastSuccessfulAt, }, - ] : - [], + ] + : [], ), }); const timestamps = stored?.sourceTimestamps as @@ -78,33 +78,33 @@ export class ForecastService { payload && 'positionIsStale' in payload && 'positionAt' in payload && - typeof payload.positionAt === 'string' ? - { + typeof payload.positionAt === 'string' + ? { ...payload, positionIsStale: tbankSourceNeedsRefresh( 'portfolio', new Date(payload.positionAt), new Date(Date.now()), ), - } : - payload; + } + : payload; return { data, meta: investmentResourceMeta({ resourceKey: input.scopeKey, dataRevision: String(stored?.dataRevision ?? MISSING_DATA_REVISION), - availability: stored ? - 'complete' : - dependencies.some( + availability: stored + ? 'complete' + : dependencies.some( (dependency) => dependency.availability !== 'missing', - ) ? - 'partial' : - 'missing', + ) + ? 'partial' + : 'missing', fetchedAt: first ? new Date(first) : null, freshForMs: - data && 'positionIsStale' in data && data.positionIsStale === true ? - STALE_FRESHNESS_MS : - Number.POSITIVE_INFINITY, + data && 'positionIsStale' in data && data.positionIsStale === true + ? STALE_FRESHNESS_MS + : Number.POSITIVE_INFINITY, work: work?.id ? { ...work, id: work.id } : null, dependencies, }), diff --git a/src/modules/investments/portfolio/application/portfolio.service.ts b/src/modules/investments/portfolio/application/portfolio.service.ts index dfd9390..e6930dc 100644 --- a/src/modules/investments/portfolio/application/portfolio.service.ts +++ b/src/modules/investments/portfolio/application/portfolio.service.ts @@ -186,15 +186,15 @@ export class PortfolioService { name: account.name, type: account.type, openedOn: account.openedOn, - snapshot: account.portfolio ? - { + snapshot: account.portfolio + ? { ...(account.portfolio.payload as unknown as PortfolioDto), lastSuccessfulAt: account.portfolio.fetchedAt.toISOString(), isStale: Date.now() - account.portfolio.fetchedAt.valueOf() >= portfolioFreshForMs, - } : - null, + } + : null, meta: investmentResourceMeta({ resourceKey: `portfolio/${encodeURIComponent(account.accountId)}`, dataRevision: String(account.portfolio?.dataRevision ?? MISSING_DATA_REVISION), @@ -220,8 +220,8 @@ export class PortfolioService { ); const metadataDependencies = [...metadata.entries()].flatMap( ([key, source]) => - source.complete && source.data ? - [ + source.complete && source.data + ? [ investmentResourceMeta({ resourceKey: `instrument/metadata/${key}`, dataRevision: source.dataRevision, @@ -230,20 +230,20 @@ export class PortfolioService { freshForMs: tbankSourceTtlMs.metadata, work: null, }), - ] : - [], + ] + : [], ); const portfolios = saved.accounts.flatMap((account) => - account.snapshot ? - [ + account.snapshot + ? [ { ...account.snapshot, positions: account.snapshot.positions.map((position) => { const shared = metadata.get(metadataKey(position))?.data; return { ...position, - ...(shared ? - { + ...(shared + ? { displayName: shared.name?.trim() || shared.ticker?.trim() || @@ -252,29 +252,29 @@ export class PortfolioService { isin: shared.isin?.trim() || position.isin, logoName: shared.brand?.logoName?.trim() || position.logoName, - } : - {}), - ...(accountId === 'all' ? - { account: { id: account.id, name: account.name } } : - {}), + } + : {}), + ...(accountId === 'all' + ? { account: { id: account.id, name: account.name } } + : {}), }; }), }, - ] : - [], + ] + : [], ); const lastSuccessfulAt = // 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. portfolios.map((portfolio) => portfolio.lastSuccessfulAt).sort()[0] ?? saved.membership.lastSuccessfulAt; const data = - missing || !lastSuccessfulAt ? - null : - { + missing || !lastSuccessfulAt + ? null + : { lastSuccessfulAt, - isStale: portfolios.length ? - portfolios.some((portfolio) => portfolio.isStale) : - saved.membership.freshness !== 'fresh', + isStale: portfolios.length + ? portfolios.some((portfolio) => portfolio.isStale) + : saved.membership.freshness !== 'fresh', totalsByCurrency: sumMoneyByCurrency( portfolios.flatMap((portfolio) => portfolio.totalsByCurrency), ), @@ -306,15 +306,15 @@ export class PortfolioService { ]), metadataDependencies.map((dependency) => dependency.dataRevision), ]), - availability: missing ? - portfolios.length ? - 'partial' : - 'missing' : - 'complete', + availability: missing + ? portfolios.length + ? 'partial' + : 'missing' + : 'complete', fetchedAt: lastSuccessfulAt ? new Date(lastSuccessfulAt) : null, - freshForMs: portfolios.length ? - portfolioFreshForMs : - membershipFreshForMs, + freshForMs: portfolios.length + ? portfolioFreshForMs + : membershipFreshForMs, work: work.id ? { ...work, id: work.id } : null, dependencies: [ saved.membership, @@ -478,8 +478,8 @@ export class PortfolioService { const absoluteReturn = currentPrice && averagePurchasePrice && - currentPrice.currency === averagePurchasePrice.currency ? - { + currentPrice.currency === averagePurchasePrice.currency + ? { currency: currentPrice.currency, value: formatDecimal( multiply( @@ -490,8 +490,8 @@ export class PortfolioService { quantity, ), ), - } : - null; + } + : null; return { figi, instrumentUid, @@ -523,8 +523,8 @@ export class PortfolioService { private money(value: TbankMoney | undefined): MoneyDto | null { const currency = value?.currency?.trim().toUpperCase(); - return currency && value ? - { currency, value: formatDecimal(decimal(value)) } : - null; + return currency && value + ? { currency, value: formatDecimal(decimal(value)) } + : null; } } diff --git a/src/modules/investments/portfolio/domain/portfolio-structure.ts b/src/modules/investments/portfolio/domain/portfolio-structure.ts index cdc656f..6b01a9a 100644 --- a/src/modules/investments/portfolio/domain/portfolio-structure.ts +++ b/src/modules/investments/portfolio/domain/portfolio-structure.ts @@ -43,9 +43,9 @@ export function portfolioStructure( currencies.set(money.currency, group); }; for (const position of positions) { - const type = ['BOND', 'STOCK'].includes(position.type) ? - position.type : - 'OTHER'; + const type = ['BOND', 'STOCK'].includes(position.type) + ? position.type + : 'OTHER'; if (position.currentValue) include(type, position.currentValue); else unvalued.set(type, (unvalued.get(type) ?? INITIAL_UNVALUED_COUNT) + POSITION_INCREMENT); } @@ -65,8 +65,8 @@ export function portfolioStructure( value: formatDecimal(value), // Rounded to six decimal places using integer arithmetic. Only the // resulting dimensionless percentage is passed to chart geometry. - sharePercent: canCalculateShares ? - formatDecimal({ + sharePercent: canCalculateShares + ? formatDecimal({ value: (value.value * DECIMAL_RADIX ** BigInt(total.scale - value.scale) * @@ -74,8 +74,8 @@ export function portfolioStructure( total.value / ROUNDING_DIVISOR) / total.value, scale: SHARE_PERCENT_DECIMAL_PLACES, - }) : - null, + }) + : null, })); return { currency, diff --git a/src/modules/investments/portfolio/infrastructure/forecast.repository.ts b/src/modules/investments/portfolio/infrastructure/forecast.repository.ts index 8f77124..d7987db 100644 --- a/src/modules/investments/portfolio/infrastructure/forecast.repository.ts +++ b/src/modules/investments/portfolio/infrastructure/forecast.repository.ts @@ -58,9 +58,9 @@ export class ForecastRepository { where: { userId_revision_scopeKey: key }, }); const fallback = - previous?.membershipRevision === input.membershipRevision ? - previous : - null; + previous?.membershipRevision === input.membershipRevision + ? previous + : null; if (!input.candidate) return fallback; const portfolios = await tx.tbankPortfolio.findMany({ where: { diff --git a/src/modules/investments/tbank-sync/tbank-screen.ts b/src/modules/investments/tbank-sync/tbank-screen.ts index 99d47f4..ca228f0 100644 --- a/src/modules/investments/tbank-sync/tbank-screen.ts +++ b/src/modules/investments/tbank-sync/tbank-screen.ts @@ -100,23 +100,23 @@ export function normalizeScreen( ...(accountId ? { accountId } : {}), ...(input.from ? { from: input.from } : {}), ...(input.to ? { to: input.to } : {}), - ...(input.instrumentUid ? - { instrumentUid: input.instrumentUid.toLowerCase() } : - {}), + ...(input.instrumentUid + ? { instrumentUid: input.instrumentUid.toLowerCase() } + : {}), ...(query ? { query } : {}), ...(input.candlePeriod ? { candlePeriod: input.candlePeriod } : {}), - ...(input.candleRange ? - { + ...(input.candleRange + ? { candleRange: { from: new Date(input.candleRange.from).toISOString(), to: new Date(input.candleRange.to).toISOString(), }, - } : - {}), - ...(input.categories ? - // 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. - { categories: [...new Set(input.categories)].sort() } : - {}), + } + : {}), + ...(input.categories + // 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. + ? { categories: [...new Set(input.categories)].sort() } + : {}), ...(input.limit !== undefined ? { limit: input.limit } : {}), }; } diff --git a/src/modules/investments/tbank-sync/tbank-sync.repository.ts b/src/modules/investments/tbank-sync/tbank-sync.repository.ts index 8189dd0..4fe800a 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.repository.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.repository.ts @@ -79,9 +79,9 @@ export class TbankSyncRepository { work, children: children.map((child) => progress.attempts?.[child.id] && - progress.attempts[child.id] !== child.attemptId ? - { ...child, state: 'cancelled', errorCode: null } : - child, + progress.attempts[child.id] !== child.attemptId + ? { ...child, state: 'cancelled', errorCode: null } + : child, ), progress, }; diff --git a/src/modules/investments/tbank-sync/tbank-sync.service.ts b/src/modules/investments/tbank-sync/tbank-sync.service.ts index 0bb01da..dcd94e7 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.service.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.service.ts @@ -120,9 +120,9 @@ export class TbankSyncService { ) ) { for (const account of scope.screen === 'stock' || - scope.screen === 'bond' ? - current.accounts : - selectedAccounts) { + scope.screen === 'bond' + ? current.accounts + : selectedAccounts) { const work = await this.portfolio.request( userId, revision, @@ -135,9 +135,9 @@ export class TbankSyncService { } const saved = await this.portfolio.read( userId, - scope.screen === 'stock' || scope.screen === 'bond' ? - 'all' : - accountScope, + scope.screen === 'stock' || scope.screen === 'bond' + ? 'all' + : accountScope, true, ); const positions = saved.data?.positions ?? []; @@ -145,8 +145,8 @@ export class TbankSyncService { ...new Map( positions.flatMap((position) => position.instrumentUid && - (position.type === 'STOCK' || position.type === 'BOND') ? - [ + (position.type === 'STOCK' || position.type === 'BOND') + ? [ [ position.instrumentUid, { @@ -155,27 +155,27 @@ export class TbankSyncService { historyFrom: null, }, ] as const, - ] : - [], + ] + : [], ), ).values(), ]; const plan = planInvestmentScreen( { ...scope, - candleRange: scope.candleRange ? - { + candleRange: scope.candleRange + ? { from: new Date(scope.candleRange.from), to: new Date(scope.candleRange.to), - } : - undefined, + } + : undefined, }, { accounts: selectedAccounts, instruments: - scope.screen === 'stocks' || scope.screen === 'bonds' ? - [] : - instruments, + scope.screen === 'stocks' || scope.screen === 'bonds' + ? [] + : instruments, membershipKnown: Boolean(current.membershipAt), }, now, @@ -185,18 +185,18 @@ export class TbankSyncService { source.kind === 'operations' && source.accountId && source.from && - source.to ? - [ + source.to + ? [ { accountId: source.accountId, from: source.from, to: source.to, - ...(source.instrumentUid ? - { instrumentUid: source.instrumentUid } : - {}), + ...(source.instrumentUid + ? { instrumentUid: source.instrumentUid } + : {}), }, - ] : - [], + ] + : [], ); if (intervals.length) { await add([ @@ -278,11 +278,11 @@ export class TbankSyncService { [uid], true, [ - type === 'share' ? - 'dividends' : - scope.screen === 'bond' ? - 'coupons' : - 'bond-events', + type === 'share' + ? 'dividends' + : scope.screen === 'bond' + ? 'coupons' + : 'bond-events', ], { from: source.from.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH), @@ -307,14 +307,14 @@ export class TbankSyncService { uid: scope.instrumentUid, instrumentType: scope.screen === 'bond' ? 'bond' : 'share', period: scope.candlePeriod ?? 'month', - ...(scope.candleRange ? - { + ...(scope.candleRange + ? { range: { from: new Date(scope.candleRange.from), to: new Date(scope.candleRange.to), }, - } : - {}), + } + : {}), }, now, true, @@ -346,9 +346,9 @@ export class TbankSyncService { await this.access.local(userId, intent.scope.accountId); const children = await Promise.all( saved.children.map((child) => - child.state === 'superseded' ? - this.operations.observe(userId, child.id) : - Promise.resolve(child), + child.state === 'superseded' + ? this.operations.observe(userId, child.id) + : Promise.resolve(child), ), ); const running = children.some((child) => child.state === 'running'); @@ -358,15 +358,15 @@ export class TbankSyncService { null; const state = saved.work.processRunId !== this.runId && - (!saved.progress.dispatched || running) ? - 'cancelled' : - !saved.progress.dispatched || running ? - 'running' : - failed ? - 'failed' : - children.some((child) => child.state === 'cancelled') ? - 'cancelled' : - 'succeeded'; + (!saved.progress.dispatched || running) + ? 'cancelled' + : !saved.progress.dispatched || running + ? 'running' + : failed + ? 'failed' + : children.some((child) => child.state === 'cancelled') + ? 'cancelled' + : 'succeeded'; const resources = await this.observe(userId, intent.scope); if ( (await this.access.local(userId, intent.scope.accountId)).revision !== @@ -423,9 +423,9 @@ export class TbankSyncService { typeof input.kind === 'string' ) { const range = - typeof input.rangeKey === 'string' && input.rangeKey.startsWith('[') ? - (JSON.parse(input.rangeKey) as string[]) : - null; + typeof input.rangeKey === 'string' && input.rangeKey.startsWith('[') + ? (JSON.parse(input.rangeKey) as string[]) + : null; const sources = await this.sources.readMany( userId, type, @@ -434,9 +434,9 @@ export class TbankSyncService { [ input.kind as Parameters< CatalogEnrichmentService['readMany'] - >[4] extends (infer K)[] | undefined ? - K : - never, + >[4] extends (infer K)[] | undefined + ? K + : never, ], range ? { from: range[0], to: range[1] } : undefined, ); @@ -492,19 +492,19 @@ export class TbankSyncService { ); } } - const observed = work.scopeKey.startsWith('operations:') ? - await this.operations.observe(userId, work.id) : - work; + const observed = work.scopeKey.startsWith('operations:') + ? await this.operations.observe(userId, work.id) + : work; const state = - observed.state === 'running' ? - observed.updatedAt < this.startedAt ? - 'cancelled' : - 'running' : - observed.state === 'failed' ? - 'failed' : - observed.state === 'cancelled' ? - 'cancelled' : - 'succeeded'; + observed.state === 'running' + ? observed.updatedAt < this.startedAt + ? 'cancelled' + : 'running' + : observed.state === 'failed' + ? 'failed' + : observed.state === 'cancelled' + ? 'cancelled' + : 'succeeded'; if ((await this.access.local(userId)).revision !== work.revision) { throw new NotFoundException(); } @@ -597,27 +597,27 @@ export class TbankSyncService { uid, instrumentType: type, period: scope.candlePeriod ?? 'month', - ...(scope.candleRange ? - { + ...(scope.candleRange + ? { range: { from: new Date(scope.candleRange.from), to: new Date(scope.candleRange.to), }, - } : - {}), + } + : {}), }, true, ), ); await add( - type === 'share' ? - this.stockIncome.readPosition(userId, uid, true) : - this.bondIncome.readPosition(userId, uid, true), + type === 'share' + ? this.stockIncome.readPosition(userId, uid, true) + : this.bondIncome.readPosition(userId, uid, true), ); await add( - type === 'share' ? - this.stockIncome.dividends(userId, uid, true) : - this.bondIncome.coupons(userId, uid, true), + type === 'share' + ? this.stockIncome.dividends(userId, uid, true) + : this.bondIncome.coupons(userId, uid, true), ); } return resources; diff --git a/src/modules/investments/upcoming-events/upcoming-events.service.ts b/src/modules/investments/upcoming-events/upcoming-events.service.ts index 5c208f3..dc30d92 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.service.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.service.ts @@ -154,15 +154,15 @@ export class UpcomingEventsService { }; const quantity = decimalFromString(position.quantity); const projected = - type === 'share' ? - (calendar.payload?.dividends ?? []) + type === 'share' + ? (calendar.payload?.dividends ?? []) .filter( (event) => event.dividendType?.toLowerCase() !== 'cancelled' && Boolean(event.recordDate), ) - .map((event) => dividendEvent(identity, quantity, event)) : - (calendar.payload?.bondEvents ?? []).map((event) => + .map((event) => dividendEvent(identity, quantity, event)) + : (calendar.payload?.bondEvents ?? []).map((event) => bondEvent(identity, quantity, event), ); events.push( @@ -172,9 +172,9 @@ export class UpcomingEventsService { ) .map((event) => ({ ...event, - ...(accountId === 'all' && position.account ? - { account: position.account } : - {}), + ...(accountId === 'all' && position.account + ? { account: position.account } + : {}), })), ); } @@ -185,14 +185,14 @@ export class UpcomingEventsService { a.type.localeCompare(b.type) || a.ticker.localeCompare(b.ticker), ); - const candidate: undefined | UpcomingEventsDto = ready ? - { + const candidate: undefined | UpcomingEventsDto = ready + ? { accountId, calculatedAt: new Date().toISOString(), events, incomeTotalsByCurrency: incomeTotals(events), - } : - undefined; + } + : undefined; return this.forecasts.result({ userId, revision: connection.revision, @@ -224,13 +224,13 @@ export function dividendEvent( .toISOString() .slice(ISO_DATE_START, ISO_DATE_LENGTH), dateKind: 'registry', - projectedAmount: event.dividendNet ? - projectedMoney( + projectedAmount: event.dividendNet + ? projectedMoney( quantity, formatDecimal(decimal(event.dividendNet)), event.dividendNet.currency, - ) : - null, + ) + : null, }; } @@ -248,9 +248,9 @@ export function bondEvent( date: event.date, dateKind: 'event', projectedAmount: - 'value' in event ? - projectedMoney(quantity, event.value, event.currency) : - null, + 'value' in event + ? projectedMoney(quantity, event.value, event.currency) + : null, }; } diff --git a/test/sqlite-regression.cjs b/test/sqlite-regression.cjs index e6e0c96..13e1298 100644 --- a/test/sqlite-regression.cjs +++ b/test/sqlite-regression.cjs @@ -64,13 +64,13 @@ async function check(name, overrides = {}, signal) { 'user database must be untouched', ); const expected = - signal === 'SIGINT' ? - 130 : - signal === 'SIGTERM' ? - 143 : - overrides.PRISMA_SCHEMA_ENGINE_BINARY ? - 1 : - 0; + signal === 'SIGINT' + ? 130 + : signal === 'SIGTERM' + ? 143 + : overrides.PRISMA_SCHEMA_ENGINE_BINARY + ? 1 + : 0; assert.equal(code, expected, `See ${name}.log`); console.log(`PASS ${name}: ${dirs.length} isolated databases`); } diff --git a/test/sqlite.cjs b/test/sqlite.cjs index 4bcedfc..6d13fe0 100644 --- a/test/sqlite.cjs +++ b/test/sqlite.cjs @@ -9,11 +9,11 @@ const available = readdirSync(__dirname) .sort(); const requested = process.argv.slice(2); const suites = - requested.length === 0 ? - ['app.e2e-spec.ts', 'bank-cards.e2e-spec.ts'] : - requested.length === 1 && requested[0] === '--all' ? - available : - requested; + requested.length === 0 + ? ['app.e2e-spec.ts', 'bank-cards.e2e-spec.ts'] + : requested.length === 1 && requested[0] === '--all' + ? available + : requested; if (suites.some((suite) => !available.includes(suite))) { throw new Error('Unknown e2e suite'); } diff --git a/test/tbank-ledger-local.e2e-spec.ts b/test/tbank-ledger-local.e2e-spec.ts index 00e0036..9f9d699 100644 --- a/test/tbank-ledger-local.e2e-spec.ts +++ b/test/tbank-ledger-local.e2e-spec.ts @@ -240,9 +240,9 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { data: { occurredAt: new Date(observedAt.valueOf() - 1000) }, }); const query = - periodKind === 'default' ? - '' : - `&to=${observedAt.toISOString().slice(0, 10)}${periodKind === 'today timestamp' ? 'T23:59:59.999Z' : ''}`; + periodKind === 'default' + ? '' + : `&to=${observedAt.toISOString().slice(0, 10)}${periodKind === 'today timestamp' ? 'T23:59:59.999Z' : ''}`; const page = (cursor?: string, observe = false) => request(app.getHttpServer()) .get( diff --git a/test/tbank-local-reads.e2e-spec.ts b/test/tbank-local-reads.e2e-spec.ts index 3536b66..8f4d307 100644 --- a/test/tbank-local-reads.e2e-spec.ts +++ b/test/tbank-local-reads.e2e-spec.ts @@ -339,9 +339,9 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { ]); await put().expect(200); upstream.getPortfolio.mockImplementation((_token, id) => - id === 'second' ? - Promise.reject(new Error('synthetic unavailable')) : - Promise.resolve({ + id === 'second' + ? Promise.reject(new Error('synthetic unavailable')) + : Promise.resolve({ positions: [ { instrumentUid: uid, diff --git a/test/tbank-metadata-persistence.e2e-spec.ts b/test/tbank-metadata-persistence.e2e-spec.ts index ef509bf..5e0dbea 100644 --- a/test/tbank-metadata-persistence.e2e-spec.ts +++ b/test/tbank-metadata-persistence.e2e-spec.ts @@ -351,9 +351,9 @@ describe('durable shared instrument metadata (SQLite)', () => { await service.read('owner', identity); await settled(); const identities = Array.from({ length: 1000 }, (_, index) => - index ? - { instrumentUid: `uid-${index}`, figi: `FIGI-${index}` } : - identity, + index + ? { instrumentUid: `uid-${index}`, figi: `FIGI-${index}` } + : identity, ); const saved = await service.savedMany(identities); expect(saved.size).toBe(1000); diff --git a/test/tbank-read-baseline.e2e-spec.ts b/test/tbank-read-baseline.e2e-spec.ts index 5a16f08..b3388b4 100644 --- a/test/tbank-read-baseline.e2e-spec.ts +++ b/test/tbank-read-baseline.e2e-spec.ts @@ -58,9 +58,9 @@ describe('T-Bank persisted read budget', () => { const operations = Array.from({ length: 100_000 }, (_, index) => { // 1,000 measured-month rows, 200 income receipts, remaining rows over 10 years. const time = - index < 1000 ? - Date.UTC(2026, 7, 1) + index * 2_500_000 : - Date.UTC(2016, 8, 1) + (index - 1000) * 3_160_000; + index < 1000 + ? Date.UTC(2026, 7, 1) + index * 2_500_000 + : Date.UTC(2016, 8, 1) + (index - 1000) * 3_160_000; const instrument = instruments[index % instruments.length]; return { id: `operation-${index}`, @@ -298,9 +298,9 @@ describe('T-Bank persisted read budget', () => { uid: instrument.uid, ticker: instrument.ticker, instrumentType: - instrument.instrumentKind === 'INSTRUMENT_TYPE_SHARE' ? - 'share' : - 'bond', + instrument.instrumentKind === 'INSTRUMENT_TYPE_SHARE' + ? 'share' + : 'bond', metadata: instrument, fetchedAt: now, }, @@ -487,13 +487,13 @@ describe('T-Bank persisted read budget', () => { runtime: process.version, hardware: { cpuQuota: - process.platform === 'linux' ? - readFileSync('/sys/fs/cgroup/cpu.max', 'utf8').trim() : - null, + process.platform === 'linux' + ? readFileSync('/sys/fs/cgroup/cpu.max', 'utf8').trim() + : null, memoryLimit: - process.platform === 'linux' ? - readFileSync('/sys/fs/cgroup/memory.max', 'utf8').trim() : - null, + process.platform === 'linux' + ? readFileSync('/sys/fs/cgroup/memory.max', 'utf8').trim() + : null, cpu: cpus()[0]?.model, cpus: cpus().length, memoryBytes: totalmem(), diff --git a/test/tbank-screen-sync.e2e-spec.ts b/test/tbank-screen-sync.e2e-spec.ts index f319d78..bf3c590 100644 --- a/test/tbank-screen-sync.e2e-spec.ts +++ b/test/tbank-screen-sync.e2e-spec.ts @@ -358,9 +358,9 @@ describe('screen sync HTTP and SQLite', () => { }), ) .mockImplementation(() => - outcome === 'failed' ? - Promise.reject(new Error('Synthetic outage')) : - Promise.resolve({ hasNext: false, items: [] }), + outcome === 'failed' + ? Promise.reject(new Error('Synthetic outage')) + : Promise.resolve({ hasNext: false, items: [] }), ); const accept = async (from: string, to: string) => { const response = await post({ diff --git a/test/upcoming-events.e2e-spec.ts b/test/upcoming-events.e2e-spec.ts index d19ee82..4a23c8d 100644 --- a/test/upcoming-events.e2e-spec.ts +++ b/test/upcoming-events.e2e-spec.ts @@ -296,9 +296,9 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { .body as UpcomingEventsResourceDto; await portfolio([position(shareUid, '7'), position(newUid, '5')]); upstream.getDividends.mockImplementation((_token: string, uid: string) => - uid === newUid ? - Promise.reject(new Error('new calendar unavailable')) : - Promise.resolve([]), + uid === newUid + ? Promise.reject(new Error('new calendar unavailable')) + : Promise.resolve([]), ); const pending = (await events(false).expect(200)) .body as UpcomingEventsResourceDto; From 33592c1d5ff582ce57b596ae4e2324b1cea2b0f4 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Mon, 28 Sep 2026 13:18:06 +0300 Subject: [PATCH 13/22] refactor: enforce enum machine codes Use owner enums for error and T-Bank protocol values with ESLint regression checks. Preserve open wire fallbacks and keep protocol imports independent of DI and Prisma. Verified npm ci, verify, 226 unit tests, 262 isolated e2e tests and unchanged OpenAPI. --- .ai/architecture.md | 14 ++-- .ai/conventions/engineering.md | 3 +- .ai/conventions/integrations.md | 4 +- eslint.config.mjs | 43 ++++++++++++ .../api-safe-error-response.decorator.ts | 4 +- src/common/error-code.ts | 8 +++ src/common/filters/http-exception.filter.ts | 16 +++-- src/integrations/moex/moex.client.ts | 12 ++-- src/integrations/ncr/ncr-ratings.client.ts | 4 +- .../tbank/instrument-metadata.repository.ts | 5 +- .../tbank/instrument-metadata.service.ts | 9 +-- .../tbank/tbank-access.service.ts | 9 +-- .../tbank/tbank-account.policy.ts | 9 +-- src/integrations/tbank/tbank-error-code.ts | 10 +++ .../tbank/tbank-membership.service.ts | 5 +- src/integrations/tbank/tbank-protocol.spec.ts | 42 +++++++++++ src/integrations/tbank/tbank-protocol.ts | 70 +++++++++++++++++++ src/integrations/tbank/tbank-token.service.ts | 5 +- src/integrations/tbank/tbank.client.spec.ts | 3 +- src/integrations/tbank/tbank.client.ts | 38 ++++++---- .../auth/application/auth-error-code.ts | 4 ++ src/modules/auth/application/auth.service.ts | 23 +++--- .../auth/infrastructure/jwt.strategy.ts | 3 +- .../auth/presentation/auth.controller.ts | 3 +- .../bank-cards/application/bank-card-input.ts | 35 +++++----- .../infrastructure/bank-cards.repository.ts | 9 +-- .../income/income-analytics.service.ts | 5 +- .../investments/income/income-period.ts | 4 +- .../instruments/application/candle.service.ts | 12 ++-- .../application/catalog-discovery.service.ts | 12 ++-- .../application/catalog-enrichment.service.ts | 7 +- .../application/catalog-selection.ts | 6 +- .../bonds/application/bond-income.service.ts | 5 +- .../domain/instrument-error-code.ts | 6 ++ .../catalog-discovery.repository.ts | 11 ++- .../brokerage-operations-sync.service.ts | 13 ++-- .../brokerage-operations.service.ts | 3 +- .../operations/domain/operation-error-code.ts | 3 + .../brokerage-operation.mapper.ts | 54 +++++++------- .../brokerage-operations.repository.ts | 9 ++- .../application/portfolio.service.ts | 5 +- .../infrastructure/portfolio.repository.ts | 8 +-- .../tbank-connection.service.ts | 3 +- .../tbank-sync/tbank-sync.service.ts | 3 +- .../upcoming-events.service.ts | 4 +- src/prisma/prisma-error-code.ts | 9 +++ test/eslint-config.cjs | 24 +++++++ 47 files changed, 432 insertions(+), 164 deletions(-) create mode 100644 src/common/error-code.ts create mode 100644 src/integrations/tbank/tbank-error-code.ts create mode 100644 src/integrations/tbank/tbank-protocol.spec.ts create mode 100644 src/integrations/tbank/tbank-protocol.ts create mode 100644 src/modules/auth/application/auth-error-code.ts create mode 100644 src/modules/investments/instruments/domain/instrument-error-code.ts create mode 100644 src/modules/investments/operations/domain/operation-error-code.ts create mode 100644 src/prisma/prisma-error-code.ts diff --git a/.ai/architecture.md b/.ai/architecture.md index 6adccfb..2dc1b5f 100644 --- a/.ai/architecture.md +++ b/.ai/architecture.md @@ -160,10 +160,11 @@ HTTP/e2e naming и расположение — в [Testing](conventions/testing `*.module.ts` задаёт DI boundary; `*.api.ts` перечисляет используемые другими модулями providers, функции и типы. Внешний production import идёт через этот -API или файл Nest module. Чистые `integrations/tbank/decimal.ts` и +API или файл Nest module. Чистые `integrations/tbank/decimal.ts`, +`integrations/tbank/tbank-protocol.ts`, `integrations/tbank/tbank-error-code.ts` и `modules/investments/operations/domain/operations-sync-plan.ts` — отдельные -публичные входы для protobuf conversion и interval rules: импорт расчётов -не загружает DI/Prisma. +публичные входы для protobuf conversion, protocol/error vocabulary и interval +rules: импорт этих значений и расчётов не загружает DI/Prisma. Внутри модуля используй прямые локальные импорты. Tests могут импортировать внутренний owner для проверки поведения и mocks. Новый export требует текущего потребителя; controller и persistence остаются @@ -251,7 +252,12 @@ OpenAPI enum metadata и runtime validation должны читать тот ж Исключения для индексов, default values, enum/type declarations и readonly properties заданы в ESLint config. Test fixtures/expected values остаются inline, остальные lint правила продолжают действовать. Numeric policy получает предметное имя и единицы; -semantic magic strings проверяются conventions/review без нового инструмента. +Machine codes и protocol values задаются строковыми enum у владельца. +`no-restricted-syntax` запрещает inline-коды и значения известных protocol families +в production TS; `no-unsafe-enum-comparison` проверяет сравнения enum. +Внешние открытые строки проверяются на boundary без cast и без потери unknown fallback. +Tests сохраняют независимые literal wire assertions; обычный текст и env keys +не считаются кодами. Новые словари требуют проверки владельца и расширения selectors. ## Logging diff --git a/.ai/conventions/engineering.md b/.ai/conventions/engineering.md index 98f5a1b..b100e4f 100644 --- a/.ai/conventions/engineering.md +++ b/.ai/conventions/engineering.md @@ -29,7 +29,8 @@ owner и 1–3 примера. Код показывает механизм; с ## Domain values Finite business vocabulary принадлежит одному domain/module/entity owner. -Переиспользуй его symbols; для нового словаря без аналога — `as const` + union. +Переиспользуй его symbols; для machine codes и protocol values используй +строковые `enum`, для структуры с разными payload — discriminated union. Сохраняй wire values; generated/external enums не переписывай вручную. Numeric policy называй по смыслу и единицам. Индексы, `Math.max(value, 0)` и обычные labels не выноси в constants без semantic benefit. diff --git a/.ai/conventions/integrations.md b/.ai/conventions/integrations.md index d742ab0..9b5f4d5 100644 --- a/.ai/conventions/integrations.md +++ b/.ai/conventions/integrations.md @@ -22,7 +22,9 @@ RPC, credentials/access и resilience. Эти владельцы не объед Преобразование provider records происходит на boundary: например, `tbank-portfolio.mapper.ts` и `bond-money.mapper.ts`; protobuf decimal helper -остаётся отдельным чистым входом integration. Domain получает внутренние +остаётся отдельным чистым входом integration. `tbank-protocol.ts` и +`tbank-error-code.ts` также импортируются напрямую: их enums и guards +не должны загружать DI/Prisma через service exports. Domain получает внутренние значения и не импортирует gRPC/SDK types. Не добавляй новый adapter на каждый client или mapper для простого копирования полей. Даты/units/nano разбираются на границе клиента/его mapping, а не становятся моделью чистых расчётов. diff --git a/eslint.config.mjs b/eslint.config.mjs index d5955f1..1e01eab 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -107,4 +107,47 @@ export default defineConfig( 'jest/expect-expect': ['error', { assertFunctionNames: ['expect', '**.expect'] }], }, }, + { + files: [ + 'src/**/*.ts', + ], + ignores: [ + '**/*.spec.ts', + 'test/**/*.ts', + ], + rules: { + 'no-restricted-syntax': [ + 'error', + { + selector: 'Literal[value=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember > Literal)', + message: 'Use the owning enum member for machine codes and protocol values.', + }, + { + selector: 'TemplateElement[value.cooked=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember TemplateElement)', + message: 'Use the owning enum member instead of a template machine code.', + }, + { + selector: 'Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > Literal.value[value=type(string)]', + message: 'Use an enum member instead of an inline code or protocol value.', + }, + { + selector: 'Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > TemplateLiteral.value', + message: 'Use an enum member instead of constructing a code or protocol value.', + }, + { + selector: 'Property[computed=false][key.name=/^(OPERATION_TYPE|ACCOUNT_TYPE)_/]', + message: 'Use a computed enum member as the protocol mapping key.', + }, + { + selector: 'BinaryExpression:has(> :matches(MemberExpression[property.name=/^(code|errorCode)$/], Identifier[name=/^(code|errorCode)$/])) > Literal[value=type(string)]', + message: 'Compare codes with their enum members, not string literals.', + }, + { + selector: 'PropertyDefinition[definite=true], VariableDeclarator[definite=true]', + message: 'Definite assignment assertions are forbidden. Initialize owned values; use declare only for externally populated DTO fields.', + }, + ], + '@typescript-eslint/no-unsafe-enum-comparison': 'error', + }, + }, ); diff --git a/src/common/decorators/api-safe-error-response.decorator.ts b/src/common/decorators/api-safe-error-response.decorator.ts index 81300b4..1c2ec64 100644 --- a/src/common/decorators/api-safe-error-response.decorator.ts +++ b/src/common/decorators/api-safe-error-response.decorator.ts @@ -7,11 +7,13 @@ import { getSchemaPath, } from '@nestjs/swagger'; +import { HttpErrorCode } from '../error-code'; + export class ProblemDetailsDto { @ApiProperty({ example: 401 }) declare status: number; - @ApiProperty({ example: 'UNAUTHORIZED' }) + @ApiProperty({ example: HttpErrorCode.Unauthorized }) declare code: string; // eslint-disable-next-line @darraghor/nestjs-typed/api-property-returning-array-should-set-array -- Raw OpenAPI items schema already declares type: array. diff --git a/src/common/error-code.ts b/src/common/error-code.ts new file mode 100644 index 0000000..db48c8a --- /dev/null +++ b/src/common/error-code.ts @@ -0,0 +1,8 @@ +export enum HttpErrorCode { + BadRequest = 'BAD_REQUEST', + InternalServerError = 'INTERNAL_SERVER_ERROR', + NotFound = 'NOT_FOUND', + Unauthorized = 'UNAUTHORIZED', + UnsupportedMediaType = 'UNSUPPORTED_MEDIA_TYPE', + ValidationError = 'VALIDATION_ERROR', +} diff --git a/src/common/filters/http-exception.filter.ts b/src/common/filters/http-exception.filter.ts index e228b30..2130736 100644 --- a/src/common/filters/http-exception.filter.ts +++ b/src/common/filters/http-exception.filter.ts @@ -9,6 +9,8 @@ import { ValidationError, } from '@nestjs/common'; +import { HttpErrorCode } from '../error-code'; + @Catch() export class HttpExceptionFilter implements ExceptionFilter { catch(exception: unknown, host: ArgumentsHost): void { @@ -25,7 +27,7 @@ export class HttpExceptionFilter implements ExceptionFilter { (payload as { message?: unknown })?.message, ); const code = isValidation - ? 'VALIDATION_ERROR' + ? HttpErrorCode.ValidationError : this.codeFor(status, payload); response.locals ??= {}; response.locals.publicErrorCode = code; @@ -53,12 +55,12 @@ export class HttpExceptionFilter implements ExceptionFilter { return ( // eslint-disable-next-line security/detect-object-injection -- The lookup key is a numeric HTTP status, never a property name from the request. { - 400: 'BAD_REQUEST', - 401: 'UNAUTHORIZED', - 404: 'NOT_FOUND', - 415: 'UNSUPPORTED_MEDIA_TYPE', - 500: 'INTERNAL_SERVER_ERROR', - }[status] ?? 'INTERNAL_SERVER_ERROR' + 400: HttpErrorCode.BadRequest, + 401: HttpErrorCode.Unauthorized, + 404: HttpErrorCode.NotFound, + 415: HttpErrorCode.UnsupportedMediaType, + 500: HttpErrorCode.InternalServerError, + }[status] ?? HttpErrorCode.InternalServerError ); } diff --git a/src/integrations/moex/moex.client.ts b/src/integrations/moex/moex.client.ts index 5b5d561..61f1f0e 100644 --- a/src/integrations/moex/moex.client.ts +++ b/src/integrations/moex/moex.client.ts @@ -1,6 +1,8 @@ import { Injectable, ServiceUnavailableException } from '@nestjs/common'; import { constants as httpStatus } from 'node:http2'; +enum MoexErrorCode { Unavailable = 'MOEX_UNAVAILABLE' } + const MIN_PRICE_PERCENT_EXCLUSIVE = 0; const FIRST_PAGE_OFFSET = 0; const EXPECTED_CURSOR_ROWS = 1; @@ -152,7 +154,7 @@ export class MoexClient { pageSize === EMPTY_PAGE_SIZE || current.length !== Math.min(pageSize, total - start) ) { - throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); + throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable }); } if (index + pageSize >= total) return rows; start = index + pageSize; @@ -172,22 +174,22 @@ export class MoexClient { } return body as IssResponse; } catch { - throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); + throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable }); } } } function parseRows(block: IssBlock | undefined): Record[] { if (!Array.isArray(block?.columns) || !Array.isArray(block?.data)) { - throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); + throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable }); } const columns = block.columns; if (!columns.every((column) => typeof column === 'string')) { - throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); + throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable }); } return block.data.map((values) => { if (!Array.isArray(values) || values.length !== columns.length) { - throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); + throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable }); } return Object.fromEntries( // eslint-disable-next-line security/detect-object-injection -- Keys are fixed MOEX block names; row indexes come from the validated columns array. diff --git a/src/integrations/ncr/ncr-ratings.client.ts b/src/integrations/ncr/ncr-ratings.client.ts index 257b58d..a5f110e 100644 --- a/src/integrations/ncr/ncr-ratings.client.ts +++ b/src/integrations/ncr/ncr-ratings.client.ts @@ -4,6 +4,8 @@ import { LRUCache } from 'lru-cache'; import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time'; +enum NcrErrorCode { Unavailable = 'NCR_UNAVAILABLE' } + const EXPECTED_REGISTRY_TABLES = 1; const RATING_COLUMN = 2; @@ -41,7 +43,7 @@ export class NcrRatingsClient { if (this.cache.get('registry') === pending) { this.cache.set('registry', pending, { ttl: 60_000 }); } - throw new ServiceUnavailableException({ code: 'NCR_UNAVAILABLE' }); + throw new ServiceUnavailableException({ code: NcrErrorCode.Unavailable }); }); this.cache.set('registry', pending); } diff --git a/src/integrations/tbank/instrument-metadata.repository.ts b/src/integrations/tbank/instrument-metadata.repository.ts index 7fc6819..4e8933f 100644 --- a/src/integrations/tbank/instrument-metadata.repository.ts +++ b/src/integrations/tbank/instrument-metadata.repository.ts @@ -11,6 +11,7 @@ import type { TbankSyncAttempt } from './tbank-access.service'; import type { TbankInstrument } from './tbank.client'; import { PrismaService } from '../../prisma/prisma.service'; +import { TbankErrorCode } from './tbank-error-code'; import { tbankSourceTtlMs } from './tbank-source.policy'; const FIRST_BATCH_OFFSET = 0; @@ -276,7 +277,7 @@ export class InstrumentMetadataRepository { }, data: { state: success ? 'complete' : 'failed', - errorCode: success ? null : 'TBANK_UNAVAILABLE', + errorCode: success ? null : TbankErrorCode.Unavailable, }, }); if (success) { @@ -286,7 +287,7 @@ export class InstrumentMetadataRepository { state: 'running', connection: { invalidToken: true }, }, - data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' }, + data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken }, }); } } diff --git a/src/integrations/tbank/instrument-metadata.service.ts b/src/integrations/tbank/instrument-metadata.service.ts index ee3c4a9..9dfb08a 100644 --- a/src/integrations/tbank/instrument-metadata.service.ts +++ b/src/integrations/tbank/instrument-metadata.service.ts @@ -12,6 +12,7 @@ import { TbankAccessService, type TbankSyncAttempt, } from './tbank-access.service'; +import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code'; import { TbankClient, type TbankInstrument } from './tbank.client'; export { metadataKey } from './instrument-metadata.repository'; export type { MetadataIdentity } from './instrument-metadata.repository'; @@ -131,7 +132,7 @@ export class InstrumentMetadataService { if ( !identity.figi || (error instanceof Error && - error.message === 'INVALID_TBANK_TOKEN') + isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) ) { throw error; } @@ -191,11 +192,11 @@ export class InstrumentMetadataService { ); } } catch (error) { - if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') { + if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) { await this.access.rejectMembershipToken(attempt); } else { this.logger.event('warn', 'instrument-metadata.failed', { - code: 'TBANK_UNAVAILABLE', + code: TbankErrorCode.Unavailable, }); } } @@ -209,7 +210,7 @@ export class InstrumentMetadataService { } } catch { this.logger.event('error', 'instrument-metadata.persistence-failed', { - code: 'TBANK_UNAVAILABLE', + code: TbankErrorCode.Unavailable, }); this.active.clear(); this.producers.clear(); diff --git a/src/integrations/tbank/tbank-access.service.ts b/src/integrations/tbank/tbank-access.service.ts index 588c9cb..eed498a 100644 --- a/src/integrations/tbank/tbank-access.service.ts +++ b/src/integrations/tbank/tbank-access.service.ts @@ -10,6 +10,7 @@ import type { TbankAccount } from './tbank.client'; import { PrismaService } from '../../prisma/prisma.service'; import { supportedTbankAccounts } from './tbank-account.policy'; +import { TbankErrorCode } from './tbank-error-code'; import { tbankSourceNeedsRefresh } from './tbank-source.policy'; import { TbankTokenService } from './tbank-token.service'; @@ -103,7 +104,7 @@ export class TbankAccessService { state: 'running', connection: { invalidToken: false }, }, - data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' }, + data: { state: 'failed', errorCode: TbankErrorCode.Unavailable }, }); return result.count === EXPECTED_UPDATED_CONNECTIONS; } @@ -205,7 +206,7 @@ export class TbankAccessService { return this.prisma.$transaction(async (tx) => { const claimed = await tx.tbankSyncWork.updateMany({ where: { ...attempt, state: 'running' }, - data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' }, + data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken }, }); if (!claimed.count) return false; await tx.tbankConnection.update({ @@ -236,10 +237,10 @@ export class TbankAccessService { }, }); if (!connection) { - throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' }); + throw new ConflictException({ code: TbankErrorCode.NotConnected }); } if (connection.invalidToken) { - throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }); + throw new BadRequestException({ code: TbankErrorCode.InvalidToken }); } if ( connection.membershipAt && diff --git a/src/integrations/tbank/tbank-account.policy.ts b/src/integrations/tbank/tbank-account.policy.ts index eb456e2..816ae98 100644 --- a/src/integrations/tbank/tbank-account.policy.ts +++ b/src/integrations/tbank/tbank-account.policy.ts @@ -1,14 +1,15 @@ import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../common/time'; +import { TbankAccountType } from './tbank-protocol'; import { TbankAccount } from './tbank.client'; const accountTypes = { - ACCOUNT_TYPE_TINKOFF: { type: 'BROKERAGE', name: 'Брокерский счет' }, - ACCOUNT_TYPE_TINKOFF_IIS: { + [TbankAccountType.Tinkoff]: { type: 'BROKERAGE', name: 'Брокерский счет' }, + [TbankAccountType.TinkoffIis]: { type: 'INDIVIDUAL_INVESTMENT', name: 'ИИС', }, - ACCOUNT_TYPE_INVEST_BOX: { type: 'INVEST_BOX', name: 'Инвесткопилка' }, - ACCOUNT_TYPE_INVEST_FUND: { + [TbankAccountType.InvestBox]: { type: 'INVEST_BOX', name: 'Инвесткопилка' }, + [TbankAccountType.InvestFund]: { type: 'MONEY_MARKET_FUND', name: 'Фонд денежного рынка', }, diff --git a/src/integrations/tbank/tbank-error-code.ts b/src/integrations/tbank/tbank-error-code.ts new file mode 100644 index 0000000..f74ecb0 --- /dev/null +++ b/src/integrations/tbank/tbank-error-code.ts @@ -0,0 +1,10 @@ +export enum TbankErrorCode { + InvalidToken = 'INVALID_TBANK_TOKEN', + NotConnected = 'TBANK_NOT_CONNECTED', + Unavailable = 'TBANK_UNAVAILABLE', +} + +// Error messages and persisted codes cross an open string boundary. +export function isTbankErrorCode(value: unknown, expected: TbankErrorCode): boolean { + return value === expected; +} diff --git a/src/integrations/tbank/tbank-membership.service.ts b/src/integrations/tbank/tbank-membership.service.ts index 784c3cc..625372d 100644 --- a/src/integrations/tbank/tbank-membership.service.ts +++ b/src/integrations/tbank/tbank-membership.service.ts @@ -6,6 +6,7 @@ import { TbankAccessService, type TbankSyncAttempt, } from './tbank-access.service'; +import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code'; import { TbankClient } from './tbank.client'; @Injectable() @@ -37,7 +38,7 @@ export class TbankMembershipService { .catch(() => this.logger.event('error', 'tbank.membership.persistence_failed', { userId, - code: 'TBANK_UNAVAILABLE', + code: TbankErrorCode.Unavailable, }), ) .finally(() => this.active.delete(attemptId)); @@ -62,7 +63,7 @@ export class TbankMembershipService { } catch (error) { await this.access.failMembership( attempt, - error instanceof Error && error.message === 'INVALID_TBANK_TOKEN', + error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken), ); } } diff --git a/src/integrations/tbank/tbank-protocol.spec.ts b/src/integrations/tbank/tbank-protocol.spec.ts new file mode 100644 index 0000000..e34be02 --- /dev/null +++ b/src/integrations/tbank/tbank-protocol.spec.ts @@ -0,0 +1,42 @@ +import { loadSync } from '@grpc/proto-loader'; +import { join } from 'node:path'; + +import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code'; +import { + InstrumentIdType, + isTbankProtocolValue, + TbankAccountStatus, + TbankAccountType, + TbankCandleInterval, + TbankCouponType, + TbankInstrumentType, + TbankOperationState, + TbankOperationType, +} from './tbank-protocol'; + +it('uses the string enum values declared by the vendored protocol', () => { + const directory = join(__dirname, 'proto/contracts'); + const definitions = loadSync(['users.proto', 'instruments.proto', 'operations.proto', 'marketdata.proto'].map((file) => join(directory, file)), { includeDirs: [directory], enums: String }); + for (const [name, values] of Object.entries({ + InstrumentIdType, + InstrumentType: TbankInstrumentType, + AccountStatus: TbankAccountStatus, + AccountType: TbankAccountType, + CandleInterval: TbankCandleInterval, + CouponType: TbankCouponType, + OperationState: TbankOperationState, + OperationType: TbankOperationType, + })) { + const definition = definitions[`tinkoff.public.invest.api.contract.v1.${name}`]; + expect(definition).toHaveProperty('format', 'Protocol Buffer 3 EnumDescriptorProto'); + const descriptor = definition as { type: { value: { name: string }[] } }; + expect(descriptor.type.value.map((member) => member.name)).toEqual(expect.arrayContaining(Object.values(values))); + } +}); + +it('recognizes supported wire values without accepting unknown ones', () => { + expect(isTbankProtocolValue('OPERATION_STATE_EXECUTED', TbankOperationState.Executed)).toBe(true); + expect(isTbankProtocolValue('NEW_UPSTREAM_STATE', TbankOperationState.Executed)).toBe(false); + expect(isTbankErrorCode('INVALID_TBANK_TOKEN', TbankErrorCode.InvalidToken)).toBe(true); + expect(isTbankErrorCode('NEW_UPSTREAM_ERROR', TbankErrorCode.InvalidToken)).toBe(false); +}); diff --git a/src/integrations/tbank/tbank-protocol.ts b/src/integrations/tbank/tbank-protocol.ts new file mode 100644 index 0000000..ebfbaf8 --- /dev/null +++ b/src/integrations/tbank/tbank-protocol.ts @@ -0,0 +1,70 @@ +// String wire values for the protocol subset used by this integration. +// proto-loader decodes enums as strings; vendor contracts remain the source. +export enum InstrumentIdType { + Figi = 'INSTRUMENT_ID_TYPE_FIGI', + Uid = 'INSTRUMENT_ID_TYPE_UID', +} + +export enum TbankInstrumentType { + Bond = 'INSTRUMENT_TYPE_BOND', + Share = 'INSTRUMENT_TYPE_SHARE', +} + +export enum TbankAccountStatus { + Open = 'ACCOUNT_STATUS_OPEN', +} + +export enum TbankAccountType { + InvestBox = 'ACCOUNT_TYPE_INVEST_BOX', + InvestFund = 'ACCOUNT_TYPE_INVEST_FUND', + Tinkoff = 'ACCOUNT_TYPE_TINKOFF', + TinkoffIis = 'ACCOUNT_TYPE_TINKOFF_IIS', +} + +export enum TbankCandleInterval { + Day = 'CANDLE_INTERVAL_DAY', +} + +export enum TbankOperationState { + Executed = 'OPERATION_STATE_EXECUTED', +} + +export enum TbankCouponType { + Floating = 'COUPON_TYPE_FLOATING', + Unspecified = 'COUPON_TYPE_UNSPECIFIED', +} + +export enum TbankOperationType { + BondTax = 'OPERATION_TYPE_BOND_TAX', + BrokerFee = 'OPERATION_TYPE_BROKER_FEE', + Buy = 'OPERATION_TYPE_BUY', + BuyCard = 'OPERATION_TYPE_BUY_CARD', + BuyMargin = 'OPERATION_TYPE_BUY_MARGIN', + Coupon = 'OPERATION_TYPE_COUPON', + DivExt = 'OPERATION_TYPE_DIV_EXT', + Dividend = 'OPERATION_TYPE_DIVIDEND', + DividendTax = 'OPERATION_TYPE_DIVIDEND_TAX', + DividendTransfer = 'OPERATION_TYPE_DIVIDEND_TRANSFER', + Input = 'OPERATION_TYPE_INPUT', + InputSecurities = 'OPERATION_TYPE_INPUT_SECURITIES', + MarginFee = 'OPERATION_TYPE_MARGIN_FEE', + Output = 'OPERATION_TYPE_OUTPUT', + OutputSecurities = 'OPERATION_TYPE_OUTPUT_SECURITIES', + Sell = 'OPERATION_TYPE_SELL', + SellCard = 'OPERATION_TYPE_SELL_CARD', + SellMargin = 'OPERATION_TYPE_SELL_MARGIN', + ServiceFee = 'OPERATION_TYPE_SERVICE_FEE', + SuccessFee = 'OPERATION_TYPE_SUCCESS_FEE', + Tax = 'OPERATION_TYPE_TAX', + TransBsBs = 'OPERATION_TYPE_TRANS_BS_BS', + TransIisBs = 'OPERATION_TYPE_TRANS_IIS_BS', + Unspecified = 'OPERATION_TYPE_UNSPECIFIED', +} + +// Provider responses may contain values newer than our supported protocol subset. +export function isTbankProtocolValue( + value: unknown, + expected: TbankCouponType | TbankInstrumentType | TbankOperationState | TbankOperationType, +): boolean { + return value === expected; +} diff --git a/src/integrations/tbank/tbank-token.service.ts b/src/integrations/tbank/tbank-token.service.ts index e029e07..92e80b5 100644 --- a/src/integrations/tbank/tbank-token.service.ts +++ b/src/integrations/tbank/tbank-token.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Environment } from '../../config/env.validation'; +import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code'; import { TbankCrypto } from './tbank.crypto'; @Injectable() @@ -26,8 +27,8 @@ export class TbankTokenService { try { return await operation(); } catch (error) { - if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') { - throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }); + if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) { + throw new BadRequestException({ code: TbankErrorCode.InvalidToken }); } throw error; } diff --git a/src/integrations/tbank/tbank.client.spec.ts b/src/integrations/tbank/tbank.client.spec.ts index 525ab85..56b6fb6 100644 --- a/src/integrations/tbank/tbank.client.spec.ts +++ b/src/integrations/tbank/tbank.client.spec.ts @@ -1,6 +1,7 @@ import { status } from '@grpc/grpc-js'; import { NotFoundException, ServiceUnavailableException } from '@nestjs/common'; +import { TbankOperationState } from './tbank-protocol'; import { TbankClient } from './tbank.client'; describe('TbankClient operations', () => { @@ -10,7 +11,7 @@ describe('TbankClient operations', () => { to: { seconds: '2' }, cursor: 'cursor', limit: 10, - state: 'OPERATION_STATE_EXECUTED' as const, + state: TbankOperationState.Executed, }; it('passes a cursor operations request to OperationsService', async () => { diff --git a/src/integrations/tbank/tbank.client.ts b/src/integrations/tbank/tbank.client.ts index 3cdcfb8..a3ff196 100644 --- a/src/integrations/tbank/tbank.client.ts +++ b/src/integrations/tbank/tbank.client.ts @@ -29,6 +29,14 @@ import type { import { StructuredLogger } from '../../common/logging/structured-logger.service'; import { MILLISECONDS_PER_SECOND } from '../../common/time'; import { Environment } from '../../config/env.validation'; +import { TbankErrorCode } from './tbank-error-code'; +import { + InstrumentIdType, + TbankAccountStatus, + TbankCandleInterval, + TbankInstrumentType, + TbankOperationState, +} from './tbank-protocol'; const INITIAL_REQUEST_COUNT = 0; const MIN_RATE_DELAY_MS = 1; @@ -115,7 +123,7 @@ export type TbankOperationsByCursorRequest = { from?: TbankTimestamp; instrumentId?: string; limit?: number; - state: 'OPERATION_STATE_EXECUTED'; + state: TbankOperationState.Executed; to?: TbankTimestamp; }; export type TbankOperationsByCursorResponse = { @@ -256,7 +264,7 @@ export class TbankClient { token, this.users, 'getAccounts', - { status: 'ACCOUNT_STATUS_OPEN' }, + { status: TbankAccountStatus.Open }, ).then((response) => response.accounts ?? []); } @@ -279,14 +287,14 @@ export class TbankClient { token: string, figi: string, ): Promise { - return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_FIGI', figi); + return this.getInstrument(token, InstrumentIdType.Figi, figi); } getInstrumentByUid( token: string, instrumentUid: string, ): Promise { - return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_UID', instrumentUid); + return this.getInstrument(token, InstrumentIdType.Uid, instrumentUid); } getDividends( @@ -314,7 +322,7 @@ export class TbankClient { 'findInstrument', { query, - instrumentKind: 'INSTRUMENT_TYPE_SHARE', + instrumentKind: TbankInstrumentType.Share, }, ).then((response) => response.instruments ?? []); } @@ -326,7 +334,7 @@ export class TbankClient { 'findInstrument', { query, - instrumentKind: 'INSTRUMENT_TYPE_BOND', + instrumentKind: TbankInstrumentType.Bond, }, ).then((response) => response.instruments ?? []); } @@ -337,7 +345,7 @@ export class TbankClient { this.sparseInstruments, 'bondBy', { - idType: 'INSTRUMENT_ID_TYPE_UID', + idType: InstrumentIdType.Uid, id: uid, }, ).then((response) => response.instrument ?? null); @@ -367,7 +375,7 @@ export class TbankClient { this.instruments, 'shareBy', { - idType: 'INSTRUMENT_ID_TYPE_UID', + idType: InstrumentIdType.Uid, id: uid, }, ).then((response) => response.instrument ?? null); @@ -446,7 +454,7 @@ export class TbankClient { to: { seconds: String(Math.floor(to.valueOf() / MILLISECONDS_PER_SECOND)), }, - interval: 'CANDLE_INTERVAL_DAY', + interval: TbankCandleInterval.Day, limit: 2400, }, ).then((response) => response.candles ?? []); @@ -454,7 +462,7 @@ export class TbankClient { private async getInstrument( token: string, - idType: 'INSTRUMENT_ID_TYPE_FIGI' | 'INSTRUMENT_ID_TYPE_UID', + idType: InstrumentIdType, id: string, ): Promise { try { @@ -484,7 +492,7 @@ export class TbankClient { const queueFull = this.queue.size >= MAX_QUEUE_SIZE; try { if (queueFull) { - throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }); + throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable }); } const response = await this.queue.run(() => this.request(token, client, method, request), @@ -498,7 +506,7 @@ export class TbankClient { } catch (error) { const grpcCode = (error as ServiceError).code; const failureContext = queueFull - ? { code: 'TBANK_UNAVAILABLE' } + ? { code: TbankErrorCode.Unavailable } : { grpcCode }; const retryable = queueFull ? metadataCall @@ -534,7 +542,7 @@ export class TbankClient { [status.PERMISSION_DENIED, status.UNAUTHENTICATED].includes(grpcCode) ) { // eslint-disable-next-line preserve-caught-error -- Discard raw upstream authentication errors at the integration boundary. - throw new Error('INVALID_TBANK_TOKEN'); + throw new Error(TbankErrorCode.InvalidToken); } if ( grpcCode === status.NOT_FOUND && @@ -542,10 +550,10 @@ export class TbankClient { ) { throw new NotFoundException(); } - throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }); + throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable }); } } - throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }); + throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable }); } private request( diff --git a/src/modules/auth/application/auth-error-code.ts b/src/modules/auth/application/auth-error-code.ts new file mode 100644 index 0000000..0b3bcc7 --- /dev/null +++ b/src/modules/auth/application/auth-error-code.ts @@ -0,0 +1,4 @@ +export enum AuthErrorCode { + EmailAlreadyRegistered = 'EMAIL_ALREADY_REGISTERED', + InvalidCredentials = 'INVALID_CREDENTIALS', +} diff --git a/src/modules/auth/application/auth.service.ts b/src/modules/auth/application/auth.service.ts index 19baedb..8410ab6 100644 --- a/src/modules/auth/application/auth.service.ts +++ b/src/modules/auth/application/auth.service.ts @@ -8,7 +8,10 @@ import * as bcrypt from 'bcrypt'; import { createHash, randomUUID } from 'crypto'; import { Session, User } from '../../../../generated/prisma/client'; +import { HttpErrorCode } from '../../../common/error-code'; +import { isPrismaErrorCode, PrismaErrorCode } from '../../../prisma/prisma-error-code'; import { PrismaService } from '../../../prisma/prisma.service'; +import { AuthErrorCode } from './auth-error-code'; import { AuthUser, RefreshPayload } from './auth.types'; const EXPECTED_ROTATED_SESSIONS = 1; @@ -37,7 +40,7 @@ export class AuthService { where: { email }, }); if (existing) { - throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' }); + throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered }); } let user: User; @@ -51,7 +54,7 @@ export class AuthService { }); } catch (error) { if (isUniqueConstraintError(error)) { - throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' }); + throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered }); } throw error; } @@ -70,7 +73,7 @@ export class AuthService { user?.passwordHash ?? DUMMY_PASSWORD_HASH, ); if (!user || !valid) { - throw new UnauthorizedException({ code: 'INVALID_CREDENTIALS' }); + throw new UnauthorizedException({ code: AuthErrorCode.InvalidCredentials }); } return this.createSession(user); } @@ -84,10 +87,10 @@ export class AuthService { algorithms: ['HS256'], }); } catch { - throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); + throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); } if (payload.type !== 'refresh') { - throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); + throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); } const session = await this.prisma.session.findUnique({ where: { id: payload.sid }, @@ -102,10 +105,10 @@ export class AuthService { if (session && !session.revokedAt) { await this.prisma.session.delete({ where: { id: session.id } }); } - throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); + throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); } if (session.refreshTokenHash !== this.hash(token)) { - throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); + throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); } return this.rotateSession(session, session.refreshTokenHash); } @@ -142,7 +145,7 @@ export class AuthService { async userById(id: string): Promise { const user = await this.prisma.user.findUnique({ where: { id } }); - if (!user) throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); + if (!user) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); return user; } @@ -183,7 +186,7 @@ export class AuthService { data, }); if (updated.count !== EXPECTED_ROTATED_SESSIONS) { - throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); + throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); } } else { await this.prisma.session.update({ where: { id: session.id }, data }); @@ -208,6 +211,6 @@ function isUniqueConstraintError(error: unknown): boolean { typeof error === 'object' && error !== null && 'code' in error && - (error as { code?: unknown }).code === 'P2002' + isPrismaErrorCode(error.code, PrismaErrorCode.UniqueConstraint) ); } diff --git a/src/modules/auth/infrastructure/jwt.strategy.ts b/src/modules/auth/infrastructure/jwt.strategy.ts index 96110de..ce4cfd9 100644 --- a/src/modules/auth/infrastructure/jwt.strategy.ts +++ b/src/modules/auth/infrastructure/jwt.strategy.ts @@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; +import { HttpErrorCode } from '../../../common/error-code'; import { Environment } from '../../../config/env.validation'; import { AccessPayload } from '../application/auth.types'; @@ -19,7 +20,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { validate(payload: AccessPayload): AccessPayload { if (payload.type !== 'access') { - throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); + throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); } return payload; } diff --git a/src/modules/auth/presentation/auth.controller.ts b/src/modules/auth/presentation/auth.controller.ts index c169f0e..8f78326 100644 --- a/src/modules/auth/presentation/auth.controller.ts +++ b/src/modules/auth/presentation/auth.controller.ts @@ -21,6 +21,7 @@ import { } from '@nestjs/swagger'; import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; +import { HttpErrorCode } from '../../../common/error-code'; import { AuthService } from '../application/auth.service'; import { AccessPayload } from '../application/auth.types'; import { CredentialsDto } from './auth.dto'; @@ -86,7 +87,7 @@ export class AuthController { ) { try { const token = this.refreshToken(request); - if (!token) throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); + if (!token) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); return this.respond(response, await this.auth.refresh(token)); } catch (error) { if (error instanceof UnauthorizedException) this.clearCookie(response); diff --git a/src/modules/banking/bank-cards/application/bank-card-input.ts b/src/modules/banking/bank-cards/application/bank-card-input.ts index 8bb0dd6..0fe28ac 100644 --- a/src/modules/banking/bank-cards/application/bank-card-input.ts +++ b/src/modules/banking/bank-cards/application/bank-card-input.ts @@ -22,24 +22,23 @@ import { const NEXT_MONTH_OFFSET = 1; const PREVIOUS_MONTH_LAST_DAY = 0; -export const BankErrorCode = { - InvalidInput: 'BANK_INVALID_INPUT', - VersionConflict: 'BANK_VERSION_CONFLICT', - NotFound: 'BANK_NOT_FOUND', - InvalidMoney: 'BANK_INVALID_MONEY', - InvalidDate: 'BANK_INVALID_DATE', - ImmutableOperation: 'BANK_IMMUTABLE_OPERATION', - InvalidRefund: 'BANK_INVALID_REFUND', - RefundLimit: 'BANK_REFUND_LIMIT', - DependentRefunds: 'BANK_DEPENDENT_REFUNDS', - IntentConflict: 'BANK_INTENT_CONFLICT', - OperationDeleted: 'BANK_OPERATION_DELETED', - CardArchived: 'BANK_CARD_ARCHIVED', - OpeningDateConflict: 'BANK_OPENING_DATE_CONFLICT', - WriteConflict: 'BANK_WRITE_CONFLICT', - DuplicateCategory: 'BANK_DUPLICATE_CATEGORY', -} as const; -export type BankErrorCode = (typeof BankErrorCode)[keyof typeof BankErrorCode]; +export enum BankErrorCode { + CardArchived = 'BANK_CARD_ARCHIVED', + DependentRefunds = 'BANK_DEPENDENT_REFUNDS', + DuplicateCategory = 'BANK_DUPLICATE_CATEGORY', + ImmutableOperation = 'BANK_IMMUTABLE_OPERATION', + IntentConflict = 'BANK_INTENT_CONFLICT', + InvalidDate = 'BANK_INVALID_DATE', + InvalidInput = 'BANK_INVALID_INPUT', + InvalidMoney = 'BANK_INVALID_MONEY', + InvalidRefund = 'BANK_INVALID_REFUND', + NotFound = 'BANK_NOT_FOUND', + OpeningDateConflict = 'BANK_OPENING_DATE_CONFLICT', + OperationDeleted = 'BANK_OPERATION_DELETED', + RefundLimit = 'BANK_REFUND_LIMIT', + VersionConflict = 'BANK_VERSION_CONFLICT', + WriteConflict = 'BANK_WRITE_CONFLICT', +} export function fail(code: BankErrorCode = BankErrorCode.InvalidInput): never { throw new BadRequestException({ code }); diff --git a/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts b/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts index 52a54e4..95a29ad 100644 --- a/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts +++ b/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; import { Prisma } from '../../../../../generated/prisma/client'; +import { isPrismaErrorCode, PrismaErrorCode } from '../../../../prisma/prisma-error-code'; import { PrismaService } from '../../../../prisma/prisma.service'; import { BankErrorCode, @@ -44,15 +45,15 @@ export class BankCardsRepository { ? error.code : ''; const busy = - code === 'P2034' || - code === 'P2028' || + isPrismaErrorCode(code, PrismaErrorCode.WriteConflict) || + isPrismaErrorCode(code, PrismaErrorCode.TransactionFailure) || (error instanceof Error && /SQLITE_BUSY|database is locked/.test(error.message)); - if ((busy || code === 'P2002') && attempt < MAX_WRITE_RETRY_COUNT) { + if ((busy || isPrismaErrorCode(code, PrismaErrorCode.UniqueConstraint)) && attempt < MAX_WRITE_RETRY_COUNT) { continue; } if (busy) conflict(BankErrorCode.WriteConflict); - if (code === 'P2002') conflict(BankErrorCode.DuplicateCategory); + if (isPrismaErrorCode(code, PrismaErrorCode.UniqueConstraint)) conflict(BankErrorCode.DuplicateCategory); throw error; } } diff --git a/src/modules/investments/income/income-analytics.service.ts b/src/modules/investments/income/income-analytics.service.ts index 0c8e551..480e37c 100644 --- a/src/modules/investments/income/income-analytics.service.ts +++ b/src/modules/investments/income/income-analytics.service.ts @@ -8,6 +8,7 @@ import { formatDecimal, subtract, } from '../../../common/decimal'; +import { isTbankProtocolValue, TbankOperationType } from '../../../integrations/tbank/tbank-protocol'; import { DateOnlyRange, moscowDate } from '../investment-calendar'; import { investmentResourceMeta } from '../investment-resource.mapper'; import { @@ -165,7 +166,7 @@ function qualifying(entries: BrokerageOperationDto[]): BrokerageOperationDto[] { return entries.filter( (entry) => Boolean(entry.payment) && - ['OPERATION_TYPE_COUPON', 'OPERATION_TYPE_DIVIDEND'].includes(entry.type), + [TbankOperationType.Coupon, TbankOperationType.Dividend].some((type) => isTbankProtocolValue(entry.type, type)), ); } @@ -184,7 +185,7 @@ 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 (isTbankProtocolValue(entry.type, TbankOperationType.Coupon)) { total.coupon = add(total.coupon, value); } else total.dividend = add(total.dividend, value); values.set(entry.payment.currency, total); diff --git a/src/modules/investments/income/income-period.ts b/src/modules/investments/income/income-period.ts index 32f91699..b4522f6 100644 --- a/src/modules/investments/income/income-period.ts +++ b/src/modules/investments/income/income-period.ts @@ -8,6 +8,8 @@ import { utcDate, } from '../investment-calendar'; +enum IncomeErrorCode { InvalidPeriod = 'INVALID_INCOME_PERIOD' } + const MONDAY_WEEKDAY_INDEX = 1; const INCLUSIVE_DAY_OFFSET = 1; const NEXT_DAY_OFFSET = 1; @@ -163,5 +165,5 @@ function yearAgo(range: DateOnlyRange): DateOnlyRange | null { } function invalidPeriod(): BadRequestException { - return new BadRequestException({ code: 'INVALID_INCOME_PERIOD' }); + return new BadRequestException({ code: IncomeErrorCode.InvalidPeriod }); } diff --git a/src/modules/investments/instruments/application/candle.service.ts b/src/modules/investments/instruments/application/candle.service.ts index 9566d4e..1c8f778 100644 --- a/src/modules/investments/instruments/application/candle.service.ts +++ b/src/modules/investments/instruments/application/candle.service.ts @@ -9,6 +9,7 @@ import { formatDecimal } from '../../../../common/decimal'; import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; import { MILLISECONDS_PER_DAY } from '../../../../common/time'; import { decimal } from '../../../../integrations/tbank/decimal'; +import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; import { TbankAccessService, TbankClient, @@ -23,6 +24,7 @@ import { candleWindows, utcDay, } from '../domain/candle-coverage'; +import { InstrumentErrorCode } from '../domain/instrument-error-code'; import { CandleRepository } from '../infrastructure/candle.repository'; import { CatalogEnrichmentService } from './catalog-enrichment.service'; import { timestamp } from './instrument-market'; @@ -71,8 +73,8 @@ function rangeFor( throw new ServiceUnavailableException({ code: input.instrumentType === 'share' - ? 'STOCK_HISTORY_START_UNAVAILABLE' - : 'BOND_HISTORY_START_UNAVAILABLE', + ? InstrumentErrorCode.StockHistoryStartUnavailable + : InstrumentErrorCode.BondHistoryStartUnavailable, }); } from.setTime(utcDay(new Date(history)).valueOf()); @@ -448,7 +450,7 @@ export class CandleService { const producer = this.queue.shift(); if (!producer) break; let success = false; - let code = 'TBANK_UNAVAILABLE'; + let code: string = TbankErrorCode.Unavailable; try { const result = await this.load(producer); if (result === null) { @@ -457,7 +459,7 @@ export class CandleService { } success = result; } catch (error) { - if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') { + if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) { await this.access.rejectMembershipToken(producer.attempt); } if (error instanceof ServiceUnavailableException) { @@ -523,7 +525,7 @@ export class CandleService { } } catch { this.logger.event('error', 'candles.persistence-failed', { - code: 'TBANK_UNAVAILABLE', + code: TbankErrorCode.Unavailable, }); } finally { this.active.clear(); diff --git a/src/modules/investments/instruments/application/catalog-discovery.service.ts b/src/modules/investments/instruments/application/catalog-discovery.service.ts index 6105b3a..9286681 100644 --- a/src/modules/investments/instruments/application/catalog-discovery.service.ts +++ b/src/modules/investments/instruments/application/catalog-discovery.service.ts @@ -6,6 +6,7 @@ import { import { randomUUID } from 'node:crypto'; import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; +import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; import { TbankAccessService, TbankClient, @@ -14,6 +15,7 @@ import { type TbankSyncAttempt, } from '../../../../integrations/tbank/tbank.api'; import { investmentResourceMeta } from '../../investment-resource.mapper'; +import { InstrumentErrorCode } from '../domain/instrument-error-code'; import { CatalogDiscoveryRepository, catalogQuery, @@ -209,8 +211,8 @@ export class CatalogDiscoveryService { throw new ConflictException({ code: instrumentType === 'share' - ? 'STOCK_TICKER_AMBIGUOUS' - : 'BOND_TICKER_AMBIGUOUS', + ? InstrumentErrorCode.StockTickerAmbiguous + : InstrumentErrorCode.BondTickerAmbiguous, }); } const result = lookup.data @@ -357,11 +359,11 @@ export class CatalogDiscoveryService { ); } } catch (error) { - if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') { + if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) { await this.access.rejectMembershipToken(attempt); } else { this.logger.event('warn', 'catalog-discovery.failed', { - code: 'TBANK_UNAVAILABLE', + code: TbankErrorCode.Unavailable, }); } } @@ -395,7 +397,7 @@ export class CatalogDiscoveryService { this.producers.clear(); this.queue.length = 0; this.logger.event('error', 'catalog-discovery.persistence-failed', { - code: 'TBANK_UNAVAILABLE', + code: TbankErrorCode.Unavailable, }); } finally { this.running = false; diff --git a/src/modules/investments/instruments/application/catalog-enrichment.service.ts b/src/modules/investments/instruments/application/catalog-enrichment.service.ts index 1b8e892..cfc78a0 100644 --- a/src/modules/investments/instruments/application/catalog-enrichment.service.ts +++ b/src/modules/investments/instruments/application/catalog-enrichment.service.ts @@ -14,6 +14,7 @@ import { import { type MoexBondEvent, MoexClient } from '../../../../integrations/moex/moex.api'; import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api'; import { decimal } from '../../../../integrations/tbank/decimal'; +import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; import { TbankAccessService, type TbankBond, @@ -520,11 +521,11 @@ export class CatalogEnrichmentService { } } catch (error) { failed = true; - if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') { + if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) { await this.access.rejectMembershipToken(producer.attempt); } else { this.logger.event('warn', 'catalog-enrichment.failed', { - code: 'TBANK_UNAVAILABLE', + code: TbankErrorCode.Unavailable, }); } } @@ -572,7 +573,7 @@ export class CatalogEnrichmentService { this.producers.clear(); this.queue.length = 0; this.logger.event('error', 'catalog-enrichment.persistence-failed', { - code: 'TBANK_UNAVAILABLE', + code: TbankErrorCode.Unavailable, }); } finally { this.running = false; diff --git a/src/modules/investments/instruments/application/catalog-selection.ts b/src/modules/investments/instruments/application/catalog-selection.ts index 2928a9e..471dbe4 100644 --- a/src/modules/investments/instruments/application/catalog-selection.ts +++ b/src/modules/investments/instruments/application/catalog-selection.ts @@ -1,5 +1,7 @@ import type { TbankShare } from '../../../../integrations/tbank/tbank.api'; +import { TbankInstrumentType } from '../../../../integrations/tbank/tbank-protocol'; + export const catalogSearchLimit = 30; export function selectCatalogInstruments( @@ -10,8 +12,8 @@ export function selectCatalogInstruments( const seen = new Set(); const kind = instrumentType === 'share' - ? 'INSTRUMENT_TYPE_SHARE' - : 'INSTRUMENT_TYPE_BOND'; + ? TbankInstrumentType.Share + : TbankInstrumentType.Bond; const preferredClass = instrumentType === 'share' ? 'TQBR' : 'TQOB'; return found .filter( diff --git a/src/modules/investments/instruments/bonds/application/bond-income.service.ts b/src/modules/investments/instruments/bonds/application/bond-income.service.ts index ab09102..b96b7d9 100644 --- a/src/modules/investments/instruments/bonds/application/bond-income.service.ts +++ b/src/modules/investments/instruments/bonds/application/bond-income.service.ts @@ -7,6 +7,7 @@ import { multiply, } from '../../../../../common/decimal'; import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time'; +import { isTbankProtocolValue, TbankCouponType } from '../../../../../integrations/tbank/tbank-protocol'; import { TbankAccessService, type TbankBond, @@ -241,10 +242,10 @@ export function bondMetrics( ) : null; const couponType = - next?.couponType && next.couponType !== 'COUPON_TYPE_UNSPECIFIED' + next?.couponType && !isTbankProtocolValue(next.couponType, TbankCouponType.Unspecified) ? next.couponType : instrument.floatingCouponFlag === true - ? 'COUPON_TYPE_FLOATING' + ? TbankCouponType.Floating : null; return { creditRating, diff --git a/src/modules/investments/instruments/domain/instrument-error-code.ts b/src/modules/investments/instruments/domain/instrument-error-code.ts new file mode 100644 index 0000000..3f5f103 --- /dev/null +++ b/src/modules/investments/instruments/domain/instrument-error-code.ts @@ -0,0 +1,6 @@ +export enum InstrumentErrorCode { + BondHistoryStartUnavailable = 'BOND_HISTORY_START_UNAVAILABLE', + BondTickerAmbiguous = 'BOND_TICKER_AMBIGUOUS', + StockHistoryStartUnavailable = 'STOCK_HISTORY_START_UNAVAILABLE', + StockTickerAmbiguous = 'STOCK_TICKER_AMBIGUOUS', +} diff --git a/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts b/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts index 46fe512..43547bf 100644 --- a/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts +++ b/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts @@ -3,12 +3,9 @@ import { randomUUID } from 'node:crypto'; import { isDeepStrictEqual } from 'node:util'; import type { Prisma } from '../../../../../generated/prisma/client'; -import type { - TbankBond, - TbankShare, - TbankSyncAttempt, -} from '../../../../integrations/tbank/tbank.api'; +import type { TbankBond, TbankShare, TbankSyncAttempt } from '../../../../integrations/tbank/tbank.api'; +import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; import { PrismaService } from '../../../../prisma/prisma.service'; const UNCHANGED_REVISION_INCREMENT = 0; @@ -462,7 +459,7 @@ export class CatalogDiscoveryRepository { state: 'running', connection: { invalidToken: true }, }, - data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' }, + data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken }, }); await tx.tbankSyncWork.updateMany({ where: { @@ -472,7 +469,7 @@ export class CatalogDiscoveryRepository { }, data: { state: success ? 'complete' : 'failed', - errorCode: success ? null : 'TBANK_UNAVAILABLE', + errorCode: success ? null : TbankErrorCode.Unavailable, }, }); }); diff --git a/src/modules/investments/operations/application/brokerage-operations-sync.service.ts b/src/modules/investments/operations/application/brokerage-operations-sync.service.ts index fdaad98..4207384 100644 --- a/src/modules/investments/operations/application/brokerage-operations-sync.service.ts +++ b/src/modules/investments/operations/application/brokerage-operations-sync.service.ts @@ -3,10 +3,9 @@ import { randomUUID } from 'node:crypto'; import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; import { MILLISECONDS_PER_SECOND } from '../../../../common/time'; -import { - TbankAccessService, - TbankClient, -} from '../../../../integrations/tbank/tbank.api'; +import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; +import { TbankOperationState } from '../../../../integrations/tbank/tbank-protocol'; +import { TbankAccessService, TbankClient } from '../../../../integrations/tbank/tbank.api'; import { accountHistory } from '../../investment-sync-plan'; import { type OperationInterval, @@ -208,7 +207,7 @@ export class BrokerageOperationsSyncService { this.logger.event( 'error', 'tbank.operations.sync.persistence_failed', - { userId: attempt.userId, code: 'TBANK_UNAVAILABLE' }, + { userId: attempt.userId, code: TbankErrorCode.Unavailable }, ); } const next = pending ? await this.repository.pending(attempt) : null; @@ -272,7 +271,7 @@ export class BrokerageOperationsSyncService { : {}), cursor, limit: operationsPageSize, - state: 'OPERATION_STATE_EXECUTED', + state: TbankOperationState.Executed, }); if ( !(await this.repository.stagePage(attempt, interval, cursor, response)) @@ -297,7 +296,7 @@ export class BrokerageOperationsSyncService { } catch (error) { await this.repository.fail( attempt, - error instanceof Error && error.message === 'INVALID_TBANK_TOKEN', + error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken), ); return false; } diff --git a/src/modules/investments/operations/application/brokerage-operations.service.ts b/src/modules/investments/operations/application/brokerage-operations.service.ts index 3a43f43..800dc29 100644 --- a/src/modules/investments/operations/application/brokerage-operations.service.ts +++ b/src/modules/investments/operations/application/brokerage-operations.service.ts @@ -17,6 +17,7 @@ import { ledgerRange, planInvestmentScreen, } from '../../investment-sync-plan'; +import { OperationErrorCode } from '../domain/operation-error-code'; import { operationIntervalsOverlap, operationsSyncPriority, @@ -442,7 +443,7 @@ export class BrokerageOperationsService { } return cursor as LocalOperationsCursor; } catch { - throw new BadRequestException({ code: 'INVALID_OPERATIONS_CURSOR' }); + throw new BadRequestException({ code: OperationErrorCode.InvalidOperationsCursor }); } } diff --git a/src/modules/investments/operations/domain/operation-error-code.ts b/src/modules/investments/operations/domain/operation-error-code.ts new file mode 100644 index 0000000..b3c10d4 --- /dev/null +++ b/src/modules/investments/operations/domain/operation-error-code.ts @@ -0,0 +1,3 @@ +export enum OperationErrorCode { + InvalidOperationsCursor = 'INVALID_OPERATIONS_CURSOR', +} diff --git a/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts b/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts index 17782cb..5d2a752 100644 --- a/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts +++ b/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts @@ -1,10 +1,8 @@ import { formatDecimal } from '../../../../common/decimal'; import { MILLISECONDS_PER_SECOND } from '../../../../common/time'; import { decimal } from '../../../../integrations/tbank/decimal'; -import { - TbankMoney, - TbankOperationItem, -} from '../../../../integrations/tbank/tbank.api'; +import { TbankOperationType } from '../../../../integrations/tbank/tbank-protocol'; +import { TbankMoney, TbankOperationItem } from '../../../../integrations/tbank/tbank.api'; import { BrokerageOperationDto, OperationCategory, @@ -15,29 +13,29 @@ const MISSING_TIMESTAMP_COMPONENT = 0; const NANOSECONDS_PER_MILLISECOND = 1_000_000; const types: Record = { - OPERATION_TYPE_BUY: 'trade', - OPERATION_TYPE_BUY_CARD: 'trade', - OPERATION_TYPE_SELL: 'trade', - OPERATION_TYPE_SELL_CARD: 'trade', - OPERATION_TYPE_BUY_MARGIN: 'trade', - OPERATION_TYPE_SELL_MARGIN: 'trade', - OPERATION_TYPE_COUPON: 'income', - OPERATION_TYPE_DIVIDEND: 'income', - OPERATION_TYPE_DIVIDEND_TRANSFER: 'income', - OPERATION_TYPE_DIV_EXT: 'income', - OPERATION_TYPE_TRANS_IIS_BS: 'transfer', - OPERATION_TYPE_TRANS_BS_BS: 'transfer', - OPERATION_TYPE_BOND_TAX: 'tax', - OPERATION_TYPE_TAX: 'tax', - OPERATION_TYPE_DIVIDEND_TAX: 'tax', - OPERATION_TYPE_SERVICE_FEE: 'fee', - OPERATION_TYPE_MARGIN_FEE: 'fee', - OPERATION_TYPE_BROKER_FEE: 'fee', - OPERATION_TYPE_SUCCESS_FEE: 'fee', - OPERATION_TYPE_INPUT: 'transfer', - OPERATION_TYPE_OUTPUT: 'transfer', - OPERATION_TYPE_INPUT_SECURITIES: 'transfer', - OPERATION_TYPE_OUTPUT_SECURITIES: 'transfer', + [TbankOperationType.Buy]: 'trade', + [TbankOperationType.BuyCard]: 'trade', + [TbankOperationType.Sell]: 'trade', + [TbankOperationType.SellCard]: 'trade', + [TbankOperationType.BuyMargin]: 'trade', + [TbankOperationType.SellMargin]: 'trade', + [TbankOperationType.Coupon]: 'income', + [TbankOperationType.Dividend]: 'income', + [TbankOperationType.DividendTransfer]: 'income', + [TbankOperationType.DivExt]: 'income', + [TbankOperationType.TransIisBs]: 'transfer', + [TbankOperationType.TransBsBs]: 'transfer', + [TbankOperationType.BondTax]: 'tax', + [TbankOperationType.Tax]: 'tax', + [TbankOperationType.DividendTax]: 'tax', + [TbankOperationType.ServiceFee]: 'fee', + [TbankOperationType.MarginFee]: 'fee', + [TbankOperationType.BrokerFee]: 'fee', + [TbankOperationType.SuccessFee]: 'fee', + [TbankOperationType.Input]: 'transfer', + [TbankOperationType.Output]: 'transfer', + [TbankOperationType.InputSecurities]: 'transfer', + [TbankOperationType.OutputSecurities]: 'transfer', }; export function operationCategory(type: string | undefined): OperationCategory { @@ -51,7 +49,7 @@ export function mapBrokerageOperation( return { id: operation.id ?? operation.cursor ?? '', occurredAt: occurredAt.toISOString(), - type: operation.type ?? 'OPERATION_TYPE_UNSPECIFIED', + type: operation.type ?? TbankOperationType.Unspecified, category: operationCategory(operation.type), description: operation.description?.trim() || operation.name?.trim() || '', figi: text(operation.figi), diff --git a/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts b/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts index 5c08d06..7181714 100644 --- a/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts +++ b/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts @@ -12,12 +12,15 @@ import type { TbankOperationsByCursorResponse, } from '../../../../integrations/tbank/tbank.api'; +import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; +import { isTbankProtocolValue, TbankOperationState } from '../../../../integrations/tbank/tbank-protocol'; import { PrismaService } from '../../../../prisma/prisma.service'; import { dateOnlyBounds, DateOnlyRange, moscowDate, } from '../../investment-calendar'; +import { OperationErrorCode } from '../domain/operation-error-code'; import { type OperationInterval, operationIntervals, @@ -137,7 +140,7 @@ export class BrokerageOperationsRepository { ) .digest('base64url'); if (input.cursor && input.cursor.binding !== binding) { - throw new BadRequestException({ code: 'INVALID_OPERATIONS_CURSOR' }); + throw new BadRequestException({ code: OperationErrorCode.InvalidOperationsCursor }); } const scope = { userId: input.userId, @@ -615,7 +618,7 @@ export class BrokerageOperationsRepository { where: { id: attempt.id }, data: { state: 'failed', - errorCode: invalidToken ? 'INVALID_TBANK_TOKEN' : 'TBANK_UNAVAILABLE', + errorCode: invalidToken ? TbankErrorCode.InvalidToken : TbankErrorCode.Unavailable, }, }); if (invalidToken) { @@ -784,7 +787,7 @@ export class BrokerageOperationsRepository { } const rows = staged.filter((row) => { const state = (row.payload as TbankOperationItem).state; - return !state || state === 'OPERATION_STATE_EXECUTED'; + return !state || isTbankProtocolValue(state, TbankOperationState.Executed); }); const scope = { userId: attempt.userId, diff --git a/src/modules/investments/portfolio/application/portfolio.service.ts b/src/modules/investments/portfolio/application/portfolio.service.ts index e6930dc..8da8363 100644 --- a/src/modules/investments/portfolio/application/portfolio.service.ts +++ b/src/modules/investments/portfolio/application/portfolio.service.ts @@ -17,6 +17,7 @@ import { RequestContextService } from '../../../../common/logging/request-contex import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; import { MoneyDto } from '../../../../common/money.dto'; import { decimal } from '../../../../integrations/tbank/decimal'; +import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; import { InstrumentMetadataService, type MetadataIdentity, @@ -387,7 +388,7 @@ export class PortfolioService { } catch (error) { if ( error instanceof Error && - error.message === 'INVALID_TBANK_TOKEN' + isTbankErrorCode(error.message, TbankErrorCode.InvalidToken) ) { await this.access.rejectMembershipToken(attempt); } else await this.repository.fail(attempt); @@ -412,7 +413,7 @@ export class PortfolioService { async get(userId: string, accountId: string): Promise { const result = await this.read(userId, accountId); if (!result.data) { - throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }); + throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable }); } return result.data; } diff --git a/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts b/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts index 22f3b5a..682e9de 100644 --- a/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts +++ b/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts @@ -4,10 +4,8 @@ import { randomUUID } from 'node:crypto'; import type { Prisma } from '../../../../../generated/prisma/client'; import type { PortfolioDto } from '../presentation/portfolio.dto'; -import { - tbankSourceNeedsRefresh, - tbankSourceTtlMs, -} from '../../../../integrations/tbank/tbank.api'; +import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; +import { tbankSourceNeedsRefresh, tbankSourceTtlMs } from '../../../../integrations/tbank/tbank.api'; import { PrismaService } from '../../../../prisma/prisma.service'; const EXPECTED_UPDATED_ACCOUNTS = 1; @@ -152,7 +150,7 @@ export class PortfolioRepository { state: 'running', connection: { invalidToken: false }, }, - data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' }, + data: { state: 'failed', errorCode: TbankErrorCode.Unavailable }, }); return result.count === EXPECTED_UPDATED_ACCOUNTS; } diff --git a/src/modules/investments/tbank-connection/tbank-connection.service.ts b/src/modules/investments/tbank-connection/tbank-connection.service.ts index 9bd15ad..cbde0b4 100644 --- a/src/modules/investments/tbank-connection/tbank-connection.service.ts +++ b/src/modules/investments/tbank-connection/tbank-connection.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { HttpErrorCode } from '../../../common/error-code'; import { supportedTbankAccounts, TbankClient, @@ -29,7 +30,7 @@ export class TbankConnectionService { async put(userId: string, value: string) { const token = value.trim(); - if (!token) throw new BadRequestException({ code: 'VALIDATION_ERROR' }); + if (!token) throw new BadRequestException({ code: HttpErrorCode.ValidationError }); const accounts = await this.tokens.translateInvalidToken(() => this.tbank.validateToken(token), ); diff --git a/src/modules/investments/tbank-sync/tbank-sync.service.ts b/src/modules/investments/tbank-sync/tbank-sync.service.ts index dcd94e7..96f0682 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.service.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.service.ts @@ -9,6 +9,7 @@ import { randomUUID } from 'node:crypto'; import type { InvestmentResourceMetaDto } from '../investment-resource.dto'; import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../common/time'; +import { TbankErrorCode } from '../../../integrations/tbank/tbank-error-code'; import { InstrumentMetadataService, TbankAccessService, @@ -325,7 +326,7 @@ export class TbankSyncService { await this.repository.progress(attempt, children, true); } catch { await this.repository - .progress(attempt, children, true, 'TBANK_UNAVAILABLE') + .progress(attempt, children, true, TbankErrorCode.Unavailable) .catch(() => undefined); } } diff --git a/src/modules/investments/upcoming-events/upcoming-events.service.ts b/src/modules/investments/upcoming-events/upcoming-events.service.ts index dc30d92..1f61e3f 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.service.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.service.ts @@ -31,6 +31,8 @@ import { UpcomingEventsQueryDto, } from './upcoming-events.dto'; +enum EventsErrorCode { InvalidPeriod = 'INVALID_EVENTS_PERIOD' } + const FIRST_BATCH_OFFSET = 0; const DEFAULT_EVENT_WINDOW_DAYS = 30; @@ -267,7 +269,7 @@ function dateWindow(query: UpcomingEventsQueryDto) { end.setUTCDate(end.getUTCDate() + DEFAULT_EVENT_WINDOW_DAYS); const to = query.to ?? end.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH); if (from > to) { - throw new BadRequestException({ code: 'INVALID_EVENTS_PERIOD' }); + throw new BadRequestException({ code: EventsErrorCode.InvalidPeriod }); } return { from, to }; } diff --git a/src/prisma/prisma-error-code.ts b/src/prisma/prisma-error-code.ts new file mode 100644 index 0000000..20843e3 --- /dev/null +++ b/src/prisma/prisma-error-code.ts @@ -0,0 +1,9 @@ +export enum PrismaErrorCode { + TransactionFailure = 'P2028', + UniqueConstraint = 'P2002', + WriteConflict = 'P2034', +} + +export function isPrismaErrorCode(value: unknown, expected: PrismaErrorCode): boolean { + return value === expected; +} diff --git a/test/eslint-config.cjs b/test/eslint-config.cjs index c186465..c574386 100644 --- a/test/eslint-config.cjs +++ b/test/eslint-config.cjs @@ -61,3 +61,27 @@ test('numeric literals require names, including previously ignored numbers and b assert.ok(!named.messages.some(({ ruleId }) => ruleId === '@typescript-eslint/no-magic-numbers'), literal); } }); + +// Single-use codes must be caught too; fixture literals remain independent wire assertions. +test('machine codes require enum members without banning ordinary text or Prisma selects', async () => { + const eslint = new ESLint(); + const filePath = 'src/main.ts'; + for (const code of ['export function check(code: string) { return code === "one-off-code"; }', 'export const result = `MOEX_UNAVAILABLE`;', 'export const result = { code: "one-off-code" };', 'export const result = { "errorCode": "one-off-code" };', 'export const result = { code: `dynamic-${Date.now()}` };', 'export const result = "MOEX_UNAVAILABLE";', 'export const result = ["INSTRUMENT_ID_TYPE_FIGI"];', 'export const result = { OPERATION_TYPE_COUPON: "income" };']) { + const [result] = await eslint.lintText(code, { filePath }); + assert.equal(result.fatalErrorCount, 0, code); + assert.ok(result.messages.some(({ ruleId }) => ruleId === 'no-restricted-syntax'), code); + } + for (const code of [ + 'enum Code { Unavailable = "MOEX_UNAVAILABLE" } export const result = { code: Code.Unavailable };', + 'export const select = { code: true, errorCode: true };', + 'export const label = "Please try again"; export const key = "JWT_SECRET";', + ]) { + const [result] = await eslint.lintText(code, { filePath }); + assert.equal(result.fatalErrorCount, 0, code); + assert.ok(!result.messages.some(({ ruleId }) => ruleId === 'no-restricted-syntax'), code); + } + const [fixture] = await eslint.lintText('export const result = { code: "MOEX_UNAVAILABLE" };', { filePath: 'src/integrations/moex/moex.client.spec.ts' }); + assert.ok(!fixture.messages.some(({ ruleId }) => ruleId === 'no-restricted-syntax')); + const [comparison] = await eslint.lintText('enum Code { Ready = "ready" } export function check(code: Code) { return code === "ready"; }', { filePath }); + assert.ok(comparison.messages.some(({ ruleId }) => ruleId === '@typescript-eslint/no-unsafe-enum-comparison')); +}); From b24433092bd7deeb12589ee4c1fbc7f9e6b2a8b0 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Mon, 28 Sep 2026 15:48:44 +0300 Subject: [PATCH 14/22] fix: resolve build imports before linting Resolve dist imports in runtime scripts against their TypeScript sources. Keep missing-module checks enabled and cover absent build output in the lint gate. Verified npm ci, prisma generate and full verify with dist absent at startup. --- eslint.config.mjs | 12 ++++++++++++ test/eslint-config.cjs | 29 +++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/eslint.config.mjs b/eslint.config.mjs index 1e01eab..998a57b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -60,6 +60,18 @@ export default defineConfig( files: ['**/*.mjs'], extends: [node.configs['flat/recommended-module']], }, + { + files: ['scripts/generate-openapi.cjs', 'test/fixtures/tbank-sync-crash.cjs', 'test/runtime-check.cjs'], + rules: { + // These scripts run after build; lint validates their source paths before dist exists. + 'n/no-missing-require': ['error', { + resolverConfig: { + alias: { '../dist': import.meta.dirname, '../../dist': import.meta.dirname }, + extensionAlias: { '.js': ['.ts', '.js'] }, + }, + }], + }, + }, { files: ['**/*.ts'], extends: [tseslint.configs.recommendedTypeChecked, importX.flatConfigs.typescript], diff --git a/test/eslint-config.cjs b/test/eslint-config.cjs index c574386..592f8c0 100644 --- a/test/eslint-config.cjs +++ b/test/eslint-config.cjs @@ -1,9 +1,38 @@ const { ESLint } = require('eslint'); const assert = require('node:assert/strict'); +const { mkdtempSync, rmSync, writeFileSync } = require('node:fs'); +const { basename, join } = require('node:path'); const { test } = require('node:test'); const eslint = new ESLint(); +test('runtime scripts resolve build imports against sources before dist exists', async () => { + const directory = mkdtempSync(join(process.cwd(), '.eslint-require-')); + try { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Test-owned temporary source with no compiled counterpart. + writeFileSync(join(directory, 'entry.ts'), 'export const value = true;'); + for (const [filePath, prefix] of [ + ['scripts/generate-openapi.cjs', '../'], + ['test/runtime-check.cjs', '../'], + ['test/fixtures/tbank-sync-crash.cjs', '../../'], + ]) { + const source = `${prefix}dist/${basename(directory)}/entry`; + const [result] = await eslint.lintText( + `require('${source}.js'); require('${source}'); require('node:fs'); require('eslint'); require('${source}-missing.js'); require('${prefix}missing-local.cjs'); require('wealthledger-missing-package');`, + { filePath }, + ); + assert.equal(result.fatalErrorCount, 0, filePath); + const missing = result.messages.filter(({ ruleId }) => ruleId === 'n/no-missing-require'); + assert.equal(missing.length, 3, filePath); + for (const target of [`${source}-missing.js`, `${prefix}missing-local.cjs`, 'wealthledger-missing-package']) { + assert.ok(missing.some(({ message }) => message.includes(`'${target}'`)), target); + } + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + // Protect the quality gate's file scopes when configs or plugin versions change. test('typed application code enforces async and unsafe-type checks', async () => { const [result] = await eslint.lintText( From 6cd9c2dd28f7f65eebb4dbfc0b459c52b0e8fe35 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Mon, 28 Sep 2026 16:23:58 +0300 Subject: [PATCH 15/22] perf: group catalog source reads by identity --- .../catalog-discovery.repository.ts | 16 ++++++- test/tbank-catalog-local.e2e-spec.ts | 44 +++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts b/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts index 43547bf..23cbc0e 100644 --- a/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts +++ b/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts @@ -123,11 +123,23 @@ export class CatalogDiscoveryRepository { } readSources(inputs: CatalogSource[]) { + // Share identity predicates across UIDs instead of preparing one OR per row. + const groups = new Map(); + for (const input of inputs) { + const key = JSON.stringify([ + input.kind, + sourceProvider(input), + input.kind === 'detail' ? input.instrumentType : (input.rangeKey ?? ''), + ]); + const group = groups.get(key); + if (group) group.push(input); + else groups.set(key, [input]); + } return this.prisma.tbankInstrumentSource.findMany({ where: { provider, - OR: inputs.map((input) => ({ - instrumentUid: input.uid, + OR: [...groups.values()].map(([input, ...rest]) => ({ + instrumentUid: { in: [input, ...rest].map((entry) => entry.uid) }, kind: input.kind, source: sourceProvider(input), rangeKey: diff --git a/test/tbank-catalog-local.e2e-spec.ts b/test/tbank-catalog-local.e2e-spec.ts index 611b42a..2b532c0 100644 --- a/test/tbank-catalog-local.e2e-spec.ts +++ b/test/tbank-catalog-local.e2e-spec.ts @@ -178,6 +178,50 @@ describe('local stock/bond search (SQLite HTTP)', () => { }); afterAll(async () => app?.close()); + it('reads only requested source identities when batching UIDs, types and periods', async () => { + const repository = app.get(CatalogDiscoveryRepository); + const fetchedAt = new Date(); + for (const uid of ['a', 'b']) { + await prisma.tbankInstrument.create({ + data: { provider: 'tbank', uid, instrumentType: 'share', metadata: {}, fetchedAt }, + }); + } + const expected = [ + { instrumentUid: 'a', source: 'tbank', kind: 'detail', rangeKey: 'share' }, + { instrumentUid: 'b', source: 'tbank', kind: 'detail', rangeKey: 'share' }, + { instrumentUid: 'a', source: 'tbank', kind: 'dividends', rangeKey: '2026' }, + { instrumentUid: 'b', source: 'tbank', kind: 'dividends', rangeKey: '2027' }, + { instrumentUid: 'b', source: 'moex', kind: 'bond-events', rangeKey: '' }, + { instrumentUid: 'a', source: 'ncr', kind: 'rating', rangeKey: '' }, + ]; + const distractors = [ + { instrumentUid: 'a', source: 'tbank', kind: 'detail', rangeKey: 'bond' }, + { instrumentUid: 'b', source: 'tbank', kind: 'dividends', rangeKey: '2026' }, + { instrumentUid: 'a', source: 'tbank', kind: 'dividends', rangeKey: '2027' }, + { instrumentUid: 'a', source: 'moex', kind: 'bond-events', rangeKey: '' }, + { instrumentUid: 'b', source: 'tbank', kind: 'bond-events', rangeKey: '' }, + { instrumentUid: 'a', source: 'tbank', kind: 'rating', rangeKey: '' }, + ]; + await prisma.tbankInstrumentSource.createMany({ + data: [...expected, ...distractors].map((identity) => ({ + ...identity, provider: 'tbank', fetchedAt, payload: identity, + })), + }); + const rows = await repository.readSources([ + { uid: 'a', instrumentType: 'share', kind: 'detail' }, + { uid: 'b', instrumentType: 'share', kind: 'detail' }, + { uid: 'a', instrumentType: 'share', kind: 'detail' }, + { uid: 'a', instrumentType: 'share', kind: 'dividends', rangeKey: '2026' }, + { uid: 'b', instrumentType: 'share', kind: 'dividends', rangeKey: '2027' }, + { uid: 'b', instrumentType: 'bond', kind: 'bond-events' }, + { uid: 'a', instrumentType: 'bond', kind: 'rating' }, + { uid: 'missing', instrumentType: 'share', kind: 'detail' }, + ]); + expect(rows.map((row) => row.payload)).toEqual(expect.arrayContaining(expected)); + expect(rows).toHaveLength(expected.length); + expect(await repository.readSources([])).toEqual([]); + }); + it('returns missing without waiting for discovery, then partial while enrichment is blocked; observe never launches work', async () => { const found = deferred<(typeof share)[]>(); const detail = deferred(); From 15a3d5f331a0368032a747b4acd60db824127d99 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Thu, 1 Oct 2026 20:47:42 +0300 Subject: [PATCH 16/22] fix(deps): resolve npm audit advisories Patch gRPC and the existing multer override. Refresh vulnerable transitive versions. Scope patched undici/basic-ftp to release-it; retain the audit gate. --- package-lock.json | 259 ++++++++-------------------------------------- package.json | 8 +- 2 files changed, 51 insertions(+), 216 deletions(-) diff --git a/package-lock.json b/package-lock.json index 697eecf..a37b5d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.2.3", "license": "UNLICENSED", "dependencies": { - "@grpc/grpc-js": "1.14.4", + "@grpc/grpc-js": "1.14.5", "@grpc/proto-loader": "0.8.1", "@nestjs/common": "12.0.1", "@nestjs/config": "12.0.0", @@ -1425,29 +1425,6 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@eslint/config-array/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@eslint/config-array/node_modules/minimatch": { "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", @@ -1536,9 +1513,9 @@ } }, "node_modules/@grpc/grpc-js": { - "version": "1.14.4", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", - "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "version": "1.14.5", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.5.tgz", + "integrity": "sha512-7VZM+SVdEcUUqSQeNI3zM8Qs/BhQKZndPo2h5VkYkAM8Iz0wJIa8mKV5ekQGqG8UUsnkQ0NMxIxwkIHYvj0qOw==", "license": "Apache-2.0", "dependencies": { "@grpc/proto-loader": "^0.8.0", @@ -2797,29 +2774,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@nestjs/cli/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@nestjs/cli/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@nestjs/cli/node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", @@ -5095,29 +5049,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", - "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", @@ -6270,6 +6201,16 @@ "@babel/core": "^7.11.0 || ^8.0.0-beta.1 || ^8.0.0" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -6304,9 +6245,9 @@ } }, "node_modules/basic-ftp": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", - "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-6.2.1.tgz", + "integrity": "sha512-bK67isD+lKq46AU8vNtjvMaT2ZqAOAmNCbxUHlFBRD4k15NWxyEjmaKtZPlgce58So4BNTjITGQOVTjL9y0ECA==", "dev": true, "license": "MIT", "engines": { @@ -6415,6 +6356,19 @@ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "license": "ISC" }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/browserslist": { "version": "4.28.8", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", @@ -6506,6 +6460,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, "license": "MIT" }, "node_modules/builtin-modules": { @@ -7019,6 +6974,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "dev": true, "engines": [ "node >= 6.0" ], @@ -8358,29 +8314,6 @@ } } }, - "node_modules/eslint-plugin-import-x/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/eslint-plugin-import-x/node_modules/brace-expansion": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", - "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/eslint-plugin-import-x/node_modules/minimatch": { "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", @@ -8587,29 +8520,6 @@ "eslint": "^8.0.0 || ^9.0.0 || ^10.0.0" } }, - "node_modules/eslint-plugin-sonarjs/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/eslint-plugin-sonarjs/node_modules/brace-expansion": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", - "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/eslint-plugin-sonarjs/node_modules/minimatch": { "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", @@ -8658,29 +8568,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/eslint/node_modules/eslint-visitor-keys": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", @@ -9053,9 +8940,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", - "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "funding": [ { "type": "github", @@ -9537,29 +9424,6 @@ "node": ">=10.13.0" } }, - "node_modules/glob/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/glob/node_modules/minimatch": { "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", @@ -9967,9 +9831,9 @@ } }, "node_modules/ip-address": { - "version": "10.7.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", - "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", + "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", "dev": true, "license": "MIT", "engines": { @@ -11641,14 +11505,13 @@ "license": "MIT" }, "node_modules/multer": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.3.0.tgz", - "integrity": "sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz", + "integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", "busboy": "^1.6.0", - "concat-stream": "^2.0.0", "type-is": "^1.6.18" }, "engines": { @@ -13213,16 +13076,6 @@ } } }, - "node_modules/release-it/node_modules/undici": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", - "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, "node_modules/remeda": { "version": "2.33.4", "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", @@ -14181,29 +14034,6 @@ "node": ">=18" } }, - "node_modules/test-exclude/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/test-exclude/node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -14234,9 +14064,9 @@ "license": "MIT" }, "node_modules/test-exclude/node_modules/glob/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, "license": "MIT", "dependencies": { @@ -14633,6 +14463,7 @@ "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "dev": true, "license": "MIT" }, "node_modules/typescript": { diff --git a/package.json b/package.json index 9ef6d3c..e98d7d7 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "test:lint": "node --test test/eslint-config.cjs" }, "dependencies": { - "@grpc/grpc-js": "1.14.4", + "@grpc/grpc-js": "1.14.5", "@grpc/proto-loader": "0.8.1", "@nestjs/common": "12.0.1", "@nestjs/config": "12.0.0", @@ -105,11 +105,15 @@ }, "overrides": { "@nestjs/platform-express": { - "multer": "2.3.0" + "multer": "2.4.0" }, "@prisma/config@7.10.0": { "deepmerge-ts": "8.0.2" }, + "release-it": { + "basic-ftp": "6.2.1", + "undici": "7.29.1" + }, "prisma": { "mysql2": "3.24.4" } From fb2e851e7e3f58c915ba1086212aa7957c3f9ed3 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Thu, 1 Oct 2026 20:53:00 +0300 Subject: [PATCH 17/22] refactor: share authentication result type --- src/modules/auth/application/auth.service.ts | 12 ++++++------ src/modules/auth/application/auth.types.ts | 6 ++++++ src/modules/auth/presentation/auth.controller.ts | 8 ++------ 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/modules/auth/application/auth.service.ts b/src/modules/auth/application/auth.service.ts index 8410ab6..6b0a2fa 100644 --- a/src/modules/auth/application/auth.service.ts +++ b/src/modules/auth/application/auth.service.ts @@ -12,7 +12,7 @@ import { HttpErrorCode } from '../../../common/error-code'; import { isPrismaErrorCode, PrismaErrorCode } from '../../../prisma/prisma-error-code'; import { PrismaService } from '../../../prisma/prisma.service'; import { AuthErrorCode } from './auth-error-code'; -import { AuthUser, RefreshPayload } from './auth.types'; +import { AuthenticationResult, AuthUser, RefreshPayload } from './auth.types'; const EXPECTED_ROTATED_SESSIONS = 1; @@ -35,7 +35,7 @@ export class AuthService { async register( email: string, password: string, - ): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> { + ): Promise { const existing = await this.prisma.user.findUnique({ where: { email }, }); @@ -64,7 +64,7 @@ export class AuthService { async login( email: string, password: string, - ): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> { + ): Promise { const user = await this.prisma.user.findUnique({ where: { email }, }); @@ -80,7 +80,7 @@ export class AuthService { async refresh( token: string, - ): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> { + ): Promise { let payload: RefreshPayload; try { payload = await this.jwt.verifyAsync(token, { @@ -151,7 +151,7 @@ export class AuthService { private async createSession( user: User, - ): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> { + ): Promise { const session = await this.prisma.session.create({ data: { id: randomUUID(), @@ -166,7 +166,7 @@ export class AuthService { private async rotateSession( session: Session & { user: User }, expectedRefreshTokenHash?: string, - ): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> { + ): Promise { const refreshToken = await this.jwt.signAsync( { sub: session.userId, diff --git a/src/modules/auth/application/auth.types.ts b/src/modules/auth/application/auth.types.ts index 6e5348e..333e4e9 100644 --- a/src/modules/auth/application/auth.types.ts +++ b/src/modules/auth/application/auth.types.ts @@ -3,6 +3,12 @@ export interface AuthUser { id: string; } +export type AuthenticationResult = { + accessToken: string; + refreshToken: string; + user: AuthUser; +}; + export interface AccessPayload { sid: string; sub: string; diff --git a/src/modules/auth/presentation/auth.controller.ts b/src/modules/auth/presentation/auth.controller.ts index 8f78326..a121898 100644 --- a/src/modules/auth/presentation/auth.controller.ts +++ b/src/modules/auth/presentation/auth.controller.ts @@ -23,7 +23,7 @@ import { import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; import { HttpErrorCode } from '../../../common/error-code'; import { AuthService } from '../application/auth.service'; -import { AccessPayload } from '../application/auth.types'; +import { AccessPayload, AuthenticationResult } from '../application/auth.types'; import { CredentialsDto } from './auth.dto'; import { JwtAuthGuard } from './jwt-auth.guard'; @@ -117,11 +117,7 @@ export class AuthController { private respond( response: Response, - result: { - accessToken: string; - refreshToken: string; - user: { email: string; id: string }; - }, + result: AuthenticationResult, ) { response.cookie(REFRESH_COOKIE, result.refreshToken, this.cookieOptions()); return { user: result.user, accessToken: result.accessToken }; From 190eb90ffe0b55a0e71047e9778aa77e167841a1 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Thu, 1 Oct 2026 20:53:00 +0300 Subject: [PATCH 18/22] refactor: clarify nullable card metadata --- .../bank-cards/application/bank-cards.service.ts | 8 ++------ test/bank-cards.e2e-spec.ts | 11 +++++++++++ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/modules/banking/bank-cards/application/bank-cards.service.ts b/src/modules/banking/bank-cards/application/bank-cards.service.ts index 7a777f9..e1e6d74 100644 --- a/src/modules/banking/bank-cards/application/bank-cards.service.ts +++ b/src/modules/banking/bank-cards/application/bank-cards.service.ts @@ -103,13 +103,9 @@ export class BankCardsService { id, name: dto.name ?? previous.name, bankName: - dto.bankName === undefined - ? (previous.bankName ?? undefined) - : (dto.bankName ?? undefined), + dto.bankName === undefined ? previous.bankName : dto.bankName, lastFour: - dto.lastFour === undefined - ? (previous.lastFour ?? undefined) - : (dto.lastFour ?? undefined), + dto.lastFour === undefined ? previous.lastFour : dto.lastFour, paymentSystem: dto.paymentSystem === undefined ? previous.paymentSystem diff --git a/test/bank-cards.e2e-spec.ts b/test/bank-cards.e2e-spec.ts index cfa6de5..97effb8 100644 --- a/test/bank-cards.e2e-spec.ts +++ b/test/bank-cards.e2e-spec.ts @@ -188,6 +188,17 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { ).toBe(0); expect((await summary('2026-08', card.id)).net.value).toBe('0'); }); + it('retains omitted metadata on a name-only patch and clears explicit nulls', async () => { + const card = await createCard('0', owner, { bankName: 'Bank', lastFour: '1234' }); + const renamed = (await auth('patch', '/' + card.id) + .send({ version: card.version, name: 'Renamed' }).expect(200)).body as BankCardDto; + expect(renamed).toMatchObject({ name: 'Renamed', bankName: 'Bank', lastFour: '1234' }); + const cleared = (await auth('patch', '/' + card.id) + .send({ version: renamed.version, bankName: null, lastFour: null }).expect(200)).body as BankCardDto; + expect(cleared).toMatchObject({ name: 'Renamed', bankName: null, lastFour: null }); + expect(cleared.version).toBe(renamed.version + 1); + }); + it('rejects explicit null on nonnullable write fields without changing values or versions', async () => { const card = await createCard(); const expense = await createOp(op(card.id)); From 7fe88a12c7cd3c5eb16a1912c5bd55d171a64c95 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Thu, 1 Oct 2026 20:53:00 +0300 Subject: [PATCH 19/22] refactor: centralize provider projections --- .../instruments/application/candle.service.ts | 15 +- .../application/catalog-enrichment.service.ts | 15 +- .../tbank-candle.mapper.spec.ts | 19 +++ .../infrastructure/tbank-candle.mapper.ts | 18 +++ .../application/portfolio.service.spec.ts | 37 +++++ .../application/portfolio.service.ts | 133 +----------------- .../infrastructure/tbank-portfolio.mapper.ts | 96 ++++++++++++- 7 files changed, 179 insertions(+), 154 deletions(-) create mode 100644 src/modules/investments/instruments/infrastructure/tbank-candle.mapper.spec.ts create mode 100644 src/modules/investments/instruments/infrastructure/tbank-candle.mapper.ts diff --git a/src/modules/investments/instruments/application/candle.service.ts b/src/modules/investments/instruments/application/candle.service.ts index 1c8f778..200d416 100644 --- a/src/modules/investments/instruments/application/candle.service.ts +++ b/src/modules/investments/instruments/application/candle.service.ts @@ -5,10 +5,8 @@ import { } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; -import { formatDecimal } from '../../../../common/decimal'; import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; import { MILLISECONDS_PER_DAY } from '../../../../common/time'; -import { decimal } from '../../../../integrations/tbank/decimal'; import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; import { TbankAccessService, @@ -26,6 +24,7 @@ import { } from '../domain/candle-coverage'; import { InstrumentErrorCode } from '../domain/instrument-error-code'; import { CandleRepository } from '../infrastructure/candle.repository'; +import { toSavedCandle } from '../infrastructure/tbank-candle.mapper'; import { CatalogEnrichmentService } from './catalog-enrichment.service'; import { timestamp } from './instrument-market'; @@ -413,17 +412,7 @@ export class CandleService { window.from, new Date(Math.min(window.to.valueOf(), now.valueOf())), ); - const rows = candles.flatMap((candle) => { - const time = timestamp(candle.time); - if (!time || !candle.close) throw new Error('Invalid candle response'); - return [ - { - time, - close: formatDecimal(decimal(candle.close)), - isComplete: candle.isComplete === true, - }, - ]; - }); + const rows = candles.map(toSavedCandle); if (!(await this.repository.stage(attempt, window, rows, now))) { return false; } diff --git a/src/modules/investments/instruments/application/catalog-enrichment.service.ts b/src/modules/investments/instruments/application/catalog-enrichment.service.ts index cfc78a0..6cf0824 100644 --- a/src/modules/investments/instruments/application/catalog-enrichment.service.ts +++ b/src/modules/investments/instruments/application/catalog-enrichment.service.ts @@ -4,7 +4,6 @@ import { randomUUID } from 'node:crypto'; import { decimalFromString, divide, - formatDecimal, } from '../../../../common/decimal'; import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; import { @@ -13,7 +12,6 @@ import { } from '../../../../common/time'; import { type MoexBondEvent, MoexClient } from '../../../../integrations/moex/moex.api'; import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api'; -import { decimal } from '../../../../integrations/tbank/decimal'; import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; import { TbankAccessService, @@ -37,6 +35,7 @@ import { catalogScope, type CatalogSource, } from '../infrastructure/catalog-discovery.repository'; +import { toSavedCandle } from '../infrastructure/tbank-candle.mapper'; import { catalogSearchLimit } from './catalog-selection'; import { previousClose, timestamp } from './instrument-market'; @@ -353,17 +352,7 @@ export class CatalogEnrichmentService { gap.from, gap.to, ); - const rows = response.map((candle) => { - const time = timestamp(candle.time); - if (!time || !candle.close) { - throw new Error('Invalid candle response'); - } - return { - time, - close: formatDecimal(decimal(candle.close)), - isComplete: candle.isComplete === true, - }; - }); + const rows = response.map(toSavedCandle); if (!(await this.candles.stage(attempt, gap, rows, now))) { throw new Error('Cancelled candle comparison'); } diff --git a/src/modules/investments/instruments/infrastructure/tbank-candle.mapper.spec.ts b/src/modules/investments/instruments/infrastructure/tbank-candle.mapper.spec.ts new file mode 100644 index 0000000..52d7d59 --- /dev/null +++ b/src/modules/investments/instruments/infrastructure/tbank-candle.mapper.spec.ts @@ -0,0 +1,19 @@ +import { toSavedCandle } from './tbank-candle.mapper'; + +describe('T-Bank candle mapping', () => { + it('keeps signed nano precision and requires explicit completeness', () => { + const input = { time: { seconds: '1767225600' }, close: { units: '0', nano: -1 } }; + expect(toSavedCandle(input)).toEqual({ + time: '2026-01-01T00:00:00.000Z', close: '-0.000000001', isComplete: false, + }); + expect(toSavedCandle({ ...input, isComplete: true }).isComplete).toBe(true); + expect(input).toEqual({ time: { seconds: '1767225600' }, close: { units: '0', nano: -1 } }); + }); + it.each([ + { close: { units: '1' } }, + { time: { seconds: 'invalid' }, close: { units: '1' } }, + { time: { seconds: '1767225600' } }, + ])('rejects missing or malformed source fields: %j', (input) => { + expect(() => toSavedCandle(input)).toThrow('Invalid candle response'); + }); +}); diff --git a/src/modules/investments/instruments/infrastructure/tbank-candle.mapper.ts b/src/modules/investments/instruments/infrastructure/tbank-candle.mapper.ts new file mode 100644 index 0000000..08942ea --- /dev/null +++ b/src/modules/investments/instruments/infrastructure/tbank-candle.mapper.ts @@ -0,0 +1,18 @@ +import type { TbankCandle } from '../../../../integrations/tbank/tbank.api'; +import type { SavedCandle } from '../domain/candle-coverage'; + +import { formatDecimal } from '../../../../common/decimal'; +import { decimal } from '../../../../integrations/tbank/decimal'; +import { timestamp } from '../application/instrument-market'; + +function toSavedCandle(candle: TbankCandle): SavedCandle { + const time = timestamp(candle.time); + if (!time || !candle.close) throw new Error('Invalid candle response'); + return { + time, + close: formatDecimal(decimal(candle.close)), + isComplete: candle.isComplete === true, + }; +} + +export { toSavedCandle }; diff --git a/src/modules/investments/portfolio/application/portfolio.service.spec.ts b/src/modules/investments/portfolio/application/portfolio.service.spec.ts index 5df4ec9..f29cf3a 100644 --- a/src/modules/investments/portfolio/application/portfolio.service.spec.ts +++ b/src/modules/investments/portfolio/application/portfolio.service.spec.ts @@ -129,6 +129,43 @@ describe('persistent portfolio projection', () => { ]); expect(snapshot.cashBalances).toEqual([{ currency: 'RUB', value: '1' }]); }); + it('keeps net-zero cash groups, missing valuations and incompatible currencies distinct', async () => { + repository.reserve.mockResolvedValueOnce({ + launch: true, + work: { id: 'work', attemptId: 'attempt' }, + }); + tbank.getPortfolio.mockResolvedValue({ positions: [ + { quantity: { units: '1' }, currentPrice: { units: '3' } }, + { + quantity: { units: '1' }, + currentPrice: { currency: 'USD', units: '3' }, + averagePositionPrice: { currency: 'RUB', units: '2' }, + }, + ] }); + tbank.getPositions.mockResolvedValue({ money: [ + { currency: 'rub', units: '2' }, + { currency: 'RUB', units: '-2' }, + { units: '99' }, + { currency: 'EUR', units: '1' }, + ] }); + await service.request('user', 'private', 'a'); + for (let index = 0; index < 10 && !repository.publish.mock.calls.length; index++) { + await immediate(); + } + const snapshot = (repository.publish.mock.calls as [unknown, string, PortfolioDto][])[0][2]; + expect(snapshot.cashBalances).toEqual([ + { currency: 'RUB', value: '0' }, + { currency: 'EUR', value: '1' }, + ]); + expect(snapshot.totalsByCurrency).toEqual([ + { currency: 'EUR', value: '1' }, + { currency: 'USD', value: '3' }, + ]); + expect(snapshot.positions[0].currentValue).toBeNull(); + expect(snapshot.positions[1].absoluteReturn).toBeNull(); + expect(snapshot.positions[1].averagePurchasePrice).toEqual({ currency: 'RUB', value: '2' }); + }); + it('does not turn a missing account snapshot into aggregate zero; summaries stay independent', async () => { const snapshot: PortfolioDto = { lastSuccessfulAt: membershipAt.toISOString(), diff --git a/src/modules/investments/portfolio/application/portfolio.service.ts b/src/modules/investments/portfolio/application/portfolio.service.ts index 8da8363..14d7f19 100644 --- a/src/modules/investments/portfolio/application/portfolio.service.ts +++ b/src/modules/investments/portfolio/application/portfolio.service.ts @@ -5,18 +5,8 @@ import { } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; -import { - add, - decimalFromString, - formatDecimal, - isZero, - multiply, - subtract, -} from '../../../../common/decimal'; import { RequestContextService } from '../../../../common/logging/request-context.service'; import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; -import { MoneyDto } from '../../../../common/money.dto'; -import { decimal } from '../../../../integrations/tbank/decimal'; import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; import { InstrumentMetadataService, @@ -24,9 +14,9 @@ import { metadataKey, TbankAccessService, TbankClient, - type TbankInstrument, TbankMembershipService, - TbankMoney, + type TbankMoney, + type TbankPortfolio, tbankSourceTtlMs, } from '../../../../integrations/tbank/tbank.api'; import { CandleService } from '../../instruments/instruments.api'; @@ -38,25 +28,12 @@ import { PortfolioRepository, portfolioWorkKey, } from '../infrastructure/portfolio.repository'; -import { totalsByCurrency } from '../infrastructure/tbank-portfolio.mapper'; -import { - PortfolioDto, - PortfolioPositionDto, -} from '../presentation/portfolio.dto'; +import { mapPortfolio } from '../infrastructure/tbank-portfolio.mapper'; +import { PortfolioDto } from '../presentation/portfolio.dto'; const MISSING_DATA_REVISION = 0; const membershipFreshForMs = tbankSourceTtlMs.membership; -const types: Record = { - share: 'STOCK', - bond: 'BOND', - etf: 'FUND', - currency: 'CURRENCY', - futures: 'FUTURE', - option: 'OPTION', - sp: 'STRUCTURED_NOTE', - dfa: 'DIGITAL_FINANCIAL_ASSET', -}; @Injectable() export class PortfolioService { @@ -423,109 +400,11 @@ export class PortfolioService { } private async normalize( - upstream: { - averagePositionPrice?: TbankMoney; - currentPrice?: TbankMoney; - figi?: string; - instrumentType?: string; - instrumentUid?: string; - quantity?: { nano?: number | string; units?: number | string }; - }[], + upstream: NonNullable, money: TbankMoney[], ): Promise { const lastSuccessfulAt = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'); const savedMetadata = await this.metadata.savedMany(upstream); - const positions = upstream.map((position) => - this.position( - position, - savedMetadata.get(metadataKey(position))?.data ?? null, - ), - ); - const cash = new Map>(); - for (const value of money) { - const currency = value.currency?.trim().toUpperCase(); - const amount = decimal(value); - if (!currency || isZero(amount)) continue; - cash.set(currency, add(cash.get(currency) ?? decimal({}), amount)); - } - const cashBalances = [...cash].map(([currency, value]) => ({ - currency, - value: formatDecimal(value), - })); - const normalizedPositions = positions.filter( - (position): position is PortfolioPositionDto => position !== null, - ); - return { - lastSuccessfulAt, - isStale: false, - totalsByCurrency: totalsByCurrency(upstream, money), - positions: normalizedPositions, - cashBalances, - structure: portfolioStructure(normalizedPositions, cashBalances), - }; - } - - private position( - position: Parameters[0][number], - metadata: null | TbankInstrument, - ): null | PortfolioPositionDto { - const quantity = decimal(position.quantity ?? {}); - if (isZero(quantity)) return null; - const figi = position.figi?.trim() || null; - const instrumentUid = position.instrumentUid?.trim() || null; - const currentPrice = this.money(position.currentPrice); - const averagePurchasePrice = this.money(position.averagePositionPrice); - const currentValue = currentPrice && this.value(quantity, currentPrice); - const absoluteReturn = - currentPrice && - averagePurchasePrice && - currentPrice.currency === averagePurchasePrice.currency - ? { - currency: currentPrice.currency, - value: formatDecimal( - multiply( - subtract( - decimalFromString(currentPrice.value), - decimalFromString(averagePurchasePrice.value), - ), - quantity, - ), - ), - } - : null; - return { - figi, - instrumentUid, - displayName: - metadata?.name?.trim() || - metadata?.ticker?.trim() || - figi || - 'Unknown instrument', - type: types[position.instrumentType?.toLowerCase() ?? ''] ?? 'OTHER', - quantity: formatDecimal(quantity), - currentValue, - averagePurchasePrice, - absoluteReturn, - isin: metadata?.isin?.trim() || null, - ticker: metadata?.ticker?.trim() || null, - logoName: metadata?.brand?.logoName?.trim() || null, - }; - } - - private value( - quantity: ReturnType, - price: MoneyDto, - ): MoneyDto { - return { - currency: price.currency, - value: formatDecimal(multiply(quantity, decimalFromString(price.value))), - }; - } - - private money(value: TbankMoney | undefined): MoneyDto | null { - const currency = value?.currency?.trim().toUpperCase(); - return currency && value - ? { currency, value: formatDecimal(decimal(value)) } - : null; + return mapPortfolio(upstream, money, savedMetadata, lastSuccessfulAt); } } diff --git a/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts b/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts index f4ae5b6..08c8873 100644 --- a/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts +++ b/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts @@ -1,16 +1,110 @@ import type { Money } from '../../../../common/money.types'; +import type { PortfolioDto, PortfolioPositionDto } from '../presentation/portfolio.dto'; import { add, + decimalFromString, formatDecimal, isZero, multiply, + subtract, } from '../../../../common/decimal'; import { decimal } from '../../../../integrations/tbank/decimal'; import { + metadataKey, + type TbankInstrument, + type TbankMoney, TbankPortfolio, TbankPositions, } from '../../../../integrations/tbank/tbank.api'; +import { portfolioStructure } from '../domain/portfolio-structure'; + +const types: Record = { + share: 'STOCK', + bond: 'BOND', + etf: 'FUND', + currency: 'CURRENCY', + futures: 'FUTURE', + option: 'OPTION', + sp: 'STRUCTURED_NOTE', + dfa: 'DIGITAL_FINANCIAL_ASSET', +}; + +function mapPortfolio( + upstream: NonNullable, + cash: TbankMoney[], + metadata: ReadonlyMap, + lastSuccessfulAt: string, +): PortfolioDto { + const positions = upstream + .map((position) => mapPosition(position, metadata.get(metadataKey(position))?.data ?? null)) + .filter((position): position is PortfolioPositionDto => position !== null); + const balances = new Map>(); + for (const value of cash) { + const currency = value.currency?.trim().toUpperCase(); + const amount = decimal(value); + if (!currency || isZero(amount)) continue; + balances.set(currency, add(balances.get(currency) ?? decimal({}), amount)); + } + const cashBalances = [...balances].map(([currency, value]) => ({ + currency, + value: formatDecimal(value), + })); + return { + lastSuccessfulAt, + isStale: false, + totalsByCurrency: totalsByCurrency(upstream, cash), + positions, + cashBalances, + structure: portfolioStructure(positions, cashBalances), + }; +} + +function mapPosition( + position: NonNullable[number], + metadata: null | TbankInstrument, +): null | PortfolioPositionDto { + const quantity = decimal(position.quantity ?? {}); + if (isZero(quantity)) return null; + const figi = position.figi?.trim() || null; + const instrumentUid = position.instrumentUid?.trim() || null; + const currentPrice = money(position.currentPrice); + const averagePurchasePrice = money(position.averagePositionPrice); + const currentValue = currentPrice && { + currency: currentPrice.currency, + value: formatDecimal(multiply(quantity, decimalFromString(currentPrice.value))), + }; + const absoluteReturn = + currentPrice && averagePurchasePrice && currentPrice.currency === averagePurchasePrice.currency + ? { + currency: currentPrice.currency, + value: formatDecimal(multiply( + subtract(decimalFromString(currentPrice.value), decimalFromString(averagePurchasePrice.value)), + quantity, + )), + } + : null; + return { + figi, + instrumentUid, + displayName: metadata?.name?.trim() || metadata?.ticker?.trim() || figi || 'Unknown instrument', + type: types[position.instrumentType?.toLowerCase() ?? ''] ?? 'OTHER', + quantity: formatDecimal(quantity), + currentValue, + averagePurchasePrice, + absoluteReturn, + isin: metadata?.isin?.trim() || null, + ticker: metadata?.ticker?.trim() || null, + logoName: metadata?.brand?.logoName?.trim() || null, + }; +} + +function money(value: TbankMoney | undefined): Money | null { + const currency = value?.currency?.trim().toUpperCase(); + return currency && value + ? { currency, value: formatDecimal(decimal(value)) } + : null; +} function totalsByCurrency( positions: NonNullable, @@ -44,4 +138,4 @@ function totalsByCurrency( .map(([currency, value]) => ({ currency, value: formatDecimal(value) })); } -export { totalsByCurrency }; +export { mapPortfolio, totalsByCurrency }; From a13cf84eadb0b36bd0c364a1d6f10831a8fdc44c Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Thu, 1 Oct 2026 20:53:01 +0300 Subject: [PATCH 20/22] refactor: isolate operation ranges and intents --- .../brokerage-operations.service.ts | 32 +------ .../domain/operations-sync-plan.spec.ts | 28 +++++- .../operations/domain/operations-sync-plan.ts | 18 ++++ .../brokerage-operations.repository.ts | 89 +++++++++---------- test/tbank-operations-sync.e2e-spec.ts | 34 +++++++ 5 files changed, 125 insertions(+), 76 deletions(-) diff --git a/src/modules/investments/operations/application/brokerage-operations.service.ts b/src/modules/investments/operations/application/brokerage-operations.service.ts index 800dc29..9108459 100644 --- a/src/modules/investments/operations/application/brokerage-operations.service.ts +++ b/src/modules/investments/operations/application/brokerage-operations.service.ts @@ -19,6 +19,7 @@ import { } from '../../investment-sync-plan'; import { OperationErrorCode } from '../domain/operation-error-code'; import { + confirmedOperationRanges, operationIntervalsOverlap, operationsSyncPriority, planOperationRead, @@ -196,22 +197,7 @@ export class BrokerageOperationsService { const plan = planOperationRead(targets, saved?.coverage ?? [], now, { movingEnd, }); - const confirmed = plan.intervals.flatMap((target) => - (saved?.coverage ?? []) - .filter( - (row) => - row.accountId === target.accountId && - (!row.instrumentUid || - row.instrumentUid === target.instrumentUid) && - row.from < target.to && - target.from < row.to, - ) - .map((row) => ({ - accountId: target.accountId, - from: new Date(Math.max(row.from.valueOf(), target.from.valueOf())), - to: new Date(Math.min(row.to.valueOf(), target.to.valueOf())), - })), - ); + const confirmed = confirmedOperationRanges(plan.intervals, saved?.coverage ?? []); const missing = uncoveredOperationIntervals( plan.intervals, confirmed.map((range) => ({ @@ -544,19 +530,7 @@ export class BrokerageOperationsService { .sort((a, b) => a.valueOf() - b.valueOf())[0] ?? (complete ? saved?.connection.membershipAt : null) ?? null; - const confirmed = plan.intervals.flatMap((target) => - sources - .filter( - (row) => - row.accountId === target.accountId && - operationIntervalsOverlap(row, target), - ) - .map((row) => ({ - accountId: target.accountId, - from: new Date(Math.max(row.from.valueOf(), target.from.valueOf())), - to: new Date(Math.min(row.to.valueOf(), target.to.valueOf())), - })), - ); + const confirmed = confirmedOperationRanges(plan.intervals, sources); const meta = investmentResourceMeta({ // eslint-disable-next-line security/detect-object-injection -- The numeric index enumerates the three comparison periods. resourceKey: `income/${accountId}/${['selected', 'previous', 'yearAgo'][index]}/${period.key}`, diff --git a/src/modules/investments/operations/domain/operations-sync-plan.spec.ts b/src/modules/investments/operations/domain/operations-sync-plan.spec.ts index f2c46f0..00b4068 100644 --- a/src/modules/investments/operations/domain/operations-sync-plan.spec.ts +++ b/src/modules/investments/operations/domain/operations-sync-plan.spec.ts @@ -1,4 +1,30 @@ -import { operationIntervals, planOperationRead } from './operations-sync-plan'; +import { confirmedOperationRanges, operationIntervals, planOperationRead } from './operations-sync-plan'; + +describe('confirmed operation range projection', () => { + it('clips overlaps in source order without merging and respects account and instrument scope', () => { + const day = (value: number) => new Date(Date.UTC(2026, 0, value)); + const target = { accountId: 'a', from: day(2), to: day(5) }; + const sources = [ + { ...target, from: day(3), to: day(6) }, + { ...target, from: day(1), to: day(4) }, + { ...target, instrumentUid: 'uid' }, + { ...target, accountId: 'other' }, + { ...target, from: day(5), to: day(6) }, + { ...target, from: day(1), to: day(2) }, + ]; + const before = structuredClone(sources); + expect(confirmedOperationRanges([target], sources)).toEqual([ + { ...target, from: day(3) }, + { ...target, to: day(4) }, + ]); + expect(confirmedOperationRanges([{ ...target, instrumentUid: 'uid' }], sources)).toEqual([ + { ...target, from: day(3) }, + { ...target, to: day(4) }, + target, + ]); + expect(sources).toEqual(before); + }); +}); describe('operations calendar windows', () => { it('merges overlaps before splitting leap February and keeps precise Moscow-derived boundaries', () => { diff --git a/src/modules/investments/operations/domain/operations-sync-plan.ts b/src/modules/investments/operations/domain/operations-sync-plan.ts index 6477330..8ae4061 100644 --- a/src/modules/investments/operations/domain/operations-sync-plan.ts +++ b/src/modules/investments/operations/domain/operations-sync-plan.ts @@ -106,6 +106,24 @@ export function uncoveredOperationIntervals( }); } +export function confirmedOperationRanges( + targets: OperationInterval[], + coverage: OperationInterval[], +): Omit[] { + return targets.flatMap((target) => coverage + .filter((row) => + row.accountId === target.accountId && + (!row.instrumentUid || row.instrumentUid === target.instrumentUid) && + row.from < target.to && target.from < row.to, + ) + .map((row) => ({ + accountId: target.accountId, + from: new Date(Math.max(row.from.valueOf(), target.from.valueOf())), + to: new Date(Math.min(row.to.valueOf(), target.to.valueOf())), + })), + ); +} + export type OperationCoverage = OperationInterval & { asOf: Date; fetchedAt: Date; diff --git a/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts b/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts index 7181714..844d1a8 100644 --- a/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts +++ b/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts @@ -392,52 +392,15 @@ export class BrokerageOperationsRepository { const work = await tx.tbankSyncWork.update({ where: { id: previous.id }, data: { - intent: { - atomic: previousIntent?.atomic ?? publicationAtomic, - atomicIntervals: previousIntent?.atomic - ? [ - ...new Set([ - ...(previousIntent.atomicIntervals ?? - previousIntent.intervals), - ...additions.map(intervalKey), - ]), - ] - : [], - screenIntervals: [ - ...new Set([ - ...(previousIntent?.screenIntervals ?? - (previous.priority === operationsSyncPriority.screen - ? (previousIntent?.intervals ?? []) - : [])), - ...(priority === operationsSyncPriority.screen - ? [...pending, ...additions] - .filter((item) => - intervals.some((target) => - operationIntervalsOverlap(item, target), - ), - ) - .map(intervalKey) - : []), - ]), - ], - intervals: (priority < previous.priority - ? [ - ...pending.filter((item) => - intervals.some((target) => - operationIntervalsOverlap(item, target), - ), - ), - ...additions, - ...pending.filter( - (item) => - !intervals.some((target) => - operationIntervalsOverlap(item, target), - ), - ), - ] - : [...pending, ...additions] - ).map(intervalKey), - }, + intent: extendRunningIntent({ + previous: previousIntent, + publicationAtomic, + pending, + additions, + intervals, + priority, + previousPriority: previous.priority, + }), priority: Math.min(priority, previous.priority), }, }); @@ -919,6 +882,40 @@ export class BrokerageOperationsRepository { } } +function extendRunningIntent(input: { + additions: OperationInterval[]; + intervals: OperationInterval[]; + pending: OperationInterval[]; + previous: OperationsIntent | undefined; + previousPriority: number; + priority: number; + publicationAtomic: boolean; +}): OperationsIntent { + const { previous, pending, additions, intervals, priority, previousPriority, publicationAtomic } = input; + const overlapsRequested = (item: OperationInterval) => + intervals.some((target) => operationIntervalsOverlap(item, target)); + const retainedScreen = previous?.screenIntervals ?? + (previousPriority === operationsSyncPriority.screen ? (previous?.intervals ?? []) : []); + const addedScreen = priority === operationsSyncPriority.screen + ? [...pending, ...additions].filter(overlapsRequested).map(intervalKey) + : []; + const ordered = priority < previousPriority + ? [ + ...pending.filter(overlapsRequested), + ...additions, + ...pending.filter((item) => !overlapsRequested(item)), + ] + : [...pending, ...additions]; + return { + atomic: previous?.atomic ?? publicationAtomic, + atomicIntervals: previous?.atomic + ? [...new Set([...(previous.atomicIntervals ?? previous.intervals), ...additions.map(intervalKey)])] + : [], + screenIntervals: [...new Set([...retainedScreen, ...addedScreen])], + intervals: ordered.map(intervalKey), + }; +} + function intervalKey(interval: OperationInterval): string { // eslint-disable-next-line sonarjs/no-inverted-boolean-check -- Negation also rejects Invalid Date; >= would silently accept NaN. if (!(interval.from < interval.to)) { diff --git a/test/tbank-operations-sync.e2e-spec.ts b/test/tbank-operations-sync.e2e-spec.ts index 0937213..7ad1719 100644 --- a/test/tbank-operations-sync.e2e-spec.ts +++ b/test/tbank-operations-sync.e2e-spec.ts @@ -115,6 +115,40 @@ describe('Request-driven operations synchronization (SQLite)', () => { }); afterAll(async () => prisma.$disconnect()); + it.each([[false, 1], [true, 1], [false, 2], [true, 2]] as const)( + 'extends legacy intents in stable priority order (atomic=%s, previousPriority=%s)', + async (atomic, previousPriority) => { + const run = 'legacy-run'; + const initial = await repository.request('owner', 'revision', [january, february], run, false, new Set(), previousPriority); + if (!initial) throw new Error('Missing work'); + const key = (range: typeof january) => JSON.stringify([ + range.accountId, range.from.toISOString(), range.to.toISOString(), '', + ]); + const march = { ...january, from: february.to, to: new Date('2026-04-01Z') }; + await prisma.tbankSyncWork.update({ + where: { id: initial.work.id }, + data: { intent: { atomic, intervals: [key(february), key(january)] } }, + }); + const extended = await repository.request( + 'owner', 'revision', [january, march], run, false, + new Set([initial.work.attemptId]), 1, + ); + expect(extended?.launch).toBe(false); + expect(extended?.work.id).toBe(initial.work.id); + expect(extended?.work.priority).toBe(1); + expect(extended?.work.intent).toEqual({ + atomic, + atomicIntervals: atomic ? [key(february), key(january), key(march)] : [], + screenIntervals: previousPriority === 1 + ? [key(february), key(january), key(march)] + : [key(january), key(march)], + intervals: previousPriority === 1 + ? [key(february), key(january), key(march)] + : [key(january), key(march), key(february)], + }); + }, + ); + it('joins overlapping account and instrument refresh into one atomic publication group', async () => { const gate = deferred(); upstream.getOperationsByCursor From 14a204de0d32ad78193c614bef28513d1f97c593 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Thu, 1 Oct 2026 20:53:01 +0300 Subject: [PATCH 21/22] chore: guard backend responsibility boundaries --- .ai/architecture.md | 7 ++++-- README.md | 4 ++++ eslint.config.mjs | 49 ++++++++++++++++++++++++++++++++++++++++++ test/eslint-config.cjs | 30 ++++++++++++++++++++++++++ 4 files changed, 88 insertions(+), 2 deletions(-) diff --git a/.ai/architecture.md b/.ai/architecture.md index 2dc1b5f..8776372 100644 --- a/.ai/architecture.md +++ b/.ai/architecture.md @@ -267,5 +267,8 @@ Tests сохраняют независимые literal wire assertions; обы ## Проверка `npm run typecheck:app`, `npm run lint`, targeted Jest по изменённому use case. -Смысл boundaries проверяется review: backend не имеет отдельного автоматического -FSD/DI architecture checker. Existing tests и build не доказывают правильный owner. +ESLint проверяет прямые imports: domain не зависит от transport/persistence, +controllers делегируют application, use cases не импортируют Prisma. Чистые +protocol inputs разрешены; existing CRUD/transaction services сохраняют DB owner. +Транзитивные зависимости, DI composition и смысл ownership проверяются review: +backend не имеет полного FSD/DI graph checker. Tests/build не доказывают owner. diff --git a/README.md b/README.md index 4ab2683..7104c04 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,10 @@ lint. Он не защищает от усложнения уже учтённо baseline командой `npx eslint . --prune-suppressions`; не пересоздавайте его для обхода новых ошибок. Security, TypeScript и Jest в baseline не включены. +Рост baseline — изменение quality gate: он требует отдельного обоснования +и review затронутого поведения. При рефакторинге сохраняйте или сокращайте +baseline; прежнее число сообщений не доказывает прежнюю сложность функции. + ## SonarQube Проект `wealthledger-backend` на `https://sonarqube.personal.ksv741.ru` diff --git a/eslint.config.mjs b/eslint.config.mjs index 998a57b..9fde38f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -15,6 +15,9 @@ import globals from 'globals'; import tseslint from 'typescript-eslint'; const tests = ['**/*.spec.ts', 'test/**/*.ts']; +const prismaImports = [ + '@prisma/*', '**/generated/prisma/**', '**/prisma.service', +]; export default defineConfig( globalIgnores(['dist/**', 'dist-dev/**', 'build/**', 'generated/**', 'coverage/**', 'node_modules/**']), @@ -119,6 +122,52 @@ export default defineConfig( 'jest/expect-expect': ['error', { assertFunctionNames: ['expect', '**.expect'] }], }, }, + { + files: ['src/modules/**/domain/**/*.ts'], + ignores: tests, + rules: { + 'no-restricted-imports': ['error', { + patterns: [{ + group: [ + '@nestjs/*', ...prismaImports, '**/*.dto', '**/*.repository', + '**/*.client', '**/application/**', '**/infrastructure/**', + ], + message: 'Domain uses pure rules and value types, not transport or persistence.', + }], + }], + }, + }, + { + files: ['src/**/*.controller.ts'], + ignores: tests, + rules: { + 'no-restricted-imports': ['error', { + patterns: [ + { + group: [...prismaImports, '**/*.repository', '**/*.client'], + message: 'Controllers delegate to application services or use cases.', + }, + { + group: ['**'], + importNamePattern: '(?:Client|Repository|PrismaService)$', + message: 'Use application orchestration rather than data providers in controllers.', + }, + ], + }], + }, + }, + { + files: ['src/**/*.use-case.ts'], + ignores: tests, + rules: { + 'no-restricted-imports': ['error', { + patterns: [{ + group: prismaImports, + message: 'Use cases obtain data through concrete repositories.', + }], + }], + }, + }, { files: [ 'src/**/*.ts', diff --git a/test/eslint-config.cjs b/test/eslint-config.cjs index 592f8c0..4a8b9d1 100644 --- a/test/eslint-config.cjs +++ b/test/eslint-config.cjs @@ -91,6 +91,36 @@ test('numeric literals require names, including previously ignored numbers and b } }); +test('architecture imports enforce direct boundaries without banning DTOs or pure protocol inputs', async () => { + const domain = 'src/modules/banking/bank-cards/domain/rub-money.ts'; + const controller = 'src/modules/auth/presentation/auth.controller.ts'; + const useCase = 'src/modules/banking/bank-cards/application/get-bank-summary.use-case.ts'; + for (const [filePath, code] of [ + [domain, 'import { Injectable } from "@nestjs/common";'], + [domain, 'import { MoneyDto } from "../../../../common/money.dto";'], + [domain, 'import { PrismaService } from "../../../../prisma/prisma.service";'], + [controller, 'import { PrismaService } from "../../../prisma/prisma.service";'], + [controller, 'import { TbankClient } from "../../../integrations/tbank/tbank.api";'], + [useCase, 'import { PrismaService } from "../../../../prisma/prisma.service";'], + ]) { + const [result] = await eslint.lintText(code, { filePath }); + assert.equal(result.fatalErrorCount, 0); + assert.ok(result.messages.some(({ ruleId }) => ruleId === 'no-restricted-imports'), code); + } + for (const [filePath, code] of [ + [domain, 'import type { Money } from "../../../../common/money.types";'], + [domain, 'import { TbankOperationState } from "../../../../integrations/tbank/tbank-protocol";'], + [controller, 'import { AuthService } from "../application/auth.service";'], + [useCase, 'import { BankCardsRepository } from "../infrastructure/bank-cards.repository";'], + [useCase, 'import { BankSummaryQueryDto } from "../presentation/bank-card.dto";'], + ['src/modules/banking/bank-cards/application/bank-cards.service.ts', 'import { PrismaService } from "../../../../prisma/prisma.service";'], + ]) { + const [result] = await eslint.lintText(code, { filePath }); + assert.equal(result.fatalErrorCount, 0); + assert.ok(result.messages.every(({ ruleId }) => ruleId !== 'no-restricted-imports'), code); + } +}); + // Single-use codes must be caught too; fixture literals remain independent wire assertions. test('machine codes require enum members without banning ordinary text or Prisma selects', async () => { const eslint = new ESLint(); From 1be6db65d70f6a96629988289500098b2728b2eb Mon Sep 17 00:00:00 2001 From: codex-bot Date: Fri, 2 Oct 2026 08:12:02 +0300 Subject: [PATCH 22/22] refactor: simplify investment data flow --- .../upcoming-events/upcoming-event.mapper.ts | 68 +++++++++++++++++++ .../upcoming-events.service.spec.ts | 2 +- .../upcoming-events.service.ts | 66 +----------------- 3 files changed, 71 insertions(+), 65 deletions(-) create mode 100644 src/modules/investments/upcoming-events/upcoming-event.mapper.ts diff --git a/src/modules/investments/upcoming-events/upcoming-event.mapper.ts b/src/modules/investments/upcoming-events/upcoming-event.mapper.ts new file mode 100644 index 0000000..5226bbe --- /dev/null +++ b/src/modules/investments/upcoming-events/upcoming-event.mapper.ts @@ -0,0 +1,68 @@ +import { + decimalFromString, + formatDecimal, + multiply, +} from '../../../common/decimal'; +import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../../common/time'; +import type { MoexBondEvent } from '../../../integrations/moex/moex.api'; +import { decimal } from '../../../integrations/tbank/decimal'; +import type { TbankDividend } from '../../../integrations/tbank/tbank.api'; +import { UpcomingEventDto } from './upcoming-events.dto'; + +export function dividendEvent( + metadata: Pick< + UpcomingEventDto, + 'displayName' | 'figi' | 'instrumentUid' | 'isin' | 'logoName' | 'ticker' + >, + quantity: ReturnType, + event: TbankDividend, +): UpcomingEventDto { + return { + ...metadata, + type: 'dividend', + date: new Date(Number(event.recordDate?.seconds) * MILLISECONDS_PER_SECOND) + .toISOString() + .slice(ISO_DATE_START, ISO_DATE_LENGTH), + dateKind: 'registry', + projectedAmount: event.dividendNet + ? projectedMoney( + quantity, + formatDecimal(decimal(event.dividendNet)), + event.dividendNet.currency, + ) + : null, + }; +} + +export function bondEvent( + metadata: Pick< + UpcomingEventDto, + 'displayName' | 'figi' | 'instrumentUid' | 'isin' | 'logoName' | 'ticker' + >, + quantity: ReturnType, + event: MoexBondEvent, +): UpcomingEventDto { + return { + ...metadata, + type: event.type, + date: event.date, + dateKind: 'event', + projectedAmount: + 'value' in event + ? projectedMoney(quantity, event.value, event.currency) + : null, + }; +} + +function projectedMoney( + quantity: ReturnType, + value: string, + currency: string | undefined, +): UpcomingEventDto['projectedAmount'] { + const normalizedCurrency = currency?.trim().toUpperCase(); + if (!normalizedCurrency) return null; + return { + currency: normalizedCurrency, + value: formatDecimal(multiply(quantity, decimalFromString(value))), + }; +} diff --git a/src/modules/investments/upcoming-events/upcoming-events.service.spec.ts b/src/modules/investments/upcoming-events/upcoming-events.service.spec.ts index 207e233..f26c890 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.service.spec.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.service.spec.ts @@ -1,5 +1,5 @@ import { decimalFromString } from '../../../common/decimal'; -import { bondEvent, dividendEvent } from './upcoming-events.service'; +import { bondEvent, dividendEvent } from './upcoming-event.mapper'; jest.mock('../../../prisma/prisma.service', () => ({ PrismaService: class {}, })); diff --git a/src/modules/investments/upcoming-events/upcoming-events.service.ts b/src/modules/investments/upcoming-events/upcoming-events.service.ts index 1f61e3f..f03fb38 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.service.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.service.ts @@ -1,22 +1,17 @@ import { BadRequestException, Injectable } from '@nestjs/common'; -import type { MoexBondEvent } from '../../../integrations/moex/moex.api'; - import { add, decimalFromString, formatDecimal, isZero, - multiply, } from '../../../common/decimal'; import { MoneyDto } from '../../../common/money.dto'; -import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../../common/time'; -import { decimal } from '../../../integrations/tbank/decimal'; +import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../common/time'; import { InstrumentMetadataService, metadataKey, TbankAccessService, - type TbankDividend, } from '../../../integrations/tbank/tbank.api'; import { CatalogEnrichmentService } from '../instruments/instruments.api'; import { investmentResourceMeta } from '../investment-resource.mapper'; @@ -30,6 +25,7 @@ import { UpcomingEventsDto, UpcomingEventsQueryDto, } from './upcoming-events.dto'; +import { bondEvent, dividendEvent } from './upcoming-event.mapper'; enum EventsErrorCode { InvalidPeriod = 'INVALID_EVENTS_PERIOD' } @@ -211,51 +207,6 @@ export class UpcomingEventsService { } } -export function dividendEvent( - metadata: Pick< - UpcomingEventDto, - 'displayName' | 'figi' | 'instrumentUid' | 'isin' | 'logoName' | 'ticker' - >, - quantity: ReturnType, - event: TbankDividend, -): UpcomingEventDto { - return { - ...metadata, - type: 'dividend', - date: new Date(Number(event.recordDate?.seconds) * MILLISECONDS_PER_SECOND) - .toISOString() - .slice(ISO_DATE_START, ISO_DATE_LENGTH), - dateKind: 'registry', - projectedAmount: event.dividendNet - ? projectedMoney( - quantity, - formatDecimal(decimal(event.dividendNet)), - event.dividendNet.currency, - ) - : null, - }; -} - -export function bondEvent( - metadata: Pick< - UpcomingEventDto, - 'displayName' | 'figi' | 'instrumentUid' | 'isin' | 'logoName' | 'ticker' - >, - quantity: ReturnType, - event: MoexBondEvent, -): UpcomingEventDto { - return { - ...metadata, - type: event.type, - date: event.date, - dateKind: 'event', - projectedAmount: - 'value' in event - ? projectedMoney(quantity, event.value, event.currency) - : null, - }; -} - function supported(position: Position): boolean { return ( !isZero(decimalFromString(position.quantity)) && @@ -274,19 +225,6 @@ function dateWindow(query: UpcomingEventsQueryDto) { return { from, to }; } -function projectedMoney( - quantity: ReturnType, - value: string, - currency: string | undefined, -): MoneyDto | null { - const normalizedCurrency = currency?.trim().toUpperCase(); - if (!normalizedCurrency) return null; - return { - currency: normalizedCurrency, - value: formatDecimal(multiply(quantity, decimalFromString(value))), - }; -} - function incomeTotals(events: UpcomingEventDto[]): MoneyDto[] { const totals = new Map>(); for (const event of events) {