diff --git a/.ai/CONVENTIONS.md b/.ai/CONVENTIONS.md index 9a3f4dd..3cc14fe 100644 --- a/.ai/CONVENTIONS.md +++ b/.ai/CONVENTIONS.md @@ -7,6 +7,7 @@ | Решение / сигнал | Читать | | --- | --- | | Новый файл/suffix/layer; поиск feature owner; progressive structure; перенос responsibility | [Architecture](conventions/backend-architecture.md) | +| TS vocabulary, domain literals, magic values | [Domain literals](conventions/backend-architecture.md#domain-literals-and-magic-values) | | Controller, DTO, route/status, validation/exception, публичная HTTP-семантика | [HTTP API](conventions/backend-http-api.md) | | Repository/data access; Prisma/schema/query, transaction, migration, atomic write | [Data](conventions/backend-data.md) | | Тесты или behavior с testing risk | [Testing](conventions/backend-testing.md) | diff --git a/.ai/conventions/backend-architecture.md b/.ai/conventions/backend-architecture.md index 9614aea..a3e16f1 100644 --- a/.ai/conventions/backend-architecture.md +++ b/.ai/conventions/backend-architecture.md @@ -203,6 +203,56 @@ protobuf units/nano conversion — T-Bank integration. Domain используе `money.types.ts`, а не Swagger DTO; это общая форма денег для карт и портфеля. Давай функциям предметные имена; не создавай глобальные `utils.ts`/`helpers.ts`. +## Domain literals and magic values + +Finite business vocabulary must be modeled explicitly; incidental implementation +literals stay inline. Это относится к error codes, statuses, operation/account/ +instrument/transaction types, permissions, roles, feature states и бизнес-режимам. + +Перед новым определением найди existing owner/type/value. В branching и comparisons +используй символическое значение владельца; не повторяй raw domain literals. +Храни словарь у domain/module/entity с соблюдением границ слоёв, без глобальной +свалки `constants.ts`. Сохраняй принятый pattern; для нового project-owned словаря +без подходящего аналога используй `as const` с derived union: + +```ts +const BankErrorCode = { + InvalidDate: "BANK_INVALID_DATE", + ImmutableOperation: "BANK_IMMUTABLE_OPERATION", +} as const; +type BankErrorCode = (typeof BankErrorCode)[keyof typeof BankErrorCode]; + +// Вместо code === "BANK_INVALID_DATE": +if (code === BankErrorCode.InvalidDate) { /* existing handling */ } +``` + +Значимые numeric policies (timeout, retry, page size, threshold, coefficient) +называй по смыслу и единицам: `REQUEST_TIMEOUT_MS`, `CACHE_TTL_SECONDS`, +`MAX_RETRY_COUNT`, `PAGE_SIZE`. Оставляй inline UI labels в принятом месте, +log messages, индексы, `index + 1`, `Math.max(value, 0)`, `page ?? 1`, +`percentage * 100` и очевидные локальные детали без semantic benefit. +При неясном owner/смысле оставь значение и укажи ограничение в review. + +Не мигрируй корректные existing enum/union styles массово. Не добавляй ради +правила factories, registries, schemas, loaders, abstraction layers, dependencies, +custom lint/AST rules, generation/validation scripts или тесты существования +констант. Semantic review проверяет ownership, единое написание и неизменность +wire values; обычные behavior tests проверяют поведение. + +OpenAPI enum metadata и runtime validation должны читать тот же vocabulary, +что application/domain. Например, `BankOperationKind` принадлежит bank-cards/domain, +а DTO получает допустимые значения через `Object.values(BankOperationKind)`. +Frontend переиспользует generated OpenAPI types/values по возможностям генератора, +а не определяет независимый backend-owned словарь. Внешние/generated enums +сохраняй; 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 получает предметное имя и единицы; +semantic magic strings проверяются conventions/review без нового инструмента. + ## Logging Единственный logger — nestjs-pino; application events используют существующий diff --git a/eslint.config.mjs b/eslint.config.mjs index 1f292a7..537b840 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -26,6 +26,43 @@ export default tseslint.config( }, { rules: { + 'no-magic-numbers': 'off', + '@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, + ignoreNumericLiteralTypes: true, + ignoreReadonlyClassProperties: true, + ignoreTypeIndexes: true, + enforceConst: true, + }, + ], '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-non-null-assertion': 'error', 'no-restricted-syntax': [ @@ -39,7 +76,11 @@ export default tseslint.config( ], '@typescript-eslint/no-floating-promises': 'warn', '@typescript-eslint/no-unsafe-argument': 'warn', - "prettier/prettier": ["error", { endOfLine: "auto" }], + 'prettier/prettier': ['error', { endOfLine: 'auto' }], }, }, + { + files: ['**/*.spec.ts', 'test/**/*.ts'], + rules: { '@typescript-eslint/no-magic-numbers': 'off' }, + }, ); diff --git a/src/common/decorators/api-safe-error-response.decorator.ts b/src/common/decorators/api-safe-error-response.decorator.ts index 3b97dca..f62ab49 100644 --- a/src/common/decorators/api-safe-error-response.decorator.ts +++ b/src/common/decorators/api-safe-error-response.decorator.ts @@ -1,4 +1,4 @@ -import { applyDecorators } from '@nestjs/common'; +import { HttpStatus, applyDecorators } from '@nestjs/common'; import { ApiExtraModels, ApiProperty, @@ -35,10 +35,13 @@ function problemResponse(status: number, description: string) { export function ApiProblemResponses(): MethodDecorator & ClassDecorator { return applyDecorators( ApiExtraModels(ProblemDetailsDto), - problemResponse(400, 'Invalid request'), - problemResponse(401, 'Authentication required'), - problemResponse(404, 'Resource not found'), - problemResponse(503, 'Upstream service unavailable'), - problemResponse(500, 'Internal server error'), + problemResponse(HttpStatus.BAD_REQUEST, 'Invalid request'), + problemResponse(HttpStatus.UNAUTHORIZED, 'Authentication required'), + problemResponse(HttpStatus.NOT_FOUND, 'Resource not found'), + problemResponse( + HttpStatus.SERVICE_UNAVAILABLE, + 'Upstream service unavailable', + ), + problemResponse(HttpStatus.INTERNAL_SERVER_ERROR, 'Internal server error'), ); } diff --git a/src/common/logging/request-logging.middleware.ts b/src/common/logging/request-logging.middleware.ts index 312559d..fdfaf04 100644 --- a/src/common/logging/request-logging.middleware.ts +++ b/src/common/logging/request-logging.middleware.ts @@ -1,3 +1,4 @@ +import { constants as httpStatus } from 'node:http2'; import { Injectable, NestMiddleware } from '@nestjs/common'; import type { NextFunction, Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; @@ -46,11 +47,11 @@ export class RequestLoggingMiddleware implements NestMiddleware { : {}), }; const level = - response.statusCode >= 500 + response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR ? 'error' - : response.statusCode === 429 || - response.statusCode === 401 || - response.statusCode === 403 + : response.statusCode === httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS || + response.statusCode === httpStatus.HTTP_STATUS_UNAUTHORIZED || + response.statusCode === httpStatus.HTTP_STATUS_FORBIDDEN ? 'warn' : 'info'; this.logger.event(level, 'http.request.completed', context); diff --git a/src/common/time.ts b/src/common/time.ts new file mode 100644 index 0000000..48deb5b --- /dev/null +++ b/src/common/time.ts @@ -0,0 +1,2 @@ +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 ae8b187..a15f0c5 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -1,3 +1,8 @@ +const DEFAULT_PORT = 3000; +const DEFAULT_TBANK_RATE_LIMIT_PER_SECOND = 5; +const MAX_PORT = 65_535; +const MIN_JWT_SECRET_LENGTH = 32; +const TOKEN_KEY_BYTES = 32; export interface Environment { DATABASE_URL: string; FRONTEND_ORIGIN: string; @@ -13,12 +18,14 @@ export interface Environment { export function validateEnv(config: Record): Environment { const databaseUrl = config['DATABASE_URL']; const frontendOrigin = config['FRONTEND_ORIGIN']; - const port = config['PORT'] ?? 3000; + const port = config['PORT'] ?? DEFAULT_PORT; const jwtSecret = config['JWT_SECRET']; const tokenEncryptionKey = config['TOKEN_ENCRYPTION_KEY']; const tbankEndpoint = config['TBANK_GRPC_ENDPOINT'] ?? 'invest-public-api.tbank.ru:443'; - const tbankRateLimit = config['TBANK_RATE_LIMIT_PER_SECOND'] ?? 5; + const tbankRateLimit = + config['TBANK_RATE_LIMIT_PER_SECOND'] ?? + DEFAULT_TBANK_RATE_LIMIT_PER_SECOND; const tbankCaCertPath = config['TBANK_CA_CERT_PATH']; const logLevel = config['LOG_LEVEL'] ?? 'info'; @@ -47,18 +54,25 @@ export function validateEnv(config: Record): Environment { } const parsedPort = Number(port); - if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) { + if ( + !Number.isInteger(parsedPort) || + parsedPort < 1 || + parsedPort > MAX_PORT + ) { throw new Error('PORT must be a valid port number'); } - if (typeof jwtSecret !== 'string' || jwtSecret.length < 32) { + if ( + typeof jwtSecret !== 'string' || + jwtSecret.length < MIN_JWT_SECRET_LENGTH + ) { throw new Error('JWT_SECRET must be at least 32 characters long'); } if ( typeof tokenEncryptionKey !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(tokenEncryptionKey) || - Buffer.from(tokenEncryptionKey, 'base64').length !== 32 + Buffer.from(tokenEncryptionKey, 'base64').length !== TOKEN_KEY_BYTES ) { throw new Error( 'TOKEN_ENCRYPTION_KEY must be a base64-encoded 32-byte key', diff --git a/src/integrations/moex/moex.client.ts b/src/integrations/moex/moex.client.ts index 925407c..85351e6 100644 --- a/src/integrations/moex/moex.client.ts +++ b/src/integrations/moex/moex.client.ts @@ -1,5 +1,8 @@ +import { constants as httpStatus } from 'node:http2'; import { Injectable, ServiceUnavailableException } from '@nestjs/common'; +const FALLBACK_PAGE_SIZE = 100; + const baseUrl = 'https://iss.moex.com/iss'; const timeoutMs = 10_000; @@ -121,8 +124,8 @@ export class MoexClient { if (!paginated) return rows; const cursorBlock = response[`${block}.cursor`]; if (!cursorBlock) { - if (current.length < 100) return rows; - start += 100; + if (current.length < FALLBACK_PAGE_SIZE) return rows; + start += FALLBACK_PAGE_SIZE; continue; } const cursor = parseRows(cursorBlock)[0]; @@ -145,7 +148,7 @@ export class MoexClient { const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs), }); - if (response.status === 404) return null; + if (response.status === httpStatus.HTTP_STATUS_NOT_FOUND) return null; if (!response.ok) throw new Error(`MOEX HTTP ${response.status}`); const body: unknown = await response.json(); if (!body || typeof body !== 'object') diff --git a/src/integrations/ncr/ncr-ratings.client.ts b/src/integrations/ncr/ncr-ratings.client.ts index 6b40676..ff3e47d 100644 --- a/src/integrations/ncr/ncr-ratings.client.ts +++ b/src/integrations/ncr/ncr-ratings.client.ts @@ -2,6 +2,12 @@ import { Injectable, ServiceUnavailableException } from '@nestjs/common'; import { load } from 'cheerio'; import { LRUCache } from 'lru-cache'; +const REGISTRY_CACHE_TTL_MS = 21_600_000; +const REQUEST_TIMEOUT_MS = 10_000; +const COLUMN_COUNT = 6; +const ISIN_COLUMN = 3; +const DATE_COLUMN = 5; + const registryUrl = 'https://ratings.ru/ratings/issues/'; export type NcrRating = { agency: 'NCR'; @@ -18,7 +24,7 @@ export class NcrRatingsClient { private readonly cache = new LRUCache< string, Promise> - >({ max: 1, ttl: 6 * 60 * 60_000 }); + >({ max: 1, ttl: REGISTRY_CACHE_TTL_MS }); async rating(isin: string): Promise { const key = isin.trim().toUpperCase(); @@ -38,7 +44,7 @@ export class NcrRatingsClient { private async registry(): Promise> { const response = await fetch(registryUrl, { - signal: AbortSignal.timeout(10_000), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); if (!response.ok) throw new Error('NCR unavailable'); const $ = load(await response.text()); @@ -49,18 +55,18 @@ export class NcrRatingsClient { .get(); if ( table.length !== 1 || - headings.length !== 6 || + headings.length !== COLUMN_COUNT || headings[2] !== 'Рейтинг' || - headings[3] !== 'ISIN' || - headings[5] !== 'Дата' + headings[ISIN_COLUMN] !== 'ISIN' || + headings[DATE_COLUMN] !== 'Дата' ) throw new Error('NCR registry changed'); const observedAt = new Date().toISOString(); const result = new Map(); table.find('tbody tr').each((_, row) => { const cells = $(row).find('td'); - if (cells.length !== 6) throw new Error('NCR row changed'); - const isin = cells.eq(3).text().trim().toUpperCase(); + if (cells.length !== COLUMN_COUNT) throw new Error('NCR row changed'); + 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(); @@ -73,9 +79,9 @@ export class NcrRatingsClient { ) throw new Error('NCR rating changed'); const dateParts = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec( - cells.eq(5).text().trim(), + cells.eq(DATE_COLUMN).text().trim(), ); - const href = cells.eq(3).find('a').attr('href'); + const href = cells.eq(ISIN_COLUMN).find('a').attr('href'); if (!dateParts || !href || !/^\/ratings\/issues\/\d+\/$/.test(href)) throw new Error('NCR identity missing'); const date = `${dateParts[3]}-${dateParts[2]}-${dateParts[1]}`; diff --git a/src/integrations/tbank/tbank-account.policy.ts b/src/integrations/tbank/tbank-account.policy.ts index c84ccf9..22c3aec 100644 --- a/src/integrations/tbank/tbank-account.policy.ts +++ b/src/integrations/tbank/tbank-account.policy.ts @@ -1,3 +1,4 @@ +import { MILLISECONDS_PER_SECOND } from '../../common/time'; import { TbankAccount } from './tbank.client'; const accountTypes = { @@ -51,7 +52,7 @@ export function accessibleTbankAccount( function openedOn(seconds: string | number | undefined): string | null { if (seconds === undefined) return null; - const date = new Date(Number(seconds) * 1000); + const date = new Date(Number(seconds) * MILLISECONDS_PER_SECOND); if (Number.isNaN(date.valueOf())) return null; return date.toISOString().slice(0, 10); } diff --git a/src/integrations/tbank/tbank.client.ts b/src/integrations/tbank/tbank.client.ts index a995d17..14f52d4 100644 --- a/src/integrations/tbank/tbank.client.ts +++ b/src/integrations/tbank/tbank.client.ts @@ -1,3 +1,4 @@ +import { MILLISECONDS_PER_SECOND } from '../../common/time'; import { ChannelCredentials, Client, @@ -28,6 +29,13 @@ import type { TbankShare, } from './tbank-market.types'; +const RATE_WINDOW_MS = 1_000; +const METADATA_MAX_ATTEMPTS = 3; +const RPC_MAX_ATTEMPTS = 2; +const MAX_QUEUE_SIZE = 50; +const METADATA_RETRY_DELAY_MS = 2_000; +const RPC_RETRY_DELAY_MS = 1_000; + const requestDeadlineMs = 15_000; type Unary = ( @@ -134,7 +142,7 @@ class RateLimiter { private drain(): void { const now = Date.now(); - if (now - this.windowStartedAt >= 1000) { + if (now - this.windowStartedAt >= RATE_WINDOW_MS) { this.windowStartedAt = now; this.starts = 0; } @@ -145,7 +153,7 @@ class RateLimiter { if (this.waiting.length && this.starts === this.perSecond) { setTimeout( () => this.drain(), - Math.max(1, 1000 - (now - this.windowStartedAt)), + Math.max(1, RATE_WINDOW_MS - (now - this.windowStartedAt)), ); } } @@ -422,8 +430,12 @@ export class TbankClient { 'getCandles', { instrumentId: uid, - from: { seconds: String(Math.floor(from.valueOf() / 1000)) }, - to: { seconds: String(Math.floor(to.valueOf() / 1000)) }, + from: { + seconds: String(Math.floor(from.valueOf() / MILLISECONDS_PER_SECOND)), + }, + to: { + seconds: String(Math.floor(to.valueOf() / MILLISECONDS_PER_SECOND)), + }, interval: 'CANDLE_INTERVAL_DAY', limit: 2400, }, @@ -456,10 +468,10 @@ export class TbankClient { request: object, ): Promise { const metadataCall = method === 'getInstrumentBy'; - const maxAttempts = metadataCall ? 3 : 2; + const maxAttempts = metadataCall ? METADATA_MAX_ATTEMPTS : RPC_MAX_ATTEMPTS; for (let attempts = 1; attempts <= maxAttempts; attempts += 1) { const startedAt = performance.now(); - const queueFull = this.queue.size >= 50; + const queueFull = this.queue.size >= MAX_QUEUE_SIZE; try { if (queueFull) throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }); @@ -492,7 +504,12 @@ export class TbankClient { durationMs: Math.round(performance.now() - startedAt), }); await new Promise((resolve) => - setTimeout(resolve, metadataCall ? attempts * 2000 : 1000), + setTimeout( + resolve, + metadataCall + ? attempts * METADATA_RETRY_DELAY_MS + : RPC_RETRY_DELAY_MS, + ), ); continue; } @@ -537,5 +554,5 @@ export class TbankClient { } function utcSeconds(date: string): number { - return Math.floor(Date.parse(`${date}T00:00:00Z`) / 1000); + return Math.floor(Date.parse(`${date}T00:00:00Z`) / MILLISECONDS_PER_SECOND); } diff --git a/src/integrations/tbank/tbank.crypto.ts b/src/integrations/tbank/tbank.crypto.ts index 23c5cf5..6eebe25 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 KEY_SIZE_BYTES = 32; + const VERSION = 1; const NONCE_SIZE = 12; const TAG_SIZE = 16; @@ -9,7 +11,8 @@ export class TbankCrypto { static fromBase64(key: string): TbankCrypto { const decoded = Buffer.from(key, 'base64'); - if (decoded.length !== 32) throw new Error('Invalid token encryption key'); + if (decoded.length !== KEY_SIZE_BYTES) + throw new Error('Invalid token encryption key'); return new TbankCrypto(decoded); } diff --git a/src/modules/auth/application/auth.service.ts b/src/modules/auth/application/auth.service.ts index ecbdf5d..2e611c2 100644 --- a/src/modules/auth/application/auth.service.ts +++ b/src/modules/auth/application/auth.service.ts @@ -10,6 +10,9 @@ import { Session, User } from '../../../../generated/prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; import { AuthUser, RefreshPayload } from './auth.types'; +const PASSWORD_HASH_ROUNDS = 12; +const REFRESH_TTL_MS = 604_800_000; + const ACCESS_TTL = '15m'; const REFRESH_TTL = '7d'; const DUMMY_PASSWORD_HASH = @@ -39,7 +42,7 @@ export class AuthService { data: { id: randomUUID(), email: normalizedEmail, - passwordHash: await bcrypt.hash(password, 12), + passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS), }, }); } catch (error) { @@ -162,7 +165,7 @@ export class AuthService { ); const data = { refreshTokenHash: this.hash(refreshToken), - expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + expiresAt: new Date(Date.now() + REFRESH_TTL_MS), }; if (expectedRefreshTokenHash) { const updated = await this.prisma.session.updateMany({ diff --git a/src/modules/auth/presentation/auth.controller.ts b/src/modules/auth/presentation/auth.controller.ts index 4d35801..3b44ca6 100644 --- a/src/modules/auth/presentation/auth.controller.ts +++ b/src/modules/auth/presentation/auth.controller.ts @@ -3,6 +3,7 @@ import { Controller, Get, HttpCode, + HttpStatus, Post, Req, Res, @@ -49,7 +50,7 @@ export class AuthController { constructor(private readonly auth: AuthService) {} @Post('register') - @HttpCode(200) + @HttpCode(HttpStatus.OK) @ApiOkResponse({ type: AuthenticationDto }) async register( @Body() credentials: CredentialsDto, @@ -62,7 +63,7 @@ export class AuthController { } @Post('login') - @HttpCode(200) + @HttpCode(HttpStatus.OK) @ApiOkResponse({ type: AuthenticationDto }) async login( @Body() credentials: CredentialsDto, @@ -75,7 +76,7 @@ export class AuthController { } @Post('refresh') - @HttpCode(200) + @HttpCode(HttpStatus.OK) @ApiOkResponse({ type: AuthenticationDto }) async refresh( @Req() request: Request, @@ -92,7 +93,7 @@ export class AuthController { } @Post('logout') - @HttpCode(204) + @HttpCode(HttpStatus.NO_CONTENT) @ApiNoContentResponse() async logout( @Req() request: Request, diff --git a/src/modules/auth/presentation/auth.dto.ts b/src/modules/auth/presentation/auth.dto.ts index fa30110..b2e25ec 100644 --- a/src/modules/auth/presentation/auth.dto.ts +++ b/src/modules/auth/presentation/auth.dto.ts @@ -7,20 +7,26 @@ import { ValidatorConstraint, } from 'class-validator'; +const MAX_EMAIL_LENGTH = 254; +const ASCII_SPACE = 32; +const ASCII_DELETE = 127; +const MIN_PASSWORD_LENGTH = 12; +const MAX_PASSWORD_BYTES = 72; + @ValidatorConstraint({ name: 'email' }) class EmailConstraint { validate(value: unknown): boolean { return ( typeof value === 'string' && - value.length <= 254 && + value.length <= MAX_EMAIL_LENGTH && value.split('@').length === 2 && !value .split('') .some( (character) => /\s/.test(character) || - character.charCodeAt(0) < 32 || - character.charCodeAt(0) === 127, + character.charCodeAt(0) < ASCII_SPACE || + character.charCodeAt(0) === ASCII_DELETE, ) && value.split('@').every(Boolean) ); @@ -33,7 +39,10 @@ class EmailConstraint { @ValidatorConstraint({ name: 'passwordBytes' }) class PasswordBytesConstraint { validate(value: unknown): boolean { - return typeof value === 'string' && Buffer.byteLength(value, 'utf8') <= 72; + return ( + typeof value === 'string' && + Buffer.byteLength(value, 'utf8') <= MAX_PASSWORD_BYTES + ); } defaultMessage(): string { return 'must not exceed 72 UTF-8 bytes'; @@ -49,9 +58,13 @@ export class CredentialsDto { @Validate(EmailConstraint) declare email: string; - @ApiProperty({ minLength: 12, maxLength: 72, example: 'a secure password' }) + @ApiProperty({ + minLength: MIN_PASSWORD_LENGTH, + maxLength: MAX_PASSWORD_BYTES, + example: 'a secure password', + }) @IsString() - @MinLength(12) + @MinLength(MIN_PASSWORD_LENGTH) @Validate(PasswordBytesConstraint) declare password: string; } 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 2fdf64d..e62f380 100644 --- a/src/modules/banking/bank-cards/application/bank-card-input.ts +++ b/src/modules/banking/bank-cards/application/bank-card-input.ts @@ -1,3 +1,7 @@ +import { + MAX_CARD_NAME_LENGTH, + MAX_OPERATION_DESCRIPTION_LENGTH, +} from '../domain/bank-card-limits'; import { BadRequestException, ConflictException, @@ -13,7 +17,26 @@ import { import { isCalendarDate, moscowToday } from '../domain/bank-card.calculations'; import { parseRubMoney } from '../domain/rub-money'; -export function fail(code = 'BANK_INVALID_INPUT'): never { +export const BankErrorCode = { + InvalidInput: 'BANK_INVALID_INPUT', + VersionConflict: 'BANK_VERSION_CONFLICT', + NotFound: 'BANK_NOT_FOUND', + InvalidMoney: 'BANK_INVALID_MONEY', + InvalidDate: 'BANK_INVALID_DATE', + ImmutableOperation: 'BANK_IMMUTABLE_OPERATION', + InvalidRefund: 'BANK_INVALID_REFUND', + RefundLimit: 'BANK_REFUND_LIMIT', + DependentRefunds: 'BANK_DEPENDENT_REFUNDS', + IntentConflict: 'BANK_INTENT_CONFLICT', + OperationDeleted: 'BANK_OPERATION_DELETED', + CardArchived: 'BANK_CARD_ARCHIVED', + OpeningDateConflict: 'BANK_OPENING_DATE_CONFLICT', + WriteConflict: 'BANK_WRITE_CONFLICT', + DuplicateCategory: 'BANK_DUPLICATE_CATEGORY', +} as const; +export type BankErrorCode = (typeof BankErrorCode)[keyof typeof BankErrorCode]; + +export function fail(code: BankErrorCode = BankErrorCode.InvalidInput): never { throw new BadRequestException({ code }); } export function periodBounds(query: BankPeriodQueryDto) { @@ -35,11 +58,13 @@ export function periodBounds(query: BankPeriodQueryDto) { return fail(); return { from: query.from, to: query.to }; } -export function conflict(code = 'BANK_VERSION_CONFLICT'): never { +export function conflict( + code: BankErrorCode = BankErrorCode.VersionConflict, +): never { throw new ConflictException({ code }); } export function missing(): never { - throw new NotFoundException({ code: 'BANK_NOT_FOUND' }); + throw new NotFoundException({ code: BankErrorCode.NotFound }); } export const fingerprint = (value: unknown) => createHash('sha256') @@ -56,16 +81,16 @@ export function amount( try { return parseRubMoney(value, positive); } catch { - return fail('BANK_INVALID_MONEY'); + return fail(BankErrorCode.InvalidMoney); } } export function calendar(value: string) { - if (!isCalendarDate(value)) fail('BANK_INVALID_DATE'); + if (!isCalendarDate(value)) fail(BankErrorCode.InvalidDate); return value; } export function currentDate(value: string) { calendar(value); - if (value > moscowToday()) fail('BANK_INVALID_DATE'); + if (value > moscowToday()) fail(BankErrorCode.InvalidDate); } export function name(value: string, max: number) { if (typeof value !== 'string' || !value.trim() || value.trim().length > max) @@ -74,7 +99,7 @@ export function name(value: string, max: number) { } export function cardInput(dto: CreateBankCardDto) { const bankName = dto.bankName?.trim() || null; - if (bankName && bankName.length > 100) fail(); + if (bankName && bankName.length > MAX_CARD_NAME_LENGTH) fail(); if (dto.lastFour != null && !/^\d{4}$/.test(dto.lastFour)) fail(); if ( dto.paymentSystem != null && @@ -83,7 +108,7 @@ export function cardInput(dto: CreateBankCardDto) { fail(); return { ...(dto.paymentSystem == null ? {} : { paymentSystem: dto.paymentSystem }), - name: name(dto.name, 100), + name: name(dto.name, MAX_CARD_NAME_LENGTH), bankName, lastFour: dto.lastFour || null, openingBalanceKopecks: amount(dto.openingBalance), @@ -93,7 +118,8 @@ export function cardInput(dto: CreateBankCardDto) { export function operationInput(dto: CreateBankOperationDto) { if ( dto.description != null && - (typeof dto.description !== 'string' || dto.description.length > 500) + (typeof dto.description !== 'string' || + dto.description.length > MAX_OPERATION_DESCRIPTION_LENGTH) ) fail(); return { 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 c83f8be..356c5a0 100644 --- a/src/modules/banking/bank-cards/application/bank-cards.service.ts +++ b/src/modules/banking/bank-cards/application/bank-cards.service.ts @@ -11,6 +11,7 @@ import { rubMoney } from '../domain/rub-money'; import { cardInput, + BankErrorCode, conflict, currentDate, fail, @@ -71,7 +72,7 @@ export class BankCardsService { if (existing) { if (existing.userId !== userId) missing(); if (existing.createFingerprint !== hash) - conflict('BANK_INTENT_CONFLICT'); + conflict(BankErrorCode.IntentConflict); return this.cardView(tx, existing); } currentDate(input.openingDate); @@ -110,7 +111,8 @@ export class BankCardsService { const financial = input.openingBalanceKopecks !== previous.openingBalanceKopecks || input.openingDate !== previous.openingDate; - if (financial && previous.archivedAt) conflict('BANK_CARD_ARCHIVED'); + if (financial && previous.archivedAt) + conflict(BankErrorCode.CardArchived); currentDate(input.openingDate); if ( await tx.bankCardOperation.findFirst({ @@ -122,7 +124,7 @@ export class BankCardsService { }, }) ) - fail('BANK_OPENING_DATE_CONFLICT'); + fail(BankErrorCode.OpeningDateConflict); const changed = await tx.bankCard.updateMany({ where: { id, userId, version: dto.version }, data: { 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 3296be7..9b6cabd 100644 --- a/src/modules/banking/bank-cards/application/bank-operations.service.ts +++ b/src/modules/banking/bank-cards/application/bank-operations.service.ts @@ -1,3 +1,4 @@ +import { BankOperationKind } from '../domain/bank-operation-kind'; import { GoneException, Injectable } from '@nestjs/common'; import { BankCardOperation, @@ -6,6 +7,7 @@ import { import { PrismaService } from '../../../../prisma/prisma.service'; import { BankOperationDto, + DEFAULT_BANK_PAGE_SIZE, BankOperationsQueryDto, BankPageQueryDto, CreateBankOperationDto, @@ -14,6 +16,7 @@ import { import { rubMoney } from '../domain/rub-money'; import { + BankErrorCode, conflict, currentDate, fail, @@ -48,7 +51,7 @@ export class BankOperationsService { }) : null; const refunds = - row.kind === 'expense' || source + row.kind === BankOperationKind.Expense || source ? await tx.bankCardOperation.findMany({ where: { userId: row.userId, @@ -70,7 +73,7 @@ export class BankOperationsService { originalExpenseId: row.originalExpenseId, amount: rubMoney(row.amountKopecks), availableRefund: - row.kind === 'expense' || source + row.kind === BankOperationKind.Expense || source ? rubMoney( (source?.amountKopecks ?? row.amountKopecks) - refunds.reduce((sum, r) => sum + r.amountKopecks, 0n), @@ -100,15 +103,15 @@ export class BankOperationsService { (id === input.cardId || id === input.targetCardId) && input.date < card.openingDate ) - fail('BANK_INVALID_DATE'); + fail(BankErrorCode.InvalidDate); } if ( old && (old.kind !== input.kind || old.originalExpenseId !== input.originalExpenseId) ) - fail('BANK_IMMUTABLE_OPERATION'); - if (input.kind === 'transfer') { + fail(BankErrorCode.ImmutableOperation); + if (input.kind === BankOperationKind.Transfer) { if ( !input.targetCardId || input.targetCardId === input.cardId || @@ -117,7 +120,7 @@ export class BankOperationsService { ) fail(); } else if (input.targetCardId) fail(); - if (input.kind === 'expense') { + if (input.kind === BankOperationKind.Expense) { if (input.originalExpenseId) fail(); if (!input.categoryId) { const category = await tx.expenseCategory.findUnique({ @@ -133,8 +136,9 @@ export class BankOperationsService { ) missing(); } else if (input.categoryId) fail(); - if (input.kind !== 'refund' && input.originalExpenseId) fail(); - if (input.kind === 'refund') { + if (input.kind !== BankOperationKind.Refund && input.originalExpenseId) + fail(); + if (input.kind === BankOperationKind.Refund) { if (!input.originalExpenseId) fail(); const source = await this.repository.operation( tx, @@ -143,10 +147,10 @@ export class BankOperationsService { ); if ( source.deletedAt || - source.kind !== 'expense' || + source.kind !== BankOperationKind.Expense || source.cardId !== input.cardId ) - fail('BANK_INVALID_REFUND'); + fail(BankErrorCode.InvalidRefund); if ( ( await tx.bankCardOperation.updateMany({ @@ -169,15 +173,15 @@ export class BankOperationsService { ...(old ? { id: { not: old.id } } : {}), }, }); - if (input.date < source.date) fail('BANK_INVALID_DATE'); + if (input.date < source.date) fail(BankErrorCode.InvalidDate); if ( refunds.reduce((sum, r) => sum + r.amountKopecks, 0n) + input.amountKopecks > source.amountKopecks ) - conflict('BANK_REFUND_LIMIT'); + conflict(BankErrorCode.RefundLimit); } - if (old?.kind === 'expense') { + if (old?.kind === BankOperationKind.Expense) { const refunds = await tx.bankCardOperation.findMany({ where: { userId, originalExpenseId: old.id, deletedAt: null }, }); @@ -188,7 +192,7 @@ export class BankOperationsService { refunds.reduce((sum, row) => sum + row.amountKopecks, 0n) > input.amountKopecks) ) - conflict('BANK_DEPENDENT_REFUNDS'); + conflict(BankErrorCode.DependentRefunds); } } createOperation(userId: string, dto: CreateBankOperationDto) { @@ -201,9 +205,9 @@ export class BankOperationsService { if (existing) { if (existing.userId !== userId) missing(); if (existing.createFingerprint !== hash) - conflict('BANK_INTENT_CONFLICT'); + conflict(BankErrorCode.IntentConflict); if (existing.deletedAt) - throw new GoneException({ code: 'BANK_OPERATION_DELETED' }); + throw new GoneException({ code: BankErrorCode.OperationDeleted }); return this.operationView(tx, existing); } const input = { ...normalized }; @@ -265,12 +269,12 @@ export class BankOperationsService { if (row.targetCardId) await this.repository.card(tx, userId, row.targetCardId, true); if ( - row.kind === 'expense' && + row.kind === BankOperationKind.Expense && (await tx.bankCardOperation.findFirst({ where: { userId, originalExpenseId: id, deletedAt: null }, })) ) - conflict('BANK_DEPENDENT_REFUNDS'); + conflict(BankErrorCode.DependentRefunds); if (row.originalExpenseId) { const source = await this.repository.operation( tx, @@ -310,14 +314,14 @@ export class BankOperationsService { return this.prisma.$transaction(async (tx) => { await this.repository.card(tx, userId, id); const page = query.page ?? 1, - limit = query.limit ?? 50; + limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE; const where: Prisma.BankCardOperationWhereInput = { userId, deletedAt: null, OR: [{ cardId: id }, { targetCardId: id }], ...(period ? { date: { gte: period.from, lte: period.to } } - : { kind: 'expense' }), + : { kind: BankOperationKind.Expense }), }; const total = await tx.bankCardOperation.count({ where }); const rows = await tx.bankCardOperation.findMany({ diff --git a/src/modules/banking/bank-cards/application/expense-categories.service.ts b/src/modules/banking/bank-cards/application/expense-categories.service.ts index 8c44a29..f110154 100644 --- a/src/modules/banking/bank-cards/application/expense-categories.service.ts +++ b/src/modules/banking/bank-cards/application/expense-categories.service.ts @@ -1,3 +1,4 @@ +import { MAX_CATEGORY_NAME_LENGTH } from '../domain/bank-card-limits'; import { Injectable } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; import { PrismaService } from '../../../../prisma/prisma.service'; @@ -24,7 +25,7 @@ export class ExpenseCategoriesService { })); } createCategory(userId: string, value: string) { - const label = name(value, 80); + const label = name(value, MAX_CATEGORY_NAME_LENGTH); return this.repository.write(async (tx) => { await this.repository.seed(tx, userId); const row = await tx.expenseCategory.create({ @@ -44,7 +45,7 @@ export class ExpenseCategoriesService { }); } updateCategory(userId: string, id: string, value: string, version: number) { - const label = name(value, 80); + const label = name(value, MAX_CATEGORY_NAME_LENGTH); return this.repository.write(async (tx) => { const row = await tx.expenseCategory.findFirst({ where: { id, userId } }); if (!row) missing(); diff --git a/src/modules/banking/bank-cards/domain/bank-card-limits.ts b/src/modules/banking/bank-cards/domain/bank-card-limits.ts new file mode 100644 index 0000000..9483b9d --- /dev/null +++ b/src/modules/banking/bank-cards/domain/bank-card-limits.ts @@ -0,0 +1,3 @@ +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 d727ff5..4ee045a 100644 --- a/src/modules/banking/bank-cards/domain/bank-card.calculations.ts +++ b/src/modules/banking/bank-cards/domain/bank-card.calculations.ts @@ -1,3 +1,6 @@ +import { BankOperationKind } from './bank-operation-kind'; + +const ISO_MONTH_LENGTH = 7; export function moscowToday(now = new Date()): string { return new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Moscow', @@ -30,12 +33,21 @@ export function bankBalance( let balance = opening; for (const operation of operations) { if (operation.cardId === cardId) { - if (operation.kind === 'expense' || operation.kind === 'transfer') + if ( + operation.kind === BankOperationKind.Expense || + operation.kind === BankOperationKind.Transfer + ) balance -= operation.amountKopecks; - else if (operation.kind === 'topup' || operation.kind === 'refund') + else if ( + operation.kind === BankOperationKind.Topup || + operation.kind === BankOperationKind.Refund + ) balance += operation.amountKopecks; } - if (operation.kind === 'transfer' && operation.targetCardId === cardId) + if ( + operation.kind === BankOperationKind.Transfer && + operation.targetCardId === cardId + ) balance += operation.amountKopecks; } return balance; @@ -60,13 +72,23 @@ export function bankSummary( for (const operation of operations) { if ( typeof period === 'string' - ? operation.date.slice(0, 7) !== period + ? operation.date.slice(0, ISO_MONTH_LENGTH) !== period : operation.date < period.from || operation.date > period.to ) continue; - if (operation.kind !== 'expense' && operation.kind !== 'refund') continue; - const expense = operation.kind === 'expense' ? operation.amountKopecks : 0n; - const refund = operation.kind === 'refund' ? operation.amountKopecks : 0n; + if ( + operation.kind !== BankOperationKind.Expense && + operation.kind !== BankOperationKind.Refund + ) + continue; + const expense = + operation.kind === BankOperationKind.Expense + ? operation.amountKopecks + : 0n; + const refund = + operation.kind === BankOperationKind.Refund + ? operation.amountKopecks + : 0n; expenses += expense; refunds += refund; if (operation.categoryId !== null) { diff --git a/src/modules/banking/bank-cards/domain/bank-operation-kind.ts b/src/modules/banking/bank-cards/domain/bank-operation-kind.ts new file mode 100644 index 0000000..28454d3 --- /dev/null +++ b/src/modules/banking/bank-cards/domain/bank-operation-kind.ts @@ -0,0 +1,8 @@ +export const BankOperationKind = { + Expense: 'expense', + Topup: 'topup', + Transfer: 'transfer', + Refund: 'refund', +} as const; +export type BankOperationKind = + (typeof BankOperationKind)[keyof typeof BankOperationKind]; diff --git a/src/modules/banking/bank-cards/domain/rub-money.ts b/src/modules/banking/bank-cards/domain/rub-money.ts index 5113565..2bb1b67 100644 --- a/src/modules/banking/bank-cards/domain/rub-money.ts +++ b/src/modules/banking/bank-cards/domain/rub-money.ts @@ -1,6 +1,9 @@ import { decimalFromString, formatDecimal } from '../../../../common/decimal'; import type { Money } from '../../../../common/money.types'; +const MIN_KOPECKS = -9_223_372_036_854_775_808n; +const MAX_KOPECKS = 9_223_372_036_854_775_807n; + export function parseRubMoney(money: Money, positive = false): bigint { if ( money?.currency !== 'RUB' || @@ -12,7 +15,7 @@ export function parseRubMoney(money: Money, positive = false): bigint { if (amount.scale > 2) throw new RangeError('RUB money supports at most two decimal places'); const kopecks = amount.value * 10n ** BigInt(2 - amount.scale); - if (kopecks < -(2n ** 63n) || kopecks > 2n ** 63n - 1n) + if (kopecks < MIN_KOPECKS || kopecks > MAX_KOPECKS) throw new RangeError('RUB money is outside the signed 64-bit range'); if (positive && kopecks <= 0n) throw new RangeError('Operation amount must be positive'); 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 98ef256..48fab86 100644 --- a/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts +++ b/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts @@ -1,10 +1,18 @@ +import { BankOperationKind } from '../domain/bank-operation-kind'; import { Injectable } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; import { Prisma } from '../../../../../generated/prisma/client'; import { PrismaService } from '../../../../prisma/prisma.service'; -import { conflict, missing } from '../application/bank-card-input'; +import { + BankErrorCode, + conflict, + missing, +} from '../application/bank-card-input'; type Tx = Prisma.TransactionClient; +const TRANSACTION_MAX_WAIT_MS = 10_000; +const TRANSACTION_TIMEOUT_MS = 15_000; +const MAX_WRITE_RETRY_COUNT = 2; const seeds = [ 'Продукты', 'Кафе', @@ -23,8 +31,8 @@ export class BankCardsRepository { for (let attempt = 0; ; attempt++) { try { return await this.prisma.$transaction(run, { - maxWait: 10000, - timeout: 15000, + maxWait: TRANSACTION_MAX_WAIT_MS, + timeout: TRANSACTION_TIMEOUT_MS, }); } catch (error) { const code = @@ -36,9 +44,10 @@ export class BankCardsRepository { code === 'P2028' || (error instanceof Error && /SQLITE_BUSY|database is locked/.test(error.message)); - if ((busy || code === 'P2002') && attempt < 2) continue; - if (busy) conflict('BANK_WRITE_CONFLICT'); - if (code === 'P2002') conflict('BANK_DUPLICATE_CATEGORY'); + if ((busy || code === 'P2002') && attempt < MAX_WRITE_RETRY_COUNT) + continue; + if (busy) conflict(BankErrorCode.WriteConflict); + if (code === 'P2002') conflict(BankErrorCode.DuplicateCategory); throw error; } } @@ -46,7 +55,7 @@ export class BankCardsRepository { async card(tx: Tx, userId: string, id: string, active = false) { const card = await tx.bankCard.findFirst({ where: { id, userId } }); if (!card) return missing(); - if (active && card.archivedAt) conflict('BANK_CARD_ARCHIVED'); + if (active && card.archivedAt) conflict(BankErrorCode.CardArchived); return card; } async operation(tx: Tx, userId: string, id: string) { @@ -56,7 +65,7 @@ export class BankCardsRepository { } async seed(tx: Tx, userId: string) { for (const [index, label] of seeds.entries()) { - const seedKey = index === 8 ? 'other' : String(index); + const seedKey = index === seeds.length - 1 ? 'other' : String(index); if ( !(await tx.expenseCategory.findUnique({ where: { userId_seedKey: { userId, seedKey } }, @@ -85,7 +94,7 @@ export class BankCardsRepository { userId, deletedAt: null, date: { gte: period.from, lte: period.to }, - kind: { in: ['expense', 'refund'] }, + kind: { in: [BankOperationKind.Expense, BankOperationKind.Refund] }, ...(cardId ? { cardId } : {}), }, include: { originalExpense: { select: { categoryId: true } } }, 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 5374707..bb23f56 100644 --- a/src/modules/banking/bank-cards/presentation/bank-card.dto.ts +++ b/src/modules/banking/bank-cards/presentation/bank-card.dto.ts @@ -1,3 +1,9 @@ +import { + MAX_CARD_NAME_LENGTH, + MAX_CATEGORY_NAME_LENGTH, + MAX_OPERATION_DESCRIPTION_LENGTH, +} from '../domain/bank-card-limits'; +import { BankOperationKind } from '../domain/bank-operation-kind'; import { ApiProperty, ApiPropertyOptional, @@ -24,16 +30,13 @@ import { } from 'class-validator'; import { MoneyDto } from '../../../../common/money.dto'; +export const DEFAULT_BANK_PAGE_SIZE = 50; +const MAX_BANK_PAGE_SIZE = 100; + const trim = ({ value }: { value: unknown }) => typeof value === 'string' ? value.trim() : value; export const bankPaymentSystems = ['mir', 'visa', 'mastercard'] as const; -export const bankOperationKinds = [ - 'expense', - 'topup', - 'transfer', - 'refund', -] as const; -export type BankOperationKind = (typeof bankOperationKinds)[number]; +const bankOperationKinds = Object.values(BankOperationKind); export class RubMoneyInputDto { @ApiProperty({ enum: ['RUB'] }) @IsIn(['RUB']) declare currency: string; @ApiProperty({ pattern: '^-?[0-9]+(\\.[0-9]{1,2})?$' }) @@ -43,17 +46,21 @@ export class RubMoneyInputDto { } export class CreateBankCardDto { @ApiProperty({ format: 'uuid' }) @IsUUID() declare id: string; - @ApiProperty({ minLength: 1, maxLength: 100 }) + @ApiProperty({ minLength: 1, maxLength: MAX_CARD_NAME_LENGTH }) @Transform(trim) @IsString() @MinLength(1) - @MaxLength(100) + @MaxLength(MAX_CARD_NAME_LENGTH) declare name: string; - @ApiPropertyOptional({ type: String, nullable: true, maxLength: 100 }) + @ApiPropertyOptional({ + type: String, + nullable: true, + maxLength: MAX_CARD_NAME_LENGTH, + }) @IsOptional() @Transform(trim) @IsString() - @MaxLength(100) + @MaxLength(MAX_CARD_NAME_LENGTH) bankName?: string | null; @ApiPropertyOptional({ type: String, nullable: true, pattern: '^[0-9]{4}$' }) @IsOptional() @@ -84,11 +91,11 @@ export class UpdateBankCardDto extends PartialType( archived?: boolean; } export class CategoryNameDto { - @ApiProperty({ minLength: 1, maxLength: 80 }) + @ApiProperty({ minLength: 1, maxLength: MAX_CATEGORY_NAME_LENGTH }) @Transform(trim) @IsString() @MinLength(1) - @MaxLength(80) + @MaxLength(MAX_CATEGORY_NAME_LENGTH) declare name: string; } export class UpdateExpenseCategoryDto extends CategoryNameDto { @@ -121,10 +128,10 @@ export class CreateBankOperationDto { @IsString() @Matches(/^\d{4}-\d{2}-\d{2}$/) declare date: string; - @ApiPropertyOptional({ maxLength: 500 }) + @ApiPropertyOptional({ maxLength: MAX_OPERATION_DESCRIPTION_LENGTH }) @ValidateIf((_object, value: unknown) => value !== undefined) @IsString() - @MaxLength(500) + @MaxLength(MAX_OPERATION_DESCRIPTION_LENGTH) description?: string; } export class UpdateBankOperationDto extends PartialType( @@ -146,12 +153,16 @@ export class BankPageQueryDto { @IsInt() @Min(1) page?: number; - @ApiPropertyOptional({ minimum: 1, maximum: 100, default: 50 }) + @ApiPropertyOptional({ + minimum: 1, + maximum: MAX_BANK_PAGE_SIZE, + default: DEFAULT_BANK_PAGE_SIZE, + }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) - @Max(100) + @Max(MAX_BANK_PAGE_SIZE) limit?: number; } export class BankPeriodQueryDto { diff --git a/src/modules/banking/bank-cards/presentation/bank-cards.controller.ts b/src/modules/banking/bank-cards/presentation/bank-cards.controller.ts index 91d6353..e484e23 100644 --- a/src/modules/banking/bank-cards/presentation/bank-cards.controller.ts +++ b/src/modules/banking/bank-cards/presentation/bank-cards.controller.ts @@ -4,6 +4,7 @@ import { Delete, Get, HttpCode, + HttpStatus, Param, Patch, Post, @@ -143,7 +144,7 @@ export class BankCardsController { return this.operations.updateOperation(req.user.sub, id, body); } @Delete('operations/:id') - @HttpCode(204) + @HttpCode(HttpStatus.NO_CONTENT) @ApiNoContentResponse() deleteOperation( @Req() req: AuthRequest, diff --git a/src/modules/investments/income/income-period.ts b/src/modules/investments/income/income-period.ts index 72b35bd..e6e242e 100644 --- a/src/modules/investments/income/income-period.ts +++ b/src/modules/investments/income/income-period.ts @@ -1,3 +1,4 @@ +import { MILLISECONDS_PER_DAY } from '../../../common/time'; import { BadRequestException } from '@nestjs/common'; import { DateOnlyRange, @@ -6,6 +7,12 @@ import { utcDate, } from '../investment-calendar'; +const DAILY_BUCKET_MAX_DAYS = 45; +const WEEKLY_BUCKET_MAX_DAYS = 180; +const DAYS_PER_WEEK = 7; +const ISO_MONTH_LENGTH = 7; +const ISO_YEAR_LENGTH = 4; + export type IncomeBucketGranularity = 'day' | 'week' | 'month'; export function planIncomePeriod( @@ -36,9 +43,9 @@ export function planIncomePeriod( previous, yearAgo: yearAgoRange, granularity: - rangeDays(selected) <= 45 + rangeDays(selected) <= DAILY_BUCKET_MAX_DAYS ? 'day' - : rangeDays(selected) <= 180 + : rangeDays(selected) <= WEEKLY_BUCKET_MAX_DAYS ? 'week' : 'month', }; @@ -53,11 +60,19 @@ export function rangeBuckets( const buckets: DateOnlyRange[] = []; let cursor = granularity === 'week' - ? addDays(range.from, -((utcDate(range.from).getUTCDay() + 6) % 7)) - : `${range.from.slice(0, 7)}-01`; + ? addDays( + range.from, + -( + (utcDate(range.from).getUTCDay() + DAYS_PER_WEEK - 1) % + DAYS_PER_WEEK + ), + ) + : `${range.from.slice(0, ISO_MONTH_LENGTH)}-01`; while (cursor <= range.to) { const end = - granularity === 'week' ? addDays(cursor, 6) : endOfMonth(cursor); + granularity === 'week' + ? addDays(cursor, DAYS_PER_WEEK - 1) + : endOfMonth(cursor); buckets.push({ from: cursor < range.from ? range.from : cursor, to: end > range.to ? range.to : end, @@ -71,7 +86,7 @@ export function rangeDays(range: DateOnlyRange): number { return ( Math.round( (utcDate(range.to).valueOf() - utcDate(range.from).valueOf()) / - 86_400_000, + MILLISECONDS_PER_DAY, ) + 1 ); } @@ -100,7 +115,7 @@ function dates(range: DateOnlyRange): string[] { } function firstOfMonth(value: string): string { - return `${value.slice(0, 7)}-01`; + return `${value.slice(0, ISO_MONTH_LENGTH)}-01`; } function endOfMonth(value: string): string { @@ -126,7 +141,7 @@ 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(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + const result = `${String(year - 1).padStart(ISO_YEAR_LENGTH, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; return utcDate(result).toISOString().slice(0, 10) === result ? result : null; diff --git a/src/modules/investments/instruments/application/instrument-market.ts b/src/modules/investments/instruments/application/instrument-market.ts index 8bdf05b..a74e55e 100644 --- a/src/modules/investments/instruments/application/instrument-market.ts +++ b/src/modules/investments/instruments/application/instrument-market.ts @@ -1,16 +1,21 @@ +import { MILLISECONDS_PER_SECOND } from '../../../../common/time'; import { ServiceUnavailableException } from '@nestjs/common'; import type { TbankCandle, TbankTimestamp, } from '../../../../integrations/tbank/tbank.api'; + +const MOSCOW_OFFSET_MS = 10_800_000; export function timestamp(value?: TbankTimestamp): string | null { const seconds = Number(value?.seconds); return Number.isFinite(seconds) && seconds > 0 - ? new Date(seconds * 1000).toISOString() + ? new Date(seconds * MILLISECONDS_PER_SECOND).toISOString() : null; } export function moscowDay(iso: string): string { - return new Date(Date.parse(iso) + 3 * 3_600_000).toISOString().slice(0, 10); + return new Date(Date.parse(iso) + MOSCOW_OFFSET_MS) + .toISOString() + .slice(0, 10); } export function previousClose( candles: TbankCandle[], diff --git a/src/modules/investments/instruments/application/instrument-position.ts b/src/modules/investments/instruments/application/instrument-position.ts index c6479dd..2c84ff4 100644 --- a/src/modules/investments/instruments/application/instrument-position.ts +++ b/src/modules/investments/instruments/application/instrument-position.ts @@ -13,6 +13,9 @@ import { } from '../../portfolio/portfolio.api'; import { StockPositionDto } from '../stocks/presentation/stock.dto'; +const PRICE_DECIMAL_PLACES = 9; +const RETURN_PERCENT_DECIMAL_PLACES = 6; + export function instrumentPosition( portfolio: PortfolioDto, uid: string, @@ -41,7 +44,11 @@ export function instrumentPosition( ? { currency: totalCost.currency, value: formatDecimal( - divide(decimalFromString(totalCost.value), quantity, 9), + divide( + decimalFromString(totalCost.value), + quantity, + PRICE_DECIMAL_PLACES, + ), ), } : null, @@ -103,7 +110,7 @@ function percent( divide( multiply(decimalFromString(result.value), decimalFromString('100')), decimalFromString(base.value), - 6, + RETURN_PERCENT_DECIMAL_PLACES, ), ); } 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 a69d169..2b0db17 100644 --- a/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts +++ b/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts @@ -19,6 +19,8 @@ import { BondDto, BondSearchResultDto } from '../presentation/bond.dto'; type IdentifiedBond = TbankBond & { uid: string }; import { money } from '../infrastructure/bond-money.mapper'; + +const SEARCH_RESULTS_LIMIT = 30; @Injectable() export class BondCatalogService { private readonly cache = new LRUCache>({ @@ -33,7 +35,7 @@ export class BondCatalogService { return this.cached(userId, `search:${query}`, async (connection) => { const { token } = connection; const found = await this.tbank.findBonds(token, query); - const bonds = selectBonds(found, query).slice(0, 30); + const bonds = selectBonds(found, query).slice(0, SEARCH_RESULTS_LIMIT); if (!bonds.length) return []; // Batch prices and share metadata with detail sections. Resolve readable // aliases only when an issue is opened, not for every search result. 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 5033a14..beafae8 100644 --- a/src/modules/investments/instruments/bonds/application/bond-income.service.ts +++ b/src/modules/investments/instruments/bonds/application/bond-income.service.ts @@ -27,6 +27,10 @@ import { import { BondMarketService } from './bond-market.service'; import { money } from '../infrastructure/bond-money.mapper'; import { BondCatalogService } from './bond-catalog.service'; + +const ISO_YEAR_LENGTH = 4; +const INCOME_HISTORY_YEARS = 5; +const COUPON_YIELD_DECIMAL_PLACES = 4; @Injectable() export class BondIncomeService { constructor( @@ -42,8 +46,8 @@ export class BondIncomeService { const instrument = await this.catalog.bond(userId, connection, uid); const { ticker, isin, classCode } = instrument; const today = moscowDay(new Date().toISOString()); - const from = `${Number(today.slice(0, 4)) - 5}${today.slice(4)}`; - const to = `${Number(today.slice(0, 4)) + 1}${today.slice(4)}`; + 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 unavailable: string[] = []; const [events, quote, effectiveYield, creditRating] = await Promise.all([ optional( @@ -94,7 +98,7 @@ export class BondIncomeService { decimalFromString(String(couponPeriodDays)), decimalFromString(clean.value), ), - 4, + COUPON_YIELD_DECIMAL_PLACES, ), ) : null; @@ -137,8 +141,8 @@ export class BondIncomeService { return this.catalog.cached(userId, `coupons:${uid}`, async (connection) => { await this.catalog.bond(userId, connection, uid); const today = moscowDay(new Date().toISOString()); - const from = `${Number(today.slice(0, 4)) - 5}${today.slice(4)}`; - const to = `${Number(today.slice(0, 4)) + 1}${today.slice(4)}`; + 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 unavailable: string[] = []; const [events, position] = await Promise.all([ this.couponEvents(userId, connection, uid, from, to), 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 30f8abd..db7a57b 100644 --- a/src/modules/investments/instruments/bonds/application/bond-market.service.ts +++ b/src/modules/investments/instruments/bonds/application/bond-market.service.ts @@ -24,6 +24,12 @@ const dayMs = 86_400_000; import { money } from '../infrastructure/bond-money.mapper'; import { BondCatalogService } from './bond-catalog.service'; + +const PREVIOUS_CLOSE_LOOKBACK_DAYS = 20; +const HALF_YEAR_MONTHS = 6; +const YEAR_MONTHS = 12; +const CANDLE_WINDOW_DAYS = 365; +const PRICE_DECIMAL_PLACES = 9; @Injectable() export class BondMarketService { constructor( @@ -66,7 +72,7 @@ export class BondMarketService { decimalFromString(nominal.value), ), decimalFromString('100'), - 9, + PRICE_DECIMAL_PLACES, ), ), } @@ -90,7 +96,9 @@ export class BondMarketService { await this.tbank.getCandles( token, uid, - new Date(Date.parse(priceAt) - 20 * dayMs), + new Date( + Date.parse(priceAt) - PREVIOUS_CLOSE_LOOKBACK_DAYS * dayMs, + ), new Date(priceAt), ), priceAt, @@ -134,7 +142,11 @@ export class BondMarketService { const from = new Date(to); from.setUTCMonth( from.getUTCMonth() - - (period === 'month' ? 1 : period === 'halfYear' ? 6 : 12), + (period === 'month' + ? 1 + : period === 'halfYear' + ? HALF_YEAR_MONTHS + : YEAR_MONTHS), ); if (period === 'all') { if (!historyFrom) @@ -147,7 +159,10 @@ export class BondMarketService { const collected = new Map(); // Conservative one-year windows also work with older deployed RPC limits. for (let start = from.valueOf(); start < to.valueOf();) { - const end = Math.min(start + 365 * dayMs, to.valueOf()); + const end = Math.min( + start + CANDLE_WINDOW_DAYS * dayMs, + to.valueOf(), + ); for (const candle of await this.tbank.getCandles( token, uid, 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 0455a76..a41aa84 100644 --- a/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts +++ b/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts @@ -15,6 +15,8 @@ import { decimal } from '../../../../../integrations/tbank/decimal'; import { optional, timestamp } from '../../application/instrument-market'; import { StockDto, StockSearchResultDto } from '../presentation/stock.dto'; +const SEARCH_RESULTS_LIMIT = 30; + type IdentifiedShare = TbankShare & { uid: string }; @Injectable() @@ -30,7 +32,7 @@ export class StockCatalogService { search(userId: string, query: string): Promise { return this.cached(userId, `search:${query}`, async (token) => { const found = await this.tbank.findShares(token, query); - const shares = selectShares(found, query).slice(0, 30); + const shares = selectShares(found, query).slice(0, SEARCH_RESULTS_LIMIT); if (!shares.length) return []; // One market-data request for the list; failed enrichment must not hide a catalog result. const [details, prices] = await Promise.all([ 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 8e65d93..a2432f0 100644 --- a/src/modules/investments/instruments/stocks/application/stock-income.service.ts +++ b/src/modules/investments/instruments/stocks/application/stock-income.service.ts @@ -20,6 +20,9 @@ import { } from '../presentation/stock.dto'; import { StockCatalogService } from './stock-catalog.service'; + +const ISO_YEAR_LENGTH = 4; +const INCOME_HISTORY_YEARS = 5; @Injectable() export class StockIncomeService { constructor( @@ -36,8 +39,8 @@ export class StockIncomeService { return this.catalog.cached(userId, `dividends:${uid}`, async (token) => { await this.catalog.share(token, uid); const today = moscowDay(new Date().toISOString()); - const from = `${Number(today.slice(0, 4)) - 5}${today.slice(4)}`; - const to = `${Number(today.slice(0, 4)) + 1}${today.slice(4)}`; + 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 unavailable: string[] = []; const [events, position] = await Promise.all([ this.tbank.getDividends(token, uid, from, to), 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 4dc80c9..3ea2464 100644 --- a/src/modules/investments/instruments/stocks/application/stock-market.service.ts +++ b/src/modules/investments/instruments/stocks/application/stock-market.service.ts @@ -22,6 +22,12 @@ import { const dayMs = 86_400_000; import { StockCatalogService } from './stock-catalog.service'; + +const PREVIOUS_CLOSE_LOOKBACK_DAYS = 20; +const HALF_YEAR_MONTHS = 6; +const YEAR_MONTHS = 12; +const CANDLE_WINDOW_DAYS = 365; +const CHANGE_PERCENT_DECIMAL_PLACES = 6; @Injectable() export class StockMarketService { constructor( @@ -58,7 +64,9 @@ export class StockMarketService { await this.tbank.getCandles( token, uid, - new Date(end.valueOf() - 20 * dayMs), + new Date( + end.valueOf() - PREVIOUS_CLOSE_LOOKBACK_DAYS * dayMs, + ), end, ), priceAt, @@ -97,7 +105,7 @@ export class StockMarketService { divide( multiply(difference, decimalFromString('100')), decimalFromString(previousPrice.value), - 6, + CHANGE_PERCENT_DECIMAL_PLACES, ), ) : null, @@ -121,7 +129,11 @@ export class StockMarketService { const from = new Date(to); from.setUTCMonth( from.getUTCMonth() - - (period === 'month' ? 1 : period === 'halfYear' ? 6 : 12), + (period === 'month' + ? 1 + : period === 'halfYear' + ? HALF_YEAR_MONTHS + : YEAR_MONTHS), ); if (period === 'all') { if (!historyFrom) @@ -134,7 +146,10 @@ export class StockMarketService { const collected = new Map(); // Conservative one-year windows also work with older deployed RPC limits. for (let start = from.valueOf(); start < to.valueOf();) { - const end = Math.min(start + 365 * dayMs, to.valueOf()); + const end = Math.min( + start + CANDLE_WINDOW_DAYS * dayMs, + to.valueOf(), + ); for (const candle of await this.tbank.getCandles( token, uid, diff --git a/src/modules/investments/instruments/stocks/presentation/stock.dto.ts b/src/modules/investments/instruments/stocks/presentation/stock.dto.ts index 4683041..405e255 100644 --- a/src/modules/investments/instruments/stocks/presentation/stock.dto.ts +++ b/src/modules/investments/instruments/stocks/presentation/stock.dto.ts @@ -11,25 +11,27 @@ import { import { MoneyDto } from '../../../../../common/money.dto'; import { PortfolioPositionDto } from '../../../portfolio/portfolio.api'; +const MAX_INSTRUMENT_QUERY_LENGTH = 80; + export class StockSearchQueryDto { - @ApiProperty({ minLength: 2, maxLength: 80 }) + @ApiProperty({ minLength: 2, maxLength: MAX_INSTRUMENT_QUERY_LENGTH }) @Transform(({ value }: { value: unknown }) => typeof value === 'string' ? value.trim() : value, ) @IsString() @MinLength(2) - @MaxLength(80) + @MaxLength(MAX_INSTRUMENT_QUERY_LENGTH) declare query: string; } export class StockTickerParamsDto { @ApiProperty({ minLength: 1, - maxLength: 80, + maxLength: MAX_INSTRUMENT_QUERY_LENGTH, pattern: '^[A-Za-z0-9][A-Za-z0-9._@+-]*$', }) @IsString() @MinLength(1) - @MaxLength(80) + @MaxLength(MAX_INSTRUMENT_QUERY_LENGTH) @Matches(/^[A-Za-z0-9][A-Za-z0-9._@+-]*$/) declare ticker: string; } diff --git a/src/modules/investments/investment-calendar.ts b/src/modules/investments/investment-calendar.ts index e7ff410..051bfbd 100644 --- a/src/modules/investments/investment-calendar.ts +++ b/src/modules/investments/investment-calendar.ts @@ -1,3 +1,10 @@ +const MOSCOW_UTC_OFFSET_HOURS = 3; +const MOSCOW_DAY_END_UTC = { + hours: 20, + minutes: 59, + seconds: 59, + milliseconds: 999, +}; export type DateOnlyRange = { from: string; to: string }; const moscow = 'Europe/Moscow'; export function moscowDate(value: Date): string { @@ -14,9 +21,21 @@ export function moscowDate(value: Date): string { export function dateOnlyBounds(range: DateOnlyRange): { from: Date; to: Date } { const from = dateParts(range.from); const end = dateParts(range.to); - const to = new Date(Date.UTC(end[0], end[1] - 1, end[2], 20, 59, 59, 999)); + const to = new Date( + Date.UTC( + end[0], + end[1] - 1, + end[2], + MOSCOW_DAY_END_UTC.hours, + MOSCOW_DAY_END_UTC.minutes, + MOSCOW_DAY_END_UTC.seconds, + MOSCOW_DAY_END_UTC.milliseconds, + ), + ); return { - from: new Date(Date.UTC(from[0], from[1] - 1, from[2], -3)), + from: new Date( + Date.UTC(from[0], from[1] - 1, from[2], -MOSCOW_UTC_OFFSET_HOURS), + ), to: to > new Date() ? new Date() : to, }; } diff --git a/src/modules/investments/operations/application/brokerage-operations.service.ts b/src/modules/investments/operations/application/brokerage-operations.service.ts index 73f0ce7..8b4270a 100644 --- a/src/modules/investments/operations/application/brokerage-operations.service.ts +++ b/src/modules/investments/operations/application/brokerage-operations.service.ts @@ -1,3 +1,4 @@ +import { MILLISECONDS_PER_SECOND } from '../../../../common/time'; import { BadRequestException, Injectable, @@ -15,6 +16,7 @@ import { AccountReferenceDto } from '../../account-reference.dto'; import { DateOnlyRange, dateOnlyBounds } from '../../investment-calendar'; import { BrokerageOperationDto, + DEFAULT_OPERATIONS_PAGE_SIZE, BrokerageOperationsPageDto, BrokerageOperationsQueryDto, } from '../presentation/brokerage-operation.dto'; @@ -23,6 +25,8 @@ import { operationCategory, } from '../infrastructure/brokerage-operation.mapper'; +const CONTINUATION_TTL_MS = 300_000; + type OperationStream = { account: AccountReferenceDto; pending: BrokerageOperationDto[]; @@ -54,7 +58,7 @@ export class BrokerageOperationsService { AllOperationsContinuation >({ max: 100, - ttl: 5 * 60_000, + ttl: CONTINUATION_TTL_MS, ttlAutopurge: true, }); @@ -86,7 +90,10 @@ export class BrokerageOperationsService { query.instrumentUid && !query.from ? { ...range, - from: new Date(Number(account.openedDate?.seconds ?? 0) * 1000), + from: new Date( + Number(account.openedDate?.seconds ?? 0) * + MILLISECONDS_PER_SECOND, + ), } : range, ), @@ -108,7 +115,7 @@ export class BrokerageOperationsService { from: query.from ?? null, to: query.to ?? null, categories: [...new Set(query.categories ?? [])].sort(), - limit: query.limit ?? 100, + limit: query.limit ?? DEFAULT_OPERATIONS_PAGE_SIZE, }); const continuation = query.cursor ? this.continuations.get(query.cursor) @@ -178,7 +185,7 @@ export class BrokerageOperationsService { ): Promise { const items: BrokerageOperationDto[] = []; // Each stream retains unconsumed rows. A page boundary never discards them. - while (items.length < (query.limit ?? 100)) { + while (items.length < (query.limit ?? DEFAULT_OPERATIONS_PAGE_SIZE)) { for (const stream of streams) { while (!stream.pending.length && !stream.finished) { const page = await this.page( @@ -324,7 +331,9 @@ export class BrokerageOperationsService { accountId, async ({ token, generation, account }) => { const accountOpenedAt = account.openedDate?.seconds - ? new Date(Number(account.openedDate.seconds) * 1000) + ? new Date( + Number(account.openedDate.seconds) * MILLISECONDS_PER_SECOND, + ) : undefined; const selected = typeof ranges === 'function' ? ranges(accountOpenedAt) : ranges; @@ -395,7 +404,7 @@ export class BrokerageOperationsService { from: seconds(range.from), to: seconds(range.to, /^\d{4}-\d{2}-\d{2}$/.test(query.to ?? '')), cursor: query.cursor, - limit: query.limit ?? 100, + limit: query.limit ?? DEFAULT_OPERATIONS_PAGE_SIZE, state: 'OPERATION_STATE_EXECUTED', ...(query.instrumentUid ? { instrumentId: query.instrumentUid } : {}), }); @@ -480,7 +489,7 @@ function cacheKey( categories, instrumentUid: query.instrumentUid ?? null, cursor: query.cursor ?? null, - limit: query.limit ?? 100, + limit: query.limit ?? DEFAULT_OPERATIONS_PAGE_SIZE, }); } @@ -493,7 +502,7 @@ function dateBoundary(value: string | undefined, end: boolean): Date | null { function seconds(value: Date, endOfSecond = false) { return { - seconds: Math.floor(value.valueOf() / 1000).toString(), + seconds: Math.floor(value.valueOf() / MILLISECONDS_PER_SECOND).toString(), ...(endOfSecond ? { nanos: 999_999_999 } : {}), }; } diff --git a/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts b/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts index d2f9911..35fc796 100644 --- a/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts +++ b/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts @@ -1,3 +1,4 @@ +import { MILLISECONDS_PER_SECOND } from '../../../../common/time'; import { formatDecimal } from '../../../../common/decimal'; import { TbankMoney, @@ -84,5 +85,7 @@ function money(value: TbankMoney | undefined) { function timestamp( value: { seconds?: string | number; nanos?: number } | undefined, ) { - return new Date(Number(value?.seconds ?? 0) * 1000).toISOString(); + return new Date( + Number(value?.seconds ?? 0) * MILLISECONDS_PER_SECOND, + ).toISOString(); } diff --git a/src/modules/investments/operations/presentation/brokerage-operation.dto.ts b/src/modules/investments/operations/presentation/brokerage-operation.dto.ts index 927f68e..c9756a2 100644 --- a/src/modules/investments/operations/presentation/brokerage-operation.dto.ts +++ b/src/modules/investments/operations/presentation/brokerage-operation.dto.ts @@ -16,6 +16,10 @@ import { MoneyDto } from '../../../../common/money.dto'; import { AccountReferenceDto } from '../../account-reference.dto'; +const MAX_CURSOR_LENGTH = 4096; +const MAX_OPERATIONS_PAGE_SIZE = 1000; +export const DEFAULT_OPERATIONS_PAGE_SIZE = 100; + export const operationCategories = [ 'trade', 'income', @@ -58,15 +62,19 @@ export class BrokerageOperationsQueryDto { @ApiPropertyOptional() @IsOptional() @IsString() - @MaxLength(4096) + @MaxLength(MAX_CURSOR_LENGTH) cursor?: string; - @ApiPropertyOptional({ minimum: 1, maximum: 1000, default: 100 }) + @ApiPropertyOptional({ + minimum: 1, + maximum: MAX_OPERATIONS_PAGE_SIZE, + default: DEFAULT_OPERATIONS_PAGE_SIZE, + }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) - @Max(1000) + @Max(MAX_OPERATIONS_PAGE_SIZE) limit?: number; } diff --git a/src/modules/investments/portfolio/domain/portfolio-structure.ts b/src/modules/investments/portfolio/domain/portfolio-structure.ts index d60f518..514f167 100644 --- a/src/modules/investments/portfolio/domain/portfolio-structure.ts +++ b/src/modules/investments/portfolio/domain/portfolio-structure.ts @@ -6,6 +6,9 @@ import { import type { Money } from '../../../../common/money.types'; +const SHARE_PERCENT_DECIMAL_PLACES = 6; +const PERCENT_SCALED_FACTOR = 100_000_000n; + const categoryTypes = ['STOCK', 'BOND', 'OTHER', 'CASH'] as const; // Build from positions and cash, never from totals (which already include cash). @@ -59,10 +62,10 @@ export function portfolioStructure( value: (value.value * 10n ** BigInt(total.scale - value.scale) * - 100_000_000n + + PERCENT_SCALED_FACTOR + total.value / 2n) / total.value, - scale: 6, + scale: SHARE_PERCENT_DECIMAL_PLACES, }) : null, })); diff --git a/src/modules/investments/tbank-connection/tbank-connection.controller.ts b/src/modules/investments/tbank-connection/tbank-connection.controller.ts index 9247d1a..4e7943b 100644 --- a/src/modules/investments/tbank-connection/tbank-connection.controller.ts +++ b/src/modules/investments/tbank-connection/tbank-connection.controller.ts @@ -4,6 +4,7 @@ import { Delete, Get, HttpCode, + HttpStatus, Put, Req, UseGuards, @@ -47,7 +48,7 @@ export class TbankConnectionController { } @Delete() - @HttpCode(204) + @HttpCode(HttpStatus.NO_CONTENT) @ApiNoContentResponse() delete(@Req() request: Request & { user: AccessPayload }): Promise { return this.connection.delete(request.user.sub); diff --git a/src/modules/investments/tbank-connection/tbank-connection.dto.ts b/src/modules/investments/tbank-connection/tbank-connection.dto.ts index 32a8aae..fd0dcb8 100644 --- a/src/modules/investments/tbank-connection/tbank-connection.dto.ts +++ b/src/modules/investments/tbank-connection/tbank-connection.dto.ts @@ -1,11 +1,13 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; +const MAX_TOKEN_LENGTH = 4096; + export class PutTbankConnectionDto { @ApiProperty({ writeOnly: true }) @IsString() @IsNotEmpty() - @MaxLength(4096) + @MaxLength(MAX_TOKEN_LENGTH) declare token: string; } diff --git a/src/modules/investments/upcoming-events/upcoming-events.service.ts b/src/modules/investments/upcoming-events/upcoming-events.service.ts index 530d3e8..d169990 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.service.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.service.ts @@ -1,3 +1,4 @@ +import { MILLISECONDS_PER_SECOND } from '../../../common/time'; import { BadRequestException, Injectable } from '@nestjs/common'; import { add, @@ -25,12 +26,14 @@ import { UpcomingEventsQueryDto, } from './upcoming-events.dto'; +const DEFAULT_EVENT_WINDOW_DAYS = 30; + type Position = NonNullable[number]; type CachedEntry = { expiresAt: number; promise: Promise }; const positionsBatchSize = 5; -const dividendCacheTtlMs = 15 * 60_000; -const instrumentCacheTtlMs = 24 * 60 * 60_000; +const dividendCacheTtlMs = 900_000; +const instrumentCacheTtlMs = 86_400_000; @Injectable() export class UpcomingEventsService { @@ -242,7 +245,9 @@ export class UpcomingEventsService { return { ...metadata, type: 'dividend', - date: new Date(Number(event.recordDate?.seconds) * 1000) + date: new Date( + Number(event.recordDate?.seconds) * MILLISECONDS_PER_SECOND, + ) .toISOString() .slice(0, 10), dateKind: 'registry', @@ -287,7 +292,7 @@ function supported(position: Position): boolean { function dateWindow(query: UpcomingEventsQueryDto) { const from = query.from ?? new Date().toISOString().slice(0, 10); const end = new Date(`${from}T00:00:00.000Z`); - end.setUTCDate(end.getUTCDate() + 30); + end.setUTCDate(end.getUTCDate() + DEFAULT_EVENT_WINDOW_DAYS); const to = query.to ?? end.toISOString().slice(0, 10); if (from > to) throw new BadRequestException({ code: 'INVALID_EVENTS_PERIOD' });