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); + } +});